From 2da01e7a2e410e510a7755ea8b5b6adb8f720a11 Mon Sep 17 00:00:00 2001 From: Giancarlo Perrone Date: Fri, 19 Dec 2025 09:56:40 -0800 Subject: [PATCH] feat(hardcover): sync Kobo reading progress to Hardcover (#1926) * feat(hardcover): sync Kobo reading progress to Hardcover - Add HardcoverSyncService to sync reading progress asynchronously - Store hardcover_book_id in book_metadata for faster lookups - Integrate with KoboReadingStateService to trigger sync on progress updates - Add database migration for hardcover_book_id column * test(hardcover): add unit tests for HardcoverSyncService * test(hardcover): add HardcoverSyncService mock to unit tests for improved coverage * refactor(hardcover): update syncProgressToHardcover method to use book ID - Changed syncProgressToHardcover to accept book ID instead of BookEntity. - Integrated BookRepository to fetch BookEntity within the method. - Updated related unit tests to reflect the new method signature and ensure proper functionality. * feat(hardcover): enhance HardcoverSyncService with edition lookup and logging - Added a method to find an edition by ISBN, improving the accuracy of edition information. - Enhanced logging for progress calculations, search responses, and reading progress updates for better traceability. - Updated the handling of default edition IDs to ensure correct page counts are used when available. * feat(metadata): add hardcoverBookId and its locking mechanism - Introduced hardcoverBookId and hardcoverBookIdLocked fields to MetadataClearFlags, BookMetadata, and BookMetadataEntity. - Updated BookMetadataUpdater to handle locking for hardcoverBookId. - Enhanced MetadataChangeDetector to compare changes for hardcoverBookId, ensuring proper metadata management. * feat(metadata): add hardcoverBookId input and display in metadata dialogs - Introduced hardcoverBookId input field in the metadata editor with locking mechanism. - Updated metadata restore dialog to display hardcoverBookId when available. - Enhanced user interface for better metadata management and visibility. * feat(metadata): integrate hardcoverBookId across components and forms - Added hardcoverBookId and hardcoverBookIdLocked fields to relevant models and interfaces. - Updated metadata editor, picker, and review components to include hardcoverBookId input and locking functionality. - Enhanced form controls and metadata handling to support the new hardcoverBookId feature for improved user experience. * feat(migration): add hardcover_book_id_locked column to book_metadata table - Introduced a new column hardcover_book_id_locked with a default value of FALSE to the book_metadata table for enhanced metadata management. * fix(metadata): realign html with develop --------- Co-authored-by: akiraslingshot --- .../booklore/model/MetadataClearFlags.java | 1 + .../booklore/model/dto/BookMetadata.java | 2 + .../model/entity/BookMetadataEntity.java | 9 + .../hardcover/HardcoverSyncService.java | 596 ++++++++++++++++++ .../service/kobo/KoboReadingStateService.java | 5 + .../service/metadata/BookMetadataUpdater.java | 2 + .../metadata/MetadataRefreshService.java | 1 + .../metadata/parser/HardcoverParser.java | 8 + .../booklore/util/MetadataChangeDetector.java | 3 + .../V74__Add_hardcover_book_id_column.sql | 6 + ...5__Add_hardcover_book_id_locked_column.sql | 3 + .../service/KoboReadingStateServiceTest.java | 4 + .../service/KoboStatusSyncProtectionTest.java | 4 + .../hardcover/HardcoverSyncServiceTest.java | 439 +++++++++++++ .../lock-unlock-metadata-dialog.component.ts | 3 +- .../metadata-restore-dialog-component.html | 3 + .../src/app/features/book/model/book.model.ts | 3 + ...bookdrop-file-metadata-picker.component.ts | 2 + .../bookdrop-file-review.component.ts | 2 + .../metadata-editor.component.html | 14 +- .../metadata-editor.component.ts | 7 + .../metadata-picker.component.ts | 9 + 22 files changed, 1124 insertions(+), 2 deletions(-) create mode 100644 booklore-api/src/main/java/com/adityachandel/booklore/service/hardcover/HardcoverSyncService.java create mode 100644 booklore-api/src/main/resources/db/migration/V74__Add_hardcover_book_id_column.sql create mode 100644 booklore-api/src/main/resources/db/migration/V75__Add_hardcover_book_id_locked_column.sql create mode 100644 booklore-api/src/test/java/com/adityachandel/booklore/service/hardcover/HardcoverSyncServiceTest.java diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/model/MetadataClearFlags.java b/booklore-api/src/main/java/com/adityachandel/booklore/model/MetadataClearFlags.java index d15ce196..4a1548fe 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/model/MetadataClearFlags.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/model/MetadataClearFlags.java @@ -19,6 +19,7 @@ public class MetadataClearFlags { private boolean goodreadsId; private boolean comicvineId; private boolean hardcoverId; + private boolean hardcoverBookId; private boolean googleId; private boolean pageCount; private boolean language; diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/BookMetadata.java b/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/BookMetadata.java index 2cb7d65d..312648eb 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/BookMetadata.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/BookMetadata.java @@ -37,6 +37,7 @@ public class BookMetadata { private Double goodreadsRating; private Integer goodreadsReviewCount; private String hardcoverId; + private Integer hardcoverBookId; private Double hardcoverRating; private Integer hardcoverReviewCount; private String doubanId; @@ -66,6 +67,7 @@ public class BookMetadata { private Boolean goodreadsIdLocked; private Boolean comicvineIdLocked; private Boolean hardcoverIdLocked; + private Boolean hardcoverBookIdLocked; private Boolean doubanIdLocked; private Boolean googleIdLocked; private Boolean pageCountLocked; diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/model/entity/BookMetadataEntity.java b/booklore-api/src/main/java/com/adityachandel/booklore/model/entity/BookMetadataEntity.java index 5ad6dc6a..19b3ab99 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/model/entity/BookMetadataEntity.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/model/entity/BookMetadataEntity.java @@ -97,6 +97,9 @@ public class BookMetadataEntity { @Column(name = "hardcover_id", length = 100) private String hardcoverId; + @Column(name = "hardcover_book_id") + private Integer hardcoverBookId; + @Column(name = "google_id", length = 100) private String googleId; @@ -208,6 +211,10 @@ public class BookMetadataEntity { @Builder.Default private Boolean hardcoverIdLocked = Boolean.FALSE; + @Column(name = "hardcover_book_id_locked") + @Builder.Default + private Boolean hardcoverBookIdLocked = Boolean.FALSE; + @Column(name = "google_id_locked") @Builder.Default private Boolean googleIdLocked = Boolean.FALSE; @@ -309,6 +316,7 @@ public class BookMetadataEntity { this.comicvineIdLocked = lock; this.goodreadsIdLocked = lock; this.hardcoverIdLocked = lock; + this.hardcoverBookIdLocked = lock; this.googleIdLocked = lock; this.reviewsLocked = lock; } @@ -341,6 +349,7 @@ public class BookMetadataEntity { && Boolean.TRUE.equals(this.goodreadsIdLocked) && Boolean.TRUE.equals(this.comicvineIdLocked) && Boolean.TRUE.equals(this.hardcoverIdLocked) + && Boolean.TRUE.equals(this.hardcoverBookIdLocked) && Boolean.TRUE.equals(this.googleIdLocked) && Boolean.TRUE.equals(this.reviewsLocked) ; 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 new file mode 100644 index 00000000..6a7e96cf --- /dev/null +++ b/booklore-api/src/main/java/com/adityachandel/booklore/service/hardcover/HardcoverSyncService.java @@ -0,0 +1,596 @@ +package com.adityachandel.booklore.service.hardcover; + +import com.adityachandel.booklore.model.dto.settings.MetadataProviderSettings; +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.metadata.parser.hardcover.GraphQLRequest; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.client.RestClient; +import org.springframework.web.client.RestClientException; + +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import java.util.List; +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. + */ +@Slf4j +@Service +public class HardcoverSyncService { + + private static final String HARDCOVER_API_URL = "https://api.hardcover.app/v1/graphql"; + private static final int STATUS_CURRENTLY_READING = 2; + private static final int STATUS_READ = 3; + + private final RestClient restClient; + private final AppSettingService appSettingService; + private final BookRepository bookRepository; + + @Autowired + public HardcoverSyncService(AppSettingService appSettingService, BookRepository bookRepository) { + this.appSettingService = appSettingService; + this.bookRepository = bookRepository; + this.restClient = RestClient.builder() + .baseUrl(HARDCOVER_API_URL) + .build(); + } + + /** + * Asynchronously sync Kobo reading progress to Hardcover. + * This method is non-blocking and will not fail the calling process if sync fails. + * + * @param bookId The book ID to sync progress for + * @param progressPercent The reading progress as a percentage (0-100) + */ + @Async + @Transactional(readOnly = true) + public void syncProgressToHardcover(Long bookId, Float progressPercent) { + try { + if (!isHardcoverSyncEnabled()) { + log.trace("Hardcover sync skipped: not enabled or no API token configured"); + return; + } + + if (progressPercent == null) { + log.debug("Hardcover sync skipped: no progress to sync"); + return; + } + + // 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); + return; + } + } + + // 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: progressPercent={}%, totalPages={}, progressPages={}", + 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: book={}, hardcoverBookId={}, progress={}% ({}pages)", + bookId, hardcoverBook.bookId, Math.round(progressPercent), progressPages); + } + + } catch (Exception e) { + log.error("Failed to sync progress to Hardcover for book {}: {}", + bookId, e.getMessage()); + } + } + + private boolean isHardcoverSyncEnabled() { + MetadataProviderSettings.Hardcover hardcoverSettings = + appSettingService.getAppSettings().getMetadataProviderSettings().getHardcover(); + + if (hardcoverSettings == null) { + return false; + } + + return hardcoverSettings.isEnabled() + && hardcoverSettings.getApiKey() != null + && !hardcoverSettings.getApiKey().isBlank(); + } + + private String getApiToken() { + return appSettingService.getAppSettings() + .getMetadataProviderSettings() + .getHardcover() + .getApiKey(); + } + + /** + * Find a book on Hardcover by ISBN or hardcoverId. + * Returns the numeric book_id, edition_id, and page count. + */ + private HardcoverBookInfo findHardcoverBook(BookMetadataEntity metadata) { + // Try ISBN first + String isbn = metadata.getIsbn13(); + if (isbn == null || isbn.isBlank()) { + isbn = metadata.getIsbn10(); + } + + if (isbn == null || isbn.isBlank()) { + log.debug("No ISBN available for Hardcover lookup"); + return null; + } + + try { + String searchQuery = """ + query SearchBooks($query: String!) { + search(query: $query, query_type: "Book", per_page: 1, page: 1) { + results + } + } + """; + + GraphQLRequest request = new GraphQLRequest(); + request.setQuery(searchQuery); + request.setVariables(Map.of("query", isbn)); + + Map response = executeGraphQL(request); + log.debug("Hardcover search response for ISBN {}: {}", isbn, response); + if (response == null) { + return null; + } + + // Navigate the response to get book info + Map data = (Map) response.get("data"); + if (data == null) return null; + + Map search = (Map) data.get("search"); + if (search == null) return null; + + Map results = (Map) search.get("results"); + if (results == null) return null; + + List> hits = (List>) results.get("hits"); + if (hits == null || hits.isEmpty()) return null; + + Map document = (Map) hits.get(0).get("document"); + if (document == null) return null; + + // Extract book info + HardcoverBookInfo info = new HardcoverBookInfo(); + + // The 'id' field contains the numeric book ID + Object idObj = document.get("id"); + if (idObj instanceof String) { + info.bookId = Integer.parseInt((String) idObj); + } else if (idObj instanceof Number) { + info.bookId = ((Number) idObj).intValue(); + } + + // Get page count + Object pagesObj = document.get("pages"); + if (pagesObj instanceof Number) { + info.pages = ((Number) pagesObj).intValue(); + } + + // Try to get default_edition_id from the search results + Object defaultEditionObj = document.get("default_edition_id"); + if (defaultEditionObj instanceof Number) { + info.editionId = ((Number) defaultEditionObj).intValue(); + } else if (defaultEditionObj instanceof String) { + try { + info.editionId = Integer.parseInt((String) defaultEditionObj); + } catch (NumberFormatException e) { + // Ignore + } + } + + // If no default edition, try to look up edition by ISBN + // This also gets the page count from the specific edition + if (info.bookId != null) { + EditionInfo edition = findEditionByIsbn(info.bookId, isbn); + if (edition != null) { + info.editionId = edition.id; + // Prefer edition page count over book page count + if (edition.pages != null && edition.pages > 0) { + info.pages = edition.pages; + } + } + } + + log.info("Found Hardcover book: bookId={}, editionId={}, pages={}", + info.bookId, info.editionId, info.pages); + + return info.bookId != null ? info : null; + + } catch (Exception e) { + log.warn("Failed to search Hardcover by ISBN {}: {}", isbn, e.getMessage()); + return null; + } + } + + /** + * Find an edition by ISBN for a given book. + * This queries Hardcover's editions table to match by ISBN. + */ + private EditionInfo findEditionByIsbn(Integer bookId, String isbn) { + String query = """ + query FindEditionByIsbn($bookId: Int!, $isbn: String!) { + editions(where: { + book_id: {_eq: $bookId}, + _or: [ + {isbn_10: {_eq: $isbn}}, + {isbn_13: {_eq: $isbn}} + ] + }, limit: 1) { + id + pages + } + } + """; + + GraphQLRequest request = new GraphQLRequest(); + request.setQuery(query); + request.setVariables(Map.of("bookId", bookId, "isbn", isbn)); + + try { + Map response = executeGraphQL(request); + log.debug("Edition lookup response: {}", response); + if (response == null) return null; + + Map data = (Map) response.get("data"); + if (data == null) return null; + + List> editions = (List>) data.get("editions"); + if (editions == null || editions.isEmpty()) return null; + + Map edition = editions.get(0); + EditionInfo info = new EditionInfo(); + + Object idObj = edition.get("id"); + if (idObj instanceof Number) { + info.id = ((Number) idObj).intValue(); + } + + Object pagesObj = edition.get("pages"); + if (pagesObj instanceof Number) { + info.pages = ((Number) pagesObj).intValue(); + } + + return info.id != null ? info : null; + + } catch (Exception e) { + log.debug("Failed to find edition by ISBN: {}", e.getMessage()); + return null; + } + } + + /** + * Insert a book into the user's library or get existing user_book_id. + */ + private Integer insertOrGetUserBook(Integer bookId, Integer editionId, int statusId) { + String mutation = """ + mutation InsertUserBook($object: UserBookCreateInput!) { + insert_user_book(object: $object) { + user_book { + id + } + error + } + } + """; + + Map bookInput = new java.util.HashMap<>(); + bookInput.put("book_id", bookId); + bookInput.put("status_id", statusId); + bookInput.put("date_added", LocalDate.now().format(DateTimeFormatter.ISO_LOCAL_DATE)); + if (editionId != null) { + bookInput.put("edition_id", editionId); + } + + GraphQLRequest request = new GraphQLRequest(); + request.setQuery(mutation); + request.setVariables(Map.of("object", bookInput)); + + try { + Map response = executeGraphQL(request); + log.debug("insert_user_book response: {}", response); + if (response == null) return null; + + Map data = (Map) response.get("data"); + if (data == null) return null; + + Map insertResult = (Map) data.get("insert_user_book"); + if (insertResult == null) return null; + + // Check for error (might mean book already exists) + String error = (String) insertResult.get("error"); + if (error != null && !error.isBlank()) { + log.debug("insert_user_book returned error: {} - book may already exist, trying to find it", error); + return findExistingUserBook(bookId); + } + + Map userBook = (Map) insertResult.get("user_book"); + if (userBook == null) return null; + + Object idObj = userBook.get("id"); + if (idObj instanceof Number) { + return ((Number) idObj).intValue(); + } + + return null; + + } catch (RestClientException e) { + log.warn("Failed to insert user_book: {}", e.getMessage()); + // Try to find existing + return findExistingUserBook(bookId); + } + } + + /** + * Find an existing user_book entry for a book. + */ + private Integer findExistingUserBook(Integer bookId) { + String query = """ + query FindUserBook($bookId: Int!) { + me { + user_books(where: {book_id: {_eq: $bookId}}, limit: 1) { + id + } + } + } + """; + + GraphQLRequest request = new GraphQLRequest(); + request.setQuery(query); + request.setVariables(Map.of("bookId", bookId)); + + try { + Map response = executeGraphQL(request); + if (response == null) return null; + + Map data = (Map) response.get("data"); + if (data == null) return null; + + Map me = (Map) data.get("me"); + if (me == null) return null; + + List> userBooks = (List>) me.get("user_books"); + if (userBooks == null || userBooks.isEmpty()) return null; + + Object idObj = userBooks.get(0).get("id"); + if (idObj instanceof Number) { + return ((Number) idObj).intValue(); + } + + return null; + + } catch (RestClientException e) { + log.warn("Failed to find existing user_book: {}", e.getMessage()); + return null; + } + } + + /** + * Create or update reading progress for a user_book. + */ + private boolean upsertReadingProgress(Integer userBookId, Integer editionId, int progressPages) { + log.info("upsertReadingProgress: userBookId={}, editionId={}, progressPages={}", + userBookId, editionId, progressPages); + + // First, try to find existing user_book_read + Integer existingReadId = findExistingUserBookRead(userBookId); + + if (existingReadId != null) { + // Update existing + log.info("Updating existing user_book_read: id={}", existingReadId); + return updateUserBookRead(existingReadId, editionId, progressPages); + } else { + // Create new + log.info("Creating new user_book_read for userBookId={}", userBookId); + return insertUserBookRead(userBookId, editionId, progressPages); + } + } + + private Integer findExistingUserBookRead(Integer userBookId) { + String query = """ + query FindUserBookRead($userBookId: Int!) { + user_book_reads(where: {user_book_id: {_eq: $userBookId}}, limit: 1) { + id + } + } + """; + + GraphQLRequest request = new GraphQLRequest(); + request.setQuery(query); + request.setVariables(Map.of("userBookId", userBookId)); + + try { + Map response = executeGraphQL(request); + if (response == null) return null; + + Map data = (Map) response.get("data"); + if (data == null) return null; + + List> reads = (List>) data.get("user_book_reads"); + if (reads == null || reads.isEmpty()) return null; + + Object idObj = reads.get(0).get("id"); + if (idObj instanceof Number) { + return ((Number) idObj).intValue(); + } + + return null; + + } catch (RestClientException e) { + log.warn("Failed to find existing user_book_read: {}", e.getMessage()); + return null; + } + } + + private boolean insertUserBookRead(Integer userBookId, Integer editionId, int progressPages) { + String mutation = """ + mutation InsertUserBookRead($userBookId: Int!, $object: DatesReadInput!) { + insert_user_book_read(user_book_id: $userBookId, user_book_read: $object) { + user_book_read { + id + } + error + } + } + """; + + Map readInput = new java.util.HashMap<>(); + readInput.put("started_at", LocalDate.now().format(DateTimeFormatter.ISO_LOCAL_DATE)); + readInput.put("progress_pages", progressPages); + if (editionId != null) { + readInput.put("edition_id", editionId); + } + + GraphQLRequest request = new GraphQLRequest(); + request.setQuery(mutation); + request.setVariables(Map.of( + "userBookId", userBookId, + "object", readInput + )); + + try { + Map response = executeGraphQL(request); + log.info("insert_user_book_read response: {}", response); + if (response == null) return false; + + if (response.containsKey("errors")) { + log.warn("insert_user_book_read returned errors: {}", response.get("errors")); + return false; + } + + return true; + + } catch (RestClientException e) { + log.error("Failed to insert user_book_read: {}", e.getMessage()); + return false; + } + } + + private boolean updateUserBookRead(Integer readId, Integer editionId, int progressPages) { + String mutation = """ + mutation UpdateUserBookRead($id: Int!, $object: DatesReadInput!) { + update_user_book_read(id: $id, object: $object) { + user_book_read { + id + progress + } + error + } + } + """; + + Map readInput = new java.util.HashMap<>(); + readInput.put("progress_pages", progressPages); + if (editionId != null) { + readInput.put("edition_id", editionId); + } + + GraphQLRequest request = new GraphQLRequest(); + request.setQuery(mutation); + request.setVariables(Map.of( + "id", readId, + "object", readInput + )); + + try { + Map response = executeGraphQL(request); + log.debug("update_user_book_read response: {}", response); + if (response == null) return false; + + if (response.containsKey("errors")) { + log.warn("update_user_book_read returned errors: {}", response.get("errors")); + return false; + } + + return true; + + } catch (RestClientException e) { + log.error("Failed to update user_book_read: {}", e.getMessage()); + return false; + } + } + + private Map executeGraphQL(GraphQLRequest request) { + try { + return restClient.post() + .uri("") + .header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE) + .header(HttpHeaders.AUTHORIZATION, "Bearer " + getApiToken()) + .body(request) + .retrieve() + .body(Map.class); + } catch (RestClientException e) { + log.error("GraphQL request failed: {}", e.getMessage()); + return null; + } + } + + /** + * Helper class to hold Hardcover book information. + */ + private static class HardcoverBookInfo { + Integer bookId; + Integer editionId; + Integer pages; + } + + /** + * Helper class to hold edition information. + */ + private static class EditionInfo { + Integer id; + Integer pages; + } +} 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 099236f8..97171740 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 @@ -16,6 +16,7 @@ import com.adityachandel.booklore.repository.BookRepository; import com.adityachandel.booklore.repository.KoboReadingStateRepository; import com.adityachandel.booklore.repository.UserBookProgressRepository; import com.adityachandel.booklore.repository.UserRepository; +import com.adityachandel.booklore.service.hardcover.HardcoverSyncService; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; @@ -41,6 +42,7 @@ public class KoboReadingStateService { private final AuthenticationService authenticationService; private final KoboSettingsService koboSettingsService; private final KoboReadingStateBuilder readingStateBuilder; + private final HardcoverSyncService hardcoverSyncService; @Transactional public KoboReadingStateResponse saveReadingState(List readingStates) { @@ -168,6 +170,9 @@ 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()); } catch (NumberFormatException e) { log.warn("Invalid entitlement ID format: {}", readingState.getEntitlementId()); } diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/service/metadata/BookMetadataUpdater.java b/booklore-api/src/main/java/com/adityachandel/booklore/service/metadata/BookMetadataUpdater.java index a5d022bb..451156c3 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/service/metadata/BookMetadataUpdater.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/service/metadata/BookMetadataUpdater.java @@ -160,6 +160,7 @@ public class BookMetadataUpdater { handleFieldUpdate(e.getGoodreadsIdLocked(), clear.isGoodreadsId(), m.getGoodreadsId(), v -> e.setGoodreadsId(nullIfBlank(v)), e::getGoodreadsId, replaceMode); handleFieldUpdate(e.getComicvineIdLocked(), clear.isComicvineId(), m.getComicvineId(), v -> e.setComicvineId(nullIfBlank(v)), e::getComicvineId, replaceMode); handleFieldUpdate(e.getHardcoverIdLocked(), clear.isHardcoverId(), m.getHardcoverId(), v -> e.setHardcoverId(nullIfBlank(v)), e::getHardcoverId, replaceMode); + handleFieldUpdate(e.getHardcoverBookIdLocked(), clear.isHardcoverBookId(), m.getHardcoverBookId(), e::setHardcoverBookId, e::getHardcoverBookId, replaceMode); handleFieldUpdate(e.getGoogleIdLocked(), clear.isGoogleId(), m.getGoogleId(), v -> e.setGoogleId(nullIfBlank(v)), e::getGoogleId, replaceMode); handleFieldUpdate(e.getPageCountLocked(), clear.isPageCount(), m.getPageCount(), e::setPageCount, e::getPageCount, replaceMode); handleFieldUpdate(e.getLanguageLocked(), clear.isLanguage(), m.getLanguage(), v -> e.setLanguage(nullIfBlank(v)), e::getLanguage, replaceMode); @@ -375,6 +376,7 @@ public class BookMetadataUpdater { Pair.of(m.getGoodreadsIdLocked(), e::setGoodreadsIdLocked), Pair.of(m.getComicvineIdLocked(), e::setComicvineIdLocked), Pair.of(m.getHardcoverIdLocked(), e::setHardcoverIdLocked), + Pair.of(m.getHardcoverBookIdLocked(), e::setHardcoverBookIdLocked), Pair.of(m.getGoogleIdLocked(), e::setGoogleIdLocked), Pair.of(m.getPageCountLocked(), e::setPageCountLocked), Pair.of(m.getLanguageLocked(), e::setLanguageLocked), diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/service/metadata/MetadataRefreshService.java b/booklore-api/src/main/java/com/adityachandel/booklore/service/metadata/MetadataRefreshService.java index 2fc270a0..288b4bee 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/service/metadata/MetadataRefreshService.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/service/metadata/MetadataRefreshService.java @@ -482,6 +482,7 @@ public class MetadataRefreshService { if (enabledFields.isHardcoverId()) { if (metadataMap.containsKey(Hardcover)) { metadata.setHardcoverId(metadataMap.get(Hardcover).getHardcoverId()); + metadata.setHardcoverBookId(metadataMap.get(Hardcover).getHardcoverBookId()); } } if (enabledFields.isGoogleId()) { diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/service/metadata/parser/HardcoverParser.java b/booklore-api/src/main/java/com/adityachandel/booklore/service/metadata/parser/HardcoverParser.java index 80081202..53621e3b 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/service/metadata/parser/HardcoverParser.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/service/metadata/parser/HardcoverParser.java @@ -78,6 +78,14 @@ public class HardcoverParser implements BookParser { .map(doc -> { BookMetadata metadata = new BookMetadata(); metadata.setHardcoverId(doc.getSlug()); + // Set numeric book ID for API operations + if (doc.getId() != null) { + try { + metadata.setHardcoverBookId(Integer.parseInt(doc.getId())); + } catch (NumberFormatException e) { + log.debug("Could not parse Hardcover book ID: {}", doc.getId()); + } + } metadata.setTitle(doc.getTitle()); metadata.setSubtitle(doc.getSubtitle()); metadata.setDescription(doc.getDescription()); diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/util/MetadataChangeDetector.java b/booklore-api/src/main/java/com/adityachandel/booklore/util/MetadataChangeDetector.java index 870cf5ce..b4f65b10 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/util/MetadataChangeDetector.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/util/MetadataChangeDetector.java @@ -35,6 +35,7 @@ public class MetadataChangeDetector { compare(changes, "goodreadsId", clear.isGoodreadsId(), newMeta.getGoodreadsId(), existingMeta.getGoodreadsId(), () -> !isTrue(existingMeta.getGoodreadsIdLocked()), newMeta.getGoodreadsIdLocked(), existingMeta.getGoodreadsIdLocked()); compare(changes, "comicvineId", clear.isComicvineId(), newMeta.getComicvineId(), existingMeta.getComicvineId(), () -> !isTrue(existingMeta.getComicvineIdLocked()), newMeta.getComicvineIdLocked(), existingMeta.getComicvineIdLocked()); compare(changes, "hardcoverId", clear.isHardcoverId(), newMeta.getHardcoverId(), existingMeta.getHardcoverId(), () -> !isTrue(existingMeta.getHardcoverIdLocked()), newMeta.getHardcoverIdLocked(), existingMeta.getHardcoverIdLocked()); + compare(changes, "hardcoverBookId", clear.isHardcoverBookId(), newMeta.getHardcoverBookId(), existingMeta.getHardcoverBookId(), () -> !isTrue(existingMeta.getHardcoverBookIdLocked()), newMeta.getHardcoverBookIdLocked(), existingMeta.getHardcoverBookIdLocked()); compare(changes, "googleId", clear.isGoogleId(), newMeta.getGoogleId(), existingMeta.getGoogleId(), () -> !isTrue(existingMeta.getGoogleIdLocked()), newMeta.getGoogleIdLocked(), existingMeta.getGoogleIdLocked()); compare(changes, "pageCount", clear.isPageCount(), newMeta.getPageCount(), existingMeta.getPageCount(), () -> !isTrue(existingMeta.getPageCountLocked()), newMeta.getPageCountLocked(), existingMeta.getPageCountLocked()); compare(changes, "language", clear.isLanguage(), newMeta.getLanguage(), existingMeta.getLanguage(), () -> !isTrue(existingMeta.getLanguageLocked()), newMeta.getLanguageLocked(), existingMeta.getLanguageLocked()); @@ -75,6 +76,7 @@ public class MetadataChangeDetector { compareValue(diffs, "goodreadsId", clear.isGoodreadsId(), newMeta.getGoodreadsId(), existingMeta.getGoodreadsId(), () -> !isTrue(existingMeta.getGoodreadsIdLocked())); compareValue(diffs, "comicvineId", clear.isComicvineId(), newMeta.getComicvineId(), existingMeta.getComicvineId(), () -> !isTrue(existingMeta.getComicvineIdLocked())); compareValue(diffs, "hardcoverId", clear.isHardcoverId(), newMeta.getHardcoverId(), existingMeta.getHardcoverId(), () -> !isTrue(existingMeta.getHardcoverIdLocked())); + compareValue(diffs, "hardcoverBookId", clear.isHardcoverBookId(), newMeta.getHardcoverBookId(), existingMeta.getHardcoverBookId(), () -> !isTrue(existingMeta.getHardcoverBookIdLocked())); compareValue(diffs, "googleId", clear.isGoogleId(), newMeta.getGoogleId(), existingMeta.getGoogleId(), () -> !isTrue(existingMeta.getGoogleIdLocked())); compareValue(diffs, "pageCount", clear.isPageCount(), newMeta.getPageCount(), existingMeta.getPageCount(), () -> !isTrue(existingMeta.getPageCountLocked())); compareValue(diffs, "language", clear.isLanguage(), newMeta.getLanguage(), existingMeta.getLanguage(), () -> !isTrue(existingMeta.getLanguageLocked())); @@ -107,6 +109,7 @@ public class MetadataChangeDetector { compareValue(diffs, "goodreadsId", clear.isGoodreadsId(), newMeta.getGoodreadsId(), existingMeta.getGoodreadsId(), () -> !isTrue(existingMeta.getGoodreadsIdLocked())); compareValue(diffs, "comicvineId", clear.isComicvineId(), newMeta.getComicvineId(), existingMeta.getComicvineId(), () -> !isTrue(existingMeta.getComicvineIdLocked())); compareValue(diffs, "hardcoverId", clear.isHardcoverId(), newMeta.getHardcoverId(), existingMeta.getHardcoverId(), () -> !isTrue(existingMeta.getHardcoverIdLocked())); + compareValue(diffs, "hardcoverBookId", clear.isHardcoverBookId(), newMeta.getHardcoverBookId(), existingMeta.getHardcoverBookId(), () -> !isTrue(existingMeta.getHardcoverBookIdLocked())); compareValue(diffs, "googleId", clear.isGoogleId(), newMeta.getGoogleId(), existingMeta.getGoogleId(), () -> !isTrue(existingMeta.getGoogleIdLocked())); compareValue(diffs, "language", clear.isLanguage(), newMeta.getLanguage(), existingMeta.getLanguage(), () -> !isTrue(existingMeta.getLanguageLocked())); compareValue(diffs, "authors", clear.isAuthors(), newMeta.getAuthors(), toNameSet(existingMeta.getAuthors()), () -> !isTrue(existingMeta.getAuthorsLocked())); diff --git a/booklore-api/src/main/resources/db/migration/V74__Add_hardcover_book_id_column.sql b/booklore-api/src/main/resources/db/migration/V74__Add_hardcover_book_id_column.sql new file mode 100644 index 00000000..18fe2a47 --- /dev/null +++ b/booklore-api/src/main/resources/db/migration/V74__Add_hardcover_book_id_column.sql @@ -0,0 +1,6 @@ +-- Add numeric hardcover_book_id column to book_metadata table +-- This stores the numeric Hardcover book ID for API operations, +-- while the existing hardcover_id column stores the slug for URL linking. + +ALTER TABLE book_metadata ADD COLUMN hardcover_book_id INTEGER; + diff --git a/booklore-api/src/main/resources/db/migration/V75__Add_hardcover_book_id_locked_column.sql b/booklore-api/src/main/resources/db/migration/V75__Add_hardcover_book_id_locked_column.sql new file mode 100644 index 00000000..a3a690a0 --- /dev/null +++ b/booklore-api/src/main/resources/db/migration/V75__Add_hardcover_book_id_locked_column.sql @@ -0,0 +1,3 @@ +-- Add hardcover_book_id_locked column to book_metadata table + +ALTER TABLE book_metadata ADD COLUMN hardcover_book_id_locked BOOLEAN DEFAULT FALSE; diff --git a/booklore-api/src/test/java/com/adityachandel/booklore/service/KoboReadingStateServiceTest.java b/booklore-api/src/test/java/com/adityachandel/booklore/service/KoboReadingStateServiceTest.java index 751176f7..6cfc44de 100644 --- a/booklore-api/src/test/java/com/adityachandel/booklore/service/KoboReadingStateServiceTest.java +++ b/booklore-api/src/test/java/com/adityachandel/booklore/service/KoboReadingStateServiceTest.java @@ -15,6 +15,7 @@ import com.adityachandel.booklore.repository.BookRepository; import com.adityachandel.booklore.repository.KoboReadingStateRepository; import com.adityachandel.booklore.repository.UserBookProgressRepository; import com.adityachandel.booklore.repository.UserRepository; +import com.adityachandel.booklore.service.hardcover.HardcoverSyncService; import com.adityachandel.booklore.service.kobo.KoboReadingStateBuilder; import com.adityachandel.booklore.service.kobo.KoboReadingStateService; import com.adityachandel.booklore.service.kobo.KoboSettingsService; @@ -64,6 +65,9 @@ class KoboReadingStateServiceTest { @Mock private KoboReadingStateBuilder readingStateBuilder; + @Mock + private HardcoverSyncService hardcoverSyncService; + @InjectMocks private KoboReadingStateService service; diff --git a/booklore-api/src/test/java/com/adityachandel/booklore/service/KoboStatusSyncProtectionTest.java b/booklore-api/src/test/java/com/adityachandel/booklore/service/KoboStatusSyncProtectionTest.java index 69909355..54f33cc7 100644 --- a/booklore-api/src/test/java/com/adityachandel/booklore/service/KoboStatusSyncProtectionTest.java +++ b/booklore-api/src/test/java/com/adityachandel/booklore/service/KoboStatusSyncProtectionTest.java @@ -14,6 +14,7 @@ import com.adityachandel.booklore.repository.BookRepository; import com.adityachandel.booklore.repository.KoboReadingStateRepository; import com.adityachandel.booklore.repository.UserBookProgressRepository; import com.adityachandel.booklore.repository.UserRepository; +import com.adityachandel.booklore.service.hardcover.HardcoverSyncService; import com.adityachandel.booklore.service.kobo.KoboReadingStateBuilder; import com.adityachandel.booklore.service.kobo.KoboReadingStateService; import com.adityachandel.booklore.service.kobo.KoboSettingsService; @@ -58,6 +59,9 @@ class KoboStatusSyncProtectionTest { @Mock private KoboReadingStateBuilder readingStateBuilder; + @Mock + private HardcoverSyncService hardcoverSyncService; + @InjectMocks private KoboReadingStateService service; 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 new file mode 100644 index 00000000..e168ddee --- /dev/null +++ b/booklore-api/src/test/java/com/adityachandel/booklore/service/hardcover/HardcoverSyncServiceTest.java @@ -0,0 +1,439 @@ +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.entity.BookEntity; +import com.adityachandel.booklore.model.entity.BookMetadataEntity; +import com.adityachandel.booklore.repository.BookRepository; +import com.adityachandel.booklore.service.appsettings.AppSettingService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; +import org.springframework.web.client.RestClient; + +import java.lang.reflect.Field; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +class HardcoverSyncServiceTest { + + @Mock + private AppSettingService appSettingService; + + @Mock + private BookRepository bookRepository; + + @Mock + private RestClient restClient; + + @Mock + private RestClient.RequestBodyUriSpec requestBodyUriSpec; + + @Mock + private RestClient.RequestBodySpec requestBodySpec; + + @Mock + private RestClient.ResponseSpec responseSpec; + + private HardcoverSyncService service; + + private BookEntity testBook; + private BookMetadataEntity testMetadata; + private AppSettings appSettings; + private MetadataProviderSettings.Hardcover hardcoverSettings; + + private static final Long TEST_BOOK_ID = 100L; + + @BeforeEach + void setUp() throws Exception { + // Create service with mocked dependencies + service = new HardcoverSyncService(appSettingService, bookRepository); + + // Inject our mocked restClient using reflection + Field restClientField = HardcoverSyncService.class.getDeclaredField("restClient"); + restClientField.setAccessible(true); + restClientField.set(service, restClient); + + testBook = new BookEntity(); + testBook.setId(TEST_BOOK_ID); + + testMetadata = new BookMetadataEntity(); + testMetadata.setIsbn13("9781234567890"); + 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); + + when(appSettingService.getAppSettings()).thenReturn(appSettings); + when(bookRepository.findById(TEST_BOOK_ID)).thenReturn(Optional.of(testBook)); + + // Setup RestClient mock chain - handles multiple calls + when(restClient.post()).thenReturn(requestBodyUriSpec); + when(requestBodyUriSpec.uri(anyString())).thenReturn(requestBodySpec); + when(requestBodySpec.header(anyString(), anyString())).thenReturn(requestBodySpec); + when(requestBodySpec.body(any())).thenReturn(requestBodySpec); + when(requestBodySpec.retrieve()).thenReturn(responseSpec); + } + + // === Tests for skipping sync (no API calls should be made) === + + @Test + @DisplayName("Should skip sync when Hardcover is not enabled") + void syncProgressToHardcover_whenHardcoverDisabled_shouldSkip() { + hardcoverSettings.setEnabled(false); + + service.syncProgressToHardcover(TEST_BOOK_ID, 50.0f); + + verify(restClient, never()).post(); + } + + @Test + @DisplayName("Should skip sync when API key is missing") + void syncProgressToHardcover_whenApiKeyMissing_shouldSkip() { + hardcoverSettings.setApiKey(null); + + service.syncProgressToHardcover(TEST_BOOK_ID, 50.0f); + + verify(restClient, never()).post(); + } + + @Test + @DisplayName("Should skip sync when API key is blank") + void syncProgressToHardcover_whenApiKeyBlank_shouldSkip() { + hardcoverSettings.setApiKey(" "); + + service.syncProgressToHardcover(TEST_BOOK_ID, 50.0f); + + verify(restClient, never()).post(); + } + + @Test + @DisplayName("Should skip sync when progress is null") + void syncProgressToHardcover_whenProgressNull_shouldSkip() { + service.syncProgressToHardcover(TEST_BOOK_ID, null); + + verify(restClient, never()).post(); + } + + @Test + @DisplayName("Should skip sync when book not found") + void syncProgressToHardcover_whenBookNotFound_shouldSkip() { + when(bookRepository.findById(TEST_BOOK_ID)).thenReturn(Optional.empty()); + + service.syncProgressToHardcover(TEST_BOOK_ID, 50.0f); + + verify(restClient, never()).post(); + } + + @Test + @DisplayName("Should skip sync when book has no metadata") + void syncProgressToHardcover_whenNoMetadata_shouldSkip() { + testBook.setMetadata(null); + + service.syncProgressToHardcover(TEST_BOOK_ID, 50.0f); + + verify(restClient, never()).post(); + } + + @Test + @DisplayName("Should skip sync when no ISBN available") + void syncProgressToHardcover_whenNoIsbn_shouldSkip() { + testMetadata.setIsbn13(null); + testMetadata.setIsbn10(null); + + service.syncProgressToHardcover(TEST_BOOK_ID, 50.0f); + + verify(restClient, never()).post(); + } + + // === Tests for successful sync (API calls should be made) === + + @Test + @DisplayName("Should use stored hardcoverBookId when available") + void syncProgressToHardcover_withStoredBookId_shouldUseStoredId() { + testMetadata.setHardcoverBookId(12345); + testMetadata.setPageCount(300); + + // Mock successful responses for the chain + when(responseSpec.body(Map.class)) + .thenReturn(createInsertUserBookResponse(5001, null)) + .thenReturn(createEmptyUserBookReadsResponse()) + .thenReturn(createInsertUserBookReadResponse()); + + service.syncProgressToHardcover(TEST_BOOK_ID, 50.0f); + + // Verify API was called at least once (using stored ID, no search needed) + verify(restClient, atLeastOnce()).post(); + } + + @Test + @DisplayName("Should search by ISBN when hardcoverBookId is not stored") + void syncProgressToHardcover_withoutStoredBookId_shouldSearchByIsbn() { + // Mock successful responses for the chain + when(responseSpec.body(Map.class)) + .thenReturn(createSearchResponse(12345, 300)) + .thenReturn(createInsertUserBookResponse(5001, null)) + .thenReturn(createEmptyUserBookReadsResponse()) + .thenReturn(createInsertUserBookReadResponse()); + + service.syncProgressToHardcover(TEST_BOOK_ID, 50.0f); + + // Verify API was called at least once + verify(restClient, atLeastOnce()).post(); + } + + @Test + @DisplayName("Should skip further processing when book not found on Hardcover") + void syncProgressToHardcover_whenBookNotFoundOnHardcover_shouldSkipAfterSearch() { + // Mock: search returns empty results + when(responseSpec.body(Map.class)).thenReturn(createEmptySearchResponse()); + + service.syncProgressToHardcover(TEST_BOOK_ID, 50.0f); + + // Should call search only + verify(restClient, times(1)).post(); + } + + @Test + @DisplayName("Should set status to READ when progress >= 99%") + void syncProgressToHardcover_whenProgress99Percent_shouldMakeApiCalls() { + testMetadata.setHardcoverBookId(12345); + testMetadata.setPageCount(300); + + when(responseSpec.body(Map.class)) + .thenReturn(createInsertUserBookResponse(5001, null)) + .thenReturn(createEmptyUserBookReadsResponse()) + .thenReturn(createInsertUserBookReadResponse()); + + service.syncProgressToHardcover(TEST_BOOK_ID, 99.0f); + + verify(restClient, atLeastOnce()).post(); + } + + @Test + @DisplayName("Should set status to CURRENTLY_READING when progress < 99%") + void syncProgressToHardcover_whenProgressLessThan99_shouldMakeApiCalls() { + testMetadata.setHardcoverBookId(12345); + testMetadata.setPageCount(300); + + when(responseSpec.body(Map.class)) + .thenReturn(createInsertUserBookResponse(5001, null)) + .thenReturn(createEmptyUserBookReadsResponse()) + .thenReturn(createInsertUserBookReadResponse()); + + service.syncProgressToHardcover(TEST_BOOK_ID, 50.0f); + + verify(restClient, atLeastOnce()).post(); + } + + @Test + @DisplayName("Should handle existing user_book gracefully") + void syncProgressToHardcover_whenUserBookExists_shouldFindExisting() { + testMetadata.setHardcoverBookId(12345); + + // Mock: insert_user_book returns error, then find existing, then create progress + when(responseSpec.body(Map.class)) + .thenReturn(createInsertUserBookResponse(null, "Book already exists")) + .thenReturn(createFindUserBookResponse(5001)) + .thenReturn(createEmptyUserBookReadsResponse()) + .thenReturn(createInsertUserBookReadResponse()); + + service.syncProgressToHardcover(TEST_BOOK_ID, 50.0f); + + verify(restClient, atLeastOnce()).post(); + } + + @Test + @DisplayName("Should update existing reading progress") + void syncProgressToHardcover_whenProgressExists_shouldUpdate() { + testMetadata.setHardcoverBookId(12345); + + // Mock: insert_user_book -> find existing read -> update read + when(responseSpec.body(Map.class)) + .thenReturn(createInsertUserBookResponse(5001, null)) + .thenReturn(createFindUserBookReadResponse(6001)) + .thenReturn(createUpdateUserBookReadResponse()); + + service.syncProgressToHardcover(TEST_BOOK_ID, 50.0f); + + verify(restClient, atLeastOnce()).post(); + } + + @Test + @DisplayName("Should use ISBN10 when ISBN13 is missing") + void syncProgressToHardcover_whenIsbn13Missing_shouldUseIsbn10() { + testMetadata.setIsbn13(null); + testMetadata.setIsbn10("1234567890"); + + when(responseSpec.body(Map.class)) + .thenReturn(createSearchResponse(12345, 300)) + .thenReturn(createInsertUserBookResponse(5001, null)) + .thenReturn(createEmptyUserBookReadsResponse()) + .thenReturn(createInsertUserBookReadResponse()); + + service.syncProgressToHardcover(TEST_BOOK_ID, 50.0f); + + verify(restClient, atLeastOnce()).post(); + } + + // === Tests for error handling === + + @Test + @DisplayName("Should handle API errors gracefully") + void syncProgressToHardcover_whenApiError_shouldNotThrow() { + testMetadata.setHardcoverBookId(12345); + + when(responseSpec.body(Map.class)).thenReturn(Map.of("errors", List.of(Map.of("message", "Unauthorized")))); + + assertDoesNotThrow(() -> service.syncProgressToHardcover(TEST_BOOK_ID, 50.0f)); + } + + @Test + @DisplayName("Should handle null response gracefully") + void syncProgressToHardcover_whenResponseNull_shouldNotThrow() { + testMetadata.setHardcoverBookId(12345); + + when(responseSpec.body(Map.class)).thenReturn(null); + + assertDoesNotThrow(() -> service.syncProgressToHardcover(TEST_BOOK_ID, 50.0f)); + } + + // === Helper methods to create mock responses === + + private Map createSearchResponse(Integer bookId, Integer pages) { + Map response = new HashMap<>(); + Map data = new HashMap<>(); + Map search = new HashMap<>(); + Map results = new HashMap<>(); + Map hit = new HashMap<>(); + Map document = new HashMap<>(); + + document.put("id", bookId.toString()); + document.put("pages", pages); + hit.put("document", document); + results.put("hits", List.of(hit)); + search.put("results", results); + data.put("search", search); + response.put("data", data); + + return response; + } + + private Map createEmptySearchResponse() { + Map response = new HashMap<>(); + Map data = new HashMap<>(); + Map search = new HashMap<>(); + Map results = new HashMap<>(); + + results.put("hits", List.of()); + search.put("results", results); + data.put("search", search); + response.put("data", data); + + return response; + } + + private Map createInsertUserBookResponse(Integer userBookId, String error) { + Map response = new HashMap<>(); + Map data = new HashMap<>(); + Map insertResult = new HashMap<>(); + + if (userBookId != null) { + Map userBook = new HashMap<>(); + userBook.put("id", userBookId); + insertResult.put("user_book", userBook); + } + if (error != null) { + insertResult.put("error", error); + } + + data.put("insert_user_book", insertResult); + response.put("data", data); + + return response; + } + + private Map createFindUserBookResponse(Integer userBookId) { + Map response = new HashMap<>(); + Map data = new HashMap<>(); + Map me = new HashMap<>(); + Map userBook = new HashMap<>(); + + userBook.put("id", userBookId); + me.put("user_books", List.of(userBook)); + data.put("me", me); + response.put("data", data); + + return response; + } + + private Map createInsertUserBookReadResponse() { + Map response = new HashMap<>(); + Map data = new HashMap<>(); + Map insertResult = new HashMap<>(); + Map userBookRead = new HashMap<>(); + + userBookRead.put("id", 6001); + insertResult.put("user_book_read", userBookRead); + data.put("insert_user_book_read", insertResult); + response.put("data", data); + + return response; + } + + private Map createFindUserBookReadResponse(Integer readId) { + Map response = new HashMap<>(); + Map data = new HashMap<>(); + Map read = new HashMap<>(); + + read.put("id", readId); + data.put("user_book_reads", List.of(read)); + response.put("data", data); + + return response; + } + + private Map createEmptyUserBookReadsResponse() { + Map response = new HashMap<>(); + Map data = new HashMap<>(); + + data.put("user_book_reads", List.of()); + response.put("data", data); + + return response; + } + + private Map createUpdateUserBookReadResponse() { + Map response = new HashMap<>(); + Map data = new HashMap<>(); + Map updateResult = new HashMap<>(); + Map userBookRead = new HashMap<>(); + + userBookRead.put("id", 6001); + userBookRead.put("progress", 50); + updateResult.put("user_book_read", userBookRead); + data.put("update_user_book_read", updateResult); + response.put("data", data); + + return response; + } +} diff --git a/booklore-ui/src/app/features/book/components/book-browser/lock-unlock-metadata-dialog/lock-unlock-metadata-dialog.component.ts b/booklore-ui/src/app/features/book/components/book-browser/lock-unlock-metadata-dialog/lock-unlock-metadata-dialog.component.ts index efafb4d5..a353cec1 100644 --- a/booklore-ui/src/app/features/book/components/book-browser/lock-unlock-metadata-dialog/lock-unlock-metadata-dialog.component.ts +++ b/booklore-ui/src/app/features/book/components/book-browser/lock-unlock-metadata-dialog/lock-unlock-metadata-dialog.component.ts @@ -35,7 +35,7 @@ export class LockUnlockMetadataDialogComponent implements OnInit { 'isbn13Locked', 'isbn10Locked', 'asinLocked', 'pageCountLocked', 'thumbnailLocked', 'languageLocked', 'coverLocked', 'seriesNameLocked', 'seriesNumberLocked', 'seriesTotalLocked', 'authorsLocked', 'categoriesLocked', 'moodsLocked', 'tagsLocked', 'amazonRatingLocked', 'amazonReviewCountLocked', 'goodreadsRatingLocked', 'goodreadsReviewCountLocked', - 'hardcoverRatingLocked', 'hardcoverReviewCountLocked', 'goodreadsIdLocked', 'hardcoverIdLocked', 'googleIdLocked', 'comicvineIdLocked' + 'hardcoverRatingLocked', 'hardcoverReviewCountLocked', 'goodreadsIdLocked', 'hardcoverIdLocked', 'hardcoverBookIdLocked', 'googleIdLocked', 'comicvineIdLocked' ]; fieldLabels: Record = { @@ -66,6 +66,7 @@ export class LockUnlockMetadataDialogComponent implements OnInit { hardcoverReviewCountLocked: 'Hardcover Reviews', goodreadsIdLocked: 'Goodreads ID', hardcoverIdLocked: 'Hardcover ID', + hardcoverBookIdLocked: 'Hardcover Book ID', googleIdLocked: 'Google ID', comicvineIdLocked: 'Comicvine ID', }; diff --git a/booklore-ui/src/app/features/book/components/book-browser/metadata-restore-dialog-component/metadata-restore-dialog-component.html b/booklore-ui/src/app/features/book/components/book-browser/metadata-restore-dialog-component/metadata-restore-dialog-component.html index f9c1119c..80397c2b 100644 --- a/booklore-ui/src/app/features/book/components/book-browser/metadata-restore-dialog-component/metadata-restore-dialog-component.html +++ b/booklore-ui/src/app/features/book/components/book-browser/metadata-restore-dialog-component/metadata-restore-dialog-component.html @@ -80,6 +80,9 @@ @if (backupMetadata.hardcoverId) {
Hardcover ID: {{ backupMetadata.hardcoverId }}
} + @if (backupMetadata.hardcoverBookId !== null) { +
Hardcover Book ID: {{ backupMetadata.hardcoverBookId }}
+ } @if (backupMetadata.hardcoverRating !== null) {
Hardcover Rating: {{ backupMetadata.hardcoverRating }}
} diff --git a/booklore-ui/src/app/features/book/model/book.model.ts b/booklore-ui/src/app/features/book/model/book.model.ts index 24f0a022..31f9f381 100644 --- a/booklore-ui/src/app/features/book/model/book.model.ts +++ b/booklore-ui/src/app/features/book/model/book.model.ts @@ -88,6 +88,7 @@ export interface BookMetadata { goodreadsId?: string; comicvineId?: string; hardcoverId?: string; + hardcoverBookId?: number | null; googleId?: string; pageCount?: number | null; language?: string; @@ -122,6 +123,7 @@ export interface BookMetadata { comicvineIdLocked?: boolean; goodreadsIdLocked?: boolean; hardcoverIdLocked?: boolean; + hardcoverBookIdLocked?: boolean; googleIdLocked?: boolean; pageCountLocked?: boolean; languageLocked?: boolean; @@ -156,6 +158,7 @@ export interface MetadataClearFlags { goodreadsId?: boolean; comicvineId?: boolean; hardcoverId?: boolean; + hardcoverBookId?: boolean; googleId?: boolean; pageCount?: boolean; language?: boolean; diff --git a/booklore-ui/src/app/features/bookdrop/component/bookdrop-file-metadata-picker/bookdrop-file-metadata-picker.component.ts b/booklore-ui/src/app/features/bookdrop/component/bookdrop-file-metadata-picker/bookdrop-file-metadata-picker.component.ts index 129ab3ea..3cbd385d 100644 --- a/booklore-ui/src/app/features/bookdrop/component/bookdrop-file-metadata-picker/bookdrop-file-metadata-picker.component.ts +++ b/booklore-ui/src/app/features/bookdrop/component/bookdrop-file-metadata-picker/bookdrop-file-metadata-picker.component.ts @@ -75,6 +75,7 @@ export class BookdropFileMetadataPickerComponent { {label: 'Goodreads #', controlName: 'goodreadsReviewCount', lockedKey: 'goodreadsReviewCountLocked', fetchedKey: 'goodreadsReviewCount'}, {label: 'Goodreads ★', controlName: 'goodreadsRating', lockedKey: 'goodreadsRatingLocked', fetchedKey: 'goodreadsRating'}, {label: 'Hardcover ID', controlName: 'hardcoverId', lockedKey: 'hardcoverIdLocked', fetchedKey: 'hardcoverId'}, + {label: 'Hardcover Book ID', controlName: 'hardcoverBookId', lockedKey: 'hardcoverBookIdLocked', fetchedKey: 'hardcoverBookId'}, {label: 'Hardcover #', controlName: 'hardcoverReviewCount', lockedKey: 'hardcoverReviewCountLocked', fetchedKey: 'hardcoverReviewCount'}, {label: 'Hardcover ★', controlName: 'hardcoverRating', lockedKey: 'hardcoverRatingLocked', fetchedKey: 'hardcoverRating'}, {label: 'Google ID', controlName: 'googleId', lockedKey: 'googleIdLocked', fetchedKey: 'googleId'}, @@ -203,6 +204,7 @@ export class BookdropFileMetadataPickerComponent { goodreadsRating: this.originalMetadata.goodreadsRating || null, goodreadsReviewCount: this.originalMetadata.goodreadsReviewCount || null, hardcoverId: this.originalMetadata.hardcoverId || null, + hardcoverBookId: this.originalMetadata.hardcoverBookId || null, hardcoverRating: this.originalMetadata.hardcoverRating || null, hardcoverReviewCount: this.originalMetadata.hardcoverReviewCount || null, googleId: this.originalMetadata.googleId || null, diff --git a/booklore-ui/src/app/features/bookdrop/component/bookdrop-file-review/bookdrop-file-review.component.ts b/booklore-ui/src/app/features/bookdrop/component/bookdrop-file-review/bookdrop-file-review.component.ts index 8f482c23..7b3f66e5 100644 --- a/booklore-ui/src/app/features/bookdrop/component/bookdrop-file-review/bookdrop-file-review.component.ts +++ b/booklore-ui/src/app/features/bookdrop/component/bookdrop-file-review/bookdrop-file-review.component.ts @@ -319,6 +319,7 @@ export class BookdropFileReviewComponent implements OnInit { goodreadsRating: original?.goodreadsRating ?? null, goodreadsReviewCount: original?.goodreadsReviewCount ?? null, hardcoverId: original?.hardcoverId ?? null, + hardcoverBookId: original?.hardcoverBookId ?? null, hardcoverRating: original?.hardcoverRating ?? null, hardcoverReviewCount: original?.hardcoverReviewCount ?? null, googleId: original?.googleId ?? null, @@ -567,6 +568,7 @@ export class BookdropFileReviewComponent implements OnInit { goodreadsRating: new FormControl(original?.goodreadsRating ?? ''), goodreadsReviewCount: new FormControl(original?.goodreadsReviewCount ?? ''), hardcoverId: new FormControl(original?.hardcoverId ?? ''), + hardcoverBookId: new FormControl(original?.hardcoverBookId ?? ''), hardcoverRating: new FormControl(original?.hardcoverRating ?? ''), hardcoverReviewCount: new FormControl(original?.hardcoverReviewCount ?? ''), googleId: new FormControl(original?.googleId ?? ''), diff --git a/booklore-ui/src/app/features/metadata/component/book-metadata-center/metadata-editor/metadata-editor.component.html b/booklore-ui/src/app/features/metadata/component/book-metadata-center/metadata-editor/metadata-editor.component.html index 7e40b504..3a4aef33 100644 --- a/booklore-ui/src/app/features/metadata/component/book-metadata-center/metadata-editor/metadata-editor.component.html +++ b/booklore-ui/src/app/features/metadata/component/book-metadata-center/metadata-editor/metadata-editor.component.html @@ -471,6 +471,18 @@ } +
+ +
+ + @if (!book.metadata!['hardcoverBookIdLocked']) { + + } + @if (book.metadata!['hardcoverBookIdLocked']) { + + } +
+
@@ -602,7 +614,7 @@
} - +
@if (navigationState$ | async) {
diff --git a/booklore-ui/src/app/features/metadata/component/book-metadata-center/metadata-editor/metadata-editor.component.ts b/booklore-ui/src/app/features/metadata/component/book-metadata-center/metadata-editor/metadata-editor.component.ts index 7331b686..b9256ce4 100644 --- a/booklore-ui/src/app/features/metadata/component/book-metadata-center/metadata-editor/metadata-editor.component.ts +++ b/booklore-ui/src/app/features/metadata/component/book-metadata-center/metadata-editor/metadata-editor.component.ts @@ -163,6 +163,7 @@ export class MetadataEditorComponent implements OnInit { goodreadsRating: new FormControl(""), goodreadsReviewCount: new FormControl(""), hardcoverId: new FormControl(""), + hardcoverBookId: new FormControl(""), hardcoverRating: new FormControl(""), hardcoverReviewCount: new FormControl(""), googleId: new FormControl(""), @@ -192,6 +193,7 @@ export class MetadataEditorComponent implements OnInit { goodreadsRatingLocked: new FormControl(false), goodreadsReviewCountLocked: new FormControl(false), hardcoverIdLocked: new FormControl(false), + hardcoverBookIdLocked: new FormControl(false), hardcoverRatingLocked: new FormControl(false), hardcoverReviewCountLocked: new FormControl(false), googleIdLocked: new FormControl(false), @@ -291,6 +293,7 @@ export class MetadataEditorComponent implements OnInit { goodreadsRating: metadata.goodreadsRating ?? null, goodreadsReviewCount: metadata.goodreadsReviewCount ?? null, hardcoverId: metadata.hardcoverId ?? null, + hardcoverBookId: metadata.hardcoverBookId ?? null, hardcoverRating: metadata.hardcoverRating ?? null, hardcoverReviewCount: metadata.hardcoverReviewCount ?? null, googleId: metadata.googleId ?? null, @@ -318,6 +321,7 @@ export class MetadataEditorComponent implements OnInit { goodreadsRatingLocked: metadata.goodreadsRatingLocked ?? false, goodreadsReviewCountLocked: metadata.goodreadsReviewCountLocked ?? false, hardcoverIdLocked: metadata.hardcoverIdLocked ?? false, + hardcoverBookIdLocked: metadata.hardcoverBookIdLocked ?? false, hardcoverRatingLocked: metadata.hardcoverRatingLocked ?? false, hardcoverReviewCountLocked: metadata.hardcoverReviewCountLocked ?? false, googleIdLocked: metadata.googleIdLocked ?? false, @@ -348,6 +352,7 @@ export class MetadataEditorComponent implements OnInit { {key: "goodreadsReviewCountLocked", control: "goodreadsReviewCount"}, {key: "goodreadsRatingLocked", control: "goodreadsRating"}, {key: "hardcoverIdLocked", control: "hardcoverId"}, + {key: "hardcoverBookIdLocked", control: "hardcoverBookId"}, {key: "hardcoverReviewCountLocked", control: "hardcoverReviewCount"}, {key: "hardcoverRatingLocked", control: "hardcoverRating"}, {key: "googleIdLocked", control: "googleId"}, @@ -486,6 +491,7 @@ export class MetadataEditorComponent implements OnInit { goodreadsRating: form.get("goodreadsRating")?.value, goodreadsReviewCount: form.get("goodreadsReviewCount")?.value, hardcoverId: form.get("hardcoverId")?.value, + hardcoverBookId: form.get("hardcoverBookId")?.value, hardcoverRating: form.get("hardcoverRating")?.value, hardcoverReviewCount: form.get("hardcoverReviewCount")?.value, googleId: form.get("googleId")?.value, @@ -517,6 +523,7 @@ export class MetadataEditorComponent implements OnInit { goodreadsRatingLocked: form.get("goodreadsRatingLocked")?.value, goodreadsReviewCountLocked: form.get("goodreadsReviewCountLocked")?.value, hardcoverIdLocked: form.get("hardcoverIdLocked")?.value, + hardcoverBookIdLocked: form.get("hardcoverBookIdLocked")?.value, hardcoverRatingLocked: form.get("hardcoverRatingLocked")?.value, hardcoverReviewCountLocked: form.get("hardcoverReviewCountLocked")?.value, googleIdLocked: form.get("googleIdLocked")?.value, diff --git a/booklore-ui/src/app/features/metadata/component/book-metadata-center/metadata-picker/metadata-picker.component.ts b/booklore-ui/src/app/features/metadata/component/book-metadata-center/metadata-picker/metadata-picker.component.ts index 509f56c0..ba1a88f2 100644 --- a/booklore-ui/src/app/features/metadata/component/book-metadata-center/metadata-picker/metadata-picker.component.ts +++ b/booklore-ui/src/app/features/metadata/component/book-metadata-center/metadata-picker/metadata-picker.component.ts @@ -73,6 +73,7 @@ export class MetadataPickerComponent implements OnInit { {label: 'GR Reviews', controlName: 'goodreadsReviewCount', lockedKey: 'goodreadsReviewCountLocked', fetchedKey: 'goodreadsReviewCount'}, {label: 'GR Rating', controlName: 'goodreadsRating', lockedKey: 'goodreadsRatingLocked', fetchedKey: 'goodreadsRating'}, {label: 'Hardcover ID', controlName: 'hardcoverId', lockedKey: 'hardcoverIdLocked', fetchedKey: 'hardcoverId'}, + {label: 'Hardcover Book ID', controlName: 'hardcoverBookId', lockedKey: 'hardcoverBookIdLocked', fetchedKey: 'hardcoverBookId'}, {label: 'HC Reviews', controlName: 'hardcoverReviewCount', lockedKey: 'hardcoverReviewCountLocked', fetchedKey: 'hardcoverReviewCount'}, {label: 'HC Rating', controlName: 'hardcoverRating', lockedKey: 'hardcoverRatingLocked', fetchedKey: 'hardcoverRating'}, {label: 'Google ID', controlName: 'googleId', lockedKey: 'googleIdLocked', fetchedKey: 'googleId'}, @@ -149,6 +150,7 @@ export class MetadataPickerComponent implements OnInit { goodreadsRating: new FormControl(''), goodreadsReviewCount: new FormControl(''), hardcoverId: new FormControl(''), + hardcoverBookId: new FormControl(''), hardcoverRating: new FormControl(''), hardcoverReviewCount: new FormControl(''), googleId: new FormControl(''), @@ -178,6 +180,7 @@ export class MetadataPickerComponent implements OnInit { goodreadsRatingLocked: new FormControl(false), goodreadsReviewCountLocked: new FormControl(false), hardcoverIdLocked: new FormControl(false), + hardcoverBookIdLocked: new FormControl(false), hardcoverRatingLocked: new FormControl(false), hardcoverReviewCountLocked: new FormControl(false), googleIdLocked: new FormControl(false), @@ -254,6 +257,7 @@ export class MetadataPickerComponent implements OnInit { goodreadsRating: metadata.goodreadsRating || null, goodreadsReviewCount: metadata.goodreadsReviewCount || null, hardcoverId: metadata.hardcoverId || null, + hardcoverBookId: metadata.hardcoverBookId || null, hardcoverRating: metadata.hardcoverRating || null, hardcoverReviewCount: metadata.hardcoverReviewCount || null, googleId: metadata.googleId || null, @@ -283,6 +287,7 @@ export class MetadataPickerComponent implements OnInit { goodreadsRatingLocked: metadata.goodreadsRatingLocked || false, goodreadsReviewCountLocked: metadata.goodreadsReviewCountLocked || false, hardcoverIdLocked: metadata.hardcoverIdLocked || false, + hardcoverBookIdLocked: metadata.hardcoverBookIdLocked || false, hardcoverRatingLocked: metadata.hardcoverRatingLocked || false, hardcoverReviewCountLocked: metadata.hardcoverReviewCountLocked || false, googleIdLocked: metadata.googleIdLocked || false, @@ -319,6 +324,7 @@ export class MetadataPickerComponent implements OnInit { if (metadata.goodreadsReviewCountLocked) this.metadataForm.get('goodreadsReviewCount')?.disable({emitEvent: false}); if (metadata.goodreadsRatingLocked) this.metadataForm.get('goodreadsRating')?.disable({emitEvent: false}); if (metadata.hardcoverIdLocked) this.metadataForm.get('hardcoverId')?.disable({emitEvent: false}); + if (metadata.hardcoverBookIdLocked) this.metadataForm.get('hardcoverBookId')?.disable({emitEvent: false}); if (metadata.hardcoverReviewCountLocked) this.metadataForm.get('hardcoverReviewCount')?.disable({emitEvent: false}); if (metadata.hardcoverRatingLocked) this.metadataForm.get('hardcoverRating')?.disable({emitEvent: false}); if (metadata.googleIdLocked) this.metadataForm.get('googleId')?.disable({emitEvent: false}); @@ -397,6 +403,7 @@ export class MetadataPickerComponent implements OnInit { goodreadsRating: this.metadataForm.get('goodreadsRating')?.value || this.copiedFields['goodreadsRating'] ? this.getNumberOrCopied('goodreadsRating') : null, goodreadsReviewCount: this.metadataForm.get('goodreadsReviewCount')?.value || this.copiedFields['goodreadsReviewCount'] ? this.getNumberOrCopied('goodreadsReviewCount') : null, hardcoverId: this.metadataForm.get('hardcoverId')?.value || this.copiedFields['hardcoverId'] ? this.getValueOrCopied('hardcoverId') : '', + hardcoverBookId: this.metadataForm.get('hardcoverBookId')?.value || this.copiedFields['hardcoverBookId'] ? (this.getNumberOrCopied('hardcoverBookId') ?? null) : null, hardcoverRating: this.metadataForm.get('hardcoverRating')?.value || this.copiedFields['hardcoverRating'] ? this.getNumberOrCopied('hardcoverRating') : null, hardcoverReviewCount: this.metadataForm.get('hardcoverReviewCount')?.value || this.copiedFields['hardcoverReviewCount'] ? this.getNumberOrCopied('hardcoverReviewCount') : null, googleId: this.metadataForm.get('googleId')?.value || this.copiedFields['googleId'] ? this.getValueOrCopied('googleId') : '', @@ -426,6 +433,7 @@ export class MetadataPickerComponent implements OnInit { goodreadsRatingLocked: this.metadataForm.get('goodreadsRatingLocked')?.value, goodreadsReviewCountLocked: this.metadataForm.get('goodreadsReviewCountLocked')?.value, hardcoverIdLocked: this.metadataForm.get('hardcoverIdLocked')?.value, + hardcoverBookIdLocked: this.metadataForm.get('hardcoverBookIdLocked')?.value, hardcoverRatingLocked: this.metadataForm.get('hardcoverRatingLocked')?.value, hardcoverReviewCountLocked: this.metadataForm.get('hardcoverReviewCountLocked')?.value, googleIdLocked: this.metadataForm.get('googleIdLocked')?.value, @@ -468,6 +476,7 @@ export class MetadataPickerComponent implements OnInit { goodreadsRating: current.goodreadsRating === null && original.goodreadsRating !== null, goodreadsReviewCount: current.goodreadsReviewCount === null && original.goodreadsReviewCount !== null, hardcoverId: !current.hardcoverId && !!original.hardcoverId, + hardcoverBookId: current.hardcoverBookId === null && original.hardcoverBookId !== null, hardcoverRating: current.hardcoverRating === null && original.hardcoverRating !== null, hardcoverReviewCount: current.hardcoverReviewCount === null && original.hardcoverReviewCount !== null, googleId: !current.googleId && !!original.googleId,