Misc Fixes + Enhancements (#1875)

* Moved Collapse Series with relationships into a user preference rather than library setting.

* Fixed bookmarks not converting to webp after initial save

* Fixed a bug where when merging we'd print out a duplicate series error when we shouldn't have

* Fixed a bug where clicking on a genre or tag from server stats wouldn't load all-series page in a filtered state.

* Implemented the ability to have Login role and thus disable accounts.

* Ensure first time flow gets the Login role

* Refactored user management screen so that pending users can be edited or deleted before the end user accepts the invite. A side effect is old legacy users that were here before email was required can now be deleted.

* Show a progress bar under the main series image on larger viewports to show whole series progress.

* Removed code no longer needed

* Cleanup tags, people, collections without connections after editing series metadata.

* Moved the Entity Builders to the main project
This commit is contained in:
Joe Milazzo 2023-03-10 19:09:38 -06:00 committed by GitHub
parent c62e594792
commit bd19b282d5
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
63 changed files with 2186 additions and 239 deletions

View File

@ -36,7 +36,6 @@ public class ComicInfoTests
}
#endregion
#region CalculatedCount
[Fact]

View File

@ -6,7 +6,7 @@ using API.Entities;
using API.Entities.Enums;
using API.Entities.Metadata;
using API.Extensions;
using API.Tests.Helpers.Builders;
using API.Helpers.Builders;
using Xunit;
namespace API.Tests.Extensions;
@ -226,40 +226,28 @@ public class QueryableExtensionsTests
{
var items = new List<Person>()
{
new Person()
{
SeriesMetadatas = new List<SeriesMetadata>()
new PersonBuilder("Test", PersonRole.Character)
.WithSeriesMetadata(new SeriesMetadata()
{
new SeriesMetadata()
{
AgeRating = AgeRating.Teen,
}
}
},
new Person()
{
SeriesMetadatas = new List<SeriesMetadata>()
AgeRating = AgeRating.Teen,
})
.Build(),
new PersonBuilder("Test", PersonRole.Character)
.WithSeriesMetadata(new SeriesMetadata()
{
new SeriesMetadata()
{
AgeRating = AgeRating.Unknown,
},
new SeriesMetadata()
{
AgeRating = AgeRating.Teen,
}
}
},
new Person()
{
SeriesMetadatas = new List<SeriesMetadata>()
AgeRating = AgeRating.Unknown,
})
.WithSeriesMetadata(new SeriesMetadata()
{
new SeriesMetadata()
{
AgeRating = AgeRating.X18Plus,
}
}
},
AgeRating = AgeRating.Teen,
})
.Build(),
new PersonBuilder("Test", PersonRole.Character)
.WithSeriesMetadata(new SeriesMetadata()
{
AgeRating = AgeRating.X18Plus,
})
.Build(),
};
var filtered = items.AsQueryable().RestrictAgainstAgeRestriction(new AgeRestriction()

View File

@ -4,7 +4,7 @@ using API.Comparators;
using API.Entities;
using API.Entities.Enums;
using API.Extensions;
using API.Tests.Helpers.Builders;
using API.Helpers.Builders;
using Xunit;
namespace API.Tests.Extensions;

View File

@ -4,9 +4,9 @@ using API.Entities.Enums;
using API.Entities.Metadata;
using API.Extensions;
using API.Helpers;
using API.Helpers.Builders;
using API.Parser;
using API.Services.Tasks.Scanner;
using API.Tests.Helpers.Builders;
using Xunit;
namespace API.Tests.Helpers;

View File

@ -8,8 +8,8 @@ using API.Entities;
using API.Entities.Enums;
using API.Extensions;
using API.Helpers;
using API.Helpers.Builders;
using API.Services;
using API.Tests.Helpers.Builders;
using AutoMapper;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;

View File

@ -244,6 +244,17 @@ public class ArchiveServiceTests
Assert.Equal(summaryInfo, comicInfo.Summary);
}
[Fact]
public void ShouldHaveComicInfo_CanParseUmlaut()
{
var testDirectory = Path.Join(Directory.GetCurrentDirectory(), "../../../Services/Test Data/ArchiveService/ComicInfos");
var archive = Path.Join(testDirectory, "Umlaut.zip");
var comicInfo = _archiveService.GetComicInfo(archive);
Assert.NotNull(comicInfo);
Assert.Equal("Belladonna", comicInfo.Series);
}
[Fact]
public void ShouldHaveComicInfo_WithAuthors()
{

View File

@ -12,9 +12,9 @@ using API.Entities.Enums;
using API.Entities.Metadata;
using API.Extensions;
using API.Helpers;
using API.Helpers.Builders;
using API.Services;
using API.SignalR;
using API.Tests.Helpers.Builders;
using AutoMapper;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;

View File

@ -12,11 +12,11 @@ using API.Entities.Enums;
using API.Entities.Metadata;
using API.Extensions;
using API.Helpers;
using API.Helpers.Builders;
using API.Services;
using API.Services.Tasks;
using API.SignalR;
using API.Tests.Helpers;
using API.Tests.Helpers.Builders;
using Microsoft.Extensions.Logging;
using NSubstitute;
using Xunit;

View File

@ -12,10 +12,10 @@ using API.Entities.Enums;
using API.Entities.Metadata;
using API.Extensions;
using API.Helpers;
using API.Helpers.Builders;
using API.Services;
using API.SignalR;
using API.Tests.Helpers;
using API.Tests.Helpers.Builders;
using AutoMapper;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;

View File

@ -13,10 +13,10 @@ using API.Entities;
using API.Entities.Enums;
using API.Extensions;
using API.Helpers;
using API.Helpers.Builders;
using API.Services;
using API.SignalR;
using API.Tests.Helpers;
using API.Tests.Helpers.Builders;
using AutoMapper;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;

View File

@ -4,11 +4,11 @@ using API.Entities;
using API.Entities.Enums;
using API.Entities.Metadata;
using API.Extensions;
using API.Helpers.Builders;
using API.Parser;
using API.Services.Tasks;
using API.Services.Tasks.Scanner;
using API.Tests.Helpers;
using API.Tests.Helpers.Builders;
using Xunit;
namespace API.Tests.Services;

View File

@ -12,10 +12,10 @@ using API.Entities;
using API.Entities.Enums;
using API.Entities.Metadata;
using API.Extensions;
using API.Helpers.Builders;
using API.Services;
using API.SignalR;
using API.Tests.Helpers;
using API.Tests.Helpers.Builders;
using Microsoft.Extensions.Logging;
using NSubstitute;
using Xunit;

View File

@ -1,5 +1,5 @@
using API.Extensions;
using API.Tests.Helpers.Builders;
using API.Helpers.Builders;
namespace API.Tests.Services;
using System.Collections.Generic;

View File

@ -121,6 +121,12 @@
<None Remove="kavita.db" />
<None Remove="covers\**" />
<None Remove="wwwroot\**" />
<None Remove="config\cache\**" />
<None Remove="config\logs\**" />
<None Remove="config\covers\**" />
<None Remove="config\bookmarks\**" />
<None Remove="config\backups\**" />
<None Remove="config\temp\**" />
</ItemGroup>
<ItemGroup>
@ -131,6 +137,12 @@
<Compile Remove="temp\**" />
<Compile Remove="covers\**" />
<Compile Remove="wwwroot\**" />
<Compile Remove="config\cache\**" />
<Compile Remove="config\logs\**" />
<Compile Remove="config\covers\**" />
<Compile Remove="config\bookmarks\**" />
<Compile Remove="config\backups\**" />
<Compile Remove="config\temp\**" />
</ItemGroup>
<ItemGroup>
@ -146,6 +158,8 @@
<EmbeddedResource Remove="config\temp\**" />
<EmbeddedResource Remove="config\stats\**" />
<EmbeddedResource Remove="wwwroot\**" />
<EmbeddedResource Remove="config\cache\**" />
<EmbeddedResource Remove="config\bookmarks\**" />
</ItemGroup>
<ItemGroup>
@ -169,6 +183,7 @@
<Content Update="bin\$(Configuration)\$(AssemblyName).xml">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</Content>
<Content Remove="config\bookmarks\**" />
</ItemGroup>
<ItemGroup>

View File

@ -31,7 +31,11 @@ public static class PolicyConstants
/// Used to give a user ability to Change Restrictions on their account
/// </summary>
public const string ChangeRestrictionRole = "Change Restriction";
/// <summary>
/// Used to give a user ability to Login to their account
/// </summary>
public const string LoginRole = "Login";
public static readonly ImmutableArray<string> ValidRoles =
ImmutableArray.Create(AdminRole, PlebRole, DownloadRole, ChangePasswordRole, BookmarkRole, ChangeRestrictionRole);
ImmutableArray.Create(AdminRole, PlebRole, DownloadRole, ChangePasswordRole, BookmarkRole, ChangeRestrictionRole, LoginRole);
}

View File

@ -144,6 +144,7 @@ public class AccountController : BaseApiController
var roleResult = await _userManager.AddToRoleAsync(user, PolicyConstants.AdminRole);
if (!roleResult.Succeeded) return BadRequest(result.Errors);
await _userManager.AddToRoleAsync(user, PolicyConstants.LoginRole);
return new UserDto
{
@ -182,6 +183,8 @@ public class AccountController : BaseApiController
.SingleOrDefaultAsync(x => x.NormalizedUserName == loginDto.Username.ToUpper());
if (user == null) return Unauthorized("Your credentials are not correct");
var roles = await _userManager.GetRolesAsync(user);
if (!roles.Contains(PolicyConstants.LoginRole)) return Unauthorized("Your account is disabled. Contact the server admin.");
var result = await _signInManager
.CheckPasswordSignInAsync(user, loginDto.Password, true);

View File

@ -338,7 +338,6 @@ public class LibraryController : BaseApiController
library.IncludeInRecommended = dto.IncludeInRecommended;
library.IncludeInSearch = dto.IncludeInSearch;
library.ManageCollections = dto.ManageCollections;
library.CollapseSeriesRelationships = dto.CollapseSeriesRelationships;
_unitOfWork.LibraryRepository.Update(library);

View File

@ -31,6 +31,7 @@ public class MetadataController : BaseApiController
/// <param name="libraryIds">String separated libraryIds or null for all genres</param>
/// <returns></returns>
[HttpGet("genres")]
[ResponseCache(CacheProfileName = ResponseCacheProfiles.Instant, VaryByQueryKeys = new []{"libraryIds"})]
public async Task<ActionResult<IList<GenreTagDto>>> GetAllGenres(string? libraryIds)
{
var userId = await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername());
@ -51,6 +52,7 @@ public class MetadataController : BaseApiController
/// <param name="libraryIds">String separated libraryIds or null for all people</param>
/// <returns></returns>
[HttpGet("people")]
[ResponseCache(CacheProfileName = ResponseCacheProfiles.Instant, VaryByQueryKeys = new []{"libraryIds"})]
public async Task<ActionResult<IList<PersonDto>>> GetAllPeople(string? libraryIds)
{
var userId = await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername());
@ -68,6 +70,7 @@ public class MetadataController : BaseApiController
/// <param name="libraryIds">String separated libraryIds or null for all tags</param>
/// <returns></returns>
[HttpGet("tags")]
[ResponseCache(CacheProfileName = ResponseCacheProfiles.Instant, VaryByQueryKeys = new []{"libraryIds"})]
public async Task<ActionResult<IList<TagDto>>> GetAllTags(string? libraryIds)
{
var userId = await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername());
@ -132,6 +135,7 @@ public class MetadataController : BaseApiController
/// <param name="libraryIds">String separated libraryIds or null for all ratings</param>
/// <returns></returns>
[HttpGet("languages")]
[ResponseCache(CacheProfileName = ResponseCacheProfiles.Instant, VaryByQueryKeys = new []{"libraryIds"})]
public async Task<ActionResult<IList<LanguageDto>>> GetAllLanguages(string? libraryIds)
{
var ids = libraryIds?.Split(",").Select(int.Parse).ToList();
@ -145,6 +149,7 @@ public class MetadataController : BaseApiController
}
[HttpGet("all-languages")]
[ResponseCache(CacheProfileName = ResponseCacheProfiles.Hour)]
public IEnumerable<LanguageDto> GetAllValidLanguages()
{
return CultureInfo.GetCultures(CultureTypes.AllCultures).Select(c =>

View File

@ -38,18 +38,16 @@ public class UsersController : BaseApiController
return BadRequest("Could not delete the user.");
}
/// <summary>
/// Returns all users of this server
/// </summary>
/// <param name="includePending">This will include pending members</param>
/// <returns></returns>
[Authorize(Policy = "RequireAdminRole")]
[HttpGet]
public async Task<ActionResult<IEnumerable<MemberDto>>> GetUsers()
public async Task<ActionResult<IEnumerable<MemberDto>>> GetUsers(bool includePending = false)
{
return Ok(await _unitOfWork.UserRepository.GetEmailConfirmedMemberDtosAsync());
}
[Authorize(Policy = "RequireAdminRole")]
[HttpGet("pending")]
public async Task<ActionResult<IEnumerable<MemberDto>>> GetPendingUsers()
{
return Ok(await _unitOfWork.UserRepository.GetPendingMemberDtosAsync());
return Ok(await _unitOfWork.UserRepository.GetEmailConfirmedMemberDtosAsync(!includePending));
}
[HttpGet("myself")]
@ -110,6 +108,7 @@ public class UsersController : BaseApiController
existingPreferences.PromptForDownloadSize = preferencesDto.PromptForDownloadSize;
existingPreferences.NoTransitions = preferencesDto.NoTransitions;
existingPreferences.SwipeToPaginate = preferencesDto.SwipeToPaginate;
existingPreferences.CollapseSeriesRelationships = preferencesDto.CollapseSeriesRelationships;
_unitOfWork.UserRepository.Update(existingPreferences);

View File

@ -7,10 +7,10 @@ public class AgeRestrictionDto
/// <summary>
/// The maximum age rating a user has access to. -1 if not applicable
/// </summary>
public AgeRating AgeRating { get; set; } = AgeRating.NotApplicable;
public required AgeRating AgeRating { get; set; } = AgeRating.NotApplicable;
/// <summary>
/// Are Unknowns explicitly allowed against age rating
/// </summary>
/// <remarks>Unknown is always lowest and default age rating. Setting this to false will ensure Teen age rating applies and unknowns are still filtered</remarks>
public bool IncludeUnknowns { get; set; } = false;
public required bool IncludeUnknowns { get; set; } = false;
}

View File

@ -1,4 +1,5 @@
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
namespace API.DTOs.Account;
@ -17,5 +18,4 @@ public record UpdateUserDto
/// An Age Rating which will limit the account to seeing everything equal to or below said rating.
/// </summary>
public AgeRestrictionDto AgeRestriction { get; init; } = default!;
}

View File

@ -12,6 +12,10 @@ public class MemberDto
public int Id { get; init; }
public string? Username { get; init; }
public string? Email { get; init; }
/// <summary>
/// If the member is still pending or not
/// </summary>
public bool IsPending { get; init; }
public AgeRestrictionDto? AgeRestriction { get; init; }
public DateTime Created { get; init; }
public DateTime LastActive { get; init; }

View File

@ -24,7 +24,4 @@ public class UpdateLibraryDto
public bool IncludeInSearch { get; init; }
[Required]
public bool ManageCollections { get; init; }
[Required]
public bool CollapseSeriesRelationships { get; init; }
}

View File

@ -1,4 +1,4 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations;
using API.Entities;
using API.Entities.Enums;
using API.Entities.Enums.UserPreferences;
@ -137,4 +137,9 @@ public class UserPreferencesDto
/// </summary>
[Required]
public bool NoTransitions { get; set; } = false;
/// <summary>
/// When showing series, only parent series or series with no relationships will be returned
/// </summary>
[Required]
public bool CollapseSeriesRelationships { get; set; } = false;
}

View File

@ -0,0 +1,36 @@
using System.Threading.Tasks;
using API.Constants;
using API.Entities;
using Microsoft.AspNetCore.Identity;
using Microsoft.Extensions.Logging;
namespace API.Data;
/// <summary>
/// Added in v0.7.1.18
/// </summary>
public static class MigrateLoginRoles
{
/// <summary>
/// Will not run if any users have the <see cref="PolicyConstants.LoginRole"/> role already
/// </summary>
/// <param name="unitOfWork"></param>
/// <param name="userManager"></param>
/// <param name="logger"></param>
public static async Task Migrate(IUnitOfWork unitOfWork, UserManager<AppUser> userManager, ILogger<Program> logger)
{
var usersWithRole = await userManager.GetUsersInRoleAsync(PolicyConstants.LoginRole);
if (usersWithRole.Count != 0) return;
logger.LogCritical("Running MigrateLoginRoles migration");
var allUsers = await unitOfWork.UserRepository.GetAllUsersAsync();
foreach (var user in allUsers)
{
await userManager.RemoveFromRoleAsync(user, PolicyConstants.LoginRole);
await userManager.AddToRoleAsync(user, PolicyConstants.LoginRole);
}
logger.LogInformation("MigrateLoginRoles migration complete");
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,29 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace API.Data.Migrations
{
/// <inheritdoc />
public partial class MoveCollapseSeriesToUserPref : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "CollapseSeriesRelationships",
table: "AppUserPreferences",
type: "INTEGER",
nullable: false,
defaultValue: false);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "CollapseSeriesRelationships",
table: "AppUserPreferences");
}
}
}

View File

@ -15,7 +15,7 @@ namespace API.Data.Migrations
protected override void BuildModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "6.0.10");
modelBuilder.HasAnnotation("ProductVersion", "7.0.3");
modelBuilder.Entity("API.Entities.AppRole", b =>
{
@ -221,10 +221,10 @@ namespace API.Data.Migrations
b.Property<int>("BookReaderReadingDirection")
.HasColumnType("INTEGER");
b.Property<int>("BookReaderWritingStyle")
b.Property<bool>("BookReaderTapToPaginate")
.HasColumnType("INTEGER");
b.Property<bool>("BookReaderTapToPaginate")
b.Property<int>("BookReaderWritingStyle")
.HasColumnType("INTEGER");
b.Property<string>("BookThemeName")
@ -232,6 +232,9 @@ namespace API.Data.Migrations
.HasColumnType("TEXT")
.HasDefaultValue("Dark");
b.Property<bool>("CollapseSeriesRelationships")
.HasColumnType("INTEGER");
b.Property<bool>("EmulateBook")
.HasColumnType("INTEGER");
@ -601,9 +604,6 @@ namespace API.Data.Migrations
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<bool>("CollapseSeriesRelationships")
.HasColumnType("INTEGER");
b.Property<string>("CoverImage")
.HasColumnType("TEXT");

View File

@ -770,9 +770,9 @@ public class SeriesRepository : ISeriesRepository
// NOTE: Why do we even have libraryId when the filter has the actual libraryIds?
var userLibraries = await GetUserLibrariesForFilteredQuery(libraryId, userId, queryContext);
var userRating = await _context.AppUser.GetUserAgeRestriction(userId);
var onlyParentSeries = await _context.Library.AsNoTracking()
.Where(l => filter.Libraries.Contains(l.Id))
.AllAsync(l => l.CollapseSeriesRelationships);
var onlyParentSeries = await _context.AppUserPreferences.Where(u => u.AppUserId == userId)
.Select(u => u.CollapseSeriesRelationships)
.SingleOrDefaultAsync();
var formats = ExtractFilters(libraryId, userId, filter, ref userLibraries,
out var allPeopleIds, out var hasPeopleFilter, out var hasGenresFilter,

View File

@ -39,8 +39,7 @@ public interface IUserRepository
void Add(AppUserBookmark bookmark);
public void Delete(AppUser? user);
void Delete(AppUserBookmark bookmark);
Task<IEnumerable<MemberDto>> GetEmailConfirmedMemberDtosAsync();
Task<IEnumerable<MemberDto>> GetPendingMemberDtosAsync();
Task<IEnumerable<MemberDto>> GetEmailConfirmedMemberDtosAsync(bool emailConfirmed = true);
Task<IEnumerable<AppUser>> GetAdminUsersAsync();
Task<bool> IsUserAdminAsync(AppUser? user);
Task<AppUserRating?> GetUserRatingAsync(int seriesId, int userId);
@ -329,10 +328,10 @@ public class UserRepository : IUserRepository
}
public async Task<IEnumerable<MemberDto>> GetEmailConfirmedMemberDtosAsync()
public async Task<IEnumerable<MemberDto>> GetEmailConfirmedMemberDtosAsync(bool emailConfirmed = true)
{
return await _context.Users
.Where(u => u.EmailConfirmed)
.Where(u => (emailConfirmed && u.EmailConfirmed) || !emailConfirmed)
.Include(x => x.Libraries)
.Include(r => r.UserRoles)
.ThenInclude(r => r.Role)
@ -344,45 +343,8 @@ public class UserRepository : IUserRepository
Email = u.Email,
Created = u.Created,
LastActive = u.LastActive,
Roles = u.UserRoles.Select(r => r.Role.Name).ToList()!,
AgeRestriction = new AgeRestrictionDto()
{
AgeRating = u.AgeRestriction,
IncludeUnknowns = u.AgeRestrictionIncludeUnknowns
},
Libraries = u.Libraries.Select(l => new LibraryDto
{
Name = l.Name,
Type = l.Type,
LastScanned = l.LastScanned,
Folders = l.Folders.Select(x => x.Path).ToList()
}).ToList()
})
.AsSplitQuery()
.AsNoTracking()
.ToListAsync();
}
/// <summary>
/// Returns a list of users that are considered Pending by invite. This means email is unconfirmed and they have never logged in
/// </summary>
/// <returns></returns>
public async Task<IEnumerable<MemberDto>> GetPendingMemberDtosAsync()
{
return await _context.Users
.Where(u => !u.EmailConfirmed && u.LastActive == DateTime.MinValue)
.Include(x => x.Libraries)
.Include(r => r.UserRoles)
.ThenInclude(r => r.Role)
.OrderBy(u => u.UserName)
.Select(u => new MemberDto
{
Id = u.Id,
Username = u.UserName,
Email = u.Email,
Created = u.Created,
LastActive = u.LastActive,
Roles = u.UserRoles.Select(r => r.Role.Name).ToList()!,
Roles = u.UserRoles.Select(r => r.Role.Name).ToList(),
IsPending = !u.EmailConfirmed,
AgeRestriction = new AgeRestrictionDto()
{
AgeRating = u.AgeRestriction,

View File

@ -52,6 +52,7 @@ public class AppUser : IdentityUser<int>, IHasConcurrencyToken
/// </summary>
public bool AgeRestrictionIncludeUnknowns { get; set; } = false;
/// <inheritdoc />
[ConcurrencyCheck]
public uint RowVersion { get; private set; }

View File

@ -119,6 +119,10 @@ public class AppUserPreferences
/// UI Site Global Setting: Should Kavita disable CSS transitions
/// </summary>
public bool NoTransitions { get; set; } = false;
/// <summary>
/// UI Site Global Setting: When showing series, only parent series or series with no relationships will be returned
/// </summary>
public bool CollapseSeriesRelationships { get; set; } = false;
public AppUser AppUser { get; set; } = null!;
public int AppUserId { get; set; }

View File

@ -31,10 +31,6 @@ public class Library : IEntityDate
/// Should this library create and manage collections from Metadata
/// </summary>
public bool ManageCollections { get; set; } = true;
/// <summary>
/// When showing series, only parent series or series with no relationships will be returned
/// </summary>
public bool CollapseSeriesRelationships { get; set; } = false;
public DateTime Created { get; set; }
public DateTime LastModified { get; set; }
public DateTime CreatedUtc { get; set; }

View File

@ -7,9 +7,9 @@ namespace API.Entities;
public class Person
{
public int Id { get; set; }
public string? Name { get; set; }
public string? NormalizedName { get; set; }
public PersonRole Role { get; set; }
public required string Name { get; set; }
public required string NormalizedName { get; set; }
public required PersonRole Role { get; set; }
// Relationships
public ICollection<SeriesMetadata> SeriesMetadatas { get; set; } = null!;

View File

@ -1,13 +1,12 @@
using System.Collections.Generic;
using API.Data;
using API.Entities;
using API.Entities.Enums;
namespace API.Tests.Helpers.Builders;
namespace API.Helpers.Builders;
public class ChapterBuilder : IEntityBuilder<Chapter>
{
private Chapter _chapter;
private readonly Chapter _chapter;
public Chapter Build() => _chapter;
public ChapterBuilder(string number, string? range=null)

View File

@ -1,4 +1,4 @@
namespace API.Tests.Helpers.Builders;
namespace API.Helpers.Builders;
public interface IEntityBuilder<out T>
{

View File

@ -0,0 +1,32 @@
using System.Collections.Generic;
using API.Entities;
using API.Entities.Enums;
using API.Entities.Metadata;
using API.Extensions;
namespace API.Helpers.Builders;
public class PersonBuilder : IEntityBuilder<Person>
{
private readonly Person _person;
public Person Build() => _person;
public PersonBuilder(string name, PersonRole role)
{
_person = new Person()
{
Name = name.Trim(),
NormalizedName = name.ToNormalized(),
Role = role,
ChapterMetadatas = new List<Chapter>(),
SeriesMetadatas = new List<SeriesMetadata>()
};
}
public PersonBuilder WithSeriesMetadata(SeriesMetadata metadata)
{
_person.SeriesMetadatas ??= new List<SeriesMetadata>();
_person.SeriesMetadatas.Add(metadata);
return this;
}
}

View File

@ -5,7 +5,7 @@ using API.Entities.Enums;
using API.Entities.Metadata;
using API.Extensions;
namespace API.Tests.Helpers.Builders;
namespace API.Helpers.Builders;
public class SeriesBuilder : IEntityBuilder<Series>
{

View File

@ -1,14 +1,13 @@
using System.Collections.Generic;
using API.Data;
using API.Entities;
using API.Entities.Enums;
using API.Entities.Metadata;
namespace API.Tests.Helpers.Builders;
namespace API.Helpers.Builders;
public class SeriesMetadataBuilder : IEntityBuilder<SeriesMetadata>
{
private SeriesMetadata _seriesMetadata;
private readonly SeriesMetadata _seriesMetadata;
public SeriesMetadata Build() => _seriesMetadata;
public SeriesMetadataBuilder()

View File

@ -3,7 +3,7 @@ using System.Linq;
using API.Data;
using API.Entities;
namespace API.Tests.Helpers.Builders;
namespace API.Helpers.Builders;
public class VolumeBuilder : IEntityBuilder<Volume>
{

View File

@ -85,7 +85,7 @@ public static class PersonHelper
foreach (var person in existingPeople)
{
var existingPerson = removeAllExcept
.FirstOrDefault(p => person.NormalizedName != null && p.Role == person.Role && person.NormalizedName.Equals(p.NormalizedName));
.FirstOrDefault(p => p.Role == person.Role && person.NormalizedName.Equals(p.NormalizedName));
if (existingPerson == null)
{
action?.Invoke(person);
@ -100,8 +100,9 @@ public static class PersonHelper
/// <param name="person"></param>
public static void AddPersonIfNotExists(ICollection<Person> metadataPeople, Person person)
{
if (string.IsNullOrEmpty(person.Name)) return;
var existingPerson = metadataPeople.SingleOrDefault(p =>
p.NormalizedName == person.Name?.ToNormalized() && p.Role == person.Role);
p.NormalizedName == person.Name.ToNormalized() && p.Role == person.Role);
if (existingPerson == null)
{
metadataPeople.Add(person);

View File

@ -348,8 +348,9 @@ public class BookmarkService : IBookmarkService
/// <param name="filename">The file to convert</param>
/// <param name="targetFolder">Full path to where files should be stored or any stem</param>
/// <returns></returns>
private async Task<string> SaveAsWebP(string imageDirectory, string filename, string targetFolder)
public async Task<string> SaveAsWebP(string imageDirectory, string filename, string targetFolder)
{
// This must be Public as it's used in via Hangfire as a background task
var fullSourcePath = _directoryService.FileSystem.Path.Join(imageDirectory, filename);
var fullTargetDirectory = fullSourcePath.Replace(new FileInfo(filename).Name, string.Empty);

View File

@ -182,7 +182,12 @@ public class SeriesService : ISeriesService
return true;
}
if (await _unitOfWork.CommitAsync() && updateSeriesMetadataDto.CollectionTags != null)
await _unitOfWork.CommitAsync();
// Trigger code to cleanup tags, collections, people, etc
await _taskScheduler.CleanupDbEntries();
if (updateSeriesMetadataDto.CollectionTags != null)
{
foreach (var tag in updateSeriesMetadataDto.CollectionTags)
{

View File

@ -31,6 +31,7 @@ public interface ITaskScheduler
Task RunStatCollection();
void ScanSiteThemes();
Task CovertAllCoversToWebP();
Task CleanupDbEntries();
}
public class TaskScheduler : ITaskScheduler
{
@ -230,6 +231,11 @@ public class TaskScheduler : ITaskScheduler
#endregion
public async Task CleanupDbEntries()
{
await _cleanupService.CleanupDbEntries();
}
public void ScanLibraries()
{
if (RunningAnyTasksByMethod(ScanTasks, ScanQueue))

View File

@ -236,6 +236,11 @@ public class ParseScannedFiles
p.Key.Format == info.Format)
.Key;
if (existingName == null)
{
return info.Series;
}
if (!string.IsNullOrEmpty(existingName.Name))
{
return existingName.Name;
@ -297,7 +302,7 @@ public class ParseScannedFiles
_logger.LogDebug("[ScannerService] Found {Count} files for {Folder}", files.Count, folder);
await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress,
MessageFactory.FileScanProgressEvent(folder, libraryName, ProgressEventType.Updated));
MessageFactory.FileScanProgressEvent($"{files.Count} files in {folder}", libraryName, ProgressEventType.Updated));
if (files.Count == 0)
{
_logger.LogInformation("[ScannerService] {Folder} is empty or is no longer in this location", folder);

View File

@ -433,7 +433,7 @@ public class ProcessSeries : IProcessSeries
var genres = chapters.SelectMany(c => c.Genres).ToList();
GenreHelper.KeepOnlySameGenreBetweenLists(series.Metadata.Genres.ToList(), genres, genre =>
{
if (series.Metadata.GenresLocked) return;
if (series.Metadata.GenresLocked) return; // NOTE: Doesn't it make sense to do the locked skip outside this loop?
series.Metadata.Genres.Remove(genre);
});

View File

@ -520,12 +520,14 @@ public class ScannerService : IScannerService
var scanElapsedTime = await ScanFiles(library, libraryFolderPaths, shouldUseLibraryScan, TrackFiles, forceUpdate);
// NOTE: This runs sync after every file is scanned
foreach (var task in processTasks)
{
await task();
}
await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress, MessageFactory.FileScanProgressEvent(string.Empty, library.Name, ProgressEventType.Ended));
await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress,
MessageFactory.FileScanProgressEvent(string.Empty, library.Name, ProgressEventType.Ended));
_logger.LogInformation("[ScannerService] Finished file scan in {ScanAndUpdateTime} milliseconds. Updating database", scanElapsedTime);

View File

@ -214,6 +214,7 @@ public class Startup
logger.LogInformation("Running Migrations");
// Only run this if we are upgrading
await MigrateChangePasswordRoles.Migrate(unitOfWork, userManager);
await MigrateRemoveExtraThemes.Migrate(unitOfWork, themeService);
@ -235,6 +236,9 @@ public class Startup
// v0.7
await MigrateBrokenGMT1Dates.Migrate(unitOfWork, dataContext, logger);
// v0.7.2
await MigrateLoginRoles.Migrate(unitOfWork, userManager, logger);
// Update the version in the DB after all migrations are run
var installVersion = await unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.InstallVersion);
installVersion.Value = BuildInfo.Version.ToString();

View File

@ -10,4 +10,5 @@ export interface Member {
roles: string[];
libraries: Library[];
ageRestriction: AgeRestriction;
isPending: boolean;
}

View File

@ -40,6 +40,7 @@ export interface Preferences {
blurUnreadSummaries: boolean;
promptForDownloadSize: boolean;
noTransitions: boolean;
collapseSeriesRelationships: boolean;
}
export const readingDirections = [{text: 'Left to Right', value: ReadingDirection.LeftToRight}, {text: 'Right to Left', value: ReadingDirection.RightToLeft}];

View File

@ -12,8 +12,8 @@ export class MemberService {
constructor(private httpClient: HttpClient) { }
getMembers() {
return this.httpClient.get<Member[]>(this.baseUrl + 'users');
getMembers(includePending: boolean = false) {
return this.httpClient.get<Member[]>(this.baseUrl + 'users?includePending=' + includePending);
}
getMemberNames() {

View File

@ -1,40 +1,11 @@
<div class="container-fluid">
<ng-container>
<div class="row mb-2">
<div class="col-8"><h3>Pending Invites</h3></div>
<div class="col-4"><button class="btn btn-primary float-end" (click)="inviteUser()"><i class="fa fa-plus" aria-hidden="true"></i><span class="phone-hidden">&nbsp;Invite</span></button></div>
</div>
<ul class="list-group">
<li class="list-group-item no-hover" *ngFor="let invite of pendingInvites; let idx = index;">
<div>
<h4>
<span id="member-name--{{idx}}">{{invite.username | titlecase}} </span>
<div class="float-end">
<button class="btn btn-danger btn-sm me-2" (click)="deleteUser(invite)">Cancel</button>
<button class="btn btn-secondary btn-sm me-2" (click)="resendEmail(invite)">Resend</button>
<button class="btn btn-secondary btn-sm" (click)="setup(invite)">Setup</button>
</div>
</h4>
<div>Invited: {{invite.created | date: 'short'}}</div>
</div>
</li>
<li *ngIf="loadingMembers" class="list-group-item no-hover">
<div class="spinner-border text-secondary" role="status">
<span class="invisible">Loading...</span>
</div>
</li>
<li class="list-group-item no-hover" *ngIf="pendingInvites.length === 0 && !loadingMembers">
There are no invited Users
</li>
</ul>
</ng-container>
<h3 class="mt-3">Active Users</h3>
<div class="row mb-2">
<div class="col-8"><h3>Active Users</h3></div>
<div class="col-4"><button class="btn btn-primary float-end" (click)="inviteUser()"><i class="fa fa-plus" aria-hidden="true"></i><span class="phone-hidden">&nbsp;Invite</span></button></div>
</div>
<ul class="list-group">
<li *ngFor="let member of members; let idx = index;" class="list-group-item no-hover">
<div>
@ -45,10 +16,14 @@
<i class="fas fa-star" aria-hidden="true"></i>
<span class="visually-hidden">(You)</span>
</span>
<span class="badge bg-secondary text-dark" *ngIf="member.isPending">Pending</span>
<div class="float-end" *ngIf="canEditMember(member)">
<button class="btn btn-danger btn-sm me-2" (click)="deleteUser(member)" placement="top" ngbTooltip="Delete User" attr.aria-label="Delete User {{member.username | titlecase}}"><i class="fa fa-trash" aria-hidden="true"></i></button>
<button class="btn btn-secondary btn-sm me-2" (click)="updatePassword(member)" placement="top" ngbTooltip="Change Password" attr.aria-label="Change Password for {{member.username | titlecase}}"><i class="fa fa-key" aria-hidden="true"></i></button>
<button class="btn btn-primary btn-sm" (click)="openEditUser(member)" placement="top" ngbTooltip="Edit" attr.aria-label="Edit {{member.username | titlecase}}"><i class="fa fa-pen" aria-hidden="true"></i></button>
<button class="btn btn-primary btn-sm me-2" (click)="openEditUser(member)" placement="top" ngbTooltip="Edit" attr.aria-label="Edit {{member.username | titlecase}}"><i class="fa fa-pen" aria-hidden="true"></i></button>
<button *ngIf="member.isPending" class="btn btn-secondary btn-sm me-2" (click)="resendEmail(member)" placement="top" ngbTooltip="Resend Invite" attr.aria-label="Delete Invite {{member.username | titlecase}}">Resend</button>
<button *ngIf="member.isPending" class="btn btn-secondary btn-sm me-2" (click)="setup(member)" placement="top" ngbTooltip="Setup User" attr.aria-label="Setup User {{member.username | titlecase}}">Setup</button>
<button *ngIf="!member.isPending" class="btn btn-secondary btn-sm" (click)="updatePassword(member)" placement="top" ngbTooltip="Change Password" attr.aria-label="Change Password for {{member.username | titlecase}}"><i class="fa fa-key" aria-hidden="true"></i></button>
</div>
</h4>
<div class="user-info">

View File

@ -58,7 +58,7 @@ export class ManageUsersComponent implements OnInit, OnDestroy {
loadMembers() {
this.loadingMembers = true;
this.memberService.getMembers().subscribe(members => {
this.memberService.getMembers(true).subscribe(members => {
this.members = members;
// Show logged in user at the top of the list
this.members.sort((a: Member, b: Member) => {

View File

@ -53,8 +53,15 @@ export class RoleSelectorComponent implements OnInit {
foundRole[0].selected = true;
}
});
this.cdRef.markForCheck();
} else {
// For new users, preselect LoginRole
this.selectedRoles.forEach(role => {
if (role.data == 'Login') {
role.selected = true;
}
});
}
this.cdRef.markForCheck();
}
handleModelUpdate() {

View File

@ -63,6 +63,9 @@
<app-tag-badge [selectionMode]="TagBadgeCursor.NotAllowed" fillStyle="filled">{{unreadCount}}</app-tag-badge>
</div>
<app-image height="100%" maxHeight="400px" objectFit="contain" background="none" [imageUrl]="seriesImage"></app-image>
<div class="progress-banner" *ngIf="series.pagesRead < series.pages && hasReadingProgress && currentlyReadingChapter && !currentlyReadingChapter.isSpecial">
<ngb-progressbar type="primary" height="5px" [value]="series.pagesRead" [max]="series.pages"></ngb-progressbar>
</div>
<div class="under-image" *ngIf="series.pagesRead < series.pages && hasReadingProgress && currentlyReadingChapter && !currentlyReadingChapter.isSpecial">
Continue {{ContinuePointTitle}}
</div>

View File

@ -53,3 +53,7 @@
.info-container {
align-items: flex-start;
}
::ng-deep .progress {
border-radius: 0px;
}

View File

@ -1,7 +1,7 @@
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { SeriesDetailRoutingModule } from './series-detail-routing.module';
import { NgbCollapseModule, NgbNavModule, NgbRatingModule, NgbTooltipModule } from '@ng-bootstrap/ng-bootstrap';
import { NgbCollapseModule, NgbNavModule, NgbProgressbarModule, NgbRatingModule, NgbTooltipModule } from '@ng-bootstrap/ng-bootstrap';
import { SeriesMetadataDetailComponent } from './_components/series-metadata-detail/series-metadata-detail.component';
import { ReviewSeriesModalComponent } from './_modals/review-series-modal/review-series-modal.component';
import { SharedModule } from '../shared/shared.module';
@ -26,6 +26,7 @@ import { SeriesDetailComponent } from './_components/series-detail/series-detail
NgbNavModule,
NgbRatingModule,
NgbTooltipModule, // Series Detail, Extras Drawer
NgbProgressbarModule,
TypeaheadModule,
PipeModule,

View File

@ -105,21 +105,7 @@
</p>
</div>
</div>
<div class="row">
<div class="col-md-12 col-sm-12 pe-2 mb-2">
<div class="mb-3 mt-1">
<div class="form-check form-switch">
<input type="checkbox" id="collapse-relationships" role="switch" formControlName="collapseSeriesRelationships" class="form-check-input" aria-labelledby="auto-close-label">
<label class="form-check-label" for="collapse-relationships">Collapse Series Relationships</label>
</div>
</div>
<p class="accent">
Experiemental: Should Kavita show Series that have no relationships or is the parent/prequel
</p>
</div>
</div>
<div class="row">
<div class="col-md-12 col-sm-12 pe-2 mb-2">
<div class="mb-3 mt-1">

View File

@ -101,7 +101,7 @@ export class ServerStatsComponent implements OnDestroy {
ref.componentInstance.title = 'Genres';
ref.componentInstance.clicked = (item: string) => {
const params: any = {};
params[FilterQueryParam.Genres] = item;
params[FilterQueryParam.Genres] = genres.filter(g => g.title === item)[0].id;
params[FilterQueryParam.Page] = 1;
this.router.navigate(['all-series'], {queryParams: params});
};
@ -115,7 +115,7 @@ export class ServerStatsComponent implements OnDestroy {
ref.componentInstance.title = 'Tags';
ref.componentInstance.clicked = (item: string) => {
const params: any = {};
params[FilterQueryParam.Tags] = item;
params[FilterQueryParam.Tags] = tags.filter(g => g.title === item)[0].id;
params[FilterQueryParam.Page] = 1;
this.router.navigate(['all-series'], {queryParams: params});
};

View File

@ -77,6 +77,19 @@
</div>
</div>
<div class="row g-0">
<div class="col-md-12 col-sm-12 pe-2 mb-2">
<div class="mb-3 mt-1">
<div class="form-check form-switch">
<input type="checkbox" id="collapse-relationships" role="switch" formControlName="collapseSeriesRelationships" aria-describedby="settings-collapse-relationships-help" class="form-check-input" aria-labelledby="auto-close-label">
<label class="form-check-label" for="collapse-relationships">Collapse Series Relationships</label>
</div>
</div>
<ng-template #noTransitionsTooltip>Experiemental: Should Kavita show Series that have no relationships or is the parent/prequel</ng-template>
<span class="visually-hidden" id="settings-collapse-relationships-help">Experiemental: Should Kavita show Series that have no relationships or is the parent/prequel</span>
</div>
</div>
<div class="col-auto d-flex d-md-block justify-content-sm-center text-md-end mb-3">
<button type="button" class="flex-fill btn btn-secondary me-2" (click)="resetForm()" aria-describedby="reading-panel">Reset</button>
<button type="submit" class="flex-fill btn btn-primary" (click)="save()" aria-describedby="reading-panel" [disabled]="!settingsForm.dirty">Save</button>

View File

@ -156,6 +156,7 @@ export class UserPreferencesComponent implements OnInit, OnDestroy {
this.settingsForm.addControl('blurUnreadSummaries', new FormControl(this.user.preferences.blurUnreadSummaries, []));
this.settingsForm.addControl('promptForDownloadSize', new FormControl(this.user.preferences.promptForDownloadSize, []));
this.settingsForm.addControl('noTransitions', new FormControl(this.user.preferences.noTransitions, []));
this.settingsForm.addControl('collapseSeriesRelationships', new FormControl(this.user.preferences.collapseSeriesRelationships, []));
this.cdRef.markForCheck();
});
@ -202,6 +203,7 @@ export class UserPreferencesComponent implements OnInit, OnDestroy {
this.settingsForm.get('noTransitions')?.setValue(this.user.preferences.noTransitions);
this.settingsForm.get('emulateBook')?.setValue(this.user.preferences.emulateBook);
this.settingsForm.get('swipeToPaginate')?.setValue(this.user.preferences.swipeToPaginate);
this.settingsForm.get('collapseSeriesRelationships')?.setValue(this.user.preferences.collapseSeriesRelationships);
this.cdRef.markForCheck();
this.settingsForm.markAsPristine();
}
@ -234,7 +236,8 @@ export class UserPreferencesComponent implements OnInit, OnDestroy {
promptForDownloadSize: modelSettings.promptForDownloadSize,
noTransitions: modelSettings.noTransitions,
emulateBook: modelSettings.emulateBook,
swipeToPaginate: modelSettings.swipeToPaginate
swipeToPaginate: modelSettings.swipeToPaginate,
collapseSeriesRelationships: modelSettings.collapseSeriesRelationships
};
this.observableHandles.push(this.accountService.updatePreferences(data).subscribe((updatedPrefs) => {

View File

@ -7,7 +7,7 @@
"name": "GPL-3.0",
"url": "https://github.com/Kareadita/Kavita/blob/develop/LICENSE"
},
"version": "0.7.1.13"
"version": "0.7.1.16"
},
"servers": [
{
@ -8926,43 +8926,17 @@
"tags": [
"Users"
],
"responses": {
"200": {
"description": "Success",
"content": {
"text/plain": {
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/MemberDto"
}
}
},
"application/json": {
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/MemberDto"
}
}
},
"text/json": {
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/MemberDto"
}
}
}
"summary": "Returns all users of this server",
"parameters": [
{
"name": "includePending",
"in": "query",
"description": "This will include pending members",
"schema": {
"type": "boolean",
"default": false
}
}
}
}
},
"/api/Users/pending": {
"get": {
"tags": [
"Users"
],
"responses": {
"200": {
@ -9811,6 +9785,10 @@
"type": "boolean",
"description": "UI Site Global Setting: Should Kavita disable CSS transitions"
},
"collapseSeriesRelationships": {
"type": "boolean",
"description": "UI Site Global Setting: When showing series, only parent series or series with no relationships will be returned"
},
"appUser": {
"$ref": "#/components/schemas/AppUser"
},
@ -11729,10 +11707,6 @@
"type": "boolean",
"description": "Should this library create and manage collections from Metadata"
},
"collapseSeriesRelationships": {
"type": "boolean",
"description": "When showing series, only parent series or series with no relationships will be returned"
},
"created": {
"type": "string",
"format": "date-time"
@ -12075,6 +12049,10 @@
"type": "string",
"nullable": true
},
"isPending": {
"type": "boolean",
"description": "If the member is still pending or not"
},
"ageRestriction": {
"$ref": "#/components/schemas/AgeRestrictionDto"
},
@ -14130,6 +14108,11 @@
"description": "Name of the Theme",
"nullable": true
},
"normalizedName": {
"type": "string",
"description": "Normalized name for lookups",
"nullable": true
},
"fileName": {
"type": "string",
"description": "File path to the content. Stored under API.Services.DirectoryService.SiteThemeDirectory.\r\nMust be a .css file",
@ -14367,7 +14350,6 @@
},
"UpdateLibraryDto": {
"required": [
"collapseSeriesRelationships",
"folders",
"folderWatching",
"id",
@ -14411,9 +14393,6 @@
},
"manageCollections": {
"type": "boolean"
},
"collapseSeriesRelationships": {
"type": "boolean"
}
},
"additionalProperties": false
@ -14964,6 +14943,7 @@
"bookReaderTapToPaginate",
"bookReaderThemeName",
"bookReaderWritingStyle",
"collapseSeriesRelationships",
"emulateBook",
"globalPageLayoutMode",
"layoutMode",
@ -15073,6 +15053,10 @@
"noTransitions": {
"type": "boolean",
"description": "UI Site Global Setting: Should Kavita disable CSS transitions"
},
"collapseSeriesRelationships": {
"type": "boolean",
"description": "When showing series, only parent series or series with no relationships will be returned"
}
},
"additionalProperties": false