Smart Filters & Dashboard Customization (#2282)

Co-authored-by: Robbie Davis <robbie@therobbiedavis.com>
This commit is contained in:
Joe Milazzo 2023-09-12 11:24:47 -07:00 committed by GitHub
parent 3d501c9532
commit 84f85b4f24
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
92 changed files with 7149 additions and 555 deletions

View file

@ -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;
}
}
}

View file

@ -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();
}
}

View file

@ -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);

View file

@ -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")]

View file

@ -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;