diff --git a/API.Tests/Helpers/SmartFilterHelperTests.cs b/API.Tests/Helpers/SmartFilterHelperTests.cs new file mode 100644 index 000000000..3d9fbc3ca --- /dev/null +++ b/API.Tests/Helpers/SmartFilterHelperTests.cs @@ -0,0 +1,40 @@ +using System.Linq; +using API.DTOs.Filtering; +using API.DTOs.Filtering.v2; +using API.Entities.Enums; +using API.Helpers; +using Xunit; + +namespace API.Tests.Helpers; + +public class SmartFilterHelperTests +{ + [Fact] + public void Test_Decode() + { + var encoded = """ + stmts=comparison%3D5%26field%3D18%26value%3D6%2Ccomparison%3D0%26field%3D4%26value%3D0%2Ccomparison%3D7%26field%3D1%26value%3Da&sortOptions=sortField=1&isAscending=true&limitTo=0&combination=1 + """; + + var filter = SmartFilterHelper.Decode(encoded); + + Assert.Equal(0, filter.LimitTo); + Assert.Equal(SortField.SortName, filter.SortOptions.SortField); + Assert.True(filter.SortOptions.IsAscending); + Assert.Null(filter.Name); + + var list = filter.Statements.ToList(); + AssertStatementSame(list[2], FilterField.SeriesName, FilterComparison.Matches, "a"); + AssertStatementSame(list[1], FilterField.AgeRating, FilterComparison.Equal, (int) AgeRating.Unknown + ""); + AssertStatementSame(list[0], FilterField.Genres, FilterComparison.Contains, "6"); + + } + + private void AssertStatementSame(FilterStatementDto statement, FilterField field, FilterComparison combination, string value) + { + Assert.Equal(statement.Field, field); + Assert.Equal(statement.Comparison, combination); + Assert.Equal(statement.Value, value); + } + +} diff --git a/API/Controllers/AccountController.cs b/API/Controllers/AccountController.cs index 0ff5882d5..e1742a519 100644 --- a/API/Controllers/AccountController.cs +++ b/API/Controllers/AccountController.cs @@ -8,6 +8,7 @@ using API.Data; using API.Data.Repositories; using API.DTOs; using API.DTOs.Account; +using API.DTOs.Dashboard; using API.DTOs.Email; using API.Entities; using API.Entities.Enums; @@ -1035,4 +1036,123 @@ public class AccountController : BaseApiController return Ok(origin + "/" + baseUrl + "api/opds/" + user!.ApiKey); } + + /// + /// Returns the layout of the user's dashboard + /// + /// + [HttpGet("dashboard")] + public async Task>> GetDashboardLayout(bool visibleOnly = true) + { + var streams = await _unitOfWork.UserRepository.GetDashboardStreams(User.GetUserId(), visibleOnly); + return Ok(streams); + } + + /// + /// Creates a Dashboard Stream from a SmartFilter and adds it to the user's dashboard as visible + /// + /// + /// + [HttpPost("add-dashboard-stream")] + public async Task> AddDashboard([FromQuery] int smartFilterId) + { + var user = await _unitOfWork.UserRepository.GetUserByIdAsync(User.GetUserId(), AppUserIncludes.DashboardStreams); + if (user == null) return Unauthorized(); + + var smartFilter = await _unitOfWork.AppUserSmartFilterRepository.GetById(smartFilterId); + if (smartFilter == null) return NoContent(); + + var stream = user?.DashboardStreams.FirstOrDefault(d => d.SmartFilter?.Id == smartFilterId); + if (stream != null) return BadRequest("There is an existing stream with this Filter"); + + var maxOrder = user!.DashboardStreams.Max(d => d.Order); + var createdStream = new AppUserDashboardStream() + { + Name = smartFilter.Name, + IsProvided = false, + StreamType = DashboardStreamType.SmartFilter, + Visible = true, + Order = maxOrder + 1, + SmartFilter = smartFilter + }; + + user.DashboardStreams.Add(createdStream); + _unitOfWork.UserRepository.Update(user); + await _unitOfWork.CommitAsync(); + + var ret = new DashboardStreamDto() + { + Name = createdStream.Name, + IsProvided = createdStream.IsProvided, + Visible = createdStream.Visible, + Order = createdStream.Order, + SmartFilterEncoded = smartFilter.Filter, + StreamType = createdStream.StreamType + }; + + + await _eventHub.SendMessageToAsync(MessageFactory.DashboardUpdate, MessageFactory.DashboardUpdateEvent(user.Id), + User.GetUserId()); + return Ok(ret); + } + + /// + /// Updates the visibility of a dashboard stream + /// + /// + /// + [HttpPost("update-dashboard-stream")] + public async Task UpdateDashboardStream(DashboardStreamDto dto) + { + var stream = await _unitOfWork.UserRepository.GetDashboardStream(dto.Id); + if (stream == null) return BadRequest(); + stream.Visible = dto.Visible; + + _unitOfWork.UserRepository.Update(stream); + await _unitOfWork.CommitAsync(); + var userId = User.GetUserId(); + await _eventHub.SendMessageToAsync(MessageFactory.DashboardUpdate, MessageFactory.DashboardUpdateEvent(userId), + userId); + return Ok(); + } + + /// + /// Updates the position of a dashboard stream + /// + /// + /// + [HttpPost("update-dashboard-position")] + public async Task UpdateDashboardStreamPosition(UpdateDashboardStreamPositionDto dto) + { + var user = await _unitOfWork.UserRepository.GetUserByIdAsync(User.GetUserId(), + AppUserIncludes.DashboardStreams); + var stream = user?.DashboardStreams.FirstOrDefault(d => d.Id == dto.DashboardStreamId); + if (stream == null) return BadRequest(); + if (stream.Order == dto.ToPosition) return Ok(); + + var list = user!.DashboardStreams.ToList(); + ReorderItems(list, stream.Id, dto.ToPosition); + user.DashboardStreams = list; + + _unitOfWork.UserRepository.Update(user); + await _unitOfWork.CommitAsync(); + await _eventHub.SendMessageToAsync(MessageFactory.DashboardUpdate, MessageFactory.DashboardUpdateEvent(user.Id), + user.Id); + return Ok(); + } + + private static void ReorderItems(List items, int itemId, int toPosition) + { + var item = items.Find(r => r.Id == itemId); + if (item != null) + { + items.Remove(item); + items.Insert(toPosition, item); + } + + for (var i = 0; i < items.Count; i++) + { + items[i].Order = i; + } + } } diff --git a/API/Controllers/FilterController.cs b/API/Controllers/FilterController.cs index 7b6e41ef8..6a2d06ee5 100644 --- a/API/Controllers/FilterController.cs +++ b/API/Controllers/FilterController.cs @@ -1,8 +1,15 @@ using System; +using System.Collections.Generic; +using System.Linq; using System.Threading.Tasks; using API.Constants; using API.Data; +using API.Data.Repositories; +using API.DTOs.Dashboard; using API.DTOs.Filtering.v2; +using API.Entities; +using API.Extensions; +using API.Helpers; using EasyCaching.Core; using Microsoft.AspNetCore.Mvc; @@ -22,38 +29,66 @@ public class FilterController : BaseApiController _cacheFactory = cacheFactory; } - [HttpGet] - public async Task> GetFilter(string name) + /// + /// Creates or Updates the filter + /// + /// + /// + [HttpPost("update")] + public async Task CreateOrUpdateSmartFilter(FilterV2Dto dto) { - var provider = _cacheFactory.GetCachingProvider(EasyCacheProfiles.Filter); - if (string.IsNullOrEmpty(name)) return Ok(null); - var filter = await provider.GetAsync(name); - if (filter.HasValue) + var user = await _unitOfWork.UserRepository.GetUserByIdAsync(User.GetUserId(), AppUserIncludes.SmartFilters); + if (user == null) return Unauthorized(); + + if (string.IsNullOrWhiteSpace(dto.Name)) return BadRequest("Name must be set"); + if (Seed.DefaultStreams.Any(s => s.Name.Equals(dto.Name, StringComparison.InvariantCultureIgnoreCase))) { - filter.Value.Name = name; - return Ok(filter.Value); + return BadRequest("You cannot use the name of a system provided stream"); } - return Ok(null); + // I might just want to use DashboardStream instead of a separate entity. It will drastically simplify implementation + + var existingFilter = + user.SmartFilters.FirstOrDefault(f => f.Name.Equals(dto.Name, StringComparison.InvariantCultureIgnoreCase)); + if (existingFilter != null) + { + // Update the filter + existingFilter.Filter = SmartFilterHelper.Encode(dto); + _unitOfWork.AppUserSmartFilterRepository.Update(existingFilter); + } + else + { + existingFilter = new AppUserSmartFilter() + { + Name = dto.Name, + Filter = SmartFilterHelper.Encode(dto) + }; + user.SmartFilters.Add(existingFilter); + _unitOfWork.UserRepository.Update(user); + } + + if (!_unitOfWork.HasChanges()) return Ok(); + await _unitOfWork.CommitAsync(); + + return Ok(); } - /// - /// Caches the filter in the backend and returns a temp string for retrieving. - /// - /// The cache line lives for only 1 hour - /// - /// - [HttpPost("create-temp")] - public async Task> CreateTempFilter(FilterV2Dto filterDto) + [HttpGet] + public ActionResult> GetFilters() { - var provider = _cacheFactory.GetCachingProvider(EasyCacheProfiles.Filter); - var name = filterDto.Name; - if (string.IsNullOrEmpty(filterDto.Name)) - { - name = Guid.NewGuid().ToString(); - } + return Ok(_unitOfWork.AppUserSmartFilterRepository.GetAllDtosByUserId(User.GetUserId())); + } - await provider.SetAsync(name, filterDto, TimeSpan.FromHours(1)); - return name; + [HttpDelete] + public async Task DeleteFilter(int filterId) + { + var filter = await _unitOfWork.AppUserSmartFilterRepository.GetById(filterId); + if (filter == null) return Ok(); + // This needs to delete any dashboard filters that have it too + var streams = await _unitOfWork.UserRepository.GetDashboardStreamWithFilter(filter.Id); + _unitOfWork.UserRepository.Delete(streams); + _unitOfWork.AppUserSmartFilterRepository.Delete(filter); + await _unitOfWork.CommitAsync(); + return Ok(); } } diff --git a/API/Controllers/LocaleController.cs b/API/Controllers/LocaleController.cs index dde8b0d03..de1c0d16c 100644 --- a/API/Controllers/LocaleController.cs +++ b/API/Controllers/LocaleController.cs @@ -1,4 +1,5 @@ -using System.Collections.Generic; +using System; +using System.Collections.Generic; using System.Globalization; using System.Linq; using API.DTOs.Filtering; @@ -19,11 +20,26 @@ public class LocaleController : BaseApiController [HttpGet] public ActionResult> GetAllLocales() { - var languages = _localizationService.GetLocales().Select(c => new CultureInfo(c)).Select(c => - new LanguageDto() + var languages = _localizationService.GetLocales().Select(c => { - Title = c.DisplayName, - IsoCode = c.IetfLanguageTag + try + { + var cult = new CultureInfo(c); + return new LanguageDto() + { + Title = cult.DisplayName, + IsoCode = cult.IetfLanguageTag + }; + } + catch (Exception ex) + { + // Some OS' don't have all culture codes supported like PT_BR, thus we need to default + return new LanguageDto() + { + Title = c, + IsoCode = c + }; + } }) .Where(l => !string.IsNullOrEmpty(l.IsoCode)) .OrderBy(d => d.Title); diff --git a/API/Controllers/OPDSController.cs b/API/Controllers/OPDSController.cs index 79380a4ea..d8fd48efd 100644 --- a/API/Controllers/OPDSController.cs +++ b/API/Controllers/OPDSController.cs @@ -102,32 +102,68 @@ public class OpdsController : BaseApiController var feed = CreateFeed("Kavita", string.Empty, apiKey, prefix); SetFeedId(feed, "root"); - feed.Entries.Add(new FeedEntry() + + // Get the user's customized dashboard + var streams = await _unitOfWork.UserRepository.GetDashboardStreams(userId, true); + foreach (var stream in streams) { - Id = "onDeck", - Title = await _localizationService.Translate(userId, "on-deck"), - Content = new FeedEntryContent() + switch (stream.StreamType) { - Text = await _localizationService.Translate(userId, "browse-on-deck") - }, - Links = new List() - { - CreateLink(FeedLinkRelation.SubSection, FeedLinkType.AtomNavigation, $"{prefix}{apiKey}/on-deck"), + case DashboardStreamType.OnDeck: + feed.Entries.Add(new FeedEntry() + { + Id = "onDeck", + Title = await _localizationService.Translate(userId, "on-deck"), + Content = new FeedEntryContent() + { + Text = await _localizationService.Translate(userId, "browse-on-deck") + }, + Links = new List() + { + CreateLink(FeedLinkRelation.SubSection, FeedLinkType.AtomNavigation, $"{prefix}{apiKey}/on-deck"), + } + }); + break; + case DashboardStreamType.NewlyAdded: + feed.Entries.Add(new FeedEntry() + { + Id = "recentlyAdded", + Title = await _localizationService.Translate(userId, "recently-added"), + Content = new FeedEntryContent() + { + Text = await _localizationService.Translate(userId, "browse-recently-added") + }, + Links = new List() + { + CreateLink(FeedLinkRelation.SubSection, FeedLinkType.AtomNavigation, $"{prefix}{apiKey}/recently-added"), + } + }); + break; + case DashboardStreamType.RecentlyUpdated: + // TODO: See if we can implement this and use (count) on series name for number of updates + break; + case DashboardStreamType.MoreInGenre: + // TODO: See if we can implement this + break; + case DashboardStreamType.SmartFilter: + + feed.Entries.Add(new FeedEntry() + { + Id = "smartFilter-" + stream.Id, + Title = stream.Name, + Content = new FeedEntryContent() + { + Text = stream.Name + }, + Links = new List() + { + CreateLink(FeedLinkRelation.SubSection, FeedLinkType.AtomNavigation, $"{prefix}{apiKey}/smart-filter/{stream.SmartFilterId}/"), + } + }); + break; } - }); - feed.Entries.Add(new FeedEntry() - { - Id = "recentlyAdded", - Title = await _localizationService.Translate(userId, "recently-added"), - Content = new FeedEntryContent() - { - Text = await _localizationService.Translate(userId, "browse-recently-added") - }, - Links = new List() - { - CreateLink(FeedLinkRelation.SubSection, FeedLinkType.AtomNavigation, $"{prefix}{apiKey}/recently-added"), - } - }); + } + feed.Entries.Add(new FeedEntry() { Id = "readingList", @@ -180,6 +216,19 @@ public class OpdsController : BaseApiController CreateLink(FeedLinkRelation.SubSection, FeedLinkType.AtomNavigation, $"{prefix}{apiKey}/collections"), } }); + feed.Entries.Add(new FeedEntry() + { + Id = "allSmartFilters", + Title = await _localizationService.Translate(userId, "smart-filters"), + Content = new FeedEntryContent() + { + Text = await _localizationService.Translate(userId, "browse-smart-filters") + }, + Links = new List() + { + CreateLink(FeedLinkRelation.SubSection, FeedLinkType.AtomNavigation, $"{prefix}{apiKey}/smart-filters"), + } + }); return CreateXmlResult(SerializeXml(feed)); } @@ -196,6 +245,67 @@ public class OpdsController : BaseApiController return new Tuple(baseUrl, prefix); } + /// + /// Returns the Series matching this smart filter. If FromDashboard, will only return 20 records. + /// + /// + [HttpGet("{apiKey}/smart-filter/{filterId}")] + [Produces("application/xml")] + public async Task GetSmartFilter(string apiKey, int filterId) + { + var userId = await GetUser(apiKey); + if (!(await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).EnableOpds) + return BadRequest(await _localizationService.Translate(userId, "opds-disabled")); + var (baseUrl, prefix) = await GetPrefix(); + + + var filter = await _unitOfWork.AppUserSmartFilterRepository.GetById(filterId); + if (filter == null) return BadRequest(_localizationService.Translate(userId, "smart-filter-doesnt-exist")); + var feed = CreateFeed(await _localizationService.Translate(userId, "smartFilter-" + filter.Id), $"{prefix}{apiKey}/smart-filter/{filter.Id}/", apiKey, prefix); + SetFeedId(feed, "smartFilter-" + filter.Id); + + var decodedFilter = SmartFilterHelper.Decode(filter.Filter); + var series = await _unitOfWork.SeriesRepository.GetSeriesDtoForLibraryIdV2Async(userId, UserParams.Default, + decodedFilter); + var seriesMetadatas = await _unitOfWork.SeriesRepository.GetSeriesMetadataForIds(series.Select(s => s.Id)); + + foreach (var seriesDto in series) + { + feed.Entries.Add(CreateSeries(seriesDto, seriesMetadatas.First(s => s.SeriesId == seriesDto.Id), apiKey, prefix, baseUrl)); + } + + AddPagination(feed, series, $"{prefix}{apiKey}/smart-filter/{filterId}/"); + return CreateXmlResult(SerializeXml(feed)); + } + + [HttpGet("{apiKey}/smart-filters")] + [Produces("application/xml")] + public async Task GetSmartFilters(string apiKey) + { + var userId = await GetUser(apiKey); + if (!(await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).EnableOpds) + return BadRequest(await _localizationService.Translate(userId, "opds-disabled")); + var (baseUrl, prefix) = await GetPrefix(); + + var filters = _unitOfWork.AppUserSmartFilterRepository.GetAllDtosByUserId(userId); + var feed = CreateFeed(await _localizationService.Translate(userId, "smartFilters"), $"{prefix}{apiKey}/smart-filters", apiKey, prefix); + SetFeedId(feed, "smartFilters"); + foreach (var filter in filters) + { + feed.Entries.Add(new FeedEntry() + { + Id = filter.Id.ToString(), + Title = filter.Name, + Links = new List() + { + CreateLink(FeedLinkRelation.SubSection, FeedLinkType.AtomNavigation, $"{prefix}{apiKey}/smart-filter/{filter.Id}") + } + }); + } + + return CreateXmlResult(SerializeXml(feed)); + } + [HttpGet("{apiKey}/libraries")] [Produces("application/xml")] diff --git a/API/Controllers/SeriesController.cs b/API/Controllers/SeriesController.cs index a86d9626a..97a340ecf 100644 --- a/API/Controllers/SeriesController.cs +++ b/API/Controllers/SeriesController.cs @@ -6,6 +6,7 @@ using API.Constants; using API.Data; using API.Data.Repositories; using API.DTOs; +using API.DTOs.Dashboard; using API.DTOs.Filtering; using API.DTOs.Filtering.v2; using API.DTOs.Metadata; diff --git a/API/DTOs/Dashboard/DashboardStreamDto.cs b/API/DTOs/Dashboard/DashboardStreamDto.cs new file mode 100644 index 000000000..59e5f4f7d --- /dev/null +++ b/API/DTOs/Dashboard/DashboardStreamDto.cs @@ -0,0 +1,30 @@ +using API.DTOs.Filtering.v2; +using API.Entities; +using API.Entities.Enums; + +namespace API.DTOs.Dashboard; + +public class DashboardStreamDto +{ + public int Id { get; set; } + public required string Name { get; set; } + /// + /// Is System Provided + /// + public bool IsProvided { get; set; } + /// + /// Sort Order on the Dashboard + /// + public int Order { get; set; } + /// + /// If Not IsProvided, the appropriate smart filter + /// + /// Encoded filter + public string? SmartFilterEncoded { get; set; } + public int? SmartFilterId { get; set; } + /// + /// For system provided + /// + public DashboardStreamType StreamType { get; set; } + public bool Visible { get; set; } +} diff --git a/API/DTOs/GroupedSeriesDto.cs b/API/DTOs/Dashboard/GroupedSeriesDto.cs similarity index 97% rename from API/DTOs/GroupedSeriesDto.cs rename to API/DTOs/Dashboard/GroupedSeriesDto.cs index 697ae3a53..3b283de34 100644 --- a/API/DTOs/GroupedSeriesDto.cs +++ b/API/DTOs/Dashboard/GroupedSeriesDto.cs @@ -1,7 +1,7 @@ using System; using API.Entities.Enums; -namespace API.DTOs; +namespace API.DTOs.Dashboard; /// /// This is a representation of a Series with some amount of underlying files within it. This is used for Recently Updated Series section /// diff --git a/API/DTOs/RecentlyAddedItemDto.cs b/API/DTOs/Dashboard/RecentlyAddedItemDto.cs similarity index 97% rename from API/DTOs/RecentlyAddedItemDto.cs rename to API/DTOs/Dashboard/RecentlyAddedItemDto.cs index 93ef9ac9a..2e5658e2e 100644 --- a/API/DTOs/RecentlyAddedItemDto.cs +++ b/API/DTOs/Dashboard/RecentlyAddedItemDto.cs @@ -1,7 +1,7 @@ using System; using API.Entities.Enums; -namespace API.DTOs; +namespace API.DTOs.Dashboard; /// /// A mesh of data for Recently added volume/chapters diff --git a/API/DTOs/Dashboard/SmartFilterDto.cs b/API/DTOs/Dashboard/SmartFilterDto.cs new file mode 100644 index 000000000..b23a74c69 --- /dev/null +++ b/API/DTOs/Dashboard/SmartFilterDto.cs @@ -0,0 +1,13 @@ +using API.DTOs.Filtering.v2; + +namespace API.DTOs.Dashboard; + +public class SmartFilterDto +{ + public int Id { get; set; } + public required string Name { get; set; } + /// + /// This is the Filter url encoded. It is decoded and reconstructed into a + /// + public required string Filter { get; set; } +} diff --git a/API/DTOs/Dashboard/UpdateDashboardStreamPositionDto.cs b/API/DTOs/Dashboard/UpdateDashboardStreamPositionDto.cs new file mode 100644 index 000000000..c2320f1a9 --- /dev/null +++ b/API/DTOs/Dashboard/UpdateDashboardStreamPositionDto.cs @@ -0,0 +1,9 @@ +namespace API.DTOs.Dashboard; + +public class UpdateDashboardStreamPositionDto +{ + public int FromPosition { get; set; } + public int ToPosition { get; set; } + public int DashboardStreamId { get; set; } + public string StreamName { get; set; } +} diff --git a/API/DTOs/Filtering/SortField.cs b/API/DTOs/Filtering/SortField.cs index 918b74279..f30b617df 100644 --- a/API/DTOs/Filtering/SortField.cs +++ b/API/DTOs/Filtering/SortField.cs @@ -25,5 +25,9 @@ public enum SortField /// /// Release Year of the Series /// - ReleaseYear = 6 + ReleaseYear = 6, + /// + /// Last time the user had any reading progress + /// + ReadProgress = 7, } diff --git a/API/DTOs/Filtering/v2/FilterField.cs b/API/DTOs/Filtering/v2/FilterField.cs index 73fef1c37..776bc0e26 100644 --- a/API/DTOs/Filtering/v2/FilterField.cs +++ b/API/DTOs/Filtering/v2/FilterField.cs @@ -36,5 +36,10 @@ public enum FilterField /// /// File path /// - FilePath = 25 + FilePath = 25, + /// + /// On Want To Read or Not + /// + WantToRead = 26 + } diff --git a/API/DTOs/Filtering/v2/FilterV2Dto.cs b/API/DTOs/Filtering/v2/FilterV2Dto.cs index 2dff500f7..e25f1e21d 100644 --- a/API/DTOs/Filtering/v2/FilterV2Dto.cs +++ b/API/DTOs/Filtering/v2/FilterV2Dto.cs @@ -10,6 +10,10 @@ namespace API.DTOs.Filtering.v2; /// public class FilterV2Dto { + /// + /// Not used in the UI. + /// + public int Id { get; set; } /// /// The name of the filter /// diff --git a/API/Data/DataContext.cs b/API/Data/DataContext.cs index e63549c6c..b5e6abaa0 100644 --- a/API/Data/DataContext.cs +++ b/API/Data/DataContext.cs @@ -54,6 +54,8 @@ public sealed class DataContext : IdentityDbContext ScrobbleHold { get; set; } = null!; public DbSet AppUserOnDeckRemoval { get; set; } = null!; public DbSet AppUserTableOfContent { get; set; } = null!; + public DbSet AppUserSmartFilter { get; set; } = null!; + public DbSet AppUserDashboardStream { get; set; } = null!; protected override void OnModelCreating(ModelBuilder builder) @@ -119,6 +121,13 @@ public sealed class DataContext : IdentityDbContext() .Property(b => b.ISBN) .HasDefaultValue(string.Empty); + + builder.Entity() + .Property(b => b.StreamType) + .HasDefaultValue(DashboardStreamType.SmartFilter); + builder.Entity() + .HasIndex(e => e.Visible) + .IsUnique(false); } diff --git a/API/Data/Migrations/20230904184205_SmartFilters.Designer.cs b/API/Data/Migrations/20230904184205_SmartFilters.Designer.cs new file mode 100644 index 000000000..2379ec2ad --- /dev/null +++ b/API/Data/Migrations/20230904184205_SmartFilters.Designer.cs @@ -0,0 +1,2310 @@ +// +using System; +using API.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace API.Data.Migrations +{ + [DbContext(typeof(DataContext))] + [Migration("20230904184205_SmartFilters")] + partial class SmartFilters + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "7.0.10"); + + modelBuilder.Entity("API.Entities.AppRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("API.Entities.AppUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AccessFailedCount") + .HasColumnType("INTEGER"); + + b.Property("AgeRestriction") + .HasColumnType("INTEGER"); + + b.Property("AgeRestrictionIncludeUnknowns") + .HasColumnType("INTEGER"); + + b.Property("AniListAccessToken") + .HasColumnType("TEXT"); + + b.Property("ApiKey") + .HasColumnType("TEXT"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("ConfirmationToken") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("EmailConfirmed") + .HasColumnType("INTEGER"); + + b.Property("LastActive") + .HasColumnType("TEXT"); + + b.Property("LastActiveUtc") + .HasColumnType("TEXT"); + + b.Property("LockoutEnabled") + .HasColumnType("INTEGER"); + + b.Property("LockoutEnd") + .HasColumnType("TEXT"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .HasColumnType("TEXT"); + + b.Property("PhoneNumber") + .HasColumnType("TEXT"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("SecurityStamp") + .HasColumnType("TEXT"); + + b.Property("TwoFactorEnabled") + .HasColumnType("INTEGER"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("API.Entities.AppUserBookmark", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("FileName") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("Page") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("AppUserBookmark"); + }); + + modelBuilder.Entity("API.Entities.AppUserOnDeckRemoval", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserOnDeckRemoval"); + }); + + modelBuilder.Entity("API.Entities.AppUserPreferences", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("AutoCloseMenu") + .HasColumnType("INTEGER"); + + b.Property("BackgroundColor") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("#000000"); + + b.Property("BlurUnreadSummaries") + .HasColumnType("INTEGER"); + + b.Property("BookReaderFontFamily") + .HasColumnType("TEXT"); + + b.Property("BookReaderFontSize") + .HasColumnType("INTEGER"); + + b.Property("BookReaderImmersiveMode") + .HasColumnType("INTEGER"); + + b.Property("BookReaderLayoutMode") + .HasColumnType("INTEGER"); + + b.Property("BookReaderLineSpacing") + .HasColumnType("INTEGER"); + + b.Property("BookReaderMargin") + .HasColumnType("INTEGER"); + + b.Property("BookReaderReadingDirection") + .HasColumnType("INTEGER"); + + b.Property("BookReaderTapToPaginate") + .HasColumnType("INTEGER"); + + b.Property("BookReaderWritingStyle") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.Property("BookThemeName") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("Dark"); + + b.Property("CollapseSeriesRelationships") + .HasColumnType("INTEGER"); + + b.Property("EmulateBook") + .HasColumnType("INTEGER"); + + b.Property("GlobalPageLayoutMode") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.Property("LayoutMode") + .HasColumnType("INTEGER"); + + b.Property("Locale") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("en"); + + b.Property("NoTransitions") + .HasColumnType("INTEGER"); + + b.Property("PageSplitOption") + .HasColumnType("INTEGER"); + + b.Property("PromptForDownloadSize") + .HasColumnType("INTEGER"); + + b.Property("ReaderMode") + .HasColumnType("INTEGER"); + + b.Property("ReadingDirection") + .HasColumnType("INTEGER"); + + b.Property("ScalingOption") + .HasColumnType("INTEGER"); + + b.Property("ShareReviews") + .HasColumnType("INTEGER"); + + b.Property("ShowScreenHints") + .HasColumnType("INTEGER"); + + b.Property("SwipeToPaginate") + .HasColumnType("INTEGER"); + + b.Property("ThemeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId") + .IsUnique(); + + b.HasIndex("ThemeId"); + + b.ToTable("AppUserPreferences"); + }); + + modelBuilder.Entity("API.Entities.AppUserProgress", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("BookScrollId") + .HasColumnType("TEXT"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("PagesRead") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("ChapterId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserProgresses"); + }); + + modelBuilder.Entity("API.Entities.AppUserRating", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("HasBeenRated") + .HasColumnType("INTEGER"); + + b.Property("Rating") + .HasColumnType("REAL"); + + b.Property("Review") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("Tagline") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserRating"); + }); + + modelBuilder.Entity("API.Entities.AppUserRole", b => + { + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.Property("RoleId") + .HasColumnType("INTEGER"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("API.Entities.AppUserSmartFilter", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Filter") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("AppUserSmartFilter"); + }); + + modelBuilder.Entity("API.Entities.AppUserTableOfContent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("BookScrollId") + .HasColumnType("TEXT"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("PageNumber") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("ChapterId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserTableOfContent"); + }); + + modelBuilder.Entity("API.Entities.Chapter", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRating") + .HasColumnType("INTEGER"); + + b.Property("AlternateCount") + .HasColumnType("INTEGER"); + + b.Property("AlternateNumber") + .HasColumnType("TEXT"); + + b.Property("AlternateSeries") + .HasColumnType("TEXT"); + + b.Property("AvgHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("Count") + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("ISBN") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue(""); + + b.Property("IsSpecial") + .HasColumnType("INTEGER"); + + b.Property("Language") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("MaxHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("MinHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("Number") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("Range") + .HasColumnType("TEXT"); + + b.Property("ReleaseDate") + .HasColumnType("TEXT"); + + b.Property("SeriesGroup") + .HasColumnType("TEXT"); + + b.Property("StoryArc") + .HasColumnType("TEXT"); + + b.Property("StoryArcNumber") + .HasColumnType("TEXT"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.Property("TitleName") + .HasColumnType("TEXT"); + + b.Property("TotalCount") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.Property("WebLinks") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue(""); + + b.Property("WordCount") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("VolumeId"); + + b.ToTable("Chapter"); + }); + + modelBuilder.Entity("API.Entities.CollectionTag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("Promoted") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Id", "Promoted") + .IsUnique(); + + b.ToTable("CollectionTag"); + }); + + modelBuilder.Entity("API.Entities.Device", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("EmailAddress") + .HasColumnType("TEXT"); + + b.Property("IpAddress") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LastUsed") + .HasColumnType("TEXT"); + + b.Property("LastUsedUtc") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Platform") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("Device"); + }); + + modelBuilder.Entity("API.Entities.FolderPath", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("LastScanned") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("Path") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId"); + + b.ToTable("FolderPath"); + }); + + modelBuilder.Entity("API.Entities.Genre", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedTitle") + .IsUnique(); + + b.ToTable("Genre"); + }); + + modelBuilder.Entity("API.Entities.Library", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AllowScrobbling") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("FolderWatching") + .HasColumnType("INTEGER"); + + b.Property("IncludeInDashboard") + .HasColumnType("INTEGER"); + + b.Property("IncludeInRecommended") + .HasColumnType("INTEGER"); + + b.Property("IncludeInSearch") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LastScanned") + .HasColumnType("TEXT"); + + b.Property("ManageCollections") + .HasColumnType("INTEGER"); + + b.Property("ManageReadingLists") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("Library"); + }); + + modelBuilder.Entity("API.Entities.MangaFile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Bytes") + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("Extension") + .HasColumnType("TEXT"); + + b.Property("FilePath") + .HasColumnType("TEXT"); + + b.Property("Format") + .HasColumnType("INTEGER"); + + b.Property("LastFileAnalysis") + .HasColumnType("TEXT"); + + b.Property("LastFileAnalysisUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ChapterId"); + + b.ToTable("MangaFile"); + }); + + modelBuilder.Entity("API.Entities.MediaError", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Comment") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("Details") + .HasColumnType("TEXT"); + + b.Property("Extension") + .HasColumnType("TEXT"); + + b.Property("FilePath") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("MediaError"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesMetadata", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRating") + .HasColumnType("INTEGER"); + + b.Property("AgeRatingLocked") + .HasColumnType("INTEGER"); + + b.Property("CharacterLocked") + .HasColumnType("INTEGER"); + + b.Property("ColoristLocked") + .HasColumnType("INTEGER"); + + b.Property("CoverArtistLocked") + .HasColumnType("INTEGER"); + + b.Property("EditorLocked") + .HasColumnType("INTEGER"); + + b.Property("GenresLocked") + .HasColumnType("INTEGER"); + + b.Property("InkerLocked") + .HasColumnType("INTEGER"); + + b.Property("Language") + .HasColumnType("TEXT"); + + b.Property("LanguageLocked") + .HasColumnType("INTEGER"); + + b.Property("LettererLocked") + .HasColumnType("INTEGER"); + + b.Property("MaxCount") + .HasColumnType("INTEGER"); + + b.Property("PencillerLocked") + .HasColumnType("INTEGER"); + + b.Property("PublicationStatus") + .HasColumnType("INTEGER"); + + b.Property("PublicationStatusLocked") + .HasColumnType("INTEGER"); + + b.Property("PublisherLocked") + .HasColumnType("INTEGER"); + + b.Property("ReleaseYear") + .HasColumnType("INTEGER"); + + b.Property("ReleaseYearLocked") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("SummaryLocked") + .HasColumnType("INTEGER"); + + b.Property("TagsLocked") + .HasColumnType("INTEGER"); + + b.Property("TotalCount") + .HasColumnType("INTEGER"); + + b.Property("TranslatorLocked") + .HasColumnType("INTEGER"); + + b.Property("WebLinks") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue(""); + + b.Property("WriterLocked") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId") + .IsUnique(); + + b.HasIndex("Id", "SeriesId") + .IsUnique(); + + b.ToTable("SeriesMetadata"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesRelation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("RelationKind") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("TargetSeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId"); + + b.HasIndex("TargetSeriesId"); + + b.ToTable("SeriesRelation"); + }); + + modelBuilder.Entity("API.Entities.Person", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.Property("Role") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("Person"); + }); + + modelBuilder.Entity("API.Entities.ReadingList", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRating") + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("EndingMonth") + .HasColumnType("INTEGER"); + + b.Property("EndingYear") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("NormalizedTitle") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Promoted") + .HasColumnType("INTEGER"); + + b.Property("StartingMonth") + .HasColumnType("INTEGER"); + + b.Property("StartingYear") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Title") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("ReadingList"); + }); + + modelBuilder.Entity("API.Entities.ReadingListItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Order") + .HasColumnType("INTEGER"); + + b.Property("ReadingListId") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ChapterId"); + + b.HasIndex("ReadingListId"); + + b.HasIndex("SeriesId"); + + b.HasIndex("VolumeId"); + + b.ToTable("ReadingListItem"); + }); + + modelBuilder.Entity("API.Entities.Scrobble.ScrobbleError", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Comment") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("Details") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("ScrobbleEventId") + .HasColumnType("INTEGER"); + + b.Property("ScrobbleEventId1") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ScrobbleEventId1"); + + b.HasIndex("SeriesId"); + + b.ToTable("ScrobbleError"); + }); + + modelBuilder.Entity("API.Entities.Scrobble.ScrobbleEvent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AniListId") + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("ChapterNumber") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("Format") + .HasColumnType("INTEGER"); + + b.Property("IsProcessed") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("MalId") + .HasColumnType("INTEGER"); + + b.Property("ProcessDateUtc") + .HasColumnType("TEXT"); + + b.Property("Rating") + .HasColumnType("REAL"); + + b.Property("ReviewBody") + .HasColumnType("TEXT"); + + b.Property("ReviewTitle") + .HasColumnType("TEXT"); + + b.Property("ScrobbleEventType") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeNumber") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("LibraryId"); + + b.HasIndex("SeriesId"); + + b.ToTable("ScrobbleEvent"); + }); + + modelBuilder.Entity("API.Entities.Scrobble.ScrobbleHold", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SeriesId"); + + b.ToTable("ScrobbleHold"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("AvgHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("FolderPath") + .HasColumnType("TEXT"); + + b.Property("Format") + .HasColumnType("INTEGER"); + + b.Property("LastChapterAdded") + .HasColumnType("TEXT"); + + b.Property("LastChapterAddedUtc") + .HasColumnType("TEXT"); + + b.Property("LastFolderScanned") + .HasColumnType("TEXT"); + + b.Property("LastFolderScannedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("LocalizedName") + .HasColumnType("TEXT"); + + b.Property("LocalizedNameLocked") + .HasColumnType("INTEGER"); + + b.Property("MaxHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("MinHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedLocalizedName") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.Property("OriginalName") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("SortName") + .HasColumnType("TEXT"); + + b.Property("SortNameLocked") + .HasColumnType("INTEGER"); + + b.Property("WordCount") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("LibraryId"); + + b.ToTable("Series"); + }); + + modelBuilder.Entity("API.Entities.ServerSetting", b => + { + b.Property("Key") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("Key"); + + b.ToTable("ServerSetting"); + }); + + modelBuilder.Entity("API.Entities.ServerStatistics", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ChapterCount") + .HasColumnType("INTEGER"); + + b.Property("FileCount") + .HasColumnType("INTEGER"); + + b.Property("GenreCount") + .HasColumnType("INTEGER"); + + b.Property("PersonCount") + .HasColumnType("INTEGER"); + + b.Property("SeriesCount") + .HasColumnType("INTEGER"); + + b.Property("TagCount") + .HasColumnType("INTEGER"); + + b.Property("UserCount") + .HasColumnType("INTEGER"); + + b.Property("VolumeCount") + .HasColumnType("INTEGER"); + + b.Property("Year") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("ServerStatistics"); + }); + + modelBuilder.Entity("API.Entities.SiteTheme", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("FileName") + .HasColumnType("TEXT"); + + b.Property("IsDefault") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.Property("Provider") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("SiteTheme"); + }); + + modelBuilder.Entity("API.Entities.Tag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedTitle") + .IsUnique(); + + b.ToTable("Tag"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AvgHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("MaxHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("MinHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Number") + .HasColumnType("INTEGER"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("WordCount") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId"); + + b.ToTable("Volume"); + }); + + modelBuilder.Entity("AppUserLibrary", b => + { + b.Property("AppUsersId") + .HasColumnType("INTEGER"); + + b.Property("LibrariesId") + .HasColumnType("INTEGER"); + + b.HasKey("AppUsersId", "LibrariesId"); + + b.HasIndex("LibrariesId"); + + b.ToTable("AppUserLibrary"); + }); + + modelBuilder.Entity("ChapterGenre", b => + { + b.Property("ChaptersId") + .HasColumnType("INTEGER"); + + b.Property("GenresId") + .HasColumnType("INTEGER"); + + b.HasKey("ChaptersId", "GenresId"); + + b.HasIndex("GenresId"); + + b.ToTable("ChapterGenre"); + }); + + modelBuilder.Entity("ChapterPerson", b => + { + b.Property("ChapterMetadatasId") + .HasColumnType("INTEGER"); + + b.Property("PeopleId") + .HasColumnType("INTEGER"); + + b.HasKey("ChapterMetadatasId", "PeopleId"); + + b.HasIndex("PeopleId"); + + b.ToTable("ChapterPerson"); + }); + + modelBuilder.Entity("ChapterTag", b => + { + b.Property("ChaptersId") + .HasColumnType("INTEGER"); + + b.Property("TagsId") + .HasColumnType("INTEGER"); + + b.HasKey("ChaptersId", "TagsId"); + + b.HasIndex("TagsId"); + + b.ToTable("ChapterTag"); + }); + + modelBuilder.Entity("CollectionTagSeriesMetadata", b => + { + b.Property("CollectionTagsId") + .HasColumnType("INTEGER"); + + b.Property("SeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("CollectionTagsId", "SeriesMetadatasId"); + + b.HasIndex("SeriesMetadatasId"); + + b.ToTable("CollectionTagSeriesMetadata"); + }); + + modelBuilder.Entity("GenreSeriesMetadata", b => + { + b.Property("GenresId") + .HasColumnType("INTEGER"); + + b.Property("SeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("GenresId", "SeriesMetadatasId"); + + b.HasIndex("SeriesMetadatasId"); + + b.ToTable("GenreSeriesMetadata"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("ProviderKey") + .HasColumnType("TEXT"); + + b.Property("ProviderDisplayName") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("PersonSeriesMetadata", b => + { + b.Property("PeopleId") + .HasColumnType("INTEGER"); + + b.Property("SeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("PeopleId", "SeriesMetadatasId"); + + b.HasIndex("SeriesMetadatasId"); + + b.ToTable("PersonSeriesMetadata"); + }); + + modelBuilder.Entity("SeriesMetadataTag", b => + { + b.Property("SeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.Property("TagsId") + .HasColumnType("INTEGER"); + + b.HasKey("SeriesMetadatasId", "TagsId"); + + b.HasIndex("TagsId"); + + b.ToTable("SeriesMetadataTag"); + }); + + modelBuilder.Entity("API.Entities.AppUserBookmark", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Bookmarks") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserOnDeckRemoval", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany() + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.AppUserPreferences", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithOne("UserPreferences") + .HasForeignKey("API.Entities.AppUserPreferences", "AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.SiteTheme", "Theme") + .WithMany() + .HasForeignKey("ThemeId"); + + b.Navigation("AppUser"); + + b.Navigation("Theme"); + }); + + modelBuilder.Entity("API.Entities.AppUserProgress", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Progresses") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Chapter", null) + .WithMany("UserProgress") + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", null) + .WithMany("Progress") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserRating", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Ratings") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany("Ratings") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.AppUserRole", b => + { + b.HasOne("API.Entities.AppRole", "Role") + .WithMany("UserRoles") + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.AppUser", "User") + .WithMany("UserRoles") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Role"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("API.Entities.AppUserSmartFilter", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("SmartFilters") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserTableOfContent", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("TableOfContents") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany() + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Chapter"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Chapter", b => + { + b.HasOne("API.Entities.Volume", "Volume") + .WithMany("Chapters") + .HasForeignKey("VolumeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Volume"); + }); + + modelBuilder.Entity("API.Entities.Device", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Devices") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.FolderPath", b => + { + b.HasOne("API.Entities.Library", "Library") + .WithMany("Folders") + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Library"); + }); + + modelBuilder.Entity("API.Entities.MangaFile", b => + { + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany("Files") + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Chapter"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesMetadata", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithOne("Metadata") + .HasForeignKey("API.Entities.Metadata.SeriesMetadata", "SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesRelation", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithMany("Relations") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "TargetSeries") + .WithMany("RelationOf") + .HasForeignKey("TargetSeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + + b.Navigation("TargetSeries"); + }); + + modelBuilder.Entity("API.Entities.ReadingList", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("ReadingLists") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.ReadingListItem", b => + { + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany() + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.ReadingList", "ReadingList") + .WithMany("Items") + .HasForeignKey("ReadingListId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Volume", "Volume") + .WithMany() + .HasForeignKey("VolumeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Chapter"); + + b.Navigation("ReadingList"); + + b.Navigation("Series"); + + b.Navigation("Volume"); + }); + + modelBuilder.Entity("API.Entities.Scrobble.ScrobbleError", b => + { + b.HasOne("API.Entities.Scrobble.ScrobbleEvent", "ScrobbleEvent") + .WithMany() + .HasForeignKey("ScrobbleEventId1"); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("ScrobbleEvent"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Scrobble.ScrobbleEvent", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany() + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Library", "Library") + .WithMany() + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Library"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Scrobble.ScrobbleHold", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("ScrobbleHolds") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany("WantToRead") + .HasForeignKey("AppUserId"); + + b.HasOne("API.Entities.Library", "Library") + .WithMany("Series") + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Library"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithMany("Volumes") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("AppUserLibrary", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("AppUsersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Library", null) + .WithMany() + .HasForeignKey("LibrariesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ChapterGenre", b => + { + b.HasOne("API.Entities.Chapter", null) + .WithMany() + .HasForeignKey("ChaptersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Genre", null) + .WithMany() + .HasForeignKey("GenresId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ChapterPerson", b => + { + b.HasOne("API.Entities.Chapter", null) + .WithMany() + .HasForeignKey("ChapterMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Person", null) + .WithMany() + .HasForeignKey("PeopleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ChapterTag", b => + { + b.HasOne("API.Entities.Chapter", null) + .WithMany() + .HasForeignKey("ChaptersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Tag", null) + .WithMany() + .HasForeignKey("TagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("CollectionTagSeriesMetadata", b => + { + b.HasOne("API.Entities.CollectionTag", null) + .WithMany() + .HasForeignKey("CollectionTagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.SeriesMetadata", null) + .WithMany() + .HasForeignKey("SeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("GenreSeriesMetadata", b => + { + b.HasOne("API.Entities.Genre", null) + .WithMany() + .HasForeignKey("GenresId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.SeriesMetadata", null) + .WithMany() + .HasForeignKey("SeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("API.Entities.AppRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("PersonSeriesMetadata", b => + { + b.HasOne("API.Entities.Person", null) + .WithMany() + .HasForeignKey("PeopleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.SeriesMetadata", null) + .WithMany() + .HasForeignKey("SeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SeriesMetadataTag", b => + { + b.HasOne("API.Entities.Metadata.SeriesMetadata", null) + .WithMany() + .HasForeignKey("SeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Tag", null) + .WithMany() + .HasForeignKey("TagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("API.Entities.AppRole", b => + { + b.Navigation("UserRoles"); + }); + + modelBuilder.Entity("API.Entities.AppUser", b => + { + b.Navigation("Bookmarks"); + + b.Navigation("Devices"); + + b.Navigation("Progresses"); + + b.Navigation("Ratings"); + + b.Navigation("ReadingLists"); + + b.Navigation("ScrobbleHolds"); + + b.Navigation("SmartFilters"); + + b.Navigation("TableOfContents"); + + b.Navigation("UserPreferences"); + + b.Navigation("UserRoles"); + + b.Navigation("WantToRead"); + }); + + modelBuilder.Entity("API.Entities.Chapter", b => + { + b.Navigation("Files"); + + b.Navigation("UserProgress"); + }); + + modelBuilder.Entity("API.Entities.Library", b => + { + b.Navigation("Folders"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.ReadingList", b => + { + b.Navigation("Items"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + b.Navigation("Metadata"); + + b.Navigation("Progress"); + + b.Navigation("Ratings"); + + b.Navigation("RelationOf"); + + b.Navigation("Relations"); + + b.Navigation("Volumes"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.Navigation("Chapters"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/API/Data/Migrations/20230904184205_SmartFilters.cs b/API/Data/Migrations/20230904184205_SmartFilters.cs new file mode 100644 index 000000000..c902b907b --- /dev/null +++ b/API/Data/Migrations/20230904184205_SmartFilters.cs @@ -0,0 +1,47 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace API.Data.Migrations +{ + /// + public partial class SmartFilters : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "AppUserSmartFilter", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + Name = table.Column(type: "TEXT", nullable: true), + Filter = table.Column(type: "TEXT", nullable: true), + AppUserId = table.Column(type: "INTEGER", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_AppUserSmartFilter", x => x.Id); + table.ForeignKey( + name: "FK_AppUserSmartFilter_AspNetUsers_AppUserId", + column: x => x.AppUserId, + principalTable: "AspNetUsers", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_AppUserSmartFilter_AppUserId", + table: "AppUserSmartFilter", + column: "AppUserId"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "AppUserSmartFilter"); + } + } +} diff --git a/API/Data/Migrations/20230908190713_DashboardStream.Designer.cs b/API/Data/Migrations/20230908190713_DashboardStream.Designer.cs new file mode 100644 index 000000000..8e436f836 --- /dev/null +++ b/API/Data/Migrations/20230908190713_DashboardStream.Designer.cs @@ -0,0 +1,2369 @@ +// +using System; +using API.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace API.Data.Migrations +{ + [DbContext(typeof(DataContext))] + [Migration("20230908190713_DashboardStream")] + partial class DashboardStream + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "7.0.10"); + + modelBuilder.Entity("API.Entities.AppRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("API.Entities.AppUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AccessFailedCount") + .HasColumnType("INTEGER"); + + b.Property("AgeRestriction") + .HasColumnType("INTEGER"); + + b.Property("AgeRestrictionIncludeUnknowns") + .HasColumnType("INTEGER"); + + b.Property("AniListAccessToken") + .HasColumnType("TEXT"); + + b.Property("ApiKey") + .HasColumnType("TEXT"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("ConfirmationToken") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("EmailConfirmed") + .HasColumnType("INTEGER"); + + b.Property("LastActive") + .HasColumnType("TEXT"); + + b.Property("LastActiveUtc") + .HasColumnType("TEXT"); + + b.Property("LockoutEnabled") + .HasColumnType("INTEGER"); + + b.Property("LockoutEnd") + .HasColumnType("TEXT"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .HasColumnType("TEXT"); + + b.Property("PhoneNumber") + .HasColumnType("TEXT"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("SecurityStamp") + .HasColumnType("TEXT"); + + b.Property("TwoFactorEnabled") + .HasColumnType("INTEGER"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("API.Entities.AppUserBookmark", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("FileName") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("Page") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("AppUserBookmark"); + }); + + modelBuilder.Entity("API.Entities.AppUserDashboardStream", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("IsProvided") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Order") + .HasColumnType("INTEGER"); + + b.Property("SmartFilterId") + .HasColumnType("INTEGER"); + + b.Property("StreamType") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(4); + + b.Property("Visible") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SmartFilterId"); + + b.HasIndex("Visible"); + + b.ToTable("AppUserDashboardStream"); + }); + + modelBuilder.Entity("API.Entities.AppUserOnDeckRemoval", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserOnDeckRemoval"); + }); + + modelBuilder.Entity("API.Entities.AppUserPreferences", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("AutoCloseMenu") + .HasColumnType("INTEGER"); + + b.Property("BackgroundColor") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("#000000"); + + b.Property("BlurUnreadSummaries") + .HasColumnType("INTEGER"); + + b.Property("BookReaderFontFamily") + .HasColumnType("TEXT"); + + b.Property("BookReaderFontSize") + .HasColumnType("INTEGER"); + + b.Property("BookReaderImmersiveMode") + .HasColumnType("INTEGER"); + + b.Property("BookReaderLayoutMode") + .HasColumnType("INTEGER"); + + b.Property("BookReaderLineSpacing") + .HasColumnType("INTEGER"); + + b.Property("BookReaderMargin") + .HasColumnType("INTEGER"); + + b.Property("BookReaderReadingDirection") + .HasColumnType("INTEGER"); + + b.Property("BookReaderTapToPaginate") + .HasColumnType("INTEGER"); + + b.Property("BookReaderWritingStyle") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.Property("BookThemeName") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("Dark"); + + b.Property("CollapseSeriesRelationships") + .HasColumnType("INTEGER"); + + b.Property("EmulateBook") + .HasColumnType("INTEGER"); + + b.Property("GlobalPageLayoutMode") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.Property("LayoutMode") + .HasColumnType("INTEGER"); + + b.Property("Locale") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("en"); + + b.Property("NoTransitions") + .HasColumnType("INTEGER"); + + b.Property("PageSplitOption") + .HasColumnType("INTEGER"); + + b.Property("PromptForDownloadSize") + .HasColumnType("INTEGER"); + + b.Property("ReaderMode") + .HasColumnType("INTEGER"); + + b.Property("ReadingDirection") + .HasColumnType("INTEGER"); + + b.Property("ScalingOption") + .HasColumnType("INTEGER"); + + b.Property("ShareReviews") + .HasColumnType("INTEGER"); + + b.Property("ShowScreenHints") + .HasColumnType("INTEGER"); + + b.Property("SwipeToPaginate") + .HasColumnType("INTEGER"); + + b.Property("ThemeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId") + .IsUnique(); + + b.HasIndex("ThemeId"); + + b.ToTable("AppUserPreferences"); + }); + + modelBuilder.Entity("API.Entities.AppUserProgress", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("BookScrollId") + .HasColumnType("TEXT"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("PagesRead") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("ChapterId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserProgresses"); + }); + + modelBuilder.Entity("API.Entities.AppUserRating", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("HasBeenRated") + .HasColumnType("INTEGER"); + + b.Property("Rating") + .HasColumnType("REAL"); + + b.Property("Review") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("Tagline") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserRating"); + }); + + modelBuilder.Entity("API.Entities.AppUserRole", b => + { + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.Property("RoleId") + .HasColumnType("INTEGER"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("API.Entities.AppUserSmartFilter", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Filter") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("AppUserSmartFilter"); + }); + + modelBuilder.Entity("API.Entities.AppUserTableOfContent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("BookScrollId") + .HasColumnType("TEXT"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("PageNumber") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("ChapterId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserTableOfContent"); + }); + + modelBuilder.Entity("API.Entities.Chapter", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRating") + .HasColumnType("INTEGER"); + + b.Property("AlternateCount") + .HasColumnType("INTEGER"); + + b.Property("AlternateNumber") + .HasColumnType("TEXT"); + + b.Property("AlternateSeries") + .HasColumnType("TEXT"); + + b.Property("AvgHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("Count") + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("ISBN") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue(""); + + b.Property("IsSpecial") + .HasColumnType("INTEGER"); + + b.Property("Language") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("MaxHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("MinHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("Number") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("Range") + .HasColumnType("TEXT"); + + b.Property("ReleaseDate") + .HasColumnType("TEXT"); + + b.Property("SeriesGroup") + .HasColumnType("TEXT"); + + b.Property("StoryArc") + .HasColumnType("TEXT"); + + b.Property("StoryArcNumber") + .HasColumnType("TEXT"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.Property("TitleName") + .HasColumnType("TEXT"); + + b.Property("TotalCount") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.Property("WebLinks") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue(""); + + b.Property("WordCount") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("VolumeId"); + + b.ToTable("Chapter"); + }); + + modelBuilder.Entity("API.Entities.CollectionTag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("Promoted") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Id", "Promoted") + .IsUnique(); + + b.ToTable("CollectionTag"); + }); + + modelBuilder.Entity("API.Entities.Device", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("EmailAddress") + .HasColumnType("TEXT"); + + b.Property("IpAddress") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LastUsed") + .HasColumnType("TEXT"); + + b.Property("LastUsedUtc") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Platform") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("Device"); + }); + + modelBuilder.Entity("API.Entities.FolderPath", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("LastScanned") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("Path") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId"); + + b.ToTable("FolderPath"); + }); + + modelBuilder.Entity("API.Entities.Genre", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedTitle") + .IsUnique(); + + b.ToTable("Genre"); + }); + + modelBuilder.Entity("API.Entities.Library", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AllowScrobbling") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("FolderWatching") + .HasColumnType("INTEGER"); + + b.Property("IncludeInDashboard") + .HasColumnType("INTEGER"); + + b.Property("IncludeInRecommended") + .HasColumnType("INTEGER"); + + b.Property("IncludeInSearch") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LastScanned") + .HasColumnType("TEXT"); + + b.Property("ManageCollections") + .HasColumnType("INTEGER"); + + b.Property("ManageReadingLists") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("Library"); + }); + + modelBuilder.Entity("API.Entities.MangaFile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Bytes") + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("Extension") + .HasColumnType("TEXT"); + + b.Property("FilePath") + .HasColumnType("TEXT"); + + b.Property("Format") + .HasColumnType("INTEGER"); + + b.Property("LastFileAnalysis") + .HasColumnType("TEXT"); + + b.Property("LastFileAnalysisUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ChapterId"); + + b.ToTable("MangaFile"); + }); + + modelBuilder.Entity("API.Entities.MediaError", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Comment") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("Details") + .HasColumnType("TEXT"); + + b.Property("Extension") + .HasColumnType("TEXT"); + + b.Property("FilePath") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("MediaError"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesMetadata", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRating") + .HasColumnType("INTEGER"); + + b.Property("AgeRatingLocked") + .HasColumnType("INTEGER"); + + b.Property("CharacterLocked") + .HasColumnType("INTEGER"); + + b.Property("ColoristLocked") + .HasColumnType("INTEGER"); + + b.Property("CoverArtistLocked") + .HasColumnType("INTEGER"); + + b.Property("EditorLocked") + .HasColumnType("INTEGER"); + + b.Property("GenresLocked") + .HasColumnType("INTEGER"); + + b.Property("InkerLocked") + .HasColumnType("INTEGER"); + + b.Property("Language") + .HasColumnType("TEXT"); + + b.Property("LanguageLocked") + .HasColumnType("INTEGER"); + + b.Property("LettererLocked") + .HasColumnType("INTEGER"); + + b.Property("MaxCount") + .HasColumnType("INTEGER"); + + b.Property("PencillerLocked") + .HasColumnType("INTEGER"); + + b.Property("PublicationStatus") + .HasColumnType("INTEGER"); + + b.Property("PublicationStatusLocked") + .HasColumnType("INTEGER"); + + b.Property("PublisherLocked") + .HasColumnType("INTEGER"); + + b.Property("ReleaseYear") + .HasColumnType("INTEGER"); + + b.Property("ReleaseYearLocked") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("SummaryLocked") + .HasColumnType("INTEGER"); + + b.Property("TagsLocked") + .HasColumnType("INTEGER"); + + b.Property("TotalCount") + .HasColumnType("INTEGER"); + + b.Property("TranslatorLocked") + .HasColumnType("INTEGER"); + + b.Property("WebLinks") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue(""); + + b.Property("WriterLocked") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId") + .IsUnique(); + + b.HasIndex("Id", "SeriesId") + .IsUnique(); + + b.ToTable("SeriesMetadata"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesRelation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("RelationKind") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("TargetSeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId"); + + b.HasIndex("TargetSeriesId"); + + b.ToTable("SeriesRelation"); + }); + + modelBuilder.Entity("API.Entities.Person", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.Property("Role") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("Person"); + }); + + modelBuilder.Entity("API.Entities.ReadingList", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRating") + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("EndingMonth") + .HasColumnType("INTEGER"); + + b.Property("EndingYear") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("NormalizedTitle") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Promoted") + .HasColumnType("INTEGER"); + + b.Property("StartingMonth") + .HasColumnType("INTEGER"); + + b.Property("StartingYear") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Title") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("ReadingList"); + }); + + modelBuilder.Entity("API.Entities.ReadingListItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Order") + .HasColumnType("INTEGER"); + + b.Property("ReadingListId") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ChapterId"); + + b.HasIndex("ReadingListId"); + + b.HasIndex("SeriesId"); + + b.HasIndex("VolumeId"); + + b.ToTable("ReadingListItem"); + }); + + modelBuilder.Entity("API.Entities.Scrobble.ScrobbleError", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Comment") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("Details") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("ScrobbleEventId") + .HasColumnType("INTEGER"); + + b.Property("ScrobbleEventId1") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ScrobbleEventId1"); + + b.HasIndex("SeriesId"); + + b.ToTable("ScrobbleError"); + }); + + modelBuilder.Entity("API.Entities.Scrobble.ScrobbleEvent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AniListId") + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("ChapterNumber") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("Format") + .HasColumnType("INTEGER"); + + b.Property("IsProcessed") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("MalId") + .HasColumnType("INTEGER"); + + b.Property("ProcessDateUtc") + .HasColumnType("TEXT"); + + b.Property("Rating") + .HasColumnType("REAL"); + + b.Property("ReviewBody") + .HasColumnType("TEXT"); + + b.Property("ReviewTitle") + .HasColumnType("TEXT"); + + b.Property("ScrobbleEventType") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeNumber") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("LibraryId"); + + b.HasIndex("SeriesId"); + + b.ToTable("ScrobbleEvent"); + }); + + modelBuilder.Entity("API.Entities.Scrobble.ScrobbleHold", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SeriesId"); + + b.ToTable("ScrobbleHold"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("AvgHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("FolderPath") + .HasColumnType("TEXT"); + + b.Property("Format") + .HasColumnType("INTEGER"); + + b.Property("LastChapterAdded") + .HasColumnType("TEXT"); + + b.Property("LastChapterAddedUtc") + .HasColumnType("TEXT"); + + b.Property("LastFolderScanned") + .HasColumnType("TEXT"); + + b.Property("LastFolderScannedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("LocalizedName") + .HasColumnType("TEXT"); + + b.Property("LocalizedNameLocked") + .HasColumnType("INTEGER"); + + b.Property("MaxHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("MinHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedLocalizedName") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.Property("OriginalName") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("SortName") + .HasColumnType("TEXT"); + + b.Property("SortNameLocked") + .HasColumnType("INTEGER"); + + b.Property("WordCount") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("LibraryId"); + + b.ToTable("Series"); + }); + + modelBuilder.Entity("API.Entities.ServerSetting", b => + { + b.Property("Key") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("Key"); + + b.ToTable("ServerSetting"); + }); + + modelBuilder.Entity("API.Entities.ServerStatistics", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ChapterCount") + .HasColumnType("INTEGER"); + + b.Property("FileCount") + .HasColumnType("INTEGER"); + + b.Property("GenreCount") + .HasColumnType("INTEGER"); + + b.Property("PersonCount") + .HasColumnType("INTEGER"); + + b.Property("SeriesCount") + .HasColumnType("INTEGER"); + + b.Property("TagCount") + .HasColumnType("INTEGER"); + + b.Property("UserCount") + .HasColumnType("INTEGER"); + + b.Property("VolumeCount") + .HasColumnType("INTEGER"); + + b.Property("Year") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("ServerStatistics"); + }); + + modelBuilder.Entity("API.Entities.SiteTheme", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("FileName") + .HasColumnType("TEXT"); + + b.Property("IsDefault") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.Property("Provider") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("SiteTheme"); + }); + + modelBuilder.Entity("API.Entities.Tag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedTitle") + .IsUnique(); + + b.ToTable("Tag"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AvgHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("MaxHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("MinHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Number") + .HasColumnType("INTEGER"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("WordCount") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId"); + + b.ToTable("Volume"); + }); + + modelBuilder.Entity("AppUserLibrary", b => + { + b.Property("AppUsersId") + .HasColumnType("INTEGER"); + + b.Property("LibrariesId") + .HasColumnType("INTEGER"); + + b.HasKey("AppUsersId", "LibrariesId"); + + b.HasIndex("LibrariesId"); + + b.ToTable("AppUserLibrary"); + }); + + modelBuilder.Entity("ChapterGenre", b => + { + b.Property("ChaptersId") + .HasColumnType("INTEGER"); + + b.Property("GenresId") + .HasColumnType("INTEGER"); + + b.HasKey("ChaptersId", "GenresId"); + + b.HasIndex("GenresId"); + + b.ToTable("ChapterGenre"); + }); + + modelBuilder.Entity("ChapterPerson", b => + { + b.Property("ChapterMetadatasId") + .HasColumnType("INTEGER"); + + b.Property("PeopleId") + .HasColumnType("INTEGER"); + + b.HasKey("ChapterMetadatasId", "PeopleId"); + + b.HasIndex("PeopleId"); + + b.ToTable("ChapterPerson"); + }); + + modelBuilder.Entity("ChapterTag", b => + { + b.Property("ChaptersId") + .HasColumnType("INTEGER"); + + b.Property("TagsId") + .HasColumnType("INTEGER"); + + b.HasKey("ChaptersId", "TagsId"); + + b.HasIndex("TagsId"); + + b.ToTable("ChapterTag"); + }); + + modelBuilder.Entity("CollectionTagSeriesMetadata", b => + { + b.Property("CollectionTagsId") + .HasColumnType("INTEGER"); + + b.Property("SeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("CollectionTagsId", "SeriesMetadatasId"); + + b.HasIndex("SeriesMetadatasId"); + + b.ToTable("CollectionTagSeriesMetadata"); + }); + + modelBuilder.Entity("GenreSeriesMetadata", b => + { + b.Property("GenresId") + .HasColumnType("INTEGER"); + + b.Property("SeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("GenresId", "SeriesMetadatasId"); + + b.HasIndex("SeriesMetadatasId"); + + b.ToTable("GenreSeriesMetadata"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("ProviderKey") + .HasColumnType("TEXT"); + + b.Property("ProviderDisplayName") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("PersonSeriesMetadata", b => + { + b.Property("PeopleId") + .HasColumnType("INTEGER"); + + b.Property("SeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("PeopleId", "SeriesMetadatasId"); + + b.HasIndex("SeriesMetadatasId"); + + b.ToTable("PersonSeriesMetadata"); + }); + + modelBuilder.Entity("SeriesMetadataTag", b => + { + b.Property("SeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.Property("TagsId") + .HasColumnType("INTEGER"); + + b.HasKey("SeriesMetadatasId", "TagsId"); + + b.HasIndex("TagsId"); + + b.ToTable("SeriesMetadataTag"); + }); + + modelBuilder.Entity("API.Entities.AppUserBookmark", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Bookmarks") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserDashboardStream", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("DashboardStreams") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.AppUserSmartFilter", "SmartFilter") + .WithMany() + .HasForeignKey("SmartFilterId"); + + b.Navigation("AppUser"); + + b.Navigation("SmartFilter"); + }); + + modelBuilder.Entity("API.Entities.AppUserOnDeckRemoval", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany() + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.AppUserPreferences", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithOne("UserPreferences") + .HasForeignKey("API.Entities.AppUserPreferences", "AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.SiteTheme", "Theme") + .WithMany() + .HasForeignKey("ThemeId"); + + b.Navigation("AppUser"); + + b.Navigation("Theme"); + }); + + modelBuilder.Entity("API.Entities.AppUserProgress", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Progresses") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Chapter", null) + .WithMany("UserProgress") + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", null) + .WithMany("Progress") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserRating", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Ratings") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany("Ratings") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.AppUserRole", b => + { + b.HasOne("API.Entities.AppRole", "Role") + .WithMany("UserRoles") + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.AppUser", "User") + .WithMany("UserRoles") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Role"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("API.Entities.AppUserSmartFilter", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("SmartFilters") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserTableOfContent", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("TableOfContents") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany() + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Chapter"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Chapter", b => + { + b.HasOne("API.Entities.Volume", "Volume") + .WithMany("Chapters") + .HasForeignKey("VolumeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Volume"); + }); + + modelBuilder.Entity("API.Entities.Device", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Devices") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.FolderPath", b => + { + b.HasOne("API.Entities.Library", "Library") + .WithMany("Folders") + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Library"); + }); + + modelBuilder.Entity("API.Entities.MangaFile", b => + { + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany("Files") + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Chapter"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesMetadata", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithOne("Metadata") + .HasForeignKey("API.Entities.Metadata.SeriesMetadata", "SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesRelation", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithMany("Relations") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "TargetSeries") + .WithMany("RelationOf") + .HasForeignKey("TargetSeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + + b.Navigation("TargetSeries"); + }); + + modelBuilder.Entity("API.Entities.ReadingList", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("ReadingLists") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.ReadingListItem", b => + { + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany() + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.ReadingList", "ReadingList") + .WithMany("Items") + .HasForeignKey("ReadingListId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Volume", "Volume") + .WithMany() + .HasForeignKey("VolumeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Chapter"); + + b.Navigation("ReadingList"); + + b.Navigation("Series"); + + b.Navigation("Volume"); + }); + + modelBuilder.Entity("API.Entities.Scrobble.ScrobbleError", b => + { + b.HasOne("API.Entities.Scrobble.ScrobbleEvent", "ScrobbleEvent") + .WithMany() + .HasForeignKey("ScrobbleEventId1"); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("ScrobbleEvent"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Scrobble.ScrobbleEvent", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany() + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Library", "Library") + .WithMany() + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Library"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Scrobble.ScrobbleHold", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("ScrobbleHolds") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany("WantToRead") + .HasForeignKey("AppUserId"); + + b.HasOne("API.Entities.Library", "Library") + .WithMany("Series") + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Library"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithMany("Volumes") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("AppUserLibrary", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("AppUsersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Library", null) + .WithMany() + .HasForeignKey("LibrariesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ChapterGenre", b => + { + b.HasOne("API.Entities.Chapter", null) + .WithMany() + .HasForeignKey("ChaptersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Genre", null) + .WithMany() + .HasForeignKey("GenresId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ChapterPerson", b => + { + b.HasOne("API.Entities.Chapter", null) + .WithMany() + .HasForeignKey("ChapterMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Person", null) + .WithMany() + .HasForeignKey("PeopleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ChapterTag", b => + { + b.HasOne("API.Entities.Chapter", null) + .WithMany() + .HasForeignKey("ChaptersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Tag", null) + .WithMany() + .HasForeignKey("TagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("CollectionTagSeriesMetadata", b => + { + b.HasOne("API.Entities.CollectionTag", null) + .WithMany() + .HasForeignKey("CollectionTagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.SeriesMetadata", null) + .WithMany() + .HasForeignKey("SeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("GenreSeriesMetadata", b => + { + b.HasOne("API.Entities.Genre", null) + .WithMany() + .HasForeignKey("GenresId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.SeriesMetadata", null) + .WithMany() + .HasForeignKey("SeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("API.Entities.AppRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("PersonSeriesMetadata", b => + { + b.HasOne("API.Entities.Person", null) + .WithMany() + .HasForeignKey("PeopleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.SeriesMetadata", null) + .WithMany() + .HasForeignKey("SeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SeriesMetadataTag", b => + { + b.HasOne("API.Entities.Metadata.SeriesMetadata", null) + .WithMany() + .HasForeignKey("SeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Tag", null) + .WithMany() + .HasForeignKey("TagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("API.Entities.AppRole", b => + { + b.Navigation("UserRoles"); + }); + + modelBuilder.Entity("API.Entities.AppUser", b => + { + b.Navigation("Bookmarks"); + + b.Navigation("DashboardStreams"); + + b.Navigation("Devices"); + + b.Navigation("Progresses"); + + b.Navigation("Ratings"); + + b.Navigation("ReadingLists"); + + b.Navigation("ScrobbleHolds"); + + b.Navigation("SmartFilters"); + + b.Navigation("TableOfContents"); + + b.Navigation("UserPreferences"); + + b.Navigation("UserRoles"); + + b.Navigation("WantToRead"); + }); + + modelBuilder.Entity("API.Entities.Chapter", b => + { + b.Navigation("Files"); + + b.Navigation("UserProgress"); + }); + + modelBuilder.Entity("API.Entities.Library", b => + { + b.Navigation("Folders"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.ReadingList", b => + { + b.Navigation("Items"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + b.Navigation("Metadata"); + + b.Navigation("Progress"); + + b.Navigation("Ratings"); + + b.Navigation("RelationOf"); + + b.Navigation("Relations"); + + b.Navigation("Volumes"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.Navigation("Chapters"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/API/Data/Migrations/20230908190713_DashboardStream.cs b/API/Data/Migrations/20230908190713_DashboardStream.cs new file mode 100644 index 000000000..10826c176 --- /dev/null +++ b/API/Data/Migrations/20230908190713_DashboardStream.cs @@ -0,0 +1,66 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace API.Data.Migrations +{ + /// + public partial class DashboardStream : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "AppUserDashboardStream", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + Name = table.Column(type: "TEXT", nullable: true), + IsProvided = table.Column(type: "INTEGER", nullable: false), + Order = table.Column(type: "INTEGER", nullable: false), + StreamType = table.Column(type: "INTEGER", nullable: false, defaultValue: 4), + Visible = table.Column(type: "INTEGER", nullable: false), + SmartFilterId = table.Column(type: "INTEGER", nullable: true), + AppUserId = table.Column(type: "INTEGER", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_AppUserDashboardStream", x => x.Id); + table.ForeignKey( + name: "FK_AppUserDashboardStream_AppUserSmartFilter_SmartFilterId", + column: x => x.SmartFilterId, + principalTable: "AppUserSmartFilter", + principalColumn: "Id"); + table.ForeignKey( + name: "FK_AppUserDashboardStream_AspNetUsers_AppUserId", + column: x => x.AppUserId, + principalTable: "AspNetUsers", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_AppUserDashboardStream_AppUserId", + table: "AppUserDashboardStream", + column: "AppUserId"); + + migrationBuilder.CreateIndex( + name: "IX_AppUserDashboardStream_SmartFilterId", + table: "AppUserDashboardStream", + column: "SmartFilterId"); + + migrationBuilder.CreateIndex( + name: "IX_AppUserDashboardStream_Visible", + table: "AppUserDashboardStream", + column: "Visible"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "AppUserDashboardStream"); + } + } +} diff --git a/API/Data/Migrations/DataContextModelSnapshot.cs b/API/Data/Migrations/DataContextModelSnapshot.cs index adc04905d..0856e4901 100644 --- a/API/Data/Migrations/DataContextModelSnapshot.cs +++ b/API/Data/Migrations/DataContextModelSnapshot.cs @@ -180,7 +180,47 @@ namespace API.Data.Migrations b.HasIndex("AppUserId"); - b.ToTable("AppUserBookmark", (string)null); + b.ToTable("AppUserBookmark"); + }); + + modelBuilder.Entity("API.Entities.AppUserDashboardStream", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("IsProvided") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Order") + .HasColumnType("INTEGER"); + + b.Property("SmartFilterId") + .HasColumnType("INTEGER"); + + b.Property("StreamType") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(4); + + b.Property("Visible") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SmartFilterId"); + + b.HasIndex("Visible"); + + b.ToTable("AppUserDashboardStream"); }); modelBuilder.Entity("API.Entities.AppUserOnDeckRemoval", b => @@ -201,7 +241,7 @@ namespace API.Data.Migrations b.HasIndex("SeriesId"); - b.ToTable("AppUserOnDeckRemoval", (string)null); + b.ToTable("AppUserOnDeckRemoval"); }); modelBuilder.Entity("API.Entities.AppUserPreferences", b => @@ -315,7 +355,7 @@ namespace API.Data.Migrations b.HasIndex("ThemeId"); - b.ToTable("AppUserPreferences", (string)null); + b.ToTable("AppUserPreferences"); }); modelBuilder.Entity("API.Entities.AppUserProgress", b => @@ -365,7 +405,7 @@ namespace API.Data.Migrations b.HasIndex("SeriesId"); - b.ToTable("AppUserProgresses", (string)null); + b.ToTable("AppUserProgresses"); }); modelBuilder.Entity("API.Entities.AppUserRating", b => @@ -398,7 +438,7 @@ namespace API.Data.Migrations b.HasIndex("SeriesId"); - b.ToTable("AppUserRating", (string)null); + b.ToTable("AppUserRating"); }); modelBuilder.Entity("API.Entities.AppUserRole", b => @@ -416,6 +456,28 @@ namespace API.Data.Migrations b.ToTable("AspNetUserRoles", (string)null); }); + modelBuilder.Entity("API.Entities.AppUserSmartFilter", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Filter") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("AppUserSmartFilter"); + }); + modelBuilder.Entity("API.Entities.AppUserTableOfContent", b => { b.Property("Id") @@ -466,7 +528,7 @@ namespace API.Data.Migrations b.HasIndex("SeriesId"); - b.ToTable("AppUserTableOfContent", (string)null); + b.ToTable("AppUserTableOfContent"); }); modelBuilder.Entity("API.Entities.Chapter", b => @@ -576,7 +638,7 @@ namespace API.Data.Migrations b.HasIndex("VolumeId"); - b.ToTable("Chapter", (string)null); + b.ToTable("Chapter"); }); modelBuilder.Entity("API.Entities.CollectionTag", b => @@ -611,7 +673,7 @@ namespace API.Data.Migrations b.HasIndex("Id", "Promoted") .IsUnique(); - b.ToTable("CollectionTag", (string)null); + b.ToTable("CollectionTag"); }); modelBuilder.Entity("API.Entities.Device", b => @@ -657,7 +719,7 @@ namespace API.Data.Migrations b.HasIndex("AppUserId"); - b.ToTable("Device", (string)null); + b.ToTable("Device"); }); modelBuilder.Entity("API.Entities.FolderPath", b => @@ -679,7 +741,7 @@ namespace API.Data.Migrations b.HasIndex("LibraryId"); - b.ToTable("FolderPath", (string)null); + b.ToTable("FolderPath"); }); modelBuilder.Entity("API.Entities.Genre", b => @@ -699,7 +761,7 @@ namespace API.Data.Migrations b.HasIndex("NormalizedTitle") .IsUnique(); - b.ToTable("Genre", (string)null); + b.ToTable("Genre"); }); modelBuilder.Entity("API.Entities.Library", b => @@ -757,7 +819,7 @@ namespace API.Data.Migrations b.HasKey("Id"); - b.ToTable("Library", (string)null); + b.ToTable("Library"); }); modelBuilder.Entity("API.Entities.MangaFile", b => @@ -806,7 +868,7 @@ namespace API.Data.Migrations b.HasIndex("ChapterId"); - b.ToTable("MangaFile", (string)null); + b.ToTable("MangaFile"); }); modelBuilder.Entity("API.Entities.MediaError", b => @@ -841,7 +903,7 @@ namespace API.Data.Migrations b.HasKey("Id"); - b.ToTable("MediaError", (string)null); + b.ToTable("MediaError"); }); modelBuilder.Entity("API.Entities.Metadata.SeriesMetadata", b => @@ -942,7 +1004,7 @@ namespace API.Data.Migrations b.HasIndex("Id", "SeriesId") .IsUnique(); - b.ToTable("SeriesMetadata", (string)null); + b.ToTable("SeriesMetadata"); }); modelBuilder.Entity("API.Entities.Metadata.SeriesRelation", b => @@ -966,7 +1028,7 @@ namespace API.Data.Migrations b.HasIndex("TargetSeriesId"); - b.ToTable("SeriesRelation", (string)null); + b.ToTable("SeriesRelation"); }); modelBuilder.Entity("API.Entities.Person", b => @@ -986,7 +1048,7 @@ namespace API.Data.Migrations b.HasKey("Id"); - b.ToTable("Person", (string)null); + b.ToTable("Person"); }); modelBuilder.Entity("API.Entities.ReadingList", b => @@ -1049,7 +1111,7 @@ namespace API.Data.Migrations b.HasIndex("AppUserId"); - b.ToTable("ReadingList", (string)null); + b.ToTable("ReadingList"); }); modelBuilder.Entity("API.Entities.ReadingListItem", b => @@ -1083,7 +1145,7 @@ namespace API.Data.Migrations b.HasIndex("VolumeId"); - b.ToTable("ReadingListItem", (string)null); + b.ToTable("ReadingListItem"); }); modelBuilder.Entity("API.Entities.Scrobble.ScrobbleError", b => @@ -1128,7 +1190,7 @@ namespace API.Data.Migrations b.HasIndex("SeriesId"); - b.ToTable("ScrobbleError", (string)null); + b.ToTable("ScrobbleError"); }); modelBuilder.Entity("API.Entities.Scrobble.ScrobbleEvent", b => @@ -1188,8 +1250,8 @@ namespace API.Data.Migrations b.Property("SeriesId") .HasColumnType("INTEGER"); - b.Property("VolumeNumber") - .HasColumnType("REAL"); + b.Property("VolumeNumber") + .HasColumnType("INTEGER"); b.HasKey("Id"); @@ -1199,7 +1261,7 @@ namespace API.Data.Migrations b.HasIndex("SeriesId"); - b.ToTable("ScrobbleEvent", (string)null); + b.ToTable("ScrobbleEvent"); }); modelBuilder.Entity("API.Entities.Scrobble.ScrobbleHold", b => @@ -1232,7 +1294,7 @@ namespace API.Data.Migrations b.HasIndex("SeriesId"); - b.ToTable("ScrobbleHold", (string)null); + b.ToTable("ScrobbleHold"); }); modelBuilder.Entity("API.Entities.Series", b => @@ -1328,7 +1390,7 @@ namespace API.Data.Migrations b.HasIndex("LibraryId"); - b.ToTable("Series", (string)null); + b.ToTable("Series"); }); modelBuilder.Entity("API.Entities.ServerSetting", b => @@ -1345,7 +1407,7 @@ namespace API.Data.Migrations b.HasKey("Key"); - b.ToTable("ServerSetting", (string)null); + b.ToTable("ServerSetting"); }); modelBuilder.Entity("API.Entities.ServerStatistics", b => @@ -1383,7 +1445,7 @@ namespace API.Data.Migrations b.HasKey("Id"); - b.ToTable("ServerStatistics", (string)null); + b.ToTable("ServerStatistics"); }); modelBuilder.Entity("API.Entities.SiteTheme", b => @@ -1421,7 +1483,7 @@ namespace API.Data.Migrations b.HasKey("Id"); - b.ToTable("SiteTheme", (string)null); + b.ToTable("SiteTheme"); }); modelBuilder.Entity("API.Entities.Tag", b => @@ -1441,7 +1503,7 @@ namespace API.Data.Migrations b.HasIndex("NormalizedTitle") .IsUnique(); - b.ToTable("Tag", (string)null); + b.ToTable("Tag"); }); modelBuilder.Entity("API.Entities.Volume", b => @@ -1477,8 +1539,8 @@ namespace API.Data.Migrations b.Property("Name") .HasColumnType("TEXT"); - b.Property("Number") - .HasColumnType("REAL"); + b.Property("Number") + .HasColumnType("INTEGER"); b.Property("Pages") .HasColumnType("INTEGER"); @@ -1493,7 +1555,7 @@ namespace API.Data.Migrations b.HasIndex("SeriesId"); - b.ToTable("Volume", (string)null); + b.ToTable("Volume"); }); modelBuilder.Entity("AppUserLibrary", b => @@ -1508,7 +1570,7 @@ namespace API.Data.Migrations b.HasIndex("LibrariesId"); - b.ToTable("AppUserLibrary", (string)null); + b.ToTable("AppUserLibrary"); }); modelBuilder.Entity("ChapterGenre", b => @@ -1523,7 +1585,7 @@ namespace API.Data.Migrations b.HasIndex("GenresId"); - b.ToTable("ChapterGenre", (string)null); + b.ToTable("ChapterGenre"); }); modelBuilder.Entity("ChapterPerson", b => @@ -1538,7 +1600,7 @@ namespace API.Data.Migrations b.HasIndex("PeopleId"); - b.ToTable("ChapterPerson", (string)null); + b.ToTable("ChapterPerson"); }); modelBuilder.Entity("ChapterTag", b => @@ -1553,7 +1615,7 @@ namespace API.Data.Migrations b.HasIndex("TagsId"); - b.ToTable("ChapterTag", (string)null); + b.ToTable("ChapterTag"); }); modelBuilder.Entity("CollectionTagSeriesMetadata", b => @@ -1568,7 +1630,7 @@ namespace API.Data.Migrations b.HasIndex("SeriesMetadatasId"); - b.ToTable("CollectionTagSeriesMetadata", (string)null); + b.ToTable("CollectionTagSeriesMetadata"); }); modelBuilder.Entity("GenreSeriesMetadata", b => @@ -1583,7 +1645,7 @@ namespace API.Data.Migrations b.HasIndex("SeriesMetadatasId"); - b.ToTable("GenreSeriesMetadata", (string)null); + b.ToTable("GenreSeriesMetadata"); }); modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => @@ -1682,7 +1744,7 @@ namespace API.Data.Migrations b.HasIndex("SeriesMetadatasId"); - b.ToTable("PersonSeriesMetadata", (string)null); + b.ToTable("PersonSeriesMetadata"); }); modelBuilder.Entity("SeriesMetadataTag", b => @@ -1697,7 +1759,7 @@ namespace API.Data.Migrations b.HasIndex("TagsId"); - b.ToTable("SeriesMetadataTag", (string)null); + b.ToTable("SeriesMetadataTag"); }); modelBuilder.Entity("API.Entities.AppUserBookmark", b => @@ -1711,6 +1773,23 @@ namespace API.Data.Migrations b.Navigation("AppUser"); }); + modelBuilder.Entity("API.Entities.AppUserDashboardStream", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("DashboardStreams") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.AppUserSmartFilter", "SmartFilter") + .WithMany() + .HasForeignKey("SmartFilterId"); + + b.Navigation("AppUser"); + + b.Navigation("SmartFilter"); + }); + modelBuilder.Entity("API.Entities.AppUserOnDeckRemoval", b => { b.HasOne("API.Entities.AppUser", "AppUser") @@ -1808,6 +1887,17 @@ namespace API.Data.Migrations b.Navigation("User"); }); + modelBuilder.Entity("API.Entities.AppUserSmartFilter", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("SmartFilters") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + modelBuilder.Entity("API.Entities.AppUserTableOfContent", b => { b.HasOne("API.Entities.AppUser", "AppUser") @@ -2209,6 +2299,8 @@ namespace API.Data.Migrations { b.Navigation("Bookmarks"); + b.Navigation("DashboardStreams"); + b.Navigation("Devices"); b.Navigation("Progresses"); @@ -2219,6 +2311,8 @@ namespace API.Data.Migrations b.Navigation("ScrobbleHolds"); + b.Navigation("SmartFilters"); + b.Navigation("TableOfContents"); b.Navigation("UserPreferences"); diff --git a/API/Data/Repositories/AppUserSmartFilterRepository.cs b/API/Data/Repositories/AppUserSmartFilterRepository.cs new file mode 100644 index 000000000..fd8933634 --- /dev/null +++ b/API/Data/Repositories/AppUserSmartFilterRepository.cs @@ -0,0 +1,60 @@ +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using API.DTOs.Dashboard; +using API.Entities; +using AutoMapper; +using AutoMapper.QueryableExtensions; +using Microsoft.EntityFrameworkCore; + +namespace API.Data.Repositories; + +public interface IAppUserSmartFilterRepository +{ + void Update(AppUserSmartFilter filter); + void Attach(AppUserSmartFilter filter); + void Delete(AppUserSmartFilter filter); + IEnumerable GetAllDtosByUserId(int userId); + Task GetById(int smartFilterId); + +} + +public class AppUserSmartFilterRepository : IAppUserSmartFilterRepository +{ + private readonly DataContext _context; + private readonly IMapper _mapper; + + public AppUserSmartFilterRepository(DataContext context, IMapper mapper) + { + _context = context; + _mapper = mapper; + } + + public void Update(AppUserSmartFilter filter) + { + _context.Entry(filter).State = EntityState.Modified; + } + + public void Attach(AppUserSmartFilter filter) + { + _context.AppUserSmartFilter.Attach(filter); + } + + public void Delete(AppUserSmartFilter filter) + { + _context.AppUserSmartFilter.Remove(filter); + } + + public IEnumerable GetAllDtosByUserId(int userId) + { + return _context.AppUserSmartFilter + .Where(f => f.AppUserId == userId) + .ProjectTo(_mapper.ConfigurationProvider) + .AsEnumerable(); + } + + public async Task GetById(int smartFilterId) + { + return await _context.AppUserSmartFilter.FirstOrDefaultAsync(d => d.Id == smartFilterId); + } +} diff --git a/API/Data/Repositories/SeriesRepository.cs b/API/Data/Repositories/SeriesRepository.cs index 3457469b4..b1a31c752 100644 --- a/API/Data/Repositories/SeriesRepository.cs +++ b/API/Data/Repositories/SeriesRepository.cs @@ -8,6 +8,7 @@ using API.Data.Misc; using API.Data.Scanner; using API.DTOs; using API.DTOs.CollectionTags; +using API.DTOs.Dashboard; using API.DTOs.Filtering; using API.DTOs.Filtering.v2; using API.DTOs.Metadata; @@ -952,6 +953,9 @@ public class SeriesRepository : ISeriesRepository // First setup any FilterField.Libraries in the statements, as these don't have any traditional query statements applied here query = ApplyLibraryFilter(filter, query); + query = ApplyWantToReadFilter(filter, query, userId); + + query = BuildFilterQuery(userId, filter, query); @@ -968,6 +972,24 @@ public class SeriesRepository : ISeriesRepository .AsSplitQuery(), filter.LimitTo); } + private IQueryable ApplyWantToReadFilter(FilterV2Dto filter, IQueryable query, int userId) + { + var wantToReadStmt = filter.Statements.FirstOrDefault(stmt => stmt.Field == FilterField.WantToRead); + if (wantToReadStmt == null) return query; + + var seriesIds = _context.AppUser.Where(u => u.Id == userId).SelectMany(u => u.WantToRead).Select(s => s.Id); + if (bool.Parse(wantToReadStmt.Value)) + { + query = query.Where(s => seriesIds.Contains(s.Id)); + } + else + { + query = query.Where(s => !seriesIds.Contains(s.Id)); + } + + return query; + } + private static IQueryable ApplyLibraryFilter(FilterV2Dto filter, IQueryable query) { var filterIncludeLibs = new List(); @@ -1060,6 +1082,9 @@ public class SeriesRepository : ISeriesRepository FilterField.Libraries => // This is handled in the code before this as it's handled in a more general, combined manner query, + FilterField.WantToRead => + // This is handled in the higher level of code as it's more general + query, FilterField.ReadProgress => query.HasReadingProgress(true, statement.Comparison, (int) value, userId), FilterField.Formats => query.HasFormat(true, statement.Comparison, (IList) value), FilterField.ReleaseYear => query.HasReleaseYear(true, statement.Comparison, (int) value), diff --git a/API/Data/Repositories/UserRepository.cs b/API/Data/Repositories/UserRepository.cs index 67a72a7a9..524c9db6d 100644 --- a/API/Data/Repositories/UserRepository.cs +++ b/API/Data/Repositories/UserRepository.cs @@ -6,7 +6,7 @@ using System.Threading.Tasks; using API.Constants; using API.DTOs; using API.DTOs.Account; -using API.DTOs.Filtering; +using API.DTOs.Dashboard; using API.DTOs.Filtering.v2; using API.DTOs.Reader; using API.DTOs.Scrobbling; @@ -15,6 +15,7 @@ using API.Entities; using API.Extensions; using API.Extensions.QueryExtensions; using API.Extensions.QueryExtensions.Filtering; +using API.Helpers; using AutoMapper; using AutoMapper.QueryableExtensions; using Microsoft.AspNetCore.Identity; @@ -34,8 +35,9 @@ public enum AppUserIncludes WantToRead = 64, ReadingListsWithItems = 128, Devices = 256, - ScrobbleHolds = 512 - + ScrobbleHolds = 512, + SmartFilters = 1024, + DashboardStreams = 2048 } public interface IUserRepository @@ -43,9 +45,11 @@ public interface IUserRepository void Update(AppUser user); void Update(AppUserPreferences preferences); void Update(AppUserBookmark bookmark); + void Update(AppUserDashboardStream stream); void Add(AppUserBookmark bookmark); - public void Delete(AppUser? user); + void Delete(AppUser? user); void Delete(AppUserBookmark bookmark); + void Delete(IList streams); Task> GetEmailConfirmedMemberDtosAsync(bool emailConfirmed = true); Task> GetAdminUsersAsync(); Task IsUserAdminAsync(AppUser? user); @@ -76,6 +80,9 @@ public interface IUserRepository Task HasHoldOnSeries(int userId, int seriesId); Task> GetHolds(int userId); Task GetLocale(int userId); + Task> GetDashboardStreams(int userId, bool visibleOnly = false); + Task GetDashboardStream(int streamId); + Task> GetDashboardStreamWithFilter(int filterId); } public class UserRepository : IUserRepository @@ -106,6 +113,11 @@ public class UserRepository : IUserRepository _context.Entry(bookmark).State = EntityState.Modified; } + public void Update(AppUserDashboardStream stream) + { + _context.Entry(stream).State = EntityState.Modified; + } + public void Add(AppUserBookmark bookmark) { _context.AppUserBookmark.Add(bookmark); @@ -122,6 +134,11 @@ public class UserRepository : IUserRepository _context.AppUserBookmark.Remove(bookmark); } + public void Delete(IList streams) + { + _context.AppUserDashboardStream.RemoveRange(streams); + } + /// /// A one stop shop to get a tracked AppUser instance with any number of JOINs generated by passing bitwise flags. /// @@ -300,6 +317,42 @@ public class UserRepository : IUserRepository .SingleAsync(); } + public async Task> GetDashboardStreams(int userId, bool visibleOnly = false) + { + return await _context.AppUserDashboardStream + .Where(d => d.AppUserId == userId) + .WhereIf(visibleOnly, d => d.Visible) + .OrderBy(d => d.Order) + .Include(d => d.SmartFilter) + .Select(d => new DashboardStreamDto() + { + Id = d.Id, + Name = d.Name, + IsProvided = d.IsProvided, + SmartFilterId = d.SmartFilter == null ? 0 : d.SmartFilter.Id, + SmartFilterEncoded = d.SmartFilter == null ? null : d.SmartFilter.Filter, + StreamType = d.StreamType, + Order = d.Order, + Visible = d.Visible + }) + .ToListAsync(); + } + + public async Task GetDashboardStream(int streamId) + { + return await _context.AppUserDashboardStream + .Include(d => d.SmartFilter) + .FirstOrDefaultAsync(d => d.Id == streamId); + } + + public async Task> GetDashboardStreamWithFilter(int filterId) + { + return await _context.AppUserDashboardStream + .Include(d => d.SmartFilter) + .Where(d => d.SmartFilter != null && d.SmartFilter.Id == filterId) + .ToListAsync(); + } + public async Task> GetAdminUsersAsync() { return await _userManager.GetUsersInRoleAsync(PolicyConstants.AdminRole); diff --git a/API/Data/Seed.cs b/API/Data/Seed.cs index a1941d17f..742e527ba 100644 --- a/API/Data/Seed.cs +++ b/API/Data/Seed.cs @@ -6,6 +6,7 @@ using System.Linq; using System.Reflection; using System.Threading.Tasks; using API.Constants; +using API.Data.Repositories; using API.Entities; using API.Entities.Enums; using API.Entities.Enums.Theme; @@ -38,6 +39,43 @@ public static class Seed } }.ToArray()); + public static readonly ImmutableArray DefaultStreams = ImmutableArray.Create( + new List + { + new() + { + Name = "On Deck", + StreamType = DashboardStreamType.OnDeck, + Order = 0, + IsProvided = true, + Visible = true + }, + new() + { + Name = "Recently Updated", + StreamType = DashboardStreamType.RecentlyUpdated, + Order = 1, + IsProvided = true, + Visible = true + }, + new() + { + Name = "Newly Added", + StreamType = DashboardStreamType.NewlyAdded, + Order = 2, + IsProvided = true, + Visible = true + }, + new() + { + Name = "More In", + StreamType = DashboardStreamType.MoreInGenre, + Order = 3, + IsProvided = true, + Visible = false + }, + }.ToArray()); + public static async Task SeedRoles(RoleManager roleManager) { var roles = typeof(PolicyConstants) @@ -74,6 +112,31 @@ public static class Seed await context.SaveChangesAsync(); } + public static async Task SeedDefaultStreams(IUnitOfWork unitOfWork) + { + var allUsers = await unitOfWork.UserRepository.GetAllUsersAsync(AppUserIncludes.DashboardStreams); + foreach (var user in allUsers) + { + if (user.DashboardStreams.Count != 0) continue; + user.DashboardStreams ??= new List(); + foreach (var defaultStream in DefaultStreams) + { + var newStream = new AppUserDashboardStream + { + Name = defaultStream.Name, + IsProvided = defaultStream.IsProvided, + Order = defaultStream.Order, + StreamType = defaultStream.StreamType, + Visible = defaultStream.Visible, + }; + + user.DashboardStreams.Add(newStream); + } + unitOfWork.UserRepository.Update(user); + await unitOfWork.CommitAsync(); + } + } + public static async Task SeedSettings(DataContext context, IDirectoryService directoryService) { await context.Database.EnsureCreatedAsync(); diff --git a/API/Data/UnitOfWork.cs b/API/Data/UnitOfWork.cs index 8eb1f3a31..c001efa58 100644 --- a/API/Data/UnitOfWork.cs +++ b/API/Data/UnitOfWork.cs @@ -28,6 +28,7 @@ public interface IUnitOfWork IMediaErrorRepository MediaErrorRepository { get; } IScrobbleRepository ScrobbleRepository { get; } IUserTableOfContentRepository UserTableOfContentRepository { get; } + IAppUserSmartFilterRepository AppUserSmartFilterRepository { get; } bool Commit(); Task CommitAsync(); bool HasChanges(); @@ -68,6 +69,7 @@ public class UnitOfWork : IUnitOfWork public IMediaErrorRepository MediaErrorRepository => new MediaErrorRepository(_context, _mapper); public IScrobbleRepository ScrobbleRepository => new ScrobbleRepository(_context, _mapper); public IUserTableOfContentRepository UserTableOfContentRepository => new UserTableOfContentRepository(_context, _mapper); + public IAppUserSmartFilterRepository AppUserSmartFilterRepository => new AppUserSmartFilterRepository(_context, _mapper); /// /// Commits changes to the DB. Completes the open transaction. diff --git a/API/Entities/AppUser.cs b/API/Entities/AppUser.cs index f50262ef0..d8e2c06cc 100644 --- a/API/Entities/AppUser.cs +++ b/API/Entities/AppUser.cs @@ -67,6 +67,15 @@ public class AppUser : IdentityUser, IHasConcurrencyToken /// A list of Series the user doesn't want scrobbling for /// public ICollection ScrobbleHolds { get; set; } = null!; + /// + /// A collection of user Smart Filters for their account + /// + public ICollection SmartFilters { get; set; } = null!; + + /// + /// An ordered list of Streams (pre-configured) or Smart Filters that makes up the User's Dashboard + /// + public IList DashboardStreams { get; set; } = null!; /// diff --git a/API/Entities/AppUserDashboardStream.cs b/API/Entities/AppUserDashboardStream.cs new file mode 100644 index 000000000..a3554b277 --- /dev/null +++ b/API/Entities/AppUserDashboardStream.cs @@ -0,0 +1,29 @@ +using API.Entities.Enums; + + +namespace API.Entities; + +public class AppUserDashboardStream +{ + public int Id { get; set; } + public required string Name { get; set; } + /// + /// Is System Provided + /// + public bool IsProvided { get; set; } + /// + /// Sort Order on the Dashboard + /// + public int Order { get; set; } + /// + /// For system provided + /// + public DashboardStreamType StreamType { get; set; } + public bool Visible { get; set; } + /// + /// If Not IsProvided, the appropriate smart filter + /// + public AppUserSmartFilter? SmartFilter { get; set; } + public int AppUserId { get; set; } + public AppUser AppUser { get; set; } +} diff --git a/API/Entities/AppUserSmartFilter.cs b/API/Entities/AppUserSmartFilter.cs new file mode 100644 index 000000000..e9f58fb5c --- /dev/null +++ b/API/Entities/AppUserSmartFilter.cs @@ -0,0 +1,19 @@ +using API.DTOs.Filtering.v2; + +namespace API.Entities; + +/// +/// Represents a Saved user Filter +/// +public class AppUserSmartFilter +{ + public int Id { get; set; } + public required string Name { get; set; } + /// + /// This is the Filter url encoded. It is decoded and reconstructed into a + /// + public required string Filter { get; set; } + + public int AppUserId { get; set; } + public AppUser AppUser { get; set; } +} diff --git a/API/Entities/Enums/DashboardStreamType.cs b/API/Entities/Enums/DashboardStreamType.cs new file mode 100644 index 000000000..27a7d67ca --- /dev/null +++ b/API/Entities/Enums/DashboardStreamType.cs @@ -0,0 +1,14 @@ +namespace API.Entities.Enums; + +public enum DashboardStreamType +{ + OnDeck = 1, + RecentlyUpdated = 2, + NewlyAdded = 3, + SmartFilter = 4, + /// + /// More In Genre + /// + MoreInGenre = 5 + +} diff --git a/API/Extensions/QueryExtensions/Filtering/SeriesFilter.cs b/API/Extensions/QueryExtensions/Filtering/SeriesFilter.cs index b1dfeab1f..d6e78da65 100644 --- a/API/Extensions/QueryExtensions/Filtering/SeriesFilter.cs +++ b/API/Extensions/QueryExtensions/Filtering/SeriesFilter.cs @@ -14,7 +14,7 @@ namespace API.Extensions.QueryExtensions.Filtering; public static class SeriesFilter { - + private const float FloatingPointTolerance = 0.01f; public static IQueryable HasLanguage(this IQueryable queryable, bool condition, FilterComparison comparison, IList languages) { @@ -94,7 +94,7 @@ public static class SeriesFilter switch (comparison) { case FilterComparison.Equal: - return queryable.Where(s => s.Ratings.Any(r => r.Rating == rating && r.AppUserId == userId)); + return queryable.Where(s => s.Ratings.Any(r => Math.Abs(r.Rating - rating) < FloatingPointTolerance && r.AppUserId == userId)); case FilterComparison.GreaterThan: return queryable.Where(s => s.Ratings.Any(r => r.Rating > rating && r.AppUserId == userId)); case FilterComparison.GreaterThanEqual: @@ -252,7 +252,7 @@ public static class SeriesFilter switch (comparison) { case FilterComparison.Equal: - subQuery = subQuery.Where(s => s.Percentage == readProgress); + subQuery = subQuery.Where(s => Math.Abs(s.Percentage - readProgress) < FloatingPointTolerance); break; case FilterComparison.GreaterThan: subQuery = subQuery.Where(s => s.Percentage > readProgress); @@ -267,7 +267,7 @@ public static class SeriesFilter subQuery = subQuery.Where(s => s.Percentage <= readProgress); break; case FilterComparison.NotEqual: - subQuery = subQuery.Where(s => s.Percentage != readProgress); + subQuery = subQuery.Where(s => Math.Abs(s.Percentage - readProgress) > FloatingPointTolerance); break; case FilterComparison.Matches: case FilterComparison.Contains: diff --git a/API/Extensions/QueryExtensions/Filtering/SeriesSort.cs b/API/Extensions/QueryExtensions/Filtering/SeriesSort.cs index ac075fc21..cd5d28b0b 100644 --- a/API/Extensions/QueryExtensions/Filtering/SeriesSort.cs +++ b/API/Extensions/QueryExtensions/Filtering/SeriesSort.cs @@ -31,6 +31,7 @@ public static class SeriesSort SortField.LastChapterAdded => query.OrderBy(s => s.LastChapterAdded), SortField.TimeToRead => query.OrderBy(s => s.AvgHoursToRead), SortField.ReleaseYear => query.OrderBy(s => s.Metadata.ReleaseYear), + //SortField.ReadProgress => query.OrderBy() _ => query }; } diff --git a/API/Extensions/QueryExtensions/IncludesExtensions.cs b/API/Extensions/QueryExtensions/IncludesExtensions.cs index 6f481eb7e..828041f1c 100644 --- a/API/Extensions/QueryExtensions/IncludesExtensions.cs +++ b/API/Extensions/QueryExtensions/IncludesExtensions.cs @@ -130,6 +130,17 @@ public static class IncludesExtensions query = query.Include(u => u.ScrobbleHolds); } + if (includeFlags.HasFlag(AppUserIncludes.SmartFilters)) + { + query = query.Include(u => u.SmartFilters); + } + + if (includeFlags.HasFlag(AppUserIncludes.DashboardStreams)) + { + query = query.Include(u => u.DashboardStreams) + .ThenInclude(s => s.SmartFilter); + } + return query.AsSplitQuery(); } diff --git a/API/Helpers/AutoMapperProfiles.cs b/API/Helpers/AutoMapperProfiles.cs index 4c5a1b4f2..ffc7bb181 100644 --- a/API/Helpers/AutoMapperProfiles.cs +++ b/API/Helpers/AutoMapperProfiles.cs @@ -4,7 +4,10 @@ using API.Data.Migrations; using API.DTOs; using API.DTOs.Account; using API.DTOs.CollectionTags; +using API.DTOs.Dashboard; using API.DTOs.Device; +using API.DTOs.Filtering; +using API.DTOs.Filtering.v2; using API.DTOs.MediaErrors; using API.DTOs.Metadata; using API.DTOs.Reader; @@ -226,5 +229,12 @@ public class AutoMapperProfiles : Profile CreateMap(); CreateMap(); + + CreateMap(); + CreateMap(); + // CreateMap() + // .ForMember(dest => dest.SmartFilterEncoded, + // opt => opt.MapFrom(src => src.SmartFilter)); + } } diff --git a/API/Helpers/Builders/AppUserBuilder.cs b/API/Helpers/Builders/AppUserBuilder.cs index fd8ae45e6..fd39f4b11 100644 --- a/API/Helpers/Builders/AppUserBuilder.cs +++ b/API/Helpers/Builders/AppUserBuilder.cs @@ -28,8 +28,13 @@ public class AppUserBuilder : IEntityBuilder Ratings = new List(), Progresses = new List(), Devices = new List(), - Id = 0 + Id = 0, + DashboardStreams = new List() }; + foreach (var s in Seed.DefaultStreams) + { + _appUser.DashboardStreams.Add(s); + } } public AppUserBuilder WithLibrary(Library library) diff --git a/API/Helpers/Builders/SmartFilterBuilder.cs b/API/Helpers/Builders/SmartFilterBuilder.cs new file mode 100644 index 000000000..538d8a529 --- /dev/null +++ b/API/Helpers/Builders/SmartFilterBuilder.cs @@ -0,0 +1,24 @@ +using API.DTOs.Filtering.v2; +using API.Entities; + +namespace API.Helpers.Builders; + +public class SmartFilterBuilder : IEntityBuilder +{ + private AppUserSmartFilter _smartFilter; + public AppUserSmartFilter Build() => _smartFilter; + + public SmartFilterBuilder(FilterV2Dto filter) + { + _smartFilter = new AppUserSmartFilter() + { + Name = filter.Name, + Filter = SmartFilterHelper.Encode(filter) + }; + } + + // public SmartFilterBuilder WithName(string name) + // { + // + // } +} diff --git a/API/Helpers/Converters/FilterFieldValueConverter.cs b/API/Helpers/Converters/FilterFieldValueConverter.cs index 36ab4913b..292f1ff2f 100644 --- a/API/Helpers/Converters/FilterFieldValueConverter.cs +++ b/API/Helpers/Converters/FilterFieldValueConverter.cs @@ -67,6 +67,7 @@ public static class FilterFieldValueConverter FilterField.Libraries => (value.Split(',') .Select(int.Parse) .ToList(), typeof(IList)), + FilterField.WantToRead => (bool.Parse(value), typeof(bool)), FilterField.ReadProgress => (int.Parse(value), typeof(int)), FilterField.Formats => (value.Split(',') .Select(x => (MangaFormat) Enum.Parse(typeof(MangaFormat), x)) diff --git a/API/Helpers/SmartFilterHelper.cs b/API/Helpers/SmartFilterHelper.cs new file mode 100644 index 000000000..450303e6f --- /dev/null +++ b/API/Helpers/SmartFilterHelper.cs @@ -0,0 +1,140 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Web; +using API.DTOs.Filtering; +using API.DTOs.Filtering.v2; + +namespace API.Helpers; + +public static class SmartFilterHelper +{ + private const string SortOptionsKey = "sortOptions="; + private const string StatementsKey = "stmts="; + private const string LimitToKey = "limitTo="; + private const string CombinationKey = "combination="; + + public static FilterV2Dto Decode(string? encodedFilter) + { + if (string.IsNullOrWhiteSpace(encodedFilter)) + { + return new FilterV2Dto(); // Create a default filter if the input is empty + } + + string[] parts = encodedFilter.Split('&'); + var filter = new FilterV2Dto(); + + foreach (var part in parts) + { + if (part.StartsWith(SortOptionsKey)) + { + filter.SortOptions = DecodeSortOptions(part.Substring(SortOptionsKey.Length)); + } + else if (part.StartsWith(LimitToKey)) + { + filter.LimitTo = int.Parse(part.Substring(LimitToKey.Length)); + } + else if (part.StartsWith(CombinationKey)) + { + filter.Combination = Enum.Parse(part.Split("=")[1]); + } + else if (part.StartsWith(StatementsKey)) + { + filter.Statements = DecodeFilterStatementDtos(part.Substring(StatementsKey.Length)); + } + else if (part.StartsWith("name=")) + { + filter.Name = HttpUtility.UrlDecode(part.Substring(5)); + } + } + + return filter; + } + + public static string Encode(FilterV2Dto filter) + { + if (filter == null) + return string.Empty; + + var encodedStatements = EncodeFilterStatementDtos(filter.Statements); + var encodedSortOptions = filter.SortOptions != null + ? $"{SortOptionsKey}{EncodeSortOptions(filter.SortOptions)}" + : ""; + var encodedLimitTo = $"{LimitToKey}{filter.LimitTo}"; + + return $"{EncodeName(filter.Name)}{encodedStatements}&{encodedSortOptions}&{encodedLimitTo}&{CombinationKey}{(int) filter.Combination}"; + } + + private static string EncodeName(string name) + { + return string.IsNullOrWhiteSpace(name) ? string.Empty : $"name={HttpUtility.UrlEncode(name)}&"; + } + + private static string EncodeSortOptions(SortOptions sortOptions) + { + return $"sortField={(int) sortOptions.SortField}&isAscending={sortOptions.IsAscending}"; + } + + private static string EncodeFilterStatementDtos(ICollection statements) + { + if (statements == null || statements.Count == 0) + return string.Empty; + + var encodedStatements = StatementsKey + HttpUtility.UrlEncode(string.Join(",", statements.Select(EncodeFilterStatementDto))); + return encodedStatements; + } + + private static string EncodeFilterStatementDto(FilterStatementDto statement) + { + var encodedComparison = $"comparison={(int) statement.Comparison}"; + var encodedField = $"field={(int) statement.Field}"; + var encodedValue = $"value={HttpUtility.UrlEncode(statement.Value)}"; + + return $"{encodedComparison}&{encodedField}&{encodedValue}"; + } + + private static List DecodeFilterStatementDtos(string encodedStatements) + { + encodedStatements = HttpUtility.UrlDecode(encodedStatements); + string[] statementStrings = encodedStatements.Split(','); + + var statements = new List(); + + foreach (var statementString in statementStrings) + { + var parts = statementString.Split('&'); + if (parts.Length < 3) + continue; + + statements.Add(new FilterStatementDto + { + Comparison = Enum.Parse(parts[0].Split("=")[1]), + Field = Enum.Parse(parts[1].Split("=")[1]), + Value = HttpUtility.UrlDecode(parts[2].Split("=")[1]) + }); + } + + return statements; + } + + private static SortOptions DecodeSortOptions(string encodedSortOptions) + { + string[] parts = encodedSortOptions.Split('&'); + var sortFieldPart = parts.FirstOrDefault(part => part.StartsWith("sortField=")); + var isAscendingPart = parts.FirstOrDefault(part => part.StartsWith("isAscending=")); + + var isAscending = isAscendingPart?.Substring(11).Equals("true", StringComparison.OrdinalIgnoreCase) ?? true; + if (sortFieldPart != null) + { + var sortField = Enum.Parse(sortFieldPart.Split("=")[1]); + + return new SortOptions + { + SortField = sortField, + IsAscending = isAscending + }; + } + + return null; + } +} diff --git a/API/I18N/en.json b/API/I18N/en.json index 59851fac5..6f218e3e4 100644 --- a/API/I18N/en.json +++ b/API/I18N/en.json @@ -150,11 +150,14 @@ "browse-libraries": "Browse by Libraries", "collections": "All Collections", "browse-collections": "Browse by Collections", + "smart-filters": "Smart Filters", + "browse-smart-filters": "Browse by Smart Filters", "reading-list-restricted": "Reading list does not exist or you don't have access", "query-required": "You must pass a query parameter", "search": "Search", "search-description": "Search for Series, Collections, or Reading Lists", "favicon-doesnt-exist": "Favicon does not exist", + "smart-filter-doesnt-exist": "Smart Filter doesn't exist", "not-authenticated": "User is not authenticated", "unable-to-register-k+": "Unable to register license due to error. Reach out to Kavita+ Support", diff --git a/API/Program.cs b/API/Program.cs index 625949f65..02c725247 100644 --- a/API/Program.cs +++ b/API/Program.cs @@ -90,6 +90,7 @@ public class Program await Seed.SeedRoles(services.GetRequiredService>()); await Seed.SeedSettings(context, directoryService); await Seed.SeedThemes(context); + await Seed.SeedDefaultStreams(services.GetRequiredService()); await Seed.SeedUserApiKeys(context); } catch (Exception ex) diff --git a/API/SignalR/MessageFactory.cs b/API/SignalR/MessageFactory.cs index a721f6e6a..23afa3d60 100644 --- a/API/SignalR/MessageFactory.cs +++ b/API/SignalR/MessageFactory.cs @@ -122,6 +122,25 @@ public static class MessageFactory /// A Scrobbling Key has expired and needs rotation /// public const string ScrobblingKeyExpired = "ScrobblingKeyExpired"; + /// + /// Order, Visibility, etc has changed on the Dashboard. UI will refresh the layout + /// + public const string DashboardUpdate = "DashboardUpdate"; + + public static SignalRMessage DashboardUpdateEvent(int userId) + { + return new SignalRMessage() + { + Name = DashboardUpdate, + Title = "Dashboard Update", + Progress = ProgressType.None, + EventType = ProgressEventType.Single, + Body = new + { + UserId = userId + } + }; + } public static SignalRMessage ScanSeriesEvent(int libraryId, int seriesId, string seriesName) diff --git a/UI/Web/package-lock.json b/UI/Web/package-lock.json index 67a882de1..cae5ef64e 100644 --- a/UI/Web/package-lock.json +++ b/UI/Web/package-lock.json @@ -21,7 +21,7 @@ "@fortawesome/fontawesome-free": "^6.4.2", "@iharbeck/ngx-virtual-scroller": "^16.0.0", "@iplab/ngx-file-upload": "^16.0.1", - "@microsoft/signalr": "^7.0.10", + "@microsoft/signalr": "^7.0.11", "@ng-bootstrap/ng-bootstrap": "^15.1.1", "@ngneat/transloco": "^5.0.7", "@ngneat/transloco-locale": "^5.1.1", @@ -3142,9 +3142,9 @@ "dev": true }, "node_modules/@microsoft/signalr": { - "version": "7.0.10", - "resolved": "https://registry.npmjs.org/@microsoft/signalr/-/signalr-7.0.10.tgz", - "integrity": "sha512-tOEn32i5EatAx4sZbzmLgcBc2VbKQmx+F4rI2/Ioq2MnBaYcFxbDzOoZgISIS4IR9H1ij/sKoU8zQOAFC8GJKg==", + "version": "7.0.11", + "resolved": "https://registry.npmjs.org/@microsoft/signalr/-/signalr-7.0.11.tgz", + "integrity": "sha512-//6ipnYKhHf2MJgM+MQSlgB5L/pcYeZ+v4w6YAr4epRM1iSDQ6WjUkCVX2ZMxcY06XGlLzggs3Z9ZIcL9ws9KQ==", "dependencies": { "abort-controller": "^3.0.0", "eventsource": "^2.0.2", diff --git a/UI/Web/package.json b/UI/Web/package.json index 05da6248c..d59335084 100644 --- a/UI/Web/package.json +++ b/UI/Web/package.json @@ -26,7 +26,7 @@ "@fortawesome/fontawesome-free": "^6.4.2", "@iharbeck/ngx-virtual-scroller": "^16.0.0", "@iplab/ngx-file-upload": "^16.0.1", - "@microsoft/signalr": "^7.0.10", + "@microsoft/signalr": "^7.0.11", "@ng-bootstrap/ng-bootstrap": "^15.1.1", "@ngneat/transloco": "^5.0.7", "@ngneat/transloco-locale": "^5.1.1", diff --git a/UI/Web/src/app/_models/dashboard/dashboard-stream.ts b/UI/Web/src/app/_models/dashboard/dashboard-stream.ts new file mode 100644 index 000000000..69824492d --- /dev/null +++ b/UI/Web/src/app/_models/dashboard/dashboard-stream.ts @@ -0,0 +1,14 @@ +import {Observable} from "rxjs"; +import {StreamType} from "./stream-type.enum"; + +export interface DashboardStream { + id: number; + name: string; + isProvided: boolean; + api: Observable; + smartFilterId: number; + smartFilterEncoded?: string; + streamType: StreamType; + order: number; + visible: boolean; +} diff --git a/UI/Web/src/app/_models/dashboard/stream-type.enum.ts b/UI/Web/src/app/_models/dashboard/stream-type.enum.ts new file mode 100644 index 000000000..ae26262ad --- /dev/null +++ b/UI/Web/src/app/_models/dashboard/stream-type.enum.ts @@ -0,0 +1,7 @@ +export enum StreamType { + OnDeck = 1, + RecentlyUpdated = 2, + NewlyAdded = 3, + SmartFilter = 4, + MoreInGenre = 5 +} diff --git a/UI/Web/src/app/_models/events/dashboard-update-event.ts b/UI/Web/src/app/_models/events/dashboard-update-event.ts new file mode 100644 index 000000000..f7d8b8838 --- /dev/null +++ b/UI/Web/src/app/_models/events/dashboard-update-event.ts @@ -0,0 +1,3 @@ +export interface DashboardUpdateEvent { + userId: number; +} diff --git a/UI/Web/src/app/_models/events/library-modified-event.ts b/UI/Web/src/app/_models/events/library-modified-event.ts index d09055401..8cc6e0138 100644 --- a/UI/Web/src/app/_models/events/library-modified-event.ts +++ b/UI/Web/src/app/_models/events/library-modified-event.ts @@ -1,4 +1,4 @@ export interface LibraryModifiedEvent { libraryId: number; - action: 'create' | 'delelte'; -} \ No newline at end of file + action: 'create' | 'delete'; +} diff --git a/UI/Web/src/app/_models/metadata/v2/filter-field.ts b/UI/Web/src/app/_models/metadata/v2/filter-field.ts index 27335b7ca..8dec89f95 100644 --- a/UI/Web/src/app/_models/metadata/v2/filter-field.ts +++ b/UI/Web/src/app/_models/metadata/v2/filter-field.ts @@ -26,7 +26,8 @@ export enum FilterField ReleaseYear = 22, ReadTime = 23, Path = 24, - FilePath = 25 + FilePath = 25, + WantToRead = 26 } export const allFields = Object.keys(FilterField) diff --git a/UI/Web/src/app/_models/metadata/v2/smart-filter.ts b/UI/Web/src/app/_models/metadata/v2/smart-filter.ts new file mode 100644 index 000000000..81a8bf58a --- /dev/null +++ b/UI/Web/src/app/_models/metadata/v2/smart-filter.ts @@ -0,0 +1,5 @@ +export interface SmartFilter { + id: number; + name: string; + filter: string; +} diff --git a/UI/Web/src/app/_services/account.service.ts b/UI/Web/src/app/_services/account.service.ts index 786a41084..fedad5ccd 100644 --- a/UI/Web/src/app/_services/account.service.ts +++ b/UI/Web/src/app/_services/account.service.ts @@ -58,7 +58,7 @@ export class AccountService { filter(userUpdateEvent => userUpdateEvent.userName === this.currentUser?.username), switchMap(() => this.refreshAccount())) .subscribe(() => {}); - } + } hasAdminRole(user: User) { return user && user.roles.includes(Role.Admin); diff --git a/UI/Web/src/app/_services/dashboard.service.ts b/UI/Web/src/app/_services/dashboard.service.ts new file mode 100644 index 000000000..cc32046a6 --- /dev/null +++ b/UI/Web/src/app/_services/dashboard.service.ts @@ -0,0 +1,29 @@ +import { Injectable } from '@angular/core'; +import {TextResonse} from "../_types/text-response"; +import {HttpClient} from "@angular/common/http"; +import {environment} from "../../environments/environment"; +import {DashboardStream} from "../_models/dashboard/dashboard-stream"; + +@Injectable({ + providedIn: 'root' +}) +export class DashboardService { + baseUrl = environment.apiUrl; + constructor(private httpClient: HttpClient) { } + + getDashboardStreams(visibleOnly = true) { + return this.httpClient.get>(this.baseUrl + 'account/dashboard?visibleOnly=' + visibleOnly); + } + + updateDashboardStreamPosition(streamName: string, dashboardStreamId: number, fromPosition: number, toPosition: number) { + return this.httpClient.post(this.baseUrl + 'account/update-dashboard-position', {streamName, dashboardStreamId, fromPosition, toPosition}, TextResonse); + } + + updateDashboardStream(stream: DashboardStream) { + return this.httpClient.post(this.baseUrl + 'account/update-dashboard-stream', stream, TextResonse); + } + + createDashboardStream(smartFilterId: number) { + return this.httpClient.post(this.baseUrl + 'account/add-dashboard-stream?smartFilterId=' + smartFilterId, {}); + } +} diff --git a/UI/Web/src/app/_services/filter.service.ts b/UI/Web/src/app/_services/filter.service.ts new file mode 100644 index 000000000..7d2648072 --- /dev/null +++ b/UI/Web/src/app/_services/filter.service.ts @@ -0,0 +1,26 @@ +import { Injectable } from '@angular/core'; +import {SeriesFilterV2} from "../_models/metadata/v2/series-filter-v2"; +import {environment} from "../../environments/environment"; +import {HttpClient} from "@angular/common/http"; +import {JumpKey} from "../_models/jumpbar/jump-key"; +import {SmartFilter} from "../_models/metadata/v2/smart-filter"; + +@Injectable({ + providedIn: 'root' +}) +export class FilterService { + + baseUrl = environment.apiUrl; + constructor(private httpClient: HttpClient) { } + + saveFilter(filter: SeriesFilterV2) { + return this.httpClient.post(this.baseUrl + 'filter/update', filter); + } + getAllFilters() { + return this.httpClient.get>(this.baseUrl + 'filter'); + } + deleteFilter(filterId: number) { + return this.httpClient.delete(this.baseUrl + 'filter?filterId=' + filterId); + } + +} diff --git a/UI/Web/src/app/_services/message-hub.service.ts b/UI/Web/src/app/_services/message-hub.service.ts index 73d0b0e14..57279de18 100644 --- a/UI/Web/src/app/_services/message-hub.service.ts +++ b/UI/Web/src/app/_services/message-hub.service.ts @@ -7,6 +7,7 @@ import { NotificationProgressEvent } from '../_models/events/notification-progre import { ThemeProgressEvent } from '../_models/events/theme-progress-event'; import { UserUpdateEvent } from '../_models/events/user-update-event'; import { User } from '../_models/user'; +import {DashboardUpdateEvent} from "../_models/events/dashboard-update-event"; export enum EVENTS { UpdateAvailable = 'UpdateAvailable', @@ -82,6 +83,10 @@ export enum EVENTS { * A scrobbling token has expired */ ScrobblingKeyExpired = 'ScrobblingKeyExpired', + /** + * User's dashboard needs to be re-rendered + */ + DashboardUpdate = 'DashboardUpdate' } export interface Message { @@ -109,7 +114,6 @@ export class MessageHubService { */ public onlineUsers$ = this.onlineUsersSource.asObservable(); - isAdmin: boolean = false; constructor() {} @@ -181,6 +185,13 @@ export class MessageHubService { }); }); + this.hubConnection.on(EVENTS.DashboardUpdate, resp => { + console.log('dashboard update event came in') + this.messagesSource.next({ + event: EVENTS.DashboardUpdate, + payload: resp.body as DashboardUpdateEvent + }); + }); this.hubConnection.on(EVENTS.NotificationProgress, (resp: NotificationProgressEvent) => { this.messagesSource.next({ diff --git a/UI/Web/src/app/_single-module/review-card/review-card.component.html b/UI/Web/src/app/_single-module/review-card/review-card.component.html index e665aaaaf..41722fb89 100644 --- a/UI/Web/src/app/_single-module/review-card/review-card.component.html +++ b/UI/Web/src/app/_single-module/review-card/review-card.component.html @@ -23,14 +23,16 @@ diff --git a/UI/Web/src/app/_single-module/review-card/review-card.component.scss b/UI/Web/src/app/_single-module/review-card/review-card.component.scss index 62bf3e443..bb425ec08 100644 --- a/UI/Web/src/app/_single-module/review-card/review-card.component.scss +++ b/UI/Web/src/app/_single-module/review-card/review-card.component.scss @@ -41,5 +41,9 @@ } .card-footer { - font-size: 13px + font-size: 13px; + display: flex; + max-width: 305px; + justify-content: space-between; + margin: 0 auto; } diff --git a/UI/Web/src/app/dashboard/_components/dashboard.component.html b/UI/Web/src/app/dashboard/_components/dashboard.component.html index 715505fb6..db3d34719 100644 --- a/UI/Web/src/app/dashboard/_components/dashboard.component.html +++ b/UI/Web/src/app/dashboard/_components/dashboard.component.html @@ -1,36 +1,86 @@ - - -
-
-

{{t('no-libraries')}} {{t('server-settings-link')}}.

-
-
-

{{t('not-granted')}}

-
-
-
+ + +
+
+

{{t('no-libraries')}} {{t('server-settings-link')}}.

+
+
+

{{t('not-granted')}}

+
+
+
+
+ + + + + + + + + + + + - - + + + + + + + + + + + + + + - - + (reload)="reloadStream(stream.id)" (dataChanged)="reloadStream(stream.id)"> + + + + - - + + + + - - + [suppressArchiveWarning]="true" (clicked)="handleRecentlyAddedChapterClick(item)" [count]="item.count"> + + +
+ + + + + + + + + + + + + + + + + + + + + - - - - - +
+ + diff --git a/UI/Web/src/app/dashboard/_components/dashboard.component.ts b/UI/Web/src/app/dashboard/_components/dashboard.component.ts index c750fde98..ece94bee5 100644 --- a/UI/Web/src/app/dashboard/_components/dashboard.component.ts +++ b/UI/Web/src/app/dashboard/_components/dashboard.component.ts @@ -1,16 +1,13 @@ import {ChangeDetectionStrategy, ChangeDetectorRef, Component, DestroyRef, inject, Input, OnInit} from '@angular/core'; 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 {Observable, of, ReplaySubject, Subject, switchMap} from 'rxjs'; +import {map, shareReplay, take, tap, throttleTime} from 'rxjs/operators'; 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'; import {RecentlyAddedItem} from 'src/app/_models/recently-added-item'; import {Series} from 'src/app/_models/series'; import {SortField} from 'src/app/_models/metadata/series-filter'; -import {SeriesGroup} from 'src/app/_models/series-group'; import {AccountService} from 'src/app/_services/account.service'; import {ImageService} from 'src/app/_services/image.service'; import {LibraryService} from 'src/app/_services/library.service'; @@ -20,13 +17,21 @@ import {takeUntilDestroyed} from "@angular/core/rxjs-interop"; import {CardItemComponent} from '../../cards/card-item/card-item.component'; import {SeriesCardComponent} from '../../cards/series-card/series-card.component'; import {CarouselReelComponent} from '../../carousel/_components/carousel-reel/carousel-reel.component'; -import {AsyncPipe, NgIf} from '@angular/common'; +import {AsyncPipe, NgForOf, NgIf, NgSwitch, NgSwitchCase, NgTemplateOutlet} from '@angular/common'; import { SideNavCompanionBarComponent } from '../../sidenav/_components/side-nav-companion-bar/side-nav-companion-bar.component'; -import {TranslocoDirective} from "@ngneat/transloco"; +import {translate, TranslocoDirective} from "@ngneat/transloco"; import {FilterField} from "../../_models/metadata/v2/filter-field"; import {FilterComparison} from "../../_models/metadata/v2/filter-comparison"; +import {DashboardService} from "../../_services/dashboard.service"; +import {MetadataService} from "../../_services/metadata.service"; +import {RecommendationService} from "../../_services/recommendation.service"; +import {Genre} from "../../_models/metadata/genre"; +import {DashboardStream} from "../../_models/dashboard/dashboard-stream"; +import {StreamType} from "../../_models/dashboard/stream-type.enum"; +import {SeriesRemovedEvent} from "../../_models/events/series-removed-event"; +import {LoadingComponent} from "../../shared/loading/loading.component"; @Component({ selector: 'app-dashboard', @@ -34,7 +39,8 @@ import {FilterComparison} from "../../_models/metadata/v2/filter-comparison"; styleUrls: ['./dashboard.component.scss'], changeDetection: ChangeDetectionStrategy.OnPush, standalone: true, - imports: [SideNavCompanionBarComponent, NgIf, RouterLink, CarouselReelComponent, SeriesCardComponent, CardItemComponent, AsyncPipe, TranslocoDirective] + imports: [SideNavCompanionBarComponent, NgIf, RouterLink, CarouselReelComponent, SeriesCardComponent, + CardItemComponent, AsyncPipe, TranslocoDirective, NgSwitchCase, NgSwitch, NgForOf, NgTemplateOutlet, LoadingComponent] }) export class DashboardComponent implements OnInit { @@ -44,13 +50,14 @@ export class DashboardComponent implements OnInit { @Input() libraryId: number = 0; libraries$: Observable = of([]); - isLoading = true; - + isLoadingAdmin = true; + isLoadingDashboard = true; isAdmin$: Observable = of(false); - recentlyUpdatedSeries: SeriesGroup[] = []; - inProgress: Series[] = []; - recentlyAddedSeries: Series[] = []; + streams: Array = []; + genre: Genre | undefined; + refreshStreams$ = new Subject(); + /** * We use this Replay subject to slow the amount of times we reload the UI @@ -58,112 +65,133 @@ export class DashboardComponent implements OnInit { private loadRecentlyAdded$: ReplaySubject = new ReplaySubject(); private readonly destroyRef = inject(DestroyRef); private readonly filterUtilityService = inject(FilterUtilitiesService); + private readonly metadataService = inject(MetadataService); + private readonly recommendationService = inject(RecommendationService); + protected readonly StreamType = StreamType; + + constructor(public accountService: AccountService, private libraryService: LibraryService, private seriesService: SeriesService, private router: Router, private titleService: Title, public imageService: ImageService, - private messageHub: MessageHubService, private readonly cdRef: ChangeDetectorRef) { - - this.messageHub.messages$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(res => { - if (res.event === EVENTS.SeriesAdded) { - const seriesAddedEvent = res.payload as SeriesAddedEvent; + private messageHub: MessageHubService, private readonly cdRef: ChangeDetectorRef, + private dashboardService: DashboardService) { - this.seriesService.getSeries(seriesAddedEvent.seriesId).subscribe(series => { - if (this.recentlyAddedSeries.filter(s => s.id === series.id).length > 0) return; - this.recentlyAddedSeries = [series, ...this.recentlyAddedSeries]; - this.cdRef.markForCheck(); - }); - } else if (res.event === EVENTS.SeriesRemoved) { - const seriesRemovedEvent = res.payload as SeriesRemovedEvent; + this.loadDashboard(); - this.inProgress = this.inProgress.filter(item => item.id != seriesRemovedEvent.seriesId); - this.recentlyAddedSeries = this.recentlyAddedSeries.filter(item => item.id != seriesRemovedEvent.seriesId); - this.recentlyUpdatedSeries = this.recentlyUpdatedSeries.filter(item => item.seriesId != seriesRemovedEvent.seriesId); - this.cdRef.markForCheck(); - } else if (res.event === EVENTS.ScanSeries) { - // We don't have events for when series are updated, but we do get events when a scan update occurs. Refresh recentlyAdded at that time. - this.loadRecentlyAdded$.next(); - } - }); + this.refreshStreams$.pipe(takeUntilDestroyed(this.destroyRef), throttleTime(10_000), + tap(() => { + this.loadDashboard() + })) + .subscribe(); - this.isAdmin$ = this.accountService.currentUser$.pipe( - takeUntilDestroyed(this.destroyRef), - map(user => (user && this.accountService.hasAdminRole(user)) || false), - shareReplay() - ); - this.loadRecentlyAdded$.pipe(debounceTime(1000), takeUntilDestroyed(this.destroyRef)).subscribe(() => { - this.loadRecentlyUpdated(); - this.loadRecentlyAddedSeries(); - this.cdRef.markForCheck(); - }); + // TODO: Solve how Websockets will work with these dyanamic streams + this.messageHub.messages$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(res => { + + if (res.event === EVENTS.DashboardUpdate) { + console.log('dashboard update triggered') + this.refreshStreams$.next(); + } else if (res.event === EVENTS.SeriesAdded) { + // const seriesAddedEvent = res.payload as SeriesAddedEvent; + + // this.seriesService.getSeries(seriesAddedEvent.seriesId).subscribe(series => { + // if (this.recentlyAddedSeries.filter(s => s.id === series.id).length > 0) return; + // this.recentlyAddedSeries = [series, ...this.recentlyAddedSeries]; + // this.cdRef.markForCheck(); + // }); + this.refreshStreams$.next(); + } else if (res.event === EVENTS.SeriesRemoved) { + //const seriesRemovedEvent = res.payload as SeriesRemovedEvent; + + // + // this.inProgress = this.inProgress.filter(item => item.id != seriesRemovedEvent.seriesId); + // this.recentlyAddedSeries = this.recentlyAddedSeries.filter(item => item.id != seriesRemovedEvent.seriesId); + // this.recentlyUpdatedSeries = this.recentlyUpdatedSeries.filter(item => item.seriesId != seriesRemovedEvent.seriesId); + // this.cdRef.markForCheck(); + this.refreshStreams$.next(); + } else if (res.event === EVENTS.ScanSeries) { + // We don't have events for when series are updated, but we do get events when a scan update occurs. Refresh recentlyAdded at that time. + this.loadRecentlyAdded$.next(); + this.refreshStreams$.next(); + } + }); + + this.isAdmin$ = this.accountService.currentUser$.pipe( + takeUntilDestroyed(this.destroyRef), + map(user => (user && this.accountService.hasAdminRole(user)) || false), + shareReplay({bufferSize: 1, refCount: true}) + ); } ngOnInit(): void { - this.titleService.setTitle('Kavita - Dashboard'); - this.isLoading = true; + this.titleService.setTitle('Kavita'); + this.isLoadingAdmin = true; this.cdRef.markForCheck(); this.libraries$ = this.libraryService.getLibraries().pipe(take(1), takeUntilDestroyed(this.destroyRef), tap((libs) => { - this.isLoading = false; + this.isLoadingAdmin = false; this.cdRef.markForCheck(); })); - - this.reloadSeries(); - } - - reloadSeries() { - this.loadOnDeck(); - this.loadRecentlyUpdated(); - this.loadRecentlyAddedSeries(); - } - - reloadInProgress(series: Series | number) { - this.loadOnDeck(); - } - - loadOnDeck() { - let api = this.seriesService.getOnDeck(0, 1, 30); - if (this.libraryId > 0) { - api = this.seriesService.getOnDeck(this.libraryId, 1, 30); - } - api.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((updatedSeries) => { - this.inProgress = updatedSeries.result; - this.cdRef.markForCheck(); - }); - } - - loadRecentlyAddedSeries() { - let api = this.seriesService.getRecentlyAdded(1, 30); - if (this.libraryId > 0) { - const filter = this.filterUtilityService.createSeriesV2Filter(); - filter.statements.push({field: FilterField.Libraries, value: this.libraryId + '', comparison: FilterComparison.Equal}); - api = this.seriesService.getRecentlyAdded(1, 30, filter); - } - api.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((updatedSeries) => { - this.recentlyAddedSeries = updatedSeries.result; - this.cdRef.markForCheck(); - }); } - loadRecentlyUpdated() { - let api = this.seriesService.getRecentlyUpdatedSeries(); - if (this.libraryId > 0) { - api = this.seriesService.getRecentlyUpdatedSeries(); - } - api.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(updatedSeries => { - this.recentlyUpdatedSeries = updatedSeries.filter(group => { - if (this.libraryId === 0) return true; - return group.libraryId === this.libraryId; + loadDashboard() { + this.isLoadingDashboard = true; + this.cdRef.markForCheck(); + this.dashboardService.getDashboardStreams().subscribe(streams => { + this.streams = streams; + this.streams.forEach(s => { + switch (s.streamType) { + case StreamType.OnDeck: + s.api = this.seriesService.getOnDeck(0, 1, 20) + .pipe(map(d => d.result), takeUntilDestroyed(this.destroyRef), shareReplay({bufferSize: 1, refCount: true})); + break; + case StreamType.NewlyAdded: + s.api = this.seriesService.getRecentlyAdded(1, 20) + .pipe(map(d => d.result), takeUntilDestroyed(this.destroyRef), shareReplay({bufferSize: 1, refCount: true})); + break; + case StreamType.RecentlyUpdated: + s.api = this.seriesService.getRecentlyUpdatedSeries(); + break; + case StreamType.SmartFilter: + s.api = this.seriesService.getAllSeriesV2(0, 20, this.filterUtilityService.decodeSeriesFilter(s.smartFilterEncoded!)) + .pipe(map(d => d.result), takeUntilDestroyed(this.destroyRef), shareReplay({bufferSize: 1, refCount: true})); + break; + case StreamType.MoreInGenre: + s.api = this.metadataService.getAllGenres().pipe( + map(genres => { + this.genre = genres[Math.floor(Math.random() * genres.length)]; + return this.genre; + }), + switchMap(genre => this.recommendationService.getMoreIn(0, genre.id, 0, 30)), + map(p => p.result), + takeUntilDestroyed(this.destroyRef), + shareReplay({bufferSize: 1, refCount: true}) + ); + break; + } }); + this.isLoadingDashboard = false; this.cdRef.markForCheck(); }); } - handleRecentlyAddedChapterClick(item: RecentlyAddedItem) { - this.router.navigate(['library', item.libraryId, 'series', item.seriesId]); + reloadStream(streamId: number) { + const index = this.streams.findIndex(s => s.id === streamId); + if (index < 0) return; + this.streams[index] = {...this.streams[index]}; + console.log('swapped out stream: ', this.streams[index]); + this.cdRef.detectChanges(); + } + + async handleRecentlyAddedChapterClick(item: RecentlyAddedItem) { + await this.router.navigate(['library', item.libraryId, 'series', item.seriesId]); + } + + async handleFilterSectionClick(stream: DashboardStream) { + await this.router.navigateByUrl('all-series?' + stream.smartFilterEncoded); } handleSectionClick(sectionTitle: string) { @@ -180,7 +208,7 @@ export class DashboardComponent implements OnInit { } else if (sectionTitle.toLowerCase() === 'on deck') { const params: any = {}; params['page'] = 1; - params['title'] = 'On Deck'; + params['title'] = translate('dashboard.on-deck-title'); const filter = this.filterUtilityService.createSeriesV2Filter(); filter.statements.push({field: FilterField.ReadProgress, comparison: FilterComparison.GreaterThan, value: '0'}); @@ -190,16 +218,23 @@ export class DashboardComponent implements OnInit { 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 = {}; params['page'] = 1; - params['title'] = 'Newly Added'; + params['title'] = translate('dashboard.recently-added-title'); const filter = this.filterUtilityService.createSeriesV2Filter(); if (filter.sortOptions) { filter.sortOptions.sortField = SortField.Created; filter.sortOptions.isAscending = false; } this.filterUtilityService.applyFilterWithParams(['all-series'], filter, params) + } else if (sectionTitle.toLowerCase() === 'more in genre') { + const params: any = {}; + params['page'] = 1; + params['title'] = translate('more-in-genre-title', {genre: this.genre?.title}); + const filter = this.filterUtilityService.createSeriesV2Filter(); + filter.statements.push({field: FilterField.Genres, value: this.genre?.id + '', comparison: FilterComparison.MustContains}); + this.filterUtilityService.applyFilterWithParams(['all-series'], filter, params) } } diff --git a/UI/Web/src/app/library-detail/library-detail.component.html b/UI/Web/src/app/library-detail/library-detail.component.html index dc5d49ca7..3f29b4b5d 100644 --- a/UI/Web/src/app/library-detail/library-detail.component.html +++ b/UI/Web/src/app/library-detail/library-detail.component.html @@ -4,45 +4,26 @@ {{libraryName}} - +
{{t('common.series-count', {num: pagination.totalItems | number})}}
-
{{t('common.series-count', {num: pagination.totalItems | number})}}
-
+ + + + + + diff --git a/UI/Web/src/app/library-detail/library-detail.component.ts b/UI/Web/src/app/library-detail/library-detail.component.ts index f2f71fc71..977b6c3cc 100644 --- a/UI/Web/src/app/library-detail/library-detail.component.ts +++ b/UI/Web/src/app/library-detail/library-detail.component.ts @@ -33,7 +33,6 @@ import {SentenceCasePipe} from '../pipe/sentence-case.pipe'; import {BulkOperationsComponent} from '../cards/bulk-operations/bulk-operations.component'; import {SeriesCardComponent} from '../cards/series-card/series-card.component'; import {CardDetailLayoutComponent} from '../cards/card-detail-layout/card-detail-layout.component'; -import {LibraryRecommendedComponent} from './library-recommended/library-recommended.component'; import {DecimalPipe, NgFor, NgIf} from '@angular/common'; import {NgbNav, NgbNavContent, NgbNavItem, NgbNavItemRole, NgbNavLink, NgbNavOutlet} from '@ng-bootstrap/ng-bootstrap'; import { @@ -52,7 +51,8 @@ import {CardActionablesComponent} from "../_single-module/card-actionables/card- styleUrls: ['./library-detail.component.scss'], changeDetection: ChangeDetectionStrategy.OnPush, standalone: true, - imports: [SideNavCompanionBarComponent, CardActionablesComponent, NgbNav, NgFor, NgbNavItem, NgbNavItemRole, NgbNavLink, NgbNavContent, NgIf, LibraryRecommendedComponent, CardDetailLayoutComponent, SeriesCardComponent, BulkOperationsComponent, NgbNavOutlet, DecimalPipe, SentenceCasePipe, TranslocoDirective] + imports: [SideNavCompanionBarComponent, CardActionablesComponent, NgbNav, NgFor, NgbNavItem, NgbNavItemRole, NgbNavLink, NgbNavContent, NgIf + , CardDetailLayoutComponent, SeriesCardComponent, BulkOperationsComponent, NgbNavOutlet, DecimalPipe, SentenceCasePipe, TranslocoDirective] }) export class LibraryDetailComponent implements OnInit { @@ -284,9 +284,5 @@ export class LibraryDetailComponent implements OnInit { }); } - seriesClicked(series: Series) { - this.router.navigate(['library', this.libraryId, 'series', series.id]); - } - trackByIdentity = (index: number, item: Series) => `${item.id}_${item.name}_${item.localizedName}_${item.pagesRead}`; } diff --git a/UI/Web/src/app/library-detail/library-detail.module.ts b/UI/Web/src/app/library-detail/library-detail.module.ts index 736f771b7..a90d0aa21 100644 --- a/UI/Web/src/app/library-detail/library-detail.module.ts +++ b/UI/Web/src/app/library-detail/library-detail.module.ts @@ -3,7 +3,6 @@ import { CommonModule } from '@angular/common'; import { LibraryDetailComponent } from './library-detail.component'; import { NgbNavModule } from '@ng-bootstrap/ng-bootstrap'; import { LibraryDetailRoutingModule } from './library-detail-routing.module'; -import { LibraryRecommendedComponent } from './library-recommended/library-recommended.component'; import {SentenceCasePipe} from "../pipe/sentence-case.pipe"; import {CardDetailLayoutComponent} from "../cards/card-detail-layout/card-detail-layout.component"; @@ -27,7 +26,7 @@ import {CardActionablesComponent} from "../_single-module/card-actionables/card- SeriesCardComponent, BulkOperationsComponent, SideNavCompanionBarComponent, - LibraryDetailComponent, LibraryRecommendedComponent + LibraryDetailComponent, ] }) export class LibraryDetailModule { } diff --git a/UI/Web/src/app/library-detail/library-recommended/library-recommended.component.html b/UI/Web/src/app/library-detail/library-recommended/library-recommended.component.html deleted file mode 100644 index d498dd87d..000000000 --- a/UI/Web/src/app/library-detail/library-recommended/library-recommended.component.html +++ /dev/null @@ -1,61 +0,0 @@ - - - -

- {{t('no-data')}} -

-
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
diff --git a/UI/Web/src/app/library-detail/library-recommended/library-recommended.component.scss b/UI/Web/src/app/library-detail/library-recommended/library-recommended.component.scss deleted file mode 100644 index e69de29bb..000000000 diff --git a/UI/Web/src/app/library-detail/library-recommended/library-recommended.component.ts b/UI/Web/src/app/library-detail/library-recommended/library-recommended.component.ts deleted file mode 100644 index 7405a4a74..000000000 --- a/UI/Web/src/app/library-detail/library-recommended/library-recommended.component.ts +++ /dev/null @@ -1,91 +0,0 @@ -import { - ChangeDetectionStrategy, - Component, - DestroyRef, - inject, - Input, - OnInit -} from '@angular/core'; -import { filter, map, merge, Observable, shareReplay } from 'rxjs'; -import { Genre } from 'src/app/_models/metadata/genre'; -import { Series } from 'src/app/_models/series'; -import { MetadataService } from 'src/app/_services/metadata.service'; -import { RecommendationService } from 'src/app/_services/recommendation.service'; -import { SeriesService } from 'src/app/_services/series.service'; -import {takeUntilDestroyed} from "@angular/core/rxjs-interop"; -import { SeriesCardComponent } from '../../cards/series-card/series-card.component'; -import { CarouselReelComponent } from '../../carousel/_components/carousel-reel/carousel-reel.component'; -import { NgIf, AsyncPipe } from '@angular/common'; -import {TranslocoDirective} from "@ngneat/transloco"; - -@Component({ - selector: 'app-library-recommended', - templateUrl: './library-recommended.component.html', - styleUrls: ['./library-recommended.component.scss'], - changeDetection: ChangeDetectionStrategy.OnPush, - standalone: true, - imports: [NgIf, CarouselReelComponent, SeriesCardComponent, AsyncPipe, TranslocoDirective] -}) -export class LibraryRecommendedComponent implements OnInit { - - @Input() libraryId: number = 0; - private readonly destroyRef = inject(DestroyRef); - - quickReads$!: Observable; - quickCatchups$!: Observable; - highlyRated$!: Observable; - onDeck$!: Observable; - rediscover$!: Observable; - moreIn$!: Observable; - genre$!: Observable; - - all$!: Observable; - - constructor(private recommendationService: RecommendationService, private seriesService: SeriesService, - private metadataService: MetadataService) { } - - ngOnInit(): void { - - this.quickReads$ = this.recommendationService.getQuickReads(this.libraryId, 0, 30) - .pipe(takeUntilDestroyed(this.destroyRef), map(p => p.result), shareReplay()); - - this.quickCatchups$ = this.recommendationService.getQuickCatchupReads(this.libraryId, 0, 30) - .pipe(takeUntilDestroyed(this.destroyRef), map(p => p.result), shareReplay()); - - this.highlyRated$ = this.recommendationService.getHighlyRated(this.libraryId, 0, 30) - .pipe(takeUntilDestroyed(this.destroyRef), map(p => p.result), shareReplay()); - - this.rediscover$ = this.recommendationService.getRediscover(this.libraryId, 0, 30) - .pipe(takeUntilDestroyed(this.destroyRef), map(p => p.result), shareReplay()); - - this.onDeck$ = this.seriesService.getOnDeck(this.libraryId, 0, 30) - .pipe(takeUntilDestroyed(this.destroyRef), map(p => p.result), shareReplay()); - - this.genre$ = this.metadataService.getAllGenres([this.libraryId]).pipe( - takeUntilDestroyed(this.destroyRef), - map(genres => genres[Math.floor(Math.random() * genres.length)]), - shareReplay() - ); - this.genre$.subscribe(genre => { - this.moreIn$ = this.recommendationService.getMoreIn(this.libraryId, genre.id, 0, 30).pipe(takeUntilDestroyed(this.destroyRef), map(p => p.result), shareReplay()); - }); - - this.all$ = merge(this.quickReads$, this.quickCatchups$, this.highlyRated$, this.rediscover$, this.onDeck$, this.genre$).pipe(takeUntilDestroyed(this.destroyRef)); - } - - - reloadInProgress(series: Series | number) { - if (Number.isInteger(series)) { - if (!series) {return;} - } - // If the update to Series doesn't affect the requirement to be in this stream, then ignore update request - const seriesObj = (series as Series); - if (seriesObj.pagesRead !== seriesObj.pages && seriesObj.pagesRead !== 0) { - return; - } - - this.quickReads$ = this.quickReads$.pipe(filter(series => !series.includes(seriesObj))); - this.quickCatchups$ = this.quickCatchups$.pipe(filter(series => !series.includes(seriesObj))); - } - -} diff --git a/UI/Web/src/app/metadata-filter/_components/metadata-filter-row/metadata-filter-row.component.html b/UI/Web/src/app/metadata-filter/_components/metadata-filter-row/metadata-filter-row.component.html index 36fa209f0..4894d96e0 100644 --- a/UI/Web/src/app/metadata-filter/_components/metadata-filter-row/metadata-filter-row.component.html +++ b/UI/Web/src/app/metadata-filter/_components/metadata-filter-row/metadata-filter-row.component.html @@ -22,6 +22,9 @@ + + + diff --git a/UI/Web/src/app/metadata-filter/_components/metadata-filter-row/metadata-filter-row.component.ts b/UI/Web/src/app/metadata-filter/_components/metadata-filter-row/metadata-filter-row.component.ts index fc6f0f84c..d61a4df86 100644 --- a/UI/Web/src/app/metadata-filter/_components/metadata-filter-row/metadata-filter-row.component.ts +++ b/UI/Web/src/app/metadata-filter/_components/metadata-filter-row/metadata-filter-row.component.ts @@ -30,6 +30,7 @@ enum PredicateType { Text = 1, Number = 2, Dropdown = 3, + Boolean = 4 } const StringFields = [FilterField.SeriesName, FilterField.Summary, FilterField.Path, FilterField.FilePath]; @@ -41,6 +42,7 @@ const DropdownFields = [FilterField.PublicationStatus, FilterField.Languages, Fi FilterField.Writers, FilterField.Genres, FilterField.Libraries, FilterField.Formats, FilterField.CollectionTags, FilterField.Tags ]; +const BooleanFields = [FilterField.WantToRead] const DropdownFieldsWithoutMustContains = [ FilterField.Libraries, FilterField.Formats, FilterField.AgeRating, FilterField.PublicationStatus @@ -69,6 +71,9 @@ const DropdownComparisons = [FilterComparison.Equal, FilterComparison.Contains, FilterComparison.NotContains, FilterComparison.MustContains]; +const BooleanComparisons = [ + FilterComparison.Equal +] @Component({ selector: 'app-metadata-row-filter', @@ -155,7 +160,11 @@ export class MetadataFilterRowComponent implements OnInit { stmt.value = stmt.value + ''; } - if (!stmt.value && stmt.field !== FilterField.SeriesName) return; + if (typeof stmt.value === 'boolean') { + stmt.value = stmt.value + ''; + } + + if (!stmt.value && (stmt.field !== FilterField.SeriesName && !BooleanFields.includes(stmt.field))) return; this.filterStatement.emit(stmt); }); @@ -172,6 +181,8 @@ export class MetadataFilterRowComponent implements OnInit { if (StringFields.includes(this.preset.field)) { this.formGroup.get('filterValue')?.patchValue(val); + } else if (BooleanFields.includes(this.preset.field)) { + this.formGroup.get('filterValue')?.patchValue(val); } else if (DropdownFields.includes(this.preset.field)) { if (this.MultipleDropdownAllowed || val.includes(',')) { this.formGroup.get('filterValue')?.patchValue(val.split(',').map(d => parseInt(d, 10))); @@ -270,6 +281,16 @@ export class MetadataFilterRowComponent implements OnInit { return; } + if (BooleanFields.includes(inputVal)) { + this.validComparisons$.next(BooleanComparisons); + this.predicateType$.next(PredicateType.Boolean); + + if (this.loaded) { + this.formGroup.get('filterValue')?.patchValue(false); + } + return; + } + if (DropdownFields.includes(inputVal)) { let comps = [...DropdownComparisons]; if (DropdownFieldsThatIncludeNumberComparisons.includes(inputVal)) { diff --git a/UI/Web/src/app/metadata-filter/_pipes/filter-field.pipe.ts b/UI/Web/src/app/metadata-filter/_pipes/filter-field.pipe.ts index d8e67b04d..7fe77b79b 100644 --- a/UI/Web/src/app/metadata-filter/_pipes/filter-field.pipe.ts +++ b/UI/Web/src/app/metadata-filter/_pipes/filter-field.pipe.ts @@ -62,6 +62,8 @@ export class FilterFieldPipe implements PipeTransform { return translate('filter-field-pipe.path'); case FilterField.FilePath: return translate('filter-field-pipe.file-path'); + case FilterField.WantToRead: + return translate('filter-field-pipe.want-to-read'); default: throw new Error(`Invalid FilterField value: ${value}`); } diff --git a/UI/Web/src/app/metadata-filter/filter-settings.ts b/UI/Web/src/app/metadata-filter/filter-settings.ts index ce641b8c5..452abee71 100644 --- a/UI/Web/src/app/metadata-filter/filter-settings.ts +++ b/UI/Web/src/app/metadata-filter/filter-settings.ts @@ -7,4 +7,5 @@ export class FilterSettings { * The number of statements that can be on the filter. Set to 1 to disable adding more. */ statementLimit: number = 0; + saveDisabled: boolean = false; } diff --git a/UI/Web/src/app/metadata-filter/metadata-filter.component.html b/UI/Web/src/app/metadata-filter/metadata-filter.component.html index 95628e33e..5cbf39384 100644 --- a/UI/Web/src/app/metadata-filter/metadata-filter.component.html +++ b/UI/Web/src/app/metadata-filter/metadata-filter.component.html @@ -48,6 +48,11 @@ +
+ + +
+
@@ -58,12 +63,18 @@ -
+
-
+
+
+ +
diff --git a/UI/Web/src/app/metadata-filter/metadata-filter.component.ts b/UI/Web/src/app/metadata-filter/metadata-filter.component.ts index 713d07877..8ffb1ac38 100644 --- a/UI/Web/src/app/metadata-filter/metadata-filter.component.ts +++ b/UI/Web/src/app/metadata-filter/metadata-filter.component.ts @@ -11,8 +11,7 @@ import { Output } from '@angular/core'; import {FormControl, FormGroup, FormsModule, ReactiveFormsModule} from '@angular/forms'; -import {NgbCollapse, NgbRating, NgbTooltip} from '@ng-bootstrap/ng-bootstrap'; -import {FilterUtilitiesService} from '../shared/_services/filter-utilities.service'; +import {NgbCollapse, NgbModal, NgbRating, NgbTooltip} from '@ng-bootstrap/ng-bootstrap'; import {Breakpoint, UtilityService} from '../shared/_services/utility.service'; import {Library} from '../_models/library'; import {allSortFields, FilterEvent, FilterItem, SortField} from '../_models/metadata/series-filter'; @@ -23,10 +22,14 @@ import {takeUntilDestroyed} from "@angular/core/rxjs-interop"; import {TypeaheadComponent} from '../typeahead/_components/typeahead.component'; import {DrawerComponent} from '../shared/drawer/drawer.component'; import {AsyncPipe, NgForOf, NgIf, NgTemplateOutlet} from '@angular/common'; -import {TranslocoModule} from "@ngneat/transloco"; +import {translate, TranslocoModule} from "@ngneat/transloco"; import {SortFieldPipe} from "../pipe/sort-field.pipe"; import {MetadataBuilderComponent} from "./_components/metadata-builder/metadata-builder.component"; import {allFields} from "../_models/metadata/v2/filter-field"; +import {MetadataService} from "../_services/metadata.service"; +import {FilterUtilitiesService} from "../shared/_services/filter-utilities.service"; +import {FilterService} from "../_services/filter.service"; +import {ToastrService} from "ngx-toastr"; @Component({ selector: 'app-metadata-filter', @@ -81,9 +84,10 @@ export class MetadataFilterComponent implements OnInit { private readonly cdRef = inject(ChangeDetectorRef); + private readonly toastr = inject(ToastrService); - constructor(public toggleService: ToggleService) {} + constructor(public toggleService: ToggleService, private filterService: FilterService) {} ngOnInit(): void { if (this.filterSettings === undefined) { @@ -141,7 +145,8 @@ export class MetadataFilterComponent implements OnInit { this.sortGroup = new FormGroup({ sortField: new FormControl({value: this.filterV2?.sortOptions?.sortField || SortField.SortName, disabled: this.filterSettings.sortDisabled}, []), - limitTo: new FormControl(this.filterV2?.limitTo || 0, []) + limitTo: new FormControl(this.filterV2?.limitTo || 0, []), + name: new FormControl(this.filterV2?.name || '', []) }); this.sortGroup.valueChanges.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(() => { @@ -153,6 +158,7 @@ export class MetadataFilterComponent implements OnInit { } this.filterV2!.sortOptions!.sortField = parseInt(this.sortGroup.get('sortField')?.value, 10); this.filterV2!.limitTo = Math.max(parseInt(this.sortGroup.get('limitTo')?.value || '0', 10), 0); + this.filterV2!.name = this.sortGroup.get('name')?.value || ''; this.cdRef.markForCheck(); }); @@ -190,6 +196,15 @@ export class MetadataFilterComponent implements OnInit { this.cdRef.markForCheck(); } + save() { + if (!this.filterV2) return; + this.filterV2.name = this.sortGroup.get('name')?.value; + this.filterService.saveFilter(this.filterV2).subscribe(() => { + this.toastr.success(translate('toasts.smart-filter-updated')); + this.apply(); + }) + } + toggleSelected() { this.toggleService.toggle(); this.cdRef.markForCheck(); diff --git a/UI/Web/src/app/reading-list/_components/draggable-ordered-list/draggable-ordered-list.component.html b/UI/Web/src/app/reading-list/_components/draggable-ordered-list/draggable-ordered-list.component.html index 0cd40a623..766dc309c 100644 --- a/UI/Web/src/app/reading-list/_components/draggable-ordered-list/draggable-ordered-list.component.html +++ b/UI/Web/src/app/reading-list/_components/draggable-ordered-list/draggable-ordered-list.component.html @@ -41,7 +41,7 @@
diff --git a/UI/Web/src/app/reading-list/_components/draggable-ordered-list/draggable-ordered-list.component.scss b/UI/Web/src/app/reading-list/_components/draggable-ordered-list/draggable-ordered-list.component.scss index 1cab9d886..69aaabed4 100644 --- a/UI/Web/src/app/reading-list/_components/draggable-ordered-list/draggable-ordered-list.component.scss +++ b/UI/Web/src/app/reading-list/_components/draggable-ordered-list/draggable-ordered-list.component.scss @@ -1,28 +1,27 @@ .example-list { - min-width: 500px; + width: 100%; max-width: 100%; min-height: 60px; display: block; border-radius: 4px; overflow: hidden; } - + .example-box { margin: 5px 0; display: flex; flex-direction: row; box-sizing: border-box; font-size: 14px; - max-height: 140px; - height: 140px; .drag-handle { cursor: move; font-size: 24px; + // TODO: This needs to be calculation based margin-top: 215%; } } - + .cdk-drag-preview { box-sizing: border-box; border-radius: 4px; @@ -30,19 +29,20 @@ 0 8px 10px 1px rgba(0, 0, 0, 0.14), 0 3px 14px 2px rgba(0, 0, 0, 0.12); } - + .cdk-drag-placeholder { opacity: 0; } - + .cdk-drag-animating { transition: transform 250ms cubic-bezier(0, 0, 0.2, 1); } - + .example-box:last-child { border: none; + margin-bottom: 20px; } - + .example-list.cdk-drop-list-dragging .example-box:not(.cdk-drag-placeholder) { transition: transform 250ms cubic-bezier(0, 0, 0.2, 1); } @@ -70,4 +70,4 @@ virtual-scroller.empty { display: none; -} \ No newline at end of file +} diff --git a/UI/Web/src/app/reading-list/_components/reading-list-detail/reading-list-detail.component.ts b/UI/Web/src/app/reading-list/_components/reading-list-detail/reading-list-detail.component.ts index 24924139d..3df8049de 100644 --- a/UI/Web/src/app/reading-list/_components/reading-list-detail/reading-list-detail.component.ts +++ b/UI/Web/src/app/reading-list/_components/reading-list-detail/reading-list-detail.component.ts @@ -178,7 +178,7 @@ export class ReadingListDetailComponent implements OnInit { orderUpdated(event: IndexUpdateEvent) { if (!this.readingList) return; - this.readingListService.updatePosition(this.readingList.id, event.item.id, event.fromPosition, event.toPosition).subscribe(() => { /* No Operation */ }); + this.readingListService.updatePosition(this.readingList.id, event.item.id, event.fromPosition, event.toPosition).subscribe(); } itemRemoved(item: ReadingListItem, position: number) { diff --git a/UI/Web/src/app/reading-list/_components/reading-list-item/reading-list-item.component.html b/UI/Web/src/app/reading-list/_components/reading-list-item/reading-list-item.component.html index 7e22b060a..ad3422dc7 100644 --- a/UI/Web/src/app/reading-list/_components/reading-list-item/reading-list-item.component.html +++ b/UI/Web/src/app/reading-list/_components/reading-list-item/reading-list-item.component.html @@ -1,5 +1,5 @@ -
+
diff --git a/UI/Web/src/app/reading-list/_components/reading-list-item/reading-list-item.component.scss b/UI/Web/src/app/reading-list/_components/reading-list-item/reading-list-item.component.scss index f2038b729..6a9691b3d 100644 --- a/UI/Web/src/app/reading-list/_components/reading-list-item/reading-list-item.component.scss +++ b/UI/Web/src/app/reading-list/_components/reading-list-item/reading-list-item.component.scss @@ -1,5 +1,10 @@ $image-height: 125px; +.reading-list-item { + max-height: 140px; + height: 140px; +} + .progress-banner { height: 5px; @@ -9,12 +14,6 @@ $image-height: 125px; } } -.list-item-container { - background: var(--card-list-item-bg-color); - border-radius: 5px; - position: relative; -} - .badge-container { border-radius: 4px; display: block; @@ -34,4 +33,4 @@ $image-height: 125px; border-style: solid; border-width: 0 var(--card-progress-triangle-size) var(--card-progress-triangle-size) 0; border-color: transparent var(--primary-color) transparent transparent; -} \ No newline at end of file +} diff --git a/UI/Web/src/app/shared/_services/filter-utilities.service.ts b/UI/Web/src/app/shared/_services/filter-utilities.service.ts index d16caedf0..406b062f1 100644 --- a/UI/Web/src/app/shared/_services/filter-utilities.service.ts +++ b/UI/Web/src/app/shared/_services/filter-utilities.service.ts @@ -107,6 +107,49 @@ export class FilterUtilitiesService { }).join(',')); } + decodeSeriesFilter(encodedFilter: string) { + const filter = this.metadataService.createDefaultFilterDto(); + + if (encodedFilter.includes('name=')) { + filter.name = decodeURIComponent(encodedFilter).split('name=')[1].split('&')[0]; + } + + const stmtsStartIndex = encodedFilter.indexOf(statementsKey); + let endIndex = encodedFilter.indexOf('&' + sortOptionsKey); + if (endIndex < 0) { + endIndex = encodedFilter.indexOf('&' + limitToKey); + } + + if (stmtsStartIndex !== -1 || endIndex !== -1) { + // +1 is for the = + const stmtsEncoded = encodedFilter.substring(stmtsStartIndex + statementsKey.length, endIndex); + filter.statements = this.decodeFilterStatements(stmtsEncoded); + } + + if (encodedFilter.includes(sortOptionsKey)) { + const optionsStartIndex = encodedFilter.indexOf('&' + sortOptionsKey); + const endIndex = encodedFilter.indexOf('&' + limitToKey); + const sortOptionsEncoded = encodedFilter.substring(optionsStartIndex + sortOptionsKey.length + 1, endIndex); + const sortOptions = this.decodeSortOptions(sortOptionsEncoded); + if (sortOptions) { + filter.sortOptions = sortOptions; + } + } + + if (encodedFilter.includes(limitToKey)) { + const limitTo = decodeURIComponent(encodedFilter).split(limitToKey)[1].split('&')[0]; + filter.limitTo = parseInt(limitTo, 10); + } + + if (encodedFilter.includes(combinationKey)) { + const combo = decodeURIComponent(encodedFilter).split(combinationKey)[1].split('&')[0];; + filter.combination = parseInt(combo, 10) as FilterCombination; + } + + return filter; + } + + filterPresetsFromUrlV2(snapshot: ActivatedRouteSnapshot): SeriesFilterV2 { const filter = this.metadataService.createDefaultFilterDto(); if (!window.location.href.includes('?')) return filter; diff --git a/UI/Web/src/app/shared/update-notification/update-notification-modal.component.scss b/UI/Web/src/app/shared/update-notification/update-notification-modal.component.scss index 0f737bca4..a29ca3998 100644 --- a/UI/Web/src/app/shared/update-notification/update-notification-modal.component.scss +++ b/UI/Web/src/app/shared/update-notification/update-notification-modal.component.scss @@ -2,4 +2,8 @@ width: 100%; word-wrap: break-word; white-space: pre-wrap; -} \ No newline at end of file +} + +img { + max-width: 100%; +} diff --git a/UI/Web/src/app/sidenav/_components/customize-dashboard-modal/customize-dashboard-modal.component.html b/UI/Web/src/app/sidenav/_components/customize-dashboard-modal/customize-dashboard-modal.component.html new file mode 100644 index 000000000..0e4ddbdc1 --- /dev/null +++ b/UI/Web/src/app/sidenav/_components/customize-dashboard-modal/customize-dashboard-modal.component.html @@ -0,0 +1,32 @@ + + + + + + diff --git a/UI/Web/src/app/sidenav/_components/customize-dashboard-modal/customize-dashboard-modal.component.scss b/UI/Web/src/app/sidenav/_components/customize-dashboard-modal/customize-dashboard-modal.component.scss new file mode 100644 index 000000000..6aee8a288 --- /dev/null +++ b/UI/Web/src/app/sidenav/_components/customize-dashboard-modal/customize-dashboard-modal.component.scss @@ -0,0 +1,24 @@ +::ng-deep .drag-handle { + margin-top: 100% !important; +} + +app-stream-list-item { + flex-grow: 1; +} + +.filter-list { + margin: 0; + padding:0; + + .filter { + padding: 0.5rem 1rem; + display: flex; + flex-direction: row; + justify-content: space-between; + align-items: center; + border-radius: 5px; + margin: 5px 0; + color: var(--list-group-hover-text-color); + background-color: var(--list-group-hover-bg-color); + } +} diff --git a/UI/Web/src/app/sidenav/_components/customize-dashboard-modal/customize-dashboard-modal.component.ts b/UI/Web/src/app/sidenav/_components/customize-dashboard-modal/customize-dashboard-modal.component.ts new file mode 100644 index 000000000..cd6850291 --- /dev/null +++ b/UI/Web/src/app/sidenav/_components/customize-dashboard-modal/customize-dashboard-modal.component.ts @@ -0,0 +1,72 @@ +import {ChangeDetectionStrategy, ChangeDetectorRef, Component, inject} from '@angular/core'; +import {CommonModule} from '@angular/common'; +import {SafeHtmlPipe} from "../../../pipe/safe-html.pipe"; +import {TranslocoDirective} from "@ngneat/transloco"; +import {NgbActiveModal} from "@ng-bootstrap/ng-bootstrap"; +import { + DraggableOrderedListComponent, + IndexUpdateEvent +} from "../../../reading-list/_components/draggable-ordered-list/draggable-ordered-list.component"; +import { + ReadingListItemComponent +} from "../../../reading-list/_components/reading-list-item/reading-list-item.component"; +import {forkJoin} from "rxjs"; +import {FilterService} from "../../../_services/filter.service"; +import {StreamListItemComponent} from "../stream-list-item/stream-list-item.component"; +import {SmartFilter} from "../../../_models/metadata/v2/smart-filter"; +import {DashboardService} from "../../../_services/dashboard.service"; +import {DashboardStream} from "../../../_models/dashboard/dashboard-stream"; + +@Component({ + selector: 'app-customize-dashboard-modal', + standalone: true, + imports: [CommonModule, SafeHtmlPipe, TranslocoDirective, DraggableOrderedListComponent, ReadingListItemComponent, StreamListItemComponent], + templateUrl: './customize-dashboard-modal.component.html', + styleUrls: ['./customize-dashboard-modal.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class CustomizeDashboardModalComponent { + + items: DashboardStream[] = []; + smartFilters: SmartFilter[] = []; + accessibilityMode: boolean = false; + + private readonly dashboardService = inject(DashboardService); + private readonly filterService = inject(FilterService); + private readonly cdRef = inject(ChangeDetectorRef); + + constructor(public modal: NgbActiveModal) { + + forkJoin([this.dashboardService.getDashboardStreams(false), this.filterService.getAllFilters()]).subscribe(results => { + this.items = results[0]; + const smartFilterStreams = new Set(results[0].filter(d => !d.isProvided).map(d => d.name)); + this.smartFilters = results[1].filter(d => !smartFilterStreams.has(d.name)); + this.cdRef.markForCheck(); + }); + } + + addFilterToStream(filter: SmartFilter) { + this.dashboardService.createDashboardStream(filter.id).subscribe(stream => { + this.smartFilters = this.smartFilters.filter(d => d.name !== filter.name); + this.items.push(stream); + this.cdRef.detectChanges(); + }); + } + + + orderUpdated(event: IndexUpdateEvent) { + this.dashboardService.updateDashboardStreamPosition(event.item.name, event.item.id, event.fromPosition, event.toPosition).subscribe(); + } + + updateVisibility(item: DashboardStream, position: number) { + this.items[position].visible = !this.items[position].visible; + this.dashboardService.updateDashboardStream(this.items[position]).subscribe(); + this.cdRef.markForCheck(); + } + + close() { + this.modal.close(); + } + + +} diff --git a/UI/Web/src/app/sidenav/_components/side-nav/side-nav.component.html b/UI/Web/src/app/sidenav/_components/side-nav/side-nav.component.html index 70b36bd4f..c0ee353c1 100644 --- a/UI/Web/src/app/sidenav/_components/side-nav/side-nav.component.html +++ b/UI/Web/src/app/sidenav/_components/side-nav/side-nav.component.html @@ -1,18 +1,21 @@
- + + + + + - + + + + + - + diff --git a/UI/Web/src/app/sidenav/_components/side-nav/side-nav.component.ts b/UI/Web/src/app/sidenav/_components/side-nav/side-nav.component.ts index 7deecdcc3..3117b4e73 100644 --- a/UI/Web/src/app/sidenav/_components/side-nav/side-nav.component.ts +++ b/UI/Web/src/app/sidenav/_components/side-nav/side-nav.component.ts @@ -27,11 +27,13 @@ import {FilterPipe} from "../../../pipe/filter.pipe"; import {FormsModule} from "@angular/forms"; import {TranslocoDirective} from "@ngneat/transloco"; import {CardActionablesComponent} from "../../../_single-module/card-actionables/card-actionables.component"; +import {SentenceCasePipe} from "../../../pipe/sentence-case.pipe"; +import {CustomizeDashboardModalComponent} from "../customize-dashboard-modal/customize-dashboard-modal.component"; @Component({ selector: 'app-side-nav', standalone: true, - imports: [CommonModule, SideNavItemComponent, CardActionablesComponent, FilterPipe, FormsModule, TranslocoDirective], + imports: [CommonModule, SideNavItemComponent, CardActionablesComponent, FilterPipe, FormsModule, TranslocoDirective, SentenceCasePipe], templateUrl: './side-nav.component.html', styleUrls: ['./side-nav.component.scss'], changeDetection: ChangeDetectionStrategy.OnPush @@ -43,6 +45,7 @@ export class SideNavComponent implements OnInit { libraries: Library[] = []; actions: ActionItem[] = []; readingListActions = [{action: Action.Import, title: 'import-cbl', children: [], requiresAdmin: true, callback: this.importCbl.bind(this)}]; + homeActions = [{action: Action.Edit, title: 'customize', children: [], requiresAdmin: false, callback: this.handleHomeActions.bind(this)}]; filterQuery: string = ''; filterLibrary = (library: Library) => { return library.name.toLowerCase().indexOf((this.filterQuery || '').toLowerCase()) >= 0; @@ -107,6 +110,12 @@ export class SideNavComponent implements OnInit { } } + handleHomeActions() { + this.ngbModal.open(CustomizeDashboardModalComponent, {size: 'xl'}); + // TODO: If on /, then refresh the page layout + } + + importCbl() { this.ngbModal.open(ImportCblModalComponent, {size: 'xl'}); } diff --git a/UI/Web/src/app/sidenav/_components/stream-list-item/stream-list-item.component.html b/UI/Web/src/app/sidenav/_components/stream-list-item/stream-list-item.component.html new file mode 100644 index 000000000..23f398771 --- /dev/null +++ b/UI/Web/src/app/sidenav/_components/stream-list-item/stream-list-item.component.html @@ -0,0 +1,23 @@ + +
+
+
+ {{item.name}} + + + +
+
+
+ {{t(item.isProvided ? 'provided' : 'smart-filter')}} +
+ +
+
+
+
diff --git a/UI/Web/src/app/sidenav/_components/stream-list-item/stream-list-item.component.scss b/UI/Web/src/app/sidenav/_components/stream-list-item/stream-list-item.component.scss new file mode 100644 index 000000000..59ce723a3 --- /dev/null +++ b/UI/Web/src/app/sidenav/_components/stream-list-item/stream-list-item.component.scss @@ -0,0 +1,8 @@ +.list-item { + height: 60px; + max-height: 60px; +} + +.meta { + display: flex; +} \ No newline at end of file diff --git a/UI/Web/src/app/sidenav/_components/stream-list-item/stream-list-item.component.ts b/UI/Web/src/app/sidenav/_components/stream-list-item/stream-list-item.component.ts new file mode 100644 index 000000000..d81bb0657 --- /dev/null +++ b/UI/Web/src/app/sidenav/_components/stream-list-item/stream-list-item.component.ts @@ -0,0 +1,35 @@ +import { + ChangeDetectionStrategy, + ChangeDetectorRef, + Component, + EventEmitter, + inject, + Input, + Output +} from '@angular/core'; +import {CommonModule} from '@angular/common'; +import {ImageComponent} from "../../../shared/image/image.component"; +import {MangaFormatIconPipe} from "../../../pipe/manga-format-icon.pipe"; +import {MangaFormatPipe} from "../../../pipe/manga-format.pipe"; +import {NgbProgressbar} from "@ng-bootstrap/ng-bootstrap"; +import {TranslocoDirective} from "@ngneat/transloco"; +import {DashboardStream} from "../../../_models/dashboard/dashboard-stream"; + +@Component({ + selector: 'app-stream-list-item', + standalone: true, + imports: [CommonModule, ImageComponent, MangaFormatIconPipe, MangaFormatPipe, NgbProgressbar, TranslocoDirective], + templateUrl: './stream-list-item.component.html', + styleUrls: ['./stream-list-item.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class StreamListItemComponent { + @Input({required: true}) item!: DashboardStream; + @Input({required: true}) position: number = 0; + @Output() hide: EventEmitter = new EventEmitter(); + + + private readonly cdRef = inject(ChangeDetectorRef); + + +} diff --git a/UI/Web/src/app/statistics/_components/server-stats/server-stats.component.ts b/UI/Web/src/app/statistics/_components/server-stats/server-stats.component.ts index e63430cd4..fe5ab3f92 100644 --- a/UI/Web/src/app/statistics/_components/server-stats/server-stats.component.ts +++ b/UI/Web/src/app/statistics/_components/server-stats/server-stats.component.ts @@ -23,7 +23,7 @@ import {TopReadersComponent} from '../top-readers/top-readers.component'; import {StatListComponent} from '../stat-list/stat-list.component'; import {IconAndTitleComponent} from '../../../shared/icon-and-title/icon-and-title.component'; import {AsyncPipe, DecimalPipe, NgIf} from '@angular/common'; -import {TranslocoDirective, TranslocoService} from "@ngneat/transloco"; +import {translate, TranslocoDirective, TranslocoService} from "@ngneat/transloco"; import {FilterComparison} from "../../../_models/metadata/v2/filter-comparison"; import {FilterField} from "../../../_models/metadata/v2/filter-field"; @@ -62,8 +62,6 @@ export class ServerStatsComponent { this.breakpointSubject.next(this.utilityService.getActiveBreakpoint()); } - - translocoService = inject(TranslocoService); get Breakpoint() { return Breakpoint; } constructor(private statService: StatisticsService, private router: Router, private imageService: ImageService, @@ -115,7 +113,7 @@ export class ServerStatsComponent { this.metadataService.getAllGenres().subscribe(genres => { const ref = this.modalService.open(GenericListModalComponent, { scrollable: true }); ref.componentInstance.items = genres.map(t => t.title); - ref.componentInstance.title = this.translocoService.translate('server-stats.genres'); + ref.componentInstance.title = translate('server-stats.genres'); ref.componentInstance.clicked = (item: string) => { this.filterUtilityService.applyFilter(['all-series'], FilterField.Genres, FilterComparison.Contains, genres.filter(g => g.title === item)[0].id + ''); }; @@ -126,7 +124,7 @@ export class ServerStatsComponent { this.metadataService.getAllTags().subscribe(tags => { const ref = this.modalService.open(GenericListModalComponent, { scrollable: true }); ref.componentInstance.items = tags.map(t => t.title); - ref.componentInstance.title = this.translocoService.translate('server-stats.tags'); + ref.componentInstance.title = translate('server-stats.tags'); ref.componentInstance.clicked = (item: string) => { this.filterUtilityService.applyFilter(['all-series'], FilterField.Tags, FilterComparison.Contains, tags.filter(g => g.title === item)[0].id + ''); }; @@ -137,7 +135,7 @@ export class ServerStatsComponent { this.metadataService.getAllPeople().subscribe(people => { const ref = this.modalService.open(GenericListModalComponent, { scrollable: true }); ref.componentInstance.items = [...new Set(people.map(person => person.name))]; - ref.componentInstance.title = this.translocoService.translate('server-stats.people'); + ref.componentInstance.title = translate('server-stats.people'); }); } diff --git a/UI/Web/src/app/statistics/_pipes/day-of-week.pipe.ts b/UI/Web/src/app/statistics/_pipes/day-of-week.pipe.ts index 0d8730dd4..f3387c107 100644 --- a/UI/Web/src/app/statistics/_pipes/day-of-week.pipe.ts +++ b/UI/Web/src/app/statistics/_pipes/day-of-week.pipe.ts @@ -1,6 +1,6 @@ import {inject, Pipe, PipeTransform} from '@angular/core'; import { DayOfWeek } from 'src/app/_services/statistics.service'; -import {TranslocoService} from "@ngneat/transloco"; +import {translate, TranslocoService} from "@ngneat/transloco"; @Pipe({ name: 'dayOfWeek', @@ -8,24 +8,22 @@ import {TranslocoService} from "@ngneat/transloco"; }) export class DayOfWeekPipe implements PipeTransform { - translocoService = inject(TranslocoService); - transform(value: DayOfWeek): string { switch(value) { case DayOfWeek.Monday: - return this.translocoService.translate('day-of-week-pipe.monday'); + return translate('day-of-week-pipe.monday'); case DayOfWeek.Tuesday: - return this.translocoService.translate('day-of-week-pipe.tuesday'); + return translate('day-of-week-pipe.tuesday'); case DayOfWeek.Wednesday: - return this.translocoService.translate('day-of-week-pipe.wednesday'); + return translate('day-of-week-pipe.wednesday'); case DayOfWeek.Thursday: - return this.translocoService.translate('day-of-week-pipe.thursday'); + return translate('day-of-week-pipe.thursday'); case DayOfWeek.Friday: - return this.translocoService.translate('day-of-week-pipe.friday'); + return translate('day-of-week-pipe.friday'); case DayOfWeek.Saturday: - return this.translocoService.translate('day-of-week-pipe.saturday'); + return translate('day-of-week-pipe.saturday'); case DayOfWeek.Sunday: - return this.translocoService.translate('day-of-week-pipe.sunday'); + return translate('day-of-week-pipe.sunday'); } } diff --git a/UI/Web/src/app/user-settings/manage-smart-filters/manage-smart-filters.component.html b/UI/Web/src/app/user-settings/manage-smart-filters/manage-smart-filters.component.html new file mode 100644 index 000000000..647bd5d42 --- /dev/null +++ b/UI/Web/src/app/user-settings/manage-smart-filters/manage-smart-filters.component.html @@ -0,0 +1,11 @@ +
    +
  • + {{f.name}} + +
  • + +
  • No Smart Filters created
  • +
diff --git a/UI/Web/src/app/user-settings/manage-smart-filters/manage-smart-filters.component.scss b/UI/Web/src/app/user-settings/manage-smart-filters/manage-smart-filters.component.scss new file mode 100644 index 000000000..fb01509b4 --- /dev/null +++ b/UI/Web/src/app/user-settings/manage-smart-filters/manage-smart-filters.component.scss @@ -0,0 +1,21 @@ + +ul { + margin:0; + padding: 0; + + li { + padding: 0.5rem 1rem; + display: flex; + flex-direction: row; + justify-content: space-between; + align-items: center; + border-radius: 5px; + margin: 5px 0; + color: var(--list-group-hover-text-color); + background-color: var(--list-group-hover-bg-color); + + span { + cursor: pointer; + } + } +} diff --git a/UI/Web/src/app/user-settings/manage-smart-filters/manage-smart-filters.component.ts b/UI/Web/src/app/user-settings/manage-smart-filters/manage-smart-filters.component.ts new file mode 100644 index 000000000..c8d82280a --- /dev/null +++ b/UI/Web/src/app/user-settings/manage-smart-filters/manage-smart-filters.component.ts @@ -0,0 +1,51 @@ +import {ChangeDetectionStrategy, ChangeDetectorRef, Component, inject} from '@angular/core'; +import {CommonModule} from '@angular/common'; +import {FilterService} from "../../_services/filter.service"; +import {SmartFilter} from "../../_models/metadata/v2/smart-filter"; +import {Router} from "@angular/router"; +import {ConfirmService} from "../../shared/confirm.service"; +import {translate} from "@ngneat/transloco"; +import {ToastrService} from "ngx-toastr"; + +@Component({ + selector: 'app-manage-smart-filters', + standalone: true, + imports: [CommonModule], + templateUrl: './manage-smart-filters.component.html', + styleUrls: ['./manage-smart-filters.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class ManageSmartFiltersComponent { + + private readonly filterService = inject(FilterService); + private readonly confirmService = inject(ConfirmService); + private readonly router = inject(Router); + private readonly cdRef = inject(ChangeDetectorRef); + private readonly toastr = inject(ToastrService); + filters: Array = []; + + constructor() { + this.loadData(); + } + + loadData() { + this.filterService.getAllFilters().subscribe(filters => { + this.filters = filters; + this.cdRef.markForCheck(); + }); + } + + async loadFilter(f: SmartFilter) { + await this.router.navigateByUrl('all-series?' + f.filter); + } + + async deleteFilter(f: SmartFilter) { + if (!await this.confirmService.confirm(translate('toasts.confirm-delete-smart-filter'))) return; + + this.filterService.deleteFilter(f.id).subscribe(() => { + this.toastr.success(translate('toasts.smart-filter-deleted')); + this.loadData(); + }); + } + +} diff --git a/UI/Web/src/app/user-settings/user-preferences/user-preferences.component.html b/UI/Web/src/app/user-settings/user-preferences/user-preferences.component.html index c3dee6f1c..65c399e1e 100644 --- a/UI/Web/src/app/user-settings/user-preferences/user-preferences.component.html +++ b/UI/Web/src/app/user-settings/user-preferences/user-preferences.component.html @@ -428,6 +428,9 @@ + + + diff --git a/UI/Web/src/app/user-settings/user-preferences/user-preferences.component.ts b/UI/Web/src/app/user-settings/user-preferences/user-preferences.component.ts index 357e3431a..6b058fa50 100644 --- a/UI/Web/src/app/user-settings/user-preferences/user-preferences.component.ts +++ b/UI/Web/src/app/user-settings/user-preferences/user-preferences.component.ts @@ -49,6 +49,7 @@ import { SideNavCompanionBarComponent } from '../../sidenav/_components/side-nav import {LocalizationService} from "../../_services/localization.service"; import {Language} from "../../_models/metadata/language"; import {translate, TranslocoDirective, TranslocoService} from "@ngneat/transloco"; +import {ManageSmartFiltersComponent} from "../manage-smart-filters/manage-smart-filters.component"; enum AccordionPanelID { ImageReader = 'image-reader', @@ -63,6 +64,7 @@ enum FragmentID { Theme = 'theme', Devices = 'devices', Stats = 'stats', + SmartFilters = 'smart-filters', Scrobbling = 'scrobbling' } @@ -76,7 +78,8 @@ enum FragmentID { imports: [SideNavCompanionBarComponent, NgbNav, NgFor, NgbNavItem, NgbNavItemRole, NgbNavLink, RouterLink, NgbNavContent, NgIf, ChangeEmailComponent, ChangePasswordComponent, ChangeAgeRestrictionComponent, AnilistKeyComponent, ReactiveFormsModule, NgbAccordionDirective, NgbAccordionItem, NgbAccordionHeader, NgbAccordionToggle, NgbAccordionButton, NgbCollapse, NgbAccordionCollapse, NgbAccordionBody, NgbTooltip, NgTemplateOutlet, ColorPickerModule, ApiKeyComponent, - ThemeManagerComponent, ManageDevicesComponent, UserStatsComponent, UserScrobbleHistoryComponent, UserHoldsComponent, NgbNavOutlet, TitleCasePipe, SentenceCasePipe, TranslocoDirective] + ThemeManagerComponent, ManageDevicesComponent, UserStatsComponent, UserScrobbleHistoryComponent, UserHoldsComponent, NgbNavOutlet, TitleCasePipe, SentenceCasePipe, + TranslocoDirective, ManageSmartFiltersComponent] }) export class UserPreferencesComponent implements OnInit, OnDestroy { @@ -107,6 +110,7 @@ export class UserPreferencesComponent implements OnInit, OnDestroy { {title: '3rd-party-clients-tab', fragment: FragmentID.Clients}, {title: 'theme-tab', fragment: FragmentID.Theme}, {title: 'devices-tab', fragment: FragmentID.Devices}, + {title: 'smart-filters-tab', fragment: FragmentID.SmartFilters}, {title: 'stats-tab', fragment: FragmentID.Stats}, ]; locales: Array = [{title: 'English', isoCode: 'en'}]; @@ -115,7 +119,6 @@ export class UserPreferencesComponent implements OnInit, OnDestroy { opdsUrl: string = ''; makeUrl: (val: string) => string = (val: string) => { return this.opdsUrl; }; private readonly destroyRef = inject(DestroyRef); - private readonly trasnlocoService = inject(TranslocoService); get AccordionPanelID() { return AccordionPanelID; @@ -304,7 +307,7 @@ export class UserPreferencesComponent implements OnInit, OnDestroy { }; this.observableHandles.push(this.accountService.updatePreferences(data).subscribe((updatedPrefs) => { - this.toastr.success(this.trasnlocoService.translate('user-preferences.success-toast')); + this.toastr.success(translate('user-preferences.success-toast')); if (this.user) { this.user.preferences = updatedPrefs; this.cdRef.markForCheck(); diff --git a/UI/Web/src/assets/langs/en.json b/UI/Web/src/assets/langs/en.json index a709b7da7..2cc36e257 100644 --- a/UI/Web/src/assets/langs/en.json +++ b/UI/Web/src/assets/langs/en.json @@ -14,7 +14,8 @@ "not-granted": "You haven't been granted access to any libraries.", "on-deck-title": "On Deck", "recently-updated-title": "Recently Updated Series", - "recently-added-title": "Newly Added Series" + "recently-added-title": "Newly Added Series", + "more-in-genre-title": "More In {{genre}}" }, "edit-user": { @@ -98,6 +99,7 @@ "devices-tab": "Devices", "stats-tab": "Stats", "scrobbling-tab": "Scrobbling", + "smart-filters-tab": "Smart Filters", "success-toast": "User preferences updated", "global-settings-title": "Global Settings", @@ -1324,6 +1326,13 @@ "read": "{{common.read}}" }, + "stream-list-item": { + "remove": "{{common.remove}}", + "load-filter": "Load Filter", + "provided": "Provided", + "smart-filter": "Smart Filter" + }, + "reading-list-detail": { "item-count": "{{common.item-count}}", "page-settings-title": "Page Settings", @@ -1494,10 +1503,12 @@ "metadata-filter": { "filter-title": "Filter", "sort-by-label": "Sort By", + "filter-name-label": "Filter Name", "ascending-alt": "Ascending", "descending-alt": "Descending", "reset": "{{common.reset}}", "apply": "{{common.apply}}", + "save": "{{common.save}}", "limit-label": "Limit To", "format-label": "Format", @@ -1707,6 +1718,12 @@ "remove-rule": "Remove Row" }, + "customize-dashboard-modal": { + "title": "Customize Dashboard", + "close": "{{common.close}}", + "save": "{{common.save}}" + }, + "filter-field-pipe": { "age-rating": "Age Rating", "characters": "Characters", @@ -1733,7 +1750,8 @@ "user-rating": "User Rating", "writers": "Writers", "path": "Path", - "file-path": "File Path" + "file-path": "File Path", + "want-to-read": "Want to Read" }, "filter-comparison-pipe": { @@ -1755,6 +1773,8 @@ "must-contains": "Must Contains" }, + + "toasts": { "regen-cover": "A job has been enqueued to regenerate the cover image", "no-pages": "There are no pages. Kavita was not able to read this archive.", @@ -1831,7 +1851,10 @@ "confirm-library-delete": "Are you sure you want to delete the {{name}} library? You cannot undo this action.", "confirm-library-type-change": "Changing library type will trigger a new scan with different parsing rules and may lead to series being re-created and hence you may loose progress and bookmarks. You should backup before you do this. Are you sure you want to continue?", "confirm-download-size": "The {{entityType}} is {{size}}. Are you sure you want to continue?", - "list-doesnt-exist": "This list doesn't exist" + "list-doesnt-exist": "This list doesn't exist", + "confirm-delete-smart-filter": "Are you sure you want to delete this Smart Filter?", + "smart-filter-deleted": "Smart Filter Deleted", + "smart-filter-updated": "Created/Updated smart filter" }, "actionable": { @@ -1861,8 +1884,8 @@ "read": "Read", "add-rule-group-and": "Add Rule Group (AND)", "add-rule-group-or": "Add Rule Group (OR)", - "remove-rule-group": "Remove Rule Group" - + "remove-rule-group": "Remove Rule Group", + "customize": "Customize" }, "preferences": { diff --git a/openapi.json b/openapi.json index f45e42d13..6fa4cb5da 100644 --- a/openapi.json +++ b/openapi.json @@ -7,7 +7,7 @@ "name": "GPL-3.0", "url": "https://github.com/Kareadita/Kavita/blob/develop/LICENSE" }, - "version": "0.7.8.0" + "version": "0.7.8.2" }, "servers": [ { @@ -875,6 +875,162 @@ } } }, + "/api/Account/dashboard": { + "get": { + "tags": [ + "Account" + ], + "summary": "Returns the layout of the user's dashboard", + "parameters": [ + { + "name": "visibleOnly", + "in": "query", + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "200": { + "description": "Success", + "content": { + "text/plain": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/DashboardStreamDto" + } + } + }, + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/DashboardStreamDto" + } + } + }, + "text/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/DashboardStreamDto" + } + } + } + } + } + } + } + }, + "/api/Account/add-dashboard-stream": { + "post": { + "tags": [ + "Account" + ], + "summary": "Creates a Dashboard Stream from a SmartFilter and adds it to the user's dashboard as visible", + "parameters": [ + { + "name": "smartFilterId", + "in": "query", + "description": "", + "schema": { + "type": "integer", + "format": "int32" + } + } + ], + "responses": { + "200": { + "description": "Success", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/DashboardStreamDto" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/DashboardStreamDto" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/DashboardStreamDto" + } + } + } + } + } + } + }, + "/api/Account/update-dashboard-stream": { + "post": { + "tags": [ + "Account" + ], + "summary": "Updates the visibility of a dashboard stream", + "requestBody": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DashboardStreamDto" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/DashboardStreamDto" + } + }, + "application/*+json": { + "schema": { + "$ref": "#/components/schemas/DashboardStreamDto" + } + } + } + }, + "responses": { + "200": { + "description": "Success" + } + } + } + }, + "/api/Account/update-dashboard-position": { + "post": { + "tags": [ + "Account" + ], + "summary": "Updates the position of a dashboard stream", + "requestBody": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateDashboardStreamPositionDto" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/UpdateDashboardStreamPositionDto" + } + }, + "application/*+json": { + "schema": { + "$ref": "#/components/schemas/UpdateDashboardStreamPositionDto" + } + } + } + }, + "responses": { + "200": { + "description": "Success" + } + } + } + }, "/api/Admin/exists": { "get": { "tags": [ @@ -1931,51 +2087,12 @@ } } }, - "/api/Filter": { - "get": { - "tags": [ - "Filter" - ], - "parameters": [ - { - "name": "name", - "in": "query", - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "Success", - "content": { - "text/plain": { - "schema": { - "$ref": "#/components/schemas/FilterV2Dto" - } - }, - "application/json": { - "schema": { - "$ref": "#/components/schemas/FilterV2Dto" - } - }, - "text/json": { - "schema": { - "$ref": "#/components/schemas/FilterV2Dto" - } - } - } - } - } - } - }, - "/api/Filter/create-temp": { + "/api/Filter/update": { "post": { "tags": [ "Filter" ], - "summary": "Caches the filter in the backend and returns a temp string for retrieving.", - "description": "The cache line lives for only 1 hour", + "summary": "Creates or Updates the filter", "requestBody": { "description": "", "content": { @@ -1996,28 +2113,69 @@ } } }, + "responses": { + "200": { + "description": "Success" + } + } + } + }, + "/api/Filter": { + "get": { + "tags": [ + "Filter" + ], "responses": { "200": { "description": "Success", "content": { "text/plain": { "schema": { - "type": "string" + "type": "array", + "items": { + "$ref": "#/components/schemas/SmartFilterDto" + } } }, "application/json": { "schema": { - "type": "string" + "type": "array", + "items": { + "$ref": "#/components/schemas/SmartFilterDto" + } } }, "text/json": { "schema": { - "type": "string" + "type": "array", + "items": { + "$ref": "#/components/schemas/SmartFilterDto" + } } } } } } + }, + "delete": { + "tags": [ + "Filter" + ], + "parameters": [ + { + "name": "filterId", + "in": "query", + "schema": { + "type": "integer", + "format": "int32" + } + } + ], + "responses": { + "200": { + "description": "Success" + } + } } }, "/api/Health": { @@ -3485,6 +3643,60 @@ } } }, + "/api/Opds/{apiKey}/smart-filter/{filterId}": { + "get": { + "tags": [ + "Opds" + ], + "summary": "Returns the Series matching this smart filter. If FromDashboard, will only return 20 records.", + "parameters": [ + { + "name": "apiKey", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "filterId", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int32" + } + } + ], + "responses": { + "200": { + "description": "Success" + } + } + } + }, + "/api/Opds/{apiKey}/smart-filters": { + "get": { + "tags": [ + "Opds" + ], + "parameters": [ + { + "name": "apiKey", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Success" + } + } + } + }, "/api/Opds/{apiKey}/libraries": { "get": { "tags": [ @@ -11552,6 +11764,22 @@ "description": "A list of Series the user doesn't want scrobbling for", "nullable": true }, + "smartFilters": { + "type": "array", + "items": { + "$ref": "#/components/schemas/AppUserSmartFilter" + }, + "description": "A collection of user Smart Filters for their account", + "nullable": true + }, + "dashboardStreams": { + "type": "array", + "items": { + "$ref": "#/components/schemas/AppUserDashboardStream" + }, + "description": "An ordered list of Streams (pre-configured) or Smart Filters that makes up the User's Dashboard", + "nullable": true + }, "rowVersion": { "type": "integer", "format": "int32", @@ -11612,6 +11840,54 @@ "additionalProperties": false, "description": "Represents a saved page in a Chapter entity for a given user." }, + "AppUserDashboardStream": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "format": "int32" + }, + "name": { + "type": "string", + "nullable": true + }, + "isProvided": { + "type": "boolean", + "description": "Is System Provided" + }, + "order": { + "type": "integer", + "description": "Sort Order on the Dashboard", + "format": "int32" + }, + "streamType": { + "enum": [ + 1, + 2, + 3, + 4, + 5 + ], + "type": "integer", + "description": "For system provided", + "format": "int32" + }, + "visible": { + "type": "boolean" + }, + "smartFilter": { + "$ref": "#/components/schemas/AppUserSmartFilter" + }, + "appUserId": { + "type": "integer", + "format": "int32" + }, + "appUser": { + "$ref": "#/components/schemas/AppUser" + } + }, + "additionalProperties": false + }, "AppUserPreferences": { "type": "object", "properties": { @@ -11930,6 +12206,33 @@ }, "additionalProperties": false }, + "AppUserSmartFilter": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "format": "int32" + }, + "name": { + "type": "string", + "nullable": true + }, + "filter": { + "type": "string", + "description": "This is the Filter url encoded. It is decoded and reconstructed into a API.DTOs.Filtering.v2.FilterV2Dto", + "nullable": true + }, + "appUserId": { + "type": "integer", + "format": "int32" + }, + "appUser": { + "$ref": "#/components/schemas/AppUser" + } + }, + "additionalProperties": false, + "description": "Represents a Saved user Filter" + }, "AppUserTableOfContent": { "type": "object", "properties": { @@ -13184,6 +13487,54 @@ }, "additionalProperties": false }, + "DashboardStreamDto": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "format": "int32" + }, + "name": { + "type": "string", + "nullable": true + }, + "isProvided": { + "type": "boolean", + "description": "Is System Provided" + }, + "order": { + "type": "integer", + "description": "Sort Order on the Dashboard", + "format": "int32" + }, + "smartFilterEncoded": { + "type": "string", + "description": "If Not IsProvided, the appropriate smart filter", + "nullable": true + }, + "smartFilterId": { + "type": "integer", + "format": "int32", + "nullable": true + }, + "streamType": { + "enum": [ + 1, + 2, + 3, + 4, + 5 + ], + "type": "integer", + "description": "For system provided", + "format": "int32" + }, + "visible": { + "type": "boolean" + } + }, + "additionalProperties": false + }, "DateTimePagesReadOnADayCount": { "type": "object", "properties": { @@ -13790,7 +14141,8 @@ 22, 23, 24, - 25 + 25, + 26 ], "type": "integer", "description": "Represents the field which will dictate the value type and the Extension used for filtering", @@ -13806,6 +14158,11 @@ "FilterV2Dto": { "type": "object", "properties": { + "id": { + "type": "integer", + "description": "Not used in the UI.", + "format": "int32" + }, "name": { "type": "string", "description": "The name of the filter", @@ -17056,6 +17413,25 @@ "additionalProperties": false, "description": "Represents a set of css overrides the user can upload to Kavita and will load into webui" }, + "SmartFilterDto": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "format": "int32" + }, + "name": { + "type": "string", + "nullable": true + }, + "filter": { + "type": "string", + "description": "This is the Filter url encoded. It is decoded and reconstructed into a API.DTOs.Filtering.v2.FilterV2Dto", + "nullable": true + } + }, + "additionalProperties": false + }, "SortOptions": { "type": "object", "properties": { @@ -17066,7 +17442,8 @@ 3, 4, 5, - 6 + 6, + 7 ], "type": "integer", "format": "int32" @@ -17212,6 +17589,28 @@ }, "additionalProperties": false }, + "UpdateDashboardStreamPositionDto": { + "type": "object", + "properties": { + "fromPosition": { + "type": "integer", + "format": "int32" + }, + "toPosition": { + "type": "integer", + "format": "int32" + }, + "dashboardStreamId": { + "type": "integer", + "format": "int32" + }, + "streamName": { + "type": "string", + "nullable": true + } + }, + "additionalProperties": false + }, "UpdateDefaultThemeDto": { "type": "object", "properties": {