Random Fixes (#3549)

Co-authored-by: Robbie Davis <robbie@therobbiedavis.com>
This commit is contained in:
Joe Milazzo 2025-02-15 17:25:18 -06:00 committed by GitHub
parent ea81a2f432
commit 39726f8c4d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
33 changed files with 425 additions and 107 deletions

View File

@ -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<string, ComicInfo>();
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<string, ComicInfo>();
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<string, ComicInfo>();
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<string, ComicInfo>();
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<string, ComicInfo>();
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);
}
}

View File

@ -0,0 +1,5 @@
[
"Antarctic Press/Plush/Plush v01.cbz",
"Antarctic Press/Plush/Plush v02.cbz",
"Antarctic Press/Plush/Extra/Plush v03.cbz"
]

View File

@ -0,0 +1,3 @@
[
"Immoral Guild/Futoku no Guild v01.cbz"
]

View File

@ -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"
]

View File

@ -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,

View File

@ -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());
}
}

View File

@ -7,7 +7,7 @@ namespace API.Entities;
/// <summary>
/// Represents the progress a single user has on a given Chapter.
/// </summary>
public class AppUserProgress
public class AppUserProgress : IEntityDate
{
/// <summary>
/// Id of Entity

View File

@ -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))
{

View File

@ -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);

View File

@ -122,6 +122,7 @@ public class ReaderService : IReaderService
var seenVolume = new Dictionary<int, bool>();
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
/// <param name="user">Must have Progresses populated</param>
/// <param name="chapter"></param>
/// <returns></returns>
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<AppUserProgress>
{
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);
}

View File

@ -674,6 +674,12 @@ public class ParseScannedFiles
private static void RemapSeries(IList<ScanResult> scanResults, List<ParserInfo> 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();

View File

@ -109,6 +109,7 @@ export class ReaderService {
return this.httpClient.post<PageBookmark[]>(this.baseUrl + 'reader/all-bookmarks', filter);
}
getBookmarks(chapterId: number) {
return this.httpClient.get<PageBookmark[]>(this.baseUrl + 'reader/chapter-bookmarks?chapterId=' + chapterId);
}

View File

@ -1,5 +1,5 @@
<ng-container *transloco="let t; read: 'related-tab'">
<div style="padding-bottom: 1rem;">
<div class="pb-2">
@if (relations.length > 0) {
<app-carousel-reel [items]="relations" [title]="t('relations-title')">
<ng-template #carouselItem let-item>
@ -30,5 +30,18 @@
</ng-template>
</app-carousel-reel>
}
@if (bookmarks.length > 0) {
<app-carousel-reel [items]="bookmarks" [title]="t('bookmarks-title')">
<ng-template #carouselItem let-item>
<app-card-item [entity]="item" [title]="t('bookmarks-title')" [imageUrl]="imageService.getSeriesCoverImage(item.seriesId)"
[suppressArchiveWarning]="true"
[linkUrl]="'/library/' + libraryId + '/series/' + item.seriesId + '/manga/0?bookmarkMode=true'"
(clicked)="viewBookmark(item)"
[count]="bookmarks.length"
[allowSelection]="false"></app-card-item>
</ng-template>
</app-carousel-reel>
}
</div>
</ng-container>

View File

@ -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<ReadingList> = [];
@Input() collections: Array<UserCollection> = [];
@Input() relations: Array<RelatedSeriesPair> = [];
@Input() bookmarks: Array<PageBookmark> = [];
@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}});
}
}

View File

@ -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;

View File

@ -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] {

View File

@ -28,7 +28,7 @@ export class BookService {
getFontFamilies(): Array<FontFamily> {
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) {

View File

@ -7,27 +7,29 @@
<h5 subtitle>{{t('series-count', {num: series.length | number})}}</h5>
</app-side-nav-companion-bar>
<app-bulk-operations [actionCallback]="bulkActionCallback"></app-bulk-operations>
<app-card-detail-layout *ngIf="filter"
[isLoading]="loadingBookmarks"
[items]="series"
[filterSettings]="filterSettings"
[trackByIdentity]="trackByIdentity"
[refresh]="refresh"
[jumpBarKeys]="jumpbarKeys"
(applyFilter)="updateFilter($event)"
>
<ng-template #cardItem let-item let-position="idx">
<app-card-item [entity]="item" [title]="item.name" [imageUrl]="imageService.getSeriesCoverImage(item.id)"
[suppressArchiveWarning]="true" (clicked)="viewBookmarks(item)" [count]="seriesIds[item.id]" [allowSelection]="true"
[actions]="actions"
[selected]="bulkSelectionService.isCardSelected('bookmark', position)"
(selection)="bulkSelectionService.handleCardSelection('bookmark', position, series.length, $event)"
></app-card-item>
</ng-template>
@if (filter) {
<app-card-detail-layout
[isLoading]="loadingBookmarks"
[items]="series"
[filterSettings]="filterSettings"
[trackByIdentity]="trackByIdentity"
[refresh]="refresh"
[jumpBarKeys]="jumpbarKeys"
(applyFilter)="updateFilter($event)"
>
<ng-template #cardItem let-item let-position="idx">
<app-card-item [entity]="item" [title]="item.name" [imageUrl]="imageService.getSeriesCoverImage(item.id)"
[suppressArchiveWarning]="true" (clicked)="viewBookmarks(item)" [count]="seriesIds[item.id]" [allowSelection]="true"
[actions]="actions"
[selected]="bulkSelectionService.isCardSelected('bookmark', position)"
(selection)="bulkSelectionService.handleCardSelection('bookmark', position, series.length, $event)"
></app-card-item>
</ng-template>
<ng-template #noData>
{{t('no-data')}} <a [href]="WikiLink.Bookmarks" rel="noopener noreferrer" target="_blank">{{t('no-data-2')}}<i class="fa fa-external-link-alt ms-1" aria-hidden="true"></i></a>
</ng-template>
</app-card-detail-layout>
<ng-template #noData>
{{t('no-data')}} <a [href]="WikiLink.Bookmarks" rel="noopener noreferrer" target="_blank">{{t('no-data-2')}}<i class="fa fa-external-link-alt ms-1" aria-hidden="true"></i></a>
</ng-template>
</app-card-detail-layout>
}
</ng-container>
</div>
</div>

View File

@ -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: 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;

View File

@ -9,7 +9,7 @@
<div class="progress-banner">
@if (read > 0 && read < total && total > 0 && read !== total) {
<p ngbTooltip="{{((read / total) * 100) | number:'1.0-1'}}% Read">
<p ngbTooltip="{{((read / total) * 100) | number:'1.0-1'}}% Read" container="body">
<ngb-progressbar type="primary" height="5px" [value]="read" [max]="total"></ngb-progressbar>
</p>
}

View File

@ -9,7 +9,7 @@
<div class="progress-banner">
@if (chapter.pagesRead > 0 && chapter.pagesRead < chapter.pages && chapter.pages > 0 && chapter.pagesRead !== chapter.pages) {
<p ngbTooltip="{{((chapter.pagesRead / chapter.pages) * 100) | number:'1.0-1'}}% Read">
<p ngbTooltip="{{((chapter.pagesRead / chapter.pages) * 100) | number:'1.0-1'}}% Read" container="body">
<ngb-progressbar type="primary" height="5px" [value]="chapter.pagesRead" [max]="chapter.pages"></ngb-progressbar>
</p>
}
@ -37,7 +37,7 @@
</div>
}
@if (chapter.files.length > 1) {
@if (chapter.files.length > 1 && chapter.files[0].format !== MangaFormat.IMAGE) {
<div class="count">
<span class="badge bg-primary">{{chapter.files.length}}</span>
</div>

View File

@ -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;
}

View File

@ -9,7 +9,7 @@
<div class="progress-banner">
@if (series.pagesRead > 0 && series.pagesRead < series.pages && series.pages > 0 && series.pagesRead !== series.pages) {
<p ngbTooltip="{{((series.pagesRead / series.pages) * 100) | number:'1.0-1'}}%">
<p ngbTooltip="{{((series.pagesRead / series.pages) * 100) | number:'1.0-1'}}%" container="body">
<ngb-progressbar type="primary" height="5px" [value]="series.pagesRead" [max]="series.pages"></ngb-progressbar>
</p>
}

View File

@ -9,7 +9,7 @@
<div class="progress-banner">
@if (volume.pagesRead > 0 && volume.pagesRead < volume.pages && volume.pages > 0 && volume.pagesRead !== volume.pages) {
<p ngbTooltip="{{((volume.pagesRead / volume.pages) * 100) | number:'1.0-1'}}% Read">
<p ngbTooltip="{{((volume.pagesRead / volume.pages) * 100) | number:'1.0-1'}}% Read" container="body">
<ngb-progressbar type="primary" height="5px" [value]="volume.pagesRead" [max]="volume.pages"></ngb-progressbar>
</p>
}

View File

@ -199,7 +199,9 @@
<div class="{{SplitIconClass}}"></div>
</div>
<select class="form-control" id="page-splitting" formControlName="pageSplitOption">
<option *ngFor="let opt of pageSplitOptionsTranslated" [value]="opt.value">{{opt.text}}</option>
@for (opt of pageSplitOptionsTranslated; track opt.value) {
<option [value]="opt.value">{{opt.text}}</option>
}
</select>
</div>
@ -216,42 +218,45 @@
<div class="row mb-2">
<div class="col-md-6 col-sm-12">
<label for="layout-mode" class="form-label">Layout Mode</label>&nbsp;
<ng-container [ngSwitch]="layoutMode">
<ng-container *ngSwitchCase="LayoutMode.Single">
@switch (layoutMode) {
@case (LayoutMode.Single) {
<div class="split-double">
<span class="fa-stack fa-1x">
<i class="fa-regular fa-square-full fa-stack-2x"></i>
<i class="fa fa-image fa-stack-1x"></i>
</span>
<span class="fa-stack fa-1x">
<i class="fa-regular fa-square-full fa-stack-2x"></i>
<i class="fa fa-image fa-stack-1x"></i>
</span>
</div>
</ng-container>
<ng-container *ngSwitchCase="LayoutMode.Double">
}
@case (LayoutMode.Double) {
<div class="split-double">
<span class="fa-stack fa-1x">
<i class="fa-regular fa-square-full fa-stack-2x"></i>
<i class="fab fa-1 fa-stack-1x"></i>
</span>
<span class="fa-stack fa-1x">
<i class="fa-regular fa-square-full fa-stack-2x"></i>
<i class="fab fa-1 fa-stack-1x"></i>
</span>
<span class="fa-stack fa right">
<i class="fa-regular fa-square-full fa-stack-2x"></i>
<i class="fab fa-2 fa-stack-1x"></i>
</span>
<i class="fa-regular fa-square-full fa-stack-2x"></i>
<i class="fab fa-2 fa-stack-1x"></i>
</span>
</div>
</ng-container>
<ng-container *ngSwitchCase="LayoutMode.DoubleReversed">
}
@case (LayoutMode.DoubleReversed) {
<div class="split-double">
<span class="fa-stack fa-1x">
<i class="fa-regular fa-square-full fa-stack-2x"></i>
<i class="fab fa-2 fa-stack-1x"></i>
</span>
<span class="fa-stack fa-1x">
<i class="fa-regular fa-square-full fa-stack-2x"></i>
<i class="fab fa-2 fa-stack-1x"></i>
</span>
<span class="fa-stack fa right">
<i class="fa-regular fa-square-full fa-stack-2x"></i>
<i class="fab fa-1 fa-stack-1x"></i>
</span>
<i class="fa-regular fa-square-full fa-stack-2x"></i>
<i class="fab fa-1 fa-stack-1x"></i>
</span>
</div>
</ng-container>
</ng-container>
}
}
<select class="form-control" id="layout-mode" formControlName="layoutMode">
<option [value]="opt.value" *ngFor="let opt of layoutModesTranslated">{{opt.text}}</option>
@for (opt of layoutModesTranslated; track opt.value) {
<option [value]="opt.value">{{opt.text}}</option>
}
</select>
</div>
<div class="col-md-3 col-sm-12">

View File

@ -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)

View File

@ -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<Element | ElementRef>) {
if (elements.length > 0) {
@ -160,7 +190,4 @@ export class ManagaReaderService {
}
}
}

View File

@ -266,15 +266,19 @@
</li>
}
@if (hasRelations || readingLists.length > 0 || collections.length > 0) {
@if (hasRelations || readingLists.length > 0 || collections.length > 0 || bookmarks.length > 0) {
<li [ngbNavItem]="TabID.Related">
<a ngbNavLink>
{{t(TabID.Related)}}
<span class="badge rounded-pill text-bg-secondary">{{relations.length + readingLists.length + collections.length}}</span>
<span class="badge rounded-pill text-bg-secondary">{{relations.length + readingLists.length + collections.length + (bookmarks.length > 0 ? 1 : 0)}}</span>
</a>
<ng-template ngbNavContent>
@defer (when activeTabId === TabID.Related; prefetch on idle) {
<app-related-tab [readingLists]="readingLists" [collections]="collections" [relations]="relations"></app-related-tab>
<app-related-tab [readingLists]="readingLists"
[collections]="collections"
[relations]="relations"
[libraryId]="libraryId"
[bookmarks]="bookmarks"></app-related-tab>
}
</ng-template>
</li>

View File

@ -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<UserReview> = [];
plusReviews: Array<UserReview> = [];
bookmarks: Array<PageBookmark> = [];
ratings: Array<Rating> = [];
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<number, PageBookmark>)
);
} else {
this.bookmarks = [];
}
this.cdRef.markForCheck();
});
this.readerService.getTimeLeft(seriesId).subscribe((timeLeft) => {
this.readingTimeLeft = timeLeft;

Binary file not shown.

Binary file not shown.

View File

@ -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": {

View File

@ -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"