Theme Viewer + Theme Updater (#2952)

This commit is contained in:
Joe Milazzo 2024-05-13 17:00:13 -05:00 committed by GitHub
parent 24302d4fcc
commit 38e7c1c131
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
35 changed files with 4563 additions and 284 deletions

View File

@ -9,6 +9,7 @@ using API.Services;
using API.Services.Tasks;
using API.SignalR;
using Kavita.Common;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging;
using NSubstitute;
using Xunit;
@ -44,13 +45,14 @@ public abstract class SiteThemeServiceTest : AbstractDbTest
var filesystem = CreateFileSystem();
filesystem.AddFile($"{SiteThemeDirectory}custom.css", new MockFileData("123"));
var ds = new DirectoryService(Substitute.For<ILogger<DirectoryService>>(), filesystem);
var siteThemeService = new ThemeService(ds, _unitOfWork, _messageHub);
var siteThemeService = new ThemeService(ds, _unitOfWork, _messageHub, Substitute.For<IFileService>(),
Substitute.For<ILogger<ThemeService>>(), Substitute.For<IMemoryCache>());
_context.SiteTheme.Add(new SiteTheme()
{
Name = "Custom",
NormalizedName = "Custom".ToNormalized(),
Provider = ThemeProvider.User,
Provider = ThemeProvider.Custom,
FileName = "custom.css",
IsDefault = false
});
@ -61,63 +63,6 @@ public abstract class SiteThemeServiceTest : AbstractDbTest
}
[Fact]
public async Task Scan_ShouldFindCustomFile()
{
await ResetDb();
_testOutputHelper.WriteLine($"[Scan_ShouldOnlyInsertOnceOnSecondScan] All Themes: {(await _unitOfWork.SiteThemeRepository.GetThemes()).Count(t => t.IsDefault)}");
var filesystem = CreateFileSystem();
filesystem.AddFile($"{SiteThemeDirectory}custom.css", new MockFileData(""));
var ds = new DirectoryService(Substitute.For<ILogger<DirectoryService>>(), filesystem);
var siteThemeService = new ThemeService(ds, _unitOfWork, _messageHub);
await siteThemeService.Scan();
Assert.NotNull(await _unitOfWork.SiteThemeRepository.GetThemeDtoByName("custom"));
}
[Fact]
public async Task Scan_ShouldOnlyInsertOnceOnSecondScan()
{
await ResetDb();
_testOutputHelper.WriteLine(
$"[Scan_ShouldOnlyInsertOnceOnSecondScan] All Themes: {(await _unitOfWork.SiteThemeRepository.GetThemes()).Count(t => t.IsDefault)}");
var filesystem = CreateFileSystem();
filesystem.AddFile($"{SiteThemeDirectory}custom.css", new MockFileData(""));
var ds = new DirectoryService(Substitute.For<ILogger<DirectoryService>>(), filesystem);
var siteThemeService = new ThemeService(ds, _unitOfWork, _messageHub);
await siteThemeService.Scan();
Assert.NotNull(await _unitOfWork.SiteThemeRepository.GetThemeDtoByName("custom"));
await siteThemeService.Scan();
var customThemes = (await _unitOfWork.SiteThemeRepository.GetThemeDtos()).Where(t =>
t.Name.ToNormalized().Equals("custom".ToNormalized()));
Assert.Single(customThemes);
}
[Fact]
public async Task Scan_ShouldDeleteWhenFileDoesntExistOnSecondScan()
{
await ResetDb();
_testOutputHelper.WriteLine($"[Scan_ShouldDeleteWhenFileDoesntExistOnSecondScan] All Themes: {(await _unitOfWork.SiteThemeRepository.GetThemes()).Count(t => t.IsDefault)}");
var filesystem = CreateFileSystem();
filesystem.AddFile($"{SiteThemeDirectory}custom.css", new MockFileData(""));
var ds = new DirectoryService(Substitute.For<ILogger<DirectoryService>>(), filesystem);
var siteThemeService = new ThemeService(ds, _unitOfWork, _messageHub);
await siteThemeService.Scan();
Assert.NotNull(await _unitOfWork.SiteThemeRepository.GetThemeDtoByName("custom"));
filesystem.RemoveFile($"{SiteThemeDirectory}custom.css");
await siteThemeService.Scan();
var themes = (await _unitOfWork.SiteThemeRepository.GetThemeDtos());
Assert.Equal(0, themes.Count(t =>
t.Name.ToNormalized().Equals("custom".ToNormalized())));
}
[Fact]
public async Task GetContent_ShouldReturnContent()
@ -127,13 +72,14 @@ public abstract class SiteThemeServiceTest : AbstractDbTest
var filesystem = CreateFileSystem();
filesystem.AddFile($"{SiteThemeDirectory}custom.css", new MockFileData("123"));
var ds = new DirectoryService(Substitute.For<ILogger<DirectoryService>>(), filesystem);
var siteThemeService = new ThemeService(ds, _unitOfWork, _messageHub);
var siteThemeService = new ThemeService(ds, _unitOfWork, _messageHub, Substitute.For<IFileService>(),
Substitute.For<ILogger<ThemeService>>(), Substitute.For<IMemoryCache>());
_context.SiteTheme.Add(new SiteTheme()
{
Name = "Custom",
NormalizedName = "Custom".ToNormalized(),
Provider = ThemeProvider.User,
Provider = ThemeProvider.Custom,
FileName = "custom.css",
IsDefault = false
});
@ -153,13 +99,14 @@ public abstract class SiteThemeServiceTest : AbstractDbTest
var filesystem = CreateFileSystem();
filesystem.AddFile($"{SiteThemeDirectory}custom.css", new MockFileData("123"));
var ds = new DirectoryService(Substitute.For<ILogger<DirectoryService>>(), filesystem);
var siteThemeService = new ThemeService(ds, _unitOfWork, _messageHub);
var siteThemeService = new ThemeService(ds, _unitOfWork, _messageHub, Substitute.For<IFileService>(),
Substitute.For<ILogger<ThemeService>>(), Substitute.For<IMemoryCache>());
_context.SiteTheme.Add(new SiteTheme()
{
Name = "Custom",
NormalizedName = "Custom".ToNormalized(),
Provider = ThemeProvider.User,
Provider = ThemeProvider.Custom,
FileName = "custom.css",
IsDefault = false
});

View File

@ -1,13 +1,21 @@
using System.Collections.Generic;
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using API.Constants;
using API.Data;
using API.DTOs.Theme;
using API.Entities;
using API.Extensions;
using API.Services;
using API.Services.Tasks;
using AutoMapper;
using Kavita.Common;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Caching.Memory;
namespace API.Controllers;
@ -17,16 +25,19 @@ public class ThemeController : BaseApiController
{
private readonly IUnitOfWork _unitOfWork;
private readonly IThemeService _themeService;
private readonly ITaskScheduler _taskScheduler;
private readonly ILocalizationService _localizationService;
private readonly IDirectoryService _directoryService;
private readonly IMapper _mapper;
public ThemeController(IUnitOfWork unitOfWork, IThemeService themeService, ITaskScheduler taskScheduler,
ILocalizationService localizationService)
public ThemeController(IUnitOfWork unitOfWork, IThemeService themeService,
ILocalizationService localizationService, IDirectoryService directoryService, IMapper mapper)
{
_unitOfWork = unitOfWork;
_themeService = themeService;
_taskScheduler = taskScheduler;
_localizationService = localizationService;
_directoryService = directoryService;
_mapper = mapper;
}
[ResponseCache(CacheProfileName = "10Minute")]
@ -37,13 +48,6 @@ public class ThemeController : BaseApiController
return Ok(await _unitOfWork.SiteThemeRepository.GetThemeDtos());
}
[Authorize("RequireAdminRole")]
[HttpPost("scan")]
public ActionResult Scan()
{
_taskScheduler.ScanSiteThemes();
return Ok();
}
[Authorize("RequireAdminRole")]
[HttpPost("update-default")]
@ -78,4 +82,68 @@ public class ThemeController : BaseApiController
return BadRequest(await _localizationService.Get("en", ex.Message));
}
}
/// <summary>
/// Browse themes that can be used on this server
/// </summary>
/// <returns></returns>
[ResponseCache(CacheProfileName = ResponseCacheProfiles.Hour)]
[HttpGet("browse")]
public async Task<ActionResult<IEnumerable<DownloadableSiteThemeDto>>> BrowseThemes()
{
var themes = await _themeService.GetDownloadableThemes();
return Ok(themes.Where(t => !t.AlreadyDownloaded));
}
/// <summary>
/// Attempts to delete a theme. If already in use by users, will not allow
/// </summary>
/// <param name="themeId"></param>
/// <returns></returns>
[HttpDelete]
public async Task<ActionResult<IEnumerable<DownloadableSiteThemeDto>>> DeleteTheme(int themeId)
{
await _themeService.DeleteTheme(themeId);
return Ok();
}
/// <summary>
/// Downloads a SiteTheme from upstream
/// </summary>
/// <param name="dto"></param>
/// <returns></returns>
[HttpPost("download-theme")]
public async Task<ActionResult<SiteThemeDto>> DownloadTheme(DownloadableSiteThemeDto dto)
{
return Ok(_mapper.Map<SiteThemeDto>(await _themeService.DownloadRepoTheme(dto)));
}
/// <summary>
/// Uploads a new theme file
/// </summary>
/// <param name="formFile"></param>
/// <returns></returns>
[HttpPost("upload-theme")]
public async Task<ActionResult<SiteThemeDto>> DownloadTheme(IFormFile formFile)
{
if (!formFile.FileName.EndsWith(".css")) return BadRequest("Invalid file");
if (formFile.FileName.Contains("..")) return BadRequest("Invalid file");
var tempFile = await UploadToTemp(formFile);
// Set summary as "Uploaded by User.GetUsername() on DATE"
var theme = await _themeService.CreateThemeFromFile(tempFile, User.GetUsername());
return Ok(_mapper.Map<SiteThemeDto>(theme));
}
private async Task<string> UploadToTemp(IFormFile file)
{
var outputFile = Path.Join(_directoryService.TempDirectory, file.FileName);
await using var stream = System.IO.File.Create(outputFile);
await file.CopyToAsync(stream);
stream.Close();
return outputFile;
}
}

View File

@ -391,4 +391,6 @@ public class UploadController : BaseApiController
return BadRequest(await _localizationService.Translate(User.GetUserId(), "reset-chapter-lock"));
}
}

View File

@ -122,9 +122,10 @@ public class UsersController : BaseApiController
existingPreferences.PdfScrollMode = preferencesDto.PdfScrollMode;
existingPreferences.PdfSpreadMode = preferencesDto.PdfSpreadMode;
if (existingPreferences.Theme.Id != preferencesDto.Theme?.Id)
if (preferencesDto.Theme != null && existingPreferences.Theme.Id != preferencesDto.Theme?.Id)
{
existingPreferences.Theme = preferencesDto.Theme ?? await _unitOfWork.SiteThemeRepository.GetDefaultTheme();
var theme = await _unitOfWork.SiteThemeRepository.GetTheme(preferencesDto.Theme!.Id);
existingPreferences.Theme = theme ?? await _unitOfWork.SiteThemeRepository.GetDefaultTheme();
}

View File

@ -0,0 +1,52 @@
using System;
using System.Collections.Generic;
namespace API.DTOs.Theme;
public class DownloadableSiteThemeDto
{
/// <summary>
/// Theme Name
/// </summary>
public string Name { get; set; }
/// <summary>
/// Url to download css file
/// </summary>
public string CssUrl { get; set; }
public string CssFile { get; set; }
/// <summary>
/// Url to preview image
/// </summary>
public IList<string> PreviewUrls { get; set; }
/// <summary>
/// If Already downloaded
/// </summary>
public bool AlreadyDownloaded { get; set; }
/// <summary>
/// Sha of the file
/// </summary>
public string Sha { get; set; }
/// <summary>
/// Path of the Folder the files reside in
/// </summary>
public string Path { get; set; }
/// <summary>
/// Author of the theme
/// </summary>
/// <remarks>Derived from Readme</remarks>
public string Author { get; set; }
/// <summary>
/// Last version tested against
/// </summary>
/// <remarks>Derived from Readme</remarks>
public string LastCompatibleVersion { get; set; }
/// <summary>
/// If version compatible with version
/// </summary>
public bool IsCompatible { get; set; }
/// <summary>
/// Small blurb about the Theme
/// </summary>
public string Description { get; set; }
}

View File

@ -1,3 +1,4 @@
using System.Collections.Generic;
using API.Entities.Enums.Theme;
using API.Services;
@ -30,5 +31,21 @@ public class SiteThemeDto
/// Where did the theme come from
/// </summary>
public ThemeProvider Provider { get; set; }
public IList<string> PreviewUrls { get; set; }
/// <summary>
/// Information about the theme
/// </summary>
public string Description { get; set; }
/// <summary>
/// Author of the Theme (only applies to non-system provided themes)
/// </summary>
public string Author { get; set; }
/// <summary>
/// Last compatible version. System provided will always be most current
/// </summary>
public string CompatibleVersion { get; set; }
public string Selector => "bg-" + Name.ToLower();
}

View File

@ -1,4 +1,5 @@
using System.ComponentModel.DataAnnotations;
using API.DTOs.Theme;
using API.Entities;
using API.Entities.Enums;
using API.Entities.Enums.UserPreferences;
@ -104,7 +105,7 @@ public class UserPreferencesDto
/// </summary>
/// <remarks>Should default to Dark</remarks>
[Required]
public SiteTheme? Theme { get; set; }
public SiteThemeDto? Theme { get; set; }
[Required] public string BookReaderThemeName { get; set; } = null!;
[Required]

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,78 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace API.Data.Migrations
{
/// <inheritdoc />
public partial class SiteThemeFields : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "Author",
table: "SiteTheme",
type: "TEXT",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "CompatibleVersion",
table: "SiteTheme",
type: "TEXT",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "Description",
table: "SiteTheme",
type: "TEXT",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "GitHubPath",
table: "SiteTheme",
type: "TEXT",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "PreviewUrls",
table: "SiteTheme",
type: "TEXT",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "ShaHash",
table: "SiteTheme",
type: "TEXT",
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "Author",
table: "SiteTheme");
migrationBuilder.DropColumn(
name: "CompatibleVersion",
table: "SiteTheme");
migrationBuilder.DropColumn(
name: "Description",
table: "SiteTheme");
migrationBuilder.DropColumn(
name: "GitHubPath",
table: "SiteTheme");
migrationBuilder.DropColumn(
name: "PreviewUrls",
table: "SiteTheme");
migrationBuilder.DropColumn(
name: "ShaHash",
table: "SiteTheme");
}
}
}

View File

@ -1871,15 +1871,27 @@ namespace API.Data.Migrations
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("Author")
.HasColumnType("TEXT");
b.Property<string>("CompatibleVersion")
.HasColumnType("TEXT");
b.Property<DateTime>("Created")
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedUtc")
.HasColumnType("TEXT");
b.Property<string>("Description")
.HasColumnType("TEXT");
b.Property<string>("FileName")
.HasColumnType("TEXT");
b.Property<string>("GitHubPath")
.HasColumnType("TEXT");
b.Property<bool>("IsDefault")
.HasColumnType("INTEGER");
@ -1895,9 +1907,15 @@ namespace API.Data.Migrations
b.Property<string>("NormalizedName")
.HasColumnType("TEXT");
b.Property<string>("PreviewUrls")
.HasColumnType("TEXT");
b.Property<int>("Provider")
.HasColumnType("INTEGER");
b.Property<string>("ShaHash")
.HasColumnType("TEXT");
b.HasKey("Id");
b.ToTable("SiteTheme");

View File

@ -19,6 +19,8 @@ public interface ISiteThemeRepository
Task<SiteThemeDto?> GetThemeDtoByName(string themeName);
Task<SiteTheme> GetDefaultTheme();
Task<IEnumerable<SiteTheme>> GetThemes();
Task<SiteTheme?> GetTheme(int themeId);
Task<bool> IsThemeInUse(int themeId);
}
public class SiteThemeRepository : ISiteThemeRepository
@ -88,6 +90,19 @@ public class SiteThemeRepository : ISiteThemeRepository
.ToListAsync();
}
public async Task<SiteTheme> GetTheme(int themeId)
{
return await _context.SiteTheme
.Where(t => t.Id == themeId)
.FirstOrDefaultAsync();
}
public async Task<bool> IsThemeInUse(int themeId)
{
return await _context.AppUserPreferences
.AnyAsync(p => p.Theme.Id == themeId);
}
public async Task<SiteThemeDto?> GetThemeDto(int themeId)
{
return await _context.SiteTheme

View File

@ -25,8 +25,8 @@ public static class Seed
/// </summary>
public static ImmutableArray<ServerSetting> DefaultSettings;
public static readonly ImmutableArray<SiteTheme> DefaultThemes = ImmutableArray.Create(
new List<SiteTheme>
public static readonly ImmutableArray<SiteTheme> DefaultThemes = [
..new List<SiteTheme>
{
new()
{
@ -36,7 +36,8 @@ public static class Seed
FileName = "dark.scss",
IsDefault = true,
}
}.ToArray());
}.ToArray()
];
public static readonly ImmutableArray<AppUserDashboardStream> DefaultStreams = ImmutableArray.Create(
new List<AppUserDashboardStream>

View File

@ -10,8 +10,8 @@ public enum ThemeProvider
[Description("System")]
System = 1,
/// <summary>
/// Theme is provided by the User (ie it's custom)
/// Theme is provided by the User (ie it's custom) or Downloaded via Themes Repo
/// </summary>
[Description("User")]
User = 2
[Description("Custom")]
Custom = 2,
}

View File

@ -37,4 +37,30 @@ public class SiteTheme : IEntityDate, ITheme
public DateTime LastModified { get; set; }
public DateTime CreatedUtc { get; set; }
public DateTime LastModifiedUtc { get; set; }
#region ThemeBrowser
/// <summary>
/// The Url on the repo to download the file from
/// </summary>
public string? GitHubPath { get; set; }
/// <summary>
/// Hash of the Css File
/// </summary>
public string? ShaHash { get; set; }
/// <summary>
/// Pipe (|) separated urls of the images. Empty string if
/// </summary>
public string PreviewUrls { get; set; }
// /// <summary>
// /// A description about the theme
// /// </summary>
public string Description { get; set; }
// /// <summary>
// /// Author of the Theme
// /// </summary>
public string Author { get; set; }
public string CompatibleVersion { get; set; }
#endregion
}

View File

@ -1,4 +1,5 @@
using System.Collections.Generic;
using System;
using System.Collections.Generic;
using System.Linq;
using API.Data.Migrations;
using API.DTOs;
@ -241,7 +242,10 @@ public class AutoMapperProfiles : Profile
IncludeUnknowns = src.AgeRestrictionIncludeUnknowns
}));
CreateMap<SiteTheme, SiteThemeDto>();
CreateMap<SiteTheme, SiteThemeDto>()
.ForMember(dest => dest.PreviewUrls,
opt =>
opt.MapFrom(src => (src.PreviewUrls ?? string.Empty).Split('|', StringSplitOptions.TrimEntries)));
CreateMap<AppUserPreferences, UserPreferencesDto>()
.ForMember(dest => dest.Theme,
opt =>

View File

@ -1,5 +1,10 @@
using System;
using System.IO;
using System.IO.Abstractions;
using System.Runtime.Intrinsics.Arm;
using System.Security.Cryptography;
using System.Text;
using System.Text.Unicode;
using API.Extensions;
namespace API.Services;
@ -9,6 +14,7 @@ public interface IFileService
IFileSystem GetFileSystem();
bool HasFileBeenModifiedSince(string filePath, DateTime time);
bool Exists(string filePath);
bool ValidateSha(string filepath, string sha);
}
public class FileService : IFileService
@ -43,4 +49,28 @@ public class FileService : IFileService
{
return _fileSystem.File.Exists(filePath);
}
/// <summary>
/// Validates the Sha256 hash matches
/// </summary>
/// <param name="filepath"></param>
/// <param name="sha"></param>
/// <returns></returns>
public bool ValidateSha(string filepath, string sha)
{
if (!Exists(filepath)) return false;
if (string.IsNullOrEmpty(sha)) throw new ArgumentException("Sha cannot be null");
using var fs = _fileSystem.File.OpenRead(filepath);
fs.Position = 0;
using var reader = new StreamReader(fs, Encoding.UTF8, detectEncodingFromByteOrderMarks: true);
var content = reader.ReadToEnd();
// Compute SHA hash
var checksum = SHA256.HashData(Encoding.UTF8.GetBytes(content));
return BitConverter.ToString(checksum).Replace("-", string.Empty).Equals(sha);
}
}

View File

@ -32,7 +32,6 @@ public interface ITaskScheduler
void AnalyzeFilesForLibrary(int libraryId, bool forceUpdate = false);
void CancelStatsTasks();
Task RunStatCollection();
void ScanSiteThemes();
void CovertAllCoversToEncoding();
Task CleanupDbEntries();
Task CheckForUpdate();
@ -64,6 +63,7 @@ public class TaskScheduler : ITaskScheduler
public const string DefaultQueue = "default";
public const string RemoveFromWantToReadTaskId = "remove-from-want-to-read";
public const string UpdateYearlyStatsTaskId = "update-yearly-stats";
public const string SyncThemesTaskId = "sync-themes";
public const string CheckForUpdateId = "check-updates";
public const string CleanupDbTaskId = "cleanup-db";
public const string CleanupTaskId = "cleanup";
@ -161,6 +161,9 @@ public class TaskScheduler : ITaskScheduler
RecurringJob.AddOrUpdate(UpdateYearlyStatsTaskId, () => _statisticService.UpdateServerStatistics(),
Cron.Monthly, RecurringJobOptions);
RecurringJob.AddOrUpdate(SyncThemesTaskId, () => _themeService.SyncThemes(),
Cron.Weekly, RecurringJobOptions);
await ScheduleKavitaPlusTasks();
}
@ -200,7 +203,7 @@ public class TaskScheduler : ITaskScheduler
public async Task ScheduleStatsTasks()
{
var allowStatCollection = (await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).AllowStatCollection;
var allowStatCollection = (await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).AllowStatCollection;
if (!allowStatCollection)
{
_logger.LogDebug("User has opted out of stat collection, not registering tasks");
@ -241,18 +244,6 @@ public class TaskScheduler : ITaskScheduler
BackgroundJob.Schedule(() => _statsService.Send(), DateTimeOffset.Now.AddDays(1));
}
public void ScanSiteThemes()
{
if (HasAlreadyEnqueuedTask("ThemeService", "Scan", Array.Empty<object>(), ScanQueue))
{
_logger.LogInformation("A Theme Scan is already running");
return;
}
_logger.LogInformation("Enqueueing Site Theme scan");
BackgroundJob.Enqueue(() => _themeService.Scan());
}
public void CovertAllCoversToEncoding()
{
var defaultParams = Array.Empty<object>();

View File

@ -1,35 +1,105 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using API.Data;
using API.DTOs.Theme;
using API.Entities;
using API.Entities.Enums.Theme;
using API.Extensions;
using API.SignalR;
using Flurl.Http;
using HtmlAgilityPack;
using Kavita.Common;
using Microsoft.AspNetCore.Authorization;
using Kavita.Common.EnvironmentInfo;
using MarkdownDeep;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging;
using Newtonsoft.Json;
namespace API.Services.Tasks;
#nullable enable
internal class GitHubContent
{
[JsonProperty("name")]
public string Name { get; set; }
[JsonProperty("path")]
public string Path { get; set; }
[JsonProperty("type")]
public string Type { get; set; }
[JsonProperty("download_url")]
public string DownloadUrl { get; set; }
[JsonProperty("sha")]
public string Sha { get; set; }
}
/// <summary>
/// The readme of the Theme repo
/// </summary>
internal class ThemeMetadata
{
public string Author { get; set; }
public string AuthorUrl { get; set; }
public string Description { get; set; }
public Version LastCompatible { get; set; }
}
public interface IThemeService
{
Task<string> GetContent(int themeId);
Task Scan();
Task UpdateDefault(int themeId);
/// <summary>
/// Browse theme repo for themes to download
/// </summary>
/// <returns></returns>
Task<List<DownloadableSiteThemeDto>> GetDownloadableThemes();
Task<SiteTheme> DownloadRepoTheme(DownloadableSiteThemeDto dto);
Task DeleteTheme(int siteThemeId);
Task<SiteTheme> CreateThemeFromFile(string tempFile, string username);
Task SyncThemes();
}
public class ThemeService : IThemeService
{
private readonly IDirectoryService _directoryService;
private readonly IUnitOfWork _unitOfWork;
private readonly IEventHub _eventHub;
private readonly IFileService _fileService;
private readonly ILogger<ThemeService> _logger;
private readonly Markdown _markdown = new();
private readonly IMemoryCache _cache;
private readonly MemoryCacheEntryOptions _cacheOptions;
public ThemeService(IDirectoryService directoryService, IUnitOfWork unitOfWork, IEventHub eventHub)
private const string GithubBaseUrl = "https://api.github.com";
/// <summary>
/// Used for refreshing metadata around themes
/// </summary>
private const string GithubReadme = "https://raw.githubusercontent.com/Kareadita/Themes/main/README.md";
public ThemeService(IDirectoryService directoryService, IUnitOfWork unitOfWork,
IEventHub eventHub, IFileService fileService, ILogger<ThemeService> logger, IMemoryCache cache)
{
_directoryService = directoryService;
_unitOfWork = unitOfWork;
_eventHub = eventHub;
_fileService = fileService;
_logger = logger;
_cache = cache;
_cacheOptions = new MemoryCacheEntryOptions()
.SetSize(1)
.SetAbsoluteExpiration(TimeSpan.FromMinutes(30));
}
/// <summary>
@ -39,8 +109,7 @@ public class ThemeService : IThemeService
/// <returns></returns>
public async Task<string> GetContent(int themeId)
{
var theme = await _unitOfWork.SiteThemeRepository.GetThemeDto(themeId);
if (theme == null) throw new KavitaException("theme-doesnt-exist");
var theme = await _unitOfWork.SiteThemeRepository.GetThemeDto(themeId) ?? throw new KavitaException("theme-doesnt-exist");
var themeFile = _directoryService.FileSystem.Path.Join(_directoryService.SiteThemeDirectory, theme.FileName);
if (string.IsNullOrEmpty(themeFile) || !_directoryService.FileSystem.File.Exists(themeFile))
throw new KavitaException("theme-doesnt-exist");
@ -48,78 +117,350 @@ public class ThemeService : IThemeService
return await _directoryService.FileSystem.File.ReadAllTextAsync(themeFile);
}
/// <summary>
/// Scans the site theme directory for custom css files and updates what the system has on store
/// </summary>
public async Task Scan()
public async Task<List<DownloadableSiteThemeDto>> GetDownloadableThemes()
{
_directoryService.ExistOrCreate(_directoryService.SiteThemeDirectory);
var reservedNames = Seed.DefaultThemes.Select(t => t.NormalizedName).ToList();
var themeFiles = _directoryService
.GetFilesWithExtension(Scanner.Parser.Parser.NormalizePath(_directoryService.SiteThemeDirectory), @"\.css")
.Where(name => !reservedNames.Contains(name.ToNormalized()) && !name.Contains(" "))
.ToList();
var allThemes = (await _unitOfWork.SiteThemeRepository.GetThemes()).ToList();
// First remove any files from allThemes that are User Defined and not on disk
var userThemes = allThemes.Where(t => t.Provider == ThemeProvider.User).ToList();
foreach (var userTheme in userThemes)
const string cacheKey = "browse";
var existingThemes = (await _unitOfWork.SiteThemeRepository.GetThemeDtos()).ToDictionary(k => k.Name);
if (_cache.TryGetValue(cacheKey, out List<DownloadableSiteThemeDto>? themes) && themes != null)
{
var filepath = Scanner.Parser.Parser.NormalizePath(
_directoryService.FileSystem.Path.Join(_directoryService.SiteThemeDirectory, userTheme.FileName));
if (_directoryService.FileSystem.File.Exists(filepath)) continue;
// I need to do the removal different. I need to update all user preferences to use DefaultTheme
allThemes.Remove(userTheme);
await RemoveTheme(userTheme);
}
// Add new custom themes
var allThemeNames = allThemes.Select(t => t.NormalizedName).ToList();
foreach (var themeFile in themeFiles)
{
var themeName =
_directoryService.FileSystem.Path.GetFileNameWithoutExtension(themeFile).ToNormalized();
if (allThemeNames.Contains(themeName)) continue;
_unitOfWork.SiteThemeRepository.Add(new SiteTheme()
foreach (var t in themes)
{
Name = _directoryService.FileSystem.Path.GetFileNameWithoutExtension(themeFile),
NormalizedName = themeName,
FileName = _directoryService.FileSystem.Path.GetFileName(themeFile),
Provider = ThemeProvider.User,
IsDefault = false,
});
await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress,
MessageFactory.SiteThemeProgressEvent(_directoryService.FileSystem.Path.GetFileName(themeFile), themeName,
ProgressEventType.Updated));
t.AlreadyDownloaded = existingThemes.ContainsKey(t.Name);
}
return themes;
}
// Fetch contents of the Native Themes directory
var themesContents = await GetDirectoryContent("Native%20Themes");
if (_unitOfWork.HasChanges())
{
await _unitOfWork.CommitAsync();
}
// Filter out directories
var themeDirectories = themesContents.Where(c => c.Type == "dir").ToList();
// if there are no default themes, reselect Dark as default
var postSaveThemes = (await _unitOfWork.SiteThemeRepository.GetThemes()).ToList();
if (!postSaveThemes.Exists(t => t.IsDefault))
// Get the Readme and augment the theme data
var themeMetadata = await GetReadme();
var themeDtos = new List<DownloadableSiteThemeDto>();
foreach (var themeDir in themeDirectories)
{
var defaultThemeName = Seed.DefaultThemes.Single(t => t.IsDefault).NormalizedName;
var theme = postSaveThemes.SingleOrDefault(t => t.NormalizedName == defaultThemeName);
if (theme != null)
var themeName = themeDir.Name.Trim();
// Fetch contents of the theme directory
var themeContents = await GetDirectoryContent(themeDir.Path);
// Find css and preview files
var cssFile = themeContents.FirstOrDefault(c => c.Name.EndsWith(".css"));
var previewUrls = GetPreviewUrls(themeContents);
if (cssFile == null) continue;
var cssUrl = cssFile.DownloadUrl;
var dto = new DownloadableSiteThemeDto()
{
theme.IsDefault = true;
_unitOfWork.SiteThemeRepository.Update(theme);
await _unitOfWork.CommitAsync();
Name = themeName,
CssUrl = cssUrl,
CssFile = cssFile.Name,
PreviewUrls = previewUrls,
Sha = cssFile.Sha,
Path = themeDir.Path,
};
if (themeMetadata.TryGetValue(themeName, out var metadata))
{
dto.Author = metadata.Author;
dto.LastCompatibleVersion = metadata.LastCompatible.ToString();
dto.IsCompatible = BuildInfo.Version <= metadata.LastCompatible;
dto.AlreadyDownloaded = existingThemes.ContainsKey(themeName);
dto.Description = metadata.Description;
}
themeDtos.Add(dto);
}
_cache.Set(themeDtos, themes, _cacheOptions);
return themeDtos;
}
private static IList<string> GetPreviewUrls(IEnumerable<GitHubContent> themeContents)
{
return themeContents.Where(c => c.Name.ToLower().EndsWith(".jpg") || c.Name.ToLower().EndsWith(".png") )
.Select(p => p.DownloadUrl)
.ToList();
}
private static async Task<IList<GitHubContent>> GetDirectoryContent(string path)
{
return await $"{GithubBaseUrl}/repos/Kareadita/Themes/contents/{path}"
.WithHeader("Accept", "application/vnd.github+json")
.WithHeader("User-Agent", "Kavita")
.GetJsonAsync<List<GitHubContent>>();
}
/// <summary>
/// Returns a map of all Native Themes names mapped to their metadata
/// </summary>
/// <returns></returns>
private async Task<IDictionary<string, ThemeMetadata>> GetReadme()
{
var tempDownloadFile = await GithubReadme.DownloadFileAsync(_directoryService.TempDirectory);
// Read file into Markdown
var htmlContent = _markdown.Transform(await _directoryService.FileSystem.File.ReadAllTextAsync(tempDownloadFile));
var htmlDoc = new HtmlDocument();
htmlDoc.LoadHtml(htmlContent);
// Find the table of Native Themes
var tableContent = htmlDoc.DocumentNode
.SelectSingleNode("//h2[contains(text(),'Native Themes')]/following-sibling::p").InnerText;
// Initialize dictionary to store theme metadata
var themes = new Dictionary<string, ThemeMetadata>();
// Split the table content by rows
var rows = tableContent.Split("\r\n").Select(row => row.Trim()).Where(row => !string.IsNullOrWhiteSpace(row)).ToList();
// Parse each row in the Native Themes table
foreach (var row in rows.Skip(2))
{
var cells = row.Split('|').Skip(1).Select(cell => cell.Trim()).ToList();
// Extract information from each cell
var themeName = cells[0];
var authorName = cells[1];
var description = cells[2];
var compatibility = Version.Parse(cells[3]);
// Create ThemeMetadata object
var themeMetadata = new ThemeMetadata
{
Author = authorName,
Description = description,
LastCompatible = compatibility
};
// Add theme metadata to dictionary
themes.Add(themeName, themeMetadata);
}
return themes;
}
private async Task<string> DownloadSiteTheme(DownloadableSiteThemeDto dto)
{
if (string.IsNullOrEmpty(dto.Sha))
{
throw new ArgumentException("SHA cannot be null or empty for already downloaded themes.");
}
_directoryService.ExistOrCreate(_directoryService.SiteThemeDirectory);
var existingTempFile = _directoryService.FileSystem.Path.Join(_directoryService.SiteThemeDirectory,
_directoryService.FileSystem.FileInfo.New(dto.CssUrl).Name);
_directoryService.DeleteFiles([existingTempFile]);
var tempDownloadFile = await dto.CssUrl.DownloadFileAsync(_directoryService.TempDirectory);
// Validate the hash on the downloaded file
// if (!_fileService.ValidateSha(tempDownloadFile, dto.Sha))
// {
// throw new KavitaException("Cannot download theme, hash does not match");
// }
_directoryService.CopyFileToDirectory(tempDownloadFile, _directoryService.SiteThemeDirectory);
var finalLocation = _directoryService.FileSystem.Path.Join(_directoryService.SiteThemeDirectory, dto.CssFile);
return finalLocation;
}
public async Task<SiteTheme> DownloadRepoTheme(DownloadableSiteThemeDto dto)
{
// Validate we don't have a collision with existing or existing doesn't already exist
var existingThemes = _directoryService.ScanFiles(_directoryService.SiteThemeDirectory, string.Empty);
if (existingThemes.Any(f => Path.GetFileName(f) == dto.CssFile))
{
throw new KavitaException("Cannot download file, file already on disk");
}
var finalLocation = await DownloadSiteTheme(dto);
// Create a new entry and note that this is downloaded
var theme = new SiteTheme()
{
Name = dto.Name,
NormalizedName = dto.Name.ToNormalized(),
FileName = _directoryService.FileSystem.Path.GetFileName(finalLocation),
Provider = ThemeProvider.Custom,
IsDefault = false,
GitHubPath = dto.Path,
Description = dto.Description,
PreviewUrls = string.Join('|', dto.PreviewUrls),
Author = dto.Author,
ShaHash = dto.Sha,
CompatibleVersion = dto.LastCompatibleVersion,
};
_unitOfWork.SiteThemeRepository.Add(theme);
await _unitOfWork.CommitAsync();
// Inform about the new theme
await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress,
MessageFactory.SiteThemeProgressEvent("", "", ProgressEventType.Ended));
MessageFactory.SiteThemeProgressEvent(_directoryService.FileSystem.Path.GetFileName(theme.FileName), theme.Name,
ProgressEventType.Ended));
return theme;
}
public async Task SyncThemes()
{
var themes = await _unitOfWork.SiteThemeRepository.GetThemes();
var themeMetadata = await GetReadme();
foreach (var theme in themes)
{
await SyncTheme(theme, themeMetadata);
}
_logger.LogInformation("Sync Themes complete");
}
/// <summary>
/// If the Theme is from the Theme repo, see if there is a new version that is compatible
/// </summary>
/// <param name="theme"></param>
/// <param name="themeMetadata">The Readme information</param>
private async Task SyncTheme(SiteTheme? theme, IDictionary<string, ThemeMetadata> themeMetadata)
{
// Given a theme, first validate that it is applicable
if (theme == null || theme.Provider == ThemeProvider.System || string.IsNullOrEmpty(theme.GitHubPath))
{
_logger.LogInformation("Cannot Sync {ThemeName} as it is not valid", theme?.Name);
return;
}
if (new Version(theme.CompatibleVersion) > BuildInfo.Version)
{
_logger.LogDebug("{ThemeName} theme supports a more up-to-date version ({Version}) of Kavita. Please update", theme.Name, theme.CompatibleVersion);
return;
}
var themeContents = await GetDirectoryContent(theme.GitHubPath);
var cssFile = themeContents.FirstOrDefault(c => c.Name.EndsWith(".css"));
if (cssFile == null) return;
// Update any metadata
if (themeMetadata.TryGetValue(theme.Name, out var metadata))
{
theme.Description = metadata.Description;
theme.Author = metadata.Author;
theme.CompatibleVersion = metadata.LastCompatible.ToString();
theme.PreviewUrls = string.Join('|', GetPreviewUrls(themeContents));
}
var hasUpdated = cssFile.Sha != theme.ShaHash;
if (hasUpdated)
{
_logger.LogDebug("Theme {ThemeName} is out of date, updating", theme.Name);
var tempLocation = _directoryService.FileSystem.Path.Join(_directoryService.TempDirectory, theme.FileName);
_directoryService.DeleteFiles([tempLocation]);
var location = await cssFile.DownloadUrl.DownloadFileAsync(_directoryService.TempDirectory);
if (_directoryService.FileSystem.File.Exists(location))
{
_directoryService.CopyFileToDirectory(location, _directoryService.SiteThemeDirectory);
_logger.LogInformation("Updated Theme on disk for {ThemeName}", theme.Name);
}
}
await _unitOfWork.CommitAsync();
if (hasUpdated)
{
await _eventHub.SendMessageAsync(MessageFactory.SiteThemeUpdated,
MessageFactory.SiteThemeUpdatedEvent(theme.Name));
}
// Send an update to refresh metadata around the themes
await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress,
MessageFactory.SiteThemeProgressEvent(_directoryService.FileSystem.Path.GetFileName(theme.FileName), theme.Name,
ProgressEventType.Ended));
_logger.LogInformation("Theme Sync complete");
}
/// <summary>
/// Deletes a SiteTheme. The CSS file will be moved to temp/ to allow user to recover data
/// </summary>
/// <param name="siteThemeId"></param>
public async Task DeleteTheme(int siteThemeId)
{
// Validate no one else is using this theme
var inUse = await _unitOfWork.SiteThemeRepository.IsThemeInUse(siteThemeId);
if (inUse)
{
throw new KavitaException("errors.delete-theme-in-use");
}
var siteTheme = await _unitOfWork.SiteThemeRepository.GetTheme(siteThemeId);
if (siteTheme == null) return;
await RemoveTheme(siteTheme);
}
/// <summary>
/// This assumes a file is already in temp directory and will be used for
/// </summary>
/// <param name="tempFile"></param>
/// <returns></returns>
public async Task<SiteTheme> CreateThemeFromFile(string tempFile, string username)
{
if (!_directoryService.FileSystem.File.Exists(tempFile))
{
_logger.LogInformation("Unable to create theme from manual upload as file not in temp");
throw new KavitaException("errors.theme-manual-upload");
}
var filename = _directoryService.FileSystem.FileInfo.New(tempFile).Name;
var themeName = Path.GetFileNameWithoutExtension(filename);
if (await _unitOfWork.SiteThemeRepository.GetThemeDtoByName(themeName) != null)
{
throw new KavitaException("errors.theme-already-in-use");
}
_directoryService.CopyFileToDirectory(tempFile, _directoryService.SiteThemeDirectory);
var finalLocation = _directoryService.FileSystem.Path.Join(_directoryService.SiteThemeDirectory, filename);
// Create a new entry and note that this is downloaded
var theme = new SiteTheme()
{
Name = Path.GetFileNameWithoutExtension(filename),
NormalizedName = themeName.ToNormalized(),
FileName = _directoryService.FileSystem.Path.GetFileName(finalLocation),
Provider = ThemeProvider.Custom,
IsDefault = false,
Description = $"Manually uploaded via UI by {username}",
PreviewUrls = string.Empty,
Author = username,
};
_unitOfWork.SiteThemeRepository.Add(theme);
await _unitOfWork.CommitAsync();
// Inform about the new theme
await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress,
MessageFactory.SiteThemeProgressEvent(_directoryService.FileSystem.Path.GetFileName(theme.FileName), theme.Name,
ProgressEventType.Ended));
return theme;
}
@ -130,6 +471,7 @@ public class ThemeService : IThemeService
/// <param name="theme"></param>
private async Task RemoveTheme(SiteTheme theme)
{
_logger.LogInformation("Removing {ThemeName}. File can be found in temp/ until nightly cleanup", theme.Name);
var prefs = await _unitOfWork.UserRepository.GetAllPreferencesByThemeAsync(theme.Id);
var defaultTheme = await _unitOfWork.SiteThemeRepository.GetDefaultTheme();
foreach (var pref in prefs)
@ -137,6 +479,20 @@ public class ThemeService : IThemeService
pref.Theme = defaultTheme;
_unitOfWork.UserRepository.Update(pref);
}
try
{
// Copy the theme file to temp for nightly removal (to give user time to reclaim if made a mistake)
var existingLocation =
_directoryService.FileSystem.Path.Join(_directoryService.SiteThemeDirectory, theme.FileName);
var newLocation =
_directoryService.FileSystem.Path.Join(_directoryService.TempDirectory, theme.FileName);
_directoryService.CopyFileToDirectory(existingLocation, newLocation);
_directoryService.DeleteFiles([existingLocation]);
}
catch (Exception) { /* Swallow */ }
_unitOfWork.SiteThemeRepository.Remove(theme);
await _unitOfWork.CommitAsync();
}

View File

@ -130,6 +130,10 @@ public static class MessageFactory
/// Order, Visibility, etc has changed on the Sidenav. UI will refresh the layout
/// </summary>
public const string SideNavUpdate = "SideNavUpdate";
/// <summary>
/// A Theme was updated and UI should refresh to get the latest version
/// </summary>
public const string SiteThemeUpdated = "SiteThemeUpdated";
public static SignalRMessage DashboardUpdateEvent(int userId)
{
@ -485,7 +489,7 @@ public static class MessageFactory
return new SignalRMessage()
{
Name = SiteThemeProgress,
Title = "Scanning Site Theme",
Title = "Processing Site Theme", // TODO: Localize SignalRMessage titles
SubTitle = subtitle,
EventType = eventType,
Progress = ProgressType.Indeterminate,
@ -496,6 +500,25 @@ public static class MessageFactory
};
}
/// <summary>
/// Sends an event to the UI informing of a SiteTheme update and UI needs to refresh the content
/// </summary>
/// <param name="themeName"></param>
/// <returns></returns>
public static SignalRMessage SiteThemeUpdatedEvent(string themeName)
{
return new SignalRMessage()
{
Name = SiteThemeUpdated,
Title = "SiteTheme Update",
Progress = ProgressType.None,
Body = new
{
ThemeName = themeName,
}
};
}
public static SignalRMessage BookThemeProgressEvent(string subtitle, string themeName, string eventType)
{
return new SignalRMessage()

View File

@ -398,7 +398,10 @@ public class Startup
endpoints.MapControllers();
endpoints.MapHub<MessageHub>("hubs/messages");
endpoints.MapHub<LogHub>("hubs/logs");
endpoints.MapHangfireDashboard();
if (env.IsDevelopment())
{
endpoints.MapHangfireDashboard();
}
endpoints.MapFallbackToController("Index", "Fallback");
});

View File

@ -504,6 +504,7 @@
"version": "17.3.4",
"resolved": "https://registry.npmjs.org/@angular/compiler-cli/-/compiler-cli-17.3.4.tgz",
"integrity": "sha512-TVWjpZSI/GIXTYsmVgEKYjBckcW8Aj62DcxLNehRFR+c7UB95OY3ZFjU8U4jL0XvWPgTkkVWQVq+P6N4KCBsyw==",
"dev": true,
"dependencies": {
"@babel/core": "7.23.9",
"@jridgewell/sourcemap-codec": "^1.4.14",
@ -531,6 +532,7 @@
"version": "7.23.9",
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.23.9.tgz",
"integrity": "sha512-5q0175NOjddqpvvzU+kDiSOAk4PfdO6FvwCWoQ6RO7rTzEe8vlo+4HVfcnAREhD4npMs0e9uZypjTwzZPCf/cw==",
"dev": true,
"dependencies": {
"@ampproject/remapping": "^2.2.0",
"@babel/code-frame": "^7.23.5",
@ -559,12 +561,14 @@
"node_modules/@angular/compiler-cli/node_modules/@babel/core/node_modules/convert-source-map": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
"integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="
"integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
"dev": true
},
"node_modules/@angular/compiler-cli/node_modules/@babel/core/node_modules/semver": {
"version": "6.3.1",
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
"integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
"dev": true,
"bin": {
"semver": "bin/semver.js"
}
@ -745,6 +749,7 @@
"version": "7.24.0",
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.24.0.tgz",
"integrity": "sha512-fQfkg0Gjkza3nf0c7/w6Xf34BW4YvzNfACRLmmb7XRLa6XHdR+K9AlJlxneFfWYf6uhOzuzZVTjF/8KfndZANw==",
"dev": true,
"dependencies": {
"@ampproject/remapping": "^2.2.0",
"@babel/code-frame": "^7.23.5",
@ -773,12 +778,14 @@
"node_modules/@babel/core/node_modules/convert-source-map": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
"integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="
"integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
"dev": true
},
"node_modules/@babel/core/node_modules/semver": {
"version": "6.3.1",
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
"integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
"dev": true,
"bin": {
"semver": "bin/semver.js"
}
@ -5622,6 +5629,7 @@
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz",
"integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==",
"dev": true,
"dependencies": {
"normalize-path": "^3.0.0",
"picomatch": "^2.0.4"
@ -5634,6 +5642,7 @@
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
"dev": true,
"engines": {
"node": ">=8.6"
},
@ -5905,6 +5914,7 @@
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
"integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==",
"dev": true,
"engines": {
"node": ">=8"
},
@ -6216,6 +6226,7 @@
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
"integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==",
"dev": true,
"dependencies": {
"anymatch": "~3.1.2",
"braces": "~3.0.2",
@ -6507,7 +6518,8 @@
"node_modules/convert-source-map": {
"version": "1.9.0",
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz",
"integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A=="
"integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==",
"dev": true
},
"node_modules/cookie": {
"version": "0.6.0",
@ -7409,6 +7421,7 @@
"version": "0.1.13",
"resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.13.tgz",
"integrity": "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==",
"dev": true,
"optional": true,
"dependencies": {
"iconv-lite": "^0.6.2"
@ -7418,6 +7431,7 @@
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
"integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
"dev": true,
"optional": true,
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3.0.0"
@ -8526,6 +8540,7 @@
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
"dev": true,
"hasInstallScript": true,
"optional": true,
"os": [
@ -9207,6 +9222,7 @@
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
"integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==",
"dev": true,
"dependencies": {
"binary-extensions": "^2.0.0"
},
@ -11047,6 +11063,7 @@
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
"integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==",
"dev": true,
"engines": {
"node": ">=0.10.0"
}
@ -12436,6 +12453,7 @@
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
"integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==",
"dev": true,
"dependencies": {
"picomatch": "^2.2.1"
},
@ -12447,6 +12465,7 @@
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
"dev": true,
"engines": {
"node": ">=8.6"
},
@ -12457,7 +12476,8 @@
"node_modules/reflect-metadata": {
"version": "0.2.2",
"resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz",
"integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q=="
"integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==",
"dev": true
},
"node_modules/regenerate": {
"version": "1.4.2",
@ -12925,7 +12945,7 @@
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"devOptional": true
"dev": true
},
"node_modules/sass": {
"version": "1.71.1",
@ -13044,6 +13064,7 @@
"version": "7.6.0",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.6.0.tgz",
"integrity": "sha512-EnwXhrlwXMk9gKu5/flx5sv/an57AkRplG3hTK68W7FRDN+k+OWBj65M7719OkA82XLBxrcX0KSHj+X5COhOVg==",
"dev": true,
"dependencies": {
"lru-cache": "^6.0.0"
},
@ -13058,6 +13079,7 @@
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
"integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==",
"dev": true,
"dependencies": {
"yallist": "^4.0.0"
},
@ -13068,7 +13090,8 @@
"node_modules/semver/node_modules/yallist": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
"integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="
"integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==",
"dev": true
},
"node_modules/send": {
"version": "0.18.0",
@ -14199,6 +14222,7 @@
"version": "5.4.5",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.4.5.tgz",
"integrity": "sha512-vcI4UpRgg81oIRUFwR0WSIHKt11nJ7SAVlYNIu+QpqeyXP+gpQJy/Z4+F0aGxSE4MqwjyXvW/TzgkLAx2AGHwQ==",
"dev": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"

View File

@ -0,0 +1,3 @@
export interface SiteThemeUpdatedEvent {
themeName: string;
}

View File

@ -3,9 +3,9 @@
*/
export enum ThemeProvider {
System = 1,
User = 2
Custom = 2,
}
/**
* Theme for the whole instance
*/
@ -20,4 +20,8 @@
* The actual class the root is defined against. It is generated at the backend.
*/
selector: string;
}
description: string;
previewUrls: Array<string>;
author: string;
}

View File

@ -0,0 +1,10 @@
export interface DownloadableSiteTheme {
name: string;
cssUrl: string;
previewUrls: Array<string>;
author: string;
isCompatible: boolean;
lastCompatibleVersion: string;
alreadyDownloaded: boolean;
description: string;
}

View File

@ -16,8 +16,8 @@ export class SiteThemeProviderPipe implements PipeTransform {
switch(provider) {
case ThemeProvider.System:
return this.translocoService.translate('site-theme-provider-pipe.system');
case ThemeProvider.User:
return this.translocoService.translate('site-theme-provider-pipe.user');
case ThemeProvider.Custom:
return this.translocoService.translate('site-theme-provider-pipe.custom');
default:
return '';
}

View File

@ -9,6 +9,7 @@ import { UserUpdateEvent } from '../_models/events/user-update-event';
import { User } from '../_models/user';
import {DashboardUpdateEvent} from "../_models/events/dashboard-update-event";
import {SideNavUpdateEvent} from "../_models/events/sidenav-update-event";
import {SiteThemeUpdatedEvent} from "../_models/events/site-theme-updated-event";
export enum EVENTS {
UpdateAvailable = 'UpdateAvailable',
@ -98,7 +99,11 @@ export enum EVENTS {
/**
* User's sidenav needs to be re-rendered
*/
SideNavUpdate = 'SideNavUpdate'
SideNavUpdate = 'SideNavUpdate',
/**
* A Theme was updated and UI should refresh to get the latest version
*/
SiteThemeUpdated= 'SiteThemeUpdated'
}
export interface Message<T> {
@ -194,6 +199,13 @@ export class MessageHubService {
});
});
this.hubConnection.on(EVENTS.SiteThemeUpdated, resp => {
this.messagesSource.next({
event: EVENTS.SiteThemeUpdated,
payload: resp.body as SiteThemeUpdatedEvent
});
});
this.hubConnection.on(EVENTS.DashboardUpdate, resp => {
this.messagesSource.next({
event: EVENTS.DashboardUpdate,

View File

@ -1,25 +1,20 @@
import { DOCUMENT } from '@angular/common';
import { HttpClient } from '@angular/common/http';
import {
DestroyRef,
inject,
Inject,
Injectable,
Renderer2,
RendererFactory2,
SecurityContext
} from '@angular/core';
import { DomSanitizer } from '@angular/platform-browser';
import { ToastrService } from 'ngx-toastr';
import { map, ReplaySubject, take } from 'rxjs';
import { environment } from 'src/environments/environment';
import { ConfirmService } from '../shared/confirm.service';
import { NotificationProgressEvent } from '../_models/events/notification-progress-event';
import { SiteTheme, ThemeProvider } from '../_models/preferences/site-theme';
import { TextResonse } from '../_types/text-response';
import { EVENTS, MessageHubService } from './message-hub.service';
import {DOCUMENT} from '@angular/common';
import {HttpClient} from '@angular/common/http';
import {DestroyRef, inject, Inject, Injectable, Renderer2, RendererFactory2, SecurityContext} from '@angular/core';
import {DomSanitizer} from '@angular/platform-browser';
import {ToastrService} from 'ngx-toastr';
import {map, ReplaySubject, take} from 'rxjs';
import {environment} from 'src/environments/environment';
import {ConfirmService} from '../shared/confirm.service';
import {NotificationProgressEvent} from '../_models/events/notification-progress-event';
import {SiteTheme, ThemeProvider} from '../_models/preferences/site-theme';
import {TextResonse} from '../_types/text-response';
import {EVENTS, MessageHubService} from './message-hub.service';
import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
import {translate} from "@ngneat/transloco";
import {DownloadableSiteTheme} from "../_models/theme/downloadable-site-theme";
import {NgxFileDropEntry} from "ngx-file-drop";
import {SiteThemeUpdatedEvent} from "../_models/events/site-theme-updated-event";
@Injectable({
@ -52,18 +47,45 @@ export class ThemeService {
messageHub.messages$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(message => {
if (message.event !== EVENTS.NotificationProgress) return;
const notificationEvent = (message.payload as NotificationProgressEvent);
if (notificationEvent.name !== EVENTS.SiteThemeProgress) return;
if (message.event === EVENTS.NotificationProgress) {
const notificationEvent = (message.payload as NotificationProgressEvent);
if (notificationEvent.name !== EVENTS.SiteThemeProgress) return;
if (notificationEvent.eventType === 'ended') {
if (notificationEvent.name === EVENTS.SiteThemeProgress) this.getThemes().subscribe(() => {
if (notificationEvent.eventType === 'ended') {
if (notificationEvent.name === EVENTS.SiteThemeProgress) this.getThemes().subscribe();
}
return;
}
if (message.event === EVENTS.SiteThemeUpdated) {
const evt = (message.payload as SiteThemeUpdatedEvent);
this.currentTheme$.pipe(take(1)).subscribe(currentTheme => {
if (currentTheme && currentTheme.name !== EVENTS.SiteThemeProgress) return;
console.log('Active theme has been updated, refreshing theme');
this.setTheme(currentTheme.name);
});
}
});
}
getDownloadableThemes() {
return this.httpClient.get<Array<DownloadableSiteTheme>>(this.baseUrl + 'theme/browse');
}
downloadTheme(theme: DownloadableSiteTheme) {
return this.httpClient.post<SiteTheme>(this.baseUrl + 'theme/download-theme', theme);
}
uploadTheme(themeFile: File, fileEntry: NgxFileDropEntry) {
const formData = new FormData()
formData.append('formFile', themeFile, fileEntry.relativePath);
return this.httpClient.post<SiteTheme>(this.baseUrl + 'theme/upload-theme', formData);
}
getColorScheme() {
return getComputedStyle(this.document.body).getPropertyValue('--color-scheme').trim();
}
@ -113,6 +135,12 @@ export class ThemeService {
this.unsetThemes();
}
deleteTheme(themeId: number) {
return this.httpClient.delete(this.baseUrl + 'theme?themeId=' + themeId).pipe(map(() => {
this.getThemes().subscribe(() => {});
}));
}
setDefault(themeId: number) {
return this.httpClient.post(this.baseUrl + 'theme/update-default', {themeId: themeId}).pipe(map(() => {
// Refresh the cache when a default state is changed
@ -148,7 +176,7 @@ export class ThemeService {
this.unsetThemes();
this.renderer.addClass(this.document.querySelector('body'), theme.selector);
if (theme.provider === ThemeProvider.User && !this.hasThemeInHead(theme.name)) {
if (theme.provider !== ThemeProvider.System && !this.hasThemeInHead(theme.name)) {
// We need to load the styles into the browser
this.fetchThemeContent(theme.id).subscribe(async (content) => {
if (content === null) {

View File

@ -13,7 +13,6 @@ import { SideNavComponent } from './sidenav/_components/side-nav/side-nav.compon
import {NavHeaderComponent} from "./nav/_components/nav-header/nav-header.component";
import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
import {ServerService} from "./_services/server.service";
import {ImportCblModalComponent} from "./reading-list/_modals/import-cbl-modal/import-cbl-modal.component";
import {OutOfDateModalComponent} from "./announcements/_components/out-of-date-modal/out-of-date-modal.component";
@Component({

View File

@ -16,27 +16,19 @@
</div>
<div>by {{stack.author}} • {{t('series-count', {num: stack.seriesCount | number})}} • <span><i class="fa-solid fa-layer-group me-1" aria-hidden="true"></i>{{t('restack-count', {num: stack.restackCount | number})}}</span></div>
</li>
} @empty {
@if (isLoading) {
<app-loading [loading]="isLoading"></app-loading>
} @else {
<p>{{t('nothing-found')}}</p>
}
}
</ul>
</div>
<div class="modal-footer">
<!-- <div class="col-auto">-->
<!-- <a class="btn btn-icon" href="https://wiki.kavitareader.com/guides/features/readinglists#creating-a-reading-list-via-cbl" target="_blank" rel="noopener noreferrer">Help</a>-->
<!-- </div>-->
<!-- <div class="col-auto">-->
<!-- <button type="button" class="btn btn-secondary" (click)="close()">{{t('close')}}</button>-->
<!-- </div>-->
<!-- <div class="col-auto">-->
<!-- <button type="button" class="btn btn-primary" (click)="prevStep()" [disabled]="!canMoveToPrevStep()">{{t('prev')}}</button>-->
<!-- </div>-->
<!-- <div class="col-auto">-->
<!-- <button type="button" class="btn btn-primary" (click)="nextStep()" [disabled]="!canMoveToNextStep()">{{t(NextButtonLabel)}}</button>-->
<!-- </div>-->
<div class="col-auto">
<button type="button" class="btn btn-secondary" (click)="ngbModal.dismiss()">{{t('close')}}</button>
</div>
<button type="button" class="btn btn-secondary" (click)="ngbModal.dismiss()">{{t('close')}}</button>
</div>
</ng-container>

View File

@ -10,6 +10,7 @@ import {ScrobbleProvider} from "../../../_services/scrobbling.service";
import {forkJoin} from "rxjs";
import {ToastrService} from "ngx-toastr";
import {DecimalPipe} from "@angular/common";
import {LoadingComponent} from "../../../shared/loading/loading.component";
@Component({
selector: 'app-import-mal-collection-modal',
@ -18,7 +19,8 @@ import {DecimalPipe} from "@angular/common";
TranslocoDirective,
ReactiveFormsModule,
Select2Module,
DecimalPipe
DecimalPipe,
LoadingComponent
],
templateUrl: './import-mal-collection-modal.component.html',
styleUrl: './import-mal-collection-modal.component.scss',

View File

@ -1,32 +1,141 @@
<ng-container *transloco="let t; read:'theme-manager'">
<div class="container-fluid">
<div class="row mb-2">
<div class="col-8"><h3>{{t('title')}}</h3></div>
<div class="col-4" *ngIf="isAdmin">
<button class="btn btn-primary float-end" (click)="scan()">
<i class="fa fa-refresh" aria-hidden="true"></i>&nbsp;{{t('scan')}}
</button>
</div>
<h3>{{t('title')}}</h3>
</div>
<p *ngIf="isAdmin">
{{t('looking-for-theme')}}<a href="https://github.com/Kareadita/Themes" target="_blank" rel="noopener noreferrer">{{t('looking-for-theme-continued')}}</a>
</p>
<p>{{t('description')}}</p>
<div class="row g-0">
<h4>{{t('site-themes')}}</h4>
<ng-container *ngFor="let theme of (themeService.themes$ | async)">
<div class="card col-auto me-3 mb-3" style="width: 18rem;">
<div class="card-body">
<h5 class="card-title">{{theme.name | sentenceCase}}</h5>
<h6 class="card-subtitle mb-2 text-muted">{{theme.provider | siteThemeProvider}}</h6>
<button class="btn btn-secondary me-2" [disabled]="theme.isDefault" *ngIf="isAdmin" (click)="updateDefault(theme)">{{t('set-default')}}</button>
<button class="btn btn-primary" (click)="applyTheme(theme)" [disabled]="currentTheme?.id === theme.id">{{currentTheme?.id === theme.id ? t('applied') : t('apply')}}</button>
</div>
<div class="row g-0 theme-container">
<div class="col-md-3">
<div class="pe-2">
<ul style="height: 100%" class="list-group list-group-flush">
@for (theme of themeService.themes$ | async; track theme.name) {
<ng-container [ngTemplateOutlet]="themeOption" [ngTemplateOutletContext]="{ $implicit: theme}"></ng-container>
}
@for (theme of downloadableThemes; track theme.name) {
<ng-container [ngTemplateOutlet]="themeOption" [ngTemplateOutletContext]="{ $implicit: theme}"></ng-container>
}
</ul>
</div>
</ng-container>
</div>
<div class="col-md-9">
@if (selectedTheme === undefined) {
<div class="row pb-4">
<div class="mx-auto">
<div class="d-flex justify-content-center">
<div class="d-flex justify-content-evenly">
@if (hasAdmin$ | async) {
{{t('preview-default-admin')}}
} @else {
{{t('preview-default')}}
}
</div>
</div>
</div>
</div>
@if (files && files.length > 0) {
<app-loading [loading]="isUploadingTheme"></app-loading>
} @else if (hasAdmin$ | async) {
<ngx-file-drop (onFileDrop)="dropped($event)" [accept]="acceptableExtensions" [directory]="false"
dropZoneClassName="file-upload" contentClassName="file-upload-zone">
<ng-template ngx-file-drop-content-tmp let-openFileSelector="openFileSelector">
<div class="row g-0 mt-3 pb-3">
<div class="mx-auto">
<div class="row g-0 mb-3">
<i class="fa fa-file-upload mx-auto" style="font-size: 24px; width: 20px;" aria-hidden="true"></i>
</div>
<div class="d-flex justify-content-center">
<div class="d-flex justify-content-evenly">
<span class="pe-0" href="javascript:void(0)">{{t('drag-n-drop')}}</span>
<span class="ps-1 pe-1"></span>
<a class="pe-0" href="javascript:void(0)" (click)="openFileSelector()">{{t('upload')}}<span class="phone-hidden"> {{t('upload-continued')}}</span></a>
</div>
</div>
</div>
</div>
</ng-template>
</ngx-file-drop>
}
}
@else {
<h4>
{{selectedTheme.name | sentenceCase}}
<div class="float-end">
@if (selectedTheme.isSiteTheme) {
@if (selectedTheme.name !== 'Dark') {
<button class="btn btn-danger me-1" (click)="deleteTheme(selectedTheme.site!)">{{t('delete')}}</button>
}
@if (hasAdmin$ | async) {
<button class="btn btn-secondary me-1" [disabled]="selectedTheme.site?.isDefault" (click)="updateDefault(selectedTheme.site!)">{{t('set-default')}}</button>
}
<button class="btn btn-primary me-1" [disabled]="currentTheme && selectedTheme.name === currentTheme.name" (click)="applyTheme(selectedTheme.site!)">{{t('apply')}}</button>
} @else {
<button class="btn btn-primary" [disabled]="selectedTheme.downloadable?.alreadyDownloaded" (click)="downloadTheme(selectedTheme.downloadable!)">{{t('download')}}</button>
}
</div>
</h4>
@if(!selectedTheme.isSiteTheme) {
<p>{{selectedTheme.downloadable!.description | defaultValue}}</p>
<app-carousel-reel [items]="selectedTheme.downloadable!.previewUrls" title="Preview">
<ng-template #carouselItem let-item>
<a [href]="item | safeUrl" target="_blank" rel="noopener noreferrer">
<app-image [imageUrl]="item" height="100px" width="160px"></app-image>
</a>
</ng-template>
</app-carousel-reel>
} @else {
<p>{{selectedTheme.site!.description | defaultValue}}</p>
<app-carousel-reel [items]="selectedTheme.site!.previewUrls" title="Preview">
<ng-template #carouselItem let-item>
<a [href]="item | safeUrl" target="_blank" rel="noopener noreferrer">
<app-image [imageUrl]="item" height="100px" width="160px"></app-image>
</a>
</ng-template>
</app-carousel-reel>
}
}
</div>
</div>
</div>
<ng-template #themeOption let-item>
@if (item !== undefined) {
<li class="list-group-item d-flex justify-content-between align-items-start {{selectedTheme && selectedTheme.name === item.name ? 'active' : ''}}" (click)="selectTheme(item)">
<div class="ms-2 me-auto">
<div class="fw-bold">{{item.name | sentenceCase}}</div>
@if (item.hasOwnProperty('provider')) {
{{item.provider | siteThemeProvider}}
} @else if (item.hasOwnProperty('lastCompatibleVersion')) {
{{ThemeProvider.Custom | siteThemeProvider}} • v{{item.lastCompatibleVersion}}
}
@if (currentTheme && item.name === currentTheme.name) {
• {{t('active-theme')}}
}
</div>
@if (item.hasOwnProperty('isDefault') && item.isDefault) {
<i class="fa-solid fa-star" [attr.aria-label]="t('default-theme')"></i>
}
</li>
}
</ng-template>
</ng-container>

View File

@ -0,0 +1,27 @@
//.theme-container {
// max-height: calc(100 * var(--vh));
// overflow-y: auto;
//}
.chooser {
display: grid;
grid-template-columns: repeat(auto-fill, 158px);
grid-gap: 0.5rem;
justify-content: space-around;
}
ngx-file-drop ::ng-deep > div {
// styling for the outer drop box
width: 100%;
border: 2px solid var(--primary-color);
border-radius: 5px;
height: 100px;
margin: auto;
> div {
// styling for the inner box (template)
width: 100%;
display: inline-block;
}
}

View File

@ -6,17 +6,36 @@ import {
inject,
} from '@angular/core';
import { ToastrService } from 'ngx-toastr';
import { distinctUntilChanged, take } from 'rxjs';
import {distinctUntilChanged, map, take} from 'rxjs';
import { ThemeService } from 'src/app/_services/theme.service';
import { SiteTheme } from 'src/app/_models/preferences/site-theme';
import {SiteTheme, ThemeProvider} from 'src/app/_models/preferences/site-theme';
import { User } from 'src/app/_models/user';
import { AccountService } from 'src/app/_services/account.service';
import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
import { SiteThemeProviderPipe } from '../../_pipes/site-theme-provider.pipe';
import { SentenceCasePipe } from '../../_pipes/sentence-case.pipe';
import { NgIf, NgFor, AsyncPipe } from '@angular/common';
import {TranslocoDirective, TranslocoService} from "@ngneat/transloco";
import {tap} from "rxjs/operators";
import {NgIf, NgFor, AsyncPipe, NgTemplateOutlet} from '@angular/common';
import {translate, TranslocoDirective} from "@ngneat/transloco";
import {shareReplay} from "rxjs/operators";
import {CarouselReelComponent} from "../../carousel/_components/carousel-reel/carousel-reel.component";
import {SeriesCardComponent} from "../../cards/series-card/series-card.component";
import {ImageComponent} from "../../shared/image/image.component";
import {DownloadableSiteTheme} from "../../_models/theme/downloadable-site-theme";
import {DefaultValuePipe} from "../../_pipes/default-value.pipe";
import {SafeUrlPipe} from "../../_pipes/safe-url.pipe";
import {ScrobbleProvider} from "../../_services/scrobbling.service";
import {ConfirmService} from "../../shared/confirm.service";
import {FileSystemFileEntry, NgxFileDropEntry, NgxFileDropModule} from "ngx-file-drop";
import {ReactiveFormsModule} from "@angular/forms";
import {Select2Module} from "ng-select2-component";
import {LoadingComponent} from "../../shared/loading/loading.component";
interface ThemeContainer {
downloadable?: DownloadableSiteTheme;
site?: SiteTheme;
isSiteTheme: boolean;
name: string;
}
@Component({
selector: 'app-theme-manager',
@ -24,51 +43,125 @@ import {tap} from "rxjs/operators";
styleUrls: ['./theme-manager.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true,
imports: [NgIf, NgFor, AsyncPipe, SentenceCasePipe, SiteThemeProviderPipe, TranslocoDirective]
imports: [NgIf, NgFor, AsyncPipe, SentenceCasePipe, SiteThemeProviderPipe, TranslocoDirective, CarouselReelComponent, SeriesCardComponent, ImageComponent, DefaultValuePipe, NgTemplateOutlet, SafeUrlPipe, NgxFileDropModule, ReactiveFormsModule, Select2Module, LoadingComponent]
})
export class ThemeManagerComponent {
private readonly destroyRef = inject(DestroyRef);
protected readonly themeService = inject(ThemeService);
private readonly accountService = inject(AccountService);
private readonly toastr = inject(ToastrService);
private readonly cdRef = inject(ChangeDetectorRef);
private readonly confirmService = inject(ConfirmService);
protected readonly ThemeProvider = ThemeProvider;
protected readonly ScrobbleProvider = ScrobbleProvider;
currentTheme: SiteTheme | undefined;
isAdmin: boolean = false;
user: User | undefined;
private readonly destroyRef = inject(DestroyRef);
private readonly translocService = inject(TranslocoService);
selectedTheme: ThemeContainer | undefined;
downloadableThemes: Array<DownloadableSiteTheme> = [];
hasAdmin$ = this.accountService.currentUser$.pipe(
takeUntilDestroyed(this.destroyRef), shareReplay({refCount: true, bufferSize: 1}),
map(c => c && this.accountService.hasAdminRole(c))
);
files: NgxFileDropEntry[] = [];
acceptableExtensions = ['.css'].join(',');
isUploadingTheme: boolean = false;
constructor(public themeService: ThemeService, private accountService: AccountService,
private toastr: ToastrService, private readonly cdRef: ChangeDetectorRef) {
themeService.currentTheme$.pipe(takeUntilDestroyed(this.destroyRef), distinctUntilChanged()).subscribe(theme => {
constructor() {
this.loadDownloadableThemes();
this.themeService.currentTheme$.pipe(takeUntilDestroyed(this.destroyRef), distinctUntilChanged()).subscribe(theme => {
this.currentTheme = theme;
this.cdRef.markForCheck();
});
accountService.currentUser$.pipe(take(1)).subscribe(user => {
if (user) {
this.user = user;
this.isAdmin = accountService.hasAdminRole(user);
this.cdRef.markForCheck();
}
}
loadDownloadableThemes() {
this.themeService.getDownloadableThemes().subscribe(d => {
this.downloadableThemes = d;
this.cdRef.markForCheck();
});
}
applyTheme(theme: SiteTheme) {
if (!this.user) return;
async deleteTheme(theme: SiteTheme) {
if (!await this.confirmService.confirm(translate('toasts.confirm-delete-theme'))) {
return;
}
const pref = Object.assign({}, this.user.preferences);
pref.theme = theme;
this.accountService.updatePreferences(pref).subscribe();
this.themeService.deleteTheme(theme.id).subscribe(_ => {
this.removeDownloadedTheme(theme);
this.loadDownloadableThemes();
});
}
removeDownloadedTheme(theme: SiteTheme) {
this.selectedTheme = undefined;
this.downloadableThemes = this.downloadableThemes.filter(d => d.name !== theme.name);
this.cdRef.markForCheck();
}
applyTheme(theme: SiteTheme) {
this.accountService.currentUser$.pipe(take(1)).subscribe(user => {
if (!user) return;
const pref = Object.assign({}, user.preferences);
pref.theme = theme;
this.accountService.updatePreferences(pref).subscribe();
// Updating theme emits the new theme to load on the themes$
});
}
updateDefault(theme: SiteTheme) {
this.themeService.setDefault(theme.id).subscribe(() => {
this.toastr.success(this.translocService.translate('theme-manager.updated-toastr', {name: theme.name}));
this.toastr.success(translate('theme-manager.updated-toastr', {name: theme.name}));
});
}
scan() {
this.themeService.scan().subscribe(() => {
this.toastr.info(this.translocService.translate('theme-manager.scan-queued'));
selectTheme(theme: SiteTheme | DownloadableSiteTheme) {
if (theme.hasOwnProperty('provider')) {
this.selectedTheme = {
isSiteTheme: true,
site: theme as SiteTheme,
name: theme.name
};
} else {
this.selectedTheme = {
isSiteTheme: false,
downloadable: theme as DownloadableSiteTheme,
name: theme.name
};
}
this.cdRef.markForCheck();
}
downloadTheme(theme: DownloadableSiteTheme) {
this.themeService.downloadTheme(theme).subscribe(theme => {
this.removeDownloadedTheme(theme);
});
}
public dropped(files: NgxFileDropEntry[]) {
this.files = files;
for (const droppedFile of files) {
// Is it a file?
if (droppedFile.fileEntry.isFile) {
const fileEntry = droppedFile.fileEntry as FileSystemFileEntry;
fileEntry.file((file: File) => {
this.themeService.uploadTheme(file, droppedFile).subscribe(t => {
this.isUploadingTheme = false;
this.cdRef.markForCheck();
});
});
}
}
this.isUploadingTheme = true;
this.cdRef.markForCheck();
}
}

View File

@ -182,15 +182,22 @@
"theme-manager": {
"title": "Theme Manager",
"looking-for-theme": "Looking for a light or e-ink theme? We have some custom themes you can use on our ",
"looking-for-theme-continued": "theme github.",
"scan": "Scan",
"description": "Kavita comes in my colors, find a color scheme that meets your needs or build one yourself and share it. Themes may be applied for your account or applied to all accounts.",
"site-themes": "Site Themes",
"set-default": "Set Default",
"default-theme": "Default theme",
"download": "{{changelog.download}}",
"apply": "{{common.apply}}",
"applied": "Applied",
"active-theme": "Active",
"updated-toastr": "Site default has been updated to {{name}}",
"scan-queued": "A site theme scan has been queued"
"scan-queued": "A site theme scan has been queued",
"delete": "{{common.delete}}",
"drag-n-drop": "{{cover-image-chooser.drag-n-drop}}",
"upload": "{{cover-image-chooser.upload}}",
"upload-continued": "a css file",
"preview-default": "Select a theme first",
"preview-default-admin": "Select a theme first or upload one manually"
},
"theme": {
@ -212,7 +219,7 @@
"site-theme-provider-pipe": {
"system": "System",
"user": "User"
"custom": "{{device-platform-pipe.custom}}"
},
"manage-devices": {
@ -1583,14 +1590,17 @@
"promote-tooltip": "Promotion means that the collection can be seen server-wide, not just for you. All series within this collection will still have user-access restrictions placed on them."
},
"browse-themes-modal": {
"title": "Browse Themes"
},
"import-mal-collection-modal": {
"close": "{{common.close}}",
"title": "MAL Interest Stack Import",
"description": "Import your MAL Interest Stacks and create Collections within Kavita",
"series-count": "{{common.series-count}}",
"restack-count": "{{num}} Restacks"
"restack-count": "{{num}} Restacks",
"nothing-found": ""
},
"edit-chapter-progress": {
@ -1946,7 +1956,10 @@
"rejected-cover-upload": "The image could not be fetched due to server refusing request. Please download and upload from file instead.",
"invalid-confirmation-url": "Invalid confirmation url",
"invalid-confirmation-email": "Invalid confirmation email",
"invalid-password-reset-url": "Invalid reset password url"
"invalid-password-reset-url": "Invalid reset password url",
"delete-theme-in-use": "Theme is currently in use by at least one user, cannot delete",
"theme-manual-upload": "There was an issue creating Theme from manual upload",
"theme-already-in-use": "Theme already exists by that name"
},
"metadata-builder": {
@ -2185,7 +2198,8 @@
"confirm-delete-collections": "Are you sure you want to delete multiple collections?",
"collections-deleted": "Collections deleted",
"pdf-book-mode-screen-size": "Screen too small for Book mode",
"stack-imported": "Stack Imported"
"stack-imported": "Stack Imported",
"confirm-delete-theme": "Removing this theme will delete it from the disk. You can grab it from temp directory before removal"
},

View File

@ -7,7 +7,7 @@
"name": "GPL-3.0",
"url": "https://github.com/Kareadita/Kavita/blob/develop/LICENSE"
},
"version": "0.8.1.0"
"version": "0.8.1.3"
},
"servers": [
{
@ -11968,16 +11968,52 @@
}
}
}
}
},
"/api/Theme/scan": {
"post": {
},
"delete": {
"tags": [
"Theme"
],
"summary": "Attempts to delete a theme. If already in use by users, will not allow",
"parameters": [
{
"name": "themeId",
"in": "query",
"description": "",
"schema": {
"type": "integer",
"format": "int32"
}
}
],
"responses": {
"200": {
"description": "Success"
"description": "Success",
"content": {
"text/plain": {
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/DownloadableSiteThemeDto"
}
}
},
"application/json": {
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/DownloadableSiteThemeDto"
}
}
},
"text/json": {
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/DownloadableSiteThemeDto"
}
}
}
}
}
}
}
@ -12053,6 +12089,145 @@
}
}
},
"/api/Theme/browse": {
"get": {
"tags": [
"Theme"
],
"summary": "Browse themes that can be used on this server",
"responses": {
"200": {
"description": "Success",
"content": {
"text/plain": {
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/DownloadableSiteThemeDto"
}
}
},
"application/json": {
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/DownloadableSiteThemeDto"
}
}
},
"text/json": {
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/DownloadableSiteThemeDto"
}
}
}
}
}
}
}
},
"/api/Theme/download-theme": {
"post": {
"tags": [
"Theme"
],
"summary": "Downloads a SiteTheme from upstream",
"requestBody": {
"description": "",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/DownloadableSiteThemeDto"
}
},
"text/json": {
"schema": {
"$ref": "#/components/schemas/DownloadableSiteThemeDto"
}
},
"application/*+json": {
"schema": {
"$ref": "#/components/schemas/DownloadableSiteThemeDto"
}
}
}
},
"responses": {
"200": {
"description": "Success",
"content": {
"text/plain": {
"schema": {
"$ref": "#/components/schemas/SiteThemeDto"
}
},
"application/json": {
"schema": {
"$ref": "#/components/schemas/SiteThemeDto"
}
},
"text/json": {
"schema": {
"$ref": "#/components/schemas/SiteThemeDto"
}
}
}
}
}
}
},
"/api/Theme/upload-theme": {
"post": {
"tags": [
"Theme"
],
"summary": "Uploads a new theme file",
"requestBody": {
"content": {
"multipart/form-data": {
"schema": {
"type": "object",
"properties": {
"formFile": {
"type": "string",
"format": "binary"
}
}
},
"encoding": {
"formFile": {
"style": "form"
}
}
}
}
},
"responses": {
"200": {
"description": "Success",
"content": {
"text/plain": {
"schema": {
"$ref": "#/components/schemas/SiteThemeDto"
}
},
"application/json": {
"schema": {
"$ref": "#/components/schemas/SiteThemeDto"
}
},
"text/json": {
"schema": {
"$ref": "#/components/schemas/SiteThemeDto"
}
}
}
}
}
}
},
"/api/Upload/upload-by-url": {
"post": {
"tags": [
@ -15777,6 +15952,67 @@
},
"additionalProperties": false
},
"DownloadableSiteThemeDto": {
"type": "object",
"properties": {
"name": {
"type": "string",
"description": "Theme Name",
"nullable": true
},
"cssUrl": {
"type": "string",
"description": "Url to download css file",
"nullable": true
},
"cssFile": {
"type": "string",
"nullable": true
},
"previewUrls": {
"type": "array",
"items": {
"type": "string"
},
"description": "Url to preview image",
"nullable": true
},
"alreadyDownloaded": {
"type": "boolean",
"description": "If Already downloaded"
},
"sha": {
"type": "string",
"description": "Sha of the file",
"nullable": true
},
"path": {
"type": "string",
"description": "Path of the Folder the files reside in",
"nullable": true
},
"author": {
"type": "string",
"description": "Author of the theme",
"nullable": true
},
"lastCompatibleVersion": {
"type": "string",
"description": "Last version tested against",
"nullable": true
},
"isCompatible": {
"type": "boolean",
"description": "If version compatible with version"
},
"description": {
"type": "string",
"description": "Small blurb about the Theme",
"nullable": true
}
},
"additionalProperties": false
},
"EmailTestResultDto": {
"type": "object",
"properties": {
@ -20117,6 +20353,33 @@
"lastModifiedUtc": {
"type": "string",
"format": "date-time"
},
"gitHubPath": {
"type": "string",
"description": "The Url on the repo to download the file from",
"nullable": true
},
"shaHash": {
"type": "string",
"description": "Hash of the Css File",
"nullable": true
},
"previewUrls": {
"type": "string",
"description": "Pipe (|) separated urls of the images. Empty string if",
"nullable": true
},
"description": {
"type": "string",
"nullable": true
},
"author": {
"type": "string",
"nullable": true
},
"compatibleVersion": {
"type": "string",
"nullable": true
}
},
"additionalProperties": false,
@ -20157,6 +20420,28 @@
"description": "Where did the theme come from",
"format": "int32"
},
"previewUrls": {
"type": "array",
"items": {
"type": "string"
},
"nullable": true
},
"description": {
"type": "string",
"description": "Information about the theme",
"nullable": true
},
"author": {
"type": "string",
"description": "Author of the Theme (only applies to non-system provided themes)",
"nullable": true
},
"compatibleVersion": {
"type": "string",
"description": "Last compatible version. System provided will always be most current",
"nullable": true
},
"selector": {
"type": "string",
"nullable": true,
@ -20239,7 +20524,8 @@
5,
6,
7,
8
8,
9
],
"type": "integer",
"format": "int32"
@ -21303,7 +21589,7 @@
"format": "int32"
},
"theme": {
"$ref": "#/components/schemas/SiteTheme"
"$ref": "#/components/schemas/SiteThemeDto"
},
"bookReaderThemeName": {
"minLength": 1,