Polish for Release (#2314)

This commit is contained in:
Joe Milazzo 2023-10-15 13:39:11 -05:00 committed by GitHub
parent fe4af4b648
commit 59b950c4bd
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
54 changed files with 1162 additions and 1056 deletions

View File

@ -10,8 +10,8 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="BenchmarkDotNet" Version="0.13.8" />
<PackageReference Include="BenchmarkDotNet.Annotations" Version="0.13.8" />
<PackageReference Include="BenchmarkDotNet" Version="0.13.9" />
<PackageReference Include="BenchmarkDotNet.Annotations" Version="0.13.9" />
<PackageReference Include="NSubstitute" Version="5.1.0" />
</ItemGroup>

View File

@ -6,13 +6,13 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="7.0.11" />
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="7.0.12" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.7.2" />
<PackageReference Include="NSubstitute" Version="5.1.0" />
<PackageReference Include="System.IO.Abstractions.TestingHelpers" Version="19.2.69" />
<PackageReference Include="TestableIO.System.IO.Abstractions.Wrappers" Version="19.2.69" />
<PackageReference Include="xunit" Version="2.5.1" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.5.1">
<PackageReference Include="xunit" Version="2.5.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.5.3">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>

View File

@ -68,22 +68,22 @@
<PackageReference Include="Hangfire.Storage.SQLite" Version="0.3.4" />
<PackageReference Include="HtmlAgilityPack" Version="1.11.53" />
<PackageReference Include="MarkdownDeep.NET.Core" Version="1.5.0.4" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="7.0.11" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.OpenIdConnect" Version="7.0.11" />
<PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="7.0.11" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="7.0.12" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.OpenIdConnect" Version="7.0.12" />
<PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="7.0.12" />
<PackageReference Include="Microsoft.AspNetCore.SignalR" Version="1.1.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="7.0.11">
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="7.0.12">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="7.0.11" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="7.0.12" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="7.0.0" />
<PackageReference Include="Microsoft.IO.RecyclableMemoryStream" Version="2.3.2" />
<PackageReference Include="MimeTypeMapOfficial" Version="1.0.17" />
<PackageReference Include="Nager.ArticleNumber" Version="1.0.7" />
<PackageReference Include="NetVips" Version="2.3.1" />
<PackageReference Include="NetVips.Native" Version="8.14.5" />
<PackageReference Include="NReco.Logging.File" Version="1.1.6" />
<PackageReference Include="NReco.Logging.File" Version="1.1.7" />
<PackageReference Include="Serilog" Version="3.0.1" />
<PackageReference Include="Serilog.AspNetCore" Version="7.0.0" />
<PackageReference Include="Serilog.Enrichers.Thread" Version="3.2.0-dev-00752" />
@ -93,15 +93,15 @@
<PackageReference Include="Serilog.Sinks.Console" Version="4.1.0" />
<PackageReference Include="Serilog.Sinks.File" Version="5.0.0" />
<PackageReference Include="Serilog.Sinks.SignalR.Core" Version="0.1.2" />
<PackageReference Include="SharpCompress" Version="0.34.0" />
<PackageReference Include="SharpCompress" Version="0.34.1" />
<PackageReference Include="SixLabors.ImageSharp" Version="3.0.2" />
<PackageReference Include="SonarAnalyzer.CSharp" Version="9.10.0.77988">
<PackageReference Include="SonarAnalyzer.CSharp" Version="9.12.0.78982">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.5.0" />
<PackageReference Include="Swashbuckle.AspNetCore.Filters" Version="7.0.11" />
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="7.0.0" />
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="7.0.2" />
<PackageReference Include="System.IO.Abstractions" Version="19.2.69" />
<PackageReference Include="System.Drawing.Common" Version="7.0.0" />
<PackageReference Include="VersOne.Epub" Version="3.3.1" />

View File

@ -306,12 +306,13 @@ public class AccountController : BaseApiController
/// <summary>
/// Resets the API Key assigned with a user
/// </summary>
/// <remarks>This will log unauthorized requests to Security log</remarks>
/// <returns></returns>
[HttpPost("reset-api-key")]
public async Task<ActionResult<string>> ResetApiKey()
{
var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername());
if (user == null) return Unauthorized();
if (user == null) throw new KavitaUnauthenticatedUserException();
user.ApiKey = HashUtil.ApiKey();

View File

@ -98,8 +98,11 @@ public class LibraryController : BaseApiController
admin.Libraries.Add(library);
}
var userIds = admins.Select(u => u.Id).Append(User.GetUserId()).ToList();
if (!await _unitOfWork.CommitAsync()) return BadRequest(await _localizationService.Translate(User.GetUserId(), "generic-library"));
_logger.LogInformation("Created a new library: {LibraryName}", library.Name);
// Assign all the necessary users with this library side nav
var userIds = admins.Select(u => u.Id).Append(User.GetUserId()).ToList();
var userNeedingNewLibrary = (await _unitOfWork.UserRepository.GetAllUsersAsync(AppUserIncludes.SideNavStreams))
.Where(u => userIds.Contains(u.Id))
.ToList();
@ -119,10 +122,8 @@ public class LibraryController : BaseApiController
_unitOfWork.UserRepository.Update(user);
}
if (!await _unitOfWork.CommitAsync()) return BadRequest(await _localizationService.Translate(User.GetUserId(), "generic-library"));
_logger.LogInformation("Created a new library: {LibraryName}", library.Name);
await _libraryWatcher.RestartWatching();
_taskScheduler.ScanLibrary(library.Id);
await _eventHub.SendMessageAsync(MessageFactory.LibraryModified,

View File

@ -216,19 +216,41 @@ public class OpdsController : BaseApiController
CreateLink(FeedLinkRelation.SubSection, FeedLinkType.AtomNavigation, $"{prefix}{apiKey}/collections"),
}
});
feed.Entries.Add(new FeedEntry()
if ((_unitOfWork.AppUserSmartFilterRepository.GetAllDtosByUserId(userId)).Any())
{
Id = "allSmartFilters",
Title = await _localizationService.Translate(userId, "smart-filters"),
Content = new FeedEntryContent()
feed.Entries.Add(new FeedEntry()
{
Text = await _localizationService.Translate(userId, "browse-smart-filters")
},
Links = new List<FeedLink>()
{
CreateLink(FeedLinkRelation.SubSection, FeedLinkType.AtomNavigation, $"{prefix}{apiKey}/smart-filters"),
}
});
Id = "allSmartFilters",
Title = await _localizationService.Translate(userId, "smart-filters"),
Content = new FeedEntryContent()
{
Text = await _localizationService.Translate(userId, "browse-smart-filters")
},
Links = new List<FeedLink>()
{
CreateLink(FeedLinkRelation.SubSection, FeedLinkType.AtomNavigation, $"{prefix}{apiKey}/smart-filters"),
}
});
}
// if ((await _unitOfWork.AppUserExternalSourceRepository.GetExternalSources(userId)).Any())
// {
// feed.Entries.Add(new FeedEntry()
// {
// Id = "allExternalSources",
// Title = await _localizationService.Translate(userId, "external-sources"),
// Content = new FeedEntryContent()
// {
// Text = await _localizationService.Translate(userId, "browse-external-sources")
// },
// Links = new List<FeedLink>()
// {
// CreateLink(FeedLinkRelation.SubSection, FeedLinkType.AtomNavigation, $"{prefix}{apiKey}/external-sources"),
// }
// });
// }
return CreateXmlResult(SerializeXml(feed));
}
@ -306,6 +328,38 @@ public class OpdsController : BaseApiController
return CreateXmlResult(SerializeXml(feed));
}
[HttpGet("{apiKey}/external-sources")]
[Produces("application/xml")]
public async Task<IActionResult> GetExternalSources(string apiKey)
{
// NOTE: This doesn't seem possible in OPDS v2.1 due to the resulting stream using relative links and most apps resolve against source url. Even using full paths doesn't work
var userId = await GetUser(apiKey);
if (!(await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).EnableOpds)
return BadRequest(await _localizationService.Translate(userId, "opds-disabled"));
var (baseUrl, prefix) = await GetPrefix();
var externalSources = await _unitOfWork.AppUserExternalSourceRepository.GetExternalSources(userId);
var feed = CreateFeed(await _localizationService.Translate(userId, "external-sources"), $"{prefix}{apiKey}/external-sources", apiKey, prefix);
SetFeedId(feed, "externalSources");
foreach (var externalSource in externalSources)
{
var opdsUrl = $"{externalSource.Host}api/opds/{externalSource.ApiKey}";
feed.Entries.Add(new FeedEntry()
{
Id = externalSource.Id.ToString(),
Title = externalSource.Name,
Summary = externalSource.Host,
Links = new List<FeedLink>()
{
CreateLink(FeedLinkRelation.Start, FeedLinkType.AtomNavigation, opdsUrl),
CreateLink(FeedLinkRelation.Thumbnail, FeedLinkType.Image, $"{opdsUrl}/favicon")
}
});
}
return CreateXmlResult(SerializeXml(feed));
}
[HttpGet("{apiKey}/libraries")]
[Produces("application/xml")]
@ -318,12 +372,16 @@ public class OpdsController : BaseApiController
var libraries = await _unitOfWork.LibraryRepository.GetLibrariesForUserIdAsync(userId);
var feed = CreateFeed(await _localizationService.Translate(userId, "libraries"), $"{prefix}{apiKey}/libraries", apiKey, prefix);
SetFeedId(feed, "libraries");
foreach (var library in libraries)
// Ensure libraries follow SideNav order
var userSideNavStreams = await _unitOfWork.UserRepository.GetSideNavStreams(userId, false);
foreach (var sideNavStream in userSideNavStreams.Where(s => s.StreamType == SideNavStreamType.Library))
{
var library = sideNavStream.Library;
feed.Entries.Add(new FeedEntry()
{
Id = library.Id.ToString(),
Title = library.Name,
Id = library!.Id.ToString(),
Title = library.Name!,
Links = new List<FeedLink>()
{
CreateLink(FeedLinkRelation.SubSection, FeedLinkType.AtomNavigation, $"{prefix}{apiKey}/libraries/{library.Id}"),

View File

@ -28,6 +28,7 @@ public class PluginController : BaseApiController
/// Authenticate with the Server given an apiKey. This will log you in by returning the user object and the JWT token.
/// </summary>
/// <remarks>This API is not fully built out and may require more information in later releases</remarks>
/// <remarks>This will log unauthorized requests to Security log</remarks>
/// <param name="apiKey">API key which will be used to authenticate and return a valid user token back</param>
/// <param name="pluginName">Name of the Plugin</param>
/// <returns></returns>
@ -37,8 +38,19 @@ public class PluginController : BaseApiController
{
// NOTE: In order to log information about plugins, we need some Plugin Description information for each request
// Should log into access table so we can tell the user
var ipAddress = HttpContext.Connection.RemoteIpAddress?.ToString();
var userAgent = HttpContext.Request.Headers["User-Agent"];
var userId = await _unitOfWork.UserRepository.GetUserIdByApiKeyAsync(apiKey);
if (userId <= 0) return Unauthorized();
if (userId <= 0)
{
_logger.LogInformation("A Plugin ({PluginName}) tried to authenticate with an apiKey that doesn't match. Information {Information}", pluginName, new
{
IpAddress = ipAddress,
UserAgent = userAgent,
ApiKey = apiKey
});
throw new KavitaUnauthenticatedUserException();
}
var user = await _unitOfWork.UserRepository.GetUserByIdAsync(userId);
_logger.LogInformation("Plugin {PluginName} has authenticated with {UserName} ({UserId})'s API Key", pluginName, user!.UserName, userId);
return new UserDto
@ -54,6 +66,7 @@ public class PluginController : BaseApiController
/// <summary>
/// Returns the version of the Kavita install
/// </summary>
/// <remarks>This will log unauthorized requests to Security log</remarks>
/// <param name="apiKey">Required for authenticating to get result</param>
/// <returns></returns>
[AllowAnonymous]
@ -61,7 +74,7 @@ public class PluginController : BaseApiController
public async Task<ActionResult<string>> GetVersion([Required] string apiKey)
{
var userId = await _unitOfWork.UserRepository.GetUserIdByApiKeyAsync(apiKey);
if (userId <= 0) return Unauthorized();
if (userId <= 0) throw new KavitaUnauthenticatedUserException();
return Ok((await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.InstallVersion)).Value);
}
}

View File

@ -111,6 +111,7 @@ public class ScrobblingController : BaseApiController
pagination ??= UserParams.Default;
var events = await _unitOfWork.ScrobbleRepository.GetUserEvents(User.GetUserId(), filter, pagination);
Response.AddPaginationHeader(events.CurrentPage, events.PageSize, events.TotalCount, events.TotalPages);
return Ok(events);
}

View File

@ -183,4 +183,11 @@ public class StreamController : BaseApiController
await _streamService.UpdateSideNavStreamPosition(User.GetUserId(), dto);
return Ok();
}
[HttpPost("bulk-sidenav-stream-visibility")]
public async Task<ActionResult> BulkUpdateSideNavStream(BulkUpdateSideNavStreamVisibilityDto dto)
{
await _streamService.UpdateSideNavStreamBulk(User.GetUserId(), dto);
return Ok();
}
}

View File

@ -0,0 +1,9 @@
using System.Collections.Generic;
namespace API.DTOs.SideNav;
public class BulkUpdateSideNavStreamVisibilityDto
{
public required IList<int> Ids { get; set; }
public required bool Visibility { get; set; }
}

View File

@ -0,0 +1,35 @@
using System.Linq;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
namespace API.Data.ManualMigrations;
/// <summary>
/// v0.7.8.6 explicitly introduced DashboardStream and v0.7.8.9 changed the default seed titles to use locale strings.
/// This migration will target nightly releases and should be removed before v0.7.9 release.
/// </summary>
public static class MigrateDashboardStreamNamesToLocaleKeys
{
public static async Task Migrate(IUnitOfWork unitOfWork, DataContext dataContext, ILogger<Program> logger)
{
var allStreams = await unitOfWork.UserRepository.GetAllDashboardStreams();
if (!allStreams.Any(s => s.Name.Equals("On Deck"))) return;
logger.LogCritical("Running MigrateDashboardStreamNamesToLocaleKeys migration. Please be patient, this may take some time depending on the size of your library. Do not abort, this can break your Database");
foreach (var stream in allStreams.Where(s => s.IsProvided))
{
stream.Name = stream.Name switch
{
"On Deck" => "on-deck",
"Recently Updated" => "recently-updated",
"Newly Added" => "newly-added",
"More In" => "more-in-genre",
_ => stream.Name
};
unitOfWork.UserRepository.Update(stream);
}
await unitOfWork.CommitAsync();
logger.LogInformation("MigrateDashboardStreamNamesToLocaleKeys migration finished");
}
}

View File

@ -84,6 +84,7 @@ public interface IUserRepository
Task<IList<ScrobbleHoldDto>> GetHolds(int userId);
Task<string> GetLocale(int userId);
Task<IList<DashboardStreamDto>> GetDashboardStreams(int userId, bool visibleOnly = false);
Task<IList<AppUserDashboardStream>> GetAllDashboardStreams();
Task<AppUserDashboardStream?> GetDashboardStream(int streamId);
Task<IList<AppUserDashboardStream>> GetDashboardStreamWithFilter(int filterId);
Task<IList<SideNavStreamDto>> GetSideNavStreams(int userId, bool visibleOnly = false);
@ -91,6 +92,7 @@ public interface IUserRepository
Task<IList<AppUserSideNavStream>> GetSideNavStreamWithFilter(int filterId);
Task<IList<AppUserSideNavStream>> GetSideNavStreamsByLibraryId(int libraryId);
Task<IList<AppUserSideNavStream>> GetSideNavStreamWithExternalSource(int externalSourceId);
Task<IList<AppUserSideNavStream>> GetDashboardStreamsByIds(IList<int> streamIds);
}
public class UserRepository : IUserRepository
@ -356,6 +358,13 @@ public class UserRepository : IUserRepository
.ToListAsync();
}
public async Task<IList<AppUserDashboardStream>> GetAllDashboardStreams()
{
return await _context.AppUserDashboardStream
.OrderBy(d => d.Order)
.ToListAsync();
}
public async Task<AppUserDashboardStream?> GetDashboardStream(int streamId)
{
return await _context.AppUserDashboardStream
@ -453,6 +462,13 @@ public class UserRepository : IUserRepository
.ToListAsync();
}
public async Task<IList<AppUserSideNavStream>> GetDashboardStreamsByIds(IList<int> streamIds)
{
return await _context.AppUserSideNavStream
.Where(d => streamIds.Contains(d.Id))
.ToListAsync();
}
public async Task<IEnumerable<AppUser>> GetAdminUsersAsync()
{

View File

@ -151,6 +151,8 @@
"collections": "All Collections",
"browse-collections": "Browse by Collections",
"smart-filters": "Smart Filters",
"external-sources": "External Sources",
"browse-external-sources": "Browse External Sources",
"browse-smart-filters": "Browse by Smart Filters",
"reading-list-restricted": "Reading list does not exist or you don't have access",
"query-required": "You must pass a query parameter",

View File

@ -0,0 +1,69 @@
using System;
using System.IO;
using System.Net;
using System.Text.Json;
using System.Threading.Tasks;
using API.Errors;
using Kavita.Common;
using Microsoft.AspNetCore.Http;
using Serilog;
using ILogger = Serilog.ILogger;
namespace API.Middleware;
public class SecurityEventMiddleware
{
private readonly RequestDelegate _next;
private readonly ILogger _logger;
public SecurityEventMiddleware(RequestDelegate next)
{
_next = next;
_logger = new LoggerConfiguration()
.MinimumLevel.Debug()
.WriteTo.File(Path.Join(Directory.GetCurrentDirectory(), "config/logs/", "security.log"), rollingInterval: RollingInterval.Day)
.CreateLogger();
}
public async Task InvokeAsync(HttpContext context)
{
try
{
await _next(context);
}
catch (KavitaUnauthenticatedUserException ex)
{
var ipAddress = context.Connection.RemoteIpAddress?.ToString();
var requestMethod = context.Request.Method;
var requestPath = context.Request.Path;
var userAgent = context.Request.Headers["User-Agent"];
var securityEvent = new
{
IpAddress = ipAddress,
RequestMethod = requestMethod,
RequestPath = requestPath,
UserAgent = userAgent,
CreatedAt = DateTime.Now,
CreatedAtUtc = DateTime.UtcNow,
};
_logger.Information("Unauthorized User attempting to access API. {@Event}", securityEvent);
context.Response.ContentType = "application/json";
context.Response.StatusCode = (int) HttpStatusCode.Unauthorized;
const string errorMessage = "Unauthorized";
var response = new ApiException(context.Response.StatusCode, errorMessage, ex.StackTrace);
var options = new JsonSerializerOptions
{
PropertyNamingPolicy =
JsonNamingPolicy.CamelCase
};
var json = JsonSerializer.Serialize(response, options);
await context.Response.WriteAsync(json);
}
}
}

View File

@ -24,6 +24,7 @@ public interface IStreamService
Task<DashboardStreamDto> CreateDashboardStreamFromSmartFilter(int userId, int smartFilterId);
Task UpdateDashboardStream(int userId, DashboardStreamDto dto);
Task UpdateDashboardStreamPosition(int userId, UpdateStreamPositionDto dto);
Task UpdateSideNavStreamBulk(int userId, BulkUpdateSideNavStreamVisibilityDto dto);
Task<SideNavStreamDto> CreateSideNavStreamFromSmartFilter(int userId, int smartFilterId);
Task<SideNavStreamDto> CreateSideNavStreamFromExternalSource(int userId, int externalSourceId);
Task UpdateSideNavStream(int userId, SideNavStreamDto dto);
@ -31,6 +32,7 @@ public interface IStreamService
Task<ExternalSourceDto> CreateExternalSource(int userId, ExternalSourceDto dto);
Task<ExternalSourceDto> UpdateExternalSource(int userId, ExternalSourceDto dto);
Task DeleteExternalSource(int userId, int externalSourceId);
}
public class StreamService : IStreamService
@ -134,6 +136,20 @@ public class StreamService : IStreamService
user.Id);
}
public async Task UpdateSideNavStreamBulk(int userId, BulkUpdateSideNavStreamVisibilityDto dto)
{
var streams = await _unitOfWork.UserRepository.GetDashboardStreamsByIds(dto.Ids);
foreach (var stream in streams)
{
stream.Visible = dto.Visibility;
_unitOfWork.UserRepository.Update(stream);
}
await _unitOfWork.CommitAsync();
await _eventHub.SendMessageToAsync(MessageFactory.SideNavUpdate, MessageFactory.SideNavUpdateEvent(userId),
userId);
}
public async Task<SideNavStreamDto> CreateSideNavStreamFromSmartFilter(int userId, int smartFilterId)
{
var user = await _unitOfWork.UserRepository.GetUserByIdAsync(userId, AppUserIncludes.SideNavStreams);

View File

@ -252,6 +252,7 @@ public class Startup
// v0.7.9
await MigrateUserLibrarySideNavStream.Migrate(unitOfWork, dataContext, logger);
await MigrateDashboardStreamNamesToLocaleKeys.Migrate(unitOfWork, dataContext, logger);
// Update the version in the DB after all migrations are run
var installVersion = await unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.InstallVersion);
@ -269,9 +270,9 @@ public class Startup
logger.LogCritical(ex, "An error occurred during migration");
}
app.UseMiddleware<ExceptionMiddleware>();
app.UseMiddleware<SecurityEventMiddleware>();
if (env.IsDevelopment())
{

View File

@ -14,7 +14,7 @@
<PackageReference Include="Flurl.Http" Version="3.2.4" />
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="7.0.0" />
<PackageReference Include="Microsoft.Extensions.Hosting" Version="7.0.1" />
<PackageReference Include="SonarAnalyzer.CSharp" Version="9.10.0.77988">
<PackageReference Include="SonarAnalyzer.CSharp" Version="9.12.0.78982">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>

View File

@ -0,0 +1,25 @@
using System;
using System.Runtime.Serialization;
namespace Kavita.Common;
/// <summary>
/// The user does not exist (aka unauthorized). This will be caught by middleware and Unauthorized() returned to UI
/// </summary>
/// <remarks>This will always log to Security Log</remarks>
[Serializable]
public class KavitaUnauthenticatedUserException : Exception
{
public KavitaUnauthenticatedUserException()
{ }
public KavitaUnauthenticatedUserException(string message) : base(message)
{ }
public KavitaUnauthenticatedUserException(string message, Exception inner)
: base(message, inner) { }
protected KavitaUnauthenticatedUserException(SerializationInfo info, StreamingContext context)
: base(info, context)
{ }
}

1220
UI/Web/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -13,22 +13,22 @@
},
"private": true,
"dependencies": {
"@angular/animations": "^16.2.7",
"@angular/cdk": "^16.2.6",
"@angular/common": "^16.2.7",
"@angular/compiler": "^16.2.7",
"@angular/core": "^16.2.7",
"@angular/forms": "^16.2.7",
"@angular/localize": "^16.2.7",
"@angular/platform-browser": "^16.2.7",
"@angular/platform-browser-dynamic": "^16.2.7",
"@angular/router": "^16.2.7",
"@angular/animations": "^16.2.9",
"@angular/cdk": "^16.2.8",
"@angular/common": "^16.2.9",
"@angular/compiler": "^16.2.9",
"@angular/core": "^16.2.9",
"@angular/forms": "^16.2.9",
"@angular/localize": "^16.2.9",
"@angular/platform-browser": "^16.2.9",
"@angular/platform-browser-dynamic": "^16.2.9",
"@angular/router": "^16.2.9",
"@fortawesome/fontawesome-free": "^6.4.2",
"@iharbeck/ngx-virtual-scroller": "^16.0.0",
"@iplab/ngx-file-upload": "^16.0.2",
"@microsoft/signalr": "^7.0.11",
"@microsoft/signalr": "^7.0.12",
"@ng-bootstrap/ng-bootstrap": "^15.1.1",
"@ngneat/transloco": "^5.0.7",
"@ngneat/transloco": "^6.0.0",
"@ngneat/transloco-locale": "^5.1.1",
"@ngneat/transloco-persist-lang": "^5.0.0",
"@ngneat/transloco-persist-translations": "^5.0.0",
@ -41,10 +41,11 @@
"eventsource": "^2.0.2",
"file-saver": "^2.0.5",
"lazysizes": "^5.3.2",
"luxon": "^3.4.3",
"ng-circle-progress": "^1.7.1",
"ng-select2-component": "^13.0.9",
"ngx-color-picker": "^15.0.0",
"ngx-extended-pdf-viewer": "^16.2.16",
"ngx-extended-pdf-viewer": "^18.0.2",
"ngx-file-drop": "^16.0.0",
"ngx-slider-v2": "^16.0.2",
"ngx-stars": "^1.6.5",
@ -52,27 +53,28 @@
"rxjs": "^7.8.0",
"screenfull": "^6.0.2",
"swiper": "^8.4.6",
"tslib": "^2.6.1",
"tslib": "^2.6.2",
"zone.js": "^0.13.0"
},
"devDependencies": {
"@angular-devkit/build-angular": "^16.2.4",
"@angular-eslint/builder": "^16.1.0",
"@angular-eslint/eslint-plugin": "^16.1.0",
"@angular-eslint/eslint-plugin-template": "^16.1.0",
"@angular-eslint/schematics": "^16.1.0",
"@angular-eslint/template-parser": "^16.1.0",
"@angular/cli": "^16.2.4",
"@angular/compiler-cli": "^16.2.7",
"@types/d3": "^7.4.0",
"@types/node": "^20.4.8",
"@typescript-eslint/eslint-plugin": "^6.3.0",
"@typescript-eslint/parser": "^6.3.0",
"eslint": "^8.46.0",
"@angular-devkit/build-angular": "^16.2.6",
"@angular-eslint/builder": "^16.2.0",
"@angular-eslint/eslint-plugin": "^16.2.0",
"@angular-eslint/eslint-plugin-template": "^16.2.0",
"@angular-eslint/schematics": "^16.2.0",
"@angular-eslint/template-parser": "^16.2.0",
"@angular/cli": "^16.2.6",
"@angular/compiler-cli": "^16.2.9",
"@types/d3": "^7.4.1",
"@types/luxon": "^3.3.2",
"@types/node": "^20.8.6",
"@typescript-eslint/eslint-plugin": "^6.7.5",
"@typescript-eslint/parser": "^6.7.5",
"eslint": "^8.51.0",
"jsonminify": "^0.4.2",
"karma-coverage": "~2.2.0",
"ts-node": "~10.9.1",
"typescript": "^5.1.6",
"webpack-bundle-analyzer": "^4.8.0"
"webpack-bundle-analyzer": "^4.9.1"
}
}

View File

@ -31,7 +31,15 @@ export enum FilterField
ReadingDate = 27
}
export const allFields = Object.keys(FilterField)
const enumArray = Object.keys(FilterField)
.filter(key => !isNaN(Number(key)) && parseInt(key, 10) >= 0)
.map(key => parseInt(key, 10))
.sort((a, b) => a - b) as FilterField[];
.map(key => {
// @ts-ignore
return ({key: key, value: FilterField[key]});
});
enumArray.sort((a, b) => a.value.localeCompare(b.value));
export const allFields = enumArray
.map(key => parseInt(key.key, 10))as FilterField[];

View File

@ -114,7 +114,7 @@ export class AccountService {
.pipe(map(res => res === "true"));
}
login(model: {username: string, password: string}) {
login(model: {username: string, password: string, apiKey?: string}) {
return this.httpClient.post<User>(this.baseUrl + 'account/login', model).pipe(
map((response: User) => {
const user = response;

View File

@ -9,6 +9,7 @@ import { Series } from '../_models/series';
import { Volume } from '../_models/volume';
import { AccountService } from './account.service';
import { DeviceService } from './device.service';
import {SideNavStream} from "../_models/sidenav/sidenav-stream";
export enum Action {
Submenu = -1,
@ -93,7 +94,9 @@ export enum Action {
*/
RemoveFromOnDeck = 19,
AddRuleGroup = 20,
RemoveRuleGroup = 21
RemoveRuleGroup = 21,
MarkAsVisible = 22,
MarkAsInvisible = 23,
}
export interface ActionItem<T> {
@ -135,6 +138,8 @@ export class ActionFactoryService {
bookmarkActions: Array<ActionItem<Series>> = [];
sideNavStreamActions: Array<ActionItem<SideNavStream>> = [];
isAdmin = false;
hasDownloadRole = false;
@ -160,6 +165,10 @@ export class ActionFactoryService {
return this.applyCallbackToList(this.seriesActions, callback);
}
getSideNavStreamActions(callback: (action: ActionItem<SideNavStream>, series: SideNavStream) => void) {
return this.applyCallbackToList(this.sideNavStreamActions, callback);
}
getVolumeActions(callback: (action: ActionItem<Volume>, volume: Volume) => void) {
return this.applyCallbackToList(this.volumeActions, callback);
}
@ -564,6 +573,23 @@ export class ActionFactoryService {
children: [],
},
];
this.sideNavStreamActions = [
{
action: Action.MarkAsVisible,
title: 'mark-visible',
callback: this.dummyCallback,
requiresAdmin: false,
children: [],
},
{
action: Action.MarkAsInvisible,
title: 'mark-invisible',
callback: this.dummyCallback,
requiresAdmin: false,
children: [],
},
];
}
private applyCallback(action: ActionItem<any>, callback: (action: ActionItem<any>, data: any) => void) {

View File

@ -62,6 +62,10 @@ export class NavService {
return this.httpClient.post<SideNavStream>(this.baseUrl + 'stream/add-sidenav-stream-from-external-source?externalSourceId=' + externalSourceId, {});
}
bulkToggleSideNavStreamVisibility(streamIds: Array<number>, targetVisibility: boolean) {
return this.httpClient.post(this.baseUrl + 'stream/bulk-sidenav-stream-visibility', {ids: streamIds, visibility: targetVisibility});
}
/**
* Shows the top nav bar. This should be visible on all pages except the reader.
*/

View File

@ -44,14 +44,14 @@
</thead>
<tbody>
<tr *ngIf="events.length === 0">
<td colspan="6">{{t('no-data')}}/td>
<td colspan="6">{{t('no-data')}}</td>
</tr>
<tr *ngFor="let item of events; let idx = index;">
<td>
{{item.createdUtc | translocoDate: {dateStyle: 'short', timeStyle: 'medium', } | defaultValue }}
{{item.createdUtc | utcToLocalTime | defaultValue}}
</td>
<td>
{{item.lastModifiedUtc | translocoDate: {dateStyle: 'short', timeStyle: 'medium' } | defaultValue }}
{{item.lastModifiedUtc | utcToLocalTime | defaultValue }}
</td>
<td>
{{item.scrobbleEventType | scrobbleEventType}}

View File

@ -14,11 +14,12 @@ import {FormControl, FormGroup, ReactiveFormsModule} from "@angular/forms";
import {TranslocoModule} from "@ngneat/transloco";
import {DefaultValuePipe} from "../../pipe/default-value.pipe";
import {TranslocoLocaleModule} from "@ngneat/transloco-locale";
import {UtcToLocalTimePipe} from "../../pipe/utc-to-local-time.pipe";
@Component({
selector: 'app-user-scrobble-history',
standalone: true,
imports: [CommonModule, ScrobbleEventTypePipe, NgbPagination, ReactiveFormsModule, SortableHeader, TranslocoModule, DefaultValuePipe, TranslocoLocaleModule],
imports: [CommonModule, ScrobbleEventTypePipe, NgbPagination, ReactiveFormsModule, SortableHeader, TranslocoModule, DefaultValuePipe, TranslocoLocaleModule, UtcToLocalTimePipe],
templateUrl: './user-scrobble-history.component.html',
styleUrls: ['./user-scrobble-history.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush

View File

@ -6,7 +6,7 @@
<div class="modal-body">
<div class="mb-3">
<label for="typeahead-focus" class="form-label">{{t('path')}}</label>
<label for="typeahead-focus" class="form-label">{{t('path-label')}}</label>
<div class="input-group">
<input id="typeahead-focus" type="text" class="form-control" [(ngModel)]="path" [ngbTypeahead]="search"
(focus)="focus$.next($any($event).target.value)" (click)="click$.next($any($event).target.value)"

View File

@ -38,7 +38,7 @@
<a href="library/{{item.libraryId}}/series/{{item.seriesId}}" target="_blank">{{item.details}}</a>
</td>
<td>
{{item.createdUtc | translocoDate: {dateStyle: 'short', timeStyle: 'medium' } | defaultValue }}
{{item.createdUtc | utcToLocalTime | defaultValue }}
</td>
<td>
{{item.comment}}

View File

@ -29,11 +29,12 @@ import {TranslocoModule} from "@ngneat/transloco";
import {DefaultDatePipe} from "../../pipe/default-date.pipe";
import {DefaultValuePipe} from "../../pipe/default-value.pipe";
import {TranslocoLocaleModule} from "@ngneat/transloco-locale";
import {UtcToLocalTimePipe} from "../../pipe/utc-to-local-time.pipe";
@Component({
selector: 'app-manage-scrobble-errors',
standalone: true,
imports: [CommonModule, ReactiveFormsModule, FilterPipe, LoadingComponent, SortableHeader, TranslocoModule, DefaultDatePipe, DefaultValuePipe, TranslocoLocaleModule],
imports: [CommonModule, ReactiveFormsModule, FilterPipe, LoadingComponent, SortableHeader, TranslocoModule, DefaultDatePipe, DefaultValuePipe, TranslocoLocaleModule, UtcToLocalTimePipe],
templateUrl: './manage-scrobble-errors.component.html',
styleUrls: ['./manage-scrobble-errors.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush

View File

@ -58,7 +58,7 @@
<td>
{{task.title | titlecase}}
</td>
<td>{{task.lastExecutionUtc | translocoDate: {dateStyle: 'short', timeStyle: 'medium' } | defaultValue }}</td>
<td>{{task.lastExecutionUtc | utcToLocalTime | defaultValue }}</td>
<td>{{task.cron}}</td>
</tr>
</tbody>

View File

@ -14,6 +14,7 @@ import {DefaultValuePipe} from '../../pipe/default-value.pipe';
import {AsyncPipe, DatePipe, NgFor, NgIf, NgTemplateOutlet, TitleCasePipe} from '@angular/common';
import {TranslocoModule, TranslocoService} from "@ngneat/transloco";
import {TranslocoLocaleModule} from "@ngneat/transloco-locale";
import {UtcToLocalTimePipe} from "../../pipe/utc-to-local-time.pipe";
interface AdhocTask {
name: string;
@ -29,7 +30,7 @@ interface AdhocTask {
styleUrls: ['./manage-tasks-settings.component.scss'],
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [NgIf, ReactiveFormsModule, NgbTooltip, NgFor, AsyncPipe, TitleCasePipe, DatePipe, DefaultValuePipe, TranslocoModule, NgTemplateOutlet, TranslocoLocaleModule]
imports: [NgIf, ReactiveFormsModule, NgbTooltip, NgFor, AsyncPipe, TitleCasePipe, DatePipe, DefaultValuePipe, TranslocoModule, NgTemplateOutlet, TranslocoLocaleModule, UtcToLocalTimePipe]
})
export class ManageTasksSettingsComponent implements OnInit {

View File

@ -430,10 +430,10 @@
<div>
<div class="row g-0">
<div class="col">
{{t('added-title')}} {{volume.createdUtc | translocoDate: {dateStyle: 'short' } | defaultDate}}
{{t('added-title')}} {{volume.createdUtc | utcToLocalTime | defaultDate}}
</div>
<div class="col">
{{t('last-modified-title')}} {{volume.lastModifiedUtc | translocoDate: {dateStyle: 'short' } | defaultDate}}
{{t('last-modified-title')}} {{volume.lastModifiedUtc | utcToLocalTime | translocoDate: {dateStyle: 'short' } | defaultDate}}
</div>
</div>
<div class="row g-0">

View File

@ -53,7 +53,7 @@ import {ImageComponent} from "../../../shared/image/image.component";
import {DefaultValuePipe} from "../../../pipe/default-value.pipe";
import {TranslocoModule} from "@ngneat/transloco";
import {TranslocoDatePipe} from "@ngneat/transloco-locale";
import {Volume} from "../../../_models/volume";
import {UtcToLocalTimePipe} from "../../../pipe/utc-to-local-time.pipe";
enum TabID {
General = 0,
@ -68,32 +68,32 @@ enum TabID {
@Component({
selector: 'app-edit-series-modal',
standalone: true,
imports: [
ReactiveFormsModule,
NgbNav,
NgbNavContent,
NgbNavItem,
NgbNavLink,
CommonModule,
TypeaheadComponent,
CoverImageChooserComponent,
EditSeriesRelationComponent,
SentenceCasePipe,
MangaFormatPipe,
DefaultDatePipe,
TimeAgoPipe,
TagBadgeComponent,
PublicationStatusPipe,
NgbTooltip,
BytesPipe,
ImageComponent,
NgbCollapse,
NgbNavOutlet,
DefaultValuePipe,
TranslocoModule,
TranslocoDatePipe,
],
imports: [
ReactiveFormsModule,
NgbNav,
NgbNavContent,
NgbNavItem,
NgbNavLink,
CommonModule,
TypeaheadComponent,
CoverImageChooserComponent,
EditSeriesRelationComponent,
SentenceCasePipe,
MangaFormatPipe,
DefaultDatePipe,
TimeAgoPipe,
TagBadgeComponent,
PublicationStatusPipe,
NgbTooltip,
BytesPipe,
ImageComponent,
NgbCollapse,
NgbNavOutlet,
DefaultValuePipe,
TranslocoModule,
TranslocoDatePipe,
UtcToLocalTimePipe,
],
templateUrl: './edit-series-modal.component.html',
styleUrls: ['./edit-series-modal.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush

View File

@ -1,6 +1,6 @@
<ng-container *transloco="let t; read: 'bulk-operations'">
<ng-container *ngIf="bulkSelectionService.selections$ | async as selectionCount">
<div *ngIf="selectionCount > 0" class="bulk-select mb-3 fixed-top" [ngStyle]="{'margin-top': topOffset + 'px'}">
<div *ngIf="selectionCount > 0" class="bulk-select mb-3 {{modalMode ? '' : 'fixed-top}}" [ngStyle]="{'margin-top': topOffset + 'px'}">
<div class="d-flex justify-content-around align-items-center">
<span class="highlight">

View File

@ -32,19 +32,23 @@ import {CardActionablesComponent} from "../../_single-module/card-actionables/ca
export class BulkOperationsComponent implements OnInit {
@Input({required: true}) actionCallback!: (action: ActionItem<any>, data: any) => void;
topOffset: number = 56;
/**
* Modal mode means don't fix to the top
*/
@Input() modalMode = false;
@Input() topOffset: number = 56;
hasMarkAsRead: boolean = false;
hasMarkAsUnread: boolean = false;
actions: Array<ActionItem<any>> = [];
private readonly destroyRef = inject(DestroyRef);
private readonly cdRef = inject(ChangeDetectorRef);
private readonly actionFactoryService = inject(ActionFactoryService);
public readonly bulkSelectionService = inject(BulkSelectionService);
get Action() {
return Action;
}
protected readonly Action = Action;
constructor(public bulkSelectionService: BulkSelectionService, private readonly cdRef: ChangeDetectorRef,
private actionFactoryService: ActionFactoryService) { }
constructor() { }
ngOnInit(): void {
this.bulkSelectionService.actions$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(actions => {

View File

@ -1,16 +1,16 @@
import { Injectable } from '@angular/core';
import { NavigationStart, Router } from '@angular/router';
import { ReplaySubject } from 'rxjs';
import { filter } from 'rxjs/operators';
import { Action, ActionFactoryService, ActionItem } from '../_services/action-factory.service';
import {Injectable} from '@angular/core';
import {NavigationStart, Router} from '@angular/router';
import {ReplaySubject} from 'rxjs';
import {filter} from 'rxjs/operators';
import {Action, ActionFactoryService, ActionItem} from '../_services/action-factory.service';
type DataSource = 'volume' | 'chapter' | 'special' | 'series' | 'bookmark';
type DataSource = 'volume' | 'chapter' | 'special' | 'series' | 'bookmark' | 'sideNavStream';
/**
* Responsible for handling selections on cards. Can handle multiple card sources next to each other in different loops.
* This will clear selections between pages.
*
* Remakrs: Page which renders cards is responsible for listening for shift keydown/keyup and updating our state variable.
* Remarks: Page which renders cards is responsible for listening for shift keydown/keyup and updating our state variable.
*/
@Injectable({
providedIn: 'root'
@ -151,6 +151,10 @@ export class BulkSelectionService {
return this.actionFactory.getBookmarkActions(callback);
}
if (Object.keys(this.selectedCards).filter(item => item === 'sideNavStream').length > 0) {
return this.applyFilterToList(this.actionFactory.getSideNavStreamActions(callback), [Action.MarkAsInvisible, Action.MarkAsVisible]);
}
return this.applyFilterToList(this.actionFactory.getVolumeActions(callback), allowedActions);
}

View File

@ -64,7 +64,7 @@
<div class="vr d-none d-lg-block m-2"></div>
<div class="col-auto">
<app-icon-and-title [label]="t('date-added-title')" [clickable]="false" fontClasses="fa-solid fa-file-import" [title]="t('date-added-title')">
{{chapter.createdUtc | translocoDate: {dateStyle: 'short', timeStyle: 'short' } | defaultDate}}
{{chapter.createdUtc | utcToLocalTime | translocoDate: {dateStyle: 'short', timeStyle: 'short' } | defaultDate}}
</app-icon-and-title>
</div>
</ng-container>

View File

@ -28,11 +28,12 @@ import {MetadataDetailComponent} from "../../series-detail/_components/metadata-
import {TranslocoModule} from "@ngneat/transloco";
import {TranslocoLocaleModule} from "@ngneat/transloco-locale";
import {FilterField} from "../../_models/metadata/v2/filter-field";
import {UtcToLocalTimePipe} from "../../pipe/utc-to-local-time.pipe";
@Component({
selector: 'app-entity-info-cards',
standalone: true,
imports: [CommonModule, IconAndTitleComponent, SafeHtmlPipe, DefaultDatePipe, BytesPipe, CompactNumberPipe, AgeRatingPipe, NgbTooltip, MetadataDetailComponent, TranslocoModule, CompactNumberPipe, TranslocoLocaleModule],
imports: [CommonModule, IconAndTitleComponent, SafeHtmlPipe, DefaultDatePipe, BytesPipe, CompactNumberPipe, AgeRatingPipe, NgbTooltip, MetadataDetailComponent, TranslocoModule, CompactNumberPipe, TranslocoLocaleModule, UtcToLocalTimePipe],
templateUrl: './entity-info-cards.component.html',
styleUrls: ['./entity-info-cards.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush

View File

@ -0,0 +1,37 @@
import { Pipe, PipeTransform } from '@angular/core';
import { DateTime } from 'luxon';
type UtcToLocalTimeFormat = 'full' | 'short' | 'shortDate' | 'shortTime';
// FULL = 'full', // 'EEE, MMMM d, y, h:mm:ss a zzzz' - Monday, June 15, 2015 at 9:03:01 AM GMT+01:00
// SHORT = 'short', // 'd/M/yy, h:mm - 15/6/15, 9:03
// SHORT_DATE = 'shortDate', // 'd/M/yy' - 15/6/15
// SHORT_TIME = 'shortTime', // 'h:mm' - 9:03
@Pipe({
name: 'utcToLocalTime',
standalone: true
})
export class UtcToLocalTimePipe implements PipeTransform {
transform(utcDate: string, format: UtcToLocalTimeFormat = 'short'): string {
const browserLanguage = navigator.language;
const dateTime = DateTime.fromISO(utcDate, { zone: 'utc' }).toLocal().setLocale(browserLanguage);
switch (format) {
case 'short':
return dateTime.toLocaleString(DateTime.DATETIME_SHORT);
case 'shortDate':
return dateTime.toLocaleString(DateTime.DATE_MED);
case 'shortTime':
return dateTime.toLocaleString(DateTime.TIME_SIMPLE);
case 'full':
return dateTime.toString();
default:
console.error('No logic in place for utc date format, format: ', format);
return utcDate;
}
}
}

View File

@ -1,24 +1,16 @@
<ng-container *transloco="let t; read: 'draggable-ordered-list'">
<ng-container *ngIf="items.length > 100; else dragList">
<ng-container *ngIf="items.length > virtualizeAfter; else dragList">
<div class="example-list list-group-flush">
<virtual-scroller #scroll [items]="items" [bufferAmount]="BufferAmount" [parentScroll]="parentScroll">
<div class="example-box" *ngFor="let item of scroll.viewPortItems; index as i; trackBy: trackByIdentity">
<div class="d-flex list-container">
<div class="me-3 align-middle">
<div style="padding-top: 40px">
<label for="reorder-{{item.order}}" class="form-label visually-hidden">{{t('reorder-label')}}</label>
<input *ngIf="accessibilityMode" id="reorder-{{i}}" class="form-control" type="number" inputmode="numeric" min="0" [max]="items.length - 1" [value]="item.order" style="width: 60px"
(focusout)="updateIndex(item.order, item)" (keydown.enter)="updateIndex(item.order, item)" aria-describedby="instructions">
</div>
</div>
<div class="d-flex list-container">
<ng-container [ngTemplateOutlet]="handle" [ngTemplateOutletContext]="{ $implicit: item, idx: i, isVirtualized: true }"></ng-container>
<ng-container [ngTemplateOutlet]="itemTemplate" [ngTemplateOutletContext]="{ $implicit: item, idx: i }"></ng-container>
<button class="btn btn-icon float-end" (click)="removeItem(item, i)" *ngIf="showRemoveButton" [disabled]="disabled">
<i class="fa fa-times" aria-hidden="true"></i>
<span class="visually-hidden" attr.aria-labelledby="item.id--{{i}}">{{t('remove-item-alt')}}</span>
</button>
<ng-container [ngTemplateOutlet]="removeBtn" [ngTemplateOutletContext]="{$implicit: item, idx: i}"></ng-container>
</div>
</div>
</virtual-scroller>
@ -26,29 +18,47 @@
</ng-container>
<ng-template #dragList>
<div cdkDropList class="{{items.length > 0 ? 'example-list list-group-flush' : ''}}" (cdkDropListDropped)="drop($event)">
<div class="example-box" *ngFor="let item of items; index as i" cdkDrag [cdkDragData]="item" cdkDragBoundary=".example-list">
<div class="example-box" *ngFor="let item of items; index as i" cdkDrag
[cdkDragData]="item" cdkDragBoundary=".example-list"
[cdkDragDisabled]="accessibilityMode || disabled || bulkMode" cdkDragPreviewContainer="parent">
<div class="d-flex list-container">
<div class="me-3 align-middle">
<div class="align-middle" style="padding-top: 40px" *ngIf="accessibilityMode">
<label for="reorder-{{i}}" class="form-label visually-hidden">{{t('reorder-label')}}</label>
<input id="reorder-{{i}}" class="form-control" type="number" inputmode="numeric" min="0" [max]="items.length - 1" [value]="i" style="width: 60px"
(focusout)="updateIndex(i, item)" (keydown.enter)="updateIndex(i, item)" aria-describedby="instructions">
</div>
<i *ngIf="!accessibilityMode && !disabled" class="fa fa-grip-vertical drag-handle" aria-hidden="true" cdkDragHandle></i>
</div>
<ng-container [ngTemplateOutlet]="handle" [ngTemplateOutletContext]="{ $implicit: item, idx: i, isVirtualized: false }"></ng-container>
<ng-container [ngTemplateOutlet]="itemTemplate" [ngTemplateOutletContext]="{ $implicit: item, idx: i }"></ng-container>
<button class="btn btn-icon float-end" (click)="removeItem(item, i)" *ngIf="showRemoveButton" [disabled]="disabled">
<i class="fa fa-times" aria-hidden="true"></i>
<span class="visually-hidden" attr.aria-labelledby="item.id--{{i}}">{{t('remove-item-alt')}}</span>
</button>
<ng-container [ngTemplateOutlet]="removeBtn" [ngTemplateOutletContext]="{$implicit: item, idx: i}"></ng-container>
</div>
</div>
</div>
</ng-template>
<ng-template #removeBtn let-item let-idx>
<button class="btn btn-icon float-end" (click)="removeItem(item, idx)" *ngIf="showRemoveButton" [disabled]="disabled">
<i class="fa fa-times" aria-hidden="true"></i>
<span class="visually-hidden" attr.aria-labelledby="item.id--{{idx}}">{{t('remove-item-alt')}}</span>
</button>
</ng-template>
<ng-template #handle let-item let-idx="idx" let-isVirtualized="isVirtualized">
<div class="me-3 align-middle">
<div class="align-middle" [ngClass]="{'accessibility-padding': accessibilityMode, 'bulk-padding': bulkMode}" *ngIf="accessibilityMode || bulkMode">
<ng-container *ngIf="accessibilityMode">
<label for="reorder-{{idx}}" class="form-label visually-hidden">{{t('reorder-label')}}</label>
<input id="reorder-{{idx}}" class="form-control manual-input" type="number" inputmode="numeric" min="0"
[max]="items.length - 1" [value]="idx"
(focusout)="updateIndex(idx, item)" (keydown.enter)="updateIndex(idx, item)" aria-describedby="instructions">
</ng-container>
<ng-container *ngIf="bulkMode">
<label for="select-{{idx}}" class="form-label visually-hidden">{{t('bulk-select-label')}}</label>
<input id="select-{{idx}}" class="form-check-input mt-0" type="checkbox" (change)="selectItem($event, item, idx)"
[value]="bulkSelectionService.isCardSelected('sideNavStream', idx)">
</ng-container>
</div>
<i *ngIf="!isVirtualized && !(accessibilityMode || bulkMode) && !disabled" class="fa fa-grip-vertical drag-handle" aria-hidden="true" cdkDragHandle></i>
</div>
</ng-template>
<p class="visually-hidden" id="instructions">
{{t('instructions-alt')}}

View File

@ -61,6 +61,12 @@
width: 100%;
}
.accessibility-padding {
padding-top: 12px;
}
.bulk-padding {
padding-top: 20px;
}
.virtual-scroller, virtual-scroller {
@ -71,3 +77,7 @@
virtual-scroller.empty {
display: none;
}
.manual-input {
width: 83px;
}

View File

@ -1,8 +1,22 @@
import { CdkDragDrop, moveItemInArray, CdkDropList, CdkDrag, CdkDragHandle } from '@angular/cdk/drag-drop';
import { ChangeDetectionStrategy, ChangeDetectorRef, Component, ContentChild, EventEmitter, Input, Output, TemplateRef, TrackByFunction } from '@angular/core';
import {
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
ContentChild,
EventEmitter,
inject,
Input,
Output,
TemplateRef,
TrackByFunction
} from '@angular/core';
import { VirtualScrollerModule } from '@iharbeck/ngx-virtual-scroller';
import { NgIf, NgFor, NgTemplateOutlet } from '@angular/common';
import {NgIf, NgFor, NgTemplateOutlet, NgClass} from '@angular/common';
import {TranslocoDirective} from "@ngneat/transloco";
import {BulkSelectionService} from "../../../cards/bulk-selection.service";
import {SeriesCardComponent} from "../../../cards/series-card/series-card.component";
import {SideNavStream} from "../../../_models/sidenav/sidenav-stream";
export interface IndexUpdateEvent {
fromPosition: number;
@ -22,10 +36,14 @@ export interface ItemRemoveEvent {
styleUrls: ['./draggable-ordered-list.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true,
imports: [NgIf, VirtualScrollerModule, NgFor, NgTemplateOutlet, CdkDropList, CdkDrag, CdkDragHandle, TranslocoDirective]
imports: [NgIf, VirtualScrollerModule, NgFor, NgTemplateOutlet, CdkDropList, CdkDrag, CdkDragHandle, TranslocoDirective, NgClass, SeriesCardComponent]
})
export class DraggableOrderedListComponent {
/**
* After this many elements, drag and drop is disabled and we use a virtualized list instead
*/
@Input() virtualizeAfter = 100;
@Input() accessibilityMode: boolean = false;
/**
* Shows the remove button on the list item
@ -40,11 +58,17 @@ export class DraggableOrderedListComponent {
* Disables drag and drop functionality. Useful if a filter is present which will skew actual index.
*/
@Input() disabled: boolean = false;
/**
* When enabled, draggability is disabled and a checkbox renders instead of order box or drag handle
*/
@Input() bulkMode: boolean = false;
@Input() trackByIdentity: TrackByFunction<any> = (index: number, item: any) => `${item.id}_${item.order}_${item.title}`;
@Output() orderUpdated: EventEmitter<IndexUpdateEvent> = new EventEmitter<IndexUpdateEvent>();
@Output() itemRemove: EventEmitter<ItemRemoveEvent> = new EventEmitter<ItemRemoveEvent>();
@ContentChild('draggableItem') itemTemplate!: TemplateRef<any>;
public readonly bulkSelectionService = inject(BulkSelectionService);
get BufferAmount() {
return Math.min(this.items.length / 20, 20);
}
@ -85,4 +109,11 @@ export class DraggableOrderedListComponent {
});
this.cdRef.markForCheck();
}
selectItem(updatedVal: Event, item: SideNavStream, index: number) {
const boolVal = (updatedVal.target as HTMLInputElement).value == 'true';
this.bulkSelectionService.handleCardSelection('sideNavStream', index, this.items.length, boolVal);
this.cdRef.markForCheck();
}
}

View File

@ -3,7 +3,7 @@
<h4 class="modal-title">{{t('title-' + activeTab)}}</h4>
<button type="button" class="btn-close" [attr.aria-label]="t('close')" (click)="close()"></button>
</div>
<div class="modal-body scrollable-modal {{utilityService.getActiveBreakpoint() === Breakpoint.Mobile ? '' : 'd-flex'}}">
<div #modalBody class="modal-body scrollable-modal {{utilityService.getActiveBreakpoint() === Breakpoint.Mobile ? '' : 'd-flex'}}">
<ul ngbNav #nav="ngbNav" [(activeId)]="activeTab" class="nav-pills" orientation="{{utilityService.getActiveBreakpoint() === Breakpoint.Mobile ? 'horizontal' : 'vertical'}}" style="min-width: 154px;">
<li [ngbNavItem]="TabID.Dashboard">
<a ngbNavLink>{{t(TabID.Dashboard)}}</a>

View File

@ -49,6 +49,12 @@ export class CustomizeDashboardStreamsComponent {
constructor(public modal: NgbActiveModal) {
forkJoin([this.dashboardService.getDashboardStreams(false), this.filterService.getAllFilters()]).subscribe(results => {
this.items = results[0];
// After 100 items, drag and drop is disabled to use virtualization
if (this.items.length > 100) {
this.accessibilityMode = true;
}
const smartFilterStreams = new Set(results[0].filter(d => !d.isProvided).map(d => d.name));
this.smartFilters = results[1].filter(d => !smartFilterStreams.has(d.name));
this.cdRef.markForCheck();

View File

@ -10,14 +10,25 @@
<span role="alert" class="mt-1" *ngIf="listForm.get('filterSideNavStream')?.value">{{t('reorder-when-filter-present')}}</span>
</div>
<div class="col-3">
<div class="form-check form-check-inline" style="margin-top: 35px; margin-left: 10px">
<input class="form-check-input" type="checkbox" id="accessibility-mode" [value]="accessibilityMode" (change)="updateAccessibilityMode()">
<label class="form-check-label" for="accessibility-mode">{{t('order-numbers-label')}}</label>
</div>
<form [formGroup]="pageOperationsForm">
<div class="form-check form-check-inline" style="margin-top: 23px; margin-left: 10px">
<input class="form-check-input" type="checkbox" id="accessibility-mode" formControlName="accessibilityMode">
<label class="form-check-label" for="accessibility-mode">{{t('order-numbers-label')}}</label>
</div>
<div class="form-check form-check-inline" style="margin-left: 10px">
<input class="form-check-input" type="checkbox" id="bulk-mode" formControlName="bulkMode" >
<label class="form-check-label" for="bulk-mode">{{t('bulk-mode-label')}}</label>
</div>
</form>
</div>
</div>
<app-draggable-ordered-list [items]="items | filter: filterSideNavStreams" (orderUpdated)="orderUpdated($event)" [accessibilityMode]="accessibilityMode"
[showRemoveButton]="false" [disabled]="listForm.get('filterSideNavStream')?.value">
<app-bulk-operations [modalMode]="true" [topOffset]="0" [actionCallback]="bulkActionCallback"></app-bulk-operations>
<app-draggable-ordered-list [items]="items | filter: filterSideNavStreams" (orderUpdated)="orderUpdated($event)"
[accessibilityMode]="pageOperationsForm.get('accessibilityMode')!.value"
[showRemoveButton]="false" [disabled]="listForm.get('filterSideNavStream')?.value"
[bulkMode]="pageOperationsForm.get('bulkMode')!.value"
[virtualizeAfter]="100"
>
<ng-template #draggableItem let-position="idx" let-item>
<app-sidenav-stream-list-item [item]="item" [position]="position" (hide)="updateVisibility($event, position)"></app-sidenav-stream-list-item>
</ng-template>

View File

@ -1,4 +1,4 @@
import {ChangeDetectionStrategy, ChangeDetectorRef, Component, inject} from '@angular/core';
import {ChangeDetectionStrategy, ChangeDetectorRef, Component, DestroyRef, inject, OnDestroy} from '@angular/core';
import {CommonModule} from '@angular/common';
import {SmartFilter} from "../../../_models/metadata/v2/smart-filter";
import {FilterService} from "../../../_services/filter.service";
@ -11,36 +11,43 @@ import {
import {SideNavStream} from "../../../_models/sidenav/sidenav-stream";
import {NavService} from "../../../_services/nav.service";
import {DashboardStreamListItemComponent} from "../dashboard-stream-list-item/dashboard-stream-list-item.component";
import {CommonStream} from "../../../_models/common-stream";
import {TranslocoDirective} from "@ngneat/transloco";
import {SidenavStreamListItemComponent} from "../sidenav-stream-list-item/sidenav-stream-list-item.component";
import {ExternalSourceService} from "../../../external-source.service";
import {ExternalSource} from "../../../_models/sidenav/external-source";
import {StreamType} from "../../../_models/dashboard/stream-type.enum";
import {SideNavStreamType} from "../../../_models/sidenav/sidenav-stream-type.enum";
import {FormControl, FormGroup, ReactiveFormsModule} from "@angular/forms";
import {FilterPipe} from "../../../pipe/filter.pipe";
import {BulkOperationsComponent} from "../../../cards/bulk-operations/bulk-operations.component";
import {Action, ActionItem} from "../../../_services/action-factory.service";
import {BulkSelectionService} from "../../../cards/bulk-selection.service";
import {filter, tap} from "rxjs/operators";
import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
@Component({
selector: 'app-customize-sidenav-streams',
standalone: true,
imports: [CommonModule, DraggableOrderedListComponent, DashboardStreamListItemComponent, TranslocoDirective, SidenavStreamListItemComponent, ReactiveFormsModule, FilterPipe],
imports: [CommonModule, DraggableOrderedListComponent, DashboardStreamListItemComponent, TranslocoDirective, SidenavStreamListItemComponent, ReactiveFormsModule, FilterPipe, BulkOperationsComponent],
templateUrl: './customize-sidenav-streams.component.html',
styleUrls: ['./customize-sidenav-streams.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class CustomizeSidenavStreamsComponent {
export class CustomizeSidenavStreamsComponent implements OnDestroy {
//@Input({required: true}) parentScrollElem!: Element | Window;
items: SideNavStream[] = [];
smartFilters: SmartFilter[] = [];
externalSources: ExternalSource[] = [];
accessibilityMode: boolean = false;
listForm: FormGroup = new FormGroup({
'filterSideNavStream': new FormControl('', []),
'filterSmartFilter': new FormControl('', []),
'filterExternalSource': new FormControl('', []),
});
pageOperationsForm: FormGroup = new FormGroup({
'accessibilityMode': new FormControl(false, []),
'bulkMode': new FormControl(false, [])
})
filterSideNavStreams = (listItem: SideNavStream) => {
const filterVal = (this.listForm.value.filterSideNavStream || '').toLowerCase();
@ -57,17 +64,87 @@ export class CustomizeSidenavStreamsComponent {
return listItem.name.toLowerCase().indexOf(filterVal) >= 0;
}
bulkActionCallback = (action: ActionItem<SideNavStream>, data: SideNavStream) => {
const streams = this.bulkSelectionService.getSelectedCardsForSource('sideNavStream').map(index => this.items[parseInt(index, 10)]);
let visibleState = false;
switch (action.action) {
case Action.MarkAsVisible:
visibleState = true;
break;
case Action.MarkAsInvisible:
visibleState = false;
break;
}
for(let index of this.bulkSelectionService.getSelectedCardsForSource('sideNavStream').map(s => parseInt(s, 10))) {
this.items[index].visible = visibleState;
this.items[index] = {...this.items[index]};
}
this.cdRef.markForCheck();
// Make bulk call
this.sideNavService.bulkToggleSideNavStreamVisibility(streams.map(s => s.id), visibleState).subscribe(() => this.bulkSelectionService.deselectAll());
}
private readonly sideNavService = inject(NavService);
private readonly filterService = inject(FilterService);
private readonly externalSourceService = inject(ExternalSourceService);
private readonly cdRef = inject(ChangeDetectorRef);
private readonly destroyRef = inject(DestroyRef);
private readonly bulkSelectionService = inject(BulkSelectionService);
constructor(public modal: NgbActiveModal) {
this.pageOperationsForm.get('accessibilityMode')?.valueChanges.pipe(
tap(_ => {
const accessibleValue = this.pageOperationsForm.get('accessibilityMode')?.value;
if (accessibleValue) {
if (this.pageOperationsForm.get('bulkMode')?.disabled) return;
this.pageOperationsForm.get('bulkMode')?.disable();
} else {
if (!this.pageOperationsForm.get('bulkMode')?.disabled) return;
this.pageOperationsForm.get('bulkMode')?.enable();
}
this.cdRef.markForCheck();
}),
takeUntilDestroyed(this.destroyRef)
).subscribe();
this.pageOperationsForm.get('bulkMode')?.valueChanges.pipe(
tap(_ => {
const bulkValue = this.pageOperationsForm.get('bulkMode')?.value;
if (bulkValue) {
if (this.pageOperationsForm.get('accessibilityMode')?.disabled) return;
this.pageOperationsForm.get('accessibilityMode')?.disable();
} else {
if (this.pageOperationsForm.get('accessibilityMode')?.disabled) return;
this.pageOperationsForm.get('accessibilityMode')?.enable();
}
}),
takeUntilDestroyed(this.destroyRef)
).subscribe();
this.pageOperationsForm.valueChanges.pipe(
tap(_ => {
if (this.pageOperationsForm.value.accessibilityMode || this.pageOperationsForm.value.bulkMode) {
this.listForm.get('filterSideNavStream')?.disable();
return;
}
this.listForm.get('filterSideNavStream')?.enable();
}),
takeUntilDestroyed(this.destroyRef)
).subscribe();
forkJoin([this.sideNavService.getSideNavStreams(false),
this.filterService.getAllFilters(), this.externalSourceService.getExternalSources()
]).subscribe(results => {
this.items = results[0];
// After 100 items, drag and drop is disabled to use virtualization
if (this.items.length > 100) {
this.pageOperationsForm.get('accessibilityMode')?.setValue(true);
}
const existingSmartFilterStreams = new Set(results[0].filter(d => !d.isProvided && d.streamType === SideNavStreamType.SmartFilter).map(d => d.name));
this.smartFilters = results[1].filter(d => !existingSmartFilterStreams.has(d.name));
@ -77,6 +154,10 @@ export class CustomizeSidenavStreamsComponent {
});
}
ngOnDestroy() {
this.bulkSelectionService.deselectAll();
}
resetSideNavFilter() {
this.listForm.get('filterSideNavStream')?.setValue('');
this.cdRef.markForCheck();
@ -108,11 +189,6 @@ export class CustomizeSidenavStreamsComponent {
});
}
updateAccessibilityMode() {
this.accessibilityMode = !this.accessibilityMode;
this.cdRef.markForCheck();
}
orderUpdated(event: IndexUpdateEvent) {
this.sideNavService.updateSideNavStreamPosition(event.item.name, event.item.id, event.fromPosition, event.toPosition).subscribe(() => {
@ -128,8 +204,8 @@ export class CustomizeSidenavStreamsComponent {
updateVisibility(item: SideNavStream, position: number) {
const stream = this.items.filter(s => s.id == item.id)[0];
stream.visible = !stream.visible;
this.sideNavService.updateSideNavStream(stream).subscribe();
this.cdRef.markForCheck();
this.sideNavService.updateSideNavStream(stream).subscribe();
}
}

View File

@ -24,5 +24,10 @@
(sourceDelete)="deleteSource(idx, $event)"
[isViewMode]="externalSource.id !== 0"></app-edit-external-source-item>
</ng-container>
<ul>
<li class="list-group-item" *ngIf="externalSources.length === 0">
{{t('no-data')}}
</li>
</ul>
</ng-container>

View File

@ -0,0 +1,20 @@
ul {
margin:0;
padding: 0;
li {
padding: 0.5rem 1rem;
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
border-radius: 5px;
margin: 5px 0;
color: var(--list-group-hover-text-color);
background-color: var(--card-bg-color);
span {
cursor: pointer;
}
}
}

View File

@ -12,7 +12,7 @@ ul {
border-radius: 5px;
margin: 5px 0;
color: var(--list-group-hover-text-color);
background-color: var(--list-group-hover-bg-color);
background-color: var(--card-bg-color);
span {
cursor: pointer;

View File

@ -159,11 +159,11 @@ export class SideNavComponent implements OnInit {
}
handleHomeActions() {
this.ngbModal.open(CustomizeDashboardModalComponent, {size: 'xl'});
this.ngbModal.open(CustomizeDashboardModalComponent, {size: 'xl', fullscreen: 'md'});
}
importCbl() {
this.ngbModal.open(ImportCblModalComponent, {size: 'xl'});
this.ngbModal.open(ImportCblModalComponent, {size: 'xl', fullscreen: 'md'});
}
performAction(action: ActionItem<Library>, library: Library) {

View File

@ -2,7 +2,7 @@ import {
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
DestroyRef,
DestroyRef, importProvidersFrom,
inject,
OnDestroy,
OnInit
@ -49,6 +49,11 @@ import { SideNavCompanionBarComponent } from '../../sidenav/_components/side-nav
import {LocalizationService} from "../../_services/localization.service";
import {Language} from "../../_models/metadata/language";
import {translate, TranslocoDirective} from "@ngneat/transloco";
import {
provideTranslocoPersistTranslations,
TranslocoPersistTranslations
} from "@ngneat/transloco-persist-translations";
import {HttpLoader} from "../../../httpLoader";
enum AccordionPanelID {
ImageReader = 'image-reader',
@ -76,7 +81,7 @@ enum FragmentID {
ChangePasswordComponent, ChangeAgeRestrictionComponent, AnilistKeyComponent, ReactiveFormsModule, NgbAccordionDirective, NgbAccordionItem, NgbAccordionHeader,
NgbAccordionToggle, NgbAccordionButton, NgbCollapse, NgbAccordionCollapse, NgbAccordionBody, NgbTooltip, NgTemplateOutlet, ColorPickerModule, ApiKeyComponent,
ThemeManagerComponent, ManageDevicesComponent, UserStatsComponent, UserScrobbleHistoryComponent, UserHoldsComponent, NgbNavOutlet, TitleCasePipe, SentenceCasePipe,
TranslocoDirective]
TranslocoDirective],
})
export class UserPreferencesComponent implements OnInit, OnDestroy {
@ -114,20 +119,22 @@ export class UserPreferencesComponent implements OnInit, OnDestroy {
opdsEnabled: boolean = false;
opdsUrl: string = '';
makeUrl: (val: string) => string = (val: string) => { return this.opdsUrl; };
private readonly destroyRef = inject(DestroyRef);
get AccordionPanelID() {
return AccordionPanelID;
}
get FragmentID() {
return FragmentID;
}
private readonly accountService = inject(AccountService);
private readonly toastr = inject(ToastrService);
private readonly bookService = inject(BookService);
private readonly titleService = inject(Title);
private readonly route = inject(ActivatedRoute);
private readonly settingsService = inject(SettingsService);
private readonly router = inject(Router);
private readonly cdRef = inject(ChangeDetectorRef);
private readonly localizationService = inject(LocalizationService);
protected readonly AccordionPanelID = AccordionPanelID;
protected readonly FragmentID = FragmentID;
constructor(private accountService: AccountService, private toastr: ToastrService, private bookService: BookService,
private titleService: Title, private route: ActivatedRoute, private settingsService: SettingsService,
private router: Router, private readonly cdRef: ChangeDetectorRef, public localizationService: LocalizationService) {
constructor() {
this.fontFamilies = this.bookService.getFontFamilies().map(f => f.title);
this.cdRef.markForCheck();
@ -306,6 +313,7 @@ export class UserPreferencesComponent implements OnInit, OnDestroy {
this.toastr.success(translate('user-preferences.success-toast'));
if (this.user) {
this.user.preferences = updatedPrefs;
this.cdRef.markForCheck();
}
this.resetForm();

View File

@ -1315,6 +1315,7 @@
"draggable-ordered-list": {
"instructions-alt": "When you put a number in the reorder input, the item will be inserted at that location and all other items will have their order updated.",
"reorder-label": "Reorder",
"bulk-select-label": "Bulk Select item",
"remove-item-alt": "Remove item"
},
@ -1771,15 +1772,17 @@
"smart-filters-title": "Smart Filters",
"external-sources-title": "{{customize-dashboard-modal.external-sources}}",
"reorder-when-filter-present": "You cannot reorder items via drag & drop while a filter is present. Use {{customize-sidenav-streams.order-numbers-label}}",
"order-numbers-label": "{{reading-list-detail.order-numbers-label}}"
"order-numbers-label": "{{reading-list-detail.order-numbers-label}}",
"bulk-mode-label": "Bulk Mode"
},
"manage-external-sources": {
"add-source": "Add",
"help-link": "More information",
"description": "Add External Servers to your account and then add them to your Side Nav for a quick way to switch between your and your friend's server.",
"description": "Enhance your experience by adding external servers and conveniently include them in your Side Nav for quick access to both your server and your friend's server.",
"clear": "{{common.clear}}",
"filter": "{{common.filter}}"
"filter": "{{common.filter}}",
"no-data": "No External Sources exist"
},
"manage-smart-filters": {
@ -1978,7 +1981,9 @@
"add-rule-group-and": "Add Rule Group (AND)",
"add-rule-group-or": "Add Rule Group (OR)",
"remove-rule-group": "Remove Rule Group",
"customize": "Customize"
"customize": "Customize",
"mark-visible": "Mark as Visible",
"mark-invisible": "Mark as Invisible"
},
"preferences": {

View File

@ -15,8 +15,8 @@ import { JwtInterceptor } from './app/_interceptors/jwt.interceptor';
import { ErrorInterceptor } from './app/_interceptors/error.interceptor';
import {HTTP_INTERCEPTORS, withInterceptorsFromDi, provideHttpClient} from '@angular/common/http';
import {
provideTransloco,
TranslocoService
provideTransloco, TranslocoConfig,
TranslocoService
} from "@ngneat/transloco";
import {environment} from "./environments/environment";
import {HttpLoader} from "./httpLoader";
@ -96,7 +96,7 @@ const languageCodes = [
'syr', 'syr-SY', 'ta', 'ta-IN', 'te', 'te-IN', 'th', 'th-TH', 'tl', 'tl-PH', 'tn',
'tn-ZA', 'tr', 'tr-TR', 'tt', 'tt-RU', 'ts', 'uk', 'uk-UA', 'ur', 'ur-PK', 'uz',
'uz-UZ', 'uz-UZ', 'vi', 'vi-VN', 'xh', 'xh-ZA', 'zh', 'zh-CN', 'zh-HK', 'zh-MO',
'zh-SG', 'zh-TW', 'zu', 'zu-ZA', 'zh_Hans',
'zh-SG', 'zh-TW', 'zu', 'zu-ZA', 'zh_Hans', 'zh_Hant',
];
const translocoOptions = {
@ -109,8 +109,8 @@ const translocoOptions = {
missingHandler: {
useFallbackTranslation: true,
allowEmpty: false,
}
}
},
} as TranslocoConfig
};
bootstrapApplication(AppComponent, {
@ -133,7 +133,8 @@ bootstrapApplication(AppComponent, {
}),
provideTranslocoPersistTranslations({
loader: HttpLoader,
storage: { useValue: localStorage }
storage: { useValue: localStorage },
ttl: 604800
}),
provideTranslocoPersistLang({
storage: {

View File

@ -7,7 +7,7 @@
"name": "GPL-3.0",
"url": "https://github.com/Kareadita/Kavita/blob/develop/LICENSE"
},
"version": "0.7.8.8"
"version": "0.7.8.10"
},
"servers": [
{
@ -285,6 +285,7 @@
"Account"
],
"summary": "Resets the API Key assigned with a user",
"description": "This will log unauthorized requests to Security log",
"responses": {
"200": {
"description": "Success",
@ -3541,6 +3542,28 @@
}
}
},
"/api/Opds/{apiKey}/external-sources": {
"get": {
"tags": [
"Opds"
],
"parameters": [
{
"name": "apiKey",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "Success"
}
}
}
},
"/api/Opds/{apiKey}/libraries": {
"get": {
"tags": [
@ -4202,6 +4225,7 @@
"Plugin"
],
"summary": "Returns the version of the Kavita install",
"description": "This will log unauthorized requests to Security log",
"parameters": [
{
"name": "apiKey",
@ -10937,6 +10961,37 @@
}
}
},
"/api/Stream/bulk-sidenav-stream-visibility": {
"post": {
"tags": [
"Stream"
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/BulkUpdateSideNavStreamVisibilityDto"
}
},
"text/json": {
"schema": {
"$ref": "#/components/schemas/BulkUpdateSideNavStreamVisibilityDto"
}
},
"application/*+json": {
"schema": {
"$ref": "#/components/schemas/BulkUpdateSideNavStreamVisibilityDto"
}
}
}
},
"responses": {
"200": {
"description": "Success"
}
}
}
},
"/api/Tachiyomi/latest-chapter": {
"get": {
"tags": [
@ -13108,6 +13163,23 @@
},
"additionalProperties": false
},
"BulkUpdateSideNavStreamVisibilityDto": {
"type": "object",
"properties": {
"ids": {
"type": "array",
"items": {
"type": "integer",
"format": "int32"
},
"nullable": true
},
"visibility": {
"type": "boolean"
}
},
"additionalProperties": false
},
"CblBookResult": {
"type": "object",
"properties": {