diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/controller/BookController.java b/booklore-api/src/main/java/com/adityachandel/booklore/controller/BookController.java index 6e4b4f17c..8de9e291c 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/controller/BookController.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/controller/BookController.java @@ -102,8 +102,7 @@ public class BookController { @ApiResponse(responseCode = "200", description = "Book content returned successfully") @GetMapping("/{bookId}/content") @CheckBookAccess(bookIdParam = "bookId") - public ResponseEntity getBookContent( - @Parameter(description = "ID of the book") @PathVariable long bookId) throws IOException { + public ResponseEntity getBookContent(@Parameter(description = "ID of the book") @PathVariable long bookId) throws IOException { return bookService.getBookContent(bookId); } @@ -115,8 +114,7 @@ public class BookController { @GetMapping("/{bookId}/download") @PreAuthorize("@securityUtil.canDownload() or @securityUtil.isAdmin()") @CheckBookAccess(bookIdParam = "bookId") - public ResponseEntity downloadBook( - @Parameter(description = "ID of the book to download") @PathVariable("bookId") Long bookId) { + public ResponseEntity downloadBook(@Parameter(description = "ID of the book to download") @PathVariable("bookId") Long bookId) { return bookService.downloadBook(bookId); } @@ -124,8 +122,7 @@ public class BookController { @ApiResponse(responseCode = "200", description = "Viewer settings returned successfully") @GetMapping("/{bookId}/viewer-setting") @CheckBookAccess(bookIdParam = "bookId") - public ResponseEntity getBookViewerSettings( - @Parameter(description = "ID of the book") @PathVariable long bookId) { + public ResponseEntity getBookViewerSettings(@Parameter(description = "ID of the book") @PathVariable long bookId) { return ResponseEntity.ok(bookService.getBookViewerSetting(bookId)); } diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/mapper/custom/BookLoreUserTransformer.java b/booklore-api/src/main/java/com/adityachandel/booklore/mapper/custom/BookLoreUserTransformer.java index 6f4f88150..1dc54489e 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/mapper/custom/BookLoreUserTransformer.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/mapper/custom/BookLoreUserTransformer.java @@ -49,6 +49,7 @@ public class BookLoreUserTransformer { case PER_BOOK_SETTING -> userSettings.setPerBookSetting(objectMapper.readValue(value, BookLoreUser.UserSettings.PerBookSetting.class)); case PDF_READER_SETTING -> userSettings.setPdfReaderSetting(objectMapper.readValue(value, BookLoreUser.UserSettings.PdfReaderSetting.class)); case EPUB_READER_SETTING -> userSettings.setEpubReaderSetting(objectMapper.readValue(value, BookLoreUser.UserSettings.EpubReaderSetting.class)); + case EBOOK_READER_SETTING -> userSettings.setEbookReaderSetting(objectMapper.readValue(value, BookLoreUser.UserSettings.EbookReaderSetting.class)); case CBX_READER_SETTING -> userSettings.setCbxReaderSetting(objectMapper.readValue(value, BookLoreUser.UserSettings.CbxReaderSetting.class)); case NEW_PDF_READER_SETTING -> userSettings.setNewPdfReaderSetting(objectMapper.readValue(value, BookLoreUser.UserSettings.NewPdfReaderSetting.class)); case SIDEBAR_LIBRARY_SORTING -> userSettings.setSidebarLibrarySorting(objectMapper.readValue(value, SidebarSortOption.class)); diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/BookLoreUser.java b/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/BookLoreUser.java index c4eb93e0a..b8eb6d9a3 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/BookLoreUser.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/BookLoreUser.java @@ -63,6 +63,7 @@ public class BookLoreUser { public PdfReaderSetting pdfReaderSetting; public NewPdfReaderSetting newPdfReaderSetting; public EpubReaderSetting epubReaderSetting; + public EbookReaderSetting ebookReaderSetting; public CbxReaderSetting cbxReaderSetting; public SidebarSortOption sidebarLibrarySorting; public SidebarSortOption sidebarShelfSorting; @@ -107,6 +108,7 @@ public class BookLoreUser { private Float coverSize; @JsonAlias("seriesCollapse") private Boolean seriesCollapsed; + private Boolean overlayBookType; } @Data @@ -129,6 +131,7 @@ public class BookLoreUser { private String view; @JsonAlias("seriesCollapse") private Boolean seriesCollapsed; + private Boolean overlayBookType; } @Data @@ -145,6 +148,25 @@ public class BookLoreUser { private String spread; } + @Data + @Builder + @AllArgsConstructor + @NoArgsConstructor + public static class EbookReaderSetting { + private String fontFamily; + private Integer fontSize; + private Float gap; + private Boolean hyphenate; + private Boolean isDark; + private Boolean justify; + private Float lineHeight; + private Integer maxBlockSize; + private Integer maxColumnCount; + private Integer maxInlineSize; + private String theme; + private String flow; + } + @Data @Builder @AllArgsConstructor diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/BookViewerSettings.java b/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/BookViewerSettings.java index 38b89aea1..45f557dd0 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/BookViewerSettings.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/BookViewerSettings.java @@ -8,6 +8,6 @@ import lombok.Data; public class BookViewerSettings { private PdfViewerPreferences pdfSettings; private NewPdfViewerPreferences newPdfSettings; - private EpubViewerPreferences epubSettings; + private EbookViewerPreferences ebookSettings; private CbxViewerPreferences cbxSettings; } \ No newline at end of file diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/EbookViewerPreferences.java b/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/EbookViewerPreferences.java new file mode 100644 index 000000000..7f4a14a84 --- /dev/null +++ b/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/EbookViewerPreferences.java @@ -0,0 +1,24 @@ +package com.adityachandel.booklore.model.dto; + +import lombok.Builder; +import lombok.Data; + +@Data +@Builder +public class EbookViewerPreferences { + private Long bookId; + private Long userId; + private String fontFamily; + private Integer fontSize; + private Float gap; + private Boolean hyphenate; + private Boolean isDark; + private Boolean justify; + private Float lineHeight; + private Integer maxBlockSize; + private Integer maxColumnCount; + private Integer maxInlineSize; + private String theme; + private String flow; +} + diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/progress/EpubProgress.java b/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/progress/EpubProgress.java index c96e337a5..59457ef31 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/progress/EpubProgress.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/progress/EpubProgress.java @@ -9,6 +9,7 @@ import lombok.Data; public class EpubProgress { @NotNull String cfi; + String href; @NotNull Float percentage; } diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/settings/MetadataPersistenceSettings.java b/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/settings/MetadataPersistenceSettings.java index 907270f73..cef64a660 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/settings/MetadataPersistenceSettings.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/settings/MetadataPersistenceSettings.java @@ -24,7 +24,9 @@ public class MetadataPersistenceSettings { private FormatSettings cbx; public boolean isAnyFormatEnabled() { - return (epub != null && epub.isEnabled()) || (pdf != null && pdf.isEnabled() || (cbx != null && cbx.isEnabled())); + return (epub != null && epub.isEnabled()) + || (pdf != null && pdf.isEnabled()) + || (cbx != null && cbx.isEnabled()); } } diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/settings/UserSettingKey.java b/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/settings/UserSettingKey.java index f4f67b905..c9f153f7a 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/settings/UserSettingKey.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/settings/UserSettingKey.java @@ -8,6 +8,7 @@ public enum UserSettingKey { PDF_READER_SETTING("pdfReaderSetting", true), NEW_PDF_READER_SETTING("newPdfReaderSetting", true), EPUB_READER_SETTING("epubReaderSetting", true), + EBOOK_READER_SETTING("ebookReaderSetting", true), CBX_READER_SETTING("cbxReaderSetting", true), SIDEBAR_LIBRARY_SORTING("sidebarLibrarySorting", true), SIDEBAR_SHELF_SORTING("sidebarShelfSorting", true), diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/model/entity/EbookViewerPreferenceEntity.java b/booklore-api/src/main/java/com/adityachandel/booklore/model/entity/EbookViewerPreferenceEntity.java new file mode 100644 index 000000000..2240146a3 --- /dev/null +++ b/booklore-api/src/main/java/com/adityachandel/booklore/model/entity/EbookViewerPreferenceEntity.java @@ -0,0 +1,63 @@ +package com.adityachandel.booklore.model.entity; + +import jakarta.persistence.*; +import lombok.*; + +@Entity +@Getter +@Setter +@Builder +@NoArgsConstructor +@AllArgsConstructor +@Table(name = "ebook_viewer_preference", uniqueConstraints = { + @UniqueConstraint(columnNames = {"user_id", "book_id"}) +}) +public class EbookViewerPreferenceEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "user_id", nullable = false) + private Long userId; + + @Column(name = "book_id", nullable = false) + private Long bookId; + + @Column(name = "font_family", nullable = false) + private String fontFamily; + + @Column(name = "font_size", nullable = false) + private Integer fontSize; + + @Column(name = "gap", nullable = false) + private Float gap; + + @Column(name = "hyphenate", nullable = false) + private Boolean hyphenate; + + @Column(name = "is_dark", nullable = false) + private Boolean isDark; + + @Column(name = "justify", nullable = false) + private Boolean justify; + + @Column(name = "line_height", nullable = false) + private Float lineHeight; + + @Column(name = "max_block_size", nullable = false) + private Integer maxBlockSize; + + @Column(name = "max_column_count", nullable = false) + private Integer maxColumnCount; + + @Column(name = "max_inline_size", nullable = false) + private Integer maxInlineSize; + + @Column(name = "theme", nullable = false) + private String theme; + + @Column(name = "flow", nullable = false) + private String flow; +} + diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/model/entity/UserBookProgressEntity.java b/booklore-api/src/main/java/com/adityachandel/booklore/model/entity/UserBookProgressEntity.java index b83fea9a5..9823047f4 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/model/entity/UserBookProgressEntity.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/model/entity/UserBookProgressEntity.java @@ -39,6 +39,9 @@ public class UserBookProgressEntity { @Column(name = "epub_progress", length = 1000) private String epubProgress; + @Column(name = "epub_progress_href", length = 1000) + private String epubProgressHref; + @Column(name = "epub_progress_percent") private Float epubProgressPercent; diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/model/enums/BookFileExtension.java b/booklore-api/src/main/java/com/adityachandel/booklore/model/enums/BookFileExtension.java index 5186d61d3..b0e12fca7 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/model/enums/BookFileExtension.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/model/enums/BookFileExtension.java @@ -14,6 +14,9 @@ public enum BookFileExtension { CBZ("cbz", BookFileType.CBX), CBR("cbr", BookFileType.CBX), CB7("cb7", BookFileType.CBX), + MOBI("mobi", BookFileType.MOBI), + AZW3("azw3", BookFileType.AZW3), + AZW("azw", BookFileType.AZW3), FB2("fb2", BookFileType.FB2); private final String extension; diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/model/enums/BookFileType.java b/booklore-api/src/main/java/com/adityachandel/booklore/model/enums/BookFileType.java index b19b6749e..44598dbcf 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/model/enums/BookFileType.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/model/enums/BookFileType.java @@ -12,7 +12,9 @@ public enum BookFileType { PDF(Set.of("pdf")), EPUB(Set.of("epub")), CBX(Set.of("cbz", "cbr", "cb7")), - FB2(Set.of("fb2")); + FB2(Set.of("fb2")), + MOBI(Set.of("mobi")), + AZW3(Set.of("azw3", "azw")); private final Set extensions; diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/repository/EbookViewerPreferenceRepository.java b/booklore-api/src/main/java/com/adityachandel/booklore/repository/EbookViewerPreferenceRepository.java new file mode 100644 index 000000000..c34281d52 --- /dev/null +++ b/booklore-api/src/main/java/com/adityachandel/booklore/repository/EbookViewerPreferenceRepository.java @@ -0,0 +1,15 @@ +package com.adityachandel.booklore.repository; + +import com.adityachandel.booklore.model.entity.EbookViewerPreferenceEntity; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +@Repository +public interface EbookViewerPreferenceRepository extends JpaRepository { + + Optional findByBookIdAndUserId(Long bookId, Long userId); + +} + diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/service/book/BookService.java b/booklore-api/src/main/java/com/adityachandel/booklore/service/book/BookService.java index 012cabe6e..35db4e09d 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/service/book/BookService.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/service/book/BookService.java @@ -4,30 +4,25 @@ import com.adityachandel.booklore.config.security.service.AuthenticationService; import com.adityachandel.booklore.exception.ApiError; import com.adityachandel.booklore.mapper.BookMapper; import com.adityachandel.booklore.model.dto.*; -import com.adityachandel.booklore.model.dto.progress.*; import com.adityachandel.booklore.model.dto.request.ReadProgressRequest; import com.adityachandel.booklore.model.dto.response.BookDeletionResponse; import com.adityachandel.booklore.model.dto.response.BookStatusUpdateResponse; import com.adityachandel.booklore.model.entity.BookEntity; -import com.adityachandel.booklore.model.entity.BookFileEntity; -import com.adityachandel.booklore.model.entity.BookLoreUserEntity; -import com.adityachandel.booklore.model.entity.CbxViewerPreferencesEntity; -import com.adityachandel.booklore.model.entity.EpubViewerPreferencesEntity; import com.adityachandel.booklore.model.entity.LibraryPathEntity; -import com.adityachandel.booklore.model.entity.NewPdfViewerPreferencesEntity; -import com.adityachandel.booklore.model.entity.PdfViewerPreferencesEntity; import com.adityachandel.booklore.model.entity.UserBookProgressEntity; import com.adityachandel.booklore.model.enums.BookFileType; -import com.adityachandel.booklore.model.enums.ReadStatus; import com.adityachandel.booklore.repository.*; import com.adityachandel.booklore.service.monitoring.MonitoringRegistrationService; import com.adityachandel.booklore.service.user.UserProgressService; +import com.adityachandel.booklore.util.BookProgressUtil; import com.adityachandel.booklore.util.FileService; import com.adityachandel.booklore.util.FileUtils; import lombok.AllArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.apache.commons.lang3.EnumUtils; -import org.springframework.core.io.*; +import org.springframework.core.io.ByteArrayResource; +import org.springframework.core.io.ClassPathResource; +import org.springframework.core.io.Resource; +import org.springframework.core.io.UrlResource; import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; @@ -41,7 +36,6 @@ import java.net.MalformedURLException; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; -import java.time.Instant; import java.util.*; import java.util.stream.Collectors; @@ -52,7 +46,6 @@ public class BookService { private final BookRepository bookRepository; private final PdfViewerPreferencesRepository pdfViewerPreferencesRepository; - private final EpubViewerPreferencesRepository epubViewerPreferencesRepository; private final CbxViewerPreferencesRepository cbxViewerPreferencesRepository; private final NewPdfViewerPreferencesRepository newPdfViewerPreferencesRepository; private final FileService fileService; @@ -64,46 +57,9 @@ public class BookService { private final BookDownloadService bookDownloadService; private final MonitoringRegistrationService monitoringRegistrationService; private final BookUpdateService bookUpdateService; + private final EbookViewerPreferenceRepository ebookViewerPreferencesRepository; - private void setBookProgress(Book book, UserBookProgressEntity progress) { - if (progress.getKoboProgressPercent() != null) { - book.setKoboProgress(KoboProgress.builder() - .percentage(progress.getKoboProgressPercent()) - .build()); - } - - switch (book.getBookType()) { - case EPUB -> { - book.setEpubProgress(EpubProgress.builder() - .cfi(progress.getEpubProgress()) - .percentage(progress.getEpubProgressPercent()) - .build()); - book.setKoreaderProgress(KoProgress.builder() - .percentage(progress.getKoreaderProgressPercent() != null ? progress.getKoreaderProgressPercent() * 100 : null) - .build()); - } - case PDF -> book.setPdfProgress(PdfProgress.builder() - .page(progress.getPdfProgress()) - .percentage(progress.getPdfProgressPercent()) - .build()); - case CBX -> book.setCbxProgress(CbxProgress.builder() - .page(progress.getCbxProgress()) - .percentage(progress.getCbxProgressPercent()) - .build()); - } - } - - private void enrichBookWithProgress(Book book, UserBookProgressEntity progress) { - if (progress != null) { - setBookProgress(book, progress); - book.setLastReadTime(progress.getLastReadTime()); - book.setReadStatus(progress.getReadStatus() == null ? String.valueOf(ReadStatus.UNSET) : String.valueOf(progress.getReadStatus())); - book.setDateFinished(progress.getDateFinished()); - book.setPersonalRating(progress.getPersonalRating()); - } - } - public List getBookDTOs(boolean includeDescription) { BookLoreUser user = authenticationService.getAuthenticatedUser(); boolean isAdmin = user.getPermissions().isAdmin(); @@ -125,7 +81,7 @@ public class BookService { ); books.forEach(book -> { - enrichBookWithProgress(book, progressMap.get(book.getId())); + BookProgressUtil.enrichBookWithProgress(book, progressMap.get(book.getId())); book.setShelves(filterShelvesByUserId(book.getShelves(), user.getId())); }); @@ -144,7 +100,7 @@ public class BookService { Book book = bookMapper.toBook(bookEntity); book.setFilePath(FileUtils.getBookFullPath(bookEntity)); if (!withDescription) book.getMetadata().setDescription(null); - enrichBookWithProgress(book, progressMap.get(bookEntity.getId())); + BookProgressUtil.enrichBookWithProgress(book, progressMap.get(bookEntity.getId())); return book; }).collect(Collectors.toList()); } @@ -158,46 +114,8 @@ public class BookService { Book book = bookMapper.toBook(bookEntity); book.setShelves(filterShelvesByUserId(book.getShelves(), user.getId())); book.setLastReadTime(userProgress.getLastReadTime()); - - if (userProgress.getKoboProgressPercent() != null) { - book.setKoboProgress(KoboProgress.builder() - .percentage(userProgress.getKoboProgressPercent()) - .build()); - } - - bookEntity.getBookFiles().iterator().forEachRemaining(bookFile -> { - if (bookFile.getBookType() == BookFileType.PDF) { - book.setPdfProgress(PdfProgress.builder() - .page(userProgress.getPdfProgress()) - .percentage(userProgress.getPdfProgressPercent()) - .build()); - } - - if (bookFile.getBookType() == BookFileType.EPUB) { - book.setEpubProgress(EpubProgress.builder() - .cfi(userProgress.getEpubProgress()) - .percentage(userProgress.getEpubProgressPercent()) - .build()); - if (userProgress.getKoreaderProgressPercent() != null) { - if (book.getKoreaderProgress() == null) { - book.setKoreaderProgress(KoProgress.builder().build()); - } - book.getKoreaderProgress().setPercentage(userProgress.getKoreaderProgressPercent() * 100); - } - } - - if (bookFile.getBookType() == BookFileType.CBX) { - book.setCbxProgress(CbxProgress.builder() - .page(userProgress.getCbxProgress()) - .percentage(userProgress.getCbxProgressPercent()) - .build()); - } - }); - + BookProgressUtil.enrichBookWithProgress(book, userProgress); book.setFilePath(FileUtils.getBookFullPath(bookEntity)); - book.setReadStatus(userProgress.getReadStatus() == null ? String.valueOf(ReadStatus.UNSET) : String.valueOf(userProgress.getReadStatus())); - book.setDateFinished(userProgress.getDateFinished()); - book.setPersonalRating(userProgress.getPersonalRating()); if (!withDescription) { book.getMetadata().setDescription(null); @@ -206,26 +124,35 @@ public class BookService { return book; } + public BookViewerSettings getBookViewerSetting(long bookId) { BookEntity bookEntity = bookRepository.findByIdWithBookFiles(bookId).orElseThrow(() -> ApiError.BOOK_NOT_FOUND.createException(bookId)); BookLoreUser user = authenticationService.getAuthenticatedUser(); BookViewerSettings.BookViewerSettingsBuilder settingsBuilder = BookViewerSettings.builder(); - BookFileEntity bookFileEntity = bookEntity.getPrimaryBookFile(); - if (bookFileEntity.getBookType() == BookFileType.EPUB) { - epubViewerPreferencesRepository.findByBookIdAndUserId(bookId, user.getId()) - .ifPresent(epubPref -> settingsBuilder.epubSettings(EpubViewerPreferences.builder() + BookFileType bookType = bookEntity.getPrimaryBookFile().getBookType(); + if (bookType == BookFileType.EPUB || bookType == BookFileType.FB2 + || bookType == BookFileType.MOBI + || bookType == BookFileType.AZW3) { + ebookViewerPreferencesRepository.findByBookIdAndUserId(bookId, user.getId()) + .ifPresent(epubPref -> settingsBuilder.ebookSettings(EbookViewerPreferences.builder() .bookId(bookId) - .font(epubPref.getFont()) + .userId(user.getId()) + .fontFamily(epubPref.getFontFamily()) .fontSize(epubPref.getFontSize()) + .gap(epubPref.getGap()) + .hyphenate(epubPref.getHyphenate()) + .isDark(epubPref.getIsDark()) + .justify(epubPref.getJustify()) + .lineHeight(epubPref.getLineHeight()) + .maxBlockSize(epubPref.getMaxBlockSize()) + .maxColumnCount(epubPref.getMaxColumnCount()) + .maxInlineSize(epubPref.getMaxInlineSize()) .theme(epubPref.getTheme()) .flow(epubPref.getFlow()) - .spread(epubPref.getSpread()) - .letterSpacing(epubPref.getLetterSpacing()) - .lineHeight(epubPref.getLineHeight()) .build())); - } else if (bookFileEntity.getBookType() == BookFileType.PDF) { + } else if (bookType == BookFileType.PDF) { pdfViewerPreferencesRepository.findByBookIdAndUserId(bookId, user.getId()) .ifPresent(pdfPref -> settingsBuilder.pdfSettings(PdfViewerPreferences.builder() .bookId(bookId) @@ -238,7 +165,7 @@ public class BookService { .pageViewMode(pdfPref.getPageViewMode()) .pageSpread(pdfPref.getPageSpread()) .build())); - } else if (bookFileEntity.getBookType() == BookFileType.CBX) { + } else if (bookType == BookFileType.CBX) { cbxViewerPreferencesRepository.findByBookIdAndUserId(bookId, user.getId()) .ifPresent(cbxPref -> settingsBuilder.cbxSettings(CbxViewerPreferences.builder() .bookId(bookId) @@ -318,7 +245,6 @@ public class BookService { } } - @Transactional public ResponseEntity deleteBooks(Set ids) { List books = bookQueryService.findAllWithMetadataByIds(ids); diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/service/book/BookUpdateService.java b/booklore-api/src/main/java/com/adityachandel/booklore/service/book/BookUpdateService.java index df15da517..bc87f9a1d 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/service/book/BookUpdateService.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/service/book/BookUpdateService.java @@ -1,64 +1,46 @@ package com.adityachandel.booklore.service.book; -import java.time.Instant; -import java.util.*; -import java.util.stream.Collectors; - -import org.apache.commons.lang3.EnumUtils; -import org.springframework.security.core.userdetails.UsernameNotFoundException; -import org.springframework.stereotype.Service; - import com.adityachandel.booklore.config.security.service.AuthenticationService; import com.adityachandel.booklore.exception.ApiError; import com.adityachandel.booklore.mapper.BookMapper; -import com.adityachandel.booklore.model.dto.Book; -import com.adityachandel.booklore.model.dto.BookLoreUser; -import com.adityachandel.booklore.model.dto.BookViewerSettings; -import com.adityachandel.booklore.model.dto.CbxViewerPreferences; -import com.adityachandel.booklore.model.dto.EpubViewerPreferences; -import com.adityachandel.booklore.model.dto.NewPdfViewerPreferences; -import com.adityachandel.booklore.model.dto.PdfViewerPreferences; -import com.adityachandel.booklore.model.dto.Shelf; +import com.adityachandel.booklore.model.dto.*; +import com.adityachandel.booklore.model.dto.progress.CbxProgress; +import com.adityachandel.booklore.model.dto.progress.EpubProgress; +import com.adityachandel.booklore.model.dto.progress.PdfProgress; import com.adityachandel.booklore.model.dto.request.ReadProgressRequest; import com.adityachandel.booklore.model.dto.response.BookStatusUpdateResponse; import com.adityachandel.booklore.model.dto.response.PersonalRatingUpdateResponse; -import com.adityachandel.booklore.model.entity.BookEntity; -import com.adityachandel.booklore.model.entity.BookFileEntity; -import com.adityachandel.booklore.model.entity.BookLoreUserEntity; -import com.adityachandel.booklore.model.entity.CbxViewerPreferencesEntity; -import com.adityachandel.booklore.model.entity.EpubViewerPreferencesEntity; -import com.adityachandel.booklore.model.entity.NewPdfViewerPreferencesEntity; -import com.adityachandel.booklore.model.entity.PdfViewerPreferencesEntity; -import com.adityachandel.booklore.model.entity.ShelfEntity; -import com.adityachandel.booklore.model.entity.UserBookProgressEntity; +import com.adityachandel.booklore.model.entity.*; import com.adityachandel.booklore.model.enums.BookFileType; import com.adityachandel.booklore.model.enums.ReadStatus; import com.adityachandel.booklore.model.enums.ResetProgressType; import com.adityachandel.booklore.model.enums.UserPermission; -import com.adityachandel.booklore.repository.BookRepository; -import com.adityachandel.booklore.repository.CbxViewerPreferencesRepository; -import com.adityachandel.booklore.repository.EpubViewerPreferencesRepository; -import com.adityachandel.booklore.repository.NewPdfViewerPreferencesRepository; -import com.adityachandel.booklore.repository.PdfViewerPreferencesRepository; -import com.adityachandel.booklore.repository.ShelfRepository; -import com.adityachandel.booklore.repository.UserBookProgressRepository; -import com.adityachandel.booklore.repository.UserRepository; +import com.adityachandel.booklore.repository.*; import com.adityachandel.booklore.service.kobo.KoboReadingStateService; import com.adityachandel.booklore.service.user.UserProgressService; +import com.adityachandel.booklore.util.BookProgressUtil; import com.adityachandel.booklore.util.FileUtils; - -import jakarta.transaction.Transactional; import lombok.AllArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.EnumUtils; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.Instant; +import java.util.*; +import java.util.stream.Collectors; @Slf4j @AllArgsConstructor @Service public class BookUpdateService { + private static final float READING_THRESHOLD = 0.5f; + private static final float COMPLETED_THRESHOLD = 99.5f; + private final BookRepository bookRepository; private final PdfViewerPreferencesRepository pdfViewerPreferencesRepository; - private final EpubViewerPreferencesRepository epubViewerPreferencesRepository; private final CbxViewerPreferencesRepository cbxViewerPreferencesRepository; private final NewPdfViewerPreferencesRepository newPdfViewerPreferencesRepository; private final ShelfRepository shelfRepository; @@ -69,132 +51,41 @@ public class BookUpdateService { private final BookQueryService bookQueryService; private final UserProgressService userProgressService; private final KoboReadingStateService koboReadingStateService; + private final EbookViewerPreferenceRepository ebookViewerPreferenceRepository; public void updateBookViewerSetting(long bookId, BookViewerSettings bookViewerSettings) { - BookEntity bookEntity = bookRepository.findByIdWithBookFiles(bookId).orElseThrow(() -> ApiError.BOOK_NOT_FOUND.createException(bookId)); - BookFileEntity bookFileEntity = bookEntity.getPrimaryBookFile(); + BookEntity book = bookRepository.findByIdWithBookFiles(bookId).orElseThrow(() -> ApiError.BOOK_NOT_FOUND.createException(bookId)); BookLoreUser user = authenticationService.getAuthenticatedUser(); - - if (bookFileEntity.getBookType() == BookFileType.PDF) { - if (bookViewerSettings.getPdfSettings() != null) { - PdfViewerPreferencesEntity pdfPrefs = pdfViewerPreferencesRepository - .findByBookIdAndUserId(bookId, user.getId()) - .orElseGet(() -> { - PdfViewerPreferencesEntity newPrefs = PdfViewerPreferencesEntity.builder() - .bookId(bookId) - .userId(user.getId()) - .build(); - return pdfViewerPreferencesRepository.save(newPrefs); - }); - PdfViewerPreferences pdfSettings = bookViewerSettings.getPdfSettings(); - pdfPrefs.setZoom(pdfSettings.getZoom()); - pdfPrefs.setSpread(pdfSettings.getSpread()); - pdfViewerPreferencesRepository.save(pdfPrefs); - } - if (bookViewerSettings.getNewPdfSettings() != null) { - NewPdfViewerPreferencesEntity pdfPrefs = newPdfViewerPreferencesRepository.findByBookIdAndUserId(bookId, user.getId()) - .orElseGet(() -> { - NewPdfViewerPreferencesEntity entity = NewPdfViewerPreferencesEntity.builder() - .bookId(bookId) - .userId(user.getId()) - .build(); - return newPdfViewerPreferencesRepository.save(entity); - }); - NewPdfViewerPreferences pdfSettings = bookViewerSettings.getNewPdfSettings(); - pdfPrefs.setPageSpread(pdfSettings.getPageSpread()); - pdfPrefs.setPageViewMode(pdfSettings.getPageViewMode()); - newPdfViewerPreferencesRepository.save(pdfPrefs); - } - } else if (bookFileEntity.getBookType() == BookFileType.EPUB) { - EpubViewerPreferencesEntity epubPrefs = epubViewerPreferencesRepository - .findByBookIdAndUserId(bookId, user.getId()) - .orElseGet(() -> { - EpubViewerPreferencesEntity newPrefs = EpubViewerPreferencesEntity.builder() - .bookId(bookId) - .userId(user.getId()) - .build(); - return epubViewerPreferencesRepository.save(newPrefs); - }); - - EpubViewerPreferences epubSettings = bookViewerSettings.getEpubSettings(); - epubPrefs.setFont(epubSettings.getFont()); - epubPrefs.setFontSize(epubSettings.getFontSize()); - epubPrefs.setTheme(epubSettings.getTheme()); - epubPrefs.setFlow(epubSettings.getFlow()); - epubPrefs.setSpread(epubSettings.getSpread()); - epubPrefs.setLetterSpacing(epubSettings.getLetterSpacing()); - epubPrefs.setLineHeight(epubSettings.getLineHeight()); - epubViewerPreferencesRepository.save(epubPrefs); - - } else if (bookFileEntity.getBookType() == BookFileType.CBX) { - CbxViewerPreferencesEntity cbxPrefs = cbxViewerPreferencesRepository - .findByBookIdAndUserId(bookId, user.getId()) - .orElseGet(() -> { - CbxViewerPreferencesEntity newPrefs = CbxViewerPreferencesEntity.builder() - .bookId(bookId) - .userId(user.getId()) - .build(); - return cbxViewerPreferencesRepository.save(newPrefs); - }); - - CbxViewerPreferences cbxSettings = bookViewerSettings.getCbxSettings(); - cbxPrefs.setPageSpread(cbxSettings.getPageSpread()); - cbxPrefs.setPageViewMode(cbxSettings.getPageViewMode()); - cbxPrefs.setFitMode(cbxSettings.getFitMode()); - cbxPrefs.setScrollMode(cbxSettings.getScrollMode()); - cbxPrefs.setBackgroundColor(cbxSettings.getBackgroundColor()); - cbxViewerPreferencesRepository.save(cbxPrefs); - - } else { + if (book.getPrimaryBookFile().getBookType() == null) { throw ApiError.UNSUPPORTED_BOOK_TYPE.createException(); } + switch (book.getPrimaryBookFile().getBookType()) { + case PDF -> updatePdfViewerSettings(bookId, user.getId(), bookViewerSettings); + case EPUB, FB2, MOBI, AZW3 -> updateEbookViewerSettings(bookId, user.getId(), bookViewerSettings); + case CBX -> updateCbxViewerSettings(bookId, user.getId(), bookViewerSettings); + default -> throw ApiError.UNSUPPORTED_BOOK_TYPE.createException(); + } } @Transactional public void updateReadProgress(ReadProgressRequest request) { BookEntity book = bookRepository.findByIdWithBookFiles(request.getBookId()).orElseThrow(() -> ApiError.BOOK_NOT_FOUND.createException(request.getBookId())); - BookLoreUser user = authenticationService.getAuthenticatedUser(); UserBookProgressEntity progress = userBookProgressRepository .findByUserIdAndBookId(user.getId(), book.getId()) .orElseGet(UserBookProgressEntity::new); - BookLoreUserEntity userEntity = userRepository.findById(user.getId()) - .orElseThrow(() -> new UsernameNotFoundException("User not found")); + BookLoreUserEntity userEntity = findUserOrThrow(user.getId()); progress.setUser(userEntity); - progress.setBook(book); progress.setLastReadTime(Instant.now()); - Float percentage = null; - BookFileEntity bookFileEntity = book.getPrimaryBookFile(); - switch (bookFileEntity.getBookType()) { - case EPUB -> { - if (request.getEpubProgress() != null) { - progress.setEpubProgress(request.getEpubProgress().getCfi()); - percentage = request.getEpubProgress().getPercentage(); - } - } - case PDF -> { - if (request.getPdfProgress() != null) { - progress.setPdfProgress(request.getPdfProgress().getPage()); - percentage = request.getPdfProgress().getPercentage(); - } - } - case CBX -> { - if (request.getCbxProgress() != null) { - progress.setCbxProgress(request.getCbxProgress().getPage()); - percentage = request.getCbxProgress().getPercentage(); - } - } - } - + Float percentage = updateProgressByBookType(progress, book.getPrimaryBookFile().getBookType(), request); if (percentage != null) { - progress.setReadStatus(getStatus(percentage)); - setProgressPercent(progress, bookFileEntity.getBookType(), percentage); + progress.setReadStatus(calculateReadStatus(percentage)); + setProgressPercent(progress, book.getPrimaryBookFile().getBookType(), percentage); } - if (request.getDateFinished() != null) { progress.setDateFinished(request.getDateFinished()); } @@ -205,161 +96,280 @@ public class BookUpdateService { @Transactional public List updateReadStatus(List bookIds, String status) { BookLoreUser user = authenticationService.getAuthenticatedUser(); - - if(bookIds.size() > 1 && !UserPermission.CAN_BULK_RESET_BOOK_READ_STATUS.isGranted(user.getPermissions())) { - throw ApiError.PERMISSION_DENIED.createException(UserPermission.CAN_BULK_RESET_BOOK_READ_STATUS); - } + validateBulkOperationPermission(bookIds, user, UserPermission.CAN_BULK_RESET_BOOK_READ_STATUS); ReadStatus readStatus = EnumUtils.getEnumIgnoreCase(ReadStatus.class, status); - Set existingProgressBookIds = validateBooksAndGetExistingProgress(user.getId(), bookIds); Instant now = Instant.now(); Instant dateFinished = readStatus == ReadStatus.READ ? now : null; - if (!existingProgressBookIds.isEmpty()) { - userBookProgressRepository.bulkUpdateReadStatus(user.getId(), new ArrayList<>(existingProgressBookIds), readStatus, now, dateFinished); - } + updateExistingProgress(user.getId(), existingProgressBookIds, readStatus, now, dateFinished); + createNewProgress(user.getId(), bookIds, existingProgressBookIds, readStatus, now, dateFinished); - Set newProgressBookIds = bookIds.stream() - .filter(id -> !existingProgressBookIds.contains(id)) - .collect(Collectors.toSet()); - - if (!newProgressBookIds.isEmpty()) { - BookLoreUserEntity userEntity = userRepository.findById(user.getId()).orElseThrow(() -> new UsernameNotFoundException("User not found")); - - List newProgressEntities = newProgressBookIds.stream() - .map(bookId -> { - UserBookProgressEntity progress = new UserBookProgressEntity(); - progress.setUser(userEntity); - - BookEntity bookEntity = new BookEntity(); - bookEntity.setId(bookId); - progress.setBook(bookEntity); - - progress.setReadStatus(readStatus); - progress.setReadStatusModifiedTime(now); - progress.setDateFinished(dateFinished); - return progress; - }) - .collect(Collectors.toList()); - - userBookProgressRepository.saveAll(newProgressEntities); - } - - return bookIds.stream() - .map(bookId -> BookStatusUpdateResponse.builder() - .bookId(bookId) - .readStatus(readStatus) - .readStatusModifiedTime(now) - .dateFinished(dateFinished) - .build()) - .collect(Collectors.toList()); + return buildStatusUpdateResponses(bookIds, readStatus, now, dateFinished); } @Transactional public List resetProgress(List bookIds, ResetProgressType type) { BookLoreUser user = authenticationService.getAuthenticatedUser(); - - if(bookIds.size() > 1 && type == ResetProgressType.BOOKLORE && !UserPermission.CAN_BULK_RESET_BOOKLORE_READ_PROGRESS.isGranted(user.getPermissions())) { - throw ApiError.PERMISSION_DENIED.createException(UserPermission.CAN_BULK_RESET_BOOKLORE_READ_PROGRESS); - } - - if(bookIds.size() > 1 && type == ResetProgressType.KOREADER && !UserPermission.CAN_BULK_RESET_KOREADER_READ_PROGRESS.isGranted(user.getPermissions())) { - throw ApiError.PERMISSION_DENIED.createException(UserPermission.CAN_BULK_RESET_KOREADER_READ_PROGRESS); - } + validateResetPermission(bookIds, user, type); Set existingProgressBookIds = validateBooksAndGetExistingProgress(user.getId(), bookIds); - Instant now = Instant.now(); if (!existingProgressBookIds.isEmpty()) { - if (type == ResetProgressType.BOOKLORE) { - userBookProgressRepository.bulkResetBookloreProgress(user.getId(), new ArrayList<>(existingProgressBookIds), now); - } else if (type == ResetProgressType.KOREADER) { - userBookProgressRepository.bulkResetKoreaderProgress(user.getId(), new ArrayList<>(existingProgressBookIds)); - } else if (type == ResetProgressType.KOBO) { - userBookProgressRepository.bulkResetKoboProgress(user.getId(), new ArrayList<>(existingProgressBookIds)); - existingProgressBookIds.forEach(koboReadingStateService::deleteReadingState); - } + performReset(user.getId(), existingProgressBookIds, type, now); } - return bookIds.stream() - .map(bookId -> BookStatusUpdateResponse.builder() - .bookId(bookId) - .readStatus(null) - .readStatusModifiedTime(existingProgressBookIds.contains(bookId) ? now : null) - .dateFinished(null) - .build()) - .collect(Collectors.toList()); + return buildResetResponses(bookIds, existingProgressBookIds, now); } @Transactional public List updatePersonalRating(List bookIds, Integer rating) { BookLoreUser user = authenticationService.getAuthenticatedUser(); - Set existingProgressBookIds = validateBooksAndGetExistingProgress(user.getId(), bookIds); if (!existingProgressBookIds.isEmpty()) { userBookProgressRepository.bulkUpdatePersonalRating(user.getId(), new ArrayList<>(existingProgressBookIds), rating); } - Set newProgressBookIds = bookIds.stream() - .filter(id -> !existingProgressBookIds.contains(id)) - .collect(Collectors.toSet()); + createProgressForRating(user.getId(), bookIds, existingProgressBookIds, rating); - if (!newProgressBookIds.isEmpty()) { - BookLoreUserEntity userEntity = userRepository.findById(user.getId()).orElseThrow(() -> new UsernameNotFoundException("User not found")); - - List newProgressEntities = newProgressBookIds.stream() - .map(bookId -> { - UserBookProgressEntity progress = new UserBookProgressEntity(); - progress.setUser(userEntity); - - BookEntity bookEntity = new BookEntity(); - bookEntity.setId(bookId); - progress.setBook(bookEntity); - - progress.setPersonalRating(rating); - return progress; - }) - .collect(Collectors.toList()); - - userBookProgressRepository.saveAll(newProgressEntities); - } - - return bookIds.stream() - .map(bookId -> PersonalRatingUpdateResponse.builder() - .bookId(bookId) - .personalRating(rating) - .build()) - .collect(Collectors.toList()); + return buildRatingUpdateResponses(bookIds, rating); } @Transactional public List resetPersonalRating(List bookIds) { BookLoreUser user = authenticationService.getAuthenticatedUser(); - Set existingProgressBookIds = validateBooksAndGetExistingProgress(user.getId(), bookIds); if (!existingProgressBookIds.isEmpty()) { userBookProgressRepository.bulkUpdatePersonalRating(user.getId(), new ArrayList<>(existingProgressBookIds), null); } - return bookIds.stream() - .map(bookId -> PersonalRatingUpdateResponse.builder() - .bookId(bookId) - .personalRating(null) - .build()) - .collect(Collectors.toList()); + return buildRatingUpdateResponses(bookIds, null); } @Transactional public List assignShelvesToBooks(Set bookIds, Set shelfIdsToAssign, Set shelfIdsToUnassign) { BookLoreUser user = authenticationService.getAuthenticatedUser(); - BookLoreUserEntity userEntity = userRepository.findById(user.getId()).orElseThrow(() -> ApiError.USER_NOT_FOUND.createException(user.getId())); + BookLoreUserEntity userEntity = findUserOrThrow(user.getId()); - Set userShelfIds = userEntity.getShelves().stream().map(ShelfEntity::getId).collect(Collectors.toSet()); + validateShelfOwnership(userEntity, shelfIdsToAssign, shelfIdsToUnassign); + + List bookEntities = bookQueryService.findAllWithMetadataByIds(bookIds); + List shelvesToAssign = shelfRepository.findAllById(shelfIdsToAssign); + + updateBookShelves(bookEntities, shelvesToAssign, shelfIdsToUnassign); + bookRepository.saveAll(bookEntities); + + return buildBooksWithProgress(bookEntities, user.getId()); + } + + private void updatePdfViewerSettings(long bookId, Long userId, BookViewerSettings settings) { + if (settings.getPdfSettings() != null) { + PdfViewerPreferencesEntity prefs = findOrCreatePdfPreferences(bookId, userId); + PdfViewerPreferences pdfSettings = settings.getPdfSettings(); + prefs.setZoom(pdfSettings.getZoom()); + prefs.setSpread(pdfSettings.getSpread()); + pdfViewerPreferencesRepository.save(prefs); + } + + if (settings.getNewPdfSettings() != null) { + NewPdfViewerPreferencesEntity prefs = findOrCreateNewPdfPreferences(bookId, userId); + NewPdfViewerPreferences pdfSettings = settings.getNewPdfSettings(); + prefs.setPageSpread(pdfSettings.getPageSpread()); + prefs.setPageViewMode(pdfSettings.getPageViewMode()); + newPdfViewerPreferencesRepository.save(prefs); + } + } + + private void updateEbookViewerSettings(long bookId, Long userId, BookViewerSettings settings) { + EbookViewerPreferenceEntity prefs = findOrCreateEbookPreferences(bookId, userId); + EbookViewerPreferences epubSettings = settings.getEbookSettings(); + + prefs.setUserId(userId); + prefs.setBookId(bookId); + prefs.setFontFamily(epubSettings.getFontFamily()); + prefs.setFontSize(epubSettings.getFontSize()); + prefs.setGap(epubSettings.getGap()); + prefs.setHyphenate(epubSettings.getHyphenate()); + prefs.setIsDark(epubSettings.getIsDark()); + prefs.setJustify(epubSettings.getJustify()); + prefs.setLineHeight(epubSettings.getLineHeight()); + prefs.setMaxBlockSize(epubSettings.getMaxBlockSize()); + prefs.setMaxColumnCount(epubSettings.getMaxColumnCount()); + prefs.setMaxInlineSize(epubSettings.getMaxInlineSize()); + prefs.setTheme(epubSettings.getTheme()); + prefs.setFlow(epubSettings.getFlow()); + + ebookViewerPreferenceRepository.save(prefs); + } + + private void updateCbxViewerSettings(long bookId, Long userId, BookViewerSettings settings) { + CbxViewerPreferencesEntity prefs = findOrCreateCbxPreferences(bookId, userId); + CbxViewerPreferences cbxSettings = settings.getCbxSettings(); + + prefs.setPageSpread(cbxSettings.getPageSpread()); + prefs.setPageViewMode(cbxSettings.getPageViewMode()); + prefs.setFitMode(cbxSettings.getFitMode()); + prefs.setScrollMode(cbxSettings.getScrollMode()); + prefs.setBackgroundColor(cbxSettings.getBackgroundColor()); + + cbxViewerPreferencesRepository.save(prefs); + } + + private PdfViewerPreferencesEntity findOrCreatePdfPreferences(long bookId, Long userId) { + return pdfViewerPreferencesRepository + .findByBookIdAndUserId(bookId, userId) + .orElseGet(() -> pdfViewerPreferencesRepository.save( + PdfViewerPreferencesEntity.builder() + .bookId(bookId) + .userId(userId) + .build() + )); + } + + private NewPdfViewerPreferencesEntity findOrCreateNewPdfPreferences(long bookId, Long userId) { + return newPdfViewerPreferencesRepository + .findByBookIdAndUserId(bookId, userId) + .orElseGet(() -> newPdfViewerPreferencesRepository.save( + NewPdfViewerPreferencesEntity.builder() + .bookId(bookId) + .userId(userId) + .build() + )); + } + + private EbookViewerPreferenceEntity findOrCreateEbookPreferences(long bookId, Long userId) { + return ebookViewerPreferenceRepository + .findByBookIdAndUserId(bookId, userId) + .orElseGet(() -> ebookViewerPreferenceRepository.save( + EbookViewerPreferenceEntity.builder() + .bookId(bookId) + .userId(userId) + .build() + )); + } + + private CbxViewerPreferencesEntity findOrCreateCbxPreferences(long bookId, Long userId) { + return cbxViewerPreferencesRepository + .findByBookIdAndUserId(bookId, userId) + .orElseGet(() -> cbxViewerPreferencesRepository.save( + CbxViewerPreferencesEntity.builder() + .bookId(bookId) + .userId(userId) + .build() + )); + } + + private Float updateProgressByBookType(UserBookProgressEntity progress, BookFileType bookType, ReadProgressRequest request) { + return switch (bookType) { + case EPUB, FB2, MOBI, AZW3 -> updateEbookProgress(progress, request.getEpubProgress()); + case PDF -> updatePdfProgress(progress, request.getPdfProgress()); + case CBX -> updateCbxProgress(progress, request.getCbxProgress()); + }; + } + + private Float updateEbookProgress(UserBookProgressEntity progress, EpubProgress epubProgress) { + if (epubProgress == null) return null; + progress.setEpubProgress(epubProgress.getCfi()); + progress.setEpubProgressHref(epubProgress.getHref()); + return epubProgress.getPercentage(); + } + + private Float updatePdfProgress(UserBookProgressEntity progress, PdfProgress pdfProgress) { + if (pdfProgress == null) return null; + progress.setPdfProgress(pdfProgress.getPage()); + return pdfProgress.getPercentage(); + } + + private Float updateCbxProgress(UserBookProgressEntity progress, CbxProgress cbxProgress) { + if (cbxProgress == null) return null; + progress.setCbxProgress(cbxProgress.getPage()); + return cbxProgress.getPercentage(); + } + + private void updateExistingProgress(Long userId, Set bookIds, ReadStatus status, Instant now, Instant dateFinished) { + if (!bookIds.isEmpty()) { + userBookProgressRepository.bulkUpdateReadStatus(userId, new ArrayList<>(bookIds), status, now, dateFinished); + } + } + + private void createNewProgress(Long userId, List allBookIds, Set existingBookIds, ReadStatus status, Instant now, Instant dateFinished) { + Set newProgressBookIds = allBookIds.stream() + .filter(id -> !existingBookIds.contains(id)) + .collect(Collectors.toSet()); + + if (newProgressBookIds.isEmpty()) return; + + BookLoreUserEntity userEntity = findUserOrThrow(userId); + List newProgressEntities = newProgressBookIds.stream() + .map(bookId -> createProgressEntity(userEntity, bookId, status, now, dateFinished)) + .collect(Collectors.toList()); + + userBookProgressRepository.saveAll(newProgressEntities); + } + + private UserBookProgressEntity createProgressEntity(BookLoreUserEntity user, Long bookId, ReadStatus status, Instant now, Instant dateFinished) { + UserBookProgressEntity progress = new UserBookProgressEntity(); + progress.setUser(user); + + BookEntity bookEntity = new BookEntity(); + bookEntity.setId(bookId); + progress.setBook(bookEntity); + + progress.setReadStatus(status); + progress.setReadStatusModifiedTime(now); + progress.setDateFinished(dateFinished); + return progress; + } + + private void performReset(Long userId, Set bookIds, ResetProgressType type, Instant now) { + List bookIdList = new ArrayList<>(bookIds); + + switch (type) { + case BOOKLORE -> userBookProgressRepository.bulkResetBookloreProgress(userId, bookIdList, now); + case KOREADER -> userBookProgressRepository.bulkResetKoreaderProgress(userId, bookIdList); + case KOBO -> { + userBookProgressRepository.bulkResetKoboProgress(userId, bookIdList); + bookIds.forEach(koboReadingStateService::deleteReadingState); + } + } + } + + private void createProgressForRating(Long userId, List allBookIds, Set existingBookIds, Integer rating) { + Set newProgressBookIds = allBookIds.stream() + .filter(id -> !existingBookIds.contains(id)) + .collect(Collectors.toSet()); + + if (newProgressBookIds.isEmpty()) return; + + BookLoreUserEntity userEntity = findUserOrThrow(userId); + List newProgressEntities = newProgressBookIds.stream() + .map(bookId -> createProgressEntityWithRating(userEntity, bookId, rating)) + .collect(Collectors.toList()); + + userBookProgressRepository.saveAll(newProgressEntities); + } + + private UserBookProgressEntity createProgressEntityWithRating(BookLoreUserEntity user, Long bookId, Integer rating) { + UserBookProgressEntity progress = new UserBookProgressEntity(); + progress.setUser(user); + + BookEntity bookEntity = new BookEntity(); + bookEntity.setId(bookId); + progress.setBook(bookEntity); + + progress.setPersonalRating(rating); + return progress; + } + + private void validateShelfOwnership(BookLoreUserEntity user, Set shelfIdsToAssign, Set shelfIdsToUnassign) { + Set userShelfIds = user.getShelves().stream() + .map(ShelfEntity::getId) + .collect(Collectors.toSet()); if (!userShelfIds.containsAll(shelfIdsToAssign)) { throw ApiError.GENERIC_UNAUTHORIZED.createException("Cannot assign shelves that do not belong to the user."); @@ -367,25 +377,53 @@ public class BookUpdateService { if (!userShelfIds.containsAll(shelfIdsToUnassign)) { throw ApiError.GENERIC_UNAUTHORIZED.createException("Cannot unassign shelves that do not belong to the user."); } + } - List bookEntities = bookQueryService.findAllWithMetadataByIds(bookIds); - List shelvesToAssign = shelfRepository.findAllById(shelfIdsToAssign); - for (BookEntity bookEntity : bookEntities) { - bookEntity.getShelves().removeIf(shelf -> shelfIdsToUnassign.contains(shelf.getId())); - bookEntity.getShelves().addAll(shelvesToAssign); + private void updateBookShelves(List books, List shelvesToAssign, Set shelfIdsToUnassign) { + for (BookEntity book : books) { + book.getShelves().removeIf(shelf -> shelfIdsToUnassign.contains(shelf.getId())); + book.getShelves().addAll(shelvesToAssign); } - bookRepository.saveAll(bookEntities); + } - Map progressMap = userProgressService.fetchUserProgress( - user.getId(), bookEntities.stream().map(BookEntity::getId).collect(Collectors.toSet())); + private List buildBooksWithProgress(List bookEntities, Long userId) { + Set bookIds = bookEntities.stream().map(BookEntity::getId).collect(Collectors.toSet()); + Map progressMap = userProgressService.fetchUserProgress(userId, bookIds); - return bookEntities.stream().map(bookEntity -> { - Book book = bookMapper.toBook(bookEntity); - book.setShelves(filterShelvesByUserId(book.getShelves(), user.getId())); - book.setFilePath(FileUtils.getBookFullPath(bookEntity)); - enrichBookWithProgress(book, progressMap.get(bookEntity.getId())); - return book; - }).collect(Collectors.toList()); + return bookEntities.stream() + .map(bookEntity -> buildBook(bookEntity, userId, progressMap)) + .collect(Collectors.toList()); + } + + private Book buildBook(BookEntity bookEntity, Long userId, Map progressMap) { + Book book = bookMapper.toBook(bookEntity); + book.setShelves(filterShelvesByUserId(book.getShelves(), userId)); + book.setFilePath(FileUtils.getBookFullPath(bookEntity)); + BookProgressUtil.enrichBookWithProgress(book, progressMap.get(bookEntity.getId())); + return book; + } + + private BookLoreUserEntity findUserOrThrow(Long userId) { + return userRepository.findById(userId) + .orElseThrow(() -> new UsernameNotFoundException("User not found with ID: " + userId)); + } + + private void validateBulkOperationPermission(List bookIds, BookLoreUser user, UserPermission permission) { + if (bookIds.size() > 1 && !permission.isGranted(user.getPermissions())) { + throw ApiError.PERMISSION_DENIED.createException(permission); + } + } + + private void validateResetPermission(List bookIds, BookLoreUser user, ResetProgressType type) { + if (bookIds.size() <= 1) return; + UserPermission permission = switch (type) { + case BOOKLORE -> UserPermission.CAN_BULK_RESET_BOOKLORE_READ_PROGRESS; + case KOREADER -> UserPermission.CAN_BULK_RESET_KOREADER_READ_PROGRESS; + default -> null; + }; + if (permission != null && !permission.isGranted(user.getPermissions())) { + throw ApiError.PERMISSION_DENIED.createException(permission); + } } private Set validateBooksAndGetExistingProgress(Long userId, List bookIds) { @@ -399,54 +437,47 @@ public class BookUpdateService { private void setProgressPercent(UserBookProgressEntity progress, BookFileType type, Float percentage) { switch (type) { - case EPUB -> progress.setEpubProgressPercent(percentage); + case EPUB, FB2, MOBI, AZW3 -> progress.setEpubProgressPercent(percentage); case PDF -> progress.setPdfProgressPercent(percentage); case CBX -> progress.setCbxProgressPercent(percentage); } } - private ReadStatus getStatus(Float percentage) { - if (percentage >= 99.5f) return ReadStatus.READ; - if (percentage > 0.5f) return ReadStatus.READING; + private ReadStatus calculateReadStatus(Float percentage) { + if (percentage >= COMPLETED_THRESHOLD) return ReadStatus.READ; + if (percentage > READING_THRESHOLD) return ReadStatus.READING; return ReadStatus.UNREAD; } - private void enrichBookWithProgress(Book book, UserBookProgressEntity progress) { - if (progress != null) { - setBookProgress(book, progress); - book.setLastReadTime(progress.getLastReadTime()); - book.setReadStatus(progress.getReadStatus() == null ? String.valueOf(ReadStatus.UNSET) : String.valueOf(progress.getReadStatus())); - book.setDateFinished(progress.getDateFinished()); - book.setPersonalRating(progress.getPersonalRating()); - } + private List buildStatusUpdateResponses(List bookIds, ReadStatus status, Instant now, Instant dateFinished) { + return bookIds.stream() + .map(bookId -> BookStatusUpdateResponse.builder() + .bookId(bookId) + .readStatus(status) + .readStatusModifiedTime(now) + .dateFinished(dateFinished) + .build()) + .collect(Collectors.toList()); } - private void setBookProgress(Book book, UserBookProgressEntity progress) { - if (progress.getKoboProgressPercent() != null) { - book.setKoboProgress(com.adityachandel.booklore.model.dto.progress.KoboProgress.builder() - .percentage(progress.getKoboProgressPercent()) - .build()); - } + private List buildResetResponses(List bookIds, Set existingBookIds, Instant now) { + return bookIds.stream() + .map(bookId -> BookStatusUpdateResponse.builder() + .bookId(bookId) + .readStatus(null) + .readStatusModifiedTime(existingBookIds.contains(bookId) ? now : null) + .dateFinished(null) + .build()) + .collect(Collectors.toList()); + } - switch (book.getBookType()) { - case EPUB -> { - book.setEpubProgress(com.adityachandel.booklore.model.dto.progress.EpubProgress.builder() - .cfi(progress.getEpubProgress()) - .percentage(progress.getEpubProgressPercent()) - .build()); - book.setKoreaderProgress(com.adityachandel.booklore.model.dto.progress.KoProgress.builder() - .percentage(progress.getKoreaderProgressPercent() != null ? progress.getKoreaderProgressPercent() * 100 : null) - .build()); - } - case PDF -> book.setPdfProgress(com.adityachandel.booklore.model.dto.progress.PdfProgress.builder() - .page(progress.getPdfProgress()) - .percentage(progress.getPdfProgressPercent()) - .build()); - case CBX -> book.setCbxProgress(com.adityachandel.booklore.model.dto.progress.CbxProgress.builder() - .page(progress.getCbxProgress()) - .percentage(progress.getCbxProgressPercent()) - .build()); - } + private List buildRatingUpdateResponses(List bookIds, Integer rating) { + return bookIds.stream() + .map(bookId -> PersonalRatingUpdateResponse.builder() + .bookId(bookId) + .personalRating(rating) + .build()) + .collect(Collectors.toList()); } private Set filterShelvesByUserId(Set shelves, Long userId) { diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/service/fileprocessor/Azw3Processor.java b/booklore-api/src/main/java/com/adityachandel/booklore/service/fileprocessor/Azw3Processor.java new file mode 100644 index 000000000..6f3e5a878 --- /dev/null +++ b/booklore-api/src/main/java/com/adityachandel/booklore/service/fileprocessor/Azw3Processor.java @@ -0,0 +1,141 @@ +package com.adityachandel.booklore.service.fileprocessor; + +import com.adityachandel.booklore.mapper.BookMapper; +import com.adityachandel.booklore.model.dto.BookMetadata; +import com.adityachandel.booklore.model.dto.settings.LibraryFile; +import com.adityachandel.booklore.model.entity.BookEntity; +import com.adityachandel.booklore.model.entity.BookMetadataEntity; +import com.adityachandel.booklore.model.enums.BookFileType; +import com.adityachandel.booklore.repository.BookAdditionalFileRepository; +import com.adityachandel.booklore.repository.BookRepository; +import com.adityachandel.booklore.service.book.BookCreatorService; +import com.adityachandel.booklore.service.metadata.MetadataMatchService; +import com.adityachandel.booklore.service.metadata.extractor.Azw3MetadataExtractor; +import com.adityachandel.booklore.util.BookCoverUtils; +import com.adityachandel.booklore.util.FileService; +import com.adityachandel.booklore.util.FileUtils; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +import java.awt.image.BufferedImage; +import java.io.File; +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; + +import static com.adityachandel.booklore.util.FileService.truncate; + +@Slf4j +@Service +public class Azw3Processor extends AbstractFileProcessor implements BookFileProcessor { + + private final Azw3MetadataExtractor azw3MetadataExtractor; + + public Azw3Processor(BookRepository bookRepository, + BookAdditionalFileRepository bookAdditionalFileRepository, + BookCreatorService bookCreatorService, + BookMapper bookMapper, + FileService fileService, + MetadataMatchService metadataMatchService, + Azw3MetadataExtractor azw3MetadataExtractor) { + super(bookRepository, bookAdditionalFileRepository, bookCreatorService, bookMapper, fileService, metadataMatchService); + this.azw3MetadataExtractor = azw3MetadataExtractor; + } + + @Override + public BookEntity processNewFile(LibraryFile libraryFile) { + BookFileType fileType = determineFileType(libraryFile.getFileName()); + BookEntity bookEntity = bookCreatorService.createShellBook(libraryFile, fileType); + setBookMetadata(bookEntity); + if (generateCover(bookEntity)) { + FileService.setBookCoverPath(bookEntity.getMetadata()); + bookEntity.setBookCoverHash(BookCoverUtils.generateCoverHash()); + } + return bookEntity; + } + + @Override + public boolean generateCover(BookEntity bookEntity) { + try { + File azw3File = new File(FileUtils.getBookFullPath(bookEntity)); + byte[] coverData = azw3MetadataExtractor.extractCover(azw3File); + + if (coverData == null || coverData.length == 0) { + log.warn("No cover image found in AZW3 '{}'", bookEntity.getPrimaryBookFile()); + return false; + } + + return saveCoverImage(coverData, bookEntity.getId()); + + } catch (Exception e) { + log.error("Error generating cover for AZW3 '{}': {}", bookEntity.getPrimaryBookFile(), e.getMessage(), e); + return false; + } + } + + @Override + public List getSupportedTypes() { + return List.of(BookFileType.AZW3); + } + + private BookFileType determineFileType(String fileName) { + return BookFileType.AZW3; + } + + private void setBookMetadata(BookEntity bookEntity) { + File bookFile = new File(bookEntity.getFullFilePath().toUri()); + BookMetadata azw3Metadata = azw3MetadataExtractor.extractMetadata(bookFile); + if (azw3Metadata == null) return; + + BookMetadataEntity metadata = bookEntity.getMetadata(); + + metadata.setTitle(truncate(azw3Metadata.getTitle(), 1000)); + metadata.setSubtitle(truncate(azw3Metadata.getSubtitle(), 1000)); + metadata.setDescription(truncate(azw3Metadata.getDescription(), 2000)); + metadata.setPublisher(truncate(azw3Metadata.getPublisher(), 1000)); + metadata.setPublishedDate(azw3Metadata.getPublishedDate()); + metadata.setSeriesName(truncate(azw3Metadata.getSeriesName(), 1000)); + metadata.setSeriesNumber(azw3Metadata.getSeriesNumber()); + metadata.setSeriesTotal(azw3Metadata.getSeriesTotal()); + metadata.setIsbn13(truncate(azw3Metadata.getIsbn13(), 64)); + metadata.setIsbn10(truncate(azw3Metadata.getIsbn10(), 64)); + metadata.setPageCount(azw3Metadata.getPageCount()); + + String lang = azw3Metadata.getLanguage(); + metadata.setLanguage(truncate((lang == null || "UND".equalsIgnoreCase(lang)) ? "en" : lang, 1000)); + + metadata.setAsin(truncate(azw3Metadata.getAsin(), 20)); + metadata.setAmazonRating(azw3Metadata.getAmazonRating()); + metadata.setAmazonReviewCount(azw3Metadata.getAmazonReviewCount()); + metadata.setGoodreadsId(truncate(azw3Metadata.getGoodreadsId(), 100)); + metadata.setGoodreadsRating(azw3Metadata.getGoodreadsRating()); + metadata.setGoodreadsReviewCount(azw3Metadata.getGoodreadsReviewCount()); + metadata.setHardcoverId(truncate(azw3Metadata.getHardcoverId(), 100)); + metadata.setHardcoverRating(azw3Metadata.getHardcoverRating()); + metadata.setHardcoverReviewCount(azw3Metadata.getHardcoverReviewCount()); + metadata.setGoogleId(truncate(azw3Metadata.getGoogleId(), 100)); + metadata.setComicvineId(truncate(azw3Metadata.getComicvineId(), 100)); + metadata.setRanobedbId(truncate(azw3Metadata.getRanobedbId(), 100)); + metadata.setRanobedbRating(azw3Metadata.getRanobedbRating()); + + bookCreatorService.addAuthorsToBook(azw3Metadata.getAuthors(), bookEntity); + + if (azw3Metadata.getCategories() != null) { + Set validSubjects = azw3Metadata.getCategories().stream() + .filter(s -> s != null && !s.isBlank() && s.length() <= 100 && !s.contains("\n") && !s.contains("\r") && !s.contains(" ")) + .collect(Collectors.toSet()); + bookCreatorService.addCategoriesToBook(validSubjects, bookEntity); + } + } + + private boolean saveCoverImage(byte[] coverData, long bookId) throws Exception { + BufferedImage originalImage = FileService.readImage(coverData); + if (originalImage == null) { + log.warn("Failed to decode cover image for AZW3"); + return false; + } + + return fileService.saveCoverImages(originalImage, bookId); + } +} + diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/service/fileprocessor/MobiProcessor.java b/booklore-api/src/main/java/com/adityachandel/booklore/service/fileprocessor/MobiProcessor.java new file mode 100644 index 000000000..fba55aaa3 --- /dev/null +++ b/booklore-api/src/main/java/com/adityachandel/booklore/service/fileprocessor/MobiProcessor.java @@ -0,0 +1,142 @@ +package com.adityachandel.booklore.service.fileprocessor; + +import com.adityachandel.booklore.mapper.BookMapper; +import com.adityachandel.booklore.model.dto.BookMetadata; +import com.adityachandel.booklore.model.dto.settings.LibraryFile; +import com.adityachandel.booklore.model.entity.BookEntity; +import com.adityachandel.booklore.model.entity.BookMetadataEntity; +import com.adityachandel.booklore.model.enums.BookFileType; +import com.adityachandel.booklore.repository.BookAdditionalFileRepository; +import com.adityachandel.booklore.repository.BookRepository; +import com.adityachandel.booklore.service.book.BookCreatorService; +import com.adityachandel.booklore.service.metadata.MetadataMatchService; +import com.adityachandel.booklore.service.metadata.extractor.MobiMetadataExtractor; +import com.adityachandel.booklore.util.BookCoverUtils; +import com.adityachandel.booklore.util.FileService; +import com.adityachandel.booklore.util.FileUtils; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +import java.awt.image.BufferedImage; +import java.io.File; +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; + +import static com.adityachandel.booklore.util.FileService.truncate; + +@Slf4j +@Service +public class MobiProcessor extends AbstractFileProcessor implements BookFileProcessor { + + private final MobiMetadataExtractor mobiMetadataExtractor; + + public MobiProcessor(BookRepository bookRepository, + BookAdditionalFileRepository bookAdditionalFileRepository, + BookCreatorService bookCreatorService, + BookMapper bookMapper, + FileService fileService, + MetadataMatchService metadataMatchService, + MobiMetadataExtractor mobiMetadataExtractor) { + super(bookRepository, bookAdditionalFileRepository, bookCreatorService, bookMapper, fileService, metadataMatchService); + this.mobiMetadataExtractor = mobiMetadataExtractor; + } + + @Override + public BookEntity processNewFile(LibraryFile libraryFile) { + BookFileType fileType = determineFileType(libraryFile.getFileName()); + BookEntity bookEntity = bookCreatorService.createShellBook(libraryFile, fileType); + setBookMetadata(bookEntity); + if (generateCover(bookEntity)) { + FileService.setBookCoverPath(bookEntity.getMetadata()); + bookEntity.setBookCoverHash(BookCoverUtils.generateCoverHash()); + } + return bookEntity; + } + + @Override + public boolean generateCover(BookEntity bookEntity) { + try { + File mobiFile = new File(FileUtils.getBookFullPath(bookEntity)); + byte[] coverData = mobiMetadataExtractor.extractCover(mobiFile); + + if (coverData == null || coverData.length == 0) { + log.warn("No cover image found in MOBI '{}'", bookEntity.getPrimaryBookFile().getFileName()); + return false; + } + + return saveCoverImage(coverData, bookEntity.getId()); + + } catch (Exception e) { + log.error("Error generating cover for MOBI '{}': {}", bookEntity.getPrimaryBookFile(), e.getMessage(), e); + return false; + } + } + + @Override + public List getSupportedTypes() { + return List.of(BookFileType.MOBI); + } + + private BookFileType determineFileType(String fileName) { + String lowerCase = fileName.toLowerCase(); + return BookFileType.MOBI; + } + + private void setBookMetadata(BookEntity bookEntity) { + File bookFile = new File(bookEntity.getFullFilePath().toUri()); + BookMetadata mobiMetadata = mobiMetadataExtractor.extractMetadata(bookFile); + if (mobiMetadata == null) return; + + BookMetadataEntity metadata = bookEntity.getMetadata(); + + metadata.setTitle(truncate(mobiMetadata.getTitle(), 1000)); + metadata.setSubtitle(truncate(mobiMetadata.getSubtitle(), 1000)); + metadata.setDescription(truncate(mobiMetadata.getDescription(), 2000)); + metadata.setPublisher(truncate(mobiMetadata.getPublisher(), 1000)); + metadata.setPublishedDate(mobiMetadata.getPublishedDate()); + metadata.setSeriesName(truncate(mobiMetadata.getSeriesName(), 1000)); + metadata.setSeriesNumber(mobiMetadata.getSeriesNumber()); + metadata.setSeriesTotal(mobiMetadata.getSeriesTotal()); + metadata.setIsbn13(truncate(mobiMetadata.getIsbn13(), 64)); + metadata.setIsbn10(truncate(mobiMetadata.getIsbn10(), 64)); + metadata.setPageCount(mobiMetadata.getPageCount()); + + String lang = mobiMetadata.getLanguage(); + metadata.setLanguage(truncate((lang == null || "UND".equalsIgnoreCase(lang)) ? "en" : lang, 1000)); + + metadata.setAsin(truncate(mobiMetadata.getAsin(), 20)); + metadata.setAmazonRating(mobiMetadata.getAmazonRating()); + metadata.setAmazonReviewCount(mobiMetadata.getAmazonReviewCount()); + metadata.setGoodreadsId(truncate(mobiMetadata.getGoodreadsId(), 100)); + metadata.setGoodreadsRating(mobiMetadata.getGoodreadsRating()); + metadata.setGoodreadsReviewCount(mobiMetadata.getGoodreadsReviewCount()); + metadata.setHardcoverId(truncate(mobiMetadata.getHardcoverId(), 100)); + metadata.setHardcoverRating(mobiMetadata.getHardcoverRating()); + metadata.setHardcoverReviewCount(mobiMetadata.getHardcoverReviewCount()); + metadata.setGoogleId(truncate(mobiMetadata.getGoogleId(), 100)); + metadata.setComicvineId(truncate(mobiMetadata.getComicvineId(), 100)); + metadata.setRanobedbId(truncate(mobiMetadata.getRanobedbId(), 100)); + metadata.setRanobedbRating(mobiMetadata.getRanobedbRating()); + + bookCreatorService.addAuthorsToBook(mobiMetadata.getAuthors(), bookEntity); + + if (mobiMetadata.getCategories() != null) { + Set validSubjects = mobiMetadata.getCategories().stream() + .filter(s -> s != null && !s.isBlank() && s.length() <= 100 && !s.contains("\n") && !s.contains("\r") && !s.contains(" ")) + .collect(Collectors.toSet()); + bookCreatorService.addCategoriesToBook(validSubjects, bookEntity); + } + } + + private boolean saveCoverImage(byte[] coverData, long bookId) throws Exception { + BufferedImage originalImage = FileService.readImage(coverData); + if (originalImage == null) { + log.warn("Failed to decode cover image for MOBI"); + return false; + } + + return fileService.saveCoverImages(originalImage, bookId); + } +} + diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/service/metadata/MetadataRefreshService.java b/booklore-api/src/main/java/com/adityachandel/booklore/service/metadata/MetadataRefreshService.java index 73ff3181c..bae8cb02d 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/service/metadata/MetadataRefreshService.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/service/metadata/MetadataRefreshService.java @@ -562,23 +562,23 @@ public class MetadataRefreshService { return metadata; } - protected T resolveField(Map metadataMap, MetadataRefreshOptions.FieldProvider fieldProvider, Function extractor) { + protected T resolveField(Map < MetadataProvider, BookMetadata > metadataMap, MetadataRefreshOptions.FieldProvider fieldProvider, Function < BookMetadata, T > extractor) { return resolveFieldWithProviders(metadataMap, fieldProvider, extractor, Objects::nonNull); } - protected Integer resolveFieldAsInteger(Map metadataMap, MetadataRefreshOptions.FieldProvider fieldProvider, Function fieldValueExtractor) { + protected Integer resolveFieldAsInteger (Map < MetadataProvider, BookMetadata > metadataMap, MetadataRefreshOptions.FieldProvider fieldProvider, Function < BookMetadata, Integer > fieldValueExtractor){ return resolveFieldWithProviders(metadataMap, fieldProvider, fieldValueExtractor, Objects::nonNull); } - protected String resolveFieldAsString(Map metadataMap, MetadataRefreshOptions.FieldProvider fieldProvider, FieldValueExtractor fieldValueExtractor) { + protected String resolveFieldAsString (Map < MetadataProvider, BookMetadata > metadataMap, MetadataRefreshOptions.FieldProvider fieldProvider, FieldValueExtractor fieldValueExtractor){ return resolveFieldWithProviders(metadataMap, fieldProvider, fieldValueExtractor::extract, Objects::nonNull); } - protected Set resolveFieldAsList(Map metadataMap, MetadataRefreshOptions.FieldProvider fieldProvider, FieldValueExtractorList fieldValueExtractor) { + protected Set resolveFieldAsList (Map < MetadataProvider, BookMetadata > metadataMap, MetadataRefreshOptions.FieldProvider fieldProvider, FieldValueExtractorList fieldValueExtractor){ return resolveFieldWithProviders(metadataMap, fieldProvider, fieldValueExtractor::extract, (value) -> value != null && !value.isEmpty()); } - private T resolveFieldWithProviders(Map metadataMap, MetadataRefreshOptions.FieldProvider fieldProvider, Function extractor, Predicate isValidValue) { + private T resolveFieldWithProviders(Map < MetadataProvider, BookMetadata > metadataMap, MetadataRefreshOptions.FieldProvider fieldProvider, Function < BookMetadata, T > extractor, Predicate < T > isValidValue) { if (fieldProvider == null) { return null; } @@ -599,7 +599,7 @@ public class MetadataRefreshService { return null; } - Set getAllCategories(Map metadataMap, MetadataRefreshOptions.FieldProvider fieldProvider, FieldValueExtractorList fieldValueExtractor) { + Set getAllCategories (Map < MetadataProvider, BookMetadata > metadataMap, MetadataRefreshOptions.FieldProvider fieldProvider, FieldValueExtractorList fieldValueExtractor){ Set uniqueCategories = new HashSet<>(); if (fieldProvider == null) { return uniqueCategories; @@ -624,7 +624,7 @@ public class MetadataRefreshService { return uniqueCategories; } - protected Set getBookEntities(MetadataRefreshRequest request) { + protected Set getBookEntities (MetadataRefreshRequest request){ MetadataRefreshRequest.RefreshType refreshType = request.getRefreshType(); if (refreshType != MetadataRefreshRequest.RefreshType.LIBRARY && refreshType != MetadataRefreshRequest.RefreshType.BOOKS) { throw ApiError.INVALID_REFRESH_TYPE.createException(); @@ -638,4 +638,3 @@ public class MetadataRefreshService { }; } } - diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/service/metadata/extractor/Azw3MetadataExtractor.java b/booklore-api/src/main/java/com/adityachandel/booklore/service/metadata/extractor/Azw3MetadataExtractor.java new file mode 100644 index 000000000..5311dbd76 --- /dev/null +++ b/booklore-api/src/main/java/com/adityachandel/booklore/service/metadata/extractor/Azw3MetadataExtractor.java @@ -0,0 +1,31 @@ +package com.adityachandel.booklore.service.metadata.extractor; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import java.io.IOException; +import java.io.RandomAccessFile; + +@Slf4j +@Component +public class Azw3MetadataExtractor extends MobiBaseMetadataExtractor { + + private static final long KF8_BOUNDARY = 0xFFFFFFFF; + + @Override + protected String getFormatName() { + return "AZW3"; + } + + @Override + protected void processFormatSpecificHeader(RandomAccessFile raf, PalmDBRecord record0, MobiHeader header, int headerLength) throws IOException { + // For KF8 (AZW3), check for KF8 boundary and resource index + if (header.isKF8 && headerLength >= 232) { + raf.seek(record0.offset + 16 + 192); + long kf8Boundary = readInt(raf) & 0xFFFFFFFFL; + if (kf8Boundary == KF8_BOUNDARY) { + log.debug("KF8 boundary found at expected location"); + } + } + } +} diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/service/metadata/extractor/MetadataExtractorFactory.java b/booklore-api/src/main/java/com/adityachandel/booklore/service/metadata/extractor/MetadataExtractorFactory.java index 54ccf587b..c99471a10 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/service/metadata/extractor/MetadataExtractorFactory.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/service/metadata/extractor/MetadataExtractorFactory.java @@ -16,6 +16,8 @@ public class MetadataExtractorFactory { private final PdfMetadataExtractor pdfMetadataExtractor; private final CbxMetadataExtractor cbxMetadataExtractor; private final Fb2MetadataExtractor fb2MetadataExtractor; + private final MobiMetadataExtractor mobiMetadataExtractor; + private final Azw3MetadataExtractor azw3MetadataExtractor; public BookMetadata extractMetadata(BookFileType bookFileType, File file) { return switch (bookFileType) { @@ -23,6 +25,8 @@ public class MetadataExtractorFactory { case EPUB -> epubMetadataExtractor.extractMetadata(file); case CBX -> cbxMetadataExtractor.extractMetadata(file); case FB2 -> fb2MetadataExtractor.extractMetadata(file); + case MOBI -> mobiMetadataExtractor.extractMetadata(file); + case AZW3 -> azw3MetadataExtractor.extractMetadata(file); }; } @@ -32,6 +36,8 @@ public class MetadataExtractorFactory { case EPUB -> epubMetadataExtractor.extractMetadata(file); case CBZ, CBR, CB7 -> cbxMetadataExtractor.extractMetadata(file); case FB2 -> fb2MetadataExtractor.extractMetadata(file); + case MOBI -> mobiMetadataExtractor.extractMetadata(file); + case AZW3, AZW -> azw3MetadataExtractor.extractMetadata(file); }; } @@ -41,6 +47,8 @@ public class MetadataExtractorFactory { case PDF -> pdfMetadataExtractor.extractCover(file); case CBZ, CBR, CB7 -> cbxMetadataExtractor.extractCover(file); case FB2 -> fb2MetadataExtractor.extractCover(file); + case MOBI -> mobiMetadataExtractor.extractCover(file); + case AZW3, AZW -> azw3MetadataExtractor.extractCover(file); }; } } diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/service/metadata/extractor/MobiBaseMetadataExtractor.java b/booklore-api/src/main/java/com/adityachandel/booklore/service/metadata/extractor/MobiBaseMetadataExtractor.java new file mode 100644 index 000000000..f5c16e00f --- /dev/null +++ b/booklore-api/src/main/java/com/adityachandel/booklore/service/metadata/extractor/MobiBaseMetadataExtractor.java @@ -0,0 +1,430 @@ +package com.adityachandel.booklore.service.metadata.extractor; + +import com.adityachandel.booklore.model.dto.BookMetadata; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.StringUtils; + +import java.io.File; +import java.io.IOException; +import java.io.RandomAccessFile; +import java.nio.charset.Charset; +import java.nio.charset.StandardCharsets; +import java.time.LocalDate; +import java.util.*; + +@Slf4j +public abstract class MobiBaseMetadataExtractor implements FileMetadataExtractor { + + // EXTH record types + protected static final int EXTH_AUTHOR = 100; + protected static final int EXTH_PUBLISHER = 101; + protected static final int EXTH_DESCRIPTION = 103; + protected static final int EXTH_ISBN = 104; + protected static final int EXTH_SUBJECT = 105; + protected static final int EXTH_PUBLISHED_DATE = 106; + protected static final int EXTH_LANGUAGE = 524; + protected static final int EXTH_COVER_OFFSET = 201; + protected static final int EXTH_THUMB_OFFSET = 202; + protected static final int EXTH_ASIN = 113; + + protected abstract String getFormatName(); + + @Override + public byte[] extractCover(File file) { + try (RandomAccessFile raf = new RandomAccessFile(file, "r")) { + PalmDB palmDB = readPalmDB(raf); + if (palmDB == null) { + return null; + } + + MobiHeader mobiHeader = readMobiHeader(raf, palmDB); + if (mobiHeader == null) { + return null; + } + + // Try to get cover from EXTH + Integer coverIndex = mobiHeader.exthRecords.get(EXTH_COVER_OFFSET); + if (coverIndex == null) { + coverIndex = mobiHeader.exthRecords.get(EXTH_THUMB_OFFSET); + } + + if (coverIndex != null) { + int imageRecordIndex = mobiHeader.firstImageIndex + coverIndex; + if (imageRecordIndex < palmDB.records.size()) { + return extractImageFromRecord(raf, palmDB.records.get(imageRecordIndex)); + } + } + + // Try first image + if (mobiHeader.firstImageIndex > 0 && mobiHeader.firstImageIndex < palmDB.records.size()) { + return extractImageFromRecord(raf, palmDB.records.get(mobiHeader.firstImageIndex)); + } + + return null; + } catch (Exception e) { + log.error("Failed to extract cover from {}: {}", getFormatName(), file.getName(), e); + return null; + } + } + + @Override + public BookMetadata extractMetadata(File file) { + try (RandomAccessFile raf = new RandomAccessFile(file, "r")) { + PalmDB palmDB = readPalmDB(raf); + if (palmDB == null) { + log.warn("Failed to read PalmDB header from: {}", file.getName()); + return null; + } + + MobiHeader mobiHeader = readMobiHeader(raf, palmDB); + if (mobiHeader == null) { + log.warn("Failed to read {} header from: {}", getFormatName(), file.getName()); + return null; + } + + BookMetadata.BookMetadataBuilder builder = BookMetadata.builder(); + Set authors = new HashSet<>(); + Set categories = new HashSet<>(); + + // Extract title + if (StringUtils.isNotBlank(mobiHeader.title)) { + builder.title(mobiHeader.title); + } + + // Extract metadata from EXTH records + for (var entry : mobiHeader.exthData.entrySet()) { + int recordType = entry.getKey(); + String value = entry.getValue(); + + if (StringUtils.isBlank(value)) { + continue; + } + + switch (recordType) { + case EXTH_AUTHOR -> authors.add(value.trim()); + case EXTH_PUBLISHER -> builder.publisher(value.trim()); + case EXTH_DESCRIPTION -> builder.description(value.trim()); + case EXTH_ISBN -> { + String isbn = value.replaceAll("[^0-9Xx]", ""); + if (isbn.length() == 13) { + builder.isbn13(isbn); + } else if (isbn.length() == 10) { + builder.isbn10(isbn); + } + } + case EXTH_SUBJECT -> { + for (String category : value.split("[;,]")) { + String trimmed = category.trim(); + if (StringUtils.isNotBlank(trimmed)) { + categories.add(trimmed); + } + } + } + case EXTH_PUBLISHED_DATE -> { + LocalDate date = parseDate(value.trim()); + if (date != null) { + builder.publishedDate(date); + } + } + case EXTH_LANGUAGE -> builder.language(value.trim()); + case EXTH_ASIN -> builder.asin(value.trim()); + } + } + + builder.authors(authors); + builder.categories(categories); + + return builder.build(); + } catch (Exception e) { + log.error("Failed to extract metadata from {}: {}", getFormatName(), file.getName(), e); + return null; + } + } + + protected PalmDB readPalmDB(RandomAccessFile raf) throws IOException { + if (raf.length() < 78) { + log.debug("File too small for PalmDB"); + return null; + } + + PalmDB palmDB = new PalmDB(); + + // Read database name + raf.seek(0); + byte[] nameBytes = new byte[32]; + raf.readFully(nameBytes); + int nameLen = 0; + for (int i = 0; i < nameBytes.length; i++) { + if (nameBytes[i] == 0) { + nameLen = i; + break; + } + } + palmDB.name = new String(nameBytes, 0, nameLen, StandardCharsets.ISO_8859_1); + + // Read number of records + raf.seek(76); + palmDB.numRecords = readShort(raf); + + log.debug("PalmDB: name={}, records={}", palmDB.name, palmDB.numRecords); + + if (palmDB.numRecords <= 0 || palmDB.numRecords > 10000) { + log.debug("Invalid number of records: {}", palmDB.numRecords); + return null; + } + + // Read record list + raf.seek(78); + for (int i = 0; i < palmDB.numRecords; i++) { + PalmDBRecord record = new PalmDBRecord(); + record.offset = readInt(raf); + record.attributes = raf.read(); + record.id = readThreeBytes(raf); + palmDB.records.add(record); + } + + // Calculate record sizes + for (int i = 0; i < palmDB.records.size(); i++) { + PalmDBRecord record = palmDB.records.get(i); + if (i < palmDB.records.size() - 1) { + record.size = palmDB.records.get(i + 1).offset - record.offset; + } else { + record.size = (int) (raf.length() - record.offset); + } + } + + return palmDB; + } + + protected MobiHeader readMobiHeader(RandomAccessFile raf, PalmDB palmDB) throws IOException { + if (palmDB.records.isEmpty()) { + return null; + } + + PalmDBRecord record0 = palmDB.records.get(0); + raf.seek(record0.offset); + + MobiHeader header = new MobiHeader(); + + // Read PalmDOC header + int compression = readShort(raf); + raf.skipBytes(2); + int textLength = readInt(raf); + int recordCount = readShort(raf); + int recordSize = readShort(raf); + int encryptionType = readShort(raf); + raf.skipBytes(2); + + log.debug("PalmDOC: compression={}, textLength={}, recordCount={}, recordSize={}", + compression, textLength, recordCount, recordSize); + + // Check for MOBI header + raf.seek(record0.offset + 16); + byte[] identifier = new byte[4]; + raf.readFully(identifier); + String identifierStr = new String(identifier, StandardCharsets.US_ASCII); + + log.debug("Header identifier: {}", identifierStr); + + if (!identifierStr.equals("MOBI")) { + log.debug("No MOBI/KF8 header found"); + return null; + } + + // Read MOBI header + raf.seek(record0.offset + 20); + int headerLength = readInt(raf); + int mobiType = readInt(raf); + int textEncoding = readInt(raf); + + log.debug("{}: headerLength={}, type={}, encoding={}", getFormatName(), headerLength, mobiType, textEncoding); + + // Check if this is KF8 (AZW3) format + header.isKF8 = (mobiType == 2); + if (header.isKF8) { + log.debug("Detected KF8 (AZW3) format"); + } + + // Process format-specific logic + processFormatSpecificHeader(raf, record0, header, headerLength); + + // Determine charset + Charset charset = textEncoding == 65001 ? StandardCharsets.UTF_8 : StandardCharsets.ISO_8859_1; + header.charset = charset; + + // Skip to full name offset/length + raf.seek(record0.offset + 16 + 68); + int fullNameOffset = readInt(raf); + int fullNameLength = readInt(raf); + + log.debug("Full name: offset={}, length={}", fullNameOffset, fullNameLength); + + // Read title + if (fullNameLength > 0 && fullNameLength < 10000) { + raf.seek(record0.offset + fullNameOffset); + byte[] titleBytes = new byte[fullNameLength]; + raf.readFully(titleBytes); + header.title = new String(titleBytes, charset).trim(); + log.debug("Title: {}", header.title); + } + + // Read first image index + raf.seek(record0.offset + 16 + 92); + header.firstImageIndex = readInt(raf); + + log.debug("First image index: {}", header.firstImageIndex); + + // Check for EXTH header + raf.seek(record0.offset + 16 + 112); + int exthFlags = readInt(raf); + + log.debug("EXTH flags: 0x{}", Integer.toHexString(exthFlags)); + + if ((exthFlags & 0x40) != 0) { + long exthOffset = record0.offset + 16 + headerLength; + raf.seek(exthOffset); + + byte[] exthIdentifier = new byte[4]; + raf.readFully(exthIdentifier); + String exthStr = new String(exthIdentifier, StandardCharsets.US_ASCII); + + log.debug("EXTH identifier: {}", exthStr); + + if (exthStr.equals("EXTH")) { + readExthRecords(raf, header, charset); + } + } + + return header; + } + + protected void processFormatSpecificHeader(RandomAccessFile raf, PalmDBRecord record0, + MobiHeader header, int headerLength) throws IOException { + // Override in subclasses for format-specific processing + } + + protected void readExthRecords(RandomAccessFile raf, MobiHeader header, Charset charset) throws IOException { + long exthStart = raf.getFilePointer() - 4; + + int headerLength = readInt(raf); + int recordCount = readInt(raf); + + log.debug("EXTH: headerLength={}, recordCount={}", headerLength, recordCount); + + for (int i = 0; i < recordCount && i < 500; i++) { + int recordType = readInt(raf); + int recordLength = readInt(raf); + + if (recordLength < 8 || recordLength > 100000) { + log.warn("Invalid EXTH record length: {}", recordLength); + break; + } + + int dataLength = recordLength - 8; + byte[] data = new byte[dataLength]; + raf.readFully(data); + + if (recordType == EXTH_COVER_OFFSET || recordType == EXTH_THUMB_OFFSET) { + if (dataLength >= 4) { + int value = ((data[0] & 0xFF) << 24) | ((data[1] & 0xFF) << 16) | + ((data[2] & 0xFF) << 8) | (data[3] & 0xFF); + header.exthRecords.put(recordType, value); + log.debug("EXTH record {} (int): {}", recordType, value); + } + } else { + String value = new String(data, charset).trim(); + value = value.replace("\0", ""); + if (!value.isEmpty()) { + header.exthData.put(recordType, value); + log.debug("EXTH record {} (string): {}", recordType, + value.length() > 100 ? value.substring(0, 100) + "..." : value); + } + } + } + } + + protected byte[] extractImageFromRecord(RandomAccessFile raf, PalmDBRecord record) throws IOException { + if (record.size <= 0 || record.size > 10_000_000) { + return null; + } + + raf.seek(record.offset); + byte[] data = new byte[record.size]; + raf.readFully(data); + + // Check for image magic bytes + if (data.length > 4) { + if ((data[0] == (byte) 0xFF && data[1] == (byte) 0xD8) || // JPEG + (data[0] == (byte) 0x89 && data[1] == (byte) 0x50 && + data[2] == (byte) 0x4E && data[3] == (byte) 0x47) || // PNG + (data[0] == (byte) 0x47 && data[1] == (byte) 0x49 && + data[2] == (byte) 0x46)) { // GIF + return data; + } + } + + return null; + } + + protected LocalDate parseDate(String dateString) { + if (StringUtils.isBlank(dateString)) { + return null; + } + + try { + if (dateString.matches("\\d{4}-\\d{2}-\\d{2}")) { + return LocalDate.parse(dateString); + } + + if (dateString.matches("\\d{4}")) { + return LocalDate.of(Integer.parseInt(dateString), 1, 1); + } + + if (dateString.matches("\\d{4}/\\d{2}/\\d{2}")) { + String[] parts = dateString.split("/"); + return LocalDate.of(Integer.parseInt(parts[0]), + Integer.parseInt(parts[1]), + Integer.parseInt(parts[2])); + } + } catch (Exception e) { + log.debug("Failed to parse date: {}", dateString, e); + } + + return null; + } + + protected int readInt(RandomAccessFile raf) throws IOException { + return (raf.read() << 24) | (raf.read() << 16) | (raf.read() << 8) | raf.read(); + } + + protected int readShort(RandomAccessFile raf) throws IOException { + return (raf.read() << 8) | raf.read(); + } + + protected int readThreeBytes(RandomAccessFile raf) throws IOException { + return (raf.read() << 16) | (raf.read() << 8) | raf.read(); + } + + protected static class PalmDB { + String name; + int numRecords; + List records = new ArrayList<>(); + } + + protected static class PalmDBRecord { + int offset; + int attributes; + int id; + int size; + } + + protected static class MobiHeader { + String title; + int firstImageIndex = -1; + boolean isKF8 = false; + Charset charset = StandardCharsets.UTF_8; + Map exthData = new HashMap<>(); + Map exthRecords = new HashMap<>(); + } +} + diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/service/metadata/extractor/MobiMetadataExtractor.java b/booklore-api/src/main/java/com/adityachandel/booklore/service/metadata/extractor/MobiMetadataExtractor.java new file mode 100644 index 000000000..03165c262 --- /dev/null +++ b/booklore-api/src/main/java/com/adityachandel/booklore/service/metadata/extractor/MobiMetadataExtractor.java @@ -0,0 +1,14 @@ +package com.adityachandel.booklore.service.metadata.extractor; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +@Slf4j +@Component +public class MobiMetadataExtractor extends MobiBaseMetadataExtractor { + + @Override + protected String getFormatName() { + return "MOBI"; + } +} diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/service/opds/OpdsFeedService.java b/booklore-api/src/main/java/com/adityachandel/booklore/service/opds/OpdsFeedService.java index a775ca504..4f62c2e17 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/service/opds/OpdsFeedService.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/service/opds/OpdsFeedService.java @@ -8,18 +8,17 @@ import com.adityachandel.booklore.model.enums.OpdsSortOrder; import com.adityachandel.booklore.service.MagicShelfService; import com.adityachandel.booklore.util.ArchiveUtils; import com.adityachandel.booklore.util.FileUtils; - -import java.io.File; -import java.util.HashSet; -import java.util.Set; import jakarta.servlet.http.HttpServletRequest; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.data.domain.Page; import org.springframework.stereotype.Service; +import java.io.File; import java.time.format.DateTimeFormatter; +import java.util.HashSet; import java.util.List; +import java.util.Set; @Slf4j @Service @@ -381,7 +380,7 @@ public class OpdsFeedService { int size = Math.min(parseLongParam(request, "size", (long) DEFAULT_PAGE_SIZE).intValue(), MAX_PAGE_SIZE); Page booksPage = opdsBookService.getRecentBooksPage(userId, page - 1, size); - + // Apply user's preferred sort order booksPage = opdsBookService.applySortOrder(booksPage, sortOrder); @@ -621,6 +620,8 @@ public class OpdsFeedService { } yield "application/x-fictionbook+xml"; } + case MOBI -> "application/x-mobipocket-ebook"; + case AZW3 -> "application/vnd.amazon.ebook"; case CBX -> { if (book.getArchiveType() != null) { yield switch (book.getArchiveType()) { @@ -674,9 +675,9 @@ public class OpdsFeedService { private Set parseShelfIds(HttpServletRequest request) { String shelfIdParam = request.getParameter("shelfId"); String shelfIdsParam = request.getParameter("shelfIds"); - + Set shelfIds = new HashSet<>(); - + // Support both single shelfId and comma-separated shelfIds if (shelfIdParam != null && !shelfIdParam.isBlank()) { try { @@ -685,7 +686,7 @@ public class OpdsFeedService { log.warn("Invalid shelfId parameter: {}", shelfIdParam); } } - + if (shelfIdsParam != null && !shelfIdsParam.isBlank()) { for (String id : shelfIdsParam.split(",")) { try { @@ -695,7 +696,7 @@ public class OpdsFeedService { } } } - + return shelfIds.isEmpty() ? null : shelfIds; } diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/service/user/DefaultUserSettingsProvider.java b/booklore-api/src/main/java/com/adityachandel/booklore/service/user/DefaultUserSettingsProvider.java index 5f56b15f6..64a8cc001 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/service/user/DefaultUserSettingsProvider.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/service/user/DefaultUserSettingsProvider.java @@ -24,6 +24,7 @@ public class DefaultUserSettingsProvider { defaultSettings.put(UserSettingKey.PER_BOOK_SETTING, this::buildDefaultPerBookSetting); defaultSettings.put(UserSettingKey.PDF_READER_SETTING, this::buildDefaultPdfReaderSetting); defaultSettings.put(UserSettingKey.EPUB_READER_SETTING, this::buildDefaultEpubReaderSetting); + defaultSettings.put(UserSettingKey.EBOOK_READER_SETTING, this::buildDefaultEbookReaderSetting); defaultSettings.put(UserSettingKey.CBX_READER_SETTING, this::buildDefaultCbxReaderSetting); defaultSettings.put(UserSettingKey.NEW_PDF_READER_SETTING, this::buildDefaultNewPdfReaderSetting); defaultSettings.put(UserSettingKey.SIDEBAR_LIBRARY_SORTING, this::buildDefaultSidebarLibrarySorting); @@ -75,6 +76,23 @@ public class DefaultUserSettingsProvider { .build(); } + private BookLoreUser.UserSettings.EbookReaderSetting buildDefaultEbookReaderSetting() { + return BookLoreUser.UserSettings.EbookReaderSetting.builder() + .fontFamily("serif") + .fontSize(16) + .gap(0.05f) + .hyphenate(false) + .isDark(false) + .justify(false) + .lineHeight(1.5f) + .maxBlockSize(1440) + .maxColumnCount(2) + .maxInlineSize(720) + .theme("gray") + .flow("paginated") + .build(); + } + private BookLoreUser.UserSettings.CbxReaderSetting buildDefaultCbxReaderSetting() { return BookLoreUser.UserSettings.CbxReaderSetting.builder() .pageViewMode(CbxPageViewMode.SINGLE_PAGE) @@ -112,6 +130,7 @@ public class DefaultUserSettingsProvider { .view("GRID") .coverSize(1.0F) .seriesCollapsed(false) + .overlayBookType(true) .build()) .overrides(null) .build(); diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/util/BookProgressUtil.java b/booklore-api/src/main/java/com/adityachandel/booklore/util/BookProgressUtil.java new file mode 100644 index 000000000..8ba9ac735 --- /dev/null +++ b/booklore-api/src/main/java/com/adityachandel/booklore/util/BookProgressUtil.java @@ -0,0 +1,58 @@ +package com.adityachandel.booklore.util; + +import com.adityachandel.booklore.model.dto.*; +import com.adityachandel.booklore.model.dto.progress.*; +import com.adityachandel.booklore.model.entity.UserBookProgressEntity; +import com.adityachandel.booklore.model.enums.BookFileType; +import com.adityachandel.booklore.model.enums.ReadStatus; + +public class BookProgressUtil { + + public static void setBookProgress(Book book, UserBookProgressEntity progress) { + if (progress.getKoboProgressPercent() != null) { + book.setKoboProgress(KoboProgress.builder() + .percentage(progress.getKoboProgressPercent()) + .build()); + } + + BookFileType type = book.getBookType(); + if (type == null) return; + + switch (type) { + case EPUB -> { + book.setEpubProgress(EpubProgress.builder() + .cfi(progress.getEpubProgress()) + .href(progress.getEpubProgressHref()) + .percentage(progress.getEpubProgressPercent()) + .build()); + book.setKoreaderProgress(KoProgress.builder() + .percentage(progress.getKoreaderProgressPercent() != null ? progress.getKoreaderProgressPercent() * 100 : null) + .build()); + } + case FB2, MOBI, AZW3 -> book.setEpubProgress(EpubProgress.builder() + .cfi(progress.getEpubProgress()) + .href(progress.getEpubProgressHref()) + .percentage(progress.getEpubProgressPercent()) + .build()); + case PDF -> book.setPdfProgress(PdfProgress.builder() + .page(progress.getPdfProgress()) + .percentage(progress.getPdfProgressPercent()) + .build()); + case CBX -> book.setCbxProgress(CbxProgress.builder() + .page(progress.getCbxProgress()) + .percentage(progress.getCbxProgressPercent()) + .build()); + } + } + + public static void enrichBookWithProgress(Book book, UserBookProgressEntity progress) { + if (progress != null) { + setBookProgress(book, progress); + book.setLastReadTime(progress.getLastReadTime()); + book.setReadStatus(progress.getReadStatus() == null ? String.valueOf(ReadStatus.UNSET) : String.valueOf(progress.getReadStatus())); + book.setDateFinished(progress.getDateFinished()); + book.setPersonalRating(progress.getPersonalRating()); + } + } +} + diff --git a/booklore-api/src/main/resources/db/migration/V91__Create_ebook_viewer_preference_table.sql b/booklore-api/src/main/resources/db/migration/V91__Create_ebook_viewer_preference_table.sql new file mode 100644 index 000000000..0b6fd3119 --- /dev/null +++ b/booklore-api/src/main/resources/db/migration/V91__Create_ebook_viewer_preference_table.sql @@ -0,0 +1,24 @@ +CREATE TABLE IF NOT EXISTS ebook_viewer_preference +( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + user_id BIGINT NOT NULL, + book_id BIGINT NOT NULL, + font_family VARCHAR(128) NULL, + font_size INT NULL, + gap FLOAT NULL, + hyphenate BOOLEAN NULL, + is_dark BOOLEAN NULL, + justify BOOLEAN NULL, + line_height FLOAT NULL, + max_block_size INT NULL, + max_column_count INT NULL, + max_inline_size INT NULL, + theme VARCHAR(64) NULL, + flow VARCHAR(32) NULL, + UNIQUE (user_id, book_id), + CONSTRAINT fk_ebook_viewer_preference_user FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE, + CONSTRAINT fk_ebook_viewer_preference_book FOREIGN KEY (book_id) REFERENCES book (id) ON DELETE CASCADE +); + +ALTER TABLE user_book_progress + ADD COLUMN IF NOT EXISTS epub_progress_href VARCHAR(1000); \ No newline at end of file diff --git a/booklore-api/src/test/java/com/adityachandel/booklore/BookServiceDeleteTests.java b/booklore-api/src/test/java/com/adityachandel/booklore/BookServiceDeleteTests.java index 68b40cb31..e07be5a5d 100644 --- a/booklore-api/src/test/java/com/adityachandel/booklore/BookServiceDeleteTests.java +++ b/booklore-api/src/test/java/com/adityachandel/booklore/BookServiceDeleteTests.java @@ -38,7 +38,7 @@ class BookServiceDeleteTests { void setUp() { BookRepository bookRepository = Mockito.mock(BookRepository.class); PdfViewerPreferencesRepository pdfViewerPreferencesRepository = Mockito.mock(PdfViewerPreferencesRepository.class); - EpubViewerPreferencesRepository epubViewerPreferencesRepository = Mockito.mock(EpubViewerPreferencesRepository.class); + EbookViewerPreferenceRepository ebookViewerPreferenceRepository = Mockito.mock(EbookViewerPreferenceRepository.class); CbxViewerPreferencesRepository cbxViewerPreferencesRepository = Mockito.mock(CbxViewerPreferencesRepository.class); NewPdfViewerPreferencesRepository newPdfViewerPreferencesRepository = Mockito.mock(NewPdfViewerPreferencesRepository.class); FileService fileService = Mockito.mock(FileService.class); @@ -54,7 +54,6 @@ class BookServiceDeleteTests { bookService = new BookService( bookRepository, pdfViewerPreferencesRepository, - epubViewerPreferencesRepository, cbxViewerPreferencesRepository, newPdfViewerPreferencesRepository, fileService, @@ -65,7 +64,8 @@ class BookServiceDeleteTests { userProgressService, bookDownloadService, monitoringRegistrationService, - bookUpdateService + bookUpdateService, + ebookViewerPreferenceRepository ); } diff --git a/booklore-api/src/test/java/com/adityachandel/booklore/service/book/BookServiceTest.java b/booklore-api/src/test/java/com/adityachandel/booklore/service/book/BookServiceTest.java index 56304b3c6..3acc0f326 100644 --- a/booklore-api/src/test/java/com/adityachandel/booklore/service/book/BookServiceTest.java +++ b/booklore-api/src/test/java/com/adityachandel/booklore/service/book/BookServiceTest.java @@ -20,6 +20,7 @@ import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.MockedStatic; +import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.core.io.ByteArrayResource; import org.springframework.core.io.ClassPathResource; import org.springframework.core.io.Resource; @@ -38,7 +39,7 @@ import java.util.Set; import static org.junit.jupiter.api.Assertions.*; import static org.mockito.Mockito.*; -@ExtendWith(org.mockito.junit.jupiter.MockitoExtension.class) +@ExtendWith(MockitoExtension.class) class BookServiceTest { @Mock @@ -46,7 +47,7 @@ class BookServiceTest { @Mock private PdfViewerPreferencesRepository pdfViewerPreferencesRepository; @Mock - private EpubViewerPreferencesRepository epubViewerPreferencesRepository; + private EbookViewerPreferenceRepository ebookViewerPreferenceRepository; @Mock private CbxViewerPreferencesRepository cbxViewerPreferencesRepository; @Mock @@ -170,15 +171,35 @@ class BookServiceTest { primaryFile.setBookType(BookFileType.EPUB); entity.setBookFiles(List.of(primaryFile)); when(bookRepository.findByIdWithBookFiles(4L)).thenReturn(Optional.of(entity)); - EpubViewerPreferencesEntity epubPref = new EpubViewerPreferencesEntity(); - epubPref.setFont("Arial"); - when(epubViewerPreferencesRepository.findByBookIdAndUserId(4L, testUser.getId())).thenReturn(Optional.of(epubPref)); + EbookViewerPreferenceEntity epubPref = new EbookViewerPreferenceEntity(); + epubPref.setFontFamily("Arial"); + epubPref.setFontSize(16); + epubPref.setGap(0.2f); + epubPref.setHyphenate(true); + epubPref.setIsDark(false); + epubPref.setJustify(true); + epubPref.setLineHeight(1.5f); + epubPref.setMaxBlockSize(800); + epubPref.setMaxColumnCount(2); + epubPref.setMaxInlineSize(1200); + epubPref.setTheme("light"); + when(ebookViewerPreferenceRepository.findByBookIdAndUserId(4L, testUser.getId())).thenReturn(Optional.of(epubPref)); when(authenticationService.getAuthenticatedUser()).thenReturn(testUser); BookViewerSettings settings = bookService.getBookViewerSetting(4L); - assertNotNull(settings.getEpubSettings()); - assertEquals("Arial", settings.getEpubSettings().getFont()); + assertNotNull(settings.getEbookSettings()); + assertEquals("Arial", settings.getEbookSettings().getFontFamily()); + assertEquals(16, settings.getEbookSettings().getFontSize()); + assertEquals(0.2f, settings.getEbookSettings().getGap()); + assertTrue(settings.getEbookSettings().getHyphenate()); + assertFalse(settings.getEbookSettings().getIsDark()); + assertTrue(settings.getEbookSettings().getJustify()); + assertEquals(1.5f, settings.getEbookSettings().getLineHeight()); + assertEquals(800, settings.getEbookSettings().getMaxBlockSize()); + assertEquals(2, settings.getEbookSettings().getMaxColumnCount()); + assertEquals(1200, settings.getEbookSettings().getMaxInlineSize()); + assertEquals("light", settings.getEbookSettings().getTheme()); } @Test diff --git a/booklore-api/src/test/java/com/adityachandel/booklore/service/book/BookUpdateServiceTest.java b/booklore-api/src/test/java/com/adityachandel/booklore/service/book/BookUpdateServiceTest.java index 46885dac1..d54acbc4b 100644 --- a/booklore-api/src/test/java/com/adityachandel/booklore/service/book/BookUpdateServiceTest.java +++ b/booklore-api/src/test/java/com/adityachandel/booklore/service/book/BookUpdateServiceTest.java @@ -34,8 +34,6 @@ class BookUpdateServiceTest { @Mock private PdfViewerPreferencesRepository pdfViewerPreferencesRepository; @Mock - private EpubViewerPreferencesRepository epubViewerPreferencesRepository; - @Mock private CbxViewerPreferencesRepository cbxViewerPreferencesRepository; @Mock private NewPdfViewerPreferencesRepository newPdfViewerPreferencesRepository; @@ -55,6 +53,8 @@ class BookUpdateServiceTest { private UserProgressService userProgressService; @Mock private KoboReadingStateService koboReadingStateService; + @Mock + private EbookViewerPreferenceRepository ebookViewerPreferenceRepository; @InjectMocks private BookUpdateService bookUpdateService; @@ -65,7 +65,6 @@ class BookUpdateServiceTest { bookUpdateService = new BookUpdateService( bookRepository, pdfViewerPreferencesRepository, - epubViewerPreferencesRepository, cbxViewerPreferencesRepository, newPdfViewerPreferencesRepository, shelfRepository, @@ -75,7 +74,8 @@ class BookUpdateServiceTest { authenticationService, bookQueryService, userProgressService, - koboReadingStateService + koboReadingStateService, + ebookViewerPreferenceRepository ); } @@ -110,7 +110,7 @@ class BookUpdateServiceTest { } @Test - void updateBookViewerSetting_epub_shouldUpdateEpubPrefs() { + void updateBookViewerSetting_epub_shouldUpdateEpubPrefsV2() { long bookId = 1L; BookEntity book = new BookEntity(); book.setId(bookId); @@ -123,30 +123,40 @@ class BookUpdateServiceTest { when(authenticationService.getAuthenticatedUser()).thenReturn(user); when(user.getId()).thenReturn(2L); - EpubViewerPreferencesEntity epubPrefs = new EpubViewerPreferencesEntity(); - when(epubViewerPreferencesRepository.findByBookIdAndUserId(bookId, 2L)).thenReturn(Optional.of(epubPrefs)); + EbookViewerPreferenceEntity epubPrefs = new EbookViewerPreferenceEntity(); + when(ebookViewerPreferenceRepository.findByBookIdAndUserId(bookId, 2L)).thenReturn(Optional.of(epubPrefs)); BookViewerSettings settings = BookViewerSettings.builder() - .epubSettings(EpubViewerPreferences.builder() - .font("font") - .fontSize(12) - .theme("theme") - .flow("flow") - .spread("spread") - .letterSpacing(1.2f) - .lineHeight(1.3f) + .ebookSettings(EbookViewerPreferences.builder() + .fontFamily("serif") + .fontSize(18) + .gap(0.1f) + .hyphenate(true) + .isDark(true) + .justify(true) + .lineHeight(1.7f) + .maxBlockSize(800) + .maxColumnCount(3) + .maxInlineSize(1200) + .theme("dark") + .flow("paginated") .build()) .build(); bookUpdateService.updateBookViewerSetting(bookId, settings); - verify(epubViewerPreferencesRepository).save(epubPrefs); - assertEquals("font", epubPrefs.getFont()); - assertEquals(12, epubPrefs.getFontSize()); - assertEquals("theme", epubPrefs.getTheme()); - assertEquals("flow", epubPrefs.getFlow()); - assertEquals("spread", epubPrefs.getSpread()); - assertEquals(1.2f, epubPrefs.getLetterSpacing()); - assertEquals(1.3f, epubPrefs.getLineHeight()); + verify(ebookViewerPreferenceRepository).save(epubPrefs); + assertEquals("serif", epubPrefs.getFontFamily()); + assertEquals(18, epubPrefs.getFontSize()); + assertEquals(0.1f, epubPrefs.getGap()); + assertTrue(epubPrefs.getHyphenate()); + assertTrue(epubPrefs.getIsDark()); + assertTrue(epubPrefs.getJustify()); + assertEquals(1.7f, epubPrefs.getLineHeight()); + assertEquals(800, epubPrefs.getMaxBlockSize()); + assertEquals(3, epubPrefs.getMaxColumnCount()); + assertEquals(1200, epubPrefs.getMaxInlineSize()); + assertEquals("dark", epubPrefs.getTheme()); + assertEquals("paginated", epubPrefs.getFlow()); } @Test diff --git a/booklore-ui/angular.json b/booklore-ui/angular.json index 5cb7cc473..2ee301054 100644 --- a/booklore-ui/angular.json +++ b/booklore-ui/angular.json @@ -42,6 +42,11 @@ "glob": "**/*", "input": "node_modules/ngx-extended-pdf-viewer/assets/", "output": "/assets/" + }, + { + "glob": "**/*", + "input": "libs/foliate-js", + "output": "/assets/foliate/" } ], "styles": [ diff --git a/booklore-ui/src/app/app.routes.ts b/booklore-ui/src/app/app.routes.ts index 336962f63..dc5fd62d6 100644 --- a/booklore-ui/src/app/app.routes.ts +++ b/booklore-ui/src/app/app.routes.ts @@ -25,6 +25,7 @@ import {BookdropGuard} from './core/security/guards/bookdrop.guard'; import {LibraryStatsGuard} from './core/security/guards/library-stats.guard'; import {UserStatsGuard} from './core/security/guards/user-stats.guard'; import {EditMetadataGuard} from './core/security/guards/edit-metdata.guard'; +import {EbookReaderComponent} from './features/readers/ebook-reader/ebook-reader.component'; export const routes: Routes = [ { @@ -68,6 +69,11 @@ export const routes: Routes = [ component: EpubReaderComponent, canActivate: [AuthGuard] }, + { + path: 'ebook-reader/book/:bookId', + component: EbookReaderComponent, + canActivate: [AuthGuard] + }, { path: 'cbx-reader/book/:bookId', component: CbxReaderComponent, diff --git a/booklore-ui/src/app/features/book/components/book-browser/book-browser.component.html b/booklore-ui/src/app/features/book/components/book-browser/book-browser.component.html index b2b340205..8daf99681 100644 --- a/booklore-ui/src/app/features/book/components/book-browser/book-browser.component.html +++ b/booklore-ui/src/app/features/book/components/book-browser/book-browser.component.html @@ -124,6 +124,16 @@ {{ coverScalePreferenceService.scaleFactor.toFixed(2) }}x + +
+ + + +
@@ -273,7 +283,8 @@ [onBookSelect]="handleBookSelect.bind(this)" [isSeriesCollapsed]="seriesCollapseFilter.isSeriesCollapsed" (checkboxClick)="onCheckboxClicked($event)" - [isSelected]="selectedBooks.has(book.id)"> + [isSelected]="selectedBooks.has(book.id)" + [overlayPreferenceService]="bookCardOverlayPreferenceService"> } diff --git a/booklore-ui/src/app/features/book/components/book-browser/book-browser.component.ts b/booklore-ui/src/app/features/book/components/book-browser/book-browser.component.ts index 628a26329..2dc90b743 100644 --- a/booklore-ui/src/app/features/book/components/book-browser/book-browser.component.ts +++ b/booklore-ui/src/app/features/book/components/book-browser/book-browser.component.ts @@ -51,6 +51,7 @@ import {TaskHelperService} from '../../../settings/task-management/task-helper.s import {FilterLabelHelper} from './filter-label.helper'; import {LoadingService} from '../../../../core/services/loading.service'; import {BookNavigationService} from '../../service/book-navigation.service'; +import {BookCardOverlayPreferenceService} from './book-card-overlay-preference.service'; export enum EntityType { LIBRARY = 'Library', @@ -123,6 +124,7 @@ export class BookBrowserComponent implements OnInit, AfterViewInit { private pageTitle = inject(PageTitleService); private loadingService = inject(LoadingService); private bookNavigationService = inject(BookNavigationService); + protected bookCardOverlayPreferenceService = inject(BookCardOverlayPreferenceService); bookState$: Observable | undefined; entity$: Observable | undefined; diff --git a/booklore-ui/src/app/features/book/components/book-browser/book-card-overlay-preference.service.ts b/booklore-ui/src/app/features/book/components/book-browser/book-card-overlay-preference.service.ts new file mode 100644 index 000000000..5c5f8b336 --- /dev/null +++ b/booklore-ui/src/app/features/book/components/book-browser/book-card-overlay-preference.service.ts @@ -0,0 +1,126 @@ +import {inject, Injectable} from '@angular/core'; +import {BehaviorSubject, Subject} from 'rxjs'; +import {debounceTime, filter, takeUntil} from 'rxjs/operators'; +import {UserService} from '../../../settings/user-management/user.service'; + +@Injectable({ + providedIn: 'root' +}) +export class BookCardOverlayPreferenceService { + private readonly userService = inject(UserService); + + private readonly _showBookTypePill = new BehaviorSubject(true); + readonly showBookTypePill$ = this._showBookTypePill.asObservable(); + + private destroy$ = new Subject(); + private hasUserToggled = false; + private currentContext: { type: 'LIBRARY' | 'SHELF' | 'MAGIC_SHELF', id: number } | null = null; + + constructor() { + this.userService.userState$ + .pipe( + filter(userState => !!userState?.user && userState.loaded), + takeUntil(this.destroy$) + ) + .subscribe(() => { + this.loadPreferencesFromUser(); + }); + + this.showBookTypePill$ + .pipe(debounceTime(500)) + .subscribe(show => { + if (this.hasUserToggled) { + this.persistPreference(show); + } + }); + } + + setShowBookTypePill(show: boolean): void { + this.hasUserToggled = true; + this._showBookTypePill.next(show); + } + + get showBookTypePill(): boolean { + return this._showBookTypePill.value; + } + + private loadPreferencesFromUser(): void { + const user = this.userService.getCurrentUser(); + const prefs = user?.userSettings?.entityViewPreferences; + + let show = true; + if (prefs) { + const globalAny = prefs.global as any; + show = prefs.global?.overlayBookType ?? globalAny?.showBookTypePill ?? true; + + if (this.currentContext) { + const override = prefs.overrides?.find(o => + o.entityType === this.currentContext?.type && o.entityId === this.currentContext?.id + ); + if (override) { + const prefAny = override.preferences as any; + if (override.preferences.overlayBookType !== undefined) { + show = override.preferences.overlayBookType; + } else if (prefAny?.showBookTypePill !== undefined) { + show = prefAny.showBookTypePill; + } + } + } + } + + this.hasUserToggled = false; + if (this._showBookTypePill.value !== show) { + this._showBookTypePill.next(show); + } + } + + private persistPreference(show: boolean): void { + const user = this.userService.getCurrentUser(); + if (!user) return; + + const prefs = structuredClone(user.userSettings.entityViewPreferences ?? { + global: { + sortKey: 'addedOn', + sortDir: 'DESC', + view: 'GRID', + coverSize: 1.0, + seriesCollapsed: false, + overlayBookType: true + }, + overrides: [] + }); + + if (!prefs.overrides) { + prefs.overrides = []; + } + + if (this.currentContext) { + let override = prefs.overrides.find(o => + o.entityType === this.currentContext?.type && o.entityId === this.currentContext?.id + ); + + if (!override) { + override = { + entityType: this.currentContext.type, + entityId: this.currentContext.id, + preferences: { + ...prefs.global, + overlayBookType: show + } + }; + prefs.overrides.push(override); + } else { + override.preferences.overlayBookType = show; + } + } else { + prefs.global.overlayBookType = show; + } + + this.userService.updateUserSetting(user.id, 'entityViewPreferences', prefs); + } + + ngOnDestroy(): void { + this.destroy$.next(); + this.destroy$.complete(); + } +} diff --git a/booklore-ui/src/app/features/book/components/book-browser/book-card/book-card.component.html b/booklore-ui/src/app/features/book/components/book-browser/book-card/book-card.component.html index 0a554be1e..171fdd038 100644 --- a/booklore-ui/src/app/features/book/components/book-browser/book-card/book-card.component.html +++ b/booklore-ui/src/app/features/book/components/book-browser/book-card/book-card.component.html @@ -12,17 +12,23 @@ decoding="async" (load)="onImageLoad()"/> - @if (!book.seriesCount && book.metadata?.seriesNumber != null) { -
- #{{ book.metadata?.seriesNumber }} -
- } - - @if (book.seriesCount && book.seriesCount >= 1) { -
- {{ book.seriesCount }} -
- } +
+ @if (showBookTypePill && book.bookType) { +
+ {{ book.bookType }} +
+ } + @if (!book.seriesCount && book.metadata?.seriesNumber != null) { +
+ #{{ book.metadata?.seriesNumber }} +
+ } + @if (book.seriesCount && book.seriesCount >= 1) { +
+ {{ book.seriesCount }} +
+ } +
@if (_isSeriesViewActive) { @@ -30,7 +36,7 @@ } - @if (!_isSeriesViewActive && _canReadBook) { + @if (!_isSeriesViewActive) { } diff --git a/booklore-ui/src/app/features/book/components/book-browser/book-card/book-card.component.scss b/booklore-ui/src/app/features/book/components/book-browser/book-card/book-card.component.scss index 4580d7487..81abf0311 100644 --- a/booklore-ui/src/app/features/book/components/book-browser/book-card/book-card.component.scss +++ b/booklore-ui/src/app/features/book/components/book-browser/book-card/book-card.component.scss @@ -21,10 +21,6 @@ justify-content: center; } -.cover-container.loaded { - background-color: transparent; -} - .book-cover { width: 100%; height: 100%; @@ -157,13 +153,11 @@ .series-items-count-overlay { position: absolute; - top: 8px; - left: 8px; background-color: var(--primary-color); color: var(--primary-text-color-dark); - font-size: 0.75rem; - padding: 3px 8px; - border-radius: 4px; + font-size: 0.7rem; + padding: 0 5px; + border-radius: 6px; z-index: 3; white-space: nowrap; user-select: none; @@ -176,6 +170,76 @@ gap: 2px; } +.series-items-count-icon { + font-size: 0.6rem; + margin-right: 2px; +} + +.book-type-pill-overlay { + position: absolute; + background: var(--primary-color, #6366f1); + color: var(--primary-text-color-dark, #fff); + font-size: 0.675rem; + font-weight: 700; + padding: 1px 6px; + border-radius: 6px; + z-index: 3; + box-shadow: 0 2px 6px rgba(0, 0, 0, 0.25); + letter-spacing: 0.03em; + user-select: none; + pointer-events: none; + text-transform: uppercase; + transition: background 0.2s; +} + +.top-left-overlay-stack { + position: absolute; + top: 7px; + left: 7px; + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 4px; + z-index: 4; +} + +.book-type-pill-overlay, +.series-number-overlay, +.series-items-count-overlay { + position: static; + margin: 0; +} + +.book-type-epub { + background: #16a34a; /* medium green */ + color: #fff; +} + +.book-type-pdf { + background: #dc2626; /* medium red */ + color: #fff; +} + +.book-type-cbx { + background: #d97706; /* amber/orange */ + color: #fff; +} + +.book-type-mobi { + background: #2563eb; /* medium blue */ + color: #fff; +} + +.book-type-azw3 { + background: #0891b2; /* cyan/teal */ + color: #fff; +} + +.book-type-fb2 { + background: #7c3aed; /* vibrant purple */ + color: #fff; +} + .read-status-indicator { display: flex; align-items: center; diff --git a/booklore-ui/src/app/features/book/components/book-browser/book-card/book-card.component.ts b/booklore-ui/src/app/features/book/components/book-browser/book-card/book-card.component.ts index 167c397a8..1cff90d0a 100644 --- a/booklore-ui/src/app/features/book/components/book-browser/book-card/book-card.component.ts +++ b/booklore-ui/src/app/features/book/components/book-browser/book-card/book-card.component.ts @@ -23,6 +23,7 @@ import {ReadStatusHelper} from '../../../helpers/read-status.helper'; import {BookDialogHelperService} from '../book-dialog-helper.service'; import {TaskHelperService} from '../../../../settings/task-management/task-helper.service'; import {BookNavigationService} from '../../../service/book-navigation.service'; +import {BookCardOverlayPreferenceService} from '../book-card-overlay-preference.service'; @Component({ selector: 'app-book-card', @@ -45,6 +46,7 @@ export class BookCardComponent implements OnInit, OnChanges, OnDestroy { @Input() bottomBarHidden: boolean = false; @Input() seriesViewEnabled: boolean = false; @Input() isSeriesCollapsed: boolean = false; + @Input() overlayPreferenceService?: BookCardOverlayPreferenceService; @ViewChild('checkboxElem') checkboxElem!: ElementRef; @@ -69,7 +71,6 @@ export class BookCardComponent implements OnInit, OnChanges, OnDestroy { protected _koProgressPercentage: number | null = null; protected _koboProgressPercentage: number | null = null; protected _displayTitle: string | undefined = undefined; - protected _canReadBook: boolean = true; protected _isSeriesViewActive: boolean = false; protected _coverImageUrl: string = ''; protected _readStatusIcon: string = ''; @@ -86,6 +87,10 @@ export class BookCardComponent implements OnInit, OnChanges, OnDestroy { private user: User | null = null; private menuInitialized = false; + showBookTypePill = true; + + private overlayPrefSub?: any; + ngOnInit(): void { this.computeAllMemoizedValues(); this.userService.userState$ @@ -98,6 +103,13 @@ export class BookCardComponent implements OnInit, OnChanges, OnDestroy { this.user = userState.user; this.metadataCenterViewMode = userState.user?.userSettings?.metadataCenterViewMode ?? 'route'; }); + + if (this.overlayPreferenceService) { + this.overlayPrefSub = this.overlayPreferenceService.showBookTypePill$.subscribe(val => { + this.showBookTypePill = val; + this.cdr.markForCheck(); + }); + } } ngOnChanges(changes: SimpleChanges): void { @@ -131,7 +143,6 @@ export class BookCardComponent implements OnInit, OnChanges, OnDestroy { this._displayTitle = (this.isSeriesCollapsed && this.book.metadata?.seriesName) ? this.book.metadata?.seriesName : this.book.metadata?.title; - this._canReadBook = this.book?.bookType !== 'FB2'; this._coverImageUrl = this.urlHelper.getThumbnailUrl(this.book.id, this.book.metadata?.coverUpdatedOn); this._readStatusIcon = this.readStatusHelper.getReadStatusIcon(this.book.readStatus); @@ -770,5 +781,8 @@ export class BookCardComponent implements OnInit, OnChanges, OnDestroy { ngOnDestroy(): void { this.destroy$.next(); this.destroy$.complete(); + if (this.overlayPrefSub) { + this.overlayPrefSub.unsubscribe(); + } } } diff --git a/booklore-ui/src/app/features/book/model/book.model.ts b/booklore-ui/src/app/features/book/model/book.model.ts index 39a979d0a..915aeea79 100644 --- a/booklore-ui/src/app/features/book/model/book.model.ts +++ b/booklore-ui/src/app/features/book/model/book.model.ts @@ -3,7 +3,7 @@ import {CbxBackgroundColor, CbxFitMode, CbxPageSpread, CbxPageViewMode, CbxScrol import {BookReview} from '../components/book-reviews/book-review-service'; import {ZoomType} from 'ngx-extended-pdf-viewer'; -export type BookType = "PDF" | "EPUB" | "CBX" | "FB2"; +export type BookType = "PDF" | "EPUB" | "CBX" | "FB2" | "MOBI" | "AZW3"; export enum AdditionalFileType { ALTERNATIVE_FORMAT = 'ALTERNATIVE_FORMAT', @@ -48,6 +48,7 @@ export interface Book extends FileInfo { libraryPath?: { id: number }; alternativeFormats?: AdditionalFile[]; supplementaryFiles?: AdditionalFile[]; + [key: string]: unknown; } @@ -150,6 +151,7 @@ export interface BookMetadata { tagsLocked?: boolean; coverLocked?: boolean; reviewsLocked?: boolean; + [key: string]: unknown; } @@ -210,6 +212,21 @@ export interface EpubViewerSetting { customFontId?: number | null; } +export interface EbookViewerSetting { + lineHeight: number; + justify: boolean; + hyphenate: boolean; + maxColumnCount: number; + gap: number; + fontSize: number; + theme: string + maxInlineSize: number; + maxBlockSize: number; + fontFamily: string | null; + isDark: boolean; + flow: 'paginated' | 'scrolled'; +} + export interface CbxViewerSetting { pageSpread: CbxPageSpread; pageViewMode: CbxPageViewMode; @@ -221,8 +238,10 @@ export interface CbxViewerSetting { export interface BookSetting { pdfSettings?: PdfViewerSetting; epubSettings?: EpubViewerSetting; + ebookSettings?: EbookViewerSetting; cbxSettings?: CbxViewerSetting; newPdfSettings?: NewPdfReaderSetting; + [key: string]: unknown; } diff --git a/booklore-ui/src/app/features/book/model/library.model.ts b/booklore-ui/src/app/features/book/model/library.model.ts index 63eb8e886..319263166 100644 --- a/booklore-ui/src/app/features/book/model/library.model.ts +++ b/booklore-ui/src/app/features/book/model/library.model.ts @@ -1,7 +1,7 @@ import {SortOption} from './sort.model'; export type LibraryScanMode = 'FILE_AS_BOOK' | 'FOLDER_AS_BOOK'; -export type BookFileType = 'PDF' | 'EPUB' | 'CBX' | 'FB2'; +export type BookFileType = 'PDF' | 'EPUB' | 'CBX' | 'FB2' | 'MOBI' | 'AZW3'; export interface Library { id?: number; diff --git a/booklore-ui/src/app/features/book/service/book-patch.service.spec.ts b/booklore-ui/src/app/features/book/service/book-patch.service.spec.ts index a64ca98af..6c2b91e10 100644 --- a/booklore-ui/src/app/features/book/service/book-patch.service.spec.ts +++ b/booklore-ui/src/app/features/book/service/book-patch.service.spec.ts @@ -68,12 +68,10 @@ describe('BookPatchService', () => { it('should save EPUB progress', () => { httpMock.post.mockReturnValue(of(void 0)); - service.saveEpubProgress(2, 'cfi123', 0.8).subscribe(result => { - expect(result).toBeUndefined(); - expect(httpMock.post).toHaveBeenCalledWith(expect.stringContaining('/progress'), { - bookId: 2, - epubProgress: {cfi: 'cfi123', percentage: 0.8} - }); + service.saveEpubProgress(2, 'cfi123', 'href123', 0.8); + expect(httpMock.post).toHaveBeenCalledWith(expect.stringContaining('/progress'), { + bookId: 2, + epubProgress: {cfi: 'cfi123', href: 'href123', percentage: 0.8} }); }); diff --git a/booklore-ui/src/app/features/book/service/book-patch.service.ts b/booklore-ui/src/app/features/book/service/book-patch.service.ts index 07d6f15bc..9b8529fb4 100644 --- a/booklore-ui/src/app/features/book/service/book-patch.service.ts +++ b/booklore-ui/src/app/features/book/service/book-patch.service.ts @@ -1,7 +1,7 @@ import {inject, Injectable} from '@angular/core'; import {HttpClient, HttpParams} from '@angular/common/http'; -import {Observable} from 'rxjs'; -import {tap} from 'rxjs/operators'; +import {Observable, Subject} from 'rxjs'; +import {distinctUntilChanged, exhaustMap, share, tap} from 'rxjs/operators'; import {Book, ReadStatus} from '../model/book.model'; import {BookStateService} from './book-state.service'; import {API_CONFIG} from '../../../core/config/api-config'; @@ -17,6 +17,33 @@ export class BookPatchService { private http = inject(HttpClient); private bookStateService = inject(BookStateService); + private epubProgressSubject = new Subject<{ bookId: number; cfi: string; href: string; percentage: number }>(); + + private epubProgress$ = this.epubProgressSubject.pipe( + distinctUntilChanged((prev, curr) => + prev.bookId === curr.bookId && + prev.cfi === curr.cfi && + prev.href === curr.href && + prev.percentage === curr.percentage + ), + exhaustMap(payload => { + const body = { + bookId: payload.bookId, + epubProgress: { + cfi: payload.cfi, + href: payload.href, + percentage: payload.percentage + } + }; + return this.http.post(`${this.url}/progress`, body); + }), + share() + ); + + constructor() { + this.epubProgress$.subscribe(); + } + updateBookShelves(bookIds: Set, shelvesToAssign: Set, shelvesToUnassign: Set): Observable { const requestPayload = { bookIds: Array.from(bookIds), @@ -49,15 +76,8 @@ export class BookPatchService { return this.http.post(`${this.url}/progress`, body); } - saveEpubProgress(bookId: number, cfi: string, percentage: number): Observable { - const body = { - bookId: bookId, - epubProgress: { - cfi: cfi, - percentage: percentage - } - }; - return this.http.post(`${this.url}/progress`, body); + saveEpubProgress(bookId: number, cfi: string, href: string, percentage: number): void { + this.epubProgressSubject.next({bookId, cfi, href, percentage}); } saveCbxProgress(bookId: number, page: number, percentage: number): Observable { @@ -197,4 +217,3 @@ export class BookPatchService { this.bookStateService.updateBookState({...currentState, books: updatedBooks}); } } - diff --git a/booklore-ui/src/app/features/book/service/book.service.spec.ts b/booklore-ui/src/app/features/book/service/book.service.spec.ts index a9eb917dc..9337bd538 100644 --- a/booklore-ui/src/app/features/book/service/book.service.spec.ts +++ b/booklore-ui/src/app/features/book/service/book.service.spec.ts @@ -329,12 +329,6 @@ describe('BookService', () => { expect(result).toBeUndefined(); }); - it('should save epub progress', async () => { - bookPatchServiceMock.saveEpubProgress.mockReturnValue(of(void 0)); - const result = await firstValueFrom(service.saveEpubProgress(1, 'cfi', 0.5)); - expect(result).toBeUndefined(); - }); - it('should save cbx progress', async () => { bookPatchServiceMock.saveCbxProgress.mockReturnValue(of(void 0)); const result = await firstValueFrom(service.saveCbxProgress(1, 2, 0.5)); diff --git a/booklore-ui/src/app/features/book/service/book.service.ts b/booklore-ui/src/app/features/book/service/book.service.ts index 232e00c33..18efc7f2a 100644 --- a/booklore-ui/src/app/features/book/service/book.service.ts +++ b/booklore-ui/src/app/features/book/service/book.service.ts @@ -259,8 +259,8 @@ export class BookService { ? reader === 'ngx' ? 'pdf-reader' : 'cbx-reader' - : book.bookType === 'EPUB' - ? 'epub-reader' + : book.bookType === 'EPUB' || book.bookType === 'FB2' || book.bookType === 'MOBI' || book.bookType === 'AZW3' + ? 'ebook-reader' : book.bookType === 'CBX' ? 'cbx-reader' : null; @@ -414,9 +414,9 @@ export class BookService { return this.bookPatchService.savePdfProgress(bookId, page, percentage); } - saveEpubProgress(bookId: number, cfi: string, percentage: number): Observable { - return this.bookPatchService.saveEpubProgress(bookId, cfi, percentage); - } + /*saveEpubProgress(bookId: number, cfi: string, href: string, percentage: number): Observable { + return this.bookPatchService.saveEpubProgress(bookId, cfi, href, percentage); + }*/ saveCbxProgress(bookId: number, page: number, percentage: number): Observable { return this.bookPatchService.saveCbxProgress(bookId, page, percentage); diff --git a/booklore-ui/src/app/features/dashboard/components/dashboard-scroller/dashboard-scroller.component.html b/booklore-ui/src/app/features/dashboard/components/dashboard-scroller/dashboard-scroller.component.html index a98860a4c..ca7d91316 100644 --- a/booklore-ui/src/app/features/dashboard/components/dashboard-scroller/dashboard-scroller.component.html +++ b/booklore-ui/src/app/features/dashboard/components/dashboard-scroller/dashboard-scroller.component.html @@ -24,7 +24,12 @@ [horizontal]="true"> @for (book of books; track book.id) {
- +
} diff --git a/booklore-ui/src/app/features/dashboard/components/dashboard-scroller/dashboard-scroller.component.ts b/booklore-ui/src/app/features/dashboard/components/dashboard-scroller/dashboard-scroller.component.ts index 8a31aee2f..87d0e8fea 100644 --- a/booklore-ui/src/app/features/dashboard/components/dashboard-scroller/dashboard-scroller.component.ts +++ b/booklore-ui/src/app/features/dashboard/components/dashboard-scroller/dashboard-scroller.component.ts @@ -1,10 +1,11 @@ -import {Component, ElementRef, Input, ViewChild} from '@angular/core'; +import {Component, ElementRef, Input, ViewChild, inject} from '@angular/core'; import {BookCardComponent} from '../../../book/components/book-browser/book-card/book-card.component'; import {InfiniteScrollDirective} from 'ngx-infinite-scroll'; import {ProgressSpinnerModule} from 'primeng/progressspinner'; import {Book} from '../../../book/model/book.model'; import {ScrollerType} from '../../models/dashboard-config.model'; +import { BookCardOverlayPreferenceService } from '../../../book/components/book-browser/book-card-overlay-preference.service'; @Component({ selector: 'app-dashboard-scroller', @@ -27,6 +28,8 @@ export class DashboardScrollerComponent { @ViewChild('scrollContainer') scrollContainer!: ElementRef; openMenuBookId: number | null = null; + public bookCardOverlayPreferenceService = inject(BookCardOverlayPreferenceService); + handleMenuToggle(bookId: number, isOpen: boolean) { this.openMenuBookId = isOpen ? bookId : null; } diff --git a/booklore-ui/src/app/features/magic-shelf/component/magic-shelf-component.ts b/booklore-ui/src/app/features/magic-shelf/component/magic-shelf-component.ts index 022271968..715706e88 100644 --- a/booklore-ui/src/app/features/magic-shelf/component/magic-shelf-component.ts +++ b/booklore-ui/src/app/features/magic-shelf/component/magic-shelf-component.ts @@ -197,7 +197,10 @@ export class MagicShelfComponent implements OnInit { {label: 'EPUB', value: 'epub'}, {label: 'CBR', value: 'cbr'}, {label: 'CBZ', value: 'cbz'}, - {label: 'CB7', value: 'cb7'} + {label: 'CB7', value: 'cb7'}, + {label: 'FB2', value: 'fb2'}, + {label: 'MOBI', value: 'mobi'}, + {label: 'AZW3', value: 'azw3'} ]; readStatusOptions = Object.entries(ReadStatus).map(([key, value]) => ({ diff --git a/booklore-ui/src/app/features/metadata/component/book-metadata-center/metadata-viewer/metadata-viewer.component.html b/booklore-ui/src/app/features/metadata/component/book-metadata-center/metadata-viewer/metadata-viewer.component.html index c1812911b..0123c6547 100644 --- a/booklore-ui/src/app/features/metadata/component/book-metadata-center/metadata-viewer/metadata-viewer.component.html +++ b/booklore-ui/src/app/features/metadata/component/book-metadata-center/metadata-viewer/metadata-viewer.component.html @@ -559,7 +559,7 @@ } } - @if (book!.bookType !== 'PDF' && book!.bookType !== 'FB2') { + @if (book!.bookType !== 'PDF') { } diff --git a/booklore-ui/src/app/features/metadata/component/book-metadata-center/metadata-viewer/metadata-viewer.component.ts b/booklore-ui/src/app/features/metadata/component/book-metadata-center/metadata-viewer/metadata-viewer.component.ts index fd7be7833..a4986ede3 100644 --- a/booklore-ui/src/app/features/metadata/component/book-metadata-center/metadata-viewer/metadata-viewer.component.ts +++ b/booklore-ui/src/app/features/metadata/component/book-metadata-center/metadata-viewer/metadata-viewer.component.ts @@ -734,6 +734,12 @@ export class MetadataViewerComponent implements OnInit, OnChanges { return 'purple'; case 'cb7': return 'blue'; + case 'fb2': + return 'orange'; + case 'mobi': + return 'yellow'; + case 'azw3': + return 'green'; default: return 'gray'; } diff --git a/booklore-ui/src/app/features/readers/ebook-reader/ebook-reader.component.html b/booklore-ui/src/app/features/readers/ebook-reader/ebook-reader.component.html new file mode 100644 index 000000000..ad7894505 --- /dev/null +++ b/booklore-ui/src/app/features/readers/ebook-reader/ebook-reader.component.html @@ -0,0 +1,96 @@ +
+ + @if (isLoading) { +
+
+
+

Loading book...

+
+
+ } + + + + +
+ @if (isCurrentCfiBookmarked) { +
+ + + +
+ } +
+
+ + + + + @if (showChapters) { + + } + + @if (showControls) { + + + } + + @if (showMetadata) { + + } +
diff --git a/booklore-ui/src/app/features/readers/ebook-reader/ebook-reader.component.scss b/booklore-ui/src/app/features/readers/ebook-reader/ebook-reader.component.scss new file mode 100644 index 000000000..2f28b0f14 --- /dev/null +++ b/booklore-ui/src/app/features/readers/ebook-reader/ebook-reader.component.scss @@ -0,0 +1,120 @@ +.reader { + width: 100vw; + height: 100dvh; + overflow: hidden; + position: fixed; + top: 0; + left: 0; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; +} + +.reader-content { + position: relative; + flex: 1; + overflow: hidden; + width: 100%; + height: 100%; +} + +.navigation-overlay { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + z-index: 10; + cursor: pointer; + pointer-events: auto; + background: transparent; +} + +#foliate-container { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + width: 100%; + height: 100%; + pointer-events: auto; +} + +::ng-deep foliate-view { + width: 100% !important; + height: 100% !important; + display: block !important; + position: absolute !important; + top: 0 !important; + left: 0 !important; +} + +.loader-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: rgba(0, 0, 0, 0.8); + display: flex; + align-items: center; + justify-content: center; + z-index: 9999; +} + +.loader-content { + display: flex; + flex-direction: column; + align-items: center; + gap: 1.5rem; +} + +.spinner { + width: 48px; + height: 48px; + border: 4px solid rgba(255, 255, 255, 0.2); + border-top-color: #fff; + border-radius: 50%; + animation: spin 0.8s linear infinite; +} + +.loader-text { + color: #fff; + font-size: 1.1rem; + margin: 0; + font-weight: 500; +} + +@keyframes spin { + to { + transform: rotate(360deg); + } +} + +foliate-view::part(head) { + display: flex; + justify-content: space-between; + align-items: center; + padding: 8px 16px; + font-size: 0.875rem; + color: var(--text-secondary, #666); + border-bottom: 1px solid var(--border-color, #e0e0e0); + font-family: inherit; + min-height: 32px; + box-sizing: border-box; +} + +foliate-view::part(foot) { + display: flex; + justify-content: space-between; + align-items: center; + padding: 8px 16px; + font-size: 0.875rem; + color: var(--text-secondary, #666); + border-top: 1px solid var(--border-color, #e0e0e0); + font-family: inherit; + min-height: 32px; + box-sizing: border-box; +} diff --git a/booklore-ui/src/app/features/readers/ebook-reader/ebook-reader.component.ts b/booklore-ui/src/app/features/readers/ebook-reader/ebook-reader.component.ts new file mode 100644 index 000000000..10160942e --- /dev/null +++ b/booklore-ui/src/app/features/readers/ebook-reader/ebook-reader.component.ts @@ -0,0 +1,449 @@ +import {Component, CUSTOM_ELEMENTS_SCHEMA, HostListener, inject, OnDestroy, OnInit} from '@angular/core'; +import {CommonModule, Location} from '@angular/common'; +import {forkJoin, Observable, of, Subject, throwError} from 'rxjs'; +import {catchError, switchMap, takeUntil, tap} from 'rxjs/operators'; +import {ReaderLoaderService} from './services/reader-loader.service'; +import {ReaderViewManagerService} from './services/reader-view-manager.service'; +import {ReaderStateService} from './services/reader-state.service'; +import {ReaderStyleService} from './services/reader-style.service'; +import {ReaderBookmarkService} from './services/reader-bookmark.service'; +import {BookService} from '../../book/service/book.service'; +import {ActivatedRoute} from '@angular/router'; +import {BookMark, BookMarkService} from '../../../shared/service/book-mark.service'; +import {BookPatchService} from '../../book/service/book-patch.service'; +import {EpubCustomFontService} from '../epub-reader/service/epub-custom-font.service'; +import {Book, EbookViewerSetting} from '../../book/model/book.model'; +import {ReaderHeaderComponent} from './reader-layout/header/reader-header.component'; +import {ReaderSidebarComponent} from './reader-layout/sidebar/reader-sidebar.component'; +import {ReaderNavbarComponent} from './reader-layout/navbar/reader-navbar.component'; +import {ReaderSettingsDialogComponent} from './reader-layout/header/reader-settings-dialog.component'; +import {ReaderBookMetadataDialogComponent} from './reader-layout/sidebar/reader-book-metadata-dialog.component'; +import {ReadingSessionService} from '../../../shared/service/reading-session.service'; +import {TocItem} from 'epubjs'; +import {PageInfo, ThemeInfo} from './utils/reader-header-footer.util'; +import {ReaderHeaderFooterVisibilityManager} from './utils/reader-header-footer-visibility.util'; + +@Component({ + selector: 'app-ebook-reader', + standalone: true, + imports: [ + CommonModule, + ReaderHeaderComponent, + ReaderSettingsDialogComponent, + ReaderBookMetadataDialogComponent, + ReaderSidebarComponent, + ReaderNavbarComponent + ], + schemas: [CUSTOM_ELEMENTS_SCHEMA], + providers: [ + ReaderLoaderService, + ReaderViewManagerService, + ReaderStateService, + ReaderStyleService, + ReaderBookmarkService + ], + templateUrl: './ebook-reader.component.html', + styleUrls: ['./ebook-reader.component.scss'] +}) +export class EbookReaderComponent implements OnInit, OnDestroy { + private destroy$ = new Subject(); + private loaderService = inject(ReaderLoaderService); + private styleService = inject(ReaderStyleService); + private bookService = inject(BookService); + private route = inject(ActivatedRoute); + private bookMarkService = inject(BookMarkService); + private bookPatchService = inject(BookPatchService); + private epubCustomFontService = inject(EpubCustomFontService); + private bookmarkService = inject(ReaderBookmarkService); + private readingSessionService = inject(ReadingSessionService); + + public viewManager = inject(ReaderViewManagerService); + public stateService = inject(ReaderStateService); + protected location = inject(Location); + protected bookId!: number; + + private hasLoadedOnce = false; + private hasStartedSession = false; + private _fileUrl: string | null = null; + private currentCfi: string | null = null; + + private visibilityManager!: ReaderHeaderFooterVisibilityManager; + + isLoading = true; + showControls = false; + showChapters = false; + showMetadata = false; + isCurrentCfiBookmarked = false; + forceHeaderVisible = false; + forceNavbarVisible = false; + + chapters: TocItem[] = []; + bookmarks: BookMark[] = []; + book: Book | null = null; + coverUpdatedOn: string | undefined; + currentChapterName: string | null = null; + currentChapterHref: string | null = null; + currentProgressData: any = null; + private currentPageInfo: PageInfo | undefined; + private relocateTimeout: any; + private sectionFractionsTimeout: any; + + sectionFractions: number[] = []; + + ngOnInit() { + this.visibilityManager = new ReaderHeaderFooterVisibilityManager(window.innerHeight); + this.visibilityManager.onStateChange((state) => { + this.forceHeaderVisible = state.headerVisible; + this.forceNavbarVisible = state.footerVisible; + }); + + this.isLoading = true; + this.initializeFoliate().pipe( + switchMap(() => this.epubCustomFontService.loadAndCacheFonts()), + tap(() => this.stateService.refreshCustomFonts()), + switchMap(() => this.setupView()), + switchMap(() => this.loadBookFromAPI()), + tap(() => { + this.loadBookmarks(); + this.subscribeToStateChanges(); + this.subscribeToViewEvents(); + this.isLoading = false; + }), + catchError(err => { + this.isLoading = false; + return of(null); + }), + takeUntil(this.destroy$) + ).subscribe(); + } + + ngOnDestroy(): void { + this.destroy$.next(); + this.destroy$.complete(); + this.viewManager.destroy(); + this.bookmarkService.reset(); + this.epubCustomFontService.cleanup(); + + if (this.readingSessionService.isSessionActive()) { + const progress = typeof this.currentProgressData?.fraction === 'number' + ? Math.round(this.currentProgressData.fraction * 100 * 100) / 100 + : undefined; + this.readingSessionService.endSession(this.currentCfi || undefined, progress); + } + + if (this._fileUrl) { + URL.revokeObjectURL(this._fileUrl); + this._fileUrl = null; + } + } + + private initializeFoliate(): Observable { + return this.loaderService.loadFoliateScript().pipe( + switchMap(() => this.loaderService.waitForCustomElement()) + ); + } + + private setupView(): Observable { + const container = document.getElementById('foliate-container'); + if (!container) { + return throwError(() => new Error('Container not found')); + } + container.setAttribute('tabindex', '0'); + this.viewManager.createView(container); + return of(undefined); + } + + private loadBookFromAPI(): Observable { + this.bookId = +this.route.snapshot.paramMap.get('bookId')!; + + return this.stateService.initializeState(this.bookId).pipe( + switchMap(() => forkJoin({ + state: this.stateService.initializeState(this.bookId), + book: this.bookService.getBookByIdFromAPI(this.bookId, false), + fileBlob: this.bookService.getFileContent(this.bookId) + })), + switchMap(({book, fileBlob}) => { + this.book = book; + this.coverUpdatedOn = book.metadata?.coverUpdatedOn; + const fileUrl = URL.createObjectURL(fileBlob); + this._fileUrl = fileUrl; + + return this.viewManager.loadEpub(fileUrl).pipe( + tap(() => { + this.applyStyles(); + this.chapters = this.viewManager.getChapters(); + }), + switchMap(() => this.viewManager.getMetadata()), + switchMap(() => { + if (!this.hasLoadedOnce) { + this.hasLoadedOnce = true; + return this.viewManager.goTo(book.epubProgress!.cfi); + } + return of(undefined); + }) + ); + }) + ); + } + + private loadBookmarks(): void { + this.bookMarkService.getBookmarksForBook(this.bookId) + .pipe(takeUntil(this.destroy$)) + .subscribe(bookmarks => { + this.bookmarks = bookmarks; + this.updateIsCurrentCfiBookmarked(); + }); + } + + private subscribeToStateChanges(): void { + this.stateService.state$ + .pipe(takeUntil(this.destroy$)) + .subscribe(() => this.applyStyles()); + } + + private subscribeToViewEvents(): void { + this.viewManager.events$ + .pipe(takeUntil(this.destroy$)) + .subscribe(event => { + switch (event.type) { + case 'load': + this.applyStyles(); + this.chapters = this.viewManager.getChapters(); + this.updateSectionFractions(); + break; + case 'relocate': + if (this.relocateTimeout) clearTimeout(this.relocateTimeout); + this.relocateTimeout = setTimeout(() => { + this.handleRelocateEvent(event.detail); + }, 100); + + if (this.sectionFractionsTimeout) clearTimeout(this.sectionFractionsTimeout); + this.sectionFractionsTimeout = setTimeout(() => { + this.updateSectionFractions(); + }, 500); + break; + case 'middle-single-tap': + this.toggleHeaderNavbarPinned(); + break; + } + }); + } + + private updateSectionFractions(): void { + this.sectionFractions = this.viewManager.getSectionFractions(); + } + + private handleRelocateEvent(detail: any): void { + this.currentProgressData = detail; + + const cfi = detail?.cfi ?? null; + const href = detail?.pageItem?.href ?? detail?.tocItem?.href ?? null; + const percentage = typeof detail?.fraction === 'number' ? detail.fraction * 100 : null; + + if (!this.hasStartedSession && cfi && percentage !== null) { + this.hasStartedSession = true; + this.readingSessionService.startSession(this.book!.id, this.book?.bookType!, cfi, percentage); + } + + if (cfi && percentage !== null) { + this.bookPatchService.saveEpubProgress(this.bookId, cfi, href, percentage); + this.readingSessionService.updateProgress(cfi, percentage); + } + + const chapterLabel = detail?.tocItem?.label; + if (chapterLabel && chapterLabel !== this.currentChapterName) { + this.currentChapterName = chapterLabel; + } + + if (href && href !== this.currentChapterHref) { + this.currentChapterHref = href; + } + + if (detail?.section) { + const percentCompleted = Math.round((detail.fraction * 100) * 10) / 10; + const totalMinutes = detail.time?.section ?? 0; + + const hours = Math.floor(totalMinutes / 60); + const minutes = Math.floor(totalMinutes % 60); + const seconds = Math.round((totalMinutes - Math.floor(totalMinutes)) * 60); + + const parts: string[] = []; + if (hours) parts.push(`${hours}h`); + if (minutes) parts.push(`${minutes}m`); + if (seconds || parts.length === 0) parts.push(`${seconds}s`); + + const sectionTimeText = parts.join(' '); + + this.currentPageInfo = { + percentCompleted, + sectionTimeText + }; + } + + if (this.stateService.currentState.flow === 'paginated') { + const renderer = this.viewManager.getRenderer(); + const theme: ThemeInfo = { + fg: this.stateService.currentState.theme.fg || this.stateService.currentState.theme.light.fg, + bg: this.stateService.currentState.theme.bg || this.stateService.currentState.theme.light.bg + }; + + if (renderer && renderer.heads && renderer.feet) { + this.viewManager.updateHeadersAndFooters(this.currentChapterName || '', this.currentPageInfo, theme); + } else { + this.viewManager.updateHeadersAndFooters(this.currentChapterName || '', this.currentPageInfo, theme); + } + } + + if (cfi) { + this.currentCfi = cfi; + this.updateIsCurrentCfiBookmarked(); + this.bookmarkService.updateCurrentPosition(cfi, chapterLabel); + } + } + + private updateIsCurrentCfiBookmarked(): void { + if (!this.currentCfi || !this.bookmarks?.length) { + this.isCurrentCfiBookmarked = false; + return; + } + this.isCurrentCfiBookmarked = this.bookmarks.some(b => b.cfi === this.currentCfi); + } + + private applyStyles(): void { + const renderer = this.viewManager.getRenderer(); + if (renderer) { + this.styleService.applyStylesToRenderer(renderer, this.stateService.currentState); + if (this.stateService.currentState.flow) { + renderer.setAttribute?.('flow', this.stateService.currentState.flow); + } + } + } + + private syncSettingsToBackend(): void { + const setting: EbookViewerSetting = { + lineHeight: this.stateService.currentState.lineHeight, + justify: this.stateService.currentState.justify, + hyphenate: this.stateService.currentState.hyphenate, + maxColumnCount: this.stateService.currentState.maxColumnCount, + gap: this.stateService.currentState.gap, + fontSize: this.stateService.currentState.fontSize, + theme: typeof this.stateService.currentState.theme === 'object' && 'name' in this.stateService.currentState.theme + ? this.stateService.currentState.theme.name + : (this.stateService.currentState.theme as any), + maxInlineSize: this.stateService.currentState.maxInlineSize, + maxBlockSize: this.stateService.currentState.maxBlockSize, + fontFamily: this.stateService.currentState.fontFamily, + isDark: this.stateService.currentState.isDark, + flow: this.stateService.currentState.flow, + }; + this.bookService.updateViewerSetting({ebookSettings: setting}, this.bookId) + .pipe(takeUntil(this.destroy$)) + .subscribe(); + } + + onChapterClick(href: string): void { + this.viewManager.goTo(href).pipe( + tap(() => this.showChapters = false), + takeUntil(this.destroy$) + ).subscribe(); + } + + onBookmarkClick(cfi: string): void { + this.viewManager.goTo(cfi).pipe( + tap(() => { + this.showChapters = false; + this.currentCfi = cfi; + this.updateIsCurrentCfiBookmarked(); + }), + takeUntil(this.destroy$) + ).subscribe(); + } + + onCreateBookmark(): void { + this.bookmarkService.createBookmarkAtCurrentPosition(this.bookId) + .pipe(takeUntil(this.destroy$)) + .subscribe(success => { + if (success) { + this.loadBookmarks(); + } + }); + } + + onDeleteBookmark(bookmarkId: number): void { + this.bookMarkService.deleteBookmark(bookmarkId) + .pipe(takeUntil(this.destroy$)) + .subscribe({ + next: () => this.loadBookmarks(), + error: () => { + } + }); + } + + onProgressChange(fraction: number): void { + this.viewManager.goToFraction(fraction) + .pipe(takeUntil(this.destroy$)) + .subscribe(); + } + + onToggleDarkMode(): void { + this.stateService.toggleDarkMode(); + this.syncSettingsToBackend(); + } + + onIncreaseFontSize(): void { + this.stateService.updateFontSize(1); + this.syncSettingsToBackend(); + } + + onDecreaseFontSize(): void { + this.stateService.updateFontSize(-1); + this.syncSettingsToBackend(); + } + + onIncreaseLineHeight(): void { + this.stateService.updateLineHeight(0.1); + this.syncSettingsToBackend(); + } + + onDecreaseLineHeight(): void { + this.stateService.updateLineHeight(-0.1); + this.syncSettingsToBackend(); + } + + onSetFlow(flow: 'paginated' | 'scrolled'): void { + this.stateService.setFlow(flow); + this.syncSettingsToBackend(); + + if (flow === 'paginated' && this.currentChapterName) { + setTimeout(() => { + const renderer = this.viewManager.getRenderer(); + if (renderer && renderer.heads && renderer.feet) { + const theme: ThemeInfo = { + fg: this.stateService.currentState.theme.fg || this.stateService.currentState.theme.light.fg, + bg: this.stateService.currentState.theme.bg || this.stateService.currentState.theme.light.bg + }; + this.viewManager.updateHeadersAndFooters(this.currentChapterName || '', this.currentPageInfo, theme); + } + }, 50); + } + } + + private toggleHeaderNavbarPinned(): void { + this.visibilityManager.togglePinned(); + } + + @HostListener('document:mousemove', ['$event']) + onMouseMove(event: MouseEvent): void { + this.visibilityManager.handleMouseMove(event.clientY); + } + + @HostListener('document:mouseleave', ['$event']) + onMouseLeave(event: MouseEvent): void { + this.visibilityManager.handleMouseLeave(); + } + + @HostListener('window:resize', ['$event']) + onWindowResize(event: Event): void { + this.visibilityManager.updateWindowHeight(window.innerHeight); + } +} diff --git a/booklore-ui/src/app/features/readers/ebook-reader/reader-layout/header/reader-header.component.html b/booklore-ui/src/app/features/readers/ebook-reader/reader-layout/header/reader-header.component.html new file mode 100644 index 000000000..a0ea13c81 --- /dev/null +++ b/booklore-ui/src/app/features/readers/ebook-reader/reader-layout/header/reader-header.component.html @@ -0,0 +1,19 @@ +
+ + + {{ bookTitle || '' }} + + +
diff --git a/booklore-ui/src/app/features/readers/ebook-reader/reader-layout/header/reader-header.component.scss b/booklore-ui/src/app/features/readers/ebook-reader/reader-layout/header/reader-header.component.scss new file mode 100644 index 000000000..38470d3d5 --- /dev/null +++ b/booklore-ui/src/app/features/readers/ebook-reader/reader-layout/header/reader-header.component.scss @@ -0,0 +1,77 @@ +.reader-header { + position: absolute; + top: 0; + left: 0; + right: 0; + height: 36px; + padding: 0 10px; + display: flex; + align-items: center; + justify-content: space-between; + font-size: 14px; + z-index: 1000; + background: inherit; + border-bottom: 1px solid rgba(0,0,0,0.13); + box-shadow: 0 2px 8px 0 rgba(0,0,0,0.13); + opacity: 0; + pointer-events: none; + transform: translateY(-100%); + transition: opacity 0.25s ease-out, transform 0.25s ease-out; + + &.visible { + opacity: 1; + pointer-events: auto; + transform: translateY(0); + } + + .icon-btn { + background: none; + border: none; + color: inherit; + cursor: pointer; + padding: 6px; + border-radius: 50%; + transition: background 0.15s, box-shadow 0.15s; + display: flex; + align-items: center; + justify-content: center; + margin-right: 8px; + + &:hover { + background: rgba(255, 255, 255, 0.14); + box-shadow: 0 2px 8px 0 rgba(0,0,0,0.10); + } + + &:last-of-type { + margin-right: 0; + } + + .pi { + font-size: 16px; + } + } + + .chapter-title { + flex: 1; + text-align: center; + font-size: 14px; + font-weight: 500; + color: inherit; + pointer-events: none; + user-select: none; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + min-height: 1.2em; + } +} + +@media (max-width: 600px) { + .reader-header { + padding: 0 6px; + + .icon-btn { + padding: 10px; + } + } +} diff --git a/booklore-ui/src/app/features/readers/ebook-reader/reader-layout/header/reader-header.component.ts b/booklore-ui/src/app/features/readers/ebook-reader/reader-layout/header/reader-header.component.ts new file mode 100644 index 000000000..5edbfb418 --- /dev/null +++ b/booklore-ui/src/app/features/readers/ebook-reader/reader-layout/header/reader-header.component.ts @@ -0,0 +1,32 @@ +import {Component, EventEmitter, Input, Output} from '@angular/core'; + +@Component({ + selector: 'app-reader-header', + standalone: true, + templateUrl: './reader-header.component.html', + styleUrls: ['./reader-header.component.scss'] +}) +export class ReaderHeaderComponent { + @Input() bookTitle: string | null = null; + @Input() currentTheme: any = null; + @Input() isDark = false; + @Input() fontSize = 16; + @Input() lineHeight = 1.5; + @Input() forceVisible = false; + @Input() flow: 'paginated' | 'scrolled' = 'paginated'; + @Output() showChapters = new EventEmitter(); + @Output() showControls = new EventEmitter(); + @Output() showMetadata = new EventEmitter(); + @Output() createBookmark = new EventEmitter(); + @Output() close = new EventEmitter(); + @Output() toggleDarkMode = new EventEmitter(); + @Output() increaseFontSize = new EventEmitter(); + @Output() decreaseFontSize = new EventEmitter(); + @Output() increaseLineHeight = new EventEmitter(); + @Output() decreaseLineHeight = new EventEmitter(); + @Output() setFlow = new EventEmitter<'paginated' | 'scrolled'>(); + + get headerVisible(): boolean { + return this.forceVisible; + } +} diff --git a/booklore-ui/src/app/features/readers/ebook-reader/reader-layout/header/reader-settings-dialog.component.html b/booklore-ui/src/app/features/readers/ebook-reader/reader-layout/header/reader-settings-dialog.component.html new file mode 100644 index 000000000..fe75ab4b0 --- /dev/null +++ b/booklore-ui/src/app/features/readers/ebook-reader/reader-layout/header/reader-settings-dialog.component.html @@ -0,0 +1,205 @@ +
+
+
+
+ + + +
+ +
+ +
+ @if (activeTab === 'theme') { +
+
+ Dark Mode + + ☀️ + + 🌙 + +
+ +
Theme Colors
+
+ @for (theme of themes; track theme.name) { + + } +
+
+ } + + @if (activeTab === 'typography') { +
+ +
Font Settings
+
+ +
+
+ + {{ state.fontSize }} + +
+
+
+
+ +
+
+ + {{ state.lineHeight | number:'1.1-1' }} + +
+
+
+ +
Font Family
+
+ @for (font of fonts; track font.value) { + + } +
+
+ } + + @if (activeTab === 'layout') { +
+
Layout
+ +
+ +
+
+ + +
+
+
+ +
+ +
+
+ + {{ state.maxColumnCount }} + +
+
+
+ +
+ +
+ + {{ (state.gap * 100) | number : '1.0-0' }}% +
+
+ +
+ +
+
+ + {{ state.maxInlineSize }} + +
+
+
+ +
+ +
+
+ + {{ state.maxBlockSize }} + +
+
+
+ +
Text Options
+ +
+ Justify Text + +
+ +
+ Hyphenate + +
+
+ } +
+
+
diff --git a/booklore-ui/src/app/features/readers/ebook-reader/reader-layout/header/reader-settings-dialog.component.scss b/booklore-ui/src/app/features/readers/ebook-reader/reader-layout/header/reader-settings-dialog.component.scss new file mode 100644 index 000000000..de1ce1bda --- /dev/null +++ b/booklore-ui/src/app/features/readers/ebook-reader/reader-layout/header/reader-settings-dialog.component.scss @@ -0,0 +1,672 @@ +.dialog-overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.2); + z-index: 12000; + display: flex; + align-items: center; + justify-content: center; + animation: fadeIn 0.2s ease-out; +} + +@keyframes fadeIn { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +.dialog { + background: rgba(30, 30, 30, 0.95); + backdrop-filter: blur(20px); + -webkit-backdrop-filter: blur(20px); + border-radius: 12px; + width: 90%; + max-width: 520px; + height: 600px; + max-height: 85vh; + overflow: hidden; + box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5); + animation: slideUp 0.3s ease-out; + display: flex; + flex-direction: column; +} + +@keyframes slideUp { + from { + transform: translateY(20px); + opacity: 0; + } + to { + transform: translateY(0); + opacity: 1; + } +} + +.dialog-header { + padding: 16px 20px; + border-bottom: 1px solid rgba(255, 255, 255, 0.08); + display: flex; + align-items: center; + justify-content: space-between; + background: linear-gradient(180deg, rgba(255, 255, 255, 0.02) 0%, transparent 100%); + + h2 { + margin: 0; + font-size: 18px; + font-weight: 600; + color: #ffffff; + letter-spacing: -0.3px; + } +} + +.tabs-row { + display: flex; + align-items: center; + border-bottom: 1px solid rgba(255, 255, 255, 0.08); + background: rgba(255, 255, 255, 0.02); + padding: 0 20px; + margin-bottom: 18px; + justify-content: center; + position: relative; +} + +.tabs { + display: flex; + align-items: center; + justify-content: center; + flex: 0 1 auto; +} + +.tabs-row-spacer { + display: none; +} + +.tab { + background: none; + border: none; + color: rgba(255, 255, 255, 0.6); + padding: 12px 18px; + cursor: pointer; + font-size: 14px; + font-weight: 500; + position: relative; + transition: all 0.2s ease; + text-align: center; + min-width: 80px; + display: flex; + align-items: center; + justify-content: center; + + &:hover { + color: rgba(255, 255, 255, 0.8); + } + + &.active { + color: #ffffff; + + &::after { + content: ''; + position: absolute; + left: 50%; + bottom: 0; + transform: translateX(-50%); + width: 60%; + height: 2px; + background: #4a9eff; + border-radius: 1px; + } + } +} + +.close-btn { + position: absolute; + right: 20px; + top: 50%; + transform: translateY(-50%); + margin-left: 0; + background: none; + border: none; + color: rgba(255, 255, 255, 0.6); + font-size: 20px; + cursor: pointer; + border-radius: 6px; + transition: all 0.2s ease; + line-height: 1; + + &:hover { + background: rgba(255, 255, 255, 0.08); + color: rgba(255, 255, 255, 0.9); + } +} + +.dialog-body { + padding: 20px 25px; + overflow-y: auto; + flex: 1 1 0; + min-height: 0; + max-height: 100%; + + &::-webkit-scrollbar { + width: 8px; + } + + &::-webkit-scrollbar-track { + background: transparent; + } + + &::-webkit-scrollbar-thumb { + background: rgba(255, 255, 255, 0.15); + border-radius: 4px; + + &:hover { + background: rgba(255, 255, 255, 0.25); + } + } + + section { + margin-bottom: 20px; + padding-bottom: 16px; + border-bottom: 1px solid rgba(255, 255, 255, 0.06); + + &:last-child { + margin-bottom: 0; + border-bottom: none; + padding-bottom: 0; + } + + h3 { + margin: 0 0 10px 0; + font-size: 12px; + font-weight: 600; + color: rgba(255, 255, 255, 0.5); + text-transform: uppercase; + letter-spacing: 0.8px; + } + } +} + +.tab-content { + animation: fadeIn 0.2s ease-out; +} + +.mode-toggle, +.toggle-control { + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px 0; + margin-bottom: 2px; + + span { + color: rgba(255, 255, 255, 0.9); + font-size: 14px; + font-weight: 500; + } +} + +.mode-switch-wrapper { + display: flex; + align-items: center; + gap: 10px; +} + +.mode-icon { + font-size: 18px; + opacity: 0.3; + transition: all 0.3s ease; + user-select: none; + + &.active { + opacity: 1; + transform: scale(1.1); + } + + &.sun { + filter: grayscale(100%); + + &.active { + filter: grayscale(0%); + } + } + + &.moon { + filter: grayscale(100%); + + &.active { + filter: grayscale(0%); + } + } +} + +.switch { + position: relative; + width: 44px; + height: 24px; + background: rgba(255, 255, 255, 0.15); + border: none; + border-radius: 12px; + cursor: pointer; + transition: background 0.3s ease; + padding: 0; + + &.active { + background: #4a9eff; + + .slider { + transform: translateX(20px); + } + } + + .slider { + position: absolute; + top: 2px; + left: 2px; + width: 20px; + height: 20px; + background: #ffffff; + border-radius: 50%; + transition: transform 0.3s ease; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2); + } + + &:hover { + background: rgba(255, 255, 255, 0.2); + + &.active { + background: #5ba9ff; + } + } +} + +.theme-grid { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 14px; + margin-bottom: 16px; +} + +@media (max-width: 768px) { + .theme-grid { + grid-template-columns: repeat(3, 1fr); + } +} + +.theme-option { + cursor: pointer; + + input[type="radio"] { + display: none; + } + + .theme-card { + background: rgba(255, 255, 255, 0.05); + border: 2px solid rgba(255, 255, 255, 0.1); + border-radius: 8px; + padding: 10px; + transition: all 0.2s ease; + display: flex; + flex-direction: column; + gap: 8px; + + &:hover { + background: rgba(255, 255, 255, 0.08); + border-color: rgba(255, 255, 255, 0.2); + } + } + + input:checked + .theme-card { + border-color: #4a9eff; + background: rgba(74, 158, 255, 0.1); + } + + .theme-colors { + display: flex; + gap: 4px; + height: 24px; + } + + .color-swatch { + flex: 1; + border-radius: 3px; + border: 1px solid rgba(0, 0, 0, 0.2); + } + + .theme-name { + color: rgba(255, 255, 255, 0.9); + font-size: 12px; + font-weight: 500; + text-align: center; + } +} + +.font-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 14px; + margin-bottom: 20px; +} + +.font-option { + background: rgba(255, 255, 255, 0.05); + border: 2px solid rgba(255, 255, 255, 0.1); + border-radius: 8px; + padding: 10px; + cursor: pointer; + transition: all 0.2s ease; + display: flex; + flex-direction: column; + align-items: center; + gap: 8px; + + &:hover { + background: rgba(255, 255, 255, 0.08); + border-color: rgba(255, 255, 255, 0.2); + } + + &.active { + border-color: #4a9eff; + background: rgba(74, 158, 255, 0.1); + } + + .font-preview { + font-size: 24px; + color: #ffffff; + line-height: 1; + } + + .font-name { + color: rgba(255, 255, 255, 0.7); + font-size: 12px; + font-weight: 500; + text-align: center; + } +} + +.control { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 16px; + min-height: 32px; + + &:last-child { + margin-bottom: 0; + } + + > label { + color: rgba(255, 255, 255, 0.9); + font-size: 14px; + font-weight: 500; + flex-shrink: 0; + } + + &.checkbox-control { + label { + display: flex; + align-items: center; + gap: 8px; + cursor: pointer; + width: 100%; + padding: 4px 0; + transition: color 0.2s ease; + + &:hover { + color: #ffffff; + } + + input[type="checkbox"] { + width: 16px; + height: 16px; + cursor: pointer; + accent-color: #4a9eff; + } + + span { + flex: 1; + } + } + } + + input[type="range"] { + flex: 1; + margin: 0 10px; + height: 5px; + background: rgba(255, 255, 255, 0.12); + border-radius: 3px; + outline: none; + cursor: pointer; + + &::-webkit-slider-thumb { + -webkit-appearance: none; + width: 14px; + height: 14px; + border-radius: 50%; + background: #4a9eff; + cursor: pointer; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3); + transition: all 0.2s ease; + + &:hover { + transform: scale(1.1); + box-shadow: 0 2px 8px rgba(74, 158, 255, 0.5); + } + } + + &::-moz-range-thumb { + width: 14px; + height: 14px; + border-radius: 50%; + background: #4a9eff; + border: none; + cursor: pointer; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3); + transition: all 0.2s ease; + + &:hover { + transform: scale(1.1); + box-shadow: 0 2px 8px rgba(74, 158, 255, 0.5); + } + } + } + + .value { + color: rgba(255, 255, 255, 0.6); + font-size: 12px; + min-width: 42px; + text-align: right; + font-variant-numeric: tabular-nums; + } + + select { + background: rgba(255, 255, 255, 0.08); + color: #ffffff; + border: 1px solid rgba(255, 255, 255, 0.15); + border-radius: 6px; + padding: 6px 12px; + font-size: 13px; + cursor: pointer; + transition: all 0.2s ease; + min-width: 130px; + + &:hover { + background: rgba(255, 255, 255, 0.12); + border-color: rgba(255, 255, 255, 0.25); + } + + &:focus { + outline: none; + border-color: #4a9eff; + box-shadow: 0 0 0 3px rgba(74, 158, 255, 0.1); + } + + option { + background: #1e1e1e; + color: #ffffff; + } + } +} + +.control-right { + display: flex; + align-items: center; + gap: 8px; +} + +.gap-control { + flex: 1; + max-width: 200px; + min-width: 160px; + + input[type="range"] { + flex: 1; + margin-right: 8px; + } + + .value { + min-width: 38px; + } +} + +.stepper { + display: flex; + align-items: center; + gap: 4px; + background: rgba(255, 255, 255, 0.05); + padding: 3px; + border-radius: 6px; + border: 1px solid rgba(255, 255, 255, 0.1); + + button { + width: 28px; + height: 28px; + background: rgba(255, 255, 255, 0.08); + color: rgba(255, 255, 255, 0.9); + border: none; + border-radius: 4px; + cursor: pointer; + font-size: 16px; + display: flex; + align-items: center; + justify-content: center; + transition: all 0.2s ease; + font-weight: 300; + + &:hover { + background: rgba(255, 255, 255, 0.15); + color: #ffffff; + } + + &:active { + transform: scale(0.95); + } + } + + span { + min-width: 48px; + text-align: center; + color: #ffffff; + font-size: 13px; + font-weight: 500; + font-variant-numeric: tabular-nums; + } +} + +.toggle { + width: 36px; + height: 36px; + padding: 0; + background: rgba(255, 255, 255, 0.08); + color: rgba(255, 255, 255, 0.7); + border: 1px solid rgba(255, 255, 255, 0.15); + border-radius: 6px; + cursor: pointer; + font-size: 16px; + display: flex; + align-items: center; + justify-content: center; + transition: all 0.2s ease; + + .icon { + display: block; + line-height: 1; + } + + &:hover { + background: rgba(255, 255, 255, 0.12); + border-color: rgba(255, 255, 255, 0.25); + transform: translateY(-1px); + } + + &:active { + transform: translateY(0); + } +} + +.section-header { + color: rgba(255, 255, 255, 0.5); + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 1px; + margin-top: 24px; + margin-bottom: 12px; + padding-bottom: 8px; + border-bottom: 1px solid rgba(255, 255, 255, 0.08); + + &:first-child { + margin-top: 0; + } + + &.dark-mode-header { + display: flex; + align-items: center; + font-size: 11px; + font-weight: 600; + gap: 30px; + padding-bottom: 8px; + margin-bottom: 30px; + border-bottom: 1px solid rgba(255, 255, 255, 0.08); + } +} + +.radio-group { + display: flex; + gap: 18px; + + label { + display: flex; + align-items: center; + gap: 7px; + cursor: pointer; + font-size: 14px; + color: rgba(255, 255, 255, 0.85); + font-weight: 500; + position: relative; + user-select: none; + + input[type="radio"] { + appearance: none; + width: 18px; + height: 18px; + border: 2px solid #4a9eff; + border-radius: 50%; + background: rgba(255, 255, 255, 0.08); + margin: 0 6px 0 0; + outline: none; + transition: border-color 0.2s, box-shadow 0.2s; + + &:checked { + background: #4a9eff; + box-shadow: 0 0 0 2px rgba(74, 158, 255, 0.15); + border-color: #4a9eff; + } + + &:focus { + box-shadow: 0 0 0 2px #4a9eff; + } + } + } +} diff --git a/booklore-ui/src/app/features/readers/ebook-reader/reader-layout/header/reader-settings-dialog.component.ts b/booklore-ui/src/app/features/readers/ebook-reader/reader-layout/header/reader-settings-dialog.component.ts new file mode 100644 index 000000000..659a94c25 --- /dev/null +++ b/booklore-ui/src/app/features/readers/ebook-reader/reader-layout/header/reader-settings-dialog.component.ts @@ -0,0 +1,157 @@ +import {Component, EventEmitter, Inject, Input, OnInit, Output, Renderer2} from '@angular/core'; +import {DecimalPipe, DOCUMENT} from '@angular/common'; +import {ReaderStateService} from '../../services/reader-state.service'; +import {ReaderViewManagerService} from '../../services/reader-view-manager.service'; +import {BookService} from '../../../../book/service/book.service'; +import {EpubCustomFontService} from '../../../epub-reader/service/epub-custom-font.service'; +import {EbookViewerSetting} from '../../../../book/model/book.model'; + +@Component({ + selector: 'app-settings-dialog', + standalone: true, + imports: [DecimalPipe], + templateUrl: './reader-settings-dialog.component.html', + styleUrls: ['./reader-settings-dialog.component.scss'] +}) +export class ReaderSettingsDialogComponent implements OnInit { + @Input() stateService!: ReaderStateService; + @Input() viewManager!: ReaderViewManagerService; + @Input() bookId!: number; + + @Output() close = new EventEmitter(); + + activeTab: 'theme' | 'typography' | 'layout' = 'theme'; + + constructor( + private bookService: BookService, + private customFontService: EpubCustomFontService, + private renderer: Renderer2, + @Inject(DOCUMENT) private document: Document + ) { + } + + ngOnInit() { + this.customFontService.injectCustomFontsStylesheet(this.renderer, this.document); + } + + getFontFamilyForPreview(fontValue: string): string { + return this.customFontService.getFontFamilyForPreview(fontValue); + } + + get state() { + return this.stateService.currentState; + } + + get themes() { + return this.stateService.themes; + } + + get fonts() { + return this.stateService.fonts; + } + + private syncSettingsToBackend() { + const setting: EbookViewerSetting = { + lineHeight: this.state.lineHeight, + justify: this.state.justify, + hyphenate: this.state.hyphenate, + maxColumnCount: this.state.maxColumnCount, + gap: this.state.gap, + fontSize: this.state.fontSize, + theme: typeof this.state.theme === 'object' && 'name' in this.state.theme ? this.state.theme.name : (this.state.theme as any), + maxInlineSize: this.state.maxInlineSize, + maxBlockSize: this.state.maxBlockSize, + fontFamily: this.state.fontFamily, + isDark: this.state.isDark, + flow: this.state.flow, + }; + this.bookService.updateViewerSetting({ebookSettings: setting}, this.bookId).subscribe(); + } + + setFontFamily(value: string | null) { + this.stateService.setFontFamily(value); + this.syncSettingsToBackend(); + } + + increaseFontSize() { + this.stateService.updateFontSize(1); + this.syncSettingsToBackend(); + } + + decreaseFontSize() { + this.stateService.updateFontSize(-1); + this.syncSettingsToBackend(); + } + + increaseLineHeight() { + this.stateService.updateLineHeight(0.1); + this.syncSettingsToBackend(); + } + + decreaseLineHeight() { + this.stateService.updateLineHeight(-0.1); + this.syncSettingsToBackend(); + } + + increaseMaxColumnCount() { + this.stateService.updateMaxColumnCount(1); + this.syncSettingsToBackend(); + } + + decreaseMaxColumnCount() { + this.stateService.updateMaxColumnCount(-1); + this.syncSettingsToBackend(); + } + + setGap(value: number) { + const delta = value - this.state.gap; + this.stateService.updateGap(delta); + this.syncSettingsToBackend(); + } + + toggleJustify() { + this.stateService.toggleJustify(); + this.syncSettingsToBackend(); + } + + toggleHyphenate() { + this.stateService.toggleHyphenate(); + this.syncSettingsToBackend(); + } + + increaseMaxInlineSize() { + this.stateService.updateMaxInlineSize(40); + this.syncSettingsToBackend(); + } + + decreaseMaxInlineSize() { + this.stateService.updateMaxInlineSize(-40); + this.syncSettingsToBackend(); + } + + increaseMaxBlockSize() { + this.stateService.updateMaxBlockSize(60); + this.syncSettingsToBackend(); + } + + decreaseMaxBlockSize() { + this.stateService.updateMaxBlockSize(-60); + this.syncSettingsToBackend(); + } + + toggleDarkMode() { + this.stateService.toggleDarkMode(); + this.syncSettingsToBackend(); + } + + onThemeChange(themeName: string) { + this.stateService.setThemeByName(themeName); + this.syncSettingsToBackend(); + } + + setFlow(flow: 'paginated' | 'scrolled') { + this.stateService.setFlow(flow); + this.viewManager.getRenderer()?.setAttribute?.('flow', flow); + this.syncSettingsToBackend(); + } +} diff --git a/booklore-ui/src/app/features/readers/ebook-reader/reader-layout/navbar/reader-navbar.component.html b/booklore-ui/src/app/features/readers/ebook-reader/reader-layout/navbar/reader-navbar.component.html new file mode 100644 index 000000000..5d98e23a4 --- /dev/null +++ b/booklore-ui/src/app/features/readers/ebook-reader/reader-layout/navbar/reader-navbar.component.html @@ -0,0 +1,92 @@ +
+ + + +
+ + +
+ +
+ @for (frac of sectionFractions; track frac) { + + } +
+
+
+ + +
+ +@if (showLocationPopover) { +
+
+
+
+ Time Left in Section + {{ timeSection }} +
+
+
+ Time Left in Book + {{ timeTotal }} +
+
+ +
+ +
+
+ + {{ currentPage }} +
+
+ + + / {{ locationTotal }} +
+
+ + {{ sectionCurrent }} / {{ sectionTotal }} +
+
+ +
+ +
+ + + + + +
+
+
+} diff --git a/booklore-ui/src/app/features/readers/ebook-reader/reader-layout/navbar/reader-navbar.component.scss b/booklore-ui/src/app/features/readers/ebook-reader/reader-layout/navbar/reader-navbar.component.scss new file mode 100644 index 000000000..c58257c67 --- /dev/null +++ b/booklore-ui/src/app/features/readers/ebook-reader/reader-layout/navbar/reader-navbar.component.scss @@ -0,0 +1,301 @@ +.reader-navbar { + position: absolute; + bottom: 0; + left: 0; + right: 0; + height: 40px; + padding: 0 12px; + display: flex; + align-items: center; + gap: 8px; + z-index: 1000; + background: rgba(30, 30, 30, 0.95); + backdrop-filter: blur(20px); + -webkit-backdrop-filter: blur(20px); + border-top: 1px solid rgba(255, 255, 255, 0.08); + box-shadow: 0 -2px 8px 0 rgba(0, 0, 0, 0.3); + opacity: 0; + pointer-events: none; + transform: translateY(100%); + transition: opacity 0.25s ease-out, transform 0.25s ease-out; + color: white; + + &.visible { + opacity: 1; + pointer-events: auto; + transform: translateY(0); + } + + .icon-btn { + background: none; + border: none; + color: rgba(255, 255, 255, 0.9); + font-size: 18px; + cursor: pointer; + padding: 0; + border-radius: 50%; + transition: background 0.15s, box-shadow 0.15s, color 0.15s; + width: 36px; + height: 36px; + aspect-ratio: 1 / 1; + display: flex; + align-items: center; + justify-content: center; + + &:hover:not(:disabled) { + background: rgba(255, 255, 255, 0.08); + box-shadow: 0 2px 8px 0 rgba(0, 0, 0, 0.10); + color: #ffffff; + } + + &:disabled { + opacity: 0.4; + cursor: not-allowed; + } + } + + .progress-section { + flex: 1; + display: flex; + align-items: center; + gap: 12px; + + .location-btn { + background: rgba(255, 255, 255, 0.05); + border: 1px solid rgba(255, 255, 255, 0.1); + color: rgba(255, 255, 255, 0.9); + padding: 4px 12px; + border-radius: 4px; + cursor: pointer; + font-size: 13px; + font-weight: 500; + transition: background 0.15s, color 0.15s; + + &:hover { + background: rgba(255, 255, 255, 0.1); + color: #ffffff; + } + } + + .progress-slider-wrapper { + position: relative; + flex: 1; + display: flex; + align-items: center; + + .progress-slider { + flex: 1; + height: 6px; + border-radius: 3px; + outline: none; + -webkit-appearance: none; + background: rgba(255, 255, 255, 0.15); + position: relative; + z-index: 1; + + &::-webkit-slider-thumb { + -webkit-appearance: none; + width: 16px; + height: 16px; + border-radius: 50%; + background: #4a9eff; + cursor: pointer; + } + + &::-moz-range-thumb { + width: 16px; + height: 16px; + border-radius: 50%; + background: #4a9eff; + cursor: pointer; + border: none; + } + } + + .progress-ticks { + position: absolute; + left: 0; + top: 50%; + width: 100%; + height: 10px; + transform: translateY(-50%); + pointer-events: none; + z-index: 0; + } + + .progress-tick { + position: absolute; + top: 0; + width: 1.5px; + height: 10px; + background: rgba(255, 255, 255, 0.5); + border-radius: 2px; + } + } + } +} + +.location-popover { + position: absolute; + bottom: 58px; + left: 50%; + transform: translateX(-50%); + background: rgba(30, 30, 30, 0.95); + backdrop-filter: blur(20px); + -webkit-backdrop-filter: blur(20px); + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 12px; + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.5); + z-index: 1001; + min-width: 380px; + animation: popoverSlideIn 0.15s ease-out; + transform-origin: bottom center; + color: white; + + .popover-content { + padding: 20px; + } + + .time-info { + display: flex; + gap: 24px; + align-items: center; + justify-content: space-around; + padding: 8px 0; + + .time-section { + display: flex; + flex-direction: column; + gap: 6px; + text-align: center; + flex: 1; + + .label { + font-size: 11px; + color: rgba(255, 255, 255, 0.6); + text-transform: uppercase; + letter-spacing: 0.5px; + font-weight: 500; + } + + .value { + font-size: 20px; + font-weight: 600; + letter-spacing: -0.3px; + color: #ffffff; + } + } + + .separator { + width: 1px; + height: 44px; + background: rgba(255, 255, 255, 0.12); + } + } + + .divider { + height: 1px; + background: rgba(255, 255, 255, 0.08); + margin: 16px 0; + } + + .location-controls { + display: flex; + flex-direction: column; + gap: 10px; + padding: 4px 0; + + .control-row { + display: flex; + align-items: center; + gap: 16px; + + label { + min-width: 70px; + font-size: 13px; + font-weight: 500; + color: rgba(255, 255, 255, 0.85); + } + + input { + width: 65px; + padding: 6px 10px; + background: rgba(255, 255, 255, 0.06); + border: 1px solid rgba(255, 255, 255, 0.12); + border-radius: 6px; + color: #ffffff; + font-size: 13px; + text-align: right; + transition: background 0.15s, border-color 0.15s; + + &:focus { + outline: none; + background: rgba(255, 255, 255, 0.1); + border-color: rgba(255, 255, 255, 0.2); + } + } + + span { + font-size: 13px; + color: rgba(255, 255, 255, 0.85); + } + } + } + + .section-navigation { + display: flex; + gap: 8px; + justify-content: center; + align-items: center; + + .nav-btn { + background: none; + border: none; + color: rgba(255, 255, 255, 0.9); + padding: 0; + margin: 0; + border-radius: 50%; + cursor: pointer; + font-size: 16px; + transition: background 0.15s ease, box-shadow 0.15s ease, transform 0.15s ease, color 0.15s ease; + width: 40px; + height: 40px; + min-width: 40px; + min-height: 40px; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + + &:hover { + background: rgba(255, 255, 255, 0.08); + box-shadow: 0 2px 8px 0 rgba(0, 0, 0, 0.15); + transform: scale(1.05); + color: #ffffff; + } + + &:active { + background: rgba(255, 255, 255, 0.15); + transform: scale(0.95); + } + + i { + font-size: 18px; + display: flex; + align-items: center; + justify-content: center; + } + } + } +} + +@keyframes popoverSlideIn { + from { + opacity: 0; + transform: translateX(-50%) translateY(4px) scale(0.95); + } + to { + opacity: 1; + transform: translateX(-50%) translateY(0) scale(1); + } +} diff --git a/booklore-ui/src/app/features/readers/ebook-reader/reader-layout/navbar/reader-navbar.component.ts b/booklore-ui/src/app/features/readers/ebook-reader/reader-layout/navbar/reader-navbar.component.ts new file mode 100644 index 000000000..92c06c4a2 --- /dev/null +++ b/booklore-ui/src/app/features/readers/ebook-reader/reader-layout/navbar/reader-navbar.component.ts @@ -0,0 +1,144 @@ +import {Component, EventEmitter, HostListener, inject, Input, Output} from '@angular/core'; +import {ReaderViewManagerService} from '../../services/reader-view-manager.service'; + +interface TocItem { + label: string; + href: string; + subitems: any; + id: number; +} + +interface PageItem { + label: string; + href: string; + subitems: any; + id: number; +} + +interface RelocateEventDetail { + fraction: number; + section: { current: number; total: number }; + location: { current: number; next: number; total: number }; + time: { section: number; total: number }; + tocItem: TocItem; + pageItem: PageItem; + cfi: string; + range: any; +} + +@Component({ + selector: 'app-reader-navbar', + standalone: true, + templateUrl: './reader-navbar.component.html', + styleUrls: ['./reader-navbar.component.scss'] +}) +export class ReaderNavbarComponent { + @Input() progressData: RelocateEventDetail | null = null; + @Input() forceVisible = false; + @Input() sectionFractions: number[] = []; + @Output() progressChange = new EventEmitter(); + + private managerService = inject(ReaderViewManagerService); + showLocationPopover = false; + + @HostListener('document:click', ['$event']) + onDocumentClick(event: MouseEvent) { + const target = event.target as HTMLElement; + const clickedInside = target.closest('.location-popover') || target.closest('.location-btn'); + if (!clickedInside && this.showLocationPopover) { + this.showLocationPopover = false; + } + } + + @HostListener('window:blur') + onWindowBlur() { + setTimeout(() => { + if (this.showLocationPopover) { + this.showLocationPopover = false; + } + }, 200); + } + + toggleLocationPopover() { + this.showLocationPopover = !this.showLocationPopover; + } + + get currentFraction(): number { + return this.progressData?.fraction ?? 0; + } + + get currentPercentage(): number { + return Math.round(this.currentFraction * 100); + } + + get timeTotal(): string { + return this.formatDuration((this.progressData?.time.total ?? 0) * 60); + } + + get timeSection(): string { + return this.formatDuration((this.progressData?.time.section ?? 0) * 60); + } + + get locationCurrent(): number { + return this.progressData?.location.current ?? 0; + } + + get locationTotal(): number { + return this.progressData?.location.total ?? 0; + } + + get sectionCurrent(): number { + return this.progressData?.section.current ?? 0; + } + + get sectionTotal(): number { + return this.progressData?.section.total ?? 0; + } + + get currentPage(): string { + return this.progressData?.pageItem?.label ?? 'N/A'; + } + + get navbarVisible(): boolean { + return this.forceVisible; + } + + onProgressChange(event: Event) { + const target = event.target as HTMLInputElement; + const fraction = parseFloat(target.value) / 100; + this.progressChange.emit(fraction); + } + + onFirstSection() { + this.managerService.goToSection(0).subscribe(); + } + + onPreviousSection(): void { + const s = this.progressData?.section; + if (!s || s.current <= 0) return; + this.managerService.goToSection(s.current - 1).subscribe(); + } + + onNextSection(): void { + const s = this.progressData?.section; + if (!s || s.current >= s.total - 1) return; + this.managerService.goToSection(s.current + 1).subscribe(); + } + + onLastSection(): void { + const s = this.progressData?.section; + if (!s || s.total <= 0) return; + this.managerService.goToSection(s.total - 1).subscribe(); + } + + private formatDuration(seconds: number): string { + if (seconds < 60) return `${Math.round(seconds)} sec`; + const minutes = Math.floor(seconds / 60); + if (minutes < 60) return `${minutes} min`; + const hours = Math.floor(minutes / 60); + const remainingMinutes = minutes % 60; + return remainingMinutes > 0 + ? `${hours} hr ${remainingMinutes} min` + : `${hours} hr`; + } +} diff --git a/booklore-ui/src/app/features/readers/ebook-reader/reader-layout/sidebar/reader-book-metadata-dialog.component.html b/booklore-ui/src/app/features/readers/ebook-reader/reader-layout/sidebar/reader-book-metadata-dialog.component.html new file mode 100644 index 000000000..6df807f2d --- /dev/null +++ b/booklore-ui/src/app/features/readers/ebook-reader/reader-layout/sidebar/reader-book-metadata-dialog.component.html @@ -0,0 +1,199 @@ +
+
+
+

Book Information

+ +
+ +
+ +
+
+
diff --git a/booklore-ui/src/app/features/readers/ebook-reader/reader-layout/sidebar/reader-book-metadata-dialog.component.scss b/booklore-ui/src/app/features/readers/ebook-reader/reader-layout/sidebar/reader-book-metadata-dialog.component.scss new file mode 100644 index 000000000..6879b9dea --- /dev/null +++ b/booklore-ui/src/app/features/readers/ebook-reader/reader-layout/sidebar/reader-book-metadata-dialog.component.scss @@ -0,0 +1,261 @@ +.dialog-overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.2); + z-index: 12000; + display: flex; + align-items: center; + justify-content: center; + animation: fadeIn 0.2s ease-out; +} + +@keyframes fadeIn { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +.dialog { + background: rgba(30, 30, 30, 0.95); + backdrop-filter: blur(20px); + -webkit-backdrop-filter: blur(20px); + border-radius: 12px; + width: 90%; + max-width: 800px; + max-height: 85vh; + display: flex; + flex-direction: column; + overflow: hidden; + box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5); + animation: slideUp 0.3s ease-out; + + @media (max-width: 768px) { + width: 95%; + max-height: 90vh; + margin: 20px; + } +} + +@keyframes slideUp { + from { + transform: translateY(20px); + opacity: 0; + } + to { + transform: translateY(0); + opacity: 1; + } +} + +.dialog-header { + padding: 16px 20px; + border-bottom: 1px solid rgba(255, 255, 255, 0.08); + display: flex; + align-items: center; + justify-content: space-between; + background: linear-gradient(180deg, rgba(255, 255, 255, 0.02) 0%, transparent 100%); + + h2 { + margin: 0; + font-size: 18px; + font-weight: 600; + color: #ffffff; + letter-spacing: -0.3px; + } +} + +.close-btn { + background: none; + border: none; + color: rgba(255, 255, 255, 0.6); + font-size: 20px; + cursor: pointer; + padding: 4px 8px; + border-radius: 6px; + transition: all 0.2s ease; + line-height: 1; + + &:hover { + background: rgba(255, 255, 255, 0.08); + color: rgba(255, 255, 255, 0.9); + } +} + +.dialog-body { + padding: 20px; + overflow-y: auto; + flex: 1; + min-height: 0; + + @media (max-width: 768px) { + padding: 16px; + } + + &::-webkit-scrollbar { + width: 8px; + } + + &::-webkit-scrollbar-track { + background: transparent; + } + + &::-webkit-scrollbar-thumb { + background: rgba(255, 255, 255, 0.15); + border-radius: 4px; + + &:hover { + background: rgba(255, 255, 255, 0.25); + } + } +} + +.metadata-container { + display: flex; + gap: 24px; + + @media (max-width: 768px) { + flex-direction: column; + gap: 16px; + } +} + +.cover-section { + flex-shrink: 0; + width: 240px; + + @media (max-width: 768px) { + width: 100%; + max-width: 180px; + margin: 0 auto; + } +} + +.book-cover { + width: 100%; + height: auto; + border-radius: 8px; + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4); + max-height: 360px; + object-fit: cover; + + @media (max-width: 768px) { + max-height: 270px; + } +} + +.cover-placeholder { + width: 100%; + aspect-ratio: 2/3; + background: linear-gradient(135deg, rgba(255, 255, 255, 0.05) 0%, rgba(255, 255, 255, 0.02) 100%); + border: 2px dashed rgba(255, 255, 255, 0.15); + border-radius: 8px; + display: flex; + align-items: center; + justify-content: center; + + @media (max-width: 768px) { + max-height: 270px; + } + + .placeholder-icon { + font-size: 64px; + opacity: 0.3; + + @media (max-width: 768px) { + font-size: 48px; + } + } +} + +.metadata-section { + flex: 1; + display: flex; + flex-direction: column; + gap: 20px; +} + +.metadata-group { + animation: fadeIn 0.2s ease-out; + + &:not(:last-child) { + padding-bottom: 16px; + border-bottom: 1px solid rgba(255, 255, 255, 0.06); + } +} + +.section-title { + color: rgba(255, 255, 255, 0.5); + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 1px; + margin: 0 0 12px 0; +} + +.metadata-item { + display: grid; + grid-template-columns: 120px 1fr; + gap: 12px; + margin-bottom: 10px; + align-items: start; + + &:last-child { + margin-bottom: 0; + } + + @media (max-width: 768px) { + grid-template-columns: 1fr; + gap: 4px; + } +} + +.label { + color: rgba(255, 255, 255, 0.6); + font-size: 13px; + font-weight: 500; +} + +.value { + color: rgba(255, 255, 255, 0.95); + font-size: 13px; + word-break: break-word; + + &.file-name { + font-family: 'Courier New', monospace; + font-size: 12px; + color: rgba(255, 255, 255, 0.8); + } +} + +.review-count { + color: rgba(255, 255, 255, 0.5); + font-size: 12px; + margin-left: 4px; +} + +.tags-container { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.tag { + background: rgba(74, 158, 255, 0.15); + color: rgba(255, 255, 255, 0.9); + padding: 4px 12px; + border-radius: 12px; + font-size: 12px; + font-weight: 500; + border: 1px solid rgba(74, 158, 255, 0.25); +} + +.description { + color: rgba(255, 255, 255, 0.8); + font-size: 13px; + line-height: 1.6; + margin: 0; + white-space: pre-wrap; +} diff --git a/booklore-ui/src/app/features/readers/ebook-reader/reader-layout/sidebar/reader-book-metadata-dialog.component.ts b/booklore-ui/src/app/features/readers/ebook-reader/reader-layout/sidebar/reader-book-metadata-dialog.component.ts new file mode 100644 index 000000000..48facf81c --- /dev/null +++ b/booklore-ui/src/app/features/readers/ebook-reader/reader-layout/sidebar/reader-book-metadata-dialog.component.ts @@ -0,0 +1,52 @@ +import {Component, EventEmitter, inject, Input, Output} from '@angular/core'; +import {CommonModule} from '@angular/common'; +import {Book} from '../../../../book/model/book.model'; +import {UrlHelperService} from '../../../../../shared/service/url-helper.service'; + +@Component({ + selector: 'app-reader-book-metadata-dialog', + standalone: true, + imports: [CommonModule], + templateUrl: './reader-book-metadata-dialog.component.html', + styleUrls: ['./reader-book-metadata-dialog.component.scss'] +}) +export class ReaderBookMetadataDialogComponent { + @Input() book: Book | null = null; + @Output() close = new EventEmitter(); + + private urlHelperService = inject(UrlHelperService); + + get metadata() { + return this.book?.metadata; + } + + get bookCoverUrl(): string | null { + if (!this.book?.id) return null; + const coverUpdatedOn = this.book.metadata?.coverUpdatedOn; + return this.urlHelperService.getCoverUrl(this.book.id, coverUpdatedOn); + } + + formatDate(date: string | undefined): string { + if (!date) return 'N/A'; + try { + return new Date(date).toLocaleDateString('en-US', { + year: 'numeric', + month: 'long', + day: 'numeric' + }); + } catch { + return date; + } + } + + formatAuthors(authors: string[] | undefined): string { + if (!authors || authors.length === 0) return 'Unknown'; + return authors.join(', '); + } + + formatFileSize(sizeKb: number | undefined): string { + if (!sizeKb) return 'N/A'; + if (sizeKb < 1024) return `${sizeKb.toFixed(1)} KB`; + return `${(sizeKb / 1024).toFixed(2)} MB`; + } +} diff --git a/booklore-ui/src/app/features/readers/ebook-reader/reader-layout/sidebar/reader-sidebar.component.html b/booklore-ui/src/app/features/readers/ebook-reader/reader-layout/sidebar/reader-sidebar.component.html new file mode 100644 index 000000000..729e0c0a6 --- /dev/null +++ b/booklore-ui/src/app/features/readers/ebook-reader/reader-layout/sidebar/reader-sidebar.component.html @@ -0,0 +1,104 @@ +
+ +
diff --git a/booklore-ui/src/app/features/readers/ebook-reader/reader-layout/sidebar/reader-sidebar.component.scss b/booklore-ui/src/app/features/readers/ebook-reader/reader-layout/sidebar/reader-sidebar.component.scss new file mode 100644 index 000000000..922e8c886 --- /dev/null +++ b/booklore-ui/src/app/features/readers/ebook-reader/reader-layout/sidebar/reader-sidebar.component.scss @@ -0,0 +1,416 @@ +.dialog-overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.2); + z-index: 12000; + display: flex; + align-items: flex-start; + animation: fadeIn 200ms ease-out; + + &.closing { + animation: fadeOut 200ms ease-in forwards; + } +} + +@keyframes fadeIn { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +@keyframes fadeOut { + from { + opacity: 1; + } + to { + opacity: 0; + } +} + +.sidebar { + position: fixed; + top: 0; + left: 0; + bottom: 0; + width: 260px; + background: rgba(30, 30, 30, 0.95); + backdrop-filter: blur(20px); + -webkit-backdrop-filter: blur(20px); + box-shadow: 8px 0 32px rgba(0, 0, 0, 0.5); + z-index: 13001; + display: flex; + flex-direction: column; + color: white; + border-top-right-radius: 12px; + border-bottom-right-radius: 12px; + overflow: hidden; + animation: slideIn 250ms ease-out; + + &.closing { + animation: slideOut 250ms ease-in forwards; + } +} + +@keyframes slideIn { + from { + transform: translateX(-100%); + } + to { + transform: translateX(0); + } +} + +@keyframes slideOut { + from { + transform: translateX(0); + } + to { + transform: translateX(-100%); + } +} + +.sidebar-header { + display: flex; + align-items: center; + justify-content: flex-start; + padding: 16px; + min-height: 100px; + background: rgba(255, 255, 255, 0.02); + border-bottom: 1px solid rgba(255, 255, 255, 0.08); + position: relative; +} + +.sidebar-header-book { + display: flex; + align-items: center; + gap: 12px; +} + +.sidebar-book-cover { + width: 84px; + height: 117px; + object-fit: cover; + border-radius: 2px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.13); + background: #222; + flex-shrink: 0; + + &.clickable { + cursor: pointer; + transition: transform 0.2s ease, opacity 0.2s ease, box-shadow 0.2s ease; + + &:hover { + transform: scale(1.05); + opacity: 0.9; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2); + } + + &:active { + transform: scale(0.98); + } + + &:focus-visible { + outline: 2px solid #4a90e2; + outline-offset: 2px; + } + } +} + +.sidebar-book-meta { + display: flex; + flex-direction: column; + min-width: 0; + gap: 4px; + + .sidebar-book-title { + font-size: 13px; + font-weight: 600; + color: #ffffff; + margin-bottom: 2px; + line-height: 1.2; + max-width: 220px; + display: -webkit-box; + -webkit-box-orient: vertical; + -webkit-line-clamp: 3; + overflow: hidden; + text-overflow: ellipsis; + word-break: break-word; + max-height: calc(1.2em * 3); + } + + .sidebar-book-authors { + font-size: 11px; + color: rgba(255, 255, 255, 0.6); + line-height: 1.2; + max-width: 220px; + display: -webkit-box; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; + overflow: hidden; + text-overflow: ellipsis; + word-break: break-word; + } +} + +.sidebar-body { + flex: 1; + overflow-y: auto; + padding: 12px 0 12px 0; + + &::-webkit-scrollbar { + width: 8px; + } + + &::-webkit-scrollbar-track { + background: transparent; + } + + &::-webkit-scrollbar-thumb { + background: rgba(255, 255, 255, 0.15); + border-radius: 4px; + + &:hover { + background: rgba(255, 255, 255, 0.25); + } + } + + ul { + list-style: none; + padding: 0; + margin: 0; + } + + li { + padding: 6px 12px 6px 12px; + cursor: pointer; + font-size: 14px; + border-radius: 8px; + margin: 1px 8px; + transition: background 0.2s ease, color 0.2s ease; + color: rgba(255, 255, 255, 0.9); + background: transparent; + + &:hover { + background: rgba(255, 255, 255, 0.08); + color: #ffffff; + } + + border-bottom: none; + + .chapter-label { + display: block; + width: 100%; + } + + .ellipsis { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + display: block; + width: 100%; + } + } + + .bookmark-item { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + padding: 8px 12px; + + .bookmark-info { + display: flex; + flex-direction: column; + gap: 4px; + min-width: 0; + flex-grow: 1; + } + + .bookmark-title { + font-size: 13px; + font-weight: 500; + color: rgba(255, 255, 255, 0.9); + } + + .bookmark-date { + font-size: 11px; + color: rgba(255, 255, 255, 0.5); + } + } + + .bookmark-delete-btn { + background: none; + border: none; + color: rgba(255, 255, 255, 0.4); + cursor: pointer; + font-size: 18px; + padding: 4px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + transition: background 0.2s ease, color 0.2s ease; + flex-shrink: 0; + + &:hover { + background: rgba(255, 255, 255, 0.08); + color: #ffffff; + } + } +} + +.sidebar-tabs { + display: flex; + background: rgba(255, 255, 255, 0.02); + border-top: 1px solid rgba(255, 255, 255, 0.08); + height: 50px; + align-items: center; + box-sizing: border-box; + overflow: hidden; + z-index: 13002; + position: relative; +} + +.sidebar-tabs > * { + flex: 1; + height: 100%; + display: flex; + align-items: center; + justify-content: center; + color: rgba(255, 255, 255, 0.75); + cursor: pointer; + user-select: none; + position: relative; + transition: background 0.15s ease, color 0.15s ease; +} + +.sidebar-tabs > *:hover { + background: rgba(255, 255, 255, 0.05); + color: #fff; +} + +.sidebar-tabs > *.active { + background: rgba(255, 255, 255, 0.08); + color: #fff; +} + +.sidebar-tabs > *:not(:last-child)::after { + content: ''; + position: absolute; + right: 0; + top: 8px; + bottom: 8px; + width: 1px; + background: rgba(255, 255, 255, 0.08); +} + +.tab-btn { + flex: 1; + background: none; + border: none; + color: rgba(255, 255, 255, 0.6); + font-size: 13px; + padding: 8px 0; + cursor: pointer; + border-radius: 0; + transition: all 0.2s ease; + outline: none; + display: flex; + flex-direction: column; + align-items: center; + gap: 2px; + position: relative; + z-index: 13003; + + &.active { + color: #ffffff; + font-weight: 600; + background: transparent; + border-bottom: 1.5px solid white; + z-index: 1; + } + + &:hover:not(.active) { + background: transparent; + color: rgba(255, 255, 255, 0.8); + z-index: 2; + } +} + +.tab-icon { + font-size: 16px; + display: block; + margin-bottom: 1px; +} + +.tab-label { + font-size: 11px; + letter-spacing: 0.01em; +} + +.empty-tab { + color: rgba(255, 255, 255, 0.5); + font-size: 14px; + text-align: center; + margin-top: 24px; +} + +.chapter-list > li { + padding: 0; + margin: 0 8px; + border-radius: 8px; +} + +.chapter-row { + display: flex; + align-items: center; + cursor: pointer; + padding: 6px 12px; + border-radius: 8px; + transition: background 0.2s; + color: rgba(255, 255, 255, 0.9); + + &:hover { + background: rgba(255, 255, 255, 0.08); + color: #fff; + } + + &.has-subchapters { + font-weight: 600; + } + + &.active { + background: rgba(74, 144, 226, 0.3); + color: #ffffff; + font-weight: 600; + + &:hover { + background: rgba(74, 144, 226, 0.4); + } + } +} + +.accordion-icon { + margin-left: auto; + transition: transform 0.2s; + display: flex; + align-items: center; + + i { + font-size: 13px; + } +} + +.subchapter-list { + list-style: none; + padding-left: 0; + margin: 0; + + li { + padding: 0; + margin: 0; + } +} diff --git a/booklore-ui/src/app/features/readers/ebook-reader/reader-layout/sidebar/reader-sidebar.component.ts b/booklore-ui/src/app/features/readers/ebook-reader/reader-layout/sidebar/reader-sidebar.component.ts new file mode 100644 index 000000000..88285697f --- /dev/null +++ b/booklore-ui/src/app/features/readers/ebook-reader/reader-layout/sidebar/reader-sidebar.component.ts @@ -0,0 +1,127 @@ +import {Component, EventEmitter, inject, Input, OnChanges, Output, SimpleChanges} from '@angular/core'; +import {CommonModule} from '@angular/common'; +import {BookMark} from '../../../../../shared/service/book-mark.service'; +import {UrlHelperService} from '../../../../../shared/service/url-helper.service'; +import {TocItem} from 'epubjs'; + +@Component({ + selector: 'app-reader-sidebar', + standalone: true, + templateUrl: './reader-sidebar.component.html', + styleUrls: ['./reader-sidebar.component.scss'], + imports: [CommonModule] +}) +export class ReaderSidebarComponent implements OnChanges { + @Input() bookId: number | null = null; + @Input() coverUpdatedOn: string | undefined; + @Input() bookTitle: string = ''; + @Input() bookAuthors: string = ''; + @Input() chapters: TocItem[] = []; + @Input() bookmarks: BookMark[] = []; + @Input() currentChapterHref: string | null = null; + @Output() close = new EventEmitter(); + @Output() chapterClick = new EventEmitter(); + @Output() bookmarkClick = new EventEmitter(); + @Output() deleteBookmark = new EventEmitter(); + @Output() coverClick = new EventEmitter(); + + private urlHelperService = inject(UrlHelperService); + + activeTab: 'chapters' | 'bookmarks' | 'annotation' = 'chapters'; + closing = false; + bookCoverUrl: string | null = null; + + expandedChapters = new Set(); + + ngOnChanges(changes: SimpleChanges): void { + if (changes['bookId'] || changes['coverUpdatedOn']) { + this.bookCoverUrl = this.bookId + ? this.urlHelperService.getThumbnailUrl(this.bookId, this.coverUpdatedOn) + : null; + } + + if (changes['currentChapterHref']) { + if (this.currentChapterHref) { + this.autoExpandCurrentChapter(); + } + } + } + + private autoExpandCurrentChapter(): void { + if (!this.currentChapterHref) return; + + const expandParents = (items: TocItem[], parents: string[] = []): boolean => { + for (const item of items) { + const currentParents = [...parents, item.href]; + + const normalizedItemHref = item.href.split('#')[0].replace(/^\//, ''); + const normalizedCurrentHref = this.currentChapterHref!.split('#')[0].replace(/^\//, ''); + + if (normalizedItemHref === normalizedCurrentHref || item.href === this.currentChapterHref) { + parents.forEach(parentHref => this.expandedChapters.add(parentHref)); + return true; + } + + if (item.subitems?.length) { + if (expandParents(item.subitems, currentParents)) { + return true; + } + } + } + return false; + }; + + expandParents(this.chapters); + } + + private closeWithAnimation(callback?: () => void) { + this.closing = true; + setTimeout(() => { + this.closing = false; + if (callback) callback(); + this.close.emit(); + }, 250); + } + + onChapterClick(href: string) { + this.closeWithAnimation(() => this.chapterClick.emit(href)); + } + + onBookmarkClick(cfi: string) { + this.closeWithAnimation(() => this.bookmarkClick.emit(cfi)); + } + + onDeleteBookmark(event: MouseEvent, bookmarkId: number) { + event.stopPropagation(); + this.deleteBookmark.emit(bookmarkId); + } + + onCoverClick() { + this.closeWithAnimation(() => this.coverClick.emit()); + } + + onOverlayClick() { + this.closeWithAnimation(); + } + + toggleChapterExpand(href: string) { + if (this.expandedChapters.has(href)) { + this.expandedChapters.delete(href); + } else { + this.expandedChapters.add(href); + } + } + + isChapterExpanded(href: string): boolean { + return this.expandedChapters.has(href); + } + + isChapterActive(href: string): boolean { + if (!this.currentChapterHref) return false; + + const normalizedItemHref = href.split('#')[0].replace(/^\//, ''); + const normalizedCurrentHref = this.currentChapterHref.split('#')[0].replace(/^\//, ''); + + return normalizedItemHref === normalizedCurrentHref || href === this.currentChapterHref; + } +} diff --git a/booklore-ui/src/app/features/readers/ebook-reader/services/reader-bookmark.service.ts b/booklore-ui/src/app/features/readers/ebook-reader/services/reader-bookmark.service.ts new file mode 100644 index 000000000..fbec39dc6 --- /dev/null +++ b/booklore-ui/src/app/features/readers/ebook-reader/services/reader-bookmark.service.ts @@ -0,0 +1,65 @@ +import {inject, Injectable} from '@angular/core'; +import {Observable, of} from 'rxjs'; +import {catchError, map} from 'rxjs/operators'; +import {BookMarkService} from '../../../../shared/service/book-mark.service'; +import {MessageService} from 'primeng/api'; + +@Injectable() +export class ReaderBookmarkService { + private currentCFI: string | null = null; + private currentChapterName: string | null = null; + + private bookMarkService = inject(BookMarkService); + private messageService = inject(MessageService); + + + updateCurrentPosition(cfi: string, chapterName?: string): void { + this.currentCFI = cfi; + if (chapterName) { + this.currentChapterName = chapterName; + } + } + + createBookmarkAtCurrentPosition(bookId: number): Observable { + const cfi = this.currentCFI; + if (!cfi) { + console.error('Could not get current CFI - please navigate to a page first'); + return of(false); + } + + const title = this.currentChapterName || 'Bookmark'; + + return this.bookMarkService.createBookmark({bookId, cfi, title}).pipe( + map(() => { + this.messageService.add({ + severity: 'success', + summary: 'Bookmark Added', + detail: 'Your bookmark was added successfully.' + }); + return true; + }), + catchError(error => { + const isDuplicate = error?.status === 409; + this.messageService.add( + isDuplicate + ? { + severity: 'warn', + summary: 'Bookmark Already Exists', + detail: 'You already have a bookmark at this location.' + } + : { + severity: 'error', + summary: 'Unable to Add Bookmark', + detail: 'Something went wrong while adding the bookmark. Please try again.' + } + ); + return of(false); + }) + ); + } + + reset(): void { + this.currentCFI = null; + this.currentChapterName = null; + } +} diff --git a/booklore-ui/src/app/features/readers/ebook-reader/services/reader-loader.service.ts b/booklore-ui/src/app/features/readers/ebook-reader/services/reader-loader.service.ts new file mode 100644 index 000000000..49b7f86b6 --- /dev/null +++ b/booklore-ui/src/app/features/readers/ebook-reader/services/reader-loader.service.ts @@ -0,0 +1,37 @@ +import {Injectable} from '@angular/core'; +import {defer, from, Observable, of} from 'rxjs'; +import {switchMap} from 'rxjs/operators'; + +@Injectable({ + providedIn: 'root' +}) +export class ReaderLoaderService { + private scriptLoaded = false; + + loadFoliateScript(): Observable { + if (this.scriptLoaded || customElements.get('foliate-view')) { + return of(undefined); + } + + return defer(() => new Observable(observer => { + const script = document.createElement('script'); + script.type = 'module'; + script.src = '/assets/foliate/view.js'; + script.onload = () => { + this.scriptLoaded = true; + setTimeout(() => { + observer.next(); + observer.complete(); + }, 100); + }; + script.onerror = () => observer.error(new Error('Failed to load foliate.js')); + document.head.appendChild(script); + })); + } + + waitForCustomElement(): Observable { + return defer(() => from(customElements.whenDefined('foliate-view'))).pipe( + switchMap(() => of(undefined)) + ); + } +} diff --git a/booklore-ui/src/app/features/readers/ebook-reader/services/reader-state.service.ts b/booklore-ui/src/app/features/readers/ebook-reader/services/reader-state.service.ts new file mode 100644 index 000000000..1c13cee33 --- /dev/null +++ b/booklore-ui/src/app/features/readers/ebook-reader/services/reader-state.service.ts @@ -0,0 +1,239 @@ +import {inject, Injectable} from '@angular/core'; +import {BehaviorSubject, forkJoin, Observable} from 'rxjs'; +import {map, tap} from 'rxjs/operators'; +import {Theme, themes} from './reader-themes'; +import {BookService} from '../../../book/service/book.service'; +import {UserService} from '../../../settings/user-management/user.service'; +import {EpubCustomFontService} from '../../epub-reader/service/epub-custom-font.service'; + +export interface ReaderState { + lineHeight: number; + justify: boolean; + hyphenate: boolean; + maxColumnCount: number; + gap: number; + fontSize: number; + theme: Theme; + maxInlineSize: number; + maxBlockSize: number; + fontFamily: string | null; + isDark: boolean; + flow: 'paginated' | 'scrolled'; +} + +@Injectable({ + providedIn: 'root' +}) +export class ReaderStateService { + private epubCustomFontService = inject(EpubCustomFontService); + + private readonly BASE_FONTS = [ + {name: 'Publisher\'s', value: null}, + {name: 'Serif', value: 'serif'}, + {name: 'Sans-Serif', value: 'sans-serif'}, + {name: 'Monospace', value: 'monospace'}, + {name: 'Cursive', value: 'cursive'}, + ]; + + private readonly defaultState: ReaderState = { + lineHeight: 1.5, + justify: true, + hyphenate: true, + maxColumnCount: 2, + gap: 0.05, + fontSize: 16, + theme: { + ...themes[0], + fg: themes[0].dark.fg, + bg: themes[0].dark.bg, + link: themes[0].dark.link, + }, + maxInlineSize: 720, + maxBlockSize: 1440, + fontFamily: null, + isDark: true, + flow: 'paginated', + }; + + private stateSubject = new BehaviorSubject(this.defaultState); + public state$ = this.stateSubject.asObservable(); + + get currentState(): ReaderState { + return this.stateSubject.value; + } + + readonly themes = themes; + private fontsSubject = new BehaviorSubject>(this.BASE_FONTS); + + get fonts(): Array<{ name: string; value: string | null }> { + return this.fontsSubject.value; + } + + constructor(private bookService: BookService, private userService: UserService) { + this.loadCustomFontsIntoList(); + } + + private loadCustomFontsIntoList(): void { + const customFonts = this.epubCustomFontService.getCustomFonts(); + const customFontOptions = customFonts.map(font => ({ + name: font.fontName.replace(/\.(ttf|otf|woff|woff2)$/i, ''), + value: `custom:${font.id}` + })); + + const updatedFonts = [ + ...this.fontsSubject.value, + ...customFontOptions + ]; + + this.fontsSubject.next(updatedFonts); + } + + refreshCustomFonts(): void { + this.fontsSubject.next([...this.BASE_FONTS]); + this.loadCustomFontsIntoList(); + } + + initializeState(bookId: number): Observable { + return forkJoin([ + this.userService.getMyself(), + this.bookService.getBookSetting(bookId) + ]).pipe( + tap(([myself, bookSetting]) => { + const settingScope = myself.userSettings.perBookSetting.epub; + const globalSettings = myself.userSettings.ebookReaderSetting; + const individualSetting = bookSetting?.ebookSettings; + const settings = settingScope === 'Global' ? globalSettings : (individualSetting || globalSettings); + const newState: Partial = {}; + if (settings.fontSize != null) newState.fontSize = settings.fontSize; + if (settings.lineHeight != null) newState.lineHeight = settings.lineHeight; + + if (settings.fontFamily != null) { + if (settings.fontFamily.startsWith('custom:')) { + newState.fontFamily = settings.fontFamily; + } else { + const numericId = parseInt(settings.fontFamily, 10); + if (!isNaN(numericId) && numericId.toString() === settings.fontFamily) { + newState.fontFamily = `custom:${numericId}`; + } else { + newState.fontFamily = settings.fontFamily; + } + } + } else if ((settings as any).customFontId != null) { + newState.fontFamily = `custom:${(settings as any).customFontId}`; + } + + if (settings.gap != null) newState.gap = settings.gap; + if (settings.hyphenate != null) newState.hyphenate = settings.hyphenate; + if (settings.justify != null) newState.justify = settings.justify; + if (settings.maxColumnCount != null) newState.maxColumnCount = settings.maxColumnCount; + if (settings.maxInlineSize != null) newState.maxInlineSize = settings.maxInlineSize; + if (settings.maxBlockSize != null) newState.maxBlockSize = settings.maxBlockSize; + if (settings.isDark != null) newState.isDark = settings.isDark; + if (settings.flow) newState.flow = settings.flow; + if (settings.theme) { + const theme = this.themes.find(t => t.name === settings.theme); + if (theme) { + newState.theme = { + ...theme, + fg: settings.isDark ? theme.dark.fg : theme.light.fg, + bg: settings.isDark ? theme.dark.bg : theme.light.bg, + link: settings.isDark ? theme.dark.link : theme.light.link, + }; + } + } + if (Object.keys(newState).length > 0) { + this.updateState(newState); + } + }), + map(() => void 0) + ); + } + + updateLineHeight(delta: number): void { + const current = this.currentState.lineHeight; + const newValue = Math.max(0.8, Math.min(3, current + delta)); + this.updateState({lineHeight: newValue}); + } + + updateMaxColumnCount(delta: number): void { + const current = this.currentState.maxColumnCount; + const newValue = Math.max(1, Math.min(10, current + delta)); + this.updateState({maxColumnCount: newValue}); + } + + updateGap(delta: number): void { + const current = this.currentState.gap; + const newValue = Math.max(0, Math.min(0.5, current + delta)); + this.updateState({gap: newValue}); + } + + toggleJustify(): void { + this.updateState({justify: !this.currentState.justify}); + } + + toggleHyphenate(): void { + this.updateState({hyphenate: !this.currentState.hyphenate}); + } + + updateFontSize(delta: number): void { + const newFontSize = Math.max(10, Math.min(32, this.currentState.fontSize + delta)); + this.updateState({fontSize: newFontSize}); + } + + setTheme(theme: Theme): void { + this.updateState({theme}); + } + + setFontFamily(font: string | null): void { + if (font === null) { + this.updateState({fontFamily: null}); + } else if (!font.includes(':') && !isNaN(parseInt(font, 10)) && parseInt(font, 10).toString() === font) { + this.updateState({fontFamily: `custom:${font}`}); + } else { + this.updateState({fontFamily: font}); + } + } + + updateMaxInlineSize(delta: number): void { + const newValue = Math.max(400, Math.min(1600, this.currentState.maxInlineSize + delta)); + this.updateState({maxInlineSize: newValue}); + } + + updateMaxBlockSize(delta: number): void { + const newValue = Math.max(600, Math.min(2400, this.currentState.maxBlockSize + delta)); + this.updateState({maxBlockSize: newValue}); + } + + toggleDarkMode() { + const currentTheme = this.currentState.theme; + const newIsDark = !this.currentState.isDark; + const newTheme = { + ...currentTheme, + fg: newIsDark ? currentTheme.dark.fg : currentTheme.light.fg, + bg: newIsDark ? currentTheme.dark.bg : currentTheme.light.bg, + link: newIsDark ? currentTheme.dark.link : currentTheme.light.link, + }; + this.updateState({theme: newTheme, isDark: newIsDark}); + } + + setThemeByName(themeName: string) { + const theme = this.themes.find(t => t.name === themeName); + if (theme) { + const newTheme = { + ...theme, + fg: this.currentState.isDark ? theme.dark.fg : theme.light.fg, + bg: this.currentState.isDark ? theme.dark.bg : theme.light.bg, + link: this.currentState.isDark ? theme.dark.link : theme.light.link, + }; + this.setTheme(newTheme); + } + } + + setFlow(flow: 'paginated' | 'scrolled'): void { + this.updateState({ flow }); + } + + private updateState(partial: Partial): void { + this.stateSubject.next({...this.currentState, ...partial}); + } +} diff --git a/booklore-ui/src/app/features/readers/ebook-reader/services/reader-style.service.ts b/booklore-ui/src/app/features/readers/ebook-reader/services/reader-style.service.ts new file mode 100644 index 000000000..3be5e9309 --- /dev/null +++ b/booklore-ui/src/app/features/readers/ebook-reader/services/reader-style.service.ts @@ -0,0 +1,190 @@ +import {inject, Injectable} from '@angular/core'; +import {ReaderState} from './reader-state.service'; +import {EpubCustomFontService} from '../../epub-reader/service/epub-custom-font.service'; + +@Injectable({ + providedIn: 'root' +}) +export class ReaderStyleService { + private epubCustomFontService = inject(EpubCustomFontService); + + generateCSS(state: ReaderState): string { + const {lineHeight, justify, hyphenate, fontSize, theme, fontFamily} = state; + const userStylesheet = ''; + const overrideFont = false; + const mediaActiveClass = 'media-active'; + + let fontFaceRule = ''; + let actualFontFamily = null; + + if (fontFamily !== null) { + const customFontId = this.parseCustomFontId(fontFamily); + if (customFontId !== null) { + const customFont = this.epubCustomFontService.getCustomFontById(customFontId); + const blobUrl = this.epubCustomFontService.getBlobUrl(customFontId); + if (customFont && blobUrl) { + const sanitizedFontName = this.epubCustomFontService.sanitizeFontName(customFont.fontName); + actualFontFamily = `"${sanitizedFontName}", sans-serif`; + fontFaceRule = `@font-face { + font-family: "${sanitizedFontName}"; + src: url("${blobUrl}") format("truetype"); + font-weight: normal; + font-style: normal; + font-display: swap; + }`; + } + } else { + actualFontFamily = fontFamily; + } + } + + const fontFamilyRule = actualFontFamily ? ` + body { + font-family: ${actualFontFamily} !important; + } + body * { + font-family: inherit !important; + }` : ''; + + return ` + ${fontFaceRule} + @namespace epub "http://www.idpf.org/2007/ops"; + @media print { + html { + column-width: auto !important; + height: auto !important; + width: auto !important; + } + } + @media screen { + html { + color-scheme: light dark; + color: ${theme.fg || theme.light.fg}; + font-size: ${fontSize}px; + }${fontFamilyRule} + a:any-link { + color: ${theme.link || theme.light.link}; + text-decoration-color: light-dark( + color-mix(in srgb, currentColor 20%, transparent), + color-mix(in srgb, currentColor 40%, transparent)); + text-underline-offset: .1em; + } + a:any-link:hover { + text-decoration-color: unset; + } + @media (prefers-color-scheme: dark) { + html { + color: ${theme.fg || theme.dark.fg}; + } + a:any-link { + color: ${theme.link || theme.dark.link}; + } + } + aside[epub|type~="footnote"] { + display: none; + } + } + html { + line-height: ${lineHeight}; + hanging-punctuation: allow-end last; + orphans: 2; + widows: 2; + } + [align="left"] { text-align: left; } + [align="right"] { text-align: right; } + [align="center"] { text-align: center; } + [align="justify"] { text-align: justify; } + :is(hgroup, header) p { + text-align: unset; + hyphens: unset; + } + h1, h2, h3, h4, h5, h6, hgroup, th { + text-wrap: balance; + } + pre { + white-space: pre-wrap !important; + tab-size: 2; + } + @media screen and (prefers-color-scheme: light) { + ${(theme.bg || theme.light.bg) !== '#ffffff' ? ` + html, body { + color: ${theme.fg || theme.light.fg} !important; + background: none !important; + } + body * { + color: inherit !important; + border-color: currentColor !important; + background-color: ${theme.bg || theme.light.bg} !important; + } + a:any-link { + color: ${theme.link || theme.light.link} !important; + } + svg, img { + background-color: transparent !important; + mix-blend-mode: multiply; + } + .${mediaActiveClass}, .${mediaActiveClass} * { + color: ${theme.fg || theme.light.fg} !important; + background: color-mix(in hsl, ${theme.fg || theme.light.fg}, #fff 50%) !important; + background: color-mix(in hsl, ${theme.fg || theme.light.fg}, ${theme.bg || theme.light.bg} 85%) !important; + }` : ''} + } + @media screen and (prefers-color-scheme: dark) { + + html, body { + color: ${theme.fg || theme.dark.fg} !important; + background: none !important; + } + body * { + color: inherit !important; + border-color: currentColor !important; + background-color: ${theme.bg || theme.dark.bg} !important; + } + a:any-link { + color: ${theme.link || theme.dark.link} !important; + } + .${mediaActiveClass}, .${mediaActiveClass} * { + color: ${theme.fg || theme.dark.fg} !important; + background: color-mix(in hsl, ${theme.fg || theme.dark.fg}, #000 50%) !important; + background: color-mix(in hsl, ${theme.fg || theme.dark.fg}, ${theme.bg || theme.dark.bg} 75%) !important; + } + } + p, li, blockquote, dd { + line-height: ${lineHeight}; + text-align: ${justify ? 'justify' : 'start'} !important; + hyphens: ${hyphenate ? 'auto' : 'none'}; + } + ${overrideFont ? '' : ''} + ${userStylesheet} + `; + } + + private parseCustomFontId(fontFamily: string): number | null { + if (fontFamily.startsWith('custom:')) { + const id = parseInt(fontFamily.substring(7), 10); + return !isNaN(id) ? id : null; + } + + const id = parseInt(fontFamily, 10); + return !isNaN(id) && id.toString() === fontFamily ? id : null; + } + + applyStylesToRenderer(renderer: any, state: ReaderState): void { + if (!renderer) return; + + renderer.setAttribute('max-column-count', state.maxColumnCount); + renderer.setAttribute('gap', `${state.gap * 100}%`); + renderer.setAttribute('max-inline-size', `${state.maxInlineSize}px`); + renderer.setAttribute('max-block-size', `${state.maxBlockSize}px`); + if (typeof renderer.setStyles === 'function') { + const css = this.generateCSS(state); + renderer.setStyles(css); + } + + if (state.flow === 'paginated') { + renderer.setAttribute('margin', '40px'); + } else { + renderer.removeAttribute('margin'); + } + } +} diff --git a/booklore-ui/src/app/features/readers/ebook-reader/services/reader-themes.ts b/booklore-ui/src/app/features/readers/ebook-reader/services/reader-themes.ts new file mode 100644 index 000000000..37bef3133 --- /dev/null +++ b/booklore-ui/src/app/features/readers/ebook-reader/services/reader-themes.ts @@ -0,0 +1,78 @@ +export interface ThemeMode { + fg: string; + bg: string; + link: string; +} + +export interface Theme { + name: string; + label: string; + light: ThemeMode; + dark: ThemeMode; + fg?: string; + bg?: string; + link?: string; +} + +export const themes: Theme[] = [ + { + name: 'default', label: 'Default', + light: {fg: '#000000', bg: '#ffffff', link: '#0066cc'}, + dark: {fg: '#e0e0e0', bg: '#222222', link: '#77bbee'}, + }, + { + name: 'gray', label: 'Gray', + light: {fg: '#222222', bg: '#e0e0e0', link: '#4488cc'}, + dark: {fg: '#c6c6c6', bg: '#444444', link: '#88ccee'}, + }, + { + name: 'sepia', label: 'Sepia', + light: {fg: '#5b4636', bg: '#f1e8d0', link: '#008b8b'}, + dark: {fg: '#ffd595', bg: '#342e25', link: '#48d1cc'}, + }, + { + name: 'crimson', label: 'Crimson', + light: {fg: '#212529', bg: '#ffffff', link: '#dd0031'}, + dark: {fg: '#dee2e6', bg: '#343a40', link: '#ff4081'}, + }, + { + name: 'meadow', label: 'Meadow', + light: {fg: '#232c16', bg: '#d7dbbd', link: '#177b4d'}, + dark: {fg: '#d8deba', bg: '#333627', link: '#a6d608'}, + }, + { + name: 'rosewood', label: 'Rosewood', + light: {fg: '#4e1609', bg: '#f0d1d5', link: '#de3838'}, + dark: {fg: '#e5c4c8', bg: '#462f32', link: '#ff646e'}, + }, + { + name: 'azure', label: 'Azure', + light: {fg: '#262d48', bg: '#cedef5', link: '#2d53e5'}, + dark: {fg: '#babee1', bg: '#282e47', link: '#ff646e'}, + }, + { + name: 'dawnlight', label: 'Dawnlight', + light: {fg: '#586e75', bg: '#fdf6e3', link: '#268bd2'}, + dark: {fg: '#93a1a1', bg: '#002b36', link: '#268bd2'}, + }, + { + name: 'ember', label: 'Ember', + light: {fg: '#3c3836', bg: '#fbf1c7', link: '#076678'}, + dark: {fg: '#ebdbb2', bg: '#282828', link: '#83a598'}, + }, + { + name: 'aurora', label: 'Aurora', + light: {fg: '#2e3440', bg: '#eceff4', link: '#5e81ac'}, + dark: {fg: '#d8dee9', bg: '#2e3440', link: '#88c0d0'}, + }, + { + name: 'ocean', label: 'Ocean', + light: {fg: '#0a4d4d', bg: '#e0f7fa', link: '#00838f'}, + dark: {fg: '#b2dfdb', bg: '#263238', link: '#4dd0e1'}, + }, + { + name: 'mist', label: 'Mist', + light: {fg: '#4a148c', bg: '#f3e5f5', link: '#7b1fa2'}, + dark: {fg: '#c7b6dd', bg: '#3a3150', link: '#b39ddb'}, + }, +]; diff --git a/booklore-ui/src/app/features/readers/ebook-reader/services/reader-view-manager.service.ts b/booklore-ui/src/app/features/readers/ebook-reader/services/reader-view-manager.service.ts new file mode 100644 index 000000000..9a2b697cf --- /dev/null +++ b/booklore-ui/src/app/features/readers/ebook-reader/services/reader-view-manager.service.ts @@ -0,0 +1,329 @@ +import {Injectable} from '@angular/core'; +import {defer, from, Observable, of, Subject, throwError, timer} from 'rxjs'; +import {catchError, map, switchMap} from 'rxjs/operators'; +import {PageInfo, ReaderHeaderFooterUtil, ThemeInfo} from '../utils/reader-header-footer.util'; + +export interface ViewEvent { + type: 'load' | 'relocate' | 'error' | 'middle-single-tap'; + detail?: any; +} + +interface TocItem { + label: string; + href: string; + subitems?: TocItem[]; +} + +export interface BookMetadata { + title?: string; + authors?: string[]; + language?: string; + publisher?: string; + description?: string; + identifier?: string; + coverUrl?: string; + + [key: string]: any; +} + +@Injectable({ + providedIn: 'root' +}) +export class ReaderViewManagerService { + private readonly DOUBLE_CLICK_INTERVAL_MS = 300; + private readonly LONG_HOLD_THRESHOLD_MS = 500; + private readonly LEFT_ZONE_PERCENT = 0.3; + private readonly RIGHT_ZONE_PERCENT = 0.7; + + private view: any; + private isNavigating = false; + private lastClickTime = 0; + private lastClickZone: 'left' | 'middle' | 'right' | null = null; + private longHoldTimeout: ReturnType | null = null; + private keydownHandler?: (event: KeyboardEvent) => void; + private clickedDocs = new WeakSet(); + + private eventSubject = new Subject(); + public events$ = this.eventSubject.asObservable(); + + createView(container: HTMLElement): void { + this.view = document.createElement('foliate-view'); + this.view.style.width = '100%'; + this.view.style.height = '100%'; + this.view.style.display = 'block'; + container.appendChild(this.view); + + this.attachEventListeners(); + this.attachKeyboardHandler(); + } + + loadEpub(epubPath: string): Observable { + if (!this.view) { + return throwError(() => new Error('View not created')); + } + + return timer(100).pipe( + switchMap(() => from(fetch(epubPath))), + switchMap(response => { + if (!response.ok) { + throw new Error(`EPUB not found: ${response.status}`); + } + return from(response.blob()); + }), + switchMap(blob => { + const file = new File([blob], epubPath.split('/').pop() || 'book.epub', { + type: 'application/epub+zip' + }); + return from(this.view.open(file) as Promise); + }), + map(() => undefined), + catchError(err => throwError(() => err)) + ); + } + + destroy(): void { + if (this.keydownHandler) { + document.removeEventListener('keydown', this.keydownHandler); + this.keydownHandler = undefined; + } + this.view?.remove(); + this.view = null; + } + + goTo(target?: string | number | null): Observable { + const resolvedTarget = target ?? 0; + if (!this.view) { + return of(undefined); + } + return defer(() => + from(this.view.goTo(resolvedTarget) as Promise) + ).pipe( + map(() => undefined) + ); + } + + goToSection(index: number): Observable { + return this.goTo(index); + } + + goToFraction(fraction: number): Observable { + if (!this.view) { + return of(undefined); + } + return defer(() => from(this.view.goToFraction(fraction) as Promise)).pipe( + map(() => undefined) + ); + } + + prev(): void { + this.view?.prev(); + } + + next(): void { + this.view?.next(); + } + + getRenderer(): any { + return this.view?.renderer; + } + + updateHeadersAndFooters(chapterName: string, pageInfo?: PageInfo, theme?: ThemeInfo): void { + const renderer = this.getRenderer(); + ReaderHeaderFooterUtil.updateHeadersAndFooters(renderer, chapterName, pageInfo, theme); + } + + getChapters(): TocItem[] { + if (!this.view?.book?.toc) return []; + + const mapToc = (items: any[]): TocItem[] => + items.map(item => ({ + label: item.label, + href: item.href, + subitems: item.subitems?.length ? mapToc(item.subitems) : undefined + })); + + return mapToc(this.view.book.toc); + } + + getSectionFractions(): number[] { + if (!this.view?.getSectionFractions) return []; + return this.view.getSectionFractions(); + } + + getMetadata(): Observable { + if (!this.view?.book?.metadata) { + return of({}); + } + + const {metadata} = this.view.book; + + return this.getCoverUrl().pipe( + map(coverUrl => ({ + title: metadata.title, + authors: metadata.authors, + language: metadata.language, + publisher: metadata.publisher, + description: metadata.description, + identifier: metadata.identifier, + coverUrl, + ...metadata + })) + ); + } + + getCover(): Observable { + if (!this.view?.book?.getCover) { + return of(null); + } + return defer(() => { + const coverPromise = this.view.book.getCover(); + return coverPromise ? from(coverPromise as Promise) : of(null); + }); + } + + getCoverUrl(): Observable { + return this.getCover().pipe( + map(blob => blob ? URL.createObjectURL(blob) : null) + ); + } + + private attachEventListeners(): void { + this.view.addEventListener('load', (e: any) => { + this.eventSubject.next({type: 'load', detail: e.detail}); + if (e.detail?.doc) { + if (this.keydownHandler) { + e.detail.doc.addEventListener('keydown', this.keydownHandler); + } + this.attachIframeEventHandlers(e.detail.doc); + } + }); + + this.view.addEventListener('relocate', (e: any) => { + this.eventSubject.next({type: 'relocate', detail: e.detail}); + }); + + this.view.addEventListener('error', (e: any) => { + this.eventSubject.next({type: 'error', detail: e.detail}); + }); + + window.addEventListener('message', (event) => { + if (event.data?.type === 'iframe-click') { + this.handleIframeClickMessage(event.data); + } + }); + } + + private attachKeyboardHandler(): void { + this.keydownHandler = (event: KeyboardEvent) => { + const k = event.key; + if (k === 'ArrowLeft' || k === 'h' || k === 'PageUp') { + this.prev(); + event.preventDefault(); + } else if (k === 'ArrowRight' || k === 'l' || k === 'PageDown') { + this.next(); + event.preventDefault(); + } + }; + document.addEventListener('keydown', this.keydownHandler); + } + + private attachIframeEventHandlers(doc: Document): void { + if (this.clickedDocs.has(doc)) { + return; + } + this.clickedDocs.add(doc); + + doc.addEventListener('mousedown', (event: MouseEvent) => { + this.longHoldTimeout = setTimeout(() => { + this.longHoldTimeout = null; + }, this.LONG_HOLD_THRESHOLD_MS); + }, true); + + doc.addEventListener('click', (event: MouseEvent) => { + const iframe = doc.defaultView?.frameElement as HTMLIFrameElement | null; + if (!iframe) return; + + const iframeRect = iframe.getBoundingClientRect(); + const viewportX = iframeRect.left + event.clientX; + const viewportY = iframeRect.top + event.clientY; + + window.postMessage({ + type: 'iframe-click', + clientX: viewportX, + clientY: viewportY, + iframeLeft: iframeRect.left, + iframeWidth: iframeRect.width, + eventClientX: event.clientX, + target: (event.target as HTMLElement)?.tagName + }, '*'); + }, true); + } + + private handleIframeClickMessage(data: any): void { + const now = Date.now(); + const timeSinceLastClick = now - this.lastClickTime; + + const viewRect = this.view.getBoundingClientRect(); + const x = data.clientX - viewRect.left; + const width = viewRect.width; + + const leftThreshold = width * this.LEFT_ZONE_PERCENT; + const rightThreshold = width * this.RIGHT_ZONE_PERCENT; + + let currentZone: 'left' | 'middle' | 'right'; + if (x < leftThreshold) { + currentZone = 'left'; + } else if (x > rightThreshold) { + currentZone = 'right'; + } else { + currentZone = 'middle'; + } + + if (timeSinceLastClick < this.DOUBLE_CLICK_INTERVAL_MS && this.lastClickZone === currentZone) { + this.lastClickTime = now; + this.lastClickZone = currentZone; + + if (currentZone !== 'middle') { + } + return; + } + + this.lastClickTime = now; + this.lastClickZone = currentZone; + + setTimeout(() => { + if (Date.now() - this.lastClickTime >= this.DOUBLE_CLICK_INTERVAL_MS) { + this.processIframeClick(data); + } + }, this.DOUBLE_CLICK_INTERVAL_MS); + } + + private processIframeClick(data: any): void { + if (!this.longHoldTimeout) { + return; + } + + if (this.isNavigating) { + return; + } + + const viewRect = this.view.getBoundingClientRect(); + const x = data.clientX - viewRect.left; + const width = viewRect.width; + + const leftThreshold = width * this.LEFT_ZONE_PERCENT; + const rightThreshold = width * this.RIGHT_ZONE_PERCENT; + + if (x < leftThreshold) { + this.isNavigating = true; + this.prev(); + setTimeout(() => this.isNavigating = false, 300); + } else if (x > rightThreshold) { + this.isNavigating = true; + this.next(); + setTimeout(() => this.isNavigating = false, 300); + } else { + this.eventSubject.next({type: 'middle-single-tap'}); + } + } +} diff --git a/booklore-ui/src/app/features/readers/ebook-reader/utils/reader-header-footer-visibility.util.ts b/booklore-ui/src/app/features/readers/ebook-reader/utils/reader-header-footer-visibility.util.ts new file mode 100644 index 000000000..6680a5798 --- /dev/null +++ b/booklore-ui/src/app/features/readers/ebook-reader/utils/reader-header-footer-visibility.util.ts @@ -0,0 +1,93 @@ +export interface HeaderFooterVisibilityState { + headerVisible: boolean; + footerVisible: boolean; +} + +export class ReaderHeaderFooterVisibilityManager { + private isPinned = false; + private mouseY = 0; + + private readonly HEADER_HEIGHT = 20; + private readonly FOOTER_HEIGHT = 20; + private readonly TRIGGER_ZONE = 20; + + private headerVisible = false; + private footerVisible = false; + + private onStateChangeCallback?: (state: HeaderFooterVisibilityState) => void; + + constructor(private windowHeight: number) { + } + + onStateChange(callback: (state: HeaderFooterVisibilityState) => void): void { + this.onStateChangeCallback = callback; + } + + updateWindowHeight(height: number): void { + this.windowHeight = height; + } + + handleMouseMove(mouseY: number): void { + this.mouseY = mouseY; + this.updateVisibility(); + } + + handleMouseLeave(): void { + if (!this.isPinned) { + this.setHeaderVisible(false); + this.setFooterVisible(false); + this.notifyStateChange(); + } + } + + togglePinned(): void { + this.isPinned = !this.isPinned; + this.updateVisibility(); + } + + getVisibilityState(): HeaderFooterVisibilityState { + return { + headerVisible: this.headerVisible, + footerVisible: this.footerVisible + }; + } + + private updateVisibility(): void { + if ( + this.mouseY <= this.TRIGGER_ZONE || + (this.mouseY <= this.HEADER_HEIGHT && this.headerVisible) || + this.isPinned + ) { + this.setHeaderVisible(true); + } else if (this.mouseY > this.HEADER_HEIGHT) { + this.setHeaderVisible(this.isPinned); + } + + const footerTop = this.windowHeight - this.FOOTER_HEIGHT; + if ( + this.mouseY >= this.windowHeight - this.TRIGGER_ZONE || + (this.mouseY >= footerTop && this.footerVisible) || + this.isPinned + ) { + this.setFooterVisible(true); + } else if (this.mouseY < footerTop) { + this.setFooterVisible(this.isPinned); + } + + this.notifyStateChange(); + } + + private setHeaderVisible(visible: boolean): void { + this.headerVisible = visible; + } + + private setFooterVisible(visible: boolean): void { + this.footerVisible = visible; + } + + private notifyStateChange(): void { + if (this.onStateChangeCallback) { + this.onStateChangeCallback(this.getVisibilityState()); + } + } +} diff --git a/booklore-ui/src/app/features/readers/ebook-reader/utils/reader-header-footer.util.ts b/booklore-ui/src/app/features/readers/ebook-reader/utils/reader-header-footer.util.ts new file mode 100644 index 000000000..2e65086c6 --- /dev/null +++ b/booklore-ui/src/app/features/readers/ebook-reader/utils/reader-header-footer.util.ts @@ -0,0 +1,130 @@ +export interface PageInfo { + percentCompleted: number; + sectionTimeText: string; +} + +export interface ThemeInfo { + fg: string; + bg: string; +} + +export class ReaderHeaderFooterUtil { + private static readonly DEFAULT_FONT_SIZE = '0.875rem'; + + static updateHeadersAndFooters(renderer: any, chapterName: string, pageInfo?: PageInfo, theme?: ThemeInfo): void { + + if (!renderer) { + return; + } + + const columnCount = renderer.heads?.length || 0; + const isSingleColumn = columnCount === 1; + + this.updateHeaders(renderer, chapterName, isSingleColumn, theme); + this.updateFooters(renderer, pageInfo, isSingleColumn, theme); + } + + private static updateHeaders(renderer: any, chapterName: string, isSingleColumn: boolean, theme?: ThemeInfo): void { + if (!renderer.heads || !Array.isArray(renderer.heads) || renderer.heads.length === 0) { + return; + } + + const headerStyle = this.buildStyle(theme); + + renderer.heads.forEach((headElement: HTMLElement, index: number) => { + if (headElement) { + headElement.style.visibility = 'visible'; + const headerContent = this.createHeaderContent(chapterName, isSingleColumn, index, headerStyle); + headElement.replaceChildren(headerContent); + } + }); + } + + private static updateFooters(renderer: any, pageInfo: PageInfo | undefined, isSingleColumn: boolean, theme?: ThemeInfo): void { + if (!renderer.feet || !Array.isArray(renderer.feet) || renderer.feet.length === 0 || !pageInfo) { + return; + } + + const footerStyle = this.buildStyle(theme); + + renderer.feet.forEach((footElement: HTMLElement, index: number) => { + if (footElement) { + const footerContent = this.createFooterContent(pageInfo, isSingleColumn, index, renderer.feet.length, footerStyle); + footElement.replaceChildren(footerContent); + } + }); + } + + private static buildStyle(theme?: ThemeInfo): string { + const baseStyle = `width: 100%; display: flex; justify-content: space-between; align-items: center; font-size: ${this.DEFAULT_FONT_SIZE}; font-family: inherit;`; + if (theme) { + return `${baseStyle} color: ${theme.fg};`; + } + return baseStyle; + } + + private static createHeaderContent(chapterName: string, isSingleColumn: boolean, index: number, style: string): HTMLElement { + const headerContent = document.createElement('div'); + headerContent.style.cssText = style; + + if (isSingleColumn) { + const spacer = document.createElement('span'); + const chapterSpan = document.createElement('span'); + chapterSpan.textContent = chapterName || ''; + chapterSpan.style.textAlign = 'right'; + headerContent.style.justifyContent = 'left'; + + headerContent.appendChild(spacer); + headerContent.appendChild(chapterSpan); + } else { + if (index === 0) { + const chapterSpan = document.createElement('span'); + chapterSpan.textContent = chapterName || ''; + chapterSpan.style.textAlign = 'left'; + headerContent.appendChild(chapterSpan); + } + } + + return headerContent; + } + + private static createFooterContent(pageInfo: PageInfo, isSingleColumn: boolean, index: number, totalColumns: number, style: string): HTMLElement { + const footerContent = document.createElement('div'); + footerContent.style.cssText = style; + + const text = 'Time remaining in section: ' + (pageInfo.sectionTimeText ?? '0s'); + + if (isSingleColumn) { + const timeSpan = document.createElement('span'); + timeSpan.textContent = text; + timeSpan.style.textAlign = 'left'; + + const progressSpan = document.createElement('span'); + progressSpan.textContent = `${pageInfo.percentCompleted}%`; + progressSpan.style.textAlign = 'right'; + + footerContent.appendChild(timeSpan); + footerContent.appendChild(progressSpan); + } else { + if (index === 0) { + const timeSpan = document.createElement('span'); + timeSpan.textContent = text; + timeSpan.style.textAlign = 'left'; + footerContent.appendChild(timeSpan); + + const spacer = document.createElement('span'); + footerContent.appendChild(spacer); + } else if (index === totalColumns - 1) { + const spacer = document.createElement('span'); + footerContent.appendChild(spacer); + + const progressSpan = document.createElement('span'); + progressSpan.textContent = `${pageInfo.percentCompleted}%`; + progressSpan.style.textAlign = 'right'; + footerContent.appendChild(progressSpan); + } + } + + return footerContent; + } +} diff --git a/booklore-ui/src/app/features/readers/epub-reader/component/epub-reader.component.ts b/booklore-ui/src/app/features/readers/epub-reader/component/epub-reader.component.ts index 6a38cd69e..ec5f4fe85 100644 --- a/booklore-ui/src/app/features/readers/epub-reader/component/epub-reader.component.ts +++ b/booklore-ui/src/app/features/readers/epub-reader/component/epub-reader.component.ts @@ -695,7 +695,7 @@ export class EpubReaderComponent implements OnInit, OnDestroy { this.progressPercentage = Math.round(percentage * 1000) / 10; } - this.bookService.saveEpubProgress(this.epub.id, cfi, Math.round(percentage * 1000) / 10).subscribe(); + //this.bookService.saveEpubProgress(this.epub.id, cfi, '', Math.round(percentage * 1000) / 10).subscribe(); this.readingSessionService.updateProgress( location.start.cfi, diff --git a/booklore-ui/src/app/features/readers/epub-reader/service/epub-custom-font.service.ts b/booklore-ui/src/app/features/readers/epub-reader/service/epub-custom-font.service.ts new file mode 100644 index 000000000..38fc78c31 --- /dev/null +++ b/booklore-ui/src/app/features/readers/epub-reader/service/epub-custom-font.service.ts @@ -0,0 +1,123 @@ +import {Injectable, inject} from '@angular/core'; +import {CustomFontService} from '../../../../shared/service/custom-font.service'; +import {CustomFont} from '../../../../shared/model/custom-font.model'; +import {Observable, forkJoin, of, from} from 'rxjs'; +import {map, switchMap, tap, catchError} from 'rxjs/operators'; + +@Injectable({ + providedIn: 'root' +}) +export class EpubCustomFontService { + private customFontService = inject(CustomFontService); + + private customFonts: CustomFont[] = []; + private customFontBlobUrls = new Map(); + + loadAndCacheFonts(): Observable { + return this.customFontService.getUserFonts().pipe( + tap(fonts => this.customFonts = fonts), + switchMap(fonts => + from(this.customFontService.loadAllFonts(fonts)).pipe( + switchMap(() => this.cacheCustomFontsAsBlobs(fonts)), + map(() => fonts) + ) + ), + catchError(() => of([])) + ); + } + + private cacheCustomFontsAsBlobs(fonts: CustomFont[]): Observable { + if (fonts.length === 0) { + return of(undefined); + } + + const requests = fonts.map(font => + this.cacheSingleFont(font).pipe( + catchError(() => of(undefined)) + ) + ); + + return forkJoin(requests).pipe(map(() => undefined)); + } + + private cacheSingleFont(font: CustomFont): Observable { + const fontUrl = this.customFontService.getFontUrl(font.id); + const fontUrlWithToken = this.customFontService.appendToken(fontUrl); + + return from(fetch(fontUrlWithToken)).pipe( + switchMap(response => from(response.blob())), + tap(blob => { + const blobUrl = URL.createObjectURL(blob); + this.customFontBlobUrls.set(font.id, blobUrl); + }), + map(() => undefined) + ); + } + + getBlobUrl(fontId: number): string | undefined { + return this.customFontBlobUrls.get(fontId); + } + + getCustomFonts(): CustomFont[] { + return this.customFonts; + } + + getCustomFontById(fontId: number): CustomFont | undefined { + return this.customFonts.find(f => f.id === fontId); + } + + cleanup(): void { + this.customFontBlobUrls.forEach(url => URL.revokeObjectURL(url)); + this.customFontBlobUrls.clear(); + } + + sanitizeFontName(fontName: string): string { + return fontName.replace(/\.(ttf|otf|woff|woff2)$/i, '').replace(/["'()]/g, '').replace(/\s+/g, '-'); + } + + getFontFamilyForPreview(fontValue: string): string { + if (fontValue.startsWith('custom:')) { + const id = parseInt(fontValue.substring(7), 10); + if (!isNaN(id)) { + const customFont = this.getCustomFontById(id); + if (customFont) { + return `"${this.sanitizeFontName(customFont.fontName)}", sans-serif`; + } + } + } + return fontValue; + } + + generateCustomFontsStylesheet(): string { + const customFonts = this.getCustomFonts(); + if (customFonts.length === 0) return ''; + + let css = ''; + customFonts.forEach(font => { + const blobUrl = this.getBlobUrl(font.id); + if (blobUrl) { + const sanitizedName = this.sanitizeFontName(font.fontName); + css += ` + @font-face { + font-family: "${sanitizedName}"; + src: url("${blobUrl}") format("truetype"); + font-weight: normal; + font-style: normal; + font-display: swap; + } + `; + } + }); + + return css; + } + + injectCustomFontsStylesheet(renderer: any, document: Document): void { + const css = this.generateCustomFontsStylesheet(); + if (css) { + const styleEl = renderer.createElement('style'); + styleEl.textContent = css; + renderer.appendChild(document.head, styleEl); + } + } +} diff --git a/booklore-ui/src/app/features/settings/device-settings/component/hardcover-settings/hardcover-settings-component.scss b/booklore-ui/src/app/features/settings/device-settings/component/hardcover-settings/hardcover-settings-component.scss index 4e129c798..7ae23636f 100644 --- a/booklore-ui/src/app/features/settings/device-settings/component/hardcover-settings/hardcover-settings-component.scss +++ b/booklore-ui/src/app/features/settings/device-settings/component/hardcover-settings/hardcover-settings-component.scss @@ -180,10 +180,12 @@ display: flex; align-items: center; gap: 1rem; - border: 1px solid var(--p-content-border-color); - border-radius: 8px; padding: 1rem; - background: var(--p-content-background); + border-radius: 8px; + background: rgba(220, 38, 38, 0.1); + border: 1px solid rgba(220, 38, 38, 0.3); + color: var(--p-red-400); + max-width: 500px; margin: 0 1rem; @media (max-width: 768px) { @@ -191,21 +193,24 @@ } .pi { - font-size: 1.5rem; - color: var(--p-text-muted-color); + font-size: 1.25rem; + flex-shrink: 0; + color: var(--p-red-300); } } .access-denied-content { h3 { - margin: 0 0 0.25rem 0; + margin: 0 0 0.5rem 0; font-size: 1rem; - color: var(--p-text-color); + font-weight: 600; + color: var(--p-red-300); } p { margin: 0; - color: var(--p-text-muted-color); font-size: 0.875rem; + line-height: 1.5; + color: var(--p-red-400); } } diff --git a/booklore-ui/src/app/features/settings/metadata-settings/metadata-persistence-settings/metadata-persistence-settings-component.html b/booklore-ui/src/app/features/settings/metadata-settings/metadata-persistence-settings/metadata-persistence-settings-component.html index 67794bc47..e11f683ed 100644 --- a/booklore-ui/src/app/features/settings/metadata-settings/metadata-persistence-settings/metadata-persistence-settings-component.html +++ b/booklore-ui/src/app/features/settings/metadata-settings/metadata-persistence-settings/metadata-persistence-settings-component.html @@ -15,7 +15,13 @@ - +
+ +
+ Note: Writing metadata is not supported for FB2, AZW3, and MOBI formats. +
+
+
@@ -55,7 +61,6 @@
-
@@ -95,7 +100,6 @@
-
diff --git a/booklore-ui/src/app/features/settings/metadata-settings/metadata-persistence-settings/metadata-persistence-settings-component.scss b/booklore-ui/src/app/features/settings/metadata-settings/metadata-persistence-settings/metadata-persistence-settings-component.scss index 712ba8882..5961df6fd 100644 --- a/booklore-ui/src/app/features/settings/metadata-settings/metadata-persistence-settings/metadata-persistence-settings-component.scss +++ b/booklore-ui/src/app/features/settings/metadata-settings/metadata-persistence-settings/metadata-persistence-settings-component.scss @@ -213,3 +213,29 @@ } } } + +.info-notice.unsupported-formats-note { + background: color-mix( + in srgb, + var(--p-surface-900) 90%, + var(--p-blue-600) 10% + ); + border-left: 4px solid var(--p-blue-400); + color: var(--p-text-color); + padding: 0.75rem 1rem; + border-radius: 8px; + display: flex; + align-items: flex-start; + gap: 0.75rem; + + .pi-info-circle { + color: var(--p-blue-400); + margin-top: 0.15rem; + flex-shrink: 0; + } + + strong { + color: var(--p-blue-300); + font-weight: 700; + } +} diff --git a/booklore-ui/src/app/features/settings/metadata-settings/metadata-persistence-settings/metadata-persistence-settings-component.ts b/booklore-ui/src/app/features/settings/metadata-settings/metadata-persistence-settings/metadata-persistence-settings-component.ts index 2f148fa53..9ddd038f4 100644 --- a/booklore-ui/src/app/features/settings/metadata-settings/metadata-persistence-settings/metadata-persistence-settings-component.ts +++ b/booklore-ui/src/app/features/settings/metadata-settings/metadata-persistence-settings/metadata-persistence-settings-component.ts @@ -87,11 +87,11 @@ export class MetadataPersistenceSettingsComponent implements OnInit { saveToOriginalFile: { epub: { enabled: persistenceSettings.saveToOriginalFile?.epub?.enabled ?? false, - maxFileSizeInMb: persistenceSettings.saveToOriginalFile?.epub?.maxFileSizeInMb ?? 100 + maxFileSizeInMb: persistenceSettings.saveToOriginalFile?.epub?.maxFileSizeInMb ?? 250 }, pdf: { enabled: persistenceSettings.saveToOriginalFile?.pdf?.enabled ?? false, - maxFileSizeInMb: persistenceSettings.saveToOriginalFile?.pdf?.maxFileSizeInMb ?? 100 + maxFileSizeInMb: persistenceSettings.saveToOriginalFile?.pdf?.maxFileSizeInMb ?? 250 }, cbx: { enabled: persistenceSettings.saveToOriginalFile?.cbx?.enabled ?? false, diff --git a/booklore-ui/src/app/features/settings/reader-preferences/epub-reader-preferences/epub-reader-preferences-component.html b/booklore-ui/src/app/features/settings/reader-preferences/epub-reader-preferences/epub-reader-preferences-component.html index ecf42ef2b..b0cdcd5c7 100644 --- a/booklore-ui/src/app/features/settings/reader-preferences/epub-reader-preferences/epub-reader-preferences-component.html +++ b/booklore-ui/src/app/features/settings/reader-preferences/epub-reader-preferences/epub-reader-preferences-component.html @@ -1,125 +1,289 @@
-
-
-
- -
- @for (theme of themes; track theme) { - - } + +
+

Appearance

+ +
+
+
+ +
+ @for (theme of themes; track theme) { + + } +
+

+ Select a color scheme to match your reading environment and comfort. +

-

- Choose the visual theme for EPUB reading experience. -

-
-
-
-
- -
- @for (font of fonts; track font) { - - } - - @if (!customFontsReady) { - - } -
-
-

- Select the font family for text display. -

-
-
- -
-
-
- -
- @for (flow of flowOptions; track flow) { +
+
+
+ +
- } -
-
-

- Configure text flow and reading direction. -

-
-
- -
-
-
- -
- @for (spread of spreadOptions; track spread) { - } +
+

+ Switch between light and dark backgrounds for day or night reading. +

-

- Choose between single page or double page spread view. -

-
-
-
- -
- - {{ fontSize }}% - +
+

Typography

+
+
+
+ +
+ @for (font of fonts; track font) { + + } + + @if (!customFontsReady) { + + } +
+

+ Choose a typeface that best suits your reading preference. +

+
+
+ +
+
+
+ +
+ + {{ fontSize }}pt + +
+
+

+ Increase or decrease text size in points for optimal readability. +

+
+
+ +
+
+
+ +
+ + {{ lineHeight.toFixed(1) }} + +
+
+

+ Adjust vertical spacing between lines for better legibility. +

+
+
+ +
+
+
+ +
+ + +
+
+

+ Align text to the left or justify it for a clean edge on both sides. +

+
+
+ +
+
+
+ +
+ + +
+
+

+ Enable to break long words at line ends for a smoother text flow. +

+
+
+
+ +
+

Layout

+ +
+
+
+ +
+ + +
+
+

+ Choose between paginated (page-by-page) or continuous scrolling flow. +

+
+
+ +
+
+
+ +
+ + {{ maxColumnCount }} + +
+
+

+ Set how many columns the text is split into on wide screens. +

+
+
+ +
+
+
+ +
+ + {{ gap }}px + +
+
+

+ Control the space between columns for a balanced layout. +

+
+
+ +
+
+
+ +
+ + {{ maxInlineSize }}px + +
+
+

+ Limit the maximum width of the reading area for easier line tracking. +

+
+
+ +
+
+
+ +
+ + {{ maxBlockSize }}px + +
+
+

+ Limit the maximum height of the reading area to fit your screen. +

-

- Adjust the text size for comfortable reading. -

diff --git a/booklore-ui/src/app/features/settings/reader-preferences/epub-reader-preferences/epub-reader-preferences-component.scss b/booklore-ui/src/app/features/settings/reader-preferences/epub-reader-preferences/epub-reader-preferences-component.scss index e88e7ea3a..d4b3fd9c3 100644 --- a/booklore-ui/src/app/features/settings/reader-preferences/epub-reader-preferences/epub-reader-preferences-component.scss +++ b/booklore-ui/src/app/features/settings/reader-preferences/epub-reader-preferences/epub-reader-preferences-component.scss @@ -1,7 +1,25 @@ .epub-preferences-container { + display: flex; + flex-direction: column; + gap: 2rem; +} + +.settings-group { display: flex; flex-direction: column; gap: 1rem; + padding: 1.5rem; + border-radius: 0.75rem; + border: 1px solid color-mix(in srgb, var(--text-color) 10%, transparent); + + .group-title { + font-size: 1.125rem; + font-weight: 600; + color: var(--p-text-color); + margin: 0 0 0.5rem 0; + padding-bottom: 0.75rem; + border-bottom: 1px solid color-mix(in srgb, var(--text-color) 10%, transparent); + } } .setting-item { @@ -80,7 +98,7 @@ .font-size-controls { display: flex; align-items: center; - gap: 1rem; + gap: 0.5rem; .font-size-value { min-width: 3rem; @@ -96,32 +114,70 @@ flex-wrap: wrap; .theme-option { - width: 2.5rem; - height: 2.5rem; - border-radius: 0.375rem; - border: 1.5px solid transparent; + display: flex; + flex-direction: column; + align-items: center; + gap: 0.5rem; + background: color-mix(in srgb, var(--text-color) 5%, transparent); + color: var(--text-color); cursor: pointer; transition: all 0.2s ease; position: relative; - display: flex; - align-items: center; - justify-content: center; + min-width: 48px; + + .theme-colors { + display: flex; + gap: 0; + padding: 0; + border-radius: 0.5rem; + overflow: hidden; + border: 2px solid color-mix(in srgb, var(--text-color) 20%, transparent); + box-sizing: border-box; + width: 72px; + height: 36px; + background: none; + } + + .theme-color { + width: 36px; + height: 36px; + display: inline-block; + border-radius: 0; + border: none; + margin: 0; + padding: 0; + } i { - color: white; - font-size: 0.875rem; - font-weight: bold; - text-shadow: 0 1px 2px rgba(0, 0, 0, 0.3); + position: absolute; + top: 4px; + right: 4px; + font-size: 0.75rem; + color: var(--primary-color); + margin-left: 0; + padding-left: 0; } &:hover { - transform: scale(1.1); - box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); + transform: translateY(-2px); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); + + .theme-colors { + border-color: color-mix(in srgb, var(--primary-color) 40%, transparent); + } } &.selected { - border-color: var(--primary-color); - box-shadow: 0 0 0 2px color-mix(in srgb, var(--primary-color) 25%, transparent); + .theme-colors { + border-color: var(--primary-color); + box-shadow: 0 0 0 2px color-mix(in srgb, var(--primary-color) 15%, transparent); + } + color: var(--primary-color); + background: color-mix(in srgb, var(--primary-color) 10%, transparent); + + &:hover { + transform: translateY(-2px); + } } } } @@ -219,3 +275,4 @@ } } } + diff --git a/booklore-ui/src/app/features/settings/reader-preferences/epub-reader-preferences/epub-reader-preferences-component.ts b/booklore-ui/src/app/features/settings/reader-preferences/epub-reader-preferences/epub-reader-preferences-component.ts index fd8fd447e..48dd56362 100644 --- a/booklore-ui/src/app/features/settings/reader-preferences/epub-reader-preferences/epub-reader-preferences-component.ts +++ b/booklore-ui/src/app/features/settings/reader-preferences/epub-reader-preferences/epub-reader-preferences-component.ts @@ -9,6 +9,7 @@ import {CustomFont} from '../../../../shared/model/custom-font.model'; import {skip, Subject, takeUntil} from 'rxjs'; import {addCustomFontsToDropdown} from '../../../../shared/util/custom-font.util'; import {Skeleton} from 'primeng/skeleton'; +import {themes} from '../../../readers/ebook-reader/services/reader-themes'; @Component({ selector: 'app-epub-reader-preferences-component', @@ -40,33 +41,7 @@ export class EpubReaderPreferencesComponent implements OnInit, OnDestroy { {name: 'Monospace', displayName: 'Monospace', key: 'monospace'} ]; - readonly flowOptions = [ - {name: 'Paginated', key: 'paginated', icon: 'pi pi-book'}, - {name: 'Scrolled', key: 'scrolled', icon: 'pi pi-sort-alt'} - ]; - - readonly spreadOptions = [ - {name: 'Single', key: 'single', icon: 'pi pi-file'}, - {name: 'Double', key: 'double', icon: 'pi pi-copy'} - ]; - - readonly themes = [ - {name: 'White', key: 'white', color: '#FFFFFF'}, - {name: 'Black', key: 'black', color: '#1A1A1A'}, - {name: 'Grey', key: 'grey', color: '#4B5563'}, - {name: 'Sepia', key: 'sepia', color: '#F4ECD8'}, - {name: 'Green', key: 'green', color: '#D1FAE5'}, - {name: 'Lavender', key: 'lavender', color: '#E9D5FF'}, - {name: 'Cream', key: 'cream', color: '#FEF3C7'}, - {name: 'Light Blue', key: 'light-blue', color: '#DBEAFE'}, - {name: 'Peach', key: 'peach', color: '#FECACA'}, - {name: 'Mint', key: 'mint', color: '#A7F3D0'}, - {name: 'Dark Slate', key: 'dark-slate', color: '#1E293B'}, - {name: 'Dark Olive', key: 'dark-olive', color: '#3F3F2C'}, - {name: 'Dark Purple', key: 'dark-purple', color: '#3B2F4A'}, - {name: 'Dark Teal', key: 'dark-teal', color: '#0F3D3E'}, - {name: 'Dark Brown', key: 'dark-brown', color: '#3E2723'} - ]; + readonly themes = themes; customFontsReady = false; @@ -129,12 +104,13 @@ export class EpubReaderPreferencesComponent implements OnInit, OnDestroy { } private isCurrentlySelectedFontDeleted(newFonts: CustomFont[]): boolean { - const customFontId = this.userSettings.epubReaderSetting.customFontId; - if (!customFontId) { + const fontFamily = this.userSettings.ebookReaderSetting.fontFamily; + if (!fontFamily || !fontFamily.startsWith('custom:')) { return false; } - const fontStillExists = newFonts.some(font => font.id === customFontId); + const fontId = fontFamily.split(':')[1]; + const fontStillExists = newFonts.some(font => font.id === parseInt(fontId, 10)); return !fontStillExists; } @@ -144,93 +120,176 @@ export class EpubReaderPreferencesComponent implements OnInit, OnDestroy { } get selectedTheme(): string | null { - return this.userSettings.epubReaderSetting.theme; + return this.userSettings.ebookReaderSetting.theme; } set selectedTheme(value: string | null) { if (typeof value === "string") { - this.userSettings.epubReaderSetting.theme = value; + this.userSettings.ebookReaderSetting.theme = value; } - this.readerPreferencesService.updatePreference(['epubReaderSetting', 'theme'], value); + this.readerPreferencesService.updatePreference(['ebookReaderSetting', 'theme'], value); } get selectedFont(): string | null { - // If customFontId is set, return the custom font key - if (this.userSettings.epubReaderSetting.customFontId) { - return `custom:${this.userSettings.epubReaderSetting.customFontId}`; - } - return this.userSettings.epubReaderSetting.font; + return this.userSettings.ebookReaderSetting.fontFamily; } set selectedFont(value: string | null) { - // Handle custom fonts - if (value && value.startsWith('custom:')) { - const fontIdStr = value.split(':')[1]; - const fontId = parseInt(fontIdStr, 10); - - if (isNaN(fontId)) { - console.error('Invalid custom font ID:', value); - return; - } - - // Update both fields in local state - this.userSettings.epubReaderSetting.customFontId = fontId; - this.userSettings.epubReaderSetting.font = value; + if (typeof value === "string") { + this.userSettings.ebookReaderSetting.fontFamily = value; } else { - // Clear customFontId and set font in local state - this.userSettings.epubReaderSetting.customFontId = null; - if (typeof value === "string") { - this.userSettings.epubReaderSetting.font = value; - } else { - // value is null - set to default font - this.userSettings.epubReaderSetting.font = null as any; - } + this.userSettings.ebookReaderSetting.fontFamily = null as any; } - - // Single API call with entire epubReaderSetting object - this.readerPreferencesService.updatePreference(['epubReaderSetting'], this.userSettings.epubReaderSetting); - } - - get selectedFlow(): string | null { - return this.userSettings.epubReaderSetting.flow; - } - - set selectedFlow(value: string | null) { - if (typeof value === "string") { - this.userSettings.epubReaderSetting.flow = value; - } - this.readerPreferencesService.updatePreference(['epubReaderSetting', 'flow'], value); - } - - get selectedSpread(): string | null { - return this.userSettings.epubReaderSetting.spread; - } - - set selectedSpread(value: string | null) { - if (typeof value === "string") { - this.userSettings.epubReaderSetting.spread = value; - } - this.readerPreferencesService.updatePreference(['epubReaderSetting', 'spread'], value); + this.readerPreferencesService.updatePreference(['ebookReaderSetting', 'fontFamily'], value); } get fontSize(): number { - return this.userSettings.epubReaderSetting.fontSize; + return this.userSettings.ebookReaderSetting.fontSize; } set fontSize(value: number) { - this.userSettings.epubReaderSetting.fontSize = value; - this.readerPreferencesService.updatePreference(['epubReaderSetting', 'fontSize'], value); + this.userSettings.ebookReaderSetting.fontSize = value; + this.readerPreferencesService.updatePreference(['ebookReaderSetting', 'fontSize'], value); + } + + get lineHeight(): number { + return this.userSettings.ebookReaderSetting.lineHeight; + } + + set lineHeight(value: number) { + this.userSettings.ebookReaderSetting.lineHeight = value; + this.readerPreferencesService.updatePreference(['ebookReaderSetting', 'lineHeight'], value); + } + + get justify(): boolean { + return this.userSettings.ebookReaderSetting.justify; + } + + set justify(value: boolean) { + this.userSettings.ebookReaderSetting.justify = value; + this.readerPreferencesService.updatePreference(['ebookReaderSetting', 'justify'], value); + } + + get hyphenate(): boolean { + return this.userSettings.ebookReaderSetting.hyphenate; + } + + set hyphenate(value: boolean) { + this.userSettings.ebookReaderSetting.hyphenate = value; + this.readerPreferencesService.updatePreference(['ebookReaderSetting', 'hyphenate'], value); + } + + get maxColumnCount(): number { + return this.userSettings.ebookReaderSetting.maxColumnCount; + } + + set maxColumnCount(value: number) { + this.userSettings.ebookReaderSetting.maxColumnCount = value; + this.readerPreferencesService.updatePreference(['ebookReaderSetting', 'maxColumnCount'], value); + } + + get gap(): number { + return this.userSettings.ebookReaderSetting.gap; + } + + set gap(value: number) { + this.userSettings.ebookReaderSetting.gap = value; + this.readerPreferencesService.updatePreference(['ebookReaderSetting', 'gap'], value); + } + + get maxInlineSize(): number { + return this.userSettings.ebookReaderSetting.maxInlineSize; + } + + set maxInlineSize(value: number) { + this.userSettings.ebookReaderSetting.maxInlineSize = value; + this.readerPreferencesService.updatePreference(['ebookReaderSetting', 'maxInlineSize'], value); + } + + get maxBlockSize(): number { + return this.userSettings.ebookReaderSetting.maxBlockSize; + } + + set maxBlockSize(value: number) { + this.userSettings.ebookReaderSetting.maxBlockSize = value; + this.readerPreferencesService.updatePreference(['ebookReaderSetting', 'maxBlockSize'], value); + } + + get isDark(): boolean { + return this.userSettings.ebookReaderSetting.isDark; + } + + set isDark(value: boolean) { + this.userSettings.ebookReaderSetting.isDark = value; + this.readerPreferencesService.updatePreference(['ebookReaderSetting', 'isDark'], value); + } + + get flow(): 'paginated' | 'scrolled' { + return this.userSettings.ebookReaderSetting.flow; + } + + set flow(value: 'paginated' | 'scrolled') { + this.userSettings.ebookReaderSetting.flow = value; + this.readerPreferencesService.updatePreference(['ebookReaderSetting', 'flow'], value); } increaseFontSize() { - if (this.fontSize < 250) { - this.fontSize += 10; + if (this.fontSize < 72) { + this.fontSize += 1; } } decreaseFontSize() { - if (this.fontSize > 50) { - this.fontSize -= 10; + if (this.fontSize > 8) { + this.fontSize -= 1; + } + } + + increaseLineHeight() { + if (this.lineHeight < 3) { + this.lineHeight += 0.1; + } + } + + decreaseLineHeight() { + if (this.lineHeight > 1) { + this.lineHeight -= 0.1; + } + } + + increaseGap() { + if (this.gap < 100) { + this.gap += 5; + } + } + + decreaseGap() { + if (this.gap > 0) { + this.gap -= 5; + } + } + + increaseMaxInlineSize() { + if (this.maxInlineSize < 2000) { + this.maxInlineSize += 50; + } + } + + decreaseMaxInlineSize() { + if (this.maxInlineSize > 400) { + this.maxInlineSize -= 50; + } + } + + increaseMaxBlockSize() { + if (this.maxBlockSize < 2000) { + this.maxBlockSize += 50; + } + } + + decreaseMaxBlockSize() { + if (this.maxBlockSize > 400) { + this.maxBlockSize -= 50; } } diff --git a/booklore-ui/src/app/features/settings/reader-preferences/reader-preferences.component.html b/booklore-ui/src/app/features/settings/reader-preferences/reader-preferences.component.html index 6429290fd..50e418904 100644 --- a/booklore-ui/src/app/features/settings/reader-preferences/reader-preferences.component.html +++ b/booklore-ui/src/app/features/settings/reader-preferences/reader-preferences.component.html @@ -5,7 +5,7 @@ Reader Preferences

- Configure reader settings scope and customize reading experience for different book formats including PDF, EPUB, and CBX files. + Configure reading experience settings for your library. Set default preferences that serve as overrides for individual books, or allow each book to maintain its own custom settings for PDF, EPUB, FB2, MOBI, AZW3, and CBX formats.

@@ -14,10 +14,10 @@

- Reader Settings Scope + Settings Application Mode

- Select whether to apply the same reader settings (font, theme, spread, zoom, etc.) globally for all books or individually per book. + Control how default settings are applied. Select "Default Override" to apply your configured defaults to all books of the same format, overriding individual book preferences. Choose "Book-Specific" to let each book retain its own custom reading settings.

@@ -32,17 +32,15 @@ type="button" class="option-button" [class.selected]="selectedPdfScope === item.key" - (click)="selectedPdfScope = item.key; onPdfScopeChange()" - [pTooltip]="item.name" - tooltipPosition="bottom"> + (click)="selectedPdfScope = item.key; onPdfScopeChange()"> - {{ item.name }} + {{ item.key === 'Global' ? 'Default Override' : 'Book-Specific' }} }

- Choose how PDF reader settings are applied across your library. + Choose whether default zoom, layout, and navigation settings override individual PDF preferences or if each document maintains its own settings.

@@ -50,24 +48,22 @@
- +
@for (item of scopeOptions; track item) { }

- Choose how EPUB reader settings are applied across your library. + Choose whether default font, theme, and layout settings override individual eBook preferences (EPUB, FB2, MOBI, AZW3) or if each book maintains its own settings.

@@ -82,17 +78,15 @@ type="button" class="option-button" [class.selected]="selectedCbxScope === item.key" - (click)="selectedCbxScope = item.key; onCbxScopeChange()" - [pTooltip]="item.name" - tooltipPosition="bottom"> + (click)="selectedCbxScope = item.key; onCbxScopeChange()"> - {{ item.name }} + {{ item.key === 'Global' ? 'Default Override' : 'Book-Specific' }} }

- Choose how CBX (comic book) reader settings are applied across your library. + Choose whether default layout, direction, and scaling settings override individual comic book preferences (CBZ, CBR, CB7) or if each file maintains its own settings.

@@ -103,10 +97,10 @@

- EPUB Preferences: Global + eBook Reader: Default Settings

- Set default reading options that will apply to all EPUB books in your library, including font, theme, text flow, and layout preferences. + Set default preferences for eBook formats (EPUB, FB2, MOBI, AZW3). When "Default Override" mode is active, these settings will override individual book preferences. Configure typography, themes, text flow, and layout options.

@@ -121,10 +115,10 @@

- PDF Preferences: Global + PDF Reader: Default Settings

- Set default reading options that apply to all PDF books in your library, including zoom level, page layout, and navigation behavior. + Set default preferences for PDF documents. When "Default Override" mode is active, these settings will override individual document preferences. Configure zoom levels, display modes, scroll behavior, and navigation.

@@ -139,10 +133,10 @@

- CBX Preferences: Global + Comic Book Reader: Default Settings

- Configure default reading settings for all comic books in your library, including page layout, reading direction, and image scaling. + Set default preferences for comic book archives (CBZ, CBR, CB7). When "Default Override" mode is active, these settings will override individual file preferences. Configure page layout, reading direction, and image rendering.

@@ -158,10 +152,10 @@

- Custom Fonts Management + Custom Font Library

- Upload and manage up to 10 custom fonts for a personalized reading experience in the EPUB reader. Each font can then be selected in the EPUB reader settings. + Personalize your reading experience by uploading custom fonts to use with eBook formats (EPUB, FB2, MOBI, AZW3). Upload up to 10 custom font files that will be available for selection in the eBook reader settings.

diff --git a/booklore-ui/src/app/features/settings/reader-preferences/reader-preferences.service.spec.ts b/booklore-ui/src/app/features/settings/reader-preferences/reader-preferences.service.spec.ts index 2f8b88e40..76d4b60ec 100644 --- a/booklore-ui/src/app/features/settings/reader-preferences/reader-preferences.service.spec.ts +++ b/booklore-ui/src/app/features/settings/reader-preferences/reader-preferences.service.spec.ts @@ -44,6 +44,20 @@ const mockUser: User = { perBookSetting: {pdf: '', epub: '', cbx: ''}, pdfReaderSetting: {pageSpread: 'off', pageZoom: '', showSidebar: false}, epubReaderSetting: {theme: '', font: '', fontSize: 1, flow: '', spread: '', lineHeight: 1, margin: 1, letterSpacing: 1}, + ebookReaderSetting: { + lineHeight: 1, + justify: false, + hyphenate: false, + maxColumnCount: 1, + gap: 0, + fontSize: 1, + theme: '', + maxInlineSize: 0, + maxBlockSize: 0, + fontFamily: '', + isDark: false, + flow: 'paginated' + }, cbxReaderSetting: { pageSpread: CbxPageSpread.EVEN, pageViewMode: CbxPageViewMode.SINGLE_PAGE, @@ -61,7 +75,7 @@ const mockUser: User = { metadataCenterViewMode: 'route', enableSeriesView: true, entityViewPreferences: { - global: {sortKey: '', sortDir: 'ASC', view: 'GRID', coverSize: 1, seriesCollapsed: false}, + global: {sortKey: '', sortDir: 'ASC', view: 'GRID', coverSize: 1, seriesCollapsed: false, overlayBookType: false}, overrides: [] }, koReaderEnabled: false, diff --git a/booklore-ui/src/app/features/settings/user-management/user.service.spec.ts b/booklore-ui/src/app/features/settings/user-management/user.service.spec.ts deleted file mode 100644 index 43228be20..000000000 --- a/booklore-ui/src/app/features/settings/user-management/user.service.spec.ts +++ /dev/null @@ -1,956 +0,0 @@ -import {beforeEach, describe, expect, it, vi} from 'vitest'; -import {TestBed} from '@angular/core/testing'; -import {EnvironmentInjector, runInInjectionContext} from '@angular/core'; -import {HttpClient} from '@angular/common/http'; -import {of, throwError} from 'rxjs'; - -import { - CbxFitMode, - CbxPageSpread, - CbxPageViewMode, - PdfPageSpread, - PdfPageViewMode, - User, - UserService, - UserUpdateRequest -} from './user.service'; -import {AuthService} from '../../../shared/service/auth.service'; - -describe('UserService', () => { - let service: UserService; - let httpClientMock: any; - let authServiceMock: any; - - const mockUser: User = { - id: 1, - username: 'testuser', - name: 'Test User', - email: 'test@example.com', - assignedLibraries: [], - permissions: { - admin: true, - canUpload: true, - canDownload: true, - canEmailBook: true, - canDeleteBook: true, - canEditMetadata: true, - canManageLibrary: true, - canManageMetadataConfig: true, - canSyncKoReader: true, - canSyncKobo: true, - canAccessOpds: true, - canAccessBookdrop: true, - canAccessLibraryStats: true, - canAccessUserStats: true, - canAccessTaskManager: true, - canManageEmailConfig: true, - canManageGlobalPreferences: true, - canManageIcons: true, - canManageFonts: true, - demoUser: false, - canBulkAutoFetchMetadata: true, - canBulkCustomFetchMetadata: true, - canBulkEditMetadata: true, - canBulkRegenerateCover: true, - canMoveOrganizeFiles: true, - canBulkLockUnlockMetadata: true - }, - userSettings: { - perBookSetting: {pdf: '', epub: '', cbx: ''}, - pdfReaderSetting: {pageSpread: 'off', pageZoom: '', showSidebar: false}, - epubReaderSetting: {theme: '', font: '', fontSize: 1, flow: '', spread: '', lineHeight: 1, margin: 1, letterSpacing: 1}, - cbxReaderSetting: { - pageSpread: CbxPageSpread.EVEN, - pageViewMode: CbxPageViewMode.SINGLE_PAGE, - fitMode: CbxFitMode.ACTUAL_SIZE - }, - newPdfReaderSetting: { - pageSpread: PdfPageSpread.EVEN, - pageViewMode: PdfPageViewMode.SINGLE_PAGE - }, - sidebarLibrarySorting: {field: '', order: ''}, - sidebarShelfSorting: {field: '', order: ''}, - sidebarMagicShelfSorting: {field: '', order: ''}, - filterMode: 'and', - filterSortingMode: 'alphabetical', - metadataCenterViewMode: 'route', - enableSeriesView: true, - entityViewPreferences: { - global: {sortKey: '', sortDir: 'ASC', view: 'GRID', coverSize: 1, seriesCollapsed: false}, - overrides: [] - }, - koReaderEnabled: false, - autoSaveMetadata: false - } - }; - - beforeEach(() => { - httpClientMock = { - get: vi.fn(), - post: vi.fn(), - put: vi.fn(), - delete: vi.fn() - }; - - authServiceMock = { - token$: of('token') - }; - - TestBed.configureTestingModule({ - providers: [ - UserService, - {provide: HttpClient, useValue: httpClientMock}, - {provide: AuthService, useValue: authServiceMock} - ] - }); - - const injector = TestBed.inject(EnvironmentInjector); - - service = runInInjectionContext( - injector, - () => TestBed.inject(UserService) - ); - }); - - it('should initialize with default state', () => { - expect(service.userStateSubject.value).toEqual({ - user: null, - loaded: false, - error: null - }); - }); - - it('should set initial user', () => { - service.setInitialUser(mockUser); - expect(service.userStateSubject.value.user).toEqual(mockUser); - expect(service.userStateSubject.value.loaded).toBe(true); - }); - - it('should get current user', () => { - service.setInitialUser(mockUser); - expect(service.getCurrentUser()).toEqual(mockUser); - }); - - it('should fetch myself and update state', () => { - httpClientMock.get.mockReturnValue(of(mockUser)); - service['fetchMyself']().subscribe(user => { - expect(user).toEqual(mockUser); - expect(service.userStateSubject.value.user).toEqual(mockUser); - expect(service.userStateSubject.value.loaded).toBe(true); - }); - }); - - it('should handle fetch myself error', () => { - httpClientMock.get.mockReturnValue(throwError(() => new Error('fail'))); - service['fetchMyself']().subscribe({ - error: (err: any) => { - expect(service.userStateSubject.value.error).toBe('fail'); - expect(err).toBeInstanceOf(Error); - } - }); - }); - - it('should get myself via getMyself()', () => { - httpClientMock.get.mockReturnValue(of(mockUser)); - service.getMyself().subscribe(user => { - expect(user).toEqual(mockUser); - expect(httpClientMock.get).toHaveBeenCalledWith(expect.stringContaining('/me')); - }); - }); - - it('should create user', () => { - httpClientMock.post.mockReturnValue(of(void 0)); - const {id, ...userData} = mockUser; - service.createUser(userData).subscribe(result => { - expect(result).toBeUndefined(); - expect(httpClientMock.post).toHaveBeenCalled(); - }); - }); - - it('should get users', () => { - httpClientMock.get.mockReturnValue(of([mockUser])); - service.getUsers().subscribe(users => { - expect(users).toEqual([mockUser]); - expect(httpClientMock.get).toHaveBeenCalled(); - }); - }); - - it('should update user', () => { - httpClientMock.put.mockReturnValue(of(mockUser)); - const update: UserUpdateRequest = {name: 'New Name'}; - service.updateUser(1, update).subscribe(user => { - expect(user).toEqual(mockUser); - expect(httpClientMock.put).toHaveBeenCalledWith(expect.stringContaining('/1'), update); - }); - }); - - it('should delete user', () => { - httpClientMock.delete.mockReturnValue(of(void 0)); - service.deleteUser(1).subscribe(result => { - expect(result).toBeUndefined(); - expect(httpClientMock.delete).toHaveBeenCalledWith(expect.stringContaining('/1')); - }); - }); - - it('should change user password', () => { - httpClientMock.put.mockReturnValue(of(void 0)); - service.changeUserPassword(1, 'newpass').subscribe(result => { - expect(result).toBeUndefined(); - expect(httpClientMock.put).toHaveBeenCalledWith(expect.stringContaining('change-user-password'), {userId: 1, newPassword: 'newpass'}); - }); - }); - - it('should handle change user password error', () => { - httpClientMock.put.mockReturnValue(throwError(() => ({error: {message: 'bad'}}))); - service.changeUserPassword(1, 'badpass').subscribe({ - error: (err: any) => { - expect(err).toBeInstanceOf(Error); - expect(err.message).toBe('bad'); - } - }); - }); - - it('should change password', () => { - httpClientMock.put.mockReturnValue(of(void 0)); - service.changePassword('old', 'new').subscribe(result => { - expect(result).toBeUndefined(); - expect(httpClientMock.put).toHaveBeenCalledWith(expect.stringContaining('change-password'), {currentPassword: 'old', newPassword: 'new'}); - }); - }); - - it('should handle change password error', () => { - httpClientMock.put.mockReturnValue(throwError(() => ({error: {message: 'fail'}}))); - service.changePassword('old', 'bad').subscribe({ - error: (err: any) => { - expect(err).toBeInstanceOf(Error); - expect(err.message).toBe('fail'); - } - }); - }); - - it('should update user setting and update state', () => { - httpClientMock.put.mockReturnValue(of(void 0)); - service.setInitialUser(mockUser); - service.updateUserSetting(1, 'koReaderEnabled', true); - expect(httpClientMock.put).toHaveBeenCalledWith(expect.stringContaining('/1/settings'), {key: 'koReaderEnabled', value: true}, expect.anything()); - expect(service.userStateSubject.value.user?.userSettings.koReaderEnabled).toBe(true); - }); -}); - -describe('UserService - API Contract Tests', () => { - let service: UserService; - let httpClientMock: any; - let authServiceMock: any; - - beforeEach(() => { - httpClientMock = { - get: vi.fn(), - post: vi.fn(), - put: vi.fn(), - delete: vi.fn() - }; - - authServiceMock = { - token$: of('token') - }; - - TestBed.configureTestingModule({ - providers: [ - UserService, - {provide: HttpClient, useValue: httpClientMock}, - {provide: AuthService, useValue: authServiceMock} - ] - }); - - const injector = TestBed.inject(EnvironmentInjector); - service = runInInjectionContext(injector, () => TestBed.inject(UserService)); - }); - - describe('User interface contract', () => { - it('should validate all required User fields exist', () => { - const requiredFields: (keyof User)[] = [ - 'id', 'username', 'name', 'email', 'assignedLibraries', 'permissions', 'userSettings' - ]; - - const mockResponse: User = { - id: 1, - username: 'test', - name: 'Test', - email: 'test@test.com', - assignedLibraries: [], - permissions: { - admin: false, - canUpload: false, - canDownload: false, - canEmailBook: false, - canDeleteBook: false, - canEditMetadata: false, - canManageLibrary: false, - canManageMetadataConfig: false, - canSyncKoReader: false, - canSyncKobo: false, - canAccessOpds: false, - canAccessBookdrop: false, - canAccessLibraryStats: false, - canAccessUserStats: false, - canAccessTaskManager: false, - canManageEmailConfig: false, - canManageGlobalPreferences: false, - canManageIcons: false, - canManageFonts: false, - demoUser: false, - canBulkAutoFetchMetadata: false, - canBulkCustomFetchMetadata: false, - canBulkEditMetadata: false, - canBulkRegenerateCover: false, - canMoveOrganizeFiles: false, - canBulkLockUnlockMetadata: false - }, - userSettings: { - perBookSetting: {pdf: '', epub: '', cbx: ''}, - pdfReaderSetting: {pageSpread: 'off', pageZoom: '', showSidebar: false}, - epubReaderSetting: {theme: '', font: '', fontSize: 1, flow: '', spread: '', lineHeight: 1, margin: 1, letterSpacing: 1}, - cbxReaderSetting: {pageSpread: CbxPageSpread.EVEN, pageViewMode: CbxPageViewMode.SINGLE_PAGE, fitMode: CbxFitMode.ACTUAL_SIZE}, - newPdfReaderSetting: {pageSpread: PdfPageSpread.EVEN, pageViewMode: PdfPageViewMode.SINGLE_PAGE}, - sidebarLibrarySorting: {field: '', order: ''}, - sidebarShelfSorting: {field: '', order: ''}, - sidebarMagicShelfSorting: {field: '', order: ''}, - filterMode: 'and', - filterSortingMode: 'alphabetical', - metadataCenterViewMode: 'route', - enableSeriesView: true, - entityViewPreferences: {global: {sortKey: '', sortDir: 'ASC' as const, view: 'GRID' as const, coverSize: 1, seriesCollapsed: false}, overrides: []}, - koReaderEnabled: false, - autoSaveMetadata: false - } - }; - - httpClientMock.get.mockReturnValue(of(mockResponse)); - - service.getMyself().subscribe(user => { - requiredFields.forEach(field => { - expect(user).toHaveProperty(field); - expect(user[field]).toBeDefined(); - }); - }); - }); - - it('should validate all required Permission fields exist', () => { - const requiredPermissions = [ - 'admin', 'canUpload', 'canDownload', 'canEmailBook', 'canDeleteBook', - 'canEditMetadata', 'canManageLibrary', 'canManageMetadataConfig', - 'canSyncKoReader', 'canSyncKobo', 'canAccessOpds', 'canAccessBookdrop', - 'canAccessLibraryStats', 'canAccessUserStats', 'canAccessTaskManager', - 'canManageEmailConfig', 'canManageGlobalPreferences', 'canManageIcons', - 'canManageFonts', // <-- Added test for canManageFonts - 'demoUser', 'canBulkAutoFetchMetadata', 'canBulkCustomFetchMetadata', - 'canBulkEditMetadata', 'canBulkRegenerateCover', 'canMoveOrganizeFiles', - 'canBulkLockUnlockMetadata' - ]; - - const mockResponse: User = { - id: 1, - username: 'test', - name: 'Test', - email: 'test@test.com', - assignedLibraries: [], - permissions: { - admin: true, - canUpload: true, - canDownload: true, - canEmailBook: true, - canDeleteBook: true, - canEditMetadata: true, - canManageLibrary: true, - canManageMetadataConfig: true, - canSyncKoReader: true, - canSyncKobo: true, - canAccessOpds: true, - canAccessBookdrop: true, - canAccessLibraryStats: true, - canAccessUserStats: true, - canAccessTaskManager: true, - canManageEmailConfig: true, - canManageGlobalPreferences: true, - canManageIcons: true, - canManageFonts: true, // <-- Added to mock - demoUser: false, - canBulkAutoFetchMetadata: true, - canBulkCustomFetchMetadata: true, - canBulkEditMetadata: true, - canBulkRegenerateCover: true, - canMoveOrganizeFiles: true, - canBulkLockUnlockMetadata: true - }, - userSettings: { - perBookSetting: {pdf: '', epub: '', cbx: ''}, - pdfReaderSetting: {pageSpread: 'off', pageZoom: '', showSidebar: false}, - epubReaderSetting: {theme: '', font: '', fontSize: 1, flow: '', spread: '', lineHeight: 1, margin: 1, letterSpacing: 1}, - cbxReaderSetting: {pageSpread: CbxPageSpread.EVEN, pageViewMode: CbxPageViewMode.SINGLE_PAGE, fitMode: CbxFitMode.ACTUAL_SIZE}, - newPdfReaderSetting: {pageSpread: PdfPageSpread.EVEN, pageViewMode: PdfPageViewMode.SINGLE_PAGE}, - sidebarLibrarySorting: {field: '', order: ''}, - sidebarShelfSorting: {field: '', order: ''}, - sidebarMagicShelfSorting: {field: '', order: ''}, - filterMode: 'and', - filterSortingMode: 'alphabetical', - metadataCenterViewMode: 'route', - enableSeriesView: true, - entityViewPreferences: {global: {sortKey: '', sortDir: 'ASC' as const, view: 'GRID' as const, coverSize: 1, seriesCollapsed: false}, overrides: []}, - koReaderEnabled: false, - autoSaveMetadata: false - } - }; - - httpClientMock.get.mockReturnValue(of(mockResponse)); - - service.getMyself().subscribe(user => { - requiredPermissions.forEach(permission => { - expect(user.permissions).toHaveProperty(permission); - expect(typeof user.permissions[permission as keyof typeof user.permissions]).toBe('boolean'); - }); - // Explicit test for canManageFonts - expect(user.permissions.canManageFonts).toBe(true); - }); - }); - - it('should validate all required UserSettings fields exist', () => { - const requiredSettings = [ - 'perBookSetting', 'pdfReaderSetting', 'epubReaderSetting', 'cbxReaderSetting', - 'newPdfReaderSetting', 'sidebarLibrarySorting', 'sidebarShelfSorting', - 'sidebarMagicShelfSorting', 'filterMode', 'filterSortingMode', - 'metadataCenterViewMode', 'enableSeriesView', 'entityViewPreferences', 'koReaderEnabled' - ]; - - const mockResponse: User = { - id: 1, - username: 'test', - name: 'Test', - email: 'test@test.com', - assignedLibraries: [], - permissions: { - admin: false, - canUpload: false, - canDownload: false, - canEmailBook: false, - canDeleteBook: false, - canEditMetadata: false, - canManageLibrary: false, - canManageMetadataConfig: false, - canSyncKoReader: false, - canSyncKobo: false, - canAccessOpds: false, - canAccessBookdrop: false, - canAccessLibraryStats: false, - canAccessUserStats: false, - canAccessTaskManager: false, - canManageEmailConfig: false, - canManageGlobalPreferences: false, - canManageIcons: false, - canManageFonts: false, - demoUser: false, - canBulkAutoFetchMetadata: false, - canBulkCustomFetchMetadata: false, - canBulkEditMetadata: false, - canBulkRegenerateCover: false, - canMoveOrganizeFiles: false, - canBulkLockUnlockMetadata: false - }, - userSettings: { - perBookSetting: {pdf: '', epub: '', cbx: ''}, - pdfReaderSetting: {pageSpread: 'off', pageZoom: '', showSidebar: false}, - epubReaderSetting: {theme: '', font: '', fontSize: 1, flow: '', spread: '', lineHeight: 1, margin: 1, letterSpacing: 1}, - cbxReaderSetting: {pageSpread: CbxPageSpread.EVEN, pageViewMode: CbxPageViewMode.SINGLE_PAGE, fitMode: CbxFitMode.ACTUAL_SIZE}, - newPdfReaderSetting: {pageSpread: PdfPageSpread.EVEN, pageViewMode: PdfPageViewMode.SINGLE_PAGE}, - sidebarLibrarySorting: {field: '', order: ''}, - sidebarShelfSorting: {field: '', order: ''}, - sidebarMagicShelfSorting: {field: '', order: ''}, - filterMode: 'and', - filterSortingMode: 'alphabetical', - metadataCenterViewMode: 'route', - enableSeriesView: true, - entityViewPreferences: {global: {sortKey: '', sortDir: 'ASC' as const, view: 'GRID' as const, coverSize: 1, seriesCollapsed: false}, overrides: []}, - koReaderEnabled: false, - autoSaveMetadata: false - } - }; - - httpClientMock.get.mockReturnValue(of(mockResponse)); - - service.getMyself().subscribe(user => { - requiredSettings.forEach(setting => { - expect(user.userSettings).toHaveProperty(setting); - expect(user.userSettings[setting as keyof typeof user.userSettings]).toBeDefined(); - }); - }); - }); - - it('should fail if API returns User without required id field', () => { - const invalidResponse = { - username: 'test', - name: 'Test', - email: 'test@test.com', - assignedLibraries: [], - permissions: {}, - userSettings: {} - }; - - httpClientMock.get.mockReturnValue(of(invalidResponse)); - - service.getMyself().subscribe(user => { - expect(user).not.toHaveProperty('id'); - }); - }); - - it('should fail if API returns User without permissions', () => { - const invalidResponse = { - id: 1, - username: 'test', - name: 'Test', - email: 'test@test.com', - assignedLibraries: [], - userSettings: {} - }; - - httpClientMock.get.mockReturnValue(of(invalidResponse)); - - service.getMyself().subscribe(user => { - expect(user).not.toHaveProperty('permissions'); - }); - }); - - it('should fail if API returns User without userSettings', () => { - const invalidResponse = { - id: 1, - username: 'test', - name: 'Test', - email: 'test@test.com', - assignedLibraries: [], - permissions: {} - }; - - httpClientMock.get.mockReturnValue(of(invalidResponse)); - - service.getMyself().subscribe(user => { - expect(user).not.toHaveProperty('userSettings'); - }); - }); - }); - - describe('Enum value contract', () => { - it('should validate CbxPageSpread enum values from API', () => { - const validValues = [CbxPageSpread.EVEN, CbxPageSpread.ODD]; - expect(validValues).toContain(CbxPageSpread.EVEN); - expect(validValues).toContain(CbxPageSpread.ODD); - expect(Object.keys(CbxPageSpread)).toHaveLength(2); - }); - - it('should validate CbxPageViewMode enum values from API', () => { - const validValues = [CbxPageViewMode.SINGLE_PAGE, CbxPageViewMode.TWO_PAGE]; - expect(validValues).toContain(CbxPageViewMode.SINGLE_PAGE); - expect(validValues).toContain(CbxPageViewMode.TWO_PAGE); - expect(Object.keys(CbxPageViewMode)).toHaveLength(2); - }); - - it('should validate CbxFitMode enum values from API', () => { - const validValues = [ - CbxFitMode.ACTUAL_SIZE, - CbxFitMode.FIT_PAGE, - CbxFitMode.FIT_WIDTH, - CbxFitMode.FIT_HEIGHT, - CbxFitMode.AUTO - ]; - validValues.forEach(value => { - expect(Object.values(CbxFitMode)).toContain(value); - }); - expect(Object.keys(CbxFitMode)).toHaveLength(5); - }); - - it('should validate PdfPageSpread enum values from API', () => { - const validValues = [PdfPageSpread.EVEN, PdfPageSpread.ODD]; - expect(validValues).toContain(PdfPageSpread.EVEN); - expect(validValues).toContain(PdfPageSpread.ODD); - expect(Object.keys(PdfPageSpread)).toHaveLength(2); - }); - - it('should validate PdfPageViewMode enum values from API', () => { - const validValues = [PdfPageViewMode.SINGLE_PAGE, PdfPageViewMode.TWO_PAGE]; - expect(validValues).toContain(PdfPageViewMode.SINGLE_PAGE); - expect(validValues).toContain(PdfPageViewMode.TWO_PAGE); - expect(Object.keys(PdfPageViewMode)).toHaveLength(2); - }); - }); - - describe('HTTP endpoint contract', () => { - it('should call correct endpoint for getMyself', () => { - httpClientMock.get.mockReturnValue(of({})); - service.getMyself().subscribe(); - expect(httpClientMock.get).toHaveBeenCalledWith(expect.stringMatching(/\/api\/v1\/users\/me$/)); - }); - - it('should call correct endpoint for getUsers', () => { - httpClientMock.get.mockReturnValue(of([])); - service.getUsers().subscribe(); - expect(httpClientMock.get).toHaveBeenCalledWith(expect.stringMatching(/\/api\/v1\/users$/)); - }); - - it('should call correct endpoint for createUser', () => { - httpClientMock.post.mockReturnValue(of(void 0)); - const userData = { - username: 'new', - name: 'New User', - email: 'new@test.com', - assignedLibraries: [], - permissions: { - admin: false, - canUpload: false, - canDownload: false, - canEmailBook: false, - canDeleteBook: false, - canEditMetadata: false, - canManageLibrary: false, - canManageMetadataConfig: false, - canSyncKoReader: false, - canSyncKobo: false, - canAccessOpds: false, - canAccessBookdrop: false, - canAccessLibraryStats: false, - canAccessUserStats: false, - canAccessTaskManager: false, - canManageEmailConfig: false, - canManageGlobalPreferences: false, - canManageIcons: false, - canManageFonts: false, - demoUser: false, - canBulkAutoFetchMetadata: false, - canBulkCustomFetchMetadata: false, - canBulkEditMetadata: false, - canBulkRegenerateCover: false, - canMoveOrganizeFiles: false, - canBulkLockUnlockMetadata: false - }, - userSettings: { - perBookSetting: {pdf: '', epub: '', cbx: ''}, - pdfReaderSetting: {pageSpread: 'off' as const, pageZoom: '', showSidebar: false}, - epubReaderSetting: {theme: '', font: '', fontSize: 1, flow: '', spread: '', lineHeight: 1, margin: 1, letterSpacing: 1}, - cbxReaderSetting: {pageSpread: CbxPageSpread.EVEN, pageViewMode: CbxPageViewMode.SINGLE_PAGE, fitMode: CbxFitMode.ACTUAL_SIZE}, - newPdfReaderSetting: {pageSpread: PdfPageSpread.EVEN, pageViewMode: PdfPageViewMode.SINGLE_PAGE}, - sidebarLibrarySorting: {field: '', order: ''}, - sidebarShelfSorting: {field: '', order: ''}, - sidebarMagicShelfSorting: {field: '', order: ''}, - filterMode: 'and' as const, - filterSortingMode: 'alphabetical' as const, - metadataCenterViewMode: 'route' as const, - enableSeriesView: true, - entityViewPreferences: {global: {sortKey: '', sortDir: 'ASC' as const, view: 'GRID' as const, coverSize: 1, seriesCollapsed: false}, overrides: []}, - koReaderEnabled: false, - autoSaveMetadata: false - } - }; - service.createUser(userData).subscribe(); - expect(httpClientMock.post).toHaveBeenCalledWith( - expect.stringMatching(/\/api\/v1\/auth\/register$/), - userData - ); - }); - - it('should call correct endpoint for updateUser', () => { - httpClientMock.put.mockReturnValue(of({})); - service.updateUser(123, {name: 'Updated'}).subscribe(); - expect(httpClientMock.put).toHaveBeenCalledWith( - expect.stringMatching(/\/api\/v1\/users\/123$/), - {name: 'Updated'} - ); - }); - - it('should call correct endpoint for deleteUser', () => { - httpClientMock.delete.mockReturnValue(of(void 0)); - service.deleteUser(123).subscribe(); - expect(httpClientMock.delete).toHaveBeenCalledWith(expect.stringMatching(/\/api\/v1\/users\/123$/)); - }); - - it('should call correct endpoint for changeUserPassword', () => { - httpClientMock.put.mockReturnValue(of(void 0)); - service.changeUserPassword(123, 'newpass').subscribe(); - expect(httpClientMock.put).toHaveBeenCalledWith( - expect.stringMatching(/\/api\/v1\/users\/change-user-password$/), - {userId: 123, newPassword: 'newpass'} - ); - }); - - it('should call correct endpoint for changePassword', () => { - httpClientMock.put.mockReturnValue(of(void 0)); - service.changePassword('oldpass', 'newpass').subscribe(); - expect(httpClientMock.put).toHaveBeenCalledWith( - expect.stringMatching(/\/api\/v1\/users\/change-password$/), - {currentPassword: 'oldpass', newPassword: 'newpass'} - ); - }); - - it('should call correct endpoint for updateUserSetting', () => { - httpClientMock.put.mockReturnValue(of(void 0)); - service.updateUserSetting(123, 'koReaderEnabled', true); - expect(httpClientMock.put).toHaveBeenCalledWith( - expect.stringMatching(/\/api\/v1\/users\/123\/settings$/), - {key: 'koReaderEnabled', value: true}, - expect.anything() - ); - }); - }); - - describe('Request payload contract', () => { - it('should send UserUpdateRequest with correct structure', () => { - httpClientMock.put.mockReturnValue(of({})); - const updateRequest: UserUpdateRequest = { - name: 'New Name', - email: 'newemail@test.com', - permissions: { - admin: true, - canUpload: true, - canDownload: true, - canEmailBook: true, - canDeleteBook: true, - canEditMetadata: true, - canManageLibrary: true, - canManageMetadataConfig: true, - canSyncKoReader: true, - canSyncKobo: true, - canAccessOpds: true, - canAccessBookdrop: true, - canAccessLibraryStats: true, - canAccessUserStats: true, - canAccessTaskManager: true, - canManageEmailConfig: true, - canManageGlobalPreferences: true, - canManageIcons: true, - canManageFonts: false, - demoUser: false, - canBulkAutoFetchMetadata: true, - canBulkCustomFetchMetadata: true, - canBulkEditMetadata: true, - canBulkRegenerateCover: true, - canMoveOrganizeFiles: true, - canBulkLockUnlockMetadata: true - }, - assignedLibraries: [1, 2, 3] - }; - service.updateUser(1, updateRequest).subscribe(); - expect(httpClientMock.put).toHaveBeenCalledWith( - expect.any(String), - updateRequest - ); - }); - - it('should send change password payload with correct structure', () => { - httpClientMock.put.mockReturnValue(of(void 0)); - service.changePassword('current', 'new').subscribe(); - expect(httpClientMock.put).toHaveBeenCalledWith( - expect.any(String), - {currentPassword: 'current', newPassword: 'new'} - ); - }); - - it('should send change user password payload with correct structure', () => { - httpClientMock.put.mockReturnValue(of(void 0)); - service.changeUserPassword(456, 'newpassword').subscribe(); - expect(httpClientMock.put).toHaveBeenCalledWith( - expect.any(String), - {userId: 456, newPassword: 'newpassword'} - ); - }); - - it('should send user setting payload with correct structure', () => { - httpClientMock.put.mockReturnValue(of(void 0)); - service.updateUserSetting(789, 'enableSeriesView', false); - expect(httpClientMock.put).toHaveBeenCalledWith( - expect.any(String), - {key: 'enableSeriesView', value: false}, - expect.anything() - ); - }); - }); - - describe('Response type contract', () => { - it('should expect User array from getUsers', () => { - const mockUsers: User[] = [{ - id: 1, - username: 'user1', - name: 'User One', - email: 'user1@test.com', - assignedLibraries: [], - permissions: { - admin: false, - canUpload: false, - canDownload: false, - canEmailBook: false, - canDeleteBook: false, - canEditMetadata: false, - canManageLibrary: false, - canManageMetadataConfig: false, - canSyncKoReader: false, - canSyncKobo: false, - canAccessOpds: false, - canAccessBookdrop: false, - canAccessLibraryStats: false, - canAccessUserStats: false, - canAccessTaskManager: false, - canManageEmailConfig: false, - canManageGlobalPreferences: false, - canManageIcons: false, - canManageFonts: false, - demoUser: false, - canBulkAutoFetchMetadata: false, - canBulkCustomFetchMetadata: false, - canBulkEditMetadata: false, - canBulkRegenerateCover: false, - canMoveOrganizeFiles: false, - canBulkLockUnlockMetadata: false - }, - userSettings: { - perBookSetting: {pdf: '', epub: '', cbx: ''}, - pdfReaderSetting: {pageSpread: 'off', pageZoom: '', showSidebar: false}, - epubReaderSetting: {theme: '', font: '', fontSize: 1, flow: '', spread: '', lineHeight: 1, margin: 1, letterSpacing: 1}, - cbxReaderSetting: {pageSpread: CbxPageSpread.EVEN, pageViewMode: CbxPageViewMode.SINGLE_PAGE, fitMode: CbxFitMode.ACTUAL_SIZE}, - newPdfReaderSetting: {pageSpread: PdfPageSpread.EVEN, pageViewMode: PdfPageViewMode.SINGLE_PAGE}, - sidebarLibrarySorting: {field: '', order: ''}, - sidebarShelfSorting: {field: '', order: ''}, - sidebarMagicShelfSorting: {field: '', order: ''}, - filterMode: 'and', - filterSortingMode: 'alphabetical', - metadataCenterViewMode: 'route', - enableSeriesView: true, - entityViewPreferences: {global: {sortKey: '', sortDir: 'ASC', view: 'GRID', coverSize: 1, seriesCollapsed: false}, overrides: []}, - koReaderEnabled: false, - autoSaveMetadata: false - } - }]; - - httpClientMock.get.mockReturnValue(of(mockUsers)); - service.getUsers().subscribe(users => { - expect(Array.isArray(users)).toBe(true); - expect(users[0]).toHaveProperty('id'); - expect(users[0]).toHaveProperty('username'); - }); - }); - - it('should expect User from updateUser', () => { - const mockUser: User = { - id: 1, - username: 'updated', - name: 'Updated User', - email: 'updated@test.com', - assignedLibraries: [], - permissions: { - admin: false, - canUpload: false, - canDownload: false, - canEmailBook: false, - canDeleteBook: false, - canEditMetadata: false, - canManageLibrary: false, - canManageMetadataConfig: false, - canSyncKoReader: false, - canSyncKobo: false, - canAccessOpds: false, - canAccessBookdrop: false, - canAccessLibraryStats: false, - canAccessUserStats: false, - canAccessTaskManager: false, - canManageEmailConfig: false, - canManageGlobalPreferences: false, - canManageIcons: false, - canManageFonts: false, - demoUser: false, - canBulkAutoFetchMetadata: false, - canBulkCustomFetchMetadata: false, - canBulkEditMetadata: false, - canBulkRegenerateCover: false, - canMoveOrganizeFiles: false, - canBulkLockUnlockMetadata: false - }, - userSettings: { - perBookSetting: {pdf: '', epub: '', cbx: ''}, - pdfReaderSetting: {pageSpread: 'off', pageZoom: '', showSidebar: false}, - epubReaderSetting: {theme: '', font: '', fontSize: 1, flow: '', spread: '', lineHeight: 1, margin: 1, letterSpacing: 1}, - cbxReaderSetting: {pageSpread: CbxPageSpread.EVEN, pageViewMode: CbxPageViewMode.SINGLE_PAGE, fitMode: CbxFitMode.ACTUAL_SIZE}, - newPdfReaderSetting: {pageSpread: PdfPageSpread.EVEN, pageViewMode: PdfPageViewMode.SINGLE_PAGE}, - sidebarLibrarySorting: {field: '', order: ''}, - sidebarShelfSorting: {field: '', order: ''}, - sidebarMagicShelfSorting: {field: '', order: ''}, - filterMode: 'and', - filterSortingMode: 'alphabetical', - metadataCenterViewMode: 'route', - enableSeriesView: true, - entityViewPreferences: {global: {sortKey: '', sortDir: 'ASC' as const, view: 'GRID' as const, coverSize: 1, seriesCollapsed: false}, overrides: []}, - koReaderEnabled: false, - autoSaveMetadata: false - } - }; - - httpClientMock.put.mockReturnValue(of(mockUser)); - service.updateUser(1, {name: 'Updated User'}).subscribe(user => { - expect(user).toHaveProperty('id'); - expect(user).toHaveProperty('username'); - expect(user).toHaveProperty('permissions'); - expect(user).toHaveProperty('userSettings'); - }); - }); - - it('should expect void from createUser', () => { - httpClientMock.post.mockReturnValue(of(void 0)); - const userData = { - username: 'new', - name: 'New', - email: 'new@test.com', - assignedLibraries: [], - permissions: { - admin: false, - canUpload: false, - canDownload: false, - canEmailBook: false, - canDeleteBook: false, - canEditMetadata: false, - canManageLibrary: false, - canManageMetadataConfig: false, - canSyncKoReader: false, - canSyncKobo: false, - canAccessOpds: false, - canAccessBookdrop: false, - canAccessLibraryStats: false, - canAccessUserStats: false, - canAccessTaskManager: false, - canManageEmailConfig: false, - canManageGlobalPreferences: false, - canManageIcons: false, - canManageFonts: false, - demoUser: false, - canBulkAutoFetchMetadata: false, - canBulkCustomFetchMetadata: false, - canBulkEditMetadata: false, - canBulkRegenerateCover: false, - canMoveOrganizeFiles: false, - canBulkLockUnlockMetadata: false - }, - userSettings: { - perBookSetting: {pdf: '', epub: '', cbx: ''}, - pdfReaderSetting: {pageSpread: 'off' as const, pageZoom: '', showSidebar: false}, - epubReaderSetting: {theme: '', font: '', fontSize: 1, flow: '', spread: '', lineHeight: 1, margin: 1, letterSpacing: 1}, - cbxReaderSetting: {pageSpread: CbxPageSpread.EVEN, pageViewMode: CbxPageViewMode.SINGLE_PAGE, fitMode: CbxFitMode.ACTUAL_SIZE}, - newPdfReaderSetting: {pageSpread: PdfPageSpread.EVEN, pageViewMode: PdfPageViewMode.SINGLE_PAGE}, - sidebarLibrarySorting: {field: '', order: ''}, - sidebarShelfSorting: {field: '', order: ''}, - sidebarMagicShelfSorting: {field: '', order: ''}, - filterMode: 'and' as const, - filterSortingMode: 'alphabetical' as const, - metadataCenterViewMode: 'route' as const, - enableSeriesView: true, - entityViewPreferences: {global: {sortKey: '', sortDir: 'ASC' as const, view: 'GRID' as const, coverSize: 1, seriesCollapsed: false}, overrides: []}, - koReaderEnabled: false, - autoSaveMetadata: false - } - }; - service.createUser(userData).subscribe(result => { - expect(result).toBeUndefined(); - }); - }); - }); -}); diff --git a/booklore-ui/src/app/features/settings/user-management/user.service.ts b/booklore-ui/src/app/features/settings/user-management/user.service.ts index 6750d21af..c152968bb 100644 --- a/booklore-ui/src/app/features/settings/user-management/user.service.ts +++ b/booklore-ui/src/app/features/settings/user-management/user.service.ts @@ -18,6 +18,7 @@ export interface EntityViewPreference { view: 'GRID' | 'TABLE'; coverSize: number; seriesCollapsed: boolean; + overlayBookType: boolean; } export interface EntityViewPreferenceOverride { @@ -96,6 +97,21 @@ export enum CbxScrollMode { INFINITE = 'INFINITE' } +export interface EbookReaderSetting { + lineHeight: number; + justify: boolean; + hyphenate: boolean; + maxColumnCount: number; + gap: number; + fontSize: number; + theme: string + maxInlineSize: number; + maxBlockSize: number; + fontFamily: string; + isDark: boolean; + flow: 'paginated' | 'scrolled'; +} + export interface EpubReaderSetting { theme: string; font: string; @@ -131,6 +147,7 @@ export interface UserSettings { perBookSetting: PerBookSetting; pdfReaderSetting: PdfReaderSetting; epubReaderSetting: EpubReaderSetting; + ebookReaderSetting: EbookReaderSetting; cbxReaderSetting: CbxReaderSetting; newPdfReaderSetting: NewPdfReaderSetting; sidebarLibrarySorting: SidebarLibrarySorting; diff --git a/booklore-ui/src/app/features/settings/view-preferences-parent/view-preferences/view-preferences.component.ts b/booklore-ui/src/app/features/settings/view-preferences-parent/view-preferences/view-preferences.component.ts index a09943464..2fcc08211 100644 --- a/booklore-ui/src/app/features/settings/view-preferences-parent/view-preferences/view-preferences.component.ts +++ b/booklore-ui/src/app/features/settings/view-preferences-parent/view-preferences/view-preferences.component.ts @@ -170,8 +170,8 @@ export class ViewPreferencesComponent implements OnInit, OnDestroy { options.map(opt => ({...opt, entityType})); return [...withEntityType(this.libraryOptions, 'LIBRARY'), - ...withEntityType(this.shelfOptions, 'SHELF'), - ...withEntityType(this.magicShelfOptions, 'MAGIC_SHELF')] + ...withEntityType(this.shelfOptions, 'SHELF'), + ...withEntityType(this.magicShelfOptions, 'MAGIC_SHELF')] .filter(opt => !used.has(`${opt.entityType}_${opt.value}`)); } @@ -217,7 +217,8 @@ export class ViewPreferencesComponent implements OnInit, OnDestroy { sortDir: o.sortDir, view: o.view, coverSize: existing?.coverSize ?? 1.0, - seriesCollapsed: existing?.seriesCollapsed ?? false + seriesCollapsed: existing?.seriesCollapsed ?? false, + overlayBookType: existing?.overlayBookType ?? true } }; }); diff --git a/booklore-ui/src/app/features/stats/service/book-size-chart.service.ts b/booklore-ui/src/app/features/stats/service/book-size-chart.service.ts index 2a743fe1e..4ae778f05 100644 --- a/booklore-ui/src/app/features/stats/service/book-size-chart.service.ts +++ b/booklore-ui/src/app/features/stats/service/book-size-chart.service.ts @@ -22,7 +22,10 @@ const BOOK_TYPE_COLORS = { 'CBZ': '#27a153', 'CBX': '#d4b50f', 'CBR': '#e67e22', - 'CB7': '#9b59b6' + 'CB7': '#9b59b6', + 'FB2': '#1abc9c', + 'MOBI': '#f39c12', + 'AZW3': '#2ecc71' } as const; const CHART_DEFAULTS = { diff --git a/booklore-ui/src/app/shared/components/book-uploader/book-uploader.component.html b/booklore-ui/src/app/shared/components/book-uploader/book-uploader.component.html index 706ceb4d6..97125235e 100644 --- a/booklore-ui/src/app/shared/components/book-uploader/book-uploader.component.html +++ b/booklore-ui/src/app/shared/components/book-uploader/book-uploader.component.html @@ -88,7 +88,7 @@ name="file" [customUpload]="true" [multiple]="true" - accept=".pdf,.epub,.cbz,.cbr,.cb7,.fb2" + accept=".pdf,.epub,.cbz,.cbr,.cb7,.fb2,.mobi,.azw,.azw3" (onSelect)="onFilesSelect($event)" (uploadHandler)="uploadFiles($event)" [disabled]="value === 'library' ? (!selectedLibrary || !selectedPath) : false"> diff --git a/booklore-ui/src/app/shared/service/reading-session.service.ts b/booklore-ui/src/app/shared/service/reading-session.service.ts index 8acd37ad3..7b1d8b4b5 100644 --- a/booklore-ui/src/app/shared/service/reading-session.service.ts +++ b/booklore-ui/src/app/shared/service/reading-session.service.ts @@ -181,9 +181,9 @@ export class ReadingSessionService { endTime: endTime.toISOString(), durationSeconds, durationFormatted: this.formatDuration(durationSeconds), - startProgress: session.startProgress, - endProgress: session.endProgress, - progressDelta: session.progressDelta, + startProgress: session.startProgress != null ? Math.round(session.startProgress * 100) / 100 : undefined, + endProgress: session.endProgress != null ? Math.round(session.endProgress * 100) / 100 : undefined, + progressDelta: session.progressDelta != null ? Math.round(session.progressDelta * 100) / 100 : undefined, startLocation: session.startLocation, endLocation: session.endLocation }; diff --git a/booklore-ui/src/assets/foliate/comic-book.js b/booklore-ui/src/assets/foliate/comic-book.js new file mode 100644 index 000000000..88c7a35a4 --- /dev/null +++ b/booklore-ui/src/assets/foliate/comic-book.js @@ -0,0 +1,45 @@ +export const makeComicBook = ({ entries, loadBlob, getSize }, file) => { + const cache = new Map() + const urls = new Map() + const load = async name => { + if (cache.has(name)) return cache.get(name) + const src = URL.createObjectURL(await loadBlob(name)) + const page = URL.createObjectURL( + new Blob([``], { type: 'text/html' })) + urls.set(name, [src, page]) + cache.set(name, page) + return page + } + const unload = name => { + urls.get(name)?.forEach?.(url => URL.revokeObjectURL(url)) + urls.delete(name) + cache.delete(name) + } + + const exts = ['.jpg', '.jpeg', '.png', '.gif', '.bmp', '.webp', '.svg', '.jxl', '.avif'] + const files = entries + .map(entry => entry.filename) + .filter(name => exts.some(ext => name.endsWith(ext))) + .sort() + if (!files.length) throw new Error('No supported image files in archive') + + const book = {} + book.getCover = () => loadBlob(files[0]) + book.metadata = { title: file.name } + book.sections = files.map(name => ({ + id: name, + load: () => load(name), + unload: () => unload(name), + size: getSize(name), + })) + book.toc = files.map(name => ({ label: name, href: name })) + book.rendition = { layout: 'pre-paginated' } + book.resolveHref = href => ({ index: book.sections.findIndex(s => s.id === href) }) + book.splitTOCHref = href => [href, null] + book.getTOCFragment = doc => doc.documentElement + book.destroy = () => { + for (const arr of urls.values()) + for (const url of arr) URL.revokeObjectURL(url) + } + return book +} diff --git a/booklore-ui/src/assets/foliate/dict.js b/booklore-ui/src/assets/foliate/dict.js new file mode 100644 index 000000000..9afa7ea1e --- /dev/null +++ b/booklore-ui/src/assets/foliate/dict.js @@ -0,0 +1,241 @@ +const decoder = new TextDecoder() +const decode = decoder.decode.bind(decoder) + +const concatTypedArray = (a, b) => { + const result = new a.constructor(a.length + b.length) + result.set(a) + result.set(b, a.length) + return result +} + +const strcmp = (a, b) => { + a = a.toLowerCase(), b = b.toLowerCase() + return a < b ? -1 : a > b ? 1 : 0 +} + +class DictZip { + #chlen + #chunks + #compressed + inflate + async load(file) { + const header = new DataView(await file.slice(0, 12).arrayBuffer()) + if (header.getUint8(0) !== 31 || header.getUint8(1) !== 139 + || header.getUint8(2) !== 8) throw new Error('Not a DictZip file') + const flg = header.getUint8(3) + if (!flg & 0b100) throw new Error('Missing FEXTRA flag') + + const xlen = header.getUint16(10, true) + const extra = new DataView(await file.slice(12, 12 + xlen).arrayBuffer()) + if (extra.getUint8(0) !== 82 || extra.getUint8(1) !== 65) + throw new Error('Subfield ID should be RA') + if (extra.getUint16(4, true) !== 1) throw new Error('Unsupported version') + + this.#chlen = extra.getUint16(6, true) + const chcnt = extra.getUint16(8, true) + this.#chunks = [] + for (let i = 0, chunkOffset = 0; i < chcnt; i++) { + const chunkSize = extra.getUint16(10 + 2 * i, true) + this.#chunks.push([chunkOffset, chunkSize]) + chunkOffset = chunkOffset + chunkSize + } + + // skip to compressed data + let offset = 12 + xlen + const max = Math.min(offset + 512, file.size) + const strArr = new Uint8Array(await file.slice(0, max).arrayBuffer()) + if (flg & 0b1000) { // fname + const i = strArr.indexOf(0, offset) + if (i < 0) throw new Error('Header too long') + offset = i + 1 + } + if (flg & 0b10000) { // fcomment + const i = strArr.indexOf(0, offset) + if (i < 0) throw new Error('Header too long') + offset = i + 1 + } + if (flg & 0b10) offset += 2 // fhcrc + this.#compressed = file.slice(offset) + } + async read(offset, size) { + const chunks = this.#chunks + const startIndex = Math.trunc(offset / this.#chlen) + const endIndex = Math.trunc((offset + size) / this.#chlen) + const buf = await this.#compressed.slice(chunks[startIndex][0], + chunks[endIndex][0] + chunks[endIndex][1]).arrayBuffer() + let arr = new Uint8Array() + for (let pos = 0, i = startIndex; i <= endIndex; i++) { + const data = new Uint8Array(buf, pos, chunks[i][1]) + arr = concatTypedArray(arr, await this.inflate(data)) + pos += chunks[i][1] + } + const startOffset = offset - startIndex * this.#chlen + return arr.subarray(startOffset, startOffset + size) + } +} + +class Index { + strcmp = strcmp + // binary search + bisect(query, start = 0, end = this.words.length - 1) { + if (end - start === 1) { + if (!this.strcmp(query, this.getWord(start))) return start + if (!this.strcmp(query, this.getWord(end))) return end + return null + } + const mid = Math.floor(start + (end - start) / 2) + const cmp = this.strcmp(query, this.getWord(mid)) + if (cmp < 0) return this.bisect(query, start, mid) + if (cmp > 0) return this.bisect(query, mid, end) + return mid + } + // check for multiple definitions + checkAdjacent(query, i) { + if (i == null) return [] + let j = i + const equals = i => { + const word = this.getWord(i) + return word ? this.strcmp(query, word) === 0 : false + } + while (equals(j - 1)) j-- + let k = i + while (equals(k + 1)) k++ + return j === k ? [i] : Array.from({ length: k + 1 - j }, (_, i) => j + i) + } + lookup(query) { + return this.checkAdjacent(query, this.bisect(query)) + } +} + +const decodeBase64Number = str => { + const { length } = str + let n = 0 + for (let i = 0; i < length; i++) { + const c = str.charCodeAt(i) + n += (c === 43 ? 62 // "+" + : c === 47 ? 63 // "/" + : c < 58 ? c + 4 // 0-9 -> 52-61 + : c < 91 ? c - 65 // A-Z -> 0-25 + : c - 71 // a-z -> 26-51 + ) * 64 ** (length - 1 - i) + } + return n +} + +class DictdIndex extends Index { + getWord(i) { + return this.words[i] + } + async load(file) { + const words = [] + const offsets = [] + const sizes = [] + for (const line of decode(await file.arrayBuffer()).split('\n')) { + const a = line.split('\t') + words.push(a[0]) + offsets.push(decodeBase64Number(a[1])) + sizes.push(decodeBase64Number(a[2])) + } + this.words = words + this.offsets = offsets + this.sizes = sizes + } +} + +export class DictdDict { + #dict = new DictZip() + #idx = new DictdIndex() + loadDict(file, inflate) { + this.#dict.inflate = inflate + return this.#dict.load(file) + } + async #readWord(i) { + const word = this.#idx.getWord(i) + const offset = this.#idx.offsets[i] + const size = this.#idx.sizes[i] + return { word, data: ['m', this.#dict.read(offset, size)] } + } + #readWords(arr) { + return Promise.all(arr.map(this.#readWord.bind(this))) + } + lookup(query) { + return this.#readWords(this.#idx.lookup(query)) + } +} + +class StarDictIndex extends Index { + isSyn + #arr + getWord(i) { + const word = this.words[i] + if (!word) return + return decode(this.#arr.subarray(word[0], word[1])) + } + async load(file) { + const { isSyn } = this + const buf = await file.arrayBuffer() + const arr = new Uint8Array(buf) + this.#arr = arr + const view = new DataView(buf) + const words = [] + const offsets = [] + const sizes = [] + for (let i = 0; i < arr.length;) { + const newI = arr.subarray(0, i + 256).indexOf(0, i) + if (newI < 0) throw new Error('Word too big') + words.push([i, newI]) + offsets.push(view.getUint32(newI + 1)) + if (isSyn) i = newI + 5 + else { + sizes.push(view.getUint32(newI + 5)) + i = newI + 9 + } + } + this.words = words + this.offsets = offsets + this.sizes = sizes + } +} + +export class StarDict { + #dict = new DictZip() + #idx = new StarDictIndex() + #syn = Object.assign(new StarDictIndex(), { isSyn: true }) + async loadIfo(file) { + const str = decode(await file.arrayBuffer()) + this.ifo = Object.fromEntries(str.split('\n').map(line => { + const sep = line.indexOf('=') + if (sep < 0) return + return [line.slice(0, sep), line.slice(sep + 1)] + }).filter(x => x)) + } + loadDict(file, inflate) { + this.#dict.inflate = inflate + return this.#dict.load(file) + } + loadIdx(file) { + return this.#idx.load(file) + } + loadSyn(file) { + if (file) return this.#syn.load(file) + } + async #readWord(i) { + const word = this.#idx.getWord(i) + const offset = this.#idx.offsets[i] + const size = this.#idx.sizes[i] + const data = await this.#dict.read(offset, size) + const seq = this.ifo.sametypesequence + if (!seq) throw new Error('TODO') + if (seq.length === 1) return { word, data: [[seq[0], data]] } + throw new Error('TODO') + } + #readWords(arr) { + return Promise.all(arr.map(this.#readWord.bind(this))) + } + lookup(query) { + return this.#readWords(this.#idx.lookup(query)) + } + synonyms(query) { + return this.#readWords(this.#syn.lookup(query).map(i => this.#syn.offsets[i])) + } +} diff --git a/booklore-ui/src/assets/foliate/epub.js b/booklore-ui/src/assets/foliate/epub.js new file mode 100644 index 000000000..0417d0d0e --- /dev/null +++ b/booklore-ui/src/assets/foliate/epub.js @@ -0,0 +1,1082 @@ +import * as CFI from './epubcfi.js' + +const NS = { + CONTAINER: 'urn:oasis:names:tc:opendocument:xmlns:container', + XHTML: 'http://www.w3.org/1999/xhtml', + OPF: 'http://www.idpf.org/2007/opf', + EPUB: 'http://www.idpf.org/2007/ops', + DC: 'http://purl.org/dc/elements/1.1/', + DCTERMS: 'http://purl.org/dc/terms/', + ENC: 'http://www.w3.org/2001/04/xmlenc#', + NCX: 'http://www.daisy.org/z3986/2005/ncx/', + XLINK: 'http://www.w3.org/1999/xlink', + SMIL: 'http://www.w3.org/ns/SMIL', +} + +const MIME = { + XML: 'application/xml', + NCX: 'application/x-dtbncx+xml', + XHTML: 'application/xhtml+xml', + HTML: 'text/html', + CSS: 'text/css', + SVG: 'image/svg+xml', + JS: /\/(x-)?(javascript|ecmascript)/, +} + +// https://www.w3.org/TR/epub-33/#sec-reserved-prefixes +const PREFIX = { + a11y: 'http://www.idpf.org/epub/vocab/package/a11y/#', + dcterms: 'http://purl.org/dc/terms/', + marc: 'http://id.loc.gov/vocabulary/', + media: 'http://www.idpf.org/epub/vocab/overlays/#', + onix: 'http://www.editeur.org/ONIX/book/codelists/current.html#', + rendition: 'http://www.idpf.org/vocab/rendition/#', + schema: 'http://schema.org/', + xsd: 'http://www.w3.org/2001/XMLSchema#', + msv: 'http://www.idpf.org/epub/vocab/structure/magazine/#', + prism: 'http://www.prismstandard.org/specifications/3.0/PRISM_CV_Spec_3.0.htm#', +} + +const RELATORS = { + art: 'artist', + aut: 'author', + clr: 'colorist', + edt: 'editor', + ill: 'illustrator', + nrt: 'narrator', + trl: 'translator', + pbl: 'publisher', +} + +const ONIX5 = { + '02': 'isbn', + '06': 'doi', + '15': 'isbn', + '26': 'doi', + '34': 'issn', +} + +// convert to camel case +const camel = x => x.toLowerCase().replace(/[-:](.)/g, (_, g) => g.toUpperCase()) + +// strip and collapse ASCII whitespace +// https://infra.spec.whatwg.org/#strip-and-collapse-ascii-whitespace +const normalizeWhitespace = str => str ? str + .replace(/[\t\n\f\r ]+/g, ' ') + .replace(/^[\t\n\f\r ]+/, '') + .replace(/[\t\n\f\r ]+$/, '') : '' + +const filterAttribute = (attr, value, isList) => isList + ? el => el.getAttribute(attr)?.split(/\s/)?.includes(value) + : typeof value === 'function' + ? el => value(el.getAttribute(attr)) + : el => el.getAttribute(attr) === value + +const getAttributes = (...xs) => el => + el ? Object.fromEntries(xs.map(x => [camel(x), el.getAttribute(x)])) : null + +const getElementText = el => normalizeWhitespace(el?.textContent) + +const childGetter = (doc, ns) => { + // ignore the namespace if it doesn't appear in document at all + const useNS = doc.lookupNamespaceURI(null) === ns || doc.lookupPrefix(ns) + const f = useNS + ? (el, name) => el => el.namespaceURI === ns && el.localName === name + : (el, name) => el => el.localName === name + return { + $: (el, name) => [...el.children].find(f(el, name)), + $$: (el, name) => [...el.children].filter(f(el, name)), + $$$: useNS + ? (el, name) => [...el.getElementsByTagNameNS(ns, name)] + : (el, name) => [...el.getElementsByTagName(name)], + } +} + +const resolveURL = (url, relativeTo) => { + try { + if (relativeTo.includes(':')) return new URL(url, relativeTo) + // the base needs to be a valid URL, so set a base URL and then remove it + const root = 'https://invalid.invalid/' + const obj = new URL(url, root + relativeTo) + obj.search = '' + return decodeURI(obj.href.replace(root, '')) + } catch(e) { + console.warn(e) + return url + } +} + +const isExternal = uri => /^(?!blob)\w+:/i.test(uri) + +// like `path.relative()` in Node.js +const pathRelative = (from, to) => { + if (!from) return to + const as = from.replace(/\/$/, '').split('/') + const bs = to.replace(/\/$/, '').split('/') + const i = (as.length > bs.length ? as : bs).findIndex((_, i) => as[i] !== bs[i]) + return i < 0 ? '' : Array(as.length - i).fill('..').concat(bs.slice(i)).join('/') +} + +const pathDirname = str => str.slice(0, str.lastIndexOf('/') + 1) + +// replace asynchronously and sequentially +// same technique as https://stackoverflow.com/a/48032528 +const replaceSeries = async (str, regex, f) => { + const matches = [] + str.replace(regex, (...args) => (matches.push(args), null)) + const results = [] + for (const args of matches) results.push(await f(...args)) + return str.replace(regex, () => results.shift()) +} + +const regexEscape = str => str.replace(/[-/\\^$*+?.()|[\]{}]/g, '\\$&') + +const tidy = obj => { + for (const [key, val] of Object.entries(obj)) + if (val == null) delete obj[key] + else if (Array.isArray(val)) { + obj[key] = val.filter(x => x).map(x => + typeof x === 'object' && !Array.isArray(x) ? tidy(x) : x) + if (!obj[key].length) delete obj[key] + else if (obj[key].length === 1) obj[key] = obj[key][0] + } + else if (typeof val === 'object') { + obj[key] = tidy(val) + if (!Object.keys(val).length) delete obj[key] + } + const keys = Object.keys(obj) + if (keys.length === 1 && keys[0] === 'name') return obj[keys[0]] + return obj +} + +// https://www.w3.org/TR/epub/#sec-prefix-attr +const getPrefixes = doc => { + const map = new Map(Object.entries(PREFIX)) + const value = doc.documentElement.getAttributeNS(NS.EPUB, 'prefix') + || doc.documentElement.getAttribute('prefix') + if (value) for (const [, prefix, url] of value + .matchAll(/(.+): +(.+)[ \t\r\n]*/g)) map.set(prefix, url) + return map +} + +// https://www.w3.org/TR/epub-rs/#sec-property-values +// but ignoring the case where the prefix is omitted +const getPropertyURL = (value, prefixes) => { + if (!value) return null + const [a, b] = value.split(':') + const prefix = b ? a : null + const reference = b ? b : a + const baseURL = prefixes.get(prefix) + return baseURL ? baseURL + reference : null +} + +const getMetadata = opf => { + const { $ } = childGetter(opf, NS.OPF) + const $metadata = $(opf.documentElement, 'metadata') + + // first pass: convert to JS objects + const els = Object.groupBy($metadata.children, el => + el.namespaceURI === NS.DC ? 'dc' + : el.namespaceURI === NS.OPF && el.localName === 'meta' ? + (el.hasAttribute('name') ? 'legacyMeta' : 'meta') : '') + const baseLang = $metadata.getAttribute('xml:lang') + ?? opf.documentElement.getAttribute('xml:lang') ?? 'und' + const prefixes = getPrefixes(opf) + const parse = el => { + const property = el.getAttribute('property') + const scheme = el.getAttribute('scheme') + return { + property: getPropertyURL(property, prefixes) ?? property, + scheme: getPropertyURL(scheme, prefixes) ?? scheme, + lang: el.getAttribute('xml:lang'), + value: getElementText(el), + props: getProperties(el), + // `opf:` attributes from EPUB 2 & EPUB 3.1 (removed in EPUB 3.2) + attrs: Object.fromEntries(Array.from(el.attributes) + .filter(attr => attr.namespaceURI === NS.OPF) + .map(attr => [attr.localName, attr.value])), + } + } + const refines = Map.groupBy(els.meta ?? [], el => el.getAttribute('refines')) + const getProperties = el => { + const els = refines.get(el ? '#' + el.getAttribute('id') : null) + if (!els) return null + return Object.groupBy(els.map(parse), x => x.property) + } + const dc = Object.fromEntries(Object.entries(Object.groupBy(els.dc, el => el.localName)) + .map(([name, els]) => [name, els.map(parse)])) + const properties = getProperties() ?? {} + const legacyMeta = Object.fromEntries(els.legacyMeta?.map(el => + [el.getAttribute('name'), el.getAttribute('content')]) ?? []) + + // second pass: map to webpub + const one = x => x?.[0]?.value + const prop = (x, p) => one(x?.props?.[p]) + const makeLanguageMap = x => { + if (!x) return null + const alts = x.props?.['alternate-script'] ?? [] + const altRep = x.attrs['alt-rep'] + if (!alts.length && (!x.lang || x.lang === baseLang) && !altRep) return x.value + const map = { [x.lang ?? baseLang]: x.value } + if (altRep) map[x.attrs['alt-rep-lang']] = altRep + for (const y of alts) map[y.lang] ??= y.value + return map + } + const makeContributor = x => x ? ({ + name: makeLanguageMap(x), + sortAs: makeLanguageMap(x.props?.['file-as']?.[0]) ?? x.attrs['file-as'], + role: x.props?.role?.filter(x => x.scheme === PREFIX.marc + 'relators') + ?.map(x => x.value) ?? [x.attrs.role], + code: prop(x, 'term') ?? x.attrs.term, + scheme: prop(x, 'authority') ?? x.attrs.authority, + }) : null + const makeCollection = x => ({ + name: makeLanguageMap(x), + // NOTE: webpub requires number but EPUB allows values like "2.2.1" + position: one(x.props?.['group-position']), + }) + const makeAltIdentifier = x => { + const { value } = x + if (/^urn:/i.test(value)) return value + if (/^doi:/i.test(value)) return `urn:${value}` + const type = x.props?.['identifier-type'] + if (!type) { + const scheme = x.attrs.scheme + if (!scheme) return value + // https://idpf.github.io/epub-registries/identifiers/ + // but no "jdcn", which isn't a registered URN namespace + if (/^(doi|isbn|uuid)$/i.test(scheme)) return `urn:${scheme}:${value}` + // NOTE: webpub requires scheme to be a URI; EPUB allows anything + return { scheme, value } + } + if (type.scheme === PREFIX.onix + 'codelist5') { + const nid = ONIX5[type.value] + if (nid) return `urn:${nid}:${value}` + } + return value + } + const belongsTo = Object.groupBy(properties['belongs-to-collection'] ?? [], + x => prop(x, 'collection-type') === 'series' ? 'series' : 'collection') + const mainTitle = dc.title?.find(x => prop(x, 'title-type') === 'main') ?? dc.title?.[0] + const metadata = { + identifier: getIdentifier(opf), + title: makeLanguageMap(mainTitle), + sortAs: makeLanguageMap(mainTitle?.props?.['file-as']?.[0]) + ?? mainTitle?.attrs?.['file-as'] + ?? legacyMeta?.['calibre:title_sort'], + subtitle: dc.title?.find(x => prop(x, 'title-type') === 'subtitle')?.value, + language: dc.language?.map(x => x.value), + description: one(dc.description), + publisher: makeContributor(dc.publisher?.[0]), + published: dc.date?.find(x => x.attrs.event === 'publication')?.value + ?? one(dc.date), + modified: one(properties[PREFIX.dcterms + 'modified']) + ?? dc.date?.find(x => x.attrs.event === 'modification')?.value, + subject: dc.subject?.map(makeContributor), + belongsTo: { + collection: belongsTo.collection?.map(makeCollection), + series: belongsTo.series?.map(makeCollection) + ?? legacyMeta?.['calibre:series'] ? { + name: legacyMeta?.['calibre:series'], + position: parseFloat(legacyMeta?.['calibre:series_index']), + } : null, + }, + altIdentifier: dc.identifier?.map(makeAltIdentifier), + source: dc.source?.map(makeAltIdentifier), // NOTE: not in webpub schema + rights: one(dc.rights), // NOTE: not in webpub schema + } + const remapContributor = defaultKey => x => { + const keys = new Set(x.role?.map(role => RELATORS[role] ?? defaultKey)) + return [keys.size ? keys : [defaultKey], x] + } + for (const [keys, val] of [].concat( + dc.creator?.map(makeContributor)?.map(remapContributor('author')) ?? [], + dc.contributor?.map(makeContributor)?.map(remapContributor('contributor')) ?? [])) + for (const key of keys) + if (metadata[key]) metadata[key].push(val) + else metadata[key] = [val] + tidy(metadata) + if (metadata.altIdentifier === metadata.identifier) + delete metadata.altIdentifier + + const rendition = {} + const media = {} + for (const [key, val] of Object.entries(properties)) { + if (key.startsWith(PREFIX.rendition)) + rendition[camel(key.replace(PREFIX.rendition, ''))] = one(val) + else if (key.startsWith(PREFIX.media)) + media[camel(key.replace(PREFIX.media, ''))] = one(val) + } + if (media.duration) media.duration = parseClock(media.duration) + return { metadata, rendition, media } +} + +const parseNav = (doc, resolve = f => f) => { + const { $, $$, $$$ } = childGetter(doc, NS.XHTML) + const resolveHref = href => href ? decodeURI(resolve(href)) : null + const parseLI = getType => $li => { + const $a = $($li, 'a') ?? $($li, 'span') + const $ol = $($li, 'ol') + const href = resolveHref($a?.getAttribute('href')) + const label = getElementText($a) || $a?.getAttribute('title') + // TODO: get and concat alt/title texts in content + const result = { label, href, subitems: parseOL($ol) } + if (getType) result.type = $a?.getAttributeNS(NS.EPUB, 'type')?.split(/\s/) + return result + } + const parseOL = ($ol, getType) => $ol ? $$($ol, 'li').map(parseLI(getType)) : null + const parseNav = ($nav, getType) => parseOL($($nav, 'ol'), getType) + + const $$nav = $$$(doc, 'nav') + let toc = null, pageList = null, landmarks = null, others = [] + for (const $nav of $$nav) { + const type = $nav.getAttributeNS(NS.EPUB, 'type')?.split(/\s/) ?? [] + if (type.includes('toc')) toc ??= parseNav($nav) + else if (type.includes('page-list')) pageList ??= parseNav($nav) + else if (type.includes('landmarks')) landmarks ??= parseNav($nav, true) + else others.push({ + label: getElementText($nav.firstElementChild), type, + list: parseNav($nav), + }) + } + return { toc, pageList, landmarks, others } +} + +const parseNCX = (doc, resolve = f => f) => { + const { $, $$ } = childGetter(doc, NS.NCX) + const resolveHref = href => href ? decodeURI(resolve(href)) : null + const parseItem = el => { + const $label = $(el, 'navLabel') + const $content = $(el, 'content') + const label = getElementText($label) + const href = resolveHref($content.getAttribute('src')) + if (el.localName === 'navPoint') { + const els = $$(el, 'navPoint') + return { label, href, subitems: els.length ? els.map(parseItem) : null } + } + return { label, href } + } + const parseList = (el, itemName) => $$(el, itemName).map(parseItem) + const getSingle = (container, itemName) => { + const $container = $(doc.documentElement, container) + return $container ? parseList($container, itemName) : null + } + return { + toc: getSingle('navMap', 'navPoint'), + pageList: getSingle('pageList', 'pageTarget'), + others: $$(doc.documentElement, 'navList').map(el => ({ + label: getElementText($(el, 'navLabel')), + list: parseList(el, 'navTarget'), + })), + } +} + +const parseClock = str => { + if (!str) return + const parts = str.split(':').map(x => parseFloat(x)) + if (parts.length === 3) { + const [h, m, s] = parts + return h * 60 * 60 + m * 60 + s + } + if (parts.length === 2) { + const [m, s] = parts + return m * 60 + s + } + const [x, unit] = str.split(/(?=[^\d.])/) + const n = parseFloat(x) + const f = unit === 'h' ? 60 * 60 + : unit === 'min' ? 60 + : unit === 'ms' ? .001 + : 1 + return n * f +} + +class MediaOverlay extends EventTarget { + #entries + #lastMediaOverlayItem + #sectionIndex + #audioIndex + #itemIndex + #audio + #volume = 1 + #rate = 1 + #state + constructor(book, loadXML) { + super() + this.book = book + this.loadXML = loadXML + } + async #loadSMIL(item) { + if (this.#lastMediaOverlayItem === item) return + const doc = await this.loadXML(item.href) + const resolve = href => href ? resolveURL(href, item.href) : null + const { $, $$$ } = childGetter(doc, NS.SMIL) + this.#audioIndex = -1 + this.#itemIndex = -1 + this.#entries = $$$(doc, 'par').reduce((arr, $par) => { + const text = resolve($($par, 'text')?.getAttribute('src')) + const $audio = $($par, 'audio') + if (!text || !$audio) return arr + const src = resolve($audio.getAttribute('src')) + const begin = parseClock($audio.getAttribute('clipBegin')) + const end = parseClock($audio.getAttribute('clipEnd')) + const last = arr.at(-1) + if (last?.src === src) last.items.push({ text, begin, end }) + else arr.push({ src, items: [{ text, begin, end }] }) + return arr + }, []) + this.#lastMediaOverlayItem = item + } + get #activeAudio() { + return this.#entries[this.#audioIndex] + } + get #activeItem() { + return this.#activeAudio?.items?.[this.#itemIndex] + } + #error(e) { + console.error(e) + this.dispatchEvent(new CustomEvent('error', { detail: e })) + } + #highlight() { + this.dispatchEvent(new CustomEvent('highlight', { detail: this.#activeItem })) + } + #unhighlight() { + this.dispatchEvent(new CustomEvent('unhighlight', { detail: this.#activeItem })) + } + async #play(audioIndex, itemIndex) { + this.#stop() + this.#audioIndex = audioIndex + this.#itemIndex = itemIndex + const src = this.#activeAudio?.src + if (!src || !this.#activeItem) return this.start(this.#sectionIndex + 1) + + const url = URL.createObjectURL(await this.book.loadBlob(src)) + const audio = new Audio(url) + this.#audio = audio + audio.volume = this.#volume + audio.playbackRate = this.#rate + audio.addEventListener('timeupdate', () => { + if (audio.paused) return + const t = audio.currentTime + const { items } = this.#activeAudio + if (t > this.#activeItem?.end) { + this.#unhighlight() + if (this.#itemIndex === items.length - 1) { + this.#play(this.#audioIndex + 1, 0).catch(e => this.#error(e)) + return + } + } + const oldIndex = this.#itemIndex + while (items[this.#itemIndex + 1]?.begin <= t) this.#itemIndex++ + if (this.#itemIndex !== oldIndex) this.#highlight() + }) + audio.addEventListener('error', () => + this.#error(new Error(`Failed to load ${src}`))) + audio.addEventListener('playing', () => this.#highlight()) + audio.addEventListener('ended', () => { + this.#unhighlight() + URL.revokeObjectURL(url) + this.#audio = null + this.#play(audioIndex + 1, 0).catch(e => this.#error(e)) + }) + if (this.#state === 'paused') { + this.#highlight() + audio.currentTime = this.#activeItem.begin ?? 0 + } + else audio.addEventListener('canplaythrough', () => { + // for some reason need to seek in `canplaythrough` + // or it won't play when skipping in WebKit + audio.currentTime = this.#activeItem.begin ?? 0 + this.#state = 'playing' + audio.play().catch(e => this.#error(e)) + }, { once: true }) + } + async start(sectionIndex, filter = () => true) { + this.#audio?.pause() + const section = this.book.sections[sectionIndex] + const href = section?.id + if (!href) return + + const { mediaOverlay } = section + if (!mediaOverlay) return this.start(sectionIndex + 1) + this.#sectionIndex = sectionIndex + await this.#loadSMIL(mediaOverlay) + + for (let i = 0; i < this.#entries.length; i++) { + const { items } = this.#entries[i] + for (let j = 0; j < items.length; j++) { + if (items[j].text.split('#')[0] === href && filter(items[j], j, items)) + return this.#play(i, j).catch(e => this.#error(e)) + } + } + } + pause() { + this.#state = 'paused' + this.#audio?.pause() + } + resume() { + this.#state = 'playing' + this.#audio?.play().catch(e => this.#error(e)) + } + #stop() { + if (this.#audio) { + this.#audio.pause() + URL.revokeObjectURL(this.#audio.src) + this.#audio = null + this.#unhighlight() + } + } + stop() { + this.#state = 'stopped' + this.#stop() + } + prev() { + if (this.#itemIndex > 0) this.#play(this.#audioIndex, this.#itemIndex - 1) + else if (this.#audioIndex > 0) this.#play(this.#audioIndex - 1, + this.#entries[this.#audioIndex - 1].items.length - 1) + else if (this.#sectionIndex > 0) + this.start(this.#sectionIndex - 1, (_, i, items) => i === items.length - 1) + } + next() { + this.#play(this.#audioIndex, this.#itemIndex + 1) + } + setVolume(volume) { + this.#volume = volume + if (this.#audio) this.#audio.volume = volume + } + setRate(rate) { + this.#rate = rate + if (this.#audio) this.#audio.playbackRate = rate + } +} + +const isUUID = /([0-9a-f]{8})-([0-9a-f]{4})-([0-9a-f]{4})-([0-9a-f]{4})-([0-9a-f]{12})/ + +const getUUID = opf => { + for (const el of opf.getElementsByTagNameNS(NS.DC, 'identifier')) { + const [id] = getElementText(el).split(':').slice(-1) + if (isUUID.test(id)) return id + } + return '' +} + +const getIdentifier = opf => getElementText( + opf.getElementById(opf.documentElement.getAttribute('unique-identifier')) + ?? opf.getElementsByTagNameNS(NS.DC, 'identifier')[0]) + +// https://www.w3.org/publishing/epub32/epub-ocf.html#sec-resource-obfuscation +const deobfuscate = async (key, length, blob) => { + const array = new Uint8Array(await blob.slice(0, length).arrayBuffer()) + length = Math.min(length, array.length) + for (var i = 0; i < length; i++) array[i] = array[i] ^ key[i % key.length] + return new Blob([array, blob.slice(length)], { type: blob.type }) +} + +const WebCryptoSHA1 = async str => { + const data = new TextEncoder().encode(str) + const buffer = await globalThis.crypto.subtle.digest('SHA-1', data) + return new Uint8Array(buffer) +} + +const deobfuscators = (sha1 = WebCryptoSHA1) => ({ + 'http://www.idpf.org/2008/embedding': { + key: opf => sha1(getIdentifier(opf) + // eslint-disable-next-line no-control-regex + .replaceAll(/[\u0020\u0009\u000d\u000a]/g, '')), + decode: (key, blob) => deobfuscate(key, 1040, blob), + }, + 'http://ns.adobe.com/pdf/enc#RC': { + key: opf => { + const uuid = getUUID(opf).replaceAll('-', '') + return Uint8Array.from({ length: 16 }, (_, i) => + parseInt(uuid.slice(i * 2, i * 2 + 2), 16)) + }, + decode: (key, blob) => deobfuscate(key, 1024, blob), + }, +}) + +class Encryption { + #uris = new Map() + #decoders = new Map() + #algorithms + constructor(algorithms) { + this.#algorithms = algorithms + } + async init(encryption, opf) { + if (!encryption) return + const data = Array.from( + encryption.getElementsByTagNameNS(NS.ENC, 'EncryptedData'), el => ({ + algorithm: el.getElementsByTagNameNS(NS.ENC, 'EncryptionMethod')[0] + ?.getAttribute('Algorithm'), + uri: el.getElementsByTagNameNS(NS.ENC, 'CipherReference')[0] + ?.getAttribute('URI'), + })) + for (const { algorithm, uri } of data) { + if (!this.#decoders.has(algorithm)) { + const algo = this.#algorithms[algorithm] + if (!algo) { + console.warn('Unknown encryption algorithm') + continue + } + const key = await algo.key(opf) + this.#decoders.set(algorithm, blob => algo.decode(key, blob)) + } + this.#uris.set(uri, algorithm) + } + } + getDecoder(uri) { + return this.#decoders.get(this.#uris.get(uri)) ?? (x => x) + } +} + +class Resources { + constructor({ opf, resolveHref }) { + this.opf = opf + const { $, $$, $$$ } = childGetter(opf, NS.OPF) + + const $manifest = $(opf.documentElement, 'manifest') + const $spine = $(opf.documentElement, 'spine') + const $$itemref = $$($spine, 'itemref') + + this.manifest = $$($manifest, 'item') + .map(getAttributes('href', 'id', 'media-type', 'properties', 'media-overlay')) + .map(item => { + item.href = resolveHref(item.href) + item.properties = item.properties?.split(/\s/) + return item + }) + this.manifestById = new Map(this.manifest.map(item => [item.id, item])) + this.spine = $$itemref + .map(getAttributes('idref', 'id', 'linear', 'properties')) + .map(item => (item.properties = item.properties?.split(/\s/), item)) + this.pageProgressionDirection = $spine + .getAttribute('page-progression-direction') + + this.navPath = this.getItemByProperty('nav')?.href + this.ncxPath = (this.getItemByID($spine.getAttribute('toc')) + ?? this.manifest.find(item => item.mediaType === MIME.NCX))?.href + + const $guide = $(opf.documentElement, 'guide') + if ($guide) this.guide = $$($guide, 'reference') + .map(getAttributes('type', 'title', 'href')) + .map(({ type, title, href }) => ({ + label: title, + type: type.split(/\s/), + href: resolveHref(href), + })) + + this.cover = this.getItemByProperty('cover-image') + // EPUB 2 compat + ?? this.getItemByID($$$(opf, 'meta') + .find(filterAttribute('name', 'cover')) + ?.getAttribute('content')) + ?? this.getItemByHref(this.guide + ?.find(ref => ref.type.includes('cover'))?.href) + + this.cfis = CFI.fromElements($$itemref) + } + getItemByID(id) { + return this.manifestById.get(id) + } + getItemByHref(href) { + return this.manifest.find(item => item.href === href) + } + getItemByProperty(prop) { + return this.manifest.find(item => item.properties?.includes(prop)) + } + resolveCFI(cfi) { + const parts = CFI.parse(cfi) + const top = (parts.parent ?? parts).shift() + let $itemref = CFI.toElement(this.opf, top) + // make sure it's an idref; if not, try again without the ID assertion + // mainly because Epub.js used to generate wrong ID assertions + // https://github.com/futurepress/epub.js/issues/1236 + if ($itemref && $itemref.nodeName !== 'idref') { + top.at(-1).id = null + $itemref = CFI.toElement(this.opf, top) + } + const idref = $itemref?.getAttribute('idref') + const index = this.spine.findIndex(item => item.idref === idref) + const anchor = doc => CFI.toRange(doc, parts) + return { index, anchor } + } +} + +class Loader { + #cache = new Map() + #children = new Map() + #refCount = new Map() + eventTarget = new EventTarget() + constructor({ loadText, loadBlob, resources }) { + this.loadText = loadText + this.loadBlob = loadBlob + this.manifest = resources.manifest + this.assets = resources.manifest + // needed only when replacing in (X)HTML w/o parsing (see below) + //.filter(({ mediaType }) => ![MIME.XHTML, MIME.HTML].includes(mediaType)) + } + async createURL(href, data, type, parent) { + if (!data) return '' + const detail = { data, type } + Object.defineProperty(detail, 'name', { value: href }) // readonly + const event = new CustomEvent('data', { detail }) + this.eventTarget.dispatchEvent(event) + const newData = await event.detail.data + const newType = await event.detail.type + const url = URL.createObjectURL(new Blob([newData], { type: newType })) + this.#cache.set(href, url) + this.#refCount.set(href, 1) + if (parent) { + const childList = this.#children.get(parent) + if (childList) childList.push(href) + else this.#children.set(parent, [href]) + } + return url + } + ref(href, parent) { + const childList = this.#children.get(parent) + if (!childList?.includes(href)) { + this.#refCount.set(href, this.#refCount.get(href) + 1) + //console.log(`referencing ${href}, now ${this.#refCount.get(href)}`) + if (childList) childList.push(href) + else this.#children.set(parent, [href]) + } + return this.#cache.get(href) + } + unref(href) { + if (!this.#refCount.has(href)) return + const count = this.#refCount.get(href) - 1 + //console.log(`unreferencing ${href}, now ${count}`) + if (count < 1) { + //console.log(`unloading ${href}`) + URL.revokeObjectURL(this.#cache.get(href)) + this.#cache.delete(href) + this.#refCount.delete(href) + // unref children + const childList = this.#children.get(href) + if (childList) while (childList.length) this.unref(childList.pop()) + this.#children.delete(href) + } else this.#refCount.set(href, count) + } + // load manifest item, recursively loading all resources as needed + async loadItem(item, parents = []) { + if (!item) return null + const { href, mediaType } = item + + const isScript = MIME.JS.test(item.mediaType) + const detail = { type: mediaType, isScript, allow: true} + const event = new CustomEvent('load', { detail }) + this.eventTarget.dispatchEvent(event) + const allow = await event.detail.allow + if (!allow) return null + + const parent = parents.at(-1) + if (this.#cache.has(href)) return this.ref(href, parent) + + const shouldReplace = + (isScript || [MIME.XHTML, MIME.HTML, MIME.CSS, MIME.SVG].includes(mediaType)) + // prevent circular references + && parents.every(p => p !== href) + if (shouldReplace) return this.loadReplaced(item, parents) + // NOTE: this can be replaced with `Promise.try()` + const tryLoadBlob = Promise.resolve().then(() => this.loadBlob(href)) + return this.createURL(href, tryLoadBlob, mediaType, parent) + } + async loadHref(href, base, parents = []) { + if (isExternal(href)) return href + const path = resolveURL(href, base) + const item = this.manifest.find(item => item.href === path) + if (!item) return href + return this.loadItem(item, parents.concat(base)) + } + async loadReplaced(item, parents = []) { + const { href, mediaType } = item + const parent = parents.at(-1) + let str = '' + try { + str = await this.loadText(href) + } catch (e) { + return this.createURL(href, Promise.reject(e), mediaType, parent) + } + if (!str) return null + + // note that one can also just use `replaceString` for everything: + // ``` + // const replaced = await this.replaceString(str, href, parents) + // return this.createURL(href, replaced, mediaType, parent) + // ``` + // which is basically what Epub.js does, which is simpler, but will + // break things like iframes (because you don't want to replace links) + // or text that just happen to be paths + + // parse and replace in HTML + if ([MIME.XHTML, MIME.HTML, MIME.SVG].includes(mediaType)) { + let doc = new DOMParser().parseFromString(str, mediaType) + // change to HTML if it's not valid XHTML + if (mediaType === MIME.XHTML && (doc.querySelector('parsererror') + || !doc.documentElement?.namespaceURI)) { + console.warn(doc.querySelector('parsererror')?.innerText ?? 'Invalid XHTML') + item.mediaType = MIME.HTML + doc = new DOMParser().parseFromString(str, item.mediaType) + } + // replace hrefs in XML processing instructions + // this is mainly for SVGs that use xml-stylesheet + if ([MIME.XHTML, MIME.SVG].includes(item.mediaType)) { + let child = doc.firstChild + while (child instanceof ProcessingInstruction) { + if (child.data) { + const replacedData = await replaceSeries(child.data, + /(?:^|\s*)(href\s*=\s*['"])([^'"]*)(['"])/i, + (_, p1, p2, p3) => this.loadHref(p2, href, parents) + .then(p2 => `${p1}${p2}${p3}`)) + child.replaceWith(doc.createProcessingInstruction( + child.target, replacedData)) + } + child = child.nextSibling + } + } + // replace hrefs (excluding anchors) + const replace = async (el, attr) => el.setAttribute(attr, + await this.loadHref(el.getAttribute(attr), href, parents)) + for (const el of doc.querySelectorAll('link[href]')) await replace(el, 'href') + for (const el of doc.querySelectorAll('[src]')) await replace(el, 'src') + for (const el of doc.querySelectorAll('[poster]')) await replace(el, 'poster') + for (const el of doc.querySelectorAll('object[data]')) await replace(el, 'data') + for (const el of doc.querySelectorAll('[*|href]:not([href])')) + el.setAttributeNS(NS.XLINK, 'href', await this.loadHref( + el.getAttributeNS(NS.XLINK, 'href'), href, parents)) + for (const el of doc.querySelectorAll('[srcset]')) + el.setAttribute('srcset', await replaceSeries(el.getAttribute('srcset'), + /(\s*)(.+?)\s*((?:\s[\d.]+[wx])+\s*(?:,|$)|,\s+|$)/g, + (_, p1, p2, p3) => this.loadHref(p2, href, parents) + .then(p2 => `${p1}${p2}${p3}`))) + // replace inline styles + for (const el of doc.querySelectorAll('style')) + if (el.textContent) el.textContent = + await this.replaceCSS(el.textContent, href, parents) + for (const el of doc.querySelectorAll('[style]')) + el.setAttribute('style', + await this.replaceCSS(el.getAttribute('style'), href, parents)) + // TODO: replace inline scripts? probably not worth the trouble + const result = new XMLSerializer().serializeToString(doc) + return this.createURL(href, result, item.mediaType, parent) + } + + const result = mediaType === MIME.CSS + ? await this.replaceCSS(str, href, parents) + : await this.replaceString(str, href, parents) + return this.createURL(href, result, mediaType, parent) + } + async replaceCSS(str, href, parents = []) { + const replacedUrls = await replaceSeries(str, + /url\(\s*["']?([^'"\n]*?)\s*["']?\s*\)/gi, + (_, url) => this.loadHref(url, href, parents) + .then(url => `url("${url}")`)) + // apart from `url()`, strings can be used for `@import` (but why?!) + return replaceSeries(replacedUrls, + /@import\s*["']([^"'\n]*?)["']/gi, + (_, url) => this.loadHref(url, href, parents) + .then(url => `@import "${url}"`)) + } + // find & replace all possible relative paths for all assets without parsing + replaceString(str, href, parents = []) { + const assetMap = new Map() + const urls = this.assets.map(asset => { + // do not replace references to the file itself + if (asset.href === href) return + // href was decoded and resolved when parsing the manifest + const relative = pathRelative(pathDirname(href), asset.href) + const relativeEnc = encodeURI(relative) + const rootRelative = '/' + asset.href + const rootRelativeEnc = encodeURI(rootRelative) + const set = new Set([relative, relativeEnc, rootRelative, rootRelativeEnc]) + for (const url of set) assetMap.set(url, asset) + return Array.from(set) + }).flat().filter(x => x) + if (!urls.length) return str + const regex = new RegExp(urls.map(regexEscape).join('|'), 'g') + return replaceSeries(str, regex, async match => + this.loadItem(assetMap.get(match.replace(/^\//, '')), + parents.concat(href))) + } + unloadItem(item) { + this.unref(item?.href) + } + destroy() { + for (const url of this.#cache.values()) URL.revokeObjectURL(url) + } +} + +const getHTMLFragment = (doc, id) => doc.getElementById(id) + ?? doc.querySelector(`[name="${CSS.escape(id)}"]`) + +const getPageSpread = properties => { + for (const p of properties) { + if (p === 'page-spread-left' || p === 'rendition:page-spread-left') + return 'left' + if (p === 'page-spread-right' || p === 'rendition:page-spread-right') + return 'right' + if (p === 'rendition:page-spread-center') return 'center' + } +} + +const getDisplayOptions = doc => { + if (!doc) return null + return { + fixedLayout: getElementText(doc.querySelector('option[name="fixed-layout"]')), + openToSpread: getElementText(doc.querySelector('option[name="open-to-spread"]')), + } +} + +export class EPUB { + parser = new DOMParser() + #loader + #encryption + constructor({ loadText, loadBlob, getSize, sha1 }) { + this.loadText = loadText + this.loadBlob = loadBlob + this.getSize = getSize + this.#encryption = new Encryption(deobfuscators(sha1)) + } + async #loadXML(uri) { + const str = await this.loadText(uri) + if (!str) return null + const doc = this.parser.parseFromString(str, MIME.XML) + if (doc.querySelector('parsererror')) + throw new Error(`XML parsing error: ${uri} +${doc.querySelector('parsererror').innerText}`) + return doc + } + async init() { + const $container = await this.#loadXML('META-INF/container.xml') + if (!$container) throw new Error('Failed to load container file') + + const opfs = Array.from( + $container.getElementsByTagNameNS(NS.CONTAINER, 'rootfile'), + getAttributes('full-path', 'media-type')) + .filter(file => file.mediaType === 'application/oebps-package+xml') + + if (!opfs.length) throw new Error('No package document defined in container') + const opfPath = opfs[0].fullPath + const opf = await this.#loadXML(opfPath) + if (!opf) throw new Error('Failed to load package document') + + const $encryption = await this.#loadXML('META-INF/encryption.xml') + await this.#encryption.init($encryption, opf) + + this.resources = new Resources({ + opf, + resolveHref: url => resolveURL(url, opfPath), + }) + this.#loader = new Loader({ + loadText: this.loadText, + loadBlob: uri => Promise.resolve(this.loadBlob(uri)) + .then(this.#encryption.getDecoder(uri)), + resources: this.resources, + }) + this.transformTarget = this.#loader.eventTarget + this.sections = this.resources.spine.map((spineItem, index) => { + const { idref, linear, properties = [] } = spineItem + const item = this.resources.getItemByID(idref) + if (!item) { + console.warn(`Could not find item with ID "${idref}" in manifest`) + return null + } + return { + id: item.href, + load: () => this.#loader.loadItem(item), + unload: () => this.#loader.unloadItem(item), + createDocument: () => this.loadDocument(item), + size: this.getSize(item.href), + cfi: this.resources.cfis[index], + linear, + pageSpread: getPageSpread(properties), + resolveHref: href => resolveURL(href, item.href), + mediaOverlay: item.mediaOverlay + ? this.resources.getItemByID(item.mediaOverlay) : null, + } + }).filter(s => s) + + const { navPath, ncxPath } = this.resources + if (navPath) try { + const resolve = url => resolveURL(url, navPath) + const nav = parseNav(await this.#loadXML(navPath), resolve) + this.toc = nav.toc + this.pageList = nav.pageList + this.landmarks = nav.landmarks + } catch(e) { + console.warn(e) + } + if (!this.toc && ncxPath) try { + const resolve = url => resolveURL(url, ncxPath) + const ncx = parseNCX(await this.#loadXML(ncxPath), resolve) + this.toc = ncx.toc + this.pageList = ncx.pageList + } catch(e) { + console.warn(e) + } + this.landmarks ??= this.resources.guide + + const { metadata, rendition, media } = getMetadata(opf) + this.metadata = metadata + this.rendition = rendition + this.media = media + this.dir = this.resources.pageProgressionDirection + const displayOptions = getDisplayOptions( + await this.#loadXML('META-INF/com.apple.ibooks.display-options.xml') + ?? await this.#loadXML('META-INF/com.kobobooks.display-options.xml')) + if (displayOptions) { + if (displayOptions.fixedLayout === 'true') + this.rendition.layout ??= 'pre-paginated' + if (displayOptions.openToSpread === 'false') this.sections + .find(section => section.linear !== 'no').pageSpread ??= + this.dir === 'rtl' ? 'left' : 'right' + } + return this + } + async loadDocument(item) { + const str = await this.loadText(item.href) + return this.parser.parseFromString(str, item.mediaType) + } + getMediaOverlay() { + return new MediaOverlay(this, this.#loadXML.bind(this)) + } + resolveCFI(cfi) { + return this.resources.resolveCFI(cfi) + } + resolveHref(href) { + const [path, hash] = href.split('#') + const item = this.resources.getItemByHref(decodeURI(path)) + if (!item) return null + const index = this.resources.spine.findIndex(({ idref }) => idref === item.id) + const anchor = hash ? doc => getHTMLFragment(doc, hash) : () => 0 + return { index, anchor } + } + splitTOCHref(href) { + return href?.split('#') ?? [] + } + getTOCFragment(doc, id) { + return doc.getElementById(id) + ?? doc.querySelector(`[name="${CSS.escape(id)}"]`) + } + isExternal(uri) { + return isExternal(uri) + } + async getCover() { + const cover = this.resources?.cover + return cover?.href + ? new Blob([await this.loadBlob(cover.href)], { type: cover.mediaType }) + : null + } + async getCalibreBookmarks() { + const txt = await this.loadText('META-INF/calibre_bookmarks.txt') + const magic = 'encoding=json+base64:' + if (txt?.startsWith(magic)) { + const json = atob(txt.slice(magic.length)) + return JSON.parse(json) + } + } + destroy() { + this.#loader?.destroy() + } +} diff --git a/booklore-ui/src/assets/foliate/epubcfi.js b/booklore-ui/src/assets/foliate/epubcfi.js new file mode 100644 index 000000000..173609128 --- /dev/null +++ b/booklore-ui/src/assets/foliate/epubcfi.js @@ -0,0 +1,345 @@ +const findIndices = (arr, f) => arr + .map((x, i, a) => f(x, i, a) ? i : null).filter(x => x != null) +const splitAt = (arr, is) => [-1, ...is, arr.length].reduce(({ xs, a }, b) => + ({ xs: xs?.concat([arr.slice(a + 1, b)]) ?? [], a: b }), {}).xs +const concatArrays = (a, b) => + a.slice(0, -1).concat([a[a.length - 1].concat(b[0])]).concat(b.slice(1)) + +const isNumber = /\d/ +export const isCFI = /^epubcfi\((.*)\)$/ +const escapeCFI = str => str.replace(/[\^[\](),;=]/g, '^$&') + +const wrap = x => isCFI.test(x) ? x : `epubcfi(${x})` +const unwrap = x => x.match(isCFI)?.[1] ?? x +const lift = f => (...xs) => + `epubcfi(${f(...xs.map(x => x.match(isCFI)?.[1] ?? x))})` +export const joinIndir = lift((...xs) => xs.join('!')) + +const tokenizer = str => { + const tokens = [] + let state, escape, value = '' + const push = x => (tokens.push(x), state = null, value = '') + const cat = x => (value += x, escape = false) + for (const char of Array.from(str.trim()).concat('')) { + if (char === '^' && !escape) { + escape = true + continue + } + if (state === '!') push(['!']) + else if (state === ',') push([',']) + else if (state === '/' || state === ':') { + if (isNumber.test(char)) { + cat(char) + continue + } else push([state, parseInt(value)]) + } else if (state === '~') { + if (isNumber.test(char) || char === '.') { + cat(char) + continue + } else push(['~', parseFloat(value)]) + } else if (state === '@') { + if (char === ':') { + push(['@', parseFloat(value)]) + state = '@' + continue + } + if (isNumber.test(char) || char === '.') { + cat(char) + continue + } else push(['@', parseFloat(value)]) + } else if (state === '[') { + if (char === ';' && !escape) { + push(['[', value]) + state = ';' + } else if (char === ',' && !escape) { + push(['[', value]) + state = '[' + } else if (char === ']' && !escape) push(['[', value]) + else cat(char) + continue + } else if (state?.startsWith(';')) { + if (char === '=' && !escape) { + state = `;${value}` + value = '' + } else if (char === ';' && !escape) { + push([state, value]) + state = ';' + } else if (char === ']' && !escape) push([state, value]) + else cat(char) + continue + } + if (char === '/' || char === ':' || char === '~' || char === '@' + || char === '[' || char === '!' || char === ',') state = char + } + return tokens +} + +const findTokens = (tokens, x) => findIndices(tokens, ([t]) => t === x) + +const parser = tokens => { + const parts = [] + let state + for (const [type, val] of tokens) { + if (type === '/') parts.push({ index: val }) + else { + const last = parts[parts.length - 1] + if (type === ':') last.offset = val + else if (type === '~') last.temporal = val + else if (type === '@') last.spatial = (last.spatial ?? []).concat(val) + else if (type === ';s') last.side = val + else if (type === '[') { + if (state === '/' && val) last.id = val + else { + last.text = (last.text ?? []).concat(val) + continue + } + } + } + state = type + } + return parts +} + +// split at step indirections, then parse each part +const parserIndir = tokens => + splitAt(tokens, findTokens(tokens, '!')).map(parser) + +export const parse = cfi => { + const tokens = tokenizer(unwrap(cfi)) + const commas = findTokens(tokens, ',') + if (!commas.length) return parserIndir(tokens) + const [parent, start, end] = splitAt(tokens, commas).map(parserIndir) + return { parent, start, end } +} + +const partToString = ({ index, id, offset, temporal, spatial, text, side }) => { + const param = side ? `;s=${side}` : '' + return `/${index}` + + (id ? `[${escapeCFI(id)}${param}]` : '') + // "CFI expressions [..] SHOULD include an explicit character offset" + + (offset != null && index % 2 ? `:${offset}` : '') + + (temporal ? `~${temporal}` : '') + + (spatial ? `@${spatial.join(':')}` : '') + + (text || (!id && side) ? '[' + + (text?.map(escapeCFI)?.join(',') ?? '') + + param + ']' : '') +} + +const toInnerString = parsed => parsed.parent + ? [parsed.parent, parsed.start, parsed.end].map(toInnerString).join(',') + : parsed.map(parts => parts.map(partToString).join('')).join('!') + +const toString = parsed => wrap(toInnerString(parsed)) + +export const collapse = (x, toEnd) => typeof x === 'string' + ? toString(collapse(parse(x), toEnd)) + : x.parent ? concatArrays(x.parent, x[toEnd ? 'end' : 'start']) : x + +// create range CFI from two CFIs +const buildRange = (from, to) => { + if (typeof from === 'string') from = parse(from) + if (typeof to === 'string') to = parse(to) + from = collapse(from) + to = collapse(to, true) + // ranges across multiple documents are not allowed; handle local paths only + const localFrom = from[from.length - 1], localTo = to[to.length - 1] + const localParent = [], localStart = [], localEnd = [] + let pushToParent = true + const len = Math.max(localFrom.length, localTo.length) + for (let i = 0; i < len; i++) { + const a = localFrom[i], b = localTo[i] + pushToParent &&= a?.index === b?.index && !a?.offset && !b?.offset + if (pushToParent) localParent.push(a) + else { + if (a) localStart.push(a) + if (b) localEnd.push(b) + } + } + // copy non-local paths from `from` + const parent = from.slice(0, -1).concat([localParent]) + return toString({ parent, start: [localStart], end: [localEnd] }) +} + +export const compare = (a, b) => { + if (typeof a === 'string') a = parse(a) + if (typeof b === 'string') b = parse(b) + if (a.start || b.start) return compare(collapse(a), collapse(b)) + || compare(collapse(a, true), collapse(b, true)) + + for (let i = 0; i < Math.max(a.length, b.length); i++) { + const p = a[i] ?? [], q = b[i] ?? [] + const maxIndex = Math.max(p.length, q.length) - 1 + for (let i = 0; i <= maxIndex; i++) { + const x = p[i], y = q[i] + if (!x) return -1 + if (!y) return 1 + if (x.index > y.index) return 1 + if (x.index < y.index) return -1 + if (i === maxIndex) { + // TODO: compare temporal & spatial offsets + if (x.offset > y.offset) return 1 + if (x.offset < y.offset) return -1 + } + } + } + return 0 +} + +const isTextNode = ({ nodeType }) => nodeType === 3 || nodeType === 4 +const isElementNode = ({ nodeType }) => nodeType === 1 + +const getChildNodes = (node, filter) => { + const nodes = Array.from(node.childNodes) + // "content other than element and character data is ignored" + .filter(node => isTextNode(node) || isElementNode(node)) + return filter ? nodes.map(node => { + const accept = filter(node) + if (accept === NodeFilter.FILTER_REJECT) return null + else if (accept === NodeFilter.FILTER_SKIP) return getChildNodes(node, filter) + else return node + }).flat().filter(x => x) : nodes +} + +// child nodes are organized such that the result is always +// [element, text, element, text, ..., element], +// regardless of the actual structure in the document; +// so multiple text nodes need to be combined, and nonexistent ones counted; +// see "Step Reference to Child Element or Character Data (/)" in EPUB CFI spec +const indexChildNodes = (node, filter) => { + const nodes = getChildNodes(node, filter) + .reduce((arr, node) => { + let last = arr[arr.length - 1] + if (!last) arr.push(node) + // "there is one chunk between each pair of child elements" + else if (isTextNode(node)) { + if (Array.isArray(last)) last.push(node) + else if (isTextNode(last)) arr[arr.length - 1] = [last, node] + else arr.push(node) + } else { + if (isElementNode(last)) arr.push(null, node) + else arr.push(node) + } + return arr + }, []) + // "the first chunk is located before the first child element" + if (isElementNode(nodes[0])) nodes.unshift('first') + // "the last chunk is located after the last child element" + if (isElementNode(nodes[nodes.length - 1])) nodes.push('last') + // "'virtual' elements" + nodes.unshift('before') // "0 is a valid index" + nodes.push('after') // "n+2 is a valid index" + return nodes +} + +const partsToNode = (node, parts, filter) => { + const { id } = parts[parts.length - 1] + if (id) { + const el = node.ownerDocument.getElementById(id) + if (el) return { node: el, offset: 0 } + } + for (const { index } of parts) { + const newNode = node ? indexChildNodes(node, filter)[index] : null + // handle non-existent nodes + if (newNode === 'first') return { node: node.firstChild ?? node } + if (newNode === 'last') return { node: node.lastChild ?? node } + if (newNode === 'before') return { node, before: true } + if (newNode === 'after') return { node, after: true } + node = newNode + } + const { offset } = parts[parts.length - 1] + if (!Array.isArray(node)) return { node, offset } + // get underlying text node and offset from the chunk + let sum = 0 + for (const n of node) { + const { length } = n.nodeValue + if (sum + length >= offset) return { node: n, offset: offset - sum } + sum += length + } +} + +const nodeToParts = (node, offset, filter) => { + const { parentNode, id } = node + const indexed = indexChildNodes(parentNode, filter) + const index = indexed.findIndex(x => + Array.isArray(x) ? x.some(x => x === node) : x === node) + // adjust offset as if merging the text nodes in the chunk + const chunk = indexed[index] + if (Array.isArray(chunk)) { + let sum = 0 + for (const x of chunk) { + if (x === node) { + sum += offset + break + } else sum += x.nodeValue.length + } + offset = sum + } + const part = { id, index, offset } + return (parentNode !== node.ownerDocument.documentElement + ? nodeToParts(parentNode, null, filter).concat(part) : [part]) + // remove ignored nodes + .filter(x => x.index !== -1) +} + +export const fromRange = (range, filter) => { + const { startContainer, startOffset, endContainer, endOffset } = range + const start = nodeToParts(startContainer, startOffset, filter) + if (range.collapsed) return toString([start]) + const end = nodeToParts(endContainer, endOffset, filter) + return buildRange([start], [end]) +} + +export const toRange = (doc, parts, filter) => { + const startParts = collapse(parts) + const endParts = collapse(parts, true) + + const root = doc.documentElement + const start = partsToNode(root, startParts[0], filter) + const end = partsToNode(root, endParts[0], filter) + + const range = doc.createRange() + + if (start.before) range.setStartBefore(start.node) + else if (start.after) range.setStartAfter(start.node) + else range.setStart(start.node, start.offset) + + if (end.before) range.setEndBefore(end.node) + else if (end.after) range.setEndAfter(end.node) + else range.setEnd(end.node, end.offset) + return range +} + +// faster way of getting CFIs for sorted elements in a single parent +export const fromElements = elements => { + const results = [] + const { parentNode } = elements[0] + const parts = nodeToParts(parentNode) + for (const [index, node] of indexChildNodes(parentNode).entries()) { + const el = elements[results.length] + if (node === el) + results.push(toString([parts.concat({ id: el.id, index })])) + } + return results +} + +export const toElement = (doc, parts) => + partsToNode(doc.documentElement, collapse(parts)).node + +// turn indices into standard CFIs when you don't have an actual package document +export const fake = { + fromIndex: index => wrap(`/6/${(index + 1) * 2}`), + toIndex: parts => parts?.at(-1).index / 2 - 1, +} + +// get CFI from Calibre bookmarks +// see https://github.com/johnfactotum/foliate/issues/849 +export const fromCalibrePos = pos => { + const [parts] = parse(pos) + const item = parts.shift() + parts.shift() + return toString([[{ index: 6 }, item], parts]) +} +export const fromCalibreHighlight = ({ spine_index, start_cfi, end_cfi }) => { + const pre = fake.fromIndex(spine_index) + '!' + return buildRange(pre + start_cfi.slice(2), pre + end_cfi.slice(2)) +} diff --git a/booklore-ui/src/assets/foliate/fb2.js b/booklore-ui/src/assets/foliate/fb2.js new file mode 100644 index 000000000..ce9bdc0ca --- /dev/null +++ b/booklore-ui/src/assets/foliate/fb2.js @@ -0,0 +1,359 @@ +const normalizeWhitespace = str => str ? str + .replace(/[\t\n\f\r ]+/g, ' ') + .replace(/^[\t\n\f\r ]+/, '') + .replace(/[\t\n\f\r ]+$/, '') : '' +const getElementText = el => normalizeWhitespace(el?.textContent) + +const NS = { + XLINK: 'http://www.w3.org/1999/xlink', + EPUB: 'http://www.idpf.org/2007/ops', +} + +const MIME = { + XML: 'application/xml', + XHTML: 'application/xhtml+xml', +} + +const STYLE = { + 'strong': ['strong', 'self'], + 'emphasis': ['em', 'self'], + 'style': ['span', 'self'], + 'a': 'anchor', + 'strikethrough': ['s', 'self'], + 'sub': ['sub', 'self'], + 'sup': ['sup', 'self'], + 'code': ['code', 'self'], + 'image': 'image', +} + +const TABLE = { + 'tr': ['tr', { + 'th': ['th', STYLE, ['colspan', 'rowspan', 'align', 'valign']], + 'td': ['td', STYLE, ['colspan', 'rowspan', 'align', 'valign']], + }, ['align']], +} + +const POEM = { + 'epigraph': ['blockquote'], + 'subtitle': ['h2', STYLE], + 'text-author': ['p', STYLE], + 'date': ['p', STYLE], + 'stanza': 'stanza', +} + +const SECTION = { + 'title': ['header', { + 'p': ['h1', STYLE], + 'empty-line': ['br'], + }], + 'epigraph': ['blockquote', 'self'], + 'image': 'image', + 'annotation': ['aside'], + 'section': ['section', 'self'], + 'p': ['p', STYLE], + 'poem': ['blockquote', POEM], + 'subtitle': ['h2', STYLE], + 'cite': ['blockquote', 'self'], + 'empty-line': ['br'], + 'table': ['table', TABLE], + 'text-author': ['p', STYLE], +} +POEM['epigraph'].push(SECTION) + +const BODY = { + 'image': 'image', + 'title': ['section', { + 'p': ['h1', STYLE], + 'empty-line': ['br'], + }], + 'epigraph': ['section', SECTION], + 'section': ['section', SECTION], +} + +class FB2Converter { + constructor(fb2) { + this.fb2 = fb2 + this.doc = document.implementation.createDocument(NS.XHTML, 'html') + // use this instead of `getElementById` to allow images like + // `` + this.bins = new Map(Array.from(this.fb2.getElementsByTagName('binary'), + el => [el.id, el])) + } + getImageSrc(el) { + const href = el.getAttributeNS(NS.XLINK, 'href') + if (!href) return 'data:,' + const [, id] = href.split('#') + if (!id) return href + const bin = this.bins.get(id) + return bin + ? `data:${bin.getAttribute('content-type')};base64,${bin.textContent}` + : href + } + image(node) { + const el = this.doc.createElement('img') + el.alt = node.getAttribute('alt') + el.title = node.getAttribute('title') + el.setAttribute('src', this.getImageSrc(node)) + return el + } + anchor(node) { + const el = this.convert(node, { 'a': ['a', STYLE] }) + el.setAttribute('href', node.getAttributeNS(NS.XLINK, 'href')) + if (node.getAttribute('type') === 'note') + el.setAttributeNS(NS.EPUB, 'epub:type', 'noteref') + return el + } + stanza(node) { + const el = this.convert(node, { + 'stanza': ['p', { + 'title': ['header', { + 'p': ['strong', STYLE], + 'empty-line': ['br'], + }], + 'subtitle': ['p', STYLE], + }], + }) + for (const child of node.children) if (child.nodeName === 'v') { + el.append(this.doc.createTextNode(child.textContent)) + el.append(this.doc.createElement('br')) + } + return el + } + convert(node, def) { + // not an element; return text content + if (node.nodeType === 3) return this.doc.createTextNode(node.textContent) + if (node.nodeType === 4) return this.doc.createCDATASection(node.textContent) + if (node.nodeType === 8) return this.doc.createComment(node.textContent) + + const d = def?.[node.nodeName] + if (!d) return null + if (typeof d === 'string') return this[d](node) + + const [name, opts, attrs] = d + const el = this.doc.createElement(name) + + // copy the ID, and set class name from original element name + if (node.id) el.id = node.id + el.classList.add(node.nodeName) + + // copy attributes + if (Array.isArray(attrs)) for (const attr of attrs) { + const value = node.getAttribute(attr) + if (value) el.setAttribute(attr, value) + } + + // process child elements recursively + const childDef = opts === 'self' ? def : opts + let child = node.firstChild + while (child) { + const childEl = this.convert(child, childDef) + if (childEl) el.append(childEl) + child = child.nextSibling + } + return el + } +} + +const parseXML = async blob => { + const buffer = await blob.arrayBuffer() + const str = new TextDecoder('utf-8').decode(buffer) + const parser = new DOMParser() + const doc = parser.parseFromString(str, MIME.XML) + const encoding = doc.xmlEncoding + // `Document.xmlEncoding` is deprecated, and already removed in Firefox + // so parse the XML declaration manually + || str.match(/^<\?xml\s+version\s*=\s*["']1.\d+"\s+encoding\s*=\s*["']([A-Za-z0-9._-]*)["']/)?.[1] + if (encoding && encoding.toLowerCase() !== 'utf-8') { + const str = new TextDecoder(encoding).decode(buffer) + return parser.parseFromString(str, MIME.XML) + } + return doc +} + +const style = URL.createObjectURL(new Blob([` +@namespace epub "http://www.idpf.org/2007/ops"; +body > img, section > img { + display: block; + margin: auto; +} +.title h1 { + text-align: center; +} +body > section > .title, body.notesBodyType > .title { + margin: 3em 0; +} +body.notesBodyType > section .title h1 { + text-align: start; +} +body.notesBodyType > section .title { + margin: 1em 0; +} +p { + text-indent: 1em; + margin: 0; +} +:not(p) + p, p:first-child { + text-indent: 0; +} +.poem p { + text-indent: 0; + margin: 1em 0; +} +.text-author, .date { + text-align: end; +} +.text-author:before { + content: "—"; +} +table { + border-collapse: collapse; +} +td, th { + padding: .25em; +} +a[epub|type~="noteref"] { + font-size: .75em; + vertical-align: super; +} +body:not(.notesBodyType) > .title, body:not(.notesBodyType) > .epigraph { + margin: 3em 0; +} +`], { type: 'text/css' })) + +const template = html => ` + + + ${html} +` + +// name of custom ID attribute for TOC items +const dataID = 'data-foliate-id' + +export const makeFB2 = async blob => { + const book = {} + const doc = await parseXML(blob) + const converter = new FB2Converter(doc) + + const $ = x => doc.querySelector(x) + const $$ = x => [...doc.querySelectorAll(x)] + const getPerson = el => { + const nick = getElementText(el.querySelector('nickname')) + if (nick) return nick + const first = getElementText(el.querySelector('first-name')) + const middle = getElementText(el.querySelector('middle-name')) + const last = getElementText(el.querySelector('last-name')) + const name = [first, middle, last].filter(x => x).join(' ') + const sortAs = last + ? [last, [first, middle].filter(x => x).join(' ')].join(', ') + : null + return { name, sortAs } + } + const getDate = el => el?.getAttribute('value') ?? getElementText(el) + const annotation = $('title-info annotation') + book.metadata = { + title: getElementText($('title-info book-title')), + identifier: getElementText($('document-info id')), + language: getElementText($('title-info lang')), + author: $$('title-info author').map(getPerson), + translator: $$('title-info translator').map(getPerson), + contributor: $$('document-info author').map(getPerson) + // techincially the program probably shouldn't get the `bkp` role + // but it has been so used by calibre, so ¯\_(ツ)_/¯ + .concat($$('document-info program-used').map(getElementText)) + .map(x => Object.assign(typeof x === 'string' ? { name: x } : x, + { role: 'bkp' })), + publisher: getElementText($('publish-info publisher')), + published: getDate($('title-info date')), + modified: getDate($('document-info date')), + description: annotation ? converter.convert(annotation, + { annotation: ['div', SECTION] }).innerHTML : null, + subject: $$('title-info genre').map(getElementText), + } + if ($('coverpage image')) { + const src = converter.getImageSrc($('coverpage image')) + book.getCover = () => fetch(src).then(res => res.blob()) + } else book.getCover = () => null + + // get convert each body + const bodyData = Array.from(doc.querySelectorAll('body'), body => { + const converted = converter.convert(body, { body: ['body', BODY] }) + return [Array.from(converted.children, el => { + // get list of IDs in the section + const ids = [el, ...el.querySelectorAll('[id]')].map(el => el.id) + return { el, ids } + }), converted] + }) + + const urls = [] + const sectionData = bodyData[0][0] + // make a separate section for each section in the first body + .map(({ el, ids }) => { + // set up titles for TOC + const titles = Array.from( + el.querySelectorAll(':scope > section > .title'), + (el, index) => { + el.setAttribute(dataID, index) + return { title: getElementText(el), index } + }) + return { ids, titles, el } + }) + // for additional bodies, only make one section for each body + .concat(bodyData.slice(1).map(([sections, body]) => { + const ids = sections.map(s => s.ids).flat() + body.classList.add('notesBodyType') + return { ids, el: body, linear: 'no' } + })) + .map(({ ids, titles, el, linear }) => { + const str = template(el.outerHTML) + const blob = new Blob([str], { type: MIME.XHTML }) + const url = URL.createObjectURL(blob) + urls.push(url) + const title = normalizeWhitespace( + el.querySelector('.title, .subtitle, p')?.textContent + ?? (el.classList.contains('title') ? el.textContent : '')) + return { + ids, title, titles, load: () => url, + createDocument: () => new DOMParser().parseFromString(str, MIME.XHTML), + // doo't count image data as it'd skew the size too much + size: blob.size - Array.from(el.querySelectorAll('[src]'), + el => el.getAttribute('src')?.length ?? 0) + .reduce((a, b) => a + b, 0), + linear, + } + }) + + const idMap = new Map() + book.sections = sectionData.map((section, index) => { + const { ids, load, createDocument, size, linear } = section + for (const id of ids) if (id) idMap.set(id, index) + return { id: index, load, createDocument, size, linear } + }) + + book.toc = sectionData.map(({ title, titles }, index) => { + const id = index.toString() + return { + label: title, + href: id, + subitems: titles?.length ? titles.map(({ title, index }) => ({ + label: title, + href: `${id}#${index}`, + })) : null, + } + }).filter(item => item) + + book.resolveHref = href => { + const [a, b] = href.split('#') + return a + // the link is from the TOC + ? { index: Number(a), anchor: doc => doc.querySelector(`[${dataID}="${b}"]`) } + // link from within the page + : { index: idMap.get(b), anchor: doc => doc.getElementById(b) } + } + book.splitTOCHref = href => href?.split('#')?.map(x => Number(x)) ?? [] + book.getTOCFragment = (doc, id) => doc.querySelector(`[${dataID}="${id}"]`) + + book.destroy = () => { + for (const url of urls) URL.revokeObjectURL(url) + } + return book +} diff --git a/booklore-ui/src/assets/foliate/fixed-layout.js b/booklore-ui/src/assets/foliate/fixed-layout.js new file mode 100644 index 000000000..c582abad1 --- /dev/null +++ b/booklore-ui/src/assets/foliate/fixed-layout.js @@ -0,0 +1,319 @@ +const parseViewport = str => str + ?.split(/[,;\s]/) // NOTE: technically, only the comma is valid + ?.filter(x => x) + ?.map(x => x.split('=').map(x => x.trim())) + +const getViewport = (doc, viewport) => { + // use `viewBox` for SVG + if (doc.documentElement.localName === 'svg') { + const [, , width, height] = doc.documentElement + .getAttribute('viewBox')?.split(/\s/) ?? [] + return { width, height } + } + + // get `viewport` `meta` element + const meta = parseViewport(doc.querySelector('meta[name="viewport"]') + ?.getAttribute('content')) + if (meta) return Object.fromEntries(meta) + + // fallback to book's viewport + if (typeof viewport === 'string') return parseViewport(viewport) + if (viewport?.width && viewport.height) return viewport + + // if no viewport (possibly with image directly in spine), get image size + const img = doc.querySelector('img') + if (img) return { width: img.naturalWidth, height: img.naturalHeight } + + // just show *something*, i guess... + console.warn(new Error('Missing viewport properties')) + return { width: 1000, height: 2000 } +} + +export class FixedLayout extends HTMLElement { + static observedAttributes = ['zoom'] + #root = this.attachShadow({ mode: 'closed' }) + #observer = new ResizeObserver(() => this.#render()) + #spreads + #index = -1 + defaultViewport + spread + #portrait = false + #left + #right + #center + #side + #zoom + constructor() { + super() + + const sheet = new CSSStyleSheet() + this.#root.adoptedStyleSheets = [sheet] + sheet.replaceSync(`:host { + width: 100%; + height: 100%; + display: flex; + justify-content: center; + align-items: center; + overflow: auto; + }`) + + this.#observer.observe(this) + } + attributeChangedCallback(name, _, value) { + switch (name) { + case 'zoom': + this.#zoom = value !== 'fit-width' && value !== 'fit-page' + ? parseFloat(value) : value + this.#render() + break + } + } + async #createFrame({ index, src: srcOption }) { + const srcOptionIsString = typeof srcOption === 'string' + const src = srcOptionIsString ? srcOption : srcOption?.src + const onZoom = srcOptionIsString ? null : srcOption?.onZoom + const element = document.createElement('div') + element.setAttribute('dir', 'ltr') + const iframe = document.createElement('iframe') + element.append(iframe) + Object.assign(iframe.style, { + border: '0', + display: 'none', + overflow: 'hidden', + }) + // `allow-scripts` is needed for events because of WebKit bug + // https://bugs.webkit.org/show_bug.cgi?id=218086 + iframe.setAttribute('sandbox', 'allow-same-origin allow-scripts') + iframe.setAttribute('scrolling', 'no') + iframe.setAttribute('part', 'filter') + this.#root.append(element) + if (!src) return { blank: true, element, iframe } + return new Promise(resolve => { + iframe.addEventListener('load', () => { + const doc = iframe.contentDocument + this.dispatchEvent(new CustomEvent('load', { detail: { doc, index } })) + const { width, height } = getViewport(doc, this.defaultViewport) + resolve({ + element, iframe, + width: parseFloat(width), + height: parseFloat(height), + onZoom, + }) + }, { once: true }) + iframe.src = src + }) + } + #render(side = this.#side) { + if (!side) return + const left = this.#left ?? {} + const right = this.#center ?? this.#right ?? {} + const target = side === 'left' ? left : right + const { width, height } = this.getBoundingClientRect() + const portrait = this.spread !== 'both' && this.spread !== 'portrait' + && height > width + this.#portrait = portrait + const blankWidth = left.width ?? right.width ?? 0 + const blankHeight = left.height ?? right.height ?? 0 + + const scale = typeof this.#zoom === 'number' && !isNaN(this.#zoom) + ? this.#zoom + : (this.#zoom === 'fit-width' + ? (portrait || this.#center + ? width / (target.width ?? blankWidth) + : width / ((left.width ?? blankWidth) + (right.width ?? blankWidth))) + : (portrait || this.#center + ? Math.min( + width / (target.width ?? blankWidth), + height / (target.height ?? blankHeight)) + : Math.min( + width / ((left.width ?? blankWidth) + (right.width ?? blankWidth)), + height / Math.max( + left.height ?? blankHeight, + right.height ?? blankHeight))) + ) || 1 + + const transform = frame => { + let { element, iframe, width, height, blank, onZoom } = frame + if (!iframe) return + if (onZoom) onZoom({ doc: frame.iframe.contentDocument, scale }) + const iframeScale = onZoom ? scale : 1 + Object.assign(iframe.style, { + width: `${width * iframeScale}px`, + height: `${height * iframeScale}px`, + transform: onZoom ? 'none' : `scale(${scale})`, + transformOrigin: 'top left', + display: blank ? 'none' : 'block', + }) + Object.assign(element.style, { + width: `${(width ?? blankWidth) * scale}px`, + height: `${(height ?? blankHeight) * scale}px`, + overflow: 'hidden', + display: 'block', + flexShrink: '0', + marginBlock: 'auto', + }) + if (portrait && frame !== target) { + element.style.display = 'none' + } + } + if (this.#center) { + transform(this.#center) + } else { + transform(left) + transform(right) + } + } + async #showSpread({ left, right, center, side }) { + this.#root.replaceChildren() + this.#left = null + this.#right = null + this.#center = null + if (center) { + this.#center = await this.#createFrame(center) + this.#side = 'center' + this.#render() + } else { + this.#left = await this.#createFrame(left) + this.#right = await this.#createFrame(right) + this.#side = this.#left.blank ? 'right' + : this.#right.blank ? 'left' : side + this.#render() + } + } + #goLeft() { + if (this.#center || this.#left?.blank) return + if (this.#portrait && this.#left?.element?.style?.display === 'none') { + this.#side = 'left' + this.#render() + this.#reportLocation('page') + return true + } + } + #goRight() { + if (this.#center || this.#right?.blank) return + if (this.#portrait && this.#right?.element?.style?.display === 'none') { + this.#side = 'right' + this.#render() + this.#reportLocation('page') + return true + } + } + open(book) { + this.book = book + const { rendition } = book + this.spread = rendition?.spread + this.defaultViewport = rendition?.viewport + + const rtl = book.dir === 'rtl' + const ltr = !rtl + this.rtl = rtl + + if (rendition?.spread === 'none') + this.#spreads = book.sections.map(section => ({ center: section })) + else this.#spreads = book.sections.reduce((arr, section, i) => { + const last = arr[arr.length - 1] + const { pageSpread } = section + const newSpread = () => { + const spread = {} + arr.push(spread) + return spread + } + if (pageSpread === 'center') { + const spread = last.left || last.right ? newSpread() : last + spread.center = section + } + else if (pageSpread === 'left') { + const spread = last.center || last.left || ltr && i ? newSpread() : last + spread.left = section + } + else if (pageSpread === 'right') { + const spread = last.center || last.right || rtl && i ? newSpread() : last + spread.right = section + } + else if (ltr) { + if (last.center || last.right) newSpread().left = section + else if (last.left || !i) last.right = section + else last.left = section + } + else { + if (last.center || last.left) newSpread().right = section + else if (last.right || !i) last.left = section + else last.right = section + } + return arr + }, [{}]) + } + get index() { + const spread = this.#spreads[this.#index] + const section = spread?.center ?? (this.#side === 'left' + ? spread.left ?? spread.right : spread.right ?? spread.left) + return this.book.sections.indexOf(section) + } + #reportLocation(reason) { + this.dispatchEvent(new CustomEvent('relocate', { detail: + { reason, range: null, index: this.index, fraction: 0, size: 1 } })) + } + getSpreadOf(section) { + const spreads = this.#spreads + for (let index = 0; index < spreads.length; index++) { + const { left, right, center } = spreads[index] + if (left === section) return { index, side: 'left' } + if (right === section) return { index, side: 'right' } + if (center === section) return { index, side: 'center' } + } + } + async goToSpread(index, side, reason) { + if (index < 0 || index > this.#spreads.length - 1) return + if (index === this.#index) { + this.#render(side) + return + } + this.#index = index + const spread = this.#spreads[index] + if (spread.center) { + const index = this.book.sections.indexOf(spread.center) + const src = await spread.center?.load?.() + await this.#showSpread({ center: { index, src } }) + } else { + const indexL = this.book.sections.indexOf(spread.left) + const indexR = this.book.sections.indexOf(spread.right) + const srcL = await spread.left?.load?.() + const srcR = await spread.right?.load?.() + const left = { index: indexL, src: srcL } + const right = { index: indexR, src: srcR } + await this.#showSpread({ left, right, side }) + } + this.#reportLocation(reason) + } + async select(target) { + await this.goTo(target) + // TODO + } + async goTo(target) { + const { book } = this + const resolved = await target + const section = book.sections[resolved.index] + if (!section) return + const { index, side } = this.getSpreadOf(section) + await this.goToSpread(index, side) + } + async next() { + const s = this.rtl ? this.#goLeft() : this.#goRight() + if (!s) return this.goToSpread(this.#index + 1, this.rtl ? 'right' : 'left', 'page') + } + async prev() { + const s = this.rtl ? this.#goRight() : this.#goLeft() + if (!s) return this.goToSpread(this.#index - 1, this.rtl ? 'left' : 'right', 'page') + } + getContents() { + return Array.from(this.#root.querySelectorAll('iframe'), frame => ({ + doc: frame.contentDocument, + // TODO: index, overlayer + })) + } + destroy() { + this.#observer.unobserve(this) + } +} + +customElements.define('foliate-fxl', FixedLayout) diff --git a/booklore-ui/src/assets/foliate/mobi.js b/booklore-ui/src/assets/foliate/mobi.js new file mode 100644 index 000000000..811b0d3cd --- /dev/null +++ b/booklore-ui/src/assets/foliate/mobi.js @@ -0,0 +1,1236 @@ +const unescapeHTML = str => { + if (!str) return '' + const textarea = document.createElement('textarea') + textarea.innerHTML = str + return textarea.value +} + +const MIME = { + XML: 'application/xml', + XHTML: 'application/xhtml+xml', + HTML: 'text/html', + CSS: 'text/css', + SVG: 'image/svg+xml', +} + +const PDB_HEADER = { + name: [0, 32, 'string'], + type: [60, 4, 'string'], + creator: [64, 4, 'string'], + numRecords: [76, 2, 'uint'], +} + +const PALMDOC_HEADER = { + compression: [0, 2, 'uint'], + numTextRecords: [8, 2, 'uint'], + recordSize: [10, 2, 'uint'], + encryption: [12, 2, 'uint'], +} + +const MOBI_HEADER = { + magic: [16, 4, 'string'], + length: [20, 4, 'uint'], + type: [24, 4, 'uint'], + encoding: [28, 4, 'uint'], + uid: [32, 4, 'uint'], + version: [36, 4, 'uint'], + titleOffset: [84, 4, 'uint'], + titleLength: [88, 4, 'uint'], + localeRegion: [94, 1, 'uint'], + localeLanguage: [95, 1, 'uint'], + resourceStart: [108, 4, 'uint'], + huffcdic: [112, 4, 'uint'], + numHuffcdic: [116, 4, 'uint'], + exthFlag: [128, 4, 'uint'], + trailingFlags: [240, 4, 'uint'], + indx: [244, 4, 'uint'], +} + +const KF8_HEADER = { + resourceStart: [108, 4, 'uint'], + fdst: [192, 4, 'uint'], + numFdst: [196, 4, 'uint'], + frag: [248, 4, 'uint'], + skel: [252, 4, 'uint'], + guide: [260, 4, 'uint'], +} + +const EXTH_HEADER = { + magic: [0, 4, 'string'], + length: [4, 4, 'uint'], + count: [8, 4, 'uint'], +} + +const INDX_HEADER = { + magic: [0, 4, 'string'], + length: [4, 4, 'uint'], + type: [8, 4, 'uint'], + idxt: [20, 4, 'uint'], + numRecords: [24, 4, 'uint'], + encoding: [28, 4, 'uint'], + language: [32, 4, 'uint'], + total: [36, 4, 'uint'], + ordt: [40, 4, 'uint'], + ligt: [44, 4, 'uint'], + numLigt: [48, 4, 'uint'], + numCncx: [52, 4, 'uint'], +} + +const TAGX_HEADER = { + magic: [0, 4, 'string'], + length: [4, 4, 'uint'], + numControlBytes: [8, 4, 'uint'], +} + +const HUFF_HEADER = { + magic: [0, 4, 'string'], + offset1: [8, 4, 'uint'], + offset2: [12, 4, 'uint'], +} + +const CDIC_HEADER = { + magic: [0, 4, 'string'], + length: [4, 4, 'uint'], + numEntries: [8, 4, 'uint'], + codeLength: [12, 4, 'uint'], +} + +const FDST_HEADER = { + magic: [0, 4, 'string'], + numEntries: [8, 4, 'uint'], +} + +const FONT_HEADER = { + flags: [8, 4, 'uint'], + dataStart: [12, 4, 'uint'], + keyLength: [16, 4, 'uint'], + keyStart: [20, 4, 'uint'], +} + +const MOBI_ENCODING = { + 1252: 'windows-1252', + 65001: 'utf-8', +} + +const EXTH_RECORD_TYPE = { + 100: ['creator', 'string', true], + 101: ['publisher'], + 103: ['description'], + 104: ['isbn'], + 105: ['subject', 'string', true], + 106: ['date'], + 108: ['contributor', 'string', true], + 109: ['rights'], + 110: ['subjectCode', 'string', true], + 112: ['source', 'string', true], + 113: ['asin'], + 121: ['boundary', 'uint'], + 122: ['fixedLayout'], + 125: ['numResources', 'uint'], + 126: ['originalResolution'], + 127: ['zeroGutter'], + 128: ['zeroMargin'], + 129: ['coverURI'], + 132: ['regionMagnification'], + 201: ['coverOffset', 'uint'], + 202: ['thumbnailOffset', 'uint'], + 503: ['title'], + 524: ['language', 'string', true], + 527: ['pageProgressionDirection'], +} + +const MOBI_LANG = { + 1: ['ar', 'ar-SA', 'ar-IQ', 'ar-EG', 'ar-LY', 'ar-DZ', 'ar-MA', 'ar-TN', 'ar-OM', + 'ar-YE', 'ar-SY', 'ar-JO', 'ar-LB', 'ar-KW', 'ar-AE', 'ar-BH', 'ar-QA'], + 2: ['bg'], 3: ['ca'], 4: ['zh', 'zh-TW', 'zh-CN', 'zh-HK', 'zh-SG'], 5: ['cs'], + 6: ['da'], 7: ['de', 'de-DE', 'de-CH', 'de-AT', 'de-LU', 'de-LI'], 8: ['el'], + 9: ['en', 'en-US', 'en-GB', 'en-AU', 'en-CA', 'en-NZ', 'en-IE', 'en-ZA', + 'en-JM', null, 'en-BZ', 'en-TT', 'en-ZW', 'en-PH'], + 10: ['es', 'es-ES', 'es-MX', null, 'es-GT', 'es-CR', 'es-PA', 'es-DO', + 'es-VE', 'es-CO', 'es-PE', 'es-AR', 'es-EC', 'es-CL', 'es-UY', 'es-PY', + 'es-BO', 'es-SV', 'es-HN', 'es-NI', 'es-PR'], + 11: ['fi'], 12: ['fr', 'fr-FR', 'fr-BE', 'fr-CA', 'fr-CH', 'fr-LU', 'fr-MC'], + 13: ['he'], 14: ['hu'], 15: ['is'], 16: ['it', 'it-IT', 'it-CH'], + 17: ['ja'], 18: ['ko'], 19: ['nl', 'nl-NL', 'nl-BE'], 20: ['no', 'nb', 'nn'], + 21: ['pl'], 22: ['pt', 'pt-BR', 'pt-PT'], 23: ['rm'], 24: ['ro'], 25: ['ru'], + 26: ['hr', null, 'sr'], 27: ['sk'], 28: ['sq'], 29: ['sv', 'sv-SE', 'sv-FI'], + 30: ['th'], 31: ['tr'], 32: ['ur'], 33: ['id'], 34: ['uk'], 35: ['be'], + 36: ['sl'], 37: ['et'], 38: ['lv'], 39: ['lt'], 41: ['fa'], 42: ['vi'], + 43: ['hy'], 44: ['az'], 45: ['eu'], 46: ['hsb'], 47: ['mk'], 48: ['st'], + 49: ['ts'], 50: ['tn'], 52: ['xh'], 53: ['zu'], 54: ['af'], 55: ['ka'], + 56: ['fo'], 57: ['hi'], 58: ['mt'], 59: ['se'], 62: ['ms'], 63: ['kk'], + 65: ['sw'], 67: ['uz', null, 'uz-UZ'], 68: ['tt'], 69: ['bn'], 70: ['pa'], + 71: ['gu'], 72: ['or'], 73: ['ta'], 74: ['te'], 75: ['kn'], 76: ['ml'], + 77: ['as'], 78: ['mr'], 79: ['sa'], 82: ['cy', 'cy-GB'], 83: ['gl', 'gl-ES'], + 87: ['kok'], 97: ['ne'], 98: ['fy'], +} + +const concatTypedArray = (a, b) => { + const result = new a.constructor(a.length + b.length) + result.set(a) + result.set(b, a.length) + return result +} +const concatTypedArray3 = (a, b, c) => { + const result = new a.constructor(a.length + b.length + c.length) + result.set(a) + result.set(b, a.length) + result.set(c, a.length + b.length) + return result +} + +const decoder = new TextDecoder() +const getString = buffer => decoder.decode(buffer) +const getUint = buffer => { + if (!buffer) return + const l = buffer.byteLength + const func = l === 4 ? 'getUint32' : l === 2 ? 'getUint16' : 'getUint8' + return new DataView(buffer)[func](0) +} +const getStruct = (def, buffer) => Object.fromEntries(Array.from(Object.entries(def)) + .map(([key, [start, len, type]]) => [key, + (type === 'string' ? getString : getUint)(buffer.slice(start, start + len))])) + +const getDecoder = x => new TextDecoder(MOBI_ENCODING[x]) + +const getVarLen = (byteArray, i = 0) => { + let value = 0, length = 0 + for (const byte of byteArray.subarray(i, i + 4)) { + value = (value << 7) | (byte & 0b111_1111) >>> 0 + length++ + if (byte & 0b1000_0000) break + } + return { value, length } +} + +// variable-length quantity, but read from the end of data +const getVarLenFromEnd = byteArray => { + let value = 0 + for (const byte of byteArray.subarray(-4)) { + // `byte & 0b1000_0000` indicates the start of value + if (byte & 0b1000_0000) value = 0 + value = (value << 7) | (byte & 0b111_1111) + } + return value +} + +const countBitsSet = x => { + let count = 0 + for (; x > 0; x = x >> 1) if ((x & 1) === 1) count++ + return count +} + +const countUnsetEnd = x => { + let count = 0 + while ((x & 1) === 0) x = x >> 1, count++ + return count +} + +const decompressPalmDOC = array => { + let output = [] + for (let i = 0; i < array.length; i++) { + const byte = array[i] + if (byte === 0) output.push(0) // uncompressed literal, just copy it + else if (byte <= 8) // copy next 1-8 bytes + for (const x of array.subarray(i + 1, (i += byte) + 1)) + output.push(x) + else if (byte <= 0b0111_1111) output.push(byte) // uncompressed literal + else if (byte <= 0b1011_1111) { + // 1st and 2nd bits are 10, meaning this is a length-distance pair + // read next byte and combine it with current byte + const bytes = (byte << 8) | array[i++ + 1] + // the 3rd to 13th bits encode distance + const distance = (bytes & 0b0011_1111_1111_1111) >>> 3 + // the last 3 bits, plus 3, is the length to copy + const length = (bytes & 0b111) + 3 + for (let j = 0; j < length; j++) + output.push(output[output.length - distance]) + } + // compressed from space plus char + else output.push(32, byte ^ 0b1000_0000) + } + return Uint8Array.from(output) +} + +const read32Bits = (byteArray, from) => { + const startByte = from >> 3 + const end = from + 32 + const endByte = end >> 3 + let bits = 0n + for (let i = startByte; i <= endByte; i++) + bits = bits << 8n | BigInt(byteArray[i] ?? 0) + return (bits >> (8n - BigInt(end & 7))) & 0xffffffffn +} + +const huffcdic = async (mobi, loadRecord) => { + const huffRecord = await loadRecord(mobi.huffcdic) + const { magic, offset1, offset2 } = getStruct(HUFF_HEADER, huffRecord) + if (magic !== 'HUFF') throw new Error('Invalid HUFF record') + + // table1 is indexed by byte value + const table1 = Array.from({ length: 256 }, (_, i) => offset1 + i * 4) + .map(offset => getUint(huffRecord.slice(offset, offset + 4))) + .map(x => [x & 0b1000_0000, x & 0b1_1111, x >>> 8]) + + // table2 is indexed by code length + const table2 = [null].concat(Array.from({ length: 32 }, (_, i) => offset2 + i * 8) + .map(offset => [ + getUint(huffRecord.slice(offset, offset + 4)), + getUint(huffRecord.slice(offset + 4, offset + 8))])) + + const dictionary = [] + for (let i = 1; i < mobi.numHuffcdic; i++) { + const record = await loadRecord(mobi.huffcdic + i) + const cdic = getStruct(CDIC_HEADER, record) + if (cdic.magic !== 'CDIC') throw new Error('Invalid CDIC record') + // `numEntries` is the total number of dictionary data across CDIC records + // so `n` here is the number of entries in *this* record + const n = Math.min(1 << cdic.codeLength, cdic.numEntries - dictionary.length) + const buffer = record.slice(cdic.length) + for (let i = 0; i < n; i++) { + const offset = getUint(buffer.slice(i * 2, i * 2 + 2)) + const x = getUint(buffer.slice(offset, offset + 2)) + const length = x & 0x7fff + const decompressed = x & 0x8000 + const value = new Uint8Array( + buffer.slice(offset + 2, offset + 2 + length)) + dictionary.push([value, decompressed]) + } + } + + const decompress = byteArray => { + let output = new Uint8Array() + const bitLength = byteArray.byteLength * 8 + for (let i = 0; i < bitLength;) { + const bits = Number(read32Bits(byteArray, i)) + let [found, codeLength, value] = table1[bits >>> 24] + if (!found) { + while (bits >>> (32 - codeLength) < table2[codeLength][0]) + codeLength += 1 + value = table2[codeLength][1] + } + if ((i += codeLength) > bitLength) break + + const code = value - (bits >>> (32 - codeLength)) + let [result, decompressed] = dictionary[code] + if (!decompressed) { + // the result is itself compressed + result = decompress(result) + // cache the result for next time + dictionary[code] = [result, true] + } + output = concatTypedArray(output, result) + } + return output + } + return decompress +} + +const getIndexData = async (indxIndex, loadRecord) => { + const indxRecord = await loadRecord(indxIndex) + const indx = getStruct(INDX_HEADER, indxRecord) + if (indx.magic !== 'INDX') throw new Error('Invalid INDX record') + const decoder = getDecoder(indx.encoding) + + const tagxBuffer = indxRecord.slice(indx.length) + const tagx = getStruct(TAGX_HEADER, tagxBuffer) + if (tagx.magic !== 'TAGX') throw new Error('Invalid TAGX section') + const numTags = (tagx.length - 12) / 4 + const tagTable = Array.from({ length: numTags }, (_, i) => + new Uint8Array(tagxBuffer.slice(12 + i * 4, 12 + i * 4 + 4))) + + const cncx = {} + let cncxRecordOffset = 0 + for (let i = 0; i < indx.numCncx; i++) { + const record = await loadRecord(indxIndex + indx.numRecords + i + 1) + const array = new Uint8Array(record) + for (let pos = 0; pos < array.byteLength;) { + const index = pos + const { value, length } = getVarLen(array, pos) + pos += length + const result = record.slice(pos, pos + value) + pos += value + cncx[cncxRecordOffset + index] = decoder.decode(result) + } + cncxRecordOffset += 0x10000 + } + + const table = [] + for (let i = 0; i < indx.numRecords; i++) { + const record = await loadRecord(indxIndex + 1 + i) + const array = new Uint8Array(record) + const indx = getStruct(INDX_HEADER, record) + if (indx.magic !== 'INDX') throw new Error('Invalid INDX record') + for (let j = 0; j < indx.numRecords; j++) { + const offsetOffset = indx.idxt + 4 + 2 * j + const offset = getUint(record.slice(offsetOffset, offsetOffset + 2)) + + const length = getUint(record.slice(offset, offset + 1)) + const name = getString(record.slice(offset + 1, offset + 1 + length)) + + const tags = [] + const startPos = offset + 1 + length + let controlByteIndex = 0 + let pos = startPos + tagx.numControlBytes + for (const [tag, numValues, mask, end] of tagTable) { + if (end & 1) { + controlByteIndex++ + continue + } + const offset = startPos + controlByteIndex + const value = getUint(record.slice(offset, offset + 1)) & mask + if (value === mask) { + if (countBitsSet(mask) > 1) { + const { value, length } = getVarLen(array, pos) + tags.push([tag, null, value, numValues]) + pos += length + } else tags.push([tag, 1, null, numValues]) + } else tags.push([tag, value >> countUnsetEnd(mask), null, numValues]) + } + + const tagMap = {} + for (const [tag, valueCount, valueBytes, numValues] of tags) { + const values = [] + if (valueCount != null) { + for (let i = 0; i < valueCount * numValues; i++) { + const { value, length } = getVarLen(array, pos) + values.push(value) + pos += length + } + } else { + let count = 0 + while (count < valueBytes) { + const { value, length } = getVarLen(array, pos) + values.push(value) + pos += length + count += length + } + } + tagMap[tag] = values + } + table.push({ name, tagMap }) + } + } + return { table, cncx } +} + +const getNCX = async (indxIndex, loadRecord) => { + const { table, cncx } = await getIndexData(indxIndex, loadRecord) + const items = table.map(({ tagMap }, index) => ({ + index, + offset: tagMap[1]?.[0], + size: tagMap[2]?.[0], + label: cncx[tagMap[3]] ?? '', + headingLevel: tagMap[4]?.[0], + pos: tagMap[6], + parent: tagMap[21]?.[0], + firstChild: tagMap[22]?.[0], + lastChild: tagMap[23]?.[0], + })) + const getChildren = item => { + if (item.firstChild == null) return item + item.children = items.filter(x => x.parent === item.index).map(getChildren) + return item + } + return items.filter(item => item.headingLevel === 0).map(getChildren) +} + +const getEXTH = (buf, encoding) => { + const { magic, count } = getStruct(EXTH_HEADER, buf) + if (magic !== 'EXTH') throw new Error('Invalid EXTH header') + const decoder = getDecoder(encoding) + const results = {} + let offset = 12 + for (let i = 0; i < count; i++) { + const type = getUint(buf.slice(offset, offset + 4)) + const length = getUint(buf.slice(offset + 4, offset + 8)) + if (type in EXTH_RECORD_TYPE) { + const [name, typ, many] = EXTH_RECORD_TYPE[type] + const data = buf.slice(offset + 8, offset + length) + const value = typ === 'uint' ? getUint(data) : decoder.decode(data) + if (many) { + results[name] ??= [] + results[name].push(value) + } else results[name] = value + } + offset += length + } + return results +} + +const getFont = async (buf, unzlib) => { + const { flags, dataStart, keyLength, keyStart } = getStruct(FONT_HEADER, buf) + const array = new Uint8Array(buf.slice(dataStart)) + // deobfuscate font + if (flags & 0b10) { + const bytes = keyLength === 16 ? 1024 : 1040 + const key = new Uint8Array(buf.slice(keyStart, keyStart + keyLength)) + const length = Math.min(bytes, array.length) + for (var i = 0; i < length; i++) array[i] = array[i] ^ key[i % key.length] + } + // decompress font + if (flags & 1) try { + return await unzlib(array) + } catch (e) { + console.warn(e) + console.warn('Failed to decompress font') + } + return array +} + +export const isMOBI = async file => { + const magic = getString(await file.slice(60, 68).arrayBuffer()) + return magic === 'BOOKMOBI'// || magic === 'TEXtREAd' +} + +class PDB { + #file + #offsets + pdb + async open(file) { + this.#file = file + const pdb = getStruct(PDB_HEADER, await file.slice(0, 78).arrayBuffer()) + this.pdb = pdb + const buffer = await file.slice(78, 78 + pdb.numRecords * 8).arrayBuffer() + // get start and end offsets for each record + this.#offsets = Array.from({ length: pdb.numRecords }, + (_, i) => getUint(buffer.slice(i * 8, i * 8 + 4))) + .map((x, i, a) => [x, a[i + 1]]) + } + loadRecord(index) { + const offsets = this.#offsets[index] + if (!offsets) throw new RangeError('Record index out of bounds') + return this.#file.slice(...offsets).arrayBuffer() + } + async loadMagic(index) { + const start = this.#offsets[index][0] + return getString(await this.#file.slice(start, start + 4).arrayBuffer()) + } +} + +export class MOBI extends PDB { + #start = 0 + #resourceStart + #decoder + #encoder + #decompress + #removeTrailingEntries + constructor({ unzlib }) { + super() + this.unzlib = unzlib + } + async open(file) { + await super.open(file) + // TODO: if (this.pdb.type === 'TEXt') + this.headers = this.#getHeaders(await super.loadRecord(0)) + this.#resourceStart = this.headers.mobi.resourceStart + let isKF8 = this.headers.mobi.version >= 8 + if (!isKF8) { + const boundary = this.headers.exth?.boundary + if (boundary < 0xffffffff) try { + // it's a "combo" MOBI/KF8 file; try to open the KF8 part + this.headers = this.#getHeaders(await super.loadRecord(boundary)) + this.#start = boundary + isKF8 = true + } catch (e) { + console.warn(e) + console.warn('Failed to open KF8; falling back to MOBI') + } + } + await this.#setup() + return isKF8 ? new KF8(this).init() : new MOBI6(this).init() + } + #getHeaders(buf) { + const palmdoc = getStruct(PALMDOC_HEADER, buf) + const mobi = getStruct(MOBI_HEADER, buf) + if (mobi.magic !== 'MOBI') throw new Error('Missing MOBI header') + + const { titleOffset, titleLength, localeLanguage, localeRegion } = mobi + mobi.title = buf.slice(titleOffset, titleOffset + titleLength) + const lang = MOBI_LANG[localeLanguage] + mobi.language = lang?.[localeRegion >> 2] ?? lang?.[0] + + const exth = mobi.exthFlag & 0b100_0000 + ? getEXTH(buf.slice(mobi.length + 16), mobi.encoding) : null + const kf8 = mobi.version >= 8 ? getStruct(KF8_HEADER, buf) : null + return { palmdoc, mobi, exth, kf8 } + } + async #setup() { + const { palmdoc, mobi } = this.headers + this.#decoder = getDecoder(mobi.encoding) + // `TextEncoder` only supports UTF-8 + // we are only encoding ASCII anyway, so I think it's fine + this.#encoder = new TextEncoder() + + // set up decompressor + const { compression } = palmdoc + this.#decompress = compression === 1 ? f => f + : compression === 2 ? decompressPalmDOC + : compression === 17480 ? await huffcdic(mobi, this.loadRecord.bind(this)) + : null + if (!this.#decompress) throw new Error('Unknown compression type') + + // set up function for removing trailing bytes + const { trailingFlags } = mobi + const multibyte = trailingFlags & 1 + const numTrailingEntries = countBitsSet(trailingFlags >>> 1) + this.#removeTrailingEntries = array => { + for (let i = 0; i < numTrailingEntries; i++) { + const length = getVarLenFromEnd(array) + array = array.subarray(0, -length) + } + if (multibyte) { + const length = (array[array.length - 1] & 0b11) + 1 + array = array.subarray(0, -length) + } + return array + } + } + decode(...args) { + return this.#decoder.decode(...args) + } + encode(...args) { + return this.#encoder.encode(...args) + } + loadRecord(index) { + return super.loadRecord(this.#start + index) + } + loadMagic(index) { + return super.loadMagic(this.#start + index) + } + loadText(index) { + return this.loadRecord(index + 1) + .then(buf => new Uint8Array(buf)) + .then(this.#removeTrailingEntries) + .then(this.#decompress) + } + async loadResource(index) { + const buf = await super.loadRecord(this.#resourceStart + index) + const magic = getString(buf.slice(0, 4)) + if (magic === 'FONT') return getFont(buf, this.unzlib) + if (magic === 'VIDE' || magic === 'AUDI') return buf.slice(12) + return buf + } + getNCX() { + const index = this.headers.mobi.indx + if (index < 0xffffffff) return getNCX(index, this.loadRecord.bind(this)) + } + getMetadata() { + const { mobi, exth } = this.headers + return { + identifier: mobi.uid.toString(), + title: unescapeHTML(exth?.title || this.decode(mobi.title)), + author: exth?.creator?.map(unescapeHTML), + publisher: unescapeHTML(exth?.publisher), + language: exth?.language ?? mobi.language, + published: exth?.date, + description: unescapeHTML(exth?.description), + subject: exth?.subject?.map(unescapeHTML), + rights: unescapeHTML(exth?.rights), + contributor: exth?.contributor, + } + } + async getCover() { + const { exth } = this.headers + const offset = exth?.coverOffset < 0xffffffff ? exth?.coverOffset + : exth?.thumbnailOffset < 0xffffffff ? exth?.thumbnailOffset : null + if (offset != null) { + const buf = await this.loadResource(offset) + return new Blob([buf]) + } + } +} + +const mbpPagebreakRegex = /<\s*(?:mbp:)?pagebreak[^>]*>/gi +const fileposRegex = /<[^<>]+filepos=['"]{0,1}(\d+)[^<>]*>/gi + +const getIndent = el => { + let x = 0 + while (el) { + const parent = el.parentElement + if (parent) { + const tag = parent.tagName.toLowerCase() + if (tag === 'p') x += 1.5 + else if (tag === 'blockquote') x += 2 + } + el = parent + } + return x +} + +function rawBytesToString(uint8Array) { + const chunkSize = 0x8000 + let result = '' + for (let i = 0; i < uint8Array.length; i += chunkSize) { + result += String.fromCharCode.apply(null, uint8Array.subarray(i, i + chunkSize)) + } + return result +} + +class MOBI6 { + parser = new DOMParser() + serializer = new XMLSerializer() + #resourceCache = new Map() + #textCache = new Map() + #cache = new Map() + #sections + #fileposList = [] + #type = MIME.HTML + constructor(mobi) { + this.mobi = mobi + } + async init() { + const recordBuffers = [] + for (let i = 0; i < this.mobi.headers.palmdoc.numTextRecords; i++) { + const buf = await this.mobi.loadText(i) + recordBuffers.push(buf) + } + const totalLength = recordBuffers.reduce((sum, buf) => sum + buf.byteLength, 0) + // load all text records in an array + const array = new Uint8Array(totalLength) + recordBuffers.reduce((offset, buf) => { + array.set(new Uint8Array(buf), offset) + return offset + buf.byteLength + }, 0) + // convert to string so we can use regex + // note that `filepos` are byte offsets + // so it needs to preserve each byte as a separate character + // (see https://stackoverflow.com/q/50198017) + const str = rawBytesToString(array) + + // split content into sections at each `` + this.#sections = [0] + .concat(Array.from(str.matchAll(mbpPagebreakRegex), m => m.index)) + .map((start, i, a) => { + const end = a[i + 1] ?? array.length + return { book: this, raw: array.subarray(start, end) } + }) + // get start and end filepos for each section + .map((section, i, arr) => { + section.start = arr[i - 1]?.end ?? 0 + section.end = section.start + section.raw.byteLength + return section + }) + + this.sections = this.#sections.map((section, index) => ({ + id: index, + load: () => this.loadSection(section), + createDocument: () => this.createDocument(section), + size: section.end - section.start, + })) + + try { + this.landmarks = await this.getGuide() + const tocHref = this.landmarks + .find(({ type }) => type?.includes('toc'))?.href + if (tocHref) { + const { index } = this.resolveHref(tocHref) + const doc = await this.sections[index].createDocument() + let lastItem + let lastLevel = 0 + let lastIndent = 0 + const lastLevelOfIndent = new Map() + const lastParentOfLevel = new Map() + this.toc = Array.from(doc.querySelectorAll('a[filepos]')) + .reduce((arr, a) => { + const indent = getIndent(a) + const item = { + label: a.innerText?.trim() ?? '', + href: `filepos:${a.getAttribute('filepos')}`, + } + const level = indent > lastIndent ? lastLevel + 1 + : indent === lastIndent ? lastLevel + : lastLevelOfIndent.get(indent) ?? Math.max(0, lastLevel - 1) + if (level > lastLevel) { + if (lastItem) { + lastItem.subitems ??= [] + lastItem.subitems.push(item) + lastParentOfLevel.set(level, lastItem) + } + else arr.push(item) + } + else { + const parent = lastParentOfLevel.get(level) + if (parent) parent.subitems.push(item) + else arr.push(item) + } + lastItem = item + lastLevel = level + lastIndent = indent + lastLevelOfIndent.set(indent, level) + return arr + }, []) + } + } catch(e) { + console.warn(e) + } + + // get list of all `filepos` references in the book, + // which will be used to insert anchor elements + // because only then can they be referenced in the DOM + this.#fileposList = [...new Set( + Array.from(str.matchAll(fileposRegex), m => m[1]))] + .map(filepos => ({ filepos, number: Number(filepos) })) + .sort((a, b) => a.number - b.number) + + this.metadata = this.mobi.getMetadata() + this.getCover = this.mobi.getCover.bind(this.mobi) + return this + } + async getGuide() { + const doc = await this.createDocument(this.#sections[0]) + return Array.from(doc.getElementsByTagName('reference'), ref => ({ + label: ref.getAttribute('title'), + type: ref.getAttribute('type')?.split(/\s/), + href: `filepos:${ref.getAttribute('filepos')}`, + })) + } + async loadResource(index) { + if (this.#resourceCache.has(index)) return this.#resourceCache.get(index) + const raw = await this.mobi.loadResource(index) + const url = URL.createObjectURL(new Blob([raw])) + this.#resourceCache.set(index, url) + return url + } + async loadRecindex(recindex) { + return this.loadResource(Number(recindex) - 1) + } + async replaceResources(doc) { + for (const img of doc.querySelectorAll('img[recindex]')) { + const recindex = img.getAttribute('recindex') + try { + img.src = await this.loadRecindex(recindex) + } catch { + console.warn(`Failed to load image ${recindex}`) + } + } + for (const media of doc.querySelectorAll('[mediarecindex]')) { + const mediarecindex = media.getAttribute('mediarecindex') + const recindex = media.getAttribute('recindex') + try { + media.src = await this.loadRecindex(mediarecindex) + if (recindex) media.poster = await this.loadRecindex(recindex) + } catch { + console.warn(`Failed to load media ${mediarecindex}`) + } + } + for (const a of doc.querySelectorAll('[filepos]')) { + const filepos = a.getAttribute('filepos') + a.href = `filepos:${filepos}` + } + } + async loadText(section) { + if (this.#textCache.has(section)) return this.#textCache.get(section) + const { raw } = section + + // insert anchor elements for each `filepos` + const fileposList = this.#fileposList + .filter(({ number }) => number >= section.start && number < section.end) + .map(obj => ({ ...obj, offset: obj.number - section.start })) + let arr = raw + if (fileposList.length) { + arr = raw.subarray(0, fileposList[0].offset) + fileposList.forEach(({ filepos, offset }, i) => { + const next = fileposList[i + 1] + const a = this.mobi.encode(``) + arr = concatTypedArray3(arr, a, raw.subarray(offset, next?.offset)) + }) + } + const str = this.mobi.decode(arr).replaceAll(mbpPagebreakRegex, '') + this.#textCache.set(section, str) + return str + } + async createDocument(section) { + const str = await this.loadText(section) + return this.parser.parseFromString(str, this.#type) + } + async loadSection(section) { + if (this.#cache.has(section)) return this.#cache.get(section) + const doc = await this.createDocument(section) + + // inject default stylesheet + const style = doc.createElement('style') + doc.head.append(style) + // blockquotes in MOBI seem to have only a small left margin by default + // many books seem to rely on this, as it's the only way to set margin + // (since there's no CSS) + style.append(doc.createTextNode(`blockquote { + margin-block-start: 0; + margin-block-end: 0; + margin-inline-start: 1em; + margin-inline-end: 0; + }`)) + + await this.replaceResources(doc) + const result = this.serializer.serializeToString(doc) + const url = URL.createObjectURL(new Blob([result], { type: this.#type })) + this.#cache.set(section, url) + return url + } + resolveHref(href) { + const filepos = href.match(/filepos:(.*)/)[1] + const number = Number(filepos) + const index = this.#sections.findIndex(section => section.end > number) + const anchor = doc => doc.getElementById(`filepos${filepos}`) + return { index, anchor } + } + splitTOCHref(href) { + const filepos = href.match(/filepos:(.*)/)[1] + const number = Number(filepos) + const index = this.#sections.findIndex(section => section.end > number) + return [index, `filepos${filepos}`] + } + getTOCFragment(doc, id) { + return doc.getElementById(id) + } + isExternal(uri) { + return /^(?!blob|filepos)\w+:/i.test(uri) + } + destroy() { + for (const url of this.#resourceCache.values()) URL.revokeObjectURL(url) + for (const url of this.#cache.values()) URL.revokeObjectURL(url) + } +} + +// handlers for `kindle:` uris +const kindleResourceRegex = /kindle:(flow|embed):(\w+)(?:\?mime=(\w+\/[-+.\w]+))?/ +const kindlePosRegex = /kindle:pos:fid:(\w+):off:(\w+)/ +const parseResourceURI = str => { + const [resourceType, id, type] = str.match(kindleResourceRegex).slice(1) + return { resourceType, id: parseInt(id, 32), type } +} +const parsePosURI = str => { + const [fid, off] = str.match(kindlePosRegex).slice(1) + return { fid: parseInt(fid, 32), off: parseInt(off, 32) } +} +const makePosURI = (fid = 0, off = 0) => + `kindle:pos:fid:${fid.toString(32).toUpperCase().padStart(4, '0') + }:off:${off.toString(32).toUpperCase().padStart(10, '0')}` + +// `kindle:pos:` links are originally links that contain fragments identifiers +// so there should exist an element with `id` or `name` +// otherwise try to find one with an `aid` attribute +const getFragmentSelector = str => { + const match = str.match(/\s(id|name|aid)\s*=\s*['"]([^'"]*)['"]/i) + if (!match) return + const [, attr, value] = match + return `[${attr}="${CSS.escape(value)}"]` +} + +// replace asynchronously and sequentially +const replaceSeries = async (str, regex, f) => { + const matches = [] + str.replace(regex, (...args) => (matches.push(args), null)) + const results = [] + for (const args of matches) results.push(await f(...args)) + return str.replace(regex, () => results.shift()) +} + +const getPageSpread = properties => { + for (const p of properties) { + if (p === 'page-spread-left' || p === 'rendition:page-spread-left') + return 'left' + if (p === 'page-spread-right' || p === 'rendition:page-spread-right') + return 'right' + if (p === 'rendition:page-spread-center') return 'center' + } +} + +class KF8 { + parser = new DOMParser() + serializer = new XMLSerializer() + transformTarget = new EventTarget() + #cache = new Map() + #fragmentOffsets = new Map() + #fragmentSelectors = new Map() + #tables = {} + #sections + #fullRawLength + #rawHead = new Uint8Array() + #rawTail = new Uint8Array() + #lastLoadedHead = -1 + #lastLoadedTail = -1 + #type = MIME.XHTML + #inlineMap = new Map() + constructor(mobi) { + this.mobi = mobi + } + async init() { + const loadRecord = this.mobi.loadRecord.bind(this.mobi) + const { kf8 } = this.mobi.headers + + try { + const fdstBuffer = await loadRecord(kf8.fdst) + const fdst = getStruct(FDST_HEADER, fdstBuffer) + if (fdst.magic !== 'FDST') throw new Error('Missing FDST record') + const fdstTable = Array.from({ length: fdst.numEntries }, + (_, i) => 12 + i * 8) + .map(offset => [ + getUint(fdstBuffer.slice(offset, offset + 4)), + getUint(fdstBuffer.slice(offset + 4, offset + 8))]) + this.#tables.fdstTable = fdstTable + this.#fullRawLength = fdstTable[fdstTable.length - 1][1] + } catch {} + + const skelTable = (await getIndexData(kf8.skel, loadRecord)).table + .map(({ name, tagMap }, index) => ({ + index, name, + numFrag: tagMap[1][0], + offset: tagMap[6][0], + length: tagMap[6][1], + })) + const fragData = await getIndexData(kf8.frag, loadRecord) + const fragTable = fragData.table.map(({ name, tagMap }) => ({ + insertOffset: parseInt(name), + selector: fragData.cncx[tagMap[2][0]], + index: tagMap[4][0], + offset: tagMap[6][0], + length: tagMap[6][1], + })) + this.#tables.skelTable = skelTable + this.#tables.fragTable = fragTable + + this.#sections = skelTable.reduce((arr, skel) => { + const last = arr[arr.length - 1] + const fragStart = last?.fragEnd ?? 0, fragEnd = fragStart + skel.numFrag + const frags = fragTable.slice(fragStart, fragEnd) + const length = skel.length + frags.map(f => f.length).reduce((a, b) => a + b, 0) + const totalLength = (last?.totalLength ?? 0) + length + return arr.concat({ skel, frags, fragEnd, length, totalLength }) + }, []) + + const resources = await this.getResourcesByMagic(['RESC', 'PAGE']) + const pageSpreads = new Map() + if (resources.RESC) { + const buf = await this.mobi.loadRecord(resources.RESC) + const str = this.mobi.decode(buf.slice(16)).replace(/\0/g, '') + // the RESC record lacks the root `` element + // but seem to be otherwise valid XML + const index = str.search(/\?>/) + const xmlStr = `${str.slice(index)}` + const opf = this.parser.parseFromString(xmlStr, MIME.XML) + for (const $itemref of opf.querySelectorAll('spine > itemref')) { + const i = parseInt($itemref.getAttribute('skelid')) + pageSpreads.set(i, getPageSpread( + $itemref.getAttribute('properties')?.split(' ') ?? [])) + } + } + + this.sections = this.#sections.map((section, index) => + section.frags.length ? ({ + id: index, + load: () => this.loadSection(section), + createDocument: () => this.createDocument(section), + size: section.length, + pageSpread: pageSpreads.get(index), + }) : ({ linear: 'no' })) + + try { + const ncx = await this.mobi.getNCX() + const map = ({ label, pos, children }) => { + const [fid, off] = pos + const href = makePosURI(fid, off) + const arr = this.#fragmentOffsets.get(fid) + if (arr) arr.push(off) + else this.#fragmentOffsets.set(fid, [off]) + return { label: unescapeHTML(label), href, subitems: children?.map(map) } + } + this.toc = ncx?.map(map) + this.landmarks = await this.getGuide() + } catch(e) { + console.warn(e) + } + + const { exth } = this.mobi.headers + this.dir = exth.pageProgressionDirection + this.rendition = { + layout: exth.fixedLayout === 'true' ? 'pre-paginated' : 'reflowable', + viewport: Object.fromEntries(exth.originalResolution + ?.split('x')?.slice(0, 2) + ?.map((x, i) => [i ? 'height' : 'width', x]) ?? []), + } + + this.metadata = this.mobi.getMetadata() + this.getCover = this.mobi.getCover.bind(this.mobi) + return this + } + // is this really the only way of getting to RESC, PAGE, etc.? + async getResourcesByMagic(keys) { + const results = {} + const start = this.mobi.headers.kf8.resourceStart + const end = this.mobi.pdb.numRecords + for (let i = start; i < end; i++) { + try { + const magic = await this.mobi.loadMagic(i) + const match = keys.find(key => key === magic) + if (match) results[match] = i + } catch {} + } + return results + } + async getGuide() { + const index = this.mobi.headers.kf8.guide + if (index < 0xffffffff) { + const loadRecord = this.mobi.loadRecord.bind(this.mobi) + const { table, cncx } = await getIndexData(index, loadRecord) + return table.map(({ name, tagMap }) => ({ + label: cncx[tagMap[1][0]] ?? '', + type: name?.split(/\s/), + href: makePosURI(tagMap[6]?.[0] ?? tagMap[3]?.[0]), + })) + } + } + async loadResourceBlob(str) { + const { resourceType, id, type } = parseResourceURI(str) + const raw = resourceType === 'flow' ? await this.loadFlow(id) + : await this.mobi.loadResource(id - 1) + const result = [MIME.XHTML, MIME.HTML, MIME.CSS, MIME.SVG].includes(type) + ? await this.replaceResources(this.mobi.decode(raw)) : raw + const detail = { data: result, type } + const event = new CustomEvent('data', { detail }) + this.transformTarget.dispatchEvent(event) + const newData = await event.detail.data + const newType = await event.detail.type + const doc = newType === MIME.SVG ? this.parser.parseFromString(newData, newType) : null + return [new Blob([newData], { newType }), + // SVG wrappers need to be inlined + // as browsers don't allow external resources when loading SVG as an image + doc?.getElementsByTagNameNS('http://www.w3.org/2000/svg', 'image')?.length + ? doc.documentElement : null] + } + async loadResource(str) { + if (this.#cache.has(str)) return this.#cache.get(str) + const [blob, inline] = await this.loadResourceBlob(str) + const url = inline ? str : URL.createObjectURL(blob) + if (inline) this.#inlineMap.set(url, inline) + this.#cache.set(str, url) + return url + } + replaceResources(str) { + const regex = new RegExp(kindleResourceRegex, 'g') + return replaceSeries(str, regex, this.loadResource.bind(this)) + } + // NOTE: there doesn't seem to be a way to access text randomly? + // how to know the decompressed size of the records without decompressing? + // 4096 is just the maximum size + async loadRaw(start, end) { + // here we load either from the front or back until we have reached the + // required offsets; at worst you'd have to load half the book at once + const distanceHead = end - this.#rawHead.length + const distanceEnd = this.#fullRawLength == null ? Infinity + : (this.#fullRawLength - this.#rawTail.length) - start + // load from the start + if (distanceHead < 0 || distanceHead < distanceEnd) { + while (this.#rawHead.length < end) { + const index = ++this.#lastLoadedHead + const data = await this.mobi.loadText(index) + this.#rawHead = concatTypedArray(this.#rawHead, data) + } + return this.#rawHead.slice(start, end) + } + // load from the end + while (this.#fullRawLength - this.#rawTail.length > start) { + const index = this.mobi.headers.palmdoc.numTextRecords - 1 + - (++this.#lastLoadedTail) + const data = await this.mobi.loadText(index) + this.#rawTail = concatTypedArray(data, this.#rawTail) + } + const rawTailStart = this.#fullRawLength - this.#rawTail.length + return this.#rawTail.slice(start - rawTailStart, end - rawTailStart) + } + loadFlow(index) { + if (index < 0xffffffff) + return this.loadRaw(...this.#tables.fdstTable[index]) + } + async loadText(section) { + const { skel, frags, length } = section + const raw = await this.loadRaw(skel.offset, skel.offset + length) + let skeleton = raw.slice(0, skel.length) + for (const frag of frags) { + const insertOffset = frag.insertOffset - skel.offset + const offset = skel.length + frag.offset + const fragRaw = raw.slice(offset, offset + frag.length) + skeleton = concatTypedArray3( + skeleton.slice(0, insertOffset), fragRaw, + skeleton.slice(insertOffset)) + + const offsets = this.#fragmentOffsets.get(frag.index) + if (offsets) for (const offset of offsets) { + const str = this.mobi.decode(fragRaw.slice(offset)) + const selector = getFragmentSelector(str) + this.#setFragmentSelector(frag.index, offset, selector) + } + } + return this.mobi.decode(skeleton) + } + async createDocument(section) { + const str = await this.loadText(section) + return this.parser.parseFromString(str, this.#type) + } + async loadSection(section) { + if (this.#cache.has(section)) return this.#cache.get(section) + const str = await this.loadText(section) + const replaced = await this.replaceResources(str) + + // by default, type is XHTML; change to HTML if it's not valid XHTML + let doc = this.parser.parseFromString(replaced, this.#type) + if (doc.querySelector('parsererror') || !doc.documentElement?.namespaceURI) { + this.#type = MIME.HTML + doc = this.parser.parseFromString(replaced, this.#type) + } + for (const [url, node] of this.#inlineMap) { + for (const el of doc.querySelectorAll(`img[src="${url}"]`)) + el.replaceWith(node) + } + const url = URL.createObjectURL( + new Blob([this.serializer.serializeToString(doc)], { type: this.#type })) + this.#cache.set(section, url) + return url + } + getIndexByFID(fid) { + return this.#sections.findIndex(section => + section.frags.some(frag => frag.index === fid)) + } + #setFragmentSelector(id, offset, selector) { + const map = this.#fragmentSelectors.get(id) + if (map) map.set(offset, selector) + else { + const map = new Map() + this.#fragmentSelectors.set(id, map) + map.set(offset, selector) + } + } + async resolveHref(href) { + const { fid, off } = parsePosURI(href) + const index = this.getIndexByFID(fid) + if (index < 0) return + + const saved = this.#fragmentSelectors.get(fid)?.get(off) + if (saved) return { index, anchor: doc => doc.querySelector(saved) } + + const { skel, frags } = this.#sections[index] + const frag = frags.find(frag => frag.index === fid) + const offset = skel.offset + skel.length + frag.offset + const fragRaw = await this.loadRaw(offset, offset + frag.length) + const str = this.mobi.decode(fragRaw.slice(off)) + const selector = getFragmentSelector(str) + this.#setFragmentSelector(fid, off, selector) + const anchor = doc => doc.querySelector(selector) + return { index, anchor } + } + splitTOCHref(href) { + const pos = parsePosURI(href) + const index = this.getIndexByFID(pos.fid) + return [index, pos] + } + getTOCFragment(doc, { fid, off }) { + const selector = this.#fragmentSelectors.get(fid)?.get(off) + return doc.querySelector(selector) + } + isExternal(uri) { + return /^(?!blob|kindle)\w+:/i.test(uri) + } + destroy() { + for (const url of this.#cache.values()) URL.revokeObjectURL(url) + } +} diff --git a/booklore-ui/src/assets/foliate/overlayer.js b/booklore-ui/src/assets/foliate/overlayer.js new file mode 100644 index 000000000..6fd03ab58 --- /dev/null +++ b/booklore-ui/src/assets/foliate/overlayer.js @@ -0,0 +1,175 @@ +const createSVGElement = tag => + document.createElementNS('http://www.w3.org/2000/svg', tag) + +export class Overlayer { + #svg = createSVGElement('svg') + #map = new Map() + constructor() { + Object.assign(this.#svg.style, { + position: 'absolute', top: '0', left: '0', + width: '100%', height: '100%', + pointerEvents: 'none', + }) + } + get element() { + return this.#svg + } + add(key, range, draw, options) { + if (this.#map.has(key)) this.remove(key) + if (typeof range === 'function') range = range(this.#svg.getRootNode()) + const rects = range.getClientRects() + const element = draw(rects, options) + this.#svg.append(element) + this.#map.set(key, { range, draw, options, element, rects }) + } + remove(key) { + if (!this.#map.has(key)) return + this.#svg.removeChild(this.#map.get(key).element) + this.#map.delete(key) + } + redraw() { + for (const obj of this.#map.values()) { + const { range, draw, options, element } = obj + this.#svg.removeChild(element) + const rects = range.getClientRects() + const el = draw(rects, options) + this.#svg.append(el) + obj.element = el + obj.rects = rects + } + } + hitTest({ x, y }) { + const arr = Array.from(this.#map.entries()) + // loop in reverse to hit more recently added items first + for (let i = arr.length - 1; i >= 0; i--) { + const [key, obj] = arr[i] + for (const { left, top, right, bottom } of obj.rects) + if (top <= y && left <= x && bottom > y && right > x) + return [key, obj.range] + } + return [] + } + static underline(rects, options = {}) { + const { color = 'red', width: strokeWidth = 2, writingMode } = options + const g = createSVGElement('g') + g.setAttribute('fill', color) + if (writingMode === 'vertical-rl' || writingMode === 'vertical-lr') + for (const { right, top, height } of rects) { + const el = createSVGElement('rect') + el.setAttribute('x', right - strokeWidth) + el.setAttribute('y', top) + el.setAttribute('height', height) + el.setAttribute('width', strokeWidth) + g.append(el) + } + else for (const { left, bottom, width } of rects) { + const el = createSVGElement('rect') + el.setAttribute('x', left) + el.setAttribute('y', bottom - strokeWidth) + el.setAttribute('height', strokeWidth) + el.setAttribute('width', width) + g.append(el) + } + return g + } + static strikethrough(rects, options = {}) { + const { color = 'red', width: strokeWidth = 2, writingMode } = options + const g = createSVGElement('g') + g.setAttribute('fill', color) + if (writingMode === 'vertical-rl' || writingMode === 'vertical-lr') + for (const { right, left, top, height } of rects) { + const el = createSVGElement('rect') + el.setAttribute('x', (right + left) / 2) + el.setAttribute('y', top) + el.setAttribute('height', height) + el.setAttribute('width', strokeWidth) + g.append(el) + } + else for (const { left, top, bottom, width } of rects) { + const el = createSVGElement('rect') + el.setAttribute('x', left) + el.setAttribute('y', (top + bottom) / 2) + el.setAttribute('height', strokeWidth) + el.setAttribute('width', width) + g.append(el) + } + return g + } + static squiggly(rects, options = {}) { + const { color = 'red', width: strokeWidth = 2, writingMode } = options + const g = createSVGElement('g') + g.setAttribute('fill', 'none') + g.setAttribute('stroke', color) + g.setAttribute('stroke-width', strokeWidth) + const block = strokeWidth * 1.5 + if (writingMode === 'vertical-rl' || writingMode === 'vertical-lr') + for (const { right, top, height } of rects) { + const el = createSVGElement('path') + const n = Math.round(height / block / 1.5) + const inline = height / n + const ls = Array.from({ length: n }, + (_, i) => `l${i % 2 ? -block : block} ${inline}`).join('') + el.setAttribute('d', `M${right} ${top}${ls}`) + g.append(el) + } + else for (const { left, bottom, width } of rects) { + const el = createSVGElement('path') + const n = Math.round(width / block / 1.5) + const inline = width / n + const ls = Array.from({ length: n }, + (_, i) => `l${inline} ${i % 2 ? block : -block}`).join('') + el.setAttribute('d', `M${left} ${bottom}${ls}`) + g.append(el) + } + return g + } + static highlight(rects, options = {}) { + const { color = 'red' } = options + const g = createSVGElement('g') + g.setAttribute('fill', color) + g.style.opacity = 'var(--overlayer-highlight-opacity, .3)' + g.style.mixBlendMode = 'var(--overlayer-highlight-blend-mode, normal)' + for (const { left, top, height, width } of rects) { + const el = createSVGElement('rect') + el.setAttribute('x', left) + el.setAttribute('y', top) + el.setAttribute('height', height) + el.setAttribute('width', width) + g.append(el) + } + return g + } + static outline(rects, options = {}) { + const { color = 'red', width: strokeWidth = 3, radius = 3 } = options + const g = createSVGElement('g') + g.setAttribute('fill', 'none') + g.setAttribute('stroke', color) + g.setAttribute('stroke-width', strokeWidth) + for (const { left, top, height, width } of rects) { + const el = createSVGElement('rect') + el.setAttribute('x', left) + el.setAttribute('y', top) + el.setAttribute('height', height) + el.setAttribute('width', width) + el.setAttribute('rx', radius) + g.append(el) + } + return g + } + // make an exact copy of an image in the overlay + // one can then apply filters to the entire element, without affecting them; + // it's a bit silly and probably better to just invert images twice + // (though the color will be off in that case if you do heu-rotate) + static copyImage([rect], options = {}) { + const { src } = options + const image = createSVGElement('image') + const { left, top, height, width } = rect + image.setAttribute('href', src) + image.setAttribute('x', left) + image.setAttribute('y', top) + image.setAttribute('height', height) + image.setAttribute('width', width) + return image + } +} + diff --git a/booklore-ui/src/assets/foliate/paginator.js b/booklore-ui/src/assets/foliate/paginator.js new file mode 100644 index 000000000..7980c2029 --- /dev/null +++ b/booklore-ui/src/assets/foliate/paginator.js @@ -0,0 +1,1130 @@ +const wait = ms => new Promise(resolve => setTimeout(resolve, ms)) + +const debounce = (f, wait, immediate) => { + let timeout + return (...args) => { + const later = () => { + timeout = null + if (!immediate) f(...args) + } + const callNow = immediate && !timeout + if (timeout) clearTimeout(timeout) + timeout = setTimeout(later, wait) + if (callNow) f(...args) + } +} + +const lerp = (min, max, x) => x * (max - min) + min +const easeOutQuad = x => 1 - (1 - x) * (1 - x) +const animate = (a, b, duration, ease, render) => new Promise(resolve => { + let start + const step = now => { + if (document.hidden) { + render(lerp(a, b, 1)) + return resolve() + } + start ??= now + const fraction = Math.min(1, (now - start) / duration) + render(lerp(a, b, ease(fraction))) + if (fraction < 1) requestAnimationFrame(step) + else resolve() + } + if (document.hidden) { + render(lerp(a, b, 1)) + return resolve() + } + requestAnimationFrame(step) +}) + +// collapsed range doesn't return client rects sometimes (or always?) +// try make get a non-collapsed range or element +const uncollapse = range => { + if (!range?.collapsed) return range + const { endOffset, endContainer } = range + if (endContainer.nodeType === 1) { + const node = endContainer.childNodes[endOffset] + if (node?.nodeType === 1) return node + return endContainer + } + if (endOffset + 1 < endContainer.length) range.setEnd(endContainer, endOffset + 1) + else if (endOffset > 1) range.setStart(endContainer, endOffset - 1) + else return endContainer.parentNode + return range +} + +const makeRange = (doc, node, start, end = start) => { + const range = doc.createRange() + range.setStart(node, start) + range.setEnd(node, end) + return range +} + +// use binary search to find an offset value in a text node +const bisectNode = (doc, node, cb, start = 0, end = node.nodeValue.length) => { + if (end - start === 1) { + const result = cb(makeRange(doc, node, start), makeRange(doc, node, end)) + return result < 0 ? start : end + } + const mid = Math.floor(start + (end - start) / 2) + const result = cb(makeRange(doc, node, start, mid), makeRange(doc, node, mid, end)) + return result < 0 ? bisectNode(doc, node, cb, start, mid) + : result > 0 ? bisectNode(doc, node, cb, mid, end) : mid +} + +const { SHOW_ELEMENT, SHOW_TEXT, SHOW_CDATA_SECTION, + FILTER_ACCEPT, FILTER_REJECT, FILTER_SKIP } = NodeFilter + +const filter = SHOW_ELEMENT | SHOW_TEXT | SHOW_CDATA_SECTION + +// needed cause there seems to be a bug in `getBoundingClientRect()` in Firefox +// where it fails to include rects that have zero width and non-zero height +// (CSSOM spec says "rectangles [...] of which the height or width is not zero") +// which makes the visible range include an extra space at column boundaries +const getBoundingClientRect = target => { + let top = Infinity, right = -Infinity, left = Infinity, bottom = -Infinity + for (const rect of target.getClientRects()) { + left = Math.min(left, rect.left) + top = Math.min(top, rect.top) + right = Math.max(right, rect.right) + bottom = Math.max(bottom, rect.bottom) + } + return new DOMRect(left, top, right - left, bottom - top) +} + +const getVisibleRange = (doc, start, end, mapRect) => { + // first get all visible nodes + const acceptNode = node => { + const name = node.localName?.toLowerCase() + // ignore all scripts, styles, and their children + if (name === 'script' || name === 'style') return FILTER_REJECT + if (node.nodeType === 1) { + const { left, right } = mapRect(node.getBoundingClientRect()) + // no need to check child nodes if it's completely out of view + if (right < start || left > end) return FILTER_REJECT + // elements must be completely in view to be considered visible + // because you can't specify offsets for elements + if (left >= start && right <= end) return FILTER_ACCEPT + // TODO: it should probably allow elements that do not contain text + // because they can exceed the whole viewport in both directions + // especially in scrolled mode + } else { + // ignore empty text nodes + if (!node.nodeValue?.trim()) return FILTER_SKIP + // create range to get rect + const range = doc.createRange() + range.selectNodeContents(node) + const { left, right } = mapRect(range.getBoundingClientRect()) + // it's visible if any part of it is in view + if (right >= start && left <= end) return FILTER_ACCEPT + } + return FILTER_SKIP + } + const walker = doc.createTreeWalker(doc.body, filter, { acceptNode }) + const nodes = [] + for (let node = walker.nextNode(); node; node = walker.nextNode()) + nodes.push(node) + + // we're only interested in the first and last visible nodes + const from = nodes[0] ?? doc.body + const to = nodes[nodes.length - 1] ?? from + + // find the offset at which visibility changes + const startOffset = from.nodeType === 1 ? 0 + : bisectNode(doc, from, (a, b) => { + const p = mapRect(getBoundingClientRect(a)) + const q = mapRect(getBoundingClientRect(b)) + if (p.right < start && q.left > start) return 0 + return q.left > start ? -1 : 1 + }) + const endOffset = to.nodeType === 1 ? 0 + : bisectNode(doc, to, (a, b) => { + const p = mapRect(getBoundingClientRect(a)) + const q = mapRect(getBoundingClientRect(b)) + if (p.right < end && q.left > end) return 0 + return q.left > end ? -1 : 1 + }) + + const range = doc.createRange() + range.setStart(from, startOffset) + range.setEnd(to, endOffset) + return range +} + +const selectionIsBackward = sel => { + const range = document.createRange() + range.setStart(sel.anchorNode, sel.anchorOffset) + range.setEnd(sel.focusNode, sel.focusOffset) + return range.collapsed +} + +const setSelectionTo = (target, collapse) => { + let range + if (target.startContainer) range = target.cloneRange() + else if (target.nodeType) { + range = document.createRange() + range.selectNode(target) + } + if (range) { + const sel = range.startContainer.ownerDocument.defaultView.getSelection() + if (sel) { + sel.removeAllRanges() + if (collapse === -1) range.collapse(true) + else if (collapse === 1) range.collapse() + sel.addRange(range) + } + } +} + +const getDirection = doc => { + const { defaultView } = doc + const { writingMode, direction } = defaultView.getComputedStyle(doc.body) + const vertical = writingMode === 'vertical-rl' + || writingMode === 'vertical-lr' + const rtl = doc.body.dir === 'rtl' + || direction === 'rtl' + || doc.documentElement.dir === 'rtl' + return { vertical, rtl } +} + +const getBackground = doc => { + const bodyStyle = doc.defaultView.getComputedStyle(doc.body) + return bodyStyle.backgroundColor === 'rgba(0, 0, 0, 0)' + && bodyStyle.backgroundImage === 'none' + ? doc.defaultView.getComputedStyle(doc.documentElement).background + : bodyStyle.background +} + +const makeMarginals = (length, part) => Array.from({ length }, () => { + const div = document.createElement('div') + const child = document.createElement('div') + div.append(child) + child.setAttribute('part', part) + return div +}) + +const setStylesImportant = (el, styles) => { + const { style } = el + for (const [k, v] of Object.entries(styles)) style.setProperty(k, v, 'important') +} + +class View { + #observer = new ResizeObserver(() => this.expand()) + #element = document.createElement('div') + #iframe = document.createElement('iframe') + #contentRange = document.createRange() + #overlayer + #vertical = false + #rtl = false + #column = true + #size + #layout = {} + constructor({ container, onExpand }) { + this.container = container + this.onExpand = onExpand + this.#iframe.setAttribute('part', 'filter') + this.#element.append(this.#iframe) + Object.assign(this.#element.style, { + boxSizing: 'content-box', + position: 'relative', + overflow: 'hidden', + flex: '0 0 auto', + width: '100%', height: '100%', + display: 'flex', + justifyContent: 'center', + alignItems: 'center', + }) + Object.assign(this.#iframe.style, { + overflow: 'hidden', + border: '0', + display: 'none', + width: '100%', height: '100%', + }) + // `allow-scripts` is needed for events because of WebKit bug + // https://bugs.webkit.org/show_bug.cgi?id=218086 + this.#iframe.setAttribute('sandbox', 'allow-same-origin allow-scripts') + this.#iframe.setAttribute('scrolling', 'no') + } + get element() { + return this.#element + } + get document() { + return this.#iframe.contentDocument + } + async load(src, afterLoad, beforeRender) { + if (typeof src !== 'string') throw new Error(`${src} is not string`) + return new Promise(resolve => { + this.#iframe.addEventListener('load', () => { + const doc = this.document + afterLoad?.(doc) + + // it needs to be visible for Firefox to get computed style + this.#iframe.style.display = 'block' + const { vertical, rtl } = getDirection(doc) + const background = getBackground(doc) + this.#iframe.style.display = 'none' + + this.#vertical = vertical + this.#rtl = rtl + + this.#contentRange.selectNodeContents(doc.body) + const layout = beforeRender?.({ vertical, rtl, background }) + this.#iframe.style.display = 'block' + this.render(layout) + this.#observer.observe(doc.body) + + // the resize observer above doesn't work in Firefox + // (see https://bugzilla.mozilla.org/show_bug.cgi?id=1832939) + // until the bug is fixed we can at least account for font load + doc.fonts.ready.then(() => this.expand()) + + resolve() + }, { once: true }) + this.#iframe.src = src + }) + } + render(layout) { + if (!layout) return + this.#column = layout.flow !== 'scrolled' + this.#layout = layout + if (this.#column) this.columnize(layout) + else this.scrolled(layout) + } + scrolled({ gap, columnWidth }) { + const vertical = this.#vertical + const doc = this.document + setStylesImportant(doc.documentElement, { + 'box-sizing': 'border-box', + 'padding': vertical ? `${gap}px 0` : `0 ${gap}px`, + 'column-width': 'auto', + 'height': 'auto', + 'width': 'auto', + }) + setStylesImportant(doc.body, { + [vertical ? 'max-height' : 'max-width']: `${columnWidth}px`, + 'margin': 'auto', + }) + this.setImageSize() + this.expand() + } + columnize({ width, height, gap, columnWidth }) { + const vertical = this.#vertical + this.#size = vertical ? height : width + + const doc = this.document + setStylesImportant(doc.documentElement, { + 'box-sizing': 'border-box', + 'column-width': `${Math.trunc(columnWidth)}px`, + 'column-gap': `${gap}px`, + 'column-fill': 'auto', + ...(vertical + ? { 'width': `${width}px` } + : { 'height': `${height}px` }), + 'padding': vertical ? `${gap / 2}px 0` : `0 ${gap / 2}px`, + 'overflow': 'hidden', + // force wrap long words + 'overflow-wrap': 'break-word', + // reset some potentially problematic props + 'position': 'static', 'border': '0', 'margin': '0', + 'max-height': 'none', 'max-width': 'none', + 'min-height': 'none', 'min-width': 'none', + // fix glyph clipping in WebKit + '-webkit-line-box-contain': 'block glyphs replaced', + }) + setStylesImportant(doc.body, { + 'max-height': 'none', + 'max-width': 'none', + 'margin': '0', + }) + this.setImageSize() + this.expand() + } + setImageSize() { + const { width, height, margin } = this.#layout + const vertical = this.#vertical + const doc = this.document + for (const el of doc.body.querySelectorAll('img, svg, video')) { + // preserve max size if they are already set + const { maxHeight, maxWidth } = doc.defaultView.getComputedStyle(el) + setStylesImportant(el, { + 'max-height': vertical + ? (maxHeight !== 'none' && maxHeight !== '0px' ? maxHeight : '100%') + : `${height - margin * 2}px`, + 'max-width': vertical + ? `${width - margin * 2}px` + : (maxWidth !== 'none' && maxWidth !== '0px' ? maxWidth : '100%'), + 'object-fit': 'contain', + 'page-break-inside': 'avoid', + 'break-inside': 'avoid', + 'box-sizing': 'border-box', + }) + } + } + expand() { + const { documentElement } = this.document + if (this.#column) { + const side = this.#vertical ? 'height' : 'width' + const otherSide = this.#vertical ? 'width' : 'height' + const contentRect = this.#contentRange.getBoundingClientRect() + const rootRect = documentElement.getBoundingClientRect() + // offset caused by column break at the start of the page + // which seem to be supported only by WebKit and only for horizontal writing + const contentStart = this.#vertical ? 0 + : this.#rtl ? rootRect.right - contentRect.right : contentRect.left - rootRect.left + const contentSize = contentStart + contentRect[side] + const pageCount = Math.ceil(contentSize / this.#size) + const expandedSize = pageCount * this.#size + this.#element.style.padding = '0' + this.#iframe.style[side] = `${expandedSize}px` + this.#element.style[side] = `${expandedSize + this.#size * 2}px` + this.#iframe.style[otherSide] = '100%' + this.#element.style[otherSide] = '100%' + documentElement.style[side] = `${this.#size}px` + if (this.#overlayer) { + this.#overlayer.element.style.margin = '0' + this.#overlayer.element.style.left = this.#vertical ? '0' : `${this.#size}px` + this.#overlayer.element.style.top = this.#vertical ? `${this.#size}px` : '0' + this.#overlayer.element.style[side] = `${expandedSize}px` + this.#overlayer.redraw() + } + } else { + const side = this.#vertical ? 'width' : 'height' + const otherSide = this.#vertical ? 'height' : 'width' + const contentSize = documentElement.getBoundingClientRect()[side] + const expandedSize = contentSize + const { margin } = this.#layout + const padding = this.#vertical ? `0 ${margin}px` : `${margin}px 0` + this.#element.style.padding = padding + this.#iframe.style[side] = `${expandedSize}px` + this.#element.style[side] = `${expandedSize}px` + this.#iframe.style[otherSide] = '100%' + this.#element.style[otherSide] = '100%' + if (this.#overlayer) { + this.#overlayer.element.style.margin = padding + this.#overlayer.element.style.left = '0' + this.#overlayer.element.style.top = '0' + this.#overlayer.element.style[side] = `${expandedSize}px` + this.#overlayer.redraw() + } + } + this.onExpand() + } + set overlayer(overlayer) { + this.#overlayer = overlayer + this.#element.append(overlayer.element) + } + get overlayer() { + return this.#overlayer + } + destroy() { + if (this.document) this.#observer.unobserve(this.document.body) + } +} + +// NOTE: everything here assumes the so-called "negative scroll type" for RTL +export class Paginator extends HTMLElement { + static observedAttributes = [ + 'flow', 'gap', 'margin', + 'max-inline-size', 'max-block-size', 'max-column-count', + ] + #root = this.attachShadow({ mode: 'closed' }) + #observer = new ResizeObserver(() => this.render()) + #top + #background + #container + #header + #footer + #view + #vertical = false + #rtl = false + #margin = 0 + #index = -1 + #anchor = 0 // anchor view to a fraction (0-1), Range, or Element + #justAnchored = false + #locked = false // while true, prevent any further navigation + #styles + #styleMap = new WeakMap() + #mediaQuery = matchMedia('(prefers-color-scheme: dark)') + #mediaQueryListener + #scrollBounds + #touchState + #touchScrolled + #lastVisibleRange + constructor() { + super() + this.#root.innerHTML = ` +
+
+ +
+ +
+ ` + + this.#top = this.#root.getElementById('top') + this.#background = this.#root.getElementById('background') + this.#container = this.#root.getElementById('container') + this.#header = this.#root.getElementById('header') + this.#footer = this.#root.getElementById('footer') + + this.#observer.observe(this.#container) + this.#container.addEventListener('scroll', () => this.dispatchEvent(new Event('scroll'))) + this.#container.addEventListener('scroll', debounce(() => { + if (this.scrolled) { + if (this.#justAnchored) this.#justAnchored = false + else this.#afterScroll('scroll') + } + }, 250)) + + const opts = { passive: false } + this.addEventListener('touchstart', this.#onTouchStart.bind(this), opts) + this.addEventListener('touchmove', this.#onTouchMove.bind(this), opts) + this.addEventListener('touchend', this.#onTouchEnd.bind(this)) + this.addEventListener('load', ({ detail: { doc } }) => { + doc.addEventListener('touchstart', this.#onTouchStart.bind(this), opts) + doc.addEventListener('touchmove', this.#onTouchMove.bind(this), opts) + doc.addEventListener('touchend', this.#onTouchEnd.bind(this)) + }) + + this.addEventListener('relocate', ({ detail }) => { + if (detail.reason === 'selection') setSelectionTo(this.#anchor, 0) + else if (detail.reason === 'navigation') { + if (this.#anchor === 1) setSelectionTo(detail.range, 1) + else if (typeof this.#anchor === 'number') + setSelectionTo(detail.range, -1) + else setSelectionTo(this.#anchor, -1) + } + }) + const checkPointerSelection = debounce((range, sel) => { + if (!sel.rangeCount) return + const selRange = sel.getRangeAt(0) + const backward = selectionIsBackward(sel) + if (backward && selRange.compareBoundaryPoints(Range.START_TO_START, range) < 0) + this.prev() + else if (!backward && selRange.compareBoundaryPoints(Range.END_TO_END, range) > 0) + this.next() + }, 700) + this.addEventListener('load', ({ detail: { doc } }) => { + let isPointerSelecting = false + doc.addEventListener('pointerdown', () => isPointerSelecting = true) + doc.addEventListener('pointerup', () => isPointerSelecting = false) + let isKeyboardSelecting = false + doc.addEventListener('keydown', () => isKeyboardSelecting = true) + doc.addEventListener('keyup', () => isKeyboardSelecting = false) + doc.addEventListener('selectionchange', () => { + if (this.scrolled) return + const range = this.#lastVisibleRange + if (!range) return + const sel = doc.getSelection() + if (!sel.rangeCount) return + if (isPointerSelecting && sel.type === 'Range') + checkPointerSelection(range, sel) + else if (isKeyboardSelecting) { + const selRange = sel.getRangeAt(0).cloneRange() + const backward = selectionIsBackward(sel) + if (!backward) selRange.collapse() + this.#scrollToAnchor(selRange) + } + }) + doc.addEventListener('focusin', e => this.scrolled ? null : + // NOTE: `requestAnimationFrame` is needed in WebKit + requestAnimationFrame(() => this.#scrollToAnchor(e.target))) + }) + + this.#mediaQueryListener = () => { + if (!this.#view) return + this.#background.style.background = getBackground(this.#view.document) + } + this.#mediaQuery.addEventListener('change', this.#mediaQueryListener) + } + attributeChangedCallback(name, _, value) { + switch (name) { + case 'flow': + this.render() + break + case 'gap': + case 'margin': + case 'max-block-size': + case 'max-column-count': + this.#top.style.setProperty('--_' + name, value) + break + case 'max-inline-size': + // needs explicit `render()` as it doesn't necessarily resize + this.#top.style.setProperty('--_' + name, value) + this.render() + break + } + } + open(book) { + this.bookDir = book.dir + this.sections = book.sections + book.transformTarget?.addEventListener('data', ({ detail }) => { + if (detail.type !== 'text/css') return + const w = innerWidth + const h = innerHeight + detail.data = Promise.resolve(detail.data).then(data => data + // unprefix as most of the props are (only) supported unprefixed + .replace(/(?<=[{\s;])-epub-/gi, '') + // replace vw and vh as they cause problems with layout + .replace(/(\d*\.?\d+)vw/gi, (_, d) => parseFloat(d) * w / 100 + 'px') + .replace(/(\d*\.?\d+)vh/gi, (_, d) => parseFloat(d) * h / 100 + 'px') + // `page-break-*` unsupported in columns; replace with `column-break-*` + .replace(/page-break-(after|before|inside)\s*:/gi, (_, x) => + `-webkit-column-break-${x}:`) + .replace(/break-(after|before|inside)\s*:\s*(avoid-)?page/gi, (_, x, y) => + `break-${x}: ${y ?? ''}column`)) + }) + } + #createView() { + if (this.#view) { + this.#view.destroy() + this.#container.removeChild(this.#view.element) + } + this.#view = new View({ + container: this, + onExpand: () => this.#scrollToAnchor(this.#anchor), + }) + this.#container.append(this.#view.element) + return this.#view + } + #beforeRender({ vertical, rtl, background }) { + this.#vertical = vertical + this.#rtl = rtl + this.#top.classList.toggle('vertical', vertical) + + // set background to `doc` background + // this is needed because the iframe does not fill the whole element + this.#background.style.background = background + + const { width, height } = this.#container.getBoundingClientRect() + const size = vertical ? height : width + + const style = getComputedStyle(this.#top) + const maxInlineSize = parseFloat(style.getPropertyValue('--_max-inline-size')) + const maxColumnCount = parseInt(style.getPropertyValue('--_max-column-count-spread')) + const margin = parseFloat(style.getPropertyValue('--_margin')) + this.#margin = margin + + const g = parseFloat(style.getPropertyValue('--_gap')) / 100 + // The gap will be a percentage of the #container, not the whole view. + // This means the outer padding will be bigger than the column gap. Let + // `a` be the gap percentage. The actual percentage for the column gap + // will be (1 - a) * a. Let us call this `b`. + // + // To make them the same, we start by shrinking the outer padding + // setting to `b`, but keep the column gap setting the same at `a`. Then + // the actual size for the column gap will be (1 - b) * a. Repeating the + // process again and again, we get the sequence + // x₁ = (1 - b) * a + // x₂ = (1 - x₁) * a + // ... + // which converges to x = (1 - x) * a. Solving for x, x = a / (1 + a). + // So to make the spacing even, we must shrink the outer padding with + // f(x) = x / (1 + x). + // But we want to keep the outer padding, and make the inner gap bigger. + // So we apply the inverse, f⁻¹ = -x / (x - 1) to the column gap. + const gap = -g / (g - 1) * size + + const flow = this.getAttribute('flow') + if (flow === 'scrolled') { + // FIXME: vertical-rl only, not -lr + this.setAttribute('dir', vertical ? 'rtl' : 'ltr') + this.#top.style.padding = '0' + const columnWidth = maxInlineSize + + this.heads = null + this.feet = null + this.#header.replaceChildren() + this.#footer.replaceChildren() + + return { flow, margin, gap, columnWidth } + } + + const divisor = Math.min(maxColumnCount, Math.ceil(size / maxInlineSize)) + const columnWidth = (size / divisor) - gap + this.setAttribute('dir', rtl ? 'rtl' : 'ltr') + + const marginalDivisor = vertical + ? Math.min(2, Math.ceil(width / maxInlineSize)) + : divisor + const marginalStyle = { + gridTemplateColumns: `repeat(${marginalDivisor}, 1fr)`, + gap: `${gap}px`, + direction: this.bookDir === 'rtl' ? 'rtl' : 'ltr', + } + Object.assign(this.#header.style, marginalStyle) + Object.assign(this.#footer.style, marginalStyle) + const heads = makeMarginals(marginalDivisor, 'head') + const feet = makeMarginals(marginalDivisor, 'foot') + this.heads = heads.map(el => el.children[0]) + this.feet = feet.map(el => el.children[0]) + this.#header.replaceChildren(...heads) + this.#footer.replaceChildren(...feet) + + return { height, width, margin, gap, columnWidth } + } + render() { + if (!this.#view) return + this.#view.render(this.#beforeRender({ + vertical: this.#vertical, + rtl: this.#rtl, + })) + this.#scrollToAnchor(this.#anchor) + } + get scrolled() { + return this.getAttribute('flow') === 'scrolled' + } + get scrollProp() { + const { scrolled } = this + return this.#vertical ? (scrolled ? 'scrollLeft' : 'scrollTop') + : scrolled ? 'scrollTop' : 'scrollLeft' + } + get sideProp() { + const { scrolled } = this + return this.#vertical ? (scrolled ? 'width' : 'height') + : scrolled ? 'height' : 'width' + } + get size() { + return this.#container.getBoundingClientRect()[this.sideProp] + } + get viewSize() { + return this.#view.element.getBoundingClientRect()[this.sideProp] + } + get start() { + return Math.abs(this.#container[this.scrollProp]) + } + get end() { + return this.start + this.size + } + get page() { + return Math.floor(((this.start + this.end) / 2) / this.size) + } + get pages() { + return Math.round(this.viewSize / this.size) + } + scrollBy(dx, dy) { + const delta = this.#vertical ? dy : dx + const element = this.#container + const { scrollProp } = this + const [offset, a, b] = this.#scrollBounds + const rtl = this.#rtl + const min = rtl ? offset - b : offset - a + const max = rtl ? offset + a : offset + b + element[scrollProp] = Math.max(min, Math.min(max, + element[scrollProp] + delta)) + } + snap(vx, vy) { + const velocity = this.#vertical ? vy : vx + const [offset, a, b] = this.#scrollBounds + const { start, end, pages, size } = this + const min = Math.abs(offset) - a + const max = Math.abs(offset) + b + const d = velocity * (this.#rtl ? -size : size) + const page = Math.floor( + Math.max(min, Math.min(max, (start + end) / 2 + + (isNaN(d) ? 0 : d))) / size) + + this.#scrollToPage(page, 'snap').then(() => { + const dir = page <= 0 ? -1 : page >= pages - 1 ? 1 : null + if (dir) return this.#goTo({ + index: this.#adjacentIndex(dir), + anchor: dir < 0 ? () => 1 : () => 0, + }) + }) + } + #onTouchStart(e) { + const touch = e.changedTouches[0] + this.#touchState = { + x: touch?.screenX, y: touch?.screenY, + t: e.timeStamp, + vx: 0, xy: 0, + } + } + #onTouchMove(e) { + const state = this.#touchState + if (state.pinched) return + state.pinched = globalThis.visualViewport.scale > 1 + if (this.scrolled || state.pinched) return + if (e.touches.length > 1) { + if (this.#touchScrolled) e.preventDefault() + return + } + e.preventDefault() + const touch = e.changedTouches[0] + const x = touch.screenX, y = touch.screenY + const dx = state.x - x, dy = state.y - y + const dt = e.timeStamp - state.t + state.x = x + state.y = y + state.t = e.timeStamp + state.vx = dx / dt + state.vy = dy / dt + this.#touchScrolled = true + this.scrollBy(dx, dy) + } + #onTouchEnd() { + this.#touchScrolled = false + if (this.scrolled) return + + // XXX: Firefox seems to report scale as 1... sometimes...? + // at this point I'm basically throwing `requestAnimationFrame` at + // anything that doesn't work + requestAnimationFrame(() => { + if (globalThis.visualViewport.scale === 1) + this.snap(this.#touchState.vx, this.#touchState.vy) + }) + } + // allows one to process rects as if they were LTR and horizontal + #getRectMapper() { + if (this.scrolled) { + const size = this.viewSize + const margin = this.#margin + return this.#vertical + ? ({ left, right }) => + ({ left: size - right - margin, right: size - left - margin }) + : ({ top, bottom }) => ({ left: top + margin, right: bottom + margin }) + } + const pxSize = this.pages * this.size + return this.#rtl + ? ({ left, right }) => + ({ left: pxSize - right, right: pxSize - left }) + : this.#vertical + ? ({ top, bottom }) => ({ left: top, right: bottom }) + : f => f + } + async #scrollToRect(rect, reason) { + if (this.scrolled) { + const offset = this.#getRectMapper()(rect).left - this.#margin + return this.#scrollTo(offset, reason) + } + const offset = this.#getRectMapper()(rect).left + return this.#scrollToPage(Math.floor(offset / this.size) + (this.#rtl ? -1 : 1), reason) + } + async #scrollTo(offset, reason, smooth) { + const element = this.#container + const { scrollProp, size } = this + if (element[scrollProp] === offset) { + this.#scrollBounds = [offset, this.atStart ? 0 : size, this.atEnd ? 0 : size] + this.#afterScroll(reason) + return + } + // FIXME: vertical-rl only, not -lr + if (this.scrolled && this.#vertical) offset = -offset + if ((reason === 'snap' || smooth) && this.hasAttribute('animated')) return animate( + element[scrollProp], offset, 300, easeOutQuad, + x => element[scrollProp] = x, + ).then(() => { + this.#scrollBounds = [offset, this.atStart ? 0 : size, this.atEnd ? 0 : size] + this.#afterScroll(reason) + }) + else { + element[scrollProp] = offset + this.#scrollBounds = [offset, this.atStart ? 0 : size, this.atEnd ? 0 : size] + this.#afterScroll(reason) + } + } + async #scrollToPage(page, reason, smooth) { + const offset = this.size * (this.#rtl ? -page : page) + return this.#scrollTo(offset, reason, smooth) + } + async scrollToAnchor(anchor, select) { + return this.#scrollToAnchor(anchor, select ? 'selection' : 'navigation') + } + async #scrollToAnchor(anchor, reason = 'anchor') { + this.#anchor = anchor + const rects = uncollapse(anchor)?.getClientRects?.() + // if anchor is an element or a range + if (rects) { + // when the start of the range is immediately after a hyphen in the + // previous column, there is an extra zero width rect in that column + const rect = Array.from(rects) + .find(r => r.width > 0 && r.height > 0) || rects[0] + if (!rect) return + await this.#scrollToRect(rect, reason) + return + } + // if anchor is a fraction + if (this.scrolled) { + await this.#scrollTo(anchor * this.viewSize, reason) + return + } + const { pages } = this + if (!pages) return + const textPages = pages - 2 + const newPage = Math.round(anchor * (textPages - 1)) + await this.#scrollToPage(newPage + 1, reason) + } + #getVisibleRange() { + if (this.scrolled) return getVisibleRange(this.#view.document, + this.start + this.#margin, this.end - this.#margin, this.#getRectMapper()) + const size = this.#rtl ? -this.size : this.size + return getVisibleRange(this.#view.document, + this.start - size, this.end - size, this.#getRectMapper()) + } + #afterScroll(reason) { + const range = this.#getVisibleRange() + this.#lastVisibleRange = range + // don't set new anchor if relocation was to scroll to anchor + if (reason !== 'selection' && reason !== 'navigation' && reason !== 'anchor') + this.#anchor = range + else this.#justAnchored = true + + const index = this.#index + const detail = { reason, range, index } + if (this.scrolled) detail.fraction = this.start / this.viewSize + else if (this.pages > 0) { + const { page, pages } = this + this.#header.style.visibility = page > 1 ? 'visible' : 'hidden' + detail.fraction = (page - 1) / (pages - 2) + detail.size = 1 / (pages - 2) + } + this.dispatchEvent(new CustomEvent('relocate', { detail })) + } + async #display(promise) { + const { index, src, anchor, onLoad, select } = await promise + this.#index = index + const hasFocus = this.#view?.document?.hasFocus() + if (src) { + const view = this.#createView() + const afterLoad = doc => { + if (doc.head) { + const $styleBefore = doc.createElement('style') + doc.head.prepend($styleBefore) + const $style = doc.createElement('style') + doc.head.append($style) + this.#styleMap.set(doc, [$styleBefore, $style]) + } + onLoad?.({ doc, index }) + } + const beforeRender = this.#beforeRender.bind(this) + await view.load(src, afterLoad, beforeRender) + this.dispatchEvent(new CustomEvent('create-overlayer', { + detail: { + doc: view.document, index, + attach: overlayer => view.overlayer = overlayer, + }, + })) + this.#view = view + } + await this.scrollToAnchor((typeof anchor === 'function' + ? anchor(this.#view.document) : anchor) ?? 0, select) + if (hasFocus) this.focusView() + } + #canGoToIndex(index) { + return index >= 0 && index <= this.sections.length - 1 + } + async #goTo({ index, anchor, select}) { + if (index === this.#index) await this.#display({ index, anchor, select }) + else { + const oldIndex = this.#index + const onLoad = detail => { + this.sections[oldIndex]?.unload?.() + this.setStyles(this.#styles) + this.dispatchEvent(new CustomEvent('load', { detail })) + } + await this.#display(Promise.resolve(this.sections[index].load()) + .then(src => ({ index, src, anchor, onLoad, select })) + .catch(e => { + console.warn(e) + console.warn(new Error(`Failed to load section ${index}`)) + return {} + })) + } + } + async goTo(target) { + if (this.#locked) return + const resolved = await target + if (this.#canGoToIndex(resolved.index)) return this.#goTo(resolved) + } + #scrollPrev(distance) { + if (!this.#view) return true + if (this.scrolled) { + if (this.start > 0) return this.#scrollTo( + Math.max(0, this.start - (distance ?? this.size)), null, true) + return true + } + if (this.atStart) return + const page = this.page - 1 + return this.#scrollToPage(page, 'page', true).then(() => page <= 0) + } + #scrollNext(distance) { + if (!this.#view) return true + if (this.scrolled) { + if (this.viewSize - this.end > 2) return this.#scrollTo( + Math.min(this.viewSize, distance ? this.start + distance : this.end), null, true) + return true + } + if (this.atEnd) return + const page = this.page + 1 + const pages = this.pages + return this.#scrollToPage(page, 'page', true).then(() => page >= pages - 1) + } + get atStart() { + return this.#adjacentIndex(-1) == null && this.page <= 1 + } + get atEnd() { + return this.#adjacentIndex(1) == null && this.page >= this.pages - 2 + } + #adjacentIndex(dir) { + for (let index = this.#index + dir; this.#canGoToIndex(index); index += dir) + if (this.sections[index]?.linear !== 'no') return index + } + async #turnPage(dir, distance) { + if (this.#locked) return + this.#locked = true + const prev = dir === -1 + const shouldGo = await (prev ? this.#scrollPrev(distance) : this.#scrollNext(distance)) + if (shouldGo) await this.#goTo({ + index: this.#adjacentIndex(dir), + anchor: prev ? () => 1 : () => 0, + }) + if (shouldGo || !this.hasAttribute('animated')) await wait(100) + this.#locked = false + } + prev(distance) { + return this.#turnPage(-1, distance) + } + next(distance) { + return this.#turnPage(1, distance) + } + prevSection() { + return this.goTo({ index: this.#adjacentIndex(-1) }) + } + nextSection() { + return this.goTo({ index: this.#adjacentIndex(1) }) + } + firstSection() { + const index = this.sections.findIndex(section => section.linear !== 'no') + return this.goTo({ index }) + } + lastSection() { + const index = this.sections.findLastIndex(section => section.linear !== 'no') + return this.goTo({ index }) + } + getContents() { + if (this.#view) return [{ + index: this.#index, + overlayer: this.#view.overlayer, + doc: this.#view.document, + }] + return [] + } + setStyles(styles) { + this.#styles = styles + const $$styles = this.#styleMap.get(this.#view?.document) + if (!$$styles) return + const [$beforeStyle, $style] = $$styles + if (Array.isArray(styles)) { + const [beforeStyle, style] = styles + $beforeStyle.textContent = beforeStyle + $style.textContent = style + } else $style.textContent = styles + + // NOTE: needs `requestAnimationFrame` in Chromium + requestAnimationFrame(() => + this.#background.style.background = getBackground(this.#view.document)) + + // needed because the resize observer doesn't work in Firefox + this.#view?.document?.fonts?.ready?.then(() => this.#view.expand()) + } + focusView() { + this.#view.document.defaultView.focus() + } + destroy() { + this.#observer.unobserve(this) + this.#view.destroy() + this.#view = null + this.sections[this.#index]?.unload?.() + this.#mediaQuery.removeEventListener('change', this.#mediaQueryListener) + } +} + +customElements.define('foliate-paginator', Paginator) diff --git a/booklore-ui/src/assets/foliate/progress.js b/booklore-ui/src/assets/foliate/progress.js new file mode 100644 index 000000000..f01c512c7 --- /dev/null +++ b/booklore-ui/src/assets/foliate/progress.js @@ -0,0 +1,113 @@ +// assign a unique ID for each TOC item +const assignIDs = toc => { + let id = 0 + const assignID = item => { + item.id = id++ + if (item.subitems) for (const subitem of item.subitems) assignID(subitem) + } + for (const item of toc) assignID(item) + return toc +} + +const flatten = items => items + .map(item => item.subitems?.length + ? [item, flatten(item.subitems)].flat() + : item) + .flat() + +export class TOCProgress { + async init({ toc, ids, splitHref, getFragment }) { + assignIDs(toc) + const items = flatten(toc) + const grouped = new Map() + for (const [i, item] of items.entries()) { + const [id, fragment] = await splitHref(item?.href) ?? [] + const value = { fragment, item } + if (grouped.has(id)) grouped.get(id).items.push(value) + else grouped.set(id, { prev: items[i - 1], items: [value] }) + } + const map = new Map() + for (const [i, id] of ids.entries()) { + if (grouped.has(id)) map.set(id, grouped.get(id)) + else map.set(id, map.get(ids[i - 1])) + } + this.ids = ids + this.map = map + this.getFragment = getFragment + } + getProgress(index, range) { + if (!this.ids) return + const id = this.ids[index] + const obj = this.map.get(id) + if (!obj) return null + const { prev, items } = obj + if (!items) return prev + if (!range || items.length === 1 && !items[0].fragment) return items[0].item + + const doc = range.startContainer.getRootNode() + for (const [i, { fragment }] of items.entries()) { + const el = this.getFragment(doc, fragment) + if (!el) continue + if (range.comparePoint(el, 0) > 0) + return (items[i - 1]?.item ?? prev) + } + return items[items.length - 1].item + } +} + +export class SectionProgress { + constructor(sections, sizePerLoc, sizePerTimeUnit) { + this.sizes = sections.map(s => s.linear != 'no' && s.size > 0 ? s.size : 0) + this.sizePerLoc = sizePerLoc + this.sizePerTimeUnit = sizePerTimeUnit + this.sizeTotal = this.sizes.reduce((a, b) => a + b, 0) + this.sectionFractions = this.#getSectionFractions() + } + #getSectionFractions() { + const { sizeTotal } = this + const results = [0] + let sum = 0 + for (const size of this.sizes) results.push((sum += size) / sizeTotal) + return results + } + // get progress given index of and fractions within a section + getProgress(index, fractionInSection, pageFraction = 0) { + const { sizes, sizePerLoc, sizePerTimeUnit, sizeTotal } = this + const sizeInSection = sizes[index] ?? 0 + const sizeBefore = sizes.slice(0, index).reduce((a, b) => a + b, 0) + const size = sizeBefore + fractionInSection * sizeInSection + const nextSize = size + pageFraction * sizeInSection + const remainingTotal = sizeTotal - size + const remainingSection = (1 - fractionInSection) * sizeInSection + return { + fraction: nextSize / sizeTotal, + section: { + current: index, + total: sizes.length, + }, + location: { + current: Math.floor(size / sizePerLoc), + next: Math.floor(nextSize / sizePerLoc), + total: Math.ceil(sizeTotal / sizePerLoc), + }, + time: { + section: remainingSection / sizePerTimeUnit, + total: remainingTotal / sizePerTimeUnit, + }, + } + } + // the inverse of `getProgress` + // get index of and fraction in section based on total fraction + getSection(fraction) { + if (fraction <= 0) return [0, 0] + if (fraction >= 1) return [this.sizes.length - 1, 1] + fraction = fraction + Number.EPSILON + const { sizeTotal } = this + let index = this.sectionFractions.findIndex(x => x > fraction) - 1 + if (index < 0) return [0, 0] + while (!this.sizes[index]) index++ + const fractionInSection = (fraction - this.sectionFractions[index]) + / (this.sizes[index] / sizeTotal) + return [index, fractionInSection] + } +} diff --git a/booklore-ui/src/assets/foliate/search.js b/booklore-ui/src/assets/foliate/search.js new file mode 100644 index 000000000..a6f176ff7 --- /dev/null +++ b/booklore-ui/src/assets/foliate/search.js @@ -0,0 +1,130 @@ +// length for context in excerpts +const CONTEXT_LENGTH = 50 + +const normalizeWhitespace = str => str.replace(/\s+/g, ' ') + +const makeExcerpt = (strs, { startIndex, startOffset, endIndex, endOffset }) => { + const start = strs[startIndex] + const end = strs[endIndex] + const match = start === end + ? start.slice(startOffset, endOffset) + : start.slice(startOffset) + + strs.slice(start + 1, end).join('') + + end.slice(0, endOffset) + const trimmedStart = normalizeWhitespace(start.slice(0, startOffset)).trimStart() + const trimmedEnd = normalizeWhitespace(end.slice(endOffset)).trimEnd() + const ellipsisPre = trimmedStart.length < CONTEXT_LENGTH ? '' : '…' + const ellipsisPost = trimmedEnd.length < CONTEXT_LENGTH ? '' : '…' + const pre = `${ellipsisPre}${trimmedStart.slice(-CONTEXT_LENGTH)}` + const post = `${trimmedEnd.slice(0, CONTEXT_LENGTH)}${ellipsisPost}` + return { pre, match, post } +} + +const simpleSearch = function* (strs, query, options = {}) { + const { locales = 'en', sensitivity } = options + const matchCase = sensitivity === 'variant' + const haystack = strs.join('') + const lowerHaystack = matchCase ? haystack : haystack.toLocaleLowerCase(locales) + const needle = matchCase ? query : query.toLocaleLowerCase(locales) + const needleLength = needle.length + let index = -1 + let strIndex = -1 + let sum = 0 + do { + index = lowerHaystack.indexOf(needle, index + 1) + if (index > -1) { + while (sum <= index) sum += strs[++strIndex].length + const startIndex = strIndex + const startOffset = index - (sum - strs[strIndex].length) + const end = index + needleLength + while (sum <= end) sum += strs[++strIndex].length + const endIndex = strIndex + const endOffset = end - (sum - strs[strIndex].length) + const range = { startIndex, startOffset, endIndex, endOffset } + yield { range, excerpt: makeExcerpt(strs, range) } + } + } while (index > -1) +} + +const segmenterSearch = function* (strs, query, options = {}) { + const { locales = 'en', granularity = 'word', sensitivity = 'base' } = options + let segmenter, collator + try { + segmenter = new Intl.Segmenter(locales, { usage: 'search', granularity }) + collator = new Intl.Collator(locales, { sensitivity }) + } catch (e) { + console.warn(e) + segmenter = new Intl.Segmenter('en', { usage: 'search', granularity }) + collator = new Intl.Collator('en', { sensitivity }) + } + const queryLength = Array.from(segmenter.segment(query)).length + + const substrArr = [] + let strIndex = 0 + let segments = segmenter.segment(strs[strIndex])[Symbol.iterator]() + main: while (strIndex < strs.length) { + while (substrArr.length < queryLength) { + const { done, value } = segments.next() + if (done) { + // the current string is exhausted + // move on to the next string + strIndex++ + if (strIndex < strs.length) { + segments = segmenter.segment(strs[strIndex])[Symbol.iterator]() + continue + } else break main + } + const { index, segment } = value + // ignore formatting characters + if (!/[^\p{Format}]/u.test(segment)) continue + // normalize whitespace + if (/\s/u.test(segment)) { + if (!/\s/u.test(substrArr[substrArr.length - 1]?.segment)) + substrArr.push({ strIndex, index, segment: ' ' }) + continue + } + value.strIndex = strIndex + substrArr.push(value) + } + const substr = substrArr.map(x => x.segment).join('') + if (collator.compare(query, substr) === 0) { + const endIndex = strIndex + const lastSeg = substrArr[substrArr.length - 1] + const endOffset = lastSeg.index + lastSeg.segment.length + const startIndex = substrArr[0].strIndex + const startOffset = substrArr[0].index + const range = { startIndex, startOffset, endIndex, endOffset } + yield { range, excerpt: makeExcerpt(strs, range) } + } + substrArr.shift() + } +} + +export const search = (strs, query, options) => { + const { granularity = 'grapheme', sensitivity = 'base' } = options + if (!Intl?.Segmenter || granularity === 'grapheme' + && (sensitivity === 'variant' || sensitivity === 'accent')) + return simpleSearch(strs, query, options) + return segmenterSearch(strs, query, options) +} + +export const searchMatcher = (textWalker, opts) => { + const { defaultLocale, matchCase, matchDiacritics, matchWholeWords, acceptNode } = opts + return function* (doc, query) { + const iter = textWalker(doc, function* (strs, makeRange) { + for (const result of search(strs, query, { + locales: doc.body.lang || doc.documentElement.lang || defaultLocale || 'en', + granularity: matchWholeWords ? 'word' : 'grapheme', + sensitivity: matchDiacritics && matchCase ? 'variant' + : matchDiacritics && !matchCase ? 'accent' + : !matchDiacritics && matchCase ? 'case' + : 'base', + })) { + const { startIndex, startOffset, endIndex, endOffset } = result.range + result.range = makeRange(startIndex, startOffset, endIndex, endOffset) + yield result + } + }, acceptNode) + for (const result of iter) yield result + } +} diff --git a/booklore-ui/src/assets/foliate/text-walker.js b/booklore-ui/src/assets/foliate/text-walker.js new file mode 100644 index 000000000..4ff9dff0b --- /dev/null +++ b/booklore-ui/src/assets/foliate/text-walker.js @@ -0,0 +1,43 @@ +const walkRange = (range, walker) => { + const nodes = [] + for (let node = walker.currentNode; node; node = walker.nextNode()) { + const compare = range.comparePoint(node, 0) + if (compare === 0) nodes.push(node) + else if (compare > 0) break + } + return nodes +} + +const walkDocument = (_, walker) => { + const nodes = [] + for (let node = walker.nextNode(); node; node = walker.nextNode()) + nodes.push(node) + return nodes +} + +const filter = NodeFilter.SHOW_ELEMENT | NodeFilter.SHOW_TEXT + | NodeFilter.SHOW_CDATA_SECTION + +const acceptNode = node => { + if (node.nodeType === 1) { + const name = node.tagName.toLowerCase() + if (name === 'script' || name === 'style') return NodeFilter.FILTER_REJECT + return NodeFilter.FILTER_SKIP + } + return NodeFilter.FILTER_ACCEPT +} + +export const textWalker = function* (x, func, filterFunc) { + const root = x.commonAncestorContainer ?? x.body ?? x + const walker = document.createTreeWalker(root, filter, { acceptNode: filterFunc || acceptNode }) + const walk = x.commonAncestorContainer ? walkRange : walkDocument + const nodes = walk(x, walker) + const strs = nodes.map(node => node.nodeValue) + const makeRange = (startIndex, startOffset, endIndex, endOffset) => { + const range = document.createRange() + range.setStart(nodes[startIndex], startOffset) + range.setEnd(nodes[endIndex], endOffset) + return range + } + for (const match of func(strs, makeRange)) yield match +} diff --git a/booklore-ui/src/assets/foliate/tts.js b/booklore-ui/src/assets/foliate/tts.js new file mode 100644 index 000000000..dee3c5798 --- /dev/null +++ b/booklore-ui/src/assets/foliate/tts.js @@ -0,0 +1,278 @@ +const NS = { + XML: 'http://www.w3.org/XML/1998/namespace', + SSML: 'http://www.w3.org/2001/10/synthesis', +} + +const blockTags = new Set([ + 'article', 'aside', 'audio', 'blockquote', 'caption', + 'details', 'dialog', 'div', 'dl', 'dt', 'dd', + 'figure', 'footer', 'form', 'figcaption', + 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'header', 'hgroup', 'hr', 'li', + 'main', 'math', 'nav', 'ol', 'p', 'pre', 'section', 'tr', +]) + +const getLang = el => { + const x = el.lang || el?.getAttributeNS?.(NS.XML, 'lang') + return x ? x : el.parentElement ? getLang(el.parentElement) : null +} + +const getAlphabet = el => { + const x = el?.getAttributeNS?.(NS.XML, 'lang') + return x ? x : el.parentElement ? getAlphabet(el.parentElement) : null +} + +const getSegmenter = (lang = 'en', granularity = 'word') => { + const segmenter = new Intl.Segmenter(lang, { granularity }) + const granularityIsWord = granularity === 'word' + return function* (strs, makeRange) { + const str = strs.join('') + let name = 0 + let strIndex = -1 + let sum = 0 + for (const { index, segment, isWordLike } of segmenter.segment(str)) { + if (granularityIsWord && !isWordLike) continue + while (sum <= index) sum += strs[++strIndex].length + const startIndex = strIndex + const startOffset = index - (sum - strs[strIndex].length) + const end = index + segment.length - 1 + if (end < str.length) while (sum <= end) sum += strs[++strIndex].length + const endIndex = strIndex + const endOffset = end - (sum - strs[strIndex].length) + 1 + yield [(name++).toString(), + makeRange(startIndex, startOffset, endIndex, endOffset)] + } + } +} + +const fragmentToSSML = (fragment, inherited) => { + const ssml = document.implementation.createDocument(NS.SSML, 'speak') + const { lang } = inherited + if (lang) ssml.documentElement.setAttributeNS(NS.XML, 'lang', lang) + + const convert = (node, parent, inheritedAlphabet) => { + if (!node) return + if (node.nodeType === 3) return ssml.createTextNode(node.textContent) + if (node.nodeType === 4) return ssml.createCDATASection(node.textContent) + if (node.nodeType !== 1) return + + let el + const nodeName = node.nodeName.toLowerCase() + if (nodeName === 'foliate-mark') { + el = ssml.createElementNS(NS.SSML, 'mark') + el.setAttribute('name', node.dataset.name) + } + else if (nodeName === 'br') + el = ssml.createElementNS(NS.SSML, 'break') + else if (nodeName === 'em' || nodeName === 'strong') + el = ssml.createElementNS(NS.SSML, 'emphasis') + + const lang = node.lang || node.getAttributeNS(NS.XML, 'lang') + if (lang) { + if (!el) el = ssml.createElementNS(NS.SSML, 'lang') + el.setAttributeNS(NS.XML, 'lang', lang) + } + + const alphabet = node.getAttributeNS(NS.SSML, 'alphabet') || inheritedAlphabet + if (!el) { + const ph = node.getAttributeNS(NS.SSML, 'ph') + if (ph) { + el = ssml.createElementNS(NS.SSML, 'phoneme') + if (alphabet) el.setAttribute('alphabet', alphabet) + el.setAttribute('ph', ph) + } + } + + if (!el) el = parent + + let child = node.firstChild + while (child) { + const childEl = convert(child, el, alphabet) + if (childEl && el !== childEl) el.append(childEl) + child = child.nextSibling + } + return el + } + convert(fragment.firstChild, ssml.documentElement, inherited.alphabet) + return ssml +} + +const getFragmentWithMarks = (range, textWalker, granularity) => { + const lang = getLang(range.commonAncestorContainer) + const alphabet = getAlphabet(range.commonAncestorContainer) + + const segmenter = getSegmenter(lang, granularity) + const fragment = range.cloneContents() + + // we need ranges on both the original document (for highlighting) + // and the document fragment (for inserting marks) + // so unfortunately need to do it twice, as you can't copy the ranges + const entries = [...textWalker(range, segmenter)] + const fragmentEntries = [...textWalker(fragment, segmenter)] + + for (const [name, range] of fragmentEntries) { + const mark = document.createElement('foliate-mark') + mark.dataset.name = name + range.insertNode(mark) + } + const ssml = fragmentToSSML(fragment, { lang, alphabet }) + return { entries, ssml } +} + +const rangeIsEmpty = range => !range.toString().trim() + +function* getBlocks(doc) { + let last + const walker = doc.createTreeWalker(doc.body, NodeFilter.SHOW_ELEMENT) + for (let node = walker.nextNode(); node; node = walker.nextNode()) { + const name = node.tagName.toLowerCase() + if (blockTags.has(name)) { + if (last) { + last.setEndBefore(node) + if (!rangeIsEmpty(last)) yield last + } + last = doc.createRange() + last.setStart(node, 0) + } + } + if (!last) { + last = doc.createRange() + last.setStart(doc.body.firstChild ?? doc.body, 0) + } + last.setEndAfter(doc.body.lastChild ?? doc.body) + if (!rangeIsEmpty(last)) yield last +} + +class ListIterator { + #arr = [] + #iter + #index = -1 + #f + constructor(iter, f = x => x) { + this.#iter = iter + this.#f = f + } + current() { + if (this.#arr[this.#index]) return this.#f(this.#arr[this.#index]) + } + first() { + const newIndex = 0 + if (this.#arr[newIndex]) { + this.#index = newIndex + return this.#f(this.#arr[newIndex]) + } + } + prev() { + const newIndex = this.#index - 1 + if (this.#arr[newIndex]) { + this.#index = newIndex + return this.#f(this.#arr[newIndex]) + } + } + next() { + const newIndex = this.#index + 1 + if (this.#arr[newIndex]) { + this.#index = newIndex + return this.#f(this.#arr[newIndex]) + } + while (true) { + const { done, value } = this.#iter.next() + if (done) break + this.#arr.push(value) + if (this.#arr[newIndex]) { + this.#index = newIndex + return this.#f(this.#arr[newIndex]) + } + } + } + find(f) { + const index = this.#arr.findIndex(x => f(x)) + if (index > -1) { + this.#index = index + return this.#f(this.#arr[index]) + } + while (true) { + const { done, value } = this.#iter.next() + if (done) break + this.#arr.push(value) + if (f(value)) { + this.#index = this.#arr.length - 1 + return this.#f(value) + } + } + } +} + +export class TTS { + #list + #ranges + #lastMark + #serializer = new XMLSerializer() + constructor(doc, textWalker, highlight, granularity) { + this.doc = doc + this.highlight = highlight + this.#list = new ListIterator(getBlocks(doc), range => { + const { entries, ssml } = getFragmentWithMarks(range, textWalker, granularity) + this.#ranges = new Map(entries) + return [ssml, range] + }) + } + #getMarkElement(doc, mark) { + if (!mark) return null + return doc.querySelector(`mark[name="${CSS.escape(mark)}"`) + } + #speak(doc, getNode) { + if (!doc) return + if (!getNode) return this.#serializer.serializeToString(doc) + const ssml = document.implementation.createDocument(NS.SSML, 'speak') + ssml.documentElement.replaceWith(ssml.importNode(doc.documentElement, true)) + let node = getNode(ssml)?.previousSibling + while (node) { + const next = node.previousSibling ?? node.parentNode?.previousSibling + node.parentNode.removeChild(node) + node = next + } + return this.#serializer.serializeToString(ssml) + } + start() { + this.#lastMark = null + const [doc] = this.#list.first() ?? [] + if (!doc) return this.next() + return this.#speak(doc, ssml => this.#getMarkElement(ssml, this.#lastMark)) + } + resume() { + const [doc] = this.#list.current() ?? [] + if (!doc) return this.next() + return this.#speak(doc, ssml => this.#getMarkElement(ssml, this.#lastMark)) + } + prev(paused) { + this.#lastMark = null + const [doc, range] = this.#list.prev() ?? [] + if (paused && range) this.highlight(range.cloneRange()) + return this.#speak(doc) + } + next(paused) { + this.#lastMark = null + const [doc, range] = this.#list.next() ?? [] + if (paused && range) this.highlight(range.cloneRange()) + return this.#speak(doc) + } + from(range) { + this.#lastMark = null + const [doc] = this.#list.find(range_ => + range.compareBoundaryPoints(Range.END_TO_START, range_) <= 0) + let mark + for (const [name, range_] of this.#ranges.entries()) + if (range.compareBoundaryPoints(Range.START_TO_START, range_) <= 0) { + mark = name + break + } + return this.#speak(doc, ssml => this.#getMarkElement(ssml, mark)) + } + setMark(mark) { + const range = this.#ranges.get(mark) + if (range) { + this.#lastMark = mark + this.highlight(range.cloneRange()) + } + } +} diff --git a/booklore-ui/src/assets/foliate/vendor/fflate.js b/booklore-ui/src/assets/foliate/vendor/fflate.js new file mode 100644 index 000000000..f275976aa --- /dev/null +++ b/booklore-ui/src/assets/foliate/vendor/fflate.js @@ -0,0 +1 @@ +var r=Uint8Array,a=Uint16Array,e=Int32Array,n=new r([0,0,0,0,0,0,0,0,1,1,1,1,2,2,2,2,3,3,3,3,4,4,4,4,5,5,5,5,0,0,0,0]),i=new r([0,0,0,0,1,1,2,2,3,3,4,4,5,5,6,6,7,7,8,8,9,9,10,10,11,11,12,12,13,13,0,0]),t=new r([16,17,18,0,8,7,9,6,10,5,11,4,12,3,13,2,14,1,15]),f=function(r,n){for(var i=new a(31),t=0;t<31;++t)i[t]=n+=1<>1|(21845&d)<<1;w=(61680&(w=(52428&w)>>2|(13107&w)<<2))>>4|(3855&w)<<4,c[d]=((65280&w)>>8|(255&w)<<8)>>1}var b=function(r,e,n){for(var i=r.length,t=0,f=new a(e);t>l]=u}else for(o=new a(i),t=0;t>15-r[t]);return o},s=new r(288);for(d=0;d<144;++d)s[d]=8;for(d=144;d<256;++d)s[d]=9;for(d=256;d<280;++d)s[d]=7;for(d=280;d<288;++d)s[d]=8;var h=new r(32);for(d=0;d<32;++d)h[d]=5;var y=b(s,9,1),g=b(h,5,1),p=function(r){for(var a=r[0],e=1;ea&&(a=r[e]);return a},k=function(r,a,e){var n=a/8|0;return(r[n]|r[n+1]<<8)>>(7&a)&e},m=function(r,a){var e=a/8|0;return(r[e]|r[e+1]<<8|r[e+2]<<16)>>(7&a)},x=["unexpected EOF","invalid block type","invalid length/literal","invalid distance","stream finished","no stream handler",,"no callback","invalid UTF-8 data","extra field too long","date not in range 1980-2099","filename too long","stream finishing","invalid zip data"],T=function(r,a,e){var n=new Error(a||x[r]);if(n.code=r,Error.captureStackTrace&&Error.captureStackTrace(n,T),!e)throw n;return n},E=function(a,e,f,o){var l=a.length,c=o?o.length:0;if(!l||e.f&&!e.l)return f||new r(0);var d=!f,w=d||2!=e.i,s=e.i;d&&(f=new r(3*l));var h=function(a){var e=f.length;if(a>e){var n=new r(Math.max(2*e,a));n.set(f),f=n}},x=e.f||0,E=e.p||0,z=e.b||0,A=e.l,U=e.d,D=e.m,F=e.n,M=8*l;do{if(!A){x=k(a,E,1);var S=k(a,E+1,3);if(E+=3,!S){var I=a[(N=4+((E+7)/8|0))-4]|a[N-3]<<8,O=N+I;if(O>l){s&&T(0);break}w&&h(z+I),f.set(a.subarray(N,O),z),e.b=z+=I,e.p=E=8*O,e.f=x;continue}if(1==S)A=y,U=g,D=9,F=5;else if(2==S){var j=k(a,E,31)+257,q=k(a,E+10,15)+4,B=j+k(a,E+5,31)+1;E+=14;for(var C=new r(B),G=new r(19),H=0;H>4)<16)C[H++]=N;else{var Q=0,R=0;for(16==N?(R=3+k(a,E,3),E+=2,Q=C[H-1]):17==N?(R=3+k(a,E,7),E+=3):18==N&&(R=11+k(a,E,127),E+=7);R--;)C[H++]=Q}}var V=C.subarray(0,j),W=C.subarray(j);D=p(V),F=p(W),A=b(V,D,1),U=b(W,F,1)}else T(1);if(E>M){s&&T(0);break}}w&&h(z+131072);for(var X=(1<>4;if((E+=15&Q)>M){s&&T(0);break}if(Q||T(2),$<256)f[z++]=$;else{if(256==$){Z=E,A=null;break}var _=$-254;if($>264){var rr=n[H=$-257];_=k(a,E,(1<>4;ar||T(3),E+=15&ar;W=u[er];if(er>3){rr=i[er];W+=m(a,E)&(1<M){s&&T(0);break}w&&h(z+131072);var nr=z+_;if(za.length)&&(n=a.length),new r(a.subarray(e,n))}(f,0,z):f.subarray(0,z)},z=new r(0);function A(r,a){return E(r.subarray((e=r,n=a&&a.dictionary,(8!=(15&e[0])||e[0]>>4>7||(e[0]<<8|e[1])%31)&&T(6,"invalid zlib data"),(e[1]>>5&1)==+!n&&T(6,"invalid zlib data: "+(32&e[1]?"need":"unexpected")+" dictionary"),2+(e[1]>>3&4)),-4),{i:2},a&&a.out,a&&a.dictionary);var e,n}var U="undefined"!=typeof TextDecoder&&new TextDecoder;try{U.decode(z,{stream:!0})}catch(r){}export{A as unzlibSync}; diff --git a/booklore-ui/src/assets/foliate/vendor/zip.js b/booklore-ui/src/assets/foliate/vendor/zip.js new file mode 100644 index 000000000..a65fd86f0 --- /dev/null +++ b/booklore-ui/src/assets/foliate/vendor/zip.js @@ -0,0 +1 @@ +const e=-2,t=-3,n=-5,i=[0,1,3,7,15,31,63,127,255,511,1023,2047,4095,8191,16383,32767,65535],r=[96,7,256,0,8,80,0,8,16,84,8,115,82,7,31,0,8,112,0,8,48,0,9,192,80,7,10,0,8,96,0,8,32,0,9,160,0,8,0,0,8,128,0,8,64,0,9,224,80,7,6,0,8,88,0,8,24,0,9,144,83,7,59,0,8,120,0,8,56,0,9,208,81,7,17,0,8,104,0,8,40,0,9,176,0,8,8,0,8,136,0,8,72,0,9,240,80,7,4,0,8,84,0,8,20,85,8,227,83,7,43,0,8,116,0,8,52,0,9,200,81,7,13,0,8,100,0,8,36,0,9,168,0,8,4,0,8,132,0,8,68,0,9,232,80,7,8,0,8,92,0,8,28,0,9,152,84,7,83,0,8,124,0,8,60,0,9,216,82,7,23,0,8,108,0,8,44,0,9,184,0,8,12,0,8,140,0,8,76,0,9,248,80,7,3,0,8,82,0,8,18,85,8,163,83,7,35,0,8,114,0,8,50,0,9,196,81,7,11,0,8,98,0,8,34,0,9,164,0,8,2,0,8,130,0,8,66,0,9,228,80,7,7,0,8,90,0,8,26,0,9,148,84,7,67,0,8,122,0,8,58,0,9,212,82,7,19,0,8,106,0,8,42,0,9,180,0,8,10,0,8,138,0,8,74,0,9,244,80,7,5,0,8,86,0,8,22,192,8,0,83,7,51,0,8,118,0,8,54,0,9,204,81,7,15,0,8,102,0,8,38,0,9,172,0,8,6,0,8,134,0,8,70,0,9,236,80,7,9,0,8,94,0,8,30,0,9,156,84,7,99,0,8,126,0,8,62,0,9,220,82,7,27,0,8,110,0,8,46,0,9,188,0,8,14,0,8,142,0,8,78,0,9,252,96,7,256,0,8,81,0,8,17,85,8,131,82,7,31,0,8,113,0,8,49,0,9,194,80,7,10,0,8,97,0,8,33,0,9,162,0,8,1,0,8,129,0,8,65,0,9,226,80,7,6,0,8,89,0,8,25,0,9,146,83,7,59,0,8,121,0,8,57,0,9,210,81,7,17,0,8,105,0,8,41,0,9,178,0,8,9,0,8,137,0,8,73,0,9,242,80,7,4,0,8,85,0,8,21,80,8,258,83,7,43,0,8,117,0,8,53,0,9,202,81,7,13,0,8,101,0,8,37,0,9,170,0,8,5,0,8,133,0,8,69,0,9,234,80,7,8,0,8,93,0,8,29,0,9,154,84,7,83,0,8,125,0,8,61,0,9,218,82,7,23,0,8,109,0,8,45,0,9,186,0,8,13,0,8,141,0,8,77,0,9,250,80,7,3,0,8,83,0,8,19,85,8,195,83,7,35,0,8,115,0,8,51,0,9,198,81,7,11,0,8,99,0,8,35,0,9,166,0,8,3,0,8,131,0,8,67,0,9,230,80,7,7,0,8,91,0,8,27,0,9,150,84,7,67,0,8,123,0,8,59,0,9,214,82,7,19,0,8,107,0,8,43,0,9,182,0,8,11,0,8,139,0,8,75,0,9,246,80,7,5,0,8,87,0,8,23,192,8,0,83,7,51,0,8,119,0,8,55,0,9,206,81,7,15,0,8,103,0,8,39,0,9,174,0,8,7,0,8,135,0,8,71,0,9,238,80,7,9,0,8,95,0,8,31,0,9,158,84,7,99,0,8,127,0,8,63,0,9,222,82,7,27,0,8,111,0,8,47,0,9,190,0,8,15,0,8,143,0,8,79,0,9,254,96,7,256,0,8,80,0,8,16,84,8,115,82,7,31,0,8,112,0,8,48,0,9,193,80,7,10,0,8,96,0,8,32,0,9,161,0,8,0,0,8,128,0,8,64,0,9,225,80,7,6,0,8,88,0,8,24,0,9,145,83,7,59,0,8,120,0,8,56,0,9,209,81,7,17,0,8,104,0,8,40,0,9,177,0,8,8,0,8,136,0,8,72,0,9,241,80,7,4,0,8,84,0,8,20,85,8,227,83,7,43,0,8,116,0,8,52,0,9,201,81,7,13,0,8,100,0,8,36,0,9,169,0,8,4,0,8,132,0,8,68,0,9,233,80,7,8,0,8,92,0,8,28,0,9,153,84,7,83,0,8,124,0,8,60,0,9,217,82,7,23,0,8,108,0,8,44,0,9,185,0,8,12,0,8,140,0,8,76,0,9,249,80,7,3,0,8,82,0,8,18,85,8,163,83,7,35,0,8,114,0,8,50,0,9,197,81,7,11,0,8,98,0,8,34,0,9,165,0,8,2,0,8,130,0,8,66,0,9,229,80,7,7,0,8,90,0,8,26,0,9,149,84,7,67,0,8,122,0,8,58,0,9,213,82,7,19,0,8,106,0,8,42,0,9,181,0,8,10,0,8,138,0,8,74,0,9,245,80,7,5,0,8,86,0,8,22,192,8,0,83,7,51,0,8,118,0,8,54,0,9,205,81,7,15,0,8,102,0,8,38,0,9,173,0,8,6,0,8,134,0,8,70,0,9,237,80,7,9,0,8,94,0,8,30,0,9,157,84,7,99,0,8,126,0,8,62,0,9,221,82,7,27,0,8,110,0,8,46,0,9,189,0,8,14,0,8,142,0,8,78,0,9,253,96,7,256,0,8,81,0,8,17,85,8,131,82,7,31,0,8,113,0,8,49,0,9,195,80,7,10,0,8,97,0,8,33,0,9,163,0,8,1,0,8,129,0,8,65,0,9,227,80,7,6,0,8,89,0,8,25,0,9,147,83,7,59,0,8,121,0,8,57,0,9,211,81,7,17,0,8,105,0,8,41,0,9,179,0,8,9,0,8,137,0,8,73,0,9,243,80,7,4,0,8,85,0,8,21,80,8,258,83,7,43,0,8,117,0,8,53,0,9,203,81,7,13,0,8,101,0,8,37,0,9,171,0,8,5,0,8,133,0,8,69,0,9,235,80,7,8,0,8,93,0,8,29,0,9,155,84,7,83,0,8,125,0,8,61,0,9,219,82,7,23,0,8,109,0,8,45,0,9,187,0,8,13,0,8,141,0,8,77,0,9,251,80,7,3,0,8,83,0,8,19,85,8,195,83,7,35,0,8,115,0,8,51,0,9,199,81,7,11,0,8,99,0,8,35,0,9,167,0,8,3,0,8,131,0,8,67,0,9,231,80,7,7,0,8,91,0,8,27,0,9,151,84,7,67,0,8,123,0,8,59,0,9,215,82,7,19,0,8,107,0,8,43,0,9,183,0,8,11,0,8,139,0,8,75,0,9,247,80,7,5,0,8,87,0,8,23,192,8,0,83,7,51,0,8,119,0,8,55,0,9,207,81,7,15,0,8,103,0,8,39,0,9,175,0,8,7,0,8,135,0,8,71,0,9,239,80,7,9,0,8,95,0,8,31,0,9,159,84,7,99,0,8,127,0,8,63,0,9,223,82,7,27,0,8,111,0,8,47,0,9,191,0,8,15,0,8,143,0,8,79,0,9,255],a=[80,5,1,87,5,257,83,5,17,91,5,4097,81,5,5,89,5,1025,85,5,65,93,5,16385,80,5,3,88,5,513,84,5,33,92,5,8193,82,5,9,90,5,2049,86,5,129,192,5,24577,80,5,2,87,5,385,83,5,25,91,5,6145,81,5,7,89,5,1537,85,5,97,93,5,24577,80,5,4,88,5,769,84,5,49,92,5,12289,82,5,13,90,5,3073,86,5,193,192,5,24577],s=[3,4,5,6,7,8,9,10,11,13,15,17,19,23,27,31,35,43,51,59,67,83,99,115,131,163,195,227,258,0,0],o=[0,0,0,0,0,0,0,0,1,1,1,1,2,2,2,2,3,3,3,3,4,4,4,4,5,5,5,5,0,112,112],l=[1,2,3,4,5,7,9,13,17,25,33,49,65,97,129,193,257,385,513,769,1025,1537,2049,3073,4097,6145,8193,12289,16385,24577],c=[0,0,0,0,1,1,2,2,3,3,4,4,5,5,6,6,7,7,8,8,9,9,10,10,11,11,12,12,13,13],u=15;function d(){let e,i,r,a,d,f;function h(e,i,s,o,l,c,h,w,_,b,p){let m,g,y,x,k,v,S,z,A,U,D,E,T,F,O;U=0,k=s;do{r[e[i+U]]++,U++,k--}while(0!==k);if(r[0]==s)return h[0]=-1,w[0]=0,0;for(z=w[0],v=1;v<=u&&0===r[v];v++);for(S=v,zk&&(z=k),w[0]=z,F=1<E+z;){if(x++,E+=z,O=y-E,O=O>z?z:O,(g=1<<(v=S-E))>m+1&&(g-=m+1,T=S,v1440)return t;d[x]=D=b[0],b[0]+=O,0!==x?(f[x]=k,a[0]=v,a[1]=z,v=k>>>E-z,a[2]=D-d[x-1]-v,_.set(a,3*(d[x-1]+v))):h[0]=D}for(a[1]=S-E,U>=s?a[0]=192:p[U]>>E;v>>=1)k^=v;for(k^=v,A=(1<257?(g==t?m.msg="oversubscribed distance tree":g==n?(m.msg="incomplete distance tree",g=t):-4!=g&&(m.msg="empty distance tree with lengths",g=t),g):0)}}d.inflate_trees_fixed=function(e,t,n,i){return e[0]=9,t[0]=5,n[0]=r,i[0]=a,0};function f(){const n=this;let r,a,s,o,l=0,c=0,u=0,d=0,f=0,h=0,w=0,_=0,b=0,p=0;function m(e,n,r,a,s,o,l,c){let u,d,f,h,w,_,b,p,m,g,y,x,k,v,S,z;b=c.next_in_index,p=c.avail_in,w=l.bitb,_=l.bitk,m=l.write,g=m>=d[z+1],_-=d[z+1],16&h){for(h&=15,k=d[z+2]+(w&i[h]),w>>=h,_-=h;_<15;)p--,w|=(255&c.read_byte(b++))<<_,_+=8;for(u=w&x,d=s,f=o,z=3*(f+u),h=d[z];;){if(w>>=d[z+1],_-=d[z+1],16&h){for(h&=15;_>=h,_-=h,g-=k,m>=v)S=m-v,m-S>0&&2>m-S?(l.win[m++]=l.win[S++],l.win[m++]=l.win[S++],k-=2):(l.win.set(l.win.subarray(S,S+2),m),m+=2,S+=2,k-=2);else{S=m-v;do{S+=l.end}while(S<0);if(h=l.end-S,k>h){if(k-=h,m-S>0&&h>m-S)do{l.win[m++]=l.win[S++]}while(0!=--h);else l.win.set(l.win.subarray(S,S+h),m),m+=h,S+=h,h=0;S=0}}if(m-S>0&&k>m-S)do{l.win[m++]=l.win[S++]}while(0!=--k);else l.win.set(l.win.subarray(S,S+k),m),m+=k,S+=k,k=0;break}if(64&h)return c.msg="invalid distance code",k=c.avail_in-p,k=_>>3>3:k,p+=k,b-=k,_-=k<<3,l.bitb=w,l.bitk=_,c.avail_in=p,c.total_in+=b-c.next_in_index,c.next_in_index=b,l.write=m,t;u+=d[z+2],u+=w&i[h],z=3*(f+u),h=d[z]}break}if(64&h)return 32&h?(k=c.avail_in-p,k=_>>3>3:k,p+=k,b-=k,_-=k<<3,l.bitb=w,l.bitk=_,c.avail_in=p,c.total_in+=b-c.next_in_index,c.next_in_index=b,l.write=m,1):(c.msg="invalid literal/length code",k=c.avail_in-p,k=_>>3>3:k,p+=k,b-=k,_-=k<<3,l.bitb=w,l.bitk=_,c.avail_in=p,c.total_in+=b-c.next_in_index,c.next_in_index=b,l.write=m,t);if(u+=d[z+2],u+=w&i[h],z=3*(f+u),0===(h=d[z])){w>>=d[z+1],_-=d[z+1],l.win[m++]=d[z+2],g--;break}}else w>>=d[z+1],_-=d[z+1],l.win[m++]=d[z+2],g--}while(g>=258&&p>=10);return k=c.avail_in-p,k=_>>3>3:k,p+=k,b-=k,_-=k<<3,l.bitb=w,l.bitk=_,c.avail_in=p,c.total_in+=b-c.next_in_index,c.next_in_index=b,l.write=m,0}n.init=function(e,t,n,i,l,c){r=0,w=e,_=t,s=n,b=i,o=l,p=c,a=null},n.proc=function(n,g,y){let x,k,v,S,z,A,U,D=0,E=0,T=0;for(T=g.next_in_index,S=g.avail_in,D=n.bitb,E=n.bitk,z=n.write,A=z=258&&S>=10&&(n.bitb=D,n.bitk=E,g.avail_in=S,g.total_in+=T-g.next_in_index,g.next_in_index=T,n.write=z,y=m(w,_,s,b,o,p,n,g),T=g.next_in_index,S=g.avail_in,D=n.bitb,E=n.bitk,z=n.write,A=z>>=a[k+1],E-=a[k+1],v=a[k],0===v){d=a[k+2],r=6;break}if(16&v){f=15&v,l=a[k+2],r=2;break}if(!(64&v)){u=v,c=k/3+a[k+2];break}if(32&v){r=7;break}return r=9,g.msg="invalid literal/length code",y=t,n.bitb=D,n.bitk=E,g.avail_in=S,g.total_in+=T-g.next_in_index,g.next_in_index=T,n.write=z,n.inflate_flush(g,y);case 2:for(x=f;E>=x,E-=x,u=_,a=o,c=p,r=3;case 3:for(x=u;E>=a[k+1],E-=a[k+1],v=a[k],16&v){f=15&v,h=a[k+2],r=4;break}if(!(64&v)){u=v,c=k/3+a[k+2];break}return r=9,g.msg="invalid distance code",y=t,n.bitb=D,n.bitk=E,g.avail_in=S,g.total_in+=T-g.next_in_index,g.next_in_index=T,n.write=z,n.inflate_flush(g,y);case 4:for(x=f;E>=x,E-=x,r=5;case 5:for(U=z-h;U<0;)U+=n.end;for(;0!==l;){if(0===A&&(z==n.end&&0!==n.read&&(z=0,A=z7&&(E-=8,S++,T--),n.write=z,y=n.inflate_flush(g,y),z=n.write,A=ze.avail_out&&(i=e.avail_out),0!==i&&t==n&&(t=0),e.avail_out-=i,e.total_out+=i,e.next_out.set(s.win.subarray(a,a+i),r),r+=i,a+=i,a==s.end&&(a=0,s.write==s.end&&(s.write=0),i=s.write-a,i>e.avail_out&&(i=e.avail_out),0!==i&&t==n&&(t=0),e.avail_out-=i,e.total_out+=i,e.next_out.set(s.win.subarray(a,a+i),r),r+=i,a+=i),e.next_out_index=r,s.read=a,t},s.proc=function(n,r){let a,f,x,k,v,S,z,A;for(k=n.next_in_index,v=n.avail_in,f=s.bitb,x=s.bitk,S=s.write,z=S>>1){case 0:f>>>=3,x-=3,a=7&x,f>>>=a,x-=a,l=1;break;case 1:U=[],D=[],E=[[]],T=[[]],d.inflate_trees_fixed(U,D,E,T),p.init(U[0],D[0],E[0],0,T[0],0),f>>>=3,x-=3,l=6;break;case 2:f>>>=3,x-=3,l=3;break;case 3:return f>>>=3,x-=3,l=9,n.msg="invalid block type",r=t,s.bitb=f,s.bitk=x,n.avail_in=v,n.total_in+=k-n.next_in_index,n.next_in_index=k,s.write=S,s.inflate_flush(n,r)}break;case 1:for(;x<32;){if(0===v)return s.bitb=f,s.bitk=x,n.avail_in=v,n.total_in+=k-n.next_in_index,n.next_in_index=k,s.write=S,s.inflate_flush(n,r);r=0,v--,f|=(255&n.read_byte(k++))<>>16&65535)!=(65535&f))return l=9,n.msg="invalid stored block lengths",r=t,s.bitb=f,s.bitk=x,n.avail_in=v,n.total_in+=k-n.next_in_index,n.next_in_index=k,s.write=S,s.inflate_flush(n,r);c=65535&f,f=x=0,l=0!==c?2:0!==m?7:0;break;case 2:if(0===v)return s.bitb=f,s.bitk=x,n.avail_in=v,n.total_in+=k-n.next_in_index,n.next_in_index=k,s.write=S,s.inflate_flush(n,r);if(0===z&&(S==s.end&&0!==s.read&&(S=0,z=Sv&&(a=v),a>z&&(a=z),s.win.set(n.read_buf(k,a),S),k+=a,v-=a,S+=a,z-=a,0!=(c-=a))break;l=0!==m?7:0;break;case 3:for(;x<14;){if(0===v)return s.bitb=f,s.bitk=x,n.avail_in=v,n.total_in+=k-n.next_in_index,n.next_in_index=k,s.write=S,s.inflate_flush(n,r);r=0,v--,f|=(255&n.read_byte(k++))<29||(a>>5&31)>29)return l=9,n.msg="too many length or distance symbols",r=t,s.bitb=f,s.bitk=x,n.avail_in=v,n.total_in+=k-n.next_in_index,n.next_in_index=k,s.write=S,s.inflate_flush(n,r);if(a=258+(31&a)+(a>>5&31),!o||o.length>>=14,x-=14,w=0,l=4;case 4:for(;w<4+(u>>>10);){for(;x<3;){if(0===v)return s.bitb=f,s.bitk=x,n.avail_in=v,n.total_in+=k-n.next_in_index,n.next_in_index=k,s.write=S,s.inflate_flush(n,r);r=0,v--,f|=(255&n.read_byte(k++))<>>=3,x-=3}for(;w<19;)o[h[w++]]=0;if(_[0]=7,a=y.inflate_trees_bits(o,_,b,g,n),0!=a)return(r=a)==t&&(o=null,l=9),s.bitb=f,s.bitk=x,n.avail_in=v,n.total_in+=k-n.next_in_index,n.next_in_index=k,s.write=S,s.inflate_flush(n,r);w=0,l=5;case 5:for(;a=u,!(w>=258+(31&a)+(a>>5&31));){let e,c;for(a=_[0];x>>=a,x-=a,o[w++]=c;else{for(A=18==c?7:c-14,e=18==c?11:3;x>>=a,x-=a,e+=f&i[A],f>>>=A,x-=A,A=w,a=u,A+e>258+(31&a)+(a>>5&31)||16==c&&A<1)return o=null,l=9,n.msg="invalid bit length repeat",r=t,s.bitb=f,s.bitk=x,n.avail_in=v,n.total_in+=k-n.next_in_index,n.next_in_index=k,s.write=S,s.inflate_flush(n,r);c=16==c?o[A-1]:0;do{o[A++]=c}while(0!=--e);w=A}}if(b[0]=-1,F=[],O=[],C=[],W=[],F[0]=9,O[0]=6,a=u,a=y.inflate_trees_dynamic(257+(31&a),1+(a>>5&31),o,F,O,C,W,g,n),0!=a)return a==t&&(o=null,l=9),r=a,s.bitb=f,s.bitk=x,n.avail_in=v,n.total_in+=k-n.next_in_index,n.next_in_index=k,s.write=S,s.inflate_flush(n,r);p.init(F[0],O[0],g,C[0],g,W[0]),l=6;case 6:if(s.bitb=f,s.bitk=x,n.avail_in=v,n.total_in+=k-n.next_in_index,n.next_in_index=k,s.write=S,1!=(r=p.proc(s,n,r)))return s.inflate_flush(n,r);if(r=0,p.free(n),k=n.next_in_index,v=n.avail_in,f=s.bitb,x=s.bitk,S=s.write,z=S15?(i.inflateEnd(t),e):(i.wbits=n,t.istate.blocks=new w(t,1<>4)>o.wbits){o.mode=_,i.msg="invalid win size",o.marker=5;break}o.mode=1;case 1:if(0===i.avail_in)return a;if(a=r,i.avail_in--,i.total_in++,s=255&i.read_byte(i.next_in_index++),((o.method<<8)+s)%31!=0){o.mode=_,i.msg="incorrect header check",o.marker=5;break}if(!(32&s)){o.mode=7;break}o.mode=2;case 2:if(0===i.avail_in)return a;a=r,i.avail_in--,i.total_in++,o.need=(255&i.read_byte(i.next_in_index++))<<24&4278190080,o.mode=3;case 3:if(0===i.avail_in)return a;a=r,i.avail_in--,i.total_in++,o.need+=(255&i.read_byte(i.next_in_index++))<<16&16711680,o.mode=4;case 4:if(0===i.avail_in)return a;a=r,i.avail_in--,i.total_in++,o.need+=(255&i.read_byte(i.next_in_index++))<<8&65280,o.mode=5;case 5:return 0===i.avail_in?a:(a=r,i.avail_in--,i.total_in++,o.need+=255&i.read_byte(i.next_in_index++),o.mode=6,2);case 6:return o.mode=_,i.msg="need dictionary",o.marker=0,e;case 7:if(a=o.blocks.proc(i,a),a==t){o.mode=_,o.marker=0;break}if(0==a&&(a=r),1!=a)return a;a=r,o.blocks.reset(i,o.was),o.mode=12;case 12:return i.avail_in=0,1;case _:return t;default:return e}},i.inflateSetDictionary=function(t,n,i){let r=0,a=i;if(!t||!t.istate||6!=t.istate.mode)return e;const s=t.istate;return a>=1<>>1^3988292384:t>>>=1;C[e]=t}class W{constructor(e){this.crc=e||-1}append(e){let t=0|this.crc;for(let n=0,i=0|e.length;n>>8^C[255&(t^e[n])];this.crc=t}get(){return~this.crc}}class j extends TransformStream{constructor(){let e;const t=new W;super({transform(e,n){t.append(e),n.enqueue(e)},flush(){const n=new Uint8Array(4);new DataView(n.buffer).setUint32(0,t.get()),e.value=n}}),e=this}}const M={concat(e,t){if(0===e.length||0===t.length)return e.concat(t);const n=e[e.length-1],i=M.getPartial(n);return 32===i?e.concat(t):M._shiftRight(t,i,0|n,e.slice(0,e.length-1))},bitLength(e){const t=e.length;if(0===t)return 0;const n=e[t-1];return 32*(t-1)+M.getPartial(n)},clamp(e,t){if(32*e.length0&&t&&(e[n-1]=M.partial(t,e[n-1]&2147483648>>t-1,1)),e},partial:(e,t,n)=>32===e?t:(n?0|t:t<<32-e)+1099511627776*e,getPartial:e=>Math.round(e/1099511627776)||32,_shiftRight(e,t,n,i){for(void 0===i&&(i=[]);t>=32;t-=32)i.push(n),n=0;if(0===t)return i.concat(e);for(let r=0;r>>t),n=e[r]<<32-t;const r=e.length?e[e.length-1]:0,a=M.getPartial(r);return i.push(M.partial(t+a&31,t+a>32?n:i.pop(),1)),i}},L={bytes:{fromBits(e){const t=M.bitLength(e)/8,n=new Uint8Array(t);let i;for(let r=0;r>>24,i<<=8;return n},toBits(e){const t=[];let n,i=0;for(n=0;n9007199254740991)throw new Error("Cannot hash more than 2^53 - 1 bits");const a=new Uint32Array(n);let s=0;for(let e=t.blockSize+i-(t.blockSize+i&t.blockSize-1);e<=r;e+=t.blockSize)t._block(a.subarray(16*s,16*(s+1))),s+=1;return n.splice(0,16*s),t}finalize(){const e=this;let t=e._buffer;const n=e._h;t=M.concat(t,[M.partial(1,1)]);for(let e=t.length+2;15&e;e++)t.push(0);for(t.push(Math.floor(e._length/4294967296)),t.push(0|e._length);t.length;)e._block(t.splice(0,16));return e.reset(),n}_f(e,t,n,i){return e<=19?t&n|~t&i:e<=39?t^n^i:e<=59?t&n|t&i|n&i:e<=79?t^n^i:void 0}_S(e,t){return t<>>32-e}_block(e){const t=this,n=t._h,i=Array(80);for(let t=0;t<16;t++)i[t]=e[t];let r=n[0],a=n[1],s=n[2],o=n[3],l=n[4];for(let e=0;e<=79;e++){e>=16&&(i[e]=t._S(1,i[e-3]^i[e-8]^i[e-14]^i[e-16]));const n=t._S(5,r)+t._f(e,a,s,o)+l+i[e]+t._key[Math.floor(e/20)]|0;l=o,o=s,s=t._S(30,a),a=r,r=n}n[0]=n[0]+r|0,n[1]=n[1]+a|0,n[2]=n[2]+s|0,n[3]=n[3]+o|0,n[4]=n[4]+l|0}}},R={aes:class{constructor(e){const t=this;t._tables=[[[],[],[],[],[]],[[],[],[],[],[]]],t._tables[0][0][0]||t._precompute();const n=t._tables[0][4],i=t._tables[1],r=e.length;let a,s,o,l=1;if(4!==r&&6!==r&&8!==r)throw new Error("invalid aes key size");for(t._key=[s=e.slice(0),o=[]],a=r;a<4*r+28;a++){let e=s[a-1];(a%r==0||8===r&&a%r==4)&&(e=n[e>>>24]<<24^n[e>>16&255]<<16^n[e>>8&255]<<8^n[255&e],a%r==0&&(e=e<<8^e>>>24^l<<24,l=l<<1^283*(l>>7))),s[a]=s[a-r]^e}for(let e=0;a;e++,a--){const t=s[3&e?a:a-4];o[e]=a<=4||e<4?t:i[0][n[t>>>24]]^i[1][n[t>>16&255]]^i[2][n[t>>8&255]]^i[3][n[255&t]]}}encrypt(e){return this._crypt(e,0)}decrypt(e){return this._crypt(e,1)}_precompute(){const e=this._tables[0],t=this._tables[1],n=e[4],i=t[4],r=[],a=[];let s,o,l,c;for(let e=0;e<256;e++)a[(r[e]=e<<1^283*(e>>7))^e]=e;for(let u=s=0;!n[u];u^=o||1,s=a[s]||1){let a=s^s<<1^s<<2^s<<3^s<<4;a=a>>8^255&a^99,n[u]=a,i[a]=u,c=r[l=r[o=r[u]]];let d=16843009*c^65537*l^257*o^16843008*u,f=257*r[a]^16843008*a;for(let n=0;n<4;n++)e[n][u]=f=f<<24^f>>>8,t[n][a]=d=d<<24^d>>>8}for(let n=0;n<5;n++)e[n]=e[n].slice(0),t[n]=t[n].slice(0)}_crypt(e,t){if(4!==e.length)throw new Error("invalid aes block size");const n=this._key[t],i=n.length/4-2,r=[0,0,0,0],a=this._tables[t],s=a[0],o=a[1],l=a[2],c=a[3],u=a[4];let d,f,h,w=e[0]^n[0],_=e[t?3:1]^n[1],b=e[2]^n[2],p=e[t?1:3]^n[3],m=4;for(let e=0;e>>24]^o[_>>16&255]^l[b>>8&255]^c[255&p]^n[m],f=s[_>>>24]^o[b>>16&255]^l[p>>8&255]^c[255&w]^n[m+1],h=s[b>>>24]^o[p>>16&255]^l[w>>8&255]^c[255&_]^n[m+2],p=s[p>>>24]^o[w>>16&255]^l[_>>8&255]^c[255&b]^n[m+3],m+=4,w=d,_=f,b=h;for(let e=0;e<4;e++)r[t?3&-e:e]=u[w>>>24]<<24^u[_>>16&255]<<16^u[b>>8&255]<<8^u[255&p]^n[m++],d=w,w=_,_=b,b=p,p=d;return r}}},B={getRandomValues(e){const t=new Uint32Array(e.buffer),n=e=>{let t=987654321;const n=4294967295;return function(){t=36969*(65535&t)+(t>>16)&n;return(((t<<16)+(e=18e3*(65535&e)+(e>>16)&n)&n)/4294967296+.5)*(Math.random()>.5?1:-1)}};for(let i,r=0;r>24))e+=1<<24;else{let t=e>>16&255,n=e>>8&255,i=255&e;255===t?(t=0,255===n?(n=0,255===i?i=0:++i):++n):++t,e=0,e+=t<<16,e+=n<<8,e+=i}return e}incCounter(e){0===(e[0]=this.incWord(e[0]))&&(e[1]=this.incWord(e[1]))}calculate(e,t,n){let i;if(!(i=t.length))return[];const r=M.bitLength(t);for(let r=0;rnew N.hmacSha1(L.bytes.toBits(e)),pbkdf2(e,t,n,i){if(n=n||1e4,i<0||n<0)throw new Error("invalid params to pbkdf2");const r=1+(i>>5)<<2;let a,s,o,l,c;const u=new ArrayBuffer(r),d=new DataView(u);let f=0;const h=M;for(t=L.bytes.toBits(t),c=1;f<(r||1);c++){for(a=s=e.encrypt(h.concat(t,[c])),o=1;or&&(e=(new n).update(e).finalize());for(let t=0;tthis.resolveReady=e)),password:be(e,t),signed:n,strength:i-1,pending:new Uint8Array})},async transform(e,t){const n=this,{password:i,strength:a,resolveReady:s,ready:o}=n;i?(await async function(e,t,n,i){const r=await _e(e,t,n,me(i,0,$[t])),a=me(i,$[t]);if(r[0]!=a[0]||r[1]!=a[1])throw new Error(q)}(n,a,i,me(e,0,$[a]+2)),e=me(e,$[a]+2),r?t.error(new Error(K)):s()):await o;const l=new Uint8Array(e.length-te-(e.length-te)%G);t.enqueue(we(n,e,l,0,te,!0))},async flush(e){const{signed:t,ctr:n,hmac:i,pending:r,ready:a}=this;if(i&&n){await a;const s=me(r,0,r.length-te),o=me(r,r.length-te);let l=new Uint8Array;if(s.length){const e=ye(se,s);i.update(e);const t=n.update(e);l=ge(se,t)}if(t){const e=me(ge(se,i.digest()),0,te);for(let t=0;tthis.resolveReady=e)),password:be(e,t),strength:n-1,pending:new Uint8Array})},async transform(e,t){const n=this,{password:i,strength:r,resolveReady:a,ready:s}=n;let o=new Uint8Array;i?(o=await async function(e,t,n){const i=Z(new Uint8Array($[t])),r=await _e(e,t,n,i);return pe(i,r)}(n,r,i),a()):await s;const l=new Uint8Array(o.length+e.length-e.length%G);l.set(o,0),t.enqueue(we(n,e,l,o.length,0))},async flush(e){const{ctr:t,hmac:n,pending:r,ready:a}=this;if(n&&t){await a;let s=new Uint8Array;if(r.length){const e=t.update(ye(se,r));n.update(e),s=ge(se,e)}i.signature=ge(se,n.digest()).slice(0,te),e.enqueue(pe(s,i.signature))}}}),i=this}}function we(e,t,n,i,r,a){const{ctr:s,hmac:o,pending:l}=e,c=t.length-r;let u;for(l.length&&(t=pe(l,t),n=function(e,t){if(t&&t>e.length){const n=e;(e=new Uint8Array(t)).set(n,0)}return e}(n,c-c%G)),u=0;u<=c-G;u+=G){const e=ye(se,me(t,u,u+G));a&&o.update(e);const r=s.update(e);a||o.update(r),n.set(ge(se,r),u+i)}return e.pending=me(t,u),n}async function _e(e,t,n,i){e.password=null;const r=await async function(e,t,n,i,r){if(!ue)return N.importKey(t);try{return await re.importKey(e,t,n,i,r)}catch(e){return ue=!1,N.importKey(t)}}("raw",n,Q,!1,Y),a=await async function(e,t,n){if(!de)return N.pbkdf2(t,e.salt,X.iterations,n);try{return await re.deriveBits(e,t,n)}catch(i){return de=!1,N.pbkdf2(t,e.salt,X.iterations,n)}}(Object.assign({salt:i},X),r,8*(2*ee[t]+2)),s=new Uint8Array(a),o=ye(se,me(s,0,ee[t])),l=ye(se,me(s,ee[t],2*ee[t])),c=me(s,2*ee[t]);return Object.assign(e,{keys:{key:o,authentication:l,passwordVerification:c},ctr:new le(new oe(o),Array.from(ne)),hmac:new ce(l)}),c}function be(e,t){return t===S?function(e){if(typeof TextEncoder==z){e=unescape(encodeURIComponent(e));const t=new Uint8Array(e.length);for(let n=0;n>>24]),r=~e.crcKey2.get(),e.keys=[n,i,r]}function De(e){const t=2|e.keys[2];return Ee(Math.imul(t,1^t)>>>8)}function Ee(e){return 255&e}function Te(e){return 4294967295&e}const Fe="deflate-raw";class Oe extends TransformStream{constructor(e,{chunkSize:t,CompressionStream:n,CompressionStreamNative:i}){super({});const{compressed:r,encrypted:a,useCompressionStream:s,zipCrypto:o,signed:l,level:c}=e,u=this;let d,f,h=We(super.readable);a&&!o||!l||(d=new j,h=Le(h,d)),r&&(h=Me(h,s,{level:c,chunkSize:t},i,n)),a&&(o?h=Le(h,new ve(e)):(f=new he(e),h=Le(h,f))),je(u,h,(()=>{let e;a&&!o&&(e=f.signature),a&&!o||!l||(e=new DataView(d.value.buffer).getUint32(0)),u.signature=e}))}}class Ce extends TransformStream{constructor(e,{chunkSize:t,DecompressionStream:n,DecompressionStreamNative:i}){super({});const{zipCrypto:r,encrypted:a,signed:s,signature:o,compressed:l,useCompressionStream:c}=e;let u,d,f=We(super.readable);a&&(r?f=Le(f,new ke(e)):(d=new fe(e),f=Le(f,d))),l&&(f=Me(f,c,{chunkSize:t},i,n)),a&&!r||!s||(u=new j,f=Le(f,u)),je(this,f,(()=>{if((!a||r)&&s){const e=new DataView(u.value.buffer);if(o!=e.getUint32(0,!1))throw new Error(H)}}))}}function We(e){return Le(e,new TransformStream({transform(e,t){e&&e.length&&t.enqueue(e)}}))}function je(e,t,n){t=Le(t,new TransformStream({flush:n})),Object.defineProperty(e,"readable",{get:()=>t})}function Me(e,t,n,i,r){try{e=Le(e,new(t&&i?i:r)(Fe,n))}catch(i){if(!t)return e;try{e=Le(e,new r(Fe,n))}catch(t){return e}}return e}function Le(e,t){return e.pipeThrough(t)}const Pe="message",Re="start",Be="pull",Ie="data",Ne="close",Ve="inflate";class qe extends TransformStream{constructor(e,t){super({});const n=this,{codecType:i}=e;let r;i.startsWith("deflate")?r=Oe:i.startsWith(Ve)&&(r=Ce);let a=0,s=0;const o=new r(e,t),l=super.readable,c=new TransformStream({transform(e,t){e&&e.length&&(s+=e.length,t.enqueue(e))},flush(){Object.assign(n,{inputSize:s})}}),u=new TransformStream({transform(e,t){e&&e.length&&(a+=e.length,t.enqueue(e))},flush(){const{signature:e}=o;Object.assign(n,{signature:e,outputSize:a,inputSize:s})}});Object.defineProperty(n,"readable",{get:()=>l.pipeThrough(c).pipeThrough(o).pipeThrough(u)})}}class He extends TransformStream{constructor(e){let t;super({transform:function n(i,r){if(t){const e=new Uint8Array(t.length+i.length);e.set(t),e.set(i,t.length),i=e,t=null}i.length>e?(r.enqueue(i.slice(0,e)),n(i.slice(e),r)):t=i},flush(e){t&&t.length&&e.enqueue(t)}})}}let Ke=typeof Worker!=z;class Ze{constructor(e,{readable:t,writable:n},{options:i,config:r,streamOptions:a,useWebWorkers:s,transferStreams:o,scripts:l},c){const{signal:u}=a;return Object.assign(e,{busy:!0,readable:t.pipeThrough(new He(r.chunkSize)).pipeThrough(new Ge(t,a),{signal:u}),writable:n,options:Object.assign({},i),scripts:l,transferStreams:o,terminate:()=>new Promise((t=>{const{worker:n,busy:i}=e;n?(i?e.resolveTerminated=t:(n.terminate(),t()),e.interface=null):t()})),onTaskFinished(){const{resolveTerminated:t}=e;t&&(e.resolveTerminated=null,e.terminated=!0,e.worker.terminate(),t()),e.busy=!1,c(e)}}),(s&&Ke?Xe:Qe)(e,r)}}class Ge extends TransformStream{constructor(e,{onstart:t,onprogress:n,size:i,onend:r}){let a=0;super({async start(){t&&await Je(t,i)},async transform(e,t){a+=e.length,n&&await Je(n,a,i),t.enqueue(e)},async flush(){e.size=a,r&&await Je(r,a)}})}}async function Je(e,...t){try{await e(...t)}catch(e){}}function Qe(e,t){return{run:()=>async function({options:e,readable:t,writable:n,onTaskFinished:i},r){try{const i=new qe(e,r);await t.pipeThrough(i).pipeTo(n,{preventClose:!0,preventAbort:!0});const{signature:a,inputSize:s,outputSize:o}=i;return{signature:a,inputSize:s,outputSize:o}}finally{i()}}(e,t)}}function Xe(e,t){const{baseURL:n,chunkSize:i}=t;if(!e.interface){let r;try{r=function(e,t,n){const i={type:"module"};let r,a;typeof e==A&&(e=e());try{r=new URL(e,t)}catch(t){r=e}if(Ye)try{a=new Worker(r)}catch(e){Ye=!1,a=new Worker(r,i)}else a=new Worker(r,i);return a.addEventListener(Pe,(e=>async function({data:e},t){const{type:n,value:i,messageId:r,result:a,error:s}=e,{reader:o,writer:l,resolveResult:c,rejectResult:u,onTaskFinished:d}=t;try{if(s){const{message:e,stack:t,code:n,name:i}=s,r=new Error(e);Object.assign(r,{stack:t,code:n,name:i}),f(r)}else{if(n==Be){const{value:e,done:n}=await o.read();et({type:Ie,value:e,done:n,messageId:r},t)}n==Ie&&(await l.ready,await l.write(new Uint8Array(i)),et({type:"ack",messageId:r},t)),n==Ne&&f(null,a)}}catch(s){et({type:Ne,messageId:r},t),f(s)}function f(e,t){e?u(e):c(t),l&&l.releaseLock(),d()}}(e,n))),a}(e.scripts[0],n,e)}catch(n){return Ke=!1,Qe(e,t)}Object.assign(e,{worker:r,interface:{run:()=>async function(e,t){let n,i;const r=new Promise(((e,t)=>{n=e,i=t}));Object.assign(e,{reader:null,writer:null,resolveResult:n,rejectResult:i,result:r});const{readable:a,options:s,scripts:o}=e,{writable:l,closed:c}=function(e){let t;const n=new Promise((e=>t=e)),i=new WritableStream({async write(t){const n=e.getWriter();await n.ready,await n.write(t),n.releaseLock()},close(){t()},abort:t=>e.getWriter().abort(t)});return{writable:i,closed:n}}(e.writable),u=et({type:Re,scripts:o.slice(1),options:s,config:t,readable:a,writable:l},e);u||Object.assign(e,{reader:a.getReader(),writer:l.getWriter()});const d=await r;u||await l.getWriter().close();return await c,d}(e,{chunkSize:i})}})}return e.interface}let Ye=!0,$e=!0;function et(e,{worker:t,writer:n,onTaskFinished:i,transferStreams:r}){try{let{value:n,readable:i,writable:a}=e;const s=[];if(n&&(n.byteLength!e.busy));if(n)return at(n),new Ze(n,e,t,w);if(tt.lengthnt.push({resolve:n,stream:e,workerOptions:t})))}()).run();function w(e){if(nt.length){const[{resolve:t,stream:n,workerOptions:i}]=nt.splice(0,1);t(new Ze(e,n,i,w))}else e.worker?(at(e),function(e,t){const{config:n}=t,{terminateWorkerTimeout:i}=n;Number.isFinite(i)&&i>=0&&(e.terminated?e.terminated=!1:e.terminateTimeout=setTimeout((async()=>{tt=tt.filter((t=>t!=e));try{await e.terminate()}catch(e){}}),i))}(e,t)):tt=tt.filter((t=>t!=e))}}function at(e){const{terminateTimeout:t}=e;t&&(clearTimeout(t),e.terminateTimeout=null)}const st=65536,ot="writable";class lt{constructor(){this.size=0}init(){this.initialized=!0}}class ct extends lt{get readable(){const e=this,{chunkSize:t=st}=e,n=new ReadableStream({start(){this.chunkOffset=0},async pull(i){const{offset:r=0,size:a,diskNumberStart:s}=n,{chunkOffset:o}=this;i.enqueue(await pt(e,r+o,Math.min(t,a-o),s)),o+t>a?i.close():this.chunkOffset+=t}});return n}}class ut extends ct{constructor(e){super(),Object.assign(this,{blob:e,size:e.size})}async readUint8Array(e,t){const n=this,i=e+t,r=e||it&&(a=a.slice(e,i)),new Uint8Array(a)}}class dt extends lt{constructor(e){super();const t=new TransformStream,n=[];e&&n.push(["Content-Type",e]),Object.defineProperty(this,ot,{get:()=>t.writable}),this.blob=new Response(t.readable,{headers:n}).blob()}getData(){return this.blob}}class ft extends dt{constructor(e){super(e),Object.assign(this,{encoding:e,utf8:!e||"utf-8"==e.toLowerCase()})}async getData(){const{encoding:e,utf8:t}=this,n=await super.getData();if(n.text&&t)return n.text();{const t=new FileReader;return new Promise(((i,r)=>{Object.assign(t,{onload:({target:e})=>i(e.result),onerror:()=>r(t.error)}),t.readAsText(n,e)}))}}}class ht extends ct{constructor(e){super(),this.readers=e}async init(){const e=this,{readers:t}=e;e.lastDiskNumber=0,e.lastDiskOffset=0,await Promise.all(t.map((async(n,i)=>{await n.init(),i!=t.length-1&&(e.lastDiskOffset+=n.size),e.size+=n.size}))),super.init()}async readUint8Array(e,t,n=0){const i=this,{readers:r}=this;let a,s=n;-1==s&&(s=r.length-1);let o=e;for(;o>=r[s].size;)o-=r[s].size,s++;const l=r[s],c=l.size;if(o+t<=c)a=await pt(l,o,t);else{const r=c-o;a=new Uint8Array(t),a.set(await pt(l,o,r)),a.set(await i.readUint8Array(e+r,t-r,n),r)}return i.lastDiskNumber=Math.max(s,i.lastDiskNumber),a}}class wt extends lt{constructor(e,t=4294967295){super();const n=this;let i,r,a;Object.assign(n,{diskNumber:0,diskOffset:0,size:0,maxSize:t,availableSize:t});const s=new WritableStream({async write(t){const{availableSize:s}=n;if(a)t.length>=s?(await o(t.slice(0,s)),await l(),n.diskOffset+=i.size,n.diskNumber++,a=null,await this.write(t.slice(s))):await o(t);else{const{value:s,done:o}=await e.next();if(o&&!s)throw new Error("Writer iterator completed too soon");i=s,i.size=0,i.maxSize&&(n.maxSize=i.maxSize),n.availableSize=n.maxSize,await _t(i),r=s.writable,a=r.getWriter(),await this.write(t)}},async close(){await a.ready,await l()}});async function o(e){const t=e.length;t&&(await a.ready,await a.write(e),i.size+=t,n.size+=t,n.availableSize-=t)}async function l(){r.size=i.size,await a.close()}Object.defineProperty(n,ot,{get:()=>s})}}async function _t(e,t){if(!e.init||e.initialized)return Promise.resolve();await e.init(t)}function bt(e){return Array.isArray(e)&&(e=new ht(e)),e instanceof ReadableStream&&(e={readable:e}),e}function pt(e,t,n,i){return e.readUint8Array(t,n,i)}const mt="\0☺☻♥♦♣♠•◘○◙♂♀♪♫☼►◄↕‼¶§▬↨↑↓→←∟↔▲▼ !\"#$%&'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~⌂ÇüéâäàåçêëèïîìÄÅÉæÆôöòûùÿÖÜ¢£¥₧ƒáíóúñѪº¿⌐¬½¼¡«»░▒▓│┤╡╢╖╕╣║╗╝╜╛┐└┴┬├─┼╞╟╚╔╩╦╠═╬╧╨╤╥╙╘╒╓╫╪┘┌█▄▌▐▀αßΓπΣσµτΦΘΩδ∞φε∩≡±≥≤⌠⌡÷≈°∙·√ⁿ²■ ".split(""),gt=256==mt.length;function yt(e,t){return t&&"cp437"==t.trim().toLowerCase()?function(e){if(gt){let t="";for(let n=0;nthis[t]=e[t]))}}const Lt="File format is not recognized",Pt="Zip64 extra field not found",Rt="Compression method not supported",Bt="Split zip file",It="utf-8",Nt="cp437",Vt=[[zt,g],[At,g],[Ut,g],[Dt,y]],qt={[y]:{getValue:tn,bytes:4},[g]:{getValue:nn,bytes:8}};class Ht{constructor(e,t={}){Object.assign(this,{reader:bt(e),options:t,config:T})}async*getEntriesGenerator(e={}){const t=this;let{reader:n}=t;const{config:i}=t;if(await _t(n),n.size!==S&&n.readUint8Array||(n=new ut(await new Response(n.readable).blob()),await _t(n)),n.size=0;e--)if(s[e]==a[0]&&s[e+1]==a[1]&&s[e+2]==a[2]&&s[e+3]==a[3])return{offset:r+e,buffer:s.slice(e,e+i).buffer}}}(n,101010256,n.size,v,1048560);if(!r){throw 134695760==tn(rn(await pt(n,0,4)))?new Error(Bt):new Error("End of central directory not found")}const a=rn(r);let s=tn(a,12),o=tn(a,16);const l=r.offset,c=en(a,20),u=l+v+c;let d=en(a,4);const f=n.lastDiskNumber||0;let h=en(a,6),w=en(a,8),_=0,b=0;if(o==g||s==g||w==y||h==y){const e=rn(await pt(n,r.offset-20,20));if(117853008==tn(e,0)){o=nn(e,8);let t=await pt(n,o,56,-1),i=rn(t);const a=r.offset-20-56;if(tn(i,0)!=k&&o!=a){const e=o;o=a,_=o-e,t=await pt(n,o,56,-1),i=rn(t)}if(tn(i,0)!=k)throw new Error("End of Zip64 central directory locator not found");d==y&&(d=tn(i,16)),h==y&&(h=tn(i,20)),w==y&&(w=nn(i,32)),s==g&&(s=nn(i,40)),o-=s}}if(o>=n.size&&(_=n.size-o-s-v,o=n.size-s-v),f!=d)throw new Error(Bt);if(o<0)throw new Error(Lt);let p=0,m=await pt(n,o,s,h),z=rn(m);if(s){const e=r.offset-s;if(tn(z,p)!=x&&o!=e){const t=o;o=e,_+=o-t,m=await pt(n,o,s,h),z=rn(m)}}const A=r.offset-o-(n.lastDiskOffset||0);if(s!=A&&A>=0&&(s=A,m=await pt(n,o,s,h),z=rn(m)),o<0||o>=n.size)throw new Error(Lt);const U=Qt(t,e,"filenameEncoding"),D=Qt(t,e,"commentEncoding");for(let r=0;ra.getData(e,j,t),p=g;const{onprogress:M}=e;if(M)try{await M(r+1,w,new Mt(a))}catch(e){}yield j}const E=Qt(t,e,"extractPrependedData"),T=Qt(t,e,"extractAppendedData");return E&&(t.prependedData=b>0?await pt(n,0,b):new Uint8Array),t.comment=c?await pt(n,l+v,c):new Uint8Array,T&&(t.appendedData=u>>8&255:d>>>24&255),signature:d,compressed:0!=l&&!g,encrypted:i.encrypted&&!g,useWebWorkers:Qt(i,n,"useWebWorkers"),useCompressionStream:Qt(i,n,"useCompressionStream"),transferStreams:Qt(i,n,"transferStreams"),checkPasswordOnly:D},config:c,streamOptions:{signal:U,size:v,onstart:T,onprogress:F,onend:O}};let W=0;try{({outputSize:W}=await rt({readable:z,writable:E},C))}catch(e){if(!D||e.message!=K)throw e}finally{const e=Qt(i,n,"preventClose");E.size+=W,e||E.locked||await E.getWriter().close()}return D?S:e.getData?e.getData():E}}function Zt(e,t,n){const i=e.rawBitFlag=en(t,n+2),r=!(1&~i),a=tn(t,n+6);Object.assign(e,{encrypted:r,version:en(t,n),bitFlag:{level:(6&i)>>1,dataDescriptor:!(8&~i),languageEncodingFlag:!(2048&~i)},rawLastModDate:a,lastModDate:Xt(a),filenameLength:en(t,n+22),extraFieldLength:en(t,n+24)})}async function Gt(e,t,n,i,r){const{rawExtraField:a}=t,s=t.extraField=new Map,o=rn(new Uint8Array(a));let l=0;try{for(;lt[e]==n));for(let r=0,a=0;r=5&&(a.push(Et),s.push(Tt));let o=1;a.forEach(((n,r)=>{if(e.data.length>=o+4){const a=tn(i,o);t[n]=e[n]=new Date(1e3*a);const l=s[r];e[l]=a}o+=4}))}(_,t,r),t.extraFieldExtendedTimestamp=_);const b=s.get(6534);b&&(t.extraFieldUSDZ=b)}async function Jt(e,t,n,i,r){const a=rn(e.data),s=new W;s.append(r[n]);const o=rn(new Uint8Array(4));o.setUint32(0,s.get(),!0);const l=tn(a,1);Object.assign(e,{version:$t(a,0),[t]:yt(e.data.subarray(5)),valid:!r.bitFlag.languageEncodingFlag&&l==tn(o,0)}),e.valid&&(i[t]=e[t],i[t+"UTF8"]=!0)}function Qt(e,t,n){return t[n]===S?e.options[n]:t[n]}function Xt(e){const t=(4294901760&e)>>16,n=65535&e;try{return new Date(1980+((65024&t)>>9),((480&t)>>5)-1,31&t,(63488&n)>>11,(2016&n)>>5,2*(31&n),0)}catch(e){}}function Yt(e){return new Date(Number(e/BigInt(1e4)-BigInt(116444736e5)))}function $t(e,t){return e.getUint8(t)}function en(e,t){return e.getUint16(t,!0)}function tn(e,t){return e.getUint32(t,!0)}function nn(e,t){return Number(e.getBigUint64(t,!0))}function rn(e){return new DataView(e.buffer)}F({Inflate:function(e){const t=new m,i=e&&e.chunkSize?Math.floor(2*e.chunkSize):131072,r=new Uint8Array(i);let a=!1;t.inflateInit(),t.next_out=r,this.append=function(e,s){const o=[];let l,c,u=0,d=0,f=0;if(0!==e.length){t.next_in_index=0,t.next_in=e,t.avail_in=e.length;do{if(t.next_out_index=0,t.avail_out=i,0!==t.avail_in||a||(t.next_in_index=0,a=!0),l=t.inflate(0),a&&l===n){if(0!==t.avail_in)throw new Error("inflating: bad input")}else if(0!==l&&1!==l)throw new Error("inflating: "+t.msg);if((a||1===l)&&t.avail_in===e.length)throw new Error("inflating: bad input");t.next_out_index&&(t.next_out_index===i?o.push(new Uint8Array(r)):o.push(r.subarray(0,t.next_out_index))),f+=t.next_out_index,s&&t.next_in_index>0&&t.next_in_index!=u&&(s(t.next_in_index),u=t.next_in_index)}while(t.avail_in>0||0===t.avail_out);return o.length>1?(c=new Uint8Array(f),o.forEach((function(e){c.set(e,d),d+=e.length}))):c=o[0]?new Uint8Array(o[0]):new Uint8Array,c}},this.flush=function(){t.inflateEnd()}}});export{ut as BlobReader,dt as BlobWriter,ft as TextWriter,Ht as ZipReader,F as configure}; diff --git a/booklore-ui/src/assets/foliate/view.js b/booklore-ui/src/assets/foliate/view.js new file mode 100644 index 000000000..a347ba2b1 --- /dev/null +++ b/booklore-ui/src/assets/foliate/view.js @@ -0,0 +1,655 @@ +import * as CFI from './epubcfi.js' +import {SectionProgress, TOCProgress} from './progress.js' +import {Overlayer} from './overlayer.js' +import {textWalker} from './text-walker.js' + +const SEARCH_PREFIX = 'foliate-search:' + +const isZip = async file => { + const arr = new Uint8Array(await file.slice(0, 4).arrayBuffer()) + return arr[0] === 0x50 && arr[1] === 0x4b && arr[2] === 0x03 && arr[3] === 0x04 +} + +const isPDF = async file => { + const arr = new Uint8Array(await file.slice(0, 5).arrayBuffer()) + return arr[0] === 0x25 + && arr[1] === 0x50 && arr[2] === 0x44 && arr[3] === 0x46 + && arr[4] === 0x2d +} + +const isCBZ = ({name, type}) => + type === 'application/vnd.comicbook+zip' || name.endsWith('.cbz') + +const isFB2 = async file => { + const {name, type} = file + console.log('[isFB2] name:', name, 'type:', type) + if (type === 'application/x-fictionbook+xml' || (name && name.endsWith('.fb2'))) { + return true + } + // Check file content for FB2 XML signature + try { + const arr = new Uint8Array(await file.slice(0, 5).arrayBuffer()) + // ' + type === 'application/x-zip-compressed-fb2' + || name.endsWith('.fb2.zip') || name.endsWith('.fbz') + +const makeZipLoader = async file => { + const {configure, ZipReader, BlobReader, TextWriter, BlobWriter} = + await import('./vendor/zip.js') + configure({useWebWorkers: false}) + const reader = new ZipReader(new BlobReader(file)) + const entries = await reader.getEntries() + const map = new Map(entries.map(entry => [entry.filename, entry])) + const load = f => (name, ...args) => + map.has(name) ? f(map.get(name), ...args) : null + const loadText = load(entry => entry.getData(new TextWriter())) + const loadBlob = load((entry, type) => entry.getData(new BlobWriter(type))) + const getSize = name => map.get(name)?.uncompressedSize ?? 0 + return {entries, loadText, loadBlob, getSize} +} + +const getFileEntries = async entry => entry.isFile ? entry + : (await Promise.all(Array.from( + await new Promise((resolve, reject) => entry.createReader() + .readEntries(entries => resolve(entries), error => reject(error))), + getFileEntries))).flat() + +const makeDirectoryLoader = async entry => { + const entries = await getFileEntries(entry) + const files = await Promise.all( + entries.map(entry => new Promise((resolve, reject) => + entry.file(file => resolve([file, entry.fullPath]), + error => reject(error))))) + const map = new Map(files.map(([file, path]) => + [path.replace(entry.fullPath + '/', ''), file])) + const decoder = new TextDecoder() + const decode = x => x ? decoder.decode(x) : null + const getBuffer = name => map.get(name)?.arrayBuffer() ?? null + const loadText = async name => decode(await getBuffer(name)) + const loadBlob = name => map.get(name) + const getSize = name => map.get(name)?.size ?? 0 + return {loadText, loadBlob, getSize} +} + +export class ResponseError extends Error { +} + +export class NotFoundError extends Error { +} + +export class UnsupportedTypeError extends Error { +} + +const fetchFile = async url => { + const res = await fetch(url) + if (!res.ok) throw new ResponseError( + `${res.status} ${res.statusText}`, {cause: res}) + return new File([await res.blob()], new URL(res.url).pathname) +} + +export const makeBook = async file => { + if (typeof file === 'string') file = await fetchFile(file) + let book + if (file.isDirectory) { + const loader = await makeDirectoryLoader(file) + const {EPUB} = await import('./epub.js') + book = await new EPUB(loader).init() + } else if (!file.size) throw new NotFoundError('File not found') + else if (await isZip(file)) { + const loader = await makeZipLoader(file) + if (isCBZ(file)) { + const {makeComicBook} = await import('./comic-book.js') + book = makeComicBook(loader, file) + } else if (isFBZ(file)) { + const {makeFB2} = await import('./fb2.js') + const {entries} = loader + const entry = entries.find(entry => entry.filename.endsWith('.fb2')) + const blob = await loader.loadBlob((entry ?? entries[0]).filename) + book = await makeFB2(blob) + } else { + const {EPUB} = await import('./epub.js') + book = await new EPUB(loader).init() + } + } else if (await isPDF(file)) { + const {makePDF} = await import('./pdf.js') + book = await makePDF(file) + } else { + const {isMOBI, MOBI} = await import('./mobi.js') + if (await isMOBI(file)) { + const fflate = await import('./vendor/fflate.js') + book = await new MOBI({unzlib: fflate.unzlibSync}).open(file) + } else if (isFB2(file)) { + const {makeFB2} = await import('./fb2.js') + book = await makeFB2(file) + } + } + if (!book) throw new UnsupportedTypeError('File type not supported') + return book +} + +class CursorAutohider { + #timeout + #el + #check + #state + + constructor(el, check, state = {}) { + this.#el = el + this.#check = check + this.#state = state + if (this.#state.hidden) this.hide() + this.#el.addEventListener('mousemove', ({screenX, screenY}) => { + // check if it actually moved + if (screenX === this.#state.x && screenY === this.#state.y) return + this.#state.x = screenX, this.#state.y = screenY + this.show() + if (this.#timeout) clearTimeout(this.#timeout) + if (check()) this.#timeout = setTimeout(this.hide.bind(this), 1000) + }, false) + } + + cloneFor(el) { + return new CursorAutohider(el, this.#check, this.#state) + } + + hide() { + this.#el.style.cursor = 'none' + this.#state.hidden = true + } + + show() { + this.#el.style.removeProperty('cursor') + this.#state.hidden = false + } +} + +class History extends EventTarget { + #arr = [] + #index = -1 + + pushState(x) { + const last = this.#arr[this.#index] + if (last === x || last?.fraction && last.fraction === x.fraction) return + this.#arr[++this.#index] = x + this.#arr.length = this.#index + 1 + this.dispatchEvent(new Event('index-change')) + } + + replaceState(x) { + const index = this.#index + this.#arr[index] = x + } + + back() { + const index = this.#index + if (index <= 0) return + const detail = {state: this.#arr[index - 1]} + this.#index = index - 1 + this.dispatchEvent(new CustomEvent('popstate', {detail})) + this.dispatchEvent(new Event('index-change')) + } + + forward() { + const index = this.#index + if (index >= this.#arr.length - 1) return + const detail = {state: this.#arr[index + 1]} + this.#index = index + 1 + this.dispatchEvent(new CustomEvent('popstate', {detail})) + this.dispatchEvent(new Event('index-change')) + } + + get canGoBack() { + return this.#index > 0 + } + + get canGoForward() { + return this.#index < this.#arr.length - 1 + } + + clear() { + this.#arr = [] + this.#index = -1 + } +} + +const languageInfo = lang => { + if (!lang) return {} + try { + const canonical = Intl.getCanonicalLocales(lang)[0] + const locale = new Intl.Locale(canonical) + const isCJK = ['zh', 'ja', 'kr'].includes(locale.language) + const direction = (locale.getTextInfo?.() ?? locale.textInfo)?.direction + return {canonical, locale, isCJK, direction} + } catch (e) { + console.warn(e) + return {} + } +} + +export class View extends HTMLElement { + #root = this.attachShadow({mode: 'closed'}) + #sectionProgress + #tocProgress + #pageProgress + #searchResults = new Map() + #cursorAutohider = new CursorAutohider(this, () => + this.hasAttribute('autohide-cursor')) + isFixedLayout = false + lastLocation + history = new History() + + constructor() { + super() + this.history.addEventListener('popstate', ({detail}) => { + const resolved = this.resolveNavigation(detail.state) + this.renderer.goTo(resolved) + }) + } + + async open(book) { + if (typeof book === 'string' + || typeof book.arrayBuffer === 'function' + || book.isDirectory) book = await makeBook(book) + this.book = book + this.language = languageInfo(book.metadata?.language) + + if (book.splitTOCHref && book.getTOCFragment) { + const ids = book.sections.map(s => s.id) + this.#sectionProgress = new SectionProgress(book.sections, 1500, 1600) + const splitHref = book.splitTOCHref.bind(book) + const getFragment = book.getTOCFragment.bind(book) + this.#tocProgress = new TOCProgress() + await this.#tocProgress.init({ + toc: book.toc ?? [], ids, splitHref, getFragment + }) + this.#pageProgress = new TOCProgress() + await this.#pageProgress.init({ + toc: book.pageList ?? [], ids, splitHref, getFragment + }) + } + + this.isFixedLayout = this.book.rendition?.layout === 'pre-paginated' + if (this.isFixedLayout) { + await import('./fixed-layout.js') + this.renderer = document.createElement('foliate-fxl') + } else { + await import('./paginator.js') + this.renderer = document.createElement('foliate-paginator') + } + this.renderer.setAttribute('exportparts', 'head,foot,filter') + this.renderer.addEventListener('load', e => this.#onLoad(e.detail)) + this.renderer.addEventListener('relocate', e => this.#onRelocate(e.detail)) + this.renderer.addEventListener('create-overlayer', e => + e.detail.attach(this.#createOverlayer(e.detail))) + this.renderer.open(book) + this.#root.append(this.renderer) + + if (book.sections.some(section => section.mediaOverlay)) { + const activeClass = book.media.activeClass + const playbackActiveClass = book.media.playbackActiveClass + this.mediaOverlay = book.getMediaOverlay() + let lastActive + this.mediaOverlay.addEventListener('highlight', e => { + const resolved = this.resolveNavigation(e.detail.text) + this.renderer.goTo(resolved) + .then(() => { + const {doc} = this.renderer.getContents() + .find(x => x.index = resolved.index) + const el = resolved.anchor(doc) + el.classList.add(activeClass) + if (playbackActiveClass) el.ownerDocument + .documentElement.classList.add(playbackActiveClass) + lastActive = new WeakRef(el) + }) + }) + this.mediaOverlay.addEventListener('unhighlight', () => { + const el = lastActive?.deref() + if (el) { + el.classList.remove(activeClass) + if (playbackActiveClass) el.ownerDocument + .documentElement.classList.remove(playbackActiveClass) + } + }) + } + } + + close() { + this.renderer?.destroy() + this.renderer?.remove() + this.#sectionProgress = null + this.#tocProgress = null + this.#pageProgress = null + this.#searchResults = new Map() + this.lastLocation = null + this.history.clear() + this.tts = null + this.mediaOverlay = null + } + + goToTextStart() { + return this.goTo(this.book.landmarks + ?.find(m => m.type.includes('bodymatter') || m.type.includes('text')) + ?.href ?? this.book.sections.findIndex(s => s.linear !== 'no')) + } + + async init({lastLocation, showTextStart}) { + const resolved = lastLocation ? this.resolveNavigation(lastLocation) : null + if (resolved) { + await this.renderer.goTo(resolved) + this.history.pushState(lastLocation) + } else if (showTextStart) await this.goToTextStart() + else { + this.history.pushState(0) + await this.next() + } + } + + #emit(name, detail, cancelable) { + return this.dispatchEvent(new CustomEvent(name, {detail, cancelable})) + } + + #onRelocate({reason, range, index, fraction, size}) { + const progress = this.#sectionProgress?.getProgress(index, fraction, size) ?? {} + const tocItem = this.#tocProgress?.getProgress(index, range) + const pageItem = this.#pageProgress?.getProgress(index, range) + const cfi = this.getCFI(index, range) + this.lastLocation = {...progress, tocItem, pageItem, cfi, range} + if (reason === 'snap' || reason === 'page' || reason === 'scroll') + this.history.replaceState(cfi) + this.#emit('relocate', this.lastLocation) + } + + #onLoad({doc, index}) { + // set language and dir if not already set + doc.documentElement.lang ||= this.language.canonical ?? '' + if (!this.language.isCJK) + doc.documentElement.dir ||= this.language.direction ?? '' + + this.#handleLinks(doc, index) + this.#cursorAutohider.cloneFor(doc.documentElement) + + this.#emit('load', {doc, index}) + } + + #handleLinks(doc, index) { + const {book} = this + const section = book.sections[index] + doc.addEventListener('click', e => { + const a = e.target.closest('a[href]') + if (!a) return + e.preventDefault() + const href_ = a.getAttribute('href') + const href = section?.resolveHref?.(href_) ?? href_ + if (book?.isExternal?.(href)) + Promise.resolve(this.#emit('external-link', {a, href}, true)) + .then(x => x ? globalThis.open(href, '_blank') : null) + .catch(e => console.error(e)) + else Promise.resolve(this.#emit('link', {a, href}, true)) + .then(x => x ? this.goTo(href) : null) + .catch(e => console.error(e)) + }) + } + + async addAnnotation(annotation, remove) { + const {value} = annotation + if (value.startsWith(SEARCH_PREFIX)) { + const cfi = value.replace(SEARCH_PREFIX, '') + const {index, anchor} = await this.resolveNavigation(cfi) + const obj = this.#getOverlayer(index) + if (obj) { + const {overlayer, doc} = obj + if (remove) { + overlayer.remove(value) + return + } + const range = doc ? anchor(doc) : anchor + overlayer.add(value, range, Overlayer.outline) + } + return + } + const {index, anchor} = await this.resolveNavigation(value) + const obj = this.#getOverlayer(index) + if (obj) { + const {overlayer, doc} = obj + overlayer.remove(value) + if (!remove) { + const range = doc ? anchor(doc) : anchor + const draw = (func, opts) => overlayer.add(value, range, func, opts) + this.#emit('draw-annotation', {draw, annotation, doc, range}) + } + } + const label = this.#tocProgress.getProgress(index)?.label ?? '' + return {index, label} + } + + deleteAnnotation(annotation) { + return this.addAnnotation(annotation, true) + } + + #getOverlayer(index) { + return this.renderer.getContents() + .find(x => x.index === index && x.overlayer) + } + + #createOverlayer({doc, index}) { + const overlayer = new Overlayer() + doc.addEventListener('click', e => { + const [value, range] = overlayer.hitTest(e) + if (value && !value.startsWith(SEARCH_PREFIX)) { + this.#emit('show-annotation', {value, index, range}) + } + }, false) + + const list = this.#searchResults.get(index) + if (list) for (const item of list) this.addAnnotation(item) + + this.#emit('create-overlay', {index}) + return overlayer + } + + async showAnnotation(annotation) { + const {value} = annotation + const resolved = await this.goTo(value) + if (resolved) { + const {index, anchor} = resolved + const {doc} = this.#getOverlayer(index) + const range = anchor(doc) + this.#emit('show-annotation', {value, index, range}) + } + } + + getCFI(index, range) { + const baseCFI = this.book.sections[index].cfi ?? CFI.fake.fromIndex(index) + if (!range) return baseCFI + return CFI.joinIndir(baseCFI, CFI.fromRange(range)) + } + + resolveCFI(cfi) { + if (this.book.resolveCFI) + return this.book.resolveCFI(cfi) + else { + const parts = CFI.parse(cfi) + const index = CFI.fake.toIndex((parts.parent ?? parts).shift()) + const anchor = doc => CFI.toRange(doc, parts) + return {index, anchor} + } + } + + resolveNavigation(target) { + try { + if (typeof target === 'number') return {index: target} + if (typeof target.fraction === 'number') { + const [index, anchor] = this.#sectionProgress.getSection(target.fraction) + return {index, anchor} + } + if (CFI.isCFI.test(target)) return this.resolveCFI(target) + return this.book.resolveHref(target) + } catch (e) { + console.error(e) + console.error(`Could not resolve target ${target}`) + } + } + + async goTo(target) { + const resolved = this.resolveNavigation(target) + try { + await this.renderer.goTo(resolved) + this.history.pushState(target) + return resolved + } catch (e) { + console.error(e) + console.error(`Could not go to ${target}`) + } + } + + async goToFraction(frac) { + const [index, anchor] = this.#sectionProgress.getSection(frac) + await this.renderer.goTo({index, anchor}) + this.history.pushState({fraction: frac}) + } + + async select(target) { + try { + const obj = await this.resolveNavigation(target) + await this.renderer.goTo({...obj, select: true}) + this.history.pushState(target) + } catch (e) { + console.error(e) + console.error(`Could not go to ${target}`) + } + } + + deselect() { + for (const {doc} of this.renderer.getContents()) + doc.defaultView.getSelection().removeAllRanges() + } + + getSectionFractions() { + return (this.#sectionProgress?.sectionFractions ?? []) + .map(x => x + Number.EPSILON) + } + + getProgressOf(index, range) { + const tocItem = this.#tocProgress?.getProgress(index, range) + const pageItem = this.#pageProgress?.getProgress(index, range) + return {tocItem, pageItem} + } + + async getTOCItemOf(target) { + try { + const {index, anchor} = await this.resolveNavigation(target) + const doc = await this.book.sections[index].createDocument() + const frag = anchor(doc) + const isRange = frag instanceof Range + const range = isRange ? frag : doc.createRange() + if (!isRange) range.selectNodeContents(frag) + return this.#tocProgress.getProgress(index, range) + } catch (e) { + console.error(e) + console.error(`Could not get ${target}`) + } + } + + async prev(distance) { + await this.renderer.prev(distance) + } + + async next(distance) { + await this.renderer.next(distance) + } + + goLeft() { + return this.book.dir === 'rtl' ? this.next() : this.prev() + } + + goRight() { + return this.book.dir === 'rtl' ? this.prev() : this.next() + } + + async* #searchSection(matcher, query, index) { + const doc = await this.book.sections[index].createDocument() + for (const {range, excerpt} of matcher(doc, query)) + yield {cfi: this.getCFI(index, range), excerpt} + } + + async* #searchBook(matcher, query) { + const {sections} = this.book + for (const [index, {createDocument}] of sections.entries()) { + if (!createDocument) continue + const doc = await createDocument() + const subitems = Array.from(matcher(doc, query), ({range, excerpt}) => + ({cfi: this.getCFI(index, range), excerpt})) + const progress = (index + 1) / sections.length + yield {progress} + if (subitems.length) yield {index, subitems} + } + } + + async* search(opts) { + this.clearSearch() + const {searchMatcher} = await import('./search.js') + const {query, index} = opts + const matcher = searchMatcher(textWalker, + {defaultLocale: this.language, ...opts}) + const iter = index != null + ? this.#searchSection(matcher, query, index) + : this.#searchBook(matcher, query) + + const list = [] + this.#searchResults.set(index, list) + + for await (const result of iter) { + if (result.subitems) { + const list = result.subitems + .map(({cfi}) => ({value: SEARCH_PREFIX + cfi})) + this.#searchResults.set(result.index, list) + for (const item of list) this.addAnnotation(item) + yield { + label: this.#tocProgress.getProgress(result.index)?.label ?? '', + subitems: result.subitems, + } + } else { + if (result.cfi) { + const item = {value: SEARCH_PREFIX + result.cfi} + list.push(item) + this.addAnnotation(item) + } + yield result + } + } + yield 'done' + } + + clearSearch() { + for (const list of this.#searchResults.values()) + for (const item of list) this.deleteAnnotation(item) + this.#searchResults.clear() + } + + async initTTS(granularity = 'word', highlight) { + const doc = this.renderer.getContents()[0].doc + if (this.tts && this.tts.doc === doc) return + const {TTS} = await import('./tts.js') + this.tts = new TTS(doc, textWalker, highlight || (range => + this.renderer.scrollToAnchor(range, true)), granularity) + } + + startMediaOverlay() { + const {index} = this.renderer.getContents()[0] + return this.mediaOverlay.start(index) + } +} + +customElements.define('foliate-view', View)