diff --git a/API/Controllers/FontController.cs b/API/Controllers/FontController.cs index 601f65d0e..7f36bf521 100644 --- a/API/Controllers/FontController.cs +++ b/API/Controllers/FontController.cs @@ -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>> GetFonts() { - return Ok(await _unitOfWork.EpubFontRepository.GetFontDtos()); + return Ok(await _unitOfWork.EpubFontRepository.GetFontDtosAsync()); } + [HttpGet] [AllowAnonymous] - [HttpGet("download-font")] - public async Task GetFont(int fontId) + public async Task GetFont(int fontId, string apiKey) { - try - { - var font = await _unitOfWork.EpubFontRepository.GetFont(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 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 = 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 DeleteFont(int fontId) { - _taskScheduler.ScanEpubFonts(); + await _fontService.Delete(fontId); return Ok(); } - private string GetContentType(string fileName) + [HttpPost("upload")] + public async Task> UploadFont(IFormFile formFile) { - var extension = Path.GetExtension(fileName).ToLowerInvariant(); - return extension switch - { - ".ttf" => "application/font-tff", - ".otf" => "application/font-otf", - ".woff" => "application/font-woff", - ".woff2" => "application/font-woff2", - _ => "application/octet-stream", - }; + 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(font)); + } + + [HttpPost("upload-url")] + public async Task> UploadFontByUrl(string url) + { + throw new NotImplementedException(); + } + + private async Task 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; } } diff --git a/API/Data/Repositories/EpubFontRepository.cs b/API/Data/Repositories/EpubFontRepository.cs index c29938be5..7be343b55 100644 --- a/API/Data/Repositories/EpubFontRepository.cs +++ b/API/Data/Repositories/EpubFontRepository.cs @@ -15,10 +15,12 @@ public interface IEpubFontRepository void Add(EpubFont font); void Remove(EpubFont font); void Update(EpubFont font); - Task> GetFontDtos(); - Task GetFontDto(int fontId); - Task> GetFonts(); - Task GetFont(int fontId); + Task> GetFontDtosAsync(); + Task GetFontDtoAsync(int fontId); + Task GetFontDtoByNameAsync(string name); + Task> GetFontsAsync(); + Task GetFontAsync(int fontId); + Task IsFontInUseAsync(int fontId); } public class EpubFontRepository: IEpubFontRepository @@ -47,14 +49,14 @@ public class EpubFontRepository: IEpubFontRepository _context.Entry(font).State = EntityState.Modified; } - public async Task> GetFontDtos() + public async Task> GetFontDtosAsync() { return await _context.EpubFont .ProjectTo(_mapper.ConfigurationProvider) .ToListAsync(); } - public async Task GetFontDto(int fontId) + public async Task GetFontDtoAsync(int fontId) { return await _context.EpubFont .Where(f => f.Id == fontId) @@ -62,16 +64,35 @@ public class EpubFontRepository: IEpubFontRepository .FirstOrDefaultAsync(); } - public async Task> GetFonts() + public async Task GetFontDtoByNameAsync(string name) + { + return await _context.EpubFont + .Where(f => f.Name.Equals(name)) + .ProjectTo(_mapper.ConfigurationProvider) + .FirstOrDefaultAsync(); + } + + public async Task> GetFontsAsync() { return await _context.EpubFont .ToListAsync(); } - public async Task GetFont(int fontId) + public async Task GetFontAsync(int fontId) { return await _context.EpubFont .Where(f => f.Id == fontId) .FirstOrDefaultAsync(); } + + public async Task 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); + } + } diff --git a/API/Data/Repositories/UserRepository.cs b/API/Data/Repositories/UserRepository.cs index 07723bf1b..70b4ef8f4 100644 --- a/API/Data/Repositories/UserRepository.cs +++ b/API/Data/Repositories/UserRepository.cs @@ -76,6 +76,7 @@ public interface IUserRepository Task> GetAllBookmarksByIds(IList bookmarkIds); Task GetUserByEmailAsync(string email, AppUserIncludes includes = AppUserIncludes.None); Task> GetAllPreferencesByThemeAsync(int themeId); + Task> GetAllPreferencesByFontAsync(string fontName); Task HasAccessToLibrary(int libraryId, int userId); Task HasAccessToSeries(int userId, int seriesId); Task> GetAllUsersAsync(AppUserIncludes includeFlags = AppUserIncludes.None); @@ -260,6 +261,14 @@ public class UserRepository : IUserRepository .ToListAsync(); } + public async Task> GetAllPreferencesByFontAsync(string fontName) + { + return await _context.AppUserPreferences + .Where(p => p.BookReaderFontFamily == fontName) + .AsSplitQuery() + .ToListAsync(); + } + public async Task HasAccessToLibrary(int libraryId, int userId) { return await _context.Library diff --git a/API/Services/TaskScheduler.cs b/API/Services/TaskScheduler.cs index 1c5c803e3..ebfc2f145 100644 --- a/API/Services/TaskScheduler.cs +++ b/API/Services/TaskScheduler.cs @@ -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()); - } - /// /// If there is an enqueued or scheduled task for method /// diff --git a/API/Services/Tasks/FontService.cs b/API/Services/Tasks/FontService.cs index cfa47f67f..a85148857 100644 --- a/API/Services/Tasks/FontService.cs +++ b/API/Services/Tasks/FontService.cs @@ -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 GetContent(int fontId); - Task Scan(); + Task 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; private readonly ILogger _logger; - public FontService(IDirectoryService directoryService, IUnitOfWork unitOfWork, IHubContext messageHub, - ILogger logger) + public FontService(IDirectoryService directoryService, IUnitOfWork unitOfWork, ILogger logger) { _directoryService = directoryService; _unitOfWork = unitOfWork; - _messageHub = messageHub; _logger = logger; } - public async Task GetContent(int fontId) + public async Task 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); - } - - 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) + if (!_directoryService.FileSystem.File.Exists(path)) { - 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); + _logger.LogInformation("Unable to create font from manual upload as font not in temp"); + throw new KavitaException("errors.font-manual-upload"); } - 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 normalizedName = Parser.Normalize(nakedFileName); - if (allFontNames.Contains(normalizedName)) continue; + 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); - _unitOfWork.EpubFontRepository.Add(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()) + if (await _unitOfWork.EpubFontRepository.GetFontDtoByNameAsync(fontName) != null) { - await _unitOfWork.CommitAsync(); + 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 - _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(); } diff --git a/API/Services/Tasks/Scanner/Parser/Parser.cs b/API/Services/Tasks/Scanner/Parser/Parser.cs index 840e7a6d8..f581c8de5 100644 --- a/API/Services/Tasks/Scanner/Parser/Parser.cs +++ b/API/Services/Tasks/Scanner/Parser/Parser.cs @@ -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]", " "); + } } diff --git a/UI/Web/src/app/_models/preferences/epub-font.ts b/UI/Web/src/app/_models/preferences/epub-font.ts new file mode 100644 index 000000000..29ffa2cb6 --- /dev/null +++ b/UI/Web/src/app/_models/preferences/epub-font.ts @@ -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; +} diff --git a/UI/Web/src/app/_services/font.service.ts b/UI/Web/src/app/_services/font.service.ts new file mode 100644 index 000000000..4ff4e5f1c --- /dev/null +++ b/UI/Web/src/app/_services/font.service.ts @@ -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(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>(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(this.baseUrl + "font/upload", formData); + } + + uploadFromUrl(url: string) { + + } + + deleteFont(id: number) { + return this.httpClient.delete(this.baseUrl + `font?fontId=${id}`); + } + +} diff --git a/UI/Web/src/app/book-reader/_components/book-reader/book-reader.component.ts b/UI/Web/src/app/book-reader/_components/book-reader/book-reader.component.ts index 5e76f8007..08cecc80b 100644 --- a/UI/Web/src/app/book-reader/_components/book-reader/book-reader.component.ts +++ b/UI/Web/src/app/book-reader/_components/book-reader/book-reader.component.ts @@ -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); }); }) }) diff --git a/UI/Web/src/app/book-reader/_components/reader-settings/reader-settings.component.ts b/UI/Web/src/app/book-reader/_components/reader-settings/reader-settings.component.ts index 0d19684c3..20b1bb99e 100644 --- a/UI/Web/src/app/book-reader/_components/reader-settings/reader-settings.component.ts +++ b/UI/Web/src/app/book-reader/_components/reader-settings/reader-settings.component.ts @@ -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(); diff --git a/UI/Web/src/app/book-reader/_services/book.service.ts b/UI/Web/src/app/book-reader/_services/book.service.ts index 208ccd1d4..18e96048d 100644 --- a/UI/Web/src/app/book-reader/_services/book.service.ts +++ b/UI/Web/src/app/book-reader/_services/book.service.ts @@ -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 { - return this.http.get>(this.baseUrl + 'Font/GetFonts') - } - getBookChapters(chapterId: number) { return this.http.get>(this.baseUrl + 'book/' + chapterId + '/chapters'); } diff --git a/UI/Web/src/app/user-settings/font-manager/font-manager/font-manager.component.html b/UI/Web/src/app/user-settings/font-manager/font-manager/font-manager.component.html new file mode 100644 index 000000000..fc615be0c --- /dev/null +++ b/UI/Web/src/app/user-settings/font-manager/font-manager/font-manager.component.html @@ -0,0 +1,118 @@ + +
+
+

{{t('title')}}

+
+ +

{{t('description')}}

+ +
+
+ +
+
+
+
+ {{t('preview-default')}} +
+
+
+
+ + @if (isUploadingFont) { + + } @else { +
+ + + + + + +
+
+ + + +
+ +
+ +
+ +
+
+
+ } + +
+
+ +
+
+
+
    + + @for (font of fontService.fonts$ | async; track font.name) { + + } +
+
+
+
+ +
+ + +
  • +
    +
    +
    {{item.name | sentenceCase}}
    +
    + +
    + @if (item.hasOwnProperty('provider') && item.provider === FontProvider.User && item.hasOwnProperty('id')) { + + } + +
    + {{item.provider | siteThemeProvider}} +
    + +
    +
    + + + + The quick brown fox jumps over the lazy dog + +
  • +
    + + + +
    diff --git a/UI/Web/src/app/user-settings/font-manager/font-manager/font-manager.component.scss b/UI/Web/src/app/user-settings/font-manager/font-manager/font-manager.component.scss new file mode 100644 index 000000000..10cde3389 --- /dev/null +++ b/UI/Web/src/app/user-settings/font-manager/font-manager/font-manager.component.scss @@ -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; + + } +} diff --git a/UI/Web/src/app/user-settings/font-manager/font-manager/font-manager.component.ts b/UI/Web/src/app/user-settings/font-manager/font-manager/font-manager.component.ts new file mode 100644 index 000000000..c6275388b --- /dev/null +++ b/UI/Web/src/app/user-settings/font-manager/font-manager/font-manager.component.ts @@ -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 = []; + 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; + } + + +} diff --git a/UI/Web/src/app/user-settings/user-preferences/user-preferences.component.html b/UI/Web/src/app/user-settings/user-preferences/user-preferences.component.html index 4ac8b10b2..6da2b5b9c 100644 --- a/UI/Web/src/app/user-settings/user-preferences/user-preferences.component.html +++ b/UI/Web/src/app/user-settings/user-preferences/user-preferences.component.html @@ -619,6 +619,10 @@ } + @defer (when tab.fragment === FragmentID.Font; prefetch on idle) { + + } + @defer (when tab.fragment === FragmentID.Devices; prefetch on idle) { } diff --git a/UI/Web/src/app/user-settings/user-preferences/user-preferences.component.ts b/UI/Web/src/app/user-settings/user-preferences/user-preferences.component.ts index 582d2bfff..9da748e86 100644 --- a/UI/Web/src/app/user-settings/user-preferences/user-preferences.component.ts +++ b/UI/Web/src/app/user-settings/user-preferences/user-preferences.component.ts @@ -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(); }) diff --git a/UI/Web/src/assets/langs/en.json b/UI/Web/src/assets/langs/en.json index da092a9b0..61da27916 100644 --- a/UI/Web/src/assets/langs/en.json +++ b/UI/Web/src/assets/langs/en.json @@ -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": { diff --git a/openapi.json b/openapi.json index 1cdd53a63..29f60d182 100644 --- a/openapi.json +++ b/openapi.json @@ -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": { @@ -22350,4 +22554,4 @@ "description": "Responsible for all things Want To Read" } ] -} +} \ No newline at end of file