mirror of
https://github.com/Kareadita/Kavita.git
synced 2025-07-09 03:04:19 -04:00
PDF Metadata Support (#3552)
Co-authored-by: Matthias Neeracher <microtherion@gmail.com>
This commit is contained in:
parent
56108eb373
commit
f76de42b28
@ -81,4 +81,47 @@ public class BookServiceTests
|
|||||||
Assert.Equal("Accel World", comicInfo.Series);
|
Assert.Equal("Accel World", comicInfo.Series);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ShouldHaveComicInfoForPdf()
|
||||||
|
{
|
||||||
|
var testDirectory = Path.Join(Directory.GetCurrentDirectory(), "../../../Services/Test Data/BookService");
|
||||||
|
var document = Path.Join(testDirectory, "test.pdf");
|
||||||
|
var comicInfo = _bookService.GetComicInfo(document);
|
||||||
|
Assert.NotNull(comicInfo);
|
||||||
|
Assert.Equal("Variations Chromatiques de concert", comicInfo.Title);
|
||||||
|
Assert.Equal("Georges Bizet \\(1838-1875\\)", comicInfo.Writer);
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: Get the file from microtherion
|
||||||
|
// [Fact]
|
||||||
|
// public void ShouldUsePdfInfoDict()
|
||||||
|
// {
|
||||||
|
// var testDirectory = Path.Join(Directory.GetCurrentDirectory(), "../../../Services/Test Data/ScannerService/Library/Books/PDFs");
|
||||||
|
// var document = Path.Join(testDirectory, "Rollo at Work SP01.pdf");
|
||||||
|
// var comicInfo = _bookService.GetComicInfo(document);
|
||||||
|
// Assert.NotNull(comicInfo);
|
||||||
|
// Assert.Equal("Rollo at Work", comicInfo.Title);
|
||||||
|
// Assert.Equal("Jacob Abbott", comicInfo.Writer);
|
||||||
|
// Assert.Equal(2008, comicInfo.Year);
|
||||||
|
// }
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ShouldHandleIndirectPdfObjects()
|
||||||
|
{
|
||||||
|
var testDirectory = Path.Join(Directory.GetCurrentDirectory(), "../../../Services/Test Data/BookService");
|
||||||
|
var document = Path.Join(testDirectory, "indirect.pdf");
|
||||||
|
var comicInfo = _bookService.GetComicInfo(document);
|
||||||
|
Assert.NotNull(comicInfo);
|
||||||
|
Assert.Equal(2018, comicInfo.Year);
|
||||||
|
Assert.Equal(8, comicInfo.Month);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void FailGracefullyWithEncryptedPdf()
|
||||||
|
{
|
||||||
|
var testDirectory = Path.Join(Directory.GetCurrentDirectory(), "../../../Services/Test Data/BookService");
|
||||||
|
var document = Path.Join(testDirectory, "encrypted.pdf");
|
||||||
|
var comicInfo = _bookService.GetComicInfo(document);
|
||||||
|
Assert.Null(comicInfo);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -101,7 +101,22 @@ public class ScannerServiceTests : AbstractDbTest
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task ScanLibrary_FlatSeriesWithSpecialFolder()
|
public async Task ScanLibrary_FlatSeriesWithSpecialFolder()
|
||||||
{
|
{
|
||||||
var testcase = "Flat Series with Specials Folder - Manga.json";
|
var testcase = "Flat Series with Specials Folder Alt Naming - Manga.json";
|
||||||
|
var library = await _scannerHelper.GenerateScannerData(testcase);
|
||||||
|
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);
|
||||||
|
Assert.Equal(4, postLib.Series.First().Volumes.Count);
|
||||||
|
Assert.NotNull(postLib.Series.First().Volumes.FirstOrDefault(v => v.Chapters.FirstOrDefault(c => c.IsSpecial) != null));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ScanLibrary_FlatSeriesWithSpecialFolder_AlternativeNaming()
|
||||||
|
{
|
||||||
|
var testcase = "Flat Series with Specials Folder Alt Naming - Manga.json";
|
||||||
var library = await _scannerHelper.GenerateScannerData(testcase);
|
var library = await _scannerHelper.GenerateScannerData(testcase);
|
||||||
var scanner = _scannerHelper.CreateServices();
|
var scanner = _scannerHelper.CreateServices();
|
||||||
await scanner.ScanLibrary(library.Id);
|
await scanner.ScanLibrary(library.Id);
|
||||||
|
BIN
API.Tests/Services/Test Data/BookService/encrypted.pdf
Normal file
BIN
API.Tests/Services/Test Data/BookService/encrypted.pdf
Normal file
Binary file not shown.
BIN
API.Tests/Services/Test Data/BookService/indirect.pdf
Normal file
BIN
API.Tests/Services/Test Data/BookService/indirect.pdf
Normal file
Binary file not shown.
@ -0,0 +1,6 @@
|
|||||||
|
[
|
||||||
|
"My Dress-Up Darling/My Dress-Up Darling v01.cbz",
|
||||||
|
"My Dress-Up Darling/My Dress-Up Darling v02.cbz",
|
||||||
|
"My Dress-Up Darling/My Dress-Up Darling ch 10.cbz",
|
||||||
|
"My Dress-Up Darling/Specials/My Dress-Up Darling - Omakes SP01.cbz"
|
||||||
|
]
|
159
API/Helpers/PdfComicInfoExtractor.cs
Normal file
159
API/Helpers/PdfComicInfoExtractor.cs
Normal file
@ -0,0 +1,159 @@
|
|||||||
|
/// Translate PDF metadata (See PdfMetadataExtractor.cs) into ComicInfo structure.
|
||||||
|
|
||||||
|
// Contributed by https://github.com/microtherion
|
||||||
|
|
||||||
|
// All references to the "PDF Spec" (section numbers, etc) refer to the
|
||||||
|
// PDF 1.7 Specification a.k.a. PDF32000-1:2008
|
||||||
|
// https://opensource.adobe.com/dc-acrobat-sdk-docs/pdfstandards/PDF32000_2008.pdf
|
||||||
|
|
||||||
|
using System;
|
||||||
|
using System.Xml;
|
||||||
|
using System.Text;
|
||||||
|
using System.IO;
|
||||||
|
using System.Diagnostics;
|
||||||
|
using API.Data.Metadata;
|
||||||
|
using API.Entities.Enums;
|
||||||
|
using API.Services;
|
||||||
|
using API.Services.Tasks.Scanner.Parser;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using Nager.ArticleNumber;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
|
||||||
|
namespace API.Helpers;
|
||||||
|
#nullable enable
|
||||||
|
|
||||||
|
public interface IPdfComicInfoExtractor
|
||||||
|
{
|
||||||
|
ComicInfo? GetComicInfo(string filePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
public class PdfComicInfoExtractor : IPdfComicInfoExtractor
|
||||||
|
{
|
||||||
|
private readonly ILogger<BookService> _logger;
|
||||||
|
private readonly IMediaErrorService _mediaErrorService;
|
||||||
|
private readonly string[] _pdfDateFormats = [ // PDF Spec 7.9.4
|
||||||
|
"D:yyyyMMddHHmmsszzz:", "D:yyyyMMddHHmmss+", "D:yyyyMMddHHmmss",
|
||||||
|
"D:yyyyMMddHHmmzzz:", "D:yyyyMMddHHmm+", "D:yyyyMMddHHmm",
|
||||||
|
"D:yyyyMMddHHzzz:", "D:yyyyMMddHH+", "D:yyyyMMddHH",
|
||||||
|
"D:yyyyMMdd", "D:yyyyMM", "D:yyyy"
|
||||||
|
];
|
||||||
|
|
||||||
|
public PdfComicInfoExtractor(ILogger<BookService> logger, IMediaErrorService mediaErrorService)
|
||||||
|
{
|
||||||
|
_logger = logger;
|
||||||
|
_mediaErrorService = mediaErrorService;
|
||||||
|
}
|
||||||
|
|
||||||
|
private float? GetFloatFromText(string? text)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(text)) return null;
|
||||||
|
|
||||||
|
if (float.TryParse(text, out var value)) return value;
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private DateTime? GetDateTimeFromText(string? text)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(text)) return null;
|
||||||
|
|
||||||
|
// Dates stored in the XMP metadata stream (PDF Spec 14.3.2)
|
||||||
|
// are stored in ISO 8601 format, which is handled by C# out of the box
|
||||||
|
if (DateTime.TryParse(text, out var date)) return date;
|
||||||
|
|
||||||
|
// Dates stored in the document information directory (PDF Spec 14.3.3)
|
||||||
|
// are stored in a proprietary format (PDF Spec 7.9.4) that needs to be
|
||||||
|
// massaged slightly to be expressible by a DateTime format.
|
||||||
|
if (text[0] != 'D') {
|
||||||
|
text = "D:" + text;
|
||||||
|
}
|
||||||
|
text = text.Replace("'", ":");
|
||||||
|
text = text.Replace("Z", "+");
|
||||||
|
|
||||||
|
foreach(var format in _pdfDateFormats)
|
||||||
|
{
|
||||||
|
if (DateTime.TryParseExact(text, format, null, System.Globalization.DateTimeStyles.None, out var pdfDate)) return pdfDate;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private string? MaybeGetMetadata(Dictionary<string, string> metadata, string key)
|
||||||
|
{
|
||||||
|
return metadata.ContainsKey(key) ? metadata[key] : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private ComicInfo? GetComicInfoFromMetadata(Dictionary<string, string> metadata, string filePath)
|
||||||
|
{
|
||||||
|
var info = new ComicInfo();
|
||||||
|
|
||||||
|
var publicationDate = GetDateTimeFromText(MaybeGetMetadata(metadata, "CreationDate"));
|
||||||
|
|
||||||
|
if (publicationDate != null)
|
||||||
|
{
|
||||||
|
info.Year = publicationDate.Value.Year;
|
||||||
|
info.Month = publicationDate.Value.Month;
|
||||||
|
info.Day = publicationDate.Value.Day;
|
||||||
|
}
|
||||||
|
|
||||||
|
info.Summary = MaybeGetMetadata(metadata, "Summary") ?? string.Empty;
|
||||||
|
info.Publisher = MaybeGetMetadata(metadata, "Publisher") ?? string.Empty;
|
||||||
|
info.Writer = MaybeGetMetadata(metadata, "Author") ?? string.Empty;
|
||||||
|
info.Title = MaybeGetMetadata(metadata, "Title") ?? string.Empty;
|
||||||
|
info.Genre = MaybeGetMetadata(metadata, "Subject") ?? string.Empty;
|
||||||
|
info.LanguageISO = BookService.ValidateLanguage(MaybeGetMetadata(metadata, "Language"));
|
||||||
|
info.Isbn = MaybeGetMetadata(metadata, "ISBN") ?? string.Empty;
|
||||||
|
|
||||||
|
if (info.Isbn != string.Empty && !ArticleNumberHelper.IsValidIsbn10(info.Isbn) && !ArticleNumberHelper.IsValidIsbn13(info.Isbn))
|
||||||
|
{
|
||||||
|
_logger.LogDebug("[BookService] {File} has an invalid ISBN number", filePath);
|
||||||
|
info.Isbn = string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
info.UserRating = GetFloatFromText(MaybeGetMetadata(metadata, "UserRating")) ?? 0.0f;
|
||||||
|
info.TitleSort = MaybeGetMetadata(metadata, "TitleSort") ?? string.Empty;
|
||||||
|
info.Series = MaybeGetMetadata(metadata, "Series") ?? info.TitleSort;
|
||||||
|
info.SeriesSort = info.Series;
|
||||||
|
info.Volume = (GetFloatFromText(MaybeGetMetadata(metadata, "Volume")) ?? 0.0f).ToString();
|
||||||
|
|
||||||
|
// If this is a single book and not a collection, set publication status to Completed
|
||||||
|
if (string.IsNullOrEmpty(info.Volume) && Parser.ParseVolume(filePath, LibraryType.Manga).Equals(Parser.LooseLeafVolume))
|
||||||
|
{
|
||||||
|
info.Count = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Removed as probably unneeded per discussion in https://github.com/Kareadita/Kavita/pull/3108#discussion_r1956747782
|
||||||
|
//
|
||||||
|
// var hasVolumeInSeries = !Parser.ParseVolume(info.Title, LibraryType.Manga)
|
||||||
|
// .Equals(Parser.LooseLeafVolume);
|
||||||
|
|
||||||
|
// if (string.IsNullOrEmpty(info.Volume) && hasVolumeInSeries && (!info.Series.Equals(info.Title) || string.IsNullOrEmpty(info.Series)))
|
||||||
|
// {
|
||||||
|
// // This is likely a light novel for which we can set series from parsed title
|
||||||
|
// info.Series = Parser.ParseSeries(info.Title, LibraryType.Manga);
|
||||||
|
// info.Volume = Parser.ParseVolume(info.Title, LibraryType.Manga);
|
||||||
|
// }
|
||||||
|
|
||||||
|
ComicInfo.CleanComicInfo(info);
|
||||||
|
|
||||||
|
return info;
|
||||||
|
}
|
||||||
|
|
||||||
|
public ComicInfo? GetComicInfo(string filePath)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var extractor = new PdfMetadataExtractor(_logger, filePath);
|
||||||
|
|
||||||
|
return GetComicInfoFromMetadata(extractor.GetMetadata(), filePath);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex, "[GetComicInfo] There was an exception parsing PDF metadata for {File}", filePath);
|
||||||
|
_mediaErrorService.ReportMediaIssue(filePath, MediaErrorProducer.BookService,
|
||||||
|
"There was an exception parsing PDF metadata", ex);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
1660
API/Helpers/PdfMetadataExtractor.cs
Normal file
1660
API/Helpers/PdfMetadataExtractor.cs
Normal file
File diff suppressed because it is too large
Load Diff
@ -6,12 +6,14 @@ using System.Linq;
|
|||||||
using System.Text;
|
using System.Text;
|
||||||
using System.Text.RegularExpressions;
|
using System.Text.RegularExpressions;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
|
using System.Xml;
|
||||||
using API.Data.Metadata;
|
using API.Data.Metadata;
|
||||||
using API.DTOs.Reader;
|
using API.DTOs.Reader;
|
||||||
using API.Entities;
|
using API.Entities;
|
||||||
using API.Entities.Enums;
|
using API.Entities.Enums;
|
||||||
using API.Extensions;
|
using API.Extensions;
|
||||||
using API.Services.Tasks.Scanner.Parser;
|
using API.Services.Tasks.Scanner.Parser;
|
||||||
|
using API.Helpers;
|
||||||
using Docnet.Core;
|
using Docnet.Core;
|
||||||
using Docnet.Core.Converters;
|
using Docnet.Core.Converters;
|
||||||
using Docnet.Core.Models;
|
using Docnet.Core.Models;
|
||||||
@ -69,6 +71,8 @@ public class BookService : IBookService
|
|||||||
private static readonly RecyclableMemoryStreamManager StreamManager = new ();
|
private static readonly RecyclableMemoryStreamManager StreamManager = new ();
|
||||||
private const string CssScopeClass = ".book-content";
|
private const string CssScopeClass = ".book-content";
|
||||||
private const string BookApiUrl = "book-resources?file=";
|
private const string BookApiUrl = "book-resources?file=";
|
||||||
|
private readonly PdfComicInfoExtractor _pdfComicInfoExtractor;
|
||||||
|
|
||||||
public static readonly EpubReaderOptions BookReaderOptions = new()
|
public static readonly EpubReaderOptions BookReaderOptions = new()
|
||||||
{
|
{
|
||||||
PackageReaderOptions = new PackageReaderOptions
|
PackageReaderOptions = new PackageReaderOptions
|
||||||
@ -84,6 +88,7 @@ public class BookService : IBookService
|
|||||||
_directoryService = directoryService;
|
_directoryService = directoryService;
|
||||||
_imageService = imageService;
|
_imageService = imageService;
|
||||||
_mediaErrorService = mediaErrorService;
|
_mediaErrorService = mediaErrorService;
|
||||||
|
_pdfComicInfoExtractor = new PdfComicInfoExtractor(_logger, _mediaErrorService);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static bool HasClickableHrefPart(HtmlNode anchor)
|
private static bool HasClickableHrefPart(HtmlNode anchor)
|
||||||
@ -425,10 +430,8 @@ public class BookService : IBookService
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public ComicInfo? GetComicInfo(string filePath)
|
private ComicInfo? GetEpubComicInfo(string filePath)
|
||||||
{
|
{
|
||||||
if (!IsValidFile(filePath) || Parser.IsPdf(filePath)) return null;
|
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
using var epubBook = EpubReader.OpenBook(filePath, BookReaderOptions);
|
using var epubBook = EpubReader.OpenBook(filePath, BookReaderOptions);
|
||||||
@ -442,7 +445,7 @@ public class BookService : IBookService
|
|||||||
var (year, month, day) = GetPublicationDate(publicationDate);
|
var (year, month, day) = GetPublicationDate(publicationDate);
|
||||||
|
|
||||||
var summary = epubBook.Schema.Package.Metadata.Descriptions.FirstOrDefault();
|
var summary = epubBook.Schema.Package.Metadata.Descriptions.FirstOrDefault();
|
||||||
var info = new ComicInfo
|
var info = new ComicInfo
|
||||||
{
|
{
|
||||||
Summary = string.IsNullOrEmpty(summary?.Description) ? string.Empty : summary.Description,
|
Summary = string.IsNullOrEmpty(summary?.Description) ? string.Empty : summary.Description,
|
||||||
Publisher = string.Join(",", epubBook.Schema.Package.Metadata.Publishers.Select(p => p.Publisher)),
|
Publisher = string.Join(",", epubBook.Schema.Package.Metadata.Publishers.Select(p => p.Publisher)),
|
||||||
@ -583,6 +586,20 @@ public class BookService : IBookService
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public ComicInfo? GetComicInfo(string filePath)
|
||||||
|
{
|
||||||
|
if (!IsValidFile(filePath)) return null;
|
||||||
|
|
||||||
|
if (Parser.IsPdf(filePath))
|
||||||
|
{
|
||||||
|
return _pdfComicInfoExtractor.GetComicInfo(filePath);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
return GetEpubComicInfo(filePath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private static void ExtractSortTitle(EpubMetadataMeta metadataItem, EpubBookRef epubBook, ComicInfo info)
|
private static void ExtractSortTitle(EpubMetadataMeta metadataItem, EpubBookRef epubBook, ComicInfo info)
|
||||||
{
|
{
|
||||||
var titleId = metadataItem.Refines?.Replace("#", string.Empty);
|
var titleId = metadataItem.Refines?.Replace("#", string.Empty);
|
||||||
@ -685,7 +702,7 @@ public class BookService : IBookService
|
|||||||
return (year, month, day);
|
return (year, month, day);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string ValidateLanguage(string? language)
|
public static string ValidateLanguage(string? language)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrEmpty(language)) return string.Empty;
|
if (string.IsNullOrEmpty(language)) return string.Empty;
|
||||||
|
|
||||||
|
@ -566,7 +566,6 @@ public class ExternalMetadataService : IExternalMetadataService
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
var relatedSeriesDict = new Dictionary<int, Series>();
|
|
||||||
foreach (var relation in externalMetadataRelations)
|
foreach (var relation in externalMetadataRelations)
|
||||||
{
|
{
|
||||||
var names = new [] {relation.SeriesName.PreferredTitle, relation.SeriesName.RomajiTitle, relation.SeriesName.EnglishTitle, relation.SeriesName.NativeTitle};
|
var names = new [] {relation.SeriesName.PreferredTitle, relation.SeriesName.RomajiTitle, relation.SeriesName.EnglishTitle, relation.SeriesName.NativeTitle};
|
||||||
@ -586,19 +585,6 @@ public class ExternalMetadataService : IExternalMetadataService
|
|||||||
|
|
||||||
if (relationshipExists) continue;
|
if (relationshipExists) continue;
|
||||||
|
|
||||||
relatedSeriesDict[relatedSeries.Id] = relatedSeries;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Process relationships
|
|
||||||
foreach (var relation in externalMetadataRelations)
|
|
||||||
{
|
|
||||||
var relatedSeries = relatedSeriesDict.GetValueOrDefault(
|
|
||||||
relatedSeriesDict.Keys.FirstOrDefault(k =>
|
|
||||||
relatedSeriesDict[k].Name == relation.SeriesName.PreferredTitle ||
|
|
||||||
relatedSeriesDict[k].Name == relation.SeriesName.NativeTitle));
|
|
||||||
|
|
||||||
if (relatedSeries == null) continue;
|
|
||||||
|
|
||||||
// Add new relationship
|
// Add new relationship
|
||||||
var newRelation = new SeriesRelation
|
var newRelation = new SeriesRelation
|
||||||
{
|
{
|
||||||
@ -969,7 +955,7 @@ public class ExternalMetadataService : IExternalMetadataService
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!string.IsNullOrEmpty(externalMetadata.CoverUrl) && !settings.HasOverride(MetadataSettingField.Covers))
|
if (string.IsNullOrEmpty(externalMetadata.CoverUrl))
|
||||||
{
|
{
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
@ -52,7 +52,7 @@ public class ReadingItemService : IReadingItemService
|
|||||||
/// <returns></returns>
|
/// <returns></returns>
|
||||||
private ComicInfo? GetComicInfo(string filePath)
|
private ComicInfo? GetComicInfo(string filePath)
|
||||||
{
|
{
|
||||||
if (Parser.IsEpub(filePath))
|
if (Parser.IsEpub(filePath) || Parser.IsPdf(filePath))
|
||||||
{
|
{
|
||||||
return _bookService.GetComicInfo(filePath);
|
return _bookService.GetComicInfo(filePath);
|
||||||
}
|
}
|
||||||
|
@ -68,6 +68,9 @@ public class PdfParser(IDirectoryService directoryService) : DefaultParser(direc
|
|||||||
ParseFromFallbackFolders(filePath, tempRootPath, type, ref ret);
|
ParseFromFallbackFolders(filePath, tempRootPath, type, ref ret);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Patch in other information from ComicInfo
|
||||||
|
UpdateFromComicInfo(ret);
|
||||||
|
|
||||||
if (ret.Chapters == Parser.DefaultChapter && ret.Volumes == Parser.LooseLeafVolume && type == LibraryType.Book)
|
if (ret.Chapters == Parser.DefaultChapter && ret.Volumes == Parser.LooseLeafVolume && type == LibraryType.Book)
|
||||||
{
|
{
|
||||||
ret.IsSpecial = true;
|
ret.IsSpecial = true;
|
||||||
|
@ -285,7 +285,7 @@ public class ProcessSeries : IProcessSeries
|
|||||||
var firstChapter = SeriesService.GetFirstChapterForMetadata(series);
|
var firstChapter = SeriesService.GetFirstChapterForMetadata(series);
|
||||||
|
|
||||||
var firstFile = firstChapter?.Files.FirstOrDefault();
|
var firstFile = firstChapter?.Files.FirstOrDefault();
|
||||||
if (firstFile == null || Parser.Parser.IsPdf(firstFile.FilePath)) return;
|
if (firstFile == null) return;
|
||||||
|
|
||||||
var chapters = series.Volumes
|
var chapters = series.Volumes
|
||||||
.SelectMany(volume => volume.Chapters)
|
.SelectMany(volume => volume.Chapters)
|
||||||
|
@ -2,7 +2,7 @@
|
|||||||
"TokenKey": "super secret unguessable key that is longer because we require it",
|
"TokenKey": "super secret unguessable key that is longer because we require it",
|
||||||
"Port": 5000,
|
"Port": 5000,
|
||||||
"IpAddresses": "0.0.0.0,::",
|
"IpAddresses": "0.0.0.0,::",
|
||||||
"BaseUrl": "/test/",
|
"BaseUrl": "/",
|
||||||
"Cache": 75,
|
"Cache": 75,
|
||||||
"AllowIFraming": false
|
"AllowIFraming": false
|
||||||
}
|
}
|
@ -17,13 +17,13 @@
|
|||||||
@if (settingsForm.get('hostName'); as formControl) {
|
@if (settingsForm.get('hostName'); as formControl) {
|
||||||
<app-setting-item [title]="t('host-name-label')" [subtitle]="t('host-name-tooltip')">
|
<app-setting-item [title]="t('host-name-label')" [subtitle]="t('host-name-tooltip')">
|
||||||
<ng-template #view>
|
<ng-template #view>
|
||||||
{{formControl.value}}
|
{{formControl.value | defaultValue}}
|
||||||
</ng-template>
|
</ng-template>
|
||||||
<ng-template #edit>
|
<ng-template #edit>
|
||||||
<input id="settings-hostname" aria-describedby="settings-hostname-help" class="form-control" formControlName="hostName" type="text"
|
<input id="settings-hostname" aria-describedby="settings-hostname-help" class="form-control" formControlName="hostName" type="text"
|
||||||
[class.is-invalid]="formControl.invalid && formControl.touched">
|
[class.is-invalid]="formControl.invalid && !formControl.untouched">
|
||||||
|
|
||||||
@if(settingsForm.dirty || settingsForm.touched) {
|
@if(settingsForm.dirty || !settingsForm.untouched) {
|
||||||
<div id="hostname-validations" class="invalid-feedback">
|
<div id="hostname-validations" class="invalid-feedback">
|
||||||
@if (formControl.errors?.pattern) {
|
@if (formControl.errors?.pattern) {
|
||||||
<div>{{t('host-name-validation')}}</div>
|
<div>{{t('host-name-validation')}}</div>
|
||||||
@ -44,11 +44,11 @@
|
|||||||
<ng-template #edit>
|
<ng-template #edit>
|
||||||
<div class="input-group">
|
<div class="input-group">
|
||||||
<input id="settings-baseurl" aria-describedby="settings-baseurl-help" class="form-control" formControlName="baseUrl" type="text"
|
<input id="settings-baseurl" aria-describedby="settings-baseurl-help" class="form-control" formControlName="baseUrl" type="text"
|
||||||
[class.is-invalid]="formControl.invalid && formControl.touched">
|
[class.is-invalid]="formControl.invalid && !formControl.untouched">
|
||||||
<button type="button" class="btn btn-outline-secondary" (click)="resetBaseUrl()">{{t('reset')}}</button>
|
<button type="button" class="btn btn-outline-secondary" (click)="resetBaseUrl()">{{t('reset')}}</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@if(settingsForm.dirty || settingsForm.touched) {
|
@if(settingsForm.dirty || !settingsForm.untouched) {
|
||||||
<div id="baseurl-validations" class="invalid-feedback">
|
<div id="baseurl-validations" class="invalid-feedback">
|
||||||
@if (formControl.errors?.pattern) {
|
@if (formControl.errors?.pattern) {
|
||||||
<div>{{t('base-url-validation')}}</div>
|
<div>{{t('base-url-validation')}}</div>
|
||||||
@ -69,11 +69,11 @@
|
|||||||
<ng-template #edit>
|
<ng-template #edit>
|
||||||
<div class="input-group">
|
<div class="input-group">
|
||||||
<input id="settings-ipaddresses" aria-describedby="settings-ipaddresses-help" class="form-control" formControlName="ipAddresses" type="text"
|
<input id="settings-ipaddresses" aria-describedby="settings-ipaddresses-help" class="form-control" formControlName="ipAddresses" type="text"
|
||||||
[class.is-invalid]="formControl.invalid && formControl.touched">
|
[class.is-invalid]="formControl.invalid && !formControl.untouched">
|
||||||
<button type="button" class="btn btn-outline-secondary" (click)="resetIPAddresses()">{{t('reset')}}</button>
|
<button type="button" class="btn btn-outline-secondary" (click)="resetIPAddresses()">{{t('reset')}}</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@if(settingsForm.dirty || settingsForm.touched) {
|
@if(settingsForm.dirty || !settingsForm.untouched) {
|
||||||
<div id="ipaddresses-validations" class="invalid-feedback">
|
<div id="ipaddresses-validations" class="invalid-feedback">
|
||||||
@if (formControl.errors?.pattern) {
|
@if (formControl.errors?.pattern) {
|
||||||
<div>{{t('ip-address-validation')}}</div>
|
<div>{{t('ip-address-validation')}}</div>
|
||||||
@ -116,9 +116,9 @@
|
|||||||
<input id="settings-backup" aria-describedby="total-backups-validations" class="form-control"
|
<input id="settings-backup" aria-describedby="total-backups-validations" class="form-control"
|
||||||
formControlName="totalBackups" type="number" inputmode="numeric" step="1" min="1" max="30"
|
formControlName="totalBackups" type="number" inputmode="numeric" step="1" min="1" max="30"
|
||||||
onkeypress="return event.charCode >= 48 && event.charCode <= 57"
|
onkeypress="return event.charCode >= 48 && event.charCode <= 57"
|
||||||
[class.is-invalid]="formControl.invalid && formControl.touched">
|
[class.is-invalid]="formControl.invalid && !formControl.untouched">
|
||||||
|
|
||||||
@if(settingsForm.dirty || settingsForm.touched) {
|
@if(settingsForm.dirty || !settingsForm.untouched) {
|
||||||
<div id="total-backups-validations" class="invalid-feedback">
|
<div id="total-backups-validations" class="invalid-feedback">
|
||||||
@if (formControl.errors?.required) {
|
@if (formControl.errors?.required) {
|
||||||
<div>{{t('field-required')}}</div>
|
<div>{{t('field-required')}}</div>
|
||||||
@ -146,9 +146,9 @@
|
|||||||
<input id="settings-logs" aria-describedby="total-logs-validations" class="form-control"
|
<input id="settings-logs" aria-describedby="total-logs-validations" class="form-control"
|
||||||
formControlName="totalLogs" type="number" inputmode="numeric" step="1" min="1" max="30"
|
formControlName="totalLogs" type="number" inputmode="numeric" step="1" min="1" max="30"
|
||||||
onkeypress="return event.charCode >= 48 && event.charCode <= 57"
|
onkeypress="return event.charCode >= 48 && event.charCode <= 57"
|
||||||
[class.is-invalid]="formControl.invalid && formControl.touched">
|
[class.is-invalid]="formControl.invalid && !formControl.untouched">
|
||||||
|
|
||||||
@if(settingsForm.dirty || settingsForm.touched) {
|
@if(settingsForm.dirty || !settingsForm.untouched) {
|
||||||
<div id="total-logs-validations" class="invalid-feedback">
|
<div id="total-logs-validations" class="invalid-feedback">
|
||||||
@if (formControl.errors?.required) {
|
@if (formControl.errors?.required) {
|
||||||
<div>{{t('field-required')}}</div>
|
<div>{{t('field-required')}}</div>
|
||||||
@ -175,13 +175,13 @@
|
|||||||
<ng-template #edit>
|
<ng-template #edit>
|
||||||
|
|
||||||
<select id="logging-level" aria-describedby="logging-level-help" class="form-select" formControlName="loggingLevel"
|
<select id="logging-level" aria-describedby="logging-level-help" class="form-select" formControlName="loggingLevel"
|
||||||
[class.is-invalid]="formControl.invalid && formControl.touched">
|
[class.is-invalid]="formControl.invalid && !formControl.untouched">
|
||||||
@for(level of logLevels; track level) {
|
@for(level of logLevels; track level) {
|
||||||
<option [value]="level">{{level | titlecase}}</option>
|
<option [value]="level">{{level | titlecase}}</option>
|
||||||
}
|
}
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
@if(settingsForm.dirty || settingsForm.touched) {
|
@if(settingsForm.dirty || !settingsForm.untouched) {
|
||||||
<div id="logging-level-validations" class="invalid-feedback">
|
<div id="logging-level-validations" class="invalid-feedback">
|
||||||
@if (formControl.errors?.pattern) {
|
@if (formControl.errors?.pattern) {
|
||||||
<div>{{t('host-name-validation')}}</div>
|
<div>{{t('host-name-validation')}}</div>
|
||||||
@ -202,9 +202,9 @@
|
|||||||
<ng-template #edit>
|
<ng-template #edit>
|
||||||
<input id="setting-cache-size" aria-describedby="cache-size-help" class="form-control" formControlName="cacheSize"
|
<input id="setting-cache-size" aria-describedby="cache-size-help" class="form-control" formControlName="cacheSize"
|
||||||
type="number" inputmode="numeric" step="5" min="50" onkeypress="return event.charCode >= 48 && event.charCode <= 57"
|
type="number" inputmode="numeric" step="5" min="50" onkeypress="return event.charCode >= 48 && event.charCode <= 57"
|
||||||
[class.is-invalid]="formControl.invalid && formControl.touched">
|
[class.is-invalid]="formControl.invalid && !formControl.untouched">
|
||||||
|
|
||||||
@if(settingsForm.dirty || settingsForm.touched) {
|
@if(settingsForm.dirty || !settingsForm.untouched) {
|
||||||
<div id="cache-size-validations" class="invalid-feedback">
|
<div id="cache-size-validations" class="invalid-feedback">
|
||||||
@if (formControl.errors?.required) {
|
@if (formControl.errors?.required) {
|
||||||
<div>{{t('field-required')}}</div>
|
<div>{{t('field-required')}}</div>
|
||||||
@ -271,9 +271,9 @@
|
|||||||
<input id="setting-on-deck-progress-days" aria-describedby="on-deck-progress-days-validations" class="form-control" formControlName="onDeckProgressDays"
|
<input id="setting-on-deck-progress-days" aria-describedby="on-deck-progress-days-validations" class="form-control" formControlName="onDeckProgressDays"
|
||||||
type="number" inputmode="numeric" step="1" min="1"
|
type="number" inputmode="numeric" step="1" min="1"
|
||||||
onkeypress="return event.charCode >= 48 && event.charCode <= 57"
|
onkeypress="return event.charCode >= 48 && event.charCode <= 57"
|
||||||
[class.is-invalid]="formControl.invalid && formControl.touched">
|
[class.is-invalid]="formControl.invalid && !formControl.untouched">
|
||||||
|
|
||||||
@if(settingsForm.dirty || settingsForm.touched) {
|
@if(settingsForm.dirty || !settingsForm.untouched) {
|
||||||
<div id="on-deck-last-progress-validations" class="invalid-feedback">
|
<div id="on-deck-last-progress-validations" class="invalid-feedback">
|
||||||
@if (formControl.errors?.required) {
|
@if (formControl.errors?.required) {
|
||||||
<div>{{t('field-required')}}</div>
|
<div>{{t('field-required')}}</div>
|
||||||
@ -298,9 +298,9 @@
|
|||||||
<input id="on-deck-last-chapter-add" aria-describedby="on-deck-last-chapter-add-validations" class="form-control" formControlName="onDeckUpdateDays"
|
<input id="on-deck-last-chapter-add" aria-describedby="on-deck-last-chapter-add-validations" class="form-control" formControlName="onDeckUpdateDays"
|
||||||
type="number" inputmode="numeric" step="1" min="1"
|
type="number" inputmode="numeric" step="1" min="1"
|
||||||
onkeypress="return event.charCode >= 48 && event.charCode <= 57"
|
onkeypress="return event.charCode >= 48 && event.charCode <= 57"
|
||||||
[class.is-invalid]="formControl.invalid && formControl.touched">
|
[class.is-invalid]="formControl.invalid && !formControl.untouched">
|
||||||
|
|
||||||
@if(settingsForm.dirty || settingsForm.touched) {
|
@if(settingsForm.dirty || !settingsForm.untouched) {
|
||||||
<div id="on-deck-last-chapter-add-validations" class="invalid-feedback">
|
<div id="on-deck-last-chapter-add-validations" class="invalid-feedback">
|
||||||
@if (formControl.errors?.required) {
|
@if (formControl.errors?.required) {
|
||||||
<div>{{t('field-required')}}</div>
|
<div>{{t('field-required')}}</div>
|
||||||
|
@ -5,17 +5,15 @@ import {take} from 'rxjs/operators';
|
|||||||
import {ServerService} from 'src/app/_services/server.service';
|
import {ServerService} from 'src/app/_services/server.service';
|
||||||
import {SettingsService} from '../settings.service';
|
import {SettingsService} from '../settings.service';
|
||||||
import {ServerSettings} from '../_models/server-settings';
|
import {ServerSettings} from '../_models/server-settings';
|
||||||
import {NgbTooltip} from '@ng-bootstrap/ng-bootstrap';
|
import {TitleCasePipe} from '@angular/common';
|
||||||
import {NgTemplateOutlet, TitleCasePipe} from '@angular/common';
|
|
||||||
import {translate, TranslocoModule, TranslocoService} from "@jsverse/transloco";
|
import {translate, TranslocoModule, TranslocoService} from "@jsverse/transloco";
|
||||||
import {WikiLink} from "../../_models/wiki";
|
import {WikiLink} from "../../_models/wiki";
|
||||||
import {PageLayoutModePipe} from "../../_pipes/page-layout-mode.pipe";
|
|
||||||
import {SettingItemComponent} from "../../settings/_components/setting-item/setting-item.component";
|
import {SettingItemComponent} from "../../settings/_components/setting-item/setting-item.component";
|
||||||
import {SettingSwitchComponent} from "../../settings/_components/setting-switch/setting-switch.component";
|
import {SettingSwitchComponent} from "../../settings/_components/setting-switch/setting-switch.component";
|
||||||
import {SafeHtmlPipe} from "../../_pipes/safe-html.pipe";
|
|
||||||
import {ConfirmService} from "../../shared/confirm.service";
|
import {ConfirmService} from "../../shared/confirm.service";
|
||||||
import {debounceTime, distinctUntilChanged, filter, of, switchMap, tap} from "rxjs";
|
import {debounceTime, distinctUntilChanged, filter, of, switchMap, tap} from "rxjs";
|
||||||
import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
|
import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
|
||||||
|
import {DefaultValuePipe} from "../../_pipes/default-value.pipe";
|
||||||
|
|
||||||
const ValidIpAddress = /^(\s*((([12]?\d{1,2}\.){3}[12]?\d{1,2})|(([\da-f]{0,4}\:){0,7}([\da-f]{0,4})))\s*\,)*\s*((([12]?\d{1,2}\.){3}[12]?\d{1,2})|(([\da-f]{0,4}\:){0,7}([\da-f]{0,4})))\s*$/i;
|
const ValidIpAddress = /^(\s*((([12]?\d{1,2}\.){3}[12]?\d{1,2})|(([\da-f]{0,4}\:){0,7}([\da-f]{0,4})))\s*\,)*\s*((([12]?\d{1,2}\.){3}[12]?\d{1,2})|(([\da-f]{0,4}\:){0,7}([\da-f]{0,4})))\s*$/i;
|
||||||
|
|
||||||
@ -25,7 +23,7 @@ const ValidIpAddress = /^(\s*((([12]?\d{1,2}\.){3}[12]?\d{1,2})|(([\da-f]{0,4}\:
|
|||||||
styleUrls: ['./manage-settings.component.scss'],
|
styleUrls: ['./manage-settings.component.scss'],
|
||||||
standalone: true,
|
standalone: true,
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
imports: [ReactiveFormsModule, TitleCasePipe, TranslocoModule, SettingItemComponent, SettingSwitchComponent]
|
imports: [ReactiveFormsModule, TitleCasePipe, TranslocoModule, SettingItemComponent, SettingSwitchComponent, DefaultValuePipe]
|
||||||
})
|
})
|
||||||
export class ManageSettingsComponent implements OnInit {
|
export class ManageSettingsComponent implements OnInit {
|
||||||
|
|
||||||
@ -81,7 +79,7 @@ export class ManageSettingsComponent implements OnInit {
|
|||||||
// Automatically save settings as we edit them
|
// Automatically save settings as we edit them
|
||||||
this.settingsForm.valueChanges.pipe(
|
this.settingsForm.valueChanges.pipe(
|
||||||
distinctUntilChanged(),
|
distinctUntilChanged(),
|
||||||
debounceTime(100),
|
debounceTime(300),
|
||||||
filter(_ => this.settingsForm.valid),
|
filter(_ => this.settingsForm.valid),
|
||||||
takeUntilDestroyed(this.destroyRef),
|
takeUntilDestroyed(this.destroyRef),
|
||||||
switchMap(_ => {
|
switchMap(_ => {
|
||||||
|
@ -95,7 +95,7 @@
|
|||||||
<div>
|
<div>
|
||||||
<app-badge-expander [items]="chapter.writers" [allowToggle]="false" (toggle)="switchTabsToDetail()">
|
<app-badge-expander [items]="chapter.writers" [allowToggle]="false" (toggle)="switchTabsToDetail()">
|
||||||
<ng-template #badgeExpanderItem let-item let-position="idx" let-last="last">
|
<ng-template #badgeExpanderItem let-item let-position="idx" let-last="last">
|
||||||
<a href="javascript:void(0)" class="dark-exempt btn-icon" (click)="openPerson(FilterField.Writers, item.id)">{{item.name}}</a>
|
<a routerLink="/person/{{encodeURIComponent(item.name)}}/" class="dark-exempt btn-icon">{{item.name}}</a>
|
||||||
</ng-template>
|
</ng-template>
|
||||||
</app-badge-expander>
|
</app-badge-expander>
|
||||||
</div>
|
</div>
|
||||||
@ -111,7 +111,7 @@
|
|||||||
<div>
|
<div>
|
||||||
<app-badge-expander [items]="chapter.coverArtists" [allowToggle]="false" (toggle)="switchTabsToDetail()">
|
<app-badge-expander [items]="chapter.coverArtists" [allowToggle]="false" (toggle)="switchTabsToDetail()">
|
||||||
<ng-template #badgeExpanderItem let-item let-position="idx" let-last="last">
|
<ng-template #badgeExpanderItem let-item let-position="idx" let-last="last">
|
||||||
<a href="javascript:void(0)" class="dark-exempt btn-icon" (click)="openPerson(FilterField.CoverArtist, item.id)">{{item.name}}</a>
|
<a routerLink="/person/{{encodeURIComponent(item.name)}}/" class="dark-exempt btn-icon">{{item.name}}</a>
|
||||||
</ng-template>
|
</ng-template>
|
||||||
</app-badge-expander>
|
</app-badge-expander>
|
||||||
</div>
|
</div>
|
||||||
|
@ -364,4 +364,5 @@ export class ChapterDetailComponent implements OnInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
protected readonly LibraryType = LibraryType;
|
protected readonly LibraryType = LibraryType;
|
||||||
|
protected readonly encodeURIComponent = encodeURIComponent;
|
||||||
}
|
}
|
||||||
|
@ -788,7 +788,7 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||||||
|
|
||||||
if (this.mangaReaderService.shouldBeWebtoonMode()) {
|
if (this.mangaReaderService.shouldBeWebtoonMode()) {
|
||||||
this.readerMode = ReaderMode.Webtoon;
|
this.readerMode = ReaderMode.Webtoon;
|
||||||
this.toastr.info(translate('manga-reader.webtoon-override'));
|
this.toastr.info(translate('toasts.webtoon-override'));
|
||||||
this.readerModeSubject.next(this.readerMode);
|
this.readerModeSubject.next(this.readerMode);
|
||||||
this.cdRef.markForCheck();
|
this.cdRef.markForCheck();
|
||||||
}
|
}
|
||||||
|
@ -26,6 +26,7 @@
|
|||||||
<div class="person-badge">
|
<div class="person-badge">
|
||||||
<app-image
|
<app-image
|
||||||
objectFit="cover"
|
objectFit="cover"
|
||||||
|
[styles]="{'object-position': 'top'}"
|
||||||
height="200px"
|
height="200px"
|
||||||
width="200px"
|
width="200px"
|
||||||
[imageUrl]="imageService.getPersonImage(person.id)"
|
[imageUrl]="imageService.getPersonImage(person.id)"
|
||||||
|
@ -6,13 +6,14 @@
|
|||||||
@if (HasCoverImage) {
|
@if (HasCoverImage) {
|
||||||
<app-image
|
<app-image
|
||||||
objectFit="cover"
|
objectFit="cover"
|
||||||
|
[styles]="{'object-position': 'top'}"
|
||||||
height="96px"
|
height="96px"
|
||||||
width="96px"
|
width="96px"
|
||||||
[imageUrl]="ImageUrl"
|
[imageUrl]="ImageUrl"
|
||||||
[errorImage]="imageService.noPersonImage">
|
[errorImage]="imageService.noPersonImage">
|
||||||
</app-image>
|
</app-image>
|
||||||
} @else {
|
} @else {
|
||||||
<i class="fas fa-user" aria-hidden="true"></i>
|
<i class="fas fa-user" aria-hidden="true"></i>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@ -31,7 +31,7 @@
|
|||||||
</ng-template>
|
</ng-template>
|
||||||
</ngx-datatable-column>
|
</ngx-datatable-column>
|
||||||
|
|
||||||
<ngx-datatable-column prop="validUntilUtc" [sortable]="true" [draggable]="false" [resizeable]="false">
|
<ngx-datatable-column prop="validUntilUtc" [sortable]="false" [draggable]="false" [resizeable]="false">
|
||||||
<ng-template let-column="column" ngx-datatable-header-template>
|
<ng-template let-column="column" ngx-datatable-header-template>
|
||||||
|
|
||||||
</ng-template>
|
</ng-template>
|
||||||
|
@ -99,7 +99,7 @@
|
|||||||
<div>
|
<div>
|
||||||
<app-badge-expander [items]="volumeCast.writers" [allowToggle]="false" (toggle)="switchTabsToDetail()">
|
<app-badge-expander [items]="volumeCast.writers" [allowToggle]="false" (toggle)="switchTabsToDetail()">
|
||||||
<ng-template #badgeExpanderItem let-item let-position="idx" let-last="last">
|
<ng-template #badgeExpanderItem let-item let-position="idx" let-last="last">
|
||||||
<a href="javascript:void(0)" class="dark-exempt btn-icon" (click)="openPerson(FilterField.Writers, item.id)">{{item.name}}</a>
|
<a routerLink="/person/{{encodeURIComponent(item.name)}}/" class="dark-exempt btn-icon">{{item.name}}</a>
|
||||||
</ng-template>
|
</ng-template>
|
||||||
</app-badge-expander>
|
</app-badge-expander>
|
||||||
</div>
|
</div>
|
||||||
@ -109,7 +109,7 @@
|
|||||||
<div>
|
<div>
|
||||||
<app-badge-expander [items]="volumeCast.coverArtists" [allowToggle]="false" (toggle)="switchTabsToDetail()">
|
<app-badge-expander [items]="volumeCast.coverArtists" [allowToggle]="false" (toggle)="switchTabsToDetail()">
|
||||||
<ng-template #badgeExpanderItem let-item let-position="idx" let-last="last">
|
<ng-template #badgeExpanderItem let-item let-position="idx" let-last="last">
|
||||||
<a href="javascript:void(0)" class="dark-exempt btn-icon" (click)="openPerson(FilterField.CoverArtist, item.id)">{{item.name}}</a>
|
<a routerLink="/person/{{encodeURIComponent(item.name)}}/" class="dark-exempt btn-icon">{{item.name}}</a>
|
||||||
</ng-template>
|
</ng-template>
|
||||||
</app-badge-expander>
|
</app-badge-expander>
|
||||||
</div>
|
</div>
|
||||||
|
@ -666,4 +666,6 @@ export class VolumeDetailComponent implements OnInit {
|
|||||||
this.currentlyReadingChapter = undefined;
|
this.currentlyReadingChapter = undefined;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected readonly encodeURIComponent = encodeURIComponent;
|
||||||
}
|
}
|
||||||
|
@ -2,7 +2,7 @@
|
|||||||
"openapi": "3.0.1",
|
"openapi": "3.0.1",
|
||||||
"info": {
|
"info": {
|
||||||
"title": "Kavita",
|
"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.14",
|
"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.15",
|
||||||
"license": {
|
"license": {
|
||||||
"name": "GPL-3.0",
|
"name": "GPL-3.0",
|
||||||
"url": "https://github.com/Kareadita/Kavita/blob/develop/LICENSE"
|
"url": "https://github.com/Kareadita/Kavita/blob/develop/LICENSE"
|
||||||
|
Loading…
x
Reference in New Issue
Block a user