diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/KoboSyncSettings.java b/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/KoboSyncSettings.java index 2e630843..98ef2d79 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/KoboSyncSettings.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/KoboSyncSettings.java @@ -12,4 +12,6 @@ public class KoboSyncSettings { private Float progressMarkAsReadingThreshold; private Float progressMarkAsFinishedThreshold; private boolean autoAddToShelf; + private String hardcoverApiKey; + private boolean hardcoverSyncEnabled; } diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/model/entity/KoboUserSettingsEntity.java b/booklore-api/src/main/java/com/adityachandel/booklore/model/entity/KoboUserSettingsEntity.java index 4d1b58f3..5a146e46 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/model/entity/KoboUserSettingsEntity.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/model/entity/KoboUserSettingsEntity.java @@ -37,4 +37,11 @@ public class KoboUserSettingsEntity { @Column(name = "auto_add_to_shelf") @Builder.Default private boolean autoAddToShelf = false; + + @Column(name = "hardcover_api_key", length = 2048) + private String hardcoverApiKey; + + @Column(name = "hardcover_sync_enabled") + @Builder.Default + private boolean hardcoverSyncEnabled = false; } diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/model/entity/UserBookProgressEntity.java b/booklore-api/src/main/java/com/adityachandel/booklore/model/entity/UserBookProgressEntity.java index 4f6d1ec4..b83fea9a 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/model/entity/UserBookProgressEntity.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/model/entity/UserBookProgressEntity.java @@ -69,7 +69,7 @@ public class UserBookProgressEntity { @Column(name = "kobo_location_type", length = 50) private String koboLocationType; - @Column(name = "kobo_location_source", length = 50) + @Column(name = "kobo_location_source", length = 512) private String koboLocationSource; @Enumerated(EnumType.STRING) diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/service/hardcover/HardcoverSyncService.java b/booklore-api/src/main/java/com/adityachandel/booklore/service/hardcover/HardcoverSyncService.java index 6a7e96cf..4e19f3e9 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/service/hardcover/HardcoverSyncService.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/service/hardcover/HardcoverSyncService.java @@ -1,13 +1,14 @@ package com.adityachandel.booklore.service.hardcover; -import com.adityachandel.booklore.model.dto.settings.MetadataProviderSettings; +import com.adityachandel.booklore.model.dto.KoboSyncSettings; import com.adityachandel.booklore.model.entity.BookEntity; import com.adityachandel.booklore.model.entity.BookMetadataEntity; import com.adityachandel.booklore.repository.BookRepository; -import com.adityachandel.booklore.service.appsettings.AppSettingService; +import com.adityachandel.booklore.service.kobo.KoboSettingsService; import com.adityachandel.booklore.service.metadata.parser.hardcover.GraphQLRequest; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Lazy; import org.springframework.http.HttpHeaders; import org.springframework.http.MediaType; import org.springframework.scheduling.annotation.Async; @@ -23,8 +24,8 @@ import java.util.Map; /** * Service to sync reading progress to Hardcover. - * Uses the global Hardcover API token from Metadata Provider Settings. - * Sync only activates if the token is configured and Hardcover is enabled. + * Uses per-user Hardcover API tokens for reading progress sync. + * Each user can configure their own Hardcover API key in Kobo settings. */ @Slf4j @Service @@ -35,12 +36,15 @@ public class HardcoverSyncService { private static final int STATUS_READ = 3; private final RestClient restClient; - private final AppSettingService appSettingService; + private final KoboSettingsService koboSettingsService; private final BookRepository bookRepository; + // Thread-local to hold the current API token for GraphQL requests + private final ThreadLocal currentApiToken = new ThreadLocal<>(); + @Autowired - public HardcoverSyncService(AppSettingService appSettingService, BookRepository bookRepository) { - this.appSettingService = appSettingService; + public HardcoverSyncService(@Lazy KoboSettingsService koboSettingsService, BookRepository bookRepository) { + this.koboSettingsService = koboSettingsService; this.bookRepository = bookRepository; this.restClient = RestClient.builder() .baseUrl(HARDCOVER_API_URL) @@ -50,105 +54,116 @@ public class HardcoverSyncService { /** * Asynchronously sync Kobo reading progress to Hardcover. * This method is non-blocking and will not fail the calling process if sync fails. + * Uses the user's personal Hardcover API key if configured. * * @param bookId The book ID to sync progress for * @param progressPercent The reading progress as a percentage (0-100) + * @param userId The user ID whose reading progress is being synced */ @Async @Transactional(readOnly = true) - public void syncProgressToHardcover(Long bookId, Float progressPercent) { + public void syncProgressToHardcover(Long bookId, Float progressPercent, Long userId) { try { - if (!isHardcoverSyncEnabled()) { - log.trace("Hardcover sync skipped: not enabled or no API token configured"); + // Get user's Hardcover settings + KoboSyncSettings userSettings = koboSettingsService.getSettingsByUserId(userId); + + if (!isHardcoverSyncEnabledForUser(userSettings)) { + log.trace("Hardcover sync skipped for user {}: not enabled or no API token configured", userId); return; } - if (progressPercent == null) { - log.debug("Hardcover sync skipped: no progress to sync"); - return; - } + // Set the user's API token for this sync operation + currentApiToken.set(userSettings.getHardcoverApiKey()); - // Fetch book fresh within the async context to avoid lazy loading issues - BookEntity book = bookRepository.findById(bookId).orElse(null); - if (book == null) { - log.debug("Hardcover sync skipped: book {} not found", bookId); - return; - } - - BookMetadataEntity metadata = book.getMetadata(); - if (metadata == null) { - log.debug("Hardcover sync skipped: book {} has no metadata", bookId); - return; - } - - // Find the book on Hardcover - use stored ID if available - HardcoverBookInfo hardcoverBook; - if (metadata.getHardcoverBookId() != null) { - // Use the stored numeric book ID directly - hardcoverBook = new HardcoverBookInfo(); - hardcoverBook.bookId = metadata.getHardcoverBookId(); - hardcoverBook.pages = metadata.getPageCount(); - log.debug("Using stored Hardcover book ID: {}", hardcoverBook.bookId); - } else { - // Search by ISBN - hardcoverBook = findHardcoverBook(metadata); - if (hardcoverBook == null) { - log.debug("Hardcover sync skipped: book {} not found on Hardcover", bookId); + try { + if (progressPercent == null) { + log.debug("Hardcover sync skipped: no progress to sync"); return; } - } - // Determine the status based on progress - int statusId = progressPercent >= 99.0f ? STATUS_READ : STATUS_CURRENTLY_READING; + // Fetch book fresh within the async context to avoid lazy loading issues + BookEntity book = bookRepository.findById(bookId).orElse(null); + if (book == null) { + log.debug("Hardcover sync skipped: book {} not found", bookId); + return; + } - // Calculate progress in pages - int progressPages = 0; - if (hardcoverBook.pages != null && hardcoverBook.pages > 0) { - progressPages = Math.round((progressPercent / 100.0f) * hardcoverBook.pages); - progressPages = Math.max(0, Math.min(hardcoverBook.pages, progressPages)); - } - log.info("Progress calculation: progressPercent={}%, totalPages={}, progressPages={}", - progressPercent, hardcoverBook.pages, progressPages); + BookMetadataEntity metadata = book.getMetadata(); + if (metadata == null) { + log.debug("Hardcover sync skipped: book {} has no metadata", bookId); + return; + } - // Step 1: Add/update the book in user's library - Integer userBookId = insertOrGetUserBook(hardcoverBook.bookId, hardcoverBook.editionId, statusId); - if (userBookId == null) { - log.warn("Hardcover sync failed: could not get user_book_id for book {}", bookId); - return; - } + // Find the book on Hardcover - use stored ID if available + HardcoverBookInfo hardcoverBook; + if (metadata.getHardcoverBookId() != null) { + // Use the stored numeric book ID directly + hardcoverBook = new HardcoverBookInfo(); + hardcoverBook.bookId = metadata.getHardcoverBookId(); + hardcoverBook.pages = metadata.getPageCount(); + log.debug("Using stored Hardcover book ID: {}", hardcoverBook.bookId); + } else { + // Search by ISBN + hardcoverBook = findHardcoverBook(metadata); + if (hardcoverBook == null) { + log.debug("Hardcover sync skipped: book {} not found on Hardcover", bookId); + return; + } + } - // Step 2: Create or update the reading progress - boolean success = upsertReadingProgress(userBookId, hardcoverBook.editionId, progressPages); - - if (success) { - log.info("Synced progress to Hardcover: book={}, hardcoverBookId={}, progress={}% ({}pages)", - bookId, hardcoverBook.bookId, Math.round(progressPercent), progressPages); + // Determine the status based on progress + int statusId = progressPercent >= 99.0f ? STATUS_READ : STATUS_CURRENTLY_READING; + + // Calculate progress in pages + int progressPages = 0; + if (hardcoverBook.pages != null && hardcoverBook.pages > 0) { + progressPages = Math.round((progressPercent / 100.0f) * hardcoverBook.pages); + progressPages = Math.max(0, Math.min(hardcoverBook.pages, progressPages)); + } + log.info("Progress calculation: userId={}, progressPercent={}%, totalPages={}, progressPages={}", + userId, progressPercent, hardcoverBook.pages, progressPages); + + // Step 1: Add/update the book in user's library + Integer userBookId = insertOrGetUserBook(hardcoverBook.bookId, hardcoverBook.editionId, statusId); + if (userBookId == null) { + log.warn("Hardcover sync failed: could not get user_book_id for book {}", bookId); + return; + } + + // Step 2: Create or update the reading progress + boolean success = upsertReadingProgress(userBookId, hardcoverBook.editionId, progressPages); + + if (success) { + log.info("Synced progress to Hardcover: userId={}, book={}, hardcoverBookId={}, progress={}% ({}pages)", + userId, bookId, hardcoverBook.bookId, Math.round(progressPercent), progressPages); + } + + } finally { + // Clean up thread-local + currentApiToken.remove(); } } catch (Exception e) { - log.error("Failed to sync progress to Hardcover for book {}: {}", - bookId, e.getMessage()); + log.error("Failed to sync progress to Hardcover for book {} (user {}): {}", + bookId, userId, e.getMessage()); } } - private boolean isHardcoverSyncEnabled() { - MetadataProviderSettings.Hardcover hardcoverSettings = - appSettingService.getAppSettings().getMetadataProviderSettings().getHardcover(); - - if (hardcoverSettings == null) { + /** + * Check if Hardcover sync is enabled for a specific user. + */ + private boolean isHardcoverSyncEnabledForUser(KoboSyncSettings userSettings) { + if (userSettings == null) { return false; } - return hardcoverSettings.isEnabled() - && hardcoverSettings.getApiKey() != null - && !hardcoverSettings.getApiKey().isBlank(); + return userSettings.isHardcoverSyncEnabled() + && userSettings.getHardcoverApiKey() != null + && !userSettings.getHardcoverApiKey().isBlank(); } private String getApiToken() { - return appSettingService.getAppSettings() - .getMetadataProviderSettings() - .getHardcover() - .getApiKey(); + return currentApiToken.get(); } /** diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/service/kobo/KoboReadingStateService.java b/booklore-api/src/main/java/com/adityachandel/booklore/service/kobo/KoboReadingStateService.java index 97171740..e21461c5 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/service/kobo/KoboReadingStateService.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/service/kobo/KoboReadingStateService.java @@ -154,6 +154,9 @@ public class KoboReadingStateService { KoboReadingState.CurrentBookmark.Location location = bookmark.getLocation(); if (location != null) { + log.debug("Kobo location data: value={}, type={}, source={} (length={})", + location.getValue(), location.getType(), location.getSource(), + location.getSource() != null ? location.getSource().length() : 0); progress.setKoboLocation(location.getValue()); progress.setKoboLocationType(location.getType()); progress.setKoboLocationSource(location.getSource()); @@ -171,8 +174,8 @@ public class KoboReadingStateService { progressRepository.save(progress); log.debug("Synced Kobo progress: bookId={}, progress={}%", bookId, progress.getKoboProgressPercent()); - // Sync progress to Hardcover asynchronously (if enabled) - hardcoverSyncService.syncProgressToHardcover(book.getId(), progress.getKoboProgressPercent()); + // Sync progress to Hardcover asynchronously (if enabled for this user) + hardcoverSyncService.syncProgressToHardcover(book.getId(), progress.getKoboProgressPercent(), userId); } catch (NumberFormatException e) { log.warn("Invalid entitlement ID format: {}", readingState.getEntitlementId()); } diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/service/kobo/KoboSettingsService.java b/booklore-api/src/main/java/com/adityachandel/booklore/service/kobo/KoboSettingsService.java index 7c85a393..130a8722 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/service/kobo/KoboSettingsService.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/service/kobo/KoboSettingsService.java @@ -82,6 +82,10 @@ public class KoboSettingsService { entity.setAutoAddToShelf(settings.isAutoAddToShelf()); + // Update Hardcover settings + entity.setHardcoverApiKey(settings.getHardcoverApiKey()); + entity.setHardcoverSyncEnabled(settings.isHardcoverSyncEnabled()); + repository.save(entity); return mapToDto(entity); } @@ -122,6 +126,19 @@ public class KoboSettingsService { dto.setProgressMarkAsReadingThreshold(entity.getProgressMarkAsReadingThreshold()); dto.setProgressMarkAsFinishedThreshold(entity.getProgressMarkAsFinishedThreshold()); dto.setAutoAddToShelf(entity.isAutoAddToShelf()); + dto.setHardcoverApiKey(entity.getHardcoverApiKey()); + dto.setHardcoverSyncEnabled(entity.isHardcoverSyncEnabled()); return dto; } + + /** + * Get Hardcover settings for a specific user by ID. + * Used by HardcoverSyncService to get user-specific API key. + */ + @Transactional(readOnly = true) + public KoboSyncSettings getSettingsByUserId(Long userId) { + return repository.findByUserId(userId) + .map(this::mapToDto) + .orElse(null); + } } diff --git a/booklore-api/src/main/resources/db/migration/V76__Add_user_hardcover_settings.sql b/booklore-api/src/main/resources/db/migration/V76__Add_user_hardcover_settings.sql new file mode 100644 index 00000000..6b502ed3 --- /dev/null +++ b/booklore-api/src/main/resources/db/migration/V76__Add_user_hardcover_settings.sql @@ -0,0 +1,6 @@ +-- Add per-user Hardcover settings to kobo_user_settings table +ALTER TABLE kobo_user_settings ADD COLUMN hardcover_api_key VARCHAR(2048); +ALTER TABLE kobo_user_settings ADD COLUMN hardcover_sync_enabled BOOLEAN DEFAULT FALSE; + +-- Fix kobo_location_source column size (Kobo devices can send longer location strings) +ALTER TABLE user_book_progress MODIFY COLUMN kobo_location_source VARCHAR(512); diff --git a/booklore-api/src/test/java/com/adityachandel/booklore/service/hardcover/HardcoverSyncServiceTest.java b/booklore-api/src/test/java/com/adityachandel/booklore/service/hardcover/HardcoverSyncServiceTest.java index e168ddee..939bbf17 100644 --- a/booklore-api/src/test/java/com/adityachandel/booklore/service/hardcover/HardcoverSyncServiceTest.java +++ b/booklore-api/src/test/java/com/adityachandel/booklore/service/hardcover/HardcoverSyncServiceTest.java @@ -1,11 +1,10 @@ package com.adityachandel.booklore.service.hardcover; -import com.adityachandel.booklore.model.dto.settings.AppSettings; -import com.adityachandel.booklore.model.dto.settings.MetadataProviderSettings; +import com.adityachandel.booklore.model.dto.KoboSyncSettings; import com.adityachandel.booklore.model.entity.BookEntity; import com.adityachandel.booklore.model.entity.BookMetadataEntity; import com.adityachandel.booklore.repository.BookRepository; -import com.adityachandel.booklore.service.appsettings.AppSettingService; +import com.adityachandel.booklore.service.kobo.KoboSettingsService; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -31,7 +30,7 @@ import static org.mockito.Mockito.*; class HardcoverSyncServiceTest { @Mock - private AppSettingService appSettingService; + private KoboSettingsService koboSettingsService; @Mock private BookRepository bookRepository; @@ -52,15 +51,15 @@ class HardcoverSyncServiceTest { private BookEntity testBook; private BookMetadataEntity testMetadata; - private AppSettings appSettings; - private MetadataProviderSettings.Hardcover hardcoverSettings; + private KoboSyncSettings koboSyncSettings; private static final Long TEST_BOOK_ID = 100L; + private static final Long TEST_USER_ID = 1L; @BeforeEach void setUp() throws Exception { // Create service with mocked dependencies - service = new HardcoverSyncService(appSettingService, bookRepository); + service = new HardcoverSyncService(koboSettingsService, bookRepository); // Inject our mocked restClient using reflection Field restClientField = HardcoverSyncService.class.getDeclaredField("restClient"); @@ -75,15 +74,12 @@ class HardcoverSyncServiceTest { testMetadata.setPageCount(300); testBook.setMetadata(testMetadata); - appSettings = new AppSettings(); - MetadataProviderSettings metadataSettings = new MetadataProviderSettings(); - hardcoverSettings = new MetadataProviderSettings.Hardcover(); - hardcoverSettings.setEnabled(true); - hardcoverSettings.setApiKey("test-api-key"); - metadataSettings.setHardcover(hardcoverSettings); - appSettings.setMetadataProviderSettings(metadataSettings); + // Setup Kobo sync settings with Hardcover enabled + koboSyncSettings = new KoboSyncSettings(); + koboSyncSettings.setHardcoverSyncEnabled(true); + koboSyncSettings.setHardcoverApiKey("test-api-key"); - when(appSettingService.getAppSettings()).thenReturn(appSettings); + when(koboSettingsService.getSettingsByUserId(TEST_USER_ID)).thenReturn(koboSyncSettings); when(bookRepository.findById(TEST_BOOK_ID)).thenReturn(Optional.of(testBook)); // Setup RestClient mock chain - handles multiple calls @@ -97,11 +93,11 @@ class HardcoverSyncServiceTest { // === Tests for skipping sync (no API calls should be made) === @Test - @DisplayName("Should skip sync when Hardcover is not enabled") + @DisplayName("Should skip sync when Hardcover sync is not enabled for user") void syncProgressToHardcover_whenHardcoverDisabled_shouldSkip() { - hardcoverSettings.setEnabled(false); + koboSyncSettings.setHardcoverSyncEnabled(false); - service.syncProgressToHardcover(TEST_BOOK_ID, 50.0f); + service.syncProgressToHardcover(TEST_BOOK_ID, 50.0f, TEST_USER_ID); verify(restClient, never()).post(); } @@ -109,9 +105,9 @@ class HardcoverSyncServiceTest { @Test @DisplayName("Should skip sync when API key is missing") void syncProgressToHardcover_whenApiKeyMissing_shouldSkip() { - hardcoverSettings.setApiKey(null); + koboSyncSettings.setHardcoverApiKey(null); - service.syncProgressToHardcover(TEST_BOOK_ID, 50.0f); + service.syncProgressToHardcover(TEST_BOOK_ID, 50.0f, TEST_USER_ID); verify(restClient, never()).post(); } @@ -119,9 +115,9 @@ class HardcoverSyncServiceTest { @Test @DisplayName("Should skip sync when API key is blank") void syncProgressToHardcover_whenApiKeyBlank_shouldSkip() { - hardcoverSettings.setApiKey(" "); + koboSyncSettings.setHardcoverApiKey(" "); - service.syncProgressToHardcover(TEST_BOOK_ID, 50.0f); + service.syncProgressToHardcover(TEST_BOOK_ID, 50.0f, TEST_USER_ID); verify(restClient, never()).post(); } @@ -129,7 +125,7 @@ class HardcoverSyncServiceTest { @Test @DisplayName("Should skip sync when progress is null") void syncProgressToHardcover_whenProgressNull_shouldSkip() { - service.syncProgressToHardcover(TEST_BOOK_ID, null); + service.syncProgressToHardcover(TEST_BOOK_ID, null, TEST_USER_ID); verify(restClient, never()).post(); } @@ -139,7 +135,7 @@ class HardcoverSyncServiceTest { void syncProgressToHardcover_whenBookNotFound_shouldSkip() { when(bookRepository.findById(TEST_BOOK_ID)).thenReturn(Optional.empty()); - service.syncProgressToHardcover(TEST_BOOK_ID, 50.0f); + service.syncProgressToHardcover(TEST_BOOK_ID, 50.0f, TEST_USER_ID); verify(restClient, never()).post(); } @@ -149,7 +145,7 @@ class HardcoverSyncServiceTest { void syncProgressToHardcover_whenNoMetadata_shouldSkip() { testBook.setMetadata(null); - service.syncProgressToHardcover(TEST_BOOK_ID, 50.0f); + service.syncProgressToHardcover(TEST_BOOK_ID, 50.0f, TEST_USER_ID); verify(restClient, never()).post(); } @@ -160,7 +156,7 @@ class HardcoverSyncServiceTest { testMetadata.setIsbn13(null); testMetadata.setIsbn10(null); - service.syncProgressToHardcover(TEST_BOOK_ID, 50.0f); + service.syncProgressToHardcover(TEST_BOOK_ID, 50.0f, TEST_USER_ID); verify(restClient, never()).post(); } @@ -179,7 +175,7 @@ class HardcoverSyncServiceTest { .thenReturn(createEmptyUserBookReadsResponse()) .thenReturn(createInsertUserBookReadResponse()); - service.syncProgressToHardcover(TEST_BOOK_ID, 50.0f); + service.syncProgressToHardcover(TEST_BOOK_ID, 50.0f, TEST_USER_ID); // Verify API was called at least once (using stored ID, no search needed) verify(restClient, atLeastOnce()).post(); @@ -195,7 +191,7 @@ class HardcoverSyncServiceTest { .thenReturn(createEmptyUserBookReadsResponse()) .thenReturn(createInsertUserBookReadResponse()); - service.syncProgressToHardcover(TEST_BOOK_ID, 50.0f); + service.syncProgressToHardcover(TEST_BOOK_ID, 50.0f, TEST_USER_ID); // Verify API was called at least once verify(restClient, atLeastOnce()).post(); @@ -207,7 +203,7 @@ class HardcoverSyncServiceTest { // Mock: search returns empty results when(responseSpec.body(Map.class)).thenReturn(createEmptySearchResponse()); - service.syncProgressToHardcover(TEST_BOOK_ID, 50.0f); + service.syncProgressToHardcover(TEST_BOOK_ID, 50.0f, TEST_USER_ID); // Should call search only verify(restClient, times(1)).post(); @@ -224,7 +220,7 @@ class HardcoverSyncServiceTest { .thenReturn(createEmptyUserBookReadsResponse()) .thenReturn(createInsertUserBookReadResponse()); - service.syncProgressToHardcover(TEST_BOOK_ID, 99.0f); + service.syncProgressToHardcover(TEST_BOOK_ID, 99.0f, TEST_USER_ID); verify(restClient, atLeastOnce()).post(); } @@ -240,7 +236,7 @@ class HardcoverSyncServiceTest { .thenReturn(createEmptyUserBookReadsResponse()) .thenReturn(createInsertUserBookReadResponse()); - service.syncProgressToHardcover(TEST_BOOK_ID, 50.0f); + service.syncProgressToHardcover(TEST_BOOK_ID, 50.0f, TEST_USER_ID); verify(restClient, atLeastOnce()).post(); } @@ -257,7 +253,7 @@ class HardcoverSyncServiceTest { .thenReturn(createEmptyUserBookReadsResponse()) .thenReturn(createInsertUserBookReadResponse()); - service.syncProgressToHardcover(TEST_BOOK_ID, 50.0f); + service.syncProgressToHardcover(TEST_BOOK_ID, 50.0f, TEST_USER_ID); verify(restClient, atLeastOnce()).post(); } @@ -273,7 +269,7 @@ class HardcoverSyncServiceTest { .thenReturn(createFindUserBookReadResponse(6001)) .thenReturn(createUpdateUserBookReadResponse()); - service.syncProgressToHardcover(TEST_BOOK_ID, 50.0f); + service.syncProgressToHardcover(TEST_BOOK_ID, 50.0f, TEST_USER_ID); verify(restClient, atLeastOnce()).post(); } @@ -290,7 +286,7 @@ class HardcoverSyncServiceTest { .thenReturn(createEmptyUserBookReadsResponse()) .thenReturn(createInsertUserBookReadResponse()); - service.syncProgressToHardcover(TEST_BOOK_ID, 50.0f); + service.syncProgressToHardcover(TEST_BOOK_ID, 50.0f, TEST_USER_ID); verify(restClient, atLeastOnce()).post(); } @@ -304,7 +300,7 @@ class HardcoverSyncServiceTest { when(responseSpec.body(Map.class)).thenReturn(Map.of("errors", List.of(Map.of("message", "Unauthorized")))); - assertDoesNotThrow(() -> service.syncProgressToHardcover(TEST_BOOK_ID, 50.0f)); + assertDoesNotThrow(() -> service.syncProgressToHardcover(TEST_BOOK_ID, 50.0f, TEST_USER_ID)); } @Test @@ -314,7 +310,17 @@ class HardcoverSyncServiceTest { when(responseSpec.body(Map.class)).thenReturn(null); - assertDoesNotThrow(() -> service.syncProgressToHardcover(TEST_BOOK_ID, 50.0f)); + assertDoesNotThrow(() -> service.syncProgressToHardcover(TEST_BOOK_ID, 50.0f, TEST_USER_ID)); + } + + @Test + @DisplayName("Should skip sync when user settings not found") + void syncProgressToHardcover_whenUserSettingsNotFound_shouldSkip() { + when(koboSettingsService.getSettingsByUserId(TEST_USER_ID)).thenReturn(null); + + service.syncProgressToHardcover(TEST_BOOK_ID, 50.0f, TEST_USER_ID); + + verify(restClient, never()).post(); } // === Helper methods to create mock responses === diff --git a/booklore-ui/src/app/features/settings/device-settings/component/kobo-sync-settings/kobo-sync-settings-component.html b/booklore-ui/src/app/features/settings/device-settings/component/kobo-sync-settings/kobo-sync-settings-component.html index 134a7328..1a80d400 100644 --- a/booklore-ui/src/app/features/settings/device-settings/component/kobo-sync-settings/kobo-sync-settings-component.html +++ b/booklore-ui/src/app/features/settings/device-settings/component/kobo-sync-settings/kobo-sync-settings-component.html @@ -174,6 +174,82 @@ + @if (koboSyncSettings.syncEnabled) { +
+
+

+ + Hardcover Integration +

+

+ Sync your Kobo reading progress to your personal Hardcover account. +

+
+ +
+
+
+
+ + + +
+

+ When enabled, your reading progress from Kobo will be automatically synced to your Hardcover account. + Each user can configure their own Hardcover account. +

+
+
+ + @if (koboSyncSettings.hardcoverSyncEnabled) { +
+
+
+ +
+ + + + + +
+
+

+ Your personal Hardcover API key. Get it from + hardcover.app/account/api. + This key is used only for syncing your reading progress and is not shared with other users. +

+
+
+ } +
+
+ } + @if (isAdmin) {
diff --git a/booklore-ui/src/app/features/settings/device-settings/component/kobo-sync-settings/kobo-sync-settings-component.ts b/booklore-ui/src/app/features/settings/device-settings/component/kobo-sync-settings/kobo-sync-settings-component.ts index 15163e3d..0dac94b4 100644 --- a/booklore-ui/src/app/features/settings/device-settings/component/kobo-sync-settings/kobo-sync-settings-component.ts +++ b/booklore-ui/src/app/features/settings/device-settings/component/kobo-sync-settings/kobo-sync-settings-component.ts @@ -43,6 +43,7 @@ export class KoboSyncSettingsComponent implements OnInit, OnDestroy { isAdmin = false; credentialsSaved = false; showToken = false; + showHardcoverApiKey = false; koboSettings: KoboSettings = { convertToKepub: false, @@ -58,7 +59,9 @@ export class KoboSyncSettingsComponent implements OnInit, OnDestroy { syncEnabled: false, progressMarkAsReadingThreshold: 1, progressMarkAsFinishedThreshold: 99, - autoAddToShelf: true + autoAddToShelf: true, + hardcoverApiKey: '', + hardcoverSyncEnabled: false } ngOnInit() { @@ -119,6 +122,8 @@ export class KoboSyncSettingsComponent implements OnInit, OnDestroy { this.koboSyncSettings.progressMarkAsReadingThreshold = settings.progressMarkAsReadingThreshold ?? 1; this.koboSyncSettings.progressMarkAsFinishedThreshold = settings.progressMarkAsFinishedThreshold ?? 99; this.koboSyncSettings.autoAddToShelf = settings.autoAddToShelf ?? false; + this.koboSyncSettings.hardcoverApiKey = settings.hardcoverApiKey ?? ''; + this.koboSyncSettings.hardcoverSyncEnabled = settings.hardcoverSyncEnabled ?? false; this.credentialsSaved = !!settings.token; }, error: () => { @@ -167,6 +172,21 @@ export class KoboSyncSettingsComponent implements OnInit, OnDestroy { this.showToken = !this.showToken; } + toggleShowHardcoverApiKey() { + this.showHardcoverApiKey = !this.showHardcoverApiKey; + } + + onHardcoverSyncToggle() { + const message = this.koboSyncSettings.hardcoverSyncEnabled + ? 'Hardcover sync enabled' + : 'Hardcover sync disabled'; + this.updateKoboSettings(message); + } + + onHardcoverApiKeyChange() { + this.updateKoboSettings('Hardcover API key updated'); + } + confirmRegenerateToken() { this.confirmationService.confirm({ message: 'This will generate a new token and invalidate the previous one. Continue?', diff --git a/booklore-ui/src/app/features/settings/device-settings/component/kobo-sync-settings/kobo.service.ts b/booklore-ui/src/app/features/settings/device-settings/component/kobo-sync-settings/kobo.service.ts index 68ea7d0c..2adf3bf8 100644 --- a/booklore-ui/src/app/features/settings/device-settings/component/kobo-sync-settings/kobo.service.ts +++ b/booklore-ui/src/app/features/settings/device-settings/component/kobo-sync-settings/kobo.service.ts @@ -9,6 +9,8 @@ export interface KoboSyncSettings { progressMarkAsReadingThreshold?: number; progressMarkAsFinishedThreshold?: number; autoAddToShelf: boolean; + hardcoverApiKey?: string; + hardcoverSyncEnabled?: boolean; } @Injectable({