mirror of
https://github.com/Kareadita/Kavita.git
synced 2025-05-24 00:52:23 -04:00
Random Fixes (#3549)
Co-authored-by: Robbie Davis <robbie@therobbiedavis.com>
This commit is contained in:
parent
ea81a2f432
commit
39726f8c4d
@ -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);
|
||||
}
|
||||
}
|
||||
|
@ -0,0 +1,5 @@
|
||||
[
|
||||
"Antarctic Press/Plush/Plush v01.cbz",
|
||||
"Antarctic Press/Plush/Plush v02.cbz",
|
||||
"Antarctic Press/Plush/Extra/Plush v03.cbz"
|
||||
]
|
@ -0,0 +1,3 @@
|
||||
[
|
||||
"Immoral Guild/Futoku no Guild v01.cbz"
|
||||
]
|
@ -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"
|
||||
]
|
@ -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,
|
||||
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
@ -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
|
||||
|
@ -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))
|
||||
{
|
||||
|
@ -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);
|
||||
|
@ -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);
|
||||
}
|
||||
|
@ -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();
|
||||
|
@ -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);
|
||||
}
|
||||
|
@ -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>
|
||||
|
@ -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}});
|
||||
}
|
||||
|
||||
}
|
||||
|
@ -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;
|
||||
|
@ -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] {
|
||||
|
@ -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) {
|
||||
|
@ -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>
|
||||
|
@ -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;
|
||||
|
@ -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>
|
||||
}
|
||||
|
@ -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>
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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>
|
||||
}
|
||||
|
@ -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>
|
||||
}
|
||||
|
@ -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>
|
||||
<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">
|
||||
|
@ -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)
|
||||
|
@ -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 {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
}
|
||||
|
@ -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>
|
||||
|
@ -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;
|
||||
|
BIN
UI/Web/src/assets/fonts/Fast_Font/Fast_Sans.woff2
Normal file
BIN
UI/Web/src/assets/fonts/Fast_Font/Fast_Sans.woff2
Normal file
Binary file not shown.
BIN
UI/Web/src/assets/fonts/Fast_Font/Fast_Serif.woff2
Normal file
BIN
UI/Web/src/assets/fonts/Fast_Font/Fast_Serif.woff2
Normal file
Binary file not shown.
@ -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": {
|
||||
|
@ -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"
|
||||
|
Loading…
x
Reference in New Issue
Block a user