Files
Kavita/UI/Web/src/app/_services/action.service.ts
T
2026-05-27 10:42:39 -06:00

1594 lines
66 KiB
TypeScript

import {inject, Injectable} from '@angular/core';
import {NgbModalRef} from '@ng-bootstrap/ng-bootstrap';
import {ToastrService} from 'ngx-toastr';
import {catchError, finalize, map, take} from 'rxjs/operators';
import {ListSelectModalComponent} from '../shared/_components/list-select-modal/list-select-modal.component';
import {ScrobbleProvider} from './scrobbling.service';
import {
EditReadingListModalComponent
} from '../reading-list/_modals/edit-reading-list-modal/edit-reading-list-modal.component';
import {ConfirmService} from '../shared/confirm.service';
import {
LibrarySettingsModalComponent
} from '../sidenav/_modals/library-settings-modal/library-settings-modal.component';
import {Chapter} from '../_models/chapter';
import {Device} from '../_models/device/device';
import {Library, LibraryType} from '../_models/library/library';
import {ReadingList} from '../_models/reading-list/reading-list';
import {Series} from '../_models/series';
import {Volume} from '../_models/volume';
import {DeviceService} from './device.service';
import {LibraryService} from './library.service';
import {MemberService} from './member.service';
import {ReaderService} from './reader.service';
import {SeriesService} from './series.service';
import {translate} from "@jsverse/transloco";
import {UserCollection} from "../_models/collection-tag";
import {CollectionTagService} from "./collection-tag.service";
import {ReadingListService} from "./reading-list.service";
import {ChapterService} from "./chapter.service";
import {VolumeService} from "./volume.service";
import {MatchSeriesModalComponent} from "../_single-module/match-series-modal/match-series-modal.component";
import {
BulkSetReadingProfileModalComponent
} from "../cards/_modals/bulk-set-reading-profile-modal/bulk-set-reading-profile-modal.component";
import {EditSeriesModalComponent} from "../cards/_modals/edit-series-modal/edit-series-modal.component";
import {EditVolumeModalComponent} from "../_single-module/edit-volume-modal/edit-volume-modal.component";
import {DownloadService} from '../shared/_services/download.service';
import {DownloadEntityType} from '../shared/_models/download-queue-item';
import {ReadingProfileService} from "./reading-profile.service";
import {Action} from "../_models/actionables/action";
import {ActionItem} from "../_models/actionables/action-item";
import {EMPTY, filter, from, Observable, of, switchMap, tap} from "rxjs";
import {ActionEffect, ActionResult} from "../_models/actionables/action-result";
import {EditChapterModalComponent} from "../_single-module/edit-chapter-modal/edit-chapter-modal.component";
import {PageBookmark} from "../_models/readers/page-bookmark";
import {Router} from "@angular/router";
import {
EditCollectionTagsModalComponent
} from "../cards/_modals/edit-collection-tags/edit-collection-tags-modal.component";
import {Annotation} from "../book-reader/_models/annotations/annotation";
import {AnnotationService} from "./annotation.service";
import {ClientDevice} from "../_models/client-device";
import {Person} from "../_models/metadata/person";
import {EditPersonModalComponent} from "../person-detail/_modal/edit-person-modal/edit-person-modal.component";
import {MergePersonModalComponent} from "../person-detail/_modal/merge-person-modal/merge-person-modal.component";
import {SmartFilter} from "../_models/metadata/v2/smart-filter";
import {
EditSmartFilterModalComponent
} from "../sidenav/_components/edit-smart-filter-modal/edit-smart-filter-modal.component";
import {SideNavStream} from "../_models/sidenav/sidenav-stream";
import {NavService} from "./nav.service";
import {ModalResult} from "../_models/modal/modal-result";
import {addToModal, editModal} from "../_models/modal/modal-options";
import {ModalService, TypedModalRef} from "./modal.service";
import {FilterService} from "src/app/_services/filter.service";
import {DashboardService} from "./dashboard.service";
export type LibraryActionCallback = (library: Partial<Library>) => void;
export type SeriesActionCallback = (series: Series) => void;
export type VolumeActionCallback = (volume: Volume) => void;
export type ChapterActionCallback = (chapter: Chapter) => void;
export type ReadingListActionCallback = (readingList: ReadingList) => void;
export type VoidActionCallback = () => void;
export type BooleanActionCallback = (result: boolean) => void;
/**
* Responsible for executing actions
*/
@Injectable({
providedIn: 'root'
})
export class ActionService {
private readonly chapterService = inject(ChapterService);
private readonly volumeService = inject(VolumeService);
private readonly libraryService = inject(LibraryService);
private readonly seriesService = inject(SeriesService);
private readonly readerService = inject(ReaderService);
private readonly toastr = inject(ToastrService);
private readonly modalService = inject(ModalService);
private readonly confirmService = inject(ConfirmService);
private readonly memberService = inject(MemberService);
private readonly deviceService = inject(DeviceService);
private readonly collectionTagService = inject(CollectionTagService);
private readonly readingListService = inject(ReadingListService);
private readonly collectionService = inject(CollectionTagService);
private readonly downloadService = inject(DownloadService);
private readonly readingProfilesService = inject(ReadingProfileService);
private readonly router = inject(Router);
private readonly annotationsService = inject(AnnotationService);
private readonly sideNavService = inject(NavService);
private readonly filterService = inject(FilterService);
private readonly dashboardService = inject(DashboardService);
private readingListModalRef: TypedModalRef<BulkSetReadingProfileModalComponent> | TypedModalRef<ListSelectModalComponent<ReadingList>> | null = null;
private collectionModalRef: TypedModalRef<ListSelectModalComponent<UserCollection>> | null = null;
// -------------------------------------------
// MAIN HANDLERS
// -------------------------------------------
handleLibraryAction(action: ActionItem<Library>, library: Library) {
if (!library.hasOwnProperty('id') || library.id === undefined) {
return of(this.fromAction(action, library, 'none'));
}
switch (action.action) {
case Action.Scan:
return this.libraryService.scan(library.id, false).pipe(
tap(() => this.toastr.info(translate('toasts.scan-queued', {name: library.name}))),
map(() => this.fromAction(action, library, 'none'))
);
case Action.RefreshMetadata:
return from(this.confirmService.confirm(translate('toasts.confirm-regen-covers'))).pipe(
filter(confirmed => confirmed),
switchMap(() => this.libraryService.refreshMetadata(library.id, true, false)),
tap(() => this.toastr.info(translate('toasts.refresh-covers-queued', {name: library.name}))),
map(() => this.fromAction(action, library, 'none'))
);
case Action.GenerateColorScape:
return this.libraryService.refreshMetadata(library.id, false, false).pipe(
tap(() => this.toastr.info(translate('toasts.generate-colorscape-queued', {name: library.name}))),
map(() => this.fromAction(action, library, 'none'))
);
case Action.Delete:
return from(this.confirmService.alert(translate('toasts.confirm-library-delete'))).pipe(
filter(confirmed => confirmed),
switchMap(() => this.libraryService.delete(library.id)),
tap(() => this.toastr.info(translate('toasts.library-deleted', {name: library.name}))),
map(() => this.fromAction(action, library, 'remove'))
);
case Action.Edit: {
const modalRef = this.modalService.open(LibrarySettingsModalComponent, editModal());
modalRef.componentInstance.library = library;
return this.handleEditModal(modalRef, action, library);
}
case Action.SetReadingProfile:
this.setReadingProfileForLibrary(library);
return of(this.fromAction(action, library, 'none'));
case Action.ClearReadingProfile:
return this.readingProfilesService.clearLibraryProfiles(library.id).pipe(
tap(() => this.toastr.success(translate('actionable.cleared-profile'))),
map(() => this.fromAction(action, library, 'none'))
);
default:
return of(this.fromAction(action, library, 'none'));
}
}
/**
* Centralized handler for all series actions.
* Returns Observable<ActionResult<Series>> so the caller can react to effects.
*/
handleSeriesAction(action: ActionItem<Series>, series: Series) {
switch (action.action) {
case Action.MarkAsRead:
case Action.MarkAsReadWithSession:
const generateReadingSession = action.action === Action.MarkAsReadWithSession;
return this.seriesService.markRead(series.id, generateReadingSession).pipe(
tap(() => this.toastr.success(translate('toasts.entity-read', {name: series.name}))),
map(() => this.fromAction(action, { ...series, pagesRead: series.pages }, 'update'))
);
case Action.MarkAsUnread:
return this.seriesService.markUnread(series.id).pipe(
tap(() => this.toastr.success(translate('toasts.entity-unread', {name: series.name}))),
map(() => this.fromAction(action, { ...series, pagesRead: 0 }, 'update'))
);
case Action.Scan:
return this.seriesService.scan(series.libraryId, series.id).pipe(
tap(() => this.toastr.info(translate('toasts.scan-queued', {name: series.name}))),
map(() => this.fromAction(action, series, 'none'))
);
case Action.RefreshMetadata:
return from(this.confirmService.confirm(translate('toasts.confirm-regen-covers'))).pipe(
filter(confirmed => confirmed),
switchMap(() => this.seriesService.refreshMetadata(series, true, false)),
tap(() => this.toastr.info(translate('toasts.refresh-covers-queued', {name: series.name}))),
map(() => this.fromAction(action, series, 'none'))
);
case Action.GenerateColorScape:
return this.seriesService.refreshMetadata(series, false, false).pipe(
tap(() => this.toastr.info(translate('toasts.generate-colorscape-queued', {name: series.name}))),
map(() => this.fromAction(action, series, 'none'))
);
case Action.AnalyzeFiles:
return this.seriesService.analyzeFiles(series.libraryId, series.id).pipe(
tap(() => this.toastr.info(translate('toasts.scan-queued', {name: series.name}))),
map(() => this.fromAction(action, series, 'none'))
);
case Action.Delete:
return from(this.confirmService.confirm(translate('toasts.confirm-delete-series'))).pipe(
filter(confirmed => confirmed),
switchMap(() => this.seriesService.delete(series.id)),
tap(() => this.toastr.success(translate('toasts.series-deleted'))),
map(() => this.fromAction(action, series, 'remove'))
);
case Action.Edit: {
const modalRef = this.modalService.open(EditSeriesModalComponent, editModal());
modalRef.componentInstance.series = series;
return this.handleEditModal(modalRef, action, series);
}
case Action.Match: {
const ref = this.modalService.open(MatchSeriesModalComponent, editModal());
ref.setInput('series', series);
return from(ref.closed).pipe(
filter((saved: boolean) => saved),
map(() => this.fromAction(action, series, 'reload'))
);
}
case Action.AddToReadingList: {
if (this.readingListModalRef != null) return EMPTY;
const rlRef = this.modalService.open(ListSelectModalComponent, addToModal()) as TypedModalRef<ListSelectModalComponent<ReadingList>>;
this.readingListModalRef = rlRef;
rlRef.setInput('title', series.name);
rlRef.setInput('showCreate', true);
rlRef.setInput('createLabel', translate('add-to-list-modal.reading-list-label'));
rlRef.setInput('inputItems', []);
rlRef.setInput('loading', true);
rlRef.setInput('createInitialValue', series.name);
this.readingListService.getReadingLists(false, true).pipe(
take(1),
catchError(() => EMPTY),
finalize(() => rlRef.setInput('loading', false))
).subscribe(result => {
rlRef.setInput('inputItems', result.result.map(l => ({ label: l.title, value: l })));
});
rlRef.setInput('interceptCreate', (name: string) =>
this.readingListService.createList(name).pipe(
switchMap(list => this.readingListService.updateBySeries(list.id, series.id)),
tap(() => this.toastr.success(translate('toasts.series-added-to-reading-list')))
)
);
rlRef.setInput('interceptConfirm', (item: ReadingList | ReadingList[]) => {
const list = item as ReadingList;
this.readingListService.updateBySeries(list.id, series.id).subscribe(() => {
this.toastr.success(translate('toasts.series-added-to-reading-list'));
rlRef.close();
});
});
return new Observable<ActionResult<Series>>(subscriber => {
rlRef.closed.subscribe(() => {
this.readingListModalRef = null;
subscriber.next(this.fromAction(action, series, 'none'));
subscriber.complete();
});
rlRef.dismissed.subscribe(() => {
this.readingListModalRef = null;
subscriber.complete();
});
});
}
case Action.AddToCollection: {
if (this.collectionModalRef != null) return EMPTY;
const colRef = this.modalService.open(ListSelectModalComponent, addToModal()) as TypedModalRef<ListSelectModalComponent<UserCollection>>;
this.collectionModalRef = colRef;
const singleSeriesIds = [series.id];
colRef.setInput('title', translate('bulk-add-to-collection.title'));
colRef.setInput('showCreate', true);
colRef.setInput('createLabel', translate('bulk-add-to-collection.collection-label'));
colRef.setInput('createInitialValue', translate('actionable.new-collection'));
colRef.setInput('inputItems', []);
colRef.setInput('loading', true);
this.collectionService.allCollections(true, true).pipe(
take(1),
catchError(() => EMPTY),
finalize(() => colRef.setInput('loading', false))
).subscribe(tags => {
const collections = tags.filter(t => t.source === ScrobbleProvider.Kavita);
colRef.setInput('inputItems', collections.map(c => ({ label: c.title, value: c })));
});
colRef.setInput('interceptCreate', (name: string) =>
this.collectionService.addByMultiple(0, singleSeriesIds, name).pipe(
tap(() => this.toastr.success(translate('toasts.series-added-to-collection', { collectionName: name })))
)
);
colRef.setInput('interceptConfirm', (item: UserCollection | UserCollection[]) => {
const tag = item as UserCollection;
this.collectionService.addByMultiple(tag.id, singleSeriesIds, '').subscribe(() => {
this.toastr.success(translate('toasts.series-added-to-collection', { collectionName: tag.title }));
colRef.close();
});
});
return new Observable<ActionResult<Series>>(subscriber => {
colRef.closed.subscribe(() => {
this.collectionModalRef = null;
subscriber.next(this.fromAction(action, series, 'none'));
subscriber.complete();
});
colRef.dismissed.subscribe(() => {
this.collectionModalRef = null;
subscriber.complete();
});
});
}
case Action.Download:
this.downloadService.download(DownloadEntityType.Series, series, series.libraryId, series.id);
return of(this.fromAction(action, series, 'none'));
case Action.AddToWantToReadList:
return this.memberService.addSeriesToWantToRead([series.id]).pipe(
tap(() => this.toastr.success(translate('toasts.series-added-want-to-read'))),
map(() => this.fromAction(action, series, 'none'))
);
case Action.RemoveFromWantToReadList:
return this.memberService.removeSeriesToWantToRead([series.id]).pipe(
tap(() => this.toastr.success(translate('toasts.series-removed-want-to-read'))),
map(() => this.fromAction(action, series, 'reload'))
);
case Action.RemoveFromOnDeck:
return this.seriesService.removeFromOnDeck(series.id).pipe(
map(() => this.fromAction(action, series, 'reload'))
);
case Action.SendTo: {
const device = action._extra!.data as Device;
return this.deviceService.sendSeriesToEmailDevice(series.id, device.id).pipe(
tap(() => this.toastr.success(translate('toasts.file-send-to', {name: device.name}))),
map(() => this.fromAction(action, series, 'none'))
);
}
case Action.SetReadingProfile:
this.setReadingProfileForMultiple([series]);
return of(this.fromAction(action, series, 'none'));
case Action.ClearReadingProfile:
return this.readingProfilesService.clearSeriesProfiles(series.id).pipe(
tap(() => this.toastr.success(translate('actionable.cleared-profile'))),
map(() => this.fromAction(action, series, 'none'))
);
default:
return of(this.fromAction(action, series, 'none'));
}
}
/**
* Centralized handler for all volume actions.
* Returns Observable<ActionResult<Volume>> so the caller can react to effects.
*/
handleVolumeAction(action: ActionItem<Volume>, volume: Volume, seriesId: number, libraryId: number, libraryType: LibraryType) {
switch (action.action) {
case Action.MarkAsRead:
case Action.MarkAsReadWithSession:
const generateReadingSession = action.action === Action.MarkAsReadWithSession;
return this.readerService.markVolumeRead(seriesId, volume.id, generateReadingSession).pipe(
tap(() => this.toastr.success(translate('toasts.mark-read'))),
map(() => {
const updated = {
...volume,
pagesRead: volume.pages,
chapters: volume.chapters?.map(c => ({...c, pagesRead: c.pages}))
};
return this.fromAction(action, updated, 'update');
})
);
case Action.MarkAsUnread:
return this.readerService.markVolumeUnread(seriesId, volume.id).pipe(
tap(() => this.toastr.success(translate('toasts.mark-unread'))),
map(() => {
const updated = {
...volume,
pagesRead: 0,
chapters: volume.chapters?.map(c => ({...c, pagesRead: 0}))
};
return this.fromAction(action, updated, 'update');
})
);
case Action.Delete:
return from(this.confirmService.confirm(translate('toasts.confirm-delete-volume'))).pipe(
filter(confirmed => confirmed),
switchMap(() => this.volumeService.deleteVolume(volume.id)),
filter(success => success),
tap(() => this.toastr.success(translate('toasts.volume-deleted'))),
map(() => this.fromAction(action, volume, 'remove'))
);
case Action.Edit: {
const ref = this.modalService.open(EditVolumeModalComponent, editModal());
ref.componentInstance.volume = volume;
ref.componentInstance.libraryType = libraryType;
ref.componentInstance.seriesId = seriesId;
ref.componentInstance.libraryId = libraryId;
return this.handleEditModal(ref, action, volume);
}
case Action.AddToReadingList: {
if (this.readingListModalRef != null) return EMPTY;
const rlRef = this.modalService.open(ListSelectModalComponent, addToModal()) as TypedModalRef<ListSelectModalComponent<ReadingList>>;
this.readingListModalRef = rlRef;
rlRef.setInput('title', translate('add-to-list-modal.title'));
rlRef.setInput('showCreate', true);
rlRef.setInput('createLabel', translate('add-to-list-modal.reading-list-label'));
rlRef.setInput('inputItems', []);
rlRef.setInput('loading', true);
this.readingListService.getReadingLists(false, true).pipe(
take(1),
catchError(() => EMPTY),
finalize(() => rlRef.setInput('loading', false))
).subscribe(result => {
rlRef.setInput('inputItems', result.result.map(l => ({ label: l.title, value: l })));
});
rlRef.setInput('interceptCreate', (name: string) =>
this.readingListService.createList(name).pipe(
switchMap(list => this.readingListService.updateByVolume(list.id, seriesId, volume.id)),
tap(() => this.toastr.success(translate('toasts.volumes-added-to-reading-list')))
)
);
rlRef.setInput('interceptConfirm', (item: ReadingList | ReadingList[]) => {
const list = item as ReadingList;
this.readingListService.updateByVolume(list.id, seriesId, volume.id).subscribe(() => {
this.toastr.success(translate('toasts.volumes-added-to-reading-list'));
rlRef.close();
});
});
return new Observable<ActionResult<Volume>>(subscriber => {
rlRef.closed.subscribe(() => {
this.readingListModalRef = null;
subscriber.next(this.fromAction(action, volume, 'none'));
subscriber.complete();
});
rlRef.dismissed.subscribe(() => {
this.readingListModalRef = null;
subscriber.complete();
});
});
}
case Action.IncognitoRead:
if (volume.chapters != undefined && volume.chapters.length >= 1) {
const sorted = [...volume.chapters].sort((a, b) => a.minNumber - b.minNumber);
this.readerService.readChapter(libraryId, seriesId, sorted[0], true);
}
return of(this.fromAction(action, volume, 'none'));
case Action.SendTo: {
const device = action._extra!.data as Device;
return this.deviceService.sendToEmailDevice(volume.chapters.map(c => c.id), device.id).pipe(
tap(() => this.toastr.success(translate('toasts.file-send-to', {name: device.name}))),
map(() => this.fromAction(action, volume, 'none'))
);
}
case Action.Download:
this.downloadService.download(DownloadEntityType.Volume, volume, libraryId, seriesId);
return of(this.fromAction(action, volume, 'none'));
default:
return of(this.fromAction(action, volume, 'none'));
}
}
/**
* Centralized handler for all chapter actions.
* Returns Observable<ActionResult<Chapter>> so the caller can react to effects.
*/
handleChapterAction(action: ActionItem<Chapter>, chapter: Chapter, seriesId: number, libraryId: number, libraryType: LibraryType) {
switch (action.action) {
case Action.MarkAsRead:
case Action.MarkAsReadWithSession:
const generateReadingSession = action.action === Action.MarkAsReadWithSession;
return this.readerService.markChapterRead(seriesId, chapter.id, generateReadingSession).pipe(
tap(() => this.toastr.success(translate('toasts.mark-read'))),
map(() => {
const updated = {
...chapter,
pagesRead: chapter.pages,
};
return this.fromAction(action, updated, 'update');
})
);
case Action.MarkAsUnread:
return this.readerService.saveProgress(libraryId, seriesId, chapter.volumeId, chapter.id, 0).pipe(
tap(() => this.toastr.success(translate('toasts.mark-unread'))),
map(() => {
const updated = {
...chapter,
pagesRead: 0,
};
return this.fromAction(action, updated, 'update');
})
);
case Action.Delete:
return from(this.confirmService.confirm(translate('toasts.confirm-delete-chapter'))).pipe(
filter(confirmed => confirmed),
switchMap(() => this.chapterService.deleteChapter(chapter.id)),
filter(success => success),
tap(() => this.toastr.success(translate('toasts.chapter-deleted'))),
map(() => this.fromAction(action, chapter, 'remove'))
);
case Action.Download:
this.downloadService.download(DownloadEntityType.Chapter, chapter, libraryId, seriesId);
return of(this.fromAction(action, chapter, 'none'));
case Action.Edit:
const ref = this.modalService.open(EditChapterModalComponent, editModal());
ref.componentInstance.chapter = chapter;
ref.componentInstance.libraryType = libraryType;
ref.componentInstance.seriesId = seriesId;
ref.componentInstance.libraryId = libraryId;
return this.handleEditModal(ref, action, chapter);
case Action.AddToReadingList: {
if (this.readingListModalRef != null) return EMPTY;
const rlRef = this.modalService.open(ListSelectModalComponent, addToModal()) as TypedModalRef<ListSelectModalComponent<ReadingList>>;
this.readingListModalRef = rlRef;
rlRef.setInput('title', translate('add-to-list-modal.title'));
rlRef.setInput('showCreate', true);
rlRef.setInput('createLabel', translate('add-to-list-modal.reading-list-label'));
rlRef.setInput('inputItems', []);
rlRef.setInput('loading', true);
this.readingListService.getReadingLists(false, true).pipe(
take(1),
catchError(() => EMPTY),
finalize(() => rlRef.setInput('loading', false))
).subscribe(result => {
rlRef.setInput('inputItems', result.result.map(l => ({ label: l.title, value: l })));
});
rlRef.setInput('interceptCreate', (name: string) =>
this.readingListService.createList(name).pipe(
switchMap(list => this.readingListService.updateByChapter(list.id, seriesId, chapter.id)),
tap(() => this.toastr.success(translate('toasts.chapter-added-to-reading-list')))
)
);
rlRef.setInput('interceptConfirm', (item: ReadingList | ReadingList[]) => {
const list = item as ReadingList;
this.readingListService.updateByChapter(list.id, seriesId, chapter.id).subscribe(() => {
this.toastr.success(translate('toasts.chapter-added-to-reading-list'));
rlRef.close();
});
});
return new Observable<ActionResult<Chapter>>(subscriber => {
rlRef.closed.subscribe(() => {
this.readingListModalRef = null;
subscriber.next(this.fromAction(action, chapter, 'none'));
subscriber.complete();
});
rlRef.dismissed.subscribe(() => {
this.readingListModalRef = null;
subscriber.complete();
});
});
}
case Action.IncognitoRead:
this.readerService.readChapter(libraryId, seriesId, chapter, true);
return of(this.fromAction(action, chapter, 'none'));
case Action.SendTo:
const device = action._extra!.data as Device;
return this.deviceService.sendToEmailDevice([chapter.id], device.id).pipe(
tap(() => this.toastr.success(translate('toasts.file-send-to', {name: device.name}))),
map(() => this.fromAction(action, chapter, 'none'))
);
default:
return of(this.fromAction(action, chapter, 'none'));
}
}
/**
* Centralized handler for all bookmark actions.
* Returns Observable<ActionResult<PageBookmark>> so the caller can react to effects.
*/
handleBookmarkAction(action: ActionItem<PageBookmark>, bookmark: PageBookmark, contextFunc: () => {seriesId: number, libraryId: number, seriesName: string}) {
const ctx = contextFunc();
switch (action.action) {
case Action.Delete:
return from(this.confirmService.confirm(translate('bookmarks.confirm-single-delete', {seriesName: ctx.seriesName}))).pipe(
filter(confirmed => confirmed),
switchMap(() => this.readerService.clearBookmarks(ctx.seriesId)),
tap(() => this.toastr.success(translate('bookmarks.delete-single-success'))),
map(() => this.fromAction(action, bookmark, 'remove'))
);
case Action.Download:
this.downloadService.download(DownloadEntityType.Bookmark, [bookmark], 0, 0);
return of(this.fromAction(action, bookmark, 'none'));
case Action.ViewSeries:
this.router.navigate(['library', ctx.libraryId, 'series', ctx.seriesId]);
return of(this.fromAction(action, bookmark, 'none'));
default:
return of(this.fromAction(action, bookmark, 'none'));
}
}
/**
* Centralized handler for all reading list actions.
* Returns Observable<ActionResult<ReadingList>> so the caller can react to effects.
*/
handleReadingListAction(action: ActionItem<ReadingList>, readingList: ReadingList) {
switch (action.action) {
case Action.Delete:
return from(this.confirmService.confirm(translate('toasts.confirm-delete-reading-list'))).pipe(
filter(confirmed => confirmed),
switchMap(() => this.readingListService.delete(readingList.id)),
tap(() => this.toastr.success(translate('toasts.reading-list-deleted'))),
map(() => this.fromAction(action, readingList, 'remove'))
);
case Action.Download:
this.downloadService.download(DownloadEntityType.ReadingList, readingList, 0, 0);
return of(this.fromAction(action, readingList, 'none'));
case Action.Edit:
const ref = this.modalService.open(EditReadingListModalComponent, editModal());
ref.componentInstance.readingList = readingList;
return this.handleEditModal(ref, action, readingList);
case Action.RefreshMetadata:
return this.readingListService.refreshCover(readingList.id).pipe(
map(() => this.fromAction(action, {...readingList}, 'none'))
);
case Action.Promote:
return this.readingListService.promoteMultipleReadingLists([readingList.id], true).pipe(
tap(() => this.toastr.success(translate('toasts.reading-list-promoted'))),
map(() => this.fromAction(action, {...readingList, promoted: true}, 'update'))
);
case Action.UnPromote:
return this.readingListService.promoteMultipleReadingLists([readingList.id], false).pipe(
tap(() => this.toastr.success(translate('toasts.reading-list-unpromoted'))),
map(() => this.fromAction(action, {...readingList, promoted: false}, 'update'))
);
case Action.ExportAsV1:
return this.downloadService.exportReadingList(readingList.id, readingList.title).pipe(
map(() => this.fromAction(action, readingList, 'none'))
);
case Action.ExportAsV2:
return this.downloadService.exportReadingList(readingList.id, readingList.title, true).pipe(
map(() => this.fromAction(action, readingList, 'none'))
);
default:
return of(this.fromAction(action, readingList, 'none'));
}
}
/**
* Centralized handler for all collection actions.
* Returns Observable<ActionResult<UserCollection>> so the caller can react to effects.
*/
handleCollectionAction(action: ActionItem<UserCollection>, collection: UserCollection) {
switch (action.action) {
case Action.Delete:
return from(this.confirmService.confirm(translate('toasts.confirm-delete-collection'))).pipe(
filter(confirmed => confirmed),
switchMap(() => this.collectionService.deleteTag(collection.id)),
tap(() => this.toastr.success(translate('toasts.collection-tag-deleted'))),
map(() => this.fromAction(action, collection, 'remove'))
);
case Action.Edit:
const ref = this.modalService.open(EditCollectionTagsModalComponent, editModal());
ref.setInput('tag', collection);
return this.handleEditModal(ref, action, collection);
case Action.Promote:
return this.collectionService.promoteMultipleCollections([collection.id], true).pipe(
tap(() => this.toastr.success(translate('toasts.collections-promoted'))),
map(() => this.fromAction(action, {...collection, promoted: true}, 'update'))
);
case Action.UnPromote:
return this.collectionService.promoteMultipleCollections([collection.id], false).pipe(
tap(() => this.toastr.success(translate('toasts.collections-unpromoted'))),
map(() => this.fromAction(action, {...collection, promoted: false}, 'update'))
);
case Action.Download:
this.downloadService.download(DownloadEntityType.Collection, collection, 0, 0);
return of(this.fromAction(action, collection, 'none'));
default:
return of(this.fromAction(action, collection, 'none'));
}
}
/**
* Centralized handler for all annotation actions.
* Returns Observable<ActionResult<Annotation>> so the caller can react to effects.
*/
handleAnnotationAction(action: ActionItem<Annotation>, annotation: Annotation) {
switch (action.action) {
case Action.Delete:
return from(this.confirmService.confirm(translate('toasts.confirm-delete-annotations'))).pipe(
filter(confirmed => confirmed),
switchMap(() => this.annotationsService.bulkDelete([annotation.id])),
tap(() => this.toastr.success(translate('toasts.annotations-deleted'))),
map(() => this.fromAction(action, annotation, 'remove'))
);
case Action.Export:
return this.annotationsService.exportAnnotations([annotation.id]).pipe(
map(() => this.fromAction(action, annotation, 'none'))
);
case Action.Like:
return this.annotationsService.likeAnnotations([annotation.id]).pipe(
map(() => this.fromAction(action, annotation, 'update'))
);
case Action.UnLike:
return this.annotationsService.unLikeAnnotations([annotation.id]).pipe(
map(() => this.fromAction(action, annotation, 'update'))
);
default:
return of(this.fromAction(action, annotation, 'none'));
}
}
/**
* Centralized handler for all client device actions.
* Returns Observable<ActionResult<ClientDevice>> so the caller can react to effects.
*/
handleClientDeviceAction(action: ActionItem<ClientDevice>, clientDevice: ClientDevice) {
switch (action.action) {
case Action.Delete:
return from(this.confirmService.confirm(translate('toasts.confirm-delete-client-device'))).pipe(
filter(confirmed => confirmed),
switchMap(() => this.deviceService.deleteClientDevice(clientDevice.id)),
map((success) => this.fromAction(action, clientDevice, success ? 'remove' : 'none'))
);
case Action.Edit:
// Special case: This actually just triggers an edit toggle. Since there is no edit modal, we send update to handle
return of(this.fromAction(action, clientDevice, 'update'));
default:
return of(this.fromAction(action, clientDevice, 'none'));
}
}
/**
* Centralized handler for all person actions.
* Returns Observable<ActionResult<Person>> so the caller can react to effects.
*/
handlePersonAction(action: ActionItem<Person>, person: Person) {
switch (action.action) {
case Action.Edit:
const ref = this.modalService.open(EditPersonModalComponent, editModal());
ref.componentInstance.person = person;
return this.handleEditModal(ref, action, person);
case Action.Merge:
const ref2 = this.modalService.open(MergePersonModalComponent, editModal());
ref2.componentInstance.person = person;
return from(ref2.closed).pipe(
filter((res: ModalResult<Person>) => res.success),
map((res: ModalResult<Person>) =>
this.fromAction(action, person, res.success ? 'reload' : 'none')
)
);
default:
return of(this.fromAction(action, person, 'none'));
}
}
/**
* Centralized handler for all smart filter actions.
* Returns Observable<ActionResult<SmartFilter>> so the caller can react to effects.
*/
handleSmartFilterAction(action: ActionItem<SmartFilter>, smartFilter: SmartFilter, allFilters: SmartFilter[]) {
switch (action.action) {
case Action.AddToDashboard:
return this.dashboardService.createDashboardStream(smartFilter.id).pipe(
map(() => this.fromAction(action, smartFilter, 'none'))
);
case Action.AddToSideNav:
return this.sideNavService.createSideNavStream(smartFilter.id).pipe(
map(() => this.fromAction(action, smartFilter, 'none'))
);
case Action.Edit:
const ref = this.modalService.open(EditSmartFilterModalComponent, editModal());
ref.componentInstance.smartFilter = smartFilter;
ref.componentInstance.allFilters = allFilters;
return this.handleEditModal(ref, action, smartFilter);
case Action.Delete:
return from(this.confirmService.confirm(translate('toasts.confirm-delete-smart-filter'))).pipe(
filter(confirmed => confirmed),
switchMap(() => this.filterService.deleteFilter(smartFilter.id)),
tap(() => this.toastr.success(translate('toasts.smart-filter-deleted'))),
map(() => this.fromAction(action, smartFilter, 'remove'))
);
default:
return of(this.fromAction(action, smartFilter, 'none'));
}
}
/**
* Centralized handler for all side nav stream actions.
* Returns Observable<ActionResult<SideNavStream>> so the caller can react to effects.
*/
handleSideNavStreamAction(action: ActionItem<SideNavStream>, sideNavStream: SideNavStream) {
switch (action.action) {
case Action.MarkAsVisible:
return this.sideNavService.bulkToggleSideNavStreamVisibility([sideNavStream.id], true).pipe(
map(() => this.fromAction(action, {...sideNavStream, visible: true}, 'update'))
);
case Action.MarkAsInvisible:
return this.sideNavService.bulkToggleSideNavStreamVisibility([sideNavStream.id], false).pipe(
map(() => this.fromAction(action, {...sideNavStream, visible: false}, 'update'))
);
default:
return of(this.fromAction(action, sideNavStream, 'none'));
}
}
/**
* Centralized handler for all side nav home stream actions.
* Returns Observable<ActionResult<{}>> so the caller can react to effects.
*/
handleSideNavHomeStream(action: ActionItem<{}>, entity: {}) {
switch (action.action) {
case Action.Edit:
return of(this.fromAction(action, entity, 'none'));
default:
return of(this.fromAction(action, entity, 'none'));
}
}
handleSideNavReadingListStream(action: ActionItem<{}>, entity: {}) {
switch (action.action) {
case Action.Navigate:
return of(this.fromAction(action, entity, 'none'));
default:
return of(this.fromAction(action, entity, 'none'));
}
}
/**
* Centralized handler for all bulk library actions.
* Returns Observable<ActionResult<Library>> so the caller can react to effects.
*/
handleBulkLibraryAction(action: ActionItem<Library>, library: Library) {
// manage-library handles all actions, the actionables don't perform as other implementations
return of(this.fromAction(action, library, 'none'));
}
// -------------------------------------------
// BULK HANDLERS
// -------------------------------------------
handleBulkSeriesAction(action: ActionItem<any>, series: Series[]): Observable<ActionResult<Series[]>> {
switch (action.action) {
case Action.MarkAsRead:
case Action.MarkAsReadWithSession:
const generateReadingSession = action.action === Action.MarkAsReadWithSession;
return this.readerService.markMultipleSeriesRead(series.map(s => s.id), generateReadingSession).pipe(
tap(() => {
series.forEach(s => s.pagesRead = s.pages);
this.toastr.success(translate('toasts.mark-read'));
}),
map(() => this.fromAction(action, series, 'update'))
);
case Action.MarkAsUnread:
return this.readerService.markMultipleSeriesUnread(series.map(s => s.id)).pipe(
tap(() => {
series.forEach(s => s.pagesRead = 0);
this.toastr.success(translate('toasts.mark-unread'));
}),
map(() => this.fromAction(action, series, 'update'))
);
case Action.Delete:
return from(this.confirmService.confirm(translate('toasts.confirm-delete-multiple-series', {count: series.length}))).pipe(
filter(confirmed => confirmed),
switchMap(() => this.seriesService.deleteMultipleSeries(series.map(s => s.id))),
tap(res => {
if (res) {
this.toastr.success(translate('toasts.series-deleted'));
} else {
this.toastr.error(translate('errors.generic'));
}
}),
filter(res => res),
map(() => this.fromAction(action, series, 'remove'))
);
case Action.AddToReadingList: {
if (this.readingListModalRef != null) return EMPTY;
const rlRef = this.modalService.open(ListSelectModalComponent<ReadingList>, addToModal());
this.readingListModalRef = rlRef;
const bulkSeriesIds = series.map(s => s.id);
rlRef.setInput('title', translate('actionable.multiple-selections'));
rlRef.setInput('showCreate', true);
rlRef.setInput('createLabel', translate('add-to-list-modal.reading-list-label'));
rlRef.setInput('inputItems', []);
rlRef.setInput('loading', true);
this.readingListService.getReadingLists(false, true).pipe(
take(1),
catchError(() => EMPTY),
finalize(() => rlRef.setInput('loading', false))
).subscribe(result => {
rlRef.setInput('inputItems', result.result.map(l => ({ label: l.title, value: l })));
});
rlRef.setInput('interceptCreate', (name: string) =>
this.readingListService.createList(name).pipe(
switchMap(list => this.readingListService.updateByMultipleSeries(list.id, bulkSeriesIds)),
tap(() => this.toastr.success(translate('toasts.series-added-to-reading-list')))
)
);
rlRef.setInput('interceptConfirm', (item: ReadingList | ReadingList[]) => {
const list = item as ReadingList;
this.readingListService.updateByMultipleSeries(list.id, bulkSeriesIds).subscribe(() => {
this.toastr.success(translate('toasts.series-added-to-reading-list'));
rlRef.close();
});
});
return new Observable<ActionResult<Series[]>>(subscriber => {
rlRef.closed.subscribe(() => {
this.readingListModalRef = null;
subscriber.next(this.fromAction(action, series, 'none'));
subscriber.complete();
});
rlRef.dismissed.subscribe(() => {
this.readingListModalRef = null;
subscriber.complete();
});
});
}
case Action.AddToCollection: {
if (this.collectionModalRef != null) return EMPTY;
const colRef = this.modalService.open(ListSelectModalComponent, addToModal()) as TypedModalRef<ListSelectModalComponent<UserCollection>>;
this.collectionModalRef = colRef;
const bulkColSeriesIds = series.map(s => s.id);
colRef.setInput('title', translate('bulk-add-to-collection.title'));
colRef.setInput('showCreate', true);
colRef.setInput('createLabel', translate('bulk-add-to-collection.collection-label'));
colRef.setInput('createInitialValue', translate('actionable.new-collection'));
colRef.setInput('inputItems', []);
colRef.setInput('loading', true);
this.collectionService.allCollections(true, true).pipe(
take(1),
catchError(() => EMPTY),
finalize(() => colRef.setInput('loading', false))
).subscribe(tags => {
const collections = tags.filter(t => t.source === ScrobbleProvider.Kavita);
colRef.setInput('inputItems', collections.map(c => ({ label: c.title, value: c })));
});
colRef.setInput('interceptCreate', (name: string) =>
this.collectionService.addByMultiple(0, bulkColSeriesIds, name).pipe(
tap(() => this.toastr.success(translate('toasts.series-added-to-collection', { collectionName: name })))
)
);
colRef.setInput('interceptConfirm', (item: UserCollection | UserCollection[]) => {
const tag = item as UserCollection;
this.collectionService.addByMultiple(tag.id, bulkColSeriesIds, '').subscribe(() => {
this.toastr.success(translate('toasts.series-added-to-collection', { collectionName: tag.title }));
colRef.close();
});
});
return new Observable<ActionResult<Series[]>>(subscriber => {
colRef.closed.subscribe(() => {
this.collectionModalRef = null;
subscriber.next(this.fromAction(action, series, 'none'));
subscriber.complete();
});
colRef.dismissed.subscribe(() => {
this.collectionModalRef = null;
subscriber.complete();
});
});
}
case Action.AddToWantToReadList:
return this.memberService.addSeriesToWantToRead(series.map(s => s.id)).pipe(
tap(() => this.toastr.success(translate('toasts.series-added-want-to-read'))),
map(() => this.fromAction(action, series, 'none'))
);
case Action.RemoveFromWantToReadList:
return this.memberService.removeSeriesToWantToRead(series.map(s => s.id)).pipe(
tap(() => this.toastr.success(translate('toasts.series-removed-want-to-read'))),
map(() => this.fromAction(action, series, 'reload'))
);
case Action.SetReadingProfile: {
if (this.readingListModalRef != null) return EMPTY;
this.readingListModalRef = this.modalService.open(BulkSetReadingProfileModalComponent, addToModal());
this.readingListModalRef.setInput('seriesIds', series.map(s => s.id));
const ref = this.readingListModalRef;
return new Observable<ActionResult<Series[]>>(subscriber => {
ref.closed.subscribe(() => {
this.readingListModalRef = null;
subscriber.next(this.fromAction(action, series, 'none'));
subscriber.complete();
});
ref.dismissed.subscribe(() => {
this.readingListModalRef = null;
subscriber.complete();
});
});
}
case Action.Download:
for (const s of series) { this.downloadService.download(DownloadEntityType.Series, s, s.libraryId, s.id); }
return of(this.fromAction(action, series, 'none'));
default:
return of(this.fromAction(action, series, 'none'));
}
}
handleBulkVolumeChapterAction(action: ActionItem<any>, volumes: Volume[], chapters: Chapter[], seriesId: number, libraryId = 0): Observable<ActionResult<any[]>> {
switch (action.action) {
case Action.MarkAsRead:
case Action.MarkAsReadWithSession:
const generateReadingSession = action.action === Action.MarkAsReadWithSession;
return this.readerService.markMultipleRead(seriesId, volumes.map(v => v.id), chapters.map(c => c.id), generateReadingSession).pipe(
tap(() => {
volumes.forEach(v => {
v.pagesRead = v.pages;
v.chapters?.forEach(c => c.pagesRead = c.pages);
});
chapters.forEach(c => c.pagesRead = c.pages);
this.toastr.success(translate('toasts.mark-read'));
}),
map(() => this.fromAction(action, [...volumes, ...chapters], 'update'))
);
case Action.MarkAsUnread:
return this.readerService.markMultipleUnread(seriesId, volumes.map(v => v.id), chapters.map(c => c.id)).pipe(
tap(() => {
volumes.forEach(v => {
v.pagesRead = 0;
v.chapters?.forEach(c => c.pagesRead = 0);
});
chapters.forEach(c => c.pagesRead = 0);
this.toastr.success(translate('toasts.mark-unread'));
}),
map(() => this.fromAction(action, [...volumes, ...chapters], 'update'))
);
case Action.AddToReadingList: {
if (this.readingListModalRef != null) return EMPTY;
const rlRef = this.modalService.open(ListSelectModalComponent, addToModal()) as TypedModalRef<ListSelectModalComponent<ReadingList>>;
this.readingListModalRef = rlRef;
const volumeIdList = volumes.map(v => v.id);
const chapterIdList = chapters.map(c => c.id);
rlRef.setInput('title', translate('actionable.multiple-selections'));
rlRef.setInput('showCreate', true);
rlRef.setInput('createLabel', translate('add-to-list-modal.reading-list-label'));
rlRef.setInput('inputItems', []);
rlRef.setInput('loading', true);
this.readingListService.getReadingLists(false, true).pipe(
take(1),
catchError(() => EMPTY),
finalize(() => rlRef.setInput('loading', false))
).subscribe(result => {
rlRef.setInput('inputItems', result.result.map(l => ({ label: l.title, value: l })));
});
rlRef.setInput('interceptCreate', (name: string) =>
this.readingListService.createList(name).pipe(
switchMap(list => this.readingListService.updateByMultiple(list.id, seriesId, volumeIdList, chapterIdList)),
tap(() => this.toastr.success(translate('toasts.multiple-added-to-reading-list')))
)
);
rlRef.setInput('interceptConfirm', (item: ReadingList | ReadingList[]) => {
const list = item as ReadingList;
this.readingListService.updateByMultiple(list.id, seriesId, volumeIdList, chapterIdList).subscribe(() => {
this.toastr.success(translate('toasts.multiple-added-to-reading-list'));
rlRef.close();
});
});
return new Observable<ActionResult<any[]>>(subscriber => {
rlRef.closed.subscribe(() => {
this.readingListModalRef = null;
subscriber.next(this.fromAction(action, [...volumes, ...chapters], 'none'));
subscriber.complete();
});
rlRef.dismissed.subscribe(() => {
this.readingListModalRef = null;
subscriber.complete();
});
});
}
case Action.SendTo: {
const device = action._extra!.data as Device;
const chapterIds = [
...volumes.flatMap(v => v.chapters?.map(c => c.id) ?? []),
...chapters.map(c => c.id)
];
return this.deviceService.sendToEmailDevice(chapterIds, device.id).pipe(
tap(() => this.toastr.success(translate('toasts.file-send-to', {name: device.name}))),
map(() => this.fromAction(action, [...volumes, ...chapters], 'none'))
);
}
case Action.Delete: {
const entities = [...volumes, ...chapters];
const deleteOps: Observable<any>[] = [];
if (volumes.length > 0) {
deleteOps.push(
from(this.confirmService.confirm(translate('toasts.confirm-delete-multiple-volumes', {count: volumes.length}))).pipe(
filter(confirmed => confirmed),
switchMap(() => this.volumeService.deleteMultipleVolumes(volumes.map(v => v.id)))
)
);
}
if (chapters.length > 0) {
deleteOps.push(
from(this.confirmService.confirm(translate('toasts.confirm-delete-multiple-chapters', {count: chapters.length}))).pipe(
filter(confirmed => confirmed),
switchMap(() => this.chapterService.deleteMultipleChapters(seriesId, chapters.map(c => c.id)))
)
);
}
if (deleteOps.length === 0) return EMPTY;
return from(deleteOps).pipe(
switchMap(op => op),
map(() => this.fromAction(action, entities, 'remove'))
);
}
case Action.Download:
this.downloadService.downloadBulk(volumes, chapters, libraryId, seriesId);
return of(this.fromAction(action, [...volumes, ...chapters], 'none'));
default:
return of(this.fromAction(action, [...volumes, ...chapters], 'none'));
}
}
handleBulkBookmarkAction(action: ActionItem<any>, bookmarks: PageBookmark[], seriesIds: number[]): Observable<ActionResult<PageBookmark[]>> {
switch (action.action) {
case Action.Download:
this.downloadService.download(DownloadEntityType.Bookmark, bookmarks, 0, 0);
return of(this.fromAction(action, bookmarks, 'none'));
case Action.Delete:
return from(this.confirmService.confirm(translate('bookmarks.confirm-single-delete', {seriesName: ''}))).pipe(
filter(confirmed => confirmed),
switchMap(() => this.readerService.clearMultipleBookmarks(seriesIds)),
tap(() => this.toastr.success(translate('bookmarks.delete-single-success'))),
map(() => this.fromAction(action, bookmarks, 'remove'))
);
default:
return of(this.fromAction(action, bookmarks, 'none'));
}
}
handleBulkCollectionAction(action: ActionItem<any>, collections: UserCollection[]): Observable<ActionResult<UserCollection[]>> {
switch (action.action) {
case Action.Promote:
return this.collectionTagService.promoteMultipleCollections(collections.map(c => c.id), true).pipe(
tap(() => this.toastr.success(translate('toasts.collections-promoted'))),
map(() => this.fromAction(action, collections.map(c => ({...c, promoted: true})), 'update'))
);
case Action.UnPromote:
return this.collectionTagService.promoteMultipleCollections(collections.map(c => c.id), false).pipe(
tap(() => this.toastr.success(translate('toasts.collections-unpromoted'))),
map(() => this.fromAction(action, collections.map(c => ({...c, promoted: false})), 'update'))
);
case Action.Delete:
return from(this.confirmService.confirm(translate('toasts.confirm-delete-collections'))).pipe(
filter(confirmed => confirmed),
switchMap(() => this.collectionTagService.deleteMultipleCollections(collections.map(c => c.id))),
tap(() => this.toastr.success(translate('toasts.collections-deleted'))),
map(() => this.fromAction(action, collections, 'remove'))
);
case Action.Download:
for (let c of collections) this.downloadService.download(DownloadEntityType.Collection, c, 0, 0);
return of(this.fromAction(action, collections, 'none'));
default:
return of(this.fromAction(action, collections, 'none'));
}
}
handleBulkReadingListAction(action: ActionItem<any>, readingLists: ReadingList[]): Observable<ActionResult<ReadingList[]>> {
switch (action.action) {
case Action.Promote:
return this.readingListService.promoteMultipleReadingLists(readingLists.map(r => r.id), true).pipe(
tap(() => this.toastr.success(translate('toasts.reading-list-promoted'))),
map(() => this.fromAction(action, readingLists.map(r => ({...r, promoted: true})), 'update'))
);
case Action.UnPromote:
return this.readingListService.promoteMultipleReadingLists(readingLists.map(r => r.id), false).pipe(
tap(() => this.toastr.success(translate('toasts.reading-list-unpromoted'))),
map(() => this.fromAction(action, readingLists.map(r => ({...r, promoted: false})), 'update'))
);
case Action.Delete:
return from(this.confirmService.confirm(translate('toasts.confirm-delete-reading-list'))).pipe(
filter(confirmed => confirmed),
switchMap(() => this.readingListService.deleteMultipleReadingLists(readingLists.map(r => r.id))),
tap(() => this.toastr.success(translate('toasts.reading-lists-deleted'))),
map(() => this.fromAction(action, readingLists, 'remove'))
);
case Action.Download:
for (const rl of readingLists) { this.downloadService.download(DownloadEntityType.ReadingList, rl, 0, 0); }
return of(this.fromAction(action, readingLists, 'none'));
default:
return of(this.fromAction(action, readingLists, 'none'));
}
}
handleBulkAnnotationAction(action: ActionItem<any>, annotations: Annotation[]): Observable<ActionResult<Annotation[]>> {
switch (action.action) {
case Action.Delete:
return from(this.confirmService.confirm(translate('toasts.confirm-delete-annotations'))).pipe(
filter(confirmed => confirmed),
switchMap(() => this.annotationsService.bulkDelete(annotations.map(a => a.id))),
tap(() => this.toastr.success(translate('toasts.annotations-deleted'))),
map(() => this.fromAction(action, annotations, 'remove'))
);
case Action.Export:
return this.annotationsService.exportAnnotations(annotations.map(a => a.id)).pipe(
map(() => this.fromAction(action, annotations, 'none'))
);
case Action.Like:
return this.annotationsService.likeAnnotations(annotations.map(a => a.id)).pipe(
map(() => this.fromAction(action, annotations, 'update'))
);
case Action.UnLike:
return this.annotationsService.unLikeAnnotations(annotations.map(a => a.id)).pipe(
map(() => this.fromAction(action, annotations, 'update'))
);
default:
return of(this.fromAction(action, annotations, 'none'));
}
}
handleBulkSideNavStreamAction(action: ActionItem<any>, streams: SideNavStream[]): Observable<ActionResult<SideNavStream[]>> {
switch (action.action) {
case Action.MarkAsVisible:
return this.sideNavService.bulkToggleSideNavStreamVisibility(streams.map(s => s.id), true).pipe(
map(() => this.fromAction(action, streams.map(s => ({...s, visible: true})), 'update'))
);
case Action.MarkAsInvisible:
return this.sideNavService.bulkToggleSideNavStreamVisibility(streams.map(s => s.id), false).pipe(
map(() => this.fromAction(action, streams.map(s => ({...s, visible: false})), 'update'))
);
default:
return of(this.fromAction(action, streams, 'none'));
}
}
// -------------------------------------------
// INDIVIDUAL HANDLERS
// -------------------------------------------
/**
* Request a file scan for a given Library
* @param library Partial Library, must have id and name populated
* @param callback Optional callback to perform actions after API completes
* @returns
*/
async scanLibrary(library: Partial<Library>, callback?: LibraryActionCallback) {
if (!library.hasOwnProperty('id') || library.id === undefined) {
return;
}
this.libraryService.scan(library.id, false).subscribe((res: any) => {
this.toastr.info(translate('toasts.scan-queued', {name: library.name}));
if (callback) {
callback(library);
}
});
}
/**
* Mark all chapters and the volume as Read
* @param seriesId Series Id
* @param volume Volume, should have id, chapters and pagesRead populated
* @param callback Optional callback to perform actions after API completes
*/
markVolumeAsRead(seriesId: number, volume: Volume, callback?: VolumeActionCallback) {
this.readerService.markVolumeRead(seriesId, volume.id).subscribe(() => {
volume.pagesRead = volume.pages;
volume.chapters?.forEach(c => c.pagesRead = c.pages);
this.toastr.success(translate('toasts.mark-read'));
if (callback) {
callback(volume);
}
});
}
/**
* Mark all chapters and the volume as unread
* @param seriesId Series Id
* @param volume Volume, should have id, chapters and pagesRead populated
* @param callback Optional callback to perform actions after API completes
*/
markVolumeAsUnread(seriesId: number, volume: Volume, callback?: VolumeActionCallback) {
this.readerService.markVolumeUnread(seriesId, volume.id).subscribe(() => {
volume.pagesRead = 0;
volume.chapters?.forEach(c => c.pagesRead = 0);
this.toastr.success(translate('toasts.mark-unread'));
if (callback) {
callback(volume);
}
});
}
/**
* Mark a chapter as read
* @param libraryId Library Id
* @param seriesId Series Id
* @param chapter Chapter, should have id, pages, volumeId populated
* @param callback Optional callback to perform actions after API completes
*/
markChapterAsRead(libraryId: number, seriesId: number, chapter: Chapter, callback?: ChapterActionCallback) {
this.readerService.saveProgress(libraryId, seriesId, chapter.volumeId, chapter.id, chapter.pages).subscribe(() => {
chapter.pagesRead = chapter.pages;
this.toastr.success(translate('toasts.mark-read'));
if (callback) {
callback(chapter);
}
});
}
/**
* Mark a chapter as unread
* @param libraryId Library Id
* @param seriesId Series Id
* @param chapter Chapter, should have id, pages, volumeId populated
* @param callback Optional callback to perform actions after API completes
*/
markChapterAsUnread(libraryId: number, seriesId: number, chapter: Chapter, callback?: ChapterActionCallback) {
this.readerService.saveProgress(libraryId, seriesId, chapter.volumeId, chapter.id, 0).subscribe(() => {
chapter.pagesRead = 0;
this.toastr.success(translate('toasts.mark-unread'));
if (callback) {
callback(chapter);
}
});
}
addMultipleSeriesToWantToReadList(seriesIds: Array<number>, callback?: VoidActionCallback) {
this.memberService.addSeriesToWantToRead(seriesIds).subscribe(() => {
this.toastr.success(translate('toasts.series-added-want-to-read'));
callback?.()
});
}
removeMultipleSeriesFromWantToReadList(seriesIds: Array<number>, callback?: VoidActionCallback) {
this.memberService.removeSeriesToWantToRead(seriesIds).subscribe(() => {
this.toastr.success(translate('toasts.series-removed-want-to-read'));
callback?.()
});
}
editReadingList(readingList: ReadingList, callback?: ReadingListActionCallback) {
const readingListModalRef = this.modalService.open(EditReadingListModalComponent, editModal());
readingListModalRef.componentInstance.readingList = readingList;
readingListModalRef.closed.pipe(take(1)).subscribe((list) => {
if (callback && list !== undefined) {
callback(readingList);
}
});
readingListModalRef.dismissed.pipe(take(1)).subscribe((list) => {
if (callback && list !== undefined) {
callback(readingList);
}
});
}
async deleteChapter(chapterId: number, callback?: BooleanActionCallback) {
if (!await this.confirmService.confirm(translate('toasts.confirm-delete-chapter'))) {
if (callback) {
callback(false);
}
return;
}
this.chapterService.deleteChapter(chapterId).subscribe((res: boolean) => {
if (callback) {
if (res) {
this.toastr.success(translate('toasts.chapter-deleted'));
} else {
this.toastr.error(translate('errors.generic'));
}
callback(res);
}
});
}
async deleteVolume(volumeId: number, callback?: BooleanActionCallback) {
if (!await this.confirmService.confirm(translate('toasts.confirm-delete-volume'))) {
if (callback) {
callback(false);
}
return;
}
this.volumeService.deleteVolume(volumeId).subscribe((res: boolean) => {
if (callback) {
if (res) {
this.toastr.success(translate('toasts.volume-deleted'));
} else {
this.toastr.error(translate('errors.generic'));
}
callback(res);
}
});
}
sendToDevice(chapterIds: Array<number>, device: Device, callback?: VoidActionCallback) {
this.deviceService.sendToEmailDevice(chapterIds, device.id).subscribe(() => {
this.toastr.success(translate('toasts.file-send-to', {name: device.name}));
callback?.()
});
}
matchSeries(series: Series, callback?: BooleanActionCallback) {
const ref = this.modalService.open(MatchSeriesModalComponent);
ref.setInput('series', series);
ref.closed.subscribe(saved => {
if (callback) {
callback(saved);
}
});
}
/**
* Sets the reading profile for multiple series
* @param series
* @param callback
*/
setReadingProfileForMultiple(series: Array<Series>, callback?: BooleanActionCallback) {
if (this.readingListModalRef != null) { return; }
this.readingListModalRef = this.modalService.open(BulkSetReadingProfileModalComponent, addToModal());
this.readingListModalRef.setInput('seriesIds', series.map(s => s.id));
this.readingListModalRef.closed.subscribe(() => {
this.readingListModalRef = null;
if (callback) {
callback(true);
}
});
this.readingListModalRef.dismissed.subscribe(() => {
this.readingListModalRef = null;
if (callback) {
callback(false);
}
});
}
/**
* Sets the reading profile for multiple series
* @param library
* @param callback
*/
setReadingProfileForLibrary(library: Library, callback?: BooleanActionCallback) {
if (this.readingListModalRef != null) { return; }
this.readingListModalRef = this.modalService.open(BulkSetReadingProfileModalComponent, addToModal());
this.readingListModalRef.setInput('libraryId', library.id);
this.readingListModalRef.closed.subscribe(() => {
this.readingListModalRef = null;
if (callback) {
callback(true);
}
});
this.readingListModalRef.dismissed.subscribe(() => {
this.readingListModalRef = null;
if (callback) {
callback(false);
}
});
}
private handleEditModal<T>(ref: NgbModalRef, action: ActionItem<T>, fallbackEntity: T): Observable<ActionResult<T>> {
return from(ref.closed).pipe(
filter((res: ModalResult<T>) => res.success),
map(res => {
if (res.isDeleted) return this.fromAction(action, fallbackEntity, 'remove');
return this.fromAction(action, res.data ?? fallbackEntity, 'update');
})
);
}
private fromAction<T>(action: ActionItem<T>, data: T, effect: ActionEffect): ActionResult<T> {
return { action: action.action, entity: data, effect: effect };
}
}