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 progressMarkAsFinishedThreshold;
|
||||
private boolean autoAddToShelf;
|
||||
private String hardcoverApiKey;
|
||||
private boolean hardcoverSyncEnabled;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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<String> 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,19 +54,28 @@ 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;
|
||||
}
|
||||
|
||||
// Set the user's API token for this sync operation
|
||||
currentApiToken.set(userSettings.getHardcoverApiKey());
|
||||
|
||||
try {
|
||||
if (progressPercent == null) {
|
||||
log.debug("Hardcover sync skipped: no progress to sync");
|
||||
return;
|
||||
@@ -107,8 +120,8 @@ public class HardcoverSyncService {
|
||||
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);
|
||||
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);
|
||||
@@ -121,34 +134,36 @@ public class HardcoverSyncService {
|
||||
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);
|
||||
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();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
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 ===
|
||||
|
||||
@@ -174,6 +174,82 @@
|
||||
</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) {
|
||||
<div class="preferences-section">
|
||||
<div class="section-header">
|
||||
|
||||
@@ -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?',
|
||||
|
||||
@@ -9,6 +9,8 @@ export interface KoboSyncSettings {
|
||||
progressMarkAsReadingThreshold?: number;
|
||||
progressMarkAsFinishedThreshold?: number;
|
||||
autoAddToShelf: boolean;
|
||||
hardcoverApiKey?: string;
|
||||
hardcoverSyncEnabled?: boolean;
|
||||
}
|
||||
|
||||
@Injectable({
|
||||
|
||||
Reference in New Issue
Block a user