Merged develop into main

This commit is contained in:
Joseph Milazzo 2021-11-27 09:46:57 -06:00
commit 8a19c1da9e
103 changed files with 1242 additions and 900 deletions

View File

@ -7,6 +7,10 @@ assignees: ''
--- ---
**If this is a feature request, request [here](https://feats.kavitareader.com/) instead. Feature requests will be deleted from Github.**
**Describe the bug** **Describe the bug**
A clear and concise description of what the bug is. A clear and concise description of what the bug is.

View File

@ -147,6 +147,8 @@ jobs:
body=${body//'%'/'%25'} body=${body//'%'/'%25'}
body=${body//$'\n'/'%0A'} body=${body//$'\n'/'%0A'}
body=${body//$'\r'/'%0D'} body=${body//$'\r'/'%0D'}
body=${body//$'`'/'%60'}
body=${body//$'>'/'%3E'}
echo $body echo $body
echo "::set-output name=BODY::$body" echo "::set-output name=BODY::$body"
@ -249,6 +251,8 @@ jobs:
body=${body//'%'/'%25'} body=${body//'%'/'%25'}
body=${body//$'\n'/'%0A'} body=${body//$'\n'/'%0A'}
body=${body//$'\r'/'%0D'} body=${body//$'\r'/'%0D'}
body=${body//$'`'/'%60'}
body=${body//$'>'/'%3E'}
echo $body echo $body
echo "::set-output name=BODY::$body" echo "::set-output name=BODY::$body"

View File

@ -58,6 +58,11 @@ namespace API.Tests.Parser
[InlineData("2000 AD 0366 [1984-04-28] (flopbie)", "2000 AD")] [InlineData("2000 AD 0366 [1984-04-28] (flopbie)", "2000 AD")]
[InlineData("Daredevil - v6 - 10 - (2019)", "Daredevil")] [InlineData("Daredevil - v6 - 10 - (2019)", "Daredevil")]
[InlineData("Batman - The Man Who Laughs #1 (2005)", "Batman - The Man Who Laughs")] [InlineData("Batman - The Man Who Laughs #1 (2005)", "Batman - The Man Who Laughs")]
[InlineData("Demon 012 (Sep 1973) c2c", "Demon")]
[InlineData("Dragon Age - Until We Sleep 01 (of 03)", "Dragon Age - Until We Sleep")]
[InlineData("Green Lantern v2 017 - The Spy-Eye that doomed Green Lantern v2", "Green Lantern")]
[InlineData("Green Lantern - Circle of Fire Special - Adam Strange (2000)", "Green Lantern - Circle of Fire - Adam Strange")]
[InlineData("Identity Crisis Extra - Rags Morales Sketches (2005)", "Identity Crisis - Rags Morales Sketches")]
public void ParseComicSeriesTest(string filename, string expected) public void ParseComicSeriesTest(string filename, string expected)
{ {
Assert.Equal(expected, API.Parser.Parser.ParseComicSeries(filename)); Assert.Equal(expected, API.Parser.Parser.ParseComicSeries(filename));
@ -138,6 +143,7 @@ namespace API.Tests.Parser
[InlineData("Cyberpunk 2077 - Trauma Team #04.cbz", "4")] [InlineData("Cyberpunk 2077 - Trauma Team #04.cbz", "4")]
[InlineData("2000 AD 0366 [1984-04-28] (flopbie)", "366")] [InlineData("2000 AD 0366 [1984-04-28] (flopbie)", "366")]
[InlineData("Daredevil - v6 - 10 - (2019)", "10")] [InlineData("Daredevil - v6 - 10 - (2019)", "10")]
[InlineData("Batman Beyond 2016 - Chapter 001.cbz", "1")]
public void ParseComicChapterTest(string filename, string expected) public void ParseComicChapterTest(string filename, string expected)
{ {
Assert.Equal(expected, API.Parser.Parser.ParseComicChapter(filename)); Assert.Equal(expected, API.Parser.Parser.ParseComicChapter(filename));

View File

@ -107,6 +107,11 @@
<EmbeddedResource Remove="logs\**" /> <EmbeddedResource Remove="logs\**" />
<EmbeddedResource Remove="temp\**" /> <EmbeddedResource Remove="temp\**" />
<EmbeddedResource Remove="covers\**" /> <EmbeddedResource Remove="covers\**" />
<EmbeddedResource Remove="config\covers\**" />
<EmbeddedResource Remove="config\backups\**" />
<EmbeddedResource Remove="config\logs\**" />
<EmbeddedResource Remove="config\temp\**" />
<EmbeddedResource Remove="config\stats\**" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
@ -115,12 +120,18 @@
<Content Remove="backups\**" /> <Content Remove="backups\**" />
<Content Remove="logs\**" /> <Content Remove="logs\**" />
<Content Remove="temp\**" /> <Content Remove="temp\**" />
<Content Remove="stats\**" /> <Content Remove="config\stats\**" />
<Content Remove="config\cache\**" />
<Content Remove="config\backups\**" />
<Content Remove="config\logs\**" />
<Content Remove="config\temp\**" />
<Content Remove="config\stats\**" />
<Content Condition=" '$(Configuration)' == 'Release' " Remove="appsettings.Development.json" /> <Content Condition=" '$(Configuration)' == 'Release' " Remove="appsettings.Development.json" />
<Content Update="appsettings.json"> <Content Update="appsettings.json">
<CopyToOutputDirectory>Always</CopyToOutputDirectory> <CopyToOutputDirectory>Always</CopyToOutputDirectory>
</Content> </Content>
<Content Remove="covers\**" /> <Content Remove="covers\**" />
<Content Remove="config\covers\**" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>

View File

@ -95,7 +95,7 @@ namespace API.Controllers
var settings = await _unitOfWork.SettingsRepository.GetSettingsDtoAsync(); var settings = await _unitOfWork.SettingsRepository.GetSettingsDtoAsync();
if (!settings.EnableAuthentication && !registerDto.IsAdmin) if (!settings.EnableAuthentication && !registerDto.IsAdmin)
{ {
_logger.LogInformation("User {UserName} is being registered as non-admin with no server authentication. Using default password.", registerDto.Username); _logger.LogInformation("User {UserName} is being registered as non-admin with no server authentication. Using default password", registerDto.Username);
registerDto.Password = AccountService.DefaultPassword; registerDto.Password = AccountService.DefaultPassword;
} }

View File

@ -17,6 +17,7 @@ using API.Interfaces.Services;
using API.Services; using API.Services;
using Kavita.Common; using Kavita.Common;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
namespace API.Controllers namespace API.Controllers
{ {
@ -48,7 +49,6 @@ namespace API.Controllers
_cacheService = cacheService; _cacheService = cacheService;
_readerService = readerService; _readerService = readerService;
_xmlSerializer = new XmlSerializer(typeof(Feed)); _xmlSerializer = new XmlSerializer(typeof(Feed));
_xmlOpenSearchSerializer = new XmlSerializer(typeof(OpenSearchDescription)); _xmlOpenSearchSerializer = new XmlSerializer(typeof(OpenSearchDescription));
@ -62,18 +62,18 @@ namespace API.Controllers
if (!(await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).EnableOpds) if (!(await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).EnableOpds)
return BadRequest("OPDS is not enabled on this server"); return BadRequest("OPDS is not enabled on this server");
var feed = CreateFeed("Kavita", string.Empty, apiKey); var feed = CreateFeed("Kavita", string.Empty, apiKey);
feed.Id = "root"; SetFeedId(feed, "root");
feed.Entries.Add(new FeedEntry() feed.Entries.Add(new FeedEntry()
{ {
Id = "inProgress", Id = "onDeck",
Title = "In Progress", Title = "On Deck",
Content = new FeedEntryContent() Content = new FeedEntryContent()
{ {
Text = "Browse by In Progress" Text = "Browse by On Deck"
}, },
Links = new List<FeedLink>() Links = new List<FeedLink>()
{ {
CreateLink(FeedLinkRelation.SubSection, FeedLinkType.AtomNavigation, Prefix + $"{apiKey}/in-progress"), CreateLink(FeedLinkRelation.SubSection, FeedLinkType.AtomNavigation, Prefix + $"{apiKey}/on-deck"),
} }
}); });
feed.Entries.Add(new FeedEntry() feed.Entries.Add(new FeedEntry()
@ -140,9 +140,8 @@ namespace API.Controllers
return BadRequest("OPDS is not enabled on this server"); return BadRequest("OPDS is not enabled on this server");
var userId = await GetUser(apiKey); var userId = await GetUser(apiKey);
var libraries = await _unitOfWork.LibraryRepository.GetLibrariesForUserIdAsync(userId); var libraries = await _unitOfWork.LibraryRepository.GetLibrariesForUserIdAsync(userId);
var feed = CreateFeed("All Libraries", $"{apiKey}/libraries", apiKey); var feed = CreateFeed("All Libraries", $"{apiKey}/libraries", apiKey);
SetFeedId(feed, "libraries");
foreach (var library in libraries) foreach (var library in libraries)
{ {
feed.Entries.Add(new FeedEntry() feed.Entries.Add(new FeedEntry()
@ -181,7 +180,7 @@ namespace API.Controllers
var feed = CreateFeed("All Collections", $"{apiKey}/collections", apiKey); var feed = CreateFeed("All Collections", $"{apiKey}/collections", apiKey);
SetFeedId(feed, "collections");
foreach (var tag in tags) foreach (var tag in tags)
{ {
feed.Entries.Add(new FeedEntry() feed.Entries.Add(new FeedEntry()
@ -198,14 +197,6 @@ namespace API.Controllers
}); });
} }
if (tags.Count == 0)
{
feed.Entries.Add(new FeedEntry()
{
Title = "Nothing here",
});
}
return CreateXmlResult(SerializeXml(feed)); return CreateXmlResult(SerializeXml(feed));
} }
@ -243,6 +234,7 @@ namespace API.Controllers
}); });
var feed = CreateFeed(tag.Title + " Collection", $"{apiKey}/collections/{collectionId}", apiKey); var feed = CreateFeed(tag.Title + " Collection", $"{apiKey}/collections/{collectionId}", apiKey);
SetFeedId(feed, $"collections-{collectionId}");
AddPagination(feed, series, $"{Prefix}{apiKey}/collections/{collectionId}"); AddPagination(feed, series, $"{Prefix}{apiKey}/collections/{collectionId}");
foreach (var seriesDto in series) foreach (var seriesDto in series)
@ -269,7 +261,7 @@ namespace API.Controllers
var feed = CreateFeed("All Reading Lists", $"{apiKey}/reading-list", apiKey); var feed = CreateFeed("All Reading Lists", $"{apiKey}/reading-list", apiKey);
SetFeedId(feed, "reading-list");
foreach (var readingListDto in readingLists) foreach (var readingListDto in readingLists)
{ {
feed.Entries.Add(new FeedEntry() feed.Entries.Add(new FeedEntry()
@ -304,6 +296,7 @@ namespace API.Controllers
} }
var feed = CreateFeed(readingList.Title + " Reading List", $"{apiKey}/reading-list/{readingListId}", apiKey); var feed = CreateFeed(readingList.Title + " Reading List", $"{apiKey}/reading-list/{readingListId}", apiKey);
SetFeedId(feed, $"reading-list-{readingListId}");
var items = (await _unitOfWork.ReadingListRepository.GetReadingListItemDtosByIdAsync(readingListId, userId)).ToList(); var items = (await _unitOfWork.ReadingListRepository.GetReadingListItemDtosByIdAsync(readingListId, userId)).ToList();
foreach (var item in items) foreach (var item in items)
@ -320,16 +313,6 @@ namespace API.Controllers
}); });
} }
if (items.Count == 0)
{
feed.Entries.Add(new FeedEntry()
{
Title = "Nothing here",
});
}
return CreateXmlResult(SerializeXml(feed)); return CreateXmlResult(SerializeXml(feed));
} }
@ -355,6 +338,7 @@ namespace API.Controllers
}, _filterDto); }, _filterDto);
var feed = CreateFeed(library.Name, $"{apiKey}/libraries/{libraryId}", apiKey); var feed = CreateFeed(library.Name, $"{apiKey}/libraries/{libraryId}", apiKey);
SetFeedId(feed, $"library-{library.Name}");
AddPagination(feed, series, $"{Prefix}{apiKey}/libraries/{libraryId}"); AddPagination(feed, series, $"{Prefix}{apiKey}/libraries/{libraryId}");
foreach (var seriesDto in series) foreach (var seriesDto in series)
@ -379,6 +363,7 @@ namespace API.Controllers
}, _filterDto); }, _filterDto);
var feed = CreateFeed("Recently Added", $"{apiKey}/recently-added", apiKey); var feed = CreateFeed("Recently Added", $"{apiKey}/recently-added", apiKey);
SetFeedId(feed, "recently-added");
AddPagination(feed, recentlyAdded, $"{Prefix}{apiKey}/recently-added"); AddPagination(feed, recentlyAdded, $"{Prefix}{apiKey}/recently-added");
foreach (var seriesDto in recentlyAdded) foreach (var seriesDto in recentlyAdded)
@ -386,21 +371,12 @@ namespace API.Controllers
feed.Entries.Add(CreateSeries(seriesDto, apiKey)); feed.Entries.Add(CreateSeries(seriesDto, apiKey));
} }
if (recentlyAdded.Count == 0)
{
feed.Entries.Add(new FeedEntry()
{
Title = "Nothing here",
});
}
return CreateXmlResult(SerializeXml(feed)); return CreateXmlResult(SerializeXml(feed));
} }
[HttpGet("{apiKey}/in-progress")] [HttpGet("{apiKey}/on-deck")]
[Produces("application/xml")] [Produces("application/xml")]
public async Task<IActionResult> GetInProgress(string apiKey, [FromQuery] int pageNumber = 1) public async Task<IActionResult> GetOnDeck(string apiKey, [FromQuery] int pageNumber = 1)
{ {
if (!(await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).EnableOpds) if (!(await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).EnableOpds)
return BadRequest("OPDS is not enabled on this server"); return BadRequest("OPDS is not enabled on this server");
@ -410,29 +386,22 @@ namespace API.Controllers
PageNumber = pageNumber, PageNumber = pageNumber,
PageSize = 20 PageSize = 20
}; };
var results = await _unitOfWork.SeriesRepository.GetInProgress(userId, 0, userParams, _filterDto); var results = await _unitOfWork.SeriesRepository.GetOnDeck(userId, 0, userParams, _filterDto);
var listResults = results.DistinctBy(s => s.Name).Skip((userParams.PageNumber - 1) * userParams.PageSize) var listResults = results.DistinctBy(s => s.Name).Skip((userParams.PageNumber - 1) * userParams.PageSize)
.Take(userParams.PageSize).ToList(); .Take(userParams.PageSize).ToList();
var pagedList = new PagedList<SeriesDto>(listResults, listResults.Count, userParams.PageNumber, userParams.PageSize); var pagedList = new PagedList<SeriesDto>(listResults, listResults.Count, userParams.PageNumber, userParams.PageSize);
Response.AddPaginationHeader(pagedList.CurrentPage, pagedList.PageSize, pagedList.TotalCount, pagedList.TotalPages); Response.AddPaginationHeader(pagedList.CurrentPage, pagedList.PageSize, pagedList.TotalCount, pagedList.TotalPages);
var feed = CreateFeed("In Progress", $"{apiKey}/in-progress", apiKey); var feed = CreateFeed("On Deck", $"{apiKey}/on-deck", apiKey);
AddPagination(feed, pagedList, $"{Prefix}{apiKey}/in-progress"); SetFeedId(feed, "on-deck");
AddPagination(feed, pagedList, $"{Prefix}{apiKey}/on-deck");
foreach (var seriesDto in pagedList) foreach (var seriesDto in pagedList)
{ {
feed.Entries.Add(CreateSeries(seriesDto, apiKey)); feed.Entries.Add(CreateSeries(seriesDto, apiKey));
} }
if (pagedList.Count == 0)
{
feed.Entries.Add(new FeedEntry()
{
Title = "Nothing here",
});
}
return CreateXmlResult(SerializeXml(feed)); return CreateXmlResult(SerializeXml(feed));
} }
@ -456,7 +425,7 @@ namespace API.Controllers
var series = await _unitOfWork.SeriesRepository.SearchSeries(libraries.Select(l => l.Id).ToArray(), query); var series = await _unitOfWork.SeriesRepository.SearchSeries(libraries.Select(l => l.Id).ToArray(), query);
var feed = CreateFeed(query, $"{apiKey}/series?query=" + query, apiKey); var feed = CreateFeed(query, $"{apiKey}/series?query=" + query, apiKey);
SetFeedId(feed, "search-series");
foreach (var seriesDto in series) foreach (var seriesDto in series)
{ {
feed.Entries.Add(CreateSeries(seriesDto, apiKey)); feed.Entries.Add(CreateSeries(seriesDto, apiKey));
@ -465,6 +434,11 @@ namespace API.Controllers
return CreateXmlResult(SerializeXml(feed)); return CreateXmlResult(SerializeXml(feed));
} }
private static void SetFeedId(Feed feed, string id)
{
feed.Id = id;
}
[HttpGet("{apiKey}/search")] [HttpGet("{apiKey}/search")]
[Produces("application/xml")] [Produces("application/xml")]
public async Task<IActionResult> GetSearchDescriptor(string apiKey) public async Task<IActionResult> GetSearchDescriptor(string apiKey)
@ -498,6 +472,7 @@ namespace API.Controllers
var series = await _unitOfWork.SeriesRepository.GetSeriesDtoByIdAsync(seriesId, userId); var series = await _unitOfWork.SeriesRepository.GetSeriesDtoByIdAsync(seriesId, userId);
var volumes = await _unitOfWork.VolumeRepository.GetVolumesDtoAsync(seriesId, userId); var volumes = await _unitOfWork.VolumeRepository.GetVolumesDtoAsync(seriesId, userId);
var feed = CreateFeed(series.Name + " - Volumes", $"{apiKey}/series/{series.Id}", apiKey); var feed = CreateFeed(series.Name + " - Volumes", $"{apiKey}/series/{series.Id}", apiKey);
SetFeedId(feed, $"series-{series.Id}");
feed.Links.Add(CreateLink(FeedLinkRelation.Image, FeedLinkType.Image, $"/api/image/series-cover?seriesId={seriesId}")); feed.Links.Add(CreateLink(FeedLinkRelation.Image, FeedLinkType.Image, $"/api/image/series-cover?seriesId={seriesId}"));
foreach (var volumeDto in volumes) foreach (var volumeDto in volumes)
{ {
@ -521,6 +496,7 @@ namespace API.Controllers
_chapterSortComparer); _chapterSortComparer);
var feed = CreateFeed(series.Name + " - Volume " + volume.Name + " - Chapters ", $"{apiKey}/series/{seriesId}/volume/{volumeId}", apiKey); var feed = CreateFeed(series.Name + " - Volume " + volume.Name + " - Chapters ", $"{apiKey}/series/{seriesId}/volume/{volumeId}", apiKey);
SetFeedId(feed, $"series-{series.Id}-volume-{volume.Id}-chapters");
foreach (var chapter in chapters) foreach (var chapter in chapters)
{ {
feed.Entries.Add(new FeedEntry() feed.Entries.Add(new FeedEntry()
@ -551,6 +527,7 @@ namespace API.Controllers
var files = await _unitOfWork.ChapterRepository.GetFilesForChapterAsync(chapterId); var files = await _unitOfWork.ChapterRepository.GetFilesForChapterAsync(chapterId);
var feed = CreateFeed(series.Name + " - Volume " + volume.Name + " - Chapters ", $"{apiKey}/series/{seriesId}/volume/{volumeId}/chapter/{chapterId}", apiKey); var feed = CreateFeed(series.Name + " - Volume " + volume.Name + " - Chapters ", $"{apiKey}/series/{seriesId}/volume/{volumeId}/chapter/{chapterId}", apiKey);
SetFeedId(feed, $"series-{series.Id}-volume-{volume.Id}-chapter-{chapter.Id}-files");
foreach (var mangaFile in files) foreach (var mangaFile in files)
{ {
feed.Entries.Add(CreateChapter(seriesId, volumeId, chapterId, mangaFile, series, volume, chapter, apiKey)); feed.Entries.Add(CreateChapter(seriesId, volumeId, chapterId, mangaFile, series, volume, chapter, apiKey));

View File

@ -180,7 +180,7 @@ namespace API.Controllers
if (series == null) return BadRequest("Series does not exist"); if (series == null) return BadRequest("Series does not exist");
if (series.Name != updateSeries.Name && await _unitOfWork.SeriesRepository.DoesSeriesNameExistInLibrary(updateSeries.Name)) if (series.Name != updateSeries.Name && await _unitOfWork.SeriesRepository.DoesSeriesNameExistInLibrary(updateSeries.Name, series.Format))
{ {
return BadRequest("A series already exists in this library with this name. Series Names must be unique to a library."); return BadRequest("A series already exists in this library with this name. Series Names must be unique to a library.");
} }
@ -230,12 +230,19 @@ namespace API.Controllers
return Ok(series); return Ok(series);
} }
[HttpPost("in-progress")] /// <summary>
public async Task<ActionResult<IEnumerable<SeriesDto>>> GetInProgress(FilterDto filterDto, [FromQuery] UserParams userParams, [FromQuery] int libraryId = 0) /// Fetches series that are on deck aka have progress on them.
/// </summary>
/// <param name="filterDto"></param>
/// <param name="userParams"></param>
/// <param name="libraryId">Default of 0 meaning all libraries</param>
/// <returns></returns>
[HttpPost("on-deck")]
public async Task<ActionResult<IEnumerable<SeriesDto>>> GetOnDeck(FilterDto filterDto, [FromQuery] UserParams userParams, [FromQuery] int libraryId = 0)
{ {
// NOTE: This has to be done manually like this due to the DistinctBy requirement // NOTE: This has to be done manually like this due to the DistinctBy requirement
var userId = await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername()); var userId = await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername());
var results = await _unitOfWork.SeriesRepository.GetInProgress(userId, libraryId, userParams, filterDto); var results = await _unitOfWork.SeriesRepository.GetOnDeck(userId, libraryId, userParams, filterDto);
var listResults = results.DistinctBy(s => s.Name).Skip((userParams.PageNumber - 1) * userParams.PageSize) var listResults = results.DistinctBy(s => s.Name).Skip((userParams.PageNumber - 1) * userParams.PageSize)
.Take(userParams.PageSize).ToList(); .Take(userParams.PageSize).ToList();

View File

@ -26,10 +26,11 @@ namespace API.Controllers
private readonly IArchiveService _archiveService; private readonly IArchiveService _archiveService;
private readonly ICacheService _cacheService; private readonly ICacheService _cacheService;
private readonly IVersionUpdaterService _versionUpdaterService; private readonly IVersionUpdaterService _versionUpdaterService;
private readonly IStatsService _statsService;
public ServerController(IHostApplicationLifetime applicationLifetime, ILogger<ServerController> logger, IConfiguration config, public ServerController(IHostApplicationLifetime applicationLifetime, ILogger<ServerController> logger, IConfiguration config,
IBackupService backupService, IArchiveService archiveService, ICacheService cacheService, IBackupService backupService, IArchiveService archiveService, ICacheService cacheService,
IVersionUpdaterService versionUpdaterService) IVersionUpdaterService versionUpdaterService, IStatsService statsService)
{ {
_applicationLifetime = applicationLifetime; _applicationLifetime = applicationLifetime;
_logger = logger; _logger = logger;
@ -38,6 +39,7 @@ namespace API.Controllers
_archiveService = archiveService; _archiveService = archiveService;
_cacheService = cacheService; _cacheService = cacheService;
_versionUpdaterService = versionUpdaterService; _versionUpdaterService = versionUpdaterService;
_statsService = statsService;
} }
/// <summary> /// <summary>
@ -84,9 +86,9 @@ namespace API.Controllers
/// </summary> /// </summary>
/// <returns></returns> /// <returns></returns>
[HttpGet("server-info")] [HttpGet("server-info")]
public ActionResult<ServerInfoDto> GetVersion() public async Task<ActionResult<ServerInfoDto>> GetVersion()
{ {
return Ok(StatsService.GetServerInfo()); return Ok(await _statsService.GetServerInfo());
} }
[HttpGet("logs")] [HttpGet("logs")]

View File

@ -1,39 +0,0 @@
using System;
using System.Threading.Tasks;
using API.DTOs.Stats;
using API.Interfaces.Services;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
namespace API.Controllers
{
public class StatsController : BaseApiController
{
private readonly ILogger<StatsController> _logger;
private readonly IStatsService _statsService;
public StatsController(ILogger<StatsController> logger, IStatsService statsService)
{
_logger = logger;
_statsService = statsService;
}
[AllowAnonymous]
[HttpPost("client-info")]
public async Task<IActionResult> AddClientInfo([FromBody] ClientInfoDto clientInfoDto)
{
try
{
await _statsService.RecordClientInfo(clientInfoDto);
return Ok();
}
catch (Exception ex)
{
_logger.LogError(ex, "Error updating the usage statistics");
throw;
}
}
}
}

View File

@ -1,4 +1,5 @@
using System.Collections.Generic; using System;
using System.Collections.Generic;
namespace API.DTOs namespace API.DTOs
{ {
@ -45,5 +46,9 @@ namespace API.DTOs
/// Volume Id this Chapter belongs to /// Volume Id this Chapter belongs to
/// </summary> /// </summary>
public int VolumeId { get; init; } public int VolumeId { get; init; }
/// <summary>
/// When chapter was created
/// </summary>
public DateTime Created { get; init; }
} }
} }

View File

@ -1,37 +0,0 @@
using System;
namespace API.DTOs.Stats
{
public class ClientInfoDto
{
public ClientInfoDto()
{
CollectedAt = DateTime.UtcNow;
}
public string KavitaUiVersion { get; set; }
public string ScreenResolution { get; set; }
public string PlatformType { get; set; }
public DetailsVersion Browser { get; set; }
public DetailsVersion Os { get; set; }
public DateTime? CollectedAt { get; set; }
public bool UsingDarkTheme { get; set; }
public bool IsTheSameDevice(ClientInfoDto clientInfoDto)
{
return (clientInfoDto.ScreenResolution ?? string.Empty).Equals(ScreenResolution) &&
(clientInfoDto.PlatformType ?? string.Empty).Equals(PlatformType) &&
(clientInfoDto.Browser?.Name ?? string.Empty).Equals(Browser?.Name) &&
(clientInfoDto.Os?.Name ?? string.Empty).Equals(Os?.Name) &&
clientInfoDto.CollectedAt.GetValueOrDefault().ToString("yyyy-MM-dd")
.Equals(CollectedAt.GetValueOrDefault().ToString("yyyy-MM-dd"));
}
}
public class DetailsVersion
{
public string Name { get; set; }
public string Version { get; set; }
}
}

View File

@ -2,13 +2,11 @@
{ {
public class ServerInfoDto public class ServerInfoDto
{ {
public string InstallId { get; set; }
public string Os { get; set; } public string Os { get; set; }
public string DotNetVersion { get; set; }
public string RunTimeVersion { get; set; }
public string KavitaVersion { get; set; }
public string BuildBranch { get; set; }
public string Culture { get; set; }
public bool IsDocker { get; set; } public bool IsDocker { get; set; }
public string DotnetVersion { get; set; }
public string KavitaVersion { get; set; }
public int NumOfCores { get; set; } public int NumOfCores { get; set; }
} }
} }

View File

@ -1,24 +0,0 @@
using System.Collections.Generic;
using API.Entities.Enums;
namespace API.DTOs.Stats
{
public class UsageInfoDto
{
public UsageInfoDto()
{
FileTypes = new HashSet<string>();
LibraryTypesCreated = new HashSet<LibInfo>();
}
public int UsersCount { get; set; }
public IEnumerable<string> FileTypes { get; set; }
public IEnumerable<LibInfo> LibraryTypesCreated { get; set; }
}
public class LibInfo
{
public LibraryType Type { get; set; }
public int Count { get; set; }
}
}

View File

@ -1,33 +0,0 @@
using System;
using System.Collections.Generic;
using System.Linq;
namespace API.DTOs.Stats
{
public class UsageStatisticsDto
{
public UsageStatisticsDto()
{
MarkAsUpdatedNow();
ClientsInfo = new List<ClientInfoDto>();
}
public string InstallId { get; set; }
public DateTime LastUpdate { get; set; }
public UsageInfoDto UsageInfo { get; set; }
public ServerInfoDto ServerInfo { get; set; }
public List<ClientInfoDto> ClientsInfo { get; set; }
public void MarkAsUpdatedNow()
{
LastUpdate = DateTime.UtcNow;
}
public void AddClientInfo(ClientInfoDto clientInfoDto)
{
if (ClientsInfo.Any(x => x.IsTheSameDevice(clientInfoDto))) return;
ClientsInfo.Add(clientInfoDto);
}
}
}

View File

@ -1,4 +1,6 @@
namespace API.DTOs.Update using System;
namespace API.DTOs.Update
{ {
/// <summary> /// <summary>
/// Update Notification denoting a new release available for user to update to /// Update Notification denoting a new release available for user to update to
@ -34,5 +36,9 @@
/// Is this a pre-release /// Is this a pre-release
/// </summary> /// </summary>
public bool IsPrerelease { get; init; } public bool IsPrerelease { get; init; }
/// <summary>
/// Date of the publish
/// </summary>
public string PublishDate { get; init; }
} }
} }

View File

@ -26,8 +26,6 @@ namespace API.Data
"temp" "temp"
}; };
private static readonly string ConfigDirectory = Path.Join(Directory.GetCurrentDirectory(), "config");
/// <summary> /// <summary>
/// In v0.4.8 we moved all config files to config/ to match with how docker was setup. This will move all config files from current directory /// In v0.4.8 we moved all config files to config/ to match with how docker was setup. This will move all config files from current directory
@ -66,8 +64,8 @@ namespace API.Data
Console.WriteLine( Console.WriteLine(
"Migrating files from pre-v0.4.8. All Kavita config files are now located in config/"); "Migrating files from pre-v0.4.8. All Kavita config files are now located in config/");
Console.WriteLine($"Creating {ConfigDirectory}"); Console.WriteLine($"Creating {DirectoryService.ConfigDirectory}");
DirectoryService.ExistOrCreate(ConfigDirectory); DirectoryService.ExistOrCreate(DirectoryService.ConfigDirectory);
try try
{ {
@ -116,13 +114,13 @@ namespace API.Data
foreach (var folderToMove in AppFolders) foreach (var folderToMove in AppFolders)
{ {
if (new DirectoryInfo(Path.Join(ConfigDirectory, folderToMove)).Exists) continue; if (new DirectoryInfo(Path.Join(DirectoryService.ConfigDirectory, folderToMove)).Exists) continue;
try try
{ {
DirectoryService.CopyDirectoryToDirectory( DirectoryService.CopyDirectoryToDirectory(
Path.Join(Directory.GetCurrentDirectory(), folderToMove), Path.Join(Directory.GetCurrentDirectory(), folderToMove),
Path.Join(ConfigDirectory, folderToMove)); Path.Join(DirectoryService.ConfigDirectory, folderToMove));
} }
catch (Exception) catch (Exception)
{ {
@ -144,7 +142,7 @@ namespace API.Data
{ {
try try
{ {
fileInfo.CopyTo(Path.Join(ConfigDirectory, fileInfo.Name)); fileInfo.CopyTo(Path.Join(DirectoryService.ConfigDirectory, fileInfo.Name));
} }
catch (Exception) catch (Exception)
{ {

View File

@ -7,6 +7,7 @@ using API.DTOs;
using API.DTOs.CollectionTags; using API.DTOs.CollectionTags;
using API.DTOs.Filtering; using API.DTOs.Filtering;
using API.Entities; using API.Entities;
using API.Entities.Enums;
using API.Extensions; using API.Extensions;
using API.Helpers; using API.Helpers;
using API.Interfaces.Repositories; using API.Interfaces.Repositories;
@ -47,16 +48,22 @@ namespace API.Data.Repositories
_context.Series.RemoveRange(series); _context.Series.RemoveRange(series);
} }
public async Task<bool> DoesSeriesNameExistInLibrary(string name) /// <summary>
/// Returns if a series name and format exists already in a library
/// </summary>
/// <param name="name">Name of series</param>
/// <param name="format">Format of series</param>
/// <returns></returns>
public async Task<bool> DoesSeriesNameExistInLibrary(string name, MangaFormat format)
{ {
var libraries = _context.Series var libraries = _context.Series
.AsNoTracking() .AsNoTracking()
.Where(x => x.Name == name) .Where(x => x.Name.Equals(name) && x.Format == format)
.Select(s => s.LibraryId); .Select(s => s.LibraryId);
return await _context.Series return await _context.Series
.AsNoTracking() .AsNoTracking()
.Where(s => libraries.Contains(s.LibraryId) && s.Name == name) .Where(s => libraries.Contains(s.LibraryId) && s.Name.Equals(name) && s.Format == format)
.CountAsync() > 1; .CountAsync() > 1;
} }
@ -312,14 +319,15 @@ namespace API.Data.Repositories
} }
/// <summary> /// <summary>
/// Returns Series that the user has some partial progress on /// Returns Series that the user has some partial progress on. Sorts based on activity. Sort first by User progress, but if a series
/// has been updated recently, bump it to the front.
/// </summary> /// </summary>
/// <param name="userId"></param> /// <param name="userId"></param>
/// <param name="libraryId">Library to restrict to, if 0, will apply to all libraries</param> /// <param name="libraryId">Library to restrict to, if 0, will apply to all libraries</param>
/// <param name="userParams">Pagination information</param> /// <param name="userParams">Pagination information</param>
/// <param name="filter">Optional (default null) filter on query</param> /// <param name="filter">Optional (default null) filter on query</param>
/// <returns></returns> /// <returns></returns>
public async Task<IEnumerable<SeriesDto>> GetInProgress(int userId, int libraryId, UserParams userParams, FilterDto filter) public async Task<IEnumerable<SeriesDto>> GetOnDeck(int userId, int libraryId, UserParams userParams, FilterDto filter)
{ {
var formats = filter.GetSqlFilter(); var formats = filter.GetSqlFilter();
IList<int> userLibraries; IList<int> userLibraries;
@ -352,6 +360,7 @@ namespace API.Data.Repositories
&& s.PagesRead > 0 && s.PagesRead > 0
&& s.PagesRead < s.Series.Pages) && s.PagesRead < s.Series.Pages)
.OrderByDescending(s => s.LastModified) .OrderByDescending(s => s.LastModified)
.ThenByDescending(s => s.Series.LastModified)
.Select(s => s.Series) .Select(s => s.Series)
.ProjectTo<SeriesDto>(_mapper.ConfigurationProvider) .ProjectTo<SeriesDto>(_mapper.ConfigurationProvider)
.AsSplitQuery() .AsSplitQuery()

View File

@ -51,6 +51,7 @@ namespace API.Data
new () {Key = ServerSettingKey.EnableOpds, Value = "false"}, new () {Key = ServerSettingKey.EnableOpds, Value = "false"},
new () {Key = ServerSettingKey.EnableAuthentication, Value = "true"}, new () {Key = ServerSettingKey.EnableAuthentication, Value = "true"},
new () {Key = ServerSettingKey.BaseUrl, Value = "/"}, new () {Key = ServerSettingKey.BaseUrl, Value = "/"},
new () {Key = ServerSettingKey.InstallId, Value = HashUtil.AnonymousToken()},
}; };
foreach (var defaultSetting in defaultSettings) foreach (var defaultSetting in defaultSettings)
@ -71,6 +72,8 @@ namespace API.Data
Configuration.LogLevel + string.Empty; Configuration.LogLevel + string.Empty;
context.ServerSetting.First(s => s.Key == ServerSettingKey.CacheDirectory).Value = context.ServerSetting.First(s => s.Key == ServerSettingKey.CacheDirectory).Value =
DirectoryService.CacheDirectory + string.Empty; DirectoryService.CacheDirectory + string.Empty;
context.ServerSetting.First(s => s.Key == ServerSettingKey.BackupDirectory).Value =
DirectoryService.BackupDirectory + string.Empty;
await context.SaveChangesAsync(); await context.SaveChangesAsync();

View File

@ -16,7 +16,7 @@ namespace API.Entities
/// <summary> /// <summary>
/// Manga Reader Option: Which side of a split image should we show first /// Manga Reader Option: Which side of a split image should we show first
/// </summary> /// </summary>
public PageSplitOption PageSplitOption { get; set; } = PageSplitOption.SplitRightToLeft; public PageSplitOption PageSplitOption { get; set; } = PageSplitOption.FitSplit;
/// <summary> /// <summary>
/// Manga Reader Option: How the manga reader should perform paging or reading of the file /// Manga Reader Option: How the manga reader should perform paging or reading of the file
/// <example> /// <example>
@ -25,14 +25,15 @@ namespace API.Entities
/// </example> /// </example>
/// </summary> /// </summary>
public ReaderMode ReaderMode { get; set; } public ReaderMode ReaderMode { get; set; }
/// <summary> /// <summary>
/// Manga Reader Option: Allow the menu to close after 6 seconds without interaction /// Manga Reader Option: Allow the menu to close after 6 seconds without interaction
/// </summary> /// </summary>
public bool AutoCloseMenu { get; set; } public bool AutoCloseMenu { get; set; } = true;
/// <summary> /// <summary>
/// Book Reader Option: Should the background color be dark /// Book Reader Option: Should the background color be dark
/// </summary> /// </summary>
public bool BookReaderDarkMode { get; set; } = false; public bool BookReaderDarkMode { get; set; } = true;
/// <summary> /// <summary>
/// Book Reader Option: Override extra Margin /// Book Reader Option: Override extra Margin
/// </summary> /// </summary>

View File

@ -4,6 +4,7 @@
{ {
SplitLeftToRight = 0, SplitLeftToRight = 0,
SplitRightToLeft = 1, SplitRightToLeft = 1,
NoSplit = 2 NoSplit = 2,
FitSplit = 3
} }
} }

View File

@ -4,26 +4,61 @@ namespace API.Entities.Enums
{ {
public enum ServerSettingKey public enum ServerSettingKey
{ {
/// <summary>
/// Cron format for how often full library scans are performed.
/// </summary>
[Description("TaskScan")] [Description("TaskScan")]
TaskScan = 0, TaskScan = 0,
/// <summary>
/// Where files are cached. Not currently used.
/// </summary>
[Description("CacheDirectory")] [Description("CacheDirectory")]
CacheDirectory = 1, CacheDirectory = 1,
/// <summary>
/// Cron format for how often backups are taken.
/// </summary>
[Description("TaskBackup")] [Description("TaskBackup")]
TaskBackup = 2, TaskBackup = 2,
/// <summary>
/// Logging level for Server. Not managed in DB. Managed in appsettings.json and synced to DB.
/// </summary>
[Description("LoggingLevel")] [Description("LoggingLevel")]
LoggingLevel = 3, LoggingLevel = 3,
/// <summary>
/// Port server listens on. Not managed in DB. Managed in appsettings.json and synced to DB.
/// </summary>
[Description("Port")] [Description("Port")]
Port = 4, Port = 4,
/// <summary>
/// Where the backups are stored.
/// </summary>
[Description("BackupDirectory")] [Description("BackupDirectory")]
BackupDirectory = 5, BackupDirectory = 5,
/// <summary>
/// Allow anonymous data to be reported to KavitaStats
/// </summary>
[Description("AllowStatCollection")] [Description("AllowStatCollection")]
AllowStatCollection = 6, AllowStatCollection = 6,
/// <summary>
/// Is OPDS enabled for the server
/// </summary>
[Description("EnableOpds")] [Description("EnableOpds")]
EnableOpds = 7, EnableOpds = 7,
/// <summary>
/// Is Authentication needed for non-admin accounts
/// </summary>
[Description("EnableAuthentication")] [Description("EnableAuthentication")]
EnableAuthentication = 8, EnableAuthentication = 8,
/// <summary>
/// Base Url for the server. Not Implemented.
/// </summary>
[Description("BaseUrl")] [Description("BaseUrl")]
BaseUrl = 9 BaseUrl = 9,
/// <summary>
/// Represents this installation of Kavita. Is tied to Stat reporting but has no information about user or files.
/// </summary>
[Description("InstallId")]
InstallId = 10
} }
} }

View File

@ -23,7 +23,7 @@ namespace API.Entities
/// </summary> /// </summary>
public string SortName { get; set; } public string SortName { get; set; }
/// <summary> /// <summary>
/// Name in Japanese. By default, will be same as Name. /// Name in original language (Japanese for Manga). By default, will be same as Name.
/// </summary> /// </summary>
public string LocalizedName { get; set; } public string LocalizedName { get; set; }
/// <summary> /// <summary>

View File

@ -17,6 +17,6 @@ namespace API.Interfaces
void RefreshSeriesMetadata(int libraryId, int seriesId, bool forceUpdate = false); void RefreshSeriesMetadata(int libraryId, int seriesId, bool forceUpdate = false);
void ScanSeries(int libraryId, int seriesId, bool forceUpdate = false); void ScanSeries(int libraryId, int seriesId, bool forceUpdate = false);
void CancelStatsTasks(); void CancelStatsTasks();
void RunStatCollection(); Task RunStatCollection();
} }
} }

View File

@ -4,6 +4,7 @@ using API.Data.Scanner;
using API.DTOs; using API.DTOs;
using API.DTOs.Filtering; using API.DTOs.Filtering;
using API.Entities; using API.Entities;
using API.Entities.Enums;
using API.Helpers; using API.Helpers;
namespace API.Interfaces.Repositories namespace API.Interfaces.Repositories
@ -14,7 +15,7 @@ namespace API.Interfaces.Repositories
void Update(Series series); void Update(Series series);
void Remove(Series series); void Remove(Series series);
void Remove(IEnumerable<Series> series); void Remove(IEnumerable<Series> series);
Task<bool> DoesSeriesNameExistInLibrary(string name); Task<bool> DoesSeriesNameExistInLibrary(string name, MangaFormat format);
/// <summary> /// <summary>
/// Adds user information like progress, ratings, etc /// Adds user information like progress, ratings, etc
/// </summary> /// </summary>
@ -45,7 +46,7 @@ namespace API.Interfaces.Repositories
/// <returns></returns> /// <returns></returns>
Task AddSeriesModifiers(int userId, List<SeriesDto> series); Task AddSeriesModifiers(int userId, List<SeriesDto> series);
Task<string> GetSeriesCoverImageAsync(int seriesId); Task<string> GetSeriesCoverImageAsync(int seriesId);
Task<IEnumerable<SeriesDto>> GetInProgress(int userId, int libraryId, UserParams userParams, FilterDto filter); Task<IEnumerable<SeriesDto>> GetOnDeck(int userId, int libraryId, UserParams userParams, FilterDto filter);
Task<PagedList<SeriesDto>> GetRecentlyAdded(int libraryId, int userId, UserParams userParams, FilterDto filter); // NOTE: Probably put this in LibraryRepo Task<PagedList<SeriesDto>> GetRecentlyAdded(int libraryId, int userId, UserParams userParams, FilterDto filter); // NOTE: Probably put this in LibraryRepo
Task<SeriesMetadataDto> GetSeriesMetadata(int seriesId); Task<SeriesMetadataDto> GetSeriesMetadata(int seriesId);
Task<PagedList<SeriesDto>> GetSeriesDtoForCollectionAsync(int collectionId, int userId, UserParams userParams); Task<PagedList<SeriesDto>> GetSeriesDtoForCollectionAsync(int collectionId, int userId, UserParams userParams);

View File

@ -5,7 +5,7 @@ namespace API.Interfaces.Services
{ {
public interface IStatsService public interface IStatsService
{ {
Task RecordClientInfo(ClientInfoDto clientInfoDto);
Task Send(); Task Send();
Task<ServerInfoDto> GetServerInfo();
} }
} }

View File

@ -258,19 +258,19 @@ namespace API.Parser
MatchOptions, RegexTimeout), MatchOptions, RegexTimeout),
// Teen Titans v1 001 (1966-02) (digital) (OkC.O.M.P.U.T.O.-Novus) // Teen Titans v1 001 (1966-02) (digital) (OkC.O.M.P.U.T.O.-Novus)
new Regex( new Regex(
@"^(?<Series>.*)(?: |_)v\d+", @"^(?<Series>.+?)(?: |_)v\d+",
MatchOptions, RegexTimeout), MatchOptions, RegexTimeout),
// Amazing Man Comics chapter 25 // Amazing Man Comics chapter 25
new Regex( new Regex(
@"^(?<Series>.*)(?: |_)c(hapter) \d+", @"^(?<Series>.+?)(?: |_)c(hapter) \d+",
MatchOptions, RegexTimeout), MatchOptions, RegexTimeout),
// Amazing Man Comics issue #25 // Amazing Man Comics issue #25
new Regex( new Regex(
@"^(?<Series>.*)(?: |_)i(ssue) #\d+", @"^(?<Series>.+?)(?: |_)i(ssue) #\d+",
MatchOptions, RegexTimeout), MatchOptions, RegexTimeout),
// Batman Wayne Family Adventures - Ep. 001 - Moving In // Batman Wayne Family Adventures - Ep. 001 - Moving In
new Regex( new Regex(
@"^(?<Series>.+?)(\s|_|-)?(?:Ep\.?)(\s|_|-)+\d+", @"^(?<Series>.+?)(\s|_|-)(?:Ep\.?)(\s|_|-)+\d+",
MatchOptions, RegexTimeout), MatchOptions, RegexTimeout),
// Batgirl Vol.2000 #57 (December, 2004) // Batgirl Vol.2000 #57 (December, 2004)
new Regex( new Regex(
@ -286,7 +286,7 @@ namespace API.Parser
MatchOptions, RegexTimeout), MatchOptions, RegexTimeout),
// Scott Pilgrim 02 - Scott Pilgrim vs. The World (2005) // Scott Pilgrim 02 - Scott Pilgrim vs. The World (2005)
new Regex( new Regex(
@"^(?<Series>.*)(?: |_)(?<Volume>\d+)", @"^(?<Series>.+?)(?: |_)(?<Chapter>\d+)",
MatchOptions, RegexTimeout), MatchOptions, RegexTimeout),
// The First Asterix Frieze (WebP by Doc MaKS) // The First Asterix Frieze (WebP by Doc MaKS)
new Regex( new Regex(
@ -336,9 +336,13 @@ namespace API.Parser
new Regex( new Regex(
@"^(?<Series>.+?)(?:\s|_)#(?<Chapter>\d+)", @"^(?<Series>.+?)(?:\s|_)#(?<Chapter>\d+)",
MatchOptions, RegexTimeout), MatchOptions, RegexTimeout),
// Batman 2016 - Chapter 01, Batman 2016 - Issue 01, Batman 2016 - Issue #01
new Regex(
@"^(?<Series>.+?)((c(hapter)?)|issue)(_|\s)#?(?<Chapter>(\d+(\.\d)?)-?(\d+(\.\d)?)?)",
MatchOptions, RegexTimeout),
// Invincible 070.5 - Invincible Returns 1 (2010) (digital) (Minutemen-InnerDemons).cbr // Invincible 070.5 - Invincible Returns 1 (2010) (digital) (Minutemen-InnerDemons).cbr
new Regex( new Regex(
@"^(?<Series>.+?)(?: |_)(c? ?)(?<Chapter>(\d+(\.\d)?)-?(\d+(\.\d)?)?)(c? ?)-", @"^(?<Series>.+?)(?:\s|_)(c? ?(chapter)?)(?<Chapter>(\d+(\.\d)?)-?(\d+(\.\d)?)?)(c? ?)-",
MatchOptions, RegexTimeout), MatchOptions, RegexTimeout),
// Batgirl Vol.2000 #57 (December, 2004) // Batgirl Vol.2000 #57 (December, 2004)
new Regex( new Regex(
@ -883,7 +887,7 @@ namespace API.Parser
{ {
if (match.Success) if (match.Success)
{ {
title = title.Replace(match.Value, "").Trim(); title = title.Replace(match.Value, string.Empty).Trim();
} }
} }
} }
@ -900,7 +904,7 @@ namespace API.Parser
{ {
if (match.Success) if (match.Success)
{ {
title = title.Replace(match.Value, "").Trim(); title = title.Replace(match.Value, string.Empty).Trim();
} }
} }
} }
@ -946,7 +950,7 @@ namespace API.Parser
{ {
if (match.Success) if (match.Success)
{ {
title = title.Replace(match.Value, ""); title = title.Replace(match.Value, string.Empty);
} }
} }
} }

View File

@ -138,6 +138,22 @@ namespace API.Services
if (!string.IsNullOrEmpty(nonNestedFile)) return nonNestedFile; if (!string.IsNullOrEmpty(nonNestedFile)) return nonNestedFile;
// Check the first folder and sort within that to see if we can find a file, else fallback to first file with basic sort.
// Get first folder, then sort within that
var firstDirectoryFile = fullNames.OrderBy(Path.GetDirectoryName, new NaturalSortComparer()).FirstOrDefault();
if (!string.IsNullOrEmpty(firstDirectoryFile))
{
var firstDirectory = Path.GetDirectoryName(firstDirectoryFile);
if (!string.IsNullOrEmpty(firstDirectory))
{
var firstDirectoryResult = fullNames.Where(f => firstDirectory.Equals(Path.GetDirectoryName(f)))
.OrderBy(Path.GetFileName, new NaturalSortComparer())
.FirstOrDefault();
if (!string.IsNullOrEmpty(firstDirectoryResult)) return firstDirectoryResult;
}
}
var result = fullNames var result = fullNames
.OrderBy(Path.GetFileName, new NaturalSortComparer()) .OrderBy(Path.GetFileName, new NaturalSortComparer())
.FirstOrDefault(); .FirstOrDefault();
@ -159,7 +175,7 @@ namespace API.Services
/// <returns></returns> /// <returns></returns>
public string GetCoverImage(string archivePath, string fileName) public string GetCoverImage(string archivePath, string fileName)
{ {
if (archivePath == null || !IsValidArchive(archivePath)) return String.Empty; if (archivePath == null || !IsValidArchive(archivePath)) return string.Empty;
try try
{ {
var libraryHandler = CanOpen(archivePath); var libraryHandler = CanOpen(archivePath);

View File

@ -30,6 +30,7 @@ namespace API.Services
private readonly ILogger<BookService> _logger; private readonly ILogger<BookService> _logger;
private readonly StylesheetParser _cssParser = new (); private readonly StylesheetParser _cssParser = new ();
private static readonly RecyclableMemoryStreamManager StreamManager = new (); private static readonly RecyclableMemoryStreamManager StreamManager = new ();
private const string CssScopeClass = ".book-content";
public BookService(ILogger<BookService> logger) public BookService(ILogger<BookService> logger)
{ {
@ -152,22 +153,23 @@ namespace API.Services
EscapeCssImageReferences(ref stylesheetHtml, apiBase, book); EscapeCssImageReferences(ref stylesheetHtml, apiBase, book);
var styleContent = RemoveWhiteSpaceFromStylesheets(stylesheetHtml); var styleContent = RemoveWhiteSpaceFromStylesheets(stylesheetHtml);
styleContent = styleContent.Replace("body", ".reading-section");
styleContent = styleContent.Replace("body", CssScopeClass);
if (string.IsNullOrEmpty(styleContent)) return string.Empty; if (string.IsNullOrEmpty(styleContent)) return string.Empty;
var stylesheet = await _cssParser.ParseAsync(styleContent); var stylesheet = await _cssParser.ParseAsync(styleContent);
foreach (var styleRule in stylesheet.StyleRules) foreach (var styleRule in stylesheet.StyleRules)
{ {
if (styleRule.Selector.Text == ".reading-section") continue; if (styleRule.Selector.Text == CssScopeClass) continue;
if (styleRule.Selector.Text.Contains(",")) if (styleRule.Selector.Text.Contains(","))
{ {
styleRule.Text = styleRule.Text.Replace(styleRule.SelectorText, styleRule.Text = styleRule.Text.Replace(styleRule.SelectorText,
string.Join(", ", string.Join(", ",
styleRule.Selector.Text.Split(",").Select(s => ".reading-section " + s))); styleRule.Selector.Text.Split(",").Select(s => $"{CssScopeClass} " + s)));
continue; continue;
} }
styleRule.Text = ".reading-section " + styleRule.Text; styleRule.Text = $"{CssScopeClass} " + styleRule.Text;
} }
return RemoveWhiteSpaceFromStylesheets(stylesheet.ToCss()); return RemoveWhiteSpaceFromStylesheets(stylesheet.ToCss());
} }
@ -371,7 +373,7 @@ namespace API.Services
FullFilePath = filePath, FullFilePath = filePath,
IsSpecial = false, IsSpecial = false,
Series = series.Trim(), Series = series.Trim(),
Volumes = seriesIndex.Split(".")[0] Volumes = seriesIndex
}; };
} }
} }

View File

@ -21,7 +21,7 @@ namespace API.Services
public static readonly string CacheDirectory = Path.Join(Directory.GetCurrentDirectory(), "config", "cache"); public static readonly string CacheDirectory = Path.Join(Directory.GetCurrentDirectory(), "config", "cache");
public static readonly string CoverImageDirectory = Path.Join(Directory.GetCurrentDirectory(), "config", "covers"); public static readonly string CoverImageDirectory = Path.Join(Directory.GetCurrentDirectory(), "config", "covers");
public static readonly string BackupDirectory = Path.Join(Directory.GetCurrentDirectory(), "config", "backups"); public static readonly string BackupDirectory = Path.Join(Directory.GetCurrentDirectory(), "config", "backups");
public static readonly string StatsDirectory = Path.Join(Directory.GetCurrentDirectory(), "config", "stats"); public static readonly string ConfigDirectory = Path.Join(Directory.GetCurrentDirectory(), "config");
public DirectoryService(ILogger<DirectoryService> logger) public DirectoryService(ILogger<DirectoryService> logger)
{ {
@ -173,7 +173,15 @@ namespace API.Services
return true; return true;
} }
/// <summary>
/// Checks if the root path of a path exists or not.
/// </summary>
/// <param name="path"></param>
/// <returns></returns>
public static bool IsDriveMounted(string path)
{
return new DirectoryInfo(Path.GetPathRoot(path) ?? string.Empty).Exists;
}
public static string[] GetFilesWithExtension(string path, string searchPatternExpression = "") public static string[] GetFilesWithExtension(string path, string searchPatternExpression = "")
{ {
@ -257,7 +265,7 @@ namespace API.Services
/// <param name="directoryPath"></param> /// <param name="directoryPath"></param>
/// <param name="prepend">An optional string to prepend to the target file's name</param> /// <param name="prepend">An optional string to prepend to the target file's name</param>
/// <returns></returns> /// <returns></returns>
public bool CopyFilesToDirectory(IEnumerable<string> filePaths, string directoryPath, string prepend = "") public static bool CopyFilesToDirectory(IEnumerable<string> filePaths, string directoryPath, string prepend = "", ILogger logger = null)
{ {
ExistOrCreate(directoryPath); ExistOrCreate(directoryPath);
string currentFile = null; string currentFile = null;
@ -273,19 +281,24 @@ namespace API.Services
} }
else else
{ {
_logger.LogWarning("Tried to copy {File} but it doesn't exist", file); logger?.LogWarning("Tried to copy {File} but it doesn't exist", file);
} }
} }
} }
catch (Exception ex) catch (Exception ex)
{ {
_logger.LogError(ex, "Unable to copy {File} to {DirectoryPath}", currentFile, directoryPath); logger?.LogError(ex, "Unable to copy {File} to {DirectoryPath}", currentFile, directoryPath);
return false; return false;
} }
return true; return true;
} }
public bool CopyFilesToDirectory(IEnumerable<string> filePaths, string directoryPath, string prepend = "")
{
return CopyFilesToDirectory(filePaths, directoryPath, prepend, _logger);
}
public IEnumerable<string> ListDirectory(string rootPath) public IEnumerable<string> ListDirectory(string rootPath)
{ {
if (!Directory.Exists(rootPath)) return ImmutableList<string>.Empty; if (!Directory.Exists(rootPath)) return ImmutableList<string>.Empty;

View File

@ -29,7 +29,7 @@ namespace API.Services.HostedServices
// These methods will automatically check if stat collection is disabled to prevent sending any data regardless // These methods will automatically check if stat collection is disabled to prevent sending any data regardless
// of when setting was changed // of when setting was changed
await taskScheduler.ScheduleStatsTasks(); await taskScheduler.ScheduleStatsTasks();
taskScheduler.RunStatCollection(); await taskScheduler.RunStatCollection();
} }
catch (Exception) catch (Exception)
{ {

View File

@ -218,14 +218,18 @@ namespace API.Services
var stopwatch = Stopwatch.StartNew(); var stopwatch = Stopwatch.StartNew();
var totalTime = 0L; 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); _logger.LogInformation("[MetadataService] Refreshing Library {LibraryName}. Total Items: {TotalSize}. Total Chunks: {TotalChunks} with {ChunkSize} size", library.Name, chunkInfo.TotalSize, chunkInfo.TotalChunks, chunkInfo.ChunkSize);
await _messageHub.Clients.All.SendAsync(SignalREvents.RefreshMetadataProgress,
MessageFactory.RefreshMetadataProgressEvent(library.Id, 0F));
for (var chunk = 1; chunk <= chunkInfo.TotalChunks; chunk++) var i = 0;
for (var chunk = 1; chunk <= chunkInfo.TotalChunks; chunk++, i++)
{ {
if (chunkInfo.TotalChunks == 0) continue; if (chunkInfo.TotalChunks == 0) continue;
totalTime += stopwatch.ElapsedMilliseconds; totalTime += stopwatch.ElapsedMilliseconds;
stopwatch.Restart(); stopwatch.Restart();
_logger.LogInformation("[MetadataService] Processing chunk {ChunkNumber} / {TotalChunks} with size {ChunkSize}. Series ({SeriesStart} - {SeriesEnd}", _logger.LogInformation("[MetadataService] Processing chunk {ChunkNumber} / {TotalChunks} with size {ChunkSize}. Series ({SeriesStart} - {SeriesEnd}",
chunk, chunkInfo.TotalChunks, chunkInfo.ChunkSize, chunk * chunkInfo.ChunkSize, (chunk + 1) * chunkInfo.ChunkSize); chunk, chunkInfo.TotalChunks, chunkInfo.ChunkSize, chunk * chunkInfo.ChunkSize, (chunk + 1) * chunkInfo.ChunkSize);
var nonLibrarySeries = await _unitOfWork.SeriesRepository.GetFullSeriesForLibraryIdAsync(library.Id, var nonLibrarySeries = await _unitOfWork.SeriesRepository.GetFullSeriesForLibraryIdAsync(library.Id,
new UserParams() new UserParams()
{ {
@ -233,6 +237,7 @@ namespace API.Services
PageSize = chunkInfo.ChunkSize PageSize = chunkInfo.ChunkSize
}); });
_logger.LogDebug("[MetadataService] Fetched {SeriesCount} series for refresh", nonLibrarySeries.Count); _logger.LogDebug("[MetadataService] Fetched {SeriesCount} series for refresh", nonLibrarySeries.Count);
Parallel.ForEach(nonLibrarySeries, series => Parallel.ForEach(nonLibrarySeries, series =>
{ {
try try
@ -275,8 +280,14 @@ namespace API.Services
"[MetadataService] Processed {SeriesStart} - {SeriesEnd} out of {TotalSeries} series in {ElapsedScanTime} milliseconds for {LibraryName}", "[MetadataService] Processed {SeriesStart} - {SeriesEnd} out of {TotalSeries} series in {ElapsedScanTime} milliseconds for {LibraryName}",
chunk * chunkInfo.ChunkSize, (chunk * chunkInfo.ChunkSize) + nonLibrarySeries.Count, chunkInfo.TotalSize, stopwatch.ElapsedMilliseconds, library.Name); chunk * chunkInfo.ChunkSize, (chunk * chunkInfo.ChunkSize) + nonLibrarySeries.Count, chunkInfo.TotalSize, stopwatch.ElapsedMilliseconds, library.Name);
} }
var progress = Math.Max(0F, Math.Min(1F, i * 1F / chunkInfo.TotalChunks));
await _messageHub.Clients.All.SendAsync(SignalREvents.RefreshMetadataProgress,
MessageFactory.RefreshMetadataProgressEvent(library.Id, progress));
} }
await _messageHub.Clients.All.SendAsync(SignalREvents.RefreshMetadataProgress,
MessageFactory.RefreshMetadataProgressEvent(library.Id, 1F));
_logger.LogInformation("[MetadataService] Updated metadata for {SeriesNumber} series in library {LibraryName} in {ElapsedMilliseconds} milliseconds total", chunkInfo.TotalSize, library.Name, totalTime); _logger.LogInformation("[MetadataService] Updated metadata for {SeriesNumber} series in library {LibraryName} in {ElapsedMilliseconds} milliseconds total", chunkInfo.TotalSize, library.Name, totalTime);
} }

View File

@ -23,9 +23,9 @@ namespace API.Services
private readonly IStatsService _statsService; private readonly IStatsService _statsService;
private readonly IVersionUpdaterService _versionUpdaterService; private readonly IVersionUpdaterService _versionUpdaterService;
private const string SendDataTask = "finalize-stats";
public static BackgroundJobServer Client => new BackgroundJobServer(); public static BackgroundJobServer Client => new BackgroundJobServer();
private static readonly Random Rnd = new Random();
public TaskScheduler(ICacheService cacheService, ILogger<TaskScheduler> logger, IScannerService scannerService, public TaskScheduler(ICacheService cacheService, ILogger<TaskScheduler> logger, IScannerService scannerService,
@ -73,7 +73,6 @@ namespace API.Services
RecurringJob.AddOrUpdate("cleanup", () => _cleanupService.Cleanup(), Cron.Daily, TimeZoneInfo.Local); RecurringJob.AddOrUpdate("cleanup", () => _cleanupService.Cleanup(), Cron.Daily, TimeZoneInfo.Local);
RecurringJob.AddOrUpdate("check-for-updates", () => _scannerService.ScanLibraries(), Cron.Daily, TimeZoneInfo.Local);
} }
#region StatsTasks #region StatsTasks
@ -89,19 +88,27 @@ namespace API.Services
} }
_logger.LogDebug("Scheduling stat collection daily"); _logger.LogDebug("Scheduling stat collection daily");
RecurringJob.AddOrUpdate(SendDataTask, () => _statsService.Send(), Cron.Daily, TimeZoneInfo.Local); RecurringJob.AddOrUpdate("report-stats", () => _statsService.Send(), Cron.Daily(Rnd.Next(0, 22)), TimeZoneInfo.Local);
} }
public void CancelStatsTasks() public void CancelStatsTasks()
{ {
_logger.LogDebug("Cancelling/Removing StatsTasks"); _logger.LogDebug("Cancelling/Removing StatsTasks");
RecurringJob.RemoveIfExists(SendDataTask); RecurringJob.RemoveIfExists("report-stats");
} }
public void RunStatCollection() /// <summary>
/// First time run stat collection. Executes immediately on a background thread. Does not block.
/// </summary>
public async Task RunStatCollection()
{ {
_logger.LogInformation("Enqueuing stat collection"); var allowStatCollection = (await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).AllowStatCollection;
if (!allowStatCollection)
{
_logger.LogDebug("User has opted out of stat collection, not sending stats");
return;
}
BackgroundJob.Enqueue(() => _statsService.Send()); BackgroundJob.Enqueue(() => _statsService.Send());
} }
@ -112,8 +119,8 @@ namespace API.Services
public void ScheduleUpdaterTasks() public void ScheduleUpdaterTasks()
{ {
_logger.LogInformation("Scheduling Auto-Update tasks"); _logger.LogInformation("Scheduling Auto-Update tasks");
RecurringJob.AddOrUpdate("check-updates", () => CheckForUpdate(), Cron.Weekly, TimeZoneInfo.Local); // Schedule update check between noon and 6pm local time
RecurringJob.AddOrUpdate("check-updates", () => _versionUpdaterService.CheckForUpdate(), Cron.Daily(Rnd.Next(12, 18)), TimeZoneInfo.Local);
} }
#endregion #endregion

View File

@ -8,8 +8,9 @@ using API.Entities.Enums;
using API.Extensions; using API.Extensions;
using API.Interfaces; using API.Interfaces;
using API.Interfaces.Services; using API.Interfaces.Services;
using API.SignalR;
using Hangfire; using Hangfire;
using Kavita.Common.EnvironmentInfo; using Microsoft.AspNetCore.SignalR;
using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
@ -20,45 +21,32 @@ namespace API.Services.Tasks
private readonly IUnitOfWork _unitOfWork; private readonly IUnitOfWork _unitOfWork;
private readonly ILogger<BackupService> _logger; private readonly ILogger<BackupService> _logger;
private readonly IDirectoryService _directoryService; private readonly IDirectoryService _directoryService;
private readonly string _tempDirectory = DirectoryService.TempDirectory; private readonly IHubContext<MessageHub> _messageHub;
private readonly string _logDirectory = DirectoryService.LogDirectory;
private readonly IList<string> _backupFiles; private readonly IList<string> _backupFiles;
public BackupService(IUnitOfWork unitOfWork, ILogger<BackupService> logger, IDirectoryService directoryService, IConfiguration config) public BackupService(IUnitOfWork unitOfWork, ILogger<BackupService> logger,
IDirectoryService directoryService, IConfiguration config, IHubContext<MessageHub> messageHub)
{ {
_unitOfWork = unitOfWork; _unitOfWork = unitOfWork;
_logger = logger; _logger = logger;
_directoryService = directoryService; _directoryService = directoryService;
_messageHub = messageHub;
var maxRollingFiles = config.GetMaxRollingFiles(); var maxRollingFiles = config.GetMaxRollingFiles();
var loggingSection = config.GetLoggingFileName(); var loggingSection = config.GetLoggingFileName();
var files = LogFiles(maxRollingFiles, loggingSection); var files = LogFiles(maxRollingFiles, loggingSection);
if (new OsInfo(Array.Empty<IOsVersionAdapter>()).IsDocker)
{
_backupFiles = new List<string>()
{
"data/appsettings.json",
"data/Hangfire.db",
"data/Hangfire-log.db",
"data/kavita.db",
"data/kavita.db-shm", // This wont always be there
"data/kavita.db-wal" // This wont always be there
};
}
else
{
_backupFiles = new List<string>() _backupFiles = new List<string>()
{ {
"appsettings.json", "appsettings.json",
"Hangfire.db", "Hangfire.db", // This is not used atm
"Hangfire-log.db", "Hangfire-log.db", // This is not used atm
"kavita.db", "kavita.db",
"kavita.db-shm", // This wont always be there "kavita.db-shm", // This wont always be there
"kavita.db-wal" // This wont always be there "kavita.db-wal" // This wont always be there
}; };
}
foreach (var file in files.Select(f => (new FileInfo(f)).Name).ToList()) foreach (var file in files.Select(f => (new FileInfo(f)).Name).ToList())
{ {
@ -72,7 +60,7 @@ namespace API.Services.Tasks
var fi = new FileInfo(logFileName); var fi = new FileInfo(logFileName);
var files = maxRollingFiles > 0 var files = maxRollingFiles > 0
? DirectoryService.GetFiles(_logDirectory, $@"{Path.GetFileNameWithoutExtension(fi.Name)}{multipleFileRegex}\.log") ? DirectoryService.GetFiles(DirectoryService.LogDirectory, $@"{Path.GetFileNameWithoutExtension(fi.Name)}{multipleFileRegex}\.log")
: new[] {"kavita.log"}; : new[] {"kavita.log"};
return files; return files;
} }
@ -89,11 +77,13 @@ namespace API.Services.Tasks
_logger.LogDebug("Backing up to {BackupDirectory}", backupDirectory); _logger.LogDebug("Backing up to {BackupDirectory}", backupDirectory);
if (!DirectoryService.ExistOrCreate(backupDirectory)) if (!DirectoryService.ExistOrCreate(backupDirectory))
{ {
_logger.LogError("Could not write to {BackupDirectory}; aborting backup", backupDirectory); _logger.LogCritical("Could not write to {BackupDirectory}; aborting backup", backupDirectory);
return; return;
} }
var dateString = DateTime.Now.ToShortDateString().Replace("/", "_"); await SendProgress(0F);
var dateString = $"{DateTime.Now.ToShortDateString()}_{DateTime.Now.ToLongTimeString()}".Replace("/", "_").Replace(":", "_");
var zipPath = Path.Join(backupDirectory, $"kavita_backup_{dateString}.zip"); var zipPath = Path.Join(backupDirectory, $"kavita_backup_{dateString}.zip");
if (File.Exists(zipPath)) if (File.Exists(zipPath))
@ -102,15 +92,19 @@ namespace API.Services.Tasks
return; return;
} }
var tempDirectory = Path.Join(_tempDirectory, dateString); var tempDirectory = Path.Join(DirectoryService.TempDirectory, dateString);
DirectoryService.ExistOrCreate(tempDirectory); DirectoryService.ExistOrCreate(tempDirectory);
DirectoryService.ClearDirectory(tempDirectory); DirectoryService.ClearDirectory(tempDirectory);
_directoryService.CopyFilesToDirectory( _directoryService.CopyFilesToDirectory(
_backupFiles.Select(file => Path.Join(Directory.GetCurrentDirectory(), file)).ToList(), tempDirectory); _backupFiles.Select(file => Path.Join(DirectoryService.ConfigDirectory, file)).ToList(), tempDirectory);
await SendProgress(0.25F);
await CopyCoverImagesToBackupDirectory(tempDirectory); await CopyCoverImagesToBackupDirectory(tempDirectory);
await SendProgress(0.75F);
try try
{ {
ZipFile.CreateFromDirectory(tempDirectory, zipPath); ZipFile.CreateFromDirectory(tempDirectory, zipPath);
@ -122,6 +116,7 @@ namespace API.Services.Tasks
DirectoryService.ClearAndDeleteDirectory(tempDirectory); DirectoryService.ClearAndDeleteDirectory(tempDirectory);
_logger.LogInformation("Database backup completed"); _logger.LogInformation("Database backup completed");
await SendProgress(1F);
} }
private async Task CopyCoverImagesToBackupDirectory(string tempDirectory) private async Task CopyCoverImagesToBackupDirectory(string tempDirectory)
@ -154,6 +149,12 @@ namespace API.Services.Tasks
} }
} }
private async Task SendProgress(float progress)
{
await _messageHub.Clients.All.SendAsync(SignalREvents.BackupDatabaseProgress,
MessageFactory.BackupDatabaseProgressEvent(progress));
}
/// <summary> /// <summary>
/// Removes Database backups older than 30 days. If all backups are older than 30 days, the latest is kept. /// Removes Database backups older than 30 days. If all backups are older than 30 days, the latest is kept.
/// </summary> /// </summary>

View File

@ -2,7 +2,9 @@
using System.Threading.Tasks; using System.Threading.Tasks;
using API.Interfaces; using API.Interfaces;
using API.Interfaces.Services; using API.Interfaces.Services;
using API.SignalR;
using Hangfire; using Hangfire;
using Microsoft.AspNetCore.SignalR;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
namespace API.Services.Tasks namespace API.Services.Tasks
@ -16,14 +18,16 @@ namespace API.Services.Tasks
private readonly ILogger<CleanupService> _logger; private readonly ILogger<CleanupService> _logger;
private readonly IBackupService _backupService; private readonly IBackupService _backupService;
private readonly IUnitOfWork _unitOfWork; private readonly IUnitOfWork _unitOfWork;
private readonly IHubContext<MessageHub> _messageHub;
public CleanupService(ICacheService cacheService, ILogger<CleanupService> logger, public CleanupService(ICacheService cacheService, ILogger<CleanupService> logger,
IBackupService backupService, IUnitOfWork unitOfWork) IBackupService backupService, IUnitOfWork unitOfWork, IHubContext<MessageHub> messageHub)
{ {
_cacheService = cacheService; _cacheService = cacheService;
_logger = logger; _logger = logger;
_backupService = backupService; _backupService = backupService;
_unitOfWork = unitOfWork; _unitOfWork = unitOfWork;
_messageHub = messageHub;
} }
public void CleanupCacheDirectory() public void CleanupCacheDirectory()
@ -39,19 +43,31 @@ namespace API.Services.Tasks
public async Task Cleanup() public async Task Cleanup()
{ {
_logger.LogInformation("Starting Cleanup"); _logger.LogInformation("Starting Cleanup");
await SendProgress(0F);
_logger.LogInformation("Cleaning temp directory"); _logger.LogInformation("Cleaning temp directory");
var tempDirectory = DirectoryService.TempDirectory; DirectoryService.ClearDirectory(DirectoryService.TempDirectory);
DirectoryService.ClearDirectory(tempDirectory); await SendProgress(0.1F);
CleanupCacheDirectory(); CleanupCacheDirectory();
await SendProgress(0.25F);
_logger.LogInformation("Cleaning old database backups"); _logger.LogInformation("Cleaning old database backups");
_backupService.CleanupBackups(); _backupService.CleanupBackups();
await SendProgress(0.50F);
_logger.LogInformation("Cleaning deleted cover images"); _logger.LogInformation("Cleaning deleted cover images");
await DeleteSeriesCoverImages(); await DeleteSeriesCoverImages();
await SendProgress(0.6F);
await DeleteChapterCoverImages(); await DeleteChapterCoverImages();
await SendProgress(0.7F);
await DeleteTagCoverImages(); await DeleteTagCoverImages();
await SendProgress(1F);
_logger.LogInformation("Cleanup finished"); _logger.LogInformation("Cleanup finished");
} }
private async Task SendProgress(float progress)
{
await _messageHub.Clients.All.SendAsync(SignalREvents.CleanupProgress,
MessageFactory.CleanupProgressEvent(progress));
}
private async Task DeleteSeriesCoverImages() private async Task DeleteSeriesCoverImages()
{ {
var images = await _unitOfWork.SeriesRepository.GetAllCoverImagesAsync(); var images = await _unitOfWork.SeriesRepository.GetAllCoverImagesAsync();

View File

@ -56,6 +56,14 @@ namespace API.Services.Tasks
var chapterIds = await _unitOfWork.SeriesRepository.GetChapterIdsForSeriesAsync(new[] {seriesId}); var chapterIds = await _unitOfWork.SeriesRepository.GetChapterIdsForSeriesAsync(new[] {seriesId});
var library = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(libraryId, LibraryIncludes.Folders); var library = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(libraryId, LibraryIncludes.Folders);
var folderPaths = library.Folders.Select(f => f.Path).ToList(); var folderPaths = library.Folders.Select(f => f.Path).ToList();
// Check if any of the folder roots are not available (ie disconnected from network, etc) and fail if any of them are
if (folderPaths.Any(f => !DirectoryService.IsDriveMounted(f)))
{
_logger.LogError("Some of the root folders for library are not accessible. Please check that drives are connected and rescan. Scan will be aborted");
return;
}
var dirs = DirectoryService.FindHighestDirectoriesFromFiles(folderPaths, files.Select(f => f.FilePath).ToList()); var dirs = DirectoryService.FindHighestDirectoriesFromFiles(folderPaths, files.Select(f => f.FilePath).ToList());
_logger.LogInformation("Beginning file scan on {SeriesName}", series.Name); _logger.LogInformation("Beginning file scan on {SeriesName}", series.Name);
@ -129,8 +137,7 @@ namespace API.Services.Tasks
await _unitOfWork.RollbackAsync(); await _unitOfWork.RollbackAsync();
} }
// Tell UI that this series is done // Tell UI that this series is done
await _messageHub.Clients.All.SendAsync(SignalREvents.ScanSeries, MessageFactory.ScanSeriesEvent(seriesId, series.Name), await _messageHub.Clients.All.SendAsync(SignalREvents.ScanSeries, MessageFactory.ScanSeriesEvent(seriesId, series.Name), token);
cancellationToken: token);
await CleanupDbEntities(); await CleanupDbEntities();
BackgroundJob.Enqueue(() => _cacheService.CleanupChapters(chapterIds)); BackgroundJob.Enqueue(() => _cacheService.CleanupChapters(chapterIds));
BackgroundJob.Enqueue(() => _metadataService.RefreshMetadataForSeries(libraryId, series.Id, false)); BackgroundJob.Enqueue(() => _metadataService.RefreshMetadataForSeries(libraryId, series.Id, false));
@ -195,6 +202,14 @@ namespace API.Services.Tasks
return; return;
} }
// Check if any of the folder roots are not available (ie disconnected from network, etc) and fail if any of them are
if (library.Folders.Any(f => !DirectoryService.IsDriveMounted(f.Path)))
{
_logger.LogError("Some of the root folders for library are not accessible. Please check that drives are connected and rescan. Scan will be aborted");
return;
}
_logger.LogInformation("[ScannerService] Beginning file scan on {LibraryName}", library.Name); _logger.LogInformation("[ScannerService] Beginning file scan on {LibraryName}", library.Name);
await _messageHub.Clients.All.SendAsync(SignalREvents.ScanLibraryProgress, await _messageHub.Clients.All.SendAsync(SignalREvents.ScanLibraryProgress,
MessageFactory.ScanLibraryProgressEvent(libraryId, 0)); MessageFactory.ScanLibraryProgressEvent(libraryId, 0));
@ -228,7 +243,7 @@ namespace API.Services.Tasks
BackgroundJob.Enqueue(() => _metadataService.RefreshMetadata(libraryId, false)); BackgroundJob.Enqueue(() => _metadataService.RefreshMetadata(libraryId, false));
await _messageHub.Clients.All.SendAsync(SignalREvents.ScanLibraryProgress, await _messageHub.Clients.All.SendAsync(SignalREvents.ScanLibraryProgress,
MessageFactory.ScanLibraryProgressEvent(libraryId, 100)); MessageFactory.ScanLibraryProgressEvent(libraryId, 1F));
} }
/// <summary> /// <summary>
@ -326,7 +341,7 @@ namespace API.Services.Tasks
await _messageHub.Clients.All.SendAsync(SignalREvents.SeriesRemoved, MessageFactory.SeriesRemovedEvent(missing.Id, missing.Name, library.Id)); await _messageHub.Clients.All.SendAsync(SignalREvents.SeriesRemoved, MessageFactory.SeriesRemovedEvent(missing.Id, missing.Name, library.Id));
} }
var progress = Math.Max(0, Math.Min(100, ((chunk + 1F) * chunkInfo.ChunkSize) / chunkInfo.TotalSize)); var progress = Math.Max(0, Math.Min(1, ((chunk + 1F) * chunkInfo.ChunkSize) / chunkInfo.TotalSize));
await _messageHub.Clients.All.SendAsync(SignalREvents.ScanLibraryProgress, await _messageHub.Clients.All.SendAsync(SignalREvents.ScanLibraryProgress,
MessageFactory.ScanLibraryProgressEvent(library.Id, progress)); MessageFactory.ScanLibraryProgressEvent(library.Id, progress));
} }
@ -343,15 +358,14 @@ namespace API.Services.Tasks
// Key is normalized already // Key is normalized already
Series existingSeries; Series existingSeries;
try try
{// NOTE: Maybe use .Equals() here {
existingSeries = allSeries.SingleOrDefault(s => existingSeries = allSeries.SingleOrDefault(s => FindSeries(s, key));
(s.NormalizedName == key.NormalizedName || Parser.Parser.Normalize(s.OriginalName) == key.NormalizedName)
&& (s.Format == key.Format || s.Format == MangaFormat.Unknown));
} }
catch (Exception e) catch (Exception e)
{ {
// NOTE: If I ever want to put Duplicates table, this is where it can go
_logger.LogCritical(e, "[ScannerService] There are multiple series that map to normalized key {Key}. You can manually delete the entity via UI and rescan to fix it. This will be skipped", key.NormalizedName); _logger.LogCritical(e, "[ScannerService] There are multiple series that map to normalized key {Key}. You can manually delete the entity via UI and rescan to fix it. This will be skipped", key.NormalizedName);
var duplicateSeries = allSeries.Where(s => s.NormalizedName == key.NormalizedName || Parser.Parser.Normalize(s.OriginalName) == key.NormalizedName).ToList(); var duplicateSeries = allSeries.Where(s => FindSeries(s, key));
foreach (var series in duplicateSeries) foreach (var series in duplicateSeries)
{ {
_logger.LogCritical("[ScannerService] Duplicate Series Found: {Key} maps with {Series}", key.Name, series.OriginalName); _logger.LogCritical("[ScannerService] Duplicate Series Found: {Key} maps with {Series}", key.Name, series.OriginalName);
@ -362,46 +376,38 @@ namespace API.Services.Tasks
if (existingSeries != null) continue; if (existingSeries != null) continue;
existingSeries = DbFactory.Series(infos[0].Series); var s = DbFactory.Series(infos[0].Series);
existingSeries.Format = key.Format; s.Format = key.Format;
newSeries.Add(existingSeries); s.LibraryId = library.Id; // We have to manually set this since we aren't adding the series to the Library's series.
newSeries.Add(s);
} }
var i = 0; var i = 0;
foreach(var series in newSeries) foreach(var series in newSeries)
{ {
_logger.LogDebug("[ScannerService] Processing series {SeriesName}", series.OriginalName);
UpdateSeries(series, parsedSeries);
_unitOfWork.SeriesRepository.Attach(series);
try try
{ {
_logger.LogDebug("[ScannerService] Processing series {SeriesName}", series.OriginalName); await _unitOfWork.CommitAsync();
UpdateVolumes(series, ParseScannedFiles.GetInfosByName(parsedSeries, series).ToArray());
series.Pages = series.Volumes.Sum(v => v.Pages);
series.LibraryId = library.Id; // We have to manually set this since we aren't adding the series to the Library's series.
_unitOfWork.SeriesRepository.Attach(series);
if (await _unitOfWork.CommitAsync())
{
_logger.LogInformation( _logger.LogInformation(
"[ScannerService] Added {NewSeries} series in {ElapsedScanTime} milliseconds for {LibraryName}", "[ScannerService] Added {NewSeries} series in {ElapsedScanTime} milliseconds for {LibraryName}",
newSeries.Count, stopwatch.ElapsedMilliseconds, library.Name); newSeries.Count, stopwatch.ElapsedMilliseconds, library.Name);
// Inform UI of new series added // Inform UI of new series added
await _messageHub.Clients.All.SendAsync(SignalREvents.SeriesAdded, MessageFactory.SeriesAddedEvent(series.Id, series.Name, library.Id)); await _messageHub.Clients.All.SendAsync(SignalREvents.SeriesAdded, MessageFactory.SeriesAddedEvent(series.Id, series.Name, library.Id));
var progress = Math.Max(0F, Math.Min(100F, i * 1F / newSeries.Count));
await _messageHub.Clients.All.SendAsync(SignalREvents.ScanLibraryProgress,
MessageFactory.ScanLibraryProgressEvent(library.Id, progress));
}
else
{
// This is probably not needed. Better to catch the exception.
_logger.LogCritical(
"[ScannerService] There was a critical error that resulted in a failed scan. Please check logs and rescan");
}
i++;
} }
catch (Exception ex) catch (Exception ex)
{ {
_logger.LogError(ex, "[ScannerService] There was an exception updating volumes for {SeriesName}", series.Name); _logger.LogCritical(ex, "[ScannerService] There was a critical exception adding new series entry for {SeriesName} with a duplicate index key: {IndexKey} ",
series.Name, $"{series.Name}_{series.NormalizedName}_{series.LocalizedName}_{series.LibraryId}_{series.Format}");
} }
var progress = Math.Max(0F, Math.Min(1F, i * 1F / newSeries.Count));
await _messageHub.Clients.All.SendAsync(SignalREvents.ScanLibraryProgress,
MessageFactory.ScanLibraryProgressEvent(library.Id, progress));
i++;
} }
_logger.LogInformation( _logger.LogInformation(
@ -409,13 +415,19 @@ namespace API.Services.Tasks
newSeries.Count, stopwatch.ElapsedMilliseconds, library.Name); newSeries.Count, stopwatch.ElapsedMilliseconds, library.Name);
} }
private static bool FindSeries(Series series, ParsedSeries parsedInfoKey)
{
return (series.NormalizedName.Equals(parsedInfoKey.NormalizedName) || Parser.Parser.Normalize(series.OriginalName).Equals(parsedInfoKey.NormalizedName))
&& (series.Format == parsedInfoKey.Format || series.Format == MangaFormat.Unknown);
}
private void UpdateSeries(Series series, Dictionary<ParsedSeries, List<ParserInfo>> parsedSeries) private void UpdateSeries(Series series, Dictionary<ParsedSeries, List<ParserInfo>> parsedSeries)
{ {
try try
{ {
_logger.LogInformation("[ScannerService] Processing series {SeriesName}", series.OriginalName); _logger.LogInformation("[ScannerService] Processing series {SeriesName}", series.OriginalName);
var parsedInfos = ParseScannedFiles.GetInfosByName(parsedSeries, series).ToArray(); var parsedInfos = ParseScannedFiles.GetInfosByName(parsedSeries, series);
UpdateVolumes(series, parsedInfos); UpdateVolumes(series, parsedInfos);
series.Pages = series.Volumes.Sum(v => v.Pages); series.Pages = series.Volumes.Sum(v => v.Pages);
@ -482,7 +494,7 @@ namespace API.Services.Tasks
/// <param name="missingSeries">Series not found on disk or can't be parsed</param> /// <param name="missingSeries">Series not found on disk or can't be parsed</param>
/// <param name="removeCount"></param> /// <param name="removeCount"></param>
/// <returns>the updated existingSeries</returns> /// <returns>the updated existingSeries</returns>
public static IList<Series> RemoveMissingSeries(IList<Series> existingSeries, IEnumerable<Series> missingSeries, out int removeCount) public static IEnumerable<Series> RemoveMissingSeries(IList<Series> existingSeries, IEnumerable<Series> missingSeries, out int removeCount)
{ {
var existingCount = existingSeries.Count; var existingCount = existingSeries.Count;
var missingList = missingSeries.ToList(); var missingList = missingSeries.ToList();
@ -496,7 +508,7 @@ namespace API.Services.Tasks
return existingSeries; return existingSeries;
} }
private void UpdateVolumes(Series series, ParserInfo[] parsedInfos) private void UpdateVolumes(Series series, IList<ParserInfo> parsedInfos)
{ {
var startingVolumeCount = series.Volumes.Count; var startingVolumeCount = series.Volumes.Count;
// Add new volumes and update chapters per volume // Add new volumes and update chapters per volume
@ -550,7 +562,7 @@ namespace API.Services.Tasks
/// </summary> /// </summary>
/// <param name="volume"></param> /// <param name="volume"></param>
/// <param name="parsedInfos"></param> /// <param name="parsedInfos"></param>
private void UpdateChapters(Volume volume, ParserInfo[] parsedInfos) private void UpdateChapters(Volume volume, IList<ParserInfo> parsedInfos)
{ {
// Add new chapters // Add new chapters
foreach (var info in parsedInfos) foreach (var info in parsedInfos)

View File

@ -1,46 +1,31 @@
using System; using System;
using System.IO;
using System.Linq;
using System.Net.Http; using System.Net.Http;
using System.Runtime.InteropServices; using System.Runtime.InteropServices;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using API.Data;
using API.DTOs.Stats; using API.DTOs.Stats;
using API.Entities.Enums;
using API.Interfaces; using API.Interfaces;
using API.Interfaces.Services; using API.Interfaces.Services;
using Flurl.Http; using Flurl.Http;
using Hangfire;
using Kavita.Common;
using Kavita.Common.EnvironmentInfo; using Kavita.Common.EnvironmentInfo;
using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
namespace API.Services.Tasks namespace API.Services.Tasks
{ {
public class StatsService : IStatsService public class StatsService : IStatsService
{ {
private const string StatFileName = "app_stats.json";
private readonly DataContext _dbContext;
private readonly ILogger<StatsService> _logger; private readonly ILogger<StatsService> _logger;
private readonly IUnitOfWork _unitOfWork; private readonly IUnitOfWork _unitOfWork;
private const string ApiUrl = "https://stats.kavitareader.com";
#pragma warning disable S1075 public StatsService(ILogger<StatsService> logger, IUnitOfWork unitOfWork)
private const string ApiUrl = "http://stats.kavitareader.com";
#pragma warning restore S1075
private static readonly string StatsFilePath = Path.Combine(DirectoryService.StatsDirectory, StatFileName);
private static bool FileExists => File.Exists(StatsFilePath);
public StatsService(DataContext dbContext, ILogger<StatsService> logger,
IUnitOfWork unitOfWork)
{ {
_dbContext = dbContext;
_logger = logger; _logger = logger;
_unitOfWork = unitOfWork; _unitOfWork = unitOfWork;
FlurlHttp.ConfigureClient(ApiUrl, cli =>
cli.Settings.HttpClientFactory = new UntrustedCertClientFactory());
} }
/// <summary> /// <summary>
@ -55,18 +40,8 @@ namespace API.Services.Tasks
return; return;
} }
var rnd = new Random();
var offset = rnd.Next(0, 6);
if (offset == 0)
{
await SendData(); await SendData();
} }
else
{
_logger.LogInformation("KavitaStats upload has been schedule to run in {Offset} hours", offset);
BackgroundJob.Schedule(() => SendData(), DateTimeOffset.Now.AddHours(offset));
}
}
/// <summary> /// <summary>
/// This must be public for Hangfire. Do not call this directly. /// This must be public for Hangfire. Do not call this directly.
@ -74,66 +49,30 @@ namespace API.Services.Tasks
// ReSharper disable once MemberCanBePrivate.Global // ReSharper disable once MemberCanBePrivate.Global
public async Task SendData() public async Task SendData()
{ {
await CollectRelevantData(); var data = await GetServerInfo();
await FinalizeStats(); await SendDataToStatsServer(data);
} }
public async Task RecordClientInfo(ClientInfoDto clientInfoDto)
{
var statisticsDto = await GetData();
statisticsDto.AddClientInfo(clientInfoDto);
await SaveFile(statisticsDto); private async Task SendDataToStatsServer(ServerInfoDto data)
}
private async Task CollectRelevantData()
{
var usageInfo = await GetUsageInfo();
var serverInfo = GetServerInfo();
await PathData(serverInfo, usageInfo);
}
private async Task FinalizeStats()
{
try
{
var data = await GetExistingData<UsageStatisticsDto>();
var successful = await SendDataToStatsServer(data);
if (successful)
{
ResetStats();
}
}
catch (Exception ex)
{
_logger.LogError(ex, "There was an exception while sending data to KavitaStats");
}
}
private async Task<bool> SendDataToStatsServer(UsageStatisticsDto data)
{ {
var responseContent = string.Empty; var responseContent = string.Empty;
try try
{ {
var response = await (ApiUrl + "/api/InstallationStats") var response = await (ApiUrl + "/api/v2/stats")
.WithHeader("Accept", "application/json") .WithHeader("Accept", "application/json")
.WithHeader("User-Agent", "Kavita") .WithHeader("User-Agent", "Kavita")
.WithHeader("x-api-key", "MsnvA2DfQqxSK5jh") .WithHeader("x-api-key", "MsnvA2DfQqxSK5jh")
.WithHeader("api-key", "MsnvA2DfQqxSK5jh")
.WithHeader("x-kavita-version", BuildInfo.Version) .WithHeader("x-kavita-version", BuildInfo.Version)
.WithHeader("Content-Type", "application/json")
.WithTimeout(TimeSpan.FromSeconds(30)) .WithTimeout(TimeSpan.FromSeconds(30))
.PostJsonAsync(data); .PostJsonAsync(data);
if (response.StatusCode != StatusCodes.Status200OK) if (response.StatusCode != StatusCodes.Status200OK)
{ {
_logger.LogError("KavitaStats did not respond successfully. {Content}", response); _logger.LogError("KavitaStats did not respond successfully. {Content}", response);
return false;
} }
return true;
} }
catch (HttpRequestException e) catch (HttpRequestException e)
{ {
@ -149,84 +88,22 @@ namespace API.Services.Tasks
{ {
_logger.LogError(e, "An error happened during the request to KavitaStats"); _logger.LogError(e, "An error happened during the request to KavitaStats");
} }
return false;
} }
private static void ResetStats() public async Task<ServerInfoDto> GetServerInfo()
{
if (FileExists) File.Delete(StatsFilePath);
}
private async Task PathData(ServerInfoDto serverInfoDto, UsageInfoDto usageInfoDto)
{
var data = await GetData();
data.ServerInfo = serverInfoDto;
data.UsageInfo = usageInfoDto;
data.MarkAsUpdatedNow();
await SaveFile(data);
}
private static async ValueTask<UsageStatisticsDto> GetData()
{
if (!FileExists) return new UsageStatisticsDto {InstallId = HashUtil.AnonymousToken()};
return await GetExistingData<UsageStatisticsDto>();
}
private async Task<UsageInfoDto> GetUsageInfo()
{
var usersCount = await _dbContext.Users.CountAsync();
var libsCountByType = await _dbContext.Library
.AsNoTracking()
.GroupBy(x => x.Type)
.Select(x => new LibInfo {Type = x.Key, Count = x.Count()})
.ToArrayAsync();
var uniqueFileTypes = await _unitOfWork.FileRepository.GetFileExtensions();
var usageInfo = new UsageInfoDto
{
UsersCount = usersCount,
LibraryTypesCreated = libsCountByType,
FileTypes = uniqueFileTypes
};
return usageInfo;
}
public static ServerInfoDto GetServerInfo()
{ {
var installId = await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.InstallId);
var serverInfo = new ServerInfoDto var serverInfo = new ServerInfoDto
{ {
InstallId = installId.Value,
Os = RuntimeInformation.OSDescription, Os = RuntimeInformation.OSDescription,
DotNetVersion = Environment.Version.ToString(),
RunTimeVersion = RuntimeInformation.FrameworkDescription,
KavitaVersion = BuildInfo.Version.ToString(), KavitaVersion = BuildInfo.Version.ToString(),
Culture = Thread.CurrentThread.CurrentCulture.Name, DotnetVersion = Environment.Version.ToString(),
BuildBranch = BuildInfo.Branch,
IsDocker = new OsInfo(Array.Empty<IOsVersionAdapter>()).IsDocker, IsDocker = new OsInfo(Array.Empty<IOsVersionAdapter>()).IsDocker,
NumOfCores = Environment.ProcessorCount NumOfCores = Math.Max(Environment.ProcessorCount, 1)
}; };
return serverInfo; return serverInfo;
} }
private static async Task<T> GetExistingData<T>()
{
var json = await File.ReadAllTextAsync(StatsFilePath);
return JsonSerializer.Deserialize<T>(json);
}
private static async Task SaveFile(UsageStatisticsDto statisticsDto)
{
DirectoryService.ExistOrCreate(DirectoryService.StatsDirectory);
await File.WriteAllTextAsync(StatsFilePath, JsonSerializer.Serialize(statisticsDto));
}
} }
} }

View File

@ -38,6 +38,11 @@ namespace API.Services.Tasks
/// </summary> /// </summary>
// ReSharper disable once InconsistentNaming // ReSharper disable once InconsistentNaming
public string Html_Url { get; init; } public string Html_Url { get; init; }
/// <summary>
/// Date Release was Published
/// </summary>
// ReSharper disable once InconsistentNaming
public string Published_At { get; init; }
} }
public class UntrustedCertClientFactory : DefaultHttpClientFactory public class UntrustedCertClientFactory : DefaultHttpClientFactory
@ -109,7 +114,8 @@ namespace API.Services.Tasks
UpdateBody = _markdown.Transform(update.Body.Trim()), UpdateBody = _markdown.Transform(update.Body.Trim()),
UpdateTitle = update.Name, UpdateTitle = update.Name,
UpdateUrl = update.Html_Url, UpdateUrl = update.Html_Url,
IsDocker = new OsInfo(Array.Empty<IOsVersionAdapter>()).IsDocker IsDocker = new OsInfo(Array.Empty<IOsVersionAdapter>()).IsDocker,
PublishDate = update.Published_At
}; };
} }

View File

@ -60,6 +60,20 @@ namespace API.SignalR
}; };
} }
public static SignalRMessage RefreshMetadataProgressEvent(int libraryId, float progress)
{
return new SignalRMessage()
{
Name = SignalREvents.RefreshMetadataProgress,
Body = new
{
LibraryId = libraryId,
Progress = progress,
EventTime = DateTime.Now
}
};
}
public static SignalRMessage RefreshMetadataEvent(int libraryId, int seriesId) public static SignalRMessage RefreshMetadataEvent(int libraryId, int seriesId)
@ -75,6 +89,31 @@ namespace API.SignalR
}; };
} }
public static SignalRMessage BackupDatabaseProgressEvent(float progress)
{
return new SignalRMessage()
{
Name = SignalREvents.BackupDatabaseProgress,
Body = new
{
Progress = progress
}
};
}
public static SignalRMessage CleanupProgressEvent(float progress)
{
return new SignalRMessage()
{
Name = SignalREvents.CleanupProgress,
Body = new
{
Progress = progress
}
};
}
public static SignalRMessage UpdateVersionEvent(UpdateNotificationDto update) public static SignalRMessage UpdateVersionEvent(UpdateNotificationDto update)
{ {
return new SignalRMessage return new SignalRMessage

View File

@ -4,7 +4,14 @@
{ {
public const string UpdateVersion = "UpdateVersion"; public const string UpdateVersion = "UpdateVersion";
public const string ScanSeries = "ScanSeries"; public const string ScanSeries = "ScanSeries";
/// <summary>
/// Event during Refresh Metadata for cover image change
/// </summary>
public const string RefreshMetadata = "RefreshMetadata"; public const string RefreshMetadata = "RefreshMetadata";
/// <summary>
/// Event sent out during Refresh Metadata for progress tracking
/// </summary>
public const string RefreshMetadataProgress = "RefreshMetadataProgress";
public const string ScanLibrary = "ScanLibrary"; public const string ScanLibrary = "ScanLibrary";
public const string SeriesAdded = "SeriesAdded"; public const string SeriesAdded = "SeriesAdded";
public const string SeriesRemoved = "SeriesRemoved"; public const string SeriesRemoved = "SeriesRemoved";
@ -12,5 +19,13 @@
public const string OnlineUsers = "OnlineUsers"; public const string OnlineUsers = "OnlineUsers";
public const string SeriesAddedToCollection = "SeriesAddedToCollection"; public const string SeriesAddedToCollection = "SeriesAddedToCollection";
public const string ScanLibraryError = "ScanLibraryError"; public const string ScanLibraryError = "ScanLibraryError";
/// <summary>
/// Event sent out during backing up the database
/// </summary>
public const string BackupDatabaseProgress = "BackupDatabaseProgress";
/// <summary>
/// Event sent out during cleaning up temp and cache folders
/// </summary>
public const string CleanupProgress = "CleanupProgress";
} }
} }

View File

@ -3,7 +3,7 @@
We're always looking for people to help make Kavita even better, there are a number of ways to contribute. We're always looking for people to help make Kavita even better, there are a number of ways to contribute.
## Documentation ## ## Documentation ##
Setup guides, FAQ, the more information we have on the [wiki](https://github.com/Kareadita/Kavita/wiki) the better. Setup guides, FAQ, the more information we have on the [wiki](https://wiki.kavitareader.com/) the better.
## Development ## ## Development ##

View File

@ -4,7 +4,7 @@
<TargetFramework>net5.0</TargetFramework> <TargetFramework>net5.0</TargetFramework>
<Company>kavitareader.com</Company> <Company>kavitareader.com</Company>
<Product>Kavita</Product> <Product>Kavita</Product>
<AssemblyVersion>0.4.8.1</AssemblyVersion> <AssemblyVersion>0.4.9.0</AssemblyVersion>
<NeutralLanguage>en</NeutralLanguage> <NeutralLanguage>en</NeutralLanguage>
</PropertyGroup> </PropertyGroup>

View File

@ -80,15 +80,15 @@ services:
**Note: Kavita is under heavy development and is being updated all the time, so the tag for current builds is `:nightly`. The `:latest` tag will be the latest stable release.** **Note: Kavita is under heavy development and is being updated all the time, so the tag for current builds is `:nightly`. The `:latest` tag will be the latest stable release.**
## Feature Requests ## Feature Requests
Got a great idea? Throw it up on the FeatHub or vote on another idea. Please check the [Project Board](https://github.com/Kareadita/Kavita/projects) first for a list of planned features. Got a great idea? Throw it up on our [Feature Request site](https://feats.kavitareader.com/) or vote on another idea. Please check the [Project Board](https://github.com/Kareadita/Kavita/projects) first for a list of planned features.
[![Feature Requests](https://feathub.com/Kareadita/Kavita?format=svg)](https://feathub.com/Kareadita/Kavita)
## Contributors ## Contributors
This project exists thanks to all the people who contribute. [Contribute](CONTRIBUTING.md). This project exists thanks to all the people who contribute. [Contribute](CONTRIBUTING.md).
<a href="https://github.com/Kareadita/Kavita/graphs/contributors"><img src="https://opencollective.com/kavita/contributors.svg?width=890&button=false" /></a> <a href="https://github.com/Kareadita/Kavita/graphs/contributors">
<img src="https://opencollective.com/kavita/contributors.svg?width=890&button=false&avatarHeight=42" />
</a>
## Donate ## Donate
@ -99,7 +99,7 @@ expenses related to Kavita. Back us through [OpenCollective](https://opencollect
Thank you to all our backers! 🙏 [Become a backer](https://opencollective.com/Kavita#backer) Thank you to all our backers! 🙏 [Become a backer](https://opencollective.com/Kavita#backer)
<img src="https://opencollective.com/Kavita/backers.svg?width=890"></a> <img src="https://opencollective.com/kavita/backers.svg?width=890&avatarHeight=42"></a>
## Sponsors ## Sponsors
@ -116,9 +116,6 @@ Thank you to [<img src="/Logo/jetbrains.svg" alt="" width="32"> JetBrains](http:
* [<img src="/Logo/rider.svg" alt="" width="32"> Rider](http://www.jetbrains.com/rider/) * [<img src="/Logo/rider.svg" alt="" width="32"> Rider](http://www.jetbrains.com/rider/)
* [<img src="/Logo/dottrace.svg" alt="" width="32"> dotTrace](http://www.jetbrains.com/dottrace/) * [<img src="/Logo/dottrace.svg" alt="" width="32"> dotTrace](http://www.jetbrains.com/dottrace/)
## Sentry
Thank you to [<img src="/Logo/sentry.svg" alt="" width="64">](https://sentry.io/welcome/) for providing us with free license to their software.
### License ### License
* [GNU GPL v3](http://www.gnu.org/licenses/gpl.html) * [GNU GPL v3](http://www.gnu.org/licenses/gpl.html)

View File

@ -71,7 +71,7 @@
{ {
"type": "anyComponentStyle", "type": "anyComponentStyle",
"maximumWarning": "2kb", "maximumWarning": "2kb",
"maximumError": "4kb" "maximumError": "5kb"
} }
] ]
} }

165
UI/Web/package-lock.json generated
View File

@ -89,6 +89,36 @@
"webpack-sources": "2.0.1", "webpack-sources": "2.0.1",
"webpack-subresource-integrity": "1.5.1", "webpack-subresource-integrity": "1.5.1",
"worker-plugin": "5.0.0" "worker-plugin": "5.0.0"
},
"dependencies": {
"postcss": {
"version": "7.0.32",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-7.0.32.tgz",
"integrity": "sha512-03eXong5NLnNCD05xscnGKGDZ98CyzoqPSMjOe6SuoQY7Z2hIj0Ld1g/O/UQRuOle2aRtiIRDg9tDcTGAkLfKw==",
"dev": true,
"requires": {
"chalk": "^2.4.2",
"source-map": "^0.6.1",
"supports-color": "^6.1.0"
},
"dependencies": {
"source-map": {
"version": "0.6.1",
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
"integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==",
"dev": true
}
}
},
"supports-color": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-6.1.0.tgz",
"integrity": "sha512-qe1jfm1Mg7Nq/NSh6XE24gPXROEVsWHxC1LIx//XNlD9iw7YZQGjZNjYN7xGaEG6iKdA8EtNFW6R0gjnVXp+wQ==",
"dev": true,
"requires": {
"has-flag": "^3.0.0"
}
}
} }
}, },
"@angular-devkit/build-optimizer": { "@angular-devkit/build-optimizer": {
@ -3252,9 +3282,9 @@
"dev": true "dev": true
}, },
"ansi-regex": { "ansi-regex": {
"version": "5.0.0", "version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.0.tgz", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
"integrity": "sha512-bY6fj56OUQ0hU1KjFNDQuJFezqKdrAyFdIevADiqrWHwSlbmBNMHp5ak2f40Pm8JTFyM2mqxkG6ngkHO11f/lg==" "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="
}, },
"ansi-styles": { "ansi-styles": {
"version": "3.2.1", "version": "3.2.1",
@ -4591,9 +4621,9 @@
"integrity": "sha1-p9BVi9icQveV3UIyj3QIMcpTvCU=" "integrity": "sha1-p9BVi9icQveV3UIyj3QIMcpTvCU="
}, },
"color-string": { "color-string": {
"version": "1.5.4", "version": "1.6.0",
"resolved": "https://registry.npmjs.org/color-string/-/color-string-1.5.4.tgz", "resolved": "https://registry.npmjs.org/color-string/-/color-string-1.6.0.tgz",
"integrity": "sha512-57yF5yt8Xa3czSEW1jfQDE79Idk0+AkN/4KWad6tbdxUmAs3MvjxlWSWD4deYytcRfoZ9nhKyFl1kj5tBvidbw==", "integrity": "sha512-c/hGS+kRWJutUBEngKKmk4iH3sD59MBkoxVapS/0wgpCz2u7XsNloxknyvBhzwEs1IbV36D9PwqLPJ2DTu3vMA==",
"dev": true, "dev": true,
"requires": { "requires": {
"color-name": "^1.0.0", "color-name": "^1.0.0",
@ -6820,9 +6850,9 @@
} }
}, },
"glob-parent": { "glob-parent": {
"version": "5.1.1", "version": "5.1.2",
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.1.tgz", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
"integrity": "sha512-FnI+VGOpnlGHWZxthPGR+QhR78fuiK0sNLkHQv+bL9fQi57lNNdquIbna/WrfROrolq8GK5Ek6BiMwqL/voRYQ==", "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
"dev": true, "dev": true,
"requires": { "requires": {
"is-glob": "^4.0.1" "is-glob": "^4.0.1"
@ -9620,9 +9650,9 @@
} }
}, },
"ws": { "ws": {
"version": "7.4.3", "version": "7.5.5",
"resolved": "https://registry.npmjs.org/ws/-/ws-7.4.3.tgz", "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.5.tgz",
"integrity": "sha512-hr6vCR76GsossIRsr8OLR9acVVm1jyfEWvhbNjtgPOrfvAlKzvyeg/P6r8RuDjRyrcQoPQT7K0DGEPc7Ae6jzA==", "integrity": "sha512-BAkMFcAzl8as1G/hArkxOxq3G7pjUqQ3gzYbLL0/5zNkph70e+lCoxBGnm6AW1+/aiNeV4fnKqZ8m4GZewmH2w==",
"dev": true "dev": true
} }
} }
@ -9710,9 +9740,9 @@
} }
}, },
"jszip": { "jszip": {
"version": "3.5.0", "version": "3.7.1",
"resolved": "https://registry.npmjs.org/jszip/-/jszip-3.5.0.tgz", "resolved": "https://registry.npmjs.org/jszip/-/jszip-3.7.1.tgz",
"integrity": "sha512-WRtu7TPCmYePR1nazfrtuF216cIVon/3GWOvHS9QR5bIwSbnxtdpma6un3jyGGNhHsKCSzn5Ypk+EkDRvTGiFA==", "integrity": "sha512-ghL0tz1XG9ZEmRMcEN2vt7xabrDdqHHeykgARpmZ0BiIctWxM47Vt63ZO2dnp4QYt/xJVLLy5Zv1l/xRdh2byg==",
"dev": true, "dev": true,
"requires": { "requires": {
"lie": "~3.3.0", "lie": "~3.3.0",
@ -11269,20 +11299,6 @@
"resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz",
"integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==",
"dev": true "dev": true
},
"tar": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/tar/-/tar-6.1.0.tgz",
"integrity": "sha512-DUCttfhsnLCjwoDoFcI+B2iJgYa93vBnDUATYEeRx6sntCTdN01VnqsIuTlALXla/LWooNg0yEGeB+Y8WdFxGA==",
"dev": true,
"requires": {
"chownr": "^2.0.0",
"fs-minipass": "^2.0.0",
"minipass": "^3.0.0",
"minizlib": "^2.1.1",
"mkdirp": "^1.0.3",
"yallist": "^4.0.0"
}
} }
} }
}, },
@ -11416,9 +11432,9 @@
"dev": true "dev": true
}, },
"path-parse": { "path-parse": {
"version": "1.0.6", "version": "1.0.7",
"resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.6.tgz", "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz",
"integrity": "sha512-GSmOT2EbHrINBf9SR7CDELwlJ8AENk3Qn7OikK4nFYAu3Ote2+JYNVvkpAEQm3/TLNEJFD/xZJjzyxg3KBWOzw==" "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="
}, },
"path-to-regexp": { "path-to-regexp": {
"version": "0.1.7", "version": "0.1.7",
@ -11451,6 +11467,12 @@
"integrity": "sha1-Ywn04OX6kT7BxpMHrjZLSzd8nns=", "integrity": "sha1-Ywn04OX6kT7BxpMHrjZLSzd8nns=",
"dev": true "dev": true
}, },
"picocolors": {
"version": "0.2.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-0.2.1.tgz",
"integrity": "sha512-cMlDqaLEqfSaW8Z7N5Jw+lyIW869EzT73/F5lhtY9cLGoVxSXznfgfXMO0Z5K0o0Q2TkTXq+0KFsdnSe3jDViA==",
"dev": true
},
"picomatch": { "picomatch": {
"version": "2.2.2", "version": "2.2.2",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.2.2.tgz", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.2.2.tgz",
@ -11534,14 +11556,13 @@
"dev": true "dev": true
}, },
"postcss": { "postcss": {
"version": "7.0.32", "version": "7.0.39",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-7.0.32.tgz", "resolved": "https://registry.npmjs.org/postcss/-/postcss-7.0.39.tgz",
"integrity": "sha512-03eXong5NLnNCD05xscnGKGDZ98CyzoqPSMjOe6SuoQY7Z2hIj0Ld1g/O/UQRuOle2aRtiIRDg9tDcTGAkLfKw==", "integrity": "sha512-yioayjNbHn6z1/Bywyb2Y4s3yvDAeXGOyxqD+LnVOinq6Mdmd++SW2wUNVzavyyHxd6+DxzWGIuosg6P1Rj8uA==",
"dev": true, "dev": true,
"requires": { "requires": {
"chalk": "^2.4.2", "picocolors": "^0.2.1",
"source-map": "^0.6.1", "source-map": "^0.6.1"
"supports-color": "^6.1.0"
}, },
"dependencies": { "dependencies": {
"source-map": { "source-map": {
@ -11549,15 +11570,6 @@
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
"integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==",
"dev": true "dev": true
},
"supports-color": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-6.1.0.tgz",
"integrity": "sha512-qe1jfm1Mg7Nq/NSh6XE24gPXROEVsWHxC1LIx//XNlD9iw7YZQGjZNjYN7xGaEG6iKdA8EtNFW6R0gjnVXp+wQ==",
"dev": true,
"requires": {
"has-flag": "^3.0.0"
}
} }
} }
}, },
@ -14752,9 +14764,9 @@
"dev": true "dev": true
}, },
"tar": { "tar": {
"version": "6.0.5", "version": "6.1.11",
"resolved": "https://registry.npmjs.org/tar/-/tar-6.0.5.tgz", "resolved": "https://registry.npmjs.org/tar/-/tar-6.1.11.tgz",
"integrity": "sha512-0b4HOimQHj9nXNEAA7zWwMM91Zhhba3pspja6sQbgTpynOJf+bkjBnfybNYzbpLbnwXnbyB4LOREvlyXLkCHSg==", "integrity": "sha512-an/KZQzQUkZCkuoAA64hM92X0Urb6VpRhAFllDzz44U2mcD5scmT3zBc4VgVpkugF580+DQn8eAFSyoQt0tznA==",
"dev": true, "dev": true,
"requires": { "requires": {
"chownr": "^2.0.0", "chownr": "^2.0.0",
@ -14919,9 +14931,9 @@
} }
}, },
"tmpl": { "tmpl": {
"version": "1.0.4", "version": "1.0.5",
"resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.4.tgz", "resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz",
"integrity": "sha1-I2QN17QtAEM5ERQIIOXPRA5SHdE=", "integrity": "sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw==",
"dev": true "dev": true
}, },
"to-arraybuffer": { "to-arraybuffer": {
@ -15390,9 +15402,9 @@
} }
}, },
"url-parse": { "url-parse": {
"version": "1.5.1", "version": "1.5.3",
"resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.1.tgz", "resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.3.tgz",
"integrity": "sha512-HOfCOUJt7iSYzEx/UqgtwKRMC6EU91NFhsCHMv9oM03VJcVo2Qrp8T8kI9D7amFf1cu+/3CEhgb3rF9zL7k85Q==", "integrity": "sha512-IIORyIQD9rvj0A4CLWsHkBBJuNqWpFQe224b6j9t/ABmquIS0qDU2pY6kl6AuOrL5OkCXHMCFNe1jBcuAggjvQ==",
"requires": { "requires": {
"querystringify": "^2.1.1", "querystringify": "^2.1.1",
"requires-port": "^1.0.0" "requires-port": "^1.0.0"
@ -16678,45 +16690,12 @@
"dev": true "dev": true
}, },
"wide-align": { "wide-align": {
"version": "1.1.3", "version": "1.1.5",
"resolved": "https://registry.npmjs.org/wide-align/-/wide-align-1.1.3.tgz", "resolved": "https://registry.npmjs.org/wide-align/-/wide-align-1.1.5.tgz",
"integrity": "sha512-QGkOQc8XL6Bt5PwnsExKBPuMKBxnGxWWW3fU55Xt4feHozMUhdUMaBCk290qpm/wG5u/RSKzwdAC4i51YigihA==", "integrity": "sha512-eDMORYaPNZ4sQIuuYPDHdQvf4gyCF9rEEV/yPxGfwPkRodwEgiMUUXTx/dex+Me0wxx53S+NgUHaP7y3MGlDmg==",
"dev": true, "dev": true,
"requires": { "requires": {
"string-width": "^1.0.2 || 2" "string-width": "^1.0.2 || 2 || 3 || 4"
},
"dependencies": {
"ansi-regex": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-3.0.0.tgz",
"integrity": "sha1-7QMXwyIGT3lGbAKWa922Bas32Zg=",
"dev": true
},
"is-fullwidth-code-point": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-2.0.0.tgz",
"integrity": "sha1-o7MKXE8ZkYMWeqq5O+764937ZU8=",
"dev": true
},
"string-width": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-2.1.1.tgz",
"integrity": "sha512-nOqH59deCq9SRHlxq1Aw85Jnt4w6KvLKqWVik6oA9ZklXLNIOlqg4F2yrT1MVaTjAqvVwdfeZ7w7aCvJD7ugkw==",
"dev": true,
"requires": {
"is-fullwidth-code-point": "^2.0.0",
"strip-ansi": "^4.0.0"
}
},
"strip-ansi": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-4.0.0.tgz",
"integrity": "sha1-qEeQIusaw2iocTibY1JixQXuNo8=",
"dev": true,
"requires": {
"ansi-regex": "^3.0.0"
}
}
} }
}, },
"wildcard": { "wildcard": {

View File

@ -46,10 +46,10 @@ export class ErrorInterceptor implements HttpInterceptor {
} }
// If we are not on no-connection, redirect there and save current url so when we refersh, we redirect back there // If we are not on no-connection, redirect there and save current url so when we refersh, we redirect back there
if (this.router.url !== '/no-connection') { // if (this.router.url !== '/no-connection') {
localStorage.setItem(this.urlKey, this.router.url); // localStorage.setItem(this.urlKey, this.router.url);
this.router.navigateByUrl('/no-connection'); // this.router.navigateByUrl('/no-connection');
} // }
break; break;
} }
return throwError(error); return throwError(error);

View File

@ -15,4 +15,5 @@ export interface Chapter {
pagesRead: number; // Attached for the given user when requesting from API pagesRead: number; // Attached for the given user when requesting from API
isSpecial: boolean; isSpecial: boolean;
title: string; title: string;
created: string;
} }

View File

@ -1,4 +1,4 @@
export interface ScanLibraryProgressEvent { export interface ProgressEvent {
libraryId: number; libraryId: number;
progress: number; progress: number;
eventTime: string; eventTime: string;

View File

@ -0,0 +1,5 @@
export interface SeriesRemovedEvent {
libraryId: number;
seriesId: number;
seriesName: string;
}

View File

@ -5,4 +5,5 @@ export interface UpdateVersionEvent {
updateTitle: string; updateTitle: string;
updateUrl: string; updateUrl: string;
isDocker: boolean; isDocker: boolean;
publishDate: string;
} }

View File

@ -1,5 +1,18 @@
export enum PageSplitOption { export enum PageSplitOption {
/**
* Renders the left side of the image then the right side
*/
SplitLeftToRight = 0, SplitLeftToRight = 0,
/**
* Renders the right side of the image then the left side
*/
SplitRightToLeft = 1, SplitRightToLeft = 1,
NoSplit = 2 /**
* Don't split and show the image in original size
*/
NoSplit = 2,
/**
* Don't split and scale the image to fit screen space
*/
FitSplit = 3
} }

View File

@ -26,5 +26,5 @@ export interface Preferences {
export const readingDirections = [{text: 'Left to Right', value: ReadingDirection.LeftToRight}, {text: 'Right to Left', value: ReadingDirection.RightToLeft}]; export const readingDirections = [{text: 'Left to Right', value: ReadingDirection.LeftToRight}, {text: 'Right to Left', value: ReadingDirection.RightToLeft}];
export const scalingOptions = [{text: 'Automatic', value: ScalingOption.Automatic}, {text: 'Fit to Height', value: ScalingOption.FitToHeight}, {text: 'Fit to Width', value: ScalingOption.FitToWidth}, {text: 'Original', value: ScalingOption.Original}]; export const scalingOptions = [{text: 'Automatic', value: ScalingOption.Automatic}, {text: 'Fit to Height', value: ScalingOption.FitToHeight}, {text: 'Fit to Width', value: ScalingOption.FitToWidth}, {text: 'Original', value: ScalingOption.Original}];
export const pageSplitOptions = [{text: 'Right to Left', value: PageSplitOption.SplitRightToLeft}, {text: 'Left to Right', value: PageSplitOption.SplitLeftToRight}, {text: 'No Split', value: PageSplitOption.NoSplit}]; export const pageSplitOptions = [{text: 'Fit to Screen', value: PageSplitOption.FitSplit}, {text: 'Right to Left', value: PageSplitOption.SplitRightToLeft}, {text: 'Left to Right', value: PageSplitOption.SplitLeftToRight}, {text: 'No Split', value: PageSplitOption.NoSplit}];
export const readingModes = [{text: 'Left to Right', value: READER_MODE.MANGA_LR}, {text: 'Up to Down', value: READER_MODE.MANGA_UD}, {text: 'Webtoon', value: READER_MODE.WEBTOON}]; export const readingModes = [{text: 'Left to Right', value: READER_MODE.MANGA_LR}, {text: 'Up to Down', value: READER_MODE.MANGA_UD}, {text: 'Webtoon', value: READER_MODE.WEBTOON}];

View File

@ -1,4 +1,5 @@
import { EventEmitter, Injectable } from '@angular/core'; import { EventEmitter, Injectable } from '@angular/core';
import { Router } from '@angular/router';
import { HubConnection, HubConnectionBuilder } from '@microsoft/signalr'; import { HubConnection, HubConnectionBuilder } from '@microsoft/signalr';
import { NgbModal, NgbModalRef } from '@ng-bootstrap/ng-bootstrap'; import { NgbModal, NgbModalRef } from '@ng-bootstrap/ng-bootstrap';
import { ToastrService } from 'ngx-toastr'; import { ToastrService } from 'ngx-toastr';
@ -6,7 +7,7 @@ import { BehaviorSubject, ReplaySubject } from 'rxjs';
import { environment } from 'src/environments/environment'; import { environment } from 'src/environments/environment';
import { UpdateNotificationModalComponent } from '../shared/update-notification/update-notification-modal.component'; import { UpdateNotificationModalComponent } from '../shared/update-notification/update-notification-modal.component';
import { RefreshMetadataEvent } from '../_models/events/refresh-metadata-event'; import { RefreshMetadataEvent } from '../_models/events/refresh-metadata-event';
import { ScanLibraryProgressEvent } from '../_models/events/scan-library-progress-event'; import { ProgressEvent } from '../_models/events/scan-library-progress-event';
import { ScanSeriesEvent } from '../_models/events/scan-series-event'; import { ScanSeriesEvent } from '../_models/events/scan-series-event';
import { SeriesAddedEvent } from '../_models/events/series-added-event'; import { SeriesAddedEvent } from '../_models/events/series-added-event';
import { User } from '../_models/user'; import { User } from '../_models/user';
@ -15,11 +16,15 @@ export enum EVENTS {
UpdateAvailable = 'UpdateAvailable', UpdateAvailable = 'UpdateAvailable',
ScanSeries = 'ScanSeries', ScanSeries = 'ScanSeries',
RefreshMetadata = 'RefreshMetadata', RefreshMetadata = 'RefreshMetadata',
RefreshMetadataProgress = 'RefreshMetadataProgress',
SeriesAdded = 'SeriesAdded', SeriesAdded = 'SeriesAdded',
SeriesRemoved = 'SeriesRemoved',
ScanLibraryProgress = 'ScanLibraryProgress', ScanLibraryProgress = 'ScanLibraryProgress',
OnlineUsers = 'OnlineUsers', OnlineUsers = 'OnlineUsers',
SeriesAddedToCollection = 'SeriesAddedToCollection', SeriesAddedToCollection = 'SeriesAddedToCollection',
ScanLibraryError = 'ScanLibraryError' ScanLibraryError = 'ScanLibraryError',
BackupDatabaseProgress = 'BackupDatabaseProgress',
CleanupProgress = 'CleanupProgress'
} }
export interface Message<T> { export interface Message<T> {
@ -42,13 +47,13 @@ export class MessageHubService {
onlineUsers$ = this.onlineUsersSource.asObservable(); onlineUsers$ = this.onlineUsersSource.asObservable();
public scanSeries: EventEmitter<ScanSeriesEvent> = new EventEmitter<ScanSeriesEvent>(); public scanSeries: EventEmitter<ScanSeriesEvent> = new EventEmitter<ScanSeriesEvent>();
public scanLibrary: EventEmitter<ScanLibraryProgressEvent> = new EventEmitter<ScanLibraryProgressEvent>(); public scanLibrary: EventEmitter<ProgressEvent> = new EventEmitter<ProgressEvent>(); // TODO: Refactor this name to be generic
public seriesAdded: EventEmitter<SeriesAddedEvent> = new EventEmitter<SeriesAddedEvent>(); public seriesAdded: EventEmitter<SeriesAddedEvent> = new EventEmitter<SeriesAddedEvent>();
public refreshMetadata: EventEmitter<RefreshMetadataEvent> = new EventEmitter<RefreshMetadataEvent>(); public refreshMetadata: EventEmitter<RefreshMetadataEvent> = new EventEmitter<RefreshMetadataEvent>();
isAdmin: boolean = false; isAdmin: boolean = false;
constructor(private modalService: NgbModal, private toastr: ToastrService) { constructor(private modalService: NgbModal, private toastr: ToastrService, private router: Router) {
} }
@ -87,6 +92,27 @@ export class MessageHubService {
this.scanLibrary.emit(resp.body); this.scanLibrary.emit(resp.body);
}); });
this.hubConnection.on(EVENTS.BackupDatabaseProgress, resp => {
this.messagesSource.next({
event: EVENTS.BackupDatabaseProgress,
payload: resp.body
});
});
this.hubConnection.on(EVENTS.CleanupProgress, resp => {
this.messagesSource.next({
event: EVENTS.CleanupProgress,
payload: resp.body
});
});
this.hubConnection.on(EVENTS.RefreshMetadataProgress, resp => {
this.messagesSource.next({
event: EVENTS.RefreshMetadataProgress,
payload: resp.body
});
});
this.hubConnection.on(EVENTS.SeriesAddedToCollection, resp => { this.hubConnection.on(EVENTS.SeriesAddedToCollection, resp => {
this.messagesSource.next({ this.messagesSource.next({
event: EVENTS.SeriesAddedToCollection, event: EVENTS.SeriesAddedToCollection,
@ -110,11 +136,19 @@ export class MessageHubService {
payload: resp.body payload: resp.body
}); });
this.seriesAdded.emit(resp.body); this.seriesAdded.emit(resp.body);
if (this.isAdmin) { // Don't show the toast when user has reader open
if (this.isAdmin && this.router.url.match(/\d+\/manga|book\/\d+/gi) !== null) {
this.toastr.info('Series ' + (resp.body as SeriesAddedEvent).seriesName + ' added'); this.toastr.info('Series ' + (resp.body as SeriesAddedEvent).seriesName + ' added');
} }
}); });
this.hubConnection.on(EVENTS.SeriesRemoved, resp => {
this.messagesSource.next({
event: EVENTS.SeriesRemoved,
payload: resp.body
});
});
this.hubConnection.on(EVENTS.RefreshMetadata, resp => { this.hubConnection.on(EVENTS.RefreshMetadata, resp => {
this.messagesSource.next({ this.messagesSource.next({
event: EVENTS.RefreshMetadata, event: EVENTS.RefreshMetadata,

View File

@ -6,7 +6,6 @@ import { environment } from 'src/environments/environment';
import { Chapter } from '../_models/chapter'; import { Chapter } from '../_models/chapter';
import { CollectionTag } from '../_models/collection-tag'; import { CollectionTag } from '../_models/collection-tag';
import { InProgressChapter } from '../_models/in-progress-chapter'; import { InProgressChapter } from '../_models/in-progress-chapter';
import { MangaFormat } from '../_models/manga-format';
import { PaginatedResult } from '../_models/pagination'; import { PaginatedResult } from '../_models/pagination';
import { Series } from '../_models/series'; import { Series } from '../_models/series';
import { SeriesFilter } from '../_models/series-filter'; import { SeriesFilter } from '../_models/series-filter';
@ -112,13 +111,13 @@ export class SeriesService {
); );
} }
getInProgress(libraryId: number = 0, pageNum?: number, itemsPerPage?: number, filter?: SeriesFilter) { getOnDeck(libraryId: number = 0, pageNum?: number, itemsPerPage?: number, filter?: SeriesFilter) {
const data = this.createSeriesFilter(filter); const data = this.createSeriesFilter(filter);
let params = new HttpParams(); let params = new HttpParams();
params = this._addPaginationIfExists(params, pageNum, itemsPerPage); params = this._addPaginationIfExists(params, pageNum, itemsPerPage);
return this.httpClient.post<Series[]>(this.baseUrl + 'series/in-progress?libraryId=' + libraryId, data, {observe: 'response', params}).pipe( return this.httpClient.post<Series[]>(this.baseUrl + 'series/on-deck?libraryId=' + libraryId, data, {observe: 'response', params}).pipe(
map(response => { map(response => {
return this._cachePaginatedResults(response, new PaginatedResult<Series[]>()); return this._cachePaginatedResults(response, new PaginatedResult<Series[]>());
})); }));

View File

@ -1,41 +0,0 @@
import { HttpClient } from "@angular/common/http";
import { Injectable } from "@angular/core";
import * as Bowser from "bowser";
import { take } from "rxjs/operators";
import { environment } from "src/environments/environment";
import { ClientInfo } from "../_models/stats/client-info";
import { DetailsVersion } from "../_models/stats/details-version";
import { NavService } from "./nav.service";
import { version } from '../../../package.json';
@Injectable({
providedIn: 'root'
})
export class StatsService {
baseUrl = environment.apiUrl;
constructor(private httpClient: HttpClient, private navService: NavService) { }
public sendClientInfo(data: ClientInfo) {
return this.httpClient.post(this.baseUrl + 'stats/client-info', data);
}
public async getInfo(): Promise<ClientInfo> {
const screenResolution = `${window.screen.width} x ${window.screen.height}`;
const browser = Bowser.getParser(window.navigator.userAgent);
const usingDarkTheme = await this.navService.darkMode$.pipe(take(1)).toPromise();
return {
os: browser.getOS() as DetailsVersion,
browser: browser.getBrowser() as DetailsVersion,
platformType: browser.getPlatformType(),
kavitaUiVersion: version,
screenResolution,
usingDarkTheme
};
}
}

View File

@ -6,17 +6,24 @@
</button> </button>
</div> </div>
<div class="modal-body"> <div class="modal-body">
<div class="list-group"> <div class="list-group" *ngIf="!isLoading">
<li class="list-group-item" *ngFor="let library of selectedLibraries; let i = index">
<div class="form-check"> <div class="form-check">
<input id="library-{{i}}" type="checkbox" attr.aria-label="Library {{library.data.name}}" class="form-check-input" <input id="selectall" type="checkbox" class="form-check-input"
[(ngModel)]="library.selected" name="library"> [ngModel]="selectAll" (change)="toggleAll()" [indeterminate]="hasSomeSelected">
<label attr.for="library-{{i}}" class="form-check-label">{{library.data.name}}</label> <label for="selectall" class="form-check-label">{{selectAll ? 'Deselect' : 'Select'}} All</label>
</div>
<ul>
<li class="list-group-item" *ngFor="let library of allLibraries; let i = index">
<div class="form-check">
<input id="library-{{i}}" type="checkbox" class="form-check-input" attr.aria-label="Library {{library.name}}"
[ngModel]="selections.isSelected(library)" (change)="handleSelection(library)">
<label attr.for="library-{{i}}" class="form-check-label">{{library.name}}</label>
</div> </div>
</li> </li>
<li class="list-group-item" *ngIf="selectedLibraries.length === 0"> <li class="list-group-item" *ngIf="allLibraries.length === 0">
There are no libraries setup yet. There are no libraries setup yet.
</li> </li>
</ul>
</div> </div>
</div> </div>
<div class="modal-footer"> <div class="modal-footer">

View File

@ -1,6 +1,7 @@
import { Component, Input, OnInit } from '@angular/core'; import { Component, Input, OnInit } from '@angular/core';
import { FormBuilder } from '@angular/forms'; import { FormBuilder } from '@angular/forms';
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'; import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap';
import { SelectionModel } from 'src/app/typeahead/typeahead.component';
import { Library } from 'src/app/_models/library'; import { Library } from 'src/app/_models/library';
import { Member } from 'src/app/_models/member'; import { Member } from 'src/app/_models/member';
import { LibraryService } from 'src/app/_services/library.service'; import { LibraryService } from 'src/app/_services/library.service';
@ -15,24 +16,21 @@ export class LibraryAccessModalComponent implements OnInit {
@Input() member: Member | undefined; @Input() member: Member | undefined;
allLibraries: Library[] = []; allLibraries: Library[] = [];
selectedLibraries: Array<{selected: boolean, data: Library}> = []; selectedLibraries: Array<{selected: boolean, data: Library}> = [];
selections!: SelectionModel<Library>;
selectAll: boolean = false;
isLoading: boolean = false;
get hasSomeSelected() {
console.log(this.selections != null && this.selections.hasSomeSelected());
return this.selections != null && this.selections.hasSomeSelected();
}
constructor(public modal: NgbActiveModal, private libraryService: LibraryService, private fb: FormBuilder) { } constructor(public modal: NgbActiveModal, private libraryService: LibraryService, private fb: FormBuilder) { }
ngOnInit(): void { ngOnInit(): void {
this.libraryService.getLibraries().subscribe(libs => { this.libraryService.getLibraries().subscribe(libs => {
this.allLibraries = libs; this.allLibraries = libs;
this.selectedLibraries = libs.map(item => { this.setupSelections();
return {selected: false, data: item};
});
if (this.member !== undefined) {
this.member.libraries.forEach(lib => {
const foundLibrary = this.selectedLibraries.filter(item => item.data.name === lib.name);
if (foundLibrary.length > 0) {
foundLibrary[0].selected = true;
}
});
}
}); });
} }
@ -45,25 +43,41 @@ export class LibraryAccessModalComponent implements OnInit {
return; return;
} }
const selectedLibraries = this.selectedLibraries.filter(item => item.selected).map(item => item.data); const selectedLibraries = this.selections.selected();
this.libraryService.updateLibrariesForMember(this.member?.username, selectedLibraries).subscribe(() => { this.libraryService.updateLibrariesForMember(this.member?.username, selectedLibraries).subscribe(() => {
this.modal.close(true); this.modal.close(true);
}); });
} }
reset() { setupSelections() {
this.selectedLibraries = this.allLibraries.map(item => { this.selections = new SelectionModel<Library>(false, this.allLibraries);
return {selected: false, data: item}; this.isLoading = false;
});
// If a member is passed in, then auto-select their libraries
if (this.member !== undefined) { if (this.member !== undefined) {
this.member.libraries.forEach(lib => { this.member.libraries.forEach(lib => {
const foundLibrary = this.selectedLibraries.filter(item => item.data.name === lib.name); this.selections.toggle(lib, true, (a, b) => a.name === b.name);
if (foundLibrary.length > 0) {
foundLibrary[0].selected = true;
}
}); });
this.selectAll = this.selections.selected().length === this.allLibraries.length;
}
}
reset() {
this.setupSelections();
}
toggleAll() {
this.selectAll = !this.selectAll;
this.allLibraries.forEach(s => this.selections.toggle(s, this.selectAll));
}
handleSelection(item: Library) {
this.selections.toggle(item);
const numberOfSelected = this.selections.selected().length;
if (numberOfSelected == 0) {
this.selectAll = false;
} else if (numberOfSelected == this.selectedLibraries.length) {
this.selectAll = true;
} }
} }

View File

@ -1,8 +1,9 @@
export interface ServerInfo { export interface ServerInfo {
os: string; os: string;
dotNetVersion: string; dotnetVersion: string;
runTimeVersion: string; runTimeVersion: string;
kavitaVersion: string; kavitaVersion: string;
buildBranch: string; NumOfCores: number;
culture: string; installId: string;
isDocker: boolean;
} }

View File

@ -1,15 +1,19 @@
<div class="changelog">
<ng-container *ngFor="let update of updates; let indx = index;"> <ng-container *ngFor="let update of updates; let indx = index;">
<div class="card w-100 mb-2" style="width: 18rem;"> <div class="card w-100 mb-2" style="width: 18rem;">
<div class="card-body"> <div class="card-body">
<h5 class="card-title">{{update.updateTitle}}&nbsp; <h4 class="card-title">{{update.updateTitle}}&nbsp;
<span class="badge badge-secondary" *ngIf="update.updateVersion === update.currentVersion">Installed</span> <span class="badge badge-secondary" *ngIf="update.updateVersion === update.currentVersion">Installed</span>
<span class="badge badge-secondary" *ngIf="update.updateVersion > update.currentVersion">Available</span> <span class="badge badge-secondary" *ngIf="update.updateVersion > update.currentVersion">Available</span>
</h5> </h4>
<h6 class="card-subtitle mb-2 text-muted">Published: {{update.publishDate | date: 'short'}}</h6>
<pre class="card-text update-body" [innerHtml]="update.updateBody | safeHtml"></pre> <pre class="card-text update-body" [innerHtml]="update.updateBody | safeHtml"></pre>
<a *ngIf="!update.isDocker" href="{{update.updateUrl}}" class="btn btn-{{indx === 0 ? 'primary' : 'secondary'}} float-right" target="_blank">Download</a> <a *ngIf="!update.isDocker" href="{{update.updateUrl}}" class="btn btn-{{indx === 0 ? 'primary' : 'secondary'}} float-right" target="_blank">Download</a>
</div> </div>
</div> </div>
</ng-container> </ng-container>
</div>
<div class="spinner-border text-secondary" *ngIf="isLoading" role="status"> <div class="spinner-border text-secondary" *ngIf="isLoading" role="status">

View File

@ -3,3 +3,19 @@
word-wrap: break-word; word-wrap: break-word;
white-space: pre-wrap; white-space: pre-wrap;
} }
::ng-deep .changelog {
h1 {
font-size: 26px;
}
p, ul {
margin-bottom: 0px;
}
}

View File

@ -4,7 +4,7 @@ import { ToastrService } from 'ngx-toastr';
import { Subject } from 'rxjs'; import { Subject } from 'rxjs';
import { take, takeUntil } from 'rxjs/operators'; import { take, takeUntil } from 'rxjs/operators';
import { ConfirmService } from 'src/app/shared/confirm.service'; import { ConfirmService } from 'src/app/shared/confirm.service';
import { ScanLibraryProgressEvent } from 'src/app/_models/events/scan-library-progress-event'; import { ProgressEvent } from 'src/app/_models/events/scan-library-progress-event';
import { Library, LibraryType } from 'src/app/_models/library'; import { Library, LibraryType } from 'src/app/_models/library';
import { LibraryService } from 'src/app/_services/library.service'; import { LibraryService } from 'src/app/_services/library.service';
import { EVENTS, MessageHubService } from 'src/app/_services/message-hub.service'; import { EVENTS, MessageHubService } from 'src/app/_services/message-hub.service';
@ -38,9 +38,9 @@ export class ManageLibraryComponent implements OnInit, OnDestroy {
// when a progress event comes in, show it on the UI next to library // when a progress event comes in, show it on the UI next to library
this.hubService.messages$.pipe(takeUntil(this.onDestroy)).subscribe((event) => { this.hubService.messages$.pipe(takeUntil(this.onDestroy)).subscribe((event) => {
if (event.event != EVENTS.ScanLibraryProgress) return; if (event.event !== EVENTS.ScanLibraryProgress) return;
const scanEvent = event.payload as ScanLibraryProgressEvent; const scanEvent = event.payload as ProgressEvent;
this.scanInProgress[scanEvent.libraryId] = {progress: scanEvent.progress !== 100}; this.scanInProgress[scanEvent.libraryId] = {progress: scanEvent.progress !== 100};
if (scanEvent.progress === 0) { if (scanEvent.progress === 0) {
this.scanInProgress[scanEvent.libraryId].timestamp = scanEvent.eventTime; this.scanInProgress[scanEvent.libraryId].timestamp = scanEvent.eventTime;
@ -55,6 +55,7 @@ export class ManageLibraryComponent implements OnInit, OnDestroy {
} }
}); });
} }
}); });
} }

View File

@ -33,8 +33,8 @@
<dt>Version</dt> <dt>Version</dt>
<dd>{{serverInfo.kavitaVersion}}</dd> <dd>{{serverInfo.kavitaVersion}}</dd>
<dt>.NET Version</dt> <dt>Install ID</dt>
<dd>{{serverInfo.dotNetVersion}}</dd> <dd>{{serverInfo.installId}}</dd>
</dl> </dl>
</div> </div>
@ -43,7 +43,7 @@
<div> <div>
<div class="row"> <div class="row">
<div class="col-4">Home page:</div> <div class="col-4">Home page:</div>
<div class="col"><a href="https://kavitareader.com" target="_blank">kavitareader.com</a></div> <div class="col"><a href="https://www.kavitareader.com" target="_blank">kavitareader.com</a></div>
</div> </div>
<div class="row"> <div class="row">
<div class="col-4">Wiki:</div> <div class="col-4">Wiki:</div>
@ -63,7 +63,6 @@
</div> </div>
<div class="row"> <div class="row">
<div class="col-4">Feature Requests:</div> <div class="col-4">Feature Requests:</div>
<div class="col"><a href="https://feathub.com/Kareadita/Kavita" target="_blank">Feathub</a><br/> <div class="col"><a href="https://feats.kavitareader.com" target="_blank">https://feats.kavitareader.com</a><br/>
<a href="https://github.com/Kareadita/Kavita/issues" target="_blank">Github issues</a></div>
</div> </div>
</div> </div>

View File

@ -7,7 +7,7 @@ import { RecentlyAddedComponent } from './recently-added/recently-added.componen
import { UserLoginComponent } from './user-login/user-login.component'; import { UserLoginComponent } from './user-login/user-login.component';
import { AuthGuard } from './_guards/auth.guard'; import { AuthGuard } from './_guards/auth.guard';
import { LibraryAccessGuard } from './_guards/library-access.guard'; import { LibraryAccessGuard } from './_guards/library-access.guard';
import { InProgressComponent } from './in-progress/in-progress.component'; import { OnDeckComponent } from './on-deck/on-deck.component';
import { DashboardComponent } from './dashboard/dashboard.component'; import { DashboardComponent } from './dashboard/dashboard.component';
// TODO: Once we modularize the components, use this and measure performance impact: https://angular.io/guide/lazy-loading-ngmodules#preloading-modules // TODO: Once we modularize the components, use this and measure performance impact: https://angular.io/guide/lazy-loading-ngmodules#preloading-modules
@ -54,7 +54,7 @@ const routes: Routes = [
children: [ children: [
{path: 'library', component: DashboardComponent}, {path: 'library', component: DashboardComponent},
{path: 'recently-added', component: RecentlyAddedComponent}, {path: 'recently-added', component: RecentlyAddedComponent},
{path: 'in-progress', component: InProgressComponent}, {path: 'on-deck', component: OnDeckComponent},
] ]
}, },
{path: 'login', component: UserLoginComponent}, {path: 'login', component: UserLoginComponent},

View File

@ -5,7 +5,6 @@ import { AccountService } from './_services/account.service';
import { LibraryService } from './_services/library.service'; import { LibraryService } from './_services/library.service';
import { MessageHubService } from './_services/message-hub.service'; import { MessageHubService } from './_services/message-hub.service';
import { NavService } from './_services/nav.service'; import { NavService } from './_services/nav.service';
import { StatsService } from './_services/stats.service';
import { filter } from 'rxjs/operators'; import { filter } from 'rxjs/operators';
import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; import { NgbModal } from '@ng-bootstrap/ng-bootstrap';
@ -17,8 +16,8 @@ import { NgbModal } from '@ng-bootstrap/ng-bootstrap';
export class AppComponent implements OnInit { export class AppComponent implements OnInit {
constructor(private accountService: AccountService, public navService: NavService, constructor(private accountService: AccountService, public navService: NavService,
private statsService: StatsService, private messageHub: MessageHubService, private messageHub: MessageHubService, private libraryService: LibraryService,
private libraryService: LibraryService, private router: Router, private ngbModal: NgbModal) { private router: Router, private ngbModal: NgbModal) {
// Close any open modals when a route change occurs // Close any open modals when a route change occurs
router.events router.events
@ -32,10 +31,6 @@ export class AppComponent implements OnInit {
ngOnInit(): void { ngOnInit(): void {
this.setCurrentUser(); this.setCurrentUser();
this.statsService.getInfo().then(data => {
this.statsService.sendClientInfo(data).subscribe(() => {/* No Operation */});
});
} }

View File

@ -1,13 +1,13 @@
import { BrowserModule, Title } from '@angular/platform-browser'; import { BrowserModule, Title } from '@angular/platform-browser';
import { APP_INITIALIZER, ErrorHandler, NgModule } from '@angular/core'; import { NgModule } from '@angular/core';
import { APP_BASE_HREF } from '@angular/common'; import { APP_BASE_HREF } from '@angular/common';
import { AppRoutingModule } from './app-routing.module'; import { AppRoutingModule } from './app-routing.module';
import { AppComponent } from './app.component'; import { AppComponent } from './app.component';
import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
import { FormsModule, ReactiveFormsModule } from '@angular/forms'; import { FormsModule, ReactiveFormsModule } from '@angular/forms';
import { HttpClient, HttpClientModule, HTTP_INTERCEPTORS } from '@angular/common/http'; import { HttpClientModule, HTTP_INTERCEPTORS } from '@angular/common/http';
import { NgbCollapseModule, NgbDropdownModule, NgbNavModule, NgbPaginationModule, NgbRatingModule } from '@ng-bootstrap/ng-bootstrap'; import { NgbCollapseModule, NgbDropdownModule, NgbNavModule, NgbPaginationModule, NgbPopoverModule, NgbRatingModule } from '@ng-bootstrap/ng-bootstrap';
import { NavHeaderComponent } from './nav-header/nav-header.component'; import { NavHeaderComponent } from './nav-header/nav-header.component';
import { JwtInterceptor } from './_interceptors/jwt.interceptor'; import { JwtInterceptor } from './_interceptors/jwt.interceptor';
import { UserLoginComponent } from './user-login/user-login.component'; import { UserLoginComponent } from './user-login/user-login.component';
@ -25,13 +25,14 @@ import { CarouselModule } from './carousel/carousel.module';
import { PersonBadgeComponent } from './person-badge/person-badge.component'; import { PersonBadgeComponent } from './person-badge/person-badge.component';
import { TypeaheadModule } from './typeahead/typeahead.module'; import { TypeaheadModule } from './typeahead/typeahead.module';
import { RecentlyAddedComponent } from './recently-added/recently-added.component'; import { RecentlyAddedComponent } from './recently-added/recently-added.component';
import { InProgressComponent } from './in-progress/in-progress.component'; import { OnDeckComponent } from './on-deck/on-deck.component';
import { DashboardComponent } from './dashboard/dashboard.component'; import { DashboardComponent } from './dashboard/dashboard.component';
import { CardsModule } from './cards/cards.module'; import { CardsModule } from './cards/cards.module';
import { CollectionsModule } from './collections/collections.module'; import { CollectionsModule } from './collections/collections.module';
import { ReadingListModule } from './reading-list/reading-list.module'; import { ReadingListModule } from './reading-list/reading-list.module';
import { SAVER, getSaver } from './shared/_providers/saver.provider'; import { SAVER, getSaver } from './shared/_providers/saver.provider';
import { ConfigData } from './_models/config-data'; import { ConfigData } from './_models/config-data';
import { NavEventsToggleComponent } from './nav-events-toggle/nav-events-toggle.component';
@NgModule({ @NgModule({
@ -46,8 +47,9 @@ import { ConfigData } from './_models/config-data';
ReviewSeriesModalComponent, ReviewSeriesModalComponent,
PersonBadgeComponent, PersonBadgeComponent,
RecentlyAddedComponent, RecentlyAddedComponent,
InProgressComponent, OnDeckComponent,
DashboardComponent, DashboardComponent,
NavEventsToggleComponent,
], ],
imports: [ imports: [
HttpClientModule, HttpClientModule,
@ -59,6 +61,7 @@ import { ConfigData } from './_models/config-data';
NgbDropdownModule, // Nav NgbDropdownModule, // Nav
AutocompleteLibModule, // Nav AutocompleteLibModule, // Nav
NgbPopoverModule, // Nav Events toggle
NgbRatingModule, // Series Detail NgbRatingModule, // Series Detail
NgbNavModule, NgbNavModule,
NgbPaginationModule, NgbPaginationModule,

View File

@ -2,7 +2,7 @@
<div class="fixed-top" #stickyTop> <div class="fixed-top" #stickyTop>
<a class="sr-only sr-only-focusable focus-visible" href="javascript:void(0);" (click)="moveFocus()">Skip to main content</a> <a class="sr-only sr-only-focusable focus-visible" href="javascript:void(0);" (click)="moveFocus()">Skip to main content</a>
<ng-container [ngTemplateOutlet]="actionBar"></ng-container> <ng-container [ngTemplateOutlet]="actionBar"></ng-container>
<app-drawer #commentDrawer="drawer" [isOpen]="drawerOpen" [style.--drawer-width]="'300px'" [options]="{topOffset: topOffset}" [style.--drawer-background-color]="backgroundColor" (drawerClosed)="closeDrawer()"> <app-drawer #commentDrawer="drawer" [isOpen]="drawerOpen" [style.--drawer-width]="'300px'" [options]="{topOffset: topOffset}" [style.--drawer-background-color]="drawerBackgroundColor" (drawerClosed)="closeDrawer()">
<div header> <div header>
<h2 style="margin-top: 0.5rem">Book Settings <h2 style="margin-top: 0.5rem">Book Settings
<button type="button" class="close" aria-label="Close" (click)="commentDrawer.close()"> <button type="button" class="close" aria-label="Close" (click)="commentDrawer.close()">
@ -100,7 +100,7 @@
</div> </div>
<div #readingSection class="reading-section" [ngStyle]="{'padding-top': topOffset + 20 + 'px'}" [@isLoading]="isLoading ? true : false" (click)="handleReaderClick($event)"> <div #readingSection class="reading-section" [ngStyle]="{'padding-top': topOffset + 20 + 'px'}" [@isLoading]="isLoading ? true : false" (click)="handleReaderClick($event)">
<div #readingHtml class="book-content" [ngStyle]="{'padding-bottom': topOffset + 20 + 'px'}" [innerHtml]="page" *ngIf="page !== undefined"></div> <div #readingHtml class="book-content" [ngStyle]="{'padding-bottom': topOffset + 20 + 'px', 'margin': '0px 0px'}" [innerHtml]="page" *ngIf="page !== undefined"></div>
<div class="left {{clickOverlayClass('left')}} no-observe" (click)="prevPage()" *ngIf="clickToPaginate"> <div class="left {{clickOverlayClass('left')}} no-observe" (click)="prevPage()" *ngIf="clickToPaginate">
</div> </div>

View File

@ -229,6 +229,10 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy {
} }
} }
get drawerBackgroundColor() {
return this.darkMode ? '#010409': '#fff';
}
constructor(private route: ActivatedRoute, private router: Router, private accountService: AccountService, constructor(private route: ActivatedRoute, private router: Router, private accountService: AccountService,
private seriesService: SeriesService, private readerService: ReaderService, private location: Location, private seriesService: SeriesService, private readerService: ReaderService, private location: Location,
private renderer: Renderer2, private navService: NavService, private toastr: ToastrService, private renderer: Renderer2, private navService: NavService, private toastr: ToastrService,
@ -887,7 +891,7 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy {
} }
getDarkModeBackgroundColor() { getDarkModeBackgroundColor() {
return this.darkMode ? '#010409' : '#fff'; return this.darkMode ? '#292929' : '#fff';
} }
setOverrideStyles() { setOverrideStyles() {

View File

@ -17,11 +17,12 @@
<div class="col"> <div class="col">
Id: {{data.id}} Id: {{data.id}}
</div> </div>
<div class="col"> <div class="col" *ngIf="series !== undefined">
Format: <span class="badge badge-secondary">{{utilityService.mangaFormat(series.format) | sentenceCase}}</span>
</div> </div>
</div> </div>
<div class="row no-gutters"> <div class="row no-gutters">
<div class="col" *ngIf="utilityService.isVolume(data)"> <div class="col" *ngIf="data.hasOwnProperty('created')">
Added: {{(data.created | date: 'short') || '-'}} Added: {{(data.created | date: 'short') || '-'}}
</div> </div>
<div class="col"> <div class="col">
@ -58,8 +59,8 @@
<div class="col"> <div class="col">
Pages: {{file.pages}} Pages: {{file.pages}}
</div> </div>
<div class="col"> <div class="col" *ngIf="data.hasOwnProperty('created')">
Format: <span class="badge badge-secondary">{{utilityService.mangaFormatToText(file.format)}}</span> Added: {{(data.created | date: 'short') || '-'}}
</div> </div>
</div> </div>
</li> </li>

View File

@ -15,6 +15,8 @@ import { UploadService } from 'src/app/_services/upload.service';
import { ChangeCoverImageModalComponent } from '../change-cover-image/change-cover-image-modal.component'; import { ChangeCoverImageModalComponent } from '../change-cover-image/change-cover-image-modal.component';
import { LibraryType } from '../../../_models/library'; import { LibraryType } from '../../../_models/library';
import { LibraryService } from '../../../_services/library.service'; import { LibraryService } from '../../../_services/library.service';
import { SeriesService } from 'src/app/_services/series.service';
import { Series } from 'src/app/_models/series';
@ -42,6 +44,7 @@ export class CardDetailsModalComponent implements OnInit {
actions: ActionItem<any>[] = []; actions: ActionItem<any>[] = [];
chapterActions: ActionItem<Chapter>[] = []; chapterActions: ActionItem<Chapter>[] = [];
libraryType: LibraryType = LibraryType.Manga; libraryType: LibraryType = LibraryType.Manga;
series: Series | undefined = undefined;
get LibraryType(): typeof LibraryType { get LibraryType(): typeof LibraryType {
return LibraryType; return LibraryType;
@ -50,7 +53,8 @@ export class CardDetailsModalComponent implements OnInit {
constructor(private modalService: NgbModal, public modal: NgbActiveModal, public utilityService: UtilityService, constructor(private modalService: NgbModal, public modal: NgbActiveModal, public utilityService: UtilityService,
public imageService: ImageService, private uploadService: UploadService, private toastr: ToastrService, public imageService: ImageService, private uploadService: UploadService, private toastr: ToastrService,
private accountService: AccountService, private actionFactoryService: ActionFactoryService, private accountService: AccountService, private actionFactoryService: ActionFactoryService,
private actionService: ActionService, private router: Router, private libraryService: LibraryService) { } private actionService: ActionService, private router: Router, private libraryService: LibraryService,
private seriesService: SeriesService) { }
ngOnInit(): void { ngOnInit(): void {
this.isChapter = this.utilityService.isChapter(this.data); this.isChapter = this.utilityService.isChapter(this.data);
@ -79,6 +83,10 @@ export class CardDetailsModalComponent implements OnInit {
this.chapters.forEach((c: Chapter) => { this.chapters.forEach((c: Chapter) => {
c.files.sort((a: MangaFile, b: MangaFile) => collator.compare(a.filePath, b.filePath)); c.files.sort((a: MangaFile, b: MangaFile) => collator.compare(a.filePath, b.filePath));
}); });
this.seriesService.getSeries(this.seriesId).subscribe(series => {
this.series = series;
})
} }
close() { close() {

View File

@ -26,7 +26,7 @@
<h6>Applies to Series</h6> <h6>Applies to Series</h6>
<div class="form-check"> <div class="form-check">
<input id="selectall" type="checkbox" class="form-check-input" <input id="selectall" type="checkbox" class="form-check-input"
[ngModel]="selectAll" (change)="toggleAll()" [indeterminate]="someSelected"> [ngModel]="selectAll" (change)="toggleAll()" [indeterminate]="hasSomeSelected">
<label for="selectall" class="form-check-label">{{selectAll ? 'Deselect' : 'Select'}} All</label> <label for="selectall" class="form-check-label">{{selectAll ? 'Deselect' : 'Select'}} All</label>
</div> </div>
<ul> <ul>

View File

@ -35,6 +35,11 @@ export class EditCollectionTagsComponent implements OnInit {
imageUrls: Array<string> = []; imageUrls: Array<string> = [];
selectedCover: string = ''; selectedCover: string = '';
get hasSomeSelected() {
return this.selections != null && this.selections.hasSomeSelected();
}
constructor(public modal: NgbActiveModal, private seriesService: SeriesService, constructor(public modal: NgbActiveModal, private seriesService: SeriesService,
private collectionService: CollectionTagService, private toastr: ToastrService, private collectionService: CollectionTagService, private toastr: ToastrService,
private confirmSerivce: ConfirmService, private libraryService: LibraryService, private confirmSerivce: ConfirmService, private libraryService: LibraryService,
@ -133,11 +138,6 @@ export class EditCollectionTagsComponent implements OnInit {
}); });
} }
get someSelected() {
const selected = this.selections.selected();
return (selected.length !== this.series.length && selected.length !== 0);
}
updateSelectedIndex(index: number) { updateSelectedIndex(index: number) {
this.collectionTagForm.patchValue({ this.collectionTagForm.patchValue({
coverImageIndex: index coverImageIndex: index

View File

@ -110,7 +110,7 @@
<div> <div>
<div class="row no-gutters"> <div class="row no-gutters">
<div class="col"> <div class="col">
Created: {{volume.created | date: 'short'}} Added: {{volume.created | date: 'short'}}
</div> </div>
<div class="col"> <div class="col">
Last Modified: {{volume.lastModified | date: 'short'}} Last Modified: {{volume.lastModified | date: 'short'}}

View File

@ -1,9 +1,8 @@
@import "../../../theme/colors"; @use "../../../theme/colors";
@import "../../../assets/themes/dark.scss";
.bulk-select { .bulk-select {
background-color: $dark-form-background-no-opacity; background-color: colors.$dark-form-background-no-opacity;
border-bottom: 2px solid $primary-color; border-bottom: 2px solid colors.$primary-color;
color: white; color: white;
} }
@ -12,5 +11,5 @@
} }
.highlight { .highlight {
color: $primary-color !important; color: colors.$primary-color !important;
} }

View File

@ -1,4 +1,4 @@
@import '../../../theme/colors'; @use '../../../theme/colors';
$image-height: 230px; $image-height: 230px;
$image-width: 160px; $image-width: 160px;
@ -14,7 +14,7 @@ $image-width: 160px;
} }
.selected { .selected {
outline: 5px solid $primary-color; outline: 5px solid colors.$primary-color;
outline-width: medium; outline-width: medium;
outline-offset: -1px; outline-offset: -1px;
} }
@ -22,7 +22,7 @@ $image-width: 160px;
ngx-file-drop ::ng-deep > div { ngx-file-drop ::ng-deep > div {
// styling for the outer drop box // styling for the outer drop box
width: 100%; width: 100%;
border: 2px solid $primary-color; border: 2px solid colors.$primary-color;
border-radius: 5px; border-radius: 5px;
height: 100px; height: 100px;
margin: auto; margin: auto;

View File

@ -11,6 +11,7 @@ import { EditCollectionTagsComponent } from 'src/app/cards/_modals/edit-collecti
import { KEY_CODES } from 'src/app/shared/_services/utility.service'; import { KEY_CODES } from 'src/app/shared/_services/utility.service';
import { CollectionTag } from 'src/app/_models/collection-tag'; import { CollectionTag } from 'src/app/_models/collection-tag';
import { SeriesAddedToCollectionEvent } from 'src/app/_models/events/series-added-to-collection-event'; import { SeriesAddedToCollectionEvent } from 'src/app/_models/events/series-added-to-collection-event';
import { SeriesRemovedEvent } from 'src/app/_models/events/series-removed-event';
import { Pagination } from 'src/app/_models/pagination'; import { Pagination } from 'src/app/_models/pagination';
import { Series } from 'src/app/_models/series'; import { Series } from 'src/app/_models/series';
import { FilterItem, mangaFormatFilters, SeriesFilter } from 'src/app/_models/series-filter'; import { FilterItem, mangaFormatFilters, SeriesFilter } from 'src/app/_models/series-filter';
@ -106,10 +107,14 @@ export class CollectionDetailComponent implements OnInit, OnDestroy {
this.collectionTagActions = this.actionFactoryService.getCollectionTagActions(this.handleCollectionActionCallback.bind(this)); this.collectionTagActions = this.actionFactoryService.getCollectionTagActions(this.handleCollectionActionCallback.bind(this));
this.messageHub.messages$.pipe(takeWhile(event => event.event === EVENTS.SeriesAddedToCollection), takeUntil(this.onDestory), debounceTime(2000)).subscribe(event => { this.messageHub.messages$.pipe(takeWhile(event => event.event === EVENTS.SeriesAddedToCollection), takeUntil(this.onDestory), debounceTime(2000)).subscribe(event => {
if (event.event == EVENTS.SeriesAddedToCollection) {
const collectionEvent = event.payload as SeriesAddedToCollectionEvent; const collectionEvent = event.payload as SeriesAddedToCollectionEvent;
if (collectionEvent.tagId === this.collectionTag.id) { if (collectionEvent.tagId === this.collectionTag.id) {
this.loadPage(); this.loadPage();
} }
} else if (event.event === EVENTS.SeriesRemoved) {
this.loadPage();
}
}); });
} }

View File

@ -5,7 +5,7 @@
<p>You haven't been granted access to any libraries.</p> <p>You haven't been granted access to any libraries.</p>
</div> </div>
<app-carousel-reel [items]="inProgress" title="In Progress" (sectionClick)="handleSectionClick($event)"> <app-carousel-reel [items]="inProgress" title="On Deck" (sectionClick)="handleSectionClick($event)">
<ng-template #carouselItem let-item let-position="idx"> <ng-template #carouselItem let-item let-position="idx">
<app-series-card [data]="item" [libraryId]="item.libraryId" (reload)="reloadInProgress($event)" (dataChanged)="reloadInProgress($event)"></app-series-card> <app-series-card [data]="item" [libraryId]="item.libraryId" (reload)="reloadInProgress($event)" (dataChanged)="reloadInProgress($event)"></app-series-card>
</ng-template> </ng-template>

View File

@ -1,19 +1,15 @@
import { Component, OnDestroy, OnInit } from '@angular/core'; import { Component, OnDestroy, OnInit } from '@angular/core';
import { Title } from '@angular/platform-browser'; import { Title } from '@angular/platform-browser';
import { Router } from '@angular/router'; import { Router } from '@angular/router';
import { NgbModal } from '@ng-bootstrap/ng-bootstrap';
import { Subject } from 'rxjs'; import { Subject } from 'rxjs';
import { take, takeUntil } from 'rxjs/operators'; import { take, takeUntil } from 'rxjs/operators';
import { EditCollectionTagsComponent } from '../cards/_modals/edit-collection-tags/edit-collection-tags.component';
import { CollectionTag } from '../_models/collection-tag';
import { SeriesAddedEvent } from '../_models/events/series-added-event'; import { SeriesAddedEvent } from '../_models/events/series-added-event';
import { SeriesRemovedEvent } from '../_models/events/series-removed-event';
import { InProgressChapter } from '../_models/in-progress-chapter'; import { InProgressChapter } from '../_models/in-progress-chapter';
import { Library } from '../_models/library'; import { Library } from '../_models/library';
import { Series } from '../_models/series'; import { Series } from '../_models/series';
import { User } from '../_models/user'; import { User } from '../_models/user';
import { AccountService } from '../_services/account.service'; import { AccountService } from '../_services/account.service';
import { Action, ActionFactoryService, ActionItem } from '../_services/action-factory.service';
import { CollectionTagService } from '../_services/collection-tag.service';
import { ImageService } from '../_services/image.service'; import { ImageService } from '../_services/image.service';
import { LibraryService } from '../_services/library.service'; import { LibraryService } from '../_services/library.service';
import { EVENTS, MessageHubService } from '../_services/message-hub.service'; import { EVENTS, MessageHubService } from '../_services/message-hub.service';
@ -44,11 +40,15 @@ export class LibraryComponent implements OnInit, OnDestroy {
private titleService: Title, public imageService: ImageService, private titleService: Title, public imageService: ImageService,
private messageHub: MessageHubService) { private messageHub: MessageHubService) {
this.messageHub.messages$.pipe(takeUntil(this.onDestroy)).subscribe(res => { this.messageHub.messages$.pipe(takeUntil(this.onDestroy)).subscribe(res => {
if (res.event == EVENTS.SeriesAdded) { if (res.event === EVENTS.SeriesAdded) {
const seriesAddedEvent = res.payload as SeriesAddedEvent; const seriesAddedEvent = res.payload as SeriesAddedEvent;
this.seriesService.getSeries(seriesAddedEvent.seriesId).subscribe(series => { this.seriesService.getSeries(seriesAddedEvent.seriesId).subscribe(series => {
this.recentlyAdded.unshift(series); this.recentlyAdded.unshift(series);
}); });
} else if (res.event === EVENTS.SeriesRemoved) {
const seriesRemovedEvent = res.payload as SeriesRemovedEvent;
this.recentlyAdded = this.recentlyAdded.filter(item => item.id != seriesRemovedEvent.seriesId);
this.inProgress = this.inProgress.filter(item => item.id != seriesRemovedEvent.seriesId);
} }
}); });
} }
@ -75,7 +75,7 @@ export class LibraryComponent implements OnInit, OnDestroy {
reloadSeries() { reloadSeries() {
this.loadRecentlyAdded(); this.loadRecentlyAdded();
this.loadInProgress(); this.loadOnDeck();
} }
reloadInProgress(series: Series | boolean) { reloadInProgress(series: Series | boolean) {
@ -88,11 +88,11 @@ export class LibraryComponent implements OnInit, OnDestroy {
return; return;
} }
this.loadInProgress(); this.loadOnDeck();
} }
loadInProgress() { loadOnDeck() {
this.seriesService.getInProgress().pipe(takeUntil(this.onDestroy)).subscribe((updatedSeries) => { this.seriesService.getOnDeck().pipe(takeUntil(this.onDestroy)).subscribe((updatedSeries) => {
this.inProgress = updatedSeries.result; this.inProgress = updatedSeries.result;
}); });
} }
@ -108,8 +108,15 @@ export class LibraryComponent implements OnInit, OnDestroy {
this.router.navigate(['collections']); this.router.navigate(['collections']);
} else if (sectionTitle.toLowerCase() === 'recently added') { } else if (sectionTitle.toLowerCase() === 'recently added') {
this.router.navigate(['recently-added']); this.router.navigate(['recently-added']);
} else if (sectionTitle.toLowerCase() === 'in progress') { } else if (sectionTitle.toLowerCase() === 'on deck') {
this.router.navigate(['in-progress']); this.router.navigate(['on-deck']);
}
}
removeFromArray(arr: Array<any>, element: any) {
const index = arr.indexOf(element);
if (index >= 0) {
arr.splice(index);
} }
} }
} }

View File

@ -26,9 +26,9 @@
</div> </div>
</div> </div>
<ng-container *ngFor="let item of webtoonImages | async; let index = index;"> <ng-container *ngFor="let item of webtoonImages | async; let index = index;">
<img src="{{item.src}}" style="display: block" class="mx-auto {{pageNum === item.page && showDebugOutline() ? 'active': ''}}" *ngIf="pageNum >= pageNum - bufferPages && pageNum <= pageNum + bufferPages" rel="nofollow" alt="image" (load)="onImageLoad($event)" id="page-{{item.page}}" [attr.page]="item.page" ondragstart="return false;" onselectstart="return false;"> <img src="{{item.src}}" style="display: block" class="mx-auto {{pageNum === item.page && showDebugOutline() ? 'active': ''}} {{areImagesWiderThanWindow ? 'full-width' : ''}}" *ngIf="pageNum >= pageNum - bufferPages && pageNum <= pageNum + bufferPages" rel="nofollow" alt="image" (load)="onImageLoad($event)" id="page-{{item.page}}" [attr.page]="item.page" ondragstart="return false;" onselectstart="return false;">
</ng-container> </ng-container>
<div *ngIf="atBottom" class="spacer bottom" role="alert" (click)="loadPrevChapter.emit()"> <div *ngIf="atBottom" class="spacer bottom" role="alert" (click)="loadNextChapter.emit()">
<div> <div>
<button class="btn btn-icon mx-auto"> <button class="btn btn-icon mx-auto">
<i class="fa fa-angle-double-down animate" aria-hidden="true"></i> <i class="fa fa-angle-double-down animate" aria-hidden="true"></i>

View File

@ -21,6 +21,11 @@
} }
} }
.full-width {
width: 100% !important;
}
@keyframes move-up-down { @keyframes move-up-down {
0%, 100% { 0%, 100% {
transform: translateY(0); transform: translateY(0);
@ -29,3 +34,16 @@
transform: translateY(-10px); transform: translateY(-10px);
} }
} }
.bookmark-effect {
animation: bookmark 1s cubic-bezier(0.165, 0.84, 0.44, 1);
}
@keyframes bookmark {
0%, 100% {
filter: opacity(1);
}
50% {
filter: opacity(0.25);
}
}

View File

@ -1,5 +1,4 @@
import { Component, EventEmitter, Input, OnChanges, OnDestroy, OnInit, Output, Renderer2, SimpleChanges } from '@angular/core'; import { Component, EventEmitter, Input, OnChanges, OnDestroy, OnInit, Output, Renderer2, SimpleChanges } from '@angular/core';
import { ToastrService } from 'ngx-toastr';
import { BehaviorSubject, fromEvent, ReplaySubject, Subject } from 'rxjs'; import { BehaviorSubject, fromEvent, ReplaySubject, Subject } from 'rxjs';
import { debounceTime, takeUntil } from 'rxjs/operators'; import { debounceTime, takeUntil } from 'rxjs/operators';
import { ReaderService } from '../../_services/reader.service'; import { ReaderService } from '../../_services/reader.service';
@ -63,6 +62,7 @@ export class InfiniteScrollerComponent implements OnInit, OnChanges, OnDestroy {
@Output() loadPrevChapter: EventEmitter<void> = new EventEmitter<void>(); @Output() loadPrevChapter: EventEmitter<void> = new EventEmitter<void>();
@Input() goToPage: ReplaySubject<number> = new ReplaySubject<number>(); @Input() goToPage: ReplaySubject<number> = new ReplaySubject<number>();
@Input() bookmarkPage: ReplaySubject<number> = new ReplaySubject<number>();
/** /**
* Stores and emits all the src urls * Stores and emits all the src urls
@ -127,12 +127,16 @@ export class InfiniteScrollerComponent implements OnInit, OnChanges, OnDestroy {
return Math.max(...Object.values(this.imagesLoaded)); return Math.max(...Object.values(this.imagesLoaded));
} }
get areImagesWiderThanWindow() {
return this.webtoonImageWidth > (window.innerWidth || document.documentElement.clientWidth);
}
private readonly onDestroy = new Subject<void>(); private readonly onDestroy = new Subject<void>();
constructor(private readerService: ReaderService, private renderer: Renderer2, private toastr: ToastrService) {} constructor(private readerService: ReaderService, private renderer: Renderer2) {}
ngOnChanges(changes: SimpleChanges): void { ngOnChanges(changes: SimpleChanges): void {
if (changes.hasOwnProperty('totalPages') && changes['totalPages'].previousValue != changes['totalPages'].currentValue) { if (changes.hasOwnProperty('totalPages') && changes['totalPages'].previousValue != changes['totalPages'].currentValue) {
@ -167,11 +171,23 @@ export class InfiniteScrollerComponent implements OnInit, OnChanges, OnDestroy {
this.setPageNum(page, true); this.setPageNum(page, true);
}); });
} }
if (this.bookmarkPage) {
this.bookmarkPage.pipe(takeUntil(this.onDestroy)).subscribe(page => {
const image = document.querySelector('img[id^="page-' + page + '"]');
if (image) {
this.renderer.addClass(image, 'bookmark-effect');
setTimeout(() => {
this.renderer.removeClass(image, 'bookmark-effect');
}, 1000);
}
});
}
} }
/** /**
* On scroll in document, calculate if the user/javascript has scrolled to the current image element (and it's visible), update that scrolling has ended completely, * On scroll in document, calculate if the user/javascript has scrolled to the current image element (and it's visible), update that scrolling has ended completely,
* and calculate the direction the scrolling is occuring. This is used for prefetching. * and calculate the direction the scrolling is occuring. This is not used for prefetching.
* @param event Scroll Event * @param event Scroll Event
*/ */
handleScrollEvent(event?: any) { handleScrollEvent(event?: any) {
@ -179,11 +195,6 @@ export class InfiniteScrollerComponent implements OnInit, OnChanges, OnDestroy {
|| document.documentElement.scrollTop || document.documentElement.scrollTop
|| document.body.scrollTop || 0); || document.body.scrollTop || 0);
if (this.isScrolling && this.currentPageElem != null && this.isElementVisible(this.currentPageElem)) {
this.debugLog('[Scroll] Image is visible from scroll, isScrolling is now false');
this.isScrolling = false;
}
if (verticalOffset > this.prevScrollPosition) { if (verticalOffset > this.prevScrollPosition) {
this.scrollingDirection = PAGING_DIRECTION.FORWARD; this.scrollingDirection = PAGING_DIRECTION.FORWARD;
} else { } else {
@ -191,6 +202,23 @@ export class InfiniteScrollerComponent implements OnInit, OnChanges, OnDestroy {
} }
this.prevScrollPosition = verticalOffset; this.prevScrollPosition = verticalOffset;
if (this.isScrolling && this.currentPageElem != null && this.isElementVisible(this.currentPageElem)) {
this.debugLog('[Scroll] Image is visible from scroll, isScrolling is now false');
this.isScrolling = false;
}
if (!this.isScrolling) {
// Use offset of the image against the scroll container to test if the most of the image is visible on the screen. We can use this
// to mark the current page and separate the prefetching code.
const midlineImages = Array.from(document.querySelectorAll('img[id^="page-"]'))
.filter(entry => this.shouldElementCountAsCurrentPage(entry));
if (midlineImages.length > 0) {
this.setPageNum(parseInt(midlineImages[0].getAttribute('page') || this.pageNum + '', 10));
}
}
// Check if we hit the last page // Check if we hit the last page
this.checkIfShouldTriggerContinuousReader(); this.checkIfShouldTriggerContinuousReader();
@ -219,12 +247,13 @@ export class InfiniteScrollerComponent implements OnInit, OnChanges, OnDestroy {
if (this.atTop && this.pageNum > 0) { if (this.atTop && this.pageNum > 0) {
this.atTop = false; this.atTop = false;
} }
// debug mode will add an extra pixel from the image border + (this.debug ? 1 : 0)
if (totalScroll === totalHeight && !this.atBottom) { if (totalScroll === totalHeight && !this.atBottom) {
this.atBottom = true; this.atBottom = true;
this.setPageNum(this.totalPages); this.setPageNum(this.totalPages);
// Scroll user back to original location // Scroll user back to original location
this.previousScrollHeightMinusTop = document.documentElement.scrollTop; this.previousScrollHeightMinusTop = this.getScrollTop();
requestAnimationFrame(() => document.documentElement.scrollTop = this.previousScrollHeightMinusTop + (SPACER_SCROLL_INTO_PX / 2)); requestAnimationFrame(() => document.documentElement.scrollTop = this.previousScrollHeightMinusTop + (SPACER_SCROLL_INTO_PX / 2));
} else if (totalScroll >= totalHeight + SPACER_SCROLL_INTO_PX && this.atBottom) { } else if (totalScroll >= totalHeight + SPACER_SCROLL_INTO_PX && this.atBottom) {
// This if statement will fire once we scroll into the spacer at all // This if statement will fire once we scroll into the spacer at all
@ -266,6 +295,28 @@ export class InfiniteScrollerComponent implements OnInit, OnChanges, OnDestroy {
); );
} }
/**
* Is any part of the element visible in the scrollport and is it above the midline trigger.
* The midline trigger does not mean it is half of the screen. It may be top 25%.
* @param elem HTML Element
* @returns If above midline
*/
shouldElementCountAsCurrentPage(elem: Element) {
if (elem === null || elem === undefined) { return false; }
var rect = elem.getBoundingClientRect();
if (rect.bottom >= 0 &&
rect.right >= 0 &&
rect.top <= (window.innerHeight || document.documentElement.clientHeight) &&
rect.left <= (window.innerWidth || document.documentElement.clientWidth)
) {
const topX = (window.innerHeight || document.documentElement.clientHeight);
return Math.abs(rect.top / topX) <= 0.25;
}
return false;
}
initWebtoonReader() { initWebtoonReader() {
this.imagesLoaded = {}; this.imagesLoaded = {};
@ -327,7 +378,7 @@ export class InfiniteScrollerComponent implements OnInit, OnChanges, OnDestroy {
this.debugLog('[Intersection] Page ' + imagePage + ' is visible: ', entry.isIntersecting); this.debugLog('[Intersection] Page ' + imagePage + ' is visible: ', entry.isIntersecting);
if (entry.isIntersecting) { if (entry.isIntersecting) {
this.debugLog('[Intersection] ! Page ' + imagePage + ' just entered screen'); this.debugLog('[Intersection] ! Page ' + imagePage + ' just entered screen');
this.setPageNum(imagePage); this.prefetchWebtoonImages(imagePage);
} }
}); });
} }
@ -350,12 +401,11 @@ export class InfiniteScrollerComponent implements OnInit, OnChanges, OnDestroy {
if (scrollToPage) { if (scrollToPage) {
const currentImage = document.querySelector('img#page-' + this.pageNum); const currentImage = document.querySelector('img#page-' + this.pageNum);
if (currentImage !== null && !this.isElementVisible(currentImage)) { if (currentImage === null) return;
this.debugLog('[GoToPage] Scrolling to page', this.pageNum); this.debugLog('[GoToPage] Scrolling to page', this.pageNum);
this.scrollToCurrentPage(); this.scrollToCurrentPage();
} }
} }
}
isScrollingForwards() { isScrollingForwards() {
return this.scrollingDirection === PAGING_DIRECTION.FORWARD; return this.scrollingDirection === PAGING_DIRECTION.FORWARD;
@ -365,6 +415,7 @@ export class InfiniteScrollerComponent implements OnInit, OnChanges, OnDestroy {
* Performs the scroll for the current page element. Updates any state variables needed. * Performs the scroll for the current page element. Updates any state variables needed.
*/ */
scrollToCurrentPage() { scrollToCurrentPage() {
this.debugLog('Scrolling to ', this.pageNum);
this.currentPageElem = document.querySelector('img#page-' + this.pageNum); this.currentPageElem = document.querySelector('img#page-' + this.pageNum);
if (!this.currentPageElem) { return; } if (!this.currentPageElem) { return; }
@ -414,19 +465,29 @@ export class InfiniteScrollerComponent implements OnInit, OnChanges, OnDestroy {
} }
} }
calculatePrefetchIndecies() { /**
* Finds the ranges of indecies to load from backend. totalPages - 1 is due to backend will automatically return last page for any page number
* above totalPages. Webtoon reader might ask for that which results in duplicate last pages.
* @param pageNum
* @returns
*/
calculatePrefetchIndecies(pageNum: number = -1) {
if (pageNum == -1) {
pageNum = this.pageNum;
}
let startingIndex = 0; let startingIndex = 0;
let endingIndex = 0; let endingIndex = 0;
if (this.isScrollingForwards()) { if (this.isScrollingForwards()) {
startingIndex = Math.min(Math.max(this.pageNum - this.bufferPages, 0), this.totalPages); startingIndex = Math.min(Math.max(pageNum - this.bufferPages, 0), this.totalPages - 1);
endingIndex = Math.min(Math.max(this.pageNum + this.bufferPages, 0), this.totalPages); endingIndex = Math.min(Math.max(pageNum + this.bufferPages, 0), this.totalPages - 1);
if (startingIndex === this.totalPages) { if (startingIndex === this.totalPages) {
return [0, 0]; return [0, 0];
} }
} else { } else {
startingIndex = Math.min(Math.max(this.pageNum - this.bufferPages, 0), this.totalPages); startingIndex = Math.min(Math.max(pageNum - this.bufferPages, 0), this.totalPages - 1);
endingIndex = Math.min(Math.max(this.pageNum + this.bufferPages, 0), this.totalPages); endingIndex = Math.min(Math.max(pageNum + this.bufferPages, 0), this.totalPages - 1);
} }
@ -443,8 +504,13 @@ export class InfiniteScrollerComponent implements OnInit, OnChanges, OnDestroy {
return [...Array(size).keys()].map(i => i + startAt); return [...Array(size).keys()].map(i => i + startAt);
} }
prefetchWebtoonImages() { prefetchWebtoonImages(pageNum: number = -1) {
const [startingIndex, endingIndex] = this.calculatePrefetchIndecies();
if (pageNum === -1) {
pageNum = this.pageNum;
}
const [startingIndex, endingIndex] = this.calculatePrefetchIndecies(pageNum);
if (startingIndex === 0 && endingIndex === 0) { return; } if (startingIndex === 0 && endingIndex === 0) { return; }
this.debugLog('\t[PREFETCH] prefetching pages: ' + startingIndex + ' to ' + endingIndex); this.debugLog('\t[PREFETCH] prefetching pages: ' + startingIndex + ' to ' + endingIndex);

View File

@ -28,7 +28,15 @@
ondragstart="return false;" onselectstart="return false;"> ondragstart="return false;" onselectstart="return false;">
</canvas> </canvas>
<div class="webtoon-images" *ngIf="readerMode === READER_MODE.WEBTOON && !isLoading"> <div class="webtoon-images" *ngIf="readerMode === READER_MODE.WEBTOON && !isLoading">
<app-infinite-scroller [pageNum]="pageNum" [bufferPages]="5" [goToPage]="goToPageEvent" (pageNumberChange)="handleWebtoonPageChange($event)" [totalPages]="maxPages - 1" [urlProvider]="getPageUrl" (loadNextChapter)="loadNextChapter()" (loadPrevChapter)="loadPrevChapter()"></app-infinite-scroller> <app-infinite-scroller [pageNum]="pageNum"
[bufferPages]="5"
[goToPage]="goToPageEvent"
(pageNumberChange)="handleWebtoonPageChange($event)"
[totalPages]="maxPages"
[urlProvider]="getPageUrl"
(loadNextChapter)="loadNextChapter()"
(loadPrevChapter)="loadPrevChapter()"
[bookmarkPage]="showBookmarkEffectEvent"></app-infinite-scroller>
</div> </div>
<ng-container *ngIf="readerMode === READER_MODE.MANGA_LR || readerMode === READER_MODE.MANGA_UD"> <ng-container *ngIf="readerMode === READER_MODE.MANGA_LR || readerMode === READER_MODE.MANGA_UD">
<div class="{{readerMode === READER_MODE.MANGA_LR ? 'right' : 'bottom'}} {{clickOverlayClass('right')}}" (click)="handlePageChange($event, 'right')"></div> <div class="{{readerMode === READER_MODE.MANGA_LR ? 'right' : 'bottom'}} {{clickOverlayClass('right')}}" (click)="handlePageChange($event, 'right')"></div>
@ -43,7 +51,7 @@
<button class="btn btn-small btn-icon col-1" [disabled]="prevChapterDisabled" (click)="loadPrevChapter();resetMenuCloseTimer();" title="Prev Chapter/Volume"><i class="fa fa-fast-backward" aria-hidden="true"></i></button> <button class="btn btn-small btn-icon col-1" [disabled]="prevChapterDisabled" (click)="loadPrevChapter();resetMenuCloseTimer();" title="Prev Chapter/Volume"><i class="fa fa-fast-backward" aria-hidden="true"></i></button>
<button class="btn btn-small btn-icon col-1" [disabled]="prevPageDisabled || pageNum === 0" (click)="goToPage(0);resetMenuCloseTimer();" title="First Page"><i class="fa fa-step-backward" aria-hidden="true"></i></button> <button class="btn btn-small btn-icon col-1" [disabled]="prevPageDisabled || pageNum === 0" (click)="goToPage(0);resetMenuCloseTimer();" title="First Page"><i class="fa fa-step-backward" aria-hidden="true"></i></button>
<div class="col custom-slider" *ngIf="pageOptions.ceil > 0; else noSlider"> <div class="col custom-slider" *ngIf="pageOptions.ceil > 0; else noSlider">
<ngx-slider [options]="pageOptions" [value]="pageNum" aria-describedby="slider-info" [manualRefresh]="refreshSlider" (userChangeEnd)="sliderPageUpdate($event);startMenuCloseTimer()" (userChangeStart)="cancelMenuCloseTimer();"></ngx-slider> <ngx-slider [options]="pageOptions" [value]="pageNum" aria-describedby="slider-info" [manualRefresh]="refreshSlider" (userChangeEnd)="sliderPageUpdate($event);startMenuCloseTimer()" (userChange)="sliderDragUpdate($event)" (userChangeStart)="cancelMenuCloseTimer();"></ngx-slider>
</div> </div>
<ng-template #noSlider> <ng-template #noSlider>
<div class="col custom-slider"> <div class="col custom-slider">
@ -94,9 +102,7 @@
<div class="col-6"> <div class="col-6">
<div class="form-group"> <div class="form-group">
<select class="form-control" id="page-splitting" formControlName="pageSplitOption"> <select class="form-control" id="page-splitting" formControlName="pageSplitOption">
<option [value]="1">Right to Left</option> <option *ngFor="let opt of pageSplitOptions" [value]="opt.value">{{opt.text}}</option>
<option [value]="0">Left to Right</option>
<option [value]="2">None</option>
</select> </select>
</div> </div>
</div> </div>

View File

@ -1,4 +1,4 @@
@import '../../theme/colors'; @use '../../theme/colors';
$center-width: 50%; $center-width: 50%;
$side-width: 25%; $side-width: 25%;
@ -178,7 +178,7 @@ canvas {
height: 2px; height: 2px;
} }
.custom-slider .ngx-slider .ngx-slider-selection { .custom-slider .ngx-slider .ngx-slider-selection {
background: $primary-color; background: colors.$primary-color;
} }
.custom-slider .ngx-slider .ngx-slider-pointer { .custom-slider .ngx-slider .ngx-slider-pointer {
@ -186,7 +186,7 @@ canvas {
height: 16px; height: 16px;
top: auto; /* to remove the default positioning */ top: auto; /* to remove the default positioning */
bottom: 0; bottom: 0;
background-color: $primary-color; // #333; background-color: colors.$primary-color; // #333;
border-top-left-radius: 3px; border-top-left-radius: 3px;
border-top-right-radius: 3px; border-top-right-radius: 3px;
} }
@ -217,12 +217,13 @@ canvas {
} }
.custom-slider .ngx-slider .ngx-slider-tick.ngx-slider-selected { .custom-slider .ngx-slider .ngx-slider-tick.ngx-slider-selected {
background: $primary-color; background: colors.$primary-color;
} }
} }
.webtoon-images { .webtoon-images {
text-align: center; text-align: center;
width: 100%;
} }
.highlight { .highlight {
@ -234,7 +235,16 @@ canvas {
animation: fadein .5s both; animation: fadein .5s both;
} }
// DEBUG
.active-image { .bookmark-effect {
border: 5px solid red; animation: bookmark 0.7s cubic-bezier(0.165, 0.84, 0.44, 1);
}
@keyframes bookmark {
0%, 100% {
border: 0px;
}
50% {
border: 5px solid colors.$primary-color;
}
} }

View File

@ -1,4 +1,4 @@
import { AfterViewInit, Component, ElementRef, EventEmitter, HostListener, OnDestroy, OnInit, SimpleChanges, ViewChild } from '@angular/core'; import { AfterViewInit, Component, ElementRef, EventEmitter, HostListener, OnDestroy, OnInit, Renderer2, SimpleChanges, ViewChild } from '@angular/core';
import { Location } from '@angular/common'; import { Location } from '@angular/common';
import { ActivatedRoute, Router } from '@angular/router'; import { ActivatedRoute, Router } from '@angular/router';
import { take, takeUntil } from 'rxjs/operators'; import { take, takeUntil } from 'rxjs/operators';
@ -12,7 +12,7 @@ import { ScalingOption } from '../_models/preferences/scaling-option';
import { PageSplitOption } from '../_models/preferences/page-split-option'; import { PageSplitOption } from '../_models/preferences/page-split-option';
import { forkJoin, ReplaySubject, Subject } from 'rxjs'; import { forkJoin, ReplaySubject, Subject } from 'rxjs';
import { ToastrService } from 'ngx-toastr'; import { ToastrService } from 'ngx-toastr';
import { KEY_CODES, UtilityService } from '../shared/_services/utility.service'; import { KEY_CODES, UtilityService, Breakpoint } from '../shared/_services/utility.service';
import { CircularArray } from '../shared/data-structures/circular-array'; import { CircularArray } from '../shared/data-structures/circular-array';
import { MemberService } from '../_services/member.service'; import { MemberService } from '../_services/member.service';
import { Stack } from '../shared/data-structures/stack'; import { Stack } from '../shared/data-structures/stack';
@ -20,7 +20,7 @@ import { ChangeContext, LabelType, Options } from '@angular-slider/ngx-slider';
import { trigger, state, style, transition, animate } from '@angular/animations'; import { trigger, state, style, transition, animate } from '@angular/animations';
import { ChapterInfo } from './_models/chapter-info'; import { ChapterInfo } from './_models/chapter-info';
import { COLOR_FILTER, FITTING_OPTION, PAGING_DIRECTION, SPLIT_PAGE_PART } from './_models/reader-enums'; import { COLOR_FILTER, FITTING_OPTION, PAGING_DIRECTION, SPLIT_PAGE_PART } from './_models/reader-enums';
import { Preferences, scalingOptions } from '../_models/preferences/preferences'; import { pageSplitOptions, scalingOptions } from '../_models/preferences/preferences';
import { READER_MODE } from '../_models/preferences/reader-mode'; import { READER_MODE } from '../_models/preferences/reader-mode';
import { MangaFormat } from '../_models/manga-format'; import { MangaFormat } from '../_models/manga-format';
import { LibraryService } from '../_services/library.service'; import { LibraryService } from '../_services/library.service';
@ -96,13 +96,15 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy {
scalingOptions = scalingOptions; scalingOptions = scalingOptions;
readingDirection = ReadingDirection.LeftToRight; readingDirection = ReadingDirection.LeftToRight;
scalingOption = ScalingOption.FitToHeight; scalingOption = ScalingOption.FitToHeight;
pageSplitOption = PageSplitOption.SplitRightToLeft; pageSplitOption = PageSplitOption.FitSplit;
currentImageSplitPart: SPLIT_PAGE_PART = SPLIT_PAGE_PART.NO_SPLIT; currentImageSplitPart: SPLIT_PAGE_PART = SPLIT_PAGE_PART.NO_SPLIT;
pagingDirection: PAGING_DIRECTION = PAGING_DIRECTION.FORWARD; pagingDirection: PAGING_DIRECTION = PAGING_DIRECTION.FORWARD;
colorMode: COLOR_FILTER = COLOR_FILTER.NONE; colorMode: COLOR_FILTER = COLOR_FILTER.NONE;
autoCloseMenu: boolean = true; autoCloseMenu: boolean = true;
readerMode: READER_MODE = READER_MODE.MANGA_LR; readerMode: READER_MODE = READER_MODE.MANGA_LR;
pageSplitOptions = pageSplitOptions;
isLoading = true; isLoading = true;
@ViewChild('content') canvas: ElementRef | undefined; @ViewChild('content') canvas: ElementRef | undefined;
@ -124,7 +126,10 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy {
* An event emiter when a page change occurs. Used soley by the webtoon reader. * An event emiter when a page change occurs. Used soley by the webtoon reader.
*/ */
goToPageEvent: ReplaySubject<number> = new ReplaySubject<number>(); goToPageEvent: ReplaySubject<number> = new ReplaySubject<number>();
/**
* An event emiter when a bookmark on a page change occurs. Used soley by the webtoon reader.
*/
showBookmarkEffectEvent: ReplaySubject<number> = new ReplaySubject<number>();
/** /**
* If the menu is open/visible. * If the menu is open/visible.
*/ */
@ -263,11 +268,16 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy {
return ReadingDirection; return ReadingDirection;
} }
get PageSplitOption(): typeof PageSplitOption {
return PageSplitOption;
}
constructor(private route: ActivatedRoute, private router: Router, private accountService: AccountService, constructor(private route: ActivatedRoute, private router: Router, private accountService: AccountService,
public readerService: ReaderService, private location: Location, public readerService: ReaderService, private location: Location,
private formBuilder: FormBuilder, private navService: NavService, private formBuilder: FormBuilder, private navService: NavService,
private toastr: ToastrService, private memberService: MemberService, private toastr: ToastrService, private memberService: MemberService,
private libraryService: LibraryService, private utilityService: UtilityService) { private libraryService: LibraryService, private utilityService: UtilityService,
private renderer: Renderer2) {
this.navService.hideNavBar(); this.navService.hideNavBar();
} }
@ -309,7 +319,7 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy {
this.generalSettingsForm = this.formBuilder.group({ this.generalSettingsForm = this.formBuilder.group({
autoCloseMenu: this.autoCloseMenu, autoCloseMenu: this.autoCloseMenu,
pageSplitOption: this.pageSplitOption + '', pageSplitOption: this.pageSplitOption,
fittingOption: this.translateScalingOption(this.scalingOption) fittingOption: this.translateScalingOption(this.scalingOption)
}); });
@ -317,8 +327,8 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy {
this.generalSettingsForm.valueChanges.pipe(takeUntil(this.onDestroy)).subscribe((changes: SimpleChanges) => { this.generalSettingsForm.valueChanges.pipe(takeUntil(this.onDestroy)).subscribe((changes: SimpleChanges) => {
this.autoCloseMenu = this.generalSettingsForm.get('autoCloseMenu')?.value; this.autoCloseMenu = this.generalSettingsForm.get('autoCloseMenu')?.value;
// On change of splitting, re-render the page if the page is already split const needsSplitting = this.isCoverImage();
const needsSplitting = this.canvasImage.width > this.canvasImage.height; // If we need to split on a menu change, then we need to re-render.
if (needsSplitting) { if (needsSplitting) {
this.loadPage(); this.loadPage();
} }
@ -354,6 +364,7 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy {
this.onDestroy.next(); this.onDestroy.next();
this.onDestroy.complete(); this.onDestroy.complete();
this.goToPageEvent.complete(); this.goToPageEvent.complete();
this.showBookmarkEffectEvent.complete();
} }
@HostListener('window:keyup', ['$event']) @HostListener('window:keyup', ['$event'])
@ -421,7 +432,7 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy {
this.maxPages = results.chapterInfo.pages; this.maxPages = results.chapterInfo.pages;
let page = results.progress.pageNum; let page = results.progress.pageNum;
if (page > this.maxPages) { if (page > this.maxPages) {
page = this.maxPages - 1; page = this.maxPages;
} }
this.setPageNum(page); this.setPageNum(page);
@ -614,15 +625,20 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy {
} }
isSplitLeftToRight() { isSplitLeftToRight() {
return (this.generalSettingsForm?.get('pageSplitOption')?.value + '') === (PageSplitOption.SplitLeftToRight + ''); return parseInt(this.generalSettingsForm?.get('pageSplitOption')?.value, 10) === PageSplitOption.SplitLeftToRight;
} }
/**
*
* @returns If the current model reflects no split of fit split
*/
isNoSplit() { isNoSplit() {
return (this.generalSettingsForm?.get('pageSplitOption')?.value + '') === (PageSplitOption.NoSplit + ''); const splitValue = parseInt(this.generalSettingsForm?.get('pageSplitOption')?.value, 10);
return splitValue === PageSplitOption.NoSplit || splitValue === PageSplitOption.FitSplit;
} }
updateSplitPage() { updateSplitPage() {
const needsSplitting = this.canvasImage.width > this.canvasImage.height; const needsSplitting = this.isCoverImage();
if (!needsSplitting || this.isNoSplit()) { if (!needsSplitting || this.isNoSplit()) {
this.currentImageSplitPart = SPLIT_PAGE_PART.NO_SPLIT; this.currentImageSplitPart = SPLIT_PAGE_PART.NO_SPLIT;
return; return;
@ -734,6 +750,7 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy {
loadNextChapter() { loadNextChapter() {
if (this.nextPageDisabled) { return; } if (this.nextPageDisabled) { return; }
if (this.nextChapterDisabled) { return; }
this.isLoading = true; this.isLoading = true;
if (this.nextChapterId === CHAPTER_ID_NOT_FETCHED || this.nextChapterId === this.chapterId) { if (this.nextChapterId === CHAPTER_ID_NOT_FETCHED || this.nextChapterId === this.chapterId) {
this.readerService.getNextChapter(this.seriesId, this.volumeId, this.chapterId, this.readingListId).pipe(take(1)).subscribe(chapterId => { this.readerService.getNextChapter(this.seriesId, this.volumeId, this.chapterId, this.readingListId).pipe(take(1)).subscribe(chapterId => {
@ -747,6 +764,7 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy {
loadPrevChapter() { loadPrevChapter() {
if (this.prevPageDisabled) { return; } if (this.prevPageDisabled) { return; }
if (this.prevChapterDisabled) { return; }
this.isLoading = true; this.isLoading = true;
this.continuousChaptersStack.pop(); this.continuousChaptersStack.pop();
const prevChapter = this.continuousChaptersStack.peek(); const prevChapter = this.continuousChaptersStack.peek();
@ -814,21 +832,23 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy {
if (needsScaling) { if (needsScaling) {
this.canvas.nativeElement.width = isSafari ? 4_096 : 16_384; this.canvas.nativeElement.width = isSafari ? 4_096 : 16_384;
this.canvas.nativeElement.height = isSafari ? 4_096 : 16_384; this.canvas.nativeElement.height = isSafari ? 4_096 : 16_384;
} else if (this.isCoverImage()) {
//this.canvas.nativeElement.width = this.canvasImage.width / 2;
//this.canvas.nativeElement.height = this.canvasImage.height;
} else { } else {
this.canvas.nativeElement.width = this.canvasImage.width; this.canvas.nativeElement.width = this.canvasImage.width;
this.canvas.nativeElement.height = this.canvasImage.height; this.canvas.nativeElement.height = this.canvasImage.height;
} }
} }
return true;
} }
renderPage() { renderPage() {
if (this.ctx && this.canvas) { if (this.ctx && this.canvas) {
this.canvasImage.onload = null; this.canvasImage.onload = null;
if (!this.setCanvasSize()) return; this.setCanvasSize();
const needsSplitting = this.canvasImage.width > this.canvasImage.height; const needsSplitting = this.isCoverImage();
this.updateSplitPage(); this.updateSplitPage();
if (needsSplitting && this.currentImageSplitPart === SPLIT_PAGE_PART.LEFT_PART) { if (needsSplitting && this.currentImageSplitPart === SPLIT_PAGE_PART.LEFT_PART) {
@ -839,8 +859,49 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy {
this.ctx.drawImage(this.canvasImage, 0, 0, this.canvasImage.width, this.canvasImage.height, -this.canvasImage.width / 2, 0, this.canvasImage.width, this.canvasImage.height); this.ctx.drawImage(this.canvasImage, 0, 0, this.canvasImage.width, this.canvasImage.height, -this.canvasImage.width / 2, 0, this.canvasImage.width, this.canvasImage.height);
} else { } else {
if (!this.firstPageRendered && this.scalingOption === ScalingOption.Automatic) { if (!this.firstPageRendered && this.scalingOption === ScalingOption.Automatic) {
this.updateScalingForFirstPageRender();
}
let newScale = this.generalSettingsForm.get('fittingOption')?.value; // Fit Split on a page that needs splitting
if (this.shouldRenderAsFitSplit()) {
const windowWidth = window.innerWidth
|| document.documentElement.clientWidth
|| document.body.clientWidth;
const windowHeight = window.innerHeight
|| document.documentElement.clientHeight
|| document.body.clientHeight;
// If the user's screen is wider than the image, just pretend this is no split, as it will render nicer
this.canvas.nativeElement.width = windowWidth;
this.canvas.nativeElement.height = windowHeight;
const ratio = this.canvasImage.width / this.canvasImage.height;
let newWidth = windowWidth;
let newHeight = newWidth / ratio;
if (newHeight > windowHeight) {
newHeight = windowHeight;
newWidth = newHeight * ratio;
}
// Optimization: When the screen is larger than newWidth, allow no split rendering to occur for a better fit
if (windowWidth > newWidth) {
this.ctx.drawImage(this.canvasImage, 0, 0);
} else {
this.ctx.drawImage(this.canvasImage, 0, 0, newWidth, newHeight);
}
} else {
this.ctx.drawImage(this.canvasImage, 0, 0);
}
}
// Reset scroll on non HEIGHT Fits
if (this.getFit() !== FITTING_OPTION.HEIGHT) {
window.scrollTo(0, 0);
}
}
this.isLoading = false;
}
updateScalingForFirstPageRender() {
const windowWidth = window.innerWidth const windowWidth = window.innerWidth
|| document.documentElement.clientWidth || document.documentElement.clientWidth
|| document.body.clientWidth; || document.body.clientWidth;
@ -848,8 +909,10 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|| document.documentElement.clientHeight || document.documentElement.clientHeight
|| document.body.clientHeight; || document.body.clientHeight;
const widthRatio = windowWidth / this.canvasImage.width; const needsSplitting = this.isCoverImage();
const heightRatio = windowHeight / this.canvasImage.height; let newScale = this.generalSettingsForm.get('fittingOption')?.value;
const widthRatio = windowWidth / (this.canvasImage.width / (needsSplitting ? 2 : 1));
const heightRatio = windowHeight / (this.canvasImage.height);
// Given that we now have image dimensions, assuming this isn't a split image, // Given that we now have image dimensions, assuming this isn't a split image,
// Try to reset one time based on who's dimension (width/height) is smaller // Try to reset one time based on who's dimension (width/height) is smaller
@ -859,18 +922,18 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy {
newScale = FITTING_OPTION.HEIGHT; newScale = FITTING_OPTION.HEIGHT;
} }
this.generalSettingsForm.get('fittingOption')?.setValue(newScale);
this.firstPageRendered = true; this.firstPageRendered = true;
} this.generalSettingsForm.get('fittingOption')?.setValue(newScale, {emitEvent: false});
this.ctx.drawImage(this.canvasImage, 0, 0);
}
// Reset scroll on non HEIGHT Fits
if (this.getFit() !== FITTING_OPTION.HEIGHT) {
window.scrollTo(0, 0);
} }
isCoverImage() {
return this.canvasImage.width > this.canvasImage.height;
} }
this.isLoading = false;
shouldRenderAsFitSplit() {
if (!this.isCoverImage() || parseInt(this.generalSettingsForm?.get('pageSplitOption')?.value, 10) !== PageSplitOption.FitSplit) return false;
return true;
} }
@ -895,16 +958,6 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy {
loadPage() { loadPage() {
if (!this.canvas || !this.ctx) { return; } if (!this.canvas || !this.ctx) { return; }
// Due to the fact that we start at image 0, but page 1, we need the last page to have progress as page + 1 to be completed
let pageNum = this.pageNum;
if (this.pageNum == this.maxPages - 1) {
pageNum = this.pageNum + 1;
}
if (!this.incognitoMode) {
this.readerService.saveProgress(this.seriesId, this.volumeId, this.chapterId, pageNum).pipe(take(1)).subscribe(() => {/* No operation */});
}
this.isLoading = true; this.isLoading = true;
this.canvasImage = this.cachedImages.current(); this.canvasImage = this.cachedImages.current();
if (this.readerService.imageUrlToPageNum(this.canvasImage.src) !== this.pageNum || this.canvasImage.src === '' || !this.canvasImage.complete) { if (this.readerService.imageUrlToPageNum(this.canvasImage.src) !== this.pageNum || this.canvasImage.src === '' || !this.canvasImage.complete) {
@ -942,6 +995,14 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy {
return side === 'right' ? 'highlight-2' : 'highlight'; return side === 'right' ? 'highlight-2' : 'highlight';
} }
sliderDragUpdate(context: ChangeContext) {
// This will update the value for value except when in webtoon due to how the webtoon reader
// responds to page changes
if (this.readerMode !== READER_MODE.WEBTOON) {
this.setPageNum(context.value);
}
}
sliderPageUpdate(context: ChangeContext) { sliderPageUpdate(context: ChangeContext) {
const page = context.value; const page = context.value;
@ -974,6 +1035,16 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy {
}); });
} }
} }
// Due to the fact that we start at image 0, but page 1, we need the last page to have progress as page + 1 to be completed
let tempPageNum = this.pageNum;
if (this.pageNum == this.maxPages - 1) {
tempPageNum = this.pageNum + 1;
}
if (!this.incognitoMode) {
this.readerService.saveProgress(this.seriesId, this.volumeId, this.chapterId, tempPageNum).pipe(take(1)).subscribe(() => {/* No operation */});
}
} }
goToPage(pageNum: number) { goToPage(pageNum: number) {
@ -1053,54 +1124,12 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy {
handleWebtoonPageChange(updatedPageNum: number) { handleWebtoonPageChange(updatedPageNum: number) {
this.setPageNum(updatedPageNum); this.setPageNum(updatedPageNum);
if (this.incognitoMode) return;
this.readerService.saveProgress(this.seriesId, this.volumeId, this.chapterId, this.pageNum).pipe(take(1)).subscribe(() => {/* No operation */});
}
saveSettings() {
// NOTE: This is not called anywhere
if (this.user === undefined) return;
const data: Preferences = {
readingDirection: this.readingDirection,
scalingOption: this.scalingOption,
pageSplitOption: this.pageSplitOption,
autoCloseMenu: this.autoCloseMenu,
readerMode: this.readerMode,
bookReaderDarkMode: this.user.preferences.bookReaderDarkMode,
bookReaderFontFamily: this.user.preferences.bookReaderFontFamily,
bookReaderFontSize: this.user.preferences.bookReaderFontSize,
bookReaderLineSpacing: this.user.preferences.bookReaderLineSpacing,
bookReaderMargin: this.user.preferences.bookReaderMargin,
bookReaderTapToPaginate: this.user.preferences.bookReaderTapToPaginate,
bookReaderReadingDirection: this.readingDirection,
siteDarkMode: this.user.preferences.siteDarkMode,
};
this.accountService.updatePreferences(data).pipe(take(1)).subscribe((updatedPrefs) => {
this.toastr.success('User settings updated');
if (this.user) {
this.user.preferences = updatedPrefs;
}
this.resetSettings();
});
}
resetSettings() {
this.generalSettingsForm.get('fittingOption')?.value.get('fittingOption')?.setValue(this.translateScalingOption(this.user.preferences.scalingOption));
this.generalSettingsForm.get('pageSplitOption')?.setValue(this.user.preferences.pageSplitOption + '');
this.generalSettingsForm.get('autoCloseMenu')?.setValue(this.autoCloseMenu);
this.updateForm();
} }
/** /**
* Bookmarks the current page for the chapter * Bookmarks the current page for the chapter
*/ */
bookmarkPage() { bookmarkPage() {
// TODO: Show some sort of UI visual to show that a page was bookmarked
const pageNum = this.pageNum; const pageNum = this.pageNum;
if (this.pageBookmarked) { if (this.pageBookmarked) {
this.readerService.unbookmark(this.seriesId, this.volumeId, this.chapterId, pageNum).pipe(take(1)).subscribe(() => { this.readerService.unbookmark(this.seriesId, this.volumeId, this.chapterId, pageNum).pipe(take(1)).subscribe(() => {
@ -1112,6 +1141,16 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy {
}); });
} }
// Show an effect on the image to show that it was bookmarked
this.showBookmarkEffectEvent.next(pageNum);
if (this.readerMode != READER_MODE.WEBTOON) {
if (this.canvas) {
this.renderer.addClass(this.canvas?.nativeElement, 'bookmark-effect');
setTimeout(() => {
this.renderer.removeClass(this.canvas?.nativeElement, 'bookmark-effect');
}, 1000);
}
}
} }
/** /**

View File

@ -0,0 +1,22 @@
<ng-container>
<button type="button" class="btn btn-icon {{progressEventsSource.getValue().length > 0 ? 'colored' : ''}}"
[ngbPopover]="popContent" title="Activity" placement="bottom" [popoverClass]="'nav-events'">
<i aria-hidden="true" class="fa fa-wave-square"></i>
</button>
<ng-template #popContent>
<ul class="list-group list-group-flush dark-menu">
<li class="list-group-item dark-menu-item" *ngFor="let event of progressEvents$ | async">
<div class="spinner-border text-primary small-spinner"
role="status" title="Started at {{event.timestamp | date: 'short'}}"
attr.aria-valuetext="{{prettyPrintProgress(event.progress)}}%" [attr.aria-valuenow]="prettyPrintProgress(event.progress)">
<span class="sr-only">Scan for {{event.libraryName}} in progress</span>
</div>
{{prettyPrintProgress(event.progress)}}%
{{prettyPrintEvent(event.eventType)}} {{event.libraryName}}
</li>
<li class="list-group-item dark-menu-item" *ngIf="progressEventsSource.getValue().length === 0">Not much going on here</li>
</ul>
</ng-template>
</ng-container>

View File

@ -0,0 +1,23 @@
@import "../../theme/colors";
.small-spinner {
width: 1rem;
height: 1rem;
}
.nav-events {
background-color: white;
}
.nav-events .popover-body {
padding: 0px;
}
.btn-icon {
color: white;
}
.colored {
background-color: $primary-color;
border-radius: 60px;
}

View File

@ -0,0 +1,90 @@
import { Component, Input, OnDestroy, OnInit } from '@angular/core';
import { BehaviorSubject, Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators';
import { ProgressEvent } from '../_models/events/scan-library-progress-event';
import { User } from '../_models/user';
import { LibraryService } from '../_services/library.service';
import { EVENTS, Message, MessageHubService } from '../_services/message-hub.service';
interface ProcessedEvent {
eventType: string;
timestamp?: string;
progress: number;
libraryId: number;
libraryName: string;
}
type ProgressType = EVENTS.ScanLibraryProgress | EVENTS.RefreshMetadataProgress | EVENTS.BackupDatabaseProgress | EVENTS.CleanupProgress;
@Component({
selector: 'app-nav-events-toggle',
templateUrl: './nav-events-toggle.component.html',
styleUrls: ['./nav-events-toggle.component.scss']
})
export class NavEventsToggleComponent implements OnInit, OnDestroy {
@Input() user!: User;
private readonly onDestroy = new Subject<void>();
/**
* Events that come through and are merged (ie progress event gets merged into a progress event)
*/
progressEventsSource = new BehaviorSubject<ProcessedEvent[]>([]);
progressEvents$ = this.progressEventsSource.asObservable();
constructor(private messageHub: MessageHubService, private libraryService: LibraryService) { }
ngOnDestroy(): void {
this.onDestroy.next();
this.onDestroy.complete();
this.progressEventsSource.complete();
}
ngOnInit(): void {
this.messageHub.messages$.pipe(takeUntil(this.onDestroy)).subscribe(event => {
if (event.event === EVENTS.ScanLibraryProgress || event.event === EVENTS.RefreshMetadataProgress || event.event === EVENTS.BackupDatabaseProgress || event.event === EVENTS.CleanupProgress) {
this.processProgressEvent(event, event.event);
}
});
}
processProgressEvent(event: Message<ProgressEvent>, eventType: string) {
const scanEvent = event.payload as ProgressEvent;
console.log(event.event, event.payload);
this.libraryService.getLibraryNames().subscribe(names => {
const data = this.progressEventsSource.getValue();
const index = data.findIndex(item => item.eventType === eventType && item.libraryId === event.payload.libraryId);
if (index >= 0) {
data.splice(index, 1);
}
if (scanEvent.progress !== 1) {
const libraryName = names[scanEvent.libraryId] || '';
const newEvent = {eventType: eventType, timestamp: scanEvent.eventTime, progress: scanEvent.progress, libraryId: scanEvent.libraryId, libraryName};
data.push(newEvent);
}
this.progressEventsSource.next(data);
});
}
prettyPrintProgress(progress: number) {
return Math.trunc(progress * 100);
}
prettyPrintEvent(eventType: string) {
switch(eventType) {
case (EVENTS.ScanLibraryProgress): return 'Scanning ';
case (EVENTS.RefreshMetadataProgress): return 'Refreshing ';
case (EVENTS.CleanupProgress): return 'Clearing Cache';
case (EVENTS.BackupDatabaseProgress): return 'Backing up Database';
default: return eventType;
}
}
}

View File

@ -62,6 +62,10 @@
</button> </button>
</div> </div>
<div class="nav-item" *ngIf="(accountService.currentUser$ | async) as user">
<app-nav-events-toggle [user]="user"></app-nav-events-toggle>
</div>
<div ngbDropdown class="nav-item dropdown" display="dynamic" placement="bottom-right" *ngIf="(accountService.currentUser$ | async) as user" dropdown> <div ngbDropdown class="nav-item dropdown" display="dynamic" placement="bottom-right" *ngIf="(accountService.currentUser$ | async) as user" dropdown>
<button class="btn btn-outline-secondary primary-text" ngbDropdownToggle> <button class="btn btn-outline-secondary primary-text" ngbDropdownToggle>
{{user.username | sentenceCase}} {{user.username | sentenceCase}}

View File

@ -1,4 +1,4 @@
@import '~bootstrap/scss/mixins/_breakpoints.scss'; @import '~bootstrap/scss/mixins/_breakpoints.scss'; // TODO: Use @forwards for this?
$primary-color: white; $primary-color: white;
$bg-color: rgb(22, 27, 34); $bg-color: rgb(22, 27, 34);

View File

@ -11,6 +11,7 @@ export class NotConnectedComponent implements OnInit {
constructor(private memberService: MemberService, private router: Router) { } constructor(private memberService: MemberService, private router: Router) { }
ngOnInit(): void { ngOnInit(): void {
// BUG: TODO: This causes an infinite reload loop on the UI when the API on backend doesn't exist
// We make a call to backend on refresh so that if it's up, we can redirect to /home // We make a call to backend on refresh so that if it's up, we can redirect to /home
this.memberService.adminExists().subscribe((exists) => { this.memberService.adminExists().subscribe((exists) => {
const pageResume = localStorage.getItem('kavita--no-connection-url'); const pageResume = localStorage.getItem('kavita--no-connection-url');

View File

@ -1,5 +1,5 @@
<app-bulk-operations [actionCallback]="bulkActionCallback"></app-bulk-operations> <app-bulk-operations [actionCallback]="bulkActionCallback"></app-bulk-operations>
<app-card-detail-layout header="In Progress" <app-card-detail-layout header="On Deck"
[isLoading]="isLoading" [isLoading]="isLoading"
[items]="series" [items]="series"
[filters]="filters" [filters]="filters"

View File

@ -13,11 +13,11 @@ import { ActionService } from '../_services/action.service';
import { SeriesService } from '../_services/series.service'; import { SeriesService } from '../_services/series.service';
@Component({ @Component({
selector: 'app-in-progress', selector: 'app-on-deck',
templateUrl: './in-progress.component.html', templateUrl: './on-deck.component.html',
styleUrls: ['./in-progress.component.scss'] styleUrls: ['./on-deck.component.scss']
}) })
export class InProgressComponent implements OnInit { export class OnDeckComponent implements OnInit {
isLoading: boolean = true; isLoading: boolean = true;
series: Series[] = []; series: Series[] = [];
@ -31,7 +31,7 @@ export class InProgressComponent implements OnInit {
constructor(private router: Router, private route: ActivatedRoute, private seriesService: SeriesService, private titleService: Title, constructor(private router: Router, private route: ActivatedRoute, private seriesService: SeriesService, private titleService: Title,
private actionService: ActionService, public bulkSelectionService: BulkSelectionService) { private actionService: ActionService, public bulkSelectionService: BulkSelectionService) {
this.router.routeReuseStrategy.shouldReuseRoute = () => false; this.router.routeReuseStrategy.shouldReuseRoute = () => false;
this.titleService.setTitle('Kavita - In Progress'); this.titleService.setTitle('Kavita - On Deck');
if (this.pagination === undefined || this.pagination === null) { if (this.pagination === undefined || this.pagination === null) {
this.pagination = {currentPage: 0, itemsPerPage: 30, totalItems: 0, totalPages: 1}; this.pagination = {currentPage: 0, itemsPerPage: 30, totalItems: 0, totalPages: 1};
} }
@ -79,7 +79,7 @@ export class InProgressComponent implements OnInit {
this.pagination.currentPage = parseInt(page, 10); this.pagination.currentPage = parseInt(page, 10);
} }
this.isLoading = true; this.isLoading = true;
this.seriesService.getInProgress(this.libraryId, this.pagination?.currentPage, this.pagination?.itemsPerPage, this.filter).pipe(take(1)).subscribe(series => { this.seriesService.getOnDeck(this.libraryId, this.pagination?.currentPage, this.pagination?.itemsPerPage, this.filter).pipe(take(1)).subscribe(series => {
this.series = series.result; this.series = series.result;
this.pagination = series.pagination; this.pagination = series.pagination;
this.isLoading = false; this.isLoading = false;

View File

@ -136,7 +136,12 @@ export class ReadingListDetailComponent implements OnInit {
return 'Volume ' + this.utilityService.cleanSpecialTitle(item.chapterNumber); return 'Volume ' + this.utilityService.cleanSpecialTitle(item.chapterNumber);
} }
return this.utilityService.formatChapterName(this.libraryTypes[item.libraryId], true, true) + item.chapterNumber; let chapterNum = item.chapterNumber;
if (!item.chapterNumber.match(/^\d+$/)) {
chapterNum = this.utilityService.cleanSpecialTitle(item.chapterNumber);
}
return this.utilityService.formatChapterName(this.libraryTypes[item.libraryId], true, true) + chapterNum;
} }
orderUpdated(event: IndexUpdateEvent) { orderUpdated(event: IndexUpdateEvent) {

View File

@ -10,13 +10,13 @@ import { SeriesAddedEvent } from '../_models/events/series-added-event';
import { Pagination } from '../_models/pagination'; import { Pagination } from '../_models/pagination';
import { Series } from '../_models/series'; import { Series } from '../_models/series';
import { FilterItem, mangaFormatFilters, SeriesFilter } from '../_models/series-filter'; import { FilterItem, mangaFormatFilters, SeriesFilter } from '../_models/series-filter';
import { Action, ActionFactoryService } from '../_services/action-factory.service'; import { Action } from '../_services/action-factory.service';
import { ActionService } from '../_services/action.service'; import { ActionService } from '../_services/action.service';
import { MessageHubService } from '../_services/message-hub.service'; import { MessageHubService } from '../_services/message-hub.service';
import { SeriesService } from '../_services/series.service'; import { SeriesService } from '../_services/series.service';
/** /**
* This component is used as a standard layout for any card detail. ie) series, in-progress, collections, etc. * This component is used as a standard layout for any card detail. ie) series, on-deck, collections, etc.
*/ */
@Component({ @Component({
selector: 'app-recently-added', selector: 'app-recently-added',

View File

@ -14,4 +14,5 @@
input { input {
background-color: #fff !important; background-color: #fff !important;
color: black !important;
} }

View File

@ -16,6 +16,7 @@ import { KEY_CODES, UtilityService } from '../shared/_services/utility.service';
import { ReviewSeriesModalComponent } from '../_modals/review-series-modal/review-series-modal.component'; import { ReviewSeriesModalComponent } from '../_modals/review-series-modal/review-series-modal.component';
import { Chapter } from '../_models/chapter'; import { Chapter } from '../_models/chapter';
import { ScanSeriesEvent } from '../_models/events/scan-series-event'; import { ScanSeriesEvent } from '../_models/events/scan-series-event';
import { SeriesRemovedEvent } from '../_models/events/series-removed-event';
import { LibraryType } from '../_models/library'; import { LibraryType } from '../_models/library';
import { MangaFormat } from '../_models/manga-format'; import { MangaFormat } from '../_models/manga-format';
import { Series } from '../_models/series'; import { Series } from '../_models/series';
@ -26,7 +27,7 @@ import { ActionItem, ActionFactoryService, Action } from '../_services/action-fa
import { ActionService } from '../_services/action.service'; import { ActionService } from '../_services/action.service';
import { ImageService } from '../_services/image.service'; import { ImageService } from '../_services/image.service';
import { LibraryService } from '../_services/library.service'; import { LibraryService } from '../_services/library.service';
import { MessageHubService } from '../_services/message-hub.service'; import { EVENTS, MessageHubService } from '../_services/message-hub.service';
import { ReaderService } from '../_services/reader.service'; import { ReaderService } from '../_services/reader.service';
import { SeriesService } from '../_services/series.service'; import { SeriesService } from '../_services/series.service';
@ -180,6 +181,16 @@ export class SeriesDetailComponent implements OnInit, OnDestroy {
this.toastr.success('Scan series completed'); this.toastr.success('Scan series completed');
}); });
this.messageHub.messages$.pipe(takeUntil(this.onDestroy)).subscribe(event => {
if (event.event === EVENTS.SeriesRemoved) {
const seriesRemovedEvent = event.payload as SeriesRemovedEvent;
if (seriesRemovedEvent.seriesId === this.series.id) {
this.toastr.info('This series no longer exists');
this.router.navigateByUrl('/libraries');
}
}
});
const seriesId = parseInt(routeId, 10); const seriesId = parseInt(routeId, 10);
this.libraryId = parseInt(libraryId, 10); this.libraryId = parseInt(libraryId, 10);
this.seriesImage = this.imageService.getSeriesCoverImage(seriesId); this.seriesImage = this.imageService.getSeriesCoverImage(seriesId);

View File

@ -158,5 +158,4 @@ export class UtilityService {
rect.right <= (window.innerWidth || document.documentElement.clientWidth) rect.right <= (window.innerWidth || document.documentElement.clientWidth)
); );
} }
} }

View File

@ -1,5 +1,3 @@
@import '../../../theme/colors';
$bg-color: #c9c9c9; $bg-color: #c9c9c9;
$bdr-color: #f2f2f2; $bdr-color: #f2f2f2;

View File

@ -1,4 +1,4 @@
@import '../../assets/themes/dark.scss'; @import "../../theme/colors";
input { input {
width: 15px; width: 15px;

View File

@ -5,6 +5,8 @@ import { debounceTime, filter, map, shareReplay, switchMap, take, takeUntil, tap
import { KEY_CODES } from '../shared/_services/utility.service'; import { KEY_CODES } from '../shared/_services/utility.service';
import { TypeaheadSettings } from './typeahead-settings'; import { TypeaheadSettings } from './typeahead-settings';
export type SelectionCompareFn<T> = (a: T, b: T) => boolean;
/** /**
* SelectionModel<T> is used for keeping track of multiple selections. Simple interface with ability to toggle. * SelectionModel<T> is used for keeping track of multiple selections. Simple interface with ability to toggle.
* @param selectedState Optional state to set selectedOptions to. If not passed, defaults to false. * @param selectedState Optional state to set selectedOptions to. If not passed, defaults to false.
@ -30,10 +32,16 @@ export class SelectionModel<T> {
/** /**
* Will toggle if the data item is selected or not. If data option is not tracked, will add it and set state to true. * Will toggle if the data item is selected or not. If data option is not tracked, will add it and set state to true.
* @param data Item to toggle * @param data Item to toggle
* @param selectedState Force the state
* @param compareFn An optional function to use for the lookup, else will use shallowEqual implementation
*/ */
toggle(data: T, selectedState?: boolean) { toggle(data: T, selectedState?: boolean, compareFn?: SelectionCompareFn<T>) {
//const dataItem = this._data.filter(d => d.value == data); let lookupMethod = this.shallowEqual;
const dataItem = this._data.filter(d => this.shallowEqual(d.value, data)); if (compareFn != undefined || compareFn != null) {
lookupMethod = compareFn;
}
const dataItem = this._data.filter(d => lookupMethod(d.value, data));
if (dataItem.length > 0) { if (dataItem.length > 0) {
if (selectedState != undefined) { if (selectedState != undefined) {
dataItem[0].selected = selectedState; dataItem[0].selected = selectedState;
@ -45,6 +53,7 @@ export class SelectionModel<T> {
} }
} }
/** /**
* Is the passed item selected * Is the passed item selected
* @param data item to check against * @param data item to check against
@ -65,6 +74,15 @@ export class SelectionModel<T> {
return false; return false;
} }
/**
*
* @returns If some of the items are selected, but not all
*/
hasSomeSelected(): boolean {
const selectedCount = this._data.filter(d => d.selected).length;
return (selectedCount !== this._data.length && selectedCount !== 0)
}
/** /**
* *
* @returns All Selected items * @returns All Selected items

View File

@ -1,4 +1,4 @@
@import "../../theme/_colors.scss"; @use "../../theme/colors";
.login { .login {
display: flex; display: flex;
@ -36,7 +36,7 @@
} }
.card { .card {
background-color: $primary-color; background-color: colors.$primary-color;
color: #fff; color: #fff;
cursor: pointer; cursor: pointer;
min-width: 300px; min-width: 300px;

Some files were not shown because too many files have changed in this diff Show More