Refactor metadata picker and parsers for Amazon, Goodreads, and Google (#2327)

Co-authored-by: acx10 <acx10@users.noreply.github.com>
This commit is contained in:
ACX
2026-01-18 14:01:50 -07:00
committed by GitHub
parent 0327ee802e
commit c42918f05b
9 changed files with 788 additions and 969 deletions

View File

@@ -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<String, LocaleInfo> 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<BookReview> 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<String> getAuthors(Document doc) {
Set<String> 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<BookReview> getReviews(Document doc, int maxReviews) {
List<BookReview> 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 <br> 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 <br> tags
while (next != null && "br".equals(next.tagName())) {
consecutiveBrCount++;
Element temp = next;
next = next.nextElementSibling();
// Remove extra <br> tags beyond the first two
if (consecutiveBrCount > 2) {
temp.remove();
}
}
}
// Clean up any remaining whitespace issues
String cleanedHtml = document.body().html();
// Replace multiple consecutive <br> patterns that might still exist
cleanedHtml = cleanedHtml.replaceAll("(<br>\\s*){3,}", "<br><br>");
cleanedHtml = cleanedHtml.replaceAll("(<br\\s*/?>\\s*){3,}", "<br><br>");
// Remove leading/trailing <br> tags
cleanedHtml = cleanedHtml.replaceAll("^(\\s*<br\\s*/?>\\s*)+", "");
cleanedHtml = cleanedHtml.replaceAll("(\\s*<br\\s*/?>\\s*)+$", "");
return cleanedHtml;
} catch (Exception e) {
log.warn("Error cleaning html description, Error: {}", e.getMessage());
}
return html;
}
}

View File

@@ -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<BookMetadata> 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<String> 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<String> 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<String> 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<String> 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 extends Number> T parseNumber(String value, Function<String, T> 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<String> getJsonKeys(JSONObject apolloStateJson) {
LinkedHashSet<String> keySet = new LinkedHashSet<>();
Iterator<String> keys = apolloStateJson.keys();
@@ -368,54 +344,40 @@ public class GoodReadsParser implements BookParser {
return keySet;
}
private JSONObject getValidBookJson(JSONObject apolloStateJson, LinkedHashSet<String> keySet, String prefix) {
private JSONObject getValidBookJson(JSONObject apolloStateJson, LinkedHashSet<String> 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<String> 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<String> extractGenres(JSONObject bookJson) {
@@ -495,7 +457,6 @@ public class GoodReadsParser implements BookParser {
for (Element previewBook : previewBooks) {
Set<String> authors = extractAuthorsPreview(previewBook);
// Author fuzzy match if author provided
if (queryAuthor != null && !queryAuthor.isBlank()) {
List<String> queryAuthorTokens = List.of(WHITESPACE_PATTERN.split(queryAuthor.toLowerCase()));
boolean matches = authors.stream()

View File

@@ -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<BookMetadata> 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<String> 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<BookMetadata> getMetadataListByTerm(String term) {
return fetchFromApi(term);
}
private List<BookMetadata> 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<String, String> 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<String, String> extractISBNs(List<GoogleBooksApiResponse.Item.IndustryIdentifier> 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;
}

View File

@@ -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<boolean>();
// 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<string, unknown> = {};
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);

View File

@@ -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<Book | null>;
@Output() goBack = new EventEmitter<boolean>();
allAuthors!: string[];
allCategories!: string[];
allMoods!: string[];
allTags!: string[];
filteredCategories: string[] = [];
filteredAuthors: string[] = [];
filteredMoods: string[] = [];
filteredTags: string[] = [];
private allItems: Record<string, string[]> = {};
filteredItems: Record<string, string[]> = {};
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<string, boolean> = {};
savedFields: Record<string, boolean> = {};
originalMetadata!: BookMetadata;
isSaving = false;
hoveredFields: Record<string, boolean> = {};
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<string>();
const categories = new Set<string>();
const moods = new Set<string>();
const tags = new Set<string>();
const itemSets: Record<string, Set<string>> = {
authors: new Set<string>(),
categories: new Set<string>(),
moods: new Set<string>(),
tags: new Set<string>()
};
(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<string, unknown> = {};
const providerFieldMap: Record<string, keyof MetadataProviderSpecificFields> = {
'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<string, boolean> = {};
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<string, unknown> = {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<string, boolean> = {};
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<string, boolean> = {};
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);
}
}

View File

@@ -0,0 +1,3 @@
export * from './metadata-field.config';
export * from './metadata-utils.service';
export * from './metadata-form.builder';

View File

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

View File

@@ -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<string, FormControl> = {};
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<string, boolean>,
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();
}
}
}
}
}

View File

@@ -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<string, boolean>
): 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<string, boolean>,
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<string, boolean>,
hoveredFields?: Record<string, boolean>
): 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<string, unknown> = {};
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);
}
}