mirror of
https://github.com/Kareadita/Kavita.git
synced 2025-07-09 03:04:19 -04:00
Next Estimated Chapter (#2342)
This commit is contained in:
parent
ca5afe94d3
commit
de9b09c71f
@ -46,8 +46,6 @@ public class FilterController : BaseApiController
|
||||
return BadRequest("You cannot use the name of a system provided stream");
|
||||
}
|
||||
|
||||
// I might just want to use DashboardStream instead of a separate entity. It will drastically simplify implementation
|
||||
|
||||
var existingFilter =
|
||||
user.SmartFilters.FirstOrDefault(f => f.Name.Equals(dto.Name, StringComparison.InvariantCultureIgnoreCase));
|
||||
if (existingFilter != null)
|
||||
|
@ -610,4 +610,18 @@ public class SeriesController : BaseApiController
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Based on the delta times between when chapters are added, for series that are not Completed/Cancelled/Hiatus, forecast the next
|
||||
/// date when it will be available.
|
||||
/// </summary>
|
||||
/// <param name="seriesId"></param>
|
||||
/// <returns></returns>
|
||||
[HttpGet("next-expected")]
|
||||
public async Task<ActionResult<NextExpectedChapterDto>> GetNextExpectedChapter(int seriesId)
|
||||
{
|
||||
var userId = User.GetUserId();
|
||||
|
||||
return Ok(await _seriesService.GetEstimatedChapterCreationDate(seriesId, userId));
|
||||
}
|
||||
|
||||
}
|
||||
|
@ -44,6 +44,6 @@ public enum FilterField
|
||||
/// <summary>
|
||||
/// Last time User Read
|
||||
/// </summary>
|
||||
ReadingDate = 27
|
||||
ReadingDate = 27,
|
||||
|
||||
}
|
||||
|
17
API/DTOs/SeriesDetail/NextExpectedChapterDto.cs
Normal file
17
API/DTOs/SeriesDetail/NextExpectedChapterDto.cs
Normal file
@ -0,0 +1,17 @@
|
||||
using System;
|
||||
|
||||
namespace API.DTOs.SeriesDetail;
|
||||
|
||||
public class NextExpectedChapterDto
|
||||
{
|
||||
public float ChapterNumber { get; set; }
|
||||
public int VolumeNumber { get; set; }
|
||||
/// <summary>
|
||||
/// Null if not applicable
|
||||
/// </summary>
|
||||
public DateTime? ExpectedDate { get; set; }
|
||||
/// <summary>
|
||||
/// The localized title to render on the card
|
||||
/// </summary>
|
||||
public string Title { get; set; }
|
||||
}
|
@ -1,6 +1,7 @@
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace API.DTOs.SeriesDetail;
|
||||
#nullable enable
|
||||
|
||||
/// <summary>
|
||||
/// This is a special DTO for a UI page in Kavita. This performs sorting and grouping and returns exactly what UI requires for layout.
|
||||
@ -32,5 +33,4 @@ public class SeriesDetailDto
|
||||
/// How many chapters are there
|
||||
/// </summary>
|
||||
public int TotalCount { get; set; }
|
||||
|
||||
}
|
||||
|
@ -1,5 +1,7 @@
|
||||
using System;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using API.Entities;
|
||||
using API.Entities.Enums;
|
||||
using API.Services;
|
||||
@ -168,6 +170,11 @@ public class ComicInfo
|
||||
info.Isbn = info.GTIN;
|
||||
}
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(info.Number))
|
||||
{
|
||||
info.Number = info.Number.Replace(",", "."); // Corrective measure for non English OSes
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@ -176,13 +183,21 @@ public class ComicInfo
|
||||
/// <returns></returns>
|
||||
public int CalculatedCount()
|
||||
{
|
||||
if (!string.IsNullOrEmpty(Number) && float.Parse(Number) > 0)
|
||||
try
|
||||
{
|
||||
return (int) Math.Floor(float.Parse(Number));
|
||||
if (float.TryParse(Number, out var chpCount) && chpCount > 0)
|
||||
{
|
||||
return (int) Math.Floor(chpCount);
|
||||
}
|
||||
|
||||
if (float.TryParse(Volume, out var volCount) && volCount > 0)
|
||||
{
|
||||
return (int) Math.Floor(volCount);
|
||||
}
|
||||
}
|
||||
if (!string.IsNullOrEmpty(Volume) && float.Parse(Volume) > 0)
|
||||
catch (Exception)
|
||||
{
|
||||
return Math.Max(Count, (int) Math.Floor(float.Parse(Volume)));
|
||||
return 0;
|
||||
}
|
||||
|
||||
return 0;
|
||||
|
@ -205,5 +205,4 @@ public class AppUserProgressRepository : IAppUserProgressRepository
|
||||
.Where(p => p.ChapterId == chapterId && p.AppUserId == userId)
|
||||
.FirstOrDefaultAsync();
|
||||
}
|
||||
#nullable disable
|
||||
}
|
||||
|
@ -40,6 +40,7 @@ public interface IChapterRepository
|
||||
Task<IList<Chapter>> GetAllChaptersWithCoversInDifferentEncoding(EncodeFormat format);
|
||||
Task<IEnumerable<string>> GetCoverImagesForLockedChaptersAsync();
|
||||
Task<ChapterDto> AddChapterModifiers(int userId, ChapterDto chapter);
|
||||
IEnumerable<Chapter> GetChaptersForSeries(int seriesId);
|
||||
}
|
||||
public class ChapterRepository : IChapterRepository
|
||||
{
|
||||
@ -264,4 +265,12 @@ public class ChapterRepository : IChapterRepository
|
||||
|
||||
return chapter;
|
||||
}
|
||||
|
||||
public IEnumerable<Chapter> GetChaptersForSeries(int seriesId)
|
||||
{
|
||||
return _context.Chapter
|
||||
.Where(c => c.Volume.SeriesId == seriesId)
|
||||
.Include(c => c.Volume)
|
||||
.AsEnumerable();
|
||||
}
|
||||
}
|
||||
|
@ -1,5 +1,6 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Threading.Tasks;
|
||||
@ -347,9 +348,9 @@ public class SeriesRepository : ISeriesRepository
|
||||
.Where(l => libraryIds.Contains(l.Id))
|
||||
.Where(l => EF.Functions.Like(l.Name, $"%{searchQuery}%"))
|
||||
.IsRestricted(QueryContext.Search)
|
||||
.OrderBy(l => l.Name.ToLower())
|
||||
.AsSplitQuery()
|
||||
.Take(maxRecords)
|
||||
.OrderBy(l => l.Name.ToLower())
|
||||
.ProjectTo<LibraryDto>(_mapper.ConfigurationProvider)
|
||||
.ToListAsync();
|
||||
|
||||
@ -366,10 +367,10 @@ public class SeriesRepository : ISeriesRepository
|
||||
|| (hasYearInQuery && s.Metadata.ReleaseYear == yearComparison))
|
||||
.RestrictAgainstAgeRestriction(userRating)
|
||||
.Include(s => s.Library)
|
||||
.OrderBy(s => s.SortName!.ToLower())
|
||||
.AsNoTracking()
|
||||
.AsSplitQuery()
|
||||
.Take(maxRecords)
|
||||
.OrderBy(s => s.SortName!.ToLower())
|
||||
.ProjectTo<SearchResultDto>(_mapper.ConfigurationProvider)
|
||||
.AsEnumerable();
|
||||
|
||||
@ -379,6 +380,7 @@ public class SeriesRepository : ISeriesRepository
|
||||
.RestrictAgainstAgeRestriction(userRating)
|
||||
.AsSplitQuery()
|
||||
.Take(maxRecords)
|
||||
.OrderBy(r => r.NormalizedTitle)
|
||||
.ProjectTo<ReadingListDto>(_mapper.ConfigurationProvider)
|
||||
.ToListAsync();
|
||||
|
||||
@ -399,8 +401,9 @@ public class SeriesRepository : ISeriesRepository
|
||||
.Where(sm => seriesIds.Contains(sm.SeriesId))
|
||||
.SelectMany(sm => sm.People.Where(t => t.Name != null && EF.Functions.Like(t.Name, $"%{searchQuery}%")))
|
||||
.AsSplitQuery()
|
||||
.Take(maxRecords)
|
||||
.Distinct()
|
||||
.Take(maxRecords)
|
||||
.OrderBy(p => p.NormalizedName)
|
||||
.ProjectTo<PersonDto>(_mapper.ConfigurationProvider)
|
||||
.ToListAsync();
|
||||
|
||||
@ -408,9 +411,9 @@ public class SeriesRepository : ISeriesRepository
|
||||
.Where(sm => seriesIds.Contains(sm.SeriesId))
|
||||
.SelectMany(sm => sm.Genres.Where(t => EF.Functions.Like(t.Title, $"%{searchQuery}%")))
|
||||
.AsSplitQuery()
|
||||
.OrderBy(t => t.NormalizedTitle)
|
||||
.Distinct()
|
||||
.Take(maxRecords)
|
||||
.OrderBy(t => t.NormalizedTitle)
|
||||
.ProjectTo<GenreTagDto>(_mapper.ConfigurationProvider)
|
||||
.ToListAsync();
|
||||
|
||||
@ -418,9 +421,9 @@ public class SeriesRepository : ISeriesRepository
|
||||
.Where(sm => seriesIds.Contains(sm.SeriesId))
|
||||
.SelectMany(sm => sm.Tags.Where(t => EF.Functions.Like(t.Title, $"%{searchQuery}%")))
|
||||
.AsSplitQuery()
|
||||
.OrderBy(t => t.NormalizedTitle)
|
||||
.Distinct()
|
||||
.Take(maxRecords)
|
||||
.OrderBy(t => t.NormalizedTitle)
|
||||
.ProjectTo<TagDto>(_mapper.ConfigurationProvider)
|
||||
.ToListAsync();
|
||||
|
||||
@ -435,6 +438,7 @@ public class SeriesRepository : ISeriesRepository
|
||||
.Where(m => EF.Functions.Like(m.FilePath, $"%{searchQuery}%") && fileIds.Contains(m.Id))
|
||||
.AsSplitQuery()
|
||||
.Take(maxRecords)
|
||||
.OrderBy(f => f.FilePath)
|
||||
.ProjectTo<MangaFileDto>(_mapper.ConfigurationProvider)
|
||||
.ToListAsync();
|
||||
|
||||
@ -447,12 +451,19 @@ public class SeriesRepository : ISeriesRepository
|
||||
.Where(c => c.Files.All(f => fileIds.Contains(f.Id)))
|
||||
.AsSplitQuery()
|
||||
.Take(maxRecords)
|
||||
.OrderBy(c => c.TitleName)
|
||||
.ProjectTo<ChapterDto>(_mapper.ConfigurationProvider)
|
||||
.ToListAsync();
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Includes Progress for the user
|
||||
/// </summary>
|
||||
/// <param name="seriesId"></param>
|
||||
/// <param name="userId"></param>
|
||||
/// <returns></returns>
|
||||
public async Task<SeriesDto?> GetSeriesDtoByIdAsync(int seriesId, int userId)
|
||||
{
|
||||
var series = await _context.Series.Where(x => x.Id == seriesId)
|
||||
@ -955,7 +966,7 @@ public class SeriesRepository : ISeriesRepository
|
||||
|
||||
|
||||
return ApplyLimit(query
|
||||
.Sort(filter.SortOptions)
|
||||
.Sort(userId, filter.SortOptions)
|
||||
.AsSplitQuery(), filter.LimitTo);
|
||||
}
|
||||
|
||||
@ -1108,7 +1119,7 @@ public class SeriesRepository : ISeriesRepository
|
||||
|| EF.Functions.Like(s.LocalizedName!, $"%{filter.SeriesNameQuery}%"))
|
||||
.Where(s => userLibraries.Contains(s.LibraryId)
|
||||
&& formats.Contains(s.Format))
|
||||
.Sort(filter.SortOptions)
|
||||
.Sort(userId, filter.SortOptions)
|
||||
.AsNoTracking();
|
||||
|
||||
return query.AsSplitQuery();
|
||||
@ -1971,4 +1982,5 @@ public class SeriesRepository : ISeriesRepository
|
||||
.IsRestricted(queryContext)
|
||||
.Select(lib => lib.Id);
|
||||
}
|
||||
|
||||
}
|
||||
|
@ -2,6 +2,7 @@
|
||||
using System.Threading.Tasks;
|
||||
using API.Data.Repositories;
|
||||
using API.Entities;
|
||||
using API.Services;
|
||||
using AutoMapper;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
|
||||
@ -40,6 +41,7 @@ public class UnitOfWork : IUnitOfWork
|
||||
private readonly DataContext _context;
|
||||
private readonly IMapper _mapper;
|
||||
private readonly UserManager<AppUser> _userManager;
|
||||
private readonly ILocalizationService _localizationService;
|
||||
|
||||
public UnitOfWork(DataContext context, IMapper mapper, UserManager<AppUser> userManager)
|
||||
{
|
||||
@ -99,6 +101,16 @@ public class UnitOfWork : IUnitOfWork
|
||||
return _context.ChangeTracker.HasChanges();
|
||||
}
|
||||
|
||||
public async Task BeginTransactionAsync()
|
||||
{
|
||||
await _context.Database.BeginTransactionAsync();
|
||||
}
|
||||
|
||||
public async Task CommitTransactionAsync()
|
||||
{
|
||||
await _context.Database.CommitTransactionAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Rollback transaction
|
||||
/// </summary>
|
||||
|
@ -67,13 +67,13 @@ public class Chapter : IEntityDate, IHasReadTimeEstimate
|
||||
/// </summary>
|
||||
public string? Language { get; set; }
|
||||
/// <summary>
|
||||
/// Total number of issues or volumes in the series
|
||||
/// Total number of issues or volumes in the series. This is straight from ComicInfo
|
||||
/// </summary>
|
||||
/// <remarks>Users may use Volume count or issue count. Kavita performs some light logic to help Count match up with TotalCount</remarks>
|
||||
public int TotalCount { get; set; } = 0;
|
||||
/// <summary>
|
||||
/// Number of the Total Count (progress the Series is complete)
|
||||
/// </summary>
|
||||
/// <remarks>This is either the highest of ComicInfo Count field and (nonparsed volume/chapter number)</remarks>
|
||||
public int Count { get; set; } = 0;
|
||||
/// <summary>
|
||||
/// SeriesGroup tag in ComicInfo
|
||||
|
@ -36,7 +36,7 @@ public class SeriesMetadata : IHasConcurrencyToken
|
||||
/// </summary>
|
||||
public string Language { get; set; } = string.Empty;
|
||||
/// <summary>
|
||||
/// Total number of issues/volumes in the series
|
||||
/// Total expected number of issues/volumes in the series from ComicInfo.xml
|
||||
/// </summary>
|
||||
public int TotalCount { get; set; } = 0;
|
||||
/// <summary>
|
||||
|
@ -11,7 +11,7 @@ public static class SeriesSort
|
||||
/// <param name="query"></param>
|
||||
/// <param name="sortOptions"></param>
|
||||
/// <returns></returns>
|
||||
public static IQueryable<Series> Sort(this IQueryable<Series> query, SortOptions? sortOptions)
|
||||
public static IQueryable<Series> Sort(this IQueryable<Series> query, int userId, SortOptions? sortOptions)
|
||||
{
|
||||
// If no sort options, default to using SortName
|
||||
sortOptions ??= new SortOptions()
|
||||
@ -28,7 +28,9 @@ public static class SeriesSort
|
||||
SortField.LastChapterAdded => query.DoOrderBy(s => s.LastChapterAdded, sortOptions),
|
||||
SortField.TimeToRead => query.DoOrderBy(s => s.AvgHoursToRead, sortOptions),
|
||||
SortField.ReleaseYear => query.DoOrderBy(s => s.Metadata.ReleaseYear, sortOptions),
|
||||
SortField.ReadProgress => query.DoOrderBy(s => s.Progress.Where(p => p.SeriesId == s.Id).Select(p => p.LastModified).Max(), sortOptions),
|
||||
SortField.ReadProgress => query.DoOrderBy(s => s.Progress.Where(p => p.SeriesId == s.Id && p.AppUserId == userId)
|
||||
.Select(p => p.LastModified)
|
||||
.Max(), sortOptions),
|
||||
_ => query
|
||||
};
|
||||
|
||||
|
@ -80,7 +80,7 @@ public static class SmartFilterHelper
|
||||
if (statements == null || statements.Count == 0)
|
||||
return string.Empty;
|
||||
|
||||
var encodedStatements = StatementsKey + HttpUtility.UrlEncode(string.Join(",", statements.Select(EncodeFilterStatementDto)));
|
||||
var encodedStatements = StatementsKey + Uri.EscapeDataString(string.Join(",", statements.Select(EncodeFilterStatementDto)));
|
||||
return encodedStatements;
|
||||
}
|
||||
|
||||
@ -88,7 +88,7 @@ public static class SmartFilterHelper
|
||||
{
|
||||
var encodedComparison = $"comparison={(int) statement.Comparison}";
|
||||
var encodedField = $"field={(int) statement.Field}";
|
||||
var encodedValue = $"value={HttpUtility.UrlEncode(statement.Value)}";
|
||||
var encodedValue = $"value={Uri.EscapeDataString(statement.Value)}";
|
||||
|
||||
return $"{encodedComparison}&{encodedField}&{encodedValue}";
|
||||
}
|
||||
|
@ -1026,16 +1026,26 @@ public class BookService : IBookService
|
||||
|
||||
if (chaptersList.Count != 0) return chaptersList;
|
||||
// Generate from TOC from links (any point past this, Kavita is generating as a TOC doesn't exist)
|
||||
var tocPage = book.Content.Html.Local.Select(s => s.Key).FirstOrDefault(k => k.Equals("TOC.XHTML", StringComparison.InvariantCultureIgnoreCase) ||
|
||||
var tocPage = book.Content.Html.Local.Select(s => s.Key)
|
||||
.FirstOrDefault(k => k.Equals("TOC.XHTML", StringComparison.InvariantCultureIgnoreCase) ||
|
||||
k.Equals("NAVIGATION.XHTML", StringComparison.InvariantCultureIgnoreCase));
|
||||
if (string.IsNullOrEmpty(tocPage)) return chaptersList;
|
||||
|
||||
|
||||
// Find all anchor tags, for each anchor we get inner text, to lower then title case on UI. Get href and generate page content
|
||||
if (!book.Content.Html.TryGetLocalFileRefByKey(tocPage, out var file)) return chaptersList;
|
||||
var content = await file.ReadContentAsync();
|
||||
|
||||
var doc = new HtmlDocument();
|
||||
doc.LoadHtml(content);
|
||||
|
||||
// TODO: We may want to check if there is a toc.ncs file to better handle nested toc
|
||||
// We could do a fallback first with ol/lis
|
||||
//var sections = doc.DocumentNode.SelectNodes("//ol");
|
||||
//if (sections == null)
|
||||
|
||||
|
||||
|
||||
var anchors = doc.DocumentNode.SelectNodes("//a");
|
||||
if (anchors == null) return chaptersList;
|
||||
|
||||
|
@ -206,8 +206,7 @@ public class ScrobblingService : IScrobblingService
|
||||
ScrobbleEventType.Review);
|
||||
if (existingEvt is {IsProcessed: false})
|
||||
{
|
||||
_logger.LogDebug("Overriding scrobble event for {Series} from Review {Tagline}/{Body} -> {UpdatedTagline}{UpdatedBody}",
|
||||
existingEvt.Series.Name, existingEvt.ReviewTitle, existingEvt.ReviewBody, reviewTitle, reviewBody);
|
||||
_logger.LogDebug("Overriding Review scrobble event for {Series}", existingEvt.Series.Name);
|
||||
existingEvt.ReviewBody = reviewBody;
|
||||
existingEvt.ReviewTitle = reviewTitle;
|
||||
_unitOfWork.ScrobbleRepository.Update(existingEvt);
|
||||
|
@ -1,5 +1,6 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
@ -17,6 +18,7 @@ using API.Helpers.Builders;
|
||||
using API.Services.Plus;
|
||||
using API.SignalR;
|
||||
using Hangfire;
|
||||
using Kavita.Common;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace API.Services;
|
||||
@ -36,6 +38,7 @@ public interface ISeriesService
|
||||
Task<string> FormatChapterTitle(int userId, bool isSpecial, LibraryType libraryType, string? chapterTitle,
|
||||
bool withHash);
|
||||
Task<string> FormatChapterName(int userId, LibraryType libraryType, bool withHash = false);
|
||||
Task<NextExpectedChapterDto> GetEstimatedChapterCreationDate(int seriesId, int userId);
|
||||
}
|
||||
|
||||
public class SeriesService : ISeriesService
|
||||
@ -399,6 +402,7 @@ public class SeriesService : ISeriesService
|
||||
public async Task<SeriesDetailDto> GetSeriesDetail(int seriesId, int userId)
|
||||
{
|
||||
var series = await _unitOfWork.SeriesRepository.GetSeriesDtoByIdAsync(seriesId, userId);
|
||||
if (series == null) throw new KavitaException(await _localizationService.Translate(userId, "series-doesnt-exist"));
|
||||
var libraryIds = _unitOfWork.LibraryRepository.GetLibraryIdsForUserIdAsync(userId);
|
||||
if (!libraryIds.Contains(series.LibraryId))
|
||||
throw new UnauthorizedAccessException("user-no-access-library-from-series");
|
||||
@ -488,7 +492,7 @@ public class SeriesService : ISeriesService
|
||||
Volumes = processedVolumes,
|
||||
StorylineChapters = storylineChapters,
|
||||
TotalCount = chapters.Count,
|
||||
UnreadCount = chapters.Count(c => c.Pages > 0 && c.PagesRead < c.Pages)
|
||||
UnreadCount = chapters.Count(c => c.Pages > 0 && c.PagesRead < c.Pages),
|
||||
};
|
||||
}
|
||||
|
||||
@ -642,4 +646,88 @@ public class SeriesService : ISeriesService
|
||||
_unitOfWork.SeriesRepository.Update(series);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<NextExpectedChapterDto> GetEstimatedChapterCreationDate(int seriesId, int userId)
|
||||
{
|
||||
var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(seriesId, SeriesIncludes.Metadata | SeriesIncludes.Library);
|
||||
if (series == null) throw new KavitaException(await _localizationService.Translate(userId, "series-doesnt-exist"));
|
||||
var libraryIds = _unitOfWork.LibraryRepository.GetLibraryIdsForUserIdAsync(userId);
|
||||
if (!libraryIds.Contains(series.LibraryId)) //// TODO: Rewrite this to use a new method which checks permissions all in the DB to be streamlined and less memory
|
||||
throw new UnauthorizedAccessException("user-no-access-library-from-series");
|
||||
if (series?.Metadata.PublicationStatus is not (PublicationStatus.OnGoing or PublicationStatus.Ended) || series.Library.Type == LibraryType.Book)
|
||||
{
|
||||
return new NextExpectedChapterDto()
|
||||
{
|
||||
ExpectedDate = null,
|
||||
ChapterNumber = 0,
|
||||
VolumeNumber = 0
|
||||
};
|
||||
}
|
||||
|
||||
var chapters = _unitOfWork.ChapterRepository.GetChaptersForSeries(seriesId)
|
||||
.Where(c => !c.IsSpecial)
|
||||
.OrderBy(c => c.CreatedUtc)
|
||||
.ToList();
|
||||
|
||||
// Calculate the time differences between consecutive chapters
|
||||
var timeDifferences = chapters
|
||||
.Select((chapter, index) => new
|
||||
{
|
||||
ChapterNumber = chapter.Number,
|
||||
VolumeNumber = chapter.Volume.Number,
|
||||
TimeDifference = index == 0 ? TimeSpan.Zero : (chapter.CreatedUtc - chapters.ElementAt(index - 1).CreatedUtc)
|
||||
})
|
||||
.ToList();
|
||||
|
||||
// Calculate the average time difference between chapters
|
||||
var averageTimeDifference = timeDifferences
|
||||
.Average(td => td.TimeDifference.TotalDays);
|
||||
|
||||
// Calculate the forecast for when the next chapter is expected
|
||||
var nextChapterExpected = chapters.Any()
|
||||
? chapters.Max(c => c.CreatedUtc) + TimeSpan.FromDays(averageTimeDifference)
|
||||
: (DateTime?) null;
|
||||
|
||||
if (nextChapterExpected != null && nextChapterExpected < DateTime.UtcNow)
|
||||
{
|
||||
nextChapterExpected = DateTime.UtcNow + TimeSpan.FromDays(averageTimeDifference);
|
||||
}
|
||||
|
||||
var lastChapter = timeDifferences.Last();
|
||||
float.TryParse(lastChapter.ChapterNumber, NumberStyles.Number, CultureInfo.InvariantCulture,
|
||||
out var lastChapterNumber);
|
||||
|
||||
var result = new NextExpectedChapterDto()
|
||||
{
|
||||
ChapterNumber = 0,
|
||||
VolumeNumber = 0,
|
||||
ExpectedDate = nextChapterExpected,
|
||||
Title = string.Empty
|
||||
};
|
||||
|
||||
if (lastChapterNumber > 0)
|
||||
{
|
||||
result.ChapterNumber = lastChapterNumber + 1;
|
||||
result.VolumeNumber = lastChapter.VolumeNumber;
|
||||
result.Title = series.Library.Type switch
|
||||
{
|
||||
LibraryType.Manga => await _localizationService.Translate(userId, "chapter-num",
|
||||
new object[] {result.ChapterNumber}),
|
||||
LibraryType.Comic => await _localizationService.Translate(userId, "issue-num",
|
||||
new object[] {"#", result.ChapterNumber}),
|
||||
LibraryType.Book => await _localizationService.Translate(userId, "book-num",
|
||||
new object[] {result.ChapterNumber}),
|
||||
_ => "Chapter " + result.ChapterNumber
|
||||
};
|
||||
}
|
||||
else
|
||||
{
|
||||
result.VolumeNumber = lastChapter.VolumeNumber + 1;
|
||||
result.Title = await _localizationService.Translate(userId, "vol-num",
|
||||
new object[] {result.VolumeNumber});
|
||||
}
|
||||
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
@ -288,14 +288,12 @@ public class ProcessSeries : IProcessSeries
|
||||
series.Metadata.TotalCount = chapters.Max(chapter => chapter.TotalCount);
|
||||
// The actual number of count's defined across all chapter's metadata
|
||||
series.Metadata.MaxCount = chapters.Max(chapter => chapter.Count);
|
||||
// To not have to rely completely on ComicInfo, try to parse out if the series is complete by checking parsed filenames as well.
|
||||
if (series.Metadata.MaxCount != series.Metadata.TotalCount)
|
||||
{
|
||||
var maxVolume = series.Volumes.Max(v => (int) Parser.Parser.MaxNumberFromRange(v.Name));
|
||||
var maxChapter = chapters.Max(c => (int) Parser.Parser.MaxNumberFromRange(c.Range));
|
||||
if (maxVolume == series.Metadata.TotalCount) series.Metadata.MaxCount = maxVolume;
|
||||
else if (maxChapter == series.Metadata.TotalCount) series.Metadata.MaxCount = maxChapter;
|
||||
}
|
||||
|
||||
var maxVolume = series.Volumes.Max(v => (int) Parser.Parser.MaxNumberFromRange(v.Name));
|
||||
var maxChapter = chapters.Max(c => (int) Parser.Parser.MaxNumberFromRange(c.Range));
|
||||
var maxActual = Math.Max(maxVolume, maxChapter);
|
||||
|
||||
series.Metadata.MaxCount = maxActual;
|
||||
|
||||
|
||||
if (!series.Metadata.PublicationStatusLocked)
|
||||
|
@ -0,0 +1,10 @@
|
||||
export interface NextExpectedChapter {
|
||||
volumeNumber: number;
|
||||
chapterNumber: number;
|
||||
expectedDate: string | null;
|
||||
title: string;
|
||||
/**
|
||||
* Not real, used for some type stuff with app-card
|
||||
*/
|
||||
id: number;
|
||||
}
|
@ -17,6 +17,7 @@ export class ImageService {
|
||||
public errorImage = 'assets/images/error-placeholder2.dark-min.png';
|
||||
public resetCoverImage = 'assets/images/image-reset-cover-min.png';
|
||||
public errorWebLinkImage = 'assets/images/broken-white-32x32.png';
|
||||
public nextChapterImage = 'assets/images/image-placeholder.dark-min.png'
|
||||
|
||||
constructor(private accountService: AccountService, private themeService: ThemeService) {
|
||||
this.themeService.currentTheme$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(theme => {
|
||||
|
@ -21,6 +21,7 @@ import {UserReview} from "../_single-module/review-card/user-review";
|
||||
import {Rating} from "../_models/rating";
|
||||
import {Recommendation} from "../_models/series-detail/recommendation";
|
||||
import {ExternalSeriesDetail} from "../_models/series-detail/external-series-detail";
|
||||
import {NextExpectedChapter} from "../_models/series-detail/next-expected-chapter";
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
@ -233,4 +234,8 @@ export class SeriesService {
|
||||
return this.httpClient.get<ExternalSeriesDetail>(this.baseUrl + 'series/external-series-detail?aniListId=' + (aniListId || 0) + '&malId=' + (malId || 0) + '&seriesId=' + (seriesId || 0));
|
||||
}
|
||||
|
||||
getNextExpectedChapterDate(seriesId: number) {
|
||||
return this.httpClient.get<NextExpectedChapter>(this.baseUrl + 'series/next-expected?seriesId=' + seriesId);
|
||||
}
|
||||
|
||||
}
|
||||
|
@ -56,6 +56,17 @@ export class ManageTasksSettingsComponent implements OnInit {
|
||||
api: this.serverService.bustCache(),
|
||||
successMessage: 'bust-cache-task-success'
|
||||
},
|
||||
{
|
||||
name: 'bust-locale-task',
|
||||
description: 'bust-locale-task-desc',
|
||||
api: defer(() => {
|
||||
localStorage.removeItem('@transloco/translations/timestamp');
|
||||
localStorage.removeItem('@transloco/translations');
|
||||
localStorage.removeItem('translocoLang');
|
||||
return of();
|
||||
}),
|
||||
successMessage: 'bust-locale-task-success',
|
||||
},
|
||||
{
|
||||
name: 'clear-reading-cache-task',
|
||||
description: 'clear-reading-cache-task-desc',
|
||||
|
@ -324,6 +324,7 @@ $action-bar-height: 38px;
|
||||
color: $primary-color;
|
||||
}
|
||||
|
||||
$pagination-color: transparent;
|
||||
|
||||
|
||||
.right {
|
||||
@ -332,7 +333,7 @@ $action-bar-height: 38px;
|
||||
top: $action-bar-height;
|
||||
width: 20vw;
|
||||
z-index: 3;
|
||||
background: transparent;
|
||||
background: $pagination-color;
|
||||
border-color: transparent;
|
||||
border: none !important;
|
||||
opacity: 0;
|
||||
@ -354,7 +355,7 @@ $action-bar-height: 38px;
|
||||
top: $action-bar-height;
|
||||
width: 18%;
|
||||
z-index: 3;
|
||||
background: transparent;
|
||||
background: $pagination-color;
|
||||
border-color: transparent;
|
||||
border: none !important;
|
||||
opacity: 0;
|
||||
@ -374,7 +375,7 @@ $action-bar-height: 38px;
|
||||
left: 0px;
|
||||
top: $action-bar-height;
|
||||
width: 20vw;
|
||||
background: transparent;
|
||||
background: $pagination-color;
|
||||
border-color: transparent;
|
||||
border: none !important;
|
||||
z-index: 3;
|
||||
|
@ -34,7 +34,7 @@
|
||||
<span class="badge bg-primary">{{count}}</span>
|
||||
</div>
|
||||
<div class="card-overlay"></div>
|
||||
<div class="overlay-information" *ngIf="overlayInformation !== '' || overlayInformation !== undefined">
|
||||
<div class="overlay-information {{centerOverlay ? 'overlay-information--centered' : ''}}" *ngIf="overlayInformation !== '' || overlayInformation !== undefined">
|
||||
<div class="position-relative">
|
||||
<span class="card-title library mx-auto" style="width: auto;" [ngbTooltip]="overlayInformation" placement="top">{{overlayInformation}}</span>
|
||||
</div>
|
||||
|
@ -77,7 +77,7 @@ $image-width: 160px;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
width: 158px;
|
||||
|
||||
|
||||
}
|
||||
|
||||
.not-read-badge {
|
||||
@ -111,6 +111,8 @@ $image-width: 160px;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
.overlay-information {
|
||||
position: absolute;
|
||||
top: 5px;
|
||||
@ -118,13 +120,18 @@ $image-width: 160px;
|
||||
border-radius: 15px;
|
||||
padding: 0 10px;
|
||||
background-color: var(--card-bg-color);
|
||||
|
||||
&.overlay-information--centered {
|
||||
top: 95px;
|
||||
left: 36px;
|
||||
}
|
||||
}
|
||||
|
||||
.overlay {
|
||||
height: $image-height;
|
||||
border-top-left-radius: 4px;
|
||||
border-top-right-radius: 4px;
|
||||
|
||||
|
||||
&:hover {
|
||||
visibility: visible;
|
||||
|
||||
@ -138,11 +145,11 @@ $image-width: 160px;
|
||||
z-index: 100;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
.overlay-item {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
|
||||
z-index: 10;
|
||||
|
||||
.count {
|
||||
|
@ -39,8 +39,10 @@ import {MangaFormatIconPipe} from "../../pipe/manga-format-icon.pipe";
|
||||
import {SentenceCasePipe} from "../../pipe/sentence-case.pipe";
|
||||
import {CommonModule} from "@angular/common";
|
||||
import {RouterLink} from "@angular/router";
|
||||
import {TranslocoModule} from "@ngneat/transloco";
|
||||
import {translate, TranslocoModule} from "@ngneat/transloco";
|
||||
import {CardActionablesComponent} from "../../_single-module/card-actionables/card-actionables.component";
|
||||
import {NextExpectedChapter} from "../../_models/series-detail/next-expected-chapter";
|
||||
import {UtcToLocalTimePipe} from "../../pipe/utc-to-local-time.pipe";
|
||||
|
||||
@Component({
|
||||
selector: 'app-card-item',
|
||||
@ -96,7 +98,7 @@ export class CardItemComponent implements OnInit {
|
||||
/**
|
||||
* This is the entity we are representing. It will be returned if an action is executed.
|
||||
*/
|
||||
@Input({required: true}) entity!: Series | Volume | Chapter | CollectionTag | PageBookmark | RecentlyAddedItem;
|
||||
@Input({required: true}) entity!: Series | Volume | Chapter | CollectionTag | PageBookmark | RecentlyAddedItem | NextExpectedChapter;
|
||||
/**
|
||||
* If the entity is selected or not.
|
||||
*/
|
||||
@ -117,6 +119,10 @@ export class CardItemComponent implements OnInit {
|
||||
* Additional information to show on the overlay area. Will always render.
|
||||
*/
|
||||
@Input() overlayInformation: string = '';
|
||||
/**
|
||||
* If overlay is enabled, should the text be centered or not
|
||||
*/
|
||||
@Input() centerOverlay = false;
|
||||
/**
|
||||
* Event emitted when item is clicked
|
||||
*/
|
||||
@ -210,8 +216,29 @@ export class CardItemComponent implements OnInit {
|
||||
}
|
||||
} else if (this.utilityService.isSeries(this.entity)) {
|
||||
this.tooltipTitle = this.title || (this.utilityService.asSeries(this.entity).name);
|
||||
} else if (this.entity.hasOwnProperty('expectedDate')) {
|
||||
this.suppressArchiveWarning = true;
|
||||
this.imageUrl = '';
|
||||
const nextDate = (this.entity as NextExpectedChapter);
|
||||
|
||||
// if (nextDate.volumeNumber > 0 && nextDate.chapterNumber === 0) {
|
||||
// this.overlayInformation = 'Volume ' + nextDate.volumeNumber;
|
||||
//
|
||||
// } else {
|
||||
// this.overlayInformation = 'Chapter ' + nextDate.chapterNumber;
|
||||
// }
|
||||
this.overlayInformation = nextDate.title;
|
||||
this.centerOverlay = true;
|
||||
|
||||
if (nextDate.expectedDate) {
|
||||
const utcPipe = new UtcToLocalTimePipe();
|
||||
this.title = utcPipe.transform(nextDate.expectedDate);
|
||||
}
|
||||
|
||||
this.cdRef.markForCheck();
|
||||
}
|
||||
|
||||
|
||||
this.filterSendTo();
|
||||
this.accountService.currentUser$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(user => {
|
||||
this.user = user;
|
||||
|
@ -3,7 +3,7 @@
|
||||
}
|
||||
|
||||
.book-title {
|
||||
margin: 8px 0 4px !important;
|
||||
margin: 8px 0 4px !important;
|
||||
}
|
||||
|
||||
// Override since it's not coming from library
|
||||
@ -11,10 +11,15 @@
|
||||
margin: 3px 0 4px !important;
|
||||
}
|
||||
|
||||
::ng-deep .dark > ngx-extended-pdf-viewer .treeItem>a {
|
||||
color: lightblue !important;
|
||||
}
|
||||
|
||||
|
||||
.progress-container {
|
||||
width: 100%;
|
||||
}
|
||||
.progress-bar {
|
||||
// NOTE: We have to override due to theme variables not being available
|
||||
background-color: #3B9E76;
|
||||
}
|
||||
}
|
||||
|
@ -157,6 +157,7 @@
|
||||
[selected]="bulkSelectionService.isCardSelected('chapter', scroll.viewPortInfo.startIndexWithBuffer + idx)" [allowSelection]="true"></app-card-item>
|
||||
</ng-template>
|
||||
</ng-container>
|
||||
<ng-container [ngTemplateOutlet]="estimatedNextCard" [ngTemplateOutletContext]="{tabId: TabID.Storyline}"></ng-container>
|
||||
</div>
|
||||
</ng-container>
|
||||
<ng-template #storylineListLayout>
|
||||
@ -203,6 +204,7 @@
|
||||
[selected]="bulkSelectionService.isCardSelected('volume', scroll.viewPortInfo.startIndexWithBuffer + idx)" [allowSelection]="true">
|
||||
</app-card-item>
|
||||
</ng-container>
|
||||
<ng-container [ngTemplateOutlet]="estimatedNextCard" [ngTemplateOutletContext]="{tabId: TabID.Volumes}"></ng-container>
|
||||
</div>
|
||||
</ng-container>
|
||||
<ng-template #volumeListLayout>
|
||||
@ -240,6 +242,7 @@
|
||||
</ng-container>
|
||||
</app-card-item>
|
||||
</div>
|
||||
<ng-container [ngTemplateOutlet]="estimatedNextCard" [ngTemplateOutletContext]="{tabId: TabID.Chapters}"></ng-container>
|
||||
</div>
|
||||
</ng-container>
|
||||
<ng-template #chapterListLayout>
|
||||
@ -354,5 +357,26 @@
|
||||
|
||||
<app-loading [loading]="isLoading"></app-loading>
|
||||
</div>
|
||||
<ng-template #estimatedNextCard let-tabId="tabId">
|
||||
<ng-container *ngIf="nextExpectedChapter">
|
||||
<ng-container [ngSwitch]="tabId">
|
||||
<ng-container *ngSwitchCase="TabID.Volumes">
|
||||
<app-card-item *ngIf="nextExpectedChapter.volumeNumber > 0 && nextExpectedChapter.chapterNumber === 0" class="col-auto mt-2 mb-2" [entity]="nextExpectedChapter" ></app-card-item>
|
||||
</ng-container>
|
||||
<ng-container *ngSwitchCase="TabID.Chapters">
|
||||
<app-card-item class="col-auto mt-2 mb-2" [entity]="nextExpectedChapter" ></app-card-item>
|
||||
</ng-container>
|
||||
<ng-container *ngSwitchCase="TabID.Storyline">
|
||||
<app-card-item class="col-auto mt-2 mb-2" [entity]="nextExpectedChapter" ></app-card-item>
|
||||
</ng-container>
|
||||
</ng-container>
|
||||
<!-- <ng-container *ngIf="tabId === TabID.Volumes && nextExpectedChapter.volumeNumber > 0 && nextExpectedChapter.chapterNumber === 0">-->
|
||||
<!-- <app-card-item class="col-auto mt-2 mb-2" [entity]="nextExpectedChapter" ></app-card-item>-->
|
||||
<!-- </ng-container>-->
|
||||
<!-- <ng-container *ngIf="tabId === TabID.Storyline || tabId === TabID.Chapters && nextExpectedChapter.chapterNumber !== 0">-->
|
||||
<!-- <app-card-item class="col-auto mt-2 mb-2" [entity]="nextExpectedChapter" ></app-card-item>-->
|
||||
<!-- </ng-container>-->
|
||||
</ng-container>
|
||||
</ng-template>
|
||||
|
||||
</ng-container>
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { DOCUMENT, NgIf, NgStyle, NgFor, DecimalPipe } from '@angular/common';
|
||||
import {DecimalPipe, DOCUMENT, NgFor, NgIf, NgStyle, NgSwitch, NgSwitchCase, NgTemplateOutlet} from '@angular/common';
|
||||
import {
|
||||
AfterContentChecked,
|
||||
ChangeDetectionStrategy,
|
||||
@ -12,17 +12,31 @@ import {
|
||||
OnInit,
|
||||
ViewChild
|
||||
} from '@angular/core';
|
||||
import { FormControl, FormGroup, ReactiveFormsModule } from '@angular/forms';
|
||||
import {FormControl, FormGroup, ReactiveFormsModule} from '@angular/forms';
|
||||
import {Title} from '@angular/platform-browser';
|
||||
import {ActivatedRoute, Router} from '@angular/router';
|
||||
import { NgbModal, NgbNavChangeEvent, NgbOffcanvas, NgbTooltip, NgbProgressbar, NgbDropdown, NgbDropdownToggle, NgbDropdownMenu, NgbDropdownItem, NgbNav, NgbNavItem, NgbNavLink, NgbNavContent, NgbNavOutlet } from '@ng-bootstrap/ng-bootstrap';
|
||||
import {
|
||||
NgbDropdown,
|
||||
NgbDropdownItem,
|
||||
NgbDropdownMenu,
|
||||
NgbDropdownToggle,
|
||||
NgbModal,
|
||||
NgbNav,
|
||||
NgbNavChangeEvent,
|
||||
NgbNavContent,
|
||||
NgbNavItem,
|
||||
NgbNavLink,
|
||||
NgbNavOutlet,
|
||||
NgbOffcanvas,
|
||||
NgbProgressbar,
|
||||
NgbTooltip
|
||||
} from '@ng-bootstrap/ng-bootstrap';
|
||||
import {ToastrService} from 'ngx-toastr';
|
||||
import {catchError, forkJoin, of} from 'rxjs';
|
||||
import {take} from 'rxjs/operators';
|
||||
import {BulkSelectionService} from 'src/app/cards/bulk-selection.service';
|
||||
import {CardDetailDrawerComponent} from 'src/app/cards/card-detail-drawer/card-detail-drawer.component';
|
||||
import {EditSeriesModalComponent} from 'src/app/cards/_modals/edit-series-modal/edit-series-modal.component';
|
||||
import {ConfirmService} from 'src/app/shared/confirm.service';
|
||||
import {TagBadgeCursor} from 'src/app/shared/tag-badge/tag-badge.component';
|
||||
import {DownloadService} from 'src/app/shared/_services/download.service';
|
||||
import {KEY_CODES, UtilityService} from 'src/app/shared/_services/utility.service';
|
||||
@ -54,27 +68,30 @@ import {ReviewSeriesModalComponent} from '../../../_single-module/review-series-
|
||||
import {PageLayoutMode} from 'src/app/_models/page-layout-mode';
|
||||
import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
|
||||
import {UserReview} from "../../../_single-module/review-card/user-review";
|
||||
import { LoadingComponent } from '../../../shared/loading/loading.component';
|
||||
import { ExternalListItemComponent } from '../../../cards/external-list-item/external-list-item.component';
|
||||
import { ExternalSeriesCardComponent } from '../../../cards/external-series-card/external-series-card.component';
|
||||
import { SeriesCardComponent } from '../../../cards/series-card/series-card.component';
|
||||
import { EntityTitleComponent } from '../../../cards/entity-title/entity-title.component';
|
||||
import { ListItemComponent } from '../../../cards/list-item/list-item.component';
|
||||
import { CardItemComponent } from '../../../cards/card-item/card-item.component';
|
||||
import { VirtualScrollerModule } from '@iharbeck/ngx-virtual-scroller';
|
||||
import { BulkOperationsComponent } from '../../../cards/bulk-operations/bulk-operations.component';
|
||||
import { ReviewCardComponent } from '../../../_single-module/review-card/review-card.component';
|
||||
import { CarouselReelComponent } from '../../../carousel/_components/carousel-reel/carousel-reel.component';
|
||||
import { SeriesMetadataDetailComponent } from '../series-metadata-detail/series-metadata-detail.component';
|
||||
import { ImageComponent } from '../../../shared/image/image.component';
|
||||
import { TagBadgeComponent } from '../../../shared/tag-badge/tag-badge.component';
|
||||
import { SideNavCompanionBarComponent } from '../../../sidenav/_components/side-nav-companion-bar/side-nav-companion-bar.component';
|
||||
import {LoadingComponent} from '../../../shared/loading/loading.component';
|
||||
import {ExternalListItemComponent} from '../../../cards/external-list-item/external-list-item.component';
|
||||
import {ExternalSeriesCardComponent} from '../../../cards/external-series-card/external-series-card.component';
|
||||
import {SeriesCardComponent} from '../../../cards/series-card/series-card.component';
|
||||
import {EntityTitleComponent} from '../../../cards/entity-title/entity-title.component';
|
||||
import {ListItemComponent} from '../../../cards/list-item/list-item.component';
|
||||
import {CardItemComponent} from '../../../cards/card-item/card-item.component';
|
||||
import {VirtualScrollerModule} from '@iharbeck/ngx-virtual-scroller';
|
||||
import {BulkOperationsComponent} from '../../../cards/bulk-operations/bulk-operations.component';
|
||||
import {ReviewCardComponent} from '../../../_single-module/review-card/review-card.component';
|
||||
import {CarouselReelComponent} from '../../../carousel/_components/carousel-reel/carousel-reel.component';
|
||||
import {SeriesMetadataDetailComponent} from '../series-metadata-detail/series-metadata-detail.component';
|
||||
import {ImageComponent} from '../../../shared/image/image.component';
|
||||
import {TagBadgeComponent} from '../../../shared/tag-badge/tag-badge.component';
|
||||
import {
|
||||
SideNavCompanionBarComponent
|
||||
} from '../../../sidenav/_components/side-nav-companion-bar/side-nav-companion-bar.component';
|
||||
import {TranslocoDirective, TranslocoService} from "@ngneat/transloco";
|
||||
import {CardActionablesComponent} from "../../../_single-module/card-actionables/card-actionables.component";
|
||||
import {ExternalSeries} from "../../../_models/series-detail/external-series";
|
||||
import {
|
||||
SeriesPreviewDrawerComponent
|
||||
} from "../../../_single-module/series-preview-drawer/series-preview-drawer.component";
|
||||
import {PublicationStatus} from "../../../_models/metadata/publication-status";
|
||||
|
||||
interface RelatedSeriesPair {
|
||||
series: Series;
|
||||
@ -102,7 +119,7 @@ interface StoryLineItem {
|
||||
styleUrls: ['./series-detail.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
standalone: true,
|
||||
imports: [NgIf, SideNavCompanionBarComponent, CardActionablesComponent, ReactiveFormsModule, NgStyle, TagBadgeComponent, ImageComponent, NgbTooltip, NgbProgressbar, NgbDropdown, NgbDropdownToggle, NgbDropdownMenu, NgbDropdownItem, SeriesMetadataDetailComponent, CarouselReelComponent, ReviewCardComponent, BulkOperationsComponent, NgbNav, NgbNavItem, NgbNavLink, NgbNavContent, VirtualScrollerModule, NgFor, CardItemComponent, ListItemComponent, EntityTitleComponent, SeriesCardComponent, ExternalSeriesCardComponent, ExternalListItemComponent, NgbNavOutlet, LoadingComponent, DecimalPipe, TranslocoDirective]
|
||||
imports: [NgIf, SideNavCompanionBarComponent, CardActionablesComponent, ReactiveFormsModule, NgStyle, TagBadgeComponent, ImageComponent, NgbTooltip, NgbProgressbar, NgbDropdown, NgbDropdownToggle, NgbDropdownMenu, NgbDropdownItem, SeriesMetadataDetailComponent, CarouselReelComponent, ReviewCardComponent, BulkOperationsComponent, NgbNav, NgbNavItem, NgbNavLink, NgbNavContent, VirtualScrollerModule, NgFor, CardItemComponent, ListItemComponent, EntityTitleComponent, SeriesCardComponent, ExternalSeriesCardComponent, ExternalListItemComponent, NgbNavOutlet, LoadingComponent, DecimalPipe, TranslocoDirective, NgTemplateOutlet, NgSwitch, NgSwitchCase]
|
||||
})
|
||||
export class SeriesDetailComponent implements OnInit, AfterContentChecked {
|
||||
|
||||
@ -150,6 +167,8 @@ export class SeriesDetailComponent implements OnInit, AfterContentChecked {
|
||||
seriesImage: string = '';
|
||||
downloadInProgress: boolean = false;
|
||||
|
||||
nextExpectedChapter: any | undefined;
|
||||
|
||||
/**
|
||||
* Track by function for Volume to tell when to refresh card data
|
||||
*/
|
||||
@ -293,7 +312,7 @@ export class SeriesDetailComponent implements OnInit, AfterContentChecked {
|
||||
public utilityService: UtilityService, private toastr: ToastrService,
|
||||
private accountService: AccountService, public imageService: ImageService,
|
||||
private actionFactoryService: ActionFactoryService, private libraryService: LibraryService,
|
||||
private confirmService: ConfirmService, private titleService: Title,
|
||||
private titleService: Title,
|
||||
private downloadService: DownloadService, private actionService: ActionService,
|
||||
private messageHub: MessageHubService, private readingListService: ReadingListService,
|
||||
public navService: NavService,
|
||||
@ -501,6 +520,14 @@ export class SeriesDetailComponent implements OnInit, AfterContentChecked {
|
||||
this.seriesService.getMetadata(seriesId).subscribe(metadata => {
|
||||
this.seriesMetadata = metadata;
|
||||
this.cdRef.markForCheck();
|
||||
|
||||
if (![PublicationStatus.Ended, PublicationStatus.OnGoing].includes(this.seriesMetadata.publicationStatus)) return;
|
||||
this.seriesService.getNextExpectedChapterDate(seriesId).subscribe(date => {
|
||||
if (date == null || date.expectedDate === null) return;
|
||||
|
||||
this.nextExpectedChapter = date;
|
||||
this.cdRef.markForCheck();
|
||||
})
|
||||
});
|
||||
|
||||
this.seriesService.isWantToRead(seriesId).subscribe(isWantToRead => {
|
||||
|
@ -64,7 +64,7 @@ export class ChangeEmailComponent implements OnInit {
|
||||
if (updateEmailResponse.hadNoExistingEmail) {
|
||||
this.toastr.success(translate('toasts.email-sent-to-no-existing', {email: model.email}));
|
||||
} else {
|
||||
this.toastr.success(translate('toasts.email-send-to'));
|
||||
this.toastr.success(translate('toasts.email-sent-to'));
|
||||
}
|
||||
} else {
|
||||
this.toastr.success(translate('toasts.change-email-private'));
|
||||
|
@ -897,7 +897,6 @@
|
||||
|
||||
"card-item": {
|
||||
"cannot-read": "Cannot Read"
|
||||
|
||||
},
|
||||
|
||||
"chapter-metadata-detail": {
|
||||
@ -1191,6 +1190,10 @@
|
||||
"bust-cache-task-desc": "Busts the Kavita+ Cache - should only be used when debugging bad matches.",
|
||||
"bust-cache-task-success": "Kavita+ Cache busted",
|
||||
|
||||
"bust-locale-task": "Bust Locale Cache",
|
||||
"bust-locale-task-desc": "Busts the Locale Cache. This can fix issues with strings not showing correctly after an update",
|
||||
"bust-locale-task-success": "Locale Cache busted",
|
||||
|
||||
"clear-reading-cache-task": "Clear Reading Cache",
|
||||
"clear-reading-cache-task-desc": "Clears cached files for reading. Useful when you've just updated a file that you were previously reading within the last 24 hours.",
|
||||
"clear-reading-cache-task-success": "Cache has been cleared",
|
||||
|
72
openapi.json
72
openapi.json
@ -7,7 +7,7 @@
|
||||
"name": "GPL-3.0",
|
||||
"url": "https://github.com/Kareadita/Kavita/blob/develop/LICENSE"
|
||||
},
|
||||
"version": "0.7.8.15"
|
||||
"version": "0.7.9.0"
|
||||
},
|
||||
"servers": [
|
||||
{
|
||||
@ -9071,6 +9071,47 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/Series/next-expected": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"Series"
|
||||
],
|
||||
"summary": "Based on the delta times between when chapters are added, for series that are not Completed/Cancelled/Hiatus, forecast the next\r\ndate when it will be available.",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "seriesId",
|
||||
"in": "query",
|
||||
"description": "",
|
||||
"schema": {
|
||||
"type": "integer",
|
||||
"format": "int32"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success",
|
||||
"content": {
|
||||
"text/plain": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/NextExpectedChapterDto"
|
||||
}
|
||||
},
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/NextExpectedChapterDto"
|
||||
}
|
||||
},
|
||||
"text/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/NextExpectedChapterDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/Server/clear-cache": {
|
||||
"post": {
|
||||
"tags": [
|
||||
@ -13389,7 +13430,7 @@
|
||||
},
|
||||
"totalCount": {
|
||||
"type": "integer",
|
||||
"description": "Total number of issues or volumes in the series",
|
||||
"description": "Total number of issues or volumes in the series. This is straight from ComicInfo",
|
||||
"format": "int32"
|
||||
},
|
||||
"count": {
|
||||
@ -15637,6 +15678,31 @@
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"NextExpectedChapterDto": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"chapterNumber": {
|
||||
"type": "number",
|
||||
"format": "float"
|
||||
},
|
||||
"volumeNumber": {
|
||||
"type": "integer",
|
||||
"format": "int32"
|
||||
},
|
||||
"expectedDate": {
|
||||
"type": "string",
|
||||
"description": "Null if not applicable",
|
||||
"format": "date-time",
|
||||
"nullable": true
|
||||
},
|
||||
"title": {
|
||||
"type": "string",
|
||||
"description": "The localized title to render on the card",
|
||||
"nullable": true
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"Person": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@ -17244,7 +17310,7 @@
|
||||
},
|
||||
"totalCount": {
|
||||
"type": "integer",
|
||||
"description": "Total number of issues/volumes in the series",
|
||||
"description": "Total expected number of issues/volumes in the series from ComicInfo.xml",
|
||||
"format": "int32"
|
||||
},
|
||||
"maxCount": {
|
||||
|
Loading…
x
Reference in New Issue
Block a user