1
0
Fork 0
mirror of https://github.com/immich-app/immich.git synced 2025-01-27 22:22:45 +01:00

feat(web): translations containing html ()

* feat(web): translations containing html

* add tests and more translations

* more translations

* rename FormatTags --> FormatMessage

* update version_announcement_message
This commit is contained in:
Michel Heusschen 2024-06-21 22:08:36 +02:00 committed by GitHub
parent 1129020159
commit b3252ffdac
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 313 additions and 101 deletions

2
web/package-lock.json generated
View file

@ -9,6 +9,7 @@
"version": "1.106.4", "version": "1.106.4",
"license": "GNU Affero General Public License version 3", "license": "GNU Affero General Public License version 3",
"dependencies": { "dependencies": {
"@formatjs/icu-messageformat-parser": "^2.7.8",
"@immich/sdk": "file:../open-api/typescript-sdk", "@immich/sdk": "file:../open-api/typescript-sdk",
"@mdi/js": "^7.4.47", "@mdi/js": "^7.4.47",
"@photo-sphere-viewer/core": "^5.7.1", "@photo-sphere-viewer/core": "^5.7.1",
@ -19,6 +20,7 @@
"copy-image-clipboard": "^2.1.2", "copy-image-clipboard": "^2.1.2",
"dom-to-image": "^2.6.0", "dom-to-image": "^2.6.0",
"handlebars": "^4.7.8", "handlebars": "^4.7.8",
"intl-messageformat": "^10.5.14",
"justified-layout": "^4.1.0", "justified-layout": "^4.1.0",
"lodash-es": "^4.17.21", "lodash-es": "^4.17.21",
"luxon": "^3.4.4", "luxon": "^3.4.4",

View file

@ -61,6 +61,7 @@
}, },
"type": "module", "type": "module",
"dependencies": { "dependencies": {
"@formatjs/icu-messageformat-parser": "^2.7.8",
"@immich/sdk": "file:../open-api/typescript-sdk", "@immich/sdk": "file:../open-api/typescript-sdk",
"@mdi/js": "^7.4.47", "@mdi/js": "^7.4.47",
"@photo-sphere-viewer/core": "^5.7.1", "@photo-sphere-viewer/core": "^5.7.1",
@ -71,6 +72,7 @@
"copy-image-clipboard": "^2.1.2", "copy-image-clipboard": "^2.1.2",
"dom-to-image": "^2.6.0", "dom-to-image": "^2.6.0",
"handlebars": "^4.7.8", "handlebars": "^4.7.8",
"intl-messageformat": "^10.5.14",
"justified-layout": "^4.1.0", "justified-layout": "^4.1.0",
"lodash-es": "^4.17.21", "lodash-es": "^4.17.21",
"luxon": "^3.4.4", "luxon": "^3.4.4",

View file

@ -5,7 +5,8 @@
import { serverConfig } from '$lib/stores/server-config.store'; import { serverConfig } from '$lib/stores/server-config.store';
import { createEventDispatcher } from 'svelte'; import { createEventDispatcher } from 'svelte';
import Checkbox from '$lib/components/elements/checkbox.svelte'; import Checkbox from '$lib/components/elements/checkbox.svelte';
import { t } from 'svelte-i18n'; import { json, t } from 'svelte-i18n';
import FormatMessage from '$lib/components/i18n/format-message.svelte';
export let user: UserResponseDto; export let user: UserResponseDto;
@ -54,12 +55,19 @@
<div class="flex flex-col gap-4"> <div class="flex flex-col gap-4">
{#if forceDelete} {#if forceDelete}
<p> <p>
<b>{user.name}</b>'s account and assets will be queued for permanent deletion <b>immediately</b>. <FormatMessage message={$json('admin.user_delete_immediately')} values={{ user: user.name }} let:message>
<b>{message}</b>
</FormatMessage>
</p> </p>
{:else} {:else}
<p> <p>
<b>{user.name}</b>'s account and assets will be scheduled for permanent deletion in {$serverConfig.userDeleteDelay} <FormatMessage
days. message={$json('admin.user_delete_delay')}
values={{ user: user.name, delay: $serverConfig.userDeleteDelay }}
let:message
>
<b>{message}</b>
</FormatMessage>
</p> </p>
{/if} {/if}

View file

@ -1,13 +1,18 @@
<script lang="ts"> <script lang="ts">
import FormatMessage from '$lib/components/i18n/format-message.svelte';
import { AppRoute, OpenSettingQueryParameterValue, QueryParameter } from '$lib/constants'; import { AppRoute, OpenSettingQueryParameterValue, QueryParameter } from '$lib/constants';
import { t } from 'svelte-i18n'; import { json, t } from 'svelte-i18n';
</script> </script>
Apply the current <FormatMessage
<a message={$json('admin.storage_template_migration_description')}
href="{AppRoute.ADMIN_SETTINGS}?{QueryParameter.IS_OPEN}={OpenSettingQueryParameterValue.STORAGE_TEMPLATE}" values={{ template: $t('admin.storage_template_settings') }}
class="text-immich-primary dark:text-immich-dark-primary" let:message
> >
{$t('admin.storage_template_settings')} <a
</a> href="{AppRoute.ADMIN_SETTINGS}?{QueryParameter.IS_OPEN}={OpenSettingQueryParameterValue.STORAGE_TEMPLATE}"
to previously uploaded assets class="text-immich-primary dark:text-immich-dark-primary"
>
{message}
</a>
</FormatMessage>

View file

@ -1,9 +1,10 @@
<script lang="ts"> <script lang="ts">
import FormatMessage from '$lib/components/i18n/format-message.svelte';
import ConfirmDialog from '$lib/components/shared-components/dialog/confirm-dialog.svelte'; import ConfirmDialog from '$lib/components/shared-components/dialog/confirm-dialog.svelte';
import { handleError } from '$lib/utils/handle-error'; import { handleError } from '$lib/utils/handle-error';
import { restoreUserAdmin, type UserResponseDto } from '@immich/sdk'; import { restoreUserAdmin, type UserResponseDto } from '@immich/sdk';
import { createEventDispatcher } from 'svelte'; import { createEventDispatcher } from 'svelte';
import { t } from 'svelte-i18n'; import { json, t } from 'svelte-i18n';
export let user: UserResponseDto; export let user: UserResponseDto;
@ -36,6 +37,10 @@
onCancel={() => dispatch('cancel')} onCancel={() => dispatch('cancel')}
> >
<svelte:fragment slot="prompt"> <svelte:fragment slot="prompt">
<p><b>{user.name}</b>'s account will be restored.</p> <p>
<FormatMessage message={$json('admin.user_restore_description')} values={{ user: user.name }} let:message>
<b>{message}</b>
</FormatMessage>
</p>
</svelte:fragment> </svelte:fragment>
</ConfirmDialog> </ConfirmDialog>

View file

@ -11,7 +11,8 @@
import { createEventDispatcher } from 'svelte'; import { createEventDispatcher } from 'svelte';
import { fade } from 'svelte/transition'; import { fade } from 'svelte/transition';
import type { SettingsEventType } from '../admin-settings'; import type { SettingsEventType } from '../admin-settings';
import { t } from 'svelte-i18n'; import { json, t } from 'svelte-i18n';
import FormatMessage from '$lib/components/i18n/format-message.svelte';
export let savedConfig: SystemConfigDto; export let savedConfig: SystemConfigDto;
export let defaultConfig: SystemConfigDto; export let defaultConfig: SystemConfigDto;
@ -52,15 +53,16 @@
<div class="flex flex-col gap-4"> <div class="flex flex-col gap-4">
<p>Are you sure you want to disable all login methods? Login will be completely disabled.</p> <p>Are you sure you want to disable all login methods? Login will be completely disabled.</p>
<p> <p>
To re-enable, use a <FormatMessage message={$json('admin.authentication_settings_reenable')} let:message>
<a <a
href="https://immich.app/docs/administration/server-commands" href="https://immich.app/docs/administration/server-commands"
rel="noreferrer" rel="noreferrer"
target="_blank" target="_blank"
class="underline" class="underline"
> >
Server Command</a {message}
>. </a>
</FormatMessage>
</p> </p>
</div> </div>
</svelte:fragment> </svelte:fragment>
@ -78,12 +80,16 @@
> >
<div class="ml-4 mt-4 flex flex-col gap-4"> <div class="ml-4 mt-4 flex flex-col gap-4">
<p class="text-sm dark:text-immich-dark-fg"> <p class="text-sm dark:text-immich-dark-fg">
For more details about this feature, refer to the <a <FormatMessage message={$json('admin.oauth_settings_more_details')} let:message>
href="https://immich.app/docs/administration/oauth" <a
class="underline" href="https://immich.app/docs/administration/oauth"
target="_blank" class="underline"
rel="noreferrer">docs</a target="_blank"
>. rel="noreferrer"
>
{message}
</a>
</FormatMessage>
</p> </p>
<SettingSwitch <SettingSwitch

View file

@ -22,7 +22,8 @@
import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte'; import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte';
import SettingCheckboxes from '$lib/components/shared-components/settings/setting-checkboxes.svelte'; import SettingCheckboxes from '$lib/components/shared-components/settings/setting-checkboxes.svelte';
import SettingButtonsRow from '$lib/components/shared-components/settings/setting-buttons-row.svelte'; import SettingButtonsRow from '$lib/components/shared-components/settings/setting-buttons-row.svelte';
import { t } from 'svelte-i18n'; import { json, t } from 'svelte-i18n';
import FormatMessage from '$lib/components/i18n/format-message.svelte';
export let savedConfig: SystemConfigDto; export let savedConfig: SystemConfigDto;
export let defaultConfig: SystemConfigDto; export let defaultConfig: SystemConfigDto;
@ -38,17 +39,21 @@
<div class="ml-4 mt-4 flex flex-col gap-4"> <div class="ml-4 mt-4 flex flex-col gap-4">
<p class="text-sm dark:text-immich-dark-fg"> <p class="text-sm dark:text-immich-dark-fg">
<Icon path={mdiHelpCircleOutline} class="inline" size="15" /> <Icon path={mdiHelpCircleOutline} class="inline" size="15" />
To learn more about the terminology used here, refer to FFmpeg documentation for <FormatMessage message={$json('admin.transcoding_codecs_learn_more')} let:tag let:message>
<a href="https://trac.ffmpeg.org/wiki/Encode/H.264" class="underline" target="_blank" rel="noreferrer" {#if tag === 'h264-link'}
>H.264 codec</a <a href="https://trac.ffmpeg.org/wiki/Encode/H.264" class="underline" target="_blank" rel="noreferrer">
>, {message}
<a href="https://trac.ffmpeg.org/wiki/Encode/H.265" class="underline" target="_blank" rel="noreferrer" </a>
>{$t('admin.transcoding_hevc_codec')}</a {:else if tag === 'hevc-link'}
> <a href="https://trac.ffmpeg.org/wiki/Encode/H.265" class="underline" target="_blank" rel="noreferrer">
and {message}
<a href="https://trac.ffmpeg.org/wiki/Encode/VP9" class="underline" target="_blank" rel="noreferrer" </a>
>VP9 codec</a {:else if tag === 'vp9-link'}
>. <a href="https://trac.ffmpeg.org/wiki/Encode/VP9" class="underline" target="_blank" rel="noreferrer">
{message}
</a>
{/if}
</FormatMessage>
</p> </p>
<SettingInputField <SettingInputField

View file

@ -10,7 +10,8 @@
} from '$lib/components/shared-components/settings/setting-input-field.svelte'; } from '$lib/components/shared-components/settings/setting-input-field.svelte';
import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte'; import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte';
import SettingButtonsRow from '$lib/components/shared-components/settings/setting-buttons-row.svelte'; import SettingButtonsRow from '$lib/components/shared-components/settings/setting-buttons-row.svelte';
import { t } from 'svelte-i18n'; import { json, t } from 'svelte-i18n';
import FormatMessage from '$lib/components/i18n/format-message.svelte';
export let savedConfig: SystemConfigDto; export let savedConfig: SystemConfigDto;
export let defaultConfig: SystemConfigDto; export let defaultConfig: SystemConfigDto;
@ -99,12 +100,11 @@
> >
<svelte:fragment slot="desc"> <svelte:fragment slot="desc">
<p class="text-sm dark:text-immich-dark-fg"> <p class="text-sm dark:text-immich-dark-fg">
Set the scanning interval using the cron format. For more information please refer to e.g. <a <FormatMessage message={$json('admin.library_cron_expression_description')} let:message>
href="https://crontab.guru" <a href="https://crontab.guru" class="underline" target="_blank" rel="noreferrer">
class="underline" {message}
target="_blank" </a>
rel="noreferrer">{$t('admin.crontab_guru')}</a </FormatMessage>
>
</p> </p>
</svelte:fragment> </svelte:fragment>
</SettingInputField> </SettingInputField>

View file

@ -12,7 +12,8 @@
import SettingSelect from '$lib/components/shared-components/settings/setting-select.svelte'; import SettingSelect from '$lib/components/shared-components/settings/setting-select.svelte';
import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte'; import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte';
import { featureFlags } from '$lib/stores/server-config.store'; import { featureFlags } from '$lib/stores/server-config.store';
import { t } from 'svelte-i18n'; import { json, t } from 'svelte-i18n';
import FormatMessage from '$lib/components/i18n/format-message.svelte';
export let savedConfig: SystemConfigDto; export let savedConfig: SystemConfigDto;
export let defaultConfig: SystemConfigDto; export let defaultConfig: SystemConfigDto;
@ -70,8 +71,9 @@
isEdited={config.machineLearning.clip.modelName !== savedConfig.machineLearning.clip.modelName} isEdited={config.machineLearning.clip.modelName !== savedConfig.machineLearning.clip.modelName}
> >
<p slot="desc" class="immich-form-label pb-2 text-sm"> <p slot="desc" class="immich-form-label pb-2 text-sm">
The name of a CLIP model listed <a href="https://huggingface.co/immich-app"><u>here</u></a>. Note that you <FormatMessage message={$json('admin.machine_learning_clip_model_description')} let:message>
must re-run the 'Smart Search' job for all images upon changing a model. <a href="https://huggingface.co/immich-app"><u>{message}</u></a>
</FormatMessage>
</p> </p>
</SettingInputField> </SettingInputField>
</div> </div>

View file

@ -20,7 +20,8 @@
SettingInputFieldType, SettingInputFieldType,
} from '$lib/components/shared-components/settings/setting-input-field.svelte'; } from '$lib/components/shared-components/settings/setting-input-field.svelte';
import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte'; import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte';
import { t } from 'svelte-i18n'; import { json, t } from 'svelte-i18n';
import FormatMessage from '$lib/components/i18n/format-message.svelte';
export let savedConfig: SystemConfigDto; export let savedConfig: SystemConfigDto;
export let defaultConfig: SystemConfigDto; export let defaultConfig: SystemConfigDto;
@ -88,21 +89,27 @@
<section class="dark:text-immich-dark-fg mt-2"> <section class="dark:text-immich-dark-fg mt-2">
<div in:fade={{ duration: 500 }} class="mx-4 flex flex-col gap-4 py-4"> <div in:fade={{ duration: 500 }} class="mx-4 flex flex-col gap-4 py-4">
<p class="text-sm dark:text-immich-dark-fg"> <p class="text-sm dark:text-immich-dark-fg">
For more details about this feature, refer to the <a <FormatMessage message={$json('admin.storage_template_more_details')} let:tag let:message>
href="https://immich.app/docs/administration/storage-template" {#if tag === 'template-link'}
class="underline" <a
target="_blank" href="https://immich.app/docs/administration/storage-template"
rel="noreferrer" class="underline"
>Storage Template target="_blank"
</a> rel="noreferrer"
and its >
<a {message}
href="https://immich.app/docs/administration/backup-and-restore#asset-types-and-storage-locations" </a>
class="underline" {:else if tag === 'implications-link'}
target="_blank" <a
rel="noreferrer" href="https://immich.app/docs/administration/backup-and-restore#asset-types-and-storage-locations"
>implications class="underline"
</a> target="_blank"
rel="noreferrer"
>
{message}
</a>
{/if}
</FormatMessage>
</p> </p>
</div> </div>
{#await getTemplateOptions() then} {#await getTemplateOptions() then}
@ -153,15 +160,23 @@
</div> </div>
<p class="text-sm"> <p class="text-sm">
Approximately path length limit : <span <FormatMessage
class="font-semibold text-immich-primary dark:text-immich-dark-primary" message={$json('admin.storage_template_path_length')}
>{parsedTemplate().length + $user.id.length + 'UPLOAD_LOCATION'.length}</span values={{ length: parsedTemplate().length + $user.id.length + 'UPLOAD_LOCATION'.length, limit: 260 }}
>/260 let:message
>
<span class="font-semibold text-immich-primary dark:text-immich-dark-primary">{message}</span>
</FormatMessage>
</p> </p>
<p class="text-sm"> <p class="text-sm">
<code class="text-immich-primary dark:text-immich-dark-primary">{$user.storageLabel || $user.id}</code> is the <FormatMessage
user's Storage Label message={$json('admin.storage_template_user_label')}
values={{ label: $user.storageLabel || $user.id }}
let:message
>
<code class="text-immich-primary dark:text-immich-dark-primary">{message}</code>
</FormatMessage>
</p> </p>
<p class="p-4 py-2 mt-2 text-xs bg-gray-200 rounded-lg dark:bg-gray-700 dark:text-immich-dark-fg"> <p class="p-4 py-2 mt-2 text-xs bg-gray-200 rounded-lg dark:bg-gray-700 dark:text-immich-dark-fg">
@ -213,20 +228,15 @@
<h3 class="text-base font-medium text-immich-primary dark:text-immich-dark-primary">{$t('notes')}</h3> <h3 class="text-base font-medium text-immich-primary dark:text-immich-dark-primary">{$t('notes')}</h3>
<section class="flex flex-col gap-2"> <section class="flex flex-col gap-2">
<p> <p>
Template changes will only apply to new assets. To retroactively apply the template to previously <FormatMessage
uploaded assets, run the message={$json('admin.storage_template_migration_info')}
<a href={AppRoute.ADMIN_JOBS} class="text-immich-primary dark:text-immich-dark-primary" values={{ job: $t('admin.storage_template_migration_job') }}
>{$t('admin.storage_template_migration_job')}</a let:message
>.
</p>
<p>
The template variable <span class="font-mono">{`{{album}}`}</span> will always be empty for new
assets, so manually running the
<a href={AppRoute.ADMIN_JOBS} class="text-immich-primary dark:text-immich-dark-primary"
>{$t('admin.storage_template_migration_job')}</a
> >
is required in order to successfully use the variable. <a href={AppRoute.ADMIN_JOBS} class="text-immich-primary dark:text-immich-dark-primary">
{message}
</a>
</FormatMessage>
</p> </p>
</section> </section>
</div> </div>

View file

@ -0,0 +1,78 @@
import FormatTagB from '$lib/components/i18n/__test__/format-tag-b.svelte';
import FormatMessage from '$lib/components/i18n/format-message.svelte';
import '@testing-library/jest-dom';
import { render, screen } from '@testing-library/svelte';
import { init, json, locale, register, waitLocale } from 'svelte-i18n';
import { get } from 'svelte/store';
import { describe } from 'vitest';
describe('FormatMessage component', () => {
let $json: (id: string, locale?: string | undefined) => unknown;
beforeAll(async () => {
register('en', () =>
Promise.resolve({
hello: 'Hello {name}',
html: 'Hello <b>{name}</b>',
plural: 'You have <b>{count, plural, one {# item} other {# items}}</b>',
xss: '<image/src/onerror=prompt(8)>',
}),
);
await init({ fallbackLocale: 'en' });
await waitLocale('en');
$json = get(json);
});
it('formats a plain text message', () => {
render(FormatMessage, {
message: $json('hello'),
values: { name: 'test' },
});
expect(screen.getByText('Hello test')).toBeInTheDocument();
});
it('throws an error when locale is empty', async () => {
await locale.set(undefined);
expect(() => render(FormatMessage, { message: undefined })).toThrowError();
await locale.set('en');
});
it('shows raw message when value is empty', () => {
render(FormatMessage, {
message: $json('hello'),
});
expect(screen.getByText('Hello {name}')).toBeInTheDocument();
});
it('shows message when slot is empty', () => {
render(FormatMessage, {
message: $json('html'),
values: { name: 'test' },
});
expect(screen.getByText('Hello test')).toBeInTheDocument();
});
it('renders a message with html', () => {
const { container } = render(FormatTagB, {
message: $json('html'),
values: { name: 'test' },
});
expect(container.innerHTML).toBe('Hello <strong>test</strong>');
});
it('renders a message with html and plural', () => {
const { container } = render(FormatTagB, {
message: $json('plural'),
values: { count: 1 },
});
expect(container.innerHTML).toBe('You have <strong>1 item</strong>');
});
it('protects agains XSS injection', () => {
render(FormatMessage, {
message: $json('xss'),
});
expect(screen.getByText('<image/src/onerror=prompt(8)>')).toBeInTheDocument();
});
});

View file

@ -0,0 +1,13 @@
<script lang="ts">
import FormatMessage from '../format-message.svelte';
import type { ComponentProps } from 'svelte';
export let message: unknown;
export let values: ComponentProps<FormatMessage>['values'];
</script>
<FormatMessage {message} {values} let:tag let:message>
{#if tag === 'b'}
<strong>{message}</strong>
{/if}
</FormatMessage>

View file

@ -0,0 +1,57 @@
<script lang="ts">
import { IntlMessageFormat, type FormatXMLElementFn, type PrimitiveType } from 'intl-messageformat';
import { TYPE, type MessageFormatElement } from '@formatjs/icu-messageformat-parser';
import { locale as i18nLocale } from 'svelte-i18n';
type InterpolationValues = Record<string, PrimitiveType | FormatXMLElementFn<unknown>>;
export let message: unknown;
export let values: InterpolationValues = {};
const getLocale = (locale?: string | null) => {
if (locale == null) {
throw new Error('Cannot format a message without first setting the initial locale.');
}
return locale;
};
const getElements = (message: unknown, locale: string): MessageFormatElement[] => {
return new IntlMessageFormat(message as string, locale, undefined, {
ignoreTag: false,
}).getAst();
};
const getParts = (message: unknown, locale: string) => {
try {
const elements = getElements(message, locale);
return elements.map((element) => {
const isTag = element.type === TYPE.tag;
return {
tag: isTag ? element.value : undefined,
message: new IntlMessageFormat(isTag ? element.children : [element], locale, undefined, {
ignoreTag: true,
}).format(values) as string,
};
});
} catch (error) {
if (error instanceof Error) {
console.warn(`Message "${message}" has syntax error:`, error.message);
}
return [{ message: message as string, tag: undefined }];
}
};
$: locale = getLocale($i18nLocale);
$: parts = getParts(message, locale);
</script>
{#each parts as { tag, message }}
{#if tag}
<slot {tag} {message}>{message}</slot>
{:else}
{message}
{/if}
{/each}

View file

@ -9,7 +9,8 @@
import Button from '../elements/buttons/button.svelte'; import Button from '../elements/buttons/button.svelte';
import Icon from '../elements/icon.svelte'; import Icon from '../elements/icon.svelte';
import OnboardingCard from './onboarding-card.svelte'; import OnboardingCard from './onboarding-card.svelte';
import { t } from 'svelte-i18n'; import { json, t } from 'svelte-i18n';
import FormatMessage from '$lib/components/i18n/format-message.svelte';
const dispatch = createEventDispatcher<{ const dispatch = createEventDispatcher<{
done: void; done: void;
@ -29,9 +30,9 @@
</p> </p>
<p> <p>
When enabled, this feature will auto-organize files based on a user-defined template. Due to stability issues the <FormatMessage message={$json('admin.storage_template_onboarding_description')} let:message>
feature has been turned off by default. For more information, please see the <a class="underline" href="https://immich.app/docs/administration/storage-template">{message}</a>
<a class="underline" href="https://immich.app/docs/administration/storage-template">documentation</a>. </FormatMessage>
</p> </p>
{#if config && $user} {#if config && $user}

View file

@ -3,7 +3,8 @@
import type { ServerVersionResponseDto } from '@immich/sdk'; import type { ServerVersionResponseDto } from '@immich/sdk';
import Button from '../elements/buttons/button.svelte'; import Button from '../elements/buttons/button.svelte';
import FullScreenModal from './full-screen-modal.svelte'; import FullScreenModal from './full-screen-modal.svelte';
import { t } from 'svelte-i18n'; import { json, t } from 'svelte-i18n';
import FormatMessage from '$lib/components/i18n/format-message.svelte';
let showModal = false; let showModal = false;
@ -36,14 +37,17 @@
{#if showModal} {#if showModal}
<FullScreenModal title="🎉 NEW VERSION AVAILABLE" onClose={() => (showModal = false)}> <FullScreenModal title="🎉 NEW VERSION AVAILABLE" onClose={() => (showModal = false)}>
<div> <div>
Hi friend, there is a new version of the application please take your time to visit the <FormatMessage message={$json('version_announcement_message')} let:tag let:message>
<span class="font-medium underline" {#if tag === 'link'}
><a href="https://github.com/immich-app/immich/releases/latest" target="_blank" rel="noopener noreferrer" <span class="font-medium underline">
>release notes</a <a href="https://github.com/immich-app/immich/releases/latest" target="_blank" rel="noopener noreferrer">
></span {message}
> </a>
and ensure your <code>docker-compose</code>, and <code>.env</code> setup is up-to-date to prevent any misconfigurations, </span>
especially if you use WatchTower or any mechanism that handles updating your application automatically. {:else if tag === 'code'}
<code>{message}</code>
{/if}
</FormatMessage>
</div> </div>
<div class="mt-4 font-medium">Your friend, Alex</div> <div class="mt-4 font-medium">Your friend, Alex</div>

View file

@ -25,6 +25,7 @@
"add_exclusion_pattern_description": "Add exclusion patterns. Globbing using *, **, and ? is supported. To ignore all files in any directory named \"Raw\", use \"**/Raw/**\". To ignore all files ending in \".tif\", use \"**/*.tif\". To ignore an absolute path, use \"/path/to/ignore/**\".", "add_exclusion_pattern_description": "Add exclusion patterns. Globbing using *, **, and ? is supported. To ignore all files in any directory named \"Raw\", use \"**/Raw/**\". To ignore all files ending in \".tif\", use \"**/*.tif\". To ignore an absolute path, use \"/path/to/ignore/**\".",
"authentication_settings": "Authentication Settings", "authentication_settings": "Authentication Settings",
"authentication_settings_description": "Manage password, OAuth, and other authentication settings", "authentication_settings_description": "Manage password, OAuth, and other authentication settings",
"authentication_settings_reenable": "To re-enable, use a <link>Server Command</link>.",
"background_task_job": "Background Tasks", "background_task_job": "Background Tasks",
"check_all": "Check All", "check_all": "Check All",
"config_set_by_file": "Config is currently set by a config file", "config_set_by_file": "Config is currently set by a config file",
@ -33,7 +34,6 @@
"confirm_email_below": "To confirm, type \"{email}\" below", "confirm_email_below": "To confirm, type \"{email}\" below",
"confirm_reprocess_all_faces": "Are you sure you want to reprocess all faces? This will also clear named people.", "confirm_reprocess_all_faces": "Are you sure you want to reprocess all faces? This will also clear named people.",
"confirm_user_password_reset": "Are you sure you want to reset {user}'s password?", "confirm_user_password_reset": "Are you sure you want to reset {user}'s password?",
"crontab_guru": "Crontab Guru",
"disable_login": "Disable login", "disable_login": "Disable login",
"duplicate_detection_job_description": "Run machine learning on assets to detect similar images. Relies on Smart Search", "duplicate_detection_job_description": "Run machine learning on assets to detect similar images. Relies on Smart Search",
"exclusion_pattern_description": "Exclusion patterns lets you ignore files and folders when scanning your library. This is useful if you have folders that contain files you don't want to import, such as RAW files.", "exclusion_pattern_description": "Exclusion patterns lets you ignore files and folders when scanning your library. This is useful if you have folders that contain files you don't want to import, such as RAW files.",
@ -68,6 +68,7 @@
"jobs_failed": "{jobCount} failed", "jobs_failed": "{jobCount} failed",
"library_created": "Created library: {library}", "library_created": "Created library: {library}",
"library_cron_expression": "Cron expression", "library_cron_expression": "Cron expression",
"library_cron_expression_description": "Set the scanning interval using the cron format. For more information please refer to e.g. <link>Crontab Guru</link>",
"library_cron_expression_presets": "Cron expression presets", "library_cron_expression_presets": "Cron expression presets",
"library_deleted": "Library deleted", "library_deleted": "Library deleted",
"library_import_path_description": "Specify a folder to import. This folder, including subfolders, will be scanned for images and videos.", "library_import_path_description": "Specify a folder to import. This folder, including subfolders, will be scanned for images and videos.",
@ -84,6 +85,7 @@
"logging_level_description": "When enabled, what log level to use.", "logging_level_description": "When enabled, what log level to use.",
"logging_settings": "Logging", "logging_settings": "Logging",
"machine_learning_clip_model": "CLIP model", "machine_learning_clip_model": "CLIP model",
"machine_learning_clip_model_description": "The name of a CLIP model listed <link>here</link>. Note that you must re-run the 'Smart Search' job for all images upon changing a model.",
"machine_learning_duplicate_detection": "Duplicate Detection", "machine_learning_duplicate_detection": "Duplicate Detection",
"machine_learning_duplicate_detection_enabled": "Enable duplicate detection", "machine_learning_duplicate_detection_enabled": "Enable duplicate detection",
"machine_learning_duplicate_detection_enabled_description": "If disabled, exactly identical assets will still be de-duplicated.", "machine_learning_duplicate_detection_enabled_description": "If disabled, exactly identical assets will still be de-duplicated.",
@ -162,6 +164,7 @@
"oauth_scope": "Scope", "oauth_scope": "Scope",
"oauth_settings": "OAuth", "oauth_settings": "OAuth",
"oauth_settings_description": "Manage OAuth login settings", "oauth_settings_description": "Manage OAuth login settings",
"oauth_settings_more_details": "For more details about this feature, refer to the <link>docs</link>.",
"oauth_signing_algorithm": "Signing algorithm", "oauth_signing_algorithm": "Signing algorithm",
"oauth_storage_label_claim": "Storage label claim", "oauth_storage_label_claim": "Storage label claim",
"oauth_storage_label_claim_description": "Automatically set the user's storage label to the value of this claim.", "oauth_storage_label_claim_description": "Automatically set the user's storage label to the value of this claim.",
@ -201,9 +204,15 @@
"storage_template_hash_verification_enabled": "Hash verification failed", "storage_template_hash_verification_enabled": "Hash verification failed",
"storage_template_hash_verification_enabled_description": "Enables hash verification, don't disable this unless you're certain of the implications", "storage_template_hash_verification_enabled_description": "Enables hash verification, don't disable this unless you're certain of the implications",
"storage_template_migration": "Storage template migration", "storage_template_migration": "Storage template migration",
"storage_template_migration_description": "Apply the current <link>{template}</link> to previously uploaded assets",
"storage_template_migration_info": "Template changes will only apply to new assets. To retroactively apply the template to previously uploaded assets, run the <link>{job}</link>.",
"storage_template_migration_job": "Storage Migration Job", "storage_template_migration_job": "Storage Migration Job",
"storage_template_more_details": "For more details about this feature, refer to the <template-link>Storage Template</template-link> and its <implications-link>implications</implications-link>",
"storage_template_onboarding_description": "When enabled, this feature will auto-organize files based on a user-defined template. Due to stability issues the feature has been turned off by default. For more information, please see the <link>documentation</link>.",
"storage_template_path_length": "Approximate path length limit: <b>{length, number}</b>/{limit, number}",
"storage_template_settings": "Storage Template", "storage_template_settings": "Storage Template",
"storage_template_settings_description": "Manage the folder structure and file name of the upload asset", "storage_template_settings_description": "Manage the folder structure and file name of the upload asset",
"storage_template_user_label": "<code>{label}</code> is the user's Storage Label",
"system_settings": "System Settings", "system_settings": "System Settings",
"theme_custom_css_settings": "Custom CSS", "theme_custom_css_settings": "Custom CSS",
"theme_custom_css_settings_description": "Cascading Style Sheets allow the design of Immich to be customized.", "theme_custom_css_settings_description": "Cascading Style Sheets allow the design of Immich to be customized.",
@ -226,6 +235,7 @@
"transcoding_audio_codec": "Audio codec", "transcoding_audio_codec": "Audio codec",
"transcoding_audio_codec_description": "Opus is the highest quality option, but has lower compatibility with old devices or software.", "transcoding_audio_codec_description": "Opus is the highest quality option, but has lower compatibility with old devices or software.",
"transcoding_bitrate_description": "Videos higher than max bitrate or not in an accepted format", "transcoding_bitrate_description": "Videos higher than max bitrate or not in an accepted format",
"transcoding_codecs_learn_more": "To learn more about the terminology used here, refer to FFmpeg documentation for <h264-link>H.264 codec</h264-link>, <hevc-link>HEVC codec</hevc-link> and <vp9-link>VP9 codec</vp9-link>.",
"transcoding_constant_quality_mode": "Constant quality mode", "transcoding_constant_quality_mode": "Constant quality mode",
"transcoding_constant_quality_mode_description": "ICQ is better than CQP, but some hardware acceleration devices do not support this mode. Setting this option will prefer the specified mode when using quality-based encoding. Ignored by NVENC as it does not support ICQ.", "transcoding_constant_quality_mode_description": "ICQ is better than CQP, but some hardware acceleration devices do not support this mode. Setting this option will prefer the specified mode when using quality-based encoding. Ignored by NVENC as it does not support ICQ.",
"transcoding_constant_rate_factor": "Constant rate factor (-crf)", "transcoding_constant_rate_factor": "Constant rate factor (-crf)",
@ -275,11 +285,14 @@
"trash_settings_description": "Manage trash settings", "trash_settings_description": "Manage trash settings",
"untracked_files": "Untracked Files", "untracked_files": "Untracked Files",
"untracked_files_description": "These files are not tracked by the application. They can be the results of failed moves, interrupted uploads, or left behind due to a bug", "untracked_files_description": "These files are not tracked by the application. They can be the results of failed moves, interrupted uploads, or left behind due to a bug",
"user_delete_delay": "<b>{user}</b>'s account and assets will be scheduled for permanent deletion in {delay, plural, one {# day} other {# days}}.",
"user_delete_delay_settings": "Delete delay", "user_delete_delay_settings": "Delete delay",
"user_delete_delay_settings_description": "Number of days after removal to permanently delete a user's account and assets. The user deletion job runs at midnight to check for users that are ready for deletion. Changes to this setting will be evaluated at the next execution.", "user_delete_delay_settings_description": "Number of days after removal to permanently delete a user's account and assets. The user deletion job runs at midnight to check for users that are ready for deletion. Changes to this setting will be evaluated at the next execution.",
"user_delete_immediately": "<b>{user}</b>'s account and assets will be queued for permanent deletion <b>immediately</b>.",
"user_management": "User Management", "user_management": "User Management",
"user_password_has_been_reset": "The user's password has been reset:", "user_password_has_been_reset": "The user's password has been reset:",
"user_password_reset_description": "Please provide the temporary password to the user and inform them they will need to change the password at their next login.", "user_password_reset_description": "Please provide the temporary password to the user and inform them they will need to change the password at their next login.",
"user_restore_description": "<b>{user}</b>'s account will be restored.",
"user_settings": "User Settings", "user_settings": "User Settings",
"user_settings_description": "Manage user settings", "user_settings_description": "Manage user settings",
"user_successfully_removed": "User {email} has been successfully removed.", "user_successfully_removed": "User {email} has been successfully removed.",
@ -902,6 +915,7 @@
"validate": "Validate", "validate": "Validate",
"variables": "Variables", "variables": "Variables",
"version": "Version", "version": "Version",
"version_announcement_message": "Hi friend, there is a new version of the application please take your time to visit the <link>release notes</link> and ensure your <code>docker-compose.yml</code>, and <code>.env</code> setup is up-to-date to prevent any misconfigurations, especially if you use WatchTower or any mechanism that handles updating your application automatically.",
"video": "Video", "video": "Video",
"video_hover_setting": "Play video thumbnail on hover", "video_hover_setting": "Play video thumbnail on hover",
"video_hover_setting_description": "Play video thumbnail when mouse is hovering over item. Even when disabled, playback can be started by hovering over the play icon.", "video_hover_setting_description": "Play video thumbnail when mouse is hovering over item. Even when disabled, playback can be started by hovering over the play icon.",