1
0
mirror of https://github.com/immich-app/immich.git synced 2024-11-27 09:21:05 +02:00

feat(web): translations containing html (#10491)

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

View File

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

View File

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

View File

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

View File

@ -1,9 +1,10 @@
<script lang="ts">
import FormatMessage from '$lib/components/i18n/format-message.svelte';
import ConfirmDialog from '$lib/components/shared-components/dialog/confirm-dialog.svelte';
import { handleError } from '$lib/utils/handle-error';
import { restoreUserAdmin, type UserResponseDto } from '@immich/sdk';
import { createEventDispatcher } from 'svelte';
import { t } from 'svelte-i18n';
import { json, t } from 'svelte-i18n';
export let user: UserResponseDto;
@ -36,6 +37,10 @@
onCancel={() => dispatch('cancel')}
>
<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>
</ConfirmDialog>

View File

@ -11,7 +11,8 @@
import { createEventDispatcher } from 'svelte';
import { fade } from 'svelte/transition';
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 defaultConfig: SystemConfigDto;
@ -52,15 +53,16 @@
<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>
To re-enable, use a
<a
href="https://immich.app/docs/administration/server-commands"
rel="noreferrer"
target="_blank"
class="underline"
>
Server Command</a
>.
<FormatMessage message={$json('admin.authentication_settings_reenable')} let:message>
<a
href="https://immich.app/docs/administration/server-commands"
rel="noreferrer"
target="_blank"
class="underline"
>
{message}
</a>
</FormatMessage>
</p>
</div>
</svelte:fragment>
@ -78,12 +80,16 @@
>
<div class="ml-4 mt-4 flex flex-col gap-4">
<p class="text-sm dark:text-immich-dark-fg">
For more details about this feature, refer to the <a
href="https://immich.app/docs/administration/oauth"
class="underline"
target="_blank"
rel="noreferrer">docs</a
>.
<FormatMessage message={$json('admin.oauth_settings_more_details')} let:message>
<a
href="https://immich.app/docs/administration/oauth"
class="underline"
target="_blank"
rel="noreferrer"
>
{message}
</a>
</FormatMessage>
</p>
<SettingSwitch

View File

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

View File

@ -10,7 +10,8 @@
} from '$lib/components/shared-components/settings/setting-input-field.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 { 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 defaultConfig: SystemConfigDto;
@ -99,12 +100,11 @@
>
<svelte:fragment slot="desc">
<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
href="https://crontab.guru"
class="underline"
target="_blank"
rel="noreferrer">{$t('admin.crontab_guru')}</a
>
<FormatMessage message={$json('admin.library_cron_expression_description')} let:message>
<a href="https://crontab.guru" class="underline" target="_blank" rel="noreferrer">
{message}
</a>
</FormatMessage>
</p>
</svelte:fragment>
</SettingInputField>

View File

@ -12,7 +12,8 @@
import SettingSelect from '$lib/components/shared-components/settings/setting-select.svelte';
import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte';
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 defaultConfig: SystemConfigDto;
@ -70,8 +71,9 @@
isEdited={config.machineLearning.clip.modelName !== savedConfig.machineLearning.clip.modelName}
>
<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
must re-run the 'Smart Search' job for all images upon changing a model.
<FormatMessage message={$json('admin.machine_learning_clip_model_description')} let:message>
<a href="https://huggingface.co/immich-app"><u>{message}</u></a>
</FormatMessage>
</p>
</SettingInputField>
</div>

View File

@ -20,7 +20,8 @@
SettingInputFieldType,
} from '$lib/components/shared-components/settings/setting-input-field.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 defaultConfig: SystemConfigDto;
@ -88,21 +89,27 @@
<section class="dark:text-immich-dark-fg mt-2">
<div in:fade={{ duration: 500 }} class="mx-4 flex flex-col gap-4 py-4">
<p class="text-sm dark:text-immich-dark-fg">
For more details about this feature, refer to the <a
href="https://immich.app/docs/administration/storage-template"
class="underline"
target="_blank"
rel="noreferrer"
>Storage Template
</a>
and its
<a
href="https://immich.app/docs/administration/backup-and-restore#asset-types-and-storage-locations"
class="underline"
target="_blank"
rel="noreferrer"
>implications
</a>
<FormatMessage message={$json('admin.storage_template_more_details')} let:tag let:message>
{#if tag === 'template-link'}
<a
href="https://immich.app/docs/administration/storage-template"
class="underline"
target="_blank"
rel="noreferrer"
>
{message}
</a>
{:else if tag === 'implications-link'}
<a
href="https://immich.app/docs/administration/backup-and-restore#asset-types-and-storage-locations"
class="underline"
target="_blank"
rel="noreferrer"
>
{message}
</a>
{/if}
</FormatMessage>
</p>
</div>
{#await getTemplateOptions() then}
@ -153,15 +160,23 @@
</div>
<p class="text-sm">
Approximately path length limit : <span
class="font-semibold text-immich-primary dark:text-immich-dark-primary"
>{parsedTemplate().length + $user.id.length + 'UPLOAD_LOCATION'.length}</span
>/260
<FormatMessage
message={$json('admin.storage_template_path_length')}
values={{ length: parsedTemplate().length + $user.id.length + 'UPLOAD_LOCATION'.length, limit: 260 }}
let:message
>
<span class="font-semibold text-immich-primary dark:text-immich-dark-primary">{message}</span>
</FormatMessage>
</p>
<p class="text-sm">
<code class="text-immich-primary dark:text-immich-dark-primary">{$user.storageLabel || $user.id}</code> is the
user's Storage Label
<FormatMessage
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 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>
<section class="flex flex-col gap-2">
<p>
Template changes will only apply to new assets. To retroactively apply the template to previously
uploaded assets, run the
<a href={AppRoute.ADMIN_JOBS} class="text-immich-primary dark:text-immich-dark-primary"
>{$t('admin.storage_template_migration_job')}</a
>.
</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
<FormatMessage
message={$json('admin.storage_template_migration_info')}
values={{ job: $t('admin.storage_template_migration_job') }}
let:message
>
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>
</section>
</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 Icon from '../elements/icon.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<{
done: void;
@ -29,9 +30,9 @@
</p>
<p>
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
<a class="underline" href="https://immich.app/docs/administration/storage-template">documentation</a>.
<FormatMessage message={$json('admin.storage_template_onboarding_description')} let:message>
<a class="underline" href="https://immich.app/docs/administration/storage-template">{message}</a>
</FormatMessage>
</p>
{#if config && $user}

View File

@ -3,7 +3,8 @@
import type { ServerVersionResponseDto } from '@immich/sdk';
import Button from '../elements/buttons/button.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;
@ -36,14 +37,17 @@
{#if showModal}
<FullScreenModal title="🎉 NEW VERSION AVAILABLE" onClose={() => (showModal = false)}>
<div>
Hi friend, there is a new version of the application please take your time to visit the
<span class="font-medium underline"
><a href="https://github.com/immich-app/immich/releases/latest" target="_blank" rel="noopener noreferrer"
>release notes</a
></span
>
and ensure your <code>docker-compose</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.
<FormatMessage message={$json('version_announcement_message')} let:tag let:message>
{#if tag === 'link'}
<span class="font-medium underline">
<a href="https://github.com/immich-app/immich/releases/latest" target="_blank" rel="noopener noreferrer">
{message}
</a>
</span>
{:else if tag === 'code'}
<code>{message}</code>
{/if}
</FormatMessage>
</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/**\".",
"authentication_settings": "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",
"check_all": "Check All",
"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_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?",
"crontab_guru": "Crontab Guru",
"disable_login": "Disable login",
"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.",
@ -68,6 +68,7 @@
"jobs_failed": "{jobCount} failed",
"library_created": "Created library: {library}",
"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_deleted": "Library deleted",
"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_settings": "Logging",
"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_enabled": "Enable duplicate detection",
"machine_learning_duplicate_detection_enabled_description": "If disabled, exactly identical assets will still be de-duplicated.",
@ -162,6 +164,7 @@
"oauth_scope": "Scope",
"oauth_settings": "OAuth",
"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_storage_label_claim": "Storage label 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_description": "Enables hash verification, don't disable this unless you're certain of the implications",
"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_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_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",
"theme_custom_css_settings": "Custom CSS",
"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_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_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_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)",
@ -275,11 +285,14 @@
"trash_settings_description": "Manage trash settings",
"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",
"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_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_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_restore_description": "<b>{user}</b>'s account will be restored.",
"user_settings": "User Settings",
"user_settings_description": "Manage user settings",
"user_successfully_removed": "User {email} has been successfully removed.",
@ -902,6 +915,7 @@
"validate": "Validate",
"variables": "Variables",
"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_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.",