using System; using System.Collections.Generic; using System.ComponentModel.DataAnnotations; using System.Linq; using System.Threading.Tasks; using Kavita.API.Database; using Kavita.API.Repositories; using Kavita.API.Services; using Kavita.Common; using Kavita.Models; using Kavita.Models.Constants; using Kavita.Models.DTOs.Dashboard; using Kavita.Models.DTOs.Filtering.v2; using Kavita.Models.DTOs.Filtering.v2.Requests; using Kavita.Models.Entities.User; using Kavita.Server.Attributes; using Kavita.Services.Helpers.SmartFilter; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Logging; namespace Kavita.Server.Controllers; public class FilterController( IUnitOfWork unitOfWork, ILocalizationService localizationService, IStreamService streamService, ILogger logger) : BaseApiController { /// /// Creates or Updates the Series filter /// /// /// [HttpPost("update/series")] [DisallowRole(PolicyConstants.ReadOnlyRole)] public async Task CreateOrUpdateSeriesSmartFilter(SeriesFilterV2Dto dto) { try { if (string.IsNullOrEmpty(dto.Name)) return BadRequest("Name is required"); var encodedString = SmartFilterHelper.Encode(dto); await ValidateAndSaveFilterUpsert(dto.Name!, encodedString, dto.EntityType); return Ok(); } catch (KavitaException ex) { return BadRequest(ex.Message); } } /// /// Creates or Updates the Reading List filter /// /// /// [HttpPost("update/reading-list")] [DisallowRole(PolicyConstants.ReadOnlyRole)] public async Task CreateOrUpdateReadingListSmartFilter(ReadingListFilterDto dto) { try { if (string.IsNullOrEmpty(dto.Name)) return BadRequest("Name is required"); var encodedString = SmartFilterHelper.Encode(dto); await ValidateAndSaveFilterUpsert(dto.Name!, encodedString, dto.EntityType); return Ok(); } catch (KavitaException ex) { return BadRequest(ex.Message); } } /// /// Creates or Updates the Person filter /// /// /// [HttpPost("update/person")] [DisallowRole(PolicyConstants.ReadOnlyRole)] public async Task CreateOrUpdatePersonSmartFilter(PersonFilterDto dto) { try { if (string.IsNullOrEmpty(dto.Name)) return BadRequest("Name is required"); var encodedString = SmartFilterHelper.Encode(dto); await ValidateAndSaveFilterUpsert(dto.Name!, encodedString, dto.EntityType); return Ok(); } catch (KavitaException ex) { return BadRequest(ex.Message); } } /// /// Creates or Updates the Reading List filter /// /// /// [HttpPost("update/annotation")] [DisallowRole(PolicyConstants.ReadOnlyRole)] public async Task CreateOrUpdateAnnotationSmartFilter(AnnotationFilterDto dto) { try { if (string.IsNullOrEmpty(dto.Name)) return BadRequest("Name is required"); var encodedString = SmartFilterHelper.Encode(dto); await ValidateAndSaveFilterUpsert(dto.Name!, encodedString, dto.EntityType); return Ok(); } catch (KavitaException ex) { return BadRequest(ex.Message); } } private async Task ValidateAndSaveFilterUpsert(string filterName, string encodedFilter, FilterEntityType entityType) { var user = (await unitOfWork.UserRepository.GetUserByIdAsync(UserId, AppUserIncludes.SmartFilters))!; if (string.IsNullOrWhiteSpace(filterName)) throw new KavitaException("Name must be set"); if (Defaults.DefaultStreams.Any(s => s.Name.Equals(filterName, StringComparison.InvariantCultureIgnoreCase))) { // NOTE: This checks against localization keys (on-deck), so this case will almost never happen throw new KavitaException("You cannot use the name of a system provided stream"); } var existingFilter = user.SmartFilters.FirstOrDefault(s => s.Name.Equals(filterName, StringComparison.InvariantCultureIgnoreCase)); if (existingFilter != null) { // Update the filter existingFilter.Filter = encodedFilter; unitOfWork.AppUserSmartFilterRepository.Update(existingFilter); } else { existingFilter = new AppUserSmartFilter() { Name = filterName, Filter = encodedFilter, EntityType = entityType }; user.SmartFilters.Add(existingFilter); unitOfWork.UserRepository.Update(user); } if (!unitOfWork.HasChanges()) return; await unitOfWork.CommitAsync(); } /// /// All Smart Filters for the authenticated user /// /// [HttpGet] public async Task>> GetFilters() { return Ok(await unitOfWork.AppUserSmartFilterRepository.GetAllDtosByUserId(UserId)); } /// /// Delete the smart filter for the authenticated user /// /// User must not be in /// /// [HttpDelete] [DisallowRole(PolicyConstants.ReadOnlyRole)] 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); var streams2 = await unitOfWork.UserRepository.GetSideNavStreamWithFilter(filter.Id); unitOfWork.UserRepository.Delete(streams2); unitOfWork.AppUserSmartFilterRepository.Delete(filter); await unitOfWork.CommitAsync(); return Ok(); } /// /// Encode a Series filter /// /// This must be entityType Series /// [HttpPost("encode/series")] public ActionResult EncodeSeriesFilter(SeriesFilterV2Dto dto) { return Ok(SmartFilterHelper.Encode(dto)); } /// /// Encode a Reading List filter /// /// This must be entityType ReadingList /// [HttpPost("encode/reading-list")] public ActionResult EncodeRlFilter(ReadingListFilterDto dto) { return Ok(SmartFilterHelper.Encode(dto)); } /// /// Encode a Person Filter /// /// This must be entityType Person /// [HttpPost("encode/person")] public ActionResult EncodePersonFilter(PersonFilterDto dto) { return Ok(SmartFilterHelper.Encode(dto)); } /// /// Encode an Annotation Filter /// /// This must be entityType Annotation /// [HttpPost("encode/annotation")] public ActionResult EncodeAnnotationFilter(AnnotationFilterDto dto) { return Ok(SmartFilterHelper.Encode(dto)); } /// /// Decodes the Filter /// /// Decoded filter will always have the same shape of . /// The concrete class is driven by EntityType. /// Classes: , , , /// /// /// [HttpPost("decode")] public ActionResult DecodeFilter(DecodeFilterDto dto) { return Ok(SmartFilterHelper.Decode(dto.EncodedFilter)); } /// /// Rename a Smart Filter given the filterId and new name /// /// /// /// [HttpPost("rename")] [DisallowRole(PolicyConstants.ReadOnlyRole)] public async Task RenameFilter([FromQuery] int filterId, [FromQuery] [Required] string name) { try { var user = await unitOfWork.UserRepository.GetUserByIdAsync(UserId, AppUserIncludes.SmartFilters); if (user == null) return Unauthorized(); name = name.Trim(); if (string.IsNullOrWhiteSpace(name)) { return BadRequest(await localizationService.TranslateAsync(user.Id, "smart-filter-name-required")); } if (Defaults.DefaultStreams.Any(s => s.Name.Equals(name, StringComparison.InvariantCultureIgnoreCase))) { return BadRequest(await localizationService.TranslateAsync(user.Id, "smart-filter-system-name")); } var filter = user.SmartFilters.FirstOrDefault(f => f.Id == filterId); if (filter == null) { return BadRequest(await localizationService.TranslateAsync(user.Id, "filter-not-found")); } filter.Name = name; unitOfWork.AppUserSmartFilterRepository.Update(filter); await unitOfWork.CommitAsync(); await streamService.RenameSmartFilterStreams(filter); return Ok(); } catch (Exception ex) { logger.LogError(ex, "There was an exception when renaming smart filter: {FilterId}", filterId); return BadRequest(await localizationService.TranslateAsync(UserId, "generic-error")); } } }