diff --git a/booklore-api/src/main/java/org/booklore/model/dto/kobo/KoboTag.java b/booklore-api/src/main/java/org/booklore/model/dto/kobo/KoboTag.java new file mode 100644 index 000000000..93ea36509 --- /dev/null +++ b/booklore-api/src/main/java/org/booklore/model/dto/kobo/KoboTag.java @@ -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 items; + + @Data + @NoArgsConstructor + @AllArgsConstructor + @JsonNaming(PropertyNamingStrategies.UpperCamelCaseStrategy.class) + @Builder + @JsonInclude(JsonInclude.Include.NON_NULL) + public static class KoboTagItem { + private String revisionId; + private String type; + } +} diff --git a/booklore-api/src/main/java/org/booklore/model/dto/kobo/KoboTagWrapper.java b/booklore-api/src/main/java/org/booklore/model/dto/kobo/KoboTagWrapper.java new file mode 100644 index 000000000..7dbd812c6 --- /dev/null +++ b/booklore-api/src/main/java/org/booklore/model/dto/kobo/KoboTagWrapper.java @@ -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; + } +} 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 35d6805b4..5e7b3b583 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 @@ -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 generateNewEntitlements(Set bookIds, String token) { List books = bookQueryService.findAllWithMetadataByIds(bookIds); @@ -105,6 +110,71 @@ public class KoboEntitlementService { .toList(); } + public List generateTags() { + Long userId = authenticationService.getAuthenticatedUser().getId(); + List 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 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 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 bookIds, List koboFilterIds) { + List 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()); 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 2bac21e30..60929ec4c 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 @@ -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()); } } 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 1fe736ecd..b9e32ca19 100644 --- a/booklore-api/src/test/java/org/booklore/service/KoboEntitlementServiceTest.java +++ b/booklore-api/src/test/java/org/booklore/service/KoboEntitlementServiceTest.java @@ -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 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 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 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 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 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 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 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 tags = koboEntitlementService.generateTags(); + + assertEquals(1, tags.size()); + + KoboTag tag = tags.getFirst().getChangedTag().getTag(); + assertEquals(3, tag.getItems().size()); + + Set 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; + } + } \ No newline at end of file