feat(kobo-sync): Sync Shelves and Magic Shelves to Kobo Tags (#2236)

* feat(kobo-sync): sync shelves and magic shelves to kobo tags

* refactor(kobo-sync): replace `EntityNotFoundException` with `NoSuchElementException`, update timestamps handling, and add unit tests for `generateTags`

---------

Co-authored-by: ACX <8075870+acx10@users.noreply.github.com>
This commit is contained in:
xcashy
2026-02-05 21:59:08 +01:00
committed by GitHub
parent 6e43b01e76
commit 539734a3cf
5 changed files with 423 additions and 4 deletions

View File

@@ -0,0 +1,37 @@
package org.booklore.model.dto.kobo;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.databind.PropertyNamingStrategies;
import com.fasterxml.jackson.databind.annotation.JsonNaming;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.List;
@Data
@NoArgsConstructor
@AllArgsConstructor
@JsonNaming(PropertyNamingStrategies.UpperCamelCaseStrategy.class)
@Builder
@JsonInclude(JsonInclude.Include.NON_NULL)
public class KoboTag {
private String created;
private String lastModified;
private String id;
private String name;
private String type;
private List<KoboTagItem> items;
@Data
@NoArgsConstructor
@AllArgsConstructor
@JsonNaming(PropertyNamingStrategies.UpperCamelCaseStrategy.class)
@Builder
@JsonInclude(JsonInclude.Include.NON_NULL)
public static class KoboTagItem {
private String revisionId;
private String type;
}
}

View File

@@ -0,0 +1,30 @@
package org.booklore.model.dto.kobo;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.databind.PropertyNamingStrategies;
import com.fasterxml.jackson.databind.annotation.JsonNaming;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
@JsonNaming(PropertyNamingStrategies.UpperCamelCaseStrategy.class)
@JsonInclude(JsonInclude.Include.NON_NULL)
public class KoboTagWrapper implements Entitlement {
private WrappedTag changedTag;
private WrappedTag deletedTag;
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
@JsonNaming(PropertyNamingStrategies.UpperCamelCaseStrategy.class)
public static class WrappedTag {
private KoboTag tag;
}
}

View File

@@ -2,16 +2,21 @@ package org.booklore.service.kobo;
import org.booklore.config.security.service.AuthenticationService;
import org.booklore.mapper.KoboReadingStateMapper;
import org.booklore.model.dto.Book;
import org.booklore.model.dto.kobo.*;
import org.booklore.model.dto.settings.KoboSettings;
import org.booklore.model.entity.*;
import org.booklore.model.enums.BookFileType;
import org.booklore.model.enums.KoboBookFormat;
import org.booklore.model.enums.KoboReadStatus;
import org.booklore.model.enums.ShelfType;
import org.booklore.repository.KoboReadingStateRepository;
import org.booklore.repository.MagicShelfRepository;
import org.booklore.repository.ShelfRepository;
import org.booklore.repository.UserBookProgressRepository;
import org.booklore.service.book.BookQueryService;
import org.booklore.service.appsettings.AppSettingService;
import org.booklore.service.opds.MagicShelfBookService;
import org.booklore.util.kobo.KoboUrlBuilder;
import lombok.AllArgsConstructor;
import lombok.extern.slf4j.Slf4j;
@@ -20,10 +25,7 @@ import org.springframework.stereotype.Service;
import java.math.BigDecimal;
import java.time.OffsetDateTime;
import java.time.ZoneOffset;
import java.util.Collections;
import java.util.List;
import java.util.Optional;
import java.util.Set;
import java.util.*;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
@@ -42,6 +44,9 @@ public class KoboEntitlementService {
private final KoboReadingStateMapper readingStateMapper;
private final AuthenticationService authenticationService;
private final KoboReadingStateBuilder readingStateBuilder;
private final ShelfRepository shelfRepository;
private final MagicShelfRepository magicShelfRepository;
private final MagicShelfBookService magicShelfBookService;
public List<NewEntitlement> generateNewEntitlements(Set<Long> bookIds, String token) {
List<BookEntity> books = bookQueryService.findAllWithMetadataByIds(bookIds);
@@ -105,6 +110,71 @@ public class KoboEntitlementService {
.toList();
}
public List<KoboTagWrapper> generateTags() {
Long userId = authenticationService.getAuthenticatedUser().getId();
List<Long> koboBookIDs = shelfRepository.findByUserIdAndName(userId, ShelfType.KOBO.getName())
.orElseThrow(() -> new NoSuchElementException("Kobo shelf not found for user: " + userId))
.getBookEntities().stream().filter(koboCompatibilityService::isBookSupportedForKobo)
.map(BookEntity::getId)
.toList();
List<KoboTagWrapper> tags = new ArrayList<>();
// Shelves
shelfRepository.findByUserId(userId).stream()
.filter(shelf -> !Objects.equals(shelf.getName(), ShelfType.KOBO.getName()))
.map(shelf -> buildKoboTag("BL-S-" + shelf.getId(), shelf.getName(), null, null,
shelf.getBookEntities().stream().map(BookEntity::getId).toList(), koboBookIDs))
.forEach(tags::add);
// Magic Shelves
magicShelfRepository.findAllByUserId(userId).stream()
.map(magicShelf -> {
List<Long> bookIds = magicShelfBookService.getBooksByMagicShelfId(userId, magicShelf.getId(), 0, Integer.MAX_VALUE)
.stream().map(Book::getId).toList();
return buildKoboTag("BL-MS-" + magicShelf.getId(), magicShelf.getName(),
magicShelf.getCreatedAt().atOffset(ZoneOffset.UTC).toString(), magicShelf.getUpdatedAt().atOffset(ZoneOffset.UTC).toString(),
bookIds, koboBookIDs);
})
.forEach(tags::add);
log.info("Synced {} tags to Kobo", tags.size());
return tags;
}
private KoboTagWrapper buildKoboTag(String id, String name, String created, String modified, List<Long> bookIds, List<Long> koboFilterIds) {
List<KoboTag.KoboTagItem> items = bookIds.stream()
.filter(koboFilterIds::contains)
.map(bookId -> KoboTag.KoboTagItem.builder()
.revisionId(bookId.toString())
.type("ProductRevisionTagItem")
.build())
.toList();
if (items.isEmpty()) {
return KoboTagWrapper.builder()
.deletedTag(KoboTagWrapper.WrappedTag.builder()
.tag(KoboTag.builder()
.id(id)
.lastModified(modified)
.build())
.build())
.build();
}
return KoboTagWrapper.builder()
.changedTag(KoboTagWrapper.WrappedTag.builder()
.tag(KoboTag.builder()
.id(id)
.name(name)
.created(created)
.lastModified(modified)
.type("UserTag")
.items(items)
.build())
.build())
.build();
}
private ChangedReadingState buildChangedReadingState(UserBookProgressEntity progress, String timestamp, OffsetDateTime now) {
String entitlementId = String.valueOf(progress.getBook().getId());

View File

@@ -88,6 +88,7 @@ public class KoboLibrarySyncService {
if (!shouldContinueSync) {
entitlements.addAll(syncReadingStatesToKobo(user.getId(), currSnapshot.getId()));
entitlements.addAll(entitlementService.generateTags());
}
} else {
int maxRemaining = 5;
@@ -104,6 +105,7 @@ public class KoboLibrarySyncService {
if (!shouldContinueSync) {
entitlements.addAll(syncReadingStatesToKobo(user.getId(), currSnapshot.getId()));
entitlements.addAll(entitlementService.generateTags());
}
}

View File

@@ -1,27 +1,46 @@
package org.booklore.service;
import org.booklore.config.security.service.AuthenticationService;
import org.booklore.model.dto.Book;
import org.booklore.model.dto.BookLoreUser;
import org.booklore.model.dto.kobo.KoboBookMetadata;
import org.booklore.model.dto.kobo.KoboTag;
import org.booklore.model.dto.kobo.KoboTagWrapper;
import org.booklore.model.dto.settings.KoboSettings;
import org.booklore.model.entity.BookFileEntity;
import org.booklore.model.entity.BookEntity;
import org.booklore.model.entity.BookMetadataEntity;
import org.booklore.model.entity.MagicShelfEntity;
import org.booklore.model.entity.ShelfEntity;
import org.booklore.model.enums.BookFileType;
import org.booklore.model.enums.KoboBookFormat;
import org.booklore.model.enums.ShelfType;
import org.booklore.repository.MagicShelfRepository;
import org.booklore.repository.ShelfRepository;
import org.booklore.service.appsettings.AppSettingService;
import org.booklore.service.book.BookQueryService;
import org.booklore.service.kobo.KoboCompatibilityService;
import org.booklore.service.kobo.KoboEntitlementService;
import org.booklore.service.opds.MagicShelfBookService;
import org.booklore.util.kobo.KoboUrlBuilder;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.data.domain.PageImpl;
import java.time.LocalDateTime;
import java.time.ZoneOffset;
import java.util.HashSet;
import java.util.List;
import java.util.Optional;
import java.util.Set;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)
@@ -39,9 +58,29 @@ class KoboEntitlementServiceTest {
@Mock
private KoboCompatibilityService koboCompatibilityService;
@Mock
private AuthenticationService authenticationService;
@Mock
private ShelfRepository shelfRepository;
@Mock
private MagicShelfRepository magicShelfRepository;
@Mock
private MagicShelfBookService magicShelfBookService;
@InjectMocks
private KoboEntitlementService koboEntitlementService;
private BookLoreUser user;
@BeforeEach
void setUp() {
BookLoreUser.UserPermissions permissions = new BookLoreUser.UserPermissions();
user = BookLoreUser.builder().permissions(permissions).build();
}
@Test
void getMetadataForBook_shouldUseCompatibilityServiceFilter() {
long bookId = 1L;
@@ -116,4 +155,245 @@ class KoboEntitlementServiceTest {
appSettings.setKoboSettings(koboSettings);
return appSettings;
}
@Test
void generateTags_shouldReturnTagsForShelvesAndMagicShelves() {
BookEntity book1 = createEpubBookEntity(1L);
BookEntity book2 = createEpubBookEntity(2L);
ShelfEntity koboShelf = ShelfEntity.builder().id(100L).name(ShelfType.KOBO.getName()).bookEntities(Set.of(book1, book2)).build();
ShelfEntity userShelf = ShelfEntity.builder().id(101L).name("My Favorites").bookEntities(Set.of(book1)).build();
MagicShelfEntity magicShelf = MagicShelfEntity.builder()
.id(201L)
.userId(user.getId())
.name("Sci-Fi Books")
.icon("pi-book")
.filterJson("{}")
.createdAt(LocalDateTime.now())
.updatedAt(LocalDateTime.now())
.build();
when(authenticationService.getAuthenticatedUser()).thenReturn(user);
when(shelfRepository.findByUserIdAndName(user.getId(), ShelfType.KOBO.getName()))
.thenReturn(Optional.of(koboShelf));
when(koboCompatibilityService.isBookSupportedForKobo(any(BookEntity.class))).thenReturn(true);
when(shelfRepository.findByUserId(user.getId())).thenReturn(List.of(koboShelf, userShelf));
when(magicShelfRepository.findAllByUserId(user.getId())).thenReturn(List.of(magicShelf));
when(magicShelfBookService.getBooksByMagicShelfId(eq(user.getId()), eq(201L), eq(0), eq(Integer.MAX_VALUE)))
.thenReturn(new PageImpl<>(List.of(Book.builder().id(2L).build())));
List<KoboTagWrapper> tags = koboEntitlementService.generateTags();
assertEquals(2, tags.size());
// Verify shelf tag
KoboTagWrapper shelfTag = tags.stream()
.filter(t -> t.getChangedTag() != null && t.getChangedTag().getTag().getId().equals("BL-S-101"))
.findFirst()
.orElseThrow();
assertEquals("My Favorites", shelfTag.getChangedTag().getTag().getName());
assertEquals("UserTag", shelfTag.getChangedTag().getTag().getType());
assertEquals(1, shelfTag.getChangedTag().getTag().getItems().size());
assertEquals("1", shelfTag.getChangedTag().getTag().getItems().getFirst().getRevisionId());
// Verify magic shelf tag
KoboTagWrapper magicShelfTag = tags.stream()
.filter(t -> t.getChangedTag() != null && t.getChangedTag().getTag().getId().equals("BL-MS-201"))
.findFirst()
.orElseThrow();
assertEquals("Sci-Fi Books", magicShelfTag.getChangedTag().getTag().getName());
assertEquals(1, magicShelfTag.getChangedTag().getTag().getItems().size());
}
@Test
void generateTags_shouldExcludeKoboShelfFromTags() {
BookEntity book1 = createEpubBookEntity(1L);
ShelfEntity koboShelf = ShelfEntity.builder().id(100L).name(ShelfType.KOBO.getName()).bookEntities(Set.of(book1)).build();
when(authenticationService.getAuthenticatedUser()).thenReturn(user);
when(shelfRepository.findByUserIdAndName(user.getId(), ShelfType.KOBO.getName()))
.thenReturn(Optional.of(koboShelf));
when(koboCompatibilityService.isBookSupportedForKobo(any(BookEntity.class))).thenReturn(true);
when(shelfRepository.findByUserId(user.getId())).thenReturn(List.of(koboShelf));
when(magicShelfRepository.findAllByUserId(user.getId())).thenReturn(List.of());
List<KoboTagWrapper> tags = koboEntitlementService.generateTags();
assertTrue(tags.isEmpty());
}
@Test
void generateTags_shouldReturnDeletedTagWhenNoMatchingBooks() {
BookEntity book1 = createEpubBookEntity(1L);
BookEntity book2 = createEpubBookEntity(2L);
// Kobo shelf only has book1
ShelfEntity koboShelf = ShelfEntity.builder().id(100L).name(ShelfType.KOBO.getName()).bookEntities(Set.of(book1)).build();
// User shelf only has book2, which is not in Kobo shelf
ShelfEntity userShelf = ShelfEntity.builder().id(101L).name("My Favorites").bookEntities(Set.of(book2)).build();
when(authenticationService.getAuthenticatedUser()).thenReturn(user);
when(shelfRepository.findByUserIdAndName(user.getId(), ShelfType.KOBO.getName()))
.thenReturn(Optional.of(koboShelf));
when(koboCompatibilityService.isBookSupportedForKobo(any(BookEntity.class))).thenReturn(true);
when(shelfRepository.findByUserId(user.getId())).thenReturn(List.of(koboShelf, userShelf));
when(magicShelfRepository.findAllByUserId(user.getId())).thenReturn(List.of());
List<KoboTagWrapper> tags = koboEntitlementService.generateTags();
assertEquals(1, tags.size());
// Should be a deleted tag since book2 is not in Kobo shelf
KoboTagWrapper deletedTag = tags.getFirst();
assertNotNull(deletedTag.getDeletedTag());
assertNull(deletedTag.getChangedTag());
assertEquals("BL-S-101", deletedTag.getDeletedTag().getTag().getId());
}
@Test
void generateTags_shouldFilterBooksNotSupportedForKobo() {
BookEntity supportedBook = createEpubBookEntity(1L);
BookEntity unsupportedBook = createEpubBookEntity(2L);
ShelfEntity koboShelf = ShelfEntity.builder().id(100L).name(ShelfType.KOBO.getName()).bookEntities(Set.of(supportedBook, unsupportedBook)).build();
ShelfEntity userShelf = ShelfEntity.builder().id(101L).name("My Favorites").bookEntities(Set.of(supportedBook, unsupportedBook)).build();
when(authenticationService.getAuthenticatedUser()).thenReturn(user);
when(shelfRepository.findByUserIdAndName(user.getId(), ShelfType.KOBO.getName()))
.thenReturn(Optional.of(koboShelf));
when(koboCompatibilityService.isBookSupportedForKobo(supportedBook)).thenReturn(true);
when(koboCompatibilityService.isBookSupportedForKobo(unsupportedBook)).thenReturn(false);
when(shelfRepository.findByUserId(user.getId())).thenReturn(List.of(koboShelf, userShelf));
when(magicShelfRepository.findAllByUserId(user.getId())).thenReturn(List.of());
List<KoboTagWrapper> tags = koboEntitlementService.generateTags();
assertEquals(1, tags.size());
KoboTagWrapper shelfTag = tags.getFirst();
assertNotNull(shelfTag.getChangedTag());
// Only supported book should be included
assertEquals(1, shelfTag.getChangedTag().getTag().getItems().size());
assertEquals("1", shelfTag.getChangedTag().getTag().getItems().getFirst().getRevisionId());
}
@Test
void generateTags_shouldSetCorrectTagItemType() {
BookEntity book1 = createEpubBookEntity(1L);
ShelfEntity koboShelf = ShelfEntity.builder().id(100L).name(ShelfType.KOBO.getName()).bookEntities(Set.of(book1)).build();
ShelfEntity userShelf = ShelfEntity.builder().id(101L).name("Reading").bookEntities(Set.of(book1)).build();
when(authenticationService.getAuthenticatedUser()).thenReturn(user);
when(shelfRepository.findByUserIdAndName(user.getId(), ShelfType.KOBO.getName()))
.thenReturn(Optional.of(koboShelf));
when(koboCompatibilityService.isBookSupportedForKobo(any(BookEntity.class))).thenReturn(true);
when(shelfRepository.findByUserId(user.getId())).thenReturn(List.of(koboShelf, userShelf));
when(magicShelfRepository.findAllByUserId(user.getId())).thenReturn(List.of());
List<KoboTagWrapper> tags = koboEntitlementService.generateTags();
assertEquals(1, tags.size());
KoboTag.KoboTagItem item = tags.getFirst().getChangedTag().getTag().getItems().getFirst();
assertEquals("ProductRevisionTagItem", item.getType());
}
@Test
void generateTags_shouldUseMagicShelfTimestamps() {
BookEntity book1 = createEpubBookEntity(1L);
ShelfEntity koboShelf = ShelfEntity.builder().id(100L).name(ShelfType.KOBO.getName()).bookEntities(Set.of(book1)).build();
LocalDateTime createdAt = LocalDateTime.of(2024, 1, 15, 10, 30, 0);
LocalDateTime updatedAt = LocalDateTime.of(2024, 6, 20, 14, 45, 0);
MagicShelfEntity magicShelf = MagicShelfEntity.builder()
.id(201L)
.userId(user.getId())
.name("Fantasy")
.icon("pi-book")
.filterJson("{}")
.createdAt(createdAt)
.updatedAt(updatedAt)
.build();
when(authenticationService.getAuthenticatedUser()).thenReturn(user);
when(shelfRepository.findByUserIdAndName(user.getId(), ShelfType.KOBO.getName()))
.thenReturn(Optional.of(koboShelf));
when(koboCompatibilityService.isBookSupportedForKobo(any(BookEntity.class))).thenReturn(true);
when(shelfRepository.findByUserId(user.getId())).thenReturn(List.of(koboShelf));
when(magicShelfRepository.findAllByUserId(user.getId())).thenReturn(List.of(magicShelf));
when(magicShelfBookService.getBooksByMagicShelfId(eq(user.getId()), eq(201L), eq(0), eq(Integer.MAX_VALUE)))
.thenReturn(new PageImpl<>(List.of(Book.builder().id(1L).build())));
List<KoboTagWrapper> tags = koboEntitlementService.generateTags();
assertEquals(1, tags.size());
KoboTag tag = tags.getFirst().getChangedTag().getTag();
assertEquals(createdAt.atOffset(ZoneOffset.UTC).toString(), tag.getCreated());
assertEquals(updatedAt.atOffset(ZoneOffset.UTC).toString(), tag.getLastModified());
}
@Test
void generateTags_shouldHandleEmptyShelvesAndMagicShelves() {
BookEntity book1 = createEpubBookEntity(1L);
ShelfEntity koboShelf = ShelfEntity.builder().id(100L).name(ShelfType.KOBO.getName()).bookEntities(Set.of(book1)).build();
when(authenticationService.getAuthenticatedUser()).thenReturn(user);
when(shelfRepository.findByUserIdAndName(user.getId(), ShelfType.KOBO.getName()))
.thenReturn(Optional.of(koboShelf));
when(koboCompatibilityService.isBookSupportedForKobo(any(BookEntity.class))).thenReturn(true);
when(shelfRepository.findByUserId(user.getId())).thenReturn(List.of(koboShelf));
when(magicShelfRepository.findAllByUserId(user.getId())).thenReturn(List.of());
List<KoboTagWrapper> tags = koboEntitlementService.generateTags();
assertTrue(tags.isEmpty());
}
@Test
void generateTags_shouldIncludeMultipleBooksInSingleTag() {
BookEntity book1 = createEpubBookEntity(1L);
BookEntity book2 = createEpubBookEntity(2L);
BookEntity book3 = createEpubBookEntity(3L);
ShelfEntity koboShelf = ShelfEntity.builder().id(100L).name(ShelfType.KOBO.getName()).bookEntities(Set.of(book1, book2, book3)).build();
ShelfEntity userShelf = ShelfEntity.builder().id(101L).name("Collection").bookEntities(Set.of(book1, book2, book3)).build();
when(authenticationService.getAuthenticatedUser()).thenReturn(user);
when(shelfRepository.findByUserIdAndName(user.getId(), ShelfType.KOBO.getName()))
.thenReturn(Optional.of(koboShelf));
when(koboCompatibilityService.isBookSupportedForKobo(any(BookEntity.class))).thenReturn(true);
when(shelfRepository.findByUserId(user.getId())).thenReturn(List.of(koboShelf, userShelf));
when(magicShelfRepository.findAllByUserId(user.getId())).thenReturn(List.of());
List<KoboTagWrapper> tags = koboEntitlementService.generateTags();
assertEquals(1, tags.size());
KoboTag tag = tags.getFirst().getChangedTag().getTag();
assertEquals(3, tag.getItems().size());
Set<String> revisionIds = new HashSet<>();
tag.getItems().forEach(item -> revisionIds.add(item.getRevisionId()));
assertTrue(revisionIds.contains("1"));
assertTrue(revisionIds.contains("2"));
assertTrue(revisionIds.contains("3"));
}
private BookEntity createEpubBookEntity(Long id) {
BookEntity book = new BookEntity();
book.setId(id);
book.setBookType(BookFileType.EPUB);
book.setFileSizeKb(1024L);
BookMetadataEntity metadata = new BookMetadataEntity();
metadata.setTitle("Test EPUB Book " + id);
metadata.setDescription("A test EPUB book");
metadata.setBookId(id);
book.setMetadata(metadata);
return book;
}
}