mirror of
https://github.com/booklore-app/booklore.git
synced 2025-12-23 22:28:11 -05:00
Fix/hardcover api key per user (#1943)
* feat: add hardcover API key and sync settings to KoboSync and KoboUserSettings entities * feat(hardcover): enhance sync functionality to use per-user API keys - Updated HardcoverSyncService to utilize user-specific Hardcover API keys for syncing reading progress. - Modified syncProgressToHardcover method to accept userId, allowing for personalized sync settings. - Improved logging to include userId in sync operations for better traceability. - Adjusted KoboReadingStateService to pass userId when triggering sync to Hardcover. * feat(database): update kobo_location_source column size and add hardcover settings - Increased the size of the kobo_location_source column to accommodate longer location strings from Kobo devices. - Added new columns for hardcover API key and sync settings in the kobo_user_settings table to enhance user customization. * refactor(hardcover): update tests to use user-specific Kobo settings - Replaced AppSettingService with KoboSettingsService in HardcoverSyncServiceTest to utilize user-specific settings. - Modified syncProgressToHardcover method calls to include userId for personalized sync operations. - Added a new test case to handle scenarios where user settings are not found, ensuring robust error handling. --------- Co-authored-by: akiraslingshot <akiraslingshot@gmail.com>
This commit is contained in:
committed by
GitHub
parent
54108754f9
commit
32a1a2ac34
@@ -12,4 +12,6 @@ public class KoboSyncSettings {
|
|||||||
private Float progressMarkAsReadingThreshold;
|
private Float progressMarkAsReadingThreshold;
|
||||||
private Float progressMarkAsFinishedThreshold;
|
private Float progressMarkAsFinishedThreshold;
|
||||||
private boolean autoAddToShelf;
|
private boolean autoAddToShelf;
|
||||||
|
private String hardcoverApiKey;
|
||||||
|
private boolean hardcoverSyncEnabled;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,4 +37,11 @@ public class KoboUserSettingsEntity {
|
|||||||
@Column(name = "auto_add_to_shelf")
|
@Column(name = "auto_add_to_shelf")
|
||||||
@Builder.Default
|
@Builder.Default
|
||||||
private boolean autoAddToShelf = false;
|
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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ public class UserBookProgressEntity {
|
|||||||
@Column(name = "kobo_location_type", length = 50)
|
@Column(name = "kobo_location_type", length = 50)
|
||||||
private String koboLocationType;
|
private String koboLocationType;
|
||||||
|
|
||||||
@Column(name = "kobo_location_source", length = 50)
|
@Column(name = "kobo_location_source", length = 512)
|
||||||
private String koboLocationSource;
|
private String koboLocationSource;
|
||||||
|
|
||||||
@Enumerated(EnumType.STRING)
|
@Enumerated(EnumType.STRING)
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
package com.adityachandel.booklore.service.hardcover;
|
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.BookEntity;
|
||||||
import com.adityachandel.booklore.model.entity.BookMetadataEntity;
|
import com.adityachandel.booklore.model.entity.BookMetadataEntity;
|
||||||
import com.adityachandel.booklore.repository.BookRepository;
|
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 com.adityachandel.booklore.service.metadata.parser.hardcover.GraphQLRequest;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.context.annotation.Lazy;
|
||||||
import org.springframework.http.HttpHeaders;
|
import org.springframework.http.HttpHeaders;
|
||||||
import org.springframework.http.MediaType;
|
import org.springframework.http.MediaType;
|
||||||
import org.springframework.scheduling.annotation.Async;
|
import org.springframework.scheduling.annotation.Async;
|
||||||
@@ -23,8 +24,8 @@ import java.util.Map;
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Service to sync reading progress to Hardcover.
|
* Service to sync reading progress to Hardcover.
|
||||||
* Uses the global Hardcover API token from Metadata Provider Settings.
|
* Uses per-user Hardcover API tokens for reading progress sync.
|
||||||
* Sync only activates if the token is configured and Hardcover is enabled.
|
* Each user can configure their own Hardcover API key in Kobo settings.
|
||||||
*/
|
*/
|
||||||
@Slf4j
|
@Slf4j
|
||||||
@Service
|
@Service
|
||||||
@@ -35,12 +36,15 @@ public class HardcoverSyncService {
|
|||||||
private static final int STATUS_READ = 3;
|
private static final int STATUS_READ = 3;
|
||||||
|
|
||||||
private final RestClient restClient;
|
private final RestClient restClient;
|
||||||
private final AppSettingService appSettingService;
|
private final KoboSettingsService koboSettingsService;
|
||||||
private final BookRepository bookRepository;
|
private final BookRepository bookRepository;
|
||||||
|
|
||||||
|
// Thread-local to hold the current API token for GraphQL requests
|
||||||
|
private final ThreadLocal<String> currentApiToken = new ThreadLocal<>();
|
||||||
|
|
||||||
@Autowired
|
@Autowired
|
||||||
public HardcoverSyncService(AppSettingService appSettingService, BookRepository bookRepository) {
|
public HardcoverSyncService(@Lazy KoboSettingsService koboSettingsService, BookRepository bookRepository) {
|
||||||
this.appSettingService = appSettingService;
|
this.koboSettingsService = koboSettingsService;
|
||||||
this.bookRepository = bookRepository;
|
this.bookRepository = bookRepository;
|
||||||
this.restClient = RestClient.builder()
|
this.restClient = RestClient.builder()
|
||||||
.baseUrl(HARDCOVER_API_URL)
|
.baseUrl(HARDCOVER_API_URL)
|
||||||
@@ -50,105 +54,116 @@ public class HardcoverSyncService {
|
|||||||
/**
|
/**
|
||||||
* Asynchronously sync Kobo reading progress to Hardcover.
|
* Asynchronously sync Kobo reading progress to Hardcover.
|
||||||
* This method is non-blocking and will not fail the calling process if sync fails.
|
* 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 bookId The book ID to sync progress for
|
||||||
* @param progressPercent The reading progress as a percentage (0-100)
|
* @param progressPercent The reading progress as a percentage (0-100)
|
||||||
|
* @param userId The user ID whose reading progress is being synced
|
||||||
*/
|
*/
|
||||||
@Async
|
@Async
|
||||||
@Transactional(readOnly = true)
|
@Transactional(readOnly = true)
|
||||||
public void syncProgressToHardcover(Long bookId, Float progressPercent) {
|
public void syncProgressToHardcover(Long bookId, Float progressPercent, Long userId) {
|
||||||
try {
|
try {
|
||||||
if (!isHardcoverSyncEnabled()) {
|
// Get user's Hardcover settings
|
||||||
log.trace("Hardcover sync skipped: not enabled or no API token configured");
|
KoboSyncSettings userSettings = koboSettingsService.getSettingsByUserId(userId);
|
||||||
|
|
||||||
|
if (!isHardcoverSyncEnabledForUser(userSettings)) {
|
||||||
|
log.trace("Hardcover sync skipped for user {}: not enabled or no API token configured", userId);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (progressPercent == null) {
|
// Set the user's API token for this sync operation
|
||||||
log.debug("Hardcover sync skipped: no progress to sync");
|
currentApiToken.set(userSettings.getHardcoverApiKey());
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fetch book fresh within the async context to avoid lazy loading issues
|
try {
|
||||||
BookEntity book = bookRepository.findById(bookId).orElse(null);
|
if (progressPercent == null) {
|
||||||
if (book == null) {
|
log.debug("Hardcover sync skipped: no progress to sync");
|
||||||
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);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Determine the status based on progress
|
// Fetch book fresh within the async context to avoid lazy loading issues
|
||||||
int statusId = progressPercent >= 99.0f ? STATUS_READ : STATUS_CURRENTLY_READING;
|
BookEntity book = bookRepository.findById(bookId).orElse(null);
|
||||||
|
if (book == null) {
|
||||||
|
log.debug("Hardcover sync skipped: book {} not found", bookId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Calculate progress in pages
|
BookMetadataEntity metadata = book.getMetadata();
|
||||||
int progressPages = 0;
|
if (metadata == null) {
|
||||||
if (hardcoverBook.pages != null && hardcoverBook.pages > 0) {
|
log.debug("Hardcover sync skipped: book {} has no metadata", bookId);
|
||||||
progressPages = Math.round((progressPercent / 100.0f) * hardcoverBook.pages);
|
return;
|
||||||
progressPages = Math.max(0, Math.min(hardcoverBook.pages, progressPages));
|
}
|
||||||
}
|
|
||||||
log.info("Progress calculation: progressPercent={}%, totalPages={}, progressPages={}",
|
|
||||||
progressPercent, hardcoverBook.pages, progressPages);
|
|
||||||
|
|
||||||
// Step 1: Add/update the book in user's library
|
// Find the book on Hardcover - use stored ID if available
|
||||||
Integer userBookId = insertOrGetUserBook(hardcoverBook.bookId, hardcoverBook.editionId, statusId);
|
HardcoverBookInfo hardcoverBook;
|
||||||
if (userBookId == null) {
|
if (metadata.getHardcoverBookId() != null) {
|
||||||
log.warn("Hardcover sync failed: could not get user_book_id for book {}", bookId);
|
// Use the stored numeric book ID directly
|
||||||
return;
|
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
|
// Determine the status based on progress
|
||||||
boolean success = upsertReadingProgress(userBookId, hardcoverBook.editionId, progressPages);
|
int statusId = progressPercent >= 99.0f ? STATUS_READ : STATUS_CURRENTLY_READING;
|
||||||
|
|
||||||
if (success) {
|
// Calculate progress in pages
|
||||||
log.info("Synced progress to Hardcover: book={}, hardcoverBookId={}, progress={}% ({}pages)",
|
int progressPages = 0;
|
||||||
bookId, hardcoverBook.bookId, Math.round(progressPercent), progressPages);
|
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) {
|
} catch (Exception e) {
|
||||||
log.error("Failed to sync progress to Hardcover for book {}: {}",
|
log.error("Failed to sync progress to Hardcover for book {} (user {}): {}",
|
||||||
bookId, e.getMessage());
|
bookId, userId, e.getMessage());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private boolean isHardcoverSyncEnabled() {
|
/**
|
||||||
MetadataProviderSettings.Hardcover hardcoverSettings =
|
* Check if Hardcover sync is enabled for a specific user.
|
||||||
appSettingService.getAppSettings().getMetadataProviderSettings().getHardcover();
|
*/
|
||||||
|
private boolean isHardcoverSyncEnabledForUser(KoboSyncSettings userSettings) {
|
||||||
if (hardcoverSettings == null) {
|
if (userSettings == null) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
return hardcoverSettings.isEnabled()
|
return userSettings.isHardcoverSyncEnabled()
|
||||||
&& hardcoverSettings.getApiKey() != null
|
&& userSettings.getHardcoverApiKey() != null
|
||||||
&& !hardcoverSettings.getApiKey().isBlank();
|
&& !userSettings.getHardcoverApiKey().isBlank();
|
||||||
}
|
}
|
||||||
|
|
||||||
private String getApiToken() {
|
private String getApiToken() {
|
||||||
return appSettingService.getAppSettings()
|
return currentApiToken.get();
|
||||||
.getMetadataProviderSettings()
|
|
||||||
.getHardcover()
|
|
||||||
.getApiKey();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -154,6 +154,9 @@ public class KoboReadingStateService {
|
|||||||
|
|
||||||
KoboReadingState.CurrentBookmark.Location location = bookmark.getLocation();
|
KoboReadingState.CurrentBookmark.Location location = bookmark.getLocation();
|
||||||
if (location != null) {
|
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.setKoboLocation(location.getValue());
|
||||||
progress.setKoboLocationType(location.getType());
|
progress.setKoboLocationType(location.getType());
|
||||||
progress.setKoboLocationSource(location.getSource());
|
progress.setKoboLocationSource(location.getSource());
|
||||||
@@ -171,8 +174,8 @@ public class KoboReadingStateService {
|
|||||||
progressRepository.save(progress);
|
progressRepository.save(progress);
|
||||||
log.debug("Synced Kobo progress: bookId={}, progress={}%", bookId, progress.getKoboProgressPercent());
|
log.debug("Synced Kobo progress: bookId={}, progress={}%", bookId, progress.getKoboProgressPercent());
|
||||||
|
|
||||||
// Sync progress to Hardcover asynchronously (if enabled)
|
// Sync progress to Hardcover asynchronously (if enabled for this user)
|
||||||
hardcoverSyncService.syncProgressToHardcover(book.getId(), progress.getKoboProgressPercent());
|
hardcoverSyncService.syncProgressToHardcover(book.getId(), progress.getKoboProgressPercent(), userId);
|
||||||
} catch (NumberFormatException e) {
|
} catch (NumberFormatException e) {
|
||||||
log.warn("Invalid entitlement ID format: {}", readingState.getEntitlementId());
|
log.warn("Invalid entitlement ID format: {}", readingState.getEntitlementId());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -82,6 +82,10 @@ public class KoboSettingsService {
|
|||||||
|
|
||||||
entity.setAutoAddToShelf(settings.isAutoAddToShelf());
|
entity.setAutoAddToShelf(settings.isAutoAddToShelf());
|
||||||
|
|
||||||
|
// Update Hardcover settings
|
||||||
|
entity.setHardcoverApiKey(settings.getHardcoverApiKey());
|
||||||
|
entity.setHardcoverSyncEnabled(settings.isHardcoverSyncEnabled());
|
||||||
|
|
||||||
repository.save(entity);
|
repository.save(entity);
|
||||||
return mapToDto(entity);
|
return mapToDto(entity);
|
||||||
}
|
}
|
||||||
@@ -122,6 +126,19 @@ public class KoboSettingsService {
|
|||||||
dto.setProgressMarkAsReadingThreshold(entity.getProgressMarkAsReadingThreshold());
|
dto.setProgressMarkAsReadingThreshold(entity.getProgressMarkAsReadingThreshold());
|
||||||
dto.setProgressMarkAsFinishedThreshold(entity.getProgressMarkAsFinishedThreshold());
|
dto.setProgressMarkAsFinishedThreshold(entity.getProgressMarkAsFinishedThreshold());
|
||||||
dto.setAutoAddToShelf(entity.isAutoAddToShelf());
|
dto.setAutoAddToShelf(entity.isAutoAddToShelf());
|
||||||
|
dto.setHardcoverApiKey(entity.getHardcoverApiKey());
|
||||||
|
dto.setHardcoverSyncEnabled(entity.isHardcoverSyncEnabled());
|
||||||
return dto;
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
@@ -1,11 +1,10 @@
|
|||||||
package com.adityachandel.booklore.service.hardcover;
|
package com.adityachandel.booklore.service.hardcover;
|
||||||
|
|
||||||
import com.adityachandel.booklore.model.dto.settings.AppSettings;
|
import com.adityachandel.booklore.model.dto.KoboSyncSettings;
|
||||||
import com.adityachandel.booklore.model.dto.settings.MetadataProviderSettings;
|
|
||||||
import com.adityachandel.booklore.model.entity.BookEntity;
|
import com.adityachandel.booklore.model.entity.BookEntity;
|
||||||
import com.adityachandel.booklore.model.entity.BookMetadataEntity;
|
import com.adityachandel.booklore.model.entity.BookMetadataEntity;
|
||||||
import com.adityachandel.booklore.repository.BookRepository;
|
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.BeforeEach;
|
||||||
import org.junit.jupiter.api.DisplayName;
|
import org.junit.jupiter.api.DisplayName;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
@@ -31,7 +30,7 @@ import static org.mockito.Mockito.*;
|
|||||||
class HardcoverSyncServiceTest {
|
class HardcoverSyncServiceTest {
|
||||||
|
|
||||||
@Mock
|
@Mock
|
||||||
private AppSettingService appSettingService;
|
private KoboSettingsService koboSettingsService;
|
||||||
|
|
||||||
@Mock
|
@Mock
|
||||||
private BookRepository bookRepository;
|
private BookRepository bookRepository;
|
||||||
@@ -52,15 +51,15 @@ class HardcoverSyncServiceTest {
|
|||||||
|
|
||||||
private BookEntity testBook;
|
private BookEntity testBook;
|
||||||
private BookMetadataEntity testMetadata;
|
private BookMetadataEntity testMetadata;
|
||||||
private AppSettings appSettings;
|
private KoboSyncSettings koboSyncSettings;
|
||||||
private MetadataProviderSettings.Hardcover hardcoverSettings;
|
|
||||||
|
|
||||||
private static final Long TEST_BOOK_ID = 100L;
|
private static final Long TEST_BOOK_ID = 100L;
|
||||||
|
private static final Long TEST_USER_ID = 1L;
|
||||||
|
|
||||||
@BeforeEach
|
@BeforeEach
|
||||||
void setUp() throws Exception {
|
void setUp() throws Exception {
|
||||||
// Create service with mocked dependencies
|
// Create service with mocked dependencies
|
||||||
service = new HardcoverSyncService(appSettingService, bookRepository);
|
service = new HardcoverSyncService(koboSettingsService, bookRepository);
|
||||||
|
|
||||||
// Inject our mocked restClient using reflection
|
// Inject our mocked restClient using reflection
|
||||||
Field restClientField = HardcoverSyncService.class.getDeclaredField("restClient");
|
Field restClientField = HardcoverSyncService.class.getDeclaredField("restClient");
|
||||||
@@ -75,15 +74,12 @@ class HardcoverSyncServiceTest {
|
|||||||
testMetadata.setPageCount(300);
|
testMetadata.setPageCount(300);
|
||||||
testBook.setMetadata(testMetadata);
|
testBook.setMetadata(testMetadata);
|
||||||
|
|
||||||
appSettings = new AppSettings();
|
// Setup Kobo sync settings with Hardcover enabled
|
||||||
MetadataProviderSettings metadataSettings = new MetadataProviderSettings();
|
koboSyncSettings = new KoboSyncSettings();
|
||||||
hardcoverSettings = new MetadataProviderSettings.Hardcover();
|
koboSyncSettings.setHardcoverSyncEnabled(true);
|
||||||
hardcoverSettings.setEnabled(true);
|
koboSyncSettings.setHardcoverApiKey("test-api-key");
|
||||||
hardcoverSettings.setApiKey("test-api-key");
|
|
||||||
metadataSettings.setHardcover(hardcoverSettings);
|
|
||||||
appSettings.setMetadataProviderSettings(metadataSettings);
|
|
||||||
|
|
||||||
when(appSettingService.getAppSettings()).thenReturn(appSettings);
|
when(koboSettingsService.getSettingsByUserId(TEST_USER_ID)).thenReturn(koboSyncSettings);
|
||||||
when(bookRepository.findById(TEST_BOOK_ID)).thenReturn(Optional.of(testBook));
|
when(bookRepository.findById(TEST_BOOK_ID)).thenReturn(Optional.of(testBook));
|
||||||
|
|
||||||
// Setup RestClient mock chain - handles multiple calls
|
// Setup RestClient mock chain - handles multiple calls
|
||||||
@@ -97,11 +93,11 @@ class HardcoverSyncServiceTest {
|
|||||||
// === Tests for skipping sync (no API calls should be made) ===
|
// === Tests for skipping sync (no API calls should be made) ===
|
||||||
|
|
||||||
@Test
|
@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() {
|
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();
|
verify(restClient, never()).post();
|
||||||
}
|
}
|
||||||
@@ -109,9 +105,9 @@ class HardcoverSyncServiceTest {
|
|||||||
@Test
|
@Test
|
||||||
@DisplayName("Should skip sync when API key is missing")
|
@DisplayName("Should skip sync when API key is missing")
|
||||||
void syncProgressToHardcover_whenApiKeyMissing_shouldSkip() {
|
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();
|
verify(restClient, never()).post();
|
||||||
}
|
}
|
||||||
@@ -119,9 +115,9 @@ class HardcoverSyncServiceTest {
|
|||||||
@Test
|
@Test
|
||||||
@DisplayName("Should skip sync when API key is blank")
|
@DisplayName("Should skip sync when API key is blank")
|
||||||
void syncProgressToHardcover_whenApiKeyBlank_shouldSkip() {
|
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();
|
verify(restClient, never()).post();
|
||||||
}
|
}
|
||||||
@@ -129,7 +125,7 @@ class HardcoverSyncServiceTest {
|
|||||||
@Test
|
@Test
|
||||||
@DisplayName("Should skip sync when progress is null")
|
@DisplayName("Should skip sync when progress is null")
|
||||||
void syncProgressToHardcover_whenProgressNull_shouldSkip() {
|
void syncProgressToHardcover_whenProgressNull_shouldSkip() {
|
||||||
service.syncProgressToHardcover(TEST_BOOK_ID, null);
|
service.syncProgressToHardcover(TEST_BOOK_ID, null, TEST_USER_ID);
|
||||||
|
|
||||||
verify(restClient, never()).post();
|
verify(restClient, never()).post();
|
||||||
}
|
}
|
||||||
@@ -139,7 +135,7 @@ class HardcoverSyncServiceTest {
|
|||||||
void syncProgressToHardcover_whenBookNotFound_shouldSkip() {
|
void syncProgressToHardcover_whenBookNotFound_shouldSkip() {
|
||||||
when(bookRepository.findById(TEST_BOOK_ID)).thenReturn(Optional.empty());
|
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();
|
verify(restClient, never()).post();
|
||||||
}
|
}
|
||||||
@@ -149,7 +145,7 @@ class HardcoverSyncServiceTest {
|
|||||||
void syncProgressToHardcover_whenNoMetadata_shouldSkip() {
|
void syncProgressToHardcover_whenNoMetadata_shouldSkip() {
|
||||||
testBook.setMetadata(null);
|
testBook.setMetadata(null);
|
||||||
|
|
||||||
service.syncProgressToHardcover(TEST_BOOK_ID, 50.0f);
|
service.syncProgressToHardcover(TEST_BOOK_ID, 50.0f, TEST_USER_ID);
|
||||||
|
|
||||||
verify(restClient, never()).post();
|
verify(restClient, never()).post();
|
||||||
}
|
}
|
||||||
@@ -160,7 +156,7 @@ class HardcoverSyncServiceTest {
|
|||||||
testMetadata.setIsbn13(null);
|
testMetadata.setIsbn13(null);
|
||||||
testMetadata.setIsbn10(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();
|
verify(restClient, never()).post();
|
||||||
}
|
}
|
||||||
@@ -179,7 +175,7 @@ class HardcoverSyncServiceTest {
|
|||||||
.thenReturn(createEmptyUserBookReadsResponse())
|
.thenReturn(createEmptyUserBookReadsResponse())
|
||||||
.thenReturn(createInsertUserBookReadResponse());
|
.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 API was called at least once (using stored ID, no search needed)
|
||||||
verify(restClient, atLeastOnce()).post();
|
verify(restClient, atLeastOnce()).post();
|
||||||
@@ -195,7 +191,7 @@ class HardcoverSyncServiceTest {
|
|||||||
.thenReturn(createEmptyUserBookReadsResponse())
|
.thenReturn(createEmptyUserBookReadsResponse())
|
||||||
.thenReturn(createInsertUserBookReadResponse());
|
.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 API was called at least once
|
||||||
verify(restClient, atLeastOnce()).post();
|
verify(restClient, atLeastOnce()).post();
|
||||||
@@ -207,7 +203,7 @@ class HardcoverSyncServiceTest {
|
|||||||
// Mock: search returns empty results
|
// Mock: search returns empty results
|
||||||
when(responseSpec.body(Map.class)).thenReturn(createEmptySearchResponse());
|
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
|
// Should call search only
|
||||||
verify(restClient, times(1)).post();
|
verify(restClient, times(1)).post();
|
||||||
@@ -224,7 +220,7 @@ class HardcoverSyncServiceTest {
|
|||||||
.thenReturn(createEmptyUserBookReadsResponse())
|
.thenReturn(createEmptyUserBookReadsResponse())
|
||||||
.thenReturn(createInsertUserBookReadResponse());
|
.thenReturn(createInsertUserBookReadResponse());
|
||||||
|
|
||||||
service.syncProgressToHardcover(TEST_BOOK_ID, 99.0f);
|
service.syncProgressToHardcover(TEST_BOOK_ID, 99.0f, TEST_USER_ID);
|
||||||
|
|
||||||
verify(restClient, atLeastOnce()).post();
|
verify(restClient, atLeastOnce()).post();
|
||||||
}
|
}
|
||||||
@@ -240,7 +236,7 @@ class HardcoverSyncServiceTest {
|
|||||||
.thenReturn(createEmptyUserBookReadsResponse())
|
.thenReturn(createEmptyUserBookReadsResponse())
|
||||||
.thenReturn(createInsertUserBookReadResponse());
|
.thenReturn(createInsertUserBookReadResponse());
|
||||||
|
|
||||||
service.syncProgressToHardcover(TEST_BOOK_ID, 50.0f);
|
service.syncProgressToHardcover(TEST_BOOK_ID, 50.0f, TEST_USER_ID);
|
||||||
|
|
||||||
verify(restClient, atLeastOnce()).post();
|
verify(restClient, atLeastOnce()).post();
|
||||||
}
|
}
|
||||||
@@ -257,7 +253,7 @@ class HardcoverSyncServiceTest {
|
|||||||
.thenReturn(createEmptyUserBookReadsResponse())
|
.thenReturn(createEmptyUserBookReadsResponse())
|
||||||
.thenReturn(createInsertUserBookReadResponse());
|
.thenReturn(createInsertUserBookReadResponse());
|
||||||
|
|
||||||
service.syncProgressToHardcover(TEST_BOOK_ID, 50.0f);
|
service.syncProgressToHardcover(TEST_BOOK_ID, 50.0f, TEST_USER_ID);
|
||||||
|
|
||||||
verify(restClient, atLeastOnce()).post();
|
verify(restClient, atLeastOnce()).post();
|
||||||
}
|
}
|
||||||
@@ -273,7 +269,7 @@ class HardcoverSyncServiceTest {
|
|||||||
.thenReturn(createFindUserBookReadResponse(6001))
|
.thenReturn(createFindUserBookReadResponse(6001))
|
||||||
.thenReturn(createUpdateUserBookReadResponse());
|
.thenReturn(createUpdateUserBookReadResponse());
|
||||||
|
|
||||||
service.syncProgressToHardcover(TEST_BOOK_ID, 50.0f);
|
service.syncProgressToHardcover(TEST_BOOK_ID, 50.0f, TEST_USER_ID);
|
||||||
|
|
||||||
verify(restClient, atLeastOnce()).post();
|
verify(restClient, atLeastOnce()).post();
|
||||||
}
|
}
|
||||||
@@ -290,7 +286,7 @@ class HardcoverSyncServiceTest {
|
|||||||
.thenReturn(createEmptyUserBookReadsResponse())
|
.thenReturn(createEmptyUserBookReadsResponse())
|
||||||
.thenReturn(createInsertUserBookReadResponse());
|
.thenReturn(createInsertUserBookReadResponse());
|
||||||
|
|
||||||
service.syncProgressToHardcover(TEST_BOOK_ID, 50.0f);
|
service.syncProgressToHardcover(TEST_BOOK_ID, 50.0f, TEST_USER_ID);
|
||||||
|
|
||||||
verify(restClient, atLeastOnce()).post();
|
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"))));
|
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
|
@Test
|
||||||
@@ -314,7 +310,17 @@ class HardcoverSyncServiceTest {
|
|||||||
|
|
||||||
when(responseSpec.body(Map.class)).thenReturn(null);
|
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 ===
|
// === Helper methods to create mock responses ===
|
||||||
|
|||||||
@@ -174,6 +174,82 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@if (koboSyncSettings.syncEnabled) {
|
||||||
|
<div class="preferences-section">
|
||||||
|
<div class="section-header">
|
||||||
|
<h3 class="section-title">
|
||||||
|
<i class="pi pi-book"></i>
|
||||||
|
Hardcover Integration
|
||||||
|
</h3>
|
||||||
|
<p class="section-description">
|
||||||
|
Sync your Kobo reading progress to your personal Hardcover account.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="settings-card">
|
||||||
|
<div class="setting-item">
|
||||||
|
<div class="setting-info">
|
||||||
|
<div class="setting-label-row">
|
||||||
|
<label class="setting-label">Sync Reading Progress to Hardcover</label>
|
||||||
|
<p-toggle-switch
|
||||||
|
id="hardcoverSyncEnabled"
|
||||||
|
name="hardcoverSyncEnabled"
|
||||||
|
[(ngModel)]="koboSyncSettings.hardcoverSyncEnabled"
|
||||||
|
(ngModelChange)="onHardcoverSyncToggle()">
|
||||||
|
</p-toggle-switch>
|
||||||
|
</div>
|
||||||
|
<p class="setting-description">
|
||||||
|
When enabled, your reading progress from Kobo will be automatically synced to your Hardcover account.
|
||||||
|
Each user can configure their own Hardcover account.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (koboSyncSettings.hardcoverSyncEnabled) {
|
||||||
|
<div class="setting-item">
|
||||||
|
<div class="setting-info">
|
||||||
|
<div class="setting-label-row">
|
||||||
|
<label class="setting-label">Hardcover API Key</label>
|
||||||
|
<div class="token-input-group">
|
||||||
|
<input
|
||||||
|
pInputText
|
||||||
|
id="hardcoverApiKey"
|
||||||
|
[(ngModel)]="koboSyncSettings.hardcoverApiKey"
|
||||||
|
[type]="showHardcoverApiKey ? 'text' : 'password'"
|
||||||
|
name="hardcoverApiKey"
|
||||||
|
class="token-input"
|
||||||
|
placeholder="Enter your Hardcover API key"
|
||||||
|
(blur)="onHardcoverApiKeyChange()"
|
||||||
|
/>
|
||||||
|
<p-button
|
||||||
|
icon="pi pi-copy"
|
||||||
|
outlined="true"
|
||||||
|
severity="info"
|
||||||
|
size="small"
|
||||||
|
(onClick)="copyText(koboSyncSettings.hardcoverApiKey ?? '', 'API Key')"
|
||||||
|
[disabled]="!koboSyncSettings.hardcoverApiKey">
|
||||||
|
</p-button>
|
||||||
|
<p-button
|
||||||
|
[icon]="showHardcoverApiKey ? 'pi pi-eye-slash' : 'pi pi-eye'"
|
||||||
|
outlined="true"
|
||||||
|
severity="info"
|
||||||
|
size="small"
|
||||||
|
(onClick)="toggleShowHardcoverApiKey()">
|
||||||
|
</p-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="setting-description">
|
||||||
|
Your personal Hardcover API key. Get it from
|
||||||
|
<a href="https://hardcover.app/account/api" target="_blank" rel="noopener">hardcover.app/account/api</a>.
|
||||||
|
This key is used only for syncing your reading progress and is not shared with other users.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
@if (isAdmin) {
|
@if (isAdmin) {
|
||||||
<div class="preferences-section">
|
<div class="preferences-section">
|
||||||
<div class="section-header">
|
<div class="section-header">
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ export class KoboSyncSettingsComponent implements OnInit, OnDestroy {
|
|||||||
isAdmin = false;
|
isAdmin = false;
|
||||||
credentialsSaved = false;
|
credentialsSaved = false;
|
||||||
showToken = false;
|
showToken = false;
|
||||||
|
showHardcoverApiKey = false;
|
||||||
|
|
||||||
koboSettings: KoboSettings = {
|
koboSettings: KoboSettings = {
|
||||||
convertToKepub: false,
|
convertToKepub: false,
|
||||||
@@ -58,7 +59,9 @@ export class KoboSyncSettingsComponent implements OnInit, OnDestroy {
|
|||||||
syncEnabled: false,
|
syncEnabled: false,
|
||||||
progressMarkAsReadingThreshold: 1,
|
progressMarkAsReadingThreshold: 1,
|
||||||
progressMarkAsFinishedThreshold: 99,
|
progressMarkAsFinishedThreshold: 99,
|
||||||
autoAddToShelf: true
|
autoAddToShelf: true,
|
||||||
|
hardcoverApiKey: '',
|
||||||
|
hardcoverSyncEnabled: false
|
||||||
}
|
}
|
||||||
|
|
||||||
ngOnInit() {
|
ngOnInit() {
|
||||||
@@ -119,6 +122,8 @@ export class KoboSyncSettingsComponent implements OnInit, OnDestroy {
|
|||||||
this.koboSyncSettings.progressMarkAsReadingThreshold = settings.progressMarkAsReadingThreshold ?? 1;
|
this.koboSyncSettings.progressMarkAsReadingThreshold = settings.progressMarkAsReadingThreshold ?? 1;
|
||||||
this.koboSyncSettings.progressMarkAsFinishedThreshold = settings.progressMarkAsFinishedThreshold ?? 99;
|
this.koboSyncSettings.progressMarkAsFinishedThreshold = settings.progressMarkAsFinishedThreshold ?? 99;
|
||||||
this.koboSyncSettings.autoAddToShelf = settings.autoAddToShelf ?? false;
|
this.koboSyncSettings.autoAddToShelf = settings.autoAddToShelf ?? false;
|
||||||
|
this.koboSyncSettings.hardcoverApiKey = settings.hardcoverApiKey ?? '';
|
||||||
|
this.koboSyncSettings.hardcoverSyncEnabled = settings.hardcoverSyncEnabled ?? false;
|
||||||
this.credentialsSaved = !!settings.token;
|
this.credentialsSaved = !!settings.token;
|
||||||
},
|
},
|
||||||
error: () => {
|
error: () => {
|
||||||
@@ -167,6 +172,21 @@ export class KoboSyncSettingsComponent implements OnInit, OnDestroy {
|
|||||||
this.showToken = !this.showToken;
|
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() {
|
confirmRegenerateToken() {
|
||||||
this.confirmationService.confirm({
|
this.confirmationService.confirm({
|
||||||
message: 'This will generate a new token and invalidate the previous one. Continue?',
|
message: 'This will generate a new token and invalidate the previous one. Continue?',
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ export interface KoboSyncSettings {
|
|||||||
progressMarkAsReadingThreshold?: number;
|
progressMarkAsReadingThreshold?: number;
|
||||||
progressMarkAsFinishedThreshold?: number;
|
progressMarkAsFinishedThreshold?: number;
|
||||||
autoAddToShelf: boolean;
|
autoAddToShelf: boolean;
|
||||||
|
hardcoverApiKey?: string;
|
||||||
|
hardcoverSyncEnabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Injectable({
|
@Injectable({
|
||||||
|
|||||||
Reference in New Issue
Block a user