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:
Giancarlo Perrone
2025-12-19 09:56:40 -08:00
committed by GitHub
parent f869ac0ac4
commit 2da01e7a2e
22 changed files with 1124 additions and 2 deletions

View File

@@ -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;

View File

@@ -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;

View File

@@ -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)
;

View File

@@ -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;
}
}

View File

@@ -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());
}

View File

@@ -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),

View File

@@ -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()) {

View File

@@ -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());

View File

@@ -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()));

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;
}
}

View File

@@ -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',
};

View File

@@ -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>
}

View File

@@ -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;

View File

@@ -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,

View File

@@ -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 ?? ''),

View File

@@ -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">

View File

@@ -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,

View File

@@ -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,