Smart Filters & Dashboard Customization (#2282)
Co-authored-by: Robbie Davis <robbie@therobbiedavis.com>
This commit is contained in:
parent
3d501c9532
commit
84f85b4f24
92 changed files with 7149 additions and 555 deletions
|
|
@ -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);
|
||||
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the layout of the user's dashboard
|
||||
/// </summary>
|
||||
/// <returns></returns>
|
||||
[HttpGet("dashboard")]
|
||||
public async Task<ActionResult<IEnumerable<DashboardStreamDto>>> GetDashboardLayout(bool visibleOnly = true)
|
||||
{
|
||||
var streams = await _unitOfWork.UserRepository.GetDashboardStreams(User.GetUserId(), visibleOnly);
|
||||
return Ok(streams);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a Dashboard Stream from a SmartFilter and adds it to the user's dashboard as visible
|
||||
/// </summary>
|
||||
/// <param name="smartFilterId"></param>
|
||||
/// <returns></returns>
|
||||
[HttpPost("add-dashboard-stream")]
|
||||
public async Task<ActionResult<DashboardStreamDto>> 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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updates the visibility of a dashboard stream
|
||||
/// </summary>
|
||||
/// <param name="dto"></param>
|
||||
/// <returns></returns>
|
||||
[HttpPost("update-dashboard-stream")]
|
||||
public async Task<ActionResult> 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();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updates the position of a dashboard stream
|
||||
/// </summary>
|
||||
/// <param name="dto"></param>
|
||||
/// <returns></returns>
|
||||
[HttpPost("update-dashboard-position")]
|
||||
public async Task<ActionResult> 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<AppUserDashboardStream> 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<ActionResult<FilterV2Dto?>> GetFilter(string name)
|
||||
/// <summary>
|
||||
/// Creates or Updates the filter
|
||||
/// </summary>
|
||||
/// <param name="dto"></param>
|
||||
/// <returns></returns>
|
||||
[HttpPost("update")]
|
||||
public async Task<ActionResult> CreateOrUpdateSmartFilter(FilterV2Dto dto)
|
||||
{
|
||||
var provider = _cacheFactory.GetCachingProvider(EasyCacheProfiles.Filter);
|
||||
if (string.IsNullOrEmpty(name)) return Ok(null);
|
||||
var filter = await provider.GetAsync<FilterV2Dto>(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();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Caches the filter in the backend and returns a temp string for retrieving.
|
||||
/// </summary>
|
||||
/// <remarks>The cache line lives for only 1 hour</remarks>
|
||||
/// <param name="filterDto"></param>
|
||||
/// <returns></returns>
|
||||
[HttpPost("create-temp")]
|
||||
public async Task<ActionResult<string>> CreateTempFilter(FilterV2Dto filterDto)
|
||||
[HttpGet]
|
||||
public ActionResult<IEnumerable<SmartFilterDto>> 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<ActionResult> 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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<IEnumerable<string>> 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);
|
||||
|
|
|
|||
|
|
@ -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<FeedLink>()
|
||||
{
|
||||
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<FeedLink>()
|
||||
{
|
||||
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<FeedLink>()
|
||||
{
|
||||
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<FeedLink>()
|
||||
{
|
||||
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<FeedLink>()
|
||||
{
|
||||
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<FeedLink>()
|
||||
{
|
||||
CreateLink(FeedLinkRelation.SubSection, FeedLinkType.AtomNavigation, $"{prefix}{apiKey}/smart-filters"),
|
||||
}
|
||||
});
|
||||
return CreateXmlResult(SerializeXml(feed));
|
||||
}
|
||||
|
||||
|
|
@ -196,6 +245,67 @@ public class OpdsController : BaseApiController
|
|||
return new Tuple<string, string>(baseUrl, prefix);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the Series matching this smart filter. If FromDashboard, will only return 20 records.
|
||||
/// </summary>
|
||||
/// <returns></returns>
|
||||
[HttpGet("{apiKey}/smart-filter/{filterId}")]
|
||||
[Produces("application/xml")]
|
||||
public async Task<IActionResult> 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<IActionResult> 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<FeedLink>()
|
||||
{
|
||||
CreateLink(FeedLinkRelation.SubSection, FeedLinkType.AtomNavigation, $"{prefix}{apiKey}/smart-filter/{filter.Id}")
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return CreateXmlResult(SerializeXml(feed));
|
||||
}
|
||||
|
||||
|
||||
[HttpGet("{apiKey}/libraries")]
|
||||
[Produces("application/xml")]
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
30
API/DTOs/Dashboard/DashboardStreamDto.cs
Normal file
30
API/DTOs/Dashboard/DashboardStreamDto.cs
Normal file
|
|
@ -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; }
|
||||
/// <summary>
|
||||
/// Is System Provided
|
||||
/// </summary>
|
||||
public bool IsProvided { get; set; }
|
||||
/// <summary>
|
||||
/// Sort Order on the Dashboard
|
||||
/// </summary>
|
||||
public int Order { get; set; }
|
||||
/// <summary>
|
||||
/// If Not IsProvided, the appropriate smart filter
|
||||
/// </summary>
|
||||
/// <remarks>Encoded filter</remarks>
|
||||
public string? SmartFilterEncoded { get; set; }
|
||||
public int? SmartFilterId { get; set; }
|
||||
/// <summary>
|
||||
/// For system provided
|
||||
/// </summary>
|
||||
public DashboardStreamType StreamType { get; set; }
|
||||
public bool Visible { get; set; }
|
||||
}
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
using System;
|
||||
using API.Entities.Enums;
|
||||
|
||||
namespace API.DTOs;
|
||||
namespace API.DTOs.Dashboard;
|
||||
/// <summary>
|
||||
/// This is a representation of a Series with some amount of underlying files within it. This is used for Recently Updated Series section
|
||||
/// </summary>
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
using System;
|
||||
using API.Entities.Enums;
|
||||
|
||||
namespace API.DTOs;
|
||||
namespace API.DTOs.Dashboard;
|
||||
|
||||
/// <summary>
|
||||
/// A mesh of data for Recently added volume/chapters
|
||||
13
API/DTOs/Dashboard/SmartFilterDto.cs
Normal file
13
API/DTOs/Dashboard/SmartFilterDto.cs
Normal file
|
|
@ -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; }
|
||||
/// <summary>
|
||||
/// This is the Filter url encoded. It is decoded and reconstructed into a <see cref="FilterV2Dto"/>
|
||||
/// </summary>
|
||||
public required string Filter { get; set; }
|
||||
}
|
||||
9
API/DTOs/Dashboard/UpdateDashboardStreamPositionDto.cs
Normal file
9
API/DTOs/Dashboard/UpdateDashboardStreamPositionDto.cs
Normal file
|
|
@ -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; }
|
||||
}
|
||||
|
|
@ -25,5 +25,9 @@ public enum SortField
|
|||
/// <summary>
|
||||
/// Release Year of the Series
|
||||
/// </summary>
|
||||
ReleaseYear = 6
|
||||
ReleaseYear = 6,
|
||||
/// <summary>
|
||||
/// Last time the user had any reading progress
|
||||
/// </summary>
|
||||
ReadProgress = 7,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -36,5 +36,10 @@ public enum FilterField
|
|||
/// <summary>
|
||||
/// File path
|
||||
/// </summary>
|
||||
FilePath = 25
|
||||
FilePath = 25,
|
||||
/// <summary>
|
||||
/// On Want To Read or Not
|
||||
/// </summary>
|
||||
WantToRead = 26
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,6 +10,10 @@ namespace API.DTOs.Filtering.v2;
|
|||
/// </summary>
|
||||
public class FilterV2Dto
|
||||
{
|
||||
/// <summary>
|
||||
/// Not used in the UI.
|
||||
/// </summary>
|
||||
public int Id { get; set; }
|
||||
/// <summary>
|
||||
/// The name of the filter
|
||||
/// </summary>
|
||||
|
|
|
|||
|
|
@ -54,6 +54,8 @@ public sealed class DataContext : IdentityDbContext<AppUser, AppRole, int,
|
|||
public DbSet<ScrobbleHold> ScrobbleHold { get; set; } = null!;
|
||||
public DbSet<AppUserOnDeckRemoval> AppUserOnDeckRemoval { get; set; } = null!;
|
||||
public DbSet<AppUserTableOfContent> AppUserTableOfContent { get; set; } = null!;
|
||||
public DbSet<AppUserSmartFilter> AppUserSmartFilter { get; set; } = null!;
|
||||
public DbSet<AppUserDashboardStream> AppUserDashboardStream { get; set; } = null!;
|
||||
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder builder)
|
||||
|
|
@ -119,6 +121,13 @@ public sealed class DataContext : IdentityDbContext<AppUser, AppRole, int,
|
|||
builder.Entity<Chapter>()
|
||||
.Property(b => b.ISBN)
|
||||
.HasDefaultValue(string.Empty);
|
||||
|
||||
builder.Entity<AppUserDashboardStream>()
|
||||
.Property(b => b.StreamType)
|
||||
.HasDefaultValue(DashboardStreamType.SmartFilter);
|
||||
builder.Entity<AppUserDashboardStream>()
|
||||
.HasIndex(e => e.Visible)
|
||||
.IsUnique(false);
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
2310
API/Data/Migrations/20230904184205_SmartFilters.Designer.cs
generated
Normal file
2310
API/Data/Migrations/20230904184205_SmartFilters.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load diff
47
API/Data/Migrations/20230904184205_SmartFilters.cs
Normal file
47
API/Data/Migrations/20230904184205_SmartFilters.cs
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace API.Data.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class SmartFilters : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "AppUserSmartFilter",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "INTEGER", nullable: false)
|
||||
.Annotation("Sqlite:Autoincrement", true),
|
||||
Name = table.Column<string>(type: "TEXT", nullable: true),
|
||||
Filter = table.Column<string>(type: "TEXT", nullable: true),
|
||||
AppUserId = table.Column<int>(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");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "AppUserSmartFilter");
|
||||
}
|
||||
}
|
||||
}
|
||||
2369
API/Data/Migrations/20230908190713_DashboardStream.Designer.cs
generated
Normal file
2369
API/Data/Migrations/20230908190713_DashboardStream.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load diff
66
API/Data/Migrations/20230908190713_DashboardStream.cs
Normal file
66
API/Data/Migrations/20230908190713_DashboardStream.cs
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace API.Data.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class DashboardStream : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "AppUserDashboardStream",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "INTEGER", nullable: false)
|
||||
.Annotation("Sqlite:Autoincrement", true),
|
||||
Name = table.Column<string>(type: "TEXT", nullable: true),
|
||||
IsProvided = table.Column<bool>(type: "INTEGER", nullable: false),
|
||||
Order = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
StreamType = table.Column<int>(type: "INTEGER", nullable: false, defaultValue: 4),
|
||||
Visible = table.Column<bool>(type: "INTEGER", nullable: false),
|
||||
SmartFilterId = table.Column<int>(type: "INTEGER", nullable: true),
|
||||
AppUserId = table.Column<int>(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");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "AppUserDashboardStream");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("AppUserId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("IsProvided")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("Order")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int?>("SmartFilterId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("StreamType")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(4);
|
||||
|
||||
b.Property<bool>("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<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("AppUserId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Filter")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("AppUserId");
|
||||
|
||||
b.ToTable("AppUserSmartFilter");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("API.Entities.AppUserTableOfContent", b =>
|
||||
{
|
||||
b.Property<int>("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<int>("SeriesId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<float?>("VolumeNumber")
|
||||
.HasColumnType("REAL");
|
||||
b.Property<int?>("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<string>("Name")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<float>("Number")
|
||||
.HasColumnType("REAL");
|
||||
b.Property<int>("Number")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("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<int>", 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");
|
||||
|
|
|
|||
60
API/Data/Repositories/AppUserSmartFilterRepository.cs
Normal file
60
API/Data/Repositories/AppUserSmartFilterRepository.cs
Normal file
|
|
@ -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<SmartFilterDto> GetAllDtosByUserId(int userId);
|
||||
Task<AppUserSmartFilter?> 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<SmartFilterDto> GetAllDtosByUserId(int userId)
|
||||
{
|
||||
return _context.AppUserSmartFilter
|
||||
.Where(f => f.AppUserId == userId)
|
||||
.ProjectTo<SmartFilterDto>(_mapper.ConfigurationProvider)
|
||||
.AsEnumerable();
|
||||
}
|
||||
|
||||
public async Task<AppUserSmartFilter?> GetById(int smartFilterId)
|
||||
{
|
||||
return await _context.AppUserSmartFilter.FirstOrDefaultAsync(d => d.Id == smartFilterId);
|
||||
}
|
||||
}
|
||||
|
|
@ -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<Series> ApplyWantToReadFilter(FilterV2Dto filter, IQueryable<Series> 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<Series> ApplyLibraryFilter(FilterV2Dto filter, IQueryable<Series> query)
|
||||
{
|
||||
var filterIncludeLibs = new List<int>();
|
||||
|
|
@ -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<MangaFormat>) value),
|
||||
FilterField.ReleaseYear => query.HasReleaseYear(true, statement.Comparison, (int) value),
|
||||
|
|
|
|||
|
|
@ -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<AppUserDashboardStream> streams);
|
||||
Task<IEnumerable<MemberDto>> GetEmailConfirmedMemberDtosAsync(bool emailConfirmed = true);
|
||||
Task<IEnumerable<AppUser>> GetAdminUsersAsync();
|
||||
Task<bool> IsUserAdminAsync(AppUser? user);
|
||||
|
|
@ -76,6 +80,9 @@ public interface IUserRepository
|
|||
Task<bool> HasHoldOnSeries(int userId, int seriesId);
|
||||
Task<IList<ScrobbleHoldDto>> GetHolds(int userId);
|
||||
Task<string> GetLocale(int userId);
|
||||
Task<IList<DashboardStreamDto>> GetDashboardStreams(int userId, bool visibleOnly = false);
|
||||
Task<AppUserDashboardStream?> GetDashboardStream(int streamId);
|
||||
Task<IList<AppUserDashboardStream>> 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<AppUserDashboardStream> streams)
|
||||
{
|
||||
_context.AppUserDashboardStream.RemoveRange(streams);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A one stop shop to get a tracked AppUser instance with any number of JOINs generated by passing bitwise flags.
|
||||
/// </summary>
|
||||
|
|
@ -300,6 +317,42 @@ public class UserRepository : IUserRepository
|
|||
.SingleAsync();
|
||||
}
|
||||
|
||||
public async Task<IList<DashboardStreamDto>> 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<AppUserDashboardStream?> GetDashboardStream(int streamId)
|
||||
{
|
||||
return await _context.AppUserDashboardStream
|
||||
.Include(d => d.SmartFilter)
|
||||
.FirstOrDefaultAsync(d => d.Id == streamId);
|
||||
}
|
||||
|
||||
public async Task<IList<AppUserDashboardStream>> GetDashboardStreamWithFilter(int filterId)
|
||||
{
|
||||
return await _context.AppUserDashboardStream
|
||||
.Include(d => d.SmartFilter)
|
||||
.Where(d => d.SmartFilter != null && d.SmartFilter.Id == filterId)
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<AppUser>> GetAdminUsersAsync()
|
||||
{
|
||||
return await _userManager.GetUsersInRoleAsync(PolicyConstants.AdminRole);
|
||||
|
|
|
|||
|
|
@ -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<AppUserDashboardStream> DefaultStreams = ImmutableArray.Create(
|
||||
new List<AppUserDashboardStream>
|
||||
{
|
||||
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<AppRole> 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<AppUserDashboardStream>();
|
||||
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();
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ public interface IUnitOfWork
|
|||
IMediaErrorRepository MediaErrorRepository { get; }
|
||||
IScrobbleRepository ScrobbleRepository { get; }
|
||||
IUserTableOfContentRepository UserTableOfContentRepository { get; }
|
||||
IAppUserSmartFilterRepository AppUserSmartFilterRepository { get; }
|
||||
bool Commit();
|
||||
Task<bool> 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);
|
||||
|
||||
/// <summary>
|
||||
/// Commits changes to the DB. Completes the open transaction.
|
||||
|
|
|
|||
|
|
@ -67,6 +67,15 @@ public class AppUser : IdentityUser<int>, IHasConcurrencyToken
|
|||
/// A list of Series the user doesn't want scrobbling for
|
||||
/// </summary>
|
||||
public ICollection<ScrobbleHold> ScrobbleHolds { get; set; } = null!;
|
||||
/// <summary>
|
||||
/// A collection of user Smart Filters for their account
|
||||
/// </summary>
|
||||
public ICollection<AppUserSmartFilter> SmartFilters { get; set; } = null!;
|
||||
|
||||
/// <summary>
|
||||
/// An ordered list of Streams (pre-configured) or Smart Filters that makes up the User's Dashboard
|
||||
/// </summary>
|
||||
public IList<AppUserDashboardStream> DashboardStreams { get; set; } = null!;
|
||||
|
||||
|
||||
/// <inheritdoc />
|
||||
|
|
|
|||
29
API/Entities/AppUserDashboardStream.cs
Normal file
29
API/Entities/AppUserDashboardStream.cs
Normal file
|
|
@ -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; }
|
||||
/// <summary>
|
||||
/// Is System Provided
|
||||
/// </summary>
|
||||
public bool IsProvided { get; set; }
|
||||
/// <summary>
|
||||
/// Sort Order on the Dashboard
|
||||
/// </summary>
|
||||
public int Order { get; set; }
|
||||
/// <summary>
|
||||
/// For system provided
|
||||
/// </summary>
|
||||
public DashboardStreamType StreamType { get; set; }
|
||||
public bool Visible { get; set; }
|
||||
/// <summary>
|
||||
/// If Not IsProvided, the appropriate smart filter
|
||||
/// </summary>
|
||||
public AppUserSmartFilter? SmartFilter { get; set; }
|
||||
public int AppUserId { get; set; }
|
||||
public AppUser AppUser { get; set; }
|
||||
}
|
||||
19
API/Entities/AppUserSmartFilter.cs
Normal file
19
API/Entities/AppUserSmartFilter.cs
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
using API.DTOs.Filtering.v2;
|
||||
|
||||
namespace API.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a Saved user Filter
|
||||
/// </summary>
|
||||
public class AppUserSmartFilter
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public required string Name { get; set; }
|
||||
/// <summary>
|
||||
/// This is the Filter url encoded. It is decoded and reconstructed into a <see cref="FilterV2Dto"/>
|
||||
/// </summary>
|
||||
public required string Filter { get; set; }
|
||||
|
||||
public int AppUserId { get; set; }
|
||||
public AppUser AppUser { get; set; }
|
||||
}
|
||||
14
API/Entities/Enums/DashboardStreamType.cs
Normal file
14
API/Entities/Enums/DashboardStreamType.cs
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
namespace API.Entities.Enums;
|
||||
|
||||
public enum DashboardStreamType
|
||||
{
|
||||
OnDeck = 1,
|
||||
RecentlyUpdated = 2,
|
||||
NewlyAdded = 3,
|
||||
SmartFilter = 4,
|
||||
/// <summary>
|
||||
/// More In Genre
|
||||
/// </summary>
|
||||
MoreInGenre = 5
|
||||
|
||||
}
|
||||
|
|
@ -14,7 +14,7 @@ namespace API.Extensions.QueryExtensions.Filtering;
|
|||
|
||||
public static class SeriesFilter
|
||||
{
|
||||
|
||||
private const float FloatingPointTolerance = 0.01f;
|
||||
public static IQueryable<Series> HasLanguage(this IQueryable<Series> queryable, bool condition,
|
||||
FilterComparison comparison, IList<string> 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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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<Device, DeviceDto>();
|
||||
CreateMap<AppUserTableOfContent, PersonalToCDto>();
|
||||
|
||||
|
||||
CreateMap<AppUserSmartFilter, SmartFilterDto>();
|
||||
CreateMap<AppUserDashboardStream, DashboardStreamDto>();
|
||||
// CreateMap<AppUserDashboardStream, DashboardStreamDto>()
|
||||
// .ForMember(dest => dest.SmartFilterEncoded,
|
||||
// opt => opt.MapFrom(src => src.SmartFilter));
|
||||
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -28,8 +28,13 @@ public class AppUserBuilder : IEntityBuilder<AppUser>
|
|||
Ratings = new List<AppUserRating>(),
|
||||
Progresses = new List<AppUserProgress>(),
|
||||
Devices = new List<Device>(),
|
||||
Id = 0
|
||||
Id = 0,
|
||||
DashboardStreams = new List<AppUserDashboardStream>()
|
||||
};
|
||||
foreach (var s in Seed.DefaultStreams)
|
||||
{
|
||||
_appUser.DashboardStreams.Add(s);
|
||||
}
|
||||
}
|
||||
|
||||
public AppUserBuilder WithLibrary(Library library)
|
||||
|
|
|
|||
24
API/Helpers/Builders/SmartFilterBuilder.cs
Normal file
24
API/Helpers/Builders/SmartFilterBuilder.cs
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
using API.DTOs.Filtering.v2;
|
||||
using API.Entities;
|
||||
|
||||
namespace API.Helpers.Builders;
|
||||
|
||||
public class SmartFilterBuilder : IEntityBuilder<AppUserSmartFilter>
|
||||
{
|
||||
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)
|
||||
// {
|
||||
//
|
||||
// }
|
||||
}
|
||||
|
|
@ -67,6 +67,7 @@ public static class FilterFieldValueConverter
|
|||
FilterField.Libraries => (value.Split(',')
|
||||
.Select(int.Parse)
|
||||
.ToList(), typeof(IList<int>)),
|
||||
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))
|
||||
|
|
|
|||
140
API/Helpers/SmartFilterHelper.cs
Normal file
140
API/Helpers/SmartFilterHelper.cs
Normal file
|
|
@ -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<FilterCombination>(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<FilterStatementDto> 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<FilterStatementDto> DecodeFilterStatementDtos(string encodedStatements)
|
||||
{
|
||||
encodedStatements = HttpUtility.UrlDecode(encodedStatements);
|
||||
string[] statementStrings = encodedStatements.Split(',');
|
||||
|
||||
var statements = new List<FilterStatementDto>();
|
||||
|
||||
foreach (var statementString in statementStrings)
|
||||
{
|
||||
var parts = statementString.Split('&');
|
||||
if (parts.Length < 3)
|
||||
continue;
|
||||
|
||||
statements.Add(new FilterStatementDto
|
||||
{
|
||||
Comparison = Enum.Parse<FilterComparison>(parts[0].Split("=")[1]),
|
||||
Field = Enum.Parse<FilterField>(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<SortField>(sortFieldPart.Split("=")[1]);
|
||||
|
||||
return new SortOptions
|
||||
{
|
||||
SortField = sortField,
|
||||
IsAscending = isAscending
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -90,6 +90,7 @@ public class Program
|
|||
await Seed.SeedRoles(services.GetRequiredService<RoleManager<AppRole>>());
|
||||
await Seed.SeedSettings(context, directoryService);
|
||||
await Seed.SeedThemes(context);
|
||||
await Seed.SeedDefaultStreams(services.GetRequiredService<IUnitOfWork>());
|
||||
await Seed.SeedUserApiKeys(context);
|
||||
}
|
||||
catch (Exception ex)
|
||||
|
|
|
|||
|
|
@ -122,6 +122,25 @@ public static class MessageFactory
|
|||
/// A Scrobbling Key has expired and needs rotation
|
||||
/// </summary>
|
||||
public const string ScrobblingKeyExpired = "ScrobblingKeyExpired";
|
||||
/// <summary>
|
||||
/// Order, Visibility, etc has changed on the Dashboard. UI will refresh the layout
|
||||
/// </summary>
|
||||
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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue