diff --git a/API.Tests/Helpers/GenreHelperTests.cs b/API.Tests/Helpers/GenreHelperTests.cs index 0d0aebe0c..94602ff01 100644 --- a/API.Tests/Helpers/GenreHelperTests.cs +++ b/API.Tests/Helpers/GenreHelperTests.cs @@ -107,4 +107,25 @@ public class GenreHelperTests Assert.Equal(1, genreRemoved.Count); } + + [Fact] + public void RemoveEveryoneIfNothingInRemoveAllExcept() + { + var existingGenres = new List + { + DbFactory.Genre("Action", false), + DbFactory.Genre("Sci-fi", false), + }; + + var peopleFromChapters = new List(); + + var genreRemoved = new List(); + GenreHelper.KeepOnlySameGenreBetweenLists(existingGenres, + peopleFromChapters, genre => + { + genreRemoved.Add(genre); + }); + + Assert.Equal(2, genreRemoved.Count); + } } diff --git a/API.Tests/Helpers/PersonHelperTests.cs b/API.Tests/Helpers/PersonHelperTests.cs index f5b5551ae..d5dafd963 100644 --- a/API.Tests/Helpers/PersonHelperTests.cs +++ b/API.Tests/Helpers/PersonHelperTests.cs @@ -1,4 +1,5 @@ -using System.Collections.Generic; +using System; +using System.Collections.Generic; using API.Data; using API.Entities; using API.Entities.Enums; @@ -92,6 +93,25 @@ public class PersonHelperTests Assert.Equal(2, peopleRemoved.Count); } + [Fact] + public void RemovePeople_ShouldRemovePeopleOfSameRole_WhenNothingPassed() + { + var existingPeople = new List + { + DbFactory.Person("Joe Shmo", PersonRole.Writer), + DbFactory.Person("Joe Shmo", PersonRole.Writer), + DbFactory.Person("Joe Shmo", PersonRole.CoverArtist) + }; + var peopleRemoved = new List(); + PersonHelper.RemovePeople(existingPeople, Array.Empty(), PersonRole.Writer, person => + { + peopleRemoved.Add(person); + }); + + Assert.NotEqual(existingPeople, peopleRemoved); + Assert.Equal(2, peopleRemoved.Count); + } + [Fact] public void KeepOnlySamePeopleBetweenLists() { @@ -137,4 +157,5 @@ public class PersonHelperTests PersonHelper.AddPersonIfNotExists(existingPeople, DbFactory.Person("Joe Shmo Two", PersonRole.CoverArtist)); Assert.Equal(4, existingPeople.Count); } + } diff --git a/API.Tests/Helpers/TagHelperTests.cs b/API.Tests/Helpers/TagHelperTests.cs index 5370d9971..80cebc03b 100644 --- a/API.Tests/Helpers/TagHelperTests.cs +++ b/API.Tests/Helpers/TagHelperTests.cs @@ -116,4 +116,25 @@ public class TagHelperTests Assert.Equal(1, tagRemoved.Count); } + + [Fact] + public void RemoveEveryoneIfNothingInRemoveAllExcept() + { + var existingTags = new List + { + DbFactory.Tag("Action", false), + DbFactory.Tag("Sci-fi", false), + }; + + var peopleFromChapters = new List(); + + var tagRemoved = new List(); + TagHelper.KeepOnlySameTagBetweenLists(existingTags, + peopleFromChapters, tag => + { + tagRemoved.Add(tag); + }); + + Assert.Equal(2, tagRemoved.Count); + } } diff --git a/API.Tests/Services/BookmarkServiceTests.cs b/API.Tests/Services/BookmarkServiceTests.cs index f04c8e676..0083a047d 100644 --- a/API.Tests/Services/BookmarkServiceTests.cs +++ b/API.Tests/Services/BookmarkServiceTests.cs @@ -10,6 +10,7 @@ using API.Data.Repositories; using API.DTOs.Reader; using API.Entities; using API.Entities.Enums; +using API.Helpers; using API.Services; using API.SignalR; using AutoMapper; @@ -44,7 +45,9 @@ public class BookmarkServiceTests _context = new DataContext(contextOptions); Task.Run(SeedDb).GetAwaiter().GetResult(); - _unitOfWork = new UnitOfWork(_context, Substitute.For(), null); + var config = new MapperConfiguration(cfg => cfg.AddProfile()); + var mapper = config.CreateMapper(); + _unitOfWork = new UnitOfWork(_context, mapper, null); } private BookmarkService Create(IDirectoryService ds) diff --git a/API.Tests/Services/SeriesServiceTests.cs b/API.Tests/Services/SeriesServiceTests.cs index 00b6c7ffd..82ebd2197 100644 --- a/API.Tests/Services/SeriesServiceTests.cs +++ b/API.Tests/Services/SeriesServiceTests.cs @@ -830,6 +830,44 @@ public class SeriesServiceTests Assert.True(series.Metadata.PublisherLocked); } + + [Fact] + public async Task UpdateSeriesMetadata_ShouldRemoveExistingPerson() + { + await ResetDb(); + var s = new Series() + { + Name = "Test", + Library = new Library() + { + Name = "Test LIb", + Type = LibraryType.Book, + }, + Metadata = DbFactory.SeriesMetadata(new List()) + }; + var g = DbFactory.Person("Existing Person", PersonRole.Publisher); + _context.Series.Add(s); + + _context.Person.Add(g); + await _context.SaveChangesAsync(); + + var success = await _seriesService.UpdateSeriesMetadata(new UpdateSeriesMetadataDto() + { + SeriesMetadata = new SeriesMetadataDto() + { + SeriesId = 1, + Publishers = new List() {}, + }, + CollectionTags = new List() + }); + + Assert.True(success); + + var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(1); + Assert.NotNull(series.Metadata); + Assert.False(series.Metadata.People.Any()); + } + [Fact] public async Task UpdateSeriesMetadata_ShouldLockIfTold() { diff --git a/API/Controllers/AccountController.cs b/API/Controllers/AccountController.cs index 81fe6b7cd..8e1853660 100644 --- a/API/Controllers/AccountController.cs +++ b/API/Controllers/AccountController.cs @@ -143,7 +143,10 @@ namespace API.Controllers catch (Exception ex) { _logger.LogError(ex, "Something went wrong when registering user"); - await _unitOfWork.RollbackAsync(); + // We need to manually delete the User as we've already committed + var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(registerDto.Username); + _unitOfWork.UserRepository.Delete(user); + await _unitOfWork.CommitAsync(); } return BadRequest("Something went wrong when registering user"); @@ -175,7 +178,7 @@ namespace API.Controllers if (!validPassword) { - return Unauthorized("Your credentials are not correct"); + return Unauthorized("Your credentials are not correct"); // TODO: Refactor backend to send back the string for i8ln } var result = await _signInManager diff --git a/API/Controllers/ReaderController.cs b/API/Controllers/ReaderController.cs index 6cf02ffec..e9ac156a2 100644 --- a/API/Controllers/ReaderController.cs +++ b/API/Controllers/ReaderController.cs @@ -110,6 +110,8 @@ namespace API.Controllers { if (page < 0) page = 0; var userId = await _unitOfWork.UserRepository.GetUserIdByApiKeyAsync(apiKey); + + // NOTE: I'm not sure why I need this flow here var totalPages = await _cacheService.CacheBookmarkForSeries(userId, seriesId); if (page > totalPages) { diff --git a/API/DTOs/CollectionTags/CollectionTagDto.cs b/API/DTOs/CollectionTags/CollectionTagDto.cs index 8612f19e0..4ada27a84 100644 --- a/API/DTOs/CollectionTags/CollectionTagDto.cs +++ b/API/DTOs/CollectionTags/CollectionTagDto.cs @@ -6,6 +6,7 @@ public string Title { get; set; } public string Summary { get; set; } public bool Promoted { get; set; } + public string CoverImage { get; set; } public bool CoverImageLocked { get; set; } } } diff --git a/API/DTOs/Settings/ServerSettingDTO.cs b/API/DTOs/Settings/ServerSettingDTO.cs index 8de3a692f..9f33b6908 100644 --- a/API/DTOs/Settings/ServerSettingDTO.cs +++ b/API/DTOs/Settings/ServerSettingDTO.cs @@ -39,6 +39,10 @@ namespace API.DTOs.Settings /// If null or empty string, will default back to default install setting aka public string EmailServiceUrl { get; set; } public string InstallVersion { get; set; } + /// + /// Represents a unique Id to this Kavita installation. Only used in Stats to identify unique installs. + /// + public string InstallId { get; set; } public bool ConvertBookmarkToWebP { get; set; } /// diff --git a/API/Data/Repositories/SeriesRepository.cs b/API/Data/Repositories/SeriesRepository.cs index e6f9bbd85..4d6c60234 100644 --- a/API/Data/Repositories/SeriesRepository.cs +++ b/API/Data/Repositories/SeriesRepository.cs @@ -1021,6 +1021,13 @@ public class SeriesRepository : ISeriesRepository return await PagedList.CreateAsync(query, userParams.PageNumber, userParams.PageSize); } + /// + /// Returns a list of Series that the user Has fully read + /// + /// + /// + /// + /// public async Task> GetRediscover(int userId, int libraryId, UserParams userParams) { var libraryIds = GetLibraryIdsForUser(userId, libraryId); diff --git a/API/Helpers/Converters/ServerSettingConverter.cs b/API/Helpers/Converters/ServerSettingConverter.cs index 862b9a10c..35c2428f3 100644 --- a/API/Helpers/Converters/ServerSettingConverter.cs +++ b/API/Helpers/Converters/ServerSettingConverter.cs @@ -57,6 +57,9 @@ namespace API.Helpers.Converters case ServerSettingKey.TotalBackups: destination.TotalBackups = int.Parse(row.Value); break; + case ServerSettingKey.InstallId: + destination.InstallId = row.Value; + break; } } diff --git a/API/Helpers/PersonHelper.cs b/API/Helpers/PersonHelper.cs index 92551b200..18dbe1f2e 100644 --- a/API/Helpers/PersonHelper.cs +++ b/API/Helpers/PersonHelper.cs @@ -13,6 +13,7 @@ public static class PersonHelper /// Given a list of all existing people, this will check the new names and roles and if it doesn't exist in allPeople, will create and /// add an entry. For each person in name, the callback will be executed. /// + /// This does not remove people if an empty list is passed into names /// This is used to add new people to a list without worrying about duplicating rows in the DB /// /// @@ -48,6 +49,17 @@ public static class PersonHelper public static void RemovePeople(ICollection existingPeople, IEnumerable people, PersonRole role, Action action = null) { var normalizedPeople = people.Select(Parser.Parser.Normalize).ToList(); + if (normalizedPeople.Count == 0) + { + var peopleToRemove = existingPeople.Where(p => p.Role == role).ToList(); + foreach (var existingRoleToRemove in peopleToRemove) + { + existingPeople.Remove(existingRoleToRemove); + action?.Invoke(existingRoleToRemove); + } + return; + } + foreach (var person in normalizedPeople) { var existingPerson = existingPeople.FirstOrDefault(p => p.Role == role && person.Equals(p.NormalizedName)); diff --git a/API/Services/BookService.cs b/API/Services/BookService.cs index 5ddf65be2..26c41edbb 100644 --- a/API/Services/BookService.cs +++ b/API/Services/BookService.cs @@ -121,7 +121,7 @@ namespace API.Services return contentType; } - public static void UpdateLinks(HtmlNode anchor, Dictionary mappings, int currentPage) + private static void UpdateLinks(HtmlNode anchor, Dictionary mappings, int currentPage) { if (anchor.Name != "a") return; var hrefParts = CleanContentKeys(anchor.GetAttributeValue("href", string.Empty)) @@ -278,7 +278,8 @@ namespace API.Services var imageFile = GetKeyForImage(book, image.Attributes[key].Value); image.Attributes.Remove(key); - image.Attributes.Add(key, $"{apiBase}" + imageFile); + // UrlEncode here to transform ../ into an escaped version, which avoids blocking on nginx + image.Attributes.Add(key, $"{apiBase}" + HttpUtility.UrlEncode(imageFile)); // Add a custom class that the reader uses to ensure images stay within reader parent.AddClass("kavita-scale-width-container"); diff --git a/API/Services/BookmarkService.cs b/API/Services/BookmarkService.cs index 3dad57ada..6b07efd00 100644 --- a/API/Services/BookmarkService.cs +++ b/API/Services/BookmarkService.cs @@ -89,11 +89,10 @@ public class BookmarkService : IBookmarkService return false; } - var fileInfo = new FileInfo(imageToBookmark); - var bookmarkDirectory = - (await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.BookmarkDirectory)).Value; + var fileInfo = _directoryService.FileSystem.FileInfo.FromFileName(imageToBookmark); + var settings = await _unitOfWork.SettingsRepository.GetSettingsDtoAsync(); var targetFolderStem = BookmarkStem(userWithBookmarks.Id, bookmarkDto.SeriesId, bookmarkDto.ChapterId); - var targetFilepath = Path.Join(bookmarkDirectory, targetFolderStem); + var targetFilepath = Path.Join(settings.BookmarksDirectory, targetFolderStem); var bookmark = new AppUserBookmark() { @@ -111,8 +110,7 @@ public class BookmarkService : IBookmarkService _unitOfWork.UserRepository.Update(userWithBookmarks); await _unitOfWork.CommitAsync(); - var convertToWebP = bool.Parse((await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.ConvertBookmarkToWebP)).Value); - if (convertToWebP) + if (settings.ConvertBookmarkToWebP) { // Enqueue a task to convert the bookmark to webP BackgroundJob.Enqueue(() => ConvertBookmarkToWebP(bookmark.Id)); diff --git a/API/Services/MetadataService.cs b/API/Services/MetadataService.cs index 6e943fc01..fa3853201 100644 --- a/API/Services/MetadataService.cs +++ b/API/Services/MetadataService.cs @@ -48,7 +48,7 @@ public class MetadataService : IMetadataService private readonly IReadingItemService _readingItemService; private readonly IDirectoryService _directoryService; private readonly ChapterSortComparerZeroFirst _chapterSortComparerForInChapterSorting = new ChapterSortComparerZeroFirst(); - private IList _updateEvents = new List(); + private readonly IList _updateEvents = new List(); public MetadataService(IUnitOfWork unitOfWork, ILogger logger, IEventHub eventHub, ICacheHelper cacheHelper, IReadingItemService readingItemService, IDirectoryService directoryService) diff --git a/API/Services/Tasks/ScannerService.cs b/API/Services/Tasks/ScannerService.cs index abcb8e6b9..d04290b11 100644 --- a/API/Services/Tasks/ScannerService.cs +++ b/API/Services/Tasks/ScannerService.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Collections.Immutable; using System.Diagnostics; using System.IO; using System.Linq; @@ -983,9 +984,6 @@ public class ScannerService : IScannerService } - - - if (comicInfo.Year > 0) { var day = Math.Max(comicInfo.Day, 1); @@ -993,104 +991,80 @@ public class ScannerService : IScannerService chapter.ReleaseDate = DateTime.Parse($"{month}/{day}/{comicInfo.Year}"); } - if (!string.IsNullOrEmpty(comicInfo.Colorist)) - { - var people = comicInfo.Colorist.Split(","); - PersonHelper.RemovePeople(chapter.People, people, PersonRole.Colorist); - PersonHelper.UpdatePeople(allPeople, people, PersonRole.Colorist, - person => PersonHelper.AddPersonIfNotExists(chapter.People, person)); - } + var people = GetTagValues(comicInfo.Colorist); + PersonHelper.RemovePeople(chapter.People, people, PersonRole.Colorist); + PersonHelper.UpdatePeople(allPeople, people, PersonRole.Colorist, + person => PersonHelper.AddPersonIfNotExists(chapter.People, person)); - if (!string.IsNullOrEmpty(comicInfo.Characters)) - { - var people = comicInfo.Characters.Split(","); - PersonHelper.RemovePeople(chapter.People, people, PersonRole.Character); - PersonHelper.UpdatePeople(allPeople, people, PersonRole.Character, - person => PersonHelper.AddPersonIfNotExists(chapter.People, person)); - } + people = GetTagValues(comicInfo.Characters); + PersonHelper.RemovePeople(chapter.People, people, PersonRole.Character); + PersonHelper.UpdatePeople(allPeople, people, PersonRole.Character, + person => PersonHelper.AddPersonIfNotExists(chapter.People, person)); - if (!string.IsNullOrEmpty(comicInfo.Translator)) - { - var people = comicInfo.Translator.Split(","); - PersonHelper.RemovePeople(chapter.People, people, PersonRole.Translator); - PersonHelper.UpdatePeople(allPeople, people, PersonRole.Translator, - person => PersonHelper.AddPersonIfNotExists(chapter.People, person)); - } - if (!string.IsNullOrEmpty(comicInfo.Tags)) - { - var tags = comicInfo.Tags.Split(",").Select(s => s.Trim()).ToList(); - // Remove all tags that aren't matching between chapter tags and metadata - TagHelper.KeepOnlySameTagBetweenLists(chapter.Tags, tags.Select(t => DbFactory.Tag(t, false)).ToList()); - TagHelper.UpdateTag(allTags, tags, false, - (tag, _) => - { - chapter.Tags.Add(tag); - }); - } + people = GetTagValues(comicInfo.Translator); + PersonHelper.RemovePeople(chapter.People, people, PersonRole.Translator); + PersonHelper.UpdatePeople(allPeople, people, PersonRole.Translator, + person => PersonHelper.AddPersonIfNotExists(chapter.People, person)); - if (!string.IsNullOrEmpty(comicInfo.Writer)) - { - var people = comicInfo.Writer.Split(","); - PersonHelper.RemovePeople(chapter.People, people, PersonRole.Writer); - PersonHelper.UpdatePeople(allPeople, people, PersonRole.Writer, - person => PersonHelper.AddPersonIfNotExists(chapter.People, person)); - } - if (!string.IsNullOrEmpty(comicInfo.Editor)) - { - var people = comicInfo.Editor.Split(","); - PersonHelper.RemovePeople(chapter.People, people, PersonRole.Editor); - PersonHelper.UpdatePeople(allPeople, people, PersonRole.Editor, - person => PersonHelper.AddPersonIfNotExists(chapter.People, person)); - } + people = GetTagValues(comicInfo.Writer); + PersonHelper.RemovePeople(chapter.People, people, PersonRole.Writer); + PersonHelper.UpdatePeople(allPeople, people, PersonRole.Writer, + person => PersonHelper.AddPersonIfNotExists(chapter.People, person)); - if (!string.IsNullOrEmpty(comicInfo.Inker)) - { - var people = comicInfo.Inker.Split(","); - PersonHelper.RemovePeople(chapter.People, people, PersonRole.Inker); - PersonHelper.UpdatePeople(allPeople, people, PersonRole.Inker, - person => PersonHelper.AddPersonIfNotExists(chapter.People, person)); - } + people = GetTagValues(comicInfo.Editor); + PersonHelper.RemovePeople(chapter.People, people, PersonRole.Editor); + PersonHelper.UpdatePeople(allPeople, people, PersonRole.Editor, + person => PersonHelper.AddPersonIfNotExists(chapter.People, person)); - if (!string.IsNullOrEmpty(comicInfo.Letterer)) - { - var people = comicInfo.Letterer.Split(","); - PersonHelper.RemovePeople(chapter.People, people, PersonRole.Letterer); - PersonHelper.UpdatePeople(allPeople, people, PersonRole.Letterer, - person => PersonHelper.AddPersonIfNotExists(chapter.People, person)); - } + people = GetTagValues(comicInfo.Inker); + PersonHelper.RemovePeople(chapter.People, people, PersonRole.Inker); + PersonHelper.UpdatePeople(allPeople, people, PersonRole.Inker, + person => PersonHelper.AddPersonIfNotExists(chapter.People, person)); - if (!string.IsNullOrEmpty(comicInfo.Penciller)) - { - var people = comicInfo.Penciller.Split(","); - PersonHelper.RemovePeople(chapter.People, people, PersonRole.Penciller); - PersonHelper.UpdatePeople(allPeople, people, PersonRole.Penciller, - person => PersonHelper.AddPersonIfNotExists(chapter.People, person)); - } + people = GetTagValues(comicInfo.Letterer); + PersonHelper.RemovePeople(chapter.People, people, PersonRole.Letterer); + PersonHelper.UpdatePeople(allPeople, people, PersonRole.Letterer, + person => PersonHelper.AddPersonIfNotExists(chapter.People, person)); - if (!string.IsNullOrEmpty(comicInfo.CoverArtist)) - { - var people = comicInfo.CoverArtist.Split(","); - PersonHelper.RemovePeople(chapter.People, people, PersonRole.CoverArtist); - PersonHelper.UpdatePeople(allPeople, people, PersonRole.CoverArtist, - person => PersonHelper.AddPersonIfNotExists(chapter.People, person)); - } - if (!string.IsNullOrEmpty(comicInfo.Publisher)) - { - var people = comicInfo.Publisher.Split(","); - PersonHelper.RemovePeople(chapter.People, people, PersonRole.Publisher); - PersonHelper.UpdatePeople(allPeople, people, PersonRole.Publisher, - person => PersonHelper.AddPersonIfNotExists(chapter.People, person)); - } + people = GetTagValues(comicInfo.Penciller); + PersonHelper.RemovePeople(chapter.People, people, PersonRole.Penciller); + PersonHelper.UpdatePeople(allPeople, people, PersonRole.Penciller, + person => PersonHelper.AddPersonIfNotExists(chapter.People, person)); - if (!string.IsNullOrEmpty(comicInfo.Genre)) + people = GetTagValues(comicInfo.CoverArtist); + PersonHelper.RemovePeople(chapter.People, people, PersonRole.CoverArtist); + PersonHelper.UpdatePeople(allPeople, people, PersonRole.CoverArtist, + person => PersonHelper.AddPersonIfNotExists(chapter.People, person)); + + people = GetTagValues(comicInfo.Publisher); + PersonHelper.RemovePeople(chapter.People, people, PersonRole.Publisher); + PersonHelper.UpdatePeople(allPeople, people, PersonRole.Publisher, + person => PersonHelper.AddPersonIfNotExists(chapter.People, person)); + + var genres = GetTagValues(comicInfo.Genre); + GenreHelper.KeepOnlySameGenreBetweenLists(chapter.Genres, genres.Select(g => DbFactory.Genre(g, false)).ToList()); + GenreHelper.UpdateGenre(allGenres, genres, false, + genre => chapter.Genres.Add(genre)); + + var tags = GetTagValues(comicInfo.Tags); + TagHelper.KeepOnlySameTagBetweenLists(chapter.Tags, tags.Select(t => DbFactory.Tag(t, false)).ToList()); + TagHelper.UpdateTag(allTags, tags, false, + (tag, _) => + { + chapter.Tags.Add(tag); + }); + } + + private static IList GetTagValues(string comicInfoTagSeparatedByComma) + { + + if (!string.IsNullOrEmpty(comicInfoTagSeparatedByComma)) { - var genres = comicInfo.Genre.Split(","); - GenreHelper.KeepOnlySameGenreBetweenLists(chapter.Genres, genres.Select(g => DbFactory.Genre(g, false)).ToList()); - GenreHelper.UpdateGenre(allGenres, genres, false, - genre => chapter.Genres.Add(genre)); + return comicInfoTagSeparatedByComma.Split(",").Select(s => s.Trim()).ToList(); } + return ImmutableList.Empty; } } diff --git a/API/Services/Tasks/StatsService.cs b/API/Services/Tasks/StatsService.cs index 9d2a85f95..a190a4113 100644 --- a/API/Services/Tasks/StatsService.cs +++ b/API/Services/Tasks/StatsService.cs @@ -103,18 +103,15 @@ public class StatsService : IStatsService public async Task GetServerInfo() { - var installId = await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.InstallId); - var installVersion = await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.InstallVersion); - var serverSettings = await _unitOfWork.SettingsRepository.GetSettingsDtoAsync(); var serverInfo = new ServerInfoDto { - InstallId = installId.Value, + InstallId = serverSettings.InstallId, Os = RuntimeInformation.OSDescription, - KavitaVersion = installVersion.Value, + KavitaVersion = serverSettings.InstallVersion, DotnetVersion = Environment.Version.ToString(), - IsDocker = new OsInfo(Array.Empty()).IsDocker, + IsDocker = new OsInfo().IsDocker, NumOfCores = Math.Max(Environment.ProcessorCount, 1), HasBookmarks = (await _unitOfWork.UserRepository.GetAllBookmarksAsync()).Any(), NumberOfLibraries = (await _unitOfWork.LibraryRepository.GetLibrariesAsync()).Count(), @@ -157,22 +154,20 @@ public class StatsService : IStatsService return _context.SeriesRelation.AnyAsync(); } - private Task MaxSeriesInAnyLibrary() + private async Task MaxSeriesInAnyLibrary() { - return _context.Series - .Select(s => new - { - LibraryId = s.LibraryId, - Count = _context.Library.Where(l => l.Id == s.LibraryId).SelectMany(l => l.Series).Count() - }) - .AsNoTracking() - .AsSplitQuery() - .MaxAsync(d => d.Count); + // If first time flow, just return 0 + if (!await _context.Series.AnyAsync()) return 0; + return await _context.Series + .Select(s => _context.Library.Where(l => l.Id == s.LibraryId).SelectMany(l => l.Series).Count()) + .MaxAsync(); } - private Task MaxVolumesInASeries() + private async Task MaxVolumesInASeries() { - return _context.Volume + // If first time flow, just return 0 + if (!await _context.Volume.AnyAsync()) return 0; + return await _context.Volume .Select(v => new { v.SeriesId, @@ -183,9 +178,11 @@ public class StatsService : IStatsService .MaxAsync(d => d.Count); } - private Task MaxChaptersInASeries() + private async Task MaxChaptersInASeries() { - return _context.Series + // If first time flow, just return 0 + if (!await _context.Chapter.AnyAsync()) return 0; + return await _context.Series .AsNoTracking() .AsSplitQuery() .MaxAsync(s => s.Volumes diff --git a/Kavita.Common/EnvironmentInfo/IOsInfo.cs b/Kavita.Common/EnvironmentInfo/IOsInfo.cs index d90be9489..c990591fc 100644 --- a/Kavita.Common/EnvironmentInfo/IOsInfo.cs +++ b/Kavita.Common/EnvironmentInfo/IOsInfo.cs @@ -83,6 +83,19 @@ namespace Kavita.Common.EnvironmentInfo } } + public OsInfo() + { + OsVersionModel osInfo = null; + + Name = Os.ToString(); + FullName = Name; + + if (IsLinux && File.Exists("/proc/1/cgroup") && File.ReadAllText("/proc/1/cgroup").Contains("/docker/")) + { + IsDocker = true; + } + } + private static Os GetPosixFlavour() { var output = RunAndCapture("uname", "-s"); diff --git a/UI/Web/src/app/_services/collection-tag.service.ts b/UI/Web/src/app/_services/collection-tag.service.ts index dd8571b6a..6c58753c3 100644 --- a/UI/Web/src/app/_services/collection-tag.service.ts +++ b/UI/Web/src/app/_services/collection-tag.service.ts @@ -15,10 +15,7 @@ export class CollectionTagService { constructor(private httpClient: HttpClient, private imageService: ImageService) { } allTags() { - return this.httpClient.get(this.baseUrl + 'collection/').pipe(map(tags => { - tags.forEach(s => s.coverImage = this.imageService.randomize(this.imageService.getCollectionCoverImage(s.id))); - return tags; - })); + return this.httpClient.get(this.baseUrl + 'collection/'); } search(query: string) { diff --git a/UI/Web/src/app/_services/jumpbar.service.ts b/UI/Web/src/app/_services/jumpbar.service.ts new file mode 100644 index 000000000..e5c22597c --- /dev/null +++ b/UI/Web/src/app/_services/jumpbar.service.ts @@ -0,0 +1,83 @@ +import { Injectable } from '@angular/core'; +import { JumpKey } from '../_models/jumpbar/jump-key'; + +const keySize = 25; // Height of the JumpBar button + +@Injectable({ + providedIn: 'root' +}) +export class JumpbarService { + + resumeKeys: {[key: string]: string} = {}; + + constructor() { } + + + getResumeKey(key: string) { + if (this.resumeKeys.hasOwnProperty(key)) return this.resumeKeys[key]; + return ''; + } + + saveResumeKey(key: string, value: string) { + this.resumeKeys[key] = value; + } + + generateJumpBar(jumpBarKeys: Array, currentSize: number) { + const fullSize = (jumpBarKeys.length * keySize); + if (currentSize >= fullSize) { + return [...jumpBarKeys]; + } + + const jumpBarKeysToRender: Array = []; + const targetNumberOfKeys = parseInt(Math.floor(currentSize / keySize) + '', 10); + const removeCount = jumpBarKeys.length - targetNumberOfKeys - 3; + if (removeCount <= 0) return jumpBarKeysToRender; + + const removalTimes = Math.ceil(removeCount / 2); + const midPoint = Math.floor(jumpBarKeys.length / 2); + jumpBarKeysToRender.push(jumpBarKeys[0]); + this.removeFirstPartOfJumpBar(midPoint, removalTimes, jumpBarKeys, jumpBarKeysToRender); + jumpBarKeysToRender.push(jumpBarKeys[midPoint]); + this.removeSecondPartOfJumpBar(midPoint, removalTimes, jumpBarKeys, jumpBarKeysToRender); + jumpBarKeysToRender.push(jumpBarKeys[jumpBarKeys.length - 1]); + + return jumpBarKeysToRender; + } + + removeSecondPartOfJumpBar(midPoint: number, numberOfRemovals: number = 1, jumpBarKeys: Array, jumpBarKeysToRender: Array) { + const removedIndexes: Array = []; + for(let removal = 0; removal < numberOfRemovals; removal++) { + let min = 100000000; + let minIndex = -1; + for(let i = midPoint + 1; i < jumpBarKeys.length - 2; i++) { + if (jumpBarKeys[i].size < min && !removedIndexes.includes(i)) { + min = jumpBarKeys[i].size; + minIndex = i; + } + } + removedIndexes.push(minIndex); + } + for(let i = midPoint + 1; i < jumpBarKeys.length - 2; i++) { + if (!removedIndexes.includes(i)) jumpBarKeysToRender.push(jumpBarKeys[i]); + } + } + + removeFirstPartOfJumpBar(midPoint: number, numberOfRemovals: number = 1, jumpBarKeys: Array, jumpBarKeysToRender: Array) { + const removedIndexes: Array = []; + for(let removal = 0; removal < numberOfRemovals; removal++) { + let min = 100000000; + let minIndex = -1; + for(let i = 1; i < midPoint; i++) { + if (jumpBarKeys[i].size < min && !removedIndexes.includes(i)) { + min = jumpBarKeys[i].size; + minIndex = i; + } + } + removedIndexes.push(minIndex); + } + + for(let i = 1; i < midPoint; i++) { + if (!removedIndexes.includes(i)) jumpBarKeysToRender.push(jumpBarKeys[i]); + } + } +} diff --git a/UI/Web/src/app/_services/scroll.service.ts b/UI/Web/src/app/_services/scroll.service.ts index 7137c5aeb..7e786f7ed 100644 --- a/UI/Web/src/app/_services/scroll.service.ts +++ b/UI/Web/src/app/_services/scroll.service.ts @@ -1,11 +1,27 @@ -import { Injectable } from '@angular/core'; +import { ElementRef, Injectable } from '@angular/core'; +import { NavigationEnd, Router } from '@angular/router'; +import { filter, ReplaySubject } from 'rxjs'; @Injectable({ providedIn: 'root' }) export class ScrollService { - constructor() { } + private scrollContainerSource = new ReplaySubject>(1); + /** + * Exposes the current container on the active screen that is our primary overlay area. Defaults to 'body' and changes to 'body' on page loads + */ + public scrollContainer$ = this.scrollContainerSource.asObservable(); + + constructor(router: Router) { + + router.events + .pipe(filter(event => event instanceof NavigationEnd)) + .subscribe(() => { + this.scrollContainerSource.next('body'); + }); + this.scrollContainerSource.next('body'); + } get scrollPosition() { return (window.pageYOffset @@ -26,4 +42,10 @@ export class ScrollService { behavior: 'auto' }); } + + setScrollContainer(elem: ElementRef | undefined) { + if (elem !== undefined) { + this.scrollContainerSource.next(elem); + } + } } diff --git a/UI/Web/src/app/cards/card-detail-drawer/card-detail-drawer.component.html b/UI/Web/src/app/cards/card-detail-drawer/card-detail-drawer.component.html index 5fe9fd403..837c08c58 100644 --- a/UI/Web/src/app/cards/card-detail-drawer/card-detail-drawer.component.html +++ b/UI/Web/src/app/cards/card-detail-drawer/card-detail-drawer.component.html @@ -108,7 +108,7 @@ -
  • +
  • {{tabs[TabID.Files].title}}

    {{utilityService.formatChapterName(libraryType) + 's'}}

    diff --git a/UI/Web/src/app/cards/card-detail-layout/card-detail-layout.component.html b/UI/Web/src/app/cards/card-detail-layout/card-detail-layout.component.html index a8425b47a..369fc8c23 100644 --- a/UI/Web/src/app/cards/card-detail-layout/card-detail-layout.component.html +++ b/UI/Web/src/app/cards/card-detail-layout/card-detail-layout.component.html @@ -30,7 +30,7 @@ - + diff --git a/UI/Web/src/app/cards/card-detail-layout/card-detail-layout.component.ts b/UI/Web/src/app/cards/card-detail-layout/card-detail-layout.component.ts index 0012cf042..9cf516bc8 100644 --- a/UI/Web/src/app/cards/card-detail-layout/card-detail-layout.component.ts +++ b/UI/Web/src/app/cards/card-detail-layout/card-detail-layout.component.ts @@ -1,8 +1,8 @@ import { CdkVirtualScrollViewport } from '@angular/cdk/scrolling'; import { DOCUMENT } from '@angular/common'; -import { ChangeDetectionStrategy, ChangeDetectorRef, Component, ContentChild, ElementRef, EventEmitter, HostListener, Inject, Input, OnChanges, OnDestroy, OnInit, Output, SimpleChanges, TemplateRef, TrackByFunction, ViewChild } from '@angular/core'; +import { AfterContentInit, AfterViewInit, ChangeDetectionStrategy, ChangeDetectorRef, Component, ContentChild, ElementRef, EventEmitter, HostListener, Inject, Input, OnChanges, OnDestroy, OnInit, Output, TemplateRef, TrackByFunction, ViewChild } from '@angular/core'; import { VirtualScrollerComponent } from '@iharbeck/ngx-virtual-scroller'; -import { Subject } from 'rxjs'; +import { first, Subject, takeUntil, takeWhile } from 'rxjs'; import { FilterSettings } from 'src/app/metadata-filter/filter-settings'; import { Breakpoint, UtilityService } from 'src/app/shared/_services/utility.service'; import { JumpKey } from 'src/app/_models/jumpbar/jump-key'; @@ -10,9 +10,10 @@ import { Library } from 'src/app/_models/library'; import { Pagination } from 'src/app/_models/pagination'; import { FilterEvent, FilterItem, SeriesFilter } from 'src/app/_models/series-filter'; import { ActionItem } from 'src/app/_services/action-factory.service'; +import { JumpbarService } from 'src/app/_services/jumpbar.service'; import { SeriesService } from 'src/app/_services/series.service'; -const keySize = 24; +const keySize = 25; // Height of the JumpBar button @Component({ selector: 'app-card-detail-layout', @@ -20,7 +21,7 @@ const keySize = 24; styleUrls: ['./card-detail-layout.component.scss'], changeDetection: ChangeDetectionStrategy.OnPush }) -export class CardDetailLayoutComponent implements OnInit, OnDestroy, OnChanges { +export class CardDetailLayoutComponent implements OnInit, OnDestroy, OnChanges, AfterViewInit { @Input() header: string = ''; @Input() isLoading: boolean = false; @@ -62,6 +63,7 @@ export class CardDetailLayoutComponent implements OnInit, OnDestroy, OnChanges { libraries: Array> = []; updateApplied: number = 0; + hasResumedJumpKey: boolean = false; private onDestory: Subject = new Subject(); @@ -70,7 +72,8 @@ export class CardDetailLayoutComponent implements OnInit, OnDestroy, OnChanges { } constructor(private seriesService: SeriesService, public utilityService: UtilityService, - @Inject(DOCUMENT) private document: Document, private changeDetectionRef: ChangeDetectorRef) { + @Inject(DOCUMENT) private document: Document, private changeDetectionRef: ChangeDetectorRef, + private jumpbarService: JumpbarService) { this.filter = this.seriesService.createSeriesFilter(); this.changeDetectionRef.markForCheck(); } @@ -78,74 +81,16 @@ export class CardDetailLayoutComponent implements OnInit, OnDestroy, OnChanges { @HostListener('window:resize', ['$event']) @HostListener('window:orientationchange', ['$event']) resizeJumpBar() { - const fullSize = (this.jumpBarKeys.length * keySize); const currentSize = (this.document.querySelector('.viewport-container')?.getBoundingClientRect().height || 10) - 30; - if (currentSize >= fullSize) { - this.jumpBarKeysToRender = [...this.jumpBarKeys]; - this.changeDetectionRef.markForCheck(); - return; - } - - const targetNumberOfKeys = parseInt(Math.floor(currentSize / keySize) + '', 10); - const removeCount = this.jumpBarKeys.length - targetNumberOfKeys - 3; - if (removeCount <= 0) return; - - - this.jumpBarKeysToRender = []; - - const removalTimes = Math.ceil(removeCount / 2); - const midPoint = Math.floor(this.jumpBarKeys.length / 2); - this.jumpBarKeysToRender.push(this.jumpBarKeys[0]); - this.removeFirstPartOfJumpBar(midPoint, removalTimes); - this.jumpBarKeysToRender.push(this.jumpBarKeys[midPoint]); - this.removeSecondPartOfJumpBar(midPoint, removalTimes); - this.jumpBarKeysToRender.push(this.jumpBarKeys[this.jumpBarKeys.length - 1]); + this.jumpBarKeysToRender = this.jumpbarService.generateJumpBar(this.jumpBarKeys, currentSize); this.changeDetectionRef.markForCheck(); } - removeSecondPartOfJumpBar(midPoint: number, numberOfRemovals: number = 1) { - const removedIndexes: Array = []; - for(let removal = 0; removal < numberOfRemovals; removal++) { - let min = 100000000; - let minIndex = -1; - for(let i = midPoint + 1; i < this.jumpBarKeys.length - 2; i++) { - if (this.jumpBarKeys[i].size < min && !removedIndexes.includes(i)) { - min = this.jumpBarKeys[i].size; - minIndex = i; - } - } - removedIndexes.push(minIndex); - } - for(let i = midPoint + 1; i < this.jumpBarKeys.length - 2; i++) { - if (!removedIndexes.includes(i)) this.jumpBarKeysToRender.push(this.jumpBarKeys[i]); - } - } - - removeFirstPartOfJumpBar(midPoint: number, numberOfRemovals: number = 1) { - const removedIndexes: Array = []; - for(let removal = 0; removal < numberOfRemovals; removal++) { - let min = 100000000; - let minIndex = -1; - for(let i = 1; i < midPoint; i++) { - if (this.jumpBarKeys[i].size < min && !removedIndexes.includes(i)) { - min = this.jumpBarKeys[i].size; - minIndex = i; - } - } - removedIndexes.push(minIndex); - } - - for(let i = 1; i < midPoint; i++) { - if (!removedIndexes.includes(i)) this.jumpBarKeysToRender.push(this.jumpBarKeys[i]); - } - } - ngOnInit(): void { if (this.trackByIdentity === undefined) { this.trackByIdentity = (index: number, item: any) => `${this.header}_${this.updateApplied}_${item?.libraryId}`; } - if (this.filterSettings === undefined) { this.filterSettings = new FilterSettings(); this.changeDetectionRef.markForCheck(); @@ -157,9 +102,27 @@ export class CardDetailLayoutComponent implements OnInit, OnDestroy, OnChanges { } } - ngOnChanges(changes: SimpleChanges): void { + ngAfterViewInit(): void { + // NOTE: I can't seem to figure out a way to resume the JumpKey with the scroller. + // this.virtualScroller.vsUpdate.pipe(takeWhile(() => this.hasResumedJumpKey), takeUntil(this.onDestory)).subscribe(() => { + // const resumeKey = this.jumpbarService.getResumeKey(this.header); + // console.log('Resume key:', resumeKey); + // if (resumeKey !== '') { + // const keys = this.jumpBarKeys.filter(k => k.key === resumeKey); + // if (keys.length >= 1) { + // console.log('Scrolling to ', keys[0].key); + // this.scrollTo(keys[0]); + // this.hasResumedJumpKey = true; + // } + // } + // this.hasResumedJumpKey = true; + // }); + } + + ngOnChanges(): void { this.jumpBarKeysToRender = [...this.jumpBarKeys]; this.resizeJumpBar(); + } @@ -188,7 +151,8 @@ export class CardDetailLayoutComponent implements OnInit, OnDestroy, OnChanges { targetIndex += this.jumpBarKeys[i].size; } - this.virtualScroller.scrollToIndex(targetIndex, true, undefined, 1000); + this.virtualScroller.scrollToIndex(targetIndex, true, 800, 1000); + this.jumpbarService.saveResumeKey(this.header, jumpKey.key); this.changeDetectionRef.markForCheck(); return; } diff --git a/UI/Web/src/app/cards/card-item/card-item.component.ts b/UI/Web/src/app/cards/card-item/card-item.component.ts index 1fc4f4c1a..432f80179 100644 --- a/UI/Web/src/app/cards/card-item/card-item.component.ts +++ b/UI/Web/src/app/cards/card-item/card-item.component.ts @@ -177,6 +177,33 @@ export class CardItemComponent implements OnInit, OnDestroy { if (this.utilityService.isVolume(this.entity) && updateEvent.volumeId !== this.entity.id) return; if (this.utilityService.isSeries(this.entity) && updateEvent.seriesId !== this.entity.id) return; + // For volume or Series, we can't just take the event + if (this.utilityService.isVolume(this.entity) || this.utilityService.isSeries(this.entity)) { + if (this.utilityService.isVolume(this.entity)) { + const v = this.utilityService.asVolume(this.entity); + const chapter = v.chapters.find(c => c.id === updateEvent.chapterId); + if (chapter) { + chapter.pagesRead = updateEvent.pagesRead; + } + } else { + // re-request progress for the series + const s = this.utilityService.asSeries(this.entity); + let pagesRead = 0; + if (s.hasOwnProperty('volumes')) { + s.volumes.forEach(v => { + v.chapters.forEach(c => { + if (c.id === updateEvent.chapterId) { + c.pagesRead = updateEvent.pagesRead; + } + pagesRead += c.pagesRead; + }); + }); + s.pagesRead = pagesRead; + } + } + } + + this.read = updateEvent.pagesRead; this.cdRef.detectChanges(); }); diff --git a/UI/Web/src/app/collections/collection-detail/collection-detail.component.html b/UI/Web/src/app/collections/collection-detail/collection-detail.component.html index 0b75795b3..9ef3d02fb 100644 --- a/UI/Web/src/app/collections/collection-detail/collection-detail.component.html +++ b/UI/Web/src/app/collections/collection-detail/collection-detail.component.html @@ -11,7 +11,7 @@
    -
    +
    diff --git a/UI/Web/src/app/collections/collection-detail/collection-detail.component.ts b/UI/Web/src/app/collections/collection-detail/collection-detail.component.ts index 40737d8ae..c3debc2d2 100644 --- a/UI/Web/src/app/collections/collection-detail/collection-detail.component.ts +++ b/UI/Web/src/app/collections/collection-detail/collection-detail.component.ts @@ -1,5 +1,5 @@ import { DOCUMENT } from '@angular/common'; -import { ChangeDetectionStrategy, ChangeDetectorRef, Component, ElementRef, EventEmitter, HostListener, Inject, OnDestroy, OnInit, ViewChild } from '@angular/core'; +import { AfterContentChecked, ChangeDetectionStrategy, ChangeDetectorRef, Component, ElementRef, EventEmitter, HostListener, Inject, OnDestroy, OnInit, ViewChild } from '@angular/core'; import { Title } from '@angular/platform-browser'; import { Router, ActivatedRoute } from '@angular/router'; import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; @@ -22,6 +22,7 @@ import { ActionService } from 'src/app/_services/action.service'; import { CollectionTagService } from 'src/app/_services/collection-tag.service'; import { ImageService } from 'src/app/_services/image.service'; import { EVENTS, MessageHubService } from 'src/app/_services/message-hub.service'; +import { ScrollService } from 'src/app/_services/scroll.service'; import { SeriesService } from 'src/app/_services/series.service'; @Component({ @@ -30,7 +31,7 @@ import { SeriesService } from 'src/app/_services/series.service'; styleUrls: ['./collection-detail.component.scss'], changeDetection: ChangeDetectionStrategy.OnPush }) -export class CollectionDetailComponent implements OnInit, OnDestroy { +export class CollectionDetailComponent implements OnInit, OnDestroy, AfterContentChecked { @ViewChild('scrollingBlock') scrollingBlock: ElementRef | undefined; @ViewChild('companionBar') companionBar: ElementRef | undefined; @@ -113,7 +114,7 @@ export class CollectionDetailComponent implements OnInit, OnDestroy { private modalService: NgbModal, private titleService: Title, public bulkSelectionService: BulkSelectionService, private actionService: ActionService, private messageHub: MessageHubService, private filterUtilityService: FilterUtilitiesService, private utilityService: UtilityService, @Inject(DOCUMENT) private document: Document, - private readonly cdRef: ChangeDetectorRef) { + private readonly cdRef: ChangeDetectorRef, private scrollService: ScrollService) { this.router.routeReuseStrategy.shouldReuseRoute = () => false; const routeId = this.route.snapshot.paramMap.get('id'); @@ -148,6 +149,10 @@ export class CollectionDetailComponent implements OnInit, OnDestroy { }); } + ngAfterContentChecked(): void { + this.scrollService.setScrollContainer(this.scrollingBlock); + } + ngOnDestroy() { this.onDestory.next(); this.onDestory.complete(); @@ -230,6 +235,8 @@ export class CollectionDetailComponent implements OnInit, OnDestroy { this.loadPage(); if (results.coverImageUpdated) { this.tagImage = this.imageService.randomize(this.imageService.getCollectionCoverImage(collectionTag.id)); + this.collectionTag.coverImage = this.imageService.randomize(this.imageService.getCollectionCoverImage(collectionTag.id)); + this.cdRef.markForCheck(); } }); } diff --git a/UI/Web/src/app/manga-reader/manga-reader.component.ts b/UI/Web/src/app/manga-reader/manga-reader.component.ts index 1cde9a299..16fddb889 100644 --- a/UI/Web/src/app/manga-reader/manga-reader.component.ts +++ b/UI/Web/src/app/manga-reader/manga-reader.component.ts @@ -611,7 +611,9 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy { } else if (event.key === KEY_CODES.B) { this.bookmarkPage(); } else if (event.key === KEY_CODES.F) { - this.toggleFullscreen() + this.toggleFullscreen(); + } else if (event.key === KEY_CODES.H) { + this.openShortcutModal(); } } diff --git a/UI/Web/src/app/nav/grouped-typeahead/grouped-typeahead.component.html b/UI/Web/src/app/nav/grouped-typeahead/grouped-typeahead.component.html index 01224ddf0..206f3414d 100644 --- a/UI/Web/src/app/nav/grouped-typeahead/grouped-typeahead.component.html +++ b/UI/Web/src/app/nav/grouped-typeahead/grouped-typeahead.component.html @@ -1,16 +1,14 @@