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 extends Entitlement> 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 书架",