Feature/cleanup chapter actions (#525)
* Extract chapter actions into "Chapters" * Extract chapter action menu into component * Rename "SelectionFABActionItem" to "MenuItem" * Decouple "MenuItem" from "SelectionFAB" * Use "MenuItem" in "ActionMenus" * Merge chapter action menus * Simplify chapter "mark as read" action
This commit is contained in:
@@ -6,25 +6,97 @@
|
||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
import { TChapter } from '@/typings.ts';
|
||||
import { t as translate } from 'i18next';
|
||||
import { TChapter, TranslationKey } from '@/typings.ts';
|
||||
import { makeToast } from '@/components/util/Toast.tsx';
|
||||
import { requestManager } from '@/lib/requests/RequestManager.ts';
|
||||
import { getMetadataServerSettings } from '@/util/metadataServerSettings.ts';
|
||||
|
||||
type ChapterDownloadInfo = Pick<TChapter, 'isDownloaded'>;
|
||||
type ChapterBookmarkInfo = Pick<TChapter, 'isBookmarked'>;
|
||||
export type ChapterAction = 'download' | 'delete' | 'bookmark' | 'unbookmark' | 'mark_as_read' | 'mark_as_unread';
|
||||
|
||||
export const actionToTranslationKey: {
|
||||
[key in ChapterAction]: {
|
||||
action: {
|
||||
single: TranslationKey;
|
||||
selected: TranslationKey;
|
||||
};
|
||||
success: TranslationKey;
|
||||
error: TranslationKey;
|
||||
};
|
||||
} = {
|
||||
download: {
|
||||
action: {
|
||||
single: 'chapter.action.download.add.label.action',
|
||||
selected: 'chapter.action.download.add.button.selected',
|
||||
},
|
||||
success: 'chapter.action.download.add.label.success',
|
||||
error: 'chapter.action.download.add.label.error',
|
||||
},
|
||||
delete: {
|
||||
action: {
|
||||
single: 'chapter.action.download.delete.label.action',
|
||||
selected: 'chapter.action.download.delete.button.selected',
|
||||
},
|
||||
success: 'chapter.action.download.delete.label.success',
|
||||
error: 'chapter.action.download.delete.label.error',
|
||||
},
|
||||
bookmark: {
|
||||
action: {
|
||||
single: 'chapter.action.bookmark.add.label.action',
|
||||
selected: 'chapter.action.bookmark.add.button.selected',
|
||||
},
|
||||
success: 'chapter.action.bookmark.add.label.success',
|
||||
error: 'chapter.action.bookmark.add.label.error',
|
||||
},
|
||||
unbookmark: {
|
||||
action: {
|
||||
single: 'chapter.action.bookmark.remove.label.action',
|
||||
selected: 'chapter.action.bookmark.remove.button.selected',
|
||||
},
|
||||
success: 'chapter.action.bookmark.remove.label.success',
|
||||
error: 'chapter.action.bookmark.remove.label.error',
|
||||
},
|
||||
mark_as_read: {
|
||||
action: {
|
||||
single: 'chapter.action.mark_as_read.add.label.action.current',
|
||||
selected: 'chapter.action.mark_as_read.add.button.selected',
|
||||
},
|
||||
success: 'chapter.action.mark_as_read.add.label.success',
|
||||
error: 'chapter.action.mark_as_read.add.label.error',
|
||||
},
|
||||
mark_as_unread: {
|
||||
action: {
|
||||
single: 'chapter.action.mark_as_read.remove.label.action',
|
||||
selected: 'chapter.action.mark_as_read.remove.button.selected',
|
||||
},
|
||||
success: 'chapter.action.mark_as_read.remove.label.success',
|
||||
error: 'chapter.action.mark_as_read.remove.label.error',
|
||||
},
|
||||
};
|
||||
|
||||
export type ChapterIdInfo = Pick<TChapter, 'id'>;
|
||||
export type ChapterDownloadInfo = ChapterIdInfo & Pick<TChapter, 'isDownloaded'>;
|
||||
export type ChapterBookmarkInfo = ChapterIdInfo & Pick<TChapter, 'isBookmarked'>;
|
||||
export type ChapterReadInfo = ChapterIdInfo & Pick<TChapter, 'isRead'>;
|
||||
|
||||
export class Chapters {
|
||||
static getIds(chapters: { id: number }[]): number[] {
|
||||
return chapters.map((chapter) => chapter.id);
|
||||
}
|
||||
|
||||
static isDeletable({ isDownloaded }: ChapterDownloadInfo): boolean {
|
||||
static isDownloaded({ isDownloaded }: ChapterDownloadInfo): boolean {
|
||||
return isDownloaded;
|
||||
}
|
||||
|
||||
static getDownloaded<Chapter extends ChapterDownloadInfo>(chapters: Chapter[]): Chapter[] {
|
||||
return chapters.filter(Chapters.isDownloaded);
|
||||
}
|
||||
|
||||
static isAutoDeletable(
|
||||
{ isBookmarked, ...chapter }: ChapterDownloadInfo & ChapterBookmarkInfo,
|
||||
canDeleteBookmarked: boolean = false,
|
||||
): boolean {
|
||||
return Chapters.isDeletable(chapter) && (!isBookmarked || canDeleteBookmarked);
|
||||
return Chapters.isDownloaded(chapter) && (!isBookmarked || canDeleteBookmarked);
|
||||
}
|
||||
|
||||
static getAutoDeletable<Chapters extends ChapterDownloadInfo & ChapterBookmarkInfo>(
|
||||
@@ -33,4 +105,139 @@ export class Chapters {
|
||||
): Chapters[] {
|
||||
return chapters.filter((chapter) => Chapters.isAutoDeletable(chapter, canDeleteBookmarked));
|
||||
}
|
||||
|
||||
static isBookmarked({ isBookmarked }: ChapterBookmarkInfo): boolean {
|
||||
return isBookmarked;
|
||||
}
|
||||
|
||||
static getBookmarked<Chapter extends ChapterBookmarkInfo>(chapters: Chapter[]): Chapter[] {
|
||||
return chapters.filter(Chapters.isBookmarked);
|
||||
}
|
||||
|
||||
static getNonBookmarked<Chapter extends ChapterBookmarkInfo>(chapters: Chapter[]): Chapter[] {
|
||||
return chapters.filter((chapter) => !Chapters.isBookmarked(chapter));
|
||||
}
|
||||
|
||||
static isRead({ isRead }: ChapterReadInfo): boolean {
|
||||
return isRead;
|
||||
}
|
||||
|
||||
static getRead<Chapter extends ChapterReadInfo>(chapters: Chapter[]): Chapter[] {
|
||||
return chapters.filter(Chapters.isRead);
|
||||
}
|
||||
|
||||
static getNonRead<Chapter extends ChapterReadInfo>(chapters: Chapter[]): Chapter[] {
|
||||
return chapters.filter((chapter) => !Chapters.isRead(chapter));
|
||||
}
|
||||
|
||||
static async download(chapterIds: number[]): Promise<void> {
|
||||
return Chapters.executeAction(
|
||||
'download',
|
||||
chapterIds.length,
|
||||
() => requestManager.addChaptersToDownloadQueue(chapterIds).response,
|
||||
);
|
||||
}
|
||||
|
||||
static async delete(chapterIds: number[]): Promise<void> {
|
||||
return Chapters.executeAction(
|
||||
'delete',
|
||||
chapterIds.length,
|
||||
() => requestManager.deleteDownloadedChapters(chapterIds).response,
|
||||
);
|
||||
}
|
||||
|
||||
static async markAsRead(
|
||||
chapters: (ChapterDownloadInfo & ChapterBookmarkInfo)[],
|
||||
wasManuallyMarkedAsRead: boolean = false,
|
||||
): Promise<void> {
|
||||
const { deleteChaptersManuallyMarkedRead, deleteChaptersWithBookmark } = await getMetadataServerSettings();
|
||||
const chapterIdsToDelete =
|
||||
deleteChaptersManuallyMarkedRead && wasManuallyMarkedAsRead
|
||||
? Chapters.getIds(Chapters.getAutoDeletable(chapters, deleteChaptersWithBookmark))
|
||||
: [];
|
||||
return Chapters.executeAction(
|
||||
'mark_as_read',
|
||||
chapters.length,
|
||||
() =>
|
||||
requestManager.updateChapters(Chapters.getIds(chapters), {
|
||||
isRead: true,
|
||||
lastPageRead: 0,
|
||||
chapterIdsToDelete,
|
||||
}).response,
|
||||
);
|
||||
}
|
||||
|
||||
static async markAsUnread(chapterIds: number[]): Promise<void> {
|
||||
return Chapters.executeAction(
|
||||
'mark_as_unread',
|
||||
chapterIds.length,
|
||||
() => requestManager.updateChapters(chapterIds, { isRead: false }).response,
|
||||
);
|
||||
}
|
||||
|
||||
static async bookmark(chapterIds: number[]): Promise<void> {
|
||||
return Chapters.executeAction(
|
||||
'bookmark',
|
||||
chapterIds.length,
|
||||
() => requestManager.updateChapters(chapterIds, { isBookmarked: true }).response,
|
||||
);
|
||||
}
|
||||
|
||||
static async unBookmark(chapterIds: number[]): Promise<void> {
|
||||
return Chapters.executeAction(
|
||||
'unbookmark',
|
||||
chapterIds.length,
|
||||
() => requestManager.updateChapters(chapterIds, { isBookmarked: false }).response,
|
||||
);
|
||||
}
|
||||
|
||||
private static async executeAction(
|
||||
action: ChapterAction,
|
||||
itemCount: number,
|
||||
fnToExecute: () => Promise<unknown>,
|
||||
): Promise<void> {
|
||||
try {
|
||||
await fnToExecute();
|
||||
makeToast(translate(actionToTranslationKey[action].success, { count: itemCount }), 'success');
|
||||
} catch (e) {
|
||||
makeToast(translate(actionToTranslationKey[action].error, { count: itemCount }), 'error');
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
static async performAction<Action extends ChapterAction>(
|
||||
action: Action,
|
||||
chapterIds: number[],
|
||||
{
|
||||
wasManuallyMarkedAsRead,
|
||||
chapters,
|
||||
}: Action extends 'mark_as_read'
|
||||
? { wasManuallyMarkedAsRead: boolean; chapters?: never }
|
||||
: Action extends 'change_categories'
|
||||
? {
|
||||
wasManuallyMarkedAsRead?: never;
|
||||
chapters: (ChapterDownloadInfo & ChapterBookmarkInfo & ChapterReadInfo)[];
|
||||
}
|
||||
: {
|
||||
wasManuallyMarkedAsRead?: boolean;
|
||||
chapters?: (ChapterDownloadInfo & ChapterBookmarkInfo & ChapterReadInfo)[];
|
||||
},
|
||||
): Promise<void> {
|
||||
switch (action) {
|
||||
case 'download':
|
||||
return Chapters.download(chapterIds);
|
||||
case 'delete':
|
||||
return Chapters.delete(chapterIds);
|
||||
case 'mark_as_read':
|
||||
return Chapters.markAsRead(chapters!, wasManuallyMarkedAsRead!);
|
||||
case 'mark_as_unread':
|
||||
return Chapters.markAsUnread(chapterIds);
|
||||
case 'bookmark':
|
||||
return Chapters.bookmark(chapterIds);
|
||||
case 'unbookmark':
|
||||
return Chapters.unBookmark(chapterIds);
|
||||
default:
|
||||
throw new Error(`Chapters::performAction: unknown action "${action}"`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
54
src/lib/data/ChaptersWithMeta.ts
Normal file
54
src/lib/data/ChaptersWithMeta.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
/*
|
||||
* 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 { TChapter } from '@/typings.ts';
|
||||
import { DownloadType } from '@/lib/graphql/generated/graphql.ts';
|
||||
import { Chapters } from '@/lib/data/Chapters.ts';
|
||||
|
||||
export type ChapterWithMetaType = {
|
||||
chapter: TChapter;
|
||||
downloadChapter: DownloadType | undefined;
|
||||
};
|
||||
|
||||
export class ChaptersWithMeta {
|
||||
static getChapters(chapters: ChapterWithMetaType[]): TChapter[] {
|
||||
return chapters.map(({ chapter }) => chapter);
|
||||
}
|
||||
|
||||
static getIds(chapters: ChapterWithMetaType[]): number[] {
|
||||
return Chapters.getIds(ChaptersWithMeta.getChapters(chapters));
|
||||
}
|
||||
|
||||
static getDownloaded<Chapter extends ChapterWithMetaType>(chapters: Chapter[]): Chapter[] {
|
||||
return chapters.filter(({ chapter }) => Chapters.isDownloaded(chapter));
|
||||
}
|
||||
|
||||
static getNonDownloaded<Chapter extends ChapterWithMetaType>(chapters: Chapter[]): Chapter[] {
|
||||
return chapters.filter(({ chapter }) => !Chapters.isDownloaded(chapter));
|
||||
}
|
||||
|
||||
static getDownloadable<Chapter extends ChapterWithMetaType>(chapters: Chapter[]): Chapter[] {
|
||||
return chapters.filter(({ chapter, downloadChapter }) => !Chapters.isDownloaded(chapter) && !downloadChapter);
|
||||
}
|
||||
|
||||
static getBookmarked<Chapter extends ChapterWithMetaType>(chapters: Chapter[]): Chapter[] {
|
||||
return chapters.filter(({ chapter }) => Chapters.isBookmarked(chapter));
|
||||
}
|
||||
|
||||
static getNonBookmarked<Chapter extends ChapterWithMetaType>(chapters: Chapter[]): Chapter[] {
|
||||
return chapters.filter(({ chapter }) => !Chapters.isBookmarked(chapter));
|
||||
}
|
||||
|
||||
static getRead<Chapter extends ChapterWithMetaType>(chapters: Chapter[]): Chapter[] {
|
||||
return chapters.filter(({ chapter }) => Chapters.isRead(chapter));
|
||||
}
|
||||
|
||||
static getNonRead<Chapter extends ChapterWithMetaType>(chapters: Chapter[]): Chapter[] {
|
||||
return chapters.filter(({ chapter }) => !Chapters.isRead(chapter));
|
||||
}
|
||||
}
|
||||
@@ -15,7 +15,6 @@ import {
|
||||
UpdateMangaCategoriesPatchInput,
|
||||
} from '@/lib/graphql/generated/graphql.ts';
|
||||
import { Chapters } from '@/lib/data/Chapters.ts';
|
||||
import { getMetadataServerSettings } from '@/util/metadataServerSettings.ts';
|
||||
import { makeToast } from '@/components/util/Toast.tsx';
|
||||
|
||||
export type MangaAction =
|
||||
@@ -26,33 +25,54 @@ export type MangaAction =
|
||||
| 'remove_from_library'
|
||||
| 'change_categories';
|
||||
|
||||
const actionToTranslationKey: {
|
||||
export const actionToTranslationKey: {
|
||||
[key in MangaAction]: {
|
||||
action: {
|
||||
selected: TranslationKey;
|
||||
};
|
||||
success: TranslationKey;
|
||||
error: TranslationKey;
|
||||
};
|
||||
} = {
|
||||
download: {
|
||||
action: {
|
||||
selected: 'chapter.action.download.add.button.selected',
|
||||
},
|
||||
success: 'chapter.action.download.add.label.success',
|
||||
error: 'chapter.action.download.add.label.error',
|
||||
},
|
||||
delete: {
|
||||
action: {
|
||||
selected: 'chapter.action.download.delete.button.selected',
|
||||
},
|
||||
success: 'chapter.action.download.delete.label.success',
|
||||
error: 'chapter.action.download.delete.label.error',
|
||||
},
|
||||
mark_as_read: {
|
||||
action: {
|
||||
selected: 'chapter.action.mark_as_read.add.button.selected',
|
||||
},
|
||||
success: 'chapter.action.mark_as_read.add.label.success',
|
||||
error: 'chapter.action.mark_as_read.add.label.error',
|
||||
},
|
||||
mark_as_unread: {
|
||||
action: {
|
||||
selected: 'chapter.action.mark_as_read.remove.button.selected',
|
||||
},
|
||||
success: 'chapter.action.mark_as_read.remove.label.success',
|
||||
error: 'chapter.action.mark_as_read.remove.label.error',
|
||||
},
|
||||
remove_from_library: {
|
||||
action: {
|
||||
selected: 'manga.action.library.remove.button.selected',
|
||||
},
|
||||
success: 'manga.action.library.remove.label.success',
|
||||
error: 'manga.action.library.remove.label.error',
|
||||
},
|
||||
change_categories: {
|
||||
action: {
|
||||
selected: 'manga.action.category.button.selected',
|
||||
},
|
||||
success: 'manga.action.category.label.success',
|
||||
error: 'manga.action.category.label.error',
|
||||
},
|
||||
@@ -124,45 +144,22 @@ export class Mangas {
|
||||
|
||||
static async downloadChapters(mangaIds: number[]): Promise<void> {
|
||||
const chapters = await Mangas.getChapterIdsWithState(mangaIds, { isDownloaded: false });
|
||||
return Mangas.executeAction(
|
||||
'download',
|
||||
chapters.length,
|
||||
() => requestManager.addChaptersToDownloadQueue(Chapters.getIds(chapters)).response,
|
||||
);
|
||||
return Chapters.download(Chapters.getIds(chapters));
|
||||
}
|
||||
|
||||
static async deleteChapters(mangaIds: number[]): Promise<void> {
|
||||
const chapters = await Mangas.getChapterIdsWithState(mangaIds, { isDownloaded: true });
|
||||
return Mangas.executeAction(
|
||||
'delete',
|
||||
chapters.length,
|
||||
() => requestManager.deleteDownloadedChapters(Chapters.getIds(chapters)).response,
|
||||
);
|
||||
return Chapters.delete(Chapters.getIds(chapters));
|
||||
}
|
||||
|
||||
static async markAsRead(mangaIds: number[], deleteChapters: boolean = false): Promise<void> {
|
||||
const [chapters, { deleteChaptersWithBookmark }] = await Promise.all([
|
||||
Mangas.getChapterIdsWithState(mangaIds, { isRead: false }),
|
||||
getMetadataServerSettings(),
|
||||
]);
|
||||
const chapterIdsToDelete = deleteChapters
|
||||
? Chapters.getIds(Chapters.getAutoDeletable(chapters, deleteChaptersWithBookmark))
|
||||
: [];
|
||||
return Mangas.executeAction(
|
||||
'mark_as_read',
|
||||
chapterIdsToDelete.length,
|
||||
() =>
|
||||
requestManager.updateChapters(Chapters.getIds(chapters), { isRead: true, chapterIdsToDelete }).response,
|
||||
);
|
||||
static async markAsRead(mangaIds: number[], wasManuallyMarkedAsRead: boolean = false): Promise<void> {
|
||||
const chapters = await Mangas.getChapterIdsWithState(mangaIds, { isRead: false });
|
||||
return Chapters.markAsRead(chapters, wasManuallyMarkedAsRead);
|
||||
}
|
||||
|
||||
static async markAsUnread(mangaIds: number[]): Promise<void> {
|
||||
const chapters = await Mangas.getChapterIdsWithState(mangaIds, { isRead: true });
|
||||
return Mangas.executeAction(
|
||||
'mark_as_unread',
|
||||
chapters.length,
|
||||
() => requestManager.updateChapters(Chapters.getIds(chapters), { isRead: false }).response,
|
||||
);
|
||||
return Chapters.markAsUnread(Chapters.getIds(chapters));
|
||||
}
|
||||
|
||||
static async removeFromLibrary(mangaIds: number[]): Promise<void> {
|
||||
@@ -199,13 +196,13 @@ export class Mangas {
|
||||
action: Action,
|
||||
mangaIds: number[],
|
||||
{
|
||||
autoDeleteChapters,
|
||||
wasManuallyMarkedAsRead,
|
||||
changeCategoriesPatch,
|
||||
}: Action extends 'mark_as_read'
|
||||
? { autoDeleteChapters: boolean; changeCategoriesPatch?: never }
|
||||
? { wasManuallyMarkedAsRead: boolean; changeCategoriesPatch?: never }
|
||||
: Action extends 'change_categories'
|
||||
? { autoDeleteChapters?: never; changeCategoriesPatch: UpdateMangaCategoriesPatchInput }
|
||||
: { autoDeleteChapters?: boolean; changeCategoriesPatch?: UpdateMangaCategoriesPatchInput },
|
||||
? { wasManuallyMarkedAsRead?: never; changeCategoriesPatch: UpdateMangaCategoriesPatchInput }
|
||||
: { wasManuallyMarkedAsRead?: boolean; changeCategoriesPatch?: UpdateMangaCategoriesPatchInput },
|
||||
): Promise<void> {
|
||||
switch (action) {
|
||||
case 'download':
|
||||
@@ -213,7 +210,7 @@ export class Mangas {
|
||||
case 'delete':
|
||||
return Mangas.deleteChapters(mangaIds);
|
||||
case 'mark_as_read':
|
||||
return Mangas.markAsRead(mangaIds, autoDeleteChapters!);
|
||||
return Mangas.markAsRead(mangaIds, wasManuallyMarkedAsRead!);
|
||||
case 'mark_as_unread':
|
||||
return Mangas.markAsUnread(mangaIds);
|
||||
case 'remove_from_library':
|
||||
@@ -221,7 +218,7 @@ export class Mangas {
|
||||
case 'change_categories':
|
||||
return Mangas.changeCategories(mangaIds, changeCategoriesPatch!);
|
||||
default:
|
||||
throw new Error(`performMangasAction::performAction: unknown action "${action}"`);
|
||||
throw new Error(`Mangas::performAction: unknown action "${action}"`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1452,6 +1452,13 @@ export class RequestManager {
|
||||
return this.doRequest(GQLMethod.USE_QUERY, GET_CHAPTERS, variables, options);
|
||||
}
|
||||
|
||||
public getChapters(
|
||||
variables: GetChaptersQueryVariables,
|
||||
options?: QueryOptions<GetChaptersQueryVariables, GetChaptersQuery>,
|
||||
): AbortabaleApolloQueryResponse<GetChaptersQuery> {
|
||||
return this.doRequest(GQLMethod.QUERY, GET_CHAPTERS, variables, options);
|
||||
}
|
||||
|
||||
public useGetMangaChapters(
|
||||
mangaId: number | string,
|
||||
options?: QueryHookOptions<GetChaptersQuery, GetChaptersQueryVariables>,
|
||||
|
||||
Reference in New Issue
Block a user