From dab4870fed64538684958903c37a92b2771a7647 Mon Sep 17 00:00:00 2001 From: Min Idzelis Date: Wed, 23 Apr 2025 23:30:13 -0400 Subject: [PATCH 01/31] fix: flappy e2e test (#17832) * fix: flappy e2e test * lint --- e2e/src/api/specs/oauth.e2e-spec.ts | 2 +- e2e/src/web/specs/shared-link.e2e-spec.ts | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/e2e/src/api/specs/oauth.e2e-spec.ts b/e2e/src/api/specs/oauth.e2e-spec.ts index 3b1e75d3e5..9e4d64892e 100644 --- a/e2e/src/api/specs/oauth.e2e-spec.ts +++ b/e2e/src/api/specs/oauth.e2e-spec.ts @@ -142,7 +142,7 @@ describe(`/oauth`, () => { it(`should throw an error if the state mismatches`, async () => { const callbackParams = await loginWithOAuth('oauth-auto-register'); const { state } = await loginWithOAuth('oauth-auto-register'); - const { status, body } = await request(app) + const { status } = await request(app) .post('/oauth/callback') .send({ ...callbackParams, state }); expect(status).toBeGreaterThanOrEqual(400); diff --git a/e2e/src/web/specs/shared-link.e2e-spec.ts b/e2e/src/web/specs/shared-link.e2e-spec.ts index 562a0b4e8c..aeddb86322 100644 --- a/e2e/src/web/specs/shared-link.e2e-spec.ts +++ b/e2e/src/web/specs/shared-link.e2e-spec.ts @@ -55,7 +55,6 @@ test.describe('Shared Links', () => { await page.goto(`/share/${sharedLink.key}`); await page.getByRole('heading', { name: 'Test Album' }).waitFor(); await page.getByRole('button', { name: 'Download' }).click(); - await page.getByText('DOWNLOADING', { exact: true }).waitFor(); await page.waitForEvent('download'); }); From 1d610ad9cb45e02929d9ddc850c0184b510ecaf4 Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Thu, 24 Apr 2025 12:58:29 -0400 Subject: [PATCH 02/31] refactor: database connection parsing (#17852) --- server/src/app.module.ts | 2 +- server/src/bin/migrations.ts | 6 +- server/src/bin/sync-sql.ts | 2 +- .../repositories/config.repository.spec.ts | 106 ++---------------- server/src/repositories/config.repository.ts | 61 +++------- .../src/repositories/database.repository.ts | 25 ++++- server/src/services/backup.service.ts | 2 +- server/src/services/database.service.spec.ts | 66 +++-------- server/src/utils/database.spec.ts | 83 ++++++++++++++ server/src/utils/database.ts | 62 +++++++--- server/test/medium/globalSetup.ts | 12 +- .../repositories/config.repository.mock.ts | 19 +--- server/test/utils.ts | 28 ++--- 13 files changed, 217 insertions(+), 257 deletions(-) create mode 100644 server/src/utils/database.spec.ts diff --git a/server/src/app.module.ts b/server/src/app.module.ts index 05dbc090fc..153b525fe5 100644 --- a/server/src/app.module.ts +++ b/server/src/app.module.ts @@ -44,7 +44,7 @@ const imports = [ BullModule.registerQueue(...bull.queues), ClsModule.forRoot(cls.config), OpenTelemetryModule.forRoot(otel), - KyselyModule.forRoot(getKyselyConfig(database.config.kysely)), + KyselyModule.forRoot(getKyselyConfig(database.config)), ]; class BaseModule implements OnModuleInit, OnModuleDestroy { diff --git a/server/src/bin/migrations.ts b/server/src/bin/migrations.ts index 2ddc6776fb..7b850f6166 100644 --- a/server/src/bin/migrations.ts +++ b/server/src/bin/migrations.ts @@ -10,7 +10,7 @@ import { DatabaseRepository } from 'src/repositories/database.repository'; import { LoggingRepository } from 'src/repositories/logging.repository'; import 'src/schema'; import { schemaDiff, schemaFromCode, schemaFromDatabase } from 'src/sql-tools'; -import { getKyselyConfig } from 'src/utils/database'; +import { asPostgresConnectionConfig, getKyselyConfig } from 'src/utils/database'; const main = async () => { const command = process.argv[2]; @@ -56,7 +56,7 @@ const main = async () => { const getDatabaseClient = () => { const configRepository = new ConfigRepository(); const { database } = configRepository.getEnv(); - return new Kysely(getKyselyConfig(database.config.kysely)); + return new Kysely(getKyselyConfig(database.config)); }; const runQuery = async (query: string) => { @@ -105,7 +105,7 @@ const create = (path: string, up: string[], down: string[]) => { const compare = async () => { const configRepository = new ConfigRepository(); const { database } = configRepository.getEnv(); - const db = postgres(database.config.kysely); + const db = postgres(asPostgresConnectionConfig(database.config)); const source = schemaFromCode(); const target = await schemaFromDatabase(db, {}); diff --git a/server/src/bin/sync-sql.ts b/server/src/bin/sync-sql.ts index 47e6610a74..b791358a90 100644 --- a/server/src/bin/sync-sql.ts +++ b/server/src/bin/sync-sql.ts @@ -78,7 +78,7 @@ class SqlGenerator { const moduleFixture = await Test.createTestingModule({ imports: [ KyselyModule.forRoot({ - ...getKyselyConfig(database.config.kysely), + ...getKyselyConfig(database.config), log: (event) => { if (event.level === 'query') { this.sqlLogger.logQuery(event.query.sql); diff --git a/server/src/repositories/config.repository.spec.ts b/server/src/repositories/config.repository.spec.ts index 888d5c33ec..9e9ed71191 100644 --- a/server/src/repositories/config.repository.spec.ts +++ b/server/src/repositories/config.repository.spec.ts @@ -80,21 +80,12 @@ describe('getEnv', () => { const { database } = getEnv(); expect(database).toEqual({ config: { - kysely: expect.objectContaining({ - host: 'database', - port: 5432, - database: 'immich', - username: 'postgres', - password: 'postgres', - }), - typeorm: expect.objectContaining({ - type: 'postgres', - host: 'database', - port: 5432, - database: 'immich', - username: 'postgres', - password: 'postgres', - }), + connectionType: 'parts', + host: 'database', + port: 5432, + database: 'immich', + username: 'postgres', + password: 'postgres', }, skipMigrations: false, vectorExtension: 'vectors', @@ -110,88 +101,9 @@ describe('getEnv', () => { it('should use DB_URL', () => { process.env.DB_URL = 'postgres://postgres1:postgres2@database1:54320/immich'; const { database } = getEnv(); - expect(database.config.kysely).toMatchObject({ - host: 'database1', - password: 'postgres2', - user: 'postgres1', - port: 54_320, - database: 'immich', - }); - }); - - it('should handle sslmode=require', () => { - process.env.DB_URL = 'postgres://postgres1:postgres2@database1:54320/immich?sslmode=require'; - - const { database } = getEnv(); - - expect(database.config.kysely).toMatchObject({ ssl: {} }); - }); - - it('should handle sslmode=prefer', () => { - process.env.DB_URL = 'postgres://postgres1:postgres2@database1:54320/immich?sslmode=prefer'; - - const { database } = getEnv(); - - expect(database.config.kysely).toMatchObject({ ssl: {} }); - }); - - it('should handle sslmode=verify-ca', () => { - process.env.DB_URL = 'postgres://postgres1:postgres2@database1:54320/immich?sslmode=verify-ca'; - - const { database } = getEnv(); - - expect(database.config.kysely).toMatchObject({ ssl: {} }); - }); - - it('should handle sslmode=verify-full', () => { - process.env.DB_URL = 'postgres://postgres1:postgres2@database1:54320/immich?sslmode=verify-full'; - - const { database } = getEnv(); - - expect(database.config.kysely).toMatchObject({ ssl: {} }); - }); - - it('should handle sslmode=no-verify', () => { - process.env.DB_URL = 'postgres://postgres1:postgres2@database1:54320/immich?sslmode=no-verify'; - - const { database } = getEnv(); - - expect(database.config.kysely).toMatchObject({ ssl: { rejectUnauthorized: false } }); - }); - - it('should handle ssl=true', () => { - process.env.DB_URL = 'postgres://postgres1:postgres2@database1:54320/immich?ssl=true'; - - const { database } = getEnv(); - - expect(database.config.kysely).toMatchObject({ ssl: true }); - }); - - it('should reject invalid ssl', () => { - process.env.DB_URL = 'postgres://postgres1:postgres2@database1:54320/immich?ssl=invalid'; - - expect(() => getEnv()).toThrowError('Invalid ssl option: invalid'); - }); - - it('should handle socket: URLs', () => { - process.env.DB_URL = 'socket:/run/postgresql?db=database1'; - - const { database } = getEnv(); - - expect(database.config.kysely).toMatchObject({ - host: '/run/postgresql', - database: 'database1', - }); - }); - - it('should handle sockets in postgres: URLs', () => { - process.env.DB_URL = 'postgres:///database2?host=/path/to/socket'; - - const { database } = getEnv(); - - expect(database.config.kysely).toMatchObject({ - host: '/path/to/socket', - database: 'database2', + expect(database.config).toMatchObject({ + connectionType: 'url', + url: 'postgres://postgres1:postgres2@database1:54320/immich', }); }); }); diff --git a/server/src/repositories/config.repository.ts b/server/src/repositories/config.repository.ts index f689641d4f..9b88a78e6b 100644 --- a/server/src/repositories/config.repository.ts +++ b/server/src/repositories/config.repository.ts @@ -7,8 +7,7 @@ import { Request, Response } from 'express'; import { RedisOptions } from 'ioredis'; import { CLS_ID, ClsModuleOptions } from 'nestjs-cls'; import { OpenTelemetryModuleOptions } from 'nestjs-otel/lib/interfaces'; -import { join, resolve } from 'node:path'; -import { parse } from 'pg-connection-string'; +import { join } from 'node:path'; import { citiesFile, excludePaths, IWorker } from 'src/constants'; import { Telemetry } from 'src/decorators'; import { EnvDto } from 'src/dtos/env.dto'; @@ -22,9 +21,7 @@ import { QueueName, } from 'src/enum'; import { DatabaseConnectionParams, VectorExtension } from 'src/types'; -import { isValidSsl, PostgresConnectionConfig } from 'src/utils/database'; import { setDifference } from 'src/utils/set'; -import { PostgresConnectionOptions } from 'typeorm/driver/postgres/PostgresConnectionOptions.js'; export interface EnvData { host?: string; @@ -59,7 +56,7 @@ export interface EnvData { }; database: { - config: { typeorm: PostgresConnectionOptions & DatabaseConnectionParams; kysely: PostgresConnectionConfig }; + config: DatabaseConnectionParams; skipMigrations: boolean; vectorExtension: VectorExtension; }; @@ -152,14 +149,10 @@ const getEnv = (): EnvData => { const isProd = environment === ImmichEnvironment.PRODUCTION; const buildFolder = dto.IMMICH_BUILD_DATA || '/build'; const folders = { - // eslint-disable-next-line unicorn/prefer-module - dist: resolve(`${__dirname}/..`), geodata: join(buildFolder, 'geodata'), web: join(buildFolder, 'www'), }; - const databaseUrl = dto.DB_URL; - let redisConfig = { host: dto.REDIS_HOSTNAME || 'redis', port: dto.REDIS_PORT || 6379, @@ -191,30 +184,16 @@ const getEnv = (): EnvData => { } } - const parts = { - connectionType: 'parts', - host: dto.DB_HOSTNAME || 'database', - port: dto.DB_PORT || 5432, - username: dto.DB_USERNAME || 'postgres', - password: dto.DB_PASSWORD || 'postgres', - database: dto.DB_DATABASE_NAME || 'immich', - } as const; - - let parsedOptions: PostgresConnectionConfig = parts; - if (dto.DB_URL) { - const parsed = parse(dto.DB_URL); - if (!isValidSsl(parsed.ssl)) { - throw new Error(`Invalid ssl option: ${parsed.ssl}`); - } - - parsedOptions = { - ...parsed, - ssl: parsed.ssl, - host: parsed.host ?? undefined, - port: parsed.port ? Number(parsed.port) : undefined, - database: parsed.database ?? undefined, - }; - } + const databaseConnection: DatabaseConnectionParams = dto.DB_URL + ? { connectionType: 'url', url: dto.DB_URL } + : { + connectionType: 'parts', + host: dto.DB_HOSTNAME || 'database', + port: dto.DB_PORT || 5432, + username: dto.DB_USERNAME || 'postgres', + password: dto.DB_PASSWORD || 'postgres', + database: dto.DB_DATABASE_NAME || 'immich', + }; return { host: dto.IMMICH_HOST, @@ -269,21 +248,7 @@ const getEnv = (): EnvData => { }, database: { - config: { - typeorm: { - type: 'postgres', - entities: [], - migrations: [`${folders.dist}/migrations` + '/*.{js,ts}'], - subscribers: [], - migrationsRun: false, - synchronize: false, - connectTimeoutMS: 10_000, // 10 seconds - parseInt8: true, - ...(databaseUrl ? { connectionType: 'url', url: databaseUrl } : parts), - }, - kysely: parsedOptions, - }, - + config: databaseConnection, skipMigrations: dto.DB_SKIP_MIGRATIONS ?? false, vectorExtension: dto.DB_VECTOR_EXTENSION === 'pgvector' ? DatabaseExtension.VECTOR : DatabaseExtension.VECTORS, }, diff --git a/server/src/repositories/database.repository.ts b/server/src/repositories/database.repository.ts index c70c2cbdd4..a402c9d28d 100644 --- a/server/src/repositories/database.repository.ts +++ b/server/src/repositories/database.repository.ts @@ -3,7 +3,7 @@ import AsyncLock from 'async-lock'; import { FileMigrationProvider, Kysely, Migrator, sql, Transaction } from 'kysely'; import { InjectKysely } from 'nestjs-kysely'; import { readdir } from 'node:fs/promises'; -import { join } from 'node:path'; +import { join, resolve } from 'node:path'; import semver from 'semver'; import { EXTENSION_NAMES, POSTGRES_VERSION_RANGE, VECTOR_VERSION_RANGE, VECTORS_VERSION_RANGE } from 'src/constants'; import { DB } from 'src/db'; @@ -205,8 +205,29 @@ export class DatabaseRepository { const { rows } = await tableExists.execute(this.db); const hasTypeOrmMigrations = !!rows[0]?.result; if (hasTypeOrmMigrations) { + // eslint-disable-next-line unicorn/prefer-module + const dist = resolve(`${__dirname}/..`); + this.logger.debug('Running typeorm migrations'); - const dataSource = new DataSource(database.config.typeorm); + const dataSource = new DataSource({ + type: 'postgres', + entities: [], + subscribers: [], + migrations: [`${dist}/migrations` + '/*.{js,ts}'], + migrationsRun: false, + synchronize: false, + connectTimeoutMS: 10_000, // 10 seconds + parseInt8: true, + ...(database.config.connectionType === 'url' + ? { url: database.config.url } + : { + host: database.config.host, + port: database.config.port, + username: database.config.username, + password: database.config.password, + database: database.config.database, + }), + }); await dataSource.initialize(); await dataSource.runMigrations(options); await dataSource.destroy(); diff --git a/server/src/services/backup.service.ts b/server/src/services/backup.service.ts index dc4f71b992..409d34ab73 100644 --- a/server/src/services/backup.service.ts +++ b/server/src/services/backup.service.ts @@ -70,7 +70,7 @@ export class BackupService extends BaseService { async handleBackupDatabase(): Promise { this.logger.debug(`Database Backup Started`); const { database } = this.configRepository.getEnv(); - const config = database.config.typeorm; + const config = database.config; const isUrlConnection = config.connectionType === 'url'; diff --git a/server/src/services/database.service.spec.ts b/server/src/services/database.service.spec.ts index 4e45ec3ae0..e0ab4a624d 100644 --- a/server/src/services/database.service.spec.ts +++ b/server/src/services/database.service.spec.ts @@ -53,22 +53,12 @@ describe(DatabaseService.name, () => { mockEnvData({ database: { config: { - kysely: { - host: 'database', - port: 5432, - user: 'postgres', - password: 'postgres', - database: 'immich', - }, - typeorm: { - connectionType: 'parts', - type: 'postgres', - host: 'database', - port: 5432, - username: 'postgres', - password: 'postgres', - database: 'immich', - }, + connectionType: 'parts', + host: 'database', + port: 5432, + username: 'postgres', + password: 'postgres', + database: 'immich', }, skipMigrations: false, vectorExtension: extension, @@ -292,22 +282,12 @@ describe(DatabaseService.name, () => { mockEnvData({ database: { config: { - kysely: { - host: 'database', - port: 5432, - user: 'postgres', - password: 'postgres', - database: 'immich', - }, - typeorm: { - connectionType: 'parts', - type: 'postgres', - host: 'database', - port: 5432, - username: 'postgres', - password: 'postgres', - database: 'immich', - }, + connectionType: 'parts', + host: 'database', + port: 5432, + username: 'postgres', + password: 'postgres', + database: 'immich', }, skipMigrations: true, vectorExtension: DatabaseExtension.VECTORS, @@ -325,22 +305,12 @@ describe(DatabaseService.name, () => { mockEnvData({ database: { config: { - kysely: { - host: 'database', - port: 5432, - user: 'postgres', - password: 'postgres', - database: 'immich', - }, - typeorm: { - connectionType: 'parts', - type: 'postgres', - host: 'database', - port: 5432, - username: 'postgres', - password: 'postgres', - database: 'immich', - }, + connectionType: 'parts', + host: 'database', + port: 5432, + username: 'postgres', + password: 'postgres', + database: 'immich', }, skipMigrations: true, vectorExtension: DatabaseExtension.VECTOR, diff --git a/server/src/utils/database.spec.ts b/server/src/utils/database.spec.ts new file mode 100644 index 0000000000..4c6a82ad8f --- /dev/null +++ b/server/src/utils/database.spec.ts @@ -0,0 +1,83 @@ +import { asPostgresConnectionConfig } from 'src/utils/database'; + +describe('database utils', () => { + describe('asPostgresConnectionConfig', () => { + it('should handle sslmode=require', () => { + expect( + asPostgresConnectionConfig({ + connectionType: 'url', + url: 'postgres://postgres1:postgres2@database1:54320/immich?sslmode=require', + }), + ).toMatchObject({ ssl: {} }); + }); + + it('should handle sslmode=prefer', () => { + expect( + asPostgresConnectionConfig({ + connectionType: 'url', + url: 'postgres://postgres1:postgres2@database1:54320/immich?sslmode=prefer', + }), + ).toMatchObject({ ssl: {} }); + }); + + it('should handle sslmode=verify-ca', () => { + expect( + asPostgresConnectionConfig({ + connectionType: 'url', + url: 'postgres://postgres1:postgres2@database1:54320/immich?sslmode=verify-ca', + }), + ).toMatchObject({ ssl: {} }); + }); + + it('should handle sslmode=verify-full', () => { + expect( + asPostgresConnectionConfig({ + connectionType: 'url', + url: 'postgres://postgres1:postgres2@database1:54320/immich?sslmode=verify-full', + }), + ).toMatchObject({ ssl: {} }); + }); + + it('should handle sslmode=no-verify', () => { + expect( + asPostgresConnectionConfig({ + connectionType: 'url', + url: 'postgres://postgres1:postgres2@database1:54320/immich?sslmode=no-verify', + }), + ).toMatchObject({ ssl: { rejectUnauthorized: false } }); + }); + + it('should handle ssl=true', () => { + expect( + asPostgresConnectionConfig({ + connectionType: 'url', + url: 'postgres://postgres1:postgres2@database1:54320/immich?ssl=true', + }), + ).toMatchObject({ ssl: true }); + }); + + it('should reject invalid ssl', () => { + expect(() => + asPostgresConnectionConfig({ + connectionType: 'url', + url: 'postgres://postgres1:postgres2@database1:54320/immich?ssl=invalid', + }), + ).toThrowError('Invalid ssl option'); + }); + + it('should handle socket: URLs', () => { + expect( + asPostgresConnectionConfig({ connectionType: 'url', url: 'socket:/run/postgresql?db=database1' }), + ).toMatchObject({ host: '/run/postgresql', database: 'database1' }); + }); + + it('should handle sockets in postgres: URLs', () => { + expect( + asPostgresConnectionConfig({ connectionType: 'url', url: 'postgres:///database2?host=/path/to/socket' }), + ).toMatchObject({ + host: '/path/to/socket', + database: 'database2', + }); + }); + }); +}); diff --git a/server/src/utils/database.ts b/server/src/utils/database.ts index 1af0aa4b4e..8f0b56597a 100644 --- a/server/src/utils/database.ts +++ b/server/src/utils/database.ts @@ -13,33 +13,57 @@ import { } from 'kysely'; import { PostgresJSDialect } from 'kysely-postgres-js'; import { jsonArrayFrom, jsonObjectFrom } from 'kysely/helpers/postgres'; +import { parse } from 'pg-connection-string'; import postgres, { Notice } from 'postgres'; import { columns, Exif, Person } from 'src/database'; import { DB } from 'src/db'; import { AssetFileType } from 'src/enum'; import { TimeBucketSize } from 'src/repositories/asset.repository'; import { AssetSearchBuilderOptions } from 'src/repositories/search.repository'; +import { DatabaseConnectionParams } from 'src/types'; type Ssl = 'require' | 'allow' | 'prefer' | 'verify-full' | boolean | object; -export type PostgresConnectionConfig = { - host?: string; - password?: string; - user?: string; - port?: number; - database?: string; - max?: number; - client_encoding?: string; - ssl?: Ssl; - application_name?: string; - fallback_application_name?: string; - options?: string; -}; - -export const isValidSsl = (ssl?: string | boolean | object): ssl is Ssl => +const isValidSsl = (ssl?: string | boolean | object): ssl is Ssl => typeof ssl !== 'string' || ssl === 'require' || ssl === 'allow' || ssl === 'prefer' || ssl === 'verify-full'; -export const getKyselyConfig = (options: PostgresConnectionConfig): KyselyConfig => { +export const asPostgresConnectionConfig = (params: DatabaseConnectionParams) => { + if (params.connectionType === 'parts') { + return { + host: params.host, + port: params.port, + username: params.username, + password: params.password, + database: params.database, + ssl: undefined, + }; + } + + const { host, port, user, password, database, ...rest } = parse(params.url); + let ssl: Ssl | undefined; + if (rest.ssl) { + if (!isValidSsl(rest.ssl)) { + throw new Error(`Invalid ssl option: ${rest.ssl}`); + } + ssl = rest.ssl; + } + + return { + host: host ?? undefined, + port: port ? Number(port) : undefined, + username: user, + password, + database: database ?? undefined, + ssl, + }; +}; + +export const getKyselyConfig = ( + params: DatabaseConnectionParams, + options: Partial>> = {}, +): KyselyConfig => { + const config = asPostgresConnectionConfig(params); + return { dialect: new PostgresJSDialect({ postgres: postgres({ @@ -66,6 +90,12 @@ export const getKyselyConfig = (options: PostgresConnectionConfig): KyselyConfig connection: { TimeZone: 'UTC', }, + host: config.host, + port: config.port, + username: config.username, + password: config.password, + database: config.database, + ssl: config.ssl, ...options, }), }), diff --git a/server/test/medium/globalSetup.ts b/server/test/medium/globalSetup.ts index 46eb1a733f..e63c9f5224 100644 --- a/server/test/medium/globalSetup.ts +++ b/server/test/medium/globalSetup.ts @@ -1,5 +1,4 @@ import { Kysely } from 'kysely'; -import { parse } from 'pg-connection-string'; import { DB } from 'src/db'; import { ConfigRepository } from 'src/repositories/config.repository'; import { DatabaseRepository } from 'src/repositories/database.repository'; @@ -37,19 +36,10 @@ const globalSetup = async () => { const postgresPort = postgresContainer.getMappedPort(5432); const postgresUrl = `postgres://postgres:postgres@localhost:${postgresPort}/immich`; - const parsed = parse(postgresUrl); process.env.IMMICH_TEST_POSTGRES_URL = postgresUrl; - const db = new Kysely( - getKyselyConfig({ - ...parsed, - ssl: false, - host: parsed.host ?? undefined, - port: parsed.port ? Number(parsed.port) : undefined, - database: parsed.database ?? undefined, - }), - ); + const db = new Kysely(getKyselyConfig({ connectionType: 'url', url: postgresUrl })); const configRepository = new ConfigRepository(); const logger = new LoggingRepository(undefined, configRepository); diff --git a/server/test/repositories/config.repository.mock.ts b/server/test/repositories/config.repository.mock.ts index 7c5450c36e..4943a56a33 100644 --- a/server/test/repositories/config.repository.mock.ts +++ b/server/test/repositories/config.repository.mock.ts @@ -21,19 +21,12 @@ const envData: EnvData = { database: { config: { - kysely: { database: 'immich', host: 'database', port: 5432 }, - typeorm: { - connectionType: 'parts', - database: 'immich', - type: 'postgres', - host: 'database', - port: 5432, - username: 'postgres', - password: 'postgres', - name: 'immich', - synchronize: false, - migrationsRun: true, - }, + connectionType: 'parts', + database: 'immich', + host: 'database', + port: 5432, + username: 'postgres', + password: 'postgres', }, skipMigrations: false, diff --git a/server/test/utils.ts b/server/test/utils.ts index e1d979fbfe..c7c29d310e 100644 --- a/server/test/utils.ts +++ b/server/test/utils.ts @@ -1,9 +1,9 @@ import { ClassConstructor } from 'class-transformer'; -import { Kysely, sql } from 'kysely'; +import { Kysely } from 'kysely'; import { ChildProcessWithoutNullStreams } from 'node:child_process'; import { Writable } from 'node:stream'; -import { parse } from 'pg-connection-string'; import { PNG } from 'pngjs'; +import postgres from 'postgres'; import { DB } from 'src/db'; import { AccessRepository } from 'src/repositories/access.repository'; import { ActivityRepository } from 'src/repositories/activity.repository'; @@ -49,7 +49,7 @@ import { VersionHistoryRepository } from 'src/repositories/version-history.repos import { ViewRepository } from 'src/repositories/view-repository'; import { BaseService } from 'src/services/base.service'; import { RepositoryInterface } from 'src/types'; -import { getKyselyConfig } from 'src/utils/database'; +import { asPostgresConnectionConfig, getKyselyConfig } from 'src/utils/database'; import { IAccessRepositoryMock, newAccessRepositoryMock } from 'test/repositories/access.repository.mock'; import { newAssetRepositoryMock } from 'test/repositories/asset.repository.mock'; import { newConfigRepositoryMock } from 'test/repositories/config.repository.mock'; @@ -297,24 +297,20 @@ function* newPngFactory() { const pngFactory = newPngFactory(); +const withDatabase = (url: string, name: string) => url.replace('/immich', `/${name}`); + export const getKyselyDB = async (suffix?: string): Promise> => { - const parsed = parse(process.env.IMMICH_TEST_POSTGRES_URL!); + const testUrl = process.env.IMMICH_TEST_POSTGRES_URL!; + const sql = postgres({ + ...asPostgresConnectionConfig({ connectionType: 'url', url: withDatabase(testUrl, 'postgres') }), + max: 1, + }); - const parsedOptions = { - ...parsed, - ssl: false, - host: parsed.host ?? undefined, - port: parsed.port ? Number(parsed.port) : undefined, - database: parsed.database ?? undefined, - }; - - const kysely = new Kysely(getKyselyConfig({ ...parsedOptions, max: 1, database: 'postgres' })); const randomSuffix = Math.random().toString(36).slice(2, 7); const dbName = `immich_${suffix ?? randomSuffix}`; + await sql.unsafe(`CREATE DATABASE ${dbName} WITH TEMPLATE immich OWNER postgres;`); - await sql.raw(`CREATE DATABASE ${dbName} WITH TEMPLATE immich OWNER postgres;`).execute(kysely); - - return new Kysely(getKyselyConfig({ ...parsedOptions, database: dbName })); + return new Kysely(getKyselyConfig({ connectionType: 'url', url: withDatabase(testUrl, dbName) })); }; export const newRandomImage = () => { From a03902f1743c56b2584ae3c17d9a60d0062b8058 Mon Sep 17 00:00:00 2001 From: Daimolean <92239625+wuzihao051119@users.noreply.github.com> Date: Fri, 25 Apr 2025 07:40:52 +0800 Subject: [PATCH 03/31] fix(docs): incorrect date sorting (#17858) --- docs/src/pages/roadmap.tsx | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/docs/src/pages/roadmap.tsx b/docs/src/pages/roadmap.tsx index 4dc391cb27..1e0914a651 100644 --- a/docs/src/pages/roadmap.tsx +++ b/docs/src/pages/roadmap.tsx @@ -252,6 +252,13 @@ const milestones: Item[] = [ description: 'Browse your photos and videos in their folder structure inside the mobile app', release: 'v1.130.0', }), + { + icon: mdiStar, + iconColor: 'gold', + title: '60,000 Stars', + description: 'Reached 60K Stars on GitHub!', + getDateLabel: withLanguage(new Date(2025, 2, 4)), + }, withRelease({ icon: mdiTagFaces, iconColor: 'teal', @@ -260,13 +267,6 @@ const milestones: Item[] = [ 'Manually tag or remove faces in photos and videos, even when automatic detection misses or misidentifies them.', release: 'v1.127.0', }), - { - icon: mdiStar, - iconColor: 'gold', - title: '60,000 Stars', - description: 'Reached 60K Stars on GitHub!', - getDateLabel: withLanguage(new Date(2025, 2, 4)), - }, withRelease({ icon: mdiLinkEdit, iconColor: 'crimson', From b0371580283420fd2a99b915fe7307f7d3488392 Mon Sep 17 00:00:00 2001 From: shenlong <139912620+shenlong-tanwen@users.noreply.github.com> Date: Fri, 25 Apr 2025 05:39:50 +0530 Subject: [PATCH 04/31] fix(mobile): auto trash using MANAGE_MEDIA (#17828) fix: auto trash using MANAGE_MEDIA Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com> --- .../immich/BackgroundServicePlugin.kt | 212 +++++++++++++++++- .../app/alextran/immich/MainActivity.kt | 8 +- mobile/lib/domain/models/store.model.dart | 1 + .../local_files_manager.interface.dart | 5 + mobile/lib/providers/websocket.provider.dart | 28 ++- .../local_files_manager.repository.dart | 25 +++ mobile/lib/services/app_settings.service.dart | 1 + mobile/lib/services/sync.service.dart | 66 +++++- mobile/lib/utils/local_files_manager.dart | 38 ++++ .../widgets/settings/advanced_settings.dart | 37 +++ .../modules/shared/sync_service_test.dart | 5 + mobile/test/repository.mocks.dart | 6 +- mobile/test/service.mocks.dart | 3 + 13 files changed, 420 insertions(+), 15 deletions(-) create mode 100644 mobile/lib/interfaces/local_files_manager.interface.dart create mode 100644 mobile/lib/repositories/local_files_manager.repository.dart create mode 100644 mobile/lib/utils/local_files_manager.dart diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/BackgroundServicePlugin.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/BackgroundServicePlugin.kt index 8520413cff..ae2ec22a71 100644 --- a/mobile/android/app/src/main/kotlin/app/alextran/immich/BackgroundServicePlugin.kt +++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/BackgroundServicePlugin.kt @@ -1,25 +1,42 @@ package app.alextran.immich +import android.app.Activity +import android.content.ContentResolver +import android.content.ContentUris import android.content.Context +import android.content.Intent +import android.net.Uri +import android.os.Build +import android.os.Bundle +import android.provider.MediaStore +import android.provider.Settings import android.util.Log +import androidx.annotation.RequiresApi import io.flutter.embedding.engine.plugins.FlutterPlugin +import io.flutter.embedding.engine.plugins.activity.ActivityAware +import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding import io.flutter.plugin.common.BinaryMessenger import io.flutter.plugin.common.MethodCall import io.flutter.plugin.common.MethodChannel +import io.flutter.plugin.common.MethodChannel.Result +import io.flutter.plugin.common.PluginRegistry import java.security.MessageDigest import java.io.FileInputStream import kotlinx.coroutines.* +import androidx.core.net.toUri /** - * Android plugin for Dart `BackgroundService` - * - * Receives messages/method calls from the foreground Dart side to manage - * the background service, e.g. start (enqueue), stop (cancel) + * Android plugin for Dart `BackgroundService` and file trash operations */ -class BackgroundServicePlugin : FlutterPlugin, MethodChannel.MethodCallHandler { +class BackgroundServicePlugin : FlutterPlugin, MethodChannel.MethodCallHandler, ActivityAware, PluginRegistry.ActivityResultListener { private var methodChannel: MethodChannel? = null + private var fileTrashChannel: MethodChannel? = null private var context: Context? = null + private var pendingResult: Result? = null + private val permissionRequestCode = 1001 + private val trashRequestCode = 1002 + private var activityBinding: ActivityPluginBinding? = null override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) { onAttachedToEngine(binding.applicationContext, binding.binaryMessenger) @@ -29,6 +46,10 @@ class BackgroundServicePlugin : FlutterPlugin, MethodChannel.MethodCallHandler { context = ctx methodChannel = MethodChannel(messenger, "immich/foregroundChannel") methodChannel?.setMethodCallHandler(this) + + // Add file trash channel + fileTrashChannel = MethodChannel(messenger, "file_trash") + fileTrashChannel?.setMethodCallHandler(this) } override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) { @@ -38,11 +59,14 @@ class BackgroundServicePlugin : FlutterPlugin, MethodChannel.MethodCallHandler { private fun onDetachedFromEngine() { methodChannel?.setMethodCallHandler(null) methodChannel = null + fileTrashChannel?.setMethodCallHandler(null) + fileTrashChannel = null } - override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) { + override fun onMethodCall(call: MethodCall, result: Result) { val ctx = context!! when (call.method) { + // Existing BackgroundService methods "enable" -> { val args = call.arguments>()!! ctx.getSharedPreferences(BackupWorker.SHARED_PREF_NAME, Context.MODE_PRIVATE) @@ -114,10 +138,184 @@ class BackgroundServicePlugin : FlutterPlugin, MethodChannel.MethodCallHandler { } } + // File Trash methods moved from MainActivity + "moveToTrash" -> { + val mediaUrls = call.argument>("mediaUrls") + if (mediaUrls != null) { + if ((Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) && hasManageMediaPermission()) { + moveToTrash(mediaUrls, result) + } else { + result.error("PERMISSION_DENIED", "Media permission required", null) + } + } else { + result.error("INVALID_NAME", "The mediaUrls is not specified.", null) + } + } + + "restoreFromTrash" -> { + val fileName = call.argument("fileName") + val type = call.argument("type") + if (fileName != null && type != null) { + if ((Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) && hasManageMediaPermission()) { + restoreFromTrash(fileName, type, result) + } else { + result.error("PERMISSION_DENIED", "Media permission required", null) + } + } else { + result.error("INVALID_NAME", "The file name is not specified.", null) + } + } + + "requestManageMediaPermission" -> { + if (!hasManageMediaPermission()) { + requestManageMediaPermission(result) + } else { + Log.e("Manage storage permission", "Permission already granted") + result.success(true) + } + } + else -> result.notImplemented() } } + + private fun hasManageMediaPermission(): Boolean { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + MediaStore.canManageMedia(context!!); + } else { + false + } + } + + private fun requestManageMediaPermission(result: Result) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + pendingResult = result // Store the result callback + val activity = activityBinding?.activity ?: return + + val intent = Intent(Settings.ACTION_REQUEST_MANAGE_MEDIA) + intent.data = "package:${activity.packageName}".toUri() + activity.startActivityForResult(intent, permissionRequestCode) + } else { + result.success(false) + } + } + + @RequiresApi(Build.VERSION_CODES.R) + private fun moveToTrash(mediaUrls: List, result: Result) { + val urisToTrash = mediaUrls.map { it.toUri() } + if (urisToTrash.isEmpty()) { + result.error("INVALID_ARGS", "No valid URIs provided", null) + return + } + + toggleTrash(urisToTrash, true, result); + } + + @RequiresApi(Build.VERSION_CODES.R) + private fun restoreFromTrash(name: String, type: Int, result: Result) { + val uri = getTrashedFileUri(name, type) + if (uri == null) { + Log.e("TrashError", "Asset Uri cannot be found obtained") + result.error("TrashError", "Asset Uri cannot be found obtained", null) + return + } + Log.e("FILE_URI", uri.toString()) + uri.let { toggleTrash(listOf(it), false, result) } + } + + @RequiresApi(Build.VERSION_CODES.R) + private fun toggleTrash(contentUris: List, isTrashed: Boolean, result: Result) { + val activity = activityBinding?.activity + val contentResolver = context?.contentResolver + if (activity == null || contentResolver == null) { + result.error("TrashError", "Activity or ContentResolver not available", null) + return + } + + try { + val pendingIntent = MediaStore.createTrashRequest(contentResolver, contentUris, isTrashed) + pendingResult = result // Store for onActivityResult + activity.startIntentSenderForResult( + pendingIntent.intentSender, + trashRequestCode, + null, 0, 0, 0 + ) + } catch (e: Exception) { + Log.e("TrashError", "Error creating or starting trash request", e) + result.error("TrashError", "Error creating or starting trash request", null) + } + } + + @RequiresApi(Build.VERSION_CODES.R) + private fun getTrashedFileUri(fileName: String, type: Int): Uri? { + val contentResolver = context?.contentResolver ?: return null + val queryUri = MediaStore.Files.getContentUri(MediaStore.VOLUME_EXTERNAL) + val projection = arrayOf(MediaStore.Files.FileColumns._ID) + + val queryArgs = Bundle().apply { + putString( + ContentResolver.QUERY_ARG_SQL_SELECTION, + "${MediaStore.Files.FileColumns.DISPLAY_NAME} = ?" + ) + putStringArray(ContentResolver.QUERY_ARG_SQL_SELECTION_ARGS, arrayOf(fileName)) + putInt(MediaStore.QUERY_ARG_MATCH_TRASHED, MediaStore.MATCH_ONLY) + } + + contentResolver.query(queryUri, projection, queryArgs, null)?.use { cursor -> + if (cursor.moveToFirst()) { + val id = cursor.getLong(cursor.getColumnIndexOrThrow(MediaStore.Files.FileColumns._ID)) + // same order as AssetType from dart + val contentUri = when (type) { + 1 -> MediaStore.Images.Media.EXTERNAL_CONTENT_URI + 2 -> MediaStore.Video.Media.EXTERNAL_CONTENT_URI + 3 -> MediaStore.Audio.Media.EXTERNAL_CONTENT_URI + else -> queryUri + } + return ContentUris.withAppendedId(contentUri, id) + } + } + return null + } + + // ActivityAware implementation + override fun onAttachedToActivity(binding: ActivityPluginBinding) { + activityBinding = binding + binding.addActivityResultListener(this) + } + + override fun onDetachedFromActivityForConfigChanges() { + activityBinding?.removeActivityResultListener(this) + activityBinding = null + } + + override fun onReattachedToActivityForConfigChanges(binding: ActivityPluginBinding) { + activityBinding = binding + binding.addActivityResultListener(this) + } + + override fun onDetachedFromActivity() { + activityBinding?.removeActivityResultListener(this) + activityBinding = null + } + + // ActivityResultListener implementation + override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?): Boolean { + if (requestCode == permissionRequestCode) { + val granted = hasManageMediaPermission() + pendingResult?.success(granted) + pendingResult = null + return true + } + + if (requestCode == trashRequestCode) { + val approved = resultCode == Activity.RESULT_OK + pendingResult?.success(approved) + pendingResult = null + return true + } + return false + } } private const val TAG = "BackgroundServicePlugin" -private const val BUFFER_SIZE = 2 * 1024 * 1024; +private const val BUFFER_SIZE = 2 * 1024 * 1024 diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/MainActivity.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/MainActivity.kt index 4ffb490c77..2b6bf81148 100644 --- a/mobile/android/app/src/main/kotlin/app/alextran/immich/MainActivity.kt +++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/MainActivity.kt @@ -2,14 +2,12 @@ package app.alextran.immich import io.flutter.embedding.android.FlutterActivity import io.flutter.embedding.engine.FlutterEngine -import android.os.Bundle -import android.content.Intent +import androidx.annotation.NonNull class MainActivity : FlutterActivity() { - - override fun configureFlutterEngine(flutterEngine: FlutterEngine) { + override fun configureFlutterEngine(@NonNull flutterEngine: FlutterEngine) { super.configureFlutterEngine(flutterEngine) flutterEngine.plugins.add(BackgroundServicePlugin()) + // No need to set up method channel here as it's now handled in the plugin } - } diff --git a/mobile/lib/domain/models/store.model.dart b/mobile/lib/domain/models/store.model.dart index e6d9ecaf48..8a5a908e0d 100644 --- a/mobile/lib/domain/models/store.model.dart +++ b/mobile/lib/domain/models/store.model.dart @@ -65,6 +65,7 @@ enum StoreKey { // Video settings loadOriginalVideo._(136), + manageLocalMediaAndroid._(137), // Experimental stuff photoManagerCustomFilter._(1000); diff --git a/mobile/lib/interfaces/local_files_manager.interface.dart b/mobile/lib/interfaces/local_files_manager.interface.dart new file mode 100644 index 0000000000..07274b7e29 --- /dev/null +++ b/mobile/lib/interfaces/local_files_manager.interface.dart @@ -0,0 +1,5 @@ +abstract interface class ILocalFilesManager { + Future moveToTrash(List mediaUrls); + Future restoreFromTrash(String fileName, int type); + Future requestManageMediaPermission(); +} diff --git a/mobile/lib/providers/websocket.provider.dart b/mobile/lib/providers/websocket.provider.dart index f92d2c8421..72dbda8b6f 100644 --- a/mobile/lib/providers/websocket.provider.dart +++ b/mobile/lib/providers/websocket.provider.dart @@ -23,6 +23,7 @@ enum PendingAction { assetDelete, assetUploaded, assetHidden, + assetTrash, } class PendingChange { @@ -160,7 +161,7 @@ class WebsocketNotifier extends StateNotifier { socket.on('on_upload_success', _handleOnUploadSuccess); socket.on('on_config_update', _handleOnConfigUpdate); socket.on('on_asset_delete', _handleOnAssetDelete); - socket.on('on_asset_trash', _handleServerUpdates); + socket.on('on_asset_trash', _handleOnAssetTrash); socket.on('on_asset_restore', _handleServerUpdates); socket.on('on_asset_update', _handleServerUpdates); socket.on('on_asset_stack_update', _handleServerUpdates); @@ -207,6 +208,26 @@ class WebsocketNotifier extends StateNotifier { _debounce.run(handlePendingChanges); } + Future _handlePendingTrashes() async { + final trashChanges = state.pendingChanges + .where((c) => c.action == PendingAction.assetTrash) + .toList(); + if (trashChanges.isNotEmpty) { + List remoteIds = trashChanges + .expand((a) => (a.value as List).map((e) => e.toString())) + .toList(); + + await _ref.read(syncServiceProvider).handleRemoteAssetRemoval(remoteIds); + await _ref.read(assetProvider.notifier).getAllAsset(); + + state = state.copyWith( + pendingChanges: state.pendingChanges + .whereNot((c) => trashChanges.contains(c)) + .toList(), + ); + } + } + Future _handlePendingDeletes() async { final deleteChanges = state.pendingChanges .where((c) => c.action == PendingAction.assetDelete) @@ -267,6 +288,7 @@ class WebsocketNotifier extends StateNotifier { await _handlePendingUploaded(); await _handlePendingDeletes(); await _handlingPendingHidden(); + await _handlePendingTrashes(); } void _handleOnConfigUpdate(dynamic _) { @@ -285,6 +307,10 @@ class WebsocketNotifier extends StateNotifier { void _handleOnAssetDelete(dynamic data) => addPendingChange(PendingAction.assetDelete, data); + void _handleOnAssetTrash(dynamic data) { + addPendingChange(PendingAction.assetTrash, data); + } + void _handleOnAssetHidden(dynamic data) => addPendingChange(PendingAction.assetHidden, data); diff --git a/mobile/lib/repositories/local_files_manager.repository.dart b/mobile/lib/repositories/local_files_manager.repository.dart new file mode 100644 index 0000000000..c2e234d14d --- /dev/null +++ b/mobile/lib/repositories/local_files_manager.repository.dart @@ -0,0 +1,25 @@ +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/interfaces/local_files_manager.interface.dart'; +import 'package:immich_mobile/utils/local_files_manager.dart'; + +final localFilesManagerRepositoryProvider = + Provider((ref) => const LocalFilesManagerRepository()); + +class LocalFilesManagerRepository implements ILocalFilesManager { + const LocalFilesManagerRepository(); + + @override + Future moveToTrash(List mediaUrls) async { + return await LocalFilesManager.moveToTrash(mediaUrls); + } + + @override + Future restoreFromTrash(String fileName, int type) async { + return await LocalFilesManager.restoreFromTrash(fileName, type); + } + + @override + Future requestManageMediaPermission() async { + return await LocalFilesManager.requestManageMediaPermission(); + } +} diff --git a/mobile/lib/services/app_settings.service.dart b/mobile/lib/services/app_settings.service.dart index cc57b8d3a3..6413b69fce 100644 --- a/mobile/lib/services/app_settings.service.dart +++ b/mobile/lib/services/app_settings.service.dart @@ -61,6 +61,7 @@ enum AppSettingsEnum { 0, ), advancedTroubleshooting(StoreKey.advancedTroubleshooting, null, false), + manageLocalMediaAndroid(StoreKey.manageLocalMediaAndroid, null, false), logLevel(StoreKey.logLevel, null, 5), // Level.INFO = 5 preferRemoteImage(StoreKey.preferRemoteImage, null, false), loopVideo(StoreKey.loopVideo, "loopVideo", true), diff --git a/mobile/lib/services/sync.service.dart b/mobile/lib/services/sync.service.dart index 11a9dcb56a..80950d8c00 100644 --- a/mobile/lib/services/sync.service.dart +++ b/mobile/lib/services/sync.service.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:io'; import 'package:collection/collection.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; @@ -16,8 +17,10 @@ import 'package:immich_mobile/interfaces/album_api.interface.dart'; import 'package:immich_mobile/interfaces/album_media.interface.dart'; import 'package:immich_mobile/interfaces/asset.interface.dart'; import 'package:immich_mobile/interfaces/etag.interface.dart'; +import 'package:immich_mobile/interfaces/local_files_manager.interface.dart'; import 'package:immich_mobile/interfaces/partner.interface.dart'; import 'package:immich_mobile/interfaces/partner_api.interface.dart'; +import 'package:immich_mobile/providers/app_settings.provider.dart'; import 'package:immich_mobile/providers/infrastructure/exif.provider.dart'; import 'package:immich_mobile/providers/infrastructure/user.provider.dart'; import 'package:immich_mobile/repositories/album.repository.dart'; @@ -25,8 +28,10 @@ import 'package:immich_mobile/repositories/album_api.repository.dart'; import 'package:immich_mobile/repositories/album_media.repository.dart'; import 'package:immich_mobile/repositories/asset.repository.dart'; import 'package:immich_mobile/repositories/etag.repository.dart'; +import 'package:immich_mobile/repositories/local_files_manager.repository.dart'; import 'package:immich_mobile/repositories/partner.repository.dart'; import 'package:immich_mobile/repositories/partner_api.repository.dart'; +import 'package:immich_mobile/services/app_settings.service.dart'; import 'package:immich_mobile/services/entity.service.dart'; import 'package:immich_mobile/services/hash.service.dart'; import 'package:immich_mobile/utils/async_mutex.dart'; @@ -48,6 +53,8 @@ final syncServiceProvider = Provider( ref.watch(userRepositoryProvider), ref.watch(userServiceProvider), ref.watch(etagRepositoryProvider), + ref.watch(appSettingsServiceProvider), + ref.watch(localFilesManagerRepositoryProvider), ref.watch(partnerApiRepositoryProvider), ref.watch(userApiRepositoryProvider), ), @@ -69,6 +76,8 @@ class SyncService { final IUserApiRepository _userApiRepository; final AsyncMutex _lock = AsyncMutex(); final Logger _log = Logger('SyncService'); + final AppSettingsService _appSettingsService; + final ILocalFilesManager _localFilesManager; SyncService( this._hashService, @@ -82,6 +91,8 @@ class SyncService { this._userRepository, this._userService, this._eTagRepository, + this._appSettingsService, + this._localFilesManager, this._partnerApiRepository, this._userApiRepository, ); @@ -238,8 +249,22 @@ class SyncService { return null; } + Future _moveToTrashMatchedAssets(Iterable idsToDelete) async { + final List localAssets = await _assetRepository.getAllLocal(); + final List matchedAssets = localAssets + .where((asset) => idsToDelete.contains(asset.remoteId)) + .toList(); + + final mediaUrls = await Future.wait( + matchedAssets + .map((asset) => asset.local?.getMediaUrl() ?? Future.value(null)), + ); + + await _localFilesManager.moveToTrash(mediaUrls.nonNulls.toList()); + } + /// Deletes remote-only assets, updates merged assets to be local-only - Future handleRemoteAssetRemoval(List idsToDelete) { + Future handleRemoteAssetRemoval(List idsToDelete) async { return _assetRepository.transaction(() async { await _assetRepository.deleteAllByRemoteId( idsToDelete, @@ -249,6 +274,12 @@ class SyncService { idsToDelete, state: AssetState.merged, ); + if (Platform.isAndroid && + _appSettingsService.getSetting( + AppSettingsEnum.manageLocalMediaAndroid, + )) { + await _moveToTrashMatchedAssets(idsToDelete); + } if (merged.isEmpty) return; for (final Asset asset in merged) { asset.remoteId = null; @@ -790,10 +821,43 @@ class SyncService { return (existing, toUpsert); } + Future _toggleTrashStatusForAssets(List assetsList) async { + final trashMediaUrls = []; + + for (final asset in assetsList) { + if (asset.isTrashed) { + final mediaUrl = await asset.local?.getMediaUrl(); + if (mediaUrl == null) { + _log.warning( + "Failed to get media URL for asset ${asset.name} while moving to trash", + ); + continue; + } + trashMediaUrls.add(mediaUrl); + } else { + await _localFilesManager.restoreFromTrash( + asset.fileName, + asset.type.index, + ); + } + } + + if (trashMediaUrls.isNotEmpty) { + await _localFilesManager.moveToTrash(trashMediaUrls); + } + } + /// Inserts or updates the assets in the database with their ExifInfo (if any) Future upsertAssetsWithExif(List assets) async { if (assets.isEmpty) return; + if (Platform.isAndroid && + _appSettingsService.getSetting( + AppSettingsEnum.manageLocalMediaAndroid, + )) { + _toggleTrashStatusForAssets(assets); + } + try { await _assetRepository.transaction(() async { await _assetRepository.updateAll(assets); diff --git a/mobile/lib/utils/local_files_manager.dart b/mobile/lib/utils/local_files_manager.dart new file mode 100644 index 0000000000..a4cf41a6e6 --- /dev/null +++ b/mobile/lib/utils/local_files_manager.dart @@ -0,0 +1,38 @@ +import 'package:flutter/services.dart'; +import 'package:logging/logging.dart'; + +abstract final class LocalFilesManager { + static final Logger _logger = Logger('LocalFilesManager'); + static const MethodChannel _channel = MethodChannel('file_trash'); + + static Future moveToTrash(List mediaUrls) async { + try { + return await _channel + .invokeMethod('moveToTrash', {'mediaUrls': mediaUrls}); + } catch (e, s) { + _logger.warning('Error moving file to trash', e, s); + return false; + } + } + + static Future restoreFromTrash(String fileName, int type) async { + try { + return await _channel.invokeMethod( + 'restoreFromTrash', + {'fileName': fileName, 'type': type}, + ); + } catch (e, s) { + _logger.warning('Error restore file from trash', e, s); + return false; + } + } + + static Future requestManageMediaPermission() async { + try { + return await _channel.invokeMethod('requestManageMediaPermission'); + } catch (e, s) { + _logger.warning('Error requesting manage media permission', e, s); + return false; + } + } +} diff --git a/mobile/lib/widgets/settings/advanced_settings.dart b/mobile/lib/widgets/settings/advanced_settings.dart index a2e0e5b95c..d65186a191 100644 --- a/mobile/lib/widgets/settings/advanced_settings.dart +++ b/mobile/lib/widgets/settings/advanced_settings.dart @@ -1,11 +1,13 @@ import 'dart:io'; +import 'package:device_info_plus/device_info_plus.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart' hide Store; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/domain/services/log.service.dart'; import 'package:immich_mobile/providers/user.provider.dart'; +import 'package:immich_mobile/repositories/local_files_manager.repository.dart'; import 'package:immich_mobile/services/app_settings.service.dart'; import 'package:immich_mobile/utils/hooks/app_settings_update_hook.dart'; import 'package:immich_mobile/utils/http_ssl_cert_override.dart'; @@ -25,6 +27,8 @@ class AdvancedSettings extends HookConsumerWidget { final advancedTroubleshooting = useAppSettingsState(AppSettingsEnum.advancedTroubleshooting); + final manageLocalMediaAndroid = + useAppSettingsState(AppSettingsEnum.manageLocalMediaAndroid); final levelId = useAppSettingsState(AppSettingsEnum.logLevel); final preferRemote = useAppSettingsState(AppSettingsEnum.preferRemoteImage); final allowSelfSignedSSLCert = @@ -40,6 +44,16 @@ class AdvancedSettings extends HookConsumerWidget { LogService.I.setlogLevel(Level.LEVELS[levelId.value].toLogLevel()), ); + Future checkAndroidVersion() async { + if (Platform.isAndroid) { + DeviceInfoPlugin deviceInfo = DeviceInfoPlugin(); + AndroidDeviceInfo androidInfo = await deviceInfo.androidInfo; + int sdkVersion = androidInfo.version.sdkInt; + return sdkVersion >= 31; + } + return false; + } + final advancedSettings = [ SettingsSwitchListTile( enabled: true, @@ -47,6 +61,29 @@ class AdvancedSettings extends HookConsumerWidget { title: "advanced_settings_troubleshooting_title".tr(), subtitle: "advanced_settings_troubleshooting_subtitle".tr(), ), + FutureBuilder( + future: checkAndroidVersion(), + builder: (context, snapshot) { + if (snapshot.hasData && snapshot.data == true) { + return SettingsSwitchListTile( + enabled: true, + valueNotifier: manageLocalMediaAndroid, + title: "advanced_settings_sync_remote_deletions_title".tr(), + subtitle: "advanced_settings_sync_remote_deletions_subtitle".tr(), + onChanged: (value) async { + if (value) { + final result = await ref + .read(localFilesManagerRepositoryProvider) + .requestManageMediaPermission(); + manageLocalMediaAndroid.value = result; + } + }, + ); + } else { + return const SizedBox.shrink(); + } + }, + ), SettingsSliderListTile( text: "advanced_settings_log_level_title".tr(args: [logLevel]), valueNotifier: levelId, diff --git a/mobile/test/modules/shared/sync_service_test.dart b/mobile/test/modules/shared/sync_service_test.dart index 3879e64237..2029ade018 100644 --- a/mobile/test/modules/shared/sync_service_test.dart +++ b/mobile/test/modules/shared/sync_service_test.dart @@ -60,6 +60,9 @@ void main() { final MockAlbumMediaRepository albumMediaRepository = MockAlbumMediaRepository(); final MockAlbumApiRepository albumApiRepository = MockAlbumApiRepository(); + final MockAppSettingService appSettingService = MockAppSettingService(); + final MockLocalFilesManagerRepository localFilesManagerRepository = + MockLocalFilesManagerRepository(); final MockPartnerApiRepository partnerApiRepository = MockPartnerApiRepository(); final MockUserApiRepository userApiRepository = MockUserApiRepository(); @@ -106,6 +109,8 @@ void main() { userRepository, userService, eTagRepository, + appSettingService, + localFilesManagerRepository, partnerApiRepository, userApiRepository, ); diff --git a/mobile/test/repository.mocks.dart b/mobile/test/repository.mocks.dart index 1c698297dc..d2f0da4231 100644 --- a/mobile/test/repository.mocks.dart +++ b/mobile/test/repository.mocks.dart @@ -10,6 +10,7 @@ import 'package:immich_mobile/interfaces/auth_api.interface.dart'; import 'package:immich_mobile/interfaces/backup_album.interface.dart'; import 'package:immich_mobile/interfaces/etag.interface.dart'; import 'package:immich_mobile/interfaces/file_media.interface.dart'; +import 'package:immich_mobile/interfaces/local_files_manager.interface.dart'; import 'package:immich_mobile/interfaces/partner.interface.dart'; import 'package:immich_mobile/interfaces/partner_api.interface.dart'; import 'package:mocktail/mocktail.dart'; @@ -41,6 +42,9 @@ class MockAuthApiRepository extends Mock implements IAuthApiRepository {} class MockAuthRepository extends Mock implements IAuthRepository {} +class MockPartnerRepository extends Mock implements IPartnerRepository {} + class MockPartnerApiRepository extends Mock implements IPartnerApiRepository {} -class MockPartnerRepository extends Mock implements IPartnerRepository {} +class MockLocalFilesManagerRepository extends Mock + implements ILocalFilesManager {} diff --git a/mobile/test/service.mocks.dart b/mobile/test/service.mocks.dart index d31a7e5d50..87a8c01cf0 100644 --- a/mobile/test/service.mocks.dart +++ b/mobile/test/service.mocks.dart @@ -1,5 +1,6 @@ import 'package:immich_mobile/services/album.service.dart'; import 'package:immich_mobile/services/api.service.dart'; +import 'package:immich_mobile/services/app_settings.service.dart'; import 'package:immich_mobile/services/background.service.dart'; import 'package:immich_mobile/services/backup.service.dart'; import 'package:immich_mobile/services/entity.service.dart'; @@ -25,4 +26,6 @@ class MockNetworkService extends Mock implements NetworkService {} class MockSearchApi extends Mock implements SearchApi {} +class MockAppSettingService extends Mock implements AppSettingsService {} + class MockBackgroundService extends Mock implements BackgroundService {} From 765da7b1821a1fc53edfdcdd91d0108a744fe42e Mon Sep 17 00:00:00 2001 From: Alex Date: Thu, 24 Apr 2025 19:16:54 -0500 Subject: [PATCH 05/31] fix(mobile): mobile migration logic (#17865) * fix(mobile): mobile migration logic * add exception * remove unused comment * finalize --- mobile/lib/utils/migration.dart | 73 ++++++++++++++++++++++++++------- 1 file changed, 59 insertions(+), 14 deletions(-) diff --git a/mobile/lib/utils/migration.dart b/mobile/lib/utils/migration.dart index bebd7a027b..6a09f79ce2 100644 --- a/mobile/lib/utils/migration.dart +++ b/mobile/lib/utils/migration.dart @@ -3,7 +3,7 @@ import 'dart:async'; import 'dart:io'; -import 'package:flutter/widgets.dart'; +import 'package:flutter/foundation.dart'; import 'package:immich_mobile/domain/models/store.model.dart'; import 'package:immich_mobile/entities/album.entity.dart'; import 'package:immich_mobile/entities/android_device_asset.entity.dart'; @@ -17,6 +17,8 @@ import 'package:immich_mobile/infrastructure/entities/store.entity.dart'; import 'package:immich_mobile/infrastructure/entities/user.entity.dart'; import 'package:immich_mobile/utils/diff.dart'; import 'package:isar/isar.dart'; +// ignore: import_rule_photo_manager +import 'package:photo_manager/photo_manager.dart'; const int targetVersion = 10; @@ -69,14 +71,45 @@ Future _migrateDeviceAsset(Isar db) async { : (await db.iOSDeviceAssets.where().findAll()) .map((i) => _DeviceAsset(assetId: i.id, hash: i.hash)) .toList(); - final localAssets = (await db.assets - .where() - .anyOf(ids, (query, id) => query.localIdEqualTo(id.assetId)) - .findAll()) - .map((a) => _DeviceAsset(assetId: a.localId!, dateTime: a.fileModifiedAt)) - .toList(); - debugPrint("Device Asset Ids length - ${ids.length}"); - debugPrint("Local Asset Ids length - ${localAssets.length}"); + + final PermissionState ps = await PhotoManager.requestPermissionExtend(); + if (!ps.hasAccess) { + if (kDebugMode) { + debugPrint( + "[MIGRATION] Photo library permission not granted. Skipping device asset migration.", + ); + } + + return; + } + + List<_DeviceAsset> localAssets = []; + final List paths = + await PhotoManager.getAssetPathList(onlyAll: true); + + if (paths.isEmpty) { + localAssets = (await db.assets + .where() + .anyOf(ids, (query, id) => query.localIdEqualTo(id.assetId)) + .findAll()) + .map( + (a) => _DeviceAsset(assetId: a.localId!, dateTime: a.fileModifiedAt), + ) + .toList(); + } else { + final AssetPathEntity albumWithAll = paths.first; + final int assetCount = await albumWithAll.assetCountAsync; + + final List allDeviceAssets = + await albumWithAll.getAssetListRange(start: 0, end: assetCount); + + localAssets = allDeviceAssets + .map((a) => _DeviceAsset(assetId: a.id, dateTime: a.modifiedDateTime)) + .toList(); + } + + debugPrint("[MIGRATION] Device Asset Ids length - ${ids.length}"); + debugPrint("[MIGRATION] Local Asset Ids length - ${localAssets.length}"); ids.sort((a, b) => a.assetId.compareTo(b.assetId)); localAssets.sort((a, b) => a.assetId.compareTo(b.assetId)); final List toAdd = []; @@ -95,15 +128,27 @@ Future _migrateDeviceAsset(Isar db) async { return false; }, onlyFirst: (deviceAsset) { - debugPrint( - 'DeviceAsset not found in local assets: ${deviceAsset.assetId}', - ); + if (kDebugMode) { + debugPrint( + '[MIGRATION] Local asset not found in DeviceAsset: ${deviceAsset.assetId}', + ); + } }, onlySecond: (asset) { - debugPrint('Local asset not found in DeviceAsset: ${asset.assetId}'); + if (kDebugMode) { + debugPrint( + '[MIGRATION] Local asset not found in DeviceAsset: ${asset.assetId}', + ); + } }, ); - debugPrint("Total number of device assets migrated - ${toAdd.length}"); + + if (kDebugMode) { + debugPrint( + "[MIGRATION] Total number of device assets migrated - ${toAdd.length}", + ); + } + await db.writeTxn(() async { await db.deviceAssetEntitys.putAll(toAdd); }); From 0d60be3d87559a7ccb98e875b1b2502321e19284 Mon Sep 17 00:00:00 2001 From: github-actions <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 25 Apr 2025 03:07:06 +0000 Subject: [PATCH 06/31] chore: version v1.132.2 --- cli/package-lock.json | 6 +++--- cli/package.json | 2 +- docs/static/archived-versions.json | 4 ++++ e2e/package-lock.json | 8 ++++---- e2e/package.json | 2 +- mobile/android/fastlane/Fastfile | 4 ++-- mobile/ios/fastlane/Fastfile | 2 +- mobile/openapi/README.md | 2 +- mobile/pubspec.yaml | 2 +- open-api/immich-openapi-specs.json | 2 +- open-api/typescript-sdk/package-lock.json | 4 ++-- open-api/typescript-sdk/package.json | 2 +- open-api/typescript-sdk/src/fetch-client.ts | 2 +- server/package-lock.json | 4 ++-- server/package.json | 2 +- web/package-lock.json | 6 +++--- web/package.json | 2 +- 17 files changed, 30 insertions(+), 26 deletions(-) diff --git a/cli/package-lock.json b/cli/package-lock.json index d15358b26e..fe75b7ce8b 100644 --- a/cli/package-lock.json +++ b/cli/package-lock.json @@ -1,12 +1,12 @@ { "name": "@immich/cli", - "version": "2.2.63", + "version": "2.2.64", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@immich/cli", - "version": "2.2.63", + "version": "2.2.64", "license": "GNU Affero General Public License version 3", "dependencies": { "chokidar": "^4.0.3", @@ -54,7 +54,7 @@ }, "../open-api/typescript-sdk": { "name": "@immich/sdk", - "version": "1.132.1", + "version": "1.132.2", "dev": true, "license": "GNU Affero General Public License version 3", "dependencies": { diff --git a/cli/package.json b/cli/package.json index 8a742cd0d7..b47156f043 100644 --- a/cli/package.json +++ b/cli/package.json @@ -1,6 +1,6 @@ { "name": "@immich/cli", - "version": "2.2.63", + "version": "2.2.64", "description": "Command Line Interface (CLI) for Immich", "type": "module", "exports": "./dist/index.js", diff --git a/docs/static/archived-versions.json b/docs/static/archived-versions.json index 26eb3a2f9a..60a7436afb 100644 --- a/docs/static/archived-versions.json +++ b/docs/static/archived-versions.json @@ -1,4 +1,8 @@ [ + { + "label": "v1.132.2", + "url": "https://v1.132.2.archive.immich.app" + }, { "label": "v1.132.1", "url": "https://v1.132.1.archive.immich.app" diff --git a/e2e/package-lock.json b/e2e/package-lock.json index 7eb831b897..c5364d95f8 100644 --- a/e2e/package-lock.json +++ b/e2e/package-lock.json @@ -1,12 +1,12 @@ { "name": "immich-e2e", - "version": "1.132.1", + "version": "1.132.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "immich-e2e", - "version": "1.132.1", + "version": "1.132.2", "license": "GNU Affero General Public License version 3", "devDependencies": { "@eslint/eslintrc": "^3.1.0", @@ -44,7 +44,7 @@ }, "../cli": { "name": "@immich/cli", - "version": "2.2.63", + "version": "2.2.64", "dev": true, "license": "GNU Affero General Public License version 3", "dependencies": { @@ -93,7 +93,7 @@ }, "../open-api/typescript-sdk": { "name": "@immich/sdk", - "version": "1.132.1", + "version": "1.132.2", "dev": true, "license": "GNU Affero General Public License version 3", "dependencies": { diff --git a/e2e/package.json b/e2e/package.json index 3946f149d6..afbf06f34b 100644 --- a/e2e/package.json +++ b/e2e/package.json @@ -1,6 +1,6 @@ { "name": "immich-e2e", - "version": "1.132.1", + "version": "1.132.2", "description": "", "main": "index.js", "type": "module", diff --git a/mobile/android/fastlane/Fastfile b/mobile/android/fastlane/Fastfile index 13f3b0b850..013a0235d8 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" => 195, - "android.injected.version.name" => "1.132.1", + "android.injected.version.code" => 196, + "android.injected.version.name" => "1.132.2", } ) upload_to_play_store(skip_upload_apk: true, skip_upload_images: true, skip_upload_screenshots: true, aab: '../build/app/outputs/bundle/release/app-release.aab') diff --git a/mobile/ios/fastlane/Fastfile b/mobile/ios/fastlane/Fastfile index f454d24973..5d46f0ebcf 100644 --- a/mobile/ios/fastlane/Fastfile +++ b/mobile/ios/fastlane/Fastfile @@ -19,7 +19,7 @@ platform :ios do desc "iOS Release" lane :release do increment_version_number( - version_number: "1.132.1" + version_number: "1.132.2" ) increment_build_number( build_number: latest_testflight_build_number + 1, diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index 073ae932ce..2ff35f9537 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.132.1 +- API version: 1.132.2 - Generator version: 7.8.0 - Build package: org.openapitools.codegen.languages.DartClientCodegen diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index 07f56fb341..bda35258f9 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.132.1+195 +version: 1.132.2+196 environment: sdk: '>=3.3.0 <4.0.0' diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index 53709f3f0c..b242ade761 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -7656,7 +7656,7 @@ "info": { "title": "Immich", "description": "Immich API", - "version": "1.132.1", + "version": "1.132.2", "contact": {} }, "tags": [], diff --git a/open-api/typescript-sdk/package-lock.json b/open-api/typescript-sdk/package-lock.json index fe398ed2bb..249ffe9960 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.132.1", + "version": "1.132.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@immich/sdk", - "version": "1.132.1", + "version": "1.132.2", "license": "GNU Affero General Public License version 3", "dependencies": { "@oazapfts/runtime": "^1.0.2" diff --git a/open-api/typescript-sdk/package.json b/open-api/typescript-sdk/package.json index 4afce16f23..6194abe583 100644 --- a/open-api/typescript-sdk/package.json +++ b/open-api/typescript-sdk/package.json @@ -1,6 +1,6 @@ { "name": "@immich/sdk", - "version": "1.132.1", + "version": "1.132.2", "description": "Auto-generated TypeScript SDK for the Immich API", "type": "module", "main": "./build/index.js", diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index 01f476517e..5eca3c83eb 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.132.1 + * 1.132.2 * DO NOT MODIFY - This file has been generated using oazapfts. * See https://www.npmjs.com/package/oazapfts */ diff --git a/server/package-lock.json b/server/package-lock.json index cde8bd3a62..8214a6f874 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -1,12 +1,12 @@ { "name": "immich", - "version": "1.132.1", + "version": "1.132.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "immich", - "version": "1.132.1", + "version": "1.132.2", "hasInstallScript": true, "license": "GNU Affero General Public License version 3", "dependencies": { diff --git a/server/package.json b/server/package.json index f4435ced68..b3ce836f7c 100644 --- a/server/package.json +++ b/server/package.json @@ -1,6 +1,6 @@ { "name": "immich", - "version": "1.132.1", + "version": "1.132.2", "description": "", "author": "", "private": true, diff --git a/web/package-lock.json b/web/package-lock.json index 91d0adb573..d6fcb816f8 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -1,12 +1,12 @@ { "name": "immich-web", - "version": "1.132.1", + "version": "1.132.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "immich-web", - "version": "1.132.1", + "version": "1.132.2", "license": "GNU Affero General Public License version 3", "dependencies": { "@formatjs/icu-messageformat-parser": "^2.9.8", @@ -82,7 +82,7 @@ }, "../open-api/typescript-sdk": { "name": "@immich/sdk", - "version": "1.132.1", + "version": "1.132.2", "license": "GNU Affero General Public License version 3", "dependencies": { "@oazapfts/runtime": "^1.0.2" diff --git a/web/package.json b/web/package.json index ec53fd69d5..b4b9a80da1 100644 --- a/web/package.json +++ b/web/package.json @@ -1,6 +1,6 @@ { "name": "immich-web", - "version": "1.132.1", + "version": "1.132.2", "license": "GNU Affero General Public License version 3", "type": "module", "scripts": { From 1fe3c7b9b3ed8864fcd6c6b495ebae93c210995c Mon Sep 17 00:00:00 2001 From: Matthew Momjian <50788000+mmomjian@users.noreply.github.com> Date: Fri, 25 Apr 2025 00:07:42 -0400 Subject: [PATCH 07/31] fix(docs): priorities (Capitalization) (#17866) priorities --- docs/docs/guides/database-queries.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/docs/guides/database-queries.md b/docs/docs/guides/database-queries.md index 89a4f07bc0..209f673993 100644 --- a/docs/docs/guides/database-queries.md +++ b/docs/docs/guides/database-queries.md @@ -1,7 +1,7 @@ # Database Queries :::danger -Keep in mind that mucking around in the database might set the moon on fire. Avoid modifying the database directly when possible, and always have current backups. +Keep in mind that mucking around in the database might set the Moon on fire. Avoid modifying the database directly when possible, and always have current backups. ::: :::tip From 644defa4a1211cefecfea763712161f68c53790b Mon Sep 17 00:00:00 2001 From: Alex Date: Thu, 24 Apr 2025 23:14:40 -0500 Subject: [PATCH 08/31] chore: post release tasks (#17867) --- mobile/ios/Runner.xcodeproj/project.pbxproj | 15 +++++++++------ mobile/ios/Runner/Info.plist | 4 ++-- 2 files changed, 11 insertions(+), 8 deletions(-) diff --git a/mobile/ios/Runner.xcodeproj/project.pbxproj b/mobile/ios/Runner.xcodeproj/project.pbxproj index e4c25fefdf..09e362b057 100644 --- a/mobile/ios/Runner.xcodeproj/project.pbxproj +++ b/mobile/ios/Runner.xcodeproj/project.pbxproj @@ -541,7 +541,7 @@ CODE_SIGN_ENTITLEMENTS = Runner/RunnerProfile.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 202; + CURRENT_PROJECT_VERSION = 203; CUSTOM_GROUP_ID = group.app.immich.share; DEVELOPMENT_TEAM = 2F67MQ8R79; ENABLE_BITCODE = NO; @@ -685,7 +685,7 @@ CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 202; + CURRENT_PROJECT_VERSION = 203; CUSTOM_GROUP_ID = group.app.immich.share; DEVELOPMENT_TEAM = 2F67MQ8R79; ENABLE_BITCODE = NO; @@ -715,7 +715,7 @@ CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 202; + CURRENT_PROJECT_VERSION = 203; CUSTOM_GROUP_ID = group.app.immich.share; DEVELOPMENT_TEAM = 2F67MQ8R79; ENABLE_BITCODE = NO; @@ -748,7 +748,7 @@ CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 202; + CURRENT_PROJECT_VERSION = 203; CUSTOM_GROUP_ID = group.app.immich.share; DEVELOPMENT_TEAM = 2F67MQ8R79; ENABLE_USER_SCRIPT_SANDBOXING = YES; @@ -769,6 +769,7 @@ MTL_FAST_MATH = YES; PRODUCT_BUNDLE_IDENTIFIER = app.alextran.immich.vdebug.ShareExtension; PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE_SPECIFIER = ""; SKIP_INSTALL = YES; SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; SWIFT_EMIT_LOC_STRINGS = YES; @@ -791,7 +792,7 @@ CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 202; + CURRENT_PROJECT_VERSION = 203; CUSTOM_GROUP_ID = group.app.immich.share; DEVELOPMENT_TEAM = 2F67MQ8R79; ENABLE_USER_SCRIPT_SANDBOXING = YES; @@ -811,6 +812,7 @@ MTL_FAST_MATH = YES; PRODUCT_BUNDLE_IDENTIFIER = app.alextran.immich.ShareExtension; PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE_SPECIFIER = ""; SKIP_INSTALL = YES; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_VERSION = 5.0; @@ -831,7 +833,7 @@ CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 202; + CURRENT_PROJECT_VERSION = 203; CUSTOM_GROUP_ID = group.app.immich.share; DEVELOPMENT_TEAM = 2F67MQ8R79; ENABLE_USER_SCRIPT_SANDBOXING = YES; @@ -851,6 +853,7 @@ MTL_FAST_MATH = YES; PRODUCT_BUNDLE_IDENTIFIER = app.alextran.immich.profile.ShareExtension; PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE_SPECIFIER = ""; SKIP_INSTALL = YES; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_VERSION = 5.0; diff --git a/mobile/ios/Runner/Info.plist b/mobile/ios/Runner/Info.plist index 02fef7a965..fc259703c8 100644 --- a/mobile/ios/Runner/Info.plist +++ b/mobile/ios/Runner/Info.plist @@ -78,7 +78,7 @@ CFBundlePackageType APPL CFBundleShortVersionString - 1.132.0 + 1.132.2 CFBundleSignature ???? CFBundleURLTypes @@ -93,7 +93,7 @@ CFBundleVersion - 202 + 203 FLTEnableImpeller ITSAppUsesNonExemptEncryption From e822e3eca99285178407e206939070f530b80850 Mon Sep 17 00:00:00 2001 From: Martin Mikita Date: Fri, 25 Apr 2025 10:57:44 +0200 Subject: [PATCH 09/31] docs: update MapTiler name (#17863) --- docs/docs/guides/custom-map-styles.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/docs/guides/custom-map-styles.md b/docs/docs/guides/custom-map-styles.md index 3f52937432..1a61afc324 100644 --- a/docs/docs/guides/custom-map-styles.md +++ b/docs/docs/guides/custom-map-styles.md @@ -14,14 +14,14 @@ online generators you can use. 2. Paste the link to your JSON style in either the **Light Style** or **Dark Style**. (You can add different styles which will help make the map style more appropriate depending on whether you set **Immich** to Light or Dark mode.) 3. Save your selections. Reload the map, and enjoy your custom map style! -## Use Maptiler to build a custom style +## Use MapTiler to build a custom style -Customizing the map style can be done easily using Maptiler, if you do not want to write an entire JSON document by hand. +Customizing the map style can be done easily using MapTiler, if you do not want to write an entire JSON document by hand. 1. Create a free account at https://cloud.maptiler.com 2. Once logged in, you can either create a brand new map by clicking on **New Map**, selecting a starter map, and then clicking **Customize**, OR by selecting a **Standard Map** and customizing it from there. 3. The **editor** interface is self-explanatory. You can change colors, remove visible layers, or add optional layers (e.g., administrative, topo, hydro, etc.) in the composer. 4. Once you have your map composed, click on **Save** at the top right. Give it a unique name to save it to your account. -5. Next, **Publish** your style using the **Publish** button at the top right. This will deploy it to production, which means it is able to be exposed over the Internet. Maptiler will present an interactive side-by-side map with the original and your changes prior to publication.
![Maptiler Publication Settings](img/immich_map_styles_publish.webp) -6. Maptiler will warn you that changing the map will change it across all apps using the map. Since no apps are using the map yet, this is okay. -7. Clicking on the name of your new map at the top left will bring you to the item's **details** page. From here, copy the link to the JSON style under **Use vector style**. This link will automatically contain your personal API key to Maptiler. +5. Next, **Publish** your style using the **Publish** button at the top right. This will deploy it to production, which means it is able to be exposed over the Internet. MapTiler will present an interactive side-by-side map with the original and your changes prior to publication.
![MapTiler Publication Settings](img/immich_map_styles_publish.webp) +6. MapTiler will warn you that changing the map will change it across all apps using the map. Since no apps are using the map yet, this is okay. +7. Clicking on the name of your new map at the top left will bring you to the item's **details** page. From here, copy the link to the JSON style under **Use vector style**. This link will automatically contain your personal API key to MapTiler. From d0014bdf94feecf84b375499a8e48bb9fa47a8b3 Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Fri, 25 Apr 2025 08:36:31 -0400 Subject: [PATCH 10/31] refactor: event manager (#17862) * refactor: event manager * refactor: event manager --- e2e/src/web/specs/auth.e2e-spec.ts | 6 +-- .../navigation-bar/navigation-bar.svelte | 15 ++---- web/src/lib/stores/auth-manager.svelte.ts | 33 ++++++++++++ web/src/lib/stores/event-manager.svelte.ts | 54 +++++++++++++++++++ web/src/lib/stores/folders.svelte.ts | 5 ++ web/src/lib/stores/memory.store.svelte.ts | 5 ++ web/src/lib/stores/search.svelte.ts | 6 +++ web/src/lib/stores/user.store.ts | 3 ++ web/src/lib/stores/user.svelte.ts | 7 ++- web/src/lib/stores/websocket.ts | 5 +- web/src/lib/utils/auth.ts | 24 +-------- .../routes/auth/change-password/+page.svelte | 11 ++-- 12 files changed, 127 insertions(+), 47 deletions(-) create mode 100644 web/src/lib/stores/auth-manager.svelte.ts create mode 100644 web/src/lib/stores/event-manager.svelte.ts diff --git a/e2e/src/web/specs/auth.e2e-spec.ts b/e2e/src/web/specs/auth.e2e-spec.ts index e89f17a4e9..74bee64e0a 100644 --- a/e2e/src/web/specs/auth.e2e-spec.ts +++ b/e2e/src/web/specs/auth.e2e-spec.ts @@ -25,7 +25,7 @@ test.describe('Registration', () => { // login await expect(page).toHaveTitle(/Login/); - await page.goto('/auth/login'); + await page.goto('/auth/login?autoLaunch=0'); await page.getByLabel('Email').fill('admin@immich.app'); await page.getByLabel('Password').fill('password'); await page.getByRole('button', { name: 'Login' }).click(); @@ -59,7 +59,7 @@ test.describe('Registration', () => { await context.clearCookies(); // login - await page.goto('/auth/login'); + await page.goto('/auth/login?autoLaunch=0'); await page.getByLabel('Email').fill('user@immich.cloud'); await page.getByLabel('Password').fill('password'); await page.getByRole('button', { name: 'Login' }).click(); @@ -72,7 +72,7 @@ test.describe('Registration', () => { await page.getByRole('button', { name: 'Change password' }).click(); // login with new password - await expect(page).toHaveURL('/auth/login'); + await expect(page).toHaveURL('/auth/login?autoLaunch=0'); await page.getByLabel('Email').fill('user@immich.cloud'); await page.getByLabel('Password').fill('new-password'); await page.getByRole('button', { name: 'Login' }).click(); diff --git a/web/src/lib/components/shared-components/navigation-bar/navigation-bar.svelte b/web/src/lib/components/shared-components/navigation-bar/navigation-bar.svelte index f7f9b877f3..90f6b3c55b 100644 --- a/web/src/lib/components/shared-components/navigation-bar/navigation-bar.svelte +++ b/web/src/lib/components/shared-components/navigation-bar/navigation-bar.svelte @@ -10,11 +10,13 @@ import ImmichLogo from '$lib/components/shared-components/immich-logo.svelte'; import SearchBar from '$lib/components/shared-components/search-bar/search-bar.svelte'; import { AppRoute } from '$lib/constants'; + import { authManager } from '$lib/stores/auth-manager.svelte'; + import { mobileDevice } from '$lib/stores/mobile-device.svelte'; import { featureFlags } from '$lib/stores/server-config.store'; + import { sidebarStore } from '$lib/stores/sidebar.svelte'; import { user } from '$lib/stores/user.store'; import { userInteraction } from '$lib/stores/user.svelte'; - import { handleLogout } from '$lib/utils/auth'; - import { getAboutInfo, logout, type ServerAboutResponseDto } from '@immich/sdk'; + import { getAboutInfo, type ServerAboutResponseDto } from '@immich/sdk'; import { Button, IconButton } from '@immich/ui'; import { mdiHelpCircleOutline, mdiMagnify, mdiMenu, mdiTrayArrowUp } from '@mdi/js'; import { onMount } from 'svelte'; @@ -23,8 +25,6 @@ import ThemeButton from '../theme-button.svelte'; import UserAvatar from '../user-avatar.svelte'; import AccountInfoPanel from './account-info-panel.svelte'; - import { sidebarStore } from '$lib/stores/sidebar.svelte'; - import { mobileDevice } from '$lib/stores/mobile-device.svelte'; interface Props { showUploadButton?: boolean; @@ -38,11 +38,6 @@ let shouldShowHelpPanel = $state(false); let innerWidth: number = $state(0); - const onLogout = async () => { - const { redirectUri } = await logout(); - await handleLogout(redirectUri); - }; - let info: ServerAboutResponseDto | undefined = $state(); onMount(async () => { @@ -183,7 +178,7 @@ {/if} {#if shouldShowAccountInfoPanel} - + authManager.logout()} /> {/if} diff --git a/web/src/lib/stores/auth-manager.svelte.ts b/web/src/lib/stores/auth-manager.svelte.ts new file mode 100644 index 0000000000..72c966df0b --- /dev/null +++ b/web/src/lib/stores/auth-manager.svelte.ts @@ -0,0 +1,33 @@ +import { goto } from '$app/navigation'; +import { AppRoute } from '$lib/constants'; +import { eventManager } from '$lib/stores/event-manager.svelte'; +import { logout } from '@immich/sdk'; + +class AuthManager { + async logout() { + let redirectUri; + + try { + const response = await logout(); + if (response.redirectUri) { + redirectUri = response.redirectUri; + } + } catch (error) { + console.log('Error logging out:', error); + } + + redirectUri = redirectUri ?? AppRoute.AUTH_LOGIN; + + try { + if (redirectUri.startsWith('/')) { + await goto(redirectUri); + } else { + globalThis.location.href = redirectUri; + } + } finally { + eventManager.emit('auth.logout'); + } + } +} + +export const authManager = new AuthManager(); diff --git a/web/src/lib/stores/event-manager.svelte.ts b/web/src/lib/stores/event-manager.svelte.ts new file mode 100644 index 0000000000..09e9b45c3c --- /dev/null +++ b/web/src/lib/stores/event-manager.svelte.ts @@ -0,0 +1,54 @@ +type Listener, K extends keyof EventMap> = (...params: EventMap[K]) => void; + +class EventManager> { + private listeners: { + [K in keyof EventMap]?: { + listener: Listener; + once?: boolean; + }[]; + } = {}; + + on(key: T, listener: (...params: EventMap[T]) => void) { + return this.addListener(key, listener, false); + } + + once(key: T, listener: (...params: EventMap[T]) => void) { + return this.addListener(key, listener, true); + } + + off(key: K, listener: Listener) { + if (this.listeners[key]) { + this.listeners[key] = this.listeners[key].filter((item) => item.listener !== listener); + } + + return this; + } + + emit(key: T, ...params: EventMap[T]) { + if (!this.listeners[key]) { + return; + } + + for (const { listener } of this.listeners[key]) { + listener(...params); + } + + // remove one time listeners + this.listeners[key] = this.listeners[key].filter((item) => !item.once); + } + + private addListener(key: T, listener: (...params: EventMap[T]) => void, once: boolean) { + if (!this.listeners[key]) { + this.listeners[key] = []; + } + + this.listeners[key].push({ listener, once }); + + return this; + } +} + +export const eventManager = new EventManager<{ + 'user.login': []; + 'auth.logout': []; +}>(); diff --git a/web/src/lib/stores/folders.svelte.ts b/web/src/lib/stores/folders.svelte.ts index fb59687a38..c6fc7808b2 100644 --- a/web/src/lib/stores/folders.svelte.ts +++ b/web/src/lib/stores/folders.svelte.ts @@ -1,3 +1,4 @@ +import { eventManager } from '$lib/stores/event-manager.svelte'; import { getAssetsByOriginalPath, getUniqueOriginalPaths, @@ -16,6 +17,10 @@ class FoldersStore { uniquePaths = $state([]); assets = $state({}); + constructor() { + eventManager.on('auth.logout', () => this.clearCache()); + } + async fetchUniquePaths() { if (this.initialized) { return; diff --git a/web/src/lib/stores/memory.store.svelte.ts b/web/src/lib/stores/memory.store.svelte.ts index 7173b43d06..ef3f87a3aa 100644 --- a/web/src/lib/stores/memory.store.svelte.ts +++ b/web/src/lib/stores/memory.store.svelte.ts @@ -1,3 +1,4 @@ +import { eventManager } from '$lib/stores/event-manager.svelte'; import { asLocalTimeISO } from '$lib/utils/date-time'; import { type AssetResponseDto, @@ -24,6 +25,10 @@ export type MemoryAsset = MemoryIndex & { }; class MemoryStoreSvelte { + constructor() { + eventManager.on('auth.logout', () => this.clearCache()); + } + memories = $state([]); private initialized = false; private memoryAssets = $derived.by(() => { diff --git a/web/src/lib/stores/search.svelte.ts b/web/src/lib/stores/search.svelte.ts index 7d012922ca..f334f53460 100644 --- a/web/src/lib/stores/search.svelte.ts +++ b/web/src/lib/stores/search.svelte.ts @@ -1,7 +1,13 @@ +import { eventManager } from '$lib/stores/event-manager.svelte'; + class SearchStore { savedSearchTerms = $state([]); isSearchEnabled = $state(false); + constructor() { + eventManager.on('auth.logout', () => this.clearCache()); + } + clearCache() { this.savedSearchTerms = []; this.isSearchEnabled = false; diff --git a/web/src/lib/stores/user.store.ts b/web/src/lib/stores/user.store.ts index 5bffc08b80..fe2288c252 100644 --- a/web/src/lib/stores/user.store.ts +++ b/web/src/lib/stores/user.store.ts @@ -1,3 +1,4 @@ +import { eventManager } from '$lib/stores/event-manager.svelte'; import { purchaseStore } from '$lib/stores/purchase.store'; import { type UserAdminResponseDto, type UserPreferencesResponseDto } from '@immich/sdk'; import { writable } from 'svelte/store'; @@ -14,3 +15,5 @@ export const resetSavedUser = () => { preferences.set(undefined as unknown as UserPreferencesResponseDto); purchaseStore.setPurchaseStatus(false); }; + +eventManager.on('auth.logout', () => resetSavedUser()); diff --git a/web/src/lib/stores/user.svelte.ts b/web/src/lib/stores/user.svelte.ts index 71b2cdd847..093d90e4b5 100644 --- a/web/src/lib/stores/user.svelte.ts +++ b/web/src/lib/stores/user.svelte.ts @@ -1,3 +1,4 @@ +import { eventManager } from '$lib/stores/event-manager.svelte'; import type { AlbumResponseDto, ServerAboutResponseDto, @@ -19,8 +20,10 @@ const defaultUserInteraction: UserInteractions = { serverInfo: undefined, }; -export const resetUserInteraction = () => { +export const userInteraction = $state(defaultUserInteraction); + +const reset = () => { Object.assign(userInteraction, defaultUserInteraction); }; -export const userInteraction = $state(defaultUserInteraction); +eventManager.on('auth.logout', () => reset()); diff --git a/web/src/lib/stores/websocket.ts b/web/src/lib/stores/websocket.ts index d398ca52a9..90228a5cbd 100644 --- a/web/src/lib/stores/websocket.ts +++ b/web/src/lib/stores/websocket.ts @@ -1,5 +1,4 @@ -import { AppRoute } from '$lib/constants'; -import { handleLogout } from '$lib/utils/auth'; +import { authManager } from '$lib/stores/auth-manager.svelte'; import { createEventEmitter } from '$lib/utils/eventemitter'; import type { AssetResponseDto, ServerVersionResponseDto } from '@immich/sdk'; import { io, type Socket } from 'socket.io-client'; @@ -50,7 +49,7 @@ websocket .on('disconnect', () => websocketStore.connected.set(false)) .on('on_server_version', (serverVersion) => websocketStore.serverVersion.set(serverVersion)) .on('on_new_release', (releaseVersion) => websocketStore.release.set(releaseVersion)) - .on('on_session_delete', () => handleLogout(AppRoute.AUTH_LOGIN)) + .on('on_session_delete', () => authManager.logout()) .on('connect_error', (e) => console.log('Websocket Connect Error', e)); export const openWebsocketConnection = () => { diff --git a/web/src/lib/utils/auth.ts b/web/src/lib/utils/auth.ts index 22b92dd988..9b78c345e2 100644 --- a/web/src/lib/utils/auth.ts +++ b/web/src/lib/utils/auth.ts @@ -1,11 +1,7 @@ import { browser } from '$app/environment'; -import { goto } from '$app/navigation'; -import { foldersStore } from '$lib/stores/folders.svelte'; -import { memoryStore } from '$lib/stores/memory.store.svelte'; import { purchaseStore } from '$lib/stores/purchase.store'; -import { searchStore } from '$lib/stores/search.svelte'; -import { preferences as preferences$, resetSavedUser, user as user$ } from '$lib/stores/user.store'; -import { resetUserInteraction, userInteraction } from '$lib/stores/user.svelte'; +import { preferences as preferences$, user as user$ } from '$lib/stores/user.store'; +import { userInteraction } from '$lib/stores/user.svelte'; import { getAboutInfo, getMyPreferences, getMyUser, getStorage } from '@immich/sdk'; import { redirect } from '@sveltejs/kit'; import { DateTime } from 'luxon'; @@ -91,19 +87,3 @@ export const getAccountAge = (): number => { return Number(accountAge); }; - -export const handleLogout = async (redirectUri: string) => { - try { - if (redirectUri.startsWith('/')) { - await goto(redirectUri); - } else { - globalThis.location.href = redirectUri; - } - } finally { - resetSavedUser(); - resetUserInteraction(); - foldersStore.clearCache(); - memoryStore.clearCache(); - searchStore.clearCache(); - } -}; diff --git a/web/src/routes/auth/change-password/+page.svelte b/web/src/routes/auth/change-password/+page.svelte index 33d354552e..16a6ffc677 100644 --- a/web/src/routes/auth/change-password/+page.svelte +++ b/web/src/routes/auth/change-password/+page.svelte @@ -1,9 +1,8 @@ From d85ef19bfcc5b82b870d6807564e1d0610d68a9a Mon Sep 17 00:00:00 2001 From: Yaros Date: Fri, 25 Apr 2025 17:38:30 +0200 Subject: [PATCH 11/31] fix(mobile): revert get location on app start (#17882) --- mobile/lib/pages/library/library.page.dart | 97 +++++++++------------- 1 file changed, 40 insertions(+), 57 deletions(-) diff --git a/mobile/lib/pages/library/library.page.dart b/mobile/lib/pages/library/library.page.dart index c08a1c715d..1dc336d204 100644 --- a/mobile/lib/pages/library/library.page.dart +++ b/mobile/lib/pages/library/library.page.dart @@ -1,7 +1,6 @@ import 'package:auto_route/auto_route.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; -import 'package:geolocator/geolocator.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/domain/models/user.model.dart'; import 'package:immich_mobile/extensions/asyncvalue_extensions.dart'; @@ -13,7 +12,6 @@ import 'package:immich_mobile/providers/server_info.provider.dart'; import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/services/api.service.dart'; import 'package:immich_mobile/utils/image_url_builder.dart'; -import 'package:immich_mobile/utils/map_utils.dart'; import 'package:immich_mobile/widgets/album/album_thumbnail_card.dart'; import 'package:immich_mobile/widgets/common/immich_app_bar.dart'; import 'package:immich_mobile/widgets/common/user_avatar.dart'; @@ -357,66 +355,51 @@ class PlacesCollectionCard extends StatelessWidget { final widthFactor = isTablet ? 0.25 : 0.5; final size = context.width * widthFactor - 20.0; - return FutureBuilder<(Position?, LocationPermission?)>( - future: MapUtils.checkPermAndGetLocation( - context: context, - silent: true, + return GestureDetector( + onTap: () => context.pushRoute( + PlacesCollectionRoute( + currentLocation: null, + ), ), - builder: (context, snapshot) { - var position = snapshot.data?.$1; - return GestureDetector( - onTap: () => context.pushRoute( - PlacesCollectionRoute( - currentLocation: position != null - ? LatLng(position.latitude, position.longitude) - : null, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + height: size, + width: size, + child: DecoratedBox( + decoration: BoxDecoration( + borderRadius: const BorderRadius.all(Radius.circular(20)), + color: + context.colorScheme.secondaryContainer.withAlpha(100), + ), + child: IgnorePointer( + child: MapThumbnail( + zoom: 8, + centre: const LatLng( + 21.44950, + -157.91959, + ), + showAttribution: false, + themeMode: context.isDarkTheme + ? ThemeMode.dark + : ThemeMode.light, + ), + ), ), ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - SizedBox( - height: size, - width: size, - child: DecoratedBox( - decoration: BoxDecoration( - borderRadius: - const BorderRadius.all(Radius.circular(20)), - color: context.colorScheme.secondaryContainer - .withAlpha(100), - ), - child: IgnorePointer( - child: snapshot.connectionState == - ConnectionState.waiting - ? const Center(child: CircularProgressIndicator()) - : MapThumbnail( - zoom: 8, - centre: LatLng( - position?.latitude ?? 21.44950, - position?.longitude ?? -157.91959, - ), - showAttribution: false, - themeMode: context.isDarkTheme - ? ThemeMode.dark - : ThemeMode.light, - ), - ), - ), + Padding( + padding: const EdgeInsets.all(8.0), + child: Text( + 'places'.tr(), + style: context.textTheme.titleSmall?.copyWith( + color: context.colorScheme.onSurface, + fontWeight: FontWeight.w500, ), - Padding( - padding: const EdgeInsets.all(8.0), - child: Text( - 'places'.tr(), - style: context.textTheme.titleSmall?.copyWith( - color: context.colorScheme.onSurface, - fontWeight: FontWeight.w500, - ), - ), - ), - ], + ), ), - ); - }, + ], + ), ); }, ); From a1f8150c30c09e163f85738ee13e55226281048f Mon Sep 17 00:00:00 2001 From: Alex Date: Fri, 25 Apr 2025 14:39:14 -0500 Subject: [PATCH 12/31] fix: Authelia OAuth code verifier value contains invalid characters (#17886) * fix(mobile): Authelia OAuth code verifier value contains invalid characters * Refactor * Refactoring with Jason * Refactoring with Jason --- .../lib/widgets/forms/login/login_form.dart | 25 ++++++++++++++++--- 1 file changed, 22 insertions(+), 3 deletions(-) diff --git a/mobile/lib/widgets/forms/login/login_form.dart b/mobile/lib/widgets/forms/login/login_form.dart index 3433648e9f..5374d1ef33 100644 --- a/mobile/lib/widgets/forms/login/login_form.dart +++ b/mobile/lib/widgets/forms/login/login_form.dart @@ -207,9 +207,27 @@ class LoginForm extends HookConsumerWidget { } String generateRandomString(int length) { + const chars = + 'AaBbCcDdEeFfGgHhIiJjKkLlMmNnOoPpQqRrSsTtUuVvWwXxYyZz1234567890'; final random = Random.secure(); - return base64Url - .encode(List.generate(32, (i) => random.nextInt(256))); + return String.fromCharCodes( + Iterable.generate( + length, + (_) => chars.codeUnitAt(random.nextInt(chars.length)), + ), + ); + } + + List randomBytes(int length) { + final random = Random.secure(); + return List.generate(length, (i) => random.nextInt(256)); + } + + /// Per specification, the code verifier must be 43-128 characters long + /// and consist of characters [A-Z, a-z, 0-9, "-", ".", "_", "~"] + /// https://datatracker.ietf.org/doc/html/rfc7636#section-4.1 + String randomCodeVerifier() { + return base64Url.encode(randomBytes(42)); } Future generatePKCECodeChallenge(String codeVerifier) async { @@ -223,7 +241,8 @@ class LoginForm extends HookConsumerWidget { String? oAuthServerUrl; final state = generateRandomString(32); - final codeVerifier = generateRandomString(64); + + final codeVerifier = randomCodeVerifier(); final codeChallenge = await generatePKCECodeChallenge(codeVerifier); try { From 02994883fe3f3972323bb6759d0170a4062f5236 Mon Sep 17 00:00:00 2001 From: github-actions <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 25 Apr 2025 19:44:05 +0000 Subject: [PATCH 13/31] chore: version v1.132.3 --- cli/package-lock.json | 6 +++--- cli/package.json | 2 +- docs/static/archived-versions.json | 4 ++++ e2e/package-lock.json | 8 ++++---- e2e/package.json | 2 +- mobile/android/fastlane/Fastfile | 4 ++-- mobile/ios/fastlane/Fastfile | 2 +- mobile/openapi/README.md | 2 +- mobile/pubspec.yaml | 2 +- open-api/immich-openapi-specs.json | 2 +- open-api/typescript-sdk/package-lock.json | 4 ++-- open-api/typescript-sdk/package.json | 2 +- open-api/typescript-sdk/src/fetch-client.ts | 2 +- server/package-lock.json | 4 ++-- server/package.json | 2 +- web/package-lock.json | 6 +++--- web/package.json | 2 +- 17 files changed, 30 insertions(+), 26 deletions(-) diff --git a/cli/package-lock.json b/cli/package-lock.json index fe75b7ce8b..fe428b2714 100644 --- a/cli/package-lock.json +++ b/cli/package-lock.json @@ -1,12 +1,12 @@ { "name": "@immich/cli", - "version": "2.2.64", + "version": "2.2.65", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@immich/cli", - "version": "2.2.64", + "version": "2.2.65", "license": "GNU Affero General Public License version 3", "dependencies": { "chokidar": "^4.0.3", @@ -54,7 +54,7 @@ }, "../open-api/typescript-sdk": { "name": "@immich/sdk", - "version": "1.132.2", + "version": "1.132.3", "dev": true, "license": "GNU Affero General Public License version 3", "dependencies": { diff --git a/cli/package.json b/cli/package.json index b47156f043..b2d29d6bb9 100644 --- a/cli/package.json +++ b/cli/package.json @@ -1,6 +1,6 @@ { "name": "@immich/cli", - "version": "2.2.64", + "version": "2.2.65", "description": "Command Line Interface (CLI) for Immich", "type": "module", "exports": "./dist/index.js", diff --git a/docs/static/archived-versions.json b/docs/static/archived-versions.json index 60a7436afb..1e45c7a696 100644 --- a/docs/static/archived-versions.json +++ b/docs/static/archived-versions.json @@ -1,4 +1,8 @@ [ + { + "label": "v1.132.3", + "url": "https://v1.132.3.archive.immich.app" + }, { "label": "v1.132.2", "url": "https://v1.132.2.archive.immich.app" diff --git a/e2e/package-lock.json b/e2e/package-lock.json index c5364d95f8..af8117da2c 100644 --- a/e2e/package-lock.json +++ b/e2e/package-lock.json @@ -1,12 +1,12 @@ { "name": "immich-e2e", - "version": "1.132.2", + "version": "1.132.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "immich-e2e", - "version": "1.132.2", + "version": "1.132.3", "license": "GNU Affero General Public License version 3", "devDependencies": { "@eslint/eslintrc": "^3.1.0", @@ -44,7 +44,7 @@ }, "../cli": { "name": "@immich/cli", - "version": "2.2.64", + "version": "2.2.65", "dev": true, "license": "GNU Affero General Public License version 3", "dependencies": { @@ -93,7 +93,7 @@ }, "../open-api/typescript-sdk": { "name": "@immich/sdk", - "version": "1.132.2", + "version": "1.132.3", "dev": true, "license": "GNU Affero General Public License version 3", "dependencies": { diff --git a/e2e/package.json b/e2e/package.json index afbf06f34b..c4da9b8a4a 100644 --- a/e2e/package.json +++ b/e2e/package.json @@ -1,6 +1,6 @@ { "name": "immich-e2e", - "version": "1.132.2", + "version": "1.132.3", "description": "", "main": "index.js", "type": "module", diff --git a/mobile/android/fastlane/Fastfile b/mobile/android/fastlane/Fastfile index 013a0235d8..a0b08bb316 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" => 196, - "android.injected.version.name" => "1.132.2", + "android.injected.version.code" => 197, + "android.injected.version.name" => "1.132.3", } ) upload_to_play_store(skip_upload_apk: true, skip_upload_images: true, skip_upload_screenshots: true, aab: '../build/app/outputs/bundle/release/app-release.aab') diff --git a/mobile/ios/fastlane/Fastfile b/mobile/ios/fastlane/Fastfile index 5d46f0ebcf..cca3ac33b3 100644 --- a/mobile/ios/fastlane/Fastfile +++ b/mobile/ios/fastlane/Fastfile @@ -19,7 +19,7 @@ platform :ios do desc "iOS Release" lane :release do increment_version_number( - version_number: "1.132.2" + version_number: "1.132.3" ) increment_build_number( build_number: latest_testflight_build_number + 1, diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index 2ff35f9537..4f9b062ba6 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.132.2 +- API version: 1.132.3 - Generator version: 7.8.0 - Build package: org.openapitools.codegen.languages.DartClientCodegen diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index bda35258f9..08e9661d58 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.132.2+196 +version: 1.132.3+197 environment: sdk: '>=3.3.0 <4.0.0' diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index b242ade761..f2851d7cf1 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -7656,7 +7656,7 @@ "info": { "title": "Immich", "description": "Immich API", - "version": "1.132.2", + "version": "1.132.3", "contact": {} }, "tags": [], diff --git a/open-api/typescript-sdk/package-lock.json b/open-api/typescript-sdk/package-lock.json index 249ffe9960..c102f594cf 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.132.2", + "version": "1.132.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@immich/sdk", - "version": "1.132.2", + "version": "1.132.3", "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 6194abe583..70f76512b4 100644 --- a/open-api/typescript-sdk/package.json +++ b/open-api/typescript-sdk/package.json @@ -1,6 +1,6 @@ { "name": "@immich/sdk", - "version": "1.132.2", + "version": "1.132.3", "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 5eca3c83eb..51e17c08ac 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.132.2 + * 1.132.3 * DO NOT MODIFY - This file has been generated using oazapfts. * See https://www.npmjs.com/package/oazapfts */ diff --git a/server/package-lock.json b/server/package-lock.json index 8214a6f874..b1fdfa1f9d 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -1,12 +1,12 @@ { "name": "immich", - "version": "1.132.2", + "version": "1.132.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "immich", - "version": "1.132.2", + "version": "1.132.3", "hasInstallScript": true, "license": "GNU Affero General Public License version 3", "dependencies": { diff --git a/server/package.json b/server/package.json index b3ce836f7c..f68ba71564 100644 --- a/server/package.json +++ b/server/package.json @@ -1,6 +1,6 @@ { "name": "immich", - "version": "1.132.2", + "version": "1.132.3", "description": "", "author": "", "private": true, diff --git a/web/package-lock.json b/web/package-lock.json index d6fcb816f8..37f944d3bb 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -1,12 +1,12 @@ { "name": "immich-web", - "version": "1.132.2", + "version": "1.132.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "immich-web", - "version": "1.132.2", + "version": "1.132.3", "license": "GNU Affero General Public License version 3", "dependencies": { "@formatjs/icu-messageformat-parser": "^2.9.8", @@ -82,7 +82,7 @@ }, "../open-api/typescript-sdk": { "name": "@immich/sdk", - "version": "1.132.2", + "version": "1.132.3", "license": "GNU Affero General Public License version 3", "dependencies": { "@oazapfts/runtime": "^1.0.2" diff --git a/web/package.json b/web/package.json index b4b9a80da1..c32e7b04a8 100644 --- a/web/package.json +++ b/web/package.json @@ -1,6 +1,6 @@ { "name": "immich-web", - "version": "1.132.2", + "version": "1.132.3", "license": "GNU Affero General Public License version 3", "type": "module", "scripts": { From 3858973be5a267b2b4ef0d5de832156c967807c5 Mon Sep 17 00:00:00 2001 From: Alex Date: Sun, 27 Apr 2025 23:00:40 -0500 Subject: [PATCH 14/31] chore(mobile): translation (#17920) --- mobile/lib/pages/onboarding/permission_onboarding.page.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mobile/lib/pages/onboarding/permission_onboarding.page.dart b/mobile/lib/pages/onboarding/permission_onboarding.page.dart index a6768cc207..b0a1b34b06 100644 --- a/mobile/lib/pages/onboarding/permission_onboarding.page.dart +++ b/mobile/lib/pages/onboarding/permission_onboarding.page.dart @@ -44,7 +44,7 @@ class PermissionOnboardingPage extends HookConsumerWidget { } }), child: const Text( - 'grant_permission', + 'continue', ).tr(), ), ], From 205260d31c72c8782182e978607423bf50e0dc44 Mon Sep 17 00:00:00 2001 From: Alex Date: Sun, 27 Apr 2025 23:02:03 -0500 Subject: [PATCH 15/31] chore: post release tasks (#17895) --- mobile/ios/Runner.xcodeproj/project.pbxproj | 14 ++++++++------ mobile/ios/Runner/Info.plist | 4 ++-- mobile/ios/fastlane/Fastfile | 3 +++ 3 files changed, 13 insertions(+), 8 deletions(-) diff --git a/mobile/ios/Runner.xcodeproj/project.pbxproj b/mobile/ios/Runner.xcodeproj/project.pbxproj index 09e362b057..744ddc053b 100644 --- a/mobile/ios/Runner.xcodeproj/project.pbxproj +++ b/mobile/ios/Runner.xcodeproj/project.pbxproj @@ -261,9 +261,11 @@ 97C146ED1CF9000F007C117D = { CreatedOnToolsVersion = 7.3.1; LastSwiftMigration = 1100; + ProvisioningStyle = Automatic; }; FAC6F88F2D287C890078CB2F = { CreatedOnToolsVersion = 16.0; + ProvisioningStyle = Automatic; }; }; }; @@ -541,7 +543,7 @@ CODE_SIGN_ENTITLEMENTS = Runner/RunnerProfile.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 203; + CURRENT_PROJECT_VERSION = 205; CUSTOM_GROUP_ID = group.app.immich.share; DEVELOPMENT_TEAM = 2F67MQ8R79; ENABLE_BITCODE = NO; @@ -685,7 +687,7 @@ CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 203; + CURRENT_PROJECT_VERSION = 205; CUSTOM_GROUP_ID = group.app.immich.share; DEVELOPMENT_TEAM = 2F67MQ8R79; ENABLE_BITCODE = NO; @@ -715,7 +717,7 @@ CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 203; + CURRENT_PROJECT_VERSION = 205; CUSTOM_GROUP_ID = group.app.immich.share; DEVELOPMENT_TEAM = 2F67MQ8R79; ENABLE_BITCODE = NO; @@ -748,7 +750,7 @@ CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 203; + CURRENT_PROJECT_VERSION = 205; CUSTOM_GROUP_ID = group.app.immich.share; DEVELOPMENT_TEAM = 2F67MQ8R79; ENABLE_USER_SCRIPT_SANDBOXING = YES; @@ -792,7 +794,7 @@ CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 203; + CURRENT_PROJECT_VERSION = 205; CUSTOM_GROUP_ID = group.app.immich.share; DEVELOPMENT_TEAM = 2F67MQ8R79; ENABLE_USER_SCRIPT_SANDBOXING = YES; @@ -833,7 +835,7 @@ CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 203; + CURRENT_PROJECT_VERSION = 205; CUSTOM_GROUP_ID = group.app.immich.share; DEVELOPMENT_TEAM = 2F67MQ8R79; ENABLE_USER_SCRIPT_SANDBOXING = YES; diff --git a/mobile/ios/Runner/Info.plist b/mobile/ios/Runner/Info.plist index fc259703c8..38394f0f1b 100644 --- a/mobile/ios/Runner/Info.plist +++ b/mobile/ios/Runner/Info.plist @@ -78,7 +78,7 @@ CFBundlePackageType APPL CFBundleShortVersionString - 1.132.2 + 1.132.3 CFBundleSignature ???? CFBundleURLTypes @@ -93,7 +93,7 @@ CFBundleVersion - 203 + 205 FLTEnableImpeller ITSAppUsesNonExemptEncryption diff --git a/mobile/ios/fastlane/Fastfile b/mobile/ios/fastlane/Fastfile index cca3ac33b3..3306fef1e2 100644 --- a/mobile/ios/fastlane/Fastfile +++ b/mobile/ios/fastlane/Fastfile @@ -18,6 +18,9 @@ default_platform(:ios) platform :ios do desc "iOS Release" lane :release do + enable_automatic_code_signing( + path: "./Runner.xcodeproj", + ) increment_version_number( version_number: "1.132.3" ) From 85ac0512a6b64244bd2fd475ecd6e17d5e13d4f9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andreas=20Tollk=C3=B6tter?= <1518021+atollk@users.noreply.github.com> Date: Mon, 28 Apr 2025 15:53:26 +0200 Subject: [PATCH 16/31] fix(web): Make date-time formatting follow locale (#17899) * fixed missing $locale parameter to .toLocaleString * Remove unused types and functions in timeline-util * remove unused export * re-enable export because it is needed for tests * format --- .../memory-page/memory-viewer.svelte | 4 +- .../server-about-modal.svelte | 16 +++-- .../user-settings-page/device-card.svelte | 4 +- web/src/lib/utils/byte-units.ts | 1 + web/src/lib/utils/thumbnail-util.ts | 5 +- web/src/lib/utils/timeline-util.ts | 71 +++---------------- 6 files changed, 31 insertions(+), 70 deletions(-) diff --git a/web/src/lib/components/memory-page/memory-viewer.svelte b/web/src/lib/components/memory-page/memory-viewer.svelte index e39a3cfa74..45aaf85b67 100644 --- a/web/src/lib/components/memory-page/memory-viewer.svelte +++ b/web/src/lib/components/memory-page/memory-viewer.svelte @@ -544,7 +544,9 @@

- {fromLocalDateTime(current.memory.assets[0].localDateTime).toLocaleString(DateTime.DATE_FULL)} + {fromLocalDateTime(current.memory.assets[0].localDateTime).toLocaleString(DateTime.DATE_FULL, { + locale: $locale, + })}

{current.asset.exifInfo?.city || ''} diff --git a/web/src/lib/components/shared-components/server-about-modal.svelte b/web/src/lib/components/shared-components/server-about-modal.svelte index cf935cd314..1284bb126d 100644 --- a/web/src/lib/components/shared-components/server-about-modal.svelte +++ b/web/src/lib/components/shared-components/server-about-modal.svelte @@ -6,6 +6,7 @@ import { t } from 'svelte-i18n'; import { mdiAlert } from '@mdi/js'; import Icon from '$lib/components/elements/icon.svelte'; + import { locale } from '$lib/stores/preferences.store'; interface Props { onClose: () => void; @@ -177,16 +178,19 @@ {$t('version_history_item', { values: { version: item.version, - date: createdAt.toLocaleString({ - month: 'short', - day: 'numeric', - year: 'numeric', - }), + date: createdAt.toLocaleString( + { + month: 'short', + day: 'numeric', + year: 'numeric', + }, + { locale: $locale }, + ), }, })} diff --git a/web/src/lib/components/user-settings-page/device-card.svelte b/web/src/lib/components/user-settings-page/device-card.svelte index 5b70b006be..74e6579dd0 100644 --- a/web/src/lib/components/user-settings-page/device-card.svelte +++ b/web/src/lib/components/user-settings-page/device-card.svelte @@ -64,7 +64,9 @@ {DateTime.fromISO(device.updatedAt, { locale: $locale }).toRelativeCalendar(options)} - - {DateTime.fromISO(device.updatedAt, { locale: $locale }).toLocaleString(DateTime.DATETIME_MED)} + {DateTime.fromISO(device.updatedAt, { locale: $locale }).toLocaleString(DateTime.DATETIME_MED, { + locale: $locale, + })}

diff --git a/web/src/lib/utils/byte-units.ts b/web/src/lib/utils/byte-units.ts index dae44009e2..218e22f671 100644 --- a/web/src/lib/utils/byte-units.ts +++ b/web/src/lib/utils/byte-units.ts @@ -34,6 +34,7 @@ export function getBytesWithUnit(bytes: number, maxPrecision = 1): [number, Byte * * de: `1,5 KiB` * * @param bytes number of bytes + * @param locale locale to use, default is `navigator.language` * @param maxPrecision maximum number of decimal places, default is `1` * @returns localized bytes with unit as string */ diff --git a/web/src/lib/utils/thumbnail-util.ts b/web/src/lib/utils/thumbnail-util.ts index a53691e716..f0043790ea 100644 --- a/web/src/lib/utils/thumbnail-util.ts +++ b/web/src/lib/utils/thumbnail-util.ts @@ -1,6 +1,7 @@ +import { locale } from '$lib/stores/preferences.store'; import { AssetTypeEnum, type AssetResponseDto } from '@immich/sdk'; import { t } from 'svelte-i18n'; -import { derived } from 'svelte/store'; +import { derived, get } from 'svelte/store'; import { fromLocalDateTime } from './timeline-util'; /** @@ -43,7 +44,7 @@ export const getAltText = derived(t, ($t) => { return asset.exifInfo.description; } - const date = fromLocalDateTime(asset.localDateTime).toLocaleString({ dateStyle: 'long' }); + const date = fromLocalDateTime(asset.localDateTime).toLocaleString({ dateStyle: 'long' }, { locale: get(locale) }); const hasPlace = !!asset.exifInfo?.city && !!asset.exifInfo?.country; const names = asset.people?.filter((p) => p.name).map((p) => p.name) ?? []; const peopleCount = names.length; diff --git a/web/src/lib/utils/timeline-util.ts b/web/src/lib/utils/timeline-util.ts index f40e2bc3eb..21a7d23953 100644 --- a/web/src/lib/utils/timeline-util.ts +++ b/web/src/lib/utils/timeline-util.ts @@ -1,41 +1,13 @@ -import type { AssetBucket } from '$lib/stores/assets-store.svelte'; import { locale } from '$lib/stores/preferences.store'; -import { type CommonJustifiedLayout } from '$lib/utils/layout-utils'; - -import type { AssetResponseDto } from '@immich/sdk'; import { memoize } from 'lodash-es'; import { DateTime, type LocaleOptions } from 'luxon'; import { get } from 'svelte/store'; -export type DateGroup = { - bucket: AssetBucket; - index: number; - row: number; - col: number; - date: DateTime; - groupTitle: string; - assets: AssetResponseDto[]; - assetsIntersecting: boolean[]; - height: number; - intersecting: boolean; - geometry: CommonJustifiedLayout; -}; export type ScrubberListener = ( bucketDate: string | undefined, overallScrollPercent: number, bucketScrollPercent: number, ) => void | Promise; -export type ScrollTargetListener = ({ - bucket, - dateGroup, - asset, - offset, -}: { - bucket: AssetBucket; - dateGroup: DateGroup; - asset: AssetResponseDto; - offset: number; -}) => void; export const fromLocalDateTime = (localDateTime: string) => DateTime.fromISO(localDateTime, { zone: 'UTC', locale: get(locale) }); @@ -43,31 +15,6 @@ export const fromLocalDateTime = (localDateTime: string) => export const fromDateTimeOriginal = (dateTimeOriginal: string, timeZone: string) => DateTime.fromISO(dateTimeOriginal, { zone: timeZone }); -export type LayoutBox = { - aspectRatio: number; - top: number; - width: number; - height: number; - left: number; - forcedAspectRatio?: boolean; -}; - -export function findTotalOffset(element: HTMLElement, stop: HTMLElement) { - let offset = 0; - while (element.offsetParent && element !== stop) { - offset += element.offsetTop; - element = element.offsetParent as HTMLElement; - } - return offset; -} - -export const groupDateFormat: Intl.DateTimeFormatOptions = { - weekday: 'short', - month: 'short', - day: 'numeric', - year: 'numeric', -}; - export function formatGroupTitle(_date: DateTime): string { if (!_date.isValid) { return _date.toString(); @@ -87,20 +34,24 @@ export function formatGroupTitle(_date: DateTime): string { // Last week if (date >= today.minus({ days: 6 }) && date < today) { - return date.toLocaleString({ weekday: 'long' }); + return date.toLocaleString({ weekday: 'long' }, { locale: get(locale) }); } // This year if (today.hasSame(date, 'year')) { - return date.toLocaleString({ - weekday: 'short', - month: 'short', - day: 'numeric', - }); + return date.toLocaleString( + { + weekday: 'short', + month: 'short', + day: 'numeric', + }, + { locale: get(locale) }, + ); } - return getDateLocaleString(date); + return getDateLocaleString(date, { locale: get(locale) }); } + export const getDateLocaleString = (date: DateTime, opts?: LocaleOptions): string => date.toLocaleString(DateTime.DATE_MED_WITH_WEEKDAY, opts); From e6c575c33ebe559598a8e68eecbd31e0ccfc8a9a Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Mon, 28 Apr 2025 09:53:53 -0400 Subject: [PATCH 17/31] feat: rtl (#17860) --- web/package-lock.json | 8 +++---- web/package.json | 2 +- .../admin-page/jobs/job-tile.svelte | 6 ++--- .../server-stats/server-stats-panel.svelte | 4 ++-- .../admin-page/server-stats/stats-card.svelte | 2 +- .../settings/auth/auth-settings.svelte | 8 +++---- .../backup-settings/backup-settings.svelte | 2 +- .../settings/ffmpeg/ffmpeg-settings.svelte | 12 +++++----- .../settings/image/image-settings.svelte | 4 ++-- .../settings/job-settings/job-settings.svelte | 4 ++-- .../library-settings/library-settings.svelte | 6 ++--- .../logging-settings/logging-settings.svelte | 2 +- .../machine-learning-settings.svelte | 8 +++---- .../settings/map-settings/map-settings.svelte | 4 ++-- .../metadata-settings.svelte | 2 +- .../new-version-check-settings.svelte | 2 +- .../notification-settings.svelte | 2 +- .../settings/server/server-settings.svelte | 4 ++-- .../storage-template-settings.svelte | 2 +- .../template-settings.svelte | 4 ++-- .../settings/theme/theme-settings.svelte | 2 +- .../trash-settings/trash-settings.svelte | 2 +- .../user-settings/user-settings.svelte | 4 ++-- .../album-page/album-card-group.svelte | 4 ++-- .../components/album-page/album-card.svelte | 2 +- .../components/album-page/album-viewer.svelte | 2 +- .../album-page/albums-table-row.svelte | 4 ++-- .../components/album-page/albums-table.svelte | 8 +++---- .../album-page/user-selection-modal.svelte | 4 ++-- .../asset-viewer/activity-viewer.svelte | 14 ++++++------ .../asset-viewer/album-list-item.svelte | 2 +- .../asset-viewer/asset-viewer.svelte | 10 ++++----- .../asset-viewer/detail-panel-location.svelte | 4 ++-- .../asset-viewer/detail-panel-tags.svelte | 4 ++-- .../asset-viewer/detail-panel.svelte | 2 +- .../asset-viewer/download-panel.svelte | 6 ++--- .../face-editor/face-editor.svelte | 8 +++---- .../asset-viewer/photo-viewer.svelte | 2 +- .../assets/thumbnail/image-thumbnail.svelte | 2 +- .../assets/thumbnail/thumbnail.svelte | 12 +++++----- .../assets/thumbnail/video-thumbnail.svelte | 4 ++-- .../components/elements/buttons/button.svelte | 2 +- .../elements/buttons/skip-link.svelte | 2 +- .../lib/components/elements/dropdown.svelte | 4 ++-- .../faces-page/edit-name-input.svelte | 2 +- .../faces-page/face-thumbnail.svelte | 6 ++--- .../manage-people-visibility.svelte | 6 ++--- .../faces-page/merge-face-selector.svelte | 4 ++-- .../components/faces-page/people-card.svelte | 4 ++-- .../faces-page/person-side-panel.svelte | 16 +++++++------- .../faces-page/unmerge-face-selector.svelte | 6 ++--- .../forms/library-import-paths-form.svelte | 4 ++-- .../forms/library-scan-settings-form.svelte | 2 +- .../components/forms/tag-asset-form.svelte | 4 ++-- .../components/layouts/AuthPageLayout.svelte | 2 +- .../memory-page/memory-viewer.svelte | 20 ++++++++--------- .../photos-page/asset-date-group.svelte | 2 +- .../components/photos-page/asset-grid.svelte | 2 +- .../components/photos-page/memory-lane.svelte | 12 +++++----- .../components/photos-page/skeleton.svelte | 2 +- .../places-page/places-card-group.svelte | 4 ++-- .../shared-components/change-date.svelte | 2 +- .../shared-components/change-location.svelte | 4 ++-- .../shared-components/combobox.svelte | 16 +++++++------- .../context-menu/button-context-menu.svelte | 12 +++++++++- .../context-menu/context-menu.svelte | 14 ++++++++---- .../context-menu/menu-option.svelte | 4 ++-- .../right-click-context-menu.svelte | 4 ++-- .../shared-components/control-app-bar.svelte | 2 +- .../shared-components/duplicates-modal.svelte | 2 +- .../full-screen-modal.svelte | 2 +- .../immich-logo-small-link.svelte | 2 +- .../navigation-bar/account-info-panel.svelte | 4 ++-- .../navigation-bar/navigation-bar.svelte | 8 +++---- .../navigation-loading-bar.svelte | 2 +- .../notification/notification-card.svelte | 4 ++-- .../notification/notification-list.svelte | 2 +- .../shared-components/password-field.svelte | 2 +- .../progress-bar/progress-bar.svelte | 2 +- .../scrubber/scrubber.svelte | 20 ++++++++--------- .../search-bar/search-bar.svelte | 10 ++++----- .../search-bar/search-history-box.svelte | 4 ++-- .../search-bar/search-tags-section.svelte | 4 ++-- .../settings/setting-accordion.svelte | 4 ++-- .../settings/setting-input-field.svelte | 2 +- .../settings/setting-select.svelte | 4 ++-- .../settings/setting-switch.svelte | 2 +- .../shared-components/show-shortcuts.svelte | 4 ++-- .../side-bar/purchase-info.svelte | 4 ++-- .../side-bar/recent-albums.svelte | 2 +- .../side-bar/server-status.svelte | 2 +- .../side-bar/side-bar-link.svelte | 6 ++--- .../side-bar/side-bar-section.svelte | 4 ++-- .../side-bar/storage-space.svelte | 2 +- .../shared-components/tree/breadcrumbs.svelte | 2 +- .../shared-components/tree/tree-items.svelte | 2 +- .../shared-components/tree/tree.svelte | 4 ++-- .../shared-components/upload-panel.svelte | 6 ++--- .../user-settings-page/app-settings.svelte | 18 +++++++-------- .../change-password-settings.svelte | 2 +- .../user-settings-page/device-card.svelte | 4 ++-- .../download-settings.svelte | 2 +- .../feature-settings.svelte | 22 +++++++++---------- .../notifications-settings.svelte | 8 +++---- .../partner-selection-modal.svelte | 2 +- .../partner-settings.svelte | 2 +- .../user-api-key-list.svelte | 2 +- .../user-profile-settings.svelte | 2 +- .../user-purchase-settings.svelte | 4 ++-- .../user-usage-statistic.svelte | 4 ++-- .../duplicates/duplicate-asset.svelte | 10 ++++----- .../duplicates-compare-control.svelte | 20 +++++------------ web/src/lib/constants.ts | 12 +++++----- web/src/lib/stores/event-manager.svelte.ts | 1 + web/src/lib/stores/language-manager.svelte.ts | 21 ++++++++++++++++++ web/src/lib/utils/album-utils.ts | 2 +- .../[[assetId=id]]/+page.svelte | 4 ++-- web/src/routes/(user)/explore/+page.svelte | 6 ++--- .../[[assetId=id]]/+page.svelte | 4 ++-- web/src/routes/(user)/people/+page.svelte | 2 +- .../[[assetId=id]]/+page.svelte | 6 ++--- .../[[assetId=id]]/+page.svelte | 12 +++++----- web/src/routes/(user)/sharing/+page.svelte | 2 +- .../[[assetId=id]]/+page.svelte | 2 +- web/src/routes/+layout.svelte | 5 ++++- web/src/routes/admin/jobs-status/+page.svelte | 2 +- .../admin/library-management/+page.svelte | 4 ++-- web/src/routes/admin/repair/+page.svelte | 14 ++++++------ .../routes/admin/user-management/+page.svelte | 2 +- web/src/routes/auth/login/+page.svelte | 2 +- 130 files changed, 354 insertions(+), 323 deletions(-) create mode 100644 web/src/lib/stores/language-manager.svelte.ts diff --git a/web/package-lock.json b/web/package-lock.json index 37f944d3bb..37f7faf711 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -11,7 +11,7 @@ "dependencies": { "@formatjs/icu-messageformat-parser": "^2.9.8", "@immich/sdk": "file:../open-api/typescript-sdk", - "@immich/ui": "^0.17.3", + "@immich/ui": "^0.18.1", "@mapbox/mapbox-gl-rtl-text": "0.2.3", "@mdi/js": "^7.4.47", "@photo-sphere-viewer/core": "^5.11.5", @@ -1320,9 +1320,9 @@ "link": true }, "node_modules/@immich/ui": { - "version": "0.17.4", - "resolved": "https://registry.npmjs.org/@immich/ui/-/ui-0.17.4.tgz", - "integrity": "sha512-a6M7Fxno5fwY5A0kxdluS8r+A4L6xZhSTKMW8c8hoFhQHvbBTHAsGFKQF3GOEQLOlUuvsS2Lt7dMevBlAPgo/A==", + "version": "0.18.1", + "resolved": "https://registry.npmjs.org/@immich/ui/-/ui-0.18.1.tgz", + "integrity": "sha512-XWWO6OTfH3MektyxCn0hWefZyOGyWwwx/2zHinuShpxTHSyfveJ4mOkFP8DkyMz0dnvJ1EfdkPBMkld3y5R/Hw==", "license": "GNU Affero General Public License version 3", "dependencies": { "@mdi/js": "^7.4.47", diff --git a/web/package.json b/web/package.json index c32e7b04a8..4102765f70 100644 --- a/web/package.json +++ b/web/package.json @@ -27,7 +27,7 @@ "dependencies": { "@formatjs/icu-messageformat-parser": "^2.9.8", "@immich/sdk": "file:../open-api/typescript-sdk", - "@immich/ui": "^0.17.3", + "@immich/ui": "^0.18.1", "@mapbox/mapbox-gl-rtl-text": "0.2.3", "@mdi/js": "^7.4.47", "@photo-sphere-viewer/core": "^5.11.5", diff --git a/web/src/lib/components/admin-page/jobs/job-tile.svelte b/web/src/lib/components/admin-page/jobs/job-tile.svelte index 80dd29e0be..c77ff60f22 100644 --- a/web/src/lib/components/admin-page/jobs/job-tile.svelte +++ b/web/src/lib/components/admin-page/jobs/job-tile.svelte @@ -51,7 +51,7 @@ let isIdle = $derived(!queueStatus.isActive && !queueStatus.isPaused); let multipleButtons = $derived(allText || refreshText); - const commonClasses = 'flex place-items-center justify-between w-full py-2 sm:py-4 pr-4 pl-6'; + const commonClasses = 'flex place-items-center justify-between w-full py-2 sm:py-4 pe-4 ps-6';

{$t('active')}

@@ -119,7 +119,7 @@

{waitingCount.toLocaleString($locale)} diff --git a/web/src/lib/components/admin-page/server-stats/server-stats-panel.svelte b/web/src/lib/components/admin-page/server-stats/server-stats-panel.svelte index bb288511ac..8bae8fee4b 100644 --- a/web/src/lib/components/admin-page/server-stats/server-stats-panel.svelte +++ b/web/src/lib/components/admin-page/server-stats/server-stats-panel.svelte @@ -79,7 +79,7 @@ {zeros(statsUsage)}{statsUsage} - {statsUsageUnit} + {statsUsageUnit}

@@ -88,7 +88,7 @@

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

- +
diff --git a/web/src/lib/components/admin-page/server-stats/stats-card.svelte b/web/src/lib/components/admin-page/server-stats/stats-card.svelte index 14d32c055f..b1804427e9 100644 --- a/web/src/lib/components/admin-page/server-stats/stats-card.svelte +++ b/web/src/lib/components/admin-page/server-stats/stats-card.svelte @@ -31,7 +31,7 @@ class="text-immich-primary dark:text-immich-dark-primary">{value} {#if unit} - {unit} + {unit} {/if} diff --git a/web/src/lib/components/admin-page/settings/auth/auth-settings.svelte b/web/src/lib/components/admin-page/settings/auth/auth-settings.svelte index 5380a76286..67da6bb7f2 100644 --- a/web/src/lib/components/admin-page/settings/auth/auth-settings.svelte +++ b/web/src/lib/components/admin-page/settings/auth/auth-settings.svelte @@ -76,13 +76,13 @@
e.preventDefault()}> -
+
-
+

{#snippet children({ message })} @@ -243,8 +243,8 @@ title={$t('admin.password_settings')} subtitle={$t('admin.password_settings_description')} > -

-
+
+
-
+
-
+

@@ -70,7 +70,7 @@ title={$t('admin.transcoding_policy')} subtitle={$t('admin.transcoding_policy_description')} > -

+
-
+
-
+
-
+
-
+
onReset({ ...options, configKeys: ['ffmpeg'] })} onSave={() => onSave({ ffmpeg: config.ffmpeg })} diff --git a/web/src/lib/components/admin-page/settings/image/image-settings.svelte b/web/src/lib/components/admin-page/settings/image/image-settings.svelte index 9a66ad9c97..9a32e8e4e0 100644 --- a/web/src/lib/components/admin-page/settings/image/image-settings.svelte +++ b/web/src/lib/components/admin-page/settings/image/image-settings.svelte @@ -40,7 +40,7 @@
-
+
-
+
onReset({ ...options, configKeys: ['image'] })} onSave={() => onSave({ image: config.image })} diff --git a/web/src/lib/components/admin-page/settings/job-settings/job-settings.svelte b/web/src/lib/components/admin-page/settings/job-settings/job-settings.svelte index 5a95dbea30..e9f54e7ee8 100644 --- a/web/src/lib/components/admin-page/settings/job-settings/job-settings.svelte +++ b/web/src/lib/components/admin-page/settings/job-settings/job-settings.svelte @@ -47,7 +47,7 @@
{#each jobNames as jobName (jobName)} -
+
{#if isSystemConfigJobDto(jobName)} {/each} -
+
onReset({ ...options, configKeys: ['job'] })} onSave={() => onSave({ job: config.job })} diff --git a/web/src/lib/components/admin-page/settings/library-settings/library-settings.svelte b/web/src/lib/components/admin-page/settings/library-settings/library-settings.svelte index b1012c0287..390b167a54 100644 --- a/web/src/lib/components/admin-page/settings/library-settings/library-settings.svelte +++ b/web/src/lib/components/admin-page/settings/library-settings/library-settings.svelte @@ -47,14 +47,14 @@
-
+
-
+
-
+
-
+
1} -
+
-
+
-
+
-
+

{/snippet} -
+
-
+
-
+
-
+
-
+
-
+
onReset({ ...options, configKeys: ['server'] })} onSave={() => onSave({ server: config.server })} diff --git a/web/src/lib/components/admin-page/settings/storage-template/storage-template-settings.svelte b/web/src/lib/components/admin-page/settings/storage-template/storage-template-settings.svelte index 67299d8f6b..efc42bf8b7 100644 --- a/web/src/lib/components/admin-page/settings/storage-template/storage-template-settings.svelte +++ b/web/src/lib/components/admin-page/settings/storage-template/storage-template-settings.svelte @@ -141,7 +141,7 @@

{#await getTemplateOptions() then} -
+
-
+

{$t('admin.template_email_if_empty')} @@ -102,7 +102,7 @@ onclick={() => getTemplate(templateName, config.templates.email[templateKey])} title={$t('admin.template_email_preview')} > - + {$t('admin.template_email_preview')}

diff --git a/web/src/lib/components/admin-page/settings/theme/theme-settings.svelte b/web/src/lib/components/admin-page/settings/theme/theme-settings.svelte index 79b5f838e3..64b4b92b5e 100644 --- a/web/src/lib/components/admin-page/settings/theme/theme-settings.svelte +++ b/web/src/lib/components/admin-page/settings/theme/theme-settings.svelte @@ -26,7 +26,7 @@
-
+
-
+

diff --git a/web/src/lib/components/admin-page/settings/user-settings/user-settings.svelte b/web/src/lib/components/admin-page/settings/user-settings/user-settings.svelte index f96c3808a8..422e1ebe49 100644 --- a/web/src/lib/components/admin-page/settings/user-settings/user-settings.svelte +++ b/web/src/lib/components/admin-page/settings/user-settings/user-settings.svelte @@ -24,7 +24,7 @@
e.preventDefault()}> -
+
-
+
onReset({ ...options, configKeys: ['user'] })} onSave={() => onSave({ user: config.user })} diff --git a/web/src/lib/components/album-page/album-card-group.svelte b/web/src/lib/components/album-page/album-card-group.svelte index 9b2aa11552..3556d9fea4 100644 --- a/web/src/lib/components/album-page/album-card-group.svelte +++ b/web/src/lib/components/album-page/album-card-group.svelte @@ -48,7 +48,7 @@
diff --git a/web/src/lib/components/album-page/album-card.svelte b/web/src/lib/components/album-page/album-card.svelte index cec4919e4e..06ec030bea 100644 --- a/web/src/lib/components/album-page/album-card.svelte +++ b/web/src/lib/components/album-page/album-card.svelte @@ -40,7 +40,7 @@ {#if onShowContextMenu}
{#if album.description}

{album.description}

diff --git a/web/src/lib/components/album-page/albums-table-row.svelte b/web/src/lib/components/album-page/albums-table-row.svelte index c900930f8a..034ed62010 100644 --- a/web/src/lib/components/album-page/albums-table-row.svelte +++ b/web/src/lib/components/album-page/albums-table-row.svelte @@ -35,13 +35,13 @@ onclick={() => goto(`${AppRoute.ALBUMS}/${album.id}`)} {oncontextmenu} > -
+ {album.albumName} {#if album.shared} - +
@@ -48,18 +48,18 @@ class="block w-full overflow-y-auto rounded-md border dark:border-immich-dark-gray dark:text-immich-dark-fg" > toggleAlbumGroupCollapsing(albumGroup.id)} aria-expanded={!isCollapsed} > - diff --git a/web/src/lib/components/album-page/user-selection-modal.svelte b/web/src/lib/components/album-page/user-selection-modal.svelte index 1496c1ce66..9ee7cc550d 100644 --- a/web/src/lib/components/album-page/user-selection-modal.svelte +++ b/web/src/lib/components/album-page/user-selection-modal.svelte @@ -94,7 +94,7 @@ -
+

{user.name}

@@ -136,7 +136,7 @@ class="flex w-full place-items-center gap-4 p-4" > -
+

{user.name}

diff --git a/web/src/lib/components/asset-viewer/activity-viewer.svelte b/web/src/lib/components/asset-viewer/activity-viewer.svelte index caa1ced290..94b66d4c22 100644 --- a/web/src/lib/components/asset-viewer/activity-viewer.svelte +++ b/web/src/lib/components/asset-viewer/activity-viewer.svelte @@ -186,7 +186,7 @@ > {#each reactions as reaction, index (reaction.id)} {#if reaction.type === ReactionType.Comment} -
+
@@ -202,7 +202,7 @@ {/if} {#if reaction.user.id === user.id || albumOwnerId === user.id} -
+
-
+
@@ -255,7 +255,7 @@ {/if} {#if reaction.user.id === user.id || albumOwnerId === user.id} -
+
{#if isSendingMessage} -
+
{:else if message} -
+
diff --git a/web/src/lib/components/asset-viewer/asset-viewer.svelte b/web/src/lib/components/asset-viewer/asset-viewer.svelte index 91461d574d..98bc087f71 100644 --- a/web/src/lib/components/asset-viewer/asset-viewer.svelte +++ b/web/src/lib/components/asset-viewer/asset-viewer.svelte @@ -422,7 +422,7 @@
@@ -547,7 +547,7 @@ /> {/if} {#if $slideshowState === SlideshowState.None && isShared && ((album && album.isActivityEnabled) || numberOfComments > 0)} -
+
($isShowDetail = false)} /> @@ -582,7 +582,7 @@
@@ -631,7 +631,7 @@
(isOwner ? (isShowChangeLocation = true) : null)} title={isOwner ? $t('edit_location') : ''} class:hover:dark:text-immich-dark-primary={isOwner} @@ -68,7 +68,7 @@ {:else if !asset.exifInfo?.city && isOwner} {$t('merge')} {/snippet} diff --git a/web/src/lib/components/faces-page/people-card.svelte b/web/src/lib/components/faces-page/people-card.svelte index 4aff4e96f8..b740953340 100644 --- a/web/src/lib/components/faces-page/people-card.svelte +++ b/web/src/lib/components/faces-page/people-card.svelte @@ -54,7 +54,7 @@ circle /> {#if person.isFavorite} -
+
{/if} @@ -62,7 +62,7 @@
{#if showVerticalDots} -
+
($boundingBoxesArray = [peopleWithFaces[index]])} onmouseover={() => ($boundingBoxesArray = [peopleWithFaces[index]])} onmouseleave={() => ($boundingBoxesArray = [])} @@ -303,7 +303,7 @@

{/if} -
+
{#if selectedPersonToCreate[face.id] || selectedPersonToReassign[face.id]} handleReset(face.id)} /> {:else} @@ -321,29 +321,29 @@ title={$t('select_new_face')} size="18" padding="1" - class="absolute left-1/2 top-1/2 translate-x-[-50%] translate-y-[-50%] transform" + class="absolute start-1/2 top-1/2 translate-x-[-50%] translate-y-[-50%] transform" onclick={() => handleFacePicker(face)} /> {/if}
-
+
{#if !selectedPersonToCreate[face.id] && !selectedPersonToReassign[face.id] && !face.person}
{/if}
{#if face.person != null} -
+
deleteAssetFace(face)} />
diff --git a/web/src/lib/components/faces-page/unmerge-face-selector.svelte b/web/src/lib/components/faces-page/unmerge-face-selector.svelte index e808c98748..41c584d602 100644 --- a/web/src/lib/components/faces-page/unmerge-face-selector.svelte +++ b/web/src/lib/components/faces-page/unmerge-face-selector.svelte @@ -120,7 +120,7 @@
{#snippet leading()} @@ -140,7 +140,7 @@ {:else} {/if} - {$t('create_new_person')} {$t('create_new_person')} {$t('reassign')}
{/snippet} diff --git a/web/src/lib/components/forms/library-import-paths-form.svelte b/web/src/lib/components/forms/library-import-paths-form.svelte index 639b81071f..64c32532ef 100644 --- a/web/src/lib/components/forms/library-import-paths-form.svelte +++ b/web/src/lib/components/forms/library-import-paths-form.svelte @@ -173,7 +173,7 @@ {/if} -
+ {albumGroup.name} - + ({$t('albums_count', { values: { count: albumGroup.albums.length } })})
+
{#each validatedPaths as validatedPath, listIndex (validatedPath.importPath)} -
+ {#if validatedPath.isValid} - +
{#each exclusionPatterns as exclusionPattern, listIndex (exclusionPattern)}

{tag.value} @@ -81,7 +81,7 @@


diff --git a/web/src/lib/components/shared-components/change-date.svelte b/web/src/lib/components/shared-components/change-date.svelte index 13b2752f0c..ef682d9048 100644 --- a/web/src/lib/components/shared-components/change-date.svelte +++ b/web/src/lib/components/shared-components/change-date.svelte @@ -144,7 +144,7 @@ {#snippet promptSnippet()} -
+
diff --git a/web/src/lib/components/shared-components/change-location.svelte b/web/src/lib/components/shared-components/change-location.svelte index a84838f1db..f981e85029 100644 --- a/web/src/lib/components/shared-components/change-location.svelte +++ b/web/src/lib/components/shared-components/change-location.svelte @@ -147,7 +147,7 @@ : ''}" onclick={() => handleUseSuggested(place.latitude, place.longitude)} > -

+

{getLocation(place.name, place.admin1name, place.admin2name)}

@@ -189,7 +189,7 @@ {/await}
-
+
{#if isActive} -
+
@@ -273,11 +273,11 @@ aria-expanded={isOpen} autocomplete="off" bind:this={input} - class:!pl-8={isActive} + class:!ps-8={isActive} class:!rounded-b-none={isOpen && dropdownDirection === 'bottom'} class:!rounded-t-none={isOpen && dropdownDirection === 'top'} class:cursor-pointer={!isActive} - class="immich-form-input text-sm text-left w-full !pr-12 transition-all" + class="immich-form-input text-sm w-full !pe-12 transition-all" id={inputId} onfocus={activate} oninput={onInput} @@ -325,8 +325,8 @@ />
{#if selectedOption} @@ -341,7 +341,7 @@ role="listbox" id={listboxId} transition:fly={{ duration: 250 }} - class="fixed text-left text-sm w-full overflow-y-auto bg-white dark:bg-gray-800 border-gray-300 dark:border-gray-900 z-[10000]" + class="fixed text-start text-sm w-full overflow-y-auto bg-white dark:bg-gray-800 border-gray-300 dark:border-gray-900 z-[10000]" class:rounded-b-xl={dropdownDirection === 'bottom'} class:rounded-t-xl={dropdownDirection === 'top'} class:shadow={dropdownDirection === 'bottom'} @@ -360,7 +360,7 @@ role="option" aria-selected={selectedIndex === 0} aria-disabled={true} - class="text-left w-full px-4 py-2 hover:bg-gray-200 dark:hover:bg-gray-700 cursor-default aria-selected:bg-gray-200 aria-selected:dark:bg-gray-700" + class="text-start w-full px-4 py-2 hover:bg-gray-200 dark:hover:bg-gray-700 cursor-default aria-selected:bg-gray-200 aria-selected:dark:bg-gray-700" id={`${listboxId}-${0}`} onclick={closeDropdown} > @@ -372,7 +372,7 @@
  • handleSelect(option)} role="option" diff --git a/web/src/lib/components/shared-components/context-menu/button-context-menu.svelte b/web/src/lib/components/shared-components/context-menu/button-context-menu.svelte index a3e12e4f12..67a17db950 100644 --- a/web/src/lib/components/shared-components/context-menu/button-context-menu.svelte +++ b/web/src/lib/components/shared-components/context-menu/button-context-menu.svelte @@ -7,6 +7,7 @@ } from '$lib/components/elements/buttons/circle-icon-button.svelte'; import ContextMenu from '$lib/components/shared-components/context-menu/context-menu.svelte'; import { optionClickCallbackStore, selectedIdStore } from '$lib/stores/context-menu.store'; + import { languageManager } from '$lib/stores/language-manager.svelte'; import { getContextMenuPositionFromBoundingRect, getContextMenuPositionFromEvent, @@ -26,6 +27,7 @@ /** * The direction in which the context menu should open. */ + // TODO change to start vs end direction?: 'left' | 'right'; color?: Color; size?: string | undefined; @@ -62,7 +64,15 @@ const menuId = `context-menu-${id}`; const openDropdown = (event: KeyboardEvent | MouseEvent) => { - contextMenuPosition = getContextMenuPositionFromEvent(event, align); + let layoutAlign = align; + if (languageManager.rtl) { + if (align.includes('left')) { + layoutAlign = align.replace('left', 'right') as Align; + } else if (align.includes('right')) { + layoutAlign = align.replace('right', 'left') as Align; + } + } + contextMenuPosition = getContextMenuPositionFromEvent(event, layoutAlign); isOpen = true; menuContainer?.focus(); }; diff --git a/web/src/lib/components/shared-components/context-menu/context-menu.svelte b/web/src/lib/components/shared-components/context-menu/context-menu.svelte index 976f86d3e5..a79a3bd385 100644 --- a/web/src/lib/components/shared-components/context-menu/context-menu.svelte +++ b/web/src/lib/components/shared-components/context-menu/context-menu.svelte @@ -3,6 +3,7 @@ import { slide } from 'svelte/transition'; import { clickOutside } from '$lib/actions/click-outside'; import type { Snippet } from 'svelte'; + import { languageManager } from '$lib/stores/language-manager.svelte'; interface Props { isVisible?: boolean; @@ -41,12 +42,17 @@ $effect(() => { if (menuElement) { + let layoutDirection = direction; + if (languageManager.rtl) { + layoutDirection = direction === 'left' ? 'right' : 'left'; + } + const rect = menuElement.getBoundingClientRect(); - const directionWidth = direction === 'left' ? rect.width : 0; + const directionWidth = layoutDirection === 'left' ? rect.width : 0; const menuHeight = Math.min(menuElement.clientHeight, height) || 0; - left = Math.min(window.innerWidth - rect.width, x - directionWidth); - top = Math.min(window.innerHeight - menuHeight, y); + left = Math.max(8, Math.min(window.innerWidth - rect.width, x - directionWidth)); + top = Math.max(8, Math.min(window.innerHeight - menuHeight, y)); } }); @@ -66,7 +72,7 @@ aria-labelledby={ariaLabelledBy} bind:this={menuElement} class="{isVisible - ? 'max-h-dvh max-h-svh' + ? 'max-h-dvh' : 'max-h-0'} flex flex-col transition-all duration-[250ms] ease-in-out outline-none overflow-auto" role="menu" tabindex="-1" 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 b3a6d41018..b331804958 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 @@ -53,7 +53,7 @@ onclick={handleClick} onmouseover={() => ($selectedIdStore = id)} onmouseleave={() => ($selectedIdStore = undefined)} - class="w-full p-4 text-left text-sm font-medium {textColor} focus:outline-none focus:ring-2 focus:ring-inset cursor-pointer border-gray-200 flex gap-2 items-center {isActive + class="w-full p-4 text-start text-sm font-medium {textColor} focus:outline-none focus:ring-2 focus:ring-inset cursor-pointer border-gray-200 flex gap-2 items-center {isActive ? activeColor : 'bg-slate-100'}" role="menuitem" @@ -65,7 +65,7 @@
    {text} {#if shortcutLabel} - + {shortcutLabel} {/if} diff --git a/web/src/lib/components/shared-components/context-menu/right-click-context-menu.svelte b/web/src/lib/components/shared-components/context-menu/right-click-context-menu.svelte index 9b9e68b6c5..27d50f4fe5 100644 --- a/web/src/lib/components/shared-components/context-menu/right-click-context-menu.svelte +++ b/web/src/lib/components/shared-components/context-menu/right-click-context-menu.svelte @@ -38,7 +38,7 @@ const elements = document.elementsFromPoint(event.x, event.y); if (menuContainer && elements.includes(menuContainer)) { - // User right-clicked on the context menu itself, we keep the context + // User end-clicked on the context menu itself, we keep the context // menu as is return; } @@ -91,7 +91,7 @@ }, ]} > -
  • +
    diff --git a/web/src/lib/components/user-settings-page/user-profile-settings.svelte b/web/src/lib/components/user-settings-page/user-profile-settings.svelte index c36c36d7cc..90487f532f 100644 --- a/web/src/lib/components/user-settings-page/user-profile-settings.svelte +++ b/web/src/lib/components/user-settings-page/user-profile-settings.svelte @@ -42,7 +42,7 @@
    -
    +
    {#if isServerProduct}
    @@ -152,7 +152,7 @@ {/if} {:else}
    diff --git a/web/src/lib/components/user-settings-page/user-usage-statistic.svelte b/web/src/lib/components/user-settings-page/user-usage-statistic.svelte index f7de1d8f64..ad77516d55 100644 --- a/web/src/lib/components/user-settings-page/user-usage-statistic.svelte +++ b/web/src/lib/components/user-settings-page/user-usage-statistic.svelte @@ -68,7 +68,7 @@

    {$t('photos_and_videos')}

    -
    +
    @@ -92,7 +92,7 @@

    {$t('albums')}

    -
    +
    diff --git a/web/src/lib/components/utilities-page/duplicates/duplicate-asset.svelte b/web/src/lib/components/utilities-page/duplicates/duplicate-asset.svelte index 97f44e3ec4..b8409cb0ef 100644 --- a/web/src/lib/components/utilities-page/duplicates/duplicate-asset.svelte +++ b/web/src/lib/components/utilities-page/duplicates/duplicate-asset.svelte @@ -44,14 +44,14 @@ {#if asset.isFavorite} -
    +
    {/if}
    @@ -59,7 +59,7 @@
    -
    +
    {#if isFromExternalLibrary}
    {$t('external')} @@ -68,7 +68,7 @@ {#if asset.stack?.assetCount}
    -
    {asset.stack.assetCount}
    +
    {asset.stack.assetCount}
    @@ -79,7 +79,7 @@
    @@ -143,21 +143,11 @@
    {#if trashCount === 0} - {:else} - {/each}
    diff --git a/web/src/routes/(user)/search/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/search/[[photos=photos]]/[[assetId=id]]/+page.svelte index c985104e3c..9d427e1ea7 100644 --- a/web/src/routes/(user)/search/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/search/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -251,7 +251,7 @@
    {#if assetInteraction.selectionActive} -
    +
    cancelMultiselect(assetInteraction)} @@ -289,13 +289,13 @@
    {:else} -
    +
    goto(previousRoute)} backIcon={mdiArrowLeft}>
    -
    +
    @@ -313,13 +313,13 @@
    {getHumanReadableSearchKey(key as keyof SearchTerms)}
    {#if value !== true} -
    +
    {#if (key === 'takenAfter' || key === 'takenBefore') && typeof value === 'string'} {getHumanReadableDate(value)} {:else if key === 'personIds' && Array.isArray(value)} @@ -349,7 +349,7 @@ > {#if searchResultAlbums.length > 0}
    -
    {$t('albums').toUpperCase()}
    +
    {$t('albums').toUpperCase()}
    diff --git a/web/src/routes/(user)/sharing/+page.svelte b/web/src/routes/(user)/sharing/+page.svelte index e3d6ac1ced..a55452b5d1 100644 --- a/web/src/routes/(user)/sharing/+page.svelte +++ b/web/src/routes/(user)/sharing/+page.svelte @@ -68,7 +68,7 @@ class="flex gap-4 rounded-lg px-5 py-4 transition-all hover:bg-gray-200 dark:hover:bg-gray-700" > -
    +

    {partner.name}

    diff --git a/web/src/routes/(user)/tags/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/tags/[[photos=photos]]/[[assetId=id]]/+page.svelte index 8bb43676e8..8d33a2eb6e 100644 --- a/web/src/routes/(user)/tags/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/tags/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -146,7 +146,7 @@
    -
    {$t('explorer').toUpperCase()}
    +
    {$t('explorer').toUpperCase()}
    languageManager.setLanguage(lang)); }); onDestroy(() => { diff --git a/web/src/routes/admin/jobs-status/+page.svelte b/web/src/routes/admin/jobs-status/+page.svelte index 21381081e0..07757614e5 100644 --- a/web/src/routes/admin/jobs-status/+page.svelte +++ b/web/src/routes/admin/jobs-status/+page.svelte @@ -107,7 +107,7 @@ > {#snippet promptSnippet()} -
    +
    {#if libraries.length > 0} -
    +
    @@ -369,7 +369,7 @@ {/if} {#if editScanSettings === index} -
    +
    {:else}
    -
    +
    @@ -265,7 +265,7 @@
    - +
    @@ -295,7 +295,7 @@ -
    copyToClipboard(orphan.pathValue)}> {}} /> + {orphan.pathValue} @@ -306,7 +306,7 @@
    - +
    @@ -337,11 +337,11 @@ -
    copyToClipboard(extra.filename)}> {}} /> - + {extra.filename} - + {#if extra.checksum} [sha1:{extra.checksum}] {/if} diff --git a/web/src/routes/admin/user-management/+page.svelte b/web/src/routes/admin/user-management/+page.svelte index 0ca17c4ed8..a25799588a 100644 --- a/web/src/routes/admin/user-management/+page.svelte +++ b/web/src/routes/admin/user-management/+page.svelte @@ -180,7 +180,7 @@ {/if} - +
    diff --git a/web/src/routes/auth/login/+page.svelte b/web/src/routes/auth/login/+page.svelte index c3d01b3c56..aa756ac2e8 100644 --- a/web/src/routes/auth/login/+page.svelte +++ b/web/src/routes/auth/login/+page.svelte @@ -132,7 +132,7 @@

    {$t('or').toUpperCase()} From 460d594791c5d4674f162345d40bb2e98e9bac8a Mon Sep 17 00:00:00 2001 From: Zack Pollard Date: Mon, 28 Apr 2025 14:54:11 +0100 Subject: [PATCH 18/31] feat: api response compression (#17878) --- server/package-lock.json | 66 +++++++++++++++++++++++++++++++++++++++ server/package.json | 2 ++ server/src/workers/api.ts | 2 ++ 3 files changed, 70 insertions(+) diff --git a/server/package-lock.json b/server/package-lock.json index b1fdfa1f9d..24180f7cac 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -32,6 +32,7 @@ "chokidar": "^3.5.3", "class-transformer": "^0.5.1", "class-validator": "^0.14.0", + "compression": "^1.8.0", "cookie": "^1.0.2", "cookie-parser": "^1.4.7", "exiftool-vendored": "^28.3.1", @@ -83,6 +84,7 @@ "@types/archiver": "^6.0.0", "@types/async-lock": "^1.4.2", "@types/bcrypt": "^5.0.0", + "@types/compression": "^1.7.5", "@types/cookie-parser": "^1.4.8", "@types/express": "^4.17.17", "@types/fluent-ffmpeg": "^2.1.21", @@ -5009,6 +5011,16 @@ "@types/node": "*" } }, + "node_modules/@types/compression": { + "version": "1.7.5", + "resolved": "https://registry.npmjs.org/@types/compression/-/compression-1.7.5.tgz", + "integrity": "sha512-AAQvK5pxMpaT+nDvhHrsBhLSYG5yQdtkaJE1WYieSNY2mVFKAgmU4ks65rkZD5oqnGCFLyQpUr1CqI4DmUMyDg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/express": "*" + } + }, "node_modules/@types/connect": { "version": "3.4.38", "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", @@ -7603,6 +7615,60 @@ "node": ">= 14" } }, + "node_modules/compressible": { + "version": "2.0.18", + "resolved": "https://registry.npmjs.org/compressible/-/compressible-2.0.18.tgz", + "integrity": "sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==", + "license": "MIT", + "dependencies": { + "mime-db": ">= 1.43.0 < 2" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/compression": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/compression/-/compression-1.8.0.tgz", + "integrity": "sha512-k6WLKfunuqCYD3t6AsuPGvQWaKwuLLh2/xHNcX4qE+vIfDNXpSqnrhwA7O53R7WVQUnt8dVAIW+YHr7xTgOgGA==", + "license": "MIT", + "dependencies": { + "bytes": "3.1.2", + "compressible": "~2.0.18", + "debug": "2.6.9", + "negotiator": "~0.6.4", + "on-headers": "~1.0.2", + "safe-buffer": "5.2.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/compression/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/compression/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/compression/node_modules/negotiator": { + "version": "0.6.4", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.4.tgz", + "integrity": "sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", diff --git a/server/package.json b/server/package.json index f68ba71564..33d1450a53 100644 --- a/server/package.json +++ b/server/package.json @@ -57,6 +57,7 @@ "chokidar": "^3.5.3", "class-transformer": "^0.5.1", "class-validator": "^0.14.0", + "compression": "^1.8.0", "cookie": "^1.0.2", "cookie-parser": "^1.4.7", "exiftool-vendored": "^28.3.1", @@ -108,6 +109,7 @@ "@types/archiver": "^6.0.0", "@types/async-lock": "^1.4.2", "@types/bcrypt": "^5.0.0", + "@types/compression": "^1.7.5", "@types/cookie-parser": "^1.4.8", "@types/express": "^4.17.17", "@types/fluent-ffmpeg": "^2.1.21", diff --git a/server/src/workers/api.ts b/server/src/workers/api.ts index ddf6e50aa2..4248b23d30 100644 --- a/server/src/workers/api.ts +++ b/server/src/workers/api.ts @@ -1,6 +1,7 @@ import { NestFactory } from '@nestjs/core'; import { NestExpressApplication } from '@nestjs/platform-express'; import { json } from 'body-parser'; +import compression from 'compression'; import cookieParser from 'cookie-parser'; import { existsSync } from 'node:fs'; import sirv from 'sirv'; @@ -60,6 +61,7 @@ async function bootstrap() { ); } app.use(app.get(ApiService).ssr(excludePaths)); + app.use(compression()); const server = await (host ? app.listen(port, host) : app.listen(port)); server.requestTimeout = 24 * 60 * 60 * 1000; From ad272333dbf15b9a3419a9d67e5c9621664f9077 Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Mon, 28 Apr 2025 09:54:51 -0400 Subject: [PATCH 19/31] refactor: user avatar color (#17753) --- e2e/src/api/specs/user-admin.e2e-spec.ts | 26 ++--- e2e/src/api/specs/user.e2e-spec.ts | 26 ++--- mobile/openapi/README.md | 1 - mobile/openapi/lib/api.dart | 1 - mobile/openapi/lib/api_client.dart | 2 - mobile/openapi/lib/model/avatar_response.dart | 99 ------------------- .../lib/model/user_admin_create_dto.dart | 13 ++- .../lib/model/user_admin_update_dto.dart | 13 ++- .../model/user_preferences_response_dto.dart | 10 +- .../openapi/lib/model/user_update_me_dto.dart | 13 ++- open-api/immich-openapi-specs.json | 43 ++++---- open-api/typescript-sdk/src/fetch-client.ts | 7 +- server/src/database.ts | 14 ++- server/src/dtos/user-preferences.dto.ts | 6 -- server/src/dtos/user.dto.ts | 28 +++++- server/src/queries/activity.repository.sql | 2 + server/src/queries/album.repository.sql | 9 ++ server/src/queries/partner.repository.sql | 8 ++ server/src/queries/user.repository.sql | 7 ++ .../1745244781846-AddUserAvatarColorColumn.ts | 14 +++ server/src/schema/tables/user.table.ts | 5 +- server/src/services/download.service.ts | 2 +- server/src/services/notification.service.ts | 4 +- server/src/services/user-admin.service.ts | 12 +-- server/src/services/user.service.ts | 9 +- server/src/types.ts | 4 - server/src/utils/preferences.ts | 20 ++-- server/test/fixtures/user.stub.ts | 12 +-- server/test/small.factory.ts | 3 + .../navigation-bar/account-info-panel.svelte | 7 +- 30 files changed, 200 insertions(+), 220 deletions(-) delete mode 100644 mobile/openapi/lib/model/avatar_response.dart create mode 100644 server/src/schema/migrations/1745244781846-AddUserAvatarColorColumn.ts diff --git a/e2e/src/api/specs/user-admin.e2e-spec.ts b/e2e/src/api/specs/user-admin.e2e-spec.ts index 9299e62b79..1fbee84c3f 100644 --- a/e2e/src/api/specs/user-admin.e2e-spec.ts +++ b/e2e/src/api/specs/user-admin.e2e-spec.ts @@ -215,6 +215,19 @@ describe('/admin/users', () => { const user = await getMyUser({ headers: asBearerAuth(token.accessToken) }); expect(user).toMatchObject({ email: nonAdmin.userEmail }); }); + + it('should update the avatar color', async () => { + const { status, body } = await request(app) + .put(`/admin/users/${admin.userId}`) + .send({ avatarColor: 'orange' }) + .set('Authorization', `Bearer ${admin.accessToken}`); + + expect(status).toBe(200); + expect(body).toMatchObject({ avatarColor: 'orange' }); + + const after = await getUserAdmin({ id: admin.userId }, { headers: asBearerAuth(admin.accessToken) }); + expect(after).toMatchObject({ avatarColor: 'orange' }); + }); }); describe('PUT /admin/users/:id/preferences', () => { @@ -240,19 +253,6 @@ describe('/admin/users', () => { expect(after).toMatchObject({ memories: { enabled: false } }); }); - it('should update the avatar color', async () => { - const { status, body } = await request(app) - .put(`/admin/users/${admin.userId}/preferences`) - .send({ avatar: { color: 'orange' } }) - .set('Authorization', `Bearer ${admin.accessToken}`); - - expect(status).toBe(200); - expect(body).toMatchObject({ avatar: { color: 'orange' } }); - - const after = await getUserPreferencesAdmin({ id: admin.userId }, { headers: asBearerAuth(admin.accessToken) }); - expect(after).toMatchObject({ avatar: { color: 'orange' } }); - }); - it('should update download archive size', async () => { const { status, body } = await request(app) .put(`/admin/users/${admin.userId}/preferences`) diff --git a/e2e/src/api/specs/user.e2e-spec.ts b/e2e/src/api/specs/user.e2e-spec.ts index 54d11e5049..b9eb140c56 100644 --- a/e2e/src/api/specs/user.e2e-spec.ts +++ b/e2e/src/api/specs/user.e2e-spec.ts @@ -139,6 +139,19 @@ describe('/users', () => { profileChangedAt: expect.anything(), }); }); + + it('should update avatar color', async () => { + const { status, body } = await request(app) + .put(`/users/me`) + .send({ avatarColor: 'blue' }) + .set('Authorization', `Bearer ${admin.accessToken}`); + + expect(status).toBe(200); + expect(body).toMatchObject({ avatarColor: 'blue' }); + + const after = await getMyUser({ headers: asBearerAuth(admin.accessToken) }); + expect(after).toMatchObject({ avatarColor: 'blue' }); + }); }); describe('PUT /users/me/preferences', () => { @@ -158,19 +171,6 @@ describe('/users', () => { expect(after).toMatchObject({ memories: { enabled: false } }); }); - it('should update avatar color', async () => { - const { status, body } = await request(app) - .put(`/users/me/preferences`) - .send({ avatar: { color: 'blue' } }) - .set('Authorization', `Bearer ${admin.accessToken}`); - - expect(status).toBe(200); - expect(body).toMatchObject({ avatar: { color: 'blue' } }); - - const after = await getMyPreferences({ headers: asBearerAuth(admin.accessToken) }); - expect(after).toMatchObject({ avatar: { color: 'blue' } }); - }); - it('should require an integer for download archive size', async () => { const { status, body } = await request(app) .put(`/users/me/preferences`) diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index 4f9b062ba6..5a7a42cce5 100644 --- a/mobile/openapi/README.md +++ b/mobile/openapi/README.md @@ -300,7 +300,6 @@ Class | Method | HTTP request | Description - [AssetStatsResponseDto](doc//AssetStatsResponseDto.md) - [AssetTypeEnum](doc//AssetTypeEnum.md) - [AudioCodec](doc//AudioCodec.md) - - [AvatarResponse](doc//AvatarResponse.md) - [AvatarUpdate](doc//AvatarUpdate.md) - [BulkIdResponseDto](doc//BulkIdResponseDto.md) - [BulkIdsDto](doc//BulkIdsDto.md) diff --git a/mobile/openapi/lib/api.dart b/mobile/openapi/lib/api.dart index ff5a95bbbc..d08f9fda38 100644 --- a/mobile/openapi/lib/api.dart +++ b/mobile/openapi/lib/api.dart @@ -107,7 +107,6 @@ part 'model/asset_stack_response_dto.dart'; part 'model/asset_stats_response_dto.dart'; part 'model/asset_type_enum.dart'; part 'model/audio_codec.dart'; -part 'model/avatar_response.dart'; part 'model/avatar_update.dart'; part 'model/bulk_id_response_dto.dart'; part 'model/bulk_ids_dto.dart'; diff --git a/mobile/openapi/lib/api_client.dart b/mobile/openapi/lib/api_client.dart index 5759217f41..0d8e4c6ba9 100644 --- a/mobile/openapi/lib/api_client.dart +++ b/mobile/openapi/lib/api_client.dart @@ -270,8 +270,6 @@ class ApiClient { return AssetTypeEnumTypeTransformer().decode(value); case 'AudioCodec': return AudioCodecTypeTransformer().decode(value); - case 'AvatarResponse': - return AvatarResponse.fromJson(value); case 'AvatarUpdate': return AvatarUpdate.fromJson(value); case 'BulkIdResponseDto': diff --git a/mobile/openapi/lib/model/avatar_response.dart b/mobile/openapi/lib/model/avatar_response.dart deleted file mode 100644 index 8ce0287565..0000000000 --- a/mobile/openapi/lib/model/avatar_response.dart +++ /dev/null @@ -1,99 +0,0 @@ -// -// AUTO-GENERATED FILE, DO NOT MODIFY! -// -// @dart=2.18 - -// ignore_for_file: unused_element, unused_import -// ignore_for_file: always_put_required_named_parameters_first -// ignore_for_file: constant_identifier_names -// ignore_for_file: lines_longer_than_80_chars - -part of openapi.api; - -class AvatarResponse { - /// Returns a new [AvatarResponse] instance. - AvatarResponse({ - required this.color, - }); - - UserAvatarColor color; - - @override - bool operator ==(Object other) => identical(this, other) || other is AvatarResponse && - other.color == color; - - @override - int get hashCode => - // ignore: unnecessary_parenthesis - (color.hashCode); - - @override - String toString() => 'AvatarResponse[color=$color]'; - - Map toJson() { - final json = {}; - json[r'color'] = this.color; - return json; - } - - /// Returns a new [AvatarResponse] instance and imports its values from - /// [value] if it's a [Map], null otherwise. - // ignore: prefer_constructors_over_static_methods - static AvatarResponse? fromJson(dynamic value) { - upgradeDto(value, "AvatarResponse"); - if (value is Map) { - final json = value.cast(); - - return AvatarResponse( - color: UserAvatarColor.fromJson(json[r'color'])!, - ); - } - return null; - } - - static List listFromJson(dynamic json, {bool growable = false,}) { - final result = []; - if (json is List && json.isNotEmpty) { - for (final row in json) { - final value = AvatarResponse.fromJson(row); - if (value != null) { - result.add(value); - } - } - } - return result.toList(growable: growable); - } - - static Map mapFromJson(dynamic json) { - final map = {}; - if (json is Map && json.isNotEmpty) { - json = json.cast(); // ignore: parameter_assignments - for (final entry in json.entries) { - final value = AvatarResponse.fromJson(entry.value); - if (value != null) { - map[entry.key] = value; - } - } - } - return map; - } - - // maps a json object with a list of AvatarResponse-objects as value to a dart map - static Map> mapListFromJson(dynamic json, {bool growable = false,}) { - final map = >{}; - if (json is Map && json.isNotEmpty) { - // ignore: parameter_assignments - json = json.cast(); - for (final entry in json.entries) { - map[entry.key] = AvatarResponse.listFromJson(entry.value, growable: growable,); - } - } - return map; - } - - /// The list of required keys that must be present in a JSON. - static const requiredKeys = { - 'color', - }; -} - diff --git a/mobile/openapi/lib/model/user_admin_create_dto.dart b/mobile/openapi/lib/model/user_admin_create_dto.dart index 4bd1266426..1477c82ca1 100644 --- a/mobile/openapi/lib/model/user_admin_create_dto.dart +++ b/mobile/openapi/lib/model/user_admin_create_dto.dart @@ -13,6 +13,7 @@ part of openapi.api; class UserAdminCreateDto { /// Returns a new [UserAdminCreateDto] instance. UserAdminCreateDto({ + this.avatarColor, required this.email, required this.name, this.notify, @@ -22,6 +23,8 @@ class UserAdminCreateDto { this.storageLabel, }); + UserAvatarColor? avatarColor; + String email; String name; @@ -51,6 +54,7 @@ class UserAdminCreateDto { @override bool operator ==(Object other) => identical(this, other) || other is UserAdminCreateDto && + other.avatarColor == avatarColor && other.email == email && other.name == name && other.notify == notify && @@ -62,6 +66,7 @@ class UserAdminCreateDto { @override int get hashCode => // ignore: unnecessary_parenthesis + (avatarColor == null ? 0 : avatarColor!.hashCode) + (email.hashCode) + (name.hashCode) + (notify == null ? 0 : notify!.hashCode) + @@ -71,10 +76,15 @@ class UserAdminCreateDto { (storageLabel == null ? 0 : storageLabel!.hashCode); @override - String toString() => 'UserAdminCreateDto[email=$email, name=$name, notify=$notify, password=$password, quotaSizeInBytes=$quotaSizeInBytes, shouldChangePassword=$shouldChangePassword, storageLabel=$storageLabel]'; + String toString() => 'UserAdminCreateDto[avatarColor=$avatarColor, email=$email, name=$name, notify=$notify, password=$password, quotaSizeInBytes=$quotaSizeInBytes, shouldChangePassword=$shouldChangePassword, storageLabel=$storageLabel]'; Map toJson() { final json = {}; + if (this.avatarColor != null) { + json[r'avatarColor'] = this.avatarColor; + } else { + // json[r'avatarColor'] = null; + } json[r'email'] = this.email; json[r'name'] = this.name; if (this.notify != null) { @@ -110,6 +120,7 @@ class UserAdminCreateDto { final json = value.cast(); return UserAdminCreateDto( + avatarColor: UserAvatarColor.fromJson(json[r'avatarColor']), email: mapValueOfType(json, r'email')!, name: mapValueOfType(json, r'name')!, notify: mapValueOfType(json, r'notify'), diff --git a/mobile/openapi/lib/model/user_admin_update_dto.dart b/mobile/openapi/lib/model/user_admin_update_dto.dart index f0478c9b4c..951ee8ce84 100644 --- a/mobile/openapi/lib/model/user_admin_update_dto.dart +++ b/mobile/openapi/lib/model/user_admin_update_dto.dart @@ -13,6 +13,7 @@ part of openapi.api; class UserAdminUpdateDto { /// Returns a new [UserAdminUpdateDto] instance. UserAdminUpdateDto({ + this.avatarColor, this.email, this.name, this.password, @@ -21,6 +22,8 @@ class UserAdminUpdateDto { this.storageLabel, }); + UserAvatarColor? avatarColor; + /// /// Please note: This property should have been non-nullable! Since the specification file /// does not include a default value (using the "default:" property), however, the generated @@ -60,6 +63,7 @@ class UserAdminUpdateDto { @override bool operator ==(Object other) => identical(this, other) || other is UserAdminUpdateDto && + other.avatarColor == avatarColor && other.email == email && other.name == name && other.password == password && @@ -70,6 +74,7 @@ class UserAdminUpdateDto { @override int get hashCode => // ignore: unnecessary_parenthesis + (avatarColor == null ? 0 : avatarColor!.hashCode) + (email == null ? 0 : email!.hashCode) + (name == null ? 0 : name!.hashCode) + (password == null ? 0 : password!.hashCode) + @@ -78,10 +83,15 @@ class UserAdminUpdateDto { (storageLabel == null ? 0 : storageLabel!.hashCode); @override - String toString() => 'UserAdminUpdateDto[email=$email, name=$name, password=$password, quotaSizeInBytes=$quotaSizeInBytes, shouldChangePassword=$shouldChangePassword, storageLabel=$storageLabel]'; + String toString() => 'UserAdminUpdateDto[avatarColor=$avatarColor, email=$email, name=$name, password=$password, quotaSizeInBytes=$quotaSizeInBytes, shouldChangePassword=$shouldChangePassword, storageLabel=$storageLabel]'; Map toJson() { final json = {}; + if (this.avatarColor != null) { + json[r'avatarColor'] = this.avatarColor; + } else { + // json[r'avatarColor'] = null; + } if (this.email != null) { json[r'email'] = this.email; } else { @@ -124,6 +134,7 @@ class UserAdminUpdateDto { final json = value.cast(); return UserAdminUpdateDto( + avatarColor: UserAvatarColor.fromJson(json[r'avatarColor']), email: mapValueOfType(json, r'email'), name: mapValueOfType(json, r'name'), password: mapValueOfType(json, r'password'), diff --git a/mobile/openapi/lib/model/user_preferences_response_dto.dart b/mobile/openapi/lib/model/user_preferences_response_dto.dart index b244284eb0..215e691cb1 100644 --- a/mobile/openapi/lib/model/user_preferences_response_dto.dart +++ b/mobile/openapi/lib/model/user_preferences_response_dto.dart @@ -13,7 +13,6 @@ part of openapi.api; class UserPreferencesResponseDto { /// Returns a new [UserPreferencesResponseDto] instance. UserPreferencesResponseDto({ - required this.avatar, required this.download, required this.emailNotifications, required this.folders, @@ -25,8 +24,6 @@ class UserPreferencesResponseDto { required this.tags, }); - AvatarResponse avatar; - DownloadResponse download; EmailNotificationsResponse emailNotifications; @@ -47,7 +44,6 @@ class UserPreferencesResponseDto { @override bool operator ==(Object other) => identical(this, other) || other is UserPreferencesResponseDto && - other.avatar == avatar && other.download == download && other.emailNotifications == emailNotifications && other.folders == folders && @@ -61,7 +57,6 @@ class UserPreferencesResponseDto { @override int get hashCode => // ignore: unnecessary_parenthesis - (avatar.hashCode) + (download.hashCode) + (emailNotifications.hashCode) + (folders.hashCode) + @@ -73,11 +68,10 @@ class UserPreferencesResponseDto { (tags.hashCode); @override - String toString() => 'UserPreferencesResponseDto[avatar=$avatar, download=$download, emailNotifications=$emailNotifications, folders=$folders, memories=$memories, people=$people, purchase=$purchase, ratings=$ratings, sharedLinks=$sharedLinks, tags=$tags]'; + String toString() => 'UserPreferencesResponseDto[download=$download, emailNotifications=$emailNotifications, folders=$folders, memories=$memories, people=$people, purchase=$purchase, ratings=$ratings, sharedLinks=$sharedLinks, tags=$tags]'; Map toJson() { final json = {}; - json[r'avatar'] = this.avatar; json[r'download'] = this.download; json[r'emailNotifications'] = this.emailNotifications; json[r'folders'] = this.folders; @@ -99,7 +93,6 @@ class UserPreferencesResponseDto { final json = value.cast(); return UserPreferencesResponseDto( - avatar: AvatarResponse.fromJson(json[r'avatar'])!, download: DownloadResponse.fromJson(json[r'download'])!, emailNotifications: EmailNotificationsResponse.fromJson(json[r'emailNotifications'])!, folders: FoldersResponse.fromJson(json[r'folders'])!, @@ -156,7 +149,6 @@ class UserPreferencesResponseDto { /// The list of required keys that must be present in a JSON. static const requiredKeys = { - 'avatar', 'download', 'emailNotifications', 'folders', diff --git a/mobile/openapi/lib/model/user_update_me_dto.dart b/mobile/openapi/lib/model/user_update_me_dto.dart index 8f3f4df37a..779e07ffa6 100644 --- a/mobile/openapi/lib/model/user_update_me_dto.dart +++ b/mobile/openapi/lib/model/user_update_me_dto.dart @@ -13,11 +13,14 @@ part of openapi.api; class UserUpdateMeDto { /// Returns a new [UserUpdateMeDto] instance. UserUpdateMeDto({ + this.avatarColor, this.email, this.name, this.password, }); + UserAvatarColor? avatarColor; + /// /// Please note: This property should have been non-nullable! Since the specification file /// does not include a default value (using the "default:" property), however, the generated @@ -44,6 +47,7 @@ class UserUpdateMeDto { @override bool operator ==(Object other) => identical(this, other) || other is UserUpdateMeDto && + other.avatarColor == avatarColor && other.email == email && other.name == name && other.password == password; @@ -51,15 +55,21 @@ class UserUpdateMeDto { @override int get hashCode => // ignore: unnecessary_parenthesis + (avatarColor == null ? 0 : avatarColor!.hashCode) + (email == null ? 0 : email!.hashCode) + (name == null ? 0 : name!.hashCode) + (password == null ? 0 : password!.hashCode); @override - String toString() => 'UserUpdateMeDto[email=$email, name=$name, password=$password]'; + String toString() => 'UserUpdateMeDto[avatarColor=$avatarColor, email=$email, name=$name, password=$password]'; Map toJson() { final json = {}; + if (this.avatarColor != null) { + json[r'avatarColor'] = this.avatarColor; + } else { + // json[r'avatarColor'] = null; + } if (this.email != null) { json[r'email'] = this.email; } else { @@ -87,6 +97,7 @@ class UserUpdateMeDto { final json = value.cast(); return UserUpdateMeDto( + avatarColor: UserAvatarColor.fromJson(json[r'avatarColor']), email: mapValueOfType(json, r'email'), name: mapValueOfType(json, r'name'), password: mapValueOfType(json, r'password'), diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index f2851d7cf1..1471020cd4 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -8884,21 +8884,6 @@ ], "type": "string" }, - "AvatarResponse": { - "properties": { - "color": { - "allOf": [ - { - "$ref": "#/components/schemas/UserAvatarColor" - } - ] - } - }, - "required": [ - "color" - ], - "type": "object" - }, "AvatarUpdate": { "properties": { "color": { @@ -13621,6 +13606,14 @@ }, "UserAdminCreateDto": { "properties": { + "avatarColor": { + "allOf": [ + { + "$ref": "#/components/schemas/UserAvatarColor" + } + ], + "nullable": true + }, "email": { "format": "email", "type": "string" @@ -13763,6 +13756,14 @@ }, "UserAdminUpdateDto": { "properties": { + "avatarColor": { + "allOf": [ + { + "$ref": "#/components/schemas/UserAvatarColor" + } + ], + "nullable": true + }, "email": { "format": "email", "type": "string" @@ -13826,9 +13827,6 @@ }, "UserPreferencesResponseDto": { "properties": { - "avatar": { - "$ref": "#/components/schemas/AvatarResponse" - }, "download": { "$ref": "#/components/schemas/DownloadResponse" }, @@ -13858,7 +13856,6 @@ } }, "required": [ - "avatar", "download", "emailNotifications", "folders", @@ -13952,6 +13949,14 @@ }, "UserUpdateMeDto": { "properties": { + "avatarColor": { + "allOf": [ + { + "$ref": "#/components/schemas/UserAvatarColor" + } + ], + "nullable": true + }, "email": { "format": "email", "type": "string" diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index 51e17c08ac..1ba4d3e231 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -64,6 +64,7 @@ export type UserAdminResponseDto = { updatedAt: string; }; export type UserAdminCreateDto = { + avatarColor?: (UserAvatarColor) | null; email: string; name: string; notify?: boolean; @@ -76,6 +77,7 @@ export type UserAdminDeleteDto = { force?: boolean; }; export type UserAdminUpdateDto = { + avatarColor?: (UserAvatarColor) | null; email?: string; name?: string; password?: string; @@ -83,9 +85,6 @@ export type UserAdminUpdateDto = { shouldChangePassword?: boolean; storageLabel?: string | null; }; -export type AvatarResponse = { - color: UserAvatarColor; -}; export type DownloadResponse = { archiveSize: number; includeEmbeddedVideos: boolean; @@ -122,7 +121,6 @@ export type TagsResponse = { sidebarWeb: boolean; }; export type UserPreferencesResponseDto = { - avatar: AvatarResponse; download: DownloadResponse; emailNotifications: EmailNotificationsResponse; folders: FoldersResponse; @@ -1388,6 +1386,7 @@ export type TrashResponseDto = { count: number; }; export type UserUpdateMeDto = { + avatarColor?: (UserAvatarColor) | null; email?: string; name?: string; password?: string; diff --git a/server/src/database.ts b/server/src/database.ts index 27094958ed..0dab61cbe0 100644 --- a/server/src/database.ts +++ b/server/src/database.ts @@ -9,6 +9,7 @@ import { Permission, SharedLinkType, SourceType, + UserAvatarColor, UserStatus, } from 'src/enum'; import { OnThisDayData, UserMetadataItem } from 'src/types'; @@ -122,6 +123,7 @@ export type User = { id: string; name: string; email: string; + avatarColor: UserAvatarColor | null; profileImagePath: string; profileChangedAt: Date; }; @@ -264,7 +266,15 @@ export type AssetFace = { person?: Person | null; }; -const userColumns = ['id', 'name', 'email', 'profileImagePath', 'profileChangedAt'] as const; +const userColumns = ['id', 'name', 'email', 'avatarColor', 'profileImagePath', 'profileChangedAt'] as const; +const userWithPrefixColumns = [ + 'users.id', + 'users.name', + 'users.email', + 'users.avatarColor', + 'users.profileImagePath', + 'users.profileChangedAt', +] as const; export const columns = { asset: [ @@ -306,7 +316,7 @@ export const columns = { 'shared_links.password', ], user: userColumns, - userWithPrefix: ['users.id', 'users.name', 'users.email', 'users.profileImagePath', 'users.profileChangedAt'], + userWithPrefix: userWithPrefixColumns, userAdmin: [ ...userColumns, 'createdAt', diff --git a/server/src/dtos/user-preferences.dto.ts b/server/src/dtos/user-preferences.dto.ts index fe92838fdb..a9d32523ae 100644 --- a/server/src/dtos/user-preferences.dto.ts +++ b/server/src/dtos/user-preferences.dto.ts @@ -137,11 +137,6 @@ export class UserPreferencesUpdateDto { purchase?: PurchaseUpdate; } -class AvatarResponse { - @ApiProperty({ enumName: 'UserAvatarColor', enum: UserAvatarColor }) - color!: UserAvatarColor; -} - class RatingsResponse { enabled: boolean = false; } @@ -195,7 +190,6 @@ export class UserPreferencesResponseDto implements UserPreferences { ratings!: RatingsResponse; sharedLinks!: SharedLinksResponse; tags!: TagsResponse; - avatar!: AvatarResponse; emailNotifications!: EmailNotificationsResponse; download!: DownloadResponse; purchase!: PurchaseResponse; diff --git a/server/src/dtos/user.dto.ts b/server/src/dtos/user.dto.ts index 72e5c83b35..31275f9c28 100644 --- a/server/src/dtos/user.dto.ts +++ b/server/src/dtos/user.dto.ts @@ -1,10 +1,9 @@ import { ApiProperty } from '@nestjs/swagger'; import { Transform } from 'class-transformer'; -import { IsBoolean, IsEmail, IsNotEmpty, IsNumber, IsString, Min } from 'class-validator'; +import { IsBoolean, IsEmail, IsEnum, IsNotEmpty, IsNumber, IsString, Min } from 'class-validator'; import { User, UserAdmin } from 'src/database'; import { UserAvatarColor, UserMetadataKey, UserStatus } from 'src/enum'; import { UserMetadataItem } from 'src/types'; -import { getPreferences } from 'src/utils/preferences'; import { Optional, ValidateBoolean, toEmail, toSanitized } from 'src/validation'; export class UserUpdateMeDto { @@ -23,6 +22,11 @@ export class UserUpdateMeDto { @IsString() @IsNotEmpty() name?: string; + + @Optional({ nullable: true }) + @IsEnum(UserAvatarColor) + @ApiProperty({ enumName: 'UserAvatarColor', enum: UserAvatarColor }) + avatarColor?: UserAvatarColor | null; } export class UserResponseDto { @@ -41,13 +45,21 @@ export class UserLicense { activatedAt!: Date; } +const emailToAvatarColor = (email: string): UserAvatarColor => { + const values = Object.values(UserAvatarColor); + const randomIndex = Math.floor( + [...email].map((letter) => letter.codePointAt(0) ?? 0).reduce((a, b) => a + b, 0) % values.length, + ); + return values[randomIndex]; +}; + export const mapUser = (entity: User | UserAdmin): UserResponseDto => { return { id: entity.id, email: entity.email, name: entity.name, profileImagePath: entity.profileImagePath, - avatarColor: getPreferences(entity.email, (entity as UserAdmin).metadata || []).avatar.color, + avatarColor: entity.avatarColor ?? emailToAvatarColor(entity.email), profileChangedAt: entity.profileChangedAt, }; }; @@ -69,6 +81,11 @@ export class UserAdminCreateDto { @IsString() name!: string; + @Optional({ nullable: true }) + @IsEnum(UserAvatarColor) + @ApiProperty({ enumName: 'UserAvatarColor', enum: UserAvatarColor }) + avatarColor?: UserAvatarColor | null; + @Optional({ nullable: true }) @IsString() @Transform(toSanitized) @@ -104,6 +121,11 @@ export class UserAdminUpdateDto { @IsNotEmpty() name?: string; + @Optional({ nullable: true }) + @IsEnum(UserAvatarColor) + @ApiProperty({ enumName: 'UserAvatarColor', enum: UserAvatarColor }) + avatarColor?: UserAvatarColor | null; + @Optional({ nullable: true }) @IsString() @Transform(toSanitized) diff --git a/server/src/queries/activity.repository.sql b/server/src/queries/activity.repository.sql index c6e4c60a19..3040de8e03 100644 --- a/server/src/queries/activity.repository.sql +++ b/server/src/queries/activity.repository.sql @@ -13,6 +13,7 @@ from "users"."id", "users"."name", "users"."email", + "users"."avatarColor", "users"."profileImagePath", "users"."profileChangedAt" from @@ -44,6 +45,7 @@ returning "id", "name", "email", + "avatarColor", "profileImagePath", "profileChangedAt" from diff --git a/server/src/queries/album.repository.sql b/server/src/queries/album.repository.sql index b89cbfb0b9..f4eb6a9929 100644 --- a/server/src/queries/album.repository.sql +++ b/server/src/queries/album.repository.sql @@ -12,6 +12,7 @@ select "id", "name", "email", + "avatarColor", "profileImagePath", "profileChangedAt" from @@ -36,6 +37,7 @@ select "id", "name", "email", + "avatarColor", "profileImagePath", "profileChangedAt" from @@ -100,6 +102,7 @@ select "id", "name", "email", + "avatarColor", "profileImagePath", "profileChangedAt" from @@ -124,6 +127,7 @@ select "id", "name", "email", + "avatarColor", "profileImagePath", "profileChangedAt" from @@ -191,6 +195,7 @@ select "id", "name", "email", + "avatarColor", "profileImagePath", "profileChangedAt" from @@ -215,6 +220,7 @@ select "id", "name", "email", + "avatarColor", "profileImagePath", "profileChangedAt" from @@ -269,6 +275,7 @@ select "id", "name", "email", + "avatarColor", "profileImagePath", "profileChangedAt" from @@ -292,6 +299,7 @@ select "id", "name", "email", + "avatarColor", "profileImagePath", "profileChangedAt" from @@ -353,6 +361,7 @@ select "id", "name", "email", + "avatarColor", "profileImagePath", "profileChangedAt" from diff --git a/server/src/queries/partner.repository.sql b/server/src/queries/partner.repository.sql index e115dc34b9..e7170f367e 100644 --- a/server/src/queries/partner.repository.sql +++ b/server/src/queries/partner.repository.sql @@ -12,6 +12,7 @@ select "id", "name", "email", + "avatarColor", "profileImagePath", "profileChangedAt" from @@ -29,6 +30,7 @@ select "id", "name", "email", + "avatarColor", "profileImagePath", "profileChangedAt" from @@ -61,6 +63,7 @@ select "id", "name", "email", + "avatarColor", "profileImagePath", "profileChangedAt" from @@ -78,6 +81,7 @@ select "id", "name", "email", + "avatarColor", "profileImagePath", "profileChangedAt" from @@ -112,6 +116,7 @@ returning "id", "name", "email", + "avatarColor", "profileImagePath", "profileChangedAt" from @@ -129,6 +134,7 @@ returning "id", "name", "email", + "avatarColor", "profileImagePath", "profileChangedAt" from @@ -156,6 +162,7 @@ returning "id", "name", "email", + "avatarColor", "profileImagePath", "profileChangedAt" from @@ -173,6 +180,7 @@ returning "id", "name", "email", + "avatarColor", "profileImagePath", "profileChangedAt" from diff --git a/server/src/queries/user.repository.sql b/server/src/queries/user.repository.sql index 1212d0f2bd..e8ab5018fc 100644 --- a/server/src/queries/user.repository.sql +++ b/server/src/queries/user.repository.sql @@ -5,6 +5,7 @@ select "id", "name", "email", + "avatarColor", "profileImagePath", "profileChangedAt", "createdAt", @@ -43,6 +44,7 @@ select "id", "name", "email", + "avatarColor", "profileImagePath", "profileChangedAt", "createdAt", @@ -90,6 +92,7 @@ select "id", "name", "email", + "avatarColor", "profileImagePath", "profileChangedAt", "createdAt", @@ -128,6 +131,7 @@ select "id", "name", "email", + "avatarColor", "profileImagePath", "profileChangedAt", "createdAt", @@ -152,6 +156,7 @@ select "id", "name", "email", + "avatarColor", "profileImagePath", "profileChangedAt", "createdAt", @@ -198,6 +203,7 @@ select "id", "name", "email", + "avatarColor", "profileImagePath", "profileChangedAt", "createdAt", @@ -235,6 +241,7 @@ select "id", "name", "email", + "avatarColor", "profileImagePath", "profileChangedAt", "createdAt", diff --git a/server/src/schema/migrations/1745244781846-AddUserAvatarColorColumn.ts b/server/src/schema/migrations/1745244781846-AddUserAvatarColorColumn.ts new file mode 100644 index 0000000000..5f3fdbedc8 --- /dev/null +++ b/server/src/schema/migrations/1745244781846-AddUserAvatarColorColumn.ts @@ -0,0 +1,14 @@ +import { Kysely, sql } from 'kysely'; + +export async function up(db: Kysely): Promise { + await sql`ALTER TABLE "users" ADD "avatarColor" character varying;`.execute(db); + await sql` + UPDATE "users" + SET "avatarColor" = "user_metadata"."value"->'avatar'->>'color' + FROM "user_metadata" + WHERE "users"."id" = "user_metadata"."userId" AND "user_metadata"."key" = 'preferences';`.execute(db); +} + +export async function down(db: Kysely): Promise { + await sql`ALTER TABLE "users" DROP COLUMN "avatarColor";`.execute(db); +} diff --git a/server/src/schema/tables/user.table.ts b/server/src/schema/tables/user.table.ts index eeef923796..7525a739a6 100644 --- a/server/src/schema/tables/user.table.ts +++ b/server/src/schema/tables/user.table.ts @@ -1,6 +1,6 @@ import { ColumnType } from 'kysely'; import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators'; -import { UserStatus } from 'src/enum'; +import { UserAvatarColor, UserStatus } from 'src/enum'; import { users_delete_audit } from 'src/schema/functions'; import { AfterDeleteTrigger, @@ -49,6 +49,9 @@ export class UserTable { @Column({ type: 'boolean', default: true }) shouldChangePassword!: Generated; + @Column({ default: null }) + avatarColor!: UserAvatarColor | null; + @DeleteDateColumn() deletedAt!: Timestamp | null; diff --git a/server/src/services/download.service.ts b/server/src/services/download.service.ts index cb664aea32..02711b9bfd 100644 --- a/server/src/services/download.service.ts +++ b/server/src/services/download.service.ts @@ -33,7 +33,7 @@ export class DownloadService extends BaseService { const targetSize = dto.archiveSize || HumanReadableSize.GiB * 4; const metadata = await this.userRepository.getMetadata(auth.user.id); - const preferences = getPreferences(auth.user.email, metadata); + const preferences = getPreferences(metadata); const motionIds = new Set(); const archives: DownloadArchiveInfo[] = []; let archive: DownloadArchiveInfo = { size: 0, assetIds: [] }; diff --git a/server/src/services/notification.service.ts b/server/src/services/notification.service.ts index 2e456718ca..573be90f93 100644 --- a/server/src/services/notification.service.ts +++ b/server/src/services/notification.service.ts @@ -271,7 +271,7 @@ export class NotificationService extends BaseService { return JobStatus.SKIPPED; } - const { emailNotifications } = getPreferences(recipient.email, recipient.metadata); + const { emailNotifications } = getPreferences(recipient.metadata); if (!emailNotifications.enabled || !emailNotifications.albumInvite) { return JobStatus.SKIPPED; @@ -333,7 +333,7 @@ export class NotificationService extends BaseService { continue; } - const { emailNotifications } = getPreferences(user.email, user.metadata); + const { emailNotifications } = getPreferences(user.metadata); if (!emailNotifications.enabled || !emailNotifications.albumUpdate) { continue; diff --git a/server/src/services/user-admin.service.ts b/server/src/services/user-admin.service.ts index 0cba749d36..c1c6cc49ec 100644 --- a/server/src/services/user-admin.service.ts +++ b/server/src/services/user-admin.service.ts @@ -106,21 +106,19 @@ export class UserAdminService extends BaseService { } async getPreferences(auth: AuthDto, id: string): Promise { - const { email } = await this.findOrFail(id, { withDeleted: true }); + await this.findOrFail(id, { withDeleted: true }); const metadata = await this.userRepository.getMetadata(id); - const preferences = getPreferences(email, metadata); - return mapPreferences(preferences); + return mapPreferences(getPreferences(metadata)); } async updatePreferences(auth: AuthDto, id: string, dto: UserPreferencesUpdateDto) { - const { email } = await this.findOrFail(id, { withDeleted: false }); + await this.findOrFail(id, { withDeleted: false }); const metadata = await this.userRepository.getMetadata(id); - const preferences = getPreferences(email, metadata); - const newPreferences = mergePreferences(preferences, dto); + const newPreferences = mergePreferences(getPreferences(metadata), dto); await this.userRepository.upsertMetadata(id, { key: UserMetadataKey.PREFERENCES, - value: getPreferencesPartial({ email }, newPreferences), + value: getPreferencesPartial(newPreferences), }); return mapPreferences(newPreferences); diff --git a/server/src/services/user.service.ts b/server/src/services/user.service.ts index 327328eb1c..a0304d51ad 100644 --- a/server/src/services/user.service.ts +++ b/server/src/services/user.service.ts @@ -53,6 +53,7 @@ export class UserService extends BaseService { const update: Updateable = { email: dto.email, name: dto.name, + avatarColor: dto.avatarColor, }; if (dto.password) { @@ -68,18 +69,16 @@ export class UserService extends BaseService { async getMyPreferences(auth: AuthDto): Promise { const metadata = await this.userRepository.getMetadata(auth.user.id); - const preferences = getPreferences(auth.user.email, metadata); - return mapPreferences(preferences); + return mapPreferences(getPreferences(metadata)); } async updateMyPreferences(auth: AuthDto, dto: UserPreferencesUpdateDto) { const metadata = await this.userRepository.getMetadata(auth.user.id); - const current = getPreferences(auth.user.email, metadata); - const updated = mergePreferences(current, dto); + const updated = mergePreferences(getPreferences(metadata), dto); await this.userRepository.upsertMetadata(auth.user.id, { key: UserMetadataKey.PREFERENCES, - value: getPreferencesPartial(auth.user, updated), + value: getPreferencesPartial(updated), }); return mapPreferences(updated); diff --git a/server/src/types.ts b/server/src/types.ts index 88ba644739..c5375ae727 100644 --- a/server/src/types.ts +++ b/server/src/types.ts @@ -11,7 +11,6 @@ import { SyncEntityType, SystemMetadataKey, TranscodeTarget, - UserAvatarColor, UserMetadataKey, VideoCodec, } from 'src/enum'; @@ -486,9 +485,6 @@ export interface UserPreferences { enabled: boolean; sidebarWeb: boolean; }; - avatar: { - color: UserAvatarColor; - }; emailNotifications: { enabled: boolean; albumInvite: boolean; diff --git a/server/src/utils/preferences.ts b/server/src/utils/preferences.ts index 584c5300cd..a013c0b74e 100644 --- a/server/src/utils/preferences.ts +++ b/server/src/utils/preferences.ts @@ -1,16 +1,11 @@ import _ from 'lodash'; import { UserPreferencesUpdateDto } from 'src/dtos/user-preferences.dto'; -import { UserAvatarColor, UserMetadataKey } from 'src/enum'; +import { UserMetadataKey } from 'src/enum'; import { DeepPartial, UserMetadataItem, UserPreferences } from 'src/types'; import { HumanReadableSize } from 'src/utils/bytes'; import { getKeysDeep } from 'src/utils/misc'; -const getDefaultPreferences = (user: { email: string }): UserPreferences => { - const values = Object.values(UserAvatarColor); - const randomIndex = Math.floor( - [...user.email].map((letter) => letter.codePointAt(0) ?? 0).reduce((a, b) => a + b, 0) % values.length, - ); - +const getDefaultPreferences = (): UserPreferences => { return { folders: { enabled: false, @@ -34,9 +29,6 @@ const getDefaultPreferences = (user: { email: string }): UserPreferences => { enabled: false, sidebarWeb: false, }, - avatar: { - color: values[randomIndex], - }, emailNotifications: { enabled: true, albumInvite: true, @@ -53,8 +45,8 @@ const getDefaultPreferences = (user: { email: string }): UserPreferences => { }; }; -export const getPreferences = (email: string, metadata: UserMetadataItem[]): UserPreferences => { - const preferences = getDefaultPreferences({ email }); +export const getPreferences = (metadata: UserMetadataItem[]): UserPreferences => { + const preferences = getDefaultPreferences(); const item = metadata.find(({ key }) => key === UserMetadataKey.PREFERENCES); const partial = item?.value || {}; for (const property of getKeysDeep(partial)) { @@ -64,8 +56,8 @@ export const getPreferences = (email: string, metadata: UserMetadataItem[]): Use return preferences; }; -export const getPreferencesPartial = (user: { email: string }, newPreferences: UserPreferences) => { - const defaultPreferences = getDefaultPreferences(user); +export const getPreferencesPartial = (newPreferences: UserPreferences) => { + const defaultPreferences = getDefaultPreferences(); const partial: DeepPartial = {}; for (const property of getKeysDeep(defaultPreferences)) { const newValue = _.get(newPreferences, property); diff --git a/server/test/fixtures/user.stub.ts b/server/test/fixtures/user.stub.ts index f0043d174a..0db58e2eed 100644 --- a/server/test/fixtures/user.stub.ts +++ b/server/test/fixtures/user.stub.ts @@ -1,5 +1,5 @@ import { UserAdmin } from 'src/database'; -import { UserAvatarColor, UserMetadataKey, UserStatus } from 'src/enum'; +import { UserStatus } from 'src/enum'; import { authStub } from 'test/fixtures/auth.stub'; export const userStub = { @@ -12,6 +12,7 @@ export const userStub = { storageLabel: 'admin', oauthId: '', shouldChangePassword: false, + avatarColor: null, profileImagePath: '', createdAt: new Date('2021-01-01'), deletedAt: null, @@ -28,16 +29,12 @@ export const userStub = { storageLabel: null, oauthId: '', shouldChangePassword: false, + avatarColor: null, profileImagePath: '', createdAt: new Date('2021-01-01'), deletedAt: null, updatedAt: new Date('2021-01-01'), - metadata: [ - { - key: UserMetadataKey.PREFERENCES, - value: { avatar: { color: UserAvatarColor.PRIMARY } }, - }, - ], + metadata: [], quotaSizeInBytes: null, quotaUsageInBytes: 0, }, @@ -50,6 +47,7 @@ export const userStub = { storageLabel: null, oauthId: '', shouldChangePassword: false, + avatarColor: null, profileImagePath: '', createdAt: new Date('2021-01-01'), deletedAt: null, diff --git a/server/test/small.factory.ts b/server/test/small.factory.ts index 29eef7002e..919cdd4b1c 100644 --- a/server/test/small.factory.ts +++ b/server/test/small.factory.ts @@ -140,6 +140,7 @@ const userFactory = (user: Partial = {}) => ({ id: newUuid(), name: 'Test User', email: 'test@immich.cloud', + avatarColor: null, profileImagePath: '', profileChangedAt: newDate(), ...user, @@ -155,6 +156,7 @@ const userAdminFactory = (user: Partial = {}) => { storageLabel = null, shouldChangePassword = false, isAdmin = false, + avatarColor = null, createdAt = newDate(), updatedAt = newDate(), deletedAt = null, @@ -173,6 +175,7 @@ const userAdminFactory = (user: Partial = {}) => { storageLabel, shouldChangePassword, isAdmin, + avatarColor, createdAt, updatedAt, deletedAt, 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 92db67eba0..5b778cf227 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 @@ -5,9 +5,9 @@ import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte'; import Icon from '$lib/components/elements/icon.svelte'; import { AppRoute } from '$lib/constants'; - import { preferences, user } from '$lib/stores/user.store'; + import { user } from '$lib/stores/user.store'; import { handleError } from '$lib/utils/handle-error'; - import { deleteProfileImage, updateMyPreferences, type UserAvatarColor } from '@immich/sdk'; + import { deleteProfileImage, updateMyUser, type UserAvatarColor } from '@immich/sdk'; import { mdiCog, mdiLogout, mdiPencil, mdiWrench } from '@mdi/js'; import { t } from 'svelte-i18n'; import { fade } from 'svelte/transition'; @@ -30,8 +30,7 @@ await deleteProfileImage(); } - $preferences = await updateMyPreferences({ userPreferencesUpdateDto: { avatar: { color } } }); - $user = { ...$user, profileImagePath: '', avatarColor: $preferences.avatar.color }; + $user = await updateMyUser({ userUpdateMeDto: { avatarColor: color } }); isShowSelectAvatar = false; notificationController.show({ From 21c7d7033671869c4b4bb6f4f2c27cb07f29a7b4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andreas=20Tollk=C3=B6tter?= <1518021+atollk@users.noreply.github.com> Date: Mon, 28 Apr 2025 15:56:36 +0200 Subject: [PATCH 20/31] feat(mobile): Capitalize month names in asset grid (#17898) * capitalize month titles * capitalize day titles as well --- mobile/lib/widgets/asset_grid/immich_asset_grid_view.dart | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/mobile/lib/widgets/asset_grid/immich_asset_grid_view.dart b/mobile/lib/widgets/asset_grid/immich_asset_grid_view.dart index a7141c33b2..060898e270 100644 --- a/mobile/lib/widgets/asset_grid/immich_asset_grid_view.dart +++ b/mobile/lib/widgets/asset_grid/immich_asset_grid_view.dart @@ -755,7 +755,7 @@ class _MonthTitle extends StatelessWidget { key: Key("month-$title"), padding: const EdgeInsets.only(left: 12.0, top: 24.0), child: Text( - title, + toBeginningOfSentenceCase(title, context.locale.languageCode), style: const TextStyle( fontSize: 26, fontWeight: FontWeight.w500, @@ -786,7 +786,7 @@ class _Title extends StatelessWidget { @override Widget build(BuildContext context) { return GroupDividerTitle( - text: title, + text: toBeginningOfSentenceCase(title, context.locale.languageCode), multiselectEnabled: selectionActive, onSelect: () => selectAssets(assets), onDeselect: () => deselectAssets(assets), From c664d99a348999d16cda45a8dd57db436b8f9fba Mon Sep 17 00:00:00 2001 From: Min Idzelis Date: Mon, 28 Apr 2025 10:11:19 -0400 Subject: [PATCH 21/31] refactor: vscode - format/organize on save (#17928) --- .vscode/settings.json | 80 ++++++++++++++++++++++++++----------------- 1 file changed, 49 insertions(+), 31 deletions(-) diff --git a/.vscode/settings.json b/.vscode/settings.json index 49692809bc..396755a634 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,45 +1,63 @@ { - "editor.formatOnSave": true, - "[javascript]": { - "editor.defaultFormatter": "esbenp.prettier-vscode", - "editor.tabSize": 2, - "editor.formatOnSave": true - }, - "[typescript]": { - "editor.defaultFormatter": "esbenp.prettier-vscode", - "editor.tabSize": 2, - "editor.formatOnSave": true - }, "[css]": { "editor.defaultFormatter": "esbenp.prettier-vscode", - "editor.tabSize": 2, - "editor.formatOnSave": true - }, - "[svelte]": { - "editor.defaultFormatter": "svelte.svelte-vscode", + "editor.formatOnSave": true, "editor.tabSize": 2 }, - "svelte.enable-ts-plugin": true, - "eslint.validate": [ - "javascript", - "svelte" - ], - "typescript.preferences.importModuleSpecifier": "non-relative", "[dart]": { + "editor.defaultFormatter": "Dart-Code.dart-code", "editor.formatOnSave": true, "editor.selectionHighlight": false, "editor.suggest.snippetsPreventQuickSuggestions": false, "editor.suggestSelection": "first", "editor.tabCompletion": "onlySnippets", - "editor.wordBasedSuggestions": "off", - "editor.defaultFormatter": "Dart-Code.dart-code" + "editor.wordBasedSuggestions": "off" }, - "cSpell.words": [ - "immich" - ], + "[javascript]": { + "editor.codeActionsOnSave": { + "source.organizeImports": "explicit", + "source.removeUnusedImports": "explicit" + }, + "editor.defaultFormatter": "esbenp.prettier-vscode", + "editor.formatOnSave": true, + "editor.tabSize": 2 + }, + "[json]": { + "editor.defaultFormatter": "esbenp.prettier-vscode", + "editor.formatOnSave": true, + "editor.tabSize": 2 + }, + "[jsonc]": { + "editor.defaultFormatter": "esbenp.prettier-vscode", + "editor.formatOnSave": true, + "editor.tabSize": 2 + }, + "[svelte]": { + "editor.codeActionsOnSave": { + "source.organizeImports": "explicit", + "source.removeUnusedImports": "explicit" + }, + "editor.defaultFormatter": "svelte.svelte-vscode", + "editor.formatOnSave": true, + "editor.tabSize": 2 + }, + "[typescript]": { + "editor.codeActionsOnSave": { + "source.organizeImports": "explicit", + "source.removeUnusedImports": "explicit" + }, + "editor.defaultFormatter": "esbenp.prettier-vscode", + "editor.formatOnSave": true, + "editor.tabSize": 2 + }, + "cSpell.words": ["immich"], + "editor.formatOnSave": true, + "eslint.validate": ["javascript", "svelte"], "explorer.fileNesting.enabled": true, "explorer.fileNesting.patterns": { - "*.ts": "${capture}.spec.ts,${capture}.mock.ts", - "*.dart": "${capture}.g.dart,${capture}.gr.dart,${capture}.drift.dart" - } -} \ No newline at end of file + "*.dart": "${capture}.g.dart,${capture}.gr.dart,${capture}.drift.dart", + "*.ts": "${capture}.spec.ts,${capture}.mock.ts" + }, + "svelte.enable-ts-plugin": true, + "typescript.preferences.importModuleSpecifier": "non-relative" +} From 2fd05e84470f94d461db2cda743dd237ab66c1a9 Mon Sep 17 00:00:00 2001 From: Min Idzelis Date: Mon, 28 Apr 2025 10:23:05 -0400 Subject: [PATCH 22/31] feat: preload and cancel images with a service worker (#16893) * feat: Service Worker to preload/cancel images and other resources * Remove caddy configuration, localhost is secure if port-forwarded * fix e2e tests * Cache/return the app.html for all web entry points * Only handle preload/cancel * fix e2e * fix e2e * e2e-2 * that'll do it * format * fix test * lint * refactor common code to conditionals --------- Co-authored-by: Alex --- Makefile | 3 + e2e/src/web/specs/photo-viewer.e2e-spec.ts | 14 --- web/eslint.config.js | 2 + .../asset-viewer/photo-viewer.svelte | 5 +- .../assets/thumbnail/image-thumbnail.svelte | 7 +- web/src/lib/utils/sw-messaging.ts | 8 ++ web/src/service-worker/index.ts | 86 +++++++++++++++++++ 7 files changed, 108 insertions(+), 17 deletions(-) create mode 100644 web/src/lib/utils/sw-messaging.ts create mode 100644 web/src/service-worker/index.ts diff --git a/Makefile b/Makefile index e15faa8051..1e7760ae68 100644 --- a/Makefile +++ b/Makefile @@ -17,6 +17,9 @@ e2e: prod: docker compose -f ./docker/docker-compose.prod.yml up --build -V --remove-orphans +prod-down: + docker compose -f ./docker/docker-compose.prod.yml down --remove-orphans + prod-scale: docker compose -f ./docker/docker-compose.prod.yml up --build -V --scale immich-server=3 --scale immich-microservices=3 --remove-orphans diff --git a/e2e/src/web/specs/photo-viewer.e2e-spec.ts b/e2e/src/web/specs/photo-viewer.e2e-spec.ts index 4871e7522c..c8a9b42b2a 100644 --- a/e2e/src/web/specs/photo-viewer.e2e-spec.ts +++ b/e2e/src/web/specs/photo-viewer.e2e-spec.ts @@ -21,23 +21,9 @@ test.describe('Photo Viewer', () => { test.beforeEach(async ({ context, page }) => { // before each test, login as user await utils.setAuthCookies(context, admin.accessToken); - await page.goto('/photos'); await page.waitForLoadState('networkidle'); }); - test('initially shows a loading spinner', async ({ page }) => { - await page.route(`/api/assets/${asset.id}/thumbnail**`, async (route) => { - // slow down the request for thumbnail, so spinner has chance to show up - await new Promise((f) => setTimeout(f, 2000)); - await route.continue(); - }); - await page.goto(`/photos/${asset.id}`); - await page.waitForLoadState('load'); - // this is the spinner - await page.waitForSelector('svg[role=status]'); - await expect(page.getByTestId('loading-spinner')).toBeVisible(); - }); - test('loads original photo when zoomed', async ({ page }) => { await page.goto(`/photos/${asset.id}`); await expect.poll(async () => await imageLocator(page).getAttribute('src')).toContain('thumbnail'); diff --git a/web/eslint.config.js b/web/eslint.config.js index 5c24cd1aeb..9ced619504 100644 --- a/web/eslint.config.js +++ b/web/eslint.config.js @@ -58,6 +58,8 @@ export default typescriptEslint.config( }, }, + ignores: ['**/service-worker/**'], + rules: { '@typescript-eslint/no-unused-vars': [ 'warn', diff --git a/web/src/lib/components/asset-viewer/photo-viewer.svelte b/web/src/lib/components/asset-viewer/photo-viewer.svelte index fdb986786e..531f075b86 100644 --- a/web/src/lib/components/asset-viewer/photo-viewer.svelte +++ b/web/src/lib/components/asset-viewer/photo-viewer.svelte @@ -21,6 +21,7 @@ import FaceEditor from '$lib/components/asset-viewer/face-editor/face-editor.svelte'; import { photoViewerImgElement } from '$lib/stores/assets-store.svelte'; import { isFaceEditMode } from '$lib/stores/face-edit.svelte'; + import { cancelImageUrl, preloadImageUrl } from '$lib/utils/sw-messaging'; interface Props { asset: AssetResponseDto; @@ -71,8 +72,7 @@ const preload = (targetSize: AssetMediaSize | 'original', preloadAssets?: AssetResponseDto[]) => { for (const preloadAsset of preloadAssets || []) { if (preloadAsset.type === AssetTypeEnum.Image) { - let img = new Image(); - img.src = getAssetUrl(preloadAsset.id, targetSize, preloadAsset.thumbhash); + preloadImageUrl(getAssetUrl(preloadAsset.id, targetSize, preloadAsset.thumbhash)); } } }; @@ -168,6 +168,7 @@ return () => { loader?.removeEventListener('load', onload); loader?.removeEventListener('error', onerror); + cancelImageUrl(imageLoaderUrl); }; }); diff --git a/web/src/lib/components/assets/thumbnail/image-thumbnail.svelte b/web/src/lib/components/assets/thumbnail/image-thumbnail.svelte index 2e8ad6ca32..04493b273c 100644 --- a/web/src/lib/components/assets/thumbnail/image-thumbnail.svelte +++ b/web/src/lib/components/assets/thumbnail/image-thumbnail.svelte @@ -2,9 +2,11 @@ import { thumbhash } from '$lib/actions/thumbhash'; import BrokenAsset from '$lib/components/assets/broken-asset.svelte'; import Icon from '$lib/components/elements/icon.svelte'; + import { cancelImageUrl } from '$lib/utils/sw-messaging'; import { TUNABLES } from '$lib/utils/tunables'; import { mdiEyeOffOutline } from '@mdi/js'; import type { ClassValue } from 'svelte/elements'; + import type { ActionReturn } from 'svelte/action'; import { fade } from 'svelte/transition'; interface Props { @@ -59,11 +61,14 @@ onComplete?.(true); }; - function mount(elem: HTMLImageElement) { + function mount(elem: HTMLImageElement): ActionReturn { if (elem.complete) { loaded = true; onComplete?.(false); } + return { + destroy: () => cancelImageUrl(url), + }; } let optionalClasses = $derived( diff --git a/web/src/lib/utils/sw-messaging.ts b/web/src/lib/utils/sw-messaging.ts new file mode 100644 index 0000000000..1a19d3c134 --- /dev/null +++ b/web/src/lib/utils/sw-messaging.ts @@ -0,0 +1,8 @@ +const broadcast = new BroadcastChannel('immich'); + +export function cancelImageUrl(url: string) { + broadcast.postMessage({ type: 'cancel', url }); +} +export function preloadImageUrl(url: string) { + broadcast.postMessage({ type: 'preload', url }); +} diff --git a/web/src/service-worker/index.ts b/web/src/service-worker/index.ts new file mode 100644 index 0000000000..797f4754b6 --- /dev/null +++ b/web/src/service-worker/index.ts @@ -0,0 +1,86 @@ +/// +/// +/// +/// +import { version } from '$service-worker'; + +const useCache = true; +const sw = globalThis as unknown as ServiceWorkerGlobalScope; +const pendingLoads = new Map(); + +// Create a unique cache name for this deployment +const CACHE = `cache-${version}`; + +sw.addEventListener('install', (event) => { + event.waitUntil(sw.skipWaiting()); +}); + +sw.addEventListener('activate', (event) => { + event.waitUntil(sw.clients.claim()); + // Remove previous cached data from disk + event.waitUntil(deleteOldCaches()); +}); + +sw.addEventListener('fetch', (event) => { + if (event.request.method !== 'GET') { + return; + } + const url = new URL(event.request.url); + if (/^\/api\/assets\/[a-f0-9-]+\/(original|thumbnail)/.test(url.pathname)) { + event.respondWith(immichAsset(url)); + } +}); + +async function deleteOldCaches() { + for (const key of await caches.keys()) { + if (key !== CACHE) { + await caches.delete(key); + } + } +} + +async function immichAsset(url: URL) { + const cache = await caches.open(CACHE); + let response = useCache ? await cache.match(url) : undefined; + if (response) { + return response; + } + try { + const cancelToken = new AbortController(); + const request = fetch(url, { + signal: cancelToken.signal, + }); + pendingLoads.set(url.toString(), cancelToken); + response = await request; + if (!(response instanceof Response)) { + throw new TypeError('invalid response from fetch'); + } + if (response.status === 200) { + cache.put(url, response.clone()); + } + return response; + } catch { + return Response.error(); + } finally { + pendingLoads.delete(url.toString()); + } +} + +const broadcast = new BroadcastChannel('immich'); +// eslint-disable-next-line unicorn/prefer-add-event-listener +broadcast.onmessage = (event) => { + if (!event.data) { + return; + } + const urlstring = event.data.url; + const url = new URL(urlstring, event.origin); + if (event.data.type === 'cancel') { + const pending = pendingLoads.get(url.toString()); + if (pending) { + pending.abort(); + pendingLoads.delete(url.toString()); + } + } else if (event.data.type === 'preload') { + immichAsset(url); + } +}; From 23717ce98155382b7cc2bb52fdc88395f81496b6 Mon Sep 17 00:00:00 2001 From: Yaros Date: Mon, 28 Apr 2025 16:23:33 +0200 Subject: [PATCH 23/31] feat(mobile): save grid size on gesture resize (#17891) --- mobile/lib/widgets/asset_grid/immich_asset_grid.dart | 1 + 1 file changed, 1 insertion(+) diff --git a/mobile/lib/widgets/asset_grid/immich_asset_grid.dart b/mobile/lib/widgets/asset_grid/immich_asset_grid.dart index 2ec01e871f..da4c47e466 100644 --- a/mobile/lib/widgets/asset_grid/immich_asset_grid.dart +++ b/mobile/lib/widgets/asset_grid/immich_asset_grid.dart @@ -97,6 +97,7 @@ class ImmichAssetGrid extends HookConsumerWidget { ); if (7 - scaleFactor.value.toInt() != perRow.value) { perRow.value = 7 - scaleFactor.value.toInt(); + settings.setSetting(AppSettingsEnum.tilesPerRow, perRow.value); } }; }), From 1b5fc9c66588e33b7818135af6cb3b9f4e1f04f3 Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Mon, 28 Apr 2025 10:36:14 -0400 Subject: [PATCH 24/31] feat: notifications (#17701) * feat: notifications * UI works * chore: pr feedback * initial fetch and clear notification upon logging out * fix: merge --------- Co-authored-by: Alex Tran --- i18n/en.json | 5 + mobile/openapi/README.md | 18 +- mobile/openapi/lib/api.dart | 8 + .../lib/api/notifications_admin_api.dart | 55 +- mobile/openapi/lib/api/notifications_api.dart | 311 ++++++++++ mobile/openapi/lib/api_client.dart | 14 + mobile/openapi/lib/api_helper.dart | 6 + .../lib/model/notification_create_dto.dart | 180 ++++++ .../model/notification_delete_all_dto.dart | 101 ++++ .../openapi/lib/model/notification_dto.dart | 182 ++++++ .../openapi/lib/model/notification_level.dart | 91 +++ .../openapi/lib/model/notification_type.dart | 91 +++ .../model/notification_update_all_dto.dart | 112 ++++ .../lib/model/notification_update_dto.dart | 102 ++++ mobile/openapi/lib/model/permission.dart | 12 + open-api/immich-openapi-specs.json | 555 ++++++++++++++++-- open-api/typescript-sdk/src/fetch-client.ts | 194 ++++-- server/src/controllers/index.ts | 2 + .../notification-admin.controller.ts | 20 +- .../controllers/notification.controller.ts | 60 ++ server/src/database.ts | 1 + server/src/db.d.ts | 18 + server/src/dtos/notification.dto.ts | 108 +++- server/src/enum.ts | 20 + server/src/queries/access.repository.sql | 9 + .../src/queries/notification.repository.sql | 58 ++ server/src/repositories/access.repository.ts | 22 + server/src/repositories/event.repository.ts | 3 + server/src/repositories/index.ts | 4 +- .../repositories/notification.repository.ts | 103 ++++ server/src/schema/index.ts | 2 + .../1744991379464-AddNotificationsTable.ts | 22 + .../src/schema/tables/notification.table.ts | 52 ++ server/src/services/backup.service.spec.ts | 27 +- server/src/services/backup.service.ts | 2 +- server/src/services/base.service.ts | 2 + server/src/services/index.ts | 2 + server/src/services/job.service.ts | 6 +- .../notification-admin.service.spec.ts | 111 ++++ .../services/notification-admin.service.ts | 120 ++++ .../src/services/notification.service.spec.ts | 77 --- server/src/services/notification.service.ts | 93 ++- server/src/types.ts | 4 + server/src/utils/access.ts | 6 + server/test/medium.factory.ts | 24 +- .../notification.controller.spec.ts | 86 +++ .../repositories/access.repository.mock.ts | 4 + server/test/small.factory.ts | 1 + server/test/utils.ts | 4 + .../navigation-bar/navigation-bar.svelte | 27 +- .../navigation-bar/notification-item.svelte | 114 ++++ .../navigation-bar/notification-panel.svelte | 82 +++ .../lib/stores/notification-manager.svelte.ts | 38 ++ web/src/lib/stores/websocket.ts | 5 +- web/src/routes/auth/login/+page.svelte | 6 +- 55 files changed, 3186 insertions(+), 196 deletions(-) create mode 100644 mobile/openapi/lib/api/notifications_api.dart create mode 100644 mobile/openapi/lib/model/notification_create_dto.dart create mode 100644 mobile/openapi/lib/model/notification_delete_all_dto.dart create mode 100644 mobile/openapi/lib/model/notification_dto.dart create mode 100644 mobile/openapi/lib/model/notification_level.dart create mode 100644 mobile/openapi/lib/model/notification_type.dart create mode 100644 mobile/openapi/lib/model/notification_update_all_dto.dart create mode 100644 mobile/openapi/lib/model/notification_update_dto.dart create mode 100644 server/src/controllers/notification.controller.ts create mode 100644 server/src/queries/notification.repository.sql create mode 100644 server/src/repositories/notification.repository.ts create mode 100644 server/src/schema/migrations/1744991379464-AddNotificationsTable.ts create mode 100644 server/src/schema/tables/notification.table.ts create mode 100644 server/src/services/notification-admin.service.spec.ts create mode 100644 server/src/services/notification-admin.service.ts create mode 100644 server/test/medium/specs/controllers/notification.controller.spec.ts create mode 100644 web/src/lib/components/shared-components/navigation-bar/notification-item.svelte create mode 100644 web/src/lib/components/shared-components/navigation-bar/notification-panel.svelte create mode 100644 web/src/lib/stores/notification-manager.svelte.ts diff --git a/i18n/en.json b/i18n/en.json index eafb3415d5..8404d6d1d0 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -857,6 +857,7 @@ "failed_to_remove_product_key": "Failed to remove product key", "failed_to_stack_assets": "Failed to stack assets", "failed_to_unstack_assets": "Failed to un-stack assets", + "failed_to_update_notification_status": "Failed to update notification status", "import_path_already_exists": "This import path already exists.", "incorrect_email_or_password": "Incorrect email or password", "paths_validation_failed": "{paths, plural, one {# path} other {# paths}} failed validation", @@ -1199,6 +1200,9 @@ "map_settings_only_show_favorites": "Show Favorite Only", "map_settings_theme_settings": "Map Theme", "map_zoom_to_see_photos": "Zoom out to see photos", + "mark_as_read": "Mark as read", + "mark_all_as_read": "Mark all as read", + "marked_all_as_read": "Marked all as read", "matches": "Matches", "media_type": "Media type", "memories": "Memories", @@ -1260,6 +1264,7 @@ "no_places": "No places", "no_results": "No results", "no_results_description": "Try a synonym or more general keyword", + "no_notifications": "No notifications", "no_shared_albums_message": "Create an album to share photos and videos with people in your network", "not_in_any_album": "Not in any album", "not_selected": "Not selected", diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index 5a7a42cce5..b8ea4b924c 100644 --- a/mobile/openapi/README.md +++ b/mobile/openapi/README.md @@ -145,8 +145,15 @@ Class | Method | HTTP request | Description *MemoriesApi* | [**removeMemoryAssets**](doc//MemoriesApi.md#removememoryassets) | **DELETE** /memories/{id}/assets | *MemoriesApi* | [**searchMemories**](doc//MemoriesApi.md#searchmemories) | **GET** /memories | *MemoriesApi* | [**updateMemory**](doc//MemoriesApi.md#updatememory) | **PUT** /memories/{id} | -*NotificationsAdminApi* | [**getNotificationTemplateAdmin**](doc//NotificationsAdminApi.md#getnotificationtemplateadmin) | **POST** /notifications/admin/templates/{name} | -*NotificationsAdminApi* | [**sendTestEmailAdmin**](doc//NotificationsAdminApi.md#sendtestemailadmin) | **POST** /notifications/admin/test-email | +*NotificationsApi* | [**deleteNotification**](doc//NotificationsApi.md#deletenotification) | **DELETE** /notifications/{id} | +*NotificationsApi* | [**deleteNotifications**](doc//NotificationsApi.md#deletenotifications) | **DELETE** /notifications | +*NotificationsApi* | [**getNotification**](doc//NotificationsApi.md#getnotification) | **GET** /notifications/{id} | +*NotificationsApi* | [**getNotifications**](doc//NotificationsApi.md#getnotifications) | **GET** /notifications | +*NotificationsApi* | [**updateNotification**](doc//NotificationsApi.md#updatenotification) | **PUT** /notifications/{id} | +*NotificationsApi* | [**updateNotifications**](doc//NotificationsApi.md#updatenotifications) | **PUT** /notifications | +*NotificationsAdminApi* | [**createNotification**](doc//NotificationsAdminApi.md#createnotification) | **POST** /admin/notifications | +*NotificationsAdminApi* | [**getNotificationTemplateAdmin**](doc//NotificationsAdminApi.md#getnotificationtemplateadmin) | **POST** /admin/notifications/templates/{name} | +*NotificationsAdminApi* | [**sendTestEmailAdmin**](doc//NotificationsAdminApi.md#sendtestemailadmin) | **POST** /admin/notifications/test-email | *OAuthApi* | [**finishOAuth**](doc//OAuthApi.md#finishoauth) | **POST** /oauth/callback | *OAuthApi* | [**linkOAuthAccount**](doc//OAuthApi.md#linkoauthaccount) | **POST** /oauth/link | *OAuthApi* | [**redirectOAuthToMobile**](doc//OAuthApi.md#redirectoauthtomobile) | **GET** /oauth/mobile-redirect | @@ -360,6 +367,13 @@ Class | Method | HTTP request | Description - [MemoryUpdateDto](doc//MemoryUpdateDto.md) - [MergePersonDto](doc//MergePersonDto.md) - [MetadataSearchDto](doc//MetadataSearchDto.md) + - [NotificationCreateDto](doc//NotificationCreateDto.md) + - [NotificationDeleteAllDto](doc//NotificationDeleteAllDto.md) + - [NotificationDto](doc//NotificationDto.md) + - [NotificationLevel](doc//NotificationLevel.md) + - [NotificationType](doc//NotificationType.md) + - [NotificationUpdateAllDto](doc//NotificationUpdateAllDto.md) + - [NotificationUpdateDto](doc//NotificationUpdateDto.md) - [OAuthAuthorizeResponseDto](doc//OAuthAuthorizeResponseDto.md) - [OAuthCallbackDto](doc//OAuthCallbackDto.md) - [OAuthConfigDto](doc//OAuthConfigDto.md) diff --git a/mobile/openapi/lib/api.dart b/mobile/openapi/lib/api.dart index d08f9fda38..e845099bd2 100644 --- a/mobile/openapi/lib/api.dart +++ b/mobile/openapi/lib/api.dart @@ -44,6 +44,7 @@ part 'api/jobs_api.dart'; part 'api/libraries_api.dart'; part 'api/map_api.dart'; part 'api/memories_api.dart'; +part 'api/notifications_api.dart'; part 'api/notifications_admin_api.dart'; part 'api/o_auth_api.dart'; part 'api/partners_api.dart'; @@ -167,6 +168,13 @@ part 'model/memory_type.dart'; part 'model/memory_update_dto.dart'; part 'model/merge_person_dto.dart'; part 'model/metadata_search_dto.dart'; +part 'model/notification_create_dto.dart'; +part 'model/notification_delete_all_dto.dart'; +part 'model/notification_dto.dart'; +part 'model/notification_level.dart'; +part 'model/notification_type.dart'; +part 'model/notification_update_all_dto.dart'; +part 'model/notification_update_dto.dart'; part 'model/o_auth_authorize_response_dto.dart'; part 'model/o_auth_callback_dto.dart'; part 'model/o_auth_config_dto.dart'; diff --git a/mobile/openapi/lib/api/notifications_admin_api.dart b/mobile/openapi/lib/api/notifications_admin_api.dart index c58bf8978d..409683a950 100644 --- a/mobile/openapi/lib/api/notifications_admin_api.dart +++ b/mobile/openapi/lib/api/notifications_admin_api.dart @@ -16,7 +16,54 @@ class NotificationsAdminApi { final ApiClient apiClient; - /// Performs an HTTP 'POST /notifications/admin/templates/{name}' operation and returns the [Response]. + /// Performs an HTTP 'POST /admin/notifications' operation and returns the [Response]. + /// Parameters: + /// + /// * [NotificationCreateDto] notificationCreateDto (required): + Future createNotificationWithHttpInfo(NotificationCreateDto notificationCreateDto,) async { + // ignore: prefer_const_declarations + final apiPath = r'/admin/notifications'; + + // ignore: prefer_final_locals + Object? postBody = notificationCreateDto; + + final queryParams = []; + final headerParams = {}; + final formParams = {}; + + const contentTypes = ['application/json']; + + + return apiClient.invokeAPI( + apiPath, + 'POST', + queryParams, + postBody, + headerParams, + formParams, + contentTypes.isEmpty ? null : contentTypes.first, + ); + } + + /// Parameters: + /// + /// * [NotificationCreateDto] notificationCreateDto (required): + Future createNotification(NotificationCreateDto notificationCreateDto,) async { + final response = await createNotificationWithHttpInfo(notificationCreateDto,); + if (response.statusCode >= HttpStatus.badRequest) { + throw ApiException(response.statusCode, await _decodeBodyBytes(response)); + } + // When a remote server returns no body with a status of 204, we shall not decode it. + // At the time of writing this, `dart:convert` will throw an "Unexpected end of input" + // FormatException when trying to decode an empty string. + if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) { + return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'NotificationDto',) as NotificationDto; + + } + return null; + } + + /// Performs an HTTP 'POST /admin/notifications/templates/{name}' operation and returns the [Response]. /// Parameters: /// /// * [String] name (required): @@ -24,7 +71,7 @@ class NotificationsAdminApi { /// * [TemplateDto] templateDto (required): Future getNotificationTemplateAdminWithHttpInfo(String name, TemplateDto templateDto,) async { // ignore: prefer_const_declarations - final apiPath = r'/notifications/admin/templates/{name}' + final apiPath = r'/admin/notifications/templates/{name}' .replaceAll('{name}', name); // ignore: prefer_final_locals @@ -68,13 +115,13 @@ class NotificationsAdminApi { return null; } - /// Performs an HTTP 'POST /notifications/admin/test-email' operation and returns the [Response]. + /// Performs an HTTP 'POST /admin/notifications/test-email' operation and returns the [Response]. /// Parameters: /// /// * [SystemConfigSmtpDto] systemConfigSmtpDto (required): Future sendTestEmailAdminWithHttpInfo(SystemConfigSmtpDto systemConfigSmtpDto,) async { // ignore: prefer_const_declarations - final apiPath = r'/notifications/admin/test-email'; + final apiPath = r'/admin/notifications/test-email'; // ignore: prefer_final_locals Object? postBody = systemConfigSmtpDto; diff --git a/mobile/openapi/lib/api/notifications_api.dart b/mobile/openapi/lib/api/notifications_api.dart new file mode 100644 index 0000000000..501cc70a29 --- /dev/null +++ b/mobile/openapi/lib/api/notifications_api.dart @@ -0,0 +1,311 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.18 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +part of openapi.api; + + +class NotificationsApi { + NotificationsApi([ApiClient? apiClient]) : apiClient = apiClient ?? defaultApiClient; + + final ApiClient apiClient; + + /// Performs an HTTP 'DELETE /notifications/{id}' operation and returns the [Response]. + /// Parameters: + /// + /// * [String] id (required): + Future deleteNotificationWithHttpInfo(String id,) async { + // ignore: prefer_const_declarations + final apiPath = r'/notifications/{id}' + .replaceAll('{id}', id); + + // ignore: prefer_final_locals + Object? postBody; + + final queryParams = []; + final headerParams = {}; + final formParams = {}; + + const contentTypes = []; + + + return apiClient.invokeAPI( + apiPath, + 'DELETE', + queryParams, + postBody, + headerParams, + formParams, + contentTypes.isEmpty ? null : contentTypes.first, + ); + } + + /// Parameters: + /// + /// * [String] id (required): + Future deleteNotification(String id,) async { + final response = await deleteNotificationWithHttpInfo(id,); + if (response.statusCode >= HttpStatus.badRequest) { + throw ApiException(response.statusCode, await _decodeBodyBytes(response)); + } + } + + /// Performs an HTTP 'DELETE /notifications' operation and returns the [Response]. + /// Parameters: + /// + /// * [NotificationDeleteAllDto] notificationDeleteAllDto (required): + Future deleteNotificationsWithHttpInfo(NotificationDeleteAllDto notificationDeleteAllDto,) async { + // ignore: prefer_const_declarations + final apiPath = r'/notifications'; + + // ignore: prefer_final_locals + Object? postBody = notificationDeleteAllDto; + + final queryParams = []; + final headerParams = {}; + final formParams = {}; + + const contentTypes = ['application/json']; + + + return apiClient.invokeAPI( + apiPath, + 'DELETE', + queryParams, + postBody, + headerParams, + formParams, + contentTypes.isEmpty ? null : contentTypes.first, + ); + } + + /// Parameters: + /// + /// * [NotificationDeleteAllDto] notificationDeleteAllDto (required): + Future deleteNotifications(NotificationDeleteAllDto notificationDeleteAllDto,) async { + final response = await deleteNotificationsWithHttpInfo(notificationDeleteAllDto,); + if (response.statusCode >= HttpStatus.badRequest) { + throw ApiException(response.statusCode, await _decodeBodyBytes(response)); + } + } + + /// Performs an HTTP 'GET /notifications/{id}' operation and returns the [Response]. + /// Parameters: + /// + /// * [String] id (required): + Future getNotificationWithHttpInfo(String id,) async { + // ignore: prefer_const_declarations + final apiPath = r'/notifications/{id}' + .replaceAll('{id}', id); + + // ignore: prefer_final_locals + Object? postBody; + + final queryParams = []; + final headerParams = {}; + final formParams = {}; + + const contentTypes = []; + + + return apiClient.invokeAPI( + apiPath, + 'GET', + queryParams, + postBody, + headerParams, + formParams, + contentTypes.isEmpty ? null : contentTypes.first, + ); + } + + /// Parameters: + /// + /// * [String] id (required): + Future getNotification(String id,) async { + final response = await getNotificationWithHttpInfo(id,); + if (response.statusCode >= HttpStatus.badRequest) { + throw ApiException(response.statusCode, await _decodeBodyBytes(response)); + } + // When a remote server returns no body with a status of 204, we shall not decode it. + // At the time of writing this, `dart:convert` will throw an "Unexpected end of input" + // FormatException when trying to decode an empty string. + if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) { + return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'NotificationDto',) as NotificationDto; + + } + return null; + } + + /// Performs an HTTP 'GET /notifications' operation and returns the [Response]. + /// Parameters: + /// + /// * [String] id: + /// + /// * [NotificationLevel] level: + /// + /// * [NotificationType] type: + /// + /// * [bool] unread: + Future getNotificationsWithHttpInfo({ String? id, NotificationLevel? level, NotificationType? type, bool? unread, }) async { + // ignore: prefer_const_declarations + final apiPath = r'/notifications'; + + // ignore: prefer_final_locals + Object? postBody; + + final queryParams = []; + final headerParams = {}; + final formParams = {}; + + if (id != null) { + queryParams.addAll(_queryParams('', 'id', id)); + } + if (level != null) { + queryParams.addAll(_queryParams('', 'level', level)); + } + if (type != null) { + queryParams.addAll(_queryParams('', 'type', type)); + } + if (unread != null) { + queryParams.addAll(_queryParams('', 'unread', unread)); + } + + const contentTypes = []; + + + return apiClient.invokeAPI( + apiPath, + 'GET', + queryParams, + postBody, + headerParams, + formParams, + contentTypes.isEmpty ? null : contentTypes.first, + ); + } + + /// Parameters: + /// + /// * [String] id: + /// + /// * [NotificationLevel] level: + /// + /// * [NotificationType] type: + /// + /// * [bool] unread: + Future?> getNotifications({ String? id, NotificationLevel? level, NotificationType? type, bool? unread, }) async { + final response = await getNotificationsWithHttpInfo( id: id, level: level, type: type, unread: unread, ); + if (response.statusCode >= HttpStatus.badRequest) { + throw ApiException(response.statusCode, await _decodeBodyBytes(response)); + } + // When a remote server returns no body with a status of 204, we shall not decode it. + // At the time of writing this, `dart:convert` will throw an "Unexpected end of input" + // FormatException when trying to decode an empty string. + if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) { + final responseBody = await _decodeBodyBytes(response); + return (await apiClient.deserializeAsync(responseBody, 'List') as List) + .cast() + .toList(growable: false); + + } + return null; + } + + /// Performs an HTTP 'PUT /notifications/{id}' operation and returns the [Response]. + /// Parameters: + /// + /// * [String] id (required): + /// + /// * [NotificationUpdateDto] notificationUpdateDto (required): + Future updateNotificationWithHttpInfo(String id, NotificationUpdateDto notificationUpdateDto,) async { + // ignore: prefer_const_declarations + final apiPath = r'/notifications/{id}' + .replaceAll('{id}', id); + + // ignore: prefer_final_locals + Object? postBody = notificationUpdateDto; + + final queryParams = []; + final headerParams = {}; + final formParams = {}; + + const contentTypes = ['application/json']; + + + return apiClient.invokeAPI( + apiPath, + 'PUT', + queryParams, + postBody, + headerParams, + formParams, + contentTypes.isEmpty ? null : contentTypes.first, + ); + } + + /// Parameters: + /// + /// * [String] id (required): + /// + /// * [NotificationUpdateDto] notificationUpdateDto (required): + Future updateNotification(String id, NotificationUpdateDto notificationUpdateDto,) async { + final response = await updateNotificationWithHttpInfo(id, notificationUpdateDto,); + if (response.statusCode >= HttpStatus.badRequest) { + throw ApiException(response.statusCode, await _decodeBodyBytes(response)); + } + // When a remote server returns no body with a status of 204, we shall not decode it. + // At the time of writing this, `dart:convert` will throw an "Unexpected end of input" + // FormatException when trying to decode an empty string. + if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) { + return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'NotificationDto',) as NotificationDto; + + } + return null; + } + + /// Performs an HTTP 'PUT /notifications' operation and returns the [Response]. + /// Parameters: + /// + /// * [NotificationUpdateAllDto] notificationUpdateAllDto (required): + Future updateNotificationsWithHttpInfo(NotificationUpdateAllDto notificationUpdateAllDto,) async { + // ignore: prefer_const_declarations + final apiPath = r'/notifications'; + + // ignore: prefer_final_locals + Object? postBody = notificationUpdateAllDto; + + final queryParams = []; + final headerParams = {}; + final formParams = {}; + + const contentTypes = ['application/json']; + + + return apiClient.invokeAPI( + apiPath, + 'PUT', + queryParams, + postBody, + headerParams, + formParams, + contentTypes.isEmpty ? null : contentTypes.first, + ); + } + + /// Parameters: + /// + /// * [NotificationUpdateAllDto] notificationUpdateAllDto (required): + Future updateNotifications(NotificationUpdateAllDto notificationUpdateAllDto,) async { + final response = await updateNotificationsWithHttpInfo(notificationUpdateAllDto,); + if (response.statusCode >= HttpStatus.badRequest) { + throw ApiException(response.statusCode, await _decodeBodyBytes(response)); + } + } +} diff --git a/mobile/openapi/lib/api_client.dart b/mobile/openapi/lib/api_client.dart index 0d8e4c6ba9..7586cc1ae2 100644 --- a/mobile/openapi/lib/api_client.dart +++ b/mobile/openapi/lib/api_client.dart @@ -390,6 +390,20 @@ class ApiClient { return MergePersonDto.fromJson(value); case 'MetadataSearchDto': return MetadataSearchDto.fromJson(value); + case 'NotificationCreateDto': + return NotificationCreateDto.fromJson(value); + case 'NotificationDeleteAllDto': + return NotificationDeleteAllDto.fromJson(value); + case 'NotificationDto': + return NotificationDto.fromJson(value); + case 'NotificationLevel': + return NotificationLevelTypeTransformer().decode(value); + case 'NotificationType': + return NotificationTypeTypeTransformer().decode(value); + case 'NotificationUpdateAllDto': + return NotificationUpdateAllDto.fromJson(value); + case 'NotificationUpdateDto': + return NotificationUpdateDto.fromJson(value); case 'OAuthAuthorizeResponseDto': return OAuthAuthorizeResponseDto.fromJson(value); case 'OAuthCallbackDto': diff --git a/mobile/openapi/lib/api_helper.dart b/mobile/openapi/lib/api_helper.dart index 1ebf8314ad..cc517d48ab 100644 --- a/mobile/openapi/lib/api_helper.dart +++ b/mobile/openapi/lib/api_helper.dart @@ -100,6 +100,12 @@ String parameterToString(dynamic value) { if (value is MemoryType) { return MemoryTypeTypeTransformer().encode(value).toString(); } + if (value is NotificationLevel) { + return NotificationLevelTypeTransformer().encode(value).toString(); + } + if (value is NotificationType) { + return NotificationTypeTypeTransformer().encode(value).toString(); + } if (value is PartnerDirection) { return PartnerDirectionTypeTransformer().encode(value).toString(); } diff --git a/mobile/openapi/lib/model/notification_create_dto.dart b/mobile/openapi/lib/model/notification_create_dto.dart new file mode 100644 index 0000000000..07985353b2 --- /dev/null +++ b/mobile/openapi/lib/model/notification_create_dto.dart @@ -0,0 +1,180 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.18 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +part of openapi.api; + +class NotificationCreateDto { + /// Returns a new [NotificationCreateDto] instance. + NotificationCreateDto({ + this.data, + this.description, + this.level, + this.readAt, + required this.title, + this.type, + required this.userId, + }); + + /// + /// Please note: This property should have been non-nullable! Since the specification file + /// does not include a default value (using the "default:" property), however, the generated + /// source code must fall back to having a nullable type. + /// Consider adding a "default:" property in the specification file to hide this note. + /// + Object? data; + + String? description; + + /// + /// Please note: This property should have been non-nullable! Since the specification file + /// does not include a default value (using the "default:" property), however, the generated + /// source code must fall back to having a nullable type. + /// Consider adding a "default:" property in the specification file to hide this note. + /// + NotificationLevel? level; + + DateTime? readAt; + + String title; + + /// + /// Please note: This property should have been non-nullable! Since the specification file + /// does not include a default value (using the "default:" property), however, the generated + /// source code must fall back to having a nullable type. + /// Consider adding a "default:" property in the specification file to hide this note. + /// + NotificationType? type; + + String userId; + + @override + bool operator ==(Object other) => identical(this, other) || other is NotificationCreateDto && + other.data == data && + other.description == description && + other.level == level && + other.readAt == readAt && + other.title == title && + other.type == type && + other.userId == userId; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (data == null ? 0 : data!.hashCode) + + (description == null ? 0 : description!.hashCode) + + (level == null ? 0 : level!.hashCode) + + (readAt == null ? 0 : readAt!.hashCode) + + (title.hashCode) + + (type == null ? 0 : type!.hashCode) + + (userId.hashCode); + + @override + String toString() => 'NotificationCreateDto[data=$data, description=$description, level=$level, readAt=$readAt, title=$title, type=$type, userId=$userId]'; + + Map toJson() { + final json = {}; + if (this.data != null) { + json[r'data'] = this.data; + } else { + // json[r'data'] = null; + } + if (this.description != null) { + json[r'description'] = this.description; + } else { + // json[r'description'] = null; + } + if (this.level != null) { + json[r'level'] = this.level; + } else { + // json[r'level'] = null; + } + if (this.readAt != null) { + json[r'readAt'] = this.readAt!.toUtc().toIso8601String(); + } else { + // json[r'readAt'] = null; + } + json[r'title'] = this.title; + if (this.type != null) { + json[r'type'] = this.type; + } else { + // json[r'type'] = null; + } + json[r'userId'] = this.userId; + return json; + } + + /// Returns a new [NotificationCreateDto] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static NotificationCreateDto? fromJson(dynamic value) { + upgradeDto(value, "NotificationCreateDto"); + if (value is Map) { + final json = value.cast(); + + return NotificationCreateDto( + data: mapValueOfType(json, r'data'), + description: mapValueOfType(json, r'description'), + level: NotificationLevel.fromJson(json[r'level']), + readAt: mapDateTime(json, r'readAt', r''), + title: mapValueOfType(json, r'title')!, + type: NotificationType.fromJson(json[r'type']), + userId: mapValueOfType(json, r'userId')!, + ); + } + return null; + } + + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = NotificationCreateDto.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } + + static Map mapFromJson(dynamic json) { + final map = {}; + if (json is Map && json.isNotEmpty) { + json = json.cast(); // ignore: parameter_assignments + for (final entry in json.entries) { + final value = NotificationCreateDto.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of NotificationCreateDto-objects as value to a dart map + static Map> mapListFromJson(dynamic json, {bool growable = false,}) { + final map = >{}; + if (json is Map && json.isNotEmpty) { + // ignore: parameter_assignments + json = json.cast(); + for (final entry in json.entries) { + map[entry.key] = NotificationCreateDto.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'title', + 'userId', + }; +} + diff --git a/mobile/openapi/lib/model/notification_delete_all_dto.dart b/mobile/openapi/lib/model/notification_delete_all_dto.dart new file mode 100644 index 0000000000..4be1b89e92 --- /dev/null +++ b/mobile/openapi/lib/model/notification_delete_all_dto.dart @@ -0,0 +1,101 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.18 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +part of openapi.api; + +class NotificationDeleteAllDto { + /// Returns a new [NotificationDeleteAllDto] instance. + NotificationDeleteAllDto({ + this.ids = const [], + }); + + List ids; + + @override + bool operator ==(Object other) => identical(this, other) || other is NotificationDeleteAllDto && + _deepEquality.equals(other.ids, ids); + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (ids.hashCode); + + @override + String toString() => 'NotificationDeleteAllDto[ids=$ids]'; + + Map toJson() { + final json = {}; + json[r'ids'] = this.ids; + return json; + } + + /// Returns a new [NotificationDeleteAllDto] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static NotificationDeleteAllDto? fromJson(dynamic value) { + upgradeDto(value, "NotificationDeleteAllDto"); + if (value is Map) { + final json = value.cast(); + + return NotificationDeleteAllDto( + ids: json[r'ids'] is Iterable + ? (json[r'ids'] as Iterable).cast().toList(growable: false) + : const [], + ); + } + return null; + } + + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = NotificationDeleteAllDto.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } + + static Map mapFromJson(dynamic json) { + final map = {}; + if (json is Map && json.isNotEmpty) { + json = json.cast(); // ignore: parameter_assignments + for (final entry in json.entries) { + final value = NotificationDeleteAllDto.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of NotificationDeleteAllDto-objects as value to a dart map + static Map> mapListFromJson(dynamic json, {bool growable = false,}) { + final map = >{}; + if (json is Map && json.isNotEmpty) { + // ignore: parameter_assignments + json = json.cast(); + for (final entry in json.entries) { + map[entry.key] = NotificationDeleteAllDto.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'ids', + }; +} + diff --git a/mobile/openapi/lib/model/notification_dto.dart b/mobile/openapi/lib/model/notification_dto.dart new file mode 100644 index 0000000000..4f730b4e50 --- /dev/null +++ b/mobile/openapi/lib/model/notification_dto.dart @@ -0,0 +1,182 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.18 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +part of openapi.api; + +class NotificationDto { + /// Returns a new [NotificationDto] instance. + NotificationDto({ + required this.createdAt, + this.data, + this.description, + required this.id, + required this.level, + this.readAt, + required this.title, + required this.type, + }); + + DateTime createdAt; + + /// + /// Please note: This property should have been non-nullable! Since the specification file + /// does not include a default value (using the "default:" property), however, the generated + /// source code must fall back to having a nullable type. + /// Consider adding a "default:" property in the specification file to hide this note. + /// + Object? data; + + /// + /// Please note: This property should have been non-nullable! Since the specification file + /// does not include a default value (using the "default:" property), however, the generated + /// source code must fall back to having a nullable type. + /// Consider adding a "default:" property in the specification file to hide this note. + /// + String? description; + + String id; + + NotificationLevel level; + + /// + /// Please note: This property should have been non-nullable! Since the specification file + /// does not include a default value (using the "default:" property), however, the generated + /// source code must fall back to having a nullable type. + /// Consider adding a "default:" property in the specification file to hide this note. + /// + DateTime? readAt; + + String title; + + NotificationType type; + + @override + bool operator ==(Object other) => identical(this, other) || other is NotificationDto && + other.createdAt == createdAt && + other.data == data && + other.description == description && + other.id == id && + other.level == level && + other.readAt == readAt && + other.title == title && + other.type == type; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (createdAt.hashCode) + + (data == null ? 0 : data!.hashCode) + + (description == null ? 0 : description!.hashCode) + + (id.hashCode) + + (level.hashCode) + + (readAt == null ? 0 : readAt!.hashCode) + + (title.hashCode) + + (type.hashCode); + + @override + String toString() => 'NotificationDto[createdAt=$createdAt, data=$data, description=$description, id=$id, level=$level, readAt=$readAt, title=$title, type=$type]'; + + Map toJson() { + final json = {}; + json[r'createdAt'] = this.createdAt.toUtc().toIso8601String(); + if (this.data != null) { + json[r'data'] = this.data; + } else { + // json[r'data'] = null; + } + if (this.description != null) { + json[r'description'] = this.description; + } else { + // json[r'description'] = null; + } + json[r'id'] = this.id; + json[r'level'] = this.level; + if (this.readAt != null) { + json[r'readAt'] = this.readAt!.toUtc().toIso8601String(); + } else { + // json[r'readAt'] = null; + } + json[r'title'] = this.title; + json[r'type'] = this.type; + return json; + } + + /// Returns a new [NotificationDto] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static NotificationDto? fromJson(dynamic value) { + upgradeDto(value, "NotificationDto"); + if (value is Map) { + final json = value.cast(); + + return NotificationDto( + createdAt: mapDateTime(json, r'createdAt', r'')!, + data: mapValueOfType(json, r'data'), + description: mapValueOfType(json, r'description'), + id: mapValueOfType(json, r'id')!, + level: NotificationLevel.fromJson(json[r'level'])!, + readAt: mapDateTime(json, r'readAt', r''), + title: mapValueOfType(json, r'title')!, + type: NotificationType.fromJson(json[r'type'])!, + ); + } + return null; + } + + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = NotificationDto.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } + + static Map mapFromJson(dynamic json) { + final map = {}; + if (json is Map && json.isNotEmpty) { + json = json.cast(); // ignore: parameter_assignments + for (final entry in json.entries) { + final value = NotificationDto.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of NotificationDto-objects as value to a dart map + static Map> mapListFromJson(dynamic json, {bool growable = false,}) { + final map = >{}; + if (json is Map && json.isNotEmpty) { + // ignore: parameter_assignments + json = json.cast(); + for (final entry in json.entries) { + map[entry.key] = NotificationDto.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'createdAt', + 'id', + 'level', + 'title', + 'type', + }; +} + diff --git a/mobile/openapi/lib/model/notification_level.dart b/mobile/openapi/lib/model/notification_level.dart new file mode 100644 index 0000000000..554863ae4f --- /dev/null +++ b/mobile/openapi/lib/model/notification_level.dart @@ -0,0 +1,91 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.18 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +part of openapi.api; + + +class NotificationLevel { + /// Instantiate a new enum with the provided [value]. + const NotificationLevel._(this.value); + + /// The underlying value of this enum member. + final String value; + + @override + String toString() => value; + + String toJson() => value; + + static const success = NotificationLevel._(r'success'); + static const error = NotificationLevel._(r'error'); + static const warning = NotificationLevel._(r'warning'); + static const info = NotificationLevel._(r'info'); + + /// List of all possible values in this [enum][NotificationLevel]. + static const values = [ + success, + error, + warning, + info, + ]; + + static NotificationLevel? fromJson(dynamic value) => NotificationLevelTypeTransformer().decode(value); + + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = NotificationLevel.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } +} + +/// Transformation class that can [encode] an instance of [NotificationLevel] to String, +/// and [decode] dynamic data back to [NotificationLevel]. +class NotificationLevelTypeTransformer { + factory NotificationLevelTypeTransformer() => _instance ??= const NotificationLevelTypeTransformer._(); + + const NotificationLevelTypeTransformer._(); + + String encode(NotificationLevel data) => data.value; + + /// Decodes a [dynamic value][data] to a NotificationLevel. + /// + /// If [allowNull] is true and the [dynamic value][data] cannot be decoded successfully, + /// then null is returned. However, if [allowNull] is false and the [dynamic value][data] + /// cannot be decoded successfully, then an [UnimplementedError] is thrown. + /// + /// The [allowNull] is very handy when an API changes and a new enum value is added or removed, + /// and users are still using an old app with the old code. + NotificationLevel? decode(dynamic data, {bool allowNull = true}) { + if (data != null) { + switch (data) { + case r'success': return NotificationLevel.success; + case r'error': return NotificationLevel.error; + case r'warning': return NotificationLevel.warning; + case r'info': return NotificationLevel.info; + default: + if (!allowNull) { + throw ArgumentError('Unknown enum value to decode: $data'); + } + } + } + return null; + } + + /// Singleton [NotificationLevelTypeTransformer] instance. + static NotificationLevelTypeTransformer? _instance; +} + diff --git a/mobile/openapi/lib/model/notification_type.dart b/mobile/openapi/lib/model/notification_type.dart new file mode 100644 index 0000000000..436d2d190f --- /dev/null +++ b/mobile/openapi/lib/model/notification_type.dart @@ -0,0 +1,91 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.18 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +part of openapi.api; + + +class NotificationType { + /// Instantiate a new enum with the provided [value]. + const NotificationType._(this.value); + + /// The underlying value of this enum member. + final String value; + + @override + String toString() => value; + + String toJson() => value; + + static const jobFailed = NotificationType._(r'JobFailed'); + static const backupFailed = NotificationType._(r'BackupFailed'); + static const systemMessage = NotificationType._(r'SystemMessage'); + static const custom = NotificationType._(r'Custom'); + + /// List of all possible values in this [enum][NotificationType]. + static const values = [ + jobFailed, + backupFailed, + systemMessage, + custom, + ]; + + static NotificationType? fromJson(dynamic value) => NotificationTypeTypeTransformer().decode(value); + + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = NotificationType.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } +} + +/// Transformation class that can [encode] an instance of [NotificationType] to String, +/// and [decode] dynamic data back to [NotificationType]. +class NotificationTypeTypeTransformer { + factory NotificationTypeTypeTransformer() => _instance ??= const NotificationTypeTypeTransformer._(); + + const NotificationTypeTypeTransformer._(); + + String encode(NotificationType data) => data.value; + + /// Decodes a [dynamic value][data] to a NotificationType. + /// + /// If [allowNull] is true and the [dynamic value][data] cannot be decoded successfully, + /// then null is returned. However, if [allowNull] is false and the [dynamic value][data] + /// cannot be decoded successfully, then an [UnimplementedError] is thrown. + /// + /// The [allowNull] is very handy when an API changes and a new enum value is added or removed, + /// and users are still using an old app with the old code. + NotificationType? decode(dynamic data, {bool allowNull = true}) { + if (data != null) { + switch (data) { + case r'JobFailed': return NotificationType.jobFailed; + case r'BackupFailed': return NotificationType.backupFailed; + case r'SystemMessage': return NotificationType.systemMessage; + case r'Custom': return NotificationType.custom; + default: + if (!allowNull) { + throw ArgumentError('Unknown enum value to decode: $data'); + } + } + } + return null; + } + + /// Singleton [NotificationTypeTypeTransformer] instance. + static NotificationTypeTypeTransformer? _instance; +} + diff --git a/mobile/openapi/lib/model/notification_update_all_dto.dart b/mobile/openapi/lib/model/notification_update_all_dto.dart new file mode 100644 index 0000000000..a6393b275a --- /dev/null +++ b/mobile/openapi/lib/model/notification_update_all_dto.dart @@ -0,0 +1,112 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.18 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +part of openapi.api; + +class NotificationUpdateAllDto { + /// Returns a new [NotificationUpdateAllDto] instance. + NotificationUpdateAllDto({ + this.ids = const [], + this.readAt, + }); + + List ids; + + DateTime? readAt; + + @override + bool operator ==(Object other) => identical(this, other) || other is NotificationUpdateAllDto && + _deepEquality.equals(other.ids, ids) && + other.readAt == readAt; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (ids.hashCode) + + (readAt == null ? 0 : readAt!.hashCode); + + @override + String toString() => 'NotificationUpdateAllDto[ids=$ids, readAt=$readAt]'; + + Map toJson() { + final json = {}; + json[r'ids'] = this.ids; + if (this.readAt != null) { + json[r'readAt'] = this.readAt!.toUtc().toIso8601String(); + } else { + // json[r'readAt'] = null; + } + return json; + } + + /// Returns a new [NotificationUpdateAllDto] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static NotificationUpdateAllDto? fromJson(dynamic value) { + upgradeDto(value, "NotificationUpdateAllDto"); + if (value is Map) { + final json = value.cast(); + + return NotificationUpdateAllDto( + ids: json[r'ids'] is Iterable + ? (json[r'ids'] as Iterable).cast().toList(growable: false) + : const [], + readAt: mapDateTime(json, r'readAt', r''), + ); + } + return null; + } + + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = NotificationUpdateAllDto.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } + + static Map mapFromJson(dynamic json) { + final map = {}; + if (json is Map && json.isNotEmpty) { + json = json.cast(); // ignore: parameter_assignments + for (final entry in json.entries) { + final value = NotificationUpdateAllDto.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of NotificationUpdateAllDto-objects as value to a dart map + static Map> mapListFromJson(dynamic json, {bool growable = false,}) { + final map = >{}; + if (json is Map && json.isNotEmpty) { + // ignore: parameter_assignments + json = json.cast(); + for (final entry in json.entries) { + map[entry.key] = NotificationUpdateAllDto.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'ids', + }; +} + diff --git a/mobile/openapi/lib/model/notification_update_dto.dart b/mobile/openapi/lib/model/notification_update_dto.dart new file mode 100644 index 0000000000..e76496eb97 --- /dev/null +++ b/mobile/openapi/lib/model/notification_update_dto.dart @@ -0,0 +1,102 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.18 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +part of openapi.api; + +class NotificationUpdateDto { + /// Returns a new [NotificationUpdateDto] instance. + NotificationUpdateDto({ + this.readAt, + }); + + DateTime? readAt; + + @override + bool operator ==(Object other) => identical(this, other) || other is NotificationUpdateDto && + other.readAt == readAt; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (readAt == null ? 0 : readAt!.hashCode); + + @override + String toString() => 'NotificationUpdateDto[readAt=$readAt]'; + + Map toJson() { + final json = {}; + if (this.readAt != null) { + json[r'readAt'] = this.readAt!.toUtc().toIso8601String(); + } else { + // json[r'readAt'] = null; + } + return json; + } + + /// Returns a new [NotificationUpdateDto] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static NotificationUpdateDto? fromJson(dynamic value) { + upgradeDto(value, "NotificationUpdateDto"); + if (value is Map) { + final json = value.cast(); + + return NotificationUpdateDto( + readAt: mapDateTime(json, r'readAt', r''), + ); + } + return null; + } + + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = NotificationUpdateDto.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } + + static Map mapFromJson(dynamic json) { + final map = {}; + if (json is Map && json.isNotEmpty) { + json = json.cast(); // ignore: parameter_assignments + for (final entry in json.entries) { + final value = NotificationUpdateDto.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of NotificationUpdateDto-objects as value to a dart map + static Map> mapListFromJson(dynamic json, {bool growable = false,}) { + final map = >{}; + if (json is Map && json.isNotEmpty) { + // ignore: parameter_assignments + json = json.cast(); + for (final entry in json.entries) { + map[entry.key] = NotificationUpdateDto.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + }; +} + diff --git a/mobile/openapi/lib/model/permission.dart b/mobile/openapi/lib/model/permission.dart index 1244a434b6..1735bc2eb5 100644 --- a/mobile/openapi/lib/model/permission.dart +++ b/mobile/openapi/lib/model/permission.dart @@ -66,6 +66,10 @@ class Permission { static const memoryPeriodRead = Permission._(r'memory.read'); static const memoryPeriodUpdate = Permission._(r'memory.update'); static const memoryPeriodDelete = Permission._(r'memory.delete'); + static const notificationPeriodCreate = Permission._(r'notification.create'); + static const notificationPeriodRead = Permission._(r'notification.read'); + static const notificationPeriodUpdate = Permission._(r'notification.update'); + static const notificationPeriodDelete = Permission._(r'notification.delete'); static const partnerPeriodCreate = Permission._(r'partner.create'); static const partnerPeriodRead = Permission._(r'partner.read'); static const partnerPeriodUpdate = Permission._(r'partner.update'); @@ -147,6 +151,10 @@ class Permission { memoryPeriodRead, memoryPeriodUpdate, memoryPeriodDelete, + notificationPeriodCreate, + notificationPeriodRead, + notificationPeriodUpdate, + notificationPeriodDelete, partnerPeriodCreate, partnerPeriodRead, partnerPeriodUpdate, @@ -263,6 +271,10 @@ class PermissionTypeTransformer { case r'memory.read': return Permission.memoryPeriodRead; case r'memory.update': return Permission.memoryPeriodUpdate; case r'memory.delete': return Permission.memoryPeriodDelete; + case r'notification.create': return Permission.notificationPeriodCreate; + case r'notification.read': return Permission.notificationPeriodRead; + case r'notification.update': return Permission.notificationPeriodUpdate; + case r'notification.delete': return Permission.notificationPeriodDelete; case r'partner.create': return Permission.partnerPeriodCreate; case r'partner.read': return Permission.partnerPeriodRead; case r'partner.update': return Permission.partnerPeriodUpdate; diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index 1471020cd4..f4ec929373 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -206,6 +206,141 @@ ] } }, + "/admin/notifications": { + "post": { + "operationId": "createNotification", + "parameters": [], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NotificationCreateDto" + } + } + }, + "required": true + }, + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NotificationDto" + } + } + }, + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "tags": [ + "Notifications (Admin)" + ] + } + }, + "/admin/notifications/templates/{name}": { + "post": { + "operationId": "getNotificationTemplateAdmin", + "parameters": [ + { + "name": "name", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TemplateDto" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TemplateResponseDto" + } + } + }, + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "tags": [ + "Notifications (Admin)" + ] + } + }, + "/admin/notifications/test-email": { + "post": { + "operationId": "sendTestEmailAdmin", + "parameters": [], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SystemConfigSmtpDto" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TestEmailResponseDto" + } + } + }, + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "tags": [ + "Notifications (Admin)" + ] + } + }, "/admin/users": { "get": { "operationId": "searchUsersAdmin", @@ -3485,15 +3620,224 @@ ] } }, - "/notifications/admin/templates/{name}": { - "post": { - "operationId": "getNotificationTemplateAdmin", + "/notifications": { + "delete": { + "operationId": "deleteNotifications", + "parameters": [], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NotificationDeleteAllDto" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "tags": [ + "Notifications" + ] + }, + "get": { + "operationId": "getNotifications", "parameters": [ { - "name": "name", + "name": "id", + "required": false, + "in": "query", + "schema": { + "format": "uuid", + "type": "string" + } + }, + { + "name": "level", + "required": false, + "in": "query", + "schema": { + "$ref": "#/components/schemas/NotificationLevel" + } + }, + { + "name": "type", + "required": false, + "in": "query", + "schema": { + "$ref": "#/components/schemas/NotificationType" + } + }, + { + "name": "unread", + "required": false, + "in": "query", + "schema": { + "type": "boolean" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/NotificationDto" + }, + "type": "array" + } + } + }, + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "tags": [ + "Notifications" + ] + }, + "put": { + "operationId": "updateNotifications", + "parameters": [], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NotificationUpdateAllDto" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "tags": [ + "Notifications" + ] + } + }, + "/notifications/{id}": { + "delete": { + "operationId": "deleteNotification", + "parameters": [ + { + "name": "id", "required": true, "in": "path", "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "tags": [ + "Notifications" + ] + }, + "get": { + "operationId": "getNotification", + "parameters": [ + { + "name": "id", + "required": true, + "in": "path", + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NotificationDto" + } + } + }, + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "tags": [ + "Notifications" + ] + }, + "put": { + "operationId": "updateNotification", + "parameters": [ + { + "name": "id", + "required": true, + "in": "path", + "schema": { + "format": "uuid", "type": "string" } } @@ -3502,7 +3846,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/TemplateDto" + "$ref": "#/components/schemas/NotificationUpdateDto" } } }, @@ -3513,7 +3857,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/TemplateResponseDto" + "$ref": "#/components/schemas/NotificationDto" } } }, @@ -3532,49 +3876,7 @@ } ], "tags": [ - "Notifications (Admin)" - ] - } - }, - "/notifications/admin/test-email": { - "post": { - "operationId": "sendTestEmailAdmin", - "parameters": [], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/SystemConfigSmtpDto" - } - } - }, - "required": true - }, - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/TestEmailResponseDto" - } - } - }, - "description": "" - } - }, - "security": [ - { - "bearer": [] - }, - { - "cookie": [] - }, - { - "api_key": [] - } - ], - "tags": [ - "Notifications (Admin)" + "Notifications" ] } }, @@ -10326,6 +10628,157 @@ }, "type": "object" }, + "NotificationCreateDto": { + "properties": { + "data": { + "type": "object" + }, + "description": { + "nullable": true, + "type": "string" + }, + "level": { + "allOf": [ + { + "$ref": "#/components/schemas/NotificationLevel" + } + ] + }, + "readAt": { + "format": "date-time", + "nullable": true, + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "allOf": [ + { + "$ref": "#/components/schemas/NotificationType" + } + ] + }, + "userId": { + "format": "uuid", + "type": "string" + } + }, + "required": [ + "title", + "userId" + ], + "type": "object" + }, + "NotificationDeleteAllDto": { + "properties": { + "ids": { + "items": { + "format": "uuid", + "type": "string" + }, + "type": "array" + } + }, + "required": [ + "ids" + ], + "type": "object" + }, + "NotificationDto": { + "properties": { + "createdAt": { + "format": "date-time", + "type": "string" + }, + "data": { + "type": "object" + }, + "description": { + "type": "string" + }, + "id": { + "type": "string" + }, + "level": { + "allOf": [ + { + "$ref": "#/components/schemas/NotificationLevel" + } + ] + }, + "readAt": { + "format": "date-time", + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "allOf": [ + { + "$ref": "#/components/schemas/NotificationType" + } + ] + } + }, + "required": [ + "createdAt", + "id", + "level", + "title", + "type" + ], + "type": "object" + }, + "NotificationLevel": { + "enum": [ + "success", + "error", + "warning", + "info" + ], + "type": "string" + }, + "NotificationType": { + "enum": [ + "JobFailed", + "BackupFailed", + "SystemMessage", + "Custom" + ], + "type": "string" + }, + "NotificationUpdateAllDto": { + "properties": { + "ids": { + "items": { + "format": "uuid", + "type": "string" + }, + "type": "array" + }, + "readAt": { + "format": "date-time", + "nullable": true, + "type": "string" + } + }, + "required": [ + "ids" + ], + "type": "object" + }, + "NotificationUpdateDto": { + "properties": { + "readAt": { + "format": "date-time", + "nullable": true, + "type": "string" + } + }, + "type": "object" + }, "OAuthAuthorizeResponseDto": { "properties": { "url": { @@ -10600,6 +11053,10 @@ "memory.read", "memory.update", "memory.delete", + "notification.create", + "notification.read", + "notification.update", + "notification.delete", "partner.create", "partner.read", "partner.update", diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index 1ba4d3e231..647c5c4ada 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -39,6 +39,48 @@ export type ActivityCreateDto = { export type ActivityStatisticsResponseDto = { comments: number; }; +export type NotificationCreateDto = { + data?: object; + description?: string | null; + level?: NotificationLevel; + readAt?: string | null; + title: string; + "type"?: NotificationType; + userId: string; +}; +export type NotificationDto = { + createdAt: string; + data?: object; + description?: string; + id: string; + level: NotificationLevel; + readAt?: string; + title: string; + "type": NotificationType; +}; +export type TemplateDto = { + template: string; +}; +export type TemplateResponseDto = { + html: string; + name: string; +}; +export type SystemConfigSmtpTransportDto = { + host: string; + ignoreCert: boolean; + password: string; + port: number; + username: string; +}; +export type SystemConfigSmtpDto = { + enabled: boolean; + "from": string; + replyTo: string; + transport: SystemConfigSmtpTransportDto; +}; +export type TestEmailResponseDto = { + messageId: string; +}; export type UserLicense = { activatedAt: string; activationKey: string; @@ -661,28 +703,15 @@ export type MemoryUpdateDto = { memoryAt?: string; seenAt?: string; }; -export type TemplateDto = { - template: string; +export type NotificationDeleteAllDto = { + ids: string[]; }; -export type TemplateResponseDto = { - html: string; - name: string; +export type NotificationUpdateAllDto = { + ids: string[]; + readAt?: string | null; }; -export type SystemConfigSmtpTransportDto = { - host: string; - ignoreCert: boolean; - password: string; - port: number; - username: string; -}; -export type SystemConfigSmtpDto = { - enabled: boolean; - "from": string; - replyTo: string; - transport: SystemConfigSmtpTransportDto; -}; -export type TestEmailResponseDto = { - messageId: string; +export type NotificationUpdateDto = { + readAt?: string | null; }; export type OAuthConfigDto = { codeChallenge?: string; @@ -1453,6 +1482,43 @@ export function deleteActivity({ id }: { method: "DELETE" })); } +export function createNotification({ notificationCreateDto }: { + notificationCreateDto: NotificationCreateDto; +}, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchJson<{ + status: 201; + data: NotificationDto; + }>("/admin/notifications", oazapfts.json({ + ...opts, + method: "POST", + body: notificationCreateDto + }))); +} +export function getNotificationTemplateAdmin({ name, templateDto }: { + name: string; + templateDto: TemplateDto; +}, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchJson<{ + status: 200; + data: TemplateResponseDto; + }>(`/admin/notifications/templates/${encodeURIComponent(name)}`, oazapfts.json({ + ...opts, + method: "POST", + body: templateDto + }))); +} +export function sendTestEmailAdmin({ systemConfigSmtpDto }: { + systemConfigSmtpDto: SystemConfigSmtpDto; +}, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchJson<{ + status: 200; + data: TestEmailResponseDto; + }>("/admin/notifications/test-email", oazapfts.json({ + ...opts, + method: "POST", + body: systemConfigSmtpDto + }))); +} export function searchUsersAdmin({ withDeleted }: { withDeleted?: boolean; }, opts?: Oazapfts.RequestOpts) { @@ -2321,29 +2387,71 @@ export function addMemoryAssets({ id, bulkIdsDto }: { body: bulkIdsDto }))); } -export function getNotificationTemplateAdmin({ name, templateDto }: { - name: string; - templateDto: TemplateDto; +export function deleteNotifications({ notificationDeleteAllDto }: { + notificationDeleteAllDto: NotificationDeleteAllDto; }, opts?: Oazapfts.RequestOpts) { - return oazapfts.ok(oazapfts.fetchJson<{ - status: 200; - data: TemplateResponseDto; - }>(`/notifications/admin/templates/${encodeURIComponent(name)}`, oazapfts.json({ + return oazapfts.ok(oazapfts.fetchText("/notifications", oazapfts.json({ ...opts, - method: "POST", - body: templateDto + method: "DELETE", + body: notificationDeleteAllDto }))); } -export function sendTestEmailAdmin({ systemConfigSmtpDto }: { - systemConfigSmtpDto: SystemConfigSmtpDto; +export function getNotifications({ id, level, $type, unread }: { + id?: string; + level?: NotificationLevel; + $type?: NotificationType; + unread?: boolean; }, opts?: Oazapfts.RequestOpts) { return oazapfts.ok(oazapfts.fetchJson<{ status: 200; - data: TestEmailResponseDto; - }>("/notifications/admin/test-email", oazapfts.json({ + data: NotificationDto[]; + }>(`/notifications${QS.query(QS.explode({ + id, + level, + "type": $type, + unread + }))}`, { + ...opts + })); +} +export function updateNotifications({ notificationUpdateAllDto }: { + notificationUpdateAllDto: NotificationUpdateAllDto; +}, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchText("/notifications", oazapfts.json({ ...opts, - method: "POST", - body: systemConfigSmtpDto + method: "PUT", + body: notificationUpdateAllDto + }))); +} +export function deleteNotification({ id }: { + id: string; +}, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchText(`/notifications/${encodeURIComponent(id)}`, { + ...opts, + method: "DELETE" + })); +} +export function getNotification({ id }: { + id: string; +}, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchJson<{ + status: 200; + data: NotificationDto; + }>(`/notifications/${encodeURIComponent(id)}`, { + ...opts + })); +} +export function updateNotification({ id, notificationUpdateDto }: { + id: string; + notificationUpdateDto: NotificationUpdateDto; +}, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchJson<{ + status: 200; + data: NotificationDto; + }>(`/notifications/${encodeURIComponent(id)}`, oazapfts.json({ + ...opts, + method: "PUT", + body: notificationUpdateDto }))); } export function startOAuth({ oAuthConfigDto }: { @@ -3452,6 +3560,18 @@ export enum UserAvatarColor { Gray = "gray", Amber = "amber" } +export enum NotificationLevel { + Success = "success", + Error = "error", + Warning = "warning", + Info = "info" +} +export enum NotificationType { + JobFailed = "JobFailed", + BackupFailed = "BackupFailed", + SystemMessage = "SystemMessage", + Custom = "Custom" +} export enum UserStatus { Active = "active", Removing = "removing", @@ -3526,6 +3646,10 @@ export enum Permission { MemoryRead = "memory.read", MemoryUpdate = "memory.update", MemoryDelete = "memory.delete", + NotificationCreate = "notification.create", + NotificationRead = "notification.read", + NotificationUpdate = "notification.update", + NotificationDelete = "notification.delete", PartnerCreate = "partner.create", PartnerRead = "partner.read", PartnerUpdate = "partner.update", diff --git a/server/src/controllers/index.ts b/server/src/controllers/index.ts index 0da0aac8b1..e36793b3d7 100644 --- a/server/src/controllers/index.ts +++ b/server/src/controllers/index.ts @@ -14,6 +14,7 @@ import { LibraryController } from 'src/controllers/library.controller'; import { MapController } from 'src/controllers/map.controller'; import { MemoryController } from 'src/controllers/memory.controller'; import { NotificationAdminController } from 'src/controllers/notification-admin.controller'; +import { NotificationController } from 'src/controllers/notification.controller'; import { OAuthController } from 'src/controllers/oauth.controller'; import { PartnerController } from 'src/controllers/partner.controller'; import { PersonController } from 'src/controllers/person.controller'; @@ -47,6 +48,7 @@ export const controllers = [ LibraryController, MapController, MemoryController, + NotificationController, NotificationAdminController, OAuthController, PartnerController, diff --git a/server/src/controllers/notification-admin.controller.ts b/server/src/controllers/notification-admin.controller.ts index 937244fc56..9bac865bdf 100644 --- a/server/src/controllers/notification-admin.controller.ts +++ b/server/src/controllers/notification-admin.controller.ts @@ -1,16 +1,28 @@ import { Body, Controller, HttpCode, HttpStatus, Param, Post } from '@nestjs/common'; import { ApiTags } from '@nestjs/swagger'; import { AuthDto } from 'src/dtos/auth.dto'; -import { TemplateDto, TemplateResponseDto, TestEmailResponseDto } from 'src/dtos/notification.dto'; +import { + NotificationCreateDto, + NotificationDto, + TemplateDto, + TemplateResponseDto, + TestEmailResponseDto, +} from 'src/dtos/notification.dto'; import { SystemConfigSmtpDto } from 'src/dtos/system-config.dto'; import { Auth, Authenticated } from 'src/middleware/auth.guard'; import { EmailTemplate } from 'src/repositories/email.repository'; -import { NotificationService } from 'src/services/notification.service'; +import { NotificationAdminService } from 'src/services/notification-admin.service'; @ApiTags('Notifications (Admin)') -@Controller('notifications/admin') +@Controller('admin/notifications') export class NotificationAdminController { - constructor(private service: NotificationService) {} + constructor(private service: NotificationAdminService) {} + + @Post() + @Authenticated({ admin: true }) + createNotification(@Auth() auth: AuthDto, @Body() dto: NotificationCreateDto): Promise { + return this.service.create(auth, dto); + } @Post('test-email') @HttpCode(HttpStatus.OK) diff --git a/server/src/controllers/notification.controller.ts b/server/src/controllers/notification.controller.ts new file mode 100644 index 0000000000..c64f786850 --- /dev/null +++ b/server/src/controllers/notification.controller.ts @@ -0,0 +1,60 @@ +import { Body, Controller, Delete, Get, Param, Put, Query } from '@nestjs/common'; +import { ApiTags } from '@nestjs/swagger'; +import { AuthDto } from 'src/dtos/auth.dto'; +import { + NotificationDeleteAllDto, + NotificationDto, + NotificationSearchDto, + NotificationUpdateAllDto, + NotificationUpdateDto, +} from 'src/dtos/notification.dto'; +import { Permission } from 'src/enum'; +import { Auth, Authenticated } from 'src/middleware/auth.guard'; +import { NotificationService } from 'src/services/notification.service'; +import { UUIDParamDto } from 'src/validation'; + +@ApiTags('Notifications') +@Controller('notifications') +export class NotificationController { + constructor(private service: NotificationService) {} + + @Get() + @Authenticated({ permission: Permission.NOTIFICATION_READ }) + getNotifications(@Auth() auth: AuthDto, @Query() dto: NotificationSearchDto): Promise { + return this.service.search(auth, dto); + } + + @Put() + @Authenticated({ permission: Permission.NOTIFICATION_UPDATE }) + updateNotifications(@Auth() auth: AuthDto, @Body() dto: NotificationUpdateAllDto): Promise { + return this.service.updateAll(auth, dto); + } + + @Delete() + @Authenticated({ permission: Permission.NOTIFICATION_DELETE }) + deleteNotifications(@Auth() auth: AuthDto, @Body() dto: NotificationDeleteAllDto): Promise { + return this.service.deleteAll(auth, dto); + } + + @Get(':id') + @Authenticated({ permission: Permission.NOTIFICATION_READ }) + getNotification(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise { + return this.service.get(auth, id); + } + + @Put(':id') + @Authenticated({ permission: Permission.NOTIFICATION_UPDATE }) + updateNotification( + @Auth() auth: AuthDto, + @Param() { id }: UUIDParamDto, + @Body() dto: NotificationUpdateDto, + ): Promise { + return this.service.update(auth, id, dto); + } + + @Delete(':id') + @Authenticated({ permission: Permission.NOTIFICATION_DELETE }) + deleteNotification(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise { + return this.service.delete(auth, id); + } +} diff --git a/server/src/database.ts b/server/src/database.ts index 0dab61cbe0..a93873ef42 100644 --- a/server/src/database.ts +++ b/server/src/database.ts @@ -333,6 +333,7 @@ export const columns = { ], tag: ['tags.id', 'tags.value', 'tags.createdAt', 'tags.updatedAt', 'tags.color', 'tags.parentId'], apiKey: ['id', 'name', 'userId', 'createdAt', 'updatedAt', 'permissions'], + notification: ['id', 'createdAt', 'level', 'type', 'title', 'description', 'data', 'readAt'], syncAsset: [ 'id', 'ownerId', diff --git a/server/src/db.d.ts b/server/src/db.d.ts index 4e9738ecec..85be9d5208 100644 --- a/server/src/db.d.ts +++ b/server/src/db.d.ts @@ -11,6 +11,8 @@ import { AssetStatus, AssetType, MemoryType, + NotificationLevel, + NotificationType, Permission, SharedLinkType, SourceType, @@ -263,6 +265,21 @@ export interface Memories { updateId: Generated; } +export interface Notifications { + id: Generated; + createdAt: Generated; + updatedAt: Generated; + deletedAt: Timestamp | null; + updateId: Generated; + userId: string; + level: Generated; + type: NotificationType; + title: string; + description: string | null; + data: any | null; + readAt: Timestamp | null; +} + export interface MemoriesAssetsAssets { assetsId: string; memoriesId: string; @@ -463,6 +480,7 @@ export interface DB { memories: Memories; memories_assets_assets: MemoriesAssetsAssets; migrations: Migrations; + notifications: Notifications; move_history: MoveHistory; naturalearth_countries: NaturalearthCountries; partners_audit: PartnersAudit; diff --git a/server/src/dtos/notification.dto.ts b/server/src/dtos/notification.dto.ts index c1a09c801c..d9847cda17 100644 --- a/server/src/dtos/notification.dto.ts +++ b/server/src/dtos/notification.dto.ts @@ -1,4 +1,7 @@ -import { IsString } from 'class-validator'; +import { ApiProperty } from '@nestjs/swagger'; +import { IsEnum, IsString } from 'class-validator'; +import { NotificationLevel, NotificationType } from 'src/enum'; +import { Optional, ValidateBoolean, ValidateDate, ValidateUUID } from 'src/validation'; export class TestEmailResponseDto { messageId!: string; @@ -11,3 +14,106 @@ export class TemplateDto { @IsString() template!: string; } + +export class NotificationDto { + id!: string; + @ValidateDate() + createdAt!: Date; + @ApiProperty({ enum: NotificationLevel, enumName: 'NotificationLevel' }) + level!: NotificationLevel; + @ApiProperty({ enum: NotificationType, enumName: 'NotificationType' }) + type!: NotificationType; + title!: string; + description?: string; + data?: any; + readAt?: Date; +} + +export class NotificationSearchDto { + @Optional() + @ValidateUUID({ optional: true }) + id?: string; + + @IsEnum(NotificationLevel) + @Optional() + @ApiProperty({ enum: NotificationLevel, enumName: 'NotificationLevel' }) + level?: NotificationLevel; + + @IsEnum(NotificationType) + @Optional() + @ApiProperty({ enum: NotificationType, enumName: 'NotificationType' }) + type?: NotificationType; + + @ValidateBoolean({ optional: true }) + unread?: boolean; +} + +export class NotificationCreateDto { + @Optional() + @IsEnum(NotificationLevel) + @ApiProperty({ enum: NotificationLevel, enumName: 'NotificationLevel' }) + level?: NotificationLevel; + + @IsEnum(NotificationType) + @Optional() + @ApiProperty({ enum: NotificationType, enumName: 'NotificationType' }) + type?: NotificationType; + + @IsString() + title!: string; + + @IsString() + @Optional({ nullable: true }) + description?: string | null; + + @Optional({ nullable: true }) + data?: any; + + @ValidateDate({ optional: true, nullable: true }) + readAt?: Date | null; + + @ValidateUUID() + userId!: string; +} + +export class NotificationUpdateDto { + @ValidateDate({ optional: true, nullable: true }) + readAt?: Date | null; +} + +export class NotificationUpdateAllDto { + @ValidateUUID({ each: true, optional: true }) + ids!: string[]; + + @ValidateDate({ optional: true, nullable: true }) + readAt?: Date | null; +} + +export class NotificationDeleteAllDto { + @ValidateUUID({ each: true }) + ids!: string[]; +} + +export type MapNotification = { + id: string; + createdAt: Date; + updateId?: string; + level: NotificationLevel; + type: NotificationType; + data: any | null; + title: string; + description: string | null; + readAt: Date | null; +}; +export const mapNotification = (notification: MapNotification): NotificationDto => { + return { + id: notification.id, + createdAt: notification.createdAt, + level: notification.level, + type: notification.type, + title: notification.title, + description: notification.description ?? undefined, + data: notification.data ?? undefined, + readAt: notification.readAt ?? undefined, + }; +}; diff --git a/server/src/enum.ts b/server/src/enum.ts index b9a914671a..9fb6168b1a 100644 --- a/server/src/enum.ts +++ b/server/src/enum.ts @@ -126,6 +126,11 @@ export enum Permission { MEMORY_UPDATE = 'memory.update', MEMORY_DELETE = 'memory.delete', + NOTIFICATION_CREATE = 'notification.create', + NOTIFICATION_READ = 'notification.read', + NOTIFICATION_UPDATE = 'notification.update', + NOTIFICATION_DELETE = 'notification.delete', + PARTNER_CREATE = 'partner.create', PARTNER_READ = 'partner.read', PARTNER_UPDATE = 'partner.update', @@ -515,6 +520,7 @@ export enum JobName { NOTIFY_SIGNUP = 'notify-signup', NOTIFY_ALBUM_INVITE = 'notify-album-invite', NOTIFY_ALBUM_UPDATE = 'notify-album-update', + NOTIFICATIONS_CLEANUP = 'notifications-cleanup', SEND_EMAIL = 'notification-send-email', // Version check @@ -580,3 +586,17 @@ export enum SyncEntityType { PartnerAssetDeleteV1 = 'PartnerAssetDeleteV1', PartnerAssetExifV1 = 'PartnerAssetExifV1', } + +export enum NotificationLevel { + Success = 'success', + Error = 'error', + Warning = 'warning', + Info = 'info', +} + +export enum NotificationType { + JobFailed = 'JobFailed', + BackupFailed = 'BackupFailed', + SystemMessage = 'SystemMessage', + Custom = 'Custom', +} diff --git a/server/src/queries/access.repository.sql b/server/src/queries/access.repository.sql index dd58aebcb2..03f1af3b28 100644 --- a/server/src/queries/access.repository.sql +++ b/server/src/queries/access.repository.sql @@ -157,6 +157,15 @@ where and "memories"."ownerId" = $2 and "memories"."deletedAt" is null +-- AccessRepository.notification.checkOwnerAccess +select + "notifications"."id" +from + "notifications" +where + "notifications"."id" in ($1) + and "notifications"."userId" = $2 + -- AccessRepository.person.checkOwnerAccess select "person"."id" diff --git a/server/src/queries/notification.repository.sql b/server/src/queries/notification.repository.sql new file mode 100644 index 0000000000..c55e00d226 --- /dev/null +++ b/server/src/queries/notification.repository.sql @@ -0,0 +1,58 @@ +-- NOTE: This file is auto generated by ./sql-generator + +-- NotificationRepository.cleanup +delete from "notifications" +where + ( + ( + "deletedAt" is not null + and "deletedAt" < $1 + ) + or ( + "readAt" > $2 + and "createdAt" < $3 + ) + or ( + "readAt" = $4 + and "createdAt" < $5 + ) + ) + +-- NotificationRepository.search +select + "id", + "createdAt", + "level", + "type", + "title", + "description", + "data", + "readAt" +from + "notifications" +where + "userId" = $1 + and "deletedAt" is null +order by + "createdAt" desc + +-- NotificationRepository.search (unread) +select + "id", + "createdAt", + "level", + "type", + "title", + "description", + "data", + "readAt" +from + "notifications" +where + ( + "userId" = $1 + and "readAt" is null + ) + and "deletedAt" is null +order by + "createdAt" desc diff --git a/server/src/repositories/access.repository.ts b/server/src/repositories/access.repository.ts index 961cccbf3e..c24209e482 100644 --- a/server/src/repositories/access.repository.ts +++ b/server/src/repositories/access.repository.ts @@ -279,6 +279,26 @@ class AuthDeviceAccess { } } +class NotificationAccess { + constructor(private db: Kysely) {} + + @GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID_SET] }) + @ChunkedSet({ paramIndex: 1 }) + async checkOwnerAccess(userId: string, notificationIds: Set) { + if (notificationIds.size === 0) { + return new Set(); + } + + return this.db + .selectFrom('notifications') + .select('notifications.id') + .where('notifications.id', 'in', [...notificationIds]) + .where('notifications.userId', '=', userId) + .execute() + .then((stacks) => new Set(stacks.map((stack) => stack.id))); + } +} + class StackAccess { constructor(private db: Kysely) {} @@ -426,6 +446,7 @@ export class AccessRepository { asset: AssetAccess; authDevice: AuthDeviceAccess; memory: MemoryAccess; + notification: NotificationAccess; person: PersonAccess; partner: PartnerAccess; stack: StackAccess; @@ -438,6 +459,7 @@ export class AccessRepository { this.asset = new AssetAccess(db); this.authDevice = new AuthDeviceAccess(db); this.memory = new MemoryAccess(db); + this.notification = new NotificationAccess(db); this.person = new PersonAccess(db); this.partner = new PartnerAccess(db); this.stack = new StackAccess(db); diff --git a/server/src/repositories/event.repository.ts b/server/src/repositories/event.repository.ts index 3156804d09..b41c007ef5 100644 --- a/server/src/repositories/event.repository.ts +++ b/server/src/repositories/event.repository.ts @@ -14,6 +14,7 @@ import { SystemConfig } from 'src/config'; import { EventConfig } from 'src/decorators'; import { AssetResponseDto } from 'src/dtos/asset-response.dto'; import { AuthDto } from 'src/dtos/auth.dto'; +import { NotificationDto } from 'src/dtos/notification.dto'; import { ReleaseNotification, ServerVersionResponseDto } from 'src/dtos/server.dto'; import { ImmichWorker, MetadataKey, QueueName } from 'src/enum'; import { ConfigRepository } from 'src/repositories/config.repository'; @@ -64,6 +65,7 @@ type EventMap = { 'assets.restore': [{ assetIds: string[]; userId: string }]; 'job.start': [QueueName, JobItem]; + 'job.failed': [{ job: JobItem; error: Error | any }]; // session events 'session.delete': [{ sessionId: string }]; @@ -104,6 +106,7 @@ export interface ClientEventMap { on_server_version: [ServerVersionResponseDto]; on_config_update: []; on_new_release: [ReleaseNotification]; + on_notification: [NotificationDto]; on_session_delete: [string]; } diff --git a/server/src/repositories/index.ts b/server/src/repositories/index.ts index bd2e5c6774..453e515fe0 100644 --- a/server/src/repositories/index.ts +++ b/server/src/repositories/index.ts @@ -22,6 +22,7 @@ import { MediaRepository } from 'src/repositories/media.repository'; import { MemoryRepository } from 'src/repositories/memory.repository'; import { MetadataRepository } from 'src/repositories/metadata.repository'; import { MoveRepository } from 'src/repositories/move.repository'; +import { NotificationRepository } from 'src/repositories/notification.repository'; import { OAuthRepository } from 'src/repositories/oauth.repository'; import { PartnerRepository } from 'src/repositories/partner.repository'; import { PersonRepository } from 'src/repositories/person.repository'; @@ -55,6 +56,7 @@ export const repositories = [ CryptoRepository, DatabaseRepository, DownloadRepository, + EmailRepository, EventRepository, JobRepository, LibraryRepository, @@ -65,7 +67,7 @@ export const repositories = [ MemoryRepository, MetadataRepository, MoveRepository, - EmailRepository, + NotificationRepository, OAuthRepository, PartnerRepository, PersonRepository, diff --git a/server/src/repositories/notification.repository.ts b/server/src/repositories/notification.repository.ts new file mode 100644 index 0000000000..112bb97e60 --- /dev/null +++ b/server/src/repositories/notification.repository.ts @@ -0,0 +1,103 @@ +import { Insertable, Kysely, Updateable } from 'kysely'; +import { DateTime } from 'luxon'; +import { InjectKysely } from 'nestjs-kysely'; +import { columns } from 'src/database'; +import { DB, Notifications } from 'src/db'; +import { DummyValue, GenerateSql } from 'src/decorators'; +import { NotificationSearchDto } from 'src/dtos/notification.dto'; + +export class NotificationRepository { + constructor(@InjectKysely() private db: Kysely) {} + + @GenerateSql({ params: [DummyValue.UUID] }) + cleanup() { + return this.db + .deleteFrom('notifications') + .where((eb) => + eb.or([ + // remove soft-deleted notifications + eb.and([eb('deletedAt', 'is not', null), eb('deletedAt', '<', DateTime.now().minus({ days: 3 }).toJSDate())]), + + // remove old, read notifications + eb.and([ + // keep recently read messages around for a few days + eb('readAt', '>', DateTime.now().minus({ days: 2 }).toJSDate()), + eb('createdAt', '<', DateTime.now().minus({ days: 15 }).toJSDate()), + ]), + + eb.and([ + // remove super old, unread notifications + eb('readAt', '=', null), + eb('createdAt', '<', DateTime.now().minus({ days: 30 }).toJSDate()), + ]), + ]), + ) + .execute(); + } + + @GenerateSql({ params: [DummyValue.UUID, {}] }, { name: 'unread', params: [DummyValue.UUID, { unread: true }] }) + search(userId: string, dto: NotificationSearchDto) { + return this.db + .selectFrom('notifications') + .select(columns.notification) + .where((qb) => + qb.and({ + userId, + id: dto.id, + level: dto.level, + type: dto.type, + readAt: dto.unread ? null : undefined, + }), + ) + .where('deletedAt', 'is', null) + .orderBy('createdAt', 'desc') + .execute(); + } + + create(notification: Insertable) { + return this.db + .insertInto('notifications') + .values(notification) + .returning(columns.notification) + .executeTakeFirstOrThrow(); + } + + get(id: string) { + return this.db + .selectFrom('notifications') + .select(columns.notification) + .where('id', '=', id) + .where('deletedAt', 'is not', null) + .executeTakeFirst(); + } + + update(id: string, notification: Updateable) { + return this.db + .updateTable('notifications') + .set(notification) + .where('deletedAt', 'is', null) + .where('id', '=', id) + .returning(columns.notification) + .executeTakeFirstOrThrow(); + } + + async updateAll(ids: string[], notification: Updateable) { + await this.db.updateTable('notifications').set(notification).where('id', 'in', ids).execute(); + } + + async delete(id: string) { + await this.db + .updateTable('notifications') + .set({ deletedAt: DateTime.now().toJSDate() }) + .where('id', '=', id) + .execute(); + } + + async deleteAll(ids: string[]) { + await this.db + .updateTable('notifications') + .set({ deletedAt: DateTime.now().toJSDate() }) + .where('id', 'in', ids) + .execute(); + } +} diff --git a/server/src/schema/index.ts b/server/src/schema/index.ts index fe4b86d65c..d297b2217d 100644 --- a/server/src/schema/index.ts +++ b/server/src/schema/index.ts @@ -28,6 +28,7 @@ import { MemoryTable } from 'src/schema/tables/memory.table'; import { MemoryAssetTable } from 'src/schema/tables/memory_asset.table'; import { MoveTable } from 'src/schema/tables/move.table'; import { NaturalEarthCountriesTable } from 'src/schema/tables/natural-earth-countries.table'; +import { NotificationTable } from 'src/schema/tables/notification.table'; import { PartnerAuditTable } from 'src/schema/tables/partner-audit.table'; import { PartnerTable } from 'src/schema/tables/partner.table'; import { PersonTable } from 'src/schema/tables/person.table'; @@ -76,6 +77,7 @@ export class ImmichDatabase { MemoryTable, MoveTable, NaturalEarthCountriesTable, + NotificationTable, PartnerAuditTable, PartnerTable, PersonTable, diff --git a/server/src/schema/migrations/1744991379464-AddNotificationsTable.ts b/server/src/schema/migrations/1744991379464-AddNotificationsTable.ts new file mode 100644 index 0000000000..28dca6658c --- /dev/null +++ b/server/src/schema/migrations/1744991379464-AddNotificationsTable.ts @@ -0,0 +1,22 @@ +import { Kysely, sql } from 'kysely'; + +export async function up(db: Kysely): Promise { + await sql`CREATE TABLE "notifications" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "createdAt" timestamp with time zone NOT NULL DEFAULT now(), "updatedAt" timestamp with time zone NOT NULL DEFAULT now(), "deletedAt" timestamp with time zone, "updateId" uuid NOT NULL DEFAULT immich_uuid_v7(), "userId" uuid, "level" character varying NOT NULL DEFAULT 'info', "type" character varying NOT NULL DEFAULT 'info', "data" jsonb, "title" character varying NOT NULL, "description" text, "readAt" timestamp with time zone);`.execute(db); + await sql`ALTER TABLE "notifications" ADD CONSTRAINT "PK_6a72c3c0f683f6462415e653c3a" PRIMARY KEY ("id");`.execute(db); + await sql`ALTER TABLE "notifications" ADD CONSTRAINT "FK_692a909ee0fa9383e7859f9b406" FOREIGN KEY ("userId") REFERENCES "users" ("id") ON UPDATE CASCADE ON DELETE CASCADE;`.execute(db); + await sql`CREATE INDEX "IDX_notifications_update_id" ON "notifications" ("updateId")`.execute(db); + await sql`CREATE INDEX "IDX_692a909ee0fa9383e7859f9b40" ON "notifications" ("userId")`.execute(db); + await sql`CREATE OR REPLACE TRIGGER "notifications_updated_at" + BEFORE UPDATE ON "notifications" + FOR EACH ROW + EXECUTE FUNCTION updated_at();`.execute(db); +} + +export async function down(db: Kysely): Promise { + await sql`DROP TRIGGER "notifications_updated_at" ON "notifications";`.execute(db); + await sql`DROP INDEX "IDX_notifications_update_id";`.execute(db); + await sql`DROP INDEX "IDX_692a909ee0fa9383e7859f9b40";`.execute(db); + await sql`ALTER TABLE "notifications" DROP CONSTRAINT "PK_6a72c3c0f683f6462415e653c3a";`.execute(db); + await sql`ALTER TABLE "notifications" DROP CONSTRAINT "FK_692a909ee0fa9383e7859f9b406";`.execute(db); + await sql`DROP TABLE "notifications";`.execute(db); +} diff --git a/server/src/schema/tables/notification.table.ts b/server/src/schema/tables/notification.table.ts new file mode 100644 index 0000000000..bf9b8bdf3b --- /dev/null +++ b/server/src/schema/tables/notification.table.ts @@ -0,0 +1,52 @@ +import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators'; +import { NotificationLevel, NotificationType } from 'src/enum'; +import { UserTable } from 'src/schema/tables/user.table'; +import { + Column, + CreateDateColumn, + DeleteDateColumn, + ForeignKeyColumn, + PrimaryGeneratedColumn, + Table, + UpdateDateColumn, +} from 'src/sql-tools'; + +@Table('notifications') +@UpdatedAtTrigger('notifications_updated_at') +export class NotificationTable { + @PrimaryGeneratedColumn() + id!: string; + + @CreateDateColumn() + createdAt!: Date; + + @UpdateDateColumn() + updatedAt!: Date; + + @DeleteDateColumn() + deletedAt?: Date; + + @UpdateIdColumn({ indexName: 'IDX_notifications_update_id' }) + updateId?: string; + + @ForeignKeyColumn(() => UserTable, { onDelete: 'CASCADE', onUpdate: 'CASCADE', nullable: true }) + userId!: string; + + @Column({ default: NotificationLevel.Info }) + level!: NotificationLevel; + + @Column({ default: NotificationLevel.Info }) + type!: NotificationType; + + @Column({ type: 'jsonb', nullable: true }) + data!: any | null; + + @Column() + title!: string; + + @Column({ type: 'text', nullable: true }) + description!: string; + + @Column({ type: 'timestamp with time zone', nullable: true }) + readAt?: Date | null; +} diff --git a/server/src/services/backup.service.spec.ts b/server/src/services/backup.service.spec.ts index 704087ab05..aa72fd588a 100644 --- a/server/src/services/backup.service.spec.ts +++ b/server/src/services/backup.service.spec.ts @@ -142,52 +142,55 @@ describe(BackupService.name, () => { mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.backupEnabled); mocks.storage.createWriteStream.mockReturnValue(new PassThrough()); }); + it('should run a database backup successfully', async () => { const result = await sut.handleBackupDatabase(); expect(result).toBe(JobStatus.SUCCESS); expect(mocks.storage.createWriteStream).toHaveBeenCalled(); }); + it('should rename file on success', async () => { const result = await sut.handleBackupDatabase(); expect(result).toBe(JobStatus.SUCCESS); expect(mocks.storage.rename).toHaveBeenCalled(); }); + it('should fail if pg_dumpall fails', async () => { mocks.process.spawn.mockReturnValueOnce(mockSpawn(1, '', 'error')); - const result = await sut.handleBackupDatabase(); - expect(result).toBe(JobStatus.FAILED); + await expect(sut.handleBackupDatabase()).rejects.toThrow('Backup failed with code 1'); }); + it('should not rename file if pgdump fails and gzip succeeds', async () => { mocks.process.spawn.mockReturnValueOnce(mockSpawn(1, '', 'error')); - const result = await sut.handleBackupDatabase(); - expect(result).toBe(JobStatus.FAILED); + await expect(sut.handleBackupDatabase()).rejects.toThrow('Backup failed with code 1'); expect(mocks.storage.rename).not.toHaveBeenCalled(); }); + it('should fail if gzip fails', async () => { mocks.process.spawn.mockReturnValueOnce(mockSpawn(0, 'data', '')); mocks.process.spawn.mockReturnValueOnce(mockSpawn(1, '', 'error')); - const result = await sut.handleBackupDatabase(); - expect(result).toBe(JobStatus.FAILED); + await expect(sut.handleBackupDatabase()).rejects.toThrow('Gzip failed with code 1'); }); + it('should fail if write stream fails', async () => { mocks.storage.createWriteStream.mockImplementation(() => { throw new Error('error'); }); - const result = await sut.handleBackupDatabase(); - expect(result).toBe(JobStatus.FAILED); + await expect(sut.handleBackupDatabase()).rejects.toThrow('error'); }); + it('should fail if rename fails', async () => { mocks.storage.rename.mockRejectedValue(new Error('error')); - const result = await sut.handleBackupDatabase(); - expect(result).toBe(JobStatus.FAILED); + await expect(sut.handleBackupDatabase()).rejects.toThrow('error'); }); + it('should ignore unlink failing and still return failed job status', async () => { mocks.process.spawn.mockReturnValueOnce(mockSpawn(1, '', 'error')); mocks.storage.unlink.mockRejectedValue(new Error('error')); - const result = await sut.handleBackupDatabase(); + await expect(sut.handleBackupDatabase()).rejects.toThrow('Backup failed with code 1'); expect(mocks.storage.unlink).toHaveBeenCalled(); - expect(result).toBe(JobStatus.FAILED); }); + it.each` postgresVersion | expectedVersion ${'14.10'} | ${14} diff --git a/server/src/services/backup.service.ts b/server/src/services/backup.service.ts index 409d34ab73..10f7becc7d 100644 --- a/server/src/services/backup.service.ts +++ b/server/src/services/backup.service.ts @@ -174,7 +174,7 @@ export class BackupService extends BaseService { await this.storageRepository .unlink(backupFilePath) .catch((error) => this.logger.error('Failed to delete failed backup file', error)); - return JobStatus.FAILED; + throw error; } this.logger.log(`Database Backup Success`); diff --git a/server/src/services/base.service.ts b/server/src/services/base.service.ts index 23ddb1b63e..3381ad7222 100644 --- a/server/src/services/base.service.ts +++ b/server/src/services/base.service.ts @@ -29,6 +29,7 @@ import { MediaRepository } from 'src/repositories/media.repository'; import { MemoryRepository } from 'src/repositories/memory.repository'; import { MetadataRepository } from 'src/repositories/metadata.repository'; import { MoveRepository } from 'src/repositories/move.repository'; +import { NotificationRepository } from 'src/repositories/notification.repository'; import { OAuthRepository } from 'src/repositories/oauth.repository'; import { PartnerRepository } from 'src/repositories/partner.repository'; import { PersonRepository } from 'src/repositories/person.repository'; @@ -80,6 +81,7 @@ export class BaseService { protected memoryRepository: MemoryRepository, protected metadataRepository: MetadataRepository, protected moveRepository: MoveRepository, + protected notificationRepository: NotificationRepository, protected oauthRepository: OAuthRepository, protected partnerRepository: PartnerRepository, protected personRepository: PersonRepository, diff --git a/server/src/services/index.ts b/server/src/services/index.ts index b214dd14f6..88b68d2c13 100644 --- a/server/src/services/index.ts +++ b/server/src/services/index.ts @@ -17,6 +17,7 @@ import { MapService } from 'src/services/map.service'; import { MediaService } from 'src/services/media.service'; import { MemoryService } from 'src/services/memory.service'; import { MetadataService } from 'src/services/metadata.service'; +import { NotificationAdminService } from 'src/services/notification-admin.service'; import { NotificationService } from 'src/services/notification.service'; import { PartnerService } from 'src/services/partner.service'; import { PersonService } from 'src/services/person.service'; @@ -60,6 +61,7 @@ export const services = [ MemoryService, MetadataService, NotificationService, + NotificationAdminService, PartnerService, PersonService, SearchService, diff --git a/server/src/services/job.service.ts b/server/src/services/job.service.ts index b81256de81..a387e6e099 100644 --- a/server/src/services/job.service.ts +++ b/server/src/services/job.service.ts @@ -215,11 +215,7 @@ export class JobService extends BaseService { await this.onDone(job); } } catch (error: Error | any) { - this.logger.error( - `Unable to run job handler (${queueName}/${job.name}): ${error}`, - error?.stack, - JSON.stringify(job.data), - ); + await this.eventRepository.emit('job.failed', { job, error }); } finally { this.telemetryRepository.jobs.addToGauge(queueMetric, -1); } diff --git a/server/src/services/notification-admin.service.spec.ts b/server/src/services/notification-admin.service.spec.ts new file mode 100644 index 0000000000..4a747d41a3 --- /dev/null +++ b/server/src/services/notification-admin.service.spec.ts @@ -0,0 +1,111 @@ +import { defaults, SystemConfig } from 'src/config'; +import { EmailTemplate } from 'src/repositories/email.repository'; +import { NotificationService } from 'src/services/notification.service'; +import { userStub } from 'test/fixtures/user.stub'; +import { newTestService, ServiceMocks } from 'test/utils'; + +const smtpTransport = Object.freeze({ + ...defaults, + notifications: { + smtp: { + ...defaults.notifications.smtp, + enabled: true, + transport: { + ignoreCert: false, + host: 'localhost', + port: 587, + username: 'test', + password: 'test', + }, + }, + }, +}); + +describe(NotificationService.name, () => { + let sut: NotificationService; + let mocks: ServiceMocks; + + beforeEach(() => { + ({ sut, mocks } = newTestService(NotificationService)); + }); + + it('should work', () => { + expect(sut).toBeDefined(); + }); + + describe('sendTestEmail', () => { + it('should throw error if user could not be found', async () => { + await expect(sut.sendTestEmail('', smtpTransport.notifications.smtp)).rejects.toThrow('User not found'); + }); + + it('should throw error if smtp validation fails', async () => { + mocks.user.get.mockResolvedValue(userStub.admin); + mocks.email.verifySmtp.mockRejectedValue(''); + + await expect(sut.sendTestEmail('', smtpTransport.notifications.smtp)).rejects.toThrow( + 'Failed to verify SMTP configuration', + ); + }); + + it('should send email to default domain', async () => { + mocks.user.get.mockResolvedValue(userStub.admin); + mocks.email.verifySmtp.mockResolvedValue(true); + mocks.email.renderEmail.mockResolvedValue({ html: '', text: '' }); + mocks.email.sendEmail.mockResolvedValue({ messageId: 'message-1', response: '' }); + + await expect(sut.sendTestEmail('', smtpTransport.notifications.smtp)).resolves.not.toThrow(); + expect(mocks.email.renderEmail).toHaveBeenCalledWith({ + template: EmailTemplate.TEST_EMAIL, + data: { baseUrl: 'https://my.immich.app', displayName: userStub.admin.name }, + }); + expect(mocks.email.sendEmail).toHaveBeenCalledWith( + expect.objectContaining({ + subject: 'Test email from Immich', + smtp: smtpTransport.notifications.smtp.transport, + }), + ); + }); + + it('should send email to external domain', async () => { + mocks.user.get.mockResolvedValue(userStub.admin); + mocks.email.verifySmtp.mockResolvedValue(true); + mocks.email.renderEmail.mockResolvedValue({ html: '', text: '' }); + mocks.systemMetadata.get.mockResolvedValue({ server: { externalDomain: 'https://demo.immich.app' } }); + mocks.email.sendEmail.mockResolvedValue({ messageId: 'message-1', response: '' }); + + await expect(sut.sendTestEmail('', smtpTransport.notifications.smtp)).resolves.not.toThrow(); + expect(mocks.email.renderEmail).toHaveBeenCalledWith({ + template: EmailTemplate.TEST_EMAIL, + data: { baseUrl: 'https://demo.immich.app', displayName: userStub.admin.name }, + }); + expect(mocks.email.sendEmail).toHaveBeenCalledWith( + expect.objectContaining({ + subject: 'Test email from Immich', + smtp: smtpTransport.notifications.smtp.transport, + }), + ); + }); + + it('should send email with replyTo', async () => { + mocks.user.get.mockResolvedValue(userStub.admin); + mocks.email.verifySmtp.mockResolvedValue(true); + mocks.email.renderEmail.mockResolvedValue({ html: '', text: '' }); + mocks.email.sendEmail.mockResolvedValue({ messageId: 'message-1', response: '' }); + + await expect( + sut.sendTestEmail('', { ...smtpTransport.notifications.smtp, replyTo: 'demo@immich.app' }), + ).resolves.not.toThrow(); + expect(mocks.email.renderEmail).toHaveBeenCalledWith({ + template: EmailTemplate.TEST_EMAIL, + data: { baseUrl: 'https://my.immich.app', displayName: userStub.admin.name }, + }); + expect(mocks.email.sendEmail).toHaveBeenCalledWith( + expect.objectContaining({ + subject: 'Test email from Immich', + smtp: smtpTransport.notifications.smtp.transport, + replyTo: 'demo@immich.app', + }), + ); + }); + }); +}); diff --git a/server/src/services/notification-admin.service.ts b/server/src/services/notification-admin.service.ts new file mode 100644 index 0000000000..bf0d2bba41 --- /dev/null +++ b/server/src/services/notification-admin.service.ts @@ -0,0 +1,120 @@ +import { BadRequestException, Injectable } from '@nestjs/common'; +import { AuthDto } from 'src/dtos/auth.dto'; +import { mapNotification, NotificationCreateDto } from 'src/dtos/notification.dto'; +import { SystemConfigSmtpDto } from 'src/dtos/system-config.dto'; +import { NotificationLevel, NotificationType } from 'src/enum'; +import { EmailTemplate } from 'src/repositories/email.repository'; +import { BaseService } from 'src/services/base.service'; +import { getExternalDomain } from 'src/utils/misc'; + +@Injectable() +export class NotificationAdminService extends BaseService { + async create(auth: AuthDto, dto: NotificationCreateDto) { + const item = await this.notificationRepository.create({ + userId: dto.userId, + type: dto.type ?? NotificationType.Custom, + level: dto.level ?? NotificationLevel.Info, + title: dto.title, + description: dto.description, + data: dto.data, + }); + + return mapNotification(item); + } + + async sendTestEmail(id: string, dto: SystemConfigSmtpDto, tempTemplate?: string) { + const user = await this.userRepository.get(id, { withDeleted: false }); + if (!user) { + throw new Error('User not found'); + } + + try { + await this.emailRepository.verifySmtp(dto.transport); + } catch (error) { + throw new BadRequestException('Failed to verify SMTP configuration', { cause: error }); + } + + const { server } = await this.getConfig({ withCache: false }); + const { html, text } = await this.emailRepository.renderEmail({ + template: EmailTemplate.TEST_EMAIL, + data: { + baseUrl: getExternalDomain(server), + displayName: user.name, + }, + customTemplate: tempTemplate!, + }); + const { messageId } = await this.emailRepository.sendEmail({ + to: user.email, + subject: 'Test email from Immich', + html, + text, + from: dto.from, + replyTo: dto.replyTo || dto.from, + smtp: dto.transport, + }); + + return { messageId }; + } + + async getTemplate(name: EmailTemplate, customTemplate: string) { + const { server, templates } = await this.getConfig({ withCache: false }); + + let templateResponse = ''; + + switch (name) { + case EmailTemplate.WELCOME: { + const { html: _welcomeHtml } = await this.emailRepository.renderEmail({ + template: EmailTemplate.WELCOME, + data: { + baseUrl: getExternalDomain(server), + displayName: 'John Doe', + username: 'john@doe.com', + password: 'thisIsAPassword123', + }, + customTemplate: customTemplate || templates.email.welcomeTemplate, + }); + + templateResponse = _welcomeHtml; + break; + } + case EmailTemplate.ALBUM_UPDATE: { + const { html: _updateAlbumHtml } = await this.emailRepository.renderEmail({ + template: EmailTemplate.ALBUM_UPDATE, + data: { + baseUrl: getExternalDomain(server), + albumId: '1', + albumName: 'Favorite Photos', + recipientName: 'Jane Doe', + cid: undefined, + }, + customTemplate: customTemplate || templates.email.albumInviteTemplate, + }); + templateResponse = _updateAlbumHtml; + break; + } + + case EmailTemplate.ALBUM_INVITE: { + const { html } = await this.emailRepository.renderEmail({ + template: EmailTemplate.ALBUM_INVITE, + data: { + baseUrl: getExternalDomain(server), + albumId: '1', + albumName: "John Doe's Favorites", + senderName: 'John Doe', + recipientName: 'Jane Doe', + cid: undefined, + }, + customTemplate: customTemplate || templates.email.albumInviteTemplate, + }); + templateResponse = html; + break; + } + default: { + templateResponse = ''; + break; + } + } + + return { name, html: templateResponse }; + } +} diff --git a/server/src/services/notification.service.spec.ts b/server/src/services/notification.service.spec.ts index 5830260753..133eb9e7f6 100644 --- a/server/src/services/notification.service.spec.ts +++ b/server/src/services/notification.service.spec.ts @@ -3,7 +3,6 @@ import { defaults, SystemConfig } from 'src/config'; import { AlbumUser } from 'src/database'; import { SystemConfigDto } from 'src/dtos/system-config.dto'; import { AssetFileType, JobName, JobStatus, UserMetadataKey } from 'src/enum'; -import { EmailTemplate } from 'src/repositories/email.repository'; import { NotificationService } from 'src/services/notification.service'; import { INotifyAlbumUpdateJob } from 'src/types'; import { albumStub } from 'test/fixtures/album.stub'; @@ -241,82 +240,6 @@ describe(NotificationService.name, () => { }); }); - describe('sendTestEmail', () => { - it('should throw error if user could not be found', async () => { - await expect(sut.sendTestEmail('', configs.smtpTransport.notifications.smtp)).rejects.toThrow('User not found'); - }); - - it('should throw error if smtp validation fails', async () => { - mocks.user.get.mockResolvedValue(userStub.admin); - mocks.email.verifySmtp.mockRejectedValue(''); - - await expect(sut.sendTestEmail('', configs.smtpTransport.notifications.smtp)).rejects.toThrow( - 'Failed to verify SMTP configuration', - ); - }); - - it('should send email to default domain', async () => { - mocks.user.get.mockResolvedValue(userStub.admin); - mocks.email.verifySmtp.mockResolvedValue(true); - mocks.email.renderEmail.mockResolvedValue({ html: '', text: '' }); - mocks.email.sendEmail.mockResolvedValue({ messageId: 'message-1', response: '' }); - - await expect(sut.sendTestEmail('', configs.smtpTransport.notifications.smtp)).resolves.not.toThrow(); - expect(mocks.email.renderEmail).toHaveBeenCalledWith({ - template: EmailTemplate.TEST_EMAIL, - data: { baseUrl: 'https://my.immich.app', displayName: userStub.admin.name }, - }); - expect(mocks.email.sendEmail).toHaveBeenCalledWith( - expect.objectContaining({ - subject: 'Test email from Immich', - smtp: configs.smtpTransport.notifications.smtp.transport, - }), - ); - }); - - it('should send email to external domain', async () => { - mocks.user.get.mockResolvedValue(userStub.admin); - mocks.email.verifySmtp.mockResolvedValue(true); - mocks.email.renderEmail.mockResolvedValue({ html: '', text: '' }); - mocks.systemMetadata.get.mockResolvedValue({ server: { externalDomain: 'https://demo.immich.app' } }); - mocks.email.sendEmail.mockResolvedValue({ messageId: 'message-1', response: '' }); - - await expect(sut.sendTestEmail('', configs.smtpTransport.notifications.smtp)).resolves.not.toThrow(); - expect(mocks.email.renderEmail).toHaveBeenCalledWith({ - template: EmailTemplate.TEST_EMAIL, - data: { baseUrl: 'https://demo.immich.app', displayName: userStub.admin.name }, - }); - expect(mocks.email.sendEmail).toHaveBeenCalledWith( - expect.objectContaining({ - subject: 'Test email from Immich', - smtp: configs.smtpTransport.notifications.smtp.transport, - }), - ); - }); - - it('should send email with replyTo', async () => { - mocks.user.get.mockResolvedValue(userStub.admin); - mocks.email.verifySmtp.mockResolvedValue(true); - mocks.email.renderEmail.mockResolvedValue({ html: '', text: '' }); - mocks.email.sendEmail.mockResolvedValue({ messageId: 'message-1', response: '' }); - - await expect( - sut.sendTestEmail('', { ...configs.smtpTransport.notifications.smtp, replyTo: 'demo@immich.app' }), - ).resolves.not.toThrow(); - expect(mocks.email.renderEmail).toHaveBeenCalledWith({ - template: EmailTemplate.TEST_EMAIL, - data: { baseUrl: 'https://my.immich.app', displayName: userStub.admin.name }, - }); - expect(mocks.email.sendEmail).toHaveBeenCalledWith( - expect.objectContaining({ - subject: 'Test email from Immich', - smtp: configs.smtpTransport.notifications.smtp.transport, - replyTo: 'demo@immich.app', - }), - ); - }); - }); - describe('handleUserSignup', () => { it('should skip if user could not be found', async () => { await expect(sut.handleUserSignup({ id: '' })).resolves.toBe(JobStatus.SKIPPED); diff --git a/server/src/services/notification.service.ts b/server/src/services/notification.service.ts index 573be90f93..be475d1dca 100644 --- a/server/src/services/notification.service.ts +++ b/server/src/services/notification.service.ts @@ -1,7 +1,24 @@ import { BadRequestException, Injectable } from '@nestjs/common'; import { OnEvent, OnJob } from 'src/decorators'; +import { AuthDto } from 'src/dtos/auth.dto'; +import { + mapNotification, + NotificationDeleteAllDto, + NotificationDto, + NotificationSearchDto, + NotificationUpdateAllDto, + NotificationUpdateDto, +} from 'src/dtos/notification.dto'; import { SystemConfigSmtpDto } from 'src/dtos/system-config.dto'; -import { AssetFileType, JobName, JobStatus, QueueName } from 'src/enum'; +import { + AssetFileType, + JobName, + JobStatus, + NotificationLevel, + NotificationType, + Permission, + QueueName, +} from 'src/enum'; import { EmailTemplate } from 'src/repositories/email.repository'; import { ArgOf } from 'src/repositories/event.repository'; import { BaseService } from 'src/services/base.service'; @@ -15,6 +32,80 @@ import { getPreferences } from 'src/utils/preferences'; export class NotificationService extends BaseService { private static albumUpdateEmailDelayMs = 300_000; + async search(auth: AuthDto, dto: NotificationSearchDto): Promise { + const items = await this.notificationRepository.search(auth.user.id, dto); + return items.map((item) => mapNotification(item)); + } + + async updateAll(auth: AuthDto, dto: NotificationUpdateAllDto) { + await this.requireAccess({ auth, ids: dto.ids, permission: Permission.NOTIFICATION_UPDATE }); + await this.notificationRepository.updateAll(dto.ids, { + readAt: dto.readAt, + }); + } + + async deleteAll(auth: AuthDto, dto: NotificationDeleteAllDto) { + await this.requireAccess({ auth, ids: dto.ids, permission: Permission.NOTIFICATION_DELETE }); + await this.notificationRepository.deleteAll(dto.ids); + } + + async get(auth: AuthDto, id: string) { + await this.requireAccess({ auth, ids: [id], permission: Permission.NOTIFICATION_READ }); + const item = await this.notificationRepository.get(id); + if (!item) { + throw new BadRequestException('Notification not found'); + } + return mapNotification(item); + } + + async update(auth: AuthDto, id: string, dto: NotificationUpdateDto) { + await this.requireAccess({ auth, ids: [id], permission: Permission.NOTIFICATION_UPDATE }); + const item = await this.notificationRepository.update(id, { + readAt: dto.readAt, + }); + return mapNotification(item); + } + + async delete(auth: AuthDto, id: string) { + await this.requireAccess({ auth, ids: [id], permission: Permission.NOTIFICATION_DELETE }); + await this.notificationRepository.delete(id); + } + + @OnJob({ name: JobName.NOTIFICATIONS_CLEANUP, queue: QueueName.BACKGROUND_TASK }) + async onNotificationsCleanup() { + await this.notificationRepository.cleanup(); + } + + @OnEvent({ name: 'job.failed' }) + async onJobFailed({ job, error }: ArgOf<'job.failed'>) { + const admin = await this.userRepository.getAdmin(); + if (!admin) { + return; + } + + this.logger.error(`Unable to run job handler (${job.name}): ${error}`, error?.stack, JSON.stringify(job.data)); + + switch (job.name) { + case JobName.BACKUP_DATABASE: { + const errorMessage = error instanceof Error ? error.message : error; + const item = await this.notificationRepository.create({ + userId: admin.id, + type: NotificationType.JobFailed, + level: NotificationLevel.Error, + title: 'Job Failed', + description: `Job ${[job.name]} failed with error: ${errorMessage}`, + }); + + this.eventRepository.clientSend('on_notification', admin.id, mapNotification(item)); + break; + } + + default: { + return; + } + } + } + @OnEvent({ name: 'config.update' }) onConfigUpdate({ oldConfig, newConfig }: ArgOf<'config.update'>) { this.eventRepository.clientBroadcast('on_config_update'); diff --git a/server/src/types.ts b/server/src/types.ts index c5375ae727..ba33e97aad 100644 --- a/server/src/types.ts +++ b/server/src/types.ts @@ -297,6 +297,10 @@ export type JobItem = // Metadata Extraction | { name: JobName.QUEUE_METADATA_EXTRACTION; data: IBaseJob } | { name: JobName.METADATA_EXTRACTION; data: IEntityJob } + + // Notifications + | { name: JobName.NOTIFICATIONS_CLEANUP; data?: IBaseJob } + // Sidecar Scanning | { name: JobName.QUEUE_SIDECAR; data: IBaseJob } | { name: JobName.SIDECAR_DISCOVERY; data: IEntityJob } diff --git a/server/src/utils/access.ts b/server/src/utils/access.ts index 4e21a9226e..b04d23f114 100644 --- a/server/src/utils/access.ts +++ b/server/src/utils/access.ts @@ -221,6 +221,12 @@ const checkOtherAccess = async (access: AccessRepository, request: OtherAccessRe return access.person.checkFaceOwnerAccess(auth.user.id, ids); } + case Permission.NOTIFICATION_READ: + case Permission.NOTIFICATION_UPDATE: + case Permission.NOTIFICATION_DELETE: { + return access.notification.checkOwnerAccess(auth.user.id, ids); + } + case Permission.TAG_ASSET: case Permission.TAG_READ: case Permission.TAG_UPDATE: diff --git a/server/test/medium.factory.ts b/server/test/medium.factory.ts index 671a8a50ca..3684837baa 100644 --- a/server/test/medium.factory.ts +++ b/server/test/medium.factory.ts @@ -13,9 +13,11 @@ import { AssetRepository } from 'src/repositories/asset.repository'; import { ConfigRepository } from 'src/repositories/config.repository'; import { CryptoRepository } from 'src/repositories/crypto.repository'; import { DatabaseRepository } from 'src/repositories/database.repository'; +import { EmailRepository } from 'src/repositories/email.repository'; import { JobRepository } from 'src/repositories/job.repository'; import { LoggingRepository } from 'src/repositories/logging.repository'; import { MemoryRepository } from 'src/repositories/memory.repository'; +import { NotificationRepository } from 'src/repositories/notification.repository'; import { PartnerRepository } from 'src/repositories/partner.repository'; import { PersonRepository } from 'src/repositories/person.repository'; import { SearchRepository } from 'src/repositories/search.repository'; @@ -42,10 +44,12 @@ type RepositoriesTypes = { config: ConfigRepository; crypto: CryptoRepository; database: DatabaseRepository; + email: EmailRepository; job: JobRepository; user: UserRepository; logger: LoggingRepository; memory: MemoryRepository; + notification: NotificationRepository; partner: PartnerRepository; person: PersonRepository; search: SearchRepository; @@ -142,6 +146,11 @@ export const getRepository = (key: K, db: Kys return new DatabaseRepository(db, new LoggingRepository(undefined, configRepo), configRepo); } + case 'email': { + const logger = new LoggingRepository(undefined, new ConfigRepository()); + return new EmailRepository(logger); + } + case 'logger': { const configMock = { getEnv: () => ({ noColor: false }) }; return new LoggingRepository(undefined, configMock as ConfigRepository); @@ -151,6 +160,10 @@ export const getRepository = (key: K, db: Kys return new MemoryRepository(db); } + case 'notification': { + return new NotificationRepository(db); + } + case 'partner': { return new PartnerRepository(db); } @@ -221,6 +234,10 @@ const getRepositoryMock = (key: K) => { }); } + case 'email': { + return automock(EmailRepository, { args: [{ setContext: () => {} }] }); + } + case 'job': { return automock(JobRepository, { args: [undefined, undefined, undefined, { setContext: () => {} }] }); } @@ -234,6 +251,10 @@ const getRepositoryMock = (key: K) => { return automock(MemoryRepository); } + case 'notification': { + return automock(NotificationRepository); + } + case 'partner': { return automock(PartnerRepository); } @@ -284,7 +305,7 @@ export const asDeps = (repositories: ServiceOverrides) => { repositories.crypto || getRepositoryMock('crypto'), repositories.database || getRepositoryMock('database'), repositories.downloadRepository, - repositories.email, + repositories.email || getRepositoryMock('email'), repositories.event, repositories.job || getRepositoryMock('job'), repositories.library, @@ -294,6 +315,7 @@ export const asDeps = (repositories: ServiceOverrides) => { repositories.memory || getRepositoryMock('memory'), repositories.metadata, repositories.move, + repositories.notification || getRepositoryMock('notification'), repositories.oauth, repositories.partner || getRepositoryMock('partner'), repositories.person || getRepositoryMock('person'), diff --git a/server/test/medium/specs/controllers/notification.controller.spec.ts b/server/test/medium/specs/controllers/notification.controller.spec.ts new file mode 100644 index 0000000000..f4a0ec82d5 --- /dev/null +++ b/server/test/medium/specs/controllers/notification.controller.spec.ts @@ -0,0 +1,86 @@ +import { NotificationController } from 'src/controllers/notification.controller'; +import { AuthService } from 'src/services/auth.service'; +import { NotificationService } from 'src/services/notification.service'; +import request from 'supertest'; +import { errorDto } from 'test/medium/responses'; +import { createControllerTestApp, TestControllerApp } from 'test/medium/utils'; +import { factory } from 'test/small.factory'; + +describe(NotificationController.name, () => { + let realApp: TestControllerApp; + let mockApp: TestControllerApp; + + beforeEach(async () => { + realApp = await createControllerTestApp({ authType: 'real' }); + mockApp = await createControllerTestApp({ authType: 'mock' }); + }); + + describe('GET /notifications', () => { + it('should require authentication', async () => { + const { status, body } = await request(realApp.getHttpServer()).get('/notifications'); + expect(status).toBe(401); + expect(body).toEqual(errorDto.unauthorized); + }); + + it('should call the service with an auth dto', async () => { + const auth = factory.auth({ user: factory.user() }); + mockApp.getMockedService(AuthService).authenticate.mockResolvedValue(auth); + const service = mockApp.getMockedService(NotificationService); + + const { status } = await request(mockApp.getHttpServer()) + .get('/notifications') + .set('Authorization', `Bearer token`); + + expect(status).toBe(200); + expect(service.search).toHaveBeenCalledWith(auth, {}); + }); + + it(`should reject an invalid notification level`, async () => { + const auth = factory.auth({ user: factory.user() }); + mockApp.getMockedService(AuthService).authenticate.mockResolvedValue(auth); + const service = mockApp.getMockedService(NotificationService); + + const { status, body } = await request(mockApp.getHttpServer()) + .get(`/notifications`) + .query({ level: 'invalid' }) + .set('Authorization', `Bearer token`); + + expect(status).toBe(400); + expect(body).toEqual(errorDto.badRequest([expect.stringContaining('level must be one of the following values')])); + expect(service.search).not.toHaveBeenCalled(); + }); + }); + + describe('PUT /notifications', () => { + it('should require authentication', async () => { + const { status, body } = await request(realApp.getHttpServer()) + .put(`/notifications`) + .send({ ids: [], readAt: new Date().toISOString() }); + expect(status).toBe(401); + expect(body).toEqual(errorDto.unauthorized); + }); + }); + + describe('GET /notifications/:id', () => { + it('should require authentication', async () => { + const { status, body } = await request(realApp.getHttpServer()).get(`/notifications/${factory.uuid()}`); + expect(status).toBe(401); + expect(body).toEqual(errorDto.unauthorized); + }); + }); + + describe('PUT /notifications/:id', () => { + it('should require authentication', async () => { + const { status, body } = await request(realApp.getHttpServer()) + .put(`/notifications/${factory.uuid()}`) + .send({ readAt: factory.date() }); + expect(status).toBe(401); + expect(body).toEqual(errorDto.unauthorized); + }); + }); + + afterAll(async () => { + await realApp.close(); + await mockApp.close(); + }); +}); diff --git a/server/test/repositories/access.repository.mock.ts b/server/test/repositories/access.repository.mock.ts index ec5115b839..5b98b95e27 100644 --- a/server/test/repositories/access.repository.mock.ts +++ b/server/test/repositories/access.repository.mock.ts @@ -37,6 +37,10 @@ export const newAccessRepositoryMock = (): IAccessRepositoryMock => { checkOwnerAccess: vitest.fn().mockResolvedValue(new Set()), }, + notification: { + checkOwnerAccess: vitest.fn().mockResolvedValue(new Set()), + }, + person: { checkFaceOwnerAccess: vitest.fn().mockResolvedValue(new Set()), checkOwnerAccess: vitest.fn().mockResolvedValue(new Set()), diff --git a/server/test/small.factory.ts b/server/test/small.factory.ts index 919cdd4b1c..d2742f7f80 100644 --- a/server/test/small.factory.ts +++ b/server/test/small.factory.ts @@ -314,4 +314,5 @@ export const factory = { sidecarWrite: assetSidecarWriteFactory, }, uuid: newUuid, + date: newDate, }; diff --git a/server/test/utils.ts b/server/test/utils.ts index c7c29d310e..2c444f491e 100644 --- a/server/test/utils.ts +++ b/server/test/utils.ts @@ -29,6 +29,7 @@ import { MediaRepository } from 'src/repositories/media.repository'; import { MemoryRepository } from 'src/repositories/memory.repository'; import { MetadataRepository } from 'src/repositories/metadata.repository'; import { MoveRepository } from 'src/repositories/move.repository'; +import { NotificationRepository } from 'src/repositories/notification.repository'; import { OAuthRepository } from 'src/repositories/oauth.repository'; import { PartnerRepository } from 'src/repositories/partner.repository'; import { PersonRepository } from 'src/repositories/person.repository'; @@ -135,6 +136,7 @@ export type ServiceOverrides = { memory: MemoryRepository; metadata: MetadataRepository; move: MoveRepository; + notification: NotificationRepository; oauth: OAuthRepository; partner: PartnerRepository; person: PersonRepository; @@ -202,6 +204,7 @@ export const newTestService = ( memory: automock(MemoryRepository), metadata: newMetadataRepositoryMock(), move: automock(MoveRepository, { strict: false }), + notification: automock(NotificationRepository), oauth: automock(OAuthRepository, { args: [loggerMock] }), partner: automock(PartnerRepository, { strict: false }), person: newPersonRepositoryMock(), @@ -250,6 +253,7 @@ export const newTestService = ( overrides.memory || (mocks.memory as As), overrides.metadata || (mocks.metadata as As), overrides.move || (mocks.move as As), + overrides.notification || (mocks.notification as As), overrides.oauth || (mocks.oauth as As), overrides.partner || (mocks.partner as As), overrides.person || (mocks.person as As), diff --git a/web/src/lib/components/shared-components/navigation-bar/navigation-bar.svelte b/web/src/lib/components/shared-components/navigation-bar/navigation-bar.svelte index e91db5cc3a..2ebe4febab 100644 --- a/web/src/lib/components/shared-components/navigation-bar/navigation-bar.svelte +++ b/web/src/lib/components/shared-components/navigation-bar/navigation-bar.svelte @@ -8,6 +8,7 @@ import SkipLink from '$lib/components/elements/buttons/skip-link.svelte'; import HelpAndFeedbackModal from '$lib/components/shared-components/help-and-feedback-modal.svelte'; import ImmichLogo from '$lib/components/shared-components/immich-logo.svelte'; + import NotificationPanel from '$lib/components/shared-components/navigation-bar/notification-panel.svelte'; import SearchBar from '$lib/components/shared-components/search-bar/search-bar.svelte'; import { AppRoute } from '$lib/constants'; import { authManager } from '$lib/stores/auth-manager.svelte'; @@ -18,13 +19,14 @@ import { userInteraction } from '$lib/stores/user.svelte'; import { getAboutInfo, type ServerAboutResponseDto } from '@immich/sdk'; import { Button, IconButton } from '@immich/ui'; - import { mdiHelpCircleOutline, mdiMagnify, mdiMenu, mdiTrayArrowUp } from '@mdi/js'; + import { mdiBellBadge, mdiBellOutline, mdiHelpCircleOutline, mdiMagnify, mdiMenu, mdiTrayArrowUp } from '@mdi/js'; import { onMount } from 'svelte'; import { t } from 'svelte-i18n'; import { fade } from 'svelte/transition'; import ThemeButton from '../theme-button.svelte'; import UserAvatar from '../user-avatar.svelte'; import AccountInfoPanel from './account-info-panel.svelte'; + import { notificationManager } from '$lib/stores/notification-manager.svelte'; interface Props { showUploadButton?: boolean; @@ -36,7 +38,9 @@ let shouldShowAccountInfo = $state(false); let shouldShowAccountInfoPanel = $state(false); let shouldShowHelpPanel = $state(false); + let shouldShowNotificationPanel = $state(false); let innerWidth: number = $state(0); + const hasUnreadNotifications = $derived(notificationManager.notifications.length > 0); let info: ServerAboutResponseDto | undefined = $state(); @@ -146,6 +150,27 @@ /> +
    (shouldShowNotificationPanel = false), + onEscape: () => (shouldShowNotificationPanel = false), + }} + > + (shouldShowNotificationPanel = !shouldShowNotificationPanel)} + aria-label={$t('notifications')} + /> + + {#if shouldShowNotificationPanel} + + {/if} +
    +
    (shouldShowAccountInfoPanel = false), diff --git a/web/src/lib/components/shared-components/navigation-bar/notification-item.svelte b/web/src/lib/components/shared-components/navigation-bar/notification-item.svelte new file mode 100644 index 0000000000..0d05e2d6d7 --- /dev/null +++ b/web/src/lib/components/shared-components/navigation-bar/notification-item.svelte @@ -0,0 +1,114 @@ + + + diff --git a/web/src/lib/components/shared-components/navigation-bar/notification-panel.svelte b/web/src/lib/components/shared-components/navigation-bar/notification-panel.svelte new file mode 100644 index 0000000000..be9fcd2a44 --- /dev/null +++ b/web/src/lib/components/shared-components/navigation-bar/notification-panel.svelte @@ -0,0 +1,82 @@ + + +
    + +
    + {$t('notifications')} +
    + +
    +
    + +
    + + {#if noUnreadNotifications} + + + {$t('no_notifications')} + + {:else} + + + {#each notificationManager.notifications as notification (notification.id)} +
    + markAsRead(id)} /> +
    + {/each} +
    +
    + {/if} +
    +
    diff --git a/web/src/lib/stores/notification-manager.svelte.ts b/web/src/lib/stores/notification-manager.svelte.ts new file mode 100644 index 0000000000..c06400fd16 --- /dev/null +++ b/web/src/lib/stores/notification-manager.svelte.ts @@ -0,0 +1,38 @@ +import { eventManager } from '$lib/stores/event-manager.svelte'; +import { getNotifications, updateNotification, updateNotifications, type NotificationDto } from '@immich/sdk'; + +class NotificationStore { + notifications = $state([]); + + constructor() { + // TODO replace this with an `auth.login` event + this.refresh().catch(() => {}); + + eventManager.on('auth.logout', () => this.clear()); + } + + get hasUnread() { + return this.notifications.length > 0; + } + + refresh = async () => { + this.notifications = await getNotifications({ unread: true }); + }; + + markAsRead = async (id: string) => { + this.notifications = this.notifications.filter((notification) => notification.id !== id); + await updateNotification({ id, notificationUpdateDto: { readAt: new Date().toISOString() } }); + }; + + markAllAsRead = async () => { + const ids = this.notifications.map(({ id }) => id); + this.notifications = []; + await updateNotifications({ notificationUpdateAllDto: { ids, readAt: new Date().toISOString() } }); + }; + + clear = () => { + this.notifications = []; + }; +} + +export const notificationManager = new NotificationStore(); diff --git a/web/src/lib/stores/websocket.ts b/web/src/lib/stores/websocket.ts index 90228a5cbd..ccfcfb7805 100644 --- a/web/src/lib/stores/websocket.ts +++ b/web/src/lib/stores/websocket.ts @@ -1,6 +1,7 @@ import { authManager } from '$lib/stores/auth-manager.svelte'; +import { notificationManager } from '$lib/stores/notification-manager.svelte'; import { createEventEmitter } from '$lib/utils/eventemitter'; -import type { AssetResponseDto, ServerVersionResponseDto } from '@immich/sdk'; +import { type AssetResponseDto, type NotificationDto, type ServerVersionResponseDto } from '@immich/sdk'; import { io, type Socket } from 'socket.io-client'; import { get, writable } from 'svelte/store'; import { user } from './user.store'; @@ -26,6 +27,7 @@ export interface Events { on_config_update: () => void; on_new_release: (newRelase: ReleaseEvent) => void; on_session_delete: (sessionId: string) => void; + on_notification: (notification: NotificationDto) => void; } const websocket: Socket = io({ @@ -50,6 +52,7 @@ websocket .on('on_server_version', (serverVersion) => websocketStore.serverVersion.set(serverVersion)) .on('on_new_release', (releaseVersion) => websocketStore.release.set(releaseVersion)) .on('on_session_delete', () => authManager.logout()) + .on('on_notification', () => notificationManager.refresh()) .on('connect_error', (e) => console.log('Websocket Connect Error', e)); export const openWebsocketConnection = () => { diff --git a/web/src/routes/auth/login/+page.svelte b/web/src/routes/auth/login/+page.svelte index aa756ac2e8..1dcb91f996 100644 --- a/web/src/routes/auth/login/+page.svelte +++ b/web/src/routes/auth/login/+page.svelte @@ -10,6 +10,7 @@ import { onMount } from 'svelte'; import { t } from 'svelte-i18n'; import type { PageData } from './$types'; + import { notificationManager } from '$lib/stores/notification-manager.svelte'; interface Props { data: PageData; @@ -24,7 +25,10 @@ let loading = $state(false); let oauthLoading = $state(true); - const onSuccess = async () => await goto(AppRoute.PHOTOS, { invalidateAll: true }); + const onSuccess = async () => { + await notificationManager.refresh(); + await goto(AppRoute.PHOTOS, { invalidateAll: true }); + }; const onFirstLogin = async () => await goto(AppRoute.AUTH_CHANGE_PASSWORD); const onOnboarding = async () => await goto(AppRoute.AUTH_ONBOARDING); From a17390a4228b01f804e7d2efe0486235f8171048 Mon Sep 17 00:00:00 2001 From: Daniel Dietzler <36593685+danieldietzler@users.noreply.github.com> Date: Mon, 28 Apr 2025 16:56:04 +0200 Subject: [PATCH 25/31] refactor: move managers to new folder (#17929) --- .../context-menu/button-context-menu.svelte | 2 +- .../shared-components/context-menu/context-menu.svelte | 6 +++--- .../shared-components/navigation-bar/navigation-bar.svelte | 2 +- web/src/lib/{stores => managers}/auth-manager.svelte.ts | 2 +- web/src/lib/{stores => managers}/event-manager.svelte.ts | 0 web/src/lib/{stores => managers}/language-manager.svelte.ts | 2 +- web/src/lib/stores/folders.svelte.ts | 2 +- web/src/lib/stores/memory.store.svelte.ts | 2 +- web/src/lib/stores/notification-manager.svelte.ts | 2 +- web/src/lib/stores/search.svelte.ts | 2 +- web/src/lib/stores/user.store.ts | 2 +- web/src/lib/stores/user.svelte.ts | 2 +- web/src/lib/stores/websocket.ts | 2 +- web/src/routes/+layout.svelte | 2 +- web/src/routes/auth/change-password/+page.svelte | 2 +- 15 files changed, 16 insertions(+), 16 deletions(-) rename web/src/lib/{stores => managers}/auth-manager.svelte.ts (91%) rename web/src/lib/{stores => managers}/event-manager.svelte.ts (100%) rename web/src/lib/{stores => managers}/language-manager.svelte.ts (86%) diff --git a/web/src/lib/components/shared-components/context-menu/button-context-menu.svelte b/web/src/lib/components/shared-components/context-menu/button-context-menu.svelte index 67a17db950..593baafc7c 100644 --- a/web/src/lib/components/shared-components/context-menu/button-context-menu.svelte +++ b/web/src/lib/components/shared-components/context-menu/button-context-menu.svelte @@ -6,8 +6,8 @@ type Padding, } from '$lib/components/elements/buttons/circle-icon-button.svelte'; import ContextMenu from '$lib/components/shared-components/context-menu/context-menu.svelte'; + import { languageManager } from '$lib/managers/language-manager.svelte'; import { optionClickCallbackStore, selectedIdStore } from '$lib/stores/context-menu.store'; - import { languageManager } from '$lib/stores/language-manager.svelte'; import { getContextMenuPositionFromBoundingRect, getContextMenuPositionFromEvent, diff --git a/web/src/lib/components/shared-components/context-menu/context-menu.svelte b/web/src/lib/components/shared-components/context-menu/context-menu.svelte index a79a3bd385..7d1be35944 100644 --- a/web/src/lib/components/shared-components/context-menu/context-menu.svelte +++ b/web/src/lib/components/shared-components/context-menu/context-menu.svelte @@ -1,9 +1,9 @@ -{#if downloadStore.isDownloading} +{#if downloadManager.isDownloading}

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

    - {#each Object.keys(downloadStore.assets) as downloadKey (downloadKey)} - {@const download = downloadStore.assets[downloadKey]} + {#each Object.keys(downloadManager.assets) as downloadKey (downloadKey)} + {@const download = downloadManager.assets[downloadKey]}
    diff --git a/web/src/lib/stores/download-store.svelte.ts b/web/src/lib/managers/download-manager.svelte.ts similarity index 74% rename from web/src/lib/stores/download-store.svelte.ts rename to web/src/lib/managers/download-manager.svelte.ts index 8c03671e73..107f80b8dc 100644 --- a/web/src/lib/stores/download-store.svelte.ts +++ b/web/src/lib/managers/download-manager.svelte.ts @@ -5,7 +5,7 @@ export interface DownloadProgress { abort: AbortController | null; } -class DownloadStore { +class DownloadManager { assets = $state>({}); isDownloading = $derived(Object.keys(this.assets).length > 0); @@ -42,10 +42,4 @@ class DownloadStore { } } -export const downloadStore = new DownloadStore(); - -export const downloadManager = { - add: (key: string, total: number, abort?: AbortController) => downloadStore.add(key, total, abort), - clear: (key: string) => downloadStore.clear(key), - update: (key: string, progress: number, total?: number) => downloadStore.update(key, progress, total), -}; +export const downloadManager = new DownloadManager(); diff --git a/web/src/lib/utils/asset-utils.ts b/web/src/lib/utils/asset-utils.ts index bd3cb416b5..35aea7eb9e 100644 --- a/web/src/lib/utils/asset-utils.ts +++ b/web/src/lib/utils/asset-utils.ts @@ -3,9 +3,9 @@ import FormatBoldMessage from '$lib/components/i18n/format-bold-message.svelte'; import type { InterpolationValues } from '$lib/components/i18n/format-message'; import { NotificationType, notificationController } from '$lib/components/shared-components/notification/notification'; import { AppRoute } from '$lib/constants'; +import { downloadManager } from '$lib/managers/download-manager.svelte'; import type { AssetInteraction } from '$lib/stores/asset-interaction.svelte'; import { assetsSnapshot, isSelectingAllAssets, type AssetStore } from '$lib/stores/assets-store.svelte'; -import { downloadManager } from '$lib/stores/download-store.svelte'; import { preferences } from '$lib/stores/user.store'; import { downloadRequest, getKey, withError } from '$lib/utils'; import { createAlbum } from '$lib/utils/album-utils'; diff --git a/web/src/routes/admin/repair/+page.svelte b/web/src/routes/admin/repair/+page.svelte index b04d8f1944..2d8ceca4da 100644 --- a/web/src/routes/admin/repair/+page.svelte +++ b/web/src/routes/admin/repair/+page.svelte @@ -7,7 +7,7 @@ NotificationType, notificationController, } from '$lib/components/shared-components/notification/notification'; - import { downloadManager } from '$lib/stores/download-store.svelte'; + import { downloadManager } from '$lib/managers/download-manager.svelte'; import { locale } from '$lib/stores/preferences.store'; import { copyToClipboard } from '$lib/utils'; import { downloadBlob } from '$lib/utils/asset-utils'; diff --git a/web/src/routes/admin/system-settings/+page.svelte b/web/src/routes/admin/system-settings/+page.svelte index 6512461ee9..1ac9f0b6fd 100644 --- a/web/src/routes/admin/system-settings/+page.svelte +++ b/web/src/routes/admin/system-settings/+page.svelte @@ -1,15 +1,16 @@ -{#if !isSharedLink() && $preferences?.ratings.enabled} +{#if !authManager.key && $preferences?.ratings.enabled}
    handlePromiseError(handleChangeRating(rating))} />
    diff --git a/web/src/lib/components/asset-viewer/detail-panel-tags.svelte b/web/src/lib/components/asset-viewer/detail-panel-tags.svelte index 592279e353..eee7a7c0b6 100644 --- a/web/src/lib/components/asset-viewer/detail-panel-tags.svelte +++ b/web/src/lib/components/asset-viewer/detail-panel-tags.svelte @@ -3,7 +3,7 @@ import TagAssetForm from '$lib/components/forms/tag-asset-form.svelte'; import Portal from '$lib/components/shared-components/portal/portal.svelte'; import { AppRoute } from '$lib/constants'; - import { isSharedLink } from '$lib/utils'; + import { authManager } from '$lib/managers/auth-manager.svelte'; import { removeTag, tagAssets } from '$lib/utils/asset-utils'; import { getAssetInfo, type AssetResponseDto } from '@immich/sdk'; import { mdiClose, mdiPlus } from '@mdi/js'; @@ -41,7 +41,7 @@ }; -{#if isOwner && !isSharedLink()} +{#if isOwner && !authManager.key}

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

    diff --git a/web/src/lib/components/asset-viewer/detail-panel.svelte b/web/src/lib/components/asset-viewer/detail-panel.svelte index 5ef0ac0d73..15bc42d001 100644 --- a/web/src/lib/components/asset-viewer/detail-panel.svelte +++ b/web/src/lib/components/asset-viewer/detail-panel.svelte @@ -6,15 +6,19 @@ import DetailPanelTags from '$lib/components/asset-viewer/detail-panel-tags.svelte'; import Icon from '$lib/components/elements/icon.svelte'; import ChangeDate from '$lib/components/shared-components/change-date.svelte'; + import Portal from '$lib/components/shared-components/portal/portal.svelte'; import { AppRoute, QueryParameter, timeToLoadTheMap } from '$lib/constants'; + import { authManager } from '$lib/managers/auth-manager.svelte'; + import { isFaceEditMode } from '$lib/stores/face-edit.svelte'; import { boundingBoxesArray } from '$lib/stores/people.store'; import { locale } from '$lib/stores/preferences.store'; import { featureFlags } from '$lib/stores/server-config.store'; import { preferences, user } from '$lib/stores/user.store'; - import { getAssetThumbnailUrl, getPeopleThumbnailUrl, handlePromiseError, isSharedLink } from '$lib/utils'; + import { getAssetThumbnailUrl, getPeopleThumbnailUrl, handlePromiseError } from '$lib/utils'; import { delay, isFlipped } from '$lib/utils/asset-utils'; import { getByteUnitString } from '$lib/utils/byte-units'; import { handleError } from '$lib/utils/handle-error'; + import { getMetadataSearchQuery } from '$lib/utils/metadata-search'; import { fromDateTimeOriginal, fromLocalDateTime } from '$lib/utils/timeline-util'; import { AssetMediaSize, @@ -44,9 +48,6 @@ import LoadingSpinner from '../shared-components/loading-spinner.svelte'; import UserAvatar from '../shared-components/user-avatar.svelte'; import AlbumListItemDetails from './album-list-item-details.svelte'; - import Portal from '$lib/components/shared-components/portal/portal.svelte'; - import { getMetadataSearchQuery } from '$lib/utils/metadata-search'; - import { isFaceEditMode } from '$lib/stores/face-edit.svelte'; interface Props { asset: AssetResponseDto; @@ -84,7 +85,7 @@ const handleNewAsset = async (newAsset: AssetResponseDto) => { // TODO: check if reloading asset data is necessary - if (newAsset.id && !isSharedLink()) { + if (newAsset.id && !authManager.key) { const data = await getAssetInfo({ id: asset.id }); people = data?.people || []; unassignedFaces = data?.unassignedFaces || []; @@ -187,7 +188,7 @@ - {#if !isSharedLink() && isOwner} + {#if !authManager.key && isOwner}

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

    diff --git a/web/src/lib/components/asset-viewer/image-panorama-viewer.svelte b/web/src/lib/components/asset-viewer/image-panorama-viewer.svelte index 7b9fd85b4a..d678b00ddb 100644 --- a/web/src/lib/components/asset-viewer/image-panorama-viewer.svelte +++ b/web/src/lib/components/asset-viewer/image-panorama-viewer.svelte @@ -1,10 +1,11 @@ diff --git a/web/src/lib/components/assets/thumbnail/__test__/thumbnail.spec.ts b/web/src/lib/components/assets/thumbnail/__test__/thumbnail.spec.ts index f7447551f0..503ea8aefd 100644 --- a/web/src/lib/components/assets/thumbnail/__test__/thumbnail.spec.ts +++ b/web/src/lib/components/assets/thumbnail/__test__/thumbnail.spec.ts @@ -25,6 +25,7 @@ describe('Thumbnail component', () => { vi.stubGlobal('IntersectionObserver', getIntersectionObserverMock()); vi.mock('$lib/utils/navigation', () => ({ currentUrlReplaceAssetId: vi.fn(), + isSharedLinkRoute: vi.fn().mockReturnValue(false), })); }); diff --git a/web/src/lib/components/assets/thumbnail/thumbnail.svelte b/web/src/lib/components/assets/thumbnail/thumbnail.svelte index eba10317aa..076b0b17cd 100644 --- a/web/src/lib/components/assets/thumbnail/thumbnail.svelte +++ b/web/src/lib/components/assets/thumbnail/thumbnail.svelte @@ -2,7 +2,7 @@ import Icon from '$lib/components/elements/icon.svelte'; import { ProjectionType } from '$lib/constants'; import { locale, playVideoThumbnailOnHover } from '$lib/stores/preferences.store'; - import { getAssetPlaybackUrl, getAssetThumbnailUrl, isSharedLink } from '$lib/utils'; + import { getAssetPlaybackUrl, getAssetThumbnailUrl } from '$lib/utils'; import { timeToSeconds } from '$lib/utils/date-time'; import { getAltText } from '$lib/utils/thumbnail-util'; import { AssetMediaSize, AssetTypeEnum, type AssetResponseDto } from '@immich/sdk'; @@ -17,15 +17,16 @@ } from '@mdi/js'; import { thumbhash } from '$lib/actions/thumbhash'; + import { authManager } from '$lib/managers/auth-manager.svelte'; import { mobileDevice } from '$lib/stores/mobile-device.svelte'; + import { getFocusable } from '$lib/utils/focus-util'; import { currentUrlReplaceAssetId } from '$lib/utils/navigation'; import { TUNABLES } from '$lib/utils/tunables'; + import { onMount } from 'svelte'; import type { ClassValue } from 'svelte/elements'; import { fade } from 'svelte/transition'; import ImageThumbnail from './image-thumbnail.svelte'; import VideoThumbnail from './video-thumbnail.svelte'; - import { onMount } from 'svelte'; - import { getFocusable } from '$lib/utils/focus-util'; interface Props { asset: AssetResponseDto; @@ -331,13 +332,13 @@ >
    - {#if !isSharedLink() && asset.isFavorite} + {#if !authManager.key && asset.isFavorite}
    {/if} - {#if !isSharedLink() && showArchiveIcon && asset.isArchived} + {#if !authManager.key && showArchiveIcon && asset.isArchived}
    diff --git a/web/src/lib/components/photos-page/actions/remove-from-shared-link.svelte b/web/src/lib/components/photos-page/actions/remove-from-shared-link.svelte index 1639a642b5..e1e803ad50 100644 --- a/web/src/lib/components/photos-page/actions/remove-from-shared-link.svelte +++ b/web/src/lib/components/photos-page/actions/remove-from-shared-link.svelte @@ -1,13 +1,13 @@