diff --git a/API.Tests/Services/ScannerServiceTests.cs b/API.Tests/Services/ScannerServiceTests.cs index 2c09fe9b9..8cc7073da 100644 --- a/API.Tests/Services/ScannerServiceTests.cs +++ b/API.Tests/Services/ScannerServiceTests.cs @@ -398,4 +398,153 @@ public class ScannerServiceTests : AbstractDbTest Assert.Equal(3, series.Volumes.Count); Assert.Equal(2, series.Volumes.First(v => v.MinNumber.Is(Parser.LooseLeafVolumeNumber)).Chapters.Count); } + + [Fact] + public async Task ScanLibrary_LocalizedSeries_MatchesFilename() + { + const string testcase = "Localized Name matches Filename - Manga.json"; + + // Get the first file and generate a ComicInfo + var infos = new Dictionary(); + infos.Add("Futoku no Guild v01.cbz", new ComicInfo() + { + Series = "Immoral Guild", + LocalizedSeries = "Futoku no Guild" + }); + + var library = await _scannerHelper.GenerateScannerData(testcase, infos); + + + var scanner = _scannerHelper.CreateServices(); + await scanner.ScanLibrary(library.Id); + var postLib = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(library.Id, LibraryIncludes.Series); + + Assert.NotNull(postLib); + Assert.Single(postLib.Series); + var s = postLib.Series.First(); + Assert.Equal("Immoral Guild", s.Name); + Assert.Equal("Futoku no Guild", s.LocalizedName); + Assert.Single(s.Volumes); + } + + [Fact] + public async Task ScanLibrary_LocalizedSeries_MatchesFilename_SameNames() + { + const string testcase = "Localized Name matches Filename - Manga.json"; + + // Get the first file and generate a ComicInfo + var infos = new Dictionary(); + infos.Add("Futoku no Guild v01.cbz", new ComicInfo() + { + Series = "Futoku no Guild", + LocalizedSeries = "Futoku no Guild" + }); + + var library = await _scannerHelper.GenerateScannerData(testcase, infos); + + + var scanner = _scannerHelper.CreateServices(); + await scanner.ScanLibrary(library.Id); + var postLib = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(library.Id, LibraryIncludes.Series); + + Assert.NotNull(postLib); + Assert.Single(postLib.Series); + var s = postLib.Series.First(); + Assert.Equal("Futoku no Guild", s.Name); + Assert.Equal("Futoku no Guild", s.LocalizedName); + Assert.Single(s.Volumes); + } + + [Fact] + public async Task ScanLibrary_ExcludePattern_Works() + { + const string testcase = "Exclude Pattern 1 - Manga.json"; + + // Get the first file and generate a ComicInfo + var infos = new Dictionary(); + var library = await _scannerHelper.GenerateScannerData(testcase, infos); + + library.LibraryExcludePatterns = [new LibraryExcludePattern() {Pattern = "**/Extra/*"}]; + _unitOfWork.LibraryRepository.Update(library); + await _unitOfWork.CommitAsync(); + + + var scanner = _scannerHelper.CreateServices(); + await scanner.ScanLibrary(library.Id); + var postLib = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(library.Id, LibraryIncludes.Series); + + Assert.NotNull(postLib); + Assert.Single(postLib.Series); + var s = postLib.Series.First(); + Assert.Equal(2, s.Volumes.Count); + } + + [Fact] + public async Task ScanLibrary_ExcludePattern_FlippedSlashes_Works() + { + const string testcase = "Exclude Pattern 1 - Manga.json"; + + // Get the first file and generate a ComicInfo + var infos = new Dictionary(); + var library = await _scannerHelper.GenerateScannerData(testcase, infos); + + library.LibraryExcludePatterns = [new LibraryExcludePattern() {Pattern = "**\\Extra\\*"}]; + _unitOfWork.LibraryRepository.Update(library); + await _unitOfWork.CommitAsync(); + + + var scanner = _scannerHelper.CreateServices(); + await scanner.ScanLibrary(library.Id); + var postLib = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(library.Id, LibraryIncludes.Series); + + Assert.NotNull(postLib); + Assert.Single(postLib.Series); + var s = postLib.Series.First(); + Assert.Equal(2, s.Volumes.Count); + } + + [Fact] + public async Task ScanLibrary_MultipleRoots_MultipleScans_DataPersists() + { + const string testcase = "Multiple Roots - Manga.json"; + + // Get the first file and generate a ComicInfo + var infos = new Dictionary(); + var library = await _scannerHelper.GenerateScannerData(testcase, infos); + + var testDirectoryPath = + Path.Join( + Path.Join(Directory.GetCurrentDirectory(), "../../../Services/Test Data/ScannerService/ScanTests"), + testcase.Replace(".json", string.Empty)); + library.Folders = + [ + new FolderPath() {Path = Path.Join(testDirectoryPath, "Root 1")}, + new FolderPath() {Path = Path.Join(testDirectoryPath, "Root 2")} + ]; + + _unitOfWork.LibraryRepository.Update(library); + await _unitOfWork.CommitAsync(); + + + var scanner = _scannerHelper.CreateServices(); + await scanner.ScanLibrary(library.Id); + var postLib = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(library.Id, LibraryIncludes.Series); + + Assert.NotNull(postLib); + Assert.Equal(2, postLib.Series.Count); + var s = postLib.Series.First(s => s.Name == "Plush"); + Assert.Equal(2, s.Volumes.Count); + var s2 = postLib.Series.First(s => s.Name == "Accel"); + Assert.Single(s2.Volumes); + + // Rescan to ensure nothing changes yet again + await scanner.ScanLibrary(library.Id, true); + + postLib = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(library.Id, LibraryIncludes.Series); + Assert.Equal(2, postLib.Series.Count); + s = postLib.Series.First(s => s.Name == "Plush"); + Assert.Equal(2, s.Volumes.Count); + s2 = postLib.Series.First(s => s.Name == "Accel"); + Assert.Single(s2.Volumes); + } } diff --git a/API.Tests/Services/Test Data/ScannerService/TestCases/Exclude Pattern 1 - Manga.json b/API.Tests/Services/Test Data/ScannerService/TestCases/Exclude Pattern 1 - Manga.json new file mode 100644 index 000000000..fe931174e --- /dev/null +++ b/API.Tests/Services/Test Data/ScannerService/TestCases/Exclude Pattern 1 - Manga.json @@ -0,0 +1,5 @@ +[ + "Antarctic Press/Plush/Plush v01.cbz", + "Antarctic Press/Plush/Plush v02.cbz", + "Antarctic Press/Plush/Extra/Plush v03.cbz" +] \ No newline at end of file diff --git a/API.Tests/Services/Test Data/ScannerService/TestCases/Localized Name matches Filename - Manga.json b/API.Tests/Services/Test Data/ScannerService/TestCases/Localized Name matches Filename - Manga.json new file mode 100644 index 000000000..feb1fd99f --- /dev/null +++ b/API.Tests/Services/Test Data/ScannerService/TestCases/Localized Name matches Filename - Manga.json @@ -0,0 +1,3 @@ +[ + "Immoral Guild/Futoku no Guild v01.cbz" +] \ No newline at end of file diff --git a/API.Tests/Services/Test Data/ScannerService/TestCases/Multiple Roots - Manga.json b/API.Tests/Services/Test Data/ScannerService/TestCases/Multiple Roots - Manga.json new file mode 100644 index 000000000..c9d4b14b6 --- /dev/null +++ b/API.Tests/Services/Test Data/ScannerService/TestCases/Multiple Roots - Manga.json @@ -0,0 +1,5 @@ +[ + "Root 1/Antarctic Press/Plush/Plush v01.cbz", + "Root 1/Antarctic Press/Plush/Plush v02.cbz", + "Root 2/Accel/Accel v01.cbz" +] \ No newline at end of file diff --git a/API/Controllers/DownloadController.cs b/API/Controllers/DownloadController.cs index ba65aec70..27a7b59ab 100644 --- a/API/Controllers/DownloadController.cs +++ b/API/Controllers/DownloadController.cs @@ -6,6 +6,7 @@ using System.Threading.Tasks; using API.Data; using API.DTOs.Downloads; using API.Entities; +using API.Entities.Enums; using API.Extensions; using API.Services; using API.SignalR; @@ -157,7 +158,7 @@ public class DownloadController : BaseApiController await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress, MessageFactory.DownloadProgressEvent(username, filename, $"Downloading {filename}", 0F, "started")); - if (files.Count == 1) + if (files.Count == 1 && files.First().Format != MangaFormat.Image) { await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress, MessageFactory.DownloadProgressEvent(username, diff --git a/API/Controllers/OPDSController.cs b/API/Controllers/OPDSController.cs index 6d49b4ee1..152e65495 100644 --- a/API/Controllers/OPDSController.cs +++ b/API/Controllers/OPDSController.cs @@ -4,6 +4,7 @@ using System.Globalization; using System.IO; using System.Linq; using System.Threading.Tasks; +using System.Xml; using System.Xml.Serialization; using API.Comparators; using API.Data; @@ -1363,9 +1364,40 @@ public class OpdsController : BaseApiController { if (feed == null) return string.Empty; + // Remove invalid XML characters from the feed object + SanitizeFeed(feed); + using var sm = new StringWriter(); _xmlSerializer.Serialize(sm, feed); return sm.ToString().Replace("utf-16", "utf-8"); // Chunky cannot accept UTF-16 feeds } + + // Recursively sanitize all string properties in the object + private static void SanitizeFeed(object? obj) + { + if (obj == null) return; + + var properties = obj.GetType().GetProperties(); + foreach (var property in properties) + { + if (property.PropertyType == typeof(string) && property.CanWrite) + { + var value = (string?)property.GetValue(obj); + if (!string.IsNullOrEmpty(value)) + { + property.SetValue(obj, RemoveInvalidXmlChars(value)); + } + } + else if (property.PropertyType.IsClass) // Handle nested objects + { + SanitizeFeed(property.GetValue(obj)); + } + } + } + + private static string RemoveInvalidXmlChars(string input) + { + return new string(input.Where(XmlConvert.IsXmlChar).ToArray()); + } } diff --git a/API/Entities/AppUserProgress.cs b/API/Entities/AppUserProgress.cs index edbd25aa7..beaf07220 100644 --- a/API/Entities/AppUserProgress.cs +++ b/API/Entities/AppUserProgress.cs @@ -7,7 +7,7 @@ namespace API.Entities; /// /// Represents the progress a single user has on a given Chapter. /// -public class AppUserProgress +public class AppUserProgress : IEntityDate { /// /// Id of Entity diff --git a/API/Services/ArchiveService.cs b/API/Services/ArchiveService.cs index aa0447fc2..f8d1a9411 100644 --- a/API/Services/ArchiveService.cs +++ b/API/Services/ArchiveService.cs @@ -16,6 +16,7 @@ using Kavita.Common; using Microsoft.Extensions.Logging; using SharpCompress.Archives; using SharpCompress.Common; +using YamlDotNet.Core; namespace API.Services; @@ -354,6 +355,14 @@ public class ArchiveService : IArchiveService foreach (var path in files) { var tempPath = Path.Join(tempLocation, _directoryService.FileSystem.Path.GetFileNameWithoutExtension(_directoryService.FileSystem.FileInfo.New(path).Name)); + + // Image series need different handling + if (Tasks.Scanner.Parser.Parser.IsImage(path)) + { + var parentDirectory = _directoryService.FileSystem.DirectoryInfo.New(path).Parent?.Name; + tempPath = Path.Join(tempLocation, parentDirectory ?? _directoryService.FileSystem.FileInfo.New(path).Name); + } + progressCallback(Tuple.Create(_directoryService.FileSystem.FileInfo.New(path).Name, (1.0f * totalFiles) / count)); if (Tasks.Scanner.Parser.Parser.IsArchive(path)) { diff --git a/API/Services/CacheService.cs b/API/Services/CacheService.cs index 9a8ef64ce..d008ab5f5 100644 --- a/API/Services/CacheService.cs +++ b/API/Services/CacheService.cs @@ -173,7 +173,22 @@ public class CacheService : ICacheService await extractLock.WaitAsync(); try { - if(_directoryService.Exists(extractPath)) return chapter; + if (_directoryService.Exists(extractPath)) + { + if (extractPdfToImages) + { + var pdfImages = _directoryService.GetFiles(extractPath, + Tasks.Scanner.Parser.Parser.ImageFileExtensions); + if (pdfImages.Any()) + { + return chapter; + } + } + else + { + return chapter; + } + } var files = chapter?.Files.ToList(); ExtractChapterFiles(extractPath, files, extractPdfToImages); diff --git a/API/Services/ReaderService.cs b/API/Services/ReaderService.cs index dd4b824b8..3b3cb37d5 100644 --- a/API/Services/ReaderService.cs +++ b/API/Services/ReaderService.cs @@ -122,6 +122,7 @@ public class ReaderService : IReaderService var seenVolume = new Dictionary(); var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(seriesId); if (series == null) throw new KavitaException("series-doesnt-exist"); + foreach (var chapter in chapters) { var userProgress = GetUserProgressForChapter(user, chapter); @@ -135,10 +136,6 @@ public class ReaderService : IReaderService SeriesId = seriesId, ChapterId = chapter.Id, LibraryId = series.LibraryId, - Created = DateTime.Now, - CreatedUtc = DateTime.UtcNow, - LastModified = DateTime.Now, - LastModifiedUtc = DateTime.UtcNow }); } else @@ -206,7 +203,7 @@ public class ReaderService : IReaderService /// Must have Progresses populated /// /// - private static AppUserProgress? GetUserProgressForChapter(AppUser user, Chapter chapter) + private AppUserProgress? GetUserProgressForChapter(AppUser user, Chapter chapter) { AppUserProgress? userProgress = null; @@ -226,11 +223,12 @@ public class ReaderService : IReaderService var progresses = user.Progresses.Where(x => x.ChapterId == chapter.Id && x.AppUserId == user.Id).ToList(); if (progresses.Count > 1) { - user.Progresses = new List - { - user.Progresses.First() - }; + var highestProgress = progresses.Max(x => x.PagesRead); + var firstProgress = progresses.OrderBy(p => p.LastModifiedUtc).First(); + firstProgress.PagesRead = highestProgress; + user.Progresses = [firstProgress]; userProgress = user.Progresses.First(); + _logger.LogInformation("Trying to save progress and multiple progress entries exist, deleting and rewriting with highest progress rate: {@Progress}", userProgress); } } @@ -274,10 +272,6 @@ public class ReaderService : IReaderService ChapterId = progressDto.ChapterId, LibraryId = progressDto.LibraryId, BookScrollId = progressDto.BookScrollId, - Created = DateTime.Now, - CreatedUtc = DateTime.UtcNow, - LastModified = DateTime.Now, - LastModifiedUtc = DateTime.UtcNow }); _unitOfWork.UserRepository.Update(userWithProgress); } diff --git a/API/Services/Tasks/Scanner/ParseScannedFiles.cs b/API/Services/Tasks/Scanner/ParseScannedFiles.cs index f4405165d..ba8ab7457 100644 --- a/API/Services/Tasks/Scanner/ParseScannedFiles.cs +++ b/API/Services/Tasks/Scanner/ParseScannedFiles.cs @@ -674,6 +674,12 @@ public class ParseScannedFiles private static void RemapSeries(IList scanResults, List allInfos, string localizedSeries, string nonLocalizedSeries) { + // If the series names are identical, no remapping is needed (rare but valid) + if (localizedSeries.ToNormalized().Equals(nonLocalizedSeries.ToNormalized())) + { + return; + } + // Find all infos that need to be remapped from the localized series to the non-localized series var normalizedLocalizedSeries = localizedSeries.ToNormalized(); var seriesToBeRemapped = allInfos.Where(i => i.Series.ToNormalized().Equals(normalizedLocalizedSeries)).ToList(); diff --git a/UI/Web/src/app/_services/reader.service.ts b/UI/Web/src/app/_services/reader.service.ts index 991a37bdb..9afa400a0 100644 --- a/UI/Web/src/app/_services/reader.service.ts +++ b/UI/Web/src/app/_services/reader.service.ts @@ -109,6 +109,7 @@ export class ReaderService { return this.httpClient.post(this.baseUrl + 'reader/all-bookmarks', filter); } + getBookmarks(chapterId: number) { return this.httpClient.get(this.baseUrl + 'reader/chapter-bookmarks?chapterId=' + chapterId); } diff --git a/UI/Web/src/app/_single-module/related-tab/related-tab.component.html b/UI/Web/src/app/_single-module/related-tab/related-tab.component.html index 17d40e515..8334eaf21 100644 --- a/UI/Web/src/app/_single-module/related-tab/related-tab.component.html +++ b/UI/Web/src/app/_single-module/related-tab/related-tab.component.html @@ -1,5 +1,5 @@ -
+
@if (relations.length > 0) { @@ -30,5 +30,18 @@ } + + @if (bookmarks.length > 0) { + + + + + + }
diff --git a/UI/Web/src/app/_single-module/related-tab/related-tab.component.ts b/UI/Web/src/app/_single-module/related-tab/related-tab.component.ts index d61c0e04e..c02c4ac53 100644 --- a/UI/Web/src/app/_single-module/related-tab/related-tab.component.ts +++ b/UI/Web/src/app/_single-module/related-tab/related-tab.component.ts @@ -1,4 +1,4 @@ -import {ChangeDetectionStrategy, Component, inject, Input} from '@angular/core'; +import {ChangeDetectionStrategy, Component, inject, Input, OnInit} from '@angular/core'; import {ReadingList} from "../../_models/reading-list"; import {CardItemComponent} from "../../cards/card-item/card-item.component"; import {CarouselReelComponent} from "../../carousel/_components/carousel-reel/carousel-reel.component"; @@ -9,6 +9,7 @@ import {Router} from "@angular/router"; import {SeriesCardComponent} from "../../cards/series-card/series-card.component"; import {Series} from "../../_models/series"; import {RelationKind} from "../../_models/series-detail/relation-kind"; +import {PageBookmark} from "../../_models/readers/page-bookmark"; export interface RelatedSeriesPair { series: Series; @@ -28,7 +29,7 @@ export interface RelatedSeriesPair { styleUrl: './related-tab.component.scss', changeDetection: ChangeDetectionStrategy.OnPush }) -export class RelatedTabComponent { +export class RelatedTabComponent implements OnInit { protected readonly imageService = inject(ImageService); protected readonly router = inject(Router); @@ -36,6 +37,12 @@ export class RelatedTabComponent { @Input() readingLists: Array = []; @Input() collections: Array = []; @Input() relations: Array = []; + @Input() bookmarks: Array = []; + @Input() libraryId!: number; + + ngOnInit() { + console.log('bookmarks: ', this.bookmarks); + } openReadingList(readingList: ReadingList) { this.router.navigate(['lists', readingList.id]); @@ -45,4 +52,8 @@ export class RelatedTabComponent { this.router.navigate(['collections', collection.id]); } + viewBookmark(bookmark: PageBookmark) { + this.router.navigate(['library', this.libraryId, 'series', bookmark.seriesId, 'manga', 0], {queryParams: {incognitoMode: false, bookmarkMode: true}}); + } + } diff --git a/UI/Web/src/app/book-reader/_components/book-reader/book-reader.component.scss b/UI/Web/src/app/book-reader/_components/book-reader/book-reader.component.scss index f0a064718..dcfa9ddcd 100644 --- a/UI/Web/src/app/book-reader/_components/book-reader/book-reader.component.scss +++ b/UI/Web/src/app/book-reader/_components/book-reader/book-reader.component.scss @@ -40,6 +40,18 @@ font-display: swap; } +@font-face { + font-family: "FastFontSerif"; + src: url(../../../../assets/fonts/Fast_Font/Fast_Serif.woff2) format("woff2"); + font-display: swap; +} + +@font-face { + font-family: "FastFontSans"; + src: url(../../../../assets/fonts/Fast_Font/Fast_Sans.woff2) format("woff2"); + font-display: swap; +} + :root { --br-actionbar-button-text-color: #6c757d; --accordion-body-bg-color: black; diff --git a/UI/Web/src/app/book-reader/_models/book-white-theme.ts b/UI/Web/src/app/book-reader/_models/book-white-theme.ts index 31c6cccec..1b4bab274 100644 --- a/UI/Web/src/app/book-reader/_models/book-white-theme.ts +++ b/UI/Web/src/app/book-reader/_models/book-white-theme.ts @@ -103,7 +103,7 @@ export const BookWhiteTheme = ` .book-content *:not(input), .book-content *:not(select), .book-content *:not(code), .book-content *:not(:link), .book-content *:not(.ngx-toastr) { - color: black !important; + color: black; } .book-content code { @@ -125,7 +125,7 @@ export const BookWhiteTheme = ` box-shadow: none; text-shadow: none; border-radius: unset; - color: #dcdcdc !important; + color: #dcdcdc; } .book-content :visited, .book-content :visited *, .book-content :visited *[class] { diff --git a/UI/Web/src/app/book-reader/_services/book.service.ts b/UI/Web/src/app/book-reader/_services/book.service.ts index 65549ab48..d98f09f38 100644 --- a/UI/Web/src/app/book-reader/_services/book.service.ts +++ b/UI/Web/src/app/book-reader/_services/book.service.ts @@ -28,7 +28,7 @@ export class BookService { getFontFamilies(): Array { return [{title: 'default', family: 'default'}, {title: 'EBGaramond', family: 'EBGaramond'}, {title: 'Fira Sans', family: 'Fira_Sans'}, {title: 'Lato', family: 'Lato'}, {title: 'Libre Baskerville', family: 'Libre_Baskerville'}, {title: 'Merriweather', family: 'Merriweather'}, - {title: 'Nanum Gothic', family: 'Nanum_Gothic'}, {title: 'RocknRoll One', family: 'RocknRoll_One'}, {title: 'Open Dyslexic', family: 'OpenDyslexic2'}]; + {title: 'Nanum Gothic', family: 'Nanum_Gothic'}, {title: 'Open Dyslexic', family: 'OpenDyslexic2'}, {title: 'RocknRoll One', family: 'RocknRoll_One'}, {title: 'Fast Font Serif (Bionic)', family: 'FastFontSerif'}, {title: 'Fast Font Sans (Bionic)', family: 'FastFontSans'}]; } getBookChapters(chapterId: number) { diff --git a/UI/Web/src/app/bookmark/_components/bookmarks/bookmarks.component.html b/UI/Web/src/app/bookmark/_components/bookmarks/bookmarks.component.html index cb4e9c890..8c167a2e5 100644 --- a/UI/Web/src/app/bookmark/_components/bookmarks/bookmarks.component.html +++ b/UI/Web/src/app/bookmark/_components/bookmarks/bookmarks.component.html @@ -7,27 +7,29 @@
{{t('series-count', {num: series.length | number})}}
- - - - + @if (filter) { + + + + - - {{t('no-data')}} {{t('no-data-2')}} - - + + {{t('no-data')}} {{t('no-data-2')}} + + + } -
+ diff --git a/UI/Web/src/app/bookmark/_components/bookmarks/bookmarks.component.ts b/UI/Web/src/app/bookmark/_components/bookmarks/bookmarks.component.ts index 878843464..ffb69fe1d 100644 --- a/UI/Web/src/app/bookmark/_components/bookmarks/bookmarks.component.ts +++ b/UI/Web/src/app/bookmark/_components/bookmarks/bookmarks.component.ts @@ -15,7 +15,6 @@ import { FilterSettings } from 'src/app/metadata-filter/filter-settings'; import { ConfirmService } from 'src/app/shared/confirm.service'; import {DownloadService} from 'src/app/shared/_services/download.service'; import { FilterUtilitiesService } from 'src/app/shared/_services/filter-utilities.service'; -import { KEY_CODES } from 'src/app/shared/_services/utility.service'; import { JumpKey } from 'src/app/_models/jumpbar/jump-key'; import { PageBookmark } from 'src/app/_models/readers/page-bookmark'; import { Pagination } from 'src/app/_models/pagination'; @@ -103,13 +102,13 @@ export class BookmarksComponent implements OnInit { async handleAction(action: ActionItem, series: Series) { switch (action.action) { case(Action.Delete): - this.clearBookmarks(series); + await this.clearBookmarks(series); break; case(Action.DownloadBookmark): this.downloadBookmarks(series); break; case(Action.ViewSeries): - this.router.navigate(['library', series.libraryId, 'series', series.id]); + await this.router.navigate(['library', series.libraryId, 'series', series.id]); break; default: break; diff --git a/UI/Web/src/app/cards/card-item/card-item.component.html b/UI/Web/src/app/cards/card-item/card-item.component.html index e654f5f1d..b9b69accc 100644 --- a/UI/Web/src/app/cards/card-item/card-item.component.html +++ b/UI/Web/src/app/cards/card-item/card-item.component.html @@ -9,7 +9,7 @@
@if (read > 0 && read < total && total > 0 && read !== total) { -

+

} diff --git a/UI/Web/src/app/cards/chapter-card/chapter-card.component.html b/UI/Web/src/app/cards/chapter-card/chapter-card.component.html index 8a5d55441..9c0009860 100644 --- a/UI/Web/src/app/cards/chapter-card/chapter-card.component.html +++ b/UI/Web/src/app/cards/chapter-card/chapter-card.component.html @@ -9,7 +9,7 @@
@if (chapter.pagesRead > 0 && chapter.pagesRead < chapter.pages && chapter.pages > 0 && chapter.pagesRead !== chapter.pages) { -

+

} @@ -37,7 +37,7 @@
} - @if (chapter.files.length > 1) { + @if (chapter.files.length > 1 && chapter.files[0].format !== MangaFormat.IMAGE) {
{{chapter.files.length}}
diff --git a/UI/Web/src/app/cards/chapter-card/chapter-card.component.ts b/UI/Web/src/app/cards/chapter-card/chapter-card.component.ts index 93fe6b700..aa43a81a6 100644 --- a/UI/Web/src/app/cards/chapter-card/chapter-card.component.ts +++ b/UI/Web/src/app/cards/chapter-card/chapter-card.component.ts @@ -35,6 +35,7 @@ import {ReaderService} from "../../_services/reader.service"; import {LibraryType} from "../../_models/library/library"; import {Device} from "../../_models/device/device"; import {ActionService} from "../../_services/action.service"; +import {MangaFormat} from "../../_models/manga-format"; @Component({ selector: 'app-chapter-card', @@ -49,8 +50,7 @@ import {ActionService} from "../../_services/action.service"; EntityTitleComponent, CardActionablesComponent, RouterLink, - TranslocoDirective, - DefaultValuePipe + TranslocoDirective ], templateUrl: './chapter-card.component.html', styleUrl: './chapter-card.component.scss', @@ -213,4 +213,5 @@ export class ChapterCardComponent implements OnInit { protected readonly LibraryType = LibraryType; + protected readonly MangaFormat = MangaFormat; } diff --git a/UI/Web/src/app/cards/series-card/series-card.component.html b/UI/Web/src/app/cards/series-card/series-card.component.html index 21b071da5..d226353cb 100644 --- a/UI/Web/src/app/cards/series-card/series-card.component.html +++ b/UI/Web/src/app/cards/series-card/series-card.component.html @@ -9,7 +9,7 @@
@if (series.pagesRead > 0 && series.pagesRead < series.pages && series.pages > 0 && series.pagesRead !== series.pages) { -

+

} diff --git a/UI/Web/src/app/cards/volume-card/volume-card.component.html b/UI/Web/src/app/cards/volume-card/volume-card.component.html index 74ba7db98..b89fd19e4 100644 --- a/UI/Web/src/app/cards/volume-card/volume-card.component.html +++ b/UI/Web/src/app/cards/volume-card/volume-card.component.html @@ -9,7 +9,7 @@
@if (volume.pagesRead > 0 && volume.pagesRead < volume.pages && volume.pages > 0 && volume.pagesRead !== volume.pages) { -

+

} diff --git a/UI/Web/src/app/manga-reader/_components/manga-reader/manga-reader.component.html b/UI/Web/src/app/manga-reader/_components/manga-reader/manga-reader.component.html index 733bdd28e..564b7ad72 100644 --- a/UI/Web/src/app/manga-reader/_components/manga-reader/manga-reader.component.html +++ b/UI/Web/src/app/manga-reader/_components/manga-reader/manga-reader.component.html @@ -199,7 +199,9 @@
@@ -216,42 +218,45 @@
  - - + @switch (layoutMode) { + @case (LayoutMode.Single) {
- - - - + + + +
-
- + } + @case (LayoutMode.Double) {
- - - - + + + + - - - + + +
-
- + } + @case (LayoutMode.DoubleReversed) {
- - - - + + + + - - - + + +
-
-
+ } + } +
diff --git a/UI/Web/src/app/manga-reader/_components/manga-reader/manga-reader.component.ts b/UI/Web/src/app/manga-reader/_components/manga-reader/manga-reader.component.ts index ddcedc562..872ee2f6f 100644 --- a/UI/Web/src/app/manga-reader/_components/manga-reader/manga-reader.component.ts +++ b/UI/Web/src/app/manga-reader/_components/manga-reader/manga-reader.component.ts @@ -8,12 +8,11 @@ import { EventEmitter, HostListener, inject, - Inject, OnDestroy, OnInit, ViewChild } from '@angular/core'; -import {AsyncPipe, DOCUMENT, NgClass, NgFor, NgStyle, NgSwitch, NgSwitchCase, PercentPipe} from '@angular/common'; +import {AsyncPipe, NgClass, NgStyle, PercentPipe} from '@angular/common'; import {ActivatedRoute, Router} from '@angular/router'; import { BehaviorSubject, @@ -33,7 +32,7 @@ import { import {ChangeContext, LabelType, NgxSliderModule, Options} from '@angular-slider/ngx-slider'; import {animate, state, style, transition, trigger} from '@angular/animations'; import {FormBuilder, FormControl, FormGroup, ReactiveFormsModule} from '@angular/forms'; -import {NgbModal, NgbProgressbar} from '@ng-bootstrap/ng-bootstrap'; +import {NgbModal} from '@ng-bootstrap/ng-bootstrap'; import {ToastrService} from 'ngx-toastr'; import {ShortcutsModalComponent} from 'src/app/reader-shared/_modals/shortcuts-modal/shortcuts-modal.component'; import {Stack} from 'src/app/shared/data-structures/stack'; @@ -126,7 +125,7 @@ enum KeyDirection { standalone: true, imports: [NgStyle, LoadingComponent, SwipeDirective, CanvasRendererComponent, SingleRendererComponent, DoubleRendererComponent, DoubleReverseRendererComponent, DoubleNoCoverRendererComponent, InfiniteScrollerComponent, - NgxSliderModule, ReactiveFormsModule, NgFor, NgSwitch, NgSwitchCase, FittingIconPipe, ReaderModeIconPipe, + NgxSliderModule, ReactiveFormsModule, FittingIconPipe, ReaderModeIconPipe, FullscreenIconPipe, TranslocoDirective, PercentPipe, NgClass, AsyncPipe, DblClickDirective] }) export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy { @@ -275,7 +274,7 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy { step: 1, boundPointerLabels: true, showSelectionBar: true, - translate: (value: number, label: LabelType) => { + translate: (_: number, label: LabelType) => { if (label == LabelType.Floor) { return 1 + ''; } else if (label === LabelType.Ceil) { @@ -467,7 +466,7 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy { } - constructor(@Inject(DOCUMENT) private document: Document) { + constructor() { this.navService.hideNavBar(); this.navService.hideSideNav(); this.cdRef.markForCheck(); @@ -784,6 +783,17 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy { return pageNum; } + switchToWebtoonReaderIfPagesLikelyWebtoon() { + if (this.readerMode === ReaderMode.Webtoon) return; + + if (this.mangaReaderService.shouldBeWebtoonMode()) { + this.readerMode = ReaderMode.Webtoon; + this.toastr.info(translate('manga-reader.webtoon-override')); + this.readerModeSubject.next(this.readerMode); + this.cdRef.markForCheck(); + } + } + disableDoubleRendererIfScreenTooSmall() { if (window.innerWidth > window.innerHeight) { this.generalSettingsForm.get('layoutMode')?.enable(); @@ -991,6 +1001,7 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy { this.inSetup = false; this.disableDoubleRendererIfScreenTooSmall(); + this.switchToWebtoonReaderIfPagesLikelyWebtoon(); // From bookmarks, create map of pages to make lookup time O(1) diff --git a/UI/Web/src/app/manga-reader/_service/managa-reader.service.ts b/UI/Web/src/app/manga-reader/_service/managa-reader.service.ts index d746eebe1..b623af6b1 100644 --- a/UI/Web/src/app/manga-reader/_service/managa-reader.service.ts +++ b/UI/Web/src/app/manga-reader/_service/managa-reader.service.ts @@ -6,6 +6,7 @@ import { ChapterInfo } from '../_models/chapter-info'; import { DimensionMap } from '../_models/file-dimension'; import { FITTING_OPTION } from '../_models/reader-enums'; import { BookmarkInfo } from 'src/app/_models/manga-reader/bookmark-info'; +import {ReaderMode} from "../../_models/preferences/reader-mode"; @Injectable({ providedIn: 'root' @@ -150,6 +151,35 @@ export class ManagaReaderService { } } + /** + * If the page dimensions are all "webtoon-like", then reader mode will be converted for the user + */ + shouldBeWebtoonMode() { + const pages = Object.values(this.pageDimensions); + + let webtoonScore = 0; + pages.forEach(info => { + const aspectRatio = info.height / info.width; + let score = 0; + + // Strong webtoon indicator: If aspect ratio is at least 2:1 + if (aspectRatio >= 2) { + score += 1; + } + + // Boost score if width is small (≤ 800px, common in webtoons) + if (info.width <= 800) { + score += 0.5; // Adjust weight as needed + } + + webtoonScore += score; + }); + + + // If at least 50% of the pages fit the webtoon criteria, switch to Webtoon mode. + return webtoonScore / pages.length >= 0.5; + } + applyBookmarkEffect(elements: Array) { if (elements.length > 0) { @@ -160,7 +190,4 @@ export class ManagaReaderService { } } - - - } diff --git a/UI/Web/src/app/series-detail/_components/series-detail/series-detail.component.html b/UI/Web/src/app/series-detail/_components/series-detail/series-detail.component.html index 5572e89d1..b957ab658 100644 --- a/UI/Web/src/app/series-detail/_components/series-detail/series-detail.component.html +++ b/UI/Web/src/app/series-detail/_components/series-detail/series-detail.component.html @@ -266,15 +266,19 @@ } - @if (hasRelations || readingLists.length > 0 || collections.length > 0) { + @if (hasRelations || readingLists.length > 0 || collections.length > 0 || bookmarks.length > 0) {
  • {{t(TabID.Related)}} - {{relations.length + readingLists.length + collections.length}} + {{relations.length + readingLists.length + collections.length + (bookmarks.length > 0 ? 1 : 0)}} @defer (when activeTabId === TabID.Related; prefetch on idle) { - + }
  • diff --git a/UI/Web/src/app/series-detail/_components/series-detail/series-detail.component.ts b/UI/Web/src/app/series-detail/_components/series-detail/series-detail.component.ts index d74dc5e94..f88eb2bfc 100644 --- a/UI/Web/src/app/series-detail/_components/series-detail/series-detail.component.ts +++ b/UI/Web/src/app/series-detail/_components/series-detail/series-detail.component.ts @@ -1,11 +1,4 @@ -import { - AsyncPipe, - DOCUMENT, - Location, - NgClass, - NgStyle, - NgTemplateOutlet -} from '@angular/common'; +import {AsyncPipe, DOCUMENT, Location, NgClass, NgStyle, NgTemplateOutlet} from '@angular/common'; import { AfterContentChecked, ChangeDetectionStrategy, @@ -121,7 +114,7 @@ import {UserCollection} from "../../../_models/collection-tag"; import {CoverImageComponent} from "../../../_single-module/cover-image/cover-image.component"; import {DefaultModalOptions} from "../../../_models/default-modal-options"; import {LicenseService} from "../../../_services/license.service"; - +import {PageBookmark} from "../../../_models/readers/page-bookmark"; enum TabID { @@ -233,6 +226,7 @@ export class SeriesDetailComponent implements OnInit, AfterContentChecked { reviews: Array = []; plusReviews: Array = []; + bookmarks: Array = []; ratings: Array = []; libraryType: LibraryType = LibraryType.Manga; seriesMetadata: SeriesMetadata | null = null; @@ -712,7 +706,24 @@ export class SeriesDetailComponent implements OnInit, AfterContentChecked { this.collectionTagService.allCollectionsForSeries(seriesId, false).subscribe(tags => { this.collections = tags; this.cdRef.markForCheck(); - }) + }); + + + this.readerService.getBookmarksForSeries(seriesId).subscribe(bookmarks => { + if (bookmarks.length > 0) { + this.bookmarks = Object.values( + bookmarks.reduce((acc, bookmark) => { + if (!acc[bookmark.seriesId]) { + acc[bookmark.seriesId] = bookmark; // Select the first one per seriesId + } + return acc; + }, {} as Record) + ); + } else { + this.bookmarks = []; + } + this.cdRef.markForCheck(); + }); this.readerService.getTimeLeft(seriesId).subscribe((timeLeft) => { this.readingTimeLeft = timeLeft; diff --git a/UI/Web/src/assets/fonts/Fast_Font/Fast_Sans.woff2 b/UI/Web/src/assets/fonts/Fast_Font/Fast_Sans.woff2 new file mode 100644 index 000000000..cc0a8d06f Binary files /dev/null and b/UI/Web/src/assets/fonts/Fast_Font/Fast_Sans.woff2 differ diff --git a/UI/Web/src/assets/fonts/Fast_Font/Fast_Serif.woff2 b/UI/Web/src/assets/fonts/Fast_Font/Fast_Serif.woff2 new file mode 100644 index 000000000..45f7ccf9e Binary files /dev/null and b/UI/Web/src/assets/fonts/Fast_Font/Fast_Serif.woff2 differ diff --git a/UI/Web/src/assets/langs/en.json b/UI/Web/src/assets/langs/en.json index afa17e68c..0c36019b0 100644 --- a/UI/Web/src/assets/langs/en.json +++ b/UI/Web/src/assets/langs/en.json @@ -1243,7 +1243,8 @@ "related-tab": { "reading-lists-title": "{{reading-lists.title}}", "collections-title": "{{side-nav.collections}}", - "relations-title": "{{tabs.related-tab}}" + "relations-title": "{{tabs.related-tab}}", + "bookmarks-title": "{{side-nav.bookmarks}}" }, "cover-image-chooser": { @@ -2613,7 +2614,8 @@ "bulk-covers": "Refreshing covers on multiple libraries is intensive and can take a long time. Are you sure you want to continue?", "person-image-downloaded": "Person cover was downloaded and applied.", "bulk-delete-libraries": "Are you sure you want to delete {{count}} libraries?", - "match-success": "Series matched correctly" + "match-success": "Series matched correctly", + "webtoon-override": "Switching to Webtoon mode due to images representing a webtoon." }, "read-time-pipe": { diff --git a/openapi.json b/openapi.json index 9016c4dd9..49c14e678 100644 --- a/openapi.json +++ b/openapi.json @@ -2,7 +2,7 @@ "openapi": "3.0.1", "info": { "title": "Kavita", - "description": "Kavita provides a set of APIs that are authenticated by JWT. JWT token can be copied from local storage. Assume all fields of a payload are required. Built against v0.8.4.13", + "description": "Kavita provides a set of APIs that are authenticated by JWT. JWT token can be copied from local storage. Assume all fields of a payload are required. Built against v0.8.4.14", "license": { "name": "GPL-3.0", "url": "https://github.com/Kareadita/Kavita/blob/develop/LICENSE"