Compare commits

...

11 Commits

Author SHA1 Message Date
Mees Frensel 4bad091eeb add fil to mobile locales 2026-06-15 16:44:46 +02:00
Mees Frensel af54c028d2 Merge branch 'main' into fix/language-selector 2026-06-15 16:42:41 +02:00
Timon b633cc4f04 fix(server): hide partner archived asset locations from map (#29028) 2026-06-15 16:30:52 +02:00
Timon a9ee6a7ce9 fix(web): show asset arrows (#29010)
* fix(web): show asset arrows

* lint
2026-06-15 10:22:59 -04:00
Daniel Dietzler c273ccf2e2 feat: languages (#29088) 2026-06-15 16:01:09 +02:00
Mees Frensel 5f1a180d1a chore(web): remove dead code (#29092) 2026-06-15 14:54:51 +02:00
Brandon Wees cc54de87aa fix(web): error loading image state (#29058) 2026-06-15 13:38:52 +02:00
Mees Frensel a97e5999e4 chore(web): make login page email and password required (#29068) 2026-06-15 13:34:49 +02:00
okxint 46631b3786 fix(web): prevent upload status panel from overlapping album action bar (#29044) 2026-06-15 13:21:02 +02:00
Mees Frensel 5a3be158b9 fix(web): show album names in duplicate review (#29080) 2026-06-15 12:47:17 +02:00
Mees Frensel 87daf9dda5 fix(web): language selector 2026-06-13 21:47:18 +02:00
19 changed files with 78 additions and 83 deletions
+47 -6
View File
@@ -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')
+1
View File
@@ -0,0 +1 @@
{}
+3 -1
View File
@@ -15,8 +15,10 @@ 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'),
'Filipino (fil)': Locale('fil'),
'Filipino (Tagalog) (tl)': Locale('tl'),
'Finnish (fi)': Locale('fi'),
'French (fr)': Locale('fr'),
'Galician (gl)': Locale('gl'),
+3 -2
View File
@@ -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)]),
]),
),
)
+1
View File
@@ -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 },
+1 -1
View File
@@ -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) {
+4 -4
View File
@@ -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));
}
});
+1 -2
View File
@@ -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';
-7
View File
@@ -1,7 +0,0 @@
export interface ResetOptions {
default?: boolean;
}
export interface SearchOptions {
force?: boolean;
}
-29
View File
@@ -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);
});
});
-5
View File
@@ -1,5 +0,0 @@
import type { AssetResponseDto } from '@immich/sdk';
export const getExifCount = (asset: AssetResponseDto) => {
return Object.values(asset.exifInfo ?? {}).filter(Boolean).length;
};
+1
View File
@@ -116,6 +116,7 @@ const nonIntlNames: Record<string, string> = {
kxm: 'Khmer Surin',
mfa: 'Malay (Pattani)',
swg: 'Schwäbisch',
tl: 'Tagalog',
};
const getLanguageName = (code: string) =>
-10
View File
@@ -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('-');
};
-7
View File
@@ -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,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>
+1 -1
View File
@@ -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
+2 -2
View File
@@ -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>