Compare commits

..

4 Commits

Author SHA1 Message Date
Daniel Dietzler
03f0106b3d refactor: asset service queries 2026-01-27 17:50:22 +01:00
Mees Frensel
818f7b3e9b fix(web): queue graph formatting for y-axis labels (#25567)
fix(web): queue graph formatting for y axis labels
2026-01-27 10:41:31 -06:00
Alex
44b4f35019 chore: expose upload errors to UI (#25566) 2026-01-27 16:33:44 +00:00
Daniel Dietzler
212c03ceff fix(web): properly encode shared link slug (#25564) 2026-01-27 16:29:51 +01:00
36 changed files with 326 additions and 107 deletions

View File

@@ -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}",

View File

@@ -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),
),
),
],
),
),

View File

@@ -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)';

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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

View File

@@ -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();
}
}

View File

@@ -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');

View File

@@ -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');

View File

@@ -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');
};

View File

@@ -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]);
});
});
});

View File

@@ -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(),
};
};

View File

@@ -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)),
];

View File

@@ -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;

View File

@@ -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>

View File

@@ -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,
},
],
};

View File

@@ -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();
});
}

View File

@@ -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() {

View File

@@ -5,9 +5,7 @@ class FeatureFlagsManager {
#value?: ServerFeaturesDto = $state();
constructor() {
eventManager.on({
SystemConfigUpdate: () => void this.#loadFeatureFlags(),
});
eventManager.on('SystemConfigUpdate', () => void this.#loadFeatureFlags());
}
async init() {

View File

@@ -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);

View File

@@ -19,9 +19,7 @@ export class QueueManager {
}
constructor() {
eventManager.on({
QueueUpdate: () => this.refresh(),
});
eventManager.on('QueueUpdate', () => this.refresh());
}
listen() {

View File

@@ -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));
}
}

View File

@@ -5,9 +5,7 @@ class ServerConfigManager {
#value?: ServerConfigDto = $state();
constructor() {
eventManager.on({
SystemConfigUpdate: () => this.loadServerConfig(),
});
eventManager.on('SystemConfigUpdate', () => this.loadServerConfig());
}
async init() {

View File

@@ -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() {

View File

@@ -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) {

View File

@@ -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 {

View File

@@ -6,7 +6,7 @@ class UploadManager {
mediaTypes = $state<ServerMediaTypesResponseDto>({ image: [], sidecar: [], video: [] });
constructor() {
eventManager.on({
eventManager.onMany({
AppInit: () => this.#loadExtensions(),
AuthLogout: () => this.reset(),
});

View 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');
});
});
});

View File

@@ -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;
};

View File

@@ -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> {

View File

@@ -23,7 +23,7 @@ class MemoryStoreSvelte {
#loading: Promise<void> | undefined;
constructor() {
eventManager.on({
eventManager.onMany({
AuthLogout: () => this.clearCache(),
AuthUserLoaded: () => this.initialize(),
});

View File

@@ -8,7 +8,7 @@ class NotificationStore {
notifications = $state<NotificationDto[]>([]);
constructor() {
eventManager.on({
eventManager.onMany({
AuthLogin: () => this.refresh(),
AuthLogout: () => this.clear(),
});

View File

@@ -5,9 +5,7 @@ class SearchStore {
isSearchEnabled = $state(false);
constructor() {
eventManager.on({
AuthLogout: () => this.clearCache(),
});
eventManager.on('AuthLogout', () => this.clearCache());
}
clearCache() {

View File

@@ -16,6 +16,4 @@ export const resetSavedUser = () => {
purchaseStore.setPurchaseStatus(false);
};
eventManager.on({
AuthLogout: () => resetSavedUser(),
});
eventManager.on('AuthLogout', () => resetSavedUser());

View File

@@ -26,6 +26,4 @@ const reset = () => {
Object.assign(userInteraction, defaultUserInteraction);
};
eventManager.on({
AuthLogout: () => reset(),
});
eventManager.on('AuthLogout', () => reset());

View File

@@ -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) {