Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).

### Added

- (**SyncYomi**) Add SyncYomi integration: settings page with server address, API key, auto-sync interval, data sync toggles, and trigger configuration (chapter read, chapter open, client start, client resume)
- (**SyncYomi**) Add `SyncTriggerHandler` component that fires sync automatically based on configured triggers
- (**SyncYomi**) Show "Syncing library..." progress toast and completion/error toasts via the `syncStatusChanged` subscription
- (**Migration**) Add a search option to ignore outdated matches
- (**Migration**) Add a search option to ignore matches with missing chapters
- (**Migration**) Add "local source" as a possible destination source
Expand Down
12 changes: 12 additions & 0 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { DefaultNavBar } from '@/features/navigation-bar/components/DefaultNavBa
import { requestManager } from '@/lib/requests/RequestManager.ts';
import { WebUIUpdateChecker } from '@/features/app-updates/components/WebUIUpdateChecker.tsx';
import { ServerUpdateChecker } from '@/features/app-updates/components/ServerUpdateChecker.tsx';
import { SyncTriggerHandler } from '@/features/settings/components/syncYomi/SyncTriggerHandler.tsx';
import { lazyLoadFallback } from '@/base/utils/LazyLoad.tsx';
import { ErrorBoundary } from '@/base/components/feedback/ErrorBoundary.tsx';
import { useNavBarContext } from '@/features/navigation-bar/NavbarContext.tsx';
Expand Down Expand Up @@ -100,6 +101,10 @@ const { GlobalReaderSettings } = loadable(
const { More } = loadable(() => import('@/features/settings/screens/More.tsx'), lazyLoadFallback);
const { Reader } = loadable(() => import('@/features/reader/screens/Reader.tsx'), lazyLoadFallback);
const { HistorySettings } = loadable(() => import('@/features/history/screens/HistorySettings.tsx'), lazyLoadFallback);
const { SyncYomiTriggerSettings } = loadable(
() => import('@/features/settings/screens/SyncYomiTriggerSettings.tsx'),
lazyLoadFallback,
);

if (import.meta.env.DEV) {
// Adds messages only in a dev environment
Expand Down Expand Up @@ -322,6 +327,12 @@ const MainApp = () => {
</Route>
<Route path={AppRoutes.settings.children.backup.match} element={<Backup />} />
<Route path={AppRoutes.settings.children.server.match} element={<ServerSettings />} />
<Route path={AppRoutes.settings.children.syncyomi.match}>
<Route
path={AppRoutes.settings.children.syncyomi.children.triggers.match}
element={<SyncYomiTriggerSettings />}
/>
</Route>
<Route path={AppRoutes.settings.children.webui.match} element={<WebUISettings />} />
<Route path={AppRoutes.settings.children.browse.match} element={<BrowseSettings />} />
<Route path={AppRoutes.settings.children.history.match} element={<HistorySettings />} />
Expand Down Expand Up @@ -396,6 +407,7 @@ export const App: React.FC = () => (
<InitializeGuard>
<ServerUpdateChecker />
<WebUIUpdateChecker />
<SyncTriggerHandler />
<InitialBackgroundRequests />
<BackgroundSubscriptions />
<ResumeMigration />
Expand Down
10 changes: 10 additions & 0 deletions src/base/AppRoute.constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,16 @@ export const AppRoutes = {
match: 'server',
path: '/settings/server',
},
syncyomi: {
match: 'syncyomi',
path: '/settings/syncyomi',
children: {
triggers: {
match: 'triggers',
path: '/settings/syncyomi/triggers',
},
},
},
webui: {
match: 'webui',
path: '/settings/webui',
Expand Down
100 changes: 100 additions & 0 deletions src/features/settings/components/syncYomi/SyncTriggerHandler.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
/*
* Copyright (C) Contributors to the Suwayomi project
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/

import { useEffect } from 'react';
import { useLocation } from 'react-router-dom';
import { useLingui } from '@lingui/react/macro';
import CircularProgress from '@mui/material/CircularProgress';
import { closeSnackbar } from 'notistack';
import { requestManager } from '@/lib/requests/RequestManager.ts';
import { defaultPromiseErrorHandler } from '@/lib/DefaultPromiseErrorHandler.ts';
import { makeToast } from '@/base/utils/Toast.ts';
import { SyncState } from '@/lib/graphql/generated/graphql-base.types.ts';

const READER_PATH_PATTERN = /^\/manga\/\d+\/chapter\//;
export const SYNC_PROGRESS_TOAST_KEY = 'syncyomi-in-progress';

export const SyncTriggerHandler = () => {
const { t } = useLingui();
const { pathname } = useLocation();
const { data } = requestManager.useGetServerSettings();
const settings = data?.settings;

const syncYomiEnabled = settings?.syncYomiEnabled ?? false;
const syncOnWebUIStart = settings?.syncOnWebUIStart ?? false;
const syncOnWebUIResume = settings?.syncOnWebUIResume ?? false;

const isOnReader = READER_PATH_PATTERN.test(pathname);

const { data: syncStatusData } = requestManager.useSyncStatusSubscription({
skip: !syncYomiEnabled,
});

useEffect(() => {
if (!syncStatusData || isOnReader) {
return;
}

const { state, errorMessage } = syncStatusData.syncStatusChanged;

const MIN_TOAST_MS = 800;
const timer = setTimeout(() => {
closeSnackbar(SYNC_PROGRESS_TOAST_KEY);

if (state === SyncState.Success) {
makeToast(t`Sync completed successfully`, 'success');
} else if (state === SyncState.Error) {
makeToast(t`Sync failed`, 'error', errorMessage ?? undefined);
}
}, MIN_TOAST_MS);

return () => clearTimeout(timer);
}, [syncStatusData, isOnReader, t]);

const triggerSync = (source: string) => {
if (!isOnReader) {
makeToast(t`Syncing library...`, {
variant: 'info',
persist: true,
key: SYNC_PROGRESS_TOAST_KEY,
action: (
<CircularProgress size={20} thickness={4} sx={{ color: 'warning.main', mr: 1, flexShrink: 0 }} />
),
});
}

requestManager.startSync().response.catch(defaultPromiseErrorHandler(`SyncTriggerHandler::${source}`));
};

useEffect(() => {
if (!syncYomiEnabled || !syncOnWebUIStart) {
return;
}

triggerSync('syncOnWebUIStart');
}, [syncYomiEnabled, syncOnWebUIStart]);

useEffect(() => {
if (!syncYomiEnabled || !syncOnWebUIResume) {
return undefined;
}

const handleVisibilityChange = () => {
if (document.visibilityState !== 'visible') {
return;
}

triggerSync('syncOnWebUIResume');
};

document.addEventListener('visibilitychange', handleVisibilityChange);
return () => document.removeEventListener('visibilitychange', handleVisibilityChange);
}, [syncYomiEnabled, syncOnWebUIResume]);

return null;
};
193 changes: 193 additions & 0 deletions src/features/settings/components/syncYomi/SyncYomiServerSettings.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,193 @@
/*
* Copyright (C) Contributors to the Suwayomi project
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/

import { useState } from 'react';
import List from '@mui/material/List';
import ListSubheader from '@mui/material/ListSubheader';
import ListItem from '@mui/material/ListItem';
import ListItemText from '@mui/material/ListItemText';
import ListItemButton from '@mui/material/ListItemButton';
import Switch from '@mui/material/Switch';
import CircularProgress from '@mui/material/CircularProgress';
import { useLingui } from '@lingui/react/macro';
import { closeSnackbar } from 'notistack';
import { requestManager } from '@/lib/requests/RequestManager.ts';
import { TextSetting } from '@/base/components/settings/text/TextSetting.tsx';
import { SelectSetting } from '@/base/components/settings/SelectSetting.tsx';
import { ListItemLink } from '@/base/components/lists/ListItemLink.tsx';
import { makeToast } from '@/base/utils/Toast.ts';
import { getErrorMessage } from '@/lib/HelperFunctions.ts';
import { StartSyncResult } from '@/lib/graphql/generated/graphql-base.types.ts';
import type { ServerSettings as ServerSettingsType, ServerSettings } from '@/features/settings/Settings.types.ts';
import { AppRoutes } from '@/base/AppRoute.constants.ts';
import { SYNC_PROGRESS_TOAST_KEY } from '@/features/settings/components/syncYomi/SyncTriggerHandler.tsx';

const SYNC_INTERVAL_VALUES: [string, { text: string }][] = [
['PT0S', { text: 'Disabled' }],
['PT15M', { text: '15 minutes' }],
['PT30M', { text: '30 minutes' }],
['PT1H', { text: '1 hour' }],
['PT2H', { text: '2 hours' }],
['PT6H', { text: '6 hours' }],
['PT12H', { text: '12 hours' }],
['PT24H', { text: '24 hours' }],
];

const normalizeSyncInterval = (value: string): string => {
const knownValues = SYNC_INTERVAL_VALUES.map(([v]) => v);
return knownValues.includes(value) ? value : 'PT0S';
};

export const SyncYomiServerSettings = ({
settings,
updateSetting,
}: {
settings: ServerSettings;
updateSetting: <Setting extends keyof ServerSettingsType>(
setting: Setting,
value: ServerSettingsType[Setting],
onCompletion?: (success: boolean) => void,
) => Promise<void>;
}) => {
const { t } = useLingui();
const [isSyncing, setIsSyncing] = useState(false);

const {
syncYomiEnabled,
syncYomiHost,
syncYomiApiKey,
syncDataManga,
syncDataChapters,
syncDataTracking,
syncDataHistory,
syncDataCategories,
syncInterval,
} = settings;

const handleSyncNow = async () => {
setIsSyncing(true);
try {
makeToast(t`Syncing library...`, {
variant: 'info',
persist: true,
key: SYNC_PROGRESS_TOAST_KEY,
action: (
<CircularProgress size={20} thickness={4} sx={{ color: 'warning.main', mr: 1, flexShrink: 0 }} />
),
});
const { data } = await requestManager.startSync().response;
const result = data?.startSync.result;
if (result === StartSyncResult.SyncInProgress) {
closeSnackbar(SYNC_PROGRESS_TOAST_KEY);
makeToast(t`Sync is already in progress`, 'info');
} else if (result === StartSyncResult.SyncDisabled) {
closeSnackbar(SYNC_PROGRESS_TOAST_KEY);
makeToast(t`SyncYomi is disabled`, 'warning');
}
} catch (e) {
closeSnackbar(SYNC_PROGRESS_TOAST_KEY);
makeToast(t`Failed to start sync`, 'error', getErrorMessage(e));
} finally {
setIsSyncing(false);
}
};

return (
<List
subheader={
<ListSubheader component="div" id="syncyomi-server-settings">
{t`SyncYomi`}
</ListSubheader>
}
>
<ListItem>
<ListItemText primary={t`Enable SyncYomi`} />
<Switch
edge="end"
checked={syncYomiEnabled}
onChange={(e) => updateSetting('syncYomiEnabled', e.target.checked)}
/>
</ListItem>
<TextSetting
settingName={t`Server address`}
value={syncYomiHost}
handleChange={(host) => updateSetting('syncYomiHost', host)}
disabled={!syncYomiEnabled}
/>
<TextSetting
settingName={t`API key`}
value={syncYomiApiKey}
handleChange={(apiKey) => updateSetting('syncYomiApiKey', apiKey)}
isPassword
disabled={!syncYomiEnabled}
/>
<ListItemButton onClick={handleSyncNow} disabled={!syncYomiEnabled || isSyncing}>
<ListItemText primary={t`Sync now`} />
{isSyncing && <CircularProgress size={24} />}
</ListItemButton>
<SelectSetting<string>
settingName={t`Auto-sync interval`}
value={normalizeSyncInterval(syncInterval)}
values={SYNC_INTERVAL_VALUES}
handleChange={(value) => updateSetting('syncInterval', value)}
disabled={!syncYomiEnabled}
/>
<ListItem>
<ListItemText primary={t`Manga`} />
<Switch
edge="end"
checked={syncDataManga}
onChange={(e) => updateSetting('syncDataManga', e.target.checked)}
disabled={!syncYomiEnabled}
/>
</ListItem>
<ListItem>
<ListItemText primary={t`Chapters`} secondary={t`Reading progress and bookmarks`} />
<Switch
edge="end"
checked={syncDataChapters}
onChange={(e) => updateSetting('syncDataChapters', e.target.checked)}
disabled={!syncYomiEnabled}
/>
</ListItem>
<ListItem>
<ListItemText primary={t`Tracking`} />
<Switch
edge="end"
checked={syncDataTracking}
onChange={(e) => updateSetting('syncDataTracking', e.target.checked)}
disabled={!syncYomiEnabled}
/>
</ListItem>
<ListItem>
<ListItemText primary={t`History`} />
<Switch
edge="end"
checked={syncDataHistory}
onChange={(e) => updateSetting('syncDataHistory', e.target.checked)}
disabled={!syncYomiEnabled}
/>
</ListItem>
<ListItem>
<ListItemText primary={t`Categories`} />
<Switch
edge="end"
checked={syncDataCategories}
onChange={(e) => updateSetting('syncDataCategories', e.target.checked)}
disabled={!syncYomiEnabled}
/>
</ListItem>
<ListItemLink to={AppRoutes.settings.children.syncyomi.children.triggers.path}>
<ListItemText
primary={t`Sync triggers`}
secondary={t`Define when synchronization is triggered automatically`}
/>
</ListItemLink>
</List>
);
};
Loading