Merge remote-tracking branch 'upstream/develop' into develop
This commit is contained in:
commit
ebbb3ec86b
202 changed files with 11600 additions and 2332 deletions
|
@ -16,7 +16,7 @@ namespace API.Benchmark;
|
|||
public class EpubBenchmark
|
||||
{
|
||||
[Benchmark]
|
||||
public async Task GetWordCount_PassByString()
|
||||
public static async Task GetWordCount_PassByString()
|
||||
{
|
||||
using var book = await EpubReader.OpenBookAsync("Data/book-test.epub", BookService.BookReaderOptions);
|
||||
foreach (var bookFile in book.Content.Html.Values)
|
||||
|
@ -27,7 +27,7 @@ public class EpubBenchmark
|
|||
}
|
||||
|
||||
[Benchmark]
|
||||
public async Task GetWordCount_PassByRef()
|
||||
public static async Task GetWordCount_PassByRef()
|
||||
{
|
||||
using var book = await EpubReader.OpenBookAsync("Data/book-test.epub", BookService.BookReaderOptions);
|
||||
foreach (var bookFile in book.Content.Html.Values)
|
||||
|
|
|
@ -69,6 +69,10 @@ namespace API.Tests.Parser
|
|||
[InlineData("幽游白书完全版 第03卷 天下", "3")]
|
||||
[InlineData("阿衰online 第1册", "1")]
|
||||
[InlineData("【TFO汉化&Petit汉化】迷你偶像漫画卷2第25话", "2")]
|
||||
[InlineData("63권#200", "63")]
|
||||
[InlineData("시즌34삽화2", "34")]
|
||||
[InlineData("スライム倒して300年、知らないうちにレベルMAXになってました 1巻", "1")]
|
||||
[InlineData("スライム倒して300年、知らないうちにレベルMAXになってました 1-3巻", "1-3")]
|
||||
public void ParseVolumeTest(string filename, string expected)
|
||||
{
|
||||
Assert.Equal(expected, API.Parser.Parser.ParseVolume(filename));
|
||||
|
@ -250,7 +254,8 @@ namespace API.Tests.Parser
|
|||
[InlineData("Kaiju No. 8 036 (2021) (Digital)", "36")]
|
||||
[InlineData("Samurai Jack Vol. 01 - The threads of Time", "0")]
|
||||
[InlineData("【TFO汉化&Petit汉化】迷你偶像漫画第25话", "25")]
|
||||
[InlineData("【TFO汉化&Petit汉化】迷你偶像漫画卷2第25话", "25")]
|
||||
[InlineData("이세계에서 고아원을 열었지만, 어째서인지 아무도 독립하려 하지 않는다 38-1화 ", "38")]
|
||||
[InlineData("[ハレム]ナナとカオル ~高校生のSMごっこ~ 第10話", "10")]
|
||||
public void ParseChaptersTest(string filename, string expected)
|
||||
{
|
||||
Assert.Equal(expected, API.Parser.Parser.ParseChapter(filename));
|
||||
|
|
|
@ -279,8 +279,8 @@ namespace API.Tests.Services
|
|||
}
|
||||
}
|
||||
};
|
||||
cs.GetCachedEpubFile(1, c);
|
||||
Assert.Same($"{DataDirectory}1.epub", cs.GetCachedEpubFile(1, c));
|
||||
cs.GetCachedFile(c);
|
||||
Assert.Same($"{DataDirectory}1.epub", cs.GetCachedFile(c));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
|
|
@ -8,6 +8,7 @@ using API.Data.Repositories;
|
|||
using API.DTOs;
|
||||
using API.DTOs.CollectionTags;
|
||||
using API.DTOs.Metadata;
|
||||
using API.DTOs.Reader;
|
||||
using API.Entities;
|
||||
using API.Entities.Enums;
|
||||
using API.Extensions;
|
||||
|
@ -21,6 +22,8 @@ using Microsoft.EntityFrameworkCore;
|
|||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using NSubstitute;
|
||||
using NSubstitute.Extensions;
|
||||
using NSubstitute.ReceivedExtensions;
|
||||
using Xunit;
|
||||
using Xunit.Sdk;
|
||||
|
||||
|
@ -253,6 +256,50 @@ public class SeriesServiceTests
|
|||
Assert.Equal(2, detail.Volumes.Count());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SeriesDetail_ShouldReturnCorrectNaming_VolumeTitle()
|
||||
{
|
||||
await ResetDb();
|
||||
|
||||
_context.Series.Add(new Series()
|
||||
{
|
||||
Name = "Test",
|
||||
Library = new Library() {
|
||||
Name = "Test LIb",
|
||||
Type = LibraryType.Manga,
|
||||
},
|
||||
Volumes = new List<Volume>()
|
||||
{
|
||||
EntityFactory.CreateVolume("0", new List<Chapter>()
|
||||
{
|
||||
EntityFactory.CreateChapter("1", false, new List<MangaFile>()),
|
||||
EntityFactory.CreateChapter("2", false, new List<MangaFile>()),
|
||||
}),
|
||||
EntityFactory.CreateVolume("2", new List<Chapter>()
|
||||
{
|
||||
EntityFactory.CreateChapter("0", false, new List<MangaFile>()),
|
||||
}),
|
||||
EntityFactory.CreateVolume("3", new List<Chapter>()
|
||||
{
|
||||
EntityFactory.CreateChapter("31", false, new List<MangaFile>()),
|
||||
}),
|
||||
}
|
||||
});
|
||||
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
var detail = await _seriesService.GetSeriesDetail(1, 1);
|
||||
Assert.NotEmpty(detail.Chapters);
|
||||
// volume 2 has a 0 chapter aka a single chapter that is represented as a volume. We don't show in Chapters area
|
||||
Assert.Equal(3, detail.Chapters.Count());
|
||||
|
||||
Assert.NotEmpty(detail.Volumes);
|
||||
Assert.Equal(2, detail.Volumes.Count());
|
||||
|
||||
Assert.Equal(string.Empty, detail.Chapters.First().VolumeTitle); // loose leaf chapter
|
||||
Assert.Equal("Volume 3", detail.Chapters.Last().VolumeTitle); // volume based chapter
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SeriesDetail_ShouldReturnChaptersOnly_WhenBookLibrary()
|
||||
{
|
||||
|
@ -700,7 +747,7 @@ public class SeriesServiceTests
|
|||
|
||||
var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(1);
|
||||
Assert.NotNull(series.Metadata);
|
||||
Assert.True(series.Metadata.Genres.Select(g => g.Title).All(g => g == "New Genre".SentenceCase()));
|
||||
Assert.True(series.Metadata.Genres.Select(g1 => g1.Title).All(g2 => g2 == "New Genre".SentenceCase()));
|
||||
Assert.False(series.Metadata.GenresLocked); // GenreLocked is false unless the UI Explicitly says it should be locked
|
||||
}
|
||||
|
||||
|
|
|
@ -7,6 +7,7 @@ using API.Data;
|
|||
using API.Entities;
|
||||
using API.Entities.Enums;
|
||||
using API.Entities.Enums.Theme;
|
||||
using API.Entities.Enums.UserPreferences;
|
||||
using API.Helpers;
|
||||
using API.Services;
|
||||
using API.Services.Tasks;
|
||||
|
|
|
@ -46,7 +46,7 @@
|
|||
<PackageReference Include="Hangfire.AspNetCore" Version="1.7.29" />
|
||||
<PackageReference Include="Hangfire.MaximumConcurrentExecutions" Version="1.1.0" />
|
||||
<PackageReference Include="Hangfire.MemoryStorage.Core" Version="1.4.0" />
|
||||
<PackageReference Include="HtmlAgilityPack" Version="1.11.42" />
|
||||
<PackageReference Include="HtmlAgilityPack" Version="1.11.43" />
|
||||
<PackageReference Include="MarkdownDeep.NET.Core" Version="1.5.0.4" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="6.0.5" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.OpenIdConnect" Version="6.0.5" />
|
||||
|
@ -63,14 +63,14 @@
|
|||
<PackageReference Include="NetVips.Native" Version="8.12.2" />
|
||||
<PackageReference Include="NReco.Logging.File" Version="1.1.5" />
|
||||
<PackageReference Include="SharpCompress" Version="0.31.0" />
|
||||
<PackageReference Include="SixLabors.ImageSharp" Version="2.1.1" />
|
||||
<PackageReference Include="SonarAnalyzer.CSharp" Version="8.39.0.47922">
|
||||
<PackageReference Include="SixLabors.ImageSharp" Version="2.1.2" />
|
||||
<PackageReference Include="SonarAnalyzer.CSharp" Version="8.40.0.48530">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.3.1" />
|
||||
<PackageReference Include="System.Drawing.Common" Version="6.0.0" />
|
||||
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="6.18.0" />
|
||||
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="6.19.0" />
|
||||
<PackageReference Include="System.IO.Abstractions" Version="17.0.15" />
|
||||
<PackageReference Include="VersOne.Epub" Version="3.1.1" />
|
||||
</ItemGroup>
|
||||
|
|
|
@ -12,6 +12,7 @@ using API.DTOs.Account;
|
|||
using API.DTOs.Email;
|
||||
using API.Entities;
|
||||
using API.Entities.Enums;
|
||||
using API.Entities.Enums.UserPreferences;
|
||||
using API.Errors;
|
||||
using API.Extensions;
|
||||
using API.Services;
|
||||
|
@ -652,22 +653,13 @@ namespace API.Controllers
|
|||
try
|
||||
{
|
||||
var token = await _userManager.GenerateEmailConfirmationTokenAsync(user);
|
||||
//if (string.IsNullOrEmpty(token)) return BadRequest("There was an issue sending email");
|
||||
|
||||
user.Email = dto.Email;
|
||||
if (!await ConfirmEmailToken(token, user)) return BadRequest("There was a critical error during migration");
|
||||
_unitOfWork.UserRepository.Update(user);
|
||||
|
||||
await _unitOfWork.CommitAsync();
|
||||
|
||||
//var emailLink = GenerateEmailLink(await _userManager.GenerateEmailConfirmationTokenAsync(user), "confirm-migration-email", user.Email);
|
||||
// _logger.LogCritical("[Email Migration]: Email Link for {UserName}: {Link}", dto.Username, emailLink);
|
||||
// // Always send an email, even if the user can't click it just to get them conformable with the system
|
||||
// await _emailService.SendMigrationEmail(new EmailMigrationDto()
|
||||
// {
|
||||
// EmailAddress = dto.Email,
|
||||
// Username = user.UserName,
|
||||
// ServerConfirmationLink = emailLink
|
||||
// });
|
||||
return Ok();
|
||||
}
|
||||
catch (Exception ex)
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using API.Data;
|
||||
|
@ -37,11 +38,34 @@ namespace API.Controllers
|
|||
{
|
||||
var dto = await _unitOfWork.ChapterRepository.GetChapterInfoDtoAsync(chapterId);
|
||||
var bookTitle = string.Empty;
|
||||
if (dto.SeriesFormat == MangaFormat.Epub)
|
||||
switch (dto.SeriesFormat)
|
||||
{
|
||||
var mangaFile = (await _unitOfWork.ChapterRepository.GetFilesForChapterAsync(chapterId)).First();
|
||||
using var book = await EpubReader.OpenBookAsync(mangaFile.FilePath, BookService.BookReaderOptions);
|
||||
bookTitle = book.Title;
|
||||
case MangaFormat.Epub:
|
||||
{
|
||||
var mangaFile = (await _unitOfWork.ChapterRepository.GetFilesForChapterAsync(chapterId)).First();
|
||||
using var book = await EpubReader.OpenBookAsync(mangaFile.FilePath, BookService.BookReaderOptions);
|
||||
bookTitle = book.Title;
|
||||
break;
|
||||
}
|
||||
case MangaFormat.Pdf:
|
||||
{
|
||||
var mangaFile = (await _unitOfWork.ChapterRepository.GetFilesForChapterAsync(chapterId)).First();
|
||||
if (string.IsNullOrEmpty(bookTitle))
|
||||
{
|
||||
// Override with filename
|
||||
bookTitle = Path.GetFileNameWithoutExtension(mangaFile.FilePath);
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
case MangaFormat.Image:
|
||||
break;
|
||||
case MangaFormat.Archive:
|
||||
break;
|
||||
case MangaFormat.Unknown:
|
||||
break;
|
||||
default:
|
||||
throw new ArgumentOutOfRangeException();
|
||||
}
|
||||
|
||||
return Ok(new BookInfoDto()
|
||||
|
@ -209,7 +233,7 @@ namespace API.Controllers
|
|||
public async Task<ActionResult<string>> GetBookPage(int chapterId, [FromQuery] int page)
|
||||
{
|
||||
var chapter = await _cacheService.Ensure(chapterId);
|
||||
var path = _cacheService.GetCachedEpubFile(chapter.Id, chapter);
|
||||
var path = _cacheService.GetCachedFile(chapter);
|
||||
|
||||
using var book = await EpubReader.OpenBookAsync(path, BookService.BookReaderOptions);
|
||||
var mappings = await _bookService.CreateKeyToPageMappingAsync(book);
|
||||
|
|
|
@ -6,7 +6,9 @@ using System.Threading.Tasks;
|
|||
using API.Data;
|
||||
using API.Data.Repositories;
|
||||
using API.DTOs;
|
||||
using API.DTOs.JumpBar;
|
||||
using API.DTOs.Search;
|
||||
using API.DTOs.System;
|
||||
using API.Entities;
|
||||
using API.Entities.Enums;
|
||||
using API.Extensions;
|
||||
|
@ -88,11 +90,15 @@ namespace API.Controllers
|
|||
/// <returns></returns>
|
||||
[Authorize(Policy = "RequireAdminRole")]
|
||||
[HttpGet("list")]
|
||||
public ActionResult<IEnumerable<string>> GetDirectories(string path)
|
||||
public ActionResult<IEnumerable<DirectoryDto>> GetDirectories(string path)
|
||||
{
|
||||
if (string.IsNullOrEmpty(path))
|
||||
{
|
||||
return Ok(Directory.GetLogicalDrives());
|
||||
return Ok(Directory.GetLogicalDrives().Select(d => new DirectoryDto()
|
||||
{
|
||||
Name = d,
|
||||
FullPath = d
|
||||
}));
|
||||
}
|
||||
|
||||
if (!Directory.Exists(path)) return BadRequest("This is not a valid path");
|
||||
|
@ -106,6 +112,16 @@ namespace API.Controllers
|
|||
return Ok(await _unitOfWork.LibraryRepository.GetLibraryDtosAsync());
|
||||
}
|
||||
|
||||
[HttpGet("jump-bar")]
|
||||
public async Task<ActionResult<IEnumerable<JumpKeyDto>>> GetJumpBar(int libraryId)
|
||||
{
|
||||
var userId = await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername());
|
||||
if (!await _unitOfWork.UserRepository.HasAccessToLibrary(libraryId, userId)) return BadRequest("User does not have access to library");
|
||||
|
||||
return Ok(_unitOfWork.LibraryRepository.GetJumpBarAsync(libraryId));
|
||||
}
|
||||
|
||||
|
||||
[Authorize(Policy = "RequireAdminRole")]
|
||||
[HttpPost("grant-access")]
|
||||
public async Task<ActionResult<MemberDto>> UpdateUserLibraries(UpdateLibraryForUserDto updateLibraryForUserDto)
|
||||
|
@ -170,7 +186,7 @@ namespace API.Controllers
|
|||
[HttpPost("analyze")]
|
||||
public ActionResult Analyze(int libraryId)
|
||||
{
|
||||
_taskScheduler.AnalyzeFilesForLibrary(libraryId);
|
||||
_taskScheduler.AnalyzeFilesForLibrary(libraryId, true);
|
||||
return Ok();
|
||||
}
|
||||
|
||||
|
|
|
@ -83,7 +83,7 @@ public class MetadataController : BaseApiController
|
|||
var ids = libraryIds?.Split(",").Select(int.Parse).ToList();
|
||||
if (ids != null && ids.Count > 0)
|
||||
{
|
||||
return Ok(await _unitOfWork.SeriesRepository.GetAllAgeRatingsDtosForLibrariesAsync(ids));
|
||||
return Ok(await _unitOfWork.LibraryRepository.GetAllAgeRatingsDtosForLibrariesAsync(ids));
|
||||
}
|
||||
|
||||
return Ok(Enum.GetValues<AgeRating>().Select(t => new AgeRatingDto()
|
||||
|
@ -104,7 +104,7 @@ public class MetadataController : BaseApiController
|
|||
var ids = libraryIds?.Split(",").Select(int.Parse).ToList();
|
||||
if (ids is {Count: > 0})
|
||||
{
|
||||
return Ok(_unitOfWork.SeriesRepository.GetAllPublicationStatusesDtosForLibrariesAsync(ids));
|
||||
return Ok(_unitOfWork.LibraryRepository.GetAllPublicationStatusesDtosForLibrariesAsync(ids));
|
||||
}
|
||||
|
||||
return Ok(Enum.GetValues<PublicationStatus>().Select(t => new PublicationStatusDto()
|
||||
|
@ -125,7 +125,7 @@ public class MetadataController : BaseApiController
|
|||
var ids = libraryIds?.Split(",").Select(int.Parse).ToList();
|
||||
if (ids is {Count: > 0})
|
||||
{
|
||||
return Ok(await _unitOfWork.SeriesRepository.GetAllLanguagesForLibrariesAsync(ids));
|
||||
return Ok(await _unitOfWork.LibraryRepository.GetAllLanguagesForLibrariesAsync(ids));
|
||||
}
|
||||
|
||||
var englishTag = CultureInfo.GetCultureInfo("en");
|
||||
|
@ -149,4 +149,18 @@ public class MetadataController : BaseApiController
|
|||
IsoCode = c.IetfLanguageTag
|
||||
}).Where(l => !string.IsNullOrEmpty(l.IsoCode));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns summary for the chapter
|
||||
/// </summary>
|
||||
/// <param name="chapterId"></param>
|
||||
/// <returns></returns>
|
||||
[HttpGet("chapter-summary")]
|
||||
public async Task<ActionResult<string>> GetChapterSummary(int chapterId)
|
||||
{
|
||||
if (chapterId <= 0) return BadRequest("Chapter does not exist");
|
||||
var chapter = await _unitOfWork.ChapterRepository.GetChapterAsync(chapterId);
|
||||
if (chapter == null) return BadRequest("Chapter does not exist");
|
||||
return Ok(chapter.Summary);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -604,6 +604,7 @@ public class OpdsController : BaseApiController
|
|||
/// <summary>
|
||||
/// Downloads a file
|
||||
/// </summary>
|
||||
/// <param name="apiKey">User's API Key</param>
|
||||
/// <param name="seriesId"></param>
|
||||
/// <param name="volumeId"></param>
|
||||
/// <param name="chapterId"></param>
|
||||
|
|
|
@ -8,9 +8,9 @@ using API.Data.Repositories;
|
|||
using API.DTOs;
|
||||
using API.DTOs.Reader;
|
||||
using API.Entities;
|
||||
using API.Entities.Enums;
|
||||
using API.Extensions;
|
||||
using API.Services;
|
||||
using API.Services.Tasks;
|
||||
using API.SignalR;
|
||||
using Hangfire;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
@ -44,6 +44,34 @@ namespace API.Controllers
|
|||
_eventHub = eventHub;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the PDF for the chapterId.
|
||||
/// </summary>
|
||||
/// <param name="apiKey">API Key for user to validate they have access</param>
|
||||
/// <param name="chapterId"></param>
|
||||
/// <returns></returns>
|
||||
[HttpGet("pdf")]
|
||||
public async Task<ActionResult> GetPdf(int chapterId)
|
||||
{
|
||||
|
||||
var chapter = await _cacheService.Ensure(chapterId);
|
||||
if (chapter == null) return BadRequest("There was an issue finding pdf file for reading");
|
||||
|
||||
try
|
||||
{
|
||||
var path = _cacheService.GetCachedFile(chapter);
|
||||
if (string.IsNullOrEmpty(path) || !System.IO.File.Exists(path)) return BadRequest($"Pdf doesn't exist when it should.");
|
||||
|
||||
Response.AddCacheHeader(path, TimeSpan.FromMinutes(60).Seconds);
|
||||
return PhysicalFile(path, "application/pdf", Path.GetFileName(path), true);
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
_cacheService.CleanupChapters(new []{ chapterId });
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns an image for a given chapter. Side effect: This will cache the chapter images for reading.
|
||||
/// </summary>
|
||||
|
@ -627,5 +655,34 @@ namespace API.Controllers
|
|||
return await _readerService.GetPrevChapterIdAsync(seriesId, volumeId, currentChapterId, userId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// For the current user, returns an estimate on how long it would take to finish reading the series.
|
||||
/// </summary>
|
||||
/// <remarks>For Epubs, this does not check words inside a chapter due to overhead so may not work in all cases.</remarks>
|
||||
/// <param name="seriesId"></param>
|
||||
/// <returns></returns>
|
||||
[HttpGet("time-left")]
|
||||
public async Task<ActionResult<HourEstimateRangeDto>> GetEstimateToCompletion(int seriesId)
|
||||
{
|
||||
var userId = await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername());
|
||||
var series = await _unitOfWork.SeriesRepository.GetSeriesDtoByIdAsync(seriesId, userId);
|
||||
|
||||
// Get all sum of all chapters with progress that is complete then subtract from series. Multiply by modifiers
|
||||
var progress = await _unitOfWork.AppUserProgressRepository.GetUserProgressForSeriesAsync(seriesId, userId);
|
||||
if (series.Format == MangaFormat.Epub)
|
||||
{
|
||||
var chapters =
|
||||
await _unitOfWork.ChapterRepository.GetChaptersByIdsAsync(progress.Select(p => p.ChapterId).ToList());
|
||||
// Word count
|
||||
var progressCount = chapters.Sum(c => c.WordCount);
|
||||
var wordsLeft = series.WordCount - progressCount;
|
||||
return _readerService.GetTimeEstimate(wordsLeft, 0, true);
|
||||
}
|
||||
|
||||
var progressPageCount = progress.Sum(p => p.PagesRead);
|
||||
var pagesLeft = series.Pages - progressPageCount;
|
||||
return _readerService.GetTimeEstimate(0, pagesLeft, false);
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
|
|
@ -19,9 +19,10 @@ public class RecommendedController : BaseApiController
|
|||
|
||||
|
||||
/// <summary>
|
||||
/// Quick Reads are series that are less than 2K pages in total.
|
||||
/// Quick Reads are series that should be readable in less than 10 in total and are not Ongoing in release.
|
||||
/// </summary>
|
||||
/// <param name="libraryId">Library to restrict series to</param>
|
||||
/// <param name="userParams">Pagination</param>
|
||||
/// <returns></returns>
|
||||
[HttpGet("quick-reads")]
|
||||
public async Task<ActionResult<PagedList<SeriesDto>>> GetQuickReads(int libraryId, [FromQuery] UserParams userParams)
|
||||
|
@ -35,10 +36,29 @@ public class RecommendedController : BaseApiController
|
|||
return Ok(series);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Quick Catchup Reads are series that should be readable in less than 10 in total and are Ongoing in release.
|
||||
/// </summary>
|
||||
/// <param name="libraryId">Library to restrict series to</param>
|
||||
/// <param name="userParams"></param>
|
||||
/// <returns></returns>
|
||||
[HttpGet("quick-catchup-reads")]
|
||||
public async Task<ActionResult<PagedList<SeriesDto>>> GetQuickCatchupReads(int libraryId, [FromQuery] UserParams userParams)
|
||||
{
|
||||
var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername());
|
||||
|
||||
userParams ??= new UserParams();
|
||||
var series = await _unitOfWork.SeriesRepository.GetQuickCatchupReads(user.Id, libraryId, userParams);
|
||||
|
||||
Response.AddPaginationHeader(series.CurrentPage, series.PageSize, series.TotalCount, series.TotalPages);
|
||||
return Ok(series);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Highly Rated based on other users ratings. Will pull series with ratings > 4.0, weighted by count of other users.
|
||||
/// </summary>
|
||||
/// <param name="libraryId">Library to restrict series to</param>
|
||||
/// <param name="userParams">Pagination</param>
|
||||
/// <returns></returns>
|
||||
[HttpGet("highly-rated")]
|
||||
public async Task<ActionResult<PagedList<SeriesDto>>> GetHighlyRated(int libraryId, [FromQuery] UserParams userParams)
|
||||
|
@ -56,6 +76,8 @@ public class RecommendedController : BaseApiController
|
|||
/// Chooses a random genre and shows series that are in that without reading progress
|
||||
/// </summary>
|
||||
/// <param name="libraryId">Library to restrict series to</param>
|
||||
/// <param name="genreId">Genre Id</param>
|
||||
/// <param name="userParams">Pagination</param>
|
||||
/// <returns></returns>
|
||||
[HttpGet("more-in")]
|
||||
public async Task<ActionResult<PagedList<SeriesDto>>> GetMoreIn(int libraryId, int genreId, [FromQuery] UserParams userParams)
|
||||
|
@ -74,6 +96,7 @@ public class RecommendedController : BaseApiController
|
|||
/// Series that are fully read by the user in no particular order
|
||||
/// </summary>
|
||||
/// <param name="libraryId">Library to restrict series to</param>
|
||||
/// <param name="userParams">Pagination</param>
|
||||
/// <returns></returns>
|
||||
[HttpGet("rediscover")]
|
||||
public async Task<ActionResult<PagedList<SeriesDto>>> GetRediscover(int libraryId, [FromQuery] UserParams userParams)
|
||||
|
|
|
@ -394,6 +394,8 @@ namespace API.Controllers
|
|||
return Ok(await _unitOfWork.SeriesRepository.GetRelatedSeries(userId, seriesId));
|
||||
}
|
||||
|
||||
|
||||
|
||||
[Authorize(Policy="RequireAdminRole")]
|
||||
[HttpPost("update-related")]
|
||||
public async Task<ActionResult> UpdateRelatedSeries(UpdateRelatedSeriesDto dto)
|
||||
|
|
|
@ -206,6 +206,12 @@ namespace API.Controllers
|
|||
}
|
||||
}
|
||||
|
||||
if (setting.Key == ServerSettingKey.EnableSwaggerUi && updateSettingsDto.EnableSwaggerUi + string.Empty != setting.Value)
|
||||
{
|
||||
setting.Value = updateSettingsDto.EnableSwaggerUi + string.Empty;
|
||||
_unitOfWork.SettingsRepository.Update(setting);
|
||||
}
|
||||
|
||||
if (setting.Key == ServerSettingKey.EmailServiceUrl && updateSettingsDto.EmailServiceUrl + string.Empty != setting.Value)
|
||||
{
|
||||
setting.Value = string.IsNullOrEmpty(updateSettingsDto.EmailServiceUrl) ? EmailService.DefaultApiUrl : updateSettingsDto.EmailServiceUrl;
|
||||
|
|
|
@ -92,8 +92,9 @@ namespace API.Controllers
|
|||
existingPreferences.BookReaderReadingDirection = preferencesDto.BookReaderReadingDirection;
|
||||
preferencesDto.Theme ??= await _unitOfWork.SiteThemeRepository.GetDefaultTheme();
|
||||
existingPreferences.BookThemeName = preferencesDto.BookReaderThemeName;
|
||||
existingPreferences.PageLayoutMode = preferencesDto.BookReaderLayoutMode;
|
||||
existingPreferences.BookReaderLayoutMode = preferencesDto.BookReaderLayoutMode;
|
||||
existingPreferences.BookReaderImmersiveMode = preferencesDto.BookReaderImmersiveMode;
|
||||
existingPreferences.GlobalPageLayoutMode = preferencesDto.GlobalPageLayoutMode;
|
||||
existingPreferences.Theme = await _unitOfWork.SiteThemeRepository.GetThemeById(preferencesDto.Theme.Id);
|
||||
|
||||
// TODO: Remove this code - this overrides layout mode to be single until the mode is released
|
||||
|
|
|
@ -1,6 +1,9 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using API.DTOs.Metadata;
|
||||
using API.DTOs.Reader;
|
||||
using API.Entities.Enums;
|
||||
using API.Entities.Interfaces;
|
||||
|
||||
namespace API.DTOs
|
||||
{
|
||||
|
@ -8,7 +11,7 @@ namespace API.DTOs
|
|||
/// A Chapter is the lowest grouping of a reading medium. A Chapter contains a set of MangaFiles which represents the underlying
|
||||
/// file (abstracted from type).
|
||||
/// </summary>
|
||||
public class ChapterDto
|
||||
public class ChapterDto : IHasReadTimeEstimate
|
||||
{
|
||||
public int Id { get; init; }
|
||||
/// <summary>
|
||||
|
@ -62,8 +65,29 @@ namespace API.DTOs
|
|||
/// <remarks>Metadata field</remarks>
|
||||
public string TitleName { get; set; }
|
||||
/// <summary>
|
||||
/// Number of Words for this chapter. Only applies to Epub
|
||||
/// Summary of the Chapter
|
||||
/// </summary>
|
||||
public long WordCount { get; set; }
|
||||
/// <remarks>This is not set normally, only for Series Detail</remarks>
|
||||
public string Summary { get; init; }
|
||||
/// <summary>
|
||||
/// Age Rating for the issue/chapter
|
||||
/// </summary>
|
||||
public AgeRating AgeRating { get; init; }
|
||||
/// <summary>
|
||||
/// Total words in a Chapter (books only)
|
||||
/// </summary>
|
||||
public long WordCount { get; set; } = 0L;
|
||||
|
||||
/// <summary>
|
||||
/// Formatted Volume title ie) Volume 2.
|
||||
/// </summary>
|
||||
/// <remarks>Only available when fetched from Series Detail API</remarks>
|
||||
public string VolumeTitle { get; set; } = string.Empty;
|
||||
/// <inheritdoc cref="IHasReadTimeEstimate.MinHoursToRead"/>
|
||||
public int MinHoursToRead { get; set; }
|
||||
/// <inheritdoc cref="IHasReadTimeEstimate.MaxHoursToRead"/>
|
||||
public int MaxHoursToRead { get; set; }
|
||||
/// <inheritdoc cref="IHasReadTimeEstimate.AvgHoursToRead"/>
|
||||
public int AvgHoursToRead { get; set; }
|
||||
}
|
||||
}
|
||||
|
|
20
API/DTOs/JumpBar/JumpKeyDto.cs
Normal file
20
API/DTOs/JumpBar/JumpKeyDto.cs
Normal file
|
@ -0,0 +1,20 @@
|
|||
namespace API.DTOs.JumpBar;
|
||||
|
||||
/// <summary>
|
||||
/// Represents an individual button in a Jump Bar
|
||||
/// </summary>
|
||||
public class JumpKeyDto
|
||||
{
|
||||
/// <summary>
|
||||
/// Number of items in this Key
|
||||
/// </summary>
|
||||
public int Size { get; set; }
|
||||
/// <summary>
|
||||
/// Code to use in URL (url encoded)
|
||||
/// </summary>
|
||||
public string Key { get; set; }
|
||||
/// <summary>
|
||||
/// What is visible to user
|
||||
/// </summary>
|
||||
public string Title { get; set; }
|
||||
}
|
|
@ -47,6 +47,10 @@ namespace API.DTOs.Metadata
|
|||
/// Total number of issues for the series
|
||||
/// </summary>
|
||||
public int TotalCount { get; set; }
|
||||
/// <summary>
|
||||
/// Number of Words for this chapter. Only applies to Epub
|
||||
/// </summary>
|
||||
public long WordCount { get; set; }
|
||||
|
||||
}
|
||||
}
|
||||
|
|
20
API/DTOs/Reader/HourEstimateRangeDto.cs
Normal file
20
API/DTOs/Reader/HourEstimateRangeDto.cs
Normal file
|
@ -0,0 +1,20 @@
|
|||
namespace API.DTOs.Reader;
|
||||
|
||||
/// <summary>
|
||||
/// A range of time to read a selection (series, chapter, etc)
|
||||
/// </summary>
|
||||
public record HourEstimateRangeDto
|
||||
{
|
||||
/// <summary>
|
||||
/// Min hours to read the selection
|
||||
/// </summary>
|
||||
public int MinHours { get; init; } = 1;
|
||||
/// <summary>
|
||||
/// Max hours to read the selection
|
||||
/// </summary>
|
||||
public int MaxHours { get; init; } = 1;
|
||||
/// <summary>
|
||||
/// Estimated average hours to read the selection
|
||||
/// </summary>
|
||||
public int AvgHours { get; init; } = 1;
|
||||
}
|
|
@ -1,6 +1,6 @@
|
|||
using System.Collections.Generic;
|
||||
|
||||
namespace API.DTOs;
|
||||
namespace API.DTOs.SeriesDetail;
|
||||
|
||||
/// <summary>
|
||||
/// This is a special DTO for a UI page in Kavita. This performs sorting and grouping and returns exactly what UI requires for layout.
|
||||
|
|
|
@ -1,9 +1,10 @@
|
|||
using System;
|
||||
using API.Entities.Enums;
|
||||
using API.Entities.Interfaces;
|
||||
|
||||
namespace API.DTOs
|
||||
{
|
||||
public class SeriesDto
|
||||
public class SeriesDto : IHasReadTimeEstimate
|
||||
{
|
||||
public int Id { get; init; }
|
||||
public string Name { get; init; }
|
||||
|
@ -47,5 +48,11 @@ namespace API.DTOs
|
|||
|
||||
public int LibraryId { get; set; }
|
||||
public string LibraryName { get; set; }
|
||||
/// <inheritdoc cref="IHasReadTimeEstimate.MinHoursToRead"/>
|
||||
public int MinHoursToRead { get; set; }
|
||||
/// <inheritdoc cref="IHasReadTimeEstimate.MaxHoursToRead"/>
|
||||
public int MaxHoursToRead { get; set; }
|
||||
/// <inheritdoc cref="IHasReadTimeEstimate.AvgHoursToRead"/>
|
||||
public int AvgHoursToRead { get; set; }
|
||||
}
|
||||
}
|
||||
|
|
|
@ -40,5 +40,9 @@ namespace API.DTOs.Settings
|
|||
public string InstallVersion { get; set; }
|
||||
|
||||
public bool ConvertBookmarkToWebP { get; set; }
|
||||
/// <summary>
|
||||
/// If the Swagger UI Should be exposed. Does not require authentication, but does require a JWT.
|
||||
/// </summary>
|
||||
public bool EnableSwaggerUi { get; set; }
|
||||
}
|
||||
}
|
||||
|
|
13
API/DTOs/System/DirectoryDto.cs
Normal file
13
API/DTOs/System/DirectoryDto.cs
Normal file
|
@ -0,0 +1,13 @@
|
|||
namespace API.DTOs.System;
|
||||
|
||||
public class DirectoryDto
|
||||
{
|
||||
/// <summary>
|
||||
/// Name of the directory
|
||||
/// </summary>
|
||||
public string Name { get; set; }
|
||||
/// <summary>
|
||||
/// Full Directory Path
|
||||
/// </summary>
|
||||
public string FullPath { get; set; }
|
||||
}
|
|
@ -7,7 +7,7 @@
|
|||
/// </summary>
|
||||
public int Id { get; set; }
|
||||
/// <summary>
|
||||
/// Url of the file to download from (can be null)
|
||||
/// Base Url encoding of the file to upload from (can be null)
|
||||
/// </summary>
|
||||
public string Url { get; set; }
|
||||
}
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
using API.DTOs.Theme;
|
||||
using API.Entities;
|
||||
using API.Entities.Enums;
|
||||
using API.Entities.Enums.UserPreferences;
|
||||
|
||||
namespace API.DTOs
|
||||
{
|
||||
|
@ -82,5 +83,10 @@ namespace API.DTOs
|
|||
/// </summary>
|
||||
/// <remarks>Defaults to false</remarks>
|
||||
public bool BookReaderImmersiveMode { get; set; } = false;
|
||||
/// <summary>
|
||||
/// Global Site Option: If the UI should layout items as Cards or List items
|
||||
/// </summary>
|
||||
/// <remarks>Defaults to Cards</remarks>
|
||||
public PageLayoutMode GlobalPageLayoutMode { get; set; } = PageLayoutMode.Cards;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,10 +1,11 @@
|
|||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using API.Entities.Interfaces;
|
||||
|
||||
namespace API.DTOs
|
||||
{
|
||||
public class VolumeDto
|
||||
public class VolumeDto : IHasReadTimeEstimate
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public int Number { get; set; }
|
||||
|
@ -15,5 +16,11 @@ namespace API.DTOs
|
|||
public DateTime Created { get; set; }
|
||||
public int SeriesId { get; set; }
|
||||
public ICollection<ChapterDto> Chapters { get; set; }
|
||||
/// <inheritdoc cref="IHasReadTimeEstimate.MinHoursToRead"/>
|
||||
public int MinHoursToRead { get; set; }
|
||||
/// <inheritdoc cref="IHasReadTimeEstimate.MaxHoursToRead"/>
|
||||
public int MaxHoursToRead { get; set; }
|
||||
/// <inheritdoc cref="IHasReadTimeEstimate.AvgHoursToRead"/>
|
||||
public int AvgHoursToRead { get; set; }
|
||||
}
|
||||
}
|
||||
|
|
|
@ -3,6 +3,7 @@ using System.Linq;
|
|||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using API.Entities;
|
||||
using API.Entities.Enums.UserPreferences;
|
||||
using API.Entities.Interfaces;
|
||||
using API.Entities.Metadata;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
|
@ -78,10 +79,14 @@ namespace API.Data
|
|||
builder.Entity<AppUserPreferences>()
|
||||
.Property(b => b.BackgroundColor)
|
||||
.HasDefaultValue("#000000");
|
||||
|
||||
builder.Entity<AppUserPreferences>()
|
||||
.Property(b => b.GlobalPageLayoutMode)
|
||||
.HasDefaultValue(PageLayoutMode.Cards);
|
||||
}
|
||||
|
||||
|
||||
static void OnEntityTracked(object sender, EntityTrackedEventArgs e)
|
||||
private static void OnEntityTracked(object sender, EntityTrackedEventArgs e)
|
||||
{
|
||||
if (!e.FromQuery && e.Entry.State == EntityState.Added && e.Entry.Entity is IEntityDate entity)
|
||||
{
|
||||
|
@ -91,7 +96,7 @@ namespace API.Data
|
|||
|
||||
}
|
||||
|
||||
static void OnEntityStateChanged(object sender, EntityStateChangedEventArgs e)
|
||||
private static void OnEntityStateChanged(object sender, EntityStateChangedEventArgs e)
|
||||
{
|
||||
if (e.NewState == EntityState.Modified && e.Entry.Entity is IEntityDate entity)
|
||||
entity.LastModified = DateTime.Now;
|
||||
|
|
|
@ -19,6 +19,9 @@ public static class MigrateBookmarks
|
|||
/// </summary>
|
||||
/// <remarks>Bookmark directory is configurable. This will always use the default bookmark directory.</remarks>
|
||||
/// <param name="directoryService"></param>
|
||||
/// <param name="unitOfWork"></param>
|
||||
/// <param name="logger"></param>
|
||||
/// <param name="cacheService"></param>
|
||||
/// <returns></returns>
|
||||
public static async Task Migrate(IDirectoryService directoryService, IUnitOfWork unitOfWork,
|
||||
ILogger<Program> logger, ICacheService cacheService)
|
||||
|
|
|
@ -148,7 +148,7 @@ namespace API.Data
|
|||
var volumes = await context.Volume.Include(v => v.Chapters).ToListAsync();
|
||||
foreach (var volume in volumes)
|
||||
{
|
||||
var firstChapter = volume.Chapters.OrderBy(x => double.Parse(x.Number), ChapterSortComparerForInChapterSorting).FirstOrDefault();
|
||||
var firstChapter = volume.Chapters.MinBy(x => double.Parse(x.Number), ChapterSortComparerForInChapterSorting);
|
||||
if (firstChapter == null) continue;
|
||||
if (directoryService.FileSystem.File.Exists(directoryService.FileSystem.Path.Join(directoryService.CoverImageDirectory,
|
||||
$"{ImageService.GetChapterFormat(firstChapter.Id, firstChapter.VolumeId)}.png")))
|
||||
|
|
1562
API/Data/Migrations/20220610153822_TimeEstimateInDB.Designer.cs
generated
Normal file
1562
API/Data/Migrations/20220610153822_TimeEstimateInDB.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load diff
125
API/Data/Migrations/20220610153822_TimeEstimateInDB.cs
Normal file
125
API/Data/Migrations/20220610153822_TimeEstimateInDB.cs
Normal file
|
@ -0,0 +1,125 @@
|
|||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace API.Data.Migrations
|
||||
{
|
||||
public partial class TimeEstimateInDB : Migration
|
||||
{
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "AvgHoursToRead",
|
||||
table: "Volume",
|
||||
type: "INTEGER",
|
||||
nullable: false,
|
||||
defaultValue: 0);
|
||||
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "MaxHoursToRead",
|
||||
table: "Volume",
|
||||
type: "INTEGER",
|
||||
nullable: false,
|
||||
defaultValue: 0);
|
||||
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "MinHoursToRead",
|
||||
table: "Volume",
|
||||
type: "INTEGER",
|
||||
nullable: false,
|
||||
defaultValue: 0);
|
||||
|
||||
migrationBuilder.AddColumn<long>(
|
||||
name: "WordCount",
|
||||
table: "Volume",
|
||||
type: "INTEGER",
|
||||
nullable: false,
|
||||
defaultValue: 0L);
|
||||
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "AvgHoursToRead",
|
||||
table: "Series",
|
||||
type: "INTEGER",
|
||||
nullable: false,
|
||||
defaultValue: 0);
|
||||
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "MaxHoursToRead",
|
||||
table: "Series",
|
||||
type: "INTEGER",
|
||||
nullable: false,
|
||||
defaultValue: 0);
|
||||
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "MinHoursToRead",
|
||||
table: "Series",
|
||||
type: "INTEGER",
|
||||
nullable: false,
|
||||
defaultValue: 0);
|
||||
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "AvgHoursToRead",
|
||||
table: "Chapter",
|
||||
type: "INTEGER",
|
||||
nullable: false,
|
||||
defaultValue: 0);
|
||||
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "MaxHoursToRead",
|
||||
table: "Chapter",
|
||||
type: "INTEGER",
|
||||
nullable: false,
|
||||
defaultValue: 0);
|
||||
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "MinHoursToRead",
|
||||
table: "Chapter",
|
||||
type: "INTEGER",
|
||||
nullable: false,
|
||||
defaultValue: 0);
|
||||
}
|
||||
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "AvgHoursToRead",
|
||||
table: "Volume");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "MaxHoursToRead",
|
||||
table: "Volume");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "MinHoursToRead",
|
||||
table: "Volume");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "WordCount",
|
||||
table: "Volume");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "AvgHoursToRead",
|
||||
table: "Series");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "MaxHoursToRead",
|
||||
table: "Series");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "MinHoursToRead",
|
||||
table: "Series");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "AvgHoursToRead",
|
||||
table: "Chapter");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "MaxHoursToRead",
|
||||
table: "Chapter");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "MinHoursToRead",
|
||||
table: "Chapter");
|
||||
}
|
||||
}
|
||||
}
|
1562
API/Data/Migrations/20220613131125_RenamedBookReaderLayoutMode.Designer.cs
generated
Normal file
1562
API/Data/Migrations/20220613131125_RenamedBookReaderLayoutMode.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load diff
|
@ -0,0 +1,25 @@
|
|||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace API.Data.Migrations
|
||||
{
|
||||
public partial class RenamedBookReaderLayoutMode : Migration
|
||||
{
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.RenameColumn(
|
||||
name: "PageLayoutMode",
|
||||
table: "AppUserPreferences",
|
||||
newName: "BookReaderLayoutMode");
|
||||
}
|
||||
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.RenameColumn(
|
||||
name: "BookReaderLayoutMode",
|
||||
table: "AppUserPreferences",
|
||||
newName: "PageLayoutMode");
|
||||
}
|
||||
}
|
||||
}
|
1567
API/Data/Migrations/20220613131302_GlobalPageLayoutModeUserSetting.Designer.cs
generated
Normal file
1567
API/Data/Migrations/20220613131302_GlobalPageLayoutModeUserSetting.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load diff
|
@ -0,0 +1,26 @@
|
|||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace API.Data.Migrations
|
||||
{
|
||||
public partial class GlobalPageLayoutModeUserSetting : Migration
|
||||
{
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "GlobalPageLayoutMode",
|
||||
table: "AppUserPreferences",
|
||||
type: "INTEGER",
|
||||
nullable: false,
|
||||
defaultValue: 0);
|
||||
}
|
||||
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "GlobalPageLayoutMode",
|
||||
table: "AppUserPreferences");
|
||||
}
|
||||
}
|
||||
}
|
1570
API/Data/Migrations/20220615190640_LastFileAnalysis.Designer.cs
generated
Normal file
1570
API/Data/Migrations/20220615190640_LastFileAnalysis.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load diff
27
API/Data/Migrations/20220615190640_LastFileAnalysis.cs
Normal file
27
API/Data/Migrations/20220615190640_LastFileAnalysis.cs
Normal file
|
@ -0,0 +1,27 @@
|
|||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace API.Data.Migrations
|
||||
{
|
||||
public partial class LastFileAnalysis : Migration
|
||||
{
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<DateTime>(
|
||||
name: "LastFileAnalysis",
|
||||
table: "MangaFile",
|
||||
type: "TEXT",
|
||||
nullable: false,
|
||||
defaultValue: new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified));
|
||||
}
|
||||
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "LastFileAnalysis",
|
||||
table: "MangaFile");
|
||||
}
|
||||
}
|
||||
}
|
|
@ -179,6 +179,9 @@ namespace API.Data.Migrations
|
|||
b.Property<bool>("BookReaderImmersiveMode")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("BookReaderLayoutMode")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("BookReaderLineSpacing")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
|
@ -196,10 +199,12 @@ namespace API.Data.Migrations
|
|||
.HasColumnType("TEXT")
|
||||
.HasDefaultValue("Dark");
|
||||
|
||||
b.Property<int>("LayoutMode")
|
||||
.HasColumnType("INTEGER");
|
||||
b.Property<int>("GlobalPageLayoutMode")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(0);
|
||||
|
||||
b.Property<int>("PageLayoutMode")
|
||||
b.Property<int>("LayoutMode")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("PageSplitOption")
|
||||
|
@ -320,6 +325,9 @@ namespace API.Data.Migrations
|
|||
b.Property<int>("AgeRating")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("AvgHoursToRead")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("Count")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
|
@ -341,6 +349,12 @@ namespace API.Data.Migrations
|
|||
b.Property<DateTime>("LastModified")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("MaxHoursToRead")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("MinHoursToRead")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Number")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
|
@ -502,6 +516,9 @@ namespace API.Data.Migrations
|
|||
b.Property<int>("Format")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("LastFileAnalysis")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("LastModified")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
|
@ -732,6 +749,9 @@ namespace API.Data.Migrations
|
|||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("AvgHoursToRead")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("CoverImage")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
|
@ -759,6 +779,12 @@ namespace API.Data.Migrations
|
|||
b.Property<bool>("LocalizedNameLocked")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("MaxHoursToRead")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("MinHoursToRead")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
|
@ -868,6 +894,9 @@ namespace API.Data.Migrations
|
|||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("AvgHoursToRead")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("CoverImage")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
|
@ -877,6 +906,12 @@ namespace API.Data.Migrations
|
|||
b.Property<DateTime>("LastModified")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("MaxHoursToRead")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("MinHoursToRead")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
|
@ -889,6 +924,9 @@ namespace API.Data.Migrations
|
|||
b.Property<int>("SeriesId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("WordCount")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("SeriesId");
|
||||
|
|
|
@ -1,12 +1,17 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using API.DTOs;
|
||||
using API.DTOs.Filtering;
|
||||
using API.DTOs.JumpBar;
|
||||
using API.DTOs.Metadata;
|
||||
using API.Entities;
|
||||
using API.Entities.Enums;
|
||||
using AutoMapper;
|
||||
using AutoMapper.QueryableExtensions;
|
||||
using Kavita.Common.Extensions;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace API.Data.Repositories;
|
||||
|
@ -38,6 +43,10 @@ public interface ILibraryRepository
|
|||
Task<LibraryType> GetLibraryTypeAsync(int libraryId);
|
||||
Task<IEnumerable<Library>> GetLibraryForIdsAsync(IList<int> libraryIds);
|
||||
Task<int> GetTotalFiles();
|
||||
IEnumerable<JumpKeyDto> GetJumpBarAsync(int libraryId);
|
||||
Task<IList<AgeRatingDto>> GetAllAgeRatingsDtosForLibrariesAsync(List<int> libraryIds);
|
||||
Task<IList<LanguageDto>> GetAllLanguagesForLibrariesAsync(List<int> libraryIds);
|
||||
IEnumerable<PublicationStatusDto> GetAllPublicationStatusesDtosForLibrariesAsync(List<int> libraryIds);
|
||||
}
|
||||
|
||||
public class LibraryRepository : ILibraryRepository
|
||||
|
@ -123,6 +132,37 @@ public class LibraryRepository : ILibraryRepository
|
|||
return await _context.MangaFile.CountAsync();
|
||||
}
|
||||
|
||||
public IEnumerable<JumpKeyDto> GetJumpBarAsync(int libraryId)
|
||||
{
|
||||
var seriesSortCharacters = _context.Series.Where(s => s.LibraryId == libraryId)
|
||||
.Select(s => s.SortName.ToUpper())
|
||||
.OrderBy(s => s)
|
||||
.AsEnumerable()
|
||||
.Select(s => s[0]);
|
||||
|
||||
// Map the title to the number of entities
|
||||
var firstCharacterMap = new Dictionary<char, int>();
|
||||
foreach (var sortChar in seriesSortCharacters)
|
||||
{
|
||||
var c = sortChar;
|
||||
var isAlpha = char.IsLetter(sortChar);
|
||||
if (!isAlpha) c = '#';
|
||||
if (!firstCharacterMap.ContainsKey(c))
|
||||
{
|
||||
firstCharacterMap[c] = 0;
|
||||
}
|
||||
|
||||
firstCharacterMap[c] += 1;
|
||||
}
|
||||
|
||||
return firstCharacterMap.Keys.Select(k => new JumpKeyDto()
|
||||
{
|
||||
Key = k + string.Empty,
|
||||
Size = firstCharacterMap[k],
|
||||
Title = k + string.Empty
|
||||
});
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<LibraryDto>> GetLibraryDtosAsync()
|
||||
{
|
||||
return await _context.Library
|
||||
|
@ -224,4 +264,54 @@ public class LibraryRepository : ILibraryRepository
|
|||
}
|
||||
|
||||
|
||||
public async Task<IList<AgeRatingDto>> GetAllAgeRatingsDtosForLibrariesAsync(List<int> libraryIds)
|
||||
{
|
||||
return await _context.Series
|
||||
.Where(s => libraryIds.Contains(s.LibraryId))
|
||||
.Select(s => s.Metadata.AgeRating)
|
||||
.Distinct()
|
||||
.Select(s => new AgeRatingDto()
|
||||
{
|
||||
Value = s,
|
||||
Title = s.ToDescription()
|
||||
})
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
public async Task<IList<LanguageDto>> GetAllLanguagesForLibrariesAsync(List<int> libraryIds)
|
||||
{
|
||||
var ret = await _context.Series
|
||||
.Where(s => libraryIds.Contains(s.LibraryId))
|
||||
.Select(s => s.Metadata.Language)
|
||||
.AsNoTracking()
|
||||
.Distinct()
|
||||
.ToListAsync();
|
||||
|
||||
return ret
|
||||
.Where(s => !string.IsNullOrEmpty(s))
|
||||
.Select(s => new LanguageDto()
|
||||
{
|
||||
Title = CultureInfo.GetCultureInfo(s).DisplayName,
|
||||
IsoCode = s
|
||||
})
|
||||
.OrderBy(s => s.Title)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
public IEnumerable<PublicationStatusDto> GetAllPublicationStatusesDtosForLibrariesAsync(List<int> libraryIds)
|
||||
{
|
||||
return _context.Series
|
||||
.Where(s => libraryIds.Contains(s.LibraryId))
|
||||
.Select(s => s.Metadata.PublicationStatus)
|
||||
.Distinct()
|
||||
.AsEnumerable()
|
||||
.Select(s => new PublicationStatusDto()
|
||||
{
|
||||
Value = s,
|
||||
Title = s.ToDescription()
|
||||
})
|
||||
.OrderBy(s => s.Title);
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
|
27
API/Data/Repositories/MangaFileRepository.cs
Normal file
27
API/Data/Repositories/MangaFileRepository.cs
Normal file
|
@ -0,0 +1,27 @@
|
|||
using API.Entities;
|
||||
using AutoMapper;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace API.Data.Repositories;
|
||||
|
||||
public interface IMangaFileRepository
|
||||
{
|
||||
void Update(MangaFile file);
|
||||
}
|
||||
|
||||
public class MangaFileRepository : IMangaFileRepository
|
||||
{
|
||||
private readonly DataContext _context;
|
||||
private readonly IMapper _mapper;
|
||||
|
||||
public MangaFileRepository(DataContext context, IMapper mapper)
|
||||
{
|
||||
_context = context;
|
||||
_mapper = mapper;
|
||||
}
|
||||
|
||||
public void Update(MangaFile file)
|
||||
{
|
||||
_context.Entry(file).State = EntityState.Modified;
|
||||
}
|
||||
}
|
|
@ -17,6 +17,7 @@ using API.Entities.Enums;
|
|||
using API.Entities.Metadata;
|
||||
using API.Extensions;
|
||||
using API.Helpers;
|
||||
using API.Services;
|
||||
using API.Services.Tasks;
|
||||
using AutoMapper;
|
||||
using AutoMapper.QueryableExtensions;
|
||||
|
@ -67,7 +68,8 @@ public interface ISeriesRepository
|
|||
/// </summary>
|
||||
/// <param name="libraryId"></param>
|
||||
/// <param name="userId"></param>
|
||||
/// <param name="userParams"></param>
|
||||
/// <param name="userParams">Pagination info</param>
|
||||
/// <param name="filter">Filtering/Sorting to apply</param>
|
||||
/// <returns></returns>
|
||||
Task<PagedList<SeriesDto>> GetSeriesDtoForLibraryIdAsync(int libraryId, int userId, UserParams userParams, FilterDto filter);
|
||||
/// <summary>
|
||||
|
@ -106,13 +108,12 @@ public interface ISeriesRepository
|
|||
Task<Series> GetFullSeriesForSeriesIdAsync(int seriesId);
|
||||
Task<Chunk> GetChunkInfo(int libraryId = 0);
|
||||
Task<IList<SeriesMetadata>> GetSeriesMetadataForIdsAsync(IEnumerable<int> seriesIds);
|
||||
Task<IList<AgeRatingDto>> GetAllAgeRatingsDtosForLibrariesAsync(List<int> libraryIds); // TODO: Move to LibraryRepository
|
||||
Task<IList<LanguageDto>> GetAllLanguagesForLibrariesAsync(List<int> libraryIds); // TODO: Move to LibraryRepository
|
||||
IEnumerable<PublicationStatusDto> GetAllPublicationStatusesDtosForLibrariesAsync(List<int> libraryIds); // TODO: Move to LibraryRepository
|
||||
|
||||
Task<IEnumerable<GroupedSeriesDto>> GetRecentlyUpdatedSeries(int userId, int pageSize = 30);
|
||||
Task<RelatedSeriesDto> GetRelatedSeries(int userId, int seriesId);
|
||||
Task<IEnumerable<SeriesDto>> GetSeriesForRelationKind(int userId, int seriesId, RelationKind kind);
|
||||
Task<PagedList<SeriesDto>> GetQuickReads(int userId, int libraryId, UserParams userParams);
|
||||
Task<PagedList<SeriesDto>> GetQuickCatchupReads(int userId, int libraryId, UserParams userParams);
|
||||
Task<PagedList<SeriesDto>> GetHighlyRated(int userId, int libraryId, UserParams userParams);
|
||||
Task<PagedList<SeriesDto>> GetMoreIn(int userId, int libraryId, int genreId, UserParams userParams);
|
||||
Task<PagedList<SeriesDto>> GetRediscover(int userId, int libraryId, UserParams userParams);
|
||||
|
@ -920,54 +921,7 @@ public class SeriesRepository : ISeriesRepository
|
|||
.ToListAsync();
|
||||
}
|
||||
|
||||
public async Task<IList<AgeRatingDto>> GetAllAgeRatingsDtosForLibrariesAsync(List<int> libraryIds)
|
||||
{
|
||||
return await _context.Series
|
||||
.Where(s => libraryIds.Contains(s.LibraryId))
|
||||
.Select(s => s.Metadata.AgeRating)
|
||||
.Distinct()
|
||||
.Select(s => new AgeRatingDto()
|
||||
{
|
||||
Value = s,
|
||||
Title = s.ToDescription()
|
||||
})
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
public async Task<IList<LanguageDto>> GetAllLanguagesForLibrariesAsync(List<int> libraryIds)
|
||||
{
|
||||
var ret = await _context.Series
|
||||
.Where(s => libraryIds.Contains(s.LibraryId))
|
||||
.Select(s => s.Metadata.Language)
|
||||
.AsNoTracking()
|
||||
.Distinct()
|
||||
.ToListAsync();
|
||||
|
||||
return ret
|
||||
.Where(s => !string.IsNullOrEmpty(s))
|
||||
.Select(s => new LanguageDto()
|
||||
{
|
||||
Title = CultureInfo.GetCultureInfo(s).DisplayName,
|
||||
IsoCode = s
|
||||
})
|
||||
.OrderBy(s => s.Title)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
public IEnumerable<PublicationStatusDto> GetAllPublicationStatusesDtosForLibrariesAsync(List<int> libraryIds)
|
||||
{
|
||||
return _context.Series
|
||||
.Where(s => libraryIds.Contains(s.LibraryId))
|
||||
.Select(s => s.Metadata.PublicationStatus)
|
||||
.Distinct()
|
||||
.AsEnumerable()
|
||||
.Select(s => new PublicationStatusDto()
|
||||
{
|
||||
Value = s,
|
||||
Title = s.ToDescription()
|
||||
})
|
||||
.OrderBy(s => s.Title);
|
||||
}
|
||||
|
||||
|
||||
/// <summary>
|
||||
|
@ -976,6 +930,7 @@ public class SeriesRepository : ISeriesRepository
|
|||
/// <remarks>This provides 2 levels of pagination. Fetching the individual chapters only looks at 3000. Then when performing grouping
|
||||
/// in memory, we stop after 30 series. </remarks>
|
||||
/// <param name="userId">Used to ensure user has access to libraries</param>
|
||||
/// <param name="pageSize">How many entities to return</param>
|
||||
/// <returns></returns>
|
||||
public async Task<IEnumerable<GroupedSeriesDto>> GetRecentlyUpdatedSeries(int userId, int pageSize = 30)
|
||||
{
|
||||
|
@ -1131,8 +1086,11 @@ public class SeriesRepository : ISeriesRepository
|
|||
|
||||
|
||||
var query = _context.Series
|
||||
.Where(s => s.Pages < 2000 && !distinctSeriesIdsWithProgress.Contains(s.Id) &&
|
||||
usersSeriesIds.Contains(s.Id))
|
||||
.Where(s => (
|
||||
(s.Pages / ReaderService.AvgPagesPerMinute / 60 < 10 && s.Format != MangaFormat.Epub)
|
||||
|| (s.WordCount * ReaderService.AvgWordsPerHour < 10 && s.Format == MangaFormat.Epub))
|
||||
&& !distinctSeriesIdsWithProgress.Contains(s.Id) &&
|
||||
usersSeriesIds.Contains(s.Id))
|
||||
.Where(s => s.Metadata.PublicationStatus != PublicationStatus.OnGoing)
|
||||
.AsSplitQuery()
|
||||
.ProjectTo<SeriesDto>(_mapper.ConfigurationProvider);
|
||||
|
@ -1141,6 +1099,30 @@ public class SeriesRepository : ISeriesRepository
|
|||
return await PagedList<SeriesDto>.CreateAsync(query, userParams.PageNumber, userParams.PageSize);
|
||||
}
|
||||
|
||||
public async Task<PagedList<SeriesDto>> GetQuickCatchupReads(int userId, int libraryId, UserParams userParams)
|
||||
{
|
||||
var libraryIds = GetLibraryIdsForUser(userId, libraryId);
|
||||
var usersSeriesIds = GetSeriesIdsForLibraryIds(libraryIds);
|
||||
var distinctSeriesIdsWithProgress = _context.AppUserProgresses
|
||||
.Where(s => usersSeriesIds.Contains(s.SeriesId))
|
||||
.Select(p => p.SeriesId)
|
||||
.Distinct();
|
||||
|
||||
|
||||
var query = _context.Series
|
||||
.Where(s => (
|
||||
(s.Pages / ReaderService.AvgPagesPerMinute / 60 < 10 && s.Format != MangaFormat.Epub)
|
||||
|| (s.WordCount * ReaderService.AvgWordsPerHour < 10 && s.Format == MangaFormat.Epub))
|
||||
&& !distinctSeriesIdsWithProgress.Contains(s.Id) &&
|
||||
usersSeriesIds.Contains(s.Id))
|
||||
.Where(s => s.Metadata.PublicationStatus == PublicationStatus.OnGoing)
|
||||
.AsSplitQuery()
|
||||
.ProjectTo<SeriesDto>(_mapper.ConfigurationProvider);
|
||||
|
||||
|
||||
return await PagedList<SeriesDto>.CreateAsync(query, userParams.PageNumber, userParams.PageSize);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns all library ids for a user
|
||||
/// </summary>
|
||||
|
@ -1205,7 +1187,7 @@ public class SeriesRepository : ISeriesRepository
|
|||
.ToListAsync();
|
||||
}
|
||||
|
||||
private async Task<IEnumerable<RecentlyAddedSeries>> GetRecentlyAddedChaptersQuery(int userId, int maxRecords = 3000)
|
||||
private async Task<IEnumerable<RecentlyAddedSeries>> GetRecentlyAddedChaptersQuery(int userId)
|
||||
{
|
||||
var libraries = await _context.AppUser
|
||||
.Where(u => u.Id == userId)
|
||||
|
|
|
@ -56,6 +56,7 @@ public interface IUserRepository
|
|||
Task<IEnumerable<AppUser>> GetAllUsers();
|
||||
|
||||
Task<IEnumerable<AppUserPreferences>> GetAllPreferencesByThemeAsync(int themeId);
|
||||
Task<bool> HasAccessToLibrary(int libraryId, int userId);
|
||||
}
|
||||
|
||||
public class UserRepository : IUserRepository
|
||||
|
@ -238,6 +239,13 @@ public class UserRepository : IUserRepository
|
|||
.ToListAsync();
|
||||
}
|
||||
|
||||
public async Task<bool> HasAccessToLibrary(int libraryId, int userId)
|
||||
{
|
||||
return await _context.Library
|
||||
.Include(l => l.AppUsers)
|
||||
.AnyAsync(library => library.AppUsers.Any(user => user.Id == userId));
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<AppUser>> GetAdminUsersAsync()
|
||||
{
|
||||
return await _userManager.GetUsersInRoleAsync(PolicyConstants.AdminRole);
|
||||
|
|
|
@ -101,6 +101,7 @@ namespace API.Data
|
|||
new() {Key = ServerSettingKey.BookmarkDirectory, Value = directoryService.BookmarkDirectory},
|
||||
new() {Key = ServerSettingKey.EmailServiceUrl, Value = EmailService.DefaultApiUrl},
|
||||
new() {Key = ServerSettingKey.ConvertBookmarkToWebP, Value = "false"},
|
||||
new() {Key = ServerSettingKey.EnableSwaggerUi, Value = "false"},
|
||||
}.ToArray());
|
||||
|
||||
foreach (var defaultSetting in DefaultSettings)
|
||||
|
|
|
@ -22,6 +22,7 @@ public interface IUnitOfWork
|
|||
IGenreRepository GenreRepository { get; }
|
||||
ITagRepository TagRepository { get; }
|
||||
ISiteThemeRepository SiteThemeRepository { get; }
|
||||
IMangaFileRepository MangaFileRepository { get; }
|
||||
bool Commit();
|
||||
Task<bool> CommitAsync();
|
||||
bool HasChanges();
|
||||
|
@ -58,6 +59,7 @@ public class UnitOfWork : IUnitOfWork
|
|||
public IGenreRepository GenreRepository => new GenreRepository(_context, _mapper);
|
||||
public ITagRepository TagRepository => new TagRepository(_context, _mapper);
|
||||
public ISiteThemeRepository SiteThemeRepository => new SiteThemeRepository(_context, _mapper);
|
||||
public IMangaFileRepository MangaFileRepository => new MangaFileRepository(_context, _mapper);
|
||||
|
||||
/// <summary>
|
||||
/// Commits changes to the DB. Completes the open transaction.
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
using API.Entities.Enums;
|
||||
using API.Entities.Enums.UserPreferences;
|
||||
|
||||
namespace API.Entities
|
||||
{
|
||||
|
@ -81,13 +82,17 @@ namespace API.Entities
|
|||
/// 2 column is fit to height, 2 columns
|
||||
/// </summary>
|
||||
/// <remarks>Defaults to Default</remarks>
|
||||
public BookPageLayoutMode PageLayoutMode { get; set; } = BookPageLayoutMode.Default;
|
||||
public BookPageLayoutMode BookReaderLayoutMode { get; set; } = BookPageLayoutMode.Default;
|
||||
/// <summary>
|
||||
/// Book Reader Option: A flag that hides the menu-ing system behind a click on the screen. This should be used with tap to paginate, but the app doesn't enforce this.
|
||||
/// </summary>
|
||||
/// <remarks>Defaults to false</remarks>
|
||||
public bool BookReaderImmersiveMode { get; set; } = false;
|
||||
|
||||
/// <summary>
|
||||
/// Global Site Option: If the UI should layout items as Cards or List items
|
||||
/// </summary>
|
||||
/// <remarks>Defaults to Cards</remarks>
|
||||
public PageLayoutMode GlobalPageLayoutMode { get; set; } = PageLayoutMode.Cards;
|
||||
|
||||
public AppUser AppUser { get; set; }
|
||||
public int AppUserId { get; set; }
|
||||
|
|
|
@ -3,10 +3,11 @@ using System.Collections.Generic;
|
|||
using API.Entities.Enums;
|
||||
using API.Entities.Interfaces;
|
||||
using API.Parser;
|
||||
using API.Services;
|
||||
|
||||
namespace API.Entities
|
||||
{
|
||||
public class Chapter : IEntityDate
|
||||
public class Chapter : IEntityDate, IHasReadTimeEstimate
|
||||
{
|
||||
public int Id { get; set; }
|
||||
/// <summary>
|
||||
|
@ -24,7 +25,7 @@ namespace API.Entities
|
|||
public DateTime Created { get; set; }
|
||||
public DateTime LastModified { get; set; }
|
||||
/// <summary>
|
||||
/// Absolute path to the (managed) image file
|
||||
/// Relative path to the (managed) image file representing the cover image
|
||||
/// </summary>
|
||||
/// <remarks>The file is managed internally to Kavita's APPDIR</remarks>
|
||||
public string CoverImage { get; set; }
|
||||
|
@ -73,9 +74,16 @@ namespace API.Entities
|
|||
public int Count { get; set; } = 0;
|
||||
|
||||
/// <summary>
|
||||
/// Total words in a Chapter (books only)
|
||||
/// Total Word count of all chapters in this chapter.
|
||||
/// </summary>
|
||||
/// <remarks>Word Count is only available from EPUB files</remarks>
|
||||
public long WordCount { get; set; }
|
||||
/// <inheritdoc cref="IHasReadTimeEstimate"/>
|
||||
public int MinHoursToRead { get; set; }
|
||||
/// <inheritdoc cref="IHasReadTimeEstimate"/>
|
||||
public int MaxHoursToRead { get; set; }
|
||||
/// <inheritdoc cref="IHasReadTimeEstimate"/>
|
||||
public int AvgHoursToRead { get; set; }
|
||||
|
||||
|
||||
/// <summary>
|
||||
|
|
|
@ -7,10 +7,6 @@
|
|||
/// </summary>
|
||||
Other = 1,
|
||||
/// <summary>
|
||||
/// Artist
|
||||
/// </summary>
|
||||
//Artist = 2,
|
||||
/// <summary>
|
||||
/// Author or Writer
|
||||
/// </summary>
|
||||
Writer = 3,
|
||||
|
|
|
@ -81,5 +81,10 @@ namespace API.Entities.Enums
|
|||
/// </summary>
|
||||
[Description("ConvertBookmarkToWebP")]
|
||||
ConvertBookmarkToWebP = 14,
|
||||
/// <summary>
|
||||
/// If the Swagger UI Should be exposed. Does not require authentication, but does require a JWT.
|
||||
/// </summary>
|
||||
[Description("EnableSwaggerUi")]
|
||||
EnableSwaggerUi = 15,
|
||||
}
|
||||
}
|
||||
|
|
11
API/Entities/Enums/UserPreferences/PageLayoutMode.cs
Normal file
11
API/Entities/Enums/UserPreferences/PageLayoutMode.cs
Normal file
|
@ -0,0 +1,11 @@
|
|||
using System.ComponentModel;
|
||||
|
||||
namespace API.Entities.Enums.UserPreferences;
|
||||
|
||||
public enum PageLayoutMode
|
||||
{
|
||||
[Description("Cards")]
|
||||
Cards = 0,
|
||||
[Description("List")]
|
||||
List = 1
|
||||
}
|
25
API/Entities/Interfaces/IHasReadTimeEstimate.cs
Normal file
25
API/Entities/Interfaces/IHasReadTimeEstimate.cs
Normal file
|
@ -0,0 +1,25 @@
|
|||
using API.Services;
|
||||
|
||||
namespace API.Entities.Interfaces;
|
||||
|
||||
/// <summary>
|
||||
/// Entity has read time estimate properties to estimate time to read
|
||||
/// </summary>
|
||||
public interface IHasReadTimeEstimate
|
||||
{
|
||||
/// <summary>
|
||||
/// Min hours to read the chapter
|
||||
/// </summary>
|
||||
/// <remarks>Uses a fixed number to calculate from <see cref="ReaderService"/></remarks>
|
||||
public int MinHoursToRead { get; set; }
|
||||
/// <summary>
|
||||
/// Max hours to read the chapter
|
||||
/// </summary>
|
||||
/// <remarks>Uses a fixed number to calculate from <see cref="ReaderService"/></remarks>
|
||||
public int MaxHoursToRead { get; set; }
|
||||
/// <summary>
|
||||
/// Average hours to read the chapter
|
||||
/// </summary>
|
||||
/// <remarks>Uses a fixed number to calculate from <see cref="ReaderService"/></remarks>
|
||||
public int AvgHoursToRead { get; set; }
|
||||
}
|
|
@ -25,6 +25,10 @@ namespace API.Entities
|
|||
/// </summary>
|
||||
/// <remarks>This gets updated anytime the file is scanned</remarks>
|
||||
public DateTime LastModified { get; set; }
|
||||
/// <summary>
|
||||
/// Last time file analysis ran on this file
|
||||
/// </summary>
|
||||
public DateTime LastFileAnalysis { get; set; }
|
||||
|
||||
|
||||
// Relationship Mapping
|
||||
|
|
|
@ -6,7 +6,7 @@ using API.Entities.Metadata;
|
|||
|
||||
namespace API.Entities;
|
||||
|
||||
public class Series : IEntityDate
|
||||
public class Series : IEntityDate, IHasReadTimeEstimate
|
||||
{
|
||||
public int Id { get; set; }
|
||||
/// <summary>
|
||||
|
@ -66,10 +66,15 @@ public class Series : IEntityDate
|
|||
public DateTime LastChapterAdded { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Total words in a Series (books only)
|
||||
/// Total Word count of all chapters in this chapter.
|
||||
/// </summary>
|
||||
/// <remarks>Word Count is only available from EPUB files</remarks>
|
||||
public long WordCount { get; set; }
|
||||
|
||||
public int MinHoursToRead { get; set; }
|
||||
public int MaxHoursToRead { get; set; }
|
||||
public int AvgHoursToRead { get; set; }
|
||||
|
||||
public SeriesMetadata Metadata { get; set; }
|
||||
|
||||
public ICollection<AppUserRating> Ratings { get; set; } = new List<AppUserRating>();
|
||||
|
@ -87,5 +92,4 @@ public class Series : IEntityDate
|
|||
public List<Volume> Volumes { get; set; }
|
||||
public Library Library { get; set; }
|
||||
public int LibraryId { get; set; }
|
||||
|
||||
}
|
||||
|
|
|
@ -1,11 +1,10 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using API.Entities.Interfaces;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace API.Entities
|
||||
{
|
||||
public class Volume : IEntityDate
|
||||
public class Volume : IEntityDate, IHasReadTimeEstimate
|
||||
{
|
||||
public int Id { get; set; }
|
||||
/// <summary>
|
||||
|
@ -25,12 +24,23 @@ namespace API.Entities
|
|||
/// </summary>
|
||||
/// <remarks>The file is managed internally to Kavita's APPDIR</remarks>
|
||||
public string CoverImage { get; set; }
|
||||
/// <summary>
|
||||
/// Total pages of all chapters in this volume
|
||||
/// </summary>
|
||||
public int Pages { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Total Word count of all chapters in this volume.
|
||||
/// </summary>
|
||||
/// <remarks>Word Count is only available from EPUB files</remarks>
|
||||
public long WordCount { get; set; }
|
||||
public int MinHoursToRead { get; set; }
|
||||
public int MaxHoursToRead { get; set; }
|
||||
public int AvgHoursToRead { get; set; }
|
||||
|
||||
|
||||
// Relationships
|
||||
public Series Series { get; set; }
|
||||
public int SeriesId { get; set; }
|
||||
|
||||
}
|
||||
}
|
||||
|
|
|
@ -113,7 +113,7 @@ namespace API.Helpers
|
|||
opt.MapFrom(src => src.BookThemeName))
|
||||
.ForMember(dest => dest.BookReaderLayoutMode,
|
||||
opt =>
|
||||
opt.MapFrom(src => src.PageLayoutMode));
|
||||
opt.MapFrom(src => src.BookReaderLayoutMode));
|
||||
|
||||
|
||||
CreateMap<AppUserBookmark, BookmarkDto>();
|
||||
|
|
|
@ -14,6 +14,7 @@ public interface ICacheHelper
|
|||
bool CoverImageExists(string path);
|
||||
|
||||
bool HasFileNotChangedSinceCreationOrLastScan(IEntityDate chapter, bool forceUpdate, MangaFile firstFile);
|
||||
bool HasFileChangedSinceLastScan(DateTime lastScan, bool forceUpdate, MangaFile firstFile);
|
||||
|
||||
}
|
||||
|
||||
|
@ -32,6 +33,7 @@ public class CacheHelper : ICacheHelper
|
|||
/// <remarks>If a cover image is locked but the underlying file has been deleted, this will allow regenerating. </remarks>
|
||||
/// <param name="coverPath">This should just be the filename, no path information</param>
|
||||
/// <param name="firstFile"></param>
|
||||
/// <param name="chapterCreated">When the chapter was created (Not Used)</param>
|
||||
/// <param name="forceUpdate">If the user has told us to force the refresh</param>
|
||||
/// <param name="isCoverLocked">If cover has been locked by user. This will force false</param>
|
||||
/// <returns></returns>
|
||||
|
@ -61,6 +63,25 @@ public class CacheHelper : ICacheHelper
|
|||
|| _fileService.HasFileBeenModifiedSince(firstFile.FilePath, firstFile.LastModified)));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Has the file been modified since last scan or is user forcing an update
|
||||
/// </summary>
|
||||
/// <param name="lastScan"></param>
|
||||
/// <param name="forceUpdate"></param>
|
||||
/// <param name="firstFile"></param>
|
||||
/// <returns></returns>
|
||||
public bool HasFileChangedSinceLastScan(DateTime lastScan, bool forceUpdate, MangaFile firstFile)
|
||||
{
|
||||
if (firstFile == null) return false;
|
||||
if (forceUpdate) return true;
|
||||
return _fileService.HasFileBeenModifiedSince(firstFile.FilePath, lastScan)
|
||||
|| _fileService.HasFileBeenModifiedSince(firstFile.FilePath, firstFile.LastModified);
|
||||
// return firstFile != null &&
|
||||
// (!forceUpdate &&
|
||||
// !(_fileService.HasFileBeenModifiedSince(firstFile.FilePath, lastScan)
|
||||
// || _fileService.HasFileBeenModifiedSince(firstFile.FilePath, firstFile.LastModified)));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Determines if a given coverImage path exists
|
||||
/// </summary>
|
||||
|
|
|
@ -51,6 +51,9 @@ namespace API.Helpers.Converters
|
|||
case ServerSettingKey.ConvertBookmarkToWebP:
|
||||
destination.ConvertBookmarkToWebP = bool.Parse(row.Value);
|
||||
break;
|
||||
case ServerSettingKey.EnableSwaggerUi:
|
||||
destination.EnableSwaggerUi = bool.Parse(row.Value);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -2,14 +2,17 @@
|
|||
{
|
||||
public class UserParams
|
||||
{
|
||||
private const int MaxPageSize = 50;
|
||||
public int PageNumber { get; set; } = 1;
|
||||
private int _pageSize = 30;
|
||||
private const int MaxPageSize = int.MaxValue;
|
||||
public int PageNumber { get; init; } = 1;
|
||||
private readonly int _pageSize = 30;
|
||||
|
||||
/// <summary>
|
||||
/// If set to 0, will set as MaxInt
|
||||
/// </summary>
|
||||
public int PageSize
|
||||
{
|
||||
get => _pageSize;
|
||||
set => _pageSize = (value > MaxPageSize) ? MaxPageSize : value;
|
||||
init => _pageSize = (value == 0) ? MaxPageSize : value;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -110,6 +110,26 @@ namespace API.Parser
|
|||
new Regex(
|
||||
@"(卷|册)(?<Volume>\d+)",
|
||||
MatchOptions, RegexTimeout),
|
||||
// Korean Volume: 제n권 -> Volume n, n권 -> Volume n, 63권#200.zip -> Volume 63 (no chapter, #200 is just files inside)
|
||||
new Regex(
|
||||
@"제?(?<Volume>\d+)권",
|
||||
MatchOptions, RegexTimeout),
|
||||
// Korean Season: 시즌n -> Season n,
|
||||
new Regex(
|
||||
@"시즌(?<Volume>\d+\-?\d+)",
|
||||
MatchOptions, RegexTimeout),
|
||||
// Korean Season: 시즌n -> Season n, n시즌 -> season n
|
||||
new Regex(
|
||||
@"(?<Volume>\d+(\-|~)?\d+?)시즌",
|
||||
MatchOptions, RegexTimeout),
|
||||
// Korean Season: 시즌n -> Season n, n시즌 -> season n
|
||||
new Regex(
|
||||
@"시즌(?<Volume>\d+(\-|~)?\d+?)",
|
||||
MatchOptions, RegexTimeout),
|
||||
// Japanese Volume: n巻 -> Volume n
|
||||
new Regex(
|
||||
@"(?<Volume>\d+(?:(\-)\d+)?)巻",
|
||||
MatchOptions, RegexTimeout),
|
||||
};
|
||||
|
||||
private static readonly Regex[] MangaSeriesRegex = new[]
|
||||
|
@ -340,6 +360,22 @@ namespace API.Parser
|
|||
new Regex(
|
||||
@"^(?<Series>.+?)(?:\s|_)(v|vol|tome|t)\.?(\s|_)?(?<Volume>\d+)",
|
||||
MatchOptions, RegexTimeout),
|
||||
// Chinese Volume: 第n卷 -> Volume n, 第n册 -> Volume n, 幽游白书完全版 第03卷 天下 or 阿衰online 第1册
|
||||
new Regex(
|
||||
@"第(?<Volume>\d+)(卷|册)",
|
||||
MatchOptions, RegexTimeout),
|
||||
// Chinese Volume: 卷n -> Volume n, 册n -> Volume n
|
||||
new Regex(
|
||||
@"(卷|册)(?<Volume>\d+)",
|
||||
MatchOptions, RegexTimeout),
|
||||
// Korean Volume: 제n권 -> Volume n, n권 -> Volume n, 63권#200.zip
|
||||
new Regex(
|
||||
@"제?(?<Volume>\d+)권",
|
||||
MatchOptions, RegexTimeout),
|
||||
// Japanese Volume: n巻 -> Volume n
|
||||
new Regex(
|
||||
@"(?<Volume>\d+(?:(\-)\d+)?)巻",
|
||||
MatchOptions, RegexTimeout),
|
||||
};
|
||||
|
||||
private static readonly Regex[] ComicChapterRegex = new[]
|
||||
|
@ -398,11 +434,7 @@ namespace API.Parser
|
|||
new Regex(
|
||||
@"^(?<Series>.+?)-(chapter-)?(?<Chapter>\d+)",
|
||||
MatchOptions, RegexTimeout),
|
||||
// Cyberpunk 2077 - Your Voice 01
|
||||
// new Regex(
|
||||
// @"^(?<Series>.+?\s?-\s?(?:.+?))(?<Chapter>(\d+(\.\d)?)-?(\d+(\.\d)?)?)$",
|
||||
// MatchOptions,
|
||||
// RegexTimeout),
|
||||
|
||||
};
|
||||
|
||||
private static readonly Regex[] ReleaseGroupRegex = new[]
|
||||
|
@ -461,7 +493,14 @@ namespace API.Parser
|
|||
new Regex(
|
||||
@"第(?<Chapter>\d+)话",
|
||||
MatchOptions, RegexTimeout),
|
||||
|
||||
// Korean Chapter: 제n화 -> Chapter n, 가디언즈 오브 갤럭시 죽음의 보석.E0008.7화#44
|
||||
new Regex(
|
||||
@"제?(?<Chapter>\d+\.?\d+)(화|장)",
|
||||
MatchOptions, RegexTimeout),
|
||||
// Korean Chapter: 第10話 -> Chapter n, [ハレム]ナナとカオル ~高校生のSMごっこ~ 第1話
|
||||
new Regex(
|
||||
@"第?(?<Chapter>\d+(?:.\d+|-\d+)?)話",
|
||||
MatchOptions, RegexTimeout),
|
||||
};
|
||||
private static readonly Regex[] MangaEditionRegex = {
|
||||
// Tenjo Tenge {Full Contact Edition} v01 (2011) (Digital) (ASTC).cbz
|
||||
|
@ -525,11 +564,13 @@ namespace API.Parser
|
|||
MatchOptions, RegexTimeout
|
||||
);
|
||||
|
||||
private static readonly ImmutableArray<string> FormatTagSpecialKeyowrds = ImmutableArray.Create(
|
||||
private static readonly ImmutableArray<string> FormatTagSpecialKeywords = ImmutableArray.Create(
|
||||
"Special", "Reference", "Director's Cut", "Box Set", "Box-Set", "Annual", "Anthology", "Epilogue",
|
||||
"One Shot", "One-Shot", "Prologue", "TPB", "Trade Paper Back", "Omnibus", "Compendium", "Absolute", "Graphic Novel",
|
||||
"GN", "FCBD");
|
||||
|
||||
private static readonly char[] LeadingZeroesTrimChars = new[] { '0' };
|
||||
|
||||
public static MangaFormat ParseFormat(string filePath)
|
||||
{
|
||||
if (IsArchive(filePath)) return MangaFormat.Archive;
|
||||
|
@ -544,15 +585,13 @@ namespace API.Parser
|
|||
foreach (var regex in MangaEditionRegex)
|
||||
{
|
||||
var matches = regex.Matches(filePath);
|
||||
foreach (Match match in matches)
|
||||
foreach (var group in matches.Select(match => match.Groups["Edition"])
|
||||
.Where(group => group.Success && group != Match.Empty))
|
||||
{
|
||||
if (match.Groups["Edition"].Success && match.Groups["Edition"].Value != string.Empty)
|
||||
{
|
||||
var edition = match.Groups["Edition"].Value.Replace("{", "").Replace("}", "")
|
||||
.Replace("[", "").Replace("]", "").Replace("(", "").Replace(")", "");
|
||||
|
||||
return edition;
|
||||
}
|
||||
return group.Value
|
||||
.Replace("{", "").Replace("}", "")
|
||||
.Replace("[", "").Replace("]", "")
|
||||
.Replace("(", "").Replace(")", "");
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -567,15 +606,8 @@ namespace API.Parser
|
|||
public static bool HasSpecialMarker(string filePath)
|
||||
{
|
||||
var matches = SpecialMarkerRegex.Matches(filePath);
|
||||
foreach (Match match in matches)
|
||||
{
|
||||
if (match.Groups["Special"].Success && match.Groups["Special"].Value != string.Empty)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
return matches.Select(match => match.Groups["Special"])
|
||||
.Any(group => group.Success && group != Match.Empty);
|
||||
}
|
||||
|
||||
public static string ParseMangaSpecial(string filePath)
|
||||
|
@ -583,12 +615,10 @@ namespace API.Parser
|
|||
foreach (var regex in MangaSpecialRegex)
|
||||
{
|
||||
var matches = regex.Matches(filePath);
|
||||
foreach (Match match in matches)
|
||||
foreach (var group in matches.Select(match => match.Groups["Special"])
|
||||
.Where(group => group.Success && group != Match.Empty))
|
||||
{
|
||||
if (match.Groups["Special"].Success && match.Groups["Special"].Value != string.Empty)
|
||||
{
|
||||
return match.Groups["Special"].Value;
|
||||
}
|
||||
return group.Value;
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -600,12 +630,10 @@ namespace API.Parser
|
|||
foreach (var regex in ComicSpecialRegex)
|
||||
{
|
||||
var matches = regex.Matches(filePath);
|
||||
foreach (Match match in matches)
|
||||
foreach (var group in matches.Select(match => match.Groups["Special"])
|
||||
.Where(group => group.Success && group != Match.Empty))
|
||||
{
|
||||
if (match.Groups["Special"].Success && match.Groups["Special"].Value != string.Empty)
|
||||
{
|
||||
return match.Groups["Special"].Value;
|
||||
}
|
||||
return group.Value;
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -617,12 +645,10 @@ namespace API.Parser
|
|||
foreach (var regex in MangaSeriesRegex)
|
||||
{
|
||||
var matches = regex.Matches(filename);
|
||||
foreach (Match match in matches)
|
||||
foreach (var group in matches.Select(match => match.Groups["Series"])
|
||||
.Where(group => group.Success && group != Match.Empty))
|
||||
{
|
||||
if (match.Groups["Series"].Success && match.Groups["Series"].Value != string.Empty)
|
||||
{
|
||||
return CleanTitle(match.Groups["Series"].Value);
|
||||
}
|
||||
return CleanTitle(group.Value);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -633,12 +659,10 @@ namespace API.Parser
|
|||
foreach (var regex in ComicSeriesRegex)
|
||||
{
|
||||
var matches = regex.Matches(filename);
|
||||
foreach (Match match in matches)
|
||||
foreach (var group in matches.Select(match => match.Groups["Series"])
|
||||
.Where(group => group.Success && group != Match.Empty))
|
||||
{
|
||||
if (match.Groups["Series"].Success && match.Groups["Series"].Value != string.Empty)
|
||||
{
|
||||
return CleanTitle(match.Groups["Series"].Value, true);
|
||||
}
|
||||
return CleanTitle(group.Value, true);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -668,12 +692,12 @@ namespace API.Parser
|
|||
foreach (var regex in ComicVolumeRegex)
|
||||
{
|
||||
var matches = regex.Matches(filename);
|
||||
foreach (Match match in matches)
|
||||
foreach (var group in matches.Select(match => match.Groups))
|
||||
{
|
||||
if (!match.Groups["Volume"].Success || match.Groups["Volume"] == Match.Empty) continue;
|
||||
if (!group["Volume"].Success || group["Volume"] == Match.Empty) continue;
|
||||
|
||||
var value = match.Groups["Volume"].Value;
|
||||
var hasPart = match.Groups["Part"].Success;
|
||||
var value = group["Volume"].Value;
|
||||
var hasPart = group["Part"].Success;
|
||||
return FormatValue(value, hasPart);
|
||||
}
|
||||
}
|
||||
|
@ -779,12 +803,9 @@ namespace API.Parser
|
|||
foreach (var regex in MangaSpecialRegex)
|
||||
{
|
||||
var matches = regex.Matches(title);
|
||||
foreach (Match match in matches)
|
||||
foreach (var match in matches.Where(m => m.Success))
|
||||
{
|
||||
if (match.Success)
|
||||
{
|
||||
title = title.Replace(match.Value, string.Empty).Trim();
|
||||
}
|
||||
title = title.Replace(match.Value, string.Empty).Trim();
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -796,12 +817,9 @@ namespace API.Parser
|
|||
foreach (var regex in EuropeanComicRegex)
|
||||
{
|
||||
var matches = regex.Matches(title);
|
||||
foreach (Match match in matches)
|
||||
foreach (var match in matches.Where(m => m.Success))
|
||||
{
|
||||
if (match.Success)
|
||||
{
|
||||
title = title.Replace(match.Value, string.Empty).Trim();
|
||||
}
|
||||
title = title.Replace(match.Value, string.Empty).Trim();
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -813,12 +831,9 @@ namespace API.Parser
|
|||
foreach (var regex in ComicSpecialRegex)
|
||||
{
|
||||
var matches = regex.Matches(title);
|
||||
foreach (Match match in matches)
|
||||
foreach (var match in matches.Where(m => m.Success))
|
||||
{
|
||||
if (match.Success)
|
||||
{
|
||||
title = title.Replace(match.Value, string.Empty).Trim();
|
||||
}
|
||||
title = title.Replace(match.Value, string.Empty).Trim();
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -876,12 +891,9 @@ namespace API.Parser
|
|||
foreach (var regex in ReleaseGroupRegex)
|
||||
{
|
||||
var matches = regex.Matches(title);
|
||||
foreach (Match match in matches)
|
||||
foreach (var match in matches.Where(m => m.Success))
|
||||
{
|
||||
if (match.Success)
|
||||
{
|
||||
title = title.Replace(match.Value, string.Empty);
|
||||
}
|
||||
title = title.Replace(match.Value, string.Empty);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -916,8 +928,8 @@ namespace API.Parser
|
|||
|
||||
public static string RemoveLeadingZeroes(string title)
|
||||
{
|
||||
var ret = title.TrimStart(new[] { '0' });
|
||||
return ret == string.Empty ? "0" : ret;
|
||||
var ret = title.TrimStart(LeadingZeroesTrimChars);
|
||||
return string.IsNullOrEmpty(ret) ? "0" : ret;
|
||||
}
|
||||
|
||||
public static bool IsArchive(string filePath)
|
||||
|
@ -1060,7 +1072,7 @@ namespace API.Parser
|
|||
/// <returns></returns>
|
||||
public static bool HasComicInfoSpecial(string comicInfoFormat)
|
||||
{
|
||||
return FormatTagSpecialKeyowrds.Contains(comicInfoFormat);
|
||||
return FormatTagSpecialKeywords.Contains(comicInfoFormat);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -249,7 +249,7 @@ namespace API.Services
|
|||
|
||||
/// <summary>
|
||||
/// Given an archive stream, will assess whether directory needs to be flattened so that the extracted archive files are directly
|
||||
/// under extract path and not nested in subfolders. See <see cref="DirectoryInfoExtensions"/> Flatten method.
|
||||
/// under extract path and not nested in subfolders. See <see cref="DirectoryService"/> Flatten method.
|
||||
/// </summary>
|
||||
/// <param name="archive">An opened archive stream</param>
|
||||
/// <returns></returns>
|
||||
|
|
|
@ -47,6 +47,7 @@ namespace API.Services
|
|||
/// </summary>
|
||||
/// <param name="fileFilePath"></param>
|
||||
/// <param name="targetDirectory">Where the files will be extracted to. If doesn't exist, will be created.</param>
|
||||
[Obsolete("This method of reading is no longer supported. Please use native pdf reader")]
|
||||
void ExtractPdfImages(string fileFilePath, string targetDirectory);
|
||||
|
||||
Task<string> ScopePage(HtmlDocument doc, EpubBookRef book, string apiBase, HtmlNode body, Dictionary<string, int> mappings, int page);
|
||||
|
@ -246,12 +247,16 @@ namespace API.Services
|
|||
private static void ScopeImages(HtmlDocument doc, EpubBookRef book, string apiBase)
|
||||
{
|
||||
var images = doc.DocumentNode.SelectNodes("//img")
|
||||
?? doc.DocumentNode.SelectNodes("//image");
|
||||
?? doc.DocumentNode.SelectNodes("//image") ?? doc.DocumentNode.SelectNodes("//svg");
|
||||
|
||||
if (images == null) return;
|
||||
|
||||
|
||||
var parent = images.First().ParentNode;
|
||||
|
||||
foreach (var image in images)
|
||||
{
|
||||
|
||||
string key = null;
|
||||
if (image.Attributes["src"] != null)
|
||||
{
|
||||
|
@ -269,6 +274,7 @@ namespace API.Services
|
|||
image.Attributes.Add(key, $"{apiBase}" + imageFile);
|
||||
|
||||
// Add a custom class that the reader uses to ensure images stay within reader
|
||||
parent.AddClass("kavita-scale-width-container");
|
||||
image.AddClass("kavita-scale-width");
|
||||
}
|
||||
|
||||
|
|
|
@ -19,6 +19,7 @@ public interface IBookmarkService
|
|||
Task<bool> BookmarkPage(AppUser userWithBookmarks, BookmarkDto bookmarkDto, string imageToBookmark);
|
||||
Task<bool> RemoveBookmarkPage(AppUser userWithBookmarks, BookmarkDto bookmarkDto);
|
||||
Task<IEnumerable<string>> GetBookmarkFilesById(IEnumerable<int> bookmarkIds);
|
||||
[DisableConcurrentExecution(timeoutInSeconds: 2 * 60 * 60), AutomaticRetry(Attempts = 0)]
|
||||
Task ConvertAllBookmarkToWebP();
|
||||
|
||||
}
|
||||
|
@ -173,7 +174,6 @@ public class BookmarkService : IBookmarkService
|
|||
/// <summary>
|
||||
/// This is a long-running job that will convert all bookmarks into WebP. Do not invoke anyway except via Hangfire.
|
||||
/// </summary>
|
||||
[DisableConcurrentExecution(timeoutInSeconds: 2 * 60 * 60), AutomaticRetry(Attempts = 0)]
|
||||
public async Task ConvertAllBookmarkToWebP()
|
||||
{
|
||||
var bookmarkDirectory =
|
||||
|
|
|
@ -29,10 +29,10 @@ namespace API.Services
|
|||
void CleanupBookmarks(IEnumerable<int> seriesIds);
|
||||
string GetCachedPagePath(Chapter chapter, int page);
|
||||
string GetCachedBookmarkPagePath(int seriesId, int page);
|
||||
string GetCachedEpubFile(int chapterId, Chapter chapter);
|
||||
string GetCachedFile(Chapter chapter);
|
||||
public void ExtractChapterFiles(string extractPath, IReadOnlyList<MangaFile> files);
|
||||
Task<int> CacheBookmarkForSeries(int userId, int seriesId);
|
||||
void CleanupBookmarkCache(int bookmarkDtoSeriesId);
|
||||
void CleanupBookmarkCache(int seriesId);
|
||||
}
|
||||
public class CacheService : ICacheService
|
||||
{
|
||||
|
@ -73,14 +73,13 @@ namespace API.Services
|
|||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the full path to the cached epub file. If the file does not exist, will fallback to the original.
|
||||
/// Returns the full path to the cached file. If the file does not exist, will fallback to the original.
|
||||
/// </summary>
|
||||
/// <param name="chapterId"></param>
|
||||
/// <param name="chapter"></param>
|
||||
/// <returns></returns>
|
||||
public string GetCachedEpubFile(int chapterId, Chapter chapter)
|
||||
public string GetCachedFile(Chapter chapter)
|
||||
{
|
||||
var extractPath = GetCachePath(chapterId);
|
||||
var extractPath = GetCachePath(chapter.Id);
|
||||
var path = Path.Join(extractPath, _directoryService.FileSystem.Path.GetFileName(chapter.Files.First().FilePath));
|
||||
if (!(_directoryService.FileSystem.FileInfo.FromFileName(path).Exists))
|
||||
{
|
||||
|
@ -89,6 +88,7 @@ namespace API.Services
|
|||
return path;
|
||||
}
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Caches the files for the given chapter to CacheDirectory
|
||||
/// </summary>
|
||||
|
@ -136,25 +136,25 @@ namespace API.Services
|
|||
extraPath = file.Id + string.Empty;
|
||||
}
|
||||
|
||||
if (file.Format == MangaFormat.Archive)
|
||||
switch (file.Format)
|
||||
{
|
||||
_readingItemService.Extract(file.FilePath, Path.Join(extractPath, extraPath), file.Format);
|
||||
}
|
||||
else if (file.Format == MangaFormat.Pdf)
|
||||
{
|
||||
_readingItemService.Extract(file.FilePath, Path.Join(extractPath, extraPath), file.Format);
|
||||
}
|
||||
else if (file.Format == MangaFormat.Epub)
|
||||
{
|
||||
removeNonImages = false;
|
||||
if (!_directoryService.FileSystem.File.Exists(files[0].FilePath))
|
||||
case MangaFormat.Archive:
|
||||
_readingItemService.Extract(file.FilePath, Path.Join(extractPath, extraPath), file.Format);
|
||||
break;
|
||||
case MangaFormat.Epub:
|
||||
case MangaFormat.Pdf:
|
||||
{
|
||||
_logger.LogError("{Archive} does not exist on disk", files[0].FilePath);
|
||||
throw new KavitaException($"{files[0].FilePath} does not exist on disk");
|
||||
}
|
||||
removeNonImages = false;
|
||||
if (!_directoryService.FileSystem.File.Exists(files[0].FilePath))
|
||||
{
|
||||
_logger.LogError("{File} does not exist on disk", files[0].FilePath);
|
||||
throw new KavitaException($"{files[0].FilePath} does not exist on disk");
|
||||
}
|
||||
|
||||
_directoryService.ExistOrCreate(extractPath);
|
||||
_directoryService.CopyFileToDirectory(files[0].FilePath, extractPath);
|
||||
_directoryService.ExistOrCreate(extractPath);
|
||||
_directoryService.CopyFileToDirectory(files[0].FilePath, extractPath);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -6,6 +6,8 @@ using System.IO.Abstractions;
|
|||
using System.Linq;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Threading.Tasks;
|
||||
using API.DTOs.System;
|
||||
using API.Entities.Enums;
|
||||
using API.Extensions;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
|
@ -29,7 +31,7 @@ namespace API.Services
|
|||
/// </summary>
|
||||
/// <param name="rootPath">Absolute path of directory to scan.</param>
|
||||
/// <returns>List of folder names</returns>
|
||||
IEnumerable<string> ListDirectory(string rootPath);
|
||||
IEnumerable<DirectoryDto> ListDirectory(string rootPath);
|
||||
Task<byte[]> ReadFileAsync(string path);
|
||||
bool CopyFilesToDirectory(IEnumerable<string> filePaths, string directoryPath, string prepend = "");
|
||||
bool Exists(string directory);
|
||||
|
@ -434,14 +436,18 @@ namespace API.Services
|
|||
/// </summary>
|
||||
/// <param name="rootPath"></param>
|
||||
/// <returns></returns>
|
||||
public IEnumerable<string> ListDirectory(string rootPath)
|
||||
public IEnumerable<DirectoryDto> ListDirectory(string rootPath)
|
||||
{
|
||||
if (!FileSystem.Directory.Exists(rootPath)) return ImmutableList<string>.Empty;
|
||||
if (!FileSystem.Directory.Exists(rootPath)) return ImmutableList<DirectoryDto>.Empty;
|
||||
|
||||
var di = FileSystem.DirectoryInfo.FromDirectoryName(rootPath);
|
||||
var dirs = di.GetDirectories()
|
||||
.Where(dir => !(dir.Attributes.HasFlag(FileAttributes.Hidden) || dir.Attributes.HasFlag(FileAttributes.System)))
|
||||
.Select(d => d.Name).ToImmutableList();
|
||||
.Select(d => new DirectoryDto()
|
||||
{
|
||||
Name = d.Name,
|
||||
FullPath = d.FullName,
|
||||
}).ToImmutableList();
|
||||
|
||||
return dirs;
|
||||
}
|
||||
|
@ -724,7 +730,7 @@ namespace API.Services
|
|||
FileSystem.Path.Join(directoryName, "test.txt"),
|
||||
string.Empty);
|
||||
}
|
||||
catch (Exception ex)
|
||||
catch (Exception)
|
||||
{
|
||||
ClearAndDeleteDirectory(directoryName);
|
||||
return false;
|
||||
|
|
|
@ -50,7 +50,7 @@ public class ImageService : IImageService
|
|||
_directoryService = directoryService;
|
||||
}
|
||||
|
||||
public void ExtractImages(string fileFilePath, string targetDirectory, int fileCount)
|
||||
public void ExtractImages(string fileFilePath, string targetDirectory, int fileCount = 1)
|
||||
{
|
||||
_directoryService.ExistOrCreate(targetDirectory);
|
||||
if (fileCount == 1)
|
||||
|
|
|
@ -27,13 +27,15 @@ public interface IMetadataService
|
|||
/// </summary>
|
||||
/// <param name="libraryId"></param>
|
||||
/// <param name="forceUpdate"></param>
|
||||
[DisableConcurrentExecution(timeoutInSeconds: 60 * 60 * 60)]
|
||||
[AutomaticRetry(Attempts = 0, OnAttemptsExceeded = AttemptsExceededAction.Delete)]
|
||||
Task RefreshMetadata(int libraryId, bool forceUpdate = false);
|
||||
/// <summary>
|
||||
/// Performs a forced refresh of metadata just for a series and it's nested entities
|
||||
/// </summary>
|
||||
/// <param name="libraryId"></param>
|
||||
/// <param name="seriesId"></param>
|
||||
Task RefreshMetadataForSeries(int libraryId, int seriesId, bool forceUpdate = false);
|
||||
Task RefreshMetadataForSeries(int libraryId, int seriesId, bool forceUpdate = true);
|
||||
}
|
||||
|
||||
public class MetadataService : IMetadataService
|
||||
|
@ -196,8 +198,6 @@ public class MetadataService : IMetadataService
|
|||
/// <remarks>This can be heavy on memory first run</remarks>
|
||||
/// <param name="libraryId"></param>
|
||||
/// <param name="forceUpdate">Force updating cover image even if underlying file has not been modified or chapter already has a cover image</param>
|
||||
[DisableConcurrentExecution(timeoutInSeconds: 360)]
|
||||
[AutomaticRetry(Attempts = 0, OnAttemptsExceeded = AttemptsExceededAction.Delete)]
|
||||
public async Task RefreshMetadata(int libraryId, bool forceUpdate = false)
|
||||
{
|
||||
var library = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(libraryId, LibraryIncludes.None);
|
||||
|
|
|
@ -7,6 +7,7 @@ using API.Comparators;
|
|||
using API.Data;
|
||||
using API.Data.Repositories;
|
||||
using API.DTOs;
|
||||
using API.DTOs.Reader;
|
||||
using API.Entities;
|
||||
using API.Extensions;
|
||||
using API.SignalR;
|
||||
|
@ -28,6 +29,7 @@ public interface IReaderService
|
|||
Task<ChapterDto> GetContinuePoint(int seriesId, int userId);
|
||||
Task MarkChaptersUntilAsRead(AppUser user, int seriesId, float chapterNumber);
|
||||
Task MarkVolumesUntilAsRead(AppUser user, int seriesId, int volumeNumber);
|
||||
HourEstimateRangeDto GetTimeEstimate(long wordCount, int pageCount, bool isEpub);
|
||||
}
|
||||
|
||||
public class ReaderService : IReaderService
|
||||
|
@ -38,6 +40,14 @@ public class ReaderService : IReaderService
|
|||
private readonly ChapterSortComparer _chapterSortComparer = new ChapterSortComparer();
|
||||
private readonly ChapterSortComparerZeroFirst _chapterSortComparerForInChapterSorting = new ChapterSortComparerZeroFirst();
|
||||
|
||||
public const float MinWordsPerHour = 10260F;
|
||||
public const float MaxWordsPerHour = 30000F;
|
||||
public const float AvgWordsPerHour = (MaxWordsPerHour + MinWordsPerHour) / 2F;
|
||||
public const float MinPagesPerMinute = 3.33F;
|
||||
public const float MaxPagesPerMinute = 2.75F;
|
||||
public const float AvgPagesPerMinute = (MaxPagesPerMinute + MinPagesPerMinute) / 2F;
|
||||
|
||||
|
||||
public ReaderService(IUnitOfWork unitOfWork, ILogger<ReaderService> logger, IEventHub eventHub)
|
||||
{
|
||||
_unitOfWork = unitOfWork;
|
||||
|
@ -160,7 +170,7 @@ 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 = new List<AppUserProgress>
|
||||
{
|
||||
user.Progresses.First()
|
||||
};
|
||||
|
@ -320,7 +330,7 @@ public class ReaderService : IReaderService
|
|||
{
|
||||
var chapterVolume = volumes.FirstOrDefault();
|
||||
if (chapterVolume?.Number != 0) return -1;
|
||||
var firstChapter = chapterVolume.Chapters.OrderBy(x => double.Parse(x.Number), _chapterSortComparer).FirstOrDefault();
|
||||
var firstChapter = chapterVolume.Chapters.MinBy(x => double.Parse(x.Number), _chapterSortComparer);
|
||||
if (firstChapter == null) return -1;
|
||||
return firstChapter.Id;
|
||||
}
|
||||
|
@ -362,17 +372,16 @@ public class ReaderService : IReaderService
|
|||
if (volume.Number == currentVolume.Number - 1)
|
||||
{
|
||||
if (currentVolume.Number - 1 == 0) break; // If we have walked all the way to chapter volume, then we should break so logic outside can work
|
||||
var lastChapter = volume.Chapters
|
||||
.OrderBy(x => double.Parse(x.Number), _chapterSortComparerForInChapterSorting).LastOrDefault();
|
||||
var lastChapter = volume.Chapters.MaxBy(x => double.Parse(x.Number), _chapterSortComparerForInChapterSorting);
|
||||
if (lastChapter == null) return -1;
|
||||
return lastChapter.Id;
|
||||
}
|
||||
}
|
||||
|
||||
var lastVolume = volumes.OrderBy(v => v.Number).LastOrDefault();
|
||||
var lastVolume = volumes.MaxBy(v => v.Number);
|
||||
if (currentVolume.Number == 0 && currentVolume.Number != lastVolume?.Number && lastVolume?.Chapters.Count > 1)
|
||||
{
|
||||
var lastChapter = lastVolume.Chapters.OrderBy(x => double.Parse(x.Number), _chapterSortComparerForInChapterSorting).LastOrDefault();
|
||||
var lastChapter = lastVolume.Chapters.MaxBy(x => double.Parse(x.Number), _chapterSortComparerForInChapterSorting);
|
||||
if (lastChapter == null) return -1;
|
||||
return lastChapter.Id;
|
||||
}
|
||||
|
@ -396,7 +405,7 @@ public class ReaderService : IReaderService
|
|||
if (progress.Count == 0)
|
||||
{
|
||||
// I think i need a way to sort volumes last
|
||||
return volumes.OrderBy(v => double.Parse(v.Number + ""), _chapterSortComparer).First().Chapters
|
||||
return volumes.OrderBy(v => double.Parse(v.Number + string.Empty), _chapterSortComparer).First().Chapters
|
||||
.OrderBy(c => float.Parse(c.Number)).First();
|
||||
}
|
||||
|
||||
|
@ -470,7 +479,7 @@ public class ReaderService : IReaderService
|
|||
/// <param name="chapterNumber"></param>
|
||||
public async Task MarkChaptersUntilAsRead(AppUser user, int seriesId, float chapterNumber)
|
||||
{
|
||||
var volumes = await _unitOfWork.VolumeRepository.GetVolumesForSeriesAsync(new List<int>() { seriesId }, true);
|
||||
var volumes = await _unitOfWork.VolumeRepository.GetVolumesForSeriesAsync(new List<int> { seriesId }, true);
|
||||
foreach (var volume in volumes.OrderBy(v => v.Number))
|
||||
{
|
||||
var chapters = volume.Chapters
|
||||
|
@ -482,10 +491,53 @@ public class ReaderService : IReaderService
|
|||
|
||||
public async Task MarkVolumesUntilAsRead(AppUser user, int seriesId, int volumeNumber)
|
||||
{
|
||||
var volumes = await _unitOfWork.VolumeRepository.GetVolumesForSeriesAsync(new List<int>() { seriesId }, true);
|
||||
var volumes = await _unitOfWork.VolumeRepository.GetVolumesForSeriesAsync(new List<int> { seriesId }, true);
|
||||
foreach (var volume in volumes.OrderBy(v => v.Number).Where(v => v.Number <= volumeNumber && v.Number > 0))
|
||||
{
|
||||
MarkChaptersAsRead(user, volume.SeriesId, volume.Chapters);
|
||||
}
|
||||
}
|
||||
|
||||
public HourEstimateRangeDto GetTimeEstimate(long wordCount, int pageCount, bool isEpub)
|
||||
{
|
||||
if (isEpub)
|
||||
{
|
||||
var minHours = Math.Max((int) Math.Round((wordCount / MinWordsPerHour)), 0);
|
||||
var maxHours = Math.Max((int) Math.Round((wordCount / MaxWordsPerHour)), 0);
|
||||
if (maxHours < minHours)
|
||||
{
|
||||
return new HourEstimateRangeDto
|
||||
{
|
||||
MinHours = maxHours,
|
||||
MaxHours = minHours,
|
||||
AvgHours = (int) Math.Round((wordCount / AvgWordsPerHour))
|
||||
};
|
||||
}
|
||||
return new HourEstimateRangeDto
|
||||
{
|
||||
MinHours = minHours,
|
||||
MaxHours = maxHours,
|
||||
AvgHours = (int) Math.Round((wordCount / AvgWordsPerHour))
|
||||
};
|
||||
}
|
||||
|
||||
var minHoursPages = Math.Max((int) Math.Round((pageCount / MinPagesPerMinute / 60F)), 0);
|
||||
var maxHoursPages = Math.Max((int) Math.Round((pageCount / MaxPagesPerMinute / 60F)), 0);
|
||||
if (maxHoursPages < minHoursPages)
|
||||
{
|
||||
return new HourEstimateRangeDto
|
||||
{
|
||||
MinHours = maxHoursPages,
|
||||
MaxHours = minHoursPages,
|
||||
AvgHours = (int) Math.Round((pageCount / AvgPagesPerMinute / 60F))
|
||||
};
|
||||
}
|
||||
|
||||
return new HourEstimateRangeDto
|
||||
{
|
||||
MinHours = minHoursPages,
|
||||
MaxHours = maxHoursPages,
|
||||
AvgHours = (int) Math.Round((pageCount / AvgPagesPerMinute / 60F))
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
@ -110,15 +110,13 @@ public class ReadingItemService : IReadingItemService
|
|||
{
|
||||
switch (format)
|
||||
{
|
||||
case MangaFormat.Pdf:
|
||||
_bookService.ExtractPdfImages(fileFilePath, targetDirectory);
|
||||
break;
|
||||
case MangaFormat.Archive:
|
||||
_archiveService.ExtractArchive(fileFilePath, targetDirectory);
|
||||
break;
|
||||
case MangaFormat.Image:
|
||||
_imageService.ExtractImages(fileFilePath, targetDirectory, imageCount);
|
||||
break;
|
||||
case MangaFormat.Pdf:
|
||||
case MangaFormat.Unknown:
|
||||
case MangaFormat.Epub:
|
||||
break;
|
||||
|
|
|
@ -8,9 +8,10 @@ using API.Data;
|
|||
using API.DTOs;
|
||||
using API.DTOs.CollectionTags;
|
||||
using API.DTOs.Metadata;
|
||||
using API.DTOs.Reader;
|
||||
using API.DTOs.SeriesDetail;
|
||||
using API.Entities;
|
||||
using API.Entities.Enums;
|
||||
using API.Extensions;
|
||||
using API.Helpers;
|
||||
using API.SignalR;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
@ -98,7 +99,7 @@ public class SeriesService : ISeriesService
|
|||
series.Metadata.SummaryLocked = true;
|
||||
}
|
||||
|
||||
if (series.Metadata.Language != updateSeriesMetadataDto.SeriesMetadata.Language)
|
||||
if (series.Metadata.Language != updateSeriesMetadataDto.SeriesMetadata?.Language)
|
||||
{
|
||||
series.Metadata.Language = updateSeriesMetadataDto.SeriesMetadata?.Language;
|
||||
series.Metadata.LanguageLocked = true;
|
||||
|
@ -112,7 +113,7 @@ public class SeriesService : ISeriesService
|
|||
});
|
||||
|
||||
series.Metadata.Genres ??= new List<Genre>();
|
||||
UpdateGenreList(updateSeriesMetadataDto.SeriesMetadata.Genres, series, allGenres, (genre) =>
|
||||
UpdateGenreList(updateSeriesMetadataDto.SeriesMetadata?.Genres, series, allGenres, (genre) =>
|
||||
{
|
||||
series.Metadata.Genres.Add(genre);
|
||||
}, () => series.Metadata.GenresLocked = true);
|
||||
|
@ -458,7 +459,6 @@ public class SeriesService : ISeriesService
|
|||
var volumes = (await _unitOfWork.VolumeRepository.GetVolumesDtoAsync(seriesId, userId))
|
||||
.OrderBy(v => Parser.Parser.MinNumberFromRange(v.Name))
|
||||
.ToList();
|
||||
var chapters = volumes.SelectMany(v => v.Chapters).ToList();
|
||||
|
||||
// For books, the Name of the Volume is remapped to the actual name of the book, rather than Volume number.
|
||||
var processedVolumes = new List<VolumeDto>();
|
||||
|
@ -479,8 +479,15 @@ public class SeriesService : ISeriesService
|
|||
processedVolumes.ForEach(v => v.Name = $"Volume {v.Name}");
|
||||
}
|
||||
|
||||
|
||||
var specials = new List<ChapterDto>();
|
||||
var chapters = volumes.SelectMany(v => v.Chapters.Select(c =>
|
||||
{
|
||||
if (v.Number == 0) return c;
|
||||
c.VolumeTitle = v.Name;
|
||||
return c;
|
||||
})).ToList();
|
||||
|
||||
|
||||
foreach (var chapter in chapters)
|
||||
{
|
||||
chapter.Title = FormatChapterTitle(chapter, libraryType);
|
||||
|
@ -490,7 +497,6 @@ public class SeriesService : ISeriesService
|
|||
specials.Add(chapter);
|
||||
}
|
||||
|
||||
|
||||
// Don't show chapter 0 (aka single volume chapters) in the Chapters tab or books that are just single numbers (they show as volumes)
|
||||
IEnumerable<ChapterDto> retChapters;
|
||||
if (libraryType == LibraryType.Book)
|
||||
|
@ -503,29 +509,28 @@ public class SeriesService : ISeriesService
|
|||
.OrderBy(c => float.Parse(c.Number), new ChapterSortComparer());
|
||||
}
|
||||
|
||||
|
||||
var storylineChapters = volumes
|
||||
.Where(v => v.Number == 0)
|
||||
.SelectMany(v => v.Chapters.Where(c => !c.IsSpecial))
|
||||
.OrderBy(c => float.Parse(c.Number), new ChapterSortComparer());
|
||||
|
||||
return new SeriesDetailDto()
|
||||
{
|
||||
Specials = specials,
|
||||
Chapters = retChapters,
|
||||
Volumes = processedVolumes,
|
||||
StorylineChapters = volumes
|
||||
.Where(v => v.Number == 0)
|
||||
.SelectMany(v => v.Chapters.Where(c => !c.IsSpecial))
|
||||
.OrderBy(c => float.Parse(c.Number), new ChapterSortComparer())
|
||||
|
||||
StorylineChapters = storylineChapters
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Should we show the given chapter on the UI. We only show non-specials and non-zero chapters.
|
||||
/// </summary>
|
||||
/// <param name="c"></param>
|
||||
/// <param name="chapter"></param>
|
||||
/// <returns></returns>
|
||||
private static bool ShouldIncludeChapter(ChapterDto c)
|
||||
private static bool ShouldIncludeChapter(ChapterDto chapter)
|
||||
{
|
||||
return !c.IsSpecial && !c.Number.Equals(Parser.Parser.DefaultChapter);
|
||||
return !chapter.IsSpecial && !chapter.Number.Equals(Parser.Parser.DefaultChapter);
|
||||
}
|
||||
|
||||
public static void RenameVolumeName(ChapterDto firstChapter, VolumeDto volume, LibraryType libraryType)
|
||||
|
|
|
@ -180,7 +180,7 @@ public class TaskScheduler : ITaskScheduler
|
|||
BackgroundJob.Enqueue(() => _metadataService.RefreshMetadata(libraryId, forceUpdate));
|
||||
}
|
||||
|
||||
public void RefreshSeriesMetadata(int libraryId, int seriesId, bool forceUpdate = true)
|
||||
public void RefreshSeriesMetadata(int libraryId, int seriesId, bool forceUpdate = false)
|
||||
{
|
||||
_logger.LogInformation("Enqueuing series metadata refresh for: {SeriesId}", seriesId);
|
||||
BackgroundJob.Enqueue(() => _metadataService.RefreshMetadataForSeries(libraryId, seriesId, forceUpdate));
|
||||
|
|
|
@ -45,11 +45,6 @@ public class BackupService : IBackupService
|
|||
_config = config;
|
||||
_eventHub = eventHub;
|
||||
|
||||
// var maxRollingFiles = config.GetMaxRollingFiles();
|
||||
// var loggingSection = config.GetLoggingFileName();
|
||||
// var files = GetLogFiles(maxRollingFiles, loggingSection);
|
||||
|
||||
|
||||
_backupFiles = new List<string>()
|
||||
{
|
||||
"appsettings.json",
|
||||
|
@ -59,11 +54,6 @@ public class BackupService : IBackupService
|
|||
"kavita.db-shm", // This wont always be there
|
||||
"kavita.db-wal" // This wont always be there
|
||||
};
|
||||
|
||||
// foreach (var file in files.Select(f => (_directoryService.FileSystem.FileInfo.FromFileName(f)).Name))
|
||||
// {
|
||||
// _backupFiles.Add(file);
|
||||
// }
|
||||
}
|
||||
|
||||
public IEnumerable<string> GetLogFiles(int maxRollingFiles, string logFileName)
|
||||
|
|
|
@ -17,8 +17,10 @@ namespace API.Services.Tasks.Metadata;
|
|||
|
||||
public interface IWordCountAnalyzerService
|
||||
{
|
||||
[DisableConcurrentExecution(timeoutInSeconds: 60 * 60 * 60)]
|
||||
[AutomaticRetry(Attempts = 0, OnAttemptsExceeded = AttemptsExceededAction.Delete)]
|
||||
Task ScanLibrary(int libraryId, bool forceUpdate = false);
|
||||
Task ScanSeries(int libraryId, int seriesId, bool forceUpdate = false);
|
||||
Task ScanSeries(int libraryId, int seriesId, bool forceUpdate = true);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
@ -30,18 +32,19 @@ public class WordCountAnalyzerService : IWordCountAnalyzerService
|
|||
private readonly IUnitOfWork _unitOfWork;
|
||||
private readonly IEventHub _eventHub;
|
||||
private readonly ICacheHelper _cacheHelper;
|
||||
private readonly IReaderService _readerService;
|
||||
|
||||
public WordCountAnalyzerService(ILogger<WordCountAnalyzerService> logger, IUnitOfWork unitOfWork, IEventHub eventHub,
|
||||
ICacheHelper cacheHelper)
|
||||
ICacheHelper cacheHelper, IReaderService readerService)
|
||||
{
|
||||
_logger = logger;
|
||||
_unitOfWork = unitOfWork;
|
||||
_eventHub = eventHub;
|
||||
_cacheHelper = cacheHelper;
|
||||
_readerService = readerService;
|
||||
}
|
||||
|
||||
[DisableConcurrentExecution(timeoutInSeconds: 360)]
|
||||
[AutomaticRetry(Attempts = 0, OnAttemptsExceeded = AttemptsExceededAction.Delete)]
|
||||
|
||||
public async Task ScanLibrary(int libraryId, bool forceUpdate = false)
|
||||
{
|
||||
var sw = Stopwatch.StartNew();
|
||||
|
@ -52,7 +55,6 @@ public class WordCountAnalyzerService : IWordCountAnalyzerService
|
|||
|
||||
var chunkInfo = await _unitOfWork.SeriesRepository.GetChunkInfo(library.Id);
|
||||
var stopwatch = Stopwatch.StartNew();
|
||||
var totalTime = 0L;
|
||||
_logger.LogInformation("[MetadataService] Refreshing Library {LibraryName}. Total Items: {TotalSize}. Total Chunks: {TotalChunks} with {ChunkSize} size", library.Name, chunkInfo.TotalSize, chunkInfo.TotalChunks, chunkInfo.ChunkSize);
|
||||
|
||||
await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress,
|
||||
|
@ -61,7 +63,6 @@ public class WordCountAnalyzerService : IWordCountAnalyzerService
|
|||
for (var chunk = 1; chunk <= chunkInfo.TotalChunks; chunk++)
|
||||
{
|
||||
if (chunkInfo.TotalChunks == 0) continue;
|
||||
totalTime += stopwatch.ElapsedMilliseconds;
|
||||
stopwatch.Restart();
|
||||
|
||||
_logger.LogInformation("[MetadataService] Processing chunk {ChunkNumber} / {TotalChunks} with size {ChunkSize}. Series ({SeriesStart} - {SeriesEnd}",
|
||||
|
@ -113,7 +114,7 @@ public class WordCountAnalyzerService : IWordCountAnalyzerService
|
|||
|
||||
}
|
||||
|
||||
public async Task ScanSeries(int libraryId, int seriesId, bool forceUpdate = false)
|
||||
public async Task ScanSeries(int libraryId, int seriesId, bool forceUpdate = true)
|
||||
{
|
||||
var sw = Stopwatch.StartNew();
|
||||
var series = await _unitOfWork.SeriesRepository.GetFullSeriesForSeriesIdAsync(seriesId);
|
||||
|
@ -126,7 +127,7 @@ public class WordCountAnalyzerService : IWordCountAnalyzerService
|
|||
await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress,
|
||||
MessageFactory.WordCountAnalyzerProgressEvent(libraryId, 0F, ProgressEventType.Started, series.Name));
|
||||
|
||||
await ProcessSeries(series);
|
||||
await ProcessSeries(series, forceUpdate);
|
||||
|
||||
if (_unitOfWork.HasChanges())
|
||||
{
|
||||
|
@ -141,58 +142,84 @@ public class WordCountAnalyzerService : IWordCountAnalyzerService
|
|||
|
||||
private async Task ProcessSeries(Series series, bool forceUpdate = false, bool useFileName = true)
|
||||
{
|
||||
if (series.Format != MangaFormat.Epub) return;
|
||||
|
||||
long totalSum = 0;
|
||||
|
||||
foreach (var chapter in series.Volumes.SelectMany(v => v.Chapters))
|
||||
var isEpub = series.Format == MangaFormat.Epub;
|
||||
series.WordCount = 0;
|
||||
foreach (var volume in series.Volumes)
|
||||
{
|
||||
// This compares if it's changed since a file scan only
|
||||
if (!_cacheHelper.HasFileNotChangedSinceCreationOrLastScan(chapter, false,
|
||||
chapter.Files.FirstOrDefault()) && chapter.WordCount != 0)
|
||||
continue;
|
||||
|
||||
long sum = 0;
|
||||
var fileCounter = 1;
|
||||
foreach (var file in chapter.Files.Select(file => file.FilePath))
|
||||
volume.WordCount = 0;
|
||||
foreach (var chapter in volume.Chapters)
|
||||
{
|
||||
var pageCounter = 1;
|
||||
try
|
||||
// This compares if it's changed since a file scan only
|
||||
var firstFile = chapter.Files.FirstOrDefault();
|
||||
if (firstFile == null) return;
|
||||
if (!_cacheHelper.HasFileChangedSinceLastScan(firstFile.LastFileAnalysis, forceUpdate,
|
||||
firstFile))
|
||||
continue;
|
||||
|
||||
if (series.Format == MangaFormat.Epub)
|
||||
{
|
||||
using var book = await EpubReader.OpenBookAsync(file, BookService.BookReaderOptions);
|
||||
|
||||
var totalPages = book.Content.Html.Values;
|
||||
foreach (var bookPage in totalPages)
|
||||
long sum = 0;
|
||||
var fileCounter = 1;
|
||||
foreach (var file in chapter.Files)
|
||||
{
|
||||
var progress = Math.Max(0F,
|
||||
Math.Min(1F, (fileCounter * pageCounter) * 1F / (chapter.Files.Count * totalPages.Count)));
|
||||
var filePath = file.FilePath;
|
||||
var pageCounter = 1;
|
||||
try
|
||||
{
|
||||
using var book = await EpubReader.OpenBookAsync(filePath, BookService.BookReaderOptions);
|
||||
|
||||
await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress,
|
||||
MessageFactory.WordCountAnalyzerProgressEvent(series.LibraryId, progress,
|
||||
ProgressEventType.Updated, useFileName ? file : series.Name));
|
||||
sum += await GetWordCountFromHtml(bookPage);
|
||||
pageCounter++;
|
||||
var totalPages = book.Content.Html.Values;
|
||||
foreach (var bookPage in totalPages)
|
||||
{
|
||||
var progress = Math.Max(0F,
|
||||
Math.Min(1F, (fileCounter * pageCounter) * 1F / (chapter.Files.Count * totalPages.Count)));
|
||||
|
||||
await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress,
|
||||
MessageFactory.WordCountAnalyzerProgressEvent(series.LibraryId, progress,
|
||||
ProgressEventType.Updated, useFileName ? filePath : series.Name));
|
||||
sum += await GetWordCountFromHtml(bookPage);
|
||||
pageCounter++;
|
||||
}
|
||||
|
||||
fileCounter++;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "There was an error reading an epub file for word count, series skipped");
|
||||
await _eventHub.SendMessageAsync(MessageFactory.Error,
|
||||
MessageFactory.ErrorEvent("There was an issue counting words on an epub",
|
||||
$"{series.Name} - {file}"));
|
||||
return;
|
||||
}
|
||||
|
||||
file.LastFileAnalysis = DateTime.Now;
|
||||
_unitOfWork.MangaFileRepository.Update(file);
|
||||
}
|
||||
|
||||
fileCounter++;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "There was an error reading an epub file for word count, series skipped");
|
||||
await _eventHub.SendMessageAsync(MessageFactory.Error,
|
||||
MessageFactory.ErrorEvent("There was an issue counting words on an epub",
|
||||
$"{series.Name} - {file}"));
|
||||
return;
|
||||
chapter.WordCount = sum;
|
||||
series.WordCount += sum;
|
||||
volume.WordCount += sum;
|
||||
}
|
||||
|
||||
var est = _readerService.GetTimeEstimate(chapter.WordCount, chapter.Pages, isEpub);
|
||||
chapter.MinHoursToRead = est.MinHours;
|
||||
chapter.MaxHoursToRead = est.MaxHours;
|
||||
chapter.AvgHoursToRead = est.AvgHours;
|
||||
_unitOfWork.ChapterRepository.Update(chapter);
|
||||
}
|
||||
|
||||
chapter.WordCount = sum;
|
||||
_unitOfWork.ChapterRepository.Update(chapter);
|
||||
totalSum += sum;
|
||||
var volumeEst = _readerService.GetTimeEstimate(volume.WordCount, volume.Pages, isEpub);
|
||||
volume.MinHoursToRead = volumeEst.MinHours;
|
||||
volume.MaxHoursToRead = volumeEst.MaxHours;
|
||||
volume.AvgHoursToRead = volumeEst.AvgHours;
|
||||
_unitOfWork.VolumeRepository.Update(volume);
|
||||
|
||||
}
|
||||
|
||||
series.WordCount = totalSum;
|
||||
var seriesEstimate = _readerService.GetTimeEstimate(series.WordCount, series.Pages, isEpub);
|
||||
series.MinHoursToRead = seriesEstimate.MinHours;
|
||||
series.MaxHoursToRead = seriesEstimate.MaxHours;
|
||||
series.AvgHoursToRead = seriesEstimate.AvgHours;
|
||||
_unitOfWork.SeriesRepository.Update(series);
|
||||
}
|
||||
|
||||
|
@ -206,8 +233,7 @@ public class WordCountAnalyzerService : IWordCountAnalyzerService
|
|||
if (textNodes == null) return 0;
|
||||
|
||||
return textNodes
|
||||
.Select(node => node.InnerText)
|
||||
.Select(text => text.Split(' ', StringSplitOptions.RemoveEmptyEntries)
|
||||
.Select(node => node.InnerText.Split(' ', StringSplitOptions.RemoveEmptyEntries)
|
||||
.Where(s => char.IsLetter(s[0])))
|
||||
.Select(words => words.Count())
|
||||
.Where(wordCount => wordCount > 0)
|
||||
|
|
|
@ -164,27 +164,44 @@ namespace API.Services.Tasks.Scanner
|
|||
info.Series = MergeName(info);
|
||||
|
||||
var normalizedSeries = Parser.Parser.Normalize(info.Series);
|
||||
var normalizedSortSeries = Parser.Parser.Normalize(info.SeriesSort);
|
||||
var normalizedLocalizedSeries = Parser.Parser.Normalize(info.LocalizedSeries);
|
||||
var existingKey = _scannedSeries.Keys.FirstOrDefault(ps =>
|
||||
ps.Format == info.Format && (ps.NormalizedName == normalizedSeries
|
||||
|| ps.NormalizedName == normalizedLocalizedSeries));
|
||||
existingKey ??= new ParsedSeries()
|
||||
{
|
||||
Format = info.Format,
|
||||
Name = info.Series,
|
||||
NormalizedName = normalizedSeries
|
||||
};
|
||||
|
||||
_scannedSeries.AddOrUpdate(existingKey, new List<ParserInfo>() {info}, (_, oldValue) =>
|
||||
try
|
||||
{
|
||||
oldValue ??= new List<ParserInfo>();
|
||||
if (!oldValue.Contains(info))
|
||||
var existingKey = _scannedSeries.Keys.SingleOrDefault(ps =>
|
||||
ps.Format == info.Format && (ps.NormalizedName.Equals(normalizedSeries)
|
||||
|| ps.NormalizedName.Equals(normalizedLocalizedSeries)
|
||||
|| ps.NormalizedName.Equals(normalizedSortSeries)));
|
||||
existingKey ??= new ParsedSeries()
|
||||
{
|
||||
oldValue.Add(info);
|
||||
}
|
||||
Format = info.Format,
|
||||
Name = info.Series,
|
||||
NormalizedName = normalizedSeries
|
||||
};
|
||||
|
||||
return oldValue;
|
||||
});
|
||||
_scannedSeries.AddOrUpdate(existingKey, new List<ParserInfo>() {info}, (_, oldValue) =>
|
||||
{
|
||||
oldValue ??= new List<ParserInfo>();
|
||||
if (!oldValue.Contains(info))
|
||||
{
|
||||
oldValue.Add(info);
|
||||
}
|
||||
|
||||
return oldValue;
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogCritical(ex, "{SeriesName} matches against multiple series in the parsed series. This indicates a critical kavita issue. Key will be skipped", info.Series);
|
||||
foreach (var seriesKey in _scannedSeries.Keys.Where(ps =>
|
||||
ps.Format == info.Format && (ps.NormalizedName.Equals(normalizedSeries)
|
||||
|| ps.NormalizedName.Equals(normalizedLocalizedSeries)
|
||||
|| ps.NormalizedName.Equals(normalizedSortSeries))))
|
||||
{
|
||||
_logger.LogCritical("Matches: {SeriesName} matches on {SeriesKey}", info.Series, seriesKey.Name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
@ -198,14 +215,32 @@ namespace API.Services.Tasks.Scanner
|
|||
var normalizedSeries = Parser.Parser.Normalize(info.Series);
|
||||
var normalizedLocalSeries = Parser.Parser.Normalize(info.LocalizedSeries);
|
||||
// We use FirstOrDefault because this was introduced late in development and users might have 2 series with both names
|
||||
var existingName =
|
||||
_scannedSeries.FirstOrDefault(p =>
|
||||
(Parser.Parser.Normalize(p.Key.NormalizedName) == normalizedSeries ||
|
||||
Parser.Parser.Normalize(p.Key.NormalizedName) == normalizedLocalSeries) && p.Key.Format == info.Format)
|
||||
.Key;
|
||||
if (existingName != null && !string.IsNullOrEmpty(existingName.Name))
|
||||
try
|
||||
{
|
||||
return existingName.Name;
|
||||
var existingName =
|
||||
_scannedSeries.SingleOrDefault(p =>
|
||||
(Parser.Parser.Normalize(p.Key.NormalizedName) == normalizedSeries ||
|
||||
Parser.Parser.Normalize(p.Key.NormalizedName) == normalizedLocalSeries) &&
|
||||
p.Key.Format == info.Format)
|
||||
.Key;
|
||||
|
||||
if (existingName != null && !string.IsNullOrEmpty(existingName.Name))
|
||||
{
|
||||
return existingName.Name;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogCritical(ex, "Multiple series detected for {SeriesName} ({File})! This is critical to fix! There should only be 1", info.Series, info.FullFilePath);
|
||||
var values = _scannedSeries.Where(p =>
|
||||
(Parser.Parser.Normalize(p.Key.NormalizedName) == normalizedSeries ||
|
||||
Parser.Parser.Normalize(p.Key.NormalizedName) == normalizedLocalSeries) &&
|
||||
p.Key.Format == info.Format);
|
||||
foreach (var pair in values)
|
||||
{
|
||||
_logger.LogCritical("Duplicate Series in DB matches with {SeriesName}: {DuplicateName}", info.Series, pair.Key.Name);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
return info.Series;
|
||||
|
|
|
@ -28,8 +28,14 @@ public interface IScannerService
|
|||
/// cover images if forceUpdate is true.
|
||||
/// </summary>
|
||||
/// <param name="libraryId">Library to scan against</param>
|
||||
[DisableConcurrentExecution(60 * 60 * 60)]
|
||||
[AutomaticRetry(Attempts = 0, OnAttemptsExceeded = AttemptsExceededAction.Delete)]
|
||||
Task ScanLibrary(int libraryId);
|
||||
[DisableConcurrentExecution(60 * 60 * 60)]
|
||||
[AutomaticRetry(Attempts = 0, OnAttemptsExceeded = AttemptsExceededAction.Delete)]
|
||||
Task ScanLibraries();
|
||||
[DisableConcurrentExecution(60 * 60 * 60)]
|
||||
[AutomaticRetry(Attempts = 3, OnAttemptsExceeded = AttemptsExceededAction.Delete)]
|
||||
Task ScanSeries(int libraryId, int seriesId, CancellationToken token);
|
||||
}
|
||||
|
||||
|
@ -63,8 +69,6 @@ public class ScannerService : IScannerService
|
|||
_wordCountAnalyzerService = wordCountAnalyzerService;
|
||||
}
|
||||
|
||||
[DisableConcurrentExecution(timeoutInSeconds: 360)]
|
||||
[AutomaticRetry(Attempts = 0, OnAttemptsExceeded = AttemptsExceededAction.Delete)]
|
||||
public async Task ScanSeries(int libraryId, int seriesId, CancellationToken token)
|
||||
{
|
||||
var sw = new Stopwatch();
|
||||
|
@ -247,8 +251,6 @@ public class ScannerService : IScannerService
|
|||
}
|
||||
|
||||
|
||||
[DisableConcurrentExecution(timeoutInSeconds: 360)]
|
||||
[AutomaticRetry(Attempts = 0, OnAttemptsExceeded = AttemptsExceededAction.Delete)]
|
||||
public async Task ScanLibraries()
|
||||
{
|
||||
_logger.LogInformation("Starting Scan of All Libraries");
|
||||
|
@ -267,8 +269,7 @@ public class ScannerService : IScannerService
|
|||
/// ie) all entities will be rechecked for new cover images and comicInfo.xml changes
|
||||
/// </summary>
|
||||
/// <param name="libraryId"></param>
|
||||
[DisableConcurrentExecution(360)]
|
||||
[AutomaticRetry(Attempts = 0, OnAttemptsExceeded = AttemptsExceededAction.Delete)]
|
||||
|
||||
public async Task ScanLibrary(int libraryId)
|
||||
{
|
||||
Library library;
|
||||
|
@ -470,6 +471,7 @@ public class ScannerService : IScannerService
|
|||
foreach (var series in duplicateSeries)
|
||||
{
|
||||
_logger.LogCritical("[ScannerService] Duplicate Series Found: {Key} maps with {Series}", key.Name, series.OriginalName);
|
||||
|
||||
}
|
||||
|
||||
continue;
|
||||
|
@ -770,7 +772,6 @@ public class ScannerService : IScannerService
|
|||
case PersonRole.Translator:
|
||||
if (!series.Metadata.TranslatorLocked) series.Metadata.People.Remove(person);
|
||||
break;
|
||||
case PersonRole.Other:
|
||||
default:
|
||||
series.Metadata.People.Remove(person);
|
||||
break;
|
||||
|
|
|
@ -176,13 +176,23 @@ namespace API
|
|||
|
||||
app.UseMiddleware<ExceptionMiddleware>();
|
||||
|
||||
Task.Run(async () =>
|
||||
{
|
||||
var allowSwaggerUi = (await unitOfWork.SettingsRepository.GetSettingsDtoAsync())
|
||||
.EnableSwaggerUi;
|
||||
|
||||
if (env.IsDevelopment() || allowSwaggerUi)
|
||||
{
|
||||
app.UseSwagger();
|
||||
app.UseSwaggerUI(c =>
|
||||
{
|
||||
c.SwaggerEndpoint("/swagger/v1/swagger.json", "Kavita API " + BuildInfo.Version);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
if (env.IsDevelopment())
|
||||
{
|
||||
app.UseSwagger();
|
||||
app.UseSwaggerUI(c =>
|
||||
{
|
||||
c.SwaggerEndpoint("/swagger/v1/swagger.json", "Kavita API " + BuildInfo.Version);
|
||||
});
|
||||
app.UseHangfireDashboard();
|
||||
}
|
||||
|
||||
|
|
|
@ -5,7 +5,7 @@
|
|||
"TokenKey": "super secret unguessable key",
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Default": "Critical",
|
||||
"Microsoft": "Information",
|
||||
"Microsoft.Hosting.Lifetime": "Error",
|
||||
"Hangfire": "Information",
|
||||
|
|
|
@ -4,7 +4,7 @@
|
|||
<TargetFramework>net6.0</TargetFramework>
|
||||
<Company>kavitareader.com</Company>
|
||||
<Product>Kavita</Product>
|
||||
<AssemblyVersion>0.5.3.6</AssemblyVersion>
|
||||
<AssemblyVersion>0.5.3.19</AssemblyVersion>
|
||||
<NeutralLanguage>en</NeutralLanguage>
|
||||
</PropertyGroup>
|
||||
|
||||
|
@ -12,7 +12,7 @@
|
|||
<PackageReference Include="Flurl.Http" Version="3.2.4" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="6.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting" Version="6.0.1" />
|
||||
<PackageReference Include="SonarAnalyzer.CSharp" Version="8.39.0.47922">
|
||||
<PackageReference Include="SonarAnalyzer.CSharp" Version="8.40.0.48530">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
|
|
|
@ -2,5 +2,6 @@
|
|||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=1BC0273F_002DFEBE_002D4DA1_002DBC04_002D3A3167E4C86C_002Fd_003AData_002Fd_003AMigrations/@EntryIndexedValue">ExplicitlyExcluded</s:String>
|
||||
<s:Boolean x:Key="/Default/CodeInspection/Highlighting/RunLongAnalysisInSwa/@EntryValue">True</s:Boolean>
|
||||
<s:Boolean x:Key="/Default/CodeInspection/Highlighting/RunValueAnalysisInNullableWarningsEnabledContext2/@EntryValue">True</s:Boolean>
|
||||
<s:Boolean x:Key="/Default/UserDictionary/Words/=Omake/@EntryIndexedValue">True</s:Boolean>
|
||||
<s:Boolean x:Key="/Default/UserDictionary/Words/=Opds/@EntryIndexedValue">True</s:Boolean>
|
||||
<s:Boolean x:Key="/Default/UserDictionary/Words/=rewinded/@EntryIndexedValue">True</s:Boolean></wpf:ResourceDictionary>
|
|
@ -30,7 +30,12 @@
|
|||
"tsConfig": "tsconfig.app.json",
|
||||
"assets": [
|
||||
"src/assets",
|
||||
"src/site.webmanifest"
|
||||
"src/site.webmanifest",
|
||||
{
|
||||
"glob": "**/*",
|
||||
"input": "node_modules/ngx-extended-pdf-viewer/assets/",
|
||||
"output": "/assets/"
|
||||
}
|
||||
],
|
||||
"sourceMap": {
|
||||
"hidden": false,
|
||||
|
|
|
@ -4,7 +4,7 @@
|
|||
"compilerOptions": {
|
||||
"outDir": "../out-tsc/e2e",
|
||||
"module": "commonjs",
|
||||
"target": "es2018",
|
||||
"target": "es2020",
|
||||
"types": [
|
||||
"jasmine",
|
||||
"node"
|
||||
|
|
1906
UI/Web/package-lock.json
generated
1906
UI/Web/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
@ -27,18 +27,21 @@
|
|||
"@angular/platform-browser-dynamic": "~13.2.2",
|
||||
"@angular/router": "~13.2.2",
|
||||
"@fortawesome/fontawesome-free": "^6.0.0",
|
||||
"@iharbeck/ngx-virtual-scroller": "^13.0.4",
|
||||
"@microsoft/signalr": "^6.0.2",
|
||||
"@ng-bootstrap/ng-bootstrap": "^12.0.0",
|
||||
"@ng-bootstrap/ng-bootstrap": "^12.1.2",
|
||||
"@popperjs/core": "^2.11.2",
|
||||
"@types/file-saver": "^2.0.5",
|
||||
"bootstrap": "^5.1.2",
|
||||
"bowser": "^2.11.0",
|
||||
"eventsource": "^1.1.1",
|
||||
"eventsource": "^2.0.2",
|
||||
"file-saver": "^2.0.5",
|
||||
"lazysizes": "^5.3.2",
|
||||
"ng-circle-progress": "^1.6.0",
|
||||
"ngx-color-picker": "^12.0.0",
|
||||
"ngx-extended-pdf-viewer": "^13.5.2",
|
||||
"ngx-file-drop": "^13.0.0",
|
||||
"ngx-infinite-scroll": "^13.0.2",
|
||||
"ngx-toastr": "^14.2.1",
|
||||
"requires": "^1.0.2",
|
||||
"rxjs": "~7.5.4",
|
||||
|
|
|
@ -17,6 +17,8 @@ export interface ChapterMetadata {
|
|||
summary: string;
|
||||
count: number;
|
||||
totalCount: number;
|
||||
wordCount: number;
|
||||
|
||||
|
||||
|
||||
genres: Array<Genre>;
|
||||
|
|
|
@ -1,4 +1,7 @@
|
|||
import { HourEstimateRange } from './hour-estimate-range';
|
||||
import { MangaFile } from './manga-file';
|
||||
import { AgeRating } from './metadata/age-rating';
|
||||
import { AgeRatingDto } from './metadata/age-rating-dto';
|
||||
|
||||
/**
|
||||
* Chapter table object. This does not have metadata on it, use ChapterMetadata which is the same Chapter but with those fields.
|
||||
|
@ -23,4 +26,19 @@ export interface Chapter {
|
|||
* Actual name of the Chapter if populated in underlying metadata
|
||||
*/
|
||||
titleName: string;
|
||||
/**
|
||||
* Summary for the chapter
|
||||
*/
|
||||
summary?: string;
|
||||
minHoursToRead: number;
|
||||
maxHoursToRead: number;
|
||||
avgHoursToRead: number;
|
||||
|
||||
ageRating: AgeRating;
|
||||
releaseDate: string;
|
||||
wordCount: number;
|
||||
/**
|
||||
* 'Volume number'. Only available for SeriesDetail
|
||||
*/
|
||||
volumeTitle?: string;
|
||||
}
|
||||
|
|
6
UI/Web/src/app/_models/hour-estimate-range.ts
Normal file
6
UI/Web/src/app/_models/hour-estimate-range.ts
Normal file
|
@ -0,0 +1,6 @@
|
|||
export interface HourEstimateRange{
|
||||
minHours: number;
|
||||
maxHours: number;
|
||||
avgHours: number;
|
||||
//hasProgress: boolean;
|
||||
}
|
5
UI/Web/src/app/_models/jumpbar/jump-key.ts
Normal file
5
UI/Web/src/app/_models/jumpbar/jump-key.ts
Normal file
|
@ -0,0 +1,5 @@
|
|||
export interface JumpKey {
|
||||
size: number;
|
||||
key: string;
|
||||
title: string;
|
||||
}
|
10
UI/Web/src/app/_models/page-layout-mode.ts
Normal file
10
UI/Web/src/app/_models/page-layout-mode.ts
Normal file
|
@ -0,0 +1,10 @@
|
|||
export enum PageLayoutMode {
|
||||
/**
|
||||
* Use Cards for laying out data
|
||||
*/
|
||||
Cards = 0,
|
||||
/**
|
||||
* Use list style for laying out items
|
||||
*/
|
||||
List = 1
|
||||
}
|
|
@ -1,6 +1,7 @@
|
|||
|
||||
import { LayoutMode } from 'src/app/manga-reader/_models/layout-mode';
|
||||
import { BookPageLayoutMode } from '../book-page-layout-mode';
|
||||
import { PageLayoutMode } from '../page-layout-mode';
|
||||
import { PageSplitOption } from './page-split-option';
|
||||
import { ReaderMode } from './reader-mode';
|
||||
import { ReadingDirection } from './reading-direction';
|
||||
|
@ -31,6 +32,7 @@ export interface Preferences {
|
|||
|
||||
// Global
|
||||
theme: SiteTheme;
|
||||
globalPageLayoutMode: PageLayoutMode;
|
||||
}
|
||||
|
||||
export const readingDirections = [{text: 'Left to Right', value: ReadingDirection.LeftToRight}, {text: 'Right to Left', value: ReadingDirection.RightToLeft}];
|
||||
|
@ -39,3 +41,4 @@ export const pageSplitOptions = [{text: 'Fit to Screen', value: PageSplitOption.
|
|||
export const readingModes = [{text: 'Left to Right', value: ReaderMode.LeftRight}, {text: 'Up to Down', value: ReaderMode.UpDown}, {text: 'Webtoon', value: ReaderMode.Webtoon}];
|
||||
export const layoutModes = [{text: 'Single', value: LayoutMode.Single}, {text: 'Double', value: LayoutMode.Double}, {text: 'Double (Manga)', value: LayoutMode.DoubleReversed}];
|
||||
export const bookLayoutModes = [{text: 'Default', value: BookPageLayoutMode.Default}, {text: '1 Column', value: BookPageLayoutMode.Column1}, {text: '2 Column', value: BookPageLayoutMode.Column2}];
|
||||
export const pageLayoutModes = [{text: 'Cards', value: PageLayoutMode.Cards}, {text: 'List', value: PageLayoutMode.List}];
|
||||
|
|
|
@ -52,4 +52,7 @@ export interface Series {
|
|||
* Number of words in the series
|
||||
*/
|
||||
wordCount: number;
|
||||
minHoursToRead: number;
|
||||
maxHoursToRead: number;
|
||||
avgHoursToRead: number;
|
||||
}
|
||||
|
|
4
UI/Web/src/app/_models/system/directory-dto.ts
Normal file
4
UI/Web/src/app/_models/system/directory-dto.ts
Normal file
|
@ -0,0 +1,4 @@
|
|||
export interface DirectoryDto {
|
||||
name: string;
|
||||
fullPath: string;
|
||||
}
|
|
@ -1,4 +1,5 @@
|
|||
import { Chapter } from './chapter';
|
||||
import { HourEstimateRange } from './hour-estimate-range';
|
||||
|
||||
export interface Volume {
|
||||
id: number;
|
||||
|
@ -8,5 +9,12 @@ export interface Volume {
|
|||
lastModified: string;
|
||||
pages: number;
|
||||
pagesRead: number;
|
||||
chapters: Array<Chapter>; // TODO: Validate any cases where this is undefined
|
||||
chapters: Array<Chapter>;
|
||||
/**
|
||||
* This is only available on the object when fetched for SeriesDetail
|
||||
*/
|
||||
timeEstimate?: HourEstimateRange;
|
||||
minHoursToRead: number;
|
||||
maxHoursToRead: number;
|
||||
avgHoursToRead: number;
|
||||
}
|
||||
|
|
|
@ -65,6 +65,10 @@ export enum Action {
|
|||
* Open Series detail page for said series
|
||||
*/
|
||||
ViewSeries = 13,
|
||||
/**
|
||||
* Open the reader for entity
|
||||
*/
|
||||
Read = 14,
|
||||
}
|
||||
|
||||
export interface ActionItem<T> {
|
||||
|
|
|
@ -2,7 +2,7 @@ import { Injectable, OnDestroy } from '@angular/core';
|
|||
import { NgbModal, NgbModalRef } from '@ng-bootstrap/ng-bootstrap';
|
||||
import { ToastrService } from 'ngx-toastr';
|
||||
import { Subject } from 'rxjs';
|
||||
import { take } from 'rxjs/operators';
|
||||
import { finalize, take, takeWhile } from 'rxjs/operators';
|
||||
import { BulkAddToCollectionComponent } from '../cards/_modals/bulk-add-to-collection/bulk-add-to-collection.component';
|
||||
import { AddToListModalComponent, ADD_FLOW } from '../reading-list/_modals/add-to-list-modal/add-to-list-modal.component';
|
||||
import { EditReadingListModalComponent } from '../reading-list/_modals/edit-reading-list-modal/edit-reading-list-modal.component';
|
||||
|
@ -527,5 +527,4 @@ export class ActionService implements OnDestroy {
|
|||
}
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -3,8 +3,10 @@ import { Injectable } from '@angular/core';
|
|||
import { of } from 'rxjs';
|
||||
import { map, take } from 'rxjs/operators';
|
||||
import { environment } from 'src/environments/environment';
|
||||
import { JumpKey } from '../_models/jumpbar/jump-key';
|
||||
import { Library, LibraryType } from '../_models/library';
|
||||
import { SearchResultGroup } from '../_models/search/search-result-group';
|
||||
import { DirectoryDto } from '../_models/system/directory-dto';
|
||||
|
||||
|
||||
@Injectable({
|
||||
|
@ -55,7 +57,11 @@ export class LibraryService {
|
|||
query = '?path=' + encodeURIComponent(rootPath);
|
||||
}
|
||||
|
||||
return this.httpClient.get<string[]>(this.baseUrl + 'library/list' + query);
|
||||
return this.httpClient.get<DirectoryDto[]>(this.baseUrl + 'library/list' + query);
|
||||
}
|
||||
|
||||
getJumpBar(libraryId: number) {
|
||||
return this.httpClient.get<JumpKey[]>(this.baseUrl + 'library/jump-bar?libraryId=' + libraryId);
|
||||
}
|
||||
|
||||
getLibraries() {
|
||||
|
|
|
@ -67,6 +67,10 @@ export enum EVENTS {
|
|||
* When bulk bookmarks are being converted
|
||||
*/
|
||||
ConvertBookmarksProgress = 'ConvertBookmarksProgress',
|
||||
/**
|
||||
* When files are being scanned to calculate word count
|
||||
*/
|
||||
WordCountAnalyzerProgress = 'WordCountAnalyzerProgress'
|
||||
}
|
||||
|
||||
export interface Message<T> {
|
||||
|
@ -155,6 +159,13 @@ export class MessageHubService {
|
|||
});
|
||||
});
|
||||
|
||||
this.hubConnection.on(EVENTS.WordCountAnalyzerProgress, resp => {
|
||||
this.messagesSource.next({
|
||||
event: EVENTS.WordCountAnalyzerProgress,
|
||||
payload: resp.body
|
||||
});
|
||||
});
|
||||
|
||||
this.hubConnection.on(EVENTS.LibraryModified, resp => {
|
||||
this.messagesSource.next({
|
||||
event: EVENTS.LibraryModified,
|
||||
|
|
|
@ -22,7 +22,7 @@ export class MetadataService {
|
|||
private ageRatingTypes: {[key: number]: string} | undefined = undefined;
|
||||
private validLanguages: Array<Language> = [];
|
||||
|
||||
constructor(private httpClient: HttpClient, private utilityService: UtilityService) { }
|
||||
constructor(private httpClient: HttpClient) { }
|
||||
|
||||
getAgeRating(ageRating: AgeRating) {
|
||||
if (this.ageRatingTypes != undefined && this.ageRatingTypes.hasOwnProperty(ageRating)) {
|
||||
|
@ -97,4 +97,8 @@ export class MetadataService {
|
|||
}
|
||||
return this.httpClient.get<Array<Person>>(this.baseUrl + method);
|
||||
}
|
||||
|
||||
getChapterSummary(chapterId: number) {
|
||||
return this.httpClient.get<string>(this.baseUrl + 'metadata/chapter-summary?chapterId=' + chapterId, {responseType: 'text' as 'json'});
|
||||
}
|
||||
}
|
||||
|
|
|
@ -4,10 +4,14 @@ import { environment } from 'src/environments/environment';
|
|||
import { ChapterInfo } from '../manga-reader/_models/chapter-info';
|
||||
import { UtilityService } from '../shared/_services/utility.service';
|
||||
import { Chapter } from '../_models/chapter';
|
||||
import { HourEstimateRange } from '../_models/hour-estimate-range';
|
||||
import { MangaFormat } from '../_models/manga-format';
|
||||
import { BookmarkInfo } from '../_models/manga-reader/bookmark-info';
|
||||
import { PageBookmark } from '../_models/page-bookmark';
|
||||
import { ProgressBookmark } from '../_models/progress-bookmark';
|
||||
import { Volume } from '../_models/volume';
|
||||
|
||||
export const CHAPTER_ID_DOESNT_EXIST = -1;
|
||||
export const CHAPTER_ID_NOT_FETCHED = -2;
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
|
@ -21,6 +25,22 @@ export class ReaderService {
|
|||
|
||||
constructor(private httpClient: HttpClient, private utilityService: UtilityService) { }
|
||||
|
||||
getNavigationArray(libraryId: number, seriesId: number, chapterId: number, format: MangaFormat) {
|
||||
if (format === undefined) format = MangaFormat.ARCHIVE;
|
||||
|
||||
if (format === MangaFormat.EPUB) {
|
||||
return ['library', libraryId, 'series', seriesId, 'book', chapterId];
|
||||
} else if (format === MangaFormat.PDF) {
|
||||
return ['library', libraryId, 'series', seriesId, 'pdf', chapterId];
|
||||
} else {
|
||||
return ['library', libraryId, 'series', seriesId, 'manga', chapterId];
|
||||
}
|
||||
}
|
||||
|
||||
downloadPdf(chapterId: number) {
|
||||
return this.baseUrl + 'reader/pdf?chapterId=' + chapterId;
|
||||
}
|
||||
|
||||
bookmark(seriesId: number, volumeId: number, chapterId: number, page: number) {
|
||||
return this.httpClient.post(this.baseUrl + 'reader/bookmark', {seriesId, volumeId, chapterId, page});
|
||||
}
|
||||
|
@ -124,6 +144,11 @@ export class ReaderService {
|
|||
return this.httpClient.get<Chapter>(this.baseUrl + 'reader/continue-point?seriesId=' + seriesId);
|
||||
}
|
||||
|
||||
// TODO: Cache this information
|
||||
getTimeLeft(seriesId: number) {
|
||||
return this.httpClient.get<HourEstimateRange>(this.baseUrl + 'reader/time-left?seriesId=' + seriesId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Captures current body color and forces background color to be black. Call @see resetOverrideStyles() on destroy of component to revert changes
|
||||
*/
|
||||
|
|
|
@ -22,6 +22,13 @@ export class RecommendationService {
|
|||
.pipe(map(response => this.utilityService.createPaginatedResult(response)));
|
||||
}
|
||||
|
||||
getQuickCatchupReads(libraryId: number, pageNum?: number, itemsPerPage?: number) {
|
||||
let params = new HttpParams();
|
||||
params = this.utilityService.addPaginationIfExists(params, pageNum, itemsPerPage);
|
||||
return this.httpClient.get<PaginatedResult<Series[]>>(this.baseUrl + 'recommended/quick-catchup-reads?libraryId=' + libraryId, {observe: 'response', params})
|
||||
.pipe(map(response => this.utilityService.createPaginatedResult(response)));
|
||||
}
|
||||
|
||||
getHighlyRated(libraryId: number, pageNum?: number, itemsPerPage?: number) {
|
||||
let params = new HttpParams();
|
||||
params = this.utilityService.addPaginationIfExists(params, pageNum, itemsPerPage);
|
||||
|
|
|
@ -3,49 +3,69 @@
|
|||
<button type="button" class="btn-close" aria-label="Close" (click)="close()"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="mb-3">
|
||||
<!-- <div class="mb-3">
|
||||
<label for="filter" class="form-label">Filter</label>
|
||||
<div class="input-group">
|
||||
<input id="filter" autocomplete="off" class="form-control" [(ngModel)]="filterQuery" type="text" aria-describedby="reset-input">
|
||||
<button class="btn btn-outline-secondary" type="button" id="reset-input" (click)="filterQuery = '';">Clear</button>
|
||||
</div>
|
||||
</div> -->
|
||||
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="filter" class="form-label">Path</label>
|
||||
<div class="input-group">
|
||||
<input id="typeahead-focus" type="text" class="form-control" [(ngModel)]="path" [ngbTypeahead]="search"
|
||||
(focus)="focus$.next($any($event).target.value)" (click)="click$.next($any($event).target.value)"
|
||||
(ngModelChange)="updateTable()" #instance="ngbTypeahead" placeholder="Start typing or select path"
|
||||
[resultTemplate]="rt" />
|
||||
</div>
|
||||
<ng-template #rt let-r="result" let-t="term">
|
||||
<ngb-highlight [result]="r" [term]="t"></ngb-highlight>
|
||||
</ng-template>
|
||||
</div>
|
||||
|
||||
<nav aria-label="directory breadcrumb">
|
||||
<ol class="breadcrumb" *ngIf="routeStack.peek() !== undefined; else noBreadcrumb">
|
||||
<li class="breadcrumb-item {{route === routeStack.peek() ? 'active' : ''}}" *ngFor="let route of routeStack.items; let index = index">
|
||||
<li class="breadcrumb-item {{route === routeStack.peek() ? 'active' : ''}}"
|
||||
*ngFor="let route of routeStack.items; let index = index">
|
||||
<ng-container *ngIf="route === routeStack.peek(); else nonActive">
|
||||
{{route}}
|
||||
</ng-container>
|
||||
<ng-template #nonActive>
|
||||
<a href="javascript:void(0);" (click)="navigateTo(index)">{{route}}</a>
|
||||
</ng-template>
|
||||
</li>
|
||||
</li>
|
||||
</ol>
|
||||
<ng-template #noBreadcrumb>
|
||||
<div class="breadcrumb">Select a folder to view breadcrumb. Don't see your directory, try checking / first.</div>
|
||||
<div class="breadcrumb">Select a folder to view breadcrumb. Don't see your directory, try checking / first.
|
||||
</div>
|
||||
</ng-template>
|
||||
</nav>
|
||||
<ul class="list-group">
|
||||
<div class="list-group-item list-group-item-action">
|
||||
<button (click)="goBack()" class="btn btn-secondary" [disabled]="routeStack.peek() === undefined">
|
||||
<i class="fa fa-arrow-left me-2" aria-hidden="true"></i>
|
||||
Back
|
||||
</button>
|
||||
|
||||
<button type="button" class="btn btn-primary float-end" [disabled]="routeStack.peek() === undefined" (click)="shareFolder('', $event)">Share</button>
|
||||
</div>
|
||||
</ul>
|
||||
<ul class="list-group scrollable">
|
||||
<button *ngFor="let folder of folders | filter: filterFolder" class="list-group-item list-group-item-action" (click)="selectNode(folder)">
|
||||
<span>{{getStem(folder)}}</span>
|
||||
<button type="button" class="btn btn-primary float-end" (click)="shareFolder(folder, $event)">Share</button>
|
||||
</button>
|
||||
<div class="list-group-item text-center" *ngIf="folders.length === 0">
|
||||
There are no folders here
|
||||
</div>
|
||||
</ul>
|
||||
<table class="table table-striped scrollable">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Type</th>
|
||||
<th scope="col">Name</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr (click)="goBack()">
|
||||
<td><i class="fa-solid fa-arrow-turn-up" aria-hidden="true"></i></td>
|
||||
<td>...</td>
|
||||
</tr>
|
||||
<tr *ngFor="let folder of folders; let idx = index;" (click)="selectNode(folder)">
|
||||
<td><i class="fa-regular fa-folder" aria-hidden="true"></i></td>
|
||||
<td id="folder--{{idx}}">
|
||||
{{folder.name}}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<a class="btn btn-icon" *ngIf="helpUrl.length > 0" href="{{helpUrl}}" target="_blank">Help</a>
|
||||
<button type="button" class="btn btn-secondary" (click)="close()">Cancel</button>
|
||||
<button type="button" class="btn btn-primary" (click)="share()">Share</button>
|
||||
</div>
|
|
@ -13,3 +13,7 @@ $breadcrumb-divider: quote(">");
|
|||
.btn-outline-secondary {
|
||||
border: 1px solid #ced4da;
|
||||
}
|
||||
|
||||
.table {
|
||||
background-color: lightgrey;
|
||||
}
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue