Kavita/UI/Web/src/app/sidenav/_modals/library-settings-modal/library-settings-modal.component.ts
2025-05-10 14:57:14 -07:00

431 lines
17 KiB
TypeScript

import {ChangeDetectionStrategy, ChangeDetectorRef, Component, DestroyRef, inject, Input, OnInit} from '@angular/core';
import {FormControl, FormGroup, ReactiveFormsModule, Validators} from '@angular/forms';
import {
NgbActiveModal,
NgbModal,
NgbModalModule,
NgbNav,
NgbNavContent,
NgbNavItem,
NgbNavLink,
NgbNavOutlet,
NgbTooltip
} from '@ng-bootstrap/ng-bootstrap';
import {ToastrService} from 'ngx-toastr';
import {debounceTime, distinctUntilChanged, switchMap, tap} from 'rxjs';
import {SettingsService} from 'src/app/admin/settings.service';
import {
DirectoryPickerComponent,
DirectoryPickerResult
} from 'src/app/admin/_modals/directory-picker/directory-picker.component';
import {ConfirmService} from 'src/app/shared/confirm.service';
import {Breakpoint, UtilityService} from 'src/app/shared/_services/utility.service';
import {allLibraryTypes, Library, LibraryType} from 'src/app/_models/library/library';
import {ImageService} from 'src/app/_services/image.service';
import {LibraryService} from 'src/app/_services/library.service';
import {UploadService} from 'src/app/_services/upload.service';
import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
import {CommonModule} from "@angular/common";
import {SentenceCasePipe} from "../../../_pipes/sentence-case.pipe";
import {CoverImageChooserComponent} from "../../../cards/cover-image-chooser/cover-image-chooser.component";
import {translate, TranslocoModule} from "@jsverse/transloco";
import {DefaultDatePipe} from "../../../_pipes/default-date.pipe";
import {allFileTypeGroup, FileTypeGroup} from "../../../_models/library/file-type-group.enum";
import {FileTypeGroupPipe} from "../../../_pipes/file-type-group.pipe";
import {EditListComponent} from "../../../shared/edit-list/edit-list.component";
import {WikiLink} from "../../../_models/wiki";
import {SettingItemComponent} from "../../../settings/_components/setting-item/setting-item.component";
import {SettingSwitchComponent} from "../../../settings/_components/setting-switch/setting-switch.component";
import {SettingButtonComponent} from "../../../settings/_components/setting-button/setting-button.component";
import {Action, ActionFactoryService, ActionItem} from "../../../_services/action-factory.service";
import {ActionService} from "../../../_services/action.service";
import {LibraryTypePipe} from "../../../_pipes/library-type.pipe";
enum TabID {
General = 'general-tab',
Folder = 'folder-tab',
Cover = 'cover-tab',
Advanced = 'advanced-tab',
Tasks = 'tasks-tab'
}
enum StepID {
General = 0,
Folder = 1,
Cover = 2,
Advanced = 3
}
@Component({
selector: 'app-library-settings-modal',
imports: [CommonModule, NgbModalModule, NgbNavLink, NgbNavItem, NgbNavContent, ReactiveFormsModule, NgbTooltip,
SentenceCasePipe, NgbNav, NgbNavOutlet, CoverImageChooserComponent, TranslocoModule, DefaultDatePipe,
FileTypeGroupPipe, EditListComponent, SettingItemComponent, SettingSwitchComponent, SettingButtonComponent],
templateUrl: './library-settings-modal.component.html',
styleUrls: ['./library-settings-modal.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class LibrarySettingsModalComponent implements OnInit {
protected readonly utilityService = inject(UtilityService);
protected readonly modal = inject(NgbActiveModal);
private readonly destroyRef = inject(DestroyRef);
private readonly uploadService = inject(UploadService);
private readonly modalService = inject(NgbModal);
private readonly settingService = inject(SettingsService);
private readonly confirmService = inject(ConfirmService);
private readonly libraryService = inject(LibraryService);
private readonly toastr = inject(ToastrService);
private readonly cdRef = inject(ChangeDetectorRef);
private readonly imageService = inject(ImageService);
private readonly actionFactoryService = inject(ActionFactoryService);
private readonly actionService = inject(ActionService);
protected readonly LibraryType = LibraryType;
protected readonly Breakpoint = Breakpoint;
protected readonly TabID = TabID;
protected readonly WikiLink = WikiLink;
protected readonly Action = Action;
protected readonly libraryTypePipe = new LibraryTypePipe();
@Input({required: true}) library!: Library | undefined;
active = TabID.General;
imageUrls: Array<string> = [];
protected readonly excludePatternTooltip = `<span>` + translate('library-settings-modal.exclude-patterns-tooltip') +
`<a class="ms-1" href="${WikiLink.ScannerExclude}" rel="noopener noreferrer" target="_blank">${translate('library-settings-modal.help')}` +
`<i class="fa fa-external-link-alt ms-1" aria-hidden="true"></i></a>`;
libraryForm: FormGroup = new FormGroup({
name: new FormControl<string>('', { nonNullable: true, validators: [Validators.required] }),
type: new FormControl<LibraryType>(LibraryType.Manga, { nonNullable: true, validators: [Validators.required] }),
folderWatching: new FormControl<boolean>(true, { nonNullable: true, validators: [Validators.required] }),
includeInDashboard: new FormControl<boolean>(true, { nonNullable: true, validators: [Validators.required] }),
includeInRecommended: new FormControl<boolean>(true, { nonNullable: true, validators: [Validators.required] }),
includeInSearch: new FormControl<boolean>(true, { nonNullable: true, validators: [Validators.required] }),
manageCollections: new FormControl<boolean>(true, { nonNullable: true, validators: [Validators.required] }),
manageReadingLists: new FormControl<boolean>(true, { nonNullable: true, validators: [Validators.required] }),
allowScrobbling: new FormControl<boolean>(true, { nonNullable: true, validators: [Validators.required] }),
allowMetadataMatching: new FormControl<boolean>(true, { nonNullable: true, validators: [Validators.required] }),
collapseSeriesRelationships: new FormControl<boolean>(false, { nonNullable: true, validators: [Validators.required] }),
});
selectedFolders: string[] = [];
madeChanges = false;
libraryTypes = allLibraryTypes.map(f => {
return {title: this.libraryTypePipe.transform(f), value: f};
}).sort((a, b) => a.title.localeCompare(b.title));
isAddLibrary = false;
setupStep = StepID.General;
fileTypeGroups = allFileTypeGroup;
excludePatterns: Array<string> = [''];
tasks: ActionItem<Library>[] = this.getTasks();
get IsKavitaPlusEligible() {
const libType = parseInt(this.libraryForm.get('type')?.value + '', 10) as LibraryType;
return libType === LibraryType.Manga || libType === LibraryType.LightNovel;
}
get IsMetadataDownloadEligible() {
const libType = parseInt(this.libraryForm.get('type')?.value + '', 10) as LibraryType;
return libType === LibraryType.Manga || libType === LibraryType.LightNovel
|| libType === LibraryType.ComicVine || libType === LibraryType.Comic;
}
ngOnInit(): void {
if (this.library === undefined) {
this.isAddLibrary = true;
this.cdRef.markForCheck();
}
if (this.library?.coverImage != null && this.library?.coverImage !== '') {
this.imageUrls.push(this.imageService.getLibraryCoverImage(this.library.id));
this.cdRef.markForCheck();
}
if (this.library && !(this.library.type === LibraryType.Manga || this.library.type === LibraryType.LightNovel) ) {
this.libraryForm.get('allowScrobbling')?.setValue(false);
this.libraryForm.get('allowScrobbling')?.disable();
if (this.IsMetadataDownloadEligible) {
this.libraryForm.get('allowMetadataMatching')?.setValue(this.library.allowMetadataMatching);
this.libraryForm.get('allowMetadataMatching')?.enable();
} else {
this.libraryForm.get('allowMetadataMatching')?.setValue(false);
this.libraryForm.get('allowMetadataMatching')?.disable();
}
}
this.libraryForm.get('name')?.valueChanges.pipe(
debounceTime(100),
distinctUntilChanged(),
switchMap(name => this.libraryService.libraryNameExists(name)),
tap(exists => {
const isExistingName = this.libraryForm.get('name')?.value === this.library?.name;
if (!exists || isExistingName) {
this.libraryForm.get('name')?.setErrors(null);
} else {
this.libraryForm.get('name')?.setErrors({duplicateName: true})
}
this.cdRef.markForCheck();
}),
takeUntilDestroyed(this.destroyRef)
).subscribe();
this.setValues();
// This needs to only apply after first render
this.libraryForm.get('type')?.valueChanges.pipe(
tap((type: LibraryType) => {
const libType = parseInt(type + '', 10) as LibraryType;
switch (libType) {
case LibraryType.Manga:
this.libraryForm.get(FileTypeGroup.Archive + '')?.setValue(true);
this.libraryForm.get(FileTypeGroup.Images + '')?.setValue(true);
this.libraryForm.get(FileTypeGroup.Pdf + '')?.setValue(false);
this.libraryForm.get(FileTypeGroup.Epub + '')?.setValue(false);
break;
case LibraryType.Comic:
case LibraryType.ComicVine:
this.libraryForm.get(FileTypeGroup.Archive + '')?.setValue(true);
this.libraryForm.get(FileTypeGroup.Images + '')?.setValue(false);
this.libraryForm.get(FileTypeGroup.Pdf + '')?.setValue(false);
this.libraryForm.get(FileTypeGroup.Epub + '')?.setValue(false);
break;
case LibraryType.Book:
this.libraryForm.get(FileTypeGroup.Archive + '')?.setValue(false);
this.libraryForm.get(FileTypeGroup.Images + '')?.setValue(false);
this.libraryForm.get(FileTypeGroup.Pdf + '')?.setValue(true);
this.libraryForm.get(FileTypeGroup.Epub + '')?.setValue(true);
break;
case LibraryType.LightNovel:
this.libraryForm.get(FileTypeGroup.Archive + '')?.setValue(false);
this.libraryForm.get(FileTypeGroup.Images + '')?.setValue(false);
this.libraryForm.get(FileTypeGroup.Pdf + '')?.setValue(false);
this.libraryForm.get(FileTypeGroup.Epub + '')?.setValue(true);
break;
case LibraryType.Images:
this.libraryForm.get(FileTypeGroup.Archive + '')?.setValue(false);
this.libraryForm.get(FileTypeGroup.Images + '')?.setValue(true);
this.libraryForm.get(FileTypeGroup.Pdf + '')?.setValue(false);
this.libraryForm.get(FileTypeGroup.Epub + '')?.setValue(false);
break;
}
this.libraryForm.get('allowScrobbling')?.setValue(this.IsKavitaPlusEligible);
this.libraryForm.get('allowMetadataMatching')?.setValue(this.IsKavitaPlusEligible);
if (!this.IsKavitaPlusEligible) {
this.libraryForm.get('allowScrobbling')?.disable();
} else {
this.libraryForm.get('allowScrobbling')?.enable();
}
if (this.IsMetadataDownloadEligible) {
this.libraryForm.get('allowMetadataMatching')?.enable();
} else {
this.libraryForm.get('allowMetadataMatching')?.disable();
}
this.cdRef.markForCheck();
}),
takeUntilDestroyed(this.destroyRef)
).subscribe();
}
setValues() {
if (this.library !== undefined) {
this.libraryForm.get('name')?.setValue(this.library.name);
this.libraryForm.get('type')?.setValue(this.library.type);
this.libraryForm.get('folderWatching')?.setValue(this.library.folderWatching);
this.libraryForm.get('includeInDashboard')?.setValue(this.library.includeInDashboard);
this.libraryForm.get('includeInRecommended')?.setValue(this.library.includeInRecommended);
this.libraryForm.get('includeInSearch')?.setValue(this.library.includeInSearch);
this.libraryForm.get('manageCollections')?.setValue(this.library.manageCollections);
this.libraryForm.get('manageReadingLists')?.setValue(this.library.manageReadingLists);
this.libraryForm.get('collapseSeriesRelationships')?.setValue(this.library.collapseSeriesRelationships);
this.libraryForm.get('allowScrobbling')?.setValue(this.IsKavitaPlusEligible ? this.library.allowScrobbling : false);
this.libraryForm.get('allowMetadataMatching')?.setValue(this.IsMetadataDownloadEligible ? this.library.allowMetadataMatching : false);
this.selectedFolders = this.library.folders;
this.madeChanges = false;
// TODO: Refactor into FormArray
for(let fileTypeGroup of allFileTypeGroup) {
this.libraryForm.addControl(fileTypeGroup + '', new FormControl((this.library.libraryFileTypes || []).includes(fileTypeGroup), []));
}
// TODO: Refactor into FormArray
for(let glob of this.library.excludePatterns) {
this.libraryForm.addControl('excludeGlob-', new FormControl(glob, []));
}
this.excludePatterns = this.library.excludePatterns;
} else {
for(let fileTypeGroup of allFileTypeGroup) {
this.libraryForm.addControl(fileTypeGroup + '', new FormControl(true, []));
}
}
if (this.excludePatterns.length === 0) {
this.excludePatterns = [''];
}
this.cdRef.markForCheck();
}
updateGlobs(items: Array<string>) {
this.excludePatterns = items;
this.cdRef.markForCheck();
}
isDisabled() {
return !(this.libraryForm.valid && this.selectedFolders.length > 0);
}
reset() {
this.setValues();
}
close(returnVal= false) {
this.modal.close(returnVal);
}
forceScan() {
this.libraryService.scan(this.library!.id, true)
.subscribe(() => {
this.toastr.info(translate('toasts.forced-scan-queued', {name: this.library!.name}));
this.close();
});
}
async save() {
const model = this.libraryForm.value;
model.folders = this.selectedFolders;
model.fileGroupTypes = [];
for(let fileTypeGroup of allFileTypeGroup) {
if (model[fileTypeGroup]) {
model.fileGroupTypes.push(fileTypeGroup);
}
}
model.excludePatterns = this.excludePatterns;
if (this.libraryForm.errors) {
return;
}
if (this.library !== undefined) {
model.id = this.library.id;
model.folders = model.folders.map((item: string) => item.startsWith('\\') ? item.substr(1, item.length) : item);
model.type = parseInt(model.type, 10);
if (model.type !== this.library.type) {
if (!await this.confirmService.confirm(translate('toasts.confirm-library-type-change'))) return;
}
this.libraryService.update(model).subscribe(() => {
this.close(true);
});
} else {
model.folders = model.folders.map((item: string) => item.startsWith('\\') ? item.substr(1, item.length) : item);
model.type = parseInt(model.type, 10);
this.libraryService.create(model).subscribe(() => {
this.toastr.success(translate('toasts.library-created'));
this.close(true);
});
}
}
nextStep() {
this.setupStep++;
switch(this.setupStep) {
case StepID.Folder:
this.active = TabID.Folder;
break;
case StepID.Cover:
this.active = TabID.Cover;
break;
case StepID.Advanced:
this.active = TabID.Advanced;
break;
}
this.cdRef.markForCheck();
}
applyCoverImage(coverUrl: string) {
this.uploadService.updateLibraryCoverImage(this.library!.id, coverUrl).subscribe(() => {});
}
updateCoverImageIndex(selectedIndex: number) {
if (selectedIndex <= 0) return;
this.applyCoverImage(this.imageUrls[selectedIndex]);
}
resetCoverImage() {
this.uploadService.updateLibraryCoverImage(this.library!.id, '', false).subscribe(() => {});
}
openDirectoryPicker() {
const modalRef = this.modalService.open(DirectoryPickerComponent, { scrollable: true, size: 'lg' });
modalRef.closed.subscribe((closeResult: DirectoryPickerResult) => {
if (closeResult.success) {
if (!this.selectedFolders.includes(closeResult.folderPath)) {
this.selectedFolders.push(closeResult.folderPath);
this.madeChanges = true;
this.cdRef.markForCheck();
}
}
});
}
removeFolder(folder: string) {
this.selectedFolders = this.selectedFolders.filter(item => item !== folder);
this.madeChanges = true;
this.cdRef.markForCheck();
}
isNextDisabled() {
switch (this.setupStep) {
case StepID.General:
return this.libraryForm.get('name')?.invalid || this.libraryForm.get('type')?.invalid;
case StepID.Folder:
return this.selectedFolders.length === 0;
case StepID.Cover:
return false; // Covers are optional
case StepID.Advanced:
return false; // Advanced are optional
}
}
getTasks() {
const blackList = [Action.Edit];
return this.actionFactoryService.getActionablesForSettingsPage(this.actionFactoryService.getLibraryActions(this.runTask.bind(this)), blackList);
}
async runTask(action: ActionItem<Library>) {
switch (action.action) {
case Action.Scan:
await this.actionService.scanLibrary(this.library!);
break;
case Action.RefreshMetadata:
await this.actionService.refreshLibraryMetadata(this.library!);
break;
case Action.GenerateColorScape:
await this.actionService.refreshLibraryMetadata(this.library!, undefined, false);
break;
case (Action.AnalyzeFiles):
await this.actionService.analyzeFiles(this.library!);
break;
case Action.Delete:
await this.actionService.deleteLibrary(this.library!, () => {
this.modal.dismiss();
});
break;
}
}
}