diff --git a/.github/workflows/sonar-scan.yml b/.github/workflows/sonar-scan.yml index d98d5b0d9..e997a9e8d 100644 --- a/.github/workflows/sonar-scan.yml +++ b/.github/workflows/sonar-scan.yml @@ -230,7 +230,7 @@ jobs: severity: info description: v${{steps.get-version.outputs.assembly-version}} - ${{ steps.findPr.outputs.title }} details: '${{ steps.parse-body.outputs.BODY }}' - text: <@&939225350775406643> A new nightly build has been released for docker. + text: <@&950058626658234398> A new nightly build has been released for docker. webhookUrl: ${{ secrets.DISCORD_DOCKER_UPDATE_URL }} stable: diff --git a/API.Tests/Services/ReaderServiceTests.cs b/API.Tests/Services/ReaderServiceTests.cs index f12472c5b..61fad6e0a 100644 --- a/API.Tests/Services/ReaderServiceTests.cs +++ b/API.Tests/Services/ReaderServiceTests.cs @@ -595,7 +595,7 @@ public class ReaderServiceTests } [Fact] - public async Task GetNextChapterIdAsync_ShouldFindNoNextChapterFromLastChapter() + public async Task GetNextChapterIdAsync_ShouldFindNoNextChapterFromLastChapter_NoSpecials() { await ResetDB(); @@ -636,7 +636,7 @@ public class ReaderServiceTests } [Fact] - public async Task GetNextChapterIdAsync_ShouldMoveFromVolumeToSpecial() + public async Task GetNextChapterIdAsync_ShouldMoveFromVolumeToSpecial_NoLooseLeafChapters() { await ResetDB(); @@ -678,6 +678,87 @@ public class ReaderServiceTests Assert.Equal("A.cbz", actualChapter.Range); } + [Fact] + public async Task GetNextChapterIdAsync_ShouldMoveFromLooseLeafChapterToSpecial() + { + await ResetDB(); + + _context.Series.Add(new Series() + { + Name = "Test", + Library = new Library() { + Name = "Test LIb", + Type = LibraryType.Manga, + }, + Volumes = new List() + { + EntityFactory.CreateVolume("0", new List() + { + EntityFactory.CreateChapter("1", false, new List()), + EntityFactory.CreateChapter("2", false, new List()), + EntityFactory.CreateChapter("A.cbz", true, new List()), + }), + } + }); + + _context.AppUser.Add(new AppUser() + { + UserName = "majora2007" + }); + + await _context.SaveChangesAsync(); + + var readerService = new ReaderService(_unitOfWork, Substitute.For>()); + + + var nextChapter = await readerService.GetNextChapterIdAsync(1, 1, 2, 1); + Assert.NotEqual(-1, nextChapter); + var actualChapter = await _unitOfWork.ChapterRepository.GetChapterAsync(nextChapter); + Assert.Equal("A.cbz", actualChapter.Range); + } + + [Fact] + public async Task GetNextChapterIdAsync_ShouldFindNoNextChapterFromSpecial_WithVolumeAndLooseLeafChapters() + { + await ResetDB(); + + _context.Series.Add(new Series() + { + Name = "Test", + Library = new Library() { + Name = "Test LIb", + Type = LibraryType.Manga, + }, + Volumes = new List() + { + EntityFactory.CreateVolume("0", new List() + { + EntityFactory.CreateChapter("1", false, new List()), + EntityFactory.CreateChapter("2", false, new List()), + EntityFactory.CreateChapter("A.cbz", true, new List()), + }), + EntityFactory.CreateVolume("1", new List() + { + EntityFactory.CreateChapter("0", false, new List()), + }), + } + }); + + _context.AppUser.Add(new AppUser() + { + UserName = "majora2007" + }); + + await _context.SaveChangesAsync(); + + var readerService = new ReaderService(_unitOfWork, Substitute.For>()); + + + var nextChapter = await readerService.GetNextChapterIdAsync(1, 1, 3, 1); + Assert.Equal(-1, nextChapter); + } + + [Fact] public async Task GetNextChapterIdAsync_ShouldMoveFromSpecialToSpecial() { diff --git a/API/Controllers/ImageController.cs b/API/Controllers/ImageController.cs index b1061997f..8b58fe9b3 100644 --- a/API/Controllers/ImageController.cs +++ b/API/Controllers/ImageController.cs @@ -4,6 +4,7 @@ using API.Data; using API.Entities.Enums; using API.Extensions; using API.Services; +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; namespace API.Controllers @@ -110,5 +111,22 @@ namespace API.Controllers Response.AddCacheHeader(file.FullName); return PhysicalFile(file.FullName, "image/" + format, Path.GetFileName(file.FullName)); } + + /// + /// Returns a temp coverupload image + /// + /// Filename of file. This is used with upload/upload-by-url + /// + [AllowAnonymous] + [HttpGet("cover-upload")] + public ActionResult GetCoverUploadImage(string filename) + { + var path = Path.Join(_directoryService.TempDirectory, filename); + if (string.IsNullOrEmpty(path) || !_directoryService.FileSystem.File.Exists(path)) return BadRequest($"File does not exist"); + var format = _directoryService.FileSystem.Path.GetExtension(path).Replace(".", ""); + + Response.AddCacheHeader(path); + return PhysicalFile(path, "image/" + format, _directoryService.FileSystem.Path.GetFileName(path)); + } } } diff --git a/API/Controllers/SeriesController.cs b/API/Controllers/SeriesController.cs index d1bce5975..b81b65158 100644 --- a/API/Controllers/SeriesController.cs +++ b/API/Controllers/SeriesController.cs @@ -150,26 +150,14 @@ namespace API.Controllers return BadRequest("A series already exists in this library with this name. Series Names must be unique to a library."); } - if (!series.Name.Equals(updateSeries.Name.Trim())) - { - series.Name = updateSeries.Name.Trim(); - series.NameLocked = true; - } - if (!series.SortName.Equals(updateSeries.SortName.Trim())) - { - series.SortName = updateSeries.SortName.Trim(); - series.SortNameLocked = true; - } - if (!series.LocalizedName.Equals(updateSeries.LocalizedName.Trim())) - { - series.LocalizedName = updateSeries.LocalizedName.Trim(); - series.LocalizedNameLocked = true; - } + series.Name = updateSeries.Name.Trim(); + series.SortName = updateSeries.SortName.Trim(); + series.LocalizedName = updateSeries.LocalizedName.Trim(); + series.NameLocked = updateSeries.NameLocked; + series.SortNameLocked = updateSeries.SortNameLocked; + series.LocalizedNameLocked = updateSeries.LocalizedNameLocked; - if (!series.NameLocked) series.NameLocked = false; - if (!series.SortNameLocked) series.SortNameLocked = false; - if (!series.LocalizedNameLocked) series.LocalizedNameLocked = false; var needsRefreshMetadata = false; // This is when you hit Reset diff --git a/API/Controllers/ServerController.cs b/API/Controllers/ServerController.cs index 7f5c16b0b..222243fdc 100644 --- a/API/Controllers/ServerController.cs +++ b/API/Controllers/ServerController.cs @@ -7,6 +7,7 @@ using API.DTOs.Update; using API.Extensions; using API.Services; using API.Services.Tasks; +using Hangfire; using Kavita.Common; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; diff --git a/API/Controllers/UploadController.cs b/API/Controllers/UploadController.cs index 6c37eac62..225d6be9a 100644 --- a/API/Controllers/UploadController.cs +++ b/API/Controllers/UploadController.cs @@ -1,8 +1,11 @@ using System; +using System.IO; using System.Threading.Tasks; using API.Data; using API.DTOs.Uploads; +using API.Extensions; using API.Services; +using Flurl.Http; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Logging; @@ -19,14 +22,37 @@ namespace API.Controllers private readonly IImageService _imageService; private readonly ILogger _logger; private readonly ITaskScheduler _taskScheduler; + private readonly IDirectoryService _directoryService; /// - public UploadController(IUnitOfWork unitOfWork, IImageService imageService, ILogger logger, ITaskScheduler taskScheduler) + public UploadController(IUnitOfWork unitOfWork, IImageService imageService, ILogger logger, + ITaskScheduler taskScheduler, IDirectoryService directoryService) { _unitOfWork = unitOfWork; _imageService = imageService; _logger = logger; _taskScheduler = taskScheduler; + _directoryService = directoryService; + } + + /// + /// This stores a file (image) in temp directory for use in a cover image replacement flow. + /// This is automatically cleaned up. + /// + /// Escaped url to download from + /// filename + [Authorize(Policy = "RequireAdminRole")] + [HttpPost("upload-by-url")] + public async Task> GetImageFromFile(UploadUrlDto dto) + { + var dateString = $"{DateTime.Now.ToShortDateString()}_{DateTime.Now.ToLongTimeString()}".Replace("/", "_").Replace(":", "_"); + var format = _directoryService.FileSystem.Path.GetExtension(dto.Url).Replace(".", ""); + var path = await dto.Url + .DownloadFileAsync(_directoryService.TempDirectory, $"coverupload_{dateString}.{format}"); + + if (string.IsNullOrEmpty(path) || !_directoryService.FileSystem.File.Exists(path)) return BadRequest($"Could not download file"); + + return $"coverupload_{dateString}.{format}"; } /// diff --git a/API/DTOs/UpdateSeriesDto.cs b/API/DTOs/UpdateSeriesDto.cs index 676178643..8f10373e4 100644 --- a/API/DTOs/UpdateSeriesDto.cs +++ b/API/DTOs/UpdateSeriesDto.cs @@ -8,8 +8,8 @@ public string SortName { get; init; } public bool CoverImageLocked { get; set; } - public bool UnlockName { get; set; } - public bool UnlockSortName { get; set; } - public bool UnlockLocalizedName { get; set; } + public bool NameLocked { get; set; } + public bool SortNameLocked { get; set; } + public bool LocalizedNameLocked { get; set; } } } diff --git a/API/DTOs/Uploads/UploadUrlDto.cs b/API/DTOs/Uploads/UploadUrlDto.cs new file mode 100644 index 000000000..cd44b78a2 --- /dev/null +++ b/API/DTOs/Uploads/UploadUrlDto.cs @@ -0,0 +1,9 @@ +namespace API.DTOs.Uploads; + +public class UploadUrlDto +{ + /// + /// External url + /// + public string Url { get; set; } +} diff --git a/API/Services/ReaderService.cs b/API/Services/ReaderService.cs index 3e6fe06a5..ab78437c2 100644 --- a/API/Services/ReaderService.cs +++ b/API/Services/ReaderService.cs @@ -278,7 +278,7 @@ public class ReaderService : IReaderService { // Handle Chapters within current Volume // In this case, i need 0 first because 0 represents a full volume file. - var chapterId = GetNextChapterId(currentVolume.Chapters.OrderBy(x => double.Parse(x.Number), _chapterSortComparerForInChapterSorting), + var chapterId = GetNextChapterId(currentVolume.Chapters.OrderBy(x => double.Parse(x.Number), _chapterSortComparer), currentChapter.Range, dto => dto.Range); if (chapterId > 0) return chapterId; @@ -291,6 +291,9 @@ public class ReaderService : IReaderService var chapters = volume.Chapters.OrderBy(x => double.Parse(x.Number), _chapterSortComparer).ToList(); if (currentChapter.Number.Equals("0") && chapters.Last().Number.Equals("0")) { + // We need to handle an extra check if the current chapter is the last special, as we should return -1 + if (currentChapter.IsSpecial) return -1; + return chapters.Last().Id; } diff --git a/API/Services/Tasks/BackupService.cs b/API/Services/Tasks/BackupService.cs index d1fc8a0f9..0f1b70f9f 100644 --- a/API/Services/Tasks/BackupService.cs +++ b/API/Services/Tasks/BackupService.cs @@ -91,6 +91,8 @@ public class BackupService : IBackupService if (!_directoryService.ExistOrCreate(backupDirectory)) { _logger.LogCritical("Could not write to {BackupDirectory}; aborting backup", backupDirectory); + await _eventHub.SendMessageAsync(MessageFactory.Error, + MessageFactory.ErrorEvent("Backup Service Error",$"Could not write to {backupDirectory}; aborting backup")); return; } @@ -101,7 +103,9 @@ public class BackupService : IBackupService if (File.Exists(zipPath)) { - _logger.LogInformation("{ZipFile} already exists, aborting", zipPath); + _logger.LogCritical("{ZipFile} already exists, aborting", zipPath); + await _eventHub.SendMessageAsync(MessageFactory.Error, + MessageFactory.ErrorEvent("Backup Service Error",$"{zipPath} already exists, aborting")); return; } diff --git a/API/Services/Tasks/ScannerService.cs b/API/Services/Tasks/ScannerService.cs index afe7ec303..78218293b 100644 --- a/API/Services/Tasks/ScannerService.cs +++ b/API/Services/Tasks/ScannerService.cs @@ -72,9 +72,9 @@ public class ScannerService : IScannerService var folderPaths = library.Folders.Select(f => f.Path).ToList(); - if (!await CheckMounts(library.Folders.Select(f => f.Path).ToList())) + if (!await CheckMounts(library.Name, library.Folders.Select(f => f.Path).ToList())) { - _logger.LogError("Some of the root folders for library are not accessible. Please check that drives are connected and rescan. Scan will be aborted"); + _logger.LogCritical("Some of the root folders for library are not accessible. Please check that drives are connected and rescan. Scan will be aborted"); return; } @@ -190,33 +190,25 @@ public class ScannerService : IScannerService } } - private async Task CheckMounts(IList folders) + private async Task CheckMounts(string libraryName, IList folders) { - // TODO: IF false, inform UI // Check if any of the folder roots are not available (ie disconnected from network, etc) and fail if any of them are if (folders.Any(f => !_directoryService.IsDriveMounted(f))) { - _logger.LogError("Some of the root folders for library are not accessible. Please check that drives are connected and rescan. Scan will be aborted"); - await _eventHub.SendMessageAsync("library.scan.error", new SignalRMessage() - { - Name = "library.scan.error", - Body = - new { - Message = - "Some of the root folders for library are not accessible. Please check that drives are connected and rescan. Scan will be aborted", - Details = "" - }, - Title = "Some of the root folders for library are not accessible. Please check that drives are connected and rescan. Scan will be aborted", - SubTitle = string.Join(", ", folders.Where(f => !_directoryService.IsDriveMounted(f))) - }); + _logger.LogError("Some of the root folders for library ({LibraryName} are not accessible. Please check that drives are connected and rescan. Scan will be aborted", libraryName); + + await _eventHub.SendMessageAsync(MessageFactory.Error, + MessageFactory.ErrorEvent("Some of the root folders for library are not accessible. Please check that drives are connected and rescan. Scan will be aborted", + string.Join(", ", folders.Where(f => !_directoryService.IsDriveMounted(f))))); return false; } + // For Docker instances check if any of the folder roots are not available (ie disconnected volumes, etc) and fail if any of them are if (folders.Any(f => _directoryService.IsDirectoryEmpty(f))) { - // TODO: Food for thought, move this to throw an exception and let a middleware inform the UI to keep the code clean. (We can throw a custom exception which + // NOTE: Food for thought, move this to throw an exception and let a middleware inform the UI to keep the code clean. (We can throw a custom exception which // will always propagate to the UI) // That way logging and UI informing is all in one place with full context _logger.LogError("Some of the root folders for the library are empty. " + @@ -224,23 +216,10 @@ public class ScannerService : IScannerService "Scan will be aborted. " + "Check that your mount is connected or change the library's root folder and rescan"); - // TODO: Use a factory method - await _eventHub.SendMessageAsync(MessageFactory.Error, new SignalRMessage() - { - Name = MessageFactory.Error, - Title = "Some of the root folders for the library are empty.", - SubTitle = "Either your mount has been disconnected or you are trying to delete all series in the library. " + - "Scan will be aborted. " + - "Check that your mount is connected or change the library's root folder and rescan", - Body = - new { - Title = - "Some of the root folders for the library are empty.", - SubTitle = "Either your mount has been disconnected or you are trying to delete all series in the library. " + - "Scan will be aborted. " + - "Check that your mount is connected or change the library's root folder and rescan" - } - }, true); + await _eventHub.SendMessageAsync(MessageFactory.Error, MessageFactory.ErrorEvent( $"Some of the root folders for the library, {libraryName}, are empty.", + "Either your mount has been disconnected or you are trying to delete all series in the library. " + + "Scan will be aborted. " + + "Check that your mount is connected or change the library's root folder and rescan")); return false; } @@ -285,25 +264,12 @@ public class ScannerService : IScannerService return; } - if (!await CheckMounts(library.Folders.Select(f => f.Path).ToList())) + if (!await CheckMounts(library.Name, library.Folders.Select(f => f.Path).ToList())) { _logger.LogCritical("Some of the root folders for library are not accessible. Please check that drives are connected and rescan. Scan will be aborted"); - // await _eventHub.SendMessageAsync(SignalREvents.NotificationProgress, - // MessageFactory.ScanLibraryProgressEvent(libraryId, 1F)); return; } - // For Docker instances check if any of the folder roots are not available (ie disconnected volumes, etc) and fail if any of them are - if (library.Folders.Any(f => _directoryService.IsDirectoryEmpty(f.Path))) - { - _logger.LogCritical("Some of the root folders for the library are empty. " + - "Either your mount has been disconnected or you are trying to delete all series in the library. " + - "Scan will be aborted. " + - "Check that your mount is connected or change the library's root folder and rescan"); - // await _eventHub.SendMessageAsync(SignalREvents.NotificationProgress, - // MessageFactory.ScanLibraryProgressEvent(libraryId, 1F)); - return; - } _logger.LogInformation("[ScannerService] Beginning file scan on {LibraryName}", library.Name); // await _eventHub.SendMessageAsync(SignalREvents.NotificationProgress, @@ -437,13 +403,16 @@ public class ScannerService : IScannerService } catch (Exception ex) { - _logger.LogCritical(ex, "[ScannerService] There was an issue writing to the DB. Chunk {ChunkNumber} did not save to DB. If debug mode, series to check will be printed", chunk); + _logger.LogCritical(ex, "[ScannerService] There was an issue writing to the DB. Chunk {ChunkNumber} did not save to DB", chunk); foreach (var series in nonLibrarySeries) { _logger.LogCritical("[ScannerService] There may be a constraint issue with {SeriesName}", series.OriginalName); } - await _eventHub.SendMessageAsync(MessageFactory.ScanLibraryError, - MessageFactory.ScanLibraryErrorEvent(library.Id, library.Name)); + + await _eventHub.SendMessageAsync(MessageFactory.Error, + MessageFactory.ErrorEvent("There was an issue writing to the DB. Chunk {ChunkNumber} did not save to DB", + "The following series had constraint issues: " + string.Join(",", nonLibrarySeries.Select(s => s.OriginalName)))); + continue; } _logger.LogInformation( diff --git a/API/SignalR/MessageFactory.cs b/API/SignalR/MessageFactory.cs index 76caeb189..f0bc2f2a8 100644 --- a/API/SignalR/MessageFactory.cs +++ b/API/SignalR/MessageFactory.cs @@ -38,10 +38,6 @@ namespace API.SignalR /// public const string SeriesAddedToCollection = "SeriesAddedToCollection"; /// - /// When an error occurs during a scan library task - /// - public const string ScanLibraryError = "ScanLibraryError"; - /// /// Event sent out during backing up the database /// private const string BackupDatabaseProgress = "BackupDatabaseProgress"; @@ -209,18 +205,22 @@ namespace API.SignalR }; } - public static SignalRMessage ScanLibraryErrorEvent(int libraryId, string libraryName) + /** + * A generic error that will show on events widget in the UI + */ + public static SignalRMessage ErrorEvent(string title, string subtitle) { return new SignalRMessage { - Name = ScanLibraryError, - Title = "Error", - SubTitle = $"Error Scanning {libraryName}", + Name = Error, + Title = title, + SubTitle = subtitle, Progress = ProgressType.None, EventType = ProgressEventType.Single, Body = new { - LibraryId = libraryId, + Title = title, + SubTitle = subtitle, } }; } diff --git a/UI/Web/src/app/_models/events/error-event.ts b/UI/Web/src/app/_models/events/error-event.ts new file mode 100644 index 000000000..0271c0568 --- /dev/null +++ b/UI/Web/src/app/_models/events/error-event.ts @@ -0,0 +1,32 @@ +import { EVENTS } from "src/app/_services/message-hub.service"; + +export interface ErrorEvent { + /** + * Payload of the event subtype + */ + body: any; + /** + * Subtype event + */ + name: EVENTS.Error; + /** + * Title to display in events widget + */ + title: string; + /** + * Optional subtitle to display. Defaults to empty string + */ + subTitle: string; + /** + * Type of event. Helps events widget to understand how to handle said event + */ + eventType: 'single'; + /** + * Type of progress. Helps widget understand how to display spinner + */ + progress: 'none'; + /** + * When event was sent + */ + eventTime: string; +} \ No newline at end of file diff --git a/UI/Web/src/app/_services/image.service.ts b/UI/Web/src/app/_services/image.service.ts index aaedd133a..d0abe8c12 100644 --- a/UI/Web/src/app/_services/image.service.ts +++ b/UI/Web/src/app/_services/image.service.ts @@ -83,6 +83,10 @@ export class ImageService implements OnDestroy { return this.baseUrl + 'image/bookmark?chapterId=' + chapterId + '&pageNum=' + pageNum + '&apiKey=' + encodeURIComponent(this.apiKey); } + getCoverUploadImage(filename: string) { + return this.baseUrl + 'image/cover-upload?filename=' + encodeURIComponent(filename); + } + updateErroredImage(event: any) { event.target.src = this.placeholderImage; } diff --git a/UI/Web/src/app/_services/message-hub.service.ts b/UI/Web/src/app/_services/message-hub.service.ts index 141294dad..bf79902f5 100644 --- a/UI/Web/src/app/_services/message-hub.service.ts +++ b/UI/Web/src/app/_services/message-hub.service.ts @@ -16,7 +16,10 @@ export enum EVENTS { ScanLibraryProgress = 'ScanLibraryProgress', OnlineUsers = 'OnlineUsers', SeriesAddedToCollection = 'SeriesAddedToCollection', - ScanLibraryError = 'ScanLibraryError', + /** + * A generic error that occurs during operations on the server + */ + Error = 'Error', BackupDatabaseProgress = 'BackupDatabaseProgress', /** * A subtype of NotificationProgress that represents maintenance cleanup on server-owned resources @@ -149,15 +152,11 @@ export class MessageHubService { }); }); - this.hubConnection.on(EVENTS.ScanLibraryError, resp => { + this.hubConnection.on(EVENTS.Error, resp => { this.messagesSource.next({ - event: EVENTS.ScanLibraryError, + event: EVENTS.Error, payload: resp.body }); - if (this.isAdmin) { - // TODO: Just show the error, RBS is done in eventhub - this.toastr.error('Library Scan had a critical error. Some series were not saved. Check logs'); - } }); this.hubConnection.on(EVENTS.SeriesAdded, resp => { diff --git a/UI/Web/src/app/_services/upload.service.ts b/UI/Web/src/app/_services/upload.service.ts index 811a35ac7..7d930e8e6 100644 --- a/UI/Web/src/app/_services/upload.service.ts +++ b/UI/Web/src/app/_services/upload.service.ts @@ -12,6 +12,10 @@ export class UploadService { constructor(private httpClient: HttpClient) { } + uploadByUrl(url: string) { + return this.httpClient.post(this.baseUrl + 'upload/upload-by-url', {url}, {responseType: 'text' as 'json'}); + } + /** * * @param seriesId Series to overwrite cover image for diff --git a/UI/Web/src/app/app.module.ts b/UI/Web/src/app/app.module.ts index 7fe0c2554..f3c974f9c 100644 --- a/UI/Web/src/app/app.module.ts +++ b/UI/Web/src/app/app.module.ts @@ -27,7 +27,7 @@ import { CardsModule } from './cards/cards.module'; import { CollectionsModule } from './collections/collections.module'; import { ReadingListModule } from './reading-list/reading-list.module'; import { SAVER, getSaver } from './shared/_providers/saver.provider'; -import { NavEventsToggleComponent } from './nav-events-toggle/nav-events-toggle.component'; +import { EventsWidgetComponent } from './events-widget/events-widget.component'; import { SeriesMetadataDetailComponent } from './series-metadata-detail/series-metadata-detail.component'; import { AllSeriesComponent } from './all-series/all-series.component'; import { RegistrationModule } from './registration/registration.module'; @@ -48,7 +48,7 @@ import { ColorPickerModule } from 'ngx-color-picker'; ReviewSeriesModalComponent, RecentlyAddedComponent, DashboardComponent, - NavEventsToggleComponent, + EventsWidgetComponent, SeriesMetadataDetailComponent, AllSeriesComponent, GroupedTypeaheadComponent, diff --git a/UI/Web/src/app/cards/_modals/edit-series-modal/edit-series-modal.component.ts b/UI/Web/src/app/cards/_modals/edit-series-modal/edit-series-modal.component.ts index 787b5e7d3..2f319a803 100644 --- a/UI/Web/src/app/cards/_modals/edit-series-modal/edit-series-modal.component.ts +++ b/UI/Web/src/app/cards/_modals/edit-series-modal/edit-series-modal.component.ts @@ -32,6 +32,10 @@ export class EditSeriesModalComponent implements OnInit, OnDestroy { @Input() 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; isCollapsed = true; volumeCollapsed: any = {}; @@ -94,6 +98,8 @@ export class EditSeriesModalComponent implements OnInit, OnDestroy { ngOnInit(): void { this.imageUrls.push(this.imageService.getSeriesCoverImage(this.series.id)); + this.initSeries = Object.assign({}, this.series); + this.libraryService.getLibraryNames().pipe(takeUntil(this.onDestroy)).subscribe(names => { this.libraryName = names[this.series.libraryId]; }); @@ -133,28 +139,24 @@ export class EditSeriesModalComponent implements OnInit, OnDestroy { this.metadata = metadata; this.setupTypeaheads(); - this.editSeriesForm.get('summary')?.setValue(this.metadata.summary); - this.editSeriesForm.get('ageRating')?.setValue(this.metadata.ageRating); - this.editSeriesForm.get('publicationStatus')?.setValue(this.metadata.publicationStatus); - this.editSeriesForm.get('language')?.setValue(this.metadata.language); + 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('name')?.valueChanges.pipe(takeUntil(this.onDestroy)).subscribe(val => { - if (!this.editSeriesForm.get('name')?.touched) return; this.series.nameLocked = true; }); this.editSeriesForm.get('sortName')?.valueChanges.pipe(takeUntil(this.onDestroy)).subscribe(val => { - if (!this.editSeriesForm.get('sortName')?.touched) return; this.series.sortNameLocked = true; }); this.editSeriesForm.get('localizedName')?.valueChanges.pipe(takeUntil(this.onDestroy)).subscribe(val => { - if (!this.editSeriesForm.get('localizedName')?.touched) return; this.series.localizedNameLocked = true; }); this.editSeriesForm.get('summary')?.valueChanges.pipe(takeUntil(this.onDestroy)).subscribe(val => { - if (!this.editSeriesForm.get('summary')?.touched) return; this.metadata.summaryLocked = true; this.metadata.summary = val; }); @@ -203,7 +205,6 @@ export class EditSeriesModalComponent implements OnInit, OnDestroy { this.setupLanguageTypeahead() ]).subscribe(results => { this.collectionTags = this.metadata.collectionTags; - this.editSeriesForm.get('summary')?.setValue(this.metadata.summary); }); } @@ -345,7 +346,6 @@ export class EditSeriesModalComponent implements OnInit, OnDestroy { this.updateFromPreset('publisher', this.metadata.publishers, PersonRole.Publisher), this.updateFromPreset('translator', this.metadata.translators, PersonRole.Translator) ]).pipe(map(results => { - //this.resetTypeaheads.next(true); return of(true); })); } @@ -406,7 +406,12 @@ export class EditSeriesModalComponent implements OnInit, OnDestroy { ]; // We only need to call updateSeries if we changed name, sort name, or localized name or reset a cover image - if (this.editSeriesForm.get('name')?.dirty || this.editSeriesForm.get('sortName')?.dirty || this.editSeriesForm.get('localizedName')?.dirty || this.coverImageReset) { + 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; apis.push(this.seriesService.updateSeries(model)); } diff --git a/UI/Web/src/app/cards/cover-image-chooser/cover-image-chooser.component.ts b/UI/Web/src/app/cards/cover-image-chooser/cover-image-chooser.component.ts index c6e3d895c..3d49894c2 100644 --- a/UI/Web/src/app/cards/cover-image-chooser/cover-image-chooser.component.ts +++ b/UI/Web/src/app/cards/cover-image-chooser/cover-image-chooser.component.ts @@ -6,6 +6,7 @@ import { takeWhile } from 'rxjs/operators'; import { ToastrService } from 'ngx-toastr'; import { ImageService } from 'src/app/_services/image.service'; import { KEY_CODES } from 'src/app/shared/_services/utility.service'; +import { UploadService } from 'src/app/_services/upload.service'; @Component({ selector: 'app-cover-image-chooser', @@ -41,7 +42,7 @@ export class CoverImageChooserComponent implements OnInit, OnDestroy { mode: 'file' | 'url' | 'all' = 'all'; private readonly onDestroy = new Subject(); - constructor(public imageService: ImageService, private fb: FormBuilder, private toastr: ToastrService) { } + constructor(public imageService: ImageService, private fb: FormBuilder, private toastr: ToastrService, private uploadService: UploadService) { } ngOnInit(): void { this.form = this.fb.group({ @@ -72,49 +73,31 @@ export class CoverImageChooserComponent implements OnInit, OnDestroy { if (this.selectedIndex === index) { return; } this.selectedIndex = index; this.imageSelected.emit(this.selectedIndex); - const selector = `.chooser img[src="${this.imageUrls[this.selectedIndex]}"]`; - - - const elem = document.querySelector(selector) || document.querySelectorAll('.chooser img.card-img-top')[this.selectedIndex]; - if (elem) { - const imageElem = elem; - if (imageElem.src.startsWith('data')) { - this.selectedBase64Url.emit(imageElem.src); - return; - } - const image = this.getBase64Image(imageElem); - if (image != '') { - this.selectedBase64Url.emit(image); - } - } + this.selectedBase64Url.emit(this.imageUrls[this.selectedIndex]); } loadImage() { const url = this.form.get('coverImageUrl')?.value.trim(); if (url && url != '') { - const img = new Image(); - img.crossOrigin = 'Anonymous'; - img.onload = (e) => this.handleUrlImageAdd(e); - img.onerror = (e) => { - this.toastr.error('The image could not be fetched due to server refusing request. Please download and upload from file instead.'); - this.form.get('coverImageUrl')?.setValue(''); - }; - img.src = this.form.get('coverImageUrl')?.value; - this.form.get('coverImageUrl')?.setValue(''); + + this.uploadService.uploadByUrl(url).subscribe(filename => { + const img = new Image(); + img.crossOrigin = 'Anonymous'; + img.src = this.imageService.getCoverUploadImage(filename); + img.onload = (e) => this.handleUrlImageAdd(e); + img.onerror = (e) => { + this.toastr.error('The image could not be fetched due to server refusing request. Please download and upload from file instead.'); + this.form.get('coverImageUrl')?.setValue(''); + }; + this.form.get('coverImageUrl')?.setValue(''); + }); } } changeMode(mode: 'url') { this.mode = mode; this.setupEnterHandler(); - setTimeout(() => { - - }) } - - - - public dropped(files: NgxFileDropEntry[]) { this.files = files; @@ -151,7 +134,7 @@ export class CoverImageChooserComponent implements OnInit, OnDestroy { setTimeout(() => { // Auto select newly uploaded image and tell parent of new base64 url - this.selectImage(this.selectedIndex + 1) + this.selectImage(this.selectedIndex + 1); }); } diff --git a/UI/Web/src/app/nav-events-toggle/nav-events-toggle.component.html b/UI/Web/src/app/events-widget/events-widget.component.html similarity index 79% rename from UI/Web/src/app/nav-events-toggle/nav-events-toggle.component.html rename to UI/Web/src/app/events-widget/events-widget.component.html index 1f23e5c00..119b5c32f 100644 --- a/UI/Web/src/app/nav-events-toggle/nav-events-toggle.component.html +++ b/UI/Web/src/app/events-widget/events-widget.component.html @@ -1,7 +1,7 @@ @@ -10,7 +10,6 @@
  • -
    Title goes here
    Subtitle goes here
    @@ -34,8 +33,14 @@
    - - + +
  • +
  • +
    +
    There was some library scan error
    +
    Click for more information
    +
    +
  • @@ -78,12 +83,26 @@
    + + + + + + +
  • {{onlineUsers.length}} Users online
  • Not much going on here
  • +
  • Active Events: {{activeEvents}}
  • diff --git a/UI/Web/src/app/nav-events-toggle/nav-events-toggle.component.scss b/UI/Web/src/app/events-widget/events-widget.component.scss similarity index 82% rename from UI/Web/src/app/nav-events-toggle/nav-events-toggle.component.scss rename to UI/Web/src/app/events-widget/events-widget.component.scss index 5ed59af74..de94c5393 100644 --- a/UI/Web/src/app/nav-events-toggle/nav-events-toggle.component.scss +++ b/UI/Web/src/app/events-widget/events-widget.component.scss @@ -73,4 +73,23 @@ color: var(--primary-color) !important; } color: var(--primary-color); +} + +.error { + cursor: pointer; + position: relative; + .h6 { + color: var(--error-color); + } + + i.fa { + color: var(--primary-color) !important; + } + + .btn-close { + top: 5px; + right: 10px; + font-size: 11px; + position: absolute; + } } \ No newline at end of file diff --git a/UI/Web/src/app/nav-events-toggle/nav-events-toggle.component.ts b/UI/Web/src/app/events-widget/events-widget.component.ts similarity index 68% rename from UI/Web/src/app/nav-events-toggle/nav-events-toggle.component.ts rename to UI/Web/src/app/events-widget/events-widget.component.ts index d41dc5d29..c350de5c6 100644 --- a/UI/Web/src/app/nav-events-toggle/nav-events-toggle.component.ts +++ b/UI/Web/src/app/events-widget/events-widget.component.ts @@ -1,4 +1,4 @@ -import { Component, Input, OnDestroy, OnInit, ViewChild } from '@angular/core'; +import { Component, Input, OnDestroy, OnInit } from '@angular/core'; import { NgbModal, NgbModalRef } from '@ng-bootstrap/ng-bootstrap'; import { BehaviorSubject, Subject } from 'rxjs'; import { takeUntil } from 'rxjs/operators'; @@ -8,17 +8,17 @@ import { UpdateVersionEvent } from '../_models/events/update-version-event'; import { User } from '../_models/user'; import { AccountService } from '../_services/account.service'; import { EVENTS, Message, MessageHubService } from '../_services/message-hub.service'; +import { ErrorEvent } from '../_models/events/error-event'; +import { ConfirmService } from '../shared/confirm.service'; +import { ConfirmConfig } from '../shared/confirm-dialog/_models/confirm-config'; +import { ServerService } from '../_services/server.service'; - - - -// TODO: Rename this to events widget @Component({ selector: 'app-nav-events-toggle', - templateUrl: './nav-events-toggle.component.html', - styleUrls: ['./nav-events-toggle.component.scss'] + templateUrl: './events-widget.component.html', + styleUrls: ['./events-widget.component.scss'] }) -export class NavEventsToggleComponent implements OnInit, OnDestroy { +export class EventsWidgetComponent implements OnInit, OnDestroy { @Input() user!: User; isAdmin: boolean = false; @@ -34,6 +34,9 @@ export class NavEventsToggleComponent implements OnInit, OnDestroy { singleUpdateSource = new BehaviorSubject([]); singleUpdates$ = this.singleUpdateSource.asObservable(); + errorSource = new BehaviorSubject([]); + errors$ = this.errorSource.asObservable(); + private updateNotificationModalRef: NgbModalRef | null = null; activeEvents: number = 0; @@ -45,22 +48,27 @@ export class NavEventsToggleComponent implements OnInit, OnDestroy { return EVENTS; } - constructor(public messageHub: MessageHubService, private modalService: NgbModal, private accountService: AccountService) { } + constructor(public messageHub: MessageHubService, private modalService: NgbModal, + private accountService: AccountService, private confirmService: ConfirmService) { } ngOnDestroy(): void { this.onDestroy.next(); this.onDestroy.complete(); this.progressEventsSource.complete(); this.singleUpdateSource.complete(); + this.errorSource.complete(); } ngOnInit(): void { // Debounce for testing. Kavita's too fast this.messageHub.messages$.pipe(takeUntil(this.onDestroy)).subscribe(event => { - if (event.event.endsWith('error')) { - // TODO: Show an error handle - } else if (event.event === EVENTS.NotificationProgress) { + if (event.event === EVENTS.NotificationProgress) { this.processNotificationProgressEvent(event); + } else if (event.event === EVENTS.Error) { + const values = this.errorSource.getValue(); + values.push(event.payload as ErrorEvent); + this.errorSource.next(values); + this.activeEvents += 1; } }); this.accountService.currentUser$.pipe(takeUntil(this.onDestroy)).subscribe(user => { @@ -94,6 +102,7 @@ export class NavEventsToggleComponent implements OnInit, OnDestroy { const index = data.findIndex(m => m.name === message.name); if (index < 0) { data.push(message); + this.activeEvents += 1; } else { data[index] = message; } @@ -103,7 +112,7 @@ export class NavEventsToggleComponent implements OnInit, OnDestroy { data = this.progressEventsSource.getValue(); data = data.filter(m => m.name !== message.name); // This does not work // && m.title !== message.title this.progressEventsSource.next(data); - this.activeEvents = Math.max(this.activeEvents - 1, 0); + this.activeEvents = Math.max(this.activeEvents - 1, 0); break; default: break; @@ -123,6 +132,31 @@ export class NavEventsToggleComponent implements OnInit, OnDestroy { }); } + async seeMoreError(error: ErrorEvent) { + const config = new ConfirmConfig(); + config.buttons = [ + {text: 'Dismiss', type: 'primary'}, + {text: 'Ok', type: 'secondary'}, + ]; + config.header = error.title; + config.content = error.subTitle; + var result = await this.confirmService.alert(error.subTitle || error.title, config); + if (result) { + this.removeError(error); + } + } + + removeError(error: ErrorEvent, event?: MouseEvent) { + if (event) { + event.stopPropagation(); + event.preventDefault(); + } + let data = this.errorSource.getValue(); + data = data.filter(m => m !== error); + this.errorSource.next(data); + this.activeEvents = Math.max(this.activeEvents - 1, 0); + } + prettyPrintProgress(progress: number) { return Math.trunc(progress * 100); } diff --git a/UI/Web/src/app/series-detail/series-detail.component.ts b/UI/Web/src/app/series-detail/series-detail.component.ts index e59087f63..0b729d6f7 100644 --- a/UI/Web/src/app/series-detail/series-detail.component.ts +++ b/UI/Web/src/app/series-detail/series-detail.component.ts @@ -532,6 +532,10 @@ export class SeriesDetailComponent implements OnInit, OnDestroy { modalRef.closed.subscribe((closeResult: {success: boolean, series: Series, coverImageUpdate: boolean}) => { window.scrollTo(0, 0); if (closeResult.success) { + this.seriesService.getSeries(this.seriesId).subscribe(s => { + this.series = s; + }); + this.loadSeries(this.seriesId); if (closeResult.coverImageUpdate) { // Random triggers a load change without any problems with API diff --git a/UI/Web/src/app/shared/confirm-dialog/_models/confirm-button.ts b/UI/Web/src/app/shared/confirm-dialog/_models/confirm-button.ts index c747aeac3..a54ace910 100644 --- a/UI/Web/src/app/shared/confirm-dialog/_models/confirm-button.ts +++ b/UI/Web/src/app/shared/confirm-dialog/_models/confirm-button.ts @@ -1,4 +1,7 @@ export interface ConfirmButton { text: string; + /** + * Type for css class. ie) primary, secondary + */ type: string; } \ No newline at end of file