Compare commits

...

10 Commits

Author SHA1 Message Date
Jason Rasmussen
a5e88f41ea refactor!: remove replace asset 2026-03-18 15:14:50 -04:00
Jason Rasmussen
77020e742a fix: validate accept header before returning html (#27019) 2026-03-18 14:15:48 -04:00
Jason Rasmussen
38b135ff36 fix: bounding box return type (#27014) 2026-03-18 11:58:40 -04:00
Jason Rasmussen
cda4a2a5fc fix: filter after searching by asset id (#26994)
* fix: filter after searching by asset id

* Update web/src/lib/modals/SearchFilterModal.svelte

Co-authored-by: Michel Heusschen <59014050+michelheusschen@users.noreply.github.com>

---------

Co-authored-by: Michel Heusschen <59014050+michelheusschen@users.noreply.github.com>
2026-03-18 13:32:54 +00:00
Min Idzelis
88002cf7fe fix(web): allow images to be downloaded again(long-press or right click) (#26992) 2026-03-18 12:40:36 +01:00
Andreas Heinz
694ea151f5 fix(web): escape handling for tagging and adding a face in asset viewer (#26870) 2026-03-18 12:39:25 +01:00
Jason Rasmussen
b092c8b601 fix: healthcheck (#26989) 2026-03-17 17:54:39 -04:00
Jason Rasmussen
48e6e17829 feat: primary notifications (#26988) 2026-03-17 17:54:11 -04:00
Jason Rasmussen
0519833d75 refactor: prefer tv (#26993) 2026-03-17 17:53:48 -04:00
Thomas
34caed3b2b fix(server): flaky metadata tests (#26964) 2026-03-17 18:06:22 +01:00
87 changed files with 318 additions and 910 deletions

View File

@@ -81,7 +81,7 @@ export const connect = async (url: string, key: string) => {
const [error] = await withError(getMyUser());
if (isHttpError(error)) {
logError(error, 'Failed to connect to server');
logError(error, `Failed to connect to server ${url}`);
process.exit(1);
}

View File

@@ -64,17 +64,17 @@ test.describe('Photo Viewer', () => {
await expect(original).toHaveAttribute('src', /fullsize/);
});
test('reloads photo when checksum changes', async ({ page }) => {
test('right-click targets the img element', async ({ page }) => {
await page.goto(`/photos/${asset.id}`);
const preview = page.getByTestId('preview').filter({ visible: true });
await expect(preview).toHaveAttribute('src', /.+/);
const initialSrc = await preview.getAttribute('src');
const websocketEvent = utils.waitForWebsocketEvent({ event: 'assetUpdate', id: asset.id });
await utils.replaceAsset(admin.accessToken, asset.id);
await websocketEvent;
await expect(preview).not.toHaveAttribute('src', initialSrc!);
const box = await preview.boundingBox();
const tagAtCenter = await page.evaluate(({ x, y }) => document.elementFromPoint(x, y)?.tagName, {
x: box!.x + box!.width / 2,
y: box!.y + box!.height / 2,
});
expect(tagAtCenter).toBe('IMG');
});
});

View File

@@ -375,40 +375,6 @@ export const utils = {
return body as AssetMediaResponseDto;
},
replaceAsset: async (
accessToken: string,
assetId: string,
dto?: Partial<Omit<AssetMediaCreateDto, 'assetData'>> & { assetData?: FileData },
) => {
const _dto = {
deviceAssetId: 'test-1',
deviceId: 'test',
fileCreatedAt: new Date().toISOString(),
fileModifiedAt: new Date().toISOString(),
...dto,
};
const assetData = dto?.assetData?.bytes || makeRandomImage();
const filename = dto?.assetData?.filename || 'example.png';
if (dto?.assetData?.bytes) {
console.log(`Uploading ${filename}`);
}
const builder = request(app)
.put(`/assets/${assetId}/original`)
.attach('assetData', assetData, filename)
.set('Authorization', `Bearer ${accessToken}`);
for (const [key, value] of Object.entries(_dto)) {
void builder.field(key, String(value));
}
const { body } = await builder;
return body as AssetMediaResponseDto;
},
createImageFile: (path: string) => {
if (!existsSync(dirname(path))) {
mkdirSync(dirname(path), { recursive: true });

View File

@@ -113,7 +113,6 @@ Class | Method | HTTP request | Description
*AssetsApi* | [**getRandom**](doc//AssetsApi.md#getrandom) | **GET** /assets/random | Get random assets
*AssetsApi* | [**playAssetVideo**](doc//AssetsApi.md#playassetvideo) | **GET** /assets/{id}/video/playback | Play asset video
*AssetsApi* | [**removeAssetEdits**](doc//AssetsApi.md#removeassetedits) | **DELETE** /assets/{id}/edits | Remove edits from an existing asset
*AssetsApi* | [**replaceAsset**](doc//AssetsApi.md#replaceasset) | **PUT** /assets/{id}/original | Replace asset
*AssetsApi* | [**runAssetJobs**](doc//AssetsApi.md#runassetjobs) | **POST** /assets/jobs | Run an asset job
*AssetsApi* | [**updateAsset**](doc//AssetsApi.md#updateasset) | **PUT** /assets/{id} | Update an asset
*AssetsApi* | [**updateAssetMetadata**](doc//AssetsApi.md#updateassetmetadata) | **PUT** /assets/{id}/metadata | Update asset metadata
@@ -149,7 +148,6 @@ Class | Method | HTTP request | Description
*DeprecatedApi* | [**getFullSyncForUser**](doc//DeprecatedApi.md#getfullsyncforuser) | **POST** /sync/full-sync | Get full sync for user
*DeprecatedApi* | [**getQueuesLegacy**](doc//DeprecatedApi.md#getqueueslegacy) | **GET** /jobs | Retrieve queue counts and status
*DeprecatedApi* | [**getRandom**](doc//DeprecatedApi.md#getrandom) | **GET** /assets/random | Get random assets
*DeprecatedApi* | [**replaceAsset**](doc//DeprecatedApi.md#replaceasset) | **PUT** /assets/{id}/original | Replace asset
*DeprecatedApi* | [**runQueueCommandLegacy**](doc//DeprecatedApi.md#runqueuecommandlegacy) | **PUT** /jobs/{name} | Run jobs
*DownloadApi* | [**downloadArchive**](doc//DownloadApi.md#downloadarchive) | **POST** /download/archive | Download asset archive
*DownloadApi* | [**getDownloadInfo**](doc//DownloadApi.md#getdownloadinfo) | **POST** /download/info | Retrieve download information

View File

@@ -1115,154 +1115,6 @@ class AssetsApi {
}
}
/// Replace asset
///
/// Replace the asset with new file, without changing its id.
///
/// Note: This method returns the HTTP [Response].
///
/// Parameters:
///
/// * [String] id (required):
///
/// * [MultipartFile] assetData (required):
/// Asset file data
///
/// * [String] deviceAssetId (required):
/// Device asset ID
///
/// * [String] deviceId (required):
/// Device ID
///
/// * [DateTime] fileCreatedAt (required):
/// File creation date
///
/// * [DateTime] fileModifiedAt (required):
/// File modification date
///
/// * [String] key:
///
/// * [String] slug:
///
/// * [String] duration:
/// Duration (for videos)
///
/// * [String] filename:
/// Filename
Future<Response> replaceAssetWithHttpInfo(String id, MultipartFile assetData, String deviceAssetId, String deviceId, DateTime fileCreatedAt, DateTime fileModifiedAt, { String? key, String? slug, String? duration, String? filename, }) async {
// ignore: prefer_const_declarations
final apiPath = r'/assets/{id}/original'
.replaceAll('{id}', id);
// ignore: prefer_final_locals
Object? postBody;
final queryParams = <QueryParam>[];
final headerParams = <String, String>{};
final formParams = <String, String>{};
if (key != null) {
queryParams.addAll(_queryParams('', 'key', key));
}
if (slug != null) {
queryParams.addAll(_queryParams('', 'slug', slug));
}
const contentTypes = <String>['multipart/form-data'];
bool hasFields = false;
final mp = MultipartRequest('PUT', Uri.parse(apiPath));
if (assetData != null) {
hasFields = true;
mp.fields[r'assetData'] = assetData.field;
mp.files.add(assetData);
}
if (deviceAssetId != null) {
hasFields = true;
mp.fields[r'deviceAssetId'] = parameterToString(deviceAssetId);
}
if (deviceId != null) {
hasFields = true;
mp.fields[r'deviceId'] = parameterToString(deviceId);
}
if (duration != null) {
hasFields = true;
mp.fields[r'duration'] = parameterToString(duration);
}
if (fileCreatedAt != null) {
hasFields = true;
mp.fields[r'fileCreatedAt'] = parameterToString(fileCreatedAt);
}
if (fileModifiedAt != null) {
hasFields = true;
mp.fields[r'fileModifiedAt'] = parameterToString(fileModifiedAt);
}
if (filename != null) {
hasFields = true;
mp.fields[r'filename'] = parameterToString(filename);
}
if (hasFields) {
postBody = mp;
}
return apiClient.invokeAPI(
apiPath,
'PUT',
queryParams,
postBody,
headerParams,
formParams,
contentTypes.isEmpty ? null : contentTypes.first,
);
}
/// Replace asset
///
/// Replace the asset with new file, without changing its id.
///
/// Parameters:
///
/// * [String] id (required):
///
/// * [MultipartFile] assetData (required):
/// Asset file data
///
/// * [String] deviceAssetId (required):
/// Device asset ID
///
/// * [String] deviceId (required):
/// Device ID
///
/// * [DateTime] fileCreatedAt (required):
/// File creation date
///
/// * [DateTime] fileModifiedAt (required):
/// File modification date
///
/// * [String] key:
///
/// * [String] slug:
///
/// * [String] duration:
/// Duration (for videos)
///
/// * [String] filename:
/// Filename
Future<AssetMediaResponseDto?> replaceAsset(String id, MultipartFile assetData, String deviceAssetId, String deviceId, DateTime fileCreatedAt, DateTime fileModifiedAt, { String? key, String? slug, String? duration, String? filename, }) async {
final response = await replaceAssetWithHttpInfo(id, assetData, deviceAssetId, deviceId, fileCreatedAt, fileModifiedAt, key: key, slug: slug, duration: duration, filename: filename, );
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), 'AssetMediaResponseDto',) as AssetMediaResponseDto;
}
return null;
}
/// Run an asset job
///
/// Run a specific job on a set of assets.

View File

@@ -363,154 +363,6 @@ class DeprecatedApi {
return null;
}
/// Replace asset
///
/// Replace the asset with new file, without changing its id.
///
/// Note: This method returns the HTTP [Response].
///
/// Parameters:
///
/// * [String] id (required):
///
/// * [MultipartFile] assetData (required):
/// Asset file data
///
/// * [String] deviceAssetId (required):
/// Device asset ID
///
/// * [String] deviceId (required):
/// Device ID
///
/// * [DateTime] fileCreatedAt (required):
/// File creation date
///
/// * [DateTime] fileModifiedAt (required):
/// File modification date
///
/// * [String] key:
///
/// * [String] slug:
///
/// * [String] duration:
/// Duration (for videos)
///
/// * [String] filename:
/// Filename
Future<Response> replaceAssetWithHttpInfo(String id, MultipartFile assetData, String deviceAssetId, String deviceId, DateTime fileCreatedAt, DateTime fileModifiedAt, { String? key, String? slug, String? duration, String? filename, }) async {
// ignore: prefer_const_declarations
final apiPath = r'/assets/{id}/original'
.replaceAll('{id}', id);
// ignore: prefer_final_locals
Object? postBody;
final queryParams = <QueryParam>[];
final headerParams = <String, String>{};
final formParams = <String, String>{};
if (key != null) {
queryParams.addAll(_queryParams('', 'key', key));
}
if (slug != null) {
queryParams.addAll(_queryParams('', 'slug', slug));
}
const contentTypes = <String>['multipart/form-data'];
bool hasFields = false;
final mp = MultipartRequest('PUT', Uri.parse(apiPath));
if (assetData != null) {
hasFields = true;
mp.fields[r'assetData'] = assetData.field;
mp.files.add(assetData);
}
if (deviceAssetId != null) {
hasFields = true;
mp.fields[r'deviceAssetId'] = parameterToString(deviceAssetId);
}
if (deviceId != null) {
hasFields = true;
mp.fields[r'deviceId'] = parameterToString(deviceId);
}
if (duration != null) {
hasFields = true;
mp.fields[r'duration'] = parameterToString(duration);
}
if (fileCreatedAt != null) {
hasFields = true;
mp.fields[r'fileCreatedAt'] = parameterToString(fileCreatedAt);
}
if (fileModifiedAt != null) {
hasFields = true;
mp.fields[r'fileModifiedAt'] = parameterToString(fileModifiedAt);
}
if (filename != null) {
hasFields = true;
mp.fields[r'filename'] = parameterToString(filename);
}
if (hasFields) {
postBody = mp;
}
return apiClient.invokeAPI(
apiPath,
'PUT',
queryParams,
postBody,
headerParams,
formParams,
contentTypes.isEmpty ? null : contentTypes.first,
);
}
/// Replace asset
///
/// Replace the asset with new file, without changing its id.
///
/// Parameters:
///
/// * [String] id (required):
///
/// * [MultipartFile] assetData (required):
/// Asset file data
///
/// * [String] deviceAssetId (required):
/// Device asset ID
///
/// * [String] deviceId (required):
/// Device ID
///
/// * [DateTime] fileCreatedAt (required):
/// File creation date
///
/// * [DateTime] fileModifiedAt (required):
/// File modification date
///
/// * [String] key:
///
/// * [String] slug:
///
/// * [String] duration:
/// Duration (for videos)
///
/// * [String] filename:
/// Filename
Future<AssetMediaResponseDto?> replaceAsset(String id, MultipartFile assetData, String deviceAssetId, String deviceId, DateTime fileCreatedAt, DateTime fileModifiedAt, { String? key, String? slug, String? duration, String? filename, }) async {
final response = await replaceAssetWithHttpInfo(id, assetData, deviceAssetId, deviceId, fileCreatedAt, fileModifiedAt, key: key, slug: slug, duration: duration, filename: filename, );
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), 'AssetMediaResponseDto',) as AssetMediaResponseDto;
}
return null;
}
/// Run jobs
///
/// Queue all assets for a specific job type. Defaults to only queueing assets that have not yet been processed, but the force command can be used to re-process all assets.

View File

@@ -41,7 +41,6 @@ class Permission {
static const assetPeriodView = Permission._(r'asset.view');
static const assetPeriodDownload = Permission._(r'asset.download');
static const assetPeriodUpload = Permission._(r'asset.upload');
static const assetPeriodReplace = Permission._(r'asset.replace');
static const assetPeriodCopy = Permission._(r'asset.copy');
static const assetPeriodDerive = Permission._(r'asset.derive');
static const assetPeriodEditPeriodGet = Permission._(r'asset.edit.get');
@@ -200,7 +199,6 @@ class Permission {
assetPeriodView,
assetPeriodDownload,
assetPeriodUpload,
assetPeriodReplace,
assetPeriodCopy,
assetPeriodDerive,
assetPeriodEditPeriodGet,
@@ -394,7 +392,6 @@ class PermissionTypeTransformer {
case r'asset.view': return Permission.assetPeriodView;
case r'asset.download': return Permission.assetPeriodDownload;
case r'asset.upload': return Permission.assetPeriodUpload;
case r'asset.replace': return Permission.assetPeriodReplace;
case r'asset.copy': return Permission.assetPeriodCopy;
case r'asset.derive': return Permission.assetPeriodDerive;
case r'asset.edit.get': return Permission.assetPeriodEditPeriodGet;

View File

@@ -4216,89 +4216,6 @@
],
"x-immich-permission": "asset.download",
"x-immich-state": "Stable"
},
"put": {
"deprecated": true,
"description": "Replace the asset with new file, without changing its id.",
"operationId": "replaceAsset",
"parameters": [
{
"name": "id",
"required": true,
"in": "path",
"schema": {
"format": "uuid",
"type": "string"
}
},
{
"name": "key",
"required": false,
"in": "query",
"schema": {
"type": "string"
}
},
{
"name": "slug",
"required": false,
"in": "query",
"schema": {
"type": "string"
}
}
],
"requestBody": {
"content": {
"multipart/form-data": {
"schema": {
"$ref": "#/components/schemas/AssetMediaReplaceDto"
}
}
},
"required": true
},
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/AssetMediaResponseDto"
}
}
},
"description": "Asset replaced successfully"
}
},
"security": [
{
"bearer": []
},
{
"cookie": []
},
{
"api_key": []
}
],
"summary": "Replace asset",
"tags": [
"Assets",
"Deprecated"
],
"x-immich-history": [
{
"version": "v1",
"state": "Added"
},
{
"version": "v1",
"state": "Deprecated",
"replacementId": "copyAsset"
}
],
"x-immich-permission": "asset.replace",
"x-immich-state": "Deprecated"
}
},
"/assets/{id}/thumbnail": {
@@ -16610,49 +16527,6 @@
],
"type": "object"
},
"AssetMediaReplaceDto": {
"properties": {
"assetData": {
"description": "Asset file data",
"format": "binary",
"type": "string"
},
"deviceAssetId": {
"description": "Device asset ID",
"type": "string"
},
"deviceId": {
"description": "Device ID",
"type": "string"
},
"duration": {
"description": "Duration (for videos)",
"type": "string"
},
"fileCreatedAt": {
"description": "File creation date",
"format": "date-time",
"type": "string"
},
"fileModifiedAt": {
"description": "File modification date",
"format": "date-time",
"type": "string"
},
"filename": {
"description": "Filename",
"type": "string"
}
},
"required": [
"assetData",
"deviceAssetId",
"deviceId",
"fileCreatedAt",
"fileModifiedAt"
],
"type": "object"
},
"AssetMediaResponseDto": {
"properties": {
"id": {
@@ -19699,7 +19573,6 @@
"asset.view",
"asset.download",
"asset.upload",
"asset.replace",
"asset.copy",
"asset.derive",
"asset.edit.get",

View File

@@ -1028,22 +1028,6 @@ export type AssetOcrResponseDto = {
/** Normalized y coordinate of box corner 4 (0-1) */
y4: number;
};
export type AssetMediaReplaceDto = {
/** Asset file data */
assetData: Blob;
/** Device asset ID */
deviceAssetId: string;
/** Device ID */
deviceId: string;
/** Duration (for videos) */
duration?: string;
/** File creation date */
fileCreatedAt: string;
/** File modification date */
fileModifiedAt: string;
/** Filename */
filename?: string;
};
export type SignUpDto = {
/** User email */
email: string;
@@ -4270,27 +4254,6 @@ export function downloadAsset({ edited, id, key, slug }: {
...opts
}));
}
/**
* Replace asset
*/
export function replaceAsset({ id, key, slug, assetMediaReplaceDto }: {
id: string;
key?: string;
slug?: string;
assetMediaReplaceDto: AssetMediaReplaceDto;
}, opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchJson<{
status: 200;
data: AssetMediaResponseDto;
}>(`/assets/${encodeURIComponent(id)}/original${QS.query(QS.explode({
key,
slug
}))}`, oazapfts.multipart({
...opts,
method: "PUT",
body: assetMediaReplaceDto
})));
}
/**
* View asset thumbnail
*/
@@ -6920,7 +6883,6 @@ export enum Permission {
AssetView = "asset.view",
AssetDownload = "asset.download",
AssetUpload = "asset.upload",
AssetReplace = "asset.replace",
AssetCopy = "asset.copy",
AssetDerive = "asset.derive",
AssetEditGet = "asset.edit.get",

View File

@@ -8,7 +8,6 @@ import {
Param,
ParseFilePipe,
Post,
Put,
Query,
Req,
Res,
@@ -28,10 +27,8 @@ import {
AssetBulkUploadCheckDto,
AssetMediaCreateDto,
AssetMediaOptionsDto,
AssetMediaReplaceDto,
AssetMediaSize,
CheckExistingAssetsDto,
UploadFieldName,
} from 'src/dtos/asset-media.dto';
import { AssetDownloadOriginalDto } from 'src/dtos/asset.dto';
import { AuthDto } from 'src/dtos/auth.dto';
@@ -112,36 +109,6 @@ export class AssetMediaController {
await sendFile(res, next, () => this.service.downloadOriginal(auth, id, dto), this.logger);
}
@Put(':id/original')
@UseInterceptors(FileUploadInterceptor)
@ApiConsumes('multipart/form-data')
@ApiResponse({
status: 200,
description: 'Asset replaced successfully',
type: AssetMediaResponseDto,
})
@Endpoint({
summary: 'Replace asset',
description: 'Replace the asset with new file, without changing its id.',
history: new HistoryBuilder().added('v1').deprecated('v1', { replacementId: 'copyAsset' }),
})
@Authenticated({ permission: Permission.AssetReplace, sharedLink: true })
async replaceAsset(
@Auth() auth: AuthDto,
@Param() { id }: UUIDParamDto,
@UploadedFiles(new ParseFilePipe({ validators: [new FileNotEmptyValidator([UploadFieldName.ASSET_DATA])] }))
files: UploadFiles,
@Body() dto: AssetMediaReplaceDto,
@Res({ passthrough: true }) res: Response,
): Promise<AssetMediaResponseDto> {
const { file } = getFiles(files);
const responseDto = await this.service.replaceAsset(auth, id, dto, file);
if (responseDto.status === AssetMediaStatus.DUPLICATE) {
res.status(HttpStatus.OK);
}
return responseDto;
}
@Get(':id/thumbnail')
@FileResponse()
@Authenticated({ permission: Permission.AssetView, sharedLink: true })

View File

@@ -93,8 +93,6 @@ export class AssetMediaCreateDto extends AssetMediaBase {
[UploadFieldName.SIDECAR_DATA]?: any;
}
export class AssetMediaReplaceDto extends AssetMediaBase {}
export class AssetBulkUploadCheckItem {
@ApiProperty({ description: 'Asset ID' })
@IsString()

View File

@@ -105,7 +105,6 @@ export enum Permission {
AssetView = 'asset.view',
AssetDownload = 'asset.download',
AssetUpload = 'asset.upload',
AssetReplace = 'asset.replace',
AssetCopy = 'asset.copy',
AssetDerive = 'asset.derive',

View File

@@ -20,7 +20,7 @@ import {
MaintenanceStatusResponseDto,
SetMaintenanceModeDto,
} from 'src/dtos/maintenance.dto';
import { ServerConfigDto, ServerVersionResponseDto } from 'src/dtos/server.dto';
import { ServerConfigDto, ServerPingResponse, ServerVersionResponseDto } from 'src/dtos/server.dto';
import { ImmichCookie } from 'src/enum';
import { MaintenanceRoute } from 'src/maintenance/maintenance-auth.guard';
import { MaintenanceWorkerService } from 'src/maintenance/maintenance-worker.service';
@@ -52,6 +52,11 @@ export class MaintenanceWorkerController {
return this.service.getSystemConfig();
}
@Get('server/ping')
pingServer(): ServerPingResponse {
return this.service.ping();
}
@Get('server/version')
getServerVersion(): ServerVersionResponseDto {
return this.service.getVersion();

View File

@@ -12,7 +12,7 @@ import {
MaintenanceStatusResponseDto,
SetMaintenanceModeDto,
} from 'src/dtos/maintenance.dto';
import { ServerConfigDto, ServerVersionResponseDto } from 'src/dtos/server.dto';
import { ServerConfigDto, ServerPingResponse, ServerVersionResponseDto } from 'src/dtos/server.dto';
import { DatabaseLock, ImmichCookie, MaintenanceAction, SystemMetadataKey } from 'src/enum';
import { MaintenanceHealthRepository } from 'src/maintenance/maintenance-health.repository';
import { MaintenanceWebsocketRepository } from 'src/maintenance/maintenance-websocket.repository';
@@ -121,6 +121,10 @@ export class MaintenanceWorkerService {
return ServerVersionResponseDto.fromSemVer(serverVersion);
}
ping(): ServerPingResponse {
return { res: 'pong' };
}
/**
* {@link _ApiService.ssr}
*/

View File

@@ -1,4 +1,4 @@
import { Injectable } from '@nestjs/common';
import { Injectable, NotAcceptableException } from '@nestjs/common';
import { Interval } from '@nestjs/schedule';
import { NextFunction, Request, Response } from 'express';
import { readFileSync } from 'node:fs';
@@ -72,6 +72,13 @@ export class ApiService {
return next();
}
const responseType = request.accepts('text/html');
if (!responseType) {
throw new NotAcceptableException(
`The route ${request.path} was requested as ${request.header('accept')}, but only returns text/html`,
);
}
let status = 200;
let html = index;
@@ -105,7 +112,7 @@ export class ApiService {
html = render(index, meta);
}
res.status(status).type('text/html').header('Cache-Control', 'no-store').send(html);
res.status(status).type(responseType).header('Cache-Control', 'no-store').send(html);
};
}
}

View File

@@ -2,7 +2,6 @@ import { BadRequestException, Injectable, InternalServerErrorException, NotFound
import { extname } from 'node:path';
import sanitize from 'sanitize-filename';
import { StorageCore } from 'src/cores/storage.core';
import { Asset } from 'src/database';
import {
AssetBulkUploadCheckResponseDto,
AssetMediaResponseDto,
@@ -15,22 +14,13 @@ import {
AssetBulkUploadCheckDto,
AssetMediaCreateDto,
AssetMediaOptionsDto,
AssetMediaReplaceDto,
AssetMediaSize,
CheckExistingAssetsDto,
UploadFieldName,
} from 'src/dtos/asset-media.dto';
import { AssetDownloadOriginalDto } from 'src/dtos/asset.dto';
import { AuthDto } from 'src/dtos/auth.dto';
import {
AssetFileType,
AssetStatus,
AssetVisibility,
CacheControl,
JobName,
Permission,
StorageFolder,
} from 'src/enum';
import { AssetFileType, AssetVisibility, CacheControl, JobName, Permission, StorageFolder } from 'src/enum';
import { AuthRequest } from 'src/middleware/auth.guard';
import { BaseService } from 'src/services/base.service';
import { UploadFile, UploadRequest } from 'src/types';
@@ -163,40 +153,6 @@ export class AssetMediaService extends BaseService {
}
}
async replaceAsset(
auth: AuthDto,
id: string,
dto: AssetMediaReplaceDto,
file: UploadFile,
sidecarFile?: UploadFile,
): Promise<AssetMediaResponseDto> {
try {
await this.requireAccess({ auth, permission: Permission.AssetUpdate, ids: [id] });
const asset = await this.assetRepository.getById(id);
if (!asset) {
throw new Error('Asset not found');
}
this.requireQuota(auth, file.size);
await this.replaceFileData(asset.id, dto, file, sidecarFile?.originalPath);
// Next, create a backup copy of the existing record. The db record has already been updated above,
// but the local variable holds the original file data paths.
const copiedPhoto = await this.createCopy(asset);
// and immediate trash it
await this.assetRepository.updateAll([copiedPhoto.id], { deletedAt: new Date(), status: AssetStatus.Trashed });
await this.eventRepository.emit('AssetTrash', { assetId: copiedPhoto.id, userId: auth.user.id });
await this.userRepository.updateUsage(auth.user.id, file.size);
return { status: AssetMediaStatus.REPLACED, id: copiedPhoto.id };
} catch (error: any) {
return this.handleUploadError(error, auth, file, sidecarFile);
}
}
async downloadOriginal(auth: AuthDto, id: string, dto: AssetDownloadOriginalDto): Promise<ImmichFileResponse> {
await this.requireAccess({ auth, permission: Permission.AssetDownload, ids: [id] });
@@ -357,82 +313,6 @@ export class AssetMediaService extends BaseService {
throw error;
}
/**
* Updates the specified assetId to the specified photo data file properties: checksum, path,
* timestamps, deviceIds, and sidecar. Derived properties like: faces, smart search info, etc
* are UNTOUCHED. The photo data files modification times on the filesysytem are updated to
* the specified timestamps. The exif db record is upserted, and then A METADATA_EXTRACTION
* job is queued to update these derived properties.
*/
private async replaceFileData(
assetId: string,
dto: AssetMediaReplaceDto,
file: UploadFile,
sidecarPath?: string,
): Promise<void> {
await this.assetRepository.update({
id: assetId,
checksum: file.checksum,
originalPath: file.originalPath,
type: mimeTypes.assetType(file.originalPath),
originalFileName: file.originalName,
deviceAssetId: dto.deviceAssetId,
deviceId: dto.deviceId,
fileCreatedAt: dto.fileCreatedAt,
fileModifiedAt: dto.fileModifiedAt,
localDateTime: dto.fileCreatedAt,
duration: dto.duration || null,
livePhotoVideoId: null,
});
await (sidecarPath
? this.assetRepository.upsertFile({ assetId, type: AssetFileType.Sidecar, path: sidecarPath })
: this.assetRepository.deleteFile({ assetId, type: AssetFileType.Sidecar }));
await this.storageRepository.utimes(file.originalPath, new Date(), new Date(dto.fileModifiedAt));
await this.assetRepository.upsertExif(
{ assetId, fileSizeInByte: file.size },
{ lockedPropertiesBehavior: 'override' },
);
await this.jobRepository.queue({
name: JobName.AssetExtractMetadata,
data: { id: assetId, source: 'upload' },
});
}
/**
* Create a 'shallow' copy of the specified asset record creating a new asset record in the database.
* Uses only vital properties excluding things like: stacks, faces, smart search info, etc,
* and then queues a METADATA_EXTRACTION job.
*/
private async createCopy(asset: Omit<Asset, 'id'>) {
const created = await this.assetRepository.create({
ownerId: asset.ownerId,
originalPath: asset.originalPath,
originalFileName: asset.originalFileName,
libraryId: asset.libraryId,
deviceAssetId: asset.deviceAssetId,
deviceId: asset.deviceId,
type: asset.type,
checksum: asset.checksum,
fileCreatedAt: asset.fileCreatedAt,
localDateTime: asset.localDateTime,
fileModifiedAt: asset.fileModifiedAt,
livePhotoVideoId: asset.livePhotoVideoId,
});
const { size } = await this.storageRepository.stat(created.originalPath);
await this.assetRepository.upsertExif(
{ assetId: created.id, fileSizeInByte: size },
{ lockedPropertiesBehavior: 'override' },
);
await this.jobRepository.queue({ name: JobName.AssetExtractMetadata, data: { id: created.id, source: 'copy' } });
return created;
}
private async create(ownerId: string, dto: AssetMediaCreateDto, file: UploadFile, sidecarFile?: UploadFile) {
const asset = await this.assetRepository.create({
ownerId,

View File

@@ -330,7 +330,7 @@ describe(MetadataService.name, () => {
duration: null,
fileCreatedAt: asset.fileCreatedAt,
fileModifiedAt: asset.fileModifiedAt,
localDateTime: asset.localDateTime,
localDateTime: asset.fileCreatedAt,
width: null,
height: null,
});
@@ -360,7 +360,7 @@ describe(MetadataService.name, () => {
duration: null,
fileCreatedAt: asset.fileCreatedAt,
fileModifiedAt: asset.fileModifiedAt,
localDateTime: asset.localDateTime,
localDateTime: asset.fileCreatedAt,
width: null,
height: null,
});

View File

@@ -155,6 +155,33 @@ describe('transformFaceBoundingBox', () => {
expect(result.boundingBoxX2).toBe(50);
expect(result.boundingBoxY2).toBe(50);
});
it('should always return whole numbers', () => {
const edits: AssetEditActionItem[] = [
{ action: AssetEditAction.Crop, parameters: { x: 50, y: 50, width: 250, height: 250 } },
];
expect(transformFaceBoundingBox(baseFace, edits, { width: 1000, height: 400 })).toMatchObject({
boundingBoxX1: 50,
boundingBoxY1: 0,
boundingBoxX2: 150,
boundingBoxY2: 50,
});
expect(transformFaceBoundingBox(baseFace, edits, { width: 1001, height: 401 })).toMatchObject({
boundingBoxX1: 50,
boundingBoxY1: 0,
boundingBoxX2: 150,
boundingBoxY2: 50,
});
expect(transformFaceBoundingBox(baseFace, edits, { width: 999, height: 399 })).toMatchObject({
boundingBoxX1: 49,
boundingBoxY1: -0,
boundingBoxX2: 149,
boundingBoxY2: 49,
});
});
});
});

View File

@@ -179,10 +179,10 @@ export const transformFaceBoundingBox = (
// Ensure x1,y1 is top-left and x2,y2 is bottom-right
const [p1, p2] = transformedPoints;
return {
boundingBoxX1: Math.min(p1.x, p2.x),
boundingBoxY1: Math.min(p1.y, p2.y),
boundingBoxX2: Math.max(p1.x, p2.x),
boundingBoxY2: Math.max(p1.y, p2.y),
boundingBoxX1: Math.trunc(Math.min(p1.x, p2.x)),
boundingBoxY1: Math.trunc(Math.min(p1.y, p2.y)),
boundingBoxX2: Math.trunc(Math.max(p1.x, p2.x)),
boundingBoxY2: Math.trunc(Math.max(p1.y, p2.y)),
imageWidth: currentWidth,
imageHeight: currentHeight,
};

View File

@@ -19,7 +19,7 @@ import {
UserLike,
} from 'test/factories/types';
import { UserFactory } from 'test/factories/user.factory';
import { newDate, newSha1, newUuid, newUuidV7 } from 'test/small.factory';
import { newSha1, newUuid, newUuidV7 } from 'test/small.factory';
export class AssetFactory {
#owner!: UserFactory;
@@ -43,10 +43,12 @@ export class AssetFactory {
const originalFileName = dto.originalFileName ?? (dto.type === AssetType.Video ? `MOV_${id}.mp4` : `IMG_${id}.jpg`);
let now = Date.now();
return new AssetFactory({
id,
createdAt: newDate(),
updatedAt: newDate(),
createdAt: new Date(now++),
updatedAt: new Date(now++),
deletedAt: null,
updateId: newUuidV7(),
status: AssetStatus.Active,
@@ -55,14 +57,14 @@ export class AssetFactory {
deviceId: '',
duplicateId: null,
duration: null,
fileCreatedAt: newDate(),
fileModifiedAt: newDate(),
fileCreatedAt: new Date(now++),
fileModifiedAt: new Date(now++),
isExternal: false,
isFavorite: false,
isOffline: false,
libraryId: null,
livePhotoVideoId: null,
localDateTime: newDate(),
localDateTime: new Date(now),
originalFileName,
originalPath: `/data/library/${originalFileName}`,
ownerId: newUuid(),

View File

@@ -591,10 +591,10 @@ describe(PersonService.name, () => {
expect.arrayContaining([
expect.objectContaining({
person: expect.objectContaining({ id: person.id }),
boundingBoxX1: expect.closeTo(25, 1),
boundingBoxY1: expect.closeTo(50, 1),
boundingBoxX2: expect.closeTo(100, 1),
boundingBoxY2: expect.closeTo(100, 1),
boundingBoxX1: 25,
boundingBoxY1: 49,
boundingBoxX2: 99,
boundingBoxY2: 100,
}),
]),
);

View File

@@ -36,7 +36,7 @@
onLoad={() => adaptiveImageLoader.onLoad(quality)}
onError={() => adaptiveImageLoader.onError(quality)}
bind:ref
class="h-full w-full bg-transparent"
class="h-full w-full bg-transparent pointer-events-auto"
{alt}
{role}
draggable={false}

View File

@@ -112,7 +112,7 @@
switch (dto.command) {
case QueueCommand.Empty: {
toastManager.success($t('admin.cleared_jobs', { values: { job: item.title } }));
toastManager.primary($t('admin.cleared_jobs', { values: { job: item.title } }));
break;
}
}

View File

@@ -55,7 +55,7 @@
try {
await unlinkAllOAuthAccountsAdmin();
toastManager.success();
toastManager.primary();
} catch (error) {
handleError(error, $t('errors.something_went_wrong'));
}

View File

@@ -45,7 +45,7 @@
},
});
toastManager.success($t('admin.notification_email_test_email_sent', { values: { email: $user.email } }));
toastManager.primary($t('admin.notification_email_test_email_sent', { values: { email: $user.email } }));
if (!disabled) {
await handleSystemConfigSave({ notifications: configToEdit.notifications });

View File

@@ -4,6 +4,7 @@
import { handleError } from '$lib/utils/handle-error';
import { updateAlbumInfo } from '@immich/sdk';
import { t } from 'svelte-i18n';
import { tv } from 'tailwind-variants';
interface Props {
id: string;
@@ -36,14 +37,22 @@
return;
}
};
const styles = tv({
base: 'w-[99%] mb-2 border-b-2 border-transparent text-2xl md:text-4xl lg:text-6xl text-primary outline-none transition-all focus:border-b-2 focus:border-immich-primary focus:outline-none bg-light dark:focus:border-immich-dark-primary dark:focus:bg-immich-dark-gray placeholder:text-primary/90',
variants: {
isOwned: {
true: 'hover:border-gray-400',
false: 'hover:border-transparent',
},
},
});
</script>
<input
use:shortcut={{ shortcut: { key: 'Enter' }, onShortcut: (e) => e.currentTarget.blur() }}
onblur={handleUpdateName}
class="w-[99%] mb-2 border-b-2 border-transparent text-2xl md:text-4xl lg:text-6xl text-primary outline-none transition-all {isOwned
? 'hover:border-gray-400'
: 'hover:border-transparent'} focus:border-b-2 focus:border-immich-primary focus:outline-none bg-light dark:focus:border-immich-dark-primary dark:focus:bg-immich-dark-gray placeholder:text-primary/90"
class={styles({ isOwned })}
type="text"
bind:value={newAlbumName}
disabled={!isOwned}

View File

@@ -40,7 +40,7 @@
preAction({ type: AssetAction.DELETE, asset: timelineAsset });
await deleteAssets({ assetBulkDeleteDto: { ids: [asset.id], force: true } });
onAction({ type: AssetAction.DELETE, asset: timelineAsset });
toastManager.success($t('permanently_deleted_asset'));
toastManager.primary($t('permanently_deleted_asset'));
} catch (error) {
handleError(error, $t('errors.unable_to_delete_asset'));
}

View File

@@ -21,7 +21,7 @@
await restoreAssets({ bulkIdsDto: { ids: [asset.id] } });
asset.isTrashed = false;
onAction({ type: AssetAction.RESTORE, asset: toTimelineAsset(asset) });
toastManager.success($t('restored_asset'));
toastManager.primary($t('restored_asset'));
} catch (error) {
handleError(error, $t('errors.unable_to_restore_assets'));
}

View File

@@ -23,7 +23,7 @@
},
});
eventManager.emit('AlbumUpdate', response);
toastManager.success($t('album_cover_updated'));
toastManager.primary($t('album_cover_updated'));
} catch (error) {
handleError(error, $t('errors.unable_to_update_album_cover'));
}

View File

@@ -31,7 +31,7 @@
person,
});
toastManager.success($t('feature_photo_updated'));
toastManager.primary($t('feature_photo_updated'));
} catch (error) {
handleError(error, $t('errors.unable_to_set_feature_photo'));
}

View File

@@ -74,7 +74,7 @@
[ReactionType.Comment]: $t('comment_deleted'),
[ReactionType.Like]: $t('like_deleted'),
};
toastManager.success(deleteMessages[reaction.type]);
toastManager.primary(deleteMessages[reaction.type]);
} catch (error) {
handleError(error, $t('errors.unable_to_remove_reaction'));
}

View File

@@ -22,6 +22,7 @@
import { Route } from '$lib/route';
import { getGlobalActions } from '$lib/services/app.service';
import { getAssetActions } from '$lib/services/asset.service';
import { isFaceEditMode } from '$lib/stores/face-edit.svelte';
import { user } from '$lib/stores/user.store';
import { getSharedLink, withoutIcons } from '$lib/utils';
import type { OnUndoDelete } from '$lib/utils/actions';
@@ -88,7 +89,7 @@
title: $t('go_back'),
type: $t('assets'),
icon: languageManager.rtl ? mdiArrowRight : mdiArrowLeft,
$if: () => !!onClose,
$if: () => !!onClose && !isFaceEditMode.value,
onAction: () => onClose?.(),
shortcuts: [{ key: 'Escape' }],
});

View File

@@ -22,7 +22,7 @@
}
try {
await updateAsset({ id: asset.id, updateAssetDto: { description } });
toastManager.success($t('asset_description_updated'));
toastManager.primary($t('asset_description_updated'));
} catch (error) {
handleError(error, $t('cannot_update_the_description'));
}

View File

@@ -6,6 +6,7 @@
import { getNaturalSize, scaleToFit } from '$lib/utils/container-utils';
import { handleError } from '$lib/utils/handle-error';
import { createFace, getAllPeople, type PersonResponseDto } from '@immich/sdk';
import { shortcut } from '$lib/actions/shortcut';
import { Button, Input, modalManager, toastManager } from '@immich/ui';
import { Canvas, InteractiveFabricObject, Rect } from 'fabric';
import { clamp } from 'lodash-es';
@@ -289,6 +290,8 @@
};
</script>
<svelte:document use:shortcut={{ shortcut: { key: 'Escape' }, onShortcut: cancel }} />
<div
id="face-editor-data"
class="absolute start-0 top-0 z-5 h-full w-full overflow-hidden"

View File

@@ -67,7 +67,7 @@
if (failCount > 0) {
toastManager.warning($t('errors.unable_to_change_visibility', { values: { count: failCount } }));
}
toastManager.success($t('visibility_changed', { values: { count: successCount } }));
toastManager.primary($t('visibility_changed', { values: { count: successCount } }));
}
for (const person of people) {

View File

@@ -72,7 +72,7 @@
});
const mergedPerson = await getPerson({ id: person.id });
const count = results.filter(({ success }) => success).length;
toastManager.success($t('merged_people_count', { values: { count } }));
toastManager.primary($t('merged_people_count', { values: { count } }));
onMerge(mergedPerson);
} catch (error) {
handleError(error, $t('cannot_merge_people'));

View File

@@ -1,6 +1,7 @@
<script lang="ts">
import OnEvents from '$lib/components/OnEvents.svelte';
import { timeBeforeShowLoadingSpinner } from '$lib/constants';
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
import { eventManager } from '$lib/managers/event-manager.svelte';
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import { boundingBoxesArray } from '$lib/stores/people.store';
@@ -25,7 +26,6 @@
import { fly } from 'svelte/transition';
import ImageThumbnail from '../assets/thumbnail/image-thumbnail.svelte';
import AssignFaceSidePanel from './assign-face-side-panel.svelte';
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
interface Props {
assetId: string;
@@ -126,7 +126,7 @@
}
}
toastManager.success($t('people_edits_count', { values: { count: numberOfChanges } }));
toastManager.primary($t('people_edits_count', { values: { count: numberOfChanges } }));
} catch (error) {
handleError(error, $t('errors.cant_apply_changes'));
}

View File

@@ -71,7 +71,7 @@
disableButtons = true;
const data = await createPerson({ personCreateDto: {} });
await reassignFaces({ id: data.id, assetFaceUpdateDto: { data: selectedPeople } });
toastManager.success($t('reassigned_assets_to_new_person', { values: { count: assetIds.length } }));
toastManager.primary($t('reassigned_assets_to_new_person', { values: { count: assetIds.length } }));
} catch (error) {
handleError(error, $t('errors.unable_to_reassign_assets_new_person'));
} finally {
@@ -88,7 +88,7 @@
disableButtons = true;
if (selectedPerson) {
await reassignFaces({ id: selectedPerson.id, assetFaceUpdateDto: { data: selectedPeople } });
toastManager.success(
toastManager.primary(
$t('reassigned_assets_to_existing_person', {
values: { count: assetIds.length, name: selectedPerson.name || null },
}),

View File

@@ -206,7 +206,7 @@
}
await memoryStore.deleteMemory(current.memory.id);
toastManager.success($t('removed_memory'));
toastManager.primary($t('removed_memory'));
init(page);
};
@@ -217,7 +217,7 @@
const newSavedState = !current.memory.isSaved;
await memoryStore.updateMemorySaved(current.memory.id, newSavedState);
toastManager.success(newSavedState ? $t('added_to_favorites') : $t('removed_from_favorites'));
toastManager.primary(newSavedState ? $t('added_to_favorites') : $t('removed_from_favorites'));
init(page);
};

View File

@@ -52,7 +52,7 @@
? openFileUploadDialog()
: fileUploadHandler({ files }));
toastManager.success();
toastManager.primary();
} catch (error) {
handleError(error, $t('errors.unable_to_add_assets_to_shared_link'));
}

View File

@@ -345,8 +345,10 @@
{
shortcut: { key: 'Escape' },
onShortcut: (event) => {
event.stopPropagation();
closeDropdown();
if (isOpen) {
event.stopPropagation();
closeDropdown();
}
},
},
]}

View File

@@ -14,11 +14,11 @@
import { t } from 'svelte-i18n';
import SearchHistoryBox from './search-history-box.svelte';
interface Props {
type Props = {
value?: string;
grayTheme: boolean;
searchQuery?: MetadataSearchDto | SmartSearchDto;
}
};
let { value = $bindable(''), grayTheme, searchQuery = {} }: Props = $props();

View File

@@ -1,21 +1,14 @@
<script lang="ts" module>
export interface SearchCameraFilter {
make?: string;
model?: string;
lensModel?: string;
}
</script>
<script lang="ts">
import Combobox, { asComboboxOptions, asSelectedOption } from '$lib/components/shared-components/combobox.svelte';
import type { SearchCameraFilter } from '$lib/types';
import { handlePromiseError } from '$lib/utils';
import { SearchSuggestionType, getSearchSuggestions } from '@immich/sdk';
import { Text } from '@immich/ui';
import { t } from 'svelte-i18n';
interface Props {
type Props = {
filters: SearchCameraFilter;
}
};
let { filters = $bindable() }: Props = $props();

View File

@@ -1,18 +1,11 @@
<script lang="ts" module>
export interface SearchDateFilter {
takenBefore?: DateTime;
takenAfter?: DateTime;
}
</script>
<script lang="ts">
import type { SearchDateFilter } from '$lib/types';
import { DatePicker, Text } from '@immich/ui';
import type { DateTime } from 'luxon';
import { t } from 'svelte-i18n';
interface Props {
type Props = {
filters: SearchDateFilter;
}
};
let { filters = $bindable() }: Props = $props();

View File

@@ -1,19 +1,11 @@
<script lang="ts" module>
export interface SearchDisplayFilters {
isNotInAlbum: boolean;
isArchive: boolean;
isFavorite: boolean;
}
</script>
<script lang="ts">
import type { SearchDisplayFilters } from '$lib/types';
import { Checkbox, Label, Text } from '@immich/ui';
import { t } from 'svelte-i18n';
interface Props {
type Props = {
filters: SearchDisplayFilters;
}
};
let { filters = $bindable() }: Props = $props();
</script>

View File

@@ -1,22 +1,15 @@
<script lang="ts" module>
export interface SearchLocationFilter {
country?: string;
state?: string;
city?: string;
}
</script>
<script lang="ts">
import Combobox, { asComboboxOptions, asSelectedOption } from '$lib/components/shared-components/combobox.svelte';
import type { SearchLocationFilter } from '$lib/types';
import { handlePromiseError } from '$lib/utils';
import { getSearchSuggestions, SearchSuggestionType } from '@immich/sdk';
import { Text } from '@immich/ui';
import { onMount } from 'svelte';
import { t } from 'svelte-i18n';
interface Props {
type Props = {
filters: SearchLocationFilter;
}
};
let { filters = $bindable() }: Props = $props();

View File

@@ -9,6 +9,7 @@
import { mdiArrowRight, mdiClose } from '@mdi/js';
import { t } from 'svelte-i18n';
import type { SvelteSet } from 'svelte/reactivity';
import { tv } from 'tailwind-variants';
interface Props {
selectedPeople: SvelteSet<string>;
@@ -49,6 +50,16 @@
const nameLower = name.toLowerCase();
return name ? list.filter((p) => p.name.toLowerCase().includes(nameLower)) : list;
};
const styles = tv({
base: 'flex flex-col items-center rounded-3xl border-2 hover:bg-subtle dark:hover:bg-immich-dark-primary/20 p-2 transition-all',
variants: {
selected: {
true: 'dark:border-slate-500 border-slate-400 bg-slate-200 dark:bg-slate-800 dark:text-white',
false: 'border-transparent',
},
},
});
</script>
{#await peoplePromise}
@@ -74,11 +85,7 @@
{#each peopleList as person (person.id)}
<button
type="button"
class="flex flex-col items-center rounded-3xl border-2 hover:bg-subtle dark:hover:bg-immich-dark-primary/20 p-2 transition-all {selectedPeople.has(
person.id,
)
? 'dark:border-slate-500 border-slate-400 bg-slate-200 dark:bg-slate-800 dark:text-white'
: 'border-transparent'}"
class={styles({ selected: selectedPeople.has(person.id) })}
onclick={() => togglePersonSelection(person.id)}
>
<ImageThumbnail circle shadow url={getPeopleThumbnailUrl(person)} altText={person.name} widthStyle="100%" />

View File

@@ -5,7 +5,7 @@
children?: import('svelte').Snippet<[{ itemCount: number }]>;
}
let { class: className = '', itemCount = $bindable(1), children }: Props = $props();
let { class: className, itemCount = $bindable(1), children }: Props = $props();
let container: HTMLElement | undefined = $state();
let contentRect: DOMRectReadOnly | undefined = $state();

View File

@@ -30,7 +30,7 @@
if ($stats.errors > 0) {
toastManager.danger($t('upload_errors', { values: { count: $stats.errors } }));
} else if ($stats.success > 0) {
toastManager.success($t('upload_success'));
toastManager.primary($t('upload_success'));
}
if ($stats.duplicates > 0) {
toastManager.warning($t('upload_skipped_duplicates', { values: { count: $stats.duplicates } }));

View File

@@ -42,7 +42,7 @@
onFavorite?.(ids, isFavorite);
toastManager.success(
toastManager.primary(
isFavorite
? $t('added_to_favorites_count', { values: { count: ids.length } })
: $t('removed_from_favorites_count', { values: { count: ids.length } }),

View File

@@ -38,7 +38,7 @@
onRemove?.(ids);
const count = results.filter(({ success }) => success).length;
toastManager.success($t('assets_removed_count', { values: { count } }));
toastManager.primary($t('assets_removed_count', { values: { count } }));
clearSelect();
} catch (error) {

View File

@@ -24,7 +24,7 @@
const ids = [...getAssets()].map((a) => a.id);
await restoreAssets({ bulkIdsDto: { ids } });
onRestore?.(ids);
toastManager.success($t('assets_restored_count', { values: { count: ids.length } }));
toastManager.primary($t('assets_restored_count', { values: { count: ids.length } }));
clearSelect();
} catch (error) {
handleError(error, $t('errors.unable_to_restore_assets'));

View File

@@ -22,7 +22,7 @@
try {
await changePinCode({ pinCodeChangeDto: { pinCode: currentPinCode, newPinCode } });
resetForm();
toastManager.success($t('pin_code_changed_successfully'));
toastManager.primary($t('pin_code_changed_successfully'));
} catch (error) {
handleError(error, $t('unable_to_change_pin_code'));
} finally {

View File

@@ -26,7 +26,7 @@
isLoading = true;
try {
await setupPinCode({ pinCodeSetupDto: { pinCode: newPinCode } });
toastManager.success($t('pin_code_setup_successfully'));
toastManager.primary($t('pin_code_setup_successfully'));
onCreated?.(newPinCode);
resetForm();
} catch (error) {

View File

@@ -25,7 +25,7 @@
try {
await deleteSession({ id: device.id });
toastManager.success($t('logged_out_device'));
toastManager.primary($t('logged_out_device'));
} catch (error) {
handleError(error, $t('errors.unable_to_log_out_device'));
} finally {
@@ -41,7 +41,7 @@
try {
await deleteAllSessions();
toastManager.success($t('logged_out_all_devices'));
toastManager.primary($t('logged_out_all_devices'));
} catch (error) {
handleError(error, $t('errors.unable_to_log_out_all_devices'));
} finally {

View File

@@ -25,7 +25,7 @@
});
$preferences = newPreferences;
toastManager.success($t('saved_settings'));
toastManager.primary($t('saved_settings'));
} catch (error) {
handleError(error, $t('errors.unable_to_update_settings'));
}

View File

@@ -53,7 +53,7 @@
$preferences = { ...data };
toastManager.success($t('saved_settings'));
toastManager.primary($t('saved_settings'));
} catch (error) {
handleError(error, $t('errors.unable_to_update_settings'));
}

View File

@@ -26,7 +26,7 @@
$preferences.emailNotifications.albumInvite = data.emailNotifications.albumInvite;
$preferences.emailNotifications.albumUpdate = data.emailNotifications.albumUpdate;
toastManager.success($t('saved_settings'));
toastManager.primary($t('saved_settings'));
} catch (error) {
handleError(error, $t('errors.unable_to_update_settings'));
}

View File

@@ -22,7 +22,7 @@
try {
loading = true;
user = await oauth.link(globalThis.location);
toastManager.success($t('linked_oauth_account'));
toastManager.primary($t('linked_oauth_account'));
} catch (error) {
handleError(error, $t('errors.unable_to_link_oauth_account'));
} finally {
@@ -36,7 +36,7 @@
const handleUnlink = async () => {
try {
user = await oauth.unlink();
toastManager.success($t('unlinked_oauth_account'));
toastManager.primary($t('unlinked_oauth_account'));
} catch (error) {
handleError(error, $t('errors.unable_to_unlink_account'));
}

View File

@@ -23,7 +23,7 @@
Object.assign(editedUser, data);
$user = data;
toastManager.success($t('saved_profile'));
toastManager.primary($t('saved_profile'));
} catch (error) {
handleError(error, $t('errors.unable_to_save_profile'));
}

View File

@@ -142,7 +142,7 @@ export class EditManager {
eventManager.emit('AssetEditsApplied', assetId);
toastManager.success(t('editor_edits_applied_success'));
toastManager.primary(t('editor_edits_applied_success'));
this.hasAppliedEdits = true;
return true;

View File

@@ -20,7 +20,7 @@
await deleteProfileImage();
}
toastManager.success($t('saved_profile'));
toastManager.primary($t('saved_profile'));
$user = await updateMyUser({ userUpdateMeDto: { avatarColor: color } });
onClose();

View File

@@ -38,7 +38,7 @@
id: personToBeMergedInto.id,
mergePersonDto: { ids: [personToMerge.id] },
});
toastManager.success($t('merge_people_successfully'));
toastManager.primary($t('merge_people_successfully'));
onClose([personToMerge, personToBeMergedInto]);
} catch (error) {
handleError(error, $t('errors.unable_to_save_name'));

View File

@@ -69,7 +69,7 @@
}
const file = new File([blob], 'profile-picture.png', { type: 'image/png' });
const { profileImagePath, profileChangedAt } = await createProfileImage({ createProfileImageDto: { file } });
toastManager.success($t('profile_picture_set'));
toastManager.primary($t('profile_picture_set'));
$user.profileImagePath = profileImagePath;
$user.profileChangedAt = profileChangedAt;

View File

@@ -1,28 +1,5 @@
<script lang="ts" module>
import { MediaType, QueryType, validQueryTypes } from '$lib/constants';
import type { SearchDateFilter } from '../components/shared-components/search-bar/search-date-section.svelte';
import type { SearchDisplayFilters } from '../components/shared-components/search-bar/search-display-section.svelte';
import type { SearchLocationFilter } from '../components/shared-components/search-bar/search-location-section.svelte';
export type SearchFilter = {
query: string;
ocr?: string;
queryType: 'smart' | 'metadata' | 'description' | 'ocr';
personIds: SvelteSet<string>;
tagIds: SvelteSet<string> | null;
location: SearchLocationFilter;
camera: SearchCameraFilter;
date: SearchDateFilter;
display: SearchDisplayFilters;
mediaType: MediaType;
rating?: number | null;
};
</script>
<script lang="ts">
import SearchCameraSection, {
type SearchCameraFilter,
} from '$lib/components/shared-components/search-bar/search-camera-section.svelte';
import SearchCameraSection from '$lib/components/shared-components/search-bar/search-camera-section.svelte';
import SearchDateSection from '$lib/components/shared-components/search-bar/search-date-section.svelte';
import SearchDisplaySection from '$lib/components/shared-components/search-bar/search-display-section.svelte';
import SearchLocationSection from '$lib/components/shared-components/search-bar/search-location-section.svelte';
@@ -31,7 +8,9 @@
import SearchRatingsSection from '$lib/components/shared-components/search-bar/search-ratings-section.svelte';
import SearchTagsSection from '$lib/components/shared-components/search-bar/search-tags-section.svelte';
import SearchTextSection from '$lib/components/shared-components/search-bar/search-text-section.svelte';
import { MediaType, QueryType, validQueryTypes } from '$lib/constants';
import { preferences } from '$lib/stores/user.store';
import type { SearchFilter } from '$lib/types';
import { parseUtcDate } from '$lib/utils/date-time';
import { generateId } from '$lib/utils/generate-id';
import { AssetTypeEnum, AssetVisibility, type MetadataSearchDto, type SmartSearchDto } from '@immich/sdk';
@@ -41,10 +20,10 @@
import { t } from 'svelte-i18n';
import { SvelteSet } from 'svelte/reactivity';
interface Props {
type Props = {
searchQuery: MetadataSearchDto | SmartSearchDto;
onClose: (search?: SmartSearchDto | MetadataSearchDto) => void;
}
};
let { searchQuery, onClose }: Props = $props();
@@ -66,52 +45,57 @@
return validQueryTypes.has(storedQueryType) ? storedQueryType : QueryType.SMART;
}
let query = '';
if ('query' in searchQuery && searchQuery.query) {
query = searchQuery.query;
}
if ('originalFileName' in searchQuery && searchQuery.originalFileName) {
query = searchQuery.originalFileName;
}
const asFilter = (searchQuery: SmartSearchDto | MetadataSearchDto): SearchFilter => {
let query = '';
if ('query' in searchQuery && searchQuery.query) {
query = searchQuery.query;
}
if ('originalFileName' in searchQuery && searchQuery.originalFileName) {
query = searchQuery.originalFileName;
}
let filter: SearchFilter = $state({
query,
ocr: searchQuery.ocr,
queryType: defaultQueryType(),
personIds: new SvelteSet('personIds' in searchQuery ? searchQuery.personIds : []),
tagIds:
'tagIds' in searchQuery
? searchQuery.tagIds === null
? null
: new SvelteSet(searchQuery.tagIds)
: new SvelteSet(),
location: {
country: withNullAsUndefined(searchQuery.country),
state: withNullAsUndefined(searchQuery.state),
city: withNullAsUndefined(searchQuery.city),
},
camera: {
make: withNullAsUndefined(searchQuery.make),
model: withNullAsUndefined(searchQuery.model),
lensModel: withNullAsUndefined(searchQuery.lensModel),
},
date: {
takenAfter: searchQuery.takenAfter ? toStartOfDayDate(searchQuery.takenAfter) : undefined,
takenBefore: searchQuery.takenBefore ? toStartOfDayDate(searchQuery.takenBefore) : undefined,
},
display: {
isArchive: searchQuery.visibility === AssetVisibility.Archive,
isFavorite: searchQuery.isFavorite ?? false,
isNotInAlbum: 'isNotInAlbum' in searchQuery ? (searchQuery.isNotInAlbum ?? false) : false,
},
mediaType:
searchQuery.type === AssetTypeEnum.Image
? MediaType.Image
: searchQuery.type === AssetTypeEnum.Video
? MediaType.Video
: MediaType.All,
rating: searchQuery.rating,
});
return {
query,
ocr: searchQuery.ocr,
queryType: defaultQueryType(),
queryAssetId: 'queryAssetId' in searchQuery ? searchQuery.queryAssetId : undefined,
personIds: new SvelteSet('personIds' in searchQuery ? searchQuery.personIds : []),
tagIds:
'tagIds' in searchQuery
? searchQuery.tagIds === null
? null
: new SvelteSet(searchQuery.tagIds)
: new SvelteSet(),
location: {
country: withNullAsUndefined(searchQuery.country),
state: withNullAsUndefined(searchQuery.state),
city: withNullAsUndefined(searchQuery.city),
},
camera: {
make: withNullAsUndefined(searchQuery.make),
model: withNullAsUndefined(searchQuery.model),
lensModel: withNullAsUndefined(searchQuery.lensModel),
},
date: {
takenAfter: searchQuery.takenAfter ? toStartOfDayDate(searchQuery.takenAfter) : undefined,
takenBefore: searchQuery.takenBefore ? toStartOfDayDate(searchQuery.takenBefore) : undefined,
},
display: {
isArchive: searchQuery.visibility === AssetVisibility.Archive,
isFavorite: searchQuery.isFavorite ?? false,
isNotInAlbum: 'isNotInAlbum' in searchQuery ? (searchQuery.isNotInAlbum ?? false) : false,
},
mediaType:
searchQuery.type === AssetTypeEnum.Image
? MediaType.Image
: searchQuery.type === AssetTypeEnum.Video
? MediaType.Video
: MediaType.All,
rating: searchQuery.rating,
};
};
let filter: SearchFilter = $state(asFilter(searchQuery));
const resetForm = () => {
filter = {
@@ -145,6 +129,7 @@
let payload: SmartSearchDto | MetadataSearchDto = {
query: filter.queryType === 'smart' ? query : undefined,
queryAssetId: filter.queryAssetId || undefined,
ocr: filter.queryType === 'ocr' ? query : undefined,
originalFileName: filter.queryType === 'metadata' ? query : undefined,
description: filter.queryType === 'description' ? query : undefined,

View File

@@ -163,7 +163,7 @@ const notifyAddToAlbums = (
} else if (results.error) {
toastManager.warning($t('assets_cannot_be_added_to_albums', { values: { count: assetIds.length } }));
} else {
toastManager.success(
toastManager.primary(
$t('assets_added_to_albums_count', {
values: { albumTotal: albumIds.length, assetTotal: assetIds.length },
}),
@@ -269,7 +269,7 @@ export const handleDeleteAlbum = async (album: AlbumResponseDto, options?: { pro
await deleteAlbum({ id: album.id });
eventManager.emit('AlbumDelete', album);
if (notify) {
toastManager.success();
toastManager.primary();
}
return true;
} catch (error) {

View File

@@ -80,7 +80,7 @@ export const handleUpdateApiKey = async (apiKey: { id: string }, dto: ApiKeyUpda
try {
const response = await updateApiKey({ id: apiKey.id, apiKeyUpdateDto: dto });
eventManager.emit('ApiKeyUpdate', response);
toastManager.success($t('saved_api_key'));
toastManager.primary($t('saved_api_key'));
return true;
} catch (error) {
handleError(error, $t('errors.unable_to_save_api_key'));
@@ -98,7 +98,7 @@ export const handleDeleteApiKey = async (apiKey: ApiKeyResponseDto) => {
try {
await deleteApiKey({ id: apiKey.id });
eventManager.emit('ApiKeyDelete', apiKey);
toastManager.success($t('removed_api_key', { values: { name: apiKey.name } }));
toastManager.primary($t('removed_api_key', { values: { name: apiKey.name } }));
} catch (error) {
handleError(error, $t('errors.unable_to_remove_api_key'));
}

View File

@@ -11,7 +11,7 @@ import { vitest } from 'vitest';
vitest.mock('@immich/ui', () => ({
toastManager: {
success: vitest.fn(),
primary: vitest.fn(),
},
}));
@@ -67,7 +67,7 @@ describe('AssetService', () => {
const asset = assetFactory.build({ originalFileName: 'asset.heic' });
await handleDownloadAsset(asset, { edited: false });
expect($t).toHaveBeenNthCalledWith(1, 'downloading_asset_filename', { values: { filename: 'asset.heic' } });
expect(toastManager.success).toHaveBeenCalledWith('formatter');
expect(toastManager.primary).toHaveBeenCalledWith('formatter');
});
it('should use the motion asset originalFileName when showing toasts', async () => {
@@ -79,7 +79,7 @@ describe('AssetService', () => {
await handleDownloadAsset(asset, { edited: false });
expect($t).toHaveBeenNthCalledWith(1, 'downloading_asset_filename', { values: { filename: 'asset.heic' } });
expect($t).toHaveBeenNthCalledWith(2, 'downloading_asset_filename', { values: { filename: 'asset.mov' } });
expect(toastManager.success).toHaveBeenCalledWith('formatter');
expect(toastManager.primary).toHaveBeenCalledWith('formatter');
});
});
});

View File

@@ -334,7 +334,7 @@ export const handleDownloadAsset = async (asset: AssetResponseDto, { edited }: {
}
try {
toastManager.success($t('downloading_asset_filename', { values: { filename } }));
toastManager.primary($t('downloading_asset_filename', { values: { filename } }));
downloadUrl(
getBaseUrl() +
`/assets/${id}/original` +
@@ -352,7 +352,7 @@ const handleFavorite = async (asset: AssetResponseDto) => {
try {
const response = await updateAsset({ id: asset.id, updateAssetDto: { isFavorite: true } });
toastManager.success($t('added_to_favorites'));
toastManager.primary($t('added_to_favorites'));
eventManager.emit('AssetUpdate', response);
} catch (error) {
handleError(error, $t('errors.unable_to_add_remove_favorites', { values: { favorite: asset.isFavorite } }));
@@ -364,7 +364,7 @@ const handleUnfavorite = async (asset: AssetResponseDto) => {
try {
const response = await updateAsset({ id: asset.id, updateAssetDto: { isFavorite: false } });
toastManager.success($t('removed_from_favorites'));
toastManager.primary($t('removed_from_favorites'));
eventManager.emit('AssetUpdate', response);
} catch (error) {
handleError(error, $t('errors.unable_to_add_remove_favorites', { values: { favorite: asset.isFavorite } }));
@@ -387,7 +387,7 @@ const handleRunAssetJob = async (dto: AssetJobsDto) => {
try {
await runAssetJobs({ assetJobsDto: dto });
toastManager.success(getAssetJobMessage($t, dto.name));
toastManager.primary(getAssetJobMessage($t, dto.name));
} catch (error) {
handleError(error, $t('errors.unable_to_submit_job'));
}

View File

@@ -8,7 +8,7 @@ export const handleCreateJob = async (dto: JobCreateDto) => {
try {
await createJob({ jobCreateDto: dto });
toastManager.success($t('admin.job_created'));
toastManager.primary($t('admin.job_created'));
return true;
} catch (error) {
handleError(error, $t('errors.unable_to_submit_job'));

View File

@@ -161,7 +161,7 @@ export const handleCreateLibrary = async (dto: CreateLibraryDto) => {
try {
const library = await createLibrary({ createLibraryDto: dto });
eventManager.emit('LibraryCreate', library);
toastManager.success($t('admin.library_created', { values: { library: library.name } }));
toastManager.primary($t('admin.library_created', { values: { library: library.name } }));
return library;
} catch (error) {
handleError(error, $t('errors.unable_to_create_library'));
@@ -174,7 +174,7 @@ export const handleUpdateLibrary = async (library: LibraryResponseDto, dto: Upda
try {
const updatedLibrary = await updateLibrary({ id: library.id, updateLibraryDto: dto });
eventManager.emit('LibraryUpdate', updatedLibrary);
toastManager.success($t('admin.library_updated'));
toastManager.primary($t('admin.library_updated'));
return true;
} catch (error) {
handleError(error, $t('errors.unable_to_update_library'));
@@ -205,7 +205,7 @@ const handleDeleteLibrary = async (library: LibraryResponseDto) => {
try {
await deleteLibrary({ id: library.id });
eventManager.emit('LibraryDelete', { id: library.id });
toastManager.success($t('admin.library_deleted'));
toastManager.primary($t('admin.library_deleted'));
} catch (error) {
handleError(error, $t('errors.unable_to_remove_library'));
}
@@ -225,7 +225,7 @@ export const handleAddLibraryFolder = async (library: LibraryResponseDto, folder
updateLibraryDto: { importPaths: [...library.importPaths, folder] },
});
eventManager.emit('LibraryUpdate', updatedLibrary);
toastManager.success($t('admin.library_updated'));
toastManager.primary($t('admin.library_updated'));
} catch (error) {
handleError(error, $t('errors.unable_to_update_library'));
return false;
@@ -246,7 +246,7 @@ export const handleEditLibraryFolder = async (library: LibraryResponseDto, oldVa
try {
const updatedLibrary = await updateLibrary({ id: library.id, updateLibraryDto: { importPaths } });
eventManager.emit('LibraryUpdate', updatedLibrary);
toastManager.success($t('admin.library_updated'));
toastManager.primary($t('admin.library_updated'));
} catch (error) {
handleError(error, $t('errors.unable_to_update_library'));
return false;
@@ -273,7 +273,7 @@ const handleDeleteLibraryFolder = async (library: LibraryResponseDto, folder: st
updateLibraryDto: { importPaths: library.importPaths.filter((path) => path !== folder) },
});
eventManager.emit('LibraryUpdate', updatedLibrary);
toastManager.success($t('admin.library_updated'));
toastManager.primary($t('admin.library_updated'));
} catch (error) {
handleError(error, $t('errors.unable_to_update_library'));
}
@@ -293,7 +293,7 @@ export const handleAddLibraryExclusionPattern = async (library: LibraryResponseD
updateLibraryDto: { exclusionPatterns: [...library.exclusionPatterns, exclusionPattern] },
});
eventManager.emit('LibraryUpdate', updatedLibrary);
toastManager.success($t('admin.library_updated'));
toastManager.primary($t('admin.library_updated'));
} catch (error) {
handleError(error, $t('errors.unable_to_update_library'));
return false;
@@ -314,7 +314,7 @@ export const handleEditExclusionPattern = async (library: LibraryResponseDto, ol
try {
const updatedLibrary = await updateLibrary({ id: library.id, updateLibraryDto: { exclusionPatterns } });
eventManager.emit('LibraryUpdate', updatedLibrary);
toastManager.success($t('admin.library_updated'));
toastManager.primary($t('admin.library_updated'));
} catch (error) {
handleError(error, $t('errors.unable_to_update_library'));
return false;
@@ -339,7 +339,7 @@ const handleDeleteExclusionPattern = async (library: LibraryResponseDto, exclusi
},
});
eventManager.emit('LibraryUpdate', updatedLibrary);
toastManager.success($t('admin.library_updated'));
toastManager.primary($t('admin.library_updated'));
} catch (error) {
handleError(error, $t('errors.unable_to_update_library'));
}

View File

@@ -57,7 +57,7 @@ const handleFavoritePerson = async (person: { id: string }) => {
try {
const response = await updatePerson({ id: person.id, personUpdateDto: { isFavorite: true } });
eventManager.emit('PersonUpdate', response);
toastManager.success($t('added_to_favorites'));
toastManager.primary($t('added_to_favorites'));
} catch (error) {
handleError(error, $t('errors.unable_to_add_remove_favorites', { values: { favorite: false } }));
}
@@ -69,7 +69,7 @@ const handleUnfavoritePerson = async (person: { id: string }) => {
try {
const response = await updatePerson({ id: person.id, personUpdateDto: { isFavorite: false } });
eventManager.emit('PersonUpdate', response);
toastManager.success($t('removed_from_favorites'));
toastManager.primary($t('removed_from_favorites'));
} catch (error) {
handleError(error, $t('errors.unable_to_add_remove_favorites', { values: { favorite: false } }));
}
@@ -80,7 +80,7 @@ const handleHidePerson = async (person: { id: string }) => {
try {
const response = await updatePerson({ id: person.id, personUpdateDto: { isHidden: true } });
toastManager.success($t('changed_visibility_successfully'));
toastManager.primary($t('changed_visibility_successfully'));
eventManager.emit('PersonUpdate', response);
} catch (error) {
handleError(error, $t('errors.unable_to_hide_person'));
@@ -92,7 +92,7 @@ const handleShowPerson = async (person: { id: string }) => {
try {
const response = await updatePerson({ id: person.id, personUpdateDto: { isHidden: false } });
toastManager.success($t('changed_visibility_successfully'));
toastManager.primary($t('changed_visibility_successfully'));
eventManager.emit('PersonUpdate', response);
} catch (error) {
handleError(error, $t('errors.something_went_wrong'));
@@ -104,7 +104,7 @@ export const handleUpdatePersonBirthDate = async (person: PersonResponseDto, bir
try {
const response = await updatePerson({ id: person.id, personUpdateDto: { birthDate } });
toastManager.success($t('date_of_birth_saved'));
toastManager.primary($t('date_of_birth_saved'));
eventManager.emit('PersonUpdate', response);
return true;
} catch (error) {

View File

@@ -129,7 +129,7 @@ export const handleEmptyQueue = async (queue: QueueResponseDto) => {
await emptyQueue({ name: queue.name, queueDeleteDto: { failed: false } });
const response = await getQueue({ name: queue.name });
eventManager.emit('QueueUpdate', response);
toastManager.success($t('admin.cleared_jobs', { values: { job: item.title } }));
toastManager.primary($t('admin.cleared_jobs', { values: { job: item.title } }));
} catch (error) {
handleError(error, $t('errors.something_went_wrong'));
}
@@ -155,7 +155,7 @@ const handleRemoveFailedJobs = async (queue: QueueResponseDto) => {
await emptyQueue({ name: queue.name, queueDeleteDto: { failed: true } });
const response = await getQueue({ name: queue.name });
eventManager.emit('QueueUpdate', response);
toastManager.success();
toastManager.primary();
} catch (error) {
handleError(error, $t('errors.something_went_wrong'));
}

View File

@@ -95,7 +95,7 @@ export const handleUpdateSharedLink = async (sharedLink: SharedLinkResponseDto,
const response = await updateSharedLink({ id: sharedLink.id, sharedLinkEditDto: dto });
eventManager.emit('SharedLinkUpdate', { album: sharedLink.album, ...response });
toastManager.success($t('saved'));
toastManager.primary($t('saved'));
return true;
} catch (error) {
@@ -118,7 +118,7 @@ const handleDeleteSharedLink = async (sharedLink: SharedLinkResponseDto) => {
try {
await removeSharedLink({ id: sharedLink.id });
eventManager.emit('SharedLinkDelete', sharedLink);
toastManager.success($t('deleted_shared_link'));
toastManager.primary($t('deleted_shared_link'));
} catch (error) {
handleError(error, $t('errors.unable_to_delete_shared_link'));
}
@@ -150,7 +150,7 @@ export const handleRemoveSharedLinkAssets = async (sharedLink: SharedLinkRespons
}
const count = results.filter((item) => item.success).length;
toastManager.success($t('assets_removed_count', { values: { count } }));
toastManager.primary($t('assets_removed_count', { values: { count } }));
return true;
} catch (error) {
handleError(error, $t('errors.unable_to_remove_assets_from_shared_link'));

View File

@@ -62,7 +62,7 @@ export const handleSystemConfigSave = async (update: Partial<SystemConfigDto>) =
const newConfig = await updateConfig({ systemConfigDto });
eventManager.emit('SystemConfigUpdate', newConfig);
toastManager.success($t('settings_saved'));
toastManager.primary($t('settings_saved'));
} catch (error) {
handleError(error, $t('errors.unable_to_save_settings'));
}

View File

@@ -42,7 +42,7 @@ export const handleCreateTag = async (tagValue: string) => {
return;
}
toastManager.success($t('tag_created', { values: { tag: tag.value } }));
toastManager.primary($t('tag_created', { values: { tag: tag.value } }));
eventManager.emit('TagCreate', tag);
return true;
@@ -61,7 +61,7 @@ export const handleUpdateTag = async (tag: TreeNode, dto: TagUpdateDto) => {
try {
const response = await updateTag({ id: tag.id, tagUpdateDto: dto });
toastManager.success($t('tag_updated', { values: { tag: tag.value } }));
toastManager.primary($t('tag_updated', { values: { tag: tag.value } }));
eventManager.emit('TagUpdate', response);
return true;
@@ -91,7 +91,7 @@ const handleDeleteTag = async (tag: TreeNode) => {
try {
await deleteTag({ id: tagId });
eventManager.emit('TagDelete', tag);
toastManager.success();
toastManager.primary();
} catch (error) {
handleError(error, $t('errors.something_went_wrong'));
}

View File

@@ -31,7 +31,7 @@ export const handleEmptyTrash = async () => {
try {
const { count } = await emptyTrash();
toastManager.success($t('assets_permanently_deleted_count', { values: { count } }));
toastManager.primary($t('assets_permanently_deleted_count', { values: { count } }));
} catch (error) {
handleError(error, $t('errors.unable_to_empty_trash'));
}
@@ -47,7 +47,7 @@ export const handleRestoreTrash = async () => {
try {
const { count } = await restoreTrash();
toastManager.success($t('assets_restored_count', { values: { count } }));
toastManager.primary($t('assets_restored_count', { values: { count } }));
} catch (error) {
handleError(error, $t('errors.unable_to_restore_trash'));
}

View File

@@ -109,7 +109,7 @@ export const handleCreateUserAdmin = async (dto: UserAdminCreateDto) => {
try {
const response = await createUserAdmin({ userAdminCreateDto: dto });
eventManager.emit('UserAdminCreate', response);
toastManager.success();
toastManager.primary();
return response;
} catch (error) {
handleError(error, $t('errors.unable_to_create_user'));
@@ -122,7 +122,7 @@ export const handleUpdateUserAdmin = async (user: UserAdminResponseDto, dto: Use
try {
const response = await updateUserAdmin({ id: user.id, userAdminUpdateDto: dto });
eventManager.emit('UserAdminUpdate', response);
toastManager.success();
toastManager.primary();
return true;
} catch (error) {
handleError(error, $t('errors.unable_to_update_user'));
@@ -136,7 +136,7 @@ export const handleDeleteUserAdmin = async (user: UserAdminResponseDto, dto: Use
try {
const result = await deleteUserAdmin({ id: user.id, userAdminDeleteDto: dto });
eventManager.emit('UserAdminDelete', result);
toastManager.success();
toastManager.primary();
return true;
} catch (error) {
handleError(error, $t('errors.unable_to_delete_user'));
@@ -149,7 +149,7 @@ export const handleRestoreUserAdmin = async (user: UserAdminResponseDto) => {
try {
const response = await restoreUserAdmin({ id: user.id });
eventManager.emit('UserAdminRestore', response);
toastManager.success();
toastManager.primary();
return true;
} catch (error) {
handleError(error, $t('errors.unable_to_restore_user'));
@@ -190,7 +190,7 @@ const handleResetPasswordUserAdmin = async (user: UserAdminResponseDto) => {
const dto = { password: generatePassword(), shouldChangePassword: true };
const response = await updateUserAdmin({ id: user.id, userAdminUpdateDto: dto });
eventManager.emit('UserAdminUpdate', response);
toastManager.success();
toastManager.primary();
await modalManager.show(PasswordResetSuccessModal, { newPassword: dto.password });
} catch (error) {
handleError(error, $t('errors.unable_to_reset_password'));
@@ -208,7 +208,7 @@ const handleResetPinCodeUserAdmin = async (user: UserAdminResponseDto) => {
try {
const response = await updateUserAdmin({ id: user.id, userAdminUpdateDto: { pinCode: null } });
eventManager.emit('UserAdminUpdate', response);
toastManager.success($t('pin_code_reset_successfully'));
toastManager.primary($t('pin_code_reset_successfully'));
} catch (error) {
handleError(error, $t('errors.unable_to_reset_pin_code'));
}

View File

@@ -39,7 +39,7 @@ export const handleResetPinCode = async (dto: PinCodeResetDto) => {
try {
await resetPinCode({ pinCodeResetDto: dto });
toastManager.success($t('pin_code_reset_successfully'));
toastManager.primary($t('pin_code_reset_successfully'));
eventManager.emit('UserPinCodeReset');
return true;
} catch (error) {
@@ -52,7 +52,7 @@ export const handleChangePassword = async (dto: ChangePasswordDto) => {
try {
await changePassword({ changePasswordDto: dto });
toastManager.success($t('updated_password'));
toastManager.primary($t('updated_password'));
return true;
} catch (error) {
handleError(error, $t('errors.unable_to_change_password'));

View File

@@ -397,7 +397,7 @@ export const handleToggleWorkflowEnabled = async (
});
eventManager.emit('WorkflowUpdate', updated);
toastManager.success($t('workflow_updated'));
toastManager.primary($t('workflow_updated'));
return updated;
} catch (error) {
handleError(error, $t('errors.unable_to_update_workflow'));
@@ -419,7 +419,7 @@ export const handleDeleteWorkflow = async (workflow: WorkflowResponseDto): Promi
try {
await deleteWorkflow({ id: workflow.id });
eventManager.emit('WorkflowDelete', workflow);
toastManager.success($t('workflow_deleted'));
toastManager.primary($t('workflow_deleted'));
return true;
} catch (error) {
handleError(error, $t('errors.unable_to_delete_workflow'));

View File

@@ -1,6 +1,9 @@
import { MediaType } from '$lib/constants';
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
import type { QueueResponseDto, ServerVersionResponseDto } from '@immich/sdk';
import type { ActionItem } from '@immich/ui';
import type { DateTime } from 'luxon';
import type { SvelteSet } from 'svelte/reactivity';
export interface ReleaseEvent {
isAvailable: boolean;
@@ -48,3 +51,41 @@ export type AssetControlContext = {
getOwnedAssets: () => TimelineAsset[]; // Only assets owned by the user
clearSelect: () => void;
};
export type SearchCameraFilter = {
make?: string;
model?: string;
lensModel?: string;
};
export type SearchDateFilter = {
takenBefore?: DateTime;
takenAfter?: DateTime;
};
export type SearchDisplayFilters = {
isNotInAlbum: boolean;
isArchive: boolean;
isFavorite: boolean;
};
export type SearchLocationFilter = {
country?: string;
state?: string;
city?: string;
};
export type SearchFilter = {
query: string;
ocr?: string;
queryType: 'smart' | 'metadata' | 'description' | 'ocr';
personIds: SvelteSet<string>;
tagIds: SvelteSet<string> | null;
location: SearchLocationFilter;
queryAssetId?: string;
camera: SearchCameraFilter;
date: SearchDateFilter;
display: SearchDisplayFilters;
mediaType: MediaType;
rating?: number | null;
};

View File

@@ -49,7 +49,7 @@ export const tagAssets = async ({
if (showNotification) {
const $t = await getFormatter();
toastManager.success($t('tagged_assets', { values: { count: assetIds.length } }));
toastManager.primary($t('tagged_assets', { values: { count: assetIds.length } }));
}
return assetIds;
@@ -70,7 +70,7 @@ export const removeTag = async ({
if (showNotification) {
const $t = await getFormatter();
toastManager.success($t('removed_tagged_assets', { values: { count: assetIds.length } }));
toastManager.primary($t('removed_tagged_assets', { values: { count: assetIds.length } }));
}
return assetIds;
@@ -364,7 +364,7 @@ export const deleteStack = async (stackIds: string[]) => {
await deleteStacks({ bulkIdsDto: { ids: [...ids] } });
toastManager.success($t('unstacked_assets_count', { values: { count } }));
toastManager.primary($t('unstacked_assets_count', { values: { count } }));
const assets = stacks.flatMap((stack) => stack.assets);
for (const asset of assets) {
@@ -385,7 +385,7 @@ export const keepThisDeleteOthers = async (keepAsset: AssetResponseDto, stack: S
await deleteAssets({ assetBulkDeleteDto: { ids: assetsToDeleteIds } });
await deleteStacks({ bulkIdsDto: { ids: [stack.id] } });
toastManager.success($t('kept_this_deleted_others', { values: { count: assetsToDeleteIds.length } }));
toastManager.primary($t('kept_this_deleted_others', { values: { count: assetsToDeleteIds.length } }));
keepAsset.stack = null;
return keepAsset;
@@ -440,7 +440,7 @@ export const toggleArchive = async (asset: AssetResponseDto) => {
});
asset.isArchived = data.isArchived;
toastManager.success(asset.isArchived ? $t(`added_to_archive`) : $t(`removed_from_archive`));
toastManager.primary(asset.isArchived ? $t(`added_to_archive`) : $t(`removed_from_archive`));
} catch (error) {
handleError(error, $t('errors.unable_to_add_remove_archive', { values: { archived: asset.isArchived } }));
}
@@ -459,7 +459,7 @@ export const archiveAssets = async (assets: { id: string }[], visibility: AssetV
});
}
toastManager.success(
toastManager.primary(
visibility === AssetVisibility.Archive
? $t('archived_count', { values: { count: ids.length } })
: $t('unarchived_count', { values: { count: ids.length } }),

View File

@@ -200,7 +200,7 @@
},
});
eventManager.emit('AlbumUpdate', response);
toastManager.success($t('album_cover_updated'));
toastManager.primary($t('album_cover_updated'));
} catch (error) {
handleError(error, $t('errors.unable_to_update_album_cover'));
}

View File

@@ -157,7 +157,7 @@
break;
}
}
toastManager.success($t('change_name_successfully'));
toastManager.primary($t('change_name_successfully'));
} catch (error) {
handleError(error, $t('errors.unable_to_save_name'));
}
@@ -178,7 +178,7 @@
return person;
});
toastManager.success($t('changed_visibility_successfully'));
toastManager.primary($t('changed_visibility_successfully'));
} catch (error) {
handleError(error, $t('errors.unable_to_hide_person'));
}
@@ -198,7 +198,7 @@
return person;
});
toastManager.success(updatedPerson.isFavorite ? $t('added_to_favorites') : $t('removed_from_favorites'));
toastManager.primary(updatedPerson.isFavorite ? $t('added_to_favorites') : $t('removed_from_favorites'));
} catch (error) {
handleError(error, $t('errors.unable_to_add_remove_favorites', { values: { favorite: detail.isFavorite } }));
}

View File

@@ -149,7 +149,7 @@
}
try {
person = await updatePerson({ id: person.id, personUpdateDto: { featureFaceAssetId: asset.id } });
toastManager.success($t('feature_photo_updated'));
toastManager.primary($t('feature_photo_updated'));
} catch (error) {
handleError(error, $t('errors.unable_to_set_feature_photo'));
}
@@ -210,7 +210,7 @@
try {
person = await updatePerson({ id: person.id, personUpdateDto: { name: personName } });
toastManager.success($t('change_name_successfully'));
toastManager.primary($t('change_name_successfully'));
} catch (error) {
handleError(error, $t('errors.unable_to_save_name'));
}

View File

@@ -95,7 +95,7 @@
const message = featureFlagsManager.value.trash
? $t('assets_moved_to_trash_count', { values: { count: trashedCount } })
: $t('permanently_deleted_assets_count', { values: { count: trashedCount } });
toastManager.success(message);
toastManager.primary(message);
};
const handleResolve = async (duplicateId: string, duplicateAssetIds: string[], trashIds: string[]) => {
@@ -167,7 +167,7 @@
duplicates = [];
toastManager.success($t('resolved_all_duplicates'));
toastManager.primary($t('resolved_all_duplicates'));
page.url.searchParams.delete('index');
await goto(Route.duplicatesUtility());
},

View File

@@ -131,7 +131,7 @@
previousWorkflow = updated;
editWorkflow = updated;
toastManager.success($t('workflow_update_success'), {
toastManager.primary($t('workflow_update_success'), {
closable: true,
});
} catch (error) {