diff --git a/src/components/globalUpdate/CheckboxContainer.ts b/src/components/globalUpdate/CheckboxContainer.ts
new file mode 100644
index 00000000..c678d37b
--- /dev/null
+++ b/src/components/globalUpdate/CheckboxContainer.ts
@@ -0,0 +1,17 @@
+/*
+ * 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 { styled } from '@mui/material';
+
+// eslint-disable-next-line import/prefer-default-export
+export const CheckboxContainer = styled('div')({
+ display: 'flex',
+ flexDirection: 'column',
+ maxHeight: '170px',
+ overflow: 'auto',
+});
diff --git a/src/components/globalUpdate/GlobalUpdateSettings.tsx b/src/components/globalUpdate/GlobalUpdateSettings.tsx
new file mode 100644
index 00000000..a635f2ea
--- /dev/null
+++ b/src/components/globalUpdate/GlobalUpdateSettings.tsx
@@ -0,0 +1,31 @@
+/*
+ * 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 List from '@mui/material/List';
+import ListSubheader from '@mui/material/ListSubheader';
+import { useTranslation } from 'react-i18next';
+import { GlobalUpdateSettingsCategories } from '@/components/globalUpdate/GlobalUpdateSettingsCategories.tsx';
+import { GlobalUpdateSettingsEntries } from '@/components/globalUpdate/GlobalUpdateSettingsEntries.tsx';
+
+// eslint-disable-next-line import/prefer-default-export
+export const GlobalUpdateSettings = () => {
+ const { t } = useTranslation();
+
+ return (
+
+ {t('library.settings.global_update.title')}
+
+ }
+ >
+
+
+
+ );
+};
diff --git a/src/components/globalUpdate/GlobalUpdateSettingsCategories.tsx b/src/components/globalUpdate/GlobalUpdateSettingsCategories.tsx
new file mode 100644
index 00000000..83cc909a
--- /dev/null
+++ b/src/components/globalUpdate/GlobalUpdateSettingsCategories.tsx
@@ -0,0 +1,217 @@
+/*
+ * 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 { useTranslation } from 'react-i18next';
+import { useEffect, useState } from 'react';
+import ListItemButton from '@mui/material/ListItemButton';
+import ListItemText from '@mui/material/ListItemText';
+import Dialog from '@mui/material/Dialog';
+import DialogContent from '@mui/material/DialogContent';
+import DialogTitle from '@mui/material/DialogTitle';
+import DialogContentText from '@mui/material/DialogContentText';
+import DialogActions from '@mui/material/DialogActions';
+import Button from '@mui/material/Button';
+import { t as translate } from 'i18next';
+import ThreeStateCheckboxInput from '@/components/atoms/ThreeStateCheckboxInput.tsx';
+import makeToast from '@/components/util/Toast.tsx';
+import { IncludeInUpdate } from '@/lib/graphql/generated/graphql.ts';
+import { TCategory } from '@/typings.ts';
+import requestManager from '@/lib/requests/RequestManager.ts';
+import { CheckboxContainer } from '@/components/globalUpdate/CheckboxContainer.ts';
+
+const booleanToIncludeInStatus = (status: boolean | null | undefined): IncludeInUpdate => {
+ switch (status) {
+ case false:
+ return IncludeInUpdate.Exclude;
+ case true:
+ return IncludeInUpdate.Include;
+ case null:
+ case undefined:
+ return IncludeInUpdate.Unset;
+ default:
+ throw new Error(`booleanToIncludeInStatus: unexpected IncludeInUpdate status "${status}"`);
+ }
+};
+
+const includeInUpdateStatusToBoolean = (status: IncludeInUpdate): boolean | null => {
+ switch (status) {
+ case IncludeInUpdate.Exclude:
+ return false;
+ case IncludeInUpdate.Include:
+ return true;
+ case IncludeInUpdate.Unset:
+ return null;
+ default:
+ throw new Error(`includeInUpdateStatusToBoolean: unexpected IncludeInUpdate status "${status}"`);
+ }
+};
+
+const getCategoryUpdateInfo = (
+ categories: TCategory[],
+ areIncluded: boolean,
+ unsetCategories: number,
+ allCategories: number,
+ error: any,
+) => {
+ if (error) {
+ return translate('global.error.label.failed_to_load_data');
+ }
+ if (allCategories === -1) {
+ return translate('global.label.loading');
+ }
+
+ const noSpecificallyIncludedCategories = areIncluded && !categories.length && unsetCategories;
+ const includesAllCategories = categories.length === allCategories;
+ if (noSpecificallyIncludedCategories || includesAllCategories) {
+ return translate('extension.language.all');
+ }
+
+ if (!categories.length) {
+ return translate('global.label.none');
+ }
+
+ return categories.map((category) => category.name).join(', ');
+};
+
+// eslint-disable-next-line import/prefer-default-export
+export const GlobalUpdateSettingsCategories = () => {
+ const { t } = useTranslation();
+
+ const { data, error: requestError } = requestManager.useGetCategories();
+ const categories = data?.categories.nodes;
+ const [dialogCategories, setDialogCategories] = useState(categories ?? []);
+ const [isDialogOpen, setIsDialogOpen] = useState(false);
+
+ useEffect(() => {
+ if (!categories) {
+ return;
+ }
+
+ setDialogCategories(categories);
+ }, [categories]);
+
+ const unsetCategories: TCategory[] =
+ categories?.filter((category) => category.includeInUpdate === IncludeInUpdate.Unset) ?? [];
+ const excludedCategories: TCategory[] =
+ categories?.filter((category) => category.includeInUpdate === IncludeInUpdate.Exclude) ?? [];
+ const includedCategories: TCategory[] =
+ categories?.filter((category) => category.includeInUpdate === IncludeInUpdate.Include) ?? [];
+ const excludedCategoriesText = getCategoryUpdateInfo(
+ excludedCategories,
+ false,
+ unsetCategories.length,
+ categories?.length ?? -1,
+ requestError,
+ );
+ const includedCategoriesText = getCategoryUpdateInfo(
+ includedCategories,
+ true,
+ unsetCategories.length,
+ categories?.length ?? -1,
+ requestError,
+ );
+
+ const updateCategory = (category: TCategory) =>
+ requestManager.updateCategory(category.id, { includeInUpdate: category.includeInUpdate }).response;
+
+ const updateCategories = async () => {
+ const categoriesToUpdate = dialogCategories.filter((category) => {
+ const currentCategory = categories?.find((currCategory) => currCategory.id === category.id);
+
+ if (!currentCategory) {
+ return false;
+ }
+
+ return currentCategory.includeInUpdate !== category.includeInUpdate;
+ });
+
+ setIsDialogOpen(false);
+
+ try {
+ await Promise.all(categoriesToUpdate.map((category) => updateCategory(category)));
+ // TODO - update cache immediately
+ // mutate(categoriesEndpoint, [...dialogCategories], { revalidate: false });
+ } catch (error) {
+ makeToast(t('global.error.label.failed_to_save_changes'), 'error');
+ // mutate(categoriesEndpoint, [...categories]);
+ }
+ };
+
+ const closeDialog = () => {
+ setDialogCategories(categories ?? []);
+ setIsDialogOpen(false);
+ };
+
+ return (
+ <>
+ setIsDialogOpen(true)}>
+
+
+ {t('library.settings.global_update.categories.label.include', {
+ includedCategoriesText,
+ })}
+
+
+ {t('library.settings.global_update.categories.label.exclude', {
+ excludedCategoriesText,
+ })}
+
+ >
+ }
+ secondaryTypographyProps={{ style: { display: 'flex', flexDirection: 'column' } }}
+ />
+
+
+
+ >
+ );
+};
diff --git a/src/components/globalUpdate/GlobalUpdateSettingsEntries.tsx b/src/components/globalUpdate/GlobalUpdateSettingsEntries.tsx
new file mode 100644
index 00000000..2ad92f47
--- /dev/null
+++ b/src/components/globalUpdate/GlobalUpdateSettingsEntries.tsx
@@ -0,0 +1,164 @@
+/*
+ * 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 { t as translate } from 'i18next';
+import { useTranslation } from 'react-i18next';
+import { useEffect, useState } from 'react';
+import ListItemButton from '@mui/material/ListItemButton';
+import ListItemText from '@mui/material/ListItemText';
+import { Button, Dialog, DialogActions, DialogContent, DialogTitle } from '@mui/material';
+import { GetServerSettingsQuery } from '@/lib/graphql/generated/graphql.ts';
+import { TranslationKey } from '@/typings.ts';
+import requestManager from '@/lib/requests/RequestManager.ts';
+import makeToast from '@/components/util/Toast.tsx';
+import { CheckboxContainer } from '@/components/globalUpdate/CheckboxContainer';
+import CheckboxInput from '@/components/atoms/CheckboxInput';
+
+type GlobalUpdateSkipEntriesSettings = Pick<
+ GetServerSettingsQuery['settings'],
+ 'excludeUnreadChapters' | 'excludeNotStarted' | 'excludeCompleted'
+>;
+
+const settingToTextMap: { [setting in keyof GlobalUpdateSkipEntriesSettings]: TranslationKey } = {
+ excludeUnreadChapters: 'library.settings.global_update.entries.label.unread_chapters',
+ excludeNotStarted: 'library.settings.global_update.entries.label.not_started',
+ excludeCompleted: 'library.settings.global_update.entries.label.completed',
+};
+
+const getSkipMangasText = (settings: GlobalUpdateSkipEntriesSettings | undefined, isLoading: boolean, error: any) => {
+ if (error) {
+ return translate('global.error.label.failed_to_load_data');
+ }
+
+ if (!settings || isLoading) {
+ return translate('global.label.loading');
+ }
+
+ const skipSettings: string[] = [];
+
+ if (settings.excludeUnreadChapters) {
+ skipSettings.push(translate(settingToTextMap.excludeUnreadChapters) as string);
+ }
+
+ if (settings.excludeNotStarted) {
+ skipSettings.push(translate(settingToTextMap.excludeNotStarted) as string);
+ }
+
+ if (settings.excludeCompleted) {
+ skipSettings.push(translate(settingToTextMap.excludeCompleted) as string);
+ }
+
+ const isNothingExcluded = !skipSettings.length;
+ if (isNothingExcluded) {
+ skipSettings.push(translate('global.label.none'));
+ }
+
+ return skipSettings.join(', ');
+};
+
+const extractSkipEntriesSettings = (
+ serverSettings: GetServerSettingsQuery['settings'],
+): GlobalUpdateSkipEntriesSettings => ({
+ excludeCompleted: serverSettings.excludeCompleted,
+ excludeNotStarted: serverSettings.excludeNotStarted,
+ excludeUnreadChapters: serverSettings.excludeUnreadChapters,
+});
+
+// eslint-disable-next-line import/prefer-default-export
+export const GlobalUpdateSettingsEntries = () => {
+ const { t } = useTranslation();
+ const { data, loading, error: requestError } = requestManager.useGetServerSettings();
+ const globalUpdateSettings = data ? extractSkipEntriesSettings(data.settings) : undefined;
+ const [mutateSettings] = requestManager.useUpdateServerSettings();
+
+ const [dialogSettings, setDialogSettings] = useState(
+ globalUpdateSettings ?? ({} as GlobalUpdateSkipEntriesSettings),
+ );
+ const [isDialogOpen, setIsDialogOpen] = useState(false);
+
+ const skipEntriesText = getSkipMangasText(globalUpdateSettings, loading, requestError);
+
+ const updateSettings = async () => {
+ const didSettingsChange =
+ globalUpdateSettings?.excludeCompleted !== dialogSettings.excludeCompleted ||
+ globalUpdateSettings.excludeNotStarted !== dialogSettings.excludeNotStarted ||
+ globalUpdateSettings.excludeUnreadChapters !== dialogSettings.excludeUnreadChapters;
+
+ setIsDialogOpen(false);
+
+ if (!didSettingsChange) {
+ return;
+ }
+
+ try {
+ await mutateSettings({ variables: { input: { settings: dialogSettings } } });
+ } catch (error) {
+ makeToast(t('global.error.label.failed_to_save_changes'), 'error');
+ }
+ };
+
+ const closeDialog = () => {
+ setDialogSettings(globalUpdateSettings ?? ({} as GlobalUpdateSkipEntriesSettings));
+ setIsDialogOpen(false);
+ };
+
+ useEffect(() => {
+ if (!globalUpdateSettings) {
+ return;
+ }
+
+ setDialogSettings(globalUpdateSettings);
+ }, [
+ globalUpdateSettings?.excludeCompleted,
+ globalUpdateSettings?.excludeNotStarted,
+ globalUpdateSettings?.excludeUnreadChapters,
+ ]);
+
+ return (
+ <>
+ setIsDialogOpen(true)}>
+ setIsDialogOpen(true)}
+ />
+
+
+
+ >
+ );
+};
diff --git a/src/i18n/locale/en.json b/src/i18n/locale/en.json
index 39527f13..6ba186ec 100644
--- a/src/i18n/locale/en.json
+++ b/src/i18n/locale/en.json
@@ -313,6 +313,14 @@
"info": "Entries in excluded categories will not be updated even if they are also in included categories"
}
},
+ "entries": {
+ "title": "Skip updating entries",
+ "label": {
+ "completed": "With \"Completed\" status",
+ "not_started": "That haven't been started",
+ "unread_chapters": "With unread chapter(s)"
+ }
+ },
"title": "Global update"
},
"title": "Library Settings"
diff --git a/src/lib/requests/RequestManager.ts b/src/lib/requests/RequestManager.ts
index ba35c996..4f1abf90 100644
--- a/src/lib/requests/RequestManager.ts
+++ b/src/lib/requests/RequestManager.ts
@@ -83,6 +83,7 @@ import {
GetMangaQueryVariables,
GetMangasQuery,
GetMangasQueryVariables,
+ GetServerSettingsQuery,
GetSourceMangasFetchMutation,
GetSourceMangasFetchMutationVariables,
GetSourceQuery,
@@ -137,6 +138,8 @@ import {
UpdateMangaPatchInput,
UpdaterSubscription,
UpdaterSubscriptionVariables,
+ UpdateServerSettingsMutation,
+ UpdateServerSettingsMutationVariables,
UpdateSourcePreferencesMutation,
UpdateSourcePreferencesMutationVariables,
ValidateBackupQuery,
@@ -199,6 +202,8 @@ import { RESTORE_BACKUP } from '@/lib/graphql/mutations/BackupMutation.ts';
import { VALIDATE_BACKUP } from '@/lib/graphql/queries/BackupQuery.ts';
import { DOWNLOAD_STATUS_SUBSCRIPTION } from '@/lib/graphql/subscriptions/DownloaderSubscription.ts';
import { UPDATER_SUBSCRIPTION } from '@/lib/graphql/subscriptions/UpdaterSubscription.ts';
+import { GET_SERVER_SETTINGS } from '@/lib/graphql/queries/SettingsQuery.ts';
+import { UPDATE_SERVER_SETTINGS } from '@/lib/graphql/mutations/SettingsMutation.ts';
enum GQLMethod {
QUERY = 'QUERY',
@@ -1776,6 +1781,18 @@ export class RequestManager {
): SubscriptionResult {
return this.doRequest(GQLMethod.USE_SUBSCRIPTION, UPDATER_SUBSCRIPTION, {}, options);
}
+
+ public useGetServerSettings(
+ options?: QueryHookOptions,
+ ): AbortableApolloUseQueryResponse {
+ return this.doRequest(GQLMethod.USE_QUERY, GET_SERVER_SETTINGS, undefined, options);
+ }
+
+ public useUpdateServerSettings(
+ options?: MutationHookOptions,
+ ): AbortableApolloUseMutationResponse {
+ return this.doRequest(GQLMethod.USE_MUTATION, UPDATE_SERVER_SETTINGS, undefined, options);
+ }
}
const requestManager = new RequestManager();
diff --git a/src/lib/requests/client/GraphQLClient.ts b/src/lib/requests/client/GraphQLClient.ts
index c2fef6a4..a4f35205 100644
--- a/src/lib/requests/client/GraphQLClient.ts
+++ b/src/lib/requests/client/GraphQLClient.ts
@@ -26,6 +26,7 @@ const typePolicies: StrictTypedTypePolicies = {
GlobalMetaType: { keyFields: ['key'] },
ExtensionType: { keyFields: ['apkName'] },
AboutPayload: { keyFields: [] },
+ SettingsType: { keyFields: [] },
Query: {
fields: {
chapters: {
diff --git a/src/screens/settings/LibrarySettings.tsx b/src/screens/settings/LibrarySettings.tsx
index cc753005..9133426e 100644
--- a/src/screens/settings/LibrarySettings.tsx
+++ b/src/screens/settings/LibrarySettings.tsx
@@ -6,88 +6,11 @@
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
-import { useContext, useEffect, useState } from 'react';
-import List from '@mui/material/List';
-import ListItemText from '@mui/material/ListItemText';
-import ListSubheader from '@mui/material/ListSubheader';
-import Dialog from '@mui/material/Dialog';
-import DialogContent from '@mui/material/DialogContent';
-import DialogContentText from '@mui/material/DialogContentText';
-import DialogActions from '@mui/material/DialogActions';
-import Button from '@mui/material/Button';
-import ListItemButton from '@mui/material/ListItemButton';
-import DialogTitle from '@mui/material/DialogTitle';
-import { styled } from '@mui/material';
-import { t as translate } from 'i18next';
import { useTranslation } from 'react-i18next';
-import requestManager from '@/lib/requests/RequestManager.ts';
-import makeToast from '@/components/util/Toast';
-import ThreeStateCheckboxInput from '@/components/atoms/ThreeStateCheckboxInput';
-import NavbarContext, { useSetDefaultBackTo } from '@/components/context/NavbarContext';
-import SearchSettings from '@/screens/settings/SearchSettings';
-import { IncludeInUpdate } from '@/lib/graphql/generated/graphql.ts';
-import { TCategory } from '@/typings.ts';
-
-const CategoriesDiv = styled('div')({
- display: 'flex',
- flexDirection: 'column',
- maxHeight: '170px',
- overflow: 'auto',
-});
-
-const booleanToIncludeInStatus = (status: boolean | null | undefined): IncludeInUpdate => {
- switch (status) {
- case false:
- return IncludeInUpdate.Exclude;
- case true:
- return IncludeInUpdate.Include;
- case null:
- case undefined:
- return IncludeInUpdate.Unset;
- default:
- throw new Error(`booleanToIncludeInStatus: unexpected IncludeInUpdate status "${status}"`);
- }
-};
-
-const includeInUpdateStatusToBoolean = (status: IncludeInUpdate): boolean | null => {
- switch (status) {
- case IncludeInUpdate.Exclude:
- return false;
- case IncludeInUpdate.Include:
- return true;
- case IncludeInUpdate.Unset:
- return null;
- default:
- throw new Error(`includeInUpdateStatusToBoolean: unexpected IncludeInUpdate status "${status}"`);
- }
-};
-
-const getCategoryUpdateInfo = (
- categories: TCategory[],
- areIncluded: boolean,
- unsetCategories: number,
- allCategories: number,
- error: any,
-) => {
- if (error) {
- return translate('global.error.label.failed_to_load_data');
- }
- if (allCategories === -1) {
- return translate('global.label.loading');
- }
-
- const noSpecificallyIncludedCategories = areIncluded && !categories.length && unsetCategories;
- const includesAllCategories = categories.length === allCategories;
- if (noSpecificallyIncludedCategories || includesAllCategories) {
- return translate('extension.language.all');
- }
-
- if (!categories.length) {
- return translate('global.label.none');
- }
-
- return categories.map((category) => category.name).join(', ');
-};
+import { useContext, useEffect } from 'react';
+import NavbarContext, { useSetDefaultBackTo } from '@/components/context/NavbarContext.tsx';
+import { GlobalUpdateSettings } from '@/components/globalUpdate/GlobalUpdateSettings.tsx';
+import SearchSettings from '@/screens/settings/SearchSettings.tsx';
export default function LibrarySettings() {
const { t } = useTranslation();
@@ -100,153 +23,10 @@ export default function LibrarySettings() {
useSetDefaultBackTo('settings');
- const { data, error: requestError } = requestManager.useGetCategories();
- const categories = data?.categories.nodes;
- const [dialogCategories, setDialogCategories] = useState(categories ?? []);
- const [isDialogOpen, setIsDialogOpen] = useState(false);
-
- useEffect(() => {
- if (!categories) {
- return;
- }
-
- setDialogCategories(categories);
- }, [categories]);
-
- const unsetCategories: TCategory[] =
- categories?.filter((category) => category.includeInUpdate === IncludeInUpdate.Unset) ?? [];
- const excludedCategories: TCategory[] =
- categories?.filter((category) => category.includeInUpdate === IncludeInUpdate.Exclude) ?? [];
- const includedCategories: TCategory[] =
- categories?.filter((category) => category.includeInUpdate === IncludeInUpdate.Include) ?? [];
- const excludedCategoriesText = getCategoryUpdateInfo(
- excludedCategories,
- false,
- unsetCategories.length,
- categories?.length ?? -1,
- requestError,
- );
- const includedCategoriesText = getCategoryUpdateInfo(
- includedCategories,
- true,
- unsetCategories.length,
- categories?.length ?? -1,
- requestError,
- );
-
- const updateCategory = (category: TCategory) =>
- requestManager.updateCategory(category.id, { includeInUpdate: category.includeInUpdate }).response;
-
- const updateCategories = async () => {
- const categoriesToUpdate = dialogCategories.filter((category) => {
- const currentCategory = categories?.find((currCategory) => currCategory.id === category.id);
-
- if (!currentCategory) {
- return false;
- }
-
- return currentCategory.includeInUpdate !== category.includeInUpdate;
- });
-
- setIsDialogOpen(false);
-
- try {
- await Promise.all(categoriesToUpdate.map((category) => updateCategory(category)));
- // TODO - update cache immediately
- // mutate(categoriesEndpoint, [...dialogCategories], { revalidate: false });
- } catch (error) {
- makeToast(t('global.error.label.failed_to_save_changes'), 'error');
- // mutate(categoriesEndpoint, [...categories]);
- }
- };
-
- const closeDialog = () => {
- setDialogCategories(categories ?? []);
- setIsDialogOpen(false);
- };
-
return (
<>
-
-
-
-
+
+
>
);
}
diff --git a/src/screens/settings/SearchSettings.tsx b/src/screens/settings/SearchSettings.tsx
index 179b22ab..3474c2f9 100644
--- a/src/screens/settings/SearchSettings.tsx
+++ b/src/screens/settings/SearchSettings.tsx
@@ -6,11 +6,12 @@
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
-import { ListItem, ListItemText, Switch } from '@mui/material';
+import { List, ListItem, ListItemText, Switch } from '@mui/material';
import ListItemSecondaryAction from '@mui/material/ListItemSecondaryAction';
import ListItemIcon from '@mui/material/ListItemIcon';
import SearchIcon from '@mui/icons-material/Search';
import { useTranslation } from 'react-i18next';
+import ListSubheader from '@mui/material/ListSubheader';
import { SearchMetadataKeys } from '@/typings';
import { convertToGqlMeta, requestUpdateServerMetadata } from '@/util/metadata';
import { useSearchSettings } from '@/util/searchSettings';
@@ -29,18 +30,26 @@ export default function SearchSettings() {
);
};
return (
-
-
-
-
-
-
- setSettingValue('ignoreFilters', e.target.checked)}
- />
-
-
+
+ {t('search.title.search')}
+
+ }
+ >
+
+
+
+
+
+
+ setSettingValue('ignoreFilters', e.target.checked)}
+ />
+
+
+
);
}