UI centered font management

This commit is contained in:
Fesaa 2024-07-09 15:57:47 +02:00
parent 1f2ea8f59d
commit a956bb18ec
No known key found for this signature in database
GPG key ID: 9EA789150BEE0E27
18 changed files with 774 additions and 146 deletions

View file

@ -1,72 +1,103 @@
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.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 Microsoft.Extensions.Logging;
using MimeTypes;
using Serilog;
namespace API.Controllers;
public class FontController : BaseApiController
{
private readonly IUnitOfWork _unitOfWork;
private readonly IFontService _fontService;
private readonly IDirectoryService _directoryService;
private readonly ITaskScheduler _taskScheduler;
private readonly IFontService _fontService;
private readonly IMapper _mapper;
public FontController(IUnitOfWork unitOfWork, IFontService fontService, ITaskScheduler taskScheduler)
private readonly Regex _fontFileExtensionRegex = new(Parser.FontFileExtensions, RegexOptions.IgnoreCase, Parser.RegexTimeout);
public FontController(IUnitOfWork unitOfWork, ITaskScheduler taskScheduler, IDirectoryService directoryService,
IFontService fontService, IMapper mapper)
{
_unitOfWork = unitOfWork;
_fontService = fontService;
_directoryService = directoryService;
_taskScheduler = taskScheduler;
_fontService = fontService;
_mapper = mapper;
}
[ResponseCache(CacheProfileName = "10Minute")]
[AllowAnonymous]
[HttpGet("GetFonts")]
[HttpGet("all")]
public async Task<ActionResult<IEnumerable<EpubFontDto>>> GetFonts()
{
return Ok(await _unitOfWork.EpubFontRepository.GetFontDtos());
return Ok(await _unitOfWork.EpubFontRepository.GetFontDtosAsync());
}
[HttpGet]
[AllowAnonymous]
[HttpGet("download-font")]
public async Task<IActionResult> GetFont(int fontId)
public async Task<IActionResult> GetFont(int fontId, string apiKey)
{
try
{
var font = await _unitOfWork.EpubFontRepository.GetFont(fontId);
var userId = await _unitOfWork.UserRepository.GetUserIdByApiKeyAsync(apiKey);
if (userId == 0) return BadRequest();
var font = await _unitOfWork.EpubFontRepository.GetFontAsync(fontId);
if (font == null) return NotFound();
var contentType = GetContentType(font.FileName);
return File(await _fontService.GetContent(fontId), contentType, font.FileName);
}
catch (KavitaException ex)
{
return BadRequest(ex.Message);
}
var contentType = MimeTypeMap.GetMimeType(Path.GetExtension(font.FileName));
var path = Path.Join(_directoryService.EpubFontDirectory, font.FileName);
return PhysicalFile(path, contentType);
}
[AllowAnonymous]
[HttpPost("scan")]
public IActionResult Scan()
[HttpDelete]
public async Task<IActionResult> DeleteFont(int fontId)
{
_taskScheduler.ScanEpubFonts();
await _fontService.Delete(fontId);
return Ok();
}
private string GetContentType(string fileName)
[HttpPost("upload")]
public async Task<ActionResult<EpubFontDto>> UploadFont(IFormFile formFile)
{
var extension = Path.GetExtension(fileName).ToLowerInvariant();
return extension switch
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-url")]
public async Task<ActionResult<EpubFontDto>> UploadFontByUrl(string url)
{
".ttf" => "application/font-tff",
".otf" => "application/font-otf",
".woff" => "application/font-woff",
".woff2" => "application/font-woff2",
_ => "application/octet-stream",
};
throw new NotImplementedException();
}
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

@ -15,10 +15,12 @@ public interface IEpubFontRepository
void Add(EpubFont font);
void Remove(EpubFont font);
void Update(EpubFont font);
Task<IEnumerable<EpubFontDto>> GetFontDtos();
Task<EpubFontDto?> GetFontDto(int fontId);
Task<IEnumerable<EpubFont>> GetFonts();
Task<EpubFont?> GetFont(int fontId);
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
@ -47,14 +49,14 @@ public class EpubFontRepository: IEpubFontRepository
_context.Entry(font).State = EntityState.Modified;
}
public async Task<IEnumerable<EpubFontDto>> GetFontDtos()
public async Task<IEnumerable<EpubFontDto>> GetFontDtosAsync()
{
return await _context.EpubFont
.ProjectTo<EpubFontDto>(_mapper.ConfigurationProvider)
.ToListAsync();
}
public async Task<EpubFontDto?> GetFontDto(int fontId)
public async Task<EpubFontDto?> GetFontDtoAsync(int fontId)
{
return await _context.EpubFont
.Where(f => f.Id == fontId)
@ -62,16 +64,35 @@ public class EpubFontRepository: IEpubFontRepository
.FirstOrDefaultAsync();
}
public async Task<IEnumerable<EpubFont>> GetFonts()
public async Task<EpubFontDto?> GetFontDtoByNameAsync(string name)
{
return await _context.EpubFont
.Where(f => f.Name.Equals(name))
.ProjectTo<EpubFontDto>(_mapper.ConfigurationProvider)
.FirstOrDefaultAsync();
}
public async Task<IEnumerable<EpubFont>> GetFontsAsync()
{
return await _context.EpubFont
.ToListAsync();
}
public async Task<EpubFont?> GetFont(int fontId)
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

@ -37,7 +37,6 @@ public interface ITaskScheduler
void CovertAllCoversToEncoding();
Task CleanupDbEntries();
Task CheckForUpdate();
void ScanEpubFonts();
}
public class TaskScheduler : ITaskScheduler
@ -60,7 +59,6 @@ public class TaskScheduler : ITaskScheduler
private readonly ILicenseService _licenseService;
private readonly IExternalMetadataService _externalMetadataService;
private readonly ISmartCollectionSyncService _smartCollectionSyncService;
private readonly IFontService _fontService;
private readonly IEventHub _eventHub;
public static BackgroundJobServer Client => new ();
@ -98,8 +96,7 @@ public class TaskScheduler : ITaskScheduler
ICleanupService cleanupService, IStatsService statsService, IVersionUpdaterService versionUpdaterService,
IThemeService themeService, IWordCountAnalyzerService wordCountAnalyzerService, IStatisticService statisticService,
IMediaConversionService mediaConversionService, IScrobblingService scrobblingService, ILicenseService licenseService,
IExternalMetadataService externalMetadataService, ISmartCollectionSyncService smartCollectionSyncService, IEventHub eventHub,
IFontService fontService)
IExternalMetadataService externalMetadataService, ISmartCollectionSyncService smartCollectionSyncService, IEventHub eventHub)
{
_cacheService = cacheService;
_logger = logger;
@ -118,7 +115,6 @@ public class TaskScheduler : ITaskScheduler
_licenseService = licenseService;
_externalMetadataService = externalMetadataService;
_smartCollectionSyncService = smartCollectionSyncService;
_fontService = fontService;
_eventHub = eventHub;
}
@ -448,13 +444,6 @@ public class TaskScheduler : ITaskScheduler
await _versionUpdaterService.PushUpdate(update);
}
// TODO: Make this auto scan from time to time?
public void ScanEpubFonts()
{
_logger.LogInformation("Starting Epub Font scam");
BackgroundJob.Enqueue(() => _fontService.Scan());
}
/// <summary>
/// If there is an enqueued or scheduled task for <see cref="ScannerService.ScanLibrary"/> method
/// </summary>

View file

@ -1,4 +1,5 @@
using System;
using System.IO;
using System.Linq;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
@ -15,93 +16,99 @@ namespace API.Services.Tasks;
public interface IFontService
{
Task<byte[]> GetContent(int fontId);
Task Scan();
Task<EpubFont> CreateFontFromFileAsync(string path);
Task Delete(int fontId);
}
public class FontService: IFontService
{
public static readonly string DefaultFont = "default";
private readonly IDirectoryService _directoryService;
private readonly IUnitOfWork _unitOfWork;
private readonly IHubContext<MessageHub> _messageHub;
private readonly ILogger<FontService> _logger;
public FontService(IDirectoryService directoryService, IUnitOfWork unitOfWork, IHubContext<MessageHub> messageHub,
ILogger<FontService> logger)
public FontService(IDirectoryService directoryService, IUnitOfWork unitOfWork, ILogger<FontService> logger)
{
_directoryService = directoryService;
_unitOfWork = unitOfWork;
_messageHub = messageHub;
_logger = logger;
}
public async Task<byte[]> GetContent(int fontId)
public async Task<EpubFont> CreateFontFromFileAsync(string path)
{
// TODO: Differentiate between Provider.User & Provider.System
var font = await _unitOfWork.EpubFontRepository.GetFont(fontId);
if (font == null) throw new KavitaException("Font file missing or invalid");
var fontFile = _directoryService.FileSystem.Path.Join(_directoryService.EpubFontDirectory, font.FileName);
if (string.IsNullOrEmpty(fontFile) || !_directoryService.FileSystem.File.Exists(fontFile))
throw new KavitaException("Font file missing or invalid");
return await _directoryService.FileSystem.File.ReadAllBytesAsync(fontFile);
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");
}
public async Task Scan()
{
_directoryService.Exists(_directoryService.EpubFontDirectory);
var reservedNames = Seed.DefaultFonts.Select(f => f.NormalizedName).ToList();
var fontFiles =
_directoryService.GetFilesWithExtension(Parser.NormalizePath(_directoryService.EpubFontDirectory), @"\.[woff2|tff|otf|woff]")
.Where(name => !reservedNames.Contains(Parser.Normalize(name))).ToList();
var allFonts = (await _unitOfWork.EpubFontRepository.GetFonts()).ToList();
var userFonts = allFonts.Where(f => f.Provider == FontProvider.User).ToList();
foreach (var userFont in userFonts)
{
var filePath = Parser.NormalizePath(
_directoryService.FileSystem.Path.Join(_directoryService.EpubFontDirectory, userFont.FileName));
if (_directoryService.FileSystem.File.Exists(filePath)) continue;
allFonts.Remove(userFont);
await RemoveFont(userFont);
// TODO: Send update to UI
_logger.LogInformation("Removed a font because it didn't exist on disk {FilePath}", filePath);
}
var allFontNames = allFonts.Select(f => f.NormalizedName).ToList();
foreach (var fontFile in fontFiles)
{
var nakedFileName = _directoryService.FileSystem.Path.GetFileNameWithoutExtension(fontFile);
// TODO: discuss this, using this to "prettyfy" the file name, to display in the UI
var fontName = Regex.Replace(nakedFileName, "[^a-zA-Z0-9]", " ");
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 (allFontNames.Contains(normalizedName)) continue;
_unitOfWork.EpubFontRepository.Add(new EpubFont()
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 = _directoryService.FileSystem.Path.GetFileName(fontFile),
Provider = FontProvider.User,
});
// TODO: Send update to UI
_logger.LogInformation("Added a new font from disk {FontFile}", fontFile);
}
if (_unitOfWork.HasChanges())
{
FileName = Path.GetFileName(finalLocation),
Provider = FontProvider.User
};
_unitOfWork.EpubFontRepository.Add(font);
await _unitOfWork.CommitAsync();
}
// TODO: Send update to UI
_logger.LogInformation("Finished FontService#Scan");
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 async Task RemoveFont(EpubFont font)
{
// TODO: Default font? Ask in kavita discord if needed, as we can always fallback to the browsers default 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,18 @@
/**
* 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;
created: Date;
lastModified: Date;
}

View file

@ -0,0 +1,61 @@
import {DestroyRef, inject, Injectable} from "@angular/core";
import {map, ReplaySubject} from "rxjs";
import {EpubFont} from "../_models/preferences/epub-font";
import {environment} from 'src/environments/environment';
import {HttpClient} from "@angular/common/http";
import {EVENTS, MessageHubService} from "./message-hub.service";
import {NgxFileDropEntry} from "ngx-file-drop";
import {AccountService} from "./account.service";
import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
import {NotificationProgressEvent} from "../_models/events/notification-progress-event";
@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 {
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) {
}
deleteFont(id: number) {
return this.httpClient.delete(this.baseUrl + `font?fontId=${id}`);
}
}

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,11 +585,10 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy {
}
ngOnInit(): void {
this.bookService.getEpubFonts().subscribe(fonts => {
this.fontService.getFonts().subscribe(fonts => {
fonts.forEach(font => {
const fontFace = new FontFace(font.name, `url(${this.bookService.baseUrl}Font/download-font?fontId=${font.id})`);
fontFace.load().then(loadedFace => {
(document as any).fonts.add(loadedFace);
this.fontService.getFontFace(font).load().then(loadedFace => {
(this.document as any).fonts.add(loadedFace);
});
})
})

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 {BookService, EpubFont} 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
@ -171,10 +173,10 @@ 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.bookService.getEpubFonts().subscribe(fonts => {
this.fontService.getFonts().subscribe(fonts => {
this.fontFamilies = fonts;
this.fontOptions = fonts.map(f => f.name);
this.cdRef.markForCheck();

View file

@ -4,20 +4,6 @@ import { TextResonse } from 'src/app/_types/text-response';
import { environment } from 'src/environments/environment';
import { BookChapterItem } from '../_models/book-chapter-item';
import { BookInfo } from '../_models/book-info';
import {Observable} from "rxjs";
export enum FontProvider {
System = 1,
User = 2,
}
export interface EpubFont {
id: number;
name: string;
provider: FontProvider;
created: Date;
lastModified: Date;
}
@Injectable({
providedIn: 'root'
@ -28,10 +14,6 @@ export class BookService {
constructor(private http: HttpClient) { }
getEpubFonts(): Observable<EpubFont[]> {
return this.http.get<Array<EpubFont>>(this.baseUrl + 'Font/GetFonts')
}
getBookChapters(chapterId: number) {
return this.http.get<Array<BookChapterItem>>(this.baseUrl + 'book/' + chapterId + '/chapters');
}

View file

@ -0,0 +1,118 @@
<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="d-flex justify-content-center flex-grow-1">
<div class="card d-flex col-lg-9 col-md-7 col-sm-4 col-xs-4 p-3">
<div class="row mb-3">
<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 (isUploadingFont) {
<app-loading [loading]="isUploadingFont"></app-loading>
} @else {
<form [formGroup]="form">
<ngx-file-drop (onFileDrop)="dropped($event)" [accept]="acceptableExtensions" [directory]="false"
dropZoneClassName="file-upload" contentClassName="file-upload-zone">
<ng-template ngx-file-drop-content-tmp let-openFileSelector="openFileSelector">
<div class="row g-0 p-3" *ngIf="mode === 'all'">
<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>
<ng-container *ngIf="mode === '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-font">{{t('url-label')}}</label>
<input type="text" autofocus autocomplete="off" class="form-control" formControlName="fontUrl" placeholder="https://" id="load-font">
<button class="btn btn-outline-secondary" type="button" id="load-font-addon" (click)="uploadFromUrl(); mode='all';" [disabled]="(form.get('fontUrl')?.value).length === 0">
{{t('load')}}
</button>
</div>
<button class="btn btn-secondary col-auto" href="javascript:void(0)" (click)="mode = 'all'">
<i class="fas fa-share" aria-hidden="true" style="transform: rotateY(180deg)"></i>&nbsp;
<span class="phone-hidden">{{t('back')}}</span>
</button>
</div>
</ng-container>
</ng-template>
</ngx-file-drop>
</form>
}
</div>
</div>
<div class="row mt-3 mb-3 g-0">
<div class="scroller">
<div class="pe-2">
<ul style="height: 100%" class="list-group list-group-flush">
@for (font of fontService.fonts$ | async; track font.name) {
<ng-container [ngTemplateOutlet]="availableFont" [ngTemplateOutletContext]="{ $implicit: font}"></ng-container>
}
</ul>
</div>
</div>
</div>
</div>
<ng-template #availableFont let-item>
<li class="list-group-item d-flex flex-column align-items-start border-bottom border-info">
<div class="d-flex justify-content-between w-100">
<div class="ms-2 me-auto fs-5">
<div class="fw-bold">{{item.name | sentenceCase}}</div>
</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 *ngIf="item.hasOwnProperty('provider')" class="align-self-center">
<span class="pill p-1 mx-1 provider">{{item.provider | siteThemeProvider}}</span>
</div>
</div>
</div>
<span class="p-1 me-1 preview mt-2 flex-grow-1 text-center w-100 fs-4 fs-lg-3" [ngStyle]="{'font-family': item.name, 'word-break': 'keep-all'}">
The quick brown fox jumps over the lazy dog
</span>
</li>
</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,123 @@
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";
@Component({
selector: 'app-font-manager',
imports: [
TranslocoDirective,
AsyncPipe,
LoadingComponent,
NgxFileDropModule,
FormsModule,
NgIf,
ReactiveFormsModule,
SentenceCasePipe,
SiteThemeProviderPipe,
NgTemplateOutlet,
NgStyle
],
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;
acceptableExtensions = ['.woff2', 'woff', 'tff', 'otf'].join(',');
mode: 'file' | 'url' | 'all' = 'all';
isUploadingFont: boolean = false;
constructor(@Inject(DOCUMENT) private document: Document) {
}
ngOnInit() {
this.form = this.fb.group({
coverImageUrl: new FormControl('', [])
});
this.cdRef.markForCheck();
this.fontService.getFonts().subscribe(fonts => {
this.fonts = fonts;
this.fonts.forEach(font => {
this.fontService.getFontFace(font).load().then(loadedFace => {
(this.document as any).fonts.add(loadedFace);
});
})
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(url?: string) {
url = url || this.form.get('coverImageUrl')?.value.trim();
if (!url || url === '') return;
}
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;
}
}

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';
@ -77,6 +76,8 @@ 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 +91,7 @@ enum FragmentID {
Preferences = '',
Clients = 'clients',
Theme = 'theme',
Font = 'font',
Devices = 'devices',
Stats = 'stats',
Scrobbling = 'scrobbling'
@ -105,14 +107,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 +155,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},
];
@ -173,7 +176,7 @@ export class UserPreferencesComponent implements OnInit, OnDestroy {
this.cdRef.markForCheck();
});
this.bookService.getEpubFonts().subscribe(res => {
this.fontService.getFonts().subscribe(res => {
this.fontFamilies = res.map(f => f.name);
this.cdRef.markForCheck();
})

View file

@ -102,6 +102,7 @@
"preferences-tab": "Preferences",
"3rd-party-clients-tab": "3rd Party Clients",
"theme-tab": "Theme",
"font-tab": "Font",
"devices-tab": "Devices",
"stats-tab": "Stats",
"scrobbling-tab": "Scrobbling",
@ -203,6 +204,21 @@
"preview-title": "Preview"
},
"font-manager": {
"title": "Font Manager",
"description": "Kavita comes with a few fonts, but you can add your own. This page is for adding and removing fonts, use the book reading settings to choose the font you want to use.",
"enter-an-url-pre-title": "Enter an {{url}}",
"url": "url",
"drag-n-drop": "{{cover-image-chooser.drag-n-drop}}",
"upload": "{{cover-image-chooser.upload}}",
"upload-continued": "a font file",
"preview-default": "Upload your own font via file or url",
"delete": "{{common.delete}}",
"url-label": "Url",
"back": "Back",
"load": "Load"
},
"theme": {
"theme-dark": "Dark",
"theme-black": "Black",
@ -1972,7 +1988,10 @@
"invalid-password-reset-url": "Invalid reset password url",
"delete-theme-in-use": "Theme is currently in use by at least one user, cannot delete",
"theme-manual-upload": "There was an issue creating Theme from manual upload",
"theme-already-in-use": "Theme already exists by that name"
"theme-already-in-use": "Theme already exists by that name",
"delete-font-in-use": "Font is currently in use by at least one user, cannot delete",
"font-manual-upload": "There was an issue creating Font from manual upload",
"font-already-in-use": "Font already exists by that name"
},
"metadata-builder": {
@ -2218,7 +2237,8 @@
"pdf-book-mode-screen-size": "Screen too small for Book mode",
"stack-imported": "Stack Imported",
"confirm-delete-theme": "Removing this theme will delete it from the disk. You can grab it from temp directory before removal",
"mal-token-required": "MAL Token is required, set in User Settings"
"mal-token-required": "MAL Token is required, set in User Settings",
"confirm-delete-font": "Removing this font will delete it from the disk. You can grab it from temp directory before removal"
},
"actionable": {

View file

@ -2314,6 +2314,180 @@
}
}
},
"/api/Font/all": {
"get": {
"tags": [
"Font"
],
"responses": {
"200": {
"description": "OK",
"content": {
"text/plain": {
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/EpubFontDto"
}
}
},
"application/json": {
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/EpubFontDto"
}
}
},
"text/json": {
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/EpubFontDto"
}
}
}
}
}
}
}
},
"/api/Font": {
"get": {
"tags": [
"Font"
],
"parameters": [
{
"name": "fontId",
"in": "query",
"schema": {
"type": "integer",
"format": "int32"
}
},
{
"name": "apiKey",
"in": "query",
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "OK"
}
}
},
"delete": {
"tags": [
"Font"
],
"parameters": [
{
"name": "fontId",
"in": "query",
"schema": {
"type": "integer",
"format": "int32"
}
}
],
"responses": {
"200": {
"description": "OK"
}
}
}
},
"/api/Font/upload": {
"post": {
"tags": [
"Font"
],
"requestBody": {
"content": {
"multipart/form-data": {
"schema": {
"type": "object",
"properties": {
"formFile": {
"type": "string",
"format": "binary"
}
}
},
"encoding": {
"formFile": {
"style": "form"
}
}
}
}
},
"responses": {
"200": {
"description": "OK",
"content": {
"text/plain": {
"schema": {
"$ref": "#/components/schemas/EpubFontDto"
}
},
"application/json": {
"schema": {
"$ref": "#/components/schemas/EpubFontDto"
}
},
"text/json": {
"schema": {
"$ref": "#/components/schemas/EpubFontDto"
}
}
}
}
}
}
},
"/api/Font/upload-url": {
"post": {
"tags": [
"Font"
],
"parameters": [
{
"name": "url",
"in": "query",
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "OK",
"content": {
"text/plain": {
"schema": {
"$ref": "#/components/schemas/EpubFontDto"
}
},
"application/json": {
"schema": {
"$ref": "#/components/schemas/EpubFontDto"
}
},
"text/json": {
"schema": {
"$ref": "#/components/schemas/EpubFontDto"
}
}
}
}
}
}
},
"/api/Health": {
"get": {
"tags": [
@ -13610,7 +13784,7 @@
},
"missingSeriesFromSource": {
"type": "string",
"description": "A \r\n separated string of all missing series",
"description": "A \n separated string of all missing series",
"nullable": true
},
"appUser": {
@ -13705,7 +13879,7 @@
},
"missingSeriesFromSource": {
"type": "string",
"description": "A \r\n separated string of all missing series",
"description": "A \n separated string of all missing series",
"nullable": true
}
},
@ -16146,6 +16320,36 @@
"additionalProperties": false,
"description": "Represents if Test Email Service URL was successful or not and if any error occured"
},
"EpubFontDto": {
"type": "object",
"properties": {
"id": {
"type": "integer",
"format": "int32"
},
"name": {
"type": "string",
"nullable": true
},
"provider": {
"enum": [
1,
2
],
"type": "integer",
"format": "int32"
},
"created": {
"type": "string",
"format": "date-time"
},
"lastModified": {
"type": "string",
"format": "date-time"
}
},
"additionalProperties": false
},
"ExternalRating": {
"type": "object",
"properties": {