mirror of
https://github.com/Kareadita/Kavita.git
synced 2025-12-09 14:45:04 -05:00
Epub Font Manager (#4037)
Co-authored-by: Amelia <77553571+Fesaa@users.noreply.github.com>
This commit is contained in:
parent
ba01a38d20
commit
d04b8a09a1
1
.gitignore
vendored
1
.gitignore
vendored
@ -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/
|
||||
|
||||
@ -190,6 +190,7 @@
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Folder Include="config\fonts\" />
|
||||
<Folder Include="config\themes" />
|
||||
<Content Include="EmailTemplates\**">
|
||||
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
|
||||
|
||||
158
API/Controllers/FontController.cs
Normal file
158
API/Controllers/FontController.cs
Normal file
@ -0,0 +1,158 @@
|
||||
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 file
|
||||
/// </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="force">If the font is in use by other users and an admin wants it deleted, they must confirm to force delete it. This is prompted in the UI.</param>
|
||||
/// <returns></returns>
|
||||
[HttpDelete]
|
||||
public async Task<IActionResult> DeleteFont(int fontId, bool force = false)
|
||||
{
|
||||
if (User.IsInRole(PolicyConstants.ReadOnlyRole)) return BadRequest(await _localizationService.Translate(User.GetUserId(), "denied"));
|
||||
|
||||
var forceDelete = User.IsInRole(PolicyConstants.AdminRole) && force;
|
||||
var fontInUse = await _fontService.IsFontInUse(fontId);
|
||||
if (!fontInUse || forceDelete)
|
||||
{
|
||||
await _fontService.Delete(fontId);
|
||||
}
|
||||
|
||||
return Ok();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns if the given font is in use by any other user. System provided fonts will always return true.
|
||||
/// </summary>
|
||||
/// <param name="fontId"></param>
|
||||
/// <returns></returns>
|
||||
[HttpGet("in-use")]
|
||||
public async Task<ActionResult<bool>> IsFontInUse(int fontId)
|
||||
{
|
||||
return Ok(await _fontService.IsFontInUse(fontId));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Manual upload
|
||||
/// </summary>
|
||||
/// <param name="formFile"></param>
|
||||
/// <returns></returns>
|
||||
[HttpPost("upload")]
|
||||
public async Task<ActionResult<EpubFontDto>> UploadFont(IFormFile formFile)
|
||||
{
|
||||
if (User.IsInRole(PolicyConstants.ReadOnlyRole)) return BadRequest(await _localizationService.Translate(User.GetUserId(), "denied"));
|
||||
|
||||
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)
|
||||
{
|
||||
if (User.IsInRole(PolicyConstants.ReadOnlyRole)) return BadRequest(await _localizationService.Translate(User.GetUserId(), "denied"));
|
||||
// Validate url
|
||||
try
|
||||
{
|
||||
var font = await _fontService.CreateFontFromUrl(url);
|
||||
return Ok(_mapper.Map<EpubFontDto>(font));
|
||||
}
|
||||
catch (KavitaException ex)
|
||||
{
|
||||
return BadRequest(_localizationService.Translate(User.GetUserId(), ex.Message));
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
@ -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()
|
||||
|
||||
13
API/DTOs/Font/EpubFontDto.cs
Normal file
13
API/DTOs/Font/EpubFontDto.cs
Normal file
@ -0,0 +1,13 @@
|
||||
using System;
|
||||
using API.Entities.Enums.Font;
|
||||
|
||||
namespace API.DTOs.Font;
|
||||
|
||||
public sealed record EpubFontDto
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public string Name { get; set; }
|
||||
public FontProvider Provider { get; set; }
|
||||
public string FileName { get; set; }
|
||||
|
||||
}
|
||||
@ -81,6 +81,8 @@ public sealed class DataContext : IdentityDbContext<AppUser, AppRole, int,
|
||||
public DbSet<AppUserChapterRating> AppUserChapterRating { get; set; } = null!;
|
||||
public DbSet<AppUserReadingProfile> AppUserReadingProfiles { get; set; } = null!;
|
||||
public DbSet<AppUserAnnotation> AppUserAnnotation { get; set; } = null!;
|
||||
public DbSet<EpubFont> EpubFont { get; set; } = null!;
|
||||
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder builder)
|
||||
{
|
||||
|
||||
3889
API/Data/Migrations/20250920212509_CustomEpubFonts.Designer.cs
generated
Normal file
3889
API/Data/Migrations/20250920212509_CustomEpubFonts.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
42
API/Data/Migrations/20250920212509_CustomEpubFonts.cs
Normal file
42
API/Data/Migrations/20250920212509_CustomEpubFonts.cs
Normal file
@ -0,0 +1,42 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace API.Data.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class CustomEpubFonts : 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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1321,6 +1321,41 @@ namespace API.Data.Migrations
|
||||
b.ToTable("EmailHistory");
|
||||
});
|
||||
|
||||
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")
|
||||
|
||||
102
API/Data/Repositories/EpubFontRepository.cs
Normal file
102
API/Data/Repositories/EpubFontRepository.cs
Normal file
@ -0,0 +1,102 @@
|
||||
#nullable enable
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using API.DTOs.Font;
|
||||
using API.Entities;
|
||||
using API.Extensions;
|
||||
using API.Services.Tasks;
|
||||
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 == FontService.DefaultFont ? -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.AppUserReadingProfiles
|
||||
.Join(_context.EpubFont,
|
||||
preference => preference.BookReaderFontFamily,
|
||||
font => font.Name,
|
||||
(preference, font) => new { preference, font })
|
||||
.AnyAsync(joined => joined.font.Id == fontId);
|
||||
}
|
||||
|
||||
}
|
||||
@ -84,6 +84,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, bool track = true);
|
||||
@ -293,6 +294,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
|
||||
|
||||
102
API/Data/Seed.cs
102
API/Data/Seed.cs
@ -12,10 +12,13 @@ using API.Data.Repositories;
|
||||
using API.DTOs.Settings;
|
||||
using API.Entities;
|
||||
using API.Entities.Enums;
|
||||
using API.Entities.Enums.Font;
|
||||
using API.Entities.Enums.Theme;
|
||||
using API.Entities.MetadataMatching;
|
||||
using API.Extensions;
|
||||
using API.Services;
|
||||
using API.Services.Tasks;
|
||||
using API.Services.Tasks.Scanner.Parser;
|
||||
using Kavita.Common;
|
||||
using Kavita.Common.EnvironmentInfo;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
@ -69,6 +72,87 @@ public static class Seed
|
||||
}
|
||||
];
|
||||
|
||||
public static readonly ImmutableArray<EpubFont> DefaultFonts =
|
||||
[
|
||||
new ()
|
||||
{
|
||||
Name = FontService.DefaultFont,
|
||||
NormalizedName = Parser.Normalize(FontService.DefaultFont),
|
||||
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 = "Nanum Gothic",
|
||||
NormalizedName = Parser.Normalize("Nanum Gothic"),
|
||||
Provider = FontProvider.System,
|
||||
FileName = "NanumGothic-Regular.woff2",
|
||||
},
|
||||
new ()
|
||||
{
|
||||
Name = "Open Dyslexic",
|
||||
NormalizedName = Parser.Normalize("Open Dyslexic"),
|
||||
Provider = FontProvider.System,
|
||||
FileName = "OpenDyslexic-Regular.woff2",
|
||||
},
|
||||
new ()
|
||||
{
|
||||
Name = "RocknRoll One",
|
||||
NormalizedName = Parser.Normalize("RocknRoll One"),
|
||||
Provider = FontProvider.System,
|
||||
FileName = "RocknRollOne-Regular.woff2",
|
||||
},
|
||||
new ()
|
||||
{
|
||||
Name = "Fast Font Serif",
|
||||
NormalizedName = Parser.Normalize("Fast Font Serif"),
|
||||
Provider = FontProvider.System,
|
||||
FileName = "Fast_Serif.woff2",
|
||||
},
|
||||
new ()
|
||||
{
|
||||
Name = "Fast Font Sans",
|
||||
NormalizedName = Parser.Normalize("Fast Font Sans"),
|
||||
Provider = FontProvider.System,
|
||||
FileName = "Fast_Sans.woff2",
|
||||
}
|
||||
];
|
||||
|
||||
public static readonly ImmutableArray<SiteTheme> DefaultThemes = [
|
||||
..new List<SiteTheme>
|
||||
{
|
||||
@ -197,7 +281,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);
|
||||
@ -207,6 +291,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);
|
||||
|
||||
@ -35,6 +35,7 @@ public interface IUnitOfWork
|
||||
IEmailHistoryRepository EmailHistoryRepository { get; }
|
||||
IAppUserReadingProfileRepository AppUserReadingProfileRepository { get; }
|
||||
IAnnotationRepository AnnotationRepository { get; }
|
||||
IEpubFontRepository EpubFontRepository { get; }
|
||||
bool Commit();
|
||||
Task<bool> CommitAsync();
|
||||
bool HasChanges();
|
||||
@ -78,6 +79,7 @@ public class UnitOfWork : IUnitOfWork
|
||||
EmailHistoryRepository = new EmailHistoryRepository(_context, _mapper);
|
||||
AppUserReadingProfileRepository = new AppUserReadingProfileRepository(_context, _mapper);
|
||||
AnnotationRepository = new AnnotationRepository(_context, _mapper);
|
||||
EpubFontRepository = new EpubFontRepository(_context, _mapper);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@ -109,6 +111,7 @@ public class UnitOfWork : IUnitOfWork
|
||||
public IEmailHistoryRepository EmailHistoryRepository { get; }
|
||||
public IAppUserReadingProfileRepository AppUserReadingProfileRepository { get; }
|
||||
public IAnnotationRepository AnnotationRepository { get; }
|
||||
public IEpubFontRepository EpubFontRepository { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Commits changes to the DB. Completes the open transaction.
|
||||
|
||||
@ -2,6 +2,7 @@
|
||||
using API.Data;
|
||||
using API.Entities.Enums;
|
||||
using API.Entities.Enums.UserPreferences;
|
||||
using API.Services.Tasks;
|
||||
|
||||
namespace API.Entities;
|
||||
|
||||
@ -79,7 +80,7 @@ public class AppUserPreferences
|
||||
/// <summary>
|
||||
/// Book Reader Option: Maps to the default Kavita font-family (inherit) or an override
|
||||
/// </summary>
|
||||
public string BookReaderFontFamily { get; set; } = "default";
|
||||
public string BookReaderFontFamily { get; set; } = FontService.DefaultFont;
|
||||
/// <summary>
|
||||
/// Book Reader Option: Allows tapping on side of screens to paginate
|
||||
/// </summary>
|
||||
|
||||
@ -2,6 +2,7 @@ using System.Collections.Generic;
|
||||
using System.ComponentModel;
|
||||
using API.Entities.Enums;
|
||||
using API.Entities.Enums.UserPreferences;
|
||||
using API.Services.Tasks;
|
||||
|
||||
namespace API.Entities;
|
||||
|
||||
@ -109,7 +110,7 @@ public class AppUserReadingProfile
|
||||
/// <summary>
|
||||
/// Book Reader Option: Maps to the default Kavita font-family (inherit) or an override
|
||||
/// </summary>
|
||||
public string BookReaderFontFamily { get; set; } = "default";
|
||||
public string BookReaderFontFamily { get; set; } = FontService.DefaultFont;
|
||||
/// <summary>
|
||||
/// Book Reader Option: Allows tapping on side of screens to paginate
|
||||
/// </summary>
|
||||
|
||||
13
API/Entities/Enums/Font/FontProvider.cs
Normal file
13
API/Entities/Enums/Font/FontProvider.cs
Normal 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
37
API/Entities/EpubFont.cs
Normal 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; }
|
||||
}
|
||||
@ -59,6 +59,7 @@ public static class ApplicationServiceExtensions
|
||||
services.AddScoped<IPersonService, PersonService>();
|
||||
services.AddScoped<IReadingProfileService, ReadingProfileService>();
|
||||
services.AddScoped<IKoreaderService, KoreaderService>();
|
||||
services.AddScoped<IFontService, FontService>();
|
||||
|
||||
services.AddScoped<IScannerService, ScannerService>();
|
||||
services.AddScoped<IProcessSeries, ProcessSeries>();
|
||||
|
||||
@ -11,6 +11,7 @@ using API.DTOs.Device;
|
||||
using API.DTOs.Email;
|
||||
using API.DTOs.Filtering;
|
||||
using API.DTOs.Filtering.v2;
|
||||
using API.DTOs.Font;
|
||||
using API.DTOs.KavitaPlus.Manage;
|
||||
using API.DTOs.KavitaPlus.Metadata;
|
||||
using API.DTOs.MediaErrors;
|
||||
@ -282,6 +283,8 @@ public class AutoMapperProfiles : Profile
|
||||
opt =>
|
||||
opt.MapFrom(src => src.BookThemeName));
|
||||
|
||||
CreateMap<EpubFont, EpubFontDto>();
|
||||
|
||||
|
||||
CreateMap<AppUserBookmark, BookmarkDto>();
|
||||
|
||||
|
||||
@ -241,6 +241,6 @@
|
||||
"update-yearly-stats": "Update Yearly Stats",
|
||||
|
||||
"generated-reading-profile-name": "Generated from {0}",
|
||||
"genre-doesnt-exist": "Genre doesn't exist"
|
||||
|
||||
"genre-doesnt-exist": "Genre doesn't exist",
|
||||
"font-url-not-allowed": "Uploading a Font by url is only allowed from Google Fonts"
|
||||
}
|
||||
|
||||
@ -124,6 +124,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);
|
||||
|
||||
@ -313,11 +313,11 @@ public partial class BookService : IBookService
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// For each bookmark on this page, inject a specialized icon
|
||||
/// For each ptoc (text) bookmark on this page, inject a specialized icon
|
||||
/// </summary>
|
||||
/// <param name="doc"></param>
|
||||
/// <param name="ptocBookmarks"></param>
|
||||
private void InjectPTOCBookmarks(HtmlDocument doc, List<PersonalToCDto> ptocBookmarks)
|
||||
private void InjectTextBookmarks(HtmlDocument doc, List<PersonalToCDto> ptocBookmarks)
|
||||
{
|
||||
if (ptocBookmarks.Count == 0) return;
|
||||
|
||||
@ -1257,7 +1257,7 @@ public partial class BookService : IBookService
|
||||
InjectImages(doc, book, apiBase);
|
||||
|
||||
// Inject PTOC Bookmark Icons
|
||||
InjectPTOCBookmarks(doc, ptocBookmarks);
|
||||
InjectTextBookmarks(doc, ptocBookmarks);
|
||||
|
||||
// Inject Annotations
|
||||
InjectAnnotations(doc, annotations);
|
||||
|
||||
@ -42,6 +42,7 @@ public interface IDirectoryService
|
||||
/// Used for random files needed, like images to check against, list of countries, etc
|
||||
/// </summary>
|
||||
string AssetsDirectory { get; }
|
||||
string EpubFontDirectory { get; }
|
||||
/// <summary>
|
||||
/// Lists out top-level folders for a given directory. Filters out System and Hidden folders.
|
||||
/// </summary>
|
||||
@ -100,6 +101,8 @@ public class DirectoryService : IDirectoryService
|
||||
public string TemplateDirectory { get; }
|
||||
public string PublisherDirectory { get; }
|
||||
public string LongTermCacheDirectory { get; }
|
||||
public string EpubFontDirectory { get; }
|
||||
|
||||
private readonly ILogger<DirectoryService> _logger;
|
||||
private const RegexOptions MatchOptions = RegexOptions.Compiled | RegexOptions.IgnoreCase;
|
||||
|
||||
@ -141,6 +144,8 @@ public class DirectoryService : IDirectoryService
|
||||
ExistOrCreate(PublisherDirectory);
|
||||
LongTermCacheDirectory = FileSystem.Path.Join(FileSystem.Directory.GetCurrentDirectory(), "config", "cache-long");
|
||||
ExistOrCreate(LongTermCacheDirectory);
|
||||
EpubFontDirectory = FileSystem.Path.Join(FileSystem.Directory.GetCurrentDirectory(), "config", "fonts");
|
||||
ExistOrCreate(EpubFontDirectory);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
264
API/Services/FontService.cs
Normal file
264
API/Services/FontService.cs
Normal file
@ -0,0 +1,264 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Threading.Tasks;
|
||||
using API.Data;
|
||||
using API.Entities;
|
||||
using API.Entities.Enums.Font;
|
||||
using API.Extensions;
|
||||
using API.Services.Tasks.Scanner.Parser;
|
||||
using API.SignalR;
|
||||
using Flurl.Http;
|
||||
using Kavita.Common;
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace API.Services.Tasks;
|
||||
|
||||
// Although we don't use all the fields, just including them all for completeness
|
||||
internal class GoogleFontsMetadata
|
||||
{
|
||||
/// <summary>
|
||||
/// Name of the zip file container all fonts
|
||||
/// </summary>
|
||||
public required string zipName { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Manifest, information about the content of the zip
|
||||
/// </summary>
|
||||
public required GoogleFontsManifest manifest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Tries to find the variable font in the manifest
|
||||
/// </summary>
|
||||
/// <returns>GoogleFontsFileRef</returns>
|
||||
public GoogleFontsFileRef? VariableFont()
|
||||
{
|
||||
foreach (var fileRef in manifest.fileRefs)
|
||||
{
|
||||
// Filename prefixed with static means it's a Bold/Italic/... font
|
||||
if (!fileRef.filename.StartsWith("static/"))
|
||||
{
|
||||
return fileRef;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
internal class GoogleFontsManifest
|
||||
{
|
||||
/// <summary>
|
||||
/// Files included in the zip
|
||||
/// <example>README.txt</example>
|
||||
/// </summary>
|
||||
public required GoogleFontsFile[] files { get; init; }
|
||||
/// <summary>
|
||||
/// References to the actual fonts
|
||||
/// </summary>
|
||||
public required GoogleFontsFileRef[] fileRefs { get; init; }
|
||||
}
|
||||
|
||||
internal class GoogleFontsFile
|
||||
{
|
||||
public required string filename { get; init; }
|
||||
public required string contents { get; init; }
|
||||
}
|
||||
|
||||
internal class GoogleFontsFileRef
|
||||
{
|
||||
public required string filename { get; init; }
|
||||
public required string url { get; init; }
|
||||
public required GoogleFontsData date { get; init; }
|
||||
}
|
||||
|
||||
internal class GoogleFontsData
|
||||
{
|
||||
public required int seconds { get; init; }
|
||||
public required int nanos { get; init; }
|
||||
}
|
||||
|
||||
public interface IFontService
|
||||
{
|
||||
Task<EpubFont> CreateFontFromFileAsync(string path);
|
||||
Task Delete(int fontId);
|
||||
Task<EpubFont> CreateFontFromUrl(string url);
|
||||
Task<bool> IsFontInUse(int fontId);
|
||||
}
|
||||
|
||||
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/";
|
||||
private const string DownloadFontUrlPrefix = "https://fonts.google.com/download/list?family=";
|
||||
private const string GoogleFontsInvalidJsonPrefix = ")]}'";
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// This does not check if in use, use <see cref="IsFontInUse"/>
|
||||
/// </summary>
|
||||
/// <param name="fontId"></param>
|
||||
public async Task Delete(int fontId)
|
||||
{
|
||||
var font = await _unitOfWork.EpubFontRepository.GetFontAsync(fontId);
|
||||
if (font == null) return;
|
||||
|
||||
await RemoveFont(font);
|
||||
}
|
||||
|
||||
public async Task<EpubFont> 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].Split("/").Last();
|
||||
_logger.LogInformation("Preparing to download {FontName} font", fontFamily.Sanitize());
|
||||
|
||||
var metaData = await GetGoogleFontsMetadataAsync(fontFamily);
|
||||
if (metaData == null)
|
||||
{
|
||||
_logger.LogError("Unable to find metadata for {FontName}", fontFamily.Sanitize());
|
||||
throw new KavitaException("errors.font-not-found");
|
||||
}
|
||||
|
||||
var googleFontRef = metaData.VariableFont();
|
||||
if (googleFontRef == null)
|
||||
{
|
||||
_logger.LogError("Unable to find variable font for {FontName} with metadata {MetaData}", fontFamily.Sanitize(), metaData);
|
||||
throw new KavitaException("errors.font-not-found");
|
||||
}
|
||||
|
||||
var fontExt = Path.GetExtension(googleFontRef.filename);
|
||||
var fileName = $"{fontFamily}{fontExt}";
|
||||
|
||||
_logger.LogDebug("Downloading font {FontFamily} to {FileName} from {Url}", fontFamily.Sanitize(), fileName, googleFontRef.url);
|
||||
var path = await googleFontRef.url.DownloadFileAsync(_directoryService.TempDirectory, fileName);
|
||||
|
||||
return await CreateFontFromFileAsync(path);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns if the given font is in use by any other user. System provided fonts will always return true.
|
||||
/// </summary>
|
||||
/// <param name="fontId"></param>
|
||||
/// <returns></returns>
|
||||
public async Task<bool> IsFontInUse(int fontId)
|
||||
{
|
||||
var font = await _unitOfWork.EpubFontRepository.GetFontAsync(fontId);
|
||||
if (font == null || font.Provider == FontProvider.System) return true;
|
||||
|
||||
return await _unitOfWork.EpubFontRepository.IsFontInUseAsync(fontId);
|
||||
}
|
||||
|
||||
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 font file to temp for nightly removal (to give user time to reclaim if made a mistake)
|
||||
var existingLocation =
|
||||
_directoryService.FileSystem.Path.Join(_directoryService.EpubFontDirectory, 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();
|
||||
}
|
||||
|
||||
private async Task<GoogleFontsMetadata?> GetGoogleFontsMetadataAsync(string fontName)
|
||||
{
|
||||
var url = DownloadFontUrlPrefix + fontName;
|
||||
string content;
|
||||
|
||||
// The request may fail if the users URL is invalid or the font doesn't exist
|
||||
// The error this produces is ugly and not user-friendly, so we catch it here
|
||||
try
|
||||
{
|
||||
content = await url
|
||||
.WithHeader("Accept", "application/json")
|
||||
.WithHeader("User-Agent", "Kavita")
|
||||
.GetStringAsync();
|
||||
} catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Unable to get metadata for {FontName} from {Url}", fontName.Sanitize(), url);
|
||||
return null;
|
||||
}
|
||||
|
||||
// The returned response isn't valid json and has this weird prefix, removing it here...
|
||||
if (content.StartsWith(GoogleFontsInvalidJsonPrefix))
|
||||
{
|
||||
content = content[GoogleFontsInvalidJsonPrefix.Length..];
|
||||
}
|
||||
|
||||
return JsonSerializer.Deserialize<GoogleFontsMetadata>(content);
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@ -124,6 +124,9 @@ public class BackupService : IBackupService
|
||||
await SendProgress(0.5F, "Copying bookmarks");
|
||||
await CopyBookmarksToBackupDirectory(tempDirectory);
|
||||
|
||||
await SendProgress(0.6F, "Copying Fonts");
|
||||
CopyFontsToBackupDirectory(tempDirectory);
|
||||
|
||||
await SendProgress(0.75F, "Copying themes");
|
||||
CopyThemesToBackupDirectory(tempDirectory);
|
||||
|
||||
@ -225,6 +228,26 @@ public class BackupService : IBackupService
|
||||
}
|
||||
}
|
||||
|
||||
private void CopyFontsToBackupDirectory(string tempDirectory)
|
||||
{
|
||||
var outputTempDir = Path.Join(tempDirectory, "fonts");
|
||||
_directoryService.ExistOrCreate(outputTempDir);
|
||||
|
||||
try
|
||||
{
|
||||
_directoryService.CopyDirectoryToDirectory(_directoryService.EpubFontDirectory, outputTempDir);
|
||||
}
|
||||
catch (IOException ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to copy fonts to backup directory '{OutputTempDir}'. Fonts will not be included in the backup.", outputTempDir);
|
||||
}
|
||||
|
||||
if (!_directoryService.GetFiles(outputTempDir, searchOption: SearchOption.AllDirectories).Any())
|
||||
{
|
||||
_directoryService.ClearAndDeleteDirectory(outputTempDir);
|
||||
}
|
||||
}
|
||||
|
||||
private void CopyThemesToBackupDirectory(string tempDirectory)
|
||||
{
|
||||
var outputTempDir = Path.Join(tempDirectory, "themes");
|
||||
|
||||
@ -31,6 +31,7 @@ public static partial class Parser
|
||||
private const string BookFileExtensions = EpubFileExtension + "|" + PdfFileExtension;
|
||||
private const string XmlRegexExtensions = @"\.xml";
|
||||
public const string MacOsMetadataFileStartsWith = @"._";
|
||||
public const string FontFileExtensions = @"\.[woff2|ttf|otf|woff]";
|
||||
|
||||
public const string SupportedExtensions =
|
||||
ArchiveFileExtensions + "|" + ImageFileExtensions + "|" + BookFileExtensions;
|
||||
@ -1257,6 +1258,14 @@ public static partial 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]", " ");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Removes duplicate chapter markers from filename, keeping only the first occurrence
|
||||
/// </summary>
|
||||
|
||||
17
UI/Web/src/app/_models/preferences/epub-font.ts
Normal file
17
UI/Web/src/app/_models/preferences/epub-font.ts
Normal 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;
|
||||
}
|
||||
@ -22,4 +22,5 @@ export enum WikiLink {
|
||||
OpdsClients = 'https://wiki.kavitareader.com/guides/features/opds/#opds-capable-clients',
|
||||
Guides = 'https://wiki.kavitareader.com/guides',
|
||||
ReadingProfiles = "https://wiki.kavitareader.com/guides/user-settings/reading-profiles/",
|
||||
EpubFontManager = "https://wiki.kavitareader.com/guides/epub-fonts/",
|
||||
}
|
||||
|
||||
@ -6,7 +6,6 @@ import {WritingStyle} from '../_models/preferences/writing-style';
|
||||
import {BookPageLayoutMode} from "../_models/readers/book-page-layout-mode";
|
||||
import {FormControl, FormGroup, NonNullableFormBuilder} from "@angular/forms";
|
||||
import {ReadingProfile, ReadingProfileKind} from "../_models/preferences/reading-profiles";
|
||||
import {BookService, FontFamily} from "../book-reader/_services/book.service";
|
||||
import {ThemeService} from './theme.service';
|
||||
import {ReadingProfileService} from "./reading-profile.service";
|
||||
import {debounceTime, distinctUntilChanged, filter, tap} from "rxjs/operators";
|
||||
@ -18,6 +17,8 @@ import {takeUntilDestroyed} from '@angular/core/rxjs-interop';
|
||||
import {UserBreakpoint, UtilityService} from "../shared/_services/utility.service";
|
||||
import {LayoutMeasurementService} from "./layout-measurement.service";
|
||||
import {environment} from "../../environments/environment";
|
||||
import {EpubFont} from "../_models/preferences/epub-font";
|
||||
import {FontService} from "./font.service";
|
||||
|
||||
export interface ReaderSettingUpdate {
|
||||
setting: 'pageStyle' | 'clickToPaginate' | 'fullscreen' | 'writingStyle' | 'layoutMode' | 'readingDirection' | 'immersiveMode' | 'theme';
|
||||
@ -43,7 +44,7 @@ const COLUMN_GAP = 20; //px gap between columns
|
||||
@Injectable()
|
||||
export class EpubReaderSettingsService {
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
private readonly bookService = inject(BookService);
|
||||
private readonly fontService = inject(FontService);
|
||||
private readonly themeService = inject(ThemeService);
|
||||
private readonly readingProfileService = inject(ReadingProfileService);
|
||||
private readonly utilityService = inject(UtilityService);
|
||||
@ -57,6 +58,7 @@ export class EpubReaderSettingsService {
|
||||
private readonly _parentReadingProfile = signal<ReadingProfile | null>(null);
|
||||
private readonly _currentSeriesId = signal<number | null>(null);
|
||||
private readonly _isInitialized = signal<boolean>(false);
|
||||
private readonly _epubFonts = signal<EpubFont[]>([]);
|
||||
|
||||
// Settings signals
|
||||
private readonly _pageStyles = signal<PageStyle>(this.getDefaultPageStyles()); // Internal property used to capture all the different css properties to render on all elements
|
||||
@ -70,7 +72,6 @@ export class EpubReaderSettingsService {
|
||||
|
||||
// Form will be managed separately but updated from signals
|
||||
private settingsForm!: BookReadingProfileFormGroup;
|
||||
private fontFamilies: FontFamily[] = this.bookService.getFontFamilies();
|
||||
private isUpdatingFromForm = false; // Flag to prevent infinite loops
|
||||
private isInitialized = this._isInitialized(); // Non-signal, updates in effect
|
||||
|
||||
@ -89,6 +90,7 @@ export class EpubReaderSettingsService {
|
||||
public readonly clickToPaginate = this._clickToPaginate.asReadonly();
|
||||
public readonly immersiveMode = this._immersiveMode.asReadonly();
|
||||
public readonly isFullscreen = this._isFullscreen.asReadonly();
|
||||
public readonly epubFonts = this._epubFonts.asReadonly();
|
||||
|
||||
// Computed signals for derived state
|
||||
public readonly layoutMode = computed(() => {
|
||||
@ -215,6 +217,9 @@ export class EpubReaderSettingsService {
|
||||
* Initialize the service with a reading profile and series ID
|
||||
*/
|
||||
async initialize(seriesId: number, readingProfile: ReadingProfile): Promise<void> {
|
||||
const fonts = await firstValueFrom(this.fontService.getFonts());
|
||||
this._epubFonts.set(fonts);
|
||||
|
||||
this._currentSeriesId.set(seriesId);
|
||||
this._currentReadingProfile.set(readingProfile);
|
||||
|
||||
@ -248,7 +253,7 @@ export class EpubReaderSettingsService {
|
||||
private setupDefaultsFromProfile(profile: ReadingProfile): void {
|
||||
// Set defaults if undefined
|
||||
if (profile.bookReaderFontFamily === undefined) {
|
||||
profile.bookReaderFontFamily = 'default';
|
||||
profile.bookReaderFontFamily = FontService.DefaultEpubFont;
|
||||
}
|
||||
if (profile.bookReaderFontSize === undefined || profile.bookReaderFontSize < 50) {
|
||||
profile.bookReaderFontSize = 100;
|
||||
@ -299,13 +304,6 @@ export class EpubReaderSettingsService {
|
||||
return this._currentReadingProfile();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get font families for UI
|
||||
*/
|
||||
getFontFamilies(): FontFamily[] {
|
||||
return this.fontFamilies;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get available themes
|
||||
*/
|
||||
@ -490,11 +488,11 @@ export class EpubReaderSettingsService {
|
||||
).subscribe(fontName => {
|
||||
this.isUpdatingFromForm = true;
|
||||
|
||||
const familyName = this.fontFamilies.find(f => f.title === fontName)?.family || 'default';
|
||||
const familyName = this._epubFonts().find(f => f.name === fontName)?.name || FontService.DefaultEpubFont;
|
||||
const currentStyles = this._pageStyles();
|
||||
|
||||
const newStyles = { ...currentStyles };
|
||||
if (familyName === 'default') {
|
||||
if (familyName === FontService.DefaultEpubFont) {
|
||||
newStyles['font-family'] = 'inherit';
|
||||
} else {
|
||||
newStyles['font-family'] = `'${familyName}'`;
|
||||
@ -670,7 +668,7 @@ export class EpubReaderSettingsService {
|
||||
|
||||
const currentStyles = this._pageStyles();
|
||||
const newStyles: PageStyle = {
|
||||
'font-family': fontFamily || currentStyles['font-family'] || 'default',
|
||||
'font-family': fontFamily || currentStyles['font-family'] || FontService.DefaultEpubFont,
|
||||
'font-size': fontSize || currentStyles['font-size'] || '100%',
|
||||
'margin-left': margin || currentStyles['margin-left'] || defaultMargin,
|
||||
'margin-right': margin || currentStyles['margin-right'] || defaultMargin,
|
||||
@ -682,7 +680,7 @@ export class EpubReaderSettingsService {
|
||||
|
||||
public getDefaultPageStyles(): PageStyle {
|
||||
return {
|
||||
'font-family': 'default',
|
||||
'font-family': FontService.DefaultEpubFont,
|
||||
'font-size': '100%',
|
||||
'margin-left': '15vw',
|
||||
'margin-right': '15vw',
|
||||
|
||||
66
UI/Web/src/app/_services/font.service.ts
Normal file
66
UI/Web/src/app/_services/font.service.ts
Normal file
@ -0,0 +1,66 @@
|
||||
import {effect, inject, Injectable} from "@angular/core";
|
||||
import {EpubFont, FontProvider} from "../_models/preferences/epub-font";
|
||||
import {environment} from 'src/environments/environment';
|
||||
import {HttpClient} from "@angular/common/http";
|
||||
import {NgxFileDropEntry} from "ngx-file-drop";
|
||||
import {AccountService} from "./account.service";
|
||||
import {TextResonse} from "../_types/text-response";
|
||||
import {map} from "rxjs/operators";
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class FontService {
|
||||
|
||||
public static readonly DefaultEpubFont = 'Default';
|
||||
|
||||
private readonly httpClient = inject(HttpClient);
|
||||
private readonly accountService = inject(AccountService);
|
||||
|
||||
baseUrl = environment.apiUrl;
|
||||
apiKey: string = '';
|
||||
encodedKey: string = '';
|
||||
|
||||
constructor() {
|
||||
effect(() => {
|
||||
const user = this.accountService.currentUserSignal();
|
||||
|
||||
if (user) {
|
||||
this.apiKey = user.apiKey;
|
||||
this.encodedKey = encodeURIComponent(this.apiKey);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
getFonts() {
|
||||
return this.httpClient.get<Array<EpubFont>>(this.baseUrl + 'font/all');
|
||||
}
|
||||
|
||||
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<EpubFont>(this.baseUrl + "font/upload-by-url?url=" + encodeURIComponent(url), {});
|
||||
}
|
||||
|
||||
deleteFont(id: number, force: boolean = false) {
|
||||
return this.httpClient.delete(this.baseUrl + `font?fontId=${id}&force=${force}`);
|
||||
}
|
||||
|
||||
isFontInUse(id: number) {
|
||||
return this.httpClient.get(this.baseUrl + `font/in-use?fontId=${id}`, TextResonse).pipe(map(res => res == 'true'));
|
||||
}
|
||||
|
||||
}
|
||||
@ -1,59 +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;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "FastFontSerif";
|
||||
src: url(../../../../assets/fonts/Fast_Font/Fast_Serif.woff2) format("woff2");
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "FastFontSans";
|
||||
src: url(../../../../assets/fonts/Fast_Font/Fast_Sans.woff2) format("woff2");
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
|
||||
|
||||
:root {
|
||||
--br-actionbar-button-text-color: #6c757d;
|
||||
--accordion-body-bg-color: black;
|
||||
|
||||
@ -67,6 +67,7 @@ import {LayoutMeasurementService} from "../../../_services/layout-measurement.se
|
||||
import {ColorscapeService} from "../../../_services/colorscape.service";
|
||||
import {environment} from "../../../../environments/environment";
|
||||
import {LoadPageEvent} from "../_drawers/view-bookmarks-drawer/view-bookmark-drawer.component";
|
||||
import {FontService} from "../../../_services/font.service";
|
||||
import afterFrame from "afterframe";
|
||||
|
||||
|
||||
@ -156,6 +157,7 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
private readonly document = inject(DOCUMENT);
|
||||
private readonly layoutService = inject(LayoutMeasurementService);
|
||||
private readonly colorscapeService = inject(ColorscapeService);
|
||||
private readonly fontService = inject(FontService);
|
||||
|
||||
protected readonly BookPageLayoutMode = BookPageLayoutMode;
|
||||
protected readonly WritingStyle = WritingStyle;
|
||||
@ -776,6 +778,14 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
}
|
||||
|
||||
async ngOnInit() {
|
||||
this.fontService.getFonts().subscribe(fonts => {
|
||||
fonts.filter(f => f.name !== FontService.DefaultEpubFont).forEach(font => {
|
||||
this.fontService.getFontFace(font).load().then(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');
|
||||
|
||||
@ -16,8 +16,8 @@
|
||||
<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">
|
||||
@for(opt of fontOptions; track opt) {
|
||||
<option [value]="opt">{{opt | titlecase}}</option>
|
||||
@for(opt of epubFonts(); track opt) {
|
||||
<option [value]="opt.name">{{opt.name | titlecase}}</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
@ -6,7 +6,6 @@ import {BookTheme} from 'src/app/_models/preferences/book-theme';
|
||||
import {ReadingDirection} from 'src/app/_models/preferences/reading-direction';
|
||||
import {WritingStyle} from 'src/app/_models/preferences/writing-style';
|
||||
import {ThemeProvider} from 'src/app/_models/preferences/site-theme';
|
||||
import {FontFamily} 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';
|
||||
@ -23,6 +22,9 @@ import {
|
||||
import {TranslocoDirective} from "@jsverse/transloco";
|
||||
import {ReadingProfile, ReadingProfileKind} from "../../../_models/preferences/reading-profiles";
|
||||
import {BookReadingProfileFormGroup, EpubReaderSettingsService} from "../../../_services/epub-reader-settings.service";
|
||||
import {LayoutMode} from "../../../manga-reader/_models/layout-mode";
|
||||
import {FontService} from "../../../_services/font.service";
|
||||
import {EpubFont} from "../../../_models/preferences/epub-font";
|
||||
|
||||
/**
|
||||
* Used for book reader. Do not use for other components
|
||||
@ -95,11 +97,6 @@ export class ReaderSettingsComponent implements OnInit {
|
||||
@Input({required:true}) readingProfile!: ReadingProfile;
|
||||
@Input({required:true}) readerSettingsService!: EpubReaderSettingsService;
|
||||
|
||||
/**
|
||||
* List of all font families user can select from
|
||||
*/
|
||||
fontOptions: Array<string> = [];
|
||||
fontFamilies: Array<FontFamily> = [];
|
||||
settingsForm!: BookReadingProfileFormGroup;
|
||||
/**
|
||||
* System provided themes
|
||||
@ -118,6 +115,7 @@ export class ReaderSettingsComponent implements OnInit {
|
||||
protected hasParentProfile!: Signal<boolean>;
|
||||
protected parentReadingProfile!: Signal<ReadingProfile | null>;
|
||||
protected currentReadingProfile!: Signal<ReadingProfile | null>;
|
||||
protected epubFonts!: Signal<EpubFont[]>;
|
||||
|
||||
|
||||
protected isVerticalLayout!: Signal<boolean>;
|
||||
@ -136,18 +134,17 @@ export class ReaderSettingsComponent implements OnInit {
|
||||
this.hasParentProfile = this.readerSettingsService.hasParentProfile;
|
||||
this.parentReadingProfile = this.readerSettingsService.parentReadingProfile;
|
||||
this.currentReadingProfile = this.readerSettingsService.currentReadingProfile;
|
||||
this.epubFonts = this.readerSettingsService.epubFonts;
|
||||
|
||||
|
||||
this.themes = this.readerSettingsService.getThemes();
|
||||
|
||||
|
||||
// Initialize the service if not already done
|
||||
if (!this.readerSettingsService.getCurrentReadingProfile()) {
|
||||
await this.readerSettingsService.initialize(this.seriesId, this.readingProfile);
|
||||
}
|
||||
|
||||
this.settingsForm = this.readerSettingsService.getSettingsForm();
|
||||
this.fontFamilies = this.readerSettingsService.getFontFamilies();
|
||||
this.fontOptions = this.fontFamilies.map(f => f.title);
|
||||
this.cdRef.markForCheck();
|
||||
}
|
||||
|
||||
|
||||
@ -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,13 +14,6 @@ export class BookService {
|
||||
private readonly baseUrl = environment.apiUrl;
|
||||
|
||||
|
||||
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: 'Open Dyslexic', family: 'OpenDyslexic2'}, {title: 'RocknRoll One', family: 'RocknRoll_One'},
|
||||
{title: 'Fast Font Serif (Bionic)', family: 'FastFontSerif'}, {title: 'Fast Font Sans (Bionic)', family: 'FastFontSans'}];
|
||||
}
|
||||
|
||||
getBookChapters(chapterId: number) {
|
||||
return this.http.get<Array<BookChapterItem>>(this.baseUrl + 'book/' + chapterId + '/chapters');
|
||||
}
|
||||
|
||||
@ -201,6 +201,14 @@
|
||||
}
|
||||
}
|
||||
|
||||
@defer (when fragment === SettingsTabId.Font; prefetch on idle) {
|
||||
@if (fragment === SettingsTabId.Font) {
|
||||
<div class="scale col-md-12">
|
||||
<app-font-manager />
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
@defer (when fragment === SettingsTabId.Devices; prefetch on idle) {
|
||||
@if (fragment === SettingsTabId.Devices) {
|
||||
<div class="scale col-md-12">
|
||||
|
||||
@ -60,6 +60,7 @@ import {
|
||||
} from "../../../admin/manage-public-metadata-settings/manage-public-metadata-settings.component";
|
||||
import {ImportMappingsComponent} from "../../../admin/import-mappings/import-mappings.component";
|
||||
import {ManageOpenIDConnectComponent} from "../../../admin/manage-open-idconnect/manage-open-idconnect.component";
|
||||
import {FontManagerComponent} from "../../../user-settings/font-manager/font-manager/font-manager.component";
|
||||
|
||||
@Component({
|
||||
selector: 'app-settings',
|
||||
@ -99,7 +100,8 @@ import {ManageOpenIDConnectComponent} from "../../../admin/manage-open-idconnect
|
||||
ManageReadingProfilesComponent,
|
||||
ManageOpenIDConnectComponent,
|
||||
ManagePublicMetadataSettingsComponent,
|
||||
ImportMappingsComponent
|
||||
ImportMappingsComponent,
|
||||
FontManagerComponent
|
||||
],
|
||||
templateUrl: './settings.component.html',
|
||||
styleUrl: './settings.component.scss',
|
||||
|
||||
@ -53,6 +53,7 @@ export enum SettingsTabId {
|
||||
Account = 'account',
|
||||
Preferences = 'preferences',
|
||||
ReadingProfiles = 'reading-profiles',
|
||||
Font = 'font',
|
||||
Clients = 'clients',
|
||||
Theme = 'theme',
|
||||
Devices = 'devices',
|
||||
@ -224,6 +225,7 @@ export class PreferenceNavComponent implements AfterViewInit {
|
||||
new SideNavItem(SettingsTabId.Customize, [], undefined, [Role.ReadOnly]),
|
||||
new SideNavItem(SettingsTabId.Clients),
|
||||
new SideNavItem(SettingsTabId.Theme),
|
||||
new SideNavItem(SettingsTabId.Font),
|
||||
new SideNavItem(SettingsTabId.Devices),
|
||||
new SideNavItem(SettingsTabId.UserStats),
|
||||
]
|
||||
|
||||
@ -0,0 +1,154 @@
|
||||
<ng-container *transloco="let t; prefix:'font-manager'">
|
||||
<div class="container-fluid g-0">
|
||||
|
||||
<p [innerHTML]="t('description', {'wikiLink': WikiLink})"></p>
|
||||
<form [formGroup]="form">
|
||||
<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">
|
||||
|
||||
<div class="d-flex justify-content-between mb-2 align-items-center {{!selectedFont() ? 'active' : ''}}">
|
||||
<button class="btn btn-outline-primary btn-sm me-1" (click)="selectFont(undefined)" [disabled]="selectedFont() === undefined || isReadOnly()">
|
||||
<i class="fa fa-plus me-1" aria-hidden="true"></i>{{t("add")}}
|
||||
</button>
|
||||
<div class="form-check form-switch">
|
||||
<input type="checkbox" formControlName="filter" class="form-check-input" id="filter-out-system-fonts" (change)="hideSystemFonts.set(!hideSystemFonts())">
|
||||
<label for="filter-out-system-fonts" class="form-check-label">{{t('filter-system-fonts-label')}}</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ul style="height: 100%" class="list-group list-group-flush">
|
||||
|
||||
@for (font of visibleFonts(); track font.id) {
|
||||
<ng-container [ngTemplateOutlet]="fontOption" [ngTemplateOutletContext]="{ $implicit: font}"></ng-container>
|
||||
} @empty {
|
||||
<app-loading [loading]="fonts().length === 0"></app-loading>
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-lg-9 col-md-7 col-sm-4 col-xs-4 ps-3">
|
||||
<div class="card p-3">
|
||||
|
||||
@let currentFont = selectedFont();
|
||||
@if (currentFont === 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 (uploadMode()) {
|
||||
@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)="uploadMode.set('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') {
|
||||
<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(); uploadMode.set('all');"
|
||||
[disabled]="(form.get('fontUrl')?.value).length === 0">
|
||||
{{t('load')}}
|
||||
</button>
|
||||
</div>
|
||||
<button class="btn btn-secondary col-auto" href="javascript:void(0)" (click)="uploadMode.set('all')">
|
||||
<i class="fas fa-share" aria-hidden="true" style="transform: rotateY(180deg)"></i>
|
||||
<span class="phone-hidden">{{t('back')}}</span>
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</ng-template>
|
||||
|
||||
</ngx-file-drop>
|
||||
}
|
||||
} @else {
|
||||
<h4>
|
||||
{{currentFont.name | sentenceCase}}
|
||||
<div class="float-end">
|
||||
@if (currentFont.provider !== FontProvider.System && currentFont.name !== FontService.DefaultEpubFont) {
|
||||
<button class="btn btn-danger me-1" (click)="deleteFont(currentFont.id)" [disabled]="isReadOnly()">{{t('delete')}}</button>
|
||||
}
|
||||
</div>
|
||||
</h4>
|
||||
|
||||
<div>
|
||||
<ng-container [ngTemplateOutlet]="availableFont" [ngTemplateOutletContext]="{ $implicit: currentFont}"></ng-container>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<ng-template #fontOption let-item>
|
||||
@if (item !== undefined) {
|
||||
@let currentFont = selectedFont();
|
||||
<li
|
||||
[@loadNewFontAnimation]="animationState(item)"
|
||||
class="list-group-item d-flex justify-content-between align-items-start clickable rounded mt-1"
|
||||
[class.active]="currentFont && currentFont?.name === item.name"
|
||||
(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 === FontService.DefaultEpubFont) {
|
||||
<div class="ms-2 me-auto fs-6">
|
||||
{{t('no-preview')}}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (item.name !== FontService.DefaultEpubFont) {
|
||||
<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'}">
|
||||
{{t('brown-fox')}}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</ng-template>
|
||||
</ng-container>
|
||||
@ -0,0 +1,48 @@
|
||||
.pill {
|
||||
font-size: .8rem;
|
||||
background-color: var(--card-bg-color);
|
||||
border-radius: 0.375rem;
|
||||
}
|
||||
|
||||
.scroller {
|
||||
max-height: calc(100dvh - 180px);
|
||||
overflow-y: auto;
|
||||
scrollbar-gutter: stable;
|
||||
|
||||
@media (max-width: 576px) {
|
||||
max-height: calc(100dvh - 480px);
|
||||
}
|
||||
}
|
||||
|
||||
.list-group-item {
|
||||
background-color: transparent;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--card-bg-color);
|
||||
}
|
||||
|
||||
&.active {
|
||||
background-color: var(--primary-color);
|
||||
}
|
||||
}
|
||||
|
||||
.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;
|
||||
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,214 @@
|
||||
import {ChangeDetectionStrategy, Component, computed, inject, OnInit, signal} from '@angular/core';
|
||||
import {FontService} from "src/app/_services/font.service";
|
||||
import {AccountService} from "../../../_services/account.service";
|
||||
import {ConfirmService} from "../../../shared/confirm.service";
|
||||
import {EpubFont, FontProvider} from 'src/app/_models/preferences/epub-font';
|
||||
import {NgxFileDropEntry, NgxFileDropModule} from "ngx-file-drop";
|
||||
import {DOCUMENT, NgStyle, NgTemplateOutlet} from "@angular/common";
|
||||
import {LoadingComponent} from "../../../shared/loading/loading.component";
|
||||
import {FormControl, FormGroup, FormsModule, ReactiveFormsModule} from "@angular/forms";
|
||||
import {SentenceCasePipe} from "../../../_pipes/sentence-case.pipe";
|
||||
import {SiteThemeProviderPipe} from "../../../_pipes/site-theme-provider.pipe";
|
||||
import {translate, TranslocoDirective} from "@jsverse/transloco";
|
||||
import {animate, style, transition, trigger} from "@angular/animations";
|
||||
import {WikiLink} from "../../../_models/wiki";
|
||||
import {ToastrService} from "ngx-toastr";
|
||||
|
||||
@Component({
|
||||
selector: 'app-font-manager',
|
||||
imports: [
|
||||
LoadingComponent,
|
||||
NgxFileDropModule,
|
||||
FormsModule,
|
||||
ReactiveFormsModule,
|
||||
SentenceCasePipe,
|
||||
SiteThemeProviderPipe,
|
||||
NgTemplateOutlet,
|
||||
TranslocoDirective,
|
||||
NgStyle,
|
||||
],
|
||||
templateUrl: './font-manager.component.html',
|
||||
styleUrl: './font-manager.component.scss',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
standalone: true,
|
||||
animations: [
|
||||
trigger('loadNewFontAnimation', [
|
||||
transition('void => loaded', [
|
||||
style({ backgroundColor: 'var(--primary-color)' }),
|
||||
animate('2s', style({ backgroundColor: 'var(--list-group-item-bg-color)' }))
|
||||
])
|
||||
])
|
||||
],
|
||||
})
|
||||
export class FontManagerComponent implements OnInit {
|
||||
private readonly document = inject(DOCUMENT);
|
||||
private readonly accountService = inject(AccountService);
|
||||
private readonly confirmService = inject(ConfirmService);
|
||||
private readonly toastr = inject(ToastrService);
|
||||
protected readonly fontService = inject(FontService);
|
||||
|
||||
protected readonly FontProvider = FontProvider;
|
||||
protected readonly WikiLink = WikiLink.EpubFontManager;
|
||||
protected readonly user = this.accountService.currentUserSignal;
|
||||
protected readonly isReadOnly = computed(() => {
|
||||
const u = this.accountService.currentUserSignal();
|
||||
if (!u) return true;
|
||||
|
||||
return this.accountService.hasReadOnlyRole(u);
|
||||
});
|
||||
|
||||
fonts = signal<EpubFont[]>([]);
|
||||
visibleFonts = computed(() => {
|
||||
const fonts = this.fonts();
|
||||
const hide = this.hideSystemFonts();
|
||||
if (!hide) return fonts;
|
||||
|
||||
return fonts.filter(f => f.provider === FontProvider.User);
|
||||
});
|
||||
|
||||
hideSystemFonts = signal(false);
|
||||
|
||||
|
||||
/**
|
||||
* Fonts added during the current sessions
|
||||
*/
|
||||
loadedFonts = signal<EpubFont[]>([]);
|
||||
|
||||
selectedFont = signal<EpubFont | undefined>(undefined);
|
||||
isUploadingFont = signal(false);
|
||||
uploadMode = signal<'file' | 'url' | 'all'>('all');
|
||||
|
||||
form: FormGroup = new FormGroup({
|
||||
fontUrl: new FormControl('', []),
|
||||
filter: new FormControl(this.hideSystemFonts(), [])
|
||||
});
|
||||
|
||||
files: NgxFileDropEntry[] = [];
|
||||
// When accepting more types, also need to update in the Parser
|
||||
acceptableExtensions = ['.woff2', '.woff', '.ttf', '.otf'].join(',');
|
||||
|
||||
ngOnInit() {
|
||||
this.loadFonts();
|
||||
}
|
||||
|
||||
loadFonts() {
|
||||
this.fontService.getFonts().subscribe(fonts => {
|
||||
this.fonts.set(fonts);
|
||||
|
||||
// First load, if there are user provided fonts, switch the filter toggle
|
||||
if (fonts.filter(f => f.provider != FontProvider.System).length > 0 && !this.hideSystemFonts()) {
|
||||
this.setHideSystemFontsFilter(true);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
selectFont(font: EpubFont | undefined) {
|
||||
if (font === undefined) {
|
||||
this.selectedFont.set(font);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
if (font.name !== FontService.DefaultEpubFont) {
|
||||
this.fontService.getFontFace(font).load().then(loadedFace => {
|
||||
(this.document as any).fonts.add(loadedFace);
|
||||
this.selectedFont.set(font);
|
||||
});
|
||||
} else {
|
||||
this.selectedFont.set(font);
|
||||
}
|
||||
}
|
||||
|
||||
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.addFont(f);
|
||||
this.isUploadingFont.set(false);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
this.isUploadingFont.set(true);
|
||||
}
|
||||
|
||||
uploadFromUrl() {
|
||||
const url = this.form.get('fontUrl')?.value.trim();
|
||||
if (!url || url === '') return;
|
||||
|
||||
this.isUploadingFont.set(true);
|
||||
this.fontService.uploadFromUrl(url).subscribe((f) => {
|
||||
this.addFont(f);
|
||||
this.form.get('fontUrl')!.setValue('');
|
||||
this.isUploadingFont.set(false);
|
||||
});
|
||||
}
|
||||
|
||||
async deleteFont(id: number) {
|
||||
if (!await this.confirmService.confirm(translate('toasts.confirm-delete-font'))) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if this font is in use
|
||||
this.fontService.isFontInUse(id).subscribe(async (inUse) => {
|
||||
if (!inUse) {
|
||||
this.performDeleteFont(id);
|
||||
return;
|
||||
}
|
||||
|
||||
const isAdmin = this.accountService.hasAdminRole(this.accountService.currentUserSignal()!);
|
||||
|
||||
if (!isAdmin) {
|
||||
this.toastr.info(translate('toasts.font-in-use'))
|
||||
return;
|
||||
}
|
||||
|
||||
if (!await this.confirmService.confirm(translate('toasts.confirm-force-delete-font'))) {
|
||||
return;
|
||||
}
|
||||
this.performDeleteFont(id, true);
|
||||
})
|
||||
|
||||
|
||||
}
|
||||
|
||||
private setHideSystemFontsFilter(value: boolean) {
|
||||
this.hideSystemFonts.set(value);
|
||||
this.form.get('filter')?.setValue(value);
|
||||
}
|
||||
|
||||
private performDeleteFont(id: number, force: boolean = false) {
|
||||
this.fontService.deleteFont(id, force).subscribe(() => {
|
||||
this.fonts.update(x => x.filter(f => f.id !== id));
|
||||
|
||||
// Select the first font in the list
|
||||
const visibleFonts = this.visibleFonts();
|
||||
if (visibleFonts.length === 0 && this.hideSystemFonts()) {
|
||||
this.setHideSystemFontsFilter(false);
|
||||
this.selectFont(this.fonts()[0]); // Default
|
||||
return;
|
||||
}
|
||||
|
||||
if (visibleFonts.length > 0) {
|
||||
this.selectFont(visibleFonts[visibleFonts.length - 1]);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private addFont(font: EpubFont) {
|
||||
this.fonts.update(x => [...x, font]);
|
||||
this.loadedFonts.update(x => [...x, font]);
|
||||
setTimeout(() => this.selectedFont.set(font), 100);
|
||||
}
|
||||
|
||||
animationState(font: EpubFont) {
|
||||
return this.loadedFonts().includes(font) ? 'loaded' : '';
|
||||
}
|
||||
|
||||
protected readonly FontService = FontService;
|
||||
}
|
||||
@ -332,8 +332,8 @@
|
||||
<ng-template #edit>
|
||||
<select class="form-select" aria-describedby="book-reader-heading"
|
||||
formControlName="bookReaderFontFamily">
|
||||
@for (opt of fontFamilies; track opt) {
|
||||
<option [ngValue]="opt">{{opt | titlecase}}</option>
|
||||
@for (opt of fonts(); track opt.id) {
|
||||
<option [ngValue]="opt.name">{{opt.name | titlecase}}</option>
|
||||
}
|
||||
</select>
|
||||
</ng-template>
|
||||
|
||||
@ -1,8 +1,18 @@
|
||||
import {ChangeDetectionStrategy, ChangeDetectorRef, Component, DestroyRef, inject, OnInit, signal} from '@angular/core';
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
ChangeDetectorRef,
|
||||
Component,
|
||||
DestroyRef,
|
||||
effect,
|
||||
inject,
|
||||
OnInit,
|
||||
signal
|
||||
} from '@angular/core';
|
||||
import {ReadingProfileService} from "../../_services/reading-profile.service";
|
||||
import {
|
||||
bookLayoutModes,
|
||||
bookWritingStyles, breakPoints,
|
||||
bookWritingStyles,
|
||||
breakPoints,
|
||||
layoutModes,
|
||||
pageSplitOptions,
|
||||
pdfScrollModes,
|
||||
@ -19,7 +29,7 @@ import {NgStyle, NgTemplateOutlet, TitleCasePipe} from "@angular/common";
|
||||
import {VirtualScrollerModule} from "@iharbeck/ngx-virtual-scroller";
|
||||
import {User} from "../../_models/user";
|
||||
import {AccountService} from "../../_services/account.service";
|
||||
import {debounceTime, distinctUntilChanged, map, take, tap} from "rxjs/operators";
|
||||
import {debounceTime, distinctUntilChanged, tap} from "rxjs/operators";
|
||||
import {SentenceCasePipe} from "../../_pipes/sentence-case.pipe";
|
||||
import {FormControl, FormGroup, FormsModule, ReactiveFormsModule, Validators} from "@angular/forms";
|
||||
import {BookService} from "../../book-reader/_services/book.service";
|
||||
@ -41,7 +51,7 @@ import {SettingItemComponent} from "../../settings/_components/setting-item/sett
|
||||
import {SettingSwitchComponent} from "../../settings/_components/setting-switch/setting-switch.component";
|
||||
import {WritingStylePipe} from "../../_pipes/writing-style.pipe";
|
||||
import {NgbNav, NgbNavContent, NgbNavItem, NgbNavLinkBase, NgbNavOutlet, NgbTooltip} from "@ng-bootstrap/ng-bootstrap";
|
||||
import {catchError, filter, of, switchMap} from "rxjs";
|
||||
import {catchError, filter, forkJoin, of, switchMap} from "rxjs";
|
||||
import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
|
||||
import {LoadingComponent} from "../../shared/loading/loading.component";
|
||||
import {ToastrService} from "ngx-toastr";
|
||||
@ -53,6 +63,8 @@ import {
|
||||
} from "../../settings/_components/setting-colour-picker/setting-color-picker.component";
|
||||
import {ColorscapeService} from "../../_services/colorscape.service";
|
||||
import {Color} from "@iplab/ngx-color-picker";
|
||||
import {FontService} from "../../_services/font.service";
|
||||
import {EpubFont} from "../../_models/preferences/epub-font";
|
||||
|
||||
enum TabId {
|
||||
ImageReader = "image-reader",
|
||||
@ -108,12 +120,13 @@ export class ManageReadingProfilesComponent implements OnInit {
|
||||
private readonly toastr = inject(ToastrService);
|
||||
private readonly confirmService = inject(ConfirmService);
|
||||
private readonly transLoco = inject(TranslocoService);
|
||||
private readonly fontService = inject(FontService);
|
||||
|
||||
virtualScrollerBreakPoint = 20;
|
||||
|
||||
savingProfile = signal(false);
|
||||
fonts = signal<EpubFont[]>([]);
|
||||
|
||||
fontFamilies: Array<string> = [];
|
||||
readingProfiles: ReadingProfile[] = [];
|
||||
user!: User;
|
||||
activeTabId = TabId.ImageReader;
|
||||
@ -128,18 +141,21 @@ export class ManageReadingProfilesComponent implements OnInit {
|
||||
});
|
||||
|
||||
constructor() {
|
||||
this.fontFamilies = this.bookService.getFontFamilies().map(f => f.title);
|
||||
this.cdRef.markForCheck();
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.accountService.currentUser$.pipe(take(1)).subscribe(user => {
|
||||
effect(() => {
|
||||
const user = this.accountService.currentUserSignal();
|
||||
if (user) {
|
||||
this.user = user;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
forkJoin([
|
||||
this.fontService.getFonts(),
|
||||
this.readingProfileService.getAllProfiles()
|
||||
]).subscribe(([fonts, profiles]) => {
|
||||
this.fonts.set(fonts);
|
||||
|
||||
this.readingProfileService.getAllProfiles().subscribe(profiles => {
|
||||
this.readingProfiles = profiles;
|
||||
this.loading = false;
|
||||
this.setupForm();
|
||||
@ -149,7 +165,6 @@ export class ManageReadingProfilesComponent implements OnInit {
|
||||
|
||||
this.cdRef.markForCheck();
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
async delete(readingProfile: ReadingProfile) {
|
||||
@ -175,7 +190,7 @@ export class ManageReadingProfilesComponent implements OnInit {
|
||||
return (val <= 0) ? '' : val + '%'
|
||||
}
|
||||
|
||||
setupForm() {
|
||||
async setupForm() {
|
||||
if (this.selectedProfile == null) {
|
||||
return;
|
||||
}
|
||||
@ -183,8 +198,8 @@ export class ManageReadingProfilesComponent implements OnInit {
|
||||
|
||||
this.readingProfileForm = new FormGroup({})
|
||||
|
||||
if (this.fontFamilies.indexOf(this.selectedProfile.bookReaderFontFamily) < 0) {
|
||||
this.selectedProfile.bookReaderFontFamily = 'default';
|
||||
if (this.fonts().find(font => font.name === this.selectedProfile?.bookReaderFontFamily) === undefined) {
|
||||
this.selectedProfile.bookReaderFontFamily = FontService.DefaultEpubFont;
|
||||
}
|
||||
|
||||
this.readingProfileForm.addControl('name', new FormControl(this.selectedProfile.name, Validators.required));
|
||||
|
||||
@ -46,7 +46,6 @@ export class ManageUserPreferencesComponent implements OnInit {
|
||||
protected readonly licenseService = inject(LicenseService);
|
||||
|
||||
|
||||
fontFamilies: Array<string> = [];
|
||||
locales: Array<KavitaLocale> = [];
|
||||
|
||||
settingsForm: FormGroup = new FormGroup({});
|
||||
@ -65,9 +64,6 @@ export class ManageUserPreferencesComponent implements OnInit {
|
||||
|
||||
|
||||
constructor() {
|
||||
this.fontFamilies = this.bookService.getFontFamilies().map(f => f.title);
|
||||
this.cdRef.markForCheck();
|
||||
|
||||
this.localizationService.getLocales().subscribe(res => {
|
||||
this.locales = res;
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user