mirror of
https://github.com/immich-app/immich.git
synced 2026-01-28 07:44:56 -08:00
Compare commits
4 Commits
refactor/e
...
refactor/a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
03f0106b3d | ||
|
|
818f7b3e9b | ||
|
|
44b4f35019 | ||
|
|
212c03ceff |
@@ -572,6 +572,9 @@
|
||||
"asset_list_layout_sub_title": "Layout",
|
||||
"asset_list_settings_subtitle": "Photo grid layout settings",
|
||||
"asset_list_settings_title": "Photo Grid",
|
||||
"asset_not_found_on_device_android": "Asset not found on device",
|
||||
"asset_not_found_on_device_ios": "Asset not found on device. If you are using iCloud, the asset may be inaccessible due to bad file stored on iCloud",
|
||||
"asset_not_found_on_icloud": "Asset not found on iCloud. the asset may be inaccessible due to bad file stored on iCloud",
|
||||
"asset_offline": "Asset Offline",
|
||||
"asset_offline_description": "This external asset is no longer found on disk. Please contact your Immich administrator for help.",
|
||||
"asset_restored_successfully": "Asset restored successfully",
|
||||
@@ -2295,6 +2298,7 @@
|
||||
"upload_details": "Upload Details",
|
||||
"upload_dialog_info": "Do you want to backup the selected Asset(s) to the server?",
|
||||
"upload_dialog_title": "Upload Asset",
|
||||
"upload_error_with_count": "Upload error for {count, plural, one {# asset} other {# assets}}",
|
||||
"upload_errors": "Upload completed with {count, plural, one {# error} other {# errors}}, refresh the page to see new upload assets.",
|
||||
"upload_finished": "Upload finished",
|
||||
"upload_progress": "Remaining {remaining, number} - Processed {processed, number}/{total, number}",
|
||||
|
||||
@@ -62,6 +62,8 @@ class BackupToggleButtonState extends ConsumerState<BackupToggleButton> with Sin
|
||||
|
||||
final iCloudProgress = ref.watch(driftBackupProvider.select((state) => state.iCloudDownloadProgress));
|
||||
|
||||
final errorCount = ref.watch(driftBackupProvider.select((state) => state.errorCount));
|
||||
|
||||
final isProcessing = uploadTasks.isNotEmpty || isSyncing || iCloudProgress.isNotEmpty;
|
||||
|
||||
return AnimatedBuilder(
|
||||
@@ -149,6 +151,14 @@ class BackupToggleButtonState extends ConsumerState<BackupToggleButton> with Sin
|
||||
),
|
||||
],
|
||||
),
|
||||
if (errorCount > 0)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 2),
|
||||
child: Text(
|
||||
"upload_error_with_count".t(context: context, args: {'count': '$errorCount'}),
|
||||
style: context.textTheme.labelMedium?.copyWith(color: context.colorScheme.error),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -149,6 +149,8 @@ class DriftBackupState {
|
||||
);
|
||||
}
|
||||
|
||||
int get errorCount => uploadItems.values.where((item) => item.isFailed == true).length;
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'DriftBackupState(totalCount: $totalCount, backupCount: $backupCount, remainderCount: $remainderCount, processingCount: $processingCount, isSyncing: $isSyncing, error: $error, uploadItems: $uploadItems, cancelToken: $cancelToken, iCloudDownloadProgress: $iCloudDownloadProgress)';
|
||||
|
||||
@@ -260,6 +260,7 @@ class BackgroundUploadService {
|
||||
Future<UploadTask?> getUploadTask(LocalAsset asset, {String group = kBackupGroup, int? priority}) async {
|
||||
final entity = await _storageRepository.getAssetEntityForAsset(asset);
|
||||
if (entity == null) {
|
||||
_logger.warning("Asset entity not found for ${asset.id} - ${asset.name}");
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -282,6 +283,7 @@ class BackgroundUploadService {
|
||||
}
|
||||
|
||||
if (file == null) {
|
||||
_logger.warning("Failed to get file for asset ${asset.id} - ${asset.name}");
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import 'package:immich_mobile/domain/models/store.model.dart';
|
||||
import 'package:immich_mobile/entities/store.entity.dart';
|
||||
import 'package:immich_mobile/extensions/platform_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/network_capability_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/backup.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/storage.repository.dart';
|
||||
import 'package:immich_mobile/platform/connectivity_api.g.dart';
|
||||
@@ -266,6 +267,10 @@ class ForegroundUploadService {
|
||||
try {
|
||||
final entity = await _storageRepository.getAssetEntityForAsset(asset);
|
||||
if (entity == null) {
|
||||
callbacks.onError?.call(
|
||||
asset.localId!,
|
||||
CurrentPlatform.isAndroid ? "asset_not_found_on_device_android".t() : "asset_not_found_on_device_ios".t(),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -298,6 +303,11 @@ class ForegroundUploadService {
|
||||
// Get files locally
|
||||
file = await _storageRepository.getFileForAsset(asset.id);
|
||||
if (file == null) {
|
||||
_logger.warning("Failed to get file ${asset.id} - ${asset.name}");
|
||||
callbacks.onError?.call(
|
||||
asset.localId!,
|
||||
CurrentPlatform.isAndroid ? "asset_not_found_on_device_android".t() : "asset_not_found_on_device_ios".t(),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -306,12 +316,17 @@ class ForegroundUploadService {
|
||||
livePhotoFile = await _storageRepository.getMotionFileForAsset(asset);
|
||||
if (livePhotoFile == null) {
|
||||
_logger.warning("Failed to obtain motion part of the livePhoto - ${asset.name}");
|
||||
callbacks.onError?.call(
|
||||
asset.localId!,
|
||||
CurrentPlatform.isAndroid ? "asset_not_found_on_device_android".t() : "asset_not_found_on_device_ios".t(),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (file == null) {
|
||||
_logger.warning("Failed to obtain file for asset ${asset.id} - ${asset.name}");
|
||||
_logger.warning("Failed to obtain file from iCloud for asset ${asset.id} - ${asset.name}");
|
||||
callbacks.onError?.call(asset.localId!, "asset_not_found_on_icloud".t());
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -622,3 +622,44 @@ from
|
||||
where
|
||||
"asset"."id" = $1
|
||||
and "asset"."type" = $2
|
||||
|
||||
-- AssetRepository.getForOcr
|
||||
select
|
||||
(
|
||||
select
|
||||
coalesce(json_agg(agg), '[]')
|
||||
from
|
||||
(
|
||||
select
|
||||
"asset_edit"."action",
|
||||
"asset_edit"."parameters"
|
||||
from
|
||||
"asset_edit"
|
||||
where
|
||||
"asset_edit"."assetId" = "asset"."id"
|
||||
) as agg
|
||||
) as "edits",
|
||||
"asset_exif"."exifImageWidth",
|
||||
"asset_exif"."exifImageHeight",
|
||||
"asset_exif"."orientation"
|
||||
from
|
||||
"asset"
|
||||
inner join "asset_exif" on "asset_exif"."assetId" = "asset"."id"
|
||||
where
|
||||
"asset"."id" = $1
|
||||
|
||||
-- AssetRepository.getForEdit
|
||||
select
|
||||
"asset"."type",
|
||||
"asset"."livePhotoVideoId",
|
||||
"asset"."originalPath",
|
||||
"asset"."originalFileName",
|
||||
"asset_exif"."exifImageWidth",
|
||||
"asset_exif"."exifImageHeight",
|
||||
"asset_exif"."orientation",
|
||||
"asset_exif"."projectionType"
|
||||
from
|
||||
"asset"
|
||||
inner join "asset_exif" on "asset_exif"."assetId" = "asset"."id"
|
||||
where
|
||||
"asset"."id" = $1
|
||||
|
||||
@@ -1053,4 +1053,31 @@ export class AssetRepository {
|
||||
.where('asset.type', '=', AssetType.Video)
|
||||
.executeTakeFirst();
|
||||
}
|
||||
|
||||
@GenerateSql({ params: [DummyValue.UUID] })
|
||||
async getForOcr(id: string) {
|
||||
return this.db
|
||||
.selectFrom('asset')
|
||||
.where('asset.id', '=', id)
|
||||
.select(withEdits)
|
||||
.innerJoin('asset_exif', (join) => join.onRef('asset_exif.assetId', '=', 'asset.id'))
|
||||
.select(['asset_exif.exifImageWidth', 'asset_exif.exifImageHeight', 'asset_exif.orientation'])
|
||||
.executeTakeFirst();
|
||||
}
|
||||
|
||||
@GenerateSql({ params: [DummyValue.UUID] })
|
||||
async getForEdit(id: string) {
|
||||
return this.db
|
||||
.selectFrom('asset')
|
||||
.select(['asset.type', 'asset.livePhotoVideoId', 'asset.originalPath', 'asset.originalFileName'])
|
||||
.where('asset.id', '=', id)
|
||||
.innerJoin('asset_exif', (join) => join.onRef('asset_exif.assetId', '=', 'asset.id'))
|
||||
.select([
|
||||
'asset_exif.exifImageWidth',
|
||||
'asset_exif.exifImageHeight',
|
||||
'asset_exif.orientation',
|
||||
'asset_exif.projectionType',
|
||||
])
|
||||
.executeTakeFirst();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -705,7 +705,7 @@ describe(AssetService.name, () => {
|
||||
|
||||
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1']));
|
||||
mocks.ocr.getByAssetId.mockResolvedValue([ocr1, ocr2]);
|
||||
mocks.asset.getById.mockResolvedValue(assetStub.image);
|
||||
mocks.asset.getForOcr.mockResolvedValue({ edits: [], ...factory.exif() });
|
||||
|
||||
await expect(sut.getOcr(authStub.admin, 'asset-1')).resolves.toEqual([ocr1, ocr2]);
|
||||
|
||||
@@ -720,7 +720,7 @@ describe(AssetService.name, () => {
|
||||
it('should return empty array when no OCR data exists', async () => {
|
||||
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1']));
|
||||
mocks.ocr.getByAssetId.mockResolvedValue([]);
|
||||
mocks.asset.getById.mockResolvedValue(assetStub.image);
|
||||
mocks.asset.getForOcr.mockResolvedValue({ edits: [factory.assetEdit()], ...factory.exif() });
|
||||
await expect(sut.getOcr(authStub.admin, 'asset-1')).resolves.toEqual([]);
|
||||
|
||||
expect(mocks.ocr.getByAssetId).toHaveBeenCalledWith('asset-1');
|
||||
|
||||
@@ -401,15 +401,19 @@ export class AssetService extends BaseService {
|
||||
async getOcr(auth: AuthDto, id: string): Promise<AssetOcrResponseDto[]> {
|
||||
await this.requireAccess({ auth, permission: Permission.AssetRead, ids: [id] });
|
||||
const ocr = await this.ocrRepository.getByAssetId(id);
|
||||
const asset = await this.assetRepository.getById(id, { exifInfo: true, edits: true });
|
||||
const asset = await this.assetRepository.getForOcr(id);
|
||||
|
||||
if (!asset || !asset.exifInfo || !asset.edits) {
|
||||
if (!asset) {
|
||||
throw new BadRequestException('Asset not found');
|
||||
}
|
||||
|
||||
const dimensions = getDimensions(asset.exifInfo);
|
||||
const dimensions = getDimensions({
|
||||
exifImageHeight: asset.exifImageHeight,
|
||||
exifImageWidth: asset.exifImageWidth,
|
||||
orientation: asset.orientation,
|
||||
});
|
||||
|
||||
return ocr.map((item) => transformOcrBoundingBox(item, asset.edits!, dimensions));
|
||||
return ocr.map((item) => transformOcrBoundingBox(item, asset.edits, dimensions));
|
||||
}
|
||||
|
||||
async upsertBulkMetadata(auth: AuthDto, dto: AssetMetadataBulkUpsertDto): Promise<AssetMetadataBulkResponseDto[]> {
|
||||
@@ -549,7 +553,7 @@ export class AssetService extends BaseService {
|
||||
async editAsset(auth: AuthDto, id: string, dto: AssetEditActionListDto): Promise<AssetEditsDto> {
|
||||
await this.requireAccess({ auth, permission: Permission.AssetEditCreate, ids: [id] });
|
||||
|
||||
const asset = await this.assetRepository.getById(id, { exifInfo: true });
|
||||
const asset = await this.assetRepository.getForEdit(id);
|
||||
if (!asset) {
|
||||
throw new BadRequestException('Asset not found');
|
||||
}
|
||||
@@ -574,15 +578,21 @@ export class AssetService extends BaseService {
|
||||
throw new BadRequestException('Editing SVG images is not supported');
|
||||
}
|
||||
|
||||
// check that crop parameters will not go out of bounds
|
||||
const { width: assetWidth, height: assetHeight } = getDimensions(asset);
|
||||
|
||||
if (!assetWidth || !assetHeight) {
|
||||
throw new BadRequestException('Asset dimensions are not available for editing');
|
||||
}
|
||||
|
||||
const cropIndex = dto.edits.findIndex((e) => e.action === AssetEditAction.Crop);
|
||||
if (cropIndex > 0) {
|
||||
throw new BadRequestException('Crop action must be the first edit action');
|
||||
}
|
||||
|
||||
const crop = cropIndex === -1 ? null : (dto.edits[cropIndex] as AssetEditActionCrop);
|
||||
if (crop) {
|
||||
// check that crop parameters will not go out of bounds
|
||||
const { width: assetWidth, height: assetHeight } = getDimensions(asset.exifInfo!);
|
||||
const { width: assetWidth, height: assetHeight } = getDimensions(asset);
|
||||
|
||||
if (!assetWidth || !assetHeight) {
|
||||
throw new BadRequestException('Asset dimensions are not available for editing');
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
import { StorageCore } from 'src/cores/storage.core';
|
||||
import { AssetFile, Exif } from 'src/database';
|
||||
import { AssetFile } from 'src/database';
|
||||
import { BulkIdErrorReason, BulkIdResponseDto } from 'src/dtos/asset-ids.response.dto';
|
||||
import { UploadFieldName } from 'src/dtos/asset-media.dto';
|
||||
import { AuthDto } from 'src/dtos/auth.dto';
|
||||
import { ExifResponseDto } from 'src/dtos/exif.dto';
|
||||
import { AssetFileType, AssetType, AssetVisibility, Permission } from 'src/enum';
|
||||
import { AuthRequest } from 'src/middleware/auth.guard';
|
||||
import { AccessRepository } from 'src/repositories/access.repository';
|
||||
@@ -210,20 +209,26 @@ const isFlipped = (orientation?: string | null) => {
|
||||
return value && [5, 6, 7, 8, -90, 90].includes(value);
|
||||
};
|
||||
|
||||
export const getDimensions = (exifInfo: ExifResponseDto | Exif) => {
|
||||
const { exifImageWidth: width, exifImageHeight: height } = exifInfo;
|
||||
|
||||
export const getDimensions = ({
|
||||
exifImageHeight: height,
|
||||
exifImageWidth: width,
|
||||
orientation,
|
||||
}: {
|
||||
exifImageHeight: number | null;
|
||||
exifImageWidth: number | null;
|
||||
orientation: string | null;
|
||||
}) => {
|
||||
if (!width || !height) {
|
||||
return { width: 0, height: 0 };
|
||||
}
|
||||
|
||||
if (isFlipped(exifInfo.orientation)) {
|
||||
if (isFlipped(orientation)) {
|
||||
return { width: height, height: width };
|
||||
}
|
||||
|
||||
return { width, height };
|
||||
};
|
||||
|
||||
export const isPanorama = (asset: { exifInfo?: Exif | null; originalFileName: string }) => {
|
||||
return asset.exifInfo?.projectionType === 'EQUIRECTANGULAR' || asset.originalFileName.toLowerCase().endsWith('.insp');
|
||||
export const isPanorama = (asset: { projectionType: string | null; originalFileName: string }) => {
|
||||
return asset.projectionType === 'EQUIRECTANGULAR' || asset.originalFileName.toLowerCase().endsWith('.insp');
|
||||
};
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
import { Kysely } from 'kysely';
|
||||
import { AssetEditAction } from 'src/dtos/editing.dto';
|
||||
import { AssetFileType, AssetMetadataKey, JobName, SharedLinkType } from 'src/enum';
|
||||
import { AccessRepository } from 'src/repositories/access.repository';
|
||||
import { AlbumRepository } from 'src/repositories/album.repository';
|
||||
import { AssetEditRepository } from 'src/repositories/asset-edit.repository';
|
||||
import { AssetJobRepository } from 'src/repositories/asset-job.repository';
|
||||
import { AssetRepository } from 'src/repositories/asset.repository';
|
||||
import { EventRepository } from 'src/repositories/event.repository';
|
||||
import { JobRepository } from 'src/repositories/job.repository';
|
||||
import { LoggingRepository } from 'src/repositories/logging.repository';
|
||||
import { OcrRepository } from 'src/repositories/ocr.repository';
|
||||
import { SharedLinkAssetRepository } from 'src/repositories/shared-link-asset.repository';
|
||||
import { SharedLinkRepository } from 'src/repositories/shared-link.repository';
|
||||
import { StackRepository } from 'src/repositories/stack.repository';
|
||||
@@ -25,6 +28,7 @@ const setup = (db?: Kysely<DB>) => {
|
||||
database: db || defaultDatabase,
|
||||
real: [
|
||||
AssetRepository,
|
||||
AssetEditRepository,
|
||||
AssetJobRepository,
|
||||
AlbumRepository,
|
||||
AccessRepository,
|
||||
@@ -32,7 +36,7 @@ const setup = (db?: Kysely<DB>) => {
|
||||
StackRepository,
|
||||
UserRepository,
|
||||
],
|
||||
mock: [EventRepository, LoggingRepository, JobRepository, StorageRepository],
|
||||
mock: [EventRepository, LoggingRepository, JobRepository, StorageRepository, OcrRepository],
|
||||
});
|
||||
};
|
||||
|
||||
@@ -431,6 +435,57 @@ describe(AssetService.name, () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('getOcr', () => {
|
||||
it('should require access', async () => {
|
||||
const { sut, ctx } = setup();
|
||||
const { user } = await ctx.newUser();
|
||||
const { user: user2 } = await ctx.newUser();
|
||||
const auth = factory.auth({ user });
|
||||
const { asset } = await ctx.newAsset({ ownerId: user2.id });
|
||||
|
||||
await expect(sut.getOcr(auth, asset.id)).rejects.toThrow('Not found or no asset.read access');
|
||||
});
|
||||
|
||||
it('should work', async () => {
|
||||
const { sut, ctx } = setup();
|
||||
const { user } = await ctx.newUser();
|
||||
const auth = factory.auth({ user });
|
||||
const { asset } = await ctx.newAsset({ ownerId: user.id });
|
||||
await ctx.newExif({ assetId: asset.id, exifImageHeight: 42, exifImageWidth: 69, orientation: '1' });
|
||||
ctx.getMock(OcrRepository).getByAssetId.mockResolvedValue([factory.assetOcr()]);
|
||||
|
||||
await expect(sut.getOcr(auth, asset.id)).resolves.toEqual([
|
||||
expect.objectContaining({ x1: 0.1, x2: 0.3, x3: 0.3, x4: 0.1, y1: 0.2, y2: 0.2, y3: 0.4, y4: 0.4 }),
|
||||
]);
|
||||
});
|
||||
|
||||
it('should apply rotation', async () => {
|
||||
const { sut, ctx } = setup();
|
||||
const { user } = await ctx.newUser();
|
||||
const auth = factory.auth({ user });
|
||||
const { asset } = await ctx.newAsset({ ownerId: user.id });
|
||||
await ctx.newExif({ assetId: asset.id, exifImageHeight: 42, exifImageWidth: 69, orientation: '1' });
|
||||
await ctx.database
|
||||
.insertInto('asset_edit')
|
||||
.values({ assetId: asset.id, action: AssetEditAction.Rotate, parameters: { angle: 90 }, sequence: 1 })
|
||||
.execute();
|
||||
ctx.getMock(OcrRepository).getByAssetId.mockResolvedValue([factory.assetOcr()]);
|
||||
|
||||
await expect(sut.getOcr(auth, asset.id)).resolves.toEqual([
|
||||
expect.objectContaining({
|
||||
x1: 0.6,
|
||||
x2: 0.8,
|
||||
x3: 0.8,
|
||||
x4: 0.6,
|
||||
y1: expect.any(Number),
|
||||
y2: expect.any(Number),
|
||||
y3: 0.3,
|
||||
y4: 0.3,
|
||||
}),
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('upsertBulkMetadata', () => {
|
||||
it('should work', async () => {
|
||||
const { sut, ctx } = setup();
|
||||
@@ -603,4 +658,38 @@ describe(AssetService.name, () => {
|
||||
expect(metadata).toEqual([expect.objectContaining({ key: 'some-other-key', value: { foo: 'bar' } })]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('editAsset', () => {
|
||||
it('should require access', async () => {
|
||||
const { sut, ctx } = setup();
|
||||
const { user } = await ctx.newUser();
|
||||
const { user: user2 } = await ctx.newUser();
|
||||
const auth = factory.auth({ user });
|
||||
const { asset } = await ctx.newAsset({ ownerId: user2.id });
|
||||
|
||||
await expect(
|
||||
sut.editAsset(auth, asset.id, { edits: [{ action: AssetEditAction.Rotate, parameters: { angle: 90 } }] }),
|
||||
).rejects.toThrow('Not found or no asset.edit.create access');
|
||||
});
|
||||
|
||||
it('should work', async () => {
|
||||
const { sut, ctx } = setup();
|
||||
ctx.getMock(JobRepository).queue.mockResolvedValue();
|
||||
const { user } = await ctx.newUser();
|
||||
const auth = factory.auth({ user });
|
||||
const { asset } = await ctx.newAsset({ ownerId: user.id });
|
||||
await ctx.newExif({ assetId: asset.id, exifImageHeight: 42, exifImageWidth: 69, orientation: '1' });
|
||||
|
||||
const editAction = { action: AssetEditAction.Rotate, parameters: { angle: 90 } } as const;
|
||||
await expect(sut.editAsset(auth, asset.id, { edits: [editAction] })).resolves.toEqual({
|
||||
assetId: asset.id,
|
||||
edits: [editAction],
|
||||
});
|
||||
|
||||
await expect(ctx.get(AssetRepository).getById(asset.id)).resolves.toEqual(
|
||||
expect.objectContaining({ isEdited: true }),
|
||||
);
|
||||
await expect(ctx.get(AssetEditRepository).getAll(asset.id)).resolves.toEqual([editAction]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -53,5 +53,7 @@ export const newAssetRepositoryMock = (): Mocked<RepositoryInterface<AssetReposi
|
||||
getForOriginal: vitest.fn(),
|
||||
getForThumbnail: vitest.fn(),
|
||||
getForVideo: vitest.fn(),
|
||||
getForEdit: vitest.fn(),
|
||||
getForOcr: vitest.fn(),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -5,7 +5,7 @@ export const zoomImageAction = (node: HTMLElement, options?: { disabled?: boolea
|
||||
const zoomInstance = createZoomImageWheel(node, { maxZoom: 10, initialState: assetViewerManager.zoomState });
|
||||
|
||||
const unsubscribes = [
|
||||
assetViewerManager.on({ ZoomChange: (state) => zoomInstance.setState(state) }),
|
||||
assetViewerManager.on('ZoomChange', (state) => zoomInstance.setState(state)),
|
||||
zoomInstance.subscribe(({ state }) => assetViewerManager.onZoomChange(state)),
|
||||
];
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { assetViewerManager, type Events } from '$lib/managers/asset-viewer-manager.svelte';
|
||||
import type { EventCallback, EventMap } from '$lib/utils/base-event-manager.svelte';
|
||||
import type { EventCallback } from '$lib/utils/base-event-manager.svelte';
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
type Props = {
|
||||
@@ -10,17 +10,22 @@
|
||||
const props: Props = $props();
|
||||
|
||||
onMount(() => {
|
||||
const events: EventMap<Events> = {};
|
||||
const unsubscribes: Array<() => void> = [];
|
||||
|
||||
for (const [name, listener] of Object.entries(props)) {
|
||||
if (listener) {
|
||||
const event = name.slice(2) as keyof Events;
|
||||
events[event] = listener as EventCallback<Events, typeof event>;
|
||||
for (const name of Object.keys(props)) {
|
||||
const event = name.slice(2) as keyof Events;
|
||||
const listener = props[name as keyof Props] as EventCallback<Events, typeof event> | undefined;
|
||||
if (!listener) {
|
||||
continue;
|
||||
}
|
||||
|
||||
unsubscribes.push(assetViewerManager.on(event, listener));
|
||||
}
|
||||
|
||||
return assetViewerManager.on(events);
|
||||
return () => {
|
||||
for (const unsubscribe of unsubscribes) {
|
||||
unsubscribe();
|
||||
}
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
const event = name.slice(2) as keyof Events;
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { eventManager, type Events } from '$lib/managers/event-manager.svelte';
|
||||
import type { EventCallback, EventMap } from '$lib/utils/base-event-manager.svelte';
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
type Props = {
|
||||
@@ -10,15 +9,25 @@
|
||||
const props: Props = $props();
|
||||
|
||||
onMount(() => {
|
||||
const events: EventMap<Events> = {};
|
||||
const unsubscribes: Array<() => void> = [];
|
||||
|
||||
for (const [name, listener] of Object.entries(props)) {
|
||||
if (listener) {
|
||||
const event = name.slice(2) as keyof Events;
|
||||
events[event] = listener as EventCallback<Events, typeof event>;
|
||||
for (const name of Object.keys(props)) {
|
||||
const event = name.slice(2) as keyof Events;
|
||||
const listener = props[name as keyof Props];
|
||||
|
||||
if (!listener) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const args = [event, listener as (...args: Events[typeof event]) => void] as const;
|
||||
|
||||
unsubscribes.push(eventManager.on(...args));
|
||||
}
|
||||
|
||||
return eventManager.on(events);
|
||||
return () => {
|
||||
for (const unsubscribe of unsubscribes) {
|
||||
unsubscribe();
|
||||
}
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -60,7 +60,7 @@
|
||||
const axisOptions: Axis = {
|
||||
stroke: () => (isDark ? '#ccc' : 'black'),
|
||||
ticks: {
|
||||
show: true,
|
||||
show: false,
|
||||
stroke: () => (isDark ? '#444' : '#ddd'),
|
||||
},
|
||||
grid: {
|
||||
@@ -116,6 +116,8 @@
|
||||
axes: [
|
||||
{
|
||||
...axisOptions,
|
||||
size: 40,
|
||||
ticks: { show: true },
|
||||
values: (plot, values) => {
|
||||
return values.map((value) => {
|
||||
if (!value) {
|
||||
@@ -125,7 +127,10 @@
|
||||
});
|
||||
},
|
||||
},
|
||||
axisOptions,
|
||||
{
|
||||
...axisOptions,
|
||||
size: 60,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
|
||||
@@ -37,11 +37,9 @@ class AssetCacheManager {
|
||||
#ocrCache = new AsyncCache<AssetOcrResponseDto[]>();
|
||||
|
||||
constructor() {
|
||||
eventManager.on({
|
||||
AssetEditsApplied: () => {
|
||||
this.#assetCache.clear();
|
||||
this.#ocrCache.clear();
|
||||
},
|
||||
eventManager.on('AssetEditsApplied', () => {
|
||||
this.#assetCache.clear();
|
||||
this.#ocrCache.clear();
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -59,9 +59,7 @@ class CastManager {
|
||||
// Add other cast destinations here (ie FCast)
|
||||
];
|
||||
|
||||
eventManager.on({
|
||||
AppInit: () => void this.initialize(),
|
||||
});
|
||||
eventManager.on('AppInit', () => void this.initialize());
|
||||
}
|
||||
|
||||
private async initialize() {
|
||||
|
||||
@@ -5,9 +5,7 @@ class FeatureFlagsManager {
|
||||
#value?: ServerFeaturesDto = $state();
|
||||
|
||||
constructor() {
|
||||
eventManager.on({
|
||||
SystemConfigUpdate: () => void this.#loadFeatureFlags(),
|
||||
});
|
||||
eventManager.on('SystemConfigUpdate', () => void this.#loadFeatureFlags());
|
||||
}
|
||||
|
||||
async init() {
|
||||
|
||||
@@ -4,9 +4,7 @@ import { lang } from '$lib/stores/preferences.store';
|
||||
|
||||
class LanguageManager {
|
||||
constructor() {
|
||||
eventManager.on({
|
||||
AppInit: () => lang.subscribe((lang) => this.setLanguage(lang)),
|
||||
});
|
||||
eventManager.on('AppInit', () => lang.subscribe((lang) => this.setLanguage(lang)));
|
||||
}
|
||||
|
||||
rtl = $state(false);
|
||||
|
||||
@@ -19,9 +19,7 @@ export class QueueManager {
|
||||
}
|
||||
|
||||
constructor() {
|
||||
eventManager.on({
|
||||
QueueUpdate: () => this.refresh(),
|
||||
});
|
||||
eventManager.on('QueueUpdate', () => this.refresh());
|
||||
}
|
||||
|
||||
listen() {
|
||||
|
||||
@@ -5,9 +5,7 @@ class ReleaseManager {
|
||||
value = $state<ReleaseEvent | undefined>();
|
||||
|
||||
constructor() {
|
||||
eventManager.on({
|
||||
ReleaseEvent: (event) => (this.value = event),
|
||||
});
|
||||
eventManager.on('ReleaseEvent', (event) => (this.value = event));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,9 +5,7 @@ class ServerConfigManager {
|
||||
#value?: ServerConfigDto = $state();
|
||||
|
||||
constructor() {
|
||||
eventManager.on({
|
||||
SystemConfigUpdate: () => this.loadServerConfig(),
|
||||
});
|
||||
eventManager.on('SystemConfigUpdate', () => this.loadServerConfig());
|
||||
}
|
||||
|
||||
async init() {
|
||||
|
||||
@@ -7,9 +7,7 @@ class SystemConfigManager {
|
||||
#defaultValue?: SystemConfigDto = $state();
|
||||
|
||||
constructor() {
|
||||
eventManager.on({
|
||||
SystemConfigUpdate: (config) => (this.#value = config),
|
||||
});
|
||||
eventManager.on('SystemConfigUpdate', (config) => (this.#value = config));
|
||||
}
|
||||
|
||||
async init() {
|
||||
|
||||
@@ -37,9 +37,7 @@ class ThemeManager {
|
||||
isDark = $derived(this.value === Theme.DARK);
|
||||
|
||||
constructor() {
|
||||
eventManager.on({
|
||||
AppInit: () => this.#onAppInit(),
|
||||
});
|
||||
eventManager.on('AppInit', () => this.#onAppInit());
|
||||
}
|
||||
|
||||
setSystem(system: boolean) {
|
||||
|
||||
@@ -111,11 +111,9 @@ export class TimelineManager extends VirtualScrollManager {
|
||||
constructor() {
|
||||
super();
|
||||
|
||||
this.#unsubscribes.push(
|
||||
eventManager.on({
|
||||
AssetUpdate: (asset: AssetResponseDto) => this.upsertAssets([toTimelineAsset(asset)]),
|
||||
}),
|
||||
);
|
||||
const onAssetUpdate = (asset: AssetResponseDto) => this.upsertAssets([toTimelineAsset(asset)]);
|
||||
|
||||
this.#unsubscribes.push(eventManager.on('AssetUpdate', onAssetUpdate));
|
||||
}
|
||||
|
||||
override get scrollTop(): number {
|
||||
|
||||
@@ -6,7 +6,7 @@ class UploadManager {
|
||||
mediaTypes = $state<ServerMediaTypesResponseDto>({ image: [], sidecar: [], video: [] });
|
||||
|
||||
constructor() {
|
||||
eventManager.on({
|
||||
eventManager.onMany({
|
||||
AppInit: () => this.#loadExtensions(),
|
||||
AuthLogout: () => this.reset(),
|
||||
});
|
||||
|
||||
21
web/src/lib/services/shared-link.service.spec.ts
Normal file
21
web/src/lib/services/shared-link.service.spec.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { asUrl } from '$lib/services/shared-link.service';
|
||||
import type { ServerConfigDto } from '@immich/sdk';
|
||||
import { sharedLinkFactory } from '@test-data/factories/shared-link-factory';
|
||||
|
||||
describe('SharedLinkService', () => {
|
||||
beforeAll(() => {
|
||||
vi.mock(import('$lib/managers/server-config-manager.svelte'), () => ({
|
||||
serverConfigManager: {
|
||||
value: { externalDomain: 'http://localhost:2283' } as ServerConfigDto,
|
||||
init: vi.fn(),
|
||||
loadServerConfig: vi.fn(),
|
||||
},
|
||||
}));
|
||||
});
|
||||
|
||||
describe('asUrl', () => {
|
||||
it('should properly encode characters in slug', () => {
|
||||
expect(asUrl(sharedLinkFactory.build({ slug: 'foo/bar' }))).toBe('http://localhost:2283/s/foo%2Fbar');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -60,8 +60,10 @@ export const getSharedLinkActions = ($t: MessageFormatter, sharedLink: SharedLin
|
||||
return { Edit, Delete, Copy, ViewQrCode };
|
||||
};
|
||||
|
||||
const asUrl = (sharedLink: SharedLinkResponseDto) => {
|
||||
const path = sharedLink.slug ? `s/${sharedLink.slug}` : `share/${sharedLink.key}`;
|
||||
export const asUrl = (sharedLink: SharedLinkResponseDto) => {
|
||||
const path = sharedLink.slug
|
||||
? `s/${encodeURIComponent(sharedLink.slug)}`
|
||||
: `share/${encodeURIComponent(sharedLink.key)}`;
|
||||
return new URL(path, serverConfigManager.value.externalDomain || globalThis.location.origin).href;
|
||||
};
|
||||
|
||||
|
||||
@@ -19,9 +19,7 @@ class FoldersStore {
|
||||
private assets = $state<AssetCache>({});
|
||||
|
||||
constructor() {
|
||||
eventManager.on({
|
||||
AuthLogout: () => this.clearCache(),
|
||||
});
|
||||
eventManager.on('AuthLogout', () => this.clearCache());
|
||||
}
|
||||
|
||||
async fetchTree(): Promise<TreeNode> {
|
||||
|
||||
@@ -23,7 +23,7 @@ class MemoryStoreSvelte {
|
||||
#loading: Promise<void> | undefined;
|
||||
|
||||
constructor() {
|
||||
eventManager.on({
|
||||
eventManager.onMany({
|
||||
AuthLogout: () => this.clearCache(),
|
||||
AuthUserLoaded: () => this.initialize(),
|
||||
});
|
||||
|
||||
@@ -8,7 +8,7 @@ class NotificationStore {
|
||||
notifications = $state<NotificationDto[]>([]);
|
||||
|
||||
constructor() {
|
||||
eventManager.on({
|
||||
eventManager.onMany({
|
||||
AuthLogin: () => this.refresh(),
|
||||
AuthLogout: () => this.clear(),
|
||||
});
|
||||
|
||||
@@ -5,9 +5,7 @@ class SearchStore {
|
||||
isSearchEnabled = $state(false);
|
||||
|
||||
constructor() {
|
||||
eventManager.on({
|
||||
AuthLogout: () => this.clearCache(),
|
||||
});
|
||||
eventManager.on('AuthLogout', () => this.clearCache());
|
||||
}
|
||||
|
||||
clearCache() {
|
||||
|
||||
@@ -16,6 +16,4 @@ export const resetSavedUser = () => {
|
||||
purchaseStore.setPurchaseStatus(false);
|
||||
};
|
||||
|
||||
eventManager.on({
|
||||
AuthLogout: () => resetSavedUser(),
|
||||
});
|
||||
eventManager.on('AuthLogout', () => resetSavedUser());
|
||||
|
||||
@@ -26,6 +26,4 @@ const reset = () => {
|
||||
Object.assign(userInteraction, defaultUserInteraction);
|
||||
};
|
||||
|
||||
eventManager.on({
|
||||
AuthLogout: () => reset(),
|
||||
});
|
||||
eventManager.on('AuthLogout', () => reset());
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
type EventsBase = Record<string, unknown[]>;
|
||||
type EventMap = Record<string, unknown[]>;
|
||||
type PromiseLike<T> = Promise<T> | T;
|
||||
|
||||
export type EventMap<E extends EventsBase> = { [K in keyof E]?: EventCallback<E, K> };
|
||||
export type EventCallback<E extends EventsBase, T extends keyof E> = (...args: E[T]) => PromiseLike<unknown>;
|
||||
export type EventItem<E extends EventsBase, T extends keyof E = keyof E> = {
|
||||
export type EventCallback<E extends EventMap, T extends keyof E> = (...args: E[T]) => PromiseLike<unknown>;
|
||||
export type EventItem<E extends EventMap, T extends keyof E = keyof E> = {
|
||||
id: number;
|
||||
event: T;
|
||||
callback: EventCallback<E, T>;
|
||||
@@ -14,22 +13,10 @@ const nextId = () => count++;
|
||||
|
||||
const noop = () => {};
|
||||
|
||||
export class BaseEventManager<Events extends EventsBase> {
|
||||
export class BaseEventManager<Events extends EventMap> {
|
||||
#callbacks: EventItem<Events>[] = $state([]);
|
||||
|
||||
on(subscriptions: EventMap<Events>): () => void {
|
||||
const cleanups = Object.entries(subscriptions).map(([event, callback]) =>
|
||||
this.#onEvent(event as keyof Events, callback as EventCallback<Events, keyof Events>),
|
||||
);
|
||||
|
||||
return () => {
|
||||
for (const cleanup of cleanups) {
|
||||
cleanup();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
#onEvent<T extends keyof Events>(event: T, callback?: EventCallback<Events, T>) {
|
||||
on<T extends keyof Events>(event: T, callback?: EventCallback<Events, T>) {
|
||||
if (!callback) {
|
||||
return noop;
|
||||
}
|
||||
@@ -43,6 +30,17 @@ export class BaseEventManager<Events extends EventsBase> {
|
||||
};
|
||||
}
|
||||
|
||||
onMany(subscriptions: { [T in keyof Events]?: EventCallback<Events, T> }) {
|
||||
const cleanups = Object.entries(subscriptions).map(([event, callback]) =>
|
||||
this.on(event as keyof Events, callback as EventCallback<Events, keyof Events>),
|
||||
);
|
||||
return () => {
|
||||
for (const cleanup of cleanups) {
|
||||
cleanup();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
emit<T extends keyof Events>(event: T, ...params: Events[T]) {
|
||||
const listeners = this.getListeners(event);
|
||||
for (const listener of listeners) {
|
||||
|
||||
Reference in New Issue
Block a user