Add sync settings

This commit is contained in:
schroda
2026-06-30 15:08:17 +02:00
parent 43fef26b89
commit 816409fcb8
14 changed files with 612 additions and 24 deletions

View File

@@ -13,6 +13,7 @@ import type {
BackupFlagInclusionState,
} from '@/features/backup/Backup.types.ts';
import { BACKUP_FLAGS_TO_TRANSLATION } from '@/features/backup/Backup.constants.ts';
import omit from 'lodash/fp/omit';
export const convertToAutoBackupFlags = (flags: BackupFlagInclusionState): AutoBackupFlagInclusionState => ({
autoBackupIncludeCategories: flags.includeCategories,
@@ -46,11 +47,14 @@ const getIncludeExcludeText = (count: number, allCount: number, specificText: st
return specificText;
};
export const getAutoBackupFlagsInfo = (autoFlags: AutoBackupFlagInclusionState): Record<`${boolean}`, string> => {
const flags = convertToBackupFlags(autoFlags);
export const getAutoBackupFlagsInfo = (
autoFlags: AutoBackupFlagInclusionState,
hiddenFlags: BackupFlag[] = [],
): Record<`${boolean}`, string> => {
const flags = omit(hiddenFlags, convertToBackupFlags(autoFlags));
const totalFlags = Object.keys(flags).length;
const flagsByState = Object.groupBy(Object.entries(flags), ([, value]) => value.toString());
const flagsByState = Object.groupBy(Object.entries(flags), ([, value]) => (value as boolean).toString());
const includedFlagsString =
flagsByState.true?.map(([key]) => t(BACKUP_FLAGS_TO_TRANSLATION[key as BackupFlag])).join(', ') ?? '';

View File

@@ -24,7 +24,7 @@ import {
BACKUP_FLAGS_BY_GROUP,
BACKUP_FLAGS_TO_TRANSLATION,
} from '@/features/backup/Backup.constants.ts';
import type { BackupFlagGroup, BackupFlagInclusionState } from '@/features/backup/Backup.types.ts';
import type { BackupFlag, BackupFlagGroup, BackupFlagInclusionState } from '@/features/backup/Backup.types.ts';
export const BackupFlagInclusionDialog = ({
onDismiss,
@@ -33,11 +33,18 @@ export const BackupFlagInclusionDialog = ({
onExitComplete,
title,
flags,
}: AwaitableComponentProps<BackupFlagInclusionState> & { title: string; flags?: BackupFlagInclusionState }) => {
hiddenFlags,
}: AwaitableComponentProps<BackupFlagInclusionState> & {
title: string;
flags?: BackupFlagInclusionState;
hiddenFlags?: BackupFlag[];
}) => {
const { t } = useLingui();
const [includeStateByFlag, setIncludeStateByFlag] = useState(
Object.fromEntries(BACKUP_FLAGS.map((flag) => [flag, flags?.[flag] ?? true])) as BackupFlagInclusionState,
Object.fromEntries(
BACKUP_FLAGS.map((flag) => [flag, flags?.[flag] ?? !hiddenFlags?.includes(flag)]),
) as BackupFlagInclusionState,
);
return (
@@ -45,24 +52,28 @@ export const BackupFlagInclusionDialog = ({
<DialogTitle>{title}</DialogTitle>
<DialogContent>
<FormGroup sx={{ gap: 2 }}>
{Object.entries(BACKUP_FLAGS_BY_GROUP).map(([group, groupFlags]) => (
<Stack key={group}>
<Typography>{t(BACKUP_FLAG_GROUP_TO_TRANSLATION[group as BackupFlagGroup])}</Typography>
{groupFlags.map((flag) => (
<CheckboxInput
key={flag}
label={t(BACKUP_FLAGS_TO_TRANSLATION[flag])}
checked={includeStateByFlag[flag]}
onChange={(_, checked) => {
setIncludeStateByFlag({
...includeStateByFlag,
[flag]: checked,
});
}}
/>
))}
</Stack>
))}
{Object.entries(BACKUP_FLAGS_BY_GROUP)
.filter(([_group, groupFlags]) => groupFlags.some((flag) => !hiddenFlags?.includes(flag)))
.map(([group, groupFlags]) => (
<Stack key={group}>
<Typography>{t(BACKUP_FLAG_GROUP_TO_TRANSLATION[group as BackupFlagGroup])}</Typography>
{groupFlags
.filter((flag) => !hiddenFlags?.includes(flag))
.map((flag) => (
<CheckboxInput
key={flag}
label={t(BACKUP_FLAGS_TO_TRANSLATION[flag])}
checked={includeStateByFlag[flag]}
onChange={(_, checked) => {
setIncludeStateByFlag({
...includeStateByFlag,
[flag]: checked,
});
}}
/>
))}
</Stack>
))}
</FormGroup>
</DialogContent>
<DialogActions>

View File

@@ -30,6 +30,7 @@ import {
} from '@/lib/graphql/generated/graphql-base.types.ts';
import { ThemeMode } from '@/features/theme/AppTheme.types.ts';
import { getPreferredISOLanguageCodes } from '@/lib/ISOLanguageUtil.ts';
import type { BackupFlag } from '@/features/backup/Backup.types.ts';
export const MANGA_GRID_WIDTH = {
min: 100,
@@ -317,3 +318,45 @@ export const IMAGE_PROCESSING_TYPE_TO_SETTING: Record<
[ImageProcessingType.DOWNLOAD]: 'downloadConversions',
[ImageProcessingType.SERVE]: 'serveConversions',
};
const SYNC_INTERVAL_TO_TRANSLATION: Record<string, SelectSettingValueDisplayInfo> = {
[d(0).seconds.asWholeMinutes.toISOString()]: {
text: msg`Disabled`,
},
[d(30).minutes.asWholeMinutes.toISOString()]: {
text: msg`Every 30 minutes`,
},
[d(1).hours.asWholeMinutes.toISOString()]: {
text: msg`Every hour`,
},
[d(3).hours.asWholeMinutes.toISOString()]: {
text: msg`Every 3 hours`,
},
[d(6).hours.asWholeMinutes.toISOString()]: {
text: msg`Every 6 hours`,
},
[d(12).hours.asWholeMinutes.toISOString()]: {
text: msg`Every 12 hours`,
},
[d(1).days.asWholeMinutes.toISOString()]: {
text: msg`Daily`,
},
[d(7).days.asWholeMinutes.toISOString()]: {
text: msg`Weekly`,
},
};
export const SYNC_INTERVAL_CUSTOM_VALUE = d(10).days.asWholeMinutes.toISOString();
export const SYNC_INTERVAL_VALUES = Object.keys(SYNC_INTERVAL_TO_TRANSLATION);
export const SYNC_INTERVAL_SELECT_VALUES: SelectSettingValue<string>[] = SYNC_INTERVAL_VALUES.map((value) => [
value,
SYNC_INTERVAL_TO_TRANSLATION[value],
]);
export const SYNC_INTERVAL_SELECT_VALUES_WITH_CUSTOM: SelectSettingValue<string>[] = [
...SYNC_INTERVAL_SELECT_VALUES,
[SYNC_INTERVAL_CUSTOM_VALUE, { text: msg`Custom` }],
];
export const SYNC_SETTINGS_HIDDEN_BACKUP_FLAGS = [
'includeServerSettings',
'includeClientData',
] as const satisfies BackupFlag[];

View File

@@ -31,17 +31,46 @@ import { makeToast } from '@/base/utils/Toast.ts';
import type { MetadataUpdateSettings } from '@/features/app-updates/AppUpdateChecker.types.ts';
import { getErrorMessage } from '@/lib/HelperFunctions.ts';
import { useAppTitle } from '@/features/navigation-bar/hooks/useAppTitle.ts';
import type { PartialSettingsTypeInput } from '@/lib/graphql/generated/graphql-base.types.ts';
import { AuthMode, CbzMediaType, DatabaseType, SortOrder } from '@/lib/graphql/generated/graphql-base.types.ts';
import {
AUTH_MODES_SELECT_VALUES,
JWT_ACCESS_TOKEN_EXPIRY,
JWT_REFRESH_TOKEN_EXPIRY,
SYNC_INTERVAL_CUSTOM_VALUE,
SYNC_INTERVAL_SELECT_VALUES,
SYNC_INTERVAL_SELECT_VALUES_WITH_CUSTOM,
SYNC_INTERVAL_VALUES,
SYNC_SETTINGS_HIDDEN_BACKUP_FLAGS,
} from '@/features/settings/Settings.constants.ts';
import { ServerAddressSetting } from '@/features/settings/components/ServerAddressSetting.tsx';
import { AuthManager } from '@/features/authentication/AuthManager.ts';
import type { ServerSettings as ServerSettingsType } from '@/features/settings/Settings.types.ts';
import { KoreaderSyncSettings } from '@/features/settings/components/koreaderSync/KoreaderSyncSettings.tsx';
import { Confirmation } from '@/base/AppAwaitableComponent.ts';
import { BackupFlagInclusionDialog } from '@/features/backup/component/BackupFlagInclusionDialog.tsx';
import ListItemButton from '@mui/material/ListItemButton';
import { AwaitableComponent } from 'awaitable-component';
import { getAutoBackupFlagsInfo } from '@/features/backup/Backup.utils.ts';
import type { BackupFlagInclusionState } from '@/features/backup/Backup.types.ts';
const convertSyncDataToBackupFlags = (settings: ServerSettingsType): BackupFlagInclusionState => ({
includeManga: settings.syncDataManga,
includeChapters: settings.syncDataChapters,
includeCategories: settings.syncDataCategories,
includeHistory: settings.syncDataHistory,
includeTracking: settings.syncDataTracking,
includeClientData: false,
includeServerSettings: false,
});
const convertBackupFlagsToSyncData = (flags: BackupFlagInclusionState): PartialSettingsTypeInput => ({
syncDataManga: flags.includeManga,
syncDataChapters: flags.includeChapters,
syncDataCategories: flags.includeCategories,
syncDataHistory: flags.includeHistory,
syncDataTracking: flags.includeTracking,
});
const getLogFilesCleanupDisplayValue = (ttl: number): string => {
if (ttl === 0) {
@@ -91,6 +120,15 @@ export const ServerSettings = () => {
onCompletion?.(false);
}
};
const updateSettings = async (settings: PartialSettingsTypeInput, onCompletion?: (success: boolean) => void) => {
try {
await mutateSettings({ variables: { input: { settings } } });
onCompletion?.(true);
} catch (e) {
makeToast(t`Failed to save changes`, 'error', getErrorMessage(e));
onCompletion?.(false);
}
};
const localSettings = useMemo(
() => (
@@ -176,6 +214,13 @@ export const ServerSettings = () => {
const authModeDisabled = !serverSettings.authUsername?.trim() || !serverSettings.authPassword?.trim();
const isH2Database = serverSettings.databaseType === DatabaseType.H2;
const isCustomSyncInterval = !SYNC_INTERVAL_VALUES.includes(
d(serverSettings.syncInterval).minutes.asWholeMinutes.toISOString(),
);
const syncDataFlagsInfo = getAutoBackupFlagsInfo(serverSettings);
const includedSyncDataText = syncDataFlagsInfo.true;
const excludedSyncDataText = syncDataFlagsInfo.false;
return (
<List sx={{ pt: 0 }}>
{localSettings}
@@ -669,6 +714,81 @@ export const ServerSettings = () => {
/>
</ListItem>
</List>
<List
subheader={
<ListSubheader component="div" id="server-settings-sync">
{t`Sync`}
</ListSubheader>
}
>
<ListItemButton
onClick={async () => {
try {
const flags = await AwaitableComponent.show(BackupFlagInclusionDialog, {
title: t`Sync data`,
flags: convertSyncDataToBackupFlags(serverSettings),
hiddenFlags: SYNC_SETTINGS_HIDDEN_BACKUP_FLAGS,
});
await updateSettings(convertBackupFlagsToSyncData(flags));
} catch (e) {
// Ignore
}
}}
>
<ListItemText
primary={t`Backup data`}
secondary={
<>
<span>{t`Include: ${includedSyncDataText}`}</span>
<span>{t`Exclude: ${excludedSyncDataText}`}</span>
</>
}
slotProps={{
secondary: { sx: { display: 'flex', flexDirection: 'column' } },
}}
/>
</ListItemButton>
<SelectSetting
settingName={t`Sync interval`}
value={
isCustomSyncInterval
? SYNC_INTERVAL_CUSTOM_VALUE
: d(serverSettings.syncInterval).minutes.asWholeMinutes.toISOString()
}
values={
isCustomSyncInterval ? SYNC_INTERVAL_SELECT_VALUES_WITH_CUSTOM : SYNC_INTERVAL_SELECT_VALUES
}
handleChange={(syncInterval) => updateSetting('syncInterval', syncInterval)}
/>
<List
subheader={
<ListSubheader component="div" id="server-settings-sync-syncyomi">
{t`SyncYomi`}
</ListSubheader>
}
>
<ListItem>
<ListItemText primary={t`SyncYomi enabled`} />
<Switch
edge="end"
checked={serverSettings.syncYomiEnabled}
onChange={(e) => updateSetting('syncYomiEnabled', e.target.checked)}
/>
</ListItem>
<TextSetting
settingName={t`SyncYomi host`}
value={serverSettings.syncYomiHost}
handleChange={(host) => updateSetting('syncYomiHost', host)}
/>
<TextSetting
settingName={t`SyncYomi API key`}
value={serverSettings.syncYomiApiKey}
handleChange={(apiKey) => updateSetting('syncYomiApiKey', apiKey)}
isPassword
/>
</List>
</List>
<List
subheader={
<ListSubheader component="div" id="server-settings-misc">