Custom keybinds, Default language per Library, and bugfixes (#4162)

Co-authored-by: Joseph Milazzo <josephmajora@gmail.com>
This commit is contained in:
Fesaa 2025-11-01 15:56:00 +01:00 committed by GitHub
parent f9280f6861
commit 2c6eddfebb
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
72 changed files with 6038 additions and 441 deletions

View File

@ -17,109 +17,19 @@ using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.Extensions.Logging;
using NSubstitute;
using Xunit;
using Xunit.Abstractions;
namespace API.Tests.Repository;
#nullable enable
public class SeriesRepositoryTests
public class SeriesRepositoryTests(ITestOutputHelper testOutputHelper): AbstractDbTest(testOutputHelper)
{
private readonly IUnitOfWork _unitOfWork;
private readonly DbConnection? _connection;
private readonly DataContext _context;
private const string CacheDirectory = "C:/kavita/config/cache/";
private const string CoverImageDirectory = "C:/kavita/config/covers/";
private const string BackupDirectory = "C:/kavita/config/backups/";
private const string DataDirectory = "C:/data/";
public SeriesRepositoryTests()
{
var contextOptions = new DbContextOptionsBuilder().UseSqlite(CreateInMemoryDatabase()).Options;
_connection = RelationalOptionsExtension.Extract(contextOptions).Connection;
_context = new DataContext(contextOptions);
Task.Run(SeedDb).GetAwaiter().GetResult();
var config = new MapperConfiguration(cfg => cfg.AddProfile<AutoMapperProfiles>());
var mapper = config.CreateMapper();
_unitOfWork = new UnitOfWork(_context, mapper, null!);
}
#region Setup
private static DbConnection CreateInMemoryDatabase()
{
var connection = new SqliteConnection("Filename=:memory:");
connection.Open();
return connection;
}
private async Task<bool> SeedDb()
{
await _context.Database.MigrateAsync();
var filesystem = CreateFileSystem();
await Seed.SeedSettings(_context,
new DirectoryService(Substitute.For<ILogger<DirectoryService>>(), filesystem));
var setting = await _context.ServerSetting.Where(s => s.Key == ServerSettingKey.CacheDirectory).SingleAsync();
setting.Value = CacheDirectory;
setting = await _context.ServerSetting.Where(s => s.Key == ServerSettingKey.BackupDirectory).SingleAsync();
setting.Value = BackupDirectory;
_context.ServerSetting.Update(setting);
var lib = new LibraryBuilder("Manga")
.WithFolderPath(new FolderPathBuilder("C:/data/").Build())
.Build();
_context.AppUser.Add(new AppUser()
{
UserName = "majora2007",
Libraries = new List<Library>()
{
lib
}
});
return await _context.SaveChangesAsync() > 0;
}
private async Task ResetDb()
{
_context.Series.RemoveRange(_context.Series.ToList());
_context.AppUserRating.RemoveRange(_context.AppUserRating.ToList());
_context.Genre.RemoveRange(_context.Genre.ToList());
_context.CollectionTag.RemoveRange(_context.CollectionTag.ToList());
_context.Person.RemoveRange(_context.Person.ToList());
await _context.SaveChangesAsync();
}
private static MockFileSystem CreateFileSystem()
{
var fileSystem = new MockFileSystem();
fileSystem.Directory.SetCurrentDirectory("C:/kavita/");
fileSystem.AddDirectory("C:/kavita/config/");
fileSystem.AddDirectory(CacheDirectory);
fileSystem.AddDirectory(CoverImageDirectory);
fileSystem.AddDirectory(BackupDirectory);
fileSystem.AddDirectory(DataDirectory);
return fileSystem;
}
#endregion
private async Task SetupSeriesData()
private async Task SetupSeriesData(IUnitOfWork unitOfWork)
{
var library = new LibraryBuilder("GetFullSeriesByAnyName Manga", LibraryType.Manga)
.WithFolderPath(new FolderPathBuilder("C:/data/manga/").Build())
.WithFolderPath(new FolderPathBuilder(DataDirectory+"manga/").Build())
.WithSeries(new SeriesBuilder("The Idaten Deities Know Only Peace")
.WithLocalizedName("Heion Sedai no Idaten-tachi")
.WithFormat(MangaFormat.Archive)
@ -130,8 +40,8 @@ public class SeriesRepositoryTests
.Build())
.Build();
_unitOfWork.LibraryRepository.Add(library);
await _unitOfWork.CommitAsync();
unitOfWork.LibraryRepository.Add(library);
await unitOfWork.CommitAsync();
}
@ -142,11 +52,11 @@ public class SeriesRepositoryTests
[InlineData("Hitomi-chan wa Hitomishiri", MangaFormat.Archive, "", "Hitomi-chan is Shy With Strangers")]
public async Task GetFullSeriesByAnyName_Should(string seriesName, MangaFormat format, string localizedName, string? expected)
{
await ResetDb();
await SetupSeriesData();
var (unitOfWork, _, _) = await CreateDatabase();
await SetupSeriesData(unitOfWork);
var series =
await _unitOfWork.SeriesRepository.GetFullSeriesByAnyName(seriesName, localizedName,
await unitOfWork.SeriesRepository.GetFullSeriesByAnyName(seriesName, localizedName,
2, format, false);
if (expected == null)
{
@ -165,8 +75,8 @@ public class SeriesRepositoryTests
[InlineData(0, "", null)] // Case 3: Return null if neither exist
public async Task GetPlusSeriesDto_Should_PrioritizeAniListId_Correctly(int externalAniListId, string? webLinks, int? expectedAniListId)
{
// Arrange
await ResetDb();
var (unitOfWork, _, _) = await CreateDatabase();
await SetupSeriesData(unitOfWork);
var series = new SeriesBuilder("Test Series")
.WithFormat(MangaFormat.Archive)
@ -195,12 +105,12 @@ public class SeriesRepositoryTests
ReleaseYear = 2021
};
_unitOfWork.LibraryRepository.Add(library);
_unitOfWork.SeriesRepository.Add(series);
await _unitOfWork.CommitAsync();
unitOfWork.LibraryRepository.Add(library);
unitOfWork.SeriesRepository.Add(series);
await unitOfWork.CommitAsync();
// Act
var result = await _unitOfWork.SeriesRepository.GetPlusSeriesDto(series.Id);
var result = await unitOfWork.SeriesRepository.GetPlusSeriesDto(series.Id);
// Assert
Assert.NotNull(result);

View File

@ -182,18 +182,15 @@ public class LibraryController : BaseApiController
/// <returns></returns>
[Authorize(Policy = "RequireAdminRole")]
[HttpPost("has-files-at-root")]
public ActionResult<IDictionary<string, bool>> AnyFilesAtRoot(CheckForFilesInFolderRootsDto dto)
public ActionResult<IList<string>> AnyFilesAtRoot(CheckForFilesInFolderRootsDto dto)
{
var results = new Dictionary<string, bool>();
foreach (var root in dto.Roots)
{
results.TryAdd(root,
_directoryService
.GetFilesWithCertainExtensions(root, Parser.SupportedExtensions, SearchOption.TopDirectoryOnly)
.Any());
}
var foldersWithFilesAtRoot = dto.Roots
.Where(root => _directoryService
.GetFilesWithCertainExtensions(root, Parser.SupportedExtensions, SearchOption.TopDirectoryOnly)
.Any())
.ToList();
return Ok(results);
return Ok(foldersWithFilesAtRoot);
}
/// <summary>
@ -658,6 +655,7 @@ public class LibraryController : BaseApiController
library.EnableMetadata = dto.EnableMetadata;
library.RemovePrefixForSortName = dto.RemovePrefixForSortName;
library.InheritWebLinksFromFirstChapter = dto.InheritWebLinksFromFirstChapter;
library.DefaultLanguage = dto.DefaultLanguage;
library.LibraryFileTypes = dto.FileGroupTypes
.Select(t => new LibraryFileTypeGroup() {FileTypeGroup = t, LibraryId = library.Id})

View File

@ -14,6 +14,7 @@ using API.SignalR;
using Flurl.Http;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.StaticFiles;
using Microsoft.Extensions.Logging;
namespace API.Controllers;
@ -62,9 +63,15 @@ public class UploadController : BaseApiController
public async Task<ActionResult<string>> GetImageFromFile(UploadUrlDto dto)
{
var dateString = $"{DateTime.UtcNow.ToShortDateString()}_{DateTime.UtcNow.ToLongTimeString()}".Replace('/', '_').Replace(':', '_');
var format = _directoryService.FileSystem.Path.GetExtension(dto.Url.Split('?')[0]).Replace(".", string.Empty);
try
{
var format = await dto.Url.GetFileFormatAsync();
if (string.IsNullOrEmpty(format))
{
// Fallback to unreliable parsing if needed
format = _directoryService.FileSystem.Path.GetExtension(dto.Url.Split('?')[0]).Replace(".", string.Empty);
}
var path = await dto.Url
.DownloadFileAsync(_directoryService.TempDirectory, $"coverupload_{dateString}.{format}");
@ -499,7 +506,7 @@ public class UploadController : BaseApiController
var person = await _unitOfWork.PersonRepository.GetPersonById(uploadFileDto.Id);
if (person == null) return BadRequest(await _localizationService.Translate(User.GetUserId(), "person-doesnt-exist"));
await _coverDbService.SetPersonCoverByUrl(person, uploadFileDto.Url, true);
await _coverDbService.SetPersonCoverByUrl(person, uploadFileDto.Url, chooseBetterImage: false);
return Ok();
}
catch (Exception e)

View File

@ -121,6 +121,7 @@ public class UsersController : BaseApiController
existingPreferences.ColorScapeEnabled = preferencesDto.ColorScapeEnabled;
existingPreferences.BookReaderHighlightSlots = preferencesDto.BookReaderHighlightSlots;
existingPreferences.DataSaver = preferencesDto.DataSaver;
existingPreferences.CustomKeyBinds = preferencesDto.CustomKeyBinds;
var allLibs = (await _unitOfWork.LibraryRepository.GetLibrariesForUserIdAsync(user.Id))
.Select(l => l.Id).ToList();

View File

@ -2,8 +2,12 @@
public sealed record UpdateStreamPositionDto
{
public string StreamName { get; set; }
public int Id { get; set; }
public int FromPosition { get; set; }
public int ToPosition { get; set; }
public int Id { get; set; }
public string StreamName { get; set; }
/// <summary>
/// If the <see cref="ToPosition"/> has taken into account non-visible items
/// </summary>
public bool PositionIncludesInvisible { get; set; }
}

View File

@ -77,4 +77,6 @@ public sealed record LibraryDto
public bool RemovePrefixForSortName { get; set; } = false;
/// <inheritdoc cref="Library.InheritWebLinksFromFirstChapter"/>
public bool InheritWebLinksFromFirstChapter { get; init; }
/// <inheritdoc cref="Library.DefaultLanguage"/>
public string DefaultLanguage { get; init; }
}

View File

@ -48,6 +48,8 @@ public sealed record UpdateLibraryDto
/// <inheritdoc cref="Library.InheritWebLinksFromFirstChapter"/>
[Required]
public bool InheritWebLinksFromFirstChapter { get; init; }
/// <inheritdoc cref="Library.DefaultLanguage"/>
public string DefaultLanguage { get; init; }
/// <summary>
/// What types of files to allow the scanner to pickup
/// </summary>

View File

@ -40,6 +40,9 @@ public sealed record UserPreferencesDto
/// <inheritdoc cref="API.Entities.AppUserPreferences.DataSaver"/>
[Required]
public bool DataSaver { get; set; } = false;
/// <inheritdoc cref="API.Entities.AppUserPreferences.CustomKeyBinds"/>
[Required]
public Dictionary<KeyBindTarget, IList<KeyBind>> CustomKeyBinds { get; set; } = [];
/// <inheritdoc cref="API.Entities.AppUserPreferences.AniListScrobblingEnabled"/>
public bool AniListScrobblingEnabled { get; set; }

View File

@ -155,6 +155,9 @@ public sealed class DataContext : IdentityDbContext<AppUser, AppRole, int,
builder.Entity<Library>()
.Property(b => b.EnableMetadata)
.HasDefaultValue(true);
builder.Entity<Library>()
.Property(l => l.DefaultLanguage)
.HasDefaultValue(string.Empty);
builder.Entity<Chapter>()
.Property(b => b.WebLinks)
@ -293,6 +296,12 @@ public sealed class DataContext : IdentityDbContext<AppUser, AppRole, int,
.HasColumnType("TEXT")
.HasDefaultValue(new List<HighlightSlot>());
builder.Entity<AppUserPreferences>()
.Property(p => p.CustomKeyBinds)
.HasJsonConversion([])
.HasColumnType("TEXT")
.HasDefaultValue(new Dictionary<KeyBindTarget, IList<KeyBind>>());
builder.Entity<AppUser>()
.Property(user => user.IdentityProvider)
.HasDefaultValue(IdentityProvider.Kavita);

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,40 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace API.Data.Migrations
{
/// <inheritdoc />
public partial class LibraryDefaultLanguageCustomKeyBinds : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "DefaultLanguage",
table: "Library",
type: "TEXT",
nullable: true,
defaultValue: "");
migrationBuilder.AddColumn<string>(
name: "CustomKeyBinds",
table: "AppUserPreferences",
type: "TEXT",
nullable: true,
defaultValue: "{}");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "DefaultLanguage",
table: "Library");
migrationBuilder.DropColumn(
name: "CustomKeyBinds",
table: "AppUserPreferences");
}
}
}

View File

@ -571,6 +571,11 @@ namespace API.Data.Migrations
.HasColumnType("INTEGER")
.HasDefaultValue(true);
b.Property<string>("CustomKeyBinds")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("{}");
b.Property<bool>("DataSaver")
.HasColumnType("INTEGER");
@ -1466,6 +1471,11 @@ namespace API.Data.Migrations
b.Property<DateTime>("CreatedUtc")
.HasColumnType("TEXT");
b.Property<string>("DefaultLanguage")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("");
b.Property<bool>("EnableMetadata")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")

View File

@ -16,7 +16,7 @@ public interface IMediaErrorRepository
void Attach(MediaError error);
void Remove(MediaError error);
void Remove(IList<MediaError> errors);
Task<MediaError> Find(string filename);
Task<MediaError?> Find(string filename);
IEnumerable<MediaErrorDto> GetAllErrorDtosAsync();
Task<bool> ExistsAsync(MediaError error);
Task DeleteAll();

View File

@ -49,6 +49,7 @@ public interface IPersonRepository
Task<IList<PersonDto>> GetAllPeopleDtosForLibrariesAsync(int userId, List<int>? libraryIds = null, PersonIncludes includes = PersonIncludes.None);
Task<string?> GetCoverImageAsync(int personId);
Task<IList<string?>> GetAllCoverImagesAsync();
Task<string?> GetCoverImageByNameAsync(string name);
Task<IEnumerable<PersonRole>> GetRolesForPersonByName(int personId, int userId);
Task<PagedList<BrowsePersonDto>> GetBrowsePersonDtos(int userId, BrowsePersonFilterDto filter, UserParams userParams);
@ -167,6 +168,13 @@ public class PersonRepository : IPersonRepository
.SingleOrDefaultAsync();
}
public async Task<IList<string?>> GetAllCoverImagesAsync()
{
return await _context.Person
.Select(p => p.CoverImage)
.ToListAsync();
}
public async Task<string?> GetCoverImageByNameAsync(string name)
{
var normalized = name.ToNormalized();
@ -358,7 +366,8 @@ public class PersonRepository : IPersonRepository
.Select(cp => cp.Chapter)
.RestrictAgainstAgeRestriction(ageRating)
.RestrictByLibrary(userLibs)
.OrderBy(ch => ch.SortOrder)
.OrderBy(ch => ch.Volume.MinNumber) // Group/Sort volumes as well
.ThenBy(ch => ch.SortOrder)
.Take(20)
.ProjectTo<StandaloneChapterDto>(_mapper.ConfigurationProvider)
.ToListAsync();

View File

@ -1,4 +1,5 @@
using System;
#nullable enable
using System;
using System.Collections.Generic;
using API.Data;
using API.Entities.Enums;
@ -175,9 +176,15 @@ public class AppUserPreferences
/// <summary>
/// Enable data saver mode across Kavita, limiting information that is pre-fetched
/// </summary>
/// <remarks>Currenty only integrated into the PDF reader</remarks>
/// <remarks>Currently only integrated into the PDF reader</remarks>
public bool DataSaver { get; set; } = false;
/// <summary>
/// JSON dictionary mappings for custom keybinds across the web app.
/// Values are a list of key codes that need to be pressed at the same time for the keybind to be valid
/// </summary>
public Dictionary<KeyBindTarget, IList<KeyBind>> CustomKeyBinds { get; set; }
#endregion
#region KavitaPlus
@ -246,3 +253,13 @@ public class AppUserSocialPreferences
/// </summary>
public bool SocialIncludeUnknowns { get; set; } = true;
}
public sealed record KeyBind
{
public string Key { get; set; }
public bool Control { get; set; }
public bool Shift { get; set; }
public bool Meta { get; set; }
public bool Alt { get; set; }
public IList<string>? ControllerSequence { get; set; }
}

View File

@ -0,0 +1,39 @@
using System.ComponentModel;
namespace API.Entities.Enums.UserPreferences;
public enum KeyBindTarget
{
[Description(nameof(NavigateToSettings))]
NavigateToSettings = 0,
[Description(nameof(OpenSearch))]
OpenSearch = 1,
[Description(nameof(NavigateToScrobbling))]
NavigateToScrobbling = 2,
[Description(nameof(ToggleFullScreen))]
ToggleFullScreen = 3,
[Description(nameof(BookmarkPage))]
BookmarkPage = 4,
[Description(nameof(OpenHelp))]
OpenHelp = 5,
[Description(nameof(GoTo))]
GoTo = 6,
[Description(nameof(ToggleMenu))]
ToggleMenu = 7,
[Description(nameof(PageLeft))]
PageLeft = 8,
[Description(nameof(PageRight))]
PageRight = 9,
[Description(nameof(Escape))]
Escape = 10,
}

View File

@ -60,7 +60,10 @@ public class Library : IEntityDate, IHasCoverImage
/// Should series inherit web links from the first chapter/volume
/// </summary>
public bool InheritWebLinksFromFirstChapter { get; set; } = false;
/// <summary>
/// Language to assign to series if none is set in the metadata
/// </summary>
public string DefaultLanguage { get; set; } = "";
public DateTime Created { get; set; }
public DateTime LastModified { get; set; }

View File

@ -1,13 +1,48 @@
using System;
using System.Linq;
using System.Threading.Tasks;
using Flurl.Http;
using Kavita.Common;
using Kavita.Common.EnvironmentInfo;
using Microsoft.AspNetCore.StaticFiles;
namespace API.Extensions;
#nullable enable
public static class FlurlExtensions
{
private static readonly FileExtensionContentTypeProvider FileTypeProvider = new ();
/// <summary>
/// Makes a head request to the url, and parses the first content type header to determine the content type
/// </summary>
/// <param name="url"></param>
/// <returns></returns>
public static async Task<string?> GetFileFormatAsync(this string url)
{
var headResponse = await url.AllowHttpStatus("2xx").HeadAsync();
// TODO: Move to new Headers class after merge with progress branch
var contentTypeHeader = headResponse.Headers.FirstOrDefault("Content-Type");
if (string.IsNullOrEmpty(contentTypeHeader))
{
return null;
}
var contentType = contentTypeHeader.Split(";").FirstOrDefault();
if (string.IsNullOrEmpty(contentType))
{
return null;
}
// The mappings have legacy mappings like .jpe => image/jpeg. We reverse to get the newer stuff first
return FileTypeProvider.Mappings
.Reverse()
.FirstOrDefault(m => m.Value.Equals(contentType, StringComparison.OrdinalIgnoreCase))
.Key?.TrimStart('.');
}
public static IFlurlRequest WithKavitaPlusHeaders(this string request, string license, string? anilistToken = null)
{
return request

View File

@ -74,6 +74,7 @@ public class ImageService : IImageService
public const string SeriesCoverImageRegex = @"series\d+";
public const string CollectionTagCoverImageRegex = @"tag\d+";
public const string ReadingListCoverImageRegex = @"readinglist\d+";
public const string PersonCoverImageRegex = @"person\d+";
private const double WhiteThreshold = 0.95; // Colors with lightness above this are considered too close to white
private const double BlackThreshold = 0.25; // Colors with lightness below this are considered too close to black

View File

@ -618,7 +618,8 @@ public class SettingsService : ISettingsService
if (currentConfig.Authority != updateSettingsDto.OidcConfig.Authority)
{
if (!await IsValidAuthority(updateSettingsDto.OidcConfig.Authority + string.Empty))
// Only check validity if we're changing into a value that would be used
if (!string.IsNullOrEmpty(updateSettingsDto.OidcConfig.Authority) && !await IsValidAuthority(updateSettingsDto.OidcConfig.Authority + string.Empty))
{
throw new KavitaException("oidc-invalid-authority");
}

View File

@ -277,7 +277,18 @@ public class StreamService : IStreamService
if (stream.Order == dto.ToPosition) return;
var list = user!.SideNavStreams.OrderBy(s => s.Order).ToList();
OrderableHelper.ReorderItems(list, stream.Id, dto.ToPosition);
var wantedPosition = dto.ToPosition;
if (!dto.PositionIncludesInvisible)
{
var visibleItems = list.Where(i => i.Visible).ToList();
if (dto.ToPosition < 0 || dto.ToPosition >= visibleItems.Count) return;
var itemAtWantedPosition = visibleItems[dto.ToPosition];
wantedPosition = list.IndexOf(itemAtWantedPosition);
}
OrderableHelper.ReorderItems(list, stream.Id, wantedPosition);
user.SideNavStreams = list;
_unitOfWork.UserRepository.Update(user);

View File

@ -89,7 +89,7 @@ public class BackupService : IBackupService
await SendProgress(0F, "Started backup");
await SendProgress(0.1F, "Copying core files");
var dateString = $"{DateTime.UtcNow.ToShortDateString()}_{DateTime.UtcNow.ToLongTimeString()}".Replace("/", "_").Replace(":", "_");
var dateString = $"{DateTime.UtcNow.ToShortDateString()}_{DateTime.UtcNow:s}Z".Replace("/", "_").Replace(":", "_");
var zipPath = _directoryService.FileSystem.Path.Join(backupDirectory, $"kavita_backup_{dateString}_v{BuildInfo.Version}.zip");
if (File.Exists(zipPath))

View File

@ -21,7 +21,7 @@ public interface ICleanupService
{
Task Cleanup();
Task CleanupDbEntries();
void CleanupCacheAndTempDirectories();
Task CleanupCacheAndTempDirectories();
void CleanupCacheDirectory();
Task DeleteSeriesCoverImages();
Task DeleteChapterCoverImages();
@ -80,38 +80,34 @@ public class CleanupService : ICleanupService
}
_logger.LogInformation("Starting Cleanup");
var cleanupSteps = new List<(Func<Task>, string)>
{
(() => Task.Run(() => _directoryService.ClearDirectory(_directoryService.TempDirectory)), "Cleaning temp directory"),
(CleanupCacheAndTempDirectories, "Cleaning cache and temp directories"),
(CleanupBackups, "Cleaning old database backups"),
(ConsolidateProgress, "Consolidating Progress Events"),
(CleanupMediaErrors, "Consolidating Media Errors"),
(CleanupDbEntries, "Cleaning abandoned database rows"), // Cleanup DB before removing files linked to DB entries
(DeleteSeriesCoverImages, "Cleaning deleted series cover images"),
(DeleteChapterCoverImages, "Cleaning deleted chapter cover images"),
(() => Task.WhenAll(DeleteTagCoverImages(), DeleteReadingListCoverImages(), DeletePersonCoverImages()), "Cleaning deleted cover images"),
(CleanupLogs, "Cleaning old logs"),
(EnsureChapterProgressIsCapped, "Cleaning progress events that exceed 100%")
};
await SendProgress(0F, "Starting cleanup");
_logger.LogInformation("Cleaning temp directory");
_directoryService.ClearDirectory(_directoryService.TempDirectory);
for (var i = 0; i < cleanupSteps.Count; i++)
{
var (method, subtitle) = cleanupSteps[i];
var progress = (float)(i + 1) / (cleanupSteps.Count + 1);
await SendProgress(0.1F, "Cleaning temp directory");
CleanupCacheAndTempDirectories();
_logger.LogInformation("{Message}", subtitle);
await method();
await SendProgress(progress, subtitle);
}
await SendProgress(0.25F, "Cleaning old database backups");
_logger.LogInformation("Cleaning old database backups");
await CleanupBackups();
await SendProgress(0.35F, "Consolidating Progress Events");
await ConsolidateProgress();
await SendProgress(0.4F, "Consolidating Media Errors");
await CleanupMediaErrors();
await SendProgress(0.50F, "Cleaning deleted cover images");
_logger.LogInformation("Cleaning deleted cover images");
await DeleteSeriesCoverImages();
await SendProgress(0.6F, "Cleaning deleted cover images");
await DeleteChapterCoverImages();
await SendProgress(0.7F, "Cleaning deleted cover images");
await DeleteTagCoverImages();
await DeleteReadingListCoverImages();
await SendProgress(0.8F, "Cleaning old logs");
await CleanupLogs();
await SendProgress(0.9F, "Cleaning progress events that exceed 100%");
await EnsureChapterProgressIsCapped();
await SendProgress(0.95F, "Cleaning abandoned database rows");
await CleanupDbEntries();
await SendProgress(1F, "Cleanup finished");
_logger.LogInformation("Cleanup finished");
}
@ -174,10 +170,20 @@ public class CleanupService : ICleanupService
_directoryService.DeleteFiles(files.Where(file => !images.Contains(_directoryService.FileSystem.Path.GetFileName(file))));
}
/// <summary>
/// Remove all person cover images no longer associated with a person in the database
/// </summary>
public async Task DeletePersonCoverImages()
{
var images = await _unitOfWork.PersonRepository.GetAllCoverImagesAsync();
var files = _directoryService.GetFiles(_directoryService.CoverImageDirectory, ImageService.PersonCoverImageRegex);
_directoryService.DeleteFiles(files.Where(file => !images.Contains(_directoryService.FileSystem.Path.GetFileName(file))));
}
/// <summary>
/// Removes all files and directories in the cache and temp directory
/// </summary>
public void CleanupCacheAndTempDirectories()
public Task CleanupCacheAndTempDirectories()
{
_logger.LogInformation("Performing cleanup of Cache & Temp directories");
_directoryService.ExistOrCreate(_directoryService.CacheDirectory);
@ -194,6 +200,8 @@ public class CleanupService : ICleanupService
}
_logger.LogInformation("Cache and temp directory purged");
return Task.CompletedTask;
}
public void CleanupCacheDirectory()

View File

@ -30,7 +30,7 @@ public interface ICoverDbService
Task<string> DownloadPublisherImageAsync(string publisherName, EncodeFormat encodeFormat);
Task<string?> DownloadPersonImageAsync(Person person, EncodeFormat encodeFormat);
Task<string?> DownloadPersonImageAsync(Person person, EncodeFormat encodeFormat, string url);
Task SetPersonCoverByUrl(Person person, string url, bool fromBase64 = true, bool checkNoImagePlaceholder = false);
Task SetPersonCoverByUrl(Person person, string url, bool fromBase64 = true, bool checkNoImagePlaceholder = false, bool chooseBetterImage = true);
Task SetSeriesCoverByUrl(Series series, string url, bool fromBase64 = true, bool chooseBetterImage = false);
Task SetChapterCoverByUrl(Chapter chapter, string url, bool fromBase64 = true, bool chooseBetterImage = false);
}
@ -472,7 +472,8 @@ public class CoverDbService : ICoverDbService
/// <param name="url"></param>
/// <param name="fromBase64"></param>
/// <param name="checkNoImagePlaceholder">Will check against all known null image placeholders to avoid writing it</param>
public async Task SetPersonCoverByUrl(Person person, string url, bool fromBase64 = true, bool checkNoImagePlaceholder = false)
/// <param name="chooseBetterImage">If we check cross-reference the current cover for the better option</param>
public async Task SetPersonCoverByUrl(Person person, string url, bool fromBase64 = true, bool checkNoImagePlaceholder = false, bool chooseBetterImage = true)
{
if (!string.IsNullOrEmpty(url))
{
@ -504,7 +505,7 @@ public class CoverDbService : ICoverDbService
try
{
if (!string.IsNullOrEmpty(person.CoverImage))
if (!string.IsNullOrEmpty(person.CoverImage) && chooseBetterImage)
{
var existingPath = Path.Combine(_directoryService.CoverImageDirectory, person.CoverImage);
var betterImage = existingPath.GetBetterImage(tempFullPath)!;

View File

@ -331,9 +331,16 @@ public class ProcessSeries : IProcessSeries
series.Metadata.Summary = firstChapter.Summary;
}
if (!string.IsNullOrEmpty(firstChapter?.Language) && !series.Metadata.LanguageLocked)
if (!series.Metadata.LanguageLocked)
{
series.Metadata.Language = firstChapter.Language;
if (!string.IsNullOrEmpty(firstChapter?.Language))
{
series.Metadata.Language = firstChapter.Language;
}
else if (!string.IsNullOrEmpty(library.DefaultLanguage))
{
series.Metadata.Language = library.DefaultLanguage;
}
}
if (!string.IsNullOrEmpty(firstChapter?.WebLinks) && library.InheritWebLinksFromFirstChapter)

View File

@ -8,19 +8,20 @@ if [ ${#migrations[@]} -lt 2 ]; then
fi
second_last=$(basename "${migrations[1]}" .cs)
last=$(basename "${migrations[0]}" .cs)
last_name=$(echo "$last" | sed 's/^[0-9]*_//')
new_name=${1:-$last_name}
echo "Rolling back to: $second_last"
echo "Removing and re-adding: $last_name"
echo "Removing $last_name and re-adding as $new_name"
read -p "Continue? (y/N) " -n 1 -r
echo ""
if [[ $REPLY =~ ^[Yy]$ ]]; then
dotnet ef database update "$second_last" && \
dotnet ef migrations remove && \
dotnet ef migrations add "$last_name"
dotnet ef migrations add "$new_name"
else
echo "Cancelled"
exit 0

View File

@ -19,9 +19,9 @@ public static class Configuration
public const string DefaultOidcClientId = "kavita";
private static readonly string AppSettingsFilename = Path.Join("config", GetAppSettingFilename());
public static readonly string KavitaPlusApiUrl = Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT") == Environments.Development
public static readonly string KavitaPlusApiUrl = Environment.GetEnvironmentVariable("LOCAL_KAVITA_PLUS") == "TRUE"
? "http://localhost:5020" : "https://plus.kavitareader.com";
public static readonly string StatsApiUrl = "https://stats.kavitareader.com";
public const string StatsApiUrl = "https://stats.kavitareader.com";
public static int Port
{

View File

@ -35,6 +35,7 @@ export interface Library {
removePrefixForSortName: boolean;
collapseSeriesRelationships: boolean;
inheritWebLinksFromFirstChapter: boolean;
defaultLanguage: string;
libraryFileTypes: Array<FileTypeGroup>;
excludePatterns: Array<string>;
}

View File

@ -2,6 +2,7 @@ import {PageLayoutMode} from '../page-layout-mode';
import {SiteTheme} from './site-theme';
import {HighlightSlot} from "../../book-reader/_models/annotations/highlight-slot";
import {AgeRating} from "../metadata/age-rating";
import {KeyCode} from "../../_services/key-bind.service";
export interface Preferences {
@ -16,6 +17,7 @@ export interface Preferences {
bookReaderHighlightSlots: HighlightSlot[];
colorScapeEnabled: boolean;
dataSaver: boolean;
customKeyBinds: Partial<Record<KeyBindTarget, KeyBind[]>>;
// Kavita+
aniListScrobblingEnabled: boolean;
@ -34,3 +36,27 @@ export interface SocialPreferences {
socialIncludeUnknowns: boolean;
}
export interface KeyBind {
meta?: boolean;
control?: boolean;
alt?: boolean;
shift?: boolean;
controllerSequence?: readonly string[];
key: KeyCode;
}
export enum KeyBindTarget {
NavigateToSettings = 'NavigateToSettings',
OpenSearch = 'OpenSearch',
NavigateToScrobbling = 'NavigateToScrobbling',
ToggleFullScreen = 'ToggleFullScreen',
BookmarkPage = 'BookmarkPage',
OpenHelp = 'OpenHelp',
GoTo = "GoTo",
ToggleMenu = 'ToggleMenu',
PageLeft = 'PageLeft',
PageRight = 'PageRight',
Escape = 'Escape',
}

View File

@ -0,0 +1,40 @@
import { Pipe, PipeTransform } from '@angular/core';
import {KeyBind} from "../_models/preferences/preferences";
import {KeyCode} from "../_services/key-bind.service";
@Pipe({
name: 'keyBind'
})
export class KeyBindPipe implements PipeTransform {
private readonly customMappings: Partial<Record<KeyCode, string>> = {
[KeyCode.ArrowDown]: '↓',
[KeyCode.ArrowUp]: '↑',
[KeyCode.ArrowLeft]: '⇽',
[KeyCode.ArrowRight]: '⇾',
[KeyCode.Space]: 'space',
} as const;
transform(keyBind: KeyBind | undefined): string {
if (!keyBind) return '';
if (keyBind.controllerSequence) {
return keyBind.controllerSequence.join('+');
}
let keys: string[] = [];
if (keyBind.control) keys.push('Ctrl');
if (keyBind.shift) keys.push('Shift');
if (keyBind.alt) keys.push('Alt');
// TODO: Use new device code after progress merge?
const isMac = navigator.platform.includes('Mac');
if (keyBind.meta) keys.push(isMac ? '⌘' : 'Win');
keys.push(this.customMappings[keyBind.key] ?? keyBind.key.toUpperCase())
return keys.join('+')
}
}

View File

@ -0,0 +1,44 @@
import {Pipe, PipeTransform} from '@angular/core';
import {KeyBindTarget} from "../_models/preferences/preferences";
import {translate} from "@jsverse/transloco";
@Pipe({
name: 'keybindSettingDescription'
})
export class KeybindSettingDescriptionPipe implements PipeTransform {
prefix = 'keybind-setting-description-pipe';
transform(value: KeyBindTarget) {
switch (value) {
case KeyBindTarget.NavigateToSettings:
return this.create('key-bind-title-navigate-to-settings', 'key-bind-tooltip-navigate-to-settings');
case KeyBindTarget.OpenSearch:
return this.create('key-bind-title-open-search', 'key-bind-tooltip-open-search');
case KeyBindTarget.NavigateToScrobbling:
return this.create('key-bind-title-navigate-to-scrobbling', 'key-bind-tooltip-navigate-to-scrobbling');
case KeyBindTarget.ToggleFullScreen:
return this.create('key-bind-title-toggle-fullscreen', 'key-bind-tooltip-toggle-fullscreen');
case KeyBindTarget.BookmarkPage:
return this.create('key-bind-title-bookmark-page', 'key-bind-tooltip-bookmark-page');
case KeyBindTarget.OpenHelp:
return this.create('key-bind-title-open-help', 'key-bind-tooltip-open-help');
case KeyBindTarget.GoTo:
return this.create('key-bind-title-go-to', 'key-bind-tooltip-go-to');
case KeyBindTarget.ToggleMenu:
return this.create('key-bind-title-toggle-menu', 'key-bind-tooltip-toggle-menu');
case KeyBindTarget.PageLeft:
return this.create('key-bind-title-page-left', 'key-bind-tooltip-page-left');
case KeyBindTarget.PageRight:
return this.create('key-bind-title-page-right', 'key-bind-tooltip-page-right');
case KeyBindTarget.Escape:
return this.create('key-bind-title-escape', 'key-bind-tooltip-escape');
}
}
private create(titleKey: string, tooltipKey: string) {
return {title: translate(`${this.prefix}.${titleKey}`), tooltip: translate(`${this.prefix}.${tooltipKey}`)}
}
}

View File

@ -69,6 +69,7 @@ export class AccountService {
public readonly currentUserSignal = toSignal(this.currentUser$);
public readonly userId = computed(() => this.currentUserSignal()?.id);
public readonly isReadOnly = computed(() => this.currentUserSignal()?.roles.includes(Role.ReadOnly) ?? true);
/**
* SetTimeout handler for keeping track of refresh token call

View File

@ -0,0 +1,146 @@
import {Injectable, signal} from '@angular/core';
import {Subject} from "rxjs";
interface GamePadKeyEvent {
/**
* Buttons currently pressed
*/
pressedButtons: readonly GamePadButtonKey[];
/**
* If the event is keydown, all newly added buttons
*/
newButtons?: readonly GamePadButtonKey[];
/**
* If the event is keyup, all removed buttons
*/
removedButtons?: readonly GamePadButtonKey[];
}
export enum GamePadButtonKey {
A = 'A',
B = 'B',
X = 'X',
Y = 'Y',
LB = 'LB',
RB = 'RB',
LT = 'LT',
RT = 'RT',
Back = 'Back',
Start = 'Start',
AxisLeft = 'Axis-Left', // Left Stick Button
AxisRight = 'Axis-Right', // Right Stick Button
DPadUp = 'DPad-Up',
DPadDown = 'DPad-Down',
DPadLeft = 'DPad-Left',
DPadRight = 'DPad-Right',
Power = 'Power', // Guide / Home / Xbox Button
}
/**
* Button order follows W3C standard mapping:
* https://www.w3.org/TR/gamepad/#remapping
*/
export const GAMEPAD_BUTTON_KEYS: readonly GamePadButtonKey[] = [
GamePadButtonKey.A,
GamePadButtonKey.B,
GamePadButtonKey.X,
GamePadButtonKey.Y,
GamePadButtonKey.LB,
GamePadButtonKey.RB,
GamePadButtonKey.LT,
GamePadButtonKey.RT,
GamePadButtonKey.Back,
GamePadButtonKey.Start,
GamePadButtonKey.AxisLeft,
GamePadButtonKey.AxisRight,
GamePadButtonKey.DPadUp,
GamePadButtonKey.DPadDown,
GamePadButtonKey.DPadLeft,
GamePadButtonKey.DPadRight,
GamePadButtonKey.Power,
];
/**
* GamePadService provides a wrapper around the native GamePad browser API as it has a bad DX
*/
@Injectable({
providedIn: 'root'
})
export class GamePadService {
protected readonly _gamePads = signal<Set<Gamepad>>(new Set());
public readonly gamePads = this._gamePads.asReadonly();
private readonly keyUpEvents = new Subject<GamePadKeyEvent>();
public readonly keyUpEvents$ = this.keyUpEvents.asObservable();
private readonly keyDownEvents = new Subject<GamePadKeyEvent>();
public readonly keyDownEvents$ = this.keyDownEvents.asObservable();
private lastState = new Map<number, readonly GamePadButtonKey[]>();
private pollId?: number;
constructor() {
window.addEventListener('gamepadconnected', (e: GamepadEvent) => {
const startLoop = this.gamePads().size === 0;
this._gamePads.update(s => new Set(s).add(e.gamepad));
if (startLoop) {
this.poll();
}
});
window.addEventListener('gamepaddisconnected', (e: GamepadEvent) => {
this._gamePads.update(s => {
const newSet = new Set(s);
newSet.delete(e.gamepad);
return newSet;
});
this.lastState.delete(e.gamepad.index);
if (this.gamePads().size == 0 && this.pollId) {
cancelAnimationFrame(this.pollId);
}
});
}
private poll() {
if (this.gamePads().size === 0) {
return;
}
for (const gamePad of this.gamePads()) {
const pressed: GamePadButtonKey[] = [];
for (let idx = 0; idx < gamePad.buttons.length; idx++) {
if (gamePad.buttons[idx].pressed) {
pressed.push(GAMEPAD_BUTTON_KEYS[idx]);
}
}
const last = this.lastState.get(gamePad.index) ?? [];
const newButtons = pressed.filter(btn => !last.includes(btn));
const removedButtons = last.filter(btn => !pressed.includes(btn));
if (newButtons.length > 0) {
this.keyDownEvents.next({
pressedButtons: [...pressed],
newButtons: [...newButtons],
});
}
if (removedButtons.length > 0) {
this.keyUpEvents.next({
pressedButtons: [...pressed],
removedButtons: [...removedButtons],
});
}
this.lastState.set(gamePad.index, [...pressed]);
}
this.pollId = requestAnimationFrame(() => this.poll());
}
}

View File

@ -0,0 +1,441 @@
import {computed, DestroyRef, inject, Injectable, signal} from '@angular/core';
import {AccountService, Role} from "./account.service";
import {KeyBind, KeyBindTarget} from "../_models/preferences/preferences";
import {DOCUMENT} from "@angular/common";
import {filter, finalize, Observable, of, Subject, tap, withLatestFrom} from "rxjs";
import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
import {map} from "rxjs/operators";
import {GamePadService} from "./game-pad.service";
/**
* Codes as returned by KeyBoardEvent.key.toLowerCase()
*/
export enum KeyCode {
KeyA = "a",
KeyB = "b",
KeyC = "c",
KeyD = "d",
KeyE = "e",
KeyF = "f",
KeyG = "g",
KeyH = "h",
KeyI = "i",
KeyJ = "j",
KeyK = "k",
KeyL = "l",
KeyM = "m",
KeyN = "n",
KeyO = "o",
KeyP = "p",
KeyQ = "q",
KeyR = "r",
KeyS = "s",
KeyT = "t",
KeyU = "u",
KeyV = "v",
KeyW = "w",
KeyX = "x",
KeyY = "y",
KeyZ = "z",
Digit0 = "0",
Digit1 = "1",
Digit2 = "2",
Digit3 = "3",
Digit4 = "4",
Digit5 = "5",
Digit6 = "6",
Digit7 = "7",
Digit8 = "8",
Digit9 = "9",
ArrowUp = "arrowup",
ArrowDown = "arrowdown",
ArrowLeft = "arrowleft",
ArrowRight = "arrowright",
Comma = ',',
Space = ' ',
Escape = 'escape',
Control = "control",
Alt = "alt",
Shift = "shift",
Meta = "meta",
Empty = '',
}
/**
* KeyCodes we consider modifiers
*/
export const ModifierKeyCodes: KeyCode[] = [
KeyCode.Control,
KeyCode.Alt,
KeyCode.Shift,
KeyCode.Meta,
];
/**
* Emitted if a keybind has been recorded
*/
export interface KeyBindEvent {
/**
* Target of the event
*/
target: KeyBindTarget;
/**
* Overriding this value must be done in the sync callback of your
* observable. When true after all observables have completed, will cancel the event that triggered it
*
* @default true
*/
triggered: boolean;
/**
* If the original event's target was editable. This is only relevant for KeyBoard events, GamePad events do not
* contain this information
*/
inEditableElement: boolean;
}
/**
* Add any keybinds in this array which cannot be used users ever
* Example: Page refresh
*/
const ReservedKeyBinds: KeyBind[] = [
{control: true, key: KeyCode.KeyR},
{meta: true, key: KeyCode.KeyR},
];
/**
* This record should hold all KeyBinds Kavita has to offer, with their default combination(s).
* To add a new keybind to the system, add it here and in the backend enum. Add it to the KeyBindGroups
* array to be displayed on the settings page
*/
export const DefaultKeyBinds: Readonly<Record<KeyBindTarget, KeyBind[]>> = {
[KeyBindTarget.NavigateToSettings]: [{meta: true, key: KeyCode.Comma}],
[KeyBindTarget.OpenSearch]: [{control: true, key: KeyCode.KeyK}, {meta: true, key: KeyCode.KeyK}],
[KeyBindTarget.NavigateToScrobbling]: [],
[KeyBindTarget.ToggleFullScreen]: [{key: KeyCode.KeyF}],
[KeyBindTarget.BookmarkPage]: [{key: KeyCode.KeyB, control: true}],
[KeyBindTarget.OpenHelp]: [{key: KeyCode.KeyH}],
[KeyBindTarget.GoTo]: [{key: KeyCode.KeyG}],
[KeyBindTarget.ToggleMenu]: [{key: KeyCode.Space}],
[KeyBindTarget.PageLeft]: [{key: KeyCode.ArrowLeft}, {key: KeyCode.ArrowUp}],
[KeyBindTarget.PageRight]: [{key: KeyCode.ArrowRight}, {key: KeyCode.ArrowDown}],
[KeyBindTarget.Escape]: [{key: KeyCode.Escape}]
} as const;
type KeyBindGroup = {
title: string,
elements: {
target: KeyBindTarget,
roles?: Role[];
restrictedRoles?: Role[],
kavitaPlus?: boolean;
}[];
}
export const KeyBindGroups: KeyBindGroup[] = [
{
title: 'global-header',
elements: [
{target: KeyBindTarget.NavigateToSettings},
{target: KeyBindTarget.OpenSearch},
{target: KeyBindTarget.NavigateToScrobbling, kavitaPlus: true},
{target: KeyBindTarget.Escape},
]
},
{
title: 'readers-header',
elements: [
{target: KeyBindTarget.ToggleFullScreen},
{target: KeyBindTarget.BookmarkPage},
{target: KeyBindTarget.OpenHelp},
{target: KeyBindTarget.GoTo},
{target: KeyBindTarget.ToggleMenu},
{target: KeyBindTarget.PageRight},
{target: KeyBindTarget.PageLeft},
],
}
];
interface RegisterListenerOptions {
/**
* @default false
*/
fireInEditable?: boolean;
/**
* @default of(true)
*/
condition$?: Observable<boolean>;
/**
* @default true
*/
markAsTriggered?: boolean;
}
@Injectable({
providedIn: 'root'
})
export class KeyBindService {
private readonly accountService = inject(AccountService);
private readonly gamePadService = inject(GamePadService);
private readonly document = inject(DOCUMENT);
/**
* Global disable switch for the keybind listener. Make sure you enable again after using
* so keybinds don't stop working across the app.
*/
public readonly disabled = signal(false);
/**
* Valid custom keybinds as configured by the authenticated user
* @private
*/
private readonly customKeyBinds = computed(() => {
const customKeyBinds = this.accountService.currentUserSignal()?.preferences.customKeyBinds ?? {};
return Object.fromEntries(Object.entries(customKeyBinds).filter(([target, _]) => {
return DefaultKeyBinds[target as KeyBindTarget] !== undefined; // Filter out unused or old targets
}))
});
/**
* All key binds for which the target is currently active
* @private
*/
private readonly activeKeyBinds = computed<Record<KeyBindTarget, KeyBind[]>>(() => {
const customKeyBindsRaw = this.customKeyBinds();
const activeTargets = this.activeTargetsSet();
const customKeyBinds: Partial<Record<KeyBindTarget, KeyBind[]>> = {};
for (const [target, combos] of Object.entries(customKeyBindsRaw) as [KeyBindTarget, KeyBind[]][]) {
if (activeTargets.has(target)) {
customKeyBinds[target] = combos.filter(combo => !this.isReservedKeyBind(combo));
}
}
return {
...DefaultKeyBinds,
...customKeyBinds,
} satisfies Record<KeyBindTarget, readonly KeyBind[]>;
});
/**
* A record of all possible keybinds in Kavita, as configured by the user
*/
public readonly allKeyBinds = computed<Record<KeyBindTarget, KeyBind[]>>(() => {
const customKeyBinds = this.customKeyBinds();
return {
...DefaultKeyBinds,
...customKeyBinds,
} satisfies Record<KeyBindTarget, readonly KeyBind[]>;
});
/**
* A set of all keys used in all keybinds, other keys should not be tracked
* @private
*/
private readonly listenedKeys = computed(() => {
const keyBinds = this.activeKeyBinds();
const combos = Object.values(keyBinds);
const allKeys = combos.flatMap(c => c).flatMap(c => c).map(kb => kb.key);
return new Set(allKeys);
});
private readonly activeTargets = signal<KeyBindTarget[]>([]);
private readonly activeTargetsSet = computed(() => new Set(this.activeTargets()));
/**
* We do not allow subscribing to the events$ directly, as there is some extra state management for performance
* reasons. See registerListener for details
* @private
*/
private readonly eventsSubject = new Subject<KeyBindEvent>();
private readonly events$ = this.eventsSubject.asObservable();
constructor() {
// We use keydown as to intercept before native browser keybinds, in case we want to cancel the event
this.document.addEventListener('keydown', e => this.handleKeyEvent(e));
this.gamePadService.keyDownEvents$.pipe(
map(e => {
return {
key: KeyCode.Empty,
controllerSequence: e.pressedButtons,
} as KeyBind;
}),
tap(kb => this.checkForKeyBind(kb)),
).subscribe();
}
private handleKeyEvent(event: KeyboardEvent) {
if (this.disabled()) return;
const eventKey = event.key.toLowerCase() as KeyCode;
if (!this.listenedKeys().has(eventKey)) return;
const activeKeyBind: KeyBind = {
key: eventKey,
control: event.ctrlKey,
meta: event.metaKey,
shift: event.shiftKey,
alt: event.altKey,
};
this.checkForKeyBind(activeKeyBind, event);
}
private checkForKeyBind(activeKeyBind: KeyBind, event?: KeyboardEvent) {
const activeKeyBinds = this.activeKeyBinds();
for (const [target, keybinds] of Object.entries(activeKeyBinds)) {
for (const keybind of keybinds) {
if (!this.areKeyBindsEqual(activeKeyBind, keybind)) continue;
const keyBindEvent: KeyBindEvent = {
target: target as KeyBindTarget,
triggered: false,
inEditableElement: event ? this.isEditableTarget(event.target) : false,
};
this.eventsSubject.next(keyBindEvent);
if (event && keyBindEvent.triggered) {
event.preventDefault();
event.stopPropagation();
}
}
}
}
/**
* Key events while in this target should be ignored
* @param target
* @private
*/
private isEditableTarget(target: EventTarget | null): boolean {
if (!(target instanceof HTMLElement)) return false;
if (target instanceof HTMLInputElement) return true;
if (target instanceof HTMLTextAreaElement) return true;
return target.isContentEditable;
}
/**
* Register a listener for targets. When a match is found will set KeyBindEvent#triggered to true
* @param destroyRef$ destroy ref used for lifetime management
* @param callback
* @param targetFilter
* @param options
*/
public registerListener(
destroyRef$: DestroyRef,
callback: (e: KeyBindEvent) => void,
targetFilter: KeyBindTarget[],
options?: RegisterListenerOptions,
) {
const {
fireInEditable = false,
condition$ = of(true),
markAsTriggered = true,
} = options ?? {};
this.activeTargets.update(s => [...s, ...targetFilter]);
this.events$.pipe(
takeUntilDestroyed(destroyRef$),
filter(e => !e.inEditableElement || fireInEditable),
filter(e => targetFilter.includes(e.target)),
withLatestFrom(condition$),
filter(([_, ok]) => ok),
map(([e, _]) => e),
tap(e => {
if (markAsTriggered) {
e.triggered = true; // Set before callback so consumers may override
}
callback(e);
}),
finalize(() => { // Remove all targets when the consumer has finished
this.activeTargets.update(targets => {
const updated = [...targets];
// Remove only once in case others have registered the same target
targetFilter.forEach(target => this.removeOnce(updated, target));
return updated;
});
}),
).subscribe();
}
/**
* Remove the first occurrence of element in the array
* @param array
* @param element
* @private
*/
private removeOnce<T>(array: T[], element: T) {
const index = array.indexOf(element);
if (index !== -1) {
array.splice(index, 1);
}
}
/**
* Returns true if the keybinds are semantically equal
* @param k1
* @param k2
*/
public areKeyBindsEqual(k1: KeyBind, k2: KeyBind) {
// If a controller sequence is present on either, it takes full and the only priority
if (k1.controllerSequence || k2.controllerSequence) {
return k1.controllerSequence?.every(k => k2.controllerSequence?.includes(k)) || false;
}
return (
(k1.alt ?? false) === (k2.alt ?? false) &&
(k1.shift ?? false) === (k2.shift ?? false) &&
(k1.control ?? false) === (k2.control ?? false) &&
(k1.meta ?? false) === (k2.meta ?? false) &&
k1.key === k2.key
);
}
/**
* Checks the given keybind against the ReservedKeyBinds list. If true, keybind should be considered invalid and unusable
* @param keyBind
*/
public isReservedKeyBind(keyBind: KeyBind) {
for (let reservedKeyBind of ReservedKeyBinds) {
if (this.areKeyBindsEqual(reservedKeyBind, keyBind)) {
return true;
}
}
return false;
}
/**
* Returns true if the given keyBinds are equal to the default ones for the target, and can be skipped when saving to user preferences
* @param target
* @param keyBinds
*/
public isDefaultKeyBinds(target: KeyBindTarget, keyBinds: KeyBind[]) {
const defaultKeyBinds = DefaultKeyBinds[target];
if (!defaultKeyBinds) {
throw Error("Could not find default keybinds for " + target)
}
if (defaultKeyBinds.length !== keyBinds.length) return false;
return keyBinds.every(keyBind =>
defaultKeyBinds.some(defaultKeyBind => this.areKeyBindsEqual(defaultKeyBind, keyBind))
);
}
}

View File

@ -78,7 +78,7 @@ export class LibraryService {
}
hasFilesAtRoot(roots: Array<string>) {
return this.httpClient.post<{[key: string]: boolean}>(this.baseUrl + 'library/has-files-at-root', {roots});
return this.httpClient.post<Array<string>>(this.baseUrl + 'library/has-files-at-root', {roots});
}
getJumpBar(libraryId: number) {

View File

@ -1,6 +1,6 @@
import {DOCUMENT} from '@angular/common';
import {DestroyRef, inject, Injectable, Renderer2, RendererFactory2, RendererStyleFlags2} from '@angular/core';
import {filter, ReplaySubject, take} from 'rxjs';
import {filter, ReplaySubject, take, tap} from 'rxjs';
import {HttpClient} from "@angular/common/http";
import {environment} from "../../environments/environment";
import {SideNavStream} from "../_models/sidenav/sidenav-stream";
@ -129,8 +129,8 @@ export class NavService {
return this.httpClient.get<Array<SideNavStream>>(this.baseUrl + 'stream/sidenav?visibleOnly=' + visibleOnly);
}
updateSideNavStreamPosition(streamName: string, sideNavStreamId: number, fromPosition: number, toPosition: number) {
return this.httpClient.post(this.baseUrl + 'stream/update-sidenav-position', {streamName, id: sideNavStreamId, fromPosition, toPosition}, TextResonse);
updateSideNavStreamPosition(streamName: string, sideNavStreamId: number, fromPosition: number, toPosition: number, positionIncludesInvisible: boolean = true) {
return this.httpClient.post(this.baseUrl + 'stream/update-sidenav-position', {streamName, id: sideNavStreamId, fromPosition, toPosition, positionIncludesInvisible}, TextResonse);
}
updateSideNavStream(stream: SideNavStream) {

View File

@ -7,7 +7,7 @@ import {TranslocoDirective} from "@jsverse/transloco";
import {AccountService} from "../../_services/account.service";
import {Chapter} from "../../_models/chapter";
import {LibraryType} from "../../_models/library/library";
import {TypeaheadSettings} from "../../typeahead/_models/typeahead-settings";
import {setupLanguageSettings, TypeaheadSettings} from "../../typeahead/_models/typeahead-settings";
import {Tag} from "../../_models/tag";
import {Language} from "../../_models/metadata/language";
import {Person, PersonRole} from "../../_models/metadata/person";
@ -125,14 +125,13 @@ export class EditChapterModalComponent implements OnInit {
coverImageReset = false;
tagsSettings: TypeaheadSettings<Tag> = new TypeaheadSettings();
languageSettings: TypeaheadSettings<Language> = new TypeaheadSettings();
languageSettings: TypeaheadSettings<Language> | null = null;
peopleSettings: {[PersonRole: string]: TypeaheadSettings<Person>} = {};
genreSettings: TypeaheadSettings<Genre> = new TypeaheadSettings();
tags: Tag[] = [];
genres: Genre[] = [];
ageRatings: Array<AgeRatingDto> = [];
validLanguages: Array<Language> = [];
tasks = this.actionFactoryService.getActionablesForSettingsPage(this.actionFactoryService.getChapterActions(this.runTask.bind(this)), blackList);
/**
@ -189,10 +188,9 @@ export class EditChapterModalComponent implements OnInit {
this.metadataService.getAllValidLanguages().pipe(
tap(validLanguages => {
this.validLanguages = validLanguages;
this.languageSettings = setupLanguageSettings(true, this.utilityService, validLanguages, this.chapter.language);
this.cdRef.markForCheck();
}),
switchMap(_ => this.setupLanguageTypeahead())
).subscribe();
this.metadataService.getAllAgeRatings().subscribe(ratings => {
@ -313,7 +311,6 @@ export class EditChapterModalComponent implements OnInit {
this.setupTagSettings(),
this.setupGenreTypeahead(),
this.setupPersonTypeahead(),
this.setupLanguageTypeahead()
]).subscribe(results => {
this.cdRef.markForCheck();
});
@ -383,34 +380,6 @@ export class EditChapterModalComponent implements OnInit {
return of(true);
}
setupLanguageTypeahead() {
this.languageSettings.minCharacters = 0;
this.languageSettings.multiple = false;
this.languageSettings.id = 'language';
this.languageSettings.unique = true;
this.languageSettings.showLocked = true;
this.languageSettings.addIfNonExisting = false;
this.languageSettings.compareFn = (options: Language[], filter: string) => {
return options.filter(m => this.utilityService.filter(m.title, filter));
}
this.languageSettings.compareFnForAdd = (options: Language[], filter: string) => {
return options.filter(m => this.utilityService.filterMatches(m.title, filter));
}
this.languageSettings.fetchFn = (filter: string) => of(this.validLanguages)
.pipe(map(items => this.languageSettings.compareFn(items, filter)));
this.languageSettings.selectionCompareFn = (a: Language, b: Language) => {
return a.isoCode == b.isoCode;
}
this.languageSettings.trackByIdentityFn = (index, value) => value.isoCode;
const l = this.validLanguages.find(l => l.isoCode === this.chapter.language);
if (l !== undefined) {
this.languageSettings.savedData = l;
}
return of(true);
}
updateFromPreset(id: string, presetField: Array<Person> | undefined, role: PersonRole) {
const personSettings = this.createBlankPersonSettings(id, role)

View File

@ -55,6 +55,9 @@
@if (formControl.errors && formControl.errors.requiredIf) {
<div>{{t('other-field-required', {name: 'clientId', other: formControl.errors.requiredIf.other})}}</div>
}
@if (formControl.errors && formControl.errors.requiredIfOtherInvalid) {
<div>{{t('other-field-invalid', {other: formControl.errors.requiredIfOtherInvalid.other})}}</div>
}
</div>
}
@ -67,7 +70,7 @@
@if (settingsForm.get('secret'); as formControl) {
<app-setting-item [title]="t('secret-label')" [subtitle]="t('secret-tooltip')">
<ng-template #view>
{{formControl.value | defaultValue}}
{{formControl.value | slice:0:40 | defaultValue}}
</ng-template>
<ng-template #edit>
<input id="oid-secret" aria-describedby="oidc-secret-validations" class="form-control"
@ -79,6 +82,9 @@
@if (formControl.errors && formControl.errors.requiredIf) {
<div>{{t('other-field-required', {name: 'secret', other: formControl.errors.requiredIf.other})}}</div>
}
@if (formControl.errors && formControl.errors.requiredIfOtherInvalid) {
<div>{{t('other-field-invalid', {other: formControl.errors.requiredIfOtherInvalid.other})}}</div>
}
</div>
}

View File

@ -44,6 +44,7 @@ import {
SettingMultiTextFieldComponent
} from "../../settings/_components/setting-multi-text-field/setting-multi-text-field.component";
import {environment} from "../../../environments/environment";
import {SlicePipe} from "@angular/common";
type OidcFormGroup = FormGroup<{
autoLogin: FormControl<boolean>;
@ -75,7 +76,8 @@ type OidcFormGroup = FormGroup<{
SafeHtmlPipe,
DefaultValuePipe,
SettingMultiCheckBox,
SettingMultiTextFieldComponent
SettingMultiTextFieldComponent,
SlicePipe
],
templateUrl: './manage-open-idconnect.component.html',
styleUrl: './manage-open-idconnect.component.scss',
@ -166,8 +168,16 @@ export class ManageOpenIDConnectComponent implements OnInit {
return newSettings;
}
save(showConfirmation: boolean = false) {
if (!this.settingsForm.valid || !this.serverSettings || !this.oidcSettings()) return;
save(showToasts: boolean = false) {
if (!this.settingsForm.valid) {
if (showToasts) {
this.toastr.error(translate('errors.invalid-form'));
}
return;
}
if (!this.serverSettings || !this.oidcSettings()) return;
const newSettings = this.packData();
this.settingsService.updateServerSettings(newSettings).subscribe({
@ -176,7 +186,7 @@ export class ManageOpenIDConnectComponent implements OnInit {
this.oidcSettings.set(data.oidcConfig);
this.cdRef.markForCheck();
if (showConfirmation) {
if (showToasts) {
this.toastr.success(translate('manage-oidc-connect.save-success'))
}
},
@ -219,7 +229,9 @@ export class ManageOpenIDConnectComponent implements OnInit {
const otherControl = this.settingsForm.get(other);
if (!otherControl) return null;
if (otherControl.invalid) return null;
if (otherControl.invalid) {
return { 'requiredIfOtherInvalid': { 'other': other, 'errors': otherControl.errors } }
}
const v = otherControl.value;
if (!v || v.length === 0) return null;

View File

@ -5,7 +5,6 @@ import {
DestroyRef,
ElementRef,
EventEmitter,
HostListener,
inject,
Input,
model,
@ -18,11 +17,12 @@ import {FormControl, FormGroup, ReactiveFormsModule, Validators} from "@angular/
import {ReaderService} from "../../../_services/reader.service";
import {ToastrService} from "ngx-toastr";
import {translate, TranslocoDirective} from "@jsverse/transloco";
import {KEY_CODES} from "../../../shared/_services/utility.service";
import {EpubReaderMenuService} from "../../../_services/epub-reader-menu.service";
import {Annotation} from "../../_models/annotations/annotation";
import {isMobileChromium} from "../../../_helpers/browser";
import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
import {KeyBindService} from "../../../_services/key-bind.service";
import {KeyBindTarget} from "../../../_models/preferences/preferences";
enum BookLineOverlayMode {
None = 0,
@ -64,18 +64,7 @@ export class BookLineOverlayComponent implements OnInit {
private readonly toastr = inject(ToastrService);
private readonly elementRef = inject(ElementRef);
private readonly epubMenuService = inject(EpubReaderMenuService);
@HostListener('window:keydown', ['$event'])
handleKeyPress(event: KeyboardEvent) {
if (event.key === KEY_CODES.ESC_KEY) {
this.reset();
this.cdRef.markForCheck();
event.stopPropagation();
event.preventDefault();
return;
}
}
private readonly keyBindService = inject(KeyBindService);
ngOnInit() {
@ -90,6 +79,12 @@ export class BookLineOverlayComponent implements OnInit {
// Fallback to mouse/touch events
this.setupLegacyEventListeners();
}
this.keyBindService.registerListener(
this.destroyRef,
() => this.reset(),
[KeyBindTarget.Escape],
);
}
private setupPointerEventListener(): void {

View File

@ -8,7 +8,6 @@ import {
effect,
ElementRef,
EventEmitter,
HostListener,
inject,
model,
OnDestroy,
@ -31,7 +30,7 @@ import {CHAPTER_ID_DOESNT_EXIST, CHAPTER_ID_NOT_FETCHED, ReaderService} from 'sr
import {SeriesService} from 'src/app/_services/series.service';
import {DomSanitizer, SafeHtml, Title} from '@angular/platform-browser';
import {BookService} from '../../_services/book.service';
import {Breakpoint, KEY_CODES, UtilityService} from 'src/app/shared/_services/utility.service';
import {Breakpoint, UtilityService} from 'src/app/shared/_services/utility.service';
import {BookChapterItem} from '../../_models/book-chapter-item';
import {animate, state, style, transition, trigger} from '@angular/animations';
import {Stack} from 'src/app/shared/data-structures/stack';
@ -69,6 +68,8 @@ import {environment} from "../../../../environments/environment";
import {LoadPageEvent} from "../_drawers/view-bookmarks-drawer/view-bookmark-drawer.component";
import {FontService} from "../../../_services/font.service";
import afterFrame from "afterframe";
import {KeyBindService} from "../../../_services/key-bind.service";
import {KeyBindTarget} from "../../../_models/preferences/preferences";
interface HistoryPoint {
@ -158,6 +159,7 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy {
private readonly layoutService = inject(LayoutMeasurementService);
private readonly colorscapeService = inject(ColorscapeService);
private readonly fontService = inject(FontService);
private readonly keyBindService = inject(KeyBindService);
libraryId!: number;
seriesId!: number;
@ -652,6 +654,56 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy {
}
}
});
this.keyBindService.registerListener(
this.destroyRef,
async (e) => {
const activeElement = this.document.activeElement as HTMLElement;
const isInputFocused = activeElement.tagName === 'INPUT'
|| activeElement.tagName === 'TEXTAREA' ||
activeElement.contentEditable === 'true' ||
activeElement.closest('.ql-editor'); // Quill editor class
if (isInputFocused) {
e.triggered = false;
return;
}
switch (e.target) {
case KeyBindTarget.PageLeft:
this.movePage(this.readingDirection() === ReadingDirection.LeftToRight ? PAGING_DIRECTION.BACKWARDS : PAGING_DIRECTION.FORWARD);
break;
case KeyBindTarget.PageRight:
this.movePage(this.readingDirection() === ReadingDirection.LeftToRight ? PAGING_DIRECTION.FORWARD : PAGING_DIRECTION.BACKWARDS);
break;
case KeyBindTarget.Escape:
const isHighlighting = window.getSelection()?.toString() != '';
if (isHighlighting && this.isLineOverlayOpen()) return;
this.closeReader();
break;
case KeyBindTarget.GoTo:
await this.goToPage();
break;
case KeyBindTarget.ToggleFullScreen:
this.applyFullscreen();
break;
case KeyBindTarget.ToggleMenu:
this.actionBarVisible.update(x => !x);
break;
}
},
[KeyBindTarget.PageLeft, KeyBindTarget.PageRight, KeyBindTarget.Escape, KeyBindTarget.GoTo,
KeyBindTarget.ToggleFullScreen, KeyBindTarget.ToggleMenu],
);
this.keyBindService.registerListener(
this.destroyRef,
() => {
this.toggleDrawer();
},
[KeyBindTarget.NavigateToSettings]
);
}
/**
@ -996,41 +1048,6 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy {
}
}
@HostListener('window:keydown', ['$event'])
async handleKeyPress(event: KeyboardEvent) {
const activeElement = document.activeElement as HTMLElement;
const isInputFocused = activeElement.tagName === 'INPUT'
|| activeElement.tagName === 'TEXTAREA' ||
activeElement.contentEditable === 'true' ||
activeElement.closest('.ql-editor'); // Quill editor class
if (isInputFocused) return;
switch (event.key) {
case KEY_CODES.RIGHT_ARROW:
this.movePage(this.readingDirection() === ReadingDirection.LeftToRight ? PAGING_DIRECTION.FORWARD : PAGING_DIRECTION.BACKWARDS);
break;
case KEY_CODES.LEFT_ARROW:
this.movePage(this.readingDirection() === ReadingDirection.LeftToRight ? PAGING_DIRECTION.BACKWARDS : PAGING_DIRECTION.FORWARD);
break;
case KEY_CODES.ESC_KEY:
const isHighlighting = window.getSelection()?.toString() != '';
if (isHighlighting || this.isLineOverlayOpen()) return;
this.closeReader();
break;
case KEY_CODES.G:
await this.goToPage();
break;
case KEY_CODES.F:
this.applyFullscreen();
break;
case KEY_CODES.SPACE:
this.actionBarVisible.update(x => !x);
break;
}
}
onWheel(event: WheelEvent) {
// This allows the user to scroll the page horizontally without holding shift
if (this.layoutMode() !== BookPageLayoutMode.Default || this.writingStyle() !== WritingStyle.Vertical) {

View File

@ -21,7 +21,7 @@ import {
import {concat, forkJoin, Observable, of, tap} from 'rxjs';
import {map, switchMap} from 'rxjs/operators';
import {Breakpoint, UtilityService} from 'src/app/shared/_services/utility.service';
import {TypeaheadSettings} from 'src/app/typeahead/_models/typeahead-settings';
import {setupLanguageSettings, TypeaheadSettings} from 'src/app/typeahead/_models/typeahead-settings';
import {Chapter, LooseLeafOrDefaultNumber, SpecialVolumeNumber} from 'src/app/_models/chapter';
import {Genre} from 'src/app/_models/metadata/genre';
import {AgeRatingDto} from 'src/app/_models/metadata/age-rating-dto';
@ -165,7 +165,7 @@ export class EditSeriesModalComponent implements OnInit {
// Typeaheads
tagsSettings: TypeaheadSettings<Tag> = new TypeaheadSettings();
languageSettings: TypeaheadSettings<Language> = new TypeaheadSettings();
languageSettings: TypeaheadSettings<Language> | null = null;
peopleSettings: {[PersonRole: string]: TypeaheadSettings<Person>} = {};
genreSettings: TypeaheadSettings<Genre> = new TypeaheadSettings();
@ -173,7 +173,6 @@ export class EditSeriesModalComponent implements OnInit {
genres: Genre[] = [];
ageRatings: Array<AgeRatingDto> = [];
publicationStatuses: Array<PublicationStatusDto> = [];
validLanguages: Array<Language> = [];
metadata!: SeriesMetadata;
imageUrls: Array<string> = [];
@ -439,33 +438,7 @@ export class EditSeriesModalComponent implements OnInit {
return this.metadataService.getAllValidLanguages()
.pipe(
tap(validLanguages => {
this.validLanguages = validLanguages;
this.languageSettings.minCharacters = 0;
this.languageSettings.multiple = false;
this.languageSettings.id = 'language';
this.languageSettings.unique = true;
this.languageSettings.showLocked = true;
this.languageSettings.addIfNonExisting = false;
this.languageSettings.compareFn = (options: Language[], filter: string) => {
return options.filter(m => this.utilityService.filter(m.title, filter));
}
this.languageSettings.compareFnForAdd = (options: Language[], filter: string) => {
return options.filter(m => this.utilityService.filterMatches(m.title, filter));
}
this.languageSettings.fetchFn = (filter: string) => of(this.validLanguages)
.pipe(map(items => this.languageSettings.compareFn(items, filter)));
this.languageSettings.selectionCompareFn = (a: Language, b: Language) => {
return a.isoCode == b.isoCode;
}
const l = this.validLanguages.find(l => l.isoCode === this.metadata.language);
if (l !== undefined) {
this.languageSettings.savedData = l;
}
this.languageSettings.trackByIdentityFn = (index, value) => value.isoCode;
this.languageSettings = setupLanguageSettings(true, this.utilityService, validLanguages, this.metadata.language);
this.cdRef.markForCheck();
}),
switchMap(_ => of(true))
@ -596,7 +569,6 @@ export class EditSeriesModalComponent implements OnInit {
updatePerson(persons: Person[], role: PersonRole) {
this.metadataService.updatePerson(this.metadata, persons, role);
this.metadata.locationLocked = true;
this.cdRef.markForCheck();
}

View File

@ -24,6 +24,7 @@
[libraryType]="libraryType"
[mangaFormat]="series.format"
[totalBytes]="size"
[releaseYear]="(chapter.releaseDate | utcToLocaleDate)?.getFullYear()"
/>
@ -105,7 +106,7 @@
@if (chapter.releaseDate !== '0001-01-01T00:00:00' && (libraryType === LibraryType.ComicVine || libraryType === LibraryType.Comic)) {
<span class="fw-bold">{{t('release-date-title')}}</span>
<div>
<a class="dark-exempt btn-icon" href="javascript:void(0);">{{chapter.releaseDate | date: 'shortDate' | defaultDate:'—'}}</a>
<a class="dark-exempt btn-icon" href="javascript:void(0);">{{chapter.releaseDate | utcToLocalTime: 'shortDate' | defaultDate:'—'}}</a>
</div>
} @else {
<span class="fw-bold">{{t('cover-artists-title')}}</span>

View File

@ -9,7 +9,7 @@ import {
OnInit,
ViewChild
} from '@angular/core';
import {AsyncPipe, DatePipe, DOCUMENT, Location, NgClass, NgStyle} from "@angular/common";
import {AsyncPipe, DOCUMENT, Location, NgClass, NgStyle} from "@angular/common";
import {CardActionablesComponent} from "../_single-module/card-actionables/card-actionables.component";
import {LoadingComponent} from "../shared/loading/loading.component";
import {
@ -61,7 +61,7 @@ import {
} from "../series-detail/_components/metadata-detail-row/metadata-detail-row.component";
import {DownloadButtonComponent} from "../series-detail/_components/download-button/download-button.component";
import {hasAnyCast} from "../_models/common/i-has-cast";
import {Breakpoint, UserBreakpoint, UtilityService} from "../shared/_services/utility.service";
import {UserBreakpoint, UtilityService} from "../shared/_services/utility.service";
import {EVENTS, MessageHubService} from "../_services/message-hub.service";
import {CoverUpdateEvent} from "../_models/events/cover-update-event";
import {ChapterRemovedEvent} from "../_models/events/chapter-removed-event";
@ -79,6 +79,8 @@ import {Rating} from "../_models/rating";
import {AnnotationService} from "../_services/annotation.service";
import {Annotation} from "../book-reader/_models/annotations/annotation";
import {AnnotationsTabComponent} from "../_single-module/annotations-tab/annotations-tab.component";
import {UtcToLocalTimePipe} from "../_pipes/utc-to-local-time.pipe";
import {UtcToLocaleDatePipe} from "../_pipes/utc-to-locale-date.pipe";
enum TabID {
Related = 'related-tab',
@ -115,12 +117,13 @@ enum TabID {
BadgeExpanderComponent,
MetadataDetailRowComponent,
DownloadButtonComponent,
DatePipe,
DefaultDatePipe,
CoverImageComponent,
ReviewsComponent,
ExternalRatingComponent,
AnnotationsTabComponent
AnnotationsTabComponent,
UtcToLocalTimePipe,
UtcToLocaleDatePipe
],
templateUrl: './chapter-detail.component.html',
styleUrl: './chapter-detail.component.scss',

View File

@ -12,6 +12,7 @@ import {
model,
OnDestroy,
OnInit,
signal,
Signal,
ViewChild
} from '@angular/core';
@ -35,11 +36,11 @@ import {
import {ChangeContext, LabelType, NgxSliderModule, Options} from '@angular-slider/ngx-slider';
import {animate, state, style, transition, trigger} from '@angular/animations';
import {FormBuilder, FormControl, FormGroup, ReactiveFormsModule} from '@angular/forms';
import {NgbModal, NgbTooltip} from '@ng-bootstrap/ng-bootstrap';
import {NgbModal, NgbModalRef, NgbTooltip} from '@ng-bootstrap/ng-bootstrap';
import {ToastrService} from 'ngx-toastr';
import {ShortcutsModalComponent} from 'src/app/reader-shared/_modals/shortcuts-modal/shortcuts-modal.component';
import {Stack} from 'src/app/shared/data-structures/stack';
import {Breakpoint, KEY_CODES, UtilityService} from 'src/app/shared/_services/utility.service';
import {Breakpoint, UtilityService} from 'src/app/shared/_services/utility.service';
import {LibraryType} from 'src/app/_models/library/library';
import {MangaFormat} from 'src/app/_models/manga-format';
import {PageSplitOption} from 'src/app/_models/preferences/page-split-option';
@ -81,6 +82,8 @@ import {
import {ReadingProfileService} from "../../../_services/reading-profile.service";
import {ConfirmService} from "../../../shared/confirm.service";
import {PageBookmark} from "../../../_models/readers/page-bookmark";
import {KeyBindService} from "../../../_services/key-bind.service";
import {KeyBindTarget} from "../../../_models/preferences/preferences";
const PREFETCH_PAGES = 10;
@ -166,6 +169,7 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy {
protected readonly readerService = inject(ReaderService);
protected readonly utilityService = inject(UtilityService);
protected readonly mangaReaderService = inject(MangaReaderService);
private readonly keyBindService = inject(KeyBindService);
protected readonly KeyDirection = KeyDirection;
@ -504,8 +508,59 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy {
return chapterInfo?.chapterTitle || chapterInfo?.subtitle || '';
});
this.keyBindService.registerListener(
this.destroyRef,
async (e) => {
switch (e.target) {
case KeyBindTarget.Escape:
if (this.menuOpen) {
this.toggleMenu();
return;
}
if (this.shortCutModalOpen()){
this.closeShortCutModal();
return;
}
this.closeReader();
break;
case KeyBindTarget.PageLeft:
this.handlePageLeft();
break;
case KeyBindTarget.PageRight:
this.handlePageRight();
break;
case KeyBindTarget.ToggleFullScreen:
this.toggleFullscreen();
break;
case KeyBindTarget.BookmarkPage:
this.bookmarkPage();
break;
case KeyBindTarget.GoTo:
const goToPageNum = await this.promptForPage();
if (goToPageNum === null) { return; }
this.goToPage(parseInt(goToPageNum.trim(), 10));
break;
case KeyBindTarget.ToggleMenu:
this.toggleMenu();
break;
case KeyBindTarget.OpenHelp:
this.openShortcutModal();
break;
}
},
[KeyBindTarget.ToggleFullScreen, KeyBindTarget.BookmarkPage, KeyBindTarget.OpenHelp, KeyBindTarget.GoTo,
KeyBindTarget.ToggleMenu, KeyBindTarget.PageRight, KeyBindTarget.PageLeft, KeyBindTarget.Escape],
);
this.keyBindService.registerListener(
this.destroyRef,
() => {
this.toggleMenu();
this.settingsOpen = !this.settingsOpen;
this.cdRef.markForCheck();
},
[KeyBindTarget.NavigateToSettings]
);
}
ngOnInit(): void {
@ -615,6 +670,33 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy {
this.readerService.disableWakeLock();
}
private handlePageLeft() {
switch (this.readerMode) {
case ReaderMode.LeftRight:
if (this.checkIfPaginationAllowed(KeyDirection.Left)) {
this.readingDirection === ReadingDirection.LeftToRight ? this.prevPage() : this.nextPage();
}
break
case ReaderMode.UpDown:
if (this.checkIfPaginationAllowed(KeyDirection.Down)) {
this.nextPage();
}
}
}
private handlePageRight() {
switch (this.readerMode) {
case ReaderMode.LeftRight:
if (this.checkIfPaginationAllowed(KeyDirection.Left)) {
this.readingDirection === ReadingDirection.LeftToRight ? this.nextPage() : this.prevPage();
}
break
case ReaderMode.UpDown:
if (this.checkIfPaginationAllowed(KeyDirection.Down)) {
this.prevPage();
}
}
}
@HostListener('window:resize', ['$event'])
@HostListener('window:orientationchange', ['$event'])
@ -622,54 +704,6 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy {
this.disableDoubleRendererIfScreenTooSmall();
}
@HostListener('window:keyup', ['$event'])
async handleKeyPress(event: KeyboardEvent) {
switch (this.readerMode) {
case ReaderMode.LeftRight:
if (event.key === KEY_CODES.RIGHT_ARROW) {
if (!this.checkIfPaginationAllowed(KeyDirection.Right)) return;
this.readingDirection === ReadingDirection.LeftToRight ? this.nextPage() : this.prevPage();
} else if (event.key === KEY_CODES.LEFT_ARROW) {
if (!this.checkIfPaginationAllowed(KeyDirection.Left)) return;
this.readingDirection === ReadingDirection.LeftToRight ? this.prevPage() : this.nextPage();
}
break;
case ReaderMode.UpDown:
if (event.key === KEY_CODES.UP_ARROW) {
if (!this.checkIfPaginationAllowed(KeyDirection.Up)) return;
this.prevPage();
} else if (event.key === KEY_CODES.DOWN_ARROW) {
if (!this.checkIfPaginationAllowed(KeyDirection.Down)) return;
this.nextPage();
}
break;
case ReaderMode.Webtoon:
break;
}
if (event.key === KEY_CODES.ESC_KEY) {
if (this.menuOpen) {
this.toggleMenu();
event.stopPropagation();
event.preventDefault();
return;
}
this.closeReader();
} else if (event.key === KEY_CODES.SPACE) {
this.toggleMenu();
} else if (event.key === KEY_CODES.G) {
const goToPageNum = await this.promptForPage();
if (goToPageNum === null) { return; }
this.goToPage(parseInt(goToPageNum.trim(), 10));
} else if (event.key === KEY_CODES.B) {
this.bookmarkPage();
} else if (event.key === KEY_CODES.F) {
this.toggleFullscreen();
} else if (event.key === KEY_CODES.H) {
this.openShortcutModal();
}
}
setupReaderSettings() {
if (this.readingProfile.kind === ReadingProfileKind.Implicit) {
@ -1824,20 +1858,34 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy {
}
}
shortCutModalOpen = signal(false);
shortCutModalRef: NgbModalRef | undefined;
private closeShortCutModal() {
if (this.shortCutModalRef) {
this.shortCutModalRef.dismiss();
this.shortCutModalRef = undefined;
}
}
// This is menu only code
openShortcutModal() {
const ref = this.modalService.open(ShortcutsModalComponent, { scrollable: true, size: 'md' });
ref.componentInstance.shortcuts = [
{key: '⇽', description: 'prev-page'},
{key: '⇾', description: 'next-page'},
{key: '↑', description: 'prev-page'},
{key: '↓', description: 'next-page'},
{key: 'G', description: 'go-to'},
{key: 'B', description: 'bookmark'},
if (this.shortCutModalOpen()) return;
this.shortCutModalOpen.set(true);
this.shortCutModalRef = this.modalService.open(ShortcutsModalComponent, { scrollable: true, size: 'md' });
this.shortCutModalRef.componentInstance.shortcuts = [
{keyBindTarget: KeyBindTarget.PageLeft, description: 'prev-page'},
{keyBindTarget: KeyBindTarget.PageRight, description: 'next-page'},
{keyBindTarget: KeyBindTarget.GoTo, description: 'go-to'},
{keyBindTarget: KeyBindTarget.ToggleFullScreen},
{keyBindTarget: KeyBindTarget.ToggleMenu},
{keyBindTarget: KeyBindTarget.OpenHelp},
{keyBindTarget: KeyBindTarget.BookmarkPage, description: 'bookmark'},
{key: translate('shortcuts-modal.double-click'), description: 'bookmark'},
{key: 'ESC', description: 'close-reader'},
{key: 'SPACE', description: 'toggle-menu'},
];
merge(this.shortCutModalRef.closed, this.shortCutModalRef.dismissed).subscribe(() => this.shortCutModalOpen.set(false));
}
// menu only code

View File

@ -17,7 +17,7 @@
}
} @else {
<div class="input-hint d-none d-lg-block">
Ctrl+K
{{keyBindService.allKeyBinds().OpenSearch.at(0) | keyBind}}
</div>
}
</div>

View File

@ -23,6 +23,9 @@ import {NgClass, NgTemplateOutlet} from '@angular/common';
import {TranslocoDirective} from "@jsverse/transloco";
import {map, startWith, tap} from "rxjs";
import {AccountService} from "../../../_services/account.service";
import {KeyBindEvent, KeyBindService} from "../../../_services/key-bind.service";
import {KeyBindTarget} from "../../../_models/preferences/preferences";
import {KeyBindPipe} from "../../../_pipes/key-bind.pipe";
export interface SearchEvent {
value: string;
@ -34,12 +37,13 @@ export interface SearchEvent {
templateUrl: './grouped-typeahead.component.html',
styleUrls: ['./grouped-typeahead.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [ReactiveFormsModule, NgClass, NgTemplateOutlet, TranslocoDirective]
imports: [ReactiveFormsModule, NgClass, NgTemplateOutlet, TranslocoDirective, KeyBindPipe]
})
export class GroupedTypeaheadComponent implements OnInit {
private readonly destroyRef = inject(DestroyRef);
private readonly cdRef = inject(ChangeDetectorRef);
private readonly accountService = inject(AccountService);
protected readonly keyBindService = inject(KeyBindService);
/**
* Unique id to tie with a label element
@ -126,37 +130,36 @@ export class GroupedTypeaheadComponent implements OnInit {
@HostListener('document:keydown', ['$event'])
handleKeyPress(event: KeyboardEvent) {
const isCtrlOrMeta = event.ctrlKey || event.metaKey;
switch(event.key) {
case KEY_CODES.ESC_KEY:
if (!this.hasFocus) { return; }
this.close();
event.stopPropagation();
break;
case KEY_CODES.K:
if (isCtrlOrMeta) {
if (this.inputElem.nativeElement) {
event.preventDefault();
event.stopPropagation();
this.inputElem.nativeElement.focus();
this.inputElem.nativeElement.click();
}
}
break;
default:
break;
}
}
private focusElement(e: KeyBindEvent) {
if (this.inputElem.nativeElement) {
e.triggered = true;
this.inputElem.nativeElement.focus();
this.inputElem.nativeElement.click();
}
}
ngOnInit(): void {
this.typeaheadForm.get('typeahead')?.setValue(this.initialValue);
this.cdRef.markForCheck();
this.keyBindService.registerListener(
this.destroyRef,
(e) => this.focusElement(e),
[KeyBindTarget.OpenSearch],
{fireInEditable: true},
);
this.searchSettingsForm.get('includeExtras')!.valueChanges.pipe(
startWith(false),
map(val => {

View File

@ -7,11 +7,18 @@ import {
HostListener,
inject,
OnDestroy,
OnInit, signal,
OnInit,
signal,
ViewChild
} from '@angular/core';
import {ActivatedRoute, Router} from '@angular/router';
import {NgxExtendedPdfViewerModule, pdfDefaultOptions, PageViewModeType, ProgressBarEvent, ScrollModeType} from 'ngx-extended-pdf-viewer';
import {
NgxExtendedPdfViewerModule,
PageViewModeType,
pdfDefaultOptions,
ProgressBarEvent,
ScrollModeType
} from 'ngx-extended-pdf-viewer';
import {ToastrService} from 'ngx-toastr';
import {take} from 'rxjs';
import {BookService} from 'src/app/book-reader/_services/book.service';
@ -36,6 +43,8 @@ import {PdfSpreadTypePipe} from "../../_pipe/pdf-spread-mode.pipe";
import {ReadingProfileService} from "../../../_services/reading-profile.service";
import {ReadingProfile} from "../../../_models/preferences/reading-profiles";
import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
import {KeyBindService} from "../../../_services/key-bind.service";
import {KeyBindTarget} from "../../../_models/preferences/preferences";
@Component({
selector: 'app-pdf-reader',
@ -61,6 +70,7 @@ export class PdfReaderComponent implements OnInit, OnDestroy {
public readonly utilityService = inject(UtilityService);
public readonly destroyRef = inject(DestroyRef);
public readonly document = inject(DOCUMENT);
private readonly keyBindService = inject(KeyBindService);
protected readonly ScrollModeType = ScrollModeType;
protected readonly Breakpoint = Breakpoint;
@ -130,13 +140,12 @@ export class PdfReaderComponent implements OnInit, OnDestroy {
this.navService.hideNavBar();
this.themeService.clearThemes();
this.navService.hideSideNav();
}
@HostListener('window:keyup', ['$event'])
handleKeyPress(event: KeyboardEvent) {
if (event.key === KEY_CODES.ESC_KEY) {
this.closeReader();
}
this.keyBindService.registerListener(
this.destroyRef,
() => this.closeReader(),
[KeyBindTarget.Escape],
);
}
@HostListener('window:resize', ['$event'])

View File

@ -34,7 +34,7 @@
<div class="row g-0">
<div class="mb-3 col-md-6 col-xs-12 pe-2">
@if (editForm.get('malId'); as formControl) {
<app-setting-item [title]="t('mal-id-label')" [toggleOnViewClick]="false" [showEdit]="false" [subtitle]="t('mal-tooltip')">
<app-setting-item [title]="t('mal-id-label')" [toggleOnViewClick]="false" [showEdit]="false" [subtitle]="t('mal-tooltip'+tooltip)">
<ng-template #view>
<input id="mal-id" class="form-control" formControlName="malId" type="number"
[class.is-invalid]="formControl.invalid && !formControl.untouched">
@ -45,7 +45,7 @@
<div class="mb-3 col-md-6 col-xs-12">
@if (editForm.get('aniListId'); as formControl) {
<app-setting-item [title]="t('anilist-id-label')" [toggleOnViewClick]="false" [showEdit]="false" [subtitle]="t('anilist-tooltip')">
<app-setting-item [title]="t('anilist-id-label')" [toggleOnViewClick]="false" [showEdit]="false" [subtitle]="t('anilist-tooltip'+tooltip)">
<ng-template #view>
<input id="anilist-id" class="form-control" formControlName="aniListId" type="number"
[class.is-invalid]="formControl.invalid && !formControl.untouched">

View File

@ -9,7 +9,7 @@ import {
ValidationErrors,
Validators
} from "@angular/forms";
import {Person} from "../../../_models/metadata/person";
import {Person, PersonRole} from "../../../_models/metadata/person";
import {
NgbActiveModal,
NgbNav,
@ -22,7 +22,7 @@ import {
import {PersonService} from "../../../_services/person.service";
import {translate, TranslocoDirective} from '@jsverse/transloco';
import {CoverImageChooserComponent} from "../../../cards/cover-image-chooser/cover-image-chooser.component";
import {concat, forkJoin, map, of} from "rxjs";
import {concat, map, of} from "rxjs";
import {UploadService} from "../../../_services/upload.service";
import {SettingItemComponent} from "../../../settings/_components/setting-item/setting-item.component";
import {AccountService} from "../../../_services/account.service";
@ -84,6 +84,11 @@ export class EditPersonModalComponent implements OnInit {
coverImageReset = false;
touchedCoverImage = false;
fetchDisabled: boolean = false;
/**
* Suffix to include in the tooltip for external ids if they support characters
*/
tooltip: string = '';
ngOnInit() {
if (this.person) {
@ -97,6 +102,11 @@ export class EditPersonModalComponent implements OnInit {
this.editForm.addControl('coverImageIndex', new FormControl(0, []));
this.editForm.addControl('coverImageLocked', new FormControl(this.person.coverImageLocked, []));
const roles = (this.person.roles ?? []);
if (roles.length === 1 && roles.includes(PersonRole.Character)) {
this.tooltip = '-character';
}
this.cdRef.markForCheck();
} else {
alert('no person')

View File

@ -1,18 +1,42 @@
<ng-container *transloco="let t; prefix: 'shortcuts-modal'">
<div class="modal-header">
<h4 class="modal-title" id="modal-basic-title">{{t('title')}}</h4>
<h4 class="modal-title" id="modal-basic-title">{{ t('title') }}</h4>
<button type="button" class="btn-close" [attr.aria-label]="t('close')" (click)="modal.close()"></button>
</div>
<div class="modal-body">
<div class="row g-0">
@for(shortcut of shortcuts; track shortcut.key) {
<div class="col-md-6 mb-2">
<span><code>{{shortcut.key}}</code> {{t(shortcut.description)}}</span>
</div>
@for(shortcut of shortcuts; track shortcut.keyBindTarget ?? shortcut.key ?? $index) {
@if (shortcut.keyBindTarget) {
<!-- Show at most 2 of the keybinds to not make the modal too long -->
@for(keyBind of keyBindService.allKeyBinds()[shortcut.keyBindTarget].slice(0, 2); track $index) {
<div class="col-md-6 mb-2">
<span>
<code>{{ keyBind | keyBind }}</code>
@if (shortcut.description) {
{{ t(shortcut.description) }}
} @else {
@let settingDesc = shortcut.keyBindTarget | keybindSettingDescription;
<span>
{{ settingDesc.tooltip }}
</span>
}
</span>
</div>
}
} @else {
<div class="col-md-6 mb-2">
<span>
<code>{{ shortcut.key }}</code>
@if (shortcut.description) {
{{ t(shortcut.description) }}
}
</span>
</div>
}
}
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-primary" (click)="modal.close()">{{t('close')}}</button>
<button type="button" class="btn btn-primary" (click)="modal.close()">{{ t('close') }}</button>
</div>
</ng-container>

View File

@ -1,27 +1,36 @@
import {ChangeDetectionStrategy, Component, inject, Input} from '@angular/core';
import {NgbActiveModal, NgbModalModule} from '@ng-bootstrap/ng-bootstrap';
import {TranslocoDirective} from "@jsverse/transloco";
import {KeyBindTarget} from "../../../_models/preferences/preferences";
import {KeyBindService} from "../../../_services/key-bind.service";
import {KeyBindPipe} from "../../../_pipes/key-bind.pipe";
import {KeybindSettingDescriptionPipe} from "../../../_pipes/keybind-setting-description.pipe";
export interface KeyboardShortcut {
/**
* String representing key or key combo. Should use + for combos. Will render as upper case
*/
key: string;
key?: string;
/**
* Description of how it works
*/
description: string;
description?: string;
/**
* Keybind target, will display the first configured keybind instead of the given key
*/
keyBindTarget?: KeyBindTarget;
}
@Component({
selector: 'app-shortcuts-modal',
imports: [NgbModalModule, TranslocoDirective],
templateUrl: './shortcuts-modal.component.html',
styleUrls: ['./shortcuts-modal.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush
selector: 'app-shortcuts-modal',
imports: [NgbModalModule, TranslocoDirective, KeyBindPipe, KeybindSettingDescriptionPipe],
templateUrl: './shortcuts-modal.component.html',
styleUrls: ['./shortcuts-modal.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class ShortcutsModalComponent {
protected readonly keyBindService = inject(KeyBindService);
protected readonly modal = inject(NgbActiveModal);
@Input() shortcuts: Array<KeyboardShortcut> = [];

View File

@ -133,7 +133,10 @@
@if (seriesMetadata.publicationStatus | publicationStatus; as pubStatus) {
<a class="dark-exempt btn-icon font-size" (click)="openFilter(FilterField.PublicationStatus, seriesMetadata!.publicationStatus)"
href="javascript:void(0);"
[ngbTooltip]="t('publication-status-tooltip') + (seriesMetadata.totalCount === 0 ? '' : ' (' + seriesMetadata.maxCount + ' / ' + seriesMetadata.totalCount + ')')">
[ngbTooltip]="t('publication-status-tooltip') +
((seriesMetadata.totalCount === 0 || seriesMetadata.maxCount === LooseLeafOrSpecialNumber)
? '' : ' (' + seriesMetadata.maxCount + ' / ' + seriesMetadata.totalCount + ')')"
>
{{pubStatus}}
</a>
}

View File

@ -0,0 +1,28 @@
<app-tag-badge *transloco="let t; prefix: 'manage-custom-key-binds'" [selectionMode]="tagBadgeCursor()" [ngbTooltip]="t('key-bind-tooltip')">
<div id="key-bind-{{target()}}-{{index()}}" tabindex="-1" (click)="startListening()" (focus)="startListening()" class="d-flex align-items-center">
@let keybind = selectedKeyBind() | keyBind;
@if (!this.isListening()) {
{{keybind | defaultValue}}
} @else {
{{keybind}}
}
@if (this.isListening()) {
<span class="typing-cursor"></span>
}
@if (duplicated()) {
<i class="fa fa-warning text-warning ms-2"
[attr.aria-label]="t('warning-duplicated-control', {target: target(), index: index() + 1})"></i>
}
@if (!control().valid) {
<i class="fa fa-warning text-danger ms-2"
[attr.aria-label]="t('errors-control', {target: target(), index: index() + 1})"></i>
}
</div>
</app-tag-badge>

View File

@ -0,0 +1,18 @@
.typing-cursor {
display: inline-block;
width: 1px;
height: 1em;
background-color: currentColor;
margin-left: 2px;
vertical-align: text-bottom;
animation: blink 1s step-end infinite;
}
@keyframes blink {
0%, 49% {
opacity: 1;
}
50%, 100% {
opacity: 0;
}
}

View File

@ -0,0 +1,161 @@
import {
ChangeDetectionStrategy,
Component,
computed,
DestroyRef,
effect,
ElementRef,
forwardRef,
inject,
input,
OnDestroy,
signal
} from '@angular/core';
import {ControlValueAccessor, FormControl, NG_VALUE_ACCESSOR} from "@angular/forms";
import {KeyBindService, KeyCode, ModifierKeyCodes} from "../../../_services/key-bind.service";
import {KeyBind, KeyBindTarget} from "../../../_models/preferences/preferences";
import {KeyBindPipe} from "../../../_pipes/key-bind.pipe";
import {DOCUMENT} from "@angular/common";
import {GamePadService} from "../../../_services/game-pad.service";
import {filter, fromEvent, merge, Subscription, tap} from "rxjs";
import {TagBadgeComponent, TagBadgeCursor} from "../../../shared/tag-badge/tag-badge.component";
import {TranslocoDirective} from "@jsverse/transloco";
import {DefaultValuePipe} from "../../../_pipes/default-value.pipe";
import {AccountService} from "../../../_services/account.service";
import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
import {debounceTime, take} from "rxjs/operators";
import {NgbTooltip} from "@ng-bootstrap/ng-bootstrap";
@Component({
selector: 'app-setting-key-bind-picker',
imports: [
KeyBindPipe,
TagBadgeComponent,
TranslocoDirective,
DefaultValuePipe,
NgbTooltip
],
templateUrl: './setting-key-bind-picker.component.html',
styleUrl: './setting-key-bind-picker.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
providers: [
{
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => SettingKeyBindPickerComponent),
multi: true,
}
]
})
export class SettingKeyBindPickerComponent implements ControlValueAccessor, OnDestroy {
private readonly destroyRef = inject(DestroyRef);
protected readonly keyBindService = inject(KeyBindService);
private readonly gamePadService = inject(GamePadService);
private readonly accountService = inject(AccountService);
private readonly document = inject(DOCUMENT);
private readonly elementRef = inject(ElementRef);
control = input.required<FormControl<KeyBind>>();
target = input.required<KeyBindTarget>();
index = input.required<number>();
duplicated = input.required<boolean>();
selectedKeyBind = signal<KeyBind>({key: KeyCode.Empty});
disabled = signal(false);
private _onChange: (value: KeyBind) => void = () => {};
private _onTouched: () => void = () => {};
protected readonly subscriptions = signal<Subscription[]>([]);
protected readonly isListening = computed(() => this.subscriptions().length > 0);
protected readonly tagBadgeCursor = computed(() =>
this.accountService.isReadOnly() ? TagBadgeCursor.NotAllowed : TagBadgeCursor.Clickable);
constructor() {
effect(() => {
const selectedKeys = this.selectedKeyBind();
this._onChange(selectedKeys);
this._onTouched();
});
fromEvent(this.document, 'click')
.pipe(
takeUntilDestroyed(this.destroyRef),
filter((event: Event) => {
return !this.elementRef.nativeElement.contains(event.target);
}),
filter(() => this.isListening()),
tap(() => this.stopListening()),
).subscribe();
}
writeValue(keyBind: KeyBind): void {
this.selectedKeyBind.set(keyBind)
}
registerOnChange(fn: (_: KeyBind) => void): void {
this._onChange = fn;
}
registerOnTouched(fn: () => void): void {
this._onTouched = fn;
}
setDisabledState?(isDisabled: boolean): void {
this.disabled.set(isDisabled);
}
ngOnDestroy() {
this.keyBindService.disabled.set(false);
this.subscriptions().forEach(s => s.unsubscribe());
}
startListening() {
if (this.isListening() || this.accountService.isReadOnly()) return;
this.keyBindService.disabled.set(true);
this.document.addEventListener('keydown', this.onKeyDown);
const keydown$ = fromEvent(this.document, 'keydown').pipe(
tap((e) => this.onKeyDown(e as KeyboardEvent)),
);
const gamePad$ = this.gamePadService.keyDownEvents$.pipe(
tap(e => this.selectedKeyBind.set({
key: KeyCode.Empty,
controllerSequence: e.pressedButtons,
})),
);
const sub = merge(keydown$, gamePad$).pipe(
takeUntilDestroyed(this.destroyRef),
debounceTime(700),
filter(() => this.control().valid),
take(1),
tap(() => this.stopListening()),
).subscribe();
this.subscriptions.update(s => [sub, ...s]);
}
stopListening() {
this.keyBindService.disabled.set(false);
this.subscriptions().forEach(s => s.unsubscribe());
this.subscriptions.set([]);
}
private onKeyDown = (event: KeyboardEvent) => {
const eventKey = event.key.toLowerCase() as KeyCode;
this.selectedKeyBind.set({
key: ModifierKeyCodes.includes(eventKey) ? KeyCode.Empty : eventKey,
meta: event.metaKey,
alt: event.altKey,
control: event.ctrlKey,
shift: event.shiftKey,
});
event.preventDefault();
event.stopPropagation();
};
}

View File

@ -177,6 +177,14 @@
}
}
@defer (when fragment === SettingsTabId.CustomKeyBinds; prefetch on idle) {
@if (fragment === SettingsTabId.CustomKeyBinds) {
<div class="col-xxl-6 col-12">
<app-manage-custom-key-binds />
</div>
}
}
@defer (when fragment === SettingsTabId.Customize; prefetch on idle) {
@if (fragment === SettingsTabId.Customize) {
<div class="scale col-md-12">

View File

@ -61,6 +61,7 @@ import {
import {ImportMappingsComponent} from "../../../admin/import-mappings/import-mappings.component";
import {ManageOpenIDConnectComponent} from "../../../admin/manage-open-idconnect/manage-open-idconnect.component";
import {FontManagerComponent} from "../../../user-settings/font-manager/font-manager/font-manager.component";
import {ManageCustomKeyBindsComponent} from "../../../user-settings/custom-key-binds/manage-custom-key-binds.component";
@Component({
selector: 'app-settings',
@ -101,7 +102,8 @@ import {FontManagerComponent} from "../../../user-settings/font-manager/font-man
ManageOpenIDConnectComponent,
ManagePublicMetadataSettingsComponent,
ImportMappingsComponent,
FontManagerComponent
FontManagerComponent,
ManageCustomKeyBindsComponent
],
templateUrl: './settings.component.html',
styleUrl: './settings.component.scss',

View File

@ -26,7 +26,10 @@ export enum KEY_CODES {
K = 'k',
BACKSPACE = 'Backspace',
DELETE = 'Delete',
SHIFT = 'Shift'
SHIFT = 'Shift',
CONTROL = 'Control',
META = 'Meta',
ALT = 'Alt',
}
/**

View File

@ -1,4 +1,4 @@
import {ChangeDetectionStrategy, ChangeDetectorRef, Component, DestroyRef, inject, OnInit} from '@angular/core';
import {ChangeDetectionStrategy, ChangeDetectorRef, Component, DestroyRef, effect, inject, OnInit} from '@angular/core';
import {NavigationEnd, Router} from '@angular/router';
import {NgbTooltip} from '@ng-bootstrap/ng-bootstrap';
import {distinctUntilChanged, filter, map, take, tap} from 'rxjs/operators';
@ -26,6 +26,8 @@ import {LicenseService} from "../../../_services/license.service";
import {CdkDrag, CdkDragDrop, CdkDropList} from "@angular/cdk/drag-drop";
import {ToastrService} from "ngx-toastr";
import {ReadingProfileService} from "../../../_services/reading-profile.service";
import {KeyBindService} from "../../../_services/key-bind.service";
import {KeyBindTarget} from "../../../_models/preferences/preferences";
@Component({
selector: 'app-side-nav',
@ -57,6 +59,7 @@ export class SideNavComponent implements OnInit {
private readonly toastr = inject(ToastrService);
private readonly readingProfilesService = inject(ReadingProfileService);
private readonly translocoService = inject(TranslocoService);
private readonly keyBindService = inject(KeyBindService);
cachedData: SideNavStream[] | null = null;
@ -146,6 +149,21 @@ export class SideNavComponent implements OnInit {
this.navService.collapseSideNav(false);
this.cdRef.markForCheck();
});
this.keyBindService.registerListener(
this.destroyRef,
(e) => this.router.navigate(['/settings'], { fragment: SettingsTabId.Account}),
[KeyBindTarget.NavigateToSettings],
{condition$: this.navService.sideNavVisibility$},
);
this.keyBindService.registerListener(
this.destroyRef,
(e) => this.router.navigate(['/settings'], { fragment: SettingsTabId.Scrobbling}),
[KeyBindTarget.NavigateToScrobbling],
{condition$: this.licenseService.hasValidLicense$},
);
}
ngOnInit(): void {
@ -257,7 +275,7 @@ export class SideNavComponent implements OnInit {
const stream = $event.item.data;
// Offset the home, back, and customize button
this.navService.updateSideNavStreamPosition(stream.name, stream.id, stream.order, $event.currentIndex - 3).subscribe({
this.navService.updateSideNavStreamPosition(stream.name, stream.id, stream.order, $event.currentIndex - 3, false).subscribe({
next: () => {
this.showAllSubject.next(this.showAll);
this.cdRef.markForCheck();

View File

@ -74,7 +74,7 @@
<a ngbNavLink>{{t(TabID.Folder)}}</a>
<ng-template ngbNavContent>
@if (filesAtRoot()) {
@if (filesAtRoot().length > 0) {
<p class="alert alert-warning">{{t('files-at-root-warning')}}</p>
}
@ -83,7 +83,15 @@
@for(folder of selectedFolders; track folder) {
<li class="list-group-item">
{{folder}}
<button class="btn float-end btn-sm" (click)="removeFolder(folder)"><i class="fa fa-times-circle" aria-hidden="true"></i></button>
<div class="float-end d-flex flex-row align-items-center">
@if (filesAtRoot().includes(folder)) {
<i class="fa fa-warning text-danger" [ngbTooltip]="t('files-at-root-warning')"></i>
}
<button class="btn btn-sm" (click)="removeFolder(folder)"><i class="fa fa-times-circle" aria-hidden="true"></i></button>
</div>
</li>
}
</ul>
@ -236,6 +244,23 @@
</app-setting-switch>
</div>
<div class="row g-0 mt-4 mb-4">
<app-setting-item [title]="t('default-language-label')" [subtitle]="t('default-language-tooltip')" [toggleOnViewClick]="false" [showEdit]="false">
<ng-template #view>
@if (languageSettings) {
<app-typeahead (selectedData)="updateLanguage($event)" [settings]="languageSettings" [locked]="false">
<ng-template #badgeItem let-item let-position="idx">
{{item.title}}
</ng-template>
<ng-template #optionItem let-item let-position="idx">
{{item.title}} ({{item.isoCode}})
</ng-template>
</app-typeahead>
}
</ng-template>
</app-setting-item>
</div>
<div class="setting-section-break"></div>
<div class="row g-0 mt-4 pb-4">

View File

@ -21,7 +21,7 @@ import {
NgbTooltip
} from '@ng-bootstrap/ng-bootstrap';
import {ToastrService} from 'ngx-toastr';
import {debounceTime, distinctUntilChanged, switchMap, tap} from 'rxjs';
import {debounceTime, distinctUntilChanged, of, switchMap, tap} from 'rxjs';
import {
DirectoryPickerComponent,
DirectoryPickerResult
@ -54,6 +54,11 @@ import {Action, ActionFactoryService, ActionItem} from "../../../_services/actio
import {ActionService} from "../../../_services/action.service";
import {LibraryTypePipe} from "../../../_pipes/library-type.pipe";
import {LibraryTypeSubtitlePipe} from "../../../_pipes/library-type-subtitle.pipe";
import {TypeaheadComponent} from "../../../typeahead/_components/typeahead.component";
import {setupLanguageSettings, TypeaheadSettings} from "../../../typeahead/_models/typeahead-settings";
import {Language} from "../../../_models/metadata/language";
import {map} from "rxjs/operators";
import {MetadataService} from "../../../_services/metadata.service";
enum TabID {
General = 'general-tab',
@ -74,7 +79,7 @@ enum StepID {
selector: 'app-library-settings-modal',
imports: [NgbModalModule, NgbNavLink, NgbNavItem, NgbNavContent, ReactiveFormsModule, NgbTooltip,
SentenceCasePipe, NgbNav, NgbNavOutlet, CoverImageChooserComponent, TranslocoModule, DefaultDatePipe,
FileTypeGroupPipe, EditListComponent, SettingItemComponent, SettingSwitchComponent, SettingButtonComponent, LibraryTypeSubtitlePipe, NgTemplateOutlet, DatePipe],
FileTypeGroupPipe, EditListComponent, SettingItemComponent, SettingSwitchComponent, SettingButtonComponent, LibraryTypeSubtitlePipe, NgTemplateOutlet, DatePipe, TypeaheadComponent],
templateUrl: './library-settings-modal.component.html',
styleUrls: ['./library-settings-modal.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush
@ -93,6 +98,7 @@ export class LibrarySettingsModalComponent implements OnInit {
private readonly imageService = inject(ImageService);
private readonly actionFactoryService = inject(ActionFactoryService);
private readonly actionService = inject(ActionService);
private readonly metadataService = inject(MetadataService);
protected readonly LibraryType = LibraryType;
protected readonly Breakpoint = Breakpoint;
@ -124,6 +130,7 @@ export class LibrarySettingsModalComponent implements OnInit {
enableMetadata: new FormControl<boolean>(true, { nonNullable: true, validators: [] }), // required validator doesn't check value, just if true
removePrefixForSortName: new FormControl<boolean>(false, { nonNullable: true, validators: [] }),
inheritWebLinksFromFirstChapter: new FormControl<boolean>(false, { nonNullable: true, validators: []}),
defaultLanguage: new FormControl<string>('', {nonNullable: true, validators: []}),
// TODO: Missing excludePatterns
});
@ -133,11 +140,13 @@ export class LibrarySettingsModalComponent implements OnInit {
return {title: this.libraryTypePipe.transform(f), value: f};
}).sort((a, b) => a.title.localeCompare(b.title));
languageSettings: TypeaheadSettings<Language> | null = null;
isAddLibrary = false;
setupStep = StepID.General;
fileTypeGroups = allFileTypeGroup;
excludePatterns: Array<string> = [''];
filesAtRoot = model<boolean>(false);
filesAtRoot = model<Array<string>>([]);
tasks: ActionItem<Library>[] = this.getTasks();
@ -159,8 +168,6 @@ export class LibrarySettingsModalComponent implements OnInit {
if (this.library === undefined) {
this.isAddLibrary = true;
this.cdRef.markForCheck();
} else {
this.checkForFilesAtRoot();
}
if (this.library?.coverImage != null && this.library?.coverImage !== '') {
@ -201,6 +208,7 @@ export class LibrarySettingsModalComponent implements OnInit {
this.setValues();
this.setupLanguageTypeahead().subscribe();
// Turn on/off manage collections/rl
this.libraryForm.get('enableMetadata')?.valueChanges.pipe(
@ -293,7 +301,9 @@ export class LibrarySettingsModalComponent implements OnInit {
this.libraryForm.get('enableMetadata')?.setValue(this.library.enableMetadata);
this.libraryForm.get('removePrefixForSortName')?.setValue(this.library.removePrefixForSortName);
this.libraryForm.get('inheritWebLinksFromFirstChapter')?.setValue(this.library.inheritWebLinksFromFirstChapter);
this.libraryForm.get('defaultLanguage')?.setValue(this.library.defaultLanguage);
this.selectedFolders = this.library.folders;
this.checkForFilesAtRoot(); // check after selectedFolders has been set
this.madeChanges = false;
@ -321,6 +331,21 @@ export class LibrarySettingsModalComponent implements OnInit {
this.cdRef.markForCheck();
}
setupLanguageTypeahead() {
return this.metadataService.getAllValidLanguages()
.pipe(
tap(validLanguages => {
this.languageSettings = setupLanguageSettings(false, this.utilityService, validLanguages, this.library?.defaultLanguage)
this.cdRef.markForCheck();
}),
switchMap(_ => of(true))
);
}
updateLanguage(languages: Array<Language>) {
this.libraryForm.get("defaultLanguage")!.setValue(languages.at(0)?.isoCode ?? '');
}
updateGlobs(items: Array<string>) {
this.excludePatterns = items;
this.cdRef.markForCheck();
@ -427,7 +452,7 @@ export class LibrarySettingsModalComponent implements OnInit {
if (!this.selectedFolders.includes(closeResult.folderPath)) {
this.selectedFolders.push(closeResult.folderPath);
this.madeChanges = true;
this.checkForFilesAtRoot();
this.checkForFilesAtRoot(true);
this.cdRef.markForCheck();
}
}
@ -478,17 +503,14 @@ export class LibrarySettingsModalComponent implements OnInit {
}
}
checkForFilesAtRoot() {
checkForFilesAtRoot(showToast: boolean = false) {
this.libraryService.hasFilesAtRoot(this.selectedFolders).subscribe(results => {
let containsMultipleFiles = false;
Object.keys(results).forEach(key => {
if (results[key]) {
containsMultipleFiles = true;
return;
}
});
const newValues = results.filter(item => !this.filesAtRoot().includes(item));
if (showToast && newValues.length > 0) {
this.toastr.error(translate('library-settings-modal.files-at-root-warning'))
}
this.filesAtRoot.set(containsMultipleFiles);
this.filesAtRoot.set(results);
})
}
}

View File

@ -24,6 +24,8 @@ import {Breakpoint, UtilityService} from "../../shared/_services/utility.service
import {LicenseService} from "../../_services/license.service";
import {ManageService} from "../../_services/manage.service";
import {MatchStateOption} from "../../_models/kavitaplus/match-state-option";
import {KeyBindService} from "../../_services/key-bind.service";
import {KeyBindTarget} from "../../_models/preferences/preferences";
export enum SettingsTabId {
@ -52,6 +54,7 @@ export enum SettingsTabId {
// Non-Admin
Account = 'account',
Preferences = 'preferences',
CustomKeyBinds = 'custom-key-binds',
ReadingProfiles = 'reading-profiles',
Font = 'font',
Clients = 'clients',
@ -135,6 +138,7 @@ export class PreferenceNavComponent implements AfterViewInit {
protected readonly utilityService = inject(UtilityService);
private readonly manageService = inject(ManageService);
private readonly document = inject(DOCUMENT);
private readonly keyBindService = inject(KeyBindService);
/**
* This links to settings.component.html which has triggers on what underlying component to render out.
@ -219,8 +223,9 @@ export class PreferenceNavComponent implements AfterViewInit {
{
title: SettingSectionId.AccountSection,
children: [
new SideNavItem(SettingsTabId.Account, []),
new SideNavItem(SettingsTabId.Account),
new SideNavItem(SettingsTabId.Preferences),
new SideNavItem(SettingsTabId.CustomKeyBinds),
new SideNavItem(SettingsTabId.ReadingProfiles),
new SideNavItem(SettingsTabId.Customize, [], undefined, [Role.ReadOnly]),
new SideNavItem(SettingsTabId.Clients),
@ -281,6 +286,14 @@ export class PreferenceNavComponent implements AfterViewInit {
this.licenseService.hasValidLicenseSignal();
this.cdRef.markForCheck();
});
this.keyBindService.registerListener(
this.destroyRef,
() => this.router.navigate(['/settings'], { fragment: SettingsTabId.Scrobbling})
.then(() => this.scrollToActiveItem()),
[KeyBindTarget.NavigateToScrobbling],
{condition$: this.licenseService.hasValidLicense$},
);
}
ngAfterViewInit() {

View File

@ -172,21 +172,22 @@ export class TypeaheadComponent implements OnInit {
);
if (this.settings.savedData) {
if (this.settings.multiple) {
this.optionSelection = new SelectionModel<any>(true, this.settings.savedData);
}
else {
const isArray = this.settings.savedData.hasOwnProperty('length');
if (isArray) {
this.optionSelection = new SelectionModel<any>(true, this.settings.savedData);
} else {
this.optionSelection = new SelectionModel<any>(true, [this.settings.savedData]);
}
}
} else {
if (!this.settings.savedData) {
this.optionSelection = new SelectionModel<any>();
return;
}
if (this.settings.multiple) {
this.optionSelection = new SelectionModel<any>(true, this.settings.savedData);
return;
}
if (Array.isArray(this.settings.savedData)) {
this.optionSelection = new SelectionModel<any>(true, this.settings.savedData);
return;
}
this.optionSelection = new SelectionModel<any>(true, [this.settings.savedData]);
}

View File

@ -1,5 +1,8 @@
import {Observable} from 'rxjs';
import {Observable, of} from 'rxjs';
import {FormControl} from '@angular/forms';
import {Language} from "../../_models/metadata/language";
import {map} from "rxjs/operators";
import {UtilityService} from "../../shared/_services/utility.service";
export type SelectionCompareFn<T> = (a: T, b: T) => boolean;
@ -70,3 +73,48 @@ export class TypeaheadSettings<T> {
*/
trackByIdentityFn!: (index: number, value: T) => string;
}
/**
* Configure a new TypeaheadSettings<Language> as a language type ahead
* @param showLocked
* @param utilityService
* @param allLanguages
* @param currentSelectedLanguage
* @returns settings
*/
export function setupLanguageSettings(
showLocked: boolean,
utilityService: UtilityService,
allLanguages: Array<Language>,
currentSelectedLanguage: string | Array<string> | undefined,
): TypeaheadSettings<Language> {
const settings = new TypeaheadSettings<Language>();
settings.minCharacters = 0;
settings.multiple = false;
settings.id = 'language';
settings.unique = true;
settings.showLocked = showLocked;
settings.addIfNonExisting = false;
settings.compareFn = (options: Language[], filter: string) => {
return options.filter(m => utilityService.filter(m.title, filter));
}
settings.compareFnForAdd = (options: Language[], filter: string) => {
return options.filter(m => utilityService.filterMatches(m.title, filter));
}
settings.fetchFn = (filter: string) => of(allLanguages)
.pipe(map(items => settings.compareFn(items, filter)));
settings.selectionCompareFn = (a: Language, b: Language) => {
return a.isoCode === b.isoCode;
}
settings.trackByIdentityFn = (_, value) => value.isoCode;
const l = allLanguages.find(l => l.isoCode === currentSelectedLanguage);
if (l !== undefined) {
settings.savedData = l;
}
return settings;
}

View File

@ -0,0 +1,74 @@
<ng-container *transloco="let t; prefix: 'manage-custom-key-binds'">
<p [innerHTML]="t('description', {max: MAX_KEYBINDS_PER_TARGET}) | safeHtml">
</p>
<form [formGroup]="keyBindForm" class="row mb-4">
@for (keyBindGroup of filteredKeyBindGroups(); track keyBindGroup.title) {
@if (!$first) {
<div class="setting-section-break"></div>
}
<h4>{{t(keyBindGroup.title)}}</h4>
@for (element of keyBindGroup.elements; track element.target) {
@if (getFormArray(element.target); as array) {
<div class="col-12 mt-4" [formArrayName]="element.target">
@let settingDesc = element.target | keybindSettingDescription;
<app-setting-item
[title]="settingDesc.title"
[subtitle]="settingDesc.tooltip"
[labelId]="element.target + ''"
[showEdit]="false"
[canEdit]="false"
[allowClickEvents]="true"
>
<ng-template #view>
<div class="d-flex flex-row justify-content-between">
<div>
@for (keyBindControl of array.controls; track $index;) {
<app-setting-key-bind-picker
appLongClick
[control]="keyBindControl"
[formControlName]="$index + ''"
[target]="element.target"
[index]="$index"
[duplicated]="duplicatedKeyBinds()[element.target]?.includes($index) ?? false"
[ngbTooltip]="errorToolTip(element.target, $index, keyBindControl.errors)"
(longClick)="removeKeyBind(element.target, $index)"
/>
} @empty {
{{null | defaultValue}}
}
</div>
<div class="float-end">
@if (array.controls.length < MAX_KEYBINDS_PER_TARGET) {
<button class="btn btn-sm" (click)="addKeyBind(element.target)" [ngbTooltip]="t('add', {target: settingDesc.title})">
<i class="fa fa-add" [attr.aria-label]="t('add', {target: element.target})"></i>
</button>
}
<button class="btn btn-sm" (click)="resetKeybindsToDefaults(element.target)" [ngbTooltip]="t('reset', {target: settingDesc.title})">
<i class="fa fa-recycle" [attr.aria-label]="t('reset', {target: element.target})"></i>
</button>
@if (!array.valid) {
<i class="fa fa-warning text-danger"
[attr.aria-label]="t('errors-array', {target: element.target})"
[ngbTooltip]="errorToolTip(element.target, -1, array.errors)">
</i>
}
</div>
</div>
</ng-template>
</app-setting-item>
</div>
}
}
}
</form>
</ng-container>

View File

@ -0,0 +1,277 @@
import {ChangeDetectionStrategy, Component, computed, DestroyRef, inject, OnInit, signal} from '@angular/core';
import {DefaultKeyBinds, KeyBindGroups, KeyBindService, KeyCode,} from "../../_services/key-bind.service";
import {
FormArray,
FormControl,
FormGroup,
NonNullableFormBuilder,
ReactiveFormsModule,
ValidationErrors,
ValidatorFn
} from "@angular/forms";
import {KeyBind, KeyBindTarget, Preferences} from "../../_models/preferences/preferences";
import {TranslocoDirective, TranslocoService} from "@jsverse/transloco";
import {SettingItemComponent} from "../../settings/_components/setting-item/setting-item.component";
import {
SettingKeyBindPickerComponent
} from "../../settings/_components/setting-key-bind-picker/setting-key-bind-picker.component";
import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
import {catchError, debounceTime, distinctUntilChanged, filter, of, switchMap, tap} from "rxjs";
import {map} from "rxjs/operators";
import {AccountService} from "../../_services/account.service";
import {DefaultValuePipe} from "../../_pipes/default-value.pipe";
import {LongClickDirective} from "../../_directives/long-click.directive";
import {NgbTooltip} from "@ng-bootstrap/ng-bootstrap";
import {ToastrService} from "ngx-toastr";
import {LicenseService} from "../../_services/license.service";
import {KeybindSettingDescriptionPipe} from "../../_pipes/keybind-setting-description.pipe";
import {DOCUMENT} from "@angular/common";
import {SafeHtmlPipe} from "../../_pipes/safe-html.pipe";
type KeyBindFormGroup = FormGroup<{
[K in KeyBindTarget]: FormArray<FormControl<KeyBind>>
}>;
const MAX_KEYBINDS_PER_TARGET = 5;
@Component({
selector: 'app-manage-custom-key-binds',
imports: [
ReactiveFormsModule,
SettingItemComponent,
SettingKeyBindPickerComponent,
DefaultValuePipe,
NgbTooltip,
KeybindSettingDescriptionPipe,
TranslocoDirective,
LongClickDirective,
SafeHtmlPipe
],
templateUrl: './manage-custom-key-binds.component.html',
styleUrl: './manage-custom-key-binds.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush
})
export class ManageCustomKeyBindsComponent implements OnInit {
private readonly accountService = inject(AccountService);
protected readonly keyBindService = inject(KeyBindService);
private readonly transLoco = inject(TranslocoService);
private readonly fb = inject(NonNullableFormBuilder);
private readonly toastr = inject(ToastrService);
private readonly destroyRef = inject(DestroyRef);
private readonly licenseService = inject(LicenseService);
private readonly document = inject(DOCUMENT);
protected keyBindForm!: KeyBindFormGroup;
protected duplicatedKeyBinds = signal<Partial<Record<KeyBindTarget, number[]>>>({});
protected filteredKeyBindGroups = computed(() => {
const roles = this.accountService.currentUserSignal()!.roles;
const hasKPlus = this.licenseService.hasValidLicenseSignal();
return KeyBindGroups.map(g => {
g.elements = g.elements.filter(e => {
if (e.roles && !e.roles.some(r => roles.includes(r))) return false;
if (e.restrictedRoles && e.restrictedRoles.some(r => roles.includes(r))) return false;
return hasKPlus || !e.kavitaPlus;
})
return g;
}).filter(g => g.elements.length > 0);
});
ngOnInit() {
const keyBinds = this.keyBindService.allKeyBinds();
const groupConfig = Object.entries(keyBinds).reduce((acc, [key, value]) => {
acc[key as KeyBindTarget] = this.fb.array(this.toFormControls(value), this.keyBindArrayValidator());
return acc;
}, {} as Record<KeyBindTarget, FormArray<FormControl<KeyBind>>>);
this.keyBindForm = this.fb.group(groupConfig);
this.duplicatedKeyBinds.set(this.extractDuplicated(keyBinds)); // Set initial
this.keyBindForm.valueChanges.pipe(
takeUntilDestroyed(this.destroyRef),
debounceTime(250),
distinctUntilChanged(),
map(formValue => this.extractDuplicated(formValue)),
tap(d => this.duplicatedKeyBinds.set(d)),
).subscribe();
this.keyBindForm.valueChanges.pipe(
takeUntilDestroyed(this.destroyRef),
debounceTime(500),
distinctUntilChanged(),
filter(() => this.keyBindForm.valid),
map(formValue => this.extractCustomKeyBinds(formValue)),
map(customKeyBinds => this.combinePreferences(customKeyBinds)),
switchMap(p => this.accountService.updatePreferences(p)),
catchError(err => {
console.error(err);
this.toastr.error(err);
return of(null);
}),
).subscribe();
}
private extractDuplicated(formValue: Partial<Record<KeyBindTarget, KeyBind[]>>): Partial<Record<KeyBindTarget, number[]>> {
const entries = Object.entries(formValue);
return Object.fromEntries(entries
.map(([target, keyBinds]) => {
const duplicatedIndices = keyBinds.map((keyBind, index) => {
const isDuplicated = entries.some(([otherTarget, otherKeyBinds]) => {
if (otherTarget === target) return false;
return otherKeyBinds.some(kb => this.keyBindService.areKeyBindsEqual(keyBind, kb));
});
return isDuplicated ? index : -1;
})
.filter(index => index !== -1) ?? [];
return [target, duplicatedIndices];
})
.filter(([_, indices]) => (indices as number[]).length > 0)
) as Partial<Record<KeyBindTarget, number[]>>;
}
private extractCustomKeyBinds(formValue: Partial<Record<KeyBindTarget, KeyBind[]>>): Partial<Record<KeyBindTarget, KeyBind[]>> {
return Object.fromEntries(
Object.entries(formValue).filter(([target, keybinds]) =>
!this.keyBindService.isDefaultKeyBinds(target as KeyBindTarget, keybinds)
)
) as Partial<Record<KeyBindTarget, KeyBind[]>>;
}
private combinePreferences(customKeyBinds: Partial<Record<KeyBindTarget, KeyBind[]>>): Preferences {
return {
...this.accountService.currentUserSignal()!.preferences,
customKeyBinds,
};
}
private toFormControls(keybinds: KeyBind[]): FormControl<KeyBind>[] {
return keybinds.map(keyBind => this.fb.control(keyBind, this.keyBindValidator()));
}
/**
* Typed getter for the FormArray of a given target
* @param key
*/
getFormArray(key: KeyBindTarget): FormArray<FormControl<KeyBind>> | null {
return this.keyBindForm.get(key) as FormArray<FormControl<KeyBind>> | null;
}
/**
* Reset keybinds to default configured values
* @param key
*/
resetKeybindsToDefaults(key: KeyBindTarget) {
if (this.accountService.isReadOnly()) return;
this.keyBindForm.setControl(key, this.fb.array(this.toFormControls(DefaultKeyBinds[key]), this.keyBindArrayValidator()));
}
/**
* Add a new keybind option to the array, NOP if MAX_KEYBINDS_PER_TARGET has been reached
* @param key
*/
addKeyBind(key: KeyBindTarget) {
if (this.accountService.isReadOnly()) return;
const array = this.getFormArray(key);
if (!array) return;
if (array.controls.length < MAX_KEYBINDS_PER_TARGET) {
array.push(this.fb.control({key: KeyCode.Empty}, this.keyBindValidator()));
}
setTimeout(() => {
const id = `key-bind-${key}-${array.length-1}`;
const newElement = this.document.getElementById(id);
if (newElement) {
newElement.focus();
}
}, 100);
}
/**
* Remove a keybind from the array, if this is the last keybind. Resets to default
* @param key
* @param index
*/
removeKeyBind(key: KeyBindTarget, index: number) {
if (this.accountService.isReadOnly()) return;
const array = this.getFormArray(key);
if (!array) return;
if (array.controls.length === 1) {
this.resetKeybindsToDefaults(key);
} else {
array.removeAt(index)
}
}
/**
* Custom validator for FormControl<KeyBind>
* @private
*/
private keyBindValidator(): ValidatorFn {
return (control) => {
const keyBind = (control as FormControl<KeyBind>).value;
if (keyBind.key.length === 0 && !keyBind.controllerSequence) return { 'need-at-least-one-key': {'length': 0} } as ValidationErrors;
if (this.keyBindService.isReservedKeyBind(keyBind)) {
return { 'reserved-key-bind': { 'keyBind': keyBind }} as ValidationErrors
}
return null;
}
}
private keyBindArrayValidator(): ValidatorFn {
return (control) => {
const controls = (control as FormArray<FormControl<KeyBind>>).controls;
const anyOverlap = controls.some((c, i) => controls.some((c2, i2)=> {
return i !== i2 && this.keyBindService.areKeyBindsEqual(c.value, c2.value);
}))
if (anyOverlap) {
return { 'overlap-in-target': { '': '' } }
}
return null;
}
}
/**
* Combined tooltip for FormControl<KeyBind> errors
* @param target
* @param index
* @param errors
* @protected
*/
protected errorToolTip(target: KeyBindTarget, index: number, errors: ValidationErrors | null): string | null {
if (errors) {
return Object.keys(errors)
.map(key => this.transLoco.translate(`manage-custom-key-binds.key-bind-error-${key}`))
.join(' ')
.trim() || null;
}
if (this.duplicatedKeyBinds()[target]?.includes(index)) {
return this.transLoco.translate('manage-custom-key-binds.warning-duplicate-key-bind');
}
return null;
}
protected readonly Object = Object;
protected readonly MAX_KEYBINDS_PER_TARGET = MAX_KEYBINDS_PER_TARGET;
}

View File

@ -223,6 +223,10 @@ export class ManageUserPreferencesComponent implements OnInit {
}
packSettings(): Preferences {
return this.settingsForm.getRawValue();
const customKeyBinds = this.accountService.currentUserSignal()!.preferences.customKeyBinds;
return {
customKeyBinds,
...this.settingsForm.getRawValue(),
};
}
}

View File

@ -28,6 +28,7 @@
[libraryType]="libraryType"
[mangaFormat]="series.format"
[totalBytes]="size"
[releaseYear]="(volume.chapters.at(0)?.releaseDate | utcToLocaleDate)?.getFullYear()"
/>
@if (libraryType !== null && series && volume.chapters.length === 1) {

View File

@ -85,6 +85,7 @@ import {User} from "../_models/user";
import {AnnotationService} from "../_services/annotation.service";
import {Annotation} from "../book-reader/_models/annotations/annotation";
import {AnnotationsTabComponent} from "../_single-module/annotations-tab/annotations-tab.component";
import {UtcToLocaleDatePipe} from "../_pipes/utc-to-locale-date.pipe";
enum TabID {
@ -158,7 +159,8 @@ interface VolumeCast extends IHasCast {
CoverImageComponent,
ReviewsComponent,
ExternalRatingComponent,
AnnotationsTabComponent
AnnotationsTabComponent,
UtcToLocaleDatePipe
],
templateUrl: './volume-detail.component.html',
styleUrl: './volume-detail.component.scss',

View File

@ -25,6 +25,7 @@
"provider-tooltip": "Provider settings require you to manually click Save. Kavita must be configured as a confidential client and needs a redirect URL. See the <a href='https://wiki.kavitareader.com/guides/admin-settings/open-id-connect/' target='_blank' rel='noreferrer noopener'>wiki</a> for more details.",
"behavior-title": "Behavior",
"other-field-required": "{{validation.other-field-required}}",
"other-field-invalid": "{{validation.other-field-invalid}}",
"invalid-uri": "{{validation.invalid-uri}}",
"tls-required": "The OIDC provider must use tls (https)",
"manual-save-label": "Changing provider settings requires a manual save",
@ -1370,6 +1371,8 @@
"remove-prefix-for-sortname-tooltip": "Kavita will remove common prefixes like 'The', 'A', 'An' from titles for sort name. Does not override set metadata.",
"inherit-web-links-label": "Inherit web links from first chapter",
"inherit-web-links-tooltip": "Should series inherit web links from their first chapter",
"default-language-label": "Default language",
"default-language-tooltip": "Language to assign to series, if none of the chapters have any language set in their metadata",
"force-scan": "Force Scan",
"force-scan-tooltip": "This will force a scan on the library, treating like a fresh scan",
"reset": "{{common.reset}}",
@ -1958,6 +1961,7 @@
"scrobble-holds": "Scrobble Holds",
"account": "Account",
"preferences": "Preferences",
"custom-key-binds": "Key Binds",
"reading-profiles": "Reading Profiles",
"clients": "API Key / OPDS",
"devices": "Devices",
@ -1971,6 +1975,49 @@
"admin-public-metadata": "Manage Metadata"
},
"manage-custom-key-binds": {
"description": "Sometimes clicking and dragging your mouse is just too slow. Use your preferred keybinds to speed things up. Each option allows for up to {{max}} different keybinds. <b> Long press a keybind to remove it. </b>",
"key-bind-error-reserved-key-bind": "This keybind is reserved and cannot be used",
"key-bind-error-need-at-least-one-key": "Your keybind must contain at least one key",
"key-bind-error-overlap-in-target": "One or more keybinds are the same",
"add": "Add alternative key",
"reset": "Reset {{target}}",
"errors-array": "Keybinds for {{target}} contain errors",
"errors-control": "Keybind {{index}} for {{target}} contains errors",
"warning-duplicated-control": "Keybind {{index}} for {{target}} has been used for an other target as well",
"warning-duplicate-key-bind": "This keybind has also been used elsewhere",
"key-bind-tooltip": "Long press a keybind to remove it.",
"global-header": "Global",
"readers-header": "Readers"
},
"keybind-setting-description-pipe": {
"key-bind-title-navigate-to-settings": "Open settings",
"key-bind-tooltip-navigate-to-settings": "Open settings while not in a reader",
"key-bind-title-open-search": "Open search",
"key-bind-tooltip-open-search": "Open the top search bar",
"key-bind-title-navigate-to-scrobbling": "Open Scrobbling",
"key-bind-tooltip-navigate-to-scrobbling": "Open settings page on the Scrobbling tab",
"key-bind-title-escape": "Escape",
"key-bind-tooltip-escape": "Close the currently open context",
"key-bind-title-toggle-fullscreen": "Toggle full screen",
"key-bind-tooltip-toggle-fullscreen": "Alternative to F11",
"key-bind-title-bookmark-page": "Bookmark current page",
"key-bind-tooltip-bookmark-page": "Saves the current image as a bookmark",
"key-bind-title-open-help": "Open help menu",
"key-bind-tooltip-open-help": "Opens a help modal with all relevant keybinds",
"key-bind-title-go-to": "Goto page",
"key-bind-tooltip-go-to": "Open a prompt to switch pages",
"key-bind-title-toggle-menu": "Toggle menu",
"key-bind-tooltip-toggle-menu": "Toggles to reader menu",
"key-bind-title-page-left": "Page left",
"key-bind-tooltip-page-left": "Move one page to the left",
"key-bind-title-page-right": "Page right",
"key-bind-tooltip-page-right": "Move one page to the right"
},
"collection-detail": {
"no-data": "There are no items. Try adding a series.",
"no-data-filtered": "No items match your current filter.",
@ -2559,8 +2606,10 @@
"role-label": "Role",
"mal-id-label": "MAL Id",
"mal-tooltip": "https://myanimelist.net/people/{MalId}/",
"mal-tooltip-character": "https://myanimelist.net/character/{MalId}/",
"anilist-id-label": "AniList Id",
"anilist-tooltip": "https://anilist.co/staff/{AniListId}/",
"anilist-tooltip-character": "https://anilist.co/character/{AniListId}/",
"hardcover-id-label": "Hardcover Id",
"hardcover-tooltip": "https://hardcover.app/authors/{HardcoverId}",
"asin-label": "ASIN",
@ -2720,6 +2769,7 @@
"font-manual-upload": "There was an issue creating Font from manual upload",
"font-already-in-use": "Font already exists by that name",
"upload-too-large": "The file is too large for upload, select a smaller image and try again.",
"invalid-form": "The form you're trying to submit contains errors",
"import-fields": {
"non-unique-age-ratings": "Age rating mapping keys aren't unique, please correct your import file",
"non-unique-fields": "Field mappings do not have a unique id, please correct your import file"
@ -3321,6 +3371,7 @@
"validation": {
"required-field": "This field is required",
"other-field-required": "{{name}} is required when {{other}} is set",
"other-field-invalid": "Cannot check validity, {{other}} is invalid",
"valid-email": "This must be a valid email",
"password-validation": "Password must be between 6 and 256 characters in length",
"year-validation": "This must be a valid year greater than 1000 and 4 characters long",