diff --git a/cli/package-lock.json b/cli/package-lock.json index cd4ba9682..dd9c2cc00 100644 --- a/cli/package-lock.json +++ b/cli/package-lock.json @@ -47,15 +47,15 @@ }, "../open-api/typescript-sdk": { "name": "@immich/sdk", - "version": "1.99.0", + "version": "1.100.0", "dev": true, "license": "GNU Affero General Public License version 3", "dependencies": { "@oazapfts/runtime": "^1.0.2" }, "devDependencies": { - "@types/node": "^20.11.30", - "typescript": "^5.4.3" + "@types/node": "^20.11.0", + "typescript": "^5.3.3" } }, "node_modules/@aashutoshrathi/word-wrap": { @@ -4456,9 +4456,9 @@ } }, "node_modules/vite": { - "version": "5.2.2", - "resolved": "https://registry.npmjs.org/vite/-/vite-5.2.2.tgz", - "integrity": "sha512-FWZbz0oSdLq5snUI0b6sULbz58iXFXdvkZfZWR/F0ZJuKTSPO7v72QPXt6KqYeMFb0yytNp6kZosxJ96Nr/wDQ==", + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.2.3.tgz", + "integrity": "sha512-+i1oagbvkVIhEy9TnEV+fgXsng13nZM90JQbrcPrf6DvW2mXARlz+DK7DLiDP+qeKoD1FCVx/1SpFL1CLq9Mhw==", "dev": true, "dependencies": { "esbuild": "^0.20.1", diff --git a/cli/src/commands/server-info.ts b/cli/src/commands/server-info.ts index a7de804df..074513bd6 100644 --- a/cli/src/commands/server-info.ts +++ b/cli/src/commands/server-info.ts @@ -1,15 +1,24 @@ -import { getAssetStatistics, getServerVersion, getSupportedMediaTypes } from '@immich/sdk'; +import { getAssetStatistics, getMyUserInfo, getServerVersion, getSupportedMediaTypes } from '@immich/sdk'; import { BaseOptions, authenticate } from 'src/utils'; export const serverInfo = async (options: BaseOptions) => { - await authenticate(options); + const { url } = await authenticate(options); - const versionInfo = await getServerVersion(); - const mediaTypes = await getSupportedMediaTypes(); - const stats = await getAssetStatistics({}); + const [versionInfo, mediaTypes, stats, userInfo] = await Promise.all([ + getServerVersion(), + getSupportedMediaTypes(), + getAssetStatistics({}), + getMyUserInfo(), + ]); - console.log(`Server Version: ${versionInfo.major}.${versionInfo.minor}.${versionInfo.patch}`); - console.log(`Image Types: ${mediaTypes.image.map((extension) => extension.replace('.', ''))}`); - console.log(`Video Types: ${mediaTypes.video.map((extension) => extension.replace('.', ''))}`); - console.log(`Statistics:\n Images: ${stats.images}\n Videos: ${stats.videos}\n Total: ${stats.total}`); + console.log(`Server Info (via ${userInfo.email})`); + console.log(` Url: ${url}`); + console.log(` Version: ${versionInfo.major}.${versionInfo.minor}.${versionInfo.patch}`); + console.log(` Formats:`); + console.log(` Images: ${mediaTypes.image.map((extension) => extension.replace('.', ''))}`); + console.log(` Videos: ${mediaTypes.video.map((extension) => extension.replace('.', ''))}`); + console.log(` Statistics:`); + console.log(` Images: ${stats.images}`); + console.log(` Videos: ${stats.videos}`); + console.log(` Total: ${stats.total}`); }; diff --git a/cli/src/utils.ts b/cli/src/utils.ts index c17ad6903..b2d34bbb4 100644 --- a/cli/src/utils.ts +++ b/cli/src/utils.ts @@ -15,21 +15,25 @@ export interface BaseOptions { export type AuthDto = { url: string; key: string }; type OldAuthDto = { instanceUrl: string; apiKey: string }; -export const authenticate = async (options: BaseOptions): Promise => { +export const authenticate = async (options: BaseOptions): Promise => { const { configDirectory: configDir, url, key } = options; // provided in command if (url && key) { - await connect(url, key); - return; + return connect(url, key); } // fallback to auth file const config = await readAuthFile(configDir); - await connect(config.url, config.key); + const auth = await connect(config.url, config.key); + if (auth.url !== config.url) { + await writeAuthFile(configDir, auth); + } + + return auth; }; -export const connect = async (url: string, key: string): Promise => { +export const connect = async (url: string, key: string) => { const wellKnownUrl = new URL('.well-known/immich', url); try { const wellKnown = await fetch(wellKnownUrl).then((response) => response.json()); @@ -50,6 +54,8 @@ export const connect = async (url: string, key: string): Promise => { logError(error, 'Failed to connect to server'); process.exit(1); } + + return { url, key }; }; export const logError = (error: unknown, message: string) => { diff --git a/docs/docs/administration/backup-and-restore.md b/docs/docs/administration/backup-and-restore.md index 6d7ee6f15..b5e407169 100644 --- a/docs/docs/administration/backup-and-restore.md +++ b/docs/docs/administration/backup-and-restore.md @@ -20,7 +20,7 @@ The recommended way to backup and restore the Immich database is to use the `pg_ -```bash title='Bash' +```bash title='Backup' docker exec -t immich_postgres pg_dumpall -c -U postgres | gzip > "/path/to/backup/dump.sql.gz" ``` diff --git a/e2e/package-lock.json b/e2e/package-lock.json index a28b2db8f..127ef5767 100644 --- a/e2e/package-lock.json +++ b/e2e/package-lock.json @@ -1,12 +1,12 @@ { "name": "immich-e2e", - "version": "1.99.0", + "version": "1.100.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "immich-e2e", - "version": "1.99.0", + "version": "1.100.0", "license": "GNU Affero General Public License version 3", "devDependencies": { "@immich/cli": "file:../cli", @@ -80,7 +80,7 @@ }, "../open-api/typescript-sdk": { "name": "@immich/sdk", - "version": "1.99.0", + "version": "1.100.0", "dev": true, "license": "GNU Affero General Public License version 3", "dependencies": { diff --git a/e2e/package.json b/e2e/package.json index fef02501f..0201dde6f 100644 --- a/e2e/package.json +++ b/e2e/package.json @@ -1,6 +1,6 @@ { "name": "immich-e2e", - "version": "1.99.0", + "version": "1.100.0", "description": "", "main": "index.js", "type": "module", diff --git a/e2e/src/api/specs/asset.e2e-spec.ts b/e2e/src/api/specs/asset.e2e-spec.ts index a13bb58eb..ddc8dd3ef 100644 --- a/e2e/src/api/specs/asset.e2e-spec.ts +++ b/e2e/src/api/specs/asset.e2e-spec.ts @@ -5,7 +5,6 @@ import { LibraryResponseDto, LoginResponseDto, SharedLinkType, - TimeBucketSize, getAllLibraries, getAssetInfo, updateAssets, @@ -942,146 +941,6 @@ describe('/asset', () => { }); }); - describe('GET /asset/time-buckets', () => { - it('should require authentication', async () => { - const { status, body } = await request(app).get('/asset/time-buckets').query({ size: TimeBucketSize.Month }); - expect(status).toBe(401); - expect(body).toEqual(errorDto.unauthorized); - }); - - it('should get time buckets by month', async () => { - const { status, body } = await request(app) - .get('/asset/time-buckets') - .set('Authorization', `Bearer ${timeBucketUser.accessToken}`) - .query({ size: TimeBucketSize.Month }); - - expect(status).toBe(200); - expect(body).toEqual( - expect.arrayContaining([ - { count: 3, timeBucket: '1970-02-01T00:00:00.000Z' }, - { count: 1, timeBucket: '1970-01-01T00:00:00.000Z' }, - ]), - ); - }); - - it('should not allow access for unrelated shared links', async () => { - const sharedLink = await utils.createSharedLink(user1.accessToken, { - type: SharedLinkType.Individual, - assetIds: user1Assets.map(({ id }) => id), - }); - - const { status, body } = await request(app) - .get('/asset/time-buckets') - .query({ key: sharedLink.key, size: TimeBucketSize.Month }); - - expect(status).toBe(400); - expect(body).toEqual(errorDto.noPermission); - }); - - it('should get time buckets by day', async () => { - const { status, body } = await request(app) - .get('/asset/time-buckets') - .set('Authorization', `Bearer ${timeBucketUser.accessToken}`) - .query({ size: TimeBucketSize.Day }); - - expect(status).toBe(200); - expect(body).toEqual([ - { count: 2, timeBucket: '1970-02-11T00:00:00.000Z' }, - { count: 1, timeBucket: '1970-02-10T00:00:00.000Z' }, - { count: 1, timeBucket: '1970-01-01T00:00:00.000Z' }, - ]); - }); - }); - - describe('GET /asset/time-bucket', () => { - it('should require authentication', async () => { - const { status, body } = await request(app).get('/asset/time-bucket').query({ - size: TimeBucketSize.Month, - timeBucket: '1900-01-01T00:00:00.000Z', - }); - - expect(status).toBe(401); - expect(body).toEqual(errorDto.unauthorized); - }); - - it('should handle 5 digit years', async () => { - const { status, body } = await request(app) - .get('/asset/time-bucket') - .query({ size: TimeBucketSize.Month, timeBucket: '+012345-01-01T00:00:00.000Z' }) - .set('Authorization', `Bearer ${timeBucketUser.accessToken}`); - - expect(status).toBe(200); - expect(body).toEqual([]); - }); - - // TODO enable date string validation while still accepting 5 digit years - // it('should fail if time bucket is invalid', async () => { - // const { status, body } = await request(app) - // .get('/asset/time-bucket') - // .set('Authorization', `Bearer ${user1.accessToken}`) - // .query({ size: TimeBucketSize.Month, timeBucket: 'foo' }); - - // expect(status).toBe(400); - // expect(body).toEqual(errorDto.badRequest); - // }); - - it('should return time bucket', async () => { - const { status, body } = await request(app) - .get('/asset/time-bucket') - .set('Authorization', `Bearer ${timeBucketUser.accessToken}`) - .query({ size: TimeBucketSize.Month, timeBucket: '1970-02-10T00:00:00.000Z' }); - - expect(status).toBe(200); - expect(body).toEqual([]); - }); - - it('should return error if time bucket is requested with partners asset and archived', async () => { - const req1 = await request(app) - .get('/asset/time-buckets') - .set('Authorization', `Bearer ${timeBucketUser.accessToken}`) - .query({ size: TimeBucketSize.Month, withPartners: true, isArchived: true }); - - expect(req1.status).toBe(400); - expect(req1.body).toEqual(errorDto.badRequest()); - - const req2 = await request(app) - .get('/asset/time-buckets') - .set('Authorization', `Bearer ${user1.accessToken}`) - .query({ size: TimeBucketSize.Month, withPartners: true, isArchived: undefined }); - - expect(req2.status).toBe(400); - expect(req2.body).toEqual(errorDto.badRequest()); - }); - - it('should return error if time bucket is requested with partners asset and favorite', async () => { - const req1 = await request(app) - .get('/asset/time-buckets') - .set('Authorization', `Bearer ${timeBucketUser.accessToken}`) - .query({ size: TimeBucketSize.Month, withPartners: true, isFavorite: true }); - - expect(req1.status).toBe(400); - expect(req1.body).toEqual(errorDto.badRequest()); - - const req2 = await request(app) - .get('/asset/time-buckets') - .set('Authorization', `Bearer ${timeBucketUser.accessToken}`) - .query({ size: TimeBucketSize.Month, withPartners: true, isFavorite: false }); - - expect(req2.status).toBe(400); - expect(req2.body).toEqual(errorDto.badRequest()); - }); - - it('should return error if time bucket is requested with partners asset and trash', async () => { - const req = await request(app) - .get('/asset/time-buckets') - .set('Authorization', `Bearer ${user1.accessToken}`) - .query({ size: TimeBucketSize.Month, withPartners: true, isTrashed: true }); - - expect(req.status).toBe(400); - expect(req.body).toEqual(errorDto.badRequest()); - }); - }); - describe('GET /asset', () => { it('should return stack data', async () => { const { status, body } = await request(app).get('/asset').set('Authorization', `Bearer ${stackUser.accessToken}`); diff --git a/e2e/src/api/specs/system-config.e2e-spec.ts b/e2e/src/api/specs/system-config.e2e-spec.ts index 8cf389134..04cfec91e 100644 --- a/e2e/src/api/specs/system-config.e2e-spec.ts +++ b/e2e/src/api/specs/system-config.e2e-spec.ts @@ -1,4 +1,4 @@ -import { LoginResponseDto, getConfig } from '@immich/sdk'; +import { AssetFileUploadResponseDto, LoginResponseDto, SharedLinkType, getConfig } from '@immich/sdk'; import { createUserDto } from 'src/fixtures'; import { errorDto } from 'src/responses'; import { app, asBearerAuth, utils } from 'src/utils'; @@ -10,11 +10,14 @@ const getSystemConfig = (accessToken: string) => getConfig({ headers: asBearerAu describe('/system-config', () => { let admin: LoginResponseDto; let nonAdmin: LoginResponseDto; + let asset: AssetFileUploadResponseDto; beforeAll(async () => { await utils.resetDatabase(); admin = await utils.adminSetup(); nonAdmin = await utils.userSetup(admin.accessToken, createUserDto.user1); + + asset = await utils.createAsset(admin.accessToken); }); describe('GET /system-config/map/style.json', () => { @@ -24,6 +27,19 @@ describe('/system-config', () => { expect(body).toEqual(errorDto.unauthorized); }); + it('should allow shared link access', async () => { + const sharedLink = await utils.createSharedLink(admin.accessToken, { + type: SharedLinkType.Individual, + assetIds: [asset.id], + }); + const { status, body } = await request(app) + .get(`/system-config/map/style.json?key=${sharedLink.key}`) + .query({ theme: 'dark' }); + + expect(status).toBe(200); + expect(body).toEqual(expect.objectContaining({ id: 'immich-map-dark' })); + }); + it('should throw an error if a theme is not light or dark', async () => { for (const theme of ['dark1', true, 123, '', null, undefined]) { const { status, body } = await request(app) diff --git a/e2e/src/api/specs/timeline.e2e-spec.ts b/e2e/src/api/specs/timeline.e2e-spec.ts new file mode 100644 index 000000000..84daa19f4 --- /dev/null +++ b/e2e/src/api/specs/timeline.e2e-spec.ts @@ -0,0 +1,193 @@ +import { AssetFileUploadResponseDto, LoginResponseDto, SharedLinkType, TimeBucketSize } from '@immich/sdk'; +import { DateTime } from 'luxon'; +import { createUserDto } from 'src/fixtures'; +import { errorDto } from 'src/responses'; +import { app, utils } from 'src/utils'; +import request from 'supertest'; +import { beforeAll, describe, expect, it } from 'vitest'; + +// TODO this should probably be a test util function +const today = DateTime.fromObject({ + year: 2023, + month: 11, + day: 3, +}) as DateTime; +const yesterday = today.minus({ days: 1 }); + +describe('/timeline', () => { + let admin: LoginResponseDto; + let user: LoginResponseDto; + let timeBucketUser: LoginResponseDto; + + let userAssets: AssetFileUploadResponseDto[]; + + beforeAll(async () => { + await utils.resetDatabase(); + admin = await utils.adminSetup({ onboarding: false }); + [user, timeBucketUser] = await Promise.all([ + utils.userSetup(admin.accessToken, createUserDto.create('1')), + utils.userSetup(admin.accessToken, createUserDto.create('time-bucket')), + ]); + + userAssets = await Promise.all([ + utils.createAsset(user.accessToken), + utils.createAsset(user.accessToken), + utils.createAsset(user.accessToken, { + isFavorite: true, + isReadOnly: true, + fileCreatedAt: yesterday.toISO(), + fileModifiedAt: yesterday.toISO(), + assetData: { filename: 'example.mp4' }, + }), + utils.createAsset(user.accessToken), + utils.createAsset(user.accessToken), + ]); + + await Promise.all([ + utils.createAsset(timeBucketUser.accessToken, { fileCreatedAt: new Date('1970-01-01').toISOString() }), + utils.createAsset(timeBucketUser.accessToken, { fileCreatedAt: new Date('1970-02-10').toISOString() }), + utils.createAsset(timeBucketUser.accessToken, { fileCreatedAt: new Date('1970-02-11').toISOString() }), + utils.createAsset(timeBucketUser.accessToken, { fileCreatedAt: new Date('1970-02-11').toISOString() }), + ]); + }); + + describe('GET /timeline/buckets', () => { + it('should require authentication', async () => { + const { status, body } = await request(app).get('/timeline/buckets').query({ size: TimeBucketSize.Month }); + expect(status).toBe(401); + expect(body).toEqual(errorDto.unauthorized); + }); + + it('should get time buckets by month', async () => { + const { status, body } = await request(app) + .get('/timeline/buckets') + .set('Authorization', `Bearer ${timeBucketUser.accessToken}`) + .query({ size: TimeBucketSize.Month }); + + expect(status).toBe(200); + expect(body).toEqual( + expect.arrayContaining([ + { count: 3, timeBucket: '1970-02-01T00:00:00.000Z' }, + { count: 1, timeBucket: '1970-01-01T00:00:00.000Z' }, + ]), + ); + }); + + it('should not allow access for unrelated shared links', async () => { + const sharedLink = await utils.createSharedLink(user.accessToken, { + type: SharedLinkType.Individual, + assetIds: userAssets.map(({ id }) => id), + }); + + const { status, body } = await request(app) + .get('/timeline/buckets') + .query({ key: sharedLink.key, size: TimeBucketSize.Month }); + + expect(status).toBe(400); + expect(body).toEqual(errorDto.noPermission); + }); + + it('should get time buckets by day', async () => { + const { status, body } = await request(app) + .get('/timeline/buckets') + .set('Authorization', `Bearer ${timeBucketUser.accessToken}`) + .query({ size: TimeBucketSize.Day }); + + expect(status).toBe(200); + expect(body).toEqual([ + { count: 2, timeBucket: '1970-02-11T00:00:00.000Z' }, + { count: 1, timeBucket: '1970-02-10T00:00:00.000Z' }, + { count: 1, timeBucket: '1970-01-01T00:00:00.000Z' }, + ]); + }); + + it('should return error if time bucket is requested with partners asset and archived', async () => { + const req1 = await request(app) + .get('/timeline/buckets') + .set('Authorization', `Bearer ${timeBucketUser.accessToken}`) + .query({ size: TimeBucketSize.Month, withPartners: true, isArchived: true }); + + expect(req1.status).toBe(400); + expect(req1.body).toEqual(errorDto.badRequest()); + + const req2 = await request(app) + .get('/timeline/buckets') + .set('Authorization', `Bearer ${user.accessToken}`) + .query({ size: TimeBucketSize.Month, withPartners: true, isArchived: undefined }); + + expect(req2.status).toBe(400); + expect(req2.body).toEqual(errorDto.badRequest()); + }); + + it('should return error if time bucket is requested with partners asset and favorite', async () => { + const req1 = await request(app) + .get('/timeline/buckets') + .set('Authorization', `Bearer ${timeBucketUser.accessToken}`) + .query({ size: TimeBucketSize.Month, withPartners: true, isFavorite: true }); + + expect(req1.status).toBe(400); + expect(req1.body).toEqual(errorDto.badRequest()); + + const req2 = await request(app) + .get('/timeline/buckets') + .set('Authorization', `Bearer ${timeBucketUser.accessToken}`) + .query({ size: TimeBucketSize.Month, withPartners: true, isFavorite: false }); + + expect(req2.status).toBe(400); + expect(req2.body).toEqual(errorDto.badRequest()); + }); + + it('should return error if time bucket is requested with partners asset and trash', async () => { + const req = await request(app) + .get('/timeline/buckets') + .set('Authorization', `Bearer ${user.accessToken}`) + .query({ size: TimeBucketSize.Month, withPartners: true, isTrashed: true }); + + expect(req.status).toBe(400); + expect(req.body).toEqual(errorDto.badRequest()); + }); + }); + + describe('GET /timeline/bucket', () => { + it('should require authentication', async () => { + const { status, body } = await request(app).get('/timeline/bucket').query({ + size: TimeBucketSize.Month, + timeBucket: '1900-01-01T00:00:00.000Z', + }); + + expect(status).toBe(401); + expect(body).toEqual(errorDto.unauthorized); + }); + + it('should handle 5 digit years', async () => { + const { status, body } = await request(app) + .get('/timeline/bucket') + .query({ size: TimeBucketSize.Month, timeBucket: '+012345-01-01T00:00:00.000Z' }) + .set('Authorization', `Bearer ${timeBucketUser.accessToken}`); + + expect(status).toBe(200); + expect(body).toEqual([]); + }); + + // TODO enable date string validation while still accepting 5 digit years + // it('should fail if time bucket is invalid', async () => { + // const { status, body } = await request(app) + // .get('/timeline/bucket') + // .set('Authorization', `Bearer ${user.accessToken}`) + // .query({ size: TimeBucketSize.Month, timeBucket: 'foo' }); + + // expect(status).toBe(400); + // expect(body).toEqual(errorDto.badRequest); + // }); + + it('should return time bucket', async () => { + const { status, body } = await request(app) + .get('/timeline/bucket') + .set('Authorization', `Bearer ${timeBucketUser.accessToken}`) + .query({ size: TimeBucketSize.Month, timeBucket: '1970-02-10T00:00:00.000Z' }); + + expect(status).toBe(200); + expect(body).toEqual([]); + }); + }); +}); diff --git a/e2e/src/cli/specs/server-info.e2e-spec.ts b/e2e/src/cli/specs/server-info.e2e-spec.ts index f207f1fa2..13eefd3df 100644 --- a/e2e/src/cli/specs/server-info.e2e-spec.ts +++ b/e2e/src/cli/specs/server-info.e2e-spec.ts @@ -11,13 +11,16 @@ describe(`immich server-info`, () => { it('should return the server info', async () => { const { stderr, stdout, exitCode } = await immichCli(['server-info']); expect(stdout.split('\n')).toEqual([ - expect.stringContaining('Server Version:'), - expect.stringContaining('Image Types:'), - expect.stringContaining('Video Types:'), - 'Statistics:', - ' Images: 0', - ' Videos: 0', - ' Total: 0', + expect.stringContaining('Server Info (via admin@immich.cloud'), + ' Url: http://127.0.0.1:2283/api', + expect.stringContaining('Version:'), + ' Formats:', + expect.stringContaining('Images:'), + expect.stringContaining('Videos:'), + ' Statistics:', + ' Images: 0', + ' Videos: 0', + ' Total: 0', ]); expect(stderr).toBe(''); expect(exitCode).toBe(0); diff --git a/machine-learning/pyproject.toml b/machine-learning/pyproject.toml index 6c55ada1c..dafd9d097 100644 --- a/machine-learning/pyproject.toml +++ b/machine-learning/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "machine-learning" -version = "1.99.0" +version = "1.100.0" description = "" authors = ["Hau Tran "] readme = "README.md" diff --git a/mobile/android/fastlane/Fastfile b/mobile/android/fastlane/Fastfile index c046ee56a..f7edc199d 100644 --- a/mobile/android/fastlane/Fastfile +++ b/mobile/android/fastlane/Fastfile @@ -35,8 +35,8 @@ platform :android do task: 'bundle', build_type: 'Release', properties: { - "android.injected.version.code" => 129, - "android.injected.version.name" => "1.99.0", + "android.injected.version.code" => 130, + "android.injected.version.name" => "1.100.0", } ) upload_to_play_store(skip_upload_apk: true, skip_upload_images: true, skip_upload_screenshots: true, aab: '../build/app/outputs/bundle/release/app-release.aab') diff --git a/mobile/android/fastlane/report.xml b/mobile/android/fastlane/report.xml index 4afb67dd1..c9be4ad53 100644 --- a/mobile/android/fastlane/report.xml +++ b/mobile/android/fastlane/report.xml @@ -5,17 +5,17 @@ - + - + - + diff --git a/mobile/ios/Podfile.lock b/mobile/ios/Podfile.lock index 8ee2cdcc0..e77749ffd 100644 --- a/mobile/ios/Podfile.lock +++ b/mobile/ios/Podfile.lock @@ -151,7 +151,7 @@ SPEC CHECKSUMS: flutter_native_splash: 52501b97d1c0a5f898d687f1646226c1f93c56ef flutter_udid: a2482c67a61b9c806ef59dd82ed8d007f1b7ac04 flutter_web_auth: c25208760459cec375a3c39f6a8759165ca0fa4d - fluttertoast: 31b00dabfa7fb7bacd9e7dbee580d7a2ff4bf265 + fluttertoast: fafc4fa4d01a6a9e4f772ecd190ffa525e9e2d9c geolocator_apple: 9157311f654584b9bb72686c55fc02a97b73f461 image_picker_ios: 99dfe1854b4fa34d0364e74a78448a0151025425 integration_test: 13825b8a9334a850581300559b8839134b124670 @@ -175,4 +175,4 @@ SPEC CHECKSUMS: PODFILE CHECKSUM: 64c9b5291666c0ca3caabdfe9865c141ac40321d -COCOAPODS: 1.15.2 +COCOAPODS: 1.12.1 diff --git a/mobile/ios/Runner.xcodeproj/project.pbxproj b/mobile/ios/Runner.xcodeproj/project.pbxproj index 2ca8dd5ff..48c76d1e0 100644 --- a/mobile/ios/Runner.xcodeproj/project.pbxproj +++ b/mobile/ios/Runner.xcodeproj/project.pbxproj @@ -383,7 +383,7 @@ CODE_SIGN_ENTITLEMENTS = Runner/RunnerProfile.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 145; + CURRENT_PROJECT_VERSION = 146; DEVELOPMENT_TEAM = 2F67MQ8R79; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; @@ -525,7 +525,7 @@ CLANG_ENABLE_MODULES = YES; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 145; + CURRENT_PROJECT_VERSION = 146; DEVELOPMENT_TEAM = 2F67MQ8R79; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; @@ -553,7 +553,7 @@ CLANG_ENABLE_MODULES = YES; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 145; + CURRENT_PROJECT_VERSION = 146; DEVELOPMENT_TEAM = 2F67MQ8R79; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; diff --git a/mobile/ios/Runner/Info.plist b/mobile/ios/Runner/Info.plist index 9570af99d..b49e7e5b7 100644 --- a/mobile/ios/Runner/Info.plist +++ b/mobile/ios/Runner/Info.plist @@ -55,11 +55,11 @@ CFBundlePackageType APPL CFBundleShortVersionString - 1.99.0 + 1.100.0 CFBundleSignature ???? CFBundleVersion - 145 + 146 FLTEnableImpeller ITSAppUsesNonExemptEncryption diff --git a/mobile/ios/fastlane/Fastfile b/mobile/ios/fastlane/Fastfile index a2ad017af..9b9766b8c 100644 --- a/mobile/ios/fastlane/Fastfile +++ b/mobile/ios/fastlane/Fastfile @@ -19,7 +19,7 @@ platform :ios do desc "iOS Beta" lane :beta do increment_version_number( - version_number: "1.99.0" + version_number: "1.100.0" ) increment_build_number( build_number: latest_testflight_build_number + 1, diff --git a/mobile/ios/fastlane/report.xml b/mobile/ios/fastlane/report.xml index 2b91e3faf..47696ac0c 100644 --- a/mobile/ios/fastlane/report.xml +++ b/mobile/ios/fastlane/report.xml @@ -5,32 +5,32 @@ - + - + - + - + - + - + diff --git a/mobile/openapi/.openapi-generator/FILES b/mobile/openapi/.openapi-generator/FILES index ddebdaf77..9ec77670f 100644 --- a/mobile/openapi/.openapi-generator/FILES +++ b/mobile/openapi/.openapi-generator/FILES @@ -169,6 +169,7 @@ doc/TagTypeEnum.md doc/ThumbnailFormat.md doc/TimeBucketResponseDto.md doc/TimeBucketSize.md +doc/TimelineApi.md doc/ToneMapping.md doc/TranscodeHWAccel.md doc/TranscodePolicy.md @@ -211,6 +212,7 @@ lib/api/server_info_api.dart lib/api/shared_link_api.dart lib/api/system_config_api.dart lib/api/tag_api.dart +lib/api/timeline_api.dart lib/api/trash_api.dart lib/api/user_api.dart lib/api_client.dart @@ -556,6 +558,7 @@ test/tag_type_enum_test.dart test/thumbnail_format_test.dart test/time_bucket_response_dto_test.dart test/time_bucket_size_test.dart +test/timeline_api_test.dart test/tone_mapping_test.dart test/transcode_hw_accel_test.dart test/transcode_policy_test.dart diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index 3b605abfc..bfdac06c4 100644 --- a/mobile/openapi/README.md +++ b/mobile/openapi/README.md @@ -3,7 +3,7 @@ Immich API This Dart package is automatically generated by the [OpenAPI Generator](https://openapi-generator.tech) project: -- API version: 1.99.0 +- API version: 1.100.0 - Build package: org.openapitools.codegen.languages.DartClientCodegen ## Requirements @@ -105,8 +105,6 @@ Class | Method | HTTP request | Description *AssetApi* | [**getMapMarkers**](doc//AssetApi.md#getmapmarkers) | **GET** /asset/map-marker | *AssetApi* | [**getMemoryLane**](doc//AssetApi.md#getmemorylane) | **GET** /asset/memory-lane | *AssetApi* | [**getRandom**](doc//AssetApi.md#getrandom) | **GET** /asset/random | -*AssetApi* | [**getTimeBucket**](doc//AssetApi.md#gettimebucket) | **GET** /asset/time-bucket | -*AssetApi* | [**getTimeBuckets**](doc//AssetApi.md#gettimebuckets) | **GET** /asset/time-buckets | *AssetApi* | [**runAssetJobs**](doc//AssetApi.md#runassetjobs) | **POST** /asset/jobs | *AssetApi* | [**searchAssets**](doc//AssetApi.md#searchassets) | **GET** /assets | *AssetApi* | [**serveFile**](doc//AssetApi.md#servefile) | **GET** /asset/file/{id} | @@ -199,6 +197,8 @@ Class | Method | HTTP request | Description *TagApi* | [**tagAssets**](doc//TagApi.md#tagassets) | **PUT** /tag/{id}/assets | *TagApi* | [**untagAssets**](doc//TagApi.md#untagassets) | **DELETE** /tag/{id}/assets | *TagApi* | [**updateTag**](doc//TagApi.md#updatetag) | **PATCH** /tag/{id} | +*TimelineApi* | [**getTimeBucket**](doc//TimelineApi.md#gettimebucket) | **GET** /timeline/bucket | +*TimelineApi* | [**getTimeBuckets**](doc//TimelineApi.md#gettimebuckets) | **GET** /timeline/buckets | *TrashApi* | [**emptyTrash**](doc//TrashApi.md#emptytrash) | **POST** /trash/empty | *TrashApi* | [**restoreAssets**](doc//TrashApi.md#restoreassets) | **POST** /trash/restore/assets | *TrashApi* | [**restoreTrash**](doc//TrashApi.md#restoretrash) | **POST** /trash/restore | diff --git a/mobile/openapi/doc/AssetApi.md b/mobile/openapi/doc/AssetApi.md index 1aaf195f3..0778485c3 100644 --- a/mobile/openapi/doc/AssetApi.md +++ b/mobile/openapi/doc/AssetApi.md @@ -23,8 +23,6 @@ Method | HTTP request | Description [**getMapMarkers**](AssetApi.md#getmapmarkers) | **GET** /asset/map-marker | [**getMemoryLane**](AssetApi.md#getmemorylane) | **GET** /asset/memory-lane | [**getRandom**](AssetApi.md#getrandom) | **GET** /asset/random | -[**getTimeBucket**](AssetApi.md#gettimebucket) | **GET** /asset/time-bucket | -[**getTimeBuckets**](AssetApi.md#gettimebuckets) | **GET** /asset/time-buckets | [**runAssetJobs**](AssetApi.md#runassetjobs) | **POST** /asset/jobs | [**searchAssets**](AssetApi.md#searchassets) | **GET** /assets | [**serveFile**](AssetApi.md#servefile) | **GET** /asset/file/{id} | @@ -833,158 +831,6 @@ Name | Type | Description | Notes [[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md) -# **getTimeBucket** -> List getTimeBucket(size, timeBucket, albumId, isArchived, isFavorite, isTrashed, key, order, personId, userId, withPartners, withStacked) - - - -### Example -```dart -import 'package:openapi/api.dart'; -// TODO Configure API key authorization: cookie -//defaultApiClient.getAuthentication('cookie').apiKey = 'YOUR_API_KEY'; -// uncomment below to setup prefix (e.g. Bearer) for API key, if needed -//defaultApiClient.getAuthentication('cookie').apiKeyPrefix = 'Bearer'; -// TODO Configure API key authorization: api_key -//defaultApiClient.getAuthentication('api_key').apiKey = 'YOUR_API_KEY'; -// uncomment below to setup prefix (e.g. Bearer) for API key, if needed -//defaultApiClient.getAuthentication('api_key').apiKeyPrefix = 'Bearer'; -// TODO Configure HTTP Bearer authorization: bearer -// Case 1. Use String Token -//defaultApiClient.getAuthentication('bearer').setAccessToken('YOUR_ACCESS_TOKEN'); -// Case 2. Use Function which generate token. -// String yourTokenGeneratorFunction() { ... } -//defaultApiClient.getAuthentication('bearer').setAccessToken(yourTokenGeneratorFunction); - -final api_instance = AssetApi(); -final size = ; // TimeBucketSize | -final timeBucket = timeBucket_example; // String | -final albumId = 38400000-8cf0-11bd-b23e-10b96e4ef00d; // String | -final isArchived = true; // bool | -final isFavorite = true; // bool | -final isTrashed = true; // bool | -final key = key_example; // String | -final order = ; // AssetOrder | -final personId = 38400000-8cf0-11bd-b23e-10b96e4ef00d; // String | -final userId = 38400000-8cf0-11bd-b23e-10b96e4ef00d; // String | -final withPartners = true; // bool | -final withStacked = true; // bool | - -try { - final result = api_instance.getTimeBucket(size, timeBucket, albumId, isArchived, isFavorite, isTrashed, key, order, personId, userId, withPartners, withStacked); - print(result); -} catch (e) { - print('Exception when calling AssetApi->getTimeBucket: $e\n'); -} -``` - -### Parameters - -Name | Type | Description | Notes -------------- | ------------- | ------------- | ------------- - **size** | [**TimeBucketSize**](.md)| | - **timeBucket** | **String**| | - **albumId** | **String**| | [optional] - **isArchived** | **bool**| | [optional] - **isFavorite** | **bool**| | [optional] - **isTrashed** | **bool**| | [optional] - **key** | **String**| | [optional] - **order** | [**AssetOrder**](.md)| | [optional] - **personId** | **String**| | [optional] - **userId** | **String**| | [optional] - **withPartners** | **bool**| | [optional] - **withStacked** | **bool**| | [optional] - -### Return type - -[**List**](AssetResponseDto.md) - -### Authorization - -[cookie](../README.md#cookie), [api_key](../README.md#api_key), [bearer](../README.md#bearer) - -### HTTP request headers - - - **Content-Type**: Not defined - - **Accept**: application/json - -[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md) - -# **getTimeBuckets** -> List getTimeBuckets(size, albumId, isArchived, isFavorite, isTrashed, key, order, personId, userId, withPartners, withStacked) - - - -### Example -```dart -import 'package:openapi/api.dart'; -// TODO Configure API key authorization: cookie -//defaultApiClient.getAuthentication('cookie').apiKey = 'YOUR_API_KEY'; -// uncomment below to setup prefix (e.g. Bearer) for API key, if needed -//defaultApiClient.getAuthentication('cookie').apiKeyPrefix = 'Bearer'; -// TODO Configure API key authorization: api_key -//defaultApiClient.getAuthentication('api_key').apiKey = 'YOUR_API_KEY'; -// uncomment below to setup prefix (e.g. Bearer) for API key, if needed -//defaultApiClient.getAuthentication('api_key').apiKeyPrefix = 'Bearer'; -// TODO Configure HTTP Bearer authorization: bearer -// Case 1. Use String Token -//defaultApiClient.getAuthentication('bearer').setAccessToken('YOUR_ACCESS_TOKEN'); -// Case 2. Use Function which generate token. -// String yourTokenGeneratorFunction() { ... } -//defaultApiClient.getAuthentication('bearer').setAccessToken(yourTokenGeneratorFunction); - -final api_instance = AssetApi(); -final size = ; // TimeBucketSize | -final albumId = 38400000-8cf0-11bd-b23e-10b96e4ef00d; // String | -final isArchived = true; // bool | -final isFavorite = true; // bool | -final isTrashed = true; // bool | -final key = key_example; // String | -final order = ; // AssetOrder | -final personId = 38400000-8cf0-11bd-b23e-10b96e4ef00d; // String | -final userId = 38400000-8cf0-11bd-b23e-10b96e4ef00d; // String | -final withPartners = true; // bool | -final withStacked = true; // bool | - -try { - final result = api_instance.getTimeBuckets(size, albumId, isArchived, isFavorite, isTrashed, key, order, personId, userId, withPartners, withStacked); - print(result); -} catch (e) { - print('Exception when calling AssetApi->getTimeBuckets: $e\n'); -} -``` - -### Parameters - -Name | Type | Description | Notes -------------- | ------------- | ------------- | ------------- - **size** | [**TimeBucketSize**](.md)| | - **albumId** | **String**| | [optional] - **isArchived** | **bool**| | [optional] - **isFavorite** | **bool**| | [optional] - **isTrashed** | **bool**| | [optional] - **key** | **String**| | [optional] - **order** | [**AssetOrder**](.md)| | [optional] - **personId** | **String**| | [optional] - **userId** | **String**| | [optional] - **withPartners** | **bool**| | [optional] - **withStacked** | **bool**| | [optional] - -### Return type - -[**List**](TimeBucketResponseDto.md) - -### Authorization - -[cookie](../README.md#cookie), [api_key](../README.md#api_key), [bearer](../README.md#bearer) - -### HTTP request headers - - - **Content-Type**: Not defined - - **Accept**: application/json - -[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md) - # **runAssetJobs** > runAssetJobs(assetJobsDto) diff --git a/mobile/openapi/doc/MemoryLaneResponseDto.md b/mobile/openapi/doc/MemoryLaneResponseDto.md index 471274446..54d1a4769 100644 --- a/mobile/openapi/doc/MemoryLaneResponseDto.md +++ b/mobile/openapi/doc/MemoryLaneResponseDto.md @@ -10,6 +10,7 @@ Name | Type | Description | Notes ------------ | ------------- | ------------- | ------------- **assets** | [**List**](AssetResponseDto.md) | | [default to const []] **title** | **String** | | +**yearsAgo** | **int** | | [[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) diff --git a/mobile/openapi/doc/SystemConfigApi.md b/mobile/openapi/doc/SystemConfigApi.md index e782265b4..2d1ce0da6 100644 --- a/mobile/openapi/doc/SystemConfigApi.md +++ b/mobile/openapi/doc/SystemConfigApi.md @@ -119,7 +119,7 @@ This endpoint does not need any parameter. [[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md) # **getMapStyle** -> Object getMapStyle(theme) +> Object getMapStyle(theme, key) @@ -143,9 +143,10 @@ import 'package:openapi/api.dart'; final api_instance = SystemConfigApi(); final theme = ; // MapTheme | +final key = key_example; // String | try { - final result = api_instance.getMapStyle(theme); + final result = api_instance.getMapStyle(theme, key); print(result); } catch (e) { print('Exception when calling SystemConfigApi->getMapStyle: $e\n'); @@ -157,6 +158,7 @@ try { Name | Type | Description | Notes ------------- | ------------- | ------------- | ------------- **theme** | [**MapTheme**](.md)| | + **key** | **String**| | [optional] ### Return type diff --git a/mobile/openapi/doc/TimelineApi.md b/mobile/openapi/doc/TimelineApi.md new file mode 100644 index 000000000..e98efe7e2 --- /dev/null +++ b/mobile/openapi/doc/TimelineApi.md @@ -0,0 +1,167 @@ +# openapi.api.TimelineApi + +## Load the API package +```dart +import 'package:openapi/api.dart'; +``` + +All URIs are relative to */api* + +Method | HTTP request | Description +------------- | ------------- | ------------- +[**getTimeBucket**](TimelineApi.md#gettimebucket) | **GET** /timeline/bucket | +[**getTimeBuckets**](TimelineApi.md#gettimebuckets) | **GET** /timeline/buckets | + + +# **getTimeBucket** +> List getTimeBucket(size, timeBucket, albumId, isArchived, isFavorite, isTrashed, key, order, personId, userId, withPartners, withStacked) + + + +### Example +```dart +import 'package:openapi/api.dart'; +// TODO Configure API key authorization: cookie +//defaultApiClient.getAuthentication('cookie').apiKey = 'YOUR_API_KEY'; +// uncomment below to setup prefix (e.g. Bearer) for API key, if needed +//defaultApiClient.getAuthentication('cookie').apiKeyPrefix = 'Bearer'; +// TODO Configure API key authorization: api_key +//defaultApiClient.getAuthentication('api_key').apiKey = 'YOUR_API_KEY'; +// uncomment below to setup prefix (e.g. Bearer) for API key, if needed +//defaultApiClient.getAuthentication('api_key').apiKeyPrefix = 'Bearer'; +// TODO Configure HTTP Bearer authorization: bearer +// Case 1. Use String Token +//defaultApiClient.getAuthentication('bearer').setAccessToken('YOUR_ACCESS_TOKEN'); +// Case 2. Use Function which generate token. +// String yourTokenGeneratorFunction() { ... } +//defaultApiClient.getAuthentication('bearer').setAccessToken(yourTokenGeneratorFunction); + +final api_instance = TimelineApi(); +final size = ; // TimeBucketSize | +final timeBucket = timeBucket_example; // String | +final albumId = 38400000-8cf0-11bd-b23e-10b96e4ef00d; // String | +final isArchived = true; // bool | +final isFavorite = true; // bool | +final isTrashed = true; // bool | +final key = key_example; // String | +final order = ; // AssetOrder | +final personId = 38400000-8cf0-11bd-b23e-10b96e4ef00d; // String | +final userId = 38400000-8cf0-11bd-b23e-10b96e4ef00d; // String | +final withPartners = true; // bool | +final withStacked = true; // bool | + +try { + final result = api_instance.getTimeBucket(size, timeBucket, albumId, isArchived, isFavorite, isTrashed, key, order, personId, userId, withPartners, withStacked); + print(result); +} catch (e) { + print('Exception when calling TimelineApi->getTimeBucket: $e\n'); +} +``` + +### Parameters + +Name | Type | Description | Notes +------------- | ------------- | ------------- | ------------- + **size** | [**TimeBucketSize**](.md)| | + **timeBucket** | **String**| | + **albumId** | **String**| | [optional] + **isArchived** | **bool**| | [optional] + **isFavorite** | **bool**| | [optional] + **isTrashed** | **bool**| | [optional] + **key** | **String**| | [optional] + **order** | [**AssetOrder**](.md)| | [optional] + **personId** | **String**| | [optional] + **userId** | **String**| | [optional] + **withPartners** | **bool**| | [optional] + **withStacked** | **bool**| | [optional] + +### Return type + +[**List**](AssetResponseDto.md) + +### Authorization + +[cookie](../README.md#cookie), [api_key](../README.md#api_key), [bearer](../README.md#bearer) + +### HTTP request headers + + - **Content-Type**: Not defined + - **Accept**: application/json + +[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md) + +# **getTimeBuckets** +> List getTimeBuckets(size, albumId, isArchived, isFavorite, isTrashed, key, order, personId, userId, withPartners, withStacked) + + + +### Example +```dart +import 'package:openapi/api.dart'; +// TODO Configure API key authorization: cookie +//defaultApiClient.getAuthentication('cookie').apiKey = 'YOUR_API_KEY'; +// uncomment below to setup prefix (e.g. Bearer) for API key, if needed +//defaultApiClient.getAuthentication('cookie').apiKeyPrefix = 'Bearer'; +// TODO Configure API key authorization: api_key +//defaultApiClient.getAuthentication('api_key').apiKey = 'YOUR_API_KEY'; +// uncomment below to setup prefix (e.g. Bearer) for API key, if needed +//defaultApiClient.getAuthentication('api_key').apiKeyPrefix = 'Bearer'; +// TODO Configure HTTP Bearer authorization: bearer +// Case 1. Use String Token +//defaultApiClient.getAuthentication('bearer').setAccessToken('YOUR_ACCESS_TOKEN'); +// Case 2. Use Function which generate token. +// String yourTokenGeneratorFunction() { ... } +//defaultApiClient.getAuthentication('bearer').setAccessToken(yourTokenGeneratorFunction); + +final api_instance = TimelineApi(); +final size = ; // TimeBucketSize | +final albumId = 38400000-8cf0-11bd-b23e-10b96e4ef00d; // String | +final isArchived = true; // bool | +final isFavorite = true; // bool | +final isTrashed = true; // bool | +final key = key_example; // String | +final order = ; // AssetOrder | +final personId = 38400000-8cf0-11bd-b23e-10b96e4ef00d; // String | +final userId = 38400000-8cf0-11bd-b23e-10b96e4ef00d; // String | +final withPartners = true; // bool | +final withStacked = true; // bool | + +try { + final result = api_instance.getTimeBuckets(size, albumId, isArchived, isFavorite, isTrashed, key, order, personId, userId, withPartners, withStacked); + print(result); +} catch (e) { + print('Exception when calling TimelineApi->getTimeBuckets: $e\n'); +} +``` + +### Parameters + +Name | Type | Description | Notes +------------- | ------------- | ------------- | ------------- + **size** | [**TimeBucketSize**](.md)| | + **albumId** | **String**| | [optional] + **isArchived** | **bool**| | [optional] + **isFavorite** | **bool**| | [optional] + **isTrashed** | **bool**| | [optional] + **key** | **String**| | [optional] + **order** | [**AssetOrder**](.md)| | [optional] + **personId** | **String**| | [optional] + **userId** | **String**| | [optional] + **withPartners** | **bool**| | [optional] + **withStacked** | **bool**| | [optional] + +### Return type + +[**List**](TimeBucketResponseDto.md) + +### Authorization + +[cookie](../README.md#cookie), [api_key](../README.md#api_key), [bearer](../README.md#bearer) + +### HTTP request headers + + - **Content-Type**: Not defined + - **Accept**: application/json + +[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md) + diff --git a/mobile/openapi/lib/api.dart b/mobile/openapi/lib/api.dart index 5b49d8d67..2abc20fde 100644 --- a/mobile/openapi/lib/api.dart +++ b/mobile/openapi/lib/api.dart @@ -47,6 +47,7 @@ part 'api/server_info_api.dart'; part 'api/shared_link_api.dart'; part 'api/system_config_api.dart'; part 'api/tag_api.dart'; +part 'api/timeline_api.dart'; part 'api/trash_api.dart'; part 'api/user_api.dart'; diff --git a/mobile/openapi/lib/api/asset_api.dart b/mobile/openapi/lib/api/asset_api.dart index b0395bfcb..e16ccc73e 100644 --- a/mobile/openapi/lib/api/asset_api.dart +++ b/mobile/openapi/lib/api/asset_api.dart @@ -835,255 +835,6 @@ class AssetApi { return null; } - /// Performs an HTTP 'GET /asset/time-bucket' operation and returns the [Response]. - /// Parameters: - /// - /// * [TimeBucketSize] size (required): - /// - /// * [String] timeBucket (required): - /// - /// * [String] albumId: - /// - /// * [bool] isArchived: - /// - /// * [bool] isFavorite: - /// - /// * [bool] isTrashed: - /// - /// * [String] key: - /// - /// * [AssetOrder] order: - /// - /// * [String] personId: - /// - /// * [String] userId: - /// - /// * [bool] withPartners: - /// - /// * [bool] withStacked: - Future getTimeBucketWithHttpInfo(TimeBucketSize size, String timeBucket, { String? albumId, bool? isArchived, bool? isFavorite, bool? isTrashed, String? key, AssetOrder? order, String? personId, String? userId, bool? withPartners, bool? withStacked, }) async { - // ignore: prefer_const_declarations - final path = r'/asset/time-bucket'; - - // ignore: prefer_final_locals - Object? postBody; - - final queryParams = []; - final headerParams = {}; - final formParams = {}; - - if (albumId != null) { - queryParams.addAll(_queryParams('', 'albumId', albumId)); - } - if (isArchived != null) { - queryParams.addAll(_queryParams('', 'isArchived', isArchived)); - } - if (isFavorite != null) { - queryParams.addAll(_queryParams('', 'isFavorite', isFavorite)); - } - if (isTrashed != null) { - queryParams.addAll(_queryParams('', 'isTrashed', isTrashed)); - } - if (key != null) { - queryParams.addAll(_queryParams('', 'key', key)); - } - if (order != null) { - queryParams.addAll(_queryParams('', 'order', order)); - } - if (personId != null) { - queryParams.addAll(_queryParams('', 'personId', personId)); - } - queryParams.addAll(_queryParams('', 'size', size)); - queryParams.addAll(_queryParams('', 'timeBucket', timeBucket)); - if (userId != null) { - queryParams.addAll(_queryParams('', 'userId', userId)); - } - if (withPartners != null) { - queryParams.addAll(_queryParams('', 'withPartners', withPartners)); - } - if (withStacked != null) { - queryParams.addAll(_queryParams('', 'withStacked', withStacked)); - } - - const contentTypes = []; - - - return apiClient.invokeAPI( - path, - 'GET', - queryParams, - postBody, - headerParams, - formParams, - contentTypes.isEmpty ? null : contentTypes.first, - ); - } - - /// Parameters: - /// - /// * [TimeBucketSize] size (required): - /// - /// * [String] timeBucket (required): - /// - /// * [String] albumId: - /// - /// * [bool] isArchived: - /// - /// * [bool] isFavorite: - /// - /// * [bool] isTrashed: - /// - /// * [String] key: - /// - /// * [AssetOrder] order: - /// - /// * [String] personId: - /// - /// * [String] userId: - /// - /// * [bool] withPartners: - /// - /// * [bool] withStacked: - Future?> getTimeBucket(TimeBucketSize size, String timeBucket, { String? albumId, bool? isArchived, bool? isFavorite, bool? isTrashed, String? key, AssetOrder? order, String? personId, String? userId, bool? withPartners, bool? withStacked, }) async { - final response = await getTimeBucketWithHttpInfo(size, timeBucket, albumId: albumId, isArchived: isArchived, isFavorite: isFavorite, isTrashed: isTrashed, key: key, order: order, personId: personId, userId: userId, withPartners: withPartners, withStacked: withStacked, ); - if (response.statusCode >= HttpStatus.badRequest) { - throw ApiException(response.statusCode, await _decodeBodyBytes(response)); - } - // When a remote server returns no body with a status of 204, we shall not decode it. - // At the time of writing this, `dart:convert` will throw an "Unexpected end of input" - // FormatException when trying to decode an empty string. - if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) { - final responseBody = await _decodeBodyBytes(response); - return (await apiClient.deserializeAsync(responseBody, 'List') as List) - .cast() - .toList(growable: false); - - } - return null; - } - - /// Performs an HTTP 'GET /asset/time-buckets' operation and returns the [Response]. - /// Parameters: - /// - /// * [TimeBucketSize] size (required): - /// - /// * [String] albumId: - /// - /// * [bool] isArchived: - /// - /// * [bool] isFavorite: - /// - /// * [bool] isTrashed: - /// - /// * [String] key: - /// - /// * [AssetOrder] order: - /// - /// * [String] personId: - /// - /// * [String] userId: - /// - /// * [bool] withPartners: - /// - /// * [bool] withStacked: - Future getTimeBucketsWithHttpInfo(TimeBucketSize size, { String? albumId, bool? isArchived, bool? isFavorite, bool? isTrashed, String? key, AssetOrder? order, String? personId, String? userId, bool? withPartners, bool? withStacked, }) async { - // ignore: prefer_const_declarations - final path = r'/asset/time-buckets'; - - // ignore: prefer_final_locals - Object? postBody; - - final queryParams = []; - final headerParams = {}; - final formParams = {}; - - if (albumId != null) { - queryParams.addAll(_queryParams('', 'albumId', albumId)); - } - if (isArchived != null) { - queryParams.addAll(_queryParams('', 'isArchived', isArchived)); - } - if (isFavorite != null) { - queryParams.addAll(_queryParams('', 'isFavorite', isFavorite)); - } - if (isTrashed != null) { - queryParams.addAll(_queryParams('', 'isTrashed', isTrashed)); - } - if (key != null) { - queryParams.addAll(_queryParams('', 'key', key)); - } - if (order != null) { - queryParams.addAll(_queryParams('', 'order', order)); - } - if (personId != null) { - queryParams.addAll(_queryParams('', 'personId', personId)); - } - queryParams.addAll(_queryParams('', 'size', size)); - if (userId != null) { - queryParams.addAll(_queryParams('', 'userId', userId)); - } - if (withPartners != null) { - queryParams.addAll(_queryParams('', 'withPartners', withPartners)); - } - if (withStacked != null) { - queryParams.addAll(_queryParams('', 'withStacked', withStacked)); - } - - const contentTypes = []; - - - return apiClient.invokeAPI( - path, - 'GET', - queryParams, - postBody, - headerParams, - formParams, - contentTypes.isEmpty ? null : contentTypes.first, - ); - } - - /// Parameters: - /// - /// * [TimeBucketSize] size (required): - /// - /// * [String] albumId: - /// - /// * [bool] isArchived: - /// - /// * [bool] isFavorite: - /// - /// * [bool] isTrashed: - /// - /// * [String] key: - /// - /// * [AssetOrder] order: - /// - /// * [String] personId: - /// - /// * [String] userId: - /// - /// * [bool] withPartners: - /// - /// * [bool] withStacked: - Future?> getTimeBuckets(TimeBucketSize size, { String? albumId, bool? isArchived, bool? isFavorite, bool? isTrashed, String? key, AssetOrder? order, String? personId, String? userId, bool? withPartners, bool? withStacked, }) async { - final response = await getTimeBucketsWithHttpInfo(size, albumId: albumId, isArchived: isArchived, isFavorite: isFavorite, isTrashed: isTrashed, key: key, order: order, personId: personId, userId: userId, withPartners: withPartners, withStacked: withStacked, ); - if (response.statusCode >= HttpStatus.badRequest) { - throw ApiException(response.statusCode, await _decodeBodyBytes(response)); - } - // When a remote server returns no body with a status of 204, we shall not decode it. - // At the time of writing this, `dart:convert` will throw an "Unexpected end of input" - // FormatException when trying to decode an empty string. - if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) { - final responseBody = await _decodeBodyBytes(response); - return (await apiClient.deserializeAsync(responseBody, 'List') as List) - .cast() - .toList(growable: false); - - } - return null; - } - /// Performs an HTTP 'POST /asset/jobs' operation and returns the [Response]. /// Parameters: /// diff --git a/mobile/openapi/lib/api/system_config_api.dart b/mobile/openapi/lib/api/system_config_api.dart index f13f8d52d..276f8c07d 100644 --- a/mobile/openapi/lib/api/system_config_api.dart +++ b/mobile/openapi/lib/api/system_config_api.dart @@ -102,7 +102,9 @@ class SystemConfigApi { /// Parameters: /// /// * [MapTheme] theme (required): - Future getMapStyleWithHttpInfo(MapTheme theme,) async { + /// + /// * [String] key: + Future getMapStyleWithHttpInfo(MapTheme theme, { String? key, }) async { // ignore: prefer_const_declarations final path = r'/system-config/map/style.json'; @@ -113,6 +115,9 @@ class SystemConfigApi { final headerParams = {}; final formParams = {}; + if (key != null) { + queryParams.addAll(_queryParams('', 'key', key)); + } queryParams.addAll(_queryParams('', 'theme', theme)); const contentTypes = []; @@ -132,8 +137,10 @@ class SystemConfigApi { /// Parameters: /// /// * [MapTheme] theme (required): - Future getMapStyle(MapTheme theme,) async { - final response = await getMapStyleWithHttpInfo(theme,); + /// + /// * [String] key: + Future getMapStyle(MapTheme theme, { String? key, }) async { + final response = await getMapStyleWithHttpInfo(theme, key: key, ); if (response.statusCode >= HttpStatus.badRequest) { throw ApiException(response.statusCode, await _decodeBodyBytes(response)); } diff --git a/mobile/openapi/lib/api/timeline_api.dart b/mobile/openapi/lib/api/timeline_api.dart new file mode 100644 index 000000000..0813f3e00 --- /dev/null +++ b/mobile/openapi/lib/api/timeline_api.dart @@ -0,0 +1,267 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.12 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +part of openapi.api; + + +class TimelineApi { + TimelineApi([ApiClient? apiClient]) : apiClient = apiClient ?? defaultApiClient; + + final ApiClient apiClient; + + /// Performs an HTTP 'GET /timeline/bucket' operation and returns the [Response]. + /// Parameters: + /// + /// * [TimeBucketSize] size (required): + /// + /// * [String] timeBucket (required): + /// + /// * [String] albumId: + /// + /// * [bool] isArchived: + /// + /// * [bool] isFavorite: + /// + /// * [bool] isTrashed: + /// + /// * [String] key: + /// + /// * [AssetOrder] order: + /// + /// * [String] personId: + /// + /// * [String] userId: + /// + /// * [bool] withPartners: + /// + /// * [bool] withStacked: + Future getTimeBucketWithHttpInfo(TimeBucketSize size, String timeBucket, { String? albumId, bool? isArchived, bool? isFavorite, bool? isTrashed, String? key, AssetOrder? order, String? personId, String? userId, bool? withPartners, bool? withStacked, }) async { + // ignore: prefer_const_declarations + final path = r'/timeline/bucket'; + + // ignore: prefer_final_locals + Object? postBody; + + final queryParams = []; + final headerParams = {}; + final formParams = {}; + + if (albumId != null) { + queryParams.addAll(_queryParams('', 'albumId', albumId)); + } + if (isArchived != null) { + queryParams.addAll(_queryParams('', 'isArchived', isArchived)); + } + if (isFavorite != null) { + queryParams.addAll(_queryParams('', 'isFavorite', isFavorite)); + } + if (isTrashed != null) { + queryParams.addAll(_queryParams('', 'isTrashed', isTrashed)); + } + if (key != null) { + queryParams.addAll(_queryParams('', 'key', key)); + } + if (order != null) { + queryParams.addAll(_queryParams('', 'order', order)); + } + if (personId != null) { + queryParams.addAll(_queryParams('', 'personId', personId)); + } + queryParams.addAll(_queryParams('', 'size', size)); + queryParams.addAll(_queryParams('', 'timeBucket', timeBucket)); + if (userId != null) { + queryParams.addAll(_queryParams('', 'userId', userId)); + } + if (withPartners != null) { + queryParams.addAll(_queryParams('', 'withPartners', withPartners)); + } + if (withStacked != null) { + queryParams.addAll(_queryParams('', 'withStacked', withStacked)); + } + + const contentTypes = []; + + + return apiClient.invokeAPI( + path, + 'GET', + queryParams, + postBody, + headerParams, + formParams, + contentTypes.isEmpty ? null : contentTypes.first, + ); + } + + /// Parameters: + /// + /// * [TimeBucketSize] size (required): + /// + /// * [String] timeBucket (required): + /// + /// * [String] albumId: + /// + /// * [bool] isArchived: + /// + /// * [bool] isFavorite: + /// + /// * [bool] isTrashed: + /// + /// * [String] key: + /// + /// * [AssetOrder] order: + /// + /// * [String] personId: + /// + /// * [String] userId: + /// + /// * [bool] withPartners: + /// + /// * [bool] withStacked: + Future?> getTimeBucket(TimeBucketSize size, String timeBucket, { String? albumId, bool? isArchived, bool? isFavorite, bool? isTrashed, String? key, AssetOrder? order, String? personId, String? userId, bool? withPartners, bool? withStacked, }) async { + final response = await getTimeBucketWithHttpInfo(size, timeBucket, albumId: albumId, isArchived: isArchived, isFavorite: isFavorite, isTrashed: isTrashed, key: key, order: order, personId: personId, userId: userId, withPartners: withPartners, withStacked: withStacked, ); + if (response.statusCode >= HttpStatus.badRequest) { + throw ApiException(response.statusCode, await _decodeBodyBytes(response)); + } + // When a remote server returns no body with a status of 204, we shall not decode it. + // At the time of writing this, `dart:convert` will throw an "Unexpected end of input" + // FormatException when trying to decode an empty string. + if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) { + final responseBody = await _decodeBodyBytes(response); + return (await apiClient.deserializeAsync(responseBody, 'List') as List) + .cast() + .toList(growable: false); + + } + return null; + } + + /// Performs an HTTP 'GET /timeline/buckets' operation and returns the [Response]. + /// Parameters: + /// + /// * [TimeBucketSize] size (required): + /// + /// * [String] albumId: + /// + /// * [bool] isArchived: + /// + /// * [bool] isFavorite: + /// + /// * [bool] isTrashed: + /// + /// * [String] key: + /// + /// * [AssetOrder] order: + /// + /// * [String] personId: + /// + /// * [String] userId: + /// + /// * [bool] withPartners: + /// + /// * [bool] withStacked: + Future getTimeBucketsWithHttpInfo(TimeBucketSize size, { String? albumId, bool? isArchived, bool? isFavorite, bool? isTrashed, String? key, AssetOrder? order, String? personId, String? userId, bool? withPartners, bool? withStacked, }) async { + // ignore: prefer_const_declarations + final path = r'/timeline/buckets'; + + // ignore: prefer_final_locals + Object? postBody; + + final queryParams = []; + final headerParams = {}; + final formParams = {}; + + if (albumId != null) { + queryParams.addAll(_queryParams('', 'albumId', albumId)); + } + if (isArchived != null) { + queryParams.addAll(_queryParams('', 'isArchived', isArchived)); + } + if (isFavorite != null) { + queryParams.addAll(_queryParams('', 'isFavorite', isFavorite)); + } + if (isTrashed != null) { + queryParams.addAll(_queryParams('', 'isTrashed', isTrashed)); + } + if (key != null) { + queryParams.addAll(_queryParams('', 'key', key)); + } + if (order != null) { + queryParams.addAll(_queryParams('', 'order', order)); + } + if (personId != null) { + queryParams.addAll(_queryParams('', 'personId', personId)); + } + queryParams.addAll(_queryParams('', 'size', size)); + if (userId != null) { + queryParams.addAll(_queryParams('', 'userId', userId)); + } + if (withPartners != null) { + queryParams.addAll(_queryParams('', 'withPartners', withPartners)); + } + if (withStacked != null) { + queryParams.addAll(_queryParams('', 'withStacked', withStacked)); + } + + const contentTypes = []; + + + return apiClient.invokeAPI( + path, + 'GET', + queryParams, + postBody, + headerParams, + formParams, + contentTypes.isEmpty ? null : contentTypes.first, + ); + } + + /// Parameters: + /// + /// * [TimeBucketSize] size (required): + /// + /// * [String] albumId: + /// + /// * [bool] isArchived: + /// + /// * [bool] isFavorite: + /// + /// * [bool] isTrashed: + /// + /// * [String] key: + /// + /// * [AssetOrder] order: + /// + /// * [String] personId: + /// + /// * [String] userId: + /// + /// * [bool] withPartners: + /// + /// * [bool] withStacked: + Future?> getTimeBuckets(TimeBucketSize size, { String? albumId, bool? isArchived, bool? isFavorite, bool? isTrashed, String? key, AssetOrder? order, String? personId, String? userId, bool? withPartners, bool? withStacked, }) async { + final response = await getTimeBucketsWithHttpInfo(size, albumId: albumId, isArchived: isArchived, isFavorite: isFavorite, isTrashed: isTrashed, key: key, order: order, personId: personId, userId: userId, withPartners: withPartners, withStacked: withStacked, ); + if (response.statusCode >= HttpStatus.badRequest) { + throw ApiException(response.statusCode, await _decodeBodyBytes(response)); + } + // When a remote server returns no body with a status of 204, we shall not decode it. + // At the time of writing this, `dart:convert` will throw an "Unexpected end of input" + // FormatException when trying to decode an empty string. + if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) { + final responseBody = await _decodeBodyBytes(response); + return (await apiClient.deserializeAsync(responseBody, 'List') as List) + .cast() + .toList(growable: false); + + } + return null; + } +} diff --git a/mobile/openapi/lib/model/memory_lane_response_dto.dart b/mobile/openapi/lib/model/memory_lane_response_dto.dart index 7d761131d..a0df07938 100644 --- a/mobile/openapi/lib/model/memory_lane_response_dto.dart +++ b/mobile/openapi/lib/model/memory_lane_response_dto.dart @@ -15,30 +15,36 @@ class MemoryLaneResponseDto { MemoryLaneResponseDto({ this.assets = const [], required this.title, + required this.yearsAgo, }); List assets; String title; + int yearsAgo; + @override bool operator ==(Object other) => identical(this, other) || other is MemoryLaneResponseDto && _deepEquality.equals(other.assets, assets) && - other.title == title; + other.title == title && + other.yearsAgo == yearsAgo; @override int get hashCode => // ignore: unnecessary_parenthesis (assets.hashCode) + - (title.hashCode); + (title.hashCode) + + (yearsAgo.hashCode); @override - String toString() => 'MemoryLaneResponseDto[assets=$assets, title=$title]'; + String toString() => 'MemoryLaneResponseDto[assets=$assets, title=$title, yearsAgo=$yearsAgo]'; Map toJson() { final json = {}; json[r'assets'] = this.assets; json[r'title'] = this.title; + json[r'yearsAgo'] = this.yearsAgo; return json; } @@ -52,6 +58,7 @@ class MemoryLaneResponseDto { return MemoryLaneResponseDto( assets: AssetResponseDto.listFromJson(json[r'assets']), title: mapValueOfType(json, r'title')!, + yearsAgo: mapValueOfType(json, r'yearsAgo')!, ); } return null; @@ -101,6 +108,7 @@ class MemoryLaneResponseDto { static const requiredKeys = { 'assets', 'title', + 'yearsAgo', }; } diff --git a/mobile/openapi/test/asset_api_test.dart b/mobile/openapi/test/asset_api_test.dart index d210d0e4d..41d0ac8f5 100644 --- a/mobile/openapi/test/asset_api_test.dart +++ b/mobile/openapi/test/asset_api_test.dart @@ -95,16 +95,6 @@ void main() { // TODO }); - //Future> getTimeBucket(TimeBucketSize size, String timeBucket, { String albumId, bool isArchived, bool isFavorite, bool isTrashed, String key, AssetOrder order, String personId, String userId, bool withPartners, bool withStacked }) async - test('test getTimeBucket', () async { - // TODO - }); - - //Future> getTimeBuckets(TimeBucketSize size, { String albumId, bool isArchived, bool isFavorite, bool isTrashed, String key, AssetOrder order, String personId, String userId, bool withPartners, bool withStacked }) async - test('test getTimeBuckets', () async { - // TODO - }); - //Future runAssetJobs(AssetJobsDto assetJobsDto) async test('test runAssetJobs', () async { // TODO diff --git a/mobile/openapi/test/memory_lane_response_dto_test.dart b/mobile/openapi/test/memory_lane_response_dto_test.dart index 2dad2c356..4ed84f5ec 100644 --- a/mobile/openapi/test/memory_lane_response_dto_test.dart +++ b/mobile/openapi/test/memory_lane_response_dto_test.dart @@ -26,6 +26,11 @@ void main() { // TODO }); + // int yearsAgo + test('to test the property `yearsAgo`', () async { + // TODO + }); + }); diff --git a/mobile/openapi/test/system_config_api_test.dart b/mobile/openapi/test/system_config_api_test.dart index fcf1ecfc5..0330d6a3d 100644 --- a/mobile/openapi/test/system_config_api_test.dart +++ b/mobile/openapi/test/system_config_api_test.dart @@ -27,7 +27,7 @@ void main() { // TODO }); - //Future getMapStyle(MapTheme theme) async + //Future getMapStyle(MapTheme theme, { String key }) async test('test getMapStyle', () async { // TODO }); diff --git a/mobile/openapi/test/timeline_api_test.dart b/mobile/openapi/test/timeline_api_test.dart new file mode 100644 index 000000000..ae217b2e4 --- /dev/null +++ b/mobile/openapi/test/timeline_api_test.dart @@ -0,0 +1,31 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.12 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +import 'package:openapi/api.dart'; +import 'package:test/test.dart'; + + +/// tests for TimelineApi +void main() { + // final instance = TimelineApi(); + + group('tests for TimelineApi', () { + //Future> getTimeBucket(TimeBucketSize size, String timeBucket, { String albumId, bool isArchived, bool isFavorite, bool isTrashed, String key, AssetOrder order, String personId, String userId, bool withPartners, bool withStacked }) async + test('test getTimeBucket', () async { + // TODO + }); + + //Future> getTimeBuckets(TimeBucketSize size, { String albumId, bool isArchived, bool isFavorite, bool isTrashed, String key, AssetOrder order, String personId, String userId, bool withPartners, bool withStacked }) async + test('test getTimeBuckets', () async { + // TODO + }); + + }); +} diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index 852a2bb08..46cfe872b 100644 --- a/mobile/pubspec.yaml +++ b/mobile/pubspec.yaml @@ -2,7 +2,7 @@ name: immich_mobile description: Immich - selfhosted backup media file on mobile phone publish_to: 'none' -version: 1.99.0+129 +version: 1.100.0+130 environment: sdk: '>=3.0.0 <4.0.0' diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index d04b91aa8..a2aae92c4 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -1720,286 +1720,6 @@ ] } }, - "/asset/time-bucket": { - "get": { - "operationId": "getTimeBucket", - "parameters": [ - { - "name": "albumId", - "required": false, - "in": "query", - "schema": { - "format": "uuid", - "type": "string" - } - }, - { - "name": "isArchived", - "required": false, - "in": "query", - "schema": { - "type": "boolean" - } - }, - { - "name": "isFavorite", - "required": false, - "in": "query", - "schema": { - "type": "boolean" - } - }, - { - "name": "isTrashed", - "required": false, - "in": "query", - "schema": { - "type": "boolean" - } - }, - { - "name": "key", - "required": false, - "in": "query", - "schema": { - "type": "string" - } - }, - { - "name": "order", - "required": false, - "in": "query", - "schema": { - "$ref": "#/components/schemas/AssetOrder" - } - }, - { - "name": "personId", - "required": false, - "in": "query", - "schema": { - "format": "uuid", - "type": "string" - } - }, - { - "name": "size", - "required": true, - "in": "query", - "schema": { - "$ref": "#/components/schemas/TimeBucketSize" - } - }, - { - "name": "timeBucket", - "required": true, - "in": "query", - "schema": { - "type": "string" - } - }, - { - "name": "userId", - "required": false, - "in": "query", - "schema": { - "format": "uuid", - "type": "string" - } - }, - { - "name": "withPartners", - "required": false, - "in": "query", - "schema": { - "type": "boolean" - } - }, - { - "name": "withStacked", - "required": false, - "in": "query", - "schema": { - "type": "boolean" - } - } - ], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "items": { - "$ref": "#/components/schemas/AssetResponseDto" - }, - "type": "array" - } - } - }, - "description": "" - } - }, - "security": [ - { - "bearer": [] - }, - { - "cookie": [] - }, - { - "api_key": [] - }, - { - "bearer": [] - }, - { - "cookie": [] - }, - { - "api_key": [] - } - ], - "tags": [ - "Asset" - ] - } - }, - "/asset/time-buckets": { - "get": { - "operationId": "getTimeBuckets", - "parameters": [ - { - "name": "albumId", - "required": false, - "in": "query", - "schema": { - "format": "uuid", - "type": "string" - } - }, - { - "name": "isArchived", - "required": false, - "in": "query", - "schema": { - "type": "boolean" - } - }, - { - "name": "isFavorite", - "required": false, - "in": "query", - "schema": { - "type": "boolean" - } - }, - { - "name": "isTrashed", - "required": false, - "in": "query", - "schema": { - "type": "boolean" - } - }, - { - "name": "key", - "required": false, - "in": "query", - "schema": { - "type": "string" - } - }, - { - "name": "order", - "required": false, - "in": "query", - "schema": { - "$ref": "#/components/schemas/AssetOrder" - } - }, - { - "name": "personId", - "required": false, - "in": "query", - "schema": { - "format": "uuid", - "type": "string" - } - }, - { - "name": "size", - "required": true, - "in": "query", - "schema": { - "$ref": "#/components/schemas/TimeBucketSize" - } - }, - { - "name": "userId", - "required": false, - "in": "query", - "schema": { - "format": "uuid", - "type": "string" - } - }, - { - "name": "withPartners", - "required": false, - "in": "query", - "schema": { - "type": "boolean" - } - }, - { - "name": "withStacked", - "required": false, - "in": "query", - "schema": { - "type": "boolean" - } - } - ], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "items": { - "$ref": "#/components/schemas/TimeBucketResponseDto" - }, - "type": "array" - } - } - }, - "description": "" - } - }, - "security": [ - { - "bearer": [] - }, - { - "cookie": [] - }, - { - "api_key": [] - }, - { - "bearer": [] - }, - { - "cookie": [] - }, - { - "api_key": [] - } - ], - "tags": [ - "Asset" - ] - } - }, "/asset/upload": { "post": { "operationId": "uploadFile", @@ -5628,6 +5348,14 @@ "get": { "operationId": "getMapStyle", "parameters": [ + { + "name": "key", + "required": false, + "in": "query", + "schema": { + "type": "string" + } + }, { "name": "theme", "required": true, @@ -6048,6 +5776,286 @@ ] } }, + "/timeline/bucket": { + "get": { + "operationId": "getTimeBucket", + "parameters": [ + { + "name": "albumId", + "required": false, + "in": "query", + "schema": { + "format": "uuid", + "type": "string" + } + }, + { + "name": "isArchived", + "required": false, + "in": "query", + "schema": { + "type": "boolean" + } + }, + { + "name": "isFavorite", + "required": false, + "in": "query", + "schema": { + "type": "boolean" + } + }, + { + "name": "isTrashed", + "required": false, + "in": "query", + "schema": { + "type": "boolean" + } + }, + { + "name": "key", + "required": false, + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "order", + "required": false, + "in": "query", + "schema": { + "$ref": "#/components/schemas/AssetOrder" + } + }, + { + "name": "personId", + "required": false, + "in": "query", + "schema": { + "format": "uuid", + "type": "string" + } + }, + { + "name": "size", + "required": true, + "in": "query", + "schema": { + "$ref": "#/components/schemas/TimeBucketSize" + } + }, + { + "name": "timeBucket", + "required": true, + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "userId", + "required": false, + "in": "query", + "schema": { + "format": "uuid", + "type": "string" + } + }, + { + "name": "withPartners", + "required": false, + "in": "query", + "schema": { + "type": "boolean" + } + }, + { + "name": "withStacked", + "required": false, + "in": "query", + "schema": { + "type": "boolean" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/AssetResponseDto" + }, + "type": "array" + } + } + }, + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + }, + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "tags": [ + "Timeline" + ] + } + }, + "/timeline/buckets": { + "get": { + "operationId": "getTimeBuckets", + "parameters": [ + { + "name": "albumId", + "required": false, + "in": "query", + "schema": { + "format": "uuid", + "type": "string" + } + }, + { + "name": "isArchived", + "required": false, + "in": "query", + "schema": { + "type": "boolean" + } + }, + { + "name": "isFavorite", + "required": false, + "in": "query", + "schema": { + "type": "boolean" + } + }, + { + "name": "isTrashed", + "required": false, + "in": "query", + "schema": { + "type": "boolean" + } + }, + { + "name": "key", + "required": false, + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "order", + "required": false, + "in": "query", + "schema": { + "$ref": "#/components/schemas/AssetOrder" + } + }, + { + "name": "personId", + "required": false, + "in": "query", + "schema": { + "format": "uuid", + "type": "string" + } + }, + { + "name": "size", + "required": true, + "in": "query", + "schema": { + "$ref": "#/components/schemas/TimeBucketSize" + } + }, + { + "name": "userId", + "required": false, + "in": "query", + "schema": { + "format": "uuid", + "type": "string" + } + }, + { + "name": "withPartners", + "required": false, + "in": "query", + "schema": { + "type": "boolean" + } + }, + { + "name": "withStacked", + "required": false, + "in": "query", + "schema": { + "type": "boolean" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/TimeBucketResponseDto" + }, + "type": "array" + } + } + }, + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + }, + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "tags": [ + "Timeline" + ] + } + }, "/trash/empty": { "post": { "operationId": "emptyTrash", @@ -6538,7 +6546,7 @@ "info": { "title": "Immich", "description": "Immich API", - "version": "1.99.0", + "version": "1.100.0", "contact": {} }, "tags": [], @@ -8427,12 +8435,17 @@ "type": "array" }, "title": { + "deprecated": true, "type": "string" + }, + "yearsAgo": { + "type": "integer" } }, "required": [ "assets", - "title" + "title", + "yearsAgo" ], "type": "object" }, diff --git a/open-api/typescript-sdk/package-lock.json b/open-api/typescript-sdk/package-lock.json index 3fa43e186..dc900ff52 100644 --- a/open-api/typescript-sdk/package-lock.json +++ b/open-api/typescript-sdk/package-lock.json @@ -1,12 +1,12 @@ { "name": "@immich/sdk", - "version": "1.99.0", + "version": "1.100.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@immich/sdk", - "version": "1.99.0", + "version": "1.100.0", "license": "GNU Affero General Public License version 3", "dependencies": { "@oazapfts/runtime": "^1.0.2" diff --git a/open-api/typescript-sdk/package.json b/open-api/typescript-sdk/package.json index c9a7b930d..8fee0fc0f 100644 --- a/open-api/typescript-sdk/package.json +++ b/open-api/typescript-sdk/package.json @@ -1,6 +1,6 @@ { "name": "@immich/sdk", - "version": "1.99.0", + "version": "1.100.0", "description": "Auto-generated TypeScript SDK for the Immich API", "type": "module", "main": "./build/index.js", diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index 883456403..b9b4978a9 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -1,6 +1,6 @@ /** * Immich - * 1.99.0 + * 1.100.0 * DO NOT MODIFY - This file has been generated using oazapfts. * See https://www.npmjs.com/package/oazapfts */ @@ -273,6 +273,7 @@ export type MapMarkerResponseDto = { export type MemoryLaneResponseDto = { assets: AssetResponseDto[]; title: string; + yearsAgo: number; }; export type UpdateStackParentDto = { newParentId: string; @@ -283,10 +284,6 @@ export type AssetStatsResponseDto = { total: number; videos: number; }; -export type TimeBucketResponseDto = { - count: number; - timeBucket: string; -}; export type CreateAssetDto = { assetData: Blob; deviceAssetId: string; @@ -970,6 +967,10 @@ export type CreateTagDto = { export type UpdateTagDto = { name?: string; }; +export type TimeBucketResponseDto = { + count: number; + timeBucket: string; +}; export type CreateUserDto = { email: string; memoriesEnabled?: boolean; @@ -1455,72 +1456,6 @@ export function getAssetThumbnail({ format, id, key }: { ...opts })); } -export function getTimeBucket({ albumId, isArchived, isFavorite, isTrashed, key, order, personId, size, timeBucket, userId, withPartners, withStacked }: { - albumId?: string; - isArchived?: boolean; - isFavorite?: boolean; - isTrashed?: boolean; - key?: string; - order?: AssetOrder; - personId?: string; - size: TimeBucketSize; - timeBucket: string; - userId?: string; - withPartners?: boolean; - withStacked?: boolean; -}, opts?: Oazapfts.RequestOpts) { - return oazapfts.ok(oazapfts.fetchJson<{ - status: 200; - data: AssetResponseDto[]; - }>(`/asset/time-bucket${QS.query(QS.explode({ - albumId, - isArchived, - isFavorite, - isTrashed, - key, - order, - personId, - size, - timeBucket, - userId, - withPartners, - withStacked - }))}`, { - ...opts - })); -} -export function getTimeBuckets({ albumId, isArchived, isFavorite, isTrashed, key, order, personId, size, userId, withPartners, withStacked }: { - albumId?: string; - isArchived?: boolean; - isFavorite?: boolean; - isTrashed?: boolean; - key?: string; - order?: AssetOrder; - personId?: string; - size: TimeBucketSize; - userId?: string; - withPartners?: boolean; - withStacked?: boolean; -}, opts?: Oazapfts.RequestOpts) { - return oazapfts.ok(oazapfts.fetchJson<{ - status: 200; - data: TimeBucketResponseDto[]; - }>(`/asset/time-buckets${QS.query(QS.explode({ - albumId, - isArchived, - isFavorite, - isTrashed, - key, - order, - personId, - size, - userId, - withPartners, - withStacked - }))}`, { - ...opts - })); -} export function uploadFile({ key, createAssetDto }: { key?: string; createAssetDto: CreateAssetDto; @@ -2487,13 +2422,15 @@ export function getConfigDefaults(opts?: Oazapfts.RequestOpts) { ...opts })); } -export function getMapStyle({ theme }: { +export function getMapStyle({ key, theme }: { + key?: string; theme: MapTheme; }, opts?: Oazapfts.RequestOpts) { return oazapfts.ok(oazapfts.fetchJson<{ status: 200; data: object; }>(`/system-config/map/style.json${QS.query(QS.explode({ + key, theme }))}`, { ...opts @@ -2594,6 +2531,72 @@ export function tagAssets({ id, assetIdsDto }: { body: assetIdsDto }))); } +export function getTimeBucket({ albumId, isArchived, isFavorite, isTrashed, key, order, personId, size, timeBucket, userId, withPartners, withStacked }: { + albumId?: string; + isArchived?: boolean; + isFavorite?: boolean; + isTrashed?: boolean; + key?: string; + order?: AssetOrder; + personId?: string; + size: TimeBucketSize; + timeBucket: string; + userId?: string; + withPartners?: boolean; + withStacked?: boolean; +}, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchJson<{ + status: 200; + data: AssetResponseDto[]; + }>(`/timeline/bucket${QS.query(QS.explode({ + albumId, + isArchived, + isFavorite, + isTrashed, + key, + order, + personId, + size, + timeBucket, + userId, + withPartners, + withStacked + }))}`, { + ...opts + })); +} +export function getTimeBuckets({ albumId, isArchived, isFavorite, isTrashed, key, order, personId, size, userId, withPartners, withStacked }: { + albumId?: string; + isArchived?: boolean; + isFavorite?: boolean; + isTrashed?: boolean; + key?: string; + order?: AssetOrder; + personId?: string; + size: TimeBucketSize; + userId?: string; + withPartners?: boolean; + withStacked?: boolean; +}, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchJson<{ + status: 200; + data: TimeBucketResponseDto[]; + }>(`/timeline/buckets${QS.query(QS.explode({ + albumId, + isArchived, + isFavorite, + isTrashed, + key, + order, + personId, + size, + userId, + withPartners, + withStacked + }))}`, { + ...opts + })); +} export function emptyTrash(opts?: Oazapfts.RequestOpts) { return oazapfts.ok(oazapfts.fetchText("/trash/empty", { ...opts, @@ -2788,10 +2791,6 @@ export enum ThumbnailFormat { Jpeg = "JPEG", Webp = "WEBP" } -export enum TimeBucketSize { - Day = "DAY", - Month = "MONTH" -} export enum EntityType { Asset = "ASSET", Album = "ALBUM" @@ -2910,3 +2909,7 @@ export enum MapTheme { Light = "light", Dark = "dark" } +export enum TimeBucketSize { + Day = "DAY", + Month = "MONTH" +} diff --git a/server/package-lock.json b/server/package-lock.json index a2be48674..a17d82d46 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -1,12 +1,12 @@ { "name": "immich", - "version": "1.99.0", + "version": "1.100.0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "immich", - "version": "1.99.0", + "version": "1.100.0", "license": "GNU Affero General Public License version 3", "dependencies": { "@babel/runtime": "^7.22.11", @@ -2537,9 +2537,9 @@ } }, "node_modules/@nestjs/common": { - "version": "10.3.4", - "resolved": "https://registry.npmjs.org/@nestjs/common/-/common-10.3.4.tgz", - "integrity": "sha512-HmehujZhUZjf9TN2o0TyzWYNwEgyRYqZZ5qIcF/mCgIUZ4olIKlazna0kGK56FGlCvviHWNKQM5eTuVeTstIgA==", + "version": "10.3.5", + "resolved": "https://registry.npmjs.org/@nestjs/common/-/common-10.3.5.tgz", + "integrity": "sha512-XWxbDf2ey/jAyEa3/XpckgfzJZ9j3I05ZkEFx7cAlebFuVKeq5UDDb5Sq9O7hMmbH9xdQj3pYT19SSj01hKeug==", "dependencies": { "iterare": "1.2.1", "tslib": "2.6.2", @@ -2591,9 +2591,9 @@ } }, "node_modules/@nestjs/core": { - "version": "10.3.4", - "resolved": "https://registry.npmjs.org/@nestjs/core/-/core-10.3.4.tgz", - "integrity": "sha512-rF0yebuHmMj+9/CkbjPWWMvlF5x8j5Biw2DRvbl8R8n2X3OdFBN+06x/9xm3/ZssR5tLoB9tsYspFUb+SvnnwA==", + "version": "10.3.5", + "resolved": "https://registry.npmjs.org/@nestjs/core/-/core-10.3.5.tgz", + "integrity": "sha512-U7SrGD9/Mu4eUtxfZYiGdY38FcksEyJegs4dQZ8B19nnusw0aTocPEy4HVsmx0LLO4sG+fBLLYzCDDr9kFwXAQ==", "hasInstallScript": true, "dependencies": { "@nuxtjs/opencollective": "0.3.2", @@ -2659,9 +2659,9 @@ } }, "node_modules/@nestjs/platform-express": { - "version": "10.3.4", - "resolved": "https://registry.npmjs.org/@nestjs/platform-express/-/platform-express-10.3.4.tgz", - "integrity": "sha512-rzUUUZCGYNs/viT9I6W5izJ1+oYCG0ym/dAn31NmYJW9UchxJdX5PCJqWF8iIbys6JgfbdcapMR5t+L7OZsasQ==", + "version": "10.3.5", + "resolved": "https://registry.npmjs.org/@nestjs/platform-express/-/platform-express-10.3.5.tgz", + "integrity": "sha512-IhVomwLvdLlv4zCdQK2ROT/nInk1i8m4K48lAUHJV5UVktgVmg0WbQga2/9KywaTjNbx+eWhZXXFii+vtFRAOw==", "dependencies": { "body-parser": "1.20.2", "cors": "2.8.5", @@ -2679,9 +2679,9 @@ } }, "node_modules/@nestjs/platform-socket.io": { - "version": "10.3.4", - "resolved": "https://registry.npmjs.org/@nestjs/platform-socket.io/-/platform-socket.io-10.3.4.tgz", - "integrity": "sha512-HiL7FbLQBanf8ORxQDpub8wdkRJmXHj8vmExDJ+lD1/E2ChrJbBgRDaKWI7QcSzPKF1uS8VVwz3w0zn3F/EDtA==", + "version": "10.3.5", + "resolved": "https://registry.npmjs.org/@nestjs/platform-socket.io/-/platform-socket.io-10.3.5.tgz", + "integrity": "sha512-G2N3sTd9tZ7XQQ7RlrpaQdt1/IBztVHuKg686QmBTLVlRHZ1AMOmXouBk+q5SINT1XURiABa8tQh1Ydx0OEh9w==", "dependencies": { "socket.io": "4.7.5", "tslib": "2.6.2" @@ -2764,9 +2764,9 @@ } }, "node_modules/@nestjs/testing": { - "version": "10.3.4", - "resolved": "https://registry.npmjs.org/@nestjs/testing/-/testing-10.3.4.tgz", - "integrity": "sha512-g3NQnRUFBcYF+ySkB7INg5RiV7CNfkP5zwaf3NFo0WjhBrfih9f1jMZ/19blLZ4djN/ngulYks2E3lzROAW8RQ==", + "version": "10.3.5", + "resolved": "https://registry.npmjs.org/@nestjs/testing/-/testing-10.3.5.tgz", + "integrity": "sha512-j30/lxH0BayeDTigapYtQn/XhMRR7CzlFsm3dHoWViWQv0qT1r2ffe3927BbBLX3N/ZzglE10OAqW06ADZV8dw==", "dev": true, "dependencies": { "tslib": "2.6.2" @@ -2806,9 +2806,9 @@ } }, "node_modules/@nestjs/websockets": { - "version": "10.3.4", - "resolved": "https://registry.npmjs.org/@nestjs/websockets/-/websockets-10.3.4.tgz", - "integrity": "sha512-ZGDY8t1bBYzY2xbOe2QOxYG+D6W1mALSS3VD/rcVW34oaysF4iQQEr4t2ktYLbPAuZlEvwM5EhutqCkBUsDw7Q==", + "version": "10.3.5", + "resolved": "https://registry.npmjs.org/@nestjs/websockets/-/websockets-10.3.5.tgz", + "integrity": "sha512-6w383LUBFHoZ0eFODqEHN2NoIRUwbTd37Hc1KqtZZihhFUzscC/0LMAV20o9LdfS/Xjog5ShNTxvOHuzNBnE4A==", "dependencies": { "iterare": "1.2.1", "object-hash": "3.0.0", @@ -8748,9 +8748,9 @@ } }, "node_modules/i18n-iso-countries": { - "version": "7.10.1", - "resolved": "https://registry.npmjs.org/i18n-iso-countries/-/i18n-iso-countries-7.10.1.tgz", - "integrity": "sha512-9DXmAMkfGcGNE+E/2fE85UUjjkPeT0LHMA8d+kcxXiO+s50W28lxiICel8f8qWZmCNic1cuhN1+nw7ZazMQJFA==", + "version": "7.11.0", + "resolved": "https://registry.npmjs.org/i18n-iso-countries/-/i18n-iso-countries-7.11.0.tgz", + "integrity": "sha512-MP2+aAJwvBTuruaMEj+mPEhu4D9rn03GkkbuP8/xkvNzhVwNe2cAg1ivkL5Oj+vwqEwvIcf5C7Q+5Y/UZNLBHw==", "dependencies": { "diacritics": "1.3.0" }, @@ -16141,9 +16141,9 @@ } }, "@nestjs/common": { - "version": "10.3.4", - "resolved": "https://registry.npmjs.org/@nestjs/common/-/common-10.3.4.tgz", - "integrity": "sha512-HmehujZhUZjf9TN2o0TyzWYNwEgyRYqZZ5qIcF/mCgIUZ4olIKlazna0kGK56FGlCvviHWNKQM5eTuVeTstIgA==", + "version": "10.3.5", + "resolved": "https://registry.npmjs.org/@nestjs/common/-/common-10.3.5.tgz", + "integrity": "sha512-XWxbDf2ey/jAyEa3/XpckgfzJZ9j3I05ZkEFx7cAlebFuVKeq5UDDb5Sq9O7hMmbH9xdQj3pYT19SSj01hKeug==", "requires": { "iterare": "1.2.1", "tslib": "2.6.2", @@ -16169,9 +16169,9 @@ } }, "@nestjs/core": { - "version": "10.3.4", - "resolved": "https://registry.npmjs.org/@nestjs/core/-/core-10.3.4.tgz", - "integrity": "sha512-rF0yebuHmMj+9/CkbjPWWMvlF5x8j5Biw2DRvbl8R8n2X3OdFBN+06x/9xm3/ZssR5tLoB9tsYspFUb+SvnnwA==", + "version": "10.3.5", + "resolved": "https://registry.npmjs.org/@nestjs/core/-/core-10.3.5.tgz", + "integrity": "sha512-U7SrGD9/Mu4eUtxfZYiGdY38FcksEyJegs4dQZ8B19nnusw0aTocPEy4HVsmx0LLO4sG+fBLLYzCDDr9kFwXAQ==", "requires": { "@nuxtjs/opencollective": "0.3.2", "fast-safe-stringify": "2.1.1", @@ -16196,9 +16196,9 @@ "requires": {} }, "@nestjs/platform-express": { - "version": "10.3.4", - "resolved": "https://registry.npmjs.org/@nestjs/platform-express/-/platform-express-10.3.4.tgz", - "integrity": "sha512-rzUUUZCGYNs/viT9I6W5izJ1+oYCG0ym/dAn31NmYJW9UchxJdX5PCJqWF8iIbys6JgfbdcapMR5t+L7OZsasQ==", + "version": "10.3.5", + "resolved": "https://registry.npmjs.org/@nestjs/platform-express/-/platform-express-10.3.5.tgz", + "integrity": "sha512-IhVomwLvdLlv4zCdQK2ROT/nInk1i8m4K48lAUHJV5UVktgVmg0WbQga2/9KywaTjNbx+eWhZXXFii+vtFRAOw==", "requires": { "body-parser": "1.20.2", "cors": "2.8.5", @@ -16208,9 +16208,9 @@ } }, "@nestjs/platform-socket.io": { - "version": "10.3.4", - "resolved": "https://registry.npmjs.org/@nestjs/platform-socket.io/-/platform-socket.io-10.3.4.tgz", - "integrity": "sha512-HiL7FbLQBanf8ORxQDpub8wdkRJmXHj8vmExDJ+lD1/E2ChrJbBgRDaKWI7QcSzPKF1uS8VVwz3w0zn3F/EDtA==", + "version": "10.3.5", + "resolved": "https://registry.npmjs.org/@nestjs/platform-socket.io/-/platform-socket.io-10.3.5.tgz", + "integrity": "sha512-G2N3sTd9tZ7XQQ7RlrpaQdt1/IBztVHuKg686QmBTLVlRHZ1AMOmXouBk+q5SINT1XURiABa8tQh1Ydx0OEh9w==", "requires": { "socket.io": "4.7.5", "tslib": "2.6.2" @@ -16260,9 +16260,9 @@ } }, "@nestjs/testing": { - "version": "10.3.4", - "resolved": "https://registry.npmjs.org/@nestjs/testing/-/testing-10.3.4.tgz", - "integrity": "sha512-g3NQnRUFBcYF+ySkB7INg5RiV7CNfkP5zwaf3NFo0WjhBrfih9f1jMZ/19blLZ4djN/ngulYks2E3lzROAW8RQ==", + "version": "10.3.5", + "resolved": "https://registry.npmjs.org/@nestjs/testing/-/testing-10.3.5.tgz", + "integrity": "sha512-j30/lxH0BayeDTigapYtQn/XhMRR7CzlFsm3dHoWViWQv0qT1r2ffe3927BbBLX3N/ZzglE10OAqW06ADZV8dw==", "dev": true, "requires": { "tslib": "2.6.2" @@ -16277,9 +16277,9 @@ } }, "@nestjs/websockets": { - "version": "10.3.4", - "resolved": "https://registry.npmjs.org/@nestjs/websockets/-/websockets-10.3.4.tgz", - "integrity": "sha512-ZGDY8t1bBYzY2xbOe2QOxYG+D6W1mALSS3VD/rcVW34oaysF4iQQEr4t2ktYLbPAuZlEvwM5EhutqCkBUsDw7Q==", + "version": "10.3.5", + "resolved": "https://registry.npmjs.org/@nestjs/websockets/-/websockets-10.3.5.tgz", + "integrity": "sha512-6w383LUBFHoZ0eFODqEHN2NoIRUwbTd37Hc1KqtZZihhFUzscC/0LMAV20o9LdfS/Xjog5ShNTxvOHuzNBnE4A==", "requires": { "iterare": "1.2.1", "object-hash": "3.0.0", @@ -20746,9 +20746,9 @@ "dev": true }, "i18n-iso-countries": { - "version": "7.10.1", - "resolved": "https://registry.npmjs.org/i18n-iso-countries/-/i18n-iso-countries-7.10.1.tgz", - "integrity": "sha512-9DXmAMkfGcGNE+E/2fE85UUjjkPeT0LHMA8d+kcxXiO+s50W28lxiICel8f8qWZmCNic1cuhN1+nw7ZazMQJFA==", + "version": "7.11.0", + "resolved": "https://registry.npmjs.org/i18n-iso-countries/-/i18n-iso-countries-7.11.0.tgz", + "integrity": "sha512-MP2+aAJwvBTuruaMEj+mPEhu4D9rn03GkkbuP8/xkvNzhVwNe2cAg1ivkL5Oj+vwqEwvIcf5C7Q+5Y/UZNLBHw==", "requires": { "diacritics": "1.3.0" } diff --git a/server/package.json b/server/package.json index 9e3fbec6b..afa5bcb29 100644 --- a/server/package.json +++ b/server/package.json @@ -1,6 +1,6 @@ { "name": "immich", - "version": "1.99.0", + "version": "1.100.0", "description": "", "author": "", "private": true, diff --git a/server/src/app.module.ts b/server/src/app.module.ts index aedd6adf1..ded08a96a 100644 --- a/server/src/app.module.ts +++ b/server/src/app.module.ts @@ -1,229 +1,28 @@ import { BullModule } from '@nestjs/bullmq'; -import { Module, OnModuleInit, Provider, ValidationPipe } from '@nestjs/common'; +import { Module, OnModuleInit, ValidationPipe } from '@nestjs/common'; import { ConfigModule } from '@nestjs/config'; import { APP_GUARD, APP_INTERCEPTOR, APP_PIPE } from '@nestjs/core'; import { EventEmitterModule } from '@nestjs/event-emitter'; import { ScheduleModule, SchedulerRegistry } from '@nestjs/schedule'; import { TypeOrmModule } from '@nestjs/typeorm'; import { OpenTelemetryModule } from 'nestjs-otel'; -import { ListUsersCommand } from 'src/commands/list-users.command'; -import { DisableOAuthLogin, EnableOAuthLogin } from 'src/commands/oauth-login'; -import { DisablePasswordLoginCommand, EnablePasswordLoginCommand } from 'src/commands/password-login'; -import { PromptPasswordQuestions, ResetAdminPasswordCommand } from 'src/commands/reset-admin-password.command'; +import { commands } from 'src/commands'; import { bullConfig, bullQueues, immichAppConfig } from 'src/config'; -import { ActivityController } from 'src/controllers/activity.controller'; -import { AlbumController } from 'src/controllers/album.controller'; -import { APIKeyController } from 'src/controllers/api-key.controller'; -import { AppController } from 'src/controllers/app.controller'; -import { AssetControllerV1 } from 'src/controllers/asset-v1.controller'; -import { AssetController, AssetsController } from 'src/controllers/asset.controller'; -import { AuditController } from 'src/controllers/audit.controller'; -import { AuthController } from 'src/controllers/auth.controller'; -import { DownloadController } from 'src/controllers/download.controller'; -import { FaceController } from 'src/controllers/face.controller'; -import { JobController } from 'src/controllers/job.controller'; -import { LibraryController } from 'src/controllers/library.controller'; -import { OAuthController } from 'src/controllers/oauth.controller'; -import { PartnerController } from 'src/controllers/partner.controller'; -import { PersonController } from 'src/controllers/person.controller'; -import { SearchController } from 'src/controllers/search.controller'; -import { ServerInfoController } from 'src/controllers/server-info.controller'; -import { SharedLinkController } from 'src/controllers/shared-link.controller'; -import { SystemConfigController } from 'src/controllers/system-config.controller'; -import { TagController } from 'src/controllers/tag.controller'; -import { TrashController } from 'src/controllers/trash.controller'; -import { UserController } from 'src/controllers/user.controller'; +import { controllers } from 'src/controllers'; import { databaseConfig } from 'src/database.config'; -import { databaseEntities } from 'src/entities'; -import { IAccessRepository } from 'src/interfaces/access.interface'; -import { IActivityRepository } from 'src/interfaces/activity.interface'; -import { IAlbumRepository } from 'src/interfaces/album.interface'; -import { IKeyRepository } from 'src/interfaces/api-key.interface'; -import { IAssetStackRepository } from 'src/interfaces/asset-stack.interface'; -import { IAssetRepositoryV1 } from 'src/interfaces/asset-v1.interface'; -import { IAssetRepository } from 'src/interfaces/asset.interface'; -import { IAuditRepository } from 'src/interfaces/audit.interface'; -import { ICryptoRepository } from 'src/interfaces/crypto.interface'; -import { IDatabaseRepository } from 'src/interfaces/database.interface'; -import { IEventRepository } from 'src/interfaces/event.interface'; -import { IJobRepository } from 'src/interfaces/job.interface'; -import { ILibraryRepository } from 'src/interfaces/library.interface'; -import { IMachineLearningRepository } from 'src/interfaces/machine-learning.interface'; -import { IMediaRepository } from 'src/interfaces/media.interface'; -import { IMetadataRepository } from 'src/interfaces/metadata.interface'; -import { IMetricRepository } from 'src/interfaces/metric.interface'; -import { IMoveRepository } from 'src/interfaces/move.interface'; -import { IPartnerRepository } from 'src/interfaces/partner.interface'; -import { IPersonRepository } from 'src/interfaces/person.interface'; -import { ISearchRepository } from 'src/interfaces/search.interface'; -import { IServerInfoRepository } from 'src/interfaces/server-info.interface'; -import { ISharedLinkRepository } from 'src/interfaces/shared-link.interface'; -import { IStorageRepository } from 'src/interfaces/storage.interface'; -import { ISystemConfigRepository } from 'src/interfaces/system-config.interface'; -import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; -import { ITagRepository } from 'src/interfaces/tag.interface'; -import { IUserTokenRepository } from 'src/interfaces/user-token.interface'; -import { IUserRepository } from 'src/interfaces/user.interface'; +import { entities } from 'src/entities'; import { AuthGuard } from 'src/middleware/auth.guard'; import { ErrorInterceptor } from 'src/middleware/error.interceptor'; import { FileUploadInterceptor } from 'src/middleware/file-upload.interceptor'; -import { AccessRepository } from 'src/repositories/access.repository'; -import { ActivityRepository } from 'src/repositories/activity.repository'; -import { AlbumRepository } from 'src/repositories/album.repository'; -import { ApiKeyRepository } from 'src/repositories/api-key.repository'; -import { AssetStackRepository } from 'src/repositories/asset-stack.repository'; -import { AssetRepositoryV1 } from 'src/repositories/asset-v1.repository'; -import { AssetRepository } from 'src/repositories/asset.repository'; -import { AuditRepository } from 'src/repositories/audit.repository'; -import { CryptoRepository } from 'src/repositories/crypto.repository'; -import { DatabaseRepository } from 'src/repositories/database.repository'; -import { EventRepository } from 'src/repositories/event.repository'; -import { JobRepository } from 'src/repositories/job.repository'; -import { LibraryRepository } from 'src/repositories/library.repository'; -import { MachineLearningRepository } from 'src/repositories/machine-learning.repository'; -import { MediaRepository } from 'src/repositories/media.repository'; -import { MetadataRepository } from 'src/repositories/metadata.repository'; -import { MetricRepository } from 'src/repositories/metric.repository'; -import { MoveRepository } from 'src/repositories/move.repository'; -import { PartnerRepository } from 'src/repositories/partner.repository'; -import { PersonRepository } from 'src/repositories/person.repository'; -import { SearchRepository } from 'src/repositories/search.repository'; -import { ServerInfoRepository } from 'src/repositories/server-info.repository'; -import { SharedLinkRepository } from 'src/repositories/shared-link.repository'; -import { StorageRepository } from 'src/repositories/storage.repository'; -import { SystemConfigRepository } from 'src/repositories/system-config.repository'; -import { SystemMetadataRepository } from 'src/repositories/system-metadata.repository'; -import { TagRepository } from 'src/repositories/tag.repository'; -import { UserTokenRepository } from 'src/repositories/user-token.repository'; -import { UserRepository } from 'src/repositories/user.repository'; -import { ActivityService } from 'src/services/activity.service'; -import { AlbumService } from 'src/services/album.service'; -import { APIKeyService } from 'src/services/api-key.service'; +import { repositories } from 'src/repositories'; +import { services } from 'src/services'; import { ApiService } from 'src/services/api.service'; -import { AssetServiceV1 } from 'src/services/asset-v1.service'; -import { AssetService } from 'src/services/asset.service'; -import { AuditService } from 'src/services/audit.service'; -import { AuthService } from 'src/services/auth.service'; -import { DatabaseService } from 'src/services/database.service'; -import { DownloadService } from 'src/services/download.service'; -import { JobService } from 'src/services/job.service'; -import { LibraryService } from 'src/services/library.service'; -import { MediaService } from 'src/services/media.service'; -import { MetadataService } from 'src/services/metadata.service'; import { MicroservicesService } from 'src/services/microservices.service'; -import { PartnerService } from 'src/services/partner.service'; -import { PersonService } from 'src/services/person.service'; -import { SearchService } from 'src/services/search.service'; -import { ServerInfoService } from 'src/services/server-info.service'; -import { SharedLinkService } from 'src/services/shared-link.service'; -import { SmartInfoService } from 'src/services/smart-info.service'; -import { StorageTemplateService } from 'src/services/storage-template.service'; -import { StorageService } from 'src/services/storage.service'; -import { SystemConfigService } from 'src/services/system-config.service'; -import { TagService } from 'src/services/tag.service'; -import { TrashService } from 'src/services/trash.service'; -import { UserService } from 'src/services/user.service'; import { otelConfig } from 'src/utils/instrumentation'; import { ImmichLogger } from 'src/utils/logger'; -const commands = [ - ResetAdminPasswordCommand, - PromptPasswordQuestions, - EnablePasswordLoginCommand, - DisablePasswordLoginCommand, - EnableOAuthLogin, - DisableOAuthLogin, - ListUsersCommand, -]; - -const controllers = [ - ActivityController, - AssetsController, - AssetControllerV1, - AssetController, - AppController, - AlbumController, - APIKeyController, - AuditController, - AuthController, - DownloadController, - FaceController, - JobController, - LibraryController, - OAuthController, - PartnerController, - SearchController, - ServerInfoController, - SharedLinkController, - SystemConfigController, - TagController, - TrashController, - UserController, - PersonController, -]; - -const services: Provider[] = [ - ApiService, - MicroservicesService, - APIKeyService, - ActivityService, - AlbumService, - AssetService, - AssetServiceV1, - AuditService, - AuthService, - DatabaseService, - DownloadService, - ImmichLogger, - JobService, - LibraryService, - MediaService, - MetadataService, - PartnerService, - PersonService, - SearchService, - ServerInfoService, - SharedLinkService, - SmartInfoService, - StorageService, - StorageTemplateService, - SystemConfigService, - TagService, - TrashService, - UserService, -]; - -const repositories: Provider[] = [ - { provide: IActivityRepository, useClass: ActivityRepository }, - { provide: IAccessRepository, useClass: AccessRepository }, - { provide: IAlbumRepository, useClass: AlbumRepository }, - { provide: IAssetRepository, useClass: AssetRepository }, - { provide: IAssetRepositoryV1, useClass: AssetRepositoryV1 }, - { provide: IAssetStackRepository, useClass: AssetStackRepository }, - { provide: IAuditRepository, useClass: AuditRepository }, - { provide: ICryptoRepository, useClass: CryptoRepository }, - { provide: IDatabaseRepository, useClass: DatabaseRepository }, - { provide: IEventRepository, useClass: EventRepository }, - { provide: IJobRepository, useClass: JobRepository }, - { provide: ILibraryRepository, useClass: LibraryRepository }, - { provide: IKeyRepository, useClass: ApiKeyRepository }, - { provide: IMachineLearningRepository, useClass: MachineLearningRepository }, - { provide: IMetadataRepository, useClass: MetadataRepository }, - { provide: IMetricRepository, useClass: MetricRepository }, - { provide: IMoveRepository, useClass: MoveRepository }, - { provide: IPartnerRepository, useClass: PartnerRepository }, - { provide: IPersonRepository, useClass: PersonRepository }, - { provide: IServerInfoRepository, useClass: ServerInfoRepository }, - { provide: ISharedLinkRepository, useClass: SharedLinkRepository }, - { provide: ISearchRepository, useClass: SearchRepository }, - { provide: IStorageRepository, useClass: StorageRepository }, - { provide: ISystemConfigRepository, useClass: SystemConfigRepository }, - { provide: ISystemMetadataRepository, useClass: SystemMetadataRepository }, - { provide: ITagRepository, useClass: TagRepository }, - { provide: IMediaRepository, useClass: MediaRepository }, - { provide: IUserRepository, useClass: UserRepository }, - { provide: IUserTokenRepository, useClass: UserTokenRepository }, -]; +const providers = [ImmichLogger]; +const common = [...services, ...providers, ...repositories]; const middleware = [ FileUploadInterceptor, @@ -239,13 +38,13 @@ const imports = [ EventEmitterModule.forRoot(), OpenTelemetryModule.forRoot(otelConfig), TypeOrmModule.forRoot(databaseConfig), - TypeOrmModule.forFeature(databaseEntities), + TypeOrmModule.forFeature(entities), ]; @Module({ imports: [...imports, ScheduleModule.forRoot()], controllers: [...controllers], - providers: [...services, ...repositories, ...middleware], + providers: [...common, ...middleware], }) export class ApiModule implements OnModuleInit { constructor(private service: ApiService) {} @@ -257,7 +56,7 @@ export class ApiModule implements OnModuleInit { @Module({ imports: [...imports], - providers: [...services, ...repositories, SchedulerRegistry], + providers: [...common, SchedulerRegistry], }) export class MicroservicesModule implements OnModuleInit { constructor(private service: MicroservicesService) {} @@ -269,7 +68,7 @@ export class MicroservicesModule implements OnModuleInit { @Module({ imports: [...imports], - providers: [...services, ...repositories, ...commands, SchedulerRegistry], + providers: [...common, ...commands, SchedulerRegistry], }) export class ImmichAdminModule {} @@ -278,10 +77,10 @@ export class ImmichAdminModule {} ConfigModule.forRoot(immichAppConfig), EventEmitterModule.forRoot(), TypeOrmModule.forRoot(databaseConfig), - TypeOrmModule.forFeature(databaseEntities), + TypeOrmModule.forFeature(entities), OpenTelemetryModule.forRoot(otelConfig), ], controllers: [...controllers], - providers: [...services, ...repositories, ...middleware, SchedulerRegistry], + providers: [...common, ...middleware, SchedulerRegistry], }) export class AppTestModule {} diff --git a/server/src/commands/index.ts b/server/src/commands/index.ts new file mode 100644 index 000000000..016a26cb3 --- /dev/null +++ b/server/src/commands/index.ts @@ -0,0 +1,14 @@ +import { ListUsersCommand } from 'src/commands/list-users.command'; +import { DisableOAuthLogin, EnableOAuthLogin } from 'src/commands/oauth-login'; +import { DisablePasswordLoginCommand, EnablePasswordLoginCommand } from 'src/commands/password-login'; +import { PromptPasswordQuestions, ResetAdminPasswordCommand } from 'src/commands/reset-admin-password.command'; + +export const commands = [ + ResetAdminPasswordCommand, + PromptPasswordQuestions, + EnablePasswordLoginCommand, + DisablePasswordLoginCommand, + EnableOAuthLogin, + DisableOAuthLogin, + ListUsersCommand, +]; diff --git a/server/src/controllers/asset.controller.ts b/server/src/controllers/asset.controller.ts index 37e169113..8e446d23f 100644 --- a/server/src/controllers/asset.controller.ts +++ b/server/src/controllers/asset.controller.ts @@ -14,7 +14,6 @@ import { import { AuthDto } from 'src/dtos/auth.dto'; import { MapMarkerDto, MapMarkerResponseDto, MemoryLaneDto, MetadataSearchDto } from 'src/dtos/search.dto'; import { UpdateStackParentDto } from 'src/dtos/stack.dto'; -import { TimeBucketAssetDto, TimeBucketDto, TimeBucketResponseDto } from 'src/dtos/time-bucket.dto'; import { Auth, Authenticated, SharedLinkRoute } from 'src/middleware/auth.guard'; import { Route } from 'src/middleware/file-upload.interceptor'; import { AssetService } from 'src/services/asset.service'; @@ -71,18 +70,6 @@ export class AssetController { return this.service.getStatistics(auth, dto); } - @Authenticated({ isShared: true }) - @Get('time-buckets') - getTimeBuckets(@Auth() auth: AuthDto, @Query() dto: TimeBucketDto): Promise { - return this.service.getTimeBuckets(auth, dto); - } - - @Authenticated({ isShared: true }) - @Get('time-bucket') - getTimeBucket(@Auth() auth: AuthDto, @Query() dto: TimeBucketAssetDto): Promise { - return this.service.getTimeBucket(auth, dto) as Promise; - } - @Post('jobs') @HttpCode(HttpStatus.NO_CONTENT) runAssetJobs(@Auth() auth: AuthDto, @Body() dto: AssetJobsDto): Promise { diff --git a/server/src/controllers/index.ts b/server/src/controllers/index.ts new file mode 100644 index 000000000..00cf7bbab --- /dev/null +++ b/server/src/controllers/index.ts @@ -0,0 +1,50 @@ +import { ActivityController } from 'src/controllers/activity.controller'; +import { AlbumController } from 'src/controllers/album.controller'; +import { APIKeyController } from 'src/controllers/api-key.controller'; +import { AppController } from 'src/controllers/app.controller'; +import { AssetControllerV1 } from 'src/controllers/asset-v1.controller'; +import { AssetController, AssetsController } from 'src/controllers/asset.controller'; +import { AuditController } from 'src/controllers/audit.controller'; +import { AuthController } from 'src/controllers/auth.controller'; +import { DownloadController } from 'src/controllers/download.controller'; +import { FaceController } from 'src/controllers/face.controller'; +import { JobController } from 'src/controllers/job.controller'; +import { LibraryController } from 'src/controllers/library.controller'; +import { OAuthController } from 'src/controllers/oauth.controller'; +import { PartnerController } from 'src/controllers/partner.controller'; +import { PersonController } from 'src/controllers/person.controller'; +import { SearchController } from 'src/controllers/search.controller'; +import { ServerInfoController } from 'src/controllers/server-info.controller'; +import { SharedLinkController } from 'src/controllers/shared-link.controller'; +import { SystemConfigController } from 'src/controllers/system-config.controller'; +import { TagController } from 'src/controllers/tag.controller'; +import { TimelineController } from 'src/controllers/timeline.controller'; +import { TrashController } from 'src/controllers/trash.controller'; +import { UserController } from 'src/controllers/user.controller'; + +export const controllers = [ + ActivityController, + AssetsController, + AssetControllerV1, + AssetController, + AppController, + AlbumController, + APIKeyController, + AuditController, + AuthController, + DownloadController, + FaceController, + JobController, + LibraryController, + OAuthController, + PartnerController, + SearchController, + ServerInfoController, + SharedLinkController, + SystemConfigController, + TagController, + TimelineController, + TrashController, + UserController, + PersonController, +]; diff --git a/server/src/controllers/system-config.controller.ts b/server/src/controllers/system-config.controller.ts index 1fb9dfbea..0b46b82a5 100644 --- a/server/src/controllers/system-config.controller.ts +++ b/server/src/controllers/system-config.controller.ts @@ -1,7 +1,7 @@ import { Body, Controller, Get, Put, Query } from '@nestjs/common'; import { ApiTags } from '@nestjs/swagger'; import { MapThemeDto, SystemConfigDto, SystemConfigTemplateStorageOptionDto } from 'src/dtos/system-config.dto'; -import { AdminRoute, Authenticated } from 'src/middleware/auth.guard'; +import { AdminRoute, Authenticated, SharedLinkRoute } from 'src/middleware/auth.guard'; import { SystemConfigService } from 'src/services/system-config.service'; @ApiTags('System Config') @@ -31,6 +31,7 @@ export class SystemConfigController { } @AdminRoute(false) + @SharedLinkRoute() @Get('map/style.json') getMapStyle(@Query() dto: MapThemeDto) { return this.service.getMapStyle(dto.theme); diff --git a/server/src/controllers/timeline.controller.ts b/server/src/controllers/timeline.controller.ts new file mode 100644 index 000000000..173c6738d --- /dev/null +++ b/server/src/controllers/timeline.controller.ts @@ -0,0 +1,26 @@ +import { Controller, Get, Query } from '@nestjs/common'; +import { ApiTags } from '@nestjs/swagger'; +import { AssetResponseDto } from 'src/dtos/asset-response.dto'; +import { AuthDto } from 'src/dtos/auth.dto'; +import { TimeBucketAssetDto, TimeBucketDto, TimeBucketResponseDto } from 'src/dtos/time-bucket.dto'; +import { Auth, Authenticated } from 'src/middleware/auth.guard'; +import { TimelineService } from 'src/services/timeline.service'; + +@ApiTags('Timeline') +@Controller('timeline') +@Authenticated() +export class TimelineController { + constructor(private service: TimelineService) {} + + @Authenticated({ isShared: true }) + @Get('buckets') + getTimeBuckets(@Auth() auth: AuthDto, @Query() dto: TimeBucketDto): Promise { + return this.service.getTimeBuckets(auth, dto); + } + + @Authenticated({ isShared: true }) + @Get('bucket') + getTimeBucket(@Auth() auth: AuthDto, @Query() dto: TimeBucketAssetDto): Promise { + return this.service.getTimeBucket(auth, dto) as Promise; + } +} diff --git a/server/src/cores/access.core.ts b/server/src/cores/access.core.ts index f836e9762..8d021031e 100644 --- a/server/src/cores/access.core.ts +++ b/server/src/cores/access.core.ts @@ -84,7 +84,7 @@ export class AccessCore { * * @returns Set */ - async checkAccess(auth: AuthDto, permission: Permission, ids: Set | string[]) { + async checkAccess(auth: AuthDto, permission: Permission, ids: Set | string[]): Promise> { const idSet = Array.isArray(ids) ? new Set(ids) : ids; if (idSet.size === 0) { return new Set(); @@ -97,7 +97,11 @@ export class AccessCore { return this.checkAccessOther(auth, permission, idSet); } - private async checkAccessSharedLink(sharedLink: SharedLinkEntity, permission: Permission, ids: Set) { + private async checkAccessSharedLink( + sharedLink: SharedLinkEntity, + permission: Permission, + ids: Set, + ): Promise> { const sharedLinkId = sharedLink.id; switch (permission) { @@ -140,7 +144,7 @@ export class AccessCore { } } - private async checkAccessOther(auth: AuthDto, permission: Permission, ids: Set) { + private async checkAccessOther(auth: AuthDto, permission: Permission, ids: Set): Promise> { switch (permission) { // uses album id case Permission.ACTIVITY_CREATE: { diff --git a/server/src/dtos/asset-response.dto.ts b/server/src/dtos/asset-response.dto.ts index 04e36645e..cf16a99a2 100644 --- a/server/src/dtos/asset-response.dto.ts +++ b/server/src/dtos/asset-response.dto.ts @@ -131,7 +131,12 @@ export function mapAsset(entity: AssetEntity, options: AssetMapOptions = {}): As } export class MemoryLaneResponseDto { + @ApiProperty({ deprecated: true }) title!: string; + + @ApiProperty({ type: 'integer' }) + yearsAgo!: number; + assets!: AssetResponseDto[]; } diff --git a/server/src/entities/index.ts b/server/src/entities/index.ts index 33b5f0013..4b568cd9c 100644 --- a/server/src/entities/index.ts +++ b/server/src/entities/index.ts @@ -21,7 +21,7 @@ import { TagEntity } from 'src/entities/tag.entity'; import { UserTokenEntity } from 'src/entities/user-token.entity'; import { UserEntity } from 'src/entities/user.entity'; -export const databaseEntities = [ +export const entities = [ ActivityEntity, AlbumEntity, APIKeyEntity, diff --git a/server/src/interfaces/album.interface.ts b/server/src/interfaces/album.interface.ts index 230e94e2a..48c728feb 100644 --- a/server/src/interfaces/album.interface.ts +++ b/server/src/interfaces/album.interface.ts @@ -1,4 +1,5 @@ import { AlbumEntity } from 'src/entities/album.entity'; +import { IBulkAsset } from 'src/utils/asset.util'; export const IAlbumRepository = 'IAlbumRepository'; @@ -23,15 +24,14 @@ export interface AlbumAssets { assetIds: string[]; } -export interface IAlbumRepository { +export interface IAlbumRepository extends IBulkAsset { getById(id: string, options: AlbumInfoOptions): Promise; getByIds(ids: string[]): Promise; getByAssetId(ownerId: string, assetId: string): Promise; - addAssets(assets: AlbumAssets): Promise; getAssetIds(albumId: string, assetIds?: string[]): Promise>; hasAsset(asset: AlbumAsset): Promise; removeAsset(assetId: string): Promise; - removeAssets(albumId: string, assetIds: string[]): Promise; + removeAssetIds(albumId: string, assetIds: string[]): Promise; getMetadataForIds(ids: string[]): Promise; getInvalidThumbnail(): Promise; getOwned(ownerId: string): Promise; diff --git a/server/src/queries/activity.repository.sql b/server/src/queries/activity.repository.sql new file mode 100644 index 000000000..306f71920 --- /dev/null +++ b/server/src/queries/activity.repository.sql @@ -0,0 +1,54 @@ +-- NOTE: This file is auto generated by ./sql-generator + +-- ActivityRepository.search +SELECT + "ActivityEntity"."id" AS "ActivityEntity_id", + "ActivityEntity"."createdAt" AS "ActivityEntity_createdAt", + "ActivityEntity"."updatedAt" AS "ActivityEntity_updatedAt", + "ActivityEntity"."albumId" AS "ActivityEntity_albumId", + "ActivityEntity"."userId" AS "ActivityEntity_userId", + "ActivityEntity"."assetId" AS "ActivityEntity_assetId", + "ActivityEntity"."comment" AS "ActivityEntity_comment", + "ActivityEntity"."isLiked" AS "ActivityEntity_isLiked", + "ActivityEntity__ActivityEntity_user"."id" AS "ActivityEntity__ActivityEntity_user_id", + "ActivityEntity__ActivityEntity_user"."name" AS "ActivityEntity__ActivityEntity_user_name", + "ActivityEntity__ActivityEntity_user"."avatarColor" AS "ActivityEntity__ActivityEntity_user_avatarColor", + "ActivityEntity__ActivityEntity_user"."isAdmin" AS "ActivityEntity__ActivityEntity_user_isAdmin", + "ActivityEntity__ActivityEntity_user"."email" AS "ActivityEntity__ActivityEntity_user_email", + "ActivityEntity__ActivityEntity_user"."storageLabel" AS "ActivityEntity__ActivityEntity_user_storageLabel", + "ActivityEntity__ActivityEntity_user"."oauthId" AS "ActivityEntity__ActivityEntity_user_oauthId", + "ActivityEntity__ActivityEntity_user"."profileImagePath" AS "ActivityEntity__ActivityEntity_user_profileImagePath", + "ActivityEntity__ActivityEntity_user"."shouldChangePassword" AS "ActivityEntity__ActivityEntity_user_shouldChangePassword", + "ActivityEntity__ActivityEntity_user"."createdAt" AS "ActivityEntity__ActivityEntity_user_createdAt", + "ActivityEntity__ActivityEntity_user"."deletedAt" AS "ActivityEntity__ActivityEntity_user_deletedAt", + "ActivityEntity__ActivityEntity_user"."status" AS "ActivityEntity__ActivityEntity_user_status", + "ActivityEntity__ActivityEntity_user"."updatedAt" AS "ActivityEntity__ActivityEntity_user_updatedAt", + "ActivityEntity__ActivityEntity_user"."memoriesEnabled" AS "ActivityEntity__ActivityEntity_user_memoriesEnabled", + "ActivityEntity__ActivityEntity_user"."quotaSizeInBytes" AS "ActivityEntity__ActivityEntity_user_quotaSizeInBytes", + "ActivityEntity__ActivityEntity_user"."quotaUsageInBytes" AS "ActivityEntity__ActivityEntity_user_quotaUsageInBytes" +FROM + "activity" "ActivityEntity" + LEFT JOIN "users" "ActivityEntity__ActivityEntity_user" ON "ActivityEntity__ActivityEntity_user"."id" = "ActivityEntity"."userId" + AND ( + "ActivityEntity__ActivityEntity_user"."deletedAt" IS NULL + ) +WHERE + (("ActivityEntity"."albumId" = $1)) +ORDER BY + "ActivityEntity"."createdAt" ASC + +-- ActivityRepository.getStatistics +SELECT + COUNT(DISTINCT ("ActivityEntity"."id")) AS "cnt" +FROM + "activity" "ActivityEntity" + LEFT JOIN "users" "ActivityEntity__ActivityEntity_user" ON "ActivityEntity__ActivityEntity_user"."id" = "ActivityEntity"."userId" + AND ( + "ActivityEntity__ActivityEntity_user"."deletedAt" IS NULL + ) +WHERE + ( + ("ActivityEntity"."assetId" = $1) + AND ("ActivityEntity"."albumId" = $2) + AND ("ActivityEntity"."isLiked" = $3) + ) diff --git a/server/src/queries/album.repository.sql b/server/src/queries/album.repository.sql index ddedc0095..50f775d2f 100644 --- a/server/src/queries/album.repository.sql +++ b/server/src/queries/album.repository.sql @@ -590,7 +590,7 @@ DELETE FROM "albums_assets_assets" WHERE "albums_assets_assets"."assetsId" = $1 --- AlbumRepository.removeAssets +-- AlbumRepository.removeAssetIds DELETE FROM "albums_assets_assets" WHERE ( @@ -646,7 +646,7 @@ WHERE LIMIT 1 --- AlbumRepository.addAssets +-- AlbumRepository.addAssetIds INSERT INTO "albums_assets_assets" ("albumsId", "assetsId") VALUES diff --git a/server/src/queries/asset.repository.sql b/server/src/queries/asset.repository.sql index de7d061cd..fab0f5376 100644 --- a/server/src/queries/asset.repository.sql +++ b/server/src/queries/asset.repository.sql @@ -1,5 +1,88 @@ -- NOTE: This file is auto generated by ./sql-generator +-- AssetRepository.getByDayOfYear +SELECT + "entity"."id" AS "entity_id", + "entity"."deviceAssetId" AS "entity_deviceAssetId", + "entity"."ownerId" AS "entity_ownerId", + "entity"."libraryId" AS "entity_libraryId", + "entity"."deviceId" AS "entity_deviceId", + "entity"."type" AS "entity_type", + "entity"."originalPath" AS "entity_originalPath", + "entity"."resizePath" AS "entity_resizePath", + "entity"."webpPath" AS "entity_webpPath", + "entity"."thumbhash" AS "entity_thumbhash", + "entity"."encodedVideoPath" AS "entity_encodedVideoPath", + "entity"."createdAt" AS "entity_createdAt", + "entity"."updatedAt" AS "entity_updatedAt", + "entity"."deletedAt" AS "entity_deletedAt", + "entity"."fileCreatedAt" AS "entity_fileCreatedAt", + "entity"."localDateTime" AS "entity_localDateTime", + "entity"."fileModifiedAt" AS "entity_fileModifiedAt", + "entity"."isFavorite" AS "entity_isFavorite", + "entity"."isArchived" AS "entity_isArchived", + "entity"."isExternal" AS "entity_isExternal", + "entity"."isReadOnly" AS "entity_isReadOnly", + "entity"."isOffline" AS "entity_isOffline", + "entity"."checksum" AS "entity_checksum", + "entity"."duration" AS "entity_duration", + "entity"."isVisible" AS "entity_isVisible", + "entity"."livePhotoVideoId" AS "entity_livePhotoVideoId", + "entity"."originalFileName" AS "entity_originalFileName", + "entity"."sidecarPath" AS "entity_sidecarPath", + "entity"."stackId" AS "entity_stackId", + "exifInfo"."assetId" AS "exifInfo_assetId", + "exifInfo"."description" AS "exifInfo_description", + "exifInfo"."exifImageWidth" AS "exifInfo_exifImageWidth", + "exifInfo"."exifImageHeight" AS "exifInfo_exifImageHeight", + "exifInfo"."fileSizeInByte" AS "exifInfo_fileSizeInByte", + "exifInfo"."orientation" AS "exifInfo_orientation", + "exifInfo"."dateTimeOriginal" AS "exifInfo_dateTimeOriginal", + "exifInfo"."modifyDate" AS "exifInfo_modifyDate", + "exifInfo"."timeZone" AS "exifInfo_timeZone", + "exifInfo"."latitude" AS "exifInfo_latitude", + "exifInfo"."longitude" AS "exifInfo_longitude", + "exifInfo"."projectionType" AS "exifInfo_projectionType", + "exifInfo"."city" AS "exifInfo_city", + "exifInfo"."livePhotoCID" AS "exifInfo_livePhotoCID", + "exifInfo"."autoStackId" AS "exifInfo_autoStackId", + "exifInfo"."state" AS "exifInfo_state", + "exifInfo"."country" AS "exifInfo_country", + "exifInfo"."make" AS "exifInfo_make", + "exifInfo"."model" AS "exifInfo_model", + "exifInfo"."lensModel" AS "exifInfo_lensModel", + "exifInfo"."fNumber" AS "exifInfo_fNumber", + "exifInfo"."focalLength" AS "exifInfo_focalLength", + "exifInfo"."iso" AS "exifInfo_iso", + "exifInfo"."exposureTime" AS "exifInfo_exposureTime", + "exifInfo"."profileDescription" AS "exifInfo_profileDescription", + "exifInfo"."colorspace" AS "exifInfo_colorspace", + "exifInfo"."bitsPerSample" AS "exifInfo_bitsPerSample", + "exifInfo"."fps" AS "exifInfo_fps" +FROM + "assets" "entity" + LEFT JOIN "exif" "exifInfo" ON "exifInfo"."assetId" = "entity"."id" +WHERE + ( + "entity"."ownerId" IN ($1) + AND "entity"."isVisible" = true + AND "entity"."isArchived" = false + AND "entity"."resizePath" IS NOT NULL + AND EXTRACT( + DAY + FROM + "entity"."localDateTime" AT TIME ZONE 'UTC' + ) = $2 + AND EXTRACT( + MONTH + FROM + "entity"."localDateTime" AT TIME ZONE 'UTC' + ) = $3 + ) + AND ("entity"."deletedAt" IS NULL) +ORDER BY + "entity"."localDateTime" ASC + -- AssetRepository.getByIds SELECT "AssetEntity"."id" AS "AssetEntity_id", @@ -170,6 +253,34 @@ DELETE FROM "assets" WHERE "ownerId" = $1 +-- AssetRepository.getLibraryAssetPaths +SELECT DISTINCT + "distinctAlias"."AssetEntity_id" AS "ids_AssetEntity_id" +FROM + ( + SELECT + "AssetEntity"."id" AS "AssetEntity_id", + "AssetEntity"."originalPath" AS "AssetEntity_originalPath", + "AssetEntity"."isOffline" AS "AssetEntity_isOffline" + FROM + "assets" "AssetEntity" + LEFT JOIN "libraries" "AssetEntity__AssetEntity_library" ON "AssetEntity__AssetEntity_library"."id" = "AssetEntity"."libraryId" + AND ( + "AssetEntity__AssetEntity_library"."deletedAt" IS NULL + ) + WHERE + ( + ( + ((("AssetEntity__AssetEntity_library"."id" = $1))) + ) + ) + AND ("AssetEntity"."deletedAt" IS NULL) + ) "distinctAlias" +ORDER BY + "AssetEntity_id" ASC +LIMIT + 2 + -- AssetRepository.getByLibraryIdAndOriginalPath SELECT DISTINCT "distinctAlias"."AssetEntity_id" AS "ids_AssetEntity_id" diff --git a/server/src/queries/audit.repository.sql b/server/src/queries/audit.repository.sql deleted file mode 100644 index 21f9f116b..000000000 --- a/server/src/queries/audit.repository.sql +++ /dev/null @@ -1 +0,0 @@ --- NOTE: This file is auto generated by ./sql-generator diff --git a/server/src/queries/metadata.repository.sql b/server/src/queries/metadata.repository.sql new file mode 100644 index 000000000..bed7d59ab --- /dev/null +++ b/server/src/queries/metadata.repository.sql @@ -0,0 +1,66 @@ +-- NOTE: This file is auto generated by ./sql-generator + +-- MetadataRepository.getCountries +SELECT DISTINCT + ON ("exif"."country") "exif"."country" AS "exif_country", + "exif"."assetId" AS "exif_assetId" +FROM + "exif" "exif" + LEFT JOIN "assets" "asset" ON "asset"."id" = "exif"."assetId" + AND ("asset"."deletedAt" IS NULL) +WHERE + "asset"."ownerId" = $1 + AND "exif"."country" IS NOT NULL + +-- MetadataRepository.getStates +SELECT DISTINCT + ON ("exif"."state") "exif"."state" AS "exif_state", + "exif"."assetId" AS "exif_assetId" +FROM + "exif" "exif" + LEFT JOIN "assets" "asset" ON "asset"."id" = "exif"."assetId" + AND ("asset"."deletedAt" IS NULL) +WHERE + "asset"."ownerId" = $1 + AND "exif"."state" IS NOT NULL + AND "exif"."country" = $2 + +-- MetadataRepository.getCities +SELECT DISTINCT + ON ("exif"."city") "exif"."city" AS "exif_city", + "exif"."assetId" AS "exif_assetId" +FROM + "exif" "exif" + LEFT JOIN "assets" "asset" ON "asset"."id" = "exif"."assetId" + AND ("asset"."deletedAt" IS NULL) +WHERE + "asset"."ownerId" = $1 + AND "exif"."city" IS NOT NULL + AND "exif"."country" = $2 + AND "exif"."state" = $3 + +-- MetadataRepository.getCameraMakes +SELECT DISTINCT + ON ("exif"."make") "exif"."make" AS "exif_make", + "exif"."assetId" AS "exif_assetId" +FROM + "exif" "exif" + LEFT JOIN "assets" "asset" ON "asset"."id" = "exif"."assetId" + AND ("asset"."deletedAt" IS NULL) +WHERE + "asset"."ownerId" = $1 + AND "exif"."make" IS NOT NULL + AND "exif"."model" = $2 + +-- MetadataRepository.getCameraModels +SELECT DISTINCT + ON ("exif"."model") "exif"."model" AS "exif_model", + "exif"."assetId" AS "exif_assetId" +FROM + "exif" "exif" + LEFT JOIN "assets" "asset" ON "asset"."id" = "exif"."assetId" + AND ("asset"."deletedAt" IS NULL) +WHERE + "asset"."ownerId" = $1 + AND "exif"."model" IS NOT NULL + AND "exif"."make" = $2 diff --git a/server/src/queries/partner.repository.sql b/server/src/queries/partner.repository.sql deleted file mode 100644 index 21f9f116b..000000000 --- a/server/src/queries/partner.repository.sql +++ /dev/null @@ -1 +0,0 @@ --- NOTE: This file is auto generated by ./sql-generator diff --git a/server/src/queries/search.repository.sql b/server/src/queries/search.repository.sql index 68a5918c4..c7b660068 100644 --- a/server/src/queries/search.repository.sql +++ b/server/src/queries/search.repository.sql @@ -278,7 +278,7 @@ WITH RECURSIVE exif INNER JOIN assets ON exif."assetId" = assets.id WHERE - "ownerId" = ANY ('$1'::uuid []) + "ownerId" = ANY ($1::uuid []) AND "isVisible" = $2 AND "isArchived" = $3 AND type = $4 @@ -302,7 +302,7 @@ WITH RECURSIVE INNER JOIN assets ON exif."assetId" = assets.id WHERE city > c.city - AND "ownerId" = ANY ('$1'::uuid []) + AND "ownerId" = ANY ($1::uuid []) AND "isVisible" = $2 AND "isArchived" = $3 AND type = $4 diff --git a/server/src/queries/system.metadata.repository.sql b/server/src/queries/system.metadata.repository.sql deleted file mode 100644 index 21f9f116b..000000000 --- a/server/src/queries/system.metadata.repository.sql +++ /dev/null @@ -1 +0,0 @@ --- NOTE: This file is auto generated by ./sql-generator diff --git a/server/src/queries/tag.repository.sql b/server/src/queries/tag.repository.sql deleted file mode 100644 index 21f9f116b..000000000 --- a/server/src/queries/tag.repository.sql +++ /dev/null @@ -1 +0,0 @@ --- NOTE: This file is auto generated by ./sql-generator diff --git a/server/src/repositories/album.repository.ts b/server/src/repositories/album.repository.ts index f98be2161..bbaab2a12 100644 --- a/server/src/repositories/album.repository.ts +++ b/server/src/repositories/album.repository.ts @@ -5,13 +5,7 @@ import { dataSource } from 'src/database.config'; import { Chunked, ChunkedArray, DATABASE_PARAMETER_CHUNK_SIZE, DummyValue, GenerateSql } from 'src/decorators'; import { AlbumEntity } from 'src/entities/album.entity'; import { AssetEntity } from 'src/entities/asset.entity'; -import { - AlbumAsset, - AlbumAssetCount, - AlbumAssets, - AlbumInfoOptions, - IAlbumRepository, -} from 'src/interfaces/album.interface'; +import { AlbumAsset, AlbumAssetCount, AlbumInfoOptions, IAlbumRepository } from 'src/interfaces/album.interface'; import { Instrumentation } from 'src/utils/instrumentation'; import { setUnion } from 'src/utils/set'; import { DataSource, FindOptionsOrder, FindOptionsRelations, In, IsNull, Not, Repository } from 'typeorm'; @@ -203,7 +197,7 @@ export class AlbumRepository implements IAlbumRepository { @GenerateSql({ params: [DummyValue.UUID, [DummyValue.UUID]] }) @Chunked({ paramIndex: 1 }) - async removeAssets(albumId: string, assetIds: string[]): Promise { + async removeAssetIds(albumId: string, assetIds: string[]): Promise { await this.dataSource .createQueryBuilder() .delete() @@ -260,8 +254,8 @@ export class AlbumRepository implements IAlbumRepository { }); } - @GenerateSql({ params: [{ albumId: DummyValue.UUID, assetIds: [DummyValue.UUID] }] }) - async addAssets({ albumId, assetIds }: AlbumAssets): Promise { + @GenerateSql({ params: [DummyValue.UUID, [DummyValue.UUID]] }) + async addAssetIds(albumId: string, assetIds: string[]): Promise { await this.dataSource .createQueryBuilder() .insert() diff --git a/server/src/repositories/asset.repository.ts b/server/src/repositories/asset.repository.ts index 6efb8762e..70a986453 100644 --- a/server/src/repositories/asset.repository.ts +++ b/server/src/repositories/asset.repository.ts @@ -75,7 +75,7 @@ export class AssetRepository implements IAssetRepository { return this.repository.save(asset); } - @GenerateSql({ params: [DummyValue.UUID, { day: 1, month: 1 }] }) + @GenerateSql({ params: [[DummyValue.UUID], { day: 1, month: 1 }] }) getByDayOfYear(ownerIds: string[], { day, month }: MonthDay): Promise { return this.repository .createQueryBuilder('entity') @@ -93,7 +93,7 @@ export class AssetRepository implements IAssetRepository { }, ) .leftJoinAndSelect('entity.exifInfo', 'exifInfo') - .orderBy('entity.localDateTime', 'DESC') + .orderBy('entity.localDateTime', 'ASC') .getMany(); } @@ -159,7 +159,7 @@ export class AssetRepository implements IAssetRepository { return this.getAll(pagination, { ...options, userIds: [userId] }); } - @GenerateSql({ params: [[DummyValue.UUID]] }) + @GenerateSql({ params: [{ take: 1, skip: 0 }, DummyValue.UUID] }) getLibraryAssetPaths(pagination: PaginationOptions, libraryId: string): Paginated { return paginate(this.repository, pagination, { select: { id: true, originalPath: true, isOffline: true }, diff --git a/server/src/repositories/index.ts b/server/src/repositories/index.ts new file mode 100644 index 000000000..4ed114216 --- /dev/null +++ b/server/src/repositories/index.ts @@ -0,0 +1,90 @@ +import { IAccessRepository } from 'src/interfaces/access.interface'; +import { IActivityRepository } from 'src/interfaces/activity.interface'; +import { IAlbumRepository } from 'src/interfaces/album.interface'; +import { IKeyRepository } from 'src/interfaces/api-key.interface'; +import { IAssetStackRepository } from 'src/interfaces/asset-stack.interface'; +import { IAssetRepositoryV1 } from 'src/interfaces/asset-v1.interface'; +import { IAssetRepository } from 'src/interfaces/asset.interface'; +import { IAuditRepository } from 'src/interfaces/audit.interface'; +import { ICryptoRepository } from 'src/interfaces/crypto.interface'; +import { IDatabaseRepository } from 'src/interfaces/database.interface'; +import { IEventRepository } from 'src/interfaces/event.interface'; +import { IJobRepository } from 'src/interfaces/job.interface'; +import { ILibraryRepository } from 'src/interfaces/library.interface'; +import { IMachineLearningRepository } from 'src/interfaces/machine-learning.interface'; +import { IMediaRepository } from 'src/interfaces/media.interface'; +import { IMetadataRepository } from 'src/interfaces/metadata.interface'; +import { IMetricRepository } from 'src/interfaces/metric.interface'; +import { IMoveRepository } from 'src/interfaces/move.interface'; +import { IPartnerRepository } from 'src/interfaces/partner.interface'; +import { IPersonRepository } from 'src/interfaces/person.interface'; +import { ISearchRepository } from 'src/interfaces/search.interface'; +import { IServerInfoRepository } from 'src/interfaces/server-info.interface'; +import { ISharedLinkRepository } from 'src/interfaces/shared-link.interface'; +import { IStorageRepository } from 'src/interfaces/storage.interface'; +import { ISystemConfigRepository } from 'src/interfaces/system-config.interface'; +import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; +import { ITagRepository } from 'src/interfaces/tag.interface'; +import { IUserTokenRepository } from 'src/interfaces/user-token.interface'; +import { IUserRepository } from 'src/interfaces/user.interface'; +import { AccessRepository } from 'src/repositories/access.repository'; +import { ActivityRepository } from 'src/repositories/activity.repository'; +import { AlbumRepository } from 'src/repositories/album.repository'; +import { ApiKeyRepository } from 'src/repositories/api-key.repository'; +import { AssetStackRepository } from 'src/repositories/asset-stack.repository'; +import { AssetRepositoryV1 } from 'src/repositories/asset-v1.repository'; +import { AssetRepository } from 'src/repositories/asset.repository'; +import { AuditRepository } from 'src/repositories/audit.repository'; +import { CryptoRepository } from 'src/repositories/crypto.repository'; +import { DatabaseRepository } from 'src/repositories/database.repository'; +import { EventRepository } from 'src/repositories/event.repository'; +import { JobRepository } from 'src/repositories/job.repository'; +import { LibraryRepository } from 'src/repositories/library.repository'; +import { MachineLearningRepository } from 'src/repositories/machine-learning.repository'; +import { MediaRepository } from 'src/repositories/media.repository'; +import { MetadataRepository } from 'src/repositories/metadata.repository'; +import { MetricRepository } from 'src/repositories/metric.repository'; +import { MoveRepository } from 'src/repositories/move.repository'; +import { PartnerRepository } from 'src/repositories/partner.repository'; +import { PersonRepository } from 'src/repositories/person.repository'; +import { SearchRepository } from 'src/repositories/search.repository'; +import { ServerInfoRepository } from 'src/repositories/server-info.repository'; +import { SharedLinkRepository } from 'src/repositories/shared-link.repository'; +import { StorageRepository } from 'src/repositories/storage.repository'; +import { SystemConfigRepository } from 'src/repositories/system-config.repository'; +import { SystemMetadataRepository } from 'src/repositories/system-metadata.repository'; +import { TagRepository } from 'src/repositories/tag.repository'; +import { UserTokenRepository } from 'src/repositories/user-token.repository'; +import { UserRepository } from 'src/repositories/user.repository'; + +export const repositories = [ + { provide: IActivityRepository, useClass: ActivityRepository }, + { provide: IAccessRepository, useClass: AccessRepository }, + { provide: IAlbumRepository, useClass: AlbumRepository }, + { provide: IAssetRepository, useClass: AssetRepository }, + { provide: IAssetRepositoryV1, useClass: AssetRepositoryV1 }, + { provide: IAssetStackRepository, useClass: AssetStackRepository }, + { provide: IAuditRepository, useClass: AuditRepository }, + { provide: ICryptoRepository, useClass: CryptoRepository }, + { provide: IDatabaseRepository, useClass: DatabaseRepository }, + { provide: IEventRepository, useClass: EventRepository }, + { provide: IJobRepository, useClass: JobRepository }, + { provide: ILibraryRepository, useClass: LibraryRepository }, + { provide: IKeyRepository, useClass: ApiKeyRepository }, + { provide: IMachineLearningRepository, useClass: MachineLearningRepository }, + { provide: IMetadataRepository, useClass: MetadataRepository }, + { provide: IMetricRepository, useClass: MetricRepository }, + { provide: IMoveRepository, useClass: MoveRepository }, + { provide: IPartnerRepository, useClass: PartnerRepository }, + { provide: IPersonRepository, useClass: PersonRepository }, + { provide: IServerInfoRepository, useClass: ServerInfoRepository }, + { provide: ISharedLinkRepository, useClass: SharedLinkRepository }, + { provide: ISearchRepository, useClass: SearchRepository }, + { provide: IStorageRepository, useClass: StorageRepository }, + { provide: ISystemConfigRepository, useClass: SystemConfigRepository }, + { provide: ISystemMetadataRepository, useClass: SystemMetadataRepository }, + { provide: ITagRepository, useClass: TagRepository }, + { provide: IMediaRepository, useClass: MediaRepository }, + { provide: IUserRepository, useClass: UserRepository }, + { provide: IUserTokenRepository, useClass: UserTokenRepository }, +]; diff --git a/server/src/repositories/library.repository.ts b/server/src/repositories/library.repository.ts index d53354e25..564573a4b 100644 --- a/server/src/repositories/library.repository.ts +++ b/server/src/repositories/library.repository.ts @@ -5,7 +5,7 @@ import { LibraryStatsResponseDto } from 'src/dtos/library.dto'; import { LibraryEntity, LibraryType } from 'src/entities/library.entity'; import { ILibraryRepository } from 'src/interfaces/library.interface'; import { Instrumentation } from 'src/utils/instrumentation'; -import { IsNull, Not } from 'typeorm'; +import { EntityNotFoundError, IsNull, Not } from 'typeorm'; import { Repository } from 'typeorm/repository/Repository.js'; @Instrumentation() @@ -139,6 +139,10 @@ export class LibraryRepository implements ILibraryRepository { .where('libraries.id = :id', { id }) .getRawOne(); + if (!stats) { + throw new EntityNotFoundError(LibraryEntity, { where: { id } }); + } + return { photos: Number(stats.photos), videos: Number(stats.videos), diff --git a/server/src/repositories/person.repository.ts b/server/src/repositories/person.repository.ts index 867d6b174..0acbafe69 100644 --- a/server/src/repositories/person.repository.ts +++ b/server/src/repositories/person.repository.ts @@ -107,6 +107,7 @@ export class PersonRepository implements IPersonRepository { @GenerateSql({ params: [DummyValue.UUID] }) getFaceById(id: string): Promise { + // TODO return null instead of find or fail return this.assetFaceRepository.findOneOrFail({ where: { id }, relations: { diff --git a/server/src/repositories/search.repository.ts b/server/src/repositories/search.repository.ts index 1642cca4f..2de48b741 100644 --- a/server/src/repositories/search.repository.ts +++ b/server/src/repositories/search.repository.ts @@ -225,7 +225,7 @@ export class SearchRepository implements ISearchRepository { .getMany(); } - @GenerateSql({ params: [[DummyValue.UUID, DummyValue.UUID]] }) + @GenerateSql({ params: [[DummyValue.UUID]] }) async getAssetsByCity(userIds: string[]): Promise { const parameters = [userIds, true, false, AssetType.IMAGE]; const rawRes = await this.repository.query(this.assetsByCityQuery, parameters); @@ -315,7 +315,7 @@ WITH RECURSIVE cte AS ( SELECT city, "assetId" FROM exif INNER JOIN assets ON exif."assetId" = assets.id - WHERE "ownerId" = ANY('$1'::uuid[]) AND "isVisible" = $2 AND "isArchived" = $3 AND type = $4 + WHERE "ownerId" = ANY($1::uuid[]) AND "isVisible" = $2 AND "isArchived" = $3 AND type = $4 ORDER BY city LIMIT 1 ) @@ -328,7 +328,7 @@ WITH RECURSIVE cte AS ( SELECT city, "assetId" FROM exif INNER JOIN assets ON exif."assetId" = assets.id - WHERE city > c.city AND "ownerId" = ANY('$1'::uuid[]) AND "isVisible" = $2 AND "isArchived" = $3 AND type = $4 + WHERE city > c.city AND "ownerId" = ANY($1::uuid[]) AND "isVisible" = $2 AND "isArchived" = $3 AND type = $4 ORDER BY city LIMIT 1 ) l diff --git a/server/src/services/album.service.spec.ts b/server/src/services/album.service.spec.ts index 48462fac4..02bb607b5 100644 --- a/server/src/services/album.service.spec.ts +++ b/server/src/services/album.service.spec.ts @@ -518,10 +518,7 @@ describe(AlbumService.name, () => { updatedAt: expect.any(Date), albumThumbnailAssetId: 'asset-1', }); - expect(albumMock.addAssets).toHaveBeenCalledWith({ - albumId: 'album-123', - assetIds: ['asset-1', 'asset-2', 'asset-3'], - }); + expect(albumMock.addAssetIds).toHaveBeenCalledWith('album-123', ['asset-1', 'asset-2', 'asset-3']); }); it('should not set the thumbnail if the album has one already', async () => { @@ -539,7 +536,7 @@ describe(AlbumService.name, () => { updatedAt: expect.any(Date), albumThumbnailAssetId: 'asset-id', }); - expect(albumMock.addAssets).toHaveBeenCalled(); + expect(albumMock.addAssetIds).toHaveBeenCalled(); }); it('should allow a shared user to add assets', async () => { @@ -561,10 +558,7 @@ describe(AlbumService.name, () => { updatedAt: expect.any(Date), albumThumbnailAssetId: 'asset-1', }); - expect(albumMock.addAssets).toHaveBeenCalledWith({ - albumId: 'album-123', - assetIds: ['asset-1', 'asset-2', 'asset-3'], - }); + expect(albumMock.addAssetIds).toHaveBeenCalledWith('album-123', ['asset-1', 'asset-2', 'asset-3']); }); it('should allow a shared link user to add assets', async () => { @@ -586,10 +580,7 @@ describe(AlbumService.name, () => { updatedAt: expect.any(Date), albumThumbnailAssetId: 'asset-1', }); - expect(albumMock.addAssets).toHaveBeenCalledWith({ - albumId: 'album-123', - assetIds: ['asset-1', 'asset-2', 'asset-3'], - }); + expect(albumMock.addAssetIds).toHaveBeenCalledWith('album-123', ['asset-1', 'asset-2', 'asset-3']); expect(accessMock.album.checkSharedLinkAccess).toHaveBeenCalledWith( authStub.adminSharedLink.sharedLink?.id, @@ -665,23 +656,23 @@ describe(AlbumService.name, () => { describe('removeAssets', () => { it('should allow the owner to remove assets', async () => { - accessMock.album.checkOwnerAccess.mockResolvedValueOnce(new Set(['album-123'])); - accessMock.album.checkOwnerAccess.mockResolvedValueOnce(new Set(['asset-id'])); + accessMock.album.checkOwnerAccess.mockResolvedValue(new Set(['album-123'])); + accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-id'])); albumMock.getById.mockResolvedValue(_.cloneDeep(albumStub.oneAsset)); - albumMock.getAssetIds.mockResolvedValueOnce(new Set(['asset-id'])); + albumMock.getAssetIds.mockResolvedValue(new Set(['asset-id'])); await expect(sut.removeAssets(authStub.admin, 'album-123', { ids: ['asset-id'] })).resolves.toEqual([ { success: true, id: 'asset-id' }, ]); expect(albumMock.update).toHaveBeenCalledWith({ id: 'album-123', updatedAt: expect.any(Date) }); - expect(albumMock.removeAssets).toHaveBeenCalledWith('album-123', ['asset-id']); + expect(albumMock.removeAssetIds).toHaveBeenCalledWith('album-123', ['asset-id']); }); it('should skip assets not in the album', async () => { accessMock.album.checkOwnerAccess.mockResolvedValue(new Set(['album-123'])); albumMock.getById.mockResolvedValue(_.cloneDeep(albumStub.empty)); - albumMock.getAssetIds.mockResolvedValueOnce(new Set()); + albumMock.getAssetIds.mockResolvedValue(new Set()); await expect(sut.removeAssets(authStub.admin, 'album-123', { ids: ['asset-id'] })).resolves.toEqual([ { success: false, id: 'asset-id', error: BulkIdErrorReason.NOT_FOUND }, @@ -693,7 +684,7 @@ describe(AlbumService.name, () => { it('should skip assets without user permission to remove', async () => { accessMock.album.checkSharedAlbumAccess.mockResolvedValue(new Set(['album-123'])); albumMock.getById.mockResolvedValue(_.cloneDeep(albumStub.oneAsset)); - albumMock.getAssetIds.mockResolvedValueOnce(new Set(['asset-id'])); + albumMock.getAssetIds.mockResolvedValue(new Set(['asset-id'])); await expect(sut.removeAssets(authStub.admin, 'album-123', { ids: ['asset-id'] })).resolves.toEqual([ { @@ -707,10 +698,10 @@ describe(AlbumService.name, () => { }); it('should reset the thumbnail if it is removed', async () => { - accessMock.album.checkOwnerAccess.mockResolvedValueOnce(new Set(['album-123'])); - accessMock.album.checkOwnerAccess.mockResolvedValueOnce(new Set(['asset-id'])); + accessMock.album.checkOwnerAccess.mockResolvedValue(new Set(['album-123'])); + accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-id'])); albumMock.getById.mockResolvedValue(_.cloneDeep(albumStub.twoAssets)); - albumMock.getAssetIds.mockResolvedValueOnce(new Set(['asset-id'])); + albumMock.getAssetIds.mockResolvedValue(new Set(['asset-id'])); await expect(sut.removeAssets(authStub.admin, 'album-123', { ids: ['asset-id'] })).resolves.toEqual([ { success: true, id: 'asset-id' }, diff --git a/server/src/services/album.service.ts b/server/src/services/album.service.ts index 483ddc3b0..df6c6b814 100644 --- a/server/src/services/album.service.ts +++ b/server/src/services/album.service.ts @@ -12,7 +12,7 @@ import { mapAlbumWithAssets, mapAlbumWithoutAssets, } from 'src/dtos/album.dto'; -import { BulkIdErrorReason, BulkIdResponseDto, BulkIdsDto } from 'src/dtos/asset-ids.response.dto'; +import { BulkIdResponseDto, BulkIdsDto } from 'src/dtos/asset-ids.response.dto'; import { AuthDto } from 'src/dtos/auth.dto'; import { AlbumEntity } from 'src/entities/album.entity'; import { AssetEntity } from 'src/entities/asset.entity'; @@ -21,13 +21,13 @@ import { IAccessRepository } from 'src/interfaces/access.interface'; import { AlbumAssetCount, AlbumInfoOptions, IAlbumRepository } from 'src/interfaces/album.interface'; import { IAssetRepository } from 'src/interfaces/asset.interface'; import { IUserRepository } from 'src/interfaces/user.interface'; -import { setUnion } from 'src/utils/set'; +import { addAssets, removeAssets } from 'src/utils/asset.util'; @Injectable() export class AlbumService { private access: AccessCore; constructor( - @Inject(IAccessRepository) accessRepository: IAccessRepository, + @Inject(IAccessRepository) private accessRepository: IAccessRepository, @Inject(IAlbumRepository) private albumRepository: IAlbumRepository, @Inject(IAssetRepository) private assetRepository: IAssetRepository, @Inject(IUserRepository) private userRepository: IUserRepository, @@ -164,37 +164,20 @@ export class AlbumService { async addAssets(auth: AuthDto, id: string, dto: BulkIdsDto): Promise { const album = await this.findOrFail(id, { withAssets: false }); - await this.access.requirePermission(auth, Permission.ALBUM_READ, id); - const existingAssetIds = await this.albumRepository.getAssetIds(id, dto.ids); - const notPresentAssetIds = dto.ids.filter((id) => !existingAssetIds.has(id)); - const allowedAssetIds = await this.access.checkAccess(auth, Permission.ASSET_SHARE, notPresentAssetIds); + const results = await addAssets( + auth, + { accessRepository: this.accessRepository, repository: this.albumRepository }, + { id, assetIds: dto.ids }, + ); - const results: BulkIdResponseDto[] = []; - for (const assetId of dto.ids) { - const hasAsset = existingAssetIds.has(assetId); - if (hasAsset) { - results.push({ id: assetId, success: false, error: BulkIdErrorReason.DUPLICATE }); - continue; - } - - const hasAccess = allowedAssetIds.has(assetId); - if (!hasAccess) { - results.push({ id: assetId, success: false, error: BulkIdErrorReason.NO_PERMISSION }); - continue; - } - - results.push({ id: assetId, success: true }); - } - - const newAssetIds = results.filter(({ success }) => success).map(({ id }) => id); - if (newAssetIds.length > 0) { - await this.albumRepository.addAssets({ albumId: id, assetIds: newAssetIds }); + const { id: firstNewAssetId } = results.find(({ success }) => success) || {}; + if (firstNewAssetId) { await this.albumRepository.update({ id, updatedAt: new Date(), - albumThumbnailAssetId: album.albumThumbnailAssetId ?? newAssetIds[0], + albumThumbnailAssetId: album.albumThumbnailAssetId ?? firstNewAssetId, }); } @@ -206,31 +189,14 @@ export class AlbumService { await this.access.requirePermission(auth, Permission.ALBUM_READ, id); - const existingAssetIds = await this.albumRepository.getAssetIds(id, dto.ids); - const canRemove = await this.access.checkAccess(auth, Permission.ALBUM_REMOVE_ASSET, existingAssetIds); - const canShare = await this.access.checkAccess(auth, Permission.ASSET_SHARE, existingAssetIds); - const allowedAssetIds = setUnion(canRemove, canShare); - - const results: BulkIdResponseDto[] = []; - for (const assetId of dto.ids) { - const hasAsset = existingAssetIds.has(assetId); - if (!hasAsset) { - results.push({ id: assetId, success: false, error: BulkIdErrorReason.NOT_FOUND }); - continue; - } - - const hasAccess = allowedAssetIds.has(assetId); - if (!hasAccess) { - results.push({ id: assetId, success: false, error: BulkIdErrorReason.NO_PERMISSION }); - continue; - } - - results.push({ id: assetId, success: true }); - } + const results = await removeAssets( + auth, + { accessRepository: this.accessRepository, repository: this.albumRepository }, + { id, assetIds: dto.ids, permissions: [Permission.ASSET_SHARE, Permission.ALBUM_REMOVE_ASSET] }, + ); const removedIds = results.filter(({ success }) => success).map(({ id }) => id); if (removedIds.length > 0) { - await this.albumRepository.removeAssets(id, removedIds); await this.albumRepository.update({ id, updatedAt: new Date() }); if (album.albumThumbnailAssetId && removedIds.includes(album.albumThumbnailAssetId)) { await this.albumRepository.updateThumbnails(); diff --git a/server/src/services/asset.service.spec.ts b/server/src/services/asset.service.spec.ts index df1f819b4..f7e502e69 100644 --- a/server/src/services/asset.service.spec.ts +++ b/server/src/services/asset.service.spec.ts @@ -4,7 +4,7 @@ import { mapAsset } from 'src/dtos/asset-response.dto'; import { AssetJobName, AssetStatsResponseDto, UploadFieldName } from 'src/dtos/asset.dto'; import { AssetEntity, AssetType } from 'src/entities/asset.entity'; import { IAssetStackRepository } from 'src/interfaces/asset-stack.interface'; -import { AssetStats, IAssetRepository, TimeBucketSize } from 'src/interfaces/asset.interface'; +import { AssetStats, IAssetRepository } from 'src/interfaces/asset.interface'; import { ClientEvent, IEventRepository } from 'src/interfaces/event.interface'; import { IJobRepository, JobItem, JobName } from 'src/interfaces/job.interface'; import { IPartnerRepository } from 'src/interfaces/partner.interface'; @@ -307,13 +307,17 @@ describe(AssetService.name, () => { jest.useRealTimers(); }); - it('should set the title correctly', async () => { + it('should group the assets correctly', async () => { + const image1 = { ...assetStub.image, localDateTime: new Date(2023, 1, 15, 0, 0, 0) }; + const image2 = { ...assetStub.image, localDateTime: new Date(2023, 1, 15, 1, 0, 0) }; + const image3 = { ...assetStub.image, localDateTime: new Date(2015, 1, 15) }; + partnerMock.getAll.mockResolvedValue([]); - assetMock.getByDayOfYear.mockResolvedValue([assetStub.image, assetStub.imageFrom2015]); + assetMock.getByDayOfYear.mockResolvedValue([image1, image2, image3]); await expect(sut.getMemoryLane(authStub.admin, { day: 15, month: 1 })).resolves.toEqual([ - { title: '1 year since...', assets: [mapAsset(assetStub.image)] }, - { title: '9 years since...', assets: [mapAsset(assetStub.imageFrom2015)] }, + { yearsAgo: 1, title: '1 year since...', assets: [mapAsset(image1), mapAsset(image2)] }, + { yearsAgo: 9, title: '9 years since...', assets: [mapAsset(image3)] }, ]); expect(assetMock.getByDayOfYear.mock.calls).toEqual([[[authStub.admin.user.id], { day: 15, month: 1 }]]); @@ -321,6 +325,7 @@ describe(AssetService.name, () => { it('should get memories with partners with inTimeline enabled', async () => { partnerMock.getAll.mockResolvedValue([partnerStub.user1ToAdmin1]); + assetMock.getByDayOfYear.mockResolvedValue([]); await sut.getMemoryLane(authStub.admin, { day: 15, month: 1 }); @@ -330,129 +335,6 @@ describe(AssetService.name, () => { }); }); - describe('getTimeBuckets', () => { - it("should return buckets if userId and albumId aren't set", async () => { - assetMock.getTimeBuckets.mockResolvedValue([{ timeBucket: 'bucket', count: 1 }]); - - await expect( - sut.getTimeBuckets(authStub.admin, { - size: TimeBucketSize.DAY, - }), - ).resolves.toEqual(expect.arrayContaining([{ timeBucket: 'bucket', count: 1 }])); - expect(assetMock.getTimeBuckets).toBeCalledWith({ size: TimeBucketSize.DAY, userIds: [authStub.admin.user.id] }); - }); - }); - - describe('getTimeBucket', () => { - it('should return the assets for a album time bucket if user has album.read', async () => { - accessMock.album.checkOwnerAccess.mockResolvedValue(new Set(['album-id'])); - assetMock.getTimeBucket.mockResolvedValue([assetStub.image]); - - await expect( - sut.getTimeBucket(authStub.admin, { size: TimeBucketSize.DAY, timeBucket: 'bucket', albumId: 'album-id' }), - ).resolves.toEqual(expect.arrayContaining([expect.objectContaining({ id: 'asset-id' })])); - - expect(accessMock.album.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['album-id'])); - expect(assetMock.getTimeBucket).toBeCalledWith('bucket', { - size: TimeBucketSize.DAY, - timeBucket: 'bucket', - albumId: 'album-id', - }); - }); - - it('should return the assets for a archive time bucket if user has archive.read', async () => { - assetMock.getTimeBucket.mockResolvedValue([assetStub.image]); - - await expect( - sut.getTimeBucket(authStub.admin, { - size: TimeBucketSize.DAY, - timeBucket: 'bucket', - isArchived: true, - userId: authStub.admin.user.id, - }), - ).resolves.toEqual(expect.arrayContaining([expect.objectContaining({ id: 'asset-id' })])); - expect(assetMock.getTimeBucket).toBeCalledWith('bucket', { - size: TimeBucketSize.DAY, - timeBucket: 'bucket', - isArchived: true, - userIds: [authStub.admin.user.id], - }); - }); - - it('should return the assets for a library time bucket if user has library.read', async () => { - assetMock.getTimeBucket.mockResolvedValue([assetStub.image]); - - await expect( - sut.getTimeBucket(authStub.admin, { - size: TimeBucketSize.DAY, - timeBucket: 'bucket', - userId: authStub.admin.user.id, - }), - ).resolves.toEqual(expect.arrayContaining([expect.objectContaining({ id: 'asset-id' })])); - expect(assetMock.getTimeBucket).toBeCalledWith('bucket', { - size: TimeBucketSize.DAY, - timeBucket: 'bucket', - userIds: [authStub.admin.user.id], - }); - }); - - it('should throw an error if withParners is true and isArchived true or undefined', async () => { - await expect( - sut.getTimeBucket(authStub.admin, { - size: TimeBucketSize.DAY, - timeBucket: 'bucket', - isArchived: true, - withPartners: true, - userId: authStub.admin.user.id, - }), - ).rejects.toThrowError(BadRequestException); - - await expect( - sut.getTimeBucket(authStub.admin, { - size: TimeBucketSize.DAY, - timeBucket: 'bucket', - isArchived: undefined, - withPartners: true, - userId: authStub.admin.user.id, - }), - ).rejects.toThrowError(BadRequestException); - }); - - it('should throw an error if withParners is true and isFavorite is either true or false', async () => { - await expect( - sut.getTimeBucket(authStub.admin, { - size: TimeBucketSize.DAY, - timeBucket: 'bucket', - isFavorite: true, - withPartners: true, - userId: authStub.admin.user.id, - }), - ).rejects.toThrowError(BadRequestException); - - await expect( - sut.getTimeBucket(authStub.admin, { - size: TimeBucketSize.DAY, - timeBucket: 'bucket', - isFavorite: false, - withPartners: true, - userId: authStub.admin.user.id, - }), - ).rejects.toThrowError(BadRequestException); - }); - - it('should throw an error if withParners is true and isTrash is true', async () => { - await expect( - sut.getTimeBucket(authStub.admin, { - size: TimeBucketSize.DAY, - timeBucket: 'bucket', - isTrashed: true, - withPartners: true, - userId: authStub.admin.user.id, - }), - ).rejects.toThrowError(BadRequestException); - }); - }); - describe('getStatistics', () => { it('should get the statistics for a user, excluding archived assets', async () => { assetMock.getStatistics.mockResolvedValue(stats); diff --git a/server/src/services/asset.service.ts b/server/src/services/asset.service.ts index 230415e80..b8a9dec87 100644 --- a/server/src/services/asset.service.ts +++ b/server/src/services/asset.service.ts @@ -25,12 +25,11 @@ import { import { AuthDto } from 'src/dtos/auth.dto'; import { MapMarkerDto, MapMarkerResponseDto, MemoryLaneDto } from 'src/dtos/search.dto'; import { UpdateStackParentDto } from 'src/dtos/stack.dto'; -import { TimeBucketAssetDto, TimeBucketDto, TimeBucketResponseDto } from 'src/dtos/time-bucket.dto'; import { AssetEntity } from 'src/entities/asset.entity'; import { LibraryType } from 'src/entities/library.entity'; import { IAccessRepository } from 'src/interfaces/access.interface'; import { IAssetStackRepository } from 'src/interfaces/asset-stack.interface'; -import { IAssetRepository, TimeBucketOptions } from 'src/interfaces/asset.interface'; +import { IAssetRepository } from 'src/interfaces/asset.interface'; import { ClientEvent, IEventRepository } from 'src/interfaces/event.interface'; import { IAssetDeletionJob, @@ -174,86 +173,25 @@ export class AssetService { userIds.push(...partnersIds); const assets = await this.assetRepository.getByDayOfYear(userIds, dto); - - return _.chain(assets) - .filter((asset) => asset.localDateTime.getFullYear() < currentYear) - .map((asset) => { - const years = currentYear - asset.localDateTime.getFullYear(); - - return { - title: `${years} year${years > 1 ? 's' : ''} since...`, - asset: mapAsset(asset, { auth }), - }; - }) - .groupBy((asset) => asset.title) - .map((items, title) => ({ title, assets: items.map(({ asset }) => asset) })) - .value(); - } - - private async timeBucketChecks(auth: AuthDto, dto: TimeBucketDto) { - if (dto.albumId) { - await this.access.requirePermission(auth, Permission.ALBUM_READ, [dto.albumId]); - } else { - dto.userId = dto.userId || auth.user.id; - } - - if (dto.userId) { - await this.access.requirePermission(auth, Permission.TIMELINE_READ, [dto.userId]); - if (dto.isArchived !== false) { - await this.access.requirePermission(auth, Permission.ARCHIVE_READ, [dto.userId]); + const groups: Record = {}; + for (const asset of assets) { + const yearsAgo = currentYear - asset.localDateTime.getFullYear(); + if (!groups[yearsAgo]) { + groups[yearsAgo] = []; } + groups[yearsAgo].push(asset); } - if (dto.withPartners) { - const requestedArchived = dto.isArchived === true || dto.isArchived === undefined; - const requestedFavorite = dto.isFavorite === true || dto.isFavorite === false; - const requestedTrash = dto.isTrashed === true; - - if (requestedArchived || requestedFavorite || requestedTrash) { - throw new BadRequestException( - 'withPartners is only supported for non-archived, non-trashed, non-favorited assets', - ); - } - } - } - - async getTimeBuckets(auth: AuthDto, dto: TimeBucketDto): Promise { - await this.timeBucketChecks(auth, dto); - const timeBucketOptions = await this.buildTimeBucketOptions(auth, dto); - - return this.assetRepository.getTimeBuckets(timeBucketOptions); - } - - async getTimeBucket( - auth: AuthDto, - dto: TimeBucketAssetDto, - ): Promise { - await this.timeBucketChecks(auth, dto); - const timeBucketOptions = await this.buildTimeBucketOptions(auth, dto); - const assets = await this.assetRepository.getTimeBucket(dto.timeBucket, timeBucketOptions); - return !auth.sharedLink || auth.sharedLink?.showExif - ? assets.map((asset) => mapAsset(asset, { withStack: true, auth })) - : assets.map((asset) => mapAsset(asset, { stripMetadata: true, auth })); - } - - async buildTimeBucketOptions(auth: AuthDto, dto: TimeBucketDto): Promise { - const { userId, ...options } = dto; - let userIds: string[] | undefined = undefined; - - if (userId) { - userIds = [userId]; - - if (dto.withPartners) { - const partners = await this.partnerRepository.getAll(auth.user.id); - const partnersIds = partners - .filter((partner) => partner.sharedBy && partner.sharedWith && partner.inTimeline) - .map((partner) => partner.sharedById); - - userIds.push(...partnersIds); - } - } - - return { ...options, userIds }; + return Object.keys(groups) + .map(Number) + .sort() + .filter((yearsAgo) => yearsAgo > 0) + .map((yearsAgo) => ({ + yearsAgo, + // TODO move this to clients + title: `${yearsAgo} year${yearsAgo > 1 ? 's' : ''} since...`, + assets: groups[yearsAgo].map((asset) => mapAsset(asset, { auth })), + })); } async getStatistics(auth: AuthDto, dto: AssetStatsDto) { diff --git a/server/src/services/index.ts b/server/src/services/index.ts new file mode 100644 index 000000000..5a97d16fe --- /dev/null +++ b/server/src/services/index.ts @@ -0,0 +1,59 @@ +import { ActivityService } from 'src/services/activity.service'; +import { AlbumService } from 'src/services/album.service'; +import { APIKeyService } from 'src/services/api-key.service'; +import { ApiService } from 'src/services/api.service'; +import { AssetServiceV1 } from 'src/services/asset-v1.service'; +import { AssetService } from 'src/services/asset.service'; +import { AuditService } from 'src/services/audit.service'; +import { AuthService } from 'src/services/auth.service'; +import { DatabaseService } from 'src/services/database.service'; +import { DownloadService } from 'src/services/download.service'; +import { JobService } from 'src/services/job.service'; +import { LibraryService } from 'src/services/library.service'; +import { MediaService } from 'src/services/media.service'; +import { MetadataService } from 'src/services/metadata.service'; +import { MicroservicesService } from 'src/services/microservices.service'; +import { PartnerService } from 'src/services/partner.service'; +import { PersonService } from 'src/services/person.service'; +import { SearchService } from 'src/services/search.service'; +import { ServerInfoService } from 'src/services/server-info.service'; +import { SharedLinkService } from 'src/services/shared-link.service'; +import { SmartInfoService } from 'src/services/smart-info.service'; +import { StorageTemplateService } from 'src/services/storage-template.service'; +import { StorageService } from 'src/services/storage.service'; +import { SystemConfigService } from 'src/services/system-config.service'; +import { TagService } from 'src/services/tag.service'; +import { TimelineService } from 'src/services/timeline.service'; +import { TrashService } from 'src/services/trash.service'; +import { UserService } from 'src/services/user.service'; + +export const services = [ + ApiService, + MicroservicesService, + APIKeyService, + ActivityService, + AlbumService, + AssetService, + AssetServiceV1, + AuditService, + AuthService, + DatabaseService, + DownloadService, + JobService, + LibraryService, + MediaService, + MetadataService, + PartnerService, + PersonService, + SearchService, + ServerInfoService, + SharedLinkService, + SmartInfoService, + StorageService, + StorageTemplateService, + SystemConfigService, + TagService, + TimelineService, + TrashService, + UserService, +]; diff --git a/server/src/services/library.service.spec.ts b/server/src/services/library.service.spec.ts index cab0987c7..7d59a0816 100644 --- a/server/src/services/library.service.spec.ts +++ b/server/src/services/library.service.spec.ts @@ -549,17 +549,17 @@ describe(LibraryService.name, () => { const mockLibraryJob: ILibraryFileJob = { id: libraryStub.externalLibrary1.id, ownerId: mockUser.id, - assetPath: '/data/user1/photo.jpg', + assetPath: assetStub.hasFileExtension.originalPath, force: false, }; storageMock.stat.mockResolvedValue({ size: 100, - mtime: assetStub.image.fileModifiedAt, + mtime: assetStub.hasFileExtension.fileModifiedAt, ctime: new Date('2023-01-01'), } as Stats); - assetMock.getByLibraryIdAndOriginalPath.mockResolvedValue(assetStub.image); + assetMock.getByLibraryIdAndOriginalPath.mockResolvedValue(assetStub.hasFileExtension); await expect(sut.handleAssetRefresh(mockLibraryJob)).resolves.toBe(JobStatus.SKIPPED); @@ -596,6 +596,26 @@ describe(LibraryService.name, () => { }); }); + it('should import an asset that is missing a file extension', async () => { + // This tests for the case where the file extension is missing from the asset path. + // This happened in previous versions of Immich + const mockLibraryJob: ILibraryFileJob = { + id: libraryStub.externalLibrary1.id, + ownerId: mockUser.id, + assetPath: assetStub.missingFileExtension.originalPath, + force: false, + }; + + assetMock.getByLibraryIdAndOriginalPath.mockResolvedValue(assetStub.missingFileExtension); + + await expect(sut.handleAssetRefresh(mockLibraryJob)).resolves.toBe(JobStatus.SUCCESS); + + expect(assetMock.updateAll).toHaveBeenCalledWith( + [assetStub.missingFileExtension.id], + expect.objectContaining({ originalFileName: 'photo.jpg' }), + ); + }); + it('should set a missing asset to offline', async () => { storageMock.stat.mockRejectedValue(new Error('Path not found')); @@ -666,19 +686,20 @@ describe(LibraryService.name, () => { it('should refresh an existing asset if forced', async () => { const mockLibraryJob: ILibraryFileJob = { id: assetStub.image.id, - ownerId: assetStub.image.ownerId, - assetPath: '/data/user1/photo.jpg', + ownerId: assetStub.hasFileExtension.ownerId, + assetPath: assetStub.hasFileExtension.originalPath, force: true, }; - assetMock.getByLibraryIdAndOriginalPath.mockResolvedValue(assetStub.image); - assetMock.create.mockResolvedValue(assetStub.image); + assetMock.getByLibraryIdAndOriginalPath.mockResolvedValue(assetStub.hasFileExtension); + assetMock.create.mockResolvedValue(assetStub.hasFileExtension); await expect(sut.handleAssetRefresh(mockLibraryJob)).resolves.toBe(JobStatus.SUCCESS); - expect(assetMock.updateAll).toHaveBeenCalledWith([assetStub.image.id], { + expect(assetMock.updateAll).toHaveBeenCalledWith([assetStub.hasFileExtension.id], { fileCreatedAt: new Date('2023-01-01'), fileModifiedAt: new Date('2023-01-01'), + originalFileName: assetStub.hasFileExtension.originalFileName, }); }); diff --git a/server/src/services/library.service.ts b/server/src/services/library.service.ts index f52f0a946..79ce188a7 100644 --- a/server/src/services/library.service.ts +++ b/server/src/services/library.service.ts @@ -436,6 +436,8 @@ export class LibraryService extends EventEmitter { doRefresh = true; } + const originalFileName = parse(assetPath).base; + if (!existingAssetEntity) { // This asset is new to us, read it from disk this.logger.debug(`Importing new asset: ${assetPath}`); @@ -446,6 +448,12 @@ export class LibraryService extends EventEmitter { `File modification time has changed, re-importing asset: ${assetPath}. Old mtime: ${existingAssetEntity.fileModifiedAt}. New mtime: ${stats.mtime}`, ); doRefresh = true; + } else if (existingAssetEntity.originalFileName !== originalFileName) { + // TODO: We can likely remove this check in the second half of 2024 when all assets have likely been re-imported by all users + this.logger.debug( + `Asset is missing file extension, re-importing: ${assetPath}. Current incorrect filename: ${existingAssetEntity.originalFileName}.`, + ); + doRefresh = true; } else if (!job.force && stats && !existingAssetEntity.isOffline) { // Asset exists on disk and in db and mtime has not changed. Also, we are not forcing refresn. Therefore, do nothing this.logger.debug(`Asset already exists in database and on disk, will not import: ${assetPath}`); @@ -504,7 +512,7 @@ export class LibraryService extends EventEmitter { fileModifiedAt: stats.mtime, localDateTime: stats.mtime, type: assetType, - originalFileName: parse(assetPath).base, + originalFileName, sidecarPath, isReadOnly: true, isExternal: true, @@ -515,6 +523,7 @@ export class LibraryService extends EventEmitter { await this.assetRepository.updateAll([existingAssetEntity.id], { fileCreatedAt: stats.mtime, fileModifiedAt: stats.mtime, + originalFileName, }); } else { // Not importing and not refreshing, do nothing diff --git a/server/src/services/timeline.service.spec.ts b/server/src/services/timeline.service.spec.ts new file mode 100644 index 000000000..c6f058022 --- /dev/null +++ b/server/src/services/timeline.service.spec.ts @@ -0,0 +1,149 @@ +import { BadRequestException } from '@nestjs/common'; +import { IAssetRepository, TimeBucketSize } from 'src/interfaces/asset.interface'; +import { IPartnerRepository } from 'src/interfaces/partner.interface'; +import { TimelineService } from 'src/services/timeline.service'; +import { assetStub } from 'test/fixtures/asset.stub'; +import { authStub } from 'test/fixtures/auth.stub'; +import { IAccessRepositoryMock, newAccessRepositoryMock } from 'test/repositories/access.repository.mock'; +import { newAssetRepositoryMock } from 'test/repositories/asset.repository.mock'; +import { newPartnerRepositoryMock } from 'test/repositories/partner.repository.mock'; + +describe(TimelineService.name, () => { + let sut: TimelineService; + let accessMock: IAccessRepositoryMock; + let assetMock: jest.Mocked; + let partnerMock: jest.Mocked; + beforeEach(() => { + accessMock = newAccessRepositoryMock(); + assetMock = newAssetRepositoryMock(); + partnerMock = newPartnerRepositoryMock(); + + sut = new TimelineService(accessMock, assetMock, partnerMock); + }); + + describe('getTimeBuckets', () => { + it("should return buckets if userId and albumId aren't set", async () => { + assetMock.getTimeBuckets.mockResolvedValue([{ timeBucket: 'bucket', count: 1 }]); + + await expect( + sut.getTimeBuckets(authStub.admin, { + size: TimeBucketSize.DAY, + }), + ).resolves.toEqual(expect.arrayContaining([{ timeBucket: 'bucket', count: 1 }])); + expect(assetMock.getTimeBuckets).toHaveBeenCalledWith({ + size: TimeBucketSize.DAY, + userIds: [authStub.admin.user.id], + }); + }); + }); + + describe('getTimeBucket', () => { + it('should return the assets for a album time bucket if user has album.read', async () => { + accessMock.album.checkOwnerAccess.mockResolvedValue(new Set(['album-id'])); + assetMock.getTimeBucket.mockResolvedValue([assetStub.image]); + + await expect( + sut.getTimeBucket(authStub.admin, { size: TimeBucketSize.DAY, timeBucket: 'bucket', albumId: 'album-id' }), + ).resolves.toEqual(expect.arrayContaining([expect.objectContaining({ id: 'asset-id' })])); + + expect(accessMock.album.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['album-id'])); + expect(assetMock.getTimeBucket).toHaveBeenCalledWith('bucket', { + size: TimeBucketSize.DAY, + timeBucket: 'bucket', + albumId: 'album-id', + }); + }); + + it('should return the assets for a archive time bucket if user has archive.read', async () => { + assetMock.getTimeBucket.mockResolvedValue([assetStub.image]); + + await expect( + sut.getTimeBucket(authStub.admin, { + size: TimeBucketSize.DAY, + timeBucket: 'bucket', + isArchived: true, + userId: authStub.admin.user.id, + }), + ).resolves.toEqual(expect.arrayContaining([expect.objectContaining({ id: 'asset-id' })])); + expect(assetMock.getTimeBucket).toHaveBeenCalledWith('bucket', { + size: TimeBucketSize.DAY, + timeBucket: 'bucket', + isArchived: true, + userIds: [authStub.admin.user.id], + }); + }); + + it('should return the assets for a library time bucket if user has library.read', async () => { + assetMock.getTimeBucket.mockResolvedValue([assetStub.image]); + + await expect( + sut.getTimeBucket(authStub.admin, { + size: TimeBucketSize.DAY, + timeBucket: 'bucket', + userId: authStub.admin.user.id, + }), + ).resolves.toEqual(expect.arrayContaining([expect.objectContaining({ id: 'asset-id' })])); + expect(assetMock.getTimeBucket).toHaveBeenCalledWith('bucket', { + size: TimeBucketSize.DAY, + timeBucket: 'bucket', + userIds: [authStub.admin.user.id], + }); + }); + + it('should throw an error if withParners is true and isArchived true or undefined', async () => { + await expect( + sut.getTimeBucket(authStub.admin, { + size: TimeBucketSize.DAY, + timeBucket: 'bucket', + isArchived: true, + withPartners: true, + userId: authStub.admin.user.id, + }), + ).rejects.toThrow(BadRequestException); + + await expect( + sut.getTimeBucket(authStub.admin, { + size: TimeBucketSize.DAY, + timeBucket: 'bucket', + isArchived: undefined, + withPartners: true, + userId: authStub.admin.user.id, + }), + ).rejects.toThrow(BadRequestException); + }); + + it('should throw an error if withParners is true and isFavorite is either true or false', async () => { + await expect( + sut.getTimeBucket(authStub.admin, { + size: TimeBucketSize.DAY, + timeBucket: 'bucket', + isFavorite: true, + withPartners: true, + userId: authStub.admin.user.id, + }), + ).rejects.toThrow(BadRequestException); + + await expect( + sut.getTimeBucket(authStub.admin, { + size: TimeBucketSize.DAY, + timeBucket: 'bucket', + isFavorite: false, + withPartners: true, + userId: authStub.admin.user.id, + }), + ).rejects.toThrow(BadRequestException); + }); + + it('should throw an error if withParners is true and isTrash is true', async () => { + await expect( + sut.getTimeBucket(authStub.admin, { + size: TimeBucketSize.DAY, + timeBucket: 'bucket', + isTrashed: true, + withPartners: true, + userId: authStub.admin.user.id, + }), + ).rejects.toThrow(BadRequestException); + }); + }); +}); diff --git a/server/src/services/timeline.service.ts b/server/src/services/timeline.service.ts new file mode 100644 index 000000000..95c4081e6 --- /dev/null +++ b/server/src/services/timeline.service.ts @@ -0,0 +1,86 @@ +import { BadRequestException, Inject } from '@nestjs/common'; +import { AccessCore, Permission } from 'src/cores/access.core'; +import { AssetResponseDto, SanitizedAssetResponseDto, mapAsset } from 'src/dtos/asset-response.dto'; +import { AuthDto } from 'src/dtos/auth.dto'; +import { TimeBucketAssetDto, TimeBucketDto, TimeBucketResponseDto } from 'src/dtos/time-bucket.dto'; +import { IAccessRepository } from 'src/interfaces/access.interface'; +import { IAssetRepository, TimeBucketOptions } from 'src/interfaces/asset.interface'; +import { IPartnerRepository } from 'src/interfaces/partner.interface'; + +export class TimelineService { + private accessCore: AccessCore; + + constructor( + @Inject(IAccessRepository) accessRepository: IAccessRepository, + @Inject(IAssetRepository) private repository: IAssetRepository, + @Inject(IPartnerRepository) private partnerRepository: IPartnerRepository, + ) { + this.accessCore = AccessCore.create(accessRepository); + } + + async getTimeBuckets(auth: AuthDto, dto: TimeBucketDto): Promise { + await this.timeBucketChecks(auth, dto); + const timeBucketOptions = await this.buildTimeBucketOptions(auth, dto); + + return this.repository.getTimeBuckets(timeBucketOptions); + } + + async getTimeBucket( + auth: AuthDto, + dto: TimeBucketAssetDto, + ): Promise { + await this.timeBucketChecks(auth, dto); + const timeBucketOptions = await this.buildTimeBucketOptions(auth, dto); + const assets = await this.repository.getTimeBucket(dto.timeBucket, timeBucketOptions); + return !auth.sharedLink || auth.sharedLink?.showExif + ? assets.map((asset) => mapAsset(asset, { withStack: true, auth })) + : assets.map((asset) => mapAsset(asset, { stripMetadata: true, auth })); + } + + private async buildTimeBucketOptions(auth: AuthDto, dto: TimeBucketDto): Promise { + const { userId, ...options } = dto; + let userIds: string[] | undefined = undefined; + + if (userId) { + userIds = [userId]; + + if (dto.withPartners) { + const partners = await this.partnerRepository.getAll(auth.user.id); + const partnersIds = partners + .filter((partner) => partner.sharedBy && partner.sharedWith && partner.inTimeline) + .map((partner) => partner.sharedById); + + userIds.push(...partnersIds); + } + } + + return { ...options, userIds }; + } + + private async timeBucketChecks(auth: AuthDto, dto: TimeBucketDto) { + if (dto.albumId) { + await this.accessCore.requirePermission(auth, Permission.ALBUM_READ, [dto.albumId]); + } else { + dto.userId = dto.userId || auth.user.id; + } + + if (dto.userId) { + await this.accessCore.requirePermission(auth, Permission.TIMELINE_READ, [dto.userId]); + if (dto.isArchived !== false) { + await this.accessCore.requirePermission(auth, Permission.ARCHIVE_READ, [dto.userId]); + } + } + + if (dto.withPartners) { + const requestedArchived = dto.isArchived === true || dto.isArchived === undefined; + const requestedFavorite = dto.isFavorite === true || dto.isFavorite === false; + const requestedTrash = dto.isTrashed === true; + + if (requestedArchived || requestedFavorite || requestedTrash) { + throw new BadRequestException( + 'withPartners is only supported for non-archived, non-trashed, non-favorited assets', + ); + } + } + } +} diff --git a/server/src/utils/asset.util.ts b/server/src/utils/asset.util.ts new file mode 100644 index 000000000..253073919 --- /dev/null +++ b/server/src/utils/asset.util.ts @@ -0,0 +1,91 @@ +import { AccessCore, Permission } from 'src/cores/access.core'; +import { BulkIdErrorReason, BulkIdResponseDto } from 'src/dtos/asset-ids.response.dto'; +import { AuthDto } from 'src/dtos/auth.dto'; +import { IAccessRepository } from 'src/interfaces/access.interface'; +import { setDifference, setUnion } from 'src/utils/set'; + +export interface IBulkAsset { + getAssetIds: (id: string, assetIds: string[]) => Promise>; + addAssetIds: (id: string, assetIds: string[]) => Promise; + removeAssetIds: (id: string, assetIds: string[]) => Promise; +} + +export const addAssets = async ( + auth: AuthDto, + repositories: { accessRepository: IAccessRepository; repository: IBulkAsset }, + dto: { id: string; assetIds: string[] }, +) => { + const { accessRepository, repository } = repositories; + const access = AccessCore.create(accessRepository); + + const existingAssetIds = await repository.getAssetIds(dto.id, dto.assetIds); + const notPresentAssetIds = dto.assetIds.filter((id) => !existingAssetIds.has(id)); + const allowedAssetIds = await access.checkAccess(auth, Permission.ASSET_SHARE, notPresentAssetIds); + + const results: BulkIdResponseDto[] = []; + for (const assetId of dto.assetIds) { + const hasAsset = existingAssetIds.has(assetId); + if (hasAsset) { + results.push({ id: assetId, success: false, error: BulkIdErrorReason.DUPLICATE }); + continue; + } + + const hasAccess = allowedAssetIds.has(assetId); + if (!hasAccess) { + results.push({ id: assetId, success: false, error: BulkIdErrorReason.NO_PERMISSION }); + continue; + } + + results.push({ id: assetId, success: true }); + } + + const newAssetIds = results.filter(({ success }) => success).map(({ id }) => id); + if (newAssetIds.length > 0) { + await repository.addAssetIds(dto.id, newAssetIds); + } + + return results; +}; + +export const removeAssets = async ( + auth: AuthDto, + repositories: { accessRepository: IAccessRepository; repository: IBulkAsset }, + dto: { id: string; assetIds: string[]; permissions: Permission[] }, +) => { + const { accessRepository, repository } = repositories; + const access = AccessCore.create(accessRepository); + + const existingAssetIds = await repository.getAssetIds(dto.id, dto.assetIds); + let allowedAssetIds = new Set(); + let remainingAssetIds = existingAssetIds; + + for (const permission of dto.permissions) { + const newAssetIds = await access.checkAccess(auth, permission, setDifference(remainingAssetIds, allowedAssetIds)); + remainingAssetIds = setDifference(remainingAssetIds, newAssetIds); + allowedAssetIds = setUnion(allowedAssetIds, newAssetIds); + } + + const results: BulkIdResponseDto[] = []; + for (const assetId of dto.assetIds) { + const hasAsset = existingAssetIds.has(assetId); + if (!hasAsset) { + results.push({ id: assetId, success: false, error: BulkIdErrorReason.NOT_FOUND }); + continue; + } + + const hasAccess = allowedAssetIds.has(assetId); + if (!hasAccess) { + results.push({ id: assetId, success: false, error: BulkIdErrorReason.NO_PERMISSION }); + continue; + } + + results.push({ id: assetId, success: true }); + } + + const removedIds = results.filter(({ success }) => success).map(({ id }) => id); + if (removedIds.length > 0) { + await repository.removeAssetIds(dto.id, removedIds); + } + + return results; +}; diff --git a/server/src/utils/sql.ts b/server/src/utils/sql.ts index 1afe4d5a8..662c40fcb 100644 --- a/server/src/utils/sql.ts +++ b/server/src/utils/sql.ts @@ -1,31 +1,21 @@ #!/usr/bin/env node import { INestApplication } from '@nestjs/common'; import { Reflector } from '@nestjs/core'; +import { EventEmitterModule } from '@nestjs/event-emitter'; +import { SchedulerRegistry } from '@nestjs/schedule'; import { Test } from '@nestjs/testing'; import { TypeOrmModule } from '@nestjs/typeorm'; +import { OpenTelemetryModule } from 'nestjs-otel'; import { mkdir, rm, writeFile } from 'node:fs/promises'; import { join } from 'node:path'; import { format } from 'sql-formatter'; import { databaseConfig } from 'src/database.config'; import { GENERATE_SQL_KEY, GenerateSqlQueries } from 'src/decorators'; -import { databaseEntities } from 'src/entities'; -import { ISystemConfigRepository } from 'src/interfaces/system-config.interface'; +import { entities } from 'src/entities'; +import { repositories } from 'src/repositories'; import { AccessRepository } from 'src/repositories/access.repository'; -import { AlbumRepository } from 'src/repositories/album.repository'; -import { ApiKeyRepository } from 'src/repositories/api-key.repository'; -import { AssetRepository } from 'src/repositories/asset.repository'; -import { AuditRepository } from 'src/repositories/audit.repository'; -import { LibraryRepository } from 'src/repositories/library.repository'; -import { MoveRepository } from 'src/repositories/move.repository'; -import { PartnerRepository } from 'src/repositories/partner.repository'; -import { PersonRepository } from 'src/repositories/person.repository'; -import { SearchRepository } from 'src/repositories/search.repository'; -import { SharedLinkRepository } from 'src/repositories/shared-link.repository'; -import { SystemConfigRepository } from 'src/repositories/system-config.repository'; -import { SystemMetadataRepository } from 'src/repositories/system-metadata.repository'; -import { TagRepository } from 'src/repositories/tag.repository'; -import { UserTokenRepository } from 'src/repositories/user-token.repository'; -import { UserRepository } from 'src/repositories/user.repository'; +import { AuthService } from 'src/services/auth.service'; +import { otelConfig } from 'src/utils/instrumentation'; import { Logger } from 'typeorm'; export class SqlLogger implements Logger { @@ -52,26 +42,9 @@ export class SqlLogger implements Logger { } const reflector = new Reflector(); -const repositories = [ - AccessRepository, - AlbumRepository, - ApiKeyRepository, - AssetRepository, - AuditRepository, - LibraryRepository, - MoveRepository, - PartnerRepository, - PersonRepository, - SharedLinkRepository, - SearchRepository, - SystemConfigRepository, - SystemMetadataRepository, - TagRepository, - UserTokenRepository, - UserRepository, -]; -type Repository = (typeof repositories)[0]; +type Repository = (typeof repositories)[0]['useClass']; +type Provider = { provide: any; useClass: Repository }; type SqlGeneratorOptions = { targetDir: string }; class SqlGenerator { @@ -84,8 +57,8 @@ class SqlGenerator { async run() { try { await this.setup(); - for (const Repository of repositories) { - await this.process(Repository); + for (const repository of repositories) { + await this.process(repository); } await this.write(); this.stats(); @@ -102,25 +75,27 @@ class SqlGenerator { imports: [ TypeOrmModule.forRoot({ ...databaseConfig, - entities: databaseEntities, + entities, logging: ['query'], logger: this.sqlLogger, }), - TypeOrmModule.forFeature(databaseEntities), + TypeOrmModule.forFeature(entities), + EventEmitterModule.forRoot(), + OpenTelemetryModule.forRoot(otelConfig), ], - providers: [{ provide: ISystemConfigRepository, useClass: SystemConfigRepository }, ...repositories], + providers: [...repositories, AuthService, SchedulerRegistry], }).compile(); this.app = await moduleFixture.createNestApplication().init(); } - async process(Repository: Repository) { + async process({ provide: token, useClass: Repository }: Provider) { if (!this.app) { throw new Error('Not initialized'); } const data: string[] = [`-- NOTE: This file is auto generated by ./sql-generator`]; - const instance = this.app.get(Repository); + const instance = this.app.get(token); // normal repositories data.push(...(await this.runTargets(instance, `${Repository.name}`))); @@ -180,6 +155,10 @@ class SqlGenerator { private async write() { for (const [repoName, data] of Object.entries(this.results)) { + // only contains the header + if (data.length === 1) { + continue; + } const filename = repoName.replaceAll(/[A-Z]/g, (letter) => `.${letter.toLowerCase()}`).replace('.', ''); const file = join(this.options.targetDir, `${filename}.sql`); await writeFile(file, data.join('\n\n') + '\n'); diff --git a/server/test/fixtures/asset.stub.ts b/server/test/fixtures/asset.stub.ts index e9b4650c2..fc94a363a 100644 --- a/server/test/fixtures/asset.stub.ts +++ b/server/test/fixtures/asset.stub.ts @@ -639,4 +639,82 @@ export const assetStub = { } as ExifEntity, deletedAt: null, }), + missingFileExtension: Object.freeze({ + id: 'asset-id', + deviceAssetId: 'device-asset-id', + fileModifiedAt: new Date('2023-02-23T05:06:29.716Z'), + fileCreatedAt: new Date('2023-02-23T05:06:29.716Z'), + owner: userStub.user1, + ownerId: 'user-id', + deviceId: 'device-id', + originalPath: '/data/user1/photo.jpg', + resizePath: '/uploads/user-id/thumbs/path.jpg', + checksum: Buffer.from('file hash', 'utf8'), + type: AssetType.IMAGE, + webpPath: '/uploads/user-id/webp/path.ext', + 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, + isReadOnly: false, + isExternal: true, + duration: null, + isVisible: true, + livePhotoVideo: null, + livePhotoVideoId: null, + isOffline: false, + libraryId: 'library-id', + library: libraryStub.externalLibrary1, + tags: [], + sharedLinks: [], + originalFileName: 'photo', + faces: [], + deletedAt: null, + sidecarPath: null, + exifInfo: { + fileSizeInByte: 5000, + } as ExifEntity, + }), + hasFileExtension: Object.freeze({ + id: 'asset-id', + deviceAssetId: 'device-asset-id', + fileModifiedAt: new Date('2023-02-23T05:06:29.716Z'), + fileCreatedAt: new Date('2023-02-23T05:06:29.716Z'), + owner: userStub.user1, + ownerId: 'user-id', + deviceId: 'device-id', + originalPath: '/data/user1/photo.jpg', + resizePath: '/uploads/user-id/thumbs/path.jpg', + checksum: Buffer.from('file hash', 'utf8'), + type: AssetType.IMAGE, + webpPath: '/uploads/user-id/webp/path.ext', + 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, + isReadOnly: false, + isExternal: true, + duration: null, + isVisible: true, + livePhotoVideo: null, + livePhotoVideoId: null, + isOffline: false, + libraryId: 'library-id', + library: libraryStub.externalLibrary1, + tags: [], + sharedLinks: [], + originalFileName: 'photo.jpg', + faces: [], + deletedAt: null, + sidecarPath: null, + exifInfo: { + fileSizeInByte: 5000, + } as ExifEntity, + }), }; diff --git a/server/test/repositories/album.repository.mock.ts b/server/test/repositories/album.repository.mock.ts index 626358d81..38db70e4b 100644 --- a/server/test/repositories/album.repository.mock.ts +++ b/server/test/repositories/album.repository.mock.ts @@ -14,9 +14,9 @@ export const newAlbumRepositoryMock = (): jest.Mocked => { softDeleteAll: jest.fn(), deleteAll: jest.fn(), getAll: jest.fn(), - addAssets: jest.fn(), + addAssetIds: jest.fn(), removeAsset: jest.fn(), - removeAssets: jest.fn(), + removeAssetIds: jest.fn(), getAssetIds: jest.fn(), hasAsset: jest.fn(), create: jest.fn(), diff --git a/web/package-lock.json b/web/package-lock.json index c6c74d2ff..5fb9aa66b 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -1,12 +1,12 @@ { "name": "immich-web", - "version": "1.99.0", + "version": "1.100.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "immich-web", - "version": "1.99.0", + "version": "1.100.0", "license": "GNU Affero General Public License version 3", "dependencies": { "@immich/sdk": "file:../open-api/typescript-sdk", @@ -63,14 +63,14 @@ }, "../open-api/typescript-sdk": { "name": "@immich/sdk", - "version": "1.99.0", + "version": "1.100.0", "license": "GNU Affero General Public License version 3", "dependencies": { "@oazapfts/runtime": "^1.0.2" }, "devDependencies": { - "@types/node": "^20.11.30", - "typescript": "^5.4.3" + "@types/node": "^20.11.0", + "typescript": "^5.3.3" } }, "node_modules/@aashutoshrathi/word-wrap": { @@ -8671,9 +8671,9 @@ } }, "node_modules/vite": { - "version": "5.2.2", - "resolved": "https://registry.npmjs.org/vite/-/vite-5.2.2.tgz", - "integrity": "sha512-FWZbz0oSdLq5snUI0b6sULbz58iXFXdvkZfZWR/F0ZJuKTSPO7v72QPXt6KqYeMFb0yytNp6kZosxJ96Nr/wDQ==", + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.2.3.tgz", + "integrity": "sha512-+i1oagbvkVIhEy9TnEV+fgXsng13nZM90JQbrcPrf6DvW2mXARlz+DK7DLiDP+qeKoD1FCVx/1SpFL1CLq9Mhw==", "dev": true, "dependencies": { "esbuild": "^0.20.1", diff --git a/web/package.json b/web/package.json index d151faaa9..c9e08c85f 100644 --- a/web/package.json +++ b/web/package.json @@ -1,6 +1,6 @@ { "name": "immich-web", - "version": "1.99.0", + "version": "1.100.0", "license": "GNU Affero General Public License version 3", "scripts": { "dev": "vite dev --host 0.0.0.0 --port 3000", diff --git a/web/src/lib/components/album-page/album-options.svelte b/web/src/lib/components/album-page/album-options.svelte index d5f816047..5118daf06 100644 --- a/web/src/lib/components/album-page/album-options.svelte +++ b/web/src/lib/components/album-page/album-options.svelte @@ -61,7 +61,7 @@

Options

- dispatch('close')} /> + dispatch('close')} />
diff --git a/web/src/lib/components/album-page/share-info-modal.svelte b/web/src/lib/components/album-page/share-info-modal.svelte index db227b03a..5f60d2b40 100644 --- a/web/src/lib/components/album-page/share-info-modal.svelte +++ b/web/src/lib/components/album-page/share-info-modal.svelte @@ -97,6 +97,7 @@ {#if isOwned}
showContextMenu(event, user)} icon={mdiDotsVertical} backgroundColor="transparent" diff --git a/web/src/lib/components/asset-viewer/activity-viewer.svelte b/web/src/lib/components/asset-viewer/activity-viewer.svelte index cf98c527f..0acfb88dc 100644 --- a/web/src/lib/components/asset-viewer/activity-viewer.svelte +++ b/web/src/lib/components/asset-viewer/activity-viewer.svelte @@ -305,7 +305,13 @@
{:else if message}
- +
{/if} diff --git a/web/src/lib/components/asset-viewer/album-list-item.svelte b/web/src/lib/components/asset-viewer/album-list-item.svelte index f2cb181e4..d97a40ae4 100644 --- a/web/src/lib/components/asset-viewer/album-list-item.svelte +++ b/web/src/lib/components/asset-viewer/album-list-item.svelte @@ -2,6 +2,7 @@ import { getAssetThumbnailUrl } from '$lib/utils'; import { ThumbnailFormat, type AlbumResponseDto } from '@immich/sdk'; import { createEventDispatcher } from 'svelte'; + import { normalizeSearchString } from '$lib/utils/string-utils.js'; const dispatch = createEventDispatcher<{ album: void; @@ -16,7 +17,7 @@ // It is used to highlight the search query in the album name $: { let { albumName } = album; - let findIndex = albumName.toLowerCase().indexOf(searchQuery.toLowerCase()); + let findIndex = normalizeSearchString(albumName).indexOf(normalizeSearchString(searchQuery)); let findLength = searchQuery.length; albumNameArray = [ albumName.slice(0, findIndex), 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 3a5105629..1772b8641 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 @@ -102,7 +102,7 @@ class="z-[1001] flex h-16 place-items-center justify-between bg-gradient-to-b from-black/40 px-3 transition-transform duration-200" >
- dispatch('back')} /> + dispatch('back')} />
{#if showShareButton} @@ -182,7 +182,12 @@ {#if !asset.isReadOnly || !asset.isExternal} dispatch('delete')} title="Delete" /> {/if} -
(isShowAssetOptions = false)}> +
(isShowAssetOptions = false), + onEscape: () => (isShowAssetOptions = false), + }} + > {#if isShowAssetOptions} diff --git a/web/src/lib/components/asset-viewer/asset-viewer.svelte b/web/src/lib/components/asset-viewer/asset-viewer.svelte index 51da30411..d81f00068 100644 --- a/web/src/lib/components/asset-viewer/asset-viewer.svelte +++ b/web/src/lib/components/asset-viewer/asset-viewer.svelte @@ -1,7 +1,6 @@ -
-
- dispatch('close')} /> -
- +
- -

Edit user

-
- -
-
- - +
+ dispatch('close')} />
-
- - +
+ +

Edit user

-
- - -

Note: Enter 0 for unlimited quota

-
+ +
+ + +
-
- - +
+ + +
-

- Note: To apply the Storage Label to previously uploaded assets, run the - - Storage Migration Job +

You set a quota higher than the disk size

+ {/if} -

-
+ +

Note: Enter 0 for unlimited quota

+
- {#if error} -

{error}

- {/if} +
+ + - {#if success} -

{success}

- {/if} -
- {#if canResetPassword} - +

+ Note: To apply the Storage Label to previously uploaded assets, run the + + Storage Migration Job +

+
+ + {#if error} +

{error}

{/if} - -
- -
+ + {#if success} +

{success}

+ {/if} +
+ {#if canResetPassword} + + {/if} + +
+ +
+ {#if isShowResetPasswordConfirmation} goto(AppRoute.PHOTOS)} forceDark>

- {currentMemory.title} + {memoryLaneTitle(currentMemory.yearsAgo)}

{#if canGoForward}
- (paused = !paused)} /> + (paused = !paused)} + /> {#each currentMemory.assets as _, index}
{/if} @@ -181,7 +186,7 @@ {#if previousMemory}

PREVIOUS

-

{previousMemory.title}

+

{memoryLaneTitle(previousMemory.yearsAgo)}

{/if} @@ -204,13 +209,23 @@ {#if canGoBack}
- +
{/if} {#if canGoForward}
- +
{/if} @@ -254,7 +269,7 @@ {#if nextMemory}

UP NEXT

-

{nextMemory.title}

+

{memoryLaneTitle(nextMemory.yearsAgo)}

{/if} @@ -271,7 +286,7 @@ class:opacity-100={!galleryInView} >
diff --git a/web/src/lib/components/photos-page/actions/add-to-album.svelte b/web/src/lib/components/photos-page/actions/add-to-album.svelte index 063d3bdb8..7068688ba 100644 --- a/web/src/lib/components/photos-page/actions/add-to-album.svelte +++ b/web/src/lib/components/photos-page/actions/add-to-album.svelte @@ -1,19 +1,14 @@ - dispatch('close')}> +

@@ -84,7 +85,7 @@

- New {shared ? 'Shared ' : ''}Album {#if search.length > 0}{search}{/if} + New Album {#if search.length > 0}{search}{/if}

{#if filteredAlbums.length > 0} diff --git a/web/src/lib/components/shared-components/base-modal.svelte b/web/src/lib/components/shared-components/base-modal.svelte index f7dd30980..c5bf5380a 100644 --- a/web/src/lib/components/shared-components/base-modal.svelte +++ b/web/src/lib/components/shared-components/base-modal.svelte @@ -6,13 +6,13 @@ import CircleIconButton from '../elements/buttons/circle-icon-button.svelte'; import { clickOutside } from '$lib/utils/click-outside'; import { mdiClose } from '@mdi/js'; + import FocusTrap from '$lib/components/shared-components/focus-trap.svelte'; const dispatch = createEventDispatcher<{ escape: void; close: void; }>(); export let zIndex = 9999; - export let ignoreClickOutside = false; onMount(() => { if (browser) { @@ -34,36 +34,40 @@ }); -
+
!ignoreClickOutside && dispatch('close')} - on:escape={() => dispatch('escape')} - class="max-h-[800px] min-h-[200px] w-[450px] overflow-y-auto rounded-lg bg-immich-bg shadow-md dark:bg-immich-dark-gray dark:text-immich-dark-fg immich-scrollbar" + id="immich-modal" + style:z-index={zIndex} + transition:fade={{ duration: 100, easing: quintOut }} + class="fixed left-0 top-0 flex h-full w-full place-content-center place-items-center overflow-hidden bg-black/50" > -
+
dispatch('close'), + onEscape: () => dispatch('escape'), + }} + class="max-h-[800px] min-h-[200px] w-[450px] overflow-y-auto rounded-lg bg-immich-bg shadow-md dark:bg-immich-dark-gray dark:text-immich-dark-fg immich-scrollbar" + tabindex="-1" + > +
+
+ +

Modal Title

+
+
+ + dispatch('close')} icon={mdiClose} size={'20'} title="Close" /> +
+
- -

Modal Title

-
+
- dispatch('close')} icon={mdiClose} size={'20'} /> + {#if $$slots['sticky-bottom']} +
+ +
+ {/if}
- -
- -
- - {#if $$slots['sticky-bottom']} -
- -
- {/if}
-
+
diff --git a/web/src/lib/components/shared-components/combobox.svelte b/web/src/lib/components/shared-components/combobox.svelte index 28a2b7253..0e8469fd2 100644 --- a/web/src/lib/components/shared-components/combobox.svelte +++ b/web/src/lib/components/shared-components/combobox.svelte @@ -185,7 +185,8 @@ }, { shortcut: { key: 'Escape' }, - onShortcut: () => { + onShortcut: (event) => { + event.stopPropagation(); closeDropdown(); }, }, diff --git a/web/src/lib/components/shared-components/context-menu/menu-option.svelte b/web/src/lib/components/shared-components/context-menu/menu-option.svelte index 6d5adc419..28c29c74a 100644 --- a/web/src/lib/components/shared-components/context-menu/menu-option.svelte +++ b/web/src/lib/components/shared-components/context-menu/menu-option.svelte @@ -14,7 +14,7 @@ {#if text} {#if icon}

- + {text}

{:else} diff --git a/web/src/lib/components/shared-components/control-app-bar.svelte b/web/src/lib/components/shared-components/control-app-bar.svelte index 095b12d27..10f1965a7 100644 --- a/web/src/lib/components/shared-components/control-app-bar.svelte +++ b/web/src/lib/components/shared-components/control-app-bar.svelte @@ -58,6 +58,7 @@
{#if showBackButton} + import { shortcuts } from '$lib/utils/shortcut'; + import { onMount, onDestroy } from 'svelte'; + + let container: HTMLElement; + let triggerElement: HTMLElement; + + onMount(() => { + triggerElement = document.activeElement as HTMLElement; + const focusableElements = getFocusableElements(); + focusableElements[0]?.focus(); + }); + + onDestroy(() => { + triggerElement?.focus(); + }); + + const getFocusableElements = () => { + return Array.from( + container.querySelectorAll('button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'), + ) as HTMLElement[]; + }; + + const trapFocus = (direction: 'forward' | 'backward', event: KeyboardEvent) => { + const focusableElements = getFocusableElements(); + const elementCount = focusableElements.length; + const firstElement = focusableElements[0]; + const lastElement = focusableElements.at(elementCount - 1); + + if (document.activeElement === lastElement && direction === 'forward') { + event.preventDefault(); + firstElement?.focus(); + } else if (document.activeElement === firstElement && direction === 'backward') { + event.preventDefault(); + lastElement?.focus(); + } + }; + + +
{ + trapFocus('forward', event); + }, + preventDefault: false, + }, + { + ignoreInputFields: false, + shortcut: { key: 'Tab', shift: true }, + onShortcut: (event) => { + trapFocus('backward', event); + }, + preventDefault: false, + }, + ]} +> + +
diff --git a/web/src/lib/components/shared-components/full-screen-modal.svelte b/web/src/lib/components/shared-components/full-screen-modal.svelte index 3ca3717ef..d7d700975 100644 --- a/web/src/lib/components/shared-components/full-screen-modal.svelte +++ b/web/src/lib/components/shared-components/full-screen-modal.svelte @@ -1,16 +1,19 @@ -
-
- -
-
+ +
+
+ +
+
+
diff --git a/web/src/lib/components/shared-components/map/map.svelte b/web/src/lib/components/shared-components/map/map.svelte index 74643e502..b42cf6649 100644 --- a/web/src/lib/components/shared-components/map/map.svelte +++ b/web/src/lib/components/shared-components/map/map.svelte @@ -2,7 +2,7 @@ import Icon from '$lib/components/elements/icon.svelte'; import { Theme } from '$lib/constants'; import { colorTheme, mapSettings } from '$lib/stores/preferences.store'; - import { getAssetThumbnailUrl, handlePromiseError } from '$lib/utils'; + import { getAssetThumbnailUrl, getKey, handlePromiseError } from '$lib/utils'; import { getMapStyle, MapTheme, type MapMarkerResponseDto } from '@immich/sdk'; import { mdiCog, mdiMapMarker } from '@mdi/js'; import type { Feature, GeoJsonProperties, Geometry, Point } from 'geojson'; @@ -50,6 +50,7 @@ $: style = (() => getMapStyle({ theme: ($mapSettings.allowDarkMode ? $colorTheme.value : Theme.LIGHT) as unknown as MapTheme, + key: getKey(), }) as Promise)(); const dispatch = createEventDispatcher<{ diff --git a/web/src/lib/components/shared-components/navigation-bar/account-info-panel.svelte b/web/src/lib/components/shared-components/navigation-bar/account-info-panel.svelte index 4087f37bd..d97b939b0 100644 --- a/web/src/lib/components/shared-components/navigation-bar/account-info-panel.svelte +++ b/web/src/lib/components/shared-components/navigation-bar/account-info-panel.svelte @@ -11,6 +11,7 @@ import { notificationController, NotificationType } from '../notification/notification'; import UserAvatar from '../user-avatar.svelte'; import AvatarSelector from './avatar-selector.svelte'; + import FocusTrap from '$lib/components/shared-components/focus-trap.svelte'; let isShowSelectAvatar = false; @@ -46,19 +47,20 @@ }; -
+
-
- {#key $user} - - +
+
+ {#key $user} + + {/key}
@@ -69,35 +71,35 @@
- {/key} -
-
-

- {$user.name} -

-

{$user.email}

+
+
+

+ {$user.name} +

+

{$user.email}

+
+ + dispatch('close')}> + +
- dispatch('close')}> - - +
+ +
- -
- -
-
+
{#if isShowSelectAvatar}
- dispatch('close')} /> + dispatch('close')} />
{#each colors as color} {/each}
diff --git a/web/src/lib/components/shared-components/notification/notification-card.svelte b/web/src/lib/components/shared-components/notification/notification-card.svelte index a81146692..ac93f9a3b 100644 --- a/web/src/lib/components/shared-components/notification/notification-card.svelte +++ b/web/src/lib/components/shared-components/notification/notification-card.svelte @@ -12,6 +12,7 @@ export let notification: Notification; $: icon = notification.type === NotificationType.Error ? mdiCloseCircleOutline : mdiInformationOutline; + $: hoverStyle = notification.action.type === 'discard' ? 'hover:cursor-pointer' : ''; const backgroundColor: Record = { [NotificationType.Info]: '#E0E2F0', @@ -31,6 +32,12 @@ [NotificationType.Warning]: '#D08613', }; + const buttonStyle: Record = { + [NotificationType.Info]: 'text-white bg-immich-primary hover:bg-immich-primary/75', + [NotificationType.Error]: 'text-white bg-immich-error hover:bg-immich-error/75', + [NotificationType.Warning]: 'text-white bg-immich-warning hover:bg-immich-warning/75', + }; + onMount(() => { const timeoutId = setTimeout(discard, notification.timeout); return () => clearTimeout(timeoutId); @@ -41,11 +48,16 @@ }; const handleClick = () => { - const action = notification.action; - if (action.type === 'discard') { + if (notification.action.type === 'discard') { discard(); - } else if (action.type == 'link') { - window.open(action.target); + } + }; + + const handleButtonClick = () => { + const button = notification.button; + if (button) { + discard(); + return notification.button?.onClick(); } }; @@ -55,7 +67,7 @@ transition:fade={{ duration: 250 }} style:background-color={backgroundColor[notification.type]} style:border-color={borderColor[notification.type]} - class="border z-[999999] mb-4 min-h-[80px] w-[300px] rounded-2xl p-4 shadow-md hover:cursor-pointer" + class="border z-[999999] mb-4 min-h-[80px] w-[300px] rounded-2xl p-4 shadow-md {hoverStyle}" on:click={handleClick} on:keydown={handleClick} > @@ -72,6 +84,22 @@

- {notification.message} + {#if notification.html} + + {@html notification.message} + {:else} + {notification.message} + {/if}

+ + {#if notification.button} +

+ +

+ {/if}
diff --git a/web/src/lib/components/shared-components/notification/notification.ts b/web/src/lib/components/shared-components/notification/notification.ts index 52e75bf41..dfe4e1f92 100644 --- a/web/src/lib/components/shared-components/notification/notification.ts +++ b/web/src/lib/components/shared-components/notification/notification.ts @@ -6,20 +6,31 @@ export enum NotificationType { Warning = 'Warning', } +export type NotificationButton = { + text: string; + onClick: () => unknown; +}; + export type Notification = { id: number; type: NotificationType; message: string; + /** + * Allow HTML to be inserted within the message. Make sure to verify/encode + * variables that may be interpoalted into 'message' + */ + html?: boolean; /** The action to take when the notification is clicked */ action: NotificationAction; + button?: NotificationButton; /** Timeout in miliseconds */ timeout: number; }; type DiscardAction = { type: 'discard' }; type NoopAction = { type: 'noop' }; -type LinkAction = { type: 'link'; target: string }; -export type NotificationAction = DiscardAction | NoopAction | LinkAction; + +export type NotificationAction = DiscardAction | NoopAction; export type NotificationOptions = Partial> & { message: string }; @@ -32,7 +43,9 @@ function createNotificationList() { currentList.push({ id: count++, type: NotificationType.Info, - action: { type: 'discard' }, + action: { + type: options.button ? 'noop' : 'discard', + }, timeout: 3000, ...options, }); diff --git a/web/src/lib/components/shared-components/profile-image-cropper.svelte b/web/src/lib/components/shared-components/profile-image-cropper.svelte index d4d60e836..36cb6152f 100644 --- a/web/src/lib/components/shared-components/profile-image-cropper.svelte +++ b/web/src/lib/components/shared-components/profile-image-cropper.svelte @@ -71,7 +71,7 @@ }; - +

Set profile picture

diff --git a/web/src/lib/components/shared-components/search-bar/search-people-section.svelte b/web/src/lib/components/shared-components/search-bar/search-people-section.svelte index 4b87ab12d..a8f000845 100644 --- a/web/src/lib/components/shared-components/search-bar/search-people-section.svelte +++ b/web/src/lib/components/shared-components/search-bar/search-people-section.svelte @@ -1,6 +1,7 @@ {#await peoplePromise then people} {#if people && people.length > 0} - {@const peopleList = showAllPeople ? people : people.slice(0, numberOfPeople)} + {@const peopleList = showAllPeople ? filterPeople(people, name) : people.slice(0, numberOfPeople)}
-
-

PEOPLE

+
+

PEOPLE

+ + {#if showAllPeople} + + {/if}
diff --git a/web/src/lib/components/shared-components/show-shortcuts.svelte b/web/src/lib/components/shared-components/show-shortcuts.svelte index 7a660de1b..cfa3768bb 100644 --- a/web/src/lib/components/shared-components/show-shortcuts.svelte +++ b/web/src/lib/components/shared-components/show-shortcuts.svelte @@ -44,7 +44,7 @@

Keyboard Shortcuts

- dispatch('close')} /> + dispatch('close')} />
diff --git a/web/src/lib/components/shared-components/user-avatar.svelte b/web/src/lib/components/shared-components/user-avatar.svelte index e07a55f86..fac7890d7 100644 --- a/web/src/lib/components/shared-components/user-avatar.svelte +++ b/web/src/lib/components/shared-components/user-avatar.svelte @@ -22,6 +22,7 @@ export let interactive = false; export let showTitle = true; export let showProfileImage = true; + export let label: string | undefined = undefined; let img: HTMLImageElement; let showFallback = true; @@ -61,7 +62,7 @@ $: colorClass = colorClasses[color]; $: sizeClass = sizeClasses[size]; - $: title = `${user.name} (${user.email})`; + $: title = label ?? `${user.name} (${user.email})`; $: interactiveClass = interactive ? 'border-2 border-immich-primary hover:border-immich-dark-primary dark:hover:border-immich-primary dark:border-immich-dark-primary transition-colors' : ''; diff --git a/web/src/lib/components/sharedlinks-page/shared-link-card.svelte b/web/src/lib/components/sharedlinks-page/shared-link-card.svelte index 993a052bf..e71e62e40 100644 --- a/web/src/lib/components/sharedlinks-page/shared-link-card.svelte +++ b/web/src/lib/components/sharedlinks-page/shared-link-card.svelte @@ -168,9 +168,9 @@
- dispatch('delete')} /> - dispatch('edit')} /> - dispatch('copy')} /> + dispatch('delete')} /> + dispatch('edit')} /> + dispatch('copy')} />
diff --git a/web/src/lib/utils.ts b/web/src/lib/utils.ts index 8f224e93b..299f04505 100644 --- a/web/src/lib/utils.ts +++ b/web/src/lib/utils.ts @@ -277,3 +277,5 @@ export const asyncTimeout = (ms: number) => { export const handlePromiseError = (promise: Promise): void => { promise.catch((error) => console.error(`[utils.ts]:handlePromiseError ${error}`, error)); }; + +export const memoryLaneTitle = (yearsAgo: number) => `${yearsAgo} ${yearsAgo ? 'years' : 'year'} since...`; diff --git a/web/src/lib/utils/asset-utils.ts b/web/src/lib/utils/asset-utils.ts index 27c99a473..532479912 100644 --- a/web/src/lib/utils/asset-utils.ts +++ b/web/src/lib/utils/asset-utils.ts @@ -1,15 +1,18 @@ -import { notificationController, NotificationType } from '$lib/components/shared-components/notification/notification'; +import { goto } from '$app/navigation'; +import { NotificationType, notificationController } from '$lib/components/shared-components/notification/notification'; +import { AppRoute } from '$lib/constants'; import type { AssetInteractionStore } from '$lib/stores/asset-interaction.store'; import { BucketPosition, isSelectingAllAssets, type AssetStore } from '$lib/stores/assets.store'; import { downloadManager } from '$lib/stores/download'; import { downloadRequest, getKey } from '$lib/utils'; +import { encodeHTMLSpecialChars } from '$lib/utils/string-utils'; import { addAssetsToAlbum as addAssets, + createAlbum, defaults, getDownloadInfo, type AssetResponseDto, type AssetTypeEnum, - type BulkIdResponseDto, type DownloadInfoDto, type DownloadResponseDto, type UserResponseDto, @@ -18,20 +21,60 @@ import { DateTime } from 'luxon'; import { get } from 'svelte/store'; import { handleError } from './handle-error'; -export const addAssetsToAlbum = async (albumId: string, assetIds: Array): Promise => - addAssets({ +export const addAssetsToAlbum = async (albumId: string, assetIds: string[]) => { + const result = await addAssets({ id: albumId, - bulkIdsDto: { ids: assetIds }, + bulkIdsDto: { + ids: assetIds, + }, key: getKey(), - }).then((results) => { - const count = results.filter(({ success }) => success).length; + }); + const count = result.filter(({ success }) => success).length; + notificationController.show({ + type: NotificationType.Info, + timeout: 5000, + message: + count > 0 + ? `Added ${count} asset${count === 1 ? '' : 's'} to the album` + : `Asset${assetIds.length === 1 ? ' was' : 's were'} already part of the album`, + button: { + text: 'View Album', + onClick() { + return goto(`${AppRoute.ALBUMS}/${albumId}`); + }, + }, + }); +}; + +export const addAssetsToNewAlbum = async (albumName: string, assetIds: string[]) => { + try { + const album = await createAlbum({ + createAlbumDto: { + albumName, + assetIds, + }, + }); + const displayName = albumName ? `${encodeHTMLSpecialChars(albumName)}` : 'new album'; notificationController.show({ type: NotificationType.Info, - message: `Added ${count} asset${count === 1 ? '' : 's'}`, + timeout: 5000, + message: `Added ${assetIds.length} asset${assetIds.length === 1 ? '' : 's'} to ${displayName}`, + html: true, + button: { + text: 'View Album', + onClick() { + return goto(`${AppRoute.ALBUMS}/${album.id}`); + }, + }, }); - - return results; - }); + return album; + } catch { + notificationController.show({ + type: NotificationType.Error, + message: 'Failed to create album', + }); + } +}; export const downloadBlob = (data: Blob, filename: string) => { const url = URL.createObjectURL(data); diff --git a/web/src/lib/utils/click-outside.ts b/web/src/lib/utils/click-outside.ts index 599146410..e04bc11fd 100644 --- a/web/src/lib/utils/click-outside.ts +++ b/web/src/lib/utils/click-outside.ts @@ -43,12 +43,12 @@ export function clickOutside(node: HTMLElement, options: Options = {}): ActionRe }; document.addEventListener('click', handleClick, true); - document.addEventListener('keydown', handleKey, true); + node.addEventListener('keydown', handleKey, false); return { destroy() { document.removeEventListener('click', handleClick, true); - document.removeEventListener('keydown', handleKey, true); + node.removeEventListener('keydown', handleKey, false); }, }; } diff --git a/web/src/lib/utils/shortcut.ts b/web/src/lib/utils/shortcut.ts index 3d80c2c80..f8ca467d1 100644 --- a/web/src/lib/utils/shortcut.ts +++ b/web/src/lib/utils/shortcut.ts @@ -12,6 +12,7 @@ export type ShortcutOptions = { shortcut: Shortcut; ignoreInputFields?: boolean; onShortcut: (event: KeyboardEvent & { currentTarget: T }) => unknown; + preventDefault?: boolean; }; export const shouldIgnoreShortcut = (event: KeyboardEvent): boolean => { @@ -53,13 +54,15 @@ export const shortcuts = ( function onKeydown(event: KeyboardEvent) { const ignoreShortcut = shouldIgnoreShortcut(event); - for (const { shortcut, onShortcut, ignoreInputFields = true } of options) { + for (const { shortcut, onShortcut, ignoreInputFields = true, preventDefault = true } of options) { if (ignoreInputFields && ignoreShortcut) { continue; } if (matchesShortcut(event, shortcut)) { - event.preventDefault(); + if (preventDefault) { + event.preventDefault(); + } onShortcut(event as KeyboardEvent & { currentTarget: T }); return; } diff --git a/web/src/lib/utils/string-utils.ts b/web/src/lib/utils/string-utils.ts new file mode 100644 index 000000000..b58f859f6 --- /dev/null +++ b/web/src/lib/utils/string-utils.ts @@ -0,0 +1,16 @@ +export const removeAccents = (str: string) => { + return str.normalize('NFD').replaceAll(/[\u0300-\u036F]/g, ''); +}; + +export const normalizeSearchString = (str: string) => { + return removeAccents(str.toLocaleLowerCase()); +}; + +export const encodeHTMLSpecialChars = (str: string) => { + return str + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll("'", '''); +}; diff --git a/web/src/routes/(user)/albums/[albumId]/+page.svelte b/web/src/routes/(user)/albums/[albumId]/+page.svelte index 23e6a1b2e..d4b665bc0 100644 --- a/web/src/routes/(user)/albums/[albumId]/+page.svelte +++ b/web/src/routes/(user)/albums/[albumId]/+page.svelte @@ -545,6 +545,7 @@ {#if album.hasSharedLink && isOwned} copyToClipboard(orphan.pathValue)}> - + {orphan.pathValue} @@ -306,7 +306,7 @@ title={extra.filename} > copyToClipboard(extra.filename)}> - +