mirror of
https://github.com/Kareadita/Kavita.git
synced 2025-07-09 03:04:19 -04:00
UX Changes, Tasks, WebP, and More! (#1280)
* When account updates occur for a user, send an event to them to tell them to refresh their account information (if they are on the site at the time). This way if we revoke permissions, the site will reactively adapt. * Some cleanup on the user preferences to remove some calls we don't need anymore. * Removed old bulk cleanup bookmark code as it's no longer needed. * Tweaked the messaging for stat collection to reflect what we collect now versus when this was initially implemented. * Implemented the ability for users to configure their servers to save bookmarks as webP. Reorganized the tabs for Admin dashboard to account for upcoming features. * Implemented the ability to bulk convert bookmarks (as many times as the user wants). Added a display of Reoccurring Jobs to the Tasks admin tab. Currently it's just placeholder, but will be enhanced further later in the release. * Tweaked the wording around the convert switch. * Moved System actions to the task tab * Added a controller just for Tachiyomi so we can have dedicated APIs for that client. Deprecated an existing API on the Reader route. * Fixed the unit tests
This commit is contained in:
parent
dd83b6a9a1
commit
e0a2fc615f
@ -7,12 +7,12 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="6.0.4" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.1.0" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="6.0.5" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.2.0" />
|
||||
<PackageReference Include="NSubstitute" Version="4.3.0" />
|
||||
<PackageReference Include="System.IO.Abstractions.TestingHelpers" Version="17.0.3" />
|
||||
<PackageReference Include="System.IO.Abstractions.TestingHelpers" Version="17.0.15" />
|
||||
<PackageReference Include="xunit" Version="2.4.1" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.4.3">
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.4.5">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
|
@ -47,6 +47,12 @@ public class BookmarkServiceTests
|
||||
_unitOfWork = new UnitOfWork(_context, Substitute.For<IMapper>(), null);
|
||||
}
|
||||
|
||||
private BookmarkService Create(IDirectoryService ds)
|
||||
{
|
||||
return new BookmarkService(Substitute.For<ILogger<BookmarkService>>(), _unitOfWork, ds,
|
||||
Substitute.For<IImageService>(), Substitute.For<IEventHub>());
|
||||
}
|
||||
|
||||
#region Setup
|
||||
|
||||
private static DbConnection CreateInMemoryDatabase()
|
||||
@ -121,7 +127,8 @@ public class BookmarkServiceTests
|
||||
public async Task BookmarkPage_ShouldCopyTheFileAndUpdateDB()
|
||||
{
|
||||
var filesystem = CreateFileSystem();
|
||||
filesystem.AddFile($"{CacheDirectory}1/0001.jpg", new MockFileData("123"));
|
||||
var file = $"{CacheDirectory}1/0001.jpg";
|
||||
filesystem.AddFile(file, new MockFileData("123"));
|
||||
|
||||
// Delete all Series to reset state
|
||||
await ResetDB();
|
||||
@ -157,7 +164,7 @@ public class BookmarkServiceTests
|
||||
|
||||
|
||||
var ds = new DirectoryService(Substitute.For<ILogger<DirectoryService>>(), filesystem);
|
||||
var bookmarkService = new BookmarkService(Substitute.For<ILogger<BookmarkService>>(), _unitOfWork, ds);
|
||||
var bookmarkService = Create(ds);
|
||||
var user = await _unitOfWork.UserRepository.GetUserByIdAsync(1, AppUserIncludes.Bookmarks);
|
||||
|
||||
var result = await bookmarkService.BookmarkPage(user, new BookmarkDto()
|
||||
@ -166,7 +173,7 @@ public class BookmarkServiceTests
|
||||
Page = 1,
|
||||
SeriesId = 1,
|
||||
VolumeId = 1
|
||||
}, $"{CacheDirectory}1/0001.jpg");
|
||||
}, file);
|
||||
|
||||
|
||||
Assert.True(result);
|
||||
@ -227,7 +234,7 @@ public class BookmarkServiceTests
|
||||
|
||||
|
||||
var ds = new DirectoryService(Substitute.For<ILogger<DirectoryService>>(), filesystem);
|
||||
var bookmarkService = new BookmarkService(Substitute.For<ILogger<BookmarkService>>(), _unitOfWork, ds);
|
||||
var bookmarkService = Create(ds);
|
||||
var user = await _unitOfWork.UserRepository.GetUserByIdAsync(1, AppUserIncludes.Bookmarks);
|
||||
|
||||
var result = await bookmarkService.RemoveBookmarkPage(user, new BookmarkDto()
|
||||
@ -319,7 +326,7 @@ public class BookmarkServiceTests
|
||||
|
||||
|
||||
var ds = new DirectoryService(Substitute.For<ILogger<DirectoryService>>(), filesystem);
|
||||
var bookmarkService = new BookmarkService(Substitute.For<ILogger<BookmarkService>>(), _unitOfWork, ds);
|
||||
var bookmarkService = Create(ds);
|
||||
|
||||
await bookmarkService.DeleteBookmarkFiles(new [] {new AppUserBookmark()
|
||||
{
|
||||
@ -378,7 +385,7 @@ public class BookmarkServiceTests
|
||||
|
||||
|
||||
var ds = new DirectoryService(Substitute.For<ILogger<DirectoryService>>(), filesystem);
|
||||
var bookmarkService = new BookmarkService(Substitute.For<ILogger<BookmarkService>>(), _unitOfWork, ds);
|
||||
var bookmarkService = Create(ds);
|
||||
var user = await _unitOfWork.UserRepository.GetUserByIdAsync(1, AppUserIncludes.Bookmarks);
|
||||
|
||||
await bookmarkService.BookmarkPage(user, new BookmarkDto()
|
||||
|
@ -40,39 +40,39 @@
|
||||
<PackageReference Include="AutoMapper.Extensions.Microsoft.DependencyInjection" Version="11.0.0" />
|
||||
<PackageReference Include="Docnet.Core" Version="2.4.0-alpha.2" />
|
||||
<PackageReference Include="ExCSS" Version="4.1.0" />
|
||||
<PackageReference Include="Flurl" Version="3.0.5" />
|
||||
<PackageReference Include="Flurl.Http" Version="3.2.3" />
|
||||
<PackageReference Include="Hangfire" Version="1.7.28" />
|
||||
<PackageReference Include="Hangfire.AspNetCore" Version="1.7.28" />
|
||||
<PackageReference Include="Flurl" Version="3.0.6" />
|
||||
<PackageReference Include="Flurl.Http" Version="3.2.4" />
|
||||
<PackageReference Include="Hangfire" Version="1.7.29" />
|
||||
<PackageReference Include="Hangfire.AspNetCore" Version="1.7.29" />
|
||||
<PackageReference Include="Hangfire.MaximumConcurrentExecutions" Version="1.1.0" />
|
||||
<PackageReference Include="Hangfire.MemoryStorage.Core" Version="1.4.0" />
|
||||
<PackageReference Include="HtmlAgilityPack" Version="1.11.42" />
|
||||
<PackageReference Include="MarkdownDeep.NET.Core" Version="1.5.0.4" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="6.0.4" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.OpenIdConnect" Version="6.0.4" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="6.0.4" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="6.0.5" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.OpenIdConnect" Version="6.0.5" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="6.0.5" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.SignalR" Version="1.1.0" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="6.0.4">
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="6.0.5">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="6.0.4" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="6.0.5" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="6.0.0" />
|
||||
<PackageReference Include="Microsoft.IO.RecyclableMemoryStream" Version="2.2.0" />
|
||||
<PackageReference Include="NetVips" Version="2.1.0" />
|
||||
<PackageReference Include="NetVips.Native" Version="8.12.2" />
|
||||
<PackageReference Include="NReco.Logging.File" Version="1.1.4" />
|
||||
<PackageReference Include="NReco.Logging.File" Version="1.1.5" />
|
||||
<PackageReference Include="SharpCompress" Version="0.31.0" />
|
||||
<PackageReference Include="SixLabors.ImageSharp" Version="2.1.1" />
|
||||
<PackageReference Include="SonarAnalyzer.CSharp" Version="8.38.0.46746">
|
||||
<PackageReference Include="SonarAnalyzer.CSharp" Version="8.39.0.47922">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.3.1" />
|
||||
<PackageReference Include="System.Drawing.Common" Version="6.0.0" />
|
||||
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="6.17.0" />
|
||||
<PackageReference Include="System.IO.Abstractions" Version="17.0.3" />
|
||||
<PackageReference Include="VersOne.Epub" Version="3.1.0" />
|
||||
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="6.18.0" />
|
||||
<PackageReference Include="System.IO.Abstractions" Version="17.0.15" />
|
||||
<PackageReference Include="VersOne.Epub" Version="3.1.1" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
@ -15,6 +15,7 @@ using API.Entities.Enums;
|
||||
using API.Errors;
|
||||
using API.Extensions;
|
||||
using API.Services;
|
||||
using API.SignalR;
|
||||
using AutoMapper;
|
||||
using Kavita.Common;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
@ -40,13 +41,16 @@ namespace API.Controllers
|
||||
private readonly IAccountService _accountService;
|
||||
private readonly IEmailService _emailService;
|
||||
private readonly IHostEnvironment _environment;
|
||||
private readonly IEventHub _eventHub;
|
||||
|
||||
/// <inheritdoc />
|
||||
public AccountController(UserManager<AppUser> userManager,
|
||||
SignInManager<AppUser> signInManager,
|
||||
ITokenService tokenService, IUnitOfWork unitOfWork,
|
||||
ILogger<AccountController> logger,
|
||||
IMapper mapper, IAccountService accountService, IEmailService emailService, IHostEnvironment environment)
|
||||
IMapper mapper, IAccountService accountService,
|
||||
IEmailService emailService, IHostEnvironment environment,
|
||||
IEventHub eventHub)
|
||||
{
|
||||
_userManager = userManager;
|
||||
_signInManager = signInManager;
|
||||
@ -57,6 +61,7 @@ namespace API.Controllers
|
||||
_accountService = accountService;
|
||||
_emailService = emailService;
|
||||
_environment = environment;
|
||||
_eventHub = eventHub;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@ -289,6 +294,7 @@ namespace API.Controllers
|
||||
{
|
||||
dto.Roles.Add(PolicyConstants.PlebRole);
|
||||
}
|
||||
|
||||
if (existingRoles.Except(dto.Roles).Any() || dto.Roles.Except(existingRoles).Any())
|
||||
{
|
||||
var roles = dto.Roles;
|
||||
@ -326,9 +332,9 @@ namespace API.Controllers
|
||||
lib.AppUsers.Add(user);
|
||||
}
|
||||
|
||||
if (!_unitOfWork.HasChanges()) return Ok();
|
||||
if (await _unitOfWork.CommitAsync())
|
||||
if (!_unitOfWork.HasChanges() || await _unitOfWork.CommitAsync())
|
||||
{
|
||||
await _eventHub.SendMessageToAsync(MessageFactory.UserUpdate, MessageFactory.UserUpdateEvent(user.Id, user.UserName), user.Id);
|
||||
return Ok();
|
||||
}
|
||||
|
||||
|
@ -424,6 +424,7 @@ namespace API.Controllers
|
||||
/// </summary>
|
||||
/// <remarks>This is built for Tachiyomi and is not expected to be called by any other place</remarks>
|
||||
/// <returns></returns>
|
||||
[Obsolete("Deprecated. Use 'Tachiyomi/mark-chapter-until-as-read'")]
|
||||
[HttpPost("mark-chapter-until-as-read")]
|
||||
public async Task<ActionResult<bool>> MarkChaptersUntilAsRead(int seriesId, float chapterNumber)
|
||||
{
|
||||
@ -497,7 +498,7 @@ namespace API.Controllers
|
||||
user.Bookmarks = user.Bookmarks.Where(bmk => bmk.SeriesId != dto.SeriesId).ToList();
|
||||
_unitOfWork.UserRepository.Update(user);
|
||||
|
||||
if (await _unitOfWork.CommitAsync())
|
||||
if (!_unitOfWork.HasChanges() || await _unitOfWork.CommitAsync())
|
||||
{
|
||||
try
|
||||
{
|
||||
|
@ -1,19 +1,24 @@
|
||||
using System;
|
||||
using System.Collections;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using API.DTOs.Jobs;
|
||||
using API.DTOs.Stats;
|
||||
using API.DTOs.Update;
|
||||
using API.Extensions;
|
||||
using API.Services;
|
||||
using API.Services.Tasks;
|
||||
using Hangfire;
|
||||
using Hangfire.Storage;
|
||||
using Kavita.Common;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using TaskScheduler = System.Threading.Tasks.TaskScheduler;
|
||||
|
||||
namespace API.Controllers
|
||||
{
|
||||
@ -29,10 +34,11 @@ namespace API.Controllers
|
||||
private readonly IStatsService _statsService;
|
||||
private readonly ICleanupService _cleanupService;
|
||||
private readonly IEmailService _emailService;
|
||||
private readonly IBookmarkService _bookmarkService;
|
||||
|
||||
public ServerController(IHostApplicationLifetime applicationLifetime, ILogger<ServerController> logger, IConfiguration config,
|
||||
IBackupService backupService, IArchiveService archiveService, IVersionUpdaterService versionUpdaterService, IStatsService statsService,
|
||||
ICleanupService cleanupService, IEmailService emailService)
|
||||
ICleanupService cleanupService, IEmailService emailService, IBookmarkService bookmarkService)
|
||||
{
|
||||
_applicationLifetime = applicationLifetime;
|
||||
_logger = logger;
|
||||
@ -43,6 +49,7 @@ namespace API.Controllers
|
||||
_statsService = statsService;
|
||||
_cleanupService = cleanupService;
|
||||
_emailService = emailService;
|
||||
_bookmarkService = bookmarkService;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@ -76,11 +83,10 @@ namespace API.Controllers
|
||||
/// </summary>
|
||||
/// <returns></returns>
|
||||
[HttpPost("backup-db")]
|
||||
public async Task<ActionResult> BackupDatabase()
|
||||
public ActionResult BackupDatabase()
|
||||
{
|
||||
_logger.LogInformation("{UserName} is backing up database of server from admin dashboard", User.GetUsername());
|
||||
await _backupService.BackupDatabase();
|
||||
|
||||
RecurringJob.Trigger("backup");
|
||||
return Ok();
|
||||
}
|
||||
|
||||
@ -94,6 +100,17 @@ namespace API.Controllers
|
||||
return Ok(await _statsService.GetServerInfo());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Triggers the scheduling of the convert bookmarks job. Only one job will run at a time.
|
||||
/// </summary>
|
||||
/// <returns></returns>
|
||||
[HttpPost("convert-bookmarks")]
|
||||
public ActionResult ScheduleConvertBookmarks()
|
||||
{
|
||||
BackgroundJob.Enqueue(() => _bookmarkService.ConvertAllBookmarkToWebP());
|
||||
return Ok();
|
||||
}
|
||||
|
||||
[HttpGet("logs")]
|
||||
public async Task<ActionResult> GetLogs()
|
||||
{
|
||||
@ -134,5 +151,24 @@ namespace API.Controllers
|
||||
{
|
||||
return await _emailService.CheckIfAccessible(Request.Host.ToString());
|
||||
}
|
||||
|
||||
[HttpGet("jobs")]
|
||||
public ActionResult<IEnumerable<JobDto>> GetJobs()
|
||||
{
|
||||
var recurringJobs = Hangfire.JobStorage.Current.GetConnection().GetRecurringJobs().Select(
|
||||
dto =>
|
||||
new JobDto() {
|
||||
Id = dto.Id,
|
||||
Title = dto.Id.Replace('-', ' '),
|
||||
Cron = dto.Cron,
|
||||
CreatedAt = dto.CreatedAt,
|
||||
LastExecution = dto.LastExecution,
|
||||
});
|
||||
|
||||
// For now, let's just do something simple
|
||||
//var enqueuedJobs = JobStorage.Current.GetMonitoringApi().EnqueuedJobs("default", 0, int.MaxValue);
|
||||
return Ok(recurringJobs);
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -169,6 +169,13 @@ namespace API.Controllers
|
||||
_unitOfWork.SettingsRepository.Update(setting);
|
||||
}
|
||||
|
||||
if (setting.Key == ServerSettingKey.ConvertBookmarkToWebP && updateSettingsDto.ConvertBookmarkToWebP + string.Empty != setting.Value)
|
||||
{
|
||||
setting.Value = updateSettingsDto.ConvertBookmarkToWebP + string.Empty;
|
||||
_unitOfWork.SettingsRepository.Update(setting);
|
||||
}
|
||||
|
||||
|
||||
if (setting.Key == ServerSettingKey.BookmarkDirectory && bookmarkDirectory != setting.Value)
|
||||
{
|
||||
// Validate new directory can be used
|
||||
|
79
API/Controllers/TachiyomiController.cs
Normal file
79
API/Controllers/TachiyomiController.cs
Normal file
@ -0,0 +1,79 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
using API.Data;
|
||||
using API.Data.Repositories;
|
||||
using API.DTOs;
|
||||
using API.Entities;
|
||||
using API.Extensions;
|
||||
using API.Services;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace API.Controllers;
|
||||
|
||||
public class TachiyomiController : BaseApiController
|
||||
{
|
||||
private readonly IUnitOfWork _unitOfWork;
|
||||
private readonly IReaderService _readerService;
|
||||
|
||||
public TachiyomiController(IUnitOfWork unitOfWork, IReaderService readerService)
|
||||
{
|
||||
_unitOfWork = unitOfWork;
|
||||
_readerService = readerService;
|
||||
}
|
||||
|
||||
[HttpGet("latest-chapter")]
|
||||
public async Task<ActionResult<ChapterDto>> GetLatestChapter(int seriesId)
|
||||
{
|
||||
var userId = await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername());
|
||||
|
||||
var currentChapter = await _readerService.GetContinuePoint(seriesId, userId);
|
||||
|
||||
var prevChapterId =
|
||||
await _readerService.GetPrevChapterIdAsync(seriesId, currentChapter.VolumeId, currentChapter.Id, userId);
|
||||
|
||||
if (prevChapterId == -1) return null;
|
||||
|
||||
var prevChapter = await _unitOfWork.ChapterRepository.GetChapterDtoAsync(prevChapterId);
|
||||
|
||||
return Ok(prevChapter);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Marks every chapter that is sorted below the passed number as Read. This will not mark any specials as read.
|
||||
/// </summary>
|
||||
/// <remarks>This is built for Tachiyomi and is not expected to be called by any other place</remarks>
|
||||
/// <returns></returns>
|
||||
[HttpPost("mark-chapter-until-as-read")]
|
||||
public async Task<ActionResult<bool>> MarkChaptersUntilAsRead(int seriesId, float chapterNumber)
|
||||
{
|
||||
var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername(), AppUserIncludes.Progress);
|
||||
user.Progresses ??= new List<AppUserProgress>();
|
||||
|
||||
switch (chapterNumber)
|
||||
{
|
||||
// Tachiyomi sends chapter 0.0f when there's no chapters read.
|
||||
// Due to the encoding for volumes this marks all chapters in volume 0 (loose chapters) as read so we ignore it
|
||||
case 0.0f:
|
||||
return true;
|
||||
case < 1.0f:
|
||||
{
|
||||
// This is a hack to track volume number. We need to map it back by x100
|
||||
var volumeNumber = int.Parse($"{chapterNumber * 100f}");
|
||||
await _readerService.MarkVolumesUntilAsRead(user, seriesId, volumeNumber);
|
||||
break;
|
||||
}
|
||||
default:
|
||||
await _readerService.MarkChaptersUntilAsRead(user, seriesId, chapterNumber);
|
||||
break;
|
||||
}
|
||||
|
||||
|
||||
_unitOfWork.UserRepository.Update(user);
|
||||
|
||||
if (!_unitOfWork.HasChanges()) return Ok(true);
|
||||
if (await _unitOfWork.CommitAsync()) return Ok(true);
|
||||
|
||||
await _unitOfWork.RollbackAsync();
|
||||
return Ok(false);
|
||||
}
|
||||
}
|
@ -6,6 +6,7 @@ using API.Data.Repositories;
|
||||
using API.DTOs;
|
||||
using API.Entities.Enums;
|
||||
using API.Extensions;
|
||||
using API.SignalR;
|
||||
using AutoMapper;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
@ -17,11 +18,13 @@ namespace API.Controllers
|
||||
{
|
||||
private readonly IUnitOfWork _unitOfWork;
|
||||
private readonly IMapper _mapper;
|
||||
private readonly IEventHub _eventHub;
|
||||
|
||||
public UsersController(IUnitOfWork unitOfWork, IMapper mapper)
|
||||
public UsersController(IUnitOfWork unitOfWork, IMapper mapper, IEventHub eventHub)
|
||||
{
|
||||
_unitOfWork = unitOfWork;
|
||||
_mapper = mapper;
|
||||
_eventHub = eventHub;
|
||||
}
|
||||
|
||||
[Authorize(Policy = "RequireAdminRole")]
|
||||
@ -69,7 +72,9 @@ namespace API.Controllers
|
||||
[HttpPost("update-preferences")]
|
||||
public async Task<ActionResult<UserPreferencesDto>> UpdatePreferences(UserPreferencesDto preferencesDto)
|
||||
{
|
||||
var existingPreferences = await _unitOfWork.UserRepository.GetPreferencesAsync(User.GetUsername());
|
||||
var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername(),
|
||||
AppUserIncludes.UserPreferences);
|
||||
var existingPreferences = user.UserPreferences;
|
||||
|
||||
existingPreferences.ReadingDirection = preferencesDto.ReadingDirection;
|
||||
existingPreferences.ScalingOption = preferencesDto.ScalingOption;
|
||||
@ -98,6 +103,7 @@ namespace API.Controllers
|
||||
|
||||
if (await _unitOfWork.CommitAsync())
|
||||
{
|
||||
await _eventHub.SendMessageToAsync(MessageFactory.UserUpdate, MessageFactory.UserUpdateEvent(user.Id, user.UserName), user.Id);
|
||||
return Ok(preferencesDto);
|
||||
}
|
||||
|
||||
|
24
API/DTOs/Jobs/JobDto.cs
Normal file
24
API/DTOs/Jobs/JobDto.cs
Normal file
@ -0,0 +1,24 @@
|
||||
using System;
|
||||
|
||||
namespace API.DTOs.Jobs;
|
||||
|
||||
public class JobDto
|
||||
{
|
||||
/// <summary>
|
||||
/// Job Id
|
||||
/// </summary>
|
||||
public string Id { get; set; }
|
||||
/// <summary>
|
||||
/// Human Readable title for the Job
|
||||
/// </summary>
|
||||
public string Title { get; set; }
|
||||
/// <summary>
|
||||
/// When the job was created
|
||||
/// </summary>
|
||||
public DateTime? CreatedAt { get; set; }
|
||||
/// <summary>
|
||||
/// Last time the job was run
|
||||
/// </summary>
|
||||
public DateTime? LastExecution { get; set; }
|
||||
public string Cron { get; set; }
|
||||
}
|
@ -38,5 +38,7 @@ namespace API.DTOs.Settings
|
||||
/// <remarks>If null or empty string, will default back to default install setting aka <see cref="EmailService.DefaultApiUrl"/></remarks>
|
||||
public string EmailServiceUrl { get; set; }
|
||||
public string InstallVersion { get; set; }
|
||||
|
||||
public bool ConvertBookmarkToWebP { get; set; }
|
||||
}
|
||||
}
|
||||
|
@ -100,6 +100,7 @@ namespace API.Data
|
||||
new() {Key = ServerSettingKey.InstallVersion, Value = BuildInfo.Version.ToString()},
|
||||
new() {Key = ServerSettingKey.BookmarkDirectory, Value = directoryService.BookmarkDirectory},
|
||||
new() {Key = ServerSettingKey.EmailServiceUrl, Value = EmailService.DefaultApiUrl},
|
||||
new() {Key = ServerSettingKey.ConvertBookmarkToWebP, Value = "false"},
|
||||
}.ToArray());
|
||||
|
||||
foreach (var defaultSetting in DefaultSettings)
|
||||
|
@ -76,5 +76,10 @@ namespace API.Entities.Enums
|
||||
/// </summary>
|
||||
[Description("CustomEmailService")]
|
||||
EmailServiceUrl = 13,
|
||||
/// <summary>
|
||||
/// If Kavita should save bookmarks as WebP images
|
||||
/// </summary>
|
||||
[Description("ConvertBookmarkToWebP")]
|
||||
ConvertBookmarkToWebP = 14,
|
||||
}
|
||||
}
|
||||
|
@ -48,6 +48,9 @@ namespace API.Helpers.Converters
|
||||
case ServerSettingKey.InstallVersion:
|
||||
destination.InstallVersion = row.Value;
|
||||
break;
|
||||
case ServerSettingKey.ConvertBookmarkToWebP:
|
||||
destination.ConvertBookmarkToWebP = bool.Parse(row.Value);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -8,6 +8,7 @@ using API.DTOs.Reader;
|
||||
using API.Entities;
|
||||
using API.Entities.Enums;
|
||||
using API.SignalR;
|
||||
using Hangfire;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace API.Services;
|
||||
@ -18,6 +19,7 @@ public interface IBookmarkService
|
||||
Task<bool> BookmarkPage(AppUser userWithBookmarks, BookmarkDto bookmarkDto, string imageToBookmark);
|
||||
Task<bool> RemoveBookmarkPage(AppUser userWithBookmarks, BookmarkDto bookmarkDto);
|
||||
Task<IEnumerable<string>> GetBookmarkFilesById(IEnumerable<int> bookmarkIds);
|
||||
Task ConvertAllBookmarkToWebP();
|
||||
|
||||
}
|
||||
|
||||
@ -26,12 +28,17 @@ public class BookmarkService : IBookmarkService
|
||||
private readonly ILogger<BookmarkService> _logger;
|
||||
private readonly IUnitOfWork _unitOfWork;
|
||||
private readonly IDirectoryService _directoryService;
|
||||
private readonly IImageService _imageService;
|
||||
private readonly IEventHub _eventHub;
|
||||
|
||||
public BookmarkService(ILogger<BookmarkService> logger, IUnitOfWork unitOfWork, IDirectoryService directoryService)
|
||||
public BookmarkService(ILogger<BookmarkService> logger, IUnitOfWork unitOfWork,
|
||||
IDirectoryService directoryService, IImageService imageService, IEventHub eventHub)
|
||||
{
|
||||
_logger = logger;
|
||||
_unitOfWork = unitOfWork;
|
||||
_directoryService = directoryService;
|
||||
_imageService = imageService;
|
||||
_eventHub = eventHub;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@ -87,18 +94,28 @@ public class BookmarkService : IBookmarkService
|
||||
var targetFolderStem = BookmarkStem(userWithBookmarks.Id, bookmarkDto.SeriesId, bookmarkDto.ChapterId);
|
||||
var targetFilepath = Path.Join(bookmarkDirectory, targetFolderStem);
|
||||
|
||||
userWithBookmarks.Bookmarks ??= new List<AppUserBookmark>();
|
||||
userWithBookmarks.Bookmarks.Add(new AppUserBookmark()
|
||||
var bookmark = new AppUserBookmark()
|
||||
{
|
||||
Page = bookmarkDto.Page,
|
||||
VolumeId = bookmarkDto.VolumeId,
|
||||
SeriesId = bookmarkDto.SeriesId,
|
||||
ChapterId = bookmarkDto.ChapterId,
|
||||
FileName = Path.Join(targetFolderStem, fileInfo.Name)
|
||||
});
|
||||
};
|
||||
|
||||
_directoryService.CopyFileToDirectory(imageToBookmark, targetFilepath);
|
||||
userWithBookmarks.Bookmarks ??= new List<AppUserBookmark>();
|
||||
userWithBookmarks.Bookmarks.Add(bookmark);
|
||||
|
||||
_unitOfWork.UserRepository.Update(userWithBookmarks);
|
||||
await _unitOfWork.CommitAsync();
|
||||
|
||||
var convertToWebP = bool.Parse((await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.ConvertBookmarkToWebP)).Value);
|
||||
if (convertToWebP)
|
||||
{
|
||||
// Enqueue a task to convert the bookmark to webP
|
||||
BackgroundJob.Enqueue(() => ConvertBookmarkToWebP(bookmark.Id));
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@ -153,6 +170,94 @@ public class BookmarkService : IBookmarkService
|
||||
b.FileName)));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// This is a long-running job that will convert all bookmarks into WebP. Do not invoke anyway except via Hangfire.
|
||||
/// </summary>
|
||||
[DisableConcurrentExecution(timeoutInSeconds: 2 * 60 * 60), AutomaticRetry(Attempts = 0)]
|
||||
public async Task ConvertAllBookmarkToWebP()
|
||||
{
|
||||
var bookmarkDirectory =
|
||||
(await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.BookmarkDirectory)).Value;
|
||||
|
||||
await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress,
|
||||
MessageFactory.ConvertBookmarksProgressEvent(0F, ProgressEventType.Started));
|
||||
var bookmarks = (await _unitOfWork.UserRepository.GetAllBookmarksAsync())
|
||||
.Where(b => !b.FileName.EndsWith(".webp")).ToList();
|
||||
|
||||
var count = 1F;
|
||||
foreach (var bookmark in bookmarks)
|
||||
{
|
||||
await SaveBookmarkAsWebP(bookmarkDirectory, bookmark);
|
||||
await _unitOfWork.CommitAsync();
|
||||
await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress,
|
||||
MessageFactory.ConvertBookmarksProgressEvent(count / bookmarks.Count, ProgressEventType.Started));
|
||||
count++;
|
||||
}
|
||||
|
||||
await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress,
|
||||
MessageFactory.ConvertBookmarksProgressEvent(1F, ProgressEventType.Ended));
|
||||
|
||||
_logger.LogInformation("[BookmarkService] Converted bookmarks to WebP");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// This is a job that runs after a bookmark is saved
|
||||
/// </summary>
|
||||
public async Task ConvertBookmarkToWebP(int bookmarkId)
|
||||
{
|
||||
var bookmarkDirectory =
|
||||
(await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.BookmarkDirectory)).Value;
|
||||
var convertBookmarkToWebP =
|
||||
(await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).ConvertBookmarkToWebP;
|
||||
|
||||
if (!convertBookmarkToWebP) return;
|
||||
|
||||
// Validate the bookmark still exists
|
||||
var bookmark = await _unitOfWork.UserRepository.GetBookmarkAsync(bookmarkId);
|
||||
if (bookmark == null) return;
|
||||
|
||||
await SaveBookmarkAsWebP(bookmarkDirectory, bookmark);
|
||||
await _unitOfWork.CommitAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Converts bookmark file, deletes original, marks bookmark as dirty. Does not commit.
|
||||
/// </summary>
|
||||
/// <param name="bookmarkDirectory"></param>
|
||||
/// <param name="bookmark"></param>
|
||||
private async Task SaveBookmarkAsWebP(string bookmarkDirectory, AppUserBookmark bookmark)
|
||||
{
|
||||
var fullSourcePath = _directoryService.FileSystem.Path.Join(bookmarkDirectory, bookmark.FileName);
|
||||
var fullTargetDirectory = fullSourcePath.Replace(new FileInfo(bookmark.FileName).Name, string.Empty);
|
||||
var targetFolderStem = BookmarkStem(bookmark.AppUserId, bookmark.SeriesId, bookmark.ChapterId);
|
||||
|
||||
_logger.LogDebug("Converting {Source} bookmark into WebP at {Target}", fullSourcePath, fullTargetDirectory);
|
||||
|
||||
try
|
||||
{
|
||||
// Convert target file to webp then delete original target file and update bookmark
|
||||
|
||||
var originalFile = bookmark.FileName;
|
||||
try
|
||||
{
|
||||
var targetFile = await _imageService.ConvertToWebP(fullSourcePath, fullTargetDirectory);
|
||||
var targetName = new FileInfo(targetFile).Name;
|
||||
bookmark.FileName = Path.Join(targetFolderStem, targetName);
|
||||
_directoryService.DeleteFiles(new[] {fullSourcePath});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Could not convert file {FilePath}", bookmark.FileName);
|
||||
bookmark.FileName = originalFile;
|
||||
}
|
||||
_unitOfWork.UserRepository.Update(bookmark);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Could not convert bookmark to WebP");
|
||||
}
|
||||
}
|
||||
|
||||
private static string BookmarkStem(int userId, int seriesId, int chapterId)
|
||||
{
|
||||
return Path.Join($"{userId}", $"{seriesId}", $"{chapterId}");
|
||||
|
@ -1,7 +1,9 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using NetVips;
|
||||
using SixLabors.ImageSharp;
|
||||
using Image = NetVips.Image;
|
||||
|
||||
namespace API.Services;
|
||||
|
||||
@ -19,6 +21,12 @@ public interface IImageService
|
||||
string CreateThumbnailFromBase64(string encodedImage, string fileName);
|
||||
|
||||
string WriteCoverThumbnail(Stream stream, string fileName, string outputDirectory);
|
||||
/// <summary>
|
||||
/// Converts the passed image to webP and outputs it in the same directory
|
||||
/// </summary>
|
||||
/// <param name="filePath">Full path to the image to convert</param>
|
||||
/// <returns>File of written webp image</returns>
|
||||
Task<string> ConvertToWebP(string filePath, string outputPath);
|
||||
}
|
||||
|
||||
public class ImageService : IImageService
|
||||
@ -95,6 +103,18 @@ public class ImageService : IImageService
|
||||
return filename;
|
||||
}
|
||||
|
||||
public async Task<string> ConvertToWebP(string filePath, string outputPath)
|
||||
{
|
||||
var file = _directoryService.FileSystem.FileInfo.FromFileName(filePath);
|
||||
var fileName = file.Name.Replace(file.Extension, string.Empty);
|
||||
var outputFile = Path.Join(outputPath, fileName + ".webp");
|
||||
|
||||
|
||||
using var sourceImage = await SixLabors.ImageSharp.Image.LoadAsync(filePath);
|
||||
await sourceImage.SaveAsWebpAsync(outputFile);
|
||||
return outputFile;
|
||||
}
|
||||
|
||||
|
||||
/// <inheritdoc />
|
||||
public string CreateThumbnailFromBase64(string encodedImage, string fileName)
|
||||
|
@ -1,4 +1,5 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using API.Data;
|
||||
@ -6,6 +7,7 @@ using API.Entities.Enums;
|
||||
using API.Helpers.Converters;
|
||||
using API.Services.Tasks;
|
||||
using Hangfire;
|
||||
using Hangfire.Storage;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace API.Services;
|
||||
@ -23,6 +25,8 @@ public interface ITaskScheduler
|
||||
void CancelStatsTasks();
|
||||
Task RunStatCollection();
|
||||
void ScanSiteThemes();
|
||||
|
||||
|
||||
}
|
||||
public class TaskScheduler : ITaskScheduler
|
||||
{
|
||||
|
@ -1,4 +1,5 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using API.Data;
|
||||
@ -19,7 +20,6 @@ namespace API.Services.Tasks
|
||||
Task DeleteChapterCoverImages();
|
||||
Task DeleteTagCoverImages();
|
||||
Task CleanupBackups();
|
||||
Task CleanupBookmarks();
|
||||
}
|
||||
/// <summary>
|
||||
/// Cleans up after operations on reoccurring basis
|
||||
@ -65,7 +65,6 @@ namespace API.Services.Tasks
|
||||
await SendProgress(0.7F, "Cleaning deleted cover images");
|
||||
await DeleteTagCoverImages();
|
||||
await DeleteReadingListCoverImages();
|
||||
await SendProgress(0.8F, "Cleaning deleted cover images");
|
||||
await SendProgress(1F, "Cleanup finished");
|
||||
_logger.LogInformation("Cleanup finished");
|
||||
}
|
||||
@ -152,7 +151,7 @@ namespace API.Services.Tasks
|
||||
/// </summary>
|
||||
public async Task CleanupBackups()
|
||||
{
|
||||
const int dayThreshold = 30;
|
||||
const int dayThreshold = 30; // TODO: We can make this a config option
|
||||
_logger.LogInformation("Beginning cleanup of Database backups at {Time}", DateTime.Now);
|
||||
var backupDirectory =
|
||||
(await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.BackupDirectory)).Value;
|
||||
@ -176,39 +175,5 @@ namespace API.Services.Tasks
|
||||
}
|
||||
_logger.LogInformation("Finished cleanup of Database backups at {Time}", DateTime.Now);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Removes all files in the BookmarkDirectory that don't currently have bookmarks in the Database
|
||||
/// </summary>
|
||||
public Task CleanupBookmarks()
|
||||
{
|
||||
// TODO: This is disabled for now while we test and validate a new method of deleting bookmarks
|
||||
return Task.CompletedTask;
|
||||
// Search all files in bookmarks/ except bookmark files and delete those
|
||||
// var bookmarkDirectory =
|
||||
// (await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.BookmarkDirectory)).Value;
|
||||
// var allBookmarkFiles = _directoryService.GetFiles(bookmarkDirectory, searchOption: SearchOption.AllDirectories).Select(Parser.Parser.NormalizePath);
|
||||
// var bookmarks = (await _unitOfWork.UserRepository.GetAllBookmarksAsync())
|
||||
// .Select(b => Parser.Parser.NormalizePath(_directoryService.FileSystem.Path.Join(bookmarkDirectory,
|
||||
// b.FileName)));
|
||||
//
|
||||
//
|
||||
// var filesToDelete = allBookmarkFiles.AsEnumerable().Except(bookmarks).ToList();
|
||||
// _logger.LogDebug("[Bookmarks] Bookmark cleanup wants to delete {Count} files", filesToDelete.Count);
|
||||
//
|
||||
// if (filesToDelete.Count == 0) return;
|
||||
//
|
||||
// _directoryService.DeleteFiles(filesToDelete);
|
||||
//
|
||||
// // Clear all empty directories
|
||||
// foreach (var directory in _directoryService.FileSystem.Directory.GetDirectories(bookmarkDirectory, "", SearchOption.AllDirectories))
|
||||
// {
|
||||
// if (_directoryService.FileSystem.Directory.GetFiles(directory, "", SearchOption.AllDirectories).Length == 0 &&
|
||||
// _directoryService.FileSystem.Directory.GetDirectories(directory).Length == 0)
|
||||
// {
|
||||
// _directoryService.FileSystem.Directory.Delete(directory, false);
|
||||
// }
|
||||
// }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -789,7 +789,7 @@ public class ScannerService : IScannerService
|
||||
// Update all the metadata on the Chapters
|
||||
foreach (var chapter in volume.Chapters)
|
||||
{
|
||||
var firstFile = chapter.Files.OrderBy(x => x.Chapter).FirstOrDefault();
|
||||
var firstFile = chapter.Files.MinBy(x => x.Chapter);
|
||||
if (firstFile == null || _cacheHelper.HasFileNotChangedSinceCreationOrLastScan(chapter, false, firstFile)) continue;
|
||||
try
|
||||
{
|
||||
@ -923,6 +923,7 @@ public class ScannerService : IScannerService
|
||||
}
|
||||
|
||||
if (comicInfo == null) return;
|
||||
_logger.LogDebug("[ScannerService] Read ComicInfo for {File}", firstFile.FilePath);
|
||||
|
||||
chapter.AgeRating = ComicInfo.ConvertAgeRatingToEnum(comicInfo.AgeRating);
|
||||
|
||||
|
@ -11,6 +11,7 @@ namespace API.SignalR;
|
||||
public interface IEventHub
|
||||
{
|
||||
Task SendMessageAsync(string method, SignalRMessage message, bool onlyAdmins = true);
|
||||
Task SendMessageToAsync(string method, SignalRMessage message, int userId);
|
||||
}
|
||||
|
||||
public class EventHub : IEventHub
|
||||
@ -42,4 +43,17 @@ public class EventHub : IEventHub
|
||||
|
||||
await users.SendAsync(method, message);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sends a message directly to a user if they are connected
|
||||
/// </summary>
|
||||
/// <param name="method"></param>
|
||||
/// <param name="message"></param>
|
||||
/// <param name="userId"></param>
|
||||
/// <returns></returns>
|
||||
public async Task SendMessageToAsync(string method, SignalRMessage message, int userId)
|
||||
{
|
||||
var user = await _unitOfWork.UserRepository.GetUserByIdAsync(userId);
|
||||
await _messageHub.Clients.User(user.UserName).SendAsync(method, message);
|
||||
}
|
||||
}
|
||||
|
@ -73,6 +73,7 @@ namespace API.SignalR
|
||||
/// A type of event that has progress (determinate or indeterminate).
|
||||
/// The underlying event will have a name to give details on how to handle.
|
||||
/// </summary>
|
||||
/// <remarks>This is not an Event Name, it is used as the method only</remarks>
|
||||
public const string NotificationProgress = "NotificationProgress";
|
||||
/// <summary>
|
||||
/// Event sent out when Scan Loop is parsing a file
|
||||
@ -94,6 +95,14 @@ namespace API.SignalR
|
||||
/// A user's progress was modified
|
||||
/// </summary>
|
||||
public const string UserProgressUpdate = "UserProgressUpdate";
|
||||
/// <summary>
|
||||
/// A user's account or preferences were updated and UI needs to refresh to stay in sync
|
||||
/// </summary>
|
||||
public const string UserUpdate = "UserUpdate";
|
||||
/// <summary>
|
||||
/// When bulk bookmarks are being converted
|
||||
/// </summary>
|
||||
public const string ConvertBookmarksProgress = "ConvertBookmarksProgress";
|
||||
|
||||
|
||||
|
||||
@ -387,5 +396,37 @@ namespace API.SignalR
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
public static SignalRMessage UserUpdateEvent(int userId, string userName)
|
||||
{
|
||||
return new SignalRMessage()
|
||||
{
|
||||
Name = UserUpdate,
|
||||
Title = "User Update",
|
||||
Progress = ProgressType.None,
|
||||
Body = new
|
||||
{
|
||||
UserId = userId,
|
||||
UserName = userName
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
public static SignalRMessage ConvertBookmarksProgressEvent(float progress, string eventType)
|
||||
{
|
||||
return new SignalRMessage()
|
||||
{
|
||||
Name = ConvertBookmarksProgress,
|
||||
Title = "Converting Bookmarks to WebP",
|
||||
SubTitle = string.Empty,
|
||||
EventType = eventType,
|
||||
Progress = ProgressType.Determinate,
|
||||
Body = new
|
||||
{
|
||||
Progress = progress,
|
||||
EventTime = DateTime.Now
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -9,7 +9,6 @@ using Microsoft.AspNetCore.SignalR;
|
||||
|
||||
namespace API.SignalR
|
||||
{
|
||||
|
||||
/// <summary>
|
||||
/// Generic hub for sending messages to UI
|
||||
/// </summary>
|
||||
@ -17,32 +16,14 @@ namespace API.SignalR
|
||||
public class MessageHub : Hub
|
||||
{
|
||||
private readonly IPresenceTracker _tracker;
|
||||
private static readonly HashSet<string> Connections = new HashSet<string>();
|
||||
|
||||
public MessageHub(IPresenceTracker tracker)
|
||||
{
|
||||
_tracker = tracker;
|
||||
}
|
||||
|
||||
public static bool IsConnected
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (Connections)
|
||||
{
|
||||
return Connections.Count != 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public override async Task OnConnectedAsync()
|
||||
{
|
||||
|
||||
lock (Connections)
|
||||
{
|
||||
Connections.Add(Context.ConnectionId);
|
||||
}
|
||||
|
||||
await _tracker.UserConnected(Context.User.GetUsername(), Context.ConnectionId);
|
||||
|
||||
var currentUsers = await PresenceTracker.GetOnlineUsers();
|
||||
@ -54,11 +35,6 @@ namespace API.SignalR
|
||||
|
||||
public override async Task OnDisconnectedAsync(Exception exception)
|
||||
{
|
||||
lock (Connections)
|
||||
{
|
||||
Connections.Remove(Context.ConnectionId);
|
||||
}
|
||||
|
||||
await _tracker.UserDisconnected(Context.User.GetUsername(), Context.ConnectionId);
|
||||
|
||||
var currentUsers = await PresenceTracker.GetOnlineUsers();
|
||||
|
@ -89,6 +89,7 @@ namespace API.SignalR.Presence
|
||||
|
||||
public Task<string[]> GetOnlineAdmins()
|
||||
{
|
||||
// TODO: This might end in stale data, we want to get the online users, query against DB to check if they are admins then return
|
||||
string[] onlineUsers;
|
||||
lock (OnlineUsers)
|
||||
{
|
||||
@ -107,7 +108,7 @@ namespace API.SignalR.Presence
|
||||
connectionIds = OnlineUsers.GetValueOrDefault(username)?.ConnectionIds;
|
||||
}
|
||||
|
||||
return Task.FromResult(connectionIds);
|
||||
return Task.FromResult(connectionIds ?? new List<string>());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -9,10 +9,10 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Flurl.Http" Version="3.2.3" />
|
||||
<PackageReference Include="Flurl.Http" Version="3.2.4" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="6.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting" Version="6.0.1" />
|
||||
<PackageReference Include="SonarAnalyzer.CSharp" Version="8.38.0.46746">
|
||||
<PackageReference Include="SonarAnalyzer.CSharp" Version="8.39.0.47922">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
|
4
UI/Web/src/app/_models/events/user-update-event.ts
Normal file
4
UI/Web/src/app/_models/events/user-update-event.ts
Normal file
@ -0,0 +1,4 @@
|
||||
export interface UserUpdateEvent {
|
||||
userId: number;
|
||||
userName: string;
|
||||
}
|
7
UI/Web/src/app/_models/job/job.ts
Normal file
7
UI/Web/src/app/_models/job/job.ts
Normal file
@ -0,0 +1,7 @@
|
||||
export interface Job {
|
||||
id: string;
|
||||
title: string;
|
||||
cron: string;
|
||||
createdAt: string;
|
||||
lastExecution: string;
|
||||
}
|
@ -1,14 +1,15 @@
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { Injectable, OnDestroy } from '@angular/core';
|
||||
import { Observable, of, ReplaySubject, Subject } from 'rxjs';
|
||||
import { map, takeUntil } from 'rxjs/operators';
|
||||
import { filter, map, switchMap, takeUntil } from 'rxjs/operators';
|
||||
import { environment } from 'src/environments/environment';
|
||||
import { Preferences } from '../_models/preferences/preferences';
|
||||
import { User } from '../_models/user';
|
||||
import { Router } from '@angular/router';
|
||||
import { MessageHubService } from './message-hub.service';
|
||||
import { EVENTS, MessageHubService } from './message-hub.service';
|
||||
import { ThemeService } from './theme.service';
|
||||
import { InviteUserResponse } from '../_models/invite-user-response';
|
||||
import { UserUpdateEvent } from '../_models/events/user-update-event';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
@ -32,7 +33,12 @@ export class AccountService implements OnDestroy {
|
||||
private readonly onDestroy = new Subject<void>();
|
||||
|
||||
constructor(private httpClient: HttpClient, private router: Router,
|
||||
private messageHub: MessageHubService, private themeService: ThemeService) {}
|
||||
private messageHub: MessageHubService, private themeService: ThemeService) {
|
||||
messageHub.messages$.pipe(filter(evt => evt.event === EVENTS.UserUpdate),
|
||||
map(evt => evt.payload as UserUpdateEvent),
|
||||
switchMap(() => this.refreshToken()))
|
||||
.subscribe(() => {});
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.onDestroy.next();
|
||||
@ -211,6 +217,7 @@ export class AccountService implements OnDestroy {
|
||||
|
||||
private refreshToken() {
|
||||
if (this.currentUser === null || this.currentUser === undefined) return of();
|
||||
console.log('refreshing token and updating user account');
|
||||
|
||||
return this.httpClient.post<{token: string, refreshToken: string}>(this.baseUrl + 'account/refresh-token', {token: this.currentUser.token, refreshToken: this.currentUser.refreshToken}).pipe(map(user => {
|
||||
if (this.currentUser) {
|
||||
@ -218,8 +225,7 @@ export class AccountService implements OnDestroy {
|
||||
this.currentUser.refreshToken = user.refreshToken;
|
||||
}
|
||||
|
||||
this.currentUserSource.next(this.currentUser);
|
||||
this.startRefreshTokenTimer();
|
||||
this.setCurrentUser(this.currentUser);
|
||||
return user;
|
||||
}));
|
||||
}
|
||||
|
@ -7,6 +7,7 @@ import { environment } from 'src/environments/environment';
|
||||
import { LibraryModifiedEvent } from '../_models/events/library-modified-event';
|
||||
import { NotificationProgressEvent } from '../_models/events/notification-progress-event';
|
||||
import { ThemeProgressEvent } from '../_models/events/theme-progress-event';
|
||||
import { UserUpdateEvent } from '../_models/events/user-update-event';
|
||||
import { User } from '../_models/user';
|
||||
|
||||
export enum EVENTS {
|
||||
@ -58,6 +59,14 @@ export enum EVENTS {
|
||||
* A user updates an entities read progress
|
||||
*/
|
||||
UserProgressUpdate = 'UserProgressUpdate',
|
||||
/**
|
||||
* A user updates account or preferences
|
||||
*/
|
||||
UserUpdate = 'UserUpdate',
|
||||
/**
|
||||
* When bulk bookmarks are being converted
|
||||
*/
|
||||
ConvertBookmarksProgress = 'ConvertBookmarksProgress',
|
||||
}
|
||||
|
||||
export interface Message<T> {
|
||||
@ -139,6 +148,13 @@ export class MessageHubService {
|
||||
});
|
||||
});
|
||||
|
||||
this.hubConnection.on(EVENTS.ConvertBookmarksProgress, resp => {
|
||||
this.messagesSource.next({
|
||||
event: EVENTS.ConvertBookmarksProgress,
|
||||
payload: resp.body
|
||||
});
|
||||
});
|
||||
|
||||
this.hubConnection.on(EVENTS.LibraryModified, resp => {
|
||||
this.messagesSource.next({
|
||||
event: EVENTS.LibraryModified,
|
||||
@ -175,6 +191,14 @@ export class MessageHubService {
|
||||
});
|
||||
});
|
||||
|
||||
this.hubConnection.on(EVENTS.UserUpdate, resp => {
|
||||
console.log('got UserUpdate', resp);
|
||||
this.messagesSource.next({
|
||||
event: EVENTS.UserUpdate,
|
||||
payload: resp.body as UserUpdateEvent
|
||||
});
|
||||
});
|
||||
|
||||
this.hubConnection.on(EVENTS.Error, resp => {
|
||||
this.messagesSource.next({
|
||||
event: EVENTS.Error,
|
||||
|
@ -3,6 +3,7 @@ import { Injectable } from '@angular/core';
|
||||
import { environment } from 'src/environments/environment';
|
||||
import { ServerInfo } from '../admin/_models/server-info';
|
||||
import { UpdateVersionEvent } from '../_models/events/update-version-event';
|
||||
import { Job } from '../_models/job/job';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
@ -40,4 +41,12 @@ export class ServerService {
|
||||
isServerAccessible() {
|
||||
return this.httpClient.get<boolean>(this.baseUrl + 'server/accessible');
|
||||
}
|
||||
|
||||
getReoccuringJobs() {
|
||||
return this.httpClient.get<Job[]>(this.baseUrl + 'server/jobs');
|
||||
}
|
||||
|
||||
convertBookmarks() {
|
||||
return this.httpClient.post(this.baseUrl + 'server/convert-bookmarks', {});
|
||||
}
|
||||
}
|
||||
|
@ -9,4 +9,5 @@ export interface ServerSettings {
|
||||
baseUrl: string;
|
||||
bookmarksDirectory: string;
|
||||
emailServiceUrl: string;
|
||||
convertBookmarkToWebP: boolean;
|
||||
}
|
||||
|
@ -20,6 +20,9 @@ import { LibrarySelectorComponent } from './library-selector/library-selector.co
|
||||
import { EditUserComponent } from './edit-user/edit-user.component';
|
||||
import { UserSettingsModule } from '../user-settings/user-settings.module';
|
||||
import { SidenavModule } from '../sidenav/sidenav.module';
|
||||
import { ManageMediaSettingsComponent } from './manage-media-settings/manage-media-settings.component';
|
||||
import { ManageEmailSettingsComponent } from './manage-email-settings/manage-email-settings.component';
|
||||
import { ManageTasksSettingsComponent } from './manage-tasks-settings/manage-tasks-settings.component';
|
||||
|
||||
|
||||
|
||||
@ -39,6 +42,9 @@ import { SidenavModule } from '../sidenav/sidenav.module';
|
||||
RoleSelectorComponent,
|
||||
LibrarySelectorComponent,
|
||||
EditUserComponent,
|
||||
ManageMediaSettingsComponent,
|
||||
ManageEmailSettingsComponent,
|
||||
ManageTasksSettingsComponent,
|
||||
],
|
||||
imports: [
|
||||
CommonModule,
|
||||
|
@ -8,18 +8,30 @@
|
||||
<li *ngFor="let tab of tabs" [ngbNavItem]="tab">
|
||||
<a ngbNavLink routerLink="." [fragment]="tab.fragment">{{ tab.title | sentenceCase }}</a>
|
||||
<ng-template ngbNavContent>
|
||||
<ng-container *ngIf="tab.fragment === ''">
|
||||
<ng-container *ngIf="tab.fragment === TabID.General">
|
||||
<app-manage-settings></app-manage-settings>
|
||||
</ng-container>
|
||||
<ng-container *ngIf="tab.fragment === 'users'">
|
||||
<ng-container *ngIf="tab.fragment === TabID.Email">
|
||||
<app-manage-email-settings></app-manage-email-settings>
|
||||
</ng-container>
|
||||
<ng-container *ngIf="tab.fragment === TabID.Media">
|
||||
<app-manage-media-settings></app-manage-media-settings>
|
||||
</ng-container>
|
||||
<ng-container *ngIf="tab.fragment === TabID.Users">
|
||||
<app-manage-users></app-manage-users>
|
||||
</ng-container>
|
||||
<ng-container *ngIf="tab.fragment === 'libraries'">
|
||||
<ng-container *ngIf="tab.fragment === TabID.Libraries">
|
||||
<app-manage-library></app-manage-library>
|
||||
</ng-container>
|
||||
<ng-container *ngIf="tab.fragment === 'system'">
|
||||
<ng-container *ngIf="tab.fragment === TabID.System">
|
||||
<app-manage-system></app-manage-system>
|
||||
</ng-container>
|
||||
<ng-container *ngIf="tab.fragment === TabID.Tasks">
|
||||
<app-manage-tasks-settings></app-manage-tasks-settings>
|
||||
</ng-container>
|
||||
<ng-container *ngIf="tab.fragment === TabID.Plugins">
|
||||
Nothing here yet. This will be built out in a future update.
|
||||
</ng-container>
|
||||
</ng-template>
|
||||
</li>
|
||||
</ul>
|
||||
|
@ -5,7 +5,16 @@ import { ServerService } from 'src/app/_services/server.service';
|
||||
import { Title } from '@angular/platform-browser';
|
||||
import { NavService } from '../../_services/nav.service';
|
||||
|
||||
|
||||
enum TabID {
|
||||
General = '',
|
||||
Email = 'email',
|
||||
Media = 'media',
|
||||
Users = 'users',
|
||||
Libraries = 'libraries',
|
||||
System = 'system',
|
||||
Plugins = 'plugins',
|
||||
Tasks = 'tasks'
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-dashboard',
|
||||
@ -15,14 +24,22 @@ import { NavService } from '../../_services/nav.service';
|
||||
export class DashboardComponent implements OnInit {
|
||||
|
||||
tabs: Array<{title: string, fragment: string}> = [
|
||||
{title: 'General', fragment: ''},
|
||||
{title: 'Users', fragment: 'users'},
|
||||
{title: 'Libraries', fragment: 'libraries'},
|
||||
{title: 'System', fragment: 'system'},
|
||||
{title: 'General', fragment: TabID.General},
|
||||
{title: 'Users', fragment: TabID.Users},
|
||||
{title: 'Libraries', fragment: TabID.Libraries},
|
||||
{title: 'Media', fragment: TabID.Media},
|
||||
{title: 'Email', fragment: TabID.Email},
|
||||
//{title: 'Plugins', fragment: TabID.Plugins},
|
||||
{title: 'Tasks', fragment: TabID.Tasks},
|
||||
{title: 'System', fragment: TabID.System},
|
||||
];
|
||||
counter = this.tabs.length + 1;
|
||||
active = this.tabs[0];
|
||||
|
||||
get TabID() {
|
||||
return TabID;
|
||||
}
|
||||
|
||||
constructor(public route: ActivatedRoute, private serverService: ServerService,
|
||||
private toastr: ToastrService, private titleService: Title, public navService: NavService) {
|
||||
this.route.fragment.subscribe(frag => {
|
||||
|
@ -0,0 +1,29 @@
|
||||
<div class="container-fluid">
|
||||
<form [formGroup]="settingsForm" *ngIf="serverSettings !== undefined">
|
||||
<h4>Email Services (SMTP)</h4>
|
||||
<p>Kavita comes out of the box with an email service to power flows like invite user, forgot password, etc. Emails sent via our service are deleted immediately. You can use your own
|
||||
email service. Set the url of the email service and use the Test button to ensure it works. At any time you can reset to ours. There is no way to disable emails although you are not required to use a
|
||||
valid email address for users. Confirmation links will always be saved to logs. Emails will not be sent if you are not accessing Kavita via a publically reachable url.
|
||||
</p>
|
||||
<div class="mb-3">
|
||||
<label for="settings-emailservice" class="form-label">Email Service Url</label> <i class="fa fa-info-circle" placement="right" [ngbTooltip]="emailServiceTooltip" role="button" tabindex="0"></i>
|
||||
<ng-template #emailServiceTooltip>Use fully qualified url of the email service. Do not include ending slash.</ng-template>
|
||||
<span class="visually-hidden" id="settings-emailservice-help"><ng-container [ngTemplateOutlet]="emailServiceTooltip"></ng-container></span>
|
||||
<div class="input-group">
|
||||
<input id="settings-emailservice" aria-describedby="settings-emailservice-help" class="form-control" formControlName="emailServiceUrl" type="text" aria-describedby="change-bookmarks-dir">
|
||||
<button class="btn btn-outline-secondary" (click)="resetEmailServiceUrl()">
|
||||
Reset
|
||||
</button>
|
||||
<button class="btn btn-outline-secondary" (click)="testEmailServiceUrl()">
|
||||
Test
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-auto d-flex d-md-block justify-content-sm-center text-md-end">
|
||||
<button type="button" class="flex-fill btn btn-secondary me-2" (click)="resetToDefaults()">Reset to Default</button>
|
||||
<button type="button" class="flex-fill btn btn-secondary me-2" (click)="resetForm()">Reset</button>
|
||||
<button type="submit" class="flex-fill btn btn-primary" (click)="saveSettings()" [disabled]="!settingsForm.touched && !settingsForm.dirty">Save</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
@ -0,0 +1,78 @@
|
||||
import { Component, OnInit } from '@angular/core';
|
||||
import { FormControl, FormGroup, Validators } from '@angular/forms';
|
||||
import { ToastrService } from 'ngx-toastr';
|
||||
import { take } from 'rxjs';
|
||||
import { SettingsService, EmailTestResult } from '../settings.service';
|
||||
import { ServerSettings } from '../_models/server-settings';
|
||||
|
||||
@Component({
|
||||
selector: 'app-manage-email-settings',
|
||||
templateUrl: './manage-email-settings.component.html',
|
||||
styleUrls: ['./manage-email-settings.component.scss']
|
||||
})
|
||||
export class ManageEmailSettingsComponent implements OnInit {
|
||||
|
||||
serverSettings!: ServerSettings;
|
||||
settingsForm: FormGroup = new FormGroup({});
|
||||
|
||||
constructor(private settingsService: SettingsService, private toastr: ToastrService) { }
|
||||
|
||||
ngOnInit(): void {
|
||||
this.settingsService.getServerSettings().pipe(take(1)).subscribe((settings: ServerSettings) => {
|
||||
this.serverSettings = settings;
|
||||
this.settingsForm.addControl('emailServiceUrl', new FormControl(this.serverSettings.emailServiceUrl, [Validators.required]));
|
||||
});
|
||||
}
|
||||
|
||||
resetForm() {
|
||||
this.settingsForm.get('emailServiceUrl')?.setValue(this.serverSettings.emailServiceUrl);
|
||||
}
|
||||
|
||||
async saveSettings() {
|
||||
const modelSettings = Object.assign({}, this.serverSettings);
|
||||
modelSettings.emailServiceUrl = this.settingsForm.get('emailServiceUrl')?.value;
|
||||
|
||||
this.settingsService.updateServerSettings(modelSettings).pipe(take(1)).subscribe(async (settings: ServerSettings) => {
|
||||
this.serverSettings = settings;
|
||||
this.resetForm();
|
||||
this.toastr.success('Server settings updated');
|
||||
}, (err: any) => {
|
||||
console.error('error: ', err);
|
||||
});
|
||||
}
|
||||
|
||||
resetToDefaults() {
|
||||
this.settingsService.resetServerSettings().pipe(take(1)).subscribe(async (settings: ServerSettings) => {
|
||||
this.serverSettings = settings;
|
||||
this.resetForm();
|
||||
this.toastr.success('Server settings updated');
|
||||
}, (err: any) => {
|
||||
console.error('error: ', err);
|
||||
});
|
||||
}
|
||||
|
||||
resetEmailServiceUrl() {
|
||||
this.settingsService.resetEmailServerSettings().pipe(take(1)).subscribe(async (settings: ServerSettings) => {
|
||||
this.serverSettings.emailServiceUrl = settings.emailServiceUrl;
|
||||
this.resetForm();
|
||||
this.toastr.success('Email Service Reset');
|
||||
}, (err: any) => {
|
||||
console.error('error: ', err);
|
||||
});
|
||||
}
|
||||
|
||||
testEmailServiceUrl() {
|
||||
this.settingsService.testEmailServerSettings(this.settingsForm.get('emailServiceUrl')?.value || '').pipe(take(1)).subscribe(async (result: EmailTestResult) => {
|
||||
if (result.successful) {
|
||||
this.toastr.success('Email Service Url validated');
|
||||
} else {
|
||||
this.toastr.error('Email Service Url did not respond. ' + result.errorMessage);
|
||||
}
|
||||
|
||||
}, (err: any) => {
|
||||
console.error('error: ', err);
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
}
|
@ -0,0 +1,21 @@
|
||||
<div class="container-fluid">
|
||||
<form [formGroup]="settingsForm" *ngIf="serverSettings !== undefined">
|
||||
<div class="row g-0">
|
||||
<div class="col-md-6 col-sm-12 mb-3">
|
||||
<label for="bookmark-webp" class="form-label" aria-describedby="collection-info">Save Bookmarks as WebP</label> <i class="fa fa-info-circle" placement="right" [ngbTooltip]="convertBookmarkToWebPTooltip" role="button" tabindex="0"></i>
|
||||
<ng-template #convertBookmarkToWebPTooltip>When saving bookmarks, covert them to WebP. WebP is not supported on Safari devices and will not render at all. WebP can drastically reduce space requirements for files.</ng-template>
|
||||
<span class="visually-hidden" id="settings-convertBookmarkToWebP-help"><ng-container [ngTemplateOutlet]="convertBookmarkToWebPTooltip"></ng-container></span>
|
||||
<div class="form-check form-switch">
|
||||
<input id="bookmark-webp" type="checkbox" class="form-check-input" formControlName="convertBookmarkToWebP" role="switch">
|
||||
<label for="bookmark-webp" class="form-check-label" aria-describedby="settings-convertBookmarkToWebP-help">{{settingsForm.get('convertBookmarkToWebP')?.value ? 'WebP' : 'Original' }}</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-auto d-flex d-md-block justify-content-sm-center text-md-end">
|
||||
<button type="button" class="flex-fill btn btn-secondary me-2" (click)="resetToDefaults()">Reset to Default</button>
|
||||
<button type="button" class="flex-fill btn btn-secondary me-2" (click)="resetForm()">Reset</button>
|
||||
<button type="submit" class="flex-fill btn btn-primary" (click)="saveSettings()" [disabled]="!settingsForm.touched && !settingsForm.dirty">Save</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
@ -0,0 +1,53 @@
|
||||
import { Component, OnInit } from '@angular/core';
|
||||
import { FormGroup, FormControl, Validators } from '@angular/forms';
|
||||
import { ToastrService } from 'ngx-toastr';
|
||||
import { take } from 'rxjs';
|
||||
import { SettingsService } from '../settings.service';
|
||||
import { ServerSettings } from '../_models/server-settings';
|
||||
|
||||
@Component({
|
||||
selector: 'app-manage-media-settings',
|
||||
templateUrl: './manage-media-settings.component.html',
|
||||
styleUrls: ['./manage-media-settings.component.scss']
|
||||
})
|
||||
export class ManageMediaSettingsComponent implements OnInit {
|
||||
|
||||
serverSettings!: ServerSettings;
|
||||
settingsForm: FormGroup = new FormGroup({});
|
||||
|
||||
constructor(private settingsService: SettingsService, private toastr: ToastrService) { }
|
||||
|
||||
ngOnInit(): void {
|
||||
this.settingsService.getServerSettings().pipe(take(1)).subscribe((settings: ServerSettings) => {
|
||||
this.serverSettings = settings;
|
||||
this.settingsForm.addControl('convertBookmarkToWebP', new FormControl(this.serverSettings.convertBookmarkToWebP, [Validators.required]));
|
||||
});
|
||||
}
|
||||
|
||||
resetForm() {
|
||||
this.settingsForm.get('convertBookmarkToWebP')?.setValue(this.serverSettings.convertBookmarkToWebP);
|
||||
}
|
||||
|
||||
async saveSettings() {
|
||||
const modelSettings = Object.assign({}, this.serverSettings);
|
||||
modelSettings.convertBookmarkToWebP = this.settingsForm.get('convertBookmarkToWebP')?.value;
|
||||
|
||||
this.settingsService.updateServerSettings(modelSettings).pipe(take(1)).subscribe(async (settings: ServerSettings) => {
|
||||
this.serverSettings = settings;
|
||||
this.resetForm();
|
||||
this.toastr.success('Server settings updated');
|
||||
}, (err: any) => {
|
||||
console.error('error: ', err);
|
||||
});
|
||||
}
|
||||
|
||||
resetToDefaults() {
|
||||
this.settingsService.resetServerSettings().pipe(take(1)).subscribe(async (settings: ServerSettings) => {
|
||||
this.serverSettings = settings;
|
||||
this.resetForm();
|
||||
this.toastr.success('Server settings updated');
|
||||
}, (err: any) => {
|
||||
console.error('error: ', err);
|
||||
});
|
||||
}
|
||||
}
|
@ -10,7 +10,7 @@
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="settings-bookmarksdir" class="form-label">Bookmarks Directory</label> <i class="fa fa-info-circle" placement="right" [ngbTooltip]="bookmarksDirectoryTooltip" role="button" tabindex="0"></i>
|
||||
<ng-template #bookmarksDirectoryTooltip>Location where bookmarks will be stored. Bookmarks are source files and can be large. Choose a location with adequate storage. Directory is managed, other files within directory will be deleted.</ng-template>
|
||||
<ng-template #bookmarksDirectoryTooltip>Location where bookmarks will be stored. Bookmarks are source files and can be large. Choose a location with adequate storage. Directory is managed, other files within directory will be deleted. If docker, mount an additional volume and use that.</ng-template>
|
||||
<span class="visually-hidden" id="settings-bookmarksdir-help"><ng-container [ngTemplateOutlet]="bookmarksDirectoryTooltip"></ng-container></span>
|
||||
<div class="input-group">
|
||||
<input readonly id="settings-bookmarksdir" aria-describedby="settings-bookmarksdir-help" class="form-control" formControlName="bookmarksDirectory" type="text" aria-describedby="change-bookmarks-dir">
|
||||
@ -40,13 +40,14 @@
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="stat-collection" class="form-label" aria-describedby="collection-info">Allow Anonymous Usage Collection</label>
|
||||
<p class="accent" id="collection-info">Send anonymous usage and error information to Kavita's servers. This includes information on your browser, error reporting as well as OS and runtime version. We will use this information to prioritize features, bug fixes, and preformance tuning. Requires restart to take effect. See <a href="https://wiki.kavitareader.com/en/faq" target="_blank" referrerpolicy="no-refer">wiki</a> for what is collected.</p>
|
||||
<p class="accent" id="collection-info">Send anonymous usage data to Kavita's servers. This includes information on certain features used, number of files, OS version, kavita install version, cpu and memory. We will use this information to prioritize features, bug fixes, and preformance tuning. Requires restart to take effect. See <a href="https://wiki.kavitareader.com/en/faq" target="_blank" referrerpolicy="no-refer">wiki</a> for what is collected.</p>
|
||||
<div class="form-check form-switch">
|
||||
<input id="stat-collection" type="checkbox" aria-label="Stat Collection" class="form-check-input" formControlName="allowStatCollection" role="switch">
|
||||
<label for="stat-collection" class="form-check-label">Send Data</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- TODO: Move this to Plugins tab once we build out some basic tables -->
|
||||
<div class="mb-3">
|
||||
<label for="opds" aria-describedby="opds-info" class="form-label">OPDS</label>
|
||||
<p class="accent" id="opds-info">OPDS support will allow all users to use OPDS to read and download content from the server. If OPDS is enabled, a user will not need download permissions to download media while using it.</p>
|
||||
@ -55,46 +56,6 @@
|
||||
<label for="opds" class="form-check-label">Enable OPDS</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h4>Email Services (SMTP)</h4>
|
||||
<p class="accent">Kavita comes out of the box with an email service to power flows like invite user, forgot password, etc. Emails sent via our service are deleted immediately. You can use your own
|
||||
email service. Set the url of the email service and use the Test button to ensure it works. At any time you can reset to ours. There is no way to disable emails although confirmation links will always
|
||||
be saved to logs.
|
||||
</p>
|
||||
<div class="mb-3">
|
||||
<label for="settings-emailservice" class="form-label">Email Service Url</label> <i class="fa fa-info-circle" placement="right" [ngbTooltip]="emailServiceTooltip" role="button" tabindex="0"></i>
|
||||
<ng-template #emailServiceTooltip>Use fully qualified url of the email service. Do not include ending slash.</ng-template>
|
||||
<span class="visually-hidden" id="settings-emailservice-help"><ng-container [ngTemplateOutlet]="emailServiceTooltip"></ng-container></span>
|
||||
<div class="input-group">
|
||||
<input id="settings-emailservice" aria-describedby="settings-emailservice-help" class="form-control" formControlName="emailServiceUrl" type="text" aria-describedby="change-bookmarks-dir">
|
||||
<button class="btn btn-outline-secondary" (click)="resetEmailServiceUrl()">
|
||||
Reset
|
||||
</button>
|
||||
<button class="btn btn-outline-secondary" (click)="testEmailServiceUrl()">
|
||||
Test
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<h4>Reoccuring Tasks</h4>
|
||||
<div class="mb-3">
|
||||
<label for="settings-tasks-scan" class="form-label">Library Scan</label> <i class="fa fa-info-circle" placement="right" [ngbTooltip]="taskScanTooltip" role="button" tabindex="0"></i>
|
||||
<ng-template #taskScanTooltip>How often Kavita will scan and refresh metadata around manga files.</ng-template>
|
||||
<span class="visually-hidden" id="settings-tasks-scan-help">How often Kavita will scan and refresh metatdata around manga files.</span>
|
||||
<select class="form-select" aria-describedby="settings-tasks-scan-help" formControlName="taskScan" id="settings-tasks-scan">
|
||||
<option *ngFor="let freq of taskFrequencies" [value]="freq">{{freq | titlecase}}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="settings-tasks-backup" class="form-label">Library Database Backup</label> <i class="fa fa-info-circle" placement="right" [ngbTooltip]="taskBackupTooltip" role="button" tabindex="0"></i>
|
||||
<ng-template #taskBackupTooltip>How often Kavita will backup the database.</ng-template>
|
||||
<span class="visually-hidden" id="settings-tasks-backup-help">How often Kavita will backup the database.</span>
|
||||
<select class="form-select" aria-describedby="settings-tasks-backup-help" formControlName="taskBackup" id="settings-tasks-backup">
|
||||
<option *ngFor="let freq of taskFrequencies" [value]="freq">{{freq | titlecase}}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="col-auto d-flex d-md-block justify-content-sm-center text-md-end">
|
||||
<button type="button" class="flex-fill btn btn-secondary me-2" (click)="resetToDefaults()">Reset to Default</button>
|
||||
|
@ -93,28 +93,28 @@ export class ManageSettingsComponent implements OnInit {
|
||||
});
|
||||
}
|
||||
|
||||
resetEmailServiceUrl() {
|
||||
this.settingsService.resetEmailServerSettings().pipe(take(1)).subscribe(async (settings: ServerSettings) => {
|
||||
this.serverSettings.emailServiceUrl = settings.emailServiceUrl;
|
||||
this.resetForm();
|
||||
this.toastr.success('Email Service Reset');
|
||||
}, (err: any) => {
|
||||
console.error('error: ', err);
|
||||
});
|
||||
}
|
||||
// resetEmailServiceUrl() {
|
||||
// this.settingsService.resetEmailServerSettings().pipe(take(1)).subscribe(async (settings: ServerSettings) => {
|
||||
// this.serverSettings.emailServiceUrl = settings.emailServiceUrl;
|
||||
// this.resetForm();
|
||||
// this.toastr.success('Email Service Reset');
|
||||
// }, (err: any) => {
|
||||
// console.error('error: ', err);
|
||||
// });
|
||||
// }
|
||||
|
||||
testEmailServiceUrl() {
|
||||
this.settingsService.testEmailServerSettings(this.settingsForm.get('emailServiceUrl')?.value || '').pipe(take(1)).subscribe(async (result: EmailTestResult) => {
|
||||
if (result.successful) {
|
||||
this.toastr.success('Email Service Url validated');
|
||||
} else {
|
||||
this.toastr.error('Email Service Url did not respond. ' + result.errorMessage);
|
||||
}
|
||||
// testEmailServiceUrl() {
|
||||
// this.settingsService.testEmailServerSettings(this.settingsForm.get('emailServiceUrl')?.value || '').pipe(take(1)).subscribe(async (result: EmailTestResult) => {
|
||||
// if (result.successful) {
|
||||
// this.toastr.success('Email Service Url validated');
|
||||
// } else {
|
||||
// this.toastr.error('Email Service Url did not respond. ' + result.errorMessage);
|
||||
// }
|
||||
|
||||
}, (err: any) => {
|
||||
console.error('error: ', err);
|
||||
});
|
||||
// }, (err: any) => {
|
||||
// console.error('error: ', err);
|
||||
// });
|
||||
|
||||
}
|
||||
// }
|
||||
|
||||
}
|
||||
|
@ -1,31 +1,4 @@
|
||||
<div class="container-fluid">
|
||||
|
||||
<div class="float-end">
|
||||
<div class="d-inline-block" ngbDropdown #myDrop="ngbDropdown">
|
||||
<button class="btn btn-outline-primary me-2" id="dropdownManual" ngbDropdownToggle>
|
||||
<ng-container *ngIf="backupDBInProgress || clearCacheInProgress || isCheckingForUpdate || downloadLogsInProgress">
|
||||
<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>
|
||||
<span class="visually-hidden">Loading...</span>
|
||||
</ng-container>
|
||||
Actions
|
||||
</button>
|
||||
<div ngbDropdownMenu aria-labelledby="dropdownManual">
|
||||
<button ngbDropdownItem (click)="backupDB()" [disabled]="backupDBInProgress">
|
||||
Backup Database
|
||||
</button>
|
||||
<button ngbDropdownItem (click)="clearCache()" [disabled]="clearCacheInProgress">
|
||||
Clear Cache
|
||||
</button>
|
||||
<button ngbDropdownItem (click)="downloadLogs()" [disabled]="downloadLogsInProgress">
|
||||
Download Logs
|
||||
</button>
|
||||
<button ngbDropdownItem (click)="checkForUpdates()">
|
||||
Check for Updates
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3>About System</h3>
|
||||
<hr/>
|
||||
<div class="mb-3" *ngIf="serverInfo">
|
||||
|
@ -1,10 +1,7 @@
|
||||
import { Component, OnInit } from '@angular/core';
|
||||
import { FormControl, FormGroup, Validators } from '@angular/forms';
|
||||
import { NgbModal } from '@ng-bootstrap/ng-bootstrap';
|
||||
import { ToastrService } from 'ngx-toastr';
|
||||
import { finalize, take, takeWhile } from 'rxjs/operators';
|
||||
import { UpdateNotificationModalComponent } from 'src/app/shared/update-notification/update-notification-modal.component';
|
||||
import { DownloadService } from 'src/app/shared/_services/download.service';
|
||||
import { take } from 'rxjs/operators';
|
||||
import { ServerService } from 'src/app/_services/server.service';
|
||||
import { SettingsService } from '../settings.service';
|
||||
import { ServerInfo } from '../_models/server-info';
|
||||
@ -21,14 +18,9 @@ export class ManageSystemComponent implements OnInit {
|
||||
serverSettings!: ServerSettings;
|
||||
serverInfo!: ServerInfo;
|
||||
|
||||
clearCacheInProgress: boolean = false;
|
||||
backupDBInProgress: boolean = false;
|
||||
isCheckingForUpdate: boolean = false;
|
||||
downloadLogsInProgress: boolean = false;
|
||||
|
||||
constructor(private settingsService: SettingsService, private toastr: ToastrService,
|
||||
private serverService: ServerService, public downloadService: DownloadService,
|
||||
private modalService: NgbModal) { }
|
||||
private serverService: ServerService) { }
|
||||
|
||||
ngOnInit(): void {
|
||||
|
||||
@ -67,45 +59,4 @@ export class ManageSystemComponent implements OnInit {
|
||||
console.error('error: ', err);
|
||||
});
|
||||
}
|
||||
|
||||
clearCache() {
|
||||
this.clearCacheInProgress = true;
|
||||
this.serverService.clearCache().subscribe(res => {
|
||||
this.clearCacheInProgress = false;
|
||||
this.toastr.success('Cache has been cleared');
|
||||
});
|
||||
}
|
||||
|
||||
backupDB() {
|
||||
this.backupDBInProgress = true;
|
||||
this.serverService.backupDatabase().subscribe(res => {
|
||||
this.backupDBInProgress = false;
|
||||
this.toastr.success('Database has been backed up');
|
||||
});
|
||||
}
|
||||
|
||||
checkForUpdates() {
|
||||
this.isCheckingForUpdate = true;
|
||||
this.serverService.checkForUpdate().subscribe((update) => {
|
||||
this.isCheckingForUpdate = false;
|
||||
if (update === null) {
|
||||
this.toastr.info('No updates available');
|
||||
return;
|
||||
}
|
||||
const modalRef = this.modalService.open(UpdateNotificationModalComponent, { scrollable: true, size: 'lg' });
|
||||
modalRef.componentInstance.updateData = update;
|
||||
});
|
||||
}
|
||||
|
||||
downloadLogs() {
|
||||
this.downloadLogsInProgress = true;
|
||||
this.downloadService.downloadLogs().pipe(
|
||||
takeWhile(val => {
|
||||
return val.state != 'DONE';
|
||||
}),
|
||||
finalize(() => {
|
||||
this.downloadLogsInProgress = false;
|
||||
})).subscribe(() => {/* No Operation */});
|
||||
}
|
||||
|
||||
}
|
||||
|
@ -0,0 +1,73 @@
|
||||
<div class="container-fluid">
|
||||
<form [formGroup]="settingsForm" *ngIf="serverSettings !== undefined">
|
||||
<h4>Reoccuring Tasks</h4>
|
||||
<div class="mb-3">
|
||||
<label for="settings-tasks-scan" class="form-label">Library Scan</label> <i class="fa fa-info-circle" placement="right" [ngbTooltip]="taskScanTooltip" role="button" tabindex="0"></i>
|
||||
<ng-template #taskScanTooltip>How often Kavita will scan and refresh metadata around manga files.</ng-template>
|
||||
<span class="visually-hidden" id="settings-tasks-scan-help">How often Kavita will scan and refresh metatdata around manga files.</span>
|
||||
<select class="form-select" aria-describedby="settings-tasks-scan-help" formControlName="taskScan" id="settings-tasks-scan">
|
||||
<option *ngFor="let freq of taskFrequencies" [value]="freq">{{freq | titlecase}}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="settings-tasks-backup" class="form-label">Library Database Backup</label> <i class="fa fa-info-circle" placement="right" [ngbTooltip]="taskBackupTooltip" role="button" tabindex="0"></i>
|
||||
<ng-template #taskBackupTooltip>How often Kavita will backup the database.</ng-template>
|
||||
<span class="visually-hidden" id="settings-tasks-backup-help">How often Kavita will backup the database.</span>
|
||||
<select class="form-select" aria-describedby="settings-tasks-backup-help" formControlName="taskBackup" id="settings-tasks-backup">
|
||||
<option *ngFor="let freq of taskFrequencies" [value]="freq">{{freq | titlecase}}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<h4>Ad-hoc Tasks</h4>
|
||||
<table class="table table-striped">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Job Title</th>
|
||||
<th scope="col">Description</th>
|
||||
<th scope="col">Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr *ngFor="let task of adhocTasks; let idx = index;">
|
||||
<td id="adhoctask--{{idx}}">
|
||||
{{task.name}}
|
||||
</td>
|
||||
<td>
|
||||
{{task.description}}
|
||||
</td>
|
||||
<td>
|
||||
<button class="btn btn-primary" (click)="runAdhoc(task)" attr.aria-labelledby="adhoctask--{{idx}}">Run</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<h4>Reoccuring Tasks</h4>
|
||||
<table class="table table-striped">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Job Title</th>
|
||||
<th scope="col">Last Executed</th>
|
||||
<th scope="col">Cron</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr *ngFor="let task of reoccuringTasks$ | async; index as i">
|
||||
<td>
|
||||
{{task.title | titlecase}}
|
||||
</td>
|
||||
<td>{{task.lastExecution | date:'short' | defaultValue }}</td>
|
||||
<td>{{task.cron}}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
|
||||
<div class="col-auto d-flex d-md-block justify-content-sm-center text-md-end">
|
||||
<button type="button" class="flex-fill btn btn-secondary me-2" (click)="resetToDefaults()">Reset to Default</button>
|
||||
<button type="button" class="flex-fill btn btn-secondary me-2" (click)="resetForm()">Reset</button>
|
||||
<button type="submit" class="flex-fill btn btn-primary" (click)="saveSettings()" [disabled]="!settingsForm.touched && !settingsForm.dirty">Save</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
@ -0,0 +1,3 @@
|
||||
.table {
|
||||
background-color: lightgrey;
|
||||
}
|
@ -0,0 +1,151 @@
|
||||
import { Component, OnInit } from '@angular/core';
|
||||
import { FormGroup, FormControl, Validators } from '@angular/forms';
|
||||
import { ToastrService } from 'ngx-toastr';
|
||||
import { ConfirmService } from 'src/app/shared/confirm.service';
|
||||
import { SettingsService } from '../settings.service';
|
||||
import { ServerSettings } from '../_models/server-settings';
|
||||
import { catchError, finalize, shareReplay, take, takeWhile } from 'rxjs/operators';
|
||||
import { forkJoin, Observable, of } from 'rxjs';
|
||||
import { ServerService } from 'src/app/_services/server.service';
|
||||
import { Job } from 'src/app/_models/job/job';
|
||||
import { UpdateNotificationModalComponent } from 'src/app/shared/update-notification/update-notification-modal.component';
|
||||
import { NgbModal } from '@ng-bootstrap/ng-bootstrap';
|
||||
import { DownloadService } from 'src/app/shared/_services/download.service';
|
||||
|
||||
interface AdhocTask {
|
||||
name: string;
|
||||
description: string;
|
||||
api: Observable<any>;
|
||||
successMessage: string;
|
||||
successFunction?: (data: any) => void;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-manage-tasks-settings',
|
||||
templateUrl: './manage-tasks-settings.component.html',
|
||||
styleUrls: ['./manage-tasks-settings.component.scss']
|
||||
})
|
||||
export class ManageTasksSettingsComponent implements OnInit {
|
||||
|
||||
serverSettings!: ServerSettings;
|
||||
settingsForm: FormGroup = new FormGroup({});
|
||||
taskFrequencies: Array<string> = [];
|
||||
logLevels: Array<string> = [];
|
||||
|
||||
reoccuringTasks$: Observable<Array<Job>> = of([]);
|
||||
adhocTasks: Array<AdhocTask> = [
|
||||
{
|
||||
name: 'Convert Bookmarks to WebP',
|
||||
description: 'Runs a long-running task which will convert all bookmarks to WebP. This is slow (especially on ARM devices).',
|
||||
api: this.serverService.convertBookmarks(),
|
||||
successMessage: 'Conversion of Bookmarks has been queued'
|
||||
},
|
||||
{
|
||||
name: 'Clear Cache',
|
||||
description: 'Clears cached files for reading. Usefull when you\'ve just updated a file that you were previously reading within last 24 hours.',
|
||||
api: this.serverService.clearCache(),
|
||||
successMessage: 'Cache has been cleared'
|
||||
},
|
||||
{
|
||||
name: 'Backup Database',
|
||||
description: 'Takes a backup of the database, bookmarks, themes, manually uploaded covers, and config files',
|
||||
api: this.serverService.backupDatabase(),
|
||||
successMessage: 'A job to backup the database has been queued'
|
||||
},
|
||||
{
|
||||
name: 'Download Logs',
|
||||
description: 'Compiles all log files into a zip and downloads it',
|
||||
api: this.downloadService.downloadLogs().pipe(
|
||||
takeWhile(val => {
|
||||
return val.state != 'DONE';
|
||||
})),
|
||||
successMessage: ''
|
||||
},
|
||||
{
|
||||
name: 'Check for Updates',
|
||||
description: 'See if there are any Stable releases ahead of your version',
|
||||
api: this.serverService.checkForUpdate(),
|
||||
successMessage: '',
|
||||
successFunction: (update) => {
|
||||
if (update === null) {
|
||||
this.toastr.info('No updates available');
|
||||
return;
|
||||
}
|
||||
const modalRef = this.modalService.open(UpdateNotificationModalComponent, { scrollable: true, size: 'lg' });
|
||||
modalRef.componentInstance.updateData = update;
|
||||
}
|
||||
},
|
||||
];
|
||||
|
||||
constructor(private settingsService: SettingsService, private toastr: ToastrService,
|
||||
private serverService: ServerService, private modalService: NgbModal,
|
||||
private downloadService: DownloadService) { }
|
||||
|
||||
ngOnInit(): void {
|
||||
forkJoin({
|
||||
frequencies: this.settingsService.getTaskFrequencies(),
|
||||
levels: this.settingsService.getLoggingLevels(),
|
||||
settings: this.settingsService.getServerSettings()
|
||||
}
|
||||
|
||||
).subscribe(result => {
|
||||
this.taskFrequencies = result.frequencies;
|
||||
this.logLevels = result.levels;
|
||||
this.serverSettings = result.settings;
|
||||
this.settingsForm.addControl('taskScan', new FormControl(this.serverSettings.taskScan, [Validators.required]));
|
||||
this.settingsForm.addControl('taskBackup', new FormControl(this.serverSettings.taskBackup, [Validators.required]));
|
||||
});
|
||||
|
||||
this.reoccuringTasks$ = this.serverService.getReoccuringJobs().pipe(shareReplay());
|
||||
}
|
||||
|
||||
resetForm() {
|
||||
this.settingsForm.get('taskScan')?.setValue(this.serverSettings.taskScan);
|
||||
this.settingsForm.get('taskBackup')?.setValue(this.serverSettings.taskBackup);
|
||||
}
|
||||
|
||||
async saveSettings() {
|
||||
const modelSettings = Object.assign({}, this.serverSettings);
|
||||
modelSettings.taskBackup = this.settingsForm.get('taskBackup')?.value;
|
||||
modelSettings.taskScan = this.settingsForm.get('taskScan')?.value;
|
||||
|
||||
this.settingsService.updateServerSettings(modelSettings).pipe(take(1)).subscribe(async (settings: ServerSettings) => {
|
||||
this.serverSettings = settings;
|
||||
this.resetForm();
|
||||
this.reoccuringTasks$ = this.serverService.getReoccuringJobs().pipe(shareReplay());
|
||||
this.toastr.success('Server settings updated');
|
||||
}, (err: any) => {
|
||||
console.error('error: ', err);
|
||||
});
|
||||
}
|
||||
|
||||
resetToDefaults() {
|
||||
this.settingsService.resetServerSettings().pipe(take(1)).subscribe(async (settings: ServerSettings) => {
|
||||
this.serverSettings = settings;
|
||||
this.resetForm();
|
||||
this.toastr.success('Server settings updated');
|
||||
}, (err: any) => {
|
||||
console.error('error: ', err);
|
||||
});
|
||||
}
|
||||
|
||||
runAdhocConvert() {
|
||||
this.serverService.convertBookmarks().subscribe(() => {
|
||||
this.toastr.success('Conversion of Bookmarks has been queued.');
|
||||
});
|
||||
}
|
||||
|
||||
runAdhoc(task: AdhocTask) {
|
||||
task.api.subscribe((data: any) => {
|
||||
if (task.successMessage.length > 0) {
|
||||
this.toastr.success(task.successMessage);
|
||||
}
|
||||
|
||||
if (task.successFunction) {
|
||||
task.successFunction(data);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
}
|
@ -39,11 +39,10 @@
|
||||
<div class="card col-auto mt-2 mb-2" *ngFor="let item of items; trackBy:trackByIdentity; index as i">
|
||||
<ng-container [ngTemplateOutlet]="itemTemplate" [ngTemplateOutletContext]="{ $implicit: item, idx: i }"></ng-container>
|
||||
</div>
|
||||
|
||||
<p *ngIf="items.length === 0 && !isLoading">
|
||||
<ng-container [ngTemplateOutlet]="noDataTemplate"></ng-container>
|
||||
</p>
|
||||
</div>
|
||||
<p *ngIf="items.length === 0 && !isLoading">
|
||||
<ng-container [ngTemplateOutlet]="noDataTemplate"></ng-container>
|
||||
</p>
|
||||
</ng-template>
|
||||
|
||||
<ng-template #paginationTemplate let-id="id">
|
||||
|
13
UI/Web/src/app/pipe/default-value.pipe.ts
Normal file
13
UI/Web/src/app/pipe/default-value.pipe.ts
Normal file
@ -0,0 +1,13 @@
|
||||
import { Pipe, PipeTransform } from '@angular/core';
|
||||
|
||||
@Pipe({
|
||||
name: 'defaultValue'
|
||||
})
|
||||
export class DefaultValuePipe implements PipeTransform {
|
||||
|
||||
transform(value: any): string {
|
||||
if (value === null || value === undefined || value === '' || value === Infinity || value === NaN || value === {}) return '—';
|
||||
return value;
|
||||
}
|
||||
|
||||
}
|
@ -6,6 +6,7 @@ import { SentenceCasePipe } from './sentence-case.pipe';
|
||||
import { PersonRolePipe } from './person-role.pipe';
|
||||
import { SafeHtmlPipe } from './safe-html.pipe';
|
||||
import { RelationshipPipe } from './relationship.pipe';
|
||||
import { DefaultValuePipe } from './default-value.pipe';
|
||||
|
||||
|
||||
|
||||
@ -16,7 +17,8 @@ import { RelationshipPipe } from './relationship.pipe';
|
||||
PublicationStatusPipe,
|
||||
SentenceCasePipe,
|
||||
SafeHtmlPipe,
|
||||
RelationshipPipe
|
||||
RelationshipPipe,
|
||||
DefaultValuePipe
|
||||
],
|
||||
imports: [
|
||||
CommonModule,
|
||||
@ -27,7 +29,8 @@ import { RelationshipPipe } from './relationship.pipe';
|
||||
PublicationStatusPipe,
|
||||
SentenceCasePipe,
|
||||
SafeHtmlPipe,
|
||||
RelationshipPipe
|
||||
RelationshipPipe,
|
||||
DefaultValuePipe
|
||||
]
|
||||
})
|
||||
export class PipeModule { }
|
||||
|
@ -227,7 +227,8 @@
|
||||
</form>
|
||||
</ng-container>
|
||||
<ng-container *ngIf="tab.fragment === 'password'">
|
||||
<ng-container *ngIf="(isAdmin || hasChangePasswordRole); else noPermission">
|
||||
|
||||
<ng-container *ngIf="(hasChangePasswordAbility | async); else noPermission">
|
||||
<p>Change your Password</p>
|
||||
<div class="alert alert-danger" role="alert" *ngIf="resetPasswordErrors.length > 0">
|
||||
<div *ngFor="let error of resetPasswordErrors">{{error}}</div>
|
||||
|
@ -1,7 +1,7 @@
|
||||
import { Component, OnDestroy, OnInit } from '@angular/core';
|
||||
import { FormControl, FormGroup, Validators } from '@angular/forms';
|
||||
import { ToastrService } from 'ngx-toastr';
|
||||
import { take, takeUntil } from 'rxjs/operators';
|
||||
import { map, shareReplay, take, takeUntil } from 'rxjs/operators';
|
||||
import { Title } from '@angular/platform-browser';
|
||||
import { BookService } from 'src/app/book-reader/book.service';
|
||||
import { readingDirections, scalingOptions, pageSplitOptions, readingModes, Preferences, bookLayoutModes, layoutModes } from 'src/app/_models/preferences/preferences';
|
||||
@ -11,7 +11,7 @@ import { ActivatedRoute, Router } from '@angular/router';
|
||||
import { SettingsService } from 'src/app/admin/settings.service';
|
||||
import { bookColorThemes } from 'src/app/book-reader/reader-settings/reader-settings.component';
|
||||
import { BookPageLayoutMode } from 'src/app/_models/book-page-layout-mode';
|
||||
import { forkJoin, Subject } from 'rxjs';
|
||||
import { forkJoin, Observable, of, Subject } from 'rxjs';
|
||||
|
||||
enum AccordionPanelID {
|
||||
ImageReader = 'image-reader',
|
||||
@ -36,8 +36,7 @@ export class UserPreferencesComponent implements OnInit, OnDestroy {
|
||||
settingsForm: FormGroup = new FormGroup({});
|
||||
passwordChangeForm: FormGroup = new FormGroup({});
|
||||
user: User | undefined = undefined;
|
||||
isAdmin: boolean = false;
|
||||
hasChangePasswordRole: boolean = false;
|
||||
hasChangePasswordAbility: Observable<boolean> = of(false);
|
||||
|
||||
passwordsMatch = false;
|
||||
resetPasswordErrors: string[] = [];
|
||||
@ -83,6 +82,10 @@ export class UserPreferencesComponent implements OnInit, OnDestroy {
|
||||
ngOnInit(): void {
|
||||
this.titleService.setTitle('Kavita - User Preferences');
|
||||
|
||||
this.hasChangePasswordAbility = this.accountService.currentUser$.pipe(takeUntil(this.onDestroy), shareReplay(), map(user => {
|
||||
return user !== undefined && (this.accountService.hasAdminRole(user) || this.accountService.hasChangePasswordRole(user));
|
||||
}));
|
||||
|
||||
forkJoin({
|
||||
user: this.accountService.currentUser$.pipe(take(1)),
|
||||
pref: this.accountService.getPreferences()
|
||||
@ -94,8 +97,6 @@ export class UserPreferencesComponent implements OnInit, OnDestroy {
|
||||
|
||||
this.user = results.user;
|
||||
this.user.preferences = results.pref;
|
||||
this.isAdmin = this.accountService.hasAdminRole(results.user);
|
||||
this.hasChangePasswordRole = this.accountService.hasChangePasswordRole(results.user);
|
||||
|
||||
if (this.fontFamilies.indexOf(this.user.preferences.bookReaderFontFamily) < 0) {
|
||||
this.user.preferences.bookReaderFontFamily = 'default';
|
||||
|
Loading…
x
Reference in New Issue
Block a user