mirror of
https://github.com/booklore-app/booklore.git
synced 2026-02-18 00:17:53 +01:00
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:
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
3
booklore-ui/src/app/shared/metadata/index.ts
Normal file
3
booklore-ui/src/app/shared/metadata/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from './metadata-field.config';
|
||||
export * from './metadata-utils.service';
|
||||
export * from './metadata-form.builder';
|
||||
79
booklore-ui/src/app/shared/metadata/metadata-field.config.ts
Normal file
79
booklore-ui/src/app/shared/metadata/metadata-field.config.ts
Normal 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;
|
||||
}
|
||||
75
booklore-ui/src/app/shared/metadata/metadata-form.builder.ts
Normal file
75
booklore-ui/src/app/shared/metadata/metadata-form.builder.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
149
booklore-ui/src/app/shared/metadata/metadata-utils.service.ts
Normal file
149
booklore-ui/src/app/shared/metadata/metadata-utils.service.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user