Migrate to lingui

Switch to "lingui" for better DX.
Tried to persist existing languages as much as possible.

Removed "vite-plugin-node-polyfills" because it's incompatible with "lingui"
This commit is contained in:
schroda
2026-01-10 19:45:02 +01:00
parent c1ac58f4d6
commit 65a9a905be
287 changed files with 120766 additions and 37748 deletions

View File

@@ -6,22 +6,23 @@
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
import { MessageDescriptor } from '@lingui/core';
import { msg } from '@lingui/core/macro';
import { BackupFlag, BackupFlagGroup } from '@/features/backup/Backup.types.ts';
import { TranslationKey } from '@/base/Base.types.ts';
export const BACKUP_FLAGS_TO_TRANSLATION: Record<BackupFlag, TranslationKey> = {
includeManga: 'settings.backup.flag.manga',
includeCategories: 'settings.backup.flag.categories',
includeChapters: 'settings.backup.flag.chapters',
includeClientData: 'settings.backup.flag.client_data',
includeHistory: 'settings.backup.flag.history',
includeServerSettings: 'settings.backup.flag.server_settings',
includeTracking: 'settings.backup.flag.tracking',
export const BACKUP_FLAGS_TO_TRANSLATION: Record<BackupFlag, MessageDescriptor> = {
includeManga: msg`Library entries`,
includeCategories: msg`Categories`,
includeChapters: msg`Chapters`,
includeClientData: msg`Client data`,
includeHistory: msg`History`,
includeServerSettings: msg`Server settings`,
includeTracking: msg`Tracking`,
};
export const BACKUP_FLAG_GROUP_TO_TRANSLATION: Record<BackupFlagGroup, TranslationKey> = {
[BackupFlagGroup.LIBRARY]: 'settings.backup.flag.group.library',
[BackupFlagGroup.SETTINGS]: 'settings.backup.flag.group.settings',
export const BACKUP_FLAG_GROUP_TO_TRANSLATION: Record<BackupFlagGroup, MessageDescriptor> = {
[BackupFlagGroup.LIBRARY]: msg`Library`,
[BackupFlagGroup.SETTINGS]: msg`Settings`,
};
export const BACKUP_FLAGS = Object.keys(BACKUP_FLAGS_TO_TRANSLATION) as readonly BackupFlag[];

View File

@@ -6,7 +6,7 @@
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
import { t as translate } from 'i18next';
import { plural, t } from '@lingui/core/macro';
import { AutoBackupFlagInclusionState, BackupFlag, BackupFlagInclusionState } from '@/features/backup/Backup.types.ts';
import { BACKUP_FLAGS_TO_TRANSLATION } from '@/features/backup/Backup.constants.ts';
@@ -32,11 +32,11 @@ export const convertToBackupFlags = (flags: AutoBackupFlagInclusionState): Backu
const getIncludeExcludeText = (count: number, allCount: number, specificText: string): string => {
if (count === 0) {
return translate('global.label.none');
return t`None`;
}
if (count === allCount) {
return translate('extension.language.all');
return t`All`;
}
return specificText;
@@ -49,9 +49,9 @@ export const getAutoBackupFlagsInfo = (autoFlags: AutoBackupFlagInclusionState):
const flagsByState = Object.groupBy(Object.entries(flags), ([, value]) => value.toString());
const includedFlagsString =
flagsByState.true?.map(([key]) => translate(BACKUP_FLAGS_TO_TRANSLATION[key as BackupFlag])).join(', ') ?? '';
flagsByState.true?.map(([key]) => t(BACKUP_FLAGS_TO_TRANSLATION[key as BackupFlag])).join(', ') ?? '';
const excludedFlagsString =
flagsByState.false?.map(([key]) => translate(BACKUP_FLAGS_TO_TRANSLATION[key as BackupFlag])).join(', ') ?? '';
flagsByState.false?.map(([key]) => t(BACKUP_FLAGS_TO_TRANSLATION[key as BackupFlag])).join(', ') ?? '';
return {
false: getIncludeExcludeText(flagsByState.false?.length ?? 0, totalFlags, excludedFlagsString),
@@ -61,8 +61,11 @@ export const getAutoBackupFlagsInfo = (autoFlags: AutoBackupFlagInclusionState):
export const getBackupCleanupDisplayValue = (ttl: number): string => {
if (ttl === 0) {
return translate('global.label.never');
return t`Never`;
}
return translate('settings.backup.automated.cleanup.label.value', { days: ttl, count: ttl });
return plural(ttl, {
one: `Delete backups that are older than # day`,
other: `Delete backups that are older than # days`,
});
};

View File

@@ -11,12 +11,12 @@ import DialogActions from '@mui/material/DialogActions';
import DialogContent from '@mui/material/DialogContent';
import DialogTitle from '@mui/material/DialogTitle';
import FormGroup from '@mui/material/FormGroup';
import { useTranslation } from 'react-i18next';
import { useState } from 'react';
import Button from '@mui/material/Button';
import Stack from '@mui/material/Stack';
import Typography from '@mui/material/Typography';
import { AwaitableComponentProps } from 'awaitable-component';
import { useLingui } from '@lingui/react/macro';
import { CheckboxInput } from '@/base/components/inputs/CheckboxInput.tsx';
import {
BACKUP_FLAG_GROUP_TO_TRANSLATION,
@@ -34,7 +34,7 @@ export const BackupFlagInclusionDialog = ({
title,
flags,
}: AwaitableComponentProps<BackupFlagInclusionState> & { title: string; flags?: BackupFlagInclusionState }) => {
const { t } = useTranslation();
const { t } = useLingui();
const [includeStateByFlag, setIncludeStateByFlag] = useState(
Object.fromEntries(BACKUP_FLAGS.map((flag) => [flag, flags?.[flag] ?? true])) as BackupFlagInclusionState,
@@ -67,10 +67,10 @@ export const BackupFlagInclusionDialog = ({
</DialogContent>
<DialogActions>
<Button autoFocus onClick={onDismiss} color="primary">
{t('global.button.cancel')}
{t`Cancel`}
</Button>
<Button onClick={() => onSubmit(includeStateByFlag)} color="primary">
{t('global.button.ok')}
{t`Ok`}
</Button>
</DialogActions>
</Dialog>

View File

@@ -7,7 +7,6 @@
*/
import List from '@mui/material/List';
import { useTranslation } from 'react-i18next';
import Dialog from '@mui/material/Dialog';
import DialogTitle from '@mui/material/DialogTitle';
import DialogContent from '@mui/material/DialogContent';
@@ -17,6 +16,7 @@ import ListItem from '@mui/material/ListItem';
import { Link } from 'react-router-dom';
import Stack from '@mui/material/Stack';
import { AwaitableComponentProps } from 'awaitable-component';
import { useLingui } from '@lingui/react/macro';
import { BrowseTab } from '@/features/browse/Browse.types.ts';
import { AppRoutes } from '@/base/AppRoute.constants.ts';
import { ValidateBackupResult } from '@/lib/graphql/generated/graphql.ts';
@@ -28,16 +28,16 @@ export const BackupValidationDialog = ({
isVisible,
onExitComplete,
}: AwaitableComponentProps & { validationResult: ValidateBackupResult }) => {
const { t } = useTranslation();
const { t } = useLingui();
return (
<Dialog open={isVisible} onTransitionExited={onExitComplete} onClose={onDismiss}>
<DialogTitle>{t('settings.backup.action.validate.dialog.title')}</DialogTitle>
<DialogTitle>{t`Backup validation`}</DialogTitle>
<DialogContent dividers>
{!!validationResult?.missingSources.length && (
<List
sx={{ listStyleType: 'initial', listStylePosition: 'inside' }}
subheader={t('settings.backup.action.validate.dialog.content.label.missing_sources')}
subheader={t`The following sources are not installed:`}
>
{validationResult?.missingSources.map(({ id, name }) => (
<ListItem sx={{ display: 'list-item' }} key={id}>
@@ -49,7 +49,7 @@ export const BackupValidationDialog = ({
{!!validationResult?.missingTrackers.length && (
<List
sx={{ listStyleType: 'initial', listStylePosition: 'inside' }}
subheader={t('settings.backup.action.validate.dialog.content.label.missing_trackers')}
subheader={t`The following trackers are not logged in:`}
>
{validationResult?.missingTrackers.map(({ name }) => (
<ListItem sx={{ display: 'list-item' }} key={name}>
@@ -75,7 +75,7 @@ export const BackupValidationDialog = ({
autoFocus={!!validationResult?.missingSources.length}
variant={validationResult?.missingSources.length ? 'contained' : 'text'}
>
{t('extension.action.label.install')}
{t`Install`}
</Button>
)}
{!!validationResult?.missingTrackers.length && (
@@ -86,11 +86,11 @@ export const BackupValidationDialog = ({
autoFocus={!!validationResult?.missingTrackers.length}
variant={validationResult?.missingTrackers.length ? 'contained' : 'text'}
>
{t('global.button.log_in')}
{t`Log in`}
</Button>
)}
<Stack direction="row">
<Button onClick={onDismiss}>{t('global.button.cancel')}</Button>
<Button onClick={onDismiss}>{t`Cancel`}</Button>
<Button
onClick={onSubmit}
autoFocus={
@@ -102,7 +102,7 @@ export const BackupValidationDialog = ({
: 'text'
}
>
{t('global.button.restore')}
{t`Restore`}
</Button>
</Stack>
</Stack>

View File

@@ -10,12 +10,13 @@ import { useEffect, useRef, useState } from 'react';
import List from '@mui/material/List';
import ListItemText from '@mui/material/ListItemText';
import { fromEvent } from 'file-selector';
import { useTranslation } from 'react-i18next';
import ListItemButton from '@mui/material/ListItemButton';
import ListItemIcon from '@mui/material/ListItemIcon';
import ListSubheader from '@mui/material/ListSubheader';
import { useEventListener, useMergedRef, useWindowEvent } from '@mantine/hooks';
import { AwaitableComponent } from 'awaitable-component';
import { useLingui } from '@lingui/react/macro';
import { plural } from '@lingui/core/macro';
import { requestManager } from '@/lib/requests/RequestManager.ts';
import { makeToast } from '@/base/utils/Toast.ts';
import { BackupRestoreState } from '@/lib/graphql/generated/graphql.ts';
@@ -41,9 +42,9 @@ import { BackupSettingsType } from '@/features/backup/Backup.types.ts';
let backupRestoreId: string | undefined;
export function Backup() {
const { t } = useTranslation();
const { t } = useLingui();
useAppTitle(t('settings.backup.title'));
useAppTitle(t`Backup`);
const {
data: settingsData,
@@ -76,7 +77,7 @@ export function Backup() {
value: BackupSettingsType[Setting],
) => {
mutateSettings({ variables: { input: { settings: { [setting]: value } } } }).catch((e) =>
makeToast(t('global.error.label.failed_to_save_changes'), 'error', getErrorMessage(e)),
makeToast(t`Failed to save changes`, 'error', getErrorMessage(e)),
);
};
@@ -84,7 +85,7 @@ export function Backup() {
settings: Record<Setting, BackupSettingsType[Setting]>,
) => {
mutateSettings({ variables: { input: { settings } } }).catch((e) =>
makeToast(t('global.error.label.failed_to_save_changes'), 'error', getErrorMessage(e)),
makeToast(t`Failed to save changes`, 'error', getErrorMessage(e)),
);
};
@@ -99,11 +100,11 @@ export function Backup() {
const isRestoreFinished = isSuccess || isFailure;
if (isRestoreFinished) {
if (isSuccess) {
makeToast(t('settings.backup.action.restore.label.success'), 'success');
makeToast(t`Backup restored.`, 'success');
}
if (isFailure) {
makeToast(t('settings.backup.action.restore.error.label.failure'), 'error');
makeToast(t`Could not restore backup`, 'error');
}
requestManager.reset();
@@ -121,21 +122,17 @@ export function Backup() {
const createBackup = async () => {
const flags = await AwaitableComponent.show(BackupFlagInclusionDialog, {
title: t('settings.backup.action.create.label.title'),
title: t`Create backup`,
});
makeToast(t('settings.backup.action.create.label.in_progress'), 'info');
makeToast(t`Creating backup`, 'info');
try {
const backupFileResponse = await requestManager.createBackupFile({ flags }).response;
const backupFileUrl = backupFileResponse.data?.createBackup.url;
if (!backupFileUrl) {
makeToast(
t('settings.backup.action.create.error.failure'),
'error',
getErrorMessage(backupFileResponse.errors),
);
makeToast(t`Could not create backup`, 'error', getErrorMessage(backupFileResponse.errors));
return;
}
@@ -146,7 +143,7 @@ export function Backup() {
link.click();
document.body.removeChild(link);
} catch (e) {
makeToast(t('settings.backup.action.create.error.failure'), 'error', getErrorMessage(e));
makeToast(t`Could not create backup`, 'error', getErrorMessage(e));
}
};
@@ -172,7 +169,7 @@ export function Backup() {
return true;
} catch (e) {
makeToast(t('settings.backup.action.validate.error.label.failure'), 'error', getErrorMessage(e));
makeToast(t`Could not validate backup`, 'error', getErrorMessage(e));
} finally {
resetBackupState();
}
@@ -182,17 +179,17 @@ export function Backup() {
const restoreBackup = async (backup: File) => {
const flags = await AwaitableComponent.show(BackupFlagInclusionDialog, {
title: t('settings.backup.action.restore.label.title'),
title: t`Restore Backup`,
});
try {
makeToast(t('settings.backup.action.restore.label.in_progress'), 'info');
makeToast(t`Restoring backup`, 'info');
const response = await requestManager.restoreBackupFile({ backup, flags }).response;
backupRestoreId = response.data?.restoreBackup.id;
setTriggerReRender(Date.now());
} catch (e) {
makeToast(t('settings.backup.action.restore.error.label.failure'), 'error', getErrorMessage(e));
makeToast(t`Could not restore backup`, 'error', getErrorMessage(e));
} finally {
resetBackupState();
}
@@ -200,13 +197,13 @@ export function Backup() {
const submitBackup = async (file: File) => {
if (file.name.toLowerCase().endsWith('json')) {
makeToast(t('settings.backup.action.restore.error.label.legacy_backup_unsupported'), 'error');
makeToast(t`legacy backups are not supported!`, 'error');
return;
}
const isValidFilename = file.name.toLowerCase().match(/proto\.gz$|tachibk$/g);
if (!isValidFilename) {
makeToast(t('global.error.label.invalid_file_type'), 'error');
makeToast(t`Invalid filetype`, 'error');
return;
}
@@ -238,7 +235,7 @@ export function Backup() {
if (error) {
return (
<EmptyViewAbsoluteCentered
message={t('global.error.label.failed_to_load_data')}
message={t`Unable to load data`}
messageExtra={getErrorMessage(error)}
retry={() => refetch().catch(defaultPromiseErrorHandler('Backup::refetch'))}
/>
@@ -247,19 +244,20 @@ export function Backup() {
const backupSettings = settingsData!.settings;
const autoBackupFlagsInfo = getAutoBackupFlagsInfo(backupSettings);
const includedCategoriesText = autoBackupFlagsInfo.true;
const excludedCategoriesText = autoBackupFlagsInfo.false;
return (
<>
<List sx={{ padding: 0 }}>
<ListItemButton onClick={createBackup}>
<ListItemText
primary={t('settings.backup.action.create.label.title')}
secondary={t('settings.backup.action.create.label.description')}
/>
<ListItemText primary={t`Create backup`} secondary={t`Back up library as a Tachiyomi backup`} />
</ListItemButton>
<ListItemButton onClick={() => inputRef.current?.click()} disabled={!!backupRestoreId}>
<ListItemText
primary={t('settings.backup.action.restore.label.title')}
secondary={t('settings.backup.action.restore.label.description')}
primary={t`Restore Backup`}
secondary={t`You can also drag and drop the backup file here to restore it`}
/>
{backupRestoreId ? (
<ListItemIcon>
@@ -275,19 +273,17 @@ export function Backup() {
}
>
<TextSetting
settingName={t('settings.backup.automated.location.label.title')}
dialogDescription={t('settings.backup.automated.location.label.description')}
settingName={t`Backup location`}
dialogDescription={t`The path to the directory on the server where automated backups should get saved in`}
value={backupSettings.backupPath}
settingDescription={
backupSettings.backupPath.length ? backupSettings.backupPath : t('global.label.default')
}
settingDescription={backupSettings.backupPath.length ? backupSettings.backupPath : t`Default`}
handleChange={(path) => updateSetting('backupPath', path)}
/>
<ListItemButton
onClick={async () => {
try {
const flags = await AwaitableComponent.show(BackupFlagInclusionDialog, {
title: t('settings.backup.automated.flags.title'),
title: t`Backup data`,
flags: convertToBackupFlags(backupSettings),
});
@@ -298,54 +294,46 @@ export function Backup() {
}}
>
<ListItemText
primary={t('settings.backup.automated.flags.title')}
primary={t`Backup data`}
secondary={
<>
<span>
{t('category.settings.inclusion.label.include', {
includedCategoriesText: getAutoBackupFlagsInfo(backupSettings).true,
})}
</span>
<span>
{t('category.settings.inclusion.label.exclude', {
excludedCategoriesText: getAutoBackupFlagsInfo(backupSettings).false,
})}
</span>
<span>{t`Include: ${includedCategoriesText}`}</span>
<span>{t`Exclude: ${excludedCategoriesText}`}</span>
</>
}
secondaryTypographyProps={{ style: { display: 'flex', flexDirection: 'column' } }}
/>
</ListItemButton>
<TimeSetting
settingName={t('settings.backup.automated.label.time')}
settingName={t`Backup time`}
value={backupSettings.backupTime}
defaultValue="00:00"
handleChange={(time: string) => updateSetting('backupTime', time)}
/>
<NumberSetting
settingTitle={t('settings.backup.automated.label.interval')}
settingValue={t('global.date.value.label.day', {
days: backupSettings.backupInterval,
count: backupSettings.backupInterval,
settingTitle={t`Backup interval`}
settingValue={plural(backupSettings.backupInterval, {
one: '# day',
other: '# days',
})}
value={backupSettings.backupInterval}
defaultValue={1}
minValue={1}
maxValue={31}
stepSize={1}
valueUnit={t('global.date.label.day_one')}
valueUnit={t`Day`}
showSlider
handleUpdate={(interval: number) => updateSetting('backupInterval', interval)}
/>
<NumberSetting
settingTitle={t('settings.backup.automated.cleanup.label.title')}
settingTitle={t`Backup cleanup`}
settingValue={getBackupCleanupDisplayValue(backupSettings.backupTTL)}
value={backupSettings.backupTTL}
defaultValue={14}
minValue={0}
maxValue={1000}
stepSize={1}
valueUnit={t('global.date.label.day_one')}
valueUnit={t`Day`}
showSlider
handleUpdate={(ttl: number) => updateSetting('backupTTL', ttl)}
/>