mirror of
https://github.com/Kareadita/Kavita.git
synced 2026-06-05 06:15:25 -04:00
1594 lines
66 KiB
TypeScript
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 };
|
|
}
|
|
}
|