Theme Viewer + Theme Updater (#2952)
This commit is contained in:
parent
24302d4fcc
commit
38e7c1c131
35 changed files with 4563 additions and 284 deletions
|
@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -391,4 +391,6 @@ public class UploadController : BaseApiController
|
|||
return BadRequest(await _localizationService.Translate(User.GetUserId(), "reset-chapter-lock"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
}
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
|
||||
|
||||
|
|
52
API/DTOs/Theme/DownloadableSiteThemeDto.cs
Normal file
52
API/DTOs/Theme/DownloadableSiteThemeDto.cs
Normal 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; }
|
||||
}
|
|
@ -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();
|
||||
}
|
||||
|
|
|
@ -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]
|
||||
|
|
3043
API/Data/Migrations/20240510134030_SiteThemeFields.Designer.cs
generated
Normal file
3043
API/Data/Migrations/20240510134030_SiteThemeFields.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load diff
78
API/Data/Migrations/20240510134030_SiteThemeFields.cs
Normal file
78
API/Data/Migrations/20240510134030_SiteThemeFields.cs
Normal 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");
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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");
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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,
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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 =>
|
||||
|
|
|
@ -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);
|
||||
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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>();
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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");
|
||||
});
|
||||
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue