From b6212813513d329b139f0e06bf8610421889a66e Mon Sep 17 00:00:00 2001 From: Abhinav Valecha Date: Wed, 2 Apr 2025 21:07:26 +0530 Subject: [PATCH 01/52] feat(server): Avoid face match with people born after file creation #4743 (#16918) * feat(server): Avoid face matching with people born after file creation date (#4743) * lint * add medium tests for facial recognition --------- Co-authored-by: Alex --- server/src/queries/search.repository.sql | 1 + server/src/repositories/search.repository.ts | 7 +- server/src/services/person.service.spec.ts | 60 ++++++ server/src/services/person.service.ts | 2 + server/test/factory.ts | 96 ++++++++- server/test/fixtures/face.stub.ts | 15 ++ .../test/medium/specs/person.service.spec.ts | 201 ++++++++++++++++++ .../repositories/person.repository.mock.ts | 36 ++++ server/test/small.factory.ts | 6 + server/test/utils.ts | 3 +- 10 files changed, 422 insertions(+), 5 deletions(-) create mode 100644 server/test/medium/specs/person.service.spec.ts create mode 100644 server/test/repositories/person.repository.mock.ts diff --git a/server/src/queries/search.repository.sql b/server/src/queries/search.repository.sql index 06590dc817..73f276a7fb 100644 --- a/server/src/queries/search.repository.sql +++ b/server/src/queries/search.repository.sql @@ -136,6 +136,7 @@ with "asset_faces" inner join "assets" on "assets"."id" = "asset_faces"."assetId" inner join "face_search" on "face_search"."faceId" = "asset_faces"."id" + left join "person" on "person"."id" = "asset_faces"."personId" where "assets"."ownerId" = any ($2::uuid[]) and "assets"."deletedAt" is null diff --git a/server/src/repositories/search.repository.ts b/server/src/repositories/search.repository.ts index e2e389f47c..954ab0fe5a 100644 --- a/server/src/repositories/search.repository.ts +++ b/server/src/repositories/search.repository.ts @@ -163,6 +163,7 @@ export interface FaceEmbeddingSearch extends SearchEmbeddingOptions { hasPerson?: boolean; numResults: number; maxDistance: number; + minBirthDate?: Date; } export interface AssetDuplicateSearch { @@ -338,7 +339,7 @@ export class SearchRepository { }, ], }) - searchFaces({ userIds, embedding, numResults, maxDistance, hasPerson }: FaceEmbeddingSearch) { + searchFaces({ userIds, embedding, numResults, maxDistance, hasPerson, minBirthDate }: FaceEmbeddingSearch) { if (!isValidInteger(numResults, { min: 1, max: 1000 })) { throw new Error(`Invalid value for 'numResults': ${numResults}`); } @@ -354,9 +355,13 @@ export class SearchRepository { ]) .innerJoin('assets', 'assets.id', 'asset_faces.assetId') .innerJoin('face_search', 'face_search.faceId', 'asset_faces.id') + .leftJoin('person', 'person.id', 'asset_faces.personId') .where('assets.ownerId', '=', anyUuid(userIds)) .where('assets.deletedAt', 'is', null) .$if(!!hasPerson, (qb) => qb.where('asset_faces.personId', 'is not', null)) + .$if(!!minBirthDate, (qb) => + qb.where((eb) => eb.or([eb('person.birthDate', 'is', null), eb('person.birthDate', '<=', minBirthDate!)])), + ) .orderBy(sql`face_search.embedding <=> ${embedding}`) .limit(numResults), ) diff --git a/server/src/services/person.service.spec.ts b/server/src/services/person.service.spec.ts index 073cf71247..1d8cdfd3b9 100644 --- a/server/src/services/person.service.spec.ts +++ b/server/src/services/person.service.spec.ts @@ -896,6 +896,66 @@ describe(PersonService.name, () => { }); }); + it('should match existing person if their birth date is unknown', async () => { + if (!faceStub.primaryFace1.person) { + throw new Error('faceStub.primaryFace1.person is null'); + } + + const faces = [ + { ...faceStub.noPerson1, distance: 0 }, + { ...faceStub.primaryFace1, distance: 0.2 }, + { ...faceStub.withBirthDate, distance: 0.3 }, + ] as FaceSearchResult[]; + + mocks.systemMetadata.get.mockResolvedValue({ machineLearning: { facialRecognition: { minFaces: 1 } } }); + mocks.search.searchFaces.mockResolvedValue(faces); + mocks.person.getFaceByIdWithAssets.mockResolvedValue(faceStub.noPerson1); + mocks.person.create.mockResolvedValue(faceStub.primaryFace1.person); + + await sut.handleRecognizeFaces({ id: faceStub.noPerson1.id }); + + expect(mocks.person.create).not.toHaveBeenCalled(); + expect(mocks.person.reassignFaces).toHaveBeenCalledTimes(1); + expect(mocks.person.reassignFaces).toHaveBeenCalledWith({ + faceIds: expect.arrayContaining([faceStub.noPerson1.id]), + newPersonId: faceStub.primaryFace1.person.id, + }); + expect(mocks.person.reassignFaces).toHaveBeenCalledWith({ + faceIds: expect.not.arrayContaining([faceStub.face1.id]), + newPersonId: faceStub.primaryFace1.person.id, + }); + }); + + it('should match existing person if their birth date is before file creation', async () => { + if (!faceStub.primaryFace1.person) { + throw new Error('faceStub.primaryFace1.person is null'); + } + + const faces = [ + { ...faceStub.noPerson1, distance: 0 }, + { ...faceStub.withBirthDate, distance: 0.2 }, + { ...faceStub.primaryFace1, distance: 0.3 }, + ] as FaceSearchResult[]; + + mocks.systemMetadata.get.mockResolvedValue({ machineLearning: { facialRecognition: { minFaces: 1 } } }); + mocks.search.searchFaces.mockResolvedValue(faces); + mocks.person.getFaceByIdWithAssets.mockResolvedValue(faceStub.noPerson1); + mocks.person.create.mockResolvedValue(faceStub.primaryFace1.person); + + await sut.handleRecognizeFaces({ id: faceStub.noPerson1.id }); + + expect(mocks.person.create).not.toHaveBeenCalled(); + expect(mocks.person.reassignFaces).toHaveBeenCalledTimes(1); + expect(mocks.person.reassignFaces).toHaveBeenCalledWith({ + faceIds: expect.arrayContaining([faceStub.noPerson1.id]), + newPersonId: faceStub.withBirthDate.person?.id, + }); + expect(mocks.person.reassignFaces).toHaveBeenCalledWith({ + faceIds: expect.not.arrayContaining([faceStub.face1.id]), + newPersonId: faceStub.withBirthDate.person?.id, + }); + }); + it('should create a new person if the face is a core point with no person', async () => { const faces = [ { ...faceStub.noPerson1, distance: 0 }, diff --git a/server/src/services/person.service.ts b/server/src/services/person.service.ts index b34b0ddcff..ec412ad307 100644 --- a/server/src/services/person.service.ts +++ b/server/src/services/person.service.ts @@ -483,6 +483,7 @@ export class PersonService extends BaseService { embedding: face.faceSearch.embedding, maxDistance: machineLearning.facialRecognition.maxDistance, numResults: machineLearning.facialRecognition.minFaces, + minBirthDate: face.asset.fileCreatedAt, }); // `matches` also includes the face itself @@ -508,6 +509,7 @@ export class PersonService extends BaseService { maxDistance: machineLearning.facialRecognition.maxDistance, numResults: 1, hasPerson: true, + minBirthDate: face.asset.fileCreatedAt, }); if (matchWithPerson.length > 0) { diff --git a/server/test/factory.ts b/server/test/factory.ts index 028b530255..faca12d068 100644 --- a/server/test/factory.ts +++ b/server/test/factory.ts @@ -1,9 +1,9 @@ import { Insertable, Kysely } from 'kysely'; import { randomBytes } from 'node:crypto'; import { Writable } from 'node:stream'; -import { Assets, DB, Partners, Sessions } from 'src/db'; +import { AssetFaces, Assets, DB, Person as DbPerson, FaceSearch, Partners, Sessions } from 'src/db'; import { AuthDto } from 'src/dtos/auth.dto'; -import { AssetType } from 'src/enum'; +import { AssetType, SourceType } from 'src/enum'; import { AccessRepository } from 'src/repositories/access.repository'; import { ActivityRepository } from 'src/repositories/activity.repository'; import { AlbumRepository } from 'src/repositories/album.repository'; @@ -37,7 +37,7 @@ import { VersionHistoryRepository } from 'src/repositories/version-history.repos import { ViewRepository } from 'src/repositories/view-repository'; import { UserTable } from 'src/schema/tables/user.table'; import { newTelemetryRepositoryMock } from 'test/repositories/telemetry.repository.mock'; -import { newUuid } from 'test/small.factory'; +import { newDate, newEmbedding, newUuid } from 'test/small.factory'; import { automock } from 'test/utils'; class CustomWritable extends Writable { @@ -61,12 +61,18 @@ type Asset = Partial>; type User = Partial>; type Session = Omit, 'token'> & { token?: string }; type Partner = Insertable; +type AssetFace = Partial>; +type Person = Partial>; +type Face = Partial>; export class TestFactory { private assets: Asset[] = []; private sessions: Session[] = []; private users: User[] = []; private partners: Partner[] = []; + private assetFaces: AssetFace[] = []; + private persons: Person[] = []; + private faces: Face[] = []; private constructor(private context: TestContext) {} @@ -141,6 +147,53 @@ export class TestFactory { }; } + static assetFace(assetFace: AssetFace) { + const defaults = { + assetId: assetFace.assetId || newUuid(), + boundingBoxX1: assetFace.boundingBoxX1 || 0, + boundingBoxX2: assetFace.boundingBoxX2 || 1, + boundingBoxY1: assetFace.boundingBoxY1 || 0, + boundingBoxY2: assetFace.boundingBoxY2 || 1, + deletedAt: assetFace.deletedAt || null, + id: assetFace.id || newUuid(), + imageHeight: assetFace.imageHeight || 10, + imageWidth: assetFace.imageWidth || 10, + personId: assetFace.personId || null, + sourceType: assetFace.sourceType || SourceType.MACHINE_LEARNING, + }; + + return { ...defaults, ...assetFace }; + } + + static person(person: Person) { + const defaults = { + birthDate: person.birthDate || null, + color: person.color || null, + createdAt: person.createdAt || newDate(), + faceAssetId: person.faceAssetId || null, + id: person.id || newUuid(), + isFavorite: person.isFavorite || false, + isHidden: person.isHidden || false, + name: person.name || 'Test Name', + ownerId: person.ownerId || newUuid(), + thumbnailPath: person.thumbnailPath || '/path/to/thumbnail.jpg', + updatedAt: person.updatedAt || newDate(), + updateId: person.updateId || newUuid(), + }; + return { ...defaults, ...person }; + } + + static face(face: Face) { + const defaults = { + faceId: face.faceId || newUuid(), + embedding: face.embedding || newEmbedding(), + }; + return { + ...defaults, + ...face, + }; + } + withAsset(asset: Asset) { this.assets.push(asset); return this; @@ -161,6 +214,21 @@ export class TestFactory { return this; } + withAssetFace(assetFace: AssetFace) { + this.assetFaces.push(assetFace); + return this; + } + + withPerson(person: Person) { + this.persons.push(person); + return this; + } + + withFaces(face: Face) { + this.faces.push(face); + return this; + } + async create() { for (const user of this.users) { await this.context.createUser(user); @@ -178,6 +246,16 @@ export class TestFactory { await this.context.createAsset(asset); } + for (const person of this.persons) { + await this.context.createPerson(person); + } + + await this.context.refreshFaces( + this.assetFaces, + [], + this.faces.map((f) => TestFactory.face(f)), + ); + return this.context; } } @@ -276,4 +354,16 @@ export class TestContext { createSession(session: Session) { return this.session.create(TestFactory.session(session)); } + + createPerson(person: Person) { + return this.person.create(TestFactory.person(person)); + } + + refreshFaces(facesToAdd: AssetFace[], faceIdsToRemove: string[], embeddingsToAdd?: Insertable[]) { + return this.person.refreshFaces( + facesToAdd.map((f) => TestFactory.assetFace(f)), + faceIdsToRemove, + embeddingsToAdd, + ); + } } diff --git a/server/test/fixtures/face.stub.ts b/server/test/fixtures/face.stub.ts index 74a59a85a8..37fab86962 100644 --- a/server/test/fixtures/face.stub.ts +++ b/server/test/fixtures/face.stub.ts @@ -164,4 +164,19 @@ export const faceStub = { sourceType: SourceType.EXIF, deletedAt: null, }), + withBirthDate: Object.freeze({ + id: 'assetFaceId10', + assetId: assetStub.image.id, + asset: assetStub.image, + personId: personStub.withBirthDate.id, + person: personStub.withBirthDate, + boundingBoxX1: 0, + boundingBoxY1: 0, + boundingBoxX2: 1, + boundingBoxY2: 1, + imageHeight: 1024, + imageWidth: 1024, + sourceType: SourceType.MACHINE_LEARNING, + deletedAt: null, + }), }; diff --git a/server/test/medium/specs/person.service.spec.ts b/server/test/medium/specs/person.service.spec.ts new file mode 100644 index 0000000000..e79564b437 --- /dev/null +++ b/server/test/medium/specs/person.service.spec.ts @@ -0,0 +1,201 @@ +import { Kysely } from 'kysely'; +import { JobStatus, SourceType } from 'src/enum'; +import { PersonService } from 'src/services/person.service'; +import { TestContext, TestFactory } from 'test/factory'; +import { newEmbedding } from 'test/small.factory'; +import { getKyselyDB, newTestService } from 'test/utils'; + +const setup = async (db: Kysely) => { + const context = await TestContext.from(db).create(); + const { sut, mocks } = newTestService(PersonService, context); + + return { sut, mocks, context }; +}; + +describe.concurrent(PersonService.name, () => { + let sut: PersonService; + let context: TestContext; + + beforeAll(async () => { + ({ sut, context } = await setup(await getKyselyDB())); + }); + + describe('handleRecognizeFaces', () => { + it('should skip if face source type is not MACHINE_LEARNING', async () => { + const user = TestFactory.user(); + const asset = TestFactory.asset({ ownerId: user.id }); + const assetFace = TestFactory.assetFace({ assetId: asset.id, sourceType: SourceType.MANUAL }); + const face = TestFactory.face({ faceId: assetFace.id }); + await context.getFactory().withUser(user).withAsset(asset).withAssetFace(assetFace).withFaces(face).create(); + + const result = await sut.handleRecognizeFaces({ id: assetFace.id, deferred: false }); + + expect(result).toBe(JobStatus.SKIPPED); + const newPersonId = await context.db + .selectFrom('asset_faces') + .select('asset_faces.personId') + .where('asset_faces.id', '=', assetFace.id) + .executeTakeFirst(); + expect(newPersonId?.personId).toBeNull(); + }); + + it('should fail if face does not have an embedding', async () => { + const user = TestFactory.user(); + const asset = TestFactory.asset({ ownerId: user.id }); + const assetFace = TestFactory.assetFace({ assetId: asset.id, sourceType: SourceType.MACHINE_LEARNING }); + await context.getFactory().withUser(user).withAsset(asset).withAssetFace(assetFace).create(); + + const result = await sut.handleRecognizeFaces({ id: assetFace.id, deferred: false }); + + expect(result).toBe(JobStatus.FAILED); + const newPersonId = await context.db + .selectFrom('asset_faces') + .select('asset_faces.personId') + .where('asset_faces.id', '=', assetFace.id) + .executeTakeFirst(); + expect(newPersonId?.personId).toBeNull(); + }); + + it('should skip if face already has a person assigned', async () => { + const user = TestFactory.user(); + const asset = TestFactory.asset({ ownerId: user.id }); + const person = TestFactory.person({ ownerId: user.id }); + const assetFace = TestFactory.assetFace({ + assetId: asset.id, + sourceType: SourceType.MACHINE_LEARNING, + personId: person.id, + }); + const face = TestFactory.face({ faceId: assetFace.id }); + await context + .getFactory() + .withUser(user) + .withAsset(asset) + .withPerson(person) + .withAssetFace(assetFace) + .withFaces(face) + .create(); + + const result = await sut.handleRecognizeFaces({ id: assetFace.id, deferred: false }); + + expect(result).toBe(JobStatus.SKIPPED); + const newPersonId = await context.db + .selectFrom('asset_faces') + .select('asset_faces.personId') + .where('asset_faces.id', '=', assetFace.id) + .executeTakeFirst(); + expect(newPersonId?.personId).toEqual(person.id); + }); + + it('should create a new person if no matches are found', async () => { + const user = TestFactory.user(); + const embedding = newEmbedding(); + + let factory = context.getFactory().withUser(user); + + for (let i = 0; i < 3; i++) { + const existingAsset = TestFactory.asset({ ownerId: user.id }); + const existingAssetFace = TestFactory.assetFace({ + assetId: existingAsset.id, + sourceType: SourceType.MACHINE_LEARNING, + }); + const existingFace = TestFactory.face({ faceId: existingAssetFace.id, embedding }); + factory = factory.withAsset(existingAsset).withAssetFace(existingAssetFace).withFaces(existingFace); + } + + const newAsset = TestFactory.asset({ ownerId: user.id }); + const newAssetFace = TestFactory.assetFace({ assetId: newAsset.id, sourceType: SourceType.MACHINE_LEARNING }); + const newFace = TestFactory.face({ faceId: newAssetFace.id, embedding }); + + await factory.withAsset(newAsset).withAssetFace(newAssetFace).withFaces(newFace).create(); + + const result = await sut.handleRecognizeFaces({ id: newAssetFace.id, deferred: false }); + + expect(result).toBe(JobStatus.SUCCESS); + + const newPersonId = await context.db + .selectFrom('asset_faces') + .select('asset_faces.personId') + .where('asset_faces.id', '=', newAssetFace.id) + .executeTakeFirstOrThrow(); + expect(newPersonId.personId).toBeDefined(); + }); + + it('should assign face to an existing person if matches are found', async () => { + const user = TestFactory.user(); + const existingPerson = TestFactory.person({ ownerId: user.id }); + const embedding = newEmbedding(); + + let factory = context.getFactory().withUser(user).withPerson(existingPerson); + + const assetFaces: string[] = []; + + for (let i = 0; i < 3; i++) { + const existingAsset = TestFactory.asset({ ownerId: user.id }); + const existingAssetFace = TestFactory.assetFace({ + assetId: existingAsset.id, + sourceType: SourceType.MACHINE_LEARNING, + }); + assetFaces.push(existingAssetFace.id); + const existingFace = TestFactory.face({ faceId: existingAssetFace.id, embedding }); + factory = factory.withAsset(existingAsset).withAssetFace(existingAssetFace).withFaces(existingFace); + } + + const newAsset = TestFactory.asset({ ownerId: user.id }); + const newAssetFace = TestFactory.assetFace({ assetId: newAsset.id, sourceType: SourceType.MACHINE_LEARNING }); + const newFace = TestFactory.face({ faceId: newAssetFace.id, embedding }); + await factory.withAsset(newAsset).withAssetFace(newAssetFace).withFaces(newFace).create(); + await context.person.reassignFaces({ newPersonId: existingPerson.id, faceIds: assetFaces }); + + const result = await sut.handleRecognizeFaces({ id: newAssetFace.id, deferred: false }); + + expect(result).toBe(JobStatus.SUCCESS); + + const after = await context.db + .selectFrom('asset_faces') + .select('asset_faces.personId') + .where('asset_faces.id', '=', newAssetFace.id) + .executeTakeFirstOrThrow(); + expect(after.personId).toEqual(existingPerson.id); + }); + + it('should not assign face to an existing person if asset is older than person', async () => { + const user = TestFactory.user(); + const assetCreatedAt = new Date('2020-02-23T05:06:29.716Z'); + const birthDate = new Date(assetCreatedAt.getTime() + 3600 * 1000 * 365); + const existingPerson = TestFactory.person({ ownerId: user.id, birthDate }); + const embedding = newEmbedding(); + + let factory = context.getFactory().withUser(user).withPerson(existingPerson); + + const assetFaces: string[] = []; + + for (let i = 0; i < 3; i++) { + const existingAsset = TestFactory.asset({ ownerId: user.id }); + const existingAssetFace = TestFactory.assetFace({ + assetId: existingAsset.id, + sourceType: SourceType.MACHINE_LEARNING, + }); + assetFaces.push(existingAssetFace.id); + const existingFace = TestFactory.face({ faceId: existingAssetFace.id, embedding }); + factory = factory.withAsset(existingAsset).withAssetFace(existingAssetFace).withFaces(existingFace); + } + + const newAsset = TestFactory.asset({ ownerId: user.id, fileCreatedAt: assetCreatedAt }); + const newAssetFace = TestFactory.assetFace({ assetId: newAsset.id, sourceType: SourceType.MACHINE_LEARNING }); + const newFace = TestFactory.face({ faceId: newAssetFace.id, embedding }); + await factory.withAsset(newAsset).withAssetFace(newAssetFace).withFaces(newFace).create(); + await context.person.reassignFaces({ newPersonId: existingPerson.id, faceIds: assetFaces }); + + const result = await sut.handleRecognizeFaces({ id: newAssetFace.id, deferred: false }); + + expect(result).toBe(JobStatus.SKIPPED); + + const after = await context.db + .selectFrom('asset_faces') + .select('asset_faces.personId') + .where('asset_faces.id', '=', newAssetFace.id) + .executeTakeFirstOrThrow(); + expect(after.personId).toBeNull(); + }); + }); +}); diff --git a/server/test/repositories/person.repository.mock.ts b/server/test/repositories/person.repository.mock.ts new file mode 100644 index 0000000000..80a6a25c74 --- /dev/null +++ b/server/test/repositories/person.repository.mock.ts @@ -0,0 +1,36 @@ +import { PersonRepository } from 'src/repositories/person.repository'; +import { RepositoryInterface } from 'src/types'; +import { Mocked, vitest } from 'vitest'; + +export const newPersonRepositoryMock = (): Mocked> => { + return { + reassignFaces: vitest.fn(), + unassignFaces: vitest.fn(), + delete: vitest.fn(), + deleteFaces: vitest.fn(), + getAllFaces: vitest.fn(), + getAll: vitest.fn(), + getAllForUser: vitest.fn(), + getAllWithoutFaces: vitest.fn(), + getFaces: vitest.fn(), + getFaceById: vitest.fn(), + getFaceByIdWithAssets: vitest.fn(), + reassignFace: vitest.fn(), + getById: vitest.fn(), + getByName: vitest.fn(), + getDistinctNames: vitest.fn(), + getStatistics: vitest.fn(), + getNumberOfPeople: vitest.fn(), + create: vitest.fn(), + createAll: vitest.fn(), + refreshFaces: vitest.fn(), + update: vitest.fn(), + updateAll: vitest.fn(), + getFacesByIds: vitest.fn(), + getRandomFace: vitest.fn(), + getLatestFaceDate: vitest.fn(), + createAssetFace: vitest.fn(), + deleteAssetFace: vitest.fn(), + softDeleteAssetFaces: vitest.fn(), + }; +}; diff --git a/server/test/small.factory.ts b/server/test/small.factory.ts index 0f6d059b6a..70ec6e5495 100644 --- a/server/test/small.factory.ts +++ b/server/test/small.factory.ts @@ -12,6 +12,12 @@ export const newUuids = () => export const newDate = () => new Date(); export const newUpdateId = () => 'uuid-v7'; export const newSha1 = () => Buffer.from('this is a fake hash'); +export const newEmbedding = () => { + const embedding = Array.from({ length: 512 }) + .fill(0) + .map(() => Math.random()); + return '[' + embedding + ']'; +}; const authFactory = ({ apiKey, ...user }: Partial & { apiKey?: Partial } = {}) => { const auth: AuthDto = { diff --git a/server/test/utils.ts b/server/test/utils.ts index 4df7904d75..06142dc149 100644 --- a/server/test/utils.ts +++ b/server/test/utils.ts @@ -58,6 +58,7 @@ import { newDatabaseRepositoryMock } from 'test/repositories/database.repository import { newJobRepositoryMock } from 'test/repositories/job.repository.mock'; import { newMediaRepositoryMock } from 'test/repositories/media.repository.mock'; import { newMetadataRepositoryMock } from 'test/repositories/metadata.repository.mock'; +import { newPersonRepositoryMock } from 'test/repositories/person.repository.mock'; import { newStorageRepositoryMock } from 'test/repositories/storage.repository.mock'; import { newSystemMetadataRepositoryMock } from 'test/repositories/system-metadata.repository.mock'; import { ITelemetryRepositoryMock, newTelemetryRepositoryMock } from 'test/repositories/telemetry.repository.mock'; @@ -197,7 +198,7 @@ export const newTestService = ( notification: automock(NotificationRepository, { args: [loggerMock] }), oauth: automock(OAuthRepository, { args: [loggerMock] }), partner: automock(PartnerRepository, { strict: false }), - person: automock(PersonRepository, { strict: false }), + person: newPersonRepositoryMock(), process: automock(ProcessRepository, { args: [loggerMock] }), search: automock(SearchRepository, { args: [loggerMock], strict: false }), // eslint-disable-next-line no-sparse-arrays From 40cff2893cf7ac3bb176d3ae7e19cc8530a029e6 Mon Sep 17 00:00:00 2001 From: Zack Pollard Date: Thu, 3 Apr 2025 12:35:39 +0100 Subject: [PATCH 02/52] fix: metadata service init failure should halt server startup (#17356) --- server/src/services/metadata.service.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/server/src/services/metadata.service.ts b/server/src/services/metadata.service.ts index 1e17f63283..402ccbbac7 100644 --- a/server/src/services/metadata.service.ts +++ b/server/src/services/metadata.service.ts @@ -117,6 +117,7 @@ export class MetadataService extends BaseService { this.logger.log(`Initialized local reverse geocoder`); } catch (error: Error | any) { this.logger.error(`Unable to initialize reverse geocoding: ${error}`, error?.stack); + throw new Error(`Metadata service init failed`); } } From 548298b0c75bff173a353ee1cf2acdda0c26dfa8 Mon Sep 17 00:00:00 2001 From: Alex Date: Thu, 3 Apr 2025 07:47:52 -0500 Subject: [PATCH 03/52] chore: post release tasks (#17341) --- mobile/ios/Runner.xcodeproj/project.pbxproj | 12 ++++++------ mobile/ios/Runner/Info.plist | 4 ++-- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/mobile/ios/Runner.xcodeproj/project.pbxproj b/mobile/ios/Runner.xcodeproj/project.pbxproj index 098683be2f..83c231d741 100644 --- a/mobile/ios/Runner.xcodeproj/project.pbxproj +++ b/mobile/ios/Runner.xcodeproj/project.pbxproj @@ -541,7 +541,7 @@ CODE_SIGN_ENTITLEMENTS = Runner/RunnerProfile.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 200; + CURRENT_PROJECT_VERSION = 201; CUSTOM_GROUP_ID = group.app.immich.share; DEVELOPMENT_TEAM = 2F67MQ8R79; ENABLE_BITCODE = NO; @@ -685,7 +685,7 @@ CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 200; + CURRENT_PROJECT_VERSION = 201; CUSTOM_GROUP_ID = group.app.immich.share; DEVELOPMENT_TEAM = 2F67MQ8R79; ENABLE_BITCODE = NO; @@ -715,7 +715,7 @@ CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 200; + CURRENT_PROJECT_VERSION = 201; CUSTOM_GROUP_ID = group.app.immich.share; DEVELOPMENT_TEAM = 2F67MQ8R79; ENABLE_BITCODE = NO; @@ -748,7 +748,7 @@ CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 200; + CURRENT_PROJECT_VERSION = 201; CUSTOM_GROUP_ID = group.app.immich.share; DEVELOPMENT_TEAM = 2F67MQ8R79; ENABLE_USER_SCRIPT_SANDBOXING = YES; @@ -791,7 +791,7 @@ CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 200; + CURRENT_PROJECT_VERSION = 201; CUSTOM_GROUP_ID = group.app.immich.share; DEVELOPMENT_TEAM = 2F67MQ8R79; ENABLE_USER_SCRIPT_SANDBOXING = YES; @@ -831,7 +831,7 @@ CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 200; + CURRENT_PROJECT_VERSION = 201; CUSTOM_GROUP_ID = group.app.immich.share; DEVELOPMENT_TEAM = 2F67MQ8R79; ENABLE_USER_SCRIPT_SANDBOXING = YES; diff --git a/mobile/ios/Runner/Info.plist b/mobile/ios/Runner/Info.plist index f83de00158..bad1ea42f2 100644 --- a/mobile/ios/Runner/Info.plist +++ b/mobile/ios/Runner/Info.plist @@ -78,7 +78,7 @@ CFBundlePackageType APPL CFBundleShortVersionString - 1.131.0 + 1.131.3 CFBundleSignature ???? CFBundleURLTypes @@ -93,7 +93,7 @@ CFBundleVersion - 200 + 201 FLTEnableImpeller ITSAppUsesNonExemptEncryption From e8b4ac05226d9cc3a007c8a48e4f6d84f7ef4b8a Mon Sep 17 00:00:00 2001 From: Mert <101130780+mertalev@users.noreply.github.com> Date: Thu, 3 Apr 2025 10:01:41 -0400 Subject: [PATCH 04/52] fix(web): use original image if web compatible (#17347) * use original image if web compatible * add e2e * fix shared link handling * handle redirect in e2e * fix size not being passed to thumbnail url * test fullsize in e2e --- e2e/src/web/specs/photo-viewer.e2e-spec.ts | 15 ++++++++- .../asset-viewer/photo-viewer.spec.ts | 22 +++++-------- .../asset-viewer/photo-viewer.svelte | 31 ++++++++++--------- 3 files changed, 38 insertions(+), 30 deletions(-) diff --git a/e2e/src/web/specs/photo-viewer.e2e-spec.ts b/e2e/src/web/specs/photo-viewer.e2e-spec.ts index 37e691625a..4871e7522c 100644 --- a/e2e/src/web/specs/photo-viewer.e2e-spec.ts +++ b/e2e/src/web/specs/photo-viewer.e2e-spec.ts @@ -8,12 +8,14 @@ function imageLocator(page: Page) { test.describe('Photo Viewer', () => { let admin: LoginResponseDto; let asset: AssetMediaResponseDto; + let rawAsset: AssetMediaResponseDto; test.beforeAll(async () => { utils.initSdk(); await utils.resetDatabase(); admin = await utils.adminSetup(); asset = await utils.createAsset(admin.accessToken); + rawAsset = await utils.createAsset(admin.accessToken, { assetData: { filename: 'test.arw' } }); }); test.beforeEach(async ({ context, page }) => { @@ -36,7 +38,7 @@ test.describe('Photo Viewer', () => { await expect(page.getByTestId('loading-spinner')).toBeVisible(); }); - test('loads high resolution photo when zoomed', async ({ page }) => { + test('loads original photo when zoomed', async ({ page }) => { await page.goto(`/photos/${asset.id}`); await expect.poll(async () => await imageLocator(page).getAttribute('src')).toContain('thumbnail'); const box = await imageLocator(page).boundingBox(); @@ -44,6 +46,17 @@ test.describe('Photo Viewer', () => { const { x, y, width, height } = box!; await page.mouse.move(x + width / 2, y + height / 2); await page.mouse.wheel(0, -1); + await expect.poll(async () => await imageLocator(page).getAttribute('src')).toContain('original'); + }); + + test('loads fullsize image when zoomed and original is web-incompatible', async ({ page }) => { + await page.goto(`/photos/${rawAsset.id}`); + await expect.poll(async () => await imageLocator(page).getAttribute('src')).toContain('thumbnail'); + const box = await imageLocator(page).boundingBox(); + expect(box).toBeTruthy(); + const { x, y, width, height } = box!; + await page.mouse.move(x + width / 2, y + height / 2); + await page.mouse.wheel(0, -1); await expect.poll(async () => await imageLocator(page).getAttribute('src')).toContain('fullsize'); }); diff --git a/web/src/lib/components/asset-viewer/photo-viewer.spec.ts b/web/src/lib/components/asset-viewer/photo-viewer.spec.ts index 8690aae0c3..d90fb89c23 100644 --- a/web/src/lib/components/asset-viewer/photo-viewer.spec.ts +++ b/web/src/lib/components/asset-viewer/photo-viewer.spec.ts @@ -48,7 +48,7 @@ describe('PhotoViewer component', () => { expect(getAssetThumbnailUrlSpy).toBeCalledWith({ id: asset.id, size: AssetMediaSize.Preview, - cacheKey: asset.checksum, + cacheKey: asset.thumbhash, }); expect(getAssetOriginalUrlSpy).not.toBeCalled(); }); @@ -57,11 +57,8 @@ describe('PhotoViewer component', () => { const asset = assetFactory.build({ originalPath: 'image.gif', originalMimeType: 'image/gif' }); render(PhotoViewer, { asset }); - expect(getAssetThumbnailUrlSpy).toBeCalledWith({ - id: asset.id, - cacheKey: asset.checksum, - size: AssetMediaSize.Fullsize, - }); + expect(getAssetThumbnailUrlSpy).not.toBeCalled(); + expect(getAssetOriginalUrlSpy).toBeCalledWith({ id: asset.id, cacheKey: asset.thumbhash }); }); it('loads original for shared link when download permission is true and showMetadata permission is true', () => { @@ -69,13 +66,8 @@ describe('PhotoViewer component', () => { const sharedLink = sharedLinkFactory.build({ allowDownload: true, showMetadata: true, assets: [asset] }); render(PhotoViewer, { asset, sharedLink }); - expect(getAssetThumbnailUrlSpy).toBeCalledWith({ - id: asset.id, - size: AssetMediaSize.Fullsize, - cacheKey: asset.checksum, - }); - // expect(getAssetThumbnailUrlSpy).not.toBeCalled(); - // expect(getAssetOriginalUrlSpy).toBeCalledWith({ id: asset.id, cacheKey: asset.thumbhash }); + expect(getAssetThumbnailUrlSpy).not.toBeCalled(); + expect(getAssetOriginalUrlSpy).toBeCalledWith({ id: asset.id, cacheKey: asset.thumbhash }); }); it('not loads original image when shared link download permission is false', () => { @@ -86,7 +78,7 @@ describe('PhotoViewer component', () => { expect(getAssetThumbnailUrlSpy).toBeCalledWith({ id: asset.id, size: AssetMediaSize.Preview, - cacheKey: asset.checksum, + cacheKey: asset.thumbhash, }); expect(getAssetOriginalUrlSpy).not.toBeCalled(); @@ -100,7 +92,7 @@ describe('PhotoViewer component', () => { expect(getAssetThumbnailUrlSpy).toBeCalledWith({ id: asset.id, size: AssetMediaSize.Preview, - cacheKey: asset.checksum, + cacheKey: asset.thumbhash, }); expect(getAssetOriginalUrlSpy).not.toBeCalled(); diff --git a/web/src/lib/components/asset-viewer/photo-viewer.svelte b/web/src/lib/components/asset-viewer/photo-viewer.svelte index 1d7c49f107..6bc67a5257 100644 --- a/web/src/lib/components/asset-viewer/photo-viewer.svelte +++ b/web/src/lib/components/asset-viewer/photo-viewer.svelte @@ -6,8 +6,8 @@ import { alwaysLoadOriginalFile } from '$lib/stores/preferences.store'; import { SlideshowLook, SlideshowState, slideshowLookCssMapping, slideshowStore } from '$lib/stores/slideshow.store'; import { photoZoomState } from '$lib/stores/zoom-image.store'; - import { getAssetThumbnailUrl, handlePromiseError } from '$lib/utils'; - import { canCopyImageToClipboard, copyImageToClipboard } from '$lib/utils/asset-utils'; + import { getAssetOriginalUrl, getAssetThumbnailUrl, handlePromiseError } from '$lib/utils'; + import { canCopyImageToClipboard, copyImageToClipboard, isWebCompatibleImage } from '$lib/utils/asset-utils'; import { getBoundingBox } from '$lib/utils/people-utils'; import { getAltText } from '$lib/utils/thumbnail-util'; import { AssetMediaSize, AssetTypeEnum, type AssetResponseDto, type SharedLinkResponseDto } from '@immich/sdk'; @@ -68,7 +68,7 @@ $boundingBoxesArray = []; }); - const preload = (targetSize: AssetMediaSize, preloadAssets?: AssetResponseDto[]) => { + const preload = (targetSize: AssetMediaSize | 'original', preloadAssets?: AssetResponseDto[]) => { for (const preloadAsset of preloadAssets || []) { if (preloadAsset.type === AssetTypeEnum.Image) { let img = new Image(); @@ -77,13 +77,14 @@ } }; - const getAssetUrl = (id: string, targetSize: AssetMediaSize, cacheKey: string | null) => { - let finalAssetMediaSize = targetSize; + const getAssetUrl = (id: string, targetSize: AssetMediaSize | 'original', cacheKey: string | null) => { if (sharedLink && (!sharedLink.allowDownload || !sharedLink.showMetadata)) { - finalAssetMediaSize = AssetMediaSize.Preview; + return getAssetThumbnailUrl({ id, size: AssetMediaSize.Preview, cacheKey }); } - return getAssetThumbnailUrl({ id, size: finalAssetMediaSize, cacheKey }); + return targetSize === 'original' + ? getAssetOriginalUrl({ id, cacheKey }) + : getAssetThumbnailUrl({ id, size: targetSize, cacheKey }); }; copyImage = async () => { @@ -136,16 +137,18 @@ // when true, will force loading of the original image let forceUseOriginal: boolean = $derived(asset.originalMimeType === 'image/gif' || $photoZoomState.currentZoom > 1); - const targetImageSize = $derived( - $alwaysLoadOriginalFile || forceUseOriginal || originalImageLoaded - ? AssetMediaSize.Fullsize - : AssetMediaSize.Preview, - ); + const targetImageSize = $derived.by(() => { + if ($alwaysLoadOriginalFile || forceUseOriginal || originalImageLoaded) { + return isWebCompatibleImage(asset) ? 'original' : AssetMediaSize.Fullsize; + } + + return AssetMediaSize.Preview; + }); const onload = () => { imageLoaded = true; assetFileUrl = imageLoaderUrl; - originalImageLoaded = targetImageSize === AssetMediaSize.Fullsize; + originalImageLoaded = targetImageSize === AssetMediaSize.Fullsize || targetImageSize === 'original'; }; const onerror = () => { @@ -168,7 +171,7 @@ }; }); - let imageLoaderUrl = $derived(getAssetUrl(asset.id, targetImageSize, asset.checksum)); + let imageLoaderUrl = $derived(getAssetUrl(asset.id, targetImageSize, asset.thumbhash)); let containerWidth = $state(0); let containerHeight = $state(0); From 97e52c5156c40b733a7cb9bdc104eb560e8d0636 Mon Sep 17 00:00:00 2001 From: shenlong <139912620+shenlong-tanwen@users.noreply.github.com> Date: Fri, 4 Apr 2025 01:12:35 +0530 Subject: [PATCH 05/52] refactor(mobile): device asset entity to use modified time (#17064) * refactor: device asset entity to use modified time * chore: cleanup * refactor: remove album media dependency from hashservice * refactor: return updated copy of asset * add hash service tests * chore: rename hash batch constants * chore: log the number of assets processed during migration * chore: more logs * refactor: use lookup and more tests * use sort approach * refactor hash service to use for loop instead * refactor: rename to getByIds --------- Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com> --- mobile/analysis_options.yaml | 2 +- mobile/lib/constants/constants.dart | 3 + .../interfaces/device_asset.interface.dart | 12 + .../lib/domain/models/device_asset.model.dart | 44 + mobile/lib/entities/asset.entity.dart | 16 +- .../entities/device_asset.entity.dart | 36 + .../entities/device_asset.entity.g.dart | 895 ++++++++++++++++++ .../repositories/device_asset.repository.dart | 37 + mobile/lib/interfaces/asset.interface.dart | 5 - .../infrastructure/device_asset.provider.dart | 8 + mobile/lib/repositories/asset.repository.dart | 18 - mobile/lib/services/hash.service.dart | 291 +++--- mobile/lib/services/sync.service.dart | 50 +- mobile/lib/utils/bootstrap.dart | 2 + mobile/lib/utils/diff.dart | 14 + mobile/lib/utils/migration.dart | 97 +- mobile/pubspec.lock | 2 +- mobile/pubspec.yaml | 1 + .../domain/services/hash_service_test.dart | 425 +++++++++ mobile/test/fixtures/asset.stub.dart | 19 +- .../test/infrastructure/repository.mock.dart | 4 + mobile/test/service.mocks.dart | 3 + mobile/test/test_utils.dart | 2 + 23 files changed, 1801 insertions(+), 185 deletions(-) create mode 100644 mobile/lib/domain/interfaces/device_asset.interface.dart create mode 100644 mobile/lib/domain/models/device_asset.model.dart create mode 100644 mobile/lib/infrastructure/entities/device_asset.entity.dart create mode 100644 mobile/lib/infrastructure/entities/device_asset.entity.g.dart create mode 100644 mobile/lib/infrastructure/repositories/device_asset.repository.dart create mode 100644 mobile/lib/providers/infrastructure/device_asset.provider.dart create mode 100644 mobile/test/domain/services/hash_service_test.dart diff --git a/mobile/analysis_options.yaml b/mobile/analysis_options.yaml index 07c6f65b71..04f3145908 100644 --- a/mobile/analysis_options.yaml +++ b/mobile/analysis_options.yaml @@ -61,6 +61,7 @@ custom_lint: # refactor to make the providers and services testable - lib/providers/backup/{backup,manual_upload}.provider.dart # uses only PMProgressHandler - lib/services/{background,backup}.service.dart # uses only PMProgressHandler + - test/**.dart - import_rule_isar: message: isar must only be used in entities and repositories restrict: package:isar @@ -150,7 +151,6 @@ dart_code_metrics: - avoid-unnecessary-continue - avoid-unnecessary-nullable-return-type: false - binary-expression-operand-order - - move-variable-outside-iteration - pattern-fields-ordering - prefer-abstract-final-static-class - prefer-commenting-future-delayed diff --git a/mobile/lib/constants/constants.dart b/mobile/lib/constants/constants.dart index 868b036d1b..83d540d54c 100644 --- a/mobile/lib/constants/constants.dart +++ b/mobile/lib/constants/constants.dart @@ -4,3 +4,6 @@ const double downloadFailed = -2; // Number of log entries to retain on app start const int kLogTruncateLimit = 250; + +const int kBatchHashFileLimit = 128; +const int kBatchHashSizeLimit = 1024 * 1024 * 1024; // 1GB diff --git a/mobile/lib/domain/interfaces/device_asset.interface.dart b/mobile/lib/domain/interfaces/device_asset.interface.dart new file mode 100644 index 0000000000..1df8cc2250 --- /dev/null +++ b/mobile/lib/domain/interfaces/device_asset.interface.dart @@ -0,0 +1,12 @@ +import 'dart:async'; + +import 'package:immich_mobile/domain/interfaces/db.interface.dart'; +import 'package:immich_mobile/domain/models/device_asset.model.dart'; + +abstract interface class IDeviceAssetRepository implements IDatabaseRepository { + Future updateAll(List assetHash); + + Future> getByIds(List localIds); + + Future deleteIds(List ids); +} diff --git a/mobile/lib/domain/models/device_asset.model.dart b/mobile/lib/domain/models/device_asset.model.dart new file mode 100644 index 0000000000..2ec56b0d80 --- /dev/null +++ b/mobile/lib/domain/models/device_asset.model.dart @@ -0,0 +1,44 @@ +import 'dart:typed_data'; + +class DeviceAsset { + final String assetId; + final Uint8List hash; + final DateTime modifiedTime; + + const DeviceAsset({ + required this.assetId, + required this.hash, + required this.modifiedTime, + }); + + @override + bool operator ==(covariant DeviceAsset other) { + if (identical(this, other)) return true; + + return other.assetId == assetId && + other.hash == hash && + other.modifiedTime == modifiedTime; + } + + @override + int get hashCode { + return assetId.hashCode ^ hash.hashCode ^ modifiedTime.hashCode; + } + + @override + String toString() { + return 'DeviceAsset(assetId: $assetId, hash: $hash, modifiedTime: $modifiedTime)'; + } + + DeviceAsset copyWith({ + String? assetId, + Uint8List? hash, + DateTime? modifiedTime, + }) { + return DeviceAsset( + assetId: assetId ?? this.assetId, + hash: hash ?? this.hash, + modifiedTime: modifiedTime ?? this.modifiedTime, + ); + } +} diff --git a/mobile/lib/entities/asset.entity.dart b/mobile/lib/entities/asset.entity.dart index 048068ad3d..084cd1ee5d 100644 --- a/mobile/lib/entities/asset.entity.dart +++ b/mobile/lib/entities/asset.entity.dart @@ -6,6 +6,7 @@ import 'package:immich_mobile/extensions/string_extensions.dart'; import 'package:immich_mobile/infrastructure/entities/exif.entity.dart' as entity; import 'package:immich_mobile/infrastructure/utils/exif.converter.dart'; +import 'package:immich_mobile/utils/diff.dart'; import 'package:immich_mobile/utils/hash.dart'; import 'package:isar/isar.dart'; import 'package:openapi/api.dart'; @@ -358,7 +359,7 @@ class Asset { // take most values from newer asset // keep vales that can never be set by the asset not in DB if (a.isRemote) { - return a._copyWith( + return a.copyWith( id: id, localId: localId, width: a.width ?? width, @@ -366,7 +367,7 @@ class Asset { exifInfo: a.exifInfo?.copyWith(assetId: id) ?? exifInfo, ); } else if (isRemote) { - return _copyWith( + return copyWith( localId: localId ?? a.localId, width: width ?? a.width, height: height ?? a.height, @@ -374,7 +375,7 @@ class Asset { ); } else { // TODO: Revisit this and remove all bool field assignments - return a._copyWith( + return a.copyWith( id: id, remoteId: remoteId, livePhotoVideoId: livePhotoVideoId, @@ -394,7 +395,7 @@ class Asset { // fill in potentially missing values, i.e. merge assets if (a.isRemote) { // values from remote take precedence - return _copyWith( + return copyWith( remoteId: a.remoteId, width: a.width, height: a.height, @@ -416,7 +417,7 @@ class Asset { ); } else { // add only missing values (and set isLocal to true) - return _copyWith( + return copyWith( localId: localId ?? a.localId, width: width ?? a.width, height: height ?? a.height, @@ -427,7 +428,7 @@ class Asset { } } - Asset _copyWith({ + Asset copyWith({ Id? id, String? checksum, String? remoteId, @@ -488,6 +489,9 @@ class Asset { static int compareById(Asset a, Asset b) => a.id.compareTo(b.id); + static int compareByLocalId(Asset a, Asset b) => + compareToNullable(a.localId, b.localId); + static int compareByChecksum(Asset a, Asset b) => a.checksum.compareTo(b.checksum); diff --git a/mobile/lib/infrastructure/entities/device_asset.entity.dart b/mobile/lib/infrastructure/entities/device_asset.entity.dart new file mode 100644 index 0000000000..d8bfb2aa45 --- /dev/null +++ b/mobile/lib/infrastructure/entities/device_asset.entity.dart @@ -0,0 +1,36 @@ +import 'dart:typed_data'; + +import 'package:immich_mobile/domain/models/device_asset.model.dart'; +import 'package:immich_mobile/utils/hash.dart'; +import 'package:isar/isar.dart'; + +part 'device_asset.entity.g.dart'; + +@Collection(inheritance: false) +class DeviceAssetEntity { + Id get id => fastHash(assetId); + + @Index(replace: true, unique: true, type: IndexType.hash) + final String assetId; + @Index(unique: false, type: IndexType.hash) + final List hash; + final DateTime modifiedTime; + + const DeviceAssetEntity({ + required this.assetId, + required this.hash, + required this.modifiedTime, + }); + + DeviceAsset toModel() => DeviceAsset( + assetId: assetId, + hash: Uint8List.fromList(hash), + modifiedTime: modifiedTime, + ); + + static DeviceAssetEntity fromDto(DeviceAsset dto) => DeviceAssetEntity( + assetId: dto.assetId, + hash: dto.hash, + modifiedTime: dto.modifiedTime, + ); +} diff --git a/mobile/lib/infrastructure/entities/device_asset.entity.g.dart b/mobile/lib/infrastructure/entities/device_asset.entity.g.dart new file mode 100644 index 0000000000..a66f8288ef --- /dev/null +++ b/mobile/lib/infrastructure/entities/device_asset.entity.g.dart @@ -0,0 +1,895 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'device_asset.entity.dart'; + +// ************************************************************************** +// IsarCollectionGenerator +// ************************************************************************** + +// coverage:ignore-file +// ignore_for_file: duplicate_ignore, non_constant_identifier_names, constant_identifier_names, invalid_use_of_protected_member, unnecessary_cast, prefer_const_constructors, lines_longer_than_80_chars, require_trailing_commas, inference_failure_on_function_invocation, unnecessary_parenthesis, unnecessary_raw_strings, unnecessary_null_checks, join_return_with_assignment, prefer_final_locals, avoid_js_rounded_ints, avoid_positional_boolean_parameters, always_specify_types + +extension GetDeviceAssetEntityCollection on Isar { + IsarCollection get deviceAssetEntitys => this.collection(); +} + +const DeviceAssetEntitySchema = CollectionSchema( + name: r'DeviceAssetEntity', + id: 6967030785073446271, + properties: { + r'assetId': PropertySchema( + id: 0, + name: r'assetId', + type: IsarType.string, + ), + r'hash': PropertySchema( + id: 1, + name: r'hash', + type: IsarType.byteList, + ), + r'modifiedTime': PropertySchema( + id: 2, + name: r'modifiedTime', + type: IsarType.dateTime, + ) + }, + estimateSize: _deviceAssetEntityEstimateSize, + serialize: _deviceAssetEntitySerialize, + deserialize: _deviceAssetEntityDeserialize, + deserializeProp: _deviceAssetEntityDeserializeProp, + idName: r'id', + indexes: { + r'assetId': IndexSchema( + id: 174362542210192109, + name: r'assetId', + unique: true, + replace: true, + properties: [ + IndexPropertySchema( + name: r'assetId', + type: IndexType.hash, + caseSensitive: true, + ) + ], + ), + r'hash': IndexSchema( + id: -7973251393006690288, + name: r'hash', + unique: false, + replace: false, + properties: [ + IndexPropertySchema( + name: r'hash', + type: IndexType.hash, + caseSensitive: false, + ) + ], + ) + }, + links: {}, + embeddedSchemas: {}, + getId: _deviceAssetEntityGetId, + getLinks: _deviceAssetEntityGetLinks, + attach: _deviceAssetEntityAttach, + version: '3.1.8', +); + +int _deviceAssetEntityEstimateSize( + DeviceAssetEntity object, + List offsets, + Map> allOffsets, +) { + var bytesCount = offsets.last; + bytesCount += 3 + object.assetId.length * 3; + bytesCount += 3 + object.hash.length; + return bytesCount; +} + +void _deviceAssetEntitySerialize( + DeviceAssetEntity object, + IsarWriter writer, + List offsets, + Map> allOffsets, +) { + writer.writeString(offsets[0], object.assetId); + writer.writeByteList(offsets[1], object.hash); + writer.writeDateTime(offsets[2], object.modifiedTime); +} + +DeviceAssetEntity _deviceAssetEntityDeserialize( + Id id, + IsarReader reader, + List offsets, + Map> allOffsets, +) { + final object = DeviceAssetEntity( + assetId: reader.readString(offsets[0]), + hash: reader.readByteList(offsets[1]) ?? [], + modifiedTime: reader.readDateTime(offsets[2]), + ); + return object; +} + +P _deviceAssetEntityDeserializeProp

( + IsarReader reader, + int propertyId, + int offset, + Map> allOffsets, +) { + switch (propertyId) { + case 0: + return (reader.readString(offset)) as P; + case 1: + return (reader.readByteList(offset) ?? []) as P; + case 2: + return (reader.readDateTime(offset)) as P; + default: + throw IsarError('Unknown property with id $propertyId'); + } +} + +Id _deviceAssetEntityGetId(DeviceAssetEntity object) { + return object.id; +} + +List> _deviceAssetEntityGetLinks( + DeviceAssetEntity object) { + return []; +} + +void _deviceAssetEntityAttach( + IsarCollection col, Id id, DeviceAssetEntity object) {} + +extension DeviceAssetEntityByIndex on IsarCollection { + Future getByAssetId(String assetId) { + return getByIndex(r'assetId', [assetId]); + } + + DeviceAssetEntity? getByAssetIdSync(String assetId) { + return getByIndexSync(r'assetId', [assetId]); + } + + Future deleteByAssetId(String assetId) { + return deleteByIndex(r'assetId', [assetId]); + } + + bool deleteByAssetIdSync(String assetId) { + return deleteByIndexSync(r'assetId', [assetId]); + } + + Future> getAllByAssetId(List assetIdValues) { + final values = assetIdValues.map((e) => [e]).toList(); + return getAllByIndex(r'assetId', values); + } + + List getAllByAssetIdSync(List assetIdValues) { + final values = assetIdValues.map((e) => [e]).toList(); + return getAllByIndexSync(r'assetId', values); + } + + Future deleteAllByAssetId(List assetIdValues) { + final values = assetIdValues.map((e) => [e]).toList(); + return deleteAllByIndex(r'assetId', values); + } + + int deleteAllByAssetIdSync(List assetIdValues) { + final values = assetIdValues.map((e) => [e]).toList(); + return deleteAllByIndexSync(r'assetId', values); + } + + Future putByAssetId(DeviceAssetEntity object) { + return putByIndex(r'assetId', object); + } + + Id putByAssetIdSync(DeviceAssetEntity object, {bool saveLinks = true}) { + return putByIndexSync(r'assetId', object, saveLinks: saveLinks); + } + + Future> putAllByAssetId(List objects) { + return putAllByIndex(r'assetId', objects); + } + + List putAllByAssetIdSync(List objects, + {bool saveLinks = true}) { + return putAllByIndexSync(r'assetId', objects, saveLinks: saveLinks); + } +} + +extension DeviceAssetEntityQueryWhereSort + on QueryBuilder { + QueryBuilder anyId() { + return QueryBuilder.apply(this, (query) { + return query.addWhereClause(const IdWhereClause.any()); + }); + } +} + +extension DeviceAssetEntityQueryWhere + on QueryBuilder { + QueryBuilder + idEqualTo(Id id) { + return QueryBuilder.apply(this, (query) { + return query.addWhereClause(IdWhereClause.between( + lower: id, + upper: id, + )); + }); + } + + QueryBuilder + idNotEqualTo(Id id) { + return QueryBuilder.apply(this, (query) { + if (query.whereSort == Sort.asc) { + return query + .addWhereClause( + IdWhereClause.lessThan(upper: id, includeUpper: false), + ) + .addWhereClause( + IdWhereClause.greaterThan(lower: id, includeLower: false), + ); + } else { + return query + .addWhereClause( + IdWhereClause.greaterThan(lower: id, includeLower: false), + ) + .addWhereClause( + IdWhereClause.lessThan(upper: id, includeUpper: false), + ); + } + }); + } + + QueryBuilder + idGreaterThan(Id id, {bool include = false}) { + return QueryBuilder.apply(this, (query) { + return query.addWhereClause( + IdWhereClause.greaterThan(lower: id, includeLower: include), + ); + }); + } + + QueryBuilder + idLessThan(Id id, {bool include = false}) { + return QueryBuilder.apply(this, (query) { + return query.addWhereClause( + IdWhereClause.lessThan(upper: id, includeUpper: include), + ); + }); + } + + QueryBuilder + idBetween( + Id lowerId, + Id upperId, { + bool includeLower = true, + bool includeUpper = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addWhereClause(IdWhereClause.between( + lower: lowerId, + includeLower: includeLower, + upper: upperId, + includeUpper: includeUpper, + )); + }); + } + + QueryBuilder + assetIdEqualTo(String assetId) { + return QueryBuilder.apply(this, (query) { + return query.addWhereClause(IndexWhereClause.equalTo( + indexName: r'assetId', + value: [assetId], + )); + }); + } + + QueryBuilder + assetIdNotEqualTo(String assetId) { + return QueryBuilder.apply(this, (query) { + if (query.whereSort == Sort.asc) { + return query + .addWhereClause(IndexWhereClause.between( + indexName: r'assetId', + lower: [], + upper: [assetId], + includeUpper: false, + )) + .addWhereClause(IndexWhereClause.between( + indexName: r'assetId', + lower: [assetId], + includeLower: false, + upper: [], + )); + } else { + return query + .addWhereClause(IndexWhereClause.between( + indexName: r'assetId', + lower: [assetId], + includeLower: false, + upper: [], + )) + .addWhereClause(IndexWhereClause.between( + indexName: r'assetId', + lower: [], + upper: [assetId], + includeUpper: false, + )); + } + }); + } + + QueryBuilder + hashEqualTo(List hash) { + return QueryBuilder.apply(this, (query) { + return query.addWhereClause(IndexWhereClause.equalTo( + indexName: r'hash', + value: [hash], + )); + }); + } + + QueryBuilder + hashNotEqualTo(List hash) { + return QueryBuilder.apply(this, (query) { + if (query.whereSort == Sort.asc) { + return query + .addWhereClause(IndexWhereClause.between( + indexName: r'hash', + lower: [], + upper: [hash], + includeUpper: false, + )) + .addWhereClause(IndexWhereClause.between( + indexName: r'hash', + lower: [hash], + includeLower: false, + upper: [], + )); + } else { + return query + .addWhereClause(IndexWhereClause.between( + indexName: r'hash', + lower: [hash], + includeLower: false, + upper: [], + )) + .addWhereClause(IndexWhereClause.between( + indexName: r'hash', + lower: [], + upper: [hash], + includeUpper: false, + )); + } + }); + } +} + +extension DeviceAssetEntityQueryFilter + on QueryBuilder { + QueryBuilder + assetIdEqualTo( + String value, { + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.equalTo( + property: r'assetId', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder + assetIdGreaterThan( + String value, { + bool include = false, + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.greaterThan( + include: include, + property: r'assetId', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder + assetIdLessThan( + String value, { + bool include = false, + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.lessThan( + include: include, + property: r'assetId', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder + assetIdBetween( + String lower, + String upper, { + bool includeLower = true, + bool includeUpper = true, + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.between( + property: r'assetId', + lower: lower, + includeLower: includeLower, + upper: upper, + includeUpper: includeUpper, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder + assetIdStartsWith( + String value, { + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.startsWith( + property: r'assetId', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder + assetIdEndsWith( + String value, { + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.endsWith( + property: r'assetId', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder + assetIdContains(String value, {bool caseSensitive = true}) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.contains( + property: r'assetId', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder + assetIdMatches(String pattern, {bool caseSensitive = true}) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.matches( + property: r'assetId', + wildcard: pattern, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder + assetIdIsEmpty() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.equalTo( + property: r'assetId', + value: '', + )); + }); + } + + QueryBuilder + assetIdIsNotEmpty() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.greaterThan( + property: r'assetId', + value: '', + )); + }); + } + + QueryBuilder + hashElementEqualTo(int value) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.equalTo( + property: r'hash', + value: value, + )); + }); + } + + QueryBuilder + hashElementGreaterThan( + int value, { + bool include = false, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.greaterThan( + include: include, + property: r'hash', + value: value, + )); + }); + } + + QueryBuilder + hashElementLessThan( + int value, { + bool include = false, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.lessThan( + include: include, + property: r'hash', + value: value, + )); + }); + } + + QueryBuilder + hashElementBetween( + int lower, + int upper, { + bool includeLower = true, + bool includeUpper = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.between( + property: r'hash', + lower: lower, + includeLower: includeLower, + upper: upper, + includeUpper: includeUpper, + )); + }); + } + + QueryBuilder + hashLengthEqualTo(int length) { + return QueryBuilder.apply(this, (query) { + return query.listLength( + r'hash', + length, + true, + length, + true, + ); + }); + } + + QueryBuilder + hashIsEmpty() { + return QueryBuilder.apply(this, (query) { + return query.listLength( + r'hash', + 0, + true, + 0, + true, + ); + }); + } + + QueryBuilder + hashIsNotEmpty() { + return QueryBuilder.apply(this, (query) { + return query.listLength( + r'hash', + 0, + false, + 999999, + true, + ); + }); + } + + QueryBuilder + hashLengthLessThan( + int length, { + bool include = false, + }) { + return QueryBuilder.apply(this, (query) { + return query.listLength( + r'hash', + 0, + true, + length, + include, + ); + }); + } + + QueryBuilder + hashLengthGreaterThan( + int length, { + bool include = false, + }) { + return QueryBuilder.apply(this, (query) { + return query.listLength( + r'hash', + length, + include, + 999999, + true, + ); + }); + } + + QueryBuilder + hashLengthBetween( + int lower, + int upper, { + bool includeLower = true, + bool includeUpper = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.listLength( + r'hash', + lower, + includeLower, + upper, + includeUpper, + ); + }); + } + + QueryBuilder + idEqualTo(Id value) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.equalTo( + property: r'id', + value: value, + )); + }); + } + + QueryBuilder + idGreaterThan( + Id value, { + bool include = false, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.greaterThan( + include: include, + property: r'id', + value: value, + )); + }); + } + + QueryBuilder + idLessThan( + Id value, { + bool include = false, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.lessThan( + include: include, + property: r'id', + value: value, + )); + }); + } + + QueryBuilder + idBetween( + Id lower, + Id upper, { + bool includeLower = true, + bool includeUpper = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.between( + property: r'id', + lower: lower, + includeLower: includeLower, + upper: upper, + includeUpper: includeUpper, + )); + }); + } + + QueryBuilder + modifiedTimeEqualTo(DateTime value) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.equalTo( + property: r'modifiedTime', + value: value, + )); + }); + } + + QueryBuilder + modifiedTimeGreaterThan( + DateTime value, { + bool include = false, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.greaterThan( + include: include, + property: r'modifiedTime', + value: value, + )); + }); + } + + QueryBuilder + modifiedTimeLessThan( + DateTime value, { + bool include = false, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.lessThan( + include: include, + property: r'modifiedTime', + value: value, + )); + }); + } + + QueryBuilder + modifiedTimeBetween( + DateTime lower, + DateTime upper, { + bool includeLower = true, + bool includeUpper = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.between( + property: r'modifiedTime', + lower: lower, + includeLower: includeLower, + upper: upper, + includeUpper: includeUpper, + )); + }); + } +} + +extension DeviceAssetEntityQueryObject + on QueryBuilder {} + +extension DeviceAssetEntityQueryLinks + on QueryBuilder {} + +extension DeviceAssetEntityQuerySortBy + on QueryBuilder { + QueryBuilder + sortByAssetId() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'assetId', Sort.asc); + }); + } + + QueryBuilder + sortByAssetIdDesc() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'assetId', Sort.desc); + }); + } + + QueryBuilder + sortByModifiedTime() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'modifiedTime', Sort.asc); + }); + } + + QueryBuilder + sortByModifiedTimeDesc() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'modifiedTime', Sort.desc); + }); + } +} + +extension DeviceAssetEntityQuerySortThenBy + on QueryBuilder { + QueryBuilder + thenByAssetId() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'assetId', Sort.asc); + }); + } + + QueryBuilder + thenByAssetIdDesc() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'assetId', Sort.desc); + }); + } + + QueryBuilder thenById() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'id', Sort.asc); + }); + } + + QueryBuilder + thenByIdDesc() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'id', Sort.desc); + }); + } + + QueryBuilder + thenByModifiedTime() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'modifiedTime', Sort.asc); + }); + } + + QueryBuilder + thenByModifiedTimeDesc() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'modifiedTime', Sort.desc); + }); + } +} + +extension DeviceAssetEntityQueryWhereDistinct + on QueryBuilder { + QueryBuilder + distinctByAssetId({bool caseSensitive = true}) { + return QueryBuilder.apply(this, (query) { + return query.addDistinctBy(r'assetId', caseSensitive: caseSensitive); + }); + } + + QueryBuilder + distinctByHash() { + return QueryBuilder.apply(this, (query) { + return query.addDistinctBy(r'hash'); + }); + } + + QueryBuilder + distinctByModifiedTime() { + return QueryBuilder.apply(this, (query) { + return query.addDistinctBy(r'modifiedTime'); + }); + } +} + +extension DeviceAssetEntityQueryProperty + on QueryBuilder { + QueryBuilder idProperty() { + return QueryBuilder.apply(this, (query) { + return query.addPropertyName(r'id'); + }); + } + + QueryBuilder assetIdProperty() { + return QueryBuilder.apply(this, (query) { + return query.addPropertyName(r'assetId'); + }); + } + + QueryBuilder, QQueryOperations> hashProperty() { + return QueryBuilder.apply(this, (query) { + return query.addPropertyName(r'hash'); + }); + } + + QueryBuilder + modifiedTimeProperty() { + return QueryBuilder.apply(this, (query) { + return query.addPropertyName(r'modifiedTime'); + }); + } +} diff --git a/mobile/lib/infrastructure/repositories/device_asset.repository.dart b/mobile/lib/infrastructure/repositories/device_asset.repository.dart new file mode 100644 index 0000000000..87784ecaab --- /dev/null +++ b/mobile/lib/infrastructure/repositories/device_asset.repository.dart @@ -0,0 +1,37 @@ +import 'package:immich_mobile/domain/interfaces/device_asset.interface.dart'; +import 'package:immich_mobile/domain/models/device_asset.model.dart'; +import 'package:immich_mobile/infrastructure/entities/device_asset.entity.dart'; +import 'package:immich_mobile/infrastructure/repositories/db.repository.dart'; +import 'package:isar/isar.dart'; + +class IsarDeviceAssetRepository extends IsarDatabaseRepository + implements IDeviceAssetRepository { + final Isar _db; + + const IsarDeviceAssetRepository(this._db) : super(_db); + + @override + Future deleteIds(List ids) { + return transaction(() async { + await _db.deviceAssetEntitys.deleteAllByAssetId(ids.toList()); + }); + } + + @override + Future> getByIds(List localIds) { + return _db.deviceAssetEntitys + .where() + .anyOf(localIds, (query, id) => query.assetIdEqualTo(id)) + .findAll() + .then((value) => value.map((e) => e.toModel()).toList()); + } + + @override + Future updateAll(List assetHash) { + return transaction(() async { + await _db.deviceAssetEntitys + .putAll(assetHash.map(DeviceAssetEntity.fromDto).toList()); + return true; + }); + } +} diff --git a/mobile/lib/interfaces/asset.interface.dart b/mobile/lib/interfaces/asset.interface.dart index ed524c4f35..76744c9172 100644 --- a/mobile/lib/interfaces/asset.interface.dart +++ b/mobile/lib/interfaces/asset.interface.dart @@ -1,6 +1,5 @@ import 'package:immich_mobile/entities/album.entity.dart'; import 'package:immich_mobile/entities/asset.entity.dart'; -import 'package:immich_mobile/entities/device_asset.entity.dart'; import 'package:immich_mobile/interfaces/database.interface.dart'; abstract interface class IAssetRepository implements IDatabaseRepository { @@ -50,10 +49,6 @@ abstract interface class IAssetRepository implements IDatabaseRepository { int limit = 100, }); - Future> getDeviceAssetsById(List ids); - - Future upsertDeviceAssets(List deviceAssets); - Future upsertDuplicatedAssets(Iterable duplicatedAssets); Future> getAllDuplicatedAssetIds(); diff --git a/mobile/lib/providers/infrastructure/device_asset.provider.dart b/mobile/lib/providers/infrastructure/device_asset.provider.dart new file mode 100644 index 0000000000..5fa532b9ec --- /dev/null +++ b/mobile/lib/providers/infrastructure/device_asset.provider.dart @@ -0,0 +1,8 @@ +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/domain/interfaces/device_asset.interface.dart'; +import 'package:immich_mobile/infrastructure/repositories/device_asset.repository.dart'; +import 'package:immich_mobile/providers/infrastructure/db.provider.dart'; + +final deviceAssetRepositoryProvider = Provider( + (ref) => IsarDeviceAssetRepository(ref.watch(isarProvider)), +); diff --git a/mobile/lib/repositories/asset.repository.dart b/mobile/lib/repositories/asset.repository.dart index d9e8897e97..cda2b25e4d 100644 --- a/mobile/lib/repositories/asset.repository.dart +++ b/mobile/lib/repositories/asset.repository.dart @@ -1,12 +1,7 @@ -import 'dart:io'; - import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/entities/album.entity.dart'; -import 'package:immich_mobile/entities/android_device_asset.entity.dart'; import 'package:immich_mobile/entities/asset.entity.dart'; -import 'package:immich_mobile/entities/device_asset.entity.dart'; import 'package:immich_mobile/entities/duplicated_asset.entity.dart'; -import 'package:immich_mobile/entities/ios_device_asset.entity.dart'; import 'package:immich_mobile/infrastructure/entities/exif.entity.dart'; import 'package:immich_mobile/interfaces/asset.interface.dart'; import 'package:immich_mobile/providers/db.provider.dart'; @@ -158,19 +153,6 @@ class AssetRepository extends DatabaseRepository implements IAssetRepository { return _getMatchesImpl(query, fastHash(ownerId), assets, limit); } - @override - Future> getDeviceAssetsById(List ids) => - Platform.isAndroid - ? db.androidDeviceAssets.getAll(ids.cast()) - : db.iOSDeviceAssets.getAllById(ids.cast()); - - @override - Future upsertDeviceAssets(List deviceAssets) => txn( - () => Platform.isAndroid - ? db.androidDeviceAssets.putAll(deviceAssets.cast()) - : db.iOSDeviceAssets.putAll(deviceAssets.cast()), - ); - @override Future update(Asset asset) async { await txn(() => asset.put(db)); diff --git a/mobile/lib/services/hash.service.dart b/mobile/lib/services/hash.service.dart index bb19340d2f..ca2b0ee37e 100644 --- a/mobile/lib/services/hash.service.dart +++ b/mobile/lib/services/hash.service.dart @@ -1,172 +1,205 @@ +// ignore_for_file: avoid-unsafe-collection-methods + +import 'dart:convert'; import 'dart:io'; import 'package:flutter/foundation.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:immich_mobile/entities/album.entity.dart'; -import 'package:immich_mobile/interfaces/album_media.interface.dart'; -import 'package:immich_mobile/interfaces/asset.interface.dart'; -import 'package:immich_mobile/repositories/album_media.repository.dart'; -import 'package:immich_mobile/repositories/asset.repository.dart'; -import 'package:immich_mobile/services/background.service.dart'; -import 'package:immich_mobile/entities/android_device_asset.entity.dart'; +import 'package:immich_mobile/constants/constants.dart'; +import 'package:immich_mobile/domain/interfaces/device_asset.interface.dart'; +import 'package:immich_mobile/domain/models/device_asset.model.dart'; import 'package:immich_mobile/entities/asset.entity.dart'; -import 'package:immich_mobile/entities/device_asset.entity.dart'; -import 'package:immich_mobile/entities/ios_device_asset.entity.dart'; -import 'package:immich_mobile/extensions/string_extensions.dart'; +import 'package:immich_mobile/providers/infrastructure/device_asset.provider.dart'; +import 'package:immich_mobile/services/background.service.dart'; import 'package:logging/logging.dart'; class HashService { - HashService( - this._assetRepository, - this._backgroundService, - this._albumMediaRepository, - ); - final IAssetRepository _assetRepository; - final BackgroundService _backgroundService; - final IAlbumMediaRepository _albumMediaRepository; - final _log = Logger('HashService'); + HashService({ + required IDeviceAssetRepository deviceAssetRepository, + required BackgroundService backgroundService, + this.batchSizeLimit = kBatchHashSizeLimit, + this.batchFileLimit = kBatchHashFileLimit, + }) : _deviceAssetRepository = deviceAssetRepository, + _backgroundService = backgroundService; - /// Returns all assets that were successfully hashed - Future> getHashedAssets( - Album album, { - int start = 0, - int end = 0x7fffffffffffffff, - DateTime? modifiedFrom, - DateTime? modifiedUntil, - Set? excludedAssets, - }) async { - final entities = await _albumMediaRepository.getAssets( - album.localId!, - start: start, - end: end, - modifiedFrom: modifiedFrom, - modifiedUntil: modifiedUntil, - ); - final filtered = excludedAssets == null - ? entities - : entities.where((e) => !excludedAssets.contains(e.localId!)).toList(); - return _hashAssets(filtered); - } + final IDeviceAssetRepository _deviceAssetRepository; + final BackgroundService _backgroundService; + final int batchSizeLimit; + final int batchFileLimit; + final _log = Logger('HashService'); /// Processes a list of local [Asset]s, storing their hash and returning only those /// that were successfully hashed. Hashes are looked up in a DB table - /// [AndroidDeviceAsset] / [IOSDeviceAsset] by local id. Only missing - /// entries are newly hashed and added to the DB table. - Future> _hashAssets(List assets) async { - const int batchFileCount = 128; - const int batchDataSize = 1024 * 1024 * 1024; // 1GB + /// [DeviceAsset] by local id. Only missing entries are newly hashed and added to the DB table. + Future> hashAssets(List assets) async { + assets.sort(Asset.compareByLocalId); - final ids = assets - .map(Platform.isAndroid ? (a) => a.localId!.toInt() : (a) => a.localId!) - .toList(); - final List hashes = - await _assetRepository.getDeviceAssetsById(ids); - final List toAdd = []; - final List toHash = []; + // Get and sort DB entries - guaranteed to be a subset of assets + final hashesInDB = await _deviceAssetRepository.getByIds( + assets.map((a) => a.localId!).toList(), + ); + hashesInDB.sort((a, b) => a.assetId.compareTo(b.assetId)); - int bytes = 0; + int dbIndex = 0; + int bytesProcessed = 0; + final hashedAssets = []; + final toBeHashed = <_AssetPath>[]; + final toBeDeleted = []; - for (int i = 0; i < assets.length; i++) { - if (hashes[i] != null) { + for (int assetIndex = 0; assetIndex < assets.length; assetIndex++) { + final asset = assets[assetIndex]; + DeviceAsset? matchingDbEntry; + + if (dbIndex < hashesInDB.length) { + final deviceAsset = hashesInDB[dbIndex]; + if (deviceAsset.assetId == asset.localId) { + matchingDbEntry = deviceAsset; + dbIndex++; + } + } + + if (matchingDbEntry != null && + matchingDbEntry.hash.isNotEmpty && + matchingDbEntry.modifiedTime.isAtSameMomentAs(asset.fileModifiedAt)) { + // Reuse the existing hash + hashedAssets.add( + asset.copyWith(checksum: base64.encode(matchingDbEntry.hash)), + ); continue; } - File? file; - - try { - file = await assets[i].local!.originFile; - } catch (error, stackTrace) { - _log.warning( - "Error getting file to hash for asset ${assets[i].localId}, name: ${assets[i].fileName}, created on: ${assets[i].fileCreatedAt}, skipping", - error, - stackTrace, - ); - } - + final file = await _tryGetAssetFile(asset); if (file == null) { - final fileName = assets[i].fileName; - - _log.warning( - "Failed to get file for asset ${assets[i].localId}, name: $fileName, created on: ${assets[i].fileCreatedAt}, skipping", - ); + // Can't access file, delete any DB entry + if (matchingDbEntry != null) { + toBeDeleted.add(matchingDbEntry.assetId); + } continue; } - bytes += await file.length(); - toHash.add(file.path); - final deviceAsset = Platform.isAndroid - ? AndroidDeviceAsset(id: ids[i] as int, hash: const []) - : IOSDeviceAsset(id: ids[i] as String, hash: const []); - toAdd.add(deviceAsset); - hashes[i] = deviceAsset; - if (toHash.length == batchFileCount || bytes >= batchDataSize) { - await _processBatch(toHash, toAdd); - toAdd.clear(); - toHash.clear(); - bytes = 0; + + bytesProcessed += await file.length(); + toBeHashed.add(_AssetPath(asset: asset, path: file.path)); + + if (_shouldProcessBatch(toBeHashed.length, bytesProcessed)) { + hashedAssets.addAll(await _processBatch(toBeHashed, toBeDeleted)); + toBeHashed.clear(); + toBeDeleted.clear(); + bytesProcessed = 0; } } - if (toHash.isNotEmpty) { - await _processBatch(toHash, toAdd); + assert(dbIndex == hashesInDB.length, "All hashes should've been processed"); + + // Process any remaining files + if (toBeHashed.isNotEmpty) { + hashedAssets.addAll(await _processBatch(toBeHashed, toBeDeleted)); } - return _getHashedAssets(assets, hashes); + + // Clean up deleted references + if (toBeDeleted.isNotEmpty) { + await _deviceAssetRepository.deleteIds(toBeDeleted); + } + + return hashedAssets; } - /// Processes a batch of files and saves any successfully hashed - /// values to the DB table. - Future _processBatch( - final List toHash, - final List toAdd, + bool _shouldProcessBatch(int assetCount, int bytesProcessed) => + assetCount >= batchFileLimit || bytesProcessed >= batchSizeLimit; + + Future _tryGetAssetFile(Asset asset) async { + try { + final file = await asset.local!.originFile; + if (file == null) { + _log.warning( + "Failed to get file for asset ${asset.localId ?? ''}, name: ${asset.fileName}, created on: ${asset.fileCreatedAt}, skipping", + ); + return null; + } + return file; + } catch (error, stackTrace) { + _log.warning( + "Error getting file to hash for asset ${asset.localId ?? ''}, name: ${asset.fileName}, created on: ${asset.fileCreatedAt}, skipping", + error, + stackTrace, + ); + return null; + } + } + + /// Processes a batch of files and returns a list of successfully hashed assets after saving + /// them in [DeviceAssetToHash] for future retrieval + Future> _processBatch( + List<_AssetPath> toBeHashed, + List toBeDeleted, ) async { - final hashes = await _hashFiles(toHash); - bool anyNull = false; - for (int j = 0; j < hashes.length; j++) { - if (hashes[j]?.length == 20) { - toAdd[j].hash = hashes[j]!; + _log.info("Hashing ${toBeHashed.length} files"); + final hashes = await _hashFiles(toBeHashed.map((e) => e.path).toList()); + assert( + hashes.length == toBeHashed.length, + "Number of Hashes returned from platform should be the same as the input", + ); + + final hashedAssets = []; + final toBeAdded = []; + + for (final (index, hash) in hashes.indexed) { + final asset = toBeHashed.elementAtOrNull(index)?.asset; + if (asset != null && hash?.length == 20) { + hashedAssets.add(asset.copyWith(checksum: base64.encode(hash!))); + toBeAdded.add( + DeviceAsset( + assetId: asset.localId!, + hash: hash, + modifiedTime: asset.fileModifiedAt, + ), + ); } else { - _log.warning("Failed to hash file ${toHash[j]}, skipping"); - anyNull = true; + _log.warning("Failed to hash file ${asset?.localId ?? ''}"); + if (asset != null) { + toBeDeleted.add(asset.localId!); + } } } - final validHashes = anyNull - ? toAdd.where((e) => e.hash.length == 20).toList(growable: false) - : toAdd; - await _assetRepository - .transaction(() => _assetRepository.upsertDeviceAssets(validHashes)); - _log.fine("Hashed ${validHashes.length}/${toHash.length} assets"); + // Update the DB for future retrieval + await _deviceAssetRepository.transaction(() async { + await _deviceAssetRepository.updateAll(toBeAdded); + await _deviceAssetRepository.deleteIds(toBeDeleted); + }); + + _log.fine("Hashed ${hashedAssets.length}/${toBeHashed.length} assets"); + return hashedAssets; } - /// Hashes the given files and returns a list of the same length - /// files that could not be hashed have a `null` value + /// Hashes the given files and returns a list of the same length. + /// Files that could not be hashed will have a `null` value Future> _hashFiles(List paths) async { - final List? hashes = - await _backgroundService.digestFiles(paths); - if (hashes == null) { - throw Exception("Hashing ${paths.length} files failed"); - } - return hashes; - } - - /// Returns all successfully hashed [Asset]s with their hash value set - List _getHashedAssets( - List assets, - List hashes, - ) { - final List result = []; - for (int i = 0; i < assets.length; i++) { - if (hashes[i] != null && hashes[i]!.hash.isNotEmpty) { - assets[i].byteHash = hashes[i]!.hash; - result.add(assets[i]); + try { + final hashes = await _backgroundService.digestFiles(paths); + if (hashes != null) { + return hashes; } + _log.severe("Hashing ${paths.length} files failed"); + } catch (e, s) { + _log.severe("Error occurred while hashing assets", e, s); } - return result; + return List.filled(paths.length, null); + } +} + +class _AssetPath { + final Asset asset; + final String path; + + const _AssetPath({required this.asset, required this.path}); + + _AssetPath copyWith({Asset? asset, String? path}) { + return _AssetPath(asset: asset ?? this.asset, path: path ?? this.path); } } final hashServiceProvider = Provider( (ref) => HashService( - ref.watch(assetRepositoryProvider), - ref.watch(backgroundServiceProvider), - ref.watch(albumMediaRepositoryProvider), + deviceAssetRepository: ref.watch(deviceAssetRepositoryProvider), + backgroundService: ref.watch(backgroundServiceProvider), ), ); diff --git a/mobile/lib/services/sync.service.dart b/mobile/lib/services/sync.service.dart index a05d4b648e..f2b16b080a 100644 --- a/mobile/lib/services/sync.service.dart +++ b/mobile/lib/services/sync.service.dart @@ -577,15 +577,18 @@ class SyncService { Set? excludedAssets, bool forceRefresh = false, ]) async { + _log.info("Syncing a local album to DB: ${deviceAlbum.name}"); if (!forceRefresh && !await _hasAlbumChangeOnDevice(deviceAlbum, dbAlbum)) { - _log.fine( + _log.info( "Local album ${deviceAlbum.name} has not changed. Skipping sync.", ); return false; } + _log.info("Local album ${deviceAlbum.name} has changed. Syncing..."); if (!forceRefresh && excludedAssets == null && await _syncDeviceAlbumFast(deviceAlbum, dbAlbum)) { + _log.info("Fast synced local album ${deviceAlbum.name} to DB"); return true; } // general case, e.g. some assets have been deleted or there are excluded albums on iOS @@ -598,7 +601,7 @@ class SyncService { assert(inDb.isSorted(Asset.compareByChecksum), "inDb not sorted!"); final int assetCountOnDevice = await _albumMediaRepository.getAssetCount(deviceAlbum.localId!); - final List onDevice = await _hashService.getHashedAssets( + final List onDevice = await _getHashedAssets( deviceAlbum, excludedAssets: excludedAssets, ); @@ -611,7 +614,7 @@ class SyncService { dbAlbum.name == deviceAlbum.name && dbAlbum.modifiedAt.isAtSameMomentAs(deviceAlbum.modifiedAt)) { // changes only affeted excluded albums - _log.fine( + _log.info( "Only excluded assets in local album ${deviceAlbum.name} changed. Stopping sync.", ); if (assetCountOnDevice != @@ -626,11 +629,11 @@ class SyncService { } return false; } - _log.fine( + _log.info( "Syncing local album ${deviceAlbum.name}. ${toAdd.length} assets to add, ${toUpdate.length} to update, ${toDelete.length} to delete", ); final (existingInDb, updated) = await _linkWithExistingFromDb(toAdd); - _log.fine( + _log.info( "Linking assets to add with existing from db. ${existingInDb.length} existing, ${updated.length} to update", ); deleteCandidates.addAll(toDelete); @@ -667,6 +670,9 @@ class SyncService { /// returns `true` if successful, else `false` Future _syncDeviceAlbumFast(Album deviceAlbum, Album dbAlbum) async { if (!deviceAlbum.modifiedAt.isAfter(dbAlbum.modifiedAt)) { + _log.info( + "Local album ${deviceAlbum.name} has not changed. Skipping sync.", + ); return false; } final int totalOnDevice = @@ -676,15 +682,21 @@ class SyncService { ?.assetCount ?? 0; if (totalOnDevice <= lastKnownTotal) { + _log.info( + "Local album ${deviceAlbum.name} totalOnDevice is less than lastKnownTotal. Skipping sync.", + ); return false; } - final List newAssets = await _hashService.getHashedAssets( + final List newAssets = await _getHashedAssets( deviceAlbum, modifiedFrom: dbAlbum.modifiedAt.add(const Duration(seconds: 1)), modifiedUntil: deviceAlbum.modifiedAt, ); if (totalOnDevice != lastKnownTotal + newAssets.length) { + _log.info( + "Local album ${deviceAlbum.name} totalOnDevice is not equal to lastKnownTotal + newAssets.length. Skipping sync.", + ); return false; } dbAlbum.modifiedAt = deviceAlbum.modifiedAt; @@ -719,8 +731,8 @@ class SyncService { List existing, [ Set? excludedAssets, ]) async { - _log.info("Syncing a new local album to DB: ${album.name}"); - final assets = await _hashService.getHashedAssets( + _log.info("Adding a new local album to DB: ${album.name}"); + final assets = await _getHashedAssets( album, excludedAssets: excludedAssets, ); @@ -824,6 +836,28 @@ class SyncService { } } + /// Returns all assets that were successfully hashed + Future> _getHashedAssets( + Album album, { + int start = 0, + int end = 0x7fffffffffffffff, + DateTime? modifiedFrom, + DateTime? modifiedUntil, + Set? excludedAssets, + }) async { + final entities = await _albumMediaRepository.getAssets( + album.localId!, + start: start, + end: end, + modifiedFrom: modifiedFrom, + modifiedUntil: modifiedUntil, + ); + final filtered = excludedAssets == null + ? entities + : entities.where((e) => !excludedAssets.contains(e.localId!)).toList(); + return _hashService.hashAssets(filtered); + } + List _removeDuplicates(List assets) { final int before = assets.length; assets.sort(Asset.compareByOwnerChecksumCreatedModified); diff --git a/mobile/lib/utils/bootstrap.dart b/mobile/lib/utils/bootstrap.dart index 21231becf6..dec48582b3 100644 --- a/mobile/lib/utils/bootstrap.dart +++ b/mobile/lib/utils/bootstrap.dart @@ -10,6 +10,7 @@ import 'package:immich_mobile/entities/backup_album.entity.dart'; import 'package:immich_mobile/entities/duplicated_asset.entity.dart'; import 'package:immich_mobile/entities/etag.entity.dart'; import 'package:immich_mobile/entities/ios_device_asset.entity.dart'; +import 'package:immich_mobile/infrastructure/entities/device_asset.entity.dart'; import 'package:immich_mobile/infrastructure/entities/exif.entity.dart'; import 'package:immich_mobile/infrastructure/entities/log.entity.dart'; import 'package:immich_mobile/infrastructure/entities/store.entity.dart'; @@ -39,6 +40,7 @@ abstract final class Bootstrap { ETagSchema, if (Platform.isAndroid) AndroidDeviceAssetSchema, if (Platform.isIOS) IOSDeviceAssetSchema, + DeviceAssetEntitySchema, ], directory: dir.path, maxSizeMiB: 1024, diff --git a/mobile/lib/utils/diff.dart b/mobile/lib/utils/diff.dart index a36902d8c7..ea20de16cc 100644 --- a/mobile/lib/utils/diff.dart +++ b/mobile/lib/utils/diff.dart @@ -75,3 +75,17 @@ bool diffSortedListsSync( } return diff; } + +int compareToNullable(T? a, T? b) { + if (a == null && b == null) { + return 0; + } + + if (a == null) { + return 1; + } + if (b == null) { + return -1; + } + return a.compareTo(b); +} diff --git a/mobile/lib/utils/migration.dart b/mobile/lib/utils/migration.dart index 3e73ab445b..bebd7a027b 100644 --- a/mobile/lib/utils/migration.dart +++ b/mobile/lib/utils/migration.dart @@ -1,40 +1,51 @@ -import 'dart:async'; +// ignore_for_file: avoid-unsafe-collection-methods +import 'dart:async'; +import 'dart:io'; + +import 'package:flutter/widgets.dart'; import 'package:immich_mobile/domain/models/store.model.dart'; import 'package:immich_mobile/entities/album.entity.dart'; +import 'package:immich_mobile/entities/android_device_asset.entity.dart'; import 'package:immich_mobile/entities/asset.entity.dart'; import 'package:immich_mobile/entities/etag.entity.dart'; +import 'package:immich_mobile/entities/ios_device_asset.entity.dart'; import 'package:immich_mobile/entities/store.entity.dart'; +import 'package:immich_mobile/infrastructure/entities/device_asset.entity.dart'; import 'package:immich_mobile/infrastructure/entities/exif.entity.dart'; import 'package:immich_mobile/infrastructure/entities/store.entity.dart'; import 'package:immich_mobile/infrastructure/entities/user.entity.dart'; +import 'package:immich_mobile/utils/diff.dart'; import 'package:isar/isar.dart'; -const int targetVersion = 9; +const int targetVersion = 10; Future migrateDatabaseIfNeeded(Isar db) async { - final int version = Store.get(StoreKey.version, 1); + final int version = Store.get(StoreKey.version, targetVersion); if (version < 9) { - await Store.put(StoreKey.version, version); + await Store.put(StoreKey.version, targetVersion); final value = await db.storeValues.get(StoreKey.currentUser.id); if (value != null) { final id = value.intValue; - if (id == null) { - return; + if (id != null) { + await db.writeTxn(() async { + final user = await db.users.get(id); + await db.storeValues + .put(StoreValue(StoreKey.currentUser.id, strValue: user?.id)); + }); } - await db.writeTxn(() async { - final user = await db.users.get(id); - await db.storeValues - .put(StoreValue(StoreKey.currentUser.id, strValue: user?.id)); - }); } - // Do not clear other entities - return; } - if (version < targetVersion) { - _migrateTo(db, targetVersion); + if (version < 10) { + await Store.put(StoreKey.version, targetVersion); + await _migrateDeviceAsset(db); + } + + final shouldTruncate = version < 8 && version < targetVersion; + if (shouldTruncate) { + await _migrateTo(db, targetVersion); } } @@ -49,3 +60,59 @@ Future _migrateTo(Isar db, int version) async { }); await Store.put(StoreKey.version, version); } + +Future _migrateDeviceAsset(Isar db) async { + final ids = Platform.isAndroid + ? (await db.androidDeviceAssets.where().findAll()) + .map((a) => _DeviceAsset(assetId: a.id.toString(), hash: a.hash)) + .toList() + : (await db.iOSDeviceAssets.where().findAll()) + .map((i) => _DeviceAsset(assetId: i.id, hash: i.hash)) + .toList(); + final localAssets = (await db.assets + .where() + .anyOf(ids, (query, id) => query.localIdEqualTo(id.assetId)) + .findAll()) + .map((a) => _DeviceAsset(assetId: a.localId!, dateTime: a.fileModifiedAt)) + .toList(); + debugPrint("Device Asset Ids length - ${ids.length}"); + debugPrint("Local Asset Ids length - ${localAssets.length}"); + ids.sort((a, b) => a.assetId.compareTo(b.assetId)); + localAssets.sort((a, b) => a.assetId.compareTo(b.assetId)); + final List toAdd = []; + await diffSortedLists( + ids, + localAssets, + compare: (a, b) => a.assetId.compareTo(b.assetId), + both: (deviceAsset, asset) { + toAdd.add( + DeviceAssetEntity( + assetId: deviceAsset.assetId, + hash: deviceAsset.hash!, + modifiedTime: asset.dateTime!, + ), + ); + return false; + }, + onlyFirst: (deviceAsset) { + debugPrint( + 'DeviceAsset not found in local assets: ${deviceAsset.assetId}', + ); + }, + onlySecond: (asset) { + debugPrint('Local asset not found in DeviceAsset: ${asset.assetId}'); + }, + ); + debugPrint("Total number of device assets migrated - ${toAdd.length}"); + await db.writeTxn(() async { + await db.deviceAssetEntitys.putAll(toAdd); + }); +} + +class _DeviceAsset { + final String assetId; + final List? hash; + final DateTime? dateTime; + + const _DeviceAsset({required this.assetId, this.hash, this.dateTime}); +} diff --git a/mobile/pubspec.lock b/mobile/pubspec.lock index e79d9f4084..7c8348726f 100644 --- a/mobile/pubspec.lock +++ b/mobile/pubspec.lock @@ -463,7 +463,7 @@ packages: source: hosted version: "2.1.4" file: - dependency: transitive + dependency: "direct dev" description: name: file sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4 diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index d4ab110a3e..e939c65836 100644 --- a/mobile/pubspec.yaml +++ b/mobile/pubspec.yaml @@ -102,6 +102,7 @@ dev_dependencies: immich_mobile_immich_lint: path: './immich_lint' fake_async: ^1.3.1 + file: ^7.0.1 # for MemoryFileSystem # Drift generator drift_dev: ^2.23.1 diff --git a/mobile/test/domain/services/hash_service_test.dart b/mobile/test/domain/services/hash_service_test.dart new file mode 100644 index 0000000000..2da41cd704 --- /dev/null +++ b/mobile/test/domain/services/hash_service_test.dart @@ -0,0 +1,425 @@ +import 'dart:convert'; +import 'dart:io'; +import 'dart:math'; + +import 'package:collection/collection.dart'; +import 'package:file/memory.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:immich_mobile/domain/interfaces/device_asset.interface.dart'; +import 'package:immich_mobile/domain/models/device_asset.model.dart'; +import 'package:immich_mobile/entities/asset.entity.dart'; +import 'package:immich_mobile/services/background.service.dart'; +import 'package:immich_mobile/services/hash.service.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:photo_manager/photo_manager.dart'; + +import '../../fixtures/asset.stub.dart'; +import '../../infrastructure/repository.mock.dart'; +import '../../service.mocks.dart'; + +class MockAsset extends Mock implements Asset {} + +class MockAssetEntity extends Mock implements AssetEntity {} + +void main() { + late HashService sut; + late BackgroundService mockBackgroundService; + late IDeviceAssetRepository mockDeviceAssetRepository; + + setUp(() { + mockBackgroundService = MockBackgroundService(); + mockDeviceAssetRepository = MockDeviceAssetRepository(); + + sut = HashService( + deviceAssetRepository: mockDeviceAssetRepository, + backgroundService: mockBackgroundService, + ); + + when(() => mockDeviceAssetRepository.transaction(any())) + .thenAnswer((_) async { + final capturedCallback = verify( + () => mockDeviceAssetRepository.transaction(captureAny()), + ).captured; + // Invoke the transaction callback + await (capturedCallback.firstOrNull as Future Function()?)?.call(); + }); + when(() => mockDeviceAssetRepository.updateAll(any())) + .thenAnswer((_) async => true); + when(() => mockDeviceAssetRepository.deleteIds(any())) + .thenAnswer((_) async => true); + }); + + group("HashService: No DeviceAsset entry", () { + test("hash successfully", () async { + final (mockAsset, file, deviceAsset, hash) = + await _createAssetMock(AssetStub.image1); + + when(() => mockBackgroundService.digestFiles([file.path])) + .thenAnswer((_) async => [hash]); + // No DB entries for this asset + when( + () => mockDeviceAssetRepository.getByIds([AssetStub.image1.localId!]), + ).thenAnswer((_) async => []); + + final result = await sut.hashAssets([mockAsset]); + + // Verify we stored the new hash in DB + when(() => mockDeviceAssetRepository.transaction(any())) + .thenAnswer((_) async { + final capturedCallback = verify( + () => mockDeviceAssetRepository.transaction(captureAny()), + ).captured; + // Invoke the transaction callback + await (capturedCallback.firstOrNull as Future Function()?) + ?.call(); + verify( + () => mockDeviceAssetRepository.updateAll([ + deviceAsset.copyWith(modifiedTime: AssetStub.image1.fileModifiedAt), + ]), + ).called(1); + verify(() => mockDeviceAssetRepository.deleteIds([])).called(1); + }); + expect( + result, + [AssetStub.image1.copyWith(checksum: base64.encode(hash))], + ); + }); + }); + + group("HashService: Has DeviceAsset entry", () { + test("when the asset is not modified", () async { + final hash = utf8.encode("image1-hash"); + + when( + () => mockDeviceAssetRepository.getByIds([AssetStub.image1.localId!]), + ).thenAnswer( + (_) async => [ + DeviceAsset( + assetId: AssetStub.image1.localId!, + hash: hash, + modifiedTime: AssetStub.image1.fileModifiedAt, + ), + ], + ); + final result = await sut.hashAssets([AssetStub.image1]); + + verifyNever(() => mockBackgroundService.digestFiles(any())); + verifyNever(() => mockBackgroundService.digestFile(any())); + verifyNever(() => mockDeviceAssetRepository.updateAll(any())); + verifyNever(() => mockDeviceAssetRepository.deleteIds(any())); + + expect(result, [ + AssetStub.image1.copyWith(checksum: base64.encode(hash)), + ]); + }); + + test("hashed successful when asset is modified", () async { + final (mockAsset, file, deviceAsset, hash) = + await _createAssetMock(AssetStub.image1); + + when(() => mockBackgroundService.digestFiles([file.path])) + .thenAnswer((_) async => [hash]); + when( + () => mockDeviceAssetRepository.getByIds([AssetStub.image1.localId!]), + ).thenAnswer((_) async => [deviceAsset]); + + final result = await sut.hashAssets([mockAsset]); + + when(() => mockDeviceAssetRepository.transaction(any())) + .thenAnswer((_) async { + final capturedCallback = verify( + () => mockDeviceAssetRepository.transaction(captureAny()), + ).captured; + // Invoke the transaction callback + await (capturedCallback.firstOrNull as Future Function()?) + ?.call(); + verify( + () => mockDeviceAssetRepository.updateAll([ + deviceAsset.copyWith(modifiedTime: AssetStub.image1.fileModifiedAt), + ]), + ).called(1); + verify(() => mockDeviceAssetRepository.deleteIds([])).called(1); + }); + + verify(() => mockBackgroundService.digestFiles([file.path])).called(1); + + expect(result, [ + AssetStub.image1.copyWith(checksum: base64.encode(hash)), + ]); + }); + }); + + group("HashService: Cleanup", () { + late Asset mockAsset; + late Uint8List hash; + late DeviceAsset deviceAsset; + late File file; + + setUp(() async { + (mockAsset, file, deviceAsset, hash) = + await _createAssetMock(AssetStub.image1); + + when(() => mockBackgroundService.digestFiles([file.path])) + .thenAnswer((_) async => [hash]); + when( + () => mockDeviceAssetRepository.getByIds([AssetStub.image1.localId!]), + ).thenAnswer((_) async => [deviceAsset]); + }); + + test("cleanups DeviceAsset when local file cannot be obtained", () async { + when(() => mockAsset.local).thenThrow(Exception("File not found")); + final result = await sut.hashAssets([mockAsset]); + + verifyNever(() => mockBackgroundService.digestFiles(any())); + verifyNever(() => mockBackgroundService.digestFile(any())); + verifyNever(() => mockDeviceAssetRepository.updateAll(any())); + verify( + () => mockDeviceAssetRepository.deleteIds([AssetStub.image1.localId!]), + ).called(1); + + expect(result, isEmpty); + }); + + test("cleanups DeviceAsset when hashing failed", () async { + when(() => mockDeviceAssetRepository.transaction(any())) + .thenAnswer((_) async { + final capturedCallback = verify( + () => mockDeviceAssetRepository.transaction(captureAny()), + ).captured; + // Invoke the transaction callback + await (capturedCallback.firstOrNull as Future Function()?) + ?.call(); + + // Verify the callback inside the transaction because, doing it outside results + // in a small delay before the callback is invoked, resulting in other LOCs getting executed + // resulting in an incorrect state + // + // i.e, consider the following piece of code + // await _deviceAssetRepository.transaction(() async { + // await _deviceAssetRepository.updateAll(toBeAdded); + // await _deviceAssetRepository.deleteIds(toBeDeleted); + // }); + // toBeDeleted.clear(); + // since the transaction method is mocked, the callback is not invoked until it is captured + // and executed manually in the next event loop. However, the toBeDeleted.clear() is executed + // immediately once the transaction stub is executed, resulting in the deleteIds method being + // called with an empty list. + // + // To avoid this, we capture the callback and execute it within the transaction stub itself + // and verify the results inside the transaction stub + verify(() => mockDeviceAssetRepository.updateAll([])).called(1); + verify( + () => + mockDeviceAssetRepository.deleteIds([AssetStub.image1.localId!]), + ).called(1); + }); + + when(() => mockBackgroundService.digestFiles([file.path])).thenAnswer( + // Invalid hash, length != 20 + (_) async => [Uint8List.fromList(hash.slice(2).toList())], + ); + + final result = await sut.hashAssets([mockAsset]); + + verify(() => mockBackgroundService.digestFiles([file.path])).called(1); + expect(result, isEmpty); + }); + }); + + group("HashService: Batch processing", () { + test("processes assets in batches when size limit is reached", () async { + // Setup multiple assets with large file sizes + final (mock1, mock2, mock3) = await ( + _createAssetMock(AssetStub.image1), + _createAssetMock(AssetStub.image2), + _createAssetMock(AssetStub.image3), + ).wait; + + final (asset1, file1, deviceAsset1, hash1) = mock1; + final (asset2, file2, deviceAsset2, hash2) = mock2; + final (asset3, file3, deviceAsset3, hash3) = mock3; + + when(() => mockDeviceAssetRepository.getByIds(any())) + .thenAnswer((_) async => []); + + // Setup for multiple batch processing calls + when(() => mockBackgroundService.digestFiles([file1.path, file2.path])) + .thenAnswer((_) async => [hash1, hash2]); + when(() => mockBackgroundService.digestFiles([file3.path])) + .thenAnswer((_) async => [hash3]); + + final size = await file1.length() + await file2.length(); + + sut = HashService( + deviceAssetRepository: mockDeviceAssetRepository, + backgroundService: mockBackgroundService, + batchSizeLimit: size, + ); + final result = await sut.hashAssets([asset1, asset2, asset3]); + + // Verify multiple batch process calls + verify(() => mockBackgroundService.digestFiles([file1.path, file2.path])) + .called(1); + verify(() => mockBackgroundService.digestFiles([file3.path])).called(1); + + expect( + result, + [ + AssetStub.image1.copyWith(checksum: base64.encode(hash1)), + AssetStub.image2.copyWith(checksum: base64.encode(hash2)), + AssetStub.image3.copyWith(checksum: base64.encode(hash3)), + ], + ); + }); + + test("processes assets in batches when file limit is reached", () async { + // Setup multiple assets with large file sizes + final (mock1, mock2, mock3) = await ( + _createAssetMock(AssetStub.image1), + _createAssetMock(AssetStub.image2), + _createAssetMock(AssetStub.image3), + ).wait; + + final (asset1, file1, deviceAsset1, hash1) = mock1; + final (asset2, file2, deviceAsset2, hash2) = mock2; + final (asset3, file3, deviceAsset3, hash3) = mock3; + + when(() => mockDeviceAssetRepository.getByIds(any())) + .thenAnswer((_) async => []); + + when(() => mockBackgroundService.digestFiles([file1.path])) + .thenAnswer((_) async => [hash1]); + when(() => mockBackgroundService.digestFiles([file2.path])) + .thenAnswer((_) async => [hash2]); + when(() => mockBackgroundService.digestFiles([file3.path])) + .thenAnswer((_) async => [hash3]); + + sut = HashService( + deviceAssetRepository: mockDeviceAssetRepository, + backgroundService: mockBackgroundService, + batchFileLimit: 1, + ); + final result = await sut.hashAssets([asset1, asset2, asset3]); + + // Verify multiple batch process calls + verify(() => mockBackgroundService.digestFiles([file1.path])).called(1); + verify(() => mockBackgroundService.digestFiles([file2.path])).called(1); + verify(() => mockBackgroundService.digestFiles([file3.path])).called(1); + + expect( + result, + [ + AssetStub.image1.copyWith(checksum: base64.encode(hash1)), + AssetStub.image2.copyWith(checksum: base64.encode(hash2)), + AssetStub.image3.copyWith(checksum: base64.encode(hash3)), + ], + ); + }); + + test("HashService: Sort & Process different states", () async { + final (asset1, file1, deviceAsset1, hash1) = + await _createAssetMock(AssetStub.image1); // Will need rehashing + final (asset2, file2, deviceAsset2, hash2) = + await _createAssetMock(AssetStub.image2); // Will have matching hash + final (asset3, file3, deviceAsset3, hash3) = + await _createAssetMock(AssetStub.image3); // No DB entry + final asset4 = + AssetStub.image3.copyWith(localId: "image4"); // Cannot be hashed + + when(() => mockBackgroundService.digestFiles([file1.path, file3.path])) + .thenAnswer((_) async => [hash1, hash3]); + // DB entries are not sorted and a dummy entry added + when( + () => mockDeviceAssetRepository.getByIds([ + AssetStub.image1.localId!, + AssetStub.image2.localId!, + AssetStub.image3.localId!, + asset4.localId!, + ]), + ).thenAnswer( + (_) async => [ + // Same timestamp to reuse deviceAsset + deviceAsset2.copyWith(modifiedTime: asset2.fileModifiedAt), + deviceAsset1, + deviceAsset3.copyWith(assetId: asset4.localId!), + ], + ); + + final result = await sut.hashAssets([asset1, asset2, asset3, asset4]); + + // Verify correct processing of all assets + verify(() => mockBackgroundService.digestFiles([file1.path, file3.path])) + .called(1); + expect(result.length, 3); + expect(result, [ + AssetStub.image2.copyWith(checksum: base64.encode(hash2)), + AssetStub.image1.copyWith(checksum: base64.encode(hash1)), + AssetStub.image3.copyWith(checksum: base64.encode(hash3)), + ]); + }); + + group("HashService: Edge cases", () { + test("handles empty list of assets", () async { + when(() => mockDeviceAssetRepository.getByIds(any())) + .thenAnswer((_) async => []); + + final result = await sut.hashAssets([]); + + verifyNever(() => mockBackgroundService.digestFiles(any())); + verifyNever(() => mockDeviceAssetRepository.updateAll(any())); + verifyNever(() => mockDeviceAssetRepository.deleteIds(any())); + + expect(result, isEmpty); + }); + + test("handles all file access failures", () async { + // No DB entries + when( + () => mockDeviceAssetRepository.getByIds( + [AssetStub.image1.localId!, AssetStub.image2.localId!], + ), + ).thenAnswer((_) async => []); + + final result = await sut.hashAssets([ + AssetStub.image1, + AssetStub.image2, + ]); + + verifyNever(() => mockBackgroundService.digestFiles(any())); + verifyNever(() => mockDeviceAssetRepository.updateAll(any())); + expect(result, isEmpty); + }); + }); + }); +} + +Future<(Asset, File, DeviceAsset, Uint8List)> _createAssetMock( + Asset asset, +) async { + final random = Random(); + final hash = + Uint8List.fromList(List.generate(20, (i) => random.nextInt(255))); + final mockAsset = MockAsset(); + final mockAssetEntity = MockAssetEntity(); + final fs = MemoryFileSystem(); + final deviceAsset = DeviceAsset( + assetId: asset.localId!, + hash: Uint8List.fromList(hash), + modifiedTime: DateTime.now(), + ); + final tmp = await fs.systemTempDirectory.createTemp(); + final file = tmp.childFile("${asset.fileName}-path"); + await file.writeAsString("${asset.fileName}-content"); + + when(() => mockAsset.localId).thenReturn(asset.localId); + when(() => mockAsset.fileName).thenReturn(asset.fileName); + when(() => mockAsset.fileCreatedAt).thenReturn(asset.fileCreatedAt); + when(() => mockAsset.fileModifiedAt).thenReturn(asset.fileModifiedAt); + when(() => mockAsset.copyWith(checksum: any(named: "checksum"))) + .thenReturn(asset.copyWith(checksum: base64.encode(hash))); + when(() => mockAsset.local).thenAnswer((_) => mockAssetEntity); + when(() => mockAssetEntity.originFile).thenAnswer((_) async => file); + + return (mockAsset, file, deviceAsset, hash); +} diff --git a/mobile/test/fixtures/asset.stub.dart b/mobile/test/fixtures/asset.stub.dart index 26108d63b2..b69b392129 100644 --- a/mobile/test/fixtures/asset.stub.dart +++ b/mobile/test/fixtures/asset.stub.dart @@ -8,8 +8,8 @@ final class AssetStub { localId: "image1", remoteId: 'image1-remote', ownerId: 1, - fileCreatedAt: DateTime.now(), - fileModifiedAt: DateTime.now(), + fileCreatedAt: DateTime(2019), + fileModifiedAt: DateTime(2020), updatedAt: DateTime.now(), durationInSeconds: 0, type: AssetType.image, @@ -34,4 +34,19 @@ final class AssetStub { isArchived: false, isTrashed: false, ); + + static final image3 = Asset( + checksum: "image3-checksum", + localId: "image3", + ownerId: 1, + fileCreatedAt: DateTime(2025), + fileModifiedAt: DateTime(2025), + updatedAt: DateTime.now(), + durationInSeconds: 60, + type: AssetType.image, + fileName: "image3.jpg", + isFavorite: true, + isArchived: false, + isTrashed: false, + ); } diff --git a/mobile/test/infrastructure/repository.mock.dart b/mobile/test/infrastructure/repository.mock.dart index c9287bfb1c..192858adff 100644 --- a/mobile/test/infrastructure/repository.mock.dart +++ b/mobile/test/infrastructure/repository.mock.dart @@ -1,3 +1,4 @@ +import 'package:immich_mobile/domain/interfaces/device_asset.interface.dart'; import 'package:immich_mobile/domain/interfaces/log.interface.dart'; import 'package:immich_mobile/domain/interfaces/store.interface.dart'; import 'package:immich_mobile/domain/interfaces/user.interface.dart'; @@ -10,5 +11,8 @@ class MockLogRepository extends Mock implements ILogRepository {} class MockUserRepository extends Mock implements IUserRepository {} +class MockDeviceAssetRepository extends Mock + implements IDeviceAssetRepository {} + // API Repos class MockUserApiRepository extends Mock implements IUserApiRepository {} diff --git a/mobile/test/service.mocks.dart b/mobile/test/service.mocks.dart index 33c325b105..8ee1c58609 100644 --- a/mobile/test/service.mocks.dart +++ b/mobile/test/service.mocks.dart @@ -1,4 +1,5 @@ import 'package:immich_mobile/services/api.service.dart'; +import 'package:immich_mobile/services/background.service.dart'; import 'package:immich_mobile/services/entity.service.dart'; import 'package:immich_mobile/services/hash.service.dart'; import 'package:immich_mobile/services/network.service.dart'; @@ -17,3 +18,5 @@ class MockEntityService extends Mock implements EntityService {} class MockNetworkService extends Mock implements NetworkService {} class MockSearchApi extends Mock implements SearchApi {} + +class MockBackgroundService extends Mock implements BackgroundService {} diff --git a/mobile/test/test_utils.dart b/mobile/test/test_utils.dart index a5a89a2440..c0f789795c 100644 --- a/mobile/test/test_utils.dart +++ b/mobile/test/test_utils.dart @@ -12,6 +12,7 @@ import 'package:immich_mobile/entities/backup_album.entity.dart'; import 'package:immich_mobile/entities/duplicated_asset.entity.dart'; import 'package:immich_mobile/entities/etag.entity.dart'; import 'package:immich_mobile/entities/ios_device_asset.entity.dart'; +import 'package:immich_mobile/infrastructure/entities/device_asset.entity.dart'; import 'package:immich_mobile/infrastructure/entities/exif.entity.dart'; import 'package:immich_mobile/infrastructure/entities/log.entity.dart'; import 'package:immich_mobile/infrastructure/entities/store.entity.dart'; @@ -52,6 +53,7 @@ abstract final class TestUtils { ETagSchema, AndroidDeviceAssetSchema, IOSDeviceAssetSchema, + DeviceAssetEntitySchema, ], directory: "test/", maxSizeMiB: 1024, From 2248a38567fc02ad1f06f5a97d2cc9d92510ad3a Mon Sep 17 00:00:00 2001 From: Zack Pollard Date: Thu, 3 Apr 2025 21:32:33 +0100 Subject: [PATCH 06/52] fix: missing index and geodata import process uses normal table (#17343) * chore: add geodata indexes to table definitions * chore: rename incorrectly name geodata index * fix: import into geodata places with correct index names --- .../1743611339000-GeodataCleanup.ts | 19 ++++++ server/src/repositories/map.repository.ts | 63 ++++++++++--------- .../src/schema/tables/geodata-places.table.ts | 9 ++- 3 files changed, 59 insertions(+), 32 deletions(-) create mode 100644 server/src/migrations/1743611339000-GeodataCleanup.ts diff --git a/server/src/migrations/1743611339000-GeodataCleanup.ts b/server/src/migrations/1743611339000-GeodataCleanup.ts new file mode 100644 index 0000000000..0e25a1268e --- /dev/null +++ b/server/src/migrations/1743611339000-GeodataCleanup.ts @@ -0,0 +1,19 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class GeodataCleanup1743611339000 implements MigrationInterface { + name = 'GeodataCleanup1743611339000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER INDEX IF EXISTS "idx_geodata_places_admin2_alternate_names" RENAME TO "idx_geodata_places_alternate_names"`, + ); + await queryRunner.query(`DROP TABLE IF EXISTS "geodata_places_tmp"`); + await queryRunner.query(`DROP TABLE IF EXISTS "naturalearth_countries_tmp"`) + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER INDEX IF EXISTS "idx_geodata_places_alternate_names" RENAME TO "idx_geodata_places_admin2_alternate_names"`, + ); + } +} diff --git a/server/src/repositories/map.repository.ts b/server/src/repositories/map.repository.ts index 442225f7c8..e6a2d51b7b 100644 --- a/server/src/repositories/map.repository.ts +++ b/server/src/repositories/map.repository.ts @@ -2,7 +2,6 @@ import { Injectable } from '@nestjs/common'; import { getName } from 'i18n-iso-countries'; import { Expression, Kysely, sql, SqlBool } from 'kysely'; import { InjectKysely } from 'nestjs-kysely'; -import { randomUUID } from 'node:crypto'; import { createReadStream, existsSync } from 'node:fs'; import { readFile } from 'node:fs/promises'; import readLine from 'node:readline'; @@ -183,11 +182,6 @@ export class MapRepository { return; } - await this.db.schema.dropTable('naturalearth_countries_tmp').ifExists().execute(); - await sql`CREATE TABLE naturalearth_countries_tmp (LIKE naturalearth_countries INCLUDING ALL EXCLUDING INDEXES)`.execute( - this.db, - ); - const entities: Omit[] = []; for (const feature of geoJSONData.features) { for (const entry of feature.geometry.coordinates) { @@ -204,15 +198,18 @@ export class MapRepository { } } } - await this.db.insertInto('naturalearth_countries_tmp').values(entities).execute(); - - await sql`ALTER TABLE naturalearth_countries_tmp ADD PRIMARY KEY (id) WITH (FILLFACTOR = 100)`.execute(this.db); await this.db.transaction().execute(async (manager) => { - await manager.schema.alterTable('naturalearth_countries').renameTo('naturalearth_countries_old').execute(); + await sql`CREATE TABLE naturalearth_countries_tmp + ( + LIKE naturalearth_countries INCLUDING ALL EXCLUDING INDEXES + )`.execute(manager); + await manager.schema.dropTable('naturalearth_countries').execute(); await manager.schema.alterTable('naturalearth_countries_tmp').renameTo('naturalearth_countries').execute(); - await manager.schema.dropTable('naturalearth_countries_old').execute(); }); + + await this.db.insertInto('naturalearth_countries').values(entities).execute(); + await sql`ALTER TABLE naturalearth_countries ADD PRIMARY KEY (id) WITH (FILLFACTOR = 100)`.execute(this.db); } private async importGeodata() { @@ -222,16 +219,17 @@ export class MapRepository { this.loadAdmin(resourcePaths.geodata.admin2), ]); - await this.db.schema.dropTable('geodata_places_tmp').ifExists().execute(); - await sql`CREATE TABLE geodata_places_tmp (LIKE geodata_places INCLUDING ALL EXCLUDING INDEXES)`.execute(this.db); + await this.db.transaction().execute(async (manager) => { + await sql`CREATE TABLE geodata_places_tmp + ( + LIKE geodata_places INCLUDING ALL EXCLUDING INDEXES + )`.execute(manager); + await manager.schema.dropTable('geodata_places').execute(); + await manager.schema.alterTable('geodata_places_tmp').renameTo('geodata_places').execute(); + }); + await this.loadCities500(admin1, admin2); await this.createGeodataIndices(); - - await this.db.transaction().execute(async (manager) => { - await manager.schema.alterTable('geodata_places').renameTo('geodata_places_old').execute(); - await manager.schema.alterTable('geodata_places_tmp').renameTo('geodata_places').execute(); - await manager.schema.dropTable('geodata_places_old').execute(); - }); } private async loadCities500(admin1Map: Map, admin2Map: Map) { @@ -271,7 +269,7 @@ export class MapRepository { const curLength = bufferGeodata.length; futures.push( this.db - .insertInto('geodata_places_tmp') + .insertInto('geodata_places') .values(bufferGeodata) .execute() .then(() => { @@ -290,7 +288,7 @@ export class MapRepository { } } - await this.db.insertInto('geodata_places_tmp').values(bufferGeodata).execute(); + await this.db.insertInto('geodata_places').values(bufferGeodata).execute(); } private async loadAdmin(filePath: string) { @@ -313,26 +311,31 @@ export class MapRepository { private createGeodataIndices() { return Promise.all([ - sql`ALTER TABLE geodata_places_tmp ADD PRIMARY KEY (id) WITH (FILLFACTOR = 100)`.execute(this.db), + sql`ALTER TABLE geodata_places ADD PRIMARY KEY (id) WITH (FILLFACTOR = 100)`.execute(this.db), sql` - CREATE INDEX IDX_geodata_gist_earthcoord_${sql.raw(randomUUID().replaceAll('-', '_'))} - ON geodata_places_tmp + CREATE INDEX IDX_geodata_gist_earthcoord + ON geodata_places USING gist (ll_to_earth_public(latitude, longitude)) WITH (fillfactor = 100) `.execute(this.db), this.db.schema - .createIndex(`idx_geodata_places_country_code_${randomUUID().replaceAll('-', '_')}`) - .on('geodata_places_tmp') + .createIndex(`idx_geodata_places_alternate_names`) + .on('geodata_places') + .using('gin (f_unaccent("alternateNames") gin_trgm_ops)') + .execute(), + this.db.schema + .createIndex(`idx_geodata_places_name`) + .on('geodata_places') .using('gin (f_unaccent(name) gin_trgm_ops)') .execute(), this.db.schema - .createIndex(`idx_geodata_places_country_code_${randomUUID().replaceAll('-', '_')}`) - .on('geodata_places_tmp') + .createIndex(`idx_geodata_places_admin1_name`) + .on('geodata_places') .using('gin (f_unaccent("admin1Name") gin_trgm_ops)') .execute(), this.db.schema - .createIndex(`idx_geodata_places_admin2_name_${randomUUID().replaceAll('-', '_')}`) - .on('geodata_places_tmp') + .createIndex(`idx_geodata_places_admin2_name`) + .on('geodata_places') .using('gin (f_unaccent("admin2Name") gin_trgm_ops)') .execute(), ]); diff --git a/server/src/schema/tables/geodata-places.table.ts b/server/src/schema/tables/geodata-places.table.ts index 5216a295cb..2ac4ab2780 100644 --- a/server/src/schema/tables/geodata-places.table.ts +++ b/server/src/schema/tables/geodata-places.table.ts @@ -1,6 +1,11 @@ -import { Column, PrimaryColumn, Table } from 'src/sql-tools'; +import { Column, Index, PrimaryColumn, Table } from 'src/sql-tools'; -@Table({ name: 'geodata_places', synchronize: false }) +@Index({ name: 'idx_geodata_places_alternate_names', expression: 'f_unaccent("alternateNames") gin_trgm_ops' }) +@Index({ name: 'idx_geodata_places_admin1_name', expression: 'f_unaccent("admin1Name") gin_trgm_ops' }) +@Index({ name: 'idx_geodata_places_admin2_name', expression: 'f_unaccent("admin2Name") gin_trgm_ops' }) +@Index({ name: 'idx_geodata_places_name', expression: 'f_unaccent("name") gin_trgm_ops' }) +@Index({ name: 'idx_geodata_places_gist_earthcoord', expression: 'll_to_earth_public(latitude, longitude)' }) +@Table({ name: 'idx_geodata_places', synchronize: false }) export class GeodataPlacesTable { @PrimaryColumn({ type: 'integer' }) id!: number; From 8b6a765e1245de5b7bfdaf6dc36254533757e983 Mon Sep 17 00:00:00 2001 From: bo0tzz Date: Fri, 4 Apr 2025 00:09:29 +0200 Subject: [PATCH 07/52] chore: remove demo box spec from README.md (#17367) --- README.md | 4 +--- readme_i18n/README_ar_JO.md | 4 ---- readme_i18n/README_ca_ES.md | 7 +------ readme_i18n/README_de_DE.md | 5 +---- readme_i18n/README_es_ES.md | 8 +------- readme_i18n/README_fr_FR.md | 8 +------- readme_i18n/README_it_IT.md | 8 +------- readme_i18n/README_ja_JP.md | 8 +------- readme_i18n/README_ko_KR.md | 4 +--- readme_i18n/README_nl_NL.md | 4 +--- readme_i18n/README_pt_BR.md | 6 +----- readme_i18n/README_ru_RU.md | 8 +------- readme_i18n/README_sv_SE.md | 8 +------- readme_i18n/README_th_TH.md | 4 +--- readme_i18n/README_tr_TR.md | 8 +------- readme_i18n/README_uk_UA.md | 4 +--- readme_i18n/README_vi_VN.md | 4 +--- readme_i18n/README_zh_CN.md | 4 +--- 18 files changed, 17 insertions(+), 89 deletions(-) diff --git a/README.md b/README.md index 19886f9873..459cda481c 100644 --- a/README.md +++ b/README.md @@ -61,9 +61,7 @@ ## Demo -Access the demo [here](https://demo.immich.app). The demo is running on a Free-tier Oracle VM in Amsterdam with a 2.4Ghz quad-core ARM64 CPU and 24GB RAM. - -For the mobile app, you can use `https://demo.immich.app` for the `Server Endpoint URL` +Access the demo [here](https://demo.immich.app). For the mobile app, you can use `https://demo.immich.app` for the `Server Endpoint URL`. ### Login credentials diff --git a/readme_i18n/README_ar_JO.md b/readme_i18n/README_ar_JO.md index 90936cdefa..4e7ba99dd2 100644 --- a/readme_i18n/README_ar_JO.md +++ b/readme_i18n/README_ar_JO.md @@ -74,10 +74,6 @@ email: demo@immich.app password: demo ``` -``` -Spec: Free-tier Oracle VM - Amsterdam - 2.4Ghz quad-core ARM64 CPU, 24GB RAM -``` - ## نشاط المساهمة ![Activities](https://repobeats.axiom.co/api/embed/9e86d9dc3ddd137161f2f6d2e758d7863b1789cb.svg "Repobeats analytics image") diff --git a/readme_i18n/README_ca_ES.md b/readme_i18n/README_ca_ES.md index 5daa12c477..0b8dd5999b 100644 --- a/readme_i18n/README_ca_ES.md +++ b/readme_i18n/README_ca_ES.md @@ -58,18 +58,13 @@ Podeu trobar la documentació principal, incloent les guies d'instal·lació, a ## Demo -Podeu accedir a la demostració web a https://demo.immich.app - -Per a l'aplicació mòbil, podeu utilitzar `https://demo.immich.app` com a "URL de punt final del servidor". +Podeu accedir a la demostració web a https://demo.immich.app. Per a l'aplicació mòbil, podeu utilitzar `https://demo.immich.app` com a "URL de punt final del servidor". ```bash title="Credencials de la demo" Les credencials email: demo@immich.app contrasenya: demo ``` -``` -Spec: Free-tier Oracle VM - Amsterdam - 2.4Ghz quad-core ARM64 CPU, 24GB RAM -``` # Funcionalitats diff --git a/readme_i18n/README_de_DE.md b/readme_i18n/README_de_DE.md index 3df41ad28f..d24818b881 100644 --- a/readme_i18n/README_de_DE.md +++ b/readme_i18n/README_de_DE.md @@ -61,10 +61,7 @@ ## Demo -Die Web-Demo kannst Du unter https://demo.immich.app finden. -Die Demo läuft auf einer Free Tier Oracle VM in Amsterdam mit einer 2.4Ghz Quad-Core ARM64 CPU und 24GB RAM. - -Für die Handy-App kannst Du `https://demo.immich.app` als `Server Endpoint URL` angeben. +Die Web-Demo kannst Du unter https://demo.immich.app finden. Für die Handy-App kannst Du `https://demo.immich.app` als `Server Endpoint URL` angeben. ### Login Daten diff --git a/readme_i18n/README_es_ES.md b/readme_i18n/README_es_ES.md index 73e9a2a14b..00417c9188 100644 --- a/readme_i18n/README_es_ES.md +++ b/readme_i18n/README_es_ES.md @@ -59,9 +59,7 @@ Puedes encontrar la documentación oficial, incluidas las guías de instalación ## Demo -Puedes acceder a la demostración web en - -Para la aplicación móvil, puedes usar `https://demo.immich.app` en la `URL del servidor`. +Puedes acceder a la demostración web en . Para la aplicación móvil, puedes usar `https://demo.immich.app` en la `URL del servidor`. ```bash title="Credenciales de la demo" Credenciales @@ -69,10 +67,6 @@ correo: demo@immich.app contraseña: demo ``` -```bash -Especificaciones: Una VM de nivel gratuito de Oracle - Ámsterdam - CPU ARM64 de cuatro núcleos a 2.4 GHz, 24 GB de RAM -``` - ## Funciones | Funcionalidades | Móvil | Web | diff --git a/readme_i18n/README_fr_FR.md b/readme_i18n/README_fr_FR.md index 100bc219cb..e1d4fb1cbc 100644 --- a/readme_i18n/README_fr_FR.md +++ b/readme_i18n/README_fr_FR.md @@ -59,9 +59,7 @@ Vous pouvez trouver la documentation principale ainsi que les guides d'installat ## Démo -Vous pouvez accéder à la démo en ligne sur https://demo.immich.app - -Pour l'application mobile, vous pouvez utiliser `https://demo.immich.app` dans le champ `URL du point d'accès au serveur` +Vous pouvez accéder à la démo en ligne sur https://demo.immich.app. Pour l'application mobile, vous pouvez utiliser `https://demo.immich.app` dans le champ `URL du point d'accès au serveur` ```bash title="Identifiants pour la démo" Les identifiants @@ -69,10 +67,6 @@ email: demo@immich.app mot de passe: demo ``` -``` -Caractéristiques : Plan gratuit Oracle VM - Amsterdam - 2.4Ghz quatre-cœurs ARM64 CPU, 24GB RAM -``` - ## Activités ![Activités](https://repobeats.axiom.co/api/embed/9e86d9dc3ddd137161f2f6d2e758d7863b1789cb.svg "Image des statistiques Repobeats") diff --git a/readme_i18n/README_it_IT.md b/readme_i18n/README_it_IT.md index b1aab7ea95..358faeb40c 100644 --- a/readme_i18n/README_it_IT.md +++ b/readme_i18n/README_it_IT.md @@ -59,9 +59,7 @@ La documentazione ufficiale, inclusa la guida all'installazione, è disponibile ## Demo -Prova la demo del progetto https://demo.immich.app - -Sull'app mobile, imposta `https://demo.immich.app` come `Server Endpoint URL` +Prova la demo del progetto https://demo.immich.app. Sull'app mobile, imposta `https://demo.immich.app` come `Server Endpoint URL` ```bash title="Demo Credential" Credenziali di accesso @@ -69,10 +67,6 @@ email: demo@immich.app password: demo ``` -``` -Spec: Free-tier Oracle VM - Amsterdam - 2.4Ghz quad-core ARM64 CPU, 24GB RAM -``` - # Funzionalità | Funzionalità | Mobile | Web | diff --git a/readme_i18n/README_ja_JP.md b/readme_i18n/README_ja_JP.md index 328cba431c..60dd0f3ed7 100644 --- a/readme_i18n/README_ja_JP.md +++ b/readme_i18n/README_ja_JP.md @@ -58,9 +58,7 @@ ## デモ -web デモは https://demo.immich.app からアクセスできます - -モバイルアプリの場合、`Server Endpoint URL` には `https://demo.immich.app` を使用することができます +web デモは https://demo.immich.app からアクセスできます。モバイルアプリの場合、`Server Endpoint URL` には `https://demo.immich.app` を使用することができます ```bash title="Demo Credential" The credential @@ -68,10 +66,6 @@ email: demo@immich.app password: demo ``` -``` -Spec: Free-tier Oracle VM - Amsterdam - 2.4Ghz quad-core ARM64 CPU, 24GB RAM -``` - # 機能 | 機能 | モバイル | Web | diff --git a/readme_i18n/README_ko_KR.md b/readme_i18n/README_ko_KR.md index b4b0841ed5..031e2fd9ca 100644 --- a/readme_i18n/README_ko_KR.md +++ b/readme_i18n/README_ko_KR.md @@ -61,9 +61,7 @@ ## 데모 -[이곳](https://demo.immich.app)에서 데모를 체험해보세요. 데모 서버는 2.4Ghz 쿼드 코어 ARM64 CPU 및 24GB 램으로 구성된 Oracle Free-tier VM 암스테르담 리전에서 구동됩니다. - -모바일 앱의 경우, `서버 엔드포인트 URL`에 `https://demo.immich.app`를 입력하세요. +[이곳](https://demo.immich.app)에서 데모를 체험해보세요. 모바일 앱의 경우, `서버 엔드포인트 URL`에 `https://demo.immich.app`를 입력하세요. ### 로그인 정보 diff --git a/readme_i18n/README_nl_NL.md b/readme_i18n/README_nl_NL.md index b67b66aa7d..46692bc612 100644 --- a/readme_i18n/README_nl_NL.md +++ b/readme_i18n/README_nl_NL.md @@ -59,9 +59,7 @@ De belangrijkste documentatie, inclusief installatie handleidingen, zijn te vind ## Demo -Je kunt de demo [hier](https://demo.immich.app/) bekijken. De demo server is actief op een Free-tier Oracle VM in Amsterdam met een 2.4GHz quad-core ARM64 CPU en 24GB RAM. - -Voor de mobiele app kun je gebruik maken van `https://demo.immich.app` voor de `Server Endpoint URL` +Je kunt de demo [hier](https://demo.immich.app/) bekijken. Voor de mobiele app kun je gebruik maken van `https://demo.immich.app` voor de `Server Endpoint URL`. ### Login gegevens diff --git a/readme_i18n/README_pt_BR.md b/readme_i18n/README_pt_BR.md index 7df3af91e4..2320e8fd6f 100644 --- a/readme_i18n/README_pt_BR.md +++ b/readme_i18n/README_pt_BR.md @@ -66,11 +66,7 @@ ## Demonstração -Acesse a demonstração [aqui](https://demo.immich.app). A demonstração está -hospedada no Nível Gratuito da Oracle VM em Amsterdam com um processador 2.4Ghz -quad-core ARM64 e 24GB de RAM. - -No aplicativo para dispositivos móveis, você pode usar +Acesse a demonstração [aqui](https://demo.immich.app). No aplicativo para dispositivos móveis, você pode usar `https://demo.immich.app` no campo `Server Endpoint URL` ### Credenciais de login diff --git a/readme_i18n/README_ru_RU.md b/readme_i18n/README_ru_RU.md index 11f1dfdc4b..be97259acc 100644 --- a/readme_i18n/README_ru_RU.md +++ b/readme_i18n/README_ru_RU.md @@ -62,9 +62,7 @@ ## Демо -Вы можете опробовать [Web демонстрационную версию](https://demo.immich.app/) - -В мобильном приложении укажите `https://demo.immich.app` в поле `URL-адрес сервера` +Вы можете опробовать [Web демонстрационную версию](https://demo.immich.app/). В мобильном приложении укажите `https://demo.immich.app` в поле `URL-адрес сервера` ### Данные для входа @@ -72,10 +70,6 @@ | --------------- | -------- | | demo@immich.app | demo | -``` -Характеристики демо-сервера: Free-tier Oracle VM - Amsterdam - 2.4Ghz quad-core ARM64 CPU, 24GB RAM -``` - ## Возможности | Возможности | Приложение | Веб | diff --git a/readme_i18n/README_sv_SE.md b/readme_i18n/README_sv_SE.md index 634de591a6..daa68b9874 100644 --- a/readme_i18n/README_sv_SE.md +++ b/readme_i18n/README_sv_SE.md @@ -60,9 +60,7 @@ Dokumentation och installationsguider hittas på https://imiich.app/. ## Demo -Ett webb-demo finns att testa på https://demo.immich.app - -Använd `https://demo.immich.app` i mobilappen som `Server Endpoint URL` +Ett webb-demo finns att testa på https://demo.immich.app. Använd `https://demo.immich.app` i mobilappen som `Server Endpoint URL` ```bash title="Inloggningsuppgifter För Demo" Inloggsningsuppgifter @@ -70,10 +68,6 @@ epost: demo@immich.app lösenord: demo ``` -``` -Spec: Free-tier Oracle VM - Amsterdam - 2.4Ghz quad-core ARM64 CPU, 24GB RAM -``` - ## Aktiviteter ![Activities](https://repobeats.axiom.co/api/embed/9e86d9dc3ddd137161f2f6d2e758d7863b1789cb.svg "Repobeats analytics image") diff --git a/readme_i18n/README_th_TH.md b/readme_i18n/README_th_TH.md index 20a82a5d81..bdb6db868d 100644 --- a/readme_i18n/README_th_TH.md +++ b/readme_i18n/README_th_TH.md @@ -63,9 +63,7 @@ ## สาธิต -เข้าถึงการสาธิตได้ [ที่นี่](https://demo.immich.app) โดยการสาธิตนี้ทำงานบน Oracle VM Free-tier ตั้งอยู่ที่อัมสเตอร์ดัม ใช้ซีพียู ARM64 quad-core 2.4Ghz และแรม 24GB - -สำหรับแอปมือถือ คุณสามารถใช้ `https://demo.immich.app` เป็น `Server Endpoint URL` +เข้าถึงการสาธิตได้ [ที่นี่](https://demo.immich.app) สำหรับแอปมือถือ คุณสามารถใช้ `https://demo.immich.app` เป็น `Server Endpoint URL` ### ข้อมูลการเข้าสู่ระบบ diff --git a/readme_i18n/README_tr_TR.md b/readme_i18n/README_tr_TR.md index fd52a4425c..6285ab55a2 100644 --- a/readme_i18n/README_tr_TR.md +++ b/readme_i18n/README_tr_TR.md @@ -58,9 +58,7 @@ Kurulum dahil olmak üzere resmi belgeleri https://immich.app/ adresinde bulabil ## Demo -Web demo adresi: https://demo.immich.app - -Mobil uygulama için `Server Endpoint URL` olarak `https://demo.immich.app` adresini kullanabilirsiniz. +Web demo adresi: https://demo.immich.app. Mobil uygulama için `Server Endpoint URL` olarak `https://demo.immich.app` adresini kullanabilirsiniz. ```bash title="Demo Bilgileri" Giriş bilgileri: @@ -68,10 +66,6 @@ email: demo@immich.app password: demo ``` -``` -Server Özellikleri: Free-tier Oracle VM - Amsterdam - 2.4Ghz quad-core ARM64 CPU, 24GB RAM -``` - # Özellikler | Özellikler | Mobile | Web | diff --git a/readme_i18n/README_uk_UA.md b/readme_i18n/README_uk_UA.md index 13cafd5ad3..5a33fa210d 100644 --- a/readme_i18n/README_uk_UA.md +++ b/readme_i18n/README_uk_UA.md @@ -61,9 +61,7 @@ ## Демо -Доступ до демо-версії [тут](https://demo.immich.app). Демоверсія працює на безкоштовному Oracle VM у Амстердамі з чотириядерним ARM64 процесором (2.4 ГГц) і 24 ГБ оперативної пам’яті. - -Для мобільного додатку ви можете використовувати `https://demo.immich.app` в якості `Server Endpoint URL`. +Доступ до демо-версії [тут](https://demo.immich.app). Для мобільного додатку ви можете використовувати `https://demo.immich.app` в якості `Server Endpoint URL`. ### Облікові дані для входу diff --git a/readme_i18n/README_vi_VN.md b/readme_i18n/README_vi_VN.md index c25ed4c92a..fd04bd9fa1 100644 --- a/readme_i18n/README_vi_VN.md +++ b/readme_i18n/README_vi_VN.md @@ -63,9 +63,7 @@ ## Demo -Truy cập bản demo [tại đây](https://demo.immich.app). Bản demo đang chạy trên máy ảo Oracle Free-tier ở Amsterdam với CPU ARM64 lõi tứ 2,4 GHz và RAM 24 GB. - -Đối với ứng dụng di động, bạn có thể sử dụng `https://demo.immich.app` cho `Server Endpoint URL` +Truy cập bản demo [tại đây](https://demo.immich.app). Đối với ứng dụng di động, bạn có thể sử dụng `https://demo.immich.app` cho `Server Endpoint URL` ### Thông tin đăng nhập diff --git a/readme_i18n/README_zh_CN.md b/readme_i18n/README_zh_CN.md index 83fbe59a68..5151e35379 100644 --- a/readme_i18n/README_zh_CN.md +++ b/readme_i18n/README_zh_CN.md @@ -65,9 +65,7 @@ ## 示例 -您可以在[此处](https://demo.immich.app)访问在线演示网站。该示例网站运行的机器配置为:甲骨文免费虚拟机套餐——阿姆斯特丹 4核 2.4Ghz ARM64 CPU,24GB RAM。 - -在移动端,您可以使用 `https://demo.immich.app` 作为 `服务终端链接` +您可以在[此处](https://demo.immich.app)访问在线演示网站。在移动端,您可以使用 `https://demo.immich.app` 作为 `服务终端链接` ### 登录认证信息 From 60174d662d57b30a1f16eac962aa6e6432a1fc17 Mon Sep 17 00:00:00 2001 From: shenlong <139912620+shenlong-tanwen@users.noreply.github.com> Date: Fri, 4 Apr 2025 08:19:50 +0530 Subject: [PATCH 08/52] fix(mobile): bump isar maxSize (#17372) --- mobile/lib/utils/bootstrap.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mobile/lib/utils/bootstrap.dart b/mobile/lib/utils/bootstrap.dart index dec48582b3..570752c6d9 100644 --- a/mobile/lib/utils/bootstrap.dart +++ b/mobile/lib/utils/bootstrap.dart @@ -43,7 +43,7 @@ abstract final class Bootstrap { DeviceAssetEntitySchema, ], directory: dir.path, - maxSizeMiB: 1024, + maxSizeMiB: 2048, inspector: kDebugMode, ); } From dfab32c8f21606d301a33f07ff4264ece053d846 Mon Sep 17 00:00:00 2001 From: shenlong <139912620+shenlong-tanwen@users.noreply.github.com> Date: Fri, 4 Apr 2025 09:05:50 +0530 Subject: [PATCH 09/52] fix(mobile): ignore invalid store keys (#17370) Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com> --- .../repositories/store.repository.dart | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/mobile/lib/infrastructure/repositories/store.repository.dart b/mobile/lib/infrastructure/repositories/store.repository.dart index 86dfaf4452..e8769c5084 100644 --- a/mobile/lib/infrastructure/repositories/store.repository.dart +++ b/mobile/lib/infrastructure/repositories/store.repository.dart @@ -9,7 +9,9 @@ import 'package:isar/isar.dart'; class IsarStoreRepository extends IsarDatabaseRepository implements IStoreRepository { final Isar _db; - const IsarStoreRepository(super.db) : _db = db; + final validStoreKeys = StoreKey.values.map((e) => e.id).toSet(); + + IsarStoreRepository(super.db) : _db = db; @override Future deleteAll() async { @@ -21,9 +23,14 @@ class IsarStoreRepository extends IsarDatabaseRepository @override Stream watchAll() { - return _db.storeValues.where().watch(fireImmediately: true).asyncExpand( - (entities) => - Stream.fromFutures(entities.map((e) async => _toUpdateEvent(e))), + return _db.storeValues + .filter() + .anyOf(validStoreKeys, (query, id) => query.idEqualTo(id)) + .watch(fireImmediately: true) + .asyncExpand( + (entities) => Stream.fromFutures( + entities.map((e) async => _toUpdateEvent(e)), + ), ); } From 720189e2c21af602d21c81db938d8ab3cd505dae Mon Sep 17 00:00:00 2001 From: martin <74269598+martabal@users.noreply.github.com> Date: Fri, 4 Apr 2025 23:04:52 +0200 Subject: [PATCH 10/52] fix: improve initial loading time (#17379) --- web/src/lib/components/shared-components/change-location.svelte | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/src/lib/components/shared-components/change-location.svelte b/web/src/lib/components/shared-components/change-location.svelte index de10e62bc5..a84838f1db 100644 --- a/web/src/lib/components/shared-components/change-location.svelte +++ b/web/src/lib/components/shared-components/change-location.svelte @@ -13,7 +13,7 @@ import { listNavigation } from '$lib/actions/list-navigation'; import { t } from 'svelte-i18n'; import CoordinatesInput from '$lib/components/shared-components/coordinates-input.svelte'; - import Map from '$lib/components/shared-components/map/map.svelte'; + import type Map from '$lib/components/shared-components/map/map.svelte'; import { get } from 'svelte/store'; interface Point { lng: number; From 7b6a4be30c2e5e8d23f6bf4b2e241883d78f6567 Mon Sep 17 00:00:00 2001 From: Mert <101130780+mertalev@users.noreply.github.com> Date: Fri, 4 Apr 2025 18:46:46 -0400 Subject: [PATCH 11/52] chore: use valkey (#17396) use valkey --- docker/docker-compose.dev.yml | 2 +- docker/docker-compose.prod.yml | 2 +- docker/docker-compose.yml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docker/docker-compose.dev.yml b/docker/docker-compose.dev.yml index 322c8ae8bc..af7e2c52a9 100644 --- a/docker/docker-compose.dev.yml +++ b/docker/docker-compose.dev.yml @@ -116,7 +116,7 @@ services: redis: container_name: immich_redis - image: redis:6.2-alpine@sha256:148bb5411c184abd288d9aaed139c98123eeb8824c5d3fce03cf721db58066d8 + image: docker.io/valkey/valkey:8-bookworm@sha256:42cba146593a5ea9a622002c1b7cba5da7be248650cbb64ecb9c6c33d29794b1 healthcheck: test: redis-cli ping || exit 1 diff --git a/docker/docker-compose.prod.yml b/docker/docker-compose.prod.yml index bc2081436c..f8caabe98c 100644 --- a/docker/docker-compose.prod.yml +++ b/docker/docker-compose.prod.yml @@ -56,7 +56,7 @@ services: redis: container_name: immich_redis - image: redis:6.2-alpine@sha256:148bb5411c184abd288d9aaed139c98123eeb8824c5d3fce03cf721db58066d8 + image: docker.io/valkey/valkey:8-bookworm@sha256:42cba146593a5ea9a622002c1b7cba5da7be248650cbb64ecb9c6c33d29794b1 healthcheck: test: redis-cli ping || exit 1 restart: always diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 55db2a7cc5..499673a383 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -49,7 +49,7 @@ services: redis: container_name: immich_redis - image: docker.io/redis:6.2-alpine@sha256:148bb5411c184abd288d9aaed139c98123eeb8824c5d3fce03cf721db58066d8 + image: docker.io/valkey/valkey:8-bookworm@sha256:42cba146593a5ea9a622002c1b7cba5da7be248650cbb64ecb9c6c33d29794b1 healthcheck: test: redis-cli ping || exit 1 restart: always From a3c3f9cfcbb3f321c8b37dd1b2d97cd1bba5e2c9 Mon Sep 17 00:00:00 2001 From: Daniel Dietzler <36593685+danieldietzler@users.noreply.github.com> Date: Sat, 5 Apr 2025 19:09:56 +0200 Subject: [PATCH 12/52] fix: reset memories on logout (#17405) --- web/src/lib/stores/memory.store.svelte.ts | 5 +++++ web/src/lib/utils/auth.ts | 2 ++ 2 files changed, 7 insertions(+) diff --git a/web/src/lib/stores/memory.store.svelte.ts b/web/src/lib/stores/memory.store.svelte.ts index 76e406682d..7173b43d06 100644 --- a/web/src/lib/stores/memory.store.svelte.ts +++ b/web/src/lib/stores/memory.store.svelte.ts @@ -110,6 +110,11 @@ class MemoryStoreSvelte { await this.loadAllMemories(); } + clearCache() { + this.initialized = false; + this.memories = []; + } + private async loadAllMemories() { const memories = await searchMemories({ $for: asLocalTimeISO(DateTime.now()) }); this.memories = memories.filter((memory) => memory.assets.length > 0); diff --git a/web/src/lib/utils/auth.ts b/web/src/lib/utils/auth.ts index f14de693f3..4299bfacae 100644 --- a/web/src/lib/utils/auth.ts +++ b/web/src/lib/utils/auth.ts @@ -1,6 +1,7 @@ import { browser } from '$app/environment'; import { goto } from '$app/navigation'; import { foldersStore } from '$lib/stores/folders.svelte'; +import { memoryStore } from '$lib/stores/memory.store.svelte'; import { purchaseStore } from '$lib/stores/purchase.store'; import { preferences as preferences$, resetSavedUser, user as user$ } from '$lib/stores/user.store'; import { resetUserInteraction, userInteraction } from '$lib/stores/user.svelte'; @@ -101,5 +102,6 @@ export const handleLogout = async (redirectUri: string) => { resetSavedUser(); resetUserInteraction(); foldersStore.clearCache(); + memoryStore.clearCache(); } }; From f096dd0cc0c8b94916b9135ed5d31bac9f5e7bb9 Mon Sep 17 00:00:00 2001 From: Matthew Momjian <50788000+mmomjian@users.noreply.github.com> Date: Sun, 6 Apr 2025 04:09:54 -0400 Subject: [PATCH 13/52] fix(deployment): warning for database on network share (#17412) Update example.env --- docker/example.env | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docker/example.env b/docker/example.env index 9ad3af3c0e..0450dc0805 100644 --- a/docker/example.env +++ b/docker/example.env @@ -2,7 +2,8 @@ # The location where your uploaded files are stored UPLOAD_LOCATION=./library -# The location where your database files are stored + +# The location where your database files are stored. Network shares are not supported for the database DB_DATA_LOCATION=./postgres # To set a timezone, uncomment the next line and change Etc/UTC to a TZ identifier from this list: https://en.wikipedia.org/wiki/List_of_tz_database_time_zones#List From 565cceb323c94ec122f8074cf6bec5430ebe24b5 Mon Sep 17 00:00:00 2001 From: Lorenzo Montanari Date: Mon, 7 Apr 2025 05:00:10 +0200 Subject: [PATCH 14/52] docs: fixed a wrong path in CLI docs page (#17369) docs: fixed a wrong path in CLI page --- docs/docs/features/command-line-interface.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/docs/features/command-line-interface.md b/docs/docs/features/command-line-interface.md index f249d6809f..d68b8ba5ed 100644 --- a/docs/docs/features/command-line-interface.md +++ b/docs/docs/features/command-line-interface.md @@ -112,7 +112,7 @@ You begin by authenticating to your Immich server. For instance: immich login http://192.168.1.216:2283/api HFEJ38DNSDUEG ``` -This will store your credentials in a `auth.yml` file in the configuration directory which defaults to `~/.config/`. The directory can be set with the `-d` option or the environment variable `IMMICH_CONFIG_DIR`. Please keep the file secure, either by performing the logout command after you are done, or deleting it manually. +This will store your credentials in a `auth.yml` file in the configuration directory which defaults to `~/.config/immich/`. The directory can be set with the `-d` option or the environment variable `IMMICH_CONFIG_DIR`. Please keep the file secure, either by performing the logout command after you are done, or deleting it manually. Once you are authenticated, you can upload assets to your Immich server. From b87ba6865b4723773d924039ac0f9d0fcec6a880 Mon Sep 17 00:00:00 2001 From: Zlendy Date: Mon, 7 Apr 2025 05:03:19 +0200 Subject: [PATCH 15/52] fix(web): Video memories are played at 100% volume instead of respecting user preference (#17424) --- web/src/lib/components/memory-page/memory-viewer.svelte | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/web/src/lib/components/memory-page/memory-viewer.svelte b/web/src/lib/components/memory-page/memory-viewer.svelte index 9c0ba87e85..e39a3cfa74 100644 --- a/web/src/lib/components/memory-page/memory-viewer.svelte +++ b/web/src/lib/components/memory-page/memory-viewer.svelte @@ -28,7 +28,7 @@ import { assetViewingStore } from '$lib/stores/asset-viewing.store'; import { type Viewport } from '$lib/stores/assets-store.svelte'; import { type MemoryAsset, memoryStore } from '$lib/stores/memory.store.svelte'; - import { locale, videoViewerMuted } from '$lib/stores/preferences.store'; + 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 { cancelMultiselect } from '$lib/utils/asset-utils'; @@ -449,6 +449,7 @@ poster={getAssetThumbnailUrl({ id: current.asset.id, size: AssetMediaSize.Preview })} draggable="false" muted={$videoViewerMuted} + volume={$videoViewerVolume} transition:fade > {:else} From b58a45015288e61b621fe3271a29a8f5ca32762d Mon Sep 17 00:00:00 2001 From: shenlong <139912620+shenlong-tanwen@users.noreply.github.com> Date: Mon, 7 Apr 2025 08:34:13 +0530 Subject: [PATCH 16/52] fix(mobile): prevent unnecessary reload on multi user timeline (#17418) Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com> --- mobile/lib/providers/user.provider.dart | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/mobile/lib/providers/user.provider.dart b/mobile/lib/providers/user.provider.dart index 99dfba9010..1a1c21554c 100644 --- a/mobile/lib/providers/user.provider.dart +++ b/mobile/lib/providers/user.provider.dart @@ -1,5 +1,6 @@ import 'dart:async'; +import 'package:collection/collection.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/domain/models/user.model.dart'; import 'package:immich_mobile/domain/services/user.service.dart'; @@ -36,10 +37,13 @@ final currentUserProvider = class TimelineUserIdsProvider extends StateNotifier> { TimelineUserIdsProvider(this._timelineService) : super([]) { + final listEquality = const ListEquality(); _timelineService.getTimelineUserIds().then((users) => state = users); - streamSub = _timelineService - .watchTimelineUserIds() - .listen((users) => state = users); + streamSub = _timelineService.watchTimelineUserIds().listen((users) { + if (!listEquality.equals(state, users)) { + state = users; + } + }); } late final StreamSubscription> streamSub; From 31ee19181a61d23546cd5b3f63ff59f3037b2b37 Mon Sep 17 00:00:00 2001 From: Ben McCann <322311+benmccann@users.noreply.github.com> Date: Sun, 6 Apr 2025 20:05:47 -0700 Subject: [PATCH 17/52] chore(web): switch to writable derived one more place (#17399) --- .../tags/[[photos=photos]]/[[assetId=id]]/+page.svelte | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/web/src/routes/(user)/tags/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/tags/[[photos=photos]]/[[assetId=id]]/+page.svelte index bf423e4825..8bb43676e8 100644 --- a/web/src/routes/(user)/tags/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/tags/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -44,11 +44,7 @@ $effect(() => void assetStore.updateOptions({ deferInit: !tag, tagId })); onDestroy(() => assetStore.destroy()); - let tags = $state([]); - $effect(() => { - tags = data.tags; - }); - + let tags = $derived(data.tags); let tagsMap = $derived(buildMap(tags)); let tag = $derived(currentPath ? tagsMap[currentPath] : null); let tagId = $derived(tag?.id); From 30d33f968f332c5823c437f22bc5be585bcdfbcb Mon Sep 17 00:00:00 2001 From: "Weblate (bot)" Date: Mon, 7 Apr 2025 13:28:59 +0200 Subject: [PATCH 18/52] chore(web): update translations (#17254) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Translate-URL: https://hosted.weblate.org/projects/immich/immich/bg/ Translate-URL: https://hosted.weblate.org/projects/immich/immich/bi/ Translate-URL: https://hosted.weblate.org/projects/immich/immich/ca/ Translate-URL: https://hosted.weblate.org/projects/immich/immich/cs/ Translate-URL: https://hosted.weblate.org/projects/immich/immich/de/ Translate-URL: https://hosted.weblate.org/projects/immich/immich/el/ Translate-URL: https://hosted.weblate.org/projects/immich/immich/es/ Translate-URL: https://hosted.weblate.org/projects/immich/immich/et/ Translate-URL: https://hosted.weblate.org/projects/immich/immich/fa/ Translate-URL: https://hosted.weblate.org/projects/immich/immich/fi/ Translate-URL: https://hosted.weblate.org/projects/immich/immich/fr/ Translate-URL: https://hosted.weblate.org/projects/immich/immich/gl/ Translate-URL: https://hosted.weblate.org/projects/immich/immich/he/ Translate-URL: https://hosted.weblate.org/projects/immich/immich/hr/ Translate-URL: https://hosted.weblate.org/projects/immich/immich/hu/ Translate-URL: https://hosted.weblate.org/projects/immich/immich/it/ Translate-URL: https://hosted.weblate.org/projects/immich/immich/ja/ Translate-URL: https://hosted.weblate.org/projects/immich/immich/ka/ Translate-URL: https://hosted.weblate.org/projects/immich/immich/lv/ Translate-URL: https://hosted.weblate.org/projects/immich/immich/ms/ Translate-URL: https://hosted.weblate.org/projects/immich/immich/nb_NO/ Translate-URL: https://hosted.weblate.org/projects/immich/immich/nl/ Translate-URL: https://hosted.weblate.org/projects/immich/immich/pl/ Translate-URL: https://hosted.weblate.org/projects/immich/immich/pt/ Translate-URL: https://hosted.weblate.org/projects/immich/immich/pt_BR/ Translate-URL: https://hosted.weblate.org/projects/immich/immich/ro/ Translate-URL: https://hosted.weblate.org/projects/immich/immich/ru/ Translate-URL: https://hosted.weblate.org/projects/immich/immich/sl/ Translate-URL: https://hosted.weblate.org/projects/immich/immich/sr_Cyrl/ Translate-URL: https://hosted.weblate.org/projects/immich/immich/sr_Latn/ Translate-URL: https://hosted.weblate.org/projects/immich/immich/sv/ Translate-URL: https://hosted.weblate.org/projects/immich/immich/ta/ Translate-URL: https://hosted.weblate.org/projects/immich/immich/te/ Translate-URL: https://hosted.weblate.org/projects/immich/immich/th/ Translate-URL: https://hosted.weblate.org/projects/immich/immich/tr/ Translate-URL: https://hosted.weblate.org/projects/immich/immich/uk/ Translate-URL: https://hosted.weblate.org/projects/immich/immich/vi/ Translate-URL: https://hosted.weblate.org/projects/immich/immich/zh_Hant/ Translate-URL: https://hosted.weblate.org/projects/immich/immich/zh_SIMPLIFIED/ Translation: Immich/immich Co-authored-by: Bonov Co-authored-by: C D Co-authored-by: Daniel Correa Lobato Co-authored-by: Emre Saraçoğlu Co-authored-by: Fjuro Co-authored-by: GND Co-authored-by: Gocha Gulua Co-authored-by: Hurricane-32 Co-authored-by: Indrek Haav Co-authored-by: Leigh van der merwe Co-authored-by: LennartWeinzierl Co-authored-by: Leo Bottaro Co-authored-by: Luis Peregrina Co-authored-by: Matjaž T Co-authored-by: Miki Mrvos Co-authored-by: Mārtiņš Bruņenieks Co-authored-by: Oleksandr Zhukov Co-authored-by: Passawish Paktiwong Co-authored-by: Petri Hämäläinen Co-authored-by: Ruben Hensen Co-authored-by: Runskrift Co-authored-by: Shawn Co-authored-by: Stein-Aksel Basma Co-authored-by: Sylvain Pichon Co-authored-by: Tachibana Saza Co-authored-by: Temuri Doghonadze Co-authored-by: Theofilos Nikolaou Co-authored-by: User 123456789 Co-authored-by: Vin Co-authored-by: aks-cadesign Co-authored-by: eav5jhl0 Co-authored-by: grgergo Co-authored-by: late Co-authored-by: millallo Co-authored-by: przmkg Co-authored-by: thehijacker Co-authored-by: timmy61109 Co-authored-by: waclaw66 Co-authored-by: xuars Co-authored-by: Вячеслав Лукьяненко --- i18n/bg.json | 2 +- i18n/bi.json | 30 ++++---- i18n/ca.json | 2 +- i18n/cs.json | 10 ++- i18n/de.json | 6 ++ i18n/el.json | 6 +- i18n/es.json | 16 ++-- i18n/et.json | 6 ++ i18n/fa.json | 2 +- i18n/fi.json | 8 +- i18n/fr.json | 6 ++ i18n/gl.json | 58 +++++++++++++- i18n/he.json | 6 ++ i18n/hr.json | 2 +- i18n/hu.json | 6 ++ i18n/it.json | 6 ++ i18n/ja.json | 4 + i18n/ka.json | 166 +++++++++++++++++++++++++++++++++++++++- i18n/lv.json | 6 ++ i18n/ms.json | 2 +- i18n/nb_NO.json | 2 +- i18n/nl.json | 8 +- i18n/pl.json | 6 +- i18n/pt.json | 12 ++- i18n/pt_BR.json | 71 +++++++++-------- i18n/ro.json | 2 +- i18n/ru.json | 18 +++-- i18n/sl.json | 8 +- i18n/sr_Cyrl.json | 6 ++ i18n/sr_Latn.json | 6 ++ i18n/sv.json | 6 ++ i18n/ta.json | 2 +- i18n/te.json | 10 +-- i18n/th.json | 67 ++++++++++++---- i18n/tr.json | 2 +- i18n/uk.json | 18 +++-- i18n/vi.json | 2 +- i18n/zh_Hant.json | 20 ++--- i18n/zh_SIMPLIFIED.json | 6 ++ 39 files changed, 500 insertions(+), 122 deletions(-) diff --git a/i18n/bg.json b/i18n/bg.json index 72948d8d50..00e835af4b 100644 --- a/i18n/bg.json +++ b/i18n/bg.json @@ -1374,4 +1374,4 @@ "yes": "Да", "you_dont_have_any_shared_links": "Нямате споделени връзки", "zoom_image": "Увеличаване на изображението" -} \ No newline at end of file +} diff --git a/i18n/bi.json b/i18n/bi.json index dfcc614bea..76223da252 100644 --- a/i18n/bi.json +++ b/i18n/bi.json @@ -1,20 +1,22 @@ { - "account": "", - "account_settings": "", - "acknowledge": "", + "about": "abaot", + "account": "Akaont", + "account_settings": "Seting blo Akaont", + "acknowledge": "Akcept", "action": "", "actions": "", - "active": "", - "activity": "", - "add": "", - "add_a_description": "", - "add_a_location": "", - "add_a_name": "", - "add_a_title": "", - "add_exclusion_pattern": "", - "add_import_path": "", - "add_location": "", - "add_more_users": "", + "active": "Stap Mekem", + "activity": "Wanem hemi Mekem", + "activity_changed": "WAnem hemi Mekem hemi", + "add": "Ad", + "add_a_description": "Putem Description blo hem", + "add_a_location": "Putem place blo hem", + "add_a_name": "Putem nam blo hem", + "add_a_title": "Putem wan name blo hem", + "add_exclusion_pattern": "Putem wan paten wae hemi karem aot", + "add_import_path": "Putem wan pat blo import", + "add_location": "Putem wan place blo hem", + "add_more_users": "Putem mor man", "add_partner": "", "add_path": "", "add_photos": "", diff --git a/i18n/ca.json b/i18n/ca.json index 77cb4a584e..afb33a8209 100644 --- a/i18n/ca.json +++ b/i18n/ca.json @@ -1374,4 +1374,4 @@ "yes": "Sí", "you_dont_have_any_shared_links": "No tens cap enllaç compartit", "zoom_image": "Ampliar Imatge" -} \ No newline at end of file +} diff --git a/i18n/cs.json b/i18n/cs.json index 6207888a47..0c2ffa2f8c 100644 --- a/i18n/cs.json +++ b/i18n/cs.json @@ -66,8 +66,13 @@ "forcing_refresh_library_files": "Vynucení obnovy všech souborů knihovny", "image_format": "Formát", "image_format_description": "WebP vytváří menší soubory než JPEG, ale je pomalejší při kódování.", + "image_fullsize_description": "Obrázek v plné velikosti s odstraněnými metadaty, použito při přiblížení", + "image_fullsize_enabled": "Povolit generování obrázků v plné velikosti", + "image_fullsize_enabled_description": "Generovat obrázky v plné velikosti pro formáty, které nejsou vhodné pro web. Pokud je povolena možnost „Preferovat vložený náhled“, budou přímo použity vložené náhledy bez převodu. Neovlivňuje formáty vhodné pro web, jako je JPEG.", + "image_fullsize_quality_description": "Kvalita obrázku v plné velikosti od 1 do 100. Vyšší je lepší, ale vytváří větší soubory.", + "image_fullsize_title": "Nastavení obrázků v plné velikosti", "image_prefer_embedded_preview": "Preferovat vložený náhled", - "image_prefer_embedded_preview_setting_description": "Použít vložené náhledy z RAW fotografií jako vstup pro zpracování snímků, pokud jsou k dispozici. U některých snímků tak lze dosáhnout přesnějších barev, ale kvalita náhledu závisí na fotoaparátu a snímek může obsahovat více kompresních artefaktů.", + "image_prefer_embedded_preview_setting_description": "Použít vložené náhledy z RAW fotografií jako vstup pro zpracování snímků a pokud jsou k dispozici. U některých snímků tak lze dosáhnout přesnějších barev, ale kvalita náhledu závisí na fotoaparátu a snímek může obsahovat více kompresních artefaktů.", "image_prefer_wide_gamut": "Preferovat široký gamut", "image_prefer_wide_gamut_setting_description": "Použít Display P3 pro miniatury. To lépe zachovává živost obrázků s širokým barevným prostorem, ale obrázky se mohou na starých zařízeních se starou verzí prohlížeče zobrazovat jinak. sRGB obrázky jsou ponechány jako sRGB, aby se zabránilo posunům barev.", "image_preview_description": "Středně velký obrázek se zbavenými metadaty, který se používá při prohlížení jedné položky a pro strojové učení", @@ -859,6 +864,7 @@ "loop_videos": "Videa ve smyčce", "loop_videos_description": "Povolit automatickou smyčku videa v prohlížeči.", "main_branch_warning": "Používáte vývojovou verzi; důrazně doporučujeme používat verzi z vydání!", + "main_menu": "Hlavní nabídka", "make": "Výrobce", "manage_shared_links": "Spravovat sdílené odkazy", "manage_sharing_with_partners": "Správa sdílení s partnery", @@ -1234,7 +1240,7 @@ "sort_oldest": "Nejstarší fotka", "sort_people_by_similarity": "Seřadit lidi podle podobnosti", "sort_recent": "Nejnovější fotka", - "sort_title": "Název", + "sort_title": "Název alba", "source": "Zdroj", "stack": "Seskupit", "stack_duplicates": "Seskupit duplicity", diff --git a/i18n/de.json b/i18n/de.json index c3dd6876b8..681f2bd2bb 100644 --- a/i18n/de.json +++ b/i18n/de.json @@ -66,6 +66,11 @@ "forcing_refresh_library_files": "Erneutes Laden aller Bibliotheksdateien erzwingen", "image_format": "Format", "image_format_description": "WebP erzeugt kleinere Dateien als JPEG, ist aber etwas langsamer in der Erstellung.", + "image_fullsize_description": "Hochauflösendes Bild mit entfernten Metadaten, das beim Zoomen verwendet wird", + "image_fullsize_enabled": "Hochauflösende Vorschaubilder aktivieren", + "image_fullsize_enabled_description": "Generiere Hochauflösende Vorschaubilder in Originalauflösung für nicht web-kompatibel Formate. Wenn \"Eingebettete Vorschau bevorzugen\" aktiviert ist, werden eingebettete Vorschaubilder direkt verwendet. Hat keinen Einfluss auf web-kompatible Formate wie JPEG.", + "image_fullsize_quality_description": "Qualität der Hochauflösenden Vorschaubilder von 1-100. Höher ist besser, erzeugt aber größere Dateien.", + "image_fullsize_title": "Hochauflösende Vorschaueinstellungen", "image_prefer_embedded_preview": "Eingebettete Vorschau bevorzugen", "image_prefer_embedded_preview_setting_description": "Verwende eingebettete Vorschaubilder in RAW-Fotos als Grundlage für die Bildverarbeitung, sofern diese zur Verfügung stehen. Dies kann bei einigen Bildern genauere Farben erzeugen, allerdings ist die Qualität der Vorschau kameraabhängig und das Bild kann mehr Kompressionsartefakte aufweisen.", "image_prefer_wide_gamut": "Breites Spektrum bevorzugen", @@ -859,6 +864,7 @@ "loop_videos": "Loop-Videos", "loop_videos_description": "Aktiviere diese Option, um eine automatische Videoschleife in der Detailansicht zu erstellen.", "main_branch_warning": "Du benutzt eine Entwicklungsversion. Wir empfehlen dringend, eine Release-Version zu verwenden!", + "main_menu": "Hauptmenü", "make": "Marke", "manage_shared_links": "Freigegebene Links verwalten", "manage_sharing_with_partners": "Gemeinsame Nutzung mit Partnern verwalten", diff --git a/i18n/el.json b/i18n/el.json index 5d4bf37b56..3476977634 100644 --- a/i18n/el.json +++ b/i18n/el.json @@ -388,7 +388,7 @@ "albums_count": "{count, plural, one {{count, number} Άλμπουμ} other {{count, number} Άλμπουμ}}", "all": "Όλα", "all_albums": "Όλα τα άλμπουμ", - "all_people": "Όλοι οι άνθρωποι", + "all_people": "Όλα τα άτομα", "all_videos": "Όλα τα βίντεο", "allow_dark_mode": "Επιτρέψτε τη σκοτεινή λειτουργία", "allow_edits": "Επιτρέψτε τις τροποποιήσεις", @@ -452,7 +452,7 @@ "camera_model": "Μοντέλο κάμερας", "cancel": "Ακύρωση", "cancel_search": "Ακύρωση αναζήτησης", - "cannot_merge_people": "Αδύνατη η συγχώνευση προσώπων", + "cannot_merge_people": "Αδύνατη η συγχώνευση ατόμων", "cannot_undo_this_action": "Δεν μπορείτε να αναιρέσετε αυτήν την ενέργεια!", "cannot_update_the_description": "Αδύνατη η ενημέρωση της περιγραφής", "change_date": "Αλλαγή ημερομηνίας", @@ -618,7 +618,7 @@ "cant_change_metadata_assets_count": "Δεν μπορείτε να αλλάξετε τα μεταδεδομένα του {count, plural, one {# αρχείου} other {# αρχείων}}", "cant_get_faces": "Δεν είναι δυνατή η ανάκτηση προσώπων", "cant_get_number_of_comments": "Δεν είναι δυνατή η ανάκτηση του αριθμού των σχολίων", - "cant_search_people": "Δεν μπορείτε να αναζητήσετε άτομα", + "cant_search_people": "Αδύνατη η αναζήτηση ατόμων", "cant_search_places": "Δεν μπορείτε να αναζητήσετε τοποθεσίες", "cleared_jobs": "Εκκαθαρισμένες εργασίες για: {job}", "error_adding_assets_to_album": "Σφάλμα κατά την προσθήκη στοιχείων στο άλμπουμ", diff --git a/i18n/es.json b/i18n/es.json index b30f8a4094..070d728cfb 100644 --- a/i18n/es.json +++ b/i18n/es.json @@ -66,8 +66,13 @@ "forcing_refresh_library_files": "Forzando la recarga de todos los elementos en la biblioteca", "image_format": "Formato", "image_format_description": "WebP genera archivos más pequeños que JPEG, pero es más lento al codificarlos.", + "image_fullsize_description": "Imagen de tamaño completo con metadatos removidos, usado cuando se hace zoom", + "image_fullsize_enabled": "Activar generación de imágenes a tamaño completo", + "image_fullsize_enabled_description": "Generar imágenes a tamaño completo para formatos no aptos para web. Cuando \"Preferir vista previa incrustada\" está activada, las vistas previas incrustadas se utilizan directamente sin conversión. No afecta a los formatos aptos para la web, como JPEG.", + "image_fullsize_quality_description": "De 1 a 100, calidad de imágenes de tamaño completo. Mientras más alto es mejor, pero genera archivos de mayor tamaño.", + "image_fullsize_title": "Configuraciones de imágenes de tamaño completo", "image_prefer_embedded_preview": "Preferir vista previa embebida", - "image_prefer_embedded_preview_setting_description": "Usar vistas previas embebidas en fotos RAW como entrada para el procesamiento de imágenes cuando estén disponibles. Esto puede producir colores más precisos en algunas imágenes, pero la calidad de la vista previa depende de la cámara y la imagen puede tener más artefactos de compresión.", + "image_prefer_embedded_preview_setting_description": "Usar vistas previas embebidas en fotos RAW como entrada para el procesamiento de imágenes y cuando estén disponibles. Esto puede producir colores más precisos en algunas imágenes, pero la calidad de la vista previa depende de la cámara y la imagen puede tener más artefactos de compresión.", "image_prefer_wide_gamut": "Preferir 'gamut' amplio", "image_prefer_wide_gamut_setting_description": "Usar \"Display P3\" para las miniaturas. Preserva mejor la vivacidad de las imágenes con espacios de color amplios pero las imágenes pueden aparecer de manera diferente en dispositivos antiguos con una versión antigua del navegador. Las imágenes sRGB se mantienen como sRGB para evitar cambios de color.", "image_preview_description": "Imagen de tamaño mediano con metadatos eliminados. Es utilizado al visualizar un solo activo y para el aprendizaje automático", @@ -414,7 +419,7 @@ "asset_description_updated": "La descripción del elemento ha sido actualizada", "asset_filename_is_offline": "El archivo {filename} está offline", "asset_has_unassigned_faces": "El archivo no tiene rostros asignados", - "asset_hashing": "Hashing…", + "asset_hashing": "Calculando hash…", "asset_offline": "Archivos sin conexión", "asset_offline_description": "Este activo externo ya no se encuentra en el disco. Por favor, póngase en contacto con su administrador de Immich para obtener ayuda.", "asset_skipped": "Omitido", @@ -442,7 +447,7 @@ "blurred_background": "Fondo borroso", "bugs_and_feature_requests": "Errores y solicitudes de funciones", "build": "Compilación", - "build_image": "Crear imagen", + "build_image": "Imagen de compilación", "bulk_delete_duplicates_confirmation": "¿Estás seguro de que deseas eliminar de forma masiva {count, plural, one {# elemento duplicado} other {# elementos duplicados}}? Esto mantendrá el activo más grande de cada grupo y eliminará permanentemente todos los demás duplicados. ¡Esta acción no se puede deshacer!", "bulk_keep_duplicates_confirmation": "¿Estas seguro de que desea mantener {count, plural, one {# duplicate asset} other {# duplicate assets}} archivos duplicados? Esto resolverá todos los grupos duplicados sin borrar nada.", "bulk_trash_duplicates_confirmation": "¿Estas seguro de que desea eliminar masivamente {count, plural, one {# duplicate asset} other {# duplicate assets}} archivos duplicados? Esto mantendrá el archivo más grande de cada grupo y eliminará todos los demás duplicados.", @@ -859,6 +864,7 @@ "loop_videos": "Vídeos en bucle", "loop_videos_description": "Habilite la reproducción automática de un video en el visor de detalles.", "main_branch_warning": "Estás ejecutando una compilación desde la rama principal. ¡Recomendamos encarecidamente usar una versión de lanzamiento!", + "main_menu": "Menú principal", "make": "Marca", "manage_shared_links": "Administrar enlaces compartidos", "manage_sharing_with_partners": "Administrar el uso compartido con invitados", @@ -1241,7 +1247,7 @@ "stack_select_one_photo": "Selecciona una imagen principal para la pila", "stack_selected_photos": "Apilar fotos seleccionadas", "stacked_assets_count": "Apilado(s) {count, plural, one {# activo} other {# activos}}", - "stacktrace": "Stacktrace", + "stacktrace": "Seguimiento de pila", "start": "Inicio", "start_date": "Fecha de inicio", "state": "Estado", @@ -1350,7 +1356,7 @@ "version_announcement_closing": "Tu amigo, Alex", "version_announcement_message": "¡Hola! Hay una nueva versión de Immich disponible. Tómese un tiempo para leer las notas de la versión para asegurarse de que su configuración esté actualizada y evitar errores de configuración, especialmente si utiliza WatchTower o cualquier mecanismo que se encargue de actualizar su instancia de Immich automáticamente.", "version_history": "Historial de versiones", - "version_history_item": "Instalada la {version} el {date}", + "version_history_item": "Instalada {version} el {date}", "video": "Vídeo", "video_hover_setting": "Iniciar vídeo al pasar por encima", "video_hover_setting_description": "Reproducir el vídeo cuando el ratón está encima de un vídeo. Aunque esté desactivado, se iniciará cuando el cursor del ratón esté sobre el icono de \"reproducir\".", diff --git a/i18n/et.json b/i18n/et.json index e071408675..24e2f3dd33 100644 --- a/i18n/et.json +++ b/i18n/et.json @@ -66,6 +66,11 @@ "forcing_refresh_library_files": "Kogu kõigi failide sundvärskendamine", "image_format": "Formaat", "image_format_description": "WebP failid on väiksemad kui JPEG, aga kodeerimine on aeglasem.", + "image_fullsize_description": "Täismõõdus pilt ilma metaandmeteta, kasutatakse sisse suumimisel", + "image_fullsize_enabled": "Luba täismõõdus piltide genereerimine", + "image_fullsize_enabled_description": "Genereeri mitte-veebisõbralike formaatide jaoks täismõõdus pilt. Kui \"Eelista manustatud eelvaadet\" on lubatud, kasutatakse manustatud eelvaateid otse ilma teisendamiseta. Ei mõjuta veebisõbralikke formaate nagu JPEG.", + "image_fullsize_quality_description": "Täismõõdus pildi kvaliteet vahemikus 1-100. Kõrgem väärtus on parem, aga tulemuseks on suuremad failid.", + "image_fullsize_title": "Täismõõdus pildi seaded", "image_prefer_embedded_preview": "Eelista manustatud eelvaadet", "image_prefer_embedded_preview_setting_description": "Kasuta pilditöötluse sisendina võimalusel RAW fotodesse manustatud eelvaateid. See võib mõnede piltide puhul anda tulemuseks täpsemad värvid, aga eelvaate kvaliteet sõltub konkreetsest kaamerast ning pildis võib olla rohkem tihendusmüra.", "image_prefer_wide_gamut": "Eelista laia värvigammat", @@ -859,6 +864,7 @@ "loop_videos": "Taasesita videod", "loop_videos_description": "Lülita sisse, et detailvaates videot automaatselt taasesitada.", "main_branch_warning": "Sa kasutad arendusversiooni; soovitame tungivalt kasutada väljalaskeversiooni!", + "main_menu": "Peamenüü", "make": "Mark", "manage_shared_links": "Halda jagatud linke", "manage_sharing_with_partners": "Halda partneritega jagamist", diff --git a/i18n/fa.json b/i18n/fa.json index ca9b75c5e4..e7df0ded6d 100644 --- a/i18n/fa.json +++ b/i18n/fa.json @@ -926,4 +926,4 @@ "yes": "بله", "you_dont_have_any_shared_links": "", "zoom_image": "بزرگنمایی تصویر" -} \ No newline at end of file +} diff --git a/i18n/fi.json b/i18n/fi.json index ea36a994b4..0c70897a89 100644 --- a/i18n/fi.json +++ b/i18n/fi.json @@ -219,7 +219,7 @@ "reset_settings_to_default": "Nollaa asetukset oletuksille", "reset_settings_to_recent_saved": "Palauta aiemmin tallennetut asetukset", "scanning_library": "Kirjastoa skannataan", - "search_jobs": "Etsi tehtäviä...", + "search_jobs": "Etsi tehtäviä…", "send_welcome_email": "Lähetä tervetuloviesti", "server_external_domain_settings": "Ulkoinen osoite", "server_external_domain_settings_description": "Osoite julkisille linkeille, http(s):// mukaan lukien", @@ -406,11 +406,11 @@ "are_these_the_same_person": "Ovatko he sama henkilö?", "are_you_sure_to_do_this": "Haluatko varmasti tehdä tämän?", "asset_added_to_album": "Lisätty albumiin", - "asset_adding_to_album": "Lisätään albumiin...", + "asset_adding_to_album": "Lisätään albumiin…", "asset_description_updated": "Kohteen kuvaus on päivitetty", "asset_filename_is_offline": "Kohde {filename} on offline-tilassa", "asset_has_unassigned_faces": "Kohteella on määrittämättömiä kasvoja", - "asset_hashing": "Hajautetaan...", + "asset_hashing": "Hajautetaan…", "asset_offline": "Aineisto offline-tilassa", "asset_offline_description": "Tätä ulkoista resurssia ei enää löydy levyltä. Ole hyvä ja ota yhteyttä Immich-järjestelmänvalvojaan saadaksesi apua.", "asset_skipped": "Ohitettu", @@ -1352,4 +1352,4 @@ "yes": "Kyllä", "you_dont_have_any_shared_links": "Sinulla ei ole jaettuja linkkejä", "zoom_image": "Zoomaa kuvaa" -} \ No newline at end of file +} diff --git a/i18n/fr.json b/i18n/fr.json index 441a185500..dc86ef7f37 100644 --- a/i18n/fr.json +++ b/i18n/fr.json @@ -66,6 +66,11 @@ "forcing_refresh_library_files": "Forcer le rafraîchissement de tous les fichiers de la bibliothèque", "image_format": "Format", "image_format_description": "WebP produit des fichiers plus petits que JPEG, mais son encodage est plus lent.", + "image_fullsize_description": "Image en taille réelle, sans métadonnées, utilisée lors d'un zoom", + "image_fullsize_enabled": "Activer la génération d'image en taille d'origine", + "image_fullsize_enabled_description": "Générer une image en taille réelle pour les formats non compatibles avec le web. Lorsque l'option « Préférer l'aperçu intégré » est activée, les aperçus intégrés sont utilisés directement sans conversion. Cette option n'affecte pas les formats compatibles avec le web tels que JPEG.", + "image_fullsize_quality_description": "Qualité de l'image en taille réelle de 1 à 100. Une valeur plus élevée est meilleure, mais produit des fichiers plus volumineux.", + "image_fullsize_title": "Paramètres des images en taille réelle", "image_prefer_embedded_preview": "Préférer l'aperçu intégré", "image_prefer_embedded_preview_setting_description": "Utiliser les miniatures intégrées dans les photos au format RAW comme entrées pour le traitement d'image quand elles sont disponibles. Cela peut donner des couleurs plus justes pour certaines images, mais la qualité des miniatures est dépendant de l'appareil photo et l'image peut avoir des artéfacts de compression.", "image_prefer_wide_gamut": "Préférer une gamme de couleurs étendue", @@ -859,6 +864,7 @@ "loop_videos": "Vidéos en boucle", "loop_videos_description": "Activer pour voir la vidéo en boucle dans le lecteur détaillé.", "main_branch_warning": "Vous utilisez une version de développement. Nous vous recommandons fortement d'utiliser une version stable !", + "main_menu": "Menu principal", "make": "Marque", "manage_shared_links": "Gérer les liens partagés", "manage_sharing_with_partners": "Gérer le partage avec les partenaires", diff --git a/i18n/gl.json b/i18n/gl.json index 0967ef424b..34cac84280 100644 --- a/i18n/gl.json +++ b/i18n/gl.json @@ -1 +1,57 @@ -{} +{ + "about": "Acerca de", + "account": "Conta", + "account_settings": "Configuración da conta", + "acknowledge": "De acordo", + "action": "Acción", + "actions": "Accións", + "active": "Activo", + "activity": "Actividade", + "activity_changed": "A actividade está {enabled, select, true {habilitada} other {deshabilitada}}", + "add": "Engadir", + "add_a_description": "Engadir unha descrición", + "add_a_location": "Engadir unha localización", + "add_a_name": "Engadir un nome", + "add_a_title": "Engadir un título", + "add_exclusion_pattern": "Engadir patrón de exclusión", + "add_import_path": "Engadir ruta de importación", + "add_location": "Engadir localización", + "add_more_users": "Engadir máis usuarios", + "add_partner": "Engadir compañeiro", + "add_path": "Engadir ruta", + "add_photos": "Engadir fotos", + "add_to": "Engadir a…", + "add_to_album": "Engadir ao álbum", + "add_to_shared_album": "Engadir ao álbum compartido", + "add_url": "Engadir URL", + "added_to_archive": "Engadido ao arquivo", + "added_to_favorites": "Engadido a favoritos", + "added_to_favorites_count": "Engadidos {count, number} a favoritos", + "admin": { + "authentication_settings": "Configuración de autenticación", + "authentication_settings_description": "Xestionar contrasinal, OAuth e outros parámetros de autenticación", + "authentication_settings_disable_all": "Estás seguro de deshabilitar todos os métodos de inicio de sesión? Iniciar a sesión quedará completamente deshabilitado.", + "authentication_settings_reenable": "Para rehabilitala, usa un Comando do servidor.", + "background_task_job": "Tarefas en segundo plano", + "backup_database": "Respaldo da base de datos", + "backup_database_enable_description": "Habilitar as copias de seguridade da base de datos", + "backup_keep_last_amount": "Cantidade de copias de seguridade previas a manter", + "backup_settings": "Configuración de copias de seguridade", + "backup_settings_description": "Xestionar a configuración das copias de seguridade da base de datos", + "check_all": "Comprobar todo", + "cleared_jobs": "Traballos borrados para: {job}", + "config_set_by_file": "As configuracións están actualmente seleccionadas por un ficheiro de configuracións", + "confirm_delete_library": "Estás seguro de que queres eliminar a biblioteca {library}?", + "exclusion_pattern_description": "Os patróns de exclusión permítenche ignorar ficheiros e cartafoles ao escanear a túa biblioteca. Isto é útil se tes cartafoles que conteñen ficheiros que non queres importar, coma ficheiros RAW.", + "external_library_created_at": "Biblioteca externa (creada o {date})", + "external_library_management": "Xestión de bibliotecas externas", + "face_detection": "Detección de caras", + "job_settings": "Configuración de tarefas", + "job_settings_description": "Administrar tarefas simultáneas", + "job_status": "Estado da tarefa", + "jobs_failed": "{jobCount, one {# errado}, plural, other {# errados}}" + }, + "year": "Ano", + "yes": "Si", + "zoom_image": "Acercar imaxe" +} diff --git a/i18n/he.json b/i18n/he.json index d742907cb5..f89af613f9 100644 --- a/i18n/he.json +++ b/i18n/he.json @@ -66,6 +66,11 @@ "forcing_refresh_library_files": "כפיית רענון של כל קבצי הספרייה", "image_format": "פורמט", "image_format_description": "WebP מפיק קבצים קטנים יותר מ JPEG, אך הוא איטי יותר לקידוד.", + "image_fullsize_description": "תמונה בגודל מלא עם מטא נתונים מוסרים, בעת שימוש בהגדלה", + "image_fullsize_enabled": "אפשר יצירה של תמונות באיכות מלאה", + "image_fullsize_enabled_description": "צור תמונה בגודל מלא עבור פורמטים שאינם ידידותיים לאינטרנט. כאשר \"העדף תצוגה מקדימה מוטמעת\" מופעלת, תצוגות מקדימות מוטמעות משמשות ישירות ללא המרה. זה לא משפיע על פורמטים ידידותיים לאינטרנט כמו JPEG.", + "image_fullsize_quality_description": "תמונה בגודל מלא באיכות מ 1-100. גבוהה יותר טוב יותר, אך מייצר קובץ גדול יותר.", + "image_fullsize_title": "הגדרות תמונה בגודל מלא", "image_prefer_embedded_preview": "העדף תצוגה מקדימה מוטמעת", "image_prefer_embedded_preview_setting_description": "השתמש בתצוגות מקדימות מוטמעות בתמונות RAW כקלט לעיבוד תמונה כאשר זמינות. זה יכול להפיק צבעים מדויקים יותר עבור תמונות מסוימות, אבל האיכות של התצוגה המקדימה היא תלוית מצלמה ולתמונה עשויים להיות יותר פגמי דחיסה.", "image_prefer_wide_gamut": "העדף סולם צבעים רחב", @@ -859,6 +864,7 @@ "loop_videos": "הפעלה חוזרת של סרטונים", "loop_videos_description": "אפשר הפעלה חוזרת אוטומטית של סרטון במציג הפרטים.", "main_branch_warning": "את/ה משתמש/ת בגרסת פיתוח; אנחנו ממליצים בחום להשתמש בגרסה יציבה!", + "main_menu": "תפריט ראשי", "make": "תוצרת", "manage_shared_links": "ניהול קישורים משותפים", "manage_sharing_with_partners": "ניהול שיתוף עם שותפים", diff --git a/i18n/hr.json b/i18n/hr.json index 8b8e909391..bf1269c32e 100644 --- a/i18n/hr.json +++ b/i18n/hr.json @@ -1253,4 +1253,4 @@ "yes": "", "you_dont_have_any_shared_links": "", "zoom_image": "" -} \ No newline at end of file +} diff --git a/i18n/hu.json b/i18n/hu.json index 4d59a6d17f..0f63602926 100644 --- a/i18n/hu.json +++ b/i18n/hu.json @@ -66,6 +66,11 @@ "forcing_refresh_library_files": "A képtár összes fájljának frissítése", "image_format": "Formátum", "image_format_description": "WebP a JPEG-nél kisebb fájlokat készít, de lassabban.", + "image_fullsize_description": "Teljes méretű kép eltávolított metaadatokkal, nagyításkor használva", + "image_fullsize_enabled": "Teljes méretű képgenerálás engedélyezése", + "image_fullsize_enabled_description": "Teljes méretű kép generálása nem webbarát formátumokhoz. Ha a „Beágyazott előnézet preferálása” engedélyezve van, a beágyazott előnézetek közvetlenül, átalakítás nélkül kerülnek felhasználásra. Nem érinti a webbarát formátumokat, például a JPEG-et.", + "image_fullsize_quality_description": "Teljes méretű képminőség 1-100 között. A magasabb érték jobb minőséget eredményez, de nagyobb fájlméretet is.", + "image_fullsize_title": "Teljes méretű képbeállítások", "image_prefer_embedded_preview": "Beágyazott előnézeti kép előnyben részesítése", "image_prefer_embedded_preview_setting_description": "Nyers (RAW) fotók esetén használja a beépített előnézeti képet (ha van) a képek feldogozásához. Ez néhány kép esetében pontosabb színeket eredményezhet, de az előnézeti kép minősége erősen fényképezőgép függő, és a képen előfordulhatnak tömörítési hibák.", "image_prefer_wide_gamut": "Széles színtér preferálása", @@ -859,6 +864,7 @@ "loop_videos": "Videók ismétlése", "loop_videos_description": "Engedélyezi a videók folyamatosan ismételt lejátszását.", "main_branch_warning": "Fejlesztői verziót használsz. Javasoljuk a stabil verzió használatát!", + "main_menu": "Főmenü", "make": "Gyártó", "manage_shared_links": "Megosztási linkek kezelése", "manage_sharing_with_partners": "Partnerekkel való megosztás kezelése", diff --git a/i18n/it.json b/i18n/it.json index f3438efd6b..58d8ec73b1 100644 --- a/i18n/it.json +++ b/i18n/it.json @@ -66,6 +66,11 @@ "forcing_refresh_library_files": "Forzando l'aggiornamento completo della libreria", "image_format": "Formato", "image_format_description": "WebP produce file più piccoli rispetto a JPEG, ma l'encoding è più lento.", + "image_fullsize_description": "Le immagini con dimensioni reali senza metadati sono utilizzate durante lo zoom", + "image_fullsize_enabled": "Abilita la generazione delle immagini con dimensioni reali", + "image_fullsize_enabled_description": "Genera immagini con dimensioni reali per i formati non web-friendly. Quando \"Preferisci l'anteprima integrata\" è abilitata, le anteprime integrate saranno usate senza conversione. Non riguarda le immagini web-friendly come il JPEG.", + "image_fullsize_quality_description": "Qualità delle immagini con dimensioni reali da 1 a 100. Più è alto il valore più la qualità sarà alta come anche la grandezza dei file.", + "image_fullsize_title": "Impostazioni Immagini con dimensioni reali", "image_prefer_embedded_preview": "Preferisci l'anteprima integrata", "image_prefer_embedded_preview_setting_description": "Usa l'anteprima integrata nelle foto RAW come input per l'elaborazione delle immagini, se disponibile. Questo permette un miglioramento dei colori per alcune immagini, ma la qualità delle anteprime dipende dalla macchina fotografica. Inoltre le immagini potrebbero presentare artefatti di compressione.", "image_prefer_wide_gamut": "Preferisci gamut più ampio", @@ -859,6 +864,7 @@ "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_menu": "Menu Principale", "make": "Produttore", "manage_shared_links": "Gestisci link condivisi", "manage_sharing_with_partners": "Gestisci la condivisione con i compagni", diff --git a/i18n/ja.json b/i18n/ja.json index b0d3a49126..83f4ba1e7c 100644 --- a/i18n/ja.json +++ b/i18n/ja.json @@ -66,6 +66,9 @@ "forcing_refresh_library_files": "すべてのライブラリファイルを強制更新", "image_format": "フォーマット", "image_format_description": "WebPはJPEGよりもファイルサイズが小さいですが、エンコードに時間がかかります。", + "image_fullsize_enabled": "原寸大画像生成を有効にする", + "image_fullsize_quality_description": "1から100まで原寸大画像の質です。高いほうがいいがファイルが大きくなります。", + "image_fullsize_title": "原寸大画像設定", "image_prefer_embedded_preview": "埋め込みプレビューを優先", "image_prefer_embedded_preview_setting_description": "RAW写真の埋め込みプレビューが利用可能な場合に画像処理の入力として使用します。これにより、いくつかの画像でより正確な色を得ることができますが、プレビューの品質はカメラによって異なり、画像により多くの圧縮アーティファクトが含まれる場合があります。", "image_prefer_wide_gamut": "広色域に対応させる", @@ -859,6 +862,7 @@ "loop_videos": "動画をループ", "loop_videos_description": "有効にすると詳細表示で自動的に動画がループします。", "main_branch_warning": "開発版を使っているようです。リリース版の使用を強く推奨します!", + "main_menu": "メインメニュー", "make": "メーカー", "manage_shared_links": "共有済みのリンクを管理", "manage_sharing_with_partners": "パートナーとの共有を管理します", diff --git a/i18n/ka.json b/i18n/ka.json index 0967ef424b..a521539d11 100644 --- a/i18n/ka.json +++ b/i18n/ka.json @@ -1 +1,165 @@ -{} +{ + "about": "შესახებ", + "account": "ანგარიში", + "account_settings": "ანგარიშის პარამეტრები", + "acknowledge": "მიღება", + "action": "ქმედება", + "actions": "ქმედებები", + "active": "აქტიური", + "activity": "აქტივობა", + "add": "დამატება", + "add_a_description": "დაამატე აღწერა", + "add_a_location": "დაამატე ადგილი", + "add_a_name": "დაამატე სახელი", + "add_a_title": "დაასათაურე", + "add_import_path": "დაამატე საიმპორტო მისამართი", + "add_location": "დაამატე ადგილი", + "add_more_users": "დაამატე მომხმარებლები", + "add_partner": "დაამატე პარტნიორი", + "add_path": "დაამატე მისამართი", + "add_photos": "დაამატე ფოტოები", + "add_to_album": "დაამატე ალბომში", + "add_to_shared_album": "დაამატე საზიარო ალბომში", + "add_url": "დაამატე URL", + "added_to_archive": "დაარქივდა", + "added_to_favorites": "დაამატე რჩეულებში", + "added_to_favorites_count": "{count, number} დაემატა რჩეულებში", + "admin": { + "authentication_settings": "ავთენტიკაციის პარამეტრები", + "authentication_settings_description": "პაროლის, OAuth-ის და სხვა ავტენთიფიკაციის პარამეტრების მართვა", + "authentication_settings_disable_all": "ნამდვილად გინდა ავტორიზაციის ყველა მეთოდის გამორთვა? ავტორიზაციას ვეღარანაირად შეძლებ.", + "authentication_settings_reenable": "რეაქტივაციისთვის, გამოიყენე სერვერის ბრძანება.", + "background_task_job": "ფონური დავალებები", + "backup_database": "შექმენი სარეზერვო ასლი", + "backup_database_enable_description": "ჩართე სარეზერვო ასლების ფუნქცია", + "backup_keep_last_amount": "შესანახი სარეზერვო ასლების რაოდენობა", + "backup_settings": "სარეზერვო ასლების პარამეტრები", + "backup_settings_description": "მონაცემთა ბაზის სარეზერვო ასლების პარამეტრების მართვა", + "check_all": "შეამოწმე ყველა", + "cleanup": "გასუფთავება", + "confirm_delete_library": "ნამდვილად გინდა {library} ბიბლიოთეკის წაშლა?", + "confirm_email_below": "დასადასტურებლად, ქვემოთ აკრიფე \"{email}\"", + "confirm_user_password_reset": "ნამდვილად გინდა {user}-(ი)ს პაროლის დარესეტება?", + "disable_login": "გამორთე ავტორიზაცია", + "external_library_management": "გარე ბიბლიოთეკების მართვა", + "face_detection": "სახის ამოცნობა", + "image_format": "ფორმატი", + "image_fullsize_title": "სრული ზომის გამოსახულების პარამეტრები", + "image_quality": "ხარისხი", + "image_resolution": "გაფართოება", + "image_settings": "გამოსახულების პარამეტრები", + "image_settings_description": "გენერირებული ფოტოების ხარისხისა და რეზოლუციის მართვა", + "image_thumbnail_description": "მინიატურა მეტაინფორმაციის გარეშე, რომელიც ფოტოები ჯგუფურად თვალიერებისას გამოიყენება(მაგ. მთავარ თაიმლაინზე)", + "image_thumbnail_quality_description": "მინიატურის ხარისხი 1-დან 100-მდე. დიდი რიცხვი შეესაბამება უკეთეს ხარისხს, თუმცა, უფრო დიდ ფაილებს და აპლიკაციის შესაძლო შენელებას.", + "image_thumbnail_title": "მინიატურის პარამეტრები", + "library_created": "შეიქმნა ბიბლიოთეკა: {library}", + "library_deleted": "ბიბლიოთეკა წაიშალა", + "library_import_path_description": "აირჩიე დასაიმპორტებელი საქაღალდე. ფოტოები და ვიდეოები მოიძებნება ამ საქაღალდესა და მასში არსებულ საქაღალდეებში.", + "library_settings_description": "გარე ბიბლიოთეკების პარამეტრების მართვა", + "logging_settings": "ჟურნალი", + "map_settings": "რუკა", + "migration_job": "მიგრაცია", + "oauth_scope": "დიაპაზონი", + "oauth_settings": "OAuth", + "template_email_preview": "მინიატურა", + "transcoding_acceleration_vaapi": "VAAPI", + "transcoding_threads": "ნაკადები", + "transcoding_tone_mapping": "ტონების ასახვა" + }, + "administration": "ადმინისტრაცია", + "advanced": "დამატებით", + "albums": "ალბომები", + "all": "ყველა", + "anti_clockwise": "საათის ისრის საწინააღმდეგო", + "archive": "არქივი", + "asset_hashing": "დაჰეშვა.…", + "asset_skipped": "გამოტოვებულია", + "asset_uploaded": "ატვირთულია", + "asset_uploading": "მიმდინარეობს ატვირთვა…", + "assets": "ობიექტები", + "back": "უკან", + "backward": "უკან გადასვლა", + "build": "აგება", + "camera": "კამერა", + "cancel": "გაუქმება", + "city": "ქალაქი", + "clear": "გასუფთავება", + "clockwise": "საათის ისრის მიმართულებით", + "close": "დახურვა", + "collapse": "აკეცვა", + "color": "ფერი", + "confirm": "დასტური", + "contain": "შეიცავს", + "context": "კონტექსტი", + "continue": "გაგრძელება", + "country": "ქვეყანა", + "cover": "ყდა", + "covers": "ყდები", + "create": "შექმნა", + "created": "შექმნილია", + "dark": "მუქი", + "day": "დღე", + "delete": "წაშლა", + "description": "აღწერა", + "details": "დეტალები", + "direction": "მიმართულება", + "disabled": "გათიშულია", + "discord": "Discord", + "discover": "აღმოჩენა", + "documentation": "დოკუმენტაცია", + "done": "მზადაა", + "download": "გადმოწერა", + "download_settings": "გადმოწერა", + "downloading": "მიმდინარეობს გადმოწერა", + "duplicates": "დუბლიკატები", + "duration": "ხანგრძლივობა", + "edit": "ჩასწორება", + "edited": "ჩასწორებულია", + "editor": "რედაქტორი", + "editor_crop_tool_h2_rotation": "ტრიალი", + "email": "ელფოსტა", + "enable": "ჩართვა", + "enabled": "ჩართულია", + "error": "შეცდომა", + "exif": "Exif", + "expired": "ვადაამოწურულია", + "explore": "დათვალიერება", + "explorer": "გამცილებელი", + "export": "გატანა", + "extension": "გაფართოება", + "external": "გარე", + "face_unassigned": "მიუნიჭებელი", + "favorite": "რჩეული", + "favorites": "რჩეულები", + "features": "თვისებები", + "filename": "ფაილის სახელი", + "filetype": "ფაილის ტიპი", + "folders": "საქაღალდეები", + "forward": "წინ", + "general": "ზოგადი", + "host": "ჰოსტი", + "hour": "საათი", + "image": "გამოსახულება", + "info": "ინფორმაცია", + "jobs": "დავალებები", + "keep": "შენარჩუნება", + "language": "ენა", + "latitude": "განედი", + "leave": "გასვლა", + "level": "დონე", + "library": "ბიბლიოთეკა", + "light": "ღია", + "list": "სია", + "loading": "ჩატვირთვა", + "login": "შესვლა", + "longitude": "გრძედი", + "look": "შეხედვა", + "make": "მწარმოებელი", + "map": "რუკა", + "matches": "დამთხვევები", + "memories": "მოგონებები", + "memory": "მეხსიერება", + "menu": "მენიუ", + "merge": "შერწყმა", + "minimize": "დაპატარავება" +} diff --git a/i18n/lv.json b/i18n/lv.json index cd6ae04da9..e946a72279 100644 --- a/i18n/lv.json +++ b/i18n/lv.json @@ -699,12 +699,18 @@ "purchase_button_remove_key": "Noņemt atslēgu", "purchase_button_select": "Izvēlēties", "purchase_individual_description_2": "Atbalstītāja statuss", + "purchase_input_suggestion": "Vai tev ir produkta atslēga? Ievadi atslēgu zemāk", + "purchase_license_subtitle": "Nopērc Immich licenci, lai atbalstītu turpmāku pakalpojuma attīstību", + "purchase_lifetime_description": "Pirkums uz mūžu", + "purchase_option_title": "IEGĀDES IESPĒJAS", "purchase_panel_title": "Atbalstīt projektu", "purchase_remove_product_key": "Noņemt produkta atslēgu", + "purchase_remove_server_product_key": "Noņemt servera produkta atslēgu", "purchase_server_description_1": "Visam serverim", "purchase_server_description_2": "Atbalstītāja statuss", "purchase_server_title": "Serveris", "purchase_settings_server_activated": "Servera produkta atslēgu pārvalda administrators", + "rating_clear": "Noņemt vērtējumu", "reaction_options": "", "read_changelog": "Lasīt izmaiņu sarakstu", "recent": "", diff --git a/i18n/ms.json b/i18n/ms.json index 7da863750d..d35f999a9b 100644 --- a/i18n/ms.json +++ b/i18n/ms.json @@ -372,4 +372,4 @@ "yes": "Ya", "you_dont_have_any_shared_links": "Anda tidak mempunyai apa-apa pautan yang dikongsi", "zoom_image": "Zum Gambar" -} \ No newline at end of file +} diff --git a/i18n/nb_NO.json b/i18n/nb_NO.json index e06baf4464..368ec816ad 100644 --- a/i18n/nb_NO.json +++ b/i18n/nb_NO.json @@ -1374,7 +1374,7 @@ "welcome": "Velkommen", "welcome_to_immich": "Velkommen til Immich", "year": "År", - "years_ago": "{years, plural, one {# year} other {# years}} siden", + "years_ago": "{years, plural, one {# år} other {# år}} siden", "yes": "Ja", "you_dont_have_any_shared_links": "Du har ingen delte lenker", "zoom_image": "Zoom Bilde" diff --git a/i18n/nl.json b/i18n/nl.json index e91bc9c222..cb747d5085 100644 --- a/i18n/nl.json +++ b/i18n/nl.json @@ -66,8 +66,13 @@ "forcing_refresh_library_files": "Geforceerd vernieuwen van alle bibliotheekbestanden", "image_format": "Formaat", "image_format_description": "WebP produceert kleinere bestanden dan JPEG, maar is langzamer om te verwerken.", + "image_fullsize_description": "Afbeelding op ware grootte met gestripte metadata, gebruikt bij inzoomen", + "image_fullsize_enabled": "Genereer afbeeldingen op ware grootte inschakelen", + "image_fullsize_enabled_description": "Genereer afbeelding op volledig formaat voor niet-webvriendelijke formaten. Als “Ingebed voorvertoning verkiezen” is ingeschakeld, worden ingesloten voorvertoningen direct gebruikt zonder conversie. Heeft geen invloed op webvriendelijke formaten zoals JPEG.", + "image_fullsize_quality_description": "Beeldkwaliteit op ware grootte van 1-100. Hoger is beter, maar genereert grotere bestanden.", + "image_fullsize_title": "Instellingen afbeelding op ware grootte", "image_prefer_embedded_preview": "Ingebedde voorbeeldafbeelding gebruiken", - "image_prefer_embedded_preview_setting_description": "Ingebedde voorbeeldafbeelding van RAW bestanden gebruiken als invoer voor beeldverwerking wanneer beschikbaar. Dit kan preciezere kleuren produceren voor sommige afbeeldingen, maar de kwaliteit van het voorbeeld is afhankelijk van de camera en de afbeelding kan mogelijk meer compressie-artefacten hebben.", + "image_prefer_embedded_preview_setting_description": "Ingebedde voorbeeldafbeelding van RAW bestanden gebruiken als invoer voor beeldverwerking wanneer beschikbaar. Dit kan preciezere kleuren produceren voor sommige afbeeldingen, maar de kwaliteit van het voorbeeld is afhankelijk van de camera en de afbeelding kan mogelijk meer compressie-artefacten bevatten.", "image_prefer_wide_gamut": "Voorkeur geven aan wide gamut", "image_prefer_wide_gamut_setting_description": "Display P3 gebruiken voor voorbeeldafbeeldingen. Dit behoudt de levendigheid van afbeeldingen met brede kleurruimtes beter, maar afbeeldingen kunnen er anders uitzien op oude apparaten met een oude browserversie. sRGB-afbeeldingen blijven sRGB gebruiken om kleurverschuivingen te vermijden.", "image_preview_description": "Middelgrote afbeelding met verwijderde metadata, gebruikt bij het bekijken van een enkele asset en voor machine learning", @@ -859,6 +864,7 @@ "loop_videos": "Video's herhalen", "loop_videos_description": "Inschakelen om video's automatisch te herhalen in de detailweergave.", "main_branch_warning": "U gebruikt een ontwikkelingsversie. Wij raden u ten zeerste aan een releaseversie te gebruiken!", + "main_menu": "Hoofdmenu", "make": "Merk", "manage_shared_links": "Beheer gedeelde links", "manage_sharing_with_partners": "Beheer delen met partners", diff --git a/i18n/pl.json b/i18n/pl.json index cccab60061..74b6f6e77f 100644 --- a/i18n/pl.json +++ b/i18n/pl.json @@ -1047,7 +1047,7 @@ "purchase_server_title": "Serwer", "purchase_settings_server_activated": "Klucz produktu serwera jest zarządzany przez administratora", "rating": "Ocena gwiazdkowa", - "rating_clear": "Wyczyść oceną", + "rating_clear": "Wyczyść ocenę", "rating_count": "{count, plural, one {# gwiazdka} other {# gwiazdek}}", "rating_description": "Wyświetl ocenę z EXIF w panelu informacji", "reaction_options": "Opcje reakcji", @@ -1077,8 +1077,10 @@ "remove_custom_date_range": "Usuń niestandardowy zakres dat", "remove_deleted_assets": "Usuń Niedostępne Pliki", "remove_from_album": "Usuń z albumu", - "remove_from_favorites": "Usuń z ulubionych", + "remove_from_favorites": "Usuń z ulubionych", "remove_from_shared_link": "Usuń z udostępnionego linku", + "remove_memory": "Usuń pamięć", + "remove_photo_from_memory": "Usuń zdjęcia z tej pamięci", "remove_url": "Usuń URL", "remove_user": "Usuń użytkownika", "removed_api_key": "Usunięto Klucz API: {name}", diff --git a/i18n/pt.json b/i18n/pt.json index 323007cb69..1cc725499e 100644 --- a/i18n/pt.json +++ b/i18n/pt.json @@ -66,8 +66,13 @@ "forcing_refresh_library_files": "A forçar a atualização de todos os ficheiros da biblioteca", "image_format": "Formato", "image_format_description": "WebP produz ficheiros mais pequenos do que JPEG, mas é mais lento para codificar.", + "image_fullsize_description": "Imagem de tamanho inteiro sem meta dados, utilizada quando esta for ampliada", + "image_fullsize_enabled": "Ativar geração de imagem em tamanho inteiro", + "image_fullsize_enabled_description": "Gerar imagens de tamanho inteiro para formatos não compatíveis com a web. Quando a opção \"Preferir visualização incorporada\" está ativada, estas serão utilizadas diretamente sem serem convertidas. Não afeta formatos compatíveis com a web tais como JPEG.", + "image_fullsize_quality_description": "Qualidade da imagem de tamanho inteiro de 1 a 100. Valores mais altos são melhores, mas produzem ficheiros maiores.", + "image_fullsize_title": "Definições de imagem de tamanho inteiro", "image_prefer_embedded_preview": "Preferir visualização incorporada", - "image_prefer_embedded_preview_setting_description": "Utilizar visualizações incorporadas em fotos RAW como entrada para processamento de imagem, quando disponível. Isto pode produzir cores mais precisas para algumas imagens, mas a qualidade da visualização depende da câmara e a imagem pode ter mais artefatos de compressão.", + "image_prefer_embedded_preview_setting_description": "Utilizar visualizações incorporadas em fotos RAW como entrada para processamento de imagem e quando disponível. Isto pode produzir cores mais precisas para algumas imagens, mas a qualidade da visualização depende da câmara e a imagem pode ter mais artefatos de compressão.", "image_prefer_wide_gamut": "Prefira ampla gama", "image_prefer_wide_gamut_setting_description": "Utilizar Display P3 para miniaturas. Isso preserva melhor a vibrância das imagens com espaços de cores amplos, mas as imagens podem aparecer de maneira diferente em dispositivos antigos com uma versão antiga do navegador. As imagens sRGB são mantidas como sRGB para evitar mudanças de cores.", "image_preview_description": "Imagem de tamanho médio sem metadados, utilizada ao visualizar um único ficheiro e pela aprendizagem de máquina", @@ -467,7 +472,7 @@ "check_all": "Verificar tudo", "check_logs": "Verificar registos", "choose_matching_people_to_merge": "Escolha pessoas correspondentes para unir", - "city": "Cidade", + "city": "Cidade/Localidade", "clear": "Limpar", "clear_all": "Limpar tudo", "clear_all_recent_searches": "Limpar todas as pesquisas recentes", @@ -859,6 +864,7 @@ "loop_videos": "Repetir vídeos", "loop_videos_description": "Ativar para repetir os vídeos automaticamente durante a exibição.", "main_branch_warning": "Está a utilizar uma versão de desenvolvimento, recomendamos vivamente que utilize uma versão estável!", + "main_menu": "Menu Principal", "make": "Marca", "manage_shared_links": "Gerir links partilhados", "manage_sharing_with_partners": "Gerir partilha com parceiros", @@ -1244,7 +1250,7 @@ "stacktrace": "Stacktrace", "start": "Iniciar", "start_date": "Data de início", - "state": "Estado", + "state": "Estado/Distrito", "status": "Estado", "stop_motion_photo": "Parar foto em movimento", "stop_photo_sharing": "Deixar de partilhar as suas fotos?", diff --git a/i18n/pt_BR.json b/i18n/pt_BR.json index bc7ec6a04c..5b99d3817f 100644 --- a/i18n/pt_BR.json +++ b/i18n/pt_BR.json @@ -66,8 +66,13 @@ "forcing_refresh_library_files": "Forçando a atualização de todos os arquivos da biblioteca", "image_format": "Formato", "image_format_description": "WebP produz arquivos menores que JPEG, mas é mais lento para codificar.", + "image_fullsize_description": "Imagem em tamanho real sem os metadados exibida quando der zoom", + "image_fullsize_enabled": "Ativar geração de imagem no tamanho real", + "image_fullsize_enabled_description": "Gerar imagens no tamanho real para os formatos de arquivos não compatíveis com a web. Quando \"Preferir visualização incorporada\" estiver ativado, essas serão utilizadas sem conversão. Não afeta arquivos já em formatos para web, como JPEG.", + "image_fullsize_quality_description": "Qualidade da imagem em tamanho real, de 1 a 100. Valores maiores tem melhor qualidade, mas gera arquivos maiores.", + "image_fullsize_title": "Configurações de imagem em tamanho real", "image_prefer_embedded_preview": "Preferir visualização incorporada", - "image_prefer_embedded_preview_setting_description": "Use visualizações incorporadas em fotos RAW como entrada para processamento de imagem, quando disponível. Isso pode produzir cores mais precisas para algumas imagens, mas a qualidade da visualização depende da câmera e a imagem pode ter mais artefatos de compactação.", + "image_prefer_embedded_preview_setting_description": "Use visualizações incorporadas em fotos RAW como a entrada para processamento de imagem e quando disponível. Isso pode produzir cores mais precisas para algumas imagens, mas a qualidade da visualização depende da câmera e a imagem pode ter mais artefatos de compactação.", "image_prefer_wide_gamut": "Prefira ampla gama", "image_prefer_wide_gamut_setting_description": "Use o Display P3 para miniaturas. Isso preserva melhor a vibração das imagens com espaços de cores amplos, mas as imagens podem aparecer de maneira diferente em dispositivos antigos com uma versão antiga do navegador. As imagens sRGB são mantidas como sRGB para evitar mudanças de cores.", "image_preview_description": "Imagem de tamanho médio sem os metadados, utilizado quando visualizando um único arquivo e também pelo aprendizado de máquina", @@ -1174,7 +1179,7 @@ "server_version": "Versão do servidor", "set": "Definir", "set_as_album_cover": "Definir como capa do álbum", - "set_as_featured_photo": "Definir como foto principal", + "set_as_featured_photo": "Definir como foto em destaque", "set_as_profile_picture": "Definir como foto de perfil", "set_date_of_birth": "Definir data de nascimento", "set_profile_picture": "Definir foto de perfil", @@ -1187,14 +1192,14 @@ "shared_by_user": "Compartilhado por {user}", "shared_by_you": "Compartilhado por você", "shared_from_partner": "Fotos de {partner}", - "shared_link_options": "Opções do link compartilhado", + "shared_link_options": "Opções de link compartilhado", "shared_links": "Links compartilhados", "shared_links_description": "Compartilhar fotos e videos com um link", - "shared_photos_and_videos_count": "{assetCount, plural, one {# arquivo compartilhado.} other {# arquivos compartilhados.}}", + "shared_photos_and_videos_count": "{assetCount, plural, one {# Foto & vídeo compartilhado.} other {# Fotos & vídeos compartilhados.}}", "shared_with_partner": "Compartilhado com {partner}", - "sharing": "Compartilhar", + "sharing": "Compartilhamento", "sharing_enter_password": "Digite a senha para visualizar esta página.", - "sharing_sidebar_description": "Exibe o link Compartilhar na barra lateral", + "sharing_sidebar_description": "Exibe um link para Compartilhamento na barra lateral", "shift_to_permanent_delete": "pressione ⇧ para excluir permanentemente o arquivo", "show_album_options": "Exibir opções do álbum", "show_albums": "Exibir álbuns", @@ -1214,15 +1219,15 @@ "show_search_options": "Exibir opções de pesquisa", "show_shared_links": "Mostrar links compartilhados", "show_slideshow_transition": "Usar transições no modo de apresentação", - "show_supporter_badge": "Insígnia de Contribuidor", - "show_supporter_badge_description": "Mostrar a insígnia de contribuidor", + "show_supporter_badge": "Insígnia de apoiador", + "show_supporter_badge_description": "Mostrar uma insígnia de apoiador", "shuffle": "Aleatório", "sidebar": "Barra lateral", - "sidebar_display_description": "Exibir um link para visualizar na barra lateral", + "sidebar_display_description": "Exibir um link para a visualização na barra lateral", "sign_out": "Sair", "sign_up": "Registrar", "size": "Tamanho", - "skip_to_content": "Pular para o conteúdo", + "skip_to_content": "Ir para o conteúdo", "skip_to_folders": "Ir para pastas", "skip_to_tags": "Ir para os marcadores", "slideshow": "Apresentação", @@ -1240,19 +1245,19 @@ "stack_duplicates": "Empilhar duplicados", "stack_select_one_photo": "Selecione uma foto principal para a pilha", "stack_selected_photos": "Empilhar fotos selecionadas", - "stacked_assets_count": "{count, plural, one {# arquivo empilhado} other {# arquivos empilhados}}", + "stacked_assets_count": "{count, plural, one {# Arquivo empilhado} other {# Arquivos empilhados}}", "stacktrace": "Rastreamento de pilha", "start": "Início", "start_date": "Data inicial", "state": "Estado", "status": "Status", "stop_motion_photo": "Parar foto em movimento", - "stop_photo_sharing": "Parar de partilhar as suas fotos?", + "stop_photo_sharing": "Parar de compartilhar suas fotos?", "stop_photo_sharing_description": "{partner} não terá mais acesso às suas fotos.", - "stop_sharing_photos_with_user": "Parar de compartilhar as fotos com este usuário", + "stop_sharing_photos_with_user": "Parar de compartilhar suas fotos com este usuário", "storage": "Espaço de armazenamento", "storage_label": "Rótulo de armazenamento", - "storage_usage": "utilizado {used} de {available}", + "storage_usage": "Utilizado {used} de {available}", "submit": "Enviar", "suggestions": "Sugestões", "sunrise_on_the_beach": "Nascer do sol na praia", @@ -1264,11 +1269,11 @@ "tag": "Marcador", "tag_assets": "Marcar arquivos", "tag_created": "Marcador criado: {tag}", - "tag_feature_description": "Visualizar fotos e videos agrupados pelo tópico do marcador", + "tag_feature_description": "Navegando por fotos e videos agrupados pelo tópico lógico do marcador", "tag_not_found_question": "Não consegue encontrar o marcador? Crie uma novo aqui.", "tag_people": "Marcar pessoas", "tag_updated": "Marcador foi atualizado: {tag}", - "tagged_assets": "{count, plural, one {# arquivo marcado} other {# arquivos marcados}}", + "tagged_assets": "{count, plural, one {# Arquivo marcado} other {# Arquivos marcados}}", "tags": "Marcadores", "template": "Modelo", "theme": "Tema", @@ -1276,14 +1281,14 @@ "theme_selection_description": "Defina automaticamente o tema como claro ou escuro com base na preferência do sistema do seu navegador", "they_will_be_merged_together": "Eles serão mesclados", "third_party_resources": "Recursos de terceiros", - "time_based_memories": "Memórias baseada no tempo", + "time_based_memories": "Memórias baseadas no tempo", "timeline": "Linha do tempo", "timezone": "Fuso horário", "to_archive": "Arquivar", "to_change_password": "Alterar senha", "to_favorite": "Favorito", "to_login": "Iniciar sessão", - "to_parent": "Voltar um nível acima", + "to_parent": "Voltar para nível acima", "to_trash": "Mover para a lixeira", "toggle_settings": "Alternar configurações", "toggle_theme": "Alternar tema escuro", @@ -1297,7 +1302,7 @@ "trashed_items_will_be_permanently_deleted_after": "Os itens da lixeira serão deletados permanentemente após {days, plural, one {# dia} other {# dias}}.", "type": "Tipo", "unarchive": "Desarquivar", - "unarchived_count": "{count, plural, one {# desarquivado} other {# desarquivados}}", + "unarchived_count": "{count, plural, one {# Desarquivado} other {# Desarquivados}}", "unfavorite": "Remover favorito", "unhide_person": "Exibir pessoa", "unknown": "Desconhecido", @@ -1312,19 +1317,19 @@ "unnamed_album_delete_confirmation": "Tem certeza que deseja excluir este álbum?", "unnamed_share": "Compartilhamento sem nome", "unsaved_change": "Alteração não salva", - "unselect_all": "Limpar seleção", + "unselect_all": "Desselecionar todos", "unselect_all_duplicates": "Desselecionar todas as duplicatas", "unstack": "Desempilhar", - "unstacked_assets_count": "{count, plural, one {# arquivo não empilhado} other {# arquivos não empilhados}}", + "unstacked_assets_count": "{count, plural, one {# Arquivo desempilhado} other {# Arquivos desempilhados}}", "untracked_files": "Arquivos não monitorados", "untracked_files_decription": "Estes arquivos não são monitorados pela aplicação. Podem ser resultados de falhas em uma movimentação, carregamentos interrompidos, ou deixados para trás por causa de um problema", "up_next": "A seguir", "updated_password": "Senha atualizada", "upload": "Carregar", - "upload_concurrency": "Carregar simultâneo", + "upload_concurrency": "Envios simultâneos", "upload_errors": "Envio concluído com {count, plural, one {# erro} other {# erros}}, atualize a página para ver os novos arquivos carregados.", - "upload_progress": "{remaining, number} processando - {processed, number}/{total, number} já processados", - "upload_skipped_duplicates": "{count, plural, one {# arquivo duplicado foi ignorado} other {# arquivos duplicados foram ignorados}}", + "upload_progress": "{remaining, number} restantes - {processed, number}/{total, number} já processados", + "upload_skipped_duplicates": "{count, plural, one {# Arquivo duplicado foi ignorado} other {# Arquivos duplicados foram ignorados}}", "upload_status_duplicates": "Duplicados", "upload_status_errors": "Erros", "upload_status_uploaded": "Carregado", @@ -1334,23 +1339,23 @@ "use_custom_date_range": "Usar intervalo de datas personalizado", "user": "Usuário", "user_id": "ID do usuário", - "user_liked": "{user} curtiu {type, select, photo {a foto} video {o vídeo} asset {o arquivo} other {isso}}", + "user_liked": "{user} curtiu {type, select, photo {esta foto} video {este vídeo} asset {este arquivo} other {isto}}", "user_purchase_settings": "Comprar", "user_purchase_settings_description": "Gerenciar sua compra", "user_role_set": "Definir {user} como {role}", "user_usage_detail": "Detalhes de uso do usuário", - "user_usage_stats": "Estatísticas de utilização de conta", - "user_usage_stats_description": "Ver estatísticas de utilização de conta", + "user_usage_stats": "Estatísticas de utilização da conta", + "user_usage_stats_description": "Ver estatísticas de utilização da conta", "username": "Nome do usuário", "users": "Usuários", - "utilities": "Utilitários", + "utilities": "Ferramentas", "validate": "Validar", "variables": "Variáveis", "version": "Versão", "version_announcement_closing": "De seu amigo, Alex", "version_announcement_message": "Olá! Uma nova versão do Immich está disponível. Para evitar configurações incorretas, leia com calma a página de notas da versão e verifique se é necessário alterar alguma configuração, principalmente se você usa o WatchTower ou qualquer outro mecanismo que faça atualizações automáticas do Immich.", "version_history": "Histórico de versões", - "version_history_item": "Instalado {version} em {date}", + "version_history_item": "Versão {version} instalada em {date}", "video": "Vídeo", "video_hover_setting": "Reproduzir miniatura do vídeo ao passar o mouse", "video_hover_setting_description": "Reproduzir a miniatura do vídeo ao passar o mouse sobre o item. Mesmo quando desativado, a reprodução pode ser iniciada ao passar o mouse sobre o ícone de reprodução.", @@ -1359,15 +1364,15 @@ "view": "Ver", "view_album": "Ver álbum", "view_all": "Ver tudo", - "view_all_users": "Ver todos usuários", + "view_all_users": "Ver todos os usuários", "view_in_timeline": "Ver na linha do tempo", "view_link": "Ver link", "view_links": "Ver links", "view_name": "Ver", "view_next_asset": "Ver próximo arquivo", "view_previous_asset": "Ver arquivo anterior", - "view_stack": "Exibir Pilha", - "visibility_changed": "A visibilidade de {count, plural, one {# pessoa foi alterada} other {# pessoas foram alteradas}}", + "view_stack": "Ver Pilha", + "visibility_changed": "A visibilidade {count, plural, one {# da pessoa foi alterada} other {# das pessoas foi alterada}}", "waiting": "Aguardando", "warning": "Aviso", "week": "Semana", @@ -1376,6 +1381,6 @@ "year": "Ano", "years_ago": "{years, plural, one {# ano} other {# anos}} atrás", "yes": "Sim", - "you_dont_have_any_shared_links": "Não há links compartilhados", + "you_dont_have_any_shared_links": "Você não possui links compartilhados", "zoom_image": "Ampliar imagem" } diff --git a/i18n/ro.json b/i18n/ro.json index c88b01cd5c..4016dca4f1 100644 --- a/i18n/ro.json +++ b/i18n/ro.json @@ -1358,4 +1358,4 @@ "yes": "Da", "you_dont_have_any_shared_links": "Nu aveți linkuri partajate", "zoom_image": "Măriți Imaginea" -} \ No newline at end of file +} diff --git a/i18n/ru.json b/i18n/ru.json index 832d5a6d9e..d8e8138b23 100644 --- a/i18n/ru.json +++ b/i18n/ru.json @@ -1,7 +1,7 @@ { "about": "О продукте", "account": "Учётная запись", - "account_settings": "Настройки аккаунта", + "account_settings": "Настройки учётной записи", "acknowledge": "Подтвердить", "action": "Действие", "actions": "Действия", @@ -33,7 +33,7 @@ "authentication_settings": "Настройки аутентификации", "authentication_settings_description": "Управление паролями, OAuth и другими настройками аутентификации", "authentication_settings_disable_all": "Вы уверены, что хотите отключить все методы входа? Вход будет полностью отключен.", - "authentication_settings_reenable": "Чтобы снова включить, используйте Команда Сервера.", + "authentication_settings_reenable": "Чтобы снова включить, используйте Команду сервера.", "background_task_job": "Фоновые задачи", "backup_database": "Резервное копирование базы данных", "backup_database_enable_description": "Включить резервное копирование базы данных", @@ -59,13 +59,18 @@ "external_library_created_at": "Внешняя библиотека (создана {date})", "external_library_management": "Управление внешними библиотеками", "face_detection": "Обнаружение лиц", - "face_detection_description": "Обнаруживает лица на медиа с помощью машинного обучения. Для видео учитывается только миниатюра. “Обновить” — обработать все медиа. “Сброс” — удалить все имеющиеся данные лиц и обработать заново. “Пропущенные” — добавить в очередь необработанные медиа. Обнаруженные лица будут помещены в очередь распознавания для привязки к существующим или новым людям.", - "facial_recognition_job_description": "Группирует распознанные лица по людям. Этот шаг выполняется после завершения обнаружения лиц. “Сброс” - группирует все лица. “Пропущенные” - помещает в очередь лица, не привязанные к человеку.", + "face_detection_description": "Обнаруживает лица на медиа с использованием машинного обучения. Для видео анализируется только миниатюра. \"Обновить\" повторно обрабатывает все медиа. \"Сброс\" дополнительно удаляет все имеющиеся данные о лицах. \"Отсутствующие\" ставит в очередь медиа, которые ещё не были обработаны. Обнаруженные лица будут переданы в очередь для распознавания после завершения процесса их обнаружения, привязываясь к существующим или новым людям.", + "facial_recognition_job_description": "Группировка обнаруженных лиц по людям. Этот шаг выполняется после завершения обнаружения лиц. \"Сброс\" (пере)группирует все лица. \"Отсутствующие\" ставит в очередь лица, не привязанные к человеку.", "failed_job_command": "Команда {command} не выполнена для задачи: {job}", "force_delete_user_warning": "ПРЕДУПРЕЖДЕНИЕ: Это приведет к немедленному удалению пользователя и его ресурсов. Это действие невозможно отменить, и файлы не могут быть восстановлены.", "forcing_refresh_library_files": "Принудительное обновление всех файлов библиотеки", "image_format": "Формат", "image_format_description": "WebP создает файлы меньшего размера, чем JPEG, но кодирует медленнее.", + "image_fullsize_description": "Полноразмерное изображение без метаданных, используется при увеличении", + "image_fullsize_enabled": "Включить создание полноразмерного изображения", + "image_fullsize_enabled_description": "Создавать полноразмерное изображение для форматов, не предназначенных для веба. Когда включен параметр «Предпочитать встроенное превью», встроенные превью используются напрямую без конверсии. Не влияет на веб-совместимые форматы, такие как JPEG.", + "image_fullsize_quality_description": "Качество полноразмерного изображения от 1 до 100. Чем выше значение, тем лучше качество, но больше размер файла.", + "image_fullsize_title": "Настройки полноразмерного изображения", "image_prefer_embedded_preview": "Предпочитать встроенное превью", "image_prefer_embedded_preview_setting_description": "Используйте встроенные превью в фотографиях RAW в качестве входных данных для обработки изображений, если они доступны. Это может обеспечить более точную цветопередачу для некоторых изображений, но качество предварительного просмотра зависит от камеры, и изображение может иметь больше артефактов сжатия.", "image_prefer_wide_gamut": "Предпочитаю широкую гамму", @@ -859,6 +864,7 @@ "loop_videos": "Циклическое воспроизведение", "loop_videos_description": "Включить циклическое воспроизведение видео.", "main_branch_warning": "Вы используете версию для разработки; мы настоятельно рекомендуем использовать релизную версию!", + "main_menu": "Главное меню", "make": "Производитель", "manage_shared_links": "Управление публичными ссылками", "manage_sharing_with_partners": "Управление обменом информацией с партнерами. Эта функция позволяет вашему партнеру видеть ваши фотографии и видеозаписи, кроме тех, которые находятся в Архиве и Корзине", @@ -886,7 +892,7 @@ "merged_people_count": "Объединено {count, plural, one {# человек} few {# человека} many {# человек} other {# человека}}", "minimize": "Минимизировать", "minute": "Минута", - "missing": "Пропущенные", + "missing": "Отсутствующие", "model": "Модель", "month": "Месяц", "more": "Больше", @@ -1374,7 +1380,7 @@ "welcome": "Добро пожаловать", "welcome_to_immich": "Добро пожаловать в Immich", "year": "Год", - "years_ago": "{years, plural, one {# год} few {# года} many {# лет} other {# года}} назад", + "years_ago": "{years, plural, one {# год} few {# года} many {# лет} other {# лет}} назад", "yes": "Да", "you_dont_have_any_shared_links": "У вас нет публичных ссылок", "zoom_image": "Приблизить" diff --git a/i18n/sl.json b/i18n/sl.json index 049c4dadd6..e05dda5ac0 100644 --- a/i18n/sl.json +++ b/i18n/sl.json @@ -66,8 +66,13 @@ "forcing_refresh_library_files": "Vsiljena osvežitev vseh datotek knjižnice", "image_format": "Format", "image_format_description": "WebP ustvari manjše datoteke kot JPEG, vendar je počasnejši za kodiranje.", + "image_fullsize_description": "Slika v polni velikosti brez metapodatkov, uporabljena pri povečavi", + "image_fullsize_enabled": "Omogoči ustvarjanje slik v polni velikosti", + "image_fullsize_enabled_description": "Ustvari sliko v polni velikosti za formate, ki niso prijazni spletu. Ko je omogočena možnost »Prednostno vdelani predogled«, se vdelani predogledi uporabljajo neposredno brez pretvorbe. Ne vpliva na spletu prijazne formate, kot je JPEG.", + "image_fullsize_quality_description": "Kakovost slike v polni velikosti od 1 do 100. Višja vrednost pomeni boljšo kakovost, vendar ustvarja večje datoteke.", + "image_fullsize_title": "Nastavitve slike v polni velikosti", "image_prefer_embedded_preview": "Uporabi raje vdelan predogled", - "image_prefer_embedded_preview_setting_description": "Uporabite vdelane predoglede v fotografije RAW kot vhod za obdelavo slik, ko so na voljo. To lahko ustvari natančnejše barve za nekatere slike, vendar je kakovost predogleda odvisna od kamere in slika ima lahko več artefaktov stiskanja.", + "image_prefer_embedded_preview_setting_description": "Uporabi vdelane predoglede v fotografijah RAW kot vhod za obdelavo slik, kadar so na voljo. To lahko pri nekaterih slikah zagotovi natančnejše barve, vendar je kakovost predogleda odvisna od fotoaparata, slika pa lahko vsebuje več artefaktov stiskanja.", "image_prefer_wide_gamut": "Uporabi raje širok razpon", "image_prefer_wide_gamut_setting_description": "Uporabite P3 Display za sličice. To bolje ohranja živahnost slik s širokimi barvnimi prostori, vendar so lahko slike videti drugače na starih napravah s staro različico brskalnika. Slike sRGB se ohranijo kot sRGB, da se izognejo barvnim zamikom.", "image_preview_description": "Slika srednje velikosti z odstranjenimi metapodatki, ki se uporablja pri ogledu posameznega sredstva in za strojno učenje", @@ -859,6 +864,7 @@ "loop_videos": "Zanka videoposnetkov", "loop_videos_description": "Omogočite samodejno ponavljanje videoposnetka v pregledovalniku podrobnosti.", "main_branch_warning": "Uporabljate razvojno različico; močno priporočamo uporabo izdajne različice!", + "main_menu": "Glavni meni", "make": "Izdelava", "manage_shared_links": "Upravljanje povezav v skupni rabi", "manage_sharing_with_partners": "Upravljajte skupno rabo s partnerji", diff --git a/i18n/sr_Cyrl.json b/i18n/sr_Cyrl.json index 348e4b182c..279d786e9e 100644 --- a/i18n/sr_Cyrl.json +++ b/i18n/sr_Cyrl.json @@ -66,6 +66,11 @@ "forcing_refresh_library_files": "Принудно освежавање свих датотека библиотеке", "image_format": "Формат", "image_format_description": "WebP производи мање датотеке од ЈПЕГ, али се спорије кодира.", + "image_fullsize_description": "Слика у пуној величини са огољеним метаподацима, користи се када је увећана", + "image_fullsize_enabled": "Омогућите генерисање слике у пуној величини", + "image_fullsize_enabled_description": "Генеришите слику пуне величине за формате који нису прилагођени вебу. Када је „Преферирај уграђени преглед“ омогућен, уграђени прегледи се користе директно без конверзије. Не утиче на формате прилагођене вебу као што је JPEG.", + "image_fullsize_quality_description": "Квалитет слике у пуној величини од 1-100. Више је боље, али производи веће датотеке.", + "image_fullsize_title": "Подешавања слике у пуној величини", "image_prefer_embedded_preview": "Преферирајте уграђени преглед", "image_prefer_embedded_preview_setting_description": "Користите уграђене прегледе у RAW фотографије као улаз за обраду слике када су доступне. Ово може да произведе прецизније боје за неке слике, али квалитет прегледа зависи од камере и слика може имати више неправилности компресије.", "image_prefer_wide_gamut": "Преферирајте широк спектар", @@ -859,6 +864,7 @@ "loop_videos": "Понављајте видео записе", "loop_videos_description": "Омогућите за аутоматско понављање видео записа у прегледнику детаља.", "main_branch_warning": "Употребљавате развојну верзију; строго препоручујемо употребу издате верзије!", + "main_menu": "Главни мени", "make": "Креирај", "manage_shared_links": "Управљајте дељеним везама", "manage_sharing_with_partners": "Управљајте дељењем са партнерима", diff --git a/i18n/sr_Latn.json b/i18n/sr_Latn.json index 19e185b648..fb314b355f 100644 --- a/i18n/sr_Latn.json +++ b/i18n/sr_Latn.json @@ -66,6 +66,11 @@ "forcing_refresh_library_files": "Prinudno osvežavanje svih datoteka biblioteke", "image_format": "Format", "image_format_description": "WebP proizvodi manje datoteke od JPEG, ali se sporije kodira.", + "image_fullsize_description": "Slika u punoj veličini sa ogoljenim metapodacima, koristi se kada je uvećana", + "image_fullsize_enabled": "Omogućite generisanje slike u punoj veličini", + "image_fullsize_enabled_description": "Generišite sliku pune veličine za formate koji nisu prilagođeni vebu. Kada je „Preferiraj ugrađeni pregled“ omogućen, ugrađeni pregledi se koriste direktno bez konverzije. Ne utiče na formate prilagođene vebu kao što je JPEG.", + "image_fullsize_quality_description": "Kvalitet slike u punoj veličini od 1-100. Više je bolje, ali proizvodi veće datoteke.", + "image_fullsize_title": "Podešavanja slike u punoj veličini", "image_prefer_embedded_preview": "Preferirajte ugrađeni pregled", "image_prefer_embedded_preview_setting_description": "Koristite ugrađene preglede u RAW fotografije kao ulaz za obradu slike kada su dostupne. Ovo može da proizvede preciznije boje za neke slike, ali kvalitet pregleda zavisi od kamere i slika može imati više nepravilnosti kompresije.", "image_prefer_wide_gamut": "Preferirajte širok spektar", @@ -859,6 +864,7 @@ "loop_videos": "Ponavljajte video zapise", "loop_videos_description": "Omogućite za automatsko ponavljanje video zapisa u pregledniku detalja.", "main_branch_warning": "Upotrebljavate razvojnu verziju; strogo preporučujemo upotrebu izdate verzije!", + "main_menu": "Glavni meni", "make": "Kreiraj", "manage_shared_links": "Upravljajte deljenim vezama", "manage_sharing_with_partners": "Upravljajte deljenjem sa partnerima", diff --git a/i18n/sv.json b/i18n/sv.json index 54eb04e9d2..d1ebca934a 100644 --- a/i18n/sv.json +++ b/i18n/sv.json @@ -66,6 +66,11 @@ "forcing_refresh_library_files": "Tvingar uppdatering av alla biblioteksfiler", "image_format": "Format", "image_format_description": "WebP producerar mindre filer än JPEG, men kodas långsammare.", + "image_fullsize_description": "Fullstor bild med borttagen metadata, används vid inzoomning", + "image_fullsize_enabled": "Använd fullstor bildgenerering", + "image_fullsize_enabled_description": "Generera fullstor bild för icke webbvänliga format. När \"Använd inbäddade förhandsvisningar\" är aktiverat används inbäddad förhandsvisning utan konvertering. Påverkar inte webbvänliga format som JPEG.", + "image_fullsize_quality_description": "Bildkvalitet för fullstora bilder 1-100. Högre värde ger bättre kvalitet men större filer.", + "image_fullsize_title": "Inställningar för fullstora bilder", "image_prefer_embedded_preview": "Föredra inbäddad förhandsgranskning", "image_prefer_embedded_preview_setting_description": "Använd inbäddade förhandsvisningar i RAW-foton som indata till bildbehandling när det är tillgängligt. Detta kan ge mer exakta färger för vissa bilder, men kvaliteten på förhandsgranskningen är kameraberoende och bilden kan ha fler komprimeringsartefakter.", "image_prefer_wide_gamut": "Föredrar brett spektrum", @@ -859,6 +864,7 @@ "loop_videos": "Loopa videor", "loop_videos_description": "Aktivera för att automatiskt loopa en video i detaljvisaren.", "main_branch_warning": "Du använder en utvecklingsversion. Vi rekommenderar starkt att du använder en utgiven version!", + "main_menu": "Huvudmeny", "make": "Tillverkare", "manage_shared_links": "Hantera Delade länkar", "manage_sharing_with_partners": "Hantera delning med partner", diff --git a/i18n/ta.json b/i18n/ta.json index 2c58867226..9f1fea8eaa 100644 --- a/i18n/ta.json +++ b/i18n/ta.json @@ -1339,4 +1339,4 @@ "yes": "ஆம்", "you_dont_have_any_shared_links": "உங்களிடம் பகிரப்பட்ட இணைப்புகள் எதுவும் இல்லை", "zoom_image": "பெரிதாக்க படம்" -} \ No newline at end of file +} diff --git a/i18n/te.json b/i18n/te.json index 0adc561b51..cfdd52cf79 100644 --- a/i18n/te.json +++ b/i18n/te.json @@ -58,7 +58,7 @@ "exclusion_pattern_description": "మినహాయింపు నమూనాలు మీ లైబ్రరీని స్కాన్ చేస్తున్నప్పుడు ఫైల్‌లు మరియు ఫోల్డర్‌లను విస్మరించడానికి మిమ్మల్ని అనుమతిస్తాయి. మీరు దిగుమతి చేయకూడదనుకునే RAW ఫైల్‌లు వంటి ఫోల్డర్‌లను కలిగి ఉన్నట్లయితే ఇది ఉపయోగకరంగా ఉంటుంది.", "external_library_created_at": "బాహ్య లైబ్రరీ ({date}న సృష్టించబడింది)", "external_library_management": "బాహ్య లైబ్రరీ నిర్వహణ", - "face_detection": "ముఖ గుర్తింపు", + "face_detection": "ముఖ గమనింపు", "face_detection_description": "మెషిన్ లెర్నింగ్ ఉపయోగించి ఆస్తులలో ముఖాలను గుర్తించండి. వీడియోల కోసం, సూక్ష్మచిత్రం మాత్రమే పరిగణించబడుతుంది. \"అన్నీ\" (పునః) అన్ని ఆస్తులను ప్రాసెస్ చేస్తుంది. ఇంకా ప్రాసెస్ చేయని ఆస్తులను \"మిస్సింగ్\" క్యూలు చేస్తుంది. గుర్తించబడిన ముఖాలు ఇప్పటికే ఉన్న లేదా కొత్త వ్యక్తులతో సమూహపరచడం పూర్తయిన తర్వాత ముఖ గుర్తింపు కోసం క్యూలో ఉంచబడతాయి.", "facial_recognition_job_description": "సమూహం వ్యక్తుల ముఖాలను గుర్తించింది. ఫేస్ డిటెక్షన్ పూర్తయిన తర్వాత ఈ దశ అమలవుతుంది. \"అన్ని\" (పునః) అన్ని ముఖాలను క్లస్టర్‌లు చేస్తుంది. \"తప్పిపోయిన\" వ్యక్తిని కేటాయించని ముఖాలను క్యూలో ఉంచుతుంది.", "failed_job_command": "ఉద్యోగం కోసం కమాండ్ {command} విఫలమైంది: {job}", @@ -67,7 +67,7 @@ "image_format": "ఫార్మాట్", "image_format_description": "WebP JPEG కంటే చిన్న ఫైల్‌లను ఉత్పత్తి చేస్తుంది, కానీ ఎన్‌కోడ్ చేయడం నెమ్మదిగా ఉంటుంది.", "image_prefer_embedded_preview": "పొందుపరిచిన పరిదృశ్యానికి ప్రాధాన్యత ఇవ్వండి", - "image_prefer_embedded_preview_setting_description": "అందుబాటులో ఉన్నప్పుడు ఇమేజ్ ప్రాసెసింగ్‌కు ఇన్‌పుట్‌గా RAW ఫోటోలలో ఎంబెడెడ్ ప్రివ్యూలను ఉపయోగించండి. ఇది కొన్ని చిత్రాలకు మరింత ఖచ్చితమైన రంగులను ఉత్పత్తి చేయగలదు, అయితే ప్రివ్యూ నాణ్యత కెమెరాపై ఆధారపడి ఉంటుంది మరియు చిత్రం మరిన్ని కుదింపు కళాఖండాలను కలిగి ఉండవచ్చు.", + "image_prefer_embedded_preview_setting_description": "అందుబాటులో ఉన్నప్పుడు మరియు ఇమేజ్ ప్రాసెసింగ్‌కు ఇన్‌పుట్‌గా RAW ఫోటోలలో ఎంబెడెడ్ ప్రివ్యూలను ఉపయోగించండి. ఇది కొన్ని చిత్రాలకు మరింత ఖచ్చితమైన రంగులను ఉత్పత్తి చేయగలదు, అయితే ప్రివ్యూ నాణ్యత కెమెరాపై ఆధారపడి ఉంటుంది మరియు చిత్రం మరిన్ని కుదింపు కళాఖండాలను కలిగి ఉండవచ్చు.", "image_prefer_wide_gamut": "విస్తృత స్వరసప్తకానికి ప్రాధాన్యత ఇవ్వండి", "image_prefer_wide_gamut_setting_description": "థంబ్‌నెయిల్‌ల కోసం డిస్‌ప్లే P3ని ఉపయోగించండి. ఇది విస్తృత రంగుల ఖాళీలతో చిత్రాల వైబ్రెన్స్‌ను మెరుగ్గా భద్రపరుస్తుంది, అయితే పాత బ్రౌజర్ వెర్షన్‌తో పాత పరికరాల్లో చిత్రాలు విభిన్నంగా కనిపించవచ్చు. రంగు మార్పులను నివారించడానికి sRGB చిత్రాలు sRGB వలె ఉంచబడతాయి.", "image_preview_description": "ఒకే ఆస్తిని వీక్షించేటప్పుడు మరియు యంత్ర అభ్యాసం కోసం మెటాడేటా లేని మధ్యస్థ-పరిమాణ చిత్రం ఉపయోగించబడుతుంది", @@ -113,7 +113,7 @@ "machine_learning_enabled": "మెషిన్ లెర్నింగ్ ప్రారంభించండి", "machine_learning_enabled_description": "డిజేబుల్ చేయబడితే, దిగువ సెట్టింగ్‌లతో సంబంధం లేకుండా అన్ని ML ఫీచర్‌లు నిలిపివేయబడతాయి.", "machine_learning_facial_recognition": "ముఖ గుర్తింపు", - "machine_learning_facial_recognition_description": "చిత్రాలలో ముఖాలను గుర్తించండి, గుర్తించండి మరియు సమూహపరచండి", + "machine_learning_facial_recognition_description": "చిత్రాలలో ముఖాలను కనుగొనండి, గుర్తించండి మరియు సమూహపరచండి", "machine_learning_facial_recognition_model": "ముఖ గుర్తింపు మోడల్", "machine_learning_facial_recognition_model_description": "నమూనాలు పరిమాణం యొక్క అవరోహణ క్రమంలో జాబితా చేయబడ్డాయి. పెద్ద మోడల్‌లు నెమ్మదిగా ఉంటాయి మరియు ఎక్కువ మెమరీని ఉపయోగిస్తాయి, కానీ మంచి ఫలితాలను ఇస్తాయి. మీరు మోడల్‌ను మార్చిన తర్వాత అన్ని చిత్రాల కోసం తప్పనిసరిగా ఫేస్ డిటెక్షన్ జాబ్‌ని మళ్లీ అమలు చేయాలని గుర్తుంచుకోండి.", "machine_learning_facial_recognition_setting": "ముఖ గుర్తింపును ప్రారంభించండి", @@ -395,7 +395,7 @@ "allow_public_user_to_download": "పబ్లిక్ వినియోగదారుడు డౌన్‌లోడ్ చేసేందుకు అనుమతించండి", "allow_public_user_to_upload": "పబ్లిక్ వినియోగదారుడు అప్‌లోడ్ చేసేందుకు అనుమతించండి", "alt_text_qr_code": "క్యూఆర్ కోడ్ చిత్రం", - "anti_clockwise": "ఎడమవైపు తిరిగే దిశ", + "anti_clockwise": "అపసవ్య-దిశ", "api_key": "API కీ", "api_key_description": "ఈ విలువ ఒక్కసారి మాత్రమే చూపబడుతుంది. విండోను మూసివేసే ముందు దయచేసి దీనిని ఖచ్చితంగా కాపీ చేసి ఎక్కడైనా భద్రపరచండి.", "api_key_empty": "మీ API కీ పేరు ఖాళీగా ఉండకూడదు", @@ -473,7 +473,7 @@ "clear_all_recent_searches": "ఇటీవల చేసిన అన్ని శోధనలను ఖాళీ చేయి", "clear_message": "సందేశాన్ని ఖాళీ చేయి", "clear_value": "విలువను ఖాళీ చేయి", - "clockwise": "సమయదిశగా", + "clockwise": "సవ్యదిశ", "close": "మూసివేయి", "collapse": "సంకుచితం చేయి", "collapse_all": "అన్నీ సంకుచితం చేయి", diff --git a/i18n/th.json b/i18n/th.json index d62fda1c14..5a69269ac5 100644 --- a/i18n/th.json +++ b/i18n/th.json @@ -41,6 +41,7 @@ "backup_settings": "ตั้งค่าการสำรองข้อมูล", "backup_settings_description": "จัดการการตั้งค่าการสำรองฐานข้อมูล", "check_all": "ตรวจสอบทั้งหมด", + "cleanup": "ทำความสะอาด", "cleared_jobs": "เคลียร์งานสำหรับ: {job}", "config_set_by_file": "การตั้งค่าคอนฟิกกำลังถูกกำหนดโดยไฟล์คอนฟิก", "confirm_delete_library": "คุณแน่ใจว่าอยากลบคลังภาพ {library} หรือไม่?", @@ -65,8 +66,12 @@ "forcing_refresh_library_files": "บังคับรีเฟรชไฟล์ทั้งหมด", "image_format": "Format", "image_format_description": "WebP จะให้ไฟล์ที่เล็กกว่า JPEG แต่ใช้เวลาแปลงไฟล์นานกว่า", + "image_fullsize_description": "รูปภาพขนาดเต็มที่ถูกถอดข้อมูล metadata ออก ใช้ในขณะทำการขยายรูปภาพดู", + "image_fullsize_enabled": "เปิดใช้งานการสร้างรูปภาพขนาดเต็ม", + "image_fullsize_quality_description": "คุณภาพรูปภาพขนาดเต็มจาก 1-100 ค่ายิ่งสูงคุณภาพยิ่งสูง แต่แลกมาด้วยขนาดไฟล์ที่ใหญ่ขึ้น", + "image_fullsize_title": "ตั้งค่ารูปภาพขนาดเต็ม", "image_prefer_embedded_preview": "ใช้พรีวิวแบบฝังตัว", - "image_prefer_embedded_preview_setting_description": "ใช้พรีวิวฝังตัวในรูปภาพ RAW ในการวิเคราะห์รูปภาพถ้ามี แต่คุณภาพรูปภาพขึ้นอยู่กับกล้อง และอาจจะมีสิ่งตกค้างจากการย่อขนาดไฟล์", + "image_prefer_embedded_preview_setting_description": "ใช้การแสดงภาพแบบฝังตัวในรูปภาพ RAW ในการวิเคราะห์รูปภาพหากสามารถใช้ได้ สิ่งนี้จะช่วยให้รูปภาพมีสีสันที่ถูกต้องมากยิ่งขึ้น แต่อย่างไรก็ตาม คุณภาพรูปภาพขึ้นอยู่กับกล้องถ่ายรูป และอาจจะเกิดร่องรอย ลาย ที่ไม่พึงประสงค์บนรูปภาพ จากการย่อขนาดไฟล์", "image_prefer_wide_gamut": "ใช้ช่วงสีกว้าง", "image_prefer_wide_gamut_setting_description": "ใช้ Display P3 สำหรับภาพตัวอย่าง (thumbnails) เพื่อรักษาความสดใสของภาพที่มีช่วงสีที่กว้างขึ้น อย่างไรก็ตาม ภาพอาจแสดงผลแตกต่างกันบนอุปกรณ์เก่าที่ใช้เว็บเบราว์เซอร์เวอร์ชันเก่า สำหรับภาพที่อยู่ใน sRGB จะยังคงใช้ sRGB ต่อไปเพื่อหลีกเลี่ยงการเปลี่ยนแปลงของสี", "image_preview_description": "ภาพขนาดปานกลางที่ถูกลบข้อมูลเมตา ใช้สำหรับการดูแอสเซ็ตเดี่ยวและสำหรับการเรียนรู้ของเครื่อง (Machine Learning)", @@ -131,7 +136,7 @@ "machine_learning_smart_search_description": "ค้นหาภาพโดยใช้ความหมายจากการใช้ CLIP", "machine_learning_smart_search_enabled": "เปิดใช้งานการค้นหาอัจฉริยะ", "machine_learning_smart_search_enabled_description": "หากปิดใช้งาน ภาพจะไม่ถูกใช้สําหรับการค้นหาอัจฉริยะ", - "machine_learning_url_description": "URL ของเซิร์ฟเวอร์ machine learning", + "machine_learning_url_description": "URL ของเซิร์ฟเวอร์ machine learning กรณีมี URL มากกว่าหนึ่ง URL จะทำการทดลองส่งข้อมูลเรียงไปทีละอันตามลำดับจนกว่าจะพบ URL ที่ตอบสนอง และจะเลิกส่งข้อมูลชั่วคราวในส่วนของ URL ที่ไม่ตอบสนอง", "manage_concurrency": "จัดการการทำงานพร้อมกัน", "manage_log_settings": "จัดการการตั้งค่าจดบันทึก", "map_dark_style": "แบบมืด", @@ -147,6 +152,8 @@ "map_settings": "การตั้งค่าแผนที่และ GPS", "map_settings_description": "จัดการการตั้งค่าแผนที่", "map_style_description": "URL ไปยังธีมแผนที่ style.json", + "memory_cleanup_job": "ล้างข้อมูลในหน่วยความจำ (memory)", + "memory_generate_job": "การสร้างความทรงจำ", "metadata_extraction_job": "ดึงข้อมูล metadata", "metadata_extraction_job_description": "ดึงข้อมูล metadata จากสื่อ เช่น GPS และความคมชัด", "metadata_faces_import_setting": "เปิดการนำเข้าข้อมูลใบหน้า", @@ -219,7 +226,7 @@ "reset_settings_to_default": "ตั้งค่าการตั้งค่าเป็นค่าเริ่มต้น", "reset_settings_to_recent_saved": "ตั้งค่าการตั้งค่าเป็นค่าล่าสุด", "scanning_library": "กำลังสแกนคลัง", - "search_jobs": "ค้นหางาน", + "search_jobs": "ค้นหางาน…", "send_welcome_email": "ส่งอีเมลต้อนรับ", "server_external_domain_settings": "โดเมนภายนอก", "server_external_domain_settings_description": "โดเมนสำหรับลิงก์แชร์สาธารณะ แบบมี http(s)://", @@ -240,7 +247,7 @@ "storage_template_hash_verification_enabled_description": "เปิดใช้งานการตรวจสอบ hash ห้ามปิดใช้งานเว้นแต่คุณจะเข้าใจผลกระทบ", "storage_template_migration": "การย้ายเทมเพลตที่เก็บข้อมูล", "storage_template_migration_description": "ใช้{template}ปัจจุบันกับสื่อที่อัปโหลดก่อนหน้านี้", - "storage_template_migration_info": "การเปลี่ยนแปลงเท็มเพลตจะมีผลกับแอสเซ็ตใหม่เท่านั้น หากต้องการนำเทมเพลตไปใช้กับ Asset ที่อัปโหลดก่อนหน้านี้ ให้รัน {job}.", + "storage_template_migration_info": "เทมเพลตของการจัดเก็บข้อมูลจะเปลี่ยนตัวอักษรเป็นตัวพิมพ์เล็กทั้งหมด การเปลี่ยนแปลงเทมเพลตจะมีผลกับแอสเซ็ตใหม่เท่านั้น หากต้องการนำเทมเพลตไปใช้กับ Asset ที่อัปโหลดก่อนหน้านี้ ให้รัน {job}.", "storage_template_migration_job": "เทมเพลตการ Migration ข้อมูล", "storage_template_more_details": "สำหรับรายละเอียดเพิ่มเติมเกี่ยวกับฟีเจอร์นี้ โปรดดูที่ Storage Template และ ผลกระทบ", "storage_template_onboarding_description": "เมื่อเปิดใช้งาน ฟีเจอร์นี้จะจัดระเบียบไฟล์โดยอัตโนมัติตามเทมเพลตที่ผู้ใช้กำหนด เนื่องจากปัญหาด้านความเสถียร ฟีเจอร์นี้จึงถูกปิดใช้งานเป็นค่าเริ่มต้น สำหรับข้อมูลเพิ่มเติม โปรดดูที่ เอกสารประกอบ", @@ -313,7 +320,7 @@ "transcoding_reference_frames_description": "จำนวนเฟรมที่จะอ้างอิงเมื่อบีบอัดเฟรมที่กำหนด ค่าที่สูงขึ้นจะช่วยเพิ่มประสิทธิภาพในการบีบอัด แต่จะทำให้การเข้ารหัสช้าลง ค่า 0 จะตั้งค่านี้โดยอัตโนมัติ", "transcoding_required_description": "เฉพาะวิดีโอที่ไม่อยู่ในรูปแบบที่ยอมรับเท่านั้น", "transcoding_settings": "การตั้งค่าการแปลงไฟล์วิดีโอ", - "transcoding_settings_description": "จัดการข้อมูลความคมชัดและแบบไฟล์วิดีโอ", + "transcoding_settings_description": "จัดการว่าวีดีโอไหนจะถูกแปลงการเข้ารหัส (Transcode) และวิธีการประมวลผลไฟล์ดังกล่าว", "transcoding_target_resolution": "เป้าหมายความคมชัด", "transcoding_target_resolution_description": "ความคมชัดที่สูงกว่าจะเก็บรายละเอียดดีกว่าแต่ใช้เวลาแปลงไฟล์นานกว่า ขนาดไฟล์ใหญ่กว่า และลดการตอบสนองของแอป", "transcoding_temporal_aq": "AQ ชั่วคราว", @@ -353,6 +360,7 @@ "version_check_implications": "การตรวจสอบเวอร์ชันใหม่จะต้องติดต่อกับ github.com เป็นระยะ", "version_check_settings": "ตรวจสอบรุ่น", "version_check_settings_description": "เปิด/ปิดการแจ้งเตือนรุ่นใหม่", + "video_conversion_job": "เข้ารหัสวีดีโอ (transcode)", "video_conversion_job_description": "แปลงไฟล์วิดีโอเพึ่อรองรับบราวเซอร์และเครื่องเล่นอื่น ๆ มากขึ้น" }, "admin_email": "อีเมลผู้ดูแลระบบ", @@ -369,7 +377,7 @@ "album_delete_confirmation_description": "หากแชร์อัลบั้มนี้ ผู้ใช้รายอื่นจะไม่สามารถเข้าถึงได้อีก", "album_info_updated": "อัปเดทข้อมูลอัลบั้มแล้ว", "album_leave": "ออกจากอัลบั้ม ?", - "album_leave_confirmation": "คุณต้องการออกจากอัลบั้ม {album} ใช่หรือไม่", + "album_leave_confirmation": "คุณต้องการออกจากอัลบั้ม {album} ใช่หรือไม่?", "album_name": "ชื่ออัลบั้ม", "album_options": "ตัวเลือกอัลบั้ม", "album_remove_user": "ลบผู้ใช้ ?", @@ -390,6 +398,7 @@ "allow_edits": "อนุญาตให้แก้ไขได้", "allow_public_user_to_download": "อนุญาตให้ผู้ใช้สาธารณะดาวน์โหลดได้", "allow_public_user_to_upload": "อนุญาตให้ผู้ใช้สาธารณะอัปโหลดได้", + "alt_text_qr_code": "รูปภาพ QR code", "anti_clockwise": "ทวนเข็มนาฬิกา", "api_key": "API key", "api_key_description": "ค่านี้จะแสดงเพียงครั้งเดียว โปรดคัดลอกก่อนปิดหน้าต่าง", @@ -444,7 +453,7 @@ "cancel": "ยกเลิก", "cancel_search": "ยกเลิกการค้นหา", "cannot_merge_people": "ไม่สามารถรวมกลุ่มคนได้", - "cannot_undo_this_action": "ไม่สามารถย้อนกลับได้", + "cannot_undo_this_action": "การกระทำนี้ไม่สามารถย้อนกลับได้!", "cannot_update_the_description": "ไม่สามารถอัพเดทรายละเอียดได้", "change_date": "เปลี่ยนวันที่", "change_expiration_time": "เปลี่ยนเวลาหมดอายุ", @@ -476,6 +485,7 @@ "comments_are_disabled": "ความคิดเห็นถูกปิดใช้งาน", "confirm": "ยืนยัน", "confirm_admin_password": "ยืนยันรหัสผ่านผู้ดูแลระบบ", + "confirm_delete_face": "คุณแน่ใจว่าต้องการลบใบหน้า{name}ออกหรือไม่?", "confirm_delete_shared_link": "คุณต้องการที่จะลบลิงก์ที่แชร์ใช่หรือไม่ ?", "confirm_keep_this_delete_others": "จะลบทั้งหมดในรายการ และยกเว้นสื่อนี้หรือไม่ คุณแน่ใจใช่ไหมที่ต้องการดำเนินการต่อ?", "confirm_password": "ยืนยันรหัสผ่าน", @@ -528,13 +538,14 @@ "delete_album": "ลบอัลบั้ม", "delete_api_key_prompt": "คุณต้องการลบ API คีย์ นี้ใช่ไหม ?", "delete_duplicates_confirmation": "คุณแน่ใจที่ต้องการลบรายการซ้ำอย่างถาวรใช่ไหม ?", + "delete_face": "ลบใบหน้า", "delete_key": "ลบกุญแจ", "delete_library": "ลบคลังภาพ", "delete_link": "ลบลิงก์", "delete_others": "ลบผู้อื่น", "delete_shared_link": "ลบลิงก์ที่แชร์", "delete_tag": "ลบแท็ก", - "delete_tag_confirmation_prompt": "คุณต้องการลบแท็ก {tagName} ใช่หรือไม่", + "delete_tag_confirmation_prompt": "คุณแน่ใจว่าต้องการลบแท็ก {tagName} ใช่หรือไม่?", "delete_user": "ลบผู้ใช้", "deleted_shared_link": "ลบลิงก์ที่แชร์แล้ว", "deletes_missing_assets": "ลบสื่อที่หายไปออกจากดิสถ์", @@ -543,7 +554,7 @@ "direction": "เส้นทาง", "disabled": "ปิดการใช้งาน", "disallow_edits": "ไม่อนุญาตให้แก้ไข", - "discord": "Discord", + "discord": "ดิสคอร์ด", "discover": "ค้นพบ", "dismiss_all_errors": "ปฏิเสธข้อผิดพลาดทั้งหมด", "dismiss_error": "ปฏิเสธข้อผิดพลาด", @@ -595,6 +606,7 @@ "enabled": "เปิดใช้งาน", "end_date": "วันสิ้นสุด", "error": "เกิดข้อผิดพลาด", + "error_delete_face": "เกิดเออเรอร์ ไม่สามารถลบใบหน้าออกได้", "error_loading_image": "เกิดข้อผิดพลาดระหว่างโหลดภาพ", "error_title": "เกิดข้อผิดพลาด", "errors": { @@ -613,7 +625,7 @@ "error_adding_users_to_album": "เกิดข้อผิดพลาดในการเพิ่มผู้ใช้ไปยังอัลบั้ม", "error_deleting_shared_user": "เกิดข้อผิดพลาดในการลบผู้ใช้ที่แชร์", "error_downloading": "ไม่สามารถดาวน์โหลด {filename} ได้", - "error_hiding_buy_button": "Error hiding buy button", + "error_hiding_buy_button": "เกิดข้อผิดพลาด ไม่สามารถซ่อนปุ่มซื้อได้", "error_removing_assets_from_album": "เกิดข้อผิดพลาดในการลบสื่อจากอัลบั้ม", "error_selecting_all_assets": "เกิดข้อผิดพลาดในการเลือกสื่อทั้งหมด", "exclusion_pattern_already_exists": "ข้อยกเว้นนี้มีอยู่แล้ว", @@ -626,7 +638,7 @@ "failed_to_load_asset": "ไม่สามารถโหลดสื่อได้", "failed_to_load_assets": "ไม่สามารถโหลดสื่อได้", "failed_to_load_people": "ไม่สามารถโหลดบุคคลได้", - "failed_to_remove_product_key": "Failed to remove product key", + "failed_to_remove_product_key": "ไม่สามารถลบ product key ได้", "failed_to_stack_assets": "Failed to stack assets", "failed_to_unstack_assets": "Failed to un-stack assets", "import_path_already_exists": "พาธนำเข้านี้มีอยู่แล้ว", @@ -760,8 +772,10 @@ "go_to_folder": "ไปที่โฟล์เดอร์", "go_to_search": "กลับไปยังการค้นหา", "group_albums_by": "จัดกลุ่มอัลบั้มตาม", + "group_country": "จัดเรียงกลุ่มตามประเทศ", "group_no": "ไม่จัดกลุ่ม", "group_owner": "จัดกลุ่มโดยเจ้าของ", + "group_places_by": "จัดเรียงกลุ่มของสถานที่ด้วยการ...", "group_year": "จัดกลุ่มตามปี", "has_quota": "เหลือพื้นที่", "hi_user": "สวัสดีคุณ {name} {email}", @@ -842,6 +856,7 @@ "loop_videos": "วนวิดีโอ", "loop_videos_description": "เปิดเพื่อให้วิดีโอวนลูปในที่ดูรายละเอียด", "main_branch_warning": "คุณกำลังใช้เวอร์ชันการพัฒนา เราขอแนะนำอย่างยิ่งให้ใช้เวอร์ชันเสถียร !", + "main_menu": "เมนูหลัก", "make": "สร้าง", "manage_shared_links": "จัดการลิงก์ที่แชร์", "manage_sharing_with_partners": "จัดการการแชร์กับคู่หู", @@ -969,6 +984,7 @@ "permanently_deleted_asset": "ลบสื่อถาวรแล้ว", "permanently_deleted_assets_count": "ลบ {count, plural, one {# asset} other {# assets}} เรียบร้อยแล้ว", "person": "บุคคล", + "person_birthdate": "เกิดวัน{date}", "photo_shared_all_users": "ดูเหมือนว่าคุณได้แชร์รูปภาพของคุณกับผู้ใช้ทั้งหมด หรือคุณไม่มีผู้ใช้ใดที่จะแชร์ด้วย", "photos": "รูปภาพ", "photos_and_videos": "รูปภาพ และ วิดีโอ", @@ -1058,12 +1074,16 @@ "remove_from_album": "ลบออกจากอัลบั้ม", "remove_from_favorites": "เอาออกจากรายการโปรด", "remove_from_shared_link": "ลบออกจากลิงก์ที่แชร์", + "remove_memory": "ลบความทรงจำ", + "remove_photo_from_memory": "ลบรูปออกจากความทรงจำนี้", "remove_url": "ลบ URL", "remove_user": "ลบผู้ใช้", "removed_api_key": "API คีย์ของ: {name} ถูกลบแล้ว", "removed_from_archive": "ลบจากเก็บถาวรแล้ว", "removed_from_favorites": "ลบจากรายการโปรดแล้ว", "removed_from_favorites_count": "{count, plural, other {ถูกลบ#}} จากรายการโปรดแล้ว", + "removed_memory": "ความทรงจำที่ถูกลบ", + "removed_photo_from_memory": "รูปที่ถูกลบออกจากความทรงจำ", "removed_tagged_assets": "ลบแท็กจาก {count, plural, one {# สื่อ} other {# สื่อ}}", "rename": "เปลี่ยนชื่อ", "repair": "ซ่อมแซม", @@ -1071,6 +1091,7 @@ "replace_with_upload": "อัปโหลดทับรูปหรือวิดีโอนี้", "require_password": "ต้องการรหัสผ่าน", "require_user_to_change_password_on_first_login": "จำเป็นต้องเปลี่ยนรหัสผ่าน ในการเข้าสู่ระบบครั้งแรก", + "rescan": "สแกนใหม่", "reset": "รีเซ็ต", "reset_password": "ตั้งค่ารหัสผ่านใหม่", "reset_people_visibility": "ปรับการมองเห็นใหม่", @@ -1099,6 +1120,8 @@ "search": "ค้นหา", "search_albums": "ค้นหาอัลบั้ม", "search_by_context": "ค้นหาตามบริบท", + "search_by_description": "ค้นหาด้วยคำอธิบาย", + "search_by_description_example": "วันปีนเขาในซาปา", "search_by_filename": "ค้นหาชื่อไฟล์ชื่อ หรือ ชนิดไฟล์", "search_by_filename_example": "ตัวอย่าง. IMG_1234.JPG หรือ PNG", "search_camera_make": "ค้นหายี่ห้อกล้อง", @@ -1112,6 +1135,7 @@ "search_options": "ตัวเลือกการค้นหา", "search_people": "ค้นหาผู้คน", "search_places": "ค้นหาสถานที่", + "search_rating": "ค้นหาตามเรตติ้ง...", "search_settings": "ตั้งค่าการค้นหา", "search_state": "ค้นหาตามรัฐ", "search_tags": "ค้นหาแท็ก", @@ -1121,6 +1145,7 @@ "searching_locales": "ค้นหาตามภูมิภาค", "second": "วินาที", "see_all_people": "ดูบุคคลทั้งหมด", + "select": "เลือก", "select_album_cover": "เลือกภาพปกอัลบั้ม", "select_all": "เลือกทั้งหมด", "select_all_duplicates": "เลือกรายการที่ซ้ำทั้งหมด", @@ -1158,6 +1183,7 @@ "shared_from_partner": "รูปจาก {partner}", "shared_link_options": "ตั้งค่าลิงก์ที่แชร์", "shared_links": "ลิงก์ที่แชร์", + "shared_links_description": "แบ่งปันรูปและวีดีโอด้วยลิ้งค์", "shared_with_partner": "แชร์กับ {partner}", "sharing": "การแชร์", "sharing_enter_password": "โปรดป้อนรหัสผ่าน สำหรับเปิดดูหน้านี้", @@ -1179,6 +1205,7 @@ "show_person_options": "แสดงตัวเลือกของตัวบุคคล", "show_progress_bar": "แสดงความคืบหน้า แถบ", "show_search_options": "แสดงตัวเลือกการค้นหา", + "show_shared_links": "แสดงลิ้งค์ที่ถูกแบ่งปัน", "show_slideshow_transition": "แสดงสไลค์โชว์", "show_supporter_badge": "เครื่องหมายผู้สนับสนุน", "show_supporter_badge_description": "แสดงเครื่องหมายผู้สนับสนุน", @@ -1203,17 +1230,21 @@ "sort_title": "ไตเติ้ล", "source": "แหล่ง", "stack": "ซ้อน", - "stack_selected_photos": "", + "stack_duplicates": "นำสิ่งที่ซ้ำมาซ้อนอยู่ด้วยกัน", + "stack_select_one_photo": "เลือกรูปหลักหนึ่งรูปสำหรับรูปที่ซ้อนกันนี้", + "stack_selected_photos": "ซ้อนรูปที่ถูกเลือก", "stacktrace": "", + "start": "เริ่มต้น", "start_date": "วันที่เริ่ม", "state": "รัฐ", "status": "สถานะ", "stop_motion_photo": "ภาพวัตถุเคลื่อนไหว", "stop_photo_sharing": "หยุดแชร์รูปภาพ?", + "stop_photo_sharing_description": "{partner}จะไม่สามารถเข้าถึงรูปของคุณได้อีก", "stop_sharing_photos_with_user": "หยุดการแชร์รูปภาพของคุณกับผู้ใช้นี้", "storage": "พื้นที่จัดเก็บ", "storage_label": "เนื้อที่จัดเก็บ", - "storage_usage": "ใช้ไป {used} จาก {available} ", + "storage_usage": "ใช้ไป {used} จาก {available}", "submit": "ส่ง", "suggestions": "ข้อเสนอแนะ", "sunrise_on_the_beach": "พระอาทิตย์ขึ้นบนชายหาด", @@ -1224,22 +1255,28 @@ "sync": "ซิงค์", "tag": "แท็ก", "tag_created": "สร้างแท็ก: {tag}", + "tag_not_found_question": "ไม่สามารถหาแท็กได้ใช่หรือไม่?สร้างแท็กใหม่", + "tag_people": "แท็กผู้คน", + "tag_updated": "แท็กที่ถูกอัพเดต: {tag}", + "tags": "แท็ก", "template": "เท็มเพลต", "theme": "ธีม", "theme_selection": "การเลือกธีม", "theme_selection_description": "ตั้งค่าธีมให้สว่างหรือมืดโดยอัตโนมัติ อิงจากค่าของเบราว์เซอร์ของคุณ", + "they_will_be_merged_together": "จะถูกรวมเข้าด้วยกัน", "third_party_resources": "ทรัพยากรบุคคลที่สาม", "time_based_memories": "ความทรงจําตามเวลา", - "timeline": "Timeline", + "timeline": "ทามไลน์", "timezone": "เขตเวลา", "to_archive": "จัดเก็บถาวร", - "to_change_password": "Change password", + "to_change_password": "เปลี่ยนรหัสผ่าน", "to_favorite": "รายการโปรด", "to_login": "เข้าสู่ระบบ", "to_parent": "ไปยังบนสุด", "to_trash": "ถังขยะ", "toggle_settings": "สลับการตั้งค่า", "toggle_theme": "สลับธีม", + "total": "ทั้งหมด", "total_usage": "การใช้งานรวม", "trash": "ถังขยะ", "trash_all": "ทิ้งทั้งหมด", diff --git a/i18n/tr.json b/i18n/tr.json index 4727be7fde..d0aea69cee 100644 --- a/i18n/tr.json +++ b/i18n/tr.json @@ -132,7 +132,7 @@ "machine_learning_smart_search_description": "Fotoğrafları CLIP kullanarak semantik olarak ara", "machine_learning_smart_search_enabled": "Akıllı aramayı etkinleştir", "machine_learning_smart_search_enabled_description": "Eğer devre dışı bırakılırsa fotoğraflar akıllı arama için işlenmeyecek.", - "machine_learning_url_description": "Makine öğrenimi sunucusunun URL'si. Birden fazla URL sağlanırsa, ilkinden sonuna doğru, biri başarılı bir şekilde yanıt verene kadar her sunucu tek tek denenir.", + "machine_learning_url_description": "Makine öğrenimi sunucusunun URL’si. Birden fazla URL sağlanırsa, her sunucu sırayla tek tek denenir ve biri başarılı yanıt verene kadar devam edilir. Yanıt vermeyen sunucular, çevrimiçi duruma gelene kadar geçici olarak yok sayılır.", "manage_concurrency": "Aynı anda çalışmayı yönet", "manage_log_settings": "Günlük ayarlarını yönet", "map_dark_style": "Koyu mod", diff --git a/i18n/uk.json b/i18n/uk.json index 6fabec2ddf..5e8de2b337 100644 --- a/i18n/uk.json +++ b/i18n/uk.json @@ -66,18 +66,23 @@ "forcing_refresh_library_files": "Примусове оновлення всіх файлів бібліотеки", "image_format": "Формат", "image_format_description": "Формат WebP виробляє меньші файлів, ніж JPEG, але його кодування вимагає більше часу.", - "image_prefer_embedded_preview": "Надати перевагу вбудованому перегляду", - "image_prefer_embedded_preview_setting_description": "Використовуйте вбудовані попередні перегляди у RAW фотографіях як вхідні дані для обробки зображень, коли це можливо. Це може забезпечити більш точні кольори для деяких зображень, але якість попереднього перегляду залежить від камери та зображення можуть мати більше артефактів стиснення.", + "image_fullsize_description": "Повнорозмірне зображення з видаленими метаданими, які використовуються під час збільшення", + "image_fullsize_enabled": "Увімкнути створення повнорозмірного зображення", + "image_fullsize_enabled_description": "Генерувати зображення повного розміру для форматів, не призначених для вебу. Якщо увімкнено \"Надавати перевагу вбудованому прев’ю\", вбудовані прев’ю використовуються без конвертації. Не впливає на веб-дружні формати, такі як JPEG.", + "image_fullsize_quality_description": "Якість повнорозмірного зображення від 1 до 100. Чим вище значення, тим краще якість, але більше розмір файлу.", + "image_fullsize_title": "Налаштування повнорозмірного зображення", + "image_prefer_embedded_preview": "Надавати перевагу вбудованому прев’ю", + "image_prefer_embedded_preview_setting_description": "Використовувати вбудовані прев’ю в RAW-фотографіях як вхідні дані для обробки зображень, якщо вони доступні. Це може забезпечити точніші кольори для деяких зображень, але якість прев’ю залежить від камери і зображення може містити більше артефактів стиснення.", "image_prefer_wide_gamut": "Віддають перевагу широкій гамі", "image_prefer_wide_gamut_setting_description": "Для мініатюр використовуйте дисплей P3. Це краще зберігає яскравість зображень з широким колірним простором, але на старих пристроях зі старою версією браузера зображення можуть виглядати інакше. sRGB-зображення зберігаються у форматі sRGB, щоб уникнути зсуву кольорів.", "image_preview_description": "Зображення середнього розміру з видаленими метаданими, яке використовується при перегляді одного об'єкта та для машинного навчання", - "image_preview_quality_description": "Якість попереднього перегляду від 1 до 100. Вища оцінка означає кращу якість, але створює більші файли та може зменшити швидкість роботи програми. Встановлення низького значення може вплинути на якість машинного навчання.", - "image_preview_title": "Налаштування попереднього перегляду", + "image_preview_quality_description": "Якість прев’ю від 1 до 100. Вища оцінка означає кращу якість, але створює більші файли та може зменшити швидкість роботи програми. Встановлення низького значення може вплинути на якість машинного навчання.", + "image_preview_title": "Налаштування прев’ю", "image_quality": "Якість", "image_resolution": "Роздільність", "image_resolution_description": "Вища роздільність може зберігати більше деталей, але займає більше часу для кодування, має більші розміри файлів і може зменшити швидкість роботи програми.", "image_settings": "Налаштування зображення", - "image_settings_description": "Керуйте якістю та роздільною здатністю згенерованих зображень", + "image_settings_description": "Керувати якістю та роздільною здатністю згенерованих зображень", "image_thumbnail_description": "Маленька мініатюра із видаленими метаданими, що використовується для перегляду груп фотографій, наприклад, на основній лінії часу", "image_thumbnail_quality_description": "Якість мініатюри від 1 до 100. Вища оцінка означає кращу якість, але створює більші файли та може зменшити швидкість роботи програми.", "image_thumbnail_title": "Налаштування мініатюр", @@ -256,7 +261,7 @@ "template_email_available_tags": "Ви можете використовувати наступні змінні у вашому шаблоні: {tags}", "template_email_if_empty": "Якщо шаблон порожній, буде використано стандартний ел. лист.", "template_email_invite_album": "Шаблон запрошення до альбому", - "template_email_preview": "Попередній перегляд", + "template_email_preview": "Прев’ю", "template_email_settings": "Шаблони ел. листів", "template_email_settings_description": "Керувати шаблонами сповіщень ел. пошти", "template_email_update_album": "Оновити шаблон альбому", @@ -859,6 +864,7 @@ "loop_videos": "Циклічні відео", "loop_videos_description": "Увімкнути циклічне відтворення відео.", "main_branch_warning": "Ви використовуєте версію для розробників; ми настійно рекомендуємо використовувати релізну версію!", + "main_menu": "Головне меню", "make": "Виробник", "manage_shared_links": "Керування спільними посиланнями", "manage_sharing_with_partners": "Керуйте спільним використанням з партнерами", diff --git a/i18n/vi.json b/i18n/vi.json index d84a588614..009b10b7de 100644 --- a/i18n/vi.json +++ b/i18n/vi.json @@ -1317,4 +1317,4 @@ "yes": "Có", "you_dont_have_any_shared_links": "Bạn không có liên kết chia sẻ nào", "zoom_image": "Thu phóng ảnh" -} \ No newline at end of file +} diff --git a/i18n/zh_Hant.json b/i18n/zh_Hant.json index bcd543c78a..14d1cb203c 100644 --- a/i18n/zh_Hant.json +++ b/i18n/zh_Hant.json @@ -2,12 +2,12 @@ "about": "關於", "account": "帳號", "account_settings": "帳號設定", - "acknowledge": "明白", + "acknowledge": "了解", "action": "操作", - "actions": "操作", + "actions": "進行動作", "active": "處理中", "activity": "動態", - "activity_changed": "動態已{enabled, select, true {啟用} other {停用}}", + "activity_changed": "動態{enabled, select, true {開啟} other {關閉}}", "add": "加入", "add_a_description": "加入文字說明", "add_a_location": "新增地點", @@ -23,8 +23,8 @@ "add_to": "加入到…", "add_to_album": "加入到相簿", "add_to_shared_album": "加到共享相簿", - "add_url": "新增URL", - "added_to_archive": "已新增至封存", + "add_url": "建立連結", + "added_to_archive": "移至封存", "added_to_favorites": "加入收藏", "added_to_favorites_count": "將 {count, number} 個項目加入收藏", "admin": { @@ -33,7 +33,7 @@ "authentication_settings": "驗證設定", "authentication_settings_description": "管理密碼、OAuth 與其他驗證設定", "authentication_settings_disable_all": "確定要停用所有登入方式嗎?這樣會完全無法登入。", - "authentication_settings_reenable": "如需重新啟用,請使用 伺服器指令。", + "authentication_settings_reenable": "如需重新啟用,請使用 伺服器指令 。", "background_task_job": "背景執行", "backup_database": "備份資料庫", "backup_database_enable_description": "啟用資料庫備份", @@ -485,7 +485,7 @@ "comments_are_disabled": "留言已停用", "confirm": "確認", "confirm_admin_password": "確認管理者密碼", - "confirm_delete_face": "您確定要從資產中刪除 {name} 的臉部嗎?", + "confirm_delete_face": "您確定要從項目中刪除 {name} 的臉孔嗎?", "confirm_delete_shared_link": "確定刪除連結嗎?", "confirm_keep_this_delete_others": "所有的其他堆疊項目將被刪除。確定繼續嗎?", "confirm_password": "確認密碼", @@ -606,7 +606,7 @@ "enabled": "己啟用", "end_date": "結束日期", "error": "錯誤", - "error_delete_face": "從資產中刪除臉部時發生錯誤", + "error_delete_face": "從項目中刪除臉孔時發生錯誤", "error_loading_image": "載入圖片時出錯", "error_title": "錯誤 - 出問題了", "errors": { @@ -618,7 +618,7 @@ "cant_change_metadata_assets_count": "無法更改 {count, plural, other {# 個檔案}}的詳細資料", "cant_get_faces": "無法取得臉孔", "cant_get_number_of_comments": "無法取得留言數量", - "cant_search_people": "無法搜尋人", + "cant_search_people": "未搜尋到人物", "cant_search_places": "無法搜尋地點", "cleared_jobs": "已清除的作業:{job}", "error_adding_assets_to_album": "將檔案加入相簿時出錯", @@ -1374,4 +1374,4 @@ "yes": "是", "you_dont_have_any_shared_links": "您沒有任何共享連結", "zoom_image": "縮放圖片" -} \ No newline at end of file +} diff --git a/i18n/zh_SIMPLIFIED.json b/i18n/zh_SIMPLIFIED.json index e5ba9a1f82..dabea62399 100644 --- a/i18n/zh_SIMPLIFIED.json +++ b/i18n/zh_SIMPLIFIED.json @@ -66,6 +66,11 @@ "forcing_refresh_library_files": "强制刷新所有图库文件", "image_format": "格式", "image_format_description": "WebP 文件体积较 JPEG 文件更小,但编码速度较慢。", + "image_fullsize_description": "去除元数据的全尺寸图像,放大时使用", + "image_fullsize_enabled": "启用全尺寸图像生成", + "image_fullsize_enabled_description": "生成非网络友好格式的全尺寸图像。启用 “首选嵌入式预览 ”后,将直接使用嵌入式预览而无需转换。不影响 JPEG 等网络友好格式。", + "image_fullsize_quality_description": "全尺寸图像质量从 1 到 100。越高越好,但生成的文件较大。", + "image_fullsize_title": "全尺寸图像设置", "image_prefer_embedded_preview": "嵌入式预览", "image_prefer_embedded_preview_setting_description": "优先使用 RAW 照片的嵌入式预览作为图像处理的输入。可以提升某些影像的颜色准确度,但嵌入式预览的质量取决于相机,图像可能压缩失真更严重。", "image_prefer_wide_gamut": "广色域", @@ -859,6 +864,7 @@ "loop_videos": "循环视频", "loop_videos_description": "启用在详细信息中自动循环播放视频。", "main_branch_warning": "您当前使用的是开发版;我们强烈建议您使用正式发行版(release版)!", + "main_menu": "主菜单", "make": "品牌", "manage_shared_links": "管理共享链接", "manage_sharing_with_partners": "管理与同伴的共享", From 99cddf1fd68735670779b9ec8b54d00b28412fe0 Mon Sep 17 00:00:00 2001 From: Ruben Hensen Date: Mon, 7 Apr 2025 16:22:56 +0200 Subject: [PATCH 19/52] feat: allow accounts with a quota of 0 GiB (#17413) * Allow 0GiB quotas in user create/edit form, remove unused translations * Make requireQuota check for null or 0 * Add unlimited quota change to the docs * Fix user dto formatting * Fix formating edit-user-form * Regenerate open-api files * Revert unnecessary i18n file changes * Re-add newline en.json * Resolve linting issues * Fix formatting edit-user-form * Re-add manifest --- .../administration/img/user-quota-size.webp | Bin 11806 -> 15964 bytes docs/docs/administration/user-management.mdx | 2 +- i18n/en.json | 4 +--- .../lib/model/user_admin_create_dto.dart | 2 +- .../lib/model/user_admin_update_dto.dart | 2 +- open-api/immich-openapi-specs.json | 4 ++-- server/src/dtos/user.dto.ts | 6 +++--- server/src/services/asset-media.service.ts | 2 +- .../components/forms/create-user-form.svelte | 4 ++-- .../components/forms/edit-user-form.svelte | 15 +++++++++++---- .../routes/admin/user-management/+page.svelte | 2 +- 11 files changed, 24 insertions(+), 19 deletions(-) diff --git a/docs/docs/administration/img/user-quota-size.webp b/docs/docs/administration/img/user-quota-size.webp index 9b5464ba3c5f69c3ef1d898246502374be25c9f0..2989bba392f4a20b38eac0c7a5d5b2b42d514086 100644 GIT binary patch literal 15964 zcmaL7b8xTCwk`aNZQHhO+qP}nwr#ICS+SE9uh_P2C->cZpYOZp)V+24ubxlUQ$6M! zJw{d6oXS$-;;Q@rfQFc`qPij{q2xdNTOgo3AnGBY0APM5^eB;HA|fQuxKct8(B?MB zE=^D7gx9_WPk0}L`<*QT>pof^t8N5sU6uQc)Ovw{vd^&}pa+`gi#3V!8^eMnh5Npw ztzm&SZ?JExZ;I#1ZGvxtD~~RR8~s9pd4aC?#h;O9uCL5zuy3)i(I1a`j&Fh&1E`&~ zfD8ZgPw4NVuXo?TfQKi*&(HGCLcs+=^tWjM{kPMX$WzLP$5)?T0Mu*6DM6gUhQND3 z(HY6nj+=f+0PK6(6T_vz-p}xl!&mPc$Vc1ZOnx6;U-5_i8`!79Ys4eNMPCEK>W`D> z?$gYp!D0XoKcGkMP2(ltI^cJ}u7C}nr-0Y@!Uw{~)xE}I!SzpOfgr)qPt1?WckPGG z*UQhz2SZQa#82Rz<-5Zh!khlCz^Fi8K<4+&ck_4s%goQuQvj-f)sNUW!l%Wz!~M?B z*Y3B>SH_pc%g#f=tHAKj&(FX+)Thf^#v8)r&XK^`kIz4!Hm((Z9NGvrZa#WX1?mhw z$KDJsbr7Bo3RE4&;JPL$tO&q@jog0p%&Xs>w`n@PNtD=gMrn(0=76-Ire6Ny8$Li} z@($QSfD4T)MuDKjU`toGP>EnOax7J@n`Ky}3m8FUepTlnuC@y(bt(zn32^TP0D_Kgk}QU!3iLl5)n?C+qzwhi~v-I*yZ6g17zFnWdje+L~S zFq(7`(}&k!Ivb_HW||I>V=<10%R7vpn4%BRfA+Ry+a9JntsN#n00ZveB5g&CaS&I- z|FDmST6!#JHBsoYx7#_c@3josgs;HW;pnorbjN7FIP|Rj%sKY2^e?G=>&<jLQBKd#CdzEZqyQCq|)1VsKaCk?z z$P|Svj&VL-gpMFakyF9SoNt$aGjHnk9%{aSp@`8*Ye`(TrHgGBm z8M;k|0yS9HtfSJ8@Zline6GcM1r&Ak=-`yW{8YLaS(OPSxQv;mv@kRhz;EOhH}3ye z3A{@fE`0MSmZwIK$MVp${~NFWp}E0Cj@B|V(r#`4=RUQzf-G!>0oeE*-mrkV$IZsj zM6=@G$pS_+UaRIHd8#I21LT$7PN%7wZCS)l>))}oO^A%1bBI+W1jwpb$iY*d1GEh znQsQXHJ|Rz;os;nxMJGtYs~&^M75#VlW)qk=Qy$N+xn=zwWGE@e>sfR-9TdSyKI0uz7V4l5V@Uwn_?N4MCX@|V9H zyB!Z`W>?}O0Clk}P*&Z91OI7my!%|)19`m>n57Dc zg-~-Es@YgHPFRMHv zyA4Oy{>zTA3(^Fr6aRz55ey;+AA*U{xa#j7+WB8=@&RvmoBhb~!$!hPy?_-1RB;Jh zo3;X!x#xbs_fH&zvwIqv?cU_7l1W2sp8i_PunOLTq(Z*KvpwB#!pIU*AV7j4go5E= z6<beB>Z;pAIaa_;z7cZ zwLgEu%}rljyiP==!qRcZ3l3=gZINv)*$!eiJmMT|ywH$PlI$W8boql$HsEG!qBjzf z^}XlU5Q~A_0D~IfB!*`G@8~!V5D3)ikl$X2Af8mv(~g|({SBOv&0_KSrg8~^A6NMu z^WV57``kA{)(3Fn9*l!eF_R1OMAui}(;yMD>7U5%x%91IEdy8qpU zo3eT-FWmrtDd95-jSI;#M5&2-Fe~$qJ*U;@e3mAcYPMf(+Y~>E;j|5A&pEy(wf3$N z|6*S+PMuJPPPIwbaR-Yu5uW`n`F@@F|I%Xtbh~U945oV;OD;^vvp#yVCRm1fX)LmE z7NC$Ix|Y2pZZxm!Pvi*9_UR5|C*Uv;0DwRYc3kb;CNo^k!B2B6?FRt%vg)R}fH<(i z<5wnD9)~t0xnydu7()1xIvy<*i>sh6&cue5-lRgZ=on>@urBLIL+ z_Z@Wp)E=M;7n?o(mn0mQm+(ZkYx7U`a!UL^bbjW^s=Vmj44uPIQ0=hTA^0DDW`tT% z&ztc*^$ky*dJx>fa~WZ1q8ClkS`etiUy%c|=xyC`co>XRsP6O`)3u~V^0q_)q9Y!m*^;QKhWD^lV%hLPcS-emt zGaaBK`7%>UrH(SJOMrnX2Gr7S)jZh^dE~PzRzL9kupBOLFN>fdsxf+e4cayH8BlRa z&e}WIIMaQpIxhyE$i!aIj1=EGZe9fn=s>E$@^kGCKz_Gz+L6NQ>>wsQME!*Tp2pt9 zE`IOoSwEEx+1rR&l=tH6(0~QzPmh27EzoW0RUM1yE<49D9UPfJRypUCY zf>j?<+aAzhX~iN;&js5xb$nE4v@?w3hos82KNV)Mhyw20;j?}Z#ER9>`V)CA+BH$p zoynIm1F1zH(3L8Q%4-z!!5L8kfDIjIHAU*9^K=|m>+eHbL& z;F+@9^ZcDAA`Lg_?lX_%fC!@aLy-rzX=3W8TciySekxjxw}1fwu+1(AgMh=+*%Kj% z<}In5R$T0+r1SZMVO9l8tjr^45L)fD!ML)w{C?`NGQ_5P0F|RGc1}(R->|n%eXH`9retF06Q3 zZ5U_gd=T@kK@mY6%^37FMw}8#l8Mx^OLM3g^Vl;S)0L2Idehqr!O#(p+#(7oCPTa@ ziB5F(8!En(WgMz$W?*Yhn9tmFus$TF>^C|3e2k^v;nQRKz9`9wq0lg7t zcEYz*+h7;`nE?fpoS_*T?L|klDK7*6NqX+LFPR^8Mrke4{KJHlsvs85X+t#w5w3nO zjt<;KtC@$RZ7gxp#hZXvOmT~}U_PwYE;WKI20>}8*!MNX)JVpGfxP*qTnK&q!ksMR3N0S}(b7Mf`$=l6p!tk?3JFTDt$w(?{Sf70pdtGh23droO5V52`!$XiW#AhS)Niy8z zI$={zqkuc)u1#YibIQjhwm>9$> z*5_&&EC~K-LR0@B|B#0=VQhU9%@5S@>K+cgIAyy%f76F)!yA6_%K=gr0rflQT>BT;%a`%chIBkh)MCH03BwCg zk6=!(#h23Rv`4Yvh&8BzNAVRyYiz0WvpDhuKJreDH zZ1}p95CA170S5_;bV4OnZ=rlc-Kg477FagV^V5}?;cKJ_yF;pH98;`X8$}ak(=f`% zeySA2N?XgTJlsg5a5?9#Ua(gDSS!#hcBUO*9y9cEzaBF%>c*fPO-LW=?&Q+RnV>sl zwDrKm*CJtEx-^nT-SZ2OCVWRb|4GeJeXt!!e+)#sdz@6~G~#vaULM!$J6#ABR-G~A zj#J55-du~Vbv7TL+__i5Ng-bHGavxw77@)K?FxC}5(s41;NAeq8q*SANpttcVX6!{n+kV2;J;iKjuu&g_QvM1dxqHS2!(O z$9dh)sSHaN5u<$&49i(3qK~-m+Re+!|Cv-Nct-jw2G37~M#it?ql}2=5YdR(6yL|o z&XE1fprcl6GAovcX6qeMKtK@R*r4P^|XyOhXP%=KYi z;SX(;Z;SW%YsZdUXNXgip*R2pu*0~#*w$;*j<#Cn&WoPAvxCuTrnuo*F(x-lTa#Rs z&NWm?a^d4oGyyD9>HvTvYS+3hVk==SQ1Y>oRu#zXghAnxDrP?!XSd!wDg5g2UY#2^ zBgV%UeP4|?dRX$%h{2)XWcy&ylYjnx#E4Qqc%vm*7dZVvWdA5ZqUXSZw^4BbTO)C` ziLr`l4_GFfmzcRIXzpg4opF$$xAWWj+Ls&NQzENN@XPZC!WQl?r4)~DNLr)xxO0H9A-MUnn)M-%W0z}e)8b$b**qR)7eI_IR zMx9UoLOXM1yNi6n*ip%pSt|WQYsMSof>@R;aaU11y16zH9V;S8KZU~tzq9+qF4le0W6{gL0TX_|1X15s_-;yIo-y7M=QASH$Afk00{^kr?XmKik=Wt z59M3)uMB4J-}Www;J^KhoV9r^K>A0ZRZ5InuI(A68Jg=^3WSduE9iJZK5jncFHmm~ z8A=RqlD3b+0S;_`1m9Ys>hYq)bFb|^_v-x}mfd;uRhSo<*qcJR!=*A&LB2ly`}h@Q zyN&C2^am4=*jQ1MzO|QHE*H!`H`3QY=LK^~M_l%NY8FBW(fJUKT*+$b^N0A$NNMao z5@C%X;8Bk&lju=r2b`HS&w zDvC7w1^~ECIyZ8v#=nXSD|a>WfK_q6dg3EpUZGfSxl!120O<4R$!zwW~`oKa5T0eS1pYIN4fXw zY6*#xbqSi1frkq-6;v488U;$y7I|ak%U{~{?mas&rv9nk+wvxEu?8yxV4vI`{+WbR z1M4Tx3@lW}TzJA6w}iY&^#Q>OsW?`BDyx2dRNQpUwU{fU-;>aOdqi_lW3fJBQ2gp^bX{7v2!3uMFnAV**|+Evk05_pacp6B`eG zwOj`$r(~zsrwXEz$^Crx1l--=uY%WBhaoYuTqlnVO(+0^94X^CDJd3Tt;a*}iftst z2lkU5A~pr)$`g-lr@$DZo!N+{aDTg=T2eWde*A6_pa{cg9-^az!jqv?P6f5S00wht zsdk*XeX_R^ziaJmL#?=1b7VwHV_G9c^Z4t!{<+jr+DsJySDJR`COiZLW8{hYCYg{ux>(bV#|j0ZPw~9m*OE;)YmlIsq(4 zOGXw1&8;WfwlWO#>gW}+eZ5zE*3ULKU=BQv8RY#}at!i5-Ol6>HZ3`A`C7rjmEU5A zo0y=p3|&Wxd+undNC|0fAqfQk`Kbxvu0eFQecny;3uP8qC0#C zd3RhUx1>gjy3Db2>;)!(8Y^Q5YVP6j9HUdKR=bduQ9(O2Q4xhi*cJ^Ilgf-b>+N0Z zAqcf*@%Hpm0i3(|r_uERX=BJp_sUM;6he}+U@qpP46z!jn@Xx(xjhZdPXOIKy%nS6 zt7oQdD#{cSIY`Ub=pp25$dAXQ1aR)RON9u8<;Ln|jw{|oDvcHWmy13-eim%X|Hw2AgK8j#z-Xo}OLZjpq5R&Q

~4dNR6+jaP-gt%X%Vu3N!%U1?4vv6c~wjZ z6SeG8MJMIM4b4#E6v6V9wPjjVX>y0W+D_P5yU@E>_4Cq7JnK3pSS83i5gw z2KQO9P}kgfH9tfWj9L1Q5BzP_9GH3da+IA)1aeSx&&#E1Yr+)fXp~Un*x)Dm?ZRwPhCGVUBmdfnBdPY&L>~c+uPGI)}JQ7I)Vw$%&f3% z2n|k(#*s#CTVL!4fVDeWdUqo#3b1qKcL2eMIyGjw>`88aoJz+g_+cK-B^}ai+>j z%#KvYdkCD|4+lHtY2>uV`=@XAVkgg|3q^jyd#Kduwu<(5V*o|(^ii@Na(o__*W2lh z-la^?@n!A7Ou3GTuttKM2h#&%`?(b7BP`%2;E(y;{Jg+(^B8;ecik`-RDDbqbK z8LoYj!NU*K`NibVssOBr(j~5ger9Qz(h$@wDF7QsUeb z)ZZ=HOSI4JUU6_<$#0y>?P&1fTV)D_n9cTxx|Y_dFZ>9~7~B?JQc>9CDZ3-27^iIJ z_-5MXZ}vw|;^M|a-UFaG^Q#u~$%1cVQODOFUD~1Q)C1w3HiSathMeF0JoRe+H$CO# zzlNQ48+CQp4hj=!WK8*RRP(1IPp5WFXb9|Z$z4)nLn6`%>twhN$v^g_to56zi zo_*O0%f>DiS_5U0Rx0cO86@?pk`?j?S7x6%jm)KIFk@;?hRNGa1Q9Meg@`Y#0R;E1 zt2V3enJ7zBtb1$Orj3)I_P2k117xeDmWVW46e$PF)@*Q`vFku-o%lxTrRDOciM_;=I4adcZZ-U5dg5eJJ^vxik<;17Z*3*u(vDOl~ddF{hhzQrHA{|ku~L_{(N>cbDDh7 z)f>wYaXTGaelRt+t^xB0Os-2n zN_|T>+Q~Q3p1W1ei`Vw-L7N~VUi06&qoq1Eac+cn!Y|tUfMC47TARcUBY9EwD{Lg>+-oo))2?%wU z#c&EzB48_htn9M11%hYOdCMhL`rWITN=V7etCxxlU~pGBw3T4?=p(cT^Ljvpfh70J z5@d{4o-iyg#9eLa#h*bt;QON%r7K-p%d3ODiLc{Ea@j&WH()Q9eKB(xKVH>i`<}J` zaWh7q8~*N`RS7vJAkG^Ar*m^3mhKMdE7n3iQwqIDClmu|16h-unqqQ#d3Yez*UfrI zdI7Peh^zu4Oa_DJs|18Jd0ik(>nvKn*0{T23x`64)nWq?DubPuYqmy)f0ztneu8`s zUIlq+Ye;*=oE~B0#jXXP7HFle!mp_Y3V<8pxH5HbQ3ctm$@K&jiVpW+mD4o5E;$W| zfP>sWG)PXC#?k#|@DW&O)7c z2ztepe1tW(t^{L_$w<7~b%_pxD{G)vvZ&({5m$t2Jtl-aDe$ zy6+DG@PN>MTWekGSuT8Uc=#d{>PG#lH2%}3TdN%=5*$m}3WQbZZ##1}bm2KV37ymg z#+mxV7$_rboe!Ngw+*~RGM~6bcSKto$T(bpBl9z96QU7w5E4%>(~PS#Yfl*nOZN+Z zNJ|^86&9GlZxFpdFWOIg#hU3^mPL$v;`yCD4Sqr9PAnnnnkEO0lZR^PEdy)Kjg#VY zjs)F$Ej#H}cT^kvf#{9Kv)kQfhBCk~_u~uLx2f91595ri@G@gj+l8TXJN9~Mh+Mxf zK4++MCeICK#6wh=LW2UK1N25$!3eZPNQ|R#81Z zE2YdWekO%1C{OyzCEJ-D_gKnJ5FMb6_FcSy87EhAW;`SE7K*oFiOl~^d!ll`hV`yg z!pJ`r#3dU6O;)3Hzmm<7gN}ZuD}EldxX5f`f*@#_wmI69q|){Bg%uB$%lDSb#74LD zsJ!$k(k7@aqG^xZRId2mffW!G;)+@(9Qn7scy|{wl~y@*NLa=%uAPEhHz8xy^_@NJ zx~#1Uc)VcW={~@g73;=NBw{;m2pL|&@&eu6!tX#|Lemvk*1Zm7F0#u4wLs7Sl4eZB zV&jqop_z)k!s^u&6G#5}w<^*4OI-_@YS3RFW4t=pw+aqsalaP0+eEmT#c&5NGU$VO zukX`9(Rl*Uv5uiALSOq>C(U1__3#-1kNAy|M>?MrJ?Ll+h_abgW05|b#cJ*_+=z^M zN@o1;mA+2A+&MXOS7jO)U5{LxLnP!{@nC_^| z;X|EYn^5}Z4H2u-1=%7O#X|a0Qokg1th2t+uYYYbkwjMEs{2g87lZ?TGZ_hB;|8&L z;yL4qU(}|(-`Z_8IkEa$F{LTAd&h?UYXZNjDlDW%S)+i83cn?iS@x|8;fu2RR61t_ zV~0#oF;wb2<$r+iYL=-jS(OX<{J^^$wUmH}+E!IhIi+(LgY%ixNbc@0m$wr{F%1 zaO-~ON4yD)tO?@1eJVw<+ryaYD(ZfrLP!0Omp{SowmR1%AFlECW}=Ie)!T{tX#yQY z@`#M_^_H`g)_LURP@kWTv9=)?vwKFCtc|T0ki~5<^q85`EXTjM0`lNa?wX7)H`g*) zJwYi~M8ws}HGCMYu`mpuMzM}iUKle}KJbWkZ~7HYf9*24H7F`)#qB2pzi>J)Bqs7j}oK;1dA)2cu0U@TQ= z!>JTg$kB3`{JoN4S*lVyL_jMcLo=4+9K9MOyMyCgsR>-UZF6~Uni z_A^0{YBVIdmy@cD@{N~;@)Jj$t`8G(+~@5B3r8s;S1(YW{k@c5Ti2c*WqIe{KFxOS z)bFwdWb*5xQ93evf>oG>zeDa`MxFt zCEwUNWMU%kQHgkyJdgg%XI2$lOVlL?#A*tuGD_>~4tu+bzXLWue{Z36Lq6n$T&A;k zy&1LqHU6GeU%z$l+0T~TBTl{<1n9S+!7<9&Dy}RtA`*OSICxgnND#zOh8!Y&|Y9(8U^$%^_@Nq*Ktu=+QNzb2JEu|LF?OE{LnJRhCd z9pH}Yuh8@sFpp%AVi$L+P6kW3$r(&Xi$>*4XZD?%Q2ZM^$Us5WR?DhTov{@q6W8poOr=LmV%%z8ljMq;!p@nyn(^`HH z5}=Bf1?iTq3hX*CObK~}{c1XPs0=bztFP%H&HSb-t3#9@pY;w5W9{GHQJ)KXs`0u| z9MjL6p0dK@=Dsp_7fEl5+(fF)0b__gOGe(=;jOs-)WjjO%?t-a+_Sr=dqX?zX3g#*s zymqjg6?F!oTF)+TMeTwdN6fyr`b8P3(HUYq zeahC7G^p~;EkTbfi(C=1iQ@pk6gz0+us;R5mlqOP?z}HJWzWR2QW4GJc+vs(f|nB( z#_mNGGZe^M)_)CgkQPezI}84wwgQ0D%C#6UCjGZ#f|vBCW&UTmj@Nc)1oTaQRS9)W zevS`gKDo@=!+--@;*N852e=LSh?R)%reDW>5G1|3nKzCd8s>aGkN*$L^*E@?ghOf{gLVH!&7Y2z0iUHX9oh14&UAu=*7(4$1m2 zCOZzHmWl>E9(L|ixf!!;Fjw<2@m}^U#3=%Fm<(&JK%5ZN zkzHD;dFrkz2Q_SMynXfrhT!_3;*ItXE~!FIta2_F<}C5d>2qWbfcHVDK|(jQ30iA7 zSUBX=0b$E(y#Ndspyp}29P9$6q+*stBJAFD_#BC?(O?X16NX5&9)OOgx~hK2NEosu z?;zj|?Lq;iTM%fvRON(YSk~hE;hpoijA^&o%3#_{a z$rG!HC#qbDxP3Dwr6~(u3kZZZ)W;Z@Em>vwc4zLE7!z(B=sPg(K`lQ|w_JaTNxPXB zxs;t(GE*eUb)Z0ZnARAuXkD;*Fh!ZYoFa+gWS8U=B2@>M!37sitec0j{pY7bY(c48 znP%3>PbS*@m_tAphZ`u4y`~=@837*%HT=JdZqd{y5-brUkflZH7Q0BG0 z(~ATFDJy;uldFMj2t75c_4%|$yFzRJyg@8 zrj=PP^v)3O0CK6aQs{AB`s_Wik{;X8;f9zSluUPDP-kuqKnZ{s?|l>Yo@dFe>{fXGM>A#l7cb4i zmBtndF2qRe2vE7!JefaVUPzkF{uaE9R4-ev%n5nt`seW9jwgUiLVRa;@wXkv!rQt0 z*LHwdvDaj%Z4t+Gy5zSR!*Ub|4mVhu0{)St+p59nF6J)32g_y*pUA}nrTyS=`*&kX zRwi2OWkK(Sbua5Tt+M_CWFF_8&$p@g1W0R;=3FrrxVqilj2^DIz;KBkBGPMW3(mTKg(1a5n`; zXzzG>k73+NW)^N&!hTEcDmYly_oMf9TB~x#HQZWv7N!38Tk_H2y#h|lJ}jxHIq43? zIg=Po9id-JgH^zsl=>Q*1}+~#niphP4>IRc!_JK{sb??hcgTyFc%4wn?CYQN|kIPanAM}*AMO9tjqrJ_DNqg0L^$8%H`$WimB~+OzEG9HIlRG-MsHmvRA!jW zu1~yK`zFB_LUCv&aPIV;l=rh%GUNuN7=yVpZ?2sh0Z(KRn$V$!u#XtrJKLOG+jAHN zL#>{aoB0>QedL+wl_5DE+2vyMY#xa@2bPYW{8Op-5p>O*R^@#dP$TcILlb<@HEytZ zeMG(6ZjNwov!{@?`MABUr^!H>TC?l92LH9>ZZtMm>nipt1UAkHA=sfTsFb5wpdCLa zrDErWaxj@K3o5_dYx3bQ&T;%)RTvWl9;%&4sD_P``ht3I< z<4FtP-Os^MTL1Z`+TUu0WVWlqrSewjli1KTZ7W&bN?dw36wJH_hD6UQ-ek zyEDg6DCqO;yY?9^y&$dQ7><>g-vPaY%%SFfm@d|JXr~TzX@M_f9e=BBKQkds(BUUM zr{&nLM;|Z_J%<*%8#T$ISfIPXAS`YxRzbk|yjV`z^^RCH6QBfCLxMZu%h&PE{G&D< zuxZuH29>qc0l2L<5l4M(7eP^nvbgiA8wzQM8>UfH>PE_&zj)hxc^HQHp^%>j*$NC! zXVF&lw2RQQY5!J9lF({6nuqfjbpMR%)L&H$?Liicr{B8sg)Qb1S>_8l))wQk1vK|S z(H%)aYsr$pn`};{n0=JeJ(wNuz(*o=$3)-zDpyc9;G#M z7jo@NU2Z~=v;c|Aiq7l1@kv1dnc5nnryE*>>x!N6LFv!O=m?AXnzB;ui3$Vu5IcZU z?vSRYXqu(wfAbZ;nN?0~aY6VRcpT2q-Xm8O7NTT;B+HCPtAwni=BdinzKeakp(sbl zy?W&y(}|hwjLvCWZycAZlgXRC@xt>XM70z6CbSQsT)Uk8ba<)+SQ;@76%r6Xf1xdb%^zvXIPDWD<5aD0E{%E+!n8_Dz?`y=#AhUpl}^kt5ND+Q4p z4%ejGK_iXY@+cki^=LpAr!c_Mi=QHZBqYq0deq`acOW&AMN@S-SYSmmKf@1u>(Huj zv;%)0$0;#4{H3-Y^?y4bB96ljn3715cbQ$#Yao1%-YSx|$f>@dN~Gb6yZP9Dy3L6o ze(!>fPLHv+P22=_&x~{?)Ot)13_FYKg$>2O(Lu1qP8w(M~6kBIVu2jQ>w!s%_NU5-t5b>DxW9moXZP{G2&Hcbqgjsu)IWMB5sF zv_dqGw;7($tx^)@m%oteQV%`jj=sUykCumgh1%9B5n4*YmEl$0L5bDse9n1old}Ow zzk(;IJE)rYF!+*>=IXTnGzu15A9A@*F(rVC zpsXLT-u|Y`JJ#eBM)P1nVn@Ci>zgD5snH$BIVzQHuVoP@@!YV$;B(NsQ%3++(D+;` zx2{cP@J?x!Otn_5IdS-B$ev&^5+4GUSUp}peK{J=F4~wbr>h{#iyHAJ{8TyVfv#ho zBnKVQ0lM&?`F9fnTTeJ_IU;_U#%I!R%(JVZmfpnB&>MY0E5`!;Ou+z>g>!=1ZM-tK ztO3x;wmc&Ae4G_1WDVOp2_Rl`H^n(DZE*BXrU`p*LdDXd$6;54a#AN%XucqAxn@DZ zD69=k#O1EvL|}sdbcFx4fRo^e^>*~9jV9ET5?P%V1Yy`bYr%of#@yLvHJ%*Kcb599 z1Ju83zjWT){vV+h_=hvi1hPyUAHW^Q|^wrMmyhnT}((s=4J63G+*!o ze(EZMaaY(+iZEgYg|AV-G{FLI-j%T?x~m)QT#+dILCax3ceBCCwaW>4AA!(km|36-2t4AM>f(e`Z2DuKKqo3=GHF!Ui+p0{+L}i! z_572)`1nVg&Zc8@47*n!%?w`5CW;`VU8eNAJKz&?4RE3SGd$7Vb`-t*4{YD^r)q1f zFALixBq)y`lpkWhh5`WqY&Czswa+}Z^=d*1IHu3l0aa`0f4qv!%Cf2o=)1S%!9;7EBW4t4C1zX{E5}Xk zpWZh^CU2p49oqzI$$g)DGHz9G!7u7Rf!M;4g8d>rg(=Xe62M(#%J_H>t|J;`?N_hA0v^$LBWe_ z`>SaY%EV*6t?#!^2jbJi$4vg&j=+rL#u+KJ4V+m2q0;;3fTjXH*x)0+ z95Qu9t?#A1f@GaNuKTd1I3-Rd00_<764$X?NV6RgNeRSkhOBren)ri8u1wy% z+(0yZSc6BW63lEfJAl~O!x-{ zMGL=oaRoGS86t99zDNegTp{U~JxILh(%u!xolHBEeD)O{&7P<3GK9)vpckLBWA(tH zv3x|2{+eq0EbOC0zX95uw*5k(ByIT7zqfPbt gkIFz4eZ%Pars#W!OIlDKl72RU#q>1||H1UX00s!o_Al54xZtc=r3y7gQDNcj=_&vZ&eGxC zFT0ErT2t#Yb~GRSgVzD|C}2MKp6;aRqwzj_G2>S28FWqvdb@g0d=^?S5c1Ra8Q<8x zB;3S#y9b@}Pkzcgu0Ig%fJnRTKNml9F1BZhlD7{7z2Adgpx!%PzTQ9=dgngFZz#e{ z27>WlYVVnG*l$9I0*bFsUy&OSFOg3uuie0}mTSlN#MkXL#~`6&!#&VTVC9qP=iS%z z8%XfWz!y4T;MMa@h;*zVU;X{I>?mD`zx?G;Ndm1#IEOaCTkY<@25m1sq@p5-s;uv3 zJtrK51&bVVWVb8&=7n3+Rlndu9y$JHnZh;UZpF~R{+YYm3w@*{u9JJn%sU#qB#+!+ zMX|{ESEST`bBu4^Hap7MOep}?bXVLep7iksE++}m|F6sV#}EFjbiAFr zRI)X!Ho`&)>~PEz)Tl|~Z>0isRryIz2z&9Xw%!z+%gu4NI{zVUV*Py{x3|xLjce8F zq^8tD*(Q)yEJpcHAuJA|mS9@I3is)Es1=3zOd527OI82DF8sHHM&rkyIDHndAuoz% zYR|%8sp@`I0?QCR)kyQ8lrVu1?IUW$V|C9qEOWn~Y$>g@uEf?IPpD7Nh7Xppd@j?#3nDxrKu%o1Am_V#}2Ow>d7_3VW zd4R^_;sHtWzw1Zsoo+=lTgkKJf}`^2a*(o8y{HO`jUeWphHNQ8z9(xSuM18xDpLJw zB?!7y|EPj+&V*j(T8(iV^m12XWdsgP4r*UI8h(^HgqGre&6mUD{X9D2}_sq5TQI_Z+ zw~roGXA!PD<&4=1azoI^?kDGYYy|vh-d*^;6$3@siYV-y4`(m?{rH#4X)WCSoaHOI zu050G{YAGYzEj&mT$CfQrTNPrOr-ah-g{Z!*e4@S&^|x&z*#M_{hvtvM~c^&5DlyRHP!+2^o4J;_c&J*zio)D1(i`CsPeyX2+E{@jj?di zVQBvbS|GePBeJfN`oz6BA@zGekmlUg@C=KeJz+dQIh#l9j9YZ7=ZJ|O^ha#bIm~Oo zATR&C(icOgPtkx>5>}l+9nwGI1&&J$=`yU6{t4V1S-U<}zLEe>)cao@C{sTG31E>d zYwTNn8LQK#;uGkjKawKmcI?U?D4DI<0W$O|3(=@*F3#8*|F3@j@oU{jZ@ESj(UG3L zm$j{yuY`))kTx%=FLwVcPcYr|itf#+X~nQ4{XJ1I1!mtwM4dJpEH zU^;I57YXzQj3tg}u+Q7N@gHxLxH4cfxsRY<&go)F#Y)9Lpq5lA<}KN`Qk9~(GU2GSsJJ8qj8s_hMx0-?KLg@Ynae|E&HHh zv)Gb)rVN!r5IeCR6P#?9!;|sjbBeVSf?qUsERh2rPW;2dgg{E7c)HC50*_){A!BN6 zF08f|od{g|=S7df)AH(=?N^Q2by5TIF{OD!`Z%oi$)nYl9Nn_6x&YV_GKTlTN?=xw ziMc4(6Q|60UqAP%&a_^XIAym;CMs^D$3-p|5pykBHI^8Ml-O?nAMs0}o)>b{V6ux_ zpJAdRa={|?e|L5Sv`tpLoI{1$qw`8#phiP5&_y)PC(A+iX zo?G|p&e?K?!0-USgQ04=W73#@p1)&5!>vQ8_&1O349^+gyOrG-_-o6mOPL_@kYJD7#sO<{YGp|~*1Dh@HN$|3 zI??j-5ZcaHW&ezZx&O>oxAAMyAi(G6<23-<7!L=~2rEYn0Ax=PkwmUTT;B&j>hApM zH~CFgx%RB=({Pytgeg_!86nweK@(e@N(=M&y+WJ!0Brf2z584!1z_TC!G;=gQvYci zQsG9@jB=Y}M~G20v@bri+`man7gssJP`$_36CVCM$7Qw7(PxgK&lj@~;s++)iH;1t zOZ)wqWJ&duT%_SQTs^69suU=rcmM)Pw}9};8MIAGlp$Ixa>kAGQWU;WWx!F3d%zBFaPTy&uM~zSH)h)-+T&jqrh6n?oK~l+xycHDy7HF#$5H zvgWS6_AF_)ok5MaIPR;VP&ontplAyKSdqyh$lZN{-}V&K1ssf+Q>h-y7BJd@)%oKy zbct#Bn89eZZpOZ=nCh%3JzT^sqKzUUdAAwS&}T&yqp%DxwnQfkru1%fK5gCuv8-!) zR{#5P+J7sw@xii{ku90$vSDUZjI_GMKuk?y2<{ZYie&vNY1``eo*lv}atN0HDQV?s zFig91RA&QCe;<~y9R}5!&2Absm)lzbCy7W9z(bbspa@3SX_sn~Nf5Fpkxd$6t3YK8 zfkaBE?e5b%a%3J=#=3%#j+3Mo5fnhN$yf%rqIb@&HZqBa%@Lf1AIIdM0Rj!E;i*}2 z2LS*8@SsbJ8u;K=P6`W>B2)c&+|^;19v!G)ZbFv97ISi;bCYjM1_s~5bFPdH^)MaM zwMac1;&|D?B5IG{wj51fTIP7K)-=}PKUk6;y{*TkZu}C5xC{%hxx7{TpkwR7p<@_O zZsnq9p4OY9We{yN!>Z($nRPs&9r;@0qOu5c%Ar;1F3@#T4-8#Zk}VzNw8N8qY-8Pu z)aLRsmK`Slq^*qH3&%Za=UCe5`fpzKakOP8lNv#}SpB>#85>?(4Rd-sD{tTc^Ns5P zm-(R6`#@zcC-LLSVJ-8gP&pw67JMwG#kB975VgU6vSx-Fya=9g5KtWK(btAQ*$f51 z7t1!dWqljEZS_ap7{a{ECD0v*SE!A2A`u*WFm9yu+G)Kq&GfUT=8^S?kaN2?DlvEB zTjc!qS2w4!34Nc2Vw4ar{;z@Tg0}D(7q)I9-j0zds@~O%?33MZ$wOBvHsMIVrQFa7tTVF19_7McPc`N@D~pzH5QJaVIU zp&i5h4SJzf2ewV+{G}nXi4wcLTD<1gp6D5lj?H~8xk$PA0t?egTIHFjC83!vSpOZ? z9advjTvSoMD`-!;qX9x~uJ?01NG||DN$kL2aE)kKoR|jeJy!nfI!9BCbK6W|GIHYV zXl<|T%#9wAf}Sak+V_HqY0n_~y%>$uD@6h89ql-r4hSgGVJTyfKh2BG{wQ9jzaq$M zRA+L2!-IvAH5eOR=q7xp6O-zVt+qxE`chGkA?O1D=qWn>>g7^rDK?>fx({v#L2&!3 zz>Sd*F89VUQrv+fEdc;6=W#0ook{|V^7qRoNh_t-j3+r(mYQYD_>SxWwyk{8MGXzO z%=$zu$C|%9#{~-0EDPcqjp5{O;hO~GKXO8;OZzx;HW(8EcTf?;r#tOk!Cjht72A=_ zJW^0?B<1!%BC|4aL^$!kiI~9EI}S6{`g!wtKdEi^Hmhd zDr|H%VB4_V++cr@bUANYa1+QXUyHr+j5M3T%#;QI5UWf=c#~_rmK}M<947)%Lb4j} z@*jF$bnVjad%qdw9!17#|9sA(0vm36Y2CnXe8K#JZjNd^*pl+0_MP%%PU+l@&P5U% zj15gi3_t#rhE_)te*p`^X#|;%*YQsE<`MfJ^Bbc} z?P3)$&ushf&BZ#pFapx$YK|8xcTB>7y!Arm8=lA%zjMXO*2^Dfe|-j-L&)v|E3@{uDC7#Dsl<5xs~!U?o?^PS!9iS z71As8IKD4P=uP|;g;{wHUx!>`@5Zas*i<$pHeL(L88#LtsdTFCZ9Ah~Do>j%V&ENK z+g1z3Bki@%IL56RoDgq4^CR~}Ub%69eOlFjEL**-<7Qh;0Q^LMS}XTl?nsDT~u9Gy%%OvuqaQDVB4Z^YhU*LQ%`M<6YT?x+U;vqg)2ocX<`*JqgC09Zg84^S{rFoD z#|ZYZO(MveejMY&L%rV7?G|8qw1WzgCmk=gEB4~uucO`=7sJ++3cAgtLu8}F;THld zQajI|hYVn$f+5hN9$MRkDjFWhMpCZ9;QFdOIdGef-pQg=1X)v56#~)wCs=i}+B+6< z!;w3VB18zQv4-iVO%E$_V=cdy-@}+g4N7pw>Z4h#L?U*#O$)`2b(I{5u9I|lEB2)~ z^vaJYi<%OY zBW>QNl<~ZC(EYCs3}AST1g$1EOP&~C9kLV55d$QUV9B7K;;VWWAaPdlkUgt|R|Rc> zSmd9%)Y-_^4&gVK$r1TkO|_i-Z0pYBZPXp28Pkjg9S%=5`n9|yM)((LUX ziGFc89EfY(hP6`3i3VU*&Q6CT{-{bi&skyUohoNfkEsPqy)Tgu({%E!C`)3?3Z`gBZ?MUHwwcu&oD<5`PJN}`$4nO+4OGgbQAz3UB+ z%X@ij&X9RqIz`iV1Mjx2>@I|JT~$LsR0%i3!)L;)Bt}XU%a}Ie>ZpQ#j{SPss$#6- z3`H`BVTk~{Qw?j=8X9K(L5-P`JMQ(RvLg2SB2xQKd-SBd zS7#iAQ&X5@FM)tgM|&Hz$&Z3MuJCDb%XcxLVW4SKZQZ z8UyC_K$G6_-Cyv>gPEfZMf#%Z2QIlBs)sVzy8#nX8|;2~!+iWE#`;9HLC{UU^bd|m zVRsEXBqq1whs@r;sikh*&Qyez?5qUwoQJYDQmlWXlhgNGMQ}Zd4lSc)@M#gLP&BfPaF+{$T9?MJ*qB6)YSXd8$+cL`rZ~_E zVV=4;8GmcT{7M^*pCO8Hmc+fmkS8dEVQg(1uW_Q+aj`XRcMCI@mU&W8?9Fvi&R4JM zPS_G8*|Y#;a6(+tAf(sKQW4B+`!II3!!PTP_Vhxj#6$;%HBd* z-W_uqUL`Q^sXk$4gh0{rB+nZr3c9HW)h900j6(rDpPgK4(R?t}@E6Vvkp!`ayYw=X`*BitP3zsPW^LB!n3q0sIR;n5Rl$y17CKd1Ga zyR$usgCc)m>qjw7$*=L=IOX3dVt0j{wAYWAAH2Gj0wALVpyT*prq*T7Acaweq3jMG zHJG~dTsM2j6&h9Gq}&3lu27(DC3yW`GxVg3^B5rw^QG*y&*d|;Csp{*TdKe|NL3>Q zYLL}0zZguCNNn;1dv}9wV}haF^gac~D4#~q;>iRy0N2E;+T{7+J>S!~Lniz6iFX^n zi|P9$xP!Yk8I7MzE)SSE`Vq5Z_yivlJOQQrFfpUlF4SFW_1bzd9gwZqxk)0;6Nyiu zRoZHQt`eeRQQ6ts{Q>zT8R7=&sAeFt`AHS{Kq0ZY2XHdj&Y)avJ4~_-X5`4j30MFE zB53*px?*G9fBVWb;AOt#nsGz18f1sO;)VWx(<{@}(( zJ@Z=nD? z)s%GVs>g`9`j!t2@oyyL{|r1!aNy%m2&FwnkG;itq+8u;*9eo+dEz7dDB#`^y26%%#C`=8~Tn~w3A;# zS3G=Aic}GJwpZd%S_pB{p%$N$cxAfIwcm1aiTo3Zy>R6XpUaF&#iQ{F7MB{`nX^?y zCm83qGm^i9q-Raod#!!uXNxsvBfQuDo2)%wH~Zvh2?#3hHz_qk=05fORdV~yt3K^S zBLGX*nGQZA)pgS&#zc$(*ifQMan#kjyj*4Z9_CTrqj-cjNJu8oz|U2LTb0e@^ngx5 z6;&gC|J;gH?=dI5yLI&W5P+d4zWeFAm;s}ChykHd%_J9_Kr$+0smG3}S=4p%M=uxu zQ@a@4&$m%+;j||s<}r^j*)Uq=M!h-Nx+-9FWU25ej|{R_sr}{dNET{W;bD=BOFuI| zCUBwC7=x%xMGBsPU8tc>6Rsrbx@Alk!hVOB&G;?l6*n2RC z-es|@Cf%2s?O;;%aC^Ql=aF##uSJL1#H&WZ+Zxjy?r^w@sd1JC*m7QVIaskU-NA-r z;mEKyBO+|9wa(m)09eV9Q-{KtK2^)@gw@rdJMVuUasa%FAz?ZM_sF8_9Krjc_t`i> zFr$x&cGzKR4Lo#4beV=>-JNE5m8VkWm_n5vBT5tB12r#_3bOdXZU{`L$qHe>0+>q) z%9)$SxGRMJiI-MNAR0_DAhb5kr`hKPkY?E5XlHkkhVBcwA{wB+a>~&%s-$GzNAo{p zjy7o#;K8)Jbyc(UfOq)Vj^6+(dfIsE=DUX78|4eP*=%0-$Rjsid5Mx8o|2mXRX_ya zVMc~6hiWcbZ*Yh&_dd)a5fwKNqHSfw%Jhw9h4(_3tS8n!1E~9C<7UR`;_t;p&^V7a zDQCk6&}4{PE1qG7B?Y9khlOr@Fngf6^l@df8t5UNqJxBi6G7k6Opga1Owr>Uk#kk3 z#J?&rHidIMsgxP9gC}cD6S3UA1vkJ$B3=&0K5o?gXbySd_6Pi=-0J$A9UdIUv}eEb z$srluqycdXPfm%}3c{ASbYV@1)i!B~`(xl13(OSk!*}+%tb2Hy$DY5fxADgAh^bCz zob@9jE=k3#@ANQ(eaFu?IAL9852ZmXqSSZ`*EY3DyGZ_i$P9Y}cMHN%G$E=99zOK% zx!t~=9l@|R*|+#Ptz9byTSYFZWQtU$R;>6bfJBeVzN4mBqqN+ud1Fw6Zw zh*pAYE~2%m!ce>K6~kd^ln3fAiEZA+6Dql;o`M@v%z%jumE?cEEy&uhtLA*oX--X|D%+&bxnxkBDxhJ%j{_Q{hzaKMT{) z<*B;I2#1Sk=(^`s?#9($?N)I6EM_(n9d{xu+%PtcoiHT?KhikZ8e#?wwsf4M>mFV2 z^iOX1hbSFU@|lZl9UK>WNH(M+>qDwmIu))<=kc_45TkscBe9j-hHf?3xwV&X@rJKY zxKD!?mwdoeQs}RSQn}SG1k@tCqde9*4+cT^!DflNC;p$~;{wTSHNONwhG;@*0F~>k zBw@77z(1VN4@>D(f!|)+%-i3oz8zBc1;LG(pgv121R|e>SBl%?YEiX&wwRjS6RBgA zHgi-=f27SHyx=Ayyu(dgMTZ5IHDvNQ>}E%yhD7%78t#j5gOE_`W1@PG%JDU9JGHx| z+eN>+N?70^R_TmB`oGe5&#M=@f3^cq>@@t>VBvmfV1oZHCMOPRUp33Eu?t6~o_ESE zMJ3#vST7QLr%)U|`oMMEQwS{#pqVnr#d&6NyXB6Lof0`1?1J!MY)kSSabVms@me8j zY)!DB)52P_f7b`M+GR?oLNJLXZw&(+hHepWbt7~VeZ1MKLYs|1CqcO6p?A|6f?Hi^ zxzEicMw+et<)&8}PizTIVRVhk7_Iwms8_jL=Xx*CXMh2fq=VNDGi;Pv9UK-L+z6i$ z!eBH?7Eo(SvD5rT>|tGdC&w%me;@+0Z`$Gos*6(o6u4iZ8QpJ(l~J4jHkP!aAdB8C z(I$jUduHC?!rvn@I+2@%dDu~Flqy@mH(F|+hi_aftE-_9QdBvz2g0xfx1njnr|EZ@ zYKfab=Z#lTCKS}`Z#+m9I1>0SQ@i~vd;3h8Rh#U>8+2BGW7c?@A(Myzxh9<;#nLIJ zRbYJu5e*2lN`%<1M|WJVhIii{!v-CLtQw$3`f_b=9A2_KeJ6rQ?2{k9Q^OySWA8CH zWL>I4vBlKtbSI)HIX^pataV)YEI$5Pz+X6DqZUt!*3*xQa>vDwi#I!(e0GCE+9CD) z=1FHNU{PEO{B=TnS;>jx+3R9C`*zBR;%}%wPP5tDjApo%g(7GD;a?F{LmGl-*DnO) zfO!E?O{iD>eY`Q-y00fVH5la%#O2Dy=VORN{(`kXt(0D*fq5>b7YbmN7D zZf#nwak`GV@3w@7SCwC?$?OE7m5=?)jU+w!o223x1mqUTBuW(W*rKCWbP5i z)i78{$UL{Pfa_A$OiL0jx2`qYxy!Ibb7q@MHNGLu+*L+EwligWpvqm<$C+xc{;5>_ zNAa!mDm#U;h2hRi6I=A93r3(1R;cY<)0Pf(2KAk5j4g%g7SkDV@(0t|Xs6mzgDydV zl%Sdb6vKPgU&3XfmvI?opONVCzk%Hbon=debf}t}giO;&tqTbz>Xh2*wGN zM3Y>q)DPl7i+vlzZX1iBUrE-V7~hB+B!o$bw-iy^z}gXi3FFT801Rsu_%4<%-S?aj zRH1=p%o%kwuen_ay%SE`kcnej-)278_6XklWq)Jr1)%#ORYwd3g**B%lEbbF+?>jp zOq?17Klt1RoudB{r-D1W$>Hg$M459pvRFY`h6t1rT{!xt{3oZkMm=o~6*Dt9lcf#c z6)S@Hq$FZ5A@kG0A%|$a;kOFA8d*kF`6){6v=33)ONX0{!mZYuD2Zc>cQoyGFi_N9 zot&G=d6o(-1mlc9ve|bNWU^LNF>-b2J>YPm9&3^=_lkyWLyrLAnZdQb~xq00azy-K)$_Qi^iDMg9uRuzu2|QFcyoIvT$~ zT-h3;Wx@#d4INe3SHAkG?7Pxu*r=WxSqJl$X#b31v%Etn3+jjt4SQYpWv+KZ!jKTcEQ9{biQao!j z+bz=;ADN%_elFi}!!mXv)Tuj)%0viEuIf}GhoTyY>b}v1+8@I+ytCq{{3dami_cnE zZYoheCp<^zYJgT3&vAvc*u<5nTtfRnIr>bf)HZW?T8b?uXA{woDW5@o{f77*?D zIElN$a1;TMJpi+On_?I zb+DlS@I7e9R7x~v*P7wqW^{1c z2tiAWOJbJ|@TA}EC%J>96?sj4j@5U31zk>pkq2Zj;oz6JS9SJ0oCPq<4QKrjYud9d zvB8ma%;1stiE6r4<&>1Q`9AyNf~G2C#;-^dC5ZQn5Bn{m?l@G}=X@y2JxgYEsO}@u z>pv`Dg@Kf6*c)1&_@r9`PBI(bjbf zI_zxBX_|j}+QJi(Rq#NwERA9XgL75SlcVU;_68vQc1t-WTh;>kN27QN6x%qp1x0d#_DmP187kD@xDva*JK+n=gMBB{a+|$#i^vRjIn%c_iuJB+! ziO~9^ih(*f;kJ{bb%n?jiutYzbeW;p5sX+IgNMN(m5)svDg$SWoXekur=9KT3N162 zsuM?Dt8q0lBX`l5JP_#^wrtD)s_;+7I9Cq!tiwqntG{! zZ$MwpwXWp_n@n&0h1Jj}dUCV)V{JdrQC;jp{FEZw(8m5#*l=RP46lf+`@5h5`q3E= zGATe%B=_&h{p7xrV@ZmLz{3)**fRPmIS2(MZRU{2Z5n6yRIO*z`+OfMx44^SlcePB zG8XJ`3;(`gfG*IJjg0@Pjlklg@nnMT(%*|25uLC7l*?&2!!x8yIJEzD*RosccMgqz z?Ig4rYEXdty3-va6E!Z^vc6&_Bl3(Fto^i^hqI#_al;zsyhA-4qM&abCSDuH0&W!2 zN=|9Z?0wL(^BZ!^tx2t|oSjxpIdNdGz4^jo(z39aK_4Ih121|1OBfPI2SS@|GSD?2 zJPTmR?dM@n0zgK+dO~!`L5WPA+k6rdZMBNJ{p)8ewau*BV>3(q#WJaUpX>wJ9Af_H z^JQrcR8`KBZtHN*UtbV5tXT@wW250GHgFG-7xTW+QWPJ(V;g#1>r}F5koNC)ZzN_T~i( zWrFBybP*JYOLt-(x)L4x2W8ud&r-~JfP1~*Z3dXCj#N=T}i!YId53RPPq zUaBCC@`>lbUcI_YuuxH5?7-SH&KkjYjMISbWf*h&8{J+5y=Q5gO+N8vhF9JU0U#0* z<%9W7-n|suENE&*QqZQXC>i11Mx8JRrP-+UI9W~oXd7uaJoc*a#t`jOD6{6nE#Ubx zjO{5+`9i6*84mI2j4fIVdgX)CG%`2G6v%wrazY*guML6pIC%WmoQ+tbxJ%JF z_n4ZO!0koYk$|Iy#PB{CgUWiynhYFtW0YwXd7>7Ne?KbIvU)|KIW)N>}#I^S=X9_u>UP>MzP8ucM+|=gaHkQQQqPm+(o_+ zpAkiLynrW+8OXwawi9l`7sYwx{Bk*$cjbM!w9>6U_^3{4Ztjsj`ZfP*ElgG?CAf(N zlL2|uLgJDU$3V1(`I?wbcT7PAu=!*$g~DAdKMvGw?6P`Vph*&_s2ISVF8x!m4msmp z*()4`3Cx5>WtGeeP)LC9YzszkzchEdtGq8HqU9Qa9B<$^Yod%F))TRBq5GA>*2D!3 zD07mmBv67&Pfe)uz(pwDH|\"", "notification_email_host_description": "Host of the email server (e.g. smtp.immich.app)", @@ -929,7 +928,6 @@ "no_shared_albums_message": "Create an album to share photos and videos with people in your network", "not_in_any_album": "Not in any album", "note_apply_storage_label_to_previously_uploaded assets": "Note: To apply the Storage Label to previously uploaded assets, run the", - "note_unlimited_quota": "Note: Enter 0 for unlimited quota", "notes": "Notes", "notification_toggle_setting_description": "Enable email notifications", "notifications": "Notifications", @@ -1384,4 +1382,4 @@ "yes": "Yes", "you_dont_have_any_shared_links": "You don't have any shared links", "zoom_image": "Zoom Image" -} \ No newline at end of file +} diff --git a/mobile/openapi/lib/model/user_admin_create_dto.dart b/mobile/openapi/lib/model/user_admin_create_dto.dart index f2709be57b..4bd1266426 100644 --- a/mobile/openapi/lib/model/user_admin_create_dto.dart +++ b/mobile/openapi/lib/model/user_admin_create_dto.dart @@ -36,7 +36,7 @@ class UserAdminCreateDto { String password; - /// Minimum value: 1 + /// Minimum value: 0 int? quotaSizeInBytes; /// diff --git a/mobile/openapi/lib/model/user_admin_update_dto.dart b/mobile/openapi/lib/model/user_admin_update_dto.dart index 6c6f73ae8e..f0478c9b4c 100644 --- a/mobile/openapi/lib/model/user_admin_update_dto.dart +++ b/mobile/openapi/lib/model/user_admin_update_dto.dart @@ -45,7 +45,7 @@ class UserAdminUpdateDto { /// String? password; - /// Minimum value: 1 + /// Minimum value: 0 int? quotaSizeInBytes; /// diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index ac30e9ae97..4e8e7ab834 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -13624,7 +13624,7 @@ }, "quotaSizeInBytes": { "format": "int64", - "minimum": 1, + "minimum": 0, "nullable": true, "type": "integer" }, @@ -13763,7 +13763,7 @@ }, "quotaSizeInBytes": { "format": "int64", - "minimum": 1, + "minimum": 0, "nullable": true, "type": "integer" }, diff --git a/server/src/dtos/user.dto.ts b/server/src/dtos/user.dto.ts index 0177e9b475..afcd13f0e9 100644 --- a/server/src/dtos/user.dto.ts +++ b/server/src/dtos/user.dto.ts @@ -1,6 +1,6 @@ import { ApiProperty } from '@nestjs/swagger'; import { Transform } from 'class-transformer'; -import { IsBoolean, IsEmail, IsNotEmpty, IsNumber, IsPositive, IsString } from 'class-validator'; +import { IsBoolean, IsEmail, IsNotEmpty, IsNumber, IsString, Min } from 'class-validator'; import { User, UserAdmin } from 'src/database'; import { UserMetadataEntity, UserMetadataItem } from 'src/entities/user-metadata.entity'; import { UserEntity } from 'src/entities/user.entity'; @@ -77,7 +77,7 @@ export class UserAdminCreateDto { @Optional({ nullable: true }) @IsNumber() - @IsPositive() + @Min(0) @ApiProperty({ type: 'integer', format: 'int64' }) quotaSizeInBytes?: number | null; @@ -115,7 +115,7 @@ export class UserAdminUpdateDto { @Optional({ nullable: true }) @IsNumber() - @IsPositive() + @Min(0) @ApiProperty({ type: 'integer', format: 'int64' }) quotaSizeInBytes?: number | null; } diff --git a/server/src/services/asset-media.service.ts b/server/src/services/asset-media.service.ts index 747d7e4514..1b8a038b9c 100644 --- a/server/src/services/asset-media.service.ts +++ b/server/src/services/asset-media.service.ts @@ -435,7 +435,7 @@ export class AssetMediaService extends BaseService { } private requireQuota(auth: AuthDto, size: number) { - if (auth.user.quotaSizeInBytes && auth.user.quotaSizeInBytes < auth.user.quotaUsageInBytes + size) { + if (auth.user.quotaSizeInBytes !== null && auth.user.quotaSizeInBytes < auth.user.quotaUsageInBytes + size) { throw new BadRequestException('Quota has been exceeded!'); } } diff --git a/web/src/lib/components/forms/create-user-form.svelte b/web/src/lib/components/forms/create-user-form.svelte index 87de0a9068..83b3154d4b 100644 --- a/web/src/lib/components/forms/create-user-form.svelte +++ b/web/src/lib/components/forms/create-user-form.svelte @@ -30,7 +30,7 @@ let quotaSize: string | undefined = $state(); let isCreatingUser = $state(false); - let quotaSizeInBytes = $derived(quotaSize ? convertToBytes(Number(quotaSize), ByteUnit.GiB) : null); + let quotaSizeInBytes = $derived(quotaSize === null ? null : convertToBytes(Number(quotaSize), ByteUnit.GiB)); let quotaSizeWarning = $derived( quotaSizeInBytes && userInteraction.serverInfo && quotaSizeInBytes > userInteraction.serverInfo.diskSizeRaw, ); @@ -113,7 +113,7 @@ - + {#if quotaSizeWarning} {$t('errors.quota_higher_than_disk_size')} {/if} diff --git a/web/src/lib/components/forms/edit-user-form.svelte b/web/src/lib/components/forms/edit-user-form.svelte index 2802ad3ab2..ab914e6430 100644 --- a/web/src/lib/components/forms/edit-user-form.svelte +++ b/web/src/lib/components/forms/edit-user-form.svelte @@ -28,7 +28,7 @@ onEditSuccess, }: Props = $props(); - let quotaSize = $state(user.quotaSizeInBytes ? convertFromBytes(user.quotaSizeInBytes, ByteUnit.GiB) : null); + let quotaSize = $state(user.quotaSizeInBytes === null ? null : convertFromBytes(user.quotaSizeInBytes, ByteUnit.GiB)); const previousQutoa = user.quotaSizeInBytes; @@ -48,7 +48,7 @@ email, name, storageLabel: storageLabel || '', - quotaSizeInBytes: quotaSize ? convertToBytes(Number(quotaSize), ByteUnit.GiB) : null, + quotaSizeInBytes: quotaSize === null ? null : convertToBytes(Number(quotaSize), ByteUnit.GiB), }, }); @@ -126,8 +126,15 @@

{$t('errors.quota_higher_than_disk_size')}

{/if} - -

{$t('admin.note_unlimited_quota')}

+
diff --git a/web/src/routes/admin/user-management/+page.svelte b/web/src/routes/admin/user-management/+page.svelte index 9ec2e9eab1..0ca17c4ed8 100644 --- a/web/src/routes/admin/user-management/+page.svelte +++ b/web/src/routes/admin/user-management/+page.svelte @@ -209,7 +209,7 @@ {immichUser.name}
- {#if immichUser.quotaSizeInBytes && immichUser.quotaSizeInBytes > 0} + {#if immichUser.quotaSizeInBytes !== null && immichUser.quotaSizeInBytes >= 0} {getByteUnitString(immichUser.quotaSizeInBytes, $locale)} {:else} From 1e4b9ae5b741f38d36e242a515ceef4e0fe2daee Mon Sep 17 00:00:00 2001 From: Sebastian Schneider Date: Mon, 7 Apr 2025 16:26:08 +0200 Subject: [PATCH 20/52] fix(mobile): video player restarting when device rotates (#17362) * fix(mobile): Video player restarting when device rotates * use global key in state * Implement suggestions from code review --- mobile/lib/pages/common/gallery_viewer.page.dart | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/mobile/lib/pages/common/gallery_viewer.page.dart b/mobile/lib/pages/common/gallery_viewer.page.dart index ab780eeb75..7392a4d340 100644 --- a/mobile/lib/pages/common/gallery_viewer.page.dart +++ b/mobile/lib/pages/common/gallery_viewer.page.dart @@ -12,8 +12,8 @@ import 'package:immich_mobile/entities/asset.entity.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/scroll_extensions.dart'; import 'package:immich_mobile/pages/common/download_panel.dart'; -import 'package:immich_mobile/pages/common/native_video_viewer.page.dart'; import 'package:immich_mobile/pages/common/gallery_stacked_children.dart'; +import 'package:immich_mobile/pages/common/native_video_viewer.page.dart'; import 'package:immich_mobile/providers/app_settings.provider.dart'; import 'package:immich_mobile/providers/asset_viewer/asset_stack.provider.dart'; import 'package:immich_mobile/providers/asset_viewer/current_asset.provider.dart'; @@ -63,6 +63,10 @@ class GalleryViewerPage extends HookConsumerWidget { final loadAsset = renderList.loadAsset; final isPlayingMotionVideo = ref.watch(isPlayingMotionVideoProvider); + // This key is to prevent the video player from being re-initialized during + // hero animation or device rotation. + final videoPlayerKey = useMemoized(() => GlobalKey()); + Future precacheNextImage(int index) async { if (!context.mounted) { return; @@ -225,8 +229,6 @@ class GalleryViewerPage extends HookConsumerWidget { } PhotoViewGalleryPageOptions buildVideo(BuildContext context, Asset asset) { - // This key is to prevent the video player from being re-initialized during the hero animation - final key = GlobalKey(); return PhotoViewGalleryPageOptions.customChild( onDragStart: (_, details, __) => localPosition.value = details.localPosition, @@ -241,7 +243,7 @@ class GalleryViewerPage extends HookConsumerWidget { width: context.width, height: context.height, child: NativeVideoViewerPage( - key: key, + key: videoPlayerKey, asset: asset, image: Image( key: ValueKey(asset), From a724f147fe720a1272abab275e4c15ddd862eefd Mon Sep 17 00:00:00 2001 From: Yaros Date: Mon, 7 Apr 2025 16:35:27 +0200 Subject: [PATCH 21/52] fix(mobile): items not deselecting on back button (#17403) * fix: items not deselecting on back button * chore: add comments --- .../asset_grid/immich_asset_grid_view.dart | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/mobile/lib/widgets/asset_grid/immich_asset_grid_view.dart b/mobile/lib/widgets/asset_grid/immich_asset_grid_view.dart index 941a2a7ac6..1c0f9a2b56 100644 --- a/mobile/lib/widgets/asset_grid/immich_asset_grid_view.dart +++ b/mobile/lib/widgets/asset_grid/immich_asset_grid_view.dart @@ -546,15 +546,16 @@ class ImmichAssetGridViewState extends ConsumerState { if (didPop) { return; } else { - if (widget.preselectedAssets == null) { - Navigator.of(context).canPop() ? Navigator.of(context).pop() : null; - } - if (_selectedAssets.length != widget.preselectedAssets!.length && - !widget.preselectedAssets!.containsAll(_selectedAssets)) { - { - _deselectAll(); - return; - } + /// `preselectedAssets` is only present when opening the asset grid from the + /// "add to album" button. + /// + /// `_selectedAssets` includes `preselectedAssets` on initialization. + if (_selectedAssets.length > + (widget.preselectedAssets?.length ?? 0)) { + /// `_deselectAll` only deselects the selected assets, + /// doesn't affect the preselected ones. + _deselectAll(); + return; } else { Navigator.of(context).canPop() ? Navigator.of(context).pop() : null; } From 042da669d1ce10881b77c846ff57882e84ba5529 Mon Sep 17 00:00:00 2001 From: shenlong <139912620+shenlong-tanwen@users.noreply.github.com> Date: Mon, 7 Apr 2025 20:09:24 +0530 Subject: [PATCH 22/52] fix(mobile): use custom filter to fetch asset path entities (#17344) Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com> --- mobile/assets/i18n/en-US.json | 2 ++ mobile/lib/domain/models/store.model.dart | 4 ++- .../repositories/album_media.repository.dart | 25 ++++++++++++------- mobile/lib/services/app_settings.service.dart | 5 ++++ .../widgets/settings/advanced_settings.dart | 8 ++++++ 5 files changed, 34 insertions(+), 10 deletions(-) diff --git a/mobile/assets/i18n/en-US.json b/mobile/assets/i18n/en-US.json index aac46f70dd..e3b6916e74 100644 --- a/mobile/assets/i18n/en-US.json +++ b/mobile/assets/i18n/en-US.json @@ -17,6 +17,8 @@ "advanced_settings_proxy_headers_title": "Proxy Headers", "advanced_settings_self_signed_ssl_subtitle": "Skips SSL certificate verification for the server endpoint. Required for self-signed certificates.", "advanced_settings_self_signed_ssl_title": "Allow self-signed SSL certificates", + "advanced_settings_enable_alternate_media_filter_title": "[EXPERIMENTAL] Use alternate device album sync filter", + "advanced_settings_enable_alternate_media_filter_subtitle": "Use this option to filter media during sync based on alternate criteria. Only try this if you have issues with the app detecting all albums.", "advanced_settings_tile_subtitle": "Advanced user's settings", "advanced_settings_tile_title": "Advanced", "advanced_settings_troubleshooting_subtitle": "Enable additional features for troubleshooting", diff --git a/mobile/lib/domain/models/store.model.dart b/mobile/lib/domain/models/store.model.dart index 8a99d68b8f..e6d9ecaf48 100644 --- a/mobile/lib/domain/models/store.model.dart +++ b/mobile/lib/domain/models/store.model.dart @@ -65,7 +65,9 @@ enum StoreKey { // Video settings loadOriginalVideo._(136), - ; + + // Experimental stuff + photoManagerCustomFilter._(1000); const StoreKey._(this.id); final int id; diff --git a/mobile/lib/repositories/album_media.repository.dart b/mobile/lib/repositories/album_media.repository.dart index f08322e20a..8673ebd2b0 100644 --- a/mobile/lib/repositories/album_media.repository.dart +++ b/mobile/lib/repositories/album_media.repository.dart @@ -8,16 +8,23 @@ import 'package:immich_mobile/interfaces/album_media.interface.dart'; import 'package:immich_mobile/repositories/asset_media.repository.dart'; import 'package:photo_manager/photo_manager.dart' hide AssetType; -final albumMediaRepositoryProvider = Provider((ref) => AlbumMediaRepository()); +final albumMediaRepositoryProvider = + Provider((ref) => const AlbumMediaRepository()); class AlbumMediaRepository implements IAlbumMediaRepository { + const AlbumMediaRepository(); + + bool get useCustomFilter => + Store.get(StoreKey.photoManagerCustomFilter, false); + @override Future> getAll() async { + final filter = useCustomFilter + ? CustomFilter.sql(where: '${CustomColumns.base.width} > 0') + : FilterOptionGroup(containsPathModified: true); + final List assetPathEntities = - await PhotoManager.getAssetPathList( - hasAll: true, - filterOption: FilterOptionGroup(containsPathModified: true), - ); + await PhotoManager.getAssetPathList(hasAll: true, filterOption: filter); return assetPathEntities.map(_toAlbum).toList(); } @@ -47,18 +54,18 @@ class AlbumMediaRepository implements IAlbumMediaRepository { final onDevice = await AssetPathEntity.fromId( albumId, filterOption: FilterOptionGroup( - containsPathModified: true, - orders: orderByModificationDate - ? [const OrderOption(type: OrderOptionType.updateDate)] - : [], imageOption: const FilterOption(needTitle: true), videoOption: const FilterOption(needTitle: true), + containsPathModified: true, updateTimeCond: modifiedFrom == null && modifiedUntil == null ? null : DateTimeCond( min: modifiedFrom ?? DateTime.utc(-271820), max: modifiedUntil ?? DateTime.utc(275760), ), + orders: orderByModificationDate + ? [const OrderOption(type: OrderOptionType.updateDate)] + : [], ), ); diff --git a/mobile/lib/services/app_settings.service.dart b/mobile/lib/services/app_settings.service.dart index 1870a61d7a..cc57b8d3a3 100644 --- a/mobile/lib/services/app_settings.service.dart +++ b/mobile/lib/services/app_settings.service.dart @@ -84,6 +84,11 @@ enum AppSettingsEnum { enableHapticFeedback(StoreKey.enableHapticFeedback, null, true), syncAlbums(StoreKey.syncAlbums, null, false), autoEndpointSwitching(StoreKey.autoEndpointSwitching, null, false), + photoManagerCustomFilter( + StoreKey.photoManagerCustomFilter, + null, + false, + ), ; const AppSettingsEnum(this.storeKey, this.hiveKey, this.defaultValue); diff --git a/mobile/lib/widgets/settings/advanced_settings.dart b/mobile/lib/widgets/settings/advanced_settings.dart index 4e399e8aec..a2e0e5b95c 100644 --- a/mobile/lib/widgets/settings/advanced_settings.dart +++ b/mobile/lib/widgets/settings/advanced_settings.dart @@ -29,6 +29,8 @@ class AdvancedSettings extends HookConsumerWidget { final preferRemote = useAppSettingsState(AppSettingsEnum.preferRemoteImage); final allowSelfSignedSSLCert = useAppSettingsState(AppSettingsEnum.allowSelfSignedSSLCert); + final useAlternatePMFilter = + useAppSettingsState(AppSettingsEnum.photoManagerCustomFilter); final logLevel = Level.LEVELS[levelId.value].name; @@ -68,6 +70,12 @@ class AdvancedSettings extends HookConsumerWidget { ), const CustomeProxyHeaderSettings(), SslClientCertSettings(isLoggedIn: ref.read(currentUserProvider) != null), + SettingsSwitchListTile( + valueNotifier: useAlternatePMFilter, + title: "advanced_settings_enable_alternate_media_filter_title".tr(), + subtitle: + "advanced_settings_enable_alternate_media_filter_subtitle".tr(), + ), ]; return SettingsSubPageScaffold(settings: advancedSettings); From 43d585ce55e4a70f030aac272c97ab203bf166bb Mon Sep 17 00:00:00 2001 From: shenlong <139912620+shenlong-tanwen@users.noreply.github.com> Date: Mon, 7 Apr 2025 20:51:37 +0530 Subject: [PATCH 23/52] fix(mobile): exifInfo not updated on sync (#17407) * fix(mobile): exifInfo not updated on sync * add tests --------- Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com> --- mobile/lib/services/asset.service.dart | 4 +- mobile/lib/services/sync.service.dart | 2 +- mobile/test/api.mocks.dart | 4 + mobile/test/fixtures/asset.stub.dart | 3 + .../modules/shared/sync_service_test.dart | 14 +++ mobile/test/repository.mocks.dart | 8 +- mobile/test/service.mocks.dart | 6 + mobile/test/services/asset.service_test.dart | 111 ++++++++++++++++++ 8 files changed, 148 insertions(+), 4 deletions(-) create mode 100644 mobile/test/api.mocks.dart create mode 100644 mobile/test/services/asset.service_test.dart diff --git a/mobile/lib/services/asset.service.dart b/mobile/lib/services/asset.service.dart index 92f04e2304..d187284d07 100644 --- a/mobile/lib/services/asset.service.dart +++ b/mobile/lib/services/asset.service.dart @@ -256,7 +256,7 @@ class AssetService { for (var element in assets) { element.fileCreatedAt = DateTime.parse(updatedDt); - element.exifInfo ??= element.exifInfo + element.exifInfo = element.exifInfo ?.copyWith(dateTimeOriginal: DateTime.parse(updatedDt)); } @@ -283,7 +283,7 @@ class AssetService { ); for (var element in assets) { - element.exifInfo ??= element.exifInfo?.copyWith( + element.exifInfo = element.exifInfo?.copyWith( latitude: location.latitude, longitude: location.longitude, ); diff --git a/mobile/lib/services/sync.service.dart b/mobile/lib/services/sync.service.dart index f2b16b080a..1e3c2a070b 100644 --- a/mobile/lib/services/sync.service.dart +++ b/mobile/lib/services/sync.service.dart @@ -798,7 +798,7 @@ class SyncService { await _assetRepository.transaction(() async { await _assetRepository.updateAll(assets); for (final Asset added in assets) { - added.exifInfo ??= added.exifInfo?.copyWith(assetId: added.id); + added.exifInfo = added.exifInfo?.copyWith(assetId: added.id); } await _exifInfoRepository.updateAll(exifInfos); }); diff --git a/mobile/test/api.mocks.dart b/mobile/test/api.mocks.dart new file mode 100644 index 0000000000..d502ea0675 --- /dev/null +++ b/mobile/test/api.mocks.dart @@ -0,0 +1,4 @@ +import 'package:mocktail/mocktail.dart'; +import 'package:openapi/api.dart'; + +class MockAssetsApi extends Mock implements AssetsApi {} diff --git a/mobile/test/fixtures/asset.stub.dart b/mobile/test/fixtures/asset.stub.dart index b69b392129..771b2dda96 100644 --- a/mobile/test/fixtures/asset.stub.dart +++ b/mobile/test/fixtures/asset.stub.dart @@ -1,3 +1,4 @@ +import 'package:immich_mobile/domain/models/exif.model.dart'; import 'package:immich_mobile/entities/asset.entity.dart'; final class AssetStub { @@ -17,6 +18,7 @@ final class AssetStub { isFavorite: true, isArchived: false, isTrashed: false, + exifInfo: const ExifInfo(isFlipped: false), ); static final image2 = Asset( @@ -33,6 +35,7 @@ final class AssetStub { isFavorite: false, isArchived: false, isTrashed: false, + exifInfo: const ExifInfo(isFlipped: true), ); static final image3 = Asset( diff --git a/mobile/test/modules/shared/sync_service_test.dart b/mobile/test/modules/shared/sync_service_test.dart index 0197008dd1..eab6b6f61a 100644 --- a/mobile/test/modules/shared/sync_service_test.dart +++ b/mobile/test/modules/shared/sync_service_test.dart @@ -16,6 +16,7 @@ import 'package:immich_mobile/services/sync.service.dart'; import 'package:mocktail/mocktail.dart'; import '../../domain/service.mock.dart'; +import '../../fixtures/asset.stub.dart'; import '../../infrastructure/repository.mock.dart'; import '../../repository.mocks.dart'; import '../../service.mocks.dart'; @@ -258,6 +259,19 @@ void main() { expect(c, isTrue); verify(() => assetRepository.updateAll(expected)); }); + + group("upsertAssetsWithExif", () { + test('test upsert with EXIF data', () async { + final assets = [AssetStub.image1, AssetStub.image2]; + + expect( + assets.map((a) => a.exifInfo?.assetId), + List.filled(assets.length, null), + ); + await s.upsertAssetsWithExif(assets); + expect(assets.map((a) => a.exifInfo?.assetId), assets.map((a) => a.id)); + }); + }); }); } diff --git a/mobile/test/repository.mocks.dart b/mobile/test/repository.mocks.dart index e4e99ffcb8..1c698297dc 100644 --- a/mobile/test/repository.mocks.dart +++ b/mobile/test/repository.mocks.dart @@ -3,14 +3,15 @@ import 'package:immich_mobile/interfaces/album.interface.dart'; import 'package:immich_mobile/interfaces/album_api.interface.dart'; import 'package:immich_mobile/interfaces/album_media.interface.dart'; import 'package:immich_mobile/interfaces/asset.interface.dart'; +import 'package:immich_mobile/interfaces/asset_api.interface.dart'; import 'package:immich_mobile/interfaces/asset_media.interface.dart'; import 'package:immich_mobile/interfaces/auth.interface.dart'; import 'package:immich_mobile/interfaces/auth_api.interface.dart'; import 'package:immich_mobile/interfaces/backup_album.interface.dart'; import 'package:immich_mobile/interfaces/etag.interface.dart'; import 'package:immich_mobile/interfaces/file_media.interface.dart'; -import 'package:immich_mobile/interfaces/partner_api.interface.dart'; import 'package:immich_mobile/interfaces/partner.interface.dart'; +import 'package:immich_mobile/interfaces/partner_api.interface.dart'; import 'package:mocktail/mocktail.dart'; class MockAlbumRepository extends Mock implements IAlbumRepository {} @@ -25,6 +26,11 @@ class MockETagRepository extends Mock implements IETagRepository {} class MockAlbumMediaRepository extends Mock implements IAlbumMediaRepository {} +class MockBackupAlbumRepository extends Mock + implements IBackupAlbumRepository {} + +class MockAssetApiRepository extends Mock implements IAssetApiRepository {} + class MockAssetMediaRepository extends Mock implements IAssetMediaRepository {} class MockFileMediaRepository extends Mock implements IFileMediaRepository {} diff --git a/mobile/test/service.mocks.dart b/mobile/test/service.mocks.dart index 8ee1c58609..d31a7e5d50 100644 --- a/mobile/test/service.mocks.dart +++ b/mobile/test/service.mocks.dart @@ -1,5 +1,7 @@ +import 'package:immich_mobile/services/album.service.dart'; import 'package:immich_mobile/services/api.service.dart'; import 'package:immich_mobile/services/background.service.dart'; +import 'package:immich_mobile/services/backup.service.dart'; import 'package:immich_mobile/services/entity.service.dart'; import 'package:immich_mobile/services/hash.service.dart'; import 'package:immich_mobile/services/network.service.dart'; @@ -9,6 +11,10 @@ import 'package:openapi/api.dart'; class MockApiService extends Mock implements ApiService {} +class MockAlbumService extends Mock implements AlbumService {} + +class MockBackupService extends Mock implements BackupService {} + class MockSyncService extends Mock implements SyncService {} class MockHashService extends Mock implements HashService {} diff --git a/mobile/test/services/asset.service_test.dart b/mobile/test/services/asset.service_test.dart new file mode 100644 index 0000000000..63546e39f1 --- /dev/null +++ b/mobile/test/services/asset.service_test.dart @@ -0,0 +1,111 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:immich_mobile/entities/asset.entity.dart'; +import 'package:immich_mobile/services/asset.service.dart'; +import 'package:maplibre_gl/maplibre_gl.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:openapi/api.dart'; + +import '../api.mocks.dart'; +import '../domain/service.mock.dart'; +import '../fixtures/asset.stub.dart'; +import '../infrastructure/repository.mock.dart'; +import '../repository.mocks.dart'; +import '../service.mocks.dart'; + +class FakeAssetBulkUpdateDto extends Fake implements AssetBulkUpdateDto {} + +void main() { + late AssetService sut; + + late MockAssetRepository assetRepository; + late MockAssetApiRepository assetApiRepository; + late MockExifInfoRepository exifInfoRepository; + late MockETagRepository eTagRepository; + late MockBackupAlbumRepository backupAlbumRepository; + late MockUserRepository userRepository; + late MockAssetMediaRepository assetMediaRepository; + late MockApiService apiService; + + late MockSyncService syncService; + late MockAlbumService albumService; + late MockBackupService backupService; + late MockUserService userService; + + setUp(() { + assetRepository = MockAssetRepository(); + assetApiRepository = MockAssetApiRepository(); + exifInfoRepository = MockExifInfoRepository(); + userRepository = MockUserRepository(); + eTagRepository = MockETagRepository(); + backupAlbumRepository = MockBackupAlbumRepository(); + apiService = MockApiService(); + assetMediaRepository = MockAssetMediaRepository(); + + syncService = MockSyncService(); + userService = MockUserService(); + albumService = MockAlbumService(); + backupService = MockBackupService(); + + sut = AssetService( + assetApiRepository, + assetRepository, + exifInfoRepository, + userRepository, + eTagRepository, + backupAlbumRepository, + apiService, + syncService, + backupService, + albumService, + userService, + assetMediaRepository, + ); + + registerFallbackValue(FakeAssetBulkUpdateDto()); + }); + + group("Edit ExifInfo", () { + late AssetsApi assetsApi; + setUp(() { + assetsApi = MockAssetsApi(); + when(() => apiService.assetsApi).thenReturn(assetsApi); + when(() => assetsApi.updateAssets(any())) + .thenAnswer((_) async => Future.value()); + }); + + test("asset is updated with DateTime", () async { + final assets = [AssetStub.image1, AssetStub.image2]; + final dateTime = DateTime.utc(2025, 6, 4, 2, 57); + await sut.changeDateTime(assets, dateTime.toIso8601String()); + + verify(() => assetsApi.updateAssets(any())).called(1); + final upsertExifCallback = + verify(() => syncService.upsertAssetsWithExif(captureAny())); + upsertExifCallback.called(1); + final receivedAssets = + upsertExifCallback.captured.firstOrNull as List? ?? []; + final receivedDatetime = receivedAssets.cast().map( + (a) => a.exifInfo?.dateTimeOriginal ?? DateTime(0), + ); + expect(receivedDatetime.every((d) => d == dateTime), isTrue); + }); + + test("asset is updated with LatLng", () async { + final assets = [AssetStub.image1, AssetStub.image2]; + final latLng = const LatLng(37.7749, -122.4194); + await sut.changeLocation(assets, latLng); + + verify(() => assetsApi.updateAssets(any())).called(1); + final upsertExifCallback = + verify(() => syncService.upsertAssetsWithExif(captureAny())); + upsertExifCallback.called(1); + final receivedAssets = + upsertExifCallback.captured.firstOrNull as List? ?? []; + final receivedCoords = receivedAssets.cast().map( + (a) => + LatLng(a.exifInfo?.latitude ?? 0, a.exifInfo?.longitude ?? 0), + ); + expect(receivedCoords.every((l) => l == latLng), isTrue); + }); + }); +} From 51c2c60231fc400b4ee4369f773c32ccc39046bf Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 7 Apr 2025 16:35:29 +0100 Subject: [PATCH 24/52] chore(deps): update dependency vite to v6.2.5 [security] (#17391) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- cli/package-lock.json | 6 +++--- web/package-lock.json | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/cli/package-lock.json b/cli/package-lock.json index 84b61991d4..959f64e376 100644 --- a/cli/package-lock.json +++ b/cli/package-lock.json @@ -4144,9 +4144,9 @@ } }, "node_modules/vite": { - "version": "6.2.4", - "resolved": "https://registry.npmjs.org/vite/-/vite-6.2.4.tgz", - "integrity": "sha512-veHMSew8CcRzhL5o8ONjy8gkfmFJAd5Ac16oxBUjlwgX3Gq2Wqr+qNC3TjPIpy7TPV/KporLga5GT9HqdrCizw==", + "version": "6.2.5", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.2.5.tgz", + "integrity": "sha512-j023J/hCAa4pRIUH6J9HemwYfjB5llR2Ps0CWeikOtdR8+pAURAk0DoJC5/mm9kd+UgdnIy7d6HE4EAvlYhPhA==", "dev": true, "license": "MIT", "dependencies": { diff --git a/web/package-lock.json b/web/package-lock.json index 0df269cfa0..501496cfaf 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -9509,9 +9509,9 @@ } }, "node_modules/vite": { - "version": "6.2.4", - "resolved": "https://registry.npmjs.org/vite/-/vite-6.2.4.tgz", - "integrity": "sha512-veHMSew8CcRzhL5o8ONjy8gkfmFJAd5Ac16oxBUjlwgX3Gq2Wqr+qNC3TjPIpy7TPV/KporLga5GT9HqdrCizw==", + "version": "6.2.5", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.2.5.tgz", + "integrity": "sha512-j023J/hCAa4pRIUH6J9HemwYfjB5llR2Ps0CWeikOtdR8+pAURAk0DoJC5/mm9kd+UgdnIy7d6HE4EAvlYhPhA==", "dev": true, "license": "MIT", "dependencies": { From e7a5b96ed06e7959429e38257051b2226d8595b7 Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Mon, 7 Apr 2025 15:12:12 -0400 Subject: [PATCH 25/52] feat: extension, triggers, functions, comments, parameters management in sql-tools (#17269) feat: sql-tools extension, triggers, functions, comments, parameters --- .github/workflows/test.yml | 4 +- server/package.json | 4 +- server/src/bin/migrations.ts | 83 ++- server/src/decorators.ts | 16 + .../migrations/1743595393000-TableCleanup.ts | 12 + .../src/repositories/database.repository.ts | 82 +-- server/src/repositories/logging.repository.ts | 6 +- server/src/schema/enums.ts | 12 + server/src/schema/functions.ts | 116 +++++ server/src/schema/index.ts | 109 ++++ server/src/schema/tables/activity.table.ts | 25 +- server/src/schema/tables/album-asset.table.ts | 18 +- server/src/schema/tables/album.table.ts | 26 +- server/src/schema/tables/api-key.table.ts | 19 +- server/src/schema/tables/asset-audit.table.ts | 5 +- server/src/schema/tables/asset-face.table.ts | 19 +- server/src/schema/tables/asset-files.table.ts | 19 +- server/src/schema/tables/asset.table.ts | 100 ++-- server/src/schema/tables/audit.table.ts | 2 +- server/src/schema/tables/exif.table.ts | 99 ++-- server/src/schema/tables/face-search.table.ts | 12 +- .../src/schema/tables/geodata-places.table.ts | 71 ++- server/src/schema/tables/index.ts | 108 ++-- server/src/schema/tables/library.table.ts | 11 +- server/src/schema/tables/memory.table.ts | 17 +- .../src/schema/tables/memory_asset.table.ts | 8 +- .../tables/natural-earth-countries.table.ts | 24 +- .../src/schema/tables/partner-audit.table.ts | 5 +- server/src/schema/tables/partner.table.ts | 18 +- server/src/schema/tables/person.table.ts | 23 +- server/src/schema/tables/session.table.ts | 15 +- server/src/schema/tables/shared-link.table.ts | 14 +- .../src/schema/tables/smart-search.table.ts | 12 +- server/src/schema/tables/stack.table.ts | 6 +- .../schema/tables/sync-checkpoint.table.ts | 9 +- server/src/schema/tables/tag-closure.table.ts | 8 +- server/src/schema/tables/tag.table.ts | 15 +- server/src/schema/tables/user-audit.table.ts | 9 +- server/src/schema/tables/user.table.ts | 70 +-- server/src/sql-tools/decorators.ts | 107 ---- .../diff/comparers/column.comparer.spec.ts | 81 +++ .../diff/comparers/column.comparer.ts | 82 +++ .../comparers/constraint.comparer.spec.ts | 63 +++ .../diff/comparers/constraint.comparer.ts | 133 +++++ .../diff/comparers/enum.comparer.spec.ts | 54 ++ .../sql-tools/diff/comparers/enum.comparer.ts | 38 ++ .../diff/comparers/extension.comparer.spec.ts | 37 ++ .../diff/comparers/extension.comparer.ts | 22 + .../diff/comparers/function.comparer.spec.ts | 53 ++ .../diff/comparers/function.comparer.ts | 32 ++ .../diff/comparers/index.comparer.spec.ts | 72 +++ .../diff/comparers/index.comparer.ts | 46 ++ .../diff/comparers/parameter.comparer.spec.ts | 44 ++ .../diff/comparers/parameter.comparer.ts | 23 + .../diff/comparers/table.comparer.spec.ts | 44 ++ .../diff/comparers/table.comparer.ts | 59 +++ .../diff/comparers/trigger.comparer.spec.ts | 88 ++++ .../diff/comparers/trigger.comparer.ts | 41 ++ .../index.spec.ts} | 50 +- server/src/sql-tools/diff/index.ts | 85 ++++ .../decorators/after-delete.decorator.ts | 8 + .../decorators/before-update.decorator.ts | 8 + .../from-code/decorators/check.decorator.ts | 11 + .../decorators/column-index.decorator.ts | 16 + .../from-code/decorators/column.decorator.ts | 30 ++ .../configuration-parameter.decorator.ts | 14 + .../create-date-column.decorator.ts | 9 + .../decorators/database.decorator.ts | 10 + .../delete-date-column.decorator.ts | 9 + .../decorators/extension.decorator.ts | 11 + .../decorators/extensions.decorator.ts | 15 + .../foreign-key-column.decorator.ts | 18 + .../decorators/generated-column.decorator.ts | 37 ++ .../from-code/decorators/index.decorator.ts | 12 + .../decorators/primary-column.decorator.ts | 3 + .../primary-generated-column.decorator.ts | 4 + .../from-code/decorators/table.decorator.ts | 14 + .../decorators/trigger-function.decorator.ts | 6 + .../from-code/decorators/trigger.decorator.ts | 19 + .../from-code/decorators/unique.decorator.ts | 11 + .../update-date-column.decorator.ts | 9 + .../index.spec.ts} | 15 +- server/src/sql-tools/from-code/index.ts | 69 +++ .../processors/check-constraint.processor.ts | 26 + .../processors/column-index.processor.ts | 32 ++ .../from-code/processors/column.processor.ts | 103 ++++ .../configuration-parameter.processor.ts | 16 + .../processors/database.processor.ts | 10 + .../from-code/processors/enum.processor.ts | 8 + .../processors/extension.processor.ts | 12 + .../foreign-key-constriant.processor.ts | 59 +++ .../processors/function.processor.ts | 8 + .../from-code/processors/index.processor.ts | 27 + .../primary-key-contraint.processor.ts | 24 + .../from-code/processors/table.processor.ts | 51 ++ .../from-code/processors/trigger.processor.ts | 28 ++ .../sql-tools/from-code/processors/type.ts | 9 + .../processors/unique-constraint.processor.ts | 27 + .../src/sql-tools/from-code/register-enum.ts | 20 + .../sql-tools/from-code/register-function.ts | 29 ++ .../src/sql-tools/from-code/register-item.ts | 31 ++ server/src/sql-tools/from-code/register.ts | 11 + .../index.ts} | 238 +++++++-- server/src/sql-tools/helpers.ts | 268 ++++++++++ server/src/sql-tools/public_api.ts | 32 +- .../src/sql-tools/schema-diff-to-sql.spec.ts | 473 ------------------ server/src/sql-tools/schema-diff-to-sql.ts | 204 -------- server/src/sql-tools/schema-diff.ts | 449 ----------------- .../src/sql-tools/schema-from-decorators.ts | 443 ---------------- server/src/sql-tools/to-sql/index.spec.ts | 21 + server/src/sql-tools/to-sql/index.ts | 59 +++ .../transformers/column.transformer.spec.ts | 126 +++++ .../to-sql/transformers/column.transformer.ts | 55 ++ .../constraint.transformer.spec.ts | 96 ++++ .../transformers/constraint.transformer.ts | 58 +++ .../to-sql/transformers/enum.transformer.ts | 26 + .../extension.transformer.spec.ts | 31 ++ .../transformers/extension.transformer.ts | 26 + .../transformers/function.transformer.spec.ts | 16 + .../transformers/function.transformer.ts | 26 + .../transformers/index.transformer.spec.ts | 100 ++++ .../to-sql/transformers/index.transformer.ts | 56 +++ .../transformers/parameter.transformer.ts | 33 ++ .../transformers/table.transformer.spec.ts | 150 ++++++ .../to-sql/transformers/table.transformer.ts | 44 ++ .../transformers/trigger.transformer.spec.ts | 91 ++++ .../transformers/trigger.transformer.ts | 52 ++ .../sql-tools/to-sql/transformers/types.ts | 3 + server/src/sql-tools/types.ts | 253 +++++++--- .../check-constraint-default-name.stub.ts | 8 +- .../check-constraint-override-name.stub.ts | 8 +- .../test/sql-tools/column-create-date.stub.ts | 39 ++ .../sql-tools/column-default-boolean.stub.ts | 8 +- .../sql-tools/column-default-date.stub.ts | 8 +- .../sql-tools/column-default-function.stub.ts | 8 +- .../sql-tools/column-default-null.stub.ts | 8 +- .../sql-tools/column-default-number.stub.ts | 8 +- .../sql-tools/column-default-string.stub.ts | 8 +- .../test/sql-tools/column-delete-date.stub.ts | 38 ++ .../test/sql-tools/column-enum-type.stub.ts | 52 ++ .../sql-tools/column-generated-identity.ts | 47 ++ .../sql-tools/column-generated-uuid.stub.ts | 47 ++ .../sql-tools/column-index-name-default.ts | 8 +- .../column-inferred-nullable.stub.ts | 8 +- .../sql-tools/column-name-default.stub.ts | 8 +- .../sql-tools/column-name-override.stub.ts | 8 +- .../test/sql-tools/column-name-string.stub.ts | 8 +- server/test/sql-tools/column-nullable.stub.ts | 8 +- ...e.stub.ts => column-string-length.stub.ts} | 22 +- ...umn-unique-constraint-name-default.stub.ts | 8 +- ...mn-unique-constraint-name-override.stub.ts | 8 +- .../test/sql-tools/column-update-date.stub.ts | 39 ++ .../foreign-key-inferred-type.stub.ts | 9 +- ...foreign-key-with-unique-constraint.stub.ts | 9 +- .../test/sql-tools/index-name-default.stub.ts | 8 +- .../sql-tools/index-name-override.stub.ts | 8 +- .../sql-tools/index-with-where.stub copy.ts | 8 +- .../test/sql-tools/index-with-where.stub.ts | 8 +- ...rimary-key-constraint-name-default.stub.ts | 8 +- ...imary-key-constraint-name-override.stub.ts | 8 +- .../test/sql-tools/table-name-default.stub.ts | 8 +- .../sql-tools/table-name-override.stub.ts | 8 +- .../table-name-string-option.stub.ts | 8 +- .../sql-tools/trigger-after-delete.stub.ts | 46 ++ .../sql-tools/trigger-before-update.stub.ts | 46 ++ .../sql-tools/trigger-name-default.stub.ts | 41 ++ .../sql-tools/trigger-name-override.stub.ts | 42 ++ .../unique-constraint-name-default.stub.ts | 8 +- .../unique-constraint-name-override.stub.ts | 8 +- server/test/vitest.config.mjs | 4 +- 170 files changed, 5205 insertions(+), 2295 deletions(-) create mode 100644 server/src/migrations/1743595393000-TableCleanup.ts create mode 100644 server/src/schema/enums.ts create mode 100644 server/src/schema/functions.ts create mode 100644 server/src/schema/index.ts delete mode 100644 server/src/sql-tools/decorators.ts create mode 100644 server/src/sql-tools/diff/comparers/column.comparer.spec.ts create mode 100644 server/src/sql-tools/diff/comparers/column.comparer.ts create mode 100644 server/src/sql-tools/diff/comparers/constraint.comparer.spec.ts create mode 100644 server/src/sql-tools/diff/comparers/constraint.comparer.ts create mode 100644 server/src/sql-tools/diff/comparers/enum.comparer.spec.ts create mode 100644 server/src/sql-tools/diff/comparers/enum.comparer.ts create mode 100644 server/src/sql-tools/diff/comparers/extension.comparer.spec.ts create mode 100644 server/src/sql-tools/diff/comparers/extension.comparer.ts create mode 100644 server/src/sql-tools/diff/comparers/function.comparer.spec.ts create mode 100644 server/src/sql-tools/diff/comparers/function.comparer.ts create mode 100644 server/src/sql-tools/diff/comparers/index.comparer.spec.ts create mode 100644 server/src/sql-tools/diff/comparers/index.comparer.ts create mode 100644 server/src/sql-tools/diff/comparers/parameter.comparer.spec.ts create mode 100644 server/src/sql-tools/diff/comparers/parameter.comparer.ts create mode 100644 server/src/sql-tools/diff/comparers/table.comparer.spec.ts create mode 100644 server/src/sql-tools/diff/comparers/table.comparer.ts create mode 100644 server/src/sql-tools/diff/comparers/trigger.comparer.spec.ts create mode 100644 server/src/sql-tools/diff/comparers/trigger.comparer.ts rename server/src/sql-tools/{schema-diff.spec.ts => diff/index.spec.ts} (94%) create mode 100644 server/src/sql-tools/diff/index.ts create mode 100644 server/src/sql-tools/from-code/decorators/after-delete.decorator.ts create mode 100644 server/src/sql-tools/from-code/decorators/before-update.decorator.ts create mode 100644 server/src/sql-tools/from-code/decorators/check.decorator.ts create mode 100644 server/src/sql-tools/from-code/decorators/column-index.decorator.ts create mode 100644 server/src/sql-tools/from-code/decorators/column.decorator.ts create mode 100644 server/src/sql-tools/from-code/decorators/configuration-parameter.decorator.ts create mode 100644 server/src/sql-tools/from-code/decorators/create-date-column.decorator.ts create mode 100644 server/src/sql-tools/from-code/decorators/database.decorator.ts create mode 100644 server/src/sql-tools/from-code/decorators/delete-date-column.decorator.ts create mode 100644 server/src/sql-tools/from-code/decorators/extension.decorator.ts create mode 100644 server/src/sql-tools/from-code/decorators/extensions.decorator.ts create mode 100644 server/src/sql-tools/from-code/decorators/foreign-key-column.decorator.ts create mode 100644 server/src/sql-tools/from-code/decorators/generated-column.decorator.ts create mode 100644 server/src/sql-tools/from-code/decorators/index.decorator.ts create mode 100644 server/src/sql-tools/from-code/decorators/primary-column.decorator.ts create mode 100644 server/src/sql-tools/from-code/decorators/primary-generated-column.decorator.ts create mode 100644 server/src/sql-tools/from-code/decorators/table.decorator.ts create mode 100644 server/src/sql-tools/from-code/decorators/trigger-function.decorator.ts create mode 100644 server/src/sql-tools/from-code/decorators/trigger.decorator.ts create mode 100644 server/src/sql-tools/from-code/decorators/unique.decorator.ts create mode 100644 server/src/sql-tools/from-code/decorators/update-date-column.decorator.ts rename server/src/sql-tools/{schema-from-decorators.spec.ts => from-code/index.spec.ts} (64%) create mode 100644 server/src/sql-tools/from-code/index.ts create mode 100644 server/src/sql-tools/from-code/processors/check-constraint.processor.ts create mode 100644 server/src/sql-tools/from-code/processors/column-index.processor.ts create mode 100644 server/src/sql-tools/from-code/processors/column.processor.ts create mode 100644 server/src/sql-tools/from-code/processors/configuration-parameter.processor.ts create mode 100644 server/src/sql-tools/from-code/processors/database.processor.ts create mode 100644 server/src/sql-tools/from-code/processors/enum.processor.ts create mode 100644 server/src/sql-tools/from-code/processors/extension.processor.ts create mode 100644 server/src/sql-tools/from-code/processors/foreign-key-constriant.processor.ts create mode 100644 server/src/sql-tools/from-code/processors/function.processor.ts create mode 100644 server/src/sql-tools/from-code/processors/index.processor.ts create mode 100644 server/src/sql-tools/from-code/processors/primary-key-contraint.processor.ts create mode 100644 server/src/sql-tools/from-code/processors/table.processor.ts create mode 100644 server/src/sql-tools/from-code/processors/trigger.processor.ts create mode 100644 server/src/sql-tools/from-code/processors/type.ts create mode 100644 server/src/sql-tools/from-code/processors/unique-constraint.processor.ts create mode 100644 server/src/sql-tools/from-code/register-enum.ts create mode 100644 server/src/sql-tools/from-code/register-function.ts create mode 100644 server/src/sql-tools/from-code/register-item.ts create mode 100644 server/src/sql-tools/from-code/register.ts rename server/src/sql-tools/{schema-from-database.ts => from-database/index.ts} (67%) create mode 100644 server/src/sql-tools/helpers.ts delete mode 100644 server/src/sql-tools/schema-diff-to-sql.spec.ts delete mode 100644 server/src/sql-tools/schema-diff-to-sql.ts delete mode 100644 server/src/sql-tools/schema-diff.ts delete mode 100644 server/src/sql-tools/schema-from-decorators.ts create mode 100644 server/src/sql-tools/to-sql/index.spec.ts create mode 100644 server/src/sql-tools/to-sql/index.ts create mode 100644 server/src/sql-tools/to-sql/transformers/column.transformer.spec.ts create mode 100644 server/src/sql-tools/to-sql/transformers/column.transformer.ts create mode 100644 server/src/sql-tools/to-sql/transformers/constraint.transformer.spec.ts create mode 100644 server/src/sql-tools/to-sql/transformers/constraint.transformer.ts create mode 100644 server/src/sql-tools/to-sql/transformers/enum.transformer.ts create mode 100644 server/src/sql-tools/to-sql/transformers/extension.transformer.spec.ts create mode 100644 server/src/sql-tools/to-sql/transformers/extension.transformer.ts create mode 100644 server/src/sql-tools/to-sql/transformers/function.transformer.spec.ts create mode 100644 server/src/sql-tools/to-sql/transformers/function.transformer.ts create mode 100644 server/src/sql-tools/to-sql/transformers/index.transformer.spec.ts create mode 100644 server/src/sql-tools/to-sql/transformers/index.transformer.ts create mode 100644 server/src/sql-tools/to-sql/transformers/parameter.transformer.ts create mode 100644 server/src/sql-tools/to-sql/transformers/table.transformer.spec.ts create mode 100644 server/src/sql-tools/to-sql/transformers/table.transformer.ts create mode 100644 server/src/sql-tools/to-sql/transformers/trigger.transformer.spec.ts create mode 100644 server/src/sql-tools/to-sql/transformers/trigger.transformer.ts create mode 100644 server/src/sql-tools/to-sql/transformers/types.ts create mode 100644 server/test/sql-tools/column-create-date.stub.ts create mode 100644 server/test/sql-tools/column-delete-date.stub.ts create mode 100644 server/test/sql-tools/column-enum-type.stub.ts create mode 100644 server/test/sql-tools/column-generated-identity.ts create mode 100644 server/test/sql-tools/column-generated-uuid.stub.ts rename server/test/sql-tools/{column-enum-name.stub.ts => column-string-length.stub.ts} (63%) create mode 100644 server/test/sql-tools/column-update-date.stub.ts create mode 100644 server/test/sql-tools/trigger-after-delete.stub.ts create mode 100644 server/test/sql-tools/trigger-before-update.stub.ts create mode 100644 server/test/sql-tools/trigger-name-default.stub.ts create mode 100644 server/test/sql-tools/trigger-name-override.stub.ts diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 024ed15fa3..1a2bca2d96 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -518,7 +518,7 @@ jobs: run: npm run build - name: Run existing migrations - run: npm run typeorm:migrations:run + run: npm run migrations:run - name: Test npm run schema:reset command works run: npm run typeorm:schema:reset @@ -532,7 +532,7 @@ jobs: id: verify-changed-files with: files: | - server/src/migrations/ + server/src - name: Verify migration files have not changed if: steps.verify-changed-files.outputs.files_changed == 'true' run: | diff --git a/server/package.json b/server/package.json index c7a64594ff..2015e1d8c5 100644 --- a/server/package.json +++ b/server/package.json @@ -25,10 +25,10 @@ "lifecycle": "node ./dist/utils/lifecycle.js", "migrations:generate": "node ./dist/bin/migrations.js generate", "migrations:create": "node ./dist/bin/migrations.js create", - "typeorm:migrations:run": "typeorm migration:run -d ./dist/bin/database.js", + "migrations:run": "node ./dist/bin/migrations.js run", "typeorm:migrations:revert": "typeorm migration:revert -d ./dist/bin/database.js", "typeorm:schema:drop": "typeorm query -d ./dist/bin/database.js 'DROP schema public cascade; CREATE schema public;'", - "typeorm:schema:reset": "npm run typeorm:schema:drop && npm run typeorm:migrations:run", + "typeorm:schema:reset": "npm run typeorm:schema:drop && npm run migrations:run", "kysely:codegen": "npx kysely-codegen --include-pattern=\"(public|vectors).*\" --dialect postgres --url postgres://postgres:postgres@localhost/immich --log-level debug --out-file=./src/db.d.ts", "sync:open-api": "node ./dist/bin/sync-open-api.js", "sync:sql": "node ./dist/bin/sync-sql.js", diff --git a/server/src/bin/migrations.ts b/server/src/bin/migrations.ts index b553ff7fa7..222ad96858 100644 --- a/server/src/bin/migrations.ts +++ b/server/src/bin/migrations.ts @@ -1,15 +1,20 @@ #!/usr/bin/env node -process.env.DB_URL = 'postgres://postgres:postgres@localhost:5432/immich'; +process.env.DB_URL = process.env.DB_URL || 'postgres://postgres:postgres@localhost:5432/immich'; +import { Kysely } from 'kysely'; +import { PostgresJSDialect } from 'kysely-postgres-js'; import { writeFileSync } from 'node:fs'; +import { basename, dirname, extname, join } from 'node:path'; import postgres from 'postgres'; import { ConfigRepository } from 'src/repositories/config.repository'; -import 'src/schema/tables'; -import { DatabaseTable, schemaDiff, schemaFromDatabase, schemaFromDecorators } from 'src/sql-tools'; +import { DatabaseRepository } from 'src/repositories/database.repository'; +import { LoggingRepository } from 'src/repositories/logging.repository'; +import 'src/schema'; +import { schemaDiff, schemaFromCode, schemaFromDatabase } from 'src/sql-tools'; const main = async () => { const command = process.argv[2]; - const name = process.argv[3] || 'Migration'; + const path = process.argv[3] || 'src/Migration'; switch (command) { case 'debug': { @@ -17,13 +22,19 @@ const main = async () => { return; } + case 'run': { + const only = process.argv[3] as 'kysely' | 'typeorm' | undefined; + await run(only); + return; + } + case 'create': { - create(name, [], []); + create(path, [], []); return; } case 'generate': { - await generate(name); + await generate(path); return; } @@ -31,32 +42,57 @@ const main = async () => { console.log(`Usage: node dist/bin/migrations.js create node dist/bin/migrations.js generate + node dist/bin/migrations.js run `); } } }; +const run = async (only?: 'kysely' | 'typeorm') => { + const configRepository = new ConfigRepository(); + const { database } = configRepository.getEnv(); + const logger = new LoggingRepository(undefined, configRepository); + const db = new Kysely({ + dialect: new PostgresJSDialect({ postgres: postgres(database.config.kysely) }), + log(event) { + if (event.level === 'error') { + console.error('Query failed :', { + durationMs: event.queryDurationMillis, + error: event.error, + sql: event.query.sql, + params: event.query.parameters, + }); + } + }, + }); + const databaseRepository = new DatabaseRepository(db, logger, configRepository); + + await databaseRepository.runMigrations({ only }); +}; + const debug = async () => { - const { up, down } = await compare(); + const { up } = await compare(); const upSql = '-- UP\n' + up.asSql({ comments: true }).join('\n'); - const downSql = '-- DOWN\n' + down.asSql({ comments: true }).join('\n'); - writeFileSync('./migrations.sql', upSql + '\n\n' + downSql); + // const downSql = '-- DOWN\n' + down.asSql({ comments: true }).join('\n'); + writeFileSync('./migrations.sql', upSql + '\n\n'); console.log('Wrote migrations.sql'); }; -const generate = async (name: string) => { +const generate = async (path: string) => { const { up, down } = await compare(); if (up.items.length === 0) { console.log('No changes detected'); return; } - create(name, up.asSql(), down.asSql()); + create(path, up.asSql(), down.asSql()); }; -const create = (name: string, up: string[], down: string[]) => { +const create = (path: string, up: string[], down: string[]) => { const timestamp = Date.now(); + const name = basename(path, extname(path)); const filename = `${timestamp}-${name}.ts`; - const fullPath = `./src/${filename}`; + const folder = dirname(path); + const fullPath = join(folder, filename); writeFileSync(fullPath, asMigration('kysely', { name, timestamp, up, down })); console.log(`Wrote ${fullPath}`); }; @@ -66,16 +102,25 @@ const compare = async () => { const { database } = configRepository.getEnv(); const db = postgres(database.config.kysely); - const source = schemaFromDecorators(); + const source = schemaFromCode(); const target = await schemaFromDatabase(db, {}); + const sourceParams = new Set(source.parameters.map(({ name }) => name)); + target.parameters = target.parameters.filter(({ name }) => sourceParams.has(name)); + + const sourceTables = new Set(source.tables.map(({ name }) => name)); + target.tables = target.tables.filter(({ name }) => sourceTables.has(name)); + console.log(source.warnings.join('\n')); - const isIncluded = (table: DatabaseTable) => source.tables.some(({ name }) => table.name === name); - target.tables = target.tables.filter((table) => isIncluded(table)); - - const up = schemaDiff(source, target, { ignoreExtraTables: true }); - const down = schemaDiff(target, source, { ignoreExtraTables: false }); + const up = schemaDiff(source, target, { + tables: { ignoreExtra: true }, + functions: { ignoreExtra: false }, + }); + const down = schemaDiff(target, source, { + tables: { ignoreExtra: false }, + functions: { ignoreExtra: false }, + }); return { up, down }; }; diff --git a/server/src/decorators.ts b/server/src/decorators.ts index 56efdd1c08..95a34d026e 100644 --- a/server/src/decorators.ts +++ b/server/src/decorators.ts @@ -4,8 +4,24 @@ import _ from 'lodash'; import { ADDED_IN_PREFIX, DEPRECATED_IN_PREFIX, LIFECYCLE_EXTENSION } from 'src/constants'; import { ImmichWorker, JobName, MetadataKey, QueueName } from 'src/enum'; import { EmitEvent } from 'src/repositories/event.repository'; +import { immich_uuid_v7, updated_at } from 'src/schema/functions'; +import { BeforeUpdateTrigger, Column, ColumnOptions } from 'src/sql-tools'; import { setUnion } from 'src/utils/set'; +const GeneratedUuidV7Column = (options: Omit = {}) => + Column({ ...options, type: 'uuid', nullable: false, default: () => `${immich_uuid_v7.name}()` }); + +export const UpdateIdColumn = () => GeneratedUuidV7Column(); + +export const PrimaryGeneratedUuidV7Column = () => GeneratedUuidV7Column({ primary: true }); + +export const UpdatedAtTrigger = (name: string) => + BeforeUpdateTrigger({ + name, + scope: 'row', + function: updated_at, + }); + // PostgreSQL uses a 16-bit integer to indicate the number of bound parameters. This means that the // maximum number of parameters is 65535. Any query that tries to bind more than that (e.g. searching // by a list of IDs) requires splitting the query into multiple chunks. diff --git a/server/src/migrations/1743595393000-TableCleanup.ts b/server/src/migrations/1743595393000-TableCleanup.ts new file mode 100644 index 0000000000..adf9c65afa --- /dev/null +++ b/server/src/migrations/1743595393000-TableCleanup.ts @@ -0,0 +1,12 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class TableCleanup1743595393000 implements MigrationInterface { + name = 'TableCleanup1743595393000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE IF EXISTS "system_config"`); + await queryRunner.query(`DROP TABLE IF EXISTS "socket_io_attachments"`); + } + + public async down(): Promise {} +} diff --git a/server/src/repositories/database.repository.ts b/server/src/repositories/database.repository.ts index 43d3d2c16c..ec0b263408 100644 --- a/server/src/repositories/database.repository.ts +++ b/server/src/repositories/database.repository.ts @@ -197,58 +197,62 @@ export class DatabaseRepository { return dimSize; } - async runMigrations(options?: { transaction?: 'all' | 'none' | 'each' }): Promise { + async runMigrations(options?: { transaction?: 'all' | 'none' | 'each'; only?: 'kysely' | 'typeorm' }): Promise { const { database } = this.configRepository.getEnv(); - const dataSource = new DataSource(database.config.typeorm); + if (options?.only !== 'kysely') { + const dataSource = new DataSource(database.config.typeorm); - this.logger.log('Running migrations, this may take a while'); + this.logger.log('Running migrations, this may take a while'); - this.logger.debug('Running typeorm migrations'); + this.logger.debug('Running typeorm migrations'); - await dataSource.initialize(); - await dataSource.runMigrations(options); - await dataSource.destroy(); + await dataSource.initialize(); + await dataSource.runMigrations(options); + await dataSource.destroy(); - this.logger.debug('Finished running typeorm migrations'); - - // eslint-disable-next-line unicorn/prefer-module - const migrationFolder = join(__dirname, '..', 'schema/migrations'); - - // TODO remove after we have at least one kysely migration - if (!existsSync(migrationFolder)) { - return; + this.logger.debug('Finished running typeorm migrations'); } - this.logger.debug('Running kysely migrations'); - const migrator = new Migrator({ - db: this.db, - migrationLockTableName: 'kysely_migrations_lock', - migrationTableName: 'kysely_migrations', - provider: new FileMigrationProvider({ - fs: { readdir }, - path: { join }, - migrationFolder, - }), - }); + if (options?.only !== 'typeorm') { + // eslint-disable-next-line unicorn/prefer-module + const migrationFolder = join(__dirname, '..', 'schema/migrations'); - const { error, results } = await migrator.migrateToLatest(); - - for (const result of results ?? []) { - if (result.status === 'Success') { - this.logger.log(`Migration "${result.migrationName}" succeeded`); + // TODO remove after we have at least one kysely migration + if (!existsSync(migrationFolder)) { + return; } - if (result.status === 'Error') { - this.logger.warn(`Migration "${result.migrationName}" failed`); + this.logger.debug('Running kysely migrations'); + const migrator = new Migrator({ + db: this.db, + migrationLockTableName: 'kysely_migrations_lock', + migrationTableName: 'kysely_migrations', + provider: new FileMigrationProvider({ + fs: { readdir }, + path: { join }, + migrationFolder, + }), + }); + + const { error, results } = await migrator.migrateToLatest(); + + for (const result of results ?? []) { + if (result.status === 'Success') { + this.logger.log(`Migration "${result.migrationName}" succeeded`); + } + + if (result.status === 'Error') { + this.logger.warn(`Migration "${result.migrationName}" failed`); + } } - } - if (error) { - this.logger.error(`Kysely migrations failed: ${error}`); - throw error; - } + if (error) { + this.logger.error(`Kysely migrations failed: ${error}`); + throw error; + } - this.logger.debug('Finished running kysely migrations'); + this.logger.debug('Finished running kysely migrations'); + } } async withLock(lock: DatabaseLock, callback: () => Promise): Promise { diff --git a/server/src/repositories/logging.repository.ts b/server/src/repositories/logging.repository.ts index aaf21a3d7c..3f809db41e 100644 --- a/server/src/repositories/logging.repository.ts +++ b/server/src/repositories/logging.repository.ts @@ -1,4 +1,4 @@ -import { ConsoleLogger, Injectable, Scope } from '@nestjs/common'; +import { ConsoleLogger, Inject, Injectable, Scope } from '@nestjs/common'; import { isLogLevelEnabled } from '@nestjs/common/services/utils/is-log-level-enabled.util'; import { ClsService } from 'nestjs-cls'; import { Telemetry } from 'src/decorators'; @@ -26,7 +26,7 @@ export class MyConsoleLogger extends ConsoleLogger { private isColorEnabled: boolean; constructor( - private cls: ClsService, + private cls: ClsService | undefined, options?: { color?: boolean; context?: string }, ) { super(options?.context || MyConsoleLogger.name); @@ -74,7 +74,7 @@ export class MyConsoleLogger extends ConsoleLogger { export class LoggingRepository { private logger: MyConsoleLogger; - constructor(cls: ClsService, configRepository: ConfigRepository) { + constructor(@Inject(ClsService) cls: ClsService | undefined, configRepository: ConfigRepository) { const { noColor } = configRepository.getEnv(); this.logger = new MyConsoleLogger(cls, { context: LoggingRepository.name, color: !noColor }); } diff --git a/server/src/schema/enums.ts b/server/src/schema/enums.ts new file mode 100644 index 0000000000..100b92aa63 --- /dev/null +++ b/server/src/schema/enums.ts @@ -0,0 +1,12 @@ +import { AssetStatus, SourceType } from 'src/enum'; +import { registerEnum } from 'src/sql-tools'; + +export const assets_status_enum = registerEnum({ + name: 'assets_status_enum', + values: Object.values(AssetStatus), +}); + +export const asset_face_source_type = registerEnum({ + name: 'sourcetype', + values: Object.values(SourceType), +}); diff --git a/server/src/schema/functions.ts b/server/src/schema/functions.ts new file mode 100644 index 0000000000..65ad2b72dc --- /dev/null +++ b/server/src/schema/functions.ts @@ -0,0 +1,116 @@ +import { registerFunction } from 'src/sql-tools'; + +export const immich_uuid_v7 = registerFunction({ + name: 'immich_uuid_v7', + arguments: ['p_timestamp timestamp with time zone default clock_timestamp()'], + returnType: 'uuid', + language: 'SQL', + behavior: 'volatile', + body: ` + SELECT encode( + set_bit( + set_bit( + overlay(uuid_send(gen_random_uuid()) + placing substring(int8send(floor(extract(epoch from p_timestamp) * 1000)::bigint) from 3) + from 1 for 6 + ), + 52, 1 + ), + 53, 1 + ), + 'hex')::uuid; +`, + synchronize: false, +}); + +export const updated_at = registerFunction({ + name: 'updated_at', + returnType: 'TRIGGER', + language: 'PLPGSQL', + body: ` + DECLARE + clock_timestamp TIMESTAMP := clock_timestamp(); + BEGIN + new."updatedAt" = clock_timestamp; + new."updateId" = immich_uuid_v7(clock_timestamp); + return new; + END;`, + synchronize: false, +}); + +export const f_concat_ws = registerFunction({ + name: 'f_concat_ws', + arguments: ['text', 'text[]'], + returnType: 'text', + language: 'SQL', + parallel: 'safe', + behavior: 'immutable', + body: `SELECT array_to_string($2, $1)`, + synchronize: false, +}); + +export const f_unaccent = registerFunction({ + name: 'f_unaccent', + arguments: ['text'], + returnType: 'text', + language: 'SQL', + parallel: 'safe', + strict: true, + behavior: 'immutable', + return: `unaccent('unaccent', $1)`, + synchronize: false, +}); + +export const ll_to_earth_public = registerFunction({ + name: 'll_to_earth_public', + arguments: ['latitude double precision', 'longitude double precision'], + returnType: 'public.earth', + language: 'SQL', + parallel: 'safe', + strict: true, + behavior: 'immutable', + body: `SELECT public.cube(public.cube(public.cube(public.earth()*cos(radians(latitude))*cos(radians(longitude))),public.earth()*cos(radians(latitude))*sin(radians(longitude))),public.earth()*sin(radians(latitude)))::public.earth`, + synchronize: false, +}); + +export const users_delete_audit = registerFunction({ + name: 'users_delete_audit', + returnType: 'TRIGGER', + language: 'PLPGSQL', + body: ` + BEGIN + INSERT INTO users_audit ("userId") + SELECT "id" + FROM OLD; + RETURN NULL; + END`, + synchronize: false, +}); + +export const partners_delete_audit = registerFunction({ + name: 'partners_delete_audit', + returnType: 'TRIGGER', + language: 'PLPGSQL', + body: ` + BEGIN + INSERT INTO partners_audit ("sharedById", "sharedWithId") + SELECT "sharedById", "sharedWithId" + FROM OLD; + RETURN NULL; + END`, + synchronize: false, +}); + +export const assets_delete_audit = registerFunction({ + name: 'assets_delete_audit', + returnType: 'TRIGGER', + language: 'PLPGSQL', + body: ` + BEGIN + INSERT INTO assets_audit ("assetId", "ownerId") + SELECT "id", "ownerId" + FROM OLD; + RETURN NULL; + END`, + synchronize: false, +}); diff --git a/server/src/schema/index.ts b/server/src/schema/index.ts new file mode 100644 index 0000000000..fe4b86d65c --- /dev/null +++ b/server/src/schema/index.ts @@ -0,0 +1,109 @@ +import { asset_face_source_type, assets_status_enum } from 'src/schema/enums'; +import { + assets_delete_audit, + f_concat_ws, + f_unaccent, + immich_uuid_v7, + ll_to_earth_public, + partners_delete_audit, + updated_at, + users_delete_audit, +} from 'src/schema/functions'; +import { ActivityTable } from 'src/schema/tables/activity.table'; +import { AlbumAssetTable } from 'src/schema/tables/album-asset.table'; +import { AlbumUserTable } from 'src/schema/tables/album-user.table'; +import { AlbumTable } from 'src/schema/tables/album.table'; +import { APIKeyTable } from 'src/schema/tables/api-key.table'; +import { AssetAuditTable } from 'src/schema/tables/asset-audit.table'; +import { AssetFaceTable } from 'src/schema/tables/asset-face.table'; +import { AssetFileTable } from 'src/schema/tables/asset-files.table'; +import { AssetJobStatusTable } from 'src/schema/tables/asset-job-status.table'; +import { AssetTable } from 'src/schema/tables/asset.table'; +import { AuditTable } from 'src/schema/tables/audit.table'; +import { ExifTable } from 'src/schema/tables/exif.table'; +import { FaceSearchTable } from 'src/schema/tables/face-search.table'; +import { GeodataPlacesTable } from 'src/schema/tables/geodata-places.table'; +import { LibraryTable } from 'src/schema/tables/library.table'; +import { MemoryTable } from 'src/schema/tables/memory.table'; +import { MemoryAssetTable } from 'src/schema/tables/memory_asset.table'; +import { MoveTable } from 'src/schema/tables/move.table'; +import { NaturalEarthCountriesTable } from 'src/schema/tables/natural-earth-countries.table'; +import { PartnerAuditTable } from 'src/schema/tables/partner-audit.table'; +import { PartnerTable } from 'src/schema/tables/partner.table'; +import { PersonTable } from 'src/schema/tables/person.table'; +import { SessionTable } from 'src/schema/tables/session.table'; +import { SharedLinkAssetTable } from 'src/schema/tables/shared-link-asset.table'; +import { SharedLinkTable } from 'src/schema/tables/shared-link.table'; +import { SmartSearchTable } from 'src/schema/tables/smart-search.table'; +import { StackTable } from 'src/schema/tables/stack.table'; +import { SessionSyncCheckpointTable } from 'src/schema/tables/sync-checkpoint.table'; +import { SystemMetadataTable } from 'src/schema/tables/system-metadata.table'; +import { TagAssetTable } from 'src/schema/tables/tag-asset.table'; +import { TagClosureTable } from 'src/schema/tables/tag-closure.table'; +import { UserAuditTable } from 'src/schema/tables/user-audit.table'; +import { UserMetadataTable } from 'src/schema/tables/user-metadata.table'; +import { UserTable } from 'src/schema/tables/user.table'; +import { VersionHistoryTable } from 'src/schema/tables/version-history.table'; +import { ConfigurationParameter, Database, Extensions } from 'src/sql-tools'; + +@Extensions(['uuid-ossp', 'unaccent', 'cube', 'earthdistance', 'pg_trgm', 'vectors', 'plpgsql']) +@ConfigurationParameter({ name: 'search_path', value: () => '"$user", public, vectors', scope: 'database' }) +@ConfigurationParameter({ + name: 'vectors.pgvector_compatibility', + value: () => 'on', + scope: 'user', + synchronize: false, +}) +@Database({ name: 'immich' }) +export class ImmichDatabase { + tables = [ + ActivityTable, + AlbumAssetTable, + AlbumUserTable, + AlbumTable, + APIKeyTable, + AssetAuditTable, + AssetFaceTable, + AssetJobStatusTable, + AssetTable, + AssetFileTable, + AuditTable, + ExifTable, + FaceSearchTable, + GeodataPlacesTable, + LibraryTable, + MemoryAssetTable, + MemoryTable, + MoveTable, + NaturalEarthCountriesTable, + PartnerAuditTable, + PartnerTable, + PersonTable, + SessionTable, + SharedLinkAssetTable, + SharedLinkTable, + SmartSearchTable, + StackTable, + SessionSyncCheckpointTable, + SystemMetadataTable, + TagAssetTable, + TagClosureTable, + UserAuditTable, + UserMetadataTable, + UserTable, + VersionHistoryTable, + ]; + + functions = [ + immich_uuid_v7, + updated_at, + f_concat_ws, + f_unaccent, + ll_to_earth_public, + users_delete_audit, + partners_delete_audit, + assets_delete_audit, + ]; + + enum = [assets_status_enum, asset_face_source_type]; +} diff --git a/server/src/schema/tables/activity.table.ts b/server/src/schema/tables/activity.table.ts index 87597838c7..e7a144722c 100644 --- a/server/src/schema/tables/activity.table.ts +++ b/server/src/schema/tables/activity.table.ts @@ -1,3 +1,4 @@ +import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators'; import { AlbumTable } from 'src/schema/tables/album.table'; import { AssetTable } from 'src/schema/tables/asset.table'; import { UserTable } from 'src/schema/tables/user.table'; @@ -11,10 +12,10 @@ import { PrimaryGeneratedColumn, Table, UpdateDateColumn, - UpdateIdColumn, } from 'src/sql-tools'; @Table('activity') +@UpdatedAtTrigger('activity_updated_at') @Index({ name: 'IDX_activity_like', columns: ['assetId', 'userId', 'albumId'], @@ -35,9 +36,14 @@ export class ActivityTable { @UpdateDateColumn() updatedAt!: Date; - @ColumnIndex('IDX_activity_update_id') - @UpdateIdColumn() - updateId!: string; + @ForeignKeyColumn(() => AlbumTable, { onDelete: 'CASCADE', onUpdate: 'CASCADE' }) + albumId!: string; + + @ForeignKeyColumn(() => UserTable, { onDelete: 'CASCADE', onUpdate: 'CASCADE' }) + userId!: string; + + @ForeignKeyColumn(() => AssetTable, { onDelete: 'CASCADE', onUpdate: 'CASCADE', nullable: true }) + assetId!: string | null; @Column({ type: 'text', default: null }) comment!: string | null; @@ -45,12 +51,7 @@ export class ActivityTable { @Column({ type: 'boolean', default: false }) isLiked!: boolean; - @ForeignKeyColumn(() => AssetTable, { onDelete: 'CASCADE', onUpdate: 'CASCADE', nullable: true }) - assetId!: string | null; - - @ForeignKeyColumn(() => UserTable, { onDelete: 'CASCADE', onUpdate: 'CASCADE' }) - userId!: string; - - @ForeignKeyColumn(() => AlbumTable, { onDelete: 'CASCADE', onUpdate: 'CASCADE' }) - albumId!: string; + @ColumnIndex('IDX_activity_update_id') + @UpdateIdColumn() + updateId!: string; } diff --git a/server/src/schema/tables/album-asset.table.ts b/server/src/schema/tables/album-asset.table.ts index ccd7fda5fd..1b931e3116 100644 --- a/server/src/schema/tables/album-asset.table.ts +++ b/server/src/schema/tables/album-asset.table.ts @@ -4,15 +4,6 @@ import { ColumnIndex, CreateDateColumn, ForeignKeyColumn, Table } from 'src/sql- @Table({ name: 'albums_assets_assets', primaryConstraintName: 'PK_c67bc36fa845fb7b18e0e398180' }) export class AlbumAssetTable { - @ForeignKeyColumn(() => AssetTable, { - onDelete: 'CASCADE', - onUpdate: 'CASCADE', - nullable: false, - primary: true, - }) - @ColumnIndex() - assetsId!: string; - @ForeignKeyColumn(() => AlbumTable, { onDelete: 'CASCADE', onUpdate: 'CASCADE', @@ -22,6 +13,15 @@ export class AlbumAssetTable { @ColumnIndex() albumsId!: string; + @ForeignKeyColumn(() => AssetTable, { + onDelete: 'CASCADE', + onUpdate: 'CASCADE', + nullable: false, + primary: true, + }) + @ColumnIndex() + assetsId!: string; + @CreateDateColumn() createdAt!: Date; } diff --git a/server/src/schema/tables/album.table.ts b/server/src/schema/tables/album.table.ts index cf2f2e1cb4..cdfd092b1b 100644 --- a/server/src/schema/tables/album.table.ts +++ b/server/src/schema/tables/album.table.ts @@ -1,3 +1,4 @@ +import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators'; import { AssetOrder } from 'src/enum'; import { AssetTable } from 'src/schema/tables/asset.table'; import { UserTable } from 'src/schema/tables/user.table'; @@ -10,10 +11,10 @@ import { PrimaryGeneratedColumn, Table, UpdateDateColumn, - UpdateIdColumn, } from 'src/sql-tools'; @Table({ name: 'albums', primaryConstraintName: 'PK_7f71c7b5bc7c87b8f94c9a93a00' }) +@UpdatedAtTrigger('albums_updated_at') export class AlbumTable { @PrimaryGeneratedColumn() id!: string; @@ -24,28 +25,33 @@ export class AlbumTable { @Column({ default: 'Untitled Album' }) albumName!: string; - @Column({ type: 'text', default: '' }) - description!: string; - @CreateDateColumn() createdAt!: Date; + @ForeignKeyColumn(() => AssetTable, { + nullable: true, + onDelete: 'SET NULL', + onUpdate: 'CASCADE', + comment: 'Asset ID to be used as thumbnail', + }) + albumThumbnailAssetId!: string; + @UpdateDateColumn() updatedAt!: Date; - @ColumnIndex('IDX_albums_update_id') - @UpdateIdColumn() - updateId?: string; + @Column({ type: 'text', default: '' }) + description!: string; @DeleteDateColumn() deletedAt!: Date | null; - @ForeignKeyColumn(() => AssetTable, { nullable: true, onDelete: 'SET NULL', onUpdate: 'CASCADE' }) - albumThumbnailAssetId!: string; - @Column({ type: 'boolean', default: true }) isActivityEnabled!: boolean; @Column({ default: AssetOrder.DESC }) order!: AssetOrder; + + @ColumnIndex('IDX_albums_update_id') + @UpdateIdColumn() + updateId?: string; } diff --git a/server/src/schema/tables/api-key.table.ts b/server/src/schema/tables/api-key.table.ts index 42b98ab957..29c4ad2b0f 100644 --- a/server/src/schema/tables/api-key.table.ts +++ b/server/src/schema/tables/api-key.table.ts @@ -1,3 +1,4 @@ +import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators'; import { Permission } from 'src/enum'; import { UserTable } from 'src/schema/tables/user.table'; import { @@ -8,22 +9,19 @@ import { PrimaryGeneratedColumn, Table, UpdateDateColumn, - UpdateIdColumn, } from 'src/sql-tools'; @Table('api_keys') +@UpdatedAtTrigger('api_keys_updated_at') export class APIKeyTable { - @PrimaryGeneratedColumn() - id!: string; - @Column() name!: string; @Column() key!: string; - @Column({ array: true, type: 'character varying' }) - permissions!: Permission[]; + @ForeignKeyColumn(() => UserTable, { onUpdate: 'CASCADE', onDelete: 'CASCADE' }) + userId!: string; @CreateDateColumn() createdAt!: Date; @@ -31,10 +29,13 @@ export class APIKeyTable { @UpdateDateColumn() updatedAt!: Date; + @PrimaryGeneratedColumn() + id!: string; + + @Column({ array: true, type: 'character varying' }) + permissions!: Permission[]; + @ColumnIndex({ name: 'IDX_api_keys_update_id' }) @UpdateIdColumn() updateId?: string; - - @ForeignKeyColumn(() => UserTable, { onUpdate: 'CASCADE', onDelete: 'CASCADE' }) - userId!: string; } diff --git a/server/src/schema/tables/asset-audit.table.ts b/server/src/schema/tables/asset-audit.table.ts index 10f7b535bc..55d6f5c911 100644 --- a/server/src/schema/tables/asset-audit.table.ts +++ b/server/src/schema/tables/asset-audit.table.ts @@ -1,8 +1,9 @@ -import { Column, ColumnIndex, CreateDateColumn, PrimaryGeneratedColumn, Table } from 'src/sql-tools'; +import { PrimaryGeneratedUuidV7Column } from 'src/decorators'; +import { Column, ColumnIndex, CreateDateColumn, Table } from 'src/sql-tools'; @Table('assets_audit') export class AssetAuditTable { - @PrimaryGeneratedColumn({ type: 'v7' }) + @PrimaryGeneratedUuidV7Column() id!: string; @ColumnIndex('IDX_assets_audit_asset_id') diff --git a/server/src/schema/tables/asset-face.table.ts b/server/src/schema/tables/asset-face.table.ts index 56f22cf9a7..0ae99f44bf 100644 --- a/server/src/schema/tables/asset-face.table.ts +++ b/server/src/schema/tables/asset-face.table.ts @@ -1,4 +1,5 @@ import { SourceType } from 'src/enum'; +import { asset_face_source_type } from 'src/schema/enums'; import { AssetTable } from 'src/schema/tables/asset.table'; import { PersonTable } from 'src/schema/tables/person.table'; import { Column, DeleteDateColumn, ForeignKeyColumn, Index, PrimaryGeneratedColumn, Table } from 'src/sql-tools'; @@ -7,8 +8,11 @@ import { Column, DeleteDateColumn, ForeignKeyColumn, Index, PrimaryGeneratedColu @Index({ name: 'IDX_asset_faces_assetId_personId', columns: ['assetId', 'personId'] }) @Index({ columns: ['personId', 'assetId'] }) export class AssetFaceTable { - @PrimaryGeneratedColumn() - id!: string; + @ForeignKeyColumn(() => AssetTable, { onDelete: 'CASCADE', onUpdate: 'CASCADE' }) + assetId!: string; + + @ForeignKeyColumn(() => PersonTable, { onDelete: 'SET NULL', onUpdate: 'CASCADE', nullable: true }) + personId!: string | null; @Column({ default: 0, type: 'integer' }) imageWidth!: number; @@ -28,15 +32,12 @@ export class AssetFaceTable { @Column({ default: 0, type: 'integer' }) boundingBoxY2!: number; - @Column({ default: SourceType.MACHINE_LEARNING, enumName: 'sourcetype', enum: SourceType }) + @PrimaryGeneratedColumn() + id!: string; + + @Column({ default: SourceType.MACHINE_LEARNING, enum: asset_face_source_type }) sourceType!: SourceType; - @ForeignKeyColumn(() => AssetTable, { onDelete: 'CASCADE', onUpdate: 'CASCADE' }) - assetId!: string; - - @ForeignKeyColumn(() => PersonTable, { onDelete: 'SET NULL', onUpdate: 'CASCADE', nullable: true }) - personId!: string | null; - @DeleteDateColumn() deletedAt!: Date | null; } diff --git a/server/src/schema/tables/asset-files.table.ts b/server/src/schema/tables/asset-files.table.ts index fb32070751..fb8750a8ef 100644 --- a/server/src/schema/tables/asset-files.table.ts +++ b/server/src/schema/tables/asset-files.table.ts @@ -1,5 +1,6 @@ -import { AssetEntity } from 'src/entities/asset.entity'; +import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators'; import { AssetFileType } from 'src/enum'; +import { AssetTable } from 'src/schema/tables/asset.table'; import { Column, ColumnIndex, @@ -9,18 +10,18 @@ import { Table, Unique, UpdateDateColumn, - UpdateIdColumn, } from 'src/sql-tools'; -@Unique({ name: 'UQ_assetId_type', columns: ['assetId', 'type'] }) @Table('asset_files') +@Unique({ name: 'UQ_assetId_type', columns: ['assetId', 'type'] }) +@UpdatedAtTrigger('asset_files_updated_at') export class AssetFileTable { @PrimaryGeneratedColumn() id!: string; @ColumnIndex('IDX_asset_files_assetId') - @ForeignKeyColumn(() => AssetEntity, { onDelete: 'CASCADE', onUpdate: 'CASCADE' }) - assetId?: AssetEntity; + @ForeignKeyColumn(() => AssetTable, { onDelete: 'CASCADE', onUpdate: 'CASCADE' }) + assetId?: string; @CreateDateColumn() createdAt!: Date; @@ -28,13 +29,13 @@ export class AssetFileTable { @UpdateDateColumn() updatedAt!: Date; - @ColumnIndex('IDX_asset_files_update_id') - @UpdateIdColumn() - updateId?: string; - @Column() type!: AssetFileType; @Column() path!: string; + + @ColumnIndex('IDX_asset_files_update_id') + @UpdateIdColumn() + updateId?: string; } diff --git a/server/src/schema/tables/asset.table.ts b/server/src/schema/tables/asset.table.ts index bd79d48149..0fcbf4f9b1 100644 --- a/server/src/schema/tables/asset.table.ts +++ b/server/src/schema/tables/asset.table.ts @@ -1,9 +1,13 @@ +import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators'; import { ASSET_CHECKSUM_CONSTRAINT } from 'src/entities/asset.entity'; import { AssetStatus, AssetType } from 'src/enum'; +import { assets_status_enum } from 'src/schema/enums'; +import { assets_delete_audit } from 'src/schema/functions'; import { LibraryTable } from 'src/schema/tables/library.table'; import { StackTable } from 'src/schema/tables/stack.table'; import { UserTable } from 'src/schema/tables/user.table'; import { + AfterDeleteTrigger, Column, ColumnIndex, CreateDateColumn, @@ -13,10 +17,17 @@ import { PrimaryGeneratedColumn, Table, UpdateDateColumn, - UpdateIdColumn, } from 'src/sql-tools'; @Table('assets') +@UpdatedAtTrigger('assets_updated_at') +@AfterDeleteTrigger({ + name: 'assets_delete_audit', + scope: 'statement', + function: assets_delete_audit, + referencingOldTableAs: 'old', + when: 'pg_trigger_depth() = 0', +}) // Checksums must be unique per user and library @Index({ name: ASSET_CHECKSUM_CONSTRAINT, @@ -30,7 +41,11 @@ import { unique: true, where: '("libraryId" IS NOT NULL)', }) -@Index({ name: 'idx_local_date_time', expression: `(("localDateTime" AT TIME ZONE 'UTC'::text))::date` }) +@Index({ + name: 'idx_local_date_time', + expression: `(("localDateTime" at time zone 'UTC')::date)`, + synchronize: false, +}) @Index({ name: 'idx_local_date_time_month', expression: `(date_trunc('MONTH'::text, ("localDateTime" AT TIME ZONE 'UTC'::text)) AT TIME ZONE 'UTC'::text)`, @@ -38,9 +53,10 @@ import { @Index({ name: 'IDX_originalPath_libraryId', columns: ['originalPath', 'libraryId'] }) @Index({ name: 'IDX_asset_id_stackId', columns: ['id', 'stackId'] }) @Index({ - name: 'idx_originalFileName_trigram', + name: 'idx_originalfilename_trigram', using: 'gin', - expression: 'f_unaccent(("originalFileName")::text)', + expression: 'f_unaccent("originalFileName") gin_trgm_ops', + synchronize: false, }) // For all assets, each originalpath must be unique per user and library export class AssetTable { @@ -53,75 +69,50 @@ export class AssetTable { @ForeignKeyColumn(() => UserTable, { onDelete: 'CASCADE', onUpdate: 'CASCADE', nullable: false }) ownerId!: string; - @ForeignKeyColumn(() => LibraryTable, { onDelete: 'CASCADE', onUpdate: 'CASCADE', nullable: true }) - libraryId?: string | null; - @Column() deviceId!: string; @Column() type!: AssetType; - @Column({ type: 'enum', enum: AssetStatus, default: AssetStatus.ACTIVE }) - status!: AssetStatus; - @Column() originalPath!: string; - @Column({ type: 'bytea', nullable: true }) - thumbhash!: Buffer | null; - - @Column({ type: 'character varying', nullable: true, default: '' }) - encodedVideoPath!: string | null; - - @CreateDateColumn() - createdAt!: Date; - - @UpdateDateColumn() - updatedAt!: Date; - - @ColumnIndex('IDX_assets_update_id') - @UpdateIdColumn() - updateId?: string; - - @DeleteDateColumn() - deletedAt!: Date | null; - @ColumnIndex('idx_asset_file_created_at') @Column({ type: 'timestamp with time zone', default: null }) fileCreatedAt!: Date; - @Column({ type: 'timestamp with time zone', default: null }) - localDateTime!: Date; - @Column({ type: 'timestamp with time zone', default: null }) fileModifiedAt!: Date; @Column({ type: 'boolean', default: false }) isFavorite!: boolean; - @Column({ type: 'boolean', default: false }) - isArchived!: boolean; + @Column({ type: 'character varying', nullable: true }) + duration!: string | null; - @Column({ type: 'boolean', default: false }) - isExternal!: boolean; - - @Column({ type: 'boolean', default: false }) - isOffline!: boolean; + @Column({ type: 'character varying', nullable: true, default: '' }) + encodedVideoPath!: string | null; @Column({ type: 'bytea' }) @ColumnIndex() checksum!: Buffer; // sha1 checksum - @Column({ type: 'character varying', nullable: true }) - duration!: string | null; - @Column({ type: 'boolean', default: true }) isVisible!: boolean; @ForeignKeyColumn(() => AssetTable, { nullable: true, onUpdate: 'CASCADE', onDelete: 'SET NULL' }) livePhotoVideoId!: string | null; + @UpdateDateColumn() + updatedAt!: Date; + + @CreateDateColumn() + createdAt!: Date; + + @Column({ type: 'boolean', default: false }) + isArchived!: boolean; + @Column() @ColumnIndex() originalFileName!: string; @@ -129,10 +120,35 @@ export class AssetTable { @Column({ nullable: true }) sidecarPath!: string | null; + @Column({ type: 'bytea', nullable: true }) + thumbhash!: Buffer | null; + + @Column({ type: 'boolean', default: false }) + isOffline!: boolean; + + @ForeignKeyColumn(() => LibraryTable, { onDelete: 'CASCADE', onUpdate: 'CASCADE', nullable: true }) + libraryId?: string | null; + + @Column({ type: 'boolean', default: false }) + isExternal!: boolean; + + @DeleteDateColumn() + deletedAt!: Date | null; + + @Column({ type: 'timestamp with time zone', default: null }) + localDateTime!: Date; + @ForeignKeyColumn(() => StackTable, { nullable: true, onDelete: 'SET NULL', onUpdate: 'CASCADE' }) stackId?: string | null; @ColumnIndex('IDX_assets_duplicateId') @Column({ type: 'uuid', nullable: true }) duplicateId!: string | null; + + @Column({ enum: assets_status_enum, default: AssetStatus.ACTIVE }) + status!: AssetStatus; + + @ColumnIndex('IDX_assets_update_id') + @UpdateIdColumn() + updateId?: string; } diff --git a/server/src/schema/tables/audit.table.ts b/server/src/schema/tables/audit.table.ts index a05b070ba7..9674bbf308 100644 --- a/server/src/schema/tables/audit.table.ts +++ b/server/src/schema/tables/audit.table.ts @@ -4,7 +4,7 @@ import { Column, CreateDateColumn, Index, PrimaryColumn, Table } from 'src/sql-t @Table('audit') @Index({ name: 'IDX_ownerId_createdAt', columns: ['ownerId', 'createdAt'] }) export class AuditTable { - @PrimaryColumn({ type: 'integer', default: 'increment', synchronize: false }) + @PrimaryColumn({ type: 'serial', synchronize: false }) id!: number; @Column() diff --git a/server/src/schema/tables/exif.table.ts b/server/src/schema/tables/exif.table.ts index 8eddafecc2..e40ce94b4f 100644 --- a/server/src/schema/tables/exif.table.ts +++ b/server/src/schema/tables/exif.table.ts @@ -1,21 +1,18 @@ +import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators'; import { AssetTable } from 'src/schema/tables/asset.table'; -import { Column, ColumnIndex, ForeignKeyColumn, Table, UpdateDateColumn, UpdateIdColumn } from 'src/sql-tools'; +import { Column, ColumnIndex, ForeignKeyColumn, Table, UpdateDateColumn } from 'src/sql-tools'; @Table('exif') +@UpdatedAtTrigger('asset_exif_updated_at') export class ExifTable { @ForeignKeyColumn(() => AssetTable, { onDelete: 'CASCADE', primary: true }) assetId!: string; - @UpdateDateColumn({ default: () => 'clock_timestamp()' }) - updatedAt?: Date; + @Column({ type: 'character varying', nullable: true }) + make!: string | null; - @ColumnIndex('IDX_asset_exif_update_id') - @UpdateIdColumn() - updateId?: string; - - /* General info */ - @Column({ type: 'text', default: '' }) - description!: string; // or caption + @Column({ type: 'character varying', nullable: true }) + model!: string | null; @Column({ type: 'integer', nullable: true }) exifImageWidth!: number | null; @@ -35,43 +32,6 @@ export class ExifTable { @Column({ type: 'timestamp with time zone', nullable: true }) modifyDate!: Date | null; - @Column({ type: 'character varying', nullable: true }) - timeZone!: string | null; - - @Column({ type: 'double precision', nullable: true }) - latitude!: number | null; - - @Column({ type: 'double precision', nullable: true }) - longitude!: number | null; - - @Column({ type: 'character varying', nullable: true }) - projectionType!: string | null; - - @ColumnIndex('exif_city') - @Column({ type: 'character varying', nullable: true }) - city!: string | null; - - @ColumnIndex('IDX_live_photo_cid') - @Column({ type: 'character varying', nullable: true }) - livePhotoCID!: string | null; - - @ColumnIndex('IDX_auto_stack_id') - @Column({ type: 'character varying', nullable: true }) - autoStackId!: string | null; - - @Column({ type: 'character varying', nullable: true }) - state!: string | null; - - @Column({ type: 'character varying', nullable: true }) - country!: string | null; - - /* Image info */ - @Column({ type: 'character varying', nullable: true }) - make!: string | null; - - @Column({ type: 'character varying', nullable: true }) - model!: string | null; - @Column({ type: 'character varying', nullable: true }) lensModel!: string | null; @@ -84,9 +44,41 @@ export class ExifTable { @Column({ type: 'integer', nullable: true }) iso!: number | null; + @Column({ type: 'double precision', nullable: true }) + latitude!: number | null; + + @Column({ type: 'double precision', nullable: true }) + longitude!: number | null; + + @ColumnIndex('exif_city') + @Column({ type: 'character varying', nullable: true }) + city!: string | null; + + @Column({ type: 'character varying', nullable: true }) + state!: string | null; + + @Column({ type: 'character varying', nullable: true }) + country!: string | null; + + @Column({ type: 'text', default: '' }) + description!: string; // or caption + + @Column({ type: 'double precision', nullable: true }) + fps?: number | null; + @Column({ type: 'character varying', nullable: true }) exposureTime!: string | null; + @ColumnIndex('IDX_live_photo_cid') + @Column({ type: 'character varying', nullable: true }) + livePhotoCID!: string | null; + + @Column({ type: 'character varying', nullable: true }) + timeZone!: string | null; + + @Column({ type: 'character varying', nullable: true }) + projectionType!: string | null; + @Column({ type: 'character varying', nullable: true }) profileDescription!: string | null; @@ -96,10 +88,17 @@ export class ExifTable { @Column({ type: 'integer', nullable: true }) bitsPerSample!: number | null; + @ColumnIndex('IDX_auto_stack_id') + @Column({ type: 'character varying', nullable: true }) + autoStackId!: string | null; + @Column({ type: 'integer', nullable: true }) rating!: number | null; - /* Video info */ - @Column({ type: 'double precision', nullable: true }) - fps?: number | null; + @UpdateDateColumn({ default: () => 'clock_timestamp()' }) + updatedAt?: Date; + + @ColumnIndex('IDX_asset_exif_update_id') + @UpdateIdColumn() + updateId?: string; } diff --git a/server/src/schema/tables/face-search.table.ts b/server/src/schema/tables/face-search.table.ts index d4da6a69ba..5ac1357198 100644 --- a/server/src/schema/tables/face-search.table.ts +++ b/server/src/schema/tables/face-search.table.ts @@ -1,7 +1,14 @@ import { AssetFaceTable } from 'src/schema/tables/asset-face.table'; -import { Column, ColumnIndex, ForeignKeyColumn, Table } from 'src/sql-tools'; +import { Column, ForeignKeyColumn, Index, Table } from 'src/sql-tools'; @Table({ name: 'face_search', primaryConstraintName: 'face_search_pkey' }) +@Index({ + name: 'face_index', + using: 'hnsw', + expression: `embedding vector_cosine_ops`, + with: 'ef_construction = 300, m = 16', + synchronize: false, +}) export class FaceSearchTable { @ForeignKeyColumn(() => AssetFaceTable, { onDelete: 'CASCADE', @@ -10,7 +17,6 @@ export class FaceSearchTable { }) faceId!: string; - @ColumnIndex({ name: 'face_index', synchronize: false }) - @Column({ type: 'vector', array: true, length: 512, synchronize: false }) + @Column({ type: 'vector', length: 512, synchronize: false }) embedding!: string; } diff --git a/server/src/schema/tables/geodata-places.table.ts b/server/src/schema/tables/geodata-places.table.ts index 2ac4ab2780..631cfdff08 100644 --- a/server/src/schema/tables/geodata-places.table.ts +++ b/server/src/schema/tables/geodata-places.table.ts @@ -1,10 +1,35 @@ import { Column, Index, PrimaryColumn, Table } from 'src/sql-tools'; -@Index({ name: 'idx_geodata_places_alternate_names', expression: 'f_unaccent("alternateNames") gin_trgm_ops' }) -@Index({ name: 'idx_geodata_places_admin1_name', expression: 'f_unaccent("admin1Name") gin_trgm_ops' }) -@Index({ name: 'idx_geodata_places_admin2_name', expression: 'f_unaccent("admin2Name") gin_trgm_ops' }) -@Index({ name: 'idx_geodata_places_name', expression: 'f_unaccent("name") gin_trgm_ops' }) -@Index({ name: 'idx_geodata_places_gist_earthcoord', expression: 'll_to_earth_public(latitude, longitude)' }) +@Table({ name: 'geodata_places' }) +@Index({ + name: 'idx_geodata_places_alternate_names', + using: 'gin', + expression: 'f_unaccent("alternateNames") gin_trgm_ops', + synchronize: false, +}) +@Index({ + name: 'idx_geodata_places_admin1_name', + using: 'gin', + expression: 'f_unaccent("admin1Name") gin_trgm_ops', + synchronize: false, +}) +@Index({ + name: 'idx_geodata_places_admin2_name', + using: 'gin', + expression: 'f_unaccent("admin2Name") gin_trgm_ops', + synchronize: false, +}) +@Index({ + name: 'idx_geodata_places_name', + using: 'gin', + expression: 'f_unaccent("name") gin_trgm_ops', + synchronize: false, +}) +@Index({ + name: 'idx_geodata_places_gist_earthcoord', + expression: 'll_to_earth_public(latitude, longitude)', + synchronize: false, +}) @Table({ name: 'idx_geodata_places', synchronize: false }) export class GeodataPlacesTable { @PrimaryColumn({ type: 'integer' }) @@ -28,41 +53,8 @@ export class GeodataPlacesTable { @Column({ type: 'character varying', length: 80, nullable: true }) admin2Code!: string; - @Column({ type: 'character varying', nullable: true }) - admin1Name!: string; - - @Column({ type: 'character varying', nullable: true }) - admin2Name!: string; - - @Column({ type: 'character varying', nullable: true }) - alternateNames!: string; - @Column({ type: 'date' }) modificationDate!: Date; -} - -@Table({ name: 'geodata_places_tmp', synchronize: false }) -export class GeodataPlacesTempEntity { - @PrimaryColumn({ type: 'integer' }) - id!: number; - - @Column({ type: 'character varying', length: 200 }) - name!: string; - - @Column({ type: 'double precision' }) - longitude!: number; - - @Column({ type: 'double precision' }) - latitude!: number; - - @Column({ type: 'character', length: 2 }) - countryCode!: string; - - @Column({ type: 'character varying', length: 20, nullable: true }) - admin1Code!: string; - - @Column({ type: 'character varying', length: 80, nullable: true }) - admin2Code!: string; @Column({ type: 'character varying', nullable: true }) admin1Name!: string; @@ -72,7 +64,4 @@ export class GeodataPlacesTempEntity { @Column({ type: 'character varying', nullable: true }) alternateNames!: string; - - @Column({ type: 'date' }) - modificationDate!: Date; } diff --git a/server/src/schema/tables/index.ts b/server/src/schema/tables/index.ts index 6991d957ae..470f500bc2 100644 --- a/server/src/schema/tables/index.ts +++ b/server/src/schema/tables/index.ts @@ -1,73 +1,35 @@ -import { ActivityTable } from 'src/schema/tables/activity.table'; -import { AlbumAssetTable } from 'src/schema/tables/album-asset.table'; -import { AlbumUserTable } from 'src/schema/tables/album-user.table'; -import { AlbumTable } from 'src/schema/tables/album.table'; -import { APIKeyTable } from 'src/schema/tables/api-key.table'; -import { AssetAuditTable } from 'src/schema/tables/asset-audit.table'; -import { AssetFaceTable } from 'src/schema/tables/asset-face.table'; -import { AssetJobStatusTable } from 'src/schema/tables/asset-job-status.table'; -import { AssetTable } from 'src/schema/tables/asset.table'; -import { AuditTable } from 'src/schema/tables/audit.table'; -import { ExifTable } from 'src/schema/tables/exif.table'; -import { FaceSearchTable } from 'src/schema/tables/face-search.table'; -import { GeodataPlacesTable } from 'src/schema/tables/geodata-places.table'; -import { LibraryTable } from 'src/schema/tables/library.table'; -import { MemoryTable } from 'src/schema/tables/memory.table'; -import { MemoryAssetTable } from 'src/schema/tables/memory_asset.table'; -import { MoveTable } from 'src/schema/tables/move.table'; -import { - NaturalEarthCountriesTable, - NaturalEarthCountriesTempTable, -} from 'src/schema/tables/natural-earth-countries.table'; -import { PartnerAuditTable } from 'src/schema/tables/partner-audit.table'; -import { PartnerTable } from 'src/schema/tables/partner.table'; -import { PersonTable } from 'src/schema/tables/person.table'; -import { SessionTable } from 'src/schema/tables/session.table'; -import { SharedLinkAssetTable } from 'src/schema/tables/shared-link-asset.table'; -import { SharedLinkTable } from 'src/schema/tables/shared-link.table'; -import { SmartSearchTable } from 'src/schema/tables/smart-search.table'; -import { StackTable } from 'src/schema/tables/stack.table'; -import { SessionSyncCheckpointTable } from 'src/schema/tables/sync-checkpoint.table'; -import { SystemMetadataTable } from 'src/schema/tables/system-metadata.table'; -import { TagAssetTable } from 'src/schema/tables/tag-asset.table'; -import { UserAuditTable } from 'src/schema/tables/user-audit.table'; -import { UserMetadataTable } from 'src/schema/tables/user-metadata.table'; -import { UserTable } from 'src/schema/tables/user.table'; -import { VersionHistoryTable } from 'src/schema/tables/version-history.table'; - -export const tables = [ - ActivityTable, - AlbumAssetTable, - AlbumUserTable, - AlbumTable, - APIKeyTable, - AssetAuditTable, - AssetFaceTable, - AssetJobStatusTable, - AssetTable, - AuditTable, - ExifTable, - FaceSearchTable, - GeodataPlacesTable, - LibraryTable, - MemoryAssetTable, - MemoryTable, - MoveTable, - NaturalEarthCountriesTable, - NaturalEarthCountriesTempTable, - PartnerAuditTable, - PartnerTable, - PersonTable, - SessionTable, - SharedLinkAssetTable, - SharedLinkTable, - SmartSearchTable, - StackTable, - SessionSyncCheckpointTable, - SystemMetadataTable, - TagAssetTable, - UserAuditTable, - UserMetadataTable, - UserTable, - VersionHistoryTable, -]; +import 'src/schema/tables/activity.table'; +import 'src/schema/tables/album-asset.table'; +import 'src/schema/tables/album-user.table'; +import 'src/schema/tables/album.table'; +import 'src/schema/tables/api-key.table'; +import 'src/schema/tables/asset-audit.table'; +import 'src/schema/tables/asset-face.table'; +import 'src/schema/tables/asset-files.table'; +import 'src/schema/tables/asset-job-status.table'; +import 'src/schema/tables/asset.table'; +import 'src/schema/tables/audit.table'; +import 'src/schema/tables/exif.table'; +import 'src/schema/tables/face-search.table'; +import 'src/schema/tables/geodata-places.table'; +import 'src/schema/tables/library.table'; +import 'src/schema/tables/memory.table'; +import 'src/schema/tables/memory_asset.table'; +import 'src/schema/tables/move.table'; +import 'src/schema/tables/natural-earth-countries.table'; +import 'src/schema/tables/partner-audit.table'; +import 'src/schema/tables/partner.table'; +import 'src/schema/tables/person.table'; +import 'src/schema/tables/session.table'; +import 'src/schema/tables/shared-link-asset.table'; +import 'src/schema/tables/shared-link.table'; +import 'src/schema/tables/smart-search.table'; +import 'src/schema/tables/stack.table'; +import 'src/schema/tables/sync-checkpoint.table'; +import 'src/schema/tables/system-metadata.table'; +import 'src/schema/tables/tag-asset.table'; +import 'src/schema/tables/tag-closure.table'; +import 'src/schema/tables/user-audit.table'; +import 'src/schema/tables/user-metadata.table'; +import 'src/schema/tables/user.table'; +import 'src/schema/tables/version-history.table'; diff --git a/server/src/schema/tables/library.table.ts b/server/src/schema/tables/library.table.ts index ff0bfd64f7..54b3752f41 100644 --- a/server/src/schema/tables/library.table.ts +++ b/server/src/schema/tables/library.table.ts @@ -1,3 +1,4 @@ +import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators'; import { UserTable } from 'src/schema/tables/user.table'; import { Column, @@ -8,10 +9,10 @@ import { PrimaryGeneratedColumn, Table, UpdateDateColumn, - UpdateIdColumn, } from 'src/sql-tools'; @Table('libraries') +@UpdatedAtTrigger('libraries_updated_at') export class LibraryTable { @PrimaryGeneratedColumn() id!: string; @@ -34,13 +35,13 @@ export class LibraryTable { @UpdateDateColumn() updatedAt!: Date; - @ColumnIndex('IDX_libraries_update_id') - @UpdateIdColumn() - updateId?: string; - @DeleteDateColumn() deletedAt?: Date; @Column({ type: 'timestamp with time zone', nullable: true }) refreshedAt!: Date | null; + + @ColumnIndex('IDX_libraries_update_id') + @UpdateIdColumn() + updateId?: string; } diff --git a/server/src/schema/tables/memory.table.ts b/server/src/schema/tables/memory.table.ts index 91a0412649..1926405565 100644 --- a/server/src/schema/tables/memory.table.ts +++ b/server/src/schema/tables/memory.table.ts @@ -1,3 +1,4 @@ +import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators'; import { MemoryType } from 'src/enum'; import { UserTable } from 'src/schema/tables/user.table'; import { @@ -9,11 +10,11 @@ import { PrimaryGeneratedColumn, Table, UpdateDateColumn, - UpdateIdColumn, } from 'src/sql-tools'; import { MemoryData } from 'src/types'; @Table('memories') +@UpdatedAtTrigger('memories_updated_at') export class MemoryTable { @PrimaryGeneratedColumn() id!: string; @@ -24,10 +25,6 @@ export class MemoryTable { @UpdateDateColumn() updatedAt!: Date; - @ColumnIndex('IDX_memories_update_id') - @UpdateIdColumn() - updateId?: string; - @DeleteDateColumn() deletedAt?: Date; @@ -48,13 +45,17 @@ export class MemoryTable { @Column({ type: 'timestamp with time zone' }) memoryAt!: Date; + /** when the user last viewed the memory */ + @Column({ type: 'timestamp with time zone', nullable: true }) + seenAt?: Date; + @Column({ type: 'timestamp with time zone', nullable: true }) showAt?: Date; @Column({ type: 'timestamp with time zone', nullable: true }) hideAt?: Date; - /** when the user last viewed the memory */ - @Column({ type: 'timestamp with time zone', nullable: true }) - seenAt?: Date; + @ColumnIndex('IDX_memories_update_id') + @UpdateIdColumn() + updateId?: string; } diff --git a/server/src/schema/tables/memory_asset.table.ts b/server/src/schema/tables/memory_asset.table.ts index 08cdcea442..864e6291c7 100644 --- a/server/src/schema/tables/memory_asset.table.ts +++ b/server/src/schema/tables/memory_asset.table.ts @@ -4,11 +4,11 @@ import { ColumnIndex, ForeignKeyColumn, Table } from 'src/sql-tools'; @Table('memories_assets_assets') export class MemoryAssetTable { - @ColumnIndex() - @ForeignKeyColumn(() => AssetTable, { onUpdate: 'CASCADE', onDelete: 'CASCADE', primary: true }) - assetsId!: string; - @ColumnIndex() @ForeignKeyColumn(() => MemoryTable, { onUpdate: 'CASCADE', onDelete: 'CASCADE', primary: true }) memoriesId!: string; + + @ColumnIndex() + @ForeignKeyColumn(() => AssetTable, { onUpdate: 'CASCADE', onDelete: 'CASCADE', primary: true }) + assetsId!: string; } diff --git a/server/src/schema/tables/natural-earth-countries.table.ts b/server/src/schema/tables/natural-earth-countries.table.ts index 5ac5384afc..df1132d17d 100644 --- a/server/src/schema/tables/natural-earth-countries.table.ts +++ b/server/src/schema/tables/natural-earth-countries.table.ts @@ -1,26 +1,8 @@ -import { Column, PrimaryColumn, PrimaryGeneratedColumn, Table } from 'src/sql-tools'; +import { Column, PrimaryGeneratedColumn, Table } from 'src/sql-tools'; -@Table({ name: 'naturalearth_countries', synchronize: false }) +@Table({ name: 'naturalearth_countries' }) export class NaturalEarthCountriesTable { - @PrimaryColumn({ type: 'serial' }) - id!: number; - - @Column({ type: 'character varying', length: 50 }) - admin!: string; - - @Column({ type: 'character varying', length: 3 }) - admin_a3!: string; - - @Column({ type: 'character varying', length: 50 }) - type!: string; - - @Column({ type: 'polygon' }) - coordinates!: string; -} - -@Table({ name: 'naturalearth_countries_tmp', synchronize: false }) -export class NaturalEarthCountriesTempTable { - @PrimaryGeneratedColumn() + @PrimaryGeneratedColumn({ strategy: 'identity' }) id!: number; @Column({ type: 'character varying', length: 50 }) diff --git a/server/src/schema/tables/partner-audit.table.ts b/server/src/schema/tables/partner-audit.table.ts index 77d9f976b1..08b6e94626 100644 --- a/server/src/schema/tables/partner-audit.table.ts +++ b/server/src/schema/tables/partner-audit.table.ts @@ -1,8 +1,9 @@ -import { Column, ColumnIndex, CreateDateColumn, PrimaryGeneratedColumn, Table } from 'src/sql-tools'; +import { PrimaryGeneratedUuidV7Column } from 'src/decorators'; +import { Column, ColumnIndex, CreateDateColumn, Table } from 'src/sql-tools'; @Table('partners_audit') export class PartnerAuditTable { - @PrimaryGeneratedColumn({ type: 'v7' }) + @PrimaryGeneratedUuidV7Column() id!: string; @ColumnIndex('IDX_partners_audit_shared_by_id') diff --git a/server/src/schema/tables/partner.table.ts b/server/src/schema/tables/partner.table.ts index 6406b48277..770107fe7a 100644 --- a/server/src/schema/tables/partner.table.ts +++ b/server/src/schema/tables/partner.table.ts @@ -1,15 +1,25 @@ +import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators'; +import { partners_delete_audit } from 'src/schema/functions'; import { UserTable } from 'src/schema/tables/user.table'; import { + AfterDeleteTrigger, Column, ColumnIndex, CreateDateColumn, ForeignKeyColumn, Table, UpdateDateColumn, - UpdateIdColumn, } from 'src/sql-tools'; @Table('partners') +@UpdatedAtTrigger('partners_updated_at') +@AfterDeleteTrigger({ + name: 'partners_delete_audit', + scope: 'statement', + function: partners_delete_audit, + referencingOldTableAs: 'old', + when: 'pg_trigger_depth() = 0', +}) export class PartnerTable { @ForeignKeyColumn(() => UserTable, { onDelete: 'CASCADE', primary: true }) sharedById!: string; @@ -23,10 +33,10 @@ export class PartnerTable { @UpdateDateColumn() updatedAt!: Date; + @Column({ type: 'boolean', default: false }) + inTimeline!: boolean; + @ColumnIndex('IDX_partners_update_id') @UpdateIdColumn() updateId!: string; - - @Column({ type: 'boolean', default: false }) - inTimeline!: boolean; } diff --git a/server/src/schema/tables/person.table.ts b/server/src/schema/tables/person.table.ts index 91a05d8d76..b96fc5b709 100644 --- a/server/src/schema/tables/person.table.ts +++ b/server/src/schema/tables/person.table.ts @@ -1,3 +1,4 @@ +import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators'; import { AssetFaceTable } from 'src/schema/tables/asset-face.table'; import { UserTable } from 'src/schema/tables/user.table'; import { @@ -9,10 +10,10 @@ import { PrimaryGeneratedColumn, Table, UpdateDateColumn, - UpdateIdColumn, } from 'src/sql-tools'; @Table('person') +@UpdatedAtTrigger('person_updated_at') @Check({ name: 'CHK_b0f82b0ed662bfc24fbb58bb45', expression: `"birthDate" <= CURRENT_DATE` }) export class PersonTable { @PrimaryGeneratedColumn('uuid') @@ -24,31 +25,31 @@ export class PersonTable { @UpdateDateColumn() updatedAt!: Date; - @ColumnIndex('IDX_person_update_id') - @UpdateIdColumn() - updateId!: string; - @ForeignKeyColumn(() => UserTable, { onDelete: 'CASCADE', onUpdate: 'CASCADE', nullable: false }) ownerId!: string; @Column({ default: '' }) name!: string; - @Column({ type: 'date', nullable: true }) - birthDate!: Date | string | null; - @Column({ default: '' }) thumbnailPath!: string; - @ForeignKeyColumn(() => AssetFaceTable, { onDelete: 'SET NULL', nullable: true }) - faceAssetId!: string | null; - @Column({ type: 'boolean', default: false }) isHidden!: boolean; + @Column({ type: 'date', nullable: true }) + birthDate!: Date | string | null; + + @ForeignKeyColumn(() => AssetFaceTable, { onDelete: 'SET NULL', nullable: true }) + faceAssetId!: string | null; + @Column({ type: 'boolean', default: false }) isFavorite!: boolean; @Column({ type: 'character varying', nullable: true, default: null }) color?: string | null; + + @ColumnIndex('IDX_person_update_id') + @UpdateIdColumn() + updateId!: string; } diff --git a/server/src/schema/tables/session.table.ts b/server/src/schema/tables/session.table.ts index 287f13de7f..a66732a7d9 100644 --- a/server/src/schema/tables/session.table.ts +++ b/server/src/schema/tables/session.table.ts @@ -1,3 +1,4 @@ +import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators'; import { UserTable } from 'src/schema/tables/user.table'; import { Column, @@ -7,10 +8,10 @@ import { PrimaryGeneratedColumn, Table, UpdateDateColumn, - UpdateIdColumn, } from 'src/sql-tools'; @Table({ name: 'sessions', primaryConstraintName: 'PK_48cb6b5c20faa63157b3c1baf7f' }) +@UpdatedAtTrigger('sessions_updated_at') export class SessionTable { @PrimaryGeneratedColumn() id!: string; @@ -19,22 +20,22 @@ export class SessionTable { @Column() token!: string; - @ForeignKeyColumn(() => UserTable, { onUpdate: 'CASCADE', onDelete: 'CASCADE' }) - userId!: string; - @CreateDateColumn() createdAt!: Date; @UpdateDateColumn() updatedAt!: Date; - @ColumnIndex('IDX_sessions_update_id') - @UpdateIdColumn() - updateId!: string; + @ForeignKeyColumn(() => UserTable, { onUpdate: 'CASCADE', onDelete: 'CASCADE' }) + userId!: string; @Column({ default: '' }) deviceType!: string; @Column({ default: '' }) deviceOS!: string; + + @ColumnIndex('IDX_sessions_update_id') + @UpdateIdColumn() + updateId!: string; } diff --git a/server/src/schema/tables/shared-link.table.ts b/server/src/schema/tables/shared-link.table.ts index 4372a5760a..36237c58ef 100644 --- a/server/src/schema/tables/shared-link.table.ts +++ b/server/src/schema/tables/shared-link.table.ts @@ -20,16 +20,9 @@ export class SharedLinkTable { @Column({ type: 'character varying', nullable: true }) description!: string | null; - @Column({ type: 'character varying', nullable: true }) - password!: string | null; - @ForeignKeyColumn(() => UserTable, { onDelete: 'CASCADE', onUpdate: 'CASCADE' }) userId!: string; - @ColumnIndex('IDX_sharedlink_albumId') - @ForeignKeyColumn(() => AlbumTable, { nullable: true, onDelete: 'CASCADE', onUpdate: 'CASCADE' }) - albumId!: string; - @ColumnIndex('IDX_sharedlink_key') @Column({ type: 'bytea' }) key!: Buffer; // use to access the inidividual asset @@ -46,9 +39,16 @@ export class SharedLinkTable { @Column({ type: 'boolean', default: false }) allowUpload!: boolean; + @ColumnIndex('IDX_sharedlink_albumId') + @ForeignKeyColumn(() => AlbumTable, { nullable: true, onDelete: 'CASCADE', onUpdate: 'CASCADE' }) + albumId!: string; + @Column({ type: 'boolean', default: true }) allowDownload!: boolean; @Column({ type: 'boolean', default: true }) showExif!: boolean; + + @Column({ type: 'character varying', nullable: true }) + password!: string | null; } diff --git a/server/src/schema/tables/smart-search.table.ts b/server/src/schema/tables/smart-search.table.ts index a71eb9ae99..09362b9dda 100644 --- a/server/src/schema/tables/smart-search.table.ts +++ b/server/src/schema/tables/smart-search.table.ts @@ -1,7 +1,14 @@ import { AssetTable } from 'src/schema/tables/asset.table'; -import { Column, ColumnIndex, ForeignKeyColumn, Table } from 'src/sql-tools'; +import { Column, ForeignKeyColumn, Index, Table } from 'src/sql-tools'; @Table({ name: 'smart_search', primaryConstraintName: 'smart_search_pkey' }) +@Index({ + name: 'clip_index', + using: 'hnsw', + expression: `embedding vector_cosine_ops`, + with: `ef_construction = 300, m = 16`, + synchronize: false, +}) export class SmartSearchTable { @ForeignKeyColumn(() => AssetTable, { onDelete: 'CASCADE', @@ -10,7 +17,6 @@ export class SmartSearchTable { }) assetId!: string; - @ColumnIndex({ name: 'clip_index', synchronize: false }) - @Column({ type: 'vector', array: true, length: 512, synchronize: false }) + @Column({ type: 'vector', length: 512, storage: 'external', synchronize: false }) embedding!: string; } diff --git a/server/src/schema/tables/stack.table.ts b/server/src/schema/tables/stack.table.ts index ea58ccb425..222114d715 100644 --- a/server/src/schema/tables/stack.table.ts +++ b/server/src/schema/tables/stack.table.ts @@ -7,10 +7,10 @@ export class StackTable { @PrimaryGeneratedColumn() id!: string; - @ForeignKeyColumn(() => UserTable, { onDelete: 'CASCADE', onUpdate: 'CASCADE' }) - ownerId!: string; - //TODO: Add constraint to ensure primary asset exists in the assets array @ForeignKeyColumn(() => AssetTable, { nullable: false, unique: true }) primaryAssetId!: string; + + @ForeignKeyColumn(() => UserTable, { onDelete: 'CASCADE', onUpdate: 'CASCADE' }) + ownerId!: string; } diff --git a/server/src/schema/tables/sync-checkpoint.table.ts b/server/src/schema/tables/sync-checkpoint.table.ts index 190cd81ffe..831205ce7a 100644 --- a/server/src/schema/tables/sync-checkpoint.table.ts +++ b/server/src/schema/tables/sync-checkpoint.table.ts @@ -1,3 +1,4 @@ +import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators'; import { SyncEntityType } from 'src/enum'; import { SessionTable } from 'src/schema/tables/session.table'; import { @@ -8,10 +9,10 @@ import { PrimaryColumn, Table, UpdateDateColumn, - UpdateIdColumn, } from 'src/sql-tools'; @Table('session_sync_checkpoints') +@UpdatedAtTrigger('session_sync_checkpoints_updated_at') export class SessionSyncCheckpointTable { @ForeignKeyColumn(() => SessionTable, { onDelete: 'CASCADE', onUpdate: 'CASCADE', primary: true }) sessionId!: string; @@ -25,10 +26,10 @@ export class SessionSyncCheckpointTable { @UpdateDateColumn() updatedAt!: Date; + @Column() + ack!: string; + @ColumnIndex('IDX_session_sync_checkpoints_update_id') @UpdateIdColumn() updateId!: string; - - @Column() - ack!: string; } diff --git a/server/src/schema/tables/tag-closure.table.ts b/server/src/schema/tables/tag-closure.table.ts index 079dd4dcc5..acde84b91d 100644 --- a/server/src/schema/tables/tag-closure.table.ts +++ b/server/src/schema/tables/tag-closure.table.ts @@ -1,15 +1,13 @@ import { TagTable } from 'src/schema/tables/tag.table'; -import { ColumnIndex, ForeignKeyColumn, PrimaryColumn, Table } from 'src/sql-tools'; +import { ColumnIndex, ForeignKeyColumn, Table } from 'src/sql-tools'; @Table('tags_closure') export class TagClosureTable { - @PrimaryColumn() @ColumnIndex() - @ForeignKeyColumn(() => TagTable, { onDelete: 'CASCADE', onUpdate: 'NO ACTION' }) + @ForeignKeyColumn(() => TagTable, { primary: true, onDelete: 'CASCADE', onUpdate: 'NO ACTION' }) id_ancestor!: string; - @PrimaryColumn() @ColumnIndex() - @ForeignKeyColumn(() => TagTable, { onDelete: 'CASCADE', onUpdate: 'NO ACTION' }) + @ForeignKeyColumn(() => TagTable, { primary: true, onDelete: 'CASCADE', onUpdate: 'NO ACTION' }) id_descendant!: string; } diff --git a/server/src/schema/tables/tag.table.ts b/server/src/schema/tables/tag.table.ts index 1c6b8cb205..5042e2eb0e 100644 --- a/server/src/schema/tables/tag.table.ts +++ b/server/src/schema/tables/tag.table.ts @@ -1,3 +1,4 @@ +import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators'; import { UserTable } from 'src/schema/tables/user.table'; import { Column, @@ -8,15 +9,18 @@ import { Table, Unique, UpdateDateColumn, - UpdateIdColumn, } from 'src/sql-tools'; @Table('tags') +@UpdatedAtTrigger('tags_updated_at') @Unique({ columns: ['userId', 'value'] }) export class TagTable { @PrimaryGeneratedColumn() id!: string; + @ForeignKeyColumn(() => UserTable, { onUpdate: 'CASCADE', onDelete: 'CASCADE' }) + userId!: string; + @Column() value!: string; @@ -26,16 +30,13 @@ export class TagTable { @UpdateDateColumn() updatedAt!: Date; - @ColumnIndex('IDX_tags_update_id') - @UpdateIdColumn() - updateId!: string; - @Column({ type: 'character varying', nullable: true, default: null }) color!: string | null; @ForeignKeyColumn(() => TagTable, { nullable: true, onDelete: 'CASCADE' }) parentId?: string; - @ForeignKeyColumn(() => UserTable, { onUpdate: 'CASCADE', onDelete: 'CASCADE' }) - userId!: string; + @ColumnIndex('IDX_tags_update_id') + @UpdateIdColumn() + updateId!: string; } diff --git a/server/src/schema/tables/user-audit.table.ts b/server/src/schema/tables/user-audit.table.ts index e3f117381c..0f881ccc9a 100644 --- a/server/src/schema/tables/user-audit.table.ts +++ b/server/src/schema/tables/user-audit.table.ts @@ -1,14 +1,15 @@ -import { Column, ColumnIndex, CreateDateColumn, PrimaryGeneratedColumn, Table } from 'src/sql-tools'; +import { PrimaryGeneratedUuidV7Column } from 'src/decorators'; +import { Column, ColumnIndex, CreateDateColumn, Table } from 'src/sql-tools'; @Table('users_audit') export class UserAuditTable { - @PrimaryGeneratedColumn({ type: 'v7' }) - id!: string; - @Column({ type: 'uuid' }) userId!: string; @ColumnIndex('IDX_users_audit_deleted_at') @CreateDateColumn({ default: () => 'clock_timestamp()' }) deletedAt!: Date; + + @PrimaryGeneratedUuidV7Column() + id!: string; } diff --git a/server/src/schema/tables/user.table.ts b/server/src/schema/tables/user.table.ts index 5bd9cd94c6..5160f979b9 100644 --- a/server/src/schema/tables/user.table.ts +++ b/server/src/schema/tables/user.table.ts @@ -1,6 +1,9 @@ import { ColumnType } from 'kysely'; +import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators'; import { UserStatus } from 'src/enum'; +import { users_delete_audit } from 'src/schema/functions'; import { + AfterDeleteTrigger, Column, ColumnIndex, CreateDateColumn, @@ -9,7 +12,6 @@ import { PrimaryGeneratedColumn, Table, UpdateDateColumn, - UpdateIdColumn, } from 'src/sql-tools'; type Timestamp = ColumnType; @@ -17,50 +19,51 @@ type Generated = T extends ColumnType ? ColumnType : ColumnType; @Table('users') +@UpdatedAtTrigger('users_updated_at') +@AfterDeleteTrigger({ + name: 'users_delete_audit', + scope: 'statement', + function: users_delete_audit, + referencingOldTableAs: 'old', + when: 'pg_trigger_depth() = 0', +}) @Index({ name: 'IDX_users_updated_at_asc_id_asc', columns: ['updatedAt', 'id'] }) export class UserTable { @PrimaryGeneratedColumn() id!: Generated; + @Column({ unique: true }) + email!: string; + @Column({ default: '' }) - name!: Generated; + password!: Generated; + + @CreateDateColumn() + createdAt!: Generated; + + @Column({ default: '' }) + profileImagePath!: Generated; @Column({ type: 'boolean', default: false }) isAdmin!: Generated; - @Column({ unique: true }) - email!: string; + @Column({ type: 'boolean', default: true }) + shouldChangePassword!: Generated; + + @DeleteDateColumn() + deletedAt!: Timestamp | null; + + @Column({ default: '' }) + oauthId!: Generated; + + @UpdateDateColumn() + updatedAt!: Generated; @Column({ unique: true, nullable: true, default: null }) storageLabel!: string | null; @Column({ default: '' }) - password!: Generated; - - @Column({ default: '' }) - oauthId!: Generated; - - @Column({ default: '' }) - profileImagePath!: Generated; - - @Column({ type: 'boolean', default: true }) - shouldChangePassword!: Generated; - - @CreateDateColumn() - createdAt!: Generated; - - @UpdateDateColumn() - updatedAt!: Generated; - - @DeleteDateColumn() - deletedAt!: Timestamp | null; - - @Column({ type: 'character varying', default: UserStatus.ACTIVE }) - status!: Generated; - - @ColumnIndex({ name: 'IDX_users_update_id' }) - @UpdateIdColumn() - updateId!: Generated; + name!: Generated; @Column({ type: 'bigint', nullable: true }) quotaSizeInBytes!: ColumnType | null; @@ -68,6 +71,13 @@ export class UserTable { @Column({ type: 'bigint', default: 0 }) quotaUsageInBytes!: Generated>; + @Column({ type: 'character varying', default: UserStatus.ACTIVE }) + status!: Generated; + @Column({ type: 'timestamp with time zone', default: () => 'now()' }) profileChangedAt!: Generated; + + @ColumnIndex({ name: 'IDX_users_update_id' }) + @UpdateIdColumn() + updateId!: Generated; } diff --git a/server/src/sql-tools/decorators.ts b/server/src/sql-tools/decorators.ts deleted file mode 100644 index 88b3e4c7d1..0000000000 --- a/server/src/sql-tools/decorators.ts +++ /dev/null @@ -1,107 +0,0 @@ -/* eslint-disable @typescript-eslint/no-unsafe-function-type */ -import { register } from 'src/sql-tools/schema-from-decorators'; -import { - CheckOptions, - ColumnDefaultValue, - ColumnIndexOptions, - ColumnOptions, - ForeignKeyColumnOptions, - GenerateColumnOptions, - IndexOptions, - TableOptions, - UniqueOptions, -} from 'src/sql-tools/types'; - -export const Table = (options: string | TableOptions = {}): ClassDecorator => { - return (object: Function) => void register({ type: 'table', item: { object, options: asOptions(options) } }); -}; - -export const Column = (options: string | ColumnOptions = {}): PropertyDecorator => { - return (object: object, propertyName: string | symbol) => - void register({ type: 'column', item: { object, propertyName, options: asOptions(options) } }); -}; - -export const Index = (options: string | IndexOptions = {}): ClassDecorator => { - return (object: Function) => void register({ type: 'index', item: { object, options: asOptions(options) } }); -}; - -export const Unique = (options: UniqueOptions): ClassDecorator => { - return (object: Function) => void register({ type: 'uniqueConstraint', item: { object, options } }); -}; - -export const Check = (options: CheckOptions): ClassDecorator => { - return (object: Function) => void register({ type: 'checkConstraint', item: { object, options } }); -}; - -export const ColumnIndex = (options: string | ColumnIndexOptions = {}): PropertyDecorator => { - return (object: object, propertyName: string | symbol) => - void register({ type: 'columnIndex', item: { object, propertyName, options: asOptions(options) } }); -}; - -export const ForeignKeyColumn = (target: () => object, options: ForeignKeyColumnOptions): PropertyDecorator => { - return (object: object, propertyName: string | symbol) => { - register({ type: 'foreignKeyColumn', item: { object, propertyName, options, target } }); - }; -}; - -export const CreateDateColumn = (options: ColumnOptions = {}): PropertyDecorator => { - return Column({ - type: 'timestamp with time zone', - default: () => 'now()', - ...options, - }); -}; - -export const UpdateDateColumn = (options: ColumnOptions = {}): PropertyDecorator => { - return Column({ - type: 'timestamp with time zone', - default: () => 'now()', - ...options, - }); -}; - -export const DeleteDateColumn = (options: ColumnOptions = {}): PropertyDecorator => { - return Column({ - type: 'timestamp with time zone', - nullable: true, - ...options, - }); -}; - -export const PrimaryGeneratedColumn = (options: Omit = {}) => - GeneratedColumn({ type: 'v4', ...options, primary: true }); - -export const PrimaryColumn = (options: Omit = {}) => Column({ ...options, primary: true }); - -export const GeneratedColumn = ({ type = 'v4', ...options }: GenerateColumnOptions): PropertyDecorator => { - const columnType = type === 'v4' || type === 'v7' ? 'uuid' : type; - - let columnDefault: ColumnDefaultValue | undefined; - switch (type) { - case 'v4': { - columnDefault = () => 'uuid_generate_v4()'; - break; - } - - case 'v7': { - columnDefault = () => 'immich_uuid_v7()'; - break; - } - } - - return Column({ - type: columnType, - default: columnDefault, - ...options, - }); -}; - -export const UpdateIdColumn = () => GeneratedColumn({ type: 'v7', nullable: false }); - -const asOptions = (options: string | T): T => { - if (typeof options === 'string') { - return { name: options } as T; - } - - return options; -}; diff --git a/server/src/sql-tools/diff/comparers/column.comparer.spec.ts b/server/src/sql-tools/diff/comparers/column.comparer.spec.ts new file mode 100644 index 0000000000..082d15f0db --- /dev/null +++ b/server/src/sql-tools/diff/comparers/column.comparer.spec.ts @@ -0,0 +1,81 @@ +import { compareColumns } from 'src/sql-tools/diff/comparers/column.comparer'; +import { DatabaseColumn, Reason } from 'src/sql-tools/types'; +import { describe, expect, it } from 'vitest'; + +const testColumn: DatabaseColumn = { + name: 'test', + tableName: 'table1', + nullable: false, + isArray: false, + type: 'character varying', + synchronize: true, +}; + +describe('compareColumns', () => { + describe('onExtra', () => { + it('should work', () => { + expect(compareColumns.onExtra(testColumn)).toEqual([ + { + tableName: 'table1', + columnName: 'test', + type: 'column.drop', + reason: Reason.MissingInSource, + }, + ]); + }); + }); + + describe('onMissing', () => { + it('should work', () => { + expect(compareColumns.onMissing(testColumn)).toEqual([ + { + type: 'column.add', + column: testColumn, + reason: Reason.MissingInTarget, + }, + ]); + }); + }); + + describe('onCompare', () => { + it('should work', () => { + expect(compareColumns.onCompare(testColumn, testColumn)).toEqual([]); + }); + + it('should detect a change in type', () => { + const source: DatabaseColumn = { ...testColumn }; + const target: DatabaseColumn = { ...testColumn, type: 'text' }; + const reason = 'column type is different (character varying vs text)'; + expect(compareColumns.onCompare(source, target)).toEqual([ + { + columnName: 'test', + tableName: 'table1', + type: 'column.drop', + reason, + }, + { + type: 'column.add', + column: source, + reason, + }, + ]); + }); + + it('should detect a comment change', () => { + const source: DatabaseColumn = { ...testColumn, comment: 'new comment' }; + const target: DatabaseColumn = { ...testColumn, comment: 'old comment' }; + const reason = 'comment is different (new comment vs old comment)'; + expect(compareColumns.onCompare(source, target)).toEqual([ + { + columnName: 'test', + tableName: 'table1', + type: 'column.alter', + changes: { + comment: 'new comment', + }, + reason, + }, + ]); + }); + }); +}); diff --git a/server/src/sql-tools/diff/comparers/column.comparer.ts b/server/src/sql-tools/diff/comparers/column.comparer.ts new file mode 100644 index 0000000000..205bd594ae --- /dev/null +++ b/server/src/sql-tools/diff/comparers/column.comparer.ts @@ -0,0 +1,82 @@ +import { getColumnType, isDefaultEqual } from 'src/sql-tools/helpers'; +import { Comparer, DatabaseColumn, Reason, SchemaDiff } from 'src/sql-tools/types'; + +export const compareColumns: Comparer = { + onMissing: (source) => [ + { + type: 'column.add', + column: source, + reason: Reason.MissingInTarget, + }, + ], + onExtra: (target) => [ + { + type: 'column.drop', + tableName: target.tableName, + columnName: target.name, + reason: Reason.MissingInSource, + }, + ], + onCompare: (source, target) => { + const sourceType = getColumnType(source); + const targetType = getColumnType(target); + + const isTypeChanged = sourceType !== targetType; + + if (isTypeChanged) { + // TODO: convert between types via UPDATE when possible + return dropAndRecreateColumn(source, target, `column type is different (${sourceType} vs ${targetType})`); + } + + const items: SchemaDiff[] = []; + if (source.nullable !== target.nullable) { + items.push({ + type: 'column.alter', + tableName: source.tableName, + columnName: source.name, + changes: { + nullable: source.nullable, + }, + reason: `nullable is different (${source.nullable} vs ${target.nullable})`, + }); + } + + if (!isDefaultEqual(source, target)) { + items.push({ + type: 'column.alter', + tableName: source.tableName, + columnName: source.name, + changes: { + default: String(source.default), + }, + reason: `default is different (${source.default} vs ${target.default})`, + }); + } + + if (source.comment !== target.comment) { + items.push({ + type: 'column.alter', + tableName: source.tableName, + columnName: source.name, + changes: { + comment: String(source.comment), + }, + reason: `comment is different (${source.comment} vs ${target.comment})`, + }); + } + + return items; + }, +}; + +const dropAndRecreateColumn = (source: DatabaseColumn, target: DatabaseColumn, reason: string): SchemaDiff[] => { + return [ + { + type: 'column.drop', + tableName: target.tableName, + columnName: target.name, + reason, + }, + { type: 'column.add', column: source, reason }, + ]; +}; diff --git a/server/src/sql-tools/diff/comparers/constraint.comparer.spec.ts b/server/src/sql-tools/diff/comparers/constraint.comparer.spec.ts new file mode 100644 index 0000000000..69d8a8cc43 --- /dev/null +++ b/server/src/sql-tools/diff/comparers/constraint.comparer.spec.ts @@ -0,0 +1,63 @@ +import { compareConstraints } from 'src/sql-tools/diff/comparers/constraint.comparer'; +import { DatabaseConstraint, DatabaseConstraintType, Reason } from 'src/sql-tools/types'; +import { describe, expect, it } from 'vitest'; + +const testConstraint: DatabaseConstraint = { + type: DatabaseConstraintType.PRIMARY_KEY, + name: 'test', + tableName: 'table1', + columnNames: ['column1'], + synchronize: true, +}; + +describe('compareConstraints', () => { + describe('onExtra', () => { + it('should work', () => { + expect(compareConstraints.onExtra(testConstraint)).toEqual([ + { + type: 'constraint.drop', + constraintName: 'test', + tableName: 'table1', + reason: Reason.MissingInSource, + }, + ]); + }); + }); + + describe('onMissing', () => { + it('should work', () => { + expect(compareConstraints.onMissing(testConstraint)).toEqual([ + { + type: 'constraint.add', + constraint: testConstraint, + reason: Reason.MissingInTarget, + }, + ]); + }); + }); + + describe('onCompare', () => { + it('should work', () => { + expect(compareConstraints.onCompare(testConstraint, testConstraint)).toEqual([]); + }); + + it('should detect a change in type', () => { + const source: DatabaseConstraint = { ...testConstraint }; + const target: DatabaseConstraint = { ...testConstraint, columnNames: ['column1', 'column2'] }; + const reason = 'Primary key columns are different: (column1 vs column1,column2)'; + expect(compareConstraints.onCompare(source, target)).toEqual([ + { + constraintName: 'test', + tableName: 'table1', + type: 'constraint.drop', + reason, + }, + { + type: 'constraint.add', + constraint: source, + reason, + }, + ]); + }); + }); +}); diff --git a/server/src/sql-tools/diff/comparers/constraint.comparer.ts b/server/src/sql-tools/diff/comparers/constraint.comparer.ts new file mode 100644 index 0000000000..ccb594741c --- /dev/null +++ b/server/src/sql-tools/diff/comparers/constraint.comparer.ts @@ -0,0 +1,133 @@ +import { haveEqualColumns } from 'src/sql-tools/helpers'; +import { + CompareFunction, + Comparer, + DatabaseCheckConstraint, + DatabaseConstraint, + DatabaseConstraintType, + DatabaseForeignKeyConstraint, + DatabasePrimaryKeyConstraint, + DatabaseUniqueConstraint, + Reason, + SchemaDiff, +} from 'src/sql-tools/types'; + +export const compareConstraints: Comparer = { + onMissing: (source) => [ + { + type: 'constraint.add', + constraint: source, + reason: Reason.MissingInTarget, + }, + ], + onExtra: (target) => [ + { + type: 'constraint.drop', + tableName: target.tableName, + constraintName: target.name, + reason: Reason.MissingInSource, + }, + ], + onCompare: (source, target) => { + switch (source.type) { + case DatabaseConstraintType.PRIMARY_KEY: { + return comparePrimaryKeyConstraint(source, target as DatabasePrimaryKeyConstraint); + } + + case DatabaseConstraintType.FOREIGN_KEY: { + return compareForeignKeyConstraint(source, target as DatabaseForeignKeyConstraint); + } + + case DatabaseConstraintType.UNIQUE: { + return compareUniqueConstraint(source, target as DatabaseUniqueConstraint); + } + + case DatabaseConstraintType.CHECK: { + return compareCheckConstraint(source, target as DatabaseCheckConstraint); + } + + default: { + return []; + } + } + }, +}; + +const comparePrimaryKeyConstraint: CompareFunction = (source, target) => { + if (!haveEqualColumns(source.columnNames, target.columnNames)) { + return dropAndRecreateConstraint( + source, + target, + `Primary key columns are different: (${source.columnNames} vs ${target.columnNames})`, + ); + } + + return []; +}; + +const compareForeignKeyConstraint: CompareFunction = (source, target) => { + let reason = ''; + + const sourceDeleteAction = source.onDelete ?? 'NO ACTION'; + const targetDeleteAction = target.onDelete ?? 'NO ACTION'; + + const sourceUpdateAction = source.onUpdate ?? 'NO ACTION'; + const targetUpdateAction = target.onUpdate ?? 'NO ACTION'; + + if (!haveEqualColumns(source.columnNames, target.columnNames)) { + reason = `columns are different (${source.columnNames} vs ${target.columnNames})`; + } else if (!haveEqualColumns(source.referenceColumnNames, target.referenceColumnNames)) { + reason = `reference columns are different (${source.referenceColumnNames} vs ${target.referenceColumnNames})`; + } else if (source.referenceTableName !== target.referenceTableName) { + reason = `reference table is different (${source.referenceTableName} vs ${target.referenceTableName})`; + } else if (sourceDeleteAction !== targetDeleteAction) { + reason = `ON DELETE action is different (${sourceDeleteAction} vs ${targetDeleteAction})`; + } else if (sourceUpdateAction !== targetUpdateAction) { + reason = `ON UPDATE action is different (${sourceUpdateAction} vs ${targetUpdateAction})`; + } + + if (reason) { + return dropAndRecreateConstraint(source, target, reason); + } + + return []; +}; + +const compareUniqueConstraint: CompareFunction = (source, target) => { + let reason = ''; + + if (!haveEqualColumns(source.columnNames, target.columnNames)) { + reason = `columns are different (${source.columnNames} vs ${target.columnNames})`; + } + + if (reason) { + return dropAndRecreateConstraint(source, target, reason); + } + + return []; +}; + +const compareCheckConstraint: CompareFunction = (source, target) => { + if (source.expression !== target.expression) { + // comparing expressions is hard because postgres reconstructs it with different formatting + // for now if the constraint exists with the same name, we will just skip it + } + + return []; +}; + +const dropAndRecreateConstraint = ( + source: DatabaseConstraint, + target: DatabaseConstraint, + reason: string, +): SchemaDiff[] => { + return [ + { + type: 'constraint.drop', + tableName: target.tableName, + constraintName: target.name, + reason, + }, + { type: 'constraint.add', constraint: source, reason }, + ]; +}; diff --git a/server/src/sql-tools/diff/comparers/enum.comparer.spec.ts b/server/src/sql-tools/diff/comparers/enum.comparer.spec.ts new file mode 100644 index 0000000000..6e1ad992d5 --- /dev/null +++ b/server/src/sql-tools/diff/comparers/enum.comparer.spec.ts @@ -0,0 +1,54 @@ +import { compareEnums } from 'src/sql-tools/diff/comparers/enum.comparer'; +import { DatabaseEnum, Reason } from 'src/sql-tools/types'; +import { describe, expect, it } from 'vitest'; + +const testEnum: DatabaseEnum = { name: 'test', values: ['foo', 'bar'], synchronize: true }; + +describe('compareEnums', () => { + describe('onExtra', () => { + it('should work', () => { + expect(compareEnums.onExtra(testEnum)).toEqual([ + { + enumName: 'test', + type: 'enum.drop', + reason: Reason.MissingInSource, + }, + ]); + }); + }); + + describe('onMissing', () => { + it('should work', () => { + expect(compareEnums.onMissing(testEnum)).toEqual([ + { + type: 'enum.create', + enum: testEnum, + reason: Reason.MissingInTarget, + }, + ]); + }); + }); + + describe('onCompare', () => { + it('should work', () => { + expect(compareEnums.onCompare(testEnum, testEnum)).toEqual([]); + }); + + it('should drop and recreate when values list is different', () => { + const source = { name: 'test', values: ['foo', 'bar'], synchronize: true }; + const target = { name: 'test', values: ['foo', 'bar', 'world'], synchronize: true }; + expect(compareEnums.onCompare(source, target)).toEqual([ + { + enumName: 'test', + type: 'enum.drop', + reason: 'enum values has changed (foo,bar vs foo,bar,world)', + }, + { + type: 'enum.create', + enum: source, + reason: 'enum values has changed (foo,bar vs foo,bar,world)', + }, + ]); + }); + }); +}); diff --git a/server/src/sql-tools/diff/comparers/enum.comparer.ts b/server/src/sql-tools/diff/comparers/enum.comparer.ts new file mode 100644 index 0000000000..408f01050b --- /dev/null +++ b/server/src/sql-tools/diff/comparers/enum.comparer.ts @@ -0,0 +1,38 @@ +import { Comparer, DatabaseEnum, Reason } from 'src/sql-tools/types'; + +export const compareEnums: Comparer = { + onMissing: (source) => [ + { + type: 'enum.create', + enum: source, + reason: Reason.MissingInTarget, + }, + ], + onExtra: (target) => [ + { + type: 'enum.drop', + enumName: target.name, + reason: Reason.MissingInSource, + }, + ], + onCompare: (source, target) => { + if (source.values.toString() !== target.values.toString()) { + // TODO add or remove values if the lists are different or the order has changed + const reason = `enum values has changed (${source.values} vs ${target.values})`; + return [ + { + type: 'enum.drop', + enumName: source.name, + reason, + }, + { + type: 'enum.create', + enum: source, + reason, + }, + ]; + } + + return []; + }, +}; diff --git a/server/src/sql-tools/diff/comparers/extension.comparer.spec.ts b/server/src/sql-tools/diff/comparers/extension.comparer.spec.ts new file mode 100644 index 0000000000..753c461c69 --- /dev/null +++ b/server/src/sql-tools/diff/comparers/extension.comparer.spec.ts @@ -0,0 +1,37 @@ +import { compareExtensions } from 'src/sql-tools/diff/comparers/extension.comparer'; +import { Reason } from 'src/sql-tools/types'; +import { describe, expect, it } from 'vitest'; + +const testExtension = { name: 'test', synchronize: true }; + +describe('compareExtensions', () => { + describe('onExtra', () => { + it('should work', () => { + expect(compareExtensions.onExtra(testExtension)).toEqual([ + { + extensionName: 'test', + type: 'extension.drop', + reason: Reason.MissingInSource, + }, + ]); + }); + }); + + describe('onMissing', () => { + it('should work', () => { + expect(compareExtensions.onMissing(testExtension)).toEqual([ + { + type: 'extension.create', + extension: testExtension, + reason: Reason.MissingInTarget, + }, + ]); + }); + }); + + describe('onCompare', () => { + it('should work', () => { + expect(compareExtensions.onCompare(testExtension, testExtension)).toEqual([]); + }); + }); +}); diff --git a/server/src/sql-tools/diff/comparers/extension.comparer.ts b/server/src/sql-tools/diff/comparers/extension.comparer.ts new file mode 100644 index 0000000000..1c9d19165a --- /dev/null +++ b/server/src/sql-tools/diff/comparers/extension.comparer.ts @@ -0,0 +1,22 @@ +import { Comparer, DatabaseExtension, Reason } from 'src/sql-tools/types'; + +export const compareExtensions: Comparer = { + onMissing: (source) => [ + { + type: 'extension.create', + extension: source, + reason: Reason.MissingInTarget, + }, + ], + onExtra: (target) => [ + { + type: 'extension.drop', + extensionName: target.name, + reason: Reason.MissingInSource, + }, + ], + onCompare: () => { + // if the name matches they are the same + return []; + }, +}; diff --git a/server/src/sql-tools/diff/comparers/function.comparer.spec.ts b/server/src/sql-tools/diff/comparers/function.comparer.spec.ts new file mode 100644 index 0000000000..ac478ed000 --- /dev/null +++ b/server/src/sql-tools/diff/comparers/function.comparer.spec.ts @@ -0,0 +1,53 @@ +import { compareFunctions } from 'src/sql-tools/diff/comparers/function.comparer'; +import { DatabaseFunction, Reason } from 'src/sql-tools/types'; +import { describe, expect, it } from 'vitest'; + +const testFunction: DatabaseFunction = { + name: 'test', + expression: 'CREATE FUNCTION something something something', + synchronize: true, +}; + +describe('compareFunctions', () => { + describe('onExtra', () => { + it('should work', () => { + expect(compareFunctions.onExtra(testFunction)).toEqual([ + { + functionName: 'test', + type: 'function.drop', + reason: Reason.MissingInSource, + }, + ]); + }); + }); + + describe('onMissing', () => { + it('should work', () => { + expect(compareFunctions.onMissing(testFunction)).toEqual([ + { + type: 'function.create', + function: testFunction, + reason: Reason.MissingInTarget, + }, + ]); + }); + }); + + describe('onCompare', () => { + it('should ignore functions with the same hash', () => { + expect(compareFunctions.onCompare(testFunction, testFunction)).toEqual([]); + }); + + it('should report differences if functions have different hashes', () => { + const source: DatabaseFunction = { ...testFunction, expression: 'SELECT 1' }; + const target: DatabaseFunction = { ...testFunction, expression: 'SELECT 2' }; + expect(compareFunctions.onCompare(source, target)).toEqual([ + { + type: 'function.create', + reason: 'function expression has changed (SELECT 1 vs SELECT 2)', + function: source, + }, + ]); + }); + }); +}); diff --git a/server/src/sql-tools/diff/comparers/function.comparer.ts b/server/src/sql-tools/diff/comparers/function.comparer.ts new file mode 100644 index 0000000000..d10353b89c --- /dev/null +++ b/server/src/sql-tools/diff/comparers/function.comparer.ts @@ -0,0 +1,32 @@ +import { Comparer, DatabaseFunction, Reason } from 'src/sql-tools/types'; + +export const compareFunctions: Comparer = { + onMissing: (source) => [ + { + type: 'function.create', + function: source, + reason: Reason.MissingInTarget, + }, + ], + onExtra: (target) => [ + { + type: 'function.drop', + functionName: target.name, + reason: Reason.MissingInSource, + }, + ], + onCompare: (source, target) => { + if (source.expression !== target.expression) { + const reason = `function expression has changed (${source.expression} vs ${target.expression})`; + return [ + { + type: 'function.create', + function: source, + reason, + }, + ]; + } + + return []; + }, +}; diff --git a/server/src/sql-tools/diff/comparers/index.comparer.spec.ts b/server/src/sql-tools/diff/comparers/index.comparer.spec.ts new file mode 100644 index 0000000000..806bab190c --- /dev/null +++ b/server/src/sql-tools/diff/comparers/index.comparer.spec.ts @@ -0,0 +1,72 @@ +import { compareIndexes } from 'src/sql-tools/diff/comparers/index.comparer'; +import { DatabaseIndex, Reason } from 'src/sql-tools/types'; +import { describe, expect, it } from 'vitest'; + +const testIndex: DatabaseIndex = { + name: 'test', + tableName: 'table1', + columnNames: ['column1', 'column2'], + unique: false, + synchronize: true, +}; + +describe('compareIndexes', () => { + describe('onExtra', () => { + it('should work', () => { + expect(compareIndexes.onExtra(testIndex)).toEqual([ + { + type: 'index.drop', + indexName: 'test', + reason: Reason.MissingInSource, + }, + ]); + }); + }); + + describe('onMissing', () => { + it('should work', () => { + expect(compareIndexes.onMissing(testIndex)).toEqual([ + { + type: 'index.create', + index: testIndex, + reason: Reason.MissingInTarget, + }, + ]); + }); + }); + + describe('onCompare', () => { + it('should work', () => { + expect(compareIndexes.onCompare(testIndex, testIndex)).toEqual([]); + }); + + it('should drop and recreate when column list is different', () => { + const source = { + name: 'test', + tableName: 'table1', + columnNames: ['column1'], + unique: true, + synchronize: true, + }; + const target = { + name: 'test', + tableName: 'table1', + columnNames: ['column1', 'column2'], + unique: true, + synchronize: true, + }; + expect(compareIndexes.onCompare(source, target)).toEqual([ + { + indexName: 'test', + type: 'index.drop', + reason: 'columns are different (column1 vs column1,column2)', + }, + { + type: 'index.create', + index: source, + reason: 'columns are different (column1 vs column1,column2)', + }, + ]); + }); + }); +}); diff --git a/server/src/sql-tools/diff/comparers/index.comparer.ts b/server/src/sql-tools/diff/comparers/index.comparer.ts new file mode 100644 index 0000000000..ef07e3a17b --- /dev/null +++ b/server/src/sql-tools/diff/comparers/index.comparer.ts @@ -0,0 +1,46 @@ +import { haveEqualColumns } from 'src/sql-tools/helpers'; +import { Comparer, DatabaseIndex, Reason } from 'src/sql-tools/types'; + +export const compareIndexes: Comparer = { + onMissing: (source) => [ + { + type: 'index.create', + index: source, + reason: Reason.MissingInTarget, + }, + ], + onExtra: (target) => [ + { + type: 'index.drop', + indexName: target.name, + reason: Reason.MissingInSource, + }, + ], + onCompare: (source, target) => { + const sourceUsing = source.using ?? 'btree'; + const targetUsing = target.using ?? 'btree'; + + let reason = ''; + + if (!haveEqualColumns(source.columnNames, target.columnNames)) { + reason = `columns are different (${source.columnNames} vs ${target.columnNames})`; + } else if (source.unique !== target.unique) { + reason = `uniqueness is different (${source.unique} vs ${target.unique})`; + } else if (sourceUsing !== targetUsing) { + reason = `using method is different (${source.using} vs ${target.using})`; + } else if (source.where !== target.where) { + reason = `where clause is different (${source.where} vs ${target.where})`; + } else if (source.expression !== target.expression) { + reason = `expression is different (${source.expression} vs ${target.expression})`; + } + + if (reason) { + return [ + { type: 'index.drop', indexName: target.name, reason }, + { type: 'index.create', index: source, reason }, + ]; + } + + return []; + }, +}; diff --git a/server/src/sql-tools/diff/comparers/parameter.comparer.spec.ts b/server/src/sql-tools/diff/comparers/parameter.comparer.spec.ts new file mode 100644 index 0000000000..517ec79341 --- /dev/null +++ b/server/src/sql-tools/diff/comparers/parameter.comparer.spec.ts @@ -0,0 +1,44 @@ +import { compareParameters } from 'src/sql-tools/diff/comparers/parameter.comparer'; +import { DatabaseParameter, Reason } from 'src/sql-tools/types'; +import { describe, expect, it } from 'vitest'; + +const testParameter: DatabaseParameter = { + name: 'test', + databaseName: 'immich', + value: 'on', + scope: 'database', + synchronize: true, +}; + +describe('compareParameters', () => { + describe('onExtra', () => { + it('should work', () => { + expect(compareParameters.onExtra(testParameter)).toEqual([ + { + type: 'parameter.reset', + databaseName: 'immich', + parameterName: 'test', + reason: Reason.MissingInSource, + }, + ]); + }); + }); + + describe('onMissing', () => { + it('should work', () => { + expect(compareParameters.onMissing(testParameter)).toEqual([ + { + type: 'parameter.set', + parameter: testParameter, + reason: Reason.MissingInTarget, + }, + ]); + }); + }); + + describe('onCompare', () => { + it('should work', () => { + expect(compareParameters.onCompare(testParameter, testParameter)).toEqual([]); + }); + }); +}); diff --git a/server/src/sql-tools/diff/comparers/parameter.comparer.ts b/server/src/sql-tools/diff/comparers/parameter.comparer.ts new file mode 100644 index 0000000000..03c24bada7 --- /dev/null +++ b/server/src/sql-tools/diff/comparers/parameter.comparer.ts @@ -0,0 +1,23 @@ +import { Comparer, DatabaseParameter, Reason } from 'src/sql-tools/types'; + +export const compareParameters: Comparer = { + onMissing: (source) => [ + { + type: 'parameter.set', + parameter: source, + reason: Reason.MissingInTarget, + }, + ], + onExtra: (target) => [ + { + type: 'parameter.reset', + databaseName: target.databaseName, + parameterName: target.name, + reason: Reason.MissingInSource, + }, + ], + onCompare: () => { + // TODO + return []; + }, +}; diff --git a/server/src/sql-tools/diff/comparers/table.comparer.spec.ts b/server/src/sql-tools/diff/comparers/table.comparer.spec.ts new file mode 100644 index 0000000000..0b1873b2ba --- /dev/null +++ b/server/src/sql-tools/diff/comparers/table.comparer.spec.ts @@ -0,0 +1,44 @@ +import { compareTables } from 'src/sql-tools/diff/comparers/table.comparer'; +import { DatabaseTable, Reason } from 'src/sql-tools/types'; +import { describe, expect, it } from 'vitest'; + +const testTable: DatabaseTable = { + name: 'test', + columns: [], + constraints: [], + indexes: [], + triggers: [], + synchronize: true, +}; + +describe('compareParameters', () => { + describe('onExtra', () => { + it('should work', () => { + expect(compareTables.onExtra(testTable)).toEqual([ + { + type: 'table.drop', + tableName: 'test', + reason: Reason.MissingInSource, + }, + ]); + }); + }); + + describe('onMissing', () => { + it('should work', () => { + expect(compareTables.onMissing(testTable)).toEqual([ + { + type: 'table.create', + table: testTable, + reason: Reason.MissingInTarget, + }, + ]); + }); + }); + + describe('onCompare', () => { + it('should work', () => { + expect(compareTables.onCompare(testTable, testTable)).toEqual([]); + }); + }); +}); diff --git a/server/src/sql-tools/diff/comparers/table.comparer.ts b/server/src/sql-tools/diff/comparers/table.comparer.ts new file mode 100644 index 0000000000..8f6d0e04f8 --- /dev/null +++ b/server/src/sql-tools/diff/comparers/table.comparer.ts @@ -0,0 +1,59 @@ +import { compareColumns } from 'src/sql-tools/diff/comparers/column.comparer'; +import { compareConstraints } from 'src/sql-tools/diff/comparers/constraint.comparer'; +import { compareIndexes } from 'src/sql-tools/diff/comparers/index.comparer'; +import { compareTriggers } from 'src/sql-tools/diff/comparers/trigger.comparer'; +import { compare } from 'src/sql-tools/helpers'; +import { Comparer, DatabaseTable, Reason, SchemaDiff } from 'src/sql-tools/types'; + +export const compareTables: Comparer = { + onMissing: (source) => [ + { + type: 'table.create', + table: source, + reason: Reason.MissingInTarget, + }, + // TODO merge constraints into table create record when possible + ...compareTable( + source, + { + name: source.name, + columns: [], + indexes: [], + constraints: [], + triggers: [], + synchronize: true, + }, + + { columns: false }, + ), + ], + onExtra: (target) => [ + ...compareTable( + { + name: target.name, + columns: [], + indexes: [], + constraints: [], + triggers: [], + synchronize: true, + }, + target, + { columns: false }, + ), + { + type: 'table.drop', + tableName: target.name, + reason: Reason.MissingInSource, + }, + ], + onCompare: (source, target) => compareTable(source, target, { columns: true }), +}; + +const compareTable = (source: DatabaseTable, target: DatabaseTable, options: { columns?: boolean }): SchemaDiff[] => { + return [ + ...(options.columns ? compare(source.columns, target.columns, {}, compareColumns) : []), + ...compare(source.indexes, target.indexes, {}, compareIndexes), + ...compare(source.constraints, target.constraints, {}, compareConstraints), + ...compare(source.triggers, target.triggers, {}, compareTriggers), + ]; +}; diff --git a/server/src/sql-tools/diff/comparers/trigger.comparer.spec.ts b/server/src/sql-tools/diff/comparers/trigger.comparer.spec.ts new file mode 100644 index 0000000000..800cb4d66b --- /dev/null +++ b/server/src/sql-tools/diff/comparers/trigger.comparer.spec.ts @@ -0,0 +1,88 @@ +import { compareTriggers } from 'src/sql-tools/diff/comparers/trigger.comparer'; +import { DatabaseTrigger, Reason } from 'src/sql-tools/types'; +import { describe, expect, it } from 'vitest'; + +const testTrigger: DatabaseTrigger = { + name: 'test', + tableName: 'table1', + timing: 'before', + actions: ['delete'], + scope: 'row', + functionName: 'my_trigger_function', + synchronize: true, +}; + +describe('compareTriggers', () => { + describe('onExtra', () => { + it('should work', () => { + expect(compareTriggers.onExtra(testTrigger)).toEqual([ + { + type: 'trigger.drop', + tableName: 'table1', + triggerName: 'test', + reason: Reason.MissingInSource, + }, + ]); + }); + }); + + describe('onMissing', () => { + it('should work', () => { + expect(compareTriggers.onMissing(testTrigger)).toEqual([ + { + type: 'trigger.create', + trigger: testTrigger, + reason: Reason.MissingInTarget, + }, + ]); + }); + }); + + describe('onCompare', () => { + it('should work', () => { + expect(compareTriggers.onCompare(testTrigger, testTrigger)).toEqual([]); + }); + + it('should detect a change in function name', () => { + const source: DatabaseTrigger = { ...testTrigger, functionName: 'my_new_name' }; + const target: DatabaseTrigger = { ...testTrigger, functionName: 'my_old_name' }; + const reason = `function is different (my_new_name vs my_old_name)`; + expect(compareTriggers.onCompare(source, target)).toEqual([{ type: 'trigger.create', trigger: source, reason }]); + }); + + it('should detect a change in actions', () => { + const source: DatabaseTrigger = { ...testTrigger, actions: ['delete'] }; + const target: DatabaseTrigger = { ...testTrigger, actions: ['delete', 'insert'] }; + const reason = `action is different (delete vs delete,insert)`; + expect(compareTriggers.onCompare(source, target)).toEqual([{ type: 'trigger.create', trigger: source, reason }]); + }); + + it('should detect a change in timing', () => { + const source: DatabaseTrigger = { ...testTrigger, timing: 'before' }; + const target: DatabaseTrigger = { ...testTrigger, timing: 'after' }; + const reason = `timing method is different (before vs after)`; + expect(compareTriggers.onCompare(source, target)).toEqual([{ type: 'trigger.create', trigger: source, reason }]); + }); + + it('should detect a change in scope', () => { + const source: DatabaseTrigger = { ...testTrigger, scope: 'row' }; + const target: DatabaseTrigger = { ...testTrigger, scope: 'statement' }; + const reason = `scope is different (row vs statement)`; + expect(compareTriggers.onCompare(source, target)).toEqual([{ type: 'trigger.create', trigger: source, reason }]); + }); + + it('should detect a change in new table reference', () => { + const source: DatabaseTrigger = { ...testTrigger, referencingNewTableAs: 'new_table' }; + const target: DatabaseTrigger = { ...testTrigger, referencingNewTableAs: undefined }; + const reason = `new table reference is different (new_table vs undefined)`; + expect(compareTriggers.onCompare(source, target)).toEqual([{ type: 'trigger.create', trigger: source, reason }]); + }); + + it('should detect a change in old table reference', () => { + const source: DatabaseTrigger = { ...testTrigger, referencingOldTableAs: 'old_table' }; + const target: DatabaseTrigger = { ...testTrigger, referencingOldTableAs: undefined }; + const reason = `old table reference is different (old_table vs undefined)`; + expect(compareTriggers.onCompare(source, target)).toEqual([{ type: 'trigger.create', trigger: source, reason }]); + }); + }); +}); diff --git a/server/src/sql-tools/diff/comparers/trigger.comparer.ts b/server/src/sql-tools/diff/comparers/trigger.comparer.ts new file mode 100644 index 0000000000..38adae9905 --- /dev/null +++ b/server/src/sql-tools/diff/comparers/trigger.comparer.ts @@ -0,0 +1,41 @@ +import { Comparer, DatabaseTrigger, Reason } from 'src/sql-tools/types'; + +export const compareTriggers: Comparer = { + onMissing: (source) => [ + { + type: 'trigger.create', + trigger: source, + reason: Reason.MissingInTarget, + }, + ], + onExtra: (target) => [ + { + type: 'trigger.drop', + tableName: target.tableName, + triggerName: target.name, + reason: Reason.MissingInSource, + }, + ], + onCompare: (source, target) => { + let reason = ''; + if (source.functionName !== target.functionName) { + reason = `function is different (${source.functionName} vs ${target.functionName})`; + } else if (source.actions.join(' OR ') !== target.actions.join(' OR ')) { + reason = `action is different (${source.actions} vs ${target.actions})`; + } else if (source.timing !== target.timing) { + reason = `timing method is different (${source.timing} vs ${target.timing})`; + } else if (source.scope !== target.scope) { + reason = `scope is different (${source.scope} vs ${target.scope})`; + } else if (source.referencingNewTableAs !== target.referencingNewTableAs) { + reason = `new table reference is different (${source.referencingNewTableAs} vs ${target.referencingNewTableAs})`; + } else if (source.referencingOldTableAs !== target.referencingOldTableAs) { + reason = `old table reference is different (${source.referencingOldTableAs} vs ${target.referencingOldTableAs})`; + } + + if (reason) { + return [{ type: 'trigger.create', trigger: source, reason }]; + } + + return []; + }, +}; diff --git a/server/src/sql-tools/schema-diff.spec.ts b/server/src/sql-tools/diff/index.spec.ts similarity index 94% rename from server/src/sql-tools/schema-diff.spec.ts rename to server/src/sql-tools/diff/index.spec.ts index 2f536cfabd..7ffd3946f2 100644 --- a/server/src/sql-tools/schema-diff.spec.ts +++ b/server/src/sql-tools/diff/index.spec.ts @@ -1,8 +1,8 @@ -import { schemaDiff } from 'src/sql-tools/schema-diff'; +import { schemaDiff } from 'src/sql-tools/diff'; import { + ColumnType, DatabaseActionType, DatabaseColumn, - DatabaseColumnType, DatabaseConstraint, DatabaseConstraintType, DatabaseIndex, @@ -15,7 +15,12 @@ const fromColumn = (column: Partial>): Databas const tableName = 'table1'; return { - name: 'public', + name: 'postgres', + schemaName: 'public', + functions: [], + enums: [], + extensions: [], + parameters: [], tables: [ { name: tableName, @@ -31,6 +36,7 @@ const fromColumn = (column: Partial>): Databas }, ], indexes: [], + triggers: [], constraints: [], synchronize: true, }, @@ -43,7 +49,12 @@ const fromConstraint = (constraint?: DatabaseConstraint): DatabaseSchema => { const tableName = constraint?.tableName || 'table1'; return { - name: 'public', + name: 'postgres', + schemaName: 'public', + functions: [], + enums: [], + extensions: [], + parameters: [], tables: [ { name: tableName, @@ -58,6 +69,7 @@ const fromConstraint = (constraint?: DatabaseConstraint): DatabaseSchema => { }, ], indexes: [], + triggers: [], constraints: constraint ? [constraint] : [], synchronize: true, }, @@ -70,7 +82,12 @@ const fromIndex = (index?: DatabaseIndex): DatabaseSchema => { const tableName = index?.tableName || 'table1'; return { - name: 'public', + name: 'postgres', + schemaName: 'public', + functions: [], + enums: [], + extensions: [], + parameters: [], tables: [ { name: tableName, @@ -86,6 +103,7 @@ const fromIndex = (index?: DatabaseIndex): DatabaseSchema => { ], indexes: index ? [index] : [], constraints: [], + triggers: [], synchronize: true, }, ], @@ -99,7 +117,7 @@ const newSchema = (schema: { name: string; columns?: Array<{ name: string; - type?: DatabaseColumnType; + type?: ColumnType; nullable?: boolean; isArray?: boolean; }>; @@ -131,12 +149,18 @@ const newSchema = (schema: { columns, indexes: table.indexes ?? [], constraints: table.constraints ?? [], + triggers: [], synchronize: true, }); } return { - name: schema?.name || 'public', + name: 'immich', + schemaName: schema?.name || 'public', + functions: [], + enums: [], + extensions: [], + parameters: [], tables, warnings: [], }; @@ -167,8 +191,14 @@ describe('schemaDiff', () => { expect(diff.items).toHaveLength(1); expect(diff.items[0]).toEqual({ type: 'table.create', - tableName: 'table1', - columns: [column], + table: { + name: 'table1', + columns: [column], + constraints: [], + indexes: [], + triggers: [], + synchronize: true, + }, reason: 'missing in target', }); }); @@ -181,7 +211,7 @@ describe('schemaDiff', () => { newSchema({ tables: [{ name: 'table1', columns: [{ name: 'column1' }] }], }), - { ignoreExtraTables: false }, + { tables: { ignoreExtra: false } }, ); expect(diff.items).toHaveLength(1); diff --git a/server/src/sql-tools/diff/index.ts b/server/src/sql-tools/diff/index.ts new file mode 100644 index 0000000000..dd90293dc3 --- /dev/null +++ b/server/src/sql-tools/diff/index.ts @@ -0,0 +1,85 @@ +import { compareEnums } from 'src/sql-tools/diff/comparers/enum.comparer'; +import { compareExtensions } from 'src/sql-tools/diff/comparers/extension.comparer'; +import { compareFunctions } from 'src/sql-tools/diff/comparers/function.comparer'; +import { compareParameters } from 'src/sql-tools/diff/comparers/parameter.comparer'; +import { compareTables } from 'src/sql-tools/diff/comparers/table.comparer'; +import { compare } from 'src/sql-tools/helpers'; +import { schemaDiffToSql } from 'src/sql-tools/to-sql'; +import { + DatabaseConstraintType, + DatabaseSchema, + SchemaDiff, + SchemaDiffOptions, + SchemaDiffToSqlOptions, +} from 'src/sql-tools/types'; + +/** + * Compute the difference between two database schemas + */ +export const schemaDiff = (source: DatabaseSchema, target: DatabaseSchema, options: SchemaDiffOptions = {}) => { + const items = [ + ...compare(source.parameters, target.parameters, options.parameters, compareParameters), + ...compare(source.extensions, target.extensions, options.extension, compareExtensions), + ...compare(source.functions, target.functions, options.functions, compareFunctions), + ...compare(source.enums, target.enums, options.enums, compareEnums), + ...compare(source.tables, target.tables, options.tables, compareTables), + ]; + + type SchemaName = SchemaDiff['type']; + const itemMap: Record = { + 'enum.create': [], + 'enum.drop': [], + 'extension.create': [], + 'extension.drop': [], + 'function.create': [], + 'function.drop': [], + 'table.create': [], + 'table.drop': [], + 'column.add': [], + 'column.alter': [], + 'column.drop': [], + 'constraint.add': [], + 'constraint.drop': [], + 'index.create': [], + 'index.drop': [], + 'trigger.create': [], + 'trigger.drop': [], + 'parameter.set': [], + 'parameter.reset': [], + }; + + for (const item of items) { + itemMap[item.type].push(item); + } + + const constraintAdds = itemMap['constraint.add'].filter((item) => item.type === 'constraint.add'); + + const orderedItems = [ + ...itemMap['extension.create'], + ...itemMap['function.create'], + ...itemMap['parameter.set'], + ...itemMap['parameter.reset'], + ...itemMap['enum.create'], + ...itemMap['trigger.drop'], + ...itemMap['index.drop'], + ...itemMap['constraint.drop'], + ...itemMap['table.create'], + ...itemMap['column.alter'], + ...itemMap['column.add'], + ...constraintAdds.filter(({ constraint }) => constraint.type === DatabaseConstraintType.PRIMARY_KEY), + ...constraintAdds.filter(({ constraint }) => constraint.type === DatabaseConstraintType.FOREIGN_KEY), + ...constraintAdds.filter(({ constraint }) => constraint.type === DatabaseConstraintType.UNIQUE), + ...constraintAdds.filter(({ constraint }) => constraint.type === DatabaseConstraintType.CHECK), + ...itemMap['index.create'], + ...itemMap['trigger.create'], + ...itemMap['column.drop'], + ...itemMap['table.drop'], + ...itemMap['enum.drop'], + ...itemMap['function.drop'], + ]; + + return { + items: orderedItems, + asSql: (options?: SchemaDiffToSqlOptions) => schemaDiffToSql(orderedItems, options), + }; +}; diff --git a/server/src/sql-tools/from-code/decorators/after-delete.decorator.ts b/server/src/sql-tools/from-code/decorators/after-delete.decorator.ts new file mode 100644 index 0000000000..7713c4b625 --- /dev/null +++ b/server/src/sql-tools/from-code/decorators/after-delete.decorator.ts @@ -0,0 +1,8 @@ +import { TriggerFunction, TriggerFunctionOptions } from 'src/sql-tools/from-code/decorators/trigger-function.decorator'; + +export const AfterDeleteTrigger = (options: Omit) => + TriggerFunction({ + timing: 'after', + actions: ['delete'], + ...options, + }); diff --git a/server/src/sql-tools/from-code/decorators/before-update.decorator.ts b/server/src/sql-tools/from-code/decorators/before-update.decorator.ts new file mode 100644 index 0000000000..03dad25ed0 --- /dev/null +++ b/server/src/sql-tools/from-code/decorators/before-update.decorator.ts @@ -0,0 +1,8 @@ +import { TriggerFunction, TriggerFunctionOptions } from 'src/sql-tools/from-code/decorators/trigger-function.decorator'; + +export const BeforeUpdateTrigger = (options: Omit) => + TriggerFunction({ + timing: 'before', + actions: ['update'], + ...options, + }); diff --git a/server/src/sql-tools/from-code/decorators/check.decorator.ts b/server/src/sql-tools/from-code/decorators/check.decorator.ts new file mode 100644 index 0000000000..7d046df0c3 --- /dev/null +++ b/server/src/sql-tools/from-code/decorators/check.decorator.ts @@ -0,0 +1,11 @@ +import { register } from 'src/sql-tools/from-code/register'; + +export type CheckOptions = { + name?: string; + expression: string; + synchronize?: boolean; +}; +export const Check = (options: CheckOptions): ClassDecorator => { + // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type + return (object: Function) => void register({ type: 'checkConstraint', item: { object, options } }); +}; diff --git a/server/src/sql-tools/from-code/decorators/column-index.decorator.ts b/server/src/sql-tools/from-code/decorators/column-index.decorator.ts new file mode 100644 index 0000000000..ab15292612 --- /dev/null +++ b/server/src/sql-tools/from-code/decorators/column-index.decorator.ts @@ -0,0 +1,16 @@ +import { register } from 'src/sql-tools/from-code/register'; +import { asOptions } from 'src/sql-tools/helpers'; + +export type ColumnIndexOptions = { + name?: string; + unique?: boolean; + expression?: string; + using?: string; + with?: string; + where?: string; + synchronize?: boolean; +}; +export const ColumnIndex = (options: string | ColumnIndexOptions = {}): PropertyDecorator => { + return (object: object, propertyName: string | symbol) => + void register({ type: 'columnIndex', item: { object, propertyName, options: asOptions(options) } }); +}; diff --git a/server/src/sql-tools/from-code/decorators/column.decorator.ts b/server/src/sql-tools/from-code/decorators/column.decorator.ts new file mode 100644 index 0000000000..74a83cbcf3 --- /dev/null +++ b/server/src/sql-tools/from-code/decorators/column.decorator.ts @@ -0,0 +1,30 @@ +import { register } from 'src/sql-tools/from-code/register'; +import { asOptions } from 'src/sql-tools/helpers'; +import { ColumnStorage, ColumnType, DatabaseEnum } from 'src/sql-tools/types'; + +export type ColumnValue = null | boolean | string | number | object | Date | (() => string); + +export type ColumnBaseOptions = { + name?: string; + primary?: boolean; + type?: ColumnType; + nullable?: boolean; + length?: number; + default?: ColumnValue; + comment?: string; + synchronize?: boolean; + storage?: ColumnStorage; + identity?: boolean; +}; + +export type ColumnOptions = ColumnBaseOptions & { + enum?: DatabaseEnum; + array?: boolean; + unique?: boolean; + uniqueConstraintName?: string; +}; + +export const Column = (options: string | ColumnOptions = {}): PropertyDecorator => { + return (object: object, propertyName: string | symbol) => + void register({ type: 'column', item: { object, propertyName, options: asOptions(options) } }); +}; diff --git a/server/src/sql-tools/from-code/decorators/configuration-parameter.decorator.ts b/server/src/sql-tools/from-code/decorators/configuration-parameter.decorator.ts new file mode 100644 index 0000000000..6a987884d1 --- /dev/null +++ b/server/src/sql-tools/from-code/decorators/configuration-parameter.decorator.ts @@ -0,0 +1,14 @@ +import { ColumnValue } from 'src/sql-tools/from-code/decorators/column.decorator'; +import { register } from 'src/sql-tools/from-code/register'; +import { ParameterScope } from 'src/sql-tools/types'; + +export type ConfigurationParameterOptions = { + name: string; + value: ColumnValue; + scope: ParameterScope; + synchronize?: boolean; +}; +export const ConfigurationParameter = (options: ConfigurationParameterOptions): ClassDecorator => { + // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type + return (object: Function) => void register({ type: 'configurationParameter', item: { object, options } }); +}; diff --git a/server/src/sql-tools/from-code/decorators/create-date-column.decorator.ts b/server/src/sql-tools/from-code/decorators/create-date-column.decorator.ts new file mode 100644 index 0000000000..8f81d59914 --- /dev/null +++ b/server/src/sql-tools/from-code/decorators/create-date-column.decorator.ts @@ -0,0 +1,9 @@ +import { Column, ColumnOptions } from 'src/sql-tools/from-code/decorators/column.decorator'; + +export const CreateDateColumn = (options: ColumnOptions = {}): PropertyDecorator => { + return Column({ + type: 'timestamp with time zone', + default: () => 'now()', + ...options, + }); +}; diff --git a/server/src/sql-tools/from-code/decorators/database.decorator.ts b/server/src/sql-tools/from-code/decorators/database.decorator.ts new file mode 100644 index 0000000000..3bcc464f74 --- /dev/null +++ b/server/src/sql-tools/from-code/decorators/database.decorator.ts @@ -0,0 +1,10 @@ +import { register } from 'src/sql-tools/from-code/register'; + +export type DatabaseOptions = { + name?: string; + synchronize?: boolean; +}; +export const Database = (options: DatabaseOptions): ClassDecorator => { + // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type + return (object: Function) => void register({ type: 'database', item: { object, options } }); +}; diff --git a/server/src/sql-tools/from-code/decorators/delete-date-column.decorator.ts b/server/src/sql-tools/from-code/decorators/delete-date-column.decorator.ts new file mode 100644 index 0000000000..518c4e76fc --- /dev/null +++ b/server/src/sql-tools/from-code/decorators/delete-date-column.decorator.ts @@ -0,0 +1,9 @@ +import { Column, ColumnOptions } from 'src/sql-tools/from-code/decorators/column.decorator'; + +export const DeleteDateColumn = (options: ColumnOptions = {}): PropertyDecorator => { + return Column({ + type: 'timestamp with time zone', + nullable: true, + ...options, + }); +}; diff --git a/server/src/sql-tools/from-code/decorators/extension.decorator.ts b/server/src/sql-tools/from-code/decorators/extension.decorator.ts new file mode 100644 index 0000000000..c43a18c16f --- /dev/null +++ b/server/src/sql-tools/from-code/decorators/extension.decorator.ts @@ -0,0 +1,11 @@ +import { register } from 'src/sql-tools/from-code/register'; +import { asOptions } from 'src/sql-tools/helpers'; + +export type ExtensionOptions = { + name: string; + synchronize?: boolean; +}; +export const Extension = (options: string | ExtensionOptions): ClassDecorator => { + // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type + return (object: Function) => void register({ type: 'extension', item: { object, options: asOptions(options) } }); +}; diff --git a/server/src/sql-tools/from-code/decorators/extensions.decorator.ts b/server/src/sql-tools/from-code/decorators/extensions.decorator.ts new file mode 100644 index 0000000000..9d3769a210 --- /dev/null +++ b/server/src/sql-tools/from-code/decorators/extensions.decorator.ts @@ -0,0 +1,15 @@ +import { register } from 'src/sql-tools/from-code/register'; +import { asOptions } from 'src/sql-tools/helpers'; + +export type ExtensionsOptions = { + name: string; + synchronize?: boolean; +}; +export const Extensions = (options: Array): ClassDecorator => { + // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type + return (object: Function) => { + for (const option of options) { + register({ type: 'extension', item: { object, options: asOptions(option) } }); + } + }; +}; 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 new file mode 100644 index 0000000000..070aa5cb51 --- /dev/null +++ b/server/src/sql-tools/from-code/decorators/foreign-key-column.decorator.ts @@ -0,0 +1,18 @@ +import { ColumnBaseOptions } from 'src/sql-tools/from-code/decorators/column.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; + constraintName?: string; + unique?: boolean; + uniqueConstraintName?: string; +}; + +export const ForeignKeyColumn = (target: () => object, options: ForeignKeyColumnOptions): PropertyDecorator => { + return (object: object, propertyName: string | symbol) => { + register({ type: 'foreignKeyColumn', item: { object, propertyName, options, target } }); + }; +}; diff --git a/server/src/sql-tools/from-code/decorators/generated-column.decorator.ts b/server/src/sql-tools/from-code/decorators/generated-column.decorator.ts new file mode 100644 index 0000000000..82d3131b5c --- /dev/null +++ b/server/src/sql-tools/from-code/decorators/generated-column.decorator.ts @@ -0,0 +1,37 @@ +import { Column, ColumnOptions, ColumnValue } from 'src/sql-tools/from-code/decorators/column.decorator'; +import { ColumnType } from 'src/sql-tools/types'; + +export type GeneratedColumnStrategy = 'uuid' | 'identity'; + +export type GenerateColumnOptions = Omit & { + strategy?: GeneratedColumnStrategy; +}; + +export const GeneratedColumn = ({ strategy = 'uuid', ...options }: GenerateColumnOptions): PropertyDecorator => { + let columnType: ColumnType | undefined; + let columnDefault: ColumnValue | undefined; + + switch (strategy) { + case 'uuid': { + columnType = 'uuid'; + columnDefault = () => 'uuid_generate_v4()'; + break; + } + + case 'identity': { + columnType = 'integer'; + options.identity = true; + break; + } + + default: { + throw new Error(`Unsupported strategy for @GeneratedColumn ${strategy}`); + } + } + + return Column({ + type: columnType, + default: columnDefault, + ...options, + }); +}; diff --git a/server/src/sql-tools/from-code/decorators/index.decorator.ts b/server/src/sql-tools/from-code/decorators/index.decorator.ts new file mode 100644 index 0000000000..cd76b5e36d --- /dev/null +++ b/server/src/sql-tools/from-code/decorators/index.decorator.ts @@ -0,0 +1,12 @@ +import { ColumnIndexOptions } from 'src/sql-tools/from-code/decorators/column-index.decorator'; +import { register } from 'src/sql-tools/from-code/register'; +import { asOptions } from 'src/sql-tools/helpers'; + +export type IndexOptions = ColumnIndexOptions & { + columns?: string[]; + synchronize?: boolean; +}; +export const Index = (options: string | IndexOptions = {}): ClassDecorator => { + // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type + return (object: Function) => void register({ type: 'index', item: { object, options: asOptions(options) } }); +}; diff --git a/server/src/sql-tools/from-code/decorators/primary-column.decorator.ts b/server/src/sql-tools/from-code/decorators/primary-column.decorator.ts new file mode 100644 index 0000000000..f702965675 --- /dev/null +++ b/server/src/sql-tools/from-code/decorators/primary-column.decorator.ts @@ -0,0 +1,3 @@ +import { Column, ColumnOptions } from 'src/sql-tools/from-code/decorators/column.decorator'; + +export const PrimaryColumn = (options: Omit = {}) => Column({ ...options, primary: true }); diff --git a/server/src/sql-tools/from-code/decorators/primary-generated-column.decorator.ts b/server/src/sql-tools/from-code/decorators/primary-generated-column.decorator.ts new file mode 100644 index 0000000000..9dc8ca6817 --- /dev/null +++ b/server/src/sql-tools/from-code/decorators/primary-generated-column.decorator.ts @@ -0,0 +1,4 @@ +import { GenerateColumnOptions, GeneratedColumn } from 'src/sql-tools/from-code/decorators/generated-column.decorator'; + +export const PrimaryGeneratedColumn = (options: Omit = {}) => + GeneratedColumn({ ...options, primary: true }); diff --git a/server/src/sql-tools/from-code/decorators/table.decorator.ts b/server/src/sql-tools/from-code/decorators/table.decorator.ts new file mode 100644 index 0000000000..589a88aa29 --- /dev/null +++ b/server/src/sql-tools/from-code/decorators/table.decorator.ts @@ -0,0 +1,14 @@ +import { register } from 'src/sql-tools/from-code/register'; +import { asOptions } from 'src/sql-tools/helpers'; + +export type TableOptions = { + name?: string; + primaryConstraintName?: string; + synchronize?: boolean; +}; + +/** Table comments here */ +export const Table = (options: string | TableOptions = {}): ClassDecorator => { + // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type + return (object: Function) => void register({ type: 'table', item: { object, options: asOptions(options) } }); +}; diff --git a/server/src/sql-tools/from-code/decorators/trigger-function.decorator.ts b/server/src/sql-tools/from-code/decorators/trigger-function.decorator.ts new file mode 100644 index 0000000000..68ea286474 --- /dev/null +++ b/server/src/sql-tools/from-code/decorators/trigger-function.decorator.ts @@ -0,0 +1,6 @@ +import { Trigger, TriggerOptions } from 'src/sql-tools/from-code/decorators/trigger.decorator'; +import { DatabaseFunction } from 'src/sql-tools/types'; + +export type TriggerFunctionOptions = Omit & { function: DatabaseFunction }; +export const TriggerFunction = (options: TriggerFunctionOptions) => + Trigger({ ...options, functionName: options.function.name }); diff --git a/server/src/sql-tools/from-code/decorators/trigger.decorator.ts b/server/src/sql-tools/from-code/decorators/trigger.decorator.ts new file mode 100644 index 0000000000..e0c0ccf3e4 --- /dev/null +++ b/server/src/sql-tools/from-code/decorators/trigger.decorator.ts @@ -0,0 +1,19 @@ +import { register } from 'src/sql-tools/from-code/register'; +import { TriggerAction, TriggerScope, TriggerTiming } from 'src/sql-tools/types'; + +export type TriggerOptions = { + name?: string; + timing: TriggerTiming; + actions: TriggerAction[]; + scope: TriggerScope; + functionName: string; + referencingNewTableAs?: string; + referencingOldTableAs?: string; + when?: string; + synchronize?: boolean; +}; + +export const Trigger = (options: TriggerOptions): ClassDecorator => { + // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type + return (object: Function) => void register({ type: 'trigger', item: { object, options } }); +}; diff --git a/server/src/sql-tools/from-code/decorators/unique.decorator.ts b/server/src/sql-tools/from-code/decorators/unique.decorator.ts new file mode 100644 index 0000000000..c7186d7296 --- /dev/null +++ b/server/src/sql-tools/from-code/decorators/unique.decorator.ts @@ -0,0 +1,11 @@ +import { register } from 'src/sql-tools/from-code/register'; + +export type UniqueOptions = { + name?: string; + columns: string[]; + synchronize?: boolean; +}; +export const Unique = (options: UniqueOptions): ClassDecorator => { + // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type + return (object: Function) => void register({ type: 'uniqueConstraint', item: { object, options } }); +}; diff --git a/server/src/sql-tools/from-code/decorators/update-date-column.decorator.ts b/server/src/sql-tools/from-code/decorators/update-date-column.decorator.ts new file mode 100644 index 0000000000..ddc7a6a1e8 --- /dev/null +++ b/server/src/sql-tools/from-code/decorators/update-date-column.decorator.ts @@ -0,0 +1,9 @@ +import { Column, ColumnOptions } from 'src/sql-tools/from-code/decorators/column.decorator'; + +export const UpdateDateColumn = (options: ColumnOptions = {}): PropertyDecorator => { + return Column({ + type: 'timestamp with time zone', + default: () => 'now()', + ...options, + }); +}; diff --git a/server/src/sql-tools/schema-from-decorators.spec.ts b/server/src/sql-tools/from-code/index.spec.ts similarity index 64% rename from server/src/sql-tools/schema-from-decorators.spec.ts rename to server/src/sql-tools/from-code/index.spec.ts index 6703277844..5306722c76 100644 --- a/server/src/sql-tools/schema-from-decorators.spec.ts +++ b/server/src/sql-tools/from-code/index.spec.ts @@ -1,16 +1,21 @@ import { readdirSync } from 'node:fs'; import { join } from 'node:path'; -import { reset, schemaFromDecorators } from 'src/sql-tools/schema-from-decorators'; +import { reset, schemaFromCode } from 'src/sql-tools/from-code'; import { describe, expect, it } from 'vitest'; -describe('schemaDiff', () => { +describe(schemaFromCode.name, () => { beforeEach(() => { reset(); }); it('should work', () => { - expect(schemaFromDecorators()).toEqual({ - name: 'public', + expect(schemaFromCode()).toEqual({ + name: 'postgres', + schemaName: 'public', + functions: [], + enums: [], + extensions: [], + parameters: [], tables: [], warnings: [], }); @@ -24,7 +29,7 @@ describe('schemaDiff', () => { const module = await import(filePath); expect(module.description).toBeDefined(); expect(module.schema).toBeDefined(); - expect(schemaFromDecorators(), module.description).toEqual(module.schema); + expect(schemaFromCode(), module.description).toEqual(module.schema); }); } }); diff --git a/server/src/sql-tools/from-code/index.ts b/server/src/sql-tools/from-code/index.ts new file mode 100644 index 0000000000..3c74d2763c --- /dev/null +++ b/server/src/sql-tools/from-code/index.ts @@ -0,0 +1,69 @@ +import 'reflect-metadata'; +import { processCheckConstraints } from 'src/sql-tools/from-code/processors/check-constraint.processor'; +import { processColumnIndexes } from 'src/sql-tools/from-code/processors/column-index.processor'; +import { processColumns } from 'src/sql-tools/from-code/processors/column.processor'; +import { processConfigurationParameters } from 'src/sql-tools/from-code/processors/configuration-parameter.processor'; +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 { 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'; +import { processTables } from 'src/sql-tools/from-code/processors/table.processor'; +import { processTriggers } from 'src/sql-tools/from-code/processors/trigger.processor'; +import { Processor, SchemaBuilder } from 'src/sql-tools/from-code/processors/type'; +import { processUniqueConstraints } from 'src/sql-tools/from-code/processors/unique-constraint.processor'; +import { getRegisteredItems, resetRegisteredItems } from 'src/sql-tools/from-code/register'; +import { DatabaseSchema } from 'src/sql-tools/types'; + +let initialized = false; +let schema: DatabaseSchema; + +export const reset = () => { + initialized = false; + resetRegisteredItems(); +}; + +const processors: Processor[] = [ + processDatabases, + processConfigurationParameters, + processEnums, + processExtensions, + processFunctions, + processTables, + processColumns, + processUniqueConstraints, + processCheckConstraints, + processPrimaryKeyConstraints, + processIndexes, + processColumnIndexes, + processForeignKeyConstraints, + processTriggers, +]; + +export const schemaFromCode = () => { + if (!initialized) { + const builder: SchemaBuilder = { + name: 'postgres', + schemaName: 'public', + tables: [], + functions: [], + enums: [], + extensions: [], + parameters: [], + warnings: [], + }; + + const items = getRegisteredItems(); + + for (const processor of processors) { + processor(builder, items); + } + + schema = { ...builder, tables: builder.tables.map(({ metadata: _, ...table }) => table) }; + initialized = true; + } + + return schema; +}; diff --git a/server/src/sql-tools/from-code/processors/check-constraint.processor.ts b/server/src/sql-tools/from-code/processors/check-constraint.processor.ts new file mode 100644 index 0000000000..d61ee18277 --- /dev/null +++ b/server/src/sql-tools/from-code/processors/check-constraint.processor.ts @@ -0,0 +1,26 @@ +import { onMissingTable, resolveTable } from 'src/sql-tools/from-code/processors/table.processor'; +import { Processor } from 'src/sql-tools/from-code/processors/type'; +import { asCheckConstraintName } from 'src/sql-tools/helpers'; +import { DatabaseConstraintType } from 'src/sql-tools/types'; + +export const processCheckConstraints: Processor = (builder, items) => { + for (const { + item: { object, options }, + } of items.filter((item) => item.type === 'checkConstraint')) { + const table = resolveTable(builder, object); + if (!table) { + onMissingTable(builder, '@Check', object); + continue; + } + + const tableName = table.name; + + table.constraints.push({ + type: DatabaseConstraintType.CHECK, + name: options.name || asCheckConstraintName(tableName, options.expression), + tableName, + expression: options.expression, + synchronize: options.synchronize ?? true, + }); + } +}; diff --git a/server/src/sql-tools/from-code/processors/column-index.processor.ts b/server/src/sql-tools/from-code/processors/column-index.processor.ts new file mode 100644 index 0000000000..0e40fa1ee3 --- /dev/null +++ b/server/src/sql-tools/from-code/processors/column-index.processor.ts @@ -0,0 +1,32 @@ +import { onMissingColumn, resolveColumn } from 'src/sql-tools/from-code/processors/column.processor'; +import { onMissingTable } from 'src/sql-tools/from-code/processors/table.processor'; +import { Processor } from 'src/sql-tools/from-code/processors/type'; +import { asIndexName } from 'src/sql-tools/helpers'; + +export const processColumnIndexes: Processor = (builder, items) => { + for (const { + item: { object, propertyName, options }, + } of items.filter((item) => item.type === 'columnIndex')) { + const { table, column } = resolveColumn(builder, object, propertyName); + if (!table) { + onMissingTable(builder, '@ColumnIndex', object); + continue; + } + + if (!column) { + onMissingColumn(builder, `@ColumnIndex`, object, propertyName); + continue; + } + + table.indexes.push({ + name: options.name || asIndexName(table.name, [column.name], options.where), + tableName: table.name, + unique: options.unique ?? false, + expression: options.expression, + using: options.using, + where: options.where, + columnNames: [column.name], + synchronize: options.synchronize ?? true, + }); + } +}; diff --git a/server/src/sql-tools/from-code/processors/column.processor.ts b/server/src/sql-tools/from-code/processors/column.processor.ts new file mode 100644 index 0000000000..37f3f5d082 --- /dev/null +++ b/server/src/sql-tools/from-code/processors/column.processor.ts @@ -0,0 +1,103 @@ +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, asUniqueConstraintName, fromColumnValue } from 'src/sql-tools/helpers'; +import { DatabaseColumn, DatabaseConstraintType } from 'src/sql-tools/types'; + +export const processColumns: Processor = (builder, items) => { + for (const { + type, + item: { object, propertyName, options }, + } of items.filter((item) => item.type === 'column' || item.type === 'foreignKeyColumn')) { + const table = resolveTable(builder, object.constructor); + if (!table) { + onMissingTable(builder, type === 'column' ? '@Column' : '@ForeignKeyColumn', object, propertyName); + continue; + } + + const columnName = options.name ?? String(propertyName); + const existingColumn = table.columns.find((column) => column.name === columnName); + if (existingColumn) { + // TODO log warnings if column name is not unique + continue; + } + + const tableName = table.name; + + let defaultValue = fromColumnValue(options.default); + let nullable = options.nullable ?? false; + + // map `{ default: null }` to `{ nullable: true }` + if (defaultValue === null) { + nullable = true; + defaultValue = undefined; + } + + const isEnum = !!(options as ColumnOptions).enum; + + const column: DatabaseColumn = { + name: columnName, + tableName, + primary: options.primary ?? false, + default: defaultValue, + nullable, + isArray: (options as ColumnOptions).array ?? false, + length: options.length, + type: isEnum ? 'enum' : options.type || 'character varying', + enumName: isEnum ? (options as ColumnOptions).enum!.name : undefined, + comment: options.comment, + storage: options.storage, + identity: options.identity, + synchronize: options.synchronize ?? true, + }; + + writeMetadata(object, propertyName, { name: column.name, options }); + + table.columns.push(column); + + if (type === 'column' && !options.primary && options.unique) { + table.constraints.push({ + type: DatabaseConstraintType.UNIQUE, + name: options.uniqueConstraintName || asUniqueConstraintName(table.name, [column.name]), + tableName: table.name, + columnNames: [column.name], + synchronize: options.synchronize ?? true, + }); + } + } +}; + +type ColumnMetadata = { name: string; options: ColumnOptions }; + +export const resolveColumn = (builder: SchemaBuilder, object: object, propertyName: string | symbol) => { + const table = resolveTable(builder, object.constructor); + if (!table) { + return {}; + } + + const metadata = readMetadata(object, propertyName); + if (!metadata) { + return { table }; + } + + const column = table.columns.find((column) => column.name === metadata.name); + return { table, column }; +}; + +export const onMissingColumn = ( + builder: SchemaBuilder, + context: string, + object: object, + propertyName?: symbol | string, +) => { + const label = object.constructor.name + (propertyName ? '.' + String(propertyName) : ''); + builder.warnings.push(`[${context}] Unable to find column (${label})`); +}; + +const METADATA_KEY = asMetadataKey('table-metadata'); + +const writeMetadata = (object: object, propertyName: symbol | string, metadata: ColumnMetadata) => + Reflect.defineMetadata(METADATA_KEY, metadata, object, propertyName); + +const readMetadata = (object: object, propertyName: symbol | string): ColumnMetadata | undefined => + Reflect.getMetadata(METADATA_KEY, object, propertyName); diff --git a/server/src/sql-tools/from-code/processors/configuration-parameter.processor.ts b/server/src/sql-tools/from-code/processors/configuration-parameter.processor.ts new file mode 100644 index 0000000000..493214e5b8 --- /dev/null +++ b/server/src/sql-tools/from-code/processors/configuration-parameter.processor.ts @@ -0,0 +1,16 @@ +import { Processor } from 'src/sql-tools/from-code/processors/type'; +import { fromColumnValue } from 'src/sql-tools/helpers'; + +export const processConfigurationParameters: Processor = (builder, items) => { + for (const { + item: { options }, + } of items.filter((item) => item.type === 'configurationParameter')) { + builder.parameters.push({ + databaseName: builder.name, + name: options.name, + value: fromColumnValue(options.value), + scope: options.scope, + synchronize: options.synchronize ?? true, + }); + } +}; diff --git a/server/src/sql-tools/from-code/processors/database.processor.ts b/server/src/sql-tools/from-code/processors/database.processor.ts new file mode 100644 index 0000000000..9b0662f7e0 --- /dev/null +++ b/server/src/sql-tools/from-code/processors/database.processor.ts @@ -0,0 +1,10 @@ +import { Processor } from 'src/sql-tools/from-code/processors/type'; +import { asSnakeCase } from 'src/sql-tools/helpers'; + +export const processDatabases: Processor = (builder, items) => { + for (const { + item: { object, options }, + } of items.filter((item) => item.type === 'database')) { + builder.name = options.name || asSnakeCase(object.name); + } +}; diff --git a/server/src/sql-tools/from-code/processors/enum.processor.ts b/server/src/sql-tools/from-code/processors/enum.processor.ts new file mode 100644 index 0000000000..d6d19ec025 --- /dev/null +++ b/server/src/sql-tools/from-code/processors/enum.processor.ts @@ -0,0 +1,8 @@ +import { Processor } from 'src/sql-tools/from-code/processors/type'; + +export const processEnums: Processor = (builder, items) => { + for (const { item } of items.filter((item) => item.type === 'enum')) { + // TODO log warnings if enum name is not unique + builder.enums.push(item); + } +}; diff --git a/server/src/sql-tools/from-code/processors/extension.processor.ts b/server/src/sql-tools/from-code/processors/extension.processor.ts new file mode 100644 index 0000000000..4b12054aa3 --- /dev/null +++ b/server/src/sql-tools/from-code/processors/extension.processor.ts @@ -0,0 +1,12 @@ +import { Processor } from 'src/sql-tools/from-code/processors/type'; + +export const processExtensions: Processor = (builder, items) => { + for (const { + item: { options }, + } of items.filter((item) => item.type === 'extension')) { + builder.extensions.push({ + name: options.name, + synchronize: options.synchronize ?? true, + }); + } +}; 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-constriant.processor.ts new file mode 100644 index 0000000000..784a8b8e99 --- /dev/null +++ b/server/src/sql-tools/from-code/processors/foreign-key-constriant.processor.ts @@ -0,0 +1,59 @@ +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 { asForeignKeyConstraintName, asRelationKeyConstraintName } from 'src/sql-tools/helpers'; +import { DatabaseActionType, DatabaseConstraintType } from 'src/sql-tools/types'; + +export const processForeignKeyConstraints: Processor = (builder, items) => { + for (const { + item: { object, propertyName, options, target }, + } of items.filter((item) => item.type === 'foreignKeyColumn')) { + const { table, column } = resolveColumn(builder, object, propertyName); + if (!table) { + onMissingTable(builder, '@ForeignKeyColumn', object); + continue; + } + + if (!column) { + // should be impossible since they are pre-created in `column.processor.ts` + onMissingColumn(builder, '@ForeignKeyColumn', object, propertyName); + continue; + } + + const referenceTable = resolveTable(builder, target()); + if (!referenceTable) { + onMissingTable(builder, '@ForeignKeyColumn', object, propertyName); + continue; + } + + const columnNames = [column.name]; + const referenceColumns = referenceTable.columns.filter((column) => column.primary); + + // infer FK column type from reference table + if (referenceColumns.length === 1) { + column.type = referenceColumns[0].type; + } + + table.constraints.push({ + name: options.constraintName || asForeignKeyConstraintName(table.name, columnNames), + tableName: table.name, + columnNames, + type: DatabaseConstraintType.FOREIGN_KEY, + referenceTableName: referenceTable.name, + referenceColumnNames: referenceColumns.map((column) => column.name), + onUpdate: options.onUpdate as DatabaseActionType, + onDelete: options.onDelete as DatabaseActionType, + synchronize: options.synchronize ?? true, + }); + + if (options.unique) { + table.constraints.push({ + name: options.uniqueConstraintName || asRelationKeyConstraintName(table.name, columnNames), + tableName: table.name, + columnNames, + type: DatabaseConstraintType.UNIQUE, + synchronize: options.synchronize ?? true, + }); + } + } +}; diff --git a/server/src/sql-tools/from-code/processors/function.processor.ts b/server/src/sql-tools/from-code/processors/function.processor.ts new file mode 100644 index 0000000000..cbd9c87abc --- /dev/null +++ b/server/src/sql-tools/from-code/processors/function.processor.ts @@ -0,0 +1,8 @@ +import { Processor } from 'src/sql-tools/from-code/processors/type'; + +export const processFunctions: Processor = (builder, items) => { + for (const { item } of items.filter((item) => item.type === 'function')) { + // TODO log warnings if function name is not unique + builder.functions.push(item); + } +}; diff --git a/server/src/sql-tools/from-code/processors/index.processor.ts b/server/src/sql-tools/from-code/processors/index.processor.ts new file mode 100644 index 0000000000..3625bf9784 --- /dev/null +++ b/server/src/sql-tools/from-code/processors/index.processor.ts @@ -0,0 +1,27 @@ +import { onMissingTable, resolveTable } from 'src/sql-tools/from-code/processors/table.processor'; +import { Processor } from 'src/sql-tools/from-code/processors/type'; +import { asIndexName } from 'src/sql-tools/helpers'; + +export const processIndexes: Processor = (builder, items) => { + for (const { + item: { object, options }, + } of items.filter((item) => item.type === 'index')) { + const table = resolveTable(builder, object); + if (!table) { + onMissingTable(builder, '@Check', object); + continue; + } + + table.indexes.push({ + name: options.name || asIndexName(table.name, options.columns, options.where), + tableName: table.name, + unique: options.unique ?? false, + expression: options.expression, + using: options.using, + with: options.with, + where: options.where, + columnNames: options.columns, + synchronize: options.synchronize ?? true, + }); + } +}; diff --git a/server/src/sql-tools/from-code/processors/primary-key-contraint.processor.ts b/server/src/sql-tools/from-code/processors/primary-key-contraint.processor.ts new file mode 100644 index 0000000000..f123f2e495 --- /dev/null +++ b/server/src/sql-tools/from-code/processors/primary-key-contraint.processor.ts @@ -0,0 +1,24 @@ +import { Processor } from 'src/sql-tools/from-code/processors/type'; +import { asPrimaryKeyConstraintName } from 'src/sql-tools/helpers'; +import { DatabaseConstraintType } from 'src/sql-tools/types'; + +export const processPrimaryKeyConstraints: Processor = (builder) => { + for (const table of builder.tables) { + const columnNames: string[] = []; + + for (const column of table.columns) { + if (column.primary) { + columnNames.push(column.name); + } + } + if (columnNames.length > 0) { + table.constraints.push({ + type: DatabaseConstraintType.PRIMARY_KEY, + name: table.metadata.options.primaryConstraintName || asPrimaryKeyConstraintName(table.name, columnNames), + tableName: table.name, + columnNames, + synchronize: table.metadata.options.synchronize ?? true, + }); + } + } +}; diff --git a/server/src/sql-tools/from-code/processors/table.processor.ts b/server/src/sql-tools/from-code/processors/table.processor.ts new file mode 100644 index 0000000000..eb4b414576 --- /dev/null +++ b/server/src/sql-tools/from-code/processors/table.processor.ts @@ -0,0 +1,51 @@ +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'; + +export const processTables: Processor = (builder, items) => { + for (const { + item: { options, object }, + } of items.filter((item) => item.type === 'table')) { + const tableName = options.name || asSnakeCase(object.name); + + writeMetadata(object, { name: tableName, options }); + + builder.tables.push({ + name: tableName, + columns: [], + constraints: [], + indexes: [], + triggers: [], + synchronize: options.synchronize ?? true, + metadata: { options, object }, + }); + } +}; + +export const resolveTable = (builder: SchemaBuilder, object: object) => { + const metadata = readMetadata(object); + if (!metadata) { + return; + } + + return builder.tables.find((table) => table.name === metadata.name); +}; + +export const onMissingTable = ( + builder: SchemaBuilder, + context: string, + object: object, + propertyName?: symbol | string, +) => { + const label = object.constructor.name + (propertyName ? '.' + String(propertyName) : ''); + builder.warnings.push(`[${context}] Unable to find table (${label})`); +}; + +const METADATA_KEY = asMetadataKey('table-metadata'); + +type TableMetadata = { name: string; options: TableOptions }; + +const readMetadata = (object: object): TableMetadata | undefined => Reflect.getMetadata(METADATA_KEY, object); + +const writeMetadata = (object: object, metadata: TableMetadata): void => + Reflect.defineMetadata(METADATA_KEY, metadata, object); diff --git a/server/src/sql-tools/from-code/processors/trigger.processor.ts b/server/src/sql-tools/from-code/processors/trigger.processor.ts new file mode 100644 index 0000000000..2f4cc04326 --- /dev/null +++ b/server/src/sql-tools/from-code/processors/trigger.processor.ts @@ -0,0 +1,28 @@ +import { onMissingTable, resolveTable } from 'src/sql-tools/from-code/processors/table.processor'; +import { Processor } from 'src/sql-tools/from-code/processors/type'; +import { asTriggerName } from 'src/sql-tools/helpers'; + +export const processTriggers: Processor = (builder, items) => { + for (const { + item: { object, options }, + } of items.filter((item) => item.type === 'trigger')) { + const table = resolveTable(builder, object); + if (!table) { + onMissingTable(builder, '@Trigger', object); + continue; + } + + table.triggers.push({ + name: options.name || asTriggerName(table.name, options), + tableName: table.name, + timing: options.timing, + actions: options.actions, + when: options.when, + scope: options.scope, + referencingNewTableAs: options.referencingNewTableAs, + referencingOldTableAs: options.referencingOldTableAs, + functionName: options.functionName, + synchronize: options.synchronize ?? true, + }); + } +}; diff --git a/server/src/sql-tools/from-code/processors/type.ts b/server/src/sql-tools/from-code/processors/type.ts new file mode 100644 index 0000000000..5a69efbcf0 --- /dev/null +++ b/server/src/sql-tools/from-code/processors/type.ts @@ -0,0 +1,9 @@ +import { TableOptions } from 'src/sql-tools/from-code/decorators/table.decorator'; +import { RegisterItem } from 'src/sql-tools/from-code/register-item'; +import { DatabaseSchema, DatabaseTable } from 'src/sql-tools/types'; + +// eslint-disable-next-line @typescript-eslint/no-unsafe-function-type +export type TableWithMetadata = DatabaseTable & { metadata: { options: TableOptions; object: Function } }; +export type SchemaBuilder = Omit & { tables: TableWithMetadata[] }; + +export type Processor = (builder: SchemaBuilder, items: RegisterItem[]) => void; diff --git a/server/src/sql-tools/from-code/processors/unique-constraint.processor.ts b/server/src/sql-tools/from-code/processors/unique-constraint.processor.ts new file mode 100644 index 0000000000..74c0504f7e --- /dev/null +++ b/server/src/sql-tools/from-code/processors/unique-constraint.processor.ts @@ -0,0 +1,27 @@ +import { onMissingTable, resolveTable } from 'src/sql-tools/from-code/processors/table.processor'; +import { Processor } from 'src/sql-tools/from-code/processors/type'; +import { asUniqueConstraintName } from 'src/sql-tools/helpers'; +import { DatabaseConstraintType } from 'src/sql-tools/types'; + +export const processUniqueConstraints: Processor = (builder, items) => { + for (const { + item: { object, options }, + } of items.filter((item) => item.type === 'uniqueConstraint')) { + const table = resolveTable(builder, object); + if (!table) { + onMissingTable(builder, '@Unique', object); + continue; + } + + const tableName = table.name; + const columnNames = options.columns; + + table.constraints.push({ + type: DatabaseConstraintType.UNIQUE, + name: options.name || asUniqueConstraintName(tableName, columnNames), + tableName, + columnNames, + synchronize: options.synchronize ?? true, + }); + } +}; diff --git a/server/src/sql-tools/from-code/register-enum.ts b/server/src/sql-tools/from-code/register-enum.ts new file mode 100644 index 0000000000..e2415cebff --- /dev/null +++ b/server/src/sql-tools/from-code/register-enum.ts @@ -0,0 +1,20 @@ +import { register } from 'src/sql-tools/from-code/register'; +import { DatabaseEnum } from 'src/sql-tools/types'; + +export type EnumOptions = { + name: string; + values: string[]; + synchronize?: boolean; +}; + +export const registerEnum = (options: EnumOptions) => { + const item: DatabaseEnum = { + name: options.name, + values: options.values, + synchronize: options.synchronize ?? true, + }; + + register({ type: 'enum', item }); + + return item; +}; diff --git a/server/src/sql-tools/from-code/register-function.ts b/server/src/sql-tools/from-code/register-function.ts new file mode 100644 index 0000000000..69e1a0f8f3 --- /dev/null +++ b/server/src/sql-tools/from-code/register-function.ts @@ -0,0 +1,29 @@ +import { register } from 'src/sql-tools/from-code/register'; +import { asFunctionExpression } from 'src/sql-tools/helpers'; +import { ColumnType, DatabaseFunction } from 'src/sql-tools/types'; + +export type FunctionOptions = { + name: string; + arguments?: string[]; + returnType: ColumnType | string; + language?: 'SQL' | 'PLPGSQL'; + behavior?: 'immutable' | 'stable' | 'volatile'; + parallel?: 'safe' | 'unsafe' | 'restricted'; + strict?: boolean; + synchronize?: boolean; +} & ({ body: string } | { return: string }); + +export const registerFunction = (options: FunctionOptions) => { + const name = options.name; + const expression = asFunctionExpression(options); + + const item: DatabaseFunction = { + name, + expression, + synchronize: options.synchronize ?? true, + }; + + register({ type: 'function', item }); + + return item; +}; diff --git a/server/src/sql-tools/from-code/register-item.ts b/server/src/sql-tools/from-code/register-item.ts new file mode 100644 index 0000000000..08200cbc4f --- /dev/null +++ b/server/src/sql-tools/from-code/register-item.ts @@ -0,0 +1,31 @@ +import { CheckOptions } from 'src/sql-tools/from-code/decorators/check.decorator'; +import { ColumnIndexOptions } from 'src/sql-tools/from-code/decorators/column-index.decorator'; +import { ColumnOptions } from 'src/sql-tools/from-code/decorators/column.decorator'; +import { ConfigurationParameterOptions } from 'src/sql-tools/from-code/decorators/configuration-parameter.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 { 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'; +import { UniqueOptions } from 'src/sql-tools/from-code/decorators/unique.decorator'; +import { DatabaseEnum, DatabaseFunction } from 'src/sql-tools/types'; + +// eslint-disable-next-line @typescript-eslint/no-unsafe-function-type +export type ClassBased = { object: Function } & T; +export type PropertyBased = { object: object; propertyName: string | symbol } & T; +export type RegisterItem = + | { type: 'database'; item: ClassBased<{ options: DatabaseOptions }> } + | { type: 'table'; item: ClassBased<{ options: TableOptions }> } + | { type: 'index'; item: ClassBased<{ options: IndexOptions }> } + | { type: 'uniqueConstraint'; item: ClassBased<{ options: UniqueOptions }> } + | { type: 'checkConstraint'; item: ClassBased<{ options: CheckOptions }> } + | { type: 'column'; item: PropertyBased<{ options: ColumnOptions }> } + | { type: 'columnIndex'; item: PropertyBased<{ options: ColumnIndexOptions }> } + | { type: 'function'; item: DatabaseFunction } + | { type: 'enum'; item: DatabaseEnum } + | { 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 }> }; +export type RegisterItemType = Extract['item']; diff --git a/server/src/sql-tools/from-code/register.ts b/server/src/sql-tools/from-code/register.ts new file mode 100644 index 0000000000..824af28c52 --- /dev/null +++ b/server/src/sql-tools/from-code/register.ts @@ -0,0 +1,11 @@ +import { RegisterItem } from 'src/sql-tools/from-code/register-item'; + +const items: RegisterItem[] = []; + +export const register = (item: RegisterItem) => void items.push(item); + +export const getRegisteredItems = () => items; + +export const resetRegisteredItems = () => { + items.length = 0; +}; diff --git a/server/src/sql-tools/schema-from-database.ts b/server/src/sql-tools/from-database/index.ts similarity index 67% rename from server/src/sql-tools/schema-from-database.ts rename to server/src/sql-tools/from-database/index.ts index fe7af6b623..3c66788670 100644 --- a/server/src/sql-tools/schema-from-database.ts +++ b/server/src/sql-tools/from-database/index.ts @@ -1,16 +1,22 @@ -import { Kysely, sql } from 'kysely'; +import { Kysely, QueryResult, sql } from 'kysely'; import { PostgresJSDialect } from 'kysely-postgres-js'; import { jsonArrayFrom } from 'kysely/helpers/postgres'; import { Sql } from 'postgres'; +import { parseTriggerType } from 'src/sql-tools/helpers'; import { + ColumnType, DatabaseActionType, DatabaseClient, DatabaseColumn, - DatabaseColumnType, DatabaseConstraintType, + DatabaseEnum, + DatabaseExtension, + DatabaseFunction, + DatabaseParameter, DatabaseSchema, DatabaseTable, LoadSchemaOptions, + ParameterScope, PostgresDB, } from 'src/sql-tools/types'; @@ -28,16 +34,66 @@ export const schemaFromDatabase = async (postgres: Sql, options: LoadSchemaOptio const schemaName = options.schemaName || 'public'; const tablesMap: Record = {}; - const [tables, columns, indexes, constraints, enums] = await Promise.all([ + const [ + databaseName, + tables, + columns, + indexes, + constraints, + enums, + routines, + extensions, + triggers, + parameters, + comments, + ] = await Promise.all([ + getDatabaseName(db), getTables(db, schemaName), getTableColumns(db, schemaName), getTableIndexes(db, schemaName), getTableConstraints(db, schemaName), getUserDefinedEnums(db, schemaName), + getRoutines(db, schemaName), + getExtensions(db), + getTriggers(db, schemaName), + getParameters(db), + getObjectComments(db), ]); + const schemaEnums: DatabaseEnum[] = []; + const schemaFunctions: DatabaseFunction[] = []; + const schemaExtensions: DatabaseExtension[] = []; + const schemaParameters: DatabaseParameter[] = []; + const enumMap = Object.fromEntries(enums.map((e) => [e.name, e.values])); + for (const { name } of extensions) { + schemaExtensions.push({ name, synchronize: true }); + } + + for (const { name, values } of enums) { + schemaEnums.push({ name, values, synchronize: true }); + } + + for (const parameter of parameters) { + schemaParameters.push({ + name: parameter.name, + value: parameter.value, + databaseName, + scope: parameter.scope as ParameterScope, + synchronize: true, + }); + } + + for (const { name, expression } of routines) { + schemaFunctions.push({ + name, + // TODO read expression from the overrides table + expression, + synchronize: true, + }); + } + // add tables for (const table of tables) { const tableName = table.table_name; @@ -49,6 +105,7 @@ export const schemaFromDatabase = async (postgres: Sql, options: LoadSchemaOptio name: table.table_name, columns: [], indexes: [], + triggers: [], constraints: [], synchronize: true, }; @@ -64,13 +121,14 @@ export const schemaFromDatabase = async (postgres: Sql, options: LoadSchemaOptio const columnName = column.column_name; const item: DatabaseColumn = { - type: column.data_type as DatabaseColumnType, + type: column.data_type as ColumnType, name: columnName, tableName: column.table_name, nullable: column.is_nullable === 'YES', isArray: column.array_type !== null, numericPrecision: column.numeric_precision ?? undefined, numericScale: column.numeric_scale ?? undefined, + length: column.character_maximum_length ?? undefined, default: column.column_default ?? undefined, synchronize: true, }; @@ -84,7 +142,7 @@ export const schemaFromDatabase = async (postgres: Sql, options: LoadSchemaOptio warn(`Unable to find type for ${columnLabel} (ARRAY)`); continue; } - item.type = column.array_type as DatabaseColumnType; + item.type = column.array_type as ColumnType; break; } @@ -97,7 +155,6 @@ export const schemaFromDatabase = async (postgres: Sql, options: LoadSchemaOptio item.type = 'enum'; item.enumName = column.udt_name; - item.enumValues = enumMap[column.udt_name]; break; } } @@ -201,10 +258,50 @@ export const schemaFromDatabase = async (postgres: Sql, options: LoadSchemaOptio } } + // add triggers to tables + for (const trigger of triggers) { + const table = tablesMap[trigger.table_name]; + if (!table) { + continue; + } + + table.triggers.push({ + name: trigger.name, + tableName: trigger.table_name, + functionName: trigger.function_name, + referencingNewTableAs: trigger.referencing_new_table_as ?? undefined, + referencingOldTableAs: trigger.referencing_old_table_as ?? undefined, + when: trigger.when_expression, + synchronize: true, + ...parseTriggerType(trigger.type), + }); + } + + for (const comment of comments) { + if (comment.object_type === 'r') { + const table = tablesMap[comment.object_name]; + if (!table) { + continue; + } + + if (comment.column_name) { + const column = table.columns.find(({ name }) => name === comment.column_name); + if (column) { + column.comment = comment.value; + } + } + } + } + await db.destroy(); return { - name: schemaName, + name: databaseName, + schemaName, + parameters: schemaParameters, + functions: schemaFunctions, + enums: schemaEnums, + extensions: schemaExtensions, tables: Object.values(tablesMap), warnings, }; @@ -237,6 +334,11 @@ const asDatabaseAction = (action: string) => { } }; +const getDatabaseName = async (db: DatabaseClient) => { + const result = (await sql`SELECT current_database() as name`.execute(db)) as QueryResult<{ name: string }>; + return result.rows[0].name; +}; + const getTables = (db: DatabaseClient, schemaName: string) => { return db .selectFrom('information_schema.tables') @@ -246,27 +348,6 @@ const getTables = (db: DatabaseClient, schemaName: string) => { .execute(); }; -const getUserDefinedEnums = async (db: DatabaseClient, schemaName: string) => { - const items = await db - .selectFrom('pg_type') - .innerJoin('pg_namespace', (join) => - join.onRef('pg_namespace.oid', '=', 'pg_type.typnamespace').on('pg_namespace.nspname', '=', schemaName), - ) - .where('typtype', '=', sql.lit('e')) - .select((eb) => [ - 'pg_type.typname as name', - jsonArrayFrom( - eb.selectFrom('pg_enum as e').select(['e.enumlabel as value']).whereRef('e.enumtypid', '=', 'pg_type.oid'), - ).as('values'), - ]) - .execute(); - - return items.map((item) => ({ - name: item.name, - values: item.values.map(({ value }) => value), - })); -}; - const getTableColumns = (db: DatabaseClient, schemaName: string) => { return db .selectFrom('information_schema.columns as c') @@ -290,6 +371,7 @@ const getTableColumns = (db: DatabaseClient, schemaName: string) => { 'c.data_type', 'c.column_default', 'c.is_nullable', + 'c.character_maximum_length', // number types 'c.numeric_precision', @@ -392,3 +474,103 @@ const getTableConstraints = (db: DatabaseClient, schemaName: string) => { .where('pg_namespace.nspname', '=', schemaName) .execute(); }; + +const getUserDefinedEnums = async (db: DatabaseClient, schemaName: string) => { + const items = await db + .selectFrom('pg_type') + .innerJoin('pg_namespace', (join) => + join.onRef('pg_namespace.oid', '=', 'pg_type.typnamespace').on('pg_namespace.nspname', '=', schemaName), + ) + .where('typtype', '=', sql.lit('e')) + .select((eb) => [ + 'pg_type.typname as name', + jsonArrayFrom( + eb.selectFrom('pg_enum as e').select(['e.enumlabel as value']).whereRef('e.enumtypid', '=', 'pg_type.oid'), + ).as('values'), + ]) + .execute(); + + return items.map((item) => ({ + name: item.name, + values: item.values.map(({ value }) => value), + })); +}; + +const getRoutines = async (db: DatabaseClient, schemaName: string) => { + return db + .selectFrom('pg_proc as p') + .innerJoin('pg_namespace', 'pg_namespace.oid', 'p.pronamespace') + .leftJoin('pg_depend as d', (join) => join.onRef('d.objid', '=', 'p.oid').on('d.deptype', '=', sql.lit('e'))) + .where('d.objid', 'is', sql.lit(null)) + .where('p.prokind', '=', sql.lit('f')) + .where('pg_namespace.nspname', '=', schemaName) + .select((eb) => [ + 'p.proname as name', + eb.fn('pg_get_function_identity_arguments', ['p.oid']).as('arguments'), + eb.fn('pg_get_functiondef', ['p.oid']).as('expression'), + ]) + .execute(); +}; + +const getExtensions = async (db: DatabaseClient) => { + return ( + db + .selectFrom('pg_catalog.pg_extension') + // .innerJoin('pg_namespace', 'pg_namespace.oid', 'pg_catalog.pg_extension.extnamespace') + // .where('pg_namespace.nspname', '=', schemaName) + .select(['extname as name', 'extversion as version']) + .execute() + ); +}; + +const getTriggers = async (db: Kysely, schemaName: string) => { + return db + .selectFrom('pg_trigger as t') + .innerJoin('pg_proc as p', 't.tgfoid', 'p.oid') + .innerJoin('pg_namespace as n', 'p.pronamespace', 'n.oid') + .innerJoin('pg_class as c', 't.tgrelid', 'c.oid') + .select((eb) => [ + 't.tgname as name', + 't.tgenabled as enabled', + 't.tgtype as type', + 't.tgconstraint as _constraint', + 't.tgdeferrable as is_deferrable', + 't.tginitdeferred as is_initially_deferred', + 't.tgargs as arguments', + 't.tgoldtable as referencing_old_table_as', + 't.tgnewtable as referencing_new_table_as', + eb.fn('pg_get_expr', ['t.tgqual', 't.tgrelid']).as('when_expression'), + 'p.proname as function_name', + 'c.relname as table_name', + ]) + .where('t.tgisinternal', '=', false) // Exclude internal system triggers + .where('n.nspname', '=', schemaName) + .execute(); +}; + +const getParameters = async (db: Kysely) => { + return db + .selectFrom('pg_settings') + .where('source', 'in', [sql.lit('database'), sql.lit('user')]) + .select(['name', 'setting as value', 'source as scope']) + .execute(); +}; + +const getObjectComments = async (db: Kysely) => { + return db + .selectFrom('pg_description as d') + .innerJoin('pg_class as c', 'd.objoid', 'c.oid') + .leftJoin('pg_attribute as a', (join) => + join.onRef('a.attrelid', '=', 'c.oid').onRef('a.attnum', '=', 'd.objsubid'), + ) + .select([ + 'c.relname as object_name', + 'c.relkind as object_type', + 'd.description as value', + 'a.attname as column_name', + ]) + .where('d.description', 'is not', null) + .orderBy('object_type') + .orderBy('object_name') + .execute(); +}; diff --git a/server/src/sql-tools/helpers.ts b/server/src/sql-tools/helpers.ts new file mode 100644 index 0000000000..364b695194 --- /dev/null +++ b/server/src/sql-tools/helpers.ts @@ -0,0 +1,268 @@ +import { createHash } from 'node:crypto'; +import { ColumnValue } from 'src/sql-tools/from-code/decorators/column.decorator'; +import { TriggerOptions } from 'src/sql-tools/from-code/decorators/trigger.decorator'; +import { FunctionOptions } from 'src/sql-tools/from-code/register-function'; +import { + Comparer, + DatabaseColumn, + DiffOptions, + SchemaDiff, + TriggerAction, + TriggerScope, + TriggerTiming, +} from 'src/sql-tools/types'; + +export const asMetadataKey = (name: string) => `sql-tools:${name}`; + +export const asSnakeCase = (name: string): string => name.replaceAll(/([a-z])([A-Z])/g, '$1_$2').toLowerCase(); +// match TypeORM +export const asKey = (prefix: string, tableName: string, values: string[]) => + (prefix + sha1(`${tableName}_${values.toSorted().join('_')}`)).slice(0, 30); +export const asPrimaryKeyConstraintName = (table: string, columns: string[]) => asKey('PK_', table, columns); +export const asForeignKeyConstraintName = (table: string, columns: string[]) => asKey('FK_', table, columns); +export const asTriggerName = (table: string, trigger: TriggerOptions) => + asKey('TR_', table, [...trigger.actions, trigger.scope, trigger.timing, trigger.functionName]); +export const asRelationKeyConstraintName = (table: string, columns: string[]) => asKey('REL_', table, columns); +export const asUniqueConstraintName = (table: string, columns: string[]) => asKey('UQ_', table, columns); +export const asCheckConstraintName = (table: string, expression: string) => asKey('CHK_', table, [expression]); +export const asIndexName = (table: string, columns: string[] | undefined, where: string | undefined) => { + const items: string[] = []; + for (const columnName of columns ?? []) { + items.push(columnName); + } + + if (where) { + items.push(where); + } + + return asKey('IDX_', table, items); +}; + +export const asOptions = (options: string | T): T => { + if (typeof options === 'string') { + return { name: options } as T; + } + + return options; +}; + +export const asFunctionExpression = (options: FunctionOptions) => { + const name = options.name; + const sql: string[] = [ + `CREATE OR REPLACE FUNCTION ${name}(${(options.arguments || []).join(', ')})`, + `RETURNS ${options.returnType}`, + ]; + + const flags = [ + options.parallel ? `PARALLEL ${options.parallel.toUpperCase()}` : undefined, + options.strict ? 'STRICT' : undefined, + options.behavior ? options.behavior.toUpperCase() : undefined, + `LANGUAGE ${options.language ?? 'SQL'}`, + ].filter((x) => x !== undefined); + + if (flags.length > 0) { + sql.push(flags.join(' ')); + } + + if ('return' in options) { + sql.push(` RETURN ${options.return}`); + } + + if ('body' in options) { + sql.push( + // + `AS $$`, + ' ' + options.body.trim(), + `$$;`, + ); + } + + return sql.join('\n ').trim(); +}; + +export const sha1 = (value: string) => createHash('sha1').update(value).digest('hex'); +export const hasMask = (input: number, mask: number) => (input & mask) === mask; + +export const parseTriggerType = (type: number) => { + // eslint-disable-next-line unicorn/prefer-math-trunc + const scope: TriggerScope = hasMask(type, 1 << 0) ? 'row' : 'statement'; + + let timing: TriggerTiming = 'after'; + const timingMasks: Array<{ mask: number; value: TriggerTiming }> = [ + { mask: 1 << 1, value: 'before' }, + { mask: 1 << 6, value: 'instead of' }, + ]; + + for (const { mask, value } of timingMasks) { + if (hasMask(type, mask)) { + timing = value; + break; + } + } + + const actions: TriggerAction[] = []; + const actionMasks: Array<{ mask: number; value: TriggerAction }> = [ + { mask: 1 << 2, value: 'insert' }, + { mask: 1 << 3, value: 'delete' }, + { mask: 1 << 4, value: 'update' }, + { mask: 1 << 5, value: 'truncate' }, + ]; + + for (const { mask, value } of actionMasks) { + if (hasMask(type, mask)) { + actions.push(value); + break; + } + } + + if (actions.length === 0) { + throw new Error(`Unable to parse trigger type ${type}`); + } + + return { actions, timing, scope }; +}; + +export const fromColumnValue = (columnValue?: ColumnValue) => { + if (columnValue === undefined) { + return; + } + + if (typeof columnValue === 'function') { + return columnValue() as string; + } + + const value = columnValue; + + if (value === null) { + return value; + } + + if (typeof value === 'number') { + return String(value); + } + + if (typeof value === 'boolean') { + return value ? 'true' : 'false'; + } + + if (value instanceof Date) { + return `'${value.toISOString()}'`; + } + + return `'${String(value)}'`; +}; + +export const setIsEqual = (source: Set, target: Set) => + source.size === target.size && [...source].every((x) => target.has(x)); + +export const haveEqualColumns = (sourceColumns?: string[], targetColumns?: string[]) => { + return setIsEqual(new Set(sourceColumns ?? []), new Set(targetColumns ?? [])); +}; + +export const compare = ( + sources: T[], + targets: T[], + options: DiffOptions | undefined, + comparer: Comparer, +) => { + options = options || {}; + const sourceMap = Object.fromEntries(sources.map((table) => [table.name, table])); + const targetMap = Object.fromEntries(targets.map((table) => [table.name, table])); + const items: SchemaDiff[] = []; + + const keys = new Set([...Object.keys(sourceMap), ...Object.keys(targetMap)]); + for (const key of keys) { + const source = sourceMap[key]; + const target = targetMap[key]; + + if (isIgnored(source, target, options)) { + continue; + } + + if (isSynchronizeDisabled(source, target)) { + continue; + } + + if (source && !target) { + items.push(...comparer.onMissing(source)); + } else if (!source && target) { + items.push(...comparer.onExtra(target)); + } else { + items.push(...comparer.onCompare(source, target)); + } + } + + return items; +}; + +const isIgnored = ( + source: { synchronize?: boolean } | undefined, + target: { synchronize?: boolean } | undefined, + options: DiffOptions, +) => { + return (options.ignoreExtra && !source) || (options.ignoreMissing && !target); +}; + +const isSynchronizeDisabled = (source?: { synchronize?: boolean }, target?: { synchronize?: boolean }) => { + return source?.synchronize === false || target?.synchronize === false; +}; + +export const isDefaultEqual = (source: DatabaseColumn, target: DatabaseColumn) => { + if (source.default === target.default) { + return true; + } + + if (source.default === undefined || target.default === undefined) { + return false; + } + + if ( + withTypeCast(source.default, getColumnType(source)) === target.default || + source.default === withTypeCast(target.default, getColumnType(target)) + ) { + return true; + } + + return false; +}; + +export const getColumnType = (column: DatabaseColumn) => { + let type = column.enumName || column.type; + if (column.isArray) { + type += `[${column.length ?? ''}]`; + } else if (column.length !== undefined) { + type += `(${column.length})`; + } + + return type; +}; + +const withTypeCast = (value: string, type: string) => { + if (!value.startsWith(`'`)) { + value = `'${value}'`; + } + return `${value}::${type}`; +}; + +export const getColumnModifiers = (column: DatabaseColumn) => { + const modifiers: string[] = []; + + if (!column.nullable) { + modifiers.push('NOT NULL'); + } + + if (column.default) { + modifiers.push(`DEFAULT ${column.default}`); + } + if (column.identity) { + modifiers.push(`GENERATED ALWAYS AS IDENTITY`); + } + + return modifiers.length === 0 ? '' : ' ' + modifiers.join(' '); +}; + +export const asColumnComment = (tableName: string, columnName: string, comment: string): string => { + return `COMMENT ON COLUMN "${tableName}"."${columnName}" IS '${comment}';`; +}; + +export const asColumnList = (columns: string[]) => columns.map((column) => `"${column}"`).join(', '); diff --git a/server/src/sql-tools/public_api.ts b/server/src/sql-tools/public_api.ts index 8b5a36e6a5..d916678d4a 100644 --- a/server/src/sql-tools/public_api.ts +++ b/server/src/sql-tools/public_api.ts @@ -1,6 +1,28 @@ -export * from 'src/sql-tools/decorators'; -export { schemaDiff } from 'src/sql-tools/schema-diff'; -export { schemaDiffToSql } from 'src/sql-tools/schema-diff-to-sql'; -export { schemaFromDatabase } from 'src/sql-tools/schema-from-database'; -export { schemaFromDecorators } from 'src/sql-tools/schema-from-decorators'; +export { schemaDiff } from 'src/sql-tools/diff'; +export { schemaFromCode } from 'src/sql-tools/from-code'; +export * from 'src/sql-tools/from-code/decorators/after-delete.decorator'; +export * from 'src/sql-tools/from-code/decorators/before-update.decorator'; +export * from 'src/sql-tools/from-code/decorators/check.decorator'; +export * from 'src/sql-tools/from-code/decorators/column-index.decorator'; +export * from 'src/sql-tools/from-code/decorators/column.decorator'; +export * from 'src/sql-tools/from-code/decorators/configuration-parameter.decorator'; +export * from 'src/sql-tools/from-code/decorators/create-date-column.decorator'; +export * from 'src/sql-tools/from-code/decorators/database.decorator'; +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/generated-column.decorator'; +export * from 'src/sql-tools/from-code/decorators/index.decorator'; +export * from 'src/sql-tools/from-code/decorators/primary-column.decorator'; +export * from 'src/sql-tools/from-code/decorators/primary-generated-column.decorator'; +export * from 'src/sql-tools/from-code/decorators/table.decorator'; +export * from 'src/sql-tools/from-code/decorators/trigger-function.decorator'; +export * from 'src/sql-tools/from-code/decorators/trigger.decorator'; +export * from 'src/sql-tools/from-code/decorators/unique.decorator'; +export * from 'src/sql-tools/from-code/decorators/update-date-column.decorator'; +export * from 'src/sql-tools/from-code/register-enum'; +export * from 'src/sql-tools/from-code/register-function'; +export { schemaFromDatabase } from 'src/sql-tools/from-database'; +export { schemaDiffToSql } from 'src/sql-tools/to-sql'; export * from 'src/sql-tools/types'; diff --git a/server/src/sql-tools/schema-diff-to-sql.spec.ts b/server/src/sql-tools/schema-diff-to-sql.spec.ts deleted file mode 100644 index c44d87e6bd..0000000000 --- a/server/src/sql-tools/schema-diff-to-sql.spec.ts +++ /dev/null @@ -1,473 +0,0 @@ -import { DatabaseConstraintType, schemaDiffToSql } from 'src/sql-tools'; -import { describe, expect, it } from 'vitest'; - -describe('diffToSql', () => { - describe('table.drop', () => { - it('should work', () => { - expect( - schemaDiffToSql([ - { - type: 'table.drop', - tableName: 'table1', - reason: 'unknown', - }, - ]), - ).toEqual([`DROP TABLE "table1";`]); - }); - }); - - describe('table.create', () => { - it('should work', () => { - expect( - schemaDiffToSql([ - { - type: 'table.create', - tableName: 'table1', - columns: [ - { - tableName: 'table1', - name: 'column1', - type: 'character varying', - nullable: true, - isArray: false, - synchronize: true, - }, - ], - reason: 'unknown', - }, - ]), - ).toEqual([`CREATE TABLE "table1" ("column1" character varying);`]); - }); - - it('should handle a non-nullable column', () => { - expect( - schemaDiffToSql([ - { - type: 'table.create', - tableName: 'table1', - columns: [ - { - tableName: 'table1', - name: 'column1', - type: 'character varying', - isArray: false, - nullable: false, - synchronize: true, - }, - ], - reason: 'unknown', - }, - ]), - ).toEqual([`CREATE TABLE "table1" ("column1" character varying NOT NULL);`]); - }); - - it('should handle a default value', () => { - expect( - schemaDiffToSql([ - { - type: 'table.create', - tableName: 'table1', - columns: [ - { - tableName: 'table1', - name: 'column1', - type: 'character varying', - isArray: false, - nullable: true, - default: 'uuid_generate_v4()', - synchronize: true, - }, - ], - reason: 'unknown', - }, - ]), - ).toEqual([`CREATE TABLE "table1" ("column1" character varying DEFAULT uuid_generate_v4());`]); - }); - - it('should handle an array type', () => { - expect( - schemaDiffToSql([ - { - type: 'table.create', - tableName: 'table1', - columns: [ - { - tableName: 'table1', - name: 'column1', - type: 'character varying', - isArray: true, - nullable: true, - synchronize: true, - }, - ], - reason: 'unknown', - }, - ]), - ).toEqual([`CREATE TABLE "table1" ("column1" character varying[]);`]); - }); - }); - - describe('column.add', () => { - it('should work', () => { - expect( - schemaDiffToSql([ - { - type: 'column.add', - column: { - name: 'column1', - tableName: 'table1', - type: 'character varying', - nullable: false, - isArray: false, - synchronize: true, - }, - reason: 'unknown', - }, - ]), - ).toEqual(['ALTER TABLE "table1" ADD "column1" character varying NOT NULL;']); - }); - - it('should add a nullable column', () => { - expect( - schemaDiffToSql([ - { - type: 'column.add', - column: { - name: 'column1', - tableName: 'table1', - type: 'character varying', - nullable: true, - isArray: false, - synchronize: true, - }, - reason: 'unknown', - }, - ]), - ).toEqual(['ALTER TABLE "table1" ADD "column1" character varying;']); - }); - - it('should add a column with an enum type', () => { - expect( - schemaDiffToSql([ - { - type: 'column.add', - column: { - name: 'column1', - tableName: 'table1', - type: 'character varying', - enumName: 'table1_column1_enum', - nullable: true, - isArray: false, - synchronize: true, - }, - reason: 'unknown', - }, - ]), - ).toEqual(['ALTER TABLE "table1" ADD "column1" table1_column1_enum;']); - }); - - it('should add a column that is an array type', () => { - expect( - schemaDiffToSql([ - { - type: 'column.add', - column: { - name: 'column1', - tableName: 'table1', - type: 'boolean', - nullable: true, - isArray: true, - synchronize: true, - }, - reason: 'unknown', - }, - ]), - ).toEqual(['ALTER TABLE "table1" ADD "column1" boolean[];']); - }); - }); - - describe('column.alter', () => { - it('should make a column nullable', () => { - expect( - schemaDiffToSql([ - { - type: 'column.alter', - tableName: 'table1', - columnName: 'column1', - changes: { nullable: true }, - reason: 'unknown', - }, - ]), - ).toEqual([`ALTER TABLE "table1" ALTER COLUMN "column1" DROP NOT NULL;`]); - }); - - it('should make a column non-nullable', () => { - expect( - schemaDiffToSql([ - { - type: 'column.alter', - tableName: 'table1', - columnName: 'column1', - changes: { nullable: false }, - reason: 'unknown', - }, - ]), - ).toEqual([`ALTER TABLE "table1" ALTER COLUMN "column1" SET NOT NULL;`]); - }); - - it('should update the default value', () => { - expect( - schemaDiffToSql([ - { - type: 'column.alter', - tableName: 'table1', - columnName: 'column1', - changes: { default: 'uuid_generate_v4()' }, - reason: 'unknown', - }, - ]), - ).toEqual([`ALTER TABLE "table1" ALTER COLUMN "column1" SET DEFAULT uuid_generate_v4();`]); - }); - }); - - describe('column.drop', () => { - it('should work', () => { - expect( - schemaDiffToSql([ - { - type: 'column.drop', - tableName: 'table1', - columnName: 'column1', - reason: 'unknown', - }, - ]), - ).toEqual([`ALTER TABLE "table1" DROP COLUMN "column1";`]); - }); - }); - - describe('constraint.add', () => { - describe('primary keys', () => { - it('should work', () => { - expect( - schemaDiffToSql([ - { - type: 'constraint.add', - constraint: { - type: DatabaseConstraintType.PRIMARY_KEY, - name: 'PK_test', - tableName: 'table1', - columnNames: ['id'], - synchronize: true, - }, - reason: 'unknown', - }, - ]), - ).toEqual(['ALTER TABLE "table1" ADD CONSTRAINT "PK_test" PRIMARY KEY ("id");']); - }); - }); - - describe('foreign keys', () => { - it('should work', () => { - expect( - schemaDiffToSql([ - { - type: 'constraint.add', - constraint: { - type: DatabaseConstraintType.FOREIGN_KEY, - name: 'FK_test', - tableName: 'table1', - columnNames: ['parentId'], - referenceColumnNames: ['id'], - referenceTableName: 'table2', - synchronize: true, - }, - reason: 'unknown', - }, - ]), - ).toEqual([ - 'ALTER TABLE "table1" ADD CONSTRAINT "FK_test" FOREIGN KEY ("parentId") REFERENCES "table2" ("id") ON UPDATE NO ACTION ON DELETE NO ACTION;', - ]); - }); - }); - - describe('unique', () => { - it('should work', () => { - expect( - schemaDiffToSql([ - { - type: 'constraint.add', - constraint: { - type: DatabaseConstraintType.UNIQUE, - name: 'UQ_test', - tableName: 'table1', - columnNames: ['id'], - synchronize: true, - }, - reason: 'unknown', - }, - ]), - ).toEqual(['ALTER TABLE "table1" ADD CONSTRAINT "UQ_test" UNIQUE ("id");']); - }); - }); - - describe('check', () => { - it('should work', () => { - expect( - schemaDiffToSql([ - { - type: 'constraint.add', - constraint: { - type: DatabaseConstraintType.CHECK, - name: 'CHK_test', - tableName: 'table1', - expression: '"id" IS NOT NULL', - synchronize: true, - }, - reason: 'unknown', - }, - ]), - ).toEqual(['ALTER TABLE "table1" ADD CONSTRAINT "CHK_test" CHECK ("id" IS NOT NULL);']); - }); - }); - }); - - describe('constraint.drop', () => { - it('should work', () => { - expect( - schemaDiffToSql([ - { - type: 'constraint.drop', - tableName: 'table1', - constraintName: 'PK_test', - reason: 'unknown', - }, - ]), - ).toEqual([`ALTER TABLE "table1" DROP CONSTRAINT "PK_test";`]); - }); - }); - - describe('index.create', () => { - it('should work', () => { - expect( - schemaDiffToSql([ - { - type: 'index.create', - index: { - name: 'IDX_test', - tableName: 'table1', - columnNames: ['column1'], - unique: false, - synchronize: true, - }, - reason: 'unknown', - }, - ]), - ).toEqual(['CREATE INDEX "IDX_test" ON "table1" ("column1")']); - }); - - it('should create an unique index', () => { - expect( - schemaDiffToSql([ - { - type: 'index.create', - index: { - name: 'IDX_test', - tableName: 'table1', - columnNames: ['column1'], - unique: true, - synchronize: true, - }, - reason: 'unknown', - }, - ]), - ).toEqual(['CREATE UNIQUE INDEX "IDX_test" ON "table1" ("column1")']); - }); - - it('should create an index with a custom expression', () => { - expect( - schemaDiffToSql([ - { - type: 'index.create', - index: { - name: 'IDX_test', - tableName: 'table1', - unique: false, - expression: '"id" IS NOT NULL', - synchronize: true, - }, - reason: 'unknown', - }, - ]), - ).toEqual(['CREATE INDEX "IDX_test" ON "table1" ("id" IS NOT NULL)']); - }); - - it('should create an index with a where clause', () => { - expect( - schemaDiffToSql([ - { - type: 'index.create', - index: { - name: 'IDX_test', - tableName: 'table1', - columnNames: ['id'], - unique: false, - where: '("id" IS NOT NULL)', - synchronize: true, - }, - reason: 'unknown', - }, - ]), - ).toEqual(['CREATE INDEX "IDX_test" ON "table1" ("id") WHERE ("id" IS NOT NULL)']); - }); - - it('should create an index with a custom expression', () => { - expect( - schemaDiffToSql([ - { - type: 'index.create', - index: { - name: 'IDX_test', - tableName: 'table1', - unique: false, - using: 'gin', - expression: '"id" IS NOT NULL', - synchronize: true, - }, - reason: 'unknown', - }, - ]), - ).toEqual(['CREATE INDEX "IDX_test" ON "table1" USING gin ("id" IS NOT NULL)']); - }); - }); - - describe('index.drop', () => { - it('should work', () => { - expect( - schemaDiffToSql([ - { - type: 'index.drop', - indexName: 'IDX_test', - reason: 'unknown', - }, - ]), - ).toEqual([`DROP INDEX "IDX_test";`]); - }); - }); - - describe('comments', () => { - it('should include the reason in a SQL comment', () => { - expect( - schemaDiffToSql( - [ - { - type: 'index.drop', - indexName: 'IDX_test', - reason: 'unknown', - }, - ], - { comments: true }, - ), - ).toEqual([`DROP INDEX "IDX_test"; -- unknown`]); - }); - }); -}); diff --git a/server/src/sql-tools/schema-diff-to-sql.ts b/server/src/sql-tools/schema-diff-to-sql.ts deleted file mode 100644 index 0a537c600b..0000000000 --- a/server/src/sql-tools/schema-diff-to-sql.ts +++ /dev/null @@ -1,204 +0,0 @@ -import { - DatabaseActionType, - DatabaseColumn, - DatabaseColumnChanges, - DatabaseConstraint, - DatabaseConstraintType, - DatabaseIndex, - SchemaDiff, - SchemaDiffToSqlOptions, -} from 'src/sql-tools/types'; - -const asColumnList = (columns: string[]) => - columns - .toSorted() - .map((column) => `"${column}"`) - .join(', '); -const withNull = (column: DatabaseColumn) => (column.nullable ? '' : ' NOT NULL'); -const withDefault = (column: DatabaseColumn) => (column.default ? ` DEFAULT ${column.default}` : ''); -const withAction = (constraint: { onDelete?: DatabaseActionType; onUpdate?: DatabaseActionType }) => - ` ON UPDATE ${constraint.onUpdate ?? DatabaseActionType.NO_ACTION} ON DELETE ${constraint.onDelete ?? DatabaseActionType.NO_ACTION}`; - -const withComments = (comments: boolean | undefined, item: SchemaDiff): string => { - if (!comments) { - return ''; - } - - return ` -- ${item.reason}`; -}; - -const asArray = (items: T | T[]): T[] => { - if (Array.isArray(items)) { - return items; - } - - return [items]; -}; - -export const getColumnType = (column: DatabaseColumn) => { - let type = column.enumName || column.type; - if (column.isArray) { - type += '[]'; - } - - return type; -}; - -/** - * Convert schema diffs into SQL statements - */ -export const schemaDiffToSql = (items: SchemaDiff[], options: SchemaDiffToSqlOptions = {}): string[] => { - return items.flatMap((item) => asArray(asSql(item)).map((result) => result + withComments(options.comments, item))); -}; - -const asSql = (item: SchemaDiff): string | string[] => { - switch (item.type) { - case 'table.create': { - return asTableCreate(item.tableName, item.columns); - } - - case 'table.drop': { - return asTableDrop(item.tableName); - } - - case 'column.add': { - return asColumnAdd(item.column); - } - - case 'column.alter': { - return asColumnAlter(item.tableName, item.columnName, item.changes); - } - - case 'column.drop': { - return asColumnDrop(item.tableName, item.columnName); - } - - case 'constraint.add': { - return asConstraintAdd(item.constraint); - } - - case 'constraint.drop': { - return asConstraintDrop(item.tableName, item.constraintName); - } - - case 'index.create': { - return asIndexCreate(item.index); - } - - case 'index.drop': { - return asIndexDrop(item.indexName); - } - - default: { - return []; - } - } -}; - -const asTableCreate = (tableName: string, tableColumns: DatabaseColumn[]): string => { - const columns = tableColumns - .map((column) => `"${column.name}" ${getColumnType(column)}` + withNull(column) + withDefault(column)) - .join(', '); - return `CREATE TABLE "${tableName}" (${columns});`; -}; - -const asTableDrop = (tableName: string): string => { - return `DROP TABLE "${tableName}";`; -}; - -const asColumnAdd = (column: DatabaseColumn): string => { - return ( - `ALTER TABLE "${column.tableName}" ADD "${column.name}" ${getColumnType(column)}` + - withNull(column) + - withDefault(column) + - ';' - ); -}; - -const asColumnAlter = (tableName: string, columnName: string, changes: DatabaseColumnChanges): string[] => { - const base = `ALTER TABLE "${tableName}" ALTER COLUMN "${columnName}"`; - const items: string[] = []; - if (changes.nullable !== undefined) { - items.push(changes.nullable ? `${base} DROP NOT NULL;` : `${base} SET NOT NULL;`); - } - - if (changes.default !== undefined) { - items.push(`${base} SET DEFAULT ${changes.default};`); - } - - return items; -}; - -const asColumnDrop = (tableName: string, columnName: string): string => { - return `ALTER TABLE "${tableName}" DROP COLUMN "${columnName}";`; -}; - -const asConstraintAdd = (constraint: DatabaseConstraint): string | string[] => { - const base = `ALTER TABLE "${constraint.tableName}" ADD CONSTRAINT "${constraint.name}"`; - switch (constraint.type) { - case DatabaseConstraintType.PRIMARY_KEY: { - const columnNames = asColumnList(constraint.columnNames); - return `${base} PRIMARY KEY (${columnNames});`; - } - - case DatabaseConstraintType.FOREIGN_KEY: { - const columnNames = asColumnList(constraint.columnNames); - const referenceColumnNames = asColumnList(constraint.referenceColumnNames); - return ( - `${base} FOREIGN KEY (${columnNames}) REFERENCES "${constraint.referenceTableName}" (${referenceColumnNames})` + - withAction(constraint) + - ';' - ); - } - - case DatabaseConstraintType.UNIQUE: { - const columnNames = asColumnList(constraint.columnNames); - return `${base} UNIQUE (${columnNames});`; - } - - case DatabaseConstraintType.CHECK: { - return `${base} CHECK (${constraint.expression});`; - } - - default: { - return []; - } - } -}; - -const asConstraintDrop = (tableName: string, constraintName: string): string => { - return `ALTER TABLE "${tableName}" DROP CONSTRAINT "${constraintName}";`; -}; - -const asIndexCreate = (index: DatabaseIndex): string => { - let sql = `CREATE`; - - if (index.unique) { - sql += ' UNIQUE'; - } - - sql += ` INDEX "${index.name}" ON "${index.tableName}"`; - - if (index.columnNames) { - const columnNames = asColumnList(index.columnNames); - sql += ` (${columnNames})`; - } - - if (index.using && index.using !== 'btree') { - sql += ` USING ${index.using}`; - } - - if (index.expression) { - sql += ` (${index.expression})`; - } - - if (index.where) { - sql += ` WHERE ${index.where}`; - } - - return sql; -}; - -const asIndexDrop = (indexName: string): string => { - return `DROP INDEX "${indexName}";`; -}; diff --git a/server/src/sql-tools/schema-diff.ts b/server/src/sql-tools/schema-diff.ts deleted file mode 100644 index ca7f35a45f..0000000000 --- a/server/src/sql-tools/schema-diff.ts +++ /dev/null @@ -1,449 +0,0 @@ -import { getColumnType, schemaDiffToSql } from 'src/sql-tools/schema-diff-to-sql'; -import { - DatabaseCheckConstraint, - DatabaseColumn, - DatabaseConstraint, - DatabaseConstraintType, - DatabaseForeignKeyConstraint, - DatabaseIndex, - DatabasePrimaryKeyConstraint, - DatabaseSchema, - DatabaseTable, - DatabaseUniqueConstraint, - SchemaDiff, - SchemaDiffToSqlOptions, -} from 'src/sql-tools/types'; - -enum Reason { - MissingInSource = 'missing in source', - MissingInTarget = 'missing in target', -} - -const setIsEqual = (source: Set, target: Set) => - source.size === target.size && [...source].every((x) => target.has(x)); - -const haveEqualColumns = (sourceColumns?: string[], targetColumns?: string[]) => { - return setIsEqual(new Set(sourceColumns ?? []), new Set(targetColumns ?? [])); -}; - -const isSynchronizeDisabled = (source?: { synchronize?: boolean }, target?: { synchronize?: boolean }) => { - return source?.synchronize === false || target?.synchronize === false; -}; - -const withTypeCast = (value: string, type: string) => { - if (!value.startsWith(`'`)) { - value = `'${value}'`; - } - return `${value}::${type}`; -}; - -const isDefaultEqual = (source: DatabaseColumn, target: DatabaseColumn) => { - if (source.default === target.default) { - return true; - } - - if (source.default === undefined || target.default === undefined) { - return false; - } - - if ( - withTypeCast(source.default, getColumnType(source)) === target.default || - source.default === withTypeCast(target.default, getColumnType(target)) - ) { - return true; - } - - return false; -}; - -/** - * Compute the difference between two database schemas - */ -export const schemaDiff = ( - source: DatabaseSchema, - target: DatabaseSchema, - options: { ignoreExtraTables?: boolean } = {}, -) => { - const items = diffTables(source.tables, target.tables, { - ignoreExtraTables: options.ignoreExtraTables ?? true, - }); - - return { - items, - asSql: (options?: SchemaDiffToSqlOptions) => schemaDiffToSql(items, options), - }; -}; - -export const diffTables = ( - sources: DatabaseTable[], - targets: DatabaseTable[], - options: { ignoreExtraTables: boolean }, -) => { - const items: SchemaDiff[] = []; - const sourceMap = Object.fromEntries(sources.map((table) => [table.name, table])); - const targetMap = Object.fromEntries(targets.map((table) => [table.name, table])); - const keys = new Set([...Object.keys(sourceMap), ...Object.keys(targetMap)]); - - for (const key of keys) { - if (options.ignoreExtraTables && !sourceMap[key]) { - continue; - } - items.push(...diffTable(sourceMap[key], targetMap[key])); - } - - return items; -}; - -const diffTable = (source?: DatabaseTable, target?: DatabaseTable): SchemaDiff[] => { - if (isSynchronizeDisabled(source, target)) { - return []; - } - - if (source && !target) { - return [ - { - type: 'table.create', - tableName: source.name, - columns: Object.values(source.columns), - reason: Reason.MissingInTarget, - }, - ...diffIndexes(source.indexes, []), - // TODO merge constraints into table create record when possible - ...diffConstraints(source.constraints, []), - ]; - } - - if (!source && target) { - return [ - { - type: 'table.drop', - tableName: target.name, - reason: Reason.MissingInSource, - }, - ]; - } - - if (!source || !target) { - return []; - } - - return [ - ...diffColumns(source.columns, target.columns), - ...diffConstraints(source.constraints, target.constraints), - ...diffIndexes(source.indexes, target.indexes), - ]; -}; - -const diffColumns = (sources: DatabaseColumn[], targets: DatabaseColumn[]): SchemaDiff[] => { - const items: SchemaDiff[] = []; - const sourceMap = Object.fromEntries(sources.map((column) => [column.name, column])); - const targetMap = Object.fromEntries(targets.map((column) => [column.name, column])); - const keys = new Set([...Object.keys(sourceMap), ...Object.keys(targetMap)]); - - for (const key of keys) { - items.push(...diffColumn(sourceMap[key], targetMap[key])); - } - - return items; -}; - -const diffColumn = (source?: DatabaseColumn, target?: DatabaseColumn): SchemaDiff[] => { - if (isSynchronizeDisabled(source, target)) { - return []; - } - - if (source && !target) { - return [ - { - type: 'column.add', - column: source, - reason: Reason.MissingInTarget, - }, - ]; - } - - if (!source && target) { - return [ - { - type: 'column.drop', - tableName: target.tableName, - columnName: target.name, - reason: Reason.MissingInSource, - }, - ]; - } - - if (!source || !target) { - return []; - } - - const sourceType = getColumnType(source); - const targetType = getColumnType(target); - - const isTypeChanged = sourceType !== targetType; - - if (isTypeChanged) { - // TODO: convert between types via UPDATE when possible - return dropAndRecreateColumn(source, target, `column type is different (${sourceType} vs ${targetType})`); - } - - const items: SchemaDiff[] = []; - if (source.nullable !== target.nullable) { - items.push({ - type: 'column.alter', - tableName: source.tableName, - columnName: source.name, - changes: { - nullable: source.nullable, - }, - reason: `nullable is different (${source.nullable} vs ${target.nullable})`, - }); - } - - if (!isDefaultEqual(source, target)) { - items.push({ - type: 'column.alter', - tableName: source.tableName, - columnName: source.name, - changes: { - default: String(source.default), - }, - reason: `default is different (${source.default} vs ${target.default})`, - }); - } - - return items; -}; - -const diffConstraints = (sources: DatabaseConstraint[], targets: DatabaseConstraint[]): SchemaDiff[] => { - const items: SchemaDiff[] = []; - - for (const type of Object.values(DatabaseConstraintType)) { - const sourceMap = Object.fromEntries(sources.filter((item) => item.type === type).map((item) => [item.name, item])); - const targetMap = Object.fromEntries(targets.filter((item) => item.type === type).map((item) => [item.name, item])); - const keys = new Set([...Object.keys(sourceMap), ...Object.keys(targetMap)]); - - for (const key of keys) { - items.push(...diffConstraint(sourceMap[key], targetMap[key])); - } - } - - return items; -}; - -const diffConstraint = (source?: T, target?: T): SchemaDiff[] => { - if (isSynchronizeDisabled(source, target)) { - return []; - } - - if (source && !target) { - return [ - { - type: 'constraint.add', - constraint: source, - reason: Reason.MissingInTarget, - }, - ]; - } - - if (!source && target) { - return [ - { - type: 'constraint.drop', - tableName: target.tableName, - constraintName: target.name, - reason: Reason.MissingInSource, - }, - ]; - } - - if (!source || !target) { - return []; - } - - switch (source.type) { - case DatabaseConstraintType.PRIMARY_KEY: { - return diffPrimaryKeyConstraint(source, target as DatabasePrimaryKeyConstraint); - } - - case DatabaseConstraintType.FOREIGN_KEY: { - return diffForeignKeyConstraint(source, target as DatabaseForeignKeyConstraint); - } - - case DatabaseConstraintType.UNIQUE: { - return diffUniqueConstraint(source, target as DatabaseUniqueConstraint); - } - - case DatabaseConstraintType.CHECK: { - return diffCheckConstraint(source, target as DatabaseCheckConstraint); - } - - default: { - return dropAndRecreateConstraint(source, target, `Unknown constraint type: ${(source as any).type}`); - } - } -}; - -const diffPrimaryKeyConstraint = ( - source: DatabasePrimaryKeyConstraint, - target: DatabasePrimaryKeyConstraint, -): SchemaDiff[] => { - if (!haveEqualColumns(source.columnNames, target.columnNames)) { - return dropAndRecreateConstraint( - source, - target, - `Primary key columns are different: (${source.columnNames} vs ${target.columnNames})`, - ); - } - - return []; -}; - -const diffForeignKeyConstraint = ( - source: DatabaseForeignKeyConstraint, - target: DatabaseForeignKeyConstraint, -): SchemaDiff[] => { - let reason = ''; - - const sourceDeleteAction = source.onDelete ?? 'NO ACTION'; - const targetDeleteAction = target.onDelete ?? 'NO ACTION'; - - const sourceUpdateAction = source.onUpdate ?? 'NO ACTION'; - const targetUpdateAction = target.onUpdate ?? 'NO ACTION'; - - if (!haveEqualColumns(source.columnNames, target.columnNames)) { - reason = `columns are different (${source.columnNames} vs ${target.columnNames})`; - } else if (!haveEqualColumns(source.referenceColumnNames, target.referenceColumnNames)) { - reason = `reference columns are different (${source.referenceColumnNames} vs ${target.referenceColumnNames})`; - } else if (source.referenceTableName !== target.referenceTableName) { - reason = `reference table is different (${source.referenceTableName} vs ${target.referenceTableName})`; - } else if (sourceDeleteAction !== targetDeleteAction) { - reason = `ON DELETE action is different (${sourceDeleteAction} vs ${targetDeleteAction})`; - } else if (sourceUpdateAction !== targetUpdateAction) { - reason = `ON UPDATE action is different (${sourceUpdateAction} vs ${targetUpdateAction})`; - } - - if (reason) { - return dropAndRecreateConstraint(source, target, reason); - } - - return []; -}; - -const diffUniqueConstraint = (source: DatabaseUniqueConstraint, target: DatabaseUniqueConstraint): SchemaDiff[] => { - let reason = ''; - - if (!haveEqualColumns(source.columnNames, target.columnNames)) { - reason = `columns are different (${source.columnNames} vs ${target.columnNames})`; - } - - if (reason) { - return dropAndRecreateConstraint(source, target, reason); - } - - return []; -}; - -const diffCheckConstraint = (source: DatabaseCheckConstraint, target: DatabaseCheckConstraint): SchemaDiff[] => { - if (source.expression !== target.expression) { - // comparing expressions is hard because postgres reconstructs it with different formatting - // for now if the constraint exists with the same name, we will just skip it - } - - return []; -}; - -const diffIndexes = (sources: DatabaseIndex[], targets: DatabaseIndex[]) => { - const items: SchemaDiff[] = []; - const sourceMap = Object.fromEntries(sources.map((index) => [index.name, index])); - const targetMap = Object.fromEntries(targets.map((index) => [index.name, index])); - const keys = new Set([...Object.keys(sourceMap), ...Object.keys(targetMap)]); - - for (const key of keys) { - items.push(...diffIndex(sourceMap[key], targetMap[key])); - } - - return items; -}; - -const diffIndex = (source?: DatabaseIndex, target?: DatabaseIndex): SchemaDiff[] => { - if (isSynchronizeDisabled(source, target)) { - return []; - } - - if (source && !target) { - return [{ type: 'index.create', index: source, reason: Reason.MissingInTarget }]; - } - - if (!source && target) { - return [ - { - type: 'index.drop', - indexName: target.name, - reason: Reason.MissingInSource, - }, - ]; - } - - if (!target || !source) { - return []; - } - - const sourceUsing = source.using ?? 'btree'; - const targetUsing = target.using ?? 'btree'; - - let reason = ''; - - if (!haveEqualColumns(source.columnNames, target.columnNames)) { - reason = `columns are different (${source.columnNames} vs ${target.columnNames})`; - } else if (source.unique !== target.unique) { - reason = `uniqueness is different (${source.unique} vs ${target.unique})`; - } else if (sourceUsing !== targetUsing) { - reason = `using method is different (${source.using} vs ${target.using})`; - } else if (source.where !== target.where) { - reason = `where clause is different (${source.where} vs ${target.where})`; - } else if (source.expression !== target.expression) { - reason = `expression is different (${source.expression} vs ${target.expression})`; - } - - if (reason) { - return dropAndRecreateIndex(source, target, reason); - } - - return []; -}; - -const dropAndRecreateColumn = (source: DatabaseColumn, target: DatabaseColumn, reason: string): SchemaDiff[] => { - return [ - { - type: 'column.drop', - tableName: target.tableName, - columnName: target.name, - reason, - }, - { type: 'column.add', column: source, reason }, - ]; -}; - -const dropAndRecreateConstraint = ( - source: DatabaseConstraint, - target: DatabaseConstraint, - reason: string, -): SchemaDiff[] => { - return [ - { - type: 'constraint.drop', - tableName: target.tableName, - constraintName: target.name, - reason, - }, - { type: 'constraint.add', constraint: source, reason }, - ]; -}; - -const dropAndRecreateIndex = (source: DatabaseIndex, target: DatabaseIndex, reason: string): SchemaDiff[] => { - return [ - { type: 'index.drop', indexName: target.name, reason }, - { type: 'index.create', index: source, reason }, - ]; -}; diff --git a/server/src/sql-tools/schema-from-decorators.ts b/server/src/sql-tools/schema-from-decorators.ts deleted file mode 100644 index b11817678e..0000000000 --- a/server/src/sql-tools/schema-from-decorators.ts +++ /dev/null @@ -1,443 +0,0 @@ -/* eslint-disable @typescript-eslint/no-unsafe-function-type */ -import { createHash } from 'node:crypto'; -import 'reflect-metadata'; -import { - CheckOptions, - ColumnDefaultValue, - ColumnIndexOptions, - ColumnOptions, - DatabaseActionType, - DatabaseColumn, - DatabaseConstraintType, - DatabaseSchema, - DatabaseTable, - ForeignKeyColumnOptions, - IndexOptions, - TableOptions, - UniqueOptions, -} from 'src/sql-tools/types'; - -enum SchemaKey { - TableName = 'immich-schema:table-name', - ColumnName = 'immich-schema:column-name', - IndexName = 'immich-schema:index-name', -} - -type SchemaTable = DatabaseTable & { options: TableOptions }; -type SchemaTables = SchemaTable[]; -type ClassBased = { object: Function } & T; -type PropertyBased = { object: object; propertyName: string | symbol } & T; -type RegisterItem = - | { type: 'table'; item: ClassBased<{ options: TableOptions }> } - | { type: 'index'; item: ClassBased<{ options: IndexOptions }> } - | { type: 'uniqueConstraint'; item: ClassBased<{ options: UniqueOptions }> } - | { type: 'checkConstraint'; item: ClassBased<{ options: CheckOptions }> } - | { type: 'column'; item: PropertyBased<{ options: ColumnOptions }> } - | { type: 'columnIndex'; item: PropertyBased<{ options: ColumnIndexOptions }> } - | { type: 'foreignKeyColumn'; item: PropertyBased<{ options: ForeignKeyColumnOptions; target: () => object }> }; - -const items: RegisterItem[] = []; -export const register = (item: RegisterItem) => void items.push(item); - -const asSnakeCase = (name: string): string => name.replaceAll(/([a-z])([A-Z])/g, '$1_$2').toLowerCase(); -const asKey = (prefix: string, tableName: string, values: string[]) => - (prefix + sha1(`${tableName}_${values.toSorted().join('_')}`)).slice(0, 30); -const asPrimaryKeyConstraintName = (table: string, columns: string[]) => asKey('PK_', table, columns); -const asForeignKeyConstraintName = (table: string, columns: string[]) => asKey('FK_', table, columns); -const asRelationKeyConstraintName = (table: string, columns: string[]) => asKey('REL_', table, columns); -const asUniqueConstraintName = (table: string, columns: string[]) => asKey('UQ_', table, columns); -const asCheckConstraintName = (table: string, expression: string) => asKey('CHK_', table, [expression]); -const asIndexName = (table: string, columns: string[] | undefined, where: string | undefined) => { - const items: string[] = []; - for (const columnName of columns ?? []) { - items.push(columnName); - } - - if (where) { - items.push(where); - } - - return asKey('IDX_', table, items); -}; - -const makeColumn = ({ - name, - tableName, - options, -}: { - name: string; - tableName: string; - options: ColumnOptions; -}): DatabaseColumn => { - const columnName = options.name ?? name; - const enumName = options.enumName ?? `${tableName}_${columnName}_enum`.toLowerCase(); - let defaultValue = asDefaultValue(options); - let nullable = options.nullable ?? false; - - if (defaultValue === null) { - nullable = true; - defaultValue = undefined; - } - - const isEnum = !!options.enum; - - return { - name: columnName, - tableName, - primary: options.primary ?? false, - default: defaultValue, - nullable, - enumName: isEnum ? enumName : undefined, - enumValues: isEnum ? Object.values(options.enum as object) : undefined, - isArray: options.array ?? false, - type: isEnum ? 'enum' : options.type || 'character varying', - synchronize: options.synchronize ?? true, - }; -}; - -const asDefaultValue = (options: { nullable?: boolean; default?: ColumnDefaultValue }) => { - if (typeof options.default === 'function') { - return options.default() as string; - } - - if (options.default === undefined) { - return; - } - - const value = options.default; - - if (value === null) { - return value; - } - - if (typeof value === 'number') { - return String(value); - } - - if (typeof value === 'boolean') { - return value ? 'true' : 'false'; - } - - if (value instanceof Date) { - return `'${value.toISOString()}'`; - } - - return `'${String(value)}'`; -}; - -const missingTableError = (context: string, object: object, propertyName?: string | symbol) => { - const label = object.constructor.name + (propertyName ? '.' + String(propertyName) : ''); - return `[${context}] Unable to find table (${label})`; -}; - -// match TypeORM -const sha1 = (value: string) => createHash('sha1').update(value).digest('hex'); - -const findByName = (items: T[], name?: string) => - name ? items.find((item) => item.name === name) : undefined; -const resolveTable = (tables: SchemaTables, object: object) => - findByName(tables, Reflect.getMetadata(SchemaKey.TableName, object)); - -let initialized = false; -let schema: DatabaseSchema; - -export const reset = () => { - initialized = false; - items.length = 0; -}; - -export const schemaFromDecorators = () => { - if (!initialized) { - const schemaTables: SchemaTables = []; - - const warnings: string[] = []; - const warn = (message: string) => void warnings.push(message); - - for (const { item } of items.filter((item) => item.type === 'table')) { - processTable(schemaTables, item); - } - - for (const { item } of items.filter((item) => item.type === 'column')) { - processColumn(schemaTables, item, { warn }); - } - - for (const { item } of items.filter((item) => item.type === 'foreignKeyColumn')) { - processForeignKeyColumn(schemaTables, item, { warn }); - } - - for (const { item } of items.filter((item) => item.type === 'uniqueConstraint')) { - processUniqueConstraint(schemaTables, item, { warn }); - } - - for (const { item } of items.filter((item) => item.type === 'checkConstraint')) { - processCheckConstraint(schemaTables, item, { warn }); - } - - for (const table of schemaTables) { - processPrimaryKeyConstraint(table); - } - - for (const { item } of items.filter((item) => item.type === 'index')) { - processIndex(schemaTables, item, { warn }); - } - - for (const { item } of items.filter((item) => item.type === 'columnIndex')) { - processColumnIndex(schemaTables, item, { warn }); - } - - for (const { item } of items.filter((item) => item.type === 'foreignKeyColumn')) { - processForeignKeyConstraint(schemaTables, item, { warn }); - } - - schema = { - name: 'public', - tables: schemaTables.map(({ options: _, ...table }) => table), - warnings, - }; - - initialized = true; - } - - return schema; -}; - -const processTable = (tables: SchemaTables, { object, options }: ClassBased<{ options: TableOptions }>) => { - const tableName = options.name || asSnakeCase(object.name); - Reflect.defineMetadata(SchemaKey.TableName, tableName, object); - tables.push({ - name: tableName, - columns: [], - constraints: [], - indexes: [], - options, - synchronize: options.synchronize ?? true, - }); -}; - -type OnWarn = (message: string) => void; - -const processColumn = ( - tables: SchemaTables, - { object, propertyName, options }: PropertyBased<{ options: ColumnOptions }>, - { warn }: { warn: OnWarn }, -) => { - const table = resolveTable(tables, object.constructor); - if (!table) { - warn(missingTableError('@Column', object, propertyName)); - return; - } - - // TODO make sure column name is unique - - const column = makeColumn({ name: String(propertyName), tableName: table.name, options }); - - Reflect.defineMetadata(SchemaKey.ColumnName, column.name, object, propertyName); - - table.columns.push(column); - - if (!options.primary && options.unique) { - table.constraints.push({ - type: DatabaseConstraintType.UNIQUE, - name: options.uniqueConstraintName || asUniqueConstraintName(table.name, [column.name]), - tableName: table.name, - columnNames: [column.name], - synchronize: options.synchronize ?? true, - }); - } -}; - -const processUniqueConstraint = ( - tables: SchemaTables, - { object, options }: ClassBased<{ options: UniqueOptions }>, - { warn }: { warn: OnWarn }, -) => { - const table = resolveTable(tables, object); - if (!table) { - warn(missingTableError('@Unique', object)); - return; - } - - const tableName = table.name; - const columnNames = options.columns; - - table.constraints.push({ - type: DatabaseConstraintType.UNIQUE, - name: options.name || asUniqueConstraintName(tableName, columnNames), - tableName, - columnNames, - synchronize: options.synchronize ?? true, - }); -}; - -const processCheckConstraint = ( - tables: SchemaTables, - { object, options }: ClassBased<{ options: CheckOptions }>, - { warn }: { warn: OnWarn }, -) => { - const table = resolveTable(tables, object); - if (!table) { - warn(missingTableError('@Check', object)); - return; - } - - const tableName = table.name; - - table.constraints.push({ - type: DatabaseConstraintType.CHECK, - name: options.name || asCheckConstraintName(tableName, options.expression), - tableName, - expression: options.expression, - synchronize: options.synchronize ?? true, - }); -}; - -const processPrimaryKeyConstraint = (table: SchemaTable) => { - const columnNames: string[] = []; - - for (const column of table.columns) { - if (column.primary) { - columnNames.push(column.name); - } - } - - if (columnNames.length > 0) { - table.constraints.push({ - type: DatabaseConstraintType.PRIMARY_KEY, - name: table.options.primaryConstraintName || asPrimaryKeyConstraintName(table.name, columnNames), - tableName: table.name, - columnNames, - synchronize: table.options.synchronize ?? true, - }); - } -}; - -const processIndex = ( - tables: SchemaTables, - { object, options }: ClassBased<{ options: IndexOptions }>, - { warn }: { warn: OnWarn }, -) => { - const table = resolveTable(tables, object); - if (!table) { - warn(missingTableError('@Index', object)); - return; - } - - table.indexes.push({ - name: options.name || asIndexName(table.name, options.columns, options.where), - tableName: table.name, - unique: options.unique ?? false, - expression: options.expression, - using: options.using, - where: options.where, - columnNames: options.columns, - synchronize: options.synchronize ?? true, - }); -}; - -const processColumnIndex = ( - tables: SchemaTables, - { object, propertyName, options }: PropertyBased<{ options: ColumnIndexOptions }>, - { warn }: { warn: OnWarn }, -) => { - const table = resolveTable(tables, object.constructor); - if (!table) { - warn(missingTableError('@ColumnIndex', object, propertyName)); - return; - } - - const column = findByName(table.columns, Reflect.getMetadata(SchemaKey.ColumnName, object, propertyName)); - if (!column) { - return; - } - - table.indexes.push({ - name: options.name || asIndexName(table.name, [column.name], options.where), - tableName: table.name, - unique: options.unique ?? false, - expression: options.expression, - using: options.using, - where: options.where, - columnNames: [column.name], - synchronize: options.synchronize ?? true, - }); -}; - -const processForeignKeyColumn = ( - tables: SchemaTables, - { object, propertyName, options }: PropertyBased<{ options: ForeignKeyColumnOptions; target: () => object }>, - { warn }: { warn: OnWarn }, -) => { - const table = resolveTable(tables, object.constructor); - if (!table) { - warn(missingTableError('@ForeignKeyColumn', object)); - return; - } - - const columnName = String(propertyName); - const existingColumn = table.columns.find((column) => column.name === columnName); - if (existingColumn) { - // TODO log warnings if column options and `@Column` is also used - return; - } - - const column = makeColumn({ name: columnName, tableName: table.name, options }); - - Reflect.defineMetadata(SchemaKey.ColumnName, columnName, object, propertyName); - - table.columns.push(column); -}; - -const processForeignKeyConstraint = ( - tables: SchemaTables, - { object, propertyName, options, target }: PropertyBased<{ options: ForeignKeyColumnOptions; target: () => object }>, - { warn }: { warn: OnWarn }, -) => { - const childTable = resolveTable(tables, object.constructor); - if (!childTable) { - warn(missingTableError('@ForeignKeyColumn', object)); - return; - } - - const parentTable = resolveTable(tables, target()); - if (!parentTable) { - warn(missingTableError('@ForeignKeyColumn', object, propertyName)); - return; - } - - const columnName = String(propertyName); - const column = childTable.columns.find((column) => column.name === columnName); - if (!column) { - warn('@ForeignKeyColumn: Column not found, creating a new one'); - return; - } - - const columnNames = [column.name]; - const referenceColumns = parentTable.columns.filter((column) => column.primary); - - // infer FK column type from reference table - if (referenceColumns.length === 1) { - column.type = referenceColumns[0].type; - } - - childTable.constraints.push({ - name: options.constraintName || asForeignKeyConstraintName(childTable.name, columnNames), - tableName: childTable.name, - columnNames, - type: DatabaseConstraintType.FOREIGN_KEY, - referenceTableName: parentTable.name, - referenceColumnNames: referenceColumns.map((column) => column.name), - onUpdate: options.onUpdate as DatabaseActionType, - onDelete: options.onDelete as DatabaseActionType, - synchronize: options.synchronize ?? true, - }); - - if (options.unique) { - childTable.constraints.push({ - name: options.uniqueConstraintName || asRelationKeyConstraintName(childTable.name, columnNames), - tableName: childTable.name, - columnNames, - type: DatabaseConstraintType.UNIQUE, - synchronize: options.synchronize ?? true, - }); - } -}; diff --git a/server/src/sql-tools/to-sql/index.spec.ts b/server/src/sql-tools/to-sql/index.spec.ts new file mode 100644 index 0000000000..509f44ebe5 --- /dev/null +++ b/server/src/sql-tools/to-sql/index.spec.ts @@ -0,0 +1,21 @@ +import { schemaDiffToSql } from 'src/sql-tools'; +import { describe, expect, it } from 'vitest'; + +describe(schemaDiffToSql.name, () => { + describe('comments', () => { + it('should include the reason in a SQL comment', () => { + expect( + schemaDiffToSql( + [ + { + type: 'index.drop', + indexName: 'IDX_test', + reason: 'unknown', + }, + ], + { comments: true }, + ), + ).toEqual([`DROP INDEX "IDX_test"; -- unknown`]); + }); + }); +}); diff --git a/server/src/sql-tools/to-sql/index.ts b/server/src/sql-tools/to-sql/index.ts new file mode 100644 index 0000000000..973c7ef287 --- /dev/null +++ b/server/src/sql-tools/to-sql/index.ts @@ -0,0 +1,59 @@ +import { transformColumns } from 'src/sql-tools/to-sql/transformers/column.transformer'; +import { transformConstraints } from 'src/sql-tools/to-sql/transformers/constraint.transformer'; +import { transformEnums } from 'src/sql-tools/to-sql/transformers/enum.transformer'; +import { transformExtensions } from 'src/sql-tools/to-sql/transformers/extension.transformer'; +import { transformFunctions } from 'src/sql-tools/to-sql/transformers/function.transformer'; +import { transformIndexes } from 'src/sql-tools/to-sql/transformers/index.transformer'; +import { transformParameters } from 'src/sql-tools/to-sql/transformers/parameter.transformer'; +import { transformTables } from 'src/sql-tools/to-sql/transformers/table.transformer'; +import { transformTriggers } from 'src/sql-tools/to-sql/transformers/trigger.transformer'; +import { SqlTransformer } from 'src/sql-tools/to-sql/transformers/types'; +import { SchemaDiff, SchemaDiffToSqlOptions } from 'src/sql-tools/types'; + +/** + * Convert schema diffs into SQL statements + */ +export const schemaDiffToSql = (items: SchemaDiff[], options: SchemaDiffToSqlOptions = {}): string[] => { + return items.flatMap((item) => asSql(item).map((result) => result + withComments(options.comments, item))); +}; + +const transformers: SqlTransformer[] = [ + transformColumns, + transformConstraints, + transformEnums, + transformExtensions, + transformFunctions, + transformIndexes, + transformParameters, + transformTables, + transformTriggers, +]; + +const asSql = (item: SchemaDiff): string[] => { + for (const transform of transformers) { + const result = transform(item); + if (!result) { + continue; + } + + return asArray(result); + } + + throw new Error(`Unhandled schema diff type: ${item.type}`); +}; + +const withComments = (comments: boolean | undefined, item: SchemaDiff): string => { + if (!comments) { + return ''; + } + + return ` -- ${item.reason}`; +}; + +const asArray = (items: T | T[]): T[] => { + if (Array.isArray(items)) { + return items; + } + + return [items]; +}; diff --git a/server/src/sql-tools/to-sql/transformers/column.transformer.spec.ts b/server/src/sql-tools/to-sql/transformers/column.transformer.spec.ts new file mode 100644 index 0000000000..8bf5ac3bc4 --- /dev/null +++ b/server/src/sql-tools/to-sql/transformers/column.transformer.spec.ts @@ -0,0 +1,126 @@ +import { transformColumns } from 'src/sql-tools/to-sql/transformers/column.transformer'; +import { describe, expect, it } from 'vitest'; + +describe(transformColumns.name, () => { + describe('column.add', () => { + it('should work', () => { + expect( + transformColumns({ + type: 'column.add', + column: { + name: 'column1', + tableName: 'table1', + type: 'character varying', + nullable: false, + isArray: false, + synchronize: true, + }, + reason: 'unknown', + }), + ).toEqual('ALTER TABLE "table1" ADD "column1" character varying NOT NULL;'); + }); + + it('should add a nullable column', () => { + expect( + transformColumns({ + type: 'column.add', + column: { + name: 'column1', + tableName: 'table1', + type: 'character varying', + nullable: true, + isArray: false, + synchronize: true, + }, + reason: 'unknown', + }), + ).toEqual('ALTER TABLE "table1" ADD "column1" character varying;'); + }); + + it('should add a column with an enum type', () => { + expect( + transformColumns({ + type: 'column.add', + column: { + name: 'column1', + tableName: 'table1', + type: 'character varying', + enumName: 'table1_column1_enum', + nullable: true, + isArray: false, + synchronize: true, + }, + reason: 'unknown', + }), + ).toEqual('ALTER TABLE "table1" ADD "column1" table1_column1_enum;'); + }); + + it('should add a column that is an array type', () => { + expect( + transformColumns({ + type: 'column.add', + column: { + name: 'column1', + tableName: 'table1', + type: 'boolean', + nullable: true, + isArray: true, + synchronize: true, + }, + reason: 'unknown', + }), + ).toEqual('ALTER TABLE "table1" ADD "column1" boolean[];'); + }); + }); + + describe('column.alter', () => { + it('should make a column nullable', () => { + expect( + transformColumns({ + type: 'column.alter', + tableName: 'table1', + columnName: 'column1', + changes: { nullable: true }, + reason: 'unknown', + }), + ).toEqual([`ALTER TABLE "table1" ALTER COLUMN "column1" DROP NOT NULL;`]); + }); + + it('should make a column non-nullable', () => { + expect( + transformColumns({ + type: 'column.alter', + tableName: 'table1', + columnName: 'column1', + changes: { nullable: false }, + reason: 'unknown', + }), + ).toEqual([`ALTER TABLE "table1" ALTER COLUMN "column1" SET NOT NULL;`]); + }); + + it('should update the default value', () => { + expect( + transformColumns({ + type: 'column.alter', + tableName: 'table1', + columnName: 'column1', + changes: { default: 'uuid_generate_v4()' }, + reason: 'unknown', + }), + ).toEqual([`ALTER TABLE "table1" ALTER COLUMN "column1" SET DEFAULT uuid_generate_v4();`]); + }); + }); + + describe('column.drop', () => { + it('should work', () => { + expect( + transformColumns({ + type: 'column.drop', + tableName: 'table1', + columnName: 'column1', + reason: 'unknown', + }), + ).toEqual(`ALTER TABLE "table1" DROP COLUMN "column1";`); + }); + }); +}); diff --git a/server/src/sql-tools/to-sql/transformers/column.transformer.ts b/server/src/sql-tools/to-sql/transformers/column.transformer.ts new file mode 100644 index 0000000000..117b460938 --- /dev/null +++ b/server/src/sql-tools/to-sql/transformers/column.transformer.ts @@ -0,0 +1,55 @@ +import { asColumnComment, getColumnModifiers, getColumnType } from 'src/sql-tools/helpers'; +import { SqlTransformer } from 'src/sql-tools/to-sql/transformers/types'; +import { ColumnChanges, DatabaseColumn, SchemaDiff } from 'src/sql-tools/types'; + +export const transformColumns: SqlTransformer = (item: SchemaDiff) => { + switch (item.type) { + case 'column.add': { + return asColumnAdd(item.column); + } + + case 'column.alter': { + return asColumnAlter(item.tableName, item.columnName, item.changes); + } + + case 'column.drop': { + return asColumnDrop(item.tableName, item.columnName); + } + + default: { + return false; + } + } +}; + +const asColumnAdd = (column: DatabaseColumn): string => { + return ( + `ALTER TABLE "${column.tableName}" ADD "${column.name}" ${getColumnType(column)}` + getColumnModifiers(column) + ';' + ); +}; + +const asColumnDrop = (tableName: string, columnName: string): string => { + return `ALTER TABLE "${tableName}" DROP COLUMN "${columnName}";`; +}; + +export const asColumnAlter = (tableName: string, columnName: string, changes: ColumnChanges): string[] => { + const base = `ALTER TABLE "${tableName}" ALTER COLUMN "${columnName}"`; + const items: string[] = []; + if (changes.nullable !== undefined) { + items.push(changes.nullable ? `${base} DROP NOT NULL;` : `${base} SET NOT NULL;`); + } + + if (changes.default !== undefined) { + items.push(`${base} SET DEFAULT ${changes.default};`); + } + + if (changes.storage !== undefined) { + items.push(`${base} SET STORAGE ${changes.storage.toUpperCase()};`); + } + + if (changes.comment !== undefined) { + items.push(asColumnComment(tableName, columnName, changes.comment)); + } + + return items; +}; diff --git a/server/src/sql-tools/to-sql/transformers/constraint.transformer.spec.ts b/server/src/sql-tools/to-sql/transformers/constraint.transformer.spec.ts new file mode 100644 index 0000000000..59d21e7b50 --- /dev/null +++ b/server/src/sql-tools/to-sql/transformers/constraint.transformer.spec.ts @@ -0,0 +1,96 @@ +import { transformConstraints } from 'src/sql-tools/to-sql/transformers/constraint.transformer'; +import { DatabaseConstraintType } from 'src/sql-tools/types'; +import { describe, expect, it } from 'vitest'; + +describe(transformConstraints.name, () => { + describe('constraint.add', () => { + describe('primary keys', () => { + it('should work', () => { + expect( + transformConstraints({ + type: 'constraint.add', + constraint: { + type: DatabaseConstraintType.PRIMARY_KEY, + name: 'PK_test', + tableName: 'table1', + columnNames: ['id'], + synchronize: true, + }, + reason: 'unknown', + }), + ).toEqual('ALTER TABLE "table1" ADD CONSTRAINT "PK_test" PRIMARY KEY ("id");'); + }); + }); + + describe('foreign keys', () => { + it('should work', () => { + expect( + transformConstraints({ + type: 'constraint.add', + constraint: { + type: DatabaseConstraintType.FOREIGN_KEY, + name: 'FK_test', + tableName: 'table1', + columnNames: ['parentId'], + referenceColumnNames: ['id'], + referenceTableName: 'table2', + synchronize: true, + }, + reason: 'unknown', + }), + ).toEqual( + 'ALTER TABLE "table1" ADD CONSTRAINT "FK_test" FOREIGN KEY ("parentId") REFERENCES "table2" ("id") ON UPDATE NO ACTION ON DELETE NO ACTION;', + ); + }); + }); + + describe('unique', () => { + it('should work', () => { + expect( + transformConstraints({ + type: 'constraint.add', + constraint: { + type: DatabaseConstraintType.UNIQUE, + name: 'UQ_test', + tableName: 'table1', + columnNames: ['id'], + synchronize: true, + }, + reason: 'unknown', + }), + ).toEqual('ALTER TABLE "table1" ADD CONSTRAINT "UQ_test" UNIQUE ("id");'); + }); + }); + + describe('check', () => { + it('should work', () => { + expect( + transformConstraints({ + type: 'constraint.add', + constraint: { + type: DatabaseConstraintType.CHECK, + name: 'CHK_test', + tableName: 'table1', + expression: '"id" IS NOT NULL', + synchronize: true, + }, + reason: 'unknown', + }), + ).toEqual('ALTER TABLE "table1" ADD CONSTRAINT "CHK_test" CHECK ("id" IS NOT NULL);'); + }); + }); + }); + + describe('constraint.drop', () => { + it('should work', () => { + expect( + transformConstraints({ + type: 'constraint.drop', + tableName: 'table1', + constraintName: 'PK_test', + reason: 'unknown', + }), + ).toEqual(`ALTER TABLE "table1" DROP CONSTRAINT "PK_test";`); + }); + }); +}); diff --git a/server/src/sql-tools/to-sql/transformers/constraint.transformer.ts b/server/src/sql-tools/to-sql/transformers/constraint.transformer.ts new file mode 100644 index 0000000000..ec65143eba --- /dev/null +++ b/server/src/sql-tools/to-sql/transformers/constraint.transformer.ts @@ -0,0 +1,58 @@ +import { asColumnList } from 'src/sql-tools/helpers'; +import { SqlTransformer } from 'src/sql-tools/to-sql/transformers/types'; +import { DatabaseActionType, DatabaseConstraint, DatabaseConstraintType, SchemaDiff } from 'src/sql-tools/types'; + +export const transformConstraints: SqlTransformer = (item: SchemaDiff) => { + switch (item.type) { + case 'constraint.add': { + return asConstraintAdd(item.constraint); + } + + case 'constraint.drop': { + return asConstraintDrop(item.tableName, item.constraintName); + } + default: { + return false; + } + } +}; + +const withAction = (constraint: { onDelete?: DatabaseActionType; onUpdate?: DatabaseActionType }) => + ` ON UPDATE ${constraint.onUpdate ?? DatabaseActionType.NO_ACTION} ON DELETE ${constraint.onDelete ?? DatabaseActionType.NO_ACTION}`; + +export const asConstraintAdd = (constraint: DatabaseConstraint): string | string[] => { + const base = `ALTER TABLE "${constraint.tableName}" ADD CONSTRAINT "${constraint.name}"`; + switch (constraint.type) { + case DatabaseConstraintType.PRIMARY_KEY: { + const columnNames = asColumnList(constraint.columnNames); + return `${base} PRIMARY KEY (${columnNames});`; + } + + case DatabaseConstraintType.FOREIGN_KEY: { + const columnNames = asColumnList(constraint.columnNames); + const referenceColumnNames = asColumnList(constraint.referenceColumnNames); + return ( + `${base} FOREIGN KEY (${columnNames}) REFERENCES "${constraint.referenceTableName}" (${referenceColumnNames})` + + withAction(constraint) + + ';' + ); + } + + case DatabaseConstraintType.UNIQUE: { + const columnNames = asColumnList(constraint.columnNames); + return `${base} UNIQUE (${columnNames});`; + } + + case DatabaseConstraintType.CHECK: { + return `${base} CHECK (${constraint.expression});`; + } + + default: { + return []; + } + } +}; + +export const asConstraintDrop = (tableName: string, constraintName: string): string => { + return `ALTER TABLE "${tableName}" DROP CONSTRAINT "${constraintName}";`; +}; diff --git a/server/src/sql-tools/to-sql/transformers/enum.transformer.ts b/server/src/sql-tools/to-sql/transformers/enum.transformer.ts new file mode 100644 index 0000000000..d5764d9b16 --- /dev/null +++ b/server/src/sql-tools/to-sql/transformers/enum.transformer.ts @@ -0,0 +1,26 @@ +import { SqlTransformer } from 'src/sql-tools/to-sql/transformers/types'; +import { DatabaseEnum, SchemaDiff } from 'src/sql-tools/types'; + +export const transformEnums: SqlTransformer = (item: SchemaDiff) => { + switch (item.type) { + case 'enum.create': { + return asEnumCreate(item.enum); + } + + case 'enum.drop': { + return asEnumDrop(item.enumName); + } + + default: { + return false; + } + } +}; + +const asEnumCreate = ({ name, values }: DatabaseEnum): string => { + return `CREATE TYPE "${name}" AS ENUM (${values.map((value) => `'${value}'`)});`; +}; + +const asEnumDrop = (enumName: string): string => { + return `DROP TYPE "${enumName}";`; +}; diff --git a/server/src/sql-tools/to-sql/transformers/extension.transformer.spec.ts b/server/src/sql-tools/to-sql/transformers/extension.transformer.spec.ts new file mode 100644 index 0000000000..81b2db4d27 --- /dev/null +++ b/server/src/sql-tools/to-sql/transformers/extension.transformer.spec.ts @@ -0,0 +1,31 @@ +import { transformExtensions } from 'src/sql-tools/to-sql/transformers/extension.transformer'; +import { describe, expect, it } from 'vitest'; + +describe(transformExtensions.name, () => { + describe('extension.drop', () => { + it('should work', () => { + expect( + transformExtensions({ + type: 'extension.drop', + extensionName: 'cube', + reason: 'unknown', + }), + ).toEqual(`DROP EXTENSION "cube";`); + }); + }); + + describe('extension.create', () => { + it('should work', () => { + expect( + transformExtensions({ + type: 'extension.create', + extension: { + name: 'cube', + synchronize: true, + }, + reason: 'unknown', + }), + ).toEqual(`CREATE EXTENSION IF NOT EXISTS "cube";`); + }); + }); +}); diff --git a/server/src/sql-tools/to-sql/transformers/extension.transformer.ts b/server/src/sql-tools/to-sql/transformers/extension.transformer.ts new file mode 100644 index 0000000000..2d51a26444 --- /dev/null +++ b/server/src/sql-tools/to-sql/transformers/extension.transformer.ts @@ -0,0 +1,26 @@ +import { SqlTransformer } from 'src/sql-tools/to-sql/transformers/types'; +import { DatabaseExtension, SchemaDiff } from 'src/sql-tools/types'; + +export const transformExtensions: SqlTransformer = (item: SchemaDiff) => { + switch (item.type) { + case 'extension.create': { + return asExtensionCreate(item.extension); + } + + case 'extension.drop': { + return asExtensionDrop(item.extensionName); + } + + default: { + return false; + } + } +}; + +const asExtensionCreate = (extension: DatabaseExtension): string => { + return `CREATE EXTENSION IF NOT EXISTS "${extension.name}";`; +}; + +const asExtensionDrop = (extensionName: string): string => { + return `DROP EXTENSION "${extensionName}";`; +}; diff --git a/server/src/sql-tools/to-sql/transformers/function.transformer.spec.ts b/server/src/sql-tools/to-sql/transformers/function.transformer.spec.ts new file mode 100644 index 0000000000..6e9a5bac56 --- /dev/null +++ b/server/src/sql-tools/to-sql/transformers/function.transformer.spec.ts @@ -0,0 +1,16 @@ +import { transformFunctions } from 'src/sql-tools/to-sql/transformers/function.transformer'; +import { describe, expect, it } from 'vitest'; + +describe(transformFunctions.name, () => { + describe('function.drop', () => { + it('should work', () => { + expect( + transformFunctions({ + type: 'function.drop', + functionName: 'test_func', + reason: 'unknown', + }), + ).toEqual(`DROP FUNCTION test_func;`); + }); + }); +}); diff --git a/server/src/sql-tools/to-sql/transformers/function.transformer.ts b/server/src/sql-tools/to-sql/transformers/function.transformer.ts new file mode 100644 index 0000000000..f05eca099a --- /dev/null +++ b/server/src/sql-tools/to-sql/transformers/function.transformer.ts @@ -0,0 +1,26 @@ +import { SqlTransformer } from 'src/sql-tools/to-sql/transformers/types'; +import { DatabaseFunction, SchemaDiff } from 'src/sql-tools/types'; + +export const transformFunctions: SqlTransformer = (item: SchemaDiff) => { + switch (item.type) { + case 'function.create': { + return asFunctionCreate(item.function); + } + + case 'function.drop': { + return asFunctionDrop(item.functionName); + } + + default: { + return false; + } + } +}; + +const asFunctionCreate = (func: DatabaseFunction): string => { + return func.expression; +}; + +const asFunctionDrop = (functionName: string): string => { + return `DROP FUNCTION ${functionName};`; +}; diff --git a/server/src/sql-tools/to-sql/transformers/index.transformer.spec.ts b/server/src/sql-tools/to-sql/transformers/index.transformer.spec.ts new file mode 100644 index 0000000000..af3cc0286c --- /dev/null +++ b/server/src/sql-tools/to-sql/transformers/index.transformer.spec.ts @@ -0,0 +1,100 @@ +import { transformIndexes } from 'src/sql-tools/to-sql/transformers/index.transformer'; +import { describe, expect, it } from 'vitest'; + +describe(transformIndexes.name, () => { + describe('index.create', () => { + it('should work', () => { + expect( + transformIndexes({ + type: 'index.create', + index: { + name: 'IDX_test', + tableName: 'table1', + columnNames: ['column1'], + unique: false, + synchronize: true, + }, + reason: 'unknown', + }), + ).toEqual('CREATE INDEX "IDX_test" ON "table1" ("column1")'); + }); + + it('should create an unique index', () => { + expect( + transformIndexes({ + type: 'index.create', + index: { + name: 'IDX_test', + tableName: 'table1', + columnNames: ['column1'], + unique: true, + synchronize: true, + }, + reason: 'unknown', + }), + ).toEqual('CREATE UNIQUE INDEX "IDX_test" ON "table1" ("column1")'); + }); + + it('should create an index with a custom expression', () => { + expect( + transformIndexes({ + type: 'index.create', + index: { + name: 'IDX_test', + tableName: 'table1', + unique: false, + expression: '"id" IS NOT NULL', + synchronize: true, + }, + reason: 'unknown', + }), + ).toEqual('CREATE INDEX "IDX_test" ON "table1" ("id" IS NOT NULL)'); + }); + + it('should create an index with a where clause', () => { + expect( + transformIndexes({ + type: 'index.create', + index: { + name: 'IDX_test', + tableName: 'table1', + columnNames: ['id'], + unique: false, + where: '("id" IS NOT NULL)', + synchronize: true, + }, + reason: 'unknown', + }), + ).toEqual('CREATE INDEX "IDX_test" ON "table1" ("id") WHERE ("id" IS NOT NULL)'); + }); + + it('should create an index with a custom expression', () => { + expect( + transformIndexes({ + type: 'index.create', + index: { + name: 'IDX_test', + tableName: 'table1', + unique: false, + using: 'gin', + expression: '"id" IS NOT NULL', + synchronize: true, + }, + reason: 'unknown', + }), + ).toEqual('CREATE INDEX "IDX_test" ON "table1" USING gin ("id" IS NOT NULL)'); + }); + }); + + describe('index.drop', () => { + it('should work', () => { + expect( + transformIndexes({ + type: 'index.drop', + indexName: 'IDX_test', + reason: 'unknown', + }), + ).toEqual(`DROP INDEX "IDX_test";`); + }); + }); +}); diff --git a/server/src/sql-tools/to-sql/transformers/index.transformer.ts b/server/src/sql-tools/to-sql/transformers/index.transformer.ts new file mode 100644 index 0000000000..73d9ac9615 --- /dev/null +++ b/server/src/sql-tools/to-sql/transformers/index.transformer.ts @@ -0,0 +1,56 @@ +import { asColumnList } from 'src/sql-tools/helpers'; +import { SqlTransformer } from 'src/sql-tools/to-sql/transformers/types'; +import { DatabaseIndex, SchemaDiff } from 'src/sql-tools/types'; + +export const transformIndexes: SqlTransformer = (item: SchemaDiff) => { + switch (item.type) { + case 'index.create': { + return asIndexCreate(item.index); + } + + case 'index.drop': { + return asIndexDrop(item.indexName); + } + + default: { + return false; + } + } +}; + +export const asIndexCreate = (index: DatabaseIndex): string => { + let sql = `CREATE`; + + if (index.unique) { + sql += ' UNIQUE'; + } + + sql += ` INDEX "${index.name}" ON "${index.tableName}"`; + + if (index.columnNames) { + const columnNames = asColumnList(index.columnNames); + sql += ` (${columnNames})`; + } + + if (index.using && index.using !== 'btree') { + sql += ` USING ${index.using}`; + } + + if (index.expression) { + sql += ` (${index.expression})`; + } + + if (index.with) { + sql += ` WITH (${index.with})`; + } + + if (index.where) { + sql += ` WHERE ${index.where}`; + } + + return sql; +}; + +export const asIndexDrop = (indexName: string): string => { + return `DROP INDEX "${indexName}";`; +}; diff --git a/server/src/sql-tools/to-sql/transformers/parameter.transformer.ts b/server/src/sql-tools/to-sql/transformers/parameter.transformer.ts new file mode 100644 index 0000000000..0b12cdb27b --- /dev/null +++ b/server/src/sql-tools/to-sql/transformers/parameter.transformer.ts @@ -0,0 +1,33 @@ +import { SqlTransformer } from 'src/sql-tools/to-sql/transformers/types'; +import { DatabaseParameter, SchemaDiff } from 'src/sql-tools/types'; + +export const transformParameters: SqlTransformer = (item: SchemaDiff) => { + switch (item.type) { + case 'parameter.set': { + return asParameterSet(item.parameter); + } + + case 'parameter.reset': { + return asParameterReset(item.databaseName, item.parameterName); + } + + default: { + return false; + } + } +}; + +const asParameterSet = (parameter: DatabaseParameter): string => { + let sql = ''; + if (parameter.scope === 'database') { + sql += `ALTER DATABASE "${parameter.databaseName}" `; + } + + sql += `SET ${parameter.name} TO ${parameter.value}`; + + return sql; +}; + +const asParameterReset = (databaseName: string, parameterName: string): string => { + return `ALTER DATABASE "${databaseName}" RESET "${parameterName}"`; +}; diff --git a/server/src/sql-tools/to-sql/transformers/table.transformer.spec.ts b/server/src/sql-tools/to-sql/transformers/table.transformer.spec.ts new file mode 100644 index 0000000000..db3ffa22ec --- /dev/null +++ b/server/src/sql-tools/to-sql/transformers/table.transformer.spec.ts @@ -0,0 +1,150 @@ +import { transformTables } from 'src/sql-tools/to-sql/transformers/table.transformer'; +import { describe, expect, it } from 'vitest'; + +describe(transformTables.name, () => { + describe('table.drop', () => { + it('should work', () => { + expect( + transformTables({ + type: 'table.drop', + tableName: 'table1', + reason: 'unknown', + }), + ).toEqual(`DROP TABLE "table1";`); + }); + }); + + describe('table.create', () => { + it('should work', () => { + expect( + transformTables({ + type: 'table.create', + table: { + name: 'table1', + columns: [ + { + tableName: 'table1', + name: 'column1', + type: 'character varying', + nullable: true, + isArray: false, + synchronize: true, + }, + ], + indexes: [], + constraints: [], + triggers: [], + synchronize: true, + }, + reason: 'unknown', + }), + ).toEqual([`CREATE TABLE "table1" ("column1" character varying);`]); + }); + + it('should handle a non-nullable column', () => { + expect( + transformTables({ + type: 'table.create', + table: { + name: 'table1', + columns: [ + { + tableName: 'table1', + name: 'column1', + type: 'character varying', + isArray: false, + nullable: false, + synchronize: true, + }, + ], + indexes: [], + constraints: [], + triggers: [], + synchronize: true, + }, + reason: 'unknown', + }), + ).toEqual([`CREATE TABLE "table1" ("column1" character varying NOT NULL);`]); + }); + + it('should handle a default value', () => { + expect( + transformTables({ + type: 'table.create', + table: { + name: 'table1', + columns: [ + { + tableName: 'table1', + name: 'column1', + type: 'character varying', + isArray: false, + nullable: true, + default: 'uuid_generate_v4()', + synchronize: true, + }, + ], + indexes: [], + constraints: [], + triggers: [], + synchronize: true, + }, + reason: 'unknown', + }), + ).toEqual([`CREATE TABLE "table1" ("column1" character varying DEFAULT uuid_generate_v4());`]); + }); + + it('should handle a string with a fixed length', () => { + expect( + transformTables({ + type: 'table.create', + table: { + name: 'table1', + columns: [ + { + tableName: 'table1', + name: 'column1', + type: 'character varying', + length: 2, + isArray: false, + nullable: true, + synchronize: true, + }, + ], + indexes: [], + constraints: [], + triggers: [], + synchronize: true, + }, + reason: 'unknown', + }), + ).toEqual([`CREATE TABLE "table1" ("column1" character varying(2));`]); + }); + + it('should handle an array type', () => { + expect( + transformTables({ + type: 'table.create', + table: { + name: 'table1', + columns: [ + { + tableName: 'table1', + name: 'column1', + type: 'character varying', + isArray: true, + nullable: true, + synchronize: true, + }, + ], + indexes: [], + constraints: [], + triggers: [], + synchronize: true, + }, + reason: 'unknown', + }), + ).toEqual([`CREATE TABLE "table1" ("column1" character varying[]);`]); + }); + }); +}); diff --git a/server/src/sql-tools/to-sql/transformers/table.transformer.ts b/server/src/sql-tools/to-sql/transformers/table.transformer.ts new file mode 100644 index 0000000000..f376b65274 --- /dev/null +++ b/server/src/sql-tools/to-sql/transformers/table.transformer.ts @@ -0,0 +1,44 @@ +import { asColumnComment, getColumnModifiers, getColumnType } from 'src/sql-tools/helpers'; +import { asColumnAlter } from 'src/sql-tools/to-sql/transformers/column.transformer'; +import { SqlTransformer } from 'src/sql-tools/to-sql/transformers/types'; +import { DatabaseTable, SchemaDiff } from 'src/sql-tools/types'; + +export const transformTables: SqlTransformer = (item: SchemaDiff) => { + switch (item.type) { + case 'table.create': { + return asTableCreate(item.table); + } + + case 'table.drop': { + return asTableDrop(item.tableName); + } + + default: { + return false; + } + } +}; + +const asTableCreate = (table: DatabaseTable): string[] => { + const tableName = table.name; + const columnsTypes = table.columns + .map((column) => `"${column.name}" ${getColumnType(column)}` + getColumnModifiers(column)) + .join(', '); + const items = [`CREATE TABLE "${tableName}" (${columnsTypes});`]; + + for (const column of table.columns) { + if (column.comment) { + items.push(asColumnComment(tableName, column.name, column.comment)); + } + + if (column.storage) { + items.push(...asColumnAlter(tableName, column.name, { storage: column.storage })); + } + } + + return items; +}; + +const asTableDrop = (tableName: string): string => { + return `DROP TABLE "${tableName}";`; +}; diff --git a/server/src/sql-tools/to-sql/transformers/trigger.transformer.spec.ts b/server/src/sql-tools/to-sql/transformers/trigger.transformer.spec.ts new file mode 100644 index 0000000000..778de88cba --- /dev/null +++ b/server/src/sql-tools/to-sql/transformers/trigger.transformer.spec.ts @@ -0,0 +1,91 @@ +import { transformTriggers } from 'src/sql-tools/to-sql/transformers/trigger.transformer'; +import { describe, expect, it } from 'vitest'; + +describe(transformTriggers.name, () => { + describe('trigger.create', () => { + it('should work', () => { + expect( + transformTriggers({ + type: 'trigger.create', + trigger: { + name: 'trigger1', + tableName: 'table1', + timing: 'before', + actions: ['update'], + scope: 'row', + functionName: 'function1', + synchronize: true, + }, + reason: 'unknown', + }), + ).toEqual( + `CREATE OR REPLACE TRIGGER "trigger1" + BEFORE UPDATE ON "table1" + FOR EACH ROW + EXECUTE FUNCTION function1();`, + ); + }); + + it('should work with multiple actions', () => { + expect( + transformTriggers({ + type: 'trigger.create', + trigger: { + name: 'trigger1', + tableName: 'table1', + timing: 'before', + actions: ['update', 'delete'], + scope: 'row', + functionName: 'function1', + synchronize: true, + }, + reason: 'unknown', + }), + ).toEqual( + `CREATE OR REPLACE TRIGGER "trigger1" + BEFORE UPDATE OR DELETE ON "table1" + FOR EACH ROW + EXECUTE FUNCTION function1();`, + ); + }); + + it('should work with old/new reference table aliases', () => { + expect( + transformTriggers({ + type: 'trigger.create', + trigger: { + name: 'trigger1', + tableName: 'table1', + timing: 'before', + actions: ['update'], + referencingNewTableAs: 'new', + referencingOldTableAs: 'old', + scope: 'row', + functionName: 'function1', + synchronize: true, + }, + reason: 'unknown', + }), + ).toEqual( + `CREATE OR REPLACE TRIGGER "trigger1" + BEFORE UPDATE ON "table1" + REFERENCING OLD TABLE AS "old" NEW TABLE AS "new" + FOR EACH ROW + EXECUTE FUNCTION function1();`, + ); + }); + }); + + describe('trigger.drop', () => { + it('should work', () => { + expect( + transformTriggers({ + type: 'trigger.drop', + tableName: 'table1', + triggerName: 'trigger1', + reason: 'unknown', + }), + ).toEqual(`DROP TRIGGER "trigger1" ON "table1";`); + }); + }); +}); diff --git a/server/src/sql-tools/to-sql/transformers/trigger.transformer.ts b/server/src/sql-tools/to-sql/transformers/trigger.transformer.ts new file mode 100644 index 0000000000..c104a2ed6b --- /dev/null +++ b/server/src/sql-tools/to-sql/transformers/trigger.transformer.ts @@ -0,0 +1,52 @@ +import { SqlTransformer } from 'src/sql-tools/to-sql/transformers/types'; +import { DatabaseTrigger, SchemaDiff } from 'src/sql-tools/types'; + +export const transformTriggers: SqlTransformer = (item: SchemaDiff) => { + switch (item.type) { + case 'trigger.create': { + return asTriggerCreate(item.trigger); + } + + case 'trigger.drop': { + return asTriggerDrop(item.tableName, item.triggerName); + } + + default: { + return false; + } + } +}; + +export const asTriggerCreate = (trigger: DatabaseTrigger): string => { + const sql: string[] = [ + `CREATE OR REPLACE TRIGGER "${trigger.name}"`, + `${trigger.timing.toUpperCase()} ${trigger.actions.map((action) => action.toUpperCase()).join(' OR ')} ON "${trigger.tableName}"`, + ]; + + if (trigger.referencingOldTableAs || trigger.referencingNewTableAs) { + let statement = `REFERENCING`; + if (trigger.referencingOldTableAs) { + statement += ` OLD TABLE AS "${trigger.referencingOldTableAs}"`; + } + if (trigger.referencingNewTableAs) { + statement += ` NEW TABLE AS "${trigger.referencingNewTableAs}"`; + } + sql.push(statement); + } + + if (trigger.scope) { + sql.push(`FOR EACH ${trigger.scope.toUpperCase()}`); + } + + if (trigger.when) { + sql.push(`WHEN (${trigger.when})`); + } + + sql.push(`EXECUTE FUNCTION ${trigger.functionName}();`); + + return sql.join('\n '); +}; + +export const asTriggerDrop = (tableName: string, triggerName: string): string => { + return `DROP TRIGGER "${triggerName}" ON "${tableName}";`; +}; diff --git a/server/src/sql-tools/to-sql/transformers/types.ts b/server/src/sql-tools/to-sql/transformers/types.ts new file mode 100644 index 0000000000..9aa1031f85 --- /dev/null +++ b/server/src/sql-tools/to-sql/transformers/types.ts @@ -0,0 +1,3 @@ +import { SchemaDiff } from 'src/sql-tools/types'; + +export type SqlTransformer = (item: SchemaDiff) => string | string[] | false; diff --git a/server/src/sql-tools/types.ts b/server/src/sql-tools/types.ts index 64813ca348..aea1288f3d 100644 --- a/server/src/sql-tools/types.ts +++ b/server/src/sql-tools/types.ts @@ -55,6 +55,42 @@ export type PostgresDB = { conindid: number; }; + pg_description: { + objoid: string; + classoid: string; + objsubid: number; + description: string; + }; + + pg_trigger: { + oid: string; + tgisinternal: boolean; + tginitdeferred: boolean; + tgdeferrable: boolean; + tgrelid: string; + tgfoid: string; + tgname: string; + tgenabled: string; + tgtype: number; + tgconstraint: string; + tgdeferred: boolean; + tgargs: Buffer; + tgoldtable: string; + tgnewtable: string; + tgqual: string; + }; + + 'pg_catalog.pg_extension': { + oid: string; + extname: string; + extowner: string; + extnamespace: string; + extrelocatable: boolean; + extversion: string; + extconfig: string[]; + extcondition: string[]; + }; + pg_enum: { oid: string; enumtypid: string; @@ -99,6 +135,38 @@ export type PostgresDB = { typarray: string; }; + pg_depend: { + objid: string; + deptype: string; + }; + + pg_proc: { + oid: string; + proname: string; + pronamespace: string; + prokind: string; + }; + + pg_settings: { + name: string; + setting: string; + unit: string | null; + category: string; + short_desc: string | null; + extra_desc: string | null; + context: string; + vartype: string; + source: string; + min_val: string | null; + max_val: string | null; + enumvals: string[] | null; + boot_val: string | null; + reset_val: string | null; + sourcefile: string | null; + sourceline: number | null; + pending_restart: PostgresYesOrNo; + }; + 'information_schema.tables': { table_catalog: string; table_schema: string; @@ -142,12 +210,31 @@ export type PostgresDB = { collection_type_identifier: string; data_type: string; }; + + 'information_schema.routines': { + specific_catalog: string; + specific_schema: string; + specific_name: string; + routine_catalog: string; + routine_schema: string; + routine_name: string; + routine_type: string; + data_type: string; + type_udt_catalog: string; + type_udt_schema: string; + type_udt_name: string; + dtd_identifier: string; + routine_body: string; + routine_definition: string; + external_name: string; + external_language: string; + is_deterministic: PostgresYesOrNo; + security_type: string; + }; }; type PostgresYesOrNo = 'YES' | 'NO'; -export type ColumnDefaultValue = null | boolean | string | number | object | Date | (() => string); - export type DatabaseClient = Kysely; export enum DatabaseConstraintType { @@ -165,7 +252,9 @@ export enum DatabaseActionType { SET_DEFAULT = 'SET DEFAULT', } -export type DatabaseColumnType = +export type ColumnStorage = 'default' | 'external' | 'extended' | 'main' | 'default'; + +export type ColumnType = | 'bigint' | 'boolean' | 'bytea' @@ -188,71 +277,63 @@ export type DatabaseColumnType = | 'enum' | 'serial'; -export type TableOptions = { - name?: string; - primaryConstraintName?: string; - synchronize?: boolean; -}; - -type ColumnBaseOptions = { - name?: string; - primary?: boolean; - type?: DatabaseColumnType; - nullable?: boolean; - length?: number; - default?: ColumnDefaultValue; - synchronize?: boolean; -}; - -export type ColumnOptions = ColumnBaseOptions & { - enum?: object; - enumName?: string; - array?: boolean; - unique?: boolean; - uniqueConstraintName?: string; -}; - -export type GenerateColumnOptions = Omit & { - type?: 'v4' | 'v7'; -}; - -export type ColumnIndexOptions = { - name?: string; - unique?: boolean; - expression?: string; - using?: string; - where?: string; - synchronize?: boolean; -}; - -export type IndexOptions = ColumnIndexOptions & { - columns?: string[]; - synchronize?: boolean; -}; - -export type UniqueOptions = { - name?: string; - columns: string[]; - synchronize?: boolean; -}; - -export type CheckOptions = { - name?: string; - expression: string; - synchronize?: boolean; -}; - export type DatabaseSchema = { name: string; + schemaName: string; + functions: DatabaseFunction[]; + enums: DatabaseEnum[]; tables: DatabaseTable[]; + extensions: DatabaseExtension[]; + parameters: DatabaseParameter[]; warnings: string[]; }; +export type SchemaDiffOptions = { + tables?: DiffOptions; + functions?: DiffOptions; + enums?: DiffOptions; + extension?: DiffOptions; + parameters?: DiffOptions; +}; + +export type DiffOptions = { + ignoreExtra?: boolean; + ignoreMissing?: boolean; +}; + +export type DatabaseParameter = { + name: string; + databaseName: string; + value: string | number | null | undefined; + scope: ParameterScope; + synchronize: boolean; +}; + +export type ParameterScope = 'database' | 'user'; + +export type DatabaseEnum = { + name: string; + values: string[]; + synchronize: boolean; +}; + +export type DatabaseFunction = { + name: string; + expression: string; + synchronize: boolean; +}; + +export type DatabaseExtension = { + name: string; + synchronize: boolean; +}; + export type DatabaseTable = { name: string; columns: DatabaseColumn[]; indexes: DatabaseIndex[]; constraints: DatabaseConstraint[]; + triggers: DatabaseTrigger[]; synchronize: boolean; }; @@ -266,17 +347,19 @@ export type DatabaseColumn = { primary?: boolean; name: string; tableName: string; + comment?: string; - type: DatabaseColumnType; + type: ColumnType; nullable: boolean; isArray: boolean; synchronize: boolean; default?: string; length?: number; + storage?: ColumnStorage; + identity?: boolean; // enum values - enumValues?: string[]; enumName?: string; // numeric types @@ -284,9 +367,11 @@ export type DatabaseColumn = { numericScale?: number; }; -export type DatabaseColumnChanges = { +export type ColumnChanges = { nullable?: boolean; default?: string; + comment?: string; + storage?: ColumnStorage; }; type ColumBasedConstraint = { @@ -322,6 +407,22 @@ export type DatabaseCheckConstraint = { synchronize: boolean; }; +export type DatabaseTrigger = { + name: string; + tableName: string; + timing: TriggerTiming; + actions: TriggerAction[]; + scope: TriggerScope; + referencingNewTableAs?: string; + referencingOldTableAs?: string; + when?: string; + functionName: string; + synchronize: boolean; +}; +export type TriggerTiming = 'before' | 'after' | 'instead of'; +export type TriggerAction = 'insert' | 'update' | 'delete' | 'truncate'; +export type TriggerScope = 'row' | 'statement'; + export type DatabaseIndex = { name: string; tableName: string; @@ -329,6 +430,7 @@ export type DatabaseIndex = { expression?: string; unique: boolean; using?: string; + with?: string; where?: string; synchronize: boolean; }; @@ -342,22 +444,35 @@ export type SchemaDiffToSqlOptions = { }; export type SchemaDiff = { reason: string } & ( - | { type: 'table.create'; tableName: string; columns: DatabaseColumn[] } + | { type: 'extension.create'; extension: DatabaseExtension } + | { type: 'extension.drop'; extensionName: string } + | { type: 'function.create'; function: DatabaseFunction } + | { type: 'function.drop'; functionName: string } + | { type: 'table.create'; table: DatabaseTable } | { type: 'table.drop'; tableName: string } | { type: 'column.add'; column: DatabaseColumn } - | { type: 'column.alter'; tableName: string; columnName: string; changes: DatabaseColumnChanges } + | { type: 'column.alter'; tableName: string; columnName: string; changes: ColumnChanges } | { type: 'column.drop'; tableName: string; columnName: string } | { type: 'constraint.add'; constraint: DatabaseConstraint } | { type: 'constraint.drop'; tableName: string; constraintName: string } | { type: 'index.create'; index: DatabaseIndex } | { type: 'index.drop'; indexName: string } + | { type: 'trigger.create'; trigger: DatabaseTrigger } + | { type: 'trigger.drop'; tableName: string; triggerName: string } + | { type: 'parameter.set'; parameter: DatabaseParameter } + | { type: 'parameter.reset'; databaseName: string; parameterName: string } + | { type: 'enum.create'; enum: DatabaseEnum } + | { type: 'enum.drop'; enumName: string } ); -type Action = 'CASCADE' | 'SET NULL' | 'SET DEFAULT' | 'RESTRICT' | 'NO ACTION'; -export type ForeignKeyColumnOptions = ColumnBaseOptions & { - onUpdate?: Action; - onDelete?: Action; - constraintName?: string; - unique?: boolean; - uniqueConstraintName?: string; +export type CompareFunction = (source: T, target: T) => SchemaDiff[]; +export type Comparer = { + onMissing: (source: T) => SchemaDiff[]; + onExtra: (target: T) => SchemaDiff[]; + onCompare: CompareFunction; }; + +export enum Reason { + MissingInSource = 'missing in source', + MissingInTarget = 'missing in target', +} diff --git a/server/test/sql-tools/check-constraint-default-name.stub.ts b/server/test/sql-tools/check-constraint-default-name.stub.ts index 42ee336b94..af03e02a2e 100644 --- a/server/test/sql-tools/check-constraint-default-name.stub.ts +++ b/server/test/sql-tools/check-constraint-default-name.stub.ts @@ -9,7 +9,12 @@ export class Table1 { export const description = 'should create a check constraint with a default name'; export const schema: DatabaseSchema = { - name: 'public', + name: 'postgres', + schemaName: 'public', + functions: [], + enums: [], + extensions: [], + parameters: [], tables: [ { name: 'table1', @@ -25,6 +30,7 @@ export const schema: DatabaseSchema = { }, ], indexes: [], + triggers: [], constraints: [ { type: DatabaseConstraintType.CHECK, diff --git a/server/test/sql-tools/check-constraint-override-name.stub.ts b/server/test/sql-tools/check-constraint-override-name.stub.ts index 89db6044a2..b30025e2fc 100644 --- a/server/test/sql-tools/check-constraint-override-name.stub.ts +++ b/server/test/sql-tools/check-constraint-override-name.stub.ts @@ -9,7 +9,12 @@ export class Table1 { export const description = 'should create a check constraint with a specific name'; export const schema: DatabaseSchema = { - name: 'public', + name: 'postgres', + schemaName: 'public', + functions: [], + enums: [], + extensions: [], + parameters: [], tables: [ { name: 'table1', @@ -25,6 +30,7 @@ export const schema: DatabaseSchema = { }, ], indexes: [], + triggers: [], constraints: [ { type: DatabaseConstraintType.CHECK, diff --git a/server/test/sql-tools/column-create-date.stub.ts b/server/test/sql-tools/column-create-date.stub.ts new file mode 100644 index 0000000000..7a284c674c --- /dev/null +++ b/server/test/sql-tools/column-create-date.stub.ts @@ -0,0 +1,39 @@ +import { CreateDateColumn, DatabaseSchema, Table } from 'src/sql-tools'; + +@Table() +export class Table1 { + @CreateDateColumn() + createdAt!: string; +} + +export const description = 'should register a table with an created at date column'; +export const schema: DatabaseSchema = { + name: 'postgres', + schemaName: 'public', + functions: [], + enums: [], + extensions: [], + parameters: [], + tables: [ + { + name: 'table1', + columns: [ + { + name: 'createdAt', + tableName: 'table1', + type: 'timestamp with time zone', + default: 'now()', + nullable: false, + isArray: false, + primary: false, + synchronize: true, + }, + ], + indexes: [], + triggers: [], + constraints: [], + synchronize: true, + }, + ], + warnings: [], +}; diff --git a/server/test/sql-tools/column-default-boolean.stub.ts b/server/test/sql-tools/column-default-boolean.stub.ts index 464a34b26e..962b023a25 100644 --- a/server/test/sql-tools/column-default-boolean.stub.ts +++ b/server/test/sql-tools/column-default-boolean.stub.ts @@ -8,7 +8,12 @@ export class Table1 { export const description = 'should register a table with a column with a default value (boolean)'; export const schema: DatabaseSchema = { - name: 'public', + name: 'postgres', + schemaName: 'public', + functions: [], + enums: [], + extensions: [], + parameters: [], tables: [ { name: 'table1', @@ -25,6 +30,7 @@ export const schema: DatabaseSchema = { }, ], indexes: [], + triggers: [], constraints: [], synchronize: true, }, diff --git a/server/test/sql-tools/column-default-date.stub.ts b/server/test/sql-tools/column-default-date.stub.ts index 72c06b3bd9..00f2db2c27 100644 --- a/server/test/sql-tools/column-default-date.stub.ts +++ b/server/test/sql-tools/column-default-date.stub.ts @@ -10,7 +10,12 @@ export class Table1 { export const description = 'should register a table with a column with a default value (date)'; export const schema: DatabaseSchema = { - name: 'public', + name: 'postgres', + schemaName: 'public', + functions: [], + enums: [], + extensions: [], + parameters: [], tables: [ { name: 'table1', @@ -27,6 +32,7 @@ export const schema: DatabaseSchema = { }, ], indexes: [], + triggers: [], constraints: [], synchronize: true, }, diff --git a/server/test/sql-tools/column-default-function.stub.ts b/server/test/sql-tools/column-default-function.stub.ts index ceb03b50f0..b13bd14c93 100644 --- a/server/test/sql-tools/column-default-function.stub.ts +++ b/server/test/sql-tools/column-default-function.stub.ts @@ -8,7 +8,12 @@ export class Table1 { export const description = 'should register a table with a column with a default function'; export const schema: DatabaseSchema = { - name: 'public', + name: 'postgres', + schemaName: 'public', + functions: [], + enums: [], + extensions: [], + parameters: [], tables: [ { name: 'table1', @@ -25,6 +30,7 @@ export const schema: DatabaseSchema = { }, ], indexes: [], + triggers: [], constraints: [], synchronize: true, }, diff --git a/server/test/sql-tools/column-default-null.stub.ts b/server/test/sql-tools/column-default-null.stub.ts index b4aa83788b..c88ed218b3 100644 --- a/server/test/sql-tools/column-default-null.stub.ts +++ b/server/test/sql-tools/column-default-null.stub.ts @@ -8,7 +8,12 @@ export class Table1 { export const description = 'should register a nullable column from a default of null'; export const schema: DatabaseSchema = { - name: 'public', + name: 'postgres', + schemaName: 'public', + functions: [], + enums: [], + extensions: [], + parameters: [], tables: [ { name: 'table1', @@ -24,6 +29,7 @@ export const schema: DatabaseSchema = { }, ], indexes: [], + triggers: [], constraints: [], synchronize: true, }, diff --git a/server/test/sql-tools/column-default-number.stub.ts b/server/test/sql-tools/column-default-number.stub.ts index f3fac229c7..36d0af5273 100644 --- a/server/test/sql-tools/column-default-number.stub.ts +++ b/server/test/sql-tools/column-default-number.stub.ts @@ -8,7 +8,12 @@ export class Table1 { export const description = 'should register a table with a column with a default value (number)'; export const schema: DatabaseSchema = { - name: 'public', + name: 'postgres', + schemaName: 'public', + functions: [], + enums: [], + extensions: [], + parameters: [], tables: [ { name: 'table1', @@ -25,6 +30,7 @@ export const schema: DatabaseSchema = { }, ], indexes: [], + triggers: [], constraints: [], synchronize: true, }, diff --git a/server/test/sql-tools/column-default-string.stub.ts b/server/test/sql-tools/column-default-string.stub.ts index 36aa584eeb..04a00a4dfe 100644 --- a/server/test/sql-tools/column-default-string.stub.ts +++ b/server/test/sql-tools/column-default-string.stub.ts @@ -8,7 +8,12 @@ export class Table1 { export const description = 'should register a table with a column with a default value (string)'; export const schema: DatabaseSchema = { - name: 'public', + name: 'postgres', + schemaName: 'public', + functions: [], + enums: [], + extensions: [], + parameters: [], tables: [ { name: 'table1', @@ -25,6 +30,7 @@ export const schema: DatabaseSchema = { }, ], indexes: [], + triggers: [], constraints: [], synchronize: true, }, diff --git a/server/test/sql-tools/column-delete-date.stub.ts b/server/test/sql-tools/column-delete-date.stub.ts new file mode 100644 index 0000000000..facbfb0328 --- /dev/null +++ b/server/test/sql-tools/column-delete-date.stub.ts @@ -0,0 +1,38 @@ +import { DatabaseSchema, DeleteDateColumn, Table } from 'src/sql-tools'; + +@Table() +export class Table1 { + @DeleteDateColumn() + deletedAt!: string; +} + +export const description = 'should register a table with a deleted at date column'; +export const schema: DatabaseSchema = { + name: 'postgres', + schemaName: 'public', + functions: [], + enums: [], + extensions: [], + parameters: [], + tables: [ + { + name: 'table1', + columns: [ + { + name: 'deletedAt', + tableName: 'table1', + type: 'timestamp with time zone', + nullable: true, + isArray: false, + primary: false, + synchronize: true, + }, + ], + indexes: [], + triggers: [], + constraints: [], + synchronize: true, + }, + ], + warnings: [], +}; diff --git a/server/test/sql-tools/column-enum-type.stub.ts b/server/test/sql-tools/column-enum-type.stub.ts new file mode 100644 index 0000000000..878910dcdb --- /dev/null +++ b/server/test/sql-tools/column-enum-type.stub.ts @@ -0,0 +1,52 @@ +import { Column, DatabaseSchema, registerEnum, Table } from 'src/sql-tools'; + +enum Test { + Foo = 'foo', + Bar = 'bar', +} + +const test_enum = registerEnum({ name: 'test_enum', values: Object.values(Test) }); + +@Table() +export class Table1 { + @Column({ enum: test_enum }) + column1!: string; +} + +export const description = 'should accept an enum type'; +export const schema: DatabaseSchema = { + name: 'postgres', + schemaName: 'public', + functions: [], + enums: [ + { + name: 'test_enum', + values: ['foo', 'bar'], + synchronize: true, + }, + ], + extensions: [], + parameters: [], + tables: [ + { + name: 'table1', + columns: [ + { + name: 'column1', + tableName: 'table1', + type: 'enum', + enumName: 'test_enum', + nullable: false, + isArray: false, + primary: false, + synchronize: true, + }, + ], + indexes: [], + triggers: [], + constraints: [], + synchronize: true, + }, + ], + warnings: [], +}; diff --git a/server/test/sql-tools/column-generated-identity.ts b/server/test/sql-tools/column-generated-identity.ts new file mode 100644 index 0000000000..98b0f582a6 --- /dev/null +++ b/server/test/sql-tools/column-generated-identity.ts @@ -0,0 +1,47 @@ +import { DatabaseConstraintType, DatabaseSchema, PrimaryGeneratedColumn, Table } from 'src/sql-tools'; + +@Table() +export class Table1 { + @PrimaryGeneratedColumn({ strategy: 'identity' }) + column1!: string; +} + +export const description = 'should register a table with a generated identity column'; +export const schema: DatabaseSchema = { + name: 'postgres', + schemaName: 'public', + functions: [], + enums: [], + extensions: [], + parameters: [], + tables: [ + { + name: 'table1', + columns: [ + { + name: 'column1', + tableName: 'table1', + type: 'integer', + identity: true, + nullable: false, + isArray: false, + primary: true, + synchronize: true, + }, + ], + indexes: [], + triggers: [], + constraints: [ + { + type: DatabaseConstraintType.PRIMARY_KEY, + name: 'PK_50c4f9905061b1e506d38a2a380', + tableName: 'table1', + columnNames: ['column1'], + synchronize: true, + }, + ], + synchronize: true, + }, + ], + warnings: [], +}; diff --git a/server/test/sql-tools/column-generated-uuid.stub.ts b/server/test/sql-tools/column-generated-uuid.stub.ts new file mode 100644 index 0000000000..69cc59530e --- /dev/null +++ b/server/test/sql-tools/column-generated-uuid.stub.ts @@ -0,0 +1,47 @@ +import { DatabaseConstraintType, DatabaseSchema, PrimaryGeneratedColumn, Table } from 'src/sql-tools'; + +@Table() +export class Table1 { + @PrimaryGeneratedColumn({ strategy: 'uuid' }) + column1!: string; +} + +export const description = 'should register a table with a primary generated uuid column'; +export const schema: DatabaseSchema = { + name: 'postgres', + schemaName: 'public', + functions: [], + enums: [], + extensions: [], + parameters: [], + tables: [ + { + name: 'table1', + columns: [ + { + name: 'column1', + tableName: 'table1', + type: 'uuid', + default: 'uuid_generate_v4()', + nullable: false, + isArray: false, + primary: true, + synchronize: true, + }, + ], + indexes: [], + triggers: [], + constraints: [ + { + type: DatabaseConstraintType.PRIMARY_KEY, + name: 'PK_50c4f9905061b1e506d38a2a380', + tableName: 'table1', + columnNames: ['column1'], + synchronize: true, + }, + ], + synchronize: true, + }, + ], + warnings: [], +}; diff --git a/server/test/sql-tools/column-index-name-default.ts b/server/test/sql-tools/column-index-name-default.ts index d3b5aba112..e8b36ec119 100644 --- a/server/test/sql-tools/column-index-name-default.ts +++ b/server/test/sql-tools/column-index-name-default.ts @@ -9,7 +9,12 @@ export class Table1 { export const description = 'should create a column with an index'; export const schema: DatabaseSchema = { - name: 'public', + name: 'postgres', + schemaName: 'public', + functions: [], + enums: [], + extensions: [], + parameters: [], tables: [ { name: 'table1', @@ -33,6 +38,7 @@ export const schema: DatabaseSchema = { synchronize: true, }, ], + triggers: [], constraints: [], synchronize: true, }, diff --git a/server/test/sql-tools/column-inferred-nullable.stub.ts b/server/test/sql-tools/column-inferred-nullable.stub.ts index d866b59093..70495db800 100644 --- a/server/test/sql-tools/column-inferred-nullable.stub.ts +++ b/server/test/sql-tools/column-inferred-nullable.stub.ts @@ -8,7 +8,12 @@ export class Table1 { export const description = 'should infer nullable from the default value'; export const schema: DatabaseSchema = { - name: 'public', + name: 'postgres', + schemaName: 'public', + functions: [], + enums: [], + extensions: [], + parameters: [], tables: [ { name: 'table1', @@ -24,6 +29,7 @@ export const schema: DatabaseSchema = { }, ], indexes: [], + triggers: [], constraints: [], synchronize: true, }, diff --git a/server/test/sql-tools/column-name-default.stub.ts b/server/test/sql-tools/column-name-default.stub.ts index 3c6df97fe4..e1db458e4b 100644 --- a/server/test/sql-tools/column-name-default.stub.ts +++ b/server/test/sql-tools/column-name-default.stub.ts @@ -8,7 +8,12 @@ export class Table1 { export const description = 'should register a table with a column with a default name'; export const schema: DatabaseSchema = { - name: 'public', + name: 'postgres', + schemaName: 'public', + functions: [], + enums: [], + extensions: [], + parameters: [], tables: [ { name: 'table1', @@ -24,6 +29,7 @@ export const schema: DatabaseSchema = { }, ], indexes: [], + triggers: [], constraints: [], synchronize: true, }, diff --git a/server/test/sql-tools/column-name-override.stub.ts b/server/test/sql-tools/column-name-override.stub.ts index b5e86e47d0..250e295280 100644 --- a/server/test/sql-tools/column-name-override.stub.ts +++ b/server/test/sql-tools/column-name-override.stub.ts @@ -8,7 +8,12 @@ export class Table1 { export const description = 'should register a table with a column with a specific name'; export const schema: DatabaseSchema = { - name: 'public', + name: 'postgres', + schemaName: 'public', + functions: [], + enums: [], + extensions: [], + parameters: [], tables: [ { name: 'table1', @@ -24,6 +29,7 @@ export const schema: DatabaseSchema = { }, ], indexes: [], + triggers: [], constraints: [], synchronize: true, }, diff --git a/server/test/sql-tools/column-name-string.stub.ts b/server/test/sql-tools/column-name-string.stub.ts index 013e74e7da..12f8b4a537 100644 --- a/server/test/sql-tools/column-name-string.stub.ts +++ b/server/test/sql-tools/column-name-string.stub.ts @@ -8,7 +8,12 @@ export class Table1 { export const description = 'should register a table with a column with a specific name'; export const schema: DatabaseSchema = { - name: 'public', + name: 'postgres', + schemaName: 'public', + functions: [], + enums: [], + extensions: [], + parameters: [], tables: [ { name: 'table1', @@ -24,6 +29,7 @@ export const schema: DatabaseSchema = { }, ], indexes: [], + triggers: [], constraints: [], synchronize: true, }, diff --git a/server/test/sql-tools/column-nullable.stub.ts b/server/test/sql-tools/column-nullable.stub.ts index 2704fb7cf6..2b82a3de13 100644 --- a/server/test/sql-tools/column-nullable.stub.ts +++ b/server/test/sql-tools/column-nullable.stub.ts @@ -8,7 +8,12 @@ export class Table1 { export const description = 'should set nullable correctly'; export const schema: DatabaseSchema = { - name: 'public', + name: 'postgres', + schemaName: 'public', + functions: [], + enums: [], + extensions: [], + parameters: [], tables: [ { name: 'table1', @@ -24,6 +29,7 @@ export const schema: DatabaseSchema = { }, ], indexes: [], + triggers: [], constraints: [], synchronize: true, }, diff --git a/server/test/sql-tools/column-enum-name.stub.ts b/server/test/sql-tools/column-string-length.stub.ts similarity index 63% rename from server/test/sql-tools/column-enum-name.stub.ts rename to server/test/sql-tools/column-string-length.stub.ts index 9ae1b4310d..47400f25e0 100644 --- a/server/test/sql-tools/column-enum-name.stub.ts +++ b/server/test/sql-tools/column-string-length.stub.ts @@ -1,19 +1,19 @@ import { Column, DatabaseSchema, Table } from 'src/sql-tools'; -enum Test { - Foo = 'foo', - Bar = 'bar', -} - @Table() export class Table1 { - @Column({ enum: Test }) + @Column({ length: 2 }) column1!: string; } -export const description = 'should use a default enum naming convention'; +export const description = 'should use create a string column with a fixed length'; export const schema: DatabaseSchema = { - name: 'public', + name: 'postgres', + schemaName: 'public', + functions: [], + enums: [], + extensions: [], + parameters: [], tables: [ { name: 'table1', @@ -21,9 +21,8 @@ export const schema: DatabaseSchema = { { name: 'column1', tableName: 'table1', - type: 'enum', - enumName: 'table1_column1_enum', - enumValues: ['foo', 'bar'], + type: 'character varying', + length: 2, nullable: false, isArray: false, primary: false, @@ -31,6 +30,7 @@ export const schema: DatabaseSchema = { }, ], indexes: [], + triggers: [], constraints: [], synchronize: true, }, diff --git a/server/test/sql-tools/column-unique-constraint-name-default.stub.ts b/server/test/sql-tools/column-unique-constraint-name-default.stub.ts index 6446a2069d..e1e1619679 100644 --- a/server/test/sql-tools/column-unique-constraint-name-default.stub.ts +++ b/server/test/sql-tools/column-unique-constraint-name-default.stub.ts @@ -8,7 +8,12 @@ export class Table1 { export const description = 'should create a unique key constraint with a default name'; export const schema: DatabaseSchema = { - name: 'public', + name: 'postgres', + schemaName: 'public', + functions: [], + enums: [], + extensions: [], + parameters: [], tables: [ { name: 'table1', @@ -24,6 +29,7 @@ export const schema: DatabaseSchema = { }, ], indexes: [], + triggers: [], constraints: [ { type: DatabaseConstraintType.UNIQUE, diff --git a/server/test/sql-tools/column-unique-constraint-name-override.stub.ts b/server/test/sql-tools/column-unique-constraint-name-override.stub.ts index fb96ff06b2..36ce80efb6 100644 --- a/server/test/sql-tools/column-unique-constraint-name-override.stub.ts +++ b/server/test/sql-tools/column-unique-constraint-name-override.stub.ts @@ -8,7 +8,12 @@ export class Table1 { export const description = 'should create a unique key constraint with a specific name'; export const schema: DatabaseSchema = { - name: 'public', + name: 'postgres', + schemaName: 'public', + functions: [], + enums: [], + extensions: [], + parameters: [], tables: [ { name: 'table1', @@ -24,6 +29,7 @@ export const schema: DatabaseSchema = { }, ], indexes: [], + triggers: [], constraints: [ { type: DatabaseConstraintType.UNIQUE, diff --git a/server/test/sql-tools/column-update-date.stub.ts b/server/test/sql-tools/column-update-date.stub.ts new file mode 100644 index 0000000000..bbdb6df923 --- /dev/null +++ b/server/test/sql-tools/column-update-date.stub.ts @@ -0,0 +1,39 @@ +import { DatabaseSchema, Table, UpdateDateColumn } from 'src/sql-tools'; + +@Table() +export class Table1 { + @UpdateDateColumn() + updatedAt!: string; +} + +export const description = 'should register a table with an updated at date column'; +export const schema: DatabaseSchema = { + name: 'postgres', + schemaName: 'public', + functions: [], + enums: [], + extensions: [], + parameters: [], + tables: [ + { + name: 'table1', + columns: [ + { + name: 'updatedAt', + tableName: 'table1', + type: 'timestamp with time zone', + default: 'now()', + nullable: false, + isArray: false, + primary: false, + synchronize: true, + }, + ], + indexes: [], + triggers: [], + constraints: [], + synchronize: true, + }, + ], + warnings: [], +}; diff --git a/server/test/sql-tools/foreign-key-inferred-type.stub.ts b/server/test/sql-tools/foreign-key-inferred-type.stub.ts index b88d834a76..2ecaafdcad 100644 --- a/server/test/sql-tools/foreign-key-inferred-type.stub.ts +++ b/server/test/sql-tools/foreign-key-inferred-type.stub.ts @@ -14,7 +14,12 @@ export class Table2 { export const description = 'should infer the column type from the reference column'; export const schema: DatabaseSchema = { - name: 'public', + name: 'postgres', + schemaName: 'public', + functions: [], + enums: [], + extensions: [], + parameters: [], tables: [ { name: 'table1', @@ -30,6 +35,7 @@ export const schema: DatabaseSchema = { }, ], indexes: [], + triggers: [], constraints: [ { type: DatabaseConstraintType.PRIMARY_KEY, @@ -55,6 +61,7 @@ export const schema: DatabaseSchema = { }, ], indexes: [], + triggers: [], constraints: [ { type: DatabaseConstraintType.FOREIGN_KEY, diff --git a/server/test/sql-tools/foreign-key-with-unique-constraint.stub.ts b/server/test/sql-tools/foreign-key-with-unique-constraint.stub.ts index 8bf2328fc3..0601a02d42 100644 --- a/server/test/sql-tools/foreign-key-with-unique-constraint.stub.ts +++ b/server/test/sql-tools/foreign-key-with-unique-constraint.stub.ts @@ -14,7 +14,12 @@ export class Table2 { export const description = 'should create a foreign key constraint with a unique constraint'; export const schema: DatabaseSchema = { - name: 'public', + name: 'postgres', + schemaName: 'public', + functions: [], + enums: [], + extensions: [], + parameters: [], tables: [ { name: 'table1', @@ -30,6 +35,7 @@ export const schema: DatabaseSchema = { }, ], indexes: [], + triggers: [], constraints: [ { type: DatabaseConstraintType.PRIMARY_KEY, @@ -55,6 +61,7 @@ export const schema: DatabaseSchema = { }, ], indexes: [], + triggers: [], constraints: [ { type: DatabaseConstraintType.FOREIGN_KEY, diff --git a/server/test/sql-tools/index-name-default.stub.ts b/server/test/sql-tools/index-name-default.stub.ts index ffadfb0b32..06ccd7e173 100644 --- a/server/test/sql-tools/index-name-default.stub.ts +++ b/server/test/sql-tools/index-name-default.stub.ts @@ -9,7 +9,12 @@ export class Table1 { export const description = 'should create an index with a default name'; export const schema: DatabaseSchema = { - name: 'public', + name: 'postgres', + schemaName: 'public', + functions: [], + enums: [], + extensions: [], + parameters: [], tables: [ { name: 'table1', @@ -33,6 +38,7 @@ export const schema: DatabaseSchema = { synchronize: true, }, ], + triggers: [], constraints: [], synchronize: true, }, diff --git a/server/test/sql-tools/index-name-override.stub.ts b/server/test/sql-tools/index-name-override.stub.ts index f72a0cbeb1..afdc26dcc0 100644 --- a/server/test/sql-tools/index-name-override.stub.ts +++ b/server/test/sql-tools/index-name-override.stub.ts @@ -9,7 +9,12 @@ export class Table1 { export const description = 'should create an index with a specific name'; export const schema: DatabaseSchema = { - name: 'public', + name: 'postgres', + schemaName: 'public', + functions: [], + enums: [], + extensions: [], + parameters: [], tables: [ { name: 'table1', @@ -33,6 +38,7 @@ export const schema: DatabaseSchema = { synchronize: true, }, ], + triggers: [], constraints: [], synchronize: true, }, diff --git a/server/test/sql-tools/index-with-where.stub copy.ts b/server/test/sql-tools/index-with-where.stub copy.ts index 0d22f4e115..dec31ebe02 100644 --- a/server/test/sql-tools/index-with-where.stub copy.ts +++ b/server/test/sql-tools/index-with-where.stub copy.ts @@ -9,7 +9,12 @@ export class Table1 { export const description = 'should create an index based off of an expression'; export const schema: DatabaseSchema = { - name: 'public', + name: 'postgres', + schemaName: 'public', + functions: [], + enums: [], + extensions: [], + parameters: [], tables: [ { name: 'table1', @@ -33,6 +38,7 @@ export const schema: DatabaseSchema = { synchronize: true, }, ], + triggers: [], constraints: [], synchronize: true, }, diff --git a/server/test/sql-tools/index-with-where.stub.ts b/server/test/sql-tools/index-with-where.stub.ts index e59d2ec36b..ce4236e490 100644 --- a/server/test/sql-tools/index-with-where.stub.ts +++ b/server/test/sql-tools/index-with-where.stub.ts @@ -9,7 +9,12 @@ export class Table1 { export const description = 'should create an index with a where clause'; export const schema: DatabaseSchema = { - name: 'public', + name: 'postgres', + schemaName: 'public', + functions: [], + enums: [], + extensions: [], + parameters: [], tables: [ { name: 'table1', @@ -34,6 +39,7 @@ export const schema: DatabaseSchema = { synchronize: true, }, ], + triggers: [], constraints: [], synchronize: true, }, diff --git a/server/test/sql-tools/primary-key-constraint-name-default.stub.ts b/server/test/sql-tools/primary-key-constraint-name-default.stub.ts index d4b426b9f1..22a515735a 100644 --- a/server/test/sql-tools/primary-key-constraint-name-default.stub.ts +++ b/server/test/sql-tools/primary-key-constraint-name-default.stub.ts @@ -8,7 +8,12 @@ export class Table1 { export const description = 'should add a primary key constraint to the table with a default name'; export const schema: DatabaseSchema = { - name: 'public', + name: 'postgres', + schemaName: 'public', + functions: [], + enums: [], + extensions: [], + parameters: [], tables: [ { name: 'table1', @@ -24,6 +29,7 @@ export const schema: DatabaseSchema = { }, ], indexes: [], + triggers: [], constraints: [ { type: DatabaseConstraintType.PRIMARY_KEY, diff --git a/server/test/sql-tools/primary-key-constraint-name-override.stub.ts b/server/test/sql-tools/primary-key-constraint-name-override.stub.ts index 717d9165b3..e1e0daa82e 100644 --- a/server/test/sql-tools/primary-key-constraint-name-override.stub.ts +++ b/server/test/sql-tools/primary-key-constraint-name-override.stub.ts @@ -8,7 +8,12 @@ export class Table1 { export const description = 'should add a primary key constraint to the table with a specific name'; export const schema: DatabaseSchema = { - name: 'public', + name: 'postgres', + schemaName: 'public', + functions: [], + enums: [], + extensions: [], + parameters: [], tables: [ { name: 'table1', @@ -24,6 +29,7 @@ export const schema: DatabaseSchema = { }, ], indexes: [], + triggers: [], constraints: [ { type: DatabaseConstraintType.PRIMARY_KEY, diff --git a/server/test/sql-tools/table-name-default.stub.ts b/server/test/sql-tools/table-name-default.stub.ts index a76a5b6dbb..6ecc042a58 100644 --- a/server/test/sql-tools/table-name-default.stub.ts +++ b/server/test/sql-tools/table-name-default.stub.ts @@ -5,12 +5,18 @@ export class Table1 {} export const description = 'should register a table with a default name'; export const schema: DatabaseSchema = { - name: 'public', + name: 'postgres', + schemaName: 'public', + functions: [], + enums: [], + extensions: [], + parameters: [], tables: [ { name: 'table1', columns: [], indexes: [], + triggers: [], constraints: [], synchronize: true, }, diff --git a/server/test/sql-tools/table-name-override.stub.ts b/server/test/sql-tools/table-name-override.stub.ts index 3290fab6a4..929a4c4b28 100644 --- a/server/test/sql-tools/table-name-override.stub.ts +++ b/server/test/sql-tools/table-name-override.stub.ts @@ -5,12 +5,18 @@ export class Table1 {} export const description = 'should register a table with a specific name'; export const schema: DatabaseSchema = { - name: 'public', + name: 'postgres', + schemaName: 'public', + functions: [], + enums: [], + extensions: [], + parameters: [], tables: [ { name: 'table-1', columns: [], indexes: [], + triggers: [], constraints: [], synchronize: true, }, diff --git a/server/test/sql-tools/table-name-string-option.stub.ts b/server/test/sql-tools/table-name-string-option.stub.ts index 0c9a045d5b..33e582fb6b 100644 --- a/server/test/sql-tools/table-name-string-option.stub.ts +++ b/server/test/sql-tools/table-name-string-option.stub.ts @@ -5,12 +5,18 @@ export class Table1 {} export const description = 'should register a table with a specific name'; export const schema: DatabaseSchema = { - name: 'public', + name: 'postgres', + schemaName: 'public', + functions: [], + enums: [], + extensions: [], + parameters: [], tables: [ { name: 'table-1', columns: [], indexes: [], + triggers: [], constraints: [], synchronize: true, }, diff --git a/server/test/sql-tools/trigger-after-delete.stub.ts b/server/test/sql-tools/trigger-after-delete.stub.ts new file mode 100644 index 0000000000..903fe2179e --- /dev/null +++ b/server/test/sql-tools/trigger-after-delete.stub.ts @@ -0,0 +1,46 @@ +import { AfterDeleteTrigger, DatabaseSchema, registerFunction, Table } from 'src/sql-tools'; + +const test_fn = registerFunction({ + name: 'test_fn', + body: 'SELECT 1;', + returnType: 'character varying', +}); + +@Table() +@AfterDeleteTrigger({ + name: 'my_trigger', + function: test_fn, + scope: 'row', +}) +export class Table1 {} + +export const description = 'should create a trigger '; +export const schema: DatabaseSchema = { + name: 'postgres', + schemaName: 'public', + functions: [expect.any(Object)], + enums: [], + extensions: [], + parameters: [], + tables: [ + { + name: 'table1', + columns: [], + indexes: [], + triggers: [ + { + name: 'my_trigger', + functionName: 'test_fn', + tableName: 'table1', + timing: 'after', + scope: 'row', + actions: ['delete'], + synchronize: true, + }, + ], + constraints: [], + synchronize: true, + }, + ], + warnings: [], +}; diff --git a/server/test/sql-tools/trigger-before-update.stub.ts b/server/test/sql-tools/trigger-before-update.stub.ts new file mode 100644 index 0000000000..a88675a9ef --- /dev/null +++ b/server/test/sql-tools/trigger-before-update.stub.ts @@ -0,0 +1,46 @@ +import { BeforeUpdateTrigger, DatabaseSchema, registerFunction, Table } from 'src/sql-tools'; + +const test_fn = registerFunction({ + name: 'test_fn', + body: 'SELECT 1;', + returnType: 'character varying', +}); + +@Table() +@BeforeUpdateTrigger({ + name: 'my_trigger', + function: test_fn, + scope: 'row', +}) +export class Table1 {} + +export const description = 'should create a trigger '; +export const schema: DatabaseSchema = { + name: 'postgres', + schemaName: 'public', + functions: [expect.any(Object)], + enums: [], + extensions: [], + parameters: [], + tables: [ + { + name: 'table1', + columns: [], + indexes: [], + triggers: [ + { + name: 'my_trigger', + functionName: 'test_fn', + tableName: 'table1', + timing: 'before', + scope: 'row', + actions: ['update'], + synchronize: true, + }, + ], + constraints: [], + synchronize: true, + }, + ], + warnings: [], +}; diff --git a/server/test/sql-tools/trigger-name-default.stub.ts b/server/test/sql-tools/trigger-name-default.stub.ts new file mode 100644 index 0000000000..a9951aef18 --- /dev/null +++ b/server/test/sql-tools/trigger-name-default.stub.ts @@ -0,0 +1,41 @@ +import { DatabaseSchema, Table, Trigger } from 'src/sql-tools'; + +@Table() +@Trigger({ + timing: 'before', + actions: ['insert'], + scope: 'row', + functionName: 'function1', +}) +export class Table1 {} + +export const description = 'should register a trigger with a default name'; +export const schema: DatabaseSchema = { + name: 'postgres', + schemaName: 'public', + functions: [], + enums: [], + extensions: [], + parameters: [], + tables: [ + { + name: 'table1', + columns: [], + indexes: [], + triggers: [ + { + name: 'TR_ca71832b10b77ed600ef05df631', + tableName: 'table1', + functionName: 'function1', + actions: ['insert'], + scope: 'row', + timing: 'before', + synchronize: true, + }, + ], + constraints: [], + synchronize: true, + }, + ], + warnings: [], +}; diff --git a/server/test/sql-tools/trigger-name-override.stub.ts b/server/test/sql-tools/trigger-name-override.stub.ts new file mode 100644 index 0000000000..3fba0e12ab --- /dev/null +++ b/server/test/sql-tools/trigger-name-override.stub.ts @@ -0,0 +1,42 @@ +import { DatabaseSchema, Table, Trigger } from 'src/sql-tools'; + +@Table() +@Trigger({ + name: 'trigger1', + timing: 'before', + actions: ['insert'], + scope: 'row', + functionName: 'function1', +}) +export class Table1 {} + +export const description = 'should a trigger with a specific name'; +export const schema: DatabaseSchema = { + name: 'postgres', + schemaName: 'public', + functions: [], + enums: [], + extensions: [], + parameters: [], + tables: [ + { + name: 'table1', + columns: [], + indexes: [], + triggers: [ + { + name: 'trigger1', + tableName: 'table1', + functionName: 'function1', + actions: ['insert'], + scope: 'row', + timing: 'before', + synchronize: true, + }, + ], + constraints: [], + synchronize: true, + }, + ], + warnings: [], +}; diff --git a/server/test/sql-tools/unique-constraint-name-default.stub.ts b/server/test/sql-tools/unique-constraint-name-default.stub.ts index 42fc63bc46..a3b9c512c5 100644 --- a/server/test/sql-tools/unique-constraint-name-default.stub.ts +++ b/server/test/sql-tools/unique-constraint-name-default.stub.ts @@ -9,7 +9,12 @@ export class Table1 { export const description = 'should add a unique constraint to the table with a default name'; export const schema: DatabaseSchema = { - name: 'public', + name: 'postgres', + schemaName: 'public', + functions: [], + enums: [], + extensions: [], + parameters: [], tables: [ { name: 'table1', @@ -25,6 +30,7 @@ export const schema: DatabaseSchema = { }, ], indexes: [], + triggers: [], constraints: [ { type: DatabaseConstraintType.UNIQUE, diff --git a/server/test/sql-tools/unique-constraint-name-override.stub.ts b/server/test/sql-tools/unique-constraint-name-override.stub.ts index e7f6fcf83c..4def45043f 100644 --- a/server/test/sql-tools/unique-constraint-name-override.stub.ts +++ b/server/test/sql-tools/unique-constraint-name-override.stub.ts @@ -9,7 +9,12 @@ export class Table1 { export const description = 'should add a unique constraint to the table with a specific name'; export const schema: DatabaseSchema = { - name: 'public', + name: 'postgres', + schemaName: 'public', + functions: [], + enums: [], + extensions: [], + parameters: [], tables: [ { name: 'table1', @@ -25,6 +30,7 @@ export const schema: DatabaseSchema = { }, ], indexes: [], + triggers: [], constraints: [ { type: DatabaseConstraintType.UNIQUE, diff --git a/server/test/vitest.config.mjs b/server/test/vitest.config.mjs index d3d1c98f5d..a6929bf806 100644 --- a/server/test/vitest.config.mjs +++ b/server/test/vitest.config.mjs @@ -12,13 +12,13 @@ export default defineConfig({ include: ['src/**/*.spec.ts'], coverage: { provider: 'v8', - include: ['src/cores/**', 'src/interfaces/**', 'src/services/**', 'src/utils/**', 'src/sql-tools/**'], + include: ['src/cores/**', 'src/services/**', 'src/utils/**', 'src/sql-tools/**'], exclude: [ 'src/services/*.spec.ts', 'src/services/api.service.ts', 'src/services/microservices.service.ts', 'src/services/index.ts', - 'src/sql-tools/schema-from-database.ts', + 'src/sql-tools/from-database/index.ts', ], thresholds: { lines: 85, From 96ed9a8c4aa7f443bad9b7ad2e2719e00b1aa865 Mon Sep 17 00:00:00 2001 From: bo0tzz Date: Tue, 8 Apr 2025 00:03:32 +0200 Subject: [PATCH 26/52] fix: restore mangled footnotes (#17446) I broke this in #17257 --- docs/docs/install/docker-compose.mdx | 3 +++ docs/docs/install/upgrading.md | 3 --- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/docs/install/docker-compose.mdx b/docs/docs/install/docker-compose.mdx index 06154161f4..fe5c043d01 100644 --- a/docs/docs/install/docker-compose.mdx +++ b/docs/docs/install/docker-compose.mdx @@ -70,3 +70,6 @@ If you get an error `can't set healthcheck.start_interval as feature require Doc ## Next Steps Read the [Post Installation](/docs/install/post-install.mdx) steps and [upgrade instructions](/docs/install/upgrading.md). + +[compose-file]: https://github.com/immich-app/immich/releases/latest/download/docker-compose.yml +[env-file]: https://github.com/immich-app/immich/releases/latest/download/example.env diff --git a/docs/docs/install/upgrading.md b/docs/docs/install/upgrading.md index 26cb22ffcc..4425e23d68 100644 --- a/docs/docs/install/upgrading.md +++ b/docs/docs/install/upgrading.md @@ -24,9 +24,6 @@ To clean up disk space, the old version's obsolete container images can be delet docker image prune ``` -[compose-file]: https://github.com/immich-app/immich/releases/latest/download/docker-compose.yml -[env-file]: https://github.com/immich-app/immich/releases/latest/download/example.env [watchtower]: https://containrrr.dev/watchtower/ [breaking]: https://github.com/immich-app/immich/discussions?discussions_q=label%3Achangelog%3Abreaking-change+sort%3Adate_created -[container-auth]: https://docs.github.com/en/packages/working-with-a-github-packages-registry/working-with-the-container-registry#authenticating-to-the-container-registry [releases]: https://github.com/immich-app/immich/releases From b352cf33369a0d2008b5e4c71db045dd50138ea0 Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Tue, 8 Apr 2025 00:15:16 -0400 Subject: [PATCH 27/52] refactor: remove natural earth countries enity (#17445) --- server/src/entities/natural-earth-countries.entity.ts | 7 ------- server/src/repositories/map.repository.ts | 7 +++---- 2 files changed, 3 insertions(+), 11 deletions(-) delete mode 100644 server/src/entities/natural-earth-countries.entity.ts diff --git a/server/src/entities/natural-earth-countries.entity.ts b/server/src/entities/natural-earth-countries.entity.ts deleted file mode 100644 index 50bce3e034..0000000000 --- a/server/src/entities/natural-earth-countries.entity.ts +++ /dev/null @@ -1,7 +0,0 @@ -export class NaturalEarthCountriesTempEntity { - id!: number; - admin!: string; - admin_a3!: string; - type!: string; - coordinates!: string; -} diff --git a/server/src/repositories/map.repository.ts b/server/src/repositories/map.repository.ts index e6a2d51b7b..3c4cf12ffd 100644 --- a/server/src/repositories/map.repository.ts +++ b/server/src/repositories/map.repository.ts @@ -1,6 +1,6 @@ import { Injectable } from '@nestjs/common'; import { getName } from 'i18n-iso-countries'; -import { Expression, Kysely, sql, SqlBool } from 'kysely'; +import { Expression, Insertable, Kysely, sql, SqlBool } from 'kysely'; import { InjectKysely } from 'nestjs-kysely'; import { createReadStream, existsSync } from 'node:fs'; import { readFile } from 'node:fs/promises'; @@ -8,7 +8,6 @@ import readLine from 'node:readline'; import { citiesFile } from 'src/constants'; import { DB, GeodataPlaces, NaturalearthCountries } from 'src/db'; import { DummyValue, GenerateSql } from 'src/decorators'; -import { NaturalEarthCountriesTempEntity } from 'src/entities/natural-earth-countries.entity'; import { SystemMetadataKey } from 'src/enum'; import { ConfigRepository } from 'src/repositories/config.repository'; import { LoggingRepository } from 'src/repositories/logging.repository'; @@ -182,11 +181,11 @@ export class MapRepository { return; } - const entities: Omit[] = []; + const entities: Insertable[] = []; for (const feature of geoJSONData.features) { for (const entry of feature.geometry.coordinates) { const coordinates: number[][][] = feature.geometry.type === 'MultiPolygon' ? entry[0] : entry; - const featureRecord: Omit = { + const featureRecord: Insertable = { admin: feature.properties.ADMIN, admin_a3: feature.properties.ADM0_A3, type: feature.properties.TYPE, From 8203b6c450e19cde28c6ccf10ddcbd96c0facd43 Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Tue, 8 Apr 2025 00:15:43 -0400 Subject: [PATCH 28/52] refactor: stop using geodata entity type (#17444) --- server/src/dtos/search.dto.ts | 9 +++++---- server/src/entities/geodata-places.entity.ts | 13 ------------- server/src/repositories/search.repository.ts | 5 ++--- server/src/types.ts | 4 ++++ 4 files changed, 11 insertions(+), 20 deletions(-) delete mode 100644 server/src/entities/geodata-places.entity.ts diff --git a/server/src/dtos/search.dto.ts b/server/src/dtos/search.dto.ts index e0b5c9b779..67ab059e11 100644 --- a/server/src/dtos/search.dto.ts +++ b/server/src/dtos/search.dto.ts @@ -4,8 +4,8 @@ import { IsEnum, IsInt, IsNotEmpty, IsString, Max, Min } from 'class-validator'; import { PropertyLifecycle } from 'src/decorators'; import { AlbumResponseDto } from 'src/dtos/album.dto'; import { AssetResponseDto } from 'src/dtos/asset-response.dto'; -import { GeodataPlacesEntity } from 'src/entities/geodata-places.entity'; import { AssetOrder, AssetType } from 'src/enum'; +import { SearchPlacesItem } from 'src/types'; import { Optional, ValidateBoolean, ValidateDate, ValidateUUID } from 'src/validation'; class BaseSearchDto { @@ -226,15 +226,16 @@ export class PlacesResponseDto { admin2name?: string; } -export function mapPlaces(place: GeodataPlacesEntity): PlacesResponseDto { +export function mapPlaces(place: SearchPlacesItem): PlacesResponseDto { return { name: place.name, latitude: place.latitude, longitude: place.longitude, - admin1name: place.admin1Name, - admin2name: place.admin2Name, + admin1name: place.admin1Name ?? undefined, + admin2name: place.admin2Name ?? undefined, }; } + export enum SearchSuggestionType { COUNTRY = 'country', STATE = 'state', diff --git a/server/src/entities/geodata-places.entity.ts b/server/src/entities/geodata-places.entity.ts deleted file mode 100644 index aad6c38dda..0000000000 --- a/server/src/entities/geodata-places.entity.ts +++ /dev/null @@ -1,13 +0,0 @@ -export class GeodataPlacesEntity { - id!: number; - name!: string; - longitude!: number; - latitude!: number; - countryCode!: string; - admin1Code!: string; - admin2Code!: string; - admin1Name!: string; - admin2Name!: string; - alternateNames!: string; - modificationDate!: Date; -} diff --git a/server/src/repositories/search.repository.ts b/server/src/repositories/search.repository.ts index 954ab0fe5a..736eb6dcc1 100644 --- a/server/src/repositories/search.repository.ts +++ b/server/src/repositories/search.repository.ts @@ -5,7 +5,6 @@ import { randomUUID } from 'node:crypto'; import { DB } from 'src/db'; import { DummyValue, GenerateSql } from 'src/decorators'; import { AssetEntity, searchAssetBuilder } from 'src/entities/asset.entity'; -import { GeodataPlacesEntity } from 'src/entities/geodata-places.entity'; import { AssetStatus, AssetType } from 'src/enum'; import { LoggingRepository } from 'src/repositories/logging.repository'; import { anyUuid, asUuid } from 'src/utils/database'; @@ -372,7 +371,7 @@ export class SearchRepository { } @GenerateSql({ params: [DummyValue.STRING] }) - searchPlaces(placeName: string): Promise { + searchPlaces(placeName: string) { return this.db .selectFrom('geodata_places') .selectAll() @@ -395,7 +394,7 @@ export class SearchRepository { `, ) .limit(20) - .execute() as Promise; + .execute(); } @GenerateSql({ params: [[DummyValue.UUID]] }) diff --git a/server/src/types.ts b/server/src/types.ts index 623b0fece0..6620621da8 100644 --- a/server/src/types.ts +++ b/server/src/types.ts @@ -16,6 +16,7 @@ import { import { ActivityRepository } from 'src/repositories/activity.repository'; import { ApiKeyRepository } from 'src/repositories/api-key.repository'; import { MemoryRepository } from 'src/repositories/memory.repository'; +import { SearchRepository } from 'src/repositories/search.repository'; import { SessionRepository } from 'src/repositories/session.repository'; export type DeepPartial = T extends object ? { [K in keyof T]?: DeepPartial } : T; @@ -25,12 +26,15 @@ export type RepositoryInterface = Pick; type IActivityRepository = RepositoryInterface; type IApiKeyRepository = RepositoryInterface; type IMemoryRepository = RepositoryInterface; +type ISearchRepository = RepositoryInterface; type ISessionRepository = RepositoryInterface; export type ActivityItem = | Awaited> | Awaited>[0]; +export type SearchPlacesItem = Awaited>[0]; + export type ApiKeyItem = | Awaited> | NonNullable>> From 8242ff9babc31c82aab4fe336dc34fa5b426267c Mon Sep 17 00:00:00 2001 From: PyKen Date: Tue, 8 Apr 2025 06:19:06 +0200 Subject: [PATCH 29/52] fix(server): Exclude album assets in shared link payload (#17207) * fix(server): Exclude album assets in shared link payload * Fix e2e test --- e2e/src/api/specs/shared-link.e2e-spec.ts | 10 +--------- server/src/dtos/shared-link.dto.ts | 5 +---- 2 files changed, 2 insertions(+), 13 deletions(-) diff --git a/e2e/src/api/specs/shared-link.e2e-spec.ts b/e2e/src/api/specs/shared-link.e2e-spec.ts index afad771bfc..04ed8ca0a4 100644 --- a/e2e/src/api/specs/shared-link.e2e-spec.ts +++ b/e2e/src/api/specs/shared-link.e2e-spec.ts @@ -246,15 +246,7 @@ describe('/shared-links', () => { const { status, body } = await request(app).get('/shared-links/me').query({ key: linkWithMetadata.key }); expect(status).toBe(200); - expect(body.assets).toHaveLength(1); - expect(body.assets[0]).toEqual( - expect.objectContaining({ - originalFileName: 'example.png', - localDateTime: expect.any(String), - fileCreatedAt: expect.any(String), - exifInfo: expect.any(Object), - }), - ); + expect(body.assets).toHaveLength(0); expect(body.album).toBeDefined(); }); diff --git a/server/src/dtos/shared-link.dto.ts b/server/src/dtos/shared-link.dto.ts index e3f8c72e19..6bb8ab1f0d 100644 --- a/server/src/dtos/shared-link.dto.ts +++ b/server/src/dtos/shared-link.dto.ts @@ -104,9 +104,6 @@ export class SharedLinkResponseDto { export function mapSharedLink(sharedLink: SharedLinkEntity): SharedLinkResponseDto { const linkAssets = sharedLink.assets || []; - const albumAssets = (sharedLink?.album?.assets || []).map((asset) => asset); - - const assets = _.uniqBy([...linkAssets, ...albumAssets], (asset) => asset.id); return { id: sharedLink.id, @@ -117,7 +114,7 @@ export function mapSharedLink(sharedLink: SharedLinkEntity): SharedLinkResponseD type: sharedLink.type, createdAt: sharedLink.createdAt, expiresAt: sharedLink.expiresAt, - assets: assets.map((asset) => mapAsset(asset)), + assets: linkAssets.map((asset) => mapAsset(asset)), album: sharedLink.album ? mapAlbumWithoutAssets(sharedLink.album) : undefined, allowUpload: sharedLink.allowUpload, allowDownload: sharedLink.allowDownload, From ffd18c5459a60e7689b95fa25ff9ba4565a0a835 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 8 Apr 2025 12:14:30 +0200 Subject: [PATCH 30/52] chore(deps): update dependency @types/node to ^22.14.0 (#17459) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- cli/package-lock.json | 18 +++++++++--------- cli/package.json | 2 +- e2e/package-lock.json | 20 ++++++++++---------- e2e/package.json | 2 +- open-api/typescript-sdk/package-lock.json | 16 ++++++++-------- open-api/typescript-sdk/package.json | 2 +- server/package-lock.json | 16 ++++++++-------- server/package.json | 2 +- 8 files changed, 39 insertions(+), 39 deletions(-) diff --git a/cli/package-lock.json b/cli/package-lock.json index 959f64e376..ef6788e7d6 100644 --- a/cli/package-lock.json +++ b/cli/package-lock.json @@ -27,7 +27,7 @@ "@types/lodash-es": "^4.17.12", "@types/micromatch": "^4.0.9", "@types/mock-fs": "^4.13.1", - "@types/node": "^22.13.14", + "@types/node": "^22.14.0", "@vitest/coverage-v8": "^3.0.0", "byte-size": "^9.0.0", "cli-progress": "^3.12.0", @@ -61,7 +61,7 @@ "@oazapfts/runtime": "^1.0.2" }, "devDependencies": { - "@types/node": "^22.13.14", + "@types/node": "^22.14.0", "typescript": "^5.3.3" } }, @@ -1362,13 +1362,13 @@ } }, "node_modules/@types/node": { - "version": "22.13.15", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.13.15.tgz", - "integrity": "sha512-imAbQEEbVni6i6h6Bd5xkCRwLqFc8hihCsi2GbtDoAtUcAFQ6Zs4pFXTZUUbroTkXdImczWM9AI8eZUuybXE3w==", + "version": "22.14.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.14.0.tgz", + "integrity": "sha512-Kmpl+z84ILoG+3T/zQFyAJsU6EPTmOCj8/2+83fSN6djd6I4o7uOuGIH6vq3PrjY5BGitSbFuMN18j3iknubbA==", "dev": true, "license": "MIT", "dependencies": { - "undici-types": "~6.20.0" + "undici-types": "~6.21.0" } }, "node_modules/@types/normalize-package-data": { @@ -4073,9 +4073,9 @@ } }, "node_modules/undici-types": { - "version": "6.20.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.20.0.tgz", - "integrity": "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==", + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", "dev": true, "license": "MIT" }, diff --git a/cli/package.json b/cli/package.json index da6ad41ed3..304c2acfbd 100644 --- a/cli/package.json +++ b/cli/package.json @@ -21,7 +21,7 @@ "@types/lodash-es": "^4.17.12", "@types/micromatch": "^4.0.9", "@types/mock-fs": "^4.13.1", - "@types/node": "^22.13.14", + "@types/node": "^22.14.0", "@vitest/coverage-v8": "^3.0.0", "byte-size": "^9.0.0", "cli-progress": "^3.12.0", diff --git a/e2e/package-lock.json b/e2e/package-lock.json index 139e4dca65..c403b2560a 100644 --- a/e2e/package-lock.json +++ b/e2e/package-lock.json @@ -15,7 +15,7 @@ "@immich/sdk": "file:../open-api/typescript-sdk", "@playwright/test": "^1.44.1", "@types/luxon": "^3.4.2", - "@types/node": "^22.13.14", + "@types/node": "^22.14.0", "@types/oidc-provider": "^8.5.1", "@types/pg": "^8.11.0", "@types/pngjs": "^6.0.4", @@ -66,7 +66,7 @@ "@types/lodash-es": "^4.17.12", "@types/micromatch": "^4.0.9", "@types/mock-fs": "^4.13.1", - "@types/node": "^22.13.14", + "@types/node": "^22.14.0", "@vitest/coverage-v8": "^3.0.0", "byte-size": "^9.0.0", "cli-progress": "^3.12.0", @@ -100,7 +100,7 @@ "@oazapfts/runtime": "^1.0.2" }, "devDependencies": { - "@types/node": "^22.13.14", + "@types/node": "^22.14.0", "typescript": "^5.3.3" } }, @@ -1585,13 +1585,13 @@ "dev": true }, "node_modules/@types/node": { - "version": "22.13.15", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.13.15.tgz", - "integrity": "sha512-imAbQEEbVni6i6h6Bd5xkCRwLqFc8hihCsi2GbtDoAtUcAFQ6Zs4pFXTZUUbroTkXdImczWM9AI8eZUuybXE3w==", + "version": "22.14.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.14.0.tgz", + "integrity": "sha512-Kmpl+z84ILoG+3T/zQFyAJsU6EPTmOCj8/2+83fSN6djd6I4o7uOuGIH6vq3PrjY5BGitSbFuMN18j3iknubbA==", "dev": true, "license": "MIT", "dependencies": { - "undici-types": "~6.20.0" + "undici-types": "~6.21.0" } }, "node_modules/@types/normalize-package-data": { @@ -6316,9 +6316,9 @@ } }, "node_modules/undici-types": { - "version": "6.20.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.20.0.tgz", - "integrity": "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==", + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", "dev": true, "license": "MIT" }, diff --git a/e2e/package.json b/e2e/package.json index 18af2be951..f141430c97 100644 --- a/e2e/package.json +++ b/e2e/package.json @@ -25,7 +25,7 @@ "@immich/sdk": "file:../open-api/typescript-sdk", "@playwright/test": "^1.44.1", "@types/luxon": "^3.4.2", - "@types/node": "^22.13.14", + "@types/node": "^22.14.0", "@types/oidc-provider": "^8.5.1", "@types/pg": "^8.11.0", "@types/pngjs": "^6.0.4", diff --git a/open-api/typescript-sdk/package-lock.json b/open-api/typescript-sdk/package-lock.json index 3d3b5b3780..c93eb29cca 100644 --- a/open-api/typescript-sdk/package-lock.json +++ b/open-api/typescript-sdk/package-lock.json @@ -12,7 +12,7 @@ "@oazapfts/runtime": "^1.0.2" }, "devDependencies": { - "@types/node": "^22.13.14", + "@types/node": "^22.14.0", "typescript": "^5.3.3" } }, @@ -23,13 +23,13 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "22.13.15", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.13.15.tgz", - "integrity": "sha512-imAbQEEbVni6i6h6Bd5xkCRwLqFc8hihCsi2GbtDoAtUcAFQ6Zs4pFXTZUUbroTkXdImczWM9AI8eZUuybXE3w==", + "version": "22.14.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.14.0.tgz", + "integrity": "sha512-Kmpl+z84ILoG+3T/zQFyAJsU6EPTmOCj8/2+83fSN6djd6I4o7uOuGIH6vq3PrjY5BGitSbFuMN18j3iknubbA==", "dev": true, "license": "MIT", "dependencies": { - "undici-types": "~6.20.0" + "undici-types": "~6.21.0" } }, "node_modules/typescript": { @@ -47,9 +47,9 @@ } }, "node_modules/undici-types": { - "version": "6.20.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.20.0.tgz", - "integrity": "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==", + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", "dev": true, "license": "MIT" } diff --git a/open-api/typescript-sdk/package.json b/open-api/typescript-sdk/package.json index 6a06647419..29fe50dcd9 100644 --- a/open-api/typescript-sdk/package.json +++ b/open-api/typescript-sdk/package.json @@ -19,7 +19,7 @@ "@oazapfts/runtime": "^1.0.2" }, "devDependencies": { - "@types/node": "^22.13.14", + "@types/node": "^22.14.0", "typescript": "^5.3.3" }, "repository": { diff --git a/server/package-lock.json b/server/package-lock.json index 02b0d357c0..6c5bc4adf5 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -90,7 +90,7 @@ "@types/lodash": "^4.14.197", "@types/mock-fs": "^4.13.1", "@types/multer": "^1.4.7", - "@types/node": "^22.13.14", + "@types/node": "^22.14.0", "@types/nodemailer": "^6.4.14", "@types/picomatch": "^3.0.0", "@types/pngjs": "^6.0.5", @@ -5825,12 +5825,12 @@ } }, "node_modules/@types/node": { - "version": "22.13.15", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.13.15.tgz", - "integrity": "sha512-imAbQEEbVni6i6h6Bd5xkCRwLqFc8hihCsi2GbtDoAtUcAFQ6Zs4pFXTZUUbroTkXdImczWM9AI8eZUuybXE3w==", + "version": "22.14.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.14.0.tgz", + "integrity": "sha512-Kmpl+z84ILoG+3T/zQFyAJsU6EPTmOCj8/2+83fSN6djd6I4o7uOuGIH6vq3PrjY5BGitSbFuMN18j3iknubbA==", "license": "MIT", "dependencies": { - "undici-types": "~6.20.0" + "undici-types": "~6.21.0" } }, "node_modules/@types/node-fetch": { @@ -16736,9 +16736,9 @@ } }, "node_modules/undici-types": { - "version": "6.20.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.20.0.tgz", - "integrity": "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==", + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", "license": "MIT" }, "node_modules/unicorn-magic": { diff --git a/server/package.json b/server/package.json index 2015e1d8c5..d6c782b2ac 100644 --- a/server/package.json +++ b/server/package.json @@ -116,7 +116,7 @@ "@types/lodash": "^4.14.197", "@types/mock-fs": "^4.13.1", "@types/multer": "^1.4.7", - "@types/node": "^22.13.14", + "@types/node": "^22.14.0", "@types/nodemailer": "^6.4.14", "@types/picomatch": "^3.0.0", "@types/pngjs": "^6.0.5", From a5123dec1a215d00303288573ba70436b7878460 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 8 Apr 2025 13:31:46 +0100 Subject: [PATCH 31/52] chore(deps): update grafana/grafana docker tag to v11.6.0 (#17460) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- docker/docker-compose.prod.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/docker-compose.prod.yml b/docker/docker-compose.prod.yml index f8caabe98c..f4a57ecbb9 100644 --- a/docker/docker-compose.prod.yml +++ b/docker/docker-compose.prod.yml @@ -102,7 +102,7 @@ services: command: [ './run.sh', '-disable-reporting' ] ports: - 3000:3000 - image: grafana/grafana:11.5.2-ubuntu@sha256:8b5858c447e06fd7a89006b562ba7bba7c4d5813600c7982374c41852adefaeb + image: grafana/grafana:11.6.0-ubuntu@sha256:fd8fa48213c624e1a95122f1d93abbf1cf1cbe85fc73212c1e599dbd76c63ff8 volumes: - grafana-data:/var/lib/grafana From bd0840c4113d4a0710df6bbd250697b02663f138 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 8 Apr 2025 13:31:57 +0100 Subject: [PATCH 32/52] chore(deps): update github/codeql-action digest to 45775bd (#17452) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- .github/workflows/codeql-analysis.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index b2e2a9ddbd..99ffee2e88 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -46,7 +46,7 @@ jobs: # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@1b549b9259bda1cb5ddde3b41741a82a2d15a841 # v3 + uses: github/codeql-action/init@45775bd8235c68ba998cffa5171334d58593da47 # v3 with: languages: ${{ matrix.language }} # If you wish to specify custom queries, you can do so here or in a config file. @@ -59,7 +59,7 @@ jobs: # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). # If this step fails, then you should remove it and run the build manually (see below) - name: Autobuild - uses: github/codeql-action/autobuild@1b549b9259bda1cb5ddde3b41741a82a2d15a841 # v3 + uses: github/codeql-action/autobuild@45775bd8235c68ba998cffa5171334d58593da47 # v3 # ℹ️ Command-line programs to run using the OS shell. # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun @@ -72,6 +72,6 @@ jobs: # ./location_of_script_within_repo/buildscript.sh - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@1b549b9259bda1cb5ddde3b41741a82a2d15a841 # v3 + uses: github/codeql-action/analyze@45775bd8235c68ba998cffa5171334d58593da47 # v3 with: category: '/language:${{matrix.language}}' From 7f116d8e98775c4a9a82387b5578286ccfae8aea Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 8 Apr 2025 13:32:14 +0100 Subject: [PATCH 33/52] chore(deps): update mcr.microsoft.com/devcontainers/typescript-node:22 docker digest to b0b88ef (#17453) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- .devcontainer/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index 670d11a06b..18a3fb3e03 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -1,4 +1,4 @@ -ARG BASEIMAGE=mcr.microsoft.com/devcontainers/typescript-node:22@sha256:2ef23730ec68d8511ec8e6e0b82550ca728b256805d81f60ed890f3bfb21cfb9 +ARG BASEIMAGE=mcr.microsoft.com/devcontainers/typescript-node:22@sha256:b0b88ef6a5abf21194343d2c5b2829dddd9be1142f65f6a5e4390a51d5a70dd8 FROM ${BASEIMAGE} # Flutter SDK From 6ae24fbbd4197d0a682b8c97c5452863c06e6d4f Mon Sep 17 00:00:00 2001 From: snek Date: Tue, 8 Apr 2025 15:11:37 +0200 Subject: [PATCH 34/52] feat(web): improve individual share ux (#17430) --- .../asset-viewer/asset-viewer-nav-bar.svelte | 7 +- .../asset-viewer/asset-viewer.svelte | 3 + .../individual-shared-viewer.svelte | 95 ++++++++++++------- 3 files changed, 69 insertions(+), 36 deletions(-) diff --git a/web/src/lib/components/asset-viewer/asset-viewer-nav-bar.svelte b/web/src/lib/components/asset-viewer/asset-viewer-nav-bar.svelte index 6111b81dbb..f7d4c30ec8 100644 --- a/web/src/lib/components/asset-viewer/asset-viewer-nav-bar.svelte +++ b/web/src/lib/components/asset-viewer/asset-viewer-nav-bar.svelte @@ -54,6 +54,7 @@ album?: AlbumResponseDto | null; person?: PersonResponseDto | null; stack?: StackResponseDto | null; + showCloseButton?: boolean; showDetailButton: boolean; showSlideshow?: boolean; onZoomImage: () => void; @@ -73,6 +74,7 @@ album = null, person = null, stack = null, + showCloseButton = true, showDetailButton, showSlideshow = false, onZoomImage, @@ -89,6 +91,7 @@ const sharedLink = getSharedLink(); let isOwner = $derived($user && asset.ownerId === $user?.id); let showDownloadButton = $derived(sharedLink ? sharedLink.allowDownload : !asset.isOffline); + // $: showEditorButton = // isOwner && // asset.type === AssetTypeEnum.Image && @@ -104,7 +107,9 @@ class="z-[1001] flex h-16 place-items-center justify-between bg-gradient-to-b from-black/40 px-3 transition-transform duration-200" >
- + {#if showCloseButton} + + {/if}
{#if !asset.isTrashed && $user} diff --git a/web/src/lib/components/asset-viewer/asset-viewer.svelte b/web/src/lib/components/asset-viewer/asset-viewer.svelte index c4db1290d6..eec55ec396 100644 --- a/web/src/lib/components/asset-viewer/asset-viewer.svelte +++ b/web/src/lib/components/asset-viewer/asset-viewer.svelte @@ -61,6 +61,7 @@ preAction?: PreAction | undefined; onAction?: OnAction | undefined; reactions?: ActivityResponseDto[]; + showCloseButton?: boolean; onClose: (dto: { asset: AssetResponseDto }) => void; onNext: () => Promise; onPrevious: () => Promise; @@ -79,6 +80,7 @@ preAction = undefined, onAction = undefined, reactions = $bindable([]), + showCloseButton, onClose, onNext, onPrevious, @@ -431,6 +433,7 @@ {album} {person} {stack} + {showCloseButton} showDetailButton={enableDetailPanel} showSlideshow={true} onZoomImage={zoomToggle} diff --git a/web/src/lib/components/share-page/individual-shared-viewer.svelte b/web/src/lib/components/share-page/individual-shared-viewer.svelte index b46342ad91..36ccf3f7dc 100644 --- a/web/src/lib/components/share-page/individual-shared-viewer.svelte +++ b/web/src/lib/components/share-page/individual-shared-viewer.svelte @@ -1,6 +1,7 @@
- {#if assetInteraction.selectionActive} - cancelMultiselect(assetInteraction)} - > - - {#if sharedLink?.allowDownload} - - {/if} - {#if isOwned} - - {/if} - - {:else} - goto(AppRoute.PHOTOS)} backIcon={mdiArrowLeft} showBackButton={false}> - {#snippet leading()} - - {/snippet} - - {#snippet trailing()} - {#if sharedLink?.allowUpload} - handleUploadAssets()} - icon={mdiFileImagePlusOutline} - /> - {/if} - + {#if sharedLink?.allowUpload || assets.length > 1} + {#if assetInteraction.selectionActive} + cancelMultiselect(assetInteraction)} + > + {#if sharedLink?.allowDownload} - + {/if} - {/snippet} - + {#if isOwned} + + {/if} + + {:else} + goto(AppRoute.PHOTOS)} backIcon={mdiArrowLeft} showBackButton={false}> + {#snippet leading()} + + {/snippet} + + {#snippet trailing()} + {#if sharedLink?.allowUpload} + handleUploadAssets()} + icon={mdiFileImagePlusOutline} + /> + {/if} + + {#if sharedLink?.allowDownload} + + {/if} + {/snippet} + + {/if} +
+ +
+ {:else} + Promise.resolve(false)} + onNext={() => Promise.resolve(false)} + onRandom={() => Promise.resolve(undefined)} + onClose={() => {}} + /> {/if} -
- -
From 2b131fe9350717fb20bdb4be6fd6fb0016e5c6a0 Mon Sep 17 00:00:00 2001 From: Aleksandr <77919646+aleksandrsovtan@users.noreply.github.com> Date: Tue, 8 Apr 2025 16:50:40 +0300 Subject: [PATCH 35/52] feat: opt-in sync of deletes and restores from web to Android (#16732) * Features: Local file movement to trash and restoration back to the album added. (Android) * Comments fixes * settings button marked as [EXPERIMENTAL] * _moveToTrashMatchedAssets refactored, moveToTrash renamed. * fix: bad merge * Permission check and request for local storage added. * Permission request added on settings switcher * Settings button logic changed * Method channel file_trash moved to BackgroundServicePlugin --------- Co-authored-by: Alex --- .../android/app/src/main/AndroidManifest.xml | 3 +- .../immich/BackgroundServicePlugin.kt | 206 +++++++++++++++++- .../app/alextran/immich/MainActivity.kt | 8 +- mobile/assets/i18n/en-US.json | 2 + mobile/lib/domain/models/store.model.dart | 1 + .../local_files_manager.interface.dart | 5 + mobile/lib/providers/websocket.provider.dart | 28 ++- .../local_files_manager.repository.dart | 23 ++ mobile/lib/services/app_settings.service.dart | 1 + mobile/lib/services/sync.service.dart | 48 +++- mobile/lib/utils/local_files_manager.dart | 39 ++++ .../widgets/settings/advanced_settings.dart | 37 ++++ .../modules/shared/sync_service_test.dart | 5 + mobile/test/repository.mocks.dart | 6 +- mobile/test/service.mocks.dart | 4 + 15 files changed, 400 insertions(+), 16 deletions(-) create mode 100644 mobile/lib/interfaces/local_files_manager.interface.dart create mode 100644 mobile/lib/repositories/local_files_manager.repository.dart create mode 100644 mobile/lib/utils/local_files_manager.dart diff --git a/mobile/android/app/src/main/AndroidManifest.xml b/mobile/android/app/src/main/AndroidManifest.xml index eb81dc267b..58d7f0655a 100644 --- a/mobile/android/app/src/main/AndroidManifest.xml +++ b/mobile/android/app/src/main/AndroidManifest.xml @@ -6,6 +6,7 @@ android:maxSdkVersion="32" /> + @@ -124,4 +125,4 @@ - \ No newline at end of file + diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/BackgroundServicePlugin.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/BackgroundServicePlugin.kt index 8520413cff..e7f787e8d8 100644 --- a/mobile/android/app/src/main/kotlin/app/alextran/immich/BackgroundServicePlugin.kt +++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/BackgroundServicePlugin.kt @@ -1,25 +1,40 @@ package app.alextran.immich +import android.content.ContentResolver +import android.content.ContentUris +import android.content.ContentValues import android.content.Context +import android.content.Intent +import android.net.Uri +import android.os.Build +import android.os.Bundle +import android.os.Environment +import android.provider.MediaStore +import android.provider.Settings import android.util.Log import io.flutter.embedding.engine.plugins.FlutterPlugin +import io.flutter.embedding.engine.plugins.activity.ActivityAware +import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding import io.flutter.plugin.common.BinaryMessenger import io.flutter.plugin.common.MethodCall import io.flutter.plugin.common.MethodChannel +import io.flutter.plugin.common.MethodChannel.Result +import io.flutter.plugin.common.PluginRegistry import java.security.MessageDigest import java.io.FileInputStream import kotlinx.coroutines.* /** - * Android plugin for Dart `BackgroundService` - * - * Receives messages/method calls from the foreground Dart side to manage - * the background service, e.g. start (enqueue), stop (cancel) + * Android plugin for Dart `BackgroundService` and file trash operations */ -class BackgroundServicePlugin : FlutterPlugin, MethodChannel.MethodCallHandler { +class BackgroundServicePlugin : FlutterPlugin, MethodChannel.MethodCallHandler, ActivityAware, PluginRegistry.ActivityResultListener { private var methodChannel: MethodChannel? = null + private var fileTrashChannel: MethodChannel? = null private var context: Context? = null + private var pendingResult: Result? = null + private val PERMISSION_REQUEST_CODE = 1001 + private var activityBinding: ActivityPluginBinding? = null override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) { onAttachedToEngine(binding.applicationContext, binding.binaryMessenger) @@ -29,6 +44,10 @@ class BackgroundServicePlugin : FlutterPlugin, MethodChannel.MethodCallHandler { context = ctx methodChannel = MethodChannel(messenger, "immich/foregroundChannel") methodChannel?.setMethodCallHandler(this) + + // Add file trash channel + fileTrashChannel = MethodChannel(messenger, "file_trash") + fileTrashChannel?.setMethodCallHandler(this) } override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) { @@ -38,11 +57,14 @@ class BackgroundServicePlugin : FlutterPlugin, MethodChannel.MethodCallHandler { private fun onDetachedFromEngine() { methodChannel?.setMethodCallHandler(null) methodChannel = null + fileTrashChannel?.setMethodCallHandler(null) + fileTrashChannel = null } - override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) { + override fun onMethodCall(call: MethodCall, result: Result) { val ctx = context!! when (call.method) { + // Existing BackgroundService methods "enable" -> { val args = call.arguments>()!! ctx.getSharedPreferences(BackupWorker.SHARED_PREF_NAME, Context.MODE_PRIVATE) @@ -114,10 +136,180 @@ class BackgroundServicePlugin : FlutterPlugin, MethodChannel.MethodCallHandler { } } + // File Trash methods moved from MainActivity + "moveToTrash" -> { + val fileName = call.argument("fileName") + if (fileName != null) { + if (hasManageStoragePermission()) { + val success = moveToTrash(fileName) + result.success(success) + } else { + result.error("PERMISSION_DENIED", "Storage permission required", null) + } + } else { + result.error("INVALID_NAME", "The file name is not specified.", null) + } + } + + "restoreFromTrash" -> { + val fileName = call.argument("fileName") + if (fileName != null) { + if (hasManageStoragePermission()) { + val success = untrashImage(fileName) + result.success(success) + } else { + result.error("PERMISSION_DENIED", "Storage permission required", null) + } + } else { + result.error("INVALID_NAME", "The file name is not specified.", null) + } + } + + "requestManageStoragePermission" -> { + if (!hasManageStoragePermission()) { + requestManageStoragePermission(result) + } else { + Log.e("Manage storage permission", "Permission already granted") + result.success(true) + } + } + else -> result.notImplemented() } } + + // File Trash methods moved from MainActivity + private fun hasManageStoragePermission(): Boolean { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + Environment.isExternalStorageManager() + } else { + true + } + } + + private fun requestManageStoragePermission(result: Result) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + pendingResult = result // Store the result callback + val activity = activityBinding?.activity ?: return + + val intent = Intent(Settings.ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION) + intent.data = Uri.parse("package:${activity.packageName}") + activity.startActivityForResult(intent, PERMISSION_REQUEST_CODE) + } else { + result.success(true) + } + } + + private fun moveToTrash(fileName: String): Boolean { + val contentResolver = context?.contentResolver ?: return false + val uri = getFileUri(fileName) + Log.e("FILE_URI", uri.toString()) + return uri?.let { moveToTrash(it) } ?: false + } + + private fun moveToTrash(contentUri: Uri): Boolean { + val contentResolver = context?.contentResolver ?: return false + return try { + val values = ContentValues().apply { + put(MediaStore.MediaColumns.IS_TRASHED, 1) // Move to trash + } + val updated = contentResolver.update(contentUri, values, null, null) + updated > 0 + } catch (e: Exception) { + Log.e("TrashError", "Error moving to trash", e) + false + } + } + + private fun getFileUri(fileName: String): Uri? { + val contentResolver = context?.contentResolver ?: return null + val contentUri = MediaStore.Files.getContentUri("external") + val projection = arrayOf(MediaStore.Images.Media._ID) + val selection = "${MediaStore.Images.Media.DISPLAY_NAME} = ?" + val selectionArgs = arrayOf(fileName) + var fileUri: Uri? = null + + contentResolver.query(contentUri, projection, selection, selectionArgs, null)?.use { cursor -> + if (cursor.moveToFirst()) { + val id = cursor.getLong(cursor.getColumnIndexOrThrow(MediaStore.Images.Media._ID)) + fileUri = ContentUris.withAppendedId(contentUri, id) + } + } + return fileUri + } + + private fun untrashImage(name: String): Boolean { + val contentResolver = context?.contentResolver ?: return false + val uri = getTrashedFileUri(contentResolver, name) + Log.e("FILE_URI", uri.toString()) + return uri?.let { untrashImage(it) } ?: false + } + + private fun untrashImage(contentUri: Uri): Boolean { + val contentResolver = context?.contentResolver ?: return false + return try { + val values = ContentValues().apply { + put(MediaStore.MediaColumns.IS_TRASHED, 0) // Restore file + } + val updated = contentResolver.update(contentUri, values, null, null) + updated > 0 + } catch (e: Exception) { + Log.e("TrashError", "Error restoring file", e) + false + } + } + + private fun getTrashedFileUri(contentResolver: ContentResolver, fileName: String): Uri? { + val contentUri = MediaStore.Files.getContentUri(MediaStore.VOLUME_EXTERNAL) + val projection = arrayOf(MediaStore.Files.FileColumns._ID) + + val queryArgs = Bundle().apply { + putString(ContentResolver.QUERY_ARG_SQL_SELECTION, "${MediaStore.Files.FileColumns.DISPLAY_NAME} = ?") + putStringArray(ContentResolver.QUERY_ARG_SQL_SELECTION_ARGS, arrayOf(fileName)) + putInt(MediaStore.QUERY_ARG_MATCH_TRASHED, MediaStore.MATCH_ONLY) + } + + contentResolver.query(contentUri, projection, queryArgs, null)?.use { cursor -> + if (cursor.moveToFirst()) { + val id = cursor.getLong(cursor.getColumnIndexOrThrow(MediaStore.Files.FileColumns._ID)) + return ContentUris.withAppendedId(contentUri, id) + } + } + return null + } + + // ActivityAware implementation + override fun onAttachedToActivity(binding: ActivityPluginBinding) { + activityBinding = binding + binding.addActivityResultListener(this) + } + + override fun onDetachedFromActivityForConfigChanges() { + activityBinding?.removeActivityResultListener(this) + activityBinding = null + } + + override fun onReattachedToActivityForConfigChanges(binding: ActivityPluginBinding) { + activityBinding = binding + binding.addActivityResultListener(this) + } + + override fun onDetachedFromActivity() { + activityBinding?.removeActivityResultListener(this) + activityBinding = null + } + + // ActivityResultListener implementation + override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?): Boolean { + if (requestCode == PERMISSION_REQUEST_CODE) { + val granted = hasManageStoragePermission() + pendingResult?.success(granted) + pendingResult = null + return true + } + return false + } } private const val TAG = "BackgroundServicePlugin" -private const val BUFFER_SIZE = 2 * 1024 * 1024; +private const val BUFFER_SIZE = 2 * 1024 * 1024 diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/MainActivity.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/MainActivity.kt index 4ffb490c77..2b6bf81148 100644 --- a/mobile/android/app/src/main/kotlin/app/alextran/immich/MainActivity.kt +++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/MainActivity.kt @@ -2,14 +2,12 @@ package app.alextran.immich import io.flutter.embedding.android.FlutterActivity import io.flutter.embedding.engine.FlutterEngine -import android.os.Bundle -import android.content.Intent +import androidx.annotation.NonNull class MainActivity : FlutterActivity() { - - override fun configureFlutterEngine(flutterEngine: FlutterEngine) { + override fun configureFlutterEngine(@NonNull flutterEngine: FlutterEngine) { super.configureFlutterEngine(flutterEngine) flutterEngine.plugins.add(BackgroundServicePlugin()) + // No need to set up method channel here as it's now handled in the plugin } - } diff --git a/mobile/assets/i18n/en-US.json b/mobile/assets/i18n/en-US.json index e3b6916e74..3aa2f1b475 100644 --- a/mobile/assets/i18n/en-US.json +++ b/mobile/assets/i18n/en-US.json @@ -23,6 +23,8 @@ "advanced_settings_tile_title": "Advanced", "advanced_settings_troubleshooting_subtitle": "Enable additional features for troubleshooting", "advanced_settings_troubleshooting_title": "Troubleshooting", + "advanced_settings_sync_remote_deletions_title": "Sync remote deletions [EXPERIMENTAL]", + "advanced_settings_sync_remote_deletions_subtitle": "Automatically delete or restore an asset on this device when that action is taken on the web", "album_info_card_backup_album_excluded": "EXCLUDED", "album_info_card_backup_album_included": "INCLUDED", "albums": "Albums", diff --git a/mobile/lib/domain/models/store.model.dart b/mobile/lib/domain/models/store.model.dart index e6d9ecaf48..8a5a908e0d 100644 --- a/mobile/lib/domain/models/store.model.dart +++ b/mobile/lib/domain/models/store.model.dart @@ -65,6 +65,7 @@ enum StoreKey { // Video settings loadOriginalVideo._(136), + manageLocalMediaAndroid._(137), // Experimental stuff photoManagerCustomFilter._(1000); diff --git a/mobile/lib/interfaces/local_files_manager.interface.dart b/mobile/lib/interfaces/local_files_manager.interface.dart new file mode 100644 index 0000000000..c8b83a7c93 --- /dev/null +++ b/mobile/lib/interfaces/local_files_manager.interface.dart @@ -0,0 +1,5 @@ +abstract interface class ILocalFilesManager { + Future moveToTrash(String fileName); + Future restoreFromTrash(String fileName); + Future requestManageStoragePermission(); +} diff --git a/mobile/lib/providers/websocket.provider.dart b/mobile/lib/providers/websocket.provider.dart index f92d2c8421..72dbda8b6f 100644 --- a/mobile/lib/providers/websocket.provider.dart +++ b/mobile/lib/providers/websocket.provider.dart @@ -23,6 +23,7 @@ enum PendingAction { assetDelete, assetUploaded, assetHidden, + assetTrash, } class PendingChange { @@ -160,7 +161,7 @@ class WebsocketNotifier extends StateNotifier { socket.on('on_upload_success', _handleOnUploadSuccess); socket.on('on_config_update', _handleOnConfigUpdate); socket.on('on_asset_delete', _handleOnAssetDelete); - socket.on('on_asset_trash', _handleServerUpdates); + socket.on('on_asset_trash', _handleOnAssetTrash); socket.on('on_asset_restore', _handleServerUpdates); socket.on('on_asset_update', _handleServerUpdates); socket.on('on_asset_stack_update', _handleServerUpdates); @@ -207,6 +208,26 @@ class WebsocketNotifier extends StateNotifier { _debounce.run(handlePendingChanges); } + Future _handlePendingTrashes() async { + final trashChanges = state.pendingChanges + .where((c) => c.action == PendingAction.assetTrash) + .toList(); + if (trashChanges.isNotEmpty) { + List remoteIds = trashChanges + .expand((a) => (a.value as List).map((e) => e.toString())) + .toList(); + + await _ref.read(syncServiceProvider).handleRemoteAssetRemoval(remoteIds); + await _ref.read(assetProvider.notifier).getAllAsset(); + + state = state.copyWith( + pendingChanges: state.pendingChanges + .whereNot((c) => trashChanges.contains(c)) + .toList(), + ); + } + } + Future _handlePendingDeletes() async { final deleteChanges = state.pendingChanges .where((c) => c.action == PendingAction.assetDelete) @@ -267,6 +288,7 @@ class WebsocketNotifier extends StateNotifier { await _handlePendingUploaded(); await _handlePendingDeletes(); await _handlingPendingHidden(); + await _handlePendingTrashes(); } void _handleOnConfigUpdate(dynamic _) { @@ -285,6 +307,10 @@ class WebsocketNotifier extends StateNotifier { void _handleOnAssetDelete(dynamic data) => addPendingChange(PendingAction.assetDelete, data); + void _handleOnAssetTrash(dynamic data) { + addPendingChange(PendingAction.assetTrash, data); + } + void _handleOnAssetHidden(dynamic data) => addPendingChange(PendingAction.assetHidden, data); diff --git a/mobile/lib/repositories/local_files_manager.repository.dart b/mobile/lib/repositories/local_files_manager.repository.dart new file mode 100644 index 0000000000..522d7e7a05 --- /dev/null +++ b/mobile/lib/repositories/local_files_manager.repository.dart @@ -0,0 +1,23 @@ +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/interfaces/local_files_manager.interface.dart'; +import 'package:immich_mobile/utils/local_files_manager.dart'; + +final localFilesManagerRepositoryProvider = + Provider((ref) => LocalFilesManagerRepository()); + +class LocalFilesManagerRepository implements ILocalFilesManager { + @override + Future moveToTrash(String fileName) async { + return await LocalFilesManager.moveToTrash(fileName); + } + + @override + Future restoreFromTrash(String fileName) async { + return await LocalFilesManager.restoreFromTrash(fileName); + } + + @override + Future requestManageStoragePermission() async { + return await LocalFilesManager.requestManageStoragePermission(); + } +} diff --git a/mobile/lib/services/app_settings.service.dart b/mobile/lib/services/app_settings.service.dart index cc57b8d3a3..6413b69fce 100644 --- a/mobile/lib/services/app_settings.service.dart +++ b/mobile/lib/services/app_settings.service.dart @@ -61,6 +61,7 @@ enum AppSettingsEnum { 0, ), advancedTroubleshooting(StoreKey.advancedTroubleshooting, null, false), + manageLocalMediaAndroid(StoreKey.manageLocalMediaAndroid, null, false), logLevel(StoreKey.logLevel, null, 5), // Level.INFO = 5 preferRemoteImage(StoreKey.preferRemoteImage, null, false), loopVideo(StoreKey.loopVideo, "loopVideo", true), diff --git a/mobile/lib/services/sync.service.dart b/mobile/lib/services/sync.service.dart index 1e3c2a070b..0574dc283b 100644 --- a/mobile/lib/services/sync.service.dart +++ b/mobile/lib/services/sync.service.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:io'; import 'package:collection/collection.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; @@ -16,6 +17,8 @@ import 'package:immich_mobile/interfaces/album_api.interface.dart'; import 'package:immich_mobile/interfaces/album_media.interface.dart'; import 'package:immich_mobile/interfaces/asset.interface.dart'; import 'package:immich_mobile/interfaces/etag.interface.dart'; +import 'package:immich_mobile/interfaces/local_files_manager.interface.dart'; +import 'package:immich_mobile/providers/app_settings.provider.dart'; import 'package:immich_mobile/interfaces/partner.interface.dart'; import 'package:immich_mobile/interfaces/partner_api.interface.dart'; import 'package:immich_mobile/providers/infrastructure/exif.provider.dart'; @@ -25,6 +28,8 @@ import 'package:immich_mobile/repositories/album_api.repository.dart'; import 'package:immich_mobile/repositories/album_media.repository.dart'; import 'package:immich_mobile/repositories/asset.repository.dart'; import 'package:immich_mobile/repositories/etag.repository.dart'; +import 'package:immich_mobile/repositories/local_files_manager.repository.dart'; +import 'package:immich_mobile/services/app_settings.service.dart'; import 'package:immich_mobile/repositories/partner.repository.dart'; import 'package:immich_mobile/repositories/partner_api.repository.dart'; import 'package:immich_mobile/services/entity.service.dart'; @@ -48,6 +53,8 @@ final syncServiceProvider = Provider( ref.watch(userRepositoryProvider), ref.watch(userServiceProvider), ref.watch(etagRepositoryProvider), + ref.watch(appSettingsServiceProvider), + ref.watch(localFilesManagerRepositoryProvider), ref.watch(partnerApiRepositoryProvider), ref.watch(userApiRepositoryProvider), ), @@ -69,6 +76,8 @@ class SyncService { final IUserApiRepository _userApiRepository; final AsyncMutex _lock = AsyncMutex(); final Logger _log = Logger('SyncService'); + final AppSettingsService _appSettingsService; + final ILocalFilesManager _localFilesManager; SyncService( this._hashService, @@ -82,6 +91,8 @@ class SyncService { this._userRepository, this._userService, this._eTagRepository, + this._appSettingsService, + this._localFilesManager, this._partnerApiRepository, this._userApiRepository, ); @@ -238,8 +249,19 @@ class SyncService { return null; } + Future _moveToTrashMatchedAssets(Iterable idsToDelete) async { + final List localAssets = await _assetRepository.getAllLocal(); + final List matchedAssets = localAssets + .where((asset) => idsToDelete.contains(asset.remoteId)) + .toList(); + + for (var asset in matchedAssets) { + _localFilesManager.moveToTrash(asset.fileName); + } + } + /// Deletes remote-only assets, updates merged assets to be local-only - Future handleRemoteAssetRemoval(List idsToDelete) { + Future handleRemoteAssetRemoval(List idsToDelete) async { return _assetRepository.transaction(() async { await _assetRepository.deleteAllByRemoteId( idsToDelete, @@ -249,6 +271,12 @@ class SyncService { idsToDelete, state: AssetState.merged, ); + if (Platform.isAndroid && + _appSettingsService.getSetting( + AppSettingsEnum.manageLocalMediaAndroid, + )) { + await _moveToTrashMatchedAssets(idsToDelete); + } if (merged.isEmpty) return; for (final Asset asset in merged) { asset.remoteId = null; @@ -790,9 +818,27 @@ class SyncService { return (existing, toUpsert); } + Future _toggleTrashStatusForAssets(List assetsList) async { + for (var asset in assetsList) { + if (asset.isTrashed) { + _localFilesManager.moveToTrash(asset.fileName); + } else { + _localFilesManager.restoreFromTrash(asset.fileName); + } + } + } + /// Inserts or updates the assets in the database with their ExifInfo (if any) Future upsertAssetsWithExif(List assets) async { if (assets.isEmpty) return; + + if (Platform.isAndroid && + _appSettingsService.getSetting( + AppSettingsEnum.manageLocalMediaAndroid, + )) { + _toggleTrashStatusForAssets(assets); + } + final exifInfos = assets.map((e) => e.exifInfo).nonNulls.toList(); try { await _assetRepository.transaction(() async { diff --git a/mobile/lib/utils/local_files_manager.dart b/mobile/lib/utils/local_files_manager.dart new file mode 100644 index 0000000000..da9308c3cf --- /dev/null +++ b/mobile/lib/utils/local_files_manager.dart @@ -0,0 +1,39 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; + +class LocalFilesManager { + static const MethodChannel _channel = MethodChannel('file_trash'); + + static Future moveToTrash(String fileName) async { + try { + final bool success = + await _channel.invokeMethod('moveToTrash', {'fileName': fileName}); + return success; + } on PlatformException catch (e) { + debugPrint('Error moving to trash: ${e.message}'); + return false; + } + } + + static Future restoreFromTrash(String fileName) async { + try { + final bool success = await _channel + .invokeMethod('restoreFromTrash', {'fileName': fileName}); + return success; + } on PlatformException catch (e) { + debugPrint('Error restoring file: ${e.message}'); + return false; + } + } + + static Future requestManageStoragePermission() async { + try { + final bool success = + await _channel.invokeMethod('requestManageStoragePermission'); + return success; + } on PlatformException catch (e) { + debugPrint('Error requesting permission: ${e.message}'); + return false; + } + } +} diff --git a/mobile/lib/widgets/settings/advanced_settings.dart b/mobile/lib/widgets/settings/advanced_settings.dart index a2e0e5b95c..98c8728298 100644 --- a/mobile/lib/widgets/settings/advanced_settings.dart +++ b/mobile/lib/widgets/settings/advanced_settings.dart @@ -1,11 +1,13 @@ import 'dart:io'; +import 'package:device_info_plus/device_info_plus.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart' hide Store; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/domain/services/log.service.dart'; import 'package:immich_mobile/providers/user.provider.dart'; +import 'package:immich_mobile/repositories/local_files_manager.repository.dart'; import 'package:immich_mobile/services/app_settings.service.dart'; import 'package:immich_mobile/utils/hooks/app_settings_update_hook.dart'; import 'package:immich_mobile/utils/http_ssl_cert_override.dart'; @@ -25,6 +27,8 @@ class AdvancedSettings extends HookConsumerWidget { final advancedTroubleshooting = useAppSettingsState(AppSettingsEnum.advancedTroubleshooting); + final manageLocalMediaAndroid = + useAppSettingsState(AppSettingsEnum.manageLocalMediaAndroid); final levelId = useAppSettingsState(AppSettingsEnum.logLevel); final preferRemote = useAppSettingsState(AppSettingsEnum.preferRemoteImage); final allowSelfSignedSSLCert = @@ -40,6 +44,16 @@ class AdvancedSettings extends HookConsumerWidget { LogService.I.setlogLevel(Level.LEVELS[levelId.value].toLogLevel()), ); + Future checkAndroidVersion() async { + if (Platform.isAndroid) { + DeviceInfoPlugin deviceInfo = DeviceInfoPlugin(); + AndroidDeviceInfo androidInfo = await deviceInfo.androidInfo; + int sdkVersion = androidInfo.version.sdkInt; + return sdkVersion >= 30; + } + return false; + } + final advancedSettings = [ SettingsSwitchListTile( enabled: true, @@ -47,6 +61,29 @@ class AdvancedSettings extends HookConsumerWidget { title: "advanced_settings_troubleshooting_title".tr(), subtitle: "advanced_settings_troubleshooting_subtitle".tr(), ), + FutureBuilder( + future: checkAndroidVersion(), + builder: (context, snapshot) { + if (snapshot.hasData && snapshot.data == true) { + return SettingsSwitchListTile( + enabled: true, + valueNotifier: manageLocalMediaAndroid, + title: "advanced_settings_sync_remote_deletions_title".tr(), + subtitle: "advanced_settings_sync_remote_deletions_subtitle".tr(), + onChanged: (value) async { + if (value) { + final result = await ref + .read(localFilesManagerRepositoryProvider) + .requestManageStoragePermission(); + manageLocalMediaAndroid.value = result; + } + }, + ); + } else { + return const SizedBox.shrink(); + } + }, + ), SettingsSliderListTile( text: "advanced_settings_log_level_title".tr(args: [logLevel]), valueNotifier: levelId, diff --git a/mobile/test/modules/shared/sync_service_test.dart b/mobile/test/modules/shared/sync_service_test.dart index eab6b6f61a..47bc1b9544 100644 --- a/mobile/test/modules/shared/sync_service_test.dart +++ b/mobile/test/modules/shared/sync_service_test.dart @@ -60,6 +60,9 @@ void main() { final MockAlbumMediaRepository albumMediaRepository = MockAlbumMediaRepository(); final MockAlbumApiRepository albumApiRepository = MockAlbumApiRepository(); + final MockAppSettingService appSettingService = MockAppSettingService(); + final MockLocalFilesManagerRepository localFilesManagerRepository = + MockLocalFilesManagerRepository(); final MockPartnerApiRepository partnerApiRepository = MockPartnerApiRepository(); final MockUserApiRepository userApiRepository = MockUserApiRepository(); @@ -106,6 +109,8 @@ void main() { userRepository, userService, eTagRepository, + appSettingService, + localFilesManagerRepository, partnerApiRepository, userApiRepository, ); diff --git a/mobile/test/repository.mocks.dart b/mobile/test/repository.mocks.dart index 1c698297dc..d2f0da4231 100644 --- a/mobile/test/repository.mocks.dart +++ b/mobile/test/repository.mocks.dart @@ -10,6 +10,7 @@ import 'package:immich_mobile/interfaces/auth_api.interface.dart'; import 'package:immich_mobile/interfaces/backup_album.interface.dart'; import 'package:immich_mobile/interfaces/etag.interface.dart'; import 'package:immich_mobile/interfaces/file_media.interface.dart'; +import 'package:immich_mobile/interfaces/local_files_manager.interface.dart'; import 'package:immich_mobile/interfaces/partner.interface.dart'; import 'package:immich_mobile/interfaces/partner_api.interface.dart'; import 'package:mocktail/mocktail.dart'; @@ -41,6 +42,9 @@ class MockAuthApiRepository extends Mock implements IAuthApiRepository {} class MockAuthRepository extends Mock implements IAuthRepository {} +class MockPartnerRepository extends Mock implements IPartnerRepository {} + class MockPartnerApiRepository extends Mock implements IPartnerApiRepository {} -class MockPartnerRepository extends Mock implements IPartnerRepository {} +class MockLocalFilesManagerRepository extends Mock + implements ILocalFilesManager {} diff --git a/mobile/test/service.mocks.dart b/mobile/test/service.mocks.dart index d31a7e5d50..e1b8df40a3 100644 --- a/mobile/test/service.mocks.dart +++ b/mobile/test/service.mocks.dart @@ -1,5 +1,6 @@ import 'package:immich_mobile/services/album.service.dart'; import 'package:immich_mobile/services/api.service.dart'; +import 'package:immich_mobile/services/app_settings.service.dart'; import 'package:immich_mobile/services/background.service.dart'; import 'package:immich_mobile/services/backup.service.dart'; import 'package:immich_mobile/services/entity.service.dart'; @@ -25,4 +26,7 @@ class MockNetworkService extends Mock implements NetworkService {} class MockSearchApi extends Mock implements SearchApi {} +class MockAppSettingService extends Mock implements AppSettingsService {} + class MockBackgroundService extends Mock implements BackgroundService {} + From fdbe6d649f8424989866109d4b9cbcc98c66ec7d Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Tue, 8 Apr 2025 09:56:45 -0400 Subject: [PATCH 36/52] refactor: remove smart search entity (#17447) refactor: smart search entity --- server/src/db.d.ts | 4 +- server/src/entities/asset.entity.ts | 6 +- server/src/entities/smart-search.entity.ts | 7 -- server/src/queries/asset.repository.sql | 31 +++++++ server/src/repositories/asset.repository.ts | 20 +++++ server/src/services/duplicate.service.spec.ts | 89 ++++++++++++------- server/src/services/duplicate.service.ts | 12 +-- server/src/utils/asset.util.ts | 5 +- server/test/fixtures/asset.stub.ts | 84 ----------------- .../repositories/asset.repository.mock.ts | 1 + 10 files changed, 125 insertions(+), 134 deletions(-) delete mode 100644 server/src/entities/smart-search.entity.ts diff --git a/server/src/db.d.ts b/server/src/db.d.ts index ca6d1813e4..9539a28031 100644 --- a/server/src/db.d.ts +++ b/server/src/db.d.ts @@ -4,7 +4,7 @@ */ import type { ColumnType } from 'kysely'; -import { AssetType, MemoryType, Permission, SyncEntityType } from 'src/enum'; +import { AssetFileType, AssetType, MemoryType, Permission, SyncEntityType } from 'src/enum'; import { UserTable } from 'src/schema/tables/user.table'; import { OnThisDayData } from 'src/types'; @@ -106,7 +106,7 @@ export interface AssetFiles { createdAt: Generated; id: Generated; path: string; - type: string; + type: AssetFileType; updatedAt: Generated; updateId: Generated; } diff --git a/server/src/entities/asset.entity.ts b/server/src/entities/asset.entity.ts index 836fc409af..e9f8bea4ca 100644 --- a/server/src/entities/asset.entity.ts +++ b/server/src/entities/asset.entity.ts @@ -7,7 +7,6 @@ import { AssetFileEntity } from 'src/entities/asset-files.entity'; import { AssetJobStatusEntity } from 'src/entities/asset-job-status.entity'; import { ExifEntity } from 'src/entities/exif.entity'; import { SharedLinkEntity } from 'src/entities/shared-link.entity'; -import { SmartSearchEntity } from 'src/entities/smart-search.entity'; import { StackEntity } from 'src/entities/stack.entity'; import { TagEntity } from 'src/entities/tag.entity'; import { UserEntity } from 'src/entities/user.entity'; @@ -50,7 +49,6 @@ export class AssetEntity { originalFileName!: string; sidecarPath!: string | null; exifInfo?: ExifEntity; - smartSearch?: SmartSearchEntity; tags!: TagEntity[]; sharedLinks!: SharedLinkEntity[]; albums?: AlbumEntity[]; @@ -97,9 +95,9 @@ export function withFiles(eb: ExpressionBuilder, type?: AssetFileT return jsonArrayFrom( eb .selectFrom('asset_files') - .selectAll() + .selectAll('asset_files') .whereRef('asset_files.assetId', '=', 'assets.id') - .$if(!!type, (qb) => qb.where('type', '=', type!)), + .$if(!!type, (qb) => qb.where('asset_files.type', '=', type!)), ).as('files'); } diff --git a/server/src/entities/smart-search.entity.ts b/server/src/entities/smart-search.entity.ts deleted file mode 100644 index e8a8f27cb1..0000000000 --- a/server/src/entities/smart-search.entity.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { AssetEntity } from 'src/entities/asset.entity'; - -export class SmartSearchEntity { - asset?: AssetEntity; - assetId!: string; - embedding!: string; -} diff --git a/server/src/queries/asset.repository.sql b/server/src/queries/asset.repository.sql index b2fdf976df..a9dcb1f8bf 100644 --- a/server/src/queries/asset.repository.sql +++ b/server/src/queries/asset.repository.sql @@ -179,6 +179,37 @@ from where "livePhotoVideoId" = $1::uuid +-- AssetRepository.getAssetForSearchDuplicatesJob +select + "id", + "type", + "ownerId", + "duplicateId", + "stackId", + "isVisible", + "smart_search"."embedding", + ( + select + coalesce(json_agg(agg), '[]') + from + ( + select + "asset_files".* + from + "asset_files" + where + "asset_files"."assetId" = "assets"."id" + and "asset_files"."type" = $1 + ) as agg + ) as "files" +from + "assets" + left join "smart_search" on "assets"."id" = "smart_search"."assetId" +where + "assets"."id" = $2::uuid +limit + $3 + -- AssetRepository.getById select "assets".* diff --git a/server/src/repositories/asset.repository.ts b/server/src/repositories/asset.repository.ts index 77154bbd1d..5f2a94cc80 100644 --- a/server/src/repositories/asset.repository.ts +++ b/server/src/repositories/asset.repository.ts @@ -475,6 +475,26 @@ export class AssetRepository { return count as number; } + @GenerateSql({ params: [DummyValue.UUID] }) + getAssetForSearchDuplicatesJob(id: string) { + return this.db + .selectFrom('assets') + .where('assets.id', '=', asUuid(id)) + .leftJoin('smart_search', 'assets.id', 'smart_search.assetId') + .select((eb) => [ + 'id', + 'type', + 'ownerId', + 'duplicateId', + 'stackId', + 'isVisible', + 'smart_search.embedding', + withFiles(eb, AssetFileType.PREVIEW), + ]) + .limit(1) + .executeTakeFirst(); + } + @GenerateSql({ params: [DummyValue.UUID] }) getById( id: string, diff --git a/server/src/services/duplicate.service.spec.ts b/server/src/services/duplicate.service.spec.ts index 8be943eaf0..adb884c24f 100644 --- a/server/src/services/duplicate.service.spec.ts +++ b/server/src/services/duplicate.service.spec.ts @@ -1,4 +1,4 @@ -import { JobName, JobStatus } from 'src/enum'; +import { AssetFileType, AssetType, JobName, JobStatus } from 'src/enum'; import { WithoutProperty } from 'src/repositories/asset.repository'; import { DuplicateService } from 'src/services/duplicate.service'; import { SearchService } from 'src/services/search.service'; @@ -9,6 +9,33 @@ import { beforeEach, vitest } from 'vitest'; vitest.useFakeTimers(); +const hasEmbedding = { + id: 'asset-1', + ownerId: 'user-id', + files: [ + { + assetId: 'asset-1', + createdAt: new Date(), + id: 'file-1', + path: 'preview.jpg', + type: AssetFileType.PREVIEW, + updatedAt: new Date(), + updateId: 'update-1', + }, + ], + isVisible: true, + stackId: null, + type: AssetType.IMAGE, + duplicateId: null, + embedding: '[1, 2, 3, 4]', +}; + +const hasDupe = { + ...hasEmbedding, + id: 'asset-2', + duplicateId: 'duplicate-id', +}; + describe(SearchService.name, () => { let sut: DuplicateService; let mocks: ServiceMocks; @@ -25,16 +52,16 @@ describe(SearchService.name, () => { it('should get duplicates', async () => { mocks.asset.getDuplicates.mockResolvedValue([ { - duplicateId: assetStub.hasDupe.duplicateId!, - assets: [assetStub.hasDupe, assetStub.hasDupe], + duplicateId: 'duplicate-id', + assets: [assetStub.image, assetStub.image], }, ]); await expect(sut.getDuplicates(authStub.admin)).resolves.toEqual([ { - duplicateId: assetStub.hasDupe.duplicateId, + duplicateId: 'duplicate-id', assets: [ - expect.objectContaining({ id: assetStub.hasDupe.id }), - expect.objectContaining({ id: assetStub.hasDupe.id }), + expect.objectContaining({ id: assetStub.image.id }), + expect.objectContaining({ id: assetStub.image.id }), ], }, ]); @@ -175,7 +202,7 @@ describe(SearchService.name, () => { it('should skip if asset is part of stack', async () => { const id = assetStub.primaryImage.id; - mocks.asset.getById.mockResolvedValue(assetStub.primaryImage); + mocks.asset.getAssetForSearchDuplicatesJob.mockResolvedValue({ ...hasEmbedding, stackId: 'stack-id' }); const result = await sut.handleSearchDuplicates({ id }); @@ -185,7 +212,7 @@ describe(SearchService.name, () => { it('should skip if asset is not visible', async () => { const id = assetStub.livePhotoMotionAsset.id; - mocks.asset.getById.mockResolvedValue(assetStub.livePhotoMotionAsset); + mocks.asset.getAssetForSearchDuplicatesJob.mockResolvedValue({ ...hasEmbedding, isVisible: false }); const result = await sut.handleSearchDuplicates({ id }); @@ -194,7 +221,7 @@ describe(SearchService.name, () => { }); it('should fail if asset is missing preview image', async () => { - mocks.asset.getById.mockResolvedValue(assetStub.noResizePath); + mocks.asset.getAssetForSearchDuplicatesJob.mockResolvedValue({ ...hasEmbedding, files: [] }); const result = await sut.handleSearchDuplicates({ id: assetStub.noResizePath.id }); @@ -203,7 +230,7 @@ describe(SearchService.name, () => { }); it('should fail if asset is missing embedding', async () => { - mocks.asset.getById.mockResolvedValue(assetStub.image); + mocks.asset.getAssetForSearchDuplicatesJob.mockResolvedValue({ ...hasEmbedding, embedding: null }); const result = await sut.handleSearchDuplicates({ id: assetStub.image.id }); @@ -212,21 +239,21 @@ describe(SearchService.name, () => { }); it('should search for duplicates and update asset with duplicateId', async () => { - mocks.asset.getById.mockResolvedValue(assetStub.hasEmbedding); + mocks.asset.getAssetForSearchDuplicatesJob.mockResolvedValue(hasEmbedding); mocks.search.searchDuplicates.mockResolvedValue([ { assetId: assetStub.image.id, distance: 0.01, duplicateId: null }, ]); - const expectedAssetIds = [assetStub.image.id, assetStub.hasEmbedding.id]; + const expectedAssetIds = [assetStub.image.id, hasEmbedding.id]; - const result = await sut.handleSearchDuplicates({ id: assetStub.hasEmbedding.id }); + const result = await sut.handleSearchDuplicates({ id: hasEmbedding.id }); expect(result).toBe(JobStatus.SUCCESS); expect(mocks.search.searchDuplicates).toHaveBeenCalledWith({ - assetId: assetStub.hasEmbedding.id, - embedding: assetStub.hasEmbedding.smartSearch!.embedding, + assetId: hasEmbedding.id, + embedding: hasEmbedding.embedding, maxDistance: 0.01, - type: assetStub.hasEmbedding.type, - userIds: [assetStub.hasEmbedding.ownerId], + type: hasEmbedding.type, + userIds: [hasEmbedding.ownerId], }); expect(mocks.asset.updateDuplicates).toHaveBeenCalledWith({ assetIds: expectedAssetIds, @@ -239,24 +266,24 @@ describe(SearchService.name, () => { }); it('should use existing duplicate ID among matched duplicates', async () => { - const duplicateId = assetStub.hasDupe.duplicateId; - mocks.asset.getById.mockResolvedValue(assetStub.hasEmbedding); - mocks.search.searchDuplicates.mockResolvedValue([{ assetId: assetStub.hasDupe.id, distance: 0.01, duplicateId }]); - const expectedAssetIds = [assetStub.hasEmbedding.id]; + const duplicateId = hasDupe.duplicateId; + mocks.asset.getAssetForSearchDuplicatesJob.mockResolvedValue(hasEmbedding); + mocks.search.searchDuplicates.mockResolvedValue([{ assetId: hasDupe.id, distance: 0.01, duplicateId }]); + const expectedAssetIds = [hasEmbedding.id]; - const result = await sut.handleSearchDuplicates({ id: assetStub.hasEmbedding.id }); + const result = await sut.handleSearchDuplicates({ id: hasEmbedding.id }); expect(result).toBe(JobStatus.SUCCESS); expect(mocks.search.searchDuplicates).toHaveBeenCalledWith({ - assetId: assetStub.hasEmbedding.id, - embedding: assetStub.hasEmbedding.smartSearch!.embedding, + assetId: hasEmbedding.id, + embedding: hasEmbedding.embedding, maxDistance: 0.01, - type: assetStub.hasEmbedding.type, - userIds: [assetStub.hasEmbedding.ownerId], + type: hasEmbedding.type, + userIds: [hasEmbedding.ownerId], }); expect(mocks.asset.updateDuplicates).toHaveBeenCalledWith({ assetIds: expectedAssetIds, - targetDuplicateId: assetStub.hasDupe.duplicateId, + targetDuplicateId: duplicateId, duplicateIds: [], }); expect(mocks.asset.upsertJobStatus).toHaveBeenCalledWith( @@ -265,15 +292,15 @@ describe(SearchService.name, () => { }); it('should remove duplicateId if no duplicates found and asset has duplicateId', async () => { - mocks.asset.getById.mockResolvedValue(assetStub.hasDupe); + mocks.asset.getAssetForSearchDuplicatesJob.mockResolvedValue(hasDupe); mocks.search.searchDuplicates.mockResolvedValue([]); - const result = await sut.handleSearchDuplicates({ id: assetStub.hasDupe.id }); + const result = await sut.handleSearchDuplicates({ id: hasDupe.id }); expect(result).toBe(JobStatus.SUCCESS); - expect(mocks.asset.update).toHaveBeenCalledWith({ id: assetStub.hasDupe.id, duplicateId: null }); + expect(mocks.asset.update).toHaveBeenCalledWith({ id: hasDupe.id, duplicateId: null }); expect(mocks.asset.upsertJobStatus).toHaveBeenCalledWith({ - assetId: assetStub.hasDupe.id, + assetId: hasDupe.id, duplicatesDetectedAt: expect.any(Date), }); }); diff --git a/server/src/services/duplicate.service.ts b/server/src/services/duplicate.service.ts index da6c6794fb..10adb645d3 100644 --- a/server/src/services/duplicate.service.ts +++ b/server/src/services/duplicate.service.ts @@ -4,7 +4,6 @@ import { OnJob } from 'src/decorators'; import { mapAsset } from 'src/dtos/asset-response.dto'; import { AuthDto } from 'src/dtos/auth.dto'; import { DuplicateResponseDto } from 'src/dtos/duplicate.dto'; -import { AssetEntity } from 'src/entities/asset.entity'; import { AssetFileType, JobName, JobStatus, QueueName } from 'src/enum'; import { WithoutProperty } from 'src/repositories/asset.repository'; import { AssetDuplicateResult } from 'src/repositories/search.repository'; @@ -53,7 +52,7 @@ export class DuplicateService extends BaseService { return JobStatus.SKIPPED; } - const asset = await this.assetRepository.getById(id, { files: true, smartSearch: true }); + const asset = await this.assetRepository.getAssetForSearchDuplicatesJob(id); if (!asset) { this.logger.error(`Asset ${id} not found`); return JobStatus.FAILED; @@ -75,14 +74,14 @@ export class DuplicateService extends BaseService { return JobStatus.FAILED; } - if (!asset.smartSearch?.embedding) { + if (!asset.embedding) { this.logger.debug(`Asset ${id} is missing embedding`); return JobStatus.FAILED; } const duplicateAssets = await this.searchRepository.searchDuplicates({ assetId: asset.id, - embedding: asset.smartSearch.embedding, + embedding: asset.embedding, maxDistance: machineLearning.duplicateDetection.maxDistance, type: asset.type, userIds: [asset.ownerId], @@ -105,7 +104,10 @@ export class DuplicateService extends BaseService { return JobStatus.SUCCESS; } - private async updateDuplicates(asset: AssetEntity, duplicateAssets: AssetDuplicateResult[]): Promise { + private async updateDuplicates( + asset: { id: string; duplicateId: string | null }, + duplicateAssets: AssetDuplicateResult[], + ): Promise { const duplicateIds = [ ...new Set( duplicateAssets diff --git a/server/src/utils/asset.util.ts b/server/src/utils/asset.util.ts index 96ef90bfce..575cbb4a21 100644 --- a/server/src/utils/asset.util.ts +++ b/server/src/utils/asset.util.ts @@ -13,7 +13,10 @@ import { PartnerRepository } from 'src/repositories/partner.repository'; import { IBulkAsset, ImmichFile, UploadFile } from 'src/types'; import { checkAccess } from 'src/utils/access'; -export const getAssetFile = (files: AssetFileEntity[], type: AssetFileType | GeneratedImageType) => { +export const getAssetFile = ( + files: T[], + type: AssetFileType | GeneratedImageType, +) => { return (files || []).find((file) => file.type === type); }; diff --git a/server/test/fixtures/asset.stub.ts b/server/test/fixtures/asset.stub.ts index d56c5f6efd..5a140ce104 100644 --- a/server/test/fixtures/asset.stub.ts +++ b/server/test/fixtures/asset.stub.ts @@ -851,88 +851,4 @@ export const assetStub = { duplicateId: null, isOffline: false, }), - - hasEmbedding: Object.freeze({ - id: 'asset-id-embedding', - status: AssetStatus.ACTIVE, - deviceAssetId: 'device-asset-id', - fileModifiedAt: new Date('2023-02-23T05:06:29.716Z'), - fileCreatedAt: new Date('2023-02-23T05:06:29.716Z'), - owner: userStub.user1, - ownerId: 'user-id', - deviceId: 'device-id', - originalPath: '/original/path.jpg', - checksum: Buffer.from('file hash', 'utf8'), - type: AssetType.IMAGE, - files, - thumbhash: Buffer.from('blablabla', 'base64'), - encodedVideoPath: null, - createdAt: new Date('2023-02-23T05:06:29.716Z'), - updatedAt: new Date('2023-02-23T05:06:29.716Z'), - localDateTime: new Date('2023-02-23T05:06:29.716Z'), - isFavorite: true, - isArchived: false, - duration: null, - isVisible: true, - isExternal: false, - livePhotoVideo: null, - livePhotoVideoId: null, - tags: [], - sharedLinks: [], - originalFileName: 'asset-id.jpg', - faces: [], - deletedAt: null, - sidecarPath: null, - exifInfo: { - fileSizeInByte: 5000, - } as ExifEntity, - duplicateId: null, - smartSearch: { - assetId: 'asset-id', - embedding: '[1, 2, 3, 4]', - }, - isOffline: false, - }), - - hasDupe: Object.freeze({ - id: 'asset-id-dupe', - status: AssetStatus.ACTIVE, - deviceAssetId: 'device-asset-id', - fileModifiedAt: new Date('2023-02-23T05:06:29.716Z'), - fileCreatedAt: new Date('2023-02-23T05:06:29.716Z'), - owner: userStub.user1, - ownerId: 'user-id', - deviceId: 'device-id', - originalPath: '/original/path.jpg', - checksum: Buffer.from('file hash', 'utf8'), - type: AssetType.IMAGE, - files, - thumbhash: Buffer.from('blablabla', 'base64'), - encodedVideoPath: null, - createdAt: new Date('2023-02-23T05:06:29.716Z'), - updatedAt: new Date('2023-02-23T05:06:29.716Z'), - localDateTime: new Date('2023-02-23T05:06:29.716Z'), - isFavorite: true, - isArchived: false, - duration: null, - isVisible: true, - isExternal: false, - livePhotoVideo: null, - livePhotoVideoId: null, - tags: [], - sharedLinks: [], - originalFileName: 'asset-id.jpg', - faces: [], - deletedAt: null, - sidecarPath: null, - exifInfo: { - fileSizeInByte: 5000, - } as ExifEntity, - duplicateId: 'duplicate-id', - smartSearch: { - assetId: 'asset-id', - embedding: '[1, 2, 3, 4]', - }, - isOffline: false, - }), }; diff --git a/server/test/repositories/asset.repository.mock.ts b/server/test/repositories/asset.repository.mock.ts index a17ca03e85..5e09e3e886 100644 --- a/server/test/repositories/asset.repository.mock.ts +++ b/server/test/repositories/asset.repository.mock.ts @@ -11,6 +11,7 @@ export const newAssetRepositoryMock = (): Mocked Date: Tue, 8 Apr 2025 19:48:33 +0530 Subject: [PATCH 37/52] fix(mobile): hide asset description text field if user is not owner (#17442) * fix(mobile): hide asset description text field if user is not owner * If user is not the owner and asset has no description then hide the text field * Apply suggestions from code review Co-authored-by: Alex --------- Co-authored-by: Alex --- .../widgets/asset_viewer/description_input.dart | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/mobile/lib/widgets/asset_viewer/description_input.dart b/mobile/lib/widgets/asset_viewer/description_input.dart index 778212eabe..3ac60fd613 100644 --- a/mobile/lib/widgets/asset_viewer/description_input.dart +++ b/mobile/lib/widgets/asset_viewer/description_input.dart @@ -34,17 +34,24 @@ class DescriptionInput extends HookConsumerWidget { final owner = ref.watch(currentUserProvider); final hasError = useState(false); final assetWithExif = ref.watch(assetDetailProvider(asset)); + final hasDescription = useState(false); + final isOwner = fastHash(owner?.id ?? '') == asset.ownerId; useEffect( () { - assetService - .getDescription(asset) - .then((value) => controller.text = value); + assetService.getDescription(asset).then((value) { + controller.text = value; + hasDescription.value = value.isNotEmpty; + }); return null; }, [assetWithExif.value], ); + if (!isOwner && !hasDescription.value) { + return const SizedBox.shrink(); + } + submitDescription(String description) async { hasError.value = false; try { @@ -82,7 +89,7 @@ class DescriptionInput extends HookConsumerWidget { } return TextField( - enabled: fastHash(owner?.id ?? '') == asset.ownerId, + enabled: isOwner, focusNode: focusNode, onTap: () => isFocus.value = true, onChanged: (value) { From b6c5a0353318c78d47281782270d829121bd5f1f Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Tue, 8 Apr 2025 10:52:54 -0400 Subject: [PATCH 38/52] refactor: remove tag entity (#17462) --- server/src/database.ts | 7 +++++ server/src/dtos/tag.dto.ts | 3 +-- server/src/entities/asset.entity.ts | 4 +-- server/src/entities/tag.entity.ts | 17 ------------ server/src/queries/asset.repository.sql | 26 +++++++++++++++++++ server/src/repositories/asset.repository.ts | 22 ++++++++++++++++ server/src/services/metadata.service.spec.ts | 17 +++++++----- server/src/services/metadata.service.ts | 2 +- server/test/fixtures/asset.stub.ts | 19 -------------- server/test/fixtures/shared-link.stub.ts | 1 - .../repositories/asset.repository.mock.ts | 1 + server/test/small.factory.ts | 23 +++++++++++++++- 12 files changed, 92 insertions(+), 50 deletions(-) delete mode 100644 server/src/entities/tag.entity.ts diff --git a/server/src/database.ts b/server/src/database.ts index 7fd791c59c..f4e49f07ab 100644 --- a/server/src/database.ts +++ b/server/src/database.ts @@ -92,6 +92,13 @@ export type Asset = { type: AssetType; }; +export type SidecarWriteAsset = { + id: string; + sidecarPath: string | null; + originalPath: string; + tags: Array<{ value: string }>; +}; + export type AuthSharedLink = { id: string; expiresAt: Date | null; diff --git a/server/src/dtos/tag.dto.ts b/server/src/dtos/tag.dto.ts index e62cf21636..3c5e74647c 100644 --- a/server/src/dtos/tag.dto.ts +++ b/server/src/dtos/tag.dto.ts @@ -1,6 +1,5 @@ import { ApiProperty } from '@nestjs/swagger'; import { IsHexColor, IsNotEmpty, IsString } from 'class-validator'; -import { TagEntity } from 'src/entities/tag.entity'; import { TagItem } from 'src/types'; import { Optional, ValidateHexColor, ValidateUUID } from 'src/validation'; @@ -52,7 +51,7 @@ export class TagResponseDto { color?: string; } -export function mapTag(entity: TagItem | TagEntity): TagResponseDto { +export function mapTag(entity: TagItem): TagResponseDto { return { id: entity.id, parentId: entity.parentId ?? undefined, diff --git a/server/src/entities/asset.entity.ts b/server/src/entities/asset.entity.ts index e9f8bea4ca..50f2d6c5d1 100644 --- a/server/src/entities/asset.entity.ts +++ b/server/src/entities/asset.entity.ts @@ -8,11 +8,11 @@ import { AssetJobStatusEntity } from 'src/entities/asset-job-status.entity'; import { ExifEntity } from 'src/entities/exif.entity'; import { SharedLinkEntity } from 'src/entities/shared-link.entity'; import { StackEntity } from 'src/entities/stack.entity'; -import { TagEntity } from 'src/entities/tag.entity'; import { UserEntity } from 'src/entities/user.entity'; import { AssetFileType, AssetStatus, AssetType } from 'src/enum'; import { TimeBucketSize } from 'src/repositories/asset.repository'; import { AssetSearchBuilderOptions } from 'src/repositories/search.repository'; +import { TagItem } from 'src/types'; import { anyUuid, asUuid } from 'src/utils/database'; export const ASSET_CHECKSUM_CONSTRAINT = 'UQ_assets_owner_checksum'; @@ -49,7 +49,7 @@ export class AssetEntity { originalFileName!: string; sidecarPath!: string | null; exifInfo?: ExifEntity; - tags!: TagEntity[]; + tags?: TagItem[]; sharedLinks!: SharedLinkEntity[]; albums?: AlbumEntity[]; faces!: AssetFaceEntity[]; diff --git a/server/src/entities/tag.entity.ts b/server/src/entities/tag.entity.ts deleted file mode 100644 index 01235085a4..0000000000 --- a/server/src/entities/tag.entity.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { AssetEntity } from 'src/entities/asset.entity'; -import { UserEntity } from 'src/entities/user.entity'; - -export class TagEntity { - id!: string; - value!: string; - createdAt!: Date; - updatedAt!: Date; - updateId?: string; - color!: string | null; - parentId?: string; - parent?: TagEntity; - children?: TagEntity[]; - user?: UserEntity; - userId!: string; - assets?: AssetEntity[]; -} diff --git a/server/src/queries/asset.repository.sql b/server/src/queries/asset.repository.sql index a9dcb1f8bf..d840a7693c 100644 --- a/server/src/queries/asset.repository.sql +++ b/server/src/queries/asset.repository.sql @@ -210,6 +210,32 @@ where limit $3 +-- AssetRepository.getAssetForSidecarWriteJob +select + "id", + "sidecarPath", + "originalPath", + ( + select + coalesce(json_agg(agg), '[]') + from + ( + select + "tags"."value" + from + "tags" + inner join "tag_asset" on "tags"."id" = "tag_asset"."tagsId" + where + "assets"."id" = "tag_asset"."assetsId" + ) as agg + ) as "tags" +from + "assets" +where + "assets"."id" = $1::uuid +limit + $2 + -- AssetRepository.getById select "assets".* diff --git a/server/src/repositories/asset.repository.ts b/server/src/repositories/asset.repository.ts index 5f2a94cc80..3b71cf84fd 100644 --- a/server/src/repositories/asset.repository.ts +++ b/server/src/repositories/asset.repository.ts @@ -1,5 +1,6 @@ import { Injectable } from '@nestjs/common'; import { Insertable, Kysely, Selectable, UpdateResult, Updateable, sql } from 'kysely'; +import { jsonArrayFrom } from 'kysely/helpers/postgres'; import { isEmpty, isUndefined, omitBy } from 'lodash'; import { InjectKysely } from 'nestjs-kysely'; import { AssetFiles, AssetJobStatus, Assets, DB, Exif } from 'src/db'; @@ -495,6 +496,27 @@ export class AssetRepository { .executeTakeFirst(); } + @GenerateSql({ params: [DummyValue.UUID] }) + getAssetForSidecarWriteJob(id: string) { + return this.db + .selectFrom('assets') + .where('assets.id', '=', asUuid(id)) + .select((eb) => [ + 'id', + 'sidecarPath', + 'originalPath', + jsonArrayFrom( + eb + .selectFrom('tags') + .select(['tags.value']) + .innerJoin('tag_asset', 'tags.id', 'tag_asset.tagsId') + .whereRef('assets.id', '=', 'tag_asset.assetsId'), + ).as('tags'), + ]) + .limit(1) + .executeTakeFirst(); + } + @GenerateSql({ params: [DummyValue.UUID] }) getById( id: string, diff --git a/server/src/services/metadata.service.spec.ts b/server/src/services/metadata.service.spec.ts index a0d1cdb4b4..874a84e34b 100644 --- a/server/src/services/metadata.service.spec.ts +++ b/server/src/services/metadata.service.spec.ts @@ -15,6 +15,7 @@ import { probeStub } from 'test/fixtures/media.stub'; import { metadataStub } from 'test/fixtures/metadata.stub'; import { personStub } from 'test/fixtures/person.stub'; import { tagStub } from 'test/fixtures/tag.stub'; +import { factory } from 'test/small.factory'; import { newTestService, ServiceMocks } from 'test/utils'; describe(MetadataService.name, () => { @@ -1405,33 +1406,35 @@ describe(MetadataService.name, () => { describe('handleSidecarWrite', () => { it('should skip assets that do not exist anymore', async () => { - mocks.asset.getByIds.mockResolvedValue([]); + mocks.asset.getAssetForSidecarWriteJob.mockResolvedValue(void 0); await expect(sut.handleSidecarWrite({ id: 'asset-123' })).resolves.toBe(JobStatus.FAILED); expect(mocks.metadata.writeTags).not.toHaveBeenCalled(); }); - it('should skip jobs with not metadata', async () => { - mocks.asset.getByIds.mockResolvedValue([assetStub.sidecar]); - await expect(sut.handleSidecarWrite({ id: assetStub.sidecar.id })).resolves.toBe(JobStatus.SKIPPED); + it('should skip jobs with no metadata', async () => { + const asset = factory.jobAssets.sidecarWrite(); + mocks.asset.getAssetForSidecarWriteJob.mockResolvedValue(asset); + await expect(sut.handleSidecarWrite({ id: asset.id })).resolves.toBe(JobStatus.SKIPPED); expect(mocks.metadata.writeTags).not.toHaveBeenCalled(); }); it('should write tags', async () => { + const asset = factory.jobAssets.sidecarWrite(); const description = 'this is a description'; const gps = 12; const date = '2023-11-22T04:56:12.196Z'; - mocks.asset.getByIds.mockResolvedValue([assetStub.sidecar]); + mocks.asset.getAssetForSidecarWriteJob.mockResolvedValue(asset); await expect( sut.handleSidecarWrite({ - id: assetStub.sidecar.id, + id: asset.id, description, latitude: gps, longitude: gps, dateTimeOriginal: date, }), ).resolves.toBe(JobStatus.SUCCESS); - expect(mocks.metadata.writeTags).toHaveBeenCalledWith(assetStub.sidecar.sidecarPath, { + expect(mocks.metadata.writeTags).toHaveBeenCalledWith(asset.sidecarPath, { Description: description, ImageDescription: description, DateTimeOriginal: date, diff --git a/server/src/services/metadata.service.ts b/server/src/services/metadata.service.ts index 402ccbbac7..824bf36c75 100644 --- a/server/src/services/metadata.service.ts +++ b/server/src/services/metadata.service.ts @@ -316,7 +316,7 @@ export class MetadataService extends BaseService { @OnJob({ name: JobName.SIDECAR_WRITE, queue: QueueName.SIDECAR }) async handleSidecarWrite(job: JobOf): Promise { const { id, description, dateTimeOriginal, latitude, longitude, rating, tags } = job; - const [asset] = await this.assetRepository.getByIds([id], { tags: true }); + const asset = await this.assetRepository.getAssetForSidecarWriteJob(id); if (!asset) { return JobStatus.FAILED; } diff --git a/server/test/fixtures/asset.stub.ts b/server/test/fixtures/asset.stub.ts index 5a140ce104..72016e9862 100644 --- a/server/test/fixtures/asset.stub.ts +++ b/server/test/fixtures/asset.stub.ts @@ -89,7 +89,6 @@ export const assetStub = { isVisible: true, livePhotoVideo: null, livePhotoVideoId: null, - tags: [], sharedLinks: [], faces: [], sidecarPath: null, @@ -123,7 +122,6 @@ export const assetStub = { isVisible: true, livePhotoVideo: null, livePhotoVideoId: null, - tags: [], sharedLinks: [], originalFileName: 'IMG_456.jpg', faces: [], @@ -162,7 +160,6 @@ export const assetStub = { isExternal: false, livePhotoVideo: null, livePhotoVideoId: null, - tags: [], sharedLinks: [], originalFileName: 'asset-id.ext', faces: [], @@ -197,7 +194,6 @@ export const assetStub = { isExternal: false, livePhotoVideo: null, livePhotoVideoId: null, - tags: [], sharedLinks: [], originalFileName: 'asset-id.jpg', faces: [], @@ -243,7 +239,6 @@ export const assetStub = { isExternal: false, livePhotoVideo: null, livePhotoVideoId: null, - tags: [], sharedLinks: [], originalFileName: 'asset-id.jpg', faces: [], @@ -283,7 +278,6 @@ export const assetStub = { isExternal: false, livePhotoVideo: null, livePhotoVideoId: null, - tags: [], sharedLinks: [], originalFileName: 'asset-id.jpg', faces: [], @@ -325,7 +319,6 @@ export const assetStub = { isExternal: false, livePhotoVideo: null, livePhotoVideoId: null, - tags: [], sharedLinks: [], originalFileName: 'asset-id.jpg', faces: [], @@ -363,7 +356,6 @@ export const assetStub = { isExternal: false, livePhotoVideo: null, livePhotoVideoId: null, - tags: [], sharedLinks: [], originalFileName: 'asset-id.jpg', faces: [], @@ -404,7 +396,6 @@ export const assetStub = { livePhotoVideo: null, livePhotoVideoId: null, libraryId: 'library-id', - tags: [], sharedLinks: [], originalFileName: 'asset-id.jpg', faces: [], @@ -443,7 +434,6 @@ export const assetStub = { livePhotoVideo: null, livePhotoVideoId: null, isExternal: false, - tags: [], sharedLinks: [], originalFileName: 'asset-id.ext', faces: [], @@ -480,7 +470,6 @@ export const assetStub = { isVisible: true, livePhotoVideo: null, livePhotoVideoId: null, - tags: [], sharedLinks: [], originalFileName: 'asset-id.ext', faces: [], @@ -519,7 +508,6 @@ export const assetStub = { isVisible: true, livePhotoVideo: null, livePhotoVideoId: null, - tags: [], sharedLinks: [], faces: [], sidecarPath: null, @@ -608,7 +596,6 @@ export const assetStub = { isVisible: true, livePhotoVideo: null, livePhotoVideoId: null, - tags: [], sharedLinks: [], originalFileName: 'asset-id.ext', faces: [], @@ -650,7 +637,6 @@ export const assetStub = { isVisible: true, livePhotoVideo: null, livePhotoVideoId: null, - tags: [], sharedLinks: [], originalFileName: 'asset-id.ext', faces: [], @@ -685,7 +671,6 @@ export const assetStub = { isVisible: true, livePhotoVideo: null, livePhotoVideoId: null, - tags: [], sharedLinks: [], originalFileName: 'asset-id.ext', faces: [], @@ -721,7 +706,6 @@ export const assetStub = { isVisible: true, livePhotoVideo: null, livePhotoVideoId: null, - tags: [], sharedLinks: [], faces: [], sidecarPath: null, @@ -759,7 +743,6 @@ export const assetStub = { livePhotoVideo: null, livePhotoVideoId: null, libraryId: 'library-id', - tags: [], sharedLinks: [], originalFileName: 'photo.jpg', faces: [], @@ -797,7 +780,6 @@ export const assetStub = { isExternal: false, livePhotoVideo: null, livePhotoVideoId: null, - tags: [], sharedLinks: [], originalFileName: 'asset-id.dng', faces: [], @@ -837,7 +819,6 @@ export const assetStub = { isExternal: false, livePhotoVideo: null, livePhotoVideoId: null, - tags: [], sharedLinks: [], originalFileName: 'asset-id.hif', faces: [], diff --git a/server/test/fixtures/shared-link.stub.ts b/server/test/fixtures/shared-link.stub.ts index 6ee31c0dea..739d6c5b93 100644 --- a/server/test/fixtures/shared-link.stub.ts +++ b/server/test/fixtures/shared-link.stub.ts @@ -241,7 +241,6 @@ export const sharedLinkStub = { autoStackId: null, rating: 3, }, - tags: [], sharedLinks: [], faces: [], sidecarPath: null, diff --git a/server/test/repositories/asset.repository.mock.ts b/server/test/repositories/asset.repository.mock.ts index 5e09e3e886..36fb298f7f 100644 --- a/server/test/repositories/asset.repository.mock.ts +++ b/server/test/repositories/asset.repository.mock.ts @@ -12,6 +12,7 @@ export const newAssetRepositoryMock = (): Mocked ({ version: '1.123.45', }); +const assetSidecarWriteFactory = (asset: Partial = {}) => ({ + id: newUuid(), + sidecarPath: '/path/to/original-path.jpg.xmp', + originalPath: '/path/to/original-path.jpg.xmp', + tags: [], + ...asset, +}); + export const factory = { activity: activityFactory, apiKey: apiKeyFactory, @@ -225,4 +243,7 @@ export const factory = { user: userFactory, userAdmin: userAdminFactory, versionHistory: versionHistoryFactory, + jobAssets: { + sidecarWrite: assetSidecarWriteFactory, + }, }; From 2c31a11e413d0ce181b6bd84132071b8e1db477f Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Tue, 8 Apr 2025 11:13:46 -0400 Subject: [PATCH 39/52] chore: replace generated enums with actual types (#17463) --- server/src/db.d.ts | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/server/src/db.d.ts b/server/src/db.d.ts index 9539a28031..b56f8caa08 100644 --- a/server/src/db.d.ts +++ b/server/src/db.d.ts @@ -4,7 +4,7 @@ */ import type { ColumnType } from 'kysely'; -import { AssetFileType, AssetType, MemoryType, Permission, SyncEntityType } from 'src/enum'; +import { AssetFileType, AssetStatus, AssetType, MemoryType, Permission, SourceType, SyncEntityType } from 'src/enum'; import { UserTable } from 'src/schema/tables/user.table'; import { OnThisDayData } from 'src/types'; @@ -12,8 +12,6 @@ export type ArrayType = ArrayTypeImpl extends (infer U)[] ? U[] : ArrayTyp export type ArrayTypeImpl = T extends ColumnType ? ColumnType : T[]; -export type AssetsStatusEnum = 'active' | 'deleted' | 'trashed'; - export type Generated = T extends ColumnType ? ColumnType : ColumnType; @@ -31,8 +29,6 @@ export type JsonPrimitive = boolean | number | string | null; export type JsonValue = JsonArray | JsonObject | JsonPrimitive; -export type Sourcetype = 'exif' | 'machine-learning' | 'manual'; - export type Timestamp = ColumnType; export interface Activity { @@ -98,7 +94,7 @@ export interface AssetFaces { imageHeight: Generated; imageWidth: Generated; personId: string | null; - sourceType: Generated; + sourceType: Generated; } export interface AssetFiles { @@ -152,7 +148,7 @@ export interface Assets { ownerId: string; sidecarPath: string | null; stackId: string | null; - status: Generated; + status: Generated; thumbhash: Buffer | null; type: AssetType; updatedAt: Generated; From 15c6506aee43fd620798536d2cfa5fe760399601 Mon Sep 17 00:00:00 2001 From: Daniel Dietzler <36593685+danieldietzler@users.noreply.github.com> Date: Tue, 8 Apr 2025 17:47:44 +0200 Subject: [PATCH 40/52] fix: broken start/end dates on album update (#17467) --- server/src/services/album.service.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/src/services/album.service.ts b/server/src/services/album.service.ts index 994912f2c7..cbe81f1c0d 100644 --- a/server/src/services/album.service.ts +++ b/server/src/services/album.service.ts @@ -140,7 +140,7 @@ export class AlbumService extends BaseService { order: dto.order, }); - return mapAlbumWithoutAssets(updatedAlbum); + return mapAlbumWithoutAssets({ ...updatedAlbum, assets: album.assets }); } async delete(auth: AuthDto, id: string): Promise { From 49be6d7fd84de28672fa5e3cb8cc395aa37159f2 Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Tue, 8 Apr 2025 12:02:05 -0400 Subject: [PATCH 41/52] refactor: more database enums (#17465) --- server/src/db.d.ts | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/server/src/db.d.ts b/server/src/db.d.ts index b56f8caa08..727b0d51e4 100644 --- a/server/src/db.d.ts +++ b/server/src/db.d.ts @@ -4,7 +4,18 @@ */ import type { ColumnType } from 'kysely'; -import { AssetFileType, AssetStatus, AssetType, MemoryType, Permission, SourceType, SyncEntityType } from 'src/enum'; +import { + AlbumUserRole, + AssetFileType, + AssetOrder, + AssetStatus, + AssetType, + MemoryType, + Permission, + SharedLinkType, + SourceType, + SyncEntityType, +} from 'src/enum'; import { UserTable } from 'src/schema/tables/user.table'; import { OnThisDayData } from 'src/types'; @@ -54,7 +65,7 @@ export interface Albums { description: Generated; id: Generated; isActivityEnabled: Generated; - order: Generated; + order: Generated; ownerId: string; updatedAt: Generated; updateId: Generated; @@ -68,7 +79,7 @@ export interface AlbumsAssetsAssets { export interface AlbumsSharedUsersUsers { albumsId: string; - role: Generated; + role: Generated; usersId: string; } @@ -346,7 +357,7 @@ export interface SharedLinks { key: Buffer; password: string | null; showExif: Generated; - type: string; + type: SharedLinkType; userId: string; } From e5ca79dd4449f0ee9276e18bee3cd35a06e90816 Mon Sep 17 00:00:00 2001 From: Alex Date: Tue, 8 Apr 2025 11:04:07 -0500 Subject: [PATCH 42/52] refactor: remove session entity (#17466) * refactor: remove session entity * fix: test * update sql * remote export --- server/src/entities/session.entity.ts | 49 ------------------- server/src/queries/session.repository.sql | 23 ++++----- server/src/repositories/session.repository.ts | 19 ++++++- server/test/fixtures/auth.stub.ts | 6 +-- server/test/fixtures/session.stub.ts | 6 +-- 5 files changed, 35 insertions(+), 68 deletions(-) delete mode 100644 server/src/entities/session.entity.ts diff --git a/server/src/entities/session.entity.ts b/server/src/entities/session.entity.ts deleted file mode 100644 index 45856ff2af..0000000000 --- a/server/src/entities/session.entity.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { ExpressionBuilder } from 'kysely'; -import { DB } from 'src/db'; -import { UserEntity } from 'src/entities/user.entity'; - -export class SessionEntity { - id!: string; - token!: string; - userId!: string; - user!: UserEntity; - createdAt!: Date; - updatedAt!: Date; - updateId!: string; - deviceType!: string; - deviceOS!: string; -} - -const userColumns = [ - 'id', - 'email', - 'createdAt', - 'profileImagePath', - 'isAdmin', - 'shouldChangePassword', - 'deletedAt', - 'oauthId', - 'updatedAt', - 'storageLabel', - 'name', - 'quotaSizeInBytes', - 'quotaUsageInBytes', - 'status', - 'profileChangedAt', -] as const; - -export const withUser = (eb: ExpressionBuilder) => { - return eb - .selectFrom('users') - .select(userColumns) - .select((eb) => - eb - .selectFrom('user_metadata') - .whereRef('users.id', '=', 'user_metadata.userId') - .select((eb) => eb.fn('array_agg', [eb.table('user_metadata')]).as('metadata')) - .as('metadata'), - ) - .whereRef('users.id', '=', 'sessions.userId') - .where('users.deletedAt', 'is', null) - .as('user'); -}; diff --git a/server/src/queries/session.repository.sql b/server/src/queries/session.repository.sql index 3d115615fd..fee3bcbd9d 100644 --- a/server/src/queries/session.repository.sql +++ b/server/src/queries/session.repository.sql @@ -45,20 +45,21 @@ from inner join lateral ( select "id", - "email", - "createdAt", - "profileImagePath", - "isAdmin", - "shouldChangePassword", - "deletedAt", - "oauthId", - "updatedAt", - "storageLabel", "name", + "email", + "profileImagePath", + "profileChangedAt", + "createdAt", + "updatedAt", + "deletedAt", + "isAdmin", + "status", + "oauthId", + "profileImagePath", + "shouldChangePassword", + "storageLabel", "quotaSizeInBytes", "quotaUsageInBytes", - "status", - "profileChangedAt", ( select array_agg("user_metadata") as "metadata" diff --git a/server/src/repositories/session.repository.ts b/server/src/repositories/session.repository.ts index 85ea5f890e..390e732c6b 100644 --- a/server/src/repositories/session.repository.ts +++ b/server/src/repositories/session.repository.ts @@ -1,15 +1,30 @@ import { Injectable } from '@nestjs/common'; -import { Insertable, Kysely, Updateable } from 'kysely'; +import { ExpressionBuilder, Insertable, Kysely, Updateable } from 'kysely'; import { jsonObjectFrom } from 'kysely/helpers/postgres'; import { InjectKysely } from 'nestjs-kysely'; import { columns } from 'src/database'; import { DB, Sessions } from 'src/db'; import { DummyValue, GenerateSql } from 'src/decorators'; -import { withUser } from 'src/entities/session.entity'; import { asUuid } from 'src/utils/database'; export type SessionSearchOptions = { updatedBefore: Date }; +const withUser = (eb: ExpressionBuilder) => { + return eb + .selectFrom('users') + .select(columns.userAdmin) + .select((eb) => + eb + .selectFrom('user_metadata') + .whereRef('users.id', '=', 'user_metadata.userId') + .select((eb) => eb.fn('array_agg', [eb.table('user_metadata')]).as('metadata')) + .as('metadata'), + ) + .whereRef('users.id', '=', 'sessions.userId') + .where('users.deletedAt', 'is', null) + .as('user'); +}; + @Injectable() export class SessionRepository { constructor(@InjectKysely() private db: Kysely) {} diff --git a/server/test/fixtures/auth.stub.ts b/server/test/fixtures/auth.stub.ts index f894314258..4201334b41 100644 --- a/server/test/fixtures/auth.stub.ts +++ b/server/test/fixtures/auth.stub.ts @@ -1,6 +1,6 @@ import { AuthDto } from 'src/dtos/auth.dto'; -import { SessionEntity } from 'src/entities/session.entity'; import { SharedLinkEntity } from 'src/entities/shared-link.entity'; +import { SessionItem } from 'src/types'; const authUser = { admin: { @@ -27,7 +27,7 @@ export const authStub = { user: authUser.user1, session: { id: 'token-id', - } as SessionEntity, + } as SessionItem, }), user2: Object.freeze({ user: { @@ -40,7 +40,7 @@ export const authStub = { }, session: { id: 'token-id', - } as SessionEntity, + } as SessionItem, }), adminSharedLink: Object.freeze({ user: authUser.admin, diff --git a/server/test/fixtures/session.stub.ts b/server/test/fixtures/session.stub.ts index af06237473..93eac28c57 100644 --- a/server/test/fixtures/session.stub.ts +++ b/server/test/fixtures/session.stub.ts @@ -1,8 +1,8 @@ -import { SessionEntity } from 'src/entities/session.entity'; +import { SessionItem } from 'src/types'; import { userStub } from 'test/fixtures/user.stub'; export const sessionStub = { - valid: Object.freeze({ + valid: Object.freeze({ id: 'token-id', token: 'auth_token', userId: userStub.user1.id, @@ -13,7 +13,7 @@ export const sessionStub = { deviceOS: '', updateId: 'uuid-v7', }), - inactive: Object.freeze({ + inactive: Object.freeze({ id: 'not_active', token: 'auth_token', userId: userStub.user1.id, From ac65d46ec6cfa5eb8d5d0614a9645f273d7952a7 Mon Sep 17 00:00:00 2001 From: Gagan Yadav Date: Tue, 8 Apr 2025 21:34:42 +0530 Subject: [PATCH 43/52] fix(mobile): adds support for Internationalized Domain Name (IDN) (#17461) --- mobile/lib/utils/url_helper.dart | 76 +++++++++- .../lib/widgets/forms/login/login_form.dart | 12 +- mobile/pubspec.yaml | 1 + .../test/modules/utils/url_helper_test.dart | 138 ++++++++++++++++++ 4 files changed, 218 insertions(+), 9 deletions(-) create mode 100644 mobile/test/modules/utils/url_helper_test.dart diff --git a/mobile/lib/utils/url_helper.dart b/mobile/lib/utils/url_helper.dart index 6b355e362f..187026b53c 100644 --- a/mobile/lib/utils/url_helper.dart +++ b/mobile/lib/utils/url_helper.dart @@ -1,5 +1,6 @@ import 'package:immich_mobile/domain/models/store.model.dart'; import 'package:immich_mobile/entities/store.entity.dart'; +import 'package:punycode/punycode.dart'; String sanitizeUrl(String url) { // Add schema if none is set @@ -11,13 +12,80 @@ String sanitizeUrl(String url) { } String? getServerUrl() { - final serverUrl = Store.tryGet(StoreKey.serverEndpoint); + final serverUrl = punycodeDecodeUrl(Store.tryGet(StoreKey.serverEndpoint)); final serverUri = serverUrl != null ? Uri.tryParse(serverUrl) : null; if (serverUri == null) { return null; } - return serverUri.hasPort - ? "${serverUri.scheme}://${serverUri.host}:${serverUri.port}" - : "${serverUri.scheme}://${serverUri.host}"; + return Uri.decodeFull( + serverUri.hasPort + ? "${serverUri.scheme}://${serverUri.host}:${serverUri.port}" + : "${serverUri.scheme}://${serverUri.host}", + ); +} + +/// Converts a Unicode URL to its ASCII-compatible encoding (Punycode). +/// +/// This is especially useful for internationalized domain names (IDNs), +/// where parts of the URL (typically the host) contain non-ASCII characters. +/// +/// Example: +/// ```dart +/// final encodedUrl = punycodeEncodeUrl('https://bücher.de'); +/// print(encodedUrl); // Outputs: https://xn--bcher-kva.de +/// ``` +/// +/// Notes: +/// - If the input URL is invalid, an empty string is returned. +/// - Only the host part of the URL is converted to Punycode; the scheme, +/// path, and port remain unchanged. +/// +String punycodeEncodeUrl(String serverUrl) { + final serverUri = Uri.tryParse(serverUrl); + if (serverUri == null || serverUri.host.isEmpty) return ''; + + final encodedHost = Uri.decodeComponent(serverUri.host).split('.').map( + (segment) { + // If segment is already ASCII, then return as it is. + if (segment.runes.every((c) => c < 0x80)) return segment; + return 'xn--${punycodeEncode(segment)}'; + }, + ).join('.'); + + return serverUri.replace(host: encodedHost).toString(); +} + +/// Decodes an ASCII-compatible (Punycode) URL back to its original Unicode representation. +/// +/// This method is useful for converting internationalized domain names (IDNs) +/// that were previously encoded with Punycode back to their human-readable Unicode form. +/// +/// Example: +/// ```dart +/// final decodedUrl = punycodeDecodeUrl('https://xn--bcher-kva.de'); +/// print(decodedUrl); // Outputs: https://bücher.de +/// ``` +/// +/// Notes: +/// - If the input URL is invalid the method returns `null`. +/// - Only the host part of the URL is decoded. The scheme and port (if any) are preserved. +/// - The method assumes that the input URL only contains: scheme, host, port (optional). +/// - Query parameters, fragments, and user info are not handled (by design, as per constraints). +/// +String? punycodeDecodeUrl(String? serverUrl) { + final serverUri = serverUrl != null ? Uri.tryParse(serverUrl) : null; + if (serverUri == null || serverUri.host.isEmpty) return null; + + final decodedHost = serverUri.host.split('.').map( + (segment) { + if (segment.toLowerCase().startsWith('xn--')) { + return punycodeDecode(segment.substring(4)); + } + // If segment is not punycode encoded, then return as it is. + return segment; + }, + ).join('.'); + + return Uri.decodeFull(serverUri.replace(host: decodedHost).toString()); } diff --git a/mobile/lib/widgets/forms/login/login_form.dart b/mobile/lib/widgets/forms/login/login_form.dart index a6da172f0e..ab532987a7 100644 --- a/mobile/lib/widgets/forms/login/login_form.dart +++ b/mobile/lib/widgets/forms/login/login_form.dart @@ -1,4 +1,5 @@ import 'dart:io'; + import 'package:auto_route/auto_route.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; @@ -7,18 +8,18 @@ import 'package:flutter_hooks/flutter_hooks.dart' hide Store; import 'package:fluttertoast/fluttertoast.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; -import 'package:immich_mobile/providers/oauth.provider.dart'; -import 'package:immich_mobile/providers/gallery_permission.provider.dart'; -import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/providers/auth.provider.dart'; import 'package:immich_mobile/providers/backup/backup.provider.dart'; +import 'package:immich_mobile/providers/gallery_permission.provider.dart'; +import 'package:immich_mobile/providers/oauth.provider.dart'; import 'package:immich_mobile/providers/server_info.provider.dart'; +import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/utils/provider_utils.dart'; +import 'package:immich_mobile/utils/url_helper.dart'; import 'package:immich_mobile/utils/version_compatibility.dart'; import 'package:immich_mobile/widgets/common/immich_logo.dart'; import 'package:immich_mobile/widgets/common/immich_title_text.dart'; import 'package:immich_mobile/widgets/common/immich_toast.dart'; -import 'package:immich_mobile/utils/url_helper.dart'; import 'package:immich_mobile/widgets/forms/login/email_input.dart'; import 'package:immich_mobile/widgets/forms/login/loading_icon.dart'; import 'package:immich_mobile/widgets/forms/login/login_button.dart'; @@ -82,7 +83,8 @@ class LoginForm extends HookConsumerWidget { /// Fetch the server login credential and enables oAuth login if necessary /// Returns true if successful, false otherwise Future getServerAuthSettings() async { - final serverUrl = sanitizeUrl(serverEndpointController.text); + final sanitizeServerUrl = sanitizeUrl(serverEndpointController.text); + final serverUrl = punycodeEncodeUrl(sanitizeServerUrl); // Guard empty URL if (serverUrl.isEmpty) { diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index e939c65836..73f60d9337 100644 --- a/mobile/pubspec.yaml +++ b/mobile/pubspec.yaml @@ -51,6 +51,7 @@ dependencies: permission_handler: ^11.4.0 photo_manager: ^3.6.4 photo_manager_image_provider: ^2.2.0 + punycode: ^1.0.0 riverpod_annotation: ^2.6.1 scrollable_positioned_list: ^0.3.8 share_handler: ^0.0.22 diff --git a/mobile/test/modules/utils/url_helper_test.dart b/mobile/test/modules/utils/url_helper_test.dart new file mode 100644 index 0000000000..840ac91f1f --- /dev/null +++ b/mobile/test/modules/utils/url_helper_test.dart @@ -0,0 +1,138 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:immich_mobile/utils/url_helper.dart'; + +void main() { + group('punycodeEncodeUrl', () { + test('should return empty string for invalid URL', () { + expect(punycodeEncodeUrl('not a url'), equals('')); + }); + + test('should handle empty input', () { + expect(punycodeEncodeUrl(''), equals('')); + }); + + test('should return ASCII-only URL unchanged', () { + const url = 'https://example.com'; + expect(punycodeEncodeUrl(url), equals(url)); + }); + + test('should encode single-segment Unicode host', () { + const url = 'https://bücher'; + const expected = 'https://xn--bcher-kva'; + expect(punycodeEncodeUrl(url), equals(expected)); + }); + + test('should encode multi-segment Unicode host', () { + const url = 'https://bücher.de'; + const expected = 'https://xn--bcher-kva.de'; + expect(punycodeEncodeUrl(url), equals(expected)); + }); + + test( + 'should encode multi-segment Unicode host with multiple non-ASCII segments', + () { + const url = 'https://bücher.münchen'; + const expected = 'https://xn--bcher-kva.xn--mnchen-3ya'; + expect(punycodeEncodeUrl(url), equals(expected)); + }); + + test('should handle URL with port', () { + const url = 'https://bücher.de:8080'; + const expected = 'https://xn--bcher-kva.de:8080'; + expect(punycodeEncodeUrl(url), equals(expected)); + }); + + test('should handle URL with path', () { + const url = 'https://bücher.de/path/to/resource'; + const expected = 'https://xn--bcher-kva.de/path/to/resource'; + expect(punycodeEncodeUrl(url), equals(expected)); + }); + + test('should handle URL with port and path', () { + const url = 'https://bücher.de:3000/path'; + const expected = 'https://xn--bcher-kva.de:3000/path'; + expect(punycodeEncodeUrl(url), equals(expected)); + }); + + test('should not encode ASCII segment in multi-segment host', () { + const url = 'https://shop.bücher.de'; + const expected = 'https://shop.xn--bcher-kva.de'; + expect(punycodeEncodeUrl(url), equals(expected)); + }); + + test('should handle host with hyphen in Unicode segment', () { + const url = 'https://bü-cher.de'; + const expected = 'https://xn--b-cher-3ya.de'; + expect(punycodeEncodeUrl(url), equals(expected)); + }); + + test('should handle host with numbers in Unicode segment', () { + const url = 'https://bücher123.de'; + const expected = 'https://xn--bcher123-65a.de'; + expect(punycodeEncodeUrl(url), equals(expected)); + }); + + test('should encode the domain of the original issue poster :)', () { + const url = 'https://фото.большойчлен.рф/'; + const expected = 'https://xn--n1aalg.xn--90ailhbncb6fh7b.xn--p1ai/'; + expect(punycodeEncodeUrl(url), expected); + }); + }); + + group('punycodeDecodeUrl', () { + test('should return null for null input', () { + expect(punycodeDecodeUrl(null), isNull); + }); + + test('should return null for an invalid URL', () { + // "not a url" should fail to parse. + expect(punycodeDecodeUrl('not a url'), isNull); + }); + + test('should return null for a URL with empty host', () { + // "https://" is a valid scheme but with no host. + expect(punycodeDecodeUrl('https://'), isNull); + }); + + test('should return ASCII-only URL unchanged', () { + const url = 'https://example.com'; + expect(punycodeDecodeUrl(url), equals(url)); + }); + + test('should decode a single-segment Punycode domain', () { + const input = 'https://xn--bcher-kva.de'; + const expected = 'https://bücher.de'; + expect(punycodeDecodeUrl(input), equals(expected)); + }); + + test('should decode a multi-segment Punycode domain', () { + const input = 'https://shop.xn--bcher-kva.de'; + const expected = 'https://shop.bücher.de'; + expect(punycodeDecodeUrl(input), equals(expected)); + }); + + test('should decode URL with port', () { + const input = 'https://xn--bcher-kva.de:8080'; + const expected = 'https://bücher.de:8080'; + expect(punycodeDecodeUrl(input), equals(expected)); + }); + + test('should decode domains with uppercase punycode prefix correctly', () { + const input = 'https://XN--BCHER-KVA.de'; + const expected = 'https://bücher.de'; + expect(punycodeDecodeUrl(input), equals(expected)); + }); + + test('should handle mixed segments with no punycode in some parts', () { + const input = 'https://news.xn--bcher-kva.de'; + const expected = 'https://news.bücher.de'; + expect(punycodeDecodeUrl(input), equals(expected)); + }); + + test('should decode the domain of the original issue poster :)', () { + const url = 'https://xn--n1aalg.xn--90ailhbncb6fh7b.xn--p1ai/'; + const expected = 'https://фото.большойчлен.рф/'; + expect(punycodeDecodeUrl(url), expected); + }); + }); +} From 4794eeca885fa310c7694ef656ce68a03501e9c4 Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Tue, 8 Apr 2025 12:40:03 -0400 Subject: [PATCH 44/52] refactor: database types (#17468) --- server/src/database.ts | 28 +++++++++++++++++++++++++- server/src/dtos/memory.dto.ts | 4 ++-- server/src/dtos/tag.dto.ts | 4 ++-- server/src/entities/asset.entity.ts | 4 ++-- server/src/services/api-key.service.ts | 4 ++-- server/src/types.ts | 22 -------------------- server/src/utils/tag.ts | 6 +++--- server/test/fixtures/tag.stub.ts | 6 +++--- server/test/small.factory.ts | 5 +++-- 9 files changed, 44 insertions(+), 39 deletions(-) diff --git a/server/src/database.ts b/server/src/database.ts index f4e49f07ab..5f2d5c5123 100644 --- a/server/src/database.ts +++ b/server/src/database.ts @@ -1,5 +1,6 @@ import { UserMetadataEntity } from 'src/entities/user-metadata.entity'; -import { AssetStatus, AssetType, Permission, UserStatus } from 'src/enum'; +import { AssetStatus, AssetType, MemoryType, Permission, UserStatus } from 'src/enum'; +import { OnThisDayData } from 'src/types'; export type AuthUser = { id: string; @@ -38,6 +39,31 @@ export type ApiKey = { permissions: Permission[]; }; +export type Tag = { + id: string; + value: string; + createdAt: Date; + updatedAt: Date; + color: string | null; + parentId: string | null; +}; + +export type Memory = { + id: string; + createdAt: Date; + updatedAt: Date; + deletedAt: Date | null; + memoryAt: Date; + seenAt: Date | null; + showAt: Date | null; + hideAt: Date | null; + type: MemoryType; + data: OnThisDayData; + ownerId: string; + isSaved: boolean; + assets: Asset[]; +}; + export type User = { id: string; name: string; diff --git a/server/src/dtos/memory.dto.ts b/server/src/dtos/memory.dto.ts index 36f4631ef5..b3054d7a4c 100644 --- a/server/src/dtos/memory.dto.ts +++ b/server/src/dtos/memory.dto.ts @@ -1,11 +1,11 @@ import { ApiProperty } from '@nestjs/swagger'; import { Type } from 'class-transformer'; import { IsEnum, IsInt, IsObject, IsPositive, ValidateNested } from 'class-validator'; +import { Memory } from 'src/database'; import { AssetResponseDto, mapAsset } from 'src/dtos/asset-response.dto'; import { AuthDto } from 'src/dtos/auth.dto'; import { AssetEntity } from 'src/entities/asset.entity'; import { MemoryType } from 'src/enum'; -import { MemoryItem } from 'src/types'; import { Optional, ValidateBoolean, ValidateDate, ValidateUUID } from 'src/validation'; class MemoryBaseDto { @@ -89,7 +89,7 @@ export class MemoryResponseDto { assets!: AssetResponseDto[]; } -export const mapMemory = (entity: MemoryItem, auth: AuthDto): MemoryResponseDto => { +export const mapMemory = (entity: Memory, auth: AuthDto): MemoryResponseDto => { return { id: entity.id, createdAt: entity.createdAt, diff --git a/server/src/dtos/tag.dto.ts b/server/src/dtos/tag.dto.ts index 3c5e74647c..a35801d07e 100644 --- a/server/src/dtos/tag.dto.ts +++ b/server/src/dtos/tag.dto.ts @@ -1,6 +1,6 @@ import { ApiProperty } from '@nestjs/swagger'; import { IsHexColor, IsNotEmpty, IsString } from 'class-validator'; -import { TagItem } from 'src/types'; +import { Tag } from 'src/database'; import { Optional, ValidateHexColor, ValidateUUID } from 'src/validation'; export class TagCreateDto { @@ -51,7 +51,7 @@ export class TagResponseDto { color?: string; } -export function mapTag(entity: TagItem): TagResponseDto { +export function mapTag(entity: Tag): TagResponseDto { return { id: entity.id, parentId: entity.parentId ?? undefined, diff --git a/server/src/entities/asset.entity.ts b/server/src/entities/asset.entity.ts index 50f2d6c5d1..ef27e0db5f 100644 --- a/server/src/entities/asset.entity.ts +++ b/server/src/entities/asset.entity.ts @@ -1,5 +1,6 @@ import { DeduplicateJoinsPlugin, ExpressionBuilder, Kysely, SelectQueryBuilder, sql } from 'kysely'; import { jsonArrayFrom, jsonObjectFrom } from 'kysely/helpers/postgres'; +import { Tag } from 'src/database'; import { DB } from 'src/db'; import { AlbumEntity } from 'src/entities/album.entity'; import { AssetFaceEntity } from 'src/entities/asset-face.entity'; @@ -12,7 +13,6 @@ import { UserEntity } from 'src/entities/user.entity'; import { AssetFileType, AssetStatus, AssetType } from 'src/enum'; import { TimeBucketSize } from 'src/repositories/asset.repository'; import { AssetSearchBuilderOptions } from 'src/repositories/search.repository'; -import { TagItem } from 'src/types'; import { anyUuid, asUuid } from 'src/utils/database'; export const ASSET_CHECKSUM_CONSTRAINT = 'UQ_assets_owner_checksum'; @@ -49,7 +49,7 @@ export class AssetEntity { originalFileName!: string; sidecarPath!: string | null; exifInfo?: ExifEntity; - tags?: TagItem[]; + tags?: Tag[]; sharedLinks!: SharedLinkEntity[]; albums?: AlbumEntity[]; faces!: AssetFaceEntity[]; diff --git a/server/src/services/api-key.service.ts b/server/src/services/api-key.service.ts index 5459b56889..33861d82cd 100644 --- a/server/src/services/api-key.service.ts +++ b/server/src/services/api-key.service.ts @@ -1,9 +1,9 @@ import { BadRequestException, Injectable } from '@nestjs/common'; +import { ApiKey } from 'src/database'; import { APIKeyCreateDto, APIKeyCreateResponseDto, APIKeyResponseDto, APIKeyUpdateDto } from 'src/dtos/api-key.dto'; import { AuthDto } from 'src/dtos/auth.dto'; import { Permission } from 'src/enum'; import { BaseService } from 'src/services/base.service'; -import { ApiKeyItem } from 'src/types'; import { isGranted } from 'src/utils/access'; @Injectable() @@ -58,7 +58,7 @@ export class ApiKeyService extends BaseService { return keys.map((key) => this.map(key)); } - private map(entity: ApiKeyItem): APIKeyResponseDto { + private map(entity: ApiKey): APIKeyResponseDto { return { id: entity.id, name: entity.name, diff --git a/server/src/types.ts b/server/src/types.ts index 6620621da8..fdbcc990e7 100644 --- a/server/src/types.ts +++ b/server/src/types.ts @@ -14,8 +14,6 @@ import { VideoCodec, } from 'src/enum'; import { ActivityRepository } from 'src/repositories/activity.repository'; -import { ApiKeyRepository } from 'src/repositories/api-key.repository'; -import { MemoryRepository } from 'src/repositories/memory.repository'; import { SearchRepository } from 'src/repositories/search.repository'; import { SessionRepository } from 'src/repositories/session.repository'; @@ -24,8 +22,6 @@ export type DeepPartial = T extends object ? { [K in keyof T]?: DeepPartial = Pick; type IActivityRepository = RepositoryInterface; -type IApiKeyRepository = RepositoryInterface; -type IMemoryRepository = RepositoryInterface; type ISearchRepository = RepositoryInterface; type ISessionRepository = RepositoryInterface; @@ -35,26 +31,8 @@ export type ActivityItem = export type SearchPlacesItem = Awaited>[0]; -export type ApiKeyItem = - | Awaited> - | NonNullable>> - | Awaited>[0]; - -export type MemoryItem = - | Awaited> - | Awaited>[0]; - export type SessionItem = Awaited>[0]; -export type TagItem = { - id: string; - value: string; - createdAt: Date; - updatedAt: Date; - color: string | null; - parentId: string | null; -}; - export interface CropOptions { top: number; left: number; diff --git a/server/src/utils/tag.ts b/server/src/utils/tag.ts index b095fcfd85..4e8a86a7f6 100644 --- a/server/src/utils/tag.ts +++ b/server/src/utils/tag.ts @@ -1,15 +1,15 @@ +import { Tag } from 'src/database'; import { TagRepository } from 'src/repositories/tag.repository'; -import { TagItem } from 'src/types'; type UpsertRequest = { userId: string; tags: string[] }; export const upsertTags = async (repository: TagRepository, { userId, tags }: UpsertRequest) => { tags = [...new Set(tags)]; - const results: TagItem[] = []; + const results: Tag[] = []; for (const tag of tags) { const parts = tag.split('/').filter(Boolean); - let parent: TagItem | undefined; + let parent: Tag | undefined; for (const part of parts) { const value = parent ? `${parent.value}/${part}` : part; diff --git a/server/test/fixtures/tag.stub.ts b/server/test/fixtures/tag.stub.ts index 1a19c2a002..7a2cacf126 100644 --- a/server/test/fixtures/tag.stub.ts +++ b/server/test/fixtures/tag.stub.ts @@ -1,7 +1,7 @@ +import { Tag } from 'src/database'; import { TagResponseDto } from 'src/dtos/tag.dto'; -import { TagItem } from 'src/types'; -const parent = Object.freeze({ +const parent = Object.freeze({ id: 'tag-parent', createdAt: new Date('2021-01-01T00:00:00Z'), updatedAt: new Date('2021-01-01T00:00:00Z'), @@ -10,7 +10,7 @@ const parent = Object.freeze({ parentId: null, }); -const child = Object.freeze({ +const child = Object.freeze({ id: 'tag-child', createdAt: new Date('2021-01-01T00:00:00Z'), updatedAt: new Date('2021-01-01T00:00:00Z'), diff --git a/server/test/small.factory.ts b/server/test/small.factory.ts index 3c20eebc2c..ea8c95e375 100644 --- a/server/test/small.factory.ts +++ b/server/test/small.factory.ts @@ -5,6 +5,7 @@ import { AuthApiKey, AuthUser, Library, + Memory, Partner, SidecarWriteAsset, User, @@ -12,7 +13,7 @@ import { } from 'src/database'; import { AuthDto } from 'src/dtos/auth.dto'; import { AssetStatus, AssetType, MemoryType, Permission, UserStatus } from 'src/enum'; -import { ActivityItem, MemoryItem, OnThisDayData } from 'src/types'; +import { ActivityItem, OnThisDayData } from 'src/types'; export const newUuid = () => randomUUID() as string; export const newUuids = () => @@ -196,7 +197,7 @@ const libraryFactory = (library: Partial = {}) => ({ ...library, }); -const memoryFactory = (memory: Partial = {}) => ({ +const memoryFactory = (memory: Partial = {}) => ({ id: newUuid(), createdAt: newDate(), updatedAt: newDate(), From ae8af84101e43c825d4555d9f6cc570f56160d03 Mon Sep 17 00:00:00 2001 From: Alex Date: Tue, 8 Apr 2025 16:07:10 -0500 Subject: [PATCH 45/52] fix: no thumbnail generated for motion assets (#17472) --- server/src/services/metadata.service.spec.ts | 12 ++++-------- server/src/services/metadata.service.ts | 3 ++- 2 files changed, 6 insertions(+), 9 deletions(-) diff --git a/server/src/services/metadata.service.spec.ts b/server/src/services/metadata.service.spec.ts index 874a84e34b..9947d803a7 100644 --- a/server/src/services/metadata.service.spec.ts +++ b/server/src/services/metadata.service.spec.ts @@ -546,7 +546,7 @@ describe(MetadataService.name, () => { id: assetStub.livePhotoWithOriginalFileName.id, livePhotoVideoId: fileStub.livePhotoMotion.uuid, }); - expect(mocks.asset.update).toHaveBeenCalledTimes(2); + expect(mocks.asset.update).toHaveBeenCalledTimes(3); }); it('should extract the EmbeddedVideo tag from Samsung JPEG motion photos', async () => { @@ -598,7 +598,7 @@ describe(MetadataService.name, () => { id: assetStub.livePhotoWithOriginalFileName.id, livePhotoVideoId: fileStub.livePhotoMotion.uuid, }); - expect(mocks.asset.update).toHaveBeenCalledTimes(2); + expect(mocks.asset.update).toHaveBeenCalledTimes(3); }); it('should extract the motion photo video from the XMP directory entry ', async () => { @@ -650,7 +650,7 @@ describe(MetadataService.name, () => { id: assetStub.livePhotoWithOriginalFileName.id, livePhotoVideoId: fileStub.livePhotoMotion.uuid, }); - expect(mocks.asset.update).toHaveBeenCalledTimes(2); + expect(mocks.asset.update).toHaveBeenCalledTimes(3); }); it('should delete old motion photo video assets if they do not match what is extracted', async () => { @@ -673,10 +673,6 @@ describe(MetadataService.name, () => { name: JobName.ASSET_DELETION, data: { id: assetStub.livePhotoWithOriginalFileName.livePhotoVideoId, deleteOnDisk: true }, }); - expect(mocks.job.queue).toHaveBeenNthCalledWith(2, { - name: JobName.METADATA_EXTRACTION, - data: { id: 'random-uuid' }, - }); }); it('should not create a new motion photo video asset if the hash of the extracted video matches an existing asset', async () => { @@ -723,7 +719,7 @@ describe(MetadataService.name, () => { id: assetStub.livePhotoStillAsset.id, livePhotoVideoId: assetStub.livePhotoMotionAsset.id, }); - expect(mocks.asset.update).toHaveBeenCalledTimes(3); + expect(mocks.asset.update).toHaveBeenCalledTimes(4); }); it('should not update storage usage if motion photo is external', async () => { diff --git a/server/src/services/metadata.service.ts b/server/src/services/metadata.service.ts index 824bf36c75..72f7270844 100644 --- a/server/src/services/metadata.service.ts +++ b/server/src/services/metadata.service.ts @@ -550,7 +550,8 @@ export class MetadataService extends BaseService { this.storageCore.ensureFolders(motionAsset.originalPath); await this.storageRepository.createFile(motionAsset.originalPath, video); this.logger.log(`Wrote motion photo video to ${motionAsset.originalPath}`); - await this.jobRepository.queue({ name: JobName.METADATA_EXTRACTION, data: { id: motionAsset.id } }); + + await this.handleMetadataExtraction({ id: motionAsset.id }); } this.logger.debug(`Finished motion photo video extraction for asset ${asset.id}: ${asset.originalPath}`); From cf2c0260a646caf1fdc830788804f3404feed6eb Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Wed, 9 Apr 2025 08:35:20 -0400 Subject: [PATCH 46/52] refactor: activity item (#17470) * refactor: activity item * fix query * qualified columns --------- Co-authored-by: mertalev <101130780+mertalev@users.noreply.github.com> --- server/src/database.ts | 14 ++++++ server/src/dtos/activity.dto.ts | 4 +- server/src/queries/activity.repository.sql | 44 +++++++++++++---- .../src/repositories/activity.repository.ts | 48 +++++++++---------- server/src/services/activity.service.ts | 4 +- server/src/types.ts | 6 --- server/test/small.factory.ts | 5 +- 7 files changed, 81 insertions(+), 44 deletions(-) diff --git a/server/src/database.ts b/server/src/database.ts index 5f2d5c5123..f8af8438a0 100644 --- a/server/src/database.ts +++ b/server/src/database.ts @@ -30,6 +30,19 @@ export type AuthApiKey = { permissions: Permission[]; }; +export type Activity = { + id: string; + createdAt: Date; + updatedAt: Date; + albumId: string; + userId: string; + user: User; + assetId: string | null; + comment: string | null; + isLiked: boolean; + updateId: string; +}; + export type ApiKey = { id: string; name: string; @@ -173,6 +186,7 @@ export const columns = { 'shared_links.password', ], user: userColumns, + userWithPrefix: ['users.id', 'users.name', 'users.email', 'users.profileImagePath', 'users.profileChangedAt'], userAdmin: [ ...userColumns, 'createdAt', diff --git a/server/src/dtos/activity.dto.ts b/server/src/dtos/activity.dto.ts index 9a0307f46b..98216147b7 100644 --- a/server/src/dtos/activity.dto.ts +++ b/server/src/dtos/activity.dto.ts @@ -1,8 +1,8 @@ import { ApiProperty } from '@nestjs/swagger'; import { IsEnum, IsNotEmpty, IsString, ValidateIf } from 'class-validator'; +import { Activity } from 'src/database'; import { mapUser, UserResponseDto } from 'src/dtos/user.dto'; import { UserEntity } from 'src/entities/user.entity'; -import { ActivityItem } from 'src/types'; import { Optional, ValidateUUID } from 'src/validation'; export enum ReactionType { @@ -68,7 +68,7 @@ export class ActivityCreateDto extends ActivityDto { comment?: string; } -export const mapActivity = (activity: ActivityItem): ActivityResponseDto => { +export const mapActivity = (activity: Activity): ActivityResponseDto => { return { id: activity.id, assetId: activity.assetId, diff --git a/server/src/queries/activity.repository.sql b/server/src/queries/activity.repository.sql index 0ddb91c692..3d4d667de6 100644 --- a/server/src/queries/activity.repository.sql +++ b/server/src/queries/activity.repository.sql @@ -3,6 +3,38 @@ -- ActivityRepository.search select "activity".*, + to_json("user") as "user" +from + "activity" + inner join "users" on "users"."id" = "activity"."userId" + and "users"."deletedAt" is null + inner join lateral ( + select + "users"."id", + "users"."name", + "users"."email", + "users"."profileImagePath", + "users"."profileChangedAt" + from + ( + select + 1 + ) as "dummy" + ) as "user" on true + left join "assets" on "assets"."id" = "activity"."assetId" + and "assets"."deletedAt" is null +where + "activity"."albumId" = $1 +order by + "activity"."createdAt" asc + +-- ActivityRepository.create +insert into + "activity" ("albumId", "userId") +values + ($1, $2) +returning + *, ( select to_json(obj) @@ -18,17 +50,13 @@ select "users" where "users"."id" = "activity"."userId" - and "users"."deletedAt" is null ) as obj ) as "user" -from - "activity" - left join "assets" on "assets"."id" = "activity"."assetId" - and "assets"."deletedAt" is null + +-- ActivityRepository.delete +delete from "activity" where - "activity"."albumId" = $1 -order by - "activity"."createdAt" asc + "id" = $1::uuid -- ActivityRepository.getStatistics select diff --git a/server/src/repositories/activity.repository.ts b/server/src/repositories/activity.repository.ts index 48def82f49..e266022b05 100644 --- a/server/src/repositories/activity.repository.ts +++ b/server/src/repositories/activity.repository.ts @@ -1,5 +1,5 @@ import { Injectable } from '@nestjs/common'; -import { ExpressionBuilder, Insertable, Kysely } from 'kysely'; +import { Insertable, Kysely, NotNull, sql } from 'kysely'; import { jsonObjectFrom } from 'kysely/helpers/postgres'; import { InjectKysely } from 'nestjs-kysely'; import { columns } from 'src/database'; @@ -14,16 +14,6 @@ export interface ActivitySearch { isLiked?: boolean; } -const withUser = (eb: ExpressionBuilder) => { - return jsonObjectFrom( - eb - .selectFrom('users') - .select(columns.user) - .whereRef('users.id', '=', 'activity.userId') - .where('users.deletedAt', 'is', null), - ).as('user'); -}; - @Injectable() export class ActivityRepository { constructor(@InjectKysely() private db: Kysely) {} @@ -35,7 +25,16 @@ export class ActivityRepository { return this.db .selectFrom('activity') .selectAll('activity') - .select(withUser) + .innerJoin('users', (join) => join.onRef('users.id', '=', 'activity.userId').on('users.deletedAt', 'is', null)) + .innerJoinLateral( + (eb) => + eb + .selectFrom(sql`(select 1)`.as('dummy')) + .select(columns.userWithPrefix) + .as('user'), + (join) => join.onTrue(), + ) + .select((eb) => eb.fn.toJson('user').as('user')) .leftJoin('assets', (join) => join.onRef('assets.id', '=', 'activity.assetId').on('assets.deletedAt', 'is', null)) .$if(!!userId, (qb) => qb.where('activity.userId', '=', userId!)) .$if(assetId === null, (qb) => qb.where('assetId', 'is', null)) @@ -46,10 +45,22 @@ export class ActivityRepository { .execute(); } + @GenerateSql({ params: [{ albumId: DummyValue.UUID, userId: DummyValue.UUID }] }) async create(activity: Insertable) { - return this.save(activity); + return this.db + .insertInto('activity') + .values(activity) + .returningAll() + .returning((eb) => + jsonObjectFrom(eb.selectFrom('users').whereRef('users.id', '=', 'activity.userId').select(columns.user)).as( + 'user', + ), + ) + .$narrowType<{ user: NotNull }>() + .executeTakeFirstOrThrow(); } + @GenerateSql({ params: [DummyValue.UUID] }) async delete(id: string) { await this.db.deleteFrom('activity').where('id', '=', asUuid(id)).execute(); } @@ -72,15 +83,4 @@ export class ActivityRepository { return count as number; } - - private async save(entity: Insertable) { - const { id } = await this.db.insertInto('activity').values(entity).returning('id').executeTakeFirstOrThrow(); - - return this.db - .selectFrom('activity') - .selectAll('activity') - .select(withUser) - .where('activity.id', '=', asUuid(id)) - .executeTakeFirstOrThrow(); - } } diff --git a/server/src/services/activity.service.ts b/server/src/services/activity.service.ts index feb1074fb2..6e3c3d7083 100644 --- a/server/src/services/activity.service.ts +++ b/server/src/services/activity.service.ts @@ -1,4 +1,5 @@ import { Injectable } from '@nestjs/common'; +import { Activity } from 'src/database'; import { ActivityCreateDto, ActivityDto, @@ -13,7 +14,6 @@ import { import { AuthDto } from 'src/dtos/auth.dto'; import { Permission } from 'src/enum'; import { BaseService } from 'src/services/base.service'; -import { ActivityItem } from 'src/types'; @Injectable() export class ActivityService extends BaseService { @@ -43,7 +43,7 @@ export class ActivityService extends BaseService { albumId: dto.albumId, }; - let activity: ActivityItem | undefined; + let activity: Activity | undefined; let duplicate = false; if (dto.type === ReactionType.LIKE) { diff --git a/server/src/types.ts b/server/src/types.ts index fdbcc990e7..3e74a39730 100644 --- a/server/src/types.ts +++ b/server/src/types.ts @@ -13,7 +13,6 @@ import { TranscodeTarget, VideoCodec, } from 'src/enum'; -import { ActivityRepository } from 'src/repositories/activity.repository'; import { SearchRepository } from 'src/repositories/search.repository'; import { SessionRepository } from 'src/repositories/session.repository'; @@ -21,14 +20,9 @@ export type DeepPartial = T extends object ? { [K in keyof T]?: DeepPartial = Pick; -type IActivityRepository = RepositoryInterface; type ISearchRepository = RepositoryInterface; type ISessionRepository = RepositoryInterface; -export type ActivityItem = - | Awaited> - | Awaited>[0]; - export type SearchPlacesItem = Awaited>[0]; export type SessionItem = Awaited>[0]; diff --git a/server/test/small.factory.ts b/server/test/small.factory.ts index ea8c95e375..d2a7ba6e8f 100644 --- a/server/test/small.factory.ts +++ b/server/test/small.factory.ts @@ -1,5 +1,6 @@ import { randomUUID } from 'node:crypto'; import { + Activity, ApiKey, Asset, AuthApiKey, @@ -13,7 +14,7 @@ import { } from 'src/database'; import { AuthDto } from 'src/dtos/auth.dto'; import { AssetStatus, AssetType, MemoryType, Permission, UserStatus } from 'src/enum'; -import { ActivityItem, OnThisDayData } from 'src/types'; +import { OnThisDayData } from 'src/types'; export const newUuid = () => randomUUID() as string; export const newUuids = () => @@ -154,7 +155,7 @@ const assetFactory = (asset: Partial = {}) => ({ ...asset, }); -const activityFactory = (activity: Partial = {}) => { +const activityFactory = (activity: Partial = {}) => { const userId = activity.userId || newUuid(); return { id: newUuid(), From 04b03f29245fd76ac11c1ae33e6428ca29e3a012 Mon Sep 17 00:00:00 2001 From: Gagan Yadav Date: Wed, 9 Apr 2025 19:06:27 +0530 Subject: [PATCH 47/52] =?UTF-8?q?fix(mobile):=20asset=20grid=20will=20infi?= =?UTF-8?q?nitely=20scroll=20on=20iOS=20when=20select=20and=E2=80=A6=20(#1?= =?UTF-8?q?7469)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit fix(mobile): asset grid will infinitely scroll on iOS when select and drag --- .../asset_grid/immich_asset_grid_view.dart | 24 +++++++++++++------ 1 file changed, 17 insertions(+), 7 deletions(-) diff --git a/mobile/lib/widgets/asset_grid/immich_asset_grid_view.dart b/mobile/lib/widgets/asset_grid/immich_asset_grid_view.dart index 1c0f9a2b56..c6e85418ca 100644 --- a/mobile/lib/widgets/asset_grid/immich_asset_grid_view.dart +++ b/mobile/lib/widgets/asset_grid/immich_asset_grid_view.dart @@ -8,25 +8,25 @@ import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; import 'package:flutter/services.dart'; +import 'package:fluttertoast/fluttertoast.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/entities/asset.entity.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/collection_extensions.dart'; import 'package:immich_mobile/extensions/theme_extensions.dart'; import 'package:immich_mobile/providers/asset_viewer/current_asset.provider.dart'; import 'package:immich_mobile/providers/asset_viewer/is_motion_video_playing.provider.dart'; import 'package:immich_mobile/providers/asset_viewer/scroll_notifier.provider.dart'; +import 'package:immich_mobile/providers/asset_viewer/scroll_to_date_notifier.provider.dart'; import 'package:immich_mobile/providers/asset_viewer/show_controls.provider.dart'; +import 'package:immich_mobile/providers/haptic_feedback.provider.dart'; +import 'package:immich_mobile/providers/tab.provider.dart'; +import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/widgets/asset_grid/asset_drag_region.dart'; +import 'package:immich_mobile/widgets/asset_grid/control_bottom_app_bar.dart'; import 'package:immich_mobile/widgets/asset_grid/thumbnail_image.dart'; import 'package:immich_mobile/widgets/asset_grid/thumbnail_placeholder.dart'; import 'package:immich_mobile/widgets/common/immich_toast.dart'; -import 'package:fluttertoast/fluttertoast.dart'; -import 'package:immich_mobile/widgets/asset_grid/control_bottom_app_bar.dart'; -import 'package:immich_mobile/routing/router.dart'; -import 'package:immich_mobile/entities/asset.entity.dart'; -import 'package:immich_mobile/providers/asset_viewer/scroll_to_date_notifier.provider.dart'; -import 'package:immich_mobile/providers/haptic_feedback.provider.dart'; -import 'package:immich_mobile/providers/tab.provider.dart'; import 'package:scrollable_positioned_list/scrollable_positioned_list.dart'; import 'asset_grid_data_structure.dart'; @@ -107,6 +107,8 @@ class ImmichAssetGridViewState extends ConsumerState { final Set _draggedAssets = HashSet(equals: (a, b) => a.id == b.id, hashCode: (a) => a.id); + ScrollPhysics? _scrollPhysics; + Set _getSelectedAssets() { return Set.from(_selectedAssets); } @@ -265,6 +267,7 @@ class ImmichAssetGridViewState extends ConsumerState { ), itemBuilder: _itemBuilder, itemPositionsListener: _itemPositionsListener, + physics: _scrollPhysics, itemScrollController: _itemScrollController, scrollOffsetController: _scrollOffsetController, itemCount: widget.renderList.elements.length + @@ -439,6 +442,7 @@ class ImmichAssetGridViewState extends ConsumerState { void _setDragStartIndex(AssetIndex index) { setState(() { + _scrollPhysics = const ClampingScrollPhysics(); _dragAnchorAssetIndex = index.rowIndex; _dragAnchorSectionIndex = index.sectionIndex; _dragging = true; @@ -446,6 +450,12 @@ class ImmichAssetGridViewState extends ConsumerState { } void _stopDrag() { + WidgetsBinding.instance.addPostFrameCallback((_) { + // Update the physics post frame to prevent sudden change in physics on iOS. + setState(() { + _scrollPhysics = null; + }); + }); setState(() { _dragging = false; _draggedAssets.clear(); From 8943ec23bac69f52b0e8c03effa6953265899aeb Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Wed, 9 Apr 2025 10:24:38 -0400 Subject: [PATCH 48/52] refactor: more database types (#17490) --- server/src/database.ts | 22 ++++++ server/src/dtos/search.dto.ts | 4 +- server/src/dtos/session.dto.ts | 4 +- server/src/queries/session.repository.sql | 37 +-------- server/src/repositories/session.repository.ts | 21 +---- server/src/services/auth.service.spec.ts | 78 +++++++++++++------ server/src/services/auth.service.ts | 4 +- server/src/services/session.service.spec.ts | 45 +++++------ server/src/types.ts | 9 --- server/test/fixtures/auth.stub.ts | 6 +- server/test/fixtures/session.stub.ts | 27 ------- server/test/small.factory.ts | 14 +++- 12 files changed, 123 insertions(+), 148 deletions(-) delete mode 100644 server/test/fixtures/session.stub.ts diff --git a/server/src/database.ts b/server/src/database.ts index f8af8438a0..33a877102f 100644 --- a/server/src/database.ts +++ b/server/src/database.ts @@ -163,6 +163,28 @@ export type Partner = { inTimeline: boolean; }; +export type Place = { + admin1Code: string | null; + admin1Name: string | null; + admin2Code: string | null; + admin2Name: string | null; + alternateNames: string | null; + countryCode: string; + id: number; + latitude: number; + longitude: number; + modificationDate: Date; + name: string; +}; + +export type Session = { + id: string; + createdAt: Date; + updatedAt: Date; + deviceOS: string; + deviceType: string; +}; + const userColumns = ['id', 'name', 'email', 'profileImagePath', 'profileChangedAt'] as const; export const columns = { diff --git a/server/src/dtos/search.dto.ts b/server/src/dtos/search.dto.ts index 67ab059e11..a7633dce78 100644 --- a/server/src/dtos/search.dto.ts +++ b/server/src/dtos/search.dto.ts @@ -1,11 +1,11 @@ import { ApiProperty } from '@nestjs/swagger'; import { Type } from 'class-transformer'; import { IsEnum, IsInt, IsNotEmpty, IsString, Max, Min } from 'class-validator'; +import { Place } from 'src/database'; import { PropertyLifecycle } from 'src/decorators'; import { AlbumResponseDto } from 'src/dtos/album.dto'; import { AssetResponseDto } from 'src/dtos/asset-response.dto'; import { AssetOrder, AssetType } from 'src/enum'; -import { SearchPlacesItem } from 'src/types'; import { Optional, ValidateBoolean, ValidateDate, ValidateUUID } from 'src/validation'; class BaseSearchDto { @@ -226,7 +226,7 @@ export class PlacesResponseDto { admin2name?: string; } -export function mapPlaces(place: SearchPlacesItem): PlacesResponseDto { +export function mapPlaces(place: Place): PlacesResponseDto { return { name: place.name, latitude: place.latitude, diff --git a/server/src/dtos/session.dto.ts b/server/src/dtos/session.dto.ts index dab1bf62b5..b54264a5b4 100644 --- a/server/src/dtos/session.dto.ts +++ b/server/src/dtos/session.dto.ts @@ -1,4 +1,4 @@ -import { SessionItem } from 'src/types'; +import { Session } from 'src/database'; export class SessionResponseDto { id!: string; @@ -9,7 +9,7 @@ export class SessionResponseDto { deviceOS!: string; } -export const mapSession = (entity: SessionItem, currentId?: string): SessionResponseDto => ({ +export const mapSession = (entity: Session, currentId?: string): SessionResponseDto => ({ id: entity.id, createdAt: entity.createdAt.toISOString(), updatedAt: entity.updatedAt.toISOString(), diff --git a/server/src/queries/session.repository.sql b/server/src/queries/session.repository.sql index fee3bcbd9d..eea2356897 100644 --- a/server/src/queries/session.repository.sql +++ b/server/src/queries/session.repository.sql @@ -38,42 +38,11 @@ where -- SessionRepository.getByUserId select - "sessions".*, - to_json("user") as "user" + "sessions".* from "sessions" - inner join lateral ( - select - "id", - "name", - "email", - "profileImagePath", - "profileChangedAt", - "createdAt", - "updatedAt", - "deletedAt", - "isAdmin", - "status", - "oauthId", - "profileImagePath", - "shouldChangePassword", - "storageLabel", - "quotaSizeInBytes", - "quotaUsageInBytes", - ( - select - array_agg("user_metadata") as "metadata" - from - "user_metadata" - where - "users"."id" = "user_metadata"."userId" - ) as "metadata" - from - "users" - where - "users"."id" = "sessions"."userId" - and "users"."deletedAt" is null - ) as "user" on true + inner join "users" on "users"."id" = "sessions"."userId" + and "users"."deletedAt" is null where "sessions"."userId" = $1 order by diff --git a/server/src/repositories/session.repository.ts b/server/src/repositories/session.repository.ts index 390e732c6b..742807dc9c 100644 --- a/server/src/repositories/session.repository.ts +++ b/server/src/repositories/session.repository.ts @@ -1,5 +1,5 @@ import { Injectable } from '@nestjs/common'; -import { ExpressionBuilder, Insertable, Kysely, Updateable } from 'kysely'; +import { Insertable, Kysely, Updateable } from 'kysely'; import { jsonObjectFrom } from 'kysely/helpers/postgres'; import { InjectKysely } from 'nestjs-kysely'; import { columns } from 'src/database'; @@ -9,22 +9,6 @@ import { asUuid } from 'src/utils/database'; export type SessionSearchOptions = { updatedBefore: Date }; -const withUser = (eb: ExpressionBuilder) => { - return eb - .selectFrom('users') - .select(columns.userAdmin) - .select((eb) => - eb - .selectFrom('user_metadata') - .whereRef('users.id', '=', 'user_metadata.userId') - .select((eb) => eb.fn('array_agg', [eb.table('user_metadata')]).as('metadata')) - .as('metadata'), - ) - .whereRef('users.id', '=', 'sessions.userId') - .where('users.deletedAt', 'is', null) - .as('user'); -}; - @Injectable() export class SessionRepository { constructor(@InjectKysely() private db: Kysely) {} @@ -60,9 +44,8 @@ export class SessionRepository { getByUserId(userId: string) { return this.db .selectFrom('sessions') - .innerJoinLateral(withUser, (join) => join.onTrue()) + .innerJoin('users', (join) => join.onRef('users.id', '=', 'sessions.userId').on('users.deletedAt', 'is', null)) .selectAll('sessions') - .select((eb) => eb.fn.toJson('user').as('user')) .where('sessions.userId', '=', userId) .orderBy('sessions.updatedAt', 'desc') .orderBy('sessions.createdAt', 'desc') diff --git a/server/src/services/auth.service.spec.ts b/server/src/services/auth.service.spec.ts index b1bd3332bf..0c5ad3099d 100644 --- a/server/src/services/auth.service.spec.ts +++ b/server/src/services/auth.service.spec.ts @@ -1,10 +1,10 @@ import { BadRequestException, ForbiddenException, UnauthorizedException } from '@nestjs/common'; +import { DateTime } from 'luxon'; import { AuthDto, SignUpDto } from 'src/dtos/auth.dto'; import { UserMetadataEntity } from 'src/entities/user-metadata.entity'; import { UserEntity } from 'src/entities/user.entity'; import { AuthType, Permission } from 'src/enum'; import { AuthService } from 'src/services/auth.service'; -import { sessionStub } from 'test/fixtures/session.stub'; import { sharedLinkStub } from 'test/fixtures/shared-link.stub'; import { systemConfigStub } from 'test/fixtures/system-config.stub'; import { userStub } from 'test/fixtures/user.stub'; @@ -97,17 +97,19 @@ describe('AuthService', () => { }); it('should successfully log the user in', async () => { - mocks.user.getByEmail.mockResolvedValue(userStub.user1); - mocks.session.create.mockResolvedValue(sessionStub.valid); + const user = { ...factory.user(), password: 'immich_password' } as UserEntity; + const session = factory.session(); + mocks.user.getByEmail.mockResolvedValue(user); + mocks.session.create.mockResolvedValue(session); await expect(sut.login(fixtures.login, loginDetails)).resolves.toEqual({ accessToken: 'cmFuZG9tLWJ5dGVz', - userId: 'user-id', - userEmail: 'immich@test.com', - name: 'immich_name', - profileImagePath: '', - isAdmin: false, - shouldChangePassword: false, + userId: user.id, + userEmail: user.email, + name: user.name, + profileImagePath: user.profileImagePath, + isAdmin: user.isAdmin, + shouldChangePassword: user.shouldChangePassword, }); expect(mocks.user.getByEmail).toHaveBeenCalledTimes(1); @@ -256,8 +258,14 @@ describe('AuthService', () => { }); it('should validate using authorization header', async () => { - mocks.user.get.mockResolvedValue(userStub.user1); - mocks.session.getByToken.mockResolvedValue(sessionStub.valid as any); + const session = factory.session(); + const sessionWithToken = { + id: session.id, + updatedAt: session.updatedAt, + user: factory.authUser(), + }; + + mocks.session.getByToken.mockResolvedValue(sessionWithToken); await expect( sut.authenticate({ @@ -266,8 +274,8 @@ describe('AuthService', () => { metadata: { adminRoute: false, sharedLinkRoute: false, uri: 'test' }, }), ).resolves.toEqual({ - user: userStub.user1, - session: sessionStub.valid, + user: sessionWithToken.user, + session: { id: session.id }, }); }); }); @@ -371,7 +379,14 @@ describe('AuthService', () => { }); it('should return an auth dto', async () => { - mocks.session.getByToken.mockResolvedValue(sessionStub.valid as any); + const session = factory.session(); + const sessionWithToken = { + id: session.id, + updatedAt: session.updatedAt, + user: factory.authUser(), + }; + + mocks.session.getByToken.mockResolvedValue(sessionWithToken); await expect( sut.authenticate({ @@ -380,13 +395,20 @@ describe('AuthService', () => { metadata: { adminRoute: false, sharedLinkRoute: false, uri: 'test' }, }), ).resolves.toEqual({ - user: userStub.user1, - session: sessionStub.valid, + user: sessionWithToken.user, + session: { id: session.id }, }); }); it('should throw if admin route and not an admin', async () => { - mocks.session.getByToken.mockResolvedValue(sessionStub.valid as any); + const session = factory.session(); + const sessionWithToken = { + id: session.id, + updatedAt: session.updatedAt, + user: factory.authUser(), + }; + + mocks.session.getByToken.mockResolvedValue(sessionWithToken); await expect( sut.authenticate({ @@ -398,8 +420,15 @@ describe('AuthService', () => { }); it('should update when access time exceeds an hour', async () => { - mocks.session.getByToken.mockResolvedValue(sessionStub.inactive as any); - mocks.session.update.mockResolvedValue(sessionStub.valid); + const session = factory.session({ updatedAt: DateTime.now().minus({ hours: 2 }).toJSDate() }); + const sessionWithToken = { + id: session.id, + updatedAt: session.updatedAt, + user: factory.authUser(), + }; + + mocks.session.getByToken.mockResolvedValue(sessionWithToken); + mocks.session.update.mockResolvedValue(session); await expect( sut.authenticate({ @@ -408,7 +437,8 @@ describe('AuthService', () => { metadata: { adminRoute: false, sharedLinkRoute: false, uri: 'test' }, }), ).resolves.toBeDefined(); - expect(mocks.session.update.mock.calls[0][1]).toMatchObject({ id: 'not_active', updatedAt: expect.any(Date) }); + + expect(mocks.session.update).toHaveBeenCalled(); }); }); @@ -506,7 +536,7 @@ describe('AuthService', () => { mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthEnabled); mocks.user.getByEmail.mockResolvedValue(userStub.user1); mocks.user.update.mockResolvedValue(userStub.user1); - mocks.session.create.mockResolvedValue(sessionStub.valid); + mocks.session.create.mockResolvedValue(factory.session()); await expect(sut.callback({ url: 'http://immich/auth/login?code=abc123' }, loginDetails)).resolves.toEqual( oauthResponse, @@ -535,7 +565,7 @@ describe('AuthService', () => { mocks.user.getByEmail.mockResolvedValue(void 0); mocks.user.getAdmin.mockResolvedValue(userStub.user1); mocks.user.create.mockResolvedValue(userStub.user1); - mocks.session.create.mockResolvedValue(sessionStub.valid); + mocks.session.create.mockResolvedValue(factory.session()); await expect(sut.callback({ url: 'http://immich/auth/login?code=abc123' }, loginDetails)).resolves.toEqual( oauthResponse, @@ -550,7 +580,7 @@ describe('AuthService', () => { mocks.user.getByEmail.mockResolvedValue(void 0); mocks.user.getAdmin.mockResolvedValue(userStub.user1); mocks.user.create.mockResolvedValue(userStub.user1); - mocks.session.create.mockResolvedValue(sessionStub.valid); + mocks.session.create.mockResolvedValue(factory.session()); mocks.oauth.getProfile.mockResolvedValue({ sub, email: undefined }); await expect(sut.callback({ url: 'http://immich/auth/login?code=abc123' }, loginDetails)).rejects.toBeInstanceOf( @@ -572,7 +602,7 @@ describe('AuthService', () => { it(`should use the mobile redirect override for a url of ${url}`, async () => { mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithMobileOverride); mocks.user.getByOAuthId.mockResolvedValue(userStub.user1); - mocks.session.create.mockResolvedValue(sessionStub.valid); + mocks.session.create.mockResolvedValue(factory.session()); await sut.callback({ url }, loginDetails); diff --git a/server/src/services/auth.service.ts b/server/src/services/auth.service.ts index 235f20e705..4110427b0c 100644 --- a/server/src/services/auth.service.ts +++ b/server/src/services/auth.service.ts @@ -338,7 +338,9 @@ export class AuthService extends BaseService { return { user: session.user, - session, + session: { + id: session.id, + }, }; } diff --git a/server/src/services/session.service.spec.ts b/server/src/services/session.service.spec.ts index 3d1b09a39d..c3ab5619be 100644 --- a/server/src/services/session.service.spec.ts +++ b/server/src/services/session.service.spec.ts @@ -1,7 +1,7 @@ import { JobStatus } from 'src/enum'; import { SessionService } from 'src/services/session.service'; import { authStub } from 'test/fixtures/auth.stub'; -import { sessionStub } from 'test/fixtures/session.stub'; +import { factory } from 'test/small.factory'; import { newTestService, ServiceMocks } from 'test/utils'; describe('SessionService', () => { @@ -45,40 +45,35 @@ describe('SessionService', () => { describe('getAll', () => { it('should get the devices', async () => { - mocks.session.getByUserId.mockResolvedValue([sessionStub.valid as any, sessionStub.inactive]); - await expect(sut.getAll(authStub.user1)).resolves.toEqual([ - { - createdAt: '2021-01-01T00:00:00.000Z', - current: true, - deviceOS: '', - deviceType: '', - id: 'token-id', - updatedAt: expect.any(String), - }, - { - createdAt: '2021-01-01T00:00:00.000Z', - current: false, - deviceOS: 'Android', - deviceType: 'Mobile', - id: 'not_active', - updatedAt: expect.any(String), - }, + const currentSession = factory.session(); + const otherSession = factory.session(); + const auth = factory.auth({ session: currentSession }); + + mocks.session.getByUserId.mockResolvedValue([currentSession, otherSession]); + + await expect(sut.getAll(auth)).resolves.toEqual([ + expect.objectContaining({ current: true, id: currentSession.id }), + expect.objectContaining({ current: false, id: otherSession.id }), ]); - expect(mocks.session.getByUserId).toHaveBeenCalledWith(authStub.user1.user.id); + expect(mocks.session.getByUserId).toHaveBeenCalledWith(auth.user.id); }); }); describe('logoutDevices', () => { it('should logout all devices', async () => { - mocks.session.getByUserId.mockResolvedValue([sessionStub.inactive, sessionStub.valid] as any[]); + const currentSession = factory.session(); + const otherSession = factory.session(); + const auth = factory.auth({ session: currentSession }); + + mocks.session.getByUserId.mockResolvedValue([currentSession, otherSession]); mocks.session.delete.mockResolvedValue(); - await sut.deleteAll(authStub.user1); + await sut.deleteAll(auth); - expect(mocks.session.getByUserId).toHaveBeenCalledWith(authStub.user1.user.id); - expect(mocks.session.delete).toHaveBeenCalledWith('not_active'); - expect(mocks.session.delete).not.toHaveBeenCalledWith('token-id'); + expect(mocks.session.getByUserId).toHaveBeenCalledWith(auth.user.id); + expect(mocks.session.delete).toHaveBeenCalledWith(otherSession.id); + expect(mocks.session.delete).not.toHaveBeenCalledWith(currentSession.id); }); }); diff --git a/server/src/types.ts b/server/src/types.ts index 3e74a39730..68207c4b9e 100644 --- a/server/src/types.ts +++ b/server/src/types.ts @@ -13,20 +13,11 @@ import { TranscodeTarget, VideoCodec, } from 'src/enum'; -import { SearchRepository } from 'src/repositories/search.repository'; -import { SessionRepository } from 'src/repositories/session.repository'; export type DeepPartial = T extends object ? { [K in keyof T]?: DeepPartial } : T; export type RepositoryInterface = Pick; -type ISearchRepository = RepositoryInterface; -type ISessionRepository = RepositoryInterface; - -export type SearchPlacesItem = Awaited>[0]; - -export type SessionItem = Awaited>[0]; - export interface CropOptions { top: number; left: number; diff --git a/server/test/fixtures/auth.stub.ts b/server/test/fixtures/auth.stub.ts index 4201334b41..f5fbe07b53 100644 --- a/server/test/fixtures/auth.stub.ts +++ b/server/test/fixtures/auth.stub.ts @@ -1,6 +1,6 @@ +import { Session } from 'src/database'; import { AuthDto } from 'src/dtos/auth.dto'; import { SharedLinkEntity } from 'src/entities/shared-link.entity'; -import { SessionItem } from 'src/types'; const authUser = { admin: { @@ -27,7 +27,7 @@ export const authStub = { user: authUser.user1, session: { id: 'token-id', - } as SessionItem, + } as Session, }), user2: Object.freeze({ user: { @@ -40,7 +40,7 @@ export const authStub = { }, session: { id: 'token-id', - } as SessionItem, + } as Session, }), adminSharedLink: Object.freeze({ user: authUser.admin, diff --git a/server/test/fixtures/session.stub.ts b/server/test/fixtures/session.stub.ts deleted file mode 100644 index 93eac28c57..0000000000 --- a/server/test/fixtures/session.stub.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { SessionItem } from 'src/types'; -import { userStub } from 'test/fixtures/user.stub'; - -export const sessionStub = { - valid: Object.freeze({ - id: 'token-id', - token: 'auth_token', - userId: userStub.user1.id, - user: userStub.user1, - createdAt: new Date('2021-01-01'), - updatedAt: new Date(), - deviceType: '', - deviceOS: '', - updateId: 'uuid-v7', - }), - inactive: Object.freeze({ - id: 'not_active', - token: 'auth_token', - userId: userStub.user1.id, - user: userStub.user1, - createdAt: new Date('2021-01-01'), - updatedAt: new Date('2021-01-01'), - deviceType: 'Mobile', - deviceOS: 'Android', - updateId: 'uuid-v7', - }), -}; diff --git a/server/test/small.factory.ts b/server/test/small.factory.ts index d2a7ba6e8f..35984cabbd 100644 --- a/server/test/small.factory.ts +++ b/server/test/small.factory.ts @@ -8,6 +8,7 @@ import { Library, Memory, Partner, + Session, SidecarWriteAsset, User, UserAdmin, @@ -31,7 +32,11 @@ export const newEmbedding = () => { return '[' + embedding + ']'; }; -const authFactory = ({ apiKey, ...user }: Partial & { apiKey?: Partial } = {}) => { +const authFactory = ({ + apiKey, + session, + ...user +}: Partial & { apiKey?: Partial; session?: { id: string } } = {}) => { const auth: AuthDto = { user: authUserFactory(user), }; @@ -40,6 +45,10 @@ const authFactory = ({ apiKey, ...user }: Partial & { apiKey?: Partial auth.apiKey = authApiKeyFactory(apiKey); } + if (session) { + auth.session = { id: session.id }; + } + return auth; }; @@ -76,7 +85,7 @@ const partnerFactory = (partner: Partial = {}) => { }; }; -const sessionFactory = () => ({ +const sessionFactory = (session: Partial = {}) => ({ id: newUuid(), createdAt: newDate(), updatedAt: newDate(), @@ -85,6 +94,7 @@ const sessionFactory = () => ({ deviceType: 'mobile', token: 'abc123', userId: newUuid(), + ...session, }); const stackFactory = () => ({ From 3e372500b0726774455fdd8718166b65b8d3d7d8 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 9 Apr 2025 14:47:29 +0000 Subject: [PATCH 49/52] fix(deps): update typescript-projects (#17456) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Co-authored-by: Daniel Dietzler --- cli/package-lock.json | 24 +- e2e/package-lock.json | 30 +- server/package-lock.json | 805 ++++++++---------- server/package.json | 2 +- web/package-lock.json | 57 +- .../components/photos-page/asset-grid.svelte | 5 +- 6 files changed, 405 insertions(+), 518 deletions(-) diff --git a/cli/package-lock.json b/cli/package-lock.json index ef6788e7d6..abab734dd8 100644 --- a/cli/package-lock.json +++ b/cli/package-lock.json @@ -1012,9 +1012,9 @@ } }, "node_modules/@pkgr/core": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/@pkgr/core/-/core-0.2.0.tgz", - "integrity": "sha512-vsJDAkYR6qCPu+ioGScGiMYR7LvZYIXh/dlQeviqoTWNCVfKTLYD/LkNWH4Mxsv2a5vpIRc77FN5DnmK1eBggQ==", + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/@pkgr/core/-/core-0.2.1.tgz", + "integrity": "sha512-VzgHzGblFmUeBmmrk55zPyrQIArQN4vujc9shWytaPdB3P7qhi0cpaiKIr7tlCmFv2lYUwnLospIqjL9ZSAhhg==", "dev": true, "license": "MIT", "engines": { @@ -2297,14 +2297,14 @@ } }, "node_modules/eslint-plugin-prettier": { - "version": "5.2.5", - "resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-5.2.5.tgz", - "integrity": "sha512-IKKP8R87pJyMl7WWamLgPkloB16dagPIdd2FjBDbyRYPKo93wS/NbCOPh6gH+ieNLC+XZrhJt/kWj0PS/DFdmg==", + "version": "5.2.6", + "resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-5.2.6.tgz", + "integrity": "sha512-mUcf7QG2Tjk7H055Jk0lGBjbgDnfrvqjhXh9t2xLMSCjZVcw9Rb1V6sVNXO0th3jgeO7zllWPTNRil3JW94TnQ==", "dev": true, "license": "MIT", "dependencies": { "prettier-linter-helpers": "^1.0.0", - "synckit": "^0.10.2" + "synckit": "^0.11.0" }, "engines": { "node": "^14.18.0 || >=16.0.0" @@ -3884,20 +3884,20 @@ } }, "node_modules/synckit": { - "version": "0.10.3", - "resolved": "https://registry.npmjs.org/synckit/-/synckit-0.10.3.tgz", - "integrity": "sha512-R1urvuyiTaWfeCggqEvpDJwAlDVdsT9NM+IP//Tk2x7qHCkSvBk/fwFgw/TLAHzZlrAnnazMcRw0ZD8HlYFTEQ==", + "version": "0.11.3", + "resolved": "https://registry.npmjs.org/synckit/-/synckit-0.11.3.tgz", + "integrity": "sha512-szhWDqNNI9etJUvbZ1/cx1StnZx8yMmFxme48SwR4dty4ioSY50KEZlpv0qAfgc1fpRzuh9hBXEzoCpJ779dLg==", "dev": true, "license": "MIT", "dependencies": { - "@pkgr/core": "^0.2.0", + "@pkgr/core": "^0.2.1", "tslib": "^2.8.1" }, "engines": { "node": "^14.18.0 || >=16.0.0" }, "funding": { - "url": "https://opencollective.com/unts" + "url": "https://opencollective.com/synckit" } }, "node_modules/test-exclude": { diff --git a/e2e/package-lock.json b/e2e/package-lock.json index c403b2560a..757dc2eaaa 100644 --- a/e2e/package-lock.json +++ b/e2e/package-lock.json @@ -1088,9 +1088,9 @@ } }, "node_modules/@pkgr/core": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/@pkgr/core/-/core-0.2.0.tgz", - "integrity": "sha512-vsJDAkYR6qCPu+ioGScGiMYR7LvZYIXh/dlQeviqoTWNCVfKTLYD/LkNWH4Mxsv2a5vpIRc77FN5DnmK1eBggQ==", + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/@pkgr/core/-/core-0.2.1.tgz", + "integrity": "sha512-VzgHzGblFmUeBmmrk55zPyrQIArQN4vujc9shWytaPdB3P7qhi0cpaiKIr7tlCmFv2lYUwnLospIqjL9ZSAhhg==", "dev": true, "license": "MIT", "engines": { @@ -1566,9 +1566,9 @@ } }, "node_modules/@types/luxon": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/@types/luxon/-/luxon-3.6.0.tgz", - "integrity": "sha512-RtEj20xRyG7cRp142MkQpV3GRF8Wo2MtDkKLz65MQs7rM1Lh8bz+HtfPXCCJEYpnDFu6VwAq/Iv2Ikyp9Jw/hw==", + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/@types/luxon/-/luxon-3.6.2.tgz", + "integrity": "sha512-R/BdP7OxEMc44l2Ex5lSXHoIXTB2JLNa3y2QISIbr58U/YcsffyQrYW//hZSdrfxrjRZj3GcUoxMPGdO8gSYuw==", "dev": true, "license": "MIT" }, @@ -3094,14 +3094,14 @@ } }, "node_modules/eslint-plugin-prettier": { - "version": "5.2.5", - "resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-5.2.5.tgz", - "integrity": "sha512-IKKP8R87pJyMl7WWamLgPkloB16dagPIdd2FjBDbyRYPKo93wS/NbCOPh6gH+ieNLC+XZrhJt/kWj0PS/DFdmg==", + "version": "5.2.6", + "resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-5.2.6.tgz", + "integrity": "sha512-mUcf7QG2Tjk7H055Jk0lGBjbgDnfrvqjhXh9t2xLMSCjZVcw9Rb1V6sVNXO0th3jgeO7zllWPTNRil3JW94TnQ==", "dev": true, "license": "MIT", "dependencies": { "prettier-linter-helpers": "^1.0.0", - "synckit": "^0.10.2" + "synckit": "^0.11.0" }, "engines": { "node": "^14.18.0 || >=16.0.0" @@ -6039,20 +6039,20 @@ } }, "node_modules/synckit": { - "version": "0.10.3", - "resolved": "https://registry.npmjs.org/synckit/-/synckit-0.10.3.tgz", - "integrity": "sha512-R1urvuyiTaWfeCggqEvpDJwAlDVdsT9NM+IP//Tk2x7qHCkSvBk/fwFgw/TLAHzZlrAnnazMcRw0ZD8HlYFTEQ==", + "version": "0.11.3", + "resolved": "https://registry.npmjs.org/synckit/-/synckit-0.11.3.tgz", + "integrity": "sha512-szhWDqNNI9etJUvbZ1/cx1StnZx8yMmFxme48SwR4dty4ioSY50KEZlpv0qAfgc1fpRzuh9hBXEzoCpJ779dLg==", "dev": true, "license": "MIT", "dependencies": { - "@pkgr/core": "^0.2.0", + "@pkgr/core": "^0.2.1", "tslib": "^2.8.1" }, "engines": { "node": "^14.18.0 || >=16.0.0" }, "funding": { - "url": "https://opencollective.com/unts" + "url": "https://opencollective.com/synckit" } }, "node_modules/tar": { diff --git a/server/package-lock.json b/server/package-lock.json index 6c5bc4adf5..de6e0c7065 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -23,7 +23,7 @@ "@opentelemetry/context-async-hooks": "^2.0.0", "@opentelemetry/exporter-prometheus": "^0.200.0", "@opentelemetry/sdk-node": "^0.200.0", - "@react-email/components": "^0.0.34", + "@react-email/components": "^0.0.35", "@socket.io/redis-adapter": "^8.3.0", "archiver": "^7.0.0", "async-lock": "^1.4.0", @@ -152,13 +152,13 @@ } }, "node_modules/@angular-devkit/schematics": { - "version": "19.1.8", - "resolved": "https://registry.npmjs.org/@angular-devkit/schematics/-/schematics-19.1.8.tgz", - "integrity": "sha512-2JGUMD3zjfY8G4RYpypm2/1YEO+O4DtFycUvptIpsBYyULgnEbJ3tlp2oRiXI2vp9tC8IyWqa/swlA8DTI6ZYQ==", + "version": "19.2.6", + "resolved": "https://registry.npmjs.org/@angular-devkit/schematics/-/schematics-19.2.6.tgz", + "integrity": "sha512-YTAxNnT++5eflx19OUHmOWu597/TbTel+QARiZCv1xQw99+X8DCKKOUXtqBRd53CAHlREDI33Rn/JLY3NYgMLQ==", "dev": true, "license": "MIT", "dependencies": { - "@angular-devkit/core": "19.1.8", + "@angular-devkit/core": "19.2.6", "jsonc-parser": "3.3.1", "magic-string": "0.30.17", "ora": "5.4.1", @@ -171,15 +171,15 @@ } }, "node_modules/@angular-devkit/schematics-cli": { - "version": "19.1.8", - "resolved": "https://registry.npmjs.org/@angular-devkit/schematics-cli/-/schematics-cli-19.1.8.tgz", - "integrity": "sha512-sHblN9EuiJgKwJVYc+nhpU+GlVkAJHJc7lBR8YSoaugNGcCMkWn4f7rJnJDywL/CEOHBICnyWZKfTCMsMyg1Cw==", + "version": "19.2.6", + "resolved": "https://registry.npmjs.org/@angular-devkit/schematics-cli/-/schematics-cli-19.2.6.tgz", + "integrity": "sha512-OCLVk1YbTWfaZwpKPnd+9A34eMAZIRjntdugGvfw21ok9dUA8gICGDhfYATSfnU8/AbVQMTPK5sgG0xhUEm3UA==", "dev": true, "license": "MIT", "dependencies": { - "@angular-devkit/core": "19.1.8", - "@angular-devkit/schematics": "19.1.8", - "@inquirer/prompts": "7.2.1", + "@angular-devkit/core": "19.2.6", + "@angular-devkit/schematics": "19.2.6", + "@inquirer/prompts": "7.3.2", "ansi-colors": "4.1.3", "symbol-observable": "4.0.0", "yargs-parser": "21.1.1" @@ -194,9 +194,9 @@ } }, "node_modules/@angular-devkit/schematics-cli/node_modules/@angular-devkit/core": { - "version": "19.1.8", - "resolved": "https://registry.npmjs.org/@angular-devkit/core/-/core-19.1.8.tgz", - "integrity": "sha512-j1zHKvOsGwu5YwAZGuzi835R9vcW7PkfxmSRIJeVl+vawgk31K3zFb4UPH8AY/NPWYqXIAnwpka3HC1+JrWLWA==", + "version": "19.2.6", + "resolved": "https://registry.npmjs.org/@angular-devkit/core/-/core-19.2.6.tgz", + "integrity": "sha512-WFgiYhrDMq83UNaGRAneIM7CYYdBozD+yYA9BjoU8AgBLKtrvn6S8ZcjKAk5heoHtY/u8pEb0mwDTz9gxFmJZQ==", "dev": true, "license": "MIT", "dependencies": { @@ -221,31 +221,6 @@ } } }, - "node_modules/@angular-devkit/schematics-cli/node_modules/@inquirer/prompts": { - "version": "7.2.1", - "resolved": "https://registry.npmjs.org/@inquirer/prompts/-/prompts-7.2.1.tgz", - "integrity": "sha512-v2JSGri6/HXSfoGIwuKEn8sNCQK6nsB2BNpy2lSX6QH9bsECrMv93QHnj5+f+1ZWpF/VNioIV2B/PDox8EvGuQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@inquirer/checkbox": "^4.0.4", - "@inquirer/confirm": "^5.1.1", - "@inquirer/editor": "^4.2.1", - "@inquirer/expand": "^4.0.4", - "@inquirer/input": "^4.1.1", - "@inquirer/number": "^3.0.4", - "@inquirer/password": "^4.0.4", - "@inquirer/rawlist": "^4.0.4", - "@inquirer/search": "^3.0.4", - "@inquirer/select": "^4.0.4" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@types/node": ">=18" - } - }, "node_modules/@angular-devkit/schematics-cli/node_modules/ajv": { "version": "8.17.1", "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", @@ -325,9 +300,9 @@ } }, "node_modules/@angular-devkit/schematics/node_modules/@angular-devkit/core": { - "version": "19.1.8", - "resolved": "https://registry.npmjs.org/@angular-devkit/core/-/core-19.1.8.tgz", - "integrity": "sha512-j1zHKvOsGwu5YwAZGuzi835R9vcW7PkfxmSRIJeVl+vawgk31K3zFb4UPH8AY/NPWYqXIAnwpka3HC1+JrWLWA==", + "version": "19.2.6", + "resolved": "https://registry.npmjs.org/@angular-devkit/core/-/core-19.2.6.tgz", + "integrity": "sha512-WFgiYhrDMq83UNaGRAneIM7CYYdBozD+yYA9BjoU8AgBLKtrvn6S8ZcjKAk5heoHtY/u8pEb0mwDTz9gxFmJZQ==", "dev": true, "license": "MIT", "dependencies": { @@ -716,9 +691,9 @@ } }, "node_modules/@emnapi/runtime": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.3.1.tgz", - "integrity": "sha512-kEBmG8KyqtxJZv+ygbEim+KCGtIq1fC22Ms3S4ziXmYKm8uyoLX0MHONVKwp+9opg390VaKRNt4a7A9NwmpNhw==", + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.4.0.tgz", + "integrity": "sha512-64WYIf4UYcdLnbKn/umDlNjQDSS8AgZrI/R9+x5ilkUVFxXcA1Ebl+gQLc/6mERA4407Xof0R7wEyEuj091CVw==", "license": "MIT", "optional": true, "dependencies": { @@ -1761,15 +1736,15 @@ } }, "node_modules/@inquirer/checkbox": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/@inquirer/checkbox/-/checkbox-4.1.2.tgz", - "integrity": "sha512-PL9ixC5YsPXzXhAZFUPmkXGxfgjkdfZdPEPPmt4kFwQ4LBMDG9n/nHXYRGGZSKZJs+d1sGKWgS2GiPzVRKUdtQ==", + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@inquirer/checkbox/-/checkbox-4.1.5.tgz", + "integrity": "sha512-swPczVU+at65xa5uPfNP9u3qx/alNwiaykiI/ExpsmMSQW55trmZcwhYWzw/7fj+n6Q8z1eENvR7vFfq9oPSAQ==", "dev": true, "license": "MIT", "dependencies": { - "@inquirer/core": "^10.1.7", - "@inquirer/figures": "^1.0.10", - "@inquirer/type": "^3.0.4", + "@inquirer/core": "^10.1.10", + "@inquirer/figures": "^1.0.11", + "@inquirer/type": "^3.0.6", "ansi-escapes": "^4.3.2", "yoctocolors-cjs": "^2.1.2" }, @@ -1786,14 +1761,14 @@ } }, "node_modules/@inquirer/confirm": { - "version": "5.1.6", - "resolved": "https://registry.npmjs.org/@inquirer/confirm/-/confirm-5.1.6.tgz", - "integrity": "sha512-6ZXYK3M1XmaVBZX6FCfChgtponnL0R6I7k8Nu+kaoNkT828FVZTcca1MqmWQipaW2oNREQl5AaPCUOOCVNdRMw==", + "version": "5.1.9", + "resolved": "https://registry.npmjs.org/@inquirer/confirm/-/confirm-5.1.9.tgz", + "integrity": "sha512-NgQCnHqFTjF7Ys2fsqK2WtnA8X1kHyInyG+nMIuHowVTIgIuS10T4AznI/PvbqSpJqjCUqNBlKGh1v3bwLFL4w==", "dev": true, "license": "MIT", "dependencies": { - "@inquirer/core": "^10.1.7", - "@inquirer/type": "^3.0.4" + "@inquirer/core": "^10.1.10", + "@inquirer/type": "^3.0.6" }, "engines": { "node": ">=18" @@ -1808,14 +1783,14 @@ } }, "node_modules/@inquirer/core": { - "version": "10.1.7", - "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-10.1.7.tgz", - "integrity": "sha512-AA9CQhlrt6ZgiSy6qoAigiA1izOa751ugX6ioSjqgJ+/Gd+tEN/TORk5sUYNjXuHWfW0r1n/a6ak4u/NqHHrtA==", + "version": "10.1.10", + "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-10.1.10.tgz", + "integrity": "sha512-roDaKeY1PYY0aCqhRmXihrHjoSW2A00pV3Ke5fTpMCkzcGF64R8e0lw3dK+eLEHwS4vB5RnW1wuQmvzoRul8Mw==", "dev": true, "license": "MIT", "dependencies": { - "@inquirer/figures": "^1.0.10", - "@inquirer/type": "^3.0.4", + "@inquirer/figures": "^1.0.11", + "@inquirer/type": "^3.0.6", "ansi-escapes": "^4.3.2", "cli-width": "^4.1.0", "mute-stream": "^2.0.0", @@ -1836,14 +1811,14 @@ } }, "node_modules/@inquirer/editor": { - "version": "4.2.7", - "resolved": "https://registry.npmjs.org/@inquirer/editor/-/editor-4.2.7.tgz", - "integrity": "sha512-gktCSQtnSZHaBytkJKMKEuswSk2cDBuXX5rxGFv306mwHfBPjg5UAldw9zWGoEyvA9KpRDkeM4jfrx0rXn0GyA==", + "version": "4.2.10", + "resolved": "https://registry.npmjs.org/@inquirer/editor/-/editor-4.2.10.tgz", + "integrity": "sha512-5GVWJ+qeI6BzR6TIInLP9SXhWCEcvgFQYmcRG6d6RIlhFjM5TyG18paTGBgRYyEouvCmzeco47x9zX9tQEofkw==", "dev": true, "license": "MIT", "dependencies": { - "@inquirer/core": "^10.1.7", - "@inquirer/type": "^3.0.4", + "@inquirer/core": "^10.1.10", + "@inquirer/type": "^3.0.6", "external-editor": "^3.1.0" }, "engines": { @@ -1859,14 +1834,14 @@ } }, "node_modules/@inquirer/expand": { - "version": "4.0.9", - "resolved": "https://registry.npmjs.org/@inquirer/expand/-/expand-4.0.9.tgz", - "integrity": "sha512-Xxt6nhomWTAmuSX61kVgglLjMEFGa+7+F6UUtdEUeg7fg4r9vaFttUUKrtkViYYrQBA5Ia1tkOJj2koP9BuLig==", + "version": "4.0.12", + "resolved": "https://registry.npmjs.org/@inquirer/expand/-/expand-4.0.12.tgz", + "integrity": "sha512-jV8QoZE1fC0vPe6TnsOfig+qwu7Iza1pkXoUJ3SroRagrt2hxiL+RbM432YAihNR7m7XnU0HWl/WQ35RIGmXHw==", "dev": true, "license": "MIT", "dependencies": { - "@inquirer/core": "^10.1.7", - "@inquirer/type": "^3.0.4", + "@inquirer/core": "^10.1.10", + "@inquirer/type": "^3.0.6", "yoctocolors-cjs": "^2.1.2" }, "engines": { @@ -1882,9 +1857,9 @@ } }, "node_modules/@inquirer/figures": { - "version": "1.0.10", - "resolved": "https://registry.npmjs.org/@inquirer/figures/-/figures-1.0.10.tgz", - "integrity": "sha512-Ey6176gZmeqZuY/W/nZiUyvmb1/qInjcpiZjXWi6nON+nxJpD1bxtSoBxNliGISae32n6OwbY+TSXPZ1CfS4bw==", + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@inquirer/figures/-/figures-1.0.11.tgz", + "integrity": "sha512-eOg92lvrn/aRUqbxRyvpEWnrvRuTYRifixHkYVpJiygTgVSBIHDqLh0SrMQXkafvULg3ck11V7xvR+zcgvpHFw==", "dev": true, "license": "MIT", "engines": { @@ -1892,14 +1867,14 @@ } }, "node_modules/@inquirer/input": { - "version": "4.1.6", - "resolved": "https://registry.npmjs.org/@inquirer/input/-/input-4.1.6.tgz", - "integrity": "sha512-1f5AIsZuVjPT4ecA8AwaxDFNHny/tSershP/cTvTDxLdiIGTeILNcKozB0LaYt6mojJLUbOYhpIxicaYf7UKIQ==", + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@inquirer/input/-/input-4.1.9.tgz", + "integrity": "sha512-mshNG24Ij5KqsQtOZMgj5TwEjIf+F2HOESk6bjMwGWgcH5UBe8UoljwzNFHqdMbGYbgAf6v2wU/X9CAdKJzgOA==", "dev": true, "license": "MIT", "dependencies": { - "@inquirer/core": "^10.1.7", - "@inquirer/type": "^3.0.4" + "@inquirer/core": "^10.1.10", + "@inquirer/type": "^3.0.6" }, "engines": { "node": ">=18" @@ -1914,14 +1889,14 @@ } }, "node_modules/@inquirer/number": { - "version": "3.0.9", - "resolved": "https://registry.npmjs.org/@inquirer/number/-/number-3.0.9.tgz", - "integrity": "sha512-iN2xZvH3tyIYXLXBvlVh0npk1q/aVuKXZo5hj+K3W3D4ngAEq/DkLpofRzx6oebTUhBvOgryZ+rMV0yImKnG3w==", + "version": "3.0.12", + "resolved": "https://registry.npmjs.org/@inquirer/number/-/number-3.0.12.tgz", + "integrity": "sha512-7HRFHxbPCA4e4jMxTQglHJwP+v/kpFsCf2szzfBHy98Wlc3L08HL76UDiA87TOdX5fwj2HMOLWqRWv9Pnn+Z5Q==", "dev": true, "license": "MIT", "dependencies": { - "@inquirer/core": "^10.1.7", - "@inquirer/type": "^3.0.4" + "@inquirer/core": "^10.1.10", + "@inquirer/type": "^3.0.6" }, "engines": { "node": ">=18" @@ -1936,14 +1911,14 @@ } }, "node_modules/@inquirer/password": { - "version": "4.0.9", - "resolved": "https://registry.npmjs.org/@inquirer/password/-/password-4.0.9.tgz", - "integrity": "sha512-xBEoOw1XKb0rIN208YU7wM7oJEHhIYkfG7LpTJAEW913GZeaoQerzf5U/LSHI45EVvjAdgNXmXgH51cUXKZcJQ==", + "version": "4.0.12", + "resolved": "https://registry.npmjs.org/@inquirer/password/-/password-4.0.12.tgz", + "integrity": "sha512-FlOB0zvuELPEbnBYiPaOdJIaDzb2PmJ7ghi/SVwIHDDSQ2K4opGBkF+5kXOg6ucrtSUQdLhVVY5tycH0j0l+0g==", "dev": true, "license": "MIT", "dependencies": { - "@inquirer/core": "^10.1.7", - "@inquirer/type": "^3.0.4", + "@inquirer/core": "^10.1.10", + "@inquirer/type": "^3.0.6", "ansi-escapes": "^4.3.2" }, "engines": { @@ -1989,14 +1964,14 @@ } }, "node_modules/@inquirer/rawlist": { - "version": "4.0.9", - "resolved": "https://registry.npmjs.org/@inquirer/rawlist/-/rawlist-4.0.9.tgz", - "integrity": "sha512-+5t6ebehKqgoxV8fXwE49HkSF2Rc9ijNiVGEQZwvbMI61/Q5RcD+jWD6Gs1tKdz5lkI8GRBL31iO0HjGK1bv+A==", + "version": "4.0.12", + "resolved": "https://registry.npmjs.org/@inquirer/rawlist/-/rawlist-4.0.12.tgz", + "integrity": "sha512-wNPJZy8Oc7RyGISPxp9/MpTOqX8lr0r+lCCWm7hQra+MDtYRgINv1hxw7R+vKP71Bu/3LszabxOodfV/uTfsaA==", "dev": true, "license": "MIT", "dependencies": { - "@inquirer/core": "^10.1.7", - "@inquirer/type": "^3.0.4", + "@inquirer/core": "^10.1.10", + "@inquirer/type": "^3.0.6", "yoctocolors-cjs": "^2.1.2" }, "engines": { @@ -2012,15 +1987,15 @@ } }, "node_modules/@inquirer/search": { - "version": "3.0.9", - "resolved": "https://registry.npmjs.org/@inquirer/search/-/search-3.0.9.tgz", - "integrity": "sha512-DWmKztkYo9CvldGBaRMr0ETUHgR86zE6sPDVOHsqz4ISe9o1LuiWfgJk+2r75acFclA93J/lqzhT0dTjCzHuoA==", + "version": "3.0.12", + "resolved": "https://registry.npmjs.org/@inquirer/search/-/search-3.0.12.tgz", + "integrity": "sha512-H/kDJA3kNlnNIjB8YsaXoQI0Qccgf0Na14K1h8ExWhNmUg2E941dyFPrZeugihEa9AZNW5NdsD/NcvUME83OPQ==", "dev": true, "license": "MIT", "dependencies": { - "@inquirer/core": "^10.1.7", - "@inquirer/figures": "^1.0.10", - "@inquirer/type": "^3.0.4", + "@inquirer/core": "^10.1.10", + "@inquirer/figures": "^1.0.11", + "@inquirer/type": "^3.0.6", "yoctocolors-cjs": "^2.1.2" }, "engines": { @@ -2036,15 +2011,15 @@ } }, "node_modules/@inquirer/select": { - "version": "4.0.9", - "resolved": "https://registry.npmjs.org/@inquirer/select/-/select-4.0.9.tgz", - "integrity": "sha512-BpJyJe7Dkhv2kz7yG7bPSbJLQuu/rqyNlF1CfiiFeFwouegfH+zh13KDyt6+d9DwucKo7hqM3wKLLyJxZMO+Xg==", + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@inquirer/select/-/select-4.1.1.tgz", + "integrity": "sha512-IUXzzTKVdiVNMA+2yUvPxWsSgOG4kfX93jOM4Zb5FgujeInotv5SPIJVeXQ+fO4xu7tW8VowFhdG5JRmmCyQ1Q==", "dev": true, "license": "MIT", "dependencies": { - "@inquirer/core": "^10.1.7", - "@inquirer/figures": "^1.0.10", - "@inquirer/type": "^3.0.4", + "@inquirer/core": "^10.1.10", + "@inquirer/figures": "^1.0.11", + "@inquirer/type": "^3.0.6", "ansi-escapes": "^4.3.2", "yoctocolors-cjs": "^2.1.2" }, @@ -2061,9 +2036,9 @@ } }, "node_modules/@inquirer/type": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/@inquirer/type/-/type-3.0.4.tgz", - "integrity": "sha512-2MNFrDY8jkFYc9Il9DgLsHhMzuHnOYM1+CUYVWbzu9oT0hC7V7EcYvdCKeoll/Fcci04A+ERZ9wcc7cQ8lTkIA==", + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@inquirer/type/-/type-3.0.6.tgz", + "integrity": "sha512-/mKVCtVpyBu3IDarv0G+59KC4stsD5mDsGpYh+GKs1NZT88Jh52+cuoA1AtLk2Q0r/quNl+1cSUyLRHBFeD0XA==", "dev": true, "license": "MIT", "engines": { @@ -2411,22 +2386,22 @@ } }, "node_modules/@nestjs/cli": { - "version": "11.0.5", - "resolved": "https://registry.npmjs.org/@nestjs/cli/-/cli-11.0.5.tgz", - "integrity": "sha512-ab/d8Ple+dMSQ4pC7RSNjhntpT8gFQQE8y/F/ilaplp7zPGpuxbayRtYbsA/wc1UkJHORDckrqFc8Jh8mrTq2A==", + "version": "11.0.6", + "resolved": "https://registry.npmjs.org/@nestjs/cli/-/cli-11.0.6.tgz", + "integrity": "sha512-Xco8pTdWHCpTXPTYMkUGAE+C7JXvAv38oVUaQeL81o7UOAi39w8p456r+IjONN/7ekjzakWnqepDzuTtH5Xk5w==", "dev": true, "license": "MIT", "dependencies": { - "@angular-devkit/core": "19.1.8", - "@angular-devkit/schematics": "19.1.8", - "@angular-devkit/schematics-cli": "19.1.8", - "@inquirer/prompts": "7.3.2", + "@angular-devkit/core": "19.2.6", + "@angular-devkit/schematics": "19.2.6", + "@angular-devkit/schematics-cli": "19.2.6", + "@inquirer/prompts": "7.4.1", "@nestjs/schematics": "^11.0.1", - "ansis": "3.16.0", + "ansis": "3.17.0", "chokidar": "4.0.3", "cli-table3": "0.6.5", "commander": "4.1.1", - "fork-ts-checker-webpack-plugin": "9.0.2", + "fork-ts-checker-webpack-plugin": "9.1.0", "glob": "11.0.1", "node-emoji": "1.11.0", "ora": "5.4.1", @@ -2457,9 +2432,9 @@ } }, "node_modules/@nestjs/cli/node_modules/@angular-devkit/core": { - "version": "19.1.8", - "resolved": "https://registry.npmjs.org/@angular-devkit/core/-/core-19.1.8.tgz", - "integrity": "sha512-j1zHKvOsGwu5YwAZGuzi835R9vcW7PkfxmSRIJeVl+vawgk31K3zFb4UPH8AY/NPWYqXIAnwpka3HC1+JrWLWA==", + "version": "19.2.6", + "resolved": "https://registry.npmjs.org/@angular-devkit/core/-/core-19.2.6.tgz", + "integrity": "sha512-WFgiYhrDMq83UNaGRAneIM7CYYdBozD+yYA9BjoU8AgBLKtrvn6S8ZcjKAk5heoHtY/u8pEb0mwDTz9gxFmJZQ==", "dev": true, "license": "MIT", "dependencies": { @@ -2484,6 +2459,36 @@ } } }, + "node_modules/@nestjs/cli/node_modules/@inquirer/prompts": { + "version": "7.4.1", + "resolved": "https://registry.npmjs.org/@inquirer/prompts/-/prompts-7.4.1.tgz", + "integrity": "sha512-UlmM5FVOZF0gpoe1PT/jN4vk8JmpIWBlMvTL8M+hlvPmzN89K6z03+IFmyeu/oFCenwdwHDr2gky7nIGSEVvlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/checkbox": "^4.1.5", + "@inquirer/confirm": "^5.1.9", + "@inquirer/editor": "^4.2.10", + "@inquirer/expand": "^4.0.12", + "@inquirer/input": "^4.1.9", + "@inquirer/number": "^3.0.12", + "@inquirer/password": "^4.0.12", + "@inquirer/rawlist": "^4.0.12", + "@inquirer/search": "^3.0.12", + "@inquirer/select": "^4.1.1" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, "node_modules/@nestjs/cli/node_modules/ajv": { "version": "8.17.1", "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", @@ -2573,9 +2578,9 @@ } }, "node_modules/@nestjs/common": { - "version": "11.0.12", - "resolved": "https://registry.npmjs.org/@nestjs/common/-/common-11.0.12.tgz", - "integrity": "sha512-6PXxmDe2iYmb57xacnxzpW1NAxRZ7Gf+acMT7/hmRB/4KpZiFU/cNvLWwgbM2BL5QSzQulOwY6ny5bbKnPpB+A==", + "version": "11.0.13", + "resolved": "https://registry.npmjs.org/@nestjs/common/-/common-11.0.13.tgz", + "integrity": "sha512-cXqXJPQTcJIYqT8GtBYqjYY9sklCBqp/rh9z1R40E60gWnsU598YIQWkojSFRI9G7lT/+uF+jqSrg/CMPBk7QQ==", "license": "MIT", "dependencies": { "iterare": "1.2.1", @@ -2602,9 +2607,9 @@ } }, "node_modules/@nestjs/core": { - "version": "11.0.12", - "resolved": "https://registry.npmjs.org/@nestjs/core/-/core-11.0.12.tgz", - "integrity": "sha512-micQrbh9iL0PuYVx2vsUojuNmMUyqoMCuj7eGAUhvjiZUh4DBLPdxYmJEayCT/equHSiw9vNC95Vm0JigVZ44g==", + "version": "11.0.13", + "resolved": "https://registry.npmjs.org/@nestjs/core/-/core-11.0.13.tgz", + "integrity": "sha512-1xjrsYjff4sg4MfvF+/NInOq+7oI1D1vK8Yj9wkrbBH1dM+h2At71tccbFfl/eJUt4ckZlH+XmROnt/T0daYcA==", "hasInstallScript": true, "license": "MIT", "dependencies": { @@ -2676,14 +2681,14 @@ } }, "node_modules/@nestjs/platform-express": { - "version": "11.0.12", - "resolved": "https://registry.npmjs.org/@nestjs/platform-express/-/platform-express-11.0.12.tgz", - "integrity": "sha512-Jze6dY1q1BBAjFuPQT9CLjYFl5IxMSQQxD+xs6cV+4EIysHxgSFZMJqiTpknZTFgPneyp0zF1TtQAjxBshnwlg==", + "version": "11.0.13", + "resolved": "https://registry.npmjs.org/@nestjs/platform-express/-/platform-express-11.0.13.tgz", + "integrity": "sha512-SaxfIDORX1oV8T6nxr/pltnW2g+3fCRPs5YwO0jBj2d8sC03Axjwlxp/ASg2mf6xvOSBD6ZbhjVLVVDZymyFXQ==", "license": "MIT", "dependencies": { "cors": "2.8.5", - "express": "5.0.1", - "multer": "1.4.5-lts.1", + "express": "5.1.0", + "multer": "1.4.5-lts.2", "path-to-regexp": "8.2.0", "tslib": "2.8.1" }, @@ -2697,9 +2702,9 @@ } }, "node_modules/@nestjs/platform-socket.io": { - "version": "11.0.12", - "resolved": "https://registry.npmjs.org/@nestjs/platform-socket.io/-/platform-socket.io-11.0.12.tgz", - "integrity": "sha512-/irtuxzIHQqUTMazQpAHQXv/Dz2/hS0EhX+ZS4e4CfF8f6ly+pEqOrP3TNY2NjDkYs8T+ulXyuKgfJvT9p+U9w==", + "version": "11.0.13", + "resolved": "https://registry.npmjs.org/@nestjs/platform-socket.io/-/platform-socket.io-11.0.13.tgz", + "integrity": "sha512-51afyhv4FnCgm4WD+BbJqzie/jBTlTamaiaTrQE7Zw5eZ23jH/QPxj6QYV2gkaVAkXUrMDTGdOWhKszjOjR68Q==", "license": "MIT", "dependencies": { "socket.io": "4.8.1", @@ -2729,14 +2734,14 @@ } }, "node_modules/@nestjs/schematics": { - "version": "11.0.2", - "resolved": "https://registry.npmjs.org/@nestjs/schematics/-/schematics-11.0.2.tgz", - "integrity": "sha512-C4KM3BHBG6tRX8t5UrHdUq8Y49asEfJUora/fBXge3UTAnxKGlXc20p5s2Q0Q1+l+1YaXqTrKGSIbYXdPX8r9g==", + "version": "11.0.3", + "resolved": "https://registry.npmjs.org/@nestjs/schematics/-/schematics-11.0.3.tgz", + "integrity": "sha512-enz9Otg1GafzmtpDRB1bs44/kipVKzmoQoJ296rRQMZPivQUBxFlRSwrR+e1jB09n5UVqCf8tUAQnRzxBR5AKw==", "dev": true, "license": "MIT", "dependencies": { - "@angular-devkit/core": "19.2.0", - "@angular-devkit/schematics": "19.2.0", + "@angular-devkit/core": "19.2.6", + "@angular-devkit/schematics": "19.2.6", "comment-json": "4.2.5", "jsonc-parser": "3.3.1", "pluralize": "8.0.0" @@ -2746,9 +2751,9 @@ } }, "node_modules/@nestjs/schematics/node_modules/@angular-devkit/core": { - "version": "19.2.0", - "resolved": "https://registry.npmjs.org/@angular-devkit/core/-/core-19.2.0.tgz", - "integrity": "sha512-qd2nYoHZOYWRsu4MjXG8KiDtfM9ZDRR2rDGa+rDZ3CYAsngCrPmqOebun10dncUjwAidX49P4S2U2elOmX3VYQ==", + "version": "19.2.6", + "resolved": "https://registry.npmjs.org/@angular-devkit/core/-/core-19.2.6.tgz", + "integrity": "sha512-WFgiYhrDMq83UNaGRAneIM7CYYdBozD+yYA9BjoU8AgBLKtrvn6S8ZcjKAk5heoHtY/u8pEb0mwDTz9gxFmJZQ==", "dev": true, "license": "MIT", "dependencies": { @@ -2773,25 +2778,6 @@ } } }, - "node_modules/@nestjs/schematics/node_modules/@angular-devkit/schematics": { - "version": "19.2.0", - "resolved": "https://registry.npmjs.org/@angular-devkit/schematics/-/schematics-19.2.0.tgz", - "integrity": "sha512-cGGqUGqBXIGJkeL65l70y0BflDAu/0Zi/ohbYat3hvadFfumRJnVElVfJ59JtWO7FfKQjxcwCVTyuQ/tevX/9A==", - "dev": true, - "license": "MIT", - "dependencies": { - "@angular-devkit/core": "19.2.0", - "jsonc-parser": "3.3.1", - "magic-string": "0.30.17", - "ora": "5.4.1", - "rxjs": "7.8.1" - }, - "engines": { - "node": "^18.19.1 || ^20.11.1 || >=22.0.0", - "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", - "yarn": ">= 1.13.0" - } - }, "node_modules/@nestjs/schematics/node_modules/ajv": { "version": "8.17.1", "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", @@ -2871,9 +2857,9 @@ } }, "node_modules/@nestjs/swagger": { - "version": "11.1.0", - "resolved": "https://registry.npmjs.org/@nestjs/swagger/-/swagger-11.1.0.tgz", - "integrity": "sha512-+GQ+q1ASTBvGi0DYHukWi8NVVVLszedwLLqHdLRnJh8rjokt8YTDb7roImvT/YMmYgPvaWBv/4JYdZH4FueLPQ==", + "version": "11.1.1", + "resolved": "https://registry.npmjs.org/@nestjs/swagger/-/swagger-11.1.1.tgz", + "integrity": "sha512-k7jEiocSQ5bL6RSnEjQ1h4uT4fErgshWQIhaVjyvufIEyBfH0Fv0Q2lihH2QLqeDjBkrH5bW0Twbqf3SlLOwCw==", "license": "MIT", "dependencies": { "@microsoft/tsdoc": "0.15.1", @@ -2881,7 +2867,7 @@ "js-yaml": "4.1.0", "lodash": "4.17.21", "path-to-regexp": "8.2.0", - "swagger-ui-dist": "5.20.1" + "swagger-ui-dist": "5.20.5" }, "peerDependencies": { "@fastify/static": "^8.0.0", @@ -2904,9 +2890,9 @@ } }, "node_modules/@nestjs/testing": { - "version": "11.0.12", - "resolved": "https://registry.npmjs.org/@nestjs/testing/-/testing-11.0.12.tgz", - "integrity": "sha512-jl1McTqrY+zRBFIWcFMVwesY2v++mAdHrrzXsLxatgkf6wRVh6te1MQ6LikgQ6qz4P5qzVV6EiXQVLGvARe5Xw==", + "version": "11.0.13", + "resolved": "https://registry.npmjs.org/@nestjs/testing/-/testing-11.0.13.tgz", + "integrity": "sha512-9E9HxD3EmiQky+pqYvpV0cHKlxYJJqHm2GmXoKHF72Raa0JTfQpamnLl6TPjDy2XOqA7oSSBDnEwku8vZ46Cdw==", "dev": true, "license": "MIT", "dependencies": { @@ -2932,9 +2918,9 @@ } }, "node_modules/@nestjs/websockets": { - "version": "11.0.12", - "resolved": "https://registry.npmjs.org/@nestjs/websockets/-/websockets-11.0.12.tgz", - "integrity": "sha512-2DCHKMdNGdjgj1/H3Rd323HHsSogjM3sZMjrSpWJIACDQTLtHFdNiUgGk5OAHniDIgLatzXDrnikfv6zmhPb+w==", + "version": "11.0.13", + "resolved": "https://registry.npmjs.org/@nestjs/websockets/-/websockets-11.0.13.tgz", + "integrity": "sha512-QvWImf/2+UHzw+OCDkrdJ9y3sH4thcbHxCgTlr9EiGOR9z85M14IIHhLpx4fse0xAqHYw/FDyCOLpszwiiZnFA==", "license": "MIT", "dependencies": { "iterare": "1.2.1", @@ -4495,9 +4481,9 @@ } }, "node_modules/@pkgr/core": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/@pkgr/core/-/core-0.2.0.tgz", - "integrity": "sha512-vsJDAkYR6qCPu+ioGScGiMYR7LvZYIXh/dlQeviqoTWNCVfKTLYD/LkNWH4Mxsv2a5vpIRc77FN5DnmK1eBggQ==", + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/@pkgr/core/-/core-0.2.1.tgz", + "integrity": "sha512-VzgHzGblFmUeBmmrk55zPyrQIArQN4vujc9shWytaPdB3P7qhi0cpaiKIr7tlCmFv2lYUwnLospIqjL9ZSAhhg==", "dev": true, "license": "MIT", "engines": { @@ -4638,9 +4624,9 @@ } }, "node_modules/@react-email/components": { - "version": "0.0.34", - "resolved": "https://registry.npmjs.org/@react-email/components/-/components-0.0.34.tgz", - "integrity": "sha512-9aUJJ4Yu5Cd5++2GHwdkmOHCghi0vPP/aZwMCGNNTovBTDCI3mc8YIUrDR7JfscrdkPK4s/E9AoD5lX6d/zITA==", + "version": "0.0.35", + "resolved": "https://registry.npmjs.org/@react-email/components/-/components-0.0.35.tgz", + "integrity": "sha512-if1kLih4pfARgsXacs9eD9O3BVtRWxKRz1jjSWWiyk32eeFJLtWjBaoF8nsxQxk4w5nfqjAHVFBrxXQceB7xDQ==", "license": "MIT", "dependencies": { "@react-email/body": "0.0.11", @@ -4662,7 +4648,7 @@ "@react-email/row": "0.0.12", "@react-email/section": "0.0.16", "@react-email/tailwind": "1.0.4", - "@react-email/text": "0.1.0" + "@react-email/text": "0.1.1" }, "engines": { "node": ">=18.0.0" @@ -4861,9 +4847,9 @@ } }, "node_modules/@react-email/text": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/@react-email/text/-/text-0.1.0.tgz", - "integrity": "sha512-LG+gEuxpoIiOojkv40iktP8UVjkJVZ+ksEEuf7zRvrcwLcVuzYyirlWdkGr4Vu/AhsD4FDRoxDWlWvLTx+WHUg==", + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/@react-email/text/-/text-0.1.1.tgz", + "integrity": "sha512-Zo9tSEzkO3fODLVH1yVhzVCiwETfeEL5wU93jXKWo2DHoMuiZ9Iabaso3T0D0UjhrCB1PBMeq2YiejqeToTyIQ==", "license": "MIT", "engines": { "node": ">=18.0.0" @@ -5249,9 +5235,9 @@ "license": "MIT" }, "node_modules/@swc/core": { - "version": "1.11.15", - "resolved": "https://registry.npmjs.org/@swc/core/-/core-1.11.15.tgz", - "integrity": "sha512-SqXjJrwydXA2OVVAFv9EdCb2kkhEM2+b4ajereGzFSQuK2FN/SlKPklvFMh9sj1sG0tgXwyLGSMgyn3FUx83DA==", + "version": "1.11.16", + "resolved": "https://registry.npmjs.org/@swc/core/-/core-1.11.16.tgz", + "integrity": "sha512-wgjrJqVUss8Lxqilg0vkiE0tkEKU3mZkoybQM1Ehy+PKWwwB6lFAwKi20cAEFlSSWo8jFR8hRo19ZELAoLDowg==", "dev": true, "hasInstallScript": true, "license": "Apache-2.0", @@ -5267,16 +5253,16 @@ "url": "https://opencollective.com/swc" }, "optionalDependencies": { - "@swc/core-darwin-arm64": "1.11.15", - "@swc/core-darwin-x64": "1.11.15", - "@swc/core-linux-arm-gnueabihf": "1.11.15", - "@swc/core-linux-arm64-gnu": "1.11.15", - "@swc/core-linux-arm64-musl": "1.11.15", - "@swc/core-linux-x64-gnu": "1.11.15", - "@swc/core-linux-x64-musl": "1.11.15", - "@swc/core-win32-arm64-msvc": "1.11.15", - "@swc/core-win32-ia32-msvc": "1.11.15", - "@swc/core-win32-x64-msvc": "1.11.15" + "@swc/core-darwin-arm64": "1.11.16", + "@swc/core-darwin-x64": "1.11.16", + "@swc/core-linux-arm-gnueabihf": "1.11.16", + "@swc/core-linux-arm64-gnu": "1.11.16", + "@swc/core-linux-arm64-musl": "1.11.16", + "@swc/core-linux-x64-gnu": "1.11.16", + "@swc/core-linux-x64-musl": "1.11.16", + "@swc/core-win32-arm64-msvc": "1.11.16", + "@swc/core-win32-ia32-msvc": "1.11.16", + "@swc/core-win32-x64-msvc": "1.11.16" }, "peerDependencies": { "@swc/helpers": "*" @@ -5288,9 +5274,9 @@ } }, "node_modules/@swc/core-darwin-arm64": { - "version": "1.11.15", - "resolved": "https://registry.npmjs.org/@swc/core-darwin-arm64/-/core-darwin-arm64-1.11.15.tgz", - "integrity": "sha512-mMoQy6TrYrvhrpi70eD01uu4WeB+Wy+9To5b95gHcyiAMRyd7afnFHo9OcPynk0Ep01PvReiB6hL2hYfNcDKvw==", + "version": "1.11.16", + "resolved": "https://registry.npmjs.org/@swc/core-darwin-arm64/-/core-darwin-arm64-1.11.16.tgz", + "integrity": "sha512-l6uWMU+MUdfLHCl3dJgtVEdsUHPskoA4BSu0L1hh9SGBwPZ8xeOz8iLIqZM27lTuXxL4KsYH6GQR/OdQ/vhLtg==", "cpu": [ "arm64" ], @@ -5305,9 +5291,9 @@ } }, "node_modules/@swc/core-darwin-x64": { - "version": "1.11.15", - "resolved": "https://registry.npmjs.org/@swc/core-darwin-x64/-/core-darwin-x64-1.11.15.tgz", - "integrity": "sha512-yBWcP5v3OXq1Nxamqh1+qecty3TFRlxAMNXMBzq/Rv6Fu9eOAU6lTSfozO0BaOoETTzorlR2/3Jn+3amyviQMw==", + "version": "1.11.16", + "resolved": "https://registry.npmjs.org/@swc/core-darwin-x64/-/core-darwin-x64-1.11.16.tgz", + "integrity": "sha512-TH0IW8Ao1WZ4ARFHIh29dAQHYBEl4YnP74n++rjppmlCjY+8v3s5nXMA7IqxO3b5LVHyggWtU4+46DXTyMJM7g==", "cpu": [ "x64" ], @@ -5322,9 +5308,9 @@ } }, "node_modules/@swc/core-linux-arm-gnueabihf": { - "version": "1.11.15", - "resolved": "https://registry.npmjs.org/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.11.15.tgz", - "integrity": "sha512-OprUQ0AvIiA2FCZqDYcnZ1nZhiCABqJPGgC9KwX8p8tC+t1mYkAeboik23S9gxzwGQImMNYYojGbNGTmLATLrA==", + "version": "1.11.16", + "resolved": "https://registry.npmjs.org/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.11.16.tgz", + "integrity": "sha512-2IxD9t09oNZrbv37p4cJ9cTHMUAK6qNiShi9s2FJ9LcqSnZSN4iS4hvaaX6KZuG54d58vWnMU7yycjkdOTQcMg==", "cpu": [ "arm" ], @@ -5339,9 +5325,9 @@ } }, "node_modules/@swc/core-linux-arm64-gnu": { - "version": "1.11.15", - "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.11.15.tgz", - "integrity": "sha512-Uq3FjjKEw1CTtFpz7Mi+CC//4KQODQ8vXFx7J/cBO6nj+/Os9J1huyqa1LljlBTCeDXTpeC7qlqO6swZ0HPaJw==", + "version": "1.11.16", + "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.11.16.tgz", + "integrity": "sha512-AYkN23DOiPh1bf3XBf/xzZQDKSsgZTxlbyTyUIhprLJpAAAT0ZCGAUcS5mHqydk0nWQ13ABUymodvHoroutNzw==", "cpu": [ "arm64" ], @@ -5356,9 +5342,9 @@ } }, "node_modules/@swc/core-linux-arm64-musl": { - "version": "1.11.15", - "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.11.15.tgz", - "integrity": "sha512-G5orst6QzXyTXgOTnjrkYaLaK3emMXBWkQ7CDFyZNCGo6Fztn0vzYcCmr31Cvqs66BsM0sdGbcrBd5br8g/pJg==", + "version": "1.11.16", + "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.11.16.tgz", + "integrity": "sha512-n/nWXDRCIhM51dDGELfBcTMNnCiFatE7LDvsbYxb7DJt1HGjaCNvHHCKURb/apJTh/YNtWfgFap9dbsTgw8yPA==", "cpu": [ "arm64" ], @@ -5373,9 +5359,9 @@ } }, "node_modules/@swc/core-linux-x64-gnu": { - "version": "1.11.15", - "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.11.15.tgz", - "integrity": "sha512-T0iR9yUcGyo1yLudL73jKbPS4AYo2iAWWH2I9u7QYiRTXPduwkH0nETNr+nsWBsYdMu+H2g169rCiGhhx6FPHw==", + "version": "1.11.16", + "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.11.16.tgz", + "integrity": "sha512-xr182YQrF47n7Awxj+/ruI21bYw+xO/B26KFVnb+i3ezF9NOhqoqTX+33RL1ZLA/uFTq8ksPZO/y+ZVS/odtQA==", "cpu": [ "x64" ], @@ -5390,9 +5376,9 @@ } }, "node_modules/@swc/core-linux-x64-musl": { - "version": "1.11.15", - "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.11.15.tgz", - "integrity": "sha512-2d8pHehwsHdQ71PRLeJ/XM69t5LCMzf1KZQDTVJTOSWRbuKGArtD+md5lVzTu458gt+JawdUgFdkdHtF7ke0AA==", + "version": "1.11.16", + "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.11.16.tgz", + "integrity": "sha512-k2JBfiwWfXCIKrBRjFO9/vEdLSYq0QLJ+iNSLdfrejZ/aENNkbEg8O7O2GKUSb30RBacn6k8HMfJrcPLFiEyCQ==", "cpu": [ "x64" ], @@ -5407,9 +5393,9 @@ } }, "node_modules/@swc/core-win32-arm64-msvc": { - "version": "1.11.15", - "resolved": "https://registry.npmjs.org/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.11.15.tgz", - "integrity": "sha512-Vz5xg03VdYftMvruvziV1doU7B64rQ8rw72bKf2+yflt1gU7BlLk4DPu2IZlUc0Xk8lrVcEDiheXATbHexKsmw==", + "version": "1.11.16", + "resolved": "https://registry.npmjs.org/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.11.16.tgz", + "integrity": "sha512-taOb5U+abyEhQgex+hr6cI48BoqSvSdfmdirWcxprIEUBHCxa1dSriVwnJRAJOFI9T+5BEz88by6rgbB9MjbHA==", "cpu": [ "arm64" ], @@ -5424,9 +5410,9 @@ } }, "node_modules/@swc/core-win32-ia32-msvc": { - "version": "1.11.15", - "resolved": "https://registry.npmjs.org/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.11.15.tgz", - "integrity": "sha512-R9jS92ubQgHQfyNVCMnuQfNPeBgAs3QaWC+DqPbhXtOyWUdSGcImbHMDCxShDj+nn8J7bPeb7L4sZqr6gBkZnQ==", + "version": "1.11.16", + "resolved": "https://registry.npmjs.org/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.11.16.tgz", + "integrity": "sha512-b7yYggM9LBDiMY+XUt5kYWvs5sn0U3PXSOGvF3CbLufD/N/YQiDcYON2N3lrWHYL8aYnwbuZl45ojmQHSQPcdA==", "cpu": [ "ia32" ], @@ -5441,9 +5427,9 @@ } }, "node_modules/@swc/core-win32-x64-msvc": { - "version": "1.11.15", - "resolved": "https://registry.npmjs.org/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.11.15.tgz", - "integrity": "sha512-UpSX492qVVTJQkRBYw3qC49ae4QRHwuC1cDgA47XBP0l31vjR83r3qEYue1Nn173etzGzbDJnygyLpqv/ieCCA==", + "version": "1.11.16", + "resolved": "https://registry.npmjs.org/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.11.16.tgz", + "integrity": "sha512-/ibq/YDc3B5AROkpOKPGxVkSyCKOg+ml8k11RxrW7FAPy6a9y5y9KPcWIqV74Ahq4RuaMNslTQqHWAGSm0xJsQ==", "cpu": [ "x64" ], @@ -5483,23 +5469,23 @@ } }, "node_modules/@testcontainers/postgresql": { - "version": "10.23.0", - "resolved": "https://registry.npmjs.org/@testcontainers/postgresql/-/postgresql-10.23.0.tgz", - "integrity": "sha512-PKuv7cSWxOxW4aOEuw1XyYb7tS8rcPEmg2ez97WTLLnVZj4JaoPJqFDSEJ2OSj8s3+6HqLC6hXDCMFmYhP63/A==", + "version": "10.24.0", + "resolved": "https://registry.npmjs.org/@testcontainers/postgresql/-/postgresql-10.24.0.tgz", + "integrity": "sha512-vRgwRxblOMhzNrUOmiXjvjOn8efqI3eyDT4KLh5kgmpGjE+Wz5LtCrhTmT4hMv5KPeZmftx+1OhQQrfyBqSvtg==", "dev": true, "license": "MIT", "dependencies": { - "testcontainers": "^10.23.0" + "testcontainers": "^10.24.0" } }, "node_modules/@testcontainers/redis": { - "version": "10.23.0", - "resolved": "https://registry.npmjs.org/@testcontainers/redis/-/redis-10.23.0.tgz", - "integrity": "sha512-rMkEdCsjhAPFuagHfI28q/Uvq6Wj/uN0qJxa6bwvenc6qhbzqYlK8iguj3M/cYs5ItDgxZ9J6HxhZKzkc3U1iQ==", + "version": "10.24.0", + "resolved": "https://registry.npmjs.org/@testcontainers/redis/-/redis-10.24.0.tgz", + "integrity": "sha512-Il3PqqgwEPRGkpVzX9BxtIzWKTHbiOixD3bYQvsgYqE5upLE1FsTnj62SgCI86/Db2MXHQ+XS0rLncnBP2lfKg==", "dev": true, "license": "MIT", "dependencies": { - "testcontainers": "^10.23.0" + "testcontainers": "^10.24.0" } }, "node_modules/@turf/boolean-point-in-polygon": { @@ -5912,9 +5898,9 @@ "license": "MIT" }, "node_modules/@types/react": { - "version": "19.0.12", - "resolved": "https://registry.npmjs.org/@types/react/-/react-19.0.12.tgz", - "integrity": "sha512-V6Ar115dBDrjbtXSrS+/Oruobc+qVbbUxDFC1RSbRqLt5SYvxxyIDrSC85RWml54g+jfNeEMZhEj7wW07ONQhA==", + "version": "19.1.0", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.0.tgz", + "integrity": "sha512-UaicktuQI+9UKyA4njtDOGBD/67t8YEBt2xdfqu8+gP9hqPUPsiXlNPcpS2gVdjmis5GKPG3fCxbQLVgxsQZ8w==", "dev": true, "license": "MIT", "dependencies": { @@ -6823,9 +6809,9 @@ } }, "node_modules/ansis": { - "version": "3.16.0", - "resolved": "https://registry.npmjs.org/ansis/-/ansis-3.16.0.tgz", - "integrity": "sha512-sU7d/tfZiYrsIAXbdL/CNZld5bCkruzwT5KmqmadCJYxuLxHAOBjidxD5+iLmN/6xEfjcQq1l7OpsiCBlc4LzA==", + "version": "3.17.0", + "resolved": "https://registry.npmjs.org/ansis/-/ansis-3.17.0.tgz", + "integrity": "sha512-0qWUglt9JEqLFr3w1I1pbrChn1grhaiAR2ocX1PP/flRmxgtwTzPFFFnfIlD6aMOLQZgSuCRlidD70lvx8yhzg==", "license": "ISC", "engines": { "node": ">=14" @@ -7298,16 +7284,16 @@ } }, "node_modules/body-parser": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.1.0.tgz", - "integrity": "sha512-/hPxh61E+ll0Ujp24Ilm64cykicul1ypfwjVttduAiEdtnJFvLePSrIPk+HMImtNv5270wOGCb1Tns2rybMkoQ==", + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.0.tgz", + "integrity": "sha512-02qvAaxv8tp7fBa/mw1ga98OGm+eCbqzJOKoRt70sLmfEEi+jyBYVTDGfCL/k06/4EMk/z01gCe7HoCH/f2LTg==", "license": "MIT", "dependencies": { "bytes": "^3.1.2", "content-type": "^1.0.5", "debug": "^4.4.0", "http-errors": "^2.0.0", - "iconv-lite": "^0.5.2", + "iconv-lite": "^0.6.3", "on-finished": "^2.4.1", "qs": "^6.14.0", "raw-body": "^3.0.0", @@ -7317,21 +7303,6 @@ "node": ">=18" } }, - "node_modules/body-parser/node_modules/qs": { - "version": "6.14.0", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz", - "integrity": "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==", - "license": "BSD-3-Clause", - "dependencies": { - "side-channel": "^1.1.0" - }, - "engines": { - "node": ">=0.6" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/brace-expansion": { "version": "1.1.11", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", @@ -8419,16 +8390,6 @@ "node": ">= 0.8" } }, - "node_modules/destroy": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", - "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", - "license": "MIT", - "engines": { - "node": ">= 0.8", - "npm": "1.2.8000 || >= 1.4.16" - } - }, "node_modules/detect-europe-js": { "version": "0.1.2", "resolved": "https://registry.npmjs.org/detect-europe-js/-/detect-europe-js-0.1.2.tgz", @@ -9100,14 +9061,14 @@ } }, "node_modules/eslint-plugin-prettier": { - "version": "5.2.5", - "resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-5.2.5.tgz", - "integrity": "sha512-IKKP8R87pJyMl7WWamLgPkloB16dagPIdd2FjBDbyRYPKo93wS/NbCOPh6gH+ieNLC+XZrhJt/kWj0PS/DFdmg==", + "version": "5.2.6", + "resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-5.2.6.tgz", + "integrity": "sha512-mUcf7QG2Tjk7H055Jk0lGBjbgDnfrvqjhXh9t2xLMSCjZVcw9Rb1V6sVNXO0th3jgeO7zllWPTNRil3JW94TnQ==", "dev": true, "license": "MIT", "dependencies": { "prettier-linter-helpers": "^1.0.0", - "synckit": "^0.10.2" + "synckit": "^0.11.0" }, "engines": { "node": "^14.18.0 || >=16.0.0" @@ -9412,46 +9373,45 @@ } }, "node_modules/express": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/express/-/express-5.0.1.tgz", - "integrity": "sha512-ORF7g6qGnD+YtUG9yx4DFoqCShNMmUKiXuT5oWMHiOvt/4WFbHC6yCwQMTSBMno7AqntNCAzzcnnjowRkTL9eQ==", + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/express/-/express-5.1.0.tgz", + "integrity": "sha512-DT9ck5YIRU+8GYzzU5kT3eHGA5iL+1Zd0EutOmTE9Dtk+Tvuzd23VBU+ec7HPNSTxXYO55gPV/hq4pSBJDjFpA==", "license": "MIT", "dependencies": { "accepts": "^2.0.0", - "body-parser": "^2.0.1", + "body-parser": "^2.2.0", "content-disposition": "^1.0.0", - "content-type": "~1.0.4", - "cookie": "0.7.1", + "content-type": "^1.0.5", + "cookie": "^0.7.1", "cookie-signature": "^1.2.1", - "debug": "4.3.6", - "depd": "2.0.0", - "encodeurl": "~2.0.0", - "escape-html": "~1.0.3", - "etag": "~1.8.1", - "finalhandler": "^2.0.0", - "fresh": "2.0.0", - "http-errors": "2.0.0", + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", "merge-descriptors": "^2.0.0", - "methods": "~1.1.2", "mime-types": "^3.0.0", - "on-finished": "2.4.1", - "once": "1.4.0", - "parseurl": "~1.3.3", - "proxy-addr": "~2.0.7", - "qs": "6.13.0", - "range-parser": "~1.2.1", - "router": "^2.0.0", - "safe-buffer": "5.2.1", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", "send": "^1.1.0", - "serve-static": "^2.1.0", - "setprototypeof": "1.2.0", - "statuses": "2.0.1", - "type-is": "^2.0.0", - "utils-merge": "1.0.1", - "vary": "~1.1.2" + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" }, "engines": { "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/express/node_modules/cookie": { @@ -9472,29 +9432,6 @@ "node": ">=6.6.0" } }, - "node_modules/express/node_modules/debug": { - "version": "4.3.6", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.6.tgz", - "integrity": "sha512-O/09Bd4Z1fBrU4VzkhFqVgpPzaGbw6Sm9FEkBT1A/YBXQFGuuSxa1dN2nxgxS34JmKXqYx8CZAwEVoJFImUXIg==", - "license": "MIT", - "dependencies": { - "ms": "2.1.2" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/express/node_modules/ms": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", - "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", - "license": "MIT" - }, "node_modules/extend": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", @@ -9792,15 +9729,15 @@ } }, "node_modules/fork-ts-checker-webpack-plugin": { - "version": "9.0.2", - "resolved": "https://registry.npmjs.org/fork-ts-checker-webpack-plugin/-/fork-ts-checker-webpack-plugin-9.0.2.tgz", - "integrity": "sha512-Uochze2R8peoN1XqlSi/rGUkDQpRogtLFocP9+PGu68zk1BDAKXfdeCdyVZpgTk8V8WFVQXdEz426VKjXLO1Gg==", + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/fork-ts-checker-webpack-plugin/-/fork-ts-checker-webpack-plugin-9.1.0.tgz", + "integrity": "sha512-mpafl89VFPJmhnJ1ssH+8wmM2b50n+Rew5x42NeI2U78aRWgtkEtGmctp7iT16UjquJTjorEmIfESj3DxdW84Q==", "dev": true, "license": "MIT", "dependencies": { "@babel/code-frame": "^7.16.7", "chalk": "^4.1.2", - "chokidar": "^3.5.3", + "chokidar": "^4.0.1", "cosmiconfig": "^8.2.0", "deepmerge": "^4.2.2", "fs-extra": "^10.0.0", @@ -9812,14 +9749,43 @@ "tapable": "^2.2.1" }, "engines": { - "node": ">=12.13.0", - "yarn": ">=1.0.0" + "node": ">=14.21.3" }, "peerDependencies": { "typescript": ">3.6.0", "webpack": "^5.11.0" } }, + "node_modules/fork-ts-checker-webpack-plugin/node_modules/chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/fork-ts-checker-webpack-plugin/node_modules/readdirp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/form-data": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.2.tgz", @@ -10593,12 +10559,12 @@ } }, "node_modules/iconv-lite": { - "version": "0.5.2", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.5.2.tgz", - "integrity": "sha512-kERHXvpSaB4aU3eANwidg79K8FlrN77m8G9V+0vOR3HYaRifrlwMEpT7ZBJqLSEIHnEgJTHcWK82wwLwwKwtag==", + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", "license": "MIT", "dependencies": { - "safer-buffer": ">= 2.1.2 < 3" + "safer-buffer": ">= 2.1.2 < 3.0.0" }, "engines": { "node": ">=0.10.0" @@ -11791,15 +11757,6 @@ "node": ">= 8" } }, - "node_modules/methods": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", - "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, "node_modules/micromatch": { "version": "4.0.8", "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", @@ -11826,21 +11783,21 @@ } }, "node_modules/mime-db": { - "version": "1.53.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.53.0.tgz", - "integrity": "sha512-oHlN/w+3MQ3rba9rqFr6V/ypF10LSkdwUysQL7GkXoTgIWeV+tcXGA852TBxH+gsh8UWoyhR1hKcoMJTuWflpg==", + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", "license": "MIT", "engines": { "node": ">= 0.6" } }, "node_modules/mime-types": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.0.tgz", - "integrity": "sha512-XqoSHeCGjVClAmoGFG3lVFqQFRIrTVw2OH3axRqAcfaw+gHWIfnASS92AV+Rl/mk0MupgZTRHQOjxY6YVnzK5w==", + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.1.tgz", + "integrity": "sha512-xRc4oEhT6eaBpU1XF7AjpOFD+xQmXNB5OVKwp4tqCuBpHLS/ZbBDrc07mYTDqVMg6PfxUjjNp85O6Cd2Z/5HWA==", "license": "MIT", "dependencies": { - "mime-db": "^1.53.0" + "mime-db": "^1.54.0" }, "engines": { "node": ">= 0.6" @@ -12009,9 +11966,9 @@ } }, "node_modules/multer": { - "version": "1.4.5-lts.1", - "resolved": "https://registry.npmjs.org/multer/-/multer-1.4.5-lts.1.tgz", - "integrity": "sha512-ywPWvcDMeH+z9gQq5qYHCCy+ethsk4goepZ45GLD63fOu0YcNecQxi64nDs3qluZB+murG3/D4dJ7+dGctcCQQ==", + "version": "1.4.5-lts.2", + "resolved": "https://registry.npmjs.org/multer/-/multer-1.4.5-lts.2.tgz", + "integrity": "sha512-VzGiVigcG9zUAoCNU+xShztrlr1auZOlurXynNvO9GiWD1/mTBbUljOKY+qMeazBqXgRnjzeEgJI/wyjJUHg9A==", "license": "MIT", "dependencies": { "append-field": "^1.0.0", @@ -13563,12 +13520,12 @@ } }, "node_modules/qs": { - "version": "6.13.0", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.13.0.tgz", - "integrity": "sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==", + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz", + "integrity": "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==", "license": "BSD-3-Clause", "dependencies": { - "side-channel": "^1.0.6" + "side-channel": "^1.1.0" }, "engines": { "node": ">=0.6" @@ -13652,18 +13609,6 @@ "node": ">= 0.8" } }, - "node_modules/raw-body/node_modules/iconv-lite": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", - "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", - "license": "MIT", - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/react": { "version": "19.1.0", "resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz", @@ -14354,11 +14299,13 @@ } }, "node_modules/router": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/router/-/router-2.1.0.tgz", - "integrity": "sha512-/m/NSLxeYEgWNtyC+WtNHCF7jbGxOibVWKnn+1Psff4dJGOfoXP+MuC/f2CwSmyiHdOIzYnYFp4W6GxWfekaLA==", + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", "license": "MIT", "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", "is-promise": "^4.0.0", "parseurl": "^1.3.3", "path-to-regexp": "^8.0.0" @@ -14507,19 +14454,18 @@ } }, "node_modules/send": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/send/-/send-1.1.0.tgz", - "integrity": "sha512-v67WcEouB5GxbTWL/4NeToqcZiAWEq90N888fczVArY8A79J0L4FD7vj5hm3eUMua5EpoQ59wa/oovY6TLvRUA==", + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.0.tgz", + "integrity": "sha512-uaW0WwXKpL9blXE2o0bRhoL2EGXIrZxQ2ZQ4mgcfoBxdFmQold+qWsD2jLrfZ0trjKL6vOw0j//eAwcALFjKSw==", "license": "MIT", "dependencies": { "debug": "^4.3.5", - "destroy": "^1.2.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", - "fresh": "^0.5.2", + "fresh": "^2.0.0", "http-errors": "^2.0.0", - "mime-types": "^2.1.35", + "mime-types": "^3.0.1", "ms": "^2.1.3", "on-finished": "^2.4.1", "range-parser": "^1.2.1", @@ -14529,36 +14475,6 @@ "node": ">= 18" } }, - "node_modules/send/node_modules/fresh": { - "version": "0.5.2", - "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", - "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/send/node_modules/mime-db": { - "version": "1.52.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", - "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/send/node_modules/mime-types": { - "version": "2.1.35", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", - "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", - "license": "MIT", - "dependencies": { - "mime-db": "1.52.0" - }, - "engines": { - "node": ">= 0.6" - } - }, "node_modules/serialize-javascript": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.2.tgz", @@ -14570,15 +14486,15 @@ } }, "node_modules/serve-static": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.1.0.tgz", - "integrity": "sha512-A3We5UfEjG8Z7VkDv6uItWw6HY2bBSBJT1KtVESn6EOoOr2jAxNhxWCLY3jDE2WcuHXByWju74ck3ZgLwL8xmA==", + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.0.tgz", + "integrity": "sha512-61g9pCh0Vnh7IutZjtLGGpTA355+OPn2TyDv/6ivP2h/AdAVX9azsoxmg2/M6nZeQZNYBEwIcsne1mJd9oQItQ==", "license": "MIT", "dependencies": { "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "parseurl": "^1.3.3", - "send": "^1.0.0" + "send": "^1.2.0" }, "engines": { "node": ">= 18" @@ -15559,9 +15475,9 @@ } }, "node_modules/swagger-ui-dist": { - "version": "5.20.1", - "resolved": "https://registry.npmjs.org/swagger-ui-dist/-/swagger-ui-dist-5.20.1.tgz", - "integrity": "sha512-qBPCis2w8nP4US7SvUxdJD3OwKcqiWeZmjN2VWhq2v+ESZEXOP/7n4DeiOiiZcGYTKMHAHUUrroHaTsjUWTEGw==", + "version": "5.20.5", + "resolved": "https://registry.npmjs.org/swagger-ui-dist/-/swagger-ui-dist-5.20.5.tgz", + "integrity": "sha512-7DqzFVHAW5MRhmWRDgd2Xr7RQUGaJv+7RfGmwChlOxz+tMLBmvHDz3vuVgaoj2CWNpTHxIm8aTsCBeJVxNrpjA==", "license": "Apache-2.0", "dependencies": { "@scarf/scarf": "=1.4.0" @@ -15578,20 +15494,20 @@ } }, "node_modules/synckit": { - "version": "0.10.3", - "resolved": "https://registry.npmjs.org/synckit/-/synckit-0.10.3.tgz", - "integrity": "sha512-R1urvuyiTaWfeCggqEvpDJwAlDVdsT9NM+IP//Tk2x7qHCkSvBk/fwFgw/TLAHzZlrAnnazMcRw0ZD8HlYFTEQ==", + "version": "0.11.3", + "resolved": "https://registry.npmjs.org/synckit/-/synckit-0.11.3.tgz", + "integrity": "sha512-szhWDqNNI9etJUvbZ1/cx1StnZx8yMmFxme48SwR4dty4ioSY50KEZlpv0qAfgc1fpRzuh9hBXEzoCpJ779dLg==", "dev": true, "license": "MIT", "dependencies": { - "@pkgr/core": "^0.2.0", + "@pkgr/core": "^0.2.1", "tslib": "^2.8.1" }, "engines": { "node": "^14.18.0 || >=16.0.0" }, "funding": { - "url": "https://opencollective.com/unts" + "url": "https://opencollective.com/synckit" } }, "node_modules/systeminformation": { @@ -16050,9 +15966,9 @@ } }, "node_modules/testcontainers": { - "version": "10.23.0", - "resolved": "https://registry.npmjs.org/testcontainers/-/testcontainers-10.23.0.tgz", - "integrity": "sha512-sZeij9mAyR9ixlaAmxU/DNb5LQ2duGCBDVjLaI975QGsX3sWatsBMDr4rqnP3IBemLynp+azZBMEfw75YsXMMg==", + "version": "10.24.0", + "resolved": "https://registry.npmjs.org/testcontainers/-/testcontainers-10.24.0.tgz", + "integrity": "sha512-akkNb3LO2IhxnJzl5kj6dDt2c5q0bWHSTUSLSsqqLuZkaJTYCyWCE76uSzJLGpCkASV7Bw4XOOKvn4Tu0GHeFA==", "dev": true, "license": "MIT", "dependencies": { @@ -16068,7 +15984,7 @@ "proper-lockfile": "^4.1.2", "properties-reader": "^2.3.0", "ssh-remote-port-forward": "^1.0.4", - "tar-fs": "^3.0.6", + "tar-fs": "^3.0.7", "tmp": "^0.2.3", "undici": "^5.28.5" } @@ -16356,9 +16272,9 @@ } }, "node_modules/type-is": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.0.tgz", - "integrity": "sha512-gd0sGezQYCbWSbkZr75mln4YBidWUN60+devscpLF5mtRDUpiaTvKpBNrdaCvel1NdR2k6vclXybU5fBd2i+nw==", + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", + "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==", "license": "MIT", "dependencies": { "content-type": "^1.0.5", @@ -16376,24 +16292,24 @@ "license": "MIT" }, "node_modules/typeorm": { - "version": "0.3.21", - "resolved": "https://registry.npmjs.org/typeorm/-/typeorm-0.3.21.tgz", - "integrity": "sha512-lh4rUWl1liZGjyPTWpwcK8RNI5x4ekln+/JJOox1wCd7xbucYDOXWD+1cSzTN3L0wbTGxxOtloM5JlxbOxEufA==", + "version": "0.3.22", + "resolved": "https://registry.npmjs.org/typeorm/-/typeorm-0.3.22.tgz", + "integrity": "sha512-P/Tsz3UpJ9+K0oryC0twK5PO27zejLYYwMsE8SISfZc1lVHX+ajigiOyWsKbuXpEFMjD9z7UjLzY3+ElVOMMDA==", "license": "MIT", "dependencies": { "@sqltools/formatter": "^1.2.5", - "ansis": "^3.9.0", + "ansis": "^3.17.0", "app-root-path": "^3.1.0", "buffer": "^6.0.3", - "dayjs": "^1.11.9", - "debug": "^4.3.4", - "dotenv": "^16.0.3", + "dayjs": "^1.11.13", + "debug": "^4.4.0", + "dotenv": "^16.4.7", "glob": "^10.4.5", "sha.js": "^2.4.11", "sql-highlight": "^6.0.0", - "tslib": "^2.5.0", - "uuid": "^11.0.5", - "yargs": "^17.6.2" + "tslib": "^2.8.1", + "uuid": "^11.1.0", + "yargs": "^17.7.2" }, "bin": { "typeorm": "cli.js", @@ -16407,12 +16323,12 @@ "url": "https://opencollective.com/typeorm" }, "peerDependencies": { - "@google-cloud/spanner": "^5.18.0", + "@google-cloud/spanner": "^5.18.0 || ^6.0.0 || ^7.0.0", "@sap/hana-client": "^2.12.25", "better-sqlite3": "^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0", "hdb-pool": "^0.1.6", "ioredis": "^5.0.4", - "mongodb": "^5.8.0", + "mongodb": "^5.8.0 || ^6.0.0", "mssql": "^9.1.1 || ^10.0.1 || ^11.0.1", "mysql2": "^2.2.5 || ^3.0.1", "oracledb": "^6.3.0", @@ -16424,7 +16340,7 @@ "sql.js": "^1.4.0", "sqlite3": "^5.0.3", "ts-node": "^10.7.0", - "typeorm-aurora-data-api-driver": "^2.0.0" + "typeorm-aurora-data-api-driver": "^2.0.0 || ^3.0.0" }, "peerDependenciesMeta": { "@google-cloud/spanner": { @@ -16854,15 +16770,6 @@ "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", "license": "MIT" }, - "node_modules/utils-merge": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", - "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", - "license": "MIT", - "engines": { - "node": ">= 0.4.0" - } - }, "node_modules/utimes": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/utimes/-/utimes-5.2.1.tgz", diff --git a/server/package.json b/server/package.json index d6c782b2ac..257258234c 100644 --- a/server/package.json +++ b/server/package.json @@ -49,7 +49,7 @@ "@opentelemetry/context-async-hooks": "^2.0.0", "@opentelemetry/exporter-prometheus": "^0.200.0", "@opentelemetry/sdk-node": "^0.200.0", - "@react-email/components": "^0.0.34", + "@react-email/components": "^0.0.35", "@socket.io/redis-adapter": "^8.3.0", "archiver": "^7.0.0", "async-lock": "^1.4.0", diff --git a/web/package-lock.json b/web/package-lock.json index 501496cfaf..029d6aede5 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -87,7 +87,7 @@ "@oazapfts/runtime": "^1.0.2" }, "devDependencies": { - "@types/node": "^22.13.14", + "@types/node": "^22.14.0", "typescript": "^5.3.3" } }, @@ -2127,9 +2127,9 @@ } }, "node_modules/@sveltejs/kit": { - "version": "2.20.2", - "resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.20.2.tgz", - "integrity": "sha512-Dv8TOAZC9vyfcAB9TMsvUEJsRbklRTeNfcYBPaeH6KnABJ99i3CvCB2eNx8fiiliIqe+9GIchBg4RodRH5p1BQ==", + "version": "2.20.3", + "resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.20.3.tgz", + "integrity": "sha512-z1SQ8qra/kGY3DzarG7xc6XsbKm8UY3SnI82XLI3PqMYWbYj/LpjPWuAz9WA5EyLjFNLD7sOAOEW8Gt4yjr5Vg==", "dev": true, "license": "MIT", "dependencies": { @@ -2541,9 +2541,9 @@ } }, "node_modules/@types/luxon": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/@types/luxon/-/luxon-3.6.0.tgz", - "integrity": "sha512-RtEj20xRyG7cRp142MkQpV3GRF8Wo2MtDkKLz65MQs7rM1Lh8bz+HtfPXCCJEYpnDFu6VwAq/Iv2Ikyp9Jw/hw==", + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/@types/luxon/-/luxon-3.6.2.tgz", + "integrity": "sha512-R/BdP7OxEMc44l2Ex5lSXHoIXTB2JLNa3y2QISIbr58U/YcsffyQrYW//hZSdrfxrjRZj3GcUoxMPGdO8gSYuw==", "dev": true, "license": "MIT" }, @@ -4248,22 +4248,6 @@ } } }, - "node_modules/eslint-compat-utils": { - "version": "0.6.4", - "resolved": "https://registry.npmjs.org/eslint-compat-utils/-/eslint-compat-utils-0.6.4.tgz", - "integrity": "sha512-/u+GQt8NMfXO8w17QendT4gvO5acfxQsAKirAt0LVxDnr2N8YLCVbregaNc/Yhp7NM128DwCaRvr8PLDfeNkQw==", - "dev": true, - "license": "MIT", - "dependencies": { - "semver": "^7.5.4" - }, - "engines": { - "node": ">=12" - }, - "peerDependencies": { - "eslint": ">=6.0.0" - } - }, "node_modules/eslint-config-prettier": { "version": "10.1.1", "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-10.1.1.tgz", @@ -4278,15 +4262,14 @@ } }, "node_modules/eslint-plugin-svelte": { - "version": "3.4.1", - "resolved": "https://registry.npmjs.org/eslint-plugin-svelte/-/eslint-plugin-svelte-3.4.1.tgz", - "integrity": "sha512-wgbRwN/6FampBBiIuuLSmp4QRqmuHuexbuRJwx+kqzsxKOhakU8o8sVgGhsf/bQiZkOmWF/5Mrj2CHmVMwY+YQ==", + "version": "3.5.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-svelte/-/eslint-plugin-svelte-3.5.1.tgz", + "integrity": "sha512-Qn1slddZHfqYiDO6IN8/iN3YL+VuHlgYjm30FT+hh0Jf/TX0jeZMTJXQMajFm5f6f6hURi+XO8P+NPYD+T4jkg==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.4.1", "@jridgewell/sourcemap-codec": "^1.5.0", - "eslint-compat-utils": "^0.6.4", "esutils": "^2.0.3", "known-css-properties": "^0.35.0", "postcss": "^8.4.49", @@ -4611,9 +4594,9 @@ } }, "node_modules/esrap": { - "version": "1.4.3", - "resolved": "https://registry.npmjs.org/esrap/-/esrap-1.4.3.tgz", - "integrity": "sha512-Xddc1RsoFJ4z9nR7W7BFaEPIp4UXoeQ0+077UdWLxbafMQFyU79sQJMk7kxNgRwQ9/aVgaKacCHC2pUACGwmYw==", + "version": "1.4.6", + "resolved": "https://registry.npmjs.org/esrap/-/esrap-1.4.6.tgz", + "integrity": "sha512-F/D2mADJ9SHY3IwksD4DAXjTt7qt7GWUf3/8RhCNWmC/67tyb55dpimHmy7EplakFaflV0R/PC+fdSPqrRHAQw==", "license": "MIT", "dependencies": { "@jridgewell/sourcemap-codec": "^1.4.15" @@ -4687,9 +4670,9 @@ } }, "node_modules/fabric": { - "version": "6.6.1", - "resolved": "https://registry.npmjs.org/fabric/-/fabric-6.6.1.tgz", - "integrity": "sha512-QrQkx6I7daFL/WdkrE8VOEiAr/ffLK36NQ0t/vNZt8P7QIXPpjT4HegjOatUW1G6vYlulX4pI1P/5NeqIgsDig==", + "version": "6.6.2", + "resolved": "https://registry.npmjs.org/fabric/-/fabric-6.6.2.tgz", + "integrity": "sha512-Mu8ETBfCl829NctOcroAkJT/t/1UWA29bmBPvqVbDtX0uiWFQD63Hk156fW9tn35PZe/kJYeap+bvVq33jEQJw==", "license": "MIT", "engines": { "node": ">=16.20.0" @@ -8272,9 +8255,9 @@ } }, "node_modules/svelte": { - "version": "5.25.5", - "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.25.5.tgz", - "integrity": "sha512-ULi9rkVWQJyJYZSpy6SIgSTchWadyWG1QYAUx3JAXL2gXrnhdXtoB20KmXGSNdtNyquq3eYd/gkwAkLcL5PGWw==", + "version": "5.25.6", + "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.25.6.tgz", + "integrity": "sha512-RGkaeAXDuJdvhA1fdSM5GgD++vYfJYijZL0uN6kM2s/TRJ663jktBhZlF0qjzAJGR/34PtaeT3G8MKJY1EKeqg==", "license": "MIT", "dependencies": { "@ampproject/remapping": "^2.3.0", @@ -8286,7 +8269,7 @@ "axobject-query": "^4.1.0", "clsx": "^2.1.1", "esm-env": "^1.2.1", - "esrap": "^1.4.3", + "esrap": "^1.4.6", "is-reference": "^3.0.3", "locate-character": "^3.0.0", "magic-string": "^0.30.11", diff --git a/web/src/lib/components/photos-page/asset-grid.svelte b/web/src/lib/components/photos-page/asset-grid.svelte index 7f716e70ef..40424749e0 100644 --- a/web/src/lib/components/photos-page/asset-grid.svelte +++ b/web/src/lib/components/photos-page/asset-grid.svelte @@ -115,10 +115,7 @@ }; beforeNavigate(() => (assetStore.suspendTransitions = true)); afterNavigate((nav) => { - const { complete, type } = nav; - if (type === 'enter') { - return; - } + const { complete } = nav; complete.then(completeNav, completeNav); }); From 206545356dfbbb58ec6ae95e9ce83648ea648340 Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Wed, 9 Apr 2025 11:45:30 -0400 Subject: [PATCH 50/52] refactor: metadata entity (#17492) --- server/src/database.ts | 5 +- server/src/dtos/activity.dto.ts | 3 +- server/src/dtos/user-preferences.dto.ts | 2 +- server/src/dtos/user.dto.ts | 7 +- server/src/entities/user-metadata.entity.ts | 110 ------------------ server/src/entities/user.entity.ts | 4 +- server/src/repositories/user.repository.ts | 2 +- .../src/schema/tables/user-metadata.table.ts | 2 +- server/src/services/auth.service.spec.ts | 4 +- .../src/services/notification.service.spec.ts | 16 --- server/src/services/user.service.ts | 5 +- server/src/types.ts | 53 +++++++++ server/src/utils/preferences.ts | 54 ++++++++- server/test/fixtures/user.stub.ts | 1 - 14 files changed, 120 insertions(+), 148 deletions(-) delete mode 100644 server/src/entities/user-metadata.entity.ts diff --git a/server/src/database.ts b/server/src/database.ts index 33a877102f..45e7cad490 100644 --- a/server/src/database.ts +++ b/server/src/database.ts @@ -1,6 +1,5 @@ -import { UserMetadataEntity } from 'src/entities/user-metadata.entity'; import { AssetStatus, AssetType, MemoryType, Permission, UserStatus } from 'src/enum'; -import { OnThisDayData } from 'src/types'; +import { OnThisDayData, UserMetadataItem } from 'src/types'; export type AuthUser = { id: string; @@ -96,7 +95,7 @@ export type UserAdmin = User & { quotaSizeInBytes: number | null; quotaUsageInBytes: number; status: UserStatus; - metadata: UserMetadataEntity[]; + metadata: UserMetadataItem[]; }; export type Asset = { diff --git a/server/src/dtos/activity.dto.ts b/server/src/dtos/activity.dto.ts index 98216147b7..a97116cf35 100644 --- a/server/src/dtos/activity.dto.ts +++ b/server/src/dtos/activity.dto.ts @@ -2,7 +2,6 @@ import { ApiProperty } from '@nestjs/swagger'; import { IsEnum, IsNotEmpty, IsString, ValidateIf } from 'class-validator'; import { Activity } from 'src/database'; import { mapUser, UserResponseDto } from 'src/dtos/user.dto'; -import { UserEntity } from 'src/entities/user.entity'; import { Optional, ValidateUUID } from 'src/validation'; export enum ReactionType { @@ -75,6 +74,6 @@ export const mapActivity = (activity: Activity): ActivityResponseDto => { createdAt: activity.createdAt, comment: activity.comment, type: activity.isLiked ? ReactionType.LIKE : ReactionType.COMMENT, - user: mapUser(activity.user as unknown as UserEntity), + user: mapUser(activity.user), }; }; diff --git a/server/src/dtos/user-preferences.dto.ts b/server/src/dtos/user-preferences.dto.ts index 5a393a2d71..fe92838fdb 100644 --- a/server/src/dtos/user-preferences.dto.ts +++ b/server/src/dtos/user-preferences.dto.ts @@ -1,8 +1,8 @@ import { ApiProperty } from '@nestjs/swagger'; import { Type } from 'class-transformer'; import { IsDateString, IsEnum, IsInt, IsPositive, ValidateNested } from 'class-validator'; -import { UserPreferences } from 'src/entities/user-metadata.entity'; import { UserAvatarColor } from 'src/enum'; +import { UserPreferences } from 'src/types'; import { Optional, ValidateBoolean } from 'src/validation'; class AvatarUpdate { diff --git a/server/src/dtos/user.dto.ts b/server/src/dtos/user.dto.ts index afcd13f0e9..851d4d3921 100644 --- a/server/src/dtos/user.dto.ts +++ b/server/src/dtos/user.dto.ts @@ -2,9 +2,9 @@ import { ApiProperty } from '@nestjs/swagger'; import { Transform } from 'class-transformer'; import { IsBoolean, IsEmail, IsNotEmpty, IsNumber, IsString, Min } from 'class-validator'; import { User, UserAdmin } from 'src/database'; -import { UserMetadataEntity, UserMetadataItem } from 'src/entities/user-metadata.entity'; import { UserEntity } from 'src/entities/user.entity'; import { UserAvatarColor, UserMetadataKey, UserStatus } from 'src/enum'; +import { UserMetadataItem } from 'src/types'; import { getPreferences } from 'src/utils/preferences'; import { Optional, ValidateBoolean, toEmail, toSanitized } from 'src/validation'; @@ -143,8 +143,9 @@ export class UserAdminResponseDto extends UserResponseDto { } export function mapUserAdmin(entity: UserEntity | UserAdmin): UserAdminResponseDto { - const license = (entity.metadata as UserMetadataItem[])?.find( - (item): item is UserMetadataEntity => item.key === UserMetadataKey.LICENSE, + const metadata = entity.metadata || []; + const license = metadata.find( + (item): item is UserMetadataItem => item.key === UserMetadataKey.LICENSE, )?.value; return { ...mapUser(entity), diff --git a/server/src/entities/user-metadata.entity.ts b/server/src/entities/user-metadata.entity.ts deleted file mode 100644 index 065f4deac3..0000000000 --- a/server/src/entities/user-metadata.entity.ts +++ /dev/null @@ -1,110 +0,0 @@ -import { UserEntity } from 'src/entities/user.entity'; -import { UserAvatarColor, UserMetadataKey } from 'src/enum'; -import { DeepPartial } from 'src/types'; -import { HumanReadableSize } from 'src/utils/bytes'; - -export type UserMetadataItem = { - key: T; - value: UserMetadata[T]; -}; - -export class UserMetadataEntity implements UserMetadataItem { - userId!: string; - user?: UserEntity; - key!: T; - value!: UserMetadata[T]; -} - -export interface UserPreferences { - folders: { - enabled: boolean; - sidebarWeb: boolean; - }; - memories: { - enabled: boolean; - }; - people: { - enabled: boolean; - sidebarWeb: boolean; - }; - ratings: { - enabled: boolean; - }; - sharedLinks: { - enabled: boolean; - sidebarWeb: boolean; - }; - tags: { - enabled: boolean; - sidebarWeb: boolean; - }; - avatar: { - color: UserAvatarColor; - }; - emailNotifications: { - enabled: boolean; - albumInvite: boolean; - albumUpdate: boolean; - }; - download: { - archiveSize: number; - includeEmbeddedVideos: boolean; - }; - purchase: { - showSupportBadge: boolean; - hideBuyButtonUntil: string; - }; -} - -export const getDefaultPreferences = (user: { email: string }): UserPreferences => { - const values = Object.values(UserAvatarColor); - const randomIndex = Math.floor( - [...user.email].map((letter) => letter.codePointAt(0) ?? 0).reduce((a, b) => a + b, 0) % values.length, - ); - - return { - folders: { - enabled: false, - sidebarWeb: false, - }, - memories: { - enabled: true, - }, - people: { - enabled: true, - sidebarWeb: false, - }, - sharedLinks: { - enabled: true, - sidebarWeb: false, - }, - ratings: { - enabled: false, - }, - tags: { - enabled: false, - sidebarWeb: false, - }, - avatar: { - color: values[randomIndex], - }, - emailNotifications: { - enabled: true, - albumInvite: true, - albumUpdate: true, - }, - download: { - archiveSize: HumanReadableSize.GiB * 4, - includeEmbeddedVideos: false, - }, - purchase: { - showSupportBadge: true, - hideBuyButtonUntil: new Date(2022, 1, 12).toISOString(), - }, - }; -}; - -export interface UserMetadata extends Record> { - [UserMetadataKey.PREFERENCES]: DeepPartial; - [UserMetadataKey.LICENSE]: { licenseKey: string; activationKey: string; activatedAt: string }; -} diff --git a/server/src/entities/user.entity.ts b/server/src/entities/user.entity.ts index 5035f96274..96c574c83d 100644 --- a/server/src/entities/user.entity.ts +++ b/server/src/entities/user.entity.ts @@ -2,8 +2,8 @@ import { ExpressionBuilder } from 'kysely'; import { jsonArrayFrom } from 'kysely/helpers/postgres'; import { DB } from 'src/db'; import { AssetEntity } from 'src/entities/asset.entity'; -import { UserMetadataEntity } from 'src/entities/user-metadata.entity'; import { UserStatus } from 'src/enum'; +import { UserMetadataItem } from 'src/types'; export class UserEntity { id!: string; @@ -23,7 +23,7 @@ export class UserEntity { assets!: AssetEntity[]; quotaSizeInBytes!: number | null; quotaUsageInBytes!: number; - metadata!: UserMetadataEntity[]; + metadata!: UserMetadataItem[]; profileChangedAt!: Date; } diff --git a/server/src/repositories/user.repository.ts b/server/src/repositories/user.repository.ts index c254085fd2..5912f60687 100644 --- a/server/src/repositories/user.repository.ts +++ b/server/src/repositories/user.repository.ts @@ -5,10 +5,10 @@ import { InjectKysely } from 'nestjs-kysely'; import { columns, UserAdmin } from 'src/database'; import { DB, UserMetadata as DbUserMetadata } from 'src/db'; import { DummyValue, GenerateSql } from 'src/decorators'; -import { UserMetadata, UserMetadataItem } from 'src/entities/user-metadata.entity'; import { UserEntity, withMetadata } from 'src/entities/user.entity'; import { AssetType, UserStatus } from 'src/enum'; import { UserTable } from 'src/schema/tables/user.table'; +import { UserMetadata, UserMetadataItem } from 'src/types'; import { asUuid } from 'src/utils/database'; type Upsert = Insertable; diff --git a/server/src/schema/tables/user-metadata.table.ts b/server/src/schema/tables/user-metadata.table.ts index e71b3bf9f9..6d03acaf80 100644 --- a/server/src/schema/tables/user-metadata.table.ts +++ b/server/src/schema/tables/user-metadata.table.ts @@ -1,7 +1,7 @@ -import { UserMetadata, UserMetadataItem } from 'src/entities/user-metadata.entity'; import { UserMetadataKey } from 'src/enum'; import { UserTable } from 'src/schema/tables/user.table'; import { Column, ForeignKeyColumn, PrimaryColumn, Table } from 'src/sql-tools'; +import { UserMetadata, UserMetadataItem } from 'src/types'; @Table('user_metadata') export class UserMetadataTable implements UserMetadataItem { diff --git a/server/src/services/auth.service.spec.ts b/server/src/services/auth.service.spec.ts index 0c5ad3099d..3c8bfa7d95 100644 --- a/server/src/services/auth.service.spec.ts +++ b/server/src/services/auth.service.spec.ts @@ -1,10 +1,10 @@ import { BadRequestException, ForbiddenException, UnauthorizedException } from '@nestjs/common'; import { DateTime } from 'luxon'; import { AuthDto, SignUpDto } from 'src/dtos/auth.dto'; -import { UserMetadataEntity } from 'src/entities/user-metadata.entity'; import { UserEntity } from 'src/entities/user.entity'; import { AuthType, Permission } from 'src/enum'; import { AuthService } from 'src/services/auth.service'; +import { UserMetadataItem } from 'src/types'; import { sharedLinkStub } from 'test/fixtures/shared-link.stub'; import { systemConfigStub } from 'test/fixtures/system-config.stub'; import { userStub } from 'test/fixtures/user.stub'; @@ -230,7 +230,7 @@ describe('AuthService', () => { ...dto, id: 'admin', createdAt: new Date('2021-01-01'), - metadata: [] as UserMetadataEntity[], + metadata: [] as UserMetadataItem[], } as UserEntity); await expect(sut.adminSignUp(dto)).resolves.toMatchObject({ diff --git a/server/src/services/notification.service.spec.ts b/server/src/services/notification.service.spec.ts index 89f211b297..823f1614ea 100644 --- a/server/src/services/notification.service.spec.ts +++ b/server/src/services/notification.service.spec.ts @@ -357,8 +357,6 @@ describe(NotificationService.name, () => { { key: UserMetadataKey.PREFERENCES, value: { emailNotifications: { enabled: false, albumInvite: true } }, - userId: userStub.user1.id, - user: userStub.user1, }, ], }); @@ -374,8 +372,6 @@ describe(NotificationService.name, () => { { key: UserMetadataKey.PREFERENCES, value: { emailNotifications: { enabled: true, albumInvite: false } }, - userId: userStub.user1.id, - user: userStub.user1, }, ], }); @@ -391,8 +387,6 @@ describe(NotificationService.name, () => { { key: UserMetadataKey.PREFERENCES, value: { emailNotifications: { enabled: true, albumInvite: true } }, - userId: userStub.user1.id, - user: userStub.user1, }, ], }); @@ -414,8 +408,6 @@ describe(NotificationService.name, () => { { key: UserMetadataKey.PREFERENCES, value: { emailNotifications: { enabled: true, albumInvite: true } }, - userId: userStub.user1.id, - user: userStub.user1, }, ], }); @@ -443,8 +435,6 @@ describe(NotificationService.name, () => { { key: UserMetadataKey.PREFERENCES, value: { emailNotifications: { enabled: true, albumInvite: true } }, - userId: userStub.user1.id, - user: userStub.user1, }, ], }); @@ -476,8 +466,6 @@ describe(NotificationService.name, () => { { key: UserMetadataKey.PREFERENCES, value: { emailNotifications: { enabled: true, albumInvite: true } }, - userId: userStub.user1.id, - user: userStub.user1, }, ], }); @@ -536,8 +524,6 @@ describe(NotificationService.name, () => { { key: UserMetadataKey.PREFERENCES, value: { emailNotifications: { enabled: false, albumUpdate: true } }, - user: userStub.user1, - userId: userStub.user1.id, }, ], }); @@ -559,8 +545,6 @@ describe(NotificationService.name, () => { { key: UserMetadataKey.PREFERENCES, value: { emailNotifications: { enabled: true, albumUpdate: false } }, - user: userStub.user1, - userId: userStub.user1.id, }, ], }); diff --git a/server/src/services/user.service.ts b/server/src/services/user.service.ts index ef06b6f4b1..d1859ed419 100644 --- a/server/src/services/user.service.ts +++ b/server/src/services/user.service.ts @@ -8,12 +8,11 @@ import { LicenseKeyDto, LicenseResponseDto } from 'src/dtos/license.dto'; import { UserPreferencesResponseDto, UserPreferencesUpdateDto, mapPreferences } from 'src/dtos/user-preferences.dto'; import { CreateProfileImageResponseDto } from 'src/dtos/user-profile.dto'; import { UserAdminResponseDto, UserResponseDto, UserUpdateMeDto, mapUser, mapUserAdmin } from 'src/dtos/user.dto'; -import { UserMetadataEntity } from 'src/entities/user-metadata.entity'; import { UserEntity } from 'src/entities/user.entity'; import { CacheControl, JobName, JobStatus, QueueName, StorageFolder, UserMetadataKey } from 'src/enum'; import { UserFindOptions } from 'src/repositories/user.repository'; import { BaseService } from 'src/services/base.service'; -import { JobOf } from 'src/types'; +import { JobOf, UserMetadataItem } from 'src/types'; import { ImmichFileResponse } from 'src/utils/file'; import { getPreferences, getPreferencesPartial, mergePreferences } from 'src/utils/preferences'; @@ -135,7 +134,7 @@ export class UserService extends BaseService { const metadata = await this.userRepository.getMetadata(auth.user.id); const license = metadata.find( - (item): item is UserMetadataEntity => item.key === UserMetadataKey.LICENSE, + (item): item is UserMetadataItem => item.key === UserMetadataKey.LICENSE, ); if (!license) { throw new NotFoundException(); diff --git a/server/src/types.ts b/server/src/types.ts index 68207c4b9e..88ba644739 100644 --- a/server/src/types.ts +++ b/server/src/types.ts @@ -11,6 +11,8 @@ import { SyncEntityType, SystemMetadataKey, TranscodeTarget, + UserAvatarColor, + UserMetadataKey, VideoCodec, } from 'src/enum'; @@ -455,3 +457,54 @@ export interface SystemMetadata extends Record = { + key: T; + value: UserMetadata[T]; +}; + +export interface UserPreferences { + folders: { + enabled: boolean; + sidebarWeb: boolean; + }; + memories: { + enabled: boolean; + }; + people: { + enabled: boolean; + sidebarWeb: boolean; + }; + ratings: { + enabled: boolean; + }; + sharedLinks: { + enabled: boolean; + sidebarWeb: boolean; + }; + tags: { + enabled: boolean; + sidebarWeb: boolean; + }; + avatar: { + color: UserAvatarColor; + }; + emailNotifications: { + enabled: boolean; + albumInvite: boolean; + albumUpdate: boolean; + }; + download: { + archiveSize: number; + includeEmbeddedVideos: boolean; + }; + purchase: { + showSupportBadge: boolean; + hideBuyButtonUntil: string; + }; +} + +export interface UserMetadata extends Record> { + [UserMetadataKey.PREFERENCES]: DeepPartial; + [UserMetadataKey.LICENSE]: { licenseKey: string; activationKey: string; activatedAt: string }; +} diff --git a/server/src/utils/preferences.ts b/server/src/utils/preferences.ts index 14e61f1919..584c5300cd 100644 --- a/server/src/utils/preferences.ts +++ b/server/src/utils/preferences.ts @@ -1,10 +1,58 @@ import _ from 'lodash'; import { UserPreferencesUpdateDto } from 'src/dtos/user-preferences.dto'; -import { UserMetadataItem, UserPreferences, getDefaultPreferences } from 'src/entities/user-metadata.entity'; -import { UserMetadataKey } from 'src/enum'; -import { DeepPartial } from 'src/types'; +import { UserAvatarColor, UserMetadataKey } from 'src/enum'; +import { DeepPartial, UserMetadataItem, UserPreferences } from 'src/types'; +import { HumanReadableSize } from 'src/utils/bytes'; import { getKeysDeep } from 'src/utils/misc'; +const getDefaultPreferences = (user: { email: string }): UserPreferences => { + const values = Object.values(UserAvatarColor); + const randomIndex = Math.floor( + [...user.email].map((letter) => letter.codePointAt(0) ?? 0).reduce((a, b) => a + b, 0) % values.length, + ); + + return { + folders: { + enabled: false, + sidebarWeb: false, + }, + memories: { + enabled: true, + }, + people: { + enabled: true, + sidebarWeb: false, + }, + sharedLinks: { + enabled: true, + sidebarWeb: false, + }, + ratings: { + enabled: false, + }, + tags: { + enabled: false, + sidebarWeb: false, + }, + avatar: { + color: values[randomIndex], + }, + emailNotifications: { + enabled: true, + albumInvite: true, + albumUpdate: true, + }, + download: { + archiveSize: HumanReadableSize.GiB * 4, + includeEmbeddedVideos: false, + }, + purchase: { + showSupportBadge: true, + hideBuyButtonUntil: new Date(2022, 1, 12).toISOString(), + }, + }; +}; + export const getPreferences = (email: string, metadata: UserMetadataItem[]): UserPreferences => { const preferences = getDefaultPreferences({ email }); const item = metadata.find(({ key }) => key === UserMetadataKey.PREFERENCES); diff --git a/server/test/fixtures/user.stub.ts b/server/test/fixtures/user.stub.ts index 0ed1502fb9..844b8c61b9 100644 --- a/server/test/fixtures/user.stub.ts +++ b/server/test/fixtures/user.stub.ts @@ -38,7 +38,6 @@ export const userStub = { assets: [], metadata: [ { - userId: authStub.user1.user.id, key: UserMetadataKey.PREFERENCES, value: { avatar: { color: UserAvatarColor.PRIMARY } }, }, From d03647904be5376026f45a4e523fc1ad27f22271 Mon Sep 17 00:00:00 2001 From: Zack Pollard Date: Wed, 9 Apr 2025 16:54:20 +0100 Subject: [PATCH 51/52] refactor: remove move entity (#17489) --- server/src/entities/move.entity.ts | 9 -------- server/src/repositories/move.repository.ts | 27 +++++++--------------- 2 files changed, 8 insertions(+), 28 deletions(-) delete mode 100644 server/src/entities/move.entity.ts diff --git a/server/src/entities/move.entity.ts b/server/src/entities/move.entity.ts deleted file mode 100644 index 0570d98edc..0000000000 --- a/server/src/entities/move.entity.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { PathType } from 'src/enum'; - -export class MoveEntity { - id!: string; - entityId!: string; - pathType!: PathType; - oldPath!: string; - newPath!: string; -} diff --git a/server/src/repositories/move.repository.ts b/server/src/repositories/move.repository.ts index 706e23cef7..21c52aec65 100644 --- a/server/src/repositories/move.repository.ts +++ b/server/src/repositories/move.repository.ts @@ -3,49 +3,38 @@ import { Insertable, Kysely, sql, Updateable } from 'kysely'; import { InjectKysely } from 'nestjs-kysely'; import { DB, MoveHistory } from 'src/db'; import { DummyValue, GenerateSql } from 'src/decorators'; -import { MoveEntity } from 'src/entities/move.entity'; import { AssetPathType, PathType } from 'src/enum'; -export type MoveCreate = Pick & Partial; - @Injectable() export class MoveRepository { constructor(@InjectKysely() private db: Kysely) {} - create(entity: Insertable): Promise { - return this.db - .insertInto('move_history') - .values(entity) - .returningAll() - .executeTakeFirstOrThrow() as Promise; + create(entity: Insertable) { + return this.db.insertInto('move_history').values(entity).returningAll().executeTakeFirstOrThrow(); } @GenerateSql({ params: [DummyValue.UUID, DummyValue.STRING] }) - getByEntity(entityId: string, pathType: PathType): Promise { + getByEntity(entityId: string, pathType: PathType) { return this.db .selectFrom('move_history') .selectAll() .where('entityId', '=', entityId) .where('pathType', '=', pathType) - .executeTakeFirst() as Promise; + .executeTakeFirst(); } - update(id: string, entity: Updateable): Promise { + update(id: string, entity: Updateable) { return this.db .updateTable('move_history') .set(entity) .where('id', '=', id) .returningAll() - .executeTakeFirstOrThrow() as unknown as Promise; + .executeTakeFirstOrThrow(); } @GenerateSql({ params: [DummyValue.UUID] }) - delete(id: string): Promise { - return this.db - .deleteFrom('move_history') - .where('id', '=', id) - .returningAll() - .executeTakeFirstOrThrow() as unknown as Promise; + delete(id: string) { + return this.db.deleteFrom('move_history').where('id', '=', id).returningAll().executeTakeFirstOrThrow(); } @GenerateSql() From 43e3075f9349c4bcd60f0b44c5b7f6da1ad0374c Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 9 Apr 2025 16:20:11 +0000 Subject: [PATCH 52/52] fix(deps): update machine-learning (#17455) --- machine-learning/Dockerfile | 8 +- machine-learning/uv.lock | 212 ++++++++++++++++++------------------ 2 files changed, 110 insertions(+), 110 deletions(-) diff --git a/machine-learning/Dockerfile b/machine-learning/Dockerfile index bde73028f8..25f3c44d9e 100644 --- a/machine-learning/Dockerfile +++ b/machine-learning/Dockerfile @@ -1,6 +1,6 @@ ARG DEVICE=cpu -FROM python:3.11-bookworm@sha256:ebfa8696e47a68cffebb31e370a93ce57c01bc753f246ceaaef72801d1661351 AS builder-cpu +FROM python:3.11-bookworm@sha256:0a9d314ae6e976351bd37b702bf6b0a89bb58e6304e5df35b960059b12531419 AS builder-cpu FROM builder-cpu AS builder-openvino @@ -54,7 +54,7 @@ ENV PYTHONDONTWRITEBYTECODE=1 \ RUN apt-get update && apt-get install -y --no-install-recommends g++ -COPY --from=ghcr.io/astral-sh/uv:latest@sha256:fb91e82e8643382d5bce074ba0d167677d678faff4bd518dac670476d19b159c /uv /uvx /bin/ +COPY --from=ghcr.io/astral-sh/uv:latest@sha256:0b6dc79013b689f3bc0cbf12807cb1c901beaafe80f2ee10a1d76aa3842afb92 /uv /uvx /bin/ RUN --mount=type=cache,target=/root/.cache/uv \ --mount=type=bind,source=uv.lock,target=uv.lock \ --mount=type=bind,source=pyproject.toml,target=pyproject.toml \ @@ -63,11 +63,11 @@ RUN if [ "$DEVICE" = "rocm" ]; then \ uv pip install /opt/onnxruntime_rocm-*.whl; \ fi -FROM python:3.11-slim-bookworm@sha256:7029b00486ac40bed03e36775b864d3f3d39dcbdf19cd45e6a52d541e6c178f0 AS prod-cpu +FROM python:3.11-slim-bookworm@sha256:49d73c49616929b0a4f37c50fee0056eb4b0f15de624591e8d9bf84b4dfdd3ce AS prod-cpu ENV LD_PRELOAD=/usr/lib/libmimalloc.so.2 -FROM python:3.11-slim-bookworm@sha256:7029b00486ac40bed03e36775b864d3f3d39dcbdf19cd45e6a52d541e6c178f0 AS prod-openvino +FROM python:3.11-slim-bookworm@sha256:49d73c49616929b0a4f37c50fee0056eb4b0f15de624591e8d9bf84b4dfdd3ce AS prod-openvino RUN apt-get update && \ apt-get install --no-install-recommends -yqq ocl-icd-libopencl1 wget && \ diff --git a/machine-learning/uv.lock b/machine-learning/uv.lock index 0894c74ecf..339e874cc9 100644 --- a/machine-learning/uv.lock +++ b/machine-learning/uv.lock @@ -876,7 +876,7 @@ wheels = [ [[package]] name = "huggingface-hub" -version = "0.29.3" +version = "0.30.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "filelock" }, @@ -887,9 +887,9 @@ dependencies = [ { name = "tqdm" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/e5/f9/851f34b02970e8143d41d4001b2d49e54ef113f273902103823b8bc95ada/huggingface_hub-0.29.3.tar.gz", hash = "sha256:64519a25716e0ba382ba2d3fb3ca082e7c7eb4a2fc634d200e8380006e0760e5", size = 390123 } +sdist = { url = "https://files.pythonhosted.org/packages/df/22/8eb91736b1dcb83d879bd49050a09df29a57cc5cd9f38e48a4b1c45ee890/huggingface_hub-0.30.2.tar.gz", hash = "sha256:9a7897c5b6fd9dad3168a794a8998d6378210f5b9688d0dfc180b1a228dc2466", size = 400868 } wheels = [ - { url = "https://files.pythonhosted.org/packages/40/0c/37d380846a2e5c9a3c6a73d26ffbcfdcad5fc3eacf42fdf7cff56f2af634/huggingface_hub-0.29.3-py3-none-any.whl", hash = "sha256:0b25710932ac649c08cdbefa6c6ccb8e88eef82927cacdb048efb726429453aa", size = 468997 }, + { url = "https://files.pythonhosted.org/packages/93/27/1fb384a841e9661faad1c31cbfa62864f59632e876df5d795234da51c395/huggingface_hub-0.30.2-py3-none-any.whl", hash = "sha256:68ff05969927058cfa41df4f2155d4bb48f5f54f719dd0390103eefa9b191e28", size = 481433 }, ] [[package]] @@ -1789,7 +1789,7 @@ wheels = [ [[package]] name = "pydantic" -version = "2.11.1" +version = "2.11.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "annotated-types" }, @@ -1797,96 +1797,96 @@ dependencies = [ { name = "typing-extensions" }, { name = "typing-inspection" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/93/a3/698b87a4d4d303d7c5f62ea5fbf7a79cab236ccfbd0a17847b7f77f8163e/pydantic-2.11.1.tar.gz", hash = "sha256:442557d2910e75c991c39f4b4ab18963d57b9b55122c8b2a9cd176d8c29ce968", size = 782817 } +sdist = { url = "https://files.pythonhosted.org/packages/10/2e/ca897f093ee6c5f3b0bee123ee4465c50e75431c3d5b6a3b44a47134e891/pydantic-2.11.3.tar.gz", hash = "sha256:7471657138c16adad9322fe3070c0116dd6c3ad8d649300e3cbdfe91f4db4ec3", size = 785513 } wheels = [ - { url = "https://files.pythonhosted.org/packages/cc/12/f9221a949f2419e2e23847303c002476c26fbcfd62dc7f3d25d0bec5ca99/pydantic-2.11.1-py3-none-any.whl", hash = "sha256:5b6c415eee9f8123a14d859be0c84363fec6b1feb6b688d6435801230b56e0b8", size = 442648 }, + { url = "https://files.pythonhosted.org/packages/b0/1d/407b29780a289868ed696d1616f4aad49d6388e5a77f567dcd2629dcd7b8/pydantic-2.11.3-py3-none-any.whl", hash = "sha256:a082753436a07f9ba1289c6ffa01cd93db3548776088aa917cc43b63f68fa60f", size = 443591 }, ] [[package]] name = "pydantic-core" -version = "2.33.0" +version = "2.33.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/b9/05/91ce14dfd5a3a99555fce436318cc0fd1f08c4daa32b3248ad63669ea8b4/pydantic_core-2.33.0.tar.gz", hash = "sha256:40eb8af662ba409c3cbf4a8150ad32ae73514cd7cb1f1a2113af39763dd616b3", size = 434080 } +sdist = { url = "https://files.pythonhosted.org/packages/17/19/ed6a078a5287aea7922de6841ef4c06157931622c89c2a47940837b5eecd/pydantic_core-2.33.1.tar.gz", hash = "sha256:bcc9c6fdb0ced789245b02b7d6603e17d1563064ddcfc36f046b61c0c05dd9df", size = 434395 } wheels = [ - { url = "https://files.pythonhosted.org/packages/29/43/0649ad07e66b36a3fb21442b425bd0348ac162c5e686b36471f363201535/pydantic_core-2.33.0-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:71dffba8fe9ddff628c68f3abd845e91b028361d43c5f8e7b3f8b91d7d85413e", size = 2042968 }, - { url = "https://files.pythonhosted.org/packages/a0/a6/975fea4774a459e495cb4be288efd8b041ac756a0a763f0b976d0861334b/pydantic_core-2.33.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:abaeec1be6ed535a5d7ffc2e6c390083c425832b20efd621562fbb5bff6dc518", size = 1860347 }, - { url = "https://files.pythonhosted.org/packages/aa/49/7858dadad305101a077ec4d0c606b6425a2b134ea8d858458a6d287fd871/pydantic_core-2.33.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:759871f00e26ad3709efc773ac37b4d571de065f9dfb1778012908bcc36b3a73", size = 1910060 }, - { url = "https://files.pythonhosted.org/packages/8d/4f/6522527911d9c5fe6d76b084d8b388d5c84b09d113247b39f91937500b34/pydantic_core-2.33.0-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:dcfebee69cd5e1c0b76a17e17e347c84b00acebb8dd8edb22d4a03e88e82a207", size = 1997129 }, - { url = "https://files.pythonhosted.org/packages/75/d0/06f396da053e3d73001ea4787e56b4d7132a87c0b5e2e15a041e808c35cd/pydantic_core-2.33.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1b1262b912435a501fa04cd213720609e2cefa723a07c92017d18693e69bf00b", size = 2140389 }, - { url = "https://files.pythonhosted.org/packages/f5/6b/b9ff5b69cd4ef007cf665463f3be2e481dc7eb26c4a55b2f57a94308c31a/pydantic_core-2.33.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4726f1f3f42d6a25678c67da3f0b10f148f5655813c5aca54b0d1742ba821b8f", size = 2754237 }, - { url = "https://files.pythonhosted.org/packages/53/80/b4879de375cdf3718d05fcb60c9aa1f119d28e261dafa51b6a69c78f7178/pydantic_core-2.33.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e790954b5093dff1e3a9a2523fddc4e79722d6f07993b4cd5547825c3cbf97b5", size = 2007433 }, - { url = "https://files.pythonhosted.org/packages/46/24/54054713dc0af98a94eab37e0f4294dfd5cd8f70b2ca9dcdccd15709fd7e/pydantic_core-2.33.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:34e7fb3abe375b5c4e64fab75733d605dda0f59827752debc99c17cb2d5f3276", size = 2123980 }, - { url = "https://files.pythonhosted.org/packages/3a/4c/257c1cb89e14cfa6e95ebcb91b308eb1dd2b348340ff76a6e6fcfa9969e1/pydantic_core-2.33.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:ecb158fb9b9091b515213bed3061eb7deb1d3b4e02327c27a0ea714ff46b0760", size = 2087433 }, - { url = "https://files.pythonhosted.org/packages/0c/62/927df8a39ad78ef7b82c5446e01dec9bb0043e1ad71d8f426062f5f014db/pydantic_core-2.33.0-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:4d9149e7528af8bbd76cc055967e6e04617dcb2a2afdaa3dea899406c5521faa", size = 2260242 }, - { url = "https://files.pythonhosted.org/packages/74/f2/389414f7c77a100954e84d6f52a82bd1788ae69db72364376d8a73b38765/pydantic_core-2.33.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:e81a295adccf73477220e15ff79235ca9dcbcee4be459eb9d4ce9a2763b8386c", size = 2258227 }, - { url = "https://files.pythonhosted.org/packages/53/99/94516313e15d906a1264bb40faf24a01a4af4e2ca8a7c10dd173b6513c5a/pydantic_core-2.33.0-cp310-cp310-win32.whl", hash = "sha256:f22dab23cdbce2005f26a8f0c71698457861f97fc6318c75814a50c75e87d025", size = 1925523 }, - { url = "https://files.pythonhosted.org/packages/7d/67/cc789611c6035a0b71305a1ec6ba196256ced76eba8375f316f840a70456/pydantic_core-2.33.0-cp310-cp310-win_amd64.whl", hash = "sha256:9cb2390355ba084c1ad49485d18449b4242da344dea3e0fe10babd1f0db7dcfc", size = 1951872 }, - { url = "https://files.pythonhosted.org/packages/f0/93/9e97af2619b4026596487a79133e425c7d3c374f0a7f100f3d76bcdf9c83/pydantic_core-2.33.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a608a75846804271cf9c83e40bbb4dab2ac614d33c6fd5b0c6187f53f5c593ef", size = 2042784 }, - { url = "https://files.pythonhosted.org/packages/42/b4/0bba8412fd242729feeb80e7152e24f0e1a1c19f4121ca3d4a307f4e6222/pydantic_core-2.33.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e1c69aa459f5609dec2fa0652d495353accf3eda5bdb18782bc5a2ae45c9273a", size = 1858179 }, - { url = "https://files.pythonhosted.org/packages/69/1f/c1c40305d929bd08af863df64b0a26203b70b352a1962d86f3bcd52950fe/pydantic_core-2.33.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b9ec80eb5a5f45a2211793f1c4aeddff0c3761d1c70d684965c1807e923a588b", size = 1909396 }, - { url = "https://files.pythonhosted.org/packages/0f/99/d2e727375c329c1e652b5d450fbb9d56e8c3933a397e4bd46e67c68c2cd5/pydantic_core-2.33.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e925819a98318d17251776bd3d6aa9f3ff77b965762155bdad15d1a9265c4cfd", size = 1998264 }, - { url = "https://files.pythonhosted.org/packages/9c/2e/3119a33931278d96ecc2e9e1b9d50c240636cfeb0c49951746ae34e4de74/pydantic_core-2.33.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5bf68bb859799e9cec3d9dd8323c40c00a254aabb56fe08f907e437005932f2b", size = 2140588 }, - { url = "https://files.pythonhosted.org/packages/35/bd/9267bd1ba55f17c80ef6cb7e07b3890b4acbe8eb6014f3102092d53d9300/pydantic_core-2.33.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1b2ea72dea0825949a045fa4071f6d5b3d7620d2a208335207793cf29c5a182d", size = 2746296 }, - { url = "https://files.pythonhosted.org/packages/6f/ed/ef37de6478a412ee627cbebd73e7b72a680f45bfacce9ff1199de6e17e88/pydantic_core-2.33.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1583539533160186ac546b49f5cde9ffc928062c96920f58bd95de32ffd7bffd", size = 2005555 }, - { url = "https://files.pythonhosted.org/packages/dd/84/72c8d1439585d8ee7bc35eb8f88a04a4d302ee4018871f1f85ae1b0c6625/pydantic_core-2.33.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:23c3e77bf8a7317612e5c26a3b084c7edeb9552d645742a54a5867635b4f2453", size = 2124452 }, - { url = "https://files.pythonhosted.org/packages/a7/8f/cb13de30c6a3e303423751a529a3d1271c2effee4b98cf3e397a66ae8498/pydantic_core-2.33.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:a7a7f2a3f628d2f7ef11cb6188bcf0b9e1558151d511b974dfea10a49afe192b", size = 2087001 }, - { url = "https://files.pythonhosted.org/packages/83/d0/e93dc8884bf288a63fedeb8040ac8f29cb71ca52e755f48e5170bb63e55b/pydantic_core-2.33.0-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:f1fb026c575e16f673c61c7b86144517705865173f3d0907040ac30c4f9f5915", size = 2261663 }, - { url = "https://files.pythonhosted.org/packages/4c/ba/4b7739c95efa0b542ee45fd872c8f6b1884ab808cf04ce7ac6621b6df76e/pydantic_core-2.33.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:635702b2fed997e0ac256b2cfbdb4dd0bf7c56b5d8fba8ef03489c03b3eb40e2", size = 2257786 }, - { url = "https://files.pythonhosted.org/packages/cc/98/73cbca1d2360c27752cfa2fcdcf14d96230e92d7d48ecd50499865c56bf7/pydantic_core-2.33.0-cp311-cp311-win32.whl", hash = "sha256:07b4ced28fccae3f00626eaa0c4001aa9ec140a29501770a88dbbb0966019a86", size = 1925697 }, - { url = "https://files.pythonhosted.org/packages/9a/26/d85a40edeca5d8830ffc33667d6fef329fd0f4bc0c5181b8b0e206cfe488/pydantic_core-2.33.0-cp311-cp311-win_amd64.whl", hash = "sha256:4927564be53239a87770a5f86bdc272b8d1fbb87ab7783ad70255b4ab01aa25b", size = 1949859 }, - { url = "https://files.pythonhosted.org/packages/7e/0b/5a381605f0b9870465b805f2c86c06b0a7c191668ebe4117777306c2c1e5/pydantic_core-2.33.0-cp311-cp311-win_arm64.whl", hash = "sha256:69297418ad644d521ea3e1aa2e14a2a422726167e9ad22b89e8f1130d68e1e9a", size = 1907978 }, - { url = "https://files.pythonhosted.org/packages/a9/c4/c9381323cbdc1bb26d352bc184422ce77c4bc2f2312b782761093a59fafc/pydantic_core-2.33.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:6c32a40712e3662bebe524abe8abb757f2fa2000028d64cc5a1006016c06af43", size = 2025127 }, - { url = "https://files.pythonhosted.org/packages/6f/bd/af35278080716ecab8f57e84515c7dc535ed95d1c7f52c1c6f7b313a9dab/pydantic_core-2.33.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8ec86b5baa36f0a0bfb37db86c7d52652f8e8aa076ab745ef7725784183c3fdd", size = 1851687 }, - { url = "https://files.pythonhosted.org/packages/12/e4/a01461225809c3533c23bd1916b1e8c2e21727f0fea60ab1acbffc4e2fca/pydantic_core-2.33.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4deac83a8cc1d09e40683be0bc6d1fa4cde8df0a9bf0cda5693f9b0569ac01b6", size = 1892232 }, - { url = "https://files.pythonhosted.org/packages/51/17/3d53d62a328fb0a49911c2962036b9e7a4f781b7d15e9093c26299e5f76d/pydantic_core-2.33.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:175ab598fb457a9aee63206a1993874badf3ed9a456e0654273e56f00747bbd6", size = 1977896 }, - { url = "https://files.pythonhosted.org/packages/30/98/01f9d86e02ec4a38f4b02086acf067f2c776b845d43f901bd1ee1c21bc4b/pydantic_core-2.33.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5f36afd0d56a6c42cf4e8465b6441cf546ed69d3a4ec92724cc9c8c61bd6ecf4", size = 2127717 }, - { url = "https://files.pythonhosted.org/packages/3c/43/6f381575c61b7c58b0fd0b92134c5a1897deea4cdfc3d47567b3ff460a4e/pydantic_core-2.33.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0a98257451164666afafc7cbf5fb00d613e33f7e7ebb322fbcd99345695a9a61", size = 2680287 }, - { url = "https://files.pythonhosted.org/packages/01/42/c0d10d1451d161a9a0da9bbef023b8005aa26e9993a8cc24dc9e3aa96c93/pydantic_core-2.33.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ecc6d02d69b54a2eb83ebcc6f29df04957f734bcf309d346b4f83354d8376862", size = 2008276 }, - { url = "https://files.pythonhosted.org/packages/20/ca/e08df9dba546905c70bae44ced9f3bea25432e34448d95618d41968f40b7/pydantic_core-2.33.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1a69b7596c6603afd049ce7f3835bcf57dd3892fc7279f0ddf987bebed8caa5a", size = 2115305 }, - { url = "https://files.pythonhosted.org/packages/03/1f/9b01d990730a98833113581a78e595fd40ed4c20f9693f5a658fb5f91eff/pydantic_core-2.33.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:ea30239c148b6ef41364c6f51d103c2988965b643d62e10b233b5efdca8c0099", size = 2068999 }, - { url = "https://files.pythonhosted.org/packages/20/18/fe752476a709191148e8b1e1139147841ea5d2b22adcde6ee6abb6c8e7cf/pydantic_core-2.33.0-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:abfa44cf2f7f7d7a199be6c6ec141c9024063205545aa09304349781b9a125e6", size = 2241488 }, - { url = "https://files.pythonhosted.org/packages/81/22/14738ad0a0bf484b928c9e52004f5e0b81dd8dabbdf23b843717b37a71d1/pydantic_core-2.33.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:20d4275f3c4659d92048c70797e5fdc396c6e4446caf517ba5cad2db60cd39d3", size = 2248430 }, - { url = "https://files.pythonhosted.org/packages/e8/27/be7571e215ac8d321712f2433c445b03dbcd645366a18f67b334df8912bc/pydantic_core-2.33.0-cp312-cp312-win32.whl", hash = "sha256:918f2013d7eadea1d88d1a35fd4a1e16aaf90343eb446f91cb091ce7f9b431a2", size = 1908353 }, - { url = "https://files.pythonhosted.org/packages/be/3a/be78f28732f93128bd0e3944bdd4b3970b389a1fbd44907c97291c8dcdec/pydantic_core-2.33.0-cp312-cp312-win_amd64.whl", hash = "sha256:aec79acc183865bad120b0190afac467c20b15289050648b876b07777e67ea48", size = 1955956 }, - { url = "https://files.pythonhosted.org/packages/21/26/b8911ac74faa994694b76ee6a22875cc7a4abea3c381fdba4edc6c6bef84/pydantic_core-2.33.0-cp312-cp312-win_arm64.whl", hash = "sha256:5461934e895968655225dfa8b3be79e7e927e95d4bd6c2d40edd2fa7052e71b6", size = 1903259 }, - { url = "https://files.pythonhosted.org/packages/79/20/de2ad03ce8f5b3accf2196ea9b44f31b0cd16ac6e8cfc6b21976ed45ec35/pydantic_core-2.33.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:f00e8b59e1fc8f09d05594aa7d2b726f1b277ca6155fc84c0396db1b373c4555", size = 2032214 }, - { url = "https://files.pythonhosted.org/packages/f9/af/6817dfda9aac4958d8b516cbb94af507eb171c997ea66453d4d162ae8948/pydantic_core-2.33.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:1a73be93ecef45786d7d95b0c5e9b294faf35629d03d5b145b09b81258c7cd6d", size = 1852338 }, - { url = "https://files.pythonhosted.org/packages/44/f3/49193a312d9c49314f2b953fb55740b7c530710977cabe7183b8ef111b7f/pydantic_core-2.33.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ff48a55be9da6930254565ff5238d71d5e9cd8c5487a191cb85df3bdb8c77365", size = 1896913 }, - { url = "https://files.pythonhosted.org/packages/06/e0/c746677825b2e29a2fa02122a8991c83cdd5b4c5f638f0664d4e35edd4b2/pydantic_core-2.33.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:26a4ea04195638dcd8c53dadb545d70badba51735b1594810e9768c2c0b4a5da", size = 1986046 }, - { url = "https://files.pythonhosted.org/packages/11/ec/44914e7ff78cef16afb5e5273d480c136725acd73d894affdbe2a1bbaad5/pydantic_core-2.33.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:41d698dcbe12b60661f0632b543dbb119e6ba088103b364ff65e951610cb7ce0", size = 2128097 }, - { url = "https://files.pythonhosted.org/packages/fe/f5/c6247d424d01f605ed2e3802f338691cae17137cee6484dce9f1ac0b872b/pydantic_core-2.33.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ae62032ef513fe6281ef0009e30838a01057b832dc265da32c10469622613885", size = 2681062 }, - { url = "https://files.pythonhosted.org/packages/f0/85/114a2113b126fdd7cf9a9443b1b1fe1b572e5bd259d50ba9d5d3e1927fa9/pydantic_core-2.33.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f225f3a3995dbbc26affc191d0443c6c4aa71b83358fd4c2b7d63e2f6f0336f9", size = 2007487 }, - { url = "https://files.pythonhosted.org/packages/e6/40/3c05ed28d225c7a9acd2b34c5c8010c279683a870219b97e9f164a5a8af0/pydantic_core-2.33.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5bdd36b362f419c78d09630cbaebc64913f66f62bda6d42d5fbb08da8cc4f181", size = 2121382 }, - { url = "https://files.pythonhosted.org/packages/8a/22/e70c086f41eebd323e6baa92cc906c3f38ddce7486007eb2bdb3b11c8f64/pydantic_core-2.33.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:2a0147c0bef783fd9abc9f016d66edb6cac466dc54a17ec5f5ada08ff65caf5d", size = 2072473 }, - { url = "https://files.pythonhosted.org/packages/3e/84/d1614dedd8fe5114f6a0e348bcd1535f97d76c038d6102f271433cd1361d/pydantic_core-2.33.0-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:c860773a0f205926172c6644c394e02c25421dc9a456deff16f64c0e299487d3", size = 2249468 }, - { url = "https://files.pythonhosted.org/packages/b0/c0/787061eef44135e00fddb4b56b387a06c303bfd3884a6df9bea5cb730230/pydantic_core-2.33.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:138d31e3f90087f42aa6286fb640f3c7a8eb7bdae829418265e7e7474bd2574b", size = 2254716 }, - { url = "https://files.pythonhosted.org/packages/ae/e2/27262eb04963201e89f9c280f1e10c493a7a37bc877e023f31aa72d2f911/pydantic_core-2.33.0-cp313-cp313-win32.whl", hash = "sha256:d20cbb9d3e95114325780f3cfe990f3ecae24de7a2d75f978783878cce2ad585", size = 1916450 }, - { url = "https://files.pythonhosted.org/packages/13/8d/25ff96f1e89b19e0b70b3cd607c9ea7ca27e1dcb810a9cd4255ed6abf869/pydantic_core-2.33.0-cp313-cp313-win_amd64.whl", hash = "sha256:ca1103d70306489e3d006b0f79db8ca5dd3c977f6f13b2c59ff745249431a606", size = 1956092 }, - { url = "https://files.pythonhosted.org/packages/1b/64/66a2efeff657b04323ffcd7b898cb0354d36dae3a561049e092134a83e9c/pydantic_core-2.33.0-cp313-cp313-win_arm64.whl", hash = "sha256:6291797cad239285275558e0a27872da735b05c75d5237bbade8736f80e4c225", size = 1908367 }, - { url = "https://files.pythonhosted.org/packages/52/54/295e38769133363d7ec4a5863a4d579f331728c71a6644ff1024ee529315/pydantic_core-2.33.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:7b79af799630af263eca9ec87db519426d8c9b3be35016eddad1832bac812d87", size = 1813331 }, - { url = "https://files.pythonhosted.org/packages/4c/9c/0c8ea02db8d682aa1ef48938abae833c1d69bdfa6e5ec13b21734b01ae70/pydantic_core-2.33.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eabf946a4739b5237f4f56d77fa6668263bc466d06a8036c055587c130a46f7b", size = 1986653 }, - { url = "https://files.pythonhosted.org/packages/8e/4f/3fb47d6cbc08c7e00f92300e64ba655428c05c56b8ab6723bd290bae6458/pydantic_core-2.33.0-cp313-cp313t-win_amd64.whl", hash = "sha256:8a1d581e8cdbb857b0e0e81df98603376c1a5c34dc5e54039dcc00f043df81e7", size = 1931234 }, - { url = "https://files.pythonhosted.org/packages/44/77/85e173b715e1a277ce934f28d877d82492df13e564fa68a01c96f36a47ad/pydantic_core-2.33.0-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:e2762c568596332fdab56b07060c8ab8362c56cf2a339ee54e491cd503612c50", size = 2040129 }, - { url = "https://files.pythonhosted.org/packages/33/e7/33da5f8a94bbe2191cfcd15bd6d16ecd113e67da1b8c78d3cc3478112dab/pydantic_core-2.33.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:5bf637300ff35d4f59c006fff201c510b2b5e745b07125458a5389af3c0dff8c", size = 1872656 }, - { url = "https://files.pythonhosted.org/packages/b4/7a/9600f222bea840e5b9ba1f17c0acc79b669b24542a78c42c6a10712c0aae/pydantic_core-2.33.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:62c151ce3d59ed56ebd7ce9ce5986a409a85db697d25fc232f8e81f195aa39a1", size = 1903731 }, - { url = "https://files.pythonhosted.org/packages/81/d2/94c7ca4e24c5dcfb74df92e0836c189e9eb6814cf62d2f26a75ea0a906db/pydantic_core-2.33.0-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9ee65f0cc652261744fd07f2c6e6901c914aa6c5ff4dcfaf1136bc394d0dd26b", size = 2083966 }, - { url = "https://files.pythonhosted.org/packages/b8/74/a0259989d220e8865ed6866a6d40539e40fa8f507e587e35d2414cc081f8/pydantic_core-2.33.0-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:024d136ae44d233e6322027bbf356712b3940bee816e6c948ce4b90f18471b3d", size = 2118951 }, - { url = "https://files.pythonhosted.org/packages/13/4c/87405ed04d6d07597920b657f082a8e8e58bf3034178bb9044b4d57a91e2/pydantic_core-2.33.0-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:e37f10f6d4bc67c58fbd727108ae1d8b92b397355e68519f1e4a7babb1473442", size = 2079632 }, - { url = "https://files.pythonhosted.org/packages/5a/4c/bcb02970ef91d4cd6de7c6893101302637da456bc8b52c18ea0d047b55ce/pydantic_core-2.33.0-pp310-pypy310_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:502ed542e0d958bd12e7c3e9a015bce57deaf50eaa8c2e1c439b512cb9db1e3a", size = 2250541 }, - { url = "https://files.pythonhosted.org/packages/a3/2b/dbe5450c4cd904be5da736dcc7f2357b828199e29e38de19fc81f988b288/pydantic_core-2.33.0-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:715c62af74c236bf386825c0fdfa08d092ab0f191eb5b4580d11c3189af9d330", size = 2255685 }, - { url = "https://files.pythonhosted.org/packages/ca/a6/ca1d35f695d81f639c5617fc9efb44caad21a9463383fa45364b3044175a/pydantic_core-2.33.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:bccc06fa0372151f37f6b69834181aa9eb57cf8665ed36405fb45fbf6cac3bae", size = 2082395 }, - { url = "https://files.pythonhosted.org/packages/2b/b2/553e42762e7b08771fca41c0230c1ac276f9e79e78f57628e1b7d328551d/pydantic_core-2.33.0-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:5d8dc9f63a26f7259b57f46a7aab5af86b2ad6fbe48487500bb1f4b27e051e4c", size = 2041207 }, - { url = "https://files.pythonhosted.org/packages/85/81/a91a57bbf3efe53525ab75f65944b8950e6ef84fe3b9a26c1ec173363263/pydantic_core-2.33.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:30369e54d6d0113d2aa5aee7a90d17f225c13d87902ace8fcd7bbf99b19124db", size = 1873736 }, - { url = "https://files.pythonhosted.org/packages/9c/d2/5ab52e9f551cdcbc1ee99a0b3ef595f56d031f66f88e5ca6726c49f9ce65/pydantic_core-2.33.0-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f3eb479354c62067afa62f53bb387827bee2f75c9c79ef25eef6ab84d4b1ae3b", size = 1903794 }, - { url = "https://files.pythonhosted.org/packages/2f/5f/a81742d3f3821b16f1265f057d6e0b68a3ab13a814fe4bffac536a1f26fd/pydantic_core-2.33.0-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0310524c833d91403c960b8a3cf9f46c282eadd6afd276c8c5edc617bd705dc9", size = 2083457 }, - { url = "https://files.pythonhosted.org/packages/b5/2f/e872005bc0fc47f9c036b67b12349a8522d32e3bda928e82d676e2a594d1/pydantic_core-2.33.0-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:eddb18a00bbb855325db27b4c2a89a4ba491cd6a0bd6d852b225172a1f54b36c", size = 2119537 }, - { url = "https://files.pythonhosted.org/packages/d3/13/183f13ce647202eaf3dada9e42cdfc59cbb95faedd44d25f22b931115c7f/pydantic_core-2.33.0-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:ade5dbcf8d9ef8f4b28e682d0b29f3008df9842bb5ac48ac2c17bc55771cc976", size = 2080069 }, - { url = "https://files.pythonhosted.org/packages/23/8b/b6be91243da44a26558d9c3a9007043b3750334136c6550551e8092d6d96/pydantic_core-2.33.0-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:2c0afd34f928383e3fd25740f2050dbac9d077e7ba5adbaa2227f4d4f3c8da5c", size = 2251618 }, - { url = "https://files.pythonhosted.org/packages/aa/c5/fbcf1977035b834f63eb542e74cd6c807177f383386175b468f0865bcac4/pydantic_core-2.33.0-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:7da333f21cd9df51d5731513a6d39319892947604924ddf2e24a4612975fb936", size = 2255374 }, - { url = "https://files.pythonhosted.org/packages/2f/f8/66f328e411f1c9574b13c2c28ab01f308b53688bbbe6ca8fb981e6cabc42/pydantic_core-2.33.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:4b6d77c75a57f041c5ee915ff0b0bb58eabb78728b69ed967bc5b780e8f701b8", size = 2082099 }, + { url = "https://files.pythonhosted.org/packages/38/ea/5f572806ab4d4223d11551af814d243b0e3e02cc6913def4d1fe4a5ca41c/pydantic_core-2.33.1-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:3077cfdb6125cc8dab61b155fdd714663e401f0e6883f9632118ec12cf42df26", size = 2044021 }, + { url = "https://files.pythonhosted.org/packages/8c/d1/f86cc96d2aa80e3881140d16d12ef2b491223f90b28b9a911346c04ac359/pydantic_core-2.33.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8ffab8b2908d152e74862d276cf5017c81a2f3719f14e8e3e8d6b83fda863927", size = 1861742 }, + { url = "https://files.pythonhosted.org/packages/37/08/fbd2cd1e9fc735a0df0142fac41c114ad9602d1c004aea340169ae90973b/pydantic_core-2.33.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5183e4f6a2d468787243ebcd70cf4098c247e60d73fb7d68d5bc1e1beaa0c4db", size = 1910414 }, + { url = "https://files.pythonhosted.org/packages/7f/73/3ac217751decbf8d6cb9443cec9b9eb0130eeada6ae56403e11b486e277e/pydantic_core-2.33.1-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:398a38d323f37714023be1e0285765f0a27243a8b1506b7b7de87b647b517e48", size = 1996848 }, + { url = "https://files.pythonhosted.org/packages/9a/f5/5c26b265cdcff2661e2520d2d1e9db72d117ea00eb41e00a76efe68cb009/pydantic_core-2.33.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:87d3776f0001b43acebfa86f8c64019c043b55cc5a6a2e313d728b5c95b46969", size = 2141055 }, + { url = "https://files.pythonhosted.org/packages/5d/14/a9c3cee817ef2f8347c5ce0713e91867a0dceceefcb2973942855c917379/pydantic_core-2.33.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c566dd9c5f63d22226409553531f89de0cac55397f2ab8d97d6f06cfce6d947e", size = 2753806 }, + { url = "https://files.pythonhosted.org/packages/f2/68/866ce83a51dd37e7c604ce0050ff6ad26de65a7799df89f4db87dd93d1d6/pydantic_core-2.33.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a0d5f3acc81452c56895e90643a625302bd6be351e7010664151cc55b7b97f89", size = 2007777 }, + { url = "https://files.pythonhosted.org/packages/b6/a8/36771f4404bb3e49bd6d4344da4dede0bf89cc1e01f3b723c47248a3761c/pydantic_core-2.33.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d3a07fadec2a13274a8d861d3d37c61e97a816beae717efccaa4b36dfcaadcde", size = 2122803 }, + { url = "https://files.pythonhosted.org/packages/18/9c/730a09b2694aa89360d20756369822d98dc2f31b717c21df33b64ffd1f50/pydantic_core-2.33.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:f99aeda58dce827f76963ee87a0ebe75e648c72ff9ba1174a253f6744f518f65", size = 2086755 }, + { url = "https://files.pythonhosted.org/packages/54/8e/2dccd89602b5ec31d1c58138d02340ecb2ebb8c2cac3cc66b65ce3edb6ce/pydantic_core-2.33.1-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:902dbc832141aa0ec374f4310f1e4e7febeebc3256f00dc359a9ac3f264a45dc", size = 2257358 }, + { url = "https://files.pythonhosted.org/packages/d1/9c/126e4ac1bfad8a95a9837acdd0963695d69264179ba4ede8b8c40d741702/pydantic_core-2.33.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:fe44d56aa0b00d66640aa84a3cbe80b7a3ccdc6f0b1ca71090696a6d4777c091", size = 2257916 }, + { url = "https://files.pythonhosted.org/packages/7d/ba/91eea2047e681a6853c81c20aeca9dcdaa5402ccb7404a2097c2adf9d038/pydantic_core-2.33.1-cp310-cp310-win32.whl", hash = "sha256:ed3eb16d51257c763539bde21e011092f127a2202692afaeaccb50db55a31383", size = 1923823 }, + { url = "https://files.pythonhosted.org/packages/94/c0/fcdf739bf60d836a38811476f6ecd50374880b01e3014318b6e809ddfd52/pydantic_core-2.33.1-cp310-cp310-win_amd64.whl", hash = "sha256:694ad99a7f6718c1a498dc170ca430687a39894a60327f548e02a9c7ee4b6504", size = 1952494 }, + { url = "https://files.pythonhosted.org/packages/d6/7f/c6298830cb780c46b4f46bb24298d01019ffa4d21769f39b908cd14bbd50/pydantic_core-2.33.1-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:6e966fc3caaf9f1d96b349b0341c70c8d6573bf1bac7261f7b0ba88f96c56c24", size = 2044224 }, + { url = "https://files.pythonhosted.org/packages/a8/65/6ab3a536776cad5343f625245bd38165d6663256ad43f3a200e5936afd6c/pydantic_core-2.33.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:bfd0adeee563d59c598ceabddf2c92eec77abcb3f4a391b19aa7366170bd9e30", size = 1858845 }, + { url = "https://files.pythonhosted.org/packages/e9/15/9a22fd26ba5ee8c669d4b8c9c244238e940cd5d818649603ca81d1c69861/pydantic_core-2.33.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:91815221101ad3c6b507804178a7bb5cb7b2ead9ecd600041669c8d805ebd595", size = 1910029 }, + { url = "https://files.pythonhosted.org/packages/d5/33/8cb1a62818974045086f55f604044bf35b9342900318f9a2a029a1bec460/pydantic_core-2.33.1-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9fea9c1869bb4742d174a57b4700c6dadea951df8b06de40c2fedb4f02931c2e", size = 1997784 }, + { url = "https://files.pythonhosted.org/packages/c0/ca/49958e4df7715c71773e1ea5be1c74544923d10319173264e6db122543f9/pydantic_core-2.33.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1d20eb4861329bb2484c021b9d9a977566ab16d84000a57e28061151c62b349a", size = 2141075 }, + { url = "https://files.pythonhosted.org/packages/7b/a6/0b3a167a9773c79ba834b959b4e18c3ae9216b8319bd8422792abc8a41b1/pydantic_core-2.33.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0fb935c5591573ae3201640579f30128ccc10739b45663f93c06796854405505", size = 2745849 }, + { url = "https://files.pythonhosted.org/packages/0b/60/516484135173aa9e5861d7a0663dce82e4746d2e7f803627d8c25dfa5578/pydantic_core-2.33.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c964fd24e6166420d18fb53996d8c9fd6eac9bf5ae3ec3d03015be4414ce497f", size = 2005794 }, + { url = "https://files.pythonhosted.org/packages/86/70/05b1eb77459ad47de00cf78ee003016da0cedf8b9170260488d7c21e9181/pydantic_core-2.33.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:681d65e9011f7392db5aa002b7423cc442d6a673c635668c227c6c8d0e5a4f77", size = 2123237 }, + { url = "https://files.pythonhosted.org/packages/c7/57/12667a1409c04ae7dc95d3b43158948eb0368e9c790be8b095cb60611459/pydantic_core-2.33.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:e100c52f7355a48413e2999bfb4e139d2977a904495441b374f3d4fb4a170961", size = 2086351 }, + { url = "https://files.pythonhosted.org/packages/57/61/cc6d1d1c1664b58fdd6ecc64c84366c34ec9b606aeb66cafab6f4088974c/pydantic_core-2.33.1-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:048831bd363490be79acdd3232f74a0e9951b11b2b4cc058aeb72b22fdc3abe1", size = 2258914 }, + { url = "https://files.pythonhosted.org/packages/d1/0a/edb137176a1f5419b2ddee8bde6a0a548cfa3c74f657f63e56232df8de88/pydantic_core-2.33.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:bdc84017d28459c00db6f918a7272a5190bec3090058334e43a76afb279eac7c", size = 2257385 }, + { url = "https://files.pythonhosted.org/packages/26/3c/48ca982d50e4b0e1d9954919c887bdc1c2b462801bf408613ccc641b3daa/pydantic_core-2.33.1-cp311-cp311-win32.whl", hash = "sha256:32cd11c5914d1179df70406427097c7dcde19fddf1418c787540f4b730289896", size = 1923765 }, + { url = "https://files.pythonhosted.org/packages/33/cd/7ab70b99e5e21559f5de38a0928ea84e6f23fdef2b0d16a6feaf942b003c/pydantic_core-2.33.1-cp311-cp311-win_amd64.whl", hash = "sha256:2ea62419ba8c397e7da28a9170a16219d310d2cf4970dbc65c32faf20d828c83", size = 1950688 }, + { url = "https://files.pythonhosted.org/packages/4b/ae/db1fc237b82e2cacd379f63e3335748ab88b5adde98bf7544a1b1bd10a84/pydantic_core-2.33.1-cp311-cp311-win_arm64.whl", hash = "sha256:fc903512177361e868bc1f5b80ac8c8a6e05fcdd574a5fb5ffeac5a9982b9e89", size = 1908185 }, + { url = "https://files.pythonhosted.org/packages/c8/ce/3cb22b07c29938f97ff5f5bb27521f95e2ebec399b882392deb68d6c440e/pydantic_core-2.33.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:1293d7febb995e9d3ec3ea09caf1a26214eec45b0f29f6074abb004723fc1de8", size = 2026640 }, + { url = "https://files.pythonhosted.org/packages/19/78/f381d643b12378fee782a72126ec5d793081ef03791c28a0fd542a5bee64/pydantic_core-2.33.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:99b56acd433386c8f20be5c4000786d1e7ca0523c8eefc995d14d79c7a081498", size = 1852649 }, + { url = "https://files.pythonhosted.org/packages/9d/2b/98a37b80b15aac9eb2c6cfc6dbd35e5058a352891c5cce3a8472d77665a6/pydantic_core-2.33.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:35a5ec3fa8c2fe6c53e1b2ccc2454398f95d5393ab398478f53e1afbbeb4d939", size = 1892472 }, + { url = "https://files.pythonhosted.org/packages/4e/d4/3c59514e0f55a161004792b9ff3039da52448f43f5834f905abef9db6e4a/pydantic_core-2.33.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b172f7b9d2f3abc0efd12e3386f7e48b576ef309544ac3a63e5e9cdd2e24585d", size = 1977509 }, + { url = "https://files.pythonhosted.org/packages/a9/b6/c2c7946ef70576f79a25db59a576bce088bdc5952d1b93c9789b091df716/pydantic_core-2.33.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9097b9f17f91eea659b9ec58148c0747ec354a42f7389b9d50701610d86f812e", size = 2128702 }, + { url = "https://files.pythonhosted.org/packages/88/fe/65a880f81e3f2a974312b61f82a03d85528f89a010ce21ad92f109d94deb/pydantic_core-2.33.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cc77ec5b7e2118b152b0d886c7514a4653bcb58c6b1d760134a9fab915f777b3", size = 2679428 }, + { url = "https://files.pythonhosted.org/packages/6f/ff/4459e4146afd0462fb483bb98aa2436d69c484737feaceba1341615fb0ac/pydantic_core-2.33.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d5e3d15245b08fa4a84cefc6c9222e6f37c98111c8679fbd94aa145f9a0ae23d", size = 2008753 }, + { url = "https://files.pythonhosted.org/packages/7c/76/1c42e384e8d78452ededac8b583fe2550c84abfef83a0552e0e7478ccbc3/pydantic_core-2.33.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ef99779001d7ac2e2461d8ab55d3373fe7315caefdbecd8ced75304ae5a6fc6b", size = 2114849 }, + { url = "https://files.pythonhosted.org/packages/00/72/7d0cf05095c15f7ffe0eb78914b166d591c0eed72f294da68378da205101/pydantic_core-2.33.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:fc6bf8869e193855e8d91d91f6bf59699a5cdfaa47a404e278e776dd7f168b39", size = 2069541 }, + { url = "https://files.pythonhosted.org/packages/b3/69/94a514066bb7d8be499aa764926937409d2389c09be0b5107a970286ef81/pydantic_core-2.33.1-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:b1caa0bc2741b043db7823843e1bde8aaa58a55a58fda06083b0569f8b45693a", size = 2239225 }, + { url = "https://files.pythonhosted.org/packages/84/b0/e390071eadb44b41f4f54c3cef64d8bf5f9612c92686c9299eaa09e267e2/pydantic_core-2.33.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:ec259f62538e8bf364903a7d0d0239447059f9434b284f5536e8402b7dd198db", size = 2248373 }, + { url = "https://files.pythonhosted.org/packages/d6/b2/288b3579ffc07e92af66e2f1a11be3b056fe1214aab314748461f21a31c3/pydantic_core-2.33.1-cp312-cp312-win32.whl", hash = "sha256:e14f369c98a7c15772b9da98987f58e2b509a93235582838bd0d1d8c08b68fda", size = 1907034 }, + { url = "https://files.pythonhosted.org/packages/02/28/58442ad1c22b5b6742b992ba9518420235adced665513868f99a1c2638a5/pydantic_core-2.33.1-cp312-cp312-win_amd64.whl", hash = "sha256:1c607801d85e2e123357b3893f82c97a42856192997b95b4d8325deb1cd0c5f4", size = 1956848 }, + { url = "https://files.pythonhosted.org/packages/a1/eb/f54809b51c7e2a1d9f439f158b8dd94359321abcc98767e16fc48ae5a77e/pydantic_core-2.33.1-cp312-cp312-win_arm64.whl", hash = "sha256:8d13f0276806ee722e70a1c93da19748594f19ac4299c7e41237fc791d1861ea", size = 1903986 }, + { url = "https://files.pythonhosted.org/packages/7a/24/eed3466a4308d79155f1cdd5c7432c80ddcc4530ba8623b79d5ced021641/pydantic_core-2.33.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:70af6a21237b53d1fe7b9325b20e65cbf2f0a848cf77bed492b029139701e66a", size = 2033551 }, + { url = "https://files.pythonhosted.org/packages/ab/14/df54b1a0bc9b6ded9b758b73139d2c11b4e8eb43e8ab9c5847c0a2913ada/pydantic_core-2.33.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:282b3fe1bbbe5ae35224a0dbd05aed9ccabccd241e8e6b60370484234b456266", size = 1852785 }, + { url = "https://files.pythonhosted.org/packages/fa/96/e275f15ff3d34bb04b0125d9bc8848bf69f25d784d92a63676112451bfb9/pydantic_core-2.33.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4b315e596282bbb5822d0c7ee9d255595bd7506d1cb20c2911a4da0b970187d3", size = 1897758 }, + { url = "https://files.pythonhosted.org/packages/b7/d8/96bc536e975b69e3a924b507d2a19aedbf50b24e08c80fb00e35f9baaed8/pydantic_core-2.33.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:1dfae24cf9921875ca0ca6a8ecb4bb2f13c855794ed0d468d6abbec6e6dcd44a", size = 1986109 }, + { url = "https://files.pythonhosted.org/packages/90/72/ab58e43ce7e900b88cb571ed057b2fcd0e95b708a2e0bed475b10130393e/pydantic_core-2.33.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6dd8ecfde08d8bfadaea669e83c63939af76f4cf5538a72597016edfa3fad516", size = 2129159 }, + { url = "https://files.pythonhosted.org/packages/dc/3f/52d85781406886c6870ac995ec0ba7ccc028b530b0798c9080531b409fdb/pydantic_core-2.33.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2f593494876eae852dc98c43c6f260f45abdbfeec9e4324e31a481d948214764", size = 2680222 }, + { url = "https://files.pythonhosted.org/packages/f4/56/6e2ef42f363a0eec0fd92f74a91e0ac48cd2e49b695aac1509ad81eee86a/pydantic_core-2.33.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:948b73114f47fd7016088e5186d13faf5e1b2fe83f5e320e371f035557fd264d", size = 2006980 }, + { url = "https://files.pythonhosted.org/packages/4c/c0/604536c4379cc78359f9ee0aa319f4aedf6b652ec2854953f5a14fc38c5a/pydantic_core-2.33.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e11f3864eb516af21b01e25fac915a82e9ddad3bb0fb9e95a246067398b435a4", size = 2120840 }, + { url = "https://files.pythonhosted.org/packages/1f/46/9eb764814f508f0edfb291a0f75d10854d78113fa13900ce13729aaec3ae/pydantic_core-2.33.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:549150be302428b56fdad0c23c2741dcdb5572413776826c965619a25d9c6bde", size = 2072518 }, + { url = "https://files.pythonhosted.org/packages/42/e3/fb6b2a732b82d1666fa6bf53e3627867ea3131c5f39f98ce92141e3e3dc1/pydantic_core-2.33.1-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:495bc156026efafd9ef2d82372bd38afce78ddd82bf28ef5276c469e57c0c83e", size = 2248025 }, + { url = "https://files.pythonhosted.org/packages/5c/9d/fbe8fe9d1aa4dac88723f10a921bc7418bd3378a567cb5e21193a3c48b43/pydantic_core-2.33.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:ec79de2a8680b1a67a07490bddf9636d5c2fab609ba8c57597e855fa5fa4dacd", size = 2254991 }, + { url = "https://files.pythonhosted.org/packages/aa/99/07e2237b8a66438d9b26482332cda99a9acccb58d284af7bc7c946a42fd3/pydantic_core-2.33.1-cp313-cp313-win32.whl", hash = "sha256:ee12a7be1742f81b8a65b36c6921022301d466b82d80315d215c4c691724986f", size = 1915262 }, + { url = "https://files.pythonhosted.org/packages/8a/f4/e457a7849beeed1e5defbcf5051c6f7b3c91a0624dd31543a64fc9adcf52/pydantic_core-2.33.1-cp313-cp313-win_amd64.whl", hash = "sha256:ede9b407e39949d2afc46385ce6bd6e11588660c26f80576c11c958e6647bc40", size = 1956626 }, + { url = "https://files.pythonhosted.org/packages/20/d0/e8d567a7cff7b04e017ae164d98011f1e1894269fe8e90ea187a3cbfb562/pydantic_core-2.33.1-cp313-cp313-win_arm64.whl", hash = "sha256:aa687a23d4b7871a00e03ca96a09cad0f28f443690d300500603bd0adba4b523", size = 1909590 }, + { url = "https://files.pythonhosted.org/packages/ef/fd/24ea4302d7a527d672c5be06e17df16aabfb4e9fdc6e0b345c21580f3d2a/pydantic_core-2.33.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:401d7b76e1000d0dd5538e6381d28febdcacb097c8d340dde7d7fc6e13e9f95d", size = 1812963 }, + { url = "https://files.pythonhosted.org/packages/5f/95/4fbc2ecdeb5c1c53f1175a32d870250194eb2fdf6291b795ab08c8646d5d/pydantic_core-2.33.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7aeb055a42d734c0255c9e489ac67e75397d59c6fbe60d155851e9782f276a9c", size = 1986896 }, + { url = "https://files.pythonhosted.org/packages/71/ae/fe31e7f4a62431222d8f65a3bd02e3fa7e6026d154a00818e6d30520ea77/pydantic_core-2.33.1-cp313-cp313t-win_amd64.whl", hash = "sha256:338ea9b73e6e109f15ab439e62cb3b78aa752c7fd9536794112e14bee02c8d18", size = 1931810 }, + { url = "https://files.pythonhosted.org/packages/9c/c7/8b311d5adb0fe00a93ee9b4e92a02b0ec08510e9838885ef781ccbb20604/pydantic_core-2.33.1-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:5c834f54f8f4640fd7e4b193f80eb25a0602bba9e19b3cd2fc7ffe8199f5ae02", size = 2041659 }, + { url = "https://files.pythonhosted.org/packages/8a/d6/4f58d32066a9e26530daaf9adc6664b01875ae0691570094968aaa7b8fcc/pydantic_core-2.33.1-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:049e0de24cf23766f12cc5cc71d8abc07d4a9deb9061b334b62093dedc7cb068", size = 1873294 }, + { url = "https://files.pythonhosted.org/packages/f7/3f/53cc9c45d9229da427909c751f8ed2bf422414f7664ea4dde2d004f596ba/pydantic_core-2.33.1-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1a28239037b3d6f16916a4c831a5a0eadf856bdd6d2e92c10a0da3a59eadcf3e", size = 1903771 }, + { url = "https://files.pythonhosted.org/packages/f0/49/bf0783279ce674eb9903fb9ae43f6c614cb2f1c4951370258823f795368b/pydantic_core-2.33.1-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9d3da303ab5f378a268fa7d45f37d7d85c3ec19769f28d2cc0c61826a8de21fe", size = 2083558 }, + { url = "https://files.pythonhosted.org/packages/9c/5b/0d998367687f986c7d8484a2c476d30f07bf5b8b1477649a6092bd4c540e/pydantic_core-2.33.1-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:25626fb37b3c543818c14821afe0fd3830bc327a43953bc88db924b68c5723f1", size = 2118038 }, + { url = "https://files.pythonhosted.org/packages/b3/33/039287d410230ee125daee57373ac01940d3030d18dba1c29cd3089dc3ca/pydantic_core-2.33.1-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:3ab2d36e20fbfcce8f02d73c33a8a7362980cff717926bbae030b93ae46b56c7", size = 2079315 }, + { url = "https://files.pythonhosted.org/packages/1f/85/6d8b2646d99c062d7da2d0ab2faeb0d6ca9cca4c02da6076376042a20da3/pydantic_core-2.33.1-pp310-pypy310_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:2f9284e11c751b003fd4215ad92d325d92c9cb19ee6729ebd87e3250072cdcde", size = 2249063 }, + { url = "https://files.pythonhosted.org/packages/17/d7/c37d208d5738f7b9ad8f22ae8a727d88ebf9c16c04ed2475122cc3f7224a/pydantic_core-2.33.1-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:048c01eee07d37cbd066fc512b9d8b5ea88ceeb4e629ab94b3e56965ad655add", size = 2254631 }, + { url = "https://files.pythonhosted.org/packages/13/e0/bafa46476d328e4553b85ab9b2f7409e7aaef0ce4c937c894821c542d347/pydantic_core-2.33.1-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:5ccd429694cf26af7997595d627dd2637e7932214486f55b8a357edaac9dae8c", size = 2080877 }, + { url = "https://files.pythonhosted.org/packages/0b/76/1794e440c1801ed35415238d2c728f26cd12695df9057154ad768b7b991c/pydantic_core-2.33.1-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:3a371dc00282c4b84246509a5ddc808e61b9864aa1eae9ecc92bb1268b82db4a", size = 2042858 }, + { url = "https://files.pythonhosted.org/packages/73/b4/9cd7b081fb0b1b4f8150507cd59d27b275c3e22ad60b35cb19ea0977d9b9/pydantic_core-2.33.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:f59295ecc75a1788af8ba92f2e8c6eeaa5a94c22fc4d151e8d9638814f85c8fc", size = 1873745 }, + { url = "https://files.pythonhosted.org/packages/e1/d7/9ddb7575d4321e40d0363903c2576c8c0c3280ebea137777e5ab58d723e3/pydantic_core-2.33.1-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:08530b8ac922003033f399128505f513e30ca770527cc8bbacf75a84fcc2c74b", size = 1904188 }, + { url = "https://files.pythonhosted.org/packages/d1/a8/3194ccfe461bb08da19377ebec8cb4f13c9bd82e13baebc53c5c7c39a029/pydantic_core-2.33.1-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bae370459da6a5466978c0eacf90690cb57ec9d533f8e63e564ef3822bfa04fe", size = 2083479 }, + { url = "https://files.pythonhosted.org/packages/42/c7/84cb569555d7179ca0b3f838cef08f66f7089b54432f5b8599aac6e9533e/pydantic_core-2.33.1-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e3de2777e3b9f4d603112f78006f4ae0acb936e95f06da6cb1a45fbad6bdb4b5", size = 2118415 }, + { url = "https://files.pythonhosted.org/packages/3b/67/72abb8c73e0837716afbb58a59cc9e3ae43d1aa8677f3b4bc72c16142716/pydantic_core-2.33.1-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:3a64e81e8cba118e108d7126362ea30e021291b7805d47e4896e52c791be2761", size = 2079623 }, + { url = "https://files.pythonhosted.org/packages/0b/cd/c59707e35a47ba4cbbf153c3f7c56420c58653b5801b055dc52cccc8e2dc/pydantic_core-2.33.1-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:52928d8c1b6bda03cc6d811e8923dffc87a2d3c8b3bfd2ce16471c7147a24850", size = 2250175 }, + { url = "https://files.pythonhosted.org/packages/84/32/e4325a6676b0bed32d5b084566ec86ed7fd1e9bcbfc49c578b1755bde920/pydantic_core-2.33.1-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:1b30d92c9412beb5ac6b10a3eb7ef92ccb14e3f2a8d7732e2d739f58b3aa7544", size = 2254674 }, + { url = "https://files.pythonhosted.org/packages/12/6f/5596dc418f2e292ffc661d21931ab34591952e2843e7168ea5a52591f6ff/pydantic_core-2.33.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:f995719707e0e29f0f41a8aa3bcea6e761a36c9136104d3189eafb83f5cec5e5", size = 2080951 }, ] [[package]] @@ -1960,15 +1960,15 @@ wheels = [ [[package]] name = "pytest-cov" -version = "6.0.0" +version = "6.1.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "coverage", extra = ["toml"] }, { name = "pytest" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/be/45/9b538de8cef30e17c7b45ef42f538a94889ed6a16f2387a6c89e73220651/pytest-cov-6.0.0.tar.gz", hash = "sha256:fde0b595ca248bb8e2d76f020b465f3b107c9632e6a1d1705f17834c89dcadc0", size = 66945 } +sdist = { url = "https://files.pythonhosted.org/packages/25/69/5f1e57f6c5a39f81411b550027bf72842c4567ff5fd572bed1edc9e4b5d9/pytest_cov-6.1.1.tar.gz", hash = "sha256:46935f7aaefba760e716c2ebfbe1c216240b9592966e7da99ea8292d4d3e2a0a", size = 66857 } wheels = [ - { url = "https://files.pythonhosted.org/packages/36/3b/48e79f2cd6a61dbbd4807b4ed46cb564b4fd50a76166b1c4ea5c1d9e2371/pytest_cov-6.0.0-py3-none-any.whl", hash = "sha256:eee6f1b9e61008bd34975a4d5bab25801eb31898b032dd55addc93e96fcaaa35", size = 22949 }, + { url = "https://files.pythonhosted.org/packages/28/d0/def53b4a790cfb21483016430ed828f64830dd981ebe1089971cd10cab25/pytest_cov-6.1.1-py3-none-any.whl", hash = "sha256:bddf29ed2d0ab6f4df17b4c55b0a657287db8684af9c42ea546b21b1041b3dde", size = 23841 }, ] [[package]] @@ -2225,27 +2225,27 @@ wheels = [ [[package]] name = "ruff" -version = "0.11.2" +version = "0.11.4" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/90/61/fb87430f040e4e577e784e325351186976516faef17d6fcd921fe28edfd7/ruff-0.11.2.tar.gz", hash = "sha256:ec47591497d5a1050175bdf4e1a4e6272cddff7da88a2ad595e1e326041d8d94", size = 3857511 } +sdist = { url = "https://files.pythonhosted.org/packages/e8/5b/3ae20f89777115944e89c2d8c2e795dcc5b9e04052f76d5347e35e0da66e/ruff-0.11.4.tar.gz", hash = "sha256:f45bd2fb1a56a5a85fae3b95add03fb185a0b30cf47f5edc92aa0355ca1d7407", size = 3933063 } wheels = [ - { url = "https://files.pythonhosted.org/packages/62/99/102578506f0f5fa29fd7e0df0a273864f79af044757aef73d1cae0afe6ad/ruff-0.11.2-py3-none-linux_armv6l.whl", hash = "sha256:c69e20ea49e973f3afec2c06376eb56045709f0212615c1adb0eda35e8a4e477", size = 10113146 }, - { url = "https://files.pythonhosted.org/packages/74/ad/5cd4ba58ab602a579997a8494b96f10f316e874d7c435bcc1a92e6da1b12/ruff-0.11.2-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:2c5424cc1c4eb1d8ecabe6d4f1b70470b4f24a0c0171356290b1953ad8f0e272", size = 10867092 }, - { url = "https://files.pythonhosted.org/packages/fc/3e/d3f13619e1d152c7b600a38c1a035e833e794c6625c9a6cea6f63dbf3af4/ruff-0.11.2-py3-none-macosx_11_0_arm64.whl", hash = "sha256:ecf20854cc73f42171eedb66f006a43d0a21bfb98a2523a809931cda569552d9", size = 10224082 }, - { url = "https://files.pythonhosted.org/packages/90/06/f77b3d790d24a93f38e3806216f263974909888fd1e826717c3ec956bbcd/ruff-0.11.2-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0c543bf65d5d27240321604cee0633a70c6c25c9a2f2492efa9f6d4b8e4199bb", size = 10394818 }, - { url = "https://files.pythonhosted.org/packages/99/7f/78aa431d3ddebfc2418cd95b786642557ba8b3cb578c075239da9ce97ff9/ruff-0.11.2-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:20967168cc21195db5830b9224be0e964cc9c8ecf3b5a9e3ce19876e8d3a96e3", size = 9952251 }, - { url = "https://files.pythonhosted.org/packages/30/3e/f11186d1ddfaca438c3bbff73c6a2fdb5b60e6450cc466129c694b0ab7a2/ruff-0.11.2-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:955a9ce63483999d9f0b8f0b4a3ad669e53484232853054cc8b9d51ab4c5de74", size = 11563566 }, - { url = "https://files.pythonhosted.org/packages/22/6c/6ca91befbc0a6539ee133d9a9ce60b1a354db12c3c5d11cfdbf77140f851/ruff-0.11.2-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:86b3a27c38b8fce73bcd262b0de32e9a6801b76d52cdb3ae4c914515f0cef608", size = 12208721 }, - { url = "https://files.pythonhosted.org/packages/19/b0/24516a3b850d55b17c03fc399b681c6a549d06ce665915721dc5d6458a5c/ruff-0.11.2-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a3b66a03b248c9fcd9d64d445bafdf1589326bee6fc5c8e92d7562e58883e30f", size = 11662274 }, - { url = "https://files.pythonhosted.org/packages/d7/65/76be06d28ecb7c6070280cef2bcb20c98fbf99ff60b1c57d2fb9b8771348/ruff-0.11.2-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0397c2672db015be5aa3d4dac54c69aa012429097ff219392c018e21f5085147", size = 13792284 }, - { url = "https://files.pythonhosted.org/packages/ce/d2/4ceed7147e05852876f3b5f3fdc23f878ce2b7e0b90dd6e698bda3d20787/ruff-0.11.2-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:869bcf3f9abf6457fbe39b5a37333aa4eecc52a3b99c98827ccc371a8e5b6f1b", size = 11327861 }, - { url = "https://files.pythonhosted.org/packages/c4/78/4935ecba13706fd60ebe0e3dc50371f2bdc3d9bc80e68adc32ff93914534/ruff-0.11.2-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:2a2b50ca35457ba785cd8c93ebbe529467594087b527a08d487cf0ee7b3087e9", size = 10276560 }, - { url = "https://files.pythonhosted.org/packages/81/7f/1b2435c3f5245d410bb5dc80f13ec796454c21fbda12b77d7588d5cf4e29/ruff-0.11.2-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:7c69c74bf53ddcfbc22e6eb2f31211df7f65054bfc1f72288fc71e5f82db3eab", size = 9945091 }, - { url = "https://files.pythonhosted.org/packages/39/c4/692284c07e6bf2b31d82bb8c32f8840f9d0627d92983edaac991a2b66c0a/ruff-0.11.2-py3-none-musllinux_1_2_i686.whl", hash = "sha256:6e8fb75e14560f7cf53b15bbc55baf5ecbe373dd5f3aab96ff7aa7777edd7630", size = 10977133 }, - { url = "https://files.pythonhosted.org/packages/94/cf/8ab81cb7dd7a3b0a3960c2769825038f3adcd75faf46dd6376086df8b128/ruff-0.11.2-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:842a472d7b4d6f5924e9297aa38149e5dcb1e628773b70e6387ae2c97a63c58f", size = 11378514 }, - { url = "https://files.pythonhosted.org/packages/d9/3a/a647fa4f316482dacf2fd68e8a386327a33d6eabd8eb2f9a0c3d291ec549/ruff-0.11.2-py3-none-win32.whl", hash = "sha256:aca01ccd0eb5eb7156b324cfaa088586f06a86d9e5314b0eb330cb48415097cc", size = 10319835 }, - { url = "https://files.pythonhosted.org/packages/86/54/3c12d3af58012a5e2cd7ebdbe9983f4834af3f8cbea0e8a8c74fa1e23b2b/ruff-0.11.2-py3-none-win_amd64.whl", hash = "sha256:3170150172a8f994136c0c66f494edf199a0bbea7a409f649e4bc8f4d7084080", size = 11373713 }, - { url = "https://files.pythonhosted.org/packages/d6/d4/dd813703af8a1e2ac33bf3feb27e8a5ad514c9f219df80c64d69807e7f71/ruff-0.11.2-py3-none-win_arm64.whl", hash = "sha256:52933095158ff328f4c77af3d74f0379e34fd52f175144cefc1b192e7ccd32b4", size = 10441990 }, + { url = "https://files.pythonhosted.org/packages/9c/db/baee59ac88f57527fcbaad3a7b309994e42329c6bc4d4d2b681a3d7b5426/ruff-0.11.4-py3-none-linux_armv6l.whl", hash = "sha256:d9f4a761ecbde448a2d3e12fb398647c7f0bf526dbc354a643ec505965824ed2", size = 10106493 }, + { url = "https://files.pythonhosted.org/packages/c1/d6/9a0962cbb347f4ff98b33d699bf1193ff04ca93bed4b4222fd881b502154/ruff-0.11.4-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:8c1747d903447d45ca3d40c794d1a56458c51e5cc1bc77b7b64bd2cf0b1626cc", size = 10876382 }, + { url = "https://files.pythonhosted.org/packages/3a/8f/62bab0c7d7e1ae3707b69b157701b41c1ccab8f83e8501734d12ea8a839f/ruff-0.11.4-py3-none-macosx_11_0_arm64.whl", hash = "sha256:51a6494209cacca79e121e9b244dc30d3414dac8cc5afb93f852173a2ecfc906", size = 10237050 }, + { url = "https://files.pythonhosted.org/packages/09/96/e296965ae9705af19c265d4d441958ed65c0c58fc4ec340c27cc9d2a1f5b/ruff-0.11.4-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3f171605f65f4fc49c87f41b456e882cd0c89e4ac9d58e149a2b07930e1d466f", size = 10424984 }, + { url = "https://files.pythonhosted.org/packages/e5/56/644595eb57d855afed6e54b852e2df8cd5ca94c78043b2f29bdfb29882d5/ruff-0.11.4-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ebf99ea9af918878e6ce42098981fc8c1db3850fef2f1ada69fb1dcdb0f8e79e", size = 9957438 }, + { url = "https://files.pythonhosted.org/packages/86/83/9d3f3bed0118aef3e871ded9e5687fb8c5776bde233427fd9ce0a45db2d4/ruff-0.11.4-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:edad2eac42279df12e176564a23fc6f4aaeeb09abba840627780b1bb11a9d223", size = 11547282 }, + { url = "https://files.pythonhosted.org/packages/40/e6/0c6e4f5ae72fac5ccb44d72c0111f294a5c2c8cc5024afcb38e6bda5f4b3/ruff-0.11.4-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:f103a848be9ff379fc19b5d656c1f911d0a0b4e3e0424f9532ececf319a4296e", size = 12182020 }, + { url = "https://files.pythonhosted.org/packages/b5/92/4aed0e460aeb1df5ea0c2fbe8d04f9725cccdb25d8da09a0d3f5b8764bf8/ruff-0.11.4-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:193e6fac6eb60cc97b9f728e953c21cc38a20077ed64f912e9d62b97487f3f2d", size = 11679154 }, + { url = "https://files.pythonhosted.org/packages/1b/d3/7316aa2609f2c592038e2543483eafbc62a0e1a6a6965178e284808c095c/ruff-0.11.4-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7af4e5f69b7c138be8dcffa5b4a061bf6ba6a3301f632a6bce25d45daff9bc99", size = 13905985 }, + { url = "https://files.pythonhosted.org/packages/63/80/734d3d17546e47ff99871f44ea7540ad2bbd7a480ed197fe8a1c8a261075/ruff-0.11.4-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:126b1bf13154aa18ae2d6c3c5efe144ec14b97c60844cfa6eb960c2a05188222", size = 11348343 }, + { url = "https://files.pythonhosted.org/packages/04/7b/70fc7f09a0161dce9613a4671d198f609e653d6f4ff9eee14d64c4c240fb/ruff-0.11.4-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:e8806daaf9dfa881a0ed603f8a0e364e4f11b6ed461b56cae2b1c0cab0645304", size = 10308487 }, + { url = "https://files.pythonhosted.org/packages/1a/22/1cdd62dabd678d75842bf4944fd889cf794dc9e58c18cc547f9eb28f95ed/ruff-0.11.4-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:5d94bb1cc2fc94a769b0eb975344f1b1f3d294da1da9ddbb5a77665feb3a3019", size = 9929091 }, + { url = "https://files.pythonhosted.org/packages/9f/20/40e0563506332313148e783bbc1e4276d657962cc370657b2fff20e6e058/ruff-0.11.4-py3-none-musllinux_1_2_i686.whl", hash = "sha256:995071203d0fe2183fc7a268766fd7603afb9996785f086b0d76edee8755c896", size = 10924659 }, + { url = "https://files.pythonhosted.org/packages/b5/41/eef9b7aac8819d9e942f617f9db296f13d2c4576806d604aba8db5a753f1/ruff-0.11.4-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:7a37ca937e307ea18156e775a6ac6e02f34b99e8c23fe63c1996185a4efe0751", size = 11428160 }, + { url = "https://files.pythonhosted.org/packages/ff/61/c488943414fb2b8754c02f3879de003e26efdd20f38167ded3fb3fc1cda3/ruff-0.11.4-py3-none-win32.whl", hash = "sha256:0e9365a7dff9b93af933dab8aebce53b72d8f815e131796268709890b4a83270", size = 10311496 }, + { url = "https://files.pythonhosted.org/packages/b6/2b/2a1c8deb5f5dfa3871eb7daa41492c4d2b2824a74d2b38e788617612a66d/ruff-0.11.4-py3-none-win_amd64.whl", hash = "sha256:5a9fa1c69c7815e39fcfb3646bbfd7f528fa8e2d4bebdcf4c2bd0fa037a255fb", size = 11399146 }, + { url = "https://files.pythonhosted.org/packages/4f/03/3aec4846226d54a37822e4c7ea39489e4abd6f88388fba74e3d4abe77300/ruff-0.11.4-py3-none-win_arm64.whl", hash = "sha256:d435db6b9b93d02934cf61ef332e66af82da6d8c69aefdea5994c89997c7a0fc", size = 10450306 }, ] [[package]]