Misc Bugfixes (#2216)

* Folder watching will now appropriately ignore changes that occur in blacklisted folders.

* Fixed up recently updated from dashboard not opening a pre-sorted page. There were issues with how encoding and decoding was done plus missing code.

* Fixed up all streams from Dashboard opening to correctly filtered pages.

* All search linking now works.

* Rating tooltip and stars are bigger on mobile.

* A bit of cleanup

* Added day breakdown to user stats page.

* Removed Token checks before we write events to the history table for scrobbling.

Refactored so series holds will prevent writing events for reviews, ratings, etc.

* Fixed a potential bug where series name could be taken from a chapter that isn't the first ordered (very unlikely) for epubs.

Fixed a bug where Volume 1.5 could be selected for series-level metadata over Volume 1.

* Optimized the license check code so that users without any license entered would still take advantage of the cache layer.

* Sped up an API that checks if the library allows scrobbling

* Cleaned up the mobile CSS a bit for filters.
This commit is contained in:
Joe Milazzo 2023-08-15 16:33:39 -05:00 committed by GitHub
parent ef3e76e3e5
commit c84a3294e9
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
30 changed files with 324 additions and 246 deletions

View File

@ -802,6 +802,26 @@ public class SeriesServiceTests : AbstractDbTest
return series;
}
[Fact]
public void GetFirstChapterForMetadata_BookWithOnlyVolumeNumbers_Test()
{
var file = new MangaFileBuilder("Test.cbz", MangaFormat.Archive, 1).Build();
var series = new SeriesBuilder("Test")
.WithVolume(new VolumeBuilder("1")
.WithChapter(new ChapterBuilder("0").WithPages(1).WithFile(file).Build())
.Build())
.WithVolume(new VolumeBuilder("1.5")
.WithChapter(new ChapterBuilder("0").WithPages(2).WithFile(file).Build())
.Build())
.Build();
series.Library = new LibraryBuilder("Test LIb", LibraryType.Book).Build();
var firstChapter = SeriesService.GetFirstChapterForMetadata(series);
Assert.Equal(1, firstChapter.Pages);
}
[Fact]
public void GetFirstChapterForMetadata_Book_Test()
{

View File

@ -143,8 +143,7 @@ public class ScrobblingController : BaseApiController
[HttpGet("library-allows-scrobbling")]
public async Task<ActionResult<bool>> LibraryAllowsScrobbling(int seriesId)
{
var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(seriesId, SeriesIncludes.Library);
return Ok(series != null && series.Library.AllowScrobbling);
return Ok(await _unitOfWork.LibraryRepository.GetAllowsScrobblingBySeriesId(seriesId));
}
/// <summary>

View File

@ -125,14 +125,21 @@ public class StatsController : BaseApiController
}
[HttpGet("day-breakdown")]
[Authorize("RequireAdminRole")]
[ResponseCache(CacheProfileName = "Statistics")]
public ActionResult<IEnumerable<StatCount<DayOfWeek>>> GetDayBreakdown()
public async Task<ActionResult<IEnumerable<StatCount<DayOfWeek>>>> GetDayBreakdown(int userId = 0)
{
return Ok(_statService.GetDayBreakdown());
if (userId == 0)
{
var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername());
var isAdmin = await _unitOfWork.UserRepository.IsUserAdminAsync(user);
if (!isAdmin) return BadRequest();
}
return Ok(_statService.GetDayBreakdown(userId));
}
[HttpGet("user/reading-history")]
[ResponseCache(CacheProfileName = "Statistics")]
public async Task<ActionResult<IEnumerable<ReadHistoryEvent>>> GetReadingHistory(int userId)

View File

@ -15,7 +15,7 @@ namespace API.Data.Migrations
protected override void BuildModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "7.0.9");
modelBuilder.HasAnnotation("ProductVersion", "7.0.10");
modelBuilder.Entity("API.Entities.AppRole", b =>
{
@ -180,7 +180,7 @@ namespace API.Data.Migrations
b.HasIndex("AppUserId");
b.ToTable("AppUserBookmark");
b.ToTable("AppUserBookmark", (string)null);
});
modelBuilder.Entity("API.Entities.AppUserOnDeckRemoval", b =>
@ -201,7 +201,7 @@ namespace API.Data.Migrations
b.HasIndex("SeriesId");
b.ToTable("AppUserOnDeckRemoval");
b.ToTable("AppUserOnDeckRemoval", (string)null);
});
modelBuilder.Entity("API.Entities.AppUserPreferences", b =>
@ -315,7 +315,7 @@ namespace API.Data.Migrations
b.HasIndex("ThemeId");
b.ToTable("AppUserPreferences");
b.ToTable("AppUserPreferences", (string)null);
});
modelBuilder.Entity("API.Entities.AppUserProgress", b =>
@ -365,7 +365,7 @@ namespace API.Data.Migrations
b.HasIndex("SeriesId");
b.ToTable("AppUserProgresses");
b.ToTable("AppUserProgresses", (string)null);
});
modelBuilder.Entity("API.Entities.AppUserRating", b =>
@ -398,7 +398,7 @@ namespace API.Data.Migrations
b.HasIndex("SeriesId");
b.ToTable("AppUserRating");
b.ToTable("AppUserRating", (string)null);
});
modelBuilder.Entity("API.Entities.AppUserRole", b =>
@ -466,7 +466,7 @@ namespace API.Data.Migrations
b.HasIndex("SeriesId");
b.ToTable("AppUserTableOfContent");
b.ToTable("AppUserTableOfContent", (string)null);
});
modelBuilder.Entity("API.Entities.Chapter", b =>
@ -576,7 +576,7 @@ namespace API.Data.Migrations
b.HasIndex("VolumeId");
b.ToTable("Chapter");
b.ToTable("Chapter", (string)null);
});
modelBuilder.Entity("API.Entities.CollectionTag", b =>
@ -611,7 +611,7 @@ namespace API.Data.Migrations
b.HasIndex("Id", "Promoted")
.IsUnique();
b.ToTable("CollectionTag");
b.ToTable("CollectionTag", (string)null);
});
modelBuilder.Entity("API.Entities.Device", b =>
@ -657,7 +657,7 @@ namespace API.Data.Migrations
b.HasIndex("AppUserId");
b.ToTable("Device");
b.ToTable("Device", (string)null);
});
modelBuilder.Entity("API.Entities.FolderPath", b =>
@ -679,7 +679,7 @@ namespace API.Data.Migrations
b.HasIndex("LibraryId");
b.ToTable("FolderPath");
b.ToTable("FolderPath", (string)null);
});
modelBuilder.Entity("API.Entities.Genre", b =>
@ -699,7 +699,7 @@ namespace API.Data.Migrations
b.HasIndex("NormalizedTitle")
.IsUnique();
b.ToTable("Genre");
b.ToTable("Genre", (string)null);
});
modelBuilder.Entity("API.Entities.Library", b =>
@ -757,7 +757,7 @@ namespace API.Data.Migrations
b.HasKey("Id");
b.ToTable("Library");
b.ToTable("Library", (string)null);
});
modelBuilder.Entity("API.Entities.MangaFile", b =>
@ -806,7 +806,7 @@ namespace API.Data.Migrations
b.HasIndex("ChapterId");
b.ToTable("MangaFile");
b.ToTable("MangaFile", (string)null);
});
modelBuilder.Entity("API.Entities.MediaError", b =>
@ -841,7 +841,7 @@ namespace API.Data.Migrations
b.HasKey("Id");
b.ToTable("MediaError");
b.ToTable("MediaError", (string)null);
});
modelBuilder.Entity("API.Entities.Metadata.SeriesMetadata", b =>
@ -942,7 +942,7 @@ namespace API.Data.Migrations
b.HasIndex("Id", "SeriesId")
.IsUnique();
b.ToTable("SeriesMetadata");
b.ToTable("SeriesMetadata", (string)null);
});
modelBuilder.Entity("API.Entities.Metadata.SeriesRelation", b =>
@ -966,7 +966,7 @@ namespace API.Data.Migrations
b.HasIndex("TargetSeriesId");
b.ToTable("SeriesRelation");
b.ToTable("SeriesRelation", (string)null);
});
modelBuilder.Entity("API.Entities.Person", b =>
@ -986,7 +986,7 @@ namespace API.Data.Migrations
b.HasKey("Id");
b.ToTable("Person");
b.ToTable("Person", (string)null);
});
modelBuilder.Entity("API.Entities.ReadingList", b =>
@ -1049,7 +1049,7 @@ namespace API.Data.Migrations
b.HasIndex("AppUserId");
b.ToTable("ReadingList");
b.ToTable("ReadingList", (string)null);
});
modelBuilder.Entity("API.Entities.ReadingListItem", b =>
@ -1083,7 +1083,7 @@ namespace API.Data.Migrations
b.HasIndex("VolumeId");
b.ToTable("ReadingListItem");
b.ToTable("ReadingListItem", (string)null);
});
modelBuilder.Entity("API.Entities.Scrobble.ScrobbleError", b =>
@ -1128,7 +1128,7 @@ namespace API.Data.Migrations
b.HasIndex("SeriesId");
b.ToTable("ScrobbleError");
b.ToTable("ScrobbleError", (string)null);
});
modelBuilder.Entity("API.Entities.Scrobble.ScrobbleEvent", b =>
@ -1188,8 +1188,8 @@ namespace API.Data.Migrations
b.Property<int>("SeriesId")
.HasColumnType("INTEGER");
b.Property<int?>("VolumeNumber")
.HasColumnType("INTEGER");
b.Property<float?>("VolumeNumber")
.HasColumnType("REAL");
b.HasKey("Id");
@ -1199,7 +1199,7 @@ namespace API.Data.Migrations
b.HasIndex("SeriesId");
b.ToTable("ScrobbleEvent");
b.ToTable("ScrobbleEvent", (string)null);
});
modelBuilder.Entity("API.Entities.Scrobble.ScrobbleHold", b =>
@ -1232,7 +1232,7 @@ namespace API.Data.Migrations
b.HasIndex("SeriesId");
b.ToTable("ScrobbleHold");
b.ToTable("ScrobbleHold", (string)null);
});
modelBuilder.Entity("API.Entities.Series", b =>
@ -1328,7 +1328,7 @@ namespace API.Data.Migrations
b.HasIndex("LibraryId");
b.ToTable("Series");
b.ToTable("Series", (string)null);
});
modelBuilder.Entity("API.Entities.ServerSetting", b =>
@ -1345,7 +1345,7 @@ namespace API.Data.Migrations
b.HasKey("Key");
b.ToTable("ServerSetting");
b.ToTable("ServerSetting", (string)null);
});
modelBuilder.Entity("API.Entities.ServerStatistics", b =>
@ -1383,7 +1383,7 @@ namespace API.Data.Migrations
b.HasKey("Id");
b.ToTable("ServerStatistics");
b.ToTable("ServerStatistics", (string)null);
});
modelBuilder.Entity("API.Entities.SiteTheme", b =>
@ -1421,7 +1421,7 @@ namespace API.Data.Migrations
b.HasKey("Id");
b.ToTable("SiteTheme");
b.ToTable("SiteTheme", (string)null);
});
modelBuilder.Entity("API.Entities.Tag", b =>
@ -1441,7 +1441,7 @@ namespace API.Data.Migrations
b.HasIndex("NormalizedTitle")
.IsUnique();
b.ToTable("Tag");
b.ToTable("Tag", (string)null);
});
modelBuilder.Entity("API.Entities.Volume", b =>
@ -1477,8 +1477,8 @@ namespace API.Data.Migrations
b.Property<string>("Name")
.HasColumnType("TEXT");
b.Property<int>("Number")
.HasColumnType("INTEGER");
b.Property<float>("Number")
.HasColumnType("REAL");
b.Property<int>("Pages")
.HasColumnType("INTEGER");
@ -1493,7 +1493,7 @@ namespace API.Data.Migrations
b.HasIndex("SeriesId");
b.ToTable("Volume");
b.ToTable("Volume", (string)null);
});
modelBuilder.Entity("AppUserLibrary", b =>
@ -1508,7 +1508,7 @@ namespace API.Data.Migrations
b.HasIndex("LibrariesId");
b.ToTable("AppUserLibrary");
b.ToTable("AppUserLibrary", (string)null);
});
modelBuilder.Entity("ChapterGenre", b =>
@ -1523,7 +1523,7 @@ namespace API.Data.Migrations
b.HasIndex("GenresId");
b.ToTable("ChapterGenre");
b.ToTable("ChapterGenre", (string)null);
});
modelBuilder.Entity("ChapterPerson", b =>
@ -1538,7 +1538,7 @@ namespace API.Data.Migrations
b.HasIndex("PeopleId");
b.ToTable("ChapterPerson");
b.ToTable("ChapterPerson", (string)null);
});
modelBuilder.Entity("ChapterTag", b =>
@ -1553,7 +1553,7 @@ namespace API.Data.Migrations
b.HasIndex("TagsId");
b.ToTable("ChapterTag");
b.ToTable("ChapterTag", (string)null);
});
modelBuilder.Entity("CollectionTagSeriesMetadata", b =>
@ -1568,7 +1568,7 @@ namespace API.Data.Migrations
b.HasIndex("SeriesMetadatasId");
b.ToTable("CollectionTagSeriesMetadata");
b.ToTable("CollectionTagSeriesMetadata", (string)null);
});
modelBuilder.Entity("GenreSeriesMetadata", b =>
@ -1583,7 +1583,7 @@ namespace API.Data.Migrations
b.HasIndex("SeriesMetadatasId");
b.ToTable("GenreSeriesMetadata");
b.ToTable("GenreSeriesMetadata", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<int>", b =>
@ -1682,7 +1682,7 @@ namespace API.Data.Migrations
b.HasIndex("SeriesMetadatasId");
b.ToTable("PersonSeriesMetadata");
b.ToTable("PersonSeriesMetadata", (string)null);
});
modelBuilder.Entity("SeriesMetadataTag", b =>
@ -1697,7 +1697,7 @@ namespace API.Data.Migrations
b.HasIndex("TagsId");
b.ToTable("SeriesMetadataTag");
b.ToTable("SeriesMetadataTag", (string)null);
});
modelBuilder.Entity("API.Entities.AppUserBookmark", b =>

View File

@ -53,6 +53,7 @@ public interface ILibraryRepository
Task<IList<string>> GetAllCoverImagesAsync();
Task<IDictionary<int, LibraryType>> GetLibraryTypesForIdsAsync(IEnumerable<int> libraryIds);
Task<IList<Library>> GetAllWithCoversInDifferentEncoding(EncodeFormat encodeFormat);
Task<bool> GetAllowsScrobblingBySeriesId(int seriesId);
}
public class LibraryRepository : ILibraryRepository
@ -374,4 +375,11 @@ public class LibraryRepository : ILibraryRepository
.Where(c => !string.IsNullOrEmpty(c.CoverImage) && !c.CoverImage.EndsWith(extension))
.ToListAsync();
}
public async Task<bool> GetAllowsScrobblingBySeriesId(int seriesId)
{
return await _context.Series.Where(s => s.Id == seriesId)
.Select(s => s.Library.AllowScrobbling)
.SingleOrDefaultAsync();
}
}

View File

@ -128,15 +128,18 @@ public class LicenseService : ILicenseService
/// <remarks>Expected to be called at startup and on reoccurring basis</remarks>
public async Task ValidateLicenseStatus()
{
var provider = _cachingProviderFactory.GetCachingProvider(EasyCacheProfiles.License);
try
{
var license = await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.LicenseKey);
if (string.IsNullOrEmpty(license.Value)) return;
if (string.IsNullOrEmpty(license.Value)) {
await provider.SetAsync(CacheKey, false, _licenseCacheTimeout);
return;
}
_logger.LogInformation("Validating Kavita+ License");
var provider = _cachingProviderFactory.GetCachingProvider(EasyCacheProfiles.License);
await provider.FlushAsync();
await provider.FlushAsync();
var isValid = await IsLicenseValid(license.Value);
await provider.SetAsync(CacheKey, isValid, _licenseCacheTimeout);
@ -145,6 +148,7 @@ public class LicenseService : ILicenseService
catch (Exception ex)
{
_logger.LogError(ex, "There was an error talking with Kavita+ API for license validation. Rescheduling check in 30 mins");
await provider.SetAsync(CacheKey, false, _licenseCacheTimeout);
BackgroundJob.Schedule(() => ValidateLicenseStatus(), TimeSpan.FromMinutes(30));
}
}

View File

@ -104,7 +104,7 @@ public class ScrobblingService : IScrobblingService
/// <summary>
///
/// An automated job that will run against all user's tokens and validate if they are still active
/// </summary>
/// <remarks>This service can validate without license check as the task which calls will be guarded</remarks>
/// <returns></returns>
@ -115,6 +115,7 @@ public class ScrobblingService : IScrobblingService
foreach (var user in users)
{
if (string.IsNullOrEmpty(user.AniListAccessToken) || !_tokenService.HasTokenExpired(user.AniListAccessToken)) continue;
_logger.LogInformation("User {UserName}'s AniList token has expired! They need to regenerate it for scrobbling to work", user.UserName);
await _eventHub.SendMessageToAsync(MessageFactory.ScrobblingKeyExpired,
MessageFactory.ScrobblingKeyExpiredEvent(ScrobbleProvider.AniList), user.Id);
}
@ -184,17 +185,13 @@ public class ScrobblingService : IScrobblingService
public async Task ScrobbleReviewUpdate(int userId, int seriesId, string reviewTitle, string reviewBody)
{
if (!await _licenseService.HasActiveLicense()) return;
var token = await GetTokenForProvider(userId, ScrobbleProvider.AniList);
if (await HasTokenExpired(token, ScrobbleProvider.AniList))
{
throw new KavitaException(await _localizationService.Translate(userId, "unable-to-register-k+"));
}
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 library = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(series.LibraryId);
if (library is not {AllowScrobbling: true}) return;
if (library.Type == LibraryType.Comic) return;
_logger.LogInformation("Processing Scrobbling review event for {UserId} on {SeriesName}", userId, series.Name);
if (await CheckIfCanScrobble(userId, seriesId, series)) return;
var existingEvt = await _unitOfWork.ScrobbleRepository.GetEvent(userId, series.Id,
ScrobbleEventType.Review);
@ -229,17 +226,12 @@ public class ScrobblingService : IScrobblingService
public async Task ScrobbleRatingUpdate(int userId, int seriesId, float rating)
{
if (!await _licenseService.HasActiveLicense()) return;
var token = await GetTokenForProvider(userId, ScrobbleProvider.AniList);
if (await HasTokenExpired(token, ScrobbleProvider.AniList))
{
throw new KavitaException(await _localizationService.Translate(userId, "anilist-cred-expired"));
}
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 library = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(series.LibraryId);
if (library is not {AllowScrobbling: true}) return;
if (library.Type == LibraryType.Comic) return;
_logger.LogInformation("Processing Scrobbling rating event for {UserId} on {SeriesName}", userId, series.Name);
if (await CheckIfCanScrobble(userId, seriesId, series)) return;
var existingEvt = await _unitOfWork.ScrobbleRepository.GetEvent(userId, series.Id,
ScrobbleEventType.ScoreUpdated);
@ -273,22 +265,12 @@ public class ScrobblingService : IScrobblingService
public async Task ScrobbleReadingUpdate(int userId, int seriesId)
{
if (!await _licenseService.HasActiveLicense()) return;
var token = await GetTokenForProvider(userId, ScrobbleProvider.AniList);
if (await HasTokenExpired(token, ScrobbleProvider.AniList))
{
throw new KavitaException(await _localizationService.Translate(userId, "anilist-cred-expired"));
}
var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(seriesId, SeriesIncludes.Metadata | SeriesIncludes.Library);
if (series == null) throw new KavitaException(await _localizationService.Translate(userId, "series-doesnt-exist"));
if (await _unitOfWork.UserRepository.HasHoldOnSeries(userId, seriesId))
{
_logger.LogInformation("Series {SeriesName} is on UserId {UserId}'s hold list. Not scrobbling", series.Name, userId);
return;
}
var library = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(series.LibraryId);
if (library is not {AllowScrobbling: true}) return;
if (library.Type == LibraryType.Comic) return;
_logger.LogInformation("Processing Scrobbling reading event for {UserId} on {SeriesName}", userId, series.Name);
if (await CheckIfCanScrobble(userId, seriesId, series)) return;
var existingEvt = await _unitOfWork.ScrobbleRepository.GetEvent(userId, series.Id,
ScrobbleEventType.ChapterRead);
@ -338,17 +320,12 @@ public class ScrobblingService : IScrobblingService
public async Task ScrobbleWantToReadUpdate(int userId, int seriesId, bool onWantToRead)
{
if (!await _licenseService.HasActiveLicense()) return;
var token = await GetTokenForProvider(userId, ScrobbleProvider.AniList);
if (await HasTokenExpired(token, ScrobbleProvider.AniList))
{
throw new KavitaException(await _localizationService.Translate(userId, "anilist-cred-expired"));
}
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 library = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(series.LibraryId);
if (library is not {AllowScrobbling: true}) return;
if (library.Type == LibraryType.Comic) return;
_logger.LogInformation("Processing Scrobbling want-to-read event for {UserId} on {SeriesName}", userId, series.Name);
if (await CheckIfCanScrobble(userId, seriesId, series)) return;
var existing = await _unitOfWork.ScrobbleRepository.Exists(userId, series.Id,
onWantToRead ? ScrobbleEventType.AddWantToRead : ScrobbleEventType.RemoveWantToRead);
@ -369,6 +346,21 @@ public class ScrobblingService : IScrobblingService
_logger.LogDebug("Added Scrobbling WantToRead update on {SeriesName} with Userid {UserId} ", series.Name, userId);
}
private async Task<bool> CheckIfCanScrobble(int userId, int seriesId, Series series)
{
if (await _unitOfWork.UserRepository.HasHoldOnSeries(userId, seriesId))
{
_logger.LogInformation("Series {SeriesName} is on UserId {UserId}'s hold list. Not scrobbling", series.Name,
userId);
return true;
}
var library = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(series.LibraryId);
if (library is not {AllowScrobbling: true}) return true;
if (library.Type == LibraryType.Comic) return true;
return false;
}
private async Task<int> GetRateLimit(string license, string aniListToken)
{
if (string.IsNullOrWhiteSpace(aniListToken)) return 0;

View File

@ -65,16 +65,19 @@ public class SeriesService : ISeriesService
/// <returns></returns>
public static Chapter? GetFirstChapterForMetadata(Series series)
{
var sortedVolumes = series.Volumes.OrderBy(v => v.Number, ChapterSortComparer.Default);
var sortedVolumes = series.Volumes
.Where(v => float.TryParse(v.Name, out var parsedValue) && parsedValue != 0.0f)
.OrderBy(v => float.TryParse(v.Name, out var parsedValue) ? parsedValue : float.MaxValue);
var minVolumeNumber = sortedVolumes
.Where(v => v.Number != 0)
.MinBy(v => v.Number);
.MinBy(v => float.Parse(v.Name));
var minChapter = series.Volumes
.SelectMany(v => v.Chapters.OrderBy(c => float.Parse(c.Number), ChapterSortComparer.Default))
var allChapters = series.Volumes
.SelectMany(v => v.Chapters.OrderBy(c => float.Parse(c.Number), ChapterSortComparer.Default)).ToList();
var minChapter = allChapters
.FirstOrDefault();
if (minVolumeNumber != null && minChapter != null && float.Parse(minChapter.Number) > minVolumeNumber.Number)
if (minVolumeNumber != null && minChapter != null && float.TryParse(minChapter.Number, out var chapNum) && chapNum >= minVolumeNumber.Number)
{
return minVolumeNumber.Chapters.MinBy(c => float.Parse(c.Number), ChapterSortComparer.Default);
}

View File

@ -27,7 +27,7 @@ public interface IStatisticService
Task<IEnumerable<TopReadDto>> GetTopUsers(int days);
Task<IEnumerable<ReadHistoryEvent>> GetReadingHistory(int userId);
Task<IEnumerable<PagesReadOnADayCount<DateTime>>> ReadCountByDay(int userId = 0, int days = 0);
IEnumerable<StatCount<DayOfWeek>> GetDayBreakdown();
IEnumerable<StatCount<DayOfWeek>> GetDayBreakdown(int userId = 0);
IEnumerable<StatCount<int>> GetPagesReadCountByYear(int userId = 0);
IEnumerable<StatCount<int>> GetWordsReadCountByYear(int userId = 0);
Task UpdateServerStatistics();
@ -411,11 +411,12 @@ public class StatisticService : IStatisticService
return results.OrderBy(r => r.Value);
}
public IEnumerable<StatCount<DayOfWeek>> GetDayBreakdown()
public IEnumerable<StatCount<DayOfWeek>> GetDayBreakdown(int userId)
{
return _context.AppUserProgresses
.AsSplitQuery()
.AsNoTracking()
.WhereIf(userId > 0, p => p.AppUserId == userId)
.GroupBy(p => p.LastModified.DayOfWeek)
.OrderBy(g => g.Key)
.Select(g => new StatCount<DayOfWeek>{ Value = g.Key, Count = g.Count() })

View File

@ -229,14 +229,20 @@ public class LibraryWatcher : ILibraryWatcher
public async Task ProcessChange(string filePath, bool isDirectoryChange = false)
{
var sw = Stopwatch.StartNew();
_logger.LogDebug("[LibraryWatcher] Processing change of {FilePath}", filePath);
_logger.LogTrace("[LibraryWatcher] Processing change of {FilePath}", filePath);
try
{
// If the change occurs in a blacklisted folder path, then abort processing
if (Parser.Parser.HasBlacklistedFolderInPath(filePath))
{
return;
}
// If not a directory change AND file is not an archive or book, ignore
if (!isDirectoryChange &&
!(Parser.Parser.IsArchive(filePath) || Parser.Parser.IsBook(filePath)))
{
_logger.LogDebug("[LibraryWatcher] Change from {FilePath} is not an archive or book, ignoring change", filePath);
_logger.LogTrace("[LibraryWatcher] Change from {FilePath} is not an archive or book, ignoring change", filePath);
return;
}
@ -248,10 +254,10 @@ public class LibraryWatcher : ILibraryWatcher
.ToList();
var fullPath = GetFolder(filePath, libraryFolders);
_logger.LogDebug("Folder path: {FolderPath}", fullPath);
_logger.LogTrace("Folder path: {FolderPath}", fullPath);
if (string.IsNullOrEmpty(fullPath))
{
_logger.LogDebug("[LibraryWatcher] Change from {FilePath} could not find root level folder, ignoring change", filePath);
_logger.LogTrace("[LibraryWatcher] Change from {FilePath} could not find root level folder, ignoring change", filePath);
return;
}

View File

@ -146,7 +146,6 @@ public class ProcessSeries : IProcessSeries
_logger.LogInformation("[ScannerService] Processing series {SeriesName}", series.OriginalName);
// parsedInfos[0] is not the first volume or chapter. We need to find it using a ComicInfo check (as it uses firstParsedInfo for series sort)
// BUG: This check doesn't work for Books, as books usually have metadata on all files. (#2167)
var firstParsedInfo = parsedInfos.FirstOrDefault(p => p.ComicInfo != null, firstInfo);
UpdateVolumes(series, parsedInfos, forceUpdate);

View File

@ -112,7 +112,7 @@ export class StatisticsService {
return this.httpClient.get<Array<any>>(this.baseUrl + 'stats/reading-count-by-day?userId=' + userId + '&days=' + days);
}
getDayBreakdown() {
return this.httpClient.get<Array<StatCount<DayOfWeek>>>(this.baseUrl + 'stats/day-breakdown');
getDayBreakdown( userId = 0) {
return this.httpClient.get<Array<StatCount<DayOfWeek>>>(this.baseUrl + 'stats/day-breakdown?userId=' + userId);
}
}

View File

@ -30,7 +30,6 @@ import { BulkOperationsComponent } from '../../../cards/bulk-operations/bulk-ope
import { NgIf, DecimalPipe } from '@angular/common';
import { SideNavCompanionBarComponent } from '../../../sidenav/_components/side-nav-companion-bar/side-nav-companion-bar.component';
import {translate, TranslocoDirective} from "@ngneat/transloco";
import {FilterField} from "../../../_models/metadata/v2/filter-field";
import {SeriesFilterV2} from "../../../_models/metadata/v2/series-filter-v2";

View File

@ -5,7 +5,6 @@ import {
ChangeDetectorRef,
Component,
ContentChild,
DestroyRef,
ElementRef,
EventEmitter,
HostListener,
@ -13,7 +12,6 @@ import {
Inject,
Input,
OnChanges,
OnDestroy,
OnInit,
Output,
TemplateRef,
@ -70,12 +68,15 @@ export class CardDetailLayoutComponent implements OnInit, OnChanges {
* Any actions to exist on the header for the parent collection (library, collection)
*/
@Input() actions: ActionItem<any>[] = [];
@Input() trackByIdentity!: TrackByFunction<any>; //(index: number, item: any) => string
/**
* A trackBy to help with rendering. This is required as without it there are issues when scrolling
*/
@Input({required: true}) trackByIdentity!: TrackByFunction<any>;
@Input() filterSettings!: FilterSettings;
@Input() refresh!: EventEmitter<void>;
@Input() jumpBarKeys: Array<JumpKey> = []; // This is aprox 784 pixels tall, original keys
@Input() jumpBarKeys: Array<JumpKey> = []; // This is approx 784 pixels tall, original keys
jumpBarKeysToRender: Array<JumpKey> = []; // What is rendered on screen
@Output() itemClicked: EventEmitter<any> = new EventEmitter();
@ -115,7 +116,7 @@ export class CardDetailLayoutComponent implements OnInit, OnChanges {
ngOnInit(): void {
if (this.trackByIdentity === undefined) {
this.trackByIdentity = (index: number, item: any) => `${this.header}_${this.updateApplied}_${item?.libraryId}`;
this.trackByIdentity = (_: number, item: any) => `${this.header}_${this.updateApplied}_${item?.libraryId}`;
}
if (this.filterSettings === undefined) {

View File

@ -25,7 +25,6 @@ import {CompactNumberPipe} from "../../pipe/compact-number.pipe";
import {AgeRatingPipe} from "../../pipe/age-rating.pipe";
import {NgbTooltip} from "@ng-bootstrap/ng-bootstrap";
import {MetadataDetailComponent} from "../../series-detail/_components/metadata-detail/metadata-detail.component";
import {FilterQueryParam} from "../../shared/_services/filter-utilities.service";
import {TranslocoModule} from "@ngneat/transloco";
import {TranslocoLocaleModule} from "@ngneat/transloco-locale";
import {FilterField} from "../../_models/metadata/v2/filter-field";
@ -132,6 +131,4 @@ export class EntityInfoCardsComponent implements OnInit {
}
this.cdRef.markForCheck();
}
protected readonly FilterQueryParam = FilterQueryParam;
}

View File

@ -3,7 +3,7 @@ import {Title} from '@angular/platform-browser';
import {Router, RouterLink} from '@angular/router';
import {Observable, of, ReplaySubject} from 'rxjs';
import {debounceTime, map, shareReplay, take, tap} from 'rxjs/operators';
import {FilterQueryParam, FilterUtilitiesService} from 'src/app/shared/_services/filter-utilities.service';
import {FilterUtilitiesService} from 'src/app/shared/_services/filter-utilities.service';
import {SeriesAddedEvent} from 'src/app/_models/events/series-added-event';
import {SeriesRemovedEvent} from 'src/app/_models/events/series-removed-event';
import {Library} from 'src/app/_models/library';
@ -169,23 +169,36 @@ export class DashboardComponent implements OnInit {
handleSectionClick(sectionTitle: string) {
if (sectionTitle.toLowerCase() === 'recently updated series') {
const params: any = {};
params[FilterQueryParam.SortBy] = SortField.LastChapterAdded + ',false'; // sort by last chapter added, desc
params[FilterQueryParam.Page] = 1;
params['page'] = 1;
params['title'] = 'Recently Updated';
this.router.navigate(['all-series'], {queryParams: params});
const filter = this.filterUtilityService.createSeriesV2Filter();
if (filter.sortOptions) {
filter.sortOptions.sortField = SortField.LastChapterAdded;
filter.sortOptions.isAscending = false;
}
this.filterUtilityService.applyFilterWithParams(['all-series'], filter, params)
} else if (sectionTitle.toLowerCase() === 'on deck') {
const params: any = {};
params[FilterQueryParam.ReadStatus] = 'true,false,false';
params[FilterQueryParam.SortBy] = SortField.LastChapterAdded + ',false'; // sort by last chapter added, desc
params[FilterQueryParam.Page] = 1;
params['page'] = 1;
params['title'] = 'On Deck';
this.router.navigate(['all-series'], {queryParams: params});
const filter = this.filterUtilityService.createSeriesV2Filter();
filter.statements.push({field: FilterField.ReadProgress, comparison: FilterComparison.GreaterThan, value: '0'});
if (filter.sortOptions) {
filter.sortOptions.sortField = SortField.LastChapterAdded;
filter.sortOptions.isAscending = false;
}
this.filterUtilityService.applyFilterWithParams(['all-series'], filter, params)
}else if (sectionTitle.toLowerCase() === 'newly added series') {
const params: any = {};
params[FilterQueryParam.SortBy] = SortField.Created + ',false'; // sort by created, desc
params[FilterQueryParam.Page] = 1;
params['page'] = 1;
params['title'] = 'Newly Added';
this.router.navigate(['all-series'], {queryParams: params});
const filter = this.filterUtilityService.createSeriesV2Filter();
if (filter.sortOptions) {
filter.sortOptions.sortField = SortField.Created;
filter.sortOptions.isAscending = false;
}
this.filterUtilityService.applyFilterWithParams(['all-series'], filter, params)
}
}

View File

@ -25,13 +25,13 @@
</div>
<form [formGroup]="sortGroup" class="container-fluid">
<div class="row mb-3">
<div class="col-md-2">
<div class="col-md-2 col-sm-2">
<div class="form-group pe-1">
<label for="limit-to" class="form-label">{{t('limit-label')}}</label>
<input id="limit-to" type="number" inputmode="numeric" class="form-control" formControlName="limitTo">
</div>
</div>
<div class="col-md-3">
<div class="col-md-3 col-sm-10">
<label for="sort-options" class="form-label">{{t('sort-by-label')}}</label>
<button class="btn btn-sm btn-secondary-outline" (click)="updateSortOrder()" style="height: 25px; padding-bottom: 0;" [disabled]="filterSettings.sortDisabled">
<i class="fa fa-arrow-up" [title]="t('ascending-alt')" *ngIf="isAscendingSort; else descSort"></i>
@ -43,15 +43,23 @@
<option *ngFor="let field of allSortFields" [value]="field">{{field | sortField}}</option>
</select>
</div>
<!-- TODO: I might want to put a Clear button which blanks out the whole filter -->
<div class="col-md-2 me-3 mt-4">
<button class="btn btn-secondary col-12" (click)="clear()">{{t('reset')}}</button>
</div>
<div class="col-md-2 me-3 mt-4">
<button class="btn btn-primary col-12" (click)="apply()">{{t('apply')}}</button>
</div>
</div>
<ng-container *ngIf="utilityService.getActiveBreakpoint() > Breakpoint.Tablet" [ngTemplateOutlet]="buttons"></ng-container>
</div>
<div class="row mb-3" *ngIf="utilityService.getActiveBreakpoint() <= Breakpoint.Tablet">
<ng-container [ngTemplateOutlet]="buttons"></ng-container>
</div>
</form>
</div>
</ng-template>
<ng-template #buttons>
<!-- TODO: I might want to put a Clear button which blanks out the whole filter -->
<div class="col-md-2 col-sm-6 mt-4">
<button class="btn btn-secondary col-12" (click)="clear()">{{t('reset')}}</button>
</div>
<div class="col-md-2 col-sm-6 mt-4">
<button class="btn btn-primary col-12" (click)="apply()">{{t('apply')}}</button>
</div>
</ng-template>
</ng-container>

View File

@ -55,6 +55,7 @@ export class MetadataFilterComponent implements OnInit {
@ContentChild('[ngbCollapse]') collapse!: NgbCollapse;
private readonly destroyRef = inject(DestroyRef);
public readonly utilityService = inject(UtilityService);
/**
@ -82,10 +83,7 @@ export class MetadataFilterComponent implements OnInit {
private readonly cdRef = inject(ChangeDetectorRef);
constructor(private utilityService: UtilityService,
public toggleService: ToggleService,
private filterUtilityService: FilterUtilitiesService) {
}
constructor(public toggleService: ToggleService) {}
ngOnInit(): void {
if (this.filterSettings === undefined) {
@ -203,4 +201,5 @@ export class MetadataFilterComponent implements OnInit {
this.toggleService.set(!this.filteringCollapsed);
}
protected readonly Breakpoint = Breakpoint;
}

View File

@ -79,7 +79,7 @@
</ng-template>
<ng-template #tagTemplate let-item>
<div style="display: flex;padding: 5px;" (click)="goTo('tags', item.id)">
<div style="display: flex;padding: 5px;" (click)="goToOther(FilterField.Tags, item.id)">
<div class="ms-1">
<span>{{item.title}}</span>
</div>
@ -97,7 +97,7 @@
</ng-template>
<ng-template #genreTemplate let-item>
<div style="display: flex;padding: 5px;" class="clickable" (click)="goTo('genres', item.id)">
<div style="display: flex;padding: 5px;" class="clickable" (click)="goToOther(FilterField.Genres, item.id)">
<div class="ms-1">
<div [innerHTML]="item.title"></div>
</div>

View File

@ -1,40 +1,44 @@
import { DOCUMENT, NgIf, NgOptimizedImage, AsyncPipe } from '@angular/common';
import {AsyncPipe, DOCUMENT, NgIf, NgOptimizedImage} from '@angular/common';
import {
ChangeDetectionStrategy,
ChangeDetectorRef,
Component, DestroyRef,
Component,
DestroyRef,
ElementRef,
inject,
Inject,
OnInit,
ViewChild
} from '@angular/core';
import { NavigationEnd, Router, RouterLink, RouterLinkActive } from '@angular/router';
import { fromEvent } from 'rxjs';
import { debounceTime, distinctUntilChanged, filter, tap } from 'rxjs/operators';
import { FilterQueryParam } from 'src/app/shared/_services/filter-utilities.service';
import { Chapter } from 'src/app/_models/chapter';
import { CollectionTag } from 'src/app/_models/collection-tag';
import { Library } from 'src/app/_models/library';
import { MangaFile } from 'src/app/_models/manga-file';
import { PersonRole } from 'src/app/_models/metadata/person';
import { ReadingList } from 'src/app/_models/reading-list';
import { SearchResult } from 'src/app/_models/search/search-result';
import { SearchResultGroup } from 'src/app/_models/search/search-result-group';
import { AccountService } from 'src/app/_services/account.service';
import { ImageService } from 'src/app/_services/image.service';
import { NavService } from 'src/app/_services/nav.service';
import { ScrollService } from 'src/app/_services/scroll.service';
import { SearchService } from 'src/app/_services/search.service';
import {NavigationEnd, Router, RouterLink, RouterLinkActive} from '@angular/router';
import {fromEvent} from 'rxjs';
import {debounceTime, distinctUntilChanged, filter, tap} from 'rxjs/operators';
import {Chapter} from 'src/app/_models/chapter';
import {CollectionTag} from 'src/app/_models/collection-tag';
import {Library} from 'src/app/_models/library';
import {MangaFile} from 'src/app/_models/manga-file';
import {PersonRole} from 'src/app/_models/metadata/person';
import {ReadingList} from 'src/app/_models/reading-list';
import {SearchResult} from 'src/app/_models/search/search-result';
import {SearchResultGroup} from 'src/app/_models/search/search-result-group';
import {AccountService} from 'src/app/_services/account.service';
import {ImageService} from 'src/app/_services/image.service';
import {NavService} from 'src/app/_services/nav.service';
import {ScrollService} from 'src/app/_services/scroll.service';
import {SearchService} from 'src/app/_services/search.service';
import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
import { SentenceCasePipe } from '../../../pipe/sentence-case.pipe';
import { PersonRolePipe } from '../../../pipe/person-role.pipe';
import { NgbDropdown, NgbDropdownToggle, NgbDropdownMenu, NgbDropdownItem } from '@ng-bootstrap/ng-bootstrap';
import { EventsWidgetComponent } from '../events-widget/events-widget.component';
import { SeriesFormatComponent } from '../../../shared/series-format/series-format.component';
import { ImageComponent } from '../../../shared/image/image.component';
import { GroupedTypeaheadComponent } from '../grouped-typeahead/grouped-typeahead.component';
import {SentenceCasePipe} from '../../../pipe/sentence-case.pipe';
import {PersonRolePipe} from '../../../pipe/person-role.pipe';
import {NgbDropdown, NgbDropdownItem, NgbDropdownMenu, NgbDropdownToggle} from '@ng-bootstrap/ng-bootstrap';
import {EventsWidgetComponent} from '../events-widget/events-widget.component';
import {SeriesFormatComponent} from '../../../shared/series-format/series-format.component';
import {ImageComponent} from '../../../shared/image/image.component';
import {GroupedTypeaheadComponent} from '../grouped-typeahead/grouped-typeahead.component';
import {TranslocoDirective} from "@ngneat/transloco";
import {FilterUtilitiesService} from "../../../shared/_services/filter-utilities.service";
import {FilterStatement} from "../../../_models/metadata/v2/filter-statement";
import {FilterField} from "../../../_models/metadata/v2/filter-field";
import {FilterComparison} from "../../../_models/metadata/v2/filter-comparison";
@Component({
selector: 'app-nav-header',
@ -58,10 +62,12 @@ export class NavHeaderComponent implements OnInit {
backToTopNeeded = false;
searchFocused: boolean = false;
scrollElem: HTMLElement;
protected readonly FilterField = FilterField;
constructor(public accountService: AccountService, private router: Router, public navService: NavService,
public imageService: ImageService, @Inject(DOCUMENT) private document: Document,
private scrollService: ScrollService, private searchService: SearchService, private readonly cdRef: ChangeDetectorRef) {
private scrollService: ScrollService, private searchService: SearchService, private readonly cdRef: ChangeDetectorRef,
private filterUtilityService: FilterUtilitiesService) {
this.scrollElem = this.document.body;
}
@ -69,12 +75,11 @@ export class NavHeaderComponent implements OnInit {
this.scrollService.scrollContainer$.pipe(distinctUntilChanged(), takeUntilDestroyed(this.destroyRef), tap((scrollContainer) => {
if (scrollContainer === 'body' || scrollContainer === undefined) {
this.scrollElem = this.document.body;
fromEvent(this.document.body, 'scroll').pipe(debounceTime(20)).subscribe(() => this.checkBackToTopNeeded(this.document.body));
} else {
const elem = scrollContainer as ElementRef<HTMLDivElement>;
this.scrollElem = elem.nativeElement;
fromEvent(elem.nativeElement, 'scroll').pipe(debounceTime(20)).subscribe(() => this.checkBackToTopNeeded(elem.nativeElement));
}
fromEvent(this.scrollElem, 'scroll').pipe(debounceTime(20)).subscribe(() => this.checkBackToTopNeeded(this.scrollElem));
})).subscribe();
// Sometimes the top event emitter can be slow, so let's also check when a navigation occurs and recalculate
@ -125,49 +130,54 @@ export class NavHeaderComponent implements OnInit {
});
}
goTo(queryParamName: string, filter: any) {
goTo(statement: FilterStatement) {
let params: any = {};
params[queryParamName] = filter;
params[FilterQueryParam.Page] = 1;
const filter = this.filterUtilityService.createSeriesV2Filter();
filter.statements = [statement];
params['page'] = 1;
this.clearSearch();
this.router.navigate(['all-series'], {queryParams: params});
this.filterUtilityService.applyFilterWithParams(['all-series'], filter, params);
}
goToOther(field: FilterField, value: string) {
this.goTo({field, comparison: FilterComparison.Equal, value});
}
goToPerson(role: PersonRole, filter: any) {
this.clearSearch();
switch(role) {
case PersonRole.Writer:
this.goTo(FilterQueryParam.Writers, filter);
this.goTo({field: FilterField.Writers, comparison: FilterComparison.Equal, value: filter});
break;
case PersonRole.Artist:
this.goTo(FilterQueryParam.Artists, filter);
this.goTo({field: FilterField.CoverArtist, comparison: FilterComparison.Equal, value: filter}); // TODO: What is this supposed to be?
break;
case PersonRole.Character:
this.goTo(FilterQueryParam.Character, filter);
this.goTo({field: FilterField.Characters, comparison: FilterComparison.Equal, value: filter});
break;
case PersonRole.Colorist:
this.goTo(FilterQueryParam.Colorist, filter);
this.goTo({field: FilterField.Colorist, comparison: FilterComparison.Equal, value: filter});
break;
case PersonRole.Editor:
this.goTo(FilterQueryParam.Editor, filter);
this.goTo({field: FilterField.Editor, comparison: FilterComparison.Equal, value: filter});
break;
case PersonRole.Inker:
this.goTo(FilterQueryParam.Inker, filter);
this.goTo({field: FilterField.Inker, comparison: FilterComparison.Equal, value: filter});
break;
case PersonRole.CoverArtist:
this.goTo(FilterQueryParam.CoverArtists, filter);
this.goTo({field: FilterField.CoverArtist, comparison: FilterComparison.Equal, value: filter});
break;
case PersonRole.Letterer:
this.goTo(FilterQueryParam.Letterer, filter);
this.goTo({field: FilterField.Letterer, comparison: FilterComparison.Equal, value: filter});
break;
case PersonRole.Penciller:
this.goTo(FilterQueryParam.Penciller, filter);
this.goTo({field: FilterField.Penciller, comparison: FilterComparison.Equal, value: filter});
break;
case PersonRole.Publisher:
this.goTo(FilterQueryParam.Publisher, filter);
this.goTo({field: FilterField.Publisher, comparison: FilterComparison.Equal, value: filter});
break;
case PersonRole.Translator:
this.goTo(FilterQueryParam.Translator, filter);
this.goTo({field: FilterField.Translators, comparison: FilterComparison.Equal, value: filter});
break;
}
}
@ -232,4 +242,6 @@ export class NavHeaderComponent implements OnInit {
hideSideNav() {
this.navService.toggleSideNav();
}
}

View File

@ -1,6 +1,6 @@
<div class="row g-0">
<div class="col-auto custom-col clickable" [ngbPopover]="popContent"
popoverTitle="Your Rating + Overall" popoverClass="md-popover">
popoverTitle="Your Rating + Overall" [popoverClass]="utilityService.getActiveBreakpoint() > Breakpoint.Mobile ? 'md-popover' : 'lg-popover'">
<span class="badge rounded-pill ps-0 me-1">
<img class="me-1" ngSrc="assets/images/logo-32.png" width="24" height="24" alt="">
<ng-container *ngIf="hasUserRated; else notYetRated">{{userRating * 20}}</ng-container>
@ -23,7 +23,7 @@
</div>
<ng-template #popContent>
<ngx-stars [initialStars]="userRating" (ratingOutput)="updateRating($event)"
<ngx-stars [initialStars]="userRating" (ratingOutput)="updateRating($event)" [size]="utilityService.getActiveBreakpoint() > Breakpoint.Mobile ? 1 : 2"
[maxStars]="5" [color]="starColor"></ngx-stars>
{{userRating * 20}}%
</ng-template>

View File

@ -19,6 +19,14 @@
}
}
.lg-popover {
width: 320px;
> .popover-body {
padding-top: 0px;
}
}
.rating-star {
i {
position: relative;

View File

@ -18,6 +18,7 @@ import {LibraryType} from "../../../_models/library";
import {ProviderNamePipe} from "../../../pipe/provider-name.pipe";
import {NgxStarsModule} from "ngx-stars";
import {ThemeService} from "../../../_services/theme.service";
import {Breakpoint, UtilityService} from "../../../shared/_services/utility.service";
@Component({
selector: 'app-external-rating',
@ -37,6 +38,7 @@ export class ExternalRatingComponent implements OnInit {
private readonly seriesService = inject(SeriesService);
private readonly accountService = inject(AccountService);
private readonly themeService = inject(ThemeService);
public readonly utilityService = inject(UtilityService);
ratings: Array<Rating> = [];
isLoading: boolean = false;
@ -71,4 +73,6 @@ export class ExternalRatingComponent implements OnInit {
this.cdRef.markForCheck();
});
}
protected readonly Breakpoint = Breakpoint;
}

View File

@ -3,8 +3,7 @@ import {CommonModule} from '@angular/common';
import {A11yClickDirective} from "../../../shared/a11y-click.directive";
import {BadgeExpanderComponent} from "../../../shared/badge-expander/badge-expander.component";
import {TagBadgeComponent, TagBadgeCursor} from "../../../shared/tag-badge/tag-badge.component";
import {FilterQueryParam, FilterUtilitiesService} from "../../../shared/_services/filter-utilities.service";
import {Router} from "@angular/router";
import {FilterUtilitiesService} from "../../../shared/_services/filter-utilities.service";
import {FilterComparison} from "../../../_models/metadata/v2/filter-comparison";
import {FilterField} from "../../../_models/metadata/v2/filter-field";
@ -25,7 +24,6 @@ export class MetadataDetailComponent {
@ContentChild('titleTemplate') titleTemplate!: TemplateRef<any>;
@ContentChild('itemTemplate') itemTemplate?: TemplateRef<any>;
private readonly router = inject(Router);
private readonly filterUtilitiesService = inject(FilterUtilitiesService);
protected readonly TagBadgeCursor = TagBadgeCursor;

View File

@ -1,5 +1,5 @@
import {Injectable} from '@angular/core';
import {ActivatedRouteSnapshot, Router} from '@angular/router';
import {ActivatedRouteSnapshot, Params, Router} from '@angular/router';
import {Pagination} from 'src/app/_models/pagination';
import {SortField, SortOptions} from 'src/app/_models/metadata/series-filter';
import {MetadataService} from "../../_services/metadata.service";
@ -9,50 +9,17 @@ import {FilterCombination} from "../../_models/metadata/v2/filter-combination";
import {FilterField} from "../../_models/metadata/v2/filter-field";
import {FilterComparison} from "../../_models/metadata/v2/filter-comparison";
/**
* Used to pass state between the filter and the url
*/
export enum FilterQueryParam {
Format = 'format',
Genres = 'genres',
AgeRating = 'ageRating',
PublicationStatus = 'publicationStatus',
Tags = 'tags',
Languages = 'languages',
CollectionTags = 'collectionTags',
Libraries = 'libraries',
Writers = 'writers',
Artists = 'artists',
Character = 'character',
Colorist = 'colorist',
CoverArtists = 'coverArtists',
Editor = 'editor',
Inker = 'inker',
Letterer = 'letterer',
Penciller = 'penciller',
Publisher = 'publisher',
Translator = 'translators',
ReadStatus = 'readStatus',
SortBy = 'sortBy',
Rating = 'rating',
Name = 'name',
/**
* This is a pagination control
*/
Page = 'page',
/**
* Special case for the UI. Does not trigger filtering
*/
None = 'none'
}
const sortOptionsKey = 'sortOptions=';
const statementsKey = 'stmts=';
const limitToKey = 'limitTo=';
const combinationKey = 'combination=';
@Injectable({
providedIn: 'root'
})
export class FilterUtilitiesService {
constructor(private metadataService: MetadataService, private router: Router) {
}
constructor(private metadataService: MetadataService, private router: Router) {}
applyFilter(page: Array<any>, filter: FilterField, comparison: FilterComparison, value: string) {
const dto: SeriesFilterV2 = {
@ -61,8 +28,14 @@ export class FilterUtilitiesService {
limitTo: 0
};
console.log('applying filter: ', this.urlFromFilterV2(page.join('/') + '?', dto))
this.router.navigateByUrl(this.urlFromFilterV2(page.join('/') + '?', dto));
const url = this.urlFromFilterV2(page.join('/') + '?', dto);
return this.router.navigateByUrl(url);
}
applyFilterWithParams(page: Array<any>, filter: SeriesFilterV2, extraParams: Params) {
let url = this.urlFromFilterV2(page.join('/') + '?', filter);
url += Object.keys(extraParams).map(k => `&${k}=${extraParams[k]}`).join('');
return this.router.navigateByUrl(url, extraParams);
}
/**
@ -84,7 +57,7 @@ export class FilterUtilitiesService {
/**
* Will fetch current page from route if present
* @param ActivatedRouteSnapshot to fetch page from. Must be from component else may get stale data
* @param snapshot to fetch page from. Must be from component else may get stale data
* @param itemsPerPage If you want pagination, pass non-zero number
* @returns A default pagination object
*/
@ -107,10 +80,10 @@ export class FilterUtilitiesService {
encodeSeriesFilter(filter: SeriesFilterV2) {
const encodedStatements = this.encodeFilterStatements(filter.statements);
const encodedSortOptions = filter.sortOptions ? `sortOptions=${this.encodeSortOptions(filter.sortOptions)}` : '';
const encodedLimitTo = `limitTo=${filter.limitTo}`;
const encodedSortOptions = filter.sortOptions ? `${sortOptionsKey}${this.encodeSortOptions(filter.sortOptions)}` : '';
const encodedLimitTo = `${limitToKey}${filter.limitTo}`;
return `${this.encodeName(filter.name)}stmts=${encodedStatements}&${encodedSortOptions}&${encodedLimitTo}&combination=${filter.combination}`;
return `${this.encodeName(filter.name)}${encodedStatements}&${encodedSortOptions}&${encodedLimitTo}&${combinationKey}${filter.combination}`;
}
encodeName(name: string | undefined) {
@ -124,7 +97,8 @@ export class FilterUtilitiesService {
}
encodeFilterStatements(statements: Array<FilterStatement>) {
return encodeURIComponent(statements.map(statement => {
if (statements.length === 0) return '';
return statementsKey + encodeURIComponent(statements.map(statement => {
const encodedComparison = `comparison=${statement.comparison}`;
const encodedField = `field=${statement.field}`;
const encodedValue = `value=${encodeURIComponent(statement.value)}`;
@ -144,19 +118,23 @@ export class FilterUtilitiesService {
}
const fullUrl = window.location.href.split('?')[1];
const stmtsStartIndex = fullUrl.indexOf('stmts=');
let endIndex = fullUrl.indexOf('&sortOptions=');
const stmtsStartIndex = fullUrl.indexOf(statementsKey);
let endIndex = fullUrl.indexOf('&' + sortOptionsKey);
if (endIndex < 0) {
endIndex = fullUrl.indexOf('&limitTo=');
endIndex = fullUrl.indexOf('&' + limitToKey);
}
if (stmtsStartIndex !== -1 && endIndex !== -1) {
const stmtsEncoded = fullUrl.substring(stmtsStartIndex + 6, endIndex);
if (stmtsStartIndex !== -1 || endIndex !== -1) {
// +1 is for the =
const stmtsEncoded = fullUrl.substring(stmtsStartIndex + statementsKey.length, endIndex);
filter.statements = this.decodeFilterStatements(stmtsEncoded);
}
if (queryParams.sortOptions) {
const sortOptions = this.decodeSortOptions(queryParams.sortOptions);
const optionsStartIndex = fullUrl.indexOf('&' + sortOptionsKey);
const endIndex = fullUrl.indexOf('&' + limitToKey);
const sortOptionsEncoded = fullUrl.substring(optionsStartIndex + sortOptionsKey.length + 1, endIndex);
const sortOptions = this.decodeSortOptions(sortOptionsEncoded);
if (sortOptions) {
filter.sortOptions = sortOptions;
}
@ -174,7 +152,7 @@ export class FilterUtilitiesService {
}
decodeSortOptions(encodedSortOptions: string): SortOptions | null {
const parts = encodedSortOptions.split('&');
const parts = decodeURIComponent(encodedSortOptions).split('&');
const sortFieldPart = parts.find(part => part.startsWith('sortField='));
const isAscendingPart = parts.find(part => part.startsWith('isAscending='));
@ -188,7 +166,7 @@ export class FilterUtilitiesService {
}
decodeFilterStatements(encodedStatements: string): FilterStatement[] {
const statementStrings = decodeURIComponent(encodedStatements).split(','); // I don't think this will wrk
const statementStrings = decodeURIComponent(encodedStatements).split(',');
return statementStrings.map(statementString => {
const parts = statementString.split('&');
if (parts === null || parts.length < 3) return null;

View File

@ -1,4 +1,4 @@
import {ChangeDetectionStrategy, Component, DestroyRef, inject} from '@angular/core';
import {ChangeDetectionStrategy, Component, DestroyRef, inject, Input, OnInit} from '@angular/core';
import {FormControl} from '@angular/forms';
import { BarChartModule } from '@swimlane/ngx-charts';
import {map, Observable} from 'rxjs';
@ -18,8 +18,9 @@ import {TranslocoDirective} from "@ngneat/transloco";
standalone: true,
imports: [BarChartModule, AsyncPipe, TranslocoDirective]
})
export class DayBreakdownComponent {
export class DayBreakdownComponent implements OnInit {
@Input() userId = 0;
view: [number, number] = [0,0];
showLegend: boolean = true;
@ -27,9 +28,11 @@ export class DayBreakdownComponent {
dayBreakdown$!: Observable<Array<PieDataItem>>;
private readonly destroyRef = inject(DestroyRef);
constructor(private statService: StatisticsService) {
constructor(private statService: StatisticsService) {}
ngOnInit() {
const dayOfWeekPipe = new DayOfWeekPipe();
this.dayBreakdown$ = this.statService.getDayBreakdown().pipe(
this.dayBreakdown$ = this.statService.getDayBreakdown(this.userId).pipe(
map((data: Array<StatCount<DayOfWeek>>) => {
return data.map(d => {
return {name: dayOfWeekPipe.transform(d.value), value: d.count};

View File

@ -2,7 +2,7 @@ import {ChangeDetectionStrategy, Component, DestroyRef, HostListener, inject} fr
import {Router} from '@angular/router';
import {NgbModal} from '@ng-bootstrap/ng-bootstrap';
import {map, Observable, ReplaySubject, shareReplay} from 'rxjs';
import {FilterQueryParam, FilterUtilitiesService} from 'src/app/shared/_services/filter-utilities.service';
import {FilterUtilitiesService} from 'src/app/shared/_services/filter-utilities.service';
import {Breakpoint, UtilityService} from 'src/app/shared/_services/utility.service';
import {Series} from 'src/app/_models/series';
import {ImageService} from 'src/app/_services/image.service';

View File

@ -14,6 +14,12 @@
</div>
</div>
<div class="row g-0 pt-4 pb-2" style="height: 242px">
<div class="col-md-12 col-sm-12 mt-4 pt-2">
<app-day-breakdown [userId]="userId"></app-day-breakdown>
</div>
</div>
<div class="row g-0 pt-4 pb-2 " style="height: 242px">
<app-stat-list [data$]="percentageRead$" [label]="t('read-percentage')" [title]="t('library-read-progress-title')"></app-stat-list>
</div>

View File

@ -13,6 +13,7 @@ import {StatListComponent} from '../stat-list/stat-list.component';
import {ReadingActivityComponent} from '../reading-activity/reading-activity.component';
import {UserStatsInfoCardsComponent} from '../user-stats-info-cards/user-stats-info-cards.component';
import {TranslocoModule} from "@ngneat/transloco";
import {DayBreakdownComponent} from "../day-breakdown/day-breakdown.component";
@Component({
selector: 'app-user-stats',
@ -27,6 +28,7 @@ import {TranslocoModule} from "@ngneat/transloco";
StatListComponent,
AsyncPipe,
TranslocoModule,
DayBreakdownComponent,
],
})
export class UserStatsComponent implements OnInit {

View File

@ -7,7 +7,7 @@
"name": "GPL-3.0",
"url": "https://github.com/Kareadita/Kavita/blob/develop/LICENSE"
},
"version": "0.7.7.9"
"version": "0.7.7.10"
},
"servers": [
{
@ -9855,6 +9855,17 @@
"tags": [
"Stats"
],
"parameters": [
{
"name": "userId",
"in": "query",
"schema": {
"type": "integer",
"format": "int32",
"default": 0
}
}
],
"responses": {
"200": {
"description": "Success",