mirror of
https://github.com/booklore-app/booklore.git
synced 2025-12-23 14:20:48 -05:00
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 <akiraslingshot@gmail.com>
This commit is contained in:
committed by
GitHub
parent
f869ac0ac4
commit
2da01e7a2e
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
;
|
||||
|
||||
@@ -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<String, Object> 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<String, Object> data = (Map<String, Object>) response.get("data");
|
||||
if (data == null) return null;
|
||||
|
||||
Map<String, Object> search = (Map<String, Object>) data.get("search");
|
||||
if (search == null) return null;
|
||||
|
||||
Map<String, Object> results = (Map<String, Object>) search.get("results");
|
||||
if (results == null) return null;
|
||||
|
||||
List<Map<String, Object>> hits = (List<Map<String, Object>>) results.get("hits");
|
||||
if (hits == null || hits.isEmpty()) return null;
|
||||
|
||||
Map<String, Object> document = (Map<String, Object>) 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<String, Object> response = executeGraphQL(request);
|
||||
log.debug("Edition lookup response: {}", response);
|
||||
if (response == null) return null;
|
||||
|
||||
Map<String, Object> data = (Map<String, Object>) response.get("data");
|
||||
if (data == null) return null;
|
||||
|
||||
List<Map<String, Object>> editions = (List<Map<String, Object>>) data.get("editions");
|
||||
if (editions == null || editions.isEmpty()) return null;
|
||||
|
||||
Map<String, Object> 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<String, Object> 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<String, Object> response = executeGraphQL(request);
|
||||
log.debug("insert_user_book response: {}", response);
|
||||
if (response == null) return null;
|
||||
|
||||
Map<String, Object> data = (Map<String, Object>) response.get("data");
|
||||
if (data == null) return null;
|
||||
|
||||
Map<String, Object> insertResult = (Map<String, Object>) 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<String, Object> userBook = (Map<String, Object>) 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<String, Object> response = executeGraphQL(request);
|
||||
if (response == null) return null;
|
||||
|
||||
Map<String, Object> data = (Map<String, Object>) response.get("data");
|
||||
if (data == null) return null;
|
||||
|
||||
Map<String, Object> me = (Map<String, Object>) data.get("me");
|
||||
if (me == null) return null;
|
||||
|
||||
List<Map<String, Object>> userBooks = (List<Map<String, Object>>) 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<String, Object> response = executeGraphQL(request);
|
||||
if (response == null) return null;
|
||||
|
||||
Map<String, Object> data = (Map<String, Object>) response.get("data");
|
||||
if (data == null) return null;
|
||||
|
||||
List<Map<String, Object>> reads = (List<Map<String, Object>>) 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<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> 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;
|
||||
}
|
||||
}
|
||||
@@ -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<KoboReadingState> 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());
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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()));
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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<String, Object> createSearchResponse(Integer bookId, Integer pages) {
|
||||
Map<String, Object> response = new HashMap<>();
|
||||
Map<String, Object> data = new HashMap<>();
|
||||
Map<String, Object> search = new HashMap<>();
|
||||
Map<String, Object> results = new HashMap<>();
|
||||
Map<String, Object> hit = new HashMap<>();
|
||||
Map<String, Object> 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<String, Object> createEmptySearchResponse() {
|
||||
Map<String, Object> response = new HashMap<>();
|
||||
Map<String, Object> data = new HashMap<>();
|
||||
Map<String, Object> search = new HashMap<>();
|
||||
Map<String, Object> results = new HashMap<>();
|
||||
|
||||
results.put("hits", List.of());
|
||||
search.put("results", results);
|
||||
data.put("search", search);
|
||||
response.put("data", data);
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
private Map<String, Object> createInsertUserBookResponse(Integer userBookId, String error) {
|
||||
Map<String, Object> response = new HashMap<>();
|
||||
Map<String, Object> data = new HashMap<>();
|
||||
Map<String, Object> insertResult = new HashMap<>();
|
||||
|
||||
if (userBookId != null) {
|
||||
Map<String, Object> 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<String, Object> createFindUserBookResponse(Integer userBookId) {
|
||||
Map<String, Object> response = new HashMap<>();
|
||||
Map<String, Object> data = new HashMap<>();
|
||||
Map<String, Object> me = new HashMap<>();
|
||||
Map<String, Object> 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<String, Object> createInsertUserBookReadResponse() {
|
||||
Map<String, Object> response = new HashMap<>();
|
||||
Map<String, Object> data = new HashMap<>();
|
||||
Map<String, Object> insertResult = new HashMap<>();
|
||||
Map<String, Object> 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<String, Object> createFindUserBookReadResponse(Integer readId) {
|
||||
Map<String, Object> response = new HashMap<>();
|
||||
Map<String, Object> data = new HashMap<>();
|
||||
Map<String, Object> read = new HashMap<>();
|
||||
|
||||
read.put("id", readId);
|
||||
data.put("user_book_reads", List.of(read));
|
||||
response.put("data", data);
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
private Map<String, Object> createEmptyUserBookReadsResponse() {
|
||||
Map<String, Object> response = new HashMap<>();
|
||||
Map<String, Object> data = new HashMap<>();
|
||||
|
||||
data.put("user_book_reads", List.of());
|
||||
response.put("data", data);
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
private Map<String, Object> createUpdateUserBookReadResponse() {
|
||||
Map<String, Object> response = new HashMap<>();
|
||||
Map<String, Object> data = new HashMap<>();
|
||||
Map<String, Object> updateResult = new HashMap<>();
|
||||
Map<String, Object> 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;
|
||||
}
|
||||
}
|
||||
@@ -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<string, string> = {
|
||||
@@ -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',
|
||||
};
|
||||
|
||||
@@ -80,6 +80,9 @@
|
||||
@if (backupMetadata.hardcoverId) {
|
||||
<div><strong>Hardcover ID:</strong> {{ backupMetadata.hardcoverId }}</div>
|
||||
}
|
||||
@if (backupMetadata.hardcoverBookId !== null) {
|
||||
<div><strong>Hardcover Book ID:</strong> {{ backupMetadata.hardcoverBookId }}</div>
|
||||
}
|
||||
@if (backupMetadata.hardcoverRating !== null) {
|
||||
<div><strong>Hardcover Rating:</strong> {{ backupMetadata.hardcoverRating }}</div>
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 ?? ''),
|
||||
|
||||
@@ -471,6 +471,18 @@
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1 md:w-1/6">
|
||||
<label class="text-sm" for="hardcoverBookId">Hardcover Book ID</label>
|
||||
<div class="flex withbutton">
|
||||
<input pSize="small" pInputText id="hardcoverBookId" formControlName="hardcoverBookId" class="w-full"/>
|
||||
@if (!book.metadata!['hardcoverBookIdLocked']) {
|
||||
<p-button size="small" icon="pi pi-lock-open" [outlined]="true" (onClick)="toggleLock('hardcoverBookId')" severity="success"></p-button>
|
||||
}
|
||||
@if (book.metadata!['hardcoverBookIdLocked']) {
|
||||
<p-button size="small" icon="pi pi-lock" [outlined]="true" (onClick)="toggleLock('hardcoverBookId')" severity="warn"></p-button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1 md:w-1/6">
|
||||
<label class="text-sm" for="hardcoverRating">Hardcover ★</label>
|
||||
<div class="flex withbutton">
|
||||
@@ -602,7 +614,7 @@
|
||||
<p-button size="small" icon="pi pi-arrow-right" [disabled]="disableNext" iconPos="right" [outlined]="true" severity="info" (onClick)="onNext()" pTooltip="Next Book" tooltipPosition="top"></p-button>
|
||||
</div>
|
||||
}
|
||||
|
||||
|
||||
<div class="flex justify-between items-center w-full gap-4">
|
||||
@if (navigationState$ | async) {
|
||||
<div class="flex gap-2 items-center">
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user