From 9a27335760a049e53acb9347d78690e7967ccdc5 Mon Sep 17 00:00:00 2001 From: schroda <50052685+schroda@users.noreply.github.com> Date: Sat, 22 Apr 2023 13:03:51 +0200 Subject: [PATCH] Add option to include and exclude categories from the global update (#265) --- src/App.tsx | 4 + src/i18n/locale/en.json | 16 ++ src/screens/Settings.tsx | 7 + src/screens/settings/LibrarySettings.tsx | 228 +++++++++++++++++++++++ src/typings.ts | 7 + 5 files changed, 262 insertions(+) create mode 100644 src/screens/settings/LibrarySettings.tsx diff --git a/src/App.tsx b/src/App.tsx index 600cc580..d52ecf3d 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -28,6 +28,7 @@ import SourceMangas from 'screens/SourceMangas'; import Sources from 'screens/Sources'; import Updates from 'screens/Updates'; import 'i18n'; +import LibrarySettings from 'screens/settings/LibrarySettings'; const App: React.FC = () => ( @@ -57,6 +58,9 @@ const App: React.FC = () => ( + + + diff --git a/src/i18n/locale/en.json b/src/i18n/locale/en.json index a97e2dd6..d011124f 100644 --- a/src/i18n/locale/en.json +++ b/src/i18n/locale/en.json @@ -210,6 +210,8 @@ }, "error": { "label": { + "failed_to_load_data": "Unable to load data", + "failed_to_save_changes": "Failed to save changes", "invalid_action": "This is not a valid Action", "invalid_file_type": "invalid file type!", "update_failed": "Checking for updates failed!" @@ -235,6 +237,7 @@ "display": "Display", "filter": "Filter", "loading": "Loading...", + "none": "None", "sort": "Sort" }, "language": { @@ -285,6 +288,19 @@ } } }, + "settings": { + "global_update": { + "categories": { + "label": { + "exclude": "Exclude: {{excludedCategoriesText}}", + "include": "Include: {{includedCategoriesText}}", + "info": "Entries in excluded categories will not be updated even if they are also in included categories" + } + }, + "title": "Global update" + }, + "title": "Library Settings" + }, "title": "Library" }, "manga": { diff --git a/src/screens/Settings.tsx b/src/screens/Settings.tsx index e9734d11..86407762 100644 --- a/src/screens/Settings.tsx +++ b/src/screens/Settings.tsx @@ -39,6 +39,7 @@ import SearchSettings from 'screens/settings/SearchSettings'; import { useTranslation } from 'react-i18next'; import LanguageIcon from '@mui/icons-material/Language'; import { langCodeToName } from 'util/language'; +import CollectionsOutlinedBookmarkIcon from '@mui/icons-material/CollectionsBookmarkOutlined'; export default function Settings() { const { t, i18n } = useTranslation(); @@ -112,6 +113,12 @@ export default function Settings() { + + + + + + diff --git a/src/screens/settings/LibrarySettings.tsx b/src/screens/settings/LibrarySettings.tsx new file mode 100644 index 00000000..c7d21dee --- /dev/null +++ b/src/screens/settings/LibrarySettings.tsx @@ -0,0 +1,228 @@ +/* + * 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 React, { useContext, useEffect, useState } from 'react'; +import List from '@mui/material/List'; +import ListItemText from '@mui/material/ListItemText'; +import NavbarContext from 'components/context/NavbarContext'; +import ListSubheader from '@mui/material/ListSubheader'; +import client, { useQuery } from 'util/client'; +import { ICategory, IncludeInGlobalUpdate } from 'typings'; +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 ThreeStateCheckboxInput from 'components/atoms/ThreeStateCheckboxInput'; +import DialogTitle from '@mui/material/DialogTitle'; +import { styled } from '@mui/system'; +import makeToast from 'components/util/Toast'; +import { t as translate } from 'i18next'; +import { useTranslation } from 'react-i18next'; + +const CategoriesDiv = styled('div')({ + display: 'flex', + flexDirection: 'column', + maxHeight: '170px', + overflow: 'auto', +}); + +const includeInUpdateStatusToBoolean = (status: IncludeInGlobalUpdate) => { + if (status === IncludeInGlobalUpdate.UNSET) { + return null; + } + + return !!status; +}; + +const getCategoryUpdateInfo = ( + categories: ICategory[], + 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(', '); +}; + +export default function LibrarySettings() { + const { t } = useTranslation(); + const { setTitle, setAction } = useContext(NavbarContext); + + useEffect(() => { + setTitle(t('library.settings.title')); + setAction(null); + }, []); + + const { data: categories, loading, error: requestError, mutate } = useQuery('/api/v1/category/'); + + const [currentCategories, setCurrentCategories] = useState(categories ?? []); // categories to check if response categories changed + const [dialogCategories, setDialogCategories] = useState(categories ?? []); // categories that are shown and updated in the dialog + const [isDialogOpen, setIsDialogOpen] = useState(false); + + const retrievedCategoriesChanged = !loading && categories?.length && categories !== currentCategories; + if (retrievedCategoriesChanged) { + setCurrentCategories(categories); + setDialogCategories(categories); + } + + const unsetCategories: ICategory[] = + categories?.filter((category) => category.includeInUpdate === IncludeInGlobalUpdate.UNSET) ?? []; + const excludedCategories: ICategory[] = + categories?.filter((category) => category.includeInUpdate === IncludeInGlobalUpdate.EXCLUDE) ?? []; + const includedCategories: ICategory[] = + categories?.filter((category) => category.includeInUpdate === IncludeInGlobalUpdate.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: ICategory) => { + const formData = new FormData(); + formData.append('includeInUpdate', `${category.includeInUpdate}`); + + return client.patch(`/api/v1/category/${category.id}`, formData); + }; + + const updateCategories = async () => { + const categoriesToUpdate = dialogCategories.filter((category) => { + const currentCategory = currentCategories.find((currCategory) => currCategory.id === category.id); + + if (!currentCategory) { + return false; + } + + return currentCategory.includeInUpdate !== category.includeInUpdate; + }); + + try { + await Promise.all(categoriesToUpdate.map((category) => updateCategory(category))); + } catch (error) { + makeToast(t('global.error.label.failed_to_save_changes'), 'error'); + } finally { + setIsDialogOpen(false); + + if (categoriesToUpdate.length) { + setDialogCategories([]); + mutate([...dialogCategories], { revalidate: false }); + } + } + }; + + const closeDialog = () => { + setDialogCategories(categories ?? []); + setIsDialogOpen(false); + }; + + return ( + <> + + {t('library.settings.global_update.title')} + + } + > + + + + {t('library.settings.global_update.categories.label.include', { + includedCategoriesText, + })} + + + {t('library.settings.global_update.categories.label.exclude', { + excludedCategoriesText, + })} + + + } + secondaryTypographyProps={{ style: { display: 'flex', flexDirection: 'column' } }} + onClick={() => setIsDialogOpen(true)} + /> + + + + + + {t('category.title.categories')} + + {t('library.settings.global_update.categories.label.info')} + + + {dialogCategories.map((category) => ( + { + const newIncludeState: IncludeInGlobalUpdate = + checked == null ? IncludeInGlobalUpdate.UNSET : Number(checked); + + const categoryIndex = dialogCategories.findIndex( + (category_) => category_ === category, + ); + const updatedDialogCategories: ICategory[] = [ + ...dialogCategories.slice(0, categoryIndex), + { + ...category, + includeInUpdate: newIncludeState, + }, + ...dialogCategories.slice(categoryIndex + 1, dialogCategories.length), + ]; + + setDialogCategories(updatedDialogCategories); + }} + /> + ))} + + + + + + + + + ); +} diff --git a/src/typings.ts b/src/typings.ts index 01d3e7bc..462bbea1 100644 --- a/src/typings.ts +++ b/src/typings.ts @@ -173,11 +173,18 @@ export interface IPartialChapter { lastPageRead: number; } +export enum IncludeInGlobalUpdate { + EXCLUDE = 0, + INCLUDE = 1, + UNSET = -1, +} + export interface ICategory { id: number; order: number; name: string; default: boolean; + includeInUpdate: IncludeInGlobalUpdate; meta: Metadata; size: number; }