import { ChangeDetectionStrategy, ChangeDetectorRef, Component, DestroyRef, EventEmitter, inject, Input, OnInit } from '@angular/core'; import {FormBuilder, FormControl, FormGroup, ReactiveFormsModule, Validators} from '@angular/forms'; import { NgbActiveModal, NgbCollapse, NgbNav, NgbNavContent, NgbNavItem, NgbNavLink, NgbNavOutlet, NgbTooltip } from '@ng-bootstrap/ng-bootstrap'; import { forkJoin, Observable, of } from 'rxjs'; import { map } from 'rxjs/operators'; import { Breakpoint, UtilityService } from 'src/app/shared/_services/utility.service'; import { TypeaheadSettings } from 'src/app/typeahead/_models/typeahead-settings'; import { Chapter } from 'src/app/_models/chapter'; import { CollectionTag } from 'src/app/_models/collection-tag'; import { Genre } from 'src/app/_models/metadata/genre'; import { AgeRatingDto } from 'src/app/_models/metadata/age-rating-dto'; import { Language } from 'src/app/_models/metadata/language'; import { PublicationStatusDto } from 'src/app/_models/metadata/publication-status-dto'; import { Person, PersonRole } from 'src/app/_models/metadata/person'; import { Series } from 'src/app/_models/series'; import { SeriesMetadata } from 'src/app/_models/metadata/series-metadata'; import { Tag } from 'src/app/_models/tag'; import { CollectionTagService } from 'src/app/_services/collection-tag.service'; import { ImageService } from 'src/app/_services/image.service'; import { LibraryService } from 'src/app/_services/library.service'; import { MetadataService } from 'src/app/_services/metadata.service'; import { SeriesService } from 'src/app/_services/series.service'; import { UploadService } from 'src/app/_services/upload.service'; import {takeUntilDestroyed} from "@angular/core/rxjs-interop"; import {CommonModule} from "@angular/common"; import {TypeaheadComponent} from "../../../typeahead/_components/typeahead.component"; import {CoverImageChooserComponent} from "../../cover-image-chooser/cover-image-chooser.component"; import {EditSeriesRelationComponent} from "../../edit-series-relation/edit-series-relation.component"; import {SentenceCasePipe} from "../../../pipe/sentence-case.pipe"; import {MangaFormatPipe} from "../../../pipe/manga-format.pipe"; import {DefaultDatePipe} from "../../../pipe/default-date.pipe"; import {TimeAgoPipe} from "../../../pipe/time-ago.pipe"; import {TagBadgeComponent} from "../../../shared/tag-badge/tag-badge.component"; import {PublicationStatusPipe} from "../../../pipe/publication-status.pipe"; import {BytesPipe} from "../../../pipe/bytes.pipe"; import {ImageComponent} from "../../../shared/image/image.component"; import {DefaultValuePipe} from "../../../pipe/default-value.pipe"; import {TranslocoModule} from "@ngneat/transloco"; import {TranslocoDatePipe} from "@ngneat/transloco-locale"; import {UtcToLocalTimePipe} from "../../../pipe/utc-to-local-time.pipe"; enum TabID { General = 0, Metadata = 1, People = 2, WebLinks = 3, CoverImage = 4, Related = 5, Info = 6, } @Component({ selector: 'app-edit-series-modal', standalone: true, imports: [ ReactiveFormsModule, NgbNav, NgbNavContent, NgbNavItem, NgbNavLink, CommonModule, TypeaheadComponent, CoverImageChooserComponent, EditSeriesRelationComponent, SentenceCasePipe, MangaFormatPipe, DefaultDatePipe, TimeAgoPipe, TagBadgeComponent, PublicationStatusPipe, NgbTooltip, BytesPipe, ImageComponent, NgbCollapse, NgbNavOutlet, DefaultValuePipe, TranslocoModule, TranslocoDatePipe, UtcToLocalTimePipe, ], templateUrl: './edit-series-modal.component.html', styleUrls: ['./edit-series-modal.component.scss'], changeDetection: ChangeDetectionStrategy.OnPush }) export class EditSeriesModalComponent implements OnInit { @Input({required: true}) series!: Series; seriesVolumes: any[] = []; isLoadingVolumes = false; /** * A copy of the series from init. This is used to compare values for name fields to see if lock was modified */ initSeries!: Series; volumeCollapsed: any = {}; tabs = ['general-tab', 'metadata-tab', 'people-tab', 'web-links-tab', 'cover-image-tab', 'related-tab', 'info-tab']; active = this.tabs[0]; editSeriesForm!: FormGroup; libraryName: string | undefined = undefined; size: number = 0; private readonly destroyRef = inject(DestroyRef); // Typeaheads tagsSettings: TypeaheadSettings = new TypeaheadSettings(); languageSettings: TypeaheadSettings = new TypeaheadSettings(); peopleSettings: {[PersonRole: string]: TypeaheadSettings} = {}; collectionTagSettings: TypeaheadSettings = new TypeaheadSettings(); genreSettings: TypeaheadSettings = new TypeaheadSettings(); collectionTags: CollectionTag[] = []; tags: Tag[] = []; genres: Genre[] = []; ageRatings: Array = []; publicationStatuses: Array = []; validLanguages: Array = []; metadata!: SeriesMetadata; imageUrls: Array = []; /** * Selected Cover for uploading */ selectedCover: string = ''; coverImageReset = false; saveNestedComponents: EventEmitter = new EventEmitter(); get Breakpoint(): typeof Breakpoint { return Breakpoint; } get PersonRole() { return PersonRole; } get TabID(): typeof TabID { return TabID; } get WebLinks() { return this.metadata?.webLinks.split(',') || ['']; } getPersonsSettings(role: PersonRole) { return this.peopleSettings[role]; } constructor(public modal: NgbActiveModal, private seriesService: SeriesService, public utilityService: UtilityService, private fb: FormBuilder, public imageService: ImageService, private libraryService: LibraryService, private collectionService: CollectionTagService, private uploadService: UploadService, private metadataService: MetadataService, private readonly cdRef: ChangeDetectorRef) { } ngOnInit(): void { this.imageUrls.push(this.imageService.getSeriesCoverImage(this.series.id)); this.libraryService.getLibraryNames().pipe(takeUntilDestroyed(this.destroyRef)).subscribe(names => { this.libraryName = names[this.series.libraryId]; }); this.initSeries = Object.assign({}, this.series); this.editSeriesForm = this.fb.group({ id: new FormControl(this.series.id, []), summary: new FormControl('', []), name: new FormControl(this.series.name, [Validators.required]), localizedName: new FormControl(this.series.localizedName, []), sortName: new FormControl(this.series.sortName, [Validators.required]), rating: new FormControl(this.series.userRating, []), coverImageIndex: new FormControl(0, []), coverImageLocked: new FormControl(this.series.coverImageLocked, []), ageRating: new FormControl('', []), publicationStatus: new FormControl('', []), language: new FormControl('', []), releaseYear: new FormControl('', [Validators.minLength(4), Validators.maxLength(4), Validators.pattern(/([1-9]\d{3})|[0]{1}/)]), }); this.cdRef.markForCheck(); this.metadataService.getAllAgeRatings().subscribe(ratings => { this.ageRatings = ratings; this.cdRef.markForCheck(); }); this.metadataService.getAllPublicationStatus().subscribe(statuses => { this.publicationStatuses = statuses; this.cdRef.markForCheck(); }); this.metadataService.getAllValidLanguages().subscribe(validLanguages => { this.validLanguages = validLanguages; this.cdRef.markForCheck(); }); this.seriesService.getMetadata(this.series.id).subscribe(metadata => { if (metadata) { this.metadata = metadata; this.setupTypeaheads(); this.editSeriesForm.get('summary')?.patchValue(this.metadata.summary); this.editSeriesForm.get('ageRating')?.patchValue(this.metadata.ageRating); this.editSeriesForm.get('publicationStatus')?.patchValue(this.metadata.publicationStatus); this.editSeriesForm.get('language')?.patchValue(this.metadata.language); this.editSeriesForm.get('releaseYear')?.patchValue(this.metadata.releaseYear); this.WebLinks.forEach((link, index) => { this.editSeriesForm.addControl('link' + index, new FormControl(link, [])); }); this.cdRef.markForCheck(); this.editSeriesForm.get('name')?.valueChanges.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(val => { this.series.nameLocked = true; this.cdRef.markForCheck(); }); this.editSeriesForm.get('sortName')?.valueChanges.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(val => { this.series.sortNameLocked = true; this.cdRef.markForCheck(); }); this.editSeriesForm.get('localizedName')?.valueChanges.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(val => { this.series.localizedNameLocked = true; this.cdRef.markForCheck(); }); this.editSeriesForm.get('summary')?.valueChanges.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(val => { this.metadata.summaryLocked = true; this.metadata.summary = val; this.cdRef.markForCheck(); }); this.editSeriesForm.get('ageRating')?.valueChanges.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(val => { this.metadata.ageRating = parseInt(val + '', 10); this.metadata.ageRatingLocked = true; this.cdRef.markForCheck(); }); this.editSeriesForm.get('publicationStatus')?.valueChanges.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(val => { this.metadata.publicationStatus = parseInt(val + '', 10); this.metadata.publicationStatusLocked = true; this.cdRef.markForCheck(); }); this.editSeriesForm.get('releaseYear')?.valueChanges.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(val => { this.metadata.releaseYear = parseInt(val + '', 10); this.metadata.releaseYearLocked = true; this.cdRef.markForCheck(); }); } }); this.isLoadingVolumes = true; this.cdRef.markForCheck(); this.seriesService.getVolumes(this.series.id).subscribe(volumes => { this.seriesVolumes = volumes; this.isLoadingVolumes = false; if (this.seriesVolumes.length === 1) { this.imageUrls.push(...this.seriesVolumes[0].chapters.map((c: Chapter) => this.imageService.getChapterCoverImage(c.id))); } else { this.imageUrls.push(...this.seriesVolumes.map(v => this.imageService.getVolumeCoverImage(v.id))); } volumes.forEach(v => { this.volumeCollapsed[v.name] = true; }); this.seriesVolumes.forEach(vol => { vol.volumeFiles = vol.chapters?.sort(this.utilityService.sortChapters).map((c: Chapter) => c.files.map((f: any) => { f.chapter = c.number; return f; })).flat(); }); if (volumes.length > 0) { this.size = volumes.reduce((sum1, volume) => { return sum1 + volume.chapters.reduce((sum2, chapter) => { return sum2 + chapter.files.reduce((sum3, file) => { return sum3 + file.bytes; }, 0); }, 0); }, 0); } this.cdRef.markForCheck(); }); } setupTypeaheads() { forkJoin([ this.setupCollectionTagsSettings(), this.setupTagSettings(), this.setupGenreTypeahead(), this.setupPersonTypeahead(), this.setupLanguageTypeahead() ]).subscribe(results => { this.collectionTags = this.metadata.collectionTags; this.cdRef.markForCheck(); }); } setupCollectionTagsSettings() { this.collectionTagSettings.minCharacters = 0; this.collectionTagSettings.multiple = true; this.collectionTagSettings.id = 'collections'; this.collectionTagSettings.unique = true; this.collectionTagSettings.addIfNonExisting = true; this.collectionTagSettings.fetchFn = (filter: string) => this.fetchCollectionTags(filter).pipe(map(items => this.collectionTagSettings.compareFn(items, filter))); this.collectionTagSettings.addTransformFn = ((title: string) => { return {id: 0, title: title, promoted: false, coverImage: '', summary: '', coverImageLocked: false }; }); this.collectionTagSettings.compareFn = (options: CollectionTag[], filter: string) => { return options.filter(m => this.utilityService.filter(m.title, filter)); } this.collectionTagSettings.compareFnForAdd = (options: CollectionTag[], filter: string) => { return options.filter(m => this.utilityService.filterMatches(m.title, filter)); } this.collectionTagSettings.selectionCompareFn = (a: CollectionTag, b: CollectionTag) => { return a.title === b.title; } if (this.metadata.collectionTags) { this.collectionTagSettings.savedData = this.metadata.collectionTags; } return of(true); } setupTagSettings() { this.tagsSettings.minCharacters = 0; this.tagsSettings.multiple = true; this.tagsSettings.id = 'tags'; this.tagsSettings.unique = true; this.tagsSettings.showLocked = true; this.tagsSettings.addIfNonExisting = true; this.tagsSettings.compareFn = (options: Tag[], filter: string) => { return options.filter(m => this.utilityService.filter(m.title, filter)); } this.tagsSettings.fetchFn = (filter: string) => this.metadataService.getAllTags() .pipe(map(items => this.tagsSettings.compareFn(items, filter))); this.tagsSettings.addTransformFn = ((title: string) => { return {id: 0, title: title }; }); this.tagsSettings.selectionCompareFn = (a: Tag, b: Tag) => { return a.id == b.id; } this.tagsSettings.compareFnForAdd = (options: Tag[], filter: string) => { return options.filter(m => this.utilityService.filterMatches(m.title, filter)); } if (this.metadata.tags) { this.tagsSettings.savedData = this.metadata.tags; } return of(true); } setupGenreTypeahead() { this.genreSettings.minCharacters = 0; this.genreSettings.multiple = true; this.genreSettings.id = 'genres'; this.genreSettings.unique = true; this.genreSettings.showLocked = true; this.genreSettings.addIfNonExisting = true; this.genreSettings.fetchFn = (filter: string) => { return this.metadataService.getAllGenres() .pipe(map(items => this.genreSettings.compareFn(items, filter))); }; this.genreSettings.compareFn = (options: Genre[], filter: string) => { return options.filter(m => this.utilityService.filter(m.title, filter)); } this.genreSettings.compareFnForAdd = (options: Genre[], filter: string) => { return options.filter(m => this.utilityService.filterMatches(m.title, filter)); } this.genreSettings.selectionCompareFn = (a: Genre, b: Genre) => { return a.title == b.title; } this.genreSettings.addTransformFn = ((title: string) => { return {id: 0, title: title }; }); if (this.metadata.genres) { this.genreSettings.savedData = this.metadata.genres; } return of(true); } updateFromPreset(id: string, presetField: Array | undefined, role: PersonRole) { const personSettings = this.createBlankPersonSettings(id, role) if (presetField && presetField.length > 0) { const fetch = personSettings.fetchFn as ((filter: string) => Observable); return fetch('').pipe(map(people => { const persetIds = presetField.map(p => p.id); personSettings.savedData = people.filter(person => persetIds.includes(person.id)); this.peopleSettings[role] = personSettings; this.updatePerson(personSettings.savedData as Person[], role); return true; })); } else { this.peopleSettings[role] = personSettings; return of(true); } } setupLanguageTypeahead() { this.languageSettings.minCharacters = 0; this.languageSettings.multiple = false; this.languageSettings.id = 'language'; this.languageSettings.unique = true; this.languageSettings.showLocked = true; this.languageSettings.addIfNonExisting = false; this.languageSettings.compareFn = (options: Language[], filter: string) => { return options.filter(m => this.utilityService.filter(m.title, filter)); } this.languageSettings.compareFnForAdd = (options: Language[], filter: string) => { return options.filter(m => this.utilityService.filterMatches(m.title, filter)); } this.languageSettings.fetchFn = (filter: string) => of(this.validLanguages) .pipe(map(items => this.languageSettings.compareFn(items, filter))); this.languageSettings.selectionCompareFn = (a: Language, b: Language) => { return a.isoCode == b.isoCode; } if (this.metadata.language === undefined || this.metadata.language === null || this.metadata.language === '') { this.metadata.language = 'en'; } const l = this.validLanguages.find(l => l.isoCode === this.metadata.language); if (l !== undefined) { this.languageSettings.savedData = l; } return of(true); } setupPersonTypeahead() { this.peopleSettings = {}; return forkJoin([ this.updateFromPreset('writer', this.metadata.writers, PersonRole.Writer), this.updateFromPreset('character', this.metadata.characters, PersonRole.Character), this.updateFromPreset('colorist', this.metadata.colorists, PersonRole.Colorist), this.updateFromPreset('cover-artist', this.metadata.coverArtists, PersonRole.CoverArtist), this.updateFromPreset('editor', this.metadata.editors, PersonRole.Editor), this.updateFromPreset('inker', this.metadata.inkers, PersonRole.Inker), this.updateFromPreset('letterer', this.metadata.letterers, PersonRole.Letterer), this.updateFromPreset('penciller', this.metadata.pencillers, PersonRole.Penciller), this.updateFromPreset('publisher', this.metadata.publishers, PersonRole.Publisher), this.updateFromPreset('translator', this.metadata.translators, PersonRole.Translator) ]).pipe(map(results => { return of(true); })); } fetchPeople(role: PersonRole, filter: string) { return this.metadataService.getAllPeople().pipe(map(people => { return people.filter(p => p.role == role && this.utilityService.filter(p.name, filter)); })); } createBlankPersonSettings(id: string, role: PersonRole) { var personSettings = new TypeaheadSettings(); personSettings.minCharacters = 0; personSettings.multiple = true; personSettings.showLocked = true; personSettings.unique = true; personSettings.addIfNonExisting = true; personSettings.id = id; personSettings.compareFn = (options: Person[], filter: string) => { return options.filter(m => this.utilityService.filter(m.name, filter)); } personSettings.compareFnForAdd = (options: Person[], filter: string) => { return options.filter(m => this.utilityService.filterMatches(m.name, filter)); } personSettings.selectionCompareFn = (a: Person, b: Person) => { return a.name == b.name && a.role == b.role; } personSettings.fetchFn = (filter: string) => { return this.fetchPeople(role, filter).pipe(map(items => personSettings.compareFn(items, filter))); }; personSettings.addTransformFn = ((title: string) => { return {id: 0, name: title, role: role }; }); return personSettings; } close() { this.modal.close({success: false, series: undefined, coverImageUpdate: this.coverImageReset}); } fetchCollectionTags(filter: string = '') { return this.collectionService.search(filter); } formatChapterNumber(chapter: Chapter) { if (chapter.number === '0') { return '1'; } return chapter.number; } save() { const model = this.editSeriesForm.value; const selectedIndex = this.editSeriesForm.get('coverImageIndex')?.value || 0; this.metadata.webLinks = Object.keys(this.editSeriesForm.controls) .filter(key => key.startsWith('link')) .map(key => this.editSeriesForm.get(key)?.value.replace(',', '%2C')) .filter(v => v !== null && v !== '') .join(','); const apis = [ this.seriesService.updateMetadata(this.metadata, this.collectionTags) ]; // We only need to call updateSeries if we changed name, sort name, or localized name or reset a cover image const nameFieldsDirty = this.editSeriesForm.get('name')?.dirty || this.editSeriesForm.get('sortName')?.dirty || this.editSeriesForm.get('localizedName')?.dirty; const nameFieldLockChanged = this.series.nameLocked !== this.initSeries.nameLocked || this.series.sortNameLocked !== this.initSeries.sortNameLocked || this.series.localizedNameLocked !== this.initSeries.localizedNameLocked; if (nameFieldsDirty || nameFieldLockChanged || this.coverImageReset) { model.nameLocked = this.series.nameLocked; model.sortNameLocked = this.series.sortNameLocked; model.localizedNameLocked = this.series.localizedNameLocked; model.language = this.metadata.language; apis.push(this.seriesService.updateSeries(model)); } if (selectedIndex > 0 && this.selectedCover !== '') { apis.push(this.uploadService.updateSeriesCoverImage(model.id, this.selectedCover)); } this.saveNestedComponents.emit(); forkJoin(apis).subscribe(results => { this.modal.close({success: true, series: model, coverImageUpdate: selectedIndex > 0 || this.coverImageReset}); }); } addWebLink() { this.metadata.webLinks += ','; this.editSeriesForm.addControl('link' + (this.WebLinks.length - 1), new FormControl('', [])); this.cdRef.markForCheck(); } removeWebLink(index: number) { const tokens = this.metadata.webLinks.split(','); const tokenToRemove = tokens[index]; this.metadata.webLinks = tokens.filter(t => t != tokenToRemove).join(','); this.editSeriesForm.removeControl('link' + index, {emitEvent: true}); this.cdRef.markForCheck(); } updateCollections(tags: CollectionTag[]) { this.collectionTags = tags; this.cdRef.markForCheck(); } updateTags(tags: Tag[]) { this.tags = tags; this.metadata.tags = tags; this.cdRef.markForCheck(); } updateGenres(genres: Genre[]) { this.genres = genres; this.metadata.genres = genres; this.cdRef.markForCheck(); } updateLanguage(language: Array) { if (language.length === 0) { this.metadata.language = ''; return; } this.metadata.language = language[0].isoCode; this.cdRef.markForCheck(); } updatePerson(persons: Person[], role: PersonRole) { switch (role) { case PersonRole.CoverArtist: this.metadata.coverArtists = persons; break; case PersonRole.Character: this.metadata.characters = persons; break; case PersonRole.Colorist: this.metadata.colorists = persons; break; case PersonRole.Editor: this.metadata.editors = persons; break; case PersonRole.Inker: this.metadata.inkers = persons; break; case PersonRole.Letterer: this.metadata.letterers = persons; break; case PersonRole.Penciller: this.metadata.pencillers = persons; break; case PersonRole.Publisher: this.metadata.publishers = persons; break; case PersonRole.Writer: this.metadata.writers = persons; break; case PersonRole.Translator: this.metadata.translators = persons; } this.cdRef.markForCheck(); } updateSelectedIndex(index: number) { this.editSeriesForm.patchValue({ coverImageIndex: index }); this.cdRef.markForCheck(); } updateSelectedImage(url: string) { this.selectedCover = url; this.cdRef.markForCheck(); } handleReset() { this.coverImageReset = true; this.editSeriesForm.patchValue({ coverImageLocked: false }); this.cdRef.markForCheck(); } unlock(b: any, field: string) { if (b) { b[field] = !b[field]; } this.cdRef.markForCheck(); } }