mirror of
https://github.com/Kareadita/Kavita.git
synced 2025-05-31 04:04:19 -04:00
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:
parent
ef3e76e3e5
commit
c84a3294e9
@ -802,6 +802,26 @@ public class SeriesServiceTests : AbstractDbTest
|
|||||||
return series;
|
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]
|
[Fact]
|
||||||
public void GetFirstChapterForMetadata_Book_Test()
|
public void GetFirstChapterForMetadata_Book_Test()
|
||||||
{
|
{
|
||||||
|
@ -143,8 +143,7 @@ public class ScrobblingController : BaseApiController
|
|||||||
[HttpGet("library-allows-scrobbling")]
|
[HttpGet("library-allows-scrobbling")]
|
||||||
public async Task<ActionResult<bool>> LibraryAllowsScrobbling(int seriesId)
|
public async Task<ActionResult<bool>> LibraryAllowsScrobbling(int seriesId)
|
||||||
{
|
{
|
||||||
var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(seriesId, SeriesIncludes.Library);
|
return Ok(await _unitOfWork.LibraryRepository.GetAllowsScrobblingBySeriesId(seriesId));
|
||||||
return Ok(series != null && series.Library.AllowScrobbling);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
@ -125,14 +125,21 @@ public class StatsController : BaseApiController
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("day-breakdown")]
|
[HttpGet("day-breakdown")]
|
||||||
[Authorize("RequireAdminRole")]
|
|
||||||
[ResponseCache(CacheProfileName = "Statistics")]
|
[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")]
|
[HttpGet("user/reading-history")]
|
||||||
[ResponseCache(CacheProfileName = "Statistics")]
|
[ResponseCache(CacheProfileName = "Statistics")]
|
||||||
public async Task<ActionResult<IEnumerable<ReadHistoryEvent>>> GetReadingHistory(int userId)
|
public async Task<ActionResult<IEnumerable<ReadHistoryEvent>>> GetReadingHistory(int userId)
|
||||||
|
@ -15,7 +15,7 @@ namespace API.Data.Migrations
|
|||||||
protected override void BuildModel(ModelBuilder modelBuilder)
|
protected override void BuildModel(ModelBuilder modelBuilder)
|
||||||
{
|
{
|
||||||
#pragma warning disable 612, 618
|
#pragma warning disable 612, 618
|
||||||
modelBuilder.HasAnnotation("ProductVersion", "7.0.9");
|
modelBuilder.HasAnnotation("ProductVersion", "7.0.10");
|
||||||
|
|
||||||
modelBuilder.Entity("API.Entities.AppRole", b =>
|
modelBuilder.Entity("API.Entities.AppRole", b =>
|
||||||
{
|
{
|
||||||
@ -180,7 +180,7 @@ namespace API.Data.Migrations
|
|||||||
|
|
||||||
b.HasIndex("AppUserId");
|
b.HasIndex("AppUserId");
|
||||||
|
|
||||||
b.ToTable("AppUserBookmark");
|
b.ToTable("AppUserBookmark", (string)null);
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("API.Entities.AppUserOnDeckRemoval", b =>
|
modelBuilder.Entity("API.Entities.AppUserOnDeckRemoval", b =>
|
||||||
@ -201,7 +201,7 @@ namespace API.Data.Migrations
|
|||||||
|
|
||||||
b.HasIndex("SeriesId");
|
b.HasIndex("SeriesId");
|
||||||
|
|
||||||
b.ToTable("AppUserOnDeckRemoval");
|
b.ToTable("AppUserOnDeckRemoval", (string)null);
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("API.Entities.AppUserPreferences", b =>
|
modelBuilder.Entity("API.Entities.AppUserPreferences", b =>
|
||||||
@ -315,7 +315,7 @@ namespace API.Data.Migrations
|
|||||||
|
|
||||||
b.HasIndex("ThemeId");
|
b.HasIndex("ThemeId");
|
||||||
|
|
||||||
b.ToTable("AppUserPreferences");
|
b.ToTable("AppUserPreferences", (string)null);
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("API.Entities.AppUserProgress", b =>
|
modelBuilder.Entity("API.Entities.AppUserProgress", b =>
|
||||||
@ -365,7 +365,7 @@ namespace API.Data.Migrations
|
|||||||
|
|
||||||
b.HasIndex("SeriesId");
|
b.HasIndex("SeriesId");
|
||||||
|
|
||||||
b.ToTable("AppUserProgresses");
|
b.ToTable("AppUserProgresses", (string)null);
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("API.Entities.AppUserRating", b =>
|
modelBuilder.Entity("API.Entities.AppUserRating", b =>
|
||||||
@ -398,7 +398,7 @@ namespace API.Data.Migrations
|
|||||||
|
|
||||||
b.HasIndex("SeriesId");
|
b.HasIndex("SeriesId");
|
||||||
|
|
||||||
b.ToTable("AppUserRating");
|
b.ToTable("AppUserRating", (string)null);
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("API.Entities.AppUserRole", b =>
|
modelBuilder.Entity("API.Entities.AppUserRole", b =>
|
||||||
@ -466,7 +466,7 @@ namespace API.Data.Migrations
|
|||||||
|
|
||||||
b.HasIndex("SeriesId");
|
b.HasIndex("SeriesId");
|
||||||
|
|
||||||
b.ToTable("AppUserTableOfContent");
|
b.ToTable("AppUserTableOfContent", (string)null);
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("API.Entities.Chapter", b =>
|
modelBuilder.Entity("API.Entities.Chapter", b =>
|
||||||
@ -576,7 +576,7 @@ namespace API.Data.Migrations
|
|||||||
|
|
||||||
b.HasIndex("VolumeId");
|
b.HasIndex("VolumeId");
|
||||||
|
|
||||||
b.ToTable("Chapter");
|
b.ToTable("Chapter", (string)null);
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("API.Entities.CollectionTag", b =>
|
modelBuilder.Entity("API.Entities.CollectionTag", b =>
|
||||||
@ -611,7 +611,7 @@ namespace API.Data.Migrations
|
|||||||
b.HasIndex("Id", "Promoted")
|
b.HasIndex("Id", "Promoted")
|
||||||
.IsUnique();
|
.IsUnique();
|
||||||
|
|
||||||
b.ToTable("CollectionTag");
|
b.ToTable("CollectionTag", (string)null);
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("API.Entities.Device", b =>
|
modelBuilder.Entity("API.Entities.Device", b =>
|
||||||
@ -657,7 +657,7 @@ namespace API.Data.Migrations
|
|||||||
|
|
||||||
b.HasIndex("AppUserId");
|
b.HasIndex("AppUserId");
|
||||||
|
|
||||||
b.ToTable("Device");
|
b.ToTable("Device", (string)null);
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("API.Entities.FolderPath", b =>
|
modelBuilder.Entity("API.Entities.FolderPath", b =>
|
||||||
@ -679,7 +679,7 @@ namespace API.Data.Migrations
|
|||||||
|
|
||||||
b.HasIndex("LibraryId");
|
b.HasIndex("LibraryId");
|
||||||
|
|
||||||
b.ToTable("FolderPath");
|
b.ToTable("FolderPath", (string)null);
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("API.Entities.Genre", b =>
|
modelBuilder.Entity("API.Entities.Genre", b =>
|
||||||
@ -699,7 +699,7 @@ namespace API.Data.Migrations
|
|||||||
b.HasIndex("NormalizedTitle")
|
b.HasIndex("NormalizedTitle")
|
||||||
.IsUnique();
|
.IsUnique();
|
||||||
|
|
||||||
b.ToTable("Genre");
|
b.ToTable("Genre", (string)null);
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("API.Entities.Library", b =>
|
modelBuilder.Entity("API.Entities.Library", b =>
|
||||||
@ -757,7 +757,7 @@ namespace API.Data.Migrations
|
|||||||
|
|
||||||
b.HasKey("Id");
|
b.HasKey("Id");
|
||||||
|
|
||||||
b.ToTable("Library");
|
b.ToTable("Library", (string)null);
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("API.Entities.MangaFile", b =>
|
modelBuilder.Entity("API.Entities.MangaFile", b =>
|
||||||
@ -806,7 +806,7 @@ namespace API.Data.Migrations
|
|||||||
|
|
||||||
b.HasIndex("ChapterId");
|
b.HasIndex("ChapterId");
|
||||||
|
|
||||||
b.ToTable("MangaFile");
|
b.ToTable("MangaFile", (string)null);
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("API.Entities.MediaError", b =>
|
modelBuilder.Entity("API.Entities.MediaError", b =>
|
||||||
@ -841,7 +841,7 @@ namespace API.Data.Migrations
|
|||||||
|
|
||||||
b.HasKey("Id");
|
b.HasKey("Id");
|
||||||
|
|
||||||
b.ToTable("MediaError");
|
b.ToTable("MediaError", (string)null);
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("API.Entities.Metadata.SeriesMetadata", b =>
|
modelBuilder.Entity("API.Entities.Metadata.SeriesMetadata", b =>
|
||||||
@ -942,7 +942,7 @@ namespace API.Data.Migrations
|
|||||||
b.HasIndex("Id", "SeriesId")
|
b.HasIndex("Id", "SeriesId")
|
||||||
.IsUnique();
|
.IsUnique();
|
||||||
|
|
||||||
b.ToTable("SeriesMetadata");
|
b.ToTable("SeriesMetadata", (string)null);
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("API.Entities.Metadata.SeriesRelation", b =>
|
modelBuilder.Entity("API.Entities.Metadata.SeriesRelation", b =>
|
||||||
@ -966,7 +966,7 @@ namespace API.Data.Migrations
|
|||||||
|
|
||||||
b.HasIndex("TargetSeriesId");
|
b.HasIndex("TargetSeriesId");
|
||||||
|
|
||||||
b.ToTable("SeriesRelation");
|
b.ToTable("SeriesRelation", (string)null);
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("API.Entities.Person", b =>
|
modelBuilder.Entity("API.Entities.Person", b =>
|
||||||
@ -986,7 +986,7 @@ namespace API.Data.Migrations
|
|||||||
|
|
||||||
b.HasKey("Id");
|
b.HasKey("Id");
|
||||||
|
|
||||||
b.ToTable("Person");
|
b.ToTable("Person", (string)null);
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("API.Entities.ReadingList", b =>
|
modelBuilder.Entity("API.Entities.ReadingList", b =>
|
||||||
@ -1049,7 +1049,7 @@ namespace API.Data.Migrations
|
|||||||
|
|
||||||
b.HasIndex("AppUserId");
|
b.HasIndex("AppUserId");
|
||||||
|
|
||||||
b.ToTable("ReadingList");
|
b.ToTable("ReadingList", (string)null);
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("API.Entities.ReadingListItem", b =>
|
modelBuilder.Entity("API.Entities.ReadingListItem", b =>
|
||||||
@ -1083,7 +1083,7 @@ namespace API.Data.Migrations
|
|||||||
|
|
||||||
b.HasIndex("VolumeId");
|
b.HasIndex("VolumeId");
|
||||||
|
|
||||||
b.ToTable("ReadingListItem");
|
b.ToTable("ReadingListItem", (string)null);
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("API.Entities.Scrobble.ScrobbleError", b =>
|
modelBuilder.Entity("API.Entities.Scrobble.ScrobbleError", b =>
|
||||||
@ -1128,7 +1128,7 @@ namespace API.Data.Migrations
|
|||||||
|
|
||||||
b.HasIndex("SeriesId");
|
b.HasIndex("SeriesId");
|
||||||
|
|
||||||
b.ToTable("ScrobbleError");
|
b.ToTable("ScrobbleError", (string)null);
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("API.Entities.Scrobble.ScrobbleEvent", b =>
|
modelBuilder.Entity("API.Entities.Scrobble.ScrobbleEvent", b =>
|
||||||
@ -1188,8 +1188,8 @@ namespace API.Data.Migrations
|
|||||||
b.Property<int>("SeriesId")
|
b.Property<int>("SeriesId")
|
||||||
.HasColumnType("INTEGER");
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
b.Property<int?>("VolumeNumber")
|
b.Property<float?>("VolumeNumber")
|
||||||
.HasColumnType("INTEGER");
|
.HasColumnType("REAL");
|
||||||
|
|
||||||
b.HasKey("Id");
|
b.HasKey("Id");
|
||||||
|
|
||||||
@ -1199,7 +1199,7 @@ namespace API.Data.Migrations
|
|||||||
|
|
||||||
b.HasIndex("SeriesId");
|
b.HasIndex("SeriesId");
|
||||||
|
|
||||||
b.ToTable("ScrobbleEvent");
|
b.ToTable("ScrobbleEvent", (string)null);
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("API.Entities.Scrobble.ScrobbleHold", b =>
|
modelBuilder.Entity("API.Entities.Scrobble.ScrobbleHold", b =>
|
||||||
@ -1232,7 +1232,7 @@ namespace API.Data.Migrations
|
|||||||
|
|
||||||
b.HasIndex("SeriesId");
|
b.HasIndex("SeriesId");
|
||||||
|
|
||||||
b.ToTable("ScrobbleHold");
|
b.ToTable("ScrobbleHold", (string)null);
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("API.Entities.Series", b =>
|
modelBuilder.Entity("API.Entities.Series", b =>
|
||||||
@ -1328,7 +1328,7 @@ namespace API.Data.Migrations
|
|||||||
|
|
||||||
b.HasIndex("LibraryId");
|
b.HasIndex("LibraryId");
|
||||||
|
|
||||||
b.ToTable("Series");
|
b.ToTable("Series", (string)null);
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("API.Entities.ServerSetting", b =>
|
modelBuilder.Entity("API.Entities.ServerSetting", b =>
|
||||||
@ -1345,7 +1345,7 @@ namespace API.Data.Migrations
|
|||||||
|
|
||||||
b.HasKey("Key");
|
b.HasKey("Key");
|
||||||
|
|
||||||
b.ToTable("ServerSetting");
|
b.ToTable("ServerSetting", (string)null);
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("API.Entities.ServerStatistics", b =>
|
modelBuilder.Entity("API.Entities.ServerStatistics", b =>
|
||||||
@ -1383,7 +1383,7 @@ namespace API.Data.Migrations
|
|||||||
|
|
||||||
b.HasKey("Id");
|
b.HasKey("Id");
|
||||||
|
|
||||||
b.ToTable("ServerStatistics");
|
b.ToTable("ServerStatistics", (string)null);
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("API.Entities.SiteTheme", b =>
|
modelBuilder.Entity("API.Entities.SiteTheme", b =>
|
||||||
@ -1421,7 +1421,7 @@ namespace API.Data.Migrations
|
|||||||
|
|
||||||
b.HasKey("Id");
|
b.HasKey("Id");
|
||||||
|
|
||||||
b.ToTable("SiteTheme");
|
b.ToTable("SiteTheme", (string)null);
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("API.Entities.Tag", b =>
|
modelBuilder.Entity("API.Entities.Tag", b =>
|
||||||
@ -1441,7 +1441,7 @@ namespace API.Data.Migrations
|
|||||||
b.HasIndex("NormalizedTitle")
|
b.HasIndex("NormalizedTitle")
|
||||||
.IsUnique();
|
.IsUnique();
|
||||||
|
|
||||||
b.ToTable("Tag");
|
b.ToTable("Tag", (string)null);
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("API.Entities.Volume", b =>
|
modelBuilder.Entity("API.Entities.Volume", b =>
|
||||||
@ -1477,8 +1477,8 @@ namespace API.Data.Migrations
|
|||||||
b.Property<string>("Name")
|
b.Property<string>("Name")
|
||||||
.HasColumnType("TEXT");
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
b.Property<int>("Number")
|
b.Property<float>("Number")
|
||||||
.HasColumnType("INTEGER");
|
.HasColumnType("REAL");
|
||||||
|
|
||||||
b.Property<int>("Pages")
|
b.Property<int>("Pages")
|
||||||
.HasColumnType("INTEGER");
|
.HasColumnType("INTEGER");
|
||||||
@ -1493,7 +1493,7 @@ namespace API.Data.Migrations
|
|||||||
|
|
||||||
b.HasIndex("SeriesId");
|
b.HasIndex("SeriesId");
|
||||||
|
|
||||||
b.ToTable("Volume");
|
b.ToTable("Volume", (string)null);
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("AppUserLibrary", b =>
|
modelBuilder.Entity("AppUserLibrary", b =>
|
||||||
@ -1508,7 +1508,7 @@ namespace API.Data.Migrations
|
|||||||
|
|
||||||
b.HasIndex("LibrariesId");
|
b.HasIndex("LibrariesId");
|
||||||
|
|
||||||
b.ToTable("AppUserLibrary");
|
b.ToTable("AppUserLibrary", (string)null);
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("ChapterGenre", b =>
|
modelBuilder.Entity("ChapterGenre", b =>
|
||||||
@ -1523,7 +1523,7 @@ namespace API.Data.Migrations
|
|||||||
|
|
||||||
b.HasIndex("GenresId");
|
b.HasIndex("GenresId");
|
||||||
|
|
||||||
b.ToTable("ChapterGenre");
|
b.ToTable("ChapterGenre", (string)null);
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("ChapterPerson", b =>
|
modelBuilder.Entity("ChapterPerson", b =>
|
||||||
@ -1538,7 +1538,7 @@ namespace API.Data.Migrations
|
|||||||
|
|
||||||
b.HasIndex("PeopleId");
|
b.HasIndex("PeopleId");
|
||||||
|
|
||||||
b.ToTable("ChapterPerson");
|
b.ToTable("ChapterPerson", (string)null);
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("ChapterTag", b =>
|
modelBuilder.Entity("ChapterTag", b =>
|
||||||
@ -1553,7 +1553,7 @@ namespace API.Data.Migrations
|
|||||||
|
|
||||||
b.HasIndex("TagsId");
|
b.HasIndex("TagsId");
|
||||||
|
|
||||||
b.ToTable("ChapterTag");
|
b.ToTable("ChapterTag", (string)null);
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("CollectionTagSeriesMetadata", b =>
|
modelBuilder.Entity("CollectionTagSeriesMetadata", b =>
|
||||||
@ -1568,7 +1568,7 @@ namespace API.Data.Migrations
|
|||||||
|
|
||||||
b.HasIndex("SeriesMetadatasId");
|
b.HasIndex("SeriesMetadatasId");
|
||||||
|
|
||||||
b.ToTable("CollectionTagSeriesMetadata");
|
b.ToTable("CollectionTagSeriesMetadata", (string)null);
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("GenreSeriesMetadata", b =>
|
modelBuilder.Entity("GenreSeriesMetadata", b =>
|
||||||
@ -1583,7 +1583,7 @@ namespace API.Data.Migrations
|
|||||||
|
|
||||||
b.HasIndex("SeriesMetadatasId");
|
b.HasIndex("SeriesMetadatasId");
|
||||||
|
|
||||||
b.ToTable("GenreSeriesMetadata");
|
b.ToTable("GenreSeriesMetadata", (string)null);
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<int>", b =>
|
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<int>", b =>
|
||||||
@ -1682,7 +1682,7 @@ namespace API.Data.Migrations
|
|||||||
|
|
||||||
b.HasIndex("SeriesMetadatasId");
|
b.HasIndex("SeriesMetadatasId");
|
||||||
|
|
||||||
b.ToTable("PersonSeriesMetadata");
|
b.ToTable("PersonSeriesMetadata", (string)null);
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("SeriesMetadataTag", b =>
|
modelBuilder.Entity("SeriesMetadataTag", b =>
|
||||||
@ -1697,7 +1697,7 @@ namespace API.Data.Migrations
|
|||||||
|
|
||||||
b.HasIndex("TagsId");
|
b.HasIndex("TagsId");
|
||||||
|
|
||||||
b.ToTable("SeriesMetadataTag");
|
b.ToTable("SeriesMetadataTag", (string)null);
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("API.Entities.AppUserBookmark", b =>
|
modelBuilder.Entity("API.Entities.AppUserBookmark", b =>
|
||||||
|
@ -53,6 +53,7 @@ public interface ILibraryRepository
|
|||||||
Task<IList<string>> GetAllCoverImagesAsync();
|
Task<IList<string>> GetAllCoverImagesAsync();
|
||||||
Task<IDictionary<int, LibraryType>> GetLibraryTypesForIdsAsync(IEnumerable<int> libraryIds);
|
Task<IDictionary<int, LibraryType>> GetLibraryTypesForIdsAsync(IEnumerable<int> libraryIds);
|
||||||
Task<IList<Library>> GetAllWithCoversInDifferentEncoding(EncodeFormat encodeFormat);
|
Task<IList<Library>> GetAllWithCoversInDifferentEncoding(EncodeFormat encodeFormat);
|
||||||
|
Task<bool> GetAllowsScrobblingBySeriesId(int seriesId);
|
||||||
}
|
}
|
||||||
|
|
||||||
public class LibraryRepository : ILibraryRepository
|
public class LibraryRepository : ILibraryRepository
|
||||||
@ -374,4 +375,11 @@ public class LibraryRepository : ILibraryRepository
|
|||||||
.Where(c => !string.IsNullOrEmpty(c.CoverImage) && !c.CoverImage.EndsWith(extension))
|
.Where(c => !string.IsNullOrEmpty(c.CoverImage) && !c.CoverImage.EndsWith(extension))
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<bool> GetAllowsScrobblingBySeriesId(int seriesId)
|
||||||
|
{
|
||||||
|
return await _context.Series.Where(s => s.Id == seriesId)
|
||||||
|
.Select(s => s.Library.AllowScrobbling)
|
||||||
|
.SingleOrDefaultAsync();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -128,15 +128,18 @@ public class LicenseService : ILicenseService
|
|||||||
/// <remarks>Expected to be called at startup and on reoccurring basis</remarks>
|
/// <remarks>Expected to be called at startup and on reoccurring basis</remarks>
|
||||||
public async Task ValidateLicenseStatus()
|
public async Task ValidateLicenseStatus()
|
||||||
{
|
{
|
||||||
|
var provider = _cachingProviderFactory.GetCachingProvider(EasyCacheProfiles.License);
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var license = await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.LicenseKey);
|
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");
|
_logger.LogInformation("Validating Kavita+ License");
|
||||||
var provider = _cachingProviderFactory.GetCachingProvider(EasyCacheProfiles.License);
|
|
||||||
await provider.FlushAsync();
|
|
||||||
|
|
||||||
|
await provider.FlushAsync();
|
||||||
var isValid = await IsLicenseValid(license.Value);
|
var isValid = await IsLicenseValid(license.Value);
|
||||||
await provider.SetAsync(CacheKey, isValid, _licenseCacheTimeout);
|
await provider.SetAsync(CacheKey, isValid, _licenseCacheTimeout);
|
||||||
|
|
||||||
@ -145,6 +148,7 @@ public class LicenseService : ILicenseService
|
|||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_logger.LogError(ex, "There was an error talking with Kavita+ API for license validation. Rescheduling check in 30 mins");
|
_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));
|
BackgroundJob.Schedule(() => ValidateLicenseStatus(), TimeSpan.FromMinutes(30));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -104,7 +104,7 @@ public class ScrobblingService : IScrobblingService
|
|||||||
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
///
|
/// An automated job that will run against all user's tokens and validate if they are still active
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>This service can validate without license check as the task which calls will be guarded</remarks>
|
/// <remarks>This service can validate without license check as the task which calls will be guarded</remarks>
|
||||||
/// <returns></returns>
|
/// <returns></returns>
|
||||||
@ -115,6 +115,7 @@ public class ScrobblingService : IScrobblingService
|
|||||||
foreach (var user in users)
|
foreach (var user in users)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrEmpty(user.AniListAccessToken) || !_tokenService.HasTokenExpired(user.AniListAccessToken)) continue;
|
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,
|
await _eventHub.SendMessageToAsync(MessageFactory.ScrobblingKeyExpired,
|
||||||
MessageFactory.ScrobblingKeyExpiredEvent(ScrobbleProvider.AniList), user.Id);
|
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)
|
public async Task ScrobbleReviewUpdate(int userId, int seriesId, string reviewTitle, string reviewBody)
|
||||||
{
|
{
|
||||||
if (!await _licenseService.HasActiveLicense()) return;
|
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);
|
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 (series == null) throw new KavitaException(await _localizationService.Translate(userId, "series-doesnt-exist"));
|
||||||
var library = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(series.LibraryId);
|
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,
|
var existingEvt = await _unitOfWork.ScrobbleRepository.GetEvent(userId, series.Id,
|
||||||
ScrobbleEventType.Review);
|
ScrobbleEventType.Review);
|
||||||
@ -229,17 +226,12 @@ public class ScrobblingService : IScrobblingService
|
|||||||
public async Task ScrobbleRatingUpdate(int userId, int seriesId, float rating)
|
public async Task ScrobbleRatingUpdate(int userId, int seriesId, float rating)
|
||||||
{
|
{
|
||||||
if (!await _licenseService.HasActiveLicense()) return;
|
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);
|
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 (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;
|
_logger.LogInformation("Processing Scrobbling rating event for {UserId} on {SeriesName}", userId, series.Name);
|
||||||
if (library.Type == LibraryType.Comic) return;
|
if (await CheckIfCanScrobble(userId, seriesId, series)) return;
|
||||||
|
|
||||||
var existingEvt = await _unitOfWork.ScrobbleRepository.GetEvent(userId, series.Id,
|
var existingEvt = await _unitOfWork.ScrobbleRepository.GetEvent(userId, series.Id,
|
||||||
ScrobbleEventType.ScoreUpdated);
|
ScrobbleEventType.ScoreUpdated);
|
||||||
@ -273,22 +265,12 @@ public class ScrobblingService : IScrobblingService
|
|||||||
public async Task ScrobbleReadingUpdate(int userId, int seriesId)
|
public async Task ScrobbleReadingUpdate(int userId, int seriesId)
|
||||||
{
|
{
|
||||||
if (!await _licenseService.HasActiveLicense()) return;
|
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);
|
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 (series == null) throw new KavitaException(await _localizationService.Translate(userId, "series-doesnt-exist"));
|
||||||
if (await _unitOfWork.UserRepository.HasHoldOnSeries(userId, seriesId))
|
|
||||||
{
|
_logger.LogInformation("Processing Scrobbling reading event for {UserId} on {SeriesName}", userId, series.Name);
|
||||||
_logger.LogInformation("Series {SeriesName} is on UserId {UserId}'s hold list. Not scrobbling", series.Name, userId);
|
if (await CheckIfCanScrobble(userId, seriesId, series)) return;
|
||||||
return;
|
|
||||||
}
|
|
||||||
var library = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(series.LibraryId);
|
|
||||||
if (library is not {AllowScrobbling: true}) return;
|
|
||||||
if (library.Type == LibraryType.Comic) return;
|
|
||||||
|
|
||||||
var existingEvt = await _unitOfWork.ScrobbleRepository.GetEvent(userId, series.Id,
|
var existingEvt = await _unitOfWork.ScrobbleRepository.GetEvent(userId, series.Id,
|
||||||
ScrobbleEventType.ChapterRead);
|
ScrobbleEventType.ChapterRead);
|
||||||
@ -338,17 +320,12 @@ public class ScrobblingService : IScrobblingService
|
|||||||
public async Task ScrobbleWantToReadUpdate(int userId, int seriesId, bool onWantToRead)
|
public async Task ScrobbleWantToReadUpdate(int userId, int seriesId, bool onWantToRead)
|
||||||
{
|
{
|
||||||
if (!await _licenseService.HasActiveLicense()) return;
|
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);
|
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 (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;
|
_logger.LogInformation("Processing Scrobbling want-to-read event for {UserId} on {SeriesName}", userId, series.Name);
|
||||||
if (library.Type == LibraryType.Comic) return;
|
if (await CheckIfCanScrobble(userId, seriesId, series)) return;
|
||||||
|
|
||||||
var existing = await _unitOfWork.ScrobbleRepository.Exists(userId, series.Id,
|
var existing = await _unitOfWork.ScrobbleRepository.Exists(userId, series.Id,
|
||||||
onWantToRead ? ScrobbleEventType.AddWantToRead : ScrobbleEventType.RemoveWantToRead);
|
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);
|
_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)
|
private async Task<int> GetRateLimit(string license, string aniListToken)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(aniListToken)) return 0;
|
if (string.IsNullOrWhiteSpace(aniListToken)) return 0;
|
||||||
|
@ -65,16 +65,19 @@ public class SeriesService : ISeriesService
|
|||||||
/// <returns></returns>
|
/// <returns></returns>
|
||||||
public static Chapter? GetFirstChapterForMetadata(Series series)
|
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
|
var minVolumeNumber = sortedVolumes
|
||||||
.Where(v => v.Number != 0)
|
.MinBy(v => float.Parse(v.Name));
|
||||||
.MinBy(v => v.Number);
|
|
||||||
|
|
||||||
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();
|
.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);
|
return minVolumeNumber.Chapters.MinBy(c => float.Parse(c.Number), ChapterSortComparer.Default);
|
||||||
}
|
}
|
||||||
|
@ -27,7 +27,7 @@ public interface IStatisticService
|
|||||||
Task<IEnumerable<TopReadDto>> GetTopUsers(int days);
|
Task<IEnumerable<TopReadDto>> GetTopUsers(int days);
|
||||||
Task<IEnumerable<ReadHistoryEvent>> GetReadingHistory(int userId);
|
Task<IEnumerable<ReadHistoryEvent>> GetReadingHistory(int userId);
|
||||||
Task<IEnumerable<PagesReadOnADayCount<DateTime>>> ReadCountByDay(int userId = 0, int days = 0);
|
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>> GetPagesReadCountByYear(int userId = 0);
|
||||||
IEnumerable<StatCount<int>> GetWordsReadCountByYear(int userId = 0);
|
IEnumerable<StatCount<int>> GetWordsReadCountByYear(int userId = 0);
|
||||||
Task UpdateServerStatistics();
|
Task UpdateServerStatistics();
|
||||||
@ -411,11 +411,12 @@ public class StatisticService : IStatisticService
|
|||||||
return results.OrderBy(r => r.Value);
|
return results.OrderBy(r => r.Value);
|
||||||
}
|
}
|
||||||
|
|
||||||
public IEnumerable<StatCount<DayOfWeek>> GetDayBreakdown()
|
public IEnumerable<StatCount<DayOfWeek>> GetDayBreakdown(int userId)
|
||||||
{
|
{
|
||||||
return _context.AppUserProgresses
|
return _context.AppUserProgresses
|
||||||
.AsSplitQuery()
|
.AsSplitQuery()
|
||||||
.AsNoTracking()
|
.AsNoTracking()
|
||||||
|
.WhereIf(userId > 0, p => p.AppUserId == userId)
|
||||||
.GroupBy(p => p.LastModified.DayOfWeek)
|
.GroupBy(p => p.LastModified.DayOfWeek)
|
||||||
.OrderBy(g => g.Key)
|
.OrderBy(g => g.Key)
|
||||||
.Select(g => new StatCount<DayOfWeek>{ Value = g.Key, Count = g.Count() })
|
.Select(g => new StatCount<DayOfWeek>{ Value = g.Key, Count = g.Count() })
|
||||||
|
@ -229,14 +229,20 @@ public class LibraryWatcher : ILibraryWatcher
|
|||||||
public async Task ProcessChange(string filePath, bool isDirectoryChange = false)
|
public async Task ProcessChange(string filePath, bool isDirectoryChange = false)
|
||||||
{
|
{
|
||||||
var sw = Stopwatch.StartNew();
|
var sw = Stopwatch.StartNew();
|
||||||
_logger.LogDebug("[LibraryWatcher] Processing change of {FilePath}", filePath);
|
_logger.LogTrace("[LibraryWatcher] Processing change of {FilePath}", filePath);
|
||||||
try
|
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 not a directory change AND file is not an archive or book, ignore
|
||||||
if (!isDirectoryChange &&
|
if (!isDirectoryChange &&
|
||||||
!(Parser.Parser.IsArchive(filePath) || Parser.Parser.IsBook(filePath)))
|
!(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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -248,10 +254,10 @@ public class LibraryWatcher : ILibraryWatcher
|
|||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
var fullPath = GetFolder(filePath, libraryFolders);
|
var fullPath = GetFolder(filePath, libraryFolders);
|
||||||
_logger.LogDebug("Folder path: {FolderPath}", fullPath);
|
_logger.LogTrace("Folder path: {FolderPath}", fullPath);
|
||||||
if (string.IsNullOrEmpty(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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -146,7 +146,6 @@ public class ProcessSeries : IProcessSeries
|
|||||||
_logger.LogInformation("[ScannerService] Processing series {SeriesName}", series.OriginalName);
|
_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)
|
// 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);
|
var firstParsedInfo = parsedInfos.FirstOrDefault(p => p.ComicInfo != null, firstInfo);
|
||||||
|
|
||||||
UpdateVolumes(series, parsedInfos, forceUpdate);
|
UpdateVolumes(series, parsedInfos, forceUpdate);
|
||||||
|
@ -112,7 +112,7 @@ export class StatisticsService {
|
|||||||
return this.httpClient.get<Array<any>>(this.baseUrl + 'stats/reading-count-by-day?userId=' + userId + '&days=' + days);
|
return this.httpClient.get<Array<any>>(this.baseUrl + 'stats/reading-count-by-day?userId=' + userId + '&days=' + days);
|
||||||
}
|
}
|
||||||
|
|
||||||
getDayBreakdown() {
|
getDayBreakdown( userId = 0) {
|
||||||
return this.httpClient.get<Array<StatCount<DayOfWeek>>>(this.baseUrl + 'stats/day-breakdown');
|
return this.httpClient.get<Array<StatCount<DayOfWeek>>>(this.baseUrl + 'stats/day-breakdown?userId=' + userId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -30,7 +30,6 @@ import { BulkOperationsComponent } from '../../../cards/bulk-operations/bulk-ope
|
|||||||
import { NgIf, DecimalPipe } from '@angular/common';
|
import { NgIf, DecimalPipe } from '@angular/common';
|
||||||
import { SideNavCompanionBarComponent } from '../../../sidenav/_components/side-nav-companion-bar/side-nav-companion-bar.component';
|
import { SideNavCompanionBarComponent } from '../../../sidenav/_components/side-nav-companion-bar/side-nav-companion-bar.component';
|
||||||
import {translate, TranslocoDirective} from "@ngneat/transloco";
|
import {translate, TranslocoDirective} from "@ngneat/transloco";
|
||||||
import {FilterField} from "../../../_models/metadata/v2/filter-field";
|
|
||||||
import {SeriesFilterV2} from "../../../_models/metadata/v2/series-filter-v2";
|
import {SeriesFilterV2} from "../../../_models/metadata/v2/series-filter-v2";
|
||||||
|
|
||||||
|
|
||||||
|
@ -5,7 +5,6 @@ import {
|
|||||||
ChangeDetectorRef,
|
ChangeDetectorRef,
|
||||||
Component,
|
Component,
|
||||||
ContentChild,
|
ContentChild,
|
||||||
DestroyRef,
|
|
||||||
ElementRef,
|
ElementRef,
|
||||||
EventEmitter,
|
EventEmitter,
|
||||||
HostListener,
|
HostListener,
|
||||||
@ -13,7 +12,6 @@ import {
|
|||||||
Inject,
|
Inject,
|
||||||
Input,
|
Input,
|
||||||
OnChanges,
|
OnChanges,
|
||||||
OnDestroy,
|
|
||||||
OnInit,
|
OnInit,
|
||||||
Output,
|
Output,
|
||||||
TemplateRef,
|
TemplateRef,
|
||||||
@ -70,12 +68,15 @@ export class CardDetailLayoutComponent implements OnInit, OnChanges {
|
|||||||
* Any actions to exist on the header for the parent collection (library, collection)
|
* Any actions to exist on the header for the parent collection (library, collection)
|
||||||
*/
|
*/
|
||||||
@Input() actions: ActionItem<any>[] = [];
|
@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() filterSettings!: FilterSettings;
|
||||||
@Input() refresh!: EventEmitter<void>;
|
@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
|
jumpBarKeysToRender: Array<JumpKey> = []; // What is rendered on screen
|
||||||
|
|
||||||
@Output() itemClicked: EventEmitter<any> = new EventEmitter();
|
@Output() itemClicked: EventEmitter<any> = new EventEmitter();
|
||||||
@ -115,7 +116,7 @@ export class CardDetailLayoutComponent implements OnInit, OnChanges {
|
|||||||
|
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
if (this.trackByIdentity === undefined) {
|
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) {
|
if (this.filterSettings === undefined) {
|
||||||
|
@ -25,7 +25,6 @@ import {CompactNumberPipe} from "../../pipe/compact-number.pipe";
|
|||||||
import {AgeRatingPipe} from "../../pipe/age-rating.pipe";
|
import {AgeRatingPipe} from "../../pipe/age-rating.pipe";
|
||||||
import {NgbTooltip} from "@ng-bootstrap/ng-bootstrap";
|
import {NgbTooltip} from "@ng-bootstrap/ng-bootstrap";
|
||||||
import {MetadataDetailComponent} from "../../series-detail/_components/metadata-detail/metadata-detail.component";
|
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 {TranslocoModule} from "@ngneat/transloco";
|
||||||
import {TranslocoLocaleModule} from "@ngneat/transloco-locale";
|
import {TranslocoLocaleModule} from "@ngneat/transloco-locale";
|
||||||
import {FilterField} from "../../_models/metadata/v2/filter-field";
|
import {FilterField} from "../../_models/metadata/v2/filter-field";
|
||||||
@ -132,6 +131,4 @@ export class EntityInfoCardsComponent implements OnInit {
|
|||||||
}
|
}
|
||||||
this.cdRef.markForCheck();
|
this.cdRef.markForCheck();
|
||||||
}
|
}
|
||||||
|
|
||||||
protected readonly FilterQueryParam = FilterQueryParam;
|
|
||||||
}
|
}
|
||||||
|
@ -3,7 +3,7 @@ import {Title} from '@angular/platform-browser';
|
|||||||
import {Router, RouterLink} from '@angular/router';
|
import {Router, RouterLink} from '@angular/router';
|
||||||
import {Observable, of, ReplaySubject} from 'rxjs';
|
import {Observable, of, ReplaySubject} from 'rxjs';
|
||||||
import {debounceTime, map, shareReplay, take, tap} from 'rxjs/operators';
|
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 {SeriesAddedEvent} from 'src/app/_models/events/series-added-event';
|
||||||
import {SeriesRemovedEvent} from 'src/app/_models/events/series-removed-event';
|
import {SeriesRemovedEvent} from 'src/app/_models/events/series-removed-event';
|
||||||
import {Library} from 'src/app/_models/library';
|
import {Library} from 'src/app/_models/library';
|
||||||
@ -169,23 +169,36 @@ export class DashboardComponent implements OnInit {
|
|||||||
handleSectionClick(sectionTitle: string) {
|
handleSectionClick(sectionTitle: string) {
|
||||||
if (sectionTitle.toLowerCase() === 'recently updated series') {
|
if (sectionTitle.toLowerCase() === 'recently updated series') {
|
||||||
const params: any = {};
|
const params: any = {};
|
||||||
params[FilterQueryParam.SortBy] = SortField.LastChapterAdded + ',false'; // sort by last chapter added, desc
|
params['page'] = 1;
|
||||||
params[FilterQueryParam.Page] = 1;
|
|
||||||
params['title'] = 'Recently Updated';
|
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') {
|
} else if (sectionTitle.toLowerCase() === 'on deck') {
|
||||||
const params: any = {};
|
const params: any = {};
|
||||||
params[FilterQueryParam.ReadStatus] = 'true,false,false';
|
params['page'] = 1;
|
||||||
params[FilterQueryParam.SortBy] = SortField.LastChapterAdded + ',false'; // sort by last chapter added, desc
|
|
||||||
params[FilterQueryParam.Page] = 1;
|
|
||||||
params['title'] = 'On Deck';
|
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') {
|
}else if (sectionTitle.toLowerCase() === 'newly added series') {
|
||||||
const params: any = {};
|
const params: any = {};
|
||||||
params[FilterQueryParam.SortBy] = SortField.Created + ',false'; // sort by created, desc
|
params['page'] = 1;
|
||||||
params[FilterQueryParam.Page] = 1;
|
|
||||||
params['title'] = 'Newly Added';
|
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -25,13 +25,13 @@
|
|||||||
</div>
|
</div>
|
||||||
<form [formGroup]="sortGroup" class="container-fluid">
|
<form [formGroup]="sortGroup" class="container-fluid">
|
||||||
<div class="row mb-3">
|
<div class="row mb-3">
|
||||||
<div class="col-md-2">
|
<div class="col-md-2 col-sm-2">
|
||||||
<div class="form-group pe-1">
|
<div class="form-group pe-1">
|
||||||
<label for="limit-to" class="form-label">{{t('limit-label')}}</label>
|
<label for="limit-to" class="form-label">{{t('limit-label')}}</label>
|
||||||
<input id="limit-to" type="number" inputmode="numeric" class="form-control" formControlName="limitTo">
|
<input id="limit-to" type="number" inputmode="numeric" class="form-control" formControlName="limitTo">
|
||||||
</div>
|
</div>
|
||||||
</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>
|
<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">
|
<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>
|
<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>
|
<option *ngFor="let field of allSortFields" [value]="field">{{field | sortField}}</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<!-- TODO: I might want to put a Clear button which blanks out the whole filter -->
|
<ng-container *ngIf="utilityService.getActiveBreakpoint() > Breakpoint.Tablet" [ngTemplateOutlet]="buttons"></ng-container>
|
||||||
<div class="col-md-2 me-3 mt-4">
|
</div>
|
||||||
<button class="btn btn-secondary col-12" (click)="clear()">{{t('reset')}}</button>
|
<div class="row mb-3" *ngIf="utilityService.getActiveBreakpoint() <= Breakpoint.Tablet">
|
||||||
</div>
|
<ng-container [ngTemplateOutlet]="buttons"></ng-container>
|
||||||
<div class="col-md-2 me-3 mt-4">
|
</div>
|
||||||
<button class="btn btn-primary col-12" (click)="apply()">{{t('apply')}}</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</ng-template>
|
</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>
|
</ng-container>
|
||||||
|
|
||||||
|
|
||||||
|
@ -55,6 +55,7 @@ export class MetadataFilterComponent implements OnInit {
|
|||||||
|
|
||||||
@ContentChild('[ngbCollapse]') collapse!: NgbCollapse;
|
@ContentChild('[ngbCollapse]') collapse!: NgbCollapse;
|
||||||
private readonly destroyRef = inject(DestroyRef);
|
private readonly destroyRef = inject(DestroyRef);
|
||||||
|
public readonly utilityService = inject(UtilityService);
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -82,10 +83,7 @@ export class MetadataFilterComponent implements OnInit {
|
|||||||
private readonly cdRef = inject(ChangeDetectorRef);
|
private readonly cdRef = inject(ChangeDetectorRef);
|
||||||
|
|
||||||
|
|
||||||
constructor(private utilityService: UtilityService,
|
constructor(public toggleService: ToggleService) {}
|
||||||
public toggleService: ToggleService,
|
|
||||||
private filterUtilityService: FilterUtilitiesService) {
|
|
||||||
}
|
|
||||||
|
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
if (this.filterSettings === undefined) {
|
if (this.filterSettings === undefined) {
|
||||||
@ -203,4 +201,5 @@ export class MetadataFilterComponent implements OnInit {
|
|||||||
this.toggleService.set(!this.filteringCollapsed);
|
this.toggleService.set(!this.filteringCollapsed);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected readonly Breakpoint = Breakpoint;
|
||||||
}
|
}
|
||||||
|
@ -79,7 +79,7 @@
|
|||||||
</ng-template>
|
</ng-template>
|
||||||
|
|
||||||
<ng-template #tagTemplate let-item>
|
<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">
|
<div class="ms-1">
|
||||||
<span>{{item.title}}</span>
|
<span>{{item.title}}</span>
|
||||||
</div>
|
</div>
|
||||||
@ -97,7 +97,7 @@
|
|||||||
</ng-template>
|
</ng-template>
|
||||||
|
|
||||||
<ng-template #genreTemplate let-item>
|
<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 class="ms-1">
|
||||||
<div [innerHTML]="item.title"></div>
|
<div [innerHTML]="item.title"></div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -1,40 +1,44 @@
|
|||||||
import { DOCUMENT, NgIf, NgOptimizedImage, AsyncPipe } from '@angular/common';
|
import {AsyncPipe, DOCUMENT, NgIf, NgOptimizedImage} from '@angular/common';
|
||||||
import {
|
import {
|
||||||
ChangeDetectionStrategy,
|
ChangeDetectionStrategy,
|
||||||
ChangeDetectorRef,
|
ChangeDetectorRef,
|
||||||
Component, DestroyRef,
|
Component,
|
||||||
|
DestroyRef,
|
||||||
ElementRef,
|
ElementRef,
|
||||||
inject,
|
inject,
|
||||||
Inject,
|
Inject,
|
||||||
OnInit,
|
OnInit,
|
||||||
ViewChild
|
ViewChild
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
import { NavigationEnd, Router, RouterLink, RouterLinkActive } from '@angular/router';
|
import {NavigationEnd, Router, RouterLink, RouterLinkActive} from '@angular/router';
|
||||||
import { fromEvent } from 'rxjs';
|
import {fromEvent} from 'rxjs';
|
||||||
import { debounceTime, distinctUntilChanged, filter, tap } from 'rxjs/operators';
|
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 { Chapter } from 'src/app/_models/chapter';
|
import {CollectionTag} from 'src/app/_models/collection-tag';
|
||||||
import { CollectionTag } from 'src/app/_models/collection-tag';
|
import {Library} from 'src/app/_models/library';
|
||||||
import { Library } from 'src/app/_models/library';
|
import {MangaFile} from 'src/app/_models/manga-file';
|
||||||
import { MangaFile } from 'src/app/_models/manga-file';
|
import {PersonRole} from 'src/app/_models/metadata/person';
|
||||||
import { PersonRole } from 'src/app/_models/metadata/person';
|
import {ReadingList} from 'src/app/_models/reading-list';
|
||||||
import { ReadingList } from 'src/app/_models/reading-list';
|
import {SearchResult} from 'src/app/_models/search/search-result';
|
||||||
import { SearchResult } from 'src/app/_models/search/search-result';
|
import {SearchResultGroup} from 'src/app/_models/search/search-result-group';
|
||||||
import { SearchResultGroup } from 'src/app/_models/search/search-result-group';
|
import {AccountService} from 'src/app/_services/account.service';
|
||||||
import { AccountService } from 'src/app/_services/account.service';
|
import {ImageService} from 'src/app/_services/image.service';
|
||||||
import { ImageService } from 'src/app/_services/image.service';
|
import {NavService} from 'src/app/_services/nav.service';
|
||||||
import { NavService } from 'src/app/_services/nav.service';
|
import {ScrollService} from 'src/app/_services/scroll.service';
|
||||||
import { ScrollService } from 'src/app/_services/scroll.service';
|
import {SearchService} from 'src/app/_services/search.service';
|
||||||
import { SearchService } from 'src/app/_services/search.service';
|
|
||||||
import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
|
import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
|
||||||
import { SentenceCasePipe } from '../../../pipe/sentence-case.pipe';
|
import {SentenceCasePipe} from '../../../pipe/sentence-case.pipe';
|
||||||
import { PersonRolePipe } from '../../../pipe/person-role.pipe';
|
import {PersonRolePipe} from '../../../pipe/person-role.pipe';
|
||||||
import { NgbDropdown, NgbDropdownToggle, NgbDropdownMenu, NgbDropdownItem } from '@ng-bootstrap/ng-bootstrap';
|
import {NgbDropdown, NgbDropdownItem, NgbDropdownMenu, NgbDropdownToggle} from '@ng-bootstrap/ng-bootstrap';
|
||||||
import { EventsWidgetComponent } from '../events-widget/events-widget.component';
|
import {EventsWidgetComponent} from '../events-widget/events-widget.component';
|
||||||
import { SeriesFormatComponent } from '../../../shared/series-format/series-format.component';
|
import {SeriesFormatComponent} from '../../../shared/series-format/series-format.component';
|
||||||
import { ImageComponent } from '../../../shared/image/image.component';
|
import {ImageComponent} from '../../../shared/image/image.component';
|
||||||
import { GroupedTypeaheadComponent } from '../grouped-typeahead/grouped-typeahead.component';
|
import {GroupedTypeaheadComponent} from '../grouped-typeahead/grouped-typeahead.component';
|
||||||
import {TranslocoDirective} from "@ngneat/transloco";
|
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({
|
@Component({
|
||||||
selector: 'app-nav-header',
|
selector: 'app-nav-header',
|
||||||
@ -58,10 +62,12 @@ export class NavHeaderComponent implements OnInit {
|
|||||||
backToTopNeeded = false;
|
backToTopNeeded = false;
|
||||||
searchFocused: boolean = false;
|
searchFocused: boolean = false;
|
||||||
scrollElem: HTMLElement;
|
scrollElem: HTMLElement;
|
||||||
|
protected readonly FilterField = FilterField;
|
||||||
|
|
||||||
constructor(public accountService: AccountService, private router: Router, public navService: NavService,
|
constructor(public accountService: AccountService, private router: Router, public navService: NavService,
|
||||||
public imageService: ImageService, @Inject(DOCUMENT) private document: Document,
|
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;
|
this.scrollElem = this.document.body;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -69,12 +75,11 @@ export class NavHeaderComponent implements OnInit {
|
|||||||
this.scrollService.scrollContainer$.pipe(distinctUntilChanged(), takeUntilDestroyed(this.destroyRef), tap((scrollContainer) => {
|
this.scrollService.scrollContainer$.pipe(distinctUntilChanged(), takeUntilDestroyed(this.destroyRef), tap((scrollContainer) => {
|
||||||
if (scrollContainer === 'body' || scrollContainer === undefined) {
|
if (scrollContainer === 'body' || scrollContainer === undefined) {
|
||||||
this.scrollElem = this.document.body;
|
this.scrollElem = this.document.body;
|
||||||
fromEvent(this.document.body, 'scroll').pipe(debounceTime(20)).subscribe(() => this.checkBackToTopNeeded(this.document.body));
|
|
||||||
} else {
|
} else {
|
||||||
const elem = scrollContainer as ElementRef<HTMLDivElement>;
|
const elem = scrollContainer as ElementRef<HTMLDivElement>;
|
||||||
this.scrollElem = elem.nativeElement;
|
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();
|
})).subscribe();
|
||||||
|
|
||||||
// Sometimes the top event emitter can be slow, so let's also check when a navigation occurs and recalculate
|
// 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 = {};
|
let params: any = {};
|
||||||
params[queryParamName] = filter;
|
const filter = this.filterUtilityService.createSeriesV2Filter();
|
||||||
params[FilterQueryParam.Page] = 1;
|
filter.statements = [statement];
|
||||||
|
params['page'] = 1;
|
||||||
this.clearSearch();
|
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) {
|
goToPerson(role: PersonRole, filter: any) {
|
||||||
this.clearSearch();
|
this.clearSearch();
|
||||||
switch(role) {
|
switch(role) {
|
||||||
case PersonRole.Writer:
|
case PersonRole.Writer:
|
||||||
this.goTo(FilterQueryParam.Writers, filter);
|
this.goTo({field: FilterField.Writers, comparison: FilterComparison.Equal, value: filter});
|
||||||
break;
|
break;
|
||||||
case PersonRole.Artist:
|
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;
|
break;
|
||||||
case PersonRole.Character:
|
case PersonRole.Character:
|
||||||
this.goTo(FilterQueryParam.Character, filter);
|
this.goTo({field: FilterField.Characters, comparison: FilterComparison.Equal, value: filter});
|
||||||
break;
|
break;
|
||||||
case PersonRole.Colorist:
|
case PersonRole.Colorist:
|
||||||
this.goTo(FilterQueryParam.Colorist, filter);
|
this.goTo({field: FilterField.Colorist, comparison: FilterComparison.Equal, value: filter});
|
||||||
break;
|
break;
|
||||||
case PersonRole.Editor:
|
case PersonRole.Editor:
|
||||||
this.goTo(FilterQueryParam.Editor, filter);
|
this.goTo({field: FilterField.Editor, comparison: FilterComparison.Equal, value: filter});
|
||||||
break;
|
break;
|
||||||
case PersonRole.Inker:
|
case PersonRole.Inker:
|
||||||
this.goTo(FilterQueryParam.Inker, filter);
|
this.goTo({field: FilterField.Inker, comparison: FilterComparison.Equal, value: filter});
|
||||||
break;
|
break;
|
||||||
case PersonRole.CoverArtist:
|
case PersonRole.CoverArtist:
|
||||||
this.goTo(FilterQueryParam.CoverArtists, filter);
|
this.goTo({field: FilterField.CoverArtist, comparison: FilterComparison.Equal, value: filter});
|
||||||
break;
|
break;
|
||||||
case PersonRole.Letterer:
|
case PersonRole.Letterer:
|
||||||
this.goTo(FilterQueryParam.Letterer, filter);
|
this.goTo({field: FilterField.Letterer, comparison: FilterComparison.Equal, value: filter});
|
||||||
break;
|
break;
|
||||||
case PersonRole.Penciller:
|
case PersonRole.Penciller:
|
||||||
this.goTo(FilterQueryParam.Penciller, filter);
|
this.goTo({field: FilterField.Penciller, comparison: FilterComparison.Equal, value: filter});
|
||||||
break;
|
break;
|
||||||
case PersonRole.Publisher:
|
case PersonRole.Publisher:
|
||||||
this.goTo(FilterQueryParam.Publisher, filter);
|
this.goTo({field: FilterField.Publisher, comparison: FilterComparison.Equal, value: filter});
|
||||||
break;
|
break;
|
||||||
case PersonRole.Translator:
|
case PersonRole.Translator:
|
||||||
this.goTo(FilterQueryParam.Translator, filter);
|
this.goTo({field: FilterField.Translators, comparison: FilterComparison.Equal, value: filter});
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -232,4 +242,6 @@ export class NavHeaderComponent implements OnInit {
|
|||||||
hideSideNav() {
|
hideSideNav() {
|
||||||
this.navService.toggleSideNav();
|
this.navService.toggleSideNav();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
<div class="row g-0">
|
<div class="row g-0">
|
||||||
<div class="col-auto custom-col clickable" [ngbPopover]="popContent"
|
<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">
|
<span class="badge rounded-pill ps-0 me-1">
|
||||||
<img class="me-1" ngSrc="assets/images/logo-32.png" width="24" height="24" alt="">
|
<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>
|
<ng-container *ngIf="hasUserRated; else notYetRated">{{userRating * 20}}</ng-container>
|
||||||
@ -23,7 +23,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ng-template #popContent>
|
<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>
|
[maxStars]="5" [color]="starColor"></ngx-stars>
|
||||||
{{userRating * 20}}%
|
{{userRating * 20}}%
|
||||||
</ng-template>
|
</ng-template>
|
||||||
|
@ -19,6 +19,14 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.lg-popover {
|
||||||
|
width: 320px;
|
||||||
|
|
||||||
|
> .popover-body {
|
||||||
|
padding-top: 0px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.rating-star {
|
.rating-star {
|
||||||
i {
|
i {
|
||||||
position: relative;
|
position: relative;
|
||||||
|
@ -18,6 +18,7 @@ import {LibraryType} from "../../../_models/library";
|
|||||||
import {ProviderNamePipe} from "../../../pipe/provider-name.pipe";
|
import {ProviderNamePipe} from "../../../pipe/provider-name.pipe";
|
||||||
import {NgxStarsModule} from "ngx-stars";
|
import {NgxStarsModule} from "ngx-stars";
|
||||||
import {ThemeService} from "../../../_services/theme.service";
|
import {ThemeService} from "../../../_services/theme.service";
|
||||||
|
import {Breakpoint, UtilityService} from "../../../shared/_services/utility.service";
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-external-rating',
|
selector: 'app-external-rating',
|
||||||
@ -37,6 +38,7 @@ export class ExternalRatingComponent implements OnInit {
|
|||||||
private readonly seriesService = inject(SeriesService);
|
private readonly seriesService = inject(SeriesService);
|
||||||
private readonly accountService = inject(AccountService);
|
private readonly accountService = inject(AccountService);
|
||||||
private readonly themeService = inject(ThemeService);
|
private readonly themeService = inject(ThemeService);
|
||||||
|
public readonly utilityService = inject(UtilityService);
|
||||||
|
|
||||||
ratings: Array<Rating> = [];
|
ratings: Array<Rating> = [];
|
||||||
isLoading: boolean = false;
|
isLoading: boolean = false;
|
||||||
@ -71,4 +73,6 @@ export class ExternalRatingComponent implements OnInit {
|
|||||||
this.cdRef.markForCheck();
|
this.cdRef.markForCheck();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected readonly Breakpoint = Breakpoint;
|
||||||
}
|
}
|
||||||
|
@ -3,8 +3,7 @@ import {CommonModule} from '@angular/common';
|
|||||||
import {A11yClickDirective} from "../../../shared/a11y-click.directive";
|
import {A11yClickDirective} from "../../../shared/a11y-click.directive";
|
||||||
import {BadgeExpanderComponent} from "../../../shared/badge-expander/badge-expander.component";
|
import {BadgeExpanderComponent} from "../../../shared/badge-expander/badge-expander.component";
|
||||||
import {TagBadgeComponent, TagBadgeCursor} from "../../../shared/tag-badge/tag-badge.component";
|
import {TagBadgeComponent, TagBadgeCursor} from "../../../shared/tag-badge/tag-badge.component";
|
||||||
import {FilterQueryParam, FilterUtilitiesService} from "../../../shared/_services/filter-utilities.service";
|
import {FilterUtilitiesService} from "../../../shared/_services/filter-utilities.service";
|
||||||
import {Router} from "@angular/router";
|
|
||||||
import {FilterComparison} from "../../../_models/metadata/v2/filter-comparison";
|
import {FilterComparison} from "../../../_models/metadata/v2/filter-comparison";
|
||||||
import {FilterField} from "../../../_models/metadata/v2/filter-field";
|
import {FilterField} from "../../../_models/metadata/v2/filter-field";
|
||||||
|
|
||||||
@ -25,7 +24,6 @@ export class MetadataDetailComponent {
|
|||||||
@ContentChild('titleTemplate') titleTemplate!: TemplateRef<any>;
|
@ContentChild('titleTemplate') titleTemplate!: TemplateRef<any>;
|
||||||
@ContentChild('itemTemplate') itemTemplate?: TemplateRef<any>;
|
@ContentChild('itemTemplate') itemTemplate?: TemplateRef<any>;
|
||||||
|
|
||||||
private readonly router = inject(Router);
|
|
||||||
private readonly filterUtilitiesService = inject(FilterUtilitiesService);
|
private readonly filterUtilitiesService = inject(FilterUtilitiesService);
|
||||||
protected readonly TagBadgeCursor = TagBadgeCursor;
|
protected readonly TagBadgeCursor = TagBadgeCursor;
|
||||||
|
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
import {Injectable} from '@angular/core';
|
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 {Pagination} from 'src/app/_models/pagination';
|
||||||
import {SortField, SortOptions} from 'src/app/_models/metadata/series-filter';
|
import {SortField, SortOptions} from 'src/app/_models/metadata/series-filter';
|
||||||
import {MetadataService} from "../../_services/metadata.service";
|
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 {FilterField} from "../../_models/metadata/v2/filter-field";
|
||||||
import {FilterComparison} from "../../_models/metadata/v2/filter-comparison";
|
import {FilterComparison} from "../../_models/metadata/v2/filter-comparison";
|
||||||
|
|
||||||
/**
|
const sortOptionsKey = 'sortOptions=';
|
||||||
* Used to pass state between the filter and the url
|
const statementsKey = 'stmts=';
|
||||||
*/
|
const limitToKey = 'limitTo=';
|
||||||
export enum FilterQueryParam {
|
const combinationKey = 'combination=';
|
||||||
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'
|
|
||||||
}
|
|
||||||
|
|
||||||
@Injectable({
|
@Injectable({
|
||||||
providedIn: 'root'
|
providedIn: 'root'
|
||||||
})
|
})
|
||||||
export class FilterUtilitiesService {
|
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) {
|
applyFilter(page: Array<any>, filter: FilterField, comparison: FilterComparison, value: string) {
|
||||||
const dto: SeriesFilterV2 = {
|
const dto: SeriesFilterV2 = {
|
||||||
@ -61,8 +28,14 @@ export class FilterUtilitiesService {
|
|||||||
limitTo: 0
|
limitTo: 0
|
||||||
};
|
};
|
||||||
|
|
||||||
console.log('applying filter: ', this.urlFromFilterV2(page.join('/') + '?', dto))
|
const url = this.urlFromFilterV2(page.join('/') + '?', dto);
|
||||||
this.router.navigateByUrl(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
|
* 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
|
* @param itemsPerPage If you want pagination, pass non-zero number
|
||||||
* @returns A default pagination object
|
* @returns A default pagination object
|
||||||
*/
|
*/
|
||||||
@ -107,10 +80,10 @@ export class FilterUtilitiesService {
|
|||||||
|
|
||||||
encodeSeriesFilter(filter: SeriesFilterV2) {
|
encodeSeriesFilter(filter: SeriesFilterV2) {
|
||||||
const encodedStatements = this.encodeFilterStatements(filter.statements);
|
const encodedStatements = this.encodeFilterStatements(filter.statements);
|
||||||
const encodedSortOptions = filter.sortOptions ? `sortOptions=${this.encodeSortOptions(filter.sortOptions)}` : '';
|
const encodedSortOptions = filter.sortOptions ? `${sortOptionsKey}${this.encodeSortOptions(filter.sortOptions)}` : '';
|
||||||
const encodedLimitTo = `limitTo=${filter.limitTo}`;
|
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) {
|
encodeName(name: string | undefined) {
|
||||||
@ -124,7 +97,8 @@ export class FilterUtilitiesService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
encodeFilterStatements(statements: Array<FilterStatement>) {
|
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 encodedComparison = `comparison=${statement.comparison}`;
|
||||||
const encodedField = `field=${statement.field}`;
|
const encodedField = `field=${statement.field}`;
|
||||||
const encodedValue = `value=${encodeURIComponent(statement.value)}`;
|
const encodedValue = `value=${encodeURIComponent(statement.value)}`;
|
||||||
@ -144,19 +118,23 @@ export class FilterUtilitiesService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const fullUrl = window.location.href.split('?')[1];
|
const fullUrl = window.location.href.split('?')[1];
|
||||||
const stmtsStartIndex = fullUrl.indexOf('stmts=');
|
const stmtsStartIndex = fullUrl.indexOf(statementsKey);
|
||||||
let endIndex = fullUrl.indexOf('&sortOptions=');
|
let endIndex = fullUrl.indexOf('&' + sortOptionsKey);
|
||||||
if (endIndex < 0) {
|
if (endIndex < 0) {
|
||||||
endIndex = fullUrl.indexOf('&limitTo=');
|
endIndex = fullUrl.indexOf('&' + limitToKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (stmtsStartIndex !== -1 && endIndex !== -1) {
|
if (stmtsStartIndex !== -1 || endIndex !== -1) {
|
||||||
const stmtsEncoded = fullUrl.substring(stmtsStartIndex + 6, endIndex);
|
// +1 is for the =
|
||||||
|
const stmtsEncoded = fullUrl.substring(stmtsStartIndex + statementsKey.length, endIndex);
|
||||||
filter.statements = this.decodeFilterStatements(stmtsEncoded);
|
filter.statements = this.decodeFilterStatements(stmtsEncoded);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (queryParams.sortOptions) {
|
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) {
|
if (sortOptions) {
|
||||||
filter.sortOptions = sortOptions;
|
filter.sortOptions = sortOptions;
|
||||||
}
|
}
|
||||||
@ -174,7 +152,7 @@ export class FilterUtilitiesService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
decodeSortOptions(encodedSortOptions: string): SortOptions | null {
|
decodeSortOptions(encodedSortOptions: string): SortOptions | null {
|
||||||
const parts = encodedSortOptions.split('&');
|
const parts = decodeURIComponent(encodedSortOptions).split('&');
|
||||||
const sortFieldPart = parts.find(part => part.startsWith('sortField='));
|
const sortFieldPart = parts.find(part => part.startsWith('sortField='));
|
||||||
const isAscendingPart = parts.find(part => part.startsWith('isAscending='));
|
const isAscendingPart = parts.find(part => part.startsWith('isAscending='));
|
||||||
|
|
||||||
@ -188,7 +166,7 @@ export class FilterUtilitiesService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
decodeFilterStatements(encodedStatements: string): FilterStatement[] {
|
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 => {
|
return statementStrings.map(statementString => {
|
||||||
const parts = statementString.split('&');
|
const parts = statementString.split('&');
|
||||||
if (parts === null || parts.length < 3) return null;
|
if (parts === null || parts.length < 3) return null;
|
||||||
|
@ -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 {FormControl} from '@angular/forms';
|
||||||
import { BarChartModule } from '@swimlane/ngx-charts';
|
import { BarChartModule } from '@swimlane/ngx-charts';
|
||||||
import {map, Observable} from 'rxjs';
|
import {map, Observable} from 'rxjs';
|
||||||
@ -18,8 +18,9 @@ import {TranslocoDirective} from "@ngneat/transloco";
|
|||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [BarChartModule, AsyncPipe, TranslocoDirective]
|
imports: [BarChartModule, AsyncPipe, TranslocoDirective]
|
||||||
})
|
})
|
||||||
export class DayBreakdownComponent {
|
export class DayBreakdownComponent implements OnInit {
|
||||||
|
|
||||||
|
@Input() userId = 0;
|
||||||
view: [number, number] = [0,0];
|
view: [number, number] = [0,0];
|
||||||
showLegend: boolean = true;
|
showLegend: boolean = true;
|
||||||
|
|
||||||
@ -27,9 +28,11 @@ export class DayBreakdownComponent {
|
|||||||
dayBreakdown$!: Observable<Array<PieDataItem>>;
|
dayBreakdown$!: Observable<Array<PieDataItem>>;
|
||||||
private readonly destroyRef = inject(DestroyRef);
|
private readonly destroyRef = inject(DestroyRef);
|
||||||
|
|
||||||
constructor(private statService: StatisticsService) {
|
constructor(private statService: StatisticsService) {}
|
||||||
|
|
||||||
|
ngOnInit() {
|
||||||
const dayOfWeekPipe = new DayOfWeekPipe();
|
const dayOfWeekPipe = new DayOfWeekPipe();
|
||||||
this.dayBreakdown$ = this.statService.getDayBreakdown().pipe(
|
this.dayBreakdown$ = this.statService.getDayBreakdown(this.userId).pipe(
|
||||||
map((data: Array<StatCount<DayOfWeek>>) => {
|
map((data: Array<StatCount<DayOfWeek>>) => {
|
||||||
return data.map(d => {
|
return data.map(d => {
|
||||||
return {name: dayOfWeekPipe.transform(d.value), value: d.count};
|
return {name: dayOfWeekPipe.transform(d.value), value: d.count};
|
||||||
|
@ -2,7 +2,7 @@ import {ChangeDetectionStrategy, Component, DestroyRef, HostListener, inject} fr
|
|||||||
import {Router} from '@angular/router';
|
import {Router} from '@angular/router';
|
||||||
import {NgbModal} from '@ng-bootstrap/ng-bootstrap';
|
import {NgbModal} from '@ng-bootstrap/ng-bootstrap';
|
||||||
import {map, Observable, ReplaySubject, shareReplay} from 'rxjs';
|
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 {Breakpoint, UtilityService} from 'src/app/shared/_services/utility.service';
|
||||||
import {Series} from 'src/app/_models/series';
|
import {Series} from 'src/app/_models/series';
|
||||||
import {ImageService} from 'src/app/_services/image.service';
|
import {ImageService} from 'src/app/_services/image.service';
|
||||||
|
@ -14,6 +14,12 @@
|
|||||||
</div>
|
</div>
|
||||||
</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">
|
<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>
|
<app-stat-list [data$]="percentageRead$" [label]="t('read-percentage')" [title]="t('library-read-progress-title')"></app-stat-list>
|
||||||
</div>
|
</div>
|
||||||
|
@ -13,6 +13,7 @@ import {StatListComponent} from '../stat-list/stat-list.component';
|
|||||||
import {ReadingActivityComponent} from '../reading-activity/reading-activity.component';
|
import {ReadingActivityComponent} from '../reading-activity/reading-activity.component';
|
||||||
import {UserStatsInfoCardsComponent} from '../user-stats-info-cards/user-stats-info-cards.component';
|
import {UserStatsInfoCardsComponent} from '../user-stats-info-cards/user-stats-info-cards.component';
|
||||||
import {TranslocoModule} from "@ngneat/transloco";
|
import {TranslocoModule} from "@ngneat/transloco";
|
||||||
|
import {DayBreakdownComponent} from "../day-breakdown/day-breakdown.component";
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-user-stats',
|
selector: 'app-user-stats',
|
||||||
@ -27,6 +28,7 @@ import {TranslocoModule} from "@ngneat/transloco";
|
|||||||
StatListComponent,
|
StatListComponent,
|
||||||
AsyncPipe,
|
AsyncPipe,
|
||||||
TranslocoModule,
|
TranslocoModule,
|
||||||
|
DayBreakdownComponent,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class UserStatsComponent implements OnInit {
|
export class UserStatsComponent implements OnInit {
|
||||||
|
13
openapi.json
13
openapi.json
@ -7,7 +7,7 @@
|
|||||||
"name": "GPL-3.0",
|
"name": "GPL-3.0",
|
||||||
"url": "https://github.com/Kareadita/Kavita/blob/develop/LICENSE"
|
"url": "https://github.com/Kareadita/Kavita/blob/develop/LICENSE"
|
||||||
},
|
},
|
||||||
"version": "0.7.7.9"
|
"version": "0.7.7.10"
|
||||||
},
|
},
|
||||||
"servers": [
|
"servers": [
|
||||||
{
|
{
|
||||||
@ -9855,6 +9855,17 @@
|
|||||||
"tags": [
|
"tags": [
|
||||||
"Stats"
|
"Stats"
|
||||||
],
|
],
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "userId",
|
||||||
|
"in": "query",
|
||||||
|
"schema": {
|
||||||
|
"type": "integer",
|
||||||
|
"format": "int32",
|
||||||
|
"default": 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
"responses": {
|
"responses": {
|
||||||
"200": {
|
"200": {
|
||||||
"description": "Success",
|
"description": "Success",
|
||||||
|
Loading…
x
Reference in New Issue
Block a user