mirror of
https://github.com/immich-app/immich.git
synced 2026-06-16 20:02:15 -07:00
Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0e64da7963 | |||
| b633cc4f04 | |||
| a9ee6a7ce9 | |||
| c273ccf2e2 | |||
| 5f1a180d1a | |||
| cc54de87aa | |||
| a97e5999e4 | |||
| 46631b3786 | |||
| 5a3be158b9 | |||
| b21af78454 | |||
| abd62d9295 | |||
| e31d4aa909 | |||
| 43b2d04e2c |
@@ -50,6 +50,7 @@ jobs:
|
||||
outputs:
|
||||
ref: ${{ steps.push-tag.outputs.commit_long_sha }}
|
||||
version: ${{ steps.output.outputs.version }}
|
||||
rc: ${{ steps.output.outputs.rc }}
|
||||
permissions: {} # No job-level permissions are needed because it uses the app-token
|
||||
steps:
|
||||
- id: token
|
||||
@@ -81,7 +82,13 @@ jobs:
|
||||
run: pnpm --silent release -s "${SERVER_BUMP}" -m "${MOBILE_BUMP}"
|
||||
|
||||
- id: output
|
||||
run: echo "version=$IMMICH_VERSION" >> $GITHUB_OUTPUT
|
||||
run: |
|
||||
echo "version=$IMMICH_VERSION" >> $GITHUB_OUTPUT
|
||||
if [[ "$IMMICH_VERSION" =~ -rc\.[0-9]+$ ]]; then
|
||||
echo "rc=true" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "rc=false" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
- name: Commit and tag
|
||||
id: push-tag
|
||||
@@ -145,6 +152,7 @@ jobs:
|
||||
uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v3.0.0
|
||||
with:
|
||||
draft: true
|
||||
prerelease: ${{ needs.bump_version.outputs.rc }}
|
||||
tag_name: ${{ needs.bump_version.outputs.version }}
|
||||
token: ${{ steps.generate-token.outputs.token }}
|
||||
generate_release_notes: true
|
||||
|
||||
@@ -2,6 +2,7 @@ import { AssetVisibility, LoginResponseDto } from '@immich/sdk';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { basename, join } from 'node:path';
|
||||
import { Socket } from 'socket.io-client';
|
||||
import { createUserDto } from 'src/fixtures';
|
||||
import { errorDto } from 'src/responses';
|
||||
import { app, testAssetDir, utils } from 'src/utils';
|
||||
import request from 'supertest';
|
||||
@@ -9,28 +10,48 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
|
||||
describe('/map', () => {
|
||||
let websocket: Socket;
|
||||
let partnerWebsocket: Socket;
|
||||
let admin: LoginResponseDto;
|
||||
let partner: LoginResponseDto;
|
||||
let partnerArchivedAssetId: string;
|
||||
let adminArchivedAssetId: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
await utils.resetDatabase();
|
||||
admin = await utils.adminSetup({ onboarding: false });
|
||||
partner = await utils.userSetup(admin.accessToken, createUserDto.user1);
|
||||
|
||||
websocket = await utils.connectWebsocket(admin.accessToken);
|
||||
partnerWebsocket = await utils.connectWebsocket(partner.accessToken);
|
||||
|
||||
const files = ['formats/heic/IMG_2682.heic', 'metadata/gps-position/thompson-springs.jpg'];
|
||||
const adminFiles = ['formats/heic/IMG_2682.heic', 'metadata/gps-position/thompson-springs.jpg'];
|
||||
const adminArchivedFile = 'metadata/dates/datetimeoriginal-gps.jpg';
|
||||
const partnerFile = 'metadata/gps-position/thompson-springs.jpg';
|
||||
utils.resetEvents();
|
||||
const uploadFile = async (input: string) => {
|
||||
const uploadFile = async (accessToken: string, input: string) => {
|
||||
const filepath = join(testAssetDir, input);
|
||||
const { id } = await utils.createAsset(admin.accessToken, {
|
||||
const { id } = await utils.createAsset(accessToken, {
|
||||
assetData: { bytes: await readFile(filepath), filename: basename(filepath) },
|
||||
});
|
||||
await utils.waitForWebsocketEvent({ event: 'assetUpload', id });
|
||||
return id;
|
||||
};
|
||||
await Promise.all(files.map((f) => uploadFile(f)));
|
||||
await Promise.all(adminFiles.map((f) => uploadFile(admin.accessToken, f)));
|
||||
[adminArchivedAssetId, partnerArchivedAssetId] = await Promise.all([
|
||||
uploadFile(admin.accessToken, adminArchivedFile),
|
||||
uploadFile(partner.accessToken, partnerFile),
|
||||
]);
|
||||
|
||||
await Promise.all([
|
||||
utils.archiveAssets(admin.accessToken, [adminArchivedAssetId]),
|
||||
utils.archiveAssets(partner.accessToken, [partnerArchivedAssetId]),
|
||||
utils.createPartner(partner.accessToken, admin.userId),
|
||||
]);
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
utils.disconnectWebsocket(websocket);
|
||||
utils.disconnectWebsocket(partnerWebsocket);
|
||||
});
|
||||
|
||||
describe('GET /map/markers', () => {
|
||||
@@ -40,7 +61,6 @@ describe('/map', () => {
|
||||
expect(body).toEqual(errorDto.unauthorized);
|
||||
});
|
||||
|
||||
// TODO archive one of these assets
|
||||
it('should get map markers for all non-archived assets', async () => {
|
||||
const { status, body } = await request(app)
|
||||
.get('/map/markers')
|
||||
@@ -69,7 +89,28 @@ describe('/map', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
// TODO archive one of these assets
|
||||
it('should not expose partner archived asset locations', async () => {
|
||||
const { status, body } = await request(app)
|
||||
.get('/map/markers')
|
||||
.query({ withPartners: true, isArchived: true })
|
||||
.set('Authorization', `Bearer ${admin.accessToken}`);
|
||||
|
||||
expect(status).toBe(200);
|
||||
const ids = body.map((m: { id: string }) => m.id);
|
||||
expect(ids).not.toContain(partnerArchivedAssetId);
|
||||
expect(ids).toContain(adminArchivedAssetId);
|
||||
});
|
||||
|
||||
it('should include own archived asset locations', async () => {
|
||||
const { status, body } = await request(app)
|
||||
.get('/map/markers')
|
||||
.query({ isArchived: true })
|
||||
.set('Authorization', `Bearer ${admin.accessToken}`);
|
||||
|
||||
expect(status).toBe(200);
|
||||
expect(body.map((m: { id: string }) => m.id)).toContain(adminArchivedAssetId);
|
||||
});
|
||||
|
||||
it('should get all map markers', async () => {
|
||||
const { status, body } = await request(app)
|
||||
.get('/map/markers')
|
||||
|
||||
@@ -95,7 +95,7 @@ describe('/server', () => {
|
||||
major: expect.any(Number),
|
||||
minor: expect.any(Number),
|
||||
patch: expect.any(Number),
|
||||
prerelease: null,
|
||||
prerelease: expect.anything(),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
{}
|
||||
@@ -15,6 +15,7 @@ const Map<String, Locale> locales = {
|
||||
'Czech (cs)': Locale('cs'),
|
||||
'Danish (da)': Locale('da'),
|
||||
'Dutch (nl)': Locale('nl'),
|
||||
'English (United Kingdom) (en_GB)': Locale('en', 'GB'),
|
||||
'Estonian (et)': Locale('et'),
|
||||
'Filipino (tl)': Locale('tl'),
|
||||
'Finnish (fi)': Locale('fi'),
|
||||
|
||||
@@ -70,7 +70,10 @@ class DeepLinkService {
|
||||
|
||||
if (assetRegex.hasMatch(path)) {
|
||||
final assetId = assetRegex.firstMatch(path)?.group(1) ?? '';
|
||||
return _buildAssetDeepLink(assetId, ref);
|
||||
// /albums/<albumId>/photos/<assetId> links carry the album context,
|
||||
// which drives the like/comment UI in the viewer
|
||||
final albumId = albumRegex.firstMatch(path)?.group(1);
|
||||
return _buildAssetDeepLink(assetId, ref, albumId: albumId);
|
||||
}
|
||||
if (albumRegex.hasMatch(path)) {
|
||||
final albumId = albumRegex.firstMatch(path)?.group(1) ?? '';
|
||||
@@ -107,16 +110,19 @@ class DeepLinkService {
|
||||
return DriftMemoryRoute(memories: memories, memoryIndex: 0);
|
||||
}
|
||||
|
||||
Future<PageRouteInfo?> _buildAssetDeepLink(String assetId, WidgetRef ref) async {
|
||||
Future<PageRouteInfo?> _buildAssetDeepLink(String assetId, WidgetRef ref, {String? albumId}) async {
|
||||
final asset = await _betaAssetService.getRemoteAsset(assetId);
|
||||
if (asset == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final album = albumId != null ? await _betaRemoteAlbumService.get(albumId) : null;
|
||||
|
||||
AssetViewer.setAsset(ref, asset);
|
||||
return AssetViewerRoute(
|
||||
initialIndex: 0,
|
||||
timelineService: _betaTimelineFactory.fromAssets([asset], TimelineOrigin.deepLink),
|
||||
currentAlbum: album,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/domain/models/album/album.model.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/domain/models/timeline.model.dart';
|
||||
import 'package:immich_mobile/domain/services/asset.service.dart';
|
||||
import 'package:immich_mobile/domain/services/memory.service.dart';
|
||||
import 'package:immich_mobile/domain/services/people.service.dart';
|
||||
import 'package:immich_mobile/domain/services/remote_album.service.dart';
|
||||
import 'package:immich_mobile/domain/services/timeline.service.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
import 'package:immich_mobile/services/deep_link.service.dart';
|
||||
import 'package:mocktail/mocktail.dart';
|
||||
|
||||
class MockTimelineFactory extends Mock implements TimelineFactory {}
|
||||
|
||||
class MockAssetService extends Mock implements AssetService {}
|
||||
|
||||
class MockRemoteAlbumService extends Mock implements RemoteAlbumService {}
|
||||
|
||||
class MockDriftMemoryService extends Mock implements DriftMemoryService {}
|
||||
|
||||
class MockDriftPeopleService extends Mock implements DriftPeopleService {}
|
||||
|
||||
class MockPlatformDeepLink extends Mock implements PlatformDeepLink {}
|
||||
|
||||
class MockWidgetRef extends Mock implements WidgetRef {}
|
||||
|
||||
class MockAssetViewerStateNotifier extends Mock implements AssetViewerStateNotifier {}
|
||||
|
||||
const _assetId = 'aaaaaaaa-1111-2222-3333-bbbbbbbbbbbb';
|
||||
const _albumId = 'cccccccc-4444-5555-6666-dddddddddddd';
|
||||
|
||||
final _asset = RemoteAsset(
|
||||
id: _assetId,
|
||||
name: 'photo.jpg',
|
||||
ownerId: 'user-1',
|
||||
checksum: 'checksum-1',
|
||||
type: AssetType.image,
|
||||
createdAt: DateTime(2026, 6, 12),
|
||||
updatedAt: DateTime(2026, 6, 12),
|
||||
isEdited: false,
|
||||
);
|
||||
|
||||
final _album = RemoteAlbum(
|
||||
id: _albumId,
|
||||
name: 'Shared Album',
|
||||
ownerId: 'user-1',
|
||||
description: '',
|
||||
createdAt: DateTime(2026, 6, 12),
|
||||
updatedAt: DateTime(2026, 6, 12),
|
||||
isActivityEnabled: true,
|
||||
isShared: true,
|
||||
order: AlbumAssetOrder.asc,
|
||||
assetCount: 1,
|
||||
ownerName: 'Owner',
|
||||
);
|
||||
|
||||
void main() {
|
||||
late MockTimelineFactory timelineFactory;
|
||||
late MockAssetService assetService;
|
||||
late MockRemoteAlbumService remoteAlbumService;
|
||||
late MockWidgetRef ref;
|
||||
late List<TimelineService> createdTimelineServices;
|
||||
late DeepLinkService sut;
|
||||
|
||||
setUp(() {
|
||||
timelineFactory = MockTimelineFactory();
|
||||
assetService = MockAssetService();
|
||||
remoteAlbumService = MockRemoteAlbumService();
|
||||
ref = MockWidgetRef();
|
||||
createdTimelineServices = [];
|
||||
|
||||
when(() => timelineFactory.fromAssets(any(), TimelineOrigin.deepLink)).thenAnswer((invocation) {
|
||||
final assets = List<BaseAsset>.from(invocation.positionalArguments[0] as List<BaseAsset>);
|
||||
final timelineService = TimelineService((
|
||||
assetSource: (index, count) async => assets.skip(index).take(count).toList(),
|
||||
bucketSource: () => Stream.value([Bucket(assetCount: assets.length)]),
|
||||
origin: TimelineOrigin.deepLink,
|
||||
));
|
||||
createdTimelineServices.add(timelineService);
|
||||
return timelineService;
|
||||
});
|
||||
|
||||
when(() => ref.read(assetViewerProvider.notifier)).thenReturn(MockAssetViewerStateNotifier());
|
||||
|
||||
sut = DeepLinkService(
|
||||
timelineFactory,
|
||||
assetService,
|
||||
remoteAlbumService,
|
||||
MockDriftMemoryService(),
|
||||
MockDriftPeopleService(),
|
||||
null,
|
||||
);
|
||||
|
||||
addTearDown(() async {
|
||||
for (final timelineService in createdTimelineServices) {
|
||||
await timelineService.dispose();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
PlatformDeepLink link(String path) {
|
||||
final deepLink = MockPlatformDeepLink();
|
||||
when(() => deepLink.uri).thenReturn(Uri.parse('https://my.immich.app$path'));
|
||||
return deepLink;
|
||||
}
|
||||
|
||||
test('album photo link carries the album into the viewer route', () async {
|
||||
when(() => assetService.getRemoteAsset(_assetId)).thenAnswer((_) async => _asset);
|
||||
when(() => remoteAlbumService.get(_albumId)).thenAnswer((_) async => _album);
|
||||
|
||||
final route = await sut.handleMyImmichApp(link('/albums/$_albumId/photos/$_assetId'), ref);
|
||||
|
||||
expect(route, isA<AssetViewerRoute>());
|
||||
expect((route!.args as AssetViewerRouteArgs).currentAlbum, _album);
|
||||
});
|
||||
|
||||
test('still opens the viewer when the album cannot be resolved', () async {
|
||||
when(() => assetService.getRemoteAsset(_assetId)).thenAnswer((_) async => _asset);
|
||||
when(() => remoteAlbumService.get(_albumId)).thenAnswer((_) async => null);
|
||||
|
||||
final route = await sut.handleMyImmichApp(link('/albums/$_albumId/photos/$_assetId'), ref);
|
||||
|
||||
expect(route, isA<AssetViewerRoute>());
|
||||
expect((route!.args as AssetViewerRouteArgs).currentAlbum, isNull);
|
||||
});
|
||||
|
||||
test('plain photo link has no album', () async {
|
||||
when(() => assetService.getRemoteAsset(_assetId)).thenAnswer((_) async => _asset);
|
||||
|
||||
final route = await sut.handleMyImmichApp(link('/photos/$_assetId'), ref);
|
||||
|
||||
expect(route, isA<AssetViewerRoute>());
|
||||
expect((route!.args as AssetViewerRouteArgs).currentAlbum, isNull);
|
||||
verifyNever(() => remoteAlbumService.get(any()));
|
||||
});
|
||||
}
|
||||
@@ -78,8 +78,9 @@ export class MapRepository {
|
||||
.execute();
|
||||
}
|
||||
|
||||
@GenerateSql({ params: [[DummyValue.UUID], [DummyValue.UUID]] })
|
||||
@GenerateSql({ params: [DummyValue.UUID, [DummyValue.UUID], [DummyValue.UUID]] })
|
||||
getMapMarkers(
|
||||
authUserId: string,
|
||||
ownerIds: string[],
|
||||
albumIds: string[],
|
||||
{ isArchived, isFavorite, fileCreatedAfter, fileCreatedBefore }: MapMarkerSearchOptions = {},
|
||||
@@ -89,7 +90,7 @@ export class MapRepository {
|
||||
qb.where((eb) =>
|
||||
eb.or([
|
||||
eb('asset.visibility', '=', AssetVisibility.Timeline),
|
||||
eb('asset.visibility', '=', AssetVisibility.Archive),
|
||||
eb.and([eb('asset.ownerId', '=', authUserId), eb('asset.visibility', '=', AssetVisibility.Archive)]),
|
||||
]),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -59,6 +59,7 @@ describe(MapService.name, () => {
|
||||
const markers = await sut.getMapMarkers(auth, { withPartners: true });
|
||||
|
||||
expect(mocks.map.getMapMarkers).toHaveBeenCalledWith(
|
||||
auth.user.id,
|
||||
[auth.user.id, partner.sharedById],
|
||||
expect.arrayContaining([]),
|
||||
{ withPartners: true },
|
||||
|
||||
@@ -15,7 +15,7 @@ export class MapService extends BaseService {
|
||||
|
||||
const albumIds = options.withSharedAlbums ? await this.albumRepository.getAllIds(auth.user.id) : [];
|
||||
|
||||
return this.mapRepository.getMapMarkers(userIds, albumIds, options);
|
||||
return this.mapRepository.getMapMarkers(auth.user.id, userIds, albumIds, options);
|
||||
}
|
||||
|
||||
async reverseGeocode(dto: MapReverseGeocodeDto) {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { DateTime } from 'luxon';
|
||||
import { SemVer } from 'semver';
|
||||
import { defaults } from 'src/config';
|
||||
import { serverVersion } from 'src/constants';
|
||||
import { ReleaseChannel } from 'src/dtos/system-config.dto';
|
||||
import { CronJob, JobName, JobStatus, SystemMetadataKey } from 'src/enum';
|
||||
import { VersionService } from 'src/services/version.service';
|
||||
@@ -23,16 +22,10 @@ describe(VersionService.name, () => {
|
||||
mocks.cron.update.mockResolvedValue();
|
||||
});
|
||||
|
||||
beforeAll(() => {
|
||||
vitest.mock(import('src/constants.js'), async () => ({
|
||||
...(await vitest.importActual<typeof import('src/constants.js')>('src/constants.js')),
|
||||
serverVersion: new SemVer('v3.0.0'),
|
||||
}));
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
vitest.unmock(import('src/constants.js'));
|
||||
});
|
||||
vitest.mock(import('src/constants.js'), async (importOriginal) => ({
|
||||
...(await importOriginal()),
|
||||
serverVersion: new SemVer('v3.0.0'),
|
||||
}));
|
||||
|
||||
it('should work', () => {
|
||||
expect(sut).toBeDefined();
|
||||
@@ -53,7 +46,7 @@ describe(VersionService.name, () => {
|
||||
mocks.versionHistory.getLatest.mockResolvedValue({
|
||||
id: 'version-1',
|
||||
createdAt: new Date(),
|
||||
version: serverVersion.toString(),
|
||||
version: '3.0.0',
|
||||
});
|
||||
await expect(sut.onBootstrap()).resolves.toBeUndefined();
|
||||
expect(mocks.versionHistory.create).not.toHaveBeenCalled();
|
||||
@@ -64,7 +57,7 @@ describe(VersionService.name, () => {
|
||||
mocks.versionHistory.getLatest.mockResolvedValue({
|
||||
id: 'version-1',
|
||||
createdAt: new Date(),
|
||||
version: serverVersion.toString(),
|
||||
version: '3.0.0',
|
||||
});
|
||||
await sut.onBootstrap();
|
||||
expect(mocks.cron.create).toHaveBeenCalledWith(
|
||||
@@ -121,7 +114,7 @@ describe(VersionService.name, () => {
|
||||
checkedAt: DateTime.utc().minus({ seconds: 60 }).toISO(),
|
||||
releaseVersion: '1.0.0',
|
||||
});
|
||||
mocks.serverInfo.getLatestRelease.mockResolvedValue(mockVersionResponse(serverVersion.toString()));
|
||||
mocks.serverInfo.getLatestRelease.mockResolvedValue(mockVersionResponse('v3.0.0'));
|
||||
await expect(sut.handleVersionCheck()).resolves.toEqual(JobStatus.Success);
|
||||
expect(mocks.serverInfo.getLatestRelease).toHaveBeenCalled();
|
||||
});
|
||||
@@ -135,11 +128,11 @@ describe(VersionService.name, () => {
|
||||
});
|
||||
|
||||
it('should not notify if the version is equal', async () => {
|
||||
mocks.serverInfo.getLatestRelease.mockResolvedValue(mockVersionResponse(serverVersion.toString()));
|
||||
mocks.serverInfo.getLatestRelease.mockResolvedValue(mockVersionResponse('v3.0.0'));
|
||||
await expect(sut.handleVersionCheck()).resolves.toEqual(JobStatus.Success);
|
||||
expect(mocks.systemMetadata.set).toHaveBeenCalledWith(SystemMetadataKey.VersionCheckState, {
|
||||
checkedAt: expect.any(String),
|
||||
releaseVersion: serverVersion.toString(),
|
||||
releaseVersion: 'v3.0.0',
|
||||
});
|
||||
expect(mocks.websocket.clientBroadcast).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -261,10 +261,6 @@
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if show.brokenAsset}
|
||||
<BrokenAsset class="absolute size-full text-xl" />
|
||||
{/if}
|
||||
|
||||
{#if show.preview}
|
||||
<ImageLayer
|
||||
{adaptiveImageLoader}
|
||||
@@ -290,6 +286,10 @@
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if show.brokenAsset}
|
||||
<BrokenAsset class="absolute inset-0 z-10 size-full text-xl" />
|
||||
{/if}
|
||||
|
||||
{#if overlays}
|
||||
<div class="pointer-events-none absolute inset-0">
|
||||
{@render overlays()}
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
import { navigate } from '$lib/utils/navigation';
|
||||
import { toTimelineAsset } from '$lib/utils/timeline-util';
|
||||
import { type AlbumResponseDto, type AssetResponseDto, type PersonResponseDto, getAssetInfo } from '@immich/sdk';
|
||||
import { onDestroy, onMount, untrack } from 'svelte';
|
||||
import { onDestroy, onMount } from 'svelte';
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
interface Props {
|
||||
@@ -82,7 +82,7 @@
|
||||
$effect(() => {
|
||||
const asset = assetViewerManager.asset;
|
||||
if (asset) {
|
||||
untrack(() => handlePromiseError(loadCloseAssets(asset)));
|
||||
handlePromiseError(loadCloseAssets(asset));
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -69,7 +69,7 @@ export enum OpenQueryParam {
|
||||
PURCHASE_SETTINGS = 'user-purchase-settings',
|
||||
}
|
||||
|
||||
export const maximumLengthSearchPeople = 1000;
|
||||
export const maximumLengthSearchPeople = 100;
|
||||
|
||||
// time to load the map before displaying the loading spinner
|
||||
export const timeToLoadTheMap: number = 100;
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
<script lang="ts">
|
||||
import type { SearchOptions } from '$lib/utils/dipatch';
|
||||
import { IconButton, LoadingSpinner } from '@immich/ui';
|
||||
import { mdiClose, mdiMagnify } from '@mdi/js';
|
||||
import { t } from 'svelte-i18n';
|
||||
@@ -9,7 +8,7 @@
|
||||
roundedBottom?: boolean;
|
||||
showLoadingSpinner: boolean;
|
||||
placeholder: string;
|
||||
onSearch?: (options: SearchOptions) => void;
|
||||
onSearch?: (options: { force?: boolean }) => void;
|
||||
onReset?: () => void;
|
||||
}
|
||||
|
||||
|
||||
@@ -44,7 +44,7 @@ class AssetViewerManager extends BaseEventManager<Events> {
|
||||
imageLoaderStatus = $state<ImageLoaderStatus | undefined>();
|
||||
#isImageLoading = $derived.by(() => {
|
||||
const quality = this.imageLoaderStatus?.quality;
|
||||
if (!quality) {
|
||||
if (!quality || this.imageLoaderStatus?.hasError) {
|
||||
return false;
|
||||
}
|
||||
const previewOrOriginalReady = quality.preview === 'success' || quality.original === 'success';
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
export interface ResetOptions {
|
||||
default?: boolean;
|
||||
}
|
||||
|
||||
export interface SearchOptions {
|
||||
force?: boolean;
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
import type { AssetResponseDto } from '@immich/sdk';
|
||||
import { getExifCount } from '$lib/utils/exif-utils';
|
||||
|
||||
describe('getting the exif count', () => {
|
||||
it('returns 0 when exifInfo is undefined', () => {
|
||||
const asset = {};
|
||||
expect(getExifCount(asset as AssetResponseDto)).toBe(0);
|
||||
});
|
||||
|
||||
it('returns 0 when exifInfo is empty', () => {
|
||||
const asset = { exifInfo: {} };
|
||||
expect(getExifCount(asset as AssetResponseDto)).toBe(0);
|
||||
});
|
||||
|
||||
it('returns the correct count of non-null exifInfo properties', () => {
|
||||
const asset = { exifInfo: { fileSizeInByte: 200, rating: 5, fNumber: null } };
|
||||
expect(getExifCount(asset as AssetResponseDto)).toBe(2);
|
||||
});
|
||||
|
||||
it('ignores null, undefined and empty properties in exifInfo', () => {
|
||||
const asset = { exifInfo: { fileSizeInByte: 200, rating: null, fNumber: undefined, description: '' } };
|
||||
expect(getExifCount(asset as AssetResponseDto)).toBe(1);
|
||||
});
|
||||
|
||||
it('returns the correct count when all exifInfo properties are non-null', () => {
|
||||
const asset = { exifInfo: { fileSizeInByte: 200, rating: 5, fNumber: 1, description: 'test' } };
|
||||
expect(getExifCount(asset as AssetResponseDto)).toBe(4);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +0,0 @@
|
||||
import type { AssetResponseDto } from '@immich/sdk';
|
||||
|
||||
export const getExifCount = (asset: AssetResponseDto) => {
|
||||
return Object.values(asset.exifInfo ?? {}).filter(Boolean).length;
|
||||
};
|
||||
@@ -13,38 +13,6 @@ export interface PlacesGroup {
|
||||
places: AssetResponseDto[];
|
||||
}
|
||||
|
||||
export interface PlacesGroupOptionMetadata {
|
||||
id: PlacesGroupBy;
|
||||
isDisabled: () => boolean;
|
||||
}
|
||||
|
||||
export const groupOptionsMetadata: PlacesGroupOptionMetadata[] = [
|
||||
{
|
||||
id: PlacesGroupBy.None,
|
||||
isDisabled: () => false,
|
||||
},
|
||||
{
|
||||
id: PlacesGroupBy.Country,
|
||||
isDisabled: () => false,
|
||||
},
|
||||
];
|
||||
|
||||
export const findGroupOptionMetadata = (groupBy: string) => {
|
||||
// Default is no grouping
|
||||
const defaultGroupOption = groupOptionsMetadata[0];
|
||||
return groupOptionsMetadata.find(({ id }) => groupBy === id) ?? defaultGroupOption;
|
||||
};
|
||||
|
||||
export const getSelectedPlacesGroupOption = (settings: PlacesViewSettings) => {
|
||||
const defaultGroupOption = PlacesGroupBy.None;
|
||||
const albumGroupOption = settings.groupBy ?? defaultGroupOption;
|
||||
|
||||
if (findGroupOptionMetadata(albumGroupOption).isDisabled()) {
|
||||
return defaultGroupOption;
|
||||
}
|
||||
return albumGroupOption;
|
||||
};
|
||||
|
||||
/**
|
||||
* ----------------------------
|
||||
* Places Groups Collapse/Expand
|
||||
|
||||
@@ -5,13 +5,3 @@ export const removeAccents = (str: string) => {
|
||||
export const normalizeSearchString = (str: string) => {
|
||||
return removeAccents(str.toLocaleLowerCase());
|
||||
};
|
||||
|
||||
export const buildDateString = (year: number, month?: number, day?: number) => {
|
||||
return [
|
||||
year.toString(),
|
||||
month && !Number.isNaN(month) ? month.toString() : undefined,
|
||||
day && !Number.isNaN(day) ? day.toString() : undefined,
|
||||
]
|
||||
.filter((date) => date !== undefined)
|
||||
.join('-');
|
||||
};
|
||||
|
||||
@@ -30,9 +30,6 @@ export const getTriggerDescription = ($t: MessageFormatter, type: WorkflowTrigge
|
||||
case WorkflowTrigger.AssetMetadataExtraction: {
|
||||
return $t('trigger_asset_metadata_extraction_description');
|
||||
}
|
||||
default: {
|
||||
return type;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -79,10 +76,6 @@ export const getWorkflowDefaultConfig = (schema: JSONSchemaProperty) => {
|
||||
config[key] = property.properties ? getWorkflowDefaultConfig(property) : {};
|
||||
break;
|
||||
}
|
||||
|
||||
default: {
|
||||
console.log(`Unknown configuration type: ${property.type}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,24 +1,11 @@
|
||||
<script lang="ts">
|
||||
import Dropdown from '$lib/elements/Dropdown.svelte';
|
||||
import SearchBar from '$lib/elements/SearchBar.svelte';
|
||||
import { PlacesGroupBy, placesViewSettings } from '$lib/stores/preferences.store';
|
||||
import {
|
||||
type PlacesGroupOptionMetadata,
|
||||
collapseAllPlacesGroups,
|
||||
expandAllPlacesGroups,
|
||||
findGroupOptionMetadata,
|
||||
getSelectedPlacesGroupOption,
|
||||
groupOptionsMetadata,
|
||||
} from '$lib/utils/places-utils';
|
||||
import { IconButton } from '@immich/ui';
|
||||
import {
|
||||
mdiFolderArrowUpOutline,
|
||||
mdiFolderRemoveOutline,
|
||||
mdiUnfoldLessHorizontal,
|
||||
mdiUnfoldMoreHorizontal,
|
||||
} from '@mdi/js';
|
||||
import { collapseAllPlacesGroups, expandAllPlacesGroups } from '$lib/utils/places-utils';
|
||||
import { IconButton, Select } from '@immich/ui';
|
||||
import { mdiUnfoldLessHorizontal, mdiUnfoldMoreHorizontal } from '@mdi/js';
|
||||
import { t } from 'svelte-i18n';
|
||||
import { fly } from 'svelte/transition';
|
||||
import { slide } from 'svelte/transition';
|
||||
|
||||
interface Props {
|
||||
placesGroups: string[];
|
||||
@@ -27,48 +14,26 @@
|
||||
|
||||
let { placesGroups, searchQuery = $bindable() }: Props = $props();
|
||||
|
||||
const handleChangeGroupBy = ({ id }: PlacesGroupOptionMetadata) => {
|
||||
$placesViewSettings.groupBy = id;
|
||||
};
|
||||
|
||||
let groupIcon = $derived.by(() => {
|
||||
return selectedGroupOption.id === PlacesGroupBy.None ? mdiFolderRemoveOutline : mdiFolderArrowUpOutline; // OR mdiFolderArrowDownOutline
|
||||
});
|
||||
|
||||
let selectedGroupOption = $derived(findGroupOptionMetadata($placesViewSettings.groupBy));
|
||||
|
||||
let placesGroupByNames: Record<PlacesGroupBy, string> = $derived({
|
||||
[PlacesGroupBy.None]: $t('group_no'),
|
||||
[PlacesGroupBy.Country]: $t('group_country'),
|
||||
});
|
||||
let options = $derived([
|
||||
{ value: PlacesGroupBy.None, label: $t('group_no') },
|
||||
{ value: PlacesGroupBy.Country, label: $t('group_country') },
|
||||
]);
|
||||
</script>
|
||||
|
||||
<!-- Search Places -->
|
||||
<div class="hidden h-10 md:block xl:w-60 2xl:w-80">
|
||||
<SearchBar placeholder={$t('search_places')} bind:name={searchQuery} showLoadingSpinner={false} />
|
||||
</div>
|
||||
|
||||
<!-- Group Places -->
|
||||
<Dropdown
|
||||
position="bottom-right"
|
||||
title={$t('group_places_by')}
|
||||
options={Object.values(groupOptionsMetadata)}
|
||||
selectedOption={selectedGroupOption}
|
||||
onSelect={handleChangeGroupBy}
|
||||
render={({ id, isDisabled }) => ({
|
||||
title: placesGroupByNames[id],
|
||||
icon: groupIcon,
|
||||
disabled: isDisabled(),
|
||||
})}
|
||||
/>
|
||||
<div title={$t('group_places_by')}>
|
||||
<Select {options} bind:value={$placesViewSettings.groupBy} class="w-fit min-w-50" />
|
||||
</div>
|
||||
|
||||
{#if getSelectedPlacesGroupOption($placesViewSettings) !== PlacesGroupBy.None}
|
||||
<span in:fly={{ x: -50, duration: 250 }}>
|
||||
{#if $placesViewSettings.groupBy !== PlacesGroupBy.None}
|
||||
<span transition:slide={{ axis: 'x', duration: 250 }}>
|
||||
<!-- Expand Countries Groups -->
|
||||
<div class="hidden gap-0 xl:flex">
|
||||
<div class="block">
|
||||
<IconButton
|
||||
title={$t('expand_all')}
|
||||
onclick={() => expandAllPlacesGroups()}
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
@@ -81,7 +46,6 @@
|
||||
<!-- Collapse Countries Groups -->
|
||||
<div class="block">
|
||||
<IconButton
|
||||
title={$t('collapse_all')}
|
||||
onclick={() => collapseAllPlacesGroups(placesGroups)}
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
import { groupBy } from 'lodash-es';
|
||||
import PlacesCardGroup from './PlacesCardGroup.svelte';
|
||||
|
||||
import { type PlacesGroup, getSelectedPlacesGroupOption } from '$lib/utils/places-utils';
|
||||
import { type PlacesGroup } from '$lib/utils/places-utils';
|
||||
import { Icon } from '@immich/ui';
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
@@ -78,9 +78,8 @@
|
||||
: places;
|
||||
});
|
||||
|
||||
const placesGroupOption: string = $derived(getSelectedPlacesGroupOption(userSettings));
|
||||
const groupingFunction = $derived(groupOptions[placesGroupOption] ?? groupOptions[PlacesGroupBy.None]);
|
||||
const groupedPlaces: PlacesGroup[] = $derived(groupingFunction(filteredPlaces));
|
||||
const groupingFunction = $derived(groupOptions[userSettings.groupBy] ?? groupOptions[PlacesGroupBy.None]);
|
||||
const groupedPlaces = $derived(groupingFunction(filteredPlaces));
|
||||
|
||||
$effect(() => {
|
||||
searchResultCount = filteredPlaces.length;
|
||||
@@ -93,7 +92,7 @@
|
||||
|
||||
{#if places.length > 0}
|
||||
<!-- Album Cards -->
|
||||
{#if placesGroupOption === PlacesGroupBy.None}
|
||||
{#if userSettings.groupBy === PlacesGroupBy.None}
|
||||
<PlacesCardGroup places={groupedPlaces[0].places} />
|
||||
{:else}
|
||||
{#each groupedPlaces as placeGroup (placeGroup.id)}
|
||||
|
||||
+10
-3
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { locale } from '$lib/stores/preferences.store';
|
||||
import { lang, locale } from '$lib/stores/preferences.store';
|
||||
import { getAssetMediaUrl } from '$lib/utils';
|
||||
import { getAllMetadataItems, type DifferingMetadataFields } from '$lib/utils/duplicate-utils';
|
||||
import { getAltText } from '$lib/utils/thumbnail-util';
|
||||
@@ -30,7 +30,8 @@
|
||||
initialVisibleCount = 5,
|
||||
}: Props = $props();
|
||||
|
||||
let isFromExternalLibrary = $derived(!!asset.libraryId);
|
||||
const listFormat = $derived(new Intl.ListFormat($lang));
|
||||
const isFromExternalLibrary = $derived(!!asset.libraryId);
|
||||
|
||||
const visibleMetadataItems = $derived(
|
||||
getAllMetadataItems(asset, $t, $locale)
|
||||
@@ -116,7 +117,13 @@
|
||||
{#await getAllAlbums({ assetId: asset.id })}
|
||||
{$t('scanning_for_album')}
|
||||
{:then albums}
|
||||
{$t('in_albums', { values: { count: albums.length } })}
|
||||
{#if albums.length === 1}
|
||||
{albums[0].albumName}
|
||||
{:else}
|
||||
<span title={listFormat.format(albums.map(({ albumName }) => albumName))}>
|
||||
{$t('in_albums', { values: { count: albums.length } })}
|
||||
</span>
|
||||
{/if}
|
||||
{/await}
|
||||
</InfoRow>
|
||||
</div>
|
||||
|
||||
@@ -37,7 +37,7 @@
|
||||
}
|
||||
uploadAssetsStore.reset();
|
||||
}}
|
||||
class="fixed inset-e-16 bottom-6"
|
||||
class="fixed inset-e-16 bottom-6 z-60"
|
||||
>
|
||||
{#if showDetail}
|
||||
<div
|
||||
|
||||
@@ -141,11 +141,11 @@
|
||||
<Alert color="danger" title={errorMessage} closable />
|
||||
{/if}
|
||||
|
||||
<Field label={$t('email')}>
|
||||
<Field label={$t('email')} required="indicator">
|
||||
<Input id="email" name="email" type="email" autocomplete="email" bind:value={email} />
|
||||
</Field>
|
||||
|
||||
<Field label={$t('password')}>
|
||||
<Field label={$t('password')} required="indicator">
|
||||
<PasswordInput id="password" bind:value={password} autocomplete="current-password" />
|
||||
</Field>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user