mirror of
https://github.com/immich-app/immich.git
synced 2026-01-12 05:05:33 -08:00
Compare commits
2 Commits
refactor/t
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5e3f5f2b55 | ||
|
|
d4ad523eb3 |
@@ -49,6 +49,23 @@ returning
|
||||
"dateTimeOriginal",
|
||||
"timeZone"
|
||||
|
||||
-- AssetRepository.unlockProperties
|
||||
update "asset_exif"
|
||||
set
|
||||
"lockedProperties" = nullif(
|
||||
array(
|
||||
select distinct
|
||||
property
|
||||
from
|
||||
unnest("asset_exif"."lockedProperties") property
|
||||
where
|
||||
not property = any ($1)
|
||||
),
|
||||
'{}'
|
||||
)
|
||||
where
|
||||
"assetId" = $2
|
||||
|
||||
-- AssetRepository.getMetadata
|
||||
select
|
||||
"key",
|
||||
|
||||
@@ -223,6 +223,17 @@ export class AssetRepository {
|
||||
.execute();
|
||||
}
|
||||
|
||||
@GenerateSql({ params: [DummyValue.UUID, ['description']] })
|
||||
unlockProperties(assetId: string, properties: LockableProperty[]) {
|
||||
return this.db
|
||||
.updateTable('asset_exif')
|
||||
.where('assetId', '=', assetId)
|
||||
.set((eb) => ({
|
||||
lockedProperties: sql`nullif(array(select distinct property from unnest(${eb.ref('asset_exif.lockedProperties')}) property where not property = any(${properties})), '{}')`,
|
||||
}))
|
||||
.execute();
|
||||
}
|
||||
|
||||
async upsertJobStatus(...jobStatus: Insertable<AssetJobStatusTable>[]): Promise<void> {
|
||||
if (jobStatus.length === 0) {
|
||||
return;
|
||||
|
||||
@@ -1758,6 +1758,12 @@ describe(MetadataService.name, () => {
|
||||
GPSLatitude: gps,
|
||||
GPSLongitude: gps,
|
||||
});
|
||||
expect(mocks.asset.unlockProperties).toHaveBeenCalledWith(asset.id, [
|
||||
'description',
|
||||
'latitude',
|
||||
'longitude',
|
||||
'dateTimeOriginal',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -461,6 +461,8 @@ export class MetadataService extends BaseService {
|
||||
await this.assetRepository.upsertFile({ assetId: id, type: AssetFileType.Sidecar, path: sidecarPath });
|
||||
}
|
||||
|
||||
await this.assetRepository.unlockProperties(asset.id, lockedProperties);
|
||||
|
||||
return JobStatus.Success;
|
||||
}
|
||||
|
||||
|
||||
@@ -87,4 +87,64 @@ describe(AssetRepository.name, () => {
|
||||
).resolves.toEqual({ lockedProperties: ['description', 'dateTimeOriginal'] });
|
||||
});
|
||||
});
|
||||
|
||||
describe('unlockProperties', () => {
|
||||
it('should unlock one property', async () => {
|
||||
const { ctx, sut } = setup();
|
||||
const { user } = await ctx.newUser();
|
||||
const { asset } = await ctx.newAsset({ ownerId: user.id });
|
||||
await ctx.newExif({
|
||||
assetId: asset.id,
|
||||
dateTimeOriginal: '2023-11-19T18:11:00',
|
||||
lockedProperties: ['dateTimeOriginal', 'description'],
|
||||
});
|
||||
|
||||
await expect(
|
||||
ctx.database
|
||||
.selectFrom('asset_exif')
|
||||
.select('lockedProperties')
|
||||
.where('assetId', '=', asset.id)
|
||||
.executeTakeFirstOrThrow(),
|
||||
).resolves.toEqual({ lockedProperties: ['dateTimeOriginal', 'description'] });
|
||||
|
||||
await sut.unlockProperties(asset.id, ['dateTimeOriginal']);
|
||||
|
||||
await expect(
|
||||
ctx.database
|
||||
.selectFrom('asset_exif')
|
||||
.select('lockedProperties')
|
||||
.where('assetId', '=', asset.id)
|
||||
.executeTakeFirstOrThrow(),
|
||||
).resolves.toEqual({ lockedProperties: ['description'] });
|
||||
});
|
||||
|
||||
it('should unlock all properties', async () => {
|
||||
const { ctx, sut } = setup();
|
||||
const { user } = await ctx.newUser();
|
||||
const { asset } = await ctx.newAsset({ ownerId: user.id });
|
||||
await ctx.newExif({
|
||||
assetId: asset.id,
|
||||
dateTimeOriginal: '2023-11-19T18:11:00',
|
||||
lockedProperties: ['dateTimeOriginal', 'description'],
|
||||
});
|
||||
|
||||
await expect(
|
||||
ctx.database
|
||||
.selectFrom('asset_exif')
|
||||
.select('lockedProperties')
|
||||
.where('assetId', '=', asset.id)
|
||||
.executeTakeFirstOrThrow(),
|
||||
).resolves.toEqual({ lockedProperties: ['dateTimeOriginal', 'description'] });
|
||||
|
||||
await sut.unlockProperties(asset.id, ['description', 'dateTimeOriginal']);
|
||||
|
||||
await expect(
|
||||
ctx.database
|
||||
.selectFrom('asset_exif')
|
||||
.select('lockedProperties')
|
||||
.where('assetId', '=', asset.id)
|
||||
.executeTakeFirstOrThrow(),
|
||||
).resolves.toEqual({ lockedProperties: null });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,6 +9,7 @@ export const newAssetRepositoryMock = (): Mocked<RepositoryInterface<AssetReposi
|
||||
upsertExif: vitest.fn(),
|
||||
updateAllExif: vitest.fn(),
|
||||
updateDateTimeOriginal: vitest.fn().mockResolvedValue([]),
|
||||
unlockProperties: vitest.fn().mockResolvedValue([]),
|
||||
upsertJobStatus: vitest.fn(),
|
||||
getForCopy: vitest.fn(),
|
||||
getByDayOfYear: vitest.fn(),
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script lang="ts">
|
||||
import Combobox, { type ComboBoxOption } from '$lib/components/shared-components/combobox.svelte';
|
||||
import { Label, Text } from '@immich/ui';
|
||||
import type { Snippet } from 'svelte';
|
||||
import { t } from 'svelte-i18n';
|
||||
import { quintOut } from 'svelte/easing';
|
||||
@@ -28,25 +29,21 @@
|
||||
}: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="grid grid-cols-2">
|
||||
<div>
|
||||
<div class="flex h-6.5 place-items-center gap-1">
|
||||
<label class="font-medium text-primary text-sm" for={title}>
|
||||
{title}
|
||||
</label>
|
||||
{#if isEdited}
|
||||
<div
|
||||
transition:fly={{ x: 10, duration: 200, easing: quintOut }}
|
||||
class="rounded-full bg-orange-100 px-2 text-[10px] text-orange-900"
|
||||
>
|
||||
{$t('unsaved_change')}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<p class="text-sm dark:text-immich-dark-fg">{subtitle}</p>
|
||||
<div>
|
||||
<div class="flex h-6.5 place-items-center gap-1">
|
||||
<Label>{title}</Label>
|
||||
{#if isEdited}
|
||||
<div
|
||||
transition:fly={{ x: 10, duration: 200, easing: quintOut }}
|
||||
class="rounded-full bg-orange-100 px-2 text-[10px] text-orange-900"
|
||||
>
|
||||
{$t('unsaved_change')}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
|
||||
<Text size="small" color="muted">{subtitle}</Text>
|
||||
<div class="flex items-center mt-2 max-w-[300px]">
|
||||
<Combobox label={title} hideLabel={true} {selectedOption} {options} placeholder={comboboxPlaceholder} {onSelect} />
|
||||
{@render children?.()}
|
||||
</div>
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
import { defaultLang, langs } from '$lib/constants';
|
||||
import { lang } from '$lib/stores/preferences.store';
|
||||
import { getClosestAvailableLocale, langCodes } from '$lib/utils/i18n';
|
||||
import { Label, Text } from '@immich/ui';
|
||||
import { locale as i18nLocale, t } from 'svelte-i18n';
|
||||
|
||||
interface Props {
|
||||
@@ -34,16 +35,14 @@
|
||||
let closestLanguage = $derived(getClosestAvailableLocale([$lang], langCodes));
|
||||
</script>
|
||||
|
||||
<div class={showSettingDescription ? 'grid grid-cols-2' : ''}>
|
||||
<div class="max-w-[300px]">
|
||||
{#if showSettingDescription}
|
||||
<div>
|
||||
<div class="flex h-6.5 place-items-center gap-1">
|
||||
<label class="font-medium text-primary text-sm" for={$t('language')}>
|
||||
{$t('language')}
|
||||
</label>
|
||||
<Label>{$t('language')}</Label>
|
||||
</div>
|
||||
|
||||
<p class="text-sm dark:text-immich-dark-fg">{$t('language_setting_description')}</p>
|
||||
<Text size="small" color="muted">{$t('language_setting_description')}</Text>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
<script lang="ts">
|
||||
import type { ComboBoxOption } from '$lib/components/shared-components/combobox.svelte';
|
||||
import SettingCombobox from '$lib/components/shared-components/settings/setting-combobox.svelte';
|
||||
import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte';
|
||||
import SettingsLanguageSelector from '$lib/components/shared-components/settings/settings-language-selector.svelte';
|
||||
import { fallbackLocale, locales } from '$lib/constants';
|
||||
import { themeManager } from '$lib/managers/theme-manager.svelte';
|
||||
@@ -15,6 +14,7 @@
|
||||
showDeleteModal,
|
||||
} from '$lib/stores/preferences.store';
|
||||
import { createDateFormatter, findLocale } from '$lib/utils';
|
||||
import { Field, Switch, Text } from '@immich/ui';
|
||||
import { onMount } from 'svelte';
|
||||
import { t } from 'svelte-i18n';
|
||||
import { fade } from 'svelte/transition';
|
||||
@@ -59,81 +59,55 @@
|
||||
|
||||
<section class="my-4">
|
||||
<div in:fade={{ duration: 500 }}>
|
||||
<div class="ms-4 mt-4 flex flex-col gap-4">
|
||||
<div class="ms-4">
|
||||
<SettingSwitch
|
||||
title={$t('theme_selection')}
|
||||
subtitle={$t('theme_selection_description')}
|
||||
checked={themeManager.theme.system}
|
||||
onToggle={(isChecked) => themeManager.setSystem(isChecked)}
|
||||
/>
|
||||
</div>
|
||||
<div class="ms-8 mt-4 flex flex-col gap-4">
|
||||
<Field label={$t('theme_selection')} description={$t('theme_selection_description')}>
|
||||
<Switch checked={themeManager.theme.system} onCheckedChange={(checked) => themeManager.setSystem(checked)} />
|
||||
</Field>
|
||||
|
||||
<div class="ms-4">
|
||||
<SettingsLanguageSelector showSettingDescription />
|
||||
</div>
|
||||
<SettingsLanguageSelector showSettingDescription />
|
||||
|
||||
<Field label={$t('default_locale')} description={$t('default_locale_description')}>
|
||||
<Switch checked={$locale == 'default'} onCheckedChange={handleToggleLocaleBrowser} />
|
||||
<Text size="small" class="mt-2">{selectedDate}</Text>
|
||||
</Field>
|
||||
|
||||
<div class="ms-4">
|
||||
<SettingSwitch
|
||||
title={$t('default_locale')}
|
||||
subtitle={$t('default_locale_description')}
|
||||
checked={$locale == 'default'}
|
||||
onToggle={handleToggleLocaleBrowser}
|
||||
>
|
||||
<p class="mt-2 dark:text-gray-400">{selectedDate}</p>
|
||||
</SettingSwitch>
|
||||
</div>
|
||||
{#if $locale !== 'default'}
|
||||
<div class="ms-4">
|
||||
<SettingCombobox
|
||||
comboboxPlaceholder={$t('searching_locales')}
|
||||
{selectedOption}
|
||||
options={getAllLanguages()}
|
||||
title={$t('custom_locale')}
|
||||
subtitle={$t('custom_locale_description')}
|
||||
onSelect={(combobox) => handleLocaleChange(combobox?.value)}
|
||||
/>
|
||||
</div>
|
||||
<SettingCombobox
|
||||
comboboxPlaceholder={$t('searching_locales')}
|
||||
{selectedOption}
|
||||
options={getAllLanguages()}
|
||||
title={$t('custom_locale')}
|
||||
subtitle={$t('custom_locale_description')}
|
||||
onSelect={(combobox) => handleLocaleChange(combobox?.value)}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<div class="ms-4">
|
||||
<SettingSwitch
|
||||
title={$t('display_original_photos')}
|
||||
subtitle={$t('display_original_photos_setting_description')}
|
||||
bind:checked={$alwaysLoadOriginalFile}
|
||||
/>
|
||||
</div>
|
||||
<div class="ms-4">
|
||||
<SettingSwitch
|
||||
title={$t('video_hover_setting')}
|
||||
subtitle={$t('video_hover_setting_description')}
|
||||
bind:checked={$playVideoThumbnailOnHover}
|
||||
/>
|
||||
</div>
|
||||
<div class="ms-4">
|
||||
<SettingSwitch
|
||||
title={$t('setting_video_viewer_auto_play_title')}
|
||||
subtitle={$t('setting_video_viewer_auto_play_subtitle')}
|
||||
bind:checked={$autoPlayVideo}
|
||||
/>
|
||||
</div>
|
||||
<div class="ms-4">
|
||||
<SettingSwitch title={$t('loop_videos')} subtitle={$t('loop_videos_description')} bind:checked={$loopVideo} />
|
||||
</div>
|
||||
<div class="ms-4">
|
||||
<SettingSwitch
|
||||
title={$t('play_original_video')}
|
||||
subtitle={$t('play_original_video_setting_description')}
|
||||
bind:checked={$alwaysLoadOriginalVideo}
|
||||
/>
|
||||
</div>
|
||||
<div class="ms-4">
|
||||
<SettingSwitch
|
||||
title={$t('permanent_deletion_warning')}
|
||||
subtitle={$t('permanent_deletion_warning_setting_description')}
|
||||
bind:checked={$showDeleteModal}
|
||||
/>
|
||||
</div>
|
||||
<Field label={$t('display_original_photos')} description={$t('display_original_photos_setting_description')}>
|
||||
<Switch bind:checked={$alwaysLoadOriginalFile} />
|
||||
</Field>
|
||||
|
||||
<Field label={$t('video_hover_setting')} description={$t('video_hover_setting_description')}>
|
||||
<Switch bind:checked={$playVideoThumbnailOnHover} />
|
||||
</Field>
|
||||
|
||||
<Field
|
||||
label={$t('setting_video_viewer_auto_play_title')}
|
||||
description={$t('setting_video_viewer_auto_play_subtitle')}
|
||||
>
|
||||
<Switch bind:checked={$autoPlayVideo} />
|
||||
</Field>
|
||||
|
||||
<Field label={$t('loop_videos')} description={$t('loop_videos_description')}>
|
||||
<Switch bind:checked={$loopVideo} />
|
||||
</Field>
|
||||
|
||||
<Field label={$t('play_original_video')} description={$t('play_original_video_setting_description')}>
|
||||
<Switch bind:checked={$alwaysLoadOriginalVideo} />
|
||||
</Field>
|
||||
|
||||
<Field label={$t('permanent_deletion_warning')} description={$t('permanent_deletion_warning_setting_description')}
|
||||
><Switch bind:checked={$showDeleteModal} />
|
||||
</Field>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
Reference in New Issue
Block a user