Compare commits

...
Sign in to create a new pull request.

11 commits

Author SHA1 Message Date
Joseph Milazzo
e63c942759 Added a locale string 2024-07-13 13:36:02 -05:00
Joseph Milazzo
2bf9adc7af Small change 2024-07-13 12:59:58 -05:00
Joseph Milazzo
01185bdb0a Fixed up the wiring for uploading by url. Method needs to be implemented. 2024-07-13 12:54:02 -05:00
Joseph Milazzo
5f1ea3c306 Fixed a bad rename for EB Garamond font family. 2024-07-13 12:35:53 -05:00
Joseph Milazzo
19be1f2bbb First pass at cleaning up the UI flow to mimic closer to the Theme manager. 2024-07-13 12:32:51 -05:00
Joseph Milazzo
58800c0b4e Refactored how system fonts were loaded (at least covered for local development) so that instead of going through the api, they are instead resolved by using assets/fonts/{fontName}/{fontFile}. 2024-07-13 11:52:23 -05:00
Joseph Milazzo
9fae799c63 Setup gitignore 2024-07-13 08:24:35 -05:00
Joseph Milazzo
85c8a13beb Merge branch 'feature/user-fonts' into feature/font-manager 2024-07-13 08:24:00 -05:00
Fesaa
a956bb18ec
UI centered font management 2024-07-09 15:57:47 +02:00
Fesaa
1f2ea8f59d
Merge branch 'refs/heads/develop' into feature/user-fonts
# Conflicts:
#	API/Services/TaskScheduler.cs
2024-07-08 21:19:07 +02:00
Amelia
d77090beff
First go
will add comments in draft pull request
2024-06-26 18:32:01 +02:00
112 changed files with 4408 additions and 94 deletions

1
.gitignore vendored
View file

@ -508,6 +508,7 @@ UI/Web/dist/
/API/config/logs/
/API/config/backups/
/API/config/cache/
/API/config/fonts/
/API/config/temp/
/API/config/themes/
/API/config/stats/

View file

@ -191,6 +191,7 @@
</ItemGroup>
<ItemGroup>
<Folder Include="config\fonts\" />
<Folder Include="config\themes" />
<Content Include="EmailTemplates\**">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>

View file

@ -0,0 +1,138 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
using API.Constants;
using API.Data;
using API.DTOs.Font;
using API.Entities.Enums.Font;
using API.Extensions;
using API.Services;
using API.Services.Tasks;
using API.Services.Tasks.Scanner.Parser;
using AutoMapper;
using Kavita.Common;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using MimeTypes;
namespace API.Controllers;
[Authorize]
public class FontController : BaseApiController
{
private readonly IUnitOfWork _unitOfWork;
private readonly IDirectoryService _directoryService;
private readonly IFontService _fontService;
private readonly IMapper _mapper;
private readonly ILocalizationService _localizationService;
private readonly Regex _fontFileExtensionRegex = new(Parser.FontFileExtensions, RegexOptions.IgnoreCase, Parser.RegexTimeout);
public FontController(IUnitOfWork unitOfWork, IDirectoryService directoryService,
IFontService fontService, IMapper mapper, ILocalizationService localizationService)
{
_unitOfWork = unitOfWork;
_directoryService = directoryService;
_fontService = fontService;
_mapper = mapper;
_localizationService = localizationService;
}
/// <summary>
/// List out the fonts
/// </summary>
/// <returns></returns>
[ResponseCache(CacheProfileName = ResponseCacheProfiles.TenMinute)]
[HttpGet("all")]
public async Task<ActionResult<IEnumerable<EpubFontDto>>> GetFonts()
{
return Ok(await _unitOfWork.EpubFontRepository.GetFontDtosAsync());
}
/// <summary>
/// Returns a font
/// </summary>
/// <param name="fontId"></param>
/// <param name="apiKey"></param>
/// <returns></returns>
[HttpGet]
[AllowAnonymous]
public async Task<IActionResult> GetFont(int fontId, string apiKey)
{
var userId = await _unitOfWork.UserRepository.GetUserIdByApiKeyAsync(apiKey);
if (userId == 0) return BadRequest();
var font = await _unitOfWork.EpubFontRepository.GetFontAsync(fontId);
if (font == null) return NotFound();
if (font.Provider == FontProvider.System) return BadRequest("System provided fonts are not loaded by API");
var contentType = MimeTypeMap.GetMimeType(Path.GetExtension(font.FileName));
var path = Path.Join(_directoryService.EpubFontDirectory, font.FileName);
return PhysicalFile(path, contentType, true);
}
/// <summary>
/// Removes a font from the system
/// </summary>
/// <param name="fontId"></param>
/// <param name="confirmed">If the font is in use by other users and an admin wants it deleted, they must confirm to force delete it</param>
/// <returns></returns>
[HttpDelete]
public async Task<IActionResult> DeleteFont(int fontId, bool confirmed = false)
{
// TODO: We need to check if this font is used by anyone else and if so, need to inform the user
// Need to check if this is a system font as well
var forceDelete = User.IsInRole(PolicyConstants.AdminRole) && confirmed;
await _fontService.Delete(fontId);
return Ok();
}
/// <summary>
/// Manual upload
/// </summary>
/// <param name="formFile"></param>
/// <returns></returns>
[HttpPost("upload")]
public async Task<ActionResult<EpubFontDto>> UploadFont(IFormFile formFile)
{
if (!_fontFileExtensionRegex.IsMatch(Path.GetExtension(formFile.FileName))) return BadRequest("Invalid file");
if (formFile.FileName.Contains("..")) return BadRequest("Invalid file");
var tempFile = await UploadToTemp(formFile);
var font = await _fontService.CreateFontFromFileAsync(tempFile);
return Ok(_mapper.Map<EpubFontDto>(font));
}
[HttpPost("upload-by-url")]
public async Task<ActionResult> UploadFontByUrl([FromQuery]string url)
{
// Validate url
try
{
await _fontService.CreateFontFromUrl(url);
}
catch (KavitaException ex)
{
return BadRequest(_localizationService.Translate(User.GetUserId(), ex.Message));
}
return Ok();
}
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

@ -40,7 +40,7 @@ public class ThemeController : BaseApiController
_mapper = mapper;
}
[ResponseCache(CacheProfileName = "10Minute")]
[ResponseCache(CacheProfileName = ResponseCacheProfiles.TenMinute)]
[AllowAnonymous]
[HttpGet]
public async Task<ActionResult<IEnumerable<SiteThemeDto>>> GetThemes()

View file

@ -0,0 +1,13 @@
using System;
using API.Entities.Enums.Font;
namespace API.DTOs.Font;
public class EpubFontDto
{
public int Id { get; set; }
public string Name { get; set; }
public FontProvider Provider { get; set; }
public string FileName { get; set; }
}

View file

@ -66,6 +66,7 @@ public sealed class DataContext : IdentityDbContext<AppUser, AppRole, int,
public DbSet<ManualMigrationHistory> ManualMigrationHistory { get; set; } = null!;
public DbSet<SeriesBlacklist> SeriesBlacklist { get; set; } = null!;
public DbSet<AppUserCollection> AppUserCollection { get; set; } = null!;
public DbSet<EpubFont> EpubFont { get; set; } = null!;
protected override void OnModelCreating(ModelBuilder builder)

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,42 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace API.Data.Migrations
{
/// <inheritdoc />
public partial class EpubFontInitial : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "EpubFont",
columns: table => new
{
Id = table.Column<int>(type: "INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
Name = table.Column<string>(type: "TEXT", nullable: true),
NormalizedName = table.Column<string>(type: "TEXT", nullable: true),
FileName = table.Column<string>(type: "TEXT", nullable: true),
Provider = table.Column<int>(type: "INTEGER", nullable: false),
Created = table.Column<DateTime>(type: "TEXT", nullable: false),
CreatedUtc = table.Column<DateTime>(type: "TEXT", nullable: false),
LastModified = table.Column<DateTime>(type: "TEXT", nullable: false),
LastModifiedUtc = table.Column<DateTime>(type: "TEXT", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_EpubFont", x => x.Id);
});
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "EpubFont");
}
}
}

View file

@ -15,7 +15,7 @@ namespace API.Data.Migrations
protected override void BuildModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "8.0.4");
modelBuilder.HasAnnotation("ProductVersion", "8.0.6");
modelBuilder.Entity("API.Entities.AppRole", b =>
{
@ -907,6 +907,41 @@ namespace API.Data.Migrations
b.ToTable("Device");
});
modelBuilder.Entity("API.Entities.EpubFont", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("Created")
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedUtc")
.HasColumnType("TEXT");
b.Property<string>("FileName")
.HasColumnType("TEXT");
b.Property<DateTime>("LastModified")
.HasColumnType("TEXT");
b.Property<DateTime>("LastModifiedUtc")
.HasColumnType("TEXT");
b.Property<string>("Name")
.HasColumnType("TEXT");
b.Property<string>("NormalizedName")
.HasColumnType("TEXT");
b.Property<int>("Provider")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.ToTable("EpubFont");
});
modelBuilder.Entity("API.Entities.FolderPath", b =>
{
b.Property<int>("Id")

View file

@ -0,0 +1,101 @@
#nullable enable
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using API.DTOs.Font;
using API.Entities;
using API.Extensions;
using AutoMapper;
using AutoMapper.QueryableExtensions;
using Microsoft.EntityFrameworkCore;
namespace API.Data.Repositories;
public interface IEpubFontRepository
{
void Add(EpubFont font);
void Remove(EpubFont font);
void Update(EpubFont font);
Task<IEnumerable<EpubFontDto>> GetFontDtosAsync();
Task<EpubFontDto?> GetFontDtoAsync(int fontId);
Task<EpubFontDto?> GetFontDtoByNameAsync(string name);
Task<IEnumerable<EpubFont>> GetFontsAsync();
Task<EpubFont?> GetFontAsync(int fontId);
Task<bool> IsFontInUseAsync(int fontId);
}
public class EpubFontRepository: IEpubFontRepository
{
private readonly DataContext _context;
private readonly IMapper _mapper;
public EpubFontRepository(DataContext context, IMapper mapper)
{
_context = context;
_mapper = mapper;
}
public void Add(EpubFont font)
{
_context.Add(font);
}
public void Remove(EpubFont font)
{
_context.Remove(font);
}
public void Update(EpubFont font)
{
_context.Entry(font).State = EntityState.Modified;
}
public async Task<IEnumerable<EpubFontDto>> GetFontDtosAsync()
{
return await _context.EpubFont
.OrderBy(s => s.Name == "Default" ? -1 : 0)
.ThenBy(s => s)
.ProjectTo<EpubFontDto>(_mapper.ConfigurationProvider)
.ToListAsync();
}
public async Task<EpubFontDto?> GetFontDtoAsync(int fontId)
{
return await _context.EpubFont
.Where(f => f.Id == fontId)
.ProjectTo<EpubFontDto>(_mapper.ConfigurationProvider)
.FirstOrDefaultAsync();
}
public async Task<EpubFontDto?> GetFontDtoByNameAsync(string name)
{
return await _context.EpubFont
.Where(f => f.NormalizedName.Equals(name.ToNormalized()))
.ProjectTo<EpubFontDto>(_mapper.ConfigurationProvider)
.FirstOrDefaultAsync();
}
public async Task<IEnumerable<EpubFont>> GetFontsAsync()
{
return await _context.EpubFont
.ToListAsync();
}
public async Task<EpubFont?> GetFontAsync(int fontId)
{
return await _context.EpubFont
.Where(f => f.Id == fontId)
.FirstOrDefaultAsync();
}
public async Task<bool> IsFontInUseAsync(int fontId)
{
return await _context.AppUserPreferences
.Join(_context.EpubFont,
preference => preference.BookReaderFontFamily,
font => font.Name,
(preference, font) => new { preference, font })
.AnyAsync(joined => joined.font.Id == fontId);
}
}

View file

@ -76,6 +76,7 @@ public interface IUserRepository
Task<IList<AppUserBookmark>> GetAllBookmarksByIds(IList<int> bookmarkIds);
Task<AppUser?> GetUserByEmailAsync(string email, AppUserIncludes includes = AppUserIncludes.None);
Task<IEnumerable<AppUserPreferences>> GetAllPreferencesByThemeAsync(int themeId);
Task<IEnumerable<AppUserPreferences>> GetAllPreferencesByFontAsync(string fontName);
Task<bool> HasAccessToLibrary(int libraryId, int userId);
Task<bool> HasAccessToSeries(int userId, int seriesId);
Task<IEnumerable<AppUser>> GetAllUsersAsync(AppUserIncludes includeFlags = AppUserIncludes.None);
@ -260,6 +261,14 @@ public class UserRepository : IUserRepository
.ToListAsync();
}
public async Task<IEnumerable<AppUserPreferences>> GetAllPreferencesByFontAsync(string fontName)
{
return await _context.AppUserPreferences
.Where(p => p.BookReaderFontFamily == fontName)
.AsSplitQuery()
.ToListAsync();
}
public async Task<bool> HasAccessToLibrary(int libraryId, int userId)
{
return await _context.Library

View file

@ -9,9 +9,11 @@ using API.Constants;
using API.Data.Repositories;
using API.Entities;
using API.Entities.Enums;
using API.Entities.Enums.Font;
using API.Entities.Enums.Theme;
using API.Extensions;
using API.Services;
using API.Services.Tasks.Scanner.Parser;
using Kavita.Common;
using Kavita.Common.EnvironmentInfo;
using Microsoft.AspNetCore.Identity;
@ -26,6 +28,97 @@ public static class Seed
/// </summary>
public static ImmutableArray<ServerSetting> DefaultSettings;
public static readonly ImmutableArray<EpubFont> DefaultFonts =
[
..new List<EpubFont>
{
new ()
{
Name = "Default",
NormalizedName = Parser.Normalize("Default"),
Provider = FontProvider.System,
FileName = string.Empty,
},
new ()
{
Name = "Merriweather",
NormalizedName = Parser.Normalize("Merriweather"),
Provider = FontProvider.System,
FileName = "Merriweather-Regular.woff2",
},
new ()
{
Name = "EB Garamond",
NormalizedName = Parser.Normalize("EB Garamond"),
Provider = FontProvider.System,
FileName = "EBGaramond-VariableFont_wght.woff2",
},
new ()
{
Name = "Fira Sans",
NormalizedName = Parser.Normalize("Fira Sans"),
Provider = FontProvider.System,
FileName = "FiraSans-Regular.woff2",
},
new ()
{
Name = "Lato",
NormalizedName = Parser.Normalize("Lato"),
Provider = FontProvider.System,
FileName = "Lato-Regular.woff2",
},
new ()
{
Name = "Libre Baskerville",
NormalizedName = Parser.Normalize("Libre Baskerville"),
Provider = FontProvider.System,
FileName = "LibreBaskerville-Regular.woff2",
},
new ()
{
Name = "Libre Caslon",
NormalizedName = Parser.Normalize("Libre Caslon"),
Provider = FontProvider.System,
FileName = "LibreCaslonText-Regular.woff2",
},
new ()
{
Name = "Nanum Gothic",
NormalizedName = Parser.Normalize("Nanum Gothic"),
Provider = FontProvider.System,
FileName = "NanumGothic-Regular.woff2",
},
new ()
{
Name = "Open Dyslexic 2",
NormalizedName = Parser.Normalize("Open Dyslexic 2"),
Provider = FontProvider.System,
FileName = "OpenDyslexic-Regular.woff2",
},
new ()
{
Name = "Oswald",
NormalizedName = Parser.Normalize("Oswald"),
Provider = FontProvider.System,
FileName = "Oswald-VariableFont_wght.woff2",
},
new ()
{
Name = "RocknRoll One",
NormalizedName = Parser.Normalize("RocknRoll One"),
Provider = FontProvider.System,
FileName = "RocknRollOne-Regular.woff2",
},
new ()
{
Name = "Spartan",
NormalizedName = Parser.Normalize("Spartan"),
Provider = FontProvider.System,
FileName = "Spartan-VariableFont_wght.woff2",
},
}
];
public static readonly ImmutableArray<SiteTheme> DefaultThemes = [
..new List<SiteTheme>
{
@ -143,7 +236,7 @@ public static class Seed
foreach (var theme in DefaultThemes)
{
var existing = context.SiteTheme.FirstOrDefault(s => s.Name.Equals(theme.Name));
var existing = await context.SiteTheme.FirstOrDefaultAsync(s => s.Name.Equals(theme.Name));
if (existing == null)
{
await context.SiteTheme.AddAsync(theme);
@ -153,6 +246,22 @@ public static class Seed
await context.SaveChangesAsync();
}
public static async Task SeedFonts(DataContext context)
{
await context.Database.EnsureCreatedAsync();
foreach (var font in DefaultFonts)
{
var existing = await context.EpubFont.FirstOrDefaultAsync(f => f.Name.Equals(font.Name));
if (existing == null)
{
await context.EpubFont.AddAsync(font);
}
}
await context.SaveChangesAsync();
}
public static async Task SeedDefaultStreams(IUnitOfWork unitOfWork)
{
var allUsers = await unitOfWork.UserRepository.GetAllUsersAsync(AppUserIncludes.DashboardStreams);
@ -259,7 +368,7 @@ public static class Seed
foreach (var defaultSetting in DefaultSettings)
{
var existing = context.ServerSetting.FirstOrDefault(s => s.Key == defaultSetting.Key);
var existing = await context.ServerSetting.FirstOrDefaultAsync(s => s.Key == defaultSetting.Key);
if (existing == null)
{
await context.ServerSetting.AddAsync(defaultSetting);
@ -269,15 +378,15 @@ public static class Seed
await context.SaveChangesAsync();
// Port, IpAddresses and LoggingLevel are managed in appSettings.json. Update the DB values to match
context.ServerSetting.First(s => s.Key == ServerSettingKey.Port).Value =
(await context.ServerSetting.FirstAsync(s => s.Key == ServerSettingKey.Port)).Value =
Configuration.Port + string.Empty;
context.ServerSetting.First(s => s.Key == ServerSettingKey.IpAddresses).Value =
(await context.ServerSetting.FirstAsync(s => s.Key == ServerSettingKey.IpAddresses)).Value =
Configuration.IpAddresses;
context.ServerSetting.First(s => s.Key == ServerSettingKey.CacheDirectory).Value =
(await context.ServerSetting.FirstAsync(s => s.Key == ServerSettingKey.CacheDirectory)).Value =
directoryService.CacheDirectory + string.Empty;
context.ServerSetting.First(s => s.Key == ServerSettingKey.BackupDirectory).Value =
(await context.ServerSetting.FirstAsync(s => s.Key == ServerSettingKey.BackupDirectory)).Value =
DirectoryService.BackupDirectory + string.Empty;
context.ServerSetting.First(s => s.Key == ServerSettingKey.CacheSize).Value =
(await context.ServerSetting.FirstAsync(s => s.Key == ServerSettingKey.CacheSize)).Value =
Configuration.CacheSize + string.Empty;
await context.SaveChangesAsync();

View file

@ -31,6 +31,7 @@ public interface IUnitOfWork
IAppUserSmartFilterRepository AppUserSmartFilterRepository { get; }
IAppUserExternalSourceRepository AppUserExternalSourceRepository { get; }
IExternalSeriesMetadataRepository ExternalSeriesMetadataRepository { get; }
IEpubFontRepository EpubFontRepository { get; }
bool Commit();
Task<bool> CommitAsync();
bool HasChanges();
@ -74,6 +75,7 @@ public class UnitOfWork : IUnitOfWork
public IAppUserSmartFilterRepository AppUserSmartFilterRepository => new AppUserSmartFilterRepository(_context, _mapper);
public IAppUserExternalSourceRepository AppUserExternalSourceRepository => new AppUserExternalSourceRepository(_context, _mapper);
public IExternalSeriesMetadataRepository ExternalSeriesMetadataRepository => new ExternalSeriesMetadataRepository(_context, _mapper);
public IEpubFontRepository EpubFontRepository => new EpubFontRepository(_context, _mapper);
/// <summary>
/// Commits changes to the DB. Completes the open transaction.

View file

@ -0,0 +1,13 @@
namespace API.Entities.Enums.Font;
public enum FontProvider
{
/// <summary>
/// Font is provider by System, always avaible
/// </summary>
System = 1,
/// <summary>
/// Font provider by the User
/// </summary>
User = 2,
}

37
API/Entities/EpubFont.cs Normal file
View file

@ -0,0 +1,37 @@
using System;
using API.Entities.Enums.Font;
using API.Entities.Interfaces;
using API.Services;
namespace API.Entities;
/// <summary>
/// Represents a user provider font to be used in the epub reader
/// </summary>
public class EpubFont: IEntityDate
{
public int Id { get; set; }
/// <summary>
/// Name of the font
/// </summary>
public required string Name { get; set; }
/// <summary>
/// Normalized name for lookups
/// </summary>
public required string NormalizedName { get; set; }
/// <summary>
/// Filename of the font, stored under <see cref="DirectoryService.EpubFontDirectory"/>
/// </summary>
/// <remarks>System provided fonts use an alternative location as they are packaged with the app</remarks>
public required string FileName { get; set; }
/// <summary>
/// Where the font came from
/// </summary>
public FontProvider Provider { get; set; }
public DateTime Created { get; set; }
public DateTime CreatedUtc { get; set; }
public DateTime LastModified { get; set; }
public DateTime LastModifiedUtc { get; set; }
}

View file

@ -53,6 +53,7 @@ public static class ApplicationServiceExtensions
services.AddScoped<IMediaConversionService, MediaConversionService>();
services.AddScoped<IRecommendationService, RecommendationService>();
services.AddScoped<IStreamService, StreamService>();
services.AddScoped<IFontService, FontService>();
services.AddScoped<IScannerService, ScannerService>();
services.AddScoped<IMetadataService, MetadataService>();

View file

@ -10,6 +10,7 @@ using API.DTOs.Dashboard;
using API.DTOs.Device;
using API.DTOs.Filtering;
using API.DTOs.Filtering.v2;
using API.DTOs.Font;
using API.DTOs.MediaErrors;
using API.DTOs.Metadata;
using API.DTOs.Progress;
@ -262,6 +263,8 @@ public class AutoMapperProfiles : Profile
opt =>
opt.MapFrom(src => src.BookReaderLayoutMode));
CreateMap<EpubFont, EpubFontDto>();
CreateMap<AppUserBookmark, BookmarkDto>();

View file

@ -218,6 +218,9 @@
"scan-libraries": "Scan Libraries",
"kavita+-data-refresh": "Kavita+ Data Refresh",
"backup": "Backup",
"update-yearly-stats": "Update Yearly Stats"
"update-yearly-stats": "Update Yearly Stats",
"font-url-not-allowed": "Uploading a Font by url is only allowed from Google Fonts"
}

View file

@ -126,6 +126,7 @@ public class Program
await Seed.SeedRoles(services.GetRequiredService<RoleManager<AppRole>>());
await Seed.SeedSettings(context, directoryService);
await Seed.SeedThemes(context);
await Seed.SeedFonts(context);
await Seed.SeedDefaultStreams(unitOfWork);
await Seed.SeedDefaultSideNavStreams(unitOfWork);
await Seed.SeedUserApiKeys(context);

View file

@ -33,6 +33,7 @@ public interface IDirectoryService
/// Original BookmarkDirectory. Only used for resetting directory. Use <see cref="ServerSettingKey.BackupDirectory"/> for actual path.
/// </summary>
string BookmarkDirectory { get; }
string EpubFontDirectory { get; }
/// <summary>
/// Lists out top-level folders for a given directory. Filters out System and Hidden folders.
/// </summary>
@ -88,6 +89,8 @@ public class DirectoryService : IDirectoryService
public string LocalizationDirectory { get; }
public string CustomizedTemplateDirectory { get; }
public string TemplateDirectory { get; }
public string EpubFontDirectory { get; }
private readonly ILogger<DirectoryService> _logger;
private const RegexOptions MatchOptions = RegexOptions.Compiled | RegexOptions.IgnoreCase;
@ -125,6 +128,8 @@ public class DirectoryService : IDirectoryService
ExistOrCreate(CustomizedTemplateDirectory);
TemplateDirectory = FileSystem.Path.Join(FileSystem.Directory.GetCurrentDirectory(), "EmailTemplates");
ExistOrCreate(TemplateDirectory);
EpubFontDirectory = FileSystem.Path.Join(FileSystem.Directory.GetCurrentDirectory(), "config", "fonts");
ExistOrCreate(EpubFontDirectory);
}
/// <summary>

View file

@ -0,0 +1,133 @@
using System;
using System.IO;
using System.Linq;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
using API.Data;
using API.Entities;
using API.Entities.Enums.Font;
using API.Services.Tasks.Scanner.Parser;
using API.SignalR;
using Kavita.Common;
using Microsoft.AspNetCore.SignalR;
using Microsoft.Extensions.Logging;
namespace API.Services.Tasks;
public interface IFontService
{
Task<EpubFont> CreateFontFromFileAsync(string path);
Task Delete(int fontId);
Task CreateFontFromUrl(string url);
}
public class FontService: IFontService
{
public static readonly string DefaultFont = "default";
private readonly IDirectoryService _directoryService;
private readonly IUnitOfWork _unitOfWork;
private readonly ILogger<FontService> _logger;
private readonly IEventHub _eventHub;
private const string SupportedFontUrlPrefix = "https://fonts.google.com/specimen/";
public FontService(IDirectoryService directoryService, IUnitOfWork unitOfWork, ILogger<FontService> logger, IEventHub eventHub)
{
_directoryService = directoryService;
_unitOfWork = unitOfWork;
_logger = logger;
_eventHub = eventHub;
}
public async Task<EpubFont> CreateFontFromFileAsync(string path)
{
if (!_directoryService.FileSystem.File.Exists(path))
{
_logger.LogInformation("Unable to create font from manual upload as font not in temp");
throw new KavitaException("errors.font-manual-upload");
}
var fileName = _directoryService.FileSystem.FileInfo.New(path).Name;
var nakedFileName = _directoryService.FileSystem.Path.GetFileNameWithoutExtension(fileName);
var fontName = Parser.PrettifyFileName(nakedFileName);
var normalizedName = Parser.Normalize(nakedFileName);
if (await _unitOfWork.EpubFontRepository.GetFontDtoByNameAsync(fontName) != null)
{
throw new KavitaException("errors.font-already-in-use");
}
_directoryService.CopyFileToDirectory(path, _directoryService.EpubFontDirectory);
var finalLocation = _directoryService.FileSystem.Path.Join(_directoryService.EpubFontDirectory, fileName);
var font = new EpubFont()
{
Name = fontName,
NormalizedName = normalizedName,
FileName = Path.GetFileName(finalLocation),
Provider = FontProvider.User
};
_unitOfWork.EpubFontRepository.Add(font);
await _unitOfWork.CommitAsync();
// TODO: Send update to UI
return font;
}
public async Task Delete(int fontId)
{
if (await _unitOfWork.EpubFontRepository.IsFontInUseAsync(fontId))
{
throw new KavitaException("errors.delete-font-in-use");
}
var font = await _unitOfWork.EpubFontRepository.GetFontAsync(fontId);
if (font == null) return;
await RemoveFont(font);
}
public Task CreateFontFromUrl(string url)
{
if (!url.StartsWith(SupportedFontUrlPrefix))
{
throw new KavitaException("font-url-not-allowed");
}
// Extract Font name from url
var fontFamily = url.Split(SupportedFontUrlPrefix)[1].Split("?")[0];
_logger.LogInformation("Preparing to download {FontName} font", fontFamily);
// TODO: Send a font update event
return Task.CompletedTask;
}
public async Task RemoveFont(EpubFont font)
{
if (font.Provider == FontProvider.System) return;
var prefs = await _unitOfWork.UserRepository.GetAllPreferencesByFontAsync(font.Name);
foreach (var pref in prefs)
{
pref.BookReaderFontFamily = DefaultFont;
_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, font.FileName);
var newLocation =
_directoryService.FileSystem.Path.Join(_directoryService.TempDirectory, font.FileName);
_directoryService.CopyFileToDirectory(existingLocation, newLocation);
_directoryService.DeleteFiles([existingLocation]);
}
catch (Exception) { /* Swallow */ }
_unitOfWork.EpubFontRepository.Remove(font);
await _unitOfWork.CommitAsync();
}
}

View file

@ -32,6 +32,7 @@ public static class Parser
private const string BookFileExtensions = EpubFileExtension + "|" + PdfFileExtension;
private const string XmlRegexExtensions = @"\.xml";
public const string MacOsMetadataFileStartsWith = @"._";
public const string FontFileExtensions = @"\.[woff2|tff|otf|woff]";
public const string SupportedExtensions =
ArchiveFileExtensions + "|" + ImageFileExtensions + "|" + BookFileExtensions;
@ -1259,4 +1260,12 @@ public static class Parser
}
return filename;
}
/**
* Replaced non-alphanumerical chars with a space
*/
public static string PrettifyFileName(string name)
{
return Regex.Replace(name, "[^a-zA-Z0-9]", " ");
}
}

View file

@ -0,0 +1,17 @@
/**
* Where does the font come from
*/
export enum FontProvider {
System = 1,
User = 2,
}
/**
* Font used in the book reader
*/
export interface EpubFont {
id: number;
name: string;
provider: FontProvider;
fileName: string;
}

View file

@ -0,0 +1,66 @@
import {DestroyRef, inject, Injectable} from "@angular/core";
import {map, ReplaySubject} from "rxjs";
import {EpubFont, FontProvider} from "../_models/preferences/epub-font";
import {environment} from 'src/environments/environment';
import {HttpClient} from "@angular/common/http";
import {MessageHubService} from "./message-hub.service";
import {NgxFileDropEntry} from "ngx-file-drop";
import {AccountService} from "./account.service";
import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
@Injectable({
providedIn: 'root'
})
export class FontService {
private readonly destroyRef = inject(DestroyRef);
public defaultEpubFont: string = 'default';
private fontsSource = new ReplaySubject<EpubFont[]>(1);
public fonts$ = this.fontsSource.asObservable();
baseUrl: string = environment.apiUrl;
apiKey: string = '';
encodedKey: string = '';
constructor(private httpClient: HttpClient, messageHub: MessageHubService, private accountService: AccountService) {
this.accountService.currentUser$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(user => {
if (user) {
this.apiKey = user.apiKey;
this.encodedKey = encodeURIComponent(this.apiKey);
}
});
}
getFonts() {
return this.httpClient.get<Array<EpubFont>>(this.baseUrl + 'font/all').pipe(map(fonts => {
this.fontsSource.next(fonts);
return fonts;
}));
}
getFontFace(font: EpubFont): FontFace {
// TODO: We need to refactor this so that we loadFonts with an array, fonts have an id to remove them, and we don't keep populating the document
if (font.provider === FontProvider.System) {
return new FontFace(font.name, `url('/assets/fonts/${font.name}/${font.fileName}')`);
}
return new FontFace(font.name, `url(${this.baseUrl}font?fontId=${font.id}&apiKey=${this.encodedKey})`);
}
uploadFont(fontFile: File, fileEntry: NgxFileDropEntry) {
const formData = new FormData();
formData.append('formFile', fontFile, fileEntry.relativePath);
return this.httpClient.post<EpubFont>(this.baseUrl + "font/upload", formData);
}
uploadFromUrl(url: string) {
return this.httpClient.post(this.baseUrl + "font/upload-by-url?url=" + encodeURIComponent(url), {});
}
deleteFont(id: number) {
return this.httpClient.delete(this.baseUrl + `font?fontId=${id}`);
}
}

View file

@ -1,45 +1,3 @@
@font-face {
font-family: "Fira_Sans";
src: url(../../../../assets/fonts/Fira_Sans/FiraSans-Regular.woff2) format("woff2");
font-display: swap;
}
@font-face {
font-family: "Lato";
src: url(../../../../assets/fonts/Lato/Lato-Regular.woff2) format("woff2");
font-display: swap;
}
@font-face {
font-family: "Libre_Baskerville";
src: url(../../../../assets/fonts/Libre_Baskerville/LibreBaskerville-Regular.woff2) format("woff2");
font-display: swap;
}
@font-face {
font-family: "Merriweather";
src: url(../../../../assets/fonts/Merriweather/Merriweather-Regular.woff2) format("woff2");
font-display: swap;
}
@font-face {
font-family: "Nanum_Gothic";
src: url(../../../../assets/fonts/Nanum_Gothic/NanumGothic-Regular.woff2) format("woff2");
font-display: swap;
}
@font-face {
font-family: "RocknRoll_One";
src: url(../../../../assets/fonts/RocknRoll_One/RocknRollOne-Regular.woff2) format("woff2");
font-display: swap;
}
@font-face {
font-family: "OpenDyslexic2";
src: url(../../../../assets/fonts/OpenDyslexic2/OpenDyslexic-Regular.woff2) format("woff2");
font-display: swap;
}
:root {
--br-actionbar-button-text-color: #6c757d;
--accordion-body-bg-color: black;

View file

@ -52,6 +52,7 @@ import {
PersonalToCEvent
} from "../personal-table-of-contents/personal-table-of-contents.component";
import {translate, TranslocoDirective} from "@ngneat/transloco";
import {FontService} from "../../../_services/font.service";
enum TabID {
@ -124,6 +125,7 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy {
private readonly libraryService = inject(LibraryService);
private readonly themeService = inject(ThemeService);
private readonly cdRef = inject(ChangeDetectorRef);
private readonly fontService = inject(FontService);
protected readonly BookPageLayoutMode = BookPageLayoutMode;
protected readonly WritingStyle = WritingStyle;
@ -583,6 +585,15 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy {
}
ngOnInit(): void {
this.fontService.getFonts().subscribe(fonts => {
fonts.forEach(font => {
this.fontService.getFontFace(font).load().then(loadedFace => {
console.log('loaded font: ', loadedFace);
(this.document as any).fonts.add(loadedFace);
});
})
})
const libraryId = this.route.snapshot.paramMap.get('libraryId');
const seriesId = this.route.snapshot.paramMap.get('seriesId');
const chapterId = this.route.snapshot.paramMap.get('chapterId');

View file

@ -16,7 +16,9 @@
<div class="mb-3">
<label for="library-type" class="form-label">{{t('font-family-label')}}</label>
<select class="form-select" id="library-type" formControlName="bookReaderFontFamily">
<option [value]="opt" *ngFor="let opt of fontOptions; let i = index">{{opt | titlecase}}</option>
@for(opt of fontFamilies; track opt) {
<option [value]="opt.name">{{opt.name | titlecase}}</option>
}
</select>
</div>
</div>

View file

@ -19,7 +19,7 @@ import { ThemeProvider } from 'src/app/_models/preferences/site-theme';
import { User } from 'src/app/_models/user';
import { AccountService } from 'src/app/_services/account.service';
import { ThemeService } from 'src/app/_services/theme.service';
import { FontFamily, BookService } from '../../_services/book.service';
import {BookService} from '../../_services/book.service';
import { BookBlackTheme } from '../../_models/book-black-theme';
import { BookDarkTheme } from '../../_models/book-dark-theme';
import { BookWhiteTheme } from '../../_models/book-white-theme';
@ -27,6 +27,8 @@ import { BookPaperTheme } from '../../_models/book-paper-theme';
import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
import { NgbAccordionDirective, NgbAccordionItem, NgbAccordionHeader, NgbAccordionToggle, NgbAccordionButton, NgbCollapse, NgbAccordionCollapse, NgbAccordionBody, NgbTooltip } from '@ng-bootstrap/ng-bootstrap';
import {TranslocoDirective} from "@ngneat/transloco";
import {FontService} from "../../../_services/font.service";
import {EpubFont} from "../../../_models/preferences/epub-font";
/**
* Used for book reader. Do not use for other components
@ -83,6 +85,7 @@ export const bookColorThemes = [
];
const mobileBreakpointMarginOverride = 700;
const defaultFontFamily = 'Default';
@Component({
selector: 'app-reader-settings',
@ -130,8 +133,7 @@ export class ReaderSettingsComponent implements OnInit {
/**
* List of all font families user can select from
*/
fontOptions: Array<string> = [];
fontFamilies: Array<FontFamily> = [];
fontFamilies: Array<EpubFont> = [];
/**
* Internal property used to capture all the different css properties to render on all elements
*/
@ -171,20 +173,20 @@ export class ReaderSettingsComponent implements OnInit {
constructor(private bookService: BookService, private accountService: AccountService,
@Inject(DOCUMENT) private document: Document, private themeService: ThemeService,
private readonly cdRef: ChangeDetectorRef) {}
private readonly cdRef: ChangeDetectorRef, private fontService: FontService) {}
ngOnInit(): void {
this.fontFamilies = this.bookService.getFontFamilies();
this.fontOptions = this.fontFamilies.map(f => f.title);
this.cdRef.markForCheck();
this.fontService.getFonts().subscribe(fonts => {
this.fontFamilies = fonts;
this.cdRef.markForCheck();
})
this.accountService.currentUser$.pipe(take(1)).subscribe(user => {
if (user) {
this.user = user;
if (this.user.preferences.bookReaderFontFamily === undefined) {
this.user.preferences.bookReaderFontFamily = 'default';
this.user.preferences.bookReaderFontFamily = defaultFontFamily;
}
if (this.user.preferences.bookReaderFontSize === undefined || this.user.preferences.bookReaderFontSize < 50) {
this.user.preferences.bookReaderFontSize = 100;
@ -208,11 +210,11 @@ export class ReaderSettingsComponent implements OnInit {
this.settingsForm.addControl('bookReaderFontFamily', new FormControl(this.user.preferences.bookReaderFontFamily, []));
this.settingsForm.get('bookReaderFontFamily')!.valueChanges.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(fontName => {
const familyName = this.fontFamilies.filter(f => f.title === fontName)[0].family;
if (familyName === 'default') {
console.log('updating font-family to ', fontName);
if (fontName === defaultFontFamily) {
this.pageStyles['font-family'] = 'inherit';
} else {
this.pageStyles['font-family'] = "'" + familyName + "'";
this.pageStyles['font-family'] = "'" + fontName + "'";
}
this.styleUpdate.emit(this.pageStyles);

View file

@ -5,17 +5,6 @@ import { environment } from 'src/environments/environment';
import { BookChapterItem } from '../_models/book-chapter-item';
import { BookInfo } from '../_models/book-info';
export interface FontFamily {
/**
* What the user should see
*/
title: string;
/**
* The actual font face
*/
family: string;
}
@Injectable({
providedIn: 'root'
})
@ -25,12 +14,6 @@ export class BookService {
constructor(private http: HttpClient) { }
getFontFamilies(): Array<FontFamily> {
return [{title: 'default', family: 'default'}, {title: 'EBGaramond', family: 'EBGaramond'}, {title: 'Fira Sans', family: 'Fira_Sans'},
{title: 'Lato', family: 'Lato'}, {title: 'Libre Baskerville', family: 'Libre_Baskerville'}, {title: 'Merriweather', family: 'Merriweather'},
{title: 'Nanum Gothic', family: 'Nanum_Gothic'}, {title: 'RocknRoll One', family: 'RocknRoll_One'}, {title: 'Open Dyslexic', family: 'OpenDyslexic2'}];
}
getBookChapters(chapterId: number) {
return this.http.get<Array<BookChapterItem>>(this.baseUrl + 'book/' + chapterId + '/chapters');
}

View file

@ -0,0 +1,144 @@
<ng-container *transloco="let t; read:'font-manager'">
<div class="container-fluid">
<div class="row mb-2">
<h3>{{t('title')}}</h3>
</div>
<p>{{t('description')}}</p>
<div class="row g-0 theme-container">
<div class="col-lg-3 col-md-5 col-sm-7 col-xs-7 scroller">
<div class="pe-2">
<ul style="height: 100%" class="list-group list-group-flush">
@for (font of fonts; track font.name) {
<ng-container [ngTemplateOutlet]="fontOption" [ngTemplateOutletContext]="{ $implicit: font}"></ng-container>
}
</ul>
</div>
</div>
<div class="col-lg-9 col-md-7 col-sm-4 col-xs-4 ps-3">
<div class="card p-3">
@if (selectedFont === undefined) {
<div class="row pb-4">
<div class="mx-auto">
<div class="d-flex justify-content-center">
<div class="d-flex justify-content-evenly">
{{t('preview-default')}}
</div>
</div>
</div>
</div>
@if (files && files.length > 0) {
<app-loading [loading]="isUploadingFont"></app-loading>
} @else {
<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">
@switch (mode) {
@case ('all') {
<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">
<a class="pe-0" href="javascript:void(0)" (click)="changeMode('url')">
<span class="phone-hidden">{{t('enter-an-url-pre-title', {url: ''})}}</span>{{t('url')}}
</a>
<span class="ps-1 pe-1"></span>
<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>
}
@case ('url') {
<form [formGroup]="form">
<div class="row g-0 mt-3 pb-3 ms-md-2 me-md-2">
<div class="input-group col-auto me-md-2" style="width: 83%">
<label class="input-group-text" for="load-url">{{t('url-label')}}</label>
<input type="text" autofocus autocomplete="off" class="form-control" formControlName="fontUrl"
placeholder="https://" id="load-url">
<button class="btn btn-outline-secondary" type="button" (click)="uploadFromUrl(); changeMode('all');"
[disabled]="(form.get('fontUrl')?.value).length === 0">
{{t('load')}}
</button>
</div>
<button class="btn btn-secondary col-auto" href="javascript:void(0)" (click)="changeMode('all')">
<i class="fas fa-share" aria-hidden="true" style="transform: rotateY(180deg)"></i>&nbsp;
<span class="phone-hidden">{{t('back')}}</span>
</button>
</div>
</form>
}
}
</ng-template>
</ngx-file-drop>
}
} @else if (selectedFont) {
<h4>
{{selectedFont.name | sentenceCase}}
<div class="float-end">
@if (selectedFont.provider !== FontProvider.System && selectedFont.name !== 'Default') {
<button class="btn btn-danger me-1" (click)="deleteFont(selectedFont.id)">{{t('delete')}}</button>
}
</div>
</h4>
<div>
<ng-container [ngTemplateOutlet]="availableFont" [ngTemplateOutletContext]="{ $implicit: selectedFont}"></ng-container>
</div>
}
</div>
</div>
</div>
</div>
<ng-template #fontOption let-item>
@if (item !== undefined) {
<li class="list-group-item d-flex justify-content-between align-items-start {{selectedFont && selectedFont.name === item.name ? 'active' : ''}}" (click)="selectFont(item)">
<div class="ms-2 me-auto">
<div class="fw-bold">{{item.name | sentenceCase}}</div>
</div>
<div><span class="pill p-1 mx-1 provider">{{item.provider | siteThemeProvider}}</span></div>
</li>
}
</ng-template>
<ng-template #availableFont let-item>
@if (item) {
<div class="d-flex justify-content-between w-100">
@if (item.name === 'Default') {
<div class="ms-2 me-auto fs-6">
This font cannot be previewed. This will take the default style from the book.
</div>
}
<div class="d-flex justify-content-end">
@if (item.hasOwnProperty('provider') && item.provider === FontProvider.User && item.hasOwnProperty('id')) {
<button class="btn btn-danger me-1" (click)="deleteFont(item.id)">{{t('delete')}}</button>
}
</div>
</div>
<div class="p-1 me-1 preview mt-2 flex-grow-1 text-center w-100 fs-4 fs-lg-3 mt-2" [ngStyle]="{'font-family': item.name, 'word-break': 'keep-all'}">
The quick brown fox jumps over the lazy dog
</div>
}
</ng-template>
</ng-container>

View file

@ -0,0 +1,26 @@
.pill {
font-size: .8rem;
background-color: var(--card-bg-color);
border-radius: 0.375rem;
}
.list-group-item, .list-group-item.active {
border-top-width: 0;
border-bottom-width: 0;
}
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

@ -0,0 +1,142 @@
import {ChangeDetectionStrategy, ChangeDetectorRef, Component, DestroyRef, Inject, inject, OnInit} from '@angular/core';
import {translate, TranslocoDirective} from "@ngneat/transloco";
import {FontService} from "src/app/_services/font.service";
import {AccountService} from "../../../_services/account.service";
import {ToastrService} from "ngx-toastr";
import {ConfirmService} from "../../../shared/confirm.service";
import {EpubFont, FontProvider} from 'src/app/_models/preferences/epub-font';
import {User} from "../../../_models/user";
import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
import {shareReplay} from "rxjs/operators";
import {map} from "rxjs";
import {NgxFileDropEntry, NgxFileDropModule} from "ngx-file-drop";
import {AsyncPipe, DOCUMENT, NgIf, NgStyle, NgTemplateOutlet} from "@angular/common";
import {LoadingComponent} from "../../../shared/loading/loading.component";
import {FormBuilder, FormControl, FormGroup, FormsModule, ReactiveFormsModule} from "@angular/forms";
import {SentenceCasePipe} from "../../../_pipes/sentence-case.pipe";
import {SiteThemeProviderPipe} from "../../../_pipes/site-theme-provider.pipe";
import {CarouselReelComponent} from "../../../carousel/_components/carousel-reel/carousel-reel.component";
import {DefaultValuePipe} from "../../../_pipes/default-value.pipe";
import {ImageComponent} from "../../../shared/image/image.component";
import {SafeUrlPipe} from "../../../_pipes/safe-url.pipe";
@Component({
selector: 'app-font-manager',
imports: [
TranslocoDirective,
AsyncPipe,
LoadingComponent,
NgxFileDropModule,
FormsModule,
NgIf,
ReactiveFormsModule,
SentenceCasePipe,
SiteThemeProviderPipe,
NgTemplateOutlet,
NgStyle,
CarouselReelComponent,
DefaultValuePipe,
ImageComponent,
SafeUrlPipe
],
templateUrl: './font-manager.component.html',
styleUrl: './font-manager.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true,
})
export class FontManagerComponent implements OnInit {
private readonly destroyRef = inject(DestroyRef);
protected readonly fontService = inject(FontService);
private readonly accountService = inject(AccountService);
public readonly fb = inject(FormBuilder);
private readonly toastr = inject(ToastrService);
private readonly cdRef = inject(ChangeDetectorRef);
private readonly confirmService = inject(ConfirmService);
protected readonly FontProvider = FontProvider;
user: User | undefined;
fonts: Array<EpubFont> = [];
hasAdmin$ = this.accountService.currentUser$.pipe(
takeUntilDestroyed(this.destroyRef), shareReplay({refCount: true, bufferSize: 1}),
map(c => c && this.accountService.hasAdminRole(c))
);
form: FormGroup = new FormGroup({
fontUrl: new FormControl('', [])
});
selectedFont: EpubFont | undefined = undefined;
files: NgxFileDropEntry[] = [];
acceptableExtensions = ['.woff2', 'woff', 'tff', 'otf'].join(',');
mode: 'file' | 'url' | 'all' = 'all';
isUploadingFont: boolean = false;
constructor(@Inject(DOCUMENT) private document: Document) {}
ngOnInit() {
this.loadFonts();
}
loadFonts() {
this.fontService.getFonts().subscribe(fonts => {
this.fonts = fonts;
this.cdRef.markForCheck();
});
}
selectFont(font: EpubFont) {
this.fontService.getFontFace(font).load().then(loadedFace => {
(this.document as any).fonts.add(loadedFace);
});
this.selectedFont = font;
this.cdRef.markForCheck();
}
dropped(files: NgxFileDropEntry[]) {
for (const droppedFile of files) {
if (!droppedFile.fileEntry.isFile) {
continue;
}
const fileEntry = droppedFile.fileEntry as FileSystemFileEntry;
fileEntry.file((file: File) => {
this.fontService.uploadFont(file, droppedFile).subscribe(f => {
this.isUploadingFont = false;
this.fonts = [...this.fonts, f];
this.cdRef.markForCheck();
});
});
}
this.isUploadingFont = true;
this.cdRef.markForCheck();
}
uploadFromUrl() {
const url = this.form.get('fontUrl')?.value.trim();
if (!url || url === '') return;
this.fontService.uploadFromUrl(url).subscribe(() => {
this.loadFonts();
});
}
async deleteFont(id: number) {
if (!await this.confirmService.confirm(translate('toasts.confirm-delete-font'))) {
return;
}
this.fontService.deleteFont(id).subscribe(() => {
this.fonts = this.fonts.filter(f => f.id !== id);
this.cdRef.markForCheck();
});
}
changeMode(mode: 'file' | 'url' | 'all') {
this.mode = mode;
this.cdRef.markForCheck();
}
}

View file

@ -71,8 +71,7 @@
</ngx-file-drop>
}
}
@else {
} @else {
<h4>
{{selectedTheme.name | sentenceCase}}
<div class="float-end">

View file

@ -619,6 +619,10 @@
<app-theme-manager></app-theme-manager>
}
@defer (when tab.fragment === FragmentID.Font; prefetch on idle) {
<app-font-manager></app-font-manager>
}
@defer (when tab.fragment === FragmentID.Devices; prefetch on idle) {
<app-manage-devices></app-manage-devices>
}

View file

@ -33,7 +33,6 @@ import {SettingsService} from 'src/app/admin/settings.service';
import {BookPageLayoutMode} from 'src/app/_models/readers/book-page-layout-mode';
import {forkJoin} from 'rxjs';
import {bookColorThemes} from 'src/app/book-reader/_components/reader-settings/reader-settings.component';
import {BookService} from 'src/app/book-reader/_services/book.service';
import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
import {SentenceCasePipe} from '../../_pipes/sentence-case.pipe';
import {UserHoldsComponent} from '../user-holds/user-holds.component';
@ -75,8 +74,9 @@ import {ManageScrobblingProvidersComponent} from "../manage-scrobbling-providers
import {PdfLayoutModePipe} from "../../pdf-reader/_pipe/pdf-layout-mode.pipe";
import {PdfTheme} from "../../_models/preferences/pdf-theme";
import {PdfScrollMode} from "../../_models/preferences/pdf-scroll-mode";
import {PdfLayoutMode} from "../../_models/preferences/pdf-layout-mode";
import {PdfSpreadMode} from "../../_models/preferences/pdf-spread-mode";
import {FontManagerComponent} from "../font-manager/font-manager/font-manager.component";
import {FontService} from "../../_services/font.service";
enum AccordionPanelID {
ImageReader = 'image-reader',
@ -90,6 +90,7 @@ enum FragmentID {
Preferences = '',
Clients = 'clients',
Theme = 'theme',
Font = 'font',
Devices = 'devices',
Stats = 'stats',
Scrobbling = 'scrobbling'
@ -105,14 +106,14 @@ enum FragmentID {
ChangePasswordComponent, ChangeAgeRestrictionComponent, ReactiveFormsModule, NgbAccordionDirective, NgbAccordionItem, NgbAccordionHeader,
NgbAccordionToggle, NgbAccordionButton, NgbCollapse, NgbAccordionCollapse, NgbAccordionBody, NgbTooltip, NgTemplateOutlet, ColorPickerModule, ApiKeyComponent,
ThemeManagerComponent, ManageDevicesComponent, UserStatsComponent, UserScrobbleHistoryComponent, UserHoldsComponent, NgbNavOutlet, TitleCasePipe, SentenceCasePipe,
TranslocoDirective, LoadingComponent, ManageScrobblingProvidersComponent, PdfLayoutModePipe],
TranslocoDirective, LoadingComponent, ManageScrobblingProvidersComponent, PdfLayoutModePipe, FontManagerComponent],
})
export class UserPreferencesComponent implements OnInit, OnDestroy {
private readonly destroyRef = inject(DestroyRef);
private readonly accountService = inject(AccountService);
private readonly toastr = inject(ToastrService);
private readonly bookService = inject(BookService);
private readonly fontService = inject(FontService);
private readonly titleService = inject(Title);
private readonly route = inject(ActivatedRoute);
private readonly settingsService = inject(SettingsService);
@ -153,6 +154,7 @@ export class UserPreferencesComponent implements OnInit, OnDestroy {
{title: 'preferences-tab', fragment: FragmentID.Preferences},
{title: '3rd-party-clients-tab', fragment: FragmentID.Clients},
{title: 'theme-tab', fragment: FragmentID.Theme},
{title: 'font-tab', fragment: FragmentID.Font},
{title: 'devices-tab', fragment: FragmentID.Devices},
{title: 'stats-tab', fragment: FragmentID.Stats},
];
@ -166,7 +168,6 @@ export class UserPreferencesComponent implements OnInit, OnDestroy {
constructor() {
this.fontFamilies = this.bookService.getFontFamilies().map(f => f.title);
this.cdRef.markForCheck();
this.accountService.getOpdsUrl().subscribe(res => {
@ -174,6 +175,11 @@ export class UserPreferencesComponent implements OnInit, OnDestroy {
this.cdRef.markForCheck();
});
this.fontService.getFonts().subscribe(res => {
this.fontFamilies = res.map(f => f.name);
this.cdRef.markForCheck();
})
this.settingsService.getOpdsEnabled().subscribe(res => {
this.opdsEnabled = res;
this.cdRef.markForCheck();
@ -227,7 +233,7 @@ export class UserPreferencesComponent implements OnInit, OnDestroy {
this.user.preferences = results.pref;
if (this.fontFamilies.indexOf(this.user.preferences.bookReaderFontFamily) < 0) {
this.user.preferences.bookReaderFontFamily = 'default';
this.user.preferences.bookReaderFontFamily = 'Default';
}
this.settingsForm.addControl('readingDirection', new FormControl(this.user.preferences.readingDirection, []));

Some files were not shown because too many files have changed in this diff Show more