diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/service/metadata/parser/AmazonBookParser.java b/booklore-api/src/main/java/com/adityachandel/booklore/service/metadata/parser/AmazonBookParser.java index 376545259..d05058c98 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/service/metadata/parser/AmazonBookParser.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/service/metadata/parser/AmazonBookParser.java @@ -35,14 +35,18 @@ public class AmazonBookParser implements BookParser { private static final int COUNT_DETAILED_METADATA_TO_GET = 3; private static final String BASE_BOOK_URL_SUFFIX = "/dp/"; private static final Pattern NON_DIGIT_PATTERN = Pattern.compile("[^\\d]"); - private static final Pattern SERIES_FORMAT_PATTERN = Pattern.compile("Book \\d+ of \\d+"); - private static final Pattern SERIES_FORMAT_WITH_DECIMAL_PATTERN = Pattern.compile("Book \\d+(\\.\\d+)? of \\d+"); + private static final Pattern SERIES_FORMAT_PATTERN = Pattern.compile("Book (\\d+(?:\\.\\d+)?) of (\\d+)"); private static final Pattern PARENTHESES_WITH_WHITESPACE_PATTERN = Pattern.compile("\\s*\\(.*?\\)"); private static final Pattern NON_ALPHANUMERIC_PATTERN = Pattern.compile("[^\\p{L}\\p{M}0-9]"); private static final Pattern DP_SEPARATOR_PATTERN = Pattern.compile("/dp/"); - // Pattern to extract country and date from strings like "Reviewed in ... on ..." or Japanese format private static final Pattern REVIEWED_IN_ON_PATTERN = Pattern.compile("(?i)(?:Reviewed in|Rezension aus|Beoordeeld in|Recensie uit|Commenté en|Recensito in|Revisado en)\\s+(.+?)\\s+(?:on|vom|op|le|il|el)\\s+(.+)"); private static final Pattern JAPANESE_REVIEW_DATE_PATTERN = Pattern.compile("(\\d{4}年\\d{1,2}月\\d{1,2}日).+"); + private static final String[] TITLE_SELECTORS = {"#productTitle", "#ebooksProductTitle", "h1#title", "span#productTitle"}; + private static final String[] DATE_PATTERNS = { + "MMMM d, yyyy", "d MMMM yyyy", "d. MMMM yyyy", "MMM d, yyyy", + "MMM. d, yyyy", "d MMM yyyy", "d MMM. yyyy", "d. MMM yyyy", + "yyyy/M/d", "yyyy/MM/dd", "yyyy年M月d日" + }; private static final Map DOMAIN_LOCALE_MAP = Map.ofEntries( Map.entry("com", new LocaleInfo("en-US,en;q=0.9", Locale.US)), @@ -69,17 +73,13 @@ public class AmazonBookParser implements BookParser { Map.entry("com.be", new LocaleInfo("en-GB,en;q=0.9,fr;q=0.8,nl;q=0.8", new Locale.Builder().setLanguage("fr").setRegion("BE").build())) ); + private static final LocaleInfo DEFAULT_LOCALE_INFO = new LocaleInfo("en-US,en;q=0.9", Locale.US); + private final AppSettingService appSettingService; - private static class LocaleInfo { - final String acceptLanguage; - final Locale locale; - - LocaleInfo(String acceptLanguage, Locale locale) { - this.acceptLanguage = acceptLanguage; - this.locale = locale; - } - } + private record LocaleInfo(String acceptLanguage, Locale locale) {} + private record TitleInfo(String title, String subtitle) {} + private record SeriesInfo(String name, Float number, Integer total) {} @Override public BookMetadata fetchTopMetadata(Book book, FetchMetadataRequest fetchMetadataRequest) { @@ -214,18 +214,21 @@ public class AmazonBookParser implements BookParser { } private BookMetadata buildBookMetadata(Document doc, String amazonBookId, List reviews) { + TitleInfo titleInfo = parseTitleInfo(doc); + SeriesInfo seriesInfo = parseSeriesInfo(doc); + return BookMetadata.builder() .provider(MetadataProvider.Amazon) - .title(getTitle(doc)) - .subtitle(getSubtitle(doc)) + .title(titleInfo.title()) + .subtitle(titleInfo.subtitle()) .authors(new HashSet<>(getAuthors(doc))) .categories(new HashSet<>(getBestSellerCategories(doc))) .description(cleanDescriptionHtml(getDescription(doc))) - .seriesName(getSeriesName(doc)) - .seriesNumber(getSeriesNumber(doc)) - .seriesTotal(getSeriesTotal(doc)) - .isbn13(getIsbn13(doc)) - .isbn10(getIsbn10(doc)) + .seriesName(seriesInfo.name()) + .seriesNumber(seriesInfo.number()) + .seriesTotal(seriesInfo.total()) + .isbn13(getIsbn(doc, "isbn13")) + .isbn10(getIsbn(doc, "isbn10")) .asin(amazonBookId) .publisher(getPublisher(doc)) .publishedDate(getPublicationDate(doc)) @@ -251,19 +254,11 @@ public class AmazonBookParser implements BookParser { String title = fetchMetadataRequest.getTitle(); if (title != null && !title.isEmpty()) { - String cleanedTitle = Arrays.stream(title.split(" ")) - .map(word -> NON_ALPHANUMERIC_PATTERN.matcher(word).replaceAll("").trim()) - .filter(word -> !word.isEmpty()) - .collect(Collectors.joining(" ")); - searchTerm.append(cleanedTitle); + searchTerm.append(cleanSearchTerm(title)); } else { String filename = BookUtils.cleanAndTruncateSearchTerm(BookUtils.cleanFileName(book.getFileName())); if (!filename.isEmpty()) { - String cleanedFilename = Arrays.stream(filename.split(" ")) - .map(word -> NON_ALPHANUMERIC_PATTERN.matcher(word).replaceAll("").trim()) - .filter(word -> !word.isEmpty()) - .collect(Collectors.joining(" ")); - searchTerm.append(cleanedFilename); + searchTerm.append(cleanSearchTerm(filename)); } } @@ -272,11 +267,7 @@ public class AmazonBookParser implements BookParser { if (!searchTerm.isEmpty()) { searchTerm.append(" "); } - String cleanedAuthor = Arrays.stream(author.split(" ")) - .map(word -> NON_ALPHANUMERIC_PATTERN.matcher(word).replaceAll("").trim()) - .filter(word -> !word.isEmpty()) - .collect(Collectors.joining(" ")); - searchTerm.append(cleanedAuthor); + searchTerm.append(cleanSearchTerm(author)); } if (searchTerm.isEmpty()) { @@ -289,6 +280,13 @@ public class AmazonBookParser implements BookParser { return url; } + private String cleanSearchTerm(String text) { + return Arrays.stream(text.split(" ")) + .map(word -> NON_ALPHANUMERIC_PATTERN.matcher(word).replaceAll("").trim()) + .filter(word -> !word.isEmpty()) + .collect(Collectors.joining(" ")); + } + private String getTextBySelectors(Document doc, String... selectors) { for (String selector : selectors) { try { @@ -303,47 +301,26 @@ public class AmazonBookParser implements BookParser { return null; } - private String getTitle(Document doc) { - String title = getTextBySelectors(doc, - "#productTitle", - "#ebooksProductTitle", - "h1#title", - "span#productTitle" - ); - if (title != null) { - return title.split(":", 2)[0].trim(); + private TitleInfo parseTitleInfo(Document doc) { + String fullTitle = getTextBySelectors(doc, TITLE_SELECTORS); + if (fullTitle == null) { + log.warn("Failed to parse title: No suitable element found."); + return new TitleInfo(null, null); } - log.warn("Failed to parse title: No suitable element found."); - return null; - } - - private String getSubtitle(Document doc) { - String title = getTextBySelectors(doc, - "#productTitle", - "#ebooksProductTitle", - "h1#title", - "span#productTitle" - ); - if (title != null) { - String[] parts = title.split(":", 2); - if (parts.length > 1) { - return parts[1].trim(); - } - } - log.warn("Failed to parse subtitle: No suitable element found."); - return null; + String[] parts = fullTitle.split(":", 2); + String title = parts[0].trim(); + String subtitle = parts.length > 1 ? parts[1].trim() : null; + return new TitleInfo(title, subtitle); } private Set getAuthors(Document doc) { Set authors = new HashSet<>(); try { - // Primary strategy: #bylineInfo_feature_div .author a Element bylineDiv = doc.selectFirst("#bylineInfo_feature_div"); if (bylineDiv != null) { authors.addAll(bylineDiv.select(".author a").stream().map(Element::text).collect(Collectors.toSet())); } - // Fallback: #bylineInfo .author a if (authors.isEmpty()) { Element bylineInfo = doc.selectFirst("#bylineInfo"); if (bylineInfo != null) { @@ -351,7 +328,6 @@ public class AmazonBookParser implements BookParser { } } - // Fallback: simple .author a check (broadest) if (authors.isEmpty()) { authors.addAll(doc.select(".author a").stream().map(Element::text).collect(Collectors.toSet())); } @@ -367,7 +343,6 @@ public class AmazonBookParser implements BookParser { private String getDescription(Document doc) { try { - // Primary: data-a-expander-name Elements descriptionElements = doc.select("[data-a-expander-name=book_description_expander] .a-expander-content"); if (!descriptionElements.isEmpty()) { String html = descriptionElements.getFirst().html(); @@ -375,13 +350,11 @@ public class AmazonBookParser implements BookParser { return html; } - // Fallback: #bookDescription_feature_div noscript (often contains the clean HTML) Element noscriptDesc = doc.selectFirst("#bookDescription_feature_div noscript"); if (noscriptDesc != null) { - return noscriptDesc.html(); // usually clean HTML inside noscript + return noscriptDesc.html(); } - // Fallback: div.product-description Element simpleDesc = doc.selectFirst("div.product-description"); if (simpleDesc != null) { return simpleDesc.html(); @@ -393,42 +366,23 @@ public class AmazonBookParser implements BookParser { return null; } - private String getIsbn10(Document doc) { - // Strategy 1: RPI attribute + private String getIsbn(Document doc, String type) { + String rpiSelector = "#rpi-attribute-book_details-" + type + " .rpi-attribute-value span"; + String bulletKey = type.equals("isbn10") ? "ISBN-10" : "ISBN-13"; + try { - Element isbn10Element = doc.select("#rpi-attribute-book_details-isbn10 .rpi-attribute-value span").first(); - if (isbn10Element != null) { - return ParserUtils.cleanIsbn(isbn10Element.text()); + Element isbnElement = doc.select(rpiSelector).first(); + if (isbnElement != null) { + return ParserUtils.cleanIsbn(isbnElement.text()); } } catch (Exception e) { - log.debug("RPI ISBN-10 extraction failed: {}", e.getMessage()); + log.debug("RPI {} extraction failed: {}", type.toUpperCase(), e.getMessage()); } - // Strategy 2: Detail bullets try { - return extractFromDetailBullets(doc, "ISBN-10"); + return extractFromDetailBullets(doc, bulletKey); } catch (Exception e) { - log.warn("DetailBullets ISBN-10 extraction failed: {}", e.getMessage()); - } - return null; - } - - private String getIsbn13(Document doc) { - // Strategy 1: RPI attribute - try { - Element isbn13Element = doc.select("#rpi-attribute-book_details-isbn13 .rpi-attribute-value span").first(); - if (isbn13Element != null) { - return ParserUtils.cleanIsbn(isbn13Element.text()); - } - } catch (Exception e) { - log.debug("RPI ISBN-13 extraction failed: {}", e.getMessage()); - } - - // Strategy 2: Detail bullets - try { - return extractFromDetailBullets(doc, "ISBN-13"); - } catch (Exception e) { - log.warn("DetailBullets ISBN-13 extraction failed: {}", e.getMessage()); + log.warn("DetailBullets {} extraction failed: {}", type.toUpperCase(), e.getMessage()); } return null; } @@ -442,15 +396,14 @@ public class AmazonBookParser implements BookParser { Element boldText = listItem.selectFirst("span.a-text-bold"); if (boldText != null) { String header = boldText.text().toLowerCase(); - // Check against known localized "Publisher" labels - if (header.contains("publisher") || + if (header.contains("publisher") || header.contains("herausgeber") || header.contains("éditeur") || header.contains("editoriale") || header.contains("editorial") || header.contains("uitgever") || header.contains("wydawca") || - header.contains("出版社") || // Japanese + header.contains("出版社") || header.contains("editora")) { Element publisherSpan = boldText.nextElementSibling(); @@ -471,38 +424,32 @@ public class AmazonBookParser implements BookParser { } private LocalDate getPublicationDate(Document doc) { - // Strategy 1: RPI attribute try { Element publicationDateElement = doc.select("#rpi-attribute-book_details-publication_date .rpi-attribute-value span").first(); if (publicationDateElement != null) { String dateText = publicationDateElement.text(); - LocalDate parsedDate = parseAmazonDate(dateText); + LocalDate parsedDate = parseDate(dateText); if (parsedDate != null) return parsedDate; } } catch (Exception e) { log.debug("RPI Publication Date extraction failed: {}", e.getMessage()); } - // Strategy 2: Detail bullets (look for specific date patterns in values) try { Element featureElement = doc.getElementById("detailBullets_feature_div"); if (featureElement != null) { Elements listItems = featureElement.select("li"); for (Element listItem : listItems) { - // We look for any value that parses as a date, as the label varies wildly ("Publication date", "Seitenzahl"?? no, "Erscheinungsdatum", etc.) - // But usually it's associated with "Publisher" line sometimes: "Publisher: XYZ (Jan 1, 2020)" Element boldText = listItem.selectFirst("span.a-text-bold"); Element valueSpan = boldText != null ? boldText.nextElementSibling() : null; if (valueSpan != null) { - // Sometimes date is inside the value span, e.g. "January 1, 2020" - LocalDate d = parseAmazonDate(valueSpan.text()); + LocalDate d = parseDate(valueSpan.text()); if (d != null) return d; - // Sometimes it's in parentheses after publisher: "Publisher: Name (January 1, 2020)" Matcher matcher = Pattern.compile("\\((.*?)\\)").matcher(valueSpan.text()); while (matcher.find()) { - LocalDate pd = parseAmazonDate(matcher.group(1)); + LocalDate pd = parseDate(matcher.group(1)); if (pd != null) return pd; } } @@ -532,54 +479,31 @@ public class AmazonBookParser implements BookParser { return null; } - private String getSeriesName(Document doc) { + private SeriesInfo parseSeriesInfo(Document doc) { + String name = null; + Float number = null; + Integer total = null; + try { Element seriesNameElement = doc.selectFirst("#rpi-attribute-book_details-series .rpi-attribute-value a span"); if (seriesNameElement != null) { - return seriesNameElement.text(); - } else { - log.debug("Failed to parse seriesName: Element not found."); + name = seriesNameElement.text(); } - } catch (Exception e) { - log.warn("Failed to parse seriesName: {}", e.getMessage()); - } - return null; - } - private Float getSeriesNumber(Document doc) { - try { Element bookDetailsLabel = doc.selectFirst("#rpi-attribute-book_details-series .rpi-attribute-label span"); if (bookDetailsLabel != null) { String bookAndTotal = bookDetailsLabel.text(); - if (SERIES_FORMAT_WITH_DECIMAL_PATTERN.matcher(bookAndTotal).matches()) { - String[] parts = bookAndTotal.split(" "); - return Float.parseFloat(parts[1]); + Matcher matcher = SERIES_FORMAT_PATTERN.matcher(bookAndTotal); + if (matcher.find()) { + number = Float.parseFloat(matcher.group(1)); + total = Integer.parseInt(matcher.group(2)); } - } else { - log.debug("Failed to parse seriesNumber: Element not found."); } } catch (Exception e) { - log.warn("Failed to parse seriesNumber: {}", e.getMessage()); + log.warn("Failed to parse series info: {}", e.getMessage()); } - return null; - } - private Integer getSeriesTotal(Document doc) { - try { - Element bookDetailsLabel = doc.selectFirst("#rpi-attribute-book_details-series .rpi-attribute-label span"); - if (bookDetailsLabel != null) { - String bookAndTotal = bookDetailsLabel.text(); - if (SERIES_FORMAT_PATTERN.matcher(bookAndTotal).matches()) { - String[] parts = bookAndTotal.split(" "); - return Integer.parseInt(parts[3]); - } - } else { - log.debug("Failed to parse seriesTotal: Element not found."); - } - } catch (Exception e) { - log.warn("Failed to parse seriesTotal: {}", e.getMessage()); - } - return null; + return new SeriesInfo(name, number, total); } private String getLanguage(Document doc) { @@ -637,7 +561,6 @@ public class AmazonBookParser implements BookParser { private List getReviews(Document doc, int maxReviews) { List reviews = new ArrayList<>(); - DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss", Locale.ENGLISH); String domain = appSettingService.getAppSettings().getMetadataProviderSettings().getAmazon().getDomain(); LocaleInfo localeInfo = getLocaleInfoForDomain(domain); @@ -665,7 +588,6 @@ public class AmazonBookParser implements BookParser { String ratingText = !ratingElements.isEmpty() ? ratingElements.first().text() : ""; if (!ratingText.isEmpty()) { try { - // Support both comma and dot as decimal separator Pattern ratingPattern = Pattern.compile("^([0-9]+([.,][0-9]+)?)"); Matcher ratingMatcher = ratingPattern.matcher(ratingText); if (ratingMatcher.find()) { @@ -694,7 +616,6 @@ public class AmazonBookParser implements BookParser { } datePart = matcher.group(2).trim(); } else { - // Try Japanese format Matcher japaneseMatcher = JAPANESE_REVIEW_DATE_PATTERN.matcher(fullDateText); if (japaneseMatcher.find()) { datePart = japaneseMatcher.group(1); @@ -702,7 +623,7 @@ public class AmazonBookParser implements BookParser { } } - LocalDate localDate = parseReviewDate(datePart, localeInfo); + LocalDate localDate = parseDate(datePart, localeInfo); if (localDate != null) { dateInstant = localDate.atStartOfDay(ZoneOffset.UTC).toInstant(); } @@ -847,11 +768,9 @@ public class AmazonBookParser implements BookParser { } private static LocaleInfo getLocaleInfoForDomain(String domain) { - return DOMAIN_LOCALE_MAP.getOrDefault(domain, - new LocaleInfo("en-US,en;q=0.9", Locale.US)); + return DOMAIN_LOCALE_MAP.getOrDefault(domain, DEFAULT_LOCALE_INFO); } - private static LocalDate parseDate(String dateString, LocaleInfo localeInfo) { if (dateString == null || dateString.trim().isEmpty()) { return null; @@ -859,51 +778,29 @@ public class AmazonBookParser implements BookParser { String trimmedDate = dateString.trim(); - String[] patterns = { - "MMMM d, yyyy", - "d MMMM yyyy", - "d. MMMM yyyy", - "MMM d, yyyy", - "MMM. d, yyyy", - "d MMM yyyy", - "d MMM. yyyy", - "d. MMM yyyy", - // Japanese date patterns - "yyyy/M/d", - "yyyy/MM/dd", - "yyyy年M月d日" - }; - - for (String pattern : patterns) { + for (String pattern : DATE_PATTERNS) { try { return LocalDate.parse(trimmedDate, DateTimeFormatter.ofPattern(pattern, Locale.ENGLISH)); - } catch (DateTimeParseException e) { - log.debug("Date '{}' did not match pattern '{}' for locale ENGLISH", trimmedDate, pattern); + } catch (DateTimeParseException ignored) { } } - if (!"en".equals(localeInfo.locale.getLanguage())) { - for (String pattern : patterns) { + if (!"en".equals(localeInfo.locale().getLanguage())) { + for (String pattern : DATE_PATTERNS) { try { - return LocalDate.parse(trimmedDate, DateTimeFormatter.ofPattern(pattern, localeInfo.locale)); - } catch (DateTimeParseException e) { - log.debug("Date '{}' did not match pattern '{}' for locale {}", trimmedDate, pattern, localeInfo.locale); + return LocalDate.parse(trimmedDate, DateTimeFormatter.ofPattern(pattern, localeInfo.locale())); + } catch (DateTimeParseException ignored) { } } } - log.warn("Failed to parse date '{}' with any known format for locale {}", dateString, localeInfo.locale); + log.warn("Failed to parse date '{}' with any known format for locale {}", dateString, localeInfo.locale()); return null; } - private LocalDate parseAmazonDate(String dateString) { + private LocalDate parseDate(String dateString) { String domain = appSettingService.getAppSettings().getMetadataProviderSettings().getAmazon().getDomain(); - LocaleInfo localeInfo = getLocaleInfoForDomain(domain); - return parseDate(dateString, localeInfo); - } - - private static LocalDate parseReviewDate(String dateString, LocaleInfo localeInfo) { - return parseDate(dateString, localeInfo); + return parseDate(dateString, getLocaleInfoForDomain(domain)); } private String cleanDescriptionHtml(String html) { @@ -932,11 +829,42 @@ public class AmazonBookParser implements BookParser { document.select("p").stream() .filter(p -> p.text().trim().isEmpty()) .forEach(Element::remove); - return document.body().html(); + + // Remove excessive line breaks (more than 2 consecutive
tags) + Elements brTags = document.select("br"); + for (int i = 0; i < brTags.size(); i++) { + Element br = brTags.get(i); + int consecutiveBrCount = 1; + Element next = br.nextElementSibling(); + + // Count consecutive
tags + while (next != null && "br".equals(next.tagName())) { + consecutiveBrCount++; + Element temp = next; + next = next.nextElementSibling(); + + // Remove extra
tags beyond the first two + if (consecutiveBrCount > 2) { + temp.remove(); + } + } + } + + // Clean up any remaining whitespace issues + String cleanedHtml = document.body().html(); + + // Replace multiple consecutive
patterns that might still exist + cleanedHtml = cleanedHtml.replaceAll("(
\\s*){3,}", "

"); + cleanedHtml = cleanedHtml.replaceAll("(\\s*){3,}", "

"); + + // Remove leading/trailing
tags + cleanedHtml = cleanedHtml.replaceAll("^(\\s*\\s*)+", ""); + cleanedHtml = cleanedHtml.replaceAll("(\\s*\\s*)+$", ""); + + return cleanedHtml; } catch (Exception e) { log.warn("Error cleaning html description, Error: {}", e.getMessage()); } return html; } } - diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/service/metadata/parser/GoodReadsParser.java b/booklore-api/src/main/java/com/adityachandel/booklore/service/metadata/parser/GoodReadsParser.java index 13d2ee5e0..e370c4c48 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/service/metadata/parser/GoodReadsParser.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/service/metadata/parser/GoodReadsParser.java @@ -27,6 +27,7 @@ import java.time.Instant; import java.time.LocalDate; import java.time.ZoneId; import java.util.*; +import java.util.function.Function; import java.util.regex.Matcher; import java.util.regex.Pattern; @@ -40,14 +41,14 @@ public class GoodReadsParser implements BookParser { private static final String BASE_ISBN_URL = "https://www.goodreads.com/book/isbn/"; private static final int COUNT_DETAILED_METADATA_TO_GET = 3; private static final Pattern WHITESPACE_PATTERN = Pattern.compile("\\s+"); - // Pattern to extract numeric Goodreads id from book URL like /book/show/12345 private static final Pattern BOOK_SHOW_ID_PATTERN = Pattern.compile("/book/show/(\\d+)"); + private final AppSettingService appSettingService; + private record TitleInfo(String title, String subtitle) {} + @Override public BookMetadata fetchTopMetadata(Book book, FetchMetadataRequest fetchMetadataRequest) { - // If book already has a Goodreads ID, use it directly instead of searching - // This ensures we fetch ratings for previously matched books String existingGoodreadsId = getExistingGoodreadsId(book); if (existingGoodreadsId != null) { log.info("GoodReads: Using existing Goodreads ID: {}", existingGoodreadsId); @@ -63,7 +64,6 @@ public class GoodReadsParser implements BookParser { } } - // Fall back to search-based approach Optional preview = fetchMetadataPreviews(book, fetchMetadataRequest).stream().findFirst(); if (preview.isEmpty()) { return null; @@ -72,10 +72,6 @@ public class GoodReadsParser implements BookParser { return fetchedMetadata.isEmpty() ? null : fetchedMetadata.getFirst(); } - /** - * Extracts existing Goodreads ID from book metadata if available. - * Returns null if no valid ID exists. - */ private String getExistingGoodreadsId(Book book) { if (book == null || book.getMetadata() == null) { return null; @@ -84,12 +80,10 @@ public class GoodReadsParser implements BookParser { if (goodreadsId == null || goodreadsId.isBlank()) { return null; } - // Validate it looks like a Goodreads ID (numeric, possibly with suffix like "11590-salems-lot") - // Strip any non-numeric suffix for the URL String numericId = goodreadsId.split("-")[0].split("\\.")[0]; try { Long.parseLong(numericId); - return goodreadsId; // Return original ID (Goodreads handles both formats) + return goodreadsId; } catch (NumberFormatException e) { log.debug("GoodReads: Invalid Goodreads ID format: {}", goodreadsId); return null; @@ -177,11 +171,9 @@ public class GoodReadsParser implements BookParser { private void extractContributorDetails(JSONObject apolloStateJson, LinkedHashSet keySet, BookMetadata.BookMetadataBuilder builder) { String contributorKey = findKeyByPrefix(keySet, "Contributor:kca"); - if (contributorKey != null) { - String contributorName = getContributorName(apolloStateJson, contributorKey); - if (contributorName != null) { - builder.authors(Set.of(contributorName)); - } + String contributorName = getJsonStringField(apolloStateJson, contributorKey, "name"); + if (contributorName != null) { + builder.authors(Set.of(contributorName)); } } @@ -258,107 +250,91 @@ public class GoodReadsParser implements BookParser { private void extractSeriesDetails(JSONObject apolloStateJson, LinkedHashSet keySet, BookMetadata.BookMetadataBuilder builder) { String seriesKey = findKeyByPrefix(keySet, "Series:kca"); - if (seriesKey != null) { - String seriesName = getSeriesName(apolloStateJson, seriesKey); - if (seriesName != null) { - builder.seriesName(seriesName); - } + String seriesName = getJsonStringField(apolloStateJson, seriesKey, "title"); + if (seriesName != null) { + builder.seriesName(seriesName); } } private void extractBookDetails(JSONObject apolloStateJson, LinkedHashSet keySet, BookMetadata.BookMetadataBuilder builder) { - JSONObject bookJson = getValidBookJson(apolloStateJson, keySet, "Book:kca:"); - if (bookJson != null) { - builder.title(handleStringNull(extractTitleFromFull(bookJson.optString("title")))) - .subtitle(handleStringNull(extractSubtitleFromFull(bookJson.optString("title")))) - .description(handleStringNull(bookJson.optString("description"))) - .thumbnailUrl(handleStringNull(bookJson.optString("imageUrl"))) - .categories(extractGenres(bookJson)); + JSONObject bookJson = getValidBookJson(apolloStateJson, keySet); + if (bookJson == null) { + return; + } - JSONObject detailsJson = bookJson.optJSONObject("details"); - if (detailsJson != null) { - builder.pageCount(parseInteger(detailsJson.optString("numPages"))) - .publishedDate(convertToLocalDate(detailsJson.optString("publicationTime"))) - .publisher(handleStringNull(detailsJson.optString("publisher"))) - .isbn10(handleStringNull(detailsJson.optString("isbn"))) - .isbn13(handleStringNull(detailsJson.optString("isbn13"))); + TitleInfo titleInfo = parseTitleInfo(bookJson.optString("title")); + builder.title(titleInfo.title()) + .subtitle(titleInfo.subtitle()) + .description(normalizeNull(bookJson.optString("description"))) + .thumbnailUrl(normalizeNull(bookJson.optString("imageUrl"))) + .categories(extractGenres(bookJson)); - JSONObject languageJson = detailsJson.optJSONObject("language"); - if (languageJson != null) { - builder.language(handleStringNull(languageJson.optString("name"))); - } + JSONObject detailsJson = bookJson.optJSONObject("details"); + if (detailsJson != null) { + builder.pageCount(parseNumber(detailsJson.optString("numPages"), Integer::parseInt)) + .publishedDate(convertToLocalDate(detailsJson.optString("publicationTime"))) + .publisher(normalizeNull(detailsJson.optString("publisher"))) + .isbn10(normalizeNull(detailsJson.optString("isbn"))) + .isbn13(normalizeNull(detailsJson.optString("isbn13"))); + + JSONObject languageJson = detailsJson.optJSONObject("language"); + if (languageJson != null) { + builder.language(normalizeNull(languageJson.optString("name"))); } + } - JSONArray bookSeriesJson = bookJson.optJSONArray("bookSeries"); - if (bookSeriesJson != null && bookSeriesJson.length() > 0) { - JSONObject firstElement = bookSeriesJson.optJSONObject(0); - if (firstElement != null) { - builder.seriesNumber(parseFloat(firstElement.optString("userPosition"))); - } + JSONArray bookSeriesJson = bookJson.optJSONArray("bookSeries"); + if (bookSeriesJson != null && bookSeriesJson.length() > 0) { + JSONObject firstElement = bookSeriesJson.optJSONObject(0); + if (firstElement != null) { + builder.seriesNumber(parseNumber(firstElement.optString("userPosition"), Float::parseFloat)); } } } private void extractWorkDetails(JSONObject apolloStateJson, LinkedHashSet keySet, BookMetadata.BookMetadataBuilder builder) { String workKey = findKeyByPrefix(keySet, "Work:kca:"); - if (workKey != null) { - JSONObject workJson = apolloStateJson.optJSONObject(workKey); - if (workJson != null) { - JSONObject statsJson = workJson.optJSONObject("stats"); - if (statsJson != null) { - builder.goodreadsRating(parseDouble(statsJson.optString("averageRating"))) - .goodreadsReviewCount(parseInteger(statsJson.optString("ratingsCount"))); - } - } + if (workKey == null) { + return; + } + JSONObject workJson = apolloStateJson.optJSONObject(workKey); + if (workJson == null) { + return; + } + JSONObject statsJson = workJson.optJSONObject("stats"); + if (statsJson != null) { + builder.goodreadsRating(parseNumber(statsJson.optString("averageRating"), Double::parseDouble)) + .goodreadsReviewCount(parseNumber(statsJson.optString("ratingsCount"), Integer::parseInt)); } } - private String extractTitleFromFull(String fullTitle) { - if (fullTitle == null) return null; + private TitleInfo parseTitleInfo(String fullTitle) { + if (fullTitle == null || "null".equals(fullTitle)) { + return new TitleInfo(null, null); + } String[] parts = fullTitle.split(":", 2); - return parts[0].trim(); + String title = parts[0].trim(); + String subtitle = parts.length > 1 ? parts[1].trim() : null; + return new TitleInfo(title.isEmpty() ? null : title, subtitle); } - private String extractSubtitleFromFull(String fullTitle) { - if (fullTitle == null) return null; - String[] parts = fullTitle.split(":", 2); - return parts.length > 1 ? parts[1].trim() : null; - } - - private Double parseDouble(String value) { + private T parseNumber(String value, Function parser) { + if (value == null || value.isEmpty() || "null".equals(value)) { + return null; + } try { - return value != null ? Double.parseDouble(value) : null; + return parser.apply(value); } catch (NumberFormatException e) { - log.error("Error parsing double: {}, Error: {}", value, e.getMessage()); + log.warn("Error parsing number: {}", value); return null; } } - private Float parseFloat(String value) { - try { - return value != null ? Float.parseFloat(value) : null; - } catch (NumberFormatException e) { - log.error("Error parsing double: {}, Error: {}", value, e.getMessage()); - return null; - } - } - - private Integer parseInteger(String value) { - try { - return value != null ? Integer.parseInt(value) : null; - } catch (NumberFormatException e) { - log.error("Error parsing integer: {}, Error: {}", value, e.getMessage()); - return null; - } - } - - private String handleStringNull(String s) { - if (s != null && "null".equals(s)) { - return null; - } - return s; + private String normalizeNull(String s) { + return "null".equals(s) || (s != null && s.isEmpty()) ? null : s; } + @SuppressWarnings("unchecked") private LinkedHashSet getJsonKeys(JSONObject apolloStateJson) { LinkedHashSet keySet = new LinkedHashSet<>(); Iterator keys = apolloStateJson.keys(); @@ -368,54 +344,40 @@ public class GoodReadsParser implements BookParser { return keySet; } - private JSONObject getValidBookJson(JSONObject apolloStateJson, LinkedHashSet keySet, String prefix) { + private JSONObject getValidBookJson(JSONObject apolloStateJson, LinkedHashSet keySet) { try { for (String key : keySet) { - if (key.contains(prefix)) { + if (key.contains("Book:kca:")) { JSONObject bookJson = apolloStateJson.getJSONObject(key); - String string = bookJson.optString("title"); - if (string != null && !string.isEmpty()) { + String title = bookJson.optString("title"); + if (title != null && !title.isEmpty()) { return bookJson; } } } } catch (Exception e) { - log.error("Error finding getValidBookJson: {}", e.getMessage()); + log.error("Error finding valid book JSON: {}", e.getMessage()); } return null; } private String findKeyByPrefix(LinkedHashSet keySet, String prefix) { - for (String key : keySet) { - if (key.contains(prefix)) { - return key; - } - } - return null; + return keySet.stream() + .filter(key -> key.contains(prefix)) + .findFirst() + .orElse(null); } - private String getSeriesName(JSONObject apolloStateJson, String seriesKey) { - try { - if (seriesKey != null) { - JSONObject seriesJson = apolloStateJson.getJSONObject(seriesKey); - return seriesJson.getString("title"); - } - } catch (Exception e) { - log.warn("Error fetching series name: {}, Error: {}", seriesKey, e.getMessage()); + private String getJsonStringField(JSONObject apolloStateJson, String key, String fieldName) { + if (key == null) { + return null; } - return null; - } - - private String getContributorName(JSONObject apolloStateJson, String contributorKey) { try { - if (contributorKey != null) { - JSONObject contributorJson = apolloStateJson.getJSONObject(contributorKey); - return contributorJson.getString("name"); - } + return apolloStateJson.getJSONObject(key).getString(fieldName); } catch (Exception e) { - log.error("Error fetching contributor name: {}, Error: {}", contributorKey, e.getMessage()); + log.warn("Error fetching {} from {}: {}", fieldName, key, e.getMessage()); + return null; } - return null; } private Set extractGenres(JSONObject bookJson) { @@ -495,7 +457,6 @@ public class GoodReadsParser implements BookParser { for (Element previewBook : previewBooks) { Set authors = extractAuthorsPreview(previewBook); - // Author fuzzy match if author provided if (queryAuthor != null && !queryAuthor.isBlank()) { List queryAuthorTokens = List.of(WHITESPACE_PATTERN.split(queryAuthor.toLowerCase())); boolean matches = authors.stream() diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/service/metadata/parser/GoogleParser.java b/booklore-api/src/main/java/com/adityachandel/booklore/service/metadata/parser/GoogleParser.java index 01e2a2719..d3898619d 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/service/metadata/parser/GoogleParser.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/service/metadata/parser/GoogleParser.java @@ -22,11 +22,13 @@ import java.time.LocalDate; import java.time.format.DateTimeFormatter; import java.util.List; import java.util.Map; +import java.util.Objects; import java.util.Optional; import java.util.Set; import java.util.concurrent.atomic.AtomicLong; import java.util.regex.Pattern; import java.util.stream.Collectors; +import java.util.stream.Stream; @Slf4j @Service @@ -37,6 +39,8 @@ public class GoogleParser implements BookParser { private static final Pattern WHITESPACE_PATTERN = Pattern.compile("\\s+"); private static final Pattern SPECIAL_CHARACTERS_PATTERN = Pattern.compile("[.,\\-\\[\\]{}()!@#$%^&*_=+|~`<>?/\";:]"); private static final long MIN_REQUEST_INTERVAL_MS = 1500; + private static final int MAX_SEARCH_TERM_LENGTH = 60; + private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd"); private final ObjectMapper objectMapper; private final AppSettingService appSettingService; private final HttpClient httpClient = HttpClient.newHttpClient(); @@ -59,42 +63,19 @@ public class GoogleParser implements BookParser { } private List getMetadataListByIsbn(String isbn) { - try { - waitForRateLimit(); - - URI uri = UriComponentsBuilder.fromUriString(getApiUrl()) - .queryParam("q", "isbn:" + isbn.replace("-", "")) - .build() - .toUri(); - - log.info("Google Books API URL (ISBN): {}", uri); - - HttpRequest request = HttpRequest.newBuilder() - .uri(uri) - .GET() - .build(); - - HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()); - - if (response.statusCode() == 200) { - return parseGoogleBooksApiResponse(response.body()); - } else { - log.error("Failed to fetch metadata from Google Books API with ISBN. Status: {}, Response: {}", - response.statusCode(), response.body()); - return List.of(); - } - } catch (IOException | InterruptedException e) { - log.error("Error occurred while fetching metadata from Google Books API with ISBN", e); - return List.of(); - } + return fetchFromApi("isbn:" + isbn.replace("-", "")); } public List getMetadataListByTerm(String term) { + return fetchFromApi(term); + } + + private List fetchFromApi(String query) { try { waitForRateLimit(); - + URI uri = UriComponentsBuilder.fromUriString(getApiUrl()) - .queryParam("q", term) + .queryParam("q", query) .build() .toUri(); @@ -109,10 +90,11 @@ public class GoogleParser implements BookParser { if (response.statusCode() == 200) { return parseGoogleBooksApiResponse(response.body()); - } else { - log.error("Failed to fetch metadata from Google Books API. Status: {}, Response: {}", response.statusCode(), response.body()); - return List.of(); } + + log.error("Failed to fetch metadata from Google Books API. Status: {}, Response: {}", + response.statusCode(), response.body()); + return List.of(); } catch (IOException | InterruptedException e) { log.error("Error occurred while fetching metadata from Google Books API", e); return List.of(); @@ -133,17 +115,6 @@ public class GoogleParser implements BookParser { GoogleBooksApiResponse.Item.VolumeInfo volumeInfo = item.getVolumeInfo(); Map isbns = extractISBNs(volumeInfo.getIndustryIdentifiers()); - String highResCover = Optional.ofNullable(volumeInfo.getImageLinks()) - .map(links -> { - if (links.getExtraLarge() != null) return links.getExtraLarge(); - if (links.getLarge() != null) return links.getLarge(); - if (links.getMedium() != null) return links.getMedium(); - if (links.getSmall() != null) return links.getSmall(); - if (links.getThumbnail() != null) return links.getThumbnail(); - return links.getSmallThumbnail(); - }) - .orElse(null); - return BookMetadata.builder() .provider(MetadataProvider.Google) .googleId(item.getId()) @@ -157,11 +128,27 @@ public class GoogleParser implements BookParser { .isbn13(isbns.get("ISBN_13")) .isbn10(isbns.get("ISBN_10")) .pageCount(volumeInfo.getPageCount()) - .thumbnailUrl(highResCover) + .thumbnailUrl(extractBestCoverImage(volumeInfo.getImageLinks())) .language(volumeInfo.getLanguage()) .build(); } + private String extractBestCoverImage(GoogleBooksApiResponse.Item.ImageLinks links) { + if (links == null) { + return null; + } + return Stream.of( + links.getExtraLarge(), + links.getLarge(), + links.getMedium(), + links.getSmall(), + links.getThumbnail(), + links.getSmallThumbnail()) + .filter(Objects::nonNull) + .findFirst() + .orElse(null); + } + private Map extractISBNs(List identifiers) { if (identifiers == null) return Map.of(); @@ -182,38 +169,46 @@ public class GoogleParser implements BookParser { .map(BookUtils::cleanFileName) .orElse(null)); - if (searchTerm != null) { - searchTerm = SPECIAL_CHARACTERS_PATTERN.matcher(searchTerm).replaceAll("").trim(); - searchTerm = "intitle:" + truncateToMaxLength(searchTerm, 60); + if (searchTerm == null) { + return null; } - if (searchTerm != null && request.getAuthor() != null && !request.getAuthor().isEmpty()) { + searchTerm = SPECIAL_CHARACTERS_PATTERN.matcher(searchTerm).replaceAll("").trim(); + searchTerm = "intitle:" + truncateToMaxWords(searchTerm); + + if (request.getAuthor() != null && !request.getAuthor().isEmpty()) { searchTerm += " inauthor:" + request.getAuthor(); } return searchTerm; } - private String truncateToMaxLength(String input, int maxLength) { + private String truncateToMaxWords(String input) { String[] words = WHITESPACE_PATTERN.split(input); StringBuilder truncated = new StringBuilder(); for (String word : words) { - if (truncated.length() + word.length() + 1 > maxLength) break; - if (!truncated.isEmpty()) truncated.append(" "); + if (truncated.length() + word.length() + 1 > MAX_SEARCH_TERM_LENGTH) { + break; + } + if (!truncated.isEmpty()) { + truncated.append(" "); + } truncated.append(word); } return truncated.toString(); } - public LocalDate parseDate(String input) { + private LocalDate parseDate(String input) { + if (input == null || input.isEmpty()) { + return null; + } try { if (FOUR_DIGIT_YEAR_PATTERN.matcher(input).matches()) { return LocalDate.of(Integer.parseInt(input), 1, 1); } - DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd"); - return LocalDate.parse(input, formatter); + return LocalDate.parse(input, DATE_FORMATTER); } catch (Exception e) { return null; } diff --git a/booklore-ui/src/app/features/bookdrop/component/bookdrop-file-metadata-picker/bookdrop-file-metadata-picker.component.ts b/booklore-ui/src/app/features/bookdrop/component/bookdrop-file-metadata-picker/bookdrop-file-metadata-picker.component.ts index 55a27def5..c210c49cc 100644 --- a/booklore-ui/src/app/features/bookdrop/component/bookdrop-file-metadata-picker/bookdrop-file-metadata-picker.component.ts +++ b/booklore-ui/src/app/features/bookdrop/component/bookdrop-file-metadata-picker/bookdrop-file-metadata-picker.component.ts @@ -12,6 +12,8 @@ import {Image} from 'primeng/image'; import {LazyLoadImageModule} from 'ng-lazyload-image'; import {ConfirmationService} from 'primeng/api'; import {DatePicker} from 'primeng/datepicker'; +import {ALL_METADATA_FIELDS, getArrayFields, getBottomFields, getTextareaFields, MetadataFieldConfig} from '../../../../shared/metadata'; +import {MetadataUtilsService} from '../../../../shared/metadata'; @Component({ selector: 'app-bookdrop-file-metadata-picker-component', @@ -34,6 +36,8 @@ import {DatePicker} from 'primeng/datepicker'; export class BookdropFileMetadataPickerComponent { private readonly confirmationService = inject(ConfirmationService); + private readonly metadataUtils = inject(MetadataUtilsService); + protected readonly urlHelper = inject(UrlHelperService); @Input() fetchedMetadata!: BookMetadata; @Input() originalMetadata?: BookMetadata; @@ -44,107 +48,52 @@ export class BookdropFileMetadataPickerComponent { @Output() metadataCopied = new EventEmitter(); + // Use shared field configuration - separate publishedDate for DatePicker + metadataFieldsTop: MetadataFieldConfig[] = ALL_METADATA_FIELDS.filter(f => + ['title', 'subtitle', 'publisher'].includes(f.controlName) + ); - metadataFieldsTop = [ - {label: 'Title', controlName: 'title', fetchedKey: 'title'}, - {label: 'Subtitle', controlName: 'subtitle', fetchedKey: 'subtitle'}, - {label: 'Publisher', controlName: 'publisher', fetchedKey: 'publisher'}, - ]; + metadataPublishDate: MetadataFieldConfig[] = ALL_METADATA_FIELDS.filter(f => + f.controlName === 'publishedDate' + ); - metadataPublishDate = [ - {label: 'Publish Date', controlName: 'publishedDate', fetchedKey: 'publishedDate'} - ]; + metadataChips: MetadataFieldConfig[] = getArrayFields(); - metadataChips = [ - {label: 'Authors', controlName: 'authors', lockedKey: 'authorsLocked', fetchedKey: 'authors'}, - {label: 'Genres', controlName: 'categories', lockedKey: 'categoriesLocked', fetchedKey: 'categories'}, - {label: 'Moods', controlName: 'moods', lockedKey: 'moodsLocked', fetchedKey: 'moods'}, - {label: 'Tags', controlName: 'tags', lockedKey: 'tagsLocked', fetchedKey: 'tags'}, - ]; + metadataDescription: MetadataFieldConfig[] = getTextareaFields(); - metadataDescription = [ - {label: 'Description', controlName: 'description', lockedKey: 'descriptionLocked', fetchedKey: 'description'}, - ]; - - metadataFieldsBottom = [ - {label: 'Series Name', controlName: 'seriesName', lockedKey: 'seriesNameLocked', fetchedKey: 'seriesName'}, - {label: 'Series #', controlName: 'seriesNumber', lockedKey: 'seriesNumberLocked', fetchedKey: 'seriesNumber'}, - {label: 'Series Total', controlName: 'seriesTotal', lockedKey: 'seriesTotalLocked', fetchedKey: 'seriesTotal'}, - {label: 'Language', controlName: 'language', lockedKey: 'languageLocked', fetchedKey: 'language'}, - {label: 'ISBN-10', controlName: 'isbn10', lockedKey: 'isbn10Locked', fetchedKey: 'isbn10'}, - {label: 'ISBN-13', controlName: 'isbn13', lockedKey: 'isbn13Locked', fetchedKey: 'isbn13'}, - {label: 'Amazon ASIN', controlName: 'asin', lockedKey: 'asinLocked', fetchedKey: 'asin'}, - {label: 'Amazon #', controlName: 'amazonReviewCount', lockedKey: 'amazonReviewCountLocked', fetchedKey: 'amazonReviewCount'}, - {label: 'Amazon ★', controlName: 'amazonRating', lockedKey: 'amazonRatingLocked', fetchedKey: 'amazonRating'}, - {label: 'Goodreads ID', controlName: 'goodreadsId', lockedKey: 'goodreadsIdLocked', fetchedKey: 'goodreadsId'}, - {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'}, - {label: 'Comicvine ID', controlName: 'comicvineId', lockedKey: 'comicvineIdLocked', fetchedKey: 'comicvineId'}, - {label: 'Ranobedb ID', controlName: 'ranobedbId', lockedKey: 'ranobedbIdLocked', fetchedKey: 'ranobedbId'}, - {label: 'Ranobedb ★', controlName: 'ranobedbRating', lockedKey: 'ranobedbRatingLocked', fetchedKey: 'ranobedbRating'}, - {label: 'Pages', controlName: 'pageCount', lockedKey: 'pageCountLocked', fetchedKey: 'pageCount'} - ]; - - protected urlHelper = inject(UrlHelperService); + metadataFieldsBottom: MetadataFieldConfig[] = getBottomFields(); copyMissing(): void { - Object.keys(this.fetchedMetadata).forEach((field) => { - const isLocked = this.metadataForm.get(`${field}Locked`)?.value; - const currentValue = this.metadataForm.get(field)?.value; - const fetchedValue = this.fetchedMetadata[field]; - - const isEmpty = Array.isArray(currentValue) - ? currentValue.length === 0 - : !currentValue; - - if (!isLocked && isEmpty && fetchedValue) { - this.copyFetchedToCurrent(field); - } - }); + this.metadataUtils.copyMissingFields( + this.fetchedMetadata, + this.metadataForm, + this.copiedFields, + (field) => this.copyFetchedToCurrent(field) + ); } copyAll(includeCover: boolean = true): void { - if (this.fetchedMetadata) { - Object.keys(this.fetchedMetadata).forEach((field) => { - if (this.fetchedMetadata[field] && (includeCover || field !== 'thumbnailUrl')) { - this.copyFetchedToCurrent(field); - } - }); - } + const excludeFields = includeCover ? ['bookId'] : ['thumbnailUrl', 'bookId']; + this.metadataUtils.copyAllFields( + this.fetchedMetadata, + this.metadataForm, + (field) => this.copyFetchedToCurrent(field), + excludeFields + ); } copyFetchedToCurrent(field: string): void { - const value = this.fetchedMetadata[field]; - if (value) { - this.metadataForm.get(field)?.setValue(value); - this.copiedFields[field] = true; + if (this.metadataUtils.copyFieldToForm(field, this.fetchedMetadata, this.metadataForm, this.copiedFields)) { this.metadataCopied.emit(true); } } isValueChanged(field: string): boolean { - const [value, original] = this.prepFieldComparison(this.metadataForm.get(field)?.value, this.originalMetadata?.[field]); - return (value && value != original) || (!value && original); + return this.metadataUtils.isValueChanged(field, this.metadataForm, this.originalMetadata); } isFetchedDifferent(field: string): boolean { - const [value, fetched] = this.prepFieldComparison(this.metadataForm.get(field)?.value, this.fetchedMetadata[field]); - return (fetched && fetched != value); - } - - private prepFieldComparison(field1: any, field2: any) { - if (Array.isArray(field1)) { - field1 = field1.length > 0 ? JSON.stringify(field1.sort()) : undefined; - } - if (Array.isArray(field2)) { - field2 = field2.length > 0 ? JSON.stringify(field2.sort()) : undefined; - } - return [field1, field2]; + return this.metadataUtils.isFetchedDifferent(field, this.metadataForm, this.fetchedMetadata); } isValueCopied(field: string): boolean { @@ -155,9 +104,8 @@ export class BookdropFileMetadataPickerComponent { return this.savedFields[field]; } - resetField(field: string) { - this.metadataForm.get(field)?.setValue(this.originalMetadata?.[field]); - this.copiedFields[field] = false; + resetField(field: string): void { + this.metadataUtils.resetField(field, this.metadataForm, this.originalMetadata, this.copiedFields); if (field === 'thumbnailUrl') { this.metadataForm.get('thumbnailUrl')?.setValue(this.urlHelper.getBookdropCoverUrl(this.bookdropFileId)); } @@ -169,7 +117,7 @@ export class BookdropFileMetadataPickerComponent { if (inputValue) { const currentValue = this.metadataForm.get(fieldName)?.value || []; const values = Array.isArray(currentValue) ? currentValue : - typeof currentValue === 'string' && currentValue ? currentValue.split(',').map((v: string) => v.trim()) : [] + typeof currentValue === 'string' && currentValue ? currentValue.split(',').map((v: string) => v.trim()) : []; if (!values.includes(inputValue)) { values.push(inputValue); this.metadataForm.get(fieldName)?.setValue(values); @@ -190,41 +138,24 @@ export class BookdropFileMetadataPickerComponent { }); } - resetAll() { + resetAll(): void { if (this.originalMetadata) { - this.metadataForm.patchValue({ - title: this.originalMetadata.title || null, - subtitle: this.originalMetadata.subtitle || null, - authors: [...(this.originalMetadata.authors ?? [])].sort(), - categories: [...(this.originalMetadata.categories ?? [])].sort(), - moods: [...(this.originalMetadata.moods ?? [])].sort(), - tags: [...(this.originalMetadata.tags ?? [])].sort(), - publisher: this.originalMetadata.publisher || null, - publishedDate: this.originalMetadata.publishedDate || null, - isbn10: this.originalMetadata.isbn10 || null, - isbn13: this.originalMetadata.isbn13 || null, - description: this.originalMetadata.description || null, - pageCount: this.originalMetadata.pageCount || null, - language: this.originalMetadata.language || null, - asin: this.originalMetadata.asin || null, - amazonRating: this.originalMetadata.amazonRating || null, - amazonReviewCount: this.originalMetadata.amazonReviewCount || null, - goodreadsId: this.originalMetadata.goodreadsId || null, - 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, - comicvineId: this.originalMetadata.comicvineId || null, - ranobedbId: this.originalMetadata.ranobedbId || null, - ranobedbRating: this.originalMetadata.ranobedbRating || null, - seriesName: this.originalMetadata.seriesName || null, - seriesNumber: this.originalMetadata.seriesNumber || null, - seriesTotal: this.originalMetadata.seriesTotal || null, - thumbnailUrl: this.urlHelper.getBookdropCoverUrl(this.bookdropFileId), - }); + const patchData: Record = {}; + + for (const field of ALL_METADATA_FIELDS) { + const key = field.controlName as keyof BookMetadata; + const value = this.originalMetadata[key]; + + if (field.type === 'array') { + patchData[field.controlName] = [...(value as string[] ?? [])].sort(); + } else { + patchData[field.controlName] = value ?? null; + } + } + + patchData['thumbnailUrl'] = this.urlHelper.getBookdropCoverUrl(this.bookdropFileId); + + this.metadataForm.patchValue(patchData); } this.copiedFields = {}; this.metadataCopied.emit(false); diff --git a/booklore-ui/src/app/features/metadata/component/book-metadata-center/metadata-picker/metadata-picker.component.ts b/booklore-ui/src/app/features/metadata/component/book-metadata-center/metadata-picker/metadata-picker.component.ts index 9743be8b2..f0c8d7e5b 100644 --- a/booklore-ui/src/app/features/metadata/component/book-metadata-center/metadata-picker/metadata-picker.component.ts +++ b/booklore-ui/src/app/features/metadata/component/book-metadata-center/metadata-picker/metadata-picker.component.ts @@ -2,7 +2,7 @@ import {Component, DestroyRef, EventEmitter, inject, Input, OnInit, Output} from import {Book, BookMetadata, MetadataClearFlags, MetadataUpdateWrapper} from '../../../../book/model/book.model'; import {MessageService} from 'primeng/api'; import {Button} from 'primeng/button'; -import {FormControl, FormGroup, FormsModule, ReactiveFormsModule} from '@angular/forms'; +import {FormGroup, FormsModule, ReactiveFormsModule} from '@angular/forms'; import {InputText} from 'primeng/inputtext'; import {AsyncPipe, NgClass} from '@angular/common'; import {Divider} from 'primeng/divider'; @@ -18,6 +18,7 @@ import {Image} from 'primeng/image'; import {LazyLoadImageModule} from 'ng-lazyload-image'; import {AppSettingsService} from '../../../../../shared/service/app-settings.service'; import {MetadataProviderSpecificFields} from '../../../../../shared/model/app-settings.model'; +import {ALL_METADATA_FIELDS, getArrayFields, getBottomFields, getTextareaFields, getTopFields, MetadataFieldConfig, MetadataFormBuilder, MetadataUtilsService} from '../../../../../shared/metadata'; @Component({ selector: 'app-metadata-picker', @@ -41,170 +42,62 @@ import {MetadataProviderSpecificFields} from '../../../../../shared/model/app-se }) export class MetadataPickerComponent implements OnInit { - metadataFieldsTop = [ - {label: 'Title', controlName: 'title', lockedKey: 'titleLocked', fetchedKey: 'title'}, - {label: 'Subtitle', controlName: 'subtitle', lockedKey: 'subtitleLocked', fetchedKey: 'subtitle'}, - {label: 'Publisher', controlName: 'publisher', lockedKey: 'publisherLocked', fetchedKey: 'publisher'}, - {label: 'Published', controlName: 'publishedDate', lockedKey: 'publishedDateLocked', fetchedKey: 'publishedDate'} - ]; - - metadataChips = [ - {label: 'Authors', controlName: 'authors', lockedKey: 'authorsLocked', fetchedKey: 'authors'}, - {label: 'Genres', controlName: 'categories', lockedKey: 'categoriesLocked', fetchedKey: 'categories'}, - {label: 'Moods', controlName: 'moods', lockedKey: 'moodsLocked', fetchedKey: 'moods'}, - {label: 'Tags', controlName: 'tags', lockedKey: 'tagsLocked', fetchedKey: 'tags'}, - ]; - - metadataDescription = [ - {label: 'Description', controlName: 'description', lockedKey: 'descriptionLocked', fetchedKey: 'description'}, - ]; - - metadataFieldsBottom = [ - {label: 'Series', controlName: 'seriesName', lockedKey: 'seriesNameLocked', fetchedKey: 'seriesName'}, - {label: 'Book #', controlName: 'seriesNumber', lockedKey: 'seriesNumberLocked', fetchedKey: 'seriesNumber'}, - {label: 'Total Books', controlName: 'seriesTotal', lockedKey: 'seriesTotalLocked', fetchedKey: 'seriesTotal'}, - {label: 'Language', controlName: 'language', lockedKey: 'languageLocked', fetchedKey: 'language'}, - {label: 'ISBN-10', controlName: 'isbn10', lockedKey: 'isbn10Locked', fetchedKey: 'isbn10'}, - {label: 'ISBN-13', controlName: 'isbn13', lockedKey: 'isbn13Locked', fetchedKey: 'isbn13'}, - {label: 'Pages', controlName: 'pageCount', lockedKey: 'pageCountLocked', fetchedKey: 'pageCount'}, - {label: 'Google ID', controlName: 'googleId', lockedKey: 'googleIdLocked', fetchedKey: 'googleId'}, - {label: 'ASIN', controlName: 'asin', lockedKey: 'asinLocked', fetchedKey: 'asin'}, - {label: 'Amazon #', controlName: 'amazonReviewCount', lockedKey: 'amazonReviewCountLocked', fetchedKey: 'amazonReviewCount'}, - {label: 'Amazon ★', controlName: 'amazonRating', lockedKey: 'amazonRatingLocked', fetchedKey: 'amazonRating'}, - {label: 'Goodreads ID', controlName: 'goodreadsId', lockedKey: 'goodreadsIdLocked', fetchedKey: 'goodreadsId'}, - {label: 'Goodreads ★', controlName: 'goodreadsReviewCount', lockedKey: 'goodreadsReviewCountLocked', fetchedKey: 'goodreadsReviewCount'}, - {label: 'Goodreads #', controlName: 'goodreadsRating', lockedKey: 'goodreadsRatingLocked', fetchedKey: 'goodreadsRating'}, - {label: 'HC Book ID', controlName: 'hardcoverBookId', lockedKey: 'hardcoverBookIdLocked', fetchedKey: 'hardcoverBookId'}, - {label: 'Hardcover ID', controlName: 'hardcoverId', lockedKey: 'hardcoverIdLocked', fetchedKey: 'hardcoverId'}, - {label: 'Hardcover #', controlName: 'hardcoverReviewCount', lockedKey: 'hardcoverReviewCountLocked', fetchedKey: 'hardcoverReviewCount'}, - {label: 'Hardcover ★', controlName: 'hardcoverRating', lockedKey: 'hardcoverRatingLocked', fetchedKey: 'hardcoverRating'}, - {label: 'Comicvine ID', controlName: 'comicvineId', lockedKey: 'comicvineIdLocked', fetchedKey: 'comicvineId'}, - {label: 'LB ID', controlName: 'lubimyczytacId', lockedKey: 'lubimyczytacIdLocked', fetchedKey: 'lubimyczytacId'}, - {label: 'LB ★', controlName: 'lubimyczytacRating', lockedKey: 'lubimyczytacRatingLocked', fetchedKey: 'lubimyczytacRating'}, - {label: 'Ranobedb ID', controlName: 'ranobedbId', lockedKey: 'ranobedbIdLocked', fetchedKey: 'ranobedbId'}, - {label: 'Ranobedb ★', controlName: 'ranobedbRating', lockedKey: 'ranobedbRatingLocked', fetchedKey: 'ranobedbRating'} - ]; + // Cached arrays for template binding (avoid getter re-computation) + metadataFieldsTop: MetadataFieldConfig[] = []; + metadataChips: MetadataFieldConfig[] = []; + metadataDescription: MetadataFieldConfig[] = []; + metadataFieldsBottom: MetadataFieldConfig[] = []; @Input() reviewMode!: boolean; @Input() fetchedMetadata!: BookMetadata; @Input() book$!: Observable; @Output() goBack = new EventEmitter(); - allAuthors!: string[]; - allCategories!: string[]; - allMoods!: string[]; - allTags!: string[]; - filteredCategories: string[] = []; - filteredAuthors: string[] = []; - filteredMoods: string[] = []; - filteredTags: string[] = []; + private allItems: Record = {}; + filteredItems: Record = {}; - getFiltered(controlName: string): string[] { - if (controlName === 'authors') return this.filteredAuthors; - if (controlName === 'categories') return this.filteredCategories; - if (controlName === 'moods') return this.filteredMoods; - if (controlName === 'tags') return this.filteredTags; - return []; - } - - filterItems(event: { query: string }, controlName: string) { - const query = event.query.toLowerCase(); - if (controlName === 'authors') { - this.filteredAuthors = this.allAuthors.filter(a => a.toLowerCase().includes(query)); - } else if (controlName === 'categories') { - this.filteredCategories = this.allCategories.filter(c => c.toLowerCase().includes(query)); - } else if (controlName === 'moods') { - this.filteredMoods = this.allMoods.filter(m => m.toLowerCase().includes(query)); - } else if (controlName === 'tags') { - this.filteredTags = this.allTags.filter(t => t.toLowerCase().includes(query)); - } - } - - metadataForm: FormGroup; + metadataForm!: FormGroup; currentBookId!: number; copiedFields: Record = {}; savedFields: Record = {}; originalMetadata!: BookMetadata; isSaving = false; + hoveredFields: Record = {}; private messageService = inject(MessageService); private bookService = inject(BookService); protected urlHelper = inject(UrlHelperService); private destroyRef = inject(DestroyRef); private appSettingsService = inject(AppSettingsService); + private formBuilder = inject(MetadataFormBuilder); + private metadataUtils = inject(MetadataUtilsService); private enabledProviderFields: MetadataProviderSpecificFields | null = null; constructor() { - this.metadataForm = new FormGroup({ - title: new FormControl(''), - subtitle: new FormControl(''), - authors: new FormControl(''), - categories: new FormControl(''), - moods: new FormControl(''), - tags: new FormControl(''), - publisher: new FormControl(''), - publishedDate: new FormControl(''), - isbn10: new FormControl(''), - isbn13: new FormControl(''), - description: new FormControl(''), - pageCount: new FormControl(''), - language: new FormControl(''), - asin: new FormControl(''), - amazonRating: new FormControl(''), - amazonReviewCount: new FormControl(''), - goodreadsId: new FormControl(''), - comicvineId: new FormControl(''), - goodreadsRating: new FormControl(''), - goodreadsReviewCount: new FormControl(''), - hardcoverId: new FormControl(''), - hardcoverBookId: new FormControl(''), - hardcoverRating: new FormControl(''), - hardcoverReviewCount: new FormControl(''), - lubimyczytacId: new FormControl(''), - lubimyczytacRating: new FormControl(''), - ranobedbId: new FormControl(''), - ranobedbRating: new FormControl(''), - googleId: new FormControl(''), - seriesName: new FormControl(''), - seriesNumber: new FormControl(''), - seriesTotal: new FormControl(''), - thumbnailUrl: new FormControl(''), + this.metadataForm = this.formBuilder.buildForm(true); + this.initFieldArrays(); + } - titleLocked: new FormControl(false), - subtitleLocked: new FormControl(false), - authorsLocked: new FormControl(false), - categoriesLocked: new FormControl(false), - moodsLocked: new FormControl(false), - tagsLocked: new FormControl(false), - publisherLocked: new FormControl(false), - publishedDateLocked: new FormControl(false), - isbn10Locked: new FormControl(false), - isbn13Locked: new FormControl(false), - descriptionLocked: new FormControl(false), - pageCountLocked: new FormControl(false), - languageLocked: new FormControl(false), - asinLocked: new FormControl(false), - amazonRatingLocked: new FormControl(false), - amazonReviewCountLocked: new FormControl(false), - goodreadsIdLocked: new FormControl(false), - comicvineIdLocked: new FormControl(false), - goodreadsRatingLocked: new FormControl(false), - goodreadsReviewCountLocked: new FormControl(false), - hardcoverIdLocked: new FormControl(false), - hardcoverBookIdLocked: new FormControl(false), - hardcoverRatingLocked: new FormControl(false), - hardcoverReviewCountLocked: new FormControl(false), - lubimyczytacIdLocked: new FormControl(false), - lubimyczytacRatingLocked: new FormControl(false), - ranobedbIdLocked: new FormControl(false), - ranobedbRatingLocked: new FormControl(false), - googleIdLocked: new FormControl(false), - seriesNameLocked: new FormControl(false), - seriesNumberLocked: new FormControl(false), - seriesTotalLocked: new FormControl(false), - coverLocked: new FormControl(false), - }); + private initFieldArrays(): void { + this.metadataFieldsTop = getTopFields(); + this.metadataChips = getArrayFields(); + this.metadataDescription = getTextareaFields(); + this.updateBottomFields(); + } + + private updateBottomFields(): void { + this.metadataFieldsBottom = getBottomFields(this.enabledProviderFields); + } + + getFiltered(controlName: string): string[] { + return this.filteredItems[controlName] ?? []; + } + + filterItems(event: { query: string }, controlName: string): void { + const query = event.query.toLowerCase(); + this.filteredItems[controlName] = (this.allItems[controlName] ?? []) + .filter(item => item.toLowerCase().includes(query)); } ngOnInit(): void { @@ -216,7 +109,7 @@ export class MetadataPickerComponent implements OnInit { .subscribe(settings => { if (settings?.metadataProviderSpecificFields) { this.enabledProviderFields = settings.metadataProviderSpecificFields; - this.filterMetadataFields(); + this.updateBottomFields(); } }); @@ -226,22 +119,23 @@ export class MetadataPickerComponent implements OnInit { take(1) ) .subscribe(bookState => { - const authors = new Set(); - const categories = new Set(); - const moods = new Set(); - const tags = new Set(); + const itemSets: Record> = { + authors: new Set(), + categories: new Set(), + moods: new Set(), + tags: new Set() + }; (bookState.books ?? []).forEach(book => { - book.metadata?.authors?.forEach(author => authors.add(author)); - book.metadata?.categories?.forEach(category => categories.add(category)); - book.metadata?.moods?.forEach(mood => moods.add(mood)); - book.metadata?.tags?.forEach(tag => tags.add(tag)); + for (const key of Object.keys(itemSets)) { + const values = book.metadata?.[key as keyof BookMetadata] as string[] | undefined; + values?.forEach(v => itemSets[key].add(v)); + } }); - this.allAuthors = Array.from(authors); - this.allCategories = Array.from(categories); - this.allMoods = Array.from(moods); - this.allTags = Array.from(tags); + for (const key of Object.keys(itemSets)) { + this.allItems[key] = Array.from(itemSets[key]); + } }); this.book$ @@ -250,7 +144,6 @@ export class MetadataPickerComponent implements OnInit { map(book => book.metadata), takeUntilDestroyed(this.destroyRef) ).subscribe((metadata) => { - if (this.reviewMode) { this.metadataForm.reset(); this.copiedFields = {}; @@ -262,145 +155,41 @@ export class MetadataPickerComponent implements OnInit { this.originalMetadata = metadata; this.originalMetadata.thumbnailUrl = this.urlHelper.getThumbnailUrl(metadata.bookId, metadata.coverUpdatedOn); this.currentBookId = metadata.bookId; - this.metadataForm.patchValue({ - title: metadata.title || null, - subtitle: metadata.subtitle || null, - authors: [...(metadata.authors ?? [])].sort(), - categories: [...(metadata.categories ?? [])].sort(), - moods: [...(metadata.moods ?? [])].sort(), - tags: [...(metadata.tags ?? [])].sort(), - publisher: metadata.publisher || null, - publishedDate: metadata.publishedDate || null, - isbn10: metadata.isbn10 || null, - isbn13: metadata.isbn13 || null, - description: metadata.description || null, - pageCount: metadata.pageCount || null, - language: metadata.language || null, - asin: metadata.asin || null, - amazonRating: metadata.amazonRating || null, - amazonReviewCount: metadata.amazonReviewCount || null, - goodreadsId: metadata.goodreadsId || null, - comicvineId: metadata.comicvineId || null, - goodreadsRating: metadata.goodreadsRating || null, - goodreadsReviewCount: metadata.goodreadsReviewCount || null, - hardcoverId: metadata.hardcoverId || null, - hardcoverBookId: metadata.hardcoverBookId || null, - hardcoverRating: metadata.hardcoverRating || null, - hardcoverReviewCount: metadata.hardcoverReviewCount || null, - lubimyczytacId: metadata.lubimyczytacId || null, - lubimyczytacRating: metadata.lubimyczytacRating || null, - ranobedbId: metadata.ranobedbId || null, - ranobedbRating: metadata.ranobedbRating || null, - googleId: metadata.googleId || null, - seriesName: metadata.seriesName || null, - seriesNumber: metadata.seriesNumber || null, - seriesTotal: metadata.seriesTotal || null, - thumbnailUrl: this.urlHelper.getCoverUrl(metadata.bookId, metadata.coverUpdatedOn), - - titleLocked: metadata.titleLocked || false, - subtitleLocked: metadata.subtitleLocked || false, - authorsLocked: metadata.authorsLocked || false, - categoriesLocked: metadata.categoriesLocked || false, - moodsLocked: metadata.moodsLocked || false, - tagsLocked: metadata.tagsLocked || false, - publisherLocked: metadata.publisherLocked || false, - publishedDateLocked: metadata.publishedDateLocked || false, - isbn10Locked: metadata.isbn10Locked || false, - isbn13Locked: metadata.isbn13Locked || false, - descriptionLocked: metadata.descriptionLocked || false, - pageCountLocked: metadata.pageCountLocked || false, - languageLocked: metadata.languageLocked || false, - asinLocked: metadata.asinLocked || false, - amazonRatingLocked: metadata.amazonRatingLocked || false, - amazonReviewCountLocked: metadata.amazonReviewCountLocked || false, - goodreadsIdLocked: metadata.goodreadsIdLocked || false, - comicvineIdLocked: metadata.comicvineIdLocked || false, - goodreadsRatingLocked: metadata.goodreadsRatingLocked || false, - goodreadsReviewCountLocked: metadata.goodreadsReviewCountLocked || false, - hardcoverIdLocked: metadata.hardcoverIdLocked || false, - hardcoverBookIdLocked: metadata.hardcoverBookIdLocked || false, - hardcoverRatingLocked: metadata.hardcoverRatingLocked || false, - hardcoverReviewCountLocked: metadata.hardcoverReviewCountLocked || false, - lubimyczytacIdLocked: metadata.lubimyczytacIdLocked || false, - lubimyczytacRatingLocked: metadata.lubimyczytacRatingLocked || false, - ranobedbIdLocked: metadata.ranobedbIdLocked || false, - ranobedbRatingLocked: metadata.ranobedbRatingLocked || false, - googleIdLocked: metadata.googleIdLocked || false, - seriesNameLocked: metadata.seriesNameLocked || false, - seriesNumberLocked: metadata.seriesNumberLocked || false, - seriesTotalLocked: metadata.seriesTotalLocked || false, - coverLocked: metadata.coverLocked || false, - }); - - Object.keys(this.metadataForm.controls).forEach((key) => { - if (!key.endsWith('Locked')) { - this.metadataForm.get(key)?.enable({emitEvent: false}); - } - }); - - if (metadata.titleLocked) this.metadataForm.get('title')?.disable({emitEvent: false}); - if (metadata.subtitleLocked) this.metadataForm.get('subtitle')?.disable({emitEvent: false}); - if (metadata.authorsLocked) this.metadataForm.get('authors')?.disable({emitEvent: false}); - if (metadata.categoriesLocked) this.metadataForm.get('categories')?.disable({emitEvent: false}); - if (metadata.moodsLocked) this.metadataForm.get('moods')?.disable({emitEvent: false}); - if (metadata.tagsLocked) this.metadataForm.get('tags')?.disable({emitEvent: false}); - if (metadata.publisherLocked) this.metadataForm.get('publisher')?.disable({emitEvent: false}); - if (metadata.publishedDateLocked) this.metadataForm.get('publishedDate')?.disable({emitEvent: false}); - if (metadata.languageLocked) this.metadataForm.get('language')?.disable({emitEvent: false}); - if (metadata.isbn10Locked) this.metadataForm.get('isbn10')?.disable({emitEvent: false}); - if (metadata.isbn13Locked) this.metadataForm.get('isbn13')?.disable({emitEvent: false}); - if (metadata.asinLocked) this.metadataForm.get('asin')?.disable({emitEvent: false}); - if (metadata.amazonReviewCountLocked) this.metadataForm.get('amazonReviewCount')?.disable({emitEvent: false}); - if (metadata.amazonRatingLocked) this.metadataForm.get('amazonRating')?.disable({emitEvent: false}); - if (metadata.googleIdLocked) this.metadataForm.get('googleIdCount')?.disable({emitEvent: false}); - if (metadata.comicvineIdLocked) this.metadataForm.get('comicvineId')?.disable({emitEvent: false}); - if (metadata.goodreadsIdLocked) this.metadataForm.get('goodreadsId')?.disable({emitEvent: false}); - 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.lubimyczytacIdLocked) this.metadataForm.get('lubimyczytacId')?.disable({emitEvent: false}); - if (metadata.lubimyczytacRatingLocked) this.metadataForm.get('lubimyczytacRating')?.disable({emitEvent: false}); - if (metadata.ranobedbIdLocked) this.metadataForm.get('ranobedbId')?.disable({emitEvent: false}); - if (metadata.ranobedbRatingLocked) this.metadataForm.get('ranobedbRating')?.disable({emitEvent: false}); - if (metadata.googleIdLocked) this.metadataForm.get('googleId')?.disable({emitEvent: false}); - if (metadata.pageCountLocked) this.metadataForm.get('pageCount')?.disable({emitEvent: false}); - if (metadata.descriptionLocked) this.metadataForm.get('description')?.disable({emitEvent: false}); - if (metadata.seriesNameLocked) this.metadataForm.get('seriesName')?.disable({emitEvent: false}); - if (metadata.seriesNumberLocked) this.metadataForm.get('seriesNumber')?.disable({emitEvent: false}); - if (metadata.seriesTotalLocked) this.metadataForm.get('seriesTotal')?.disable({emitEvent: false}); + this.patchMetadataToForm(metadata); } }); } - private filterMetadataFields(): void { - if (!this.enabledProviderFields) return; + private patchMetadataToForm(metadata: BookMetadata): void { + const patchData: Record = {}; - const providerFieldMap: Record = { - 'asin': 'asin', - 'amazonRating': 'amazonRating', - 'amazonReviewCount': 'amazonReviewCount', - 'googleId': 'googleId', - 'goodreadsId': 'goodreadsId', - 'goodreadsRating': 'goodreadsRating', - 'goodreadsReviewCount': 'goodreadsReviewCount', - 'hardcoverId': 'hardcoverId', - 'hardcoverBookId': 'hardcoverId', - 'hardcoverRating': 'hardcoverRating', - 'hardcoverReviewCount': 'hardcoverReviewCount', - 'comicvineId': 'comicvineId', - 'lubimyczytacId': 'lubimyczytacId', - 'lubimyczytacRating': 'lubimyczytacRating', - 'ranobedbId': 'ranobedbId', - 'ranobedbRating': 'ranobedbRating' - }; + for (const field of ALL_METADATA_FIELDS) { + const key = field.controlName as keyof BookMetadata; + const lockedKey = field.lockedKey as keyof BookMetadata; + const value = metadata[key]; - this.metadataFieldsBottom = this.metadataFieldsBottom.filter(field => { - const providerKey = providerFieldMap[field.controlName]; - return !providerKey || this.enabledProviderFields![providerKey]; - }); + if (field.type === 'array') { + patchData[field.controlName] = [...(value as string[] ?? [])].sort(); + } else { + patchData[field.controlName] = value ?? null; + } + patchData[field.lockedKey] = metadata[lockedKey] ?? false; + } + + // Handle special fields + patchData['thumbnailUrl'] = this.urlHelper.getCoverUrl(metadata.bookId, metadata.coverUpdatedOn); + patchData['coverLocked'] = metadata.coverLocked ?? false; + + this.metadataForm.patchValue(patchData); + this.applyLockStates(metadata); + } + + private applyLockStates(metadata: BookMetadata): void { + const lockedFields: Record = {}; + for (const field of ALL_METADATA_FIELDS) { + lockedFields[field.lockedKey] = !!metadata[field.lockedKey as keyof BookMetadata]; + } + this.formBuilder.applyLockStates(this.metadataForm, lockedFields); } onAutoCompleteSelect(fieldName: string, event: AutoCompleteSelectEvent) { @@ -429,13 +218,13 @@ export class MetadataPickerComponent implements OnInit { this.isSaving = true; const updatedBookMetadata = this.buildMetadataWrapper(undefined); this.bookService.updateBookMetadata(this.currentBookId, updatedBookMetadata, false).subscribe({ - next: (bookMetadata) => { + next: () => { this.isSaving = false; - Object.keys(this.copiedFields).forEach((field) => { + for (const field of Object.keys(this.copiedFields)) { if (this.copiedFields[field]) { this.savedFields[field] = true; } - }); + } this.messageService.add({severity: 'info', summary: 'Success', detail: 'Book metadata updated'}); }, error: () => { @@ -446,136 +235,113 @@ export class MetadataPickerComponent implements OnInit { } private buildMetadataWrapper(shouldLockAllFields: boolean | undefined): MetadataUpdateWrapper { - const updatedBookMetadata: BookMetadata = { - bookId: this.currentBookId, - title: this.metadataForm.get('title')?.value || this.copiedFields['title'] ? this.getValueOrCopied('title') : '', - subtitle: this.metadataForm.get('subtitle')?.value || this.copiedFields['subtitle'] ? this.getValueOrCopied('subtitle') : '', - authors: this.metadataForm.get('authors')?.value || this.copiedFields['authors'] ? this.getArrayFromFormField('authors', this.fetchedMetadata.authors) : [], - categories: this.metadataForm.get('categories')?.value || this.copiedFields['categories'] ? this.getArrayFromFormField('categories', this.fetchedMetadata.categories) : [], - moods: this.metadataForm.get('moods')?.value || this.copiedFields['moods'] ? this.getArrayFromFormField('moods', this.fetchedMetadata.moods) : [], - tags: this.metadataForm.get('tags')?.value || this.copiedFields['tags'] ? this.getArrayFromFormField('tags', this.fetchedMetadata.tags) : [], - publisher: this.metadataForm.get('publisher')?.value || this.copiedFields['publisher'] ? this.getValueOrCopied('publisher') : '', - publishedDate: this.metadataForm.get('publishedDate')?.value || this.copiedFields['publishedDate'] ? this.getValueOrCopied('publishedDate') : '', - isbn10: this.metadataForm.get('isbn10')?.value || this.copiedFields['isbn10'] ? this.getValueOrCopied('isbn10') : '', - isbn13: this.metadataForm.get('isbn13')?.value || this.copiedFields['isbn13'] ? this.getValueOrCopied('isbn13') : '', - description: this.metadataForm.get('description')?.value || this.copiedFields['description'] ? this.getValueOrCopied('description') : '', - pageCount: this.metadataForm.get('pageCount')?.value || this.copiedFields['pageCount'] ? this.getPageCountOrCopied() : null, - language: this.metadataForm.get('language')?.value || this.copiedFields['language'] ? this.getValueOrCopied('language') : '', - asin: this.metadataForm.get('asin')?.value || this.copiedFields['asin'] ? this.getValueOrCopied('asin') : '', - amazonRating: this.metadataForm.get('amazonRating')?.value || this.copiedFields['amazonRating'] ? this.getNumberOrCopied('amazonRating') : null, - amazonReviewCount: this.metadataForm.get('amazonReviewCount')?.value || this.copiedFields['amazonReviewCount'] ? this.getNumberOrCopied('amazonReviewCount') : null, - goodreadsId: this.metadataForm.get('goodreadsId')?.value || this.copiedFields['goodreadsId'] ? this.getValueOrCopied('goodreadsId') : '', - comicvineId: this.metadataForm.get('comicvineId')?.value || this.copiedFields['comicvineId'] ? this.getValueOrCopied('comicvineId') : '', - 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, - lubimyczytacId: this.metadataForm.get('lubimyczytacId')?.value || this.copiedFields['lubimyczytacId'] ? this.getValueOrCopied('lubimyczytacId') : '', - lubimyczytacRating: this.metadataForm.get('lubimyczytacRating')?.value || this.copiedFields['lubimyczytacRating'] ? this.getNumberOrCopied('lubimyczytacRating') : null, - ranobedbId: this.metadataForm.get('ranobedbId')?.value || this.copiedFields['ranobedbId'] ? this.getValueOrCopied('ranobedbId') : '', - ranobedbRating: this.metadataForm.get('ranobedbRating')?.value || this.copiedFields['ranobedbRating'] ? this.getNumberOrCopied('ranobedbRating') : null, - googleId: this.metadataForm.get('googleId')?.value || this.copiedFields['googleId'] ? this.getValueOrCopied('googleId') : '', - seriesName: this.metadataForm.get('seriesName')?.value || this.copiedFields['seriesName'] ? this.getValueOrCopied('seriesName') : '', - seriesNumber: this.metadataForm.get('seriesNumber')?.value || this.copiedFields['seriesNumber'] ? this.getNumberOrCopied('seriesNumber') : null, - seriesTotal: this.metadataForm.get('seriesTotal')?.value || this.copiedFields['seriesTotal'] ? this.getNumberOrCopied('seriesTotal') : null, - thumbnailUrl: this.getThumbnail(), + const metadata = this.buildMetadataFromForm(); - titleLocked: this.metadataForm.get('titleLocked')?.value, - subtitleLocked: this.metadataForm.get('subtitleLocked')?.value, - authorsLocked: this.metadataForm.get('authorsLocked')?.value, - categoriesLocked: this.metadataForm.get('categoriesLocked')?.value, - moodsLocked: this.metadataForm.get('moodsLocked')?.value, - tagsLocked: this.metadataForm.get('tagsLocked')?.value, - publisherLocked: this.metadataForm.get('publisherLocked')?.value, - publishedDateLocked: this.metadataForm.get('publishedDateLocked')?.value, - isbn10Locked: this.metadataForm.get('isbn10Locked')?.value, - isbn13Locked: this.metadataForm.get('isbn13Locked')?.value, - descriptionLocked: this.metadataForm.get('descriptionLocked')?.value, - pageCountLocked: this.metadataForm.get('pageCountLocked')?.value, - languageLocked: this.metadataForm.get('languageLocked')?.value, - asinLocked: this.metadataForm.get('asinLocked')?.value, - amazonRatingLocked: this.metadataForm.get('amazonRatingLocked')?.value, - amazonReviewCountLocked: this.metadataForm.get('amazonReviewCountLocked')?.value, - goodreadsIdLocked: this.metadataForm.get('goodreadsIdLocked')?.value, - comicvineIdLocked: this.metadataForm.get('comicvineIdLocked')?.value, - 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, - lubimyczytacIdLocked: this.metadataForm.get('lubimyczytacIdLocked')?.value, - lubimyczytacRatingLocked: this.metadataForm.get('lubimyczytacRatingLocked')?.value, - ranobedbIdLocked: this.metadataForm.get('ranobedbIdLocked')?.value, - ranobedbRatingLocked: this.metadataForm.get('ranobedbRatingLocked')?.value, - googleIdLocked: this.metadataForm.get('googleIdLocked')?.value, - seriesNameLocked: this.metadataForm.get('seriesNameLocked')?.value, - seriesNumberLocked: this.metadataForm.get('seriesNumberLocked')?.value, - seriesTotalLocked: this.metadataForm.get('seriesTotalLocked')?.value, - coverLocked: this.metadataForm.get('coverLocked')?.value, + if (shouldLockAllFields !== undefined) { + (metadata as BookMetadata & { allFieldsLocked?: boolean }).allFieldsLocked = shouldLockAllFields; + } - ...(shouldLockAllFields !== undefined && {allFieldsLocked: shouldLockAllFields}), - }; - - const clearFlags: MetadataClearFlags = this.inferClearFlags(updatedBookMetadata, this.originalMetadata); + const clearFlags = this.inferClearFlags(metadata, this.originalMetadata); return { - metadata: updatedBookMetadata, + metadata: metadata, clearFlags: clearFlags }; } - private inferClearFlags(current: BookMetadata, original: BookMetadata): MetadataClearFlags { - return { - title: !current.title && !!original.title, - subtitle: !current.subtitle && !!original.subtitle, - authors: current.authors?.length === 0 && original.authors?.length! > 0, - categories: current.categories?.length === 0 && original.categories?.length! > 0, - moods: current.moods?.length === 0 && original.moods?.length! > 0, - tags: current.tags?.length === 0 && original.tags?.length! > 0, - publisher: !current.publisher && !!original.publisher, - publishedDate: !current.publishedDate && !!original.publishedDate, - isbn10: !current.isbn10 && !!original.isbn10, - isbn13: !current.isbn13 && !!original.isbn13, - description: !current.description && !!original.description, - pageCount: current.pageCount === null && original.pageCount !== null, - language: !current.language && !!original.language, - asin: !current.asin && !!original.asin, - amazonRating: current.amazonRating === null && original.amazonRating !== null, - amazonReviewCount: current.amazonReviewCount === null && original.amazonReviewCount !== null, - goodreadsId: !current.goodreadsId && !!original.goodreadsId, - comicvineId: !current.comicvineId && !!original.comicvineId, - 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, - lubimyczytacId: !current.lubimyczytacId && !!original.lubimyczytacId, - lubimyczytacRating: current.lubimyczytacRating === null && original.lubimyczytacRating !== null, - ranobedbId: !current.ranobedbId && !!original.ranobedbId, - ranobedbRating: current.ranobedbRating === null && original.ranobedbRating !== null, - googleId: !current.googleId && !!original.googleId, - seriesName: !current.seriesName && !!original.seriesName, - seriesNumber: current.seriesNumber === null && original.seriesNumber !== null, - seriesTotal: current.seriesTotal === null && original.seriesTotal !== null, - cover: !current.thumbnailUrl && !!original.thumbnailUrl, - }; + private buildMetadataFromForm(): BookMetadata { + const metadata: Record = {bookId: this.currentBookId}; + + for (const field of ALL_METADATA_FIELDS) { + if (field.type === 'array') { + metadata[field.controlName] = this.getArrayValue(field.controlName); + } else if (field.type === 'number') { + metadata[field.controlName] = this.getNumberValue(field.controlName); + } else { + metadata[field.controlName] = this.getStringValue(field.controlName); + } + + metadata[field.lockedKey] = this.metadataForm.get(field.lockedKey)?.value ?? false; + } + + metadata['thumbnailUrl'] = this.getThumbnail(); + metadata['coverLocked'] = this.metadataForm.get('coverLocked')?.value; + + return metadata as BookMetadata; } - getThumbnail() { + private getStringValue(field: string): string { + const formValue = this.metadataForm.get(field)?.value; + if (!formValue || formValue === '') { + if (this.copiedFields[field]) { + return (this.fetchedMetadata[field as keyof BookMetadata] as string) || ''; + } + return ''; + } + return formValue; + } + + private getNumberValue(field: string): number | null { + const formValue = this.metadataForm.get(field)?.value; + if (formValue === '' || formValue === null || formValue === undefined || isNaN(formValue)) { + if (this.copiedFields[field]) { + return (this.fetchedMetadata[field as keyof BookMetadata] as number | null) ?? null; + } + return null; + } + return Number(formValue); + } + + private getArrayValue(field: string): string[] { + const fieldValue = this.metadataForm.get(field)?.value; + if (!fieldValue || (Array.isArray(fieldValue) && fieldValue.length === 0)) { + if (this.copiedFields[field]) { + const fallback = this.fetchedMetadata[field as keyof BookMetadata]; + return Array.isArray(fallback) ? fallback as string[] : []; + } + return []; + } + if (typeof fieldValue === 'string') { + return fieldValue.split(',').map(item => item.trim()); + } + return Array.isArray(fieldValue) ? fieldValue as string[] : []; + } + + private inferClearFlags(current: BookMetadata, original: BookMetadata): MetadataClearFlags { + const flags: Record = {}; + + for (const field of ALL_METADATA_FIELDS) { + const key = field.controlName as keyof BookMetadata; + const curr = current[key]; + const orig = original[key]; + + if (field.type === 'array') { + flags[key] = !(curr as string[])?.length && !!(orig as string[])?.length; + } else if (field.type === 'number') { + flags[key] = curr === null && orig !== null; + } else { + flags[key] = !curr && !!orig; + } + } + + flags['cover'] = !current.thumbnailUrl && !!original.thumbnailUrl; + return flags as MetadataClearFlags; + } + + getThumbnail(): string | null { const thumbnailUrl = this.metadataForm.get('thumbnailUrl')?.value; if (thumbnailUrl?.includes('api/v1')) { return null; } - return this.copiedFields['thumbnailUrl'] ? this.getValueOrCopied('thumbnailUrl') : null; + if (this.copiedFields['thumbnailUrl']) { + return (this.fetchedMetadata['thumbnailUrl' as keyof BookMetadata] as string) || null; + } + return null; } private updateMetadata(shouldLockAllFields: boolean | undefined): void { this.bookService.updateBookMetadata(this.currentBookId, this.buildMetadataWrapper(shouldLockAllFields), false).subscribe({ - next: (response) => { + next: () => { if (shouldLockAllFields !== undefined) { this.messageService.add({ severity: 'success', @@ -598,7 +364,7 @@ export class MetadataPickerComponent implements OnInit { toggleLock(field: string): void { if (field === 'thumbnailUrl') { - field = 'cover' + field = 'cover'; } const isLocked = this.metadataForm.get(field + 'Locked')?.value; const updatedLockedState = !isLocked; @@ -612,31 +378,20 @@ export class MetadataPickerComponent implements OnInit { } copyMissing(): void { - Object.keys(this.fetchedMetadata).forEach((field) => { - const isLocked = this.metadataForm.get(`${field}Locked`)?.value; - const currentValue = this.metadataForm.get(field)?.value; - const fetchedValue = this.fetchedMetadata[field]; - - const isEmpty = Array.isArray(currentValue) - ? currentValue.length === 0 - : (currentValue === null || currentValue === undefined || currentValue === ''); - - const hasFetchedValue = fetchedValue !== null && fetchedValue !== undefined && fetchedValue !== ''; - - if (!isLocked && isEmpty && hasFetchedValue) { - this.copyFetchedToCurrent(field); - } - }); + this.metadataUtils.copyMissingFields( + this.fetchedMetadata, + this.metadataForm, + this.copiedFields, + (field) => this.copyFetchedToCurrent(field) + ); } - copyAll() { - Object.keys(this.fetchedMetadata).forEach((field) => { - const isLocked = this.metadataForm.get(`${field}Locked`)?.value; - const fetchedValue = this.fetchedMetadata[field]; - if (!isLocked && fetchedValue !== null && fetchedValue !== undefined && fetchedValue !== '' && field !== 'thumbnailUrl') { - this.copyFetchedToCurrent(field); - } - }); + copyAll(): void { + this.metadataUtils.copyAllFields( + this.fetchedMetadata, + this.metadataForm, + (field) => this.copyFetchedToCurrent(field) + ); } copyFetchedToCurrent(field: string): void { @@ -655,71 +410,18 @@ export class MetadataPickerComponent implements OnInit { if (field === 'cover') { field = 'thumbnailUrl'; } - const value = this.fetchedMetadata[field]; - if (value !== null && value !== undefined && value !== '') { - this.metadataForm.get(field)?.setValue(value); - this.copiedFields[field] = true; + if (this.metadataUtils.copyFieldToForm(field, this.fetchedMetadata, this.metadataForm, this.copiedFields)) { this.highlightCopiedInput(field); } } - private getNumberOrCopied(field: string): number | null { - const formValue = this.metadataForm.get(field)?.value; - if (formValue === '' || formValue === null || isNaN(formValue)) { - this.copiedFields[field] = true; - return (this.fetchedMetadata[field as keyof BookMetadata] as number | null) || null; - } - return Number(formValue); - } - - private getPageCountOrCopied(): number | null { - const formValue = this.metadataForm.get('pageCount')?.value; - if (formValue === '' || formValue === null || isNaN(formValue)) { - this.copiedFields['pageCount'] = true; - return (this.fetchedMetadata.pageCount as number | null) || null; - } - return Number(formValue); - } - - private getValueOrCopied(field: string): string { - const formValue = this.metadataForm.get(field)?.value; - if (!formValue || formValue === '') { - this.copiedFields[field] = true; - return (this.fetchedMetadata[field as keyof BookMetadata] as string) || ''; - } - return formValue; - } - - getArrayFromFormField(field: string, fallbackValue: unknown): string[] { - const fieldValue = this.metadataForm.get(field)?.value; - if (!fieldValue) { - return fallbackValue ? (Array.isArray(fallbackValue) ? fallbackValue as string[] : [fallbackValue as string]) : []; - } - if (typeof fieldValue === 'string') { - return fieldValue.split(',').map(item => item.trim()); - } - return Array.isArray(fieldValue) ? fieldValue as string[] : []; - } - lockAll(): void { - Object.keys(this.metadataForm.controls).forEach((key) => { - if (key.endsWith('Locked')) { - this.metadataForm.get(key)?.setValue(true); - const fieldName = key.replace('Locked', ''); - this.metadataForm.get(fieldName)?.disable(); - } - }); + this.formBuilder.setAllFieldsLocked(this.metadataForm, true); this.updateMetadata(true); } unlockAll(): void { - Object.keys(this.metadataForm.controls).forEach((key) => { - if (key.endsWith('Locked')) { - this.metadataForm.get(key)?.setValue(false); - const fieldName = key.replace('Locked', ''); - this.metadataForm.get(fieldName)?.enable(); - } - }); + this.formBuilder.setAllFieldsLocked(this.metadataForm, false); this.updateMetadata(false); } @@ -735,12 +437,10 @@ export class MetadataPickerComponent implements OnInit { return this.savedFields[field]; } - goBackClick() { + goBackClick(): void { this.goBack.emit(true); } - hoveredFields: Record = {}; - onMouseEnter(controlName: string): void { if (this.isValueCopied(controlName) && !this.isValueSaved(controlName)) { this.hoveredFields[controlName] = true; @@ -751,9 +451,7 @@ export class MetadataPickerComponent implements OnInit { this.hoveredFields[controlName] = false; } - resetField(field: string) { - this.metadataForm.get(field)?.setValue(this.originalMetadata[field]); - this.copiedFields[field] = false; - this.hoveredFields[field] = false; + resetField(field: string): void { + this.metadataUtils.resetField(field, this.metadataForm, this.originalMetadata, this.copiedFields, this.hoveredFields); } } diff --git a/booklore-ui/src/app/shared/metadata/index.ts b/booklore-ui/src/app/shared/metadata/index.ts new file mode 100644 index 000000000..7c21d56f4 --- /dev/null +++ b/booklore-ui/src/app/shared/metadata/index.ts @@ -0,0 +1,3 @@ +export * from './metadata-field.config'; +export * from './metadata-utils.service'; +export * from './metadata-form.builder'; diff --git a/booklore-ui/src/app/shared/metadata/metadata-field.config.ts b/booklore-ui/src/app/shared/metadata/metadata-field.config.ts new file mode 100644 index 000000000..321af74b9 --- /dev/null +++ b/booklore-ui/src/app/shared/metadata/metadata-field.config.ts @@ -0,0 +1,79 @@ +import {MetadataProviderSpecificFields} from '../model/app-settings.model'; + +export type FieldType = 'string' | 'number' | 'array' | 'textarea'; + +export interface MetadataFieldConfig { + label: string; + controlName: string; + lockedKey: string; + fetchedKey: string; + type: FieldType; + providerKey?: keyof MetadataProviderSpecificFields; +} + +export const ALL_METADATA_FIELDS: MetadataFieldConfig[] = [ + {label: 'Title', controlName: 'title', lockedKey: 'titleLocked', fetchedKey: 'title', type: 'string'}, + {label: 'Subtitle', controlName: 'subtitle', lockedKey: 'subtitleLocked', fetchedKey: 'subtitle', type: 'string'}, + {label: 'Publisher', controlName: 'publisher', lockedKey: 'publisherLocked', fetchedKey: 'publisher', type: 'string'}, + {label: 'Published', controlName: 'publishedDate', lockedKey: 'publishedDateLocked', fetchedKey: 'publishedDate', type: 'string'}, + {label: 'Authors', controlName: 'authors', lockedKey: 'authorsLocked', fetchedKey: 'authors', type: 'array'}, + {label: 'Genres', controlName: 'categories', lockedKey: 'categoriesLocked', fetchedKey: 'categories', type: 'array'}, + {label: 'Moods', controlName: 'moods', lockedKey: 'moodsLocked', fetchedKey: 'moods', type: 'array'}, + {label: 'Tags', controlName: 'tags', lockedKey: 'tagsLocked', fetchedKey: 'tags', type: 'array'}, + {label: 'Description', controlName: 'description', lockedKey: 'descriptionLocked', fetchedKey: 'description', type: 'textarea'}, + {label: 'Series', controlName: 'seriesName', lockedKey: 'seriesNameLocked', fetchedKey: 'seriesName', type: 'string'}, + {label: 'Book #', controlName: 'seriesNumber', lockedKey: 'seriesNumberLocked', fetchedKey: 'seriesNumber', type: 'number'}, + {label: 'Total Books', controlName: 'seriesTotal', lockedKey: 'seriesTotalLocked', fetchedKey: 'seriesTotal', type: 'number'}, + {label: 'Language', controlName: 'language', lockedKey: 'languageLocked', fetchedKey: 'language', type: 'string'}, + {label: 'ISBN-10', controlName: 'isbn10', lockedKey: 'isbn10Locked', fetchedKey: 'isbn10', type: 'string'}, + {label: 'ISBN-13', controlName: 'isbn13', lockedKey: 'isbn13Locked', fetchedKey: 'isbn13', type: 'string'}, + {label: 'Pages', controlName: 'pageCount', lockedKey: 'pageCountLocked', fetchedKey: 'pageCount', type: 'number'}, + {label: 'Google ID', controlName: 'googleId', lockedKey: 'googleIdLocked', fetchedKey: 'googleId', type: 'string', providerKey: 'googleId'}, + {label: 'ASIN', controlName: 'asin', lockedKey: 'asinLocked', fetchedKey: 'asin', type: 'string', providerKey: 'asin'}, + {label: 'Amazon #', controlName: 'amazonReviewCount', lockedKey: 'amazonReviewCountLocked', fetchedKey: 'amazonReviewCount', type: 'number', providerKey: 'amazonReviewCount'}, + {label: 'Amazon ★', controlName: 'amazonRating', lockedKey: 'amazonRatingLocked', fetchedKey: 'amazonRating', type: 'number', providerKey: 'amazonRating'}, + {label: 'Goodreads ID', controlName: 'goodreadsId', lockedKey: 'goodreadsIdLocked', fetchedKey: 'goodreadsId', type: 'string', providerKey: 'goodreadsId'}, + {label: 'Goodreads ★', controlName: 'goodreadsReviewCount', lockedKey: 'goodreadsReviewCountLocked', fetchedKey: 'goodreadsReviewCount', type: 'number', providerKey: 'goodreadsReviewCount'}, + {label: 'Goodreads #', controlName: 'goodreadsRating', lockedKey: 'goodreadsRatingLocked', fetchedKey: 'goodreadsRating', type: 'number', providerKey: 'goodreadsRating'}, + {label: 'HC Book ID', controlName: 'hardcoverBookId', lockedKey: 'hardcoverBookIdLocked', fetchedKey: 'hardcoverBookId', type: 'number', providerKey: 'hardcoverId'}, + {label: 'Hardcover ID', controlName: 'hardcoverId', lockedKey: 'hardcoverIdLocked', fetchedKey: 'hardcoverId', type: 'string', providerKey: 'hardcoverId'}, + {label: 'Hardcover #', controlName: 'hardcoverReviewCount', lockedKey: 'hardcoverReviewCountLocked', fetchedKey: 'hardcoverReviewCount', type: 'number', providerKey: 'hardcoverReviewCount'}, + {label: 'Hardcover ★', controlName: 'hardcoverRating', lockedKey: 'hardcoverRatingLocked', fetchedKey: 'hardcoverRating', type: 'number', providerKey: 'hardcoverRating'}, + {label: 'Comicvine ID', controlName: 'comicvineId', lockedKey: 'comicvineIdLocked', fetchedKey: 'comicvineId', type: 'string', providerKey: 'comicvineId'}, + {label: 'LB ID', controlName: 'lubimyczytacId', lockedKey: 'lubimyczytacIdLocked', fetchedKey: 'lubimyczytacId', type: 'string', providerKey: 'lubimyczytacId'}, + {label: 'LB ★', controlName: 'lubimyczytacRating', lockedKey: 'lubimyczytacRatingLocked', fetchedKey: 'lubimyczytacRating', type: 'number', providerKey: 'lubimyczytacRating'}, + {label: 'Ranobedb ID', controlName: 'ranobedbId', lockedKey: 'ranobedbIdLocked', fetchedKey: 'ranobedbId', type: 'string', providerKey: 'ranobedbId'}, + {label: 'Ranobedb ★', controlName: 'ranobedbRating', lockedKey: 'ranobedbRatingLocked', fetchedKey: 'ranobedbRating', type: 'number', providerKey: 'ranobedbRating'} +]; + +export const TOP_FIELD_NAMES = ['title', 'subtitle', 'publisher', 'publishedDate']; +export const ARRAY_FIELD_NAMES = ['authors', 'categories', 'moods', 'tags']; +export const TEXTAREA_FIELD_NAMES = ['description']; + +export function getTopFields(): MetadataFieldConfig[] { + return ALL_METADATA_FIELDS.filter(f => TOP_FIELD_NAMES.includes(f.controlName)); +} + +export function getArrayFields(): MetadataFieldConfig[] { + return ALL_METADATA_FIELDS.filter(f => f.type === 'array'); +} + +export function getTextareaFields(): MetadataFieldConfig[] { + return ALL_METADATA_FIELDS.filter(f => f.type === 'textarea'); +} + +export function getBottomFields(enabledProviderFields?: MetadataProviderSpecificFields | null): MetadataFieldConfig[] { + const bottomFields = ALL_METADATA_FIELDS.filter(f => + !TOP_FIELD_NAMES.includes(f.controlName) && + !ARRAY_FIELD_NAMES.includes(f.controlName) && + !TEXTAREA_FIELD_NAMES.includes(f.controlName) + ); + + if (enabledProviderFields) { + return bottomFields.filter(field => + !field.providerKey || enabledProviderFields[field.providerKey] + ); + } + + return bottomFields; +} diff --git a/booklore-ui/src/app/shared/metadata/metadata-form.builder.ts b/booklore-ui/src/app/shared/metadata/metadata-form.builder.ts new file mode 100644 index 000000000..5a47017d0 --- /dev/null +++ b/booklore-ui/src/app/shared/metadata/metadata-form.builder.ts @@ -0,0 +1,75 @@ +import {Injectable} from '@angular/core'; +import {FormControl, FormGroup} from '@angular/forms'; +import {ALL_METADATA_FIELDS, MetadataFieldConfig} from './metadata-field.config'; + +@Injectable({ + providedIn: 'root' +}) +export class MetadataFormBuilder { + + buildForm( + includeLockedControls: boolean = true, + fields: MetadataFieldConfig[] = ALL_METADATA_FIELDS + ): FormGroup { + const controls: Record = {}; + + for (const field of fields) { + const defaultValue = this.getDefaultValue(field.type); + controls[field.controlName] = new FormControl(defaultValue); + + if (includeLockedControls) { + controls[field.lockedKey] = new FormControl(false); + } + } + + controls['thumbnailUrl'] = new FormControl(''); + if (includeLockedControls) { + controls['coverLocked'] = new FormControl(false); + } + + return new FormGroup(controls); + } + + private getDefaultValue(type: string): unknown { + switch (type) { + case 'array': + return []; + case 'number': + return null; + default: + return ''; + } + } + + applyLockStates( + metadataForm: FormGroup, + lockedFields: Record, + fields: MetadataFieldConfig[] = ALL_METADATA_FIELDS + ): void { + for (const key of Object.keys(metadataForm.controls)) { + if (!key.endsWith('Locked')) { + metadataForm.get(key)?.enable({emitEvent: false}); + } + } + + for (const field of fields) { + if (lockedFields[field.lockedKey]) { + metadataForm.get(field.controlName)?.disable({emitEvent: false}); + } + } + } + + setAllFieldsLocked(metadataForm: FormGroup, locked: boolean): void { + for (const key of Object.keys(metadataForm.controls)) { + if (key.endsWith('Locked')) { + metadataForm.get(key)?.setValue(locked); + const fieldName = key.replace('Locked', ''); + if (locked) { + metadataForm.get(fieldName)?.disable(); + } else { + metadataForm.get(fieldName)?.enable(); + } + } + } + } +} diff --git a/booklore-ui/src/app/shared/metadata/metadata-utils.service.ts b/booklore-ui/src/app/shared/metadata/metadata-utils.service.ts new file mode 100644 index 000000000..6e6d42533 --- /dev/null +++ b/booklore-ui/src/app/shared/metadata/metadata-utils.service.ts @@ -0,0 +1,149 @@ +import {Injectable} from '@angular/core'; +import {FormGroup} from '@angular/forms'; +import {BookMetadata} from '../../features/book/model/book.model'; + +@Injectable({ + providedIn: 'root' +}) +export class MetadataUtilsService { + + copyFieldToForm( + field: string, + fetchedMetadata: BookMetadata, + metadataForm: FormGroup, + copiedFields: Record + ): boolean { + const value = fetchedMetadata[field as keyof BookMetadata]; + if (value !== null && value !== undefined && value !== '') { + metadataForm.get(field)?.setValue(value); + copiedFields[field] = true; + return true; + } + return false; + } + + copyMissingFields( + fetchedMetadata: BookMetadata, + metadataForm: FormGroup, + copiedFields: Record, + copyCallback: (field: string) => void + ): void { + for (const field of Object.keys(fetchedMetadata)) { + const isLocked = metadataForm.get(`${field}Locked`)?.value; + const currentValue = metadataForm.get(field)?.value; + const fetchedValue = fetchedMetadata[field as keyof BookMetadata]; + + const isEmpty = Array.isArray(currentValue) + ? currentValue.length === 0 + : (currentValue === null || currentValue === undefined || currentValue === ''); + + const hasFetchedValue = fetchedValue !== null && fetchedValue !== undefined && fetchedValue !== ''; + + if (!isLocked && isEmpty && hasFetchedValue) { + copyCallback(field); + } + } + } + + copyAllFields( + fetchedMetadata: BookMetadata, + metadataForm: FormGroup, + copyCallback: (field: string) => void, + excludeFields: string[] = ['thumbnailUrl', 'bookId'] + ): void { + for (const field of Object.keys(fetchedMetadata)) { + if (excludeFields.includes(field)) continue; + + const isLocked = metadataForm.get(`${field}Locked`)?.value; + const fetchedValue = fetchedMetadata[field as keyof BookMetadata]; + + if (!isLocked && fetchedValue != null && fetchedValue !== '') { + copyCallback(field); + } + } + } + + isValueEmpty(value: unknown): boolean { + if (value === null || value === undefined || value === '') return true; + if (Array.isArray(value)) return value.length === 0; + return false; + } + + areFieldsEqual(field1: unknown, field2: unknown): boolean { + const [normalized1, normalized2] = this.normalizeForComparison(field1, field2); + return normalized1 === normalized2; + } + + normalizeForComparison(field1: unknown, field2: unknown): [string | undefined, string | undefined] { + let val1: string | undefined = undefined; + let val2: string | undefined = undefined; + + if (Array.isArray(field1)) { + val1 = field1.length > 0 ? JSON.stringify([...field1].sort()) : undefined; + } else if (field1 != null && field1 !== '') { + val1 = String(field1); + } + + if (Array.isArray(field2)) { + val2 = field2.length > 0 ? JSON.stringify([...field2].sort()) : undefined; + } else if (field2 != null && field2 !== '') { + val2 = String(field2); + } + + return [val1, val2]; + } + + isValueChanged(field: string, metadataForm: FormGroup, originalMetadata?: BookMetadata): boolean { + if (!originalMetadata) return false; + const [current, original] = this.normalizeForComparison( + metadataForm.get(field)?.value, + originalMetadata[field as keyof BookMetadata] + ); + return (!!current && current !== original) || (!current && !!original); + } + + isFetchedDifferent(field: string, metadataForm: FormGroup, fetchedMetadata: BookMetadata): boolean { + const [current, fetched] = this.normalizeForComparison( + metadataForm.get(field)?.value, + fetchedMetadata[field as keyof BookMetadata] + ); + return !!fetched && fetched !== current; + } + + resetField( + field: string, + metadataForm: FormGroup, + originalMetadata: BookMetadata | undefined, + copiedFields: Record, + hoveredFields?: Record + ): void { + metadataForm.get(field)?.setValue(originalMetadata?.[field as keyof BookMetadata]); + copiedFields[field] = false; + if (hoveredFields) { + hoveredFields[field] = false; + } + } + + patchMetadataToForm( + metadata: BookMetadata, + metadataForm: FormGroup, + fields: Array<{ controlName: string; lockedKey: string; type: string }> + ): void { + const patchData: Record = {}; + + for (const field of fields) { + const key = field.controlName as keyof BookMetadata; + const lockedKey = field.lockedKey as keyof BookMetadata; + const value = metadata[key]; + + if (field.type === 'array') { + patchData[field.controlName] = [...(value as string[] ?? [])].sort(); + } else { + patchData[field.controlName] = value ?? null; + } + patchData[field.lockedKey] = metadata[lockedKey] ?? false; + } + + metadataForm.patchValue(patchData); + } +}