diff --git a/booklore-api/src/main/java/org/booklore/convertor/KoboSpanMapJsonConverter.java b/booklore-api/src/main/java/org/booklore/convertor/KoboSpanMapJsonConverter.java new file mode 100644 index 000000000..aae5b25d6 --- /dev/null +++ b/booklore-api/src/main/java/org/booklore/convertor/KoboSpanMapJsonConverter.java @@ -0,0 +1,37 @@ +package org.booklore.convertor; + +import jakarta.persistence.AttributeConverter; +import jakarta.persistence.Converter; +import org.booklore.model.dto.kobo.KoboSpanPositionMap; +import tools.jackson.core.JacksonException; +import tools.jackson.databind.ObjectMapper; + +@Converter +public class KoboSpanMapJsonConverter implements AttributeConverter { + + private static final ObjectMapper objectMapper = new ObjectMapper(); + + @Override + public String convertToDatabaseColumn(KoboSpanPositionMap attribute) { + if (attribute == null) { + return null; + } + try { + return objectMapper.writeValueAsString(attribute); + } catch (JacksonException e) { + throw new IllegalStateException("Failed to serialize Kobo span map to JSON", e); + } + } + + @Override + public KoboSpanPositionMap convertToEntityAttribute(String dbData) { + if (dbData == null || dbData.isEmpty()) { + return null; + } + try { + return objectMapper.readValue(dbData, KoboSpanPositionMap.class); + } catch (JacksonException e) { + throw new IllegalStateException("Failed to deserialize Kobo span map from JSON", e); + } + } +} diff --git a/booklore-api/src/main/java/org/booklore/model/dto/kobo/KoboSpanPositionMap.java b/booklore-api/src/main/java/org/booklore/model/dto/kobo/KoboSpanPositionMap.java new file mode 100644 index 000000000..3b2fbc23f --- /dev/null +++ b/booklore-api/src/main/java/org/booklore/model/dto/kobo/KoboSpanPositionMap.java @@ -0,0 +1,25 @@ +package org.booklore.model.dto.kobo; + +import java.util.List; + +public record KoboSpanPositionMap(List chapters) { + + public KoboSpanPositionMap { + chapters = chapters == null ? List.of() : List.copyOf(chapters); + } + + public record Chapter(String sourceHref, + String normalizedHref, + int spineIndex, + float globalStartProgress, + float globalEndProgress, + List spans) { + + public Chapter { + spans = spans == null ? List.of() : List.copyOf(spans); + } + } + + public record Span(String id, float progression) { + } +} diff --git a/booklore-api/src/main/java/org/booklore/model/dto/progress/EpubProgress.java b/booklore-api/src/main/java/org/booklore/model/dto/progress/EpubProgress.java index 58fa3136c..60f9c4ee7 100644 --- a/booklore-api/src/main/java/org/booklore/model/dto/progress/EpubProgress.java +++ b/booklore-api/src/main/java/org/booklore/model/dto/progress/EpubProgress.java @@ -11,9 +11,9 @@ @AllArgsConstructor @NoArgsConstructor public class EpubProgress { - @NotNull String cfi; String href; + Float contentSourceProgressPercent; @NotNull Float percentage; String ttsPositionCfi; diff --git a/booklore-api/src/main/java/org/booklore/model/dto/request/BookFileProgress.java b/booklore-api/src/main/java/org/booklore/model/dto/request/BookFileProgress.java index ae5bebd45..dfeef30cb 100644 --- a/booklore-api/src/main/java/org/booklore/model/dto/request/BookFileProgress.java +++ b/booklore-api/src/main/java/org/booklore/model/dto/request/BookFileProgress.java @@ -7,5 +7,6 @@ public record BookFileProgress( String positionData, String positionHref, @NotNull Float progressPercent, - String ttsPositionCfi) { + String ttsPositionCfi, + Float contentSourceProgressPercent) { } diff --git a/booklore-api/src/main/java/org/booklore/model/dto/settings/KoboSettings.java b/booklore-api/src/main/java/org/booklore/model/dto/settings/KoboSettings.java index c2a267214..bd703b921 100644 --- a/booklore-api/src/main/java/org/booklore/model/dto/settings/KoboSettings.java +++ b/booklore-api/src/main/java/org/booklore/model/dto/settings/KoboSettings.java @@ -13,7 +13,7 @@ @NoArgsConstructor public class KoboSettings { @Builder.Default @JsonSetter(nulls = Nulls.SKIP) - private boolean convertToKepub = false; + private boolean convertToKepub = true; @Builder.Default @JsonSetter(nulls = Nulls.SKIP) private int conversionLimitInMb = 100; @Builder.Default @JsonSetter(nulls = Nulls.SKIP) diff --git a/booklore-api/src/main/java/org/booklore/model/entity/KoboSpanMapEntity.java b/booklore-api/src/main/java/org/booklore/model/entity/KoboSpanMapEntity.java new file mode 100644 index 000000000..80e2360ec --- /dev/null +++ b/booklore-api/src/main/java/org/booklore/model/entity/KoboSpanMapEntity.java @@ -0,0 +1,37 @@ +package org.booklore.model.entity; + +import jakarta.persistence.*; +import lombok.*; +import org.booklore.convertor.KoboSpanMapJsonConverter; +import org.booklore.model.dto.kobo.KoboSpanPositionMap; + +import java.time.Instant; + +@Entity +@Table(name = "kobo_span_map", + uniqueConstraints = @UniqueConstraint(name = "uk_kobo_span_map_book_file", columnNames = "book_file_id")) +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class KoboSpanMapEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @OneToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "book_file_id", nullable = false) + private BookFileEntity bookFile; + + @Column(name = "file_hash", nullable = false, length = 128) + private String fileHash; + + @Convert(converter = KoboSpanMapJsonConverter.class) + @Column(name = "span_map_json", nullable = false, columnDefinition = "LONGTEXT") + private KoboSpanPositionMap spanMap; + + @Column(name = "created_at", nullable = false) + private Instant createdAt; +} diff --git a/booklore-api/src/main/java/org/booklore/model/entity/UserBookFileProgressEntity.java b/booklore-api/src/main/java/org/booklore/model/entity/UserBookFileProgressEntity.java index 53134bd94..07b3ab0ef 100644 --- a/booklore-api/src/main/java/org/booklore/model/entity/UserBookFileProgressEntity.java +++ b/booklore-api/src/main/java/org/booklore/model/entity/UserBookFileProgressEntity.java @@ -36,6 +36,9 @@ public class UserBookFileProgressEntity { @Column(name = "progress_percent") private Float progressPercent; + @Column(name = "content_source_progress_percent") + private Float contentSourceProgressPercent; + @Column(name = "tts_position_cfi", length = 1000) private String ttsPositionCfi; diff --git a/booklore-api/src/main/java/org/booklore/repository/KoboSpanMapRepository.java b/booklore-api/src/main/java/org/booklore/repository/KoboSpanMapRepository.java new file mode 100644 index 000000000..9e2c2e3f0 --- /dev/null +++ b/booklore-api/src/main/java/org/booklore/repository/KoboSpanMapRepository.java @@ -0,0 +1,21 @@ +package org.booklore.repository; + +import org.booklore.model.entity.KoboSpanMapEntity; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.util.Collection; +import java.util.List; +import java.util.Optional; + +@Repository +public interface KoboSpanMapRepository extends JpaRepository { + + @Query("SELECT ksm FROM KoboSpanMapEntity ksm WHERE ksm.bookFile.id = :bookFileId") + Optional findByBookFileId(@Param("bookFileId") Long bookFileId); + + @Query("SELECT ksm FROM KoboSpanMapEntity ksm WHERE ksm.bookFile.id IN :bookFileIds") + List findByBookFileIdIn(@Param("bookFileIds") Collection bookFileIds); +} diff --git a/booklore-api/src/main/java/org/booklore/repository/UserBookFileProgressRepository.java b/booklore-api/src/main/java/org/booklore/repository/UserBookFileProgressRepository.java index ca039bea0..5b42bb3eb 100644 --- a/booklore-api/src/main/java/org/booklore/repository/UserBookFileProgressRepository.java +++ b/booklore-api/src/main/java/org/booklore/repository/UserBookFileProgressRepository.java @@ -63,6 +63,17 @@ List findByUserIdAndBookFileBookIdIn( @Param("bookIds") Iterable bookIds ); + @EntityGraph(attributePaths = {"bookFile", "bookFile.book"}) + @Query(""" + SELECT ubfp FROM UserBookFileProgressEntity ubfp + WHERE ubfp.user.id = :userId + AND ubfp.bookFile.id IN :bookFileIds + """) + List findByUserIdAndBookFileIdIn( + @Param("userId") Long userId, + @Param("bookFileIds") Iterable bookFileIds + ); + @Modifying @Transactional @Query(""" diff --git a/booklore-api/src/main/java/org/booklore/service/appsettings/SettingPersistenceHelper.java b/booklore-api/src/main/java/org/booklore/service/appsettings/SettingPersistenceHelper.java index 071edd1da..25dcdff89 100644 --- a/booklore-api/src/main/java/org/booklore/service/appsettings/SettingPersistenceHelper.java +++ b/booklore-api/src/main/java/org/booklore/service/appsettings/SettingPersistenceHelper.java @@ -298,7 +298,7 @@ public MetadataPublicReviewsSettings getDefaultMetadataPublicReviewsSettings() { public KoboSettings getDefaultKoboSettings() { return KoboSettings.builder() - .convertToKepub(false) + .convertToKepub(true) .conversionLimitInMb(100) .convertCbxToEpub(false) .conversionLimitInMbForCbx(100) diff --git a/booklore-api/src/main/java/org/booklore/service/book/BookDownloadService.java b/booklore-api/src/main/java/org/booklore/service/book/BookDownloadService.java index 1933c72dd..438e282c0 100644 --- a/booklore-api/src/main/java/org/booklore/service/book/BookDownloadService.java +++ b/booklore-api/src/main/java/org/booklore/service/book/BookDownloadService.java @@ -13,6 +13,7 @@ import org.booklore.service.appsettings.AppSettingService; import org.booklore.service.kobo.CbxConversionService; import org.booklore.service.kobo.KepubConversionService; +import org.booklore.service.kobo.KoboSpanMapService; import org.booklore.util.FileUtils; import org.springframework.core.io.FileSystemResource; import org.springframework.core.io.Resource; @@ -48,6 +49,7 @@ public class BookDownloadService { private final BookFileRepository bookFileRepository; private final KepubConversionService kepubConversionService; private final CbxConversionService cbxConversionService; + private final KoboSpanMapService koboSpanMapService; private final AppSettingService appSettingService; public ResponseEntity downloadBook(Long bookId) { @@ -252,7 +254,7 @@ public void downloadKoboBook(Long bookId, HttpServletResponse response) { throw ApiError.GENERIC_BAD_REQUEST.createException("Kobo settings not found."); } - boolean convertEpubToKepub = isEpub && koboSettings.isConvertToKepub() && primaryFile.getFileSizeKb() <= (long) koboSettings.getConversionLimitInMb() * 1024; + boolean convertEpubToKepub = isEpub && !primaryFile.isFixedLayout() && koboSettings.isConvertToKepub() && primaryFile.getFileSizeKb() <= (long) koboSettings.getConversionLimitInMb() * 1024; boolean convertCbxToEpub = isCbx && koboSettings.isConvertCbxToEpub() && primaryFile.getFileSizeKb() <= (long) koboSettings.getConversionLimitInMbForCbx() * 1024; int compressionPercentage = koboSettings.getConversionImageCompressionPercentage(); @@ -272,6 +274,11 @@ public void downloadKoboBook(Long bookId, HttpServletResponse response) { if (convertEpubToKepub) { fileToSend = kepubConversionService.convertEpubToKepub(inputFile, tempDir.toFile(), koboSettings.isForceEnableHyphenation()); + try { + koboSpanMapService.computeAndStoreIfNeeded(primaryFile, fileToSend); + } catch (Exception e) { + log.warn("Failed to compute Kobo span map for file {}: {}", primaryFile.getId(), e.getMessage()); + } } setResponseHeaders(response, fileToSend); diff --git a/booklore-api/src/main/java/org/booklore/service/kobo/KoboBookmarkLocationResolver.java b/booklore-api/src/main/java/org/booklore/service/kobo/KoboBookmarkLocationResolver.java new file mode 100644 index 000000000..ad41842f6 --- /dev/null +++ b/booklore-api/src/main/java/org/booklore/service/kobo/KoboBookmarkLocationResolver.java @@ -0,0 +1,245 @@ +package org.booklore.service.kobo; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.booklore.model.dto.kobo.KoboSpanPositionMap; +import org.booklore.model.entity.BookFileEntity; +import org.booklore.model.entity.UserBookFileProgressEntity; +import org.booklore.model.entity.UserBookProgressEntity; +import org.booklore.model.enums.BookFileType; +import org.booklore.util.koreader.EpubCfiService; +import org.springframework.stereotype.Service; + +import java.nio.file.Path; +import java.util.Comparator; +import java.util.Map; +import java.util.Optional; + +import static org.booklore.service.kobo.KoboEpubUtils.normalizeHref; + +@Slf4j +@Service +@RequiredArgsConstructor +public class KoboBookmarkLocationResolver { + + private final KoboSpanMapService koboSpanMapService; + private final EpubCfiService epubCfiService; + + public Optional resolve(UserBookProgressEntity progress, + UserBookFileProgressEntity fileProgress) { + return resolve(progress, fileProgress, null); + } + + public Optional resolve(UserBookProgressEntity progress, + UserBookFileProgressEntity fileProgress, + Map preloadedMaps) { + BookFileEntity bookFile = resolveBookFile(progress, fileProgress); + if (!isKepubExportEnabled(bookFile)) { + return Optional.empty(); + } + Optional spanMap = lookupSpanMap(bookFile, preloadedMaps); + if (spanMap.isEmpty() || spanMap.get().chapters().isEmpty()) { + return Optional.empty(); + } + + Optional cfiLocation = resolveCfiLocation(bookFile, progress, fileProgress); + String href = cfiLocation + .map(EpubCfiService.CfiLocation::href) + .filter(value -> !value.isBlank()) + .orElseGet(() -> resolveHref(progress, fileProgress)); + Float chapterProgressPercent = Optional.ofNullable(fileProgress) + .map(UserBookFileProgressEntity::getContentSourceProgressPercent) + .orElseGet(() -> cfiLocation + .map(EpubCfiService.CfiLocation::contentSourceProgressPercent) + .orElse(null)); + Float globalProgressPercent = resolveGlobalProgressPercent(progress, fileProgress); + + Optional chapter = resolveChapter(spanMap.get(), href, globalProgressPercent); + if (chapter.isEmpty()) { + return Optional.empty(); + } + + Float resolvedChapterProgressPercent = resolveChapterProgressPercent(chapter.get(), chapterProgressPercent, + globalProgressPercent); + KoboSpanPositionMap.Span span = resolveSpanMarker(chapter.get(), resolvedChapterProgressPercent); + if (span == null) { + return Optional.empty(); + } + + return Optional.of(new ResolvedBookmarkLocation( + span.id(), + "KoboSpan", + chapter.get().sourceHref(), + resolvedChapterProgressPercent)); + } + + private Optional resolveCfiLocation(BookFileEntity bookFile, + UserBookProgressEntity progress, + UserBookFileProgressEntity fileProgress) { + String cfi = resolveCfi(progress, fileProgress); + if (cfi == null || cfi.isBlank() || bookFile == null) { + return Optional.empty(); + } + + try { + Path epubPath = bookFile.getFullFilePath(); + if (epubPath == null) { + return Optional.empty(); + } + return epubCfiService.resolveCfiLocation(epubPath, cfi); + } catch (Exception e) { + log.debug("Failed to derive chapter position from CFI for bookFile {}: {}", bookFile.getId(), e.getMessage()); + return Optional.empty(); + } + } + + private BookFileEntity resolveBookFile(UserBookProgressEntity progress, + UserBookFileProgressEntity fileProgress) { + if (fileProgress != null && fileProgress.getBookFile() != null) { + return fileProgress.getBookFile(); + } + if (progress == null || progress.getBook() == null) { + return null; + } + return progress.getBook().getPrimaryBookFile(); + } + + private Optional lookupSpanMap(BookFileEntity bookFile, + Map preloadedMaps) { + if (preloadedMaps != null && bookFile.getId() != null) { + KoboSpanPositionMap map = preloadedMaps.get(bookFile.getId()); + if (map != null) { + return Optional.of(map); + } + } + return koboSpanMapService.getValidMap(bookFile); + } + + private boolean isKepubExportEnabled(BookFileEntity bookFile) { + if (bookFile == null) { + return false; + } + return bookFile.getBookType() == BookFileType.EPUB && !bookFile.isFixedLayout(); + } + + private String resolveHref(UserBookProgressEntity progress, + UserBookFileProgressEntity fileProgress) { + return Optional.ofNullable(fileProgress) + .map(UserBookFileProgressEntity::getPositionHref) + .filter(value -> !value.isBlank()) + .orElseGet(() -> Optional.ofNullable(progress) + .map(UserBookProgressEntity::getEpubProgressHref) + .filter(value -> !value.isBlank()) + .orElse(null)); + } + + private String resolveCfi(UserBookProgressEntity progress, + UserBookFileProgressEntity fileProgress) { + return Optional.ofNullable(fileProgress) + .map(UserBookFileProgressEntity::getPositionData) + .filter(value -> value != null && value.startsWith("epubcfi(")) + .orElseGet(() -> Optional.ofNullable(progress) + .map(UserBookProgressEntity::getEpubProgress) + .filter(value -> value != null && value.startsWith("epubcfi(")) + .orElse(null)); + } + + private Float resolveGlobalProgressPercent(UserBookProgressEntity progress, + UserBookFileProgressEntity fileProgress) { + Float fileProgressPercent = Optional.ofNullable(fileProgress) + .map(UserBookFileProgressEntity::getProgressPercent) + .orElse(null); + if (fileProgressPercent != null) { + return fileProgressPercent; + } + return Optional.ofNullable(progress) + .map(UserBookProgressEntity::getEpubProgressPercent) + .orElse(null); + } + + private Optional resolveChapter(KoboSpanPositionMap spanMap, + String href, + Float globalProgressPercent) { + Optional byHref = findChapterByHref(spanMap, href); + if (byHref.isPresent()) { + return byHref; + } + if (globalProgressPercent == null) { + return Optional.empty(); + } + return findChapterByGlobalProgress(spanMap, globalProgressPercent); + } + + private Optional findChapterByHref(KoboSpanPositionMap spanMap, String href) { + if (href == null || href.isBlank()) { + return Optional.empty(); + } + String normalizedHref = normalizeHref(href); + return spanMap.chapters().stream() + .filter(chapter -> { + String normalizedChapter = chapter.normalizedHref(); + return normalizedChapter.equals(normalizedHref) + || normalizedChapter.endsWith("/" + normalizedHref); + }) + .max(Comparator.comparingInt(chapter -> { + String normalizedChapter = chapter.normalizedHref(); + return normalizedChapter.equals(normalizedHref) ? 1 : 0; + }).thenComparingInt(chapter -> chapter.normalizedHref().length())); + } + + private Optional findChapterByGlobalProgress(KoboSpanPositionMap spanMap, + Float globalProgressPercent) { + float globalProgress = globalProgressPercent / 100f; + return spanMap.chapters().stream() + .min(Comparator.comparingDouble(chapter -> distanceToChapter(globalProgress, chapter))); + } + + private Float resolveChapterProgressPercent(KoboSpanPositionMap.Chapter chapter, + Float chapterProgressPercent, + Float globalProgressPercent) { + if (chapterProgressPercent != null) { + return chapterProgressPercent; + } + if (globalProgressPercent != null) { + float chapterStart = chapter.globalStartProgress(); + float chapterEnd = chapter.globalEndProgress(); + float chapterWidth = chapterEnd - chapterStart; + if (chapterWidth <= 0f) { + return 0f; + } + float chapterProgress = (globalProgressPercent / 100f - chapterStart) / chapterWidth; + return chapterProgress * 100f; + } + return null; + } + + private KoboSpanPositionMap.Span resolveSpanMarker(KoboSpanPositionMap.Chapter chapter, Float chapterProgressPercent) { + if (chapter.spans().isEmpty()) { + return null; + } + if (chapterProgressPercent == null) { + return chapter.spans().getFirst(); + } + + float targetProgress = chapterProgressPercent / 100f; + return chapter.spans().stream() + .min(Comparator.comparingDouble(item -> Math.abs(item.progression() - targetProgress))) + .orElse(null); + } + + private double distanceToChapter(float globalProgress, KoboSpanPositionMap.Chapter chapter) { + if (globalProgress < chapter.globalStartProgress()) { + return chapter.globalStartProgress() - globalProgress; + } + if (globalProgress > chapter.globalEndProgress()) { + return globalProgress - chapter.globalEndProgress(); + } + return 0d; + } + + public record ResolvedBookmarkLocation(String value, + String type, + String source, + Float contentSourceProgressPercent) { + } +} diff --git a/booklore-api/src/main/java/org/booklore/service/kobo/KoboEntitlementService.java b/booklore-api/src/main/java/org/booklore/service/kobo/KoboEntitlementService.java index a3c53bf65..16afd181d 100644 --- a/booklore-api/src/main/java/org/booklore/service/kobo/KoboEntitlementService.java +++ b/booklore-api/src/main/java/org/booklore/service/kobo/KoboEntitlementService.java @@ -14,6 +14,7 @@ import org.booklore.repository.KoboReadingStateRepository; import org.booklore.repository.MagicShelfRepository; import org.booklore.repository.ShelfRepository; +import org.booklore.repository.UserBookFileProgressRepository; import org.booklore.repository.UserBookProgressRepository; import org.booklore.service.appsettings.AppSettingService; import org.booklore.service.book.BookQueryService; @@ -43,6 +44,7 @@ public class KoboEntitlementService { private final AppSettingService appSettingService; private final KoboCompatibilityService koboCompatibilityService; private final UserBookProgressRepository progressRepository; + private final UserBookFileProgressRepository fileProgressRepository; private final KoboReadingStateRepository readingStateRepository; private final KoboReadingStateMapper readingStateMapper; private final AuthenticationService authenticationService; @@ -51,6 +53,7 @@ public class KoboEntitlementService { private final MagicShelfRepository magicShelfRepository; private final MagicShelfBookService magicShelfBookService; private final KoboSettingsService koboSettingsService; + private final KoboSpanMapService koboSpanMapService; public List generateNewEntitlements(Set bookIds, String token) { List books = bookQueryService.findAllWithMetadataByIds(bookIds); @@ -108,9 +111,40 @@ public List generateChangedEntitlements(Set bookIds public List generateChangedReadingStates(List progressEntries) { OffsetDateTime now = getCurrentUtc(); String timestamp = now.toString(); + Long userId = authenticationService.getAuthenticatedUser().getId(); + + Map syncedEpubFileIdsByBookId = new HashMap<>(); + Map bookFilesByFileId = new HashMap<>(); + for (UserBookProgressEntity entry : progressEntries) { + BookEntity book = entry.getBook(); + if (book != null) { + BookFileEntity syncedFile = KoboEpubUtils.getSyncedEpubFile(book); + if (syncedFile != null) { + syncedEpubFileIdsByBookId.putIfAbsent(book.getId(), syncedFile.getId()); + bookFilesByFileId.putIfAbsent(syncedFile.getId(), syncedFile); + } + } + } + + Map fileProgressByFileId = syncedEpubFileIdsByBookId.isEmpty() + ? Collections.emptyMap() + : fileProgressRepository.findByUserIdAndBookFileIdIn(userId, syncedEpubFileIdsByBookId.values()).stream() + .collect(Collectors.toMap( + fp -> fp.getBookFile().getId(), + fp -> fp + )); + + Map spanMapsByFileId = bookFilesByFileId.isEmpty() + ? Collections.emptyMap() + : koboSpanMapService.getValidMaps(bookFilesByFileId); return progressEntries.stream() - .map(progress -> buildChangedReadingState(progress, timestamp, now)) + .map(progress -> buildChangedReadingState( + progress, + fileProgressByFileId.get(syncedEpubFileIdsByBookId.get(progress.getBook().getId())), + timestamp, + now, + spanMapsByFileId)) .toList(); } @@ -180,12 +214,16 @@ private KoboTagWrapper buildKoboTag(String id, String name, String created, Stri .build(); } - private ChangedReadingState buildChangedReadingState(UserBookProgressEntity progress, String timestamp, OffsetDateTime now) { + private ChangedReadingState buildChangedReadingState(UserBookProgressEntity progress, + UserBookFileProgressEntity fileProgress, + String timestamp, + OffsetDateTime now, + Map preloadedMaps) { String entitlementId = String.valueOf(progress.getBook().getId()); boolean twoWaySync = koboSettingsService.getCurrentUserSettings().isTwoWayProgressSync(); KoboReadingState.CurrentBookmark bookmark = (progress.getKoboProgressPercent() != null || (twoWaySync && progress.getEpubProgressPercent() != null)) - ? readingStateBuilder.buildBookmarkFromProgress(progress, now) + ? readingStateBuilder.buildBookmarkFromProgress(progress, fileProgress, now, preloadedMaps) : readingStateBuilder.buildEmptyBookmark(now); KoboReadingState readingState = KoboReadingState.builder() @@ -218,14 +256,15 @@ private KoboReadingState getReadingStateForBook(BookEntity book) { .orElse(null); Optional userProgress = progressRepository - .findByUserIdAndBookId(authenticationService.getAuthenticatedUser().getId(), book.getId()); + .findByUserIdAndBookId(userId, book.getId()); + UserBookFileProgressEntity fileProgress = findSyncedEpubFileProgress(userId, book).orElse(null); boolean twoWaySync = koboSettingsService.getCurrentUserSettings().isTwoWayProgressSync(); KoboReadingState.CurrentBookmark webReaderBookmark = twoWaySync ? userProgress - .filter(progress -> progress.getEpubProgress() != null && progress.getEpubProgressPercent() != null) - .map(progress -> readingStateBuilder.buildBookmarkFromProgress(progress, now)) + .filter(readingStateBuilder::shouldUseWebReaderProgress) + .map(progress -> readingStateBuilder.buildBookmarkFromProgress(progress, fileProgress, now)) .orElse(null) : null; @@ -235,7 +274,7 @@ private KoboReadingState getReadingStateForBook(BookEntity book) { ? existingState.getCurrentBookmark() : userProgress .filter(progress -> progress.getKoboProgressPercent() != null || (twoWaySync && progress.getEpubProgressPercent() != null)) - .map(progress -> readingStateBuilder.buildBookmarkFromProgress(progress, now)) + .map(progress -> readingStateBuilder.buildBookmarkFromProgress(progress, fileProgress, now)) .orElseGet(() -> readingStateBuilder.buildEmptyBookmark(now)); KoboReadingState.StatusInfo statusInfo = userProgress @@ -257,6 +296,14 @@ private KoboReadingState getReadingStateForBook(BookEntity book) { .build(); } + private Optional findSyncedEpubFileProgress(Long userId, BookEntity book) { + BookFileEntity syncedFile = KoboEpubUtils.getSyncedEpubFile(book); + if (syncedFile == null) { + return Optional.empty(); + } + return fileProgressRepository.findByUserIdAndBookFileId(userId, syncedFile.getId()); + } + private BookEntitlement buildBookEntitlement(BookEntity book, boolean removed) { OffsetDateTime now = getCurrentUtc(); OffsetDateTime createdOn = getCreatedOn(book); @@ -330,14 +377,12 @@ private KoboBookMetadata mapToKoboMetadata(BookEntity book, String token) { boolean isEpubFile = primaryFile.getBookType() == BookFileType.EPUB; boolean isCbxFile = primaryFile.getBookType() == BookFileType.CBX; - if (koboSettings != null) { - if (isEpubFile && primaryFile.isFixedLayout()) { - bookFormat = KoboBookFormat.EPUB3FL; - } else if (isEpubFile && koboSettings.isConvertToKepub()) { - bookFormat = KoboBookFormat.KEPUB; - } else if (isCbxFile && koboSettings.isConvertCbxToEpub()) { - bookFormat = KoboBookFormat.EPUB3; - } + if (isEpubFile && primaryFile.isFixedLayout()) { + bookFormat = KoboBookFormat.EPUB3FL; + } else if (isEpubFile && koboSettings != null && koboSettings.isConvertToKepub()) { + bookFormat = KoboBookFormat.KEPUB; + } else if (koboSettings != null && isCbxFile && koboSettings.isConvertCbxToEpub()) { + bookFormat = KoboBookFormat.EPUB3; } return KoboBookMetadata.builder() diff --git a/booklore-api/src/main/java/org/booklore/service/kobo/KoboEpubUtils.java b/booklore-api/src/main/java/org/booklore/service/kobo/KoboEpubUtils.java new file mode 100644 index 000000000..7874bc426 --- /dev/null +++ b/booklore-api/src/main/java/org/booklore/service/kobo/KoboEpubUtils.java @@ -0,0 +1,41 @@ +package org.booklore.service.kobo; + +import lombok.experimental.UtilityClass; +import org.booklore.model.entity.BookEntity; +import org.booklore.model.entity.BookFileEntity; +import org.booklore.model.enums.BookFileType; + +import java.net.URI; + +@UtilityClass +class KoboEpubUtils { + + static BookFileEntity getSyncedEpubFile(BookEntity book) { + BookFileEntity primaryFile = book != null ? book.getPrimaryBookFile() : null; + if (primaryFile == null || primaryFile.getBookType() != BookFileType.EPUB) { + return null; + } + return primaryFile; + } + + static String decodeHrefPath(String href) { + if (href == null) { + return null; + } + + String normalizedPath = href.replaceFirst("#.*$", "") + .replace('\\', '/'); + try { + String decodedPath = URI.create(normalizedPath).getPath(); + return decodedPath != null ? decodedPath : normalizedPath; + } catch (IllegalArgumentException e) { + return normalizedPath; + } + } + + static String normalizeHref(String href) { + return decodeHrefPath(href) + .replaceFirst("^/+", ""); + } + +} diff --git a/booklore-api/src/main/java/org/booklore/service/kobo/KoboLibrarySyncService.java b/booklore-api/src/main/java/org/booklore/service/kobo/KoboLibrarySyncService.java index ac028c80a..81b624328 100644 --- a/booklore-api/src/main/java/org/booklore/service/kobo/KoboLibrarySyncService.java +++ b/booklore-api/src/main/java/org/booklore/service/kobo/KoboLibrarySyncService.java @@ -76,7 +76,7 @@ public ResponseEntity syncLibrary(BookLoreUser user, String token) { boolean shouldContinueSync = false; if (prevSnapshot.isPresent()) { - int maxRemaining = 5; + int maxRemaining = 100; List removedAll = new ArrayList<>(); List changedAll = new ArrayList<>(); @@ -116,7 +116,7 @@ public ResponseEntity syncLibrary(BookLoreUser user, String token) { entitlements.addAll(entitlementService.generateTags()); } } else { - int maxRemaining = 5; + int maxRemaining = 100; List snapshotBookEntities = new ArrayList<>(); while (maxRemaining > 0) { Page page = koboLibrarySnapshotService.getUnsyncedBooks(currSnapshot.getId(), PageRequest.of(0, maxRemaining)); @@ -223,7 +223,7 @@ private boolean needsProgressSync(UserBookProgressEntity progress) { } if (koboSettingsService.getCurrentUserSettings().isTwoWayProgressSync() - && progress.getEpubProgress() != null && progress.getEpubProgressPercent() != null) { + && progress.getEpubProgressPercent() != null) { Instant sentTime = progress.getKoboProgressSentTime(); Instant lastReadTime = progress.getLastReadTime(); if (lastReadTime != null && (sentTime == null || lastReadTime.isAfter(sentTime))) { diff --git a/booklore-api/src/main/java/org/booklore/service/kobo/KoboReadingStateBuilder.java b/booklore-api/src/main/java/org/booklore/service/kobo/KoboReadingStateBuilder.java index 5fff677e0..a720e9cda 100644 --- a/booklore-api/src/main/java/org/booklore/service/kobo/KoboReadingStateBuilder.java +++ b/booklore-api/src/main/java/org/booklore/service/kobo/KoboReadingStateBuilder.java @@ -2,21 +2,30 @@ import lombok.RequiredArgsConstructor; import org.booklore.model.dto.kobo.KoboReadingState; +import org.booklore.model.entity.UserBookFileProgressEntity; import org.booklore.model.entity.UserBookProgressEntity; import org.booklore.model.enums.KoboReadStatus; import org.booklore.model.enums.ReadStatus; import org.springframework.stereotype.Component; +import org.booklore.model.dto.kobo.KoboSpanPositionMap; + import java.time.Instant; import java.time.OffsetDateTime; import java.time.ZoneOffset; +import java.time.format.DateTimeFormatter; +import java.util.Map; import java.util.Optional; @Component @RequiredArgsConstructor public class KoboReadingStateBuilder { + private static final DateTimeFormatter KOBO_TIMESTAMP_FORMAT = + DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss.SSSSSSS'Z'").withZone(ZoneOffset.UTC); + private final KoboSettingsService koboSettingsService; + private final KoboBookmarkLocationResolver bookmarkLocationResolver; public KoboReadingState.CurrentBookmark buildEmptyBookmark(OffsetDateTime timestamp) { return KoboReadingState.CurrentBookmark.builder() @@ -24,35 +33,80 @@ public KoboReadingState.CurrentBookmark buildEmptyBookmark(OffsetDateTime timest .build(); } - public KoboReadingState.CurrentBookmark buildBookmarkFromProgress(UserBookProgressEntity progress) { - return buildBookmarkFromProgress(progress, null); + public KoboReadingState.CurrentBookmark buildBookmarkFromProgress(UserBookProgressEntity progress, + UserBookFileProgressEntity fileProgress) { + return buildBookmarkFromProgress(progress, fileProgress, null, null); + } + + public KoboReadingState.CurrentBookmark buildBookmarkFromProgress(UserBookProgressEntity progress, + UserBookFileProgressEntity fileProgress, + OffsetDateTime defaultTime) { + return buildBookmarkFromProgress(progress, fileProgress, defaultTime, null); } - public KoboReadingState.CurrentBookmark buildBookmarkFromProgress(UserBookProgressEntity progress, OffsetDateTime defaultTime) { - if (isWebReaderNewer(progress)) { - return buildBookmarkFromWebReaderProgress(progress, defaultTime); + public KoboReadingState.CurrentBookmark buildBookmarkFromProgress(UserBookProgressEntity progress, + UserBookFileProgressEntity fileProgress, + OffsetDateTime defaultTime, + Map preloadedMaps) { + if (shouldUseWebReaderProgress(progress)) { + return buildBookmarkFromWebReaderProgress(progress, fileProgress, defaultTime, preloadedMaps); } - return buildBookmarkFromKoboProgress(progress, defaultTime); + return buildBookmarkFromKoboProgress(progress, fileProgress, defaultTime); } - private boolean isWebReaderNewer(UserBookProgressEntity progress) { - return koboSettingsService.getCurrentUserSettings().isTwoWayProgressSync() - && progress.getEpubProgress() != null && progress.getEpubProgressPercent() != null; + public boolean shouldUseWebReaderProgress(UserBookProgressEntity progress) { + if (!koboSettingsService.getCurrentUserSettings().isTwoWayProgressSync()) { + return false; + } + if (progress.getEpubProgressPercent() == null) { + return false; + } + if (progress.getLastReadTime() == null) { + return false; + } + if (progress.getKoboProgressReceivedTime() == null) { + return true; + } + return progress.getLastReadTime().isAfter(progress.getKoboProgressReceivedTime()); } - private KoboReadingState.CurrentBookmark buildBookmarkFromWebReaderProgress(UserBookProgressEntity progress, OffsetDateTime defaultTime) { + private KoboReadingState.CurrentBookmark buildBookmarkFromWebReaderProgress(UserBookProgressEntity progress, + UserBookFileProgressEntity fileProgress, + OffsetDateTime defaultTime, + Map preloadedMaps) { String lastModified = Optional.ofNullable(progress.getLastReadTime()) .map(this::formatTimestamp) .or(() -> Optional.ofNullable(defaultTime).map(OffsetDateTime::toString)) .orElse(null); + Optional resolvedLocation = + bookmarkLocationResolver.resolve(progress, fileProgress, preloadedMaps); + + KoboReadingState.CurrentBookmark.Location location = resolvedLocation + .map(resolved -> KoboReadingState.CurrentBookmark.Location.builder() + .value(resolved.value()) + .type(resolved.type()) + .source(resolved.source()) + .build()) + .orElse(null); + return KoboReadingState.CurrentBookmark.builder() .progressPercent(Math.round(progress.getEpubProgressPercent())) + .contentSourceProgressPercent(resolvedLocation + .map(KoboBookmarkLocationResolver.ResolvedBookmarkLocation::contentSourceProgressPercent) + .map(Math::round) + .orElseGet(() -> Optional.ofNullable(fileProgress) + .map(UserBookFileProgressEntity::getContentSourceProgressPercent) + .map(Math::round) + .orElse(null))) + .location(location) .lastModified(lastModified) .build(); } - private KoboReadingState.CurrentBookmark buildBookmarkFromKoboProgress(UserBookProgressEntity progress, OffsetDateTime defaultTime) { + private KoboReadingState.CurrentBookmark buildBookmarkFromKoboProgress(UserBookProgressEntity progress, + UserBookFileProgressEntity fileProgress, + OffsetDateTime defaultTime) { KoboReadingState.CurrentBookmark.Location location = Optional.ofNullable(progress.getKoboLocation()) .map(koboLocation -> KoboReadingState.CurrentBookmark.Location.builder() .value(koboLocation) @@ -70,13 +124,19 @@ private KoboReadingState.CurrentBookmark buildBookmarkFromKoboProgress(UserBookP .progressPercent(Optional.ofNullable(progress.getKoboProgressPercent()) .map(Math::round) .orElse(null)) + .contentSourceProgressPercent(Optional.ofNullable(fileProgress) + .map(UserBookFileProgressEntity::getContentSourceProgressPercent) + .map(Math::round) + .orElse(null)) .location(location) .lastModified(lastModified) .build(); } - public KoboReadingState buildReadingStateFromProgress(String entitlementId, UserBookProgressEntity progress) { - KoboReadingState.CurrentBookmark bookmark = buildBookmarkFromProgress(progress); + public KoboReadingState buildReadingStateFromProgress(String entitlementId, + UserBookProgressEntity progress, + UserBookFileProgressEntity fileProgress) { + KoboReadingState.CurrentBookmark bookmark = buildBookmarkFromProgress(progress, fileProgress); String lastModified = bookmark.getLastModified(); KoboReadingState.StatusInfo statusInfo = buildStatusInfoFromProgress(progress, lastModified); @@ -118,6 +178,6 @@ public KoboReadStatus mapReadStatusToKoboStatus(ReadStatus readStatus) { } private String formatTimestamp(Instant instant) { - return instant.atOffset(ZoneOffset.UTC).toString(); + return KOBO_TIMESTAMP_FORMAT.format(instant); } } diff --git a/booklore-api/src/main/java/org/booklore/service/kobo/KoboReadingStateService.java b/booklore-api/src/main/java/org/booklore/service/kobo/KoboReadingStateService.java index b201f811e..2a5ee0215 100644 --- a/booklore-api/src/main/java/org/booklore/service/kobo/KoboReadingStateService.java +++ b/booklore-api/src/main/java/org/booklore/service/kobo/KoboReadingStateService.java @@ -9,6 +9,7 @@ import org.booklore.model.dto.kobo.KoboReadingState; import org.booklore.model.dto.response.kobo.KoboReadingStateResponse; import org.booklore.model.entity.BookEntity; +import org.booklore.model.entity.BookFileEntity; import org.booklore.model.entity.BookLoreUserEntity; import org.booklore.model.entity.KoboReadingStateEntity; import org.booklore.model.entity.UserBookFileProgressEntity; @@ -117,9 +118,8 @@ private List saveAll(List dtos) { syncKoboProgressToUserBookProgress(savedState, userId); - return savedEntity; + return savedState; }) - .map(mapper::toDto) .collect(Collectors.toList()); } @@ -155,19 +155,11 @@ private void overlayWebReaderBookmark(KoboReadingState state, String entitlement } try { Long bookId = Long.parseLong(entitlementId); + UserBookFileProgressEntity fileProgress = findSyncedEpubFileProgress(userId, bookId).orElse(null); progressRepository.findByUserIdAndBookId(userId, bookId) - .filter(progress -> progress.getEpubProgress() != null && progress.getEpubProgressPercent() != null) - .ifPresent(progress -> { - KoboReadingState.CurrentBookmark existing = state.getCurrentBookmark(); - KoboReadingState.CurrentBookmark webReaderBookmark = readingStateBuilder.buildBookmarkFromProgress(progress); - - if (existing != null) { - existing.setProgressPercent(webReaderBookmark.getProgressPercent()); - existing.setLastModified(webReaderBookmark.getLastModified()); - } else { - state.setCurrentBookmark(webReaderBookmark); - } - }); + .filter(readingStateBuilder::shouldUseWebReaderProgress) + .ifPresent(progress -> state.setCurrentBookmark( + readingStateBuilder.buildBookmarkFromProgress(progress, fileProgress))); } catch (NumberFormatException e) { // Not a valid book ID, skip overlay } @@ -181,7 +173,10 @@ private Optional constructReadingStateFromProgress(String enti boolean twoWaySync = koboSettingsService.getCurrentUserSettings().isTwoWayProgressSync(); return progressRepository.findByUserIdAndBookId(user.getId(), bookId) .filter(progress -> progress.getKoboProgressPercent() != null || progress.getKoboLocation() != null || (twoWaySync && progress.getEpubProgressPercent() != null)) - .map(progress -> readingStateBuilder.buildReadingStateFromProgress(entitlementId, progress)); + .map(progress -> readingStateBuilder.buildReadingStateFromProgress( + entitlementId, + progress, + findSyncedEpubFileProgress(user.getId(), bookId).orElse(null))); } catch (NumberFormatException e) { log.warn("Invalid entitlement ID format when constructing reading state: {}", entitlementId); return Optional.empty(); @@ -213,7 +208,7 @@ private void syncKoboProgressToUserBookProgress(KoboReadingState readingState, L return newProgress; }); - Float prevousKoboProgressPercent = progress.getKoboProgressPercent(); + Float previousKoboProgressPercent = progress.getKoboProgressPercent(); ReadStatus previousReadStatus = progress.getReadStatus(); KoboReadingState.CurrentBookmark bookmark = readingState.getCurrentBookmark(); @@ -231,23 +226,31 @@ private void syncKoboProgressToUserBookProgress(KoboReadingState readingState, L } Instant now = Instant.now(); - progress.setKoboProgressReceivedTime(now); + Instant bookmarkTime = bookmark != null ? parseTimestamp(bookmark.getLastModified()) : null; + Instant effectiveBookmarkTime = bookmarkTime != null ? bookmarkTime : now; + Optional existingFileProgress = findSyncedEpubFileProgress(userId, book); + boolean webReaderIsNewer = isWebReaderNewerThanBookmark(existingFileProgress.orElse(null), bookmarkTime); + if (bookmark != null && (bookmark.getProgressPercent() != null || bookmark.getLocation() != null)) { + progress.setKoboProgressReceivedTime(effectiveBookmarkTime); + } - boolean koboApplied = crossPopulateEpubFieldsFromKobo(progress, bookmark, book, userId, now); + boolean koboApplied = crossPopulateEpubFieldsFromKobo(progress, bookmark, book, + existingFileProgress.orElse(null), bookmarkTime, effectiveBookmarkTime); if (koboApplied) { - progress.setLastReadTime(now); + progress.setLastReadTime(effectiveBookmarkTime); } - if (progress.getKoboProgressPercent() != null) { - updateReadStatusFromKoboProgress(progress, now); + if (progress.getKoboProgressPercent() != null && !webReaderIsNewer) { + updateReadStatusFromKoboProgress(progress, effectiveBookmarkTime); } progressRepository.save(progress); // Sync progress to Hardcover asynchronously (if enabled for this user) // But only if the progress percentage has changed from last time, or the read status has changed - if (progress.getKoboProgressPercent() != null - && (!progress.getKoboProgressPercent().equals(prevousKoboProgressPercent) + if (!webReaderIsNewer + && progress.getKoboProgressPercent() != null + && (!progress.getKoboProgressPercent().equals(previousKoboProgressPercent) || progress.getReadStatus() != previousReadStatus)) { hardcoverSyncService.syncProgressToHardcover(book.getId(), progress.getKoboProgressPercent(), userId); } @@ -257,8 +260,11 @@ private void syncKoboProgressToUserBookProgress(KoboReadingState readingState, L } private boolean crossPopulateEpubFieldsFromKobo(UserBookProgressEntity progress, - KoboReadingState.CurrentBookmark bookmark, - BookEntity book, Long userId, Instant now) { + KoboReadingState.CurrentBookmark bookmark, + BookEntity book, + UserBookFileProgressEntity existingFileProgress, + Instant bookmarkTime, + Instant effectiveBookmarkTime) { if (bookmark == null || bookmark.getProgressPercent() == null) { return false; } @@ -267,40 +273,65 @@ private boolean crossPopulateEpubFieldsFromKobo(UserBookProgressEntity progress, return false; } - UserBookFileProgressEntity fileProgress = book.getPrimaryBookFile() != null - ? fileProgressRepository.findByUserIdAndBookFileId(userId, book.getPrimaryBookFile().getId()).orElse(null) - : null; - - Instant bookmarkTime = parseTimestamp(bookmark.getLastModified()); - boolean webReaderIsNewer = fileProgress != null - && fileProgress.getLastReadTime() != null + boolean webReaderIsNewer = existingFileProgress != null + && existingFileProgress.getLastReadTime() != null && bookmarkTime != null - && fileProgress.getLastReadTime().isAfter(bookmarkTime); + && existingFileProgress.getLastReadTime().isAfter(bookmarkTime); if (webReaderIsNewer) { return false; } + UserBookFileProgressEntity fileProgress = existingFileProgress; + BookFileEntity syncedEpubFile = KoboEpubUtils.getSyncedEpubFile(book); + if (fileProgress == null && syncedEpubFile != null) { + fileProgress = UserBookFileProgressEntity.builder() + .user(progress.getUser()) + .bookFile(syncedEpubFile) + .build(); + } + progress.setEpubProgressPercent(bookmark.getProgressPercent().floatValue()); KoboReadingState.CurrentBookmark.Location location = bookmark.getLocation(); - if (location != null && location.getSource() != null) { - progress.setEpubProgressHref(location.getSource()); - } progress.setEpubProgress(null); + progress.setEpubProgressHref(location != null ? location.getSource() : null); if (fileProgress != null) { fileProgress.setProgressPercent(bookmark.getProgressPercent().floatValue()); - if (location != null && location.getSource() != null) { - fileProgress.setPositionHref(location.getSource()); - } + fileProgress.setContentSourceProgressPercent(bookmark.getContentSourceProgressPercent() != null + ? bookmark.getContentSourceProgressPercent().floatValue() + : null); fileProgress.setPositionData(null); - fileProgress.setLastReadTime(now); + fileProgress.setPositionHref(location != null ? location.getSource() : null); + fileProgress.setLastReadTime(effectiveBookmarkTime); fileProgressRepository.save(fileProgress); } return true; } + private Optional findSyncedEpubFileProgress(Long userId, Long bookId) { + return bookRepository.findById(bookId) + .flatMap(book -> findSyncedEpubFileProgress(userId, book)); + } + + private Optional findSyncedEpubFileProgress(Long userId, BookEntity book) { + BookFileEntity syncedEpubFile = KoboEpubUtils.getSyncedEpubFile(book); + if (syncedEpubFile == null) { + return Optional.empty(); + } + return fileProgressRepository.findByUserIdAndBookFileId(userId, syncedEpubFile.getId()); + } + + private boolean isWebReaderNewerThanBookmark(UserBookFileProgressEntity fileProgress, Instant bookmarkTime) { + if (bookmarkTime == null || !koboSettingsService.getCurrentUserSettings().isTwoWayProgressSync()) { + return false; + } + return fileProgress != null + && fileProgress.getLastReadTime() != null + && fileProgress.getLastReadTime().isAfter(bookmarkTime); + } + private void normalizePutTimestamps(List readingStates) { if (readingStates == null || readingStates.isEmpty()) { return; diff --git a/booklore-api/src/main/java/org/booklore/service/kobo/KoboSpanMapExtractionService.java b/booklore-api/src/main/java/org/booklore/service/kobo/KoboSpanMapExtractionService.java new file mode 100644 index 000000000..f48e19770 --- /dev/null +++ b/booklore-api/src/main/java/org/booklore/service/kobo/KoboSpanMapExtractionService.java @@ -0,0 +1,234 @@ +package org.booklore.service.kobo; + +import lombok.extern.slf4j.Slf4j; +import net.lingala.zip4j.ZipFile; +import net.lingala.zip4j.model.FileHeader; +import org.booklore.model.dto.kobo.KoboSpanPositionMap; +import org.booklore.util.SecureXmlUtils; +import org.jsoup.Jsoup; +import org.jsoup.parser.Parser; +import org.springframework.stereotype.Service; +import org.w3c.dom.Element; +import org.w3c.dom.NodeList; + +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; + +import static org.booklore.service.kobo.KoboEpubUtils.normalizeHref; + +@Service +@Slf4j +public class KoboSpanMapExtractionService { + + private static final String CONTAINER_PATH = "META-INF/container.xml"; + private static final String CONTAINER_NS = "urn:oasis:names:tc:opendocument:xmlns:container"; + private static final String OPF_NS = "http://www.idpf.org/2007/opf"; + + public KoboSpanPositionMap extractFromKepub(File kepubFile) throws IOException { + validateKepub(kepubFile); + + try (ZipFile zipFile = new ZipFile(kepubFile)) { + String opfPath = readOpfPath(zipFile); + org.w3c.dom.Document opfDocument = parseXmlEntry(zipFile, opfPath); + String opfRootPath = opfPath.contains("/") ? opfPath.substring(0, opfPath.lastIndexOf('/') + 1) : ""; + + Map manifestById = parseManifest(opfDocument, opfRootPath); + List spineItems = parseSpine(opfDocument, manifestById); + List extractedChapters = new ArrayList<>(); + + for (int i = 0; i < spineItems.size(); i++) { + ExtractedChapter chapter = extractChapter(zipFile, spineItems.get(i), i); + if (chapter != null) { + extractedChapters.add(chapter); + } + } + + if (extractedChapters.isEmpty()) { + return new KoboSpanPositionMap(List.of()); + } + + long totalContentLength = extractedChapters.stream() + .mapToLong(ExtractedChapter::contentLength) + .sum(); + long accumulatedLength = 0L; + List chapters = new ArrayList<>(extractedChapters.size()); + + for (int i = 0; i < extractedChapters.size(); i++) { + ExtractedChapter chapter = extractedChapters.get(i); + float startProgress = accumulatedLength / (float) totalContentLength; + accumulatedLength += chapter.contentLength(); + float endProgress = i == extractedChapters.size() - 1 + ? 1f + : accumulatedLength / (float) totalContentLength; + + chapters.add(new KoboSpanPositionMap.Chapter( + chapter.sourceHref(), + normalizeHref(chapter.sourceHref()), + chapter.spineIndex(), + startProgress, + endProgress, + chapter.spans())); + } + + return new KoboSpanPositionMap(chapters); + } catch (IOException e) { + throw e; + } catch (Exception e) { + throw new IOException("Failed to extract Kobo span map from " + kepubFile.getName(), e); + } + } + + private void validateKepub(File kepubFile) { + if (kepubFile == null || !kepubFile.isFile()) { + throw new IllegalArgumentException("Invalid KEPUB file: " + kepubFile); + } + } + + private String readOpfPath(ZipFile zipFile) throws Exception { + org.w3c.dom.Document containerDocument = parseXmlEntry(zipFile, CONTAINER_PATH); + NodeList rootfiles = containerDocument.getElementsByTagNameNS(CONTAINER_NS, "rootfile"); + if (rootfiles.getLength() == 0) { + rootfiles = containerDocument.getElementsByTagName("rootfile"); + } + if (rootfiles.getLength() == 0) { + throw new IOException("No rootfile found in container.xml"); + } + + Element rootfile = (Element) rootfiles.item(0); + String fullPath = rootfile.getAttribute("full-path"); + if (fullPath == null || fullPath.isEmpty()) { + throw new IOException("No full-path attribute found in container.xml"); + } + return fullPath; + } + + private org.w3c.dom.Document parseXmlEntry(ZipFile zipFile, String entryName) throws Exception { + FileHeader fileHeader = findFileHeader(zipFile, entryName); + if (fileHeader == null) { + throw new IOException("Entry not found in archive: " + entryName); + } + + try (InputStream inputStream = zipFile.getInputStream(fileHeader)) { + return SecureXmlUtils.createSecureDocumentBuilder(true).parse(inputStream); + } + } + + private Map parseManifest(org.w3c.dom.Document opfDocument, String opfRootPath) { + Map manifestById = new HashMap<>(); + NodeList items = opfDocument.getElementsByTagNameNS(OPF_NS, "item"); + if (items.getLength() == 0) { + items = opfDocument.getElementsByTagName("item"); + } + + for (int i = 0; i < items.getLength(); i++) { + Element item = (Element) items.item(i); + String id = item.getAttribute("id"); + String href = resolveHref(item.getAttribute("href"), opfRootPath); + if (id != null && !id.isBlank() && href != null && !href.isBlank()) { + manifestById.put(id, new ManifestItem(href)); + } + } + return manifestById; + } + + private List parseSpine(org.w3c.dom.Document opfDocument, Map manifestById) { + List spineItems = new ArrayList<>(); + NodeList itemrefs = opfDocument.getElementsByTagNameNS(OPF_NS, "itemref"); + if (itemrefs.getLength() == 0) { + itemrefs = opfDocument.getElementsByTagName("itemref"); + } + + for (int i = 0; i < itemrefs.getLength(); i++) { + Element itemref = (Element) itemrefs.item(i); + ManifestItem manifestItem = manifestById.get(itemref.getAttribute("idref")); + if (manifestItem != null) { + spineItems.add(manifestItem); + } + } + return spineItems; + } + + private ExtractedChapter extractChapter(ZipFile zipFile, ManifestItem manifestItem, int spineIndex) throws IOException { + FileHeader fileHeader = findFileHeader(zipFile, manifestItem.href()); + if (fileHeader == null || fileHeader.isDirectory()) { + log.debug("Skipping missing Kobo span chapter {}", manifestItem.href()); + return null; + } + + String html; + try (InputStream inputStream = zipFile.getInputStream(fileHeader)) { + html = new String(inputStream.readAllBytes(), StandardCharsets.UTF_8); + } + + if (html.isBlank()) { + return new ExtractedChapter(fileHeader.getFileName(), spineIndex, 1, List.of()); + } + + org.jsoup.nodes.Document document = Jsoup.parse(html, "", Parser.htmlParser().setTrackPosition(true)); + List spans = document.select("span.koboSpan[id]").stream() + .map(span -> { + if (span.id().isBlank() || span.sourceRange() == null) { + return null; + } + return new KoboSpanPositionMap.Span( + span.id(), + span.sourceRange().endPos() / (float) Math.max(html.length(), 1)); + }) + .filter(Objects::nonNull) + .toList(); + + return new ExtractedChapter( + fileHeader.getFileName(), + spineIndex, + Math.max(html.length(), 1), + spans); + } + + private FileHeader findFileHeader(ZipFile zipFile, String href) throws IOException { + String normalizedHref = normalizeHref(href); + return zipFile.getFileHeaders().stream() + .filter(header -> !header.isDirectory()) + .filter(header -> { + String normalizedEntry = normalizeHref(header.getFileName()); + return normalizedEntry.equals(normalizedHref) + || normalizedEntry.endsWith("/" + normalizedHref); + }) + .min(Comparator.comparingInt(header -> { + String normalizedEntry = normalizeHref(header.getFileName()); + return normalizedEntry.equals(normalizedHref) ? 0 : normalizedEntry.length(); + })) + .orElse(null); + } + + private String resolveHref(String href, String rootPath) { + if (href == null || href.isBlank()) { + return null; + } + String decodedHref = KoboEpubUtils.decodeHrefPath(href); + if (decodedHref.startsWith("/")) { + return decodedHref.substring(1); + } + if (rootPath == null || rootPath.isEmpty()) { + return decodedHref; + } + return Path.of(rootPath).resolve(decodedHref).normalize().toString().replace('\\', '/'); + } + + private record ManifestItem(String href) { + } + + private record ExtractedChapter(String sourceHref, + int spineIndex, + int contentLength, + List spans) { + } +} diff --git a/booklore-api/src/main/java/org/booklore/service/kobo/KoboSpanMapService.java b/booklore-api/src/main/java/org/booklore/service/kobo/KoboSpanMapService.java new file mode 100644 index 000000000..723b2cea7 --- /dev/null +++ b/booklore-api/src/main/java/org/booklore/service/kobo/KoboSpanMapService.java @@ -0,0 +1,94 @@ +package org.booklore.service.kobo; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.booklore.model.dto.kobo.KoboSpanPositionMap; +import org.booklore.model.entity.BookFileEntity; +import org.booklore.model.entity.KoboSpanMapEntity; +import org.booklore.repository.KoboSpanMapRepository; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import org.springframework.dao.DataIntegrityViolationException; + +import java.io.File; +import java.time.Instant; +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; + +@Service +@RequiredArgsConstructor +@Slf4j +public class KoboSpanMapService { + + private final KoboSpanMapRepository koboSpanMapRepository; + private final KoboSpanMapExtractionService koboSpanMapExtractionService; + + @Transactional + public void computeAndStoreIfNeeded(BookFileEntity bookFile, File kepubFile) throws java.io.IOException { + if (bookFile == null || bookFile.getId() == null) { + return; + } + String currentHash = bookFile.getCurrentHash(); + if (currentHash == null || currentHash.isBlank()) { + log.debug("Skipping Kobo span map creation for file {} because current hash is missing", bookFile.getId()); + return; + } + + Optional existingMap = koboSpanMapRepository.findByBookFileId(bookFile.getId()); + if (existingMap.filter(map -> currentHash.equals(map.getFileHash()) && map.getSpanMap() != null).isPresent()) { + return; + } + if (kepubFile == null || !kepubFile.isFile()) { + return; + } + + KoboSpanPositionMap spanMap = koboSpanMapExtractionService.extractFromKepub(kepubFile); + KoboSpanMapEntity entity = existingMap.orElseGet(KoboSpanMapEntity::new); + entity.setBookFile(bookFile); + entity.setFileHash(currentHash); + entity.setSpanMap(spanMap); + if (entity.getCreatedAt() == null) { + entity.setCreatedAt(Instant.now()); + } + try { + koboSpanMapRepository.save(entity); + } catch (DataIntegrityViolationException e) { + log.debug("Kobo span map already stored by a concurrent request for file {}", bookFile.getId()); + } + } + + @Transactional(readOnly = true) + public Map getValidMaps(Map bookFilesByFileId) { + if (bookFilesByFileId.isEmpty()) { + return Map.of(); + } + Map result = new HashMap<>(); + for (KoboSpanMapEntity entity : koboSpanMapRepository.findByBookFileIdIn(bookFilesByFileId.keySet())) { + Long fileId = entity.getBookFile().getId(); + BookFileEntity bookFile = bookFilesByFileId.get(fileId); + if (bookFile != null && bookFile.getCurrentHash() != null + && bookFile.getCurrentHash().equals(entity.getFileHash()) + && entity.getSpanMap() != null) { + result.put(fileId, entity.getSpanMap()); + } + } + return result; + } + + @Transactional(readOnly = true) + public Optional getValidMap(BookFileEntity bookFile) { + if (bookFile == null || bookFile.getId() == null) { + return Optional.empty(); + } + String currentHash = bookFile.getCurrentHash(); + if (currentHash == null || currentHash.isBlank()) { + return Optional.empty(); + } + + return koboSpanMapRepository.findByBookFileId(bookFile.getId()) + .filter(map -> currentHash.equals(map.getFileHash())) + .map(KoboSpanMapEntity::getSpanMap); + } +} diff --git a/booklore-api/src/main/java/org/booklore/service/progress/ReadingProgressService.java b/booklore-api/src/main/java/org/booklore/service/progress/ReadingProgressService.java index 2289237ed..54360720b 100644 --- a/booklore-api/src/main/java/org/booklore/service/progress/ReadingProgressService.java +++ b/booklore-api/src/main/java/org/booklore/service/progress/ReadingProgressService.java @@ -152,6 +152,7 @@ private void setBookProgressFromFileProgress(Book book, UserBookFileProgressEnti case EPUB, FB2, MOBI, AZW3 -> book.setEpubProgress(EpubProgress.builder() .cfi(fileProgress.getPositionData()) .href(fileProgress.getPositionHref()) + .contentSourceProgressPercent(roundToOneDecimal(fileProgress.getContentSourceProgressPercent())) .percentage(roundToOneDecimal(fileProgress.getProgressPercent())) .ttsPositionCfi(fileProgress.getTtsPositionCfi()) .build()); @@ -302,6 +303,7 @@ private void saveToUserBookFileProgress(BookLoreUserEntity user, BookFileProgres entity.setPositionHref(fileProgress.positionHref()); entity.setProgressPercent(fileProgress.progressPercent()); entity.setTtsPositionCfi(fileProgress.ttsPositionCfi()); + entity.setContentSourceProgressPercent(fileProgress.contentSourceProgressPercent()); entity.setLastReadTime(now); userBookFileProgressRepository.save(entity); @@ -316,6 +318,7 @@ private void saveToUserBookFileProgressFromLegacy(BookLoreUserEntity user, BookF entity.setUser(user); entity.setBookFile(bookFile); entity.setLastReadTime(now); + entity.setContentSourceProgressPercent(null); switch (bookFile.getBookType()) { case PDF -> { diff --git a/booklore-api/src/main/java/org/booklore/util/koreader/CfiConvertor.java b/booklore-api/src/main/java/org/booklore/util/koreader/CfiConvertor.java index 92204221f..ef2f185d6 100644 --- a/booklore-api/src/main/java/org/booklore/util/koreader/CfiConvertor.java +++ b/booklore-api/src/main/java/org/booklore/util/koreader/CfiConvertor.java @@ -142,6 +142,43 @@ public boolean validateCfi(String cfi) { } } + public Integer resolveSourceOffset(String cfi) { + Matcher cfiMatcher = CFI_PATTERN.matcher(cfi); + if (!cfiMatcher.matches()) { + throw new IllegalArgumentException("Invalid CFI format: " + cfi); + } + + String innerCfi = cfiMatcher.group(1); + Matcher spineMatcher = CFI_SPINE_PATTERN.matcher(innerCfi); + if (!spineMatcher.matches()) { + throw new IllegalArgumentException("Cannot parse CFI spine step: " + cfi); + } + + int spineStep = Integer.parseInt(spineMatcher.group(1)); + int cfiSpineIndex = (spineStep - 2) / 2; + if (cfiSpineIndex != spineItemIndex) { + throw new IllegalArgumentException( + String.format("CFI spine index %d does not match converter spine index %d", + cfiSpineIndex, spineItemIndex)); + } + + String contentPath = spineMatcher.group(2); + CfiPathResult pathResult = parseCfiPath(contentPath); + Element element = resolveElementFromCfiSteps(pathResult.steps()); + if (element == null) { + throw new IllegalArgumentException("Element not found for CFI: " + cfi); + } + + if (pathResult.textOffset() != null) { + Integer textOffset = resolveTextOffsetSourcePosition(element, pathResult.textOffset()); + if (textOffset != null) { + return textOffset; + } + } + + return resolveElementSourceOffset(element); + } + public boolean validateXPointer(String xpointer, String pos1) { try { xPointerToCfi(xpointer, pos1); @@ -327,6 +364,13 @@ private CfiPathResult parseCfiPath(String contentPath) { steps.add(new CfiStep(stepIndex, assertion)); } + // The content-document path generated by EPUB readers commonly starts with /4 + // before the body descendants. Our converter already starts resolution from , + // so keeping that synthetic leading step collapses many CFIs back to the chapter root. + if (!steps.isEmpty() && steps.getFirst().index() == 4) { + steps = new ArrayList<>(steps.subList(1, steps.size())); + } + return new CfiPathResult(steps, textOffset); } @@ -472,6 +516,36 @@ private String handleTextOffset(Element element, int cfiOffset) { return basePath + "/text()." + offsetInNode; } + private Integer resolveTextOffsetSourcePosition(Element element, int cfiOffset) { + List textNodes = collectTextNodes(element); + + int totalChars = 0; + for (TextNode textNode : textNodes) { + String nodeText = textNode.text(); + int nodeLength = nodeText.length(); + + if (totalChars + nodeLength >= cfiOffset) { + if (textNode.sourceRange() == null) { + return null; + } + int offsetInNode = Math.max(cfiOffset - totalChars, 0); + int startPos = Math.max(textNode.sourceRange().startPos(), 0); + return startPos + offsetInNode; + } + + totalChars += nodeLength; + } + + return resolveElementSourceOffset(element); + } + + private Integer resolveElementSourceOffset(Element element) { + if (element.sourceRange() == null) { + return null; + } + return Math.max(element.sourceRange().startPos(), 0); + } + private List collectTextNodes(Element element) { List textNodes = new ArrayList<>(); collectTextNodesRecursive(element, textNodes); diff --git a/booklore-api/src/main/java/org/booklore/util/koreader/EpubCfiService.java b/booklore-api/src/main/java/org/booklore/util/koreader/EpubCfiService.java index d204f8a33..9d8b052fc 100644 --- a/booklore-api/src/main/java/org/booklore/util/koreader/EpubCfiService.java +++ b/booklore-api/src/main/java/org/booklore/util/koreader/EpubCfiService.java @@ -5,6 +5,7 @@ import com.github.benmanes.caffeine.cache.Caffeine; import lombok.extern.slf4j.Slf4j; import org.jsoup.Jsoup; +import org.jsoup.parser.Parser; import org.jsoup.nodes.Document; import org.springframework.stereotype.Service; @@ -12,6 +13,7 @@ import java.io.IOException; import java.nio.file.Path; import java.time.Duration; +import java.util.Optional; @Slf4j @Service @@ -66,6 +68,40 @@ public String convertCfiToProgressXPointer(Path epubPath, String cfi) { return convertCfiToProgressXPointer(epubPath.toFile(), cfi); } + public Optional resolveCfiLocation(File epubFile, String cfi) { + if (cfi == null || cfi.isBlank()) { + return Optional.empty(); + } + + try { + int spineIndex = CfiConvertor.extractSpineIndex(cfi); + String href = EpubContentReader.getSpineItemHref(epubFile, spineIndex); + if (href == null || href.isBlank()) { + return Optional.empty(); + } + + String html = EpubContentReader.getSpineItemContent(epubFile, spineIndex); + if (html.isBlank()) { + return Optional.empty(); + } + + CfiConvertor converter = createConverter(epubFile, spineIndex); + Integer sourceOffset = converter.resolveSourceOffset(cfi); + Float contentSourceProgressPercent = sourceOffset == null + ? null + : clampPercent((sourceOffset / (float) Math.max(html.length(), 1)) * 100f); + + return Optional.of(new CfiLocation(href, contentSourceProgressPercent)); + } catch (Exception e) { + log.debug("Failed to resolve chapter position from CFI: {}", e.getMessage()); + return Optional.empty(); + } + } + + public Optional resolveCfiLocation(Path epubPath, String cfi) { + return resolveCfiLocation(epubPath.toFile(), cfi); + } + public boolean validateCfi(File epubFile, String cfi) { try { int spineIndex = CfiConvertor.extractSpineIndex(cfi); @@ -110,7 +146,7 @@ private Document getCachedDocument(File epubFile, int spineIndex) { return documentCache.get(cacheKey, key -> { log.debug("Cache miss for epub spine: {} index {}", epubFile.getName(), spineIndex); String html = EpubContentReader.getSpineItemContent(epubFile, spineIndex); - return Jsoup.parse(html); + return Jsoup.parse(html, "", Parser.htmlParser().setTrackPosition(true)); }); } @@ -135,4 +171,11 @@ public void evictCache(File epubFile) { public void clearCache() { documentCache.invalidateAll(); } + + private Float clampPercent(float percent) { + return Math.max(0f, Math.min(percent, 100f)); + } + + public record CfiLocation(String href, Float contentSourceProgressPercent) { + } } diff --git a/booklore-api/src/main/resources/db/migration/V134__Add_content_source_progress_percent_to_user_book_file_progress.sql b/booklore-api/src/main/resources/db/migration/V134__Add_content_source_progress_percent_to_user_book_file_progress.sql new file mode 100644 index 000000000..cf4c3199f --- /dev/null +++ b/booklore-api/src/main/resources/db/migration/V134__Add_content_source_progress_percent_to_user_book_file_progress.sql @@ -0,0 +1,2 @@ +ALTER TABLE user_book_file_progress + ADD COLUMN IF NOT EXISTS content_source_progress_percent FLOAT NULL; diff --git a/booklore-api/src/main/resources/db/migration/V135__Create_kobo_span_map_table.sql b/booklore-api/src/main/resources/db/migration/V135__Create_kobo_span_map_table.sql new file mode 100644 index 000000000..f04f2d3ae --- /dev/null +++ b/booklore-api/src/main/resources/db/migration/V135__Create_kobo_span_map_table.sql @@ -0,0 +1,14 @@ +CREATE TABLE kobo_span_map +( + id BIGINT AUTO_INCREMENT NOT NULL, + book_file_id BIGINT NOT NULL, + file_hash VARCHAR(128) NOT NULL, + span_map_json LONGTEXT NOT NULL, + created_at datetime NOT NULL, + CONSTRAINT pk_kobo_span_map PRIMARY KEY (id), + CONSTRAINT uk_kobo_span_map_book_file UNIQUE (book_file_id) +); + +ALTER TABLE kobo_span_map + ADD CONSTRAINT fk_kobo_span_map_book_file + FOREIGN KEY (book_file_id) REFERENCES book_file (id) ON DELETE CASCADE; diff --git a/booklore-api/src/test/java/org/booklore/service/KoboEntitlementServiceTest.java b/booklore-api/src/test/java/org/booklore/service/KoboEntitlementServiceTest.java index d94820906..58e8b93b8 100644 --- a/booklore-api/src/test/java/org/booklore/service/KoboEntitlementServiceTest.java +++ b/booklore-api/src/test/java/org/booklore/service/KoboEntitlementServiceTest.java @@ -25,14 +25,17 @@ import org.booklore.model.dto.KoboSyncSettings; import org.booklore.model.dto.kobo.*; import org.booklore.model.entity.KoboReadingStateEntity; +import org.booklore.model.entity.UserBookFileProgressEntity; import org.booklore.model.entity.UserBookProgressEntity; import org.booklore.model.enums.KoboReadStatus; import org.booklore.model.enums.ReadStatus; import org.booklore.repository.KoboReadingStateRepository; +import org.booklore.repository.UserBookFileProgressRepository; import org.booklore.repository.UserBookProgressRepository; import org.booklore.service.kobo.KoboEntitlementService; import org.booklore.service.kobo.KoboReadingStateBuilder; import org.booklore.service.kobo.KoboSettingsService; +import org.booklore.service.kobo.KoboSpanMapService; import org.booklore.service.opds.MagicShelfBookService; import org.booklore.util.kobo.KoboUrlBuilder; import org.junit.jupiter.api.BeforeEach; @@ -54,6 +57,7 @@ import java.time.ZoneOffset; import java.util.HashSet; import java.util.List; +import java.util.Map; import java.util.Optional; import java.util.Set; @@ -92,6 +96,9 @@ class KoboEntitlementServiceTest { @Mock private UserBookProgressRepository progressRepository; + @Mock + private UserBookFileProgressRepository fileProgressRepository; + @Mock private KoboReadingStateRepository readingStateRepository; @@ -104,6 +111,9 @@ class KoboEntitlementServiceTest { @Mock private KoboSettingsService koboSettingsService; + @Mock + private KoboSpanMapService koboSpanMapService; + @InjectMocks private KoboEntitlementService koboEntitlementService; @@ -112,7 +122,9 @@ class KoboEntitlementServiceTest { @BeforeEach void setUp() { BookLoreUser.UserPermissions permissions = new BookLoreUser.UserPermissions(); - user = BookLoreUser.builder().permissions(permissions).build(); + user = BookLoreUser.builder().id(1L).permissions(permissions).build(); + when(authenticationService.getAuthenticatedUser()).thenReturn(user); + when(koboSpanMapService.getValidMaps(anyMap())).thenReturn(Map.of()); } @Test @@ -159,6 +171,28 @@ void mapToKoboMetadata_cbxBookWithConversionEnabled_shouldReturnEpubFormat() { assertEquals(KoboBookFormat.EPUB3.toString(), result.getDownloadUrls().getFirst().getFormat()); } + @Test + void mapToKoboMetadata_epubBookShouldReturnKepubFormatEvenWhenToggleIsOff() { + long bookId = 1L; + BookEntity epubBook = createEpubBookEntity(bookId); + String token = "test-token"; + + when(bookQueryService.findAllWithMetadataByIds(Set.of(bookId))) + .thenReturn(List.of(epubBook)); + when(koboCompatibilityService.isBookSupportedForKobo(epubBook)) + .thenReturn(true); + when(koboUrlBuilder.downloadUrl(token, epubBook.getId())) + .thenReturn("http://test.com/download/" + epubBook.getId()); + when(appSettingService.getAppSettings()) + .thenReturn(createAppSettingsWithKoboSettings()); + + KoboBookMetadata result = koboEntitlementService.getMetadataForBook(bookId, token); + + assertNotNull(result); + assertEquals(1, result.getDownloadUrls().size()); + assertEquals(KoboBookFormat.KEPUB.toString(), result.getDownloadUrls().getFirst().getFormat()); + } + private BookEntity createCbxBookEntity(Long id) { BookEntity book = new BookEntity(); book.setId(id); @@ -183,7 +217,6 @@ private AppSettings createAppSettingsWithKoboSettings() { KoboSettings koboSettings = KoboSettings.builder() .convertCbxToEpub(true) .conversionLimitInMbForCbx(50) - .convertToKepub(false) .conversionLimitInMb(50) .build(); appSettings.setKoboSettings(koboSettings); @@ -485,6 +518,127 @@ void generateNewEntitlements_filterUnsupported() { assertTrue(result.isEmpty()); } + @Test + @DisplayName("Should prefer href-only web reader bookmark over existing state when two-way sync is on") + void generateNewEntitlements_prefersHrefOnlyWebReaderBookmark() { + BookEntity book = createEpubBookEntity(1L); + book.setAddedOn(Instant.parse("2025-01-01T00:00:00Z")); + book.getPrimaryBookFile().setId(10L); + + UserBookProgressEntity progress = new UserBookProgressEntity(); + progress.setBook(book); + progress.setEpubProgressHref("OPS/chapter3.xhtml"); + progress.setEpubProgressPercent(54.6f); + + KoboReadingState.CurrentBookmark existingBookmark = KoboReadingState.CurrentBookmark.builder() + .progressPercent(12) + .build(); + KoboReadingState existingState = KoboReadingState.builder() + .entitlementId("1") + .currentBookmark(existingBookmark) + .build(); + KoboReadingStateEntity existingEntity = new KoboReadingStateEntity(); + + KoboReadingState.CurrentBookmark webReaderBookmark = KoboReadingState.CurrentBookmark.builder() + .progressPercent(55) + .build(); + + KoboSyncSettings settings = new KoboSyncSettings(); + settings.setTwoWayProgressSync(true); + + when(bookQueryService.findAllWithMetadataByIds(Set.of(1L))).thenReturn(List.of(book)); + when(koboCompatibilityService.isBookSupportedForKobo(book)).thenReturn(true); + when(koboUrlBuilder.downloadUrl("token1", 1L)).thenReturn("http://test.com/download/1"); + when(appSettingService.getAppSettings()).thenReturn(createAppSettingsWithKoboSettings()); + when(koboSettingsService.getCurrentUserSettings()).thenReturn(settings); + when(progressRepository.findByUserIdAndBookId(user.getId(), 1L)).thenReturn(Optional.of(progress)); + when(fileProgressRepository.findByUserIdAndBookFileId(user.getId(), 10L)).thenReturn(Optional.empty()); + when(readingStateRepository.findByEntitlementIdAndUserId("1", user.getId())) + .thenReturn(Optional.of(existingEntity)); + when(readingStateMapper.toDto(existingEntity)).thenReturn(existingState); + when(readingStateBuilder.shouldUseWebReaderProgress(progress)).thenReturn(true); + when(readingStateBuilder.buildBookmarkFromProgress(eq(progress), isNull(), any(OffsetDateTime.class))) + .thenReturn(webReaderBookmark); + when(readingStateBuilder.buildStatusInfoFromProgress(eq(progress), anyString())) + .thenReturn(KoboReadingState.StatusInfo.builder() + .status(KoboReadStatus.READING) + .timesStartedReading(1) + .build()); + + List result = koboEntitlementService.generateNewEntitlements(Set.of(1L), "token1"); + + assertEquals(1, result.size()); + assertEquals(55, result.getFirst().getNewEntitlement().getReadingState().getCurrentBookmark().getProgressPercent()); + verify(readingStateBuilder).buildBookmarkFromProgress(eq(progress), isNull(), any(OffsetDateTime.class)); + } + + @Test + @DisplayName("Should keep stored Kobo bookmark when mirrored EPUB progress is not newer") + void generateNewEntitlements_preservesStoredKoboBookmarkWhenKoboIsFreshest() { + BookEntity book = createEpubBookEntity(1L); + book.setAddedOn(Instant.parse("2025-01-01T00:00:00Z")); + book.getPrimaryBookFile().setId(10L); + + UserBookProgressEntity progress = new UserBookProgressEntity(); + progress.setBook(book); + progress.setKoboProgressPercent(55f); + progress.setKoboLocation("kobo.12.18"); + progress.setKoboLocationType("KoboSpan"); + progress.setKoboLocationSource("OPS/chapter3.xhtml"); + progress.setKoboProgressReceivedTime(Instant.parse("2025-06-15T12:00:00Z")); + progress.setEpubProgressPercent(55f); + progress.setLastReadTime(Instant.parse("2025-06-15T12:00:00Z")); + progress.setReadStatus(ReadStatus.READING); + + KoboReadingState.CurrentBookmark existingBookmark = KoboReadingState.CurrentBookmark.builder() + .progressPercent(55) + .contentSourceProgressPercent(23) + .location(KoboReadingState.CurrentBookmark.Location.builder() + .value("kobo.12.18") + .type("KoboSpan") + .source("OPS/chapter3.xhtml") + .build()) + .lastModified("2025-06-15T12:00:00Z") + .build(); + KoboReadingState existingState = KoboReadingState.builder() + .entitlementId("1") + .currentBookmark(existingBookmark) + .build(); + KoboReadingStateEntity existingEntity = new KoboReadingStateEntity(); + + KoboSyncSettings settings = new KoboSyncSettings(); + settings.setTwoWayProgressSync(true); + + when(bookQueryService.findAllWithMetadataByIds(Set.of(1L))).thenReturn(List.of(book)); + when(koboCompatibilityService.isBookSupportedForKobo(book)).thenReturn(true); + when(koboUrlBuilder.downloadUrl("token1", 1L)).thenReturn("http://test.com/download/1"); + when(appSettingService.getAppSettings()).thenReturn(createAppSettingsWithKoboSettings()); + when(koboSettingsService.getCurrentUserSettings()).thenReturn(settings); + when(progressRepository.findByUserIdAndBookId(user.getId(), 1L)).thenReturn(Optional.of(progress)); + when(fileProgressRepository.findByUserIdAndBookFileId(user.getId(), 10L)).thenReturn(Optional.empty()); + when(readingStateRepository.findByEntitlementIdAndUserId("1", user.getId())) + .thenReturn(Optional.of(existingEntity)); + when(readingStateMapper.toDto(existingEntity)).thenReturn(existingState); + when(readingStateBuilder.shouldUseWebReaderProgress(progress)).thenReturn(false); + when(readingStateBuilder.buildStatusInfoFromProgress(eq(progress), anyString())) + .thenReturn(KoboReadingState.StatusInfo.builder() + .status(KoboReadStatus.READING) + .timesStartedReading(1) + .build()); + + List result = koboEntitlementService.generateNewEntitlements(Set.of(1L), "token1"); + + assertEquals(1, result.size()); + KoboReadingState.CurrentBookmark bookmark = result.getFirst().getNewEntitlement().getReadingState().getCurrentBookmark(); + assertEquals(55, bookmark.getProgressPercent()); + assertEquals(23, bookmark.getContentSourceProgressPercent()); + assertNotNull(bookmark.getLocation()); + assertEquals("kobo.12.18", bookmark.getLocation().getValue()); + assertEquals("OPS/chapter3.xhtml", bookmark.getLocation().getSource()); + verify(readingStateBuilder).shouldUseWebReaderProgress(progress); + verify(readingStateBuilder, never()).buildBookmarkFromProgress(eq(progress), isNull(), any(OffsetDateTime.class)); + } + @Test @DisplayName("Should handle empty book IDs") void generateNewEntitlements_emptyIds() { @@ -542,6 +696,11 @@ class GenerateChangedReadingStates { void generateChangedReadingStates_fromProgress() { BookEntity book = new BookEntity(); book.setId(1L); + BookFileEntity primaryFile = new BookFileEntity(); + primaryFile.setId(10L); + primaryFile.setBook(book); + primaryFile.setBookType(BookFileType.EPUB); + book.setBookFiles(List.of(primaryFile)); UserBookProgressEntity progress = new UserBookProgressEntity(); progress.setBook(book); @@ -549,12 +708,19 @@ void generateChangedReadingStates_fromProgress() { progress.setReadStatus(ReadStatus.READING); KoboSyncSettings settings = new KoboSyncSettings(); + settings.setTwoWayProgressSync(true); when(koboSettingsService.getCurrentUserSettings()).thenReturn(settings); + UserBookFileProgressEntity fileProgress = new UserBookFileProgressEntity(); + fileProgress.setBookFile(primaryFile); + fileProgress.setContentSourceProgressPercent(20f); + when(fileProgressRepository.findByUserIdAndBookFileIdIn(eq(1L), any())).thenReturn(List.of(fileProgress)); + KoboReadingState.CurrentBookmark bookmark = KoboReadingState.CurrentBookmark.builder() .progressPercent(50) + .contentSourceProgressPercent(20) .build(); - when(readingStateBuilder.buildBookmarkFromProgress(eq(progress), any(OffsetDateTime.class))) + when(readingStateBuilder.buildBookmarkFromProgress(eq(progress), eq(fileProgress), any(OffsetDateTime.class), anyMap())) .thenReturn(bookmark); when(readingStateBuilder.buildStatusInfoFromProgress(eq(progress), anyString())) .thenReturn(KoboReadingState.StatusInfo.builder() @@ -569,9 +735,68 @@ void generateChangedReadingStates_fromProgress() { KoboReadingState state = result.getFirst().getChangedReadingState().getReadingState(); assertEquals("1", state.getEntitlementId()); assertNotNull(state.getCurrentBookmark()); + assertEquals(20, state.getCurrentBookmark().getContentSourceProgressPercent()); assertNotNull(state.getStatusInfo()); } + @Test + @DisplayName("Should use synced EPUB file progress when book has multiple files") + void generateChangedReadingStates_usesSyncedEpubFileProgress() { + BookEntity book = new BookEntity(); + book.setId(1L); + + BookFileEntity primaryEpub = new BookFileEntity(); + primaryEpub.setId(10L); + primaryEpub.setBook(book); + primaryEpub.setBookType(BookFileType.EPUB); + + BookFileEntity alternateAudiobook = new BookFileEntity(); + alternateAudiobook.setId(20L); + alternateAudiobook.setBook(book); + alternateAudiobook.setBookType(BookFileType.AUDIOBOOK); + + book.setBookFiles(List.of(primaryEpub, alternateAudiobook)); + + UserBookProgressEntity progress = new UserBookProgressEntity(); + progress.setBook(book); + progress.setEpubProgressPercent(70f); + progress.setReadStatus(ReadStatus.READING); + + KoboSyncSettings settings = new KoboSyncSettings(); + settings.setTwoWayProgressSync(true); + when(koboSettingsService.getCurrentUserSettings()).thenReturn(settings); + + UserBookFileProgressEntity epubFileProgress = new UserBookFileProgressEntity(); + epubFileProgress.setBookFile(primaryEpub); + epubFileProgress.setContentSourceProgressPercent(21f); + + UserBookFileProgressEntity audiobookProgress = new UserBookFileProgressEntity(); + audiobookProgress.setBookFile(alternateAudiobook); + audiobookProgress.setContentSourceProgressPercent(99f); + + when(fileProgressRepository.findByUserIdAndBookFileIdIn(eq(1L), any())) + .thenReturn(List.of(epubFileProgress, audiobookProgress)); + + KoboReadingState.CurrentBookmark bookmark = KoboReadingState.CurrentBookmark.builder() + .progressPercent(70) + .contentSourceProgressPercent(21) + .build(); + when(readingStateBuilder.buildBookmarkFromProgress(eq(progress), eq(epubFileProgress), any(OffsetDateTime.class), anyMap())) + .thenReturn(bookmark); + when(readingStateBuilder.buildStatusInfoFromProgress(eq(progress), anyString())) + .thenReturn(KoboReadingState.StatusInfo.builder() + .status(KoboReadStatus.READING) + .timesStartedReading(1) + .build()); + + List result = koboEntitlementService.generateChangedReadingStates(List.of(progress)); + + assertEquals(1, result.size()); + assertEquals(21, result.getFirst().getChangedReadingState().getReadingState() + .getCurrentBookmark().getContentSourceProgressPercent()); + verify(readingStateBuilder).buildBookmarkFromProgress(eq(progress), eq(epubFileProgress), any(OffsetDateTime.class), anyMap()); + } + @Test @DisplayName("Should use empty bookmark when no progress data exists") void generateChangedReadingStates_emptyBookmark() { @@ -616,7 +841,7 @@ void generateChangedReadingStates_twoWaySync() { KoboReadingState.CurrentBookmark bookmark = KoboReadingState.CurrentBookmark.builder() .progressPercent(70) .build(); - when(readingStateBuilder.buildBookmarkFromProgress(eq(progress), any(OffsetDateTime.class))) + when(readingStateBuilder.buildBookmarkFromProgress(eq(progress), isNull(), any(OffsetDateTime.class), anyMap())) .thenReturn(bookmark); when(readingStateBuilder.buildStatusInfoFromProgress(eq(progress), anyString())) .thenReturn(KoboReadingState.StatusInfo.builder() @@ -661,4 +886,4 @@ void generateChangedReadingStates_twoWaySyncOff() { verify(readingStateBuilder).buildEmptyBookmark(any(OffsetDateTime.class)); } } -} \ No newline at end of file +} diff --git a/booklore-api/src/test/java/org/booklore/service/KoboReadingStateBuilderTest.java b/booklore-api/src/test/java/org/booklore/service/KoboReadingStateBuilderTest.java index e03dda598..a7c01ef22 100644 --- a/booklore-api/src/test/java/org/booklore/service/KoboReadingStateBuilderTest.java +++ b/booklore-api/src/test/java/org/booklore/service/KoboReadingStateBuilderTest.java @@ -3,9 +3,11 @@ import org.booklore.model.dto.KoboSyncSettings; import org.booklore.model.dto.kobo.KoboReadingState; import org.booklore.model.entity.BookEntity; +import org.booklore.model.entity.UserBookFileProgressEntity; import org.booklore.model.entity.UserBookProgressEntity; import org.booklore.model.enums.KoboReadStatus; import org.booklore.model.enums.ReadStatus; +import org.booklore.service.kobo.KoboBookmarkLocationResolver; import org.booklore.service.kobo.KoboReadingStateBuilder; import org.booklore.service.kobo.KoboSettingsService; import org.junit.jupiter.api.BeforeEach; @@ -27,6 +29,7 @@ import java.util.stream.Stream; import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.when; @DisplayName("KoboReadingStateBuilder Tests") @@ -37,6 +40,9 @@ class KoboReadingStateBuilderTest { @Mock private KoboSettingsService koboSettingsService; + @Mock + private KoboBookmarkLocationResolver bookmarkLocationResolver; + private KoboReadingStateBuilder builder; @BeforeEach @@ -44,7 +50,8 @@ void setUp() { KoboSyncSettings settings = new KoboSyncSettings(); settings.setTwoWayProgressSync(true); when(koboSettingsService.getCurrentUserSettings()).thenReturn(settings); - builder = new KoboReadingStateBuilder(koboSettingsService); + when(bookmarkLocationResolver.resolve(any(), any(), any())).thenReturn(java.util.Optional.empty()); + builder = new KoboReadingStateBuilder(koboSettingsService, bookmarkLocationResolver); } @Nested @@ -186,19 +193,19 @@ void buildEmptyBookmark() { void buildBookmarkFromProgress_WithLocation() { UserBookProgressEntity progress = new UserBookProgressEntity(); progress.setKoboProgressPercent(75.5f); - progress.setKoboLocation("epubcfi(/6/4[chap01ref]!/4/2/1:3)"); - progress.setKoboLocationType("EpubCfi"); - progress.setKoboLocationSource("Kobo"); + progress.setKoboLocation("kobo.12.18"); + progress.setKoboLocationType("KoboSpan"); + progress.setKoboLocationSource("OEBPS/chapter3.xhtml"); progress.setKoboProgressReceivedTime(Instant.parse("2025-11-26T10:00:00Z")); - KoboReadingState.CurrentBookmark bookmark = builder.buildBookmarkFromProgress(progress); + KoboReadingState.CurrentBookmark bookmark = builder.buildBookmarkFromProgress(progress, (UserBookFileProgressEntity) null); assertNotNull(bookmark); assertEquals(76, bookmark.getProgressPercent()); // Rounded assertNotNull(bookmark.getLocation()); - assertEquals("epubcfi(/6/4[chap01ref]!/4/2/1:3)", bookmark.getLocation().getValue()); - assertEquals("EpubCfi", bookmark.getLocation().getType()); - assertEquals("Kobo", bookmark.getLocation().getSource()); + assertEquals("kobo.12.18", bookmark.getLocation().getValue()); + assertEquals("KoboSpan", bookmark.getLocation().getType()); + assertEquals("OEBPS/chapter3.xhtml", bookmark.getLocation().getSource()); } @Test @@ -209,13 +216,161 @@ void buildBookmarkFromProgress_NoLocation() { progress.setKoboLocation(null); progress.setKoboProgressReceivedTime(Instant.now()); - KoboReadingState.CurrentBookmark bookmark = builder.buildBookmarkFromProgress(progress); + KoboReadingState.CurrentBookmark bookmark = builder.buildBookmarkFromProgress(progress, (UserBookFileProgressEntity) null); assertNotNull(bookmark); assertEquals(50, bookmark.getProgressPercent()); assertNull(bookmark.getLocation()); } + @Test + @DisplayName("Should carry file content progress into Kobo bookmark") + void buildBookmarkFromProgress_KoboBookmarkReusesFileContentProgress() { + UserBookProgressEntity progress = new UserBookProgressEntity(); + progress.setKoboProgressPercent(44f); + progress.setKoboProgressReceivedTime(Instant.parse("2025-11-26T10:00:00Z")); + + UserBookFileProgressEntity fileProgress = new UserBookFileProgressEntity(); + fileProgress.setContentSourceProgressPercent(18.6f); + + KoboReadingState.CurrentBookmark bookmark = builder.buildBookmarkFromProgress(progress, fileProgress); + + assertNotNull(bookmark); + assertEquals(44, bookmark.getProgressPercent()); + assertEquals(19, bookmark.getContentSourceProgressPercent()); + } + + @Test + @DisplayName("Should build web reader bookmark without a fallback location when no KoboSpan resolves") + void buildBookmarkFromProgress_WebReaderLocation() { + UserBookProgressEntity progress = new UserBookProgressEntity(); + progress.setEpubProgress("epubcfi(/6/4!/4/2/6/1:1)"); + progress.setEpubProgressHref("OPS/chapter1.xhtml"); + progress.setEpubProgressPercent(54.6f); + progress.setLastReadTime(Instant.parse("2025-11-26T10:00:00Z")); + + UserBookFileProgressEntity fileProgress = new UserBookFileProgressEntity(); + fileProgress.setPositionData("epubcfi(/6/8!/4/2/6/1:15)"); + fileProgress.setPositionHref("OPS/chapter3.xhtml"); + fileProgress.setContentSourceProgressPercent(18.6f); + + KoboReadingState.CurrentBookmark bookmark = builder.buildBookmarkFromProgress(progress, fileProgress); + + assertNotNull(bookmark); + assertEquals(55, bookmark.getProgressPercent()); + assertEquals(19, bookmark.getContentSourceProgressPercent()); + assertNull(bookmark.getLocation()); + } + + @Test + @DisplayName("Should build web reader bookmark with resolved KoboSpan location") + void buildBookmarkFromProgress_WebReaderResolvedKoboSpanLocation() { + UserBookProgressEntity progress = new UserBookProgressEntity(); + progress.setEpubProgress("epubcfi(/6/4!/4/2/6/1:1)"); + progress.setEpubProgressHref("OPS/chapter3.xhtml"); + progress.setEpubProgressPercent(54.6f); + progress.setLastReadTime(Instant.parse("2025-11-26T10:00:00Z")); + + UserBookFileProgressEntity fileProgress = new UserBookFileProgressEntity(); + fileProgress.setPositionData("epubcfi(/6/8!/4/2/6/1:15)"); + fileProgress.setPositionHref("OPS/chapter3.xhtml"); + + when(bookmarkLocationResolver.resolve(progress, fileProgress, null)) + .thenReturn(java.util.Optional.of( + new KoboBookmarkLocationResolver.ResolvedBookmarkLocation( + "kobo.12.18", + "KoboSpan", + "OEBPS/OPS/chapter3.xhtml", + 18.6f))); + + KoboReadingState.CurrentBookmark bookmark = builder.buildBookmarkFromProgress(progress, fileProgress); + + assertNotNull(bookmark); + assertEquals(55, bookmark.getProgressPercent()); + assertEquals(19, bookmark.getContentSourceProgressPercent()); + assertNotNull(bookmark.getLocation()); + assertEquals("kobo.12.18", bookmark.getLocation().getValue()); + assertEquals("KoboSpan", bookmark.getLocation().getType()); + assertEquals("OEBPS/OPS/chapter3.xhtml", bookmark.getLocation().getSource()); + } + + @Test + @DisplayName("Should prefer web reader bookmark when Kobo bookmark timestamp is older") + void buildBookmarkFromProgress_PrefersWebReaderWhenKoboTimestampIsOlder() { + UserBookProgressEntity progress = new UserBookProgressEntity(); + progress.setEpubProgress("epubcfi(/6/4!/4/2/6/1:1)"); + progress.setEpubProgressHref("OPS/chapter3.xhtml"); + progress.setEpubProgressPercent(54.6f); + progress.setLastReadTime(Instant.parse("2025-11-26T10:00:00Z")); + progress.setKoboProgressPercent(12f); + progress.setKoboLocation("kobo.1.1"); + progress.setKoboLocationType("KoboSpan"); + progress.setKoboLocationSource("OPS/chapter1.xhtml"); + progress.setKoboProgressReceivedTime(Instant.parse("2025-11-26T09:00:00Z")); + + UserBookFileProgressEntity fileProgress = new UserBookFileProgressEntity(); + fileProgress.setPositionData("epubcfi(/6/8!/4/2/6/1:15)"); + fileProgress.setPositionHref("OPS/chapter3.xhtml"); + + KoboReadingState.CurrentBookmark bookmark = builder.buildBookmarkFromProgress(progress, fileProgress); + + assertNotNull(bookmark); + assertEquals(55, bookmark.getProgressPercent()); + assertNull(bookmark.getLocation()); + } + + @Test + @DisplayName("Should keep inbound Kobo bookmark when mirrored EPUB progress has the same timestamp") + void buildBookmarkFromProgress_PrefersKoboWhenTimestampsAreEqual() { + UserBookProgressEntity progress = new UserBookProgressEntity(); + progress.setEpubProgressPercent(54.6f); + progress.setLastReadTime(Instant.parse("2025-11-26T10:00:00Z")); + progress.setKoboProgressPercent(12f); + progress.setKoboLocation("kobo.1.1"); + progress.setKoboLocationType("KoboSpan"); + progress.setKoboLocationSource("OPS/chapter1.xhtml"); + progress.setKoboProgressReceivedTime(Instant.parse("2025-11-26T10:00:00Z")); + + UserBookFileProgressEntity fileProgress = new UserBookFileProgressEntity(); + fileProgress.setPositionHref("OPS/chapter3.xhtml"); + fileProgress.setContentSourceProgressPercent(18.6f); + + KoboReadingState.CurrentBookmark bookmark = builder.buildBookmarkFromProgress(progress, fileProgress); + + assertNotNull(bookmark); + assertEquals(12, bookmark.getProgressPercent()); + assertEquals(19, bookmark.getContentSourceProgressPercent()); + assertNotNull(bookmark.getLocation()); + assertEquals("kobo.1.1", bookmark.getLocation().getValue()); + assertEquals("KoboSpan", bookmark.getLocation().getType()); + assertEquals("OPS/chapter1.xhtml", bookmark.getLocation().getSource()); + } + + @Test + @DisplayName("Should prefer web reader bookmark when newer progress has href and percent but no CFI") + void buildBookmarkFromProgress_UsesWebReaderWithoutCfi() { + UserBookProgressEntity progress = new UserBookProgressEntity(); + progress.setEpubProgressHref("OPS/chapter3.xhtml"); + progress.setEpubProgressPercent(54.6f); + progress.setLastReadTime(Instant.parse("2025-11-26T10:00:00Z")); + progress.setKoboProgressPercent(12f); + progress.setKoboLocation("kobo.1.1"); + progress.setKoboLocationType("KoboSpan"); + progress.setKoboLocationSource("OPS/chapter1.xhtml"); + progress.setKoboProgressReceivedTime(Instant.parse("2025-11-26T09:00:00Z")); + + UserBookFileProgressEntity fileProgress = new UserBookFileProgressEntity(); + fileProgress.setPositionHref("OPS/chapter3.xhtml"); + fileProgress.setContentSourceProgressPercent(18.6f); + + KoboReadingState.CurrentBookmark bookmark = builder.buildBookmarkFromProgress(progress, fileProgress); + + assertNotNull(bookmark); + assertEquals(55, bookmark.getProgressPercent()); + assertEquals(19, bookmark.getContentSourceProgressPercent()); + assertNull(bookmark.getLocation()); + } + @Test @DisplayName("Should use default time when progress received time is null") void buildBookmarkFromProgress_UseDefaultTime() { @@ -224,7 +379,7 @@ void buildBookmarkFromProgress_UseDefaultTime() { progress.setKoboProgressReceivedTime(null); OffsetDateTime defaultTime = OffsetDateTime.parse("2025-11-26T12:00:00Z"); - KoboReadingState.CurrentBookmark bookmark = builder.buildBookmarkFromProgress(progress, defaultTime); + KoboReadingState.CurrentBookmark bookmark = builder.buildBookmarkFromProgress(progress, null, defaultTime); assertNotNull(bookmark); assertEquals(defaultTime.toString(), bookmark.getLastModified()); @@ -237,7 +392,7 @@ void buildBookmarkFromProgress_RoundProgress() { progress.setKoboProgressPercent(33.7f); progress.setKoboProgressReceivedTime(Instant.now()); - KoboReadingState.CurrentBookmark bookmark = builder.buildBookmarkFromProgress(progress); + KoboReadingState.CurrentBookmark bookmark = builder.buildBookmarkFromProgress(progress, (UserBookFileProgressEntity) null); assertEquals(34, bookmark.getProgressPercent()); // Rounded up } @@ -256,13 +411,13 @@ void buildReadingStateFromProgress() { UserBookProgressEntity progress = new UserBookProgressEntity(); progress.setBook(book); progress.setKoboProgressPercent(75f); - progress.setKoboLocation("epubcfi(/6/4!)"); - progress.setKoboLocationType("EpubCfi"); - progress.setKoboLocationSource("Kobo"); + progress.setKoboLocation("kobo.1.1"); + progress.setKoboLocationType("KoboSpan"); + progress.setKoboLocationSource("OEBPS/CoverImage.xhtml"); progress.setKoboProgressReceivedTime(Instant.parse("2025-11-26T10:00:00Z")); progress.setReadStatus(ReadStatus.READING); - KoboReadingState state = builder.buildReadingStateFromProgress("100", progress); + KoboReadingState state = builder.buildReadingStateFromProgress("100", progress, null); assertNotNull(state); assertEquals("100", state.getEntitlementId()); diff --git a/booklore-api/src/test/java/org/booklore/service/KoboReadingStateServiceTest.java b/booklore-api/src/test/java/org/booklore/service/KoboReadingStateServiceTest.java index aacb9b3bf..07f45e0e3 100644 --- a/booklore-api/src/test/java/org/booklore/service/KoboReadingStateServiceTest.java +++ b/booklore-api/src/test/java/org/booklore/service/KoboReadingStateServiceTest.java @@ -9,6 +9,7 @@ import org.booklore.model.entity.BookLoreUserEntity; import org.booklore.model.entity.KoboReadingStateEntity; import org.booklore.model.entity.UserBookProgressEntity; +import org.booklore.model.enums.BookFileType; import org.booklore.model.enums.KoboReadStatus; import org.booklore.model.enums.ReadStatus; import org.booklore.repository.BookRepository; @@ -112,6 +113,15 @@ void setUp() { lenient().when(mapper.cleanString(any())).thenCallRealMethod(); } + private BookFileEntity setPrimaryEpub(Long bookFileId) { + BookFileEntity primaryFile = new BookFileEntity(); + primaryFile.setId(bookFileId); + primaryFile.setBook(testBook); + primaryFile.setBookType(BookFileType.EPUB); + testBook.setBookFiles(List.of(primaryFile)); + return primaryFile; + } + @Test @DisplayName("Should not overwrite existing finished date when syncing completed book") void testSyncKoboProgressToUserBookProgress_PreserveExistingFinishedDate() { @@ -248,9 +258,9 @@ void testGetReadingState_ConstructFromProgress() { String entitlementId = "100"; UserBookProgressEntity progress = new UserBookProgressEntity(); progress.setKoboProgressPercent(75.5f); - progress.setKoboLocation("epubcfi(/6/4[chap01ref]!/4/2/1:3)"); - progress.setKoboLocationType("EpubCfi"); - progress.setKoboLocationSource("Kobo"); + progress.setKoboLocation("kobo.12.18"); + progress.setKoboLocationType("KoboSpan"); + progress.setKoboLocationSource("OEBPS/chapter3.xhtml"); progress.setKoboProgressReceivedTime(Instant.now()); KoboReadingState expectedState = KoboReadingState.builder() @@ -258,9 +268,9 @@ void testGetReadingState_ConstructFromProgress() { .currentBookmark(KoboReadingState.CurrentBookmark.builder() .progressPercent(75) .location(KoboReadingState.CurrentBookmark.Location.builder() - .value("epubcfi(/6/4[chap01ref]!/4/2/1:3)") - .type("EpubCfi") - .source("Kobo") + .value("kobo.12.18") + .type("KoboSpan") + .source("OEBPS/chapter3.xhtml") .build()) .build()) .build(); @@ -268,25 +278,26 @@ void testGetReadingState_ConstructFromProgress() { when(repository.findByEntitlementIdAndUserId(entitlementId, 1L)).thenReturn(Optional.empty()); when(authenticationService.getAuthenticatedUser()).thenReturn(testUser); when(progressRepository.findByUserIdAndBookId(1L, 100L)).thenReturn(Optional.of(progress)); - when(readingStateBuilder.buildReadingStateFromProgress(entitlementId, progress)).thenReturn(expectedState); + when(bookRepository.findById(100L)).thenReturn(Optional.of(testBook)); + when(readingStateBuilder.buildReadingStateFromProgress(eq(entitlementId), eq(progress), isNull())).thenReturn(expectedState); List result = service.getReadingState(entitlementId); assertNotNull(result); assertEquals(1, result.size()); - + KoboReadingState state = result.getFirst(); assertEquals(entitlementId, state.getEntitlementId()); assertNotNull(state.getCurrentBookmark()); assertEquals(75, state.getCurrentBookmark().getProgressPercent()); assertNotNull(state.getCurrentBookmark().getLocation()); - assertEquals("epubcfi(/6/4[chap01ref]!/4/2/1:3)", state.getCurrentBookmark().getLocation().getValue()); - assertEquals("EpubCfi", state.getCurrentBookmark().getLocation().getType()); - assertEquals("Kobo", state.getCurrentBookmark().getLocation().getSource()); - + assertEquals("kobo.12.18", state.getCurrentBookmark().getLocation().getValue()); + assertEquals("KoboSpan", state.getCurrentBookmark().getLocation().getType()); + assertEquals("OEBPS/chapter3.xhtml", state.getCurrentBookmark().getLocation().getSource()); + verify(repository).findByEntitlementIdAndUserId(entitlementId, 1L); verify(progressRepository, atLeastOnce()).findByUserIdAndBookId(1L, 100L); - verify(readingStateBuilder).buildReadingStateFromProgress(entitlementId, progress); + verify(readingStateBuilder).buildReadingStateFromProgress(eq(entitlementId), eq(progress), isNull()); } @Test @@ -369,7 +380,7 @@ void testSyncKoboProgressToUserBookProgress_NullBookmark() { UserBookProgressEntity savedProgress = progressCaptor.getValue(); assertNull(savedProgress.getKoboProgressPercent()); - assertNotNull(savedProgress.getKoboProgressReceivedTime()); + assertNull(savedProgress.getKoboProgressReceivedTime()); } @Test @@ -556,6 +567,7 @@ void testDeleteReadingState_notFound() { void testGetReadingState_overlayWebReaderBookmark() { testSettings.setTwoWayProgressSync(true); String entitlementId = "100"; + BookFileEntity primaryFile = setPrimaryEpub(10L); KoboReadingState existingState = KoboReadingState.builder() .entitlementId(entitlementId) @@ -563,9 +575,9 @@ void testGetReadingState_overlayWebReaderBookmark() { .progressPercent(30) .contentSourceProgressPercent(45) .location(KoboReadingState.CurrentBookmark.Location.builder() - .value("epubcfi(/6/10)") - .type("EpubCfi") - .source("Kobo") + .value("kobo.1.1") + .type("KoboSpan") + .source("OEBPS/CoverImage.xhtml") .build()) .lastModified("2025-01-01T00:00:00Z") .build()) @@ -580,21 +592,23 @@ void testGetReadingState_overlayWebReaderBookmark() { progress.setEpubProgressPercent(65f); progress.setLastReadTime(Instant.parse("2025-06-01T10:00:00Z")); when(progressRepository.findByUserIdAndBookId(1L, 100L)).thenReturn(Optional.of(progress)); + when(bookRepository.findById(100L)).thenReturn(Optional.of(testBook)); + when(fileProgressRepository.findByUserIdAndBookFileId(1L, primaryFile.getId())).thenReturn(Optional.empty()); KoboReadingState.CurrentBookmark webBookmark = KoboReadingState.CurrentBookmark.builder() .progressPercent(65) .lastModified("2025-06-01T10:00:00Z") .build(); - when(readingStateBuilder.buildBookmarkFromProgress(progress)).thenReturn(webBookmark); + when(readingStateBuilder.shouldUseWebReaderProgress(progress)).thenReturn(true); + when(readingStateBuilder.buildBookmarkFromProgress(progress, (UserBookFileProgressEntity) null)).thenReturn(webBookmark); List result = service.getReadingState(entitlementId); assertEquals(1, result.size()); KoboReadingState.CurrentBookmark bookmark = result.getFirst().getCurrentBookmark(); assertEquals(65, bookmark.getProgressPercent()); - assertEquals(45, bookmark.getContentSourceProgressPercent()); - assertNotNull(bookmark.getLocation()); - assertEquals("epubcfi(/6/10)", bookmark.getLocation().getValue()); + assertNull(bookmark.getContentSourceProgressPercent()); + assertNull(bookmark.getLocation()); } @Test @@ -619,30 +633,142 @@ void testGetReadingState_noOverlayWhenToggleOff() { assertEquals(1, result.size()); assertEquals(30, result.getFirst().getCurrentBookmark().getProgressPercent()); - verify(readingStateBuilder, never()).buildBookmarkFromProgress(any()); + verify(readingStateBuilder, never()).buildBookmarkFromProgress(any(), any()); } @Test - @DisplayName("Should cross-populate epub fields from Kobo when two-way sync is ON") + @DisplayName("Should overlay web reader location when available") + void testGetReadingState_overlayWebReaderBookmark_replacesLocation() { + testSettings.setTwoWayProgressSync(true); + String entitlementId = "100"; + BookFileEntity primaryFile = setPrimaryEpub(10L); + + KoboReadingState existingState = KoboReadingState.builder() + .entitlementId(entitlementId) + .currentBookmark(KoboReadingState.CurrentBookmark.builder() + .progressPercent(30) + .location(KoboReadingState.CurrentBookmark.Location.builder() + .value("kobo.1.1") + .type("KoboSpan") + .source("old.xhtml") + .build()) + .lastModified("2025-01-01T00:00:00Z") + .build()) + .build(); + + KoboReadingStateEntity entity = new KoboReadingStateEntity(); + when(repository.findByEntitlementIdAndUserId(entitlementId, 1L)).thenReturn(Optional.of(entity)); + when(mapper.toDto(entity)).thenReturn(existingState); + + UserBookProgressEntity progress = new UserBookProgressEntity(); + progress.setEpubProgress("epubcfi(/6/20)"); + progress.setEpubProgressPercent(65f); + progress.setLastReadTime(Instant.parse("2025-06-01T10:00:00Z")); + when(progressRepository.findByUserIdAndBookId(1L, 100L)).thenReturn(Optional.of(progress)); + when(bookRepository.findById(100L)).thenReturn(Optional.of(testBook)); + + UserBookFileProgressEntity fileProgress = new UserBookFileProgressEntity(); + fileProgress.setContentSourceProgressPercent(22f); + when(fileProgressRepository.findByUserIdAndBookFileId(1L, primaryFile.getId())).thenReturn(Optional.of(fileProgress)); + + KoboReadingState.CurrentBookmark webBookmark = KoboReadingState.CurrentBookmark.builder() + .progressPercent(65) + .contentSourceProgressPercent(22) + .location(KoboReadingState.CurrentBookmark.Location.builder() + .value("kobo.12.18") + .type("KoboSpan") + .source("new.xhtml") + .build()) + .lastModified("2025-06-01T10:00:00Z") + .build(); + when(readingStateBuilder.shouldUseWebReaderProgress(progress)).thenReturn(true); + when(readingStateBuilder.buildBookmarkFromProgress(progress, fileProgress)).thenReturn(webBookmark); + + List result = service.getReadingState(entitlementId); + + assertEquals(1, result.size()); + KoboReadingState.CurrentBookmark bookmark = result.getFirst().getCurrentBookmark(); + assertEquals(65, bookmark.getProgressPercent()); + assertEquals(22, bookmark.getContentSourceProgressPercent()); + assertNotNull(bookmark.getLocation()); + assertEquals("kobo.12.18", bookmark.getLocation().getValue()); + assertEquals("new.xhtml", bookmark.getLocation().getSource()); + } + + @Test + @DisplayName("Should keep stored Kobo bookmark when mirrored EPUB progress is not newer") + void testGetReadingState_preservesStoredKoboBookmarkWhenKoboIsFreshest() { + testSettings.setTwoWayProgressSync(true); + String entitlementId = "100"; + BookFileEntity primaryFile = setPrimaryEpub(10L); + + KoboReadingState.CurrentBookmark existingBookmark = KoboReadingState.CurrentBookmark.builder() + .progressPercent(55) + .contentSourceProgressPercent(23) + .location(KoboReadingState.CurrentBookmark.Location.builder() + .value("kobo.12.18") + .type("KoboSpan") + .source("OEBPS/chapter3.xhtml") + .build()) + .lastModified("2025-06-15T12:00:00Z") + .build(); + + KoboReadingState existingState = KoboReadingState.builder() + .entitlementId(entitlementId) + .currentBookmark(existingBookmark) + .build(); + + KoboReadingStateEntity entity = new KoboReadingStateEntity(); + when(repository.findByEntitlementIdAndUserId(entitlementId, 1L)).thenReturn(Optional.of(entity)); + when(mapper.toDto(entity)).thenReturn(existingState); + + UserBookProgressEntity progress = new UserBookProgressEntity(); + progress.setKoboProgressPercent(55f); + progress.setKoboLocation("kobo.12.18"); + progress.setKoboLocationType("KoboSpan"); + progress.setKoboLocationSource("OEBPS/chapter3.xhtml"); + progress.setKoboProgressReceivedTime(Instant.parse("2025-06-15T12:00:00Z")); + progress.setEpubProgressPercent(55f); + progress.setLastReadTime(Instant.parse("2025-06-15T12:00:00Z")); + when(progressRepository.findByUserIdAndBookId(1L, 100L)).thenReturn(Optional.of(progress)); + when(bookRepository.findById(100L)).thenReturn(Optional.of(testBook)); + when(fileProgressRepository.findByUserIdAndBookFileId(1L, primaryFile.getId())).thenReturn(Optional.empty()); + when(readingStateBuilder.shouldUseWebReaderProgress(progress)).thenReturn(false); + + List result = service.getReadingState(entitlementId); + + assertEquals(1, result.size()); + KoboReadingState.CurrentBookmark bookmark = result.getFirst().getCurrentBookmark(); + assertEquals(55, bookmark.getProgressPercent()); + assertEquals(23, bookmark.getContentSourceProgressPercent()); + assertNotNull(bookmark.getLocation()); + assertEquals("kobo.12.18", bookmark.getLocation().getValue()); + assertEquals("OEBPS/chapter3.xhtml", bookmark.getLocation().getSource()); + verify(readingStateBuilder).shouldUseWebReaderProgress(progress); + verify(readingStateBuilder, never()).buildBookmarkFromProgress(eq(progress), any()); + } + + @Test + @DisplayName("Should cross-populate chapter-aware EPUB fields from Kobo when two-way sync is ON") void testSyncKoboProgress_crossPopulateEpubFields() { testSettings.setTwoWayProgressSync(true); String entitlementId = "100"; - BookFileEntity primaryFile = BookFileEntity.builder().id(10L).build(); - testBook.setBookFiles(List.of(primaryFile)); + BookFileEntity primaryFile = setPrimaryEpub(10L); UserBookProgressEntity existingProgress = new UserBookProgressEntity(); existingProgress.setUser(testUserEntity); existingProgress.setBook(testBook); KoboReadingState.CurrentBookmark.Location location = KoboReadingState.CurrentBookmark.Location.builder() - .value("epubcfi(/6/8)") - .type("EpubCfi") - .source("chapter3.xhtml") + .value("kobo.12.18") + .type("KoboSpan") + .source("OEBPS/chapter3.xhtml") .build(); KoboReadingState.CurrentBookmark bookmark = KoboReadingState.CurrentBookmark.builder() .progressPercent(55) + .contentSourceProgressPercent(23) .location(location) .lastModified("2025-06-15T12:00:00Z") .build(); @@ -660,17 +786,30 @@ void testSyncKoboProgress_crossPopulateEpubFields() { when(bookRepository.findById(100L)).thenReturn(Optional.of(testBook)); when(userRepository.findById(1L)).thenReturn(Optional.of(testUserEntity)); when(progressRepository.findByUserIdAndBookId(1L, 100L)).thenReturn(Optional.of(existingProgress)); - when(fileProgressRepository.findByUserIdAndBookFileId(1L, 10L)).thenReturn(Optional.empty()); + UserBookFileProgressEntity fileProgress = new UserBookFileProgressEntity(); + fileProgress.setUser(testUserEntity); + fileProgress.setBookFile(primaryFile); + when(fileProgressRepository.findByUserIdAndBookFileId(1L, 10L)).thenReturn(Optional.of(fileProgress)); ArgumentCaptor captor = ArgumentCaptor.forClass(UserBookProgressEntity.class); when(progressRepository.save(captor.capture())).thenReturn(existingProgress); + ArgumentCaptor fileProgressCaptor = + ArgumentCaptor.forClass(UserBookFileProgressEntity.class); service.saveReadingState(List.of(readingState)); UserBookProgressEntity saved = captor.getValue(); assertEquals(55f, saved.getEpubProgressPercent()); - assertEquals("chapter3.xhtml", saved.getEpubProgressHref()); assertNull(saved.getEpubProgress()); + assertEquals("OEBPS/chapter3.xhtml", saved.getEpubProgressHref()); + assertEquals(Instant.parse("2025-06-15T12:00:00Z"), saved.getKoboProgressReceivedTime()); + assertEquals(Instant.parse("2025-06-15T12:00:00Z"), saved.getLastReadTime()); + verify(fileProgressRepository).save(fileProgressCaptor.capture()); + UserBookFileProgressEntity savedFileProgress = fileProgressCaptor.getValue(); + assertNull(savedFileProgress.getPositionData()); + assertEquals("OEBPS/chapter3.xhtml", savedFileProgress.getPositionHref()); + assertEquals(23f, savedFileProgress.getContentSourceProgressPercent()); + assertEquals(Instant.parse("2025-06-15T12:00:00Z"), savedFileProgress.getLastReadTime()); } @Test @@ -711,14 +850,135 @@ void testSyncKoboProgress_noCrossPopulateWhenToggleOff() { assertEquals("epubcfi(/6/4)", saved.getEpubProgress()); } + @Test + @DisplayName("Should clear stale EPUB CFI but keep chapter source when Kobo sends newer KoboSpan") + void testSyncKoboProgress_clearEpubCfiWhenKoboSendsKoboSpan() { + testSettings.setTwoWayProgressSync(true); + String entitlementId = "100"; + + BookFileEntity primaryFile = setPrimaryEpub(10L); + + UserBookProgressEntity existingProgress = new UserBookProgressEntity(); + existingProgress.setUser(testUserEntity); + existingProgress.setBook(testBook); + existingProgress.setEpubProgressPercent(55f); + existingProgress.setEpubProgress("epubcfi(/6/8!/4/2/6/1:15)"); + existingProgress.setEpubProgressHref("OPS/chapter3.xhtml"); + + UserBookFileProgressEntity fileProgress = new UserBookFileProgressEntity(); + fileProgress.setUser(testUserEntity); + fileProgress.setBookFile(primaryFile); + fileProgress.setPositionData("epubcfi(/6/8!/4/2/6/1:15)"); + fileProgress.setPositionHref("OPS/chapter3.xhtml"); + + KoboReadingState.CurrentBookmark.Location location = KoboReadingState.CurrentBookmark.Location.builder() + .value("kobo.12.18") + .type("KoboSpan") + .source("OEBPS/OPS/chapter3.xhtml") + .build(); + + KoboReadingState.CurrentBookmark bookmark = KoboReadingState.CurrentBookmark.builder() + .progressPercent(55) + .contentSourceProgressPercent(23) + .location(location) + .lastModified("2025-06-15T12:00:00Z") + .build(); + + KoboReadingState readingState = KoboReadingState.builder() + .entitlementId(entitlementId) + .currentBookmark(bookmark) + .build(); + + KoboReadingStateEntity entity = new KoboReadingStateEntity(); + when(mapper.toEntity(any())).thenReturn(entity); + when(mapper.toDto(any(KoboReadingStateEntity.class))).thenReturn(readingState); + when(repository.findByEntitlementIdAndUserId(entitlementId, 1L)).thenReturn(Optional.empty()); + when(repository.save(any())).thenReturn(entity); + when(bookRepository.findById(100L)).thenReturn(Optional.of(testBook)); + when(userRepository.findById(1L)).thenReturn(Optional.of(testUserEntity)); + when(progressRepository.findByUserIdAndBookId(1L, 100L)).thenReturn(Optional.of(existingProgress)); + when(fileProgressRepository.findByUserIdAndBookFileId(1L, primaryFile.getId())).thenReturn(Optional.of(fileProgress)); + + ArgumentCaptor progressCaptor = ArgumentCaptor.forClass(UserBookProgressEntity.class); + when(progressRepository.save(progressCaptor.capture())).thenReturn(existingProgress); + ArgumentCaptor fileProgressCaptor = + ArgumentCaptor.forClass(UserBookFileProgressEntity.class); + + service.saveReadingState(List.of(readingState)); + + UserBookProgressEntity savedProgress = progressCaptor.getValue(); + assertNull(savedProgress.getEpubProgress()); + assertEquals("OEBPS/OPS/chapter3.xhtml", savedProgress.getEpubProgressHref()); + + verify(fileProgressRepository).save(fileProgressCaptor.capture()); + UserBookFileProgressEntity savedFileProgress = fileProgressCaptor.getValue(); + assertNull(savedFileProgress.getPositionData()); + assertEquals("OEBPS/OPS/chapter3.xhtml", savedFileProgress.getPositionHref()); + assertEquals(23f, savedFileProgress.getContentSourceProgressPercent()); + } + + @Test + @DisplayName("Should clear stale chapter progress when Kobo bookmark omits content source progress") + void testSyncKoboProgress_clearStaleContentSourceProgressPercent() { + testSettings.setTwoWayProgressSync(true); + String entitlementId = "100"; + + BookFileEntity primaryFile = setPrimaryEpub(10L); + + UserBookProgressEntity existingProgress = new UserBookProgressEntity(); + existingProgress.setUser(testUserEntity); + existingProgress.setBook(testBook); + + UserBookFileProgressEntity fileProgress = new UserBookFileProgressEntity(); + fileProgress.setUser(testUserEntity); + fileProgress.setBookFile(primaryFile); + fileProgress.setContentSourceProgressPercent(23f); + fileProgress.setPositionHref("OPS/chapter3.xhtml"); + + KoboReadingState.CurrentBookmark.Location location = KoboReadingState.CurrentBookmark.Location.builder() + .value("kobo.12.18") + .type("KoboSpan") + .source("OEBPS/OPS/chapter3.xhtml") + .build(); + + KoboReadingState.CurrentBookmark bookmark = KoboReadingState.CurrentBookmark.builder() + .progressPercent(55) + .location(location) + .lastModified("2025-06-15T12:00:00Z") + .build(); + + KoboReadingState readingState = KoboReadingState.builder() + .entitlementId(entitlementId) + .currentBookmark(bookmark) + .build(); + + KoboReadingStateEntity entity = new KoboReadingStateEntity(); + when(mapper.toEntity(any())).thenReturn(entity); + when(mapper.toDto(any(KoboReadingStateEntity.class))).thenReturn(readingState); + when(repository.findByEntitlementIdAndUserId(entitlementId, 1L)).thenReturn(Optional.empty()); + when(repository.save(any())).thenReturn(entity); + when(bookRepository.findById(100L)).thenReturn(Optional.of(testBook)); + when(userRepository.findById(1L)).thenReturn(Optional.of(testUserEntity)); + when(progressRepository.findByUserIdAndBookId(1L, 100L)).thenReturn(Optional.of(existingProgress)); + when(progressRepository.save(any())).thenReturn(existingProgress); + when(fileProgressRepository.findByUserIdAndBookFileId(1L, primaryFile.getId())).thenReturn(Optional.of(fileProgress)); + + ArgumentCaptor fileProgressCaptor = + ArgumentCaptor.forClass(UserBookFileProgressEntity.class); + + service.saveReadingState(List.of(readingState)); + + verify(fileProgressRepository).save(fileProgressCaptor.capture()); + assertNull(fileProgressCaptor.getValue().getContentSourceProgressPercent()); + } + @Test @DisplayName("Should not cross-populate when web reader has newer progress") void testSyncKoboProgress_skipCrossPopulateWhenWebReaderNewer() { testSettings.setTwoWayProgressSync(true); String entitlementId = "100"; - BookFileEntity primaryFile = BookFileEntity.builder().id(10L).build(); - testBook.setBookFiles(List.of(primaryFile)); + BookFileEntity primaryFile = setPrimaryEpub(10L); UserBookFileProgressEntity fileProgress = new UserBookFileProgressEntity(); fileProgress.setLastReadTime(Instant.now().plusSeconds(3600)); @@ -728,6 +988,7 @@ void testSyncKoboProgress_skipCrossPopulateWhenWebReaderNewer() { existingProgress.setBook(testBook); existingProgress.setEpubProgressPercent(80f); existingProgress.setEpubProgress("epubcfi(/6/20)"); + existingProgress.setReadStatus(ReadStatus.READING); KoboReadingState readingState = KoboReadingState.builder() .entitlementId(entitlementId) @@ -755,6 +1016,9 @@ void testSyncKoboProgress_skipCrossPopulateWhenWebReaderNewer() { UserBookProgressEntity saved = captor.getValue(); assertEquals(80f, saved.getEpubProgressPercent()); assertEquals("epubcfi(/6/20)", saved.getEpubProgress()); + assertEquals(ReadStatus.READING, saved.getReadStatus()); + assertEquals(Instant.parse("2025-01-01T00:00:00Z"), saved.getKoboProgressReceivedTime()); + verify(hardcoverSyncService, never()).syncProgressToHardcover(any(), any(), any()); } @Test @@ -778,18 +1042,24 @@ void testGetReadingState_constructFromWebReaderProgress_whenToggleOn() { when(repository.findByEntitlementIdAndUserId(entitlementId, 1L)).thenReturn(Optional.empty()); when(progressRepository.findByUserIdAndBookId(1L, 100L)).thenReturn(Optional.of(progress)); - when(readingStateBuilder.buildReadingStateFromProgress(entitlementId, progress)).thenReturn(expectedState); + BookFileEntity primaryFile = setPrimaryEpub(10L); + when(bookRepository.findById(100L)).thenReturn(Optional.of(testBook)); + UserBookFileProgressEntity fileProgress = new UserBookFileProgressEntity(); + fileProgress.setContentSourceProgressPercent(35f); + when(fileProgressRepository.findByUserIdAndBookFileId(1L, primaryFile.getId())).thenReturn(Optional.of(fileProgress)); + when(readingStateBuilder.buildReadingStateFromProgress(entitlementId, progress, fileProgress)).thenReturn(expectedState); KoboReadingState.CurrentBookmark webBookmark = KoboReadingState.CurrentBookmark.builder() .progressPercent(60) .lastModified("2025-06-01T10:00:00Z") .build(); - when(readingStateBuilder.buildBookmarkFromProgress(progress)).thenReturn(webBookmark); + when(readingStateBuilder.shouldUseWebReaderProgress(progress)).thenReturn(true); + when(readingStateBuilder.buildBookmarkFromProgress(progress, fileProgress)).thenReturn(webBookmark); List result = service.getReadingState(entitlementId); assertEquals(1, result.size()); - verify(readingStateBuilder).buildReadingStateFromProgress(entitlementId, progress); + verify(readingStateBuilder).buildReadingStateFromProgress(entitlementId, progress, fileProgress); } @Test @@ -809,7 +1079,7 @@ void testGetReadingState_noConstructFromWebReaderProgress_whenToggleOff() { List result = service.getReadingState(entitlementId); assertTrue(result.isEmpty()); - verify(readingStateBuilder, never()).buildReadingStateFromProgress(any(), any()); + verify(readingStateBuilder, never()).buildReadingStateFromProgress(any(), any(), any()); } @Test @@ -818,9 +1088,9 @@ void testSyncKoboProgress_locationData() { String entitlementId = "100"; KoboReadingState.CurrentBookmark.Location location = KoboReadingState.CurrentBookmark.Location.builder() - .value("epubcfi(/6/4[chap01ref]!/4/2/1:3)") - .type("EpubCfi") - .source("Kobo") + .value("kobo.12.18") + .type("KoboSpan") + .source("OEBPS/chapter3.xhtml") .build(); KoboReadingState readingState = KoboReadingState.builder() @@ -846,9 +1116,9 @@ void testSyncKoboProgress_locationData() { service.saveReadingState(List.of(readingState)); UserBookProgressEntity saved = captor.getValue(); - assertEquals("epubcfi(/6/4[chap01ref]!/4/2/1:3)", saved.getKoboLocation()); - assertEquals("EpubCfi", saved.getKoboLocationType()); - assertEquals("Kobo", saved.getKoboLocationSource()); + assertEquals("kobo.12.18", saved.getKoboLocation()); + assertEquals("KoboSpan", saved.getKoboLocationType()); + assertEquals("OEBPS/chapter3.xhtml", saved.getKoboLocationSource()); assertEquals(42f, saved.getKoboProgressPercent()); } diff --git a/booklore-api/src/test/java/org/booklore/service/kobo/KoboBookmarkLocationResolverTest.java b/booklore-api/src/test/java/org/booklore/service/kobo/KoboBookmarkLocationResolverTest.java new file mode 100644 index 000000000..c4fac89b6 --- /dev/null +++ b/booklore-api/src/test/java/org/booklore/service/kobo/KoboBookmarkLocationResolverTest.java @@ -0,0 +1,242 @@ +package org.booklore.service.kobo; + +import org.booklore.model.dto.kobo.KoboSpanPositionMap; +import org.booklore.model.entity.BookEntity; +import org.booklore.model.entity.BookFileEntity; +import org.booklore.model.entity.LibraryPathEntity; +import org.booklore.model.entity.UserBookFileProgressEntity; +import org.booklore.model.entity.UserBookProgressEntity; +import org.booklore.model.enums.BookFileType; +import org.booklore.util.koreader.EpubCfiService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.nio.file.Path; +import java.util.List; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class KoboBookmarkLocationResolverTest { + + @Mock + private KoboSpanMapService koboSpanMapService; + + @Mock + private EpubCfiService epubCfiService; + + private KoboBookmarkLocationResolver resolver; + + @BeforeEach + void setUp() { + resolver = new KoboBookmarkLocationResolver(koboSpanMapService, epubCfiService); + } + + @Test + void resolve_UsesFirstKoboSpanWhenOnlyHrefIsAvailable() { + BookFileEntity bookFile = createBookFile(); + when(koboSpanMapService.getValidMap(bookFile)).thenReturn(Optional.of(singleChapterMap())); + + UserBookFileProgressEntity fileProgress = new UserBookFileProgressEntity(); + fileProgress.setBookFile(bookFile); + fileProgress.setPositionHref("chapter1.xhtml"); + + UserBookProgressEntity progress = new UserBookProgressEntity(); + progress.setEpubProgressHref("chapter1.xhtml"); + + Optional result = + resolver.resolve(progress, fileProgress); + + assertTrue(result.isPresent()); + assertEquals("kobo.1.1", result.get().value()); + assertEquals("KoboSpan", result.get().type()); + assertEquals("OPS/chapter1.xhtml", result.get().source()); + assertNull(result.get().contentSourceProgressPercent()); + } + + @Test + void resolve_UsesStoredContentSourceProgressPercentToSelectNearestKoboSpan() { + BookFileEntity bookFile = createBookFile(); + when(koboSpanMapService.getValidMap(bookFile)).thenReturn(Optional.of(singleChapterMap())); + + UserBookFileProgressEntity fileProgress = new UserBookFileProgressEntity(); + fileProgress.setBookFile(bookFile); + fileProgress.setPositionHref("chapter1.xhtml"); + fileProgress.setContentSourceProgressPercent(80f); + + UserBookProgressEntity progress = new UserBookProgressEntity(); + progress.setEpubProgressHref("chapter1.xhtml"); + + Optional result = + resolver.resolve(progress, fileProgress); + + assertTrue(result.isPresent()); + assertEquals("kobo.1.2", result.get().value()); + assertEquals("KoboSpan", result.get().type()); + assertEquals("OPS/chapter1.xhtml", result.get().source()); + assertEquals(80f, result.get().contentSourceProgressPercent()); + } + + @Test + void resolve_FallsBackToPrimaryEpubWhenFileProgressIsMissing() { + BookFileEntity bookFile = createBookFile(); + when(koboSpanMapService.getValidMap(bookFile)).thenReturn(Optional.of(singleChapterMap())); + + UserBookProgressEntity progress = new UserBookProgressEntity(); + progress.setBook(bookFile.getBook()); + progress.setEpubProgressHref("chapter1.xhtml"); + + Optional result = + resolver.resolve(progress, null); + + assertTrue(result.isPresent()); + assertEquals("kobo.1.1", result.get().value()); + assertEquals("OPS/chapter1.xhtml", result.get().source()); + assertNull(result.get().contentSourceProgressPercent()); + } + + @Test + void resolve_UsesGlobalProgressWhenHrefIsMissing() { + BookFileEntity bookFile = createBookFile(); + when(koboSpanMapService.getValidMap(bookFile)).thenReturn(Optional.of(twoChapterMap())); + + UserBookFileProgressEntity fileProgress = new UserBookFileProgressEntity(); + fileProgress.setBookFile(bookFile); + fileProgress.setProgressPercent(75f); + + UserBookProgressEntity progress = new UserBookProgressEntity(); + progress.setEpubProgressPercent(75f); + + Optional result = + resolver.resolve(progress, fileProgress); + + assertTrue(result.isPresent()); + assertEquals("kobo.2.2", result.get().value()); + assertEquals("OPS/chapter2.xhtml", result.get().source()); + assertEquals(50f, result.get().contentSourceProgressPercent()); + } + + @Test + void resolve_UsesCfiDerivedChapterPositionWhenStoredChapterProgressIsMissing() { + BookFileEntity bookFile = createBookFile(); + when(koboSpanMapService.getValidMap(bookFile)).thenReturn(Optional.of(singleChapterMap())); + when(epubCfiService.resolveCfiLocation(Path.of("/library/book.epub"), "epubcfi(/6/2!/4/2/2:15)")) + .thenReturn(Optional.of(new EpubCfiService.CfiLocation("chapter1.xhtml", 80f))); + + UserBookFileProgressEntity fileProgress = new UserBookFileProgressEntity(); + fileProgress.setBookFile(bookFile); + fileProgress.setPositionData("epubcfi(/6/2!/4/2/2:15)"); + + UserBookProgressEntity progress = new UserBookProgressEntity(); + progress.setEpubProgress("epubcfi(/6/2!/4/2/2:15)"); + + Optional result = + resolver.resolve(progress, fileProgress); + + assertTrue(result.isPresent()); + assertEquals("kobo.1.2", result.get().value()); + assertEquals("OPS/chapter1.xhtml", result.get().source()); + assertEquals(80f, result.get().contentSourceProgressPercent()); + } + + @Test + void resolve_PrefersExactHrefMatchOverSuffixMatch() { + BookFileEntity bookFile = createBookFile(); + when(koboSpanMapService.getValidMap(bookFile)).thenReturn(Optional.of(new KoboSpanPositionMap(List.of( + new KoboSpanPositionMap.Chapter( + "OPS/chapter3.xhtml", + "OPS/chapter3.xhtml", + 0, + 0f, + 0.5f, + List.of(new KoboSpanPositionMap.Span("exact-span", 0.2f))), + new KoboSpanPositionMap.Chapter( + "OEBPS/OPS/chapter3.xhtml", + "OEBPS/OPS/chapter3.xhtml", + 1, + 0.5f, + 1f, + List.of(new KoboSpanPositionMap.Span("suffix-span", 0.2f))) + )))); + + UserBookFileProgressEntity fileProgress = new UserBookFileProgressEntity(); + fileProgress.setBookFile(bookFile); + fileProgress.setPositionHref("OPS/chapter3.xhtml"); + + UserBookProgressEntity progress = new UserBookProgressEntity(); + progress.setEpubProgressHref("OPS/chapter3.xhtml"); + + Optional result = + resolver.resolve(progress, fileProgress); + + assertTrue(result.isPresent()); + assertEquals("exact-span", result.get().value()); + assertEquals("OPS/chapter3.xhtml", result.get().source()); + } + + private BookFileEntity createBookFile() { + BookEntity book = new BookEntity(); + LibraryPathEntity libraryPath = new LibraryPathEntity(); + libraryPath.setPath("/library"); + book.setLibraryPath(libraryPath); + + BookFileEntity bookFile = new BookFileEntity(); + bookFile.setId(10L); + bookFile.setBook(book); + bookFile.setBookType(BookFileType.EPUB); + bookFile.setCurrentHash("hash-123"); + bookFile.setFileName("book.epub"); + bookFile.setFileSubPath(""); + book.setBookFiles(List.of(bookFile)); + + return bookFile; + } + + private KoboSpanPositionMap singleChapterMap() { + return new KoboSpanPositionMap(List.of( + new KoboSpanPositionMap.Chapter( + "OPS/chapter1.xhtml", + "OPS/chapter1.xhtml", + 0, + 0f, + 1f, + List.of( + new KoboSpanPositionMap.Span("kobo.1.1", 0.2f), + new KoboSpanPositionMap.Span("kobo.1.2", 0.85f) + )) + )); + } + + private KoboSpanPositionMap twoChapterMap() { + return new KoboSpanPositionMap(List.of( + new KoboSpanPositionMap.Chapter( + "OPS/chapter1.xhtml", + "OPS/chapter1.xhtml", + 0, + 0f, + 0.5f, + List.of( + new KoboSpanPositionMap.Span("kobo.1.1", 0.1f), + new KoboSpanPositionMap.Span("kobo.1.2", 0.9f) + )), + new KoboSpanPositionMap.Chapter( + "OPS/chapter2.xhtml", + "OPS/chapter2.xhtml", + 1, + 0.5f, + 1f, + List.of( + new KoboSpanPositionMap.Span("kobo.2.1", 0.1f), + new KoboSpanPositionMap.Span("kobo.2.2", 0.5f), + new KoboSpanPositionMap.Span("kobo.2.3", 0.9f) + )) + )); + } +} diff --git a/booklore-api/src/test/java/org/booklore/service/kobo/KoboCompatibilityServiceTest.java b/booklore-api/src/test/java/org/booklore/service/kobo/KoboCompatibilityServiceTest.java index 4f86d4b1c..e615353f9 100644 --- a/booklore-api/src/test/java/org/booklore/service/kobo/KoboCompatibilityServiceTest.java +++ b/booklore-api/src/test/java/org/booklore/service/kobo/KoboCompatibilityServiceTest.java @@ -35,7 +35,6 @@ class KoboCompatibilityServiceTest { @BeforeEach void setUp() { koboSettings = KoboSettings.builder() - .convertToKepub(false) .conversionLimitInMb(100) .convertCbxToEpub(false) .conversionLimitInMbForCbx(50) diff --git a/booklore-api/src/test/java/org/booklore/service/kobo/KoboLibrarySyncServiceTest.java b/booklore-api/src/test/java/org/booklore/service/kobo/KoboLibrarySyncServiceTest.java index b99649d0f..b01f1e56e 100644 --- a/booklore-api/src/test/java/org/booklore/service/kobo/KoboLibrarySyncServiceTest.java +++ b/booklore-api/src/test/java/org/booklore/service/kobo/KoboLibrarySyncServiceTest.java @@ -230,6 +230,21 @@ void needsProgressSync_webReaderNewer() { assertTrue(needsProgressSync(progress)); } + @Test + @DisplayName("Should detect href-only web reader progress when toggle ON and lastReadTime after sent") + void needsProgressSync_webReaderHrefOnly() { + testSettings.setTwoWayProgressSync(true); + + UserBookProgressEntity progress = createProgress(1L); + progress.setEpubProgressHref("OPS/chapter3.xhtml"); + progress.setEpubProgressPercent(65f); + progress.setLastReadTime(Instant.now()); + progress.setKoboProgressSentTime(Instant.now().minusSeconds(60)); + progress.setKoboProgressReceivedTime(Instant.now().minusSeconds(120)); + + assertTrue(needsProgressSync(progress)); + } + @Test @DisplayName("Should not sync web reader progress when toggle OFF") void needsProgressSync_toggleOff() { @@ -286,7 +301,7 @@ private boolean needsProgressSync(UserBookProgressEntity progress) { if (needsKoboProgressSync(progress)) return true; if (testSettings.isTwoWayProgressSync() - && progress.getEpubProgress() != null && progress.getEpubProgressPercent() != null) { + && progress.getEpubProgressPercent() != null) { Instant sentTime = progress.getKoboProgressSentTime(); Instant lastReadTime = progress.getLastReadTime(); if (lastReadTime != null && (sentTime == null || lastReadTime.isAfter(sentTime))) { diff --git a/booklore-api/src/test/java/org/booklore/service/kobo/KoboSpanMapExtractionServiceTest.java b/booklore-api/src/test/java/org/booklore/service/kobo/KoboSpanMapExtractionServiceTest.java new file mode 100644 index 000000000..ade09c87a --- /dev/null +++ b/booklore-api/src/test/java/org/booklore/service/kobo/KoboSpanMapExtractionServiceTest.java @@ -0,0 +1,193 @@ +package org.booklore.service.kobo; + +import org.booklore.model.dto.kobo.KoboSpanPositionMap; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Path; +import java.util.zip.ZipEntry; +import java.util.zip.ZipOutputStream; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class KoboSpanMapExtractionServiceTest { + + private final KoboSpanMapExtractionService service = new KoboSpanMapExtractionService(); + + @TempDir + Path tempDir; + + @Test + void extractFromKepub_ExtractsSpineEntriesAndProgressBoundaries() throws Exception { + File kepubFile = createKepub( + "OPS/package.opf", + """ + + + + + + + + + + + + """, + new ZipResource("OPS/chapter1.xhtml", chapterHtml("kobo.1.1", "kobo.1.2")), + new ZipResource("OPS/chapter2.xhtml", chapterHtml("kobo.2.1", "kobo.2.2")) + ); + + KoboSpanPositionMap result = service.extractFromKepub(kepubFile); + + assertEquals(2, result.chapters().size()); + assertEquals("OPS/chapter1.xhtml", result.chapters().get(0).sourceHref()); + assertEquals("OPS/chapter2.xhtml", result.chapters().get(1).sourceHref()); + assertEquals("kobo.1.1", result.chapters().get(0).spans().get(0).id()); + assertEquals("kobo.2.2", result.chapters().get(1).spans().get(1).id()); + assertEquals(0f, result.chapters().get(0).globalStartProgress(), 0.0001f); + assertTrue(result.chapters().get(0).globalEndProgress() > 0f); + assertEquals(1f, result.chapters().get(1).globalEndProgress(), 0.0001f); + } + + @Test + void extractFromKepub_ResolvesEncodedManifestHrefsToActualEntries() throws Exception { + File kepubFile = createKepub( + "OPS/package.opf", + """ + + + + + + + + + + """, + new ZipResource("OPS/Text/chapter 2.xhtml", chapterHtml("kobo.2.1", "kobo.2.2")) + ); + + KoboSpanPositionMap result = service.extractFromKepub(kepubFile); + + assertEquals(1, result.chapters().size()); + assertEquals("OPS/Text/chapter 2.xhtml", result.chapters().getFirst().sourceHref()); + assertEquals("OPS/Text/chapter 2.xhtml", result.chapters().getFirst().normalizedHref()); + } + + @Test + void extractFromKepub_PreservesPlusSignsInManifestHrefs() throws Exception { + File kepubFile = createKepub( + "OPS/package.opf", + """ + + + + + + + + + + """, + new ZipResource("OPS/Text/chapter+1.xhtml", chapterHtml("kobo.1.1", "kobo.1.2")) + ); + + KoboSpanPositionMap result = service.extractFromKepub(kepubFile); + + assertEquals(1, result.chapters().size()); + assertEquals("OPS/Text/chapter+1.xhtml", result.chapters().getFirst().sourceHref()); + assertEquals("OPS/Text/chapter+1.xhtml", result.chapters().getFirst().normalizedHref()); + } + + @Test + void extractFromKepub_KeepsSpanlessChaptersInProgressModel() throws Exception { + File kepubFile = createKepub( + "OPS/package.opf", + """ + + + + + + + + + + + + + + """, + new ZipResource("OPS/front.xhtml", chapterHtmlWithoutSpans()), + new ZipResource("OPS/chapter1.xhtml", chapterHtml("kobo.1.1", "kobo.1.2")), + new ZipResource("OPS/chapter2.xhtml", chapterHtml("kobo.2.1", "kobo.2.2")) + ); + + KoboSpanPositionMap result = service.extractFromKepub(kepubFile); + + assertEquals(3, result.chapters().size()); + assertTrue(result.chapters().getFirst().spans().isEmpty()); + assertTrue(result.chapters().get(1).globalStartProgress() > 0f); + } + + private File createKepub(String opfPath, String opfContent, ZipResource... resources) throws IOException { + File kepubFile = tempDir.resolve("test.kepub.epub").toFile(); + try (ZipOutputStream outputStream = new ZipOutputStream(new FileOutputStream(kepubFile))) { + outputStream.putNextEntry(new ZipEntry("META-INF/container.xml")); + outputStream.write(containerXml(opfPath).getBytes(StandardCharsets.UTF_8)); + outputStream.closeEntry(); + + outputStream.putNextEntry(new ZipEntry(opfPath)); + outputStream.write(opfContent.getBytes(StandardCharsets.UTF_8)); + outputStream.closeEntry(); + + for (ZipResource resource : resources) { + outputStream.putNextEntry(new ZipEntry(resource.path())); + outputStream.write(resource.content().getBytes(StandardCharsets.UTF_8)); + outputStream.closeEntry(); + } + } + return kepubFile; + } + + private String containerXml(String opfPath) { + return """ + + + + + + + """.formatted(opfPath); + } + + private String chapterHtml(String firstSpanId, String secondSpanId) { + return """ + + +

First paragraph with enough text to move the marker.

+

Second paragraph with even more text to shift the marker later.

+ + + """.formatted(firstSpanId, secondSpanId); + } + + private String chapterHtmlWithoutSpans() { + return """ + + +

Front matter without Kobo span markers but with enough text to count in the book length.

+ + + """; + } + + private record ZipResource(String path, String content) { + } +} diff --git a/booklore-api/src/test/java/org/booklore/service/kobo/KoboSpanMapServiceTest.java b/booklore-api/src/test/java/org/booklore/service/kobo/KoboSpanMapServiceTest.java new file mode 100644 index 000000000..25352cc07 --- /dev/null +++ b/booklore-api/src/test/java/org/booklore/service/kobo/KoboSpanMapServiceTest.java @@ -0,0 +1,152 @@ +package org.booklore.service.kobo; + +import org.booklore.model.dto.kobo.KoboSpanPositionMap; +import org.booklore.model.entity.BookFileEntity; +import org.booklore.model.entity.KoboSpanMapEntity; +import org.booklore.model.enums.BookFileType; +import org.booklore.repository.KoboSpanMapRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.io.File; +import java.time.Instant; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class KoboSpanMapServiceTest { + + @Mock + private KoboSpanMapRepository koboSpanMapRepository; + + @Mock + private KoboSpanMapExtractionService koboSpanMapExtractionService; + + private KoboSpanMapService service; + + @BeforeEach + void setUp() { + service = new KoboSpanMapService(koboSpanMapRepository, koboSpanMapExtractionService); + } + + @Test + void computeAndStoreIfNeeded_SkipsWhenExistingHashMatches() throws Exception { + BookFileEntity bookFile = createBookFile("hash-1"); + KoboSpanMapEntity existing = KoboSpanMapEntity.builder() + .bookFile(bookFile) + .fileHash("hash-1") + .spanMap(spanMap()) + .createdAt(Instant.now()) + .build(); + when(koboSpanMapRepository.findByBookFileId(bookFile.getId())).thenReturn(Optional.of(existing)); + + service.computeAndStoreIfNeeded(bookFile, new File("ignored.kepub.epub")); + + verify(koboSpanMapExtractionService, never()).extractFromKepub(any()); + verify(koboSpanMapRepository, never()).save(any()); + } + + @Test + void computeAndStoreIfNeeded_ExtractsAndSavesWhenHashChanges() throws Exception { + BookFileEntity bookFile = createBookFile("hash-2"); + KoboSpanMapEntity existing = KoboSpanMapEntity.builder() + .id(99L) + .bookFile(bookFile) + .fileHash("old-hash") + .spanMap(spanMap()) + .createdAt(Instant.parse("2024-01-01T00:00:00Z")) + .build(); + KoboSpanPositionMap newMap = new KoboSpanPositionMap(List.of( + new KoboSpanPositionMap.Chapter("OPS/chapter2.xhtml", "OPS/chapter2.xhtml", 0, 0f, 1f, + List.of(new KoboSpanPositionMap.Span("kobo.2.1", 0.5f))) + )); + + when(koboSpanMapRepository.findByBookFileId(bookFile.getId())).thenReturn(Optional.of(existing)); + when(koboSpanMapExtractionService.extractFromKepub(any())).thenReturn(newMap); + + service.computeAndStoreIfNeeded(bookFile, tempKepubFile()); + + ArgumentCaptor captor = ArgumentCaptor.forClass(KoboSpanMapEntity.class); + verify(koboSpanMapRepository).save(captor.capture()); + assertEquals(99L, captor.getValue().getId()); + assertEquals("hash-2", captor.getValue().getFileHash()); + assertEquals(newMap, captor.getValue().getSpanMap()); + assertEquals(Instant.parse("2024-01-01T00:00:00Z"), captor.getValue().getCreatedAt()); + } + + @Test + void getValidMap_ReturnsOnlyWhenHashMatches() { + BookFileEntity bookFile = createBookFile("hash-3"); + KoboSpanMapEntity stored = KoboSpanMapEntity.builder() + .bookFile(bookFile) + .fileHash("hash-3") + .spanMap(spanMap()) + .createdAt(Instant.now()) + .build(); + when(koboSpanMapRepository.findByBookFileId(bookFile.getId())).thenReturn(Optional.of(stored)); + + Optional validMap = service.getValidMap(bookFile); + + assertTrue(validMap.isPresent()); + assertEquals(spanMap(), validMap.get()); + + stored.setFileHash("stale-hash"); + Optional staleMap = service.getValidMap(bookFile); + + assertFalse(staleMap.isPresent()); + } + + @Test + void getValidMaps_SkipsEntriesWithNullSpanMap() { + BookFileEntity bookFile = createBookFile("hash-4"); + KoboSpanMapEntity stored = KoboSpanMapEntity.builder() + .bookFile(bookFile) + .fileHash("hash-4") + .spanMap(null) + .createdAt(Instant.now()) + .build(); + when(koboSpanMapRepository.findByBookFileIdIn(bookFilesById(bookFile).keySet())).thenReturn(List.of(stored)); + + Map validMaps = service.getValidMaps(bookFilesById(bookFile)); + + assertTrue(validMaps.isEmpty()); + } + + private BookFileEntity createBookFile(String hash) { + BookFileEntity bookFile = new BookFileEntity(); + bookFile.setId(42L); + bookFile.setBookType(BookFileType.EPUB); + bookFile.setCurrentHash(hash); + return bookFile; + } + + private KoboSpanPositionMap spanMap() { + return new KoboSpanPositionMap(List.of( + new KoboSpanPositionMap.Chapter("OPS/chapter1.xhtml", "OPS/chapter1.xhtml", 0, 0f, 1f, + List.of(new KoboSpanPositionMap.Span("kobo.1.1", 0.2f))) + )); + } + + private Map bookFilesById(BookFileEntity bookFile) { + return Map.of(bookFile.getId(), bookFile); + } + + private File tempKepubFile() throws Exception { + File file = File.createTempFile("kobo-span-map", ".kepub.epub"); + file.deleteOnExit(); + return file; + } +} diff --git a/booklore-api/src/test/java/org/booklore/service/progress/ReadingProgressServiceTest.java b/booklore-api/src/test/java/org/booklore/service/progress/ReadingProgressServiceTest.java index 737d9bb46..e96deb5a3 100644 --- a/booklore-api/src/test/java/org/booklore/service/progress/ReadingProgressServiceTest.java +++ b/booklore-api/src/test/java/org/booklore/service/progress/ReadingProgressServiceTest.java @@ -6,6 +6,7 @@ import org.booklore.model.dto.BookLoreUser; import org.booklore.model.dto.progress.EpubProgress; import org.booklore.model.dto.progress.PdfProgress; +import org.booklore.model.dto.request.BookFileProgress; import org.booklore.model.dto.request.ReadProgressRequest; import org.booklore.model.dto.response.BookStatusUpdateResponse; import org.booklore.model.entity.*; @@ -153,12 +154,16 @@ void enrichBookWithProgress_withFileProgress_shouldOverlayFileProgress() { UserBookFileProgressEntity fileProgress = new UserBookFileProgressEntity(); fileProgress.setBookFile(bookFile); fileProgress.setPositionData("new-cfi"); + fileProgress.setPositionHref("OPS/chapter3.xhtml"); + fileProgress.setContentSourceProgressPercent(18.6f); fileProgress.setProgressPercent(50.0f); fileProgress.setLastReadTime(Instant.now()); readingProgressService.enrichBookWithProgress(book, progress, fileProgress); assertEquals("new-cfi", book.getEpubProgress().getCfi()); + assertEquals("OPS/chapter3.xhtml", book.getEpubProgress().getHref()); + assertEquals(18.6f, book.getEpubProgress().getContentSourceProgressPercent()); assertEquals(50.0f, book.getEpubProgress().getPercentage()); } @@ -200,6 +205,49 @@ void updateReadProgress_epub_shouldSaveProgress() { assertEquals(100f, progress.getEpubProgressPercent()); } + @Test + void updateReadProgress_fileProgress_shouldSaveContentSourceProgress() { + long bookId = 1L; + BookEntity book = new BookEntity(); + book.setId(bookId); + BookFileEntity primaryFile = new BookFileEntity(); + primaryFile.setId(1L); + primaryFile.setBook(book); + primaryFile.setBookType(BookFileType.EPUB); + book.setBookFiles(List.of(primaryFile)); + + BookLoreUser user = mock(BookLoreUser.class); + when(user.getId()).thenReturn(2L); + when(authenticationService.getAuthenticatedUser()).thenReturn(user); + when(bookRepository.findByIdWithBookFiles(bookId)).thenReturn(Optional.of(book)); + when(bookFileRepository.findById(1L)).thenReturn(Optional.of(primaryFile)); + + BookLoreUserEntity userEntity = new BookLoreUserEntity(); + userEntity.setId(2L); + when(userRepository.findById(2L)).thenReturn(Optional.of(userEntity)); + + UserBookProgressEntity progress = new UserBookProgressEntity(); + when(userBookProgressRepository.findByUserIdAndBookId(2L, bookId)).thenReturn(Optional.of(progress)); + when(userBookFileProgressRepository.findByUserIdAndBookFileId(2L, 1L)).thenReturn(Optional.empty()); + + ReadProgressRequest req = new ReadProgressRequest(); + req.setBookId(bookId); + req.setFileProgress(new BookFileProgress( + 1L, + "epubcfi(/6/8!/4/2/6/1:15)", + "OPS/chapter3.xhtml", + 55f, + "epubcfi(/6/8!/4/2/6/1:10)", + 18.6f)); + + readingProgressService.updateReadProgress(req); + + verify(userBookFileProgressRepository).save(argThat(savedFileProgress -> + savedFileProgress.getContentSourceProgressPercent() != null + && savedFileProgress.getContentSourceProgressPercent().equals(18.6f) + && "OPS/chapter3.xhtml".equals(savedFileProgress.getPositionHref()))); + } + @Test void updateReadProgress_pdf_shouldSaveProgress() { long bookId = 1L; diff --git a/booklore-api/src/test/java/org/booklore/util/koreader/EpubCfiServiceTest.java b/booklore-api/src/test/java/org/booklore/util/koreader/EpubCfiServiceTest.java index 61331167a..a2c3840cd 100644 --- a/booklore-api/src/test/java/org/booklore/util/koreader/EpubCfiServiceTest.java +++ b/booklore-api/src/test/java/org/booklore/util/koreader/EpubCfiServiceTest.java @@ -179,6 +179,18 @@ void convertCfiToXPointer_withPath_returnsXPointerResult() { assertNotNull(result); assertNotNull(result.getXpointer()); } + + @Test + void convertCfiToXPointer_roundTripsNestedParagraphLocation() { + String originalXPointer = "/body/DocFragment[1]/body/div[1]/p[2]/text().10"; + String cfi = service.convertXPointerToCfi(testEpubFile, originalXPointer); + + CfiConvertor.XPointerResult result = service.convertCfiToXPointer(testEpubFile, cfi); + + assertNotNull(result); + assertNotNull(result.getXpointer()); + assertTrue(result.getXpointer().contains("/div/p[2]/text().10")); + } } @Nested @@ -229,6 +241,31 @@ void convertCfiToProgressXPointer_withPath_returnsNormalizedXPointer() { } } + @Nested + class ResolveCfiLocationTests { + + @Test + void resolveCfiLocation_textOffsetsReturnMatchingHrefAndIncreasingProgress() { + String earlyCfi = service.convertXPointerToCfi(testEpubFile, "/body/DocFragment[1]/body/div[1]/p[1]/text().0"); + String laterCfi = service.convertXPointerToCfi(testEpubFile, "/body/DocFragment[1]/body/div[1]/p[2]/text().10"); + + EpubCfiService.CfiLocation earlyLocation = service.resolveCfiLocation(testEpubFile, earlyCfi).orElseThrow(); + EpubCfiService.CfiLocation laterLocation = service.resolveCfiLocation(testEpubFile, laterCfi).orElseThrow(); + + assertEquals("chapter1.xhtml", earlyLocation.href()); + assertEquals("chapter1.xhtml", laterLocation.href()); + assertNotNull(earlyLocation.contentSourceProgressPercent()); + assertNotNull(laterLocation.contentSourceProgressPercent()); + assertTrue(earlyLocation.contentSourceProgressPercent() > 0f); + assertTrue(earlyLocation.contentSourceProgressPercent() < laterLocation.contentSourceProgressPercent()); + } + + @Test + void resolveCfiLocation_invalidCfiReturnsEmpty() { + assertTrue(service.resolveCfiLocation(testEpubFile, "invalid").isEmpty()); + } + } + @Nested class ValidationTests { diff --git a/frontend/src/app/features/book/model/book.model.ts b/frontend/src/app/features/book/model/book.model.ts index 1ab3e3c8e..089db61a6 100644 --- a/frontend/src/app/features/book/model/book.model.ts +++ b/frontend/src/app/features/book/model/book.model.ts @@ -61,7 +61,9 @@ export interface Book extends FileInfo { } export interface EpubProgress { - cfi: string; + cfi?: string | null; + href?: string; + contentSourceProgressPercent?: number | null; percentage: number; } diff --git a/frontend/src/app/features/readers/ebook-reader/ebook-reader.component.ts b/frontend/src/app/features/readers/ebook-reader/ebook-reader.component.ts index 324758029..33958c631 100644 --- a/frontend/src/app/features/readers/ebook-reader/ebook-reader.component.ts +++ b/frontend/src/app/features/readers/ebook-reader/ebook-reader.component.ts @@ -71,6 +71,8 @@ import {RelocateProgressData} from './state/progress.service'; styleUrls: ['./ebook-reader.component.scss'] }) export class EbookReaderComponent implements OnInit, OnDestroy { + private static readonly MAX_CHAPTER_PROGRESS_PERCENT = 99.9; + private destroy$ = new Subject(); private loaderService = inject(ReaderLoaderService); private styleService = inject(ReaderStyleService); @@ -97,6 +99,7 @@ export class EbookReaderComponent implements OnInit, OnDestroy { private visibilityManager!: ReaderHeaderFooterVisibilityManager; private relocateTimeout?: ReturnType; private sectionFractionsTimeout?: ReturnType; + private pendingInitialChapterRestore: { href: string; contentSourceProgressPercent: number } | null = null; isLoading = true; showQuickSettings = false; @@ -266,14 +269,7 @@ export class EbookReaderComponent implements OnInit, OnDestroy { switchMap(() => { if (!this.hasLoadedOnce) { this.hasLoadedOnce = true; - // Navigate to saved position if progress exists, otherwise go to first page - if (book.epubProgress?.cfi) { - return this.viewManager.goTo(book.epubProgress.cfi); - } else if (book.epubProgress?.percentage && book.epubProgress.percentage > 0) { - return this.viewManager.goToFraction(book.epubProgress.percentage / 100); - } else { - return this.viewManager.goTo(0); - } + return this.restoreSavedPosition(book); } return of(undefined); }) @@ -303,6 +299,14 @@ export class EbookReaderComponent implements OnInit, OnDestroy { this.updateSectionFractions(); break; case 'relocate': + if (this.handlePendingInitialChapterRestore(event.detail)) { + if (this.sectionFractionsTimeout) clearTimeout(this.sectionFractionsTimeout); + this.sectionFractionsTimeout = setTimeout(() => { + this.updateSectionFractions(); + }, 500); + break; + } + if (this.relocateTimeout) clearTimeout(this.relocateTimeout); this.relocateTimeout = setTimeout(() => { this.progressService.handleRelocateEvent(event.detail); @@ -368,6 +372,98 @@ export class EbookReaderComponent implements OnInit, OnDestroy { this.sectionFractions = this.viewManager.getSectionFractions(); } + private restoreSavedPosition(book: Book): Observable { + const progress = book.epubProgress; + + if (progress?.cfi) { + return this.viewManager.goTo(progress.cfi); + } + + if (progress?.href) { + const chapterProgress = progress.contentSourceProgressPercent; + if (typeof chapterProgress === 'number' && Number.isFinite(chapterProgress) && chapterProgress > 0) { + this.pendingInitialChapterRestore = { + href: this.normalizeHref(progress.href), + contentSourceProgressPercent: chapterProgress + }; + } else { + this.pendingInitialChapterRestore = null; + } + return this.viewManager.goTo(progress.href); + } + + this.pendingInitialChapterRestore = null; + + if (progress?.percentage && progress.percentage > 0) { + return this.viewManager.goToFraction(progress.percentage / 100); + } + + return this.viewManager.goTo(0); + } + + private handlePendingInitialChapterRestore(detail: RelocateProgressData): boolean { + if (!this.pendingInitialChapterRestore) { + return false; + } + + const currentHref = this.normalizeHref(detail.pageItem?.href ?? detail.tocItem?.href ?? null); + if (!currentHref || !this.hrefsMatch(currentHref, this.pendingInitialChapterRestore.href)) { + return false; + } + + const targetFraction = this.resolveChapterFraction( + detail.section?.current, + this.pendingInitialChapterRestore.contentSourceProgressPercent + ); + + if (targetFraction === null) { + return false; + } + + if (typeof detail.fraction === 'number' && Math.abs(detail.fraction - targetFraction) < 0.0001) { + this.pendingInitialChapterRestore = null; + return false; + } + + this.pendingInitialChapterRestore = null; + this.viewManager.goToFraction(targetFraction) + .pipe(takeUntil(this.destroy$)) + .subscribe(); + + return true; + } + + private resolveChapterFraction(sectionIndex: number | undefined, chapterProgressPercent: number): number | null { + if (sectionIndex === undefined) { + return null; + } + + const sectionFractions = this.viewManager.getSectionFractions(); + const start = sectionFractions[sectionIndex]; + const end = sectionFractions[sectionIndex + 1]; + + if (start === undefined || end === undefined || end <= start) { + return null; + } + + const normalizedProgress = Math.min( + Math.max(chapterProgressPercent, 0), + EbookReaderComponent.MAX_CHAPTER_PROGRESS_PERCENT + ) / 100; + + return start + ((end - start) * normalizedProgress); + } + + private normalizeHref(href: string | null | undefined): string { + return (href ?? '').split('#')[0].replace(/^(\.\/|\/)+/, ''); + } + + private hrefsMatch(leftHref: string, rightHref: string): boolean { + return leftHref === rightHref + || leftHref.endsWith(`/${rightHref}`) + || rightHref.endsWith(`/${leftHref}`); + } + private updateBookmarkIndicator(): void { const currentCfi = this.progressService.currentCfi; const isBookmarked = currentCfi diff --git a/frontend/src/app/features/settings/device-settings/component/kobo-sync-settings/kobo-sync-settings-component.html b/frontend/src/app/features/settings/device-settings/component/kobo-sync-settings/kobo-sync-settings-component.html index a8c45afef..ff6f75bd2 100644 --- a/frontend/src/app/features/settings/device-settings/component/kobo-sync-settings/kobo-sync-settings-component.html +++ b/frontend/src/app/features/settings/device-settings/component/kobo-sync-settings/kobo-sync-settings-component.html @@ -62,6 +62,16 @@


{{ t('kobo.twoWaySyncNote') }}

+ @if (koboSyncSettings.twoWayProgressSync && !koboSettings.convertToKepub) { +

+ + @if (isAdmin) { + {{ t('kobo.twoWaySyncKepubWarningAdmin') }} + } @else { + {{ t('kobo.twoWaySyncKepubWarning') }} + } +

+ } } diff --git a/frontend/src/app/features/settings/device-settings/component/kobo-sync-settings/kobo-sync-settings-component.scss b/frontend/src/app/features/settings/device-settings/component/kobo-sync-settings/kobo-sync-settings-component.scss index 38b19a580..511bff561 100644 --- a/frontend/src/app/features/settings/device-settings/component/kobo-sync-settings/kobo-sync-settings-component.scss +++ b/frontend/src/app/features/settings/device-settings/component/kobo-sync-settings/kobo-sync-settings-component.scss @@ -118,6 +118,12 @@ line-height: 1.5; margin: 0; } + + .setting-warning { + @include settings.settings-warning-notice; + + margin-top: 0.75rem; + } } .setting-control { diff --git a/frontend/src/app/features/settings/device-settings/component/kobo-sync-settings/kobo-sync-settings-component.ts b/frontend/src/app/features/settings/device-settings/component/kobo-sync-settings/kobo-sync-settings-component.ts index 15e206f81..20e11ba41 100644 --- a/frontend/src/app/features/settings/device-settings/component/kobo-sync-settings/kobo-sync-settings-component.ts +++ b/frontend/src/app/features/settings/device-settings/component/kobo-sync-settings/kobo-sync-settings-component.ts @@ -47,7 +47,7 @@ export class KoboSyncSettingsComponent implements OnInit, OnDestroy { showToken = false; koboSettings: KoboSettings = { - convertToKepub: false, + convertToKepub: true, conversionLimitInMb: 100, convertCbxToEpub: false, conversionImageCompressionPercentage: 85, @@ -97,6 +97,7 @@ export class KoboSyncSettingsComponent implements OnInit, OnDestroy { if (currHasKoboTokenPermission && !this.prevHasKoboTokenPermission) { this.hasKoboTokenPermission = true; this.loadKoboUserSettings(); + this.loadKepubSetting(); } else { this.hasKoboTokenPermission = currHasKoboTokenPermission; } @@ -129,6 +130,13 @@ export class KoboSyncSettingsComponent implements OnInit, OnDestroy { }); } + private loadKepubSetting() { + const settings = this.appSettingsService.appSettings(); + if (settings) { + this.koboSettings.convertToKepub = settings.koboSettings?.convertToKepub ?? true; + } + } + private loadKoboAdminSettings() { const settings = this.appSettingsService.appSettings(); if (settings) { diff --git a/frontend/src/i18n/da/settings-device.json b/frontend/src/i18n/da/settings-device.json index ecc9d5cb0..8a4c0a2a2 100644 --- a/frontend/src/i18n/da/settings-device.json +++ b/frontend/src/i18n/da/settings-device.json @@ -97,6 +97,8 @@ "twoWaySyncLabel": "", "twoWaySyncDesc": "", "twoWaySyncNote": "", + "twoWaySyncKepubWarning": "KEPUB conversion is currently disabled. Two-way progress sync requires KEPUB conversion to be enabled by an administrator.", + "twoWaySyncKepubWarningAdmin": "KEPUB conversion is currently disabled. Enable it in the Administrator Settings below for two-way progress sync to work.", "twoWaySyncEnabled": "", "twoWaySyncDisabled": "", "autoAddEnabled": "", diff --git a/frontend/src/i18n/de/settings-device.json b/frontend/src/i18n/de/settings-device.json index 3df5c51a8..3bc7ee6a1 100644 --- a/frontend/src/i18n/de/settings-device.json +++ b/frontend/src/i18n/de/settings-device.json @@ -97,6 +97,8 @@ "twoWaySyncLabel": "Bidirektionale Lesefortschrittssynchronisierung", "twoWaySyncDesc": "Aktivieren Sie die bidirektionale Synchronisierung zwischen Grimmorys Web-Reader und Ihrem Kobo-Gerät. Der Lesefortschritt auf beiden Plattformen wird gegenseitig übernommen.", "twoWaySyncNote": "Hinweis: Aufgrund unterschiedlicher Positionsformate ist die Synchronisierungsgenauigkeit bestmöglich. In den meisten Fällen ist der Fortschritt auf Kapitelebene genau.", + "twoWaySyncKepubWarning": "KEPUB conversion is currently disabled. Two-way progress sync requires KEPUB conversion to be enabled by an administrator.", + "twoWaySyncKepubWarningAdmin": "KEPUB conversion is currently disabled. Enable it in the Administrator Settings below for two-way progress sync to work.", "twoWaySyncEnabled": "Bidirektionale Fortschrittssynchronisierung aktiviert", "twoWaySyncDisabled": "Bidirektionale Fortschrittssynchronisierung deaktiviert", "autoAddEnabled": "Neue Bücher werden automatisch zum Kobo-Regal hinzugefügt", diff --git a/frontend/src/i18n/en/settings-device.json b/frontend/src/i18n/en/settings-device.json index 631f93ae9..4c11c575f 100644 --- a/frontend/src/i18n/en/settings-device.json +++ b/frontend/src/i18n/en/settings-device.json @@ -97,6 +97,8 @@ "twoWaySyncLabel": "Two-Way Reading Progress Sync", "twoWaySyncDesc": "Enable bidirectional sync between Grimmory's web reader and your Kobo device. Reading progress made on either platform will be reflected on the other.", "twoWaySyncNote": "Note: Due to different position formats, sync accuracy is best-effort. In most cases progress will be accurate to the chapter level.", + "twoWaySyncKepubWarning": "KEPUB conversion is currently disabled. Two-way progress sync requires KEPUB conversion to be enabled by an administrator.", + "twoWaySyncKepubWarningAdmin": "KEPUB conversion is currently disabled. Enable it in the Administrator Settings below for two-way progress sync to work.", "twoWaySyncEnabled": "Two-way progress sync enabled", "twoWaySyncDisabled": "Two-way progress sync disabled", "autoAddEnabled": "New books will be automatically added to Kobo shelf", diff --git a/frontend/src/i18n/es/settings-device.json b/frontend/src/i18n/es/settings-device.json index 8e3fe3532..b5d213eae 100644 --- a/frontend/src/i18n/es/settings-device.json +++ b/frontend/src/i18n/es/settings-device.json @@ -97,6 +97,8 @@ "twoWaySyncLabel": "Sincronización bidireccional del progreso de lectura", "twoWaySyncDesc": "Habilita la sincronización bidireccional entre el lector web de Grimmory y tu dispositivo Kobo. El progreso de lectura realizado en cualquiera de las plataformas se reflejará en la otra.", "twoWaySyncNote": "Nota: Debido a los diferentes formatos de posición, la precisión de la sincronización es aproximada. En la mayoría de los casos, el progreso será preciso a nivel de capítulo.", + "twoWaySyncKepubWarning": "KEPUB conversion is currently disabled. Two-way progress sync requires KEPUB conversion to be enabled by an administrator.", + "twoWaySyncKepubWarningAdmin": "KEPUB conversion is currently disabled. Enable it in the Administrator Settings below for two-way progress sync to work.", "twoWaySyncEnabled": "Sincronización bidireccional de progreso habilitada", "twoWaySyncDisabled": "Sincronización bidireccional de progreso deshabilitada", "autoAddEnabled": "Los libros nuevos se añadirán automáticamente al estante Kobo", diff --git a/frontend/src/i18n/fr/settings-device.json b/frontend/src/i18n/fr/settings-device.json index 9fdf23b91..50e6dd33d 100644 --- a/frontend/src/i18n/fr/settings-device.json +++ b/frontend/src/i18n/fr/settings-device.json @@ -97,6 +97,8 @@ "twoWaySyncLabel": "Synchronisation bidirectionnelle de la progression", "twoWaySyncDesc": "Activez la synchronisation bidirectionnelle entre le lecteur web de Grimmory et votre appareil Kobo. La progression de lecture effectuée sur l'une ou l'autre plateforme sera reflétée sur l'autre.", "twoWaySyncNote": "Note : En raison de formats de position différents, la précision de la synchronisation est approximative. Dans la plupart des cas, la progression sera précise au niveau du chapitre.", + "twoWaySyncKepubWarning": "KEPUB conversion is currently disabled. Two-way progress sync requires KEPUB conversion to be enabled by an administrator.", + "twoWaySyncKepubWarningAdmin": "KEPUB conversion is currently disabled. Enable it in the Administrator Settings below for two-way progress sync to work.", "twoWaySyncEnabled": "Synchronisation bidirectionnelle de la progression activée", "twoWaySyncDisabled": "Synchronisation bidirectionnelle de la progression désactivée", "autoAddEnabled": "Les nouveaux livres seront automatiquement ajoutés à l'étagère Kobo", diff --git a/frontend/src/i18n/hr/settings-device.json b/frontend/src/i18n/hr/settings-device.json index f5981d9bb..730adf848 100644 --- a/frontend/src/i18n/hr/settings-device.json +++ b/frontend/src/i18n/hr/settings-device.json @@ -97,6 +97,8 @@ "twoWaySyncLabel": "Dvosmjerna sinkronizacija napretka čitanja", "twoWaySyncDesc": "Omogući dvosmjernu sinkronizaciju između Grimmory web čitača i vašeg Kobo uređaja. Napredak čitanja na bilo kojoj platformi odrazit će se na drugoj.", "twoWaySyncNote": "Napomena: Zbog različitih formata pozicije, točnost sinkronizacije je na razini najboljeg napora. U većini slučajeva napredak će biti točan na razini poglavlja.", + "twoWaySyncKepubWarning": "KEPUB conversion is currently disabled. Two-way progress sync requires KEPUB conversion to be enabled by an administrator.", + "twoWaySyncKepubWarningAdmin": "KEPUB conversion is currently disabled. Enable it in the Administrator Settings below for two-way progress sync to work.", "twoWaySyncEnabled": "Dvosmjerna sinkronizacija napretka omogućena", "twoWaySyncDisabled": "Dvosmjerna sinkronizacija napretka onemogućena", "autoAddEnabled": "Nove knjige će se automatski dodavati na Kobo policu", diff --git a/frontend/src/i18n/hu/settings-device.json b/frontend/src/i18n/hu/settings-device.json index ecc9d5cb0..8a4c0a2a2 100644 --- a/frontend/src/i18n/hu/settings-device.json +++ b/frontend/src/i18n/hu/settings-device.json @@ -97,6 +97,8 @@ "twoWaySyncLabel": "", "twoWaySyncDesc": "", "twoWaySyncNote": "", + "twoWaySyncKepubWarning": "KEPUB conversion is currently disabled. Two-way progress sync requires KEPUB conversion to be enabled by an administrator.", + "twoWaySyncKepubWarningAdmin": "KEPUB conversion is currently disabled. Enable it in the Administrator Settings below for two-way progress sync to work.", "twoWaySyncEnabled": "", "twoWaySyncDisabled": "", "autoAddEnabled": "", diff --git a/frontend/src/i18n/id/settings-device.json b/frontend/src/i18n/id/settings-device.json index c0a3e96ac..9755360f4 100644 --- a/frontend/src/i18n/id/settings-device.json +++ b/frontend/src/i18n/id/settings-device.json @@ -97,6 +97,8 @@ "twoWaySyncLabel": "", "twoWaySyncDesc": "", "twoWaySyncNote": "", + "twoWaySyncKepubWarning": "KEPUB conversion is currently disabled. Two-way progress sync requires KEPUB conversion to be enabled by an administrator.", + "twoWaySyncKepubWarningAdmin": "KEPUB conversion is currently disabled. Enable it in the Administrator Settings below for two-way progress sync to work.", "twoWaySyncEnabled": "", "twoWaySyncDisabled": "", "autoAddEnabled": "", diff --git a/frontend/src/i18n/it/settings-device.json b/frontend/src/i18n/it/settings-device.json index 23365d707..c36607114 100644 --- a/frontend/src/i18n/it/settings-device.json +++ b/frontend/src/i18n/it/settings-device.json @@ -97,6 +97,8 @@ "twoWaySyncLabel": "Sincronizzazione bidirezionale progresso di lettura", "twoWaySyncDesc": "Abilita la sincronizzazione bidirezionale tra il lettore web di Grimmory e il tuo dispositivo Kobo. Il progresso di lettura effettuato su una delle piattaforme verrà riflesso sull'altra.", "twoWaySyncNote": "Nota: A causa dei diversi formati di posizione, la precisione della sincronizzazione è basata sul miglior risultato possibile. Nella maggior parte dei casi il progresso sarà preciso a livello di capitolo.", + "twoWaySyncKepubWarning": "KEPUB conversion is currently disabled. Two-way progress sync requires KEPUB conversion to be enabled by an administrator.", + "twoWaySyncKepubWarningAdmin": "KEPUB conversion is currently disabled. Enable it in the Administrator Settings below for two-way progress sync to work.", "twoWaySyncEnabled": "Sincronizzazione bidirezionale del progresso abilitata", "twoWaySyncDisabled": "Sincronizzazione bidirezionale del progresso disabilitata", "autoAddEnabled": "I nuovi libri verranno automaticamente aggiunti allo scaffale Kobo", diff --git a/frontend/src/i18n/ja/settings-device.json b/frontend/src/i18n/ja/settings-device.json index a968b6950..8655e3245 100644 --- a/frontend/src/i18n/ja/settings-device.json +++ b/frontend/src/i18n/ja/settings-device.json @@ -62,8 +62,8 @@ "adminSettings": "管理者設定", "adminSettingsDesc": "このサーバーのすべてのユーザーに影響する高度なKobo同期設定を構成します。", "convertKepub": "KEPUBに変換", - "convertKepubPros": "KEPUB形式により、より良いタイポグラフィ、読書統計、高速なページめくりなどのKobo拡張機能が有効になります。", - "convertKepubCons": "変換には追加の処理時間がかかり、複雑なレイアウトでは失敗する場合があります。以下のサイズ制限を超える本は自動的に変換をスキップします。", + "convertKepubPros": "KEPUB format enables enhanced Kobo features like better typography, reading stats, and faster page turns. Required for reading progress sync to work.", + "convertKepubCons": "Conversion adds processing time during download. Books over the size limit below will skip conversion and sync as regular EPUB without progress tracking.", "conversionSizeLimit": "変換サイズ制限: {{value}} MB", "conversionSizeLimitDesc": "大きなファイル(100MB以上)は変換タイムアウトやサーバー負荷を引き起こす可能性があります。制限を低くすると同期が高速になりますが、大きな本の変換がスキップされる場合があります。", "conversionSizeLimitRec": "ほとんどのライブラリでは50~100MB。非常に大きな本はこの設定に関係なく通常のEPUBとして同期されます。", @@ -97,6 +97,8 @@ "twoWaySyncLabel": "双方向読書進捗同期", "twoWaySyncDesc": "GrimmoryのWebリーダーとKoboデバイス間の双方向同期を有効にします。どちらのプラットフォームでの読書進捗も、もう一方に反映されます。", "twoWaySyncNote": "注意:位置形式が異なるため、同期精度はベストエフォートです。ほとんどの場合、チャプターレベルで正確になります。", + "twoWaySyncKepubWarning": "KEPUB conversion is currently disabled. Two-way progress sync requires KEPUB conversion to be enabled by an administrator.", + "twoWaySyncKepubWarningAdmin": "KEPUB conversion is currently disabled. Enable it in the Administrator Settings below for two-way progress sync to work.", "twoWaySyncEnabled": "双方向進捗同期が有効になりました", "twoWaySyncDisabled": "双方向進捗同期が無効になりました", "autoAddEnabled": "新しい本はKoboシェルフに自動的に追加されます", diff --git a/frontend/src/i18n/nl/settings-device.json b/frontend/src/i18n/nl/settings-device.json index a4163a236..f15ea302b 100644 --- a/frontend/src/i18n/nl/settings-device.json +++ b/frontend/src/i18n/nl/settings-device.json @@ -97,6 +97,8 @@ "twoWaySyncLabel": "", "twoWaySyncDesc": "", "twoWaySyncNote": "", + "twoWaySyncKepubWarning": "KEPUB conversion is currently disabled. Two-way progress sync requires KEPUB conversion to be enabled by an administrator.", + "twoWaySyncKepubWarningAdmin": "KEPUB conversion is currently disabled. Enable it in the Administrator Settings below for two-way progress sync to work.", "twoWaySyncEnabled": "", "twoWaySyncDisabled": "", "autoAddEnabled": "", diff --git a/frontend/src/i18n/pl/settings-device.json b/frontend/src/i18n/pl/settings-device.json index a4f7309de..881011005 100644 --- a/frontend/src/i18n/pl/settings-device.json +++ b/frontend/src/i18n/pl/settings-device.json @@ -97,6 +97,8 @@ "twoWaySyncLabel": "Dwukierunkowa synchronizacja postępu czytania", "twoWaySyncDesc": "Włącz dwukierunkową synchronizację między czytnikiem internetowym Grimmory a urządzeniem Kobo. Postęp czytania z jednej platformy będzie widoczny na drugiej.", "twoWaySyncNote": "Uwaga: Ze względu na różne formaty pozycji, dokładność synchronizacji jest w najlepszym przypadku przybliżona. W większości przypadków postęp będzie dokładny na poziomie rozdziałów.", + "twoWaySyncKepubWarning": "KEPUB conversion is currently disabled. Two-way progress sync requires KEPUB conversion to be enabled by an administrator.", + "twoWaySyncKepubWarningAdmin": "KEPUB conversion is currently disabled. Enable it in the Administrator Settings below for two-way progress sync to work.", "twoWaySyncEnabled": "Dwukierunkowa synchronizacja postępu włączona", "twoWaySyncDisabled": "Dwukierunkowa synchronizacja postępu wyłączona", "autoAddEnabled": "Nowe książki będą automatycznie dodawane do półki Kobo", diff --git a/frontend/src/i18n/pt/settings-device.json b/frontend/src/i18n/pt/settings-device.json index 8b654e3b1..fff99045a 100644 --- a/frontend/src/i18n/pt/settings-device.json +++ b/frontend/src/i18n/pt/settings-device.json @@ -97,6 +97,8 @@ "twoWaySyncLabel": "Sincronização Bidirecional de Progresso de Leitura", "twoWaySyncDesc": "Ative a sincronização bidirecional entre o leitor web do Grimmory e o seu dispositivo Kobo. O progresso de leitura feito em qualquer plataforma será refletido na outra.", "twoWaySyncNote": "Nota: Devido a formatos de posição diferentes, a precisão da sincronização é aproximada. Na maioria dos casos, o progresso será preciso ao nível de capítulo.", + "twoWaySyncKepubWarning": "KEPUB conversion is currently disabled. Two-way progress sync requires KEPUB conversion to be enabled by an administrator.", + "twoWaySyncKepubWarningAdmin": "KEPUB conversion is currently disabled. Enable it in the Administrator Settings below for two-way progress sync to work.", "twoWaySyncEnabled": "Sincronização bidirecional de progresso ativada", "twoWaySyncDisabled": "Sincronização bidirecional de progresso desativada", "autoAddEnabled": "Novos livros serão adicionados automaticamente à prateleira Kobo", diff --git a/frontend/src/i18n/ru/settings-device.json b/frontend/src/i18n/ru/settings-device.json index 55e5dfa34..ea872f648 100644 --- a/frontend/src/i18n/ru/settings-device.json +++ b/frontend/src/i18n/ru/settings-device.json @@ -97,6 +97,8 @@ "twoWaySyncLabel": "Двунаправленная синхронизация прогресса чтения", "twoWaySyncDesc": "Включить двунаправленную синхронизацию между веб-читалкой Grimmory и Вашим устройством Kobo. Прогресс чтения на любой из платформ будет отражаться на другой.", "twoWaySyncNote": "Примечание: из-за различий форматов позиции точность синхронизации обеспечивается на уровне «лучших усилий». В большинстве случаев прогресс будет точен на уровне главы.", + "twoWaySyncKepubWarning": "KEPUB conversion is currently disabled. Two-way progress sync requires KEPUB conversion to be enabled by an administrator.", + "twoWaySyncKepubWarningAdmin": "KEPUB conversion is currently disabled. Enable it in the Administrator Settings below for two-way progress sync to work.", "twoWaySyncEnabled": "Двунаправленная синхронизация прогресса включена", "twoWaySyncDisabled": "Двунаправленная синхронизация прогресса отключена", "autoAddEnabled": "Новые книги будут автоматически добавляться на полку Kobo", diff --git a/frontend/src/i18n/sk/settings-device.json b/frontend/src/i18n/sk/settings-device.json index 7f28382ff..140cc38c4 100644 --- a/frontend/src/i18n/sk/settings-device.json +++ b/frontend/src/i18n/sk/settings-device.json @@ -97,6 +97,8 @@ "twoWaySyncLabel": "", "twoWaySyncDesc": "", "twoWaySyncNote": "", + "twoWaySyncKepubWarning": "KEPUB conversion is currently disabled. Two-way progress sync requires KEPUB conversion to be enabled by an administrator.", + "twoWaySyncKepubWarningAdmin": "KEPUB conversion is currently disabled. Enable it in the Administrator Settings below for two-way progress sync to work.", "twoWaySyncEnabled": "", "twoWaySyncDisabled": "", "autoAddEnabled": "", diff --git a/frontend/src/i18n/sl/settings-device.json b/frontend/src/i18n/sl/settings-device.json index 811c30481..2e161e1ec 100644 --- a/frontend/src/i18n/sl/settings-device.json +++ b/frontend/src/i18n/sl/settings-device.json @@ -97,6 +97,8 @@ "twoWaySyncLabel": "Dvosmerna sinhronizacija napredka branja", "twoWaySyncDesc": "Omogoči dvosmerno sinhronizacijo med spletnim bralnikom Grimmory in tvojo napravo Kobo. Napredek na kateri koli platformi se bo odrazil na drugi.", "twoWaySyncNote": "Opomba: Zaradi različnih formatov lokacij je natančnost sinhronizacije po principu \"najboljšega truda\". V večini primerov bo napredek natančen na nivoju poglavja.", + "twoWaySyncKepubWarning": "KEPUB conversion is currently disabled. Two-way progress sync requires KEPUB conversion to be enabled by an administrator.", + "twoWaySyncKepubWarningAdmin": "KEPUB conversion is currently disabled. Enable it in the Administrator Settings below for two-way progress sync to work.", "twoWaySyncEnabled": "Dvosmerna sinhronizacija napredka omogočena", "twoWaySyncDisabled": "Dvosmerna sinhronizacija napredka onemogočena", "autoAddEnabled": "Nove knjige bodo samodejno dodane na polico Kobo", diff --git a/frontend/src/i18n/sv/settings-device.json b/frontend/src/i18n/sv/settings-device.json index 83418873e..23277048a 100644 --- a/frontend/src/i18n/sv/settings-device.json +++ b/frontend/src/i18n/sv/settings-device.json @@ -97,6 +97,8 @@ "twoWaySyncLabel": "Dubbelriktad läsframstegssynk", "twoWaySyncDesc": "Aktivera dubbelriktad synk mellan Grimmorys webbläsare och din Kobo-enhet. Läsframsteg gjorda på endera plattformen reflekteras på den andra.", "twoWaySyncNote": "Obs: På grund av olika positionsformat är synknoggrannheten bäst-ansträngning. I de flesta fall är framstegen korrekta på kapitelnivå.", + "twoWaySyncKepubWarning": "KEPUB conversion is currently disabled. Two-way progress sync requires KEPUB conversion to be enabled by an administrator.", + "twoWaySyncKepubWarningAdmin": "KEPUB conversion is currently disabled. Enable it in the Administrator Settings below for two-way progress sync to work.", "twoWaySyncEnabled": "Dubbelriktad framstegssynk aktiverad", "twoWaySyncDisabled": "Dubbelriktad framstegssynk inaktiverad", "autoAddEnabled": "Nya böcker läggs automatiskt till på Kobo-hyllan", diff --git a/frontend/src/i18n/uk/settings-device.json b/frontend/src/i18n/uk/settings-device.json index 52155f99d..1a543e949 100644 --- a/frontend/src/i18n/uk/settings-device.json +++ b/frontend/src/i18n/uk/settings-device.json @@ -97,6 +97,8 @@ "twoWaySyncLabel": "Двостороння синхронізація прогресу читання", "twoWaySyncDesc": "Увімкніть двосторонню синхронізацію між веб-рідером Grimmory та вашим пристроєм Kobo. Прогрес читання, зроблений на будь-якій платформі, буде відображений на іншій.", "twoWaySyncNote": "Примітка: через різні формати позицій точність синхронізації є найкращим можливим наближенням. У більшості випадків прогрес буде точним до рівня розділу.", + "twoWaySyncKepubWarning": "KEPUB conversion is currently disabled. Two-way progress sync requires KEPUB conversion to be enabled by an administrator.", + "twoWaySyncKepubWarningAdmin": "KEPUB conversion is currently disabled. Enable it in the Administrator Settings below for two-way progress sync to work.", "twoWaySyncEnabled": "Двосторонню синхронізацію прогресу увімкнено", "twoWaySyncDisabled": "Двосторонню синхронізацію прогресу вимкнено", "autoAddEnabled": "Нові книги автоматично додаватимуться на полицю Kobo", diff --git a/frontend/src/i18n/zh/settings-device.json b/frontend/src/i18n/zh/settings-device.json index 800ecb20c..23781fed3 100644 --- a/frontend/src/i18n/zh/settings-device.json +++ b/frontend/src/i18n/zh/settings-device.json @@ -97,6 +97,8 @@ "twoWaySyncLabel": "双向阅读进度同步", "twoWaySyncDesc": "启用 Grimmory 网页阅读器与 Kobo 设备之间的双向同步。在任一平台上的阅读进度都将反映到另一个平台。", "twoWaySyncNote": "注意:由于位置格式不同,同步精度是尽力而为的。大多数情况下进度将精确到章节级别。", + "twoWaySyncKepubWarning": "KEPUB conversion is currently disabled. Two-way progress sync requires KEPUB conversion to be enabled by an administrator.", + "twoWaySyncKepubWarningAdmin": "KEPUB conversion is currently disabled. Enable it in the Administrator Settings below for two-way progress sync to work.", "twoWaySyncEnabled": "双向进度同步已启用", "twoWaySyncDisabled": "双向进度同步已禁用", "autoAddEnabled": "新图书将自动添加到 Kobo 书架",