mirror of
https://github.com/booklore-app/booklore.git
synced 2026-02-18 00:17:53 +01:00
feat(metadata): add custom metadata write to file support for moods, tags, ratings, and external IDs in PDF, and CBZ (#2552)
* feat(metadata): add BookLore custom metadata support for moods, tags, ratings, and external IDs in EPUB, PDF, and CBZ - Introduce BookLoreSchema for XMP custom fields and BookLoreMetadata for namespace constants - Write and extract moods, tags, ratings, and external IDs (Goodreads, Amazon, Hardcover, Lubimyczytac, RanobeDB, Google) in EPUB, PDF, and CBZ metadata - Store BookLore custom fields in ComicInfo.xml notes and Web fields for CBZ - Add BookLore fields to XMP metadata in PDF, including moods and tags as separate fields - Update extractors and writers to handle BookLore fields and ensure separation of categories, moods, and tags - Add comprehensive tests for BookLore metadata extraction and writing Signed-off-by: Balázs Szücs <bszucs1209@gmail.com> * refactor(metadata): standardize hardcoverBookId as String and improve identifier handling - Change hardcoverBookId field and related methods to use String instead of Integer for consistency across extractors and writers - Update metadata copy helpers and tests to reflect new hardcoverBookId type - Improve identifier prefix handling in EPUB and PDF extractors to be case-insensitive and trim whitespace - Allow PDF keywords to be split by comma or semicolon - Rename TaskCreateRequest#getOptions to getOptionsAs for clarity and update usages - Adjust series number writing in EPUB metadata to preserve decimals when needed Signed-off-by: Balázs Szücs <bszucs1209@gmail.com> * refactor(metadata): standardize usage of tags over categories and improve BookLore field extraction Signed-off-by: Balázs Szücs <bszucs1209@gmail.com> * feat(metadata): add rating and ratingLocked fields to BookMetadata and improve PDF metadata extraction - Add rating and ratingLocked fields to BookMetadata for enhanced rating support - Refine PDF keyword extraction to handle both comma and semicolon delimiters - Ensure ISBN fields are only set if cleaned values are non-blank - Use temp file only stream cache when loading PDFs for metadata writing Signed-off-by: Balázs Szücs <bszucs1209@gmail.com> * feat(metadata): implement ComicInfo.xml writing via JAXB, ensure XSD compliance and add tests for CBX metadata persistence Signed-off-by: Balázs Szücs <bszucs1209@gmail.com> * feat(metadata): enhance metadata extraction with additional identifiers and improve parsing logic Signed-off-by: Balázs Szücs <bszucs1209@gmail.com> * feat(metadata): improve metadata handling by ensuring descriptions are prioritized and enhancing keyword management for PDF compatibility Signed-off-by: Balázs Szücs <bszucs1209@gmail.com> * refactor(metadata): simplify Pages class structure in ComicInfo.java Signed-off-by: Balázs Szücs <bszucs1209@gmail.com> * feat(metadata): refine PDF metadata writing by isolating categories for legacy compatibility and removing moods and tags from keywords Signed-off-by: Balázs Szücs <bszucs1209@gmail.com> * feat(metadata): enhance PDF metadata extraction and writing with support for additional fields and improved handling of series information Signed-off-by: Balázs Szücs <bszucs1209@gmail.com> * feat(metadata): enhance PDF metadata extraction and writing with support for additional fields and improved handling of series information Signed-off-by: Balázs Szücs <bszucs1209@gmail.com> * feat(metadata): implement secure XML parsing utility and enhance metadata extraction with improved GTIN validation and debug logging Signed-off-by: Balázs Szücs <bszucs1209@gmail.com> * refactor(metadata): update package structure to align with new organization naming conventions Signed-off-by: Balázs Szücs <bszucs1209@gmail.com> * fix(epub-metadata): streamline cover image extraction logic and improve error handling Signed-off-by: Balázs Szücs <bszucs1209@gmail.com> * feat(metadata): enhance PDF and comic metadata extraction with additional fields Signed-off-by: Balázs Szücs <bszucs1209@gmail.com> * feat(metadata): migrate to tools.jackson for JSON processing in sidecar metadata handling Signed-off-by: Balázs Szücs <bszucs1209@gmail.com> * fix(epub-metadata): streamline cover image extraction and enhance XML parsing security Signed-off-by: Balázs Szücs <bszucs1209@gmail.com> --------- Signed-off-by: Balázs Szücs <bszucs1209@gmail.com>
This commit is contained in:
@@ -90,6 +90,10 @@ dependencies {
|
||||
implementation 'org.tukaani:xz:1.11' // Required by commons-compress for 7z support
|
||||
implementation 'org.apache.commons:commons-text:1.15.0'
|
||||
|
||||
// --- XML Support (JAXB) ---
|
||||
implementation 'jakarta.xml.bind:jakarta.xml.bind-api:4.0.4'
|
||||
runtimeOnly 'org.glassfish.jaxb:jaxb-runtime:4.0.6'
|
||||
|
||||
// --- Template Engine ---
|
||||
implementation 'org.freemarker:freemarker:2.3.34'
|
||||
|
||||
|
||||
@@ -24,6 +24,11 @@ import java.io.IOException;
|
||||
import java.time.Instant;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* JWT filter for EPUB streaming endpoints that supports both:
|
||||
* 1. Authorization header (Bearer token) - for fetch() requests
|
||||
* 2. Query parameter (token) - for browser-initiated requests (fonts, images in CSS)
|
||||
*/
|
||||
@Component
|
||||
@AllArgsConstructor
|
||||
public class EpubStreamingJwtFilter extends OncePerRequestFilter {
|
||||
@@ -38,6 +43,7 @@ public class EpubStreamingJwtFilter extends OncePerRequestFilter {
|
||||
@Override
|
||||
protected boolean shouldNotFilter(HttpServletRequest request) {
|
||||
String path = request.getRequestURI();
|
||||
// Only filter requests to EPUB file streaming endpoint
|
||||
return !EPUB_STREAMING_ENDPOINT_PATTERN.matcher(path).matches();
|
||||
}
|
||||
|
||||
@@ -45,6 +51,7 @@ public class EpubStreamingJwtFilter extends OncePerRequestFilter {
|
||||
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
|
||||
throws ServletException, IOException {
|
||||
|
||||
// Try Authorization header first, then fall back to query parameter
|
||||
String token = extractTokenFromHeader(request);
|
||||
if (token == null) {
|
||||
token = request.getParameter("token");
|
||||
|
||||
@@ -28,4 +28,8 @@ public interface BookMetadataMapper {
|
||||
@Mapping(target = "tags", ignore = true)
|
||||
BookMetadata toBookMetadataWithoutRelations(BookMetadataEntity bookMetadataEntity, @Context boolean includeDescription);
|
||||
|
||||
default BookMetadata toBookMetadata(BookMetadataEntity bookMetadataEntity) {
|
||||
return toBookMetadata(bookMetadataEntity, true);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -68,6 +68,7 @@ public class BookMetadata {
|
||||
private MetadataProvider provider;
|
||||
private String thumbnailUrl;
|
||||
private List<BookReview> bookReviews;
|
||||
private Double rating;
|
||||
|
||||
private Boolean titleLocked;
|
||||
private Boolean subtitleLocked;
|
||||
|
||||
@@ -30,7 +30,7 @@ public class TaskCreateRequest {
|
||||
})
|
||||
private Object options;
|
||||
|
||||
public <T> T getOptions(Class<T> optionsClass) {
|
||||
public <T> T getOptionsAs(Class<T> optionsClass) {
|
||||
if (options == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -35,6 +35,7 @@ import java.io.File;
|
||||
import java.io.InputStream;
|
||||
import java.util.*;
|
||||
import java.util.regex.Pattern;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import static org.booklore.util.FileService.truncate;
|
||||
|
||||
@@ -224,7 +225,10 @@ public class CbxProcessor extends AbstractFileProcessor implements BookFileProce
|
||||
}
|
||||
|
||||
BookMetadataEntity metadata = bookEntity.getMetadata();
|
||||
|
||||
// Basic fields
|
||||
metadata.setTitle(truncate(extracted.getTitle(), 1000));
|
||||
metadata.setSubtitle(truncate(extracted.getSubtitle(), 1000));
|
||||
metadata.setDescription(truncate(extracted.getDescription(), 5000));
|
||||
metadata.setPublisher(truncate(extracted.getPublisher(), 1000));
|
||||
metadata.setPublishedDate(extracted.getPublishedDate());
|
||||
@@ -233,12 +237,58 @@ public class CbxProcessor extends AbstractFileProcessor implements BookFileProce
|
||||
metadata.setSeriesTotal(extracted.getSeriesTotal());
|
||||
metadata.setPageCount(extracted.getPageCount());
|
||||
metadata.setLanguage(truncate(extracted.getLanguage(), 1000));
|
||||
|
||||
// ISBN fields
|
||||
metadata.setIsbn13(truncate(extracted.getIsbn13(), 64));
|
||||
metadata.setIsbn10(truncate(extracted.getIsbn10(), 64));
|
||||
|
||||
// External IDs
|
||||
metadata.setAsin(truncate(extracted.getAsin(), 20));
|
||||
metadata.setGoodreadsId(truncate(extracted.getGoodreadsId(), 100));
|
||||
metadata.setHardcoverId(truncate(extracted.getHardcoverId(), 100));
|
||||
metadata.setHardcoverBookId(truncate(extracted.getHardcoverBookId(), 100));
|
||||
metadata.setGoogleId(truncate(extracted.getGoogleId(), 100));
|
||||
metadata.setComicvineId(truncate(extracted.getComicvineId(), 100));
|
||||
metadata.setLubimyczytacId(truncate(extracted.getLubimyczytacId(), 100));
|
||||
metadata.setRanobedbId(truncate(extracted.getRanobedbId(), 100));
|
||||
|
||||
// Ratings
|
||||
metadata.setAmazonRating(extracted.getAmazonRating());
|
||||
metadata.setAmazonReviewCount(extracted.getAmazonReviewCount());
|
||||
metadata.setGoodreadsRating(extracted.getGoodreadsRating());
|
||||
metadata.setGoodreadsReviewCount(extracted.getGoodreadsReviewCount());
|
||||
metadata.setHardcoverRating(extracted.getHardcoverRating());
|
||||
metadata.setHardcoverReviewCount(extracted.getHardcoverReviewCount());
|
||||
metadata.setLubimyczytacRating(extracted.getLubimyczytacRating());
|
||||
metadata.setRanobedbRating(extracted.getRanobedbRating());
|
||||
|
||||
// Authors
|
||||
if (extracted.getAuthors() != null) {
|
||||
bookCreatorService.addAuthorsToBook(extracted.getAuthors(), bookEntity);
|
||||
}
|
||||
|
||||
// Categories
|
||||
if (extracted.getCategories() != null) {
|
||||
bookCreatorService.addCategoriesToBook(extracted.getCategories(), bookEntity);
|
||||
Set<String> validCategories = extracted.getCategories().stream()
|
||||
.filter(s -> s != null && !s.isBlank() && s.length() <= 100 && !s.contains("\n") && !s.contains("\r") && !s.contains(" "))
|
||||
.collect(Collectors.toSet());
|
||||
bookCreatorService.addCategoriesToBook(validCategories, bookEntity);
|
||||
}
|
||||
|
||||
// Moods
|
||||
if (extracted.getMoods() != null && !extracted.getMoods().isEmpty()) {
|
||||
Set<String> validMoods = extracted.getMoods().stream()
|
||||
.filter(s -> s != null && !s.isBlank() && s.length() <= 255)
|
||||
.collect(Collectors.toSet());
|
||||
bookCreatorService.addMoodsToBook(validMoods, bookEntity);
|
||||
}
|
||||
|
||||
// Tags
|
||||
if (extracted.getTags() != null && !extracted.getTags().isEmpty()) {
|
||||
Set<String> validTags = extracted.getTags().stream()
|
||||
.filter(s -> s != null && !s.isBlank() && s.length() <= 255)
|
||||
.collect(Collectors.toSet());
|
||||
bookCreatorService.addTagsToBook(validTags, bookEntity);
|
||||
}
|
||||
if (extracted.getComicMetadata() != null) {
|
||||
saveComicMetadata(bookEntity, extracted.getComicMetadata());
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
package org.booklore.service.fileprocessor;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.apache.pdfbox.Loader;
|
||||
import org.apache.pdfbox.io.RandomAccessReadBufferedFile;
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.apache.pdfbox.rendering.ImageType;
|
||||
import org.apache.pdfbox.rendering.PDFRenderer;
|
||||
import org.booklore.mapper.BookMapper;
|
||||
import org.booklore.model.dto.BookMetadata;
|
||||
import org.booklore.model.dto.settings.LibraryFile;
|
||||
@@ -15,13 +22,6 @@ import org.booklore.service.metadata.sidecar.SidecarMetadataWriter;
|
||||
import org.booklore.util.BookCoverUtils;
|
||||
import org.booklore.util.FileService;
|
||||
import org.booklore.util.FileUtils;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.apache.pdfbox.Loader;
|
||||
import org.apache.pdfbox.io.RandomAccessReadBufferedFile;
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.apache.pdfbox.rendering.ImageType;
|
||||
import org.apache.pdfbox.rendering.PDFRenderer;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.awt.image.BufferedImage;
|
||||
@@ -102,12 +102,18 @@ public class PdfProcessor extends AbstractFileProcessor implements BookFileProce
|
||||
if (StringUtils.isNotBlank(extracted.getTitle())) {
|
||||
bookEntity.getMetadata().setTitle(truncate(extracted.getTitle(), 1000));
|
||||
}
|
||||
if (StringUtils.isNotBlank(extracted.getSubtitle())) {
|
||||
bookEntity.getMetadata().setSubtitle(truncate(extracted.getSubtitle(), 1000));
|
||||
}
|
||||
if (StringUtils.isNotBlank(extracted.getSeriesName())) {
|
||||
bookEntity.getMetadata().setSeriesName(truncate(extracted.getSeriesName(), 1000));
|
||||
}
|
||||
if (extracted.getSeriesNumber() != null) {
|
||||
bookEntity.getMetadata().setSeriesNumber(extracted.getSeriesNumber());
|
||||
}
|
||||
if (extracted.getSeriesTotal() != null) {
|
||||
bookEntity.getMetadata().setSeriesTotal(extracted.getSeriesTotal());
|
||||
}
|
||||
if (extracted.getAuthors() != null) {
|
||||
bookCreatorService.addAuthorsToBook(extracted.getAuthors(), bookEntity);
|
||||
}
|
||||
@@ -123,6 +129,11 @@ public class PdfProcessor extends AbstractFileProcessor implements BookFileProce
|
||||
if (StringUtils.isNotBlank(extracted.getLanguage())) {
|
||||
bookEntity.getMetadata().setLanguage(extracted.getLanguage());
|
||||
}
|
||||
if (extracted.getPageCount() != null) {
|
||||
bookEntity.getMetadata().setPageCount(extracted.getPageCount());
|
||||
}
|
||||
|
||||
// External IDs
|
||||
if (StringUtils.isNotBlank(extracted.getAsin())) {
|
||||
bookEntity.getMetadata().setAsin(extracted.getAsin());
|
||||
}
|
||||
@@ -132,6 +143,9 @@ public class PdfProcessor extends AbstractFileProcessor implements BookFileProce
|
||||
if (StringUtils.isNotBlank(extracted.getHardcoverId())) {
|
||||
bookEntity.getMetadata().setHardcoverId(extracted.getHardcoverId());
|
||||
}
|
||||
if (StringUtils.isNotBlank(extracted.getHardcoverBookId())) {
|
||||
bookEntity.getMetadata().setHardcoverBookId(extracted.getHardcoverBookId());
|
||||
}
|
||||
if (StringUtils.isNotBlank(extracted.getGoodreadsId())) {
|
||||
bookEntity.getMetadata().setGoodreadsId(extracted.getGoodreadsId());
|
||||
}
|
||||
@@ -141,15 +155,46 @@ public class PdfProcessor extends AbstractFileProcessor implements BookFileProce
|
||||
if (StringUtils.isNotBlank(extracted.getRanobedbId())) {
|
||||
bookEntity.getMetadata().setRanobedbId(extracted.getRanobedbId());
|
||||
}
|
||||
if (StringUtils.isNotBlank(extracted.getLubimyczytacId())) {
|
||||
bookEntity.getMetadata().setLubimyczytacId(extracted.getLubimyczytacId());
|
||||
}
|
||||
if (StringUtils.isNotBlank(extracted.getIsbn10())) {
|
||||
bookEntity.getMetadata().setIsbn10(extracted.getIsbn10());
|
||||
}
|
||||
if (StringUtils.isNotBlank(extracted.getIsbn13())) {
|
||||
bookEntity.getMetadata().setIsbn13(extracted.getIsbn13());
|
||||
}
|
||||
if (extracted.getCategories() != null) {
|
||||
|
||||
// Categories, moods, and tags
|
||||
if (extracted.getCategories() != null && !extracted.getCategories().isEmpty()) {
|
||||
bookCreatorService.addCategoriesToBook(extracted.getCategories(), bookEntity);
|
||||
}
|
||||
if (extracted.getMoods() != null && !extracted.getMoods().isEmpty()) {
|
||||
bookCreatorService.addMoodsToBook(extracted.getMoods(), bookEntity);
|
||||
}
|
||||
if (extracted.getTags() != null && !extracted.getTags().isEmpty()) {
|
||||
bookCreatorService.addTagsToBook(extracted.getTags(), bookEntity);
|
||||
}
|
||||
|
||||
// Ratings
|
||||
if (extracted.getAmazonRating() != null) {
|
||||
bookEntity.getMetadata().setAmazonRating(extracted.getAmazonRating());
|
||||
}
|
||||
if (extracted.getGoodreadsRating() != null) {
|
||||
bookEntity.getMetadata().setGoodreadsRating(extracted.getGoodreadsRating());
|
||||
}
|
||||
if (extracted.getHardcoverRating() != null) {
|
||||
bookEntity.getMetadata().setHardcoverRating(extracted.getHardcoverRating());
|
||||
}
|
||||
if (extracted.getLubimyczytacRating() != null) {
|
||||
bookEntity.getMetadata().setLubimyczytacRating(extracted.getLubimyczytacRating());
|
||||
}
|
||||
if (extracted.getRanobedbRating() != null) {
|
||||
bookEntity.getMetadata().setRanobedbRating(extracted.getRanobedbRating());
|
||||
}
|
||||
if (extracted.getRating() != null) {
|
||||
bookEntity.getMetadata().setRating(extracted.getRating());
|
||||
}
|
||||
|
||||
} catch (Exception e) {
|
||||
log.warn("Failed to extract PDF metadata for '{}': {}", bookEntity.getPrimaryBookFile().getFileName(), e.getMessage());
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
package org.booklore.service.metadata;
|
||||
|
||||
public class BookLoreMetadata {
|
||||
public static final String NS_URI = "http://booklore.org/metadata/1.0/";
|
||||
public static final String NS_PREFIX = "booklore";
|
||||
}
|
||||
@@ -197,7 +197,7 @@ public class BookMetadataService {
|
||||
.build();
|
||||
|
||||
bookMetadataUpdater.setBookMetadata(context);
|
||||
notificationService.sendMessage(Topic.BOOK_UPDATE, bookMapper.toBook(book));
|
||||
notificationService.sendMessage(Topic.BOOK_UPDATE, bookMapper.toBookWithDescription(book, true));
|
||||
return null;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -311,7 +311,7 @@ public class MetadataRefreshService {
|
||||
if (context.getMetadataUpdateWrapper() != null && context.getMetadataUpdateWrapper().getMetadata() != null) {
|
||||
bookMetadataUpdater.setBookMetadata(context);
|
||||
|
||||
Book book = bookMapper.toBook(context.getBookEntity());
|
||||
Book book = bookMapper.toBookWithDescription(context.getBookEntity(), true);
|
||||
|
||||
BookLoreUser user = authenticationService.getAuthenticatedUser();
|
||||
if (user != null && book.getShelves() != null) {
|
||||
|
||||
@@ -36,6 +36,14 @@ public class CbxMetadataExtractor implements FileMetadataExtractor {
|
||||
|
||||
private static final Pattern LEADING_ZEROS_PATTERN = Pattern.compile("^0+");
|
||||
private static final Pattern COMMA_SEMICOLON_PATTERN = Pattern.compile("[,;]");
|
||||
private static final Pattern BOOKLORE_NOTE_PATTERN = Pattern.compile("\\[BookLore:([^\\]]+)\\]\\s*(.*)");
|
||||
private static final Pattern WEB_SPLIT_PATTERN = Pattern.compile("[,;\\s]+");
|
||||
|
||||
// URL Patterns
|
||||
private static final Pattern GOODREADS_URL_PATTERN = Pattern.compile("goodreads\\.com/book/show/(\\d+)(?:-[\\w-]+)?");
|
||||
private static final Pattern AMAZON_URL_PATTERN = Pattern.compile("amazon\\.com/dp/([A-Z0-9]{10})");
|
||||
private static final Pattern COMICVINE_URL_PATTERN = Pattern.compile("comicvine\\.gamespot\\.com/issue/(?:[^/]+/)?([\\w-]+)");
|
||||
private static final Pattern HARDCOVER_URL_PATTERN = Pattern.compile("hardcover\\.app/books/([\\w-]+)");
|
||||
|
||||
@Override
|
||||
public BookMetadata extractMetadata(File file) {
|
||||
@@ -193,6 +201,18 @@ public class CbxMetadataExtractor implements FileMetadataExtractor {
|
||||
);
|
||||
builder.language(getTextContent(document, "LanguageISO"));
|
||||
|
||||
// GTIN is the standard ComicInfo field for ISBN (EAN/UPC)
|
||||
// Validate it's a 13-digit number (ISBN-13/EAN-13)
|
||||
String gtin = getTextContent(document, "GTIN");
|
||||
if (gtin != null && !gtin.isBlank()) {
|
||||
String normalized = gtin.replaceAll("[- ]", "");
|
||||
if (normalized.matches("\\d{13}")) {
|
||||
builder.isbn13(normalized);
|
||||
} else {
|
||||
log.debug("Invalid GTIN format (expected 13 digits): {}", gtin);
|
||||
}
|
||||
}
|
||||
|
||||
Set<String> authors = new HashSet<>();
|
||||
authors.addAll(splitValues(getTextContent(document, "Writer")));
|
||||
if (!authors.isEmpty()) {
|
||||
@@ -201,11 +221,16 @@ public class CbxMetadataExtractor implements FileMetadataExtractor {
|
||||
|
||||
Set<String> categories = new HashSet<>();
|
||||
categories.addAll(splitValues(getTextContent(document, "Genre")));
|
||||
categories.addAll(splitValues(getTextContent(document, "Tags")));
|
||||
if (!categories.isEmpty()) {
|
||||
builder.categories(categories);
|
||||
}
|
||||
|
||||
Set<String> tags = new HashSet<>();
|
||||
tags.addAll(splitValues(getTextContent(document, "Tags")));
|
||||
if (!tags.isEmpty()) {
|
||||
builder.tags(tags);
|
||||
}
|
||||
|
||||
// Extract comic-specific metadata
|
||||
ComicMetadata.ComicMetadataBuilder comicBuilder = ComicMetadata.builder();
|
||||
boolean hasComicFields = false;
|
||||
@@ -325,21 +350,123 @@ public class CbxMetadataExtractor implements FileMetadataExtractor {
|
||||
if (web != null && !web.isBlank()) {
|
||||
comicBuilder.webLink(web);
|
||||
hasComicFields = true;
|
||||
// Also parse the web field for IDs (goodreads, comicvine, etc.)
|
||||
parseWebField(web, builder);
|
||||
}
|
||||
|
||||
String notes = getTextContent(document, "Notes");
|
||||
if (notes != null && !notes.isBlank()) {
|
||||
comicBuilder.notes(notes);
|
||||
hasComicFields = true;
|
||||
parseNotes(notes, builder);
|
||||
|
||||
// Store whether we already have a description from Summary/Description XML elements
|
||||
String existingDescription = coalesce(
|
||||
getTextContent(document, "Summary"),
|
||||
getTextContent(document, "Description")
|
||||
);
|
||||
boolean hasDescription = existingDescription != null && !existingDescription.isBlank();
|
||||
|
||||
// If description is missing, use cleaned notes (removing BookLore tags)
|
||||
if (!hasDescription) {
|
||||
String cleanedNotes = notes.replaceAll("\\[BookLore:[^\\]]+\\][^\\n]*(\n|$)", "").trim();
|
||||
if (!cleanedNotes.isEmpty()) {
|
||||
builder.description(cleanedNotes);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (hasComicFields) {
|
||||
builder.comicMetadata(comicBuilder.build());
|
||||
}
|
||||
|
||||
return builder.build();
|
||||
}
|
||||
|
||||
private void parseWebField(String web, BookMetadata.BookMetadataBuilder builder) {
|
||||
String[] urls = WEB_SPLIT_PATTERN.split(web);
|
||||
for (String url : urls) {
|
||||
if (url.isBlank()) continue;
|
||||
url = url.trim();
|
||||
|
||||
java.util.regex.Matcher grMatcher = GOODREADS_URL_PATTERN.matcher(url);
|
||||
if (grMatcher.find()) {
|
||||
builder.goodreadsId(grMatcher.group(1));
|
||||
continue;
|
||||
}
|
||||
|
||||
java.util.regex.Matcher azMatcher = AMAZON_URL_PATTERN.matcher(url);
|
||||
if (azMatcher.find()) {
|
||||
builder.asin(azMatcher.group(1));
|
||||
continue;
|
||||
}
|
||||
|
||||
java.util.regex.Matcher cvMatcher = COMICVINE_URL_PATTERN.matcher(url);
|
||||
if (cvMatcher.find()) {
|
||||
builder.comicvineId(cvMatcher.group(1));
|
||||
continue;
|
||||
}
|
||||
|
||||
java.util.regex.Matcher hcMatcher = HARDCOVER_URL_PATTERN.matcher(url);
|
||||
if (hcMatcher.find()) {
|
||||
builder.hardcoverId(hcMatcher.group(1));
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void parseNotes(String notes, BookMetadata.BookMetadataBuilder builder) {
|
||||
java.util.regex.Matcher matcher = BOOKLORE_NOTE_PATTERN.matcher(notes);
|
||||
while (matcher.find()) {
|
||||
String key = matcher.group(1).trim();
|
||||
String value = matcher.group(2).trim();
|
||||
|
||||
switch (key) {
|
||||
case "Moods" -> {
|
||||
if (!value.isEmpty()) builder.moods(splitValues(value));
|
||||
}
|
||||
case "Tags" -> {
|
||||
if (!value.isEmpty()) {
|
||||
Set<String> tags = splitValues(value);
|
||||
BookMetadata current = builder.build();
|
||||
if (current.getTags() != null) tags.addAll(current.getTags());
|
||||
builder.tags(tags);
|
||||
}
|
||||
}
|
||||
case "Subtitle" -> builder.subtitle(value);
|
||||
case "ISBN13" -> builder.isbn13(value);
|
||||
case "ISBN10" -> builder.isbn10(value);
|
||||
case "AmazonRating" -> safeParseDouble(value, builder::amazonRating);
|
||||
case "GoodreadsRating" -> safeParseDouble(value, builder::goodreadsRating);
|
||||
case "HardcoverRating" -> safeParseDouble(value, builder::hardcoverRating);
|
||||
case "LubimyczytacRating" -> safeParseDouble(value, builder::lubimyczytacRating);
|
||||
case "RanobedbRating" -> safeParseDouble(value, builder::ranobedbRating);
|
||||
case "HardcoverBookId" -> builder.hardcoverBookId(value);
|
||||
case "HardcoverId" -> builder.hardcoverId(value);
|
||||
case "LubimyczytacId" -> builder.lubimyczytacId(value);
|
||||
case "RanobedbId" -> builder.ranobedbId(value);
|
||||
case "GoogleId" -> builder.googleId(value);
|
||||
case "GoodreadsId" -> builder.goodreadsId(value);
|
||||
case "ASIN" -> builder.asin(value);
|
||||
case "ComicvineId" -> builder.comicvineId(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void safeParseDouble(String value, java.util.function.DoubleConsumer consumer) {
|
||||
try {
|
||||
consumer.accept(Double.parseDouble(value));
|
||||
} catch (NumberFormatException e) {
|
||||
log.debug("Failed to parse double from value: {}", value);
|
||||
}
|
||||
}
|
||||
|
||||
private void safeParseInt(String value, java.util.function.IntConsumer consumer) {
|
||||
try {
|
||||
consumer.accept(Integer.parseInt(value));
|
||||
} catch (NumberFormatException e) {
|
||||
log.debug("Failed to parse int from value: {}", value);
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Extracts and trims text content from the first element with the given tag name.
|
||||
*
|
||||
@@ -812,14 +939,7 @@ public class CbxMetadataExtractor implements FileMetadataExtractor {
|
||||
return null;
|
||||
}
|
||||
|
||||
private FileHeader findFirstImageHeader(Archive archive) {
|
||||
for (FileHeader fh : archive.getFileHeaders()) {
|
||||
if (!fh.isDirectory() && isImageEntry(fh.getFileName())) {
|
||||
return fh;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
private byte[] readRarEntryBytes(Archive archive, FileHeader header) {
|
||||
try (ByteArrayOutputStream baos = new ByteArrayOutputStream()) {
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
package org.booklore.service.metadata.extractor;
|
||||
|
||||
import io.documentnode.epub4j.domain.Book;
|
||||
import io.documentnode.epub4j.domain.MediaType;
|
||||
import io.documentnode.epub4j.domain.MediaTypes;
|
||||
import io.documentnode.epub4j.domain.Resource;
|
||||
import io.documentnode.epub4j.epub.EpubReader;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import net.lingala.zip4j.ZipFile;
|
||||
@@ -8,7 +11,8 @@ import net.lingala.zip4j.model.FileHeader;
|
||||
import org.apache.commons.io.FilenameUtils;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.booklore.model.dto.BookMetadata;
|
||||
import org.springframework.boot.configurationprocessor.json.JSONArray;
|
||||
import org.booklore.service.metadata.BookLoreMetadata;
|
||||
import org.booklore.util.SecureXmlUtils;
|
||||
import org.springframework.boot.configurationprocessor.json.JSONException;
|
||||
import org.springframework.boot.configurationprocessor.json.JSONObject;
|
||||
import org.springframework.stereotype.Component;
|
||||
@@ -16,85 +20,36 @@ import org.w3c.dom.Document;
|
||||
import org.w3c.dom.Element;
|
||||
import org.w3c.dom.NodeList;
|
||||
|
||||
import javax.xml.XMLConstants;
|
||||
import javax.xml.parsers.DocumentBuilder;
|
||||
import javax.xml.parsers.DocumentBuilderFactory;
|
||||
import java.io.File;
|
||||
import java.io.FileInputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.net.URLDecoder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.LocalDate;
|
||||
import java.time.OffsetDateTime;
|
||||
import java.util.*;
|
||||
import java.util.function.BiConsumer;
|
||||
import java.util.regex.Pattern;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@Slf4j
|
||||
@Component
|
||||
public class EpubMetadataExtractor implements FileMetadataExtractor {
|
||||
|
||||
private static final String OPF_NS = "http://www.idpf.org/2007/opf";
|
||||
private static final Pattern YEAR_ONLY_PATTERN = Pattern.compile("^\\d{4}$");
|
||||
private static final Pattern ISBN_13_PATTERN = Pattern.compile("\\d{13}");
|
||||
private static final Pattern ISBN_10_PATTERN = Pattern.compile("\\d{10}|[0-9]{9}[xX]");
|
||||
private static final String OPF_NS = "http://www.idpf.org/2007/opf";
|
||||
|
||||
private static class IdentifierMapping {
|
||||
final String prefix;
|
||||
final String fieldName;
|
||||
final BiConsumer<BookMetadata.BookMetadataBuilder, String> setter;
|
||||
// List of all media types that epub4j has so we can lazy load them.
|
||||
// Note that we have to add in null to handle files without extentions like mimetype.
|
||||
private static final List<MediaType> MEDIA_TYPES = new ArrayList<>();
|
||||
private static final Pattern ISBN_SEPARATOR_PATTERN = Pattern.compile("[- ]");
|
||||
|
||||
IdentifierMapping(String prefix, String fieldName, BiConsumer<BookMetadata.BookMetadataBuilder, String> setter) {
|
||||
this.prefix = prefix;
|
||||
this.fieldName = fieldName;
|
||||
this.setter = setter;
|
||||
}
|
||||
static {
|
||||
MEDIA_TYPES.addAll(Arrays.asList(MediaTypes.mediaTypes));
|
||||
MEDIA_TYPES.add(null);
|
||||
}
|
||||
|
||||
private static final List<IdentifierMapping> IDENTIFIER_PREFIX_MAPPINGS = List.of(
|
||||
new IdentifierMapping("urn:isbn:", "isbn", null),
|
||||
new IdentifierMapping("urn:amazon:", "asin", BookMetadata.BookMetadataBuilder::asin),
|
||||
new IdentifierMapping("urn:goodreads:", "goodreadsId", BookMetadata.BookMetadataBuilder::goodreadsId),
|
||||
new IdentifierMapping("urn:google:", "googleId", BookMetadata.BookMetadataBuilder::googleId),
|
||||
new IdentifierMapping("urn:hardcover:", "hardcoverId", BookMetadata.BookMetadataBuilder::hardcoverId),
|
||||
new IdentifierMapping("urn:hardcoverbook:", "hardcoverBookId", BookMetadata.BookMetadataBuilder::hardcoverBookId),
|
||||
new IdentifierMapping("urn:comicvine:", "comicvineId", BookMetadata.BookMetadataBuilder::comicvineId),
|
||||
new IdentifierMapping("urn:lubimyczytac:", "lubimyczytacId", (builder, value) -> builder.lubimyczytacId(value)),
|
||||
new IdentifierMapping("urn:ranobedb:", "ranobedbId", BookMetadata.BookMetadataBuilder::ranobedbId),
|
||||
new IdentifierMapping("asin:", "asin", BookMetadata.BookMetadataBuilder::asin),
|
||||
new IdentifierMapping("amazon:", "asin", BookMetadata.BookMetadataBuilder::asin),
|
||||
new IdentifierMapping("mobi-asin:", "asin", BookMetadata.BookMetadataBuilder::asin),
|
||||
new IdentifierMapping("goodreads:", "goodreadsId", BookMetadata.BookMetadataBuilder::goodreadsId),
|
||||
new IdentifierMapping("google:", "googleId", BookMetadata.BookMetadataBuilder::googleId),
|
||||
new IdentifierMapping("hardcover:", "hardcoverId", BookMetadata.BookMetadataBuilder::hardcoverId),
|
||||
new IdentifierMapping("hardcoverbook:", "hardcoverBookId", BookMetadata.BookMetadataBuilder::hardcoverBookId),
|
||||
new IdentifierMapping("comicvine:", "comicvineId", BookMetadata.BookMetadataBuilder::comicvineId),
|
||||
new IdentifierMapping("lubimyczytac:", "lubimyczytacId", (builder, value) -> builder.lubimyczytacId(value)),
|
||||
new IdentifierMapping("ranobedb:", "ranobedbId", BookMetadata.BookMetadataBuilder::ranobedbId)
|
||||
);
|
||||
|
||||
private static final Map<String, BiConsumer<BookMetadata.BookMetadataBuilder, String>> SCHEME_MAPPINGS = Map.of(
|
||||
"GOODREADS", BookMetadata.BookMetadataBuilder::goodreadsId,
|
||||
"COMICVINE", BookMetadata.BookMetadataBuilder::comicvineId,
|
||||
"GOOGLE", BookMetadata.BookMetadataBuilder::googleId,
|
||||
"AMAZON", BookMetadata.BookMetadataBuilder::asin,
|
||||
"HARDCOVER", BookMetadata.BookMetadataBuilder::hardcoverId
|
||||
);
|
||||
|
||||
private static final Map<String, BiConsumer<BookMetadata.BookMetadataBuilder, String>> CALIBRE_FIELD_MAPPINGS = Map.ofEntries(
|
||||
Map.entry("#subtitle", BookMetadata.BookMetadataBuilder::subtitle),
|
||||
Map.entry("#pagecount", (builder, value) -> safeParseInt(value, builder::pageCount)),
|
||||
Map.entry("#series_total", (builder, value) -> safeParseInt(value, builder::seriesTotal)),
|
||||
Map.entry("#amazon_rating", (builder, value) -> safeParseDouble(value, builder::amazonRating)),
|
||||
Map.entry("#amazon_review_count", (builder, value) -> safeParseInt(value, builder::amazonReviewCount)),
|
||||
Map.entry("#goodreads_rating", (builder, value) -> safeParseDouble(value, builder::goodreadsRating)),
|
||||
Map.entry("#goodreads_review_count", (builder, value) -> safeParseInt(value, builder::goodreadsReviewCount)),
|
||||
Map.entry("#hardcover_rating", (builder, value) -> safeParseDouble(value, builder::hardcoverRating)),
|
||||
Map.entry("#hardcover_review_count", (builder, value) -> safeParseInt(value, builder::hardcoverReviewCount)),
|
||||
Map.entry("#lubimyczytac_rating", (builder, value) -> safeParseDouble(value, builder::lubimyczytacRating)),
|
||||
Map.entry("#ranobedb_rating", (builder, value) -> safeParseDouble(value, builder::ranobedbRating))
|
||||
);
|
||||
|
||||
@Override
|
||||
public byte[] extractCover(File epubFile) {
|
||||
Book epub = null;
|
||||
@@ -110,22 +65,12 @@ public class EpubMetadataExtractor implements FileMetadataExtractor {
|
||||
log.debug("epub4j failed to parse EPUB for cover extraction (will try fallbacks) in {}: {}", epubFile.getName(), e.getMessage());
|
||||
}
|
||||
|
||||
if (coverImage == null) {
|
||||
String coverHref = findCoverImageHrefInOpf(epubFile);
|
||||
if (coverHref != null) {
|
||||
byte[] data = extractFileFromZip(epubFile, coverHref);
|
||||
if (data != null) return data;
|
||||
}
|
||||
}
|
||||
|
||||
if (coverImage == null) {
|
||||
String metaCoverId = findMetaCoverIdInOpf(epubFile);
|
||||
if (metaCoverId != null) {
|
||||
String href = findHrefForManifestId(epubFile, metaCoverId);
|
||||
if (href != null) {
|
||||
byte[] data = extractFileFromZip(epubFile, href);
|
||||
if (data != null) return data;
|
||||
}
|
||||
// We fall back to reading the image based on the cover-image property.
|
||||
String coverHref = findCoverImageHrefInOpf(epubFile);
|
||||
if (coverHref != null) {
|
||||
byte[] image = extractFileFromZip(epubFile, coverHref);
|
||||
if (image != null) {
|
||||
return image;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -161,16 +106,14 @@ public class EpubMetadataExtractor implements FileMetadataExtractor {
|
||||
return (coverImage != null) ? coverImage.getData() : null;
|
||||
} catch (Exception e) {
|
||||
log.warn("Failed to extract cover from EPUB: {}", epubFile.getName(), e);
|
||||
return null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private String findManifestCoverByHeuristic(File epubFile) {
|
||||
try (ZipFile zip = new ZipFile(epubFile)) {
|
||||
DocumentBuilderFactory dbf = DocumentBuilderFactory.newInstance();
|
||||
dbf.setNamespaceAware(true);
|
||||
dbf.setFeature(XMLConstants.FEATURE_SECURE_PROCESSING, true);
|
||||
DocumentBuilder builder = dbf.newDocumentBuilder();
|
||||
DocumentBuilder builder = SecureXmlUtils.createSecureDocumentBuilder(true);
|
||||
|
||||
FileHeader containerHdr = zip.getFileHeader("META-INF/container.xml");
|
||||
if (containerHdr == null) return null;
|
||||
@@ -231,11 +174,7 @@ public class EpubMetadataExtractor implements FileMetadataExtractor {
|
||||
@Override
|
||||
public BookMetadata extractMetadata(File epubFile) {
|
||||
try (ZipFile zip = new ZipFile(epubFile)) {
|
||||
DocumentBuilderFactory dbf = DocumentBuilderFactory.newInstance();
|
||||
dbf.setNamespaceAware(true);
|
||||
dbf.setFeature(XMLConstants.FEATURE_SECURE_PROCESSING, true);
|
||||
DocumentBuilder builder = dbf.newDocumentBuilder();
|
||||
|
||||
DocumentBuilder builder = SecureXmlUtils.createSecureDocumentBuilder(true);
|
||||
FileHeader containerHdr = zip.getFileHeader("META-INF/container.xml");
|
||||
if (containerHdr == null) return null;
|
||||
|
||||
@@ -257,9 +196,6 @@ public class EpubMetadataExtractor implements FileMetadataExtractor {
|
||||
|
||||
BookMetadata.BookMetadataBuilder builderMeta = BookMetadata.builder();
|
||||
Set<String> categories = new HashSet<>();
|
||||
Set<String> moods = new HashSet<>();
|
||||
Set<String> tags = new HashSet<>();
|
||||
Set<String> processedIdentifierFields = new HashSet<>();
|
||||
|
||||
boolean seriesFound = false;
|
||||
boolean seriesIndexFound = false;
|
||||
@@ -300,14 +236,14 @@ public class EpubMetadataExtractor implements FileMetadataExtractor {
|
||||
}
|
||||
|
||||
if ("role".equals(prop) && StringUtils.isNotBlank(refines)) {
|
||||
creatorRoleById.put(refines.substring(1), content.toLowerCase());
|
||||
creatorRoleById.put(refines.substring(1), content.toLowerCase());
|
||||
}
|
||||
|
||||
if (!seriesFound && ("booklore:series".equals(prop) || "calibre:series".equals(name) || "belongs-to-collection".equals(prop))) {
|
||||
if (!seriesFound && ((BookLoreMetadata.NS_PREFIX + ":series").equals(prop) || "calibre:series".equals(name) || "belongs-to-collection".equals(prop))) {
|
||||
builderMeta.seriesName(content);
|
||||
seriesFound = true;
|
||||
}
|
||||
if (!seriesIndexFound && ("booklore:series_index".equals(prop) || "calibre:series_index".equals(name) || "group-position".equals(prop))) {
|
||||
if (!seriesIndexFound && ((BookLoreMetadata.NS_PREFIX + ":series_index").equals(prop) || "calibre:series_index".equals(name) || "group-position".equals(prop))) {
|
||||
try {
|
||||
builderMeta.seriesNumber(Float.parseFloat(content));
|
||||
seriesIndexFound = true;
|
||||
@@ -315,33 +251,53 @@ public class EpubMetadataExtractor implements FileMetadataExtractor {
|
||||
}
|
||||
}
|
||||
|
||||
switch (prop) {
|
||||
case "booklore:asin" -> builderMeta.asin(content);
|
||||
case "booklore:goodreads_id" -> builderMeta.goodreadsId(content);
|
||||
case "booklore:comicvine_id" -> builderMeta.comicvineId(content);
|
||||
case "booklore:hardcover_id" -> builderMeta.hardcoverId(content);
|
||||
case "booklore:google_books_id" -> builderMeta.googleId(content);
|
||||
case "booklore:page_count" -> safeParseInt(content, builderMeta::pageCount);
|
||||
case "booklore:moods" -> extractSetField(content, moods);
|
||||
case "booklore:tags" -> extractSetField(content, tags);
|
||||
case "booklore:series_total" -> safeParseInt(content, builderMeta::seriesTotal);
|
||||
case "booklore:amazon_rating" -> safeParseDouble(content, builderMeta::amazonRating);
|
||||
case "booklore:amazon_review_count" -> safeParseInt(content, builderMeta::amazonReviewCount);
|
||||
case "booklore:goodreads_rating" -> safeParseDouble(content, builderMeta::goodreadsRating);
|
||||
case "booklore:goodreads_review_count" -> safeParseInt(content, builderMeta::goodreadsReviewCount);
|
||||
case "booklore:hardcover_book_id" -> builderMeta.hardcoverBookId(content);
|
||||
case "booklore:hardcover_rating" -> safeParseDouble(content, builderMeta::hardcoverRating);
|
||||
case "booklore:hardcover_review_count" -> safeParseInt(content, builderMeta::hardcoverReviewCount);
|
||||
case "booklore:lubimyczytac_rating" -> safeParseDouble(content, value -> builderMeta.lubimyczytacRating(value));
|
||||
case "booklore:ranobedb_rating" -> safeParseDouble(content, builderMeta::ranobedbRating);
|
||||
}
|
||||
|
||||
if ("calibre:user_metadata".equals(prop)) {
|
||||
if ("calibre:pages".equals(name) || "pagecount".equals(name) || "schema:pagecount".equals(prop) || "media:pagecount".equals(prop) || (BookLoreMetadata.NS_PREFIX + ":page_count").equals(prop)) {
|
||||
safeParseInt(content, builderMeta::pageCount);
|
||||
} else if ("calibre:user_metadata:#pagecount".equals(name)) {
|
||||
try {
|
||||
JSONObject jsonroot = new JSONObject(content);
|
||||
extractCalibreUserMetadata(jsonroot, builderMeta, moods, tags);
|
||||
} catch (JSONException e) {
|
||||
log.warn("Failed to parse Calibre user_metadata JSON: {}", e.getMessage());
|
||||
Object value = jsonroot.opt("#value#");
|
||||
safeParseInt(String.valueOf(value), builderMeta::pageCount);
|
||||
} catch (JSONException ignored) {
|
||||
}
|
||||
} else if ("calibre:user_metadata".equals(prop)) {
|
||||
try {
|
||||
JSONObject jsonroot = new JSONObject(content);
|
||||
JSONObject pages = jsonroot.getJSONObject("#pagecount");
|
||||
Object value = pages.opt("#value#");
|
||||
safeParseInt(String.valueOf(value), builderMeta::pageCount);
|
||||
} catch (JSONException ignored) {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
String key = StringUtils.isNotBlank(prop) ? prop : name;
|
||||
|
||||
if (key.equals(BookLoreMetadata.NS_PREFIX + ":asin")) builderMeta.asin(content);
|
||||
else if (key.equals(BookLoreMetadata.NS_PREFIX + ":goodreads_id")) builderMeta.goodreadsId(content);
|
||||
else if (key.equals(BookLoreMetadata.NS_PREFIX + ":comicvine_id")) builderMeta.comicvineId(content);
|
||||
else if (key.equals(BookLoreMetadata.NS_PREFIX + ":ranobedb_id")) builderMeta.ranobedbId(content);
|
||||
else if (key.equals(BookLoreMetadata.NS_PREFIX + ":hardcover_id")) builderMeta.hardcoverId(content);
|
||||
else if (key.equals(BookLoreMetadata.NS_PREFIX + ":google_books_id")) builderMeta.googleId(content);
|
||||
else if (key.equals(BookLoreMetadata.NS_PREFIX + ":lubimyczytac_id")) builderMeta.lubimyczytacId(content);
|
||||
else if (key.equals(BookLoreMetadata.NS_PREFIX + ":page_count")) safeParseInt(content, builderMeta::pageCount);
|
||||
else if (key.equals(BookLoreMetadata.NS_PREFIX + ":subtitle")) builderMeta.subtitle(content);
|
||||
else if (key.equals(BookLoreMetadata.NS_PREFIX + ":series_total")) safeParseInt(content, builderMeta::seriesTotal);
|
||||
else if (key.equals(BookLoreMetadata.NS_PREFIX + ":rating")) { /* Generic rating not supported */ }
|
||||
else if (key.equals(BookLoreMetadata.NS_PREFIX + ":amazon_rating")) safeParseDouble(content, builderMeta::amazonRating);
|
||||
else if (key.equals(BookLoreMetadata.NS_PREFIX + ":goodreads_rating")) safeParseDouble(content, builderMeta::goodreadsRating);
|
||||
else if (key.equals(BookLoreMetadata.NS_PREFIX + ":hardcover_rating")) safeParseDouble(content, builderMeta::hardcoverRating);
|
||||
else if (key.equals(BookLoreMetadata.NS_PREFIX + ":lubimyczytac_rating")) safeParseDouble(content, builderMeta::lubimyczytacRating);
|
||||
else if (key.equals(BookLoreMetadata.NS_PREFIX + ":ranobedb_rating")) safeParseDouble(content, builderMeta::ranobedbRating);
|
||||
else if (key.equals(BookLoreMetadata.NS_PREFIX + ":moods")) {
|
||||
if (StringUtils.isNotBlank(content)) {
|
||||
builderMeta.moods(parseJsonArrayOrCsv(content));
|
||||
}
|
||||
}
|
||||
else if (key.equals(BookLoreMetadata.NS_PREFIX + ":tags")) {
|
||||
if (StringUtils.isNotBlank(content)) {
|
||||
builderMeta.tags(parseJsonArrayOrCsv(content));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -364,20 +320,43 @@ public class EpubMetadataExtractor implements FileMetadataExtractor {
|
||||
case "language" -> builderMeta.language(text);
|
||||
case "identifier" -> {
|
||||
String scheme = el.getAttributeNS(OPF_NS, "scheme").toUpperCase();
|
||||
String value = text.toLowerCase();
|
||||
String textLower = text.toLowerCase();
|
||||
|
||||
if (processIdentifierWithPrefix(value, builderMeta, processedIdentifierFields)) {
|
||||
continue;
|
||||
// Parse URN format: urn:scheme:value
|
||||
String value = text;
|
||||
String urnScheme = null;
|
||||
if (textLower.startsWith("urn:")) {
|
||||
String[] parts = text.split(":", 3);
|
||||
if (parts.length >= 3) {
|
||||
urnScheme = parts[1].toUpperCase();
|
||||
value = parts[2];
|
||||
}
|
||||
} else if (textLower.startsWith("isbn:")) {
|
||||
value = text.substring(5);
|
||||
urnScheme = "ISBN";
|
||||
}
|
||||
|
||||
if (value.startsWith("isbn:")) {
|
||||
value = value.substring("isbn:".length());
|
||||
// Use URN scheme if opf:scheme is not present
|
||||
if (scheme.isEmpty() && urnScheme != null) {
|
||||
scheme = urnScheme;
|
||||
}
|
||||
|
||||
if (!scheme.isEmpty()) {
|
||||
processIdentifierByScheme(scheme, value, builderMeta, processedIdentifierFields);
|
||||
} else {
|
||||
processIsbnIdentifier(value, builderMeta, processedIdentifierFields);
|
||||
switch (scheme) {
|
||||
case "ISBN", "ISBN10", "ISBN13" -> {
|
||||
String cleanValue = ISBN_SEPARATOR_PATTERN.matcher(value).replaceAll("");
|
||||
if (cleanValue.length() == 13) builderMeta.isbn13(value);
|
||||
else if (cleanValue.length() == 10) builderMeta.isbn10(value);
|
||||
}
|
||||
case "GOODREADS" -> builderMeta.goodreadsId(value);
|
||||
case "COMICVINE" -> builderMeta.comicvineId(value);
|
||||
case "RANOBEDB" -> builderMeta.ranobedbId(value);
|
||||
case "GOOGLE" -> builderMeta.googleId(value);
|
||||
case "AMAZON" -> builderMeta.asin(value);
|
||||
case "HARDCOVER" -> builderMeta.hardcoverId(value);
|
||||
case "HARDCOVERBOOK", "HARDCOVER_BOOK_ID" -> builderMeta.hardcoverBookId(value);
|
||||
case "LUBIMYCZYTAC" -> builderMeta.lubimyczytacId(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
case "date" -> {
|
||||
@@ -419,9 +398,15 @@ public class EpubMetadataExtractor implements FileMetadataExtractor {
|
||||
}
|
||||
|
||||
builderMeta.authors(creatorsByRole.get("aut"));
|
||||
|
||||
// Remove tags and moods from categories to ensure strict separation
|
||||
BookMetadata intermediate = builderMeta.build();
|
||||
Set<String> knownNonCategories = new HashSet<>();
|
||||
if (intermediate.getMoods() != null) knownNonCategories.addAll(intermediate.getMoods());
|
||||
if (intermediate.getTags() != null) knownNonCategories.addAll(intermediate.getTags());
|
||||
categories.removeAll(knownNonCategories);
|
||||
|
||||
builderMeta.categories(categories);
|
||||
builderMeta.moods(moods);
|
||||
builderMeta.tags(tags);
|
||||
|
||||
BookMetadata extractedMetadata = builderMeta.build();
|
||||
|
||||
@@ -440,128 +425,58 @@ public class EpubMetadataExtractor implements FileMetadataExtractor {
|
||||
}
|
||||
}
|
||||
|
||||
private boolean processIdentifierWithPrefix(String value, BookMetadata.BookMetadataBuilder builder,
|
||||
Set<String> processedFields) {
|
||||
for (IdentifierMapping mapping : IDENTIFIER_PREFIX_MAPPINGS) {
|
||||
if (value.startsWith(mapping.prefix)) {
|
||||
String extractedValue = value.substring(mapping.prefix.length());
|
||||
|
||||
if ("isbn".equals(mapping.fieldName)) {
|
||||
processIsbnIdentifier(extractedValue, builder, processedFields);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!processedFields.contains(mapping.fieldName)) {
|
||||
mapping.setter.accept(builder, extractedValue);
|
||||
processedFields.add(mapping.fieldName);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private void processIdentifierByScheme(String scheme, String value, BookMetadata.BookMetadataBuilder builder,
|
||||
Set<String> processedFields) {
|
||||
if ("ISBN".equals(scheme)) {
|
||||
processIsbnIdentifier(value, builder, processedFields);
|
||||
} else {
|
||||
BiConsumer<BookMetadata.BookMetadataBuilder, String> setter = SCHEME_MAPPINGS.get(scheme);
|
||||
if (setter != null) {
|
||||
String fieldName = getFieldNameForScheme(scheme);
|
||||
if (!processedFields.contains(fieldName)) {
|
||||
setter.accept(builder, value);
|
||||
processedFields.add(fieldName);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void processIsbnIdentifier(String value, BookMetadata.BookMetadataBuilder builder,
|
||||
Set<String> processedFields) {
|
||||
String cleanIsbn = value.replaceAll("[- ]", "");
|
||||
|
||||
if (cleanIsbn.length() == 13 && ISBN_13_PATTERN.matcher(cleanIsbn).matches()) {
|
||||
if (!processedFields.contains("isbn13")) {
|
||||
builder.isbn13(value);
|
||||
processedFields.add("isbn13");
|
||||
}
|
||||
} else if (cleanIsbn.length() == 10 && ISBN_10_PATTERN.matcher(cleanIsbn).matches()) {
|
||||
if (!processedFields.contains("isbn10")) {
|
||||
builder.isbn10(value);
|
||||
processedFields.add("isbn10");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private String getFieldNameForScheme(String scheme) {
|
||||
return switch (scheme) {
|
||||
case "GOODREADS" -> "goodreadsId";
|
||||
case "COMICVINE" -> "comicvineId";
|
||||
case "GOOGLE" -> "googleId";
|
||||
case "AMAZON" -> "asin";
|
||||
case "HARDCOVER" -> "hardcoverId";
|
||||
default -> scheme.toLowerCase();
|
||||
};
|
||||
}
|
||||
|
||||
private static void safeParseInt(String value, java.util.function.IntConsumer setter) {
|
||||
private void safeParseInt(String value, java.util.function.IntConsumer setter) {
|
||||
try {
|
||||
setter.accept(Integer.parseInt(value));
|
||||
} catch (NumberFormatException ignored) {
|
||||
}
|
||||
}
|
||||
|
||||
private static void safeParseFloat(String value, java.util.function.Consumer<Float> setter) {
|
||||
try {
|
||||
setter.accept(Float.parseFloat(value));
|
||||
} catch (NumberFormatException ignored) {
|
||||
}
|
||||
}
|
||||
|
||||
private static void safeParseDouble(String value, java.util.function.DoubleConsumer setter) {
|
||||
private void safeParseDouble(String value, java.util.function.DoubleConsumer setter) {
|
||||
try {
|
||||
setter.accept(Double.parseDouble(value));
|
||||
} catch (NumberFormatException ignored) {
|
||||
}
|
||||
}
|
||||
|
||||
private static void extractSetField(String value, Set<String> targetSet) {
|
||||
if (value == null || value.trim().isEmpty()) {
|
||||
return;
|
||||
/**
|
||||
* Parses a string that may be either a JSON array (e.g., ["item1", "item2"]) or a CSV (item1, item2).
|
||||
* Returns a Set of parsed values.
|
||||
*/
|
||||
private Set<String> parseJsonArrayOrCsv(String content) {
|
||||
if (StringUtils.isBlank(content)) {
|
||||
return new HashSet<>();
|
||||
}
|
||||
|
||||
String trimmedValue = value.trim();
|
||||
content = content.trim();
|
||||
|
||||
if (trimmedValue.startsWith("[")) {
|
||||
try {
|
||||
JSONArray jsonArray = new JSONArray(trimmedValue);
|
||||
for (int i = 0; i < jsonArray.length(); i++) {
|
||||
String item = jsonArray.getString(i).trim();
|
||||
if (!item.isEmpty()) {
|
||||
targetSet.add(item);
|
||||
}
|
||||
}
|
||||
return;
|
||||
} catch (JSONException ignored) {
|
||||
// Check if it looks like a JSON array
|
||||
if (content.startsWith("[") && content.endsWith("]")) {
|
||||
// Remove brackets
|
||||
String inner = content.substring(1, content.length() - 1).trim();
|
||||
if (inner.isEmpty()) {
|
||||
return new HashSet<>();
|
||||
}
|
||||
}
|
||||
|
||||
String[] items = trimmedValue.split(",");
|
||||
for (String item : items) {
|
||||
String trimmedItem = item.trim();
|
||||
if (!trimmedItem.isEmpty()) {
|
||||
targetSet.add(trimmedItem);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void extractAndSetUserMetadataSet(String value, java.util.function.Consumer<Set<String>> setter) {
|
||||
Set<String> items = new HashSet<>();
|
||||
extractSetField(value, items);
|
||||
if (!items.isEmpty()) {
|
||||
setter.accept(items);
|
||||
// Split by comma and parse each quoted item
|
||||
return Arrays.stream(inner.split(","))
|
||||
.map(String::trim)
|
||||
.map(s -> {
|
||||
// Remove surrounding quotes if present
|
||||
if (s.startsWith("\"") && s.endsWith("\"") && s.length() >= 2) {
|
||||
return s.substring(1, s.length() - 1);
|
||||
}
|
||||
return s;
|
||||
})
|
||||
.filter(StringUtils::isNotBlank)
|
||||
.collect(Collectors.toSet());
|
||||
}
|
||||
|
||||
// Fallback to CSV parsing
|
||||
return Arrays.stream(content.split(","))
|
||||
.map(String::trim)
|
||||
.filter(StringUtils::isNotBlank)
|
||||
.collect(Collectors.toSet());
|
||||
}
|
||||
|
||||
private LocalDate parseDate(String value) {
|
||||
@@ -569,6 +484,7 @@ public class EpubMetadataExtractor implements FileMetadataExtractor {
|
||||
|
||||
value = value.trim();
|
||||
|
||||
// Check for year-only format first (e.g., "2024") - common in EPUB metadata
|
||||
if (YEAR_ONLY_PATTERN.matcher(value).matches()) {
|
||||
int year = Integer.parseInt(value);
|
||||
if (year >= 1 && year <= 9999) {
|
||||
@@ -586,6 +502,7 @@ public class EpubMetadataExtractor implements FileMetadataExtractor {
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
|
||||
// Try parsing first 10 characters for ISO date format with extra content
|
||||
if (value.length() >= 10) {
|
||||
try {
|
||||
return LocalDate.parse(value.substring(0, 10));
|
||||
@@ -597,12 +514,27 @@ public class EpubMetadataExtractor implements FileMetadataExtractor {
|
||||
return null;
|
||||
}
|
||||
|
||||
private byte[] getImageFromEpubResource(Resource res) {
|
||||
if (res == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
MediaType mt = res.getMediaType();
|
||||
if (mt == null || mt.getName() == null || !mt.getName().startsWith("image")) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return res.getData();
|
||||
} catch (IOException e) {
|
||||
log.warn("Failed to read data for resource", e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private String findCoverImageHrefInOpf(File epubFile) {
|
||||
try (ZipFile zip = new ZipFile(epubFile)) {
|
||||
DocumentBuilderFactory dbf = DocumentBuilderFactory.newInstance();
|
||||
dbf.setNamespaceAware(true);
|
||||
dbf.setFeature(XMLConstants.FEATURE_SECURE_PROCESSING, true);
|
||||
DocumentBuilder builder = dbf.newDocumentBuilder();
|
||||
DocumentBuilder builder = SecureXmlUtils.createSecureDocumentBuilder(true);
|
||||
|
||||
FileHeader containerHdr = zip.getFileHeader("META-INF/container.xml");
|
||||
if (containerHdr == null) return null;
|
||||
@@ -639,91 +571,10 @@ public class EpubMetadataExtractor implements FileMetadataExtractor {
|
||||
return null;
|
||||
}
|
||||
|
||||
private String findMetaCoverIdInOpf(File epubFile) {
|
||||
try (ZipFile zip = new ZipFile(epubFile)) {
|
||||
DocumentBuilderFactory dbf = DocumentBuilderFactory.newInstance();
|
||||
dbf.setNamespaceAware(true);
|
||||
dbf.setFeature(XMLConstants.FEATURE_SECURE_PROCESSING, true);
|
||||
DocumentBuilder builder = dbf.newDocumentBuilder();
|
||||
|
||||
FileHeader containerHdr = zip.getFileHeader("META-INF/container.xml");
|
||||
if (containerHdr == null) return null;
|
||||
|
||||
try (InputStream cis = zip.getInputStream(containerHdr)) {
|
||||
Document containerDoc = builder.parse(cis);
|
||||
NodeList roots = containerDoc.getElementsByTagName("rootfile");
|
||||
if (roots.getLength() == 0) return null;
|
||||
|
||||
String opfPath = ((Element) roots.item(0)).getAttribute("full-path");
|
||||
if (StringUtils.isBlank(opfPath)) return null;
|
||||
|
||||
FileHeader opfHdr = zip.getFileHeader(opfPath);
|
||||
if (opfHdr == null) return null;
|
||||
|
||||
try (InputStream in = zip.getInputStream(opfHdr)) {
|
||||
Document doc = builder.parse(in);
|
||||
NodeList metaElements = doc.getElementsByTagName("meta");
|
||||
|
||||
for (int i = 0; i < metaElements.getLength(); i++) {
|
||||
Element meta = (Element) metaElements.item(i);
|
||||
String name = meta.getAttribute("name");
|
||||
if ("cover".equals(name)) {
|
||||
return meta.getAttribute("content");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.debug("Failed to find meta cover ID in OPF: {}", e.getMessage());
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private String findHrefForManifestId(File epubFile, String manifestId) {
|
||||
try (ZipFile zip = new ZipFile(epubFile)) {
|
||||
DocumentBuilderFactory dbf = DocumentBuilderFactory.newInstance();
|
||||
dbf.setNamespaceAware(true);
|
||||
dbf.setFeature(XMLConstants.FEATURE_SECURE_PROCESSING, true);
|
||||
DocumentBuilder builder = dbf.newDocumentBuilder();
|
||||
|
||||
FileHeader containerHdr = zip.getFileHeader("META-INF/container.xml");
|
||||
if (containerHdr == null) return null;
|
||||
|
||||
try (InputStream cis = zip.getInputStream(containerHdr)) {
|
||||
Document containerDoc = builder.parse(cis);
|
||||
NodeList roots = containerDoc.getElementsByTagName("rootfile");
|
||||
if (roots.getLength() == 0) return null;
|
||||
|
||||
String opfPath = ((Element) roots.item(0)).getAttribute("full-path");
|
||||
if (StringUtils.isBlank(opfPath)) return null;
|
||||
|
||||
FileHeader opfHdr = zip.getFileHeader(opfPath);
|
||||
if (opfHdr == null) return null;
|
||||
|
||||
try (InputStream in = zip.getInputStream(opfHdr)) {
|
||||
Document doc = builder.parse(in);
|
||||
NodeList manifestItems = doc.getElementsByTagName("item");
|
||||
|
||||
for (int i = 0; i < manifestItems.getLength(); i++) {
|
||||
Element item = (Element) manifestItems.item(i);
|
||||
String id = item.getAttribute("id");
|
||||
if (manifestId.equals(id)) {
|
||||
String href = item.getAttribute("href");
|
||||
String decodedHref = URLDecoder.decode(href, StandardCharsets.UTF_8);
|
||||
return resolvePath(opfPath, decodedHref);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.debug("Failed to find href for manifest ID {}: {}", manifestId, e.getMessage());
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private String resolvePath(String opfPath, String href) {
|
||||
if (href == null || href.isEmpty()) return null;
|
||||
|
||||
// If href is absolute within the zip (starts with /), return it without leading /
|
||||
if (href.startsWith("/")) return href.substring(1);
|
||||
|
||||
int lastSlash = opfPath.lastIndexOf('/');
|
||||
@@ -731,6 +582,7 @@ public class EpubMetadataExtractor implements FileMetadataExtractor {
|
||||
|
||||
String combined = basePath + href;
|
||||
|
||||
// Normalize path components to handle ".." and "."
|
||||
java.util.LinkedList<String> parts = new java.util.LinkedList<>();
|
||||
for (String part : combined.split("/")) {
|
||||
if ("..".equals(part)) {
|
||||
@@ -755,60 +607,4 @@ public class EpubMetadataExtractor implements FileMetadataExtractor {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private void extractCalibreUserMetadata(JSONObject userMetadata, BookMetadata.BookMetadataBuilder builder,
|
||||
Set<String> moodsSet, Set<String> tagsSet) {
|
||||
try {
|
||||
java.util.Iterator<String> keys = userMetadata.keys();
|
||||
|
||||
while (keys.hasNext()) {
|
||||
String fieldName = keys.next();
|
||||
|
||||
try {
|
||||
JSONObject fieldObject = userMetadata.optJSONObject(fieldName);
|
||||
if (fieldObject == null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
Object rawValue = fieldObject.opt("#value#");
|
||||
if (rawValue == null) {
|
||||
rawValue = fieldObject.opt("value");
|
||||
if (rawValue == null) {
|
||||
rawValue = fieldObject.opt("#val#");
|
||||
}
|
||||
if (rawValue == null) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
String value = String.valueOf(rawValue).trim();
|
||||
if (value.isEmpty() || "null".equals(value)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ("#moods".equals(fieldName)) {
|
||||
extractSetField(value, moodsSet);
|
||||
continue;
|
||||
}
|
||||
|
||||
if ("#extra_tags".equals(fieldName)) {
|
||||
extractSetField(value, tagsSet);
|
||||
continue;
|
||||
}
|
||||
|
||||
BiConsumer<BookMetadata.BookMetadataBuilder, String> mapper = CALIBRE_FIELD_MAPPINGS.get(fieldName);
|
||||
if (mapper != null) {
|
||||
mapper.accept(builder, value);
|
||||
}
|
||||
|
||||
} catch (Exception e) {
|
||||
log.debug("Failed to extract Calibre field '{}': {}", fieldName, e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
} catch (Exception e) {
|
||||
log.debug("Failed to process Calibre user_metadata: {}", e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
package org.booklore.service.metadata.extractor;
|
||||
|
||||
import org.booklore.model.dto.BookMetadata;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.commons.io.FilenameUtils;
|
||||
import org.apache.commons.io.IOUtils;
|
||||
@@ -14,6 +13,8 @@ import org.apache.pdfbox.pdmodel.PDDocumentInformation;
|
||||
import org.apache.pdfbox.pdmodel.common.PDMetadata;
|
||||
import org.apache.pdfbox.rendering.ImageType;
|
||||
import org.apache.pdfbox.rendering.PDFRenderer;
|
||||
import org.booklore.model.dto.BookMetadata;
|
||||
import org.booklore.util.SecureXmlUtils;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.w3c.dom.Document;
|
||||
import org.w3c.dom.NodeList;
|
||||
@@ -21,8 +22,10 @@ import org.w3c.dom.NodeList;
|
||||
import javax.imageio.ImageIO;
|
||||
import javax.xml.namespace.NamespaceContext;
|
||||
import javax.xml.parsers.DocumentBuilder;
|
||||
import javax.xml.parsers.DocumentBuilderFactory;
|
||||
import javax.xml.xpath.*;
|
||||
import javax.xml.xpath.XPath;
|
||||
import javax.xml.xpath.XPathConstants;
|
||||
import javax.xml.xpath.XPathExpressionException;
|
||||
import javax.xml.xpath.XPathFactory;
|
||||
import java.awt.image.BufferedImage;
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
@@ -109,8 +112,17 @@ public class PdfMetadataExtractor implements FileMetadataExtractor {
|
||||
}
|
||||
}
|
||||
|
||||
metadataBuilder.pageCount(pdf.getNumberOfPages());
|
||||
|
||||
if (StringUtils.isNotBlank(info.getKeywords())) {
|
||||
Set<String> categories = Arrays.stream(info.getKeywords().split(","))
|
||||
String keywords = info.getKeywords();
|
||||
String[] parts;
|
||||
if (keywords.contains(";")) {
|
||||
parts = keywords.split(";");
|
||||
} else {
|
||||
parts = keywords.split(",");
|
||||
}
|
||||
Set<String> categories = Arrays.stream(parts)
|
||||
.map(String::trim)
|
||||
.filter(StringUtils::isNotBlank)
|
||||
.collect(Collectors.toSet());
|
||||
@@ -134,9 +146,7 @@ public class PdfMetadataExtractor implements FileMetadataExtractor {
|
||||
} else {
|
||||
String rawXmp = IOUtils.toString(is, StandardCharsets.UTF_8);
|
||||
if (StringUtils.isNotBlank(rawXmp)) {
|
||||
DocumentBuilderFactory dbFactory = DocumentBuilderFactory.newInstance();
|
||||
dbFactory.setNamespaceAware(true);
|
||||
DocumentBuilder dBuilder = dbFactory.newDocumentBuilder();
|
||||
DocumentBuilder dBuilder = SecureXmlUtils.createSecureDocumentBuilder(true);
|
||||
Document doc = dBuilder.parse(new ByteArrayInputStream(rawXmp.getBytes(StandardCharsets.UTF_8)));
|
||||
|
||||
XPathFactory xPathfactory = XPathFactory.newInstance();
|
||||
@@ -145,9 +155,18 @@ public class PdfMetadataExtractor implements FileMetadataExtractor {
|
||||
|
||||
extractDublinCoreMetadata(xpath, doc, metadataBuilder);
|
||||
extractCalibreMetadata(xpath, doc, metadataBuilder);
|
||||
extractBookloreMetadata(xpath, doc, metadataBuilder);
|
||||
|
||||
// Debug logging for troubleshooting extraction issues
|
||||
if (log.isDebugEnabled()) {
|
||||
BookMetadata debugMeta = metadataBuilder.build();
|
||||
log.debug("PDF XMP extraction results - subtitle: '{}', moods: {}, tags: {}",
|
||||
debugMeta.getSubtitle(), debugMeta.getMoods(), debugMeta.getTags());
|
||||
}
|
||||
|
||||
Map<String, String> identifiers = extractIdentifiers(xpath, doc);
|
||||
if (!identifiers.isEmpty()) {
|
||||
// Extract generic ISBN first
|
||||
String isbn = identifiers.get("isbn");
|
||||
if (StringUtils.isNotBlank(isbn)) {
|
||||
isbn = ISBN_CLEANUP_PATTERN.matcher(isbn).replaceAll("");
|
||||
@@ -156,8 +175,24 @@ public class PdfMetadataExtractor implements FileMetadataExtractor {
|
||||
} else if (isbn.length() == 13) {
|
||||
metadataBuilder.isbn13(isbn);
|
||||
} else {
|
||||
metadataBuilder.isbn13(isbn);
|
||||
log.warn("ISBN length not 10 or 13: {}", isbn);
|
||||
metadataBuilder.isbn13(isbn); // Fallback
|
||||
}
|
||||
}
|
||||
|
||||
// Extract specific ISBN schemes (overwrites generic only if valid)
|
||||
String isbn13 = identifiers.get("isbn13");
|
||||
if (StringUtils.isNotBlank(isbn13)) {
|
||||
String cleaned = ISBN_CLEANUP_PATTERN.matcher(isbn13).replaceAll("");
|
||||
if (!cleaned.isBlank()) {
|
||||
metadataBuilder.isbn13(cleaned);
|
||||
}
|
||||
}
|
||||
|
||||
String isbn10 = identifiers.get("isbn10");
|
||||
if (StringUtils.isNotBlank(isbn10)) {
|
||||
String cleaned = ISBN_CLEANUP_PATTERN.matcher(isbn10).replaceAll("");
|
||||
if (!cleaned.isBlank()) {
|
||||
metadataBuilder.isbn10(cleaned);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -190,6 +225,16 @@ public class PdfMetadataExtractor implements FileMetadataExtractor {
|
||||
if (StringUtils.isNotBlank(hardcover)) {
|
||||
metadataBuilder.hardcoverId(hardcover);
|
||||
}
|
||||
|
||||
String hardcoverBookId = identifiers.get("hardcover_book_id");
|
||||
if (StringUtils.isNotBlank(hardcoverBookId)) {
|
||||
metadataBuilder.hardcoverBookId(hardcoverBookId);
|
||||
}
|
||||
|
||||
String lubimyczytac = identifiers.get("lubimyczytac");
|
||||
if (StringUtils.isNotBlank(lubimyczytac)) {
|
||||
metadataBuilder.lubimyczytacId(lubimyczytac);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -233,7 +278,24 @@ public class PdfMetadataExtractor implements FileMetadataExtractor {
|
||||
|
||||
Set<String> subjects = xpathEvaluateMultiple(xpath, doc, "//dc:subject/rdf:Bag/rdf:li/text()");
|
||||
if (!subjects.isEmpty()) {
|
||||
builder.categories(subjects);
|
||||
Set<String> knownNonCategories = new HashSet<>();
|
||||
|
||||
try {
|
||||
String moods = xpath.evaluate("//booklore:Moods/text()", doc);
|
||||
if (StringUtils.isNotBlank(moods)) {
|
||||
Arrays.stream(moods.split(";")).map(String::trim).forEach(knownNonCategories::add);
|
||||
}
|
||||
String tags = xpath.evaluate("//booklore:Tags/text()", doc);
|
||||
if (StringUtils.isNotBlank(tags)) {
|
||||
Arrays.stream(tags.split(";")).map(String::trim).forEach(knownNonCategories::add);
|
||||
}
|
||||
} catch (Exception ignored) {}
|
||||
|
||||
subjects.removeAll(knownNonCategories);
|
||||
|
||||
if (!subjects.isEmpty()) {
|
||||
builder.categories(subjects);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -265,6 +327,205 @@ public class PdfMetadataExtractor implements FileMetadataExtractor {
|
||||
}
|
||||
}
|
||||
|
||||
private void extractBookloreMetadata(XPath xpath, Document doc, BookMetadata.BookMetadataBuilder builder) {
|
||||
try {
|
||||
// Series information (now in Booklore namespace, not Calibre)
|
||||
String seriesName = extractBookloreField(xpath, doc, "seriesName");
|
||||
if (StringUtils.isNotBlank(seriesName)) {
|
||||
builder.seriesName(seriesName);
|
||||
}
|
||||
|
||||
String seriesNumber = extractBookloreField(xpath, doc, "seriesNumber");
|
||||
if (StringUtils.isNotBlank(seriesNumber)) {
|
||||
try {
|
||||
builder.seriesNumber(Float.parseFloat(seriesNumber.trim()));
|
||||
} catch (NumberFormatException e) {
|
||||
log.warn("Invalid series number: {}", seriesNumber);
|
||||
}
|
||||
}
|
||||
|
||||
String seriesTotal = extractBookloreField(xpath, doc, "seriesTotal");
|
||||
if (StringUtils.isNotBlank(seriesTotal)) {
|
||||
try {
|
||||
builder.seriesTotal(Integer.parseInt(seriesTotal.trim()));
|
||||
} catch (NumberFormatException e) {
|
||||
log.warn("Invalid series total: {}", seriesTotal);
|
||||
}
|
||||
}
|
||||
|
||||
// Subtitle (try both old PascalCase and new camelCase)
|
||||
String subtitle = extractBookloreField(xpath, doc, "subtitle");
|
||||
log.debug("Extracted subtitle (camelCase): '{}'", subtitle);
|
||||
if (StringUtils.isBlank(subtitle)) {
|
||||
subtitle = xpath.evaluate("//booklore:Subtitle/text()", doc);
|
||||
log.debug("Extracted subtitle (PascalCase fallback): '{}'", subtitle);
|
||||
}
|
||||
if (StringUtils.isNotBlank(subtitle)) {
|
||||
builder.subtitle(subtitle.trim());
|
||||
}
|
||||
|
||||
// ISBNs from Booklore namespace
|
||||
String isbn13 = extractBookloreField(xpath, doc, "isbn13");
|
||||
if (StringUtils.isNotBlank(isbn13)) {
|
||||
builder.isbn13(isbn13.trim());
|
||||
}
|
||||
|
||||
String isbn10 = extractBookloreField(xpath, doc, "isbn10");
|
||||
if (StringUtils.isNotBlank(isbn10)) {
|
||||
builder.isbn10(isbn10.trim());
|
||||
}
|
||||
|
||||
// External IDs from Booklore namespace
|
||||
String googleId = extractBookloreField(xpath, doc, "googleId");
|
||||
if (StringUtils.isNotBlank(googleId)) {
|
||||
builder.googleId(googleId.trim());
|
||||
}
|
||||
|
||||
String goodreadsId = extractBookloreField(xpath, doc, "goodreadsId");
|
||||
if (StringUtils.isNotBlank(goodreadsId)) {
|
||||
builder.goodreadsId(goodreadsId.trim());
|
||||
}
|
||||
|
||||
String hardcoverId = extractBookloreField(xpath, doc, "hardcoverId");
|
||||
if (StringUtils.isNotBlank(hardcoverId)) {
|
||||
builder.hardcoverId(hardcoverId.trim());
|
||||
}
|
||||
|
||||
String hardcoverBookId = extractBookloreField(xpath, doc, "hardcoverBookId");
|
||||
if (StringUtils.isNotBlank(hardcoverBookId)) {
|
||||
builder.hardcoverBookId(hardcoverBookId.trim());
|
||||
}
|
||||
|
||||
String asin = extractBookloreField(xpath, doc, "asin");
|
||||
if (StringUtils.isNotBlank(asin)) {
|
||||
builder.asin(asin.trim());
|
||||
}
|
||||
|
||||
String comicvineId = extractBookloreField(xpath, doc, "comicvineId");
|
||||
if (StringUtils.isNotBlank(comicvineId)) {
|
||||
builder.comicvineId(comicvineId.trim());
|
||||
}
|
||||
|
||||
String lubimyczytacId = extractBookloreField(xpath, doc, "lubimyczytacId");
|
||||
if (StringUtils.isNotBlank(lubimyczytacId)) {
|
||||
builder.lubimyczytacId(lubimyczytacId.trim());
|
||||
}
|
||||
|
||||
String ranobedbId = extractBookloreField(xpath, doc, "ranobedbId");
|
||||
if (StringUtils.isNotBlank(ranobedbId)) {
|
||||
builder.ranobedbId(ranobedbId.trim());
|
||||
}
|
||||
|
||||
// Page count: Do NOT read from XMP metadata for PDFs.
|
||||
// The actual PDF page count (from pdf.getNumberOfPages()) is set earlier
|
||||
// and should not be overridden by metadata that describes the original book.
|
||||
|
||||
// Moods (try new RDF Bag format first, then legacy semicolon-separated)
|
||||
Set<String> moods = extractBookloreBag(xpath, doc, "moods");
|
||||
log.debug("Extracted moods from RDF Bag: {}", moods);
|
||||
if (moods.isEmpty()) {
|
||||
// Legacy format support
|
||||
String moodsLegacy = xpath.evaluate("//booklore:Moods/text()", doc);
|
||||
log.debug("Legacy moods string: '{}'", moodsLegacy);
|
||||
if (StringUtils.isNotBlank(moodsLegacy)) {
|
||||
moods = Arrays.stream(moodsLegacy.split(";"))
|
||||
.map(String::trim)
|
||||
.filter(StringUtils::isNotBlank)
|
||||
.collect(Collectors.toSet());
|
||||
}
|
||||
}
|
||||
if (!moods.isEmpty()) {
|
||||
builder.moods(moods);
|
||||
}
|
||||
|
||||
// Tags (try new RDF Bag format first, then legacy semicolon-separated)
|
||||
Set<String> tags = extractBookloreBag(xpath, doc, "tags");
|
||||
log.debug("Extracted tags from RDF Bag: {}", tags);
|
||||
if (tags.isEmpty()) {
|
||||
// Legacy format support
|
||||
String tagsLegacy = xpath.evaluate("//booklore:Tags/text()", doc);
|
||||
log.debug("Legacy tags string: '{}'", tagsLegacy);
|
||||
if (StringUtils.isNotBlank(tagsLegacy)) {
|
||||
tags = Arrays.stream(tagsLegacy.split(";"))
|
||||
.map(String::trim)
|
||||
.filter(StringUtils::isNotBlank)
|
||||
.collect(Collectors.toSet());
|
||||
}
|
||||
}
|
||||
if (!tags.isEmpty()) {
|
||||
builder.tags(tags);
|
||||
}
|
||||
|
||||
// Ratings (try both old PascalCase and new camelCase)
|
||||
extractBookloreRating(xpath, doc, "amazonRating", "AmazonRating", builder::amazonRating);
|
||||
extractBookloreRating(xpath, doc, "goodreadsRating", "GoodreadsRating", builder::goodreadsRating);
|
||||
extractBookloreRating(xpath, doc, "hardcoverRating", "HardcoverRating", builder::hardcoverRating);
|
||||
extractBookloreRating(xpath, doc, "lubimyczytacRating", "LubimyczytacRating", builder::lubimyczytacRating);
|
||||
extractBookloreRating(xpath, doc, "ranobedbRating", "RanobedbRating", builder::ranobedbRating);
|
||||
|
||||
// User rating
|
||||
extractBookloreRating(xpath, doc, "rating", "Rating", builder::rating);
|
||||
|
||||
} catch (Exception e) {
|
||||
log.warn("Failed to extract booklore metadata: {}", e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts a simple text field from Booklore namespace.
|
||||
*/
|
||||
private String extractBookloreField(XPath xpath, Document doc, String fieldName) {
|
||||
try {
|
||||
return xpath.evaluate("//booklore:" + fieldName + "/text()", doc);
|
||||
} catch (Exception e) {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts an RDF Bag as a Set of strings from Booklore namespace.
|
||||
*/
|
||||
private Set<String> extractBookloreBag(XPath xpath, Document doc, String fieldName) {
|
||||
Set<String> values = new HashSet<>();
|
||||
try {
|
||||
String xpathExpr = "//booklore:" + fieldName + "/rdf:Bag/rdf:li/text()";
|
||||
log.debug("Executing XPath for {}: {}", fieldName, xpathExpr);
|
||||
NodeList nodes = (NodeList) xpath.evaluate(xpathExpr, doc, XPathConstants.NODESET);
|
||||
log.debug("XPath for {} returned {} nodes", fieldName, nodes != null ? nodes.getLength() : 0);
|
||||
if (nodes != null) {
|
||||
for (int i = 0; i < nodes.getLength(); i++) {
|
||||
String text = nodes.item(i).getNodeValue();
|
||||
if (StringUtils.isNotBlank(text)) {
|
||||
values.add(text.trim());
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn("Failed to extract RDF Bag for booklore:{}: {}", fieldName, e.getMessage());
|
||||
}
|
||||
return values;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts a rating field, trying both new camelCase and old PascalCase format.
|
||||
*/
|
||||
private void extractBookloreRating(XPath xpath, Document doc, String newName, String legacyName,
|
||||
java.util.function.Consumer<Double> setter) {
|
||||
try {
|
||||
String value = xpath.evaluate("//booklore:" + newName + "/text()", doc);
|
||||
if (StringUtils.isBlank(value)) {
|
||||
value = xpath.evaluate("//booklore:" + legacyName + "/text()", doc);
|
||||
}
|
||||
if (StringUtils.isNotBlank(value)) {
|
||||
setter.accept(Double.parseDouble(value.trim()));
|
||||
}
|
||||
} catch (NumberFormatException e) {
|
||||
log.warn("Invalid rating value for {}", newName);
|
||||
} catch (Exception e) {
|
||||
// Ignore
|
||||
}
|
||||
}
|
||||
|
||||
private Map<String, String> extractIdentifiers(XPath xpath, Document doc) throws XPathExpressionException {
|
||||
Map<String, String> ids = new HashMap<>();
|
||||
NodeList idNodes = (NodeList) xpath.evaluate(
|
||||
@@ -329,6 +590,7 @@ public class PdfMetadataExtractor implements FileMetadataExtractor {
|
||||
prefixMap.put("xmpidq", "http://ns.adobe.com/xmp/Identifier/qual/1.0/");
|
||||
prefixMap.put("calibre", "http://calibre-ebook.com/xmp-namespace");
|
||||
prefixMap.put("calibreSI", "http://calibre-ebook.com/xmp-namespace/seriesIndex");
|
||||
prefixMap.put("booklore", "http://booklore.org/metadata/1.0/");
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
package org.booklore.service.metadata.sidecar;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.booklore.model.dto.sidecar.SidecarMetadata;
|
||||
import org.booklore.model.entity.BookEntity;
|
||||
import org.booklore.model.entity.BookMetadataEntity;
|
||||
import org.booklore.model.enums.SidecarSyncStatus;
|
||||
import org.springframework.stereotype.Service;
|
||||
import tools.jackson.databind.ObjectMapper;
|
||||
import tools.jackson.databind.json.JsonMapper;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
@@ -23,8 +23,9 @@ public class SidecarMetadataReader {
|
||||
|
||||
public SidecarMetadataReader(SidecarMetadataMapper mapper) {
|
||||
this.mapper = mapper;
|
||||
this.objectMapper = new ObjectMapper();
|
||||
this.objectMapper.registerModule(new JavaTimeModule());
|
||||
this.objectMapper = JsonMapper.builder()
|
||||
.findAndAddModules()
|
||||
.build();
|
||||
}
|
||||
|
||||
public Optional<SidecarMetadata> readSidecarMetadata(Path bookPath) {
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
package org.booklore.service.metadata.sidecar;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.databind.SerializationFeature;
|
||||
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.booklore.model.dto.settings.MetadataPersistenceSettings;
|
||||
import org.booklore.model.dto.settings.SidecarSettings;
|
||||
@@ -12,6 +9,9 @@ import org.booklore.model.entity.BookMetadataEntity;
|
||||
import org.booklore.service.appsettings.AppSettingService;
|
||||
import org.booklore.util.FileService;
|
||||
import org.springframework.stereotype.Service;
|
||||
import tools.jackson.databind.ObjectMapper;
|
||||
import tools.jackson.databind.SerializationFeature;
|
||||
import tools.jackson.databind.json.JsonMapper;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
@@ -31,10 +31,10 @@ public class SidecarMetadataWriter {
|
||||
this.mapper = mapper;
|
||||
this.fileService = fileService;
|
||||
this.appSettingService = appSettingService;
|
||||
this.objectMapper = new ObjectMapper();
|
||||
this.objectMapper.registerModule(new JavaTimeModule());
|
||||
this.objectMapper.enable(SerializationFeature.INDENT_OUTPUT);
|
||||
this.objectMapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
|
||||
this.objectMapper = JsonMapper.builder()
|
||||
.findAndAddModules()
|
||||
.configure(SerializationFeature.INDENT_OUTPUT, true)
|
||||
.build();
|
||||
}
|
||||
|
||||
public void writeSidecarMetadata(BookEntity book) {
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
package org.booklore.service.metadata.writer;
|
||||
|
||||
import org.apache.xmpbox.XMPMetadata;
|
||||
import org.apache.xmpbox.schema.XMPSchema;
|
||||
|
||||
import java.util.Locale;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
public class BookLoreSchema extends XMPSchema {
|
||||
|
||||
public static final String NAMESPACE = "http://booklore.org/metadata/1.0/";
|
||||
public static final String PREFIX = "booklore";
|
||||
|
||||
public static final String SUBTITLE = "Subtitle";
|
||||
public static final String MOODS = "Moods";
|
||||
public static final String TAGS = "Tags";
|
||||
public static final String RATING = "Rating";
|
||||
public static final String AMAZON_RATING = "AmazonRating";
|
||||
public static final String GOODREADS_RATING = "GoodreadsRating";
|
||||
public static final String HARDCOVER_RATING = "HardcoverRating";
|
||||
public static final String LUBIMYCZYTAC_RATING = "LubimyczytacRating";
|
||||
public static final String RANOBEDB_RATING = "RanobedbRating";
|
||||
public static final String LUBIMYCZYTAC_ID = "LubimyczytacId";
|
||||
public static final String HARDCOVER_BOOK_ID = "HardcoverBookId";
|
||||
public static final String ISBN_10 = "ISBN10";
|
||||
|
||||
public BookLoreSchema(XMPMetadata metadata) {
|
||||
super(metadata, PREFIX, NAMESPACE);
|
||||
}
|
||||
|
||||
|
||||
public void setSubtitle(String subtitle) {
|
||||
if (subtitle != null && !subtitle.isBlank()) {
|
||||
setTextPropertyValue(SUBTITLE, subtitle);
|
||||
}
|
||||
}
|
||||
|
||||
public void setIsbn10(String isbn10) {
|
||||
if (isbn10 != null && !isbn10.isBlank()) {
|
||||
setTextPropertyValue(ISBN_10, isbn10);
|
||||
}
|
||||
}
|
||||
|
||||
public void setLubimyczytacId(String id) {
|
||||
if (id != null && !id.isBlank()) {
|
||||
setTextPropertyValue(LUBIMYCZYTAC_ID, id);
|
||||
}
|
||||
}
|
||||
|
||||
public void setHardcoverBookId(Integer id) {
|
||||
if (id != null) {
|
||||
setTextPropertyValue(HARDCOVER_BOOK_ID, id.toString());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
public void setRating(Double rating) {
|
||||
if (rating != null) {
|
||||
setTextPropertyValue(RATING, String.format(Locale.US, "%.2f", rating));
|
||||
}
|
||||
}
|
||||
|
||||
public void setAmazonRating(Double rating) {
|
||||
if (rating != null) {
|
||||
setTextPropertyValue(AMAZON_RATING, String.format(Locale.US, "%.2f", rating));
|
||||
}
|
||||
}
|
||||
|
||||
public void setGoodreadsRating(Double rating) {
|
||||
if (rating != null) {
|
||||
setTextPropertyValue(GOODREADS_RATING, String.format(Locale.US, "%.2f", rating));
|
||||
}
|
||||
}
|
||||
|
||||
public void setHardcoverRating(Double rating) {
|
||||
if (rating != null) {
|
||||
setTextPropertyValue(HARDCOVER_RATING, String.format(Locale.US, "%.2f", rating));
|
||||
}
|
||||
}
|
||||
|
||||
public void setLubimyczytacRating(Double rating) {
|
||||
if (rating != null) {
|
||||
setTextPropertyValue(LUBIMYCZYTAC_RATING, String.format(Locale.US, "%.2f", rating));
|
||||
}
|
||||
}
|
||||
|
||||
public void setRanobedbRating(Double rating) {
|
||||
if (rating != null) {
|
||||
setTextPropertyValue(RANOBEDB_RATING, String.format(Locale.US, "%.2f", rating));
|
||||
}
|
||||
}
|
||||
|
||||
public void setMoods(Set<String> moods) {
|
||||
if (moods == null || moods.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
String joined = moods.stream()
|
||||
.filter(m -> m != null && !m.isBlank())
|
||||
.collect(Collectors.joining("; "));
|
||||
if (!joined.isEmpty()) {
|
||||
setTextPropertyValue(MOODS, joined);
|
||||
}
|
||||
}
|
||||
|
||||
public void setTags(Set<String> tags) {
|
||||
if (tags == null || tags.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
String joined = tags.stream()
|
||||
.filter(t -> t != null && !t.isBlank())
|
||||
.collect(Collectors.joining("; "));
|
||||
if (!joined.isEmpty()) {
|
||||
setTextPropertyValue(TAGS, joined);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,41 +1,36 @@
|
||||
package org.booklore.service.metadata.writer;
|
||||
|
||||
import org.booklore.model.MetadataClearFlags;
|
||||
import org.booklore.model.dto.settings.MetadataPersistenceSettings;
|
||||
import org.booklore.model.entity.BookMetadataEntity;
|
||||
import org.booklore.model.enums.BookFileType;
|
||||
import org.booklore.service.appsettings.AppSettingService;
|
||||
import org.booklore.util.ArchiveUtils;
|
||||
import com.github.junrar.Archive;
|
||||
import com.github.junrar.rarfile.FileHeader;
|
||||
import jakarta.xml.bind.JAXBContext;
|
||||
import jakarta.xml.bind.Marshaller;
|
||||
import jakarta.xml.bind.Unmarshaller;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.commons.compress.archivers.sevenz.SevenZArchiveEntry;
|
||||
import org.apache.commons.compress.archivers.sevenz.SevenZFile;
|
||||
import org.booklore.model.MetadataClearFlags;
|
||||
import org.booklore.model.dto.settings.MetadataPersistenceSettings;
|
||||
import org.booklore.model.entity.*;
|
||||
import org.booklore.model.enums.BookFileType;
|
||||
import org.booklore.model.enums.ComicCreatorRole;
|
||||
import org.booklore.service.appsettings.AppSettingService;
|
||||
import org.booklore.util.ArchiveUtils;
|
||||
import org.jsoup.Jsoup;
|
||||
import org.jsoup.safety.Safelist;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.w3c.dom.Document;
|
||||
import org.w3c.dom.Element;
|
||||
import org.w3c.dom.NodeList;
|
||||
|
||||
import javax.xml.XMLConstants;
|
||||
import javax.xml.parsers.DocumentBuilder;
|
||||
import javax.xml.parsers.DocumentBuilderFactory;
|
||||
import javax.xml.transform.OutputKeys;
|
||||
import javax.xml.transform.Transformer;
|
||||
import javax.xml.transform.TransformerFactory;
|
||||
import javax.xml.transform.dom.DOMSource;
|
||||
import javax.xml.transform.stream.StreamResult;
|
||||
import java.io.*;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.StandardCopyOption;
|
||||
import java.nio.file.StandardOpenOption;
|
||||
import java.time.LocalDate;
|
||||
import java.util.Comparator;
|
||||
import java.util.Enumeration;
|
||||
import java.util.Locale;
|
||||
import java.util.Set;
|
||||
import java.util.regex.Pattern;
|
||||
import java.util.stream.Collectors;
|
||||
import java.util.zip.ZipEntry;
|
||||
import java.util.zip.ZipFile;
|
||||
import java.util.zip.ZipOutputStream;
|
||||
@@ -48,6 +43,21 @@ public class CbxMetadataWriter implements MetadataWriter {
|
||||
private static final Pattern VALID_FILENAME_PATTERN = Pattern.compile("^[\\w./\\\\-]+$");
|
||||
private static final int BUFFER_SIZE = 8192;
|
||||
|
||||
// Cache JAXBContext for performance
|
||||
private static final JAXBContext JAXB_CONTEXT;
|
||||
|
||||
static {
|
||||
// XXE protection: Disable external DTD and Schema access for security
|
||||
System.setProperty("javax.xml.accessExternalDTD", "");
|
||||
System.setProperty("javax.xml.accessExternalSchema", "");
|
||||
|
||||
try {
|
||||
JAXB_CONTEXT = JAXBContext.newInstance(ComicInfo.class);
|
||||
} catch (jakarta.xml.bind.JAXBException e) {
|
||||
throw new RuntimeException("Failed to initialize JAXB Context", e);
|
||||
}
|
||||
}
|
||||
|
||||
private final AppSettingService appSettingService;
|
||||
|
||||
@Override
|
||||
@@ -62,7 +72,7 @@ public class CbxMetadataWriter implements MetadataWriter {
|
||||
boolean isCb7 = type == ArchiveUtils.ArchiveType.SEVEN_ZIP;
|
||||
|
||||
if (type == ArchiveUtils.ArchiveType.UNKNOWN) {
|
||||
log.warn("Unsupported file type for CBX writer: {}", file.getName());
|
||||
log.warn("Unknown archive type for file: {}", file.getName());
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -72,19 +82,25 @@ public class CbxMetadataWriter implements MetadataWriter {
|
||||
boolean writeSucceeded = false;
|
||||
|
||||
try {
|
||||
Document xmlDoc = loadOrCreateComicInfoXml(file, isCbz, isCb7, isCbr);
|
||||
applyMetadataChanges(xmlDoc, metadata, clearFlags);
|
||||
byte[] xmlContent = convertDocumentToBytes(xmlDoc);
|
||||
ComicInfo comicInfo = loadOrCreateComicInfo(file, isCbz, isCb7, isCbr);
|
||||
applyMetadataChanges(comicInfo, metadata, clearFlags);
|
||||
byte[] xmlContent = convertToBytes(comicInfo);
|
||||
|
||||
if (isCbz) {
|
||||
log.debug("CbxMetadataWriter: Writing ComicInfo.xml to CBZ file: {}, XML size: {} bytes", file.getName(), xmlContent.length);
|
||||
tempArchive = updateZipArchive(file, xmlContent);
|
||||
writeSucceeded = true;
|
||||
log.info("CbxMetadataWriter: Successfully wrote metadata to CBZ file: {}", file.getName());
|
||||
} else if (isCb7) {
|
||||
log.debug("CbxMetadataWriter: Converting CB7 to CBZ and writing ComicInfo.xml: {}", file.getName());
|
||||
tempArchive = convert7zToZip(file, xmlContent);
|
||||
writeSucceeded = true;
|
||||
log.info("CbxMetadataWriter: Successfully converted CB7 to CBZ and wrote metadata: {}", file.getName());
|
||||
} else {
|
||||
log.debug("CbxMetadataWriter: Writing ComicInfo.xml to RAR file: {}", file.getName());
|
||||
tempArchive = updateRarArchive(file, xmlContent, extractDir);
|
||||
writeSucceeded = true;
|
||||
log.info("CbxMetadataWriter: Successfully wrote metadata to RAR/CBZ file: {}", file.getName());
|
||||
}
|
||||
} catch (Exception e) {
|
||||
restoreOriginalFile(backupPath, file);
|
||||
@@ -123,37 +139,37 @@ public class CbxMetadataWriter implements MetadataWriter {
|
||||
}
|
||||
}
|
||||
|
||||
private Document loadOrCreateComicInfoXml(File file, boolean isCbz, boolean isCb7, boolean isCbr) throws Exception {
|
||||
private ComicInfo loadOrCreateComicInfo(File file, boolean isCbz, boolean isCb7, boolean isCbr) throws Exception {
|
||||
if (isCbz) {
|
||||
return loadXmlFromZip(file);
|
||||
return loadFromZip(file);
|
||||
} else if (isCb7) {
|
||||
return loadXmlFrom7z(file);
|
||||
return loadFrom7z(file);
|
||||
} else {
|
||||
return loadXmlFromRar(file);
|
||||
return loadFromRar(file);
|
||||
}
|
||||
}
|
||||
|
||||
private Document loadXmlFromZip(File file) throws Exception {
|
||||
private ComicInfo loadFromZip(File file) throws Exception {
|
||||
try (ZipFile zipFile = new ZipFile(file)) {
|
||||
ZipEntry xmlEntry = findComicInfoEntry(zipFile);
|
||||
if (xmlEntry != null) {
|
||||
try (InputStream stream = zipFile.getInputStream(xmlEntry)) {
|
||||
return parseXmlSecurely(stream);
|
||||
return parseComicInfo(stream);
|
||||
}
|
||||
}
|
||||
return createEmptyComicInfoXml();
|
||||
return new ComicInfo();
|
||||
}
|
||||
}
|
||||
|
||||
private Document loadXmlFrom7z(File file) throws Exception {
|
||||
private ComicInfo loadFrom7z(File file) throws Exception {
|
||||
try (SevenZFile archive = SevenZFile.builder().setFile(file).get()) {
|
||||
SevenZArchiveEntry xmlEntry = findComicInfoIn7z(archive);
|
||||
if (xmlEntry != null) {
|
||||
try (InputStream stream = archive.getInputStream(xmlEntry)) {
|
||||
return parseXmlSecurely(stream);
|
||||
return parseComicInfo(stream);
|
||||
}
|
||||
}
|
||||
return createEmptyComicInfoXml();
|
||||
return new ComicInfo();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -166,69 +182,317 @@ public class CbxMetadataWriter implements MetadataWriter {
|
||||
return null;
|
||||
}
|
||||
|
||||
private Document loadXmlFromRar(File file) throws Exception {
|
||||
private ComicInfo loadFromRar(File file) throws Exception {
|
||||
try (Archive archive = new Archive(file)) {
|
||||
FileHeader xmlHeader = findComicInfoInRar(archive);
|
||||
if (xmlHeader != null) {
|
||||
try (ByteArrayOutputStream buffer = new ByteArrayOutputStream()) {
|
||||
extractRarEntry(archive, xmlHeader, buffer);
|
||||
try (InputStream stream = new ByteArrayInputStream(buffer.toByteArray())) {
|
||||
return parseXmlSecurely(stream);
|
||||
return parseComicInfo(stream);
|
||||
}
|
||||
}
|
||||
}
|
||||
return createEmptyComicInfoXml();
|
||||
return new ComicInfo();
|
||||
}
|
||||
}
|
||||
|
||||
private void applyMetadataChanges(Document xmlDoc, BookMetadataEntity metadata, MetadataClearFlags clearFlags) {
|
||||
Element rootElement = xmlDoc.getDocumentElement();
|
||||
private void applyMetadataChanges(ComicInfo info, BookMetadataEntity metadata, MetadataClearFlags clearFlags) {
|
||||
MetadataCopyHelper helper = new MetadataCopyHelper(metadata);
|
||||
|
||||
helper.copyTitle(clearFlags != null && clearFlags.isTitle(), val -> updateXmlElement(xmlDoc, rootElement, "Title", val));
|
||||
helper.copyTitle(clearFlags != null && clearFlags.isTitle(), info::setTitle);
|
||||
|
||||
// Summary: Remove HTML tags safely using Jsoup (handles complex HTML like attributes with '>')
|
||||
helper.copyDescription(clearFlags != null && clearFlags.isDescription(), val -> {
|
||||
updateXmlElement(xmlDoc, rootElement, "Summary", val);
|
||||
removeXmlElement(rootElement, "Description");
|
||||
if (val != null) {
|
||||
// Jsoup.clean with Safelist.none() removes all HTML tags safely,
|
||||
// handling edge cases like '<a href="...>">' that regex fails on
|
||||
String clean = Jsoup.clean(val, Safelist.none()).trim();
|
||||
log.debug("CbxMetadataWriter: Setting Summary to: {} (original length: {}, cleaned length: {})",
|
||||
clean.length() > 50 ? clean.substring(0, 50) + "..." : clean,
|
||||
val.length(),
|
||||
clean.length());
|
||||
info.setSummary(clean);
|
||||
} else {
|
||||
log.debug("CbxMetadataWriter: Clearing Summary (null description)");
|
||||
info.setSummary(null);
|
||||
}
|
||||
});
|
||||
helper.copyPublisher(clearFlags != null && clearFlags.isPublisher(), val -> updateXmlElement(xmlDoc, rootElement, "Publisher", val));
|
||||
helper.copySeriesName(clearFlags != null && clearFlags.isSeriesName(), val -> updateXmlElement(xmlDoc, rootElement, "Series", val));
|
||||
helper.copySeriesNumber(clearFlags != null && clearFlags.isSeriesNumber(), val -> updateXmlElement(xmlDoc, rootElement, "Number", formatFloatValue(val)));
|
||||
helper.copySeriesTotal(clearFlags != null && clearFlags.isSeriesTotal(), val -> updateXmlElement(xmlDoc, rootElement, "Count", val != null ? val.toString() : null));
|
||||
helper.copyPublishedDate(clearFlags != null && clearFlags.isPublishedDate(), date -> updateDateElements(xmlDoc, rootElement, date));
|
||||
helper.copyPageCount(clearFlags != null && clearFlags.isPageCount(), val -> updateXmlElement(xmlDoc, rootElement, "PageCount", val != null ? val.toString() : null));
|
||||
helper.copyLanguage(clearFlags != null && clearFlags.isLanguage(), val -> updateXmlElement(xmlDoc, rootElement, "LanguageISO", val));
|
||||
|
||||
helper.copyPublisher(clearFlags != null && clearFlags.isPublisher(), info::setPublisher);
|
||||
helper.copySeriesName(clearFlags != null && clearFlags.isSeriesName(), info::setSeries);
|
||||
helper.copySeriesNumber(clearFlags != null && clearFlags.isSeriesNumber(), val -> info.setNumber(formatFloatValue(val)));
|
||||
helper.copySeriesTotal(clearFlags != null && clearFlags.isSeriesTotal(), info::setCount);
|
||||
|
||||
helper.copyPublishedDate(clearFlags != null && clearFlags.isPublishedDate(), date -> {
|
||||
if (date != null) {
|
||||
info.setYear(date.getYear());
|
||||
info.setMonth(date.getMonthValue());
|
||||
info.setDay(date.getDayOfMonth());
|
||||
} else {
|
||||
info.setYear(null);
|
||||
info.setMonth(null);
|
||||
info.setDay(null);
|
||||
}
|
||||
});
|
||||
|
||||
helper.copyPageCount(clearFlags != null && clearFlags.isPageCount(), info::setPageCount);
|
||||
helper.copyLanguage(clearFlags != null && clearFlags.isLanguage(), info::setLanguageISO);
|
||||
|
||||
helper.copyAuthors(clearFlags != null && clearFlags.isAuthors(), set -> {
|
||||
updateXmlElement(xmlDoc, rootElement, "Writer", joinStrings(set));
|
||||
removeXmlElement(rootElement, "Penciller");
|
||||
removeXmlElement(rootElement, "Inker");
|
||||
removeXmlElement(rootElement, "Colorist");
|
||||
removeXmlElement(rootElement, "Letterer");
|
||||
removeXmlElement(rootElement, "CoverArtist");
|
||||
info.setWriter(joinStrings(set));
|
||||
info.setPenciller(null);
|
||||
info.setInker(null);
|
||||
info.setColorist(null);
|
||||
info.setLetterer(null);
|
||||
info.setCoverArtist(null);
|
||||
});
|
||||
|
||||
// Genre - categories
|
||||
helper.copyCategories(clearFlags != null && clearFlags.isCategories(), set -> {
|
||||
updateXmlElement(xmlDoc, rootElement, "Genre", joinStrings(set));
|
||||
removeXmlElement(rootElement, "Tags");
|
||||
info.setGenre(joinStrings(set));
|
||||
});
|
||||
|
||||
// Tags - separate from Genre per Anansi v2.1
|
||||
if (metadata.getTags() != null && !metadata.getTags().isEmpty()) {
|
||||
info.setTags(joinStrings(metadata.getTags().stream().map(TagEntity::getName).collect(Collectors.toSet())));
|
||||
}
|
||||
|
||||
// CommunityRating - normalized to 0-5 scale
|
||||
helper.copyRating(false, rating -> {
|
||||
if (rating != null) {
|
||||
double normalized = Math.min(5.0, Math.max(0.0, rating / 2.0));
|
||||
info.setCommunityRating(String.format(Locale.US, "%.1f", normalized));
|
||||
} else {
|
||||
info.setCommunityRating(null);
|
||||
}
|
||||
});
|
||||
|
||||
// Web field - pick one primary
|
||||
String primaryUrl = null;
|
||||
if (metadata.getHardcoverBookId() != null && !metadata.getHardcoverBookId().isBlank()) {
|
||||
primaryUrl = "https://hardcover.app/books/" + metadata.getHardcoverBookId();
|
||||
} else if (metadata.getComicvineId() != null && !metadata.getComicvineId().isBlank()) {
|
||||
primaryUrl = "https://comicvine.gamespot.com/issue/" + metadata.getComicvineId();
|
||||
} else if (metadata.getGoodreadsId() != null && !metadata.getGoodreadsId().isBlank()) {
|
||||
primaryUrl = "https://www.goodreads.com/book/show/" + metadata.getGoodreadsId();
|
||||
} else if (metadata.getAsin() != null && !metadata.getAsin().isBlank()) {
|
||||
primaryUrl = "https://www.amazon.com/dp/" + metadata.getAsin();
|
||||
}
|
||||
info.setWeb(primaryUrl);
|
||||
|
||||
// Notes - Custom Metadata
|
||||
StringBuilder notesBuilder = new StringBuilder();
|
||||
String existingNotes = info.getNotes();
|
||||
|
||||
// Preserve existing notes that don't start with [BookLore
|
||||
if (existingNotes != null && !existingNotes.isBlank()) {
|
||||
String preservedRules = existingNotes.lines()
|
||||
.map(String::trim)
|
||||
.filter(line -> !line.startsWith("[BookLore:") && !line.startsWith("[BookLore]"))
|
||||
.collect(Collectors.joining("\n"));
|
||||
if (!preservedRules.isEmpty()) {
|
||||
notesBuilder.append(preservedRules);
|
||||
}
|
||||
}
|
||||
|
||||
if (metadata.getMoods() != null) {
|
||||
appendBookLoreTag(notesBuilder, "Moods", joinStrings(metadata.getMoods().stream().map(MoodEntity::getName).collect(Collectors.toSet())));
|
||||
}
|
||||
if (metadata.getTags() != null) {
|
||||
appendBookLoreTag(notesBuilder, "Tags", joinStrings(metadata.getTags().stream().map(TagEntity::getName).collect(Collectors.toSet())));
|
||||
}
|
||||
appendBookLoreTag(notesBuilder, "Subtitle", metadata.getSubtitle());
|
||||
|
||||
if (metadata.getIsbn13() != null && !metadata.getIsbn13().isBlank()) {
|
||||
info.setGtin(metadata.getIsbn13());
|
||||
}
|
||||
appendBookLoreTag(notesBuilder, "ISBN10", metadata.getIsbn10());
|
||||
|
||||
appendBookLoreTag(notesBuilder, "AmazonRating", metadata.getAmazonRating());
|
||||
appendBookLoreTag(notesBuilder, "GoodreadsRating", metadata.getGoodreadsRating());
|
||||
appendBookLoreTag(notesBuilder, "HardcoverRating", metadata.getHardcoverRating());
|
||||
appendBookLoreTag(notesBuilder, "LubimyczytacRating", metadata.getLubimyczytacRating());
|
||||
appendBookLoreTag(notesBuilder, "RanobedbRating", metadata.getRanobedbRating());
|
||||
|
||||
appendBookLoreTag(notesBuilder, "HardcoverBookId", metadata.getHardcoverBookId());
|
||||
appendBookLoreTag(notesBuilder, "HardcoverId", metadata.getHardcoverId());
|
||||
appendBookLoreTag(notesBuilder, "LubimyczytacId", metadata.getLubimyczytacId());
|
||||
appendBookLoreTag(notesBuilder, "RanobedbId", metadata.getRanobedbId());
|
||||
appendBookLoreTag(notesBuilder, "GoogleId", metadata.getGoogleId());
|
||||
appendBookLoreTag(notesBuilder, "GoodreadsId", metadata.getGoodreadsId());
|
||||
appendBookLoreTag(notesBuilder, "ASIN", metadata.getAsin());
|
||||
appendBookLoreTag(notesBuilder, "ComicvineId", metadata.getComicvineId());
|
||||
|
||||
// Comic-specific metadata from ComicMetadataEntity
|
||||
ComicMetadataEntity comic = metadata.getComicMetadata();
|
||||
if (comic != null) {
|
||||
// Volume
|
||||
if (comic.getVolumeNumber() != null) {
|
||||
info.setVolume(comic.getVolumeNumber());
|
||||
}
|
||||
|
||||
// Alternate Series
|
||||
if (comic.getAlternateSeries() != null && !comic.getAlternateSeries().isBlank()) {
|
||||
info.setAlternateSeries(comic.getAlternateSeries());
|
||||
}
|
||||
if (comic.getAlternateIssue() != null && !comic.getAlternateIssue().isBlank()) {
|
||||
info.setAlternateNumber(comic.getAlternateIssue());
|
||||
}
|
||||
|
||||
// Story Arc
|
||||
if (comic.getStoryArc() != null && !comic.getStoryArc().isBlank()) {
|
||||
info.setStoryArc(comic.getStoryArc());
|
||||
}
|
||||
|
||||
// Format
|
||||
if (comic.getFormat() != null && !comic.getFormat().isBlank()) {
|
||||
info.setFormat(comic.getFormat());
|
||||
}
|
||||
|
||||
// Imprint
|
||||
if (comic.getImprint() != null && !comic.getImprint().isBlank()) {
|
||||
info.setImprint(comic.getImprint());
|
||||
}
|
||||
|
||||
// BlackAndWhite (Yes/No)
|
||||
if (comic.getBlackAndWhite() != null) {
|
||||
info.setBlackAndWhite(comic.getBlackAndWhite() ? "Yes" : "No");
|
||||
}
|
||||
|
||||
// Manga / Reading Direction
|
||||
if (comic.getManga() != null && comic.getManga()) {
|
||||
if (comic.getReadingDirection() != null && "RTL".equalsIgnoreCase(comic.getReadingDirection())) {
|
||||
info.setManga("YesAndRightToLeft");
|
||||
} else {
|
||||
info.setManga("Yes");
|
||||
}
|
||||
} else if (comic.getManga() != null) {
|
||||
info.setManga("No");
|
||||
}
|
||||
|
||||
// Characters (comma-separated)
|
||||
if (comic.getCharacters() != null && !comic.getCharacters().isEmpty()) {
|
||||
String chars = comic.getCharacters().stream()
|
||||
.map(ComicCharacterEntity::getName)
|
||||
.collect(Collectors.joining(", "));
|
||||
info.setCharacters(chars);
|
||||
}
|
||||
|
||||
// Teams (comma-separated)
|
||||
if (comic.getTeams() != null && !comic.getTeams().isEmpty()) {
|
||||
String teams = comic.getTeams().stream()
|
||||
.map(ComicTeamEntity::getName)
|
||||
.collect(Collectors.joining(", "));
|
||||
info.setTeams(teams);
|
||||
}
|
||||
|
||||
// Locations (comma-separated)
|
||||
if (comic.getLocations() != null && !comic.getLocations().isEmpty()) {
|
||||
String locs = comic.getLocations().stream()
|
||||
.map(ComicLocationEntity::getName)
|
||||
.collect(Collectors.joining(", "));
|
||||
info.setLocations(locs);
|
||||
}
|
||||
|
||||
// Creators by role (overrides the author-based writer if present)
|
||||
if (comic.getCreatorMappings() != null && !comic.getCreatorMappings().isEmpty()) {
|
||||
String pencillers = getCreatorsByRole(comic, ComicCreatorRole.PENCILLER);
|
||||
String inkers = getCreatorsByRole(comic, ComicCreatorRole.INKER);
|
||||
String colorists = getCreatorsByRole(comic, ComicCreatorRole.COLORIST);
|
||||
String letterers = getCreatorsByRole(comic, ComicCreatorRole.LETTERER);
|
||||
String coverArtists = getCreatorsByRole(comic, ComicCreatorRole.COVER_ARTIST);
|
||||
String editors = getCreatorsByRole(comic, ComicCreatorRole.EDITOR);
|
||||
|
||||
if (!pencillers.isEmpty()) info.setPenciller(pencillers);
|
||||
if (!inkers.isEmpty()) info.setInker(inkers);
|
||||
if (!colorists.isEmpty()) info.setColorist(colorists);
|
||||
if (!letterers.isEmpty()) info.setLetterer(letterers);
|
||||
if (!coverArtists.isEmpty()) info.setCoverArtist(coverArtists);
|
||||
if (!editors.isEmpty()) info.setEditor(editors);
|
||||
}
|
||||
|
||||
// Store comic-specific metadata in notes as well
|
||||
appendBookLoreTag(notesBuilder, "VolumeName", comic.getVolumeName());
|
||||
appendBookLoreTag(notesBuilder, "StoryArcNumber", comic.getStoryArcNumber());
|
||||
appendBookLoreTag(notesBuilder, "IssueNumber", comic.getIssueNumber());
|
||||
}
|
||||
|
||||
// Age Rating (from BookMetadataEntity - mapped to ComicInfo AgeRating format)
|
||||
if (metadata.getAgeRating() != null) {
|
||||
info.setAgeRating(mapAgeRatingToComicInfo(metadata.getAgeRating()));
|
||||
}
|
||||
|
||||
info.setNotes(notesBuilder.length() > 0 ? notesBuilder.toString() : null);
|
||||
}
|
||||
|
||||
private String getCreatorsByRole(ComicMetadataEntity comic, ComicCreatorRole role) {
|
||||
if (comic.getCreatorMappings() == null) return "";
|
||||
return comic.getCreatorMappings().stream()
|
||||
.filter(m -> m.getRole() == role)
|
||||
.map(m -> m.getCreator().getName())
|
||||
.collect(Collectors.joining(", "));
|
||||
}
|
||||
|
||||
private String mapAgeRatingToComicInfo(Integer ageRating) {
|
||||
// Map numeric age rating to ComicInfo AgeRating string values
|
||||
if (ageRating == null) return null;
|
||||
if (ageRating >= 18) return "Adults Only 18+";
|
||||
if (ageRating >= 17) return "Mature 17+";
|
||||
if (ageRating >= 15) return "MA15+";
|
||||
if (ageRating >= 13) return "Teen";
|
||||
if (ageRating >= 10) return "Everyone 10+";
|
||||
if (ageRating >= 6) return "Everyone";
|
||||
return "Early Childhood";
|
||||
}
|
||||
|
||||
private byte[] convertDocumentToBytes(Document xmlDoc) throws Exception {
|
||||
Transformer transformer = TransformerFactory.newInstance().newTransformer();
|
||||
transformer.setOutputProperty(OutputKeys.INDENT, "yes");
|
||||
transformer.setOutputProperty(OutputKeys.ENCODING, "UTF-8");
|
||||
private ComicInfo parseComicInfo(InputStream xmlStream) throws Exception {
|
||||
Unmarshaller unmarshaller = JAXB_CONTEXT.createUnmarshaller();
|
||||
unmarshaller.setEventHandler(event -> {
|
||||
if (event.getSeverity() == jakarta.xml.bind.ValidationEvent.WARNING ||
|
||||
event.getSeverity() == jakarta.xml.bind.ValidationEvent.ERROR) {
|
||||
log.warn("JAXB Parsing Issue: {} [Line: {}, Col: {}]",
|
||||
event.getMessage(),
|
||||
event.getLocator().getLineNumber(),
|
||||
event.getLocator().getColumnNumber());
|
||||
}
|
||||
return true; // Continue processing
|
||||
});
|
||||
ComicInfo result = (ComicInfo) unmarshaller.unmarshal(xmlStream);
|
||||
log.debug("CbxMetadataWriter: Parsed ComicInfo - Title: {}, Summary length: {}",
|
||||
result.getTitle(),
|
||||
result.getSummary() != null ? result.getSummary().length() : 0);
|
||||
return result;
|
||||
}
|
||||
|
||||
private byte[] convertToBytes(ComicInfo comicInfo) throws Exception {
|
||||
Marshaller marshaller = JAXB_CONTEXT.createMarshaller();
|
||||
marshaller.setProperty(Marshaller.JAXB_FORMATTED_OUTPUT, true);
|
||||
marshaller.setProperty(Marshaller.JAXB_ENCODING, "UTF-8");
|
||||
marshaller.setProperty(Marshaller.JAXB_FRAGMENT, false);
|
||||
// Ensure 2-space indentation if possible
|
||||
try {
|
||||
marshaller.setProperty("com.sun.xml.bind.indentString", " ");
|
||||
} catch (Exception ignored) {
|
||||
log.debug("Custom indentation property not supported via 'com.sun.xml.bind.indentString'");
|
||||
}
|
||||
|
||||
ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
|
||||
transformer.transform(new DOMSource(xmlDoc), new StreamResult(outputStream));
|
||||
marshaller.marshal(comicInfo, outputStream);
|
||||
return outputStream.toByteArray();
|
||||
}
|
||||
|
||||
private Path updateZipArchive(File originalFile, byte[] xmlContent) throws Exception {
|
||||
Path tempArchive = Files.createTempFile("cbx_edit", ".cbz");
|
||||
// Create temp file in same directory as original for true atomic move on same filesystem
|
||||
Path tempArchive = Files.createTempFile(originalFile.toPath().getParent(), ".cbx_edit_", ".cbz");
|
||||
rebuildZipWithNewXml(originalFile.toPath(), tempArchive, xmlContent);
|
||||
replaceFileAtomic(tempArchive, originalFile.toPath());
|
||||
return null; // temp file already moved
|
||||
return null;
|
||||
}
|
||||
|
||||
private Path convert7zToZip(File original7z, byte[] xmlContent) throws Exception {
|
||||
Path tempZip = Files.createTempFile("cbx_edit", ".cbz");
|
||||
// Create temp file in same directory as original for true atomic move on same filesystem
|
||||
Path tempZip = Files.createTempFile(original7z.toPath().getParent(), ".cbx_edit_", ".cbz");
|
||||
repack7zToZipWithXml(original7z, tempZip, xmlContent);
|
||||
|
||||
Path targetPath = original7z.toPath().resolveSibling(removeFileExtension(original7z.getName()) + ".cbz");
|
||||
@@ -330,7 +594,8 @@ public class CbxMetadataWriter implements MetadataWriter {
|
||||
}
|
||||
|
||||
private Path convertRarToZipArchive(File rarFile, byte[] xmlContent) throws Exception {
|
||||
Path tempZip = Files.createTempFile("cbx_edit", ".cbz");
|
||||
// Create temp file in same directory as original for true atomic move on same filesystem
|
||||
Path tempZip = Files.createTempFile(rarFile.toPath().getParent(), ".cbx_edit_", ".cbz");
|
||||
|
||||
try (Archive rarArchive = new Archive(rarFile);
|
||||
ZipOutputStream zipOutput = new ZipOutputStream(Files.newOutputStream(tempZip))) {
|
||||
@@ -416,55 +681,6 @@ public class CbxMetadataWriter implements MetadataWriter {
|
||||
return null;
|
||||
}
|
||||
|
||||
private Document parseXmlSecurely(InputStream xmlStream) throws Exception {
|
||||
DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();
|
||||
factory.setFeature(XMLConstants.FEATURE_SECURE_PROCESSING, true);
|
||||
factory.setAttribute(XMLConstants.ACCESS_EXTERNAL_DTD, "");
|
||||
factory.setAttribute(XMLConstants.ACCESS_EXTERNAL_SCHEMA, "");
|
||||
factory.setExpandEntityReferences(false);
|
||||
DocumentBuilder builder = factory.newDocumentBuilder();
|
||||
return builder.parse(xmlStream);
|
||||
}
|
||||
|
||||
private Document createEmptyComicInfoXml() throws Exception {
|
||||
DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();
|
||||
factory.setFeature(XMLConstants.FEATURE_SECURE_PROCESSING, true);
|
||||
factory.setAttribute(XMLConstants.ACCESS_EXTERNAL_DTD, "");
|
||||
factory.setAttribute(XMLConstants.ACCESS_EXTERNAL_SCHEMA, "");
|
||||
DocumentBuilder builder = factory.newDocumentBuilder();
|
||||
Document xmlDoc = builder.newDocument();
|
||||
xmlDoc.appendChild(xmlDoc.createElement("ComicInfo"));
|
||||
return xmlDoc;
|
||||
}
|
||||
|
||||
private void updateXmlElement(Document xmlDoc, Element rootElement, String tagName, String value) {
|
||||
removeXmlElement(rootElement, tagName);
|
||||
if (value != null && !value.isBlank()) {
|
||||
Element element = xmlDoc.createElement(tagName);
|
||||
element.setTextContent(value);
|
||||
rootElement.appendChild(element);
|
||||
}
|
||||
}
|
||||
|
||||
private void removeXmlElement(Element rootElement, String tagName) {
|
||||
NodeList nodes = rootElement.getElementsByTagName(tagName);
|
||||
for (int i = nodes.getLength() - 1; i >= 0; i--) {
|
||||
rootElement.removeChild(nodes.item(i));
|
||||
}
|
||||
}
|
||||
|
||||
private void updateDateElements(Document xmlDoc, Element rootElement, LocalDate date) {
|
||||
if (date == null) {
|
||||
removeXmlElement(rootElement, "Year");
|
||||
removeXmlElement(rootElement, "Month");
|
||||
removeXmlElement(rootElement, "Day");
|
||||
return;
|
||||
}
|
||||
updateXmlElement(xmlDoc, rootElement, "Year", Integer.toString(date.getYear()));
|
||||
updateXmlElement(xmlDoc, rootElement, "Month", Integer.toString(date.getMonthValue()));
|
||||
updateXmlElement(xmlDoc, rootElement, "Day", Integer.toString(date.getDayOfMonth()));
|
||||
}
|
||||
|
||||
private String joinStrings(Set<String> values) {
|
||||
return (values == null || values.isEmpty()) ? null : String.join(", ", values);
|
||||
}
|
||||
@@ -585,4 +801,21 @@ public class CbxMetadataWriter implements MetadataWriter {
|
||||
log.warn("Failed to clean up temporary directory: {}", directory, e);
|
||||
}
|
||||
}
|
||||
|
||||
private void appendBookLoreTag(StringBuilder sb, String tag, String value) {
|
||||
if (value != null && !value.isBlank()) {
|
||||
if (sb.length() > 0) sb.append("\n");
|
||||
sb.append("[BookLore:").append(tag).append("] ").append(value);
|
||||
}
|
||||
}
|
||||
|
||||
private void appendBookLoreTag(StringBuilder sb, String tag, Number value) {
|
||||
if (value != null) {
|
||||
if (sb.length() > 0) sb.append("\n");
|
||||
String formatted = (value instanceof Double || value instanceof Float)
|
||||
? String.format(Locale.US, "%.2f", value.doubleValue())
|
||||
: value.toString();
|
||||
sb.append("[BookLore:").append(tag).append("] ").append(formatted);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
package org.booklore.service.metadata.writer;
|
||||
|
||||
import jakarta.xml.bind.annotation.*;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@XmlRootElement(name = "ComicInfo")
|
||||
@XmlAccessorType(XmlAccessType.FIELD)
|
||||
@XmlType(propOrder = {
|
||||
"title",
|
||||
"series",
|
||||
"number",
|
||||
"count",
|
||||
"volume",
|
||||
"alternateSeries",
|
||||
"alternateNumber",
|
||||
"alternateCount",
|
||||
"summary",
|
||||
"notes",
|
||||
"year",
|
||||
"month",
|
||||
"day",
|
||||
"writer",
|
||||
"penciller",
|
||||
"inker",
|
||||
"colorist",
|
||||
"letterer",
|
||||
"coverArtist",
|
||||
"editor",
|
||||
"publisher",
|
||||
"imprint",
|
||||
"genre",
|
||||
"tags",
|
||||
"web",
|
||||
"pageCount",
|
||||
"languageISO",
|
||||
"format",
|
||||
"blackAndWhite",
|
||||
"manga",
|
||||
"characters",
|
||||
"teams",
|
||||
"locations",
|
||||
"scanInformation",
|
||||
"storyArc",
|
||||
"storyArcNumber",
|
||||
"seriesGroup",
|
||||
"ageRating",
|
||||
"pages",
|
||||
"communityRating",
|
||||
"mainCharacterOrTeam",
|
||||
"review",
|
||||
"gtin"
|
||||
})
|
||||
public class ComicInfo {
|
||||
@XmlElement(name = "Title")
|
||||
private String title;
|
||||
|
||||
@XmlElement(name = "Series")
|
||||
private String series;
|
||||
|
||||
@XmlElement(name = "Number")
|
||||
private String number;
|
||||
|
||||
@XmlElement(name = "Count")
|
||||
private Integer count;
|
||||
|
||||
@XmlElement(name = "Volume")
|
||||
private Integer volume;
|
||||
|
||||
@XmlElement(name = "AlternateSeries")
|
||||
private String alternateSeries;
|
||||
|
||||
@XmlElement(name = "AlternateNumber")
|
||||
private String alternateNumber;
|
||||
|
||||
@XmlElement(name = "AlternateCount")
|
||||
private Integer alternateCount;
|
||||
|
||||
@XmlElement(name = "Summary")
|
||||
private String summary;
|
||||
|
||||
@XmlElement(name = "Notes")
|
||||
private String notes;
|
||||
|
||||
@XmlElement(name = "Year")
|
||||
private Integer year;
|
||||
|
||||
@XmlElement(name = "Month")
|
||||
private Integer month;
|
||||
|
||||
@XmlElement(name = "Day")
|
||||
private Integer day;
|
||||
|
||||
@XmlElement(name = "Writer")
|
||||
private String writer;
|
||||
|
||||
@XmlElement(name = "Penciller")
|
||||
private String penciller;
|
||||
|
||||
@XmlElement(name = "Inker")
|
||||
private String inker;
|
||||
|
||||
@XmlElement(name = "Colorist")
|
||||
private String colorist;
|
||||
|
||||
@XmlElement(name = "Letterer")
|
||||
private String letterer;
|
||||
|
||||
@XmlElement(name = "CoverArtist")
|
||||
private String coverArtist;
|
||||
|
||||
@XmlElement(name = "Editor")
|
||||
private String editor;
|
||||
|
||||
@XmlElement(name = "Publisher")
|
||||
private String publisher;
|
||||
|
||||
@XmlElement(name = "Imprint")
|
||||
private String imprint;
|
||||
|
||||
@XmlElement(name = "Genre")
|
||||
private String genre;
|
||||
|
||||
@XmlElement(name = "Tags")
|
||||
private String tags;
|
||||
|
||||
@XmlElement(name = "Web")
|
||||
private String web;
|
||||
|
||||
@XmlElement(name = "PageCount")
|
||||
private Integer pageCount;
|
||||
|
||||
@XmlElement(name = "LanguageISO")
|
||||
private String languageISO;
|
||||
|
||||
@XmlElement(name = "Format")
|
||||
private String format;
|
||||
|
||||
@XmlElement(name = "BlackAndWhite")
|
||||
private String blackAndWhite; // Yes, No, Unknown
|
||||
|
||||
@XmlElement(name = "Manga")
|
||||
private String manga; // YesAndRightToLeft, Unknown, No, Yes
|
||||
|
||||
@XmlElement(name = "Characters")
|
||||
private String characters;
|
||||
|
||||
@XmlElement(name = "Teams")
|
||||
private String teams;
|
||||
|
||||
@XmlElement(name = "Locations")
|
||||
private String locations;
|
||||
|
||||
@XmlElement(name = "ScanInformation")
|
||||
private String scanInformation;
|
||||
|
||||
@XmlElement(name = "StoryArc")
|
||||
private String storyArc;
|
||||
|
||||
@XmlElement(name = "StoryArcNumber")
|
||||
private String storyArcNumber;
|
||||
|
||||
@XmlElement(name = "SeriesGroup")
|
||||
private String seriesGroup;
|
||||
|
||||
@XmlElement(name = "AgeRating")
|
||||
private String ageRating; // Unknown, Adults Only 18+, Early Childhood, Everyone, Everyone 10+, G, Kids to Adults, M, MA15+, Mature 17+, PG, R18+, Rating Pending, T, Teen, X18+
|
||||
|
||||
@XmlElement(name = "Pages")
|
||||
private Pages pages;
|
||||
|
||||
@XmlElement(name = "CommunityRating")
|
||||
private String communityRating; // 0.0 to 5.0
|
||||
|
||||
@XmlElement(name = "MainCharacterOrTeam")
|
||||
private String mainCharacterOrTeam;
|
||||
|
||||
@XmlElement(name = "Review")
|
||||
private String review;
|
||||
|
||||
@XmlElement(name = "GTIN")
|
||||
private String gtin;
|
||||
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@XmlAccessorType(XmlAccessType.FIELD)
|
||||
public static class Pages {}
|
||||
}
|
||||
@@ -1,16 +1,17 @@
|
||||
package org.booklore.service.metadata.writer;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import net.lingala.zip4j.ZipFile;
|
||||
import net.lingala.zip4j.model.ZipParameters;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.booklore.model.MetadataClearFlags;
|
||||
import org.booklore.model.dto.settings.MetadataPersistenceSettings;
|
||||
import org.booklore.model.entity.BookEntity;
|
||||
import org.booklore.model.entity.BookMetadataEntity;
|
||||
import org.booklore.model.enums.BookFileType;
|
||||
import org.booklore.service.appsettings.AppSettingService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import net.lingala.zip4j.ZipFile;
|
||||
import net.lingala.zip4j.model.ZipParameters;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.booklore.util.SecureXmlUtils;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
import org.w3c.dom.Document;
|
||||
@@ -20,7 +21,6 @@ import org.w3c.dom.NodeList;
|
||||
import org.xml.sax.SAXException;
|
||||
|
||||
import javax.xml.parsers.DocumentBuilder;
|
||||
import javax.xml.parsers.DocumentBuilderFactory;
|
||||
import javax.xml.parsers.ParserConfigurationException;
|
||||
import javax.xml.transform.OutputKeys;
|
||||
import javax.xml.transform.Transformer;
|
||||
@@ -78,9 +78,7 @@ public class EpubMetadataWriter implements MetadataWriter {
|
||||
return;
|
||||
}
|
||||
|
||||
DocumentBuilderFactory dbf = DocumentBuilderFactory.newInstance();
|
||||
dbf.setNamespaceAware(true);
|
||||
DocumentBuilder builder = dbf.newDocumentBuilder();
|
||||
DocumentBuilder builder = SecureXmlUtils.createSecureDocumentBuilder(true);
|
||||
Document opfDoc = builder.parse(opfFile);
|
||||
|
||||
NodeList metadataList = opfDoc.getElementsByTagNameNS(OPF_NS, "metadata");
|
||||
@@ -369,9 +367,7 @@ public class EpubMetadataWriter implements MetadataWriter {
|
||||
return;
|
||||
}
|
||||
|
||||
DocumentBuilderFactory dbf = DocumentBuilderFactory.newInstance();
|
||||
dbf.setNamespaceAware(true);
|
||||
DocumentBuilder builder = dbf.newDocumentBuilder();
|
||||
DocumentBuilder builder = org.booklore.util.SecureXmlUtils.createSecureDocumentBuilder(true);
|
||||
Document opfDoc = builder.parse(opfFile);
|
||||
|
||||
applyCoverImageToEpub(tempDir, opfDoc, coverData);
|
||||
@@ -490,7 +486,8 @@ public class EpubMetadataWriter implements MetadataWriter {
|
||||
throw new IOException("container.xml not found at expected location: " + containerXml);
|
||||
}
|
||||
|
||||
Document containerDoc = DocumentBuilderFactory.newInstance().newDocumentBuilder().parse(containerXml.toFile());
|
||||
DocumentBuilder builder = org.booklore.util.SecureXmlUtils.createSecureDocumentBuilder(false);
|
||||
Document containerDoc = builder.parse(containerXml.toFile());
|
||||
Node rootfile = containerDoc.getElementsByTagName("rootfile").item(0);
|
||||
if (rootfile == null) {
|
||||
throw new IOException("No <rootfile> found in container.xml");
|
||||
@@ -757,7 +754,11 @@ public class EpubMetadataWriter implements MetadataWriter {
|
||||
positionMeta.setPrefix("opf");
|
||||
positionMeta.setAttribute("property", "group-position");
|
||||
positionMeta.setAttribute("refines", "#" + collectionId);
|
||||
positionMeta.setTextContent(String.format("%.0f", seriesNumber));
|
||||
if (seriesNumber % 1.0f == 0) {
|
||||
positionMeta.setTextContent(String.format("%.0f", seriesNumber));
|
||||
} else {
|
||||
positionMeta.setTextContent(String.valueOf(seriesNumber));
|
||||
}
|
||||
metadataElement.appendChild(positionMeta);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
package org.booklore.service.metadata.writer;
|
||||
|
||||
import org.booklore.model.entity.AuthorEntity;
|
||||
import org.booklore.model.entity.BookMetadataEntity;
|
||||
import org.booklore.model.entity.CategoryEntity;
|
||||
import org.booklore.model.entity.*;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.Set;
|
||||
@@ -147,6 +145,8 @@ public class MetadataCopyHelper {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
public void copyLubimyczytacId(boolean clear, Consumer<String> consumer) {
|
||||
if (!isLocked(metadata.getLubimyczytacIdLocked())) {
|
||||
if (clear) consumer.accept(null);
|
||||
@@ -188,4 +188,79 @@ public class MetadataCopyHelper {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void copyMoods(boolean clear, Consumer<Set<String>> consumer) {
|
||||
if (!isLocked(metadata.getMoodsLocked())) {
|
||||
if (clear) {
|
||||
consumer.accept(Set.of());
|
||||
} else if (metadata.getMoods() != null) {
|
||||
Set<String> moods = metadata.getMoods().stream()
|
||||
.map(MoodEntity::getName)
|
||||
.filter(n -> n != null && !n.isBlank())
|
||||
.collect(Collectors.toSet());
|
||||
consumer.accept(moods);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void copyTags(boolean clear, Consumer<Set<String>> consumer) {
|
||||
if (!isLocked(metadata.getTagsLocked())) {
|
||||
if (clear) {
|
||||
consumer.accept(Set.of());
|
||||
} else if (metadata.getTags() != null) {
|
||||
Set<String> tags = metadata.getTags().stream()
|
||||
.map(TagEntity::getName)
|
||||
.filter(n -> n != null && !n.isBlank())
|
||||
.collect(Collectors.toSet());
|
||||
consumer.accept(tags);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void copyRating(boolean clear, Consumer<Double> consumer) {
|
||||
if (clear) {
|
||||
consumer.accept(null);
|
||||
} else if (metadata.getRating() != null) {
|
||||
consumer.accept(metadata.getRating());
|
||||
}
|
||||
}
|
||||
|
||||
public void copyAmazonRating(boolean clear, Consumer<Double> consumer) {
|
||||
if (!isLocked(metadata.getAmazonRatingLocked())) {
|
||||
if (clear) consumer.accept(null);
|
||||
else if (metadata.getAmazonRating() != null) consumer.accept(metadata.getAmazonRating());
|
||||
}
|
||||
}
|
||||
|
||||
public void copyGoodreadsRating(boolean clear, Consumer<Double> consumer) {
|
||||
if (!isLocked(metadata.getGoodreadsRatingLocked())) {
|
||||
if (clear) consumer.accept(null);
|
||||
else if (metadata.getGoodreadsRating() != null) consumer.accept(metadata.getGoodreadsRating());
|
||||
}
|
||||
}
|
||||
|
||||
public void copyHardcoverRating(boolean clear, Consumer<Double> consumer) {
|
||||
if (!isLocked(metadata.getHardcoverRatingLocked())) {
|
||||
if (clear) consumer.accept(null);
|
||||
else if (metadata.getHardcoverRating() != null) consumer.accept(metadata.getHardcoverRating());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
public void copyLubimyczytacRating(boolean clear, Consumer<Double> consumer) {
|
||||
if (!isLocked(metadata.getLubimyczytacRatingLocked())) {
|
||||
if (clear) consumer.accept(null);
|
||||
else if (metadata.getLubimyczytacRating() != null) consumer.accept(metadata.getLubimyczytacRating());
|
||||
}
|
||||
}
|
||||
|
||||
public void copyRanobedbRating(boolean clear, Consumer<Double> consumer) {
|
||||
if (!isLocked(metadata.getRanobedbRatingLocked())) {
|
||||
if (clear) consumer.accept(null);
|
||||
else if (metadata.getRanobedbRating() != null) consumer.accept(metadata.getRanobedbRating());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
@@ -1,28 +1,26 @@
|
||||
package org.booklore.service.metadata.writer;
|
||||
|
||||
import org.booklore.model.MetadataClearFlags;
|
||||
import org.booklore.model.entity.BookMetadataEntity;
|
||||
import org.booklore.model.enums.BookFileType;
|
||||
import org.booklore.service.appsettings.AppSettingService;
|
||||
import org.booklore.model.dto.settings.MetadataPersistenceSettings;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.apache.pdfbox.Loader;
|
||||
import org.apache.pdfbox.io.IOUtils;
|
||||
import org.apache.pdfbox.io.RandomAccessReadBufferedFile;
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.apache.pdfbox.pdmodel.PDDocumentInformation;
|
||||
import org.apache.pdfbox.pdmodel.common.PDMetadata;
|
||||
import org.apache.xmpbox.XMPMetadata;
|
||||
import org.apache.xmpbox.schema.DublinCoreSchema;
|
||||
import org.apache.xmpbox.xml.XmpSerializer;
|
||||
import org.booklore.model.MetadataClearFlags;
|
||||
import org.booklore.model.dto.settings.MetadataPersistenceSettings;
|
||||
import org.booklore.model.entity.BookMetadataEntity;
|
||||
import org.booklore.model.enums.BookFileType;
|
||||
import org.booklore.service.appsettings.AppSettingService;
|
||||
import org.booklore.service.metadata.BookLoreMetadata;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.w3c.dom.Document;
|
||||
import org.w3c.dom.Element;
|
||||
|
||||
import javax.xml.parsers.DocumentBuilder;
|
||||
import javax.xml.parsers.DocumentBuilderFactory;
|
||||
import javax.xml.transform.OutputKeys;
|
||||
import javax.xml.transform.Transformer;
|
||||
import javax.xml.transform.TransformerFactory;
|
||||
@@ -37,10 +35,7 @@ import java.nio.file.Path;
|
||||
import java.nio.file.StandardCopyOption;
|
||||
import java.time.ZoneId;
|
||||
import java.time.ZonedDateTime;
|
||||
import java.util.Calendar;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.UUID;
|
||||
import java.util.*;
|
||||
|
||||
@Slf4j
|
||||
@Component
|
||||
@@ -74,14 +69,14 @@ public class PdfMetadataWriter implements MetadataWriter {
|
||||
log.warn("Could not create PDF temp backup for {}: {}", file.getName(), e.getMessage());
|
||||
}
|
||||
|
||||
try (RandomAccessReadBufferedFile randomAccessRead = new RandomAccessReadBufferedFile(file);
|
||||
PDDocument pdf = Loader.loadPDF(randomAccessRead, IOUtils.createMemoryOnlyStreamCache())) {
|
||||
try (PDDocument pdf = Loader.loadPDF(file, IOUtils.createTempFileOnlyStreamCache())) {
|
||||
pdf.setAllSecurityToBeRemoved(true);
|
||||
applyMetadataToDocument(pdf, metadataEntity, clear);
|
||||
tempFile = File.createTempFile("pdfmeta-", ".pdf");
|
||||
// PDFBox 3.x saves in compressed mode by default
|
||||
pdf.save(tempFile);
|
||||
Files.move(tempFile.toPath(), filePath, StandardCopyOption.REPLACE_EXISTING);
|
||||
tempFile = null; // Prevent deletion in finally block after successful move
|
||||
log.info("Successfully embedded metadata into PDF: {}", file.getName());
|
||||
} catch (Exception e) {
|
||||
log.warn("Failed to write metadata to PDF {}: {}", file.getName(), e.getMessage(), e);
|
||||
@@ -130,14 +125,40 @@ public class PdfMetadataWriter implements MetadataWriter {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Maximum length for PDF Info Dictionary keywords (some older PDF specs limit to 255 bytes)
|
||||
private static final int MAX_INFO_KEYWORDS_LENGTH = 255;
|
||||
|
||||
private void applyMetadataToDocument(PDDocument pdf, BookMetadataEntity entity, MetadataClearFlags clear) {
|
||||
PDDocumentInformation info = pdf.getDocumentInformation();
|
||||
MetadataCopyHelper helper = new MetadataCopyHelper(entity);
|
||||
|
||||
// Build categories-only keywords for PDF legacy compatibility (Info Dictionary)
|
||||
// Moods and tags are stored separately in XMP booklore namespace, so they should NOT be in Info Dict keywords
|
||||
StringBuilder keywordsBuilder = new StringBuilder();
|
||||
helper.copyCategories(clear != null && clear.isCategories(), cats -> {
|
||||
if (cats != null && !cats.isEmpty()) {
|
||||
keywordsBuilder.append(String.join("; ", cats));
|
||||
}
|
||||
});
|
||||
|
||||
helper.copyTitle(clear != null && clear.isTitle(), title -> info.setTitle(title != null ? title : ""));
|
||||
helper.copyPublisher(clear != null && clear.isPublisher(), pub -> info.setProducer(pub != null ? pub : ""));
|
||||
helper.copyAuthors(clear != null && clear.isAuthors(), authors -> info.setAuthor(authors != null ? String.join(", ", authors) : ""));
|
||||
helper.copyCategories(clear != null && clear.isCategories(), cats -> info.setKeywords(cats != null ? String.join(", ", cats) : ""));
|
||||
helper.copyPublishedDate(clear != null && clear.isPublishedDate(), date -> {
|
||||
Calendar cal = Calendar.getInstance();
|
||||
cal.setTimeInMillis((date != null ? date : ZonedDateTime.now().toLocalDate())
|
||||
.atStartOfDay(ZoneId.systemDefault()).toInstant().toEpochMilli());
|
||||
info.setCreationDate(cal);
|
||||
});
|
||||
|
||||
// Truncate keywords for legacy PDF Info Dictionary (255 byte limit in older specs)
|
||||
String keywords = keywordsBuilder.toString();
|
||||
if (keywords.length() > MAX_INFO_KEYWORDS_LENGTH) {
|
||||
keywords = keywords.substring(0, MAX_INFO_KEYWORDS_LENGTH - 3) + "...";
|
||||
log.debug("PDF keywords truncated from {} to {} characters for legacy compatibility",
|
||||
keywordsBuilder.length(), keywords.length());
|
||||
}
|
||||
info.setKeywords(keywords);
|
||||
|
||||
try {
|
||||
XMPMetadata xmp = XMPMetadata.createXMPMetadata();
|
||||
@@ -146,17 +167,45 @@ public class PdfMetadataWriter implements MetadataWriter {
|
||||
helper.copyTitle(clear != null && clear.isTitle(), title -> dc.setTitle(title != null ? title : ""));
|
||||
helper.copyDescription(clear != null && clear.isDescription(), desc -> dc.setDescription(desc != null ? desc : ""));
|
||||
helper.copyPublisher(clear != null && clear.isPublisher(), pub -> dc.addPublisher(pub != null ? pub : ""));
|
||||
helper.copyLanguage(clear != null && clear.isLanguage(), lang -> dc.addLanguage(lang != null ? lang : ""));
|
||||
|
||||
// Write language as provided by user
|
||||
helper.copyLanguage(clear != null && clear.isLanguage(), lang -> {
|
||||
if (lang != null && !lang.isBlank()) {
|
||||
dc.addLanguage(lang);
|
||||
}
|
||||
});
|
||||
|
||||
// Use date-only format for dc:date (YYYY-MM-DD)
|
||||
helper.copyPublishedDate(clear != null && clear.isPublishedDate(), date -> {
|
||||
Calendar cal = Calendar.getInstance();
|
||||
cal.setTimeInMillis((date != null ? date : ZonedDateTime.now().toLocalDate())
|
||||
.atStartOfDay(ZoneId.systemDefault()).toInstant().toEpochMilli());
|
||||
dc.addDate(cal);
|
||||
if (date != null) {
|
||||
// XMPBox requires Calendar, but we can create one with just the date (no time)
|
||||
Calendar cal = Calendar.getInstance();
|
||||
cal.clear(); // Clear time fields
|
||||
cal.set(date.getYear(), date.getMonthValue() - 1, date.getDayOfMonth());
|
||||
dc.addDate(cal);
|
||||
}
|
||||
});
|
||||
|
||||
helper.copyAuthors(clear != null && clear.isAuthors(), authors -> (authors != null ? authors : List.of("")).forEach(dc::addCreator));
|
||||
// Clean author names (normalize whitespace)
|
||||
helper.copyAuthors(clear != null && clear.isAuthors(), authors -> {
|
||||
if (authors != null && !authors.isEmpty()) {
|
||||
authors.stream()
|
||||
.map(name -> name.replaceAll("\\s+", " ").trim())
|
||||
.filter(name -> !name.isBlank())
|
||||
.forEach(dc::addCreator);
|
||||
}
|
||||
});
|
||||
|
||||
helper.copyCategories(clear != null && clear.isCategories(), cats -> (cats != null ? cats : List.of("")).forEach(dc::addSubject));
|
||||
// Add categories as dc:subject
|
||||
helper.copyCategories(clear != null && clear.isCategories(), cats -> {
|
||||
if (cats != null && !cats.isEmpty()) {
|
||||
cats.forEach(dc::addSubject);
|
||||
}
|
||||
});
|
||||
|
||||
// Note: BookLore custom fields (subtitle, ratings, moods, tags as separate field)
|
||||
// are added via raw XML manipulation in addCustomIdentifiersToXmp to avoid XMPBox namespace issues
|
||||
// Moods and tags are stored separately in booklore namespace to avoid confusion with categories
|
||||
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
new XmpSerializer().serialize(xmp, baos, true);
|
||||
@@ -188,69 +237,163 @@ public class PdfMetadataWriter implements MetadataWriter {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Adds custom metadata to XMP using Booklore namespace for all custom fields.
|
||||
* <p>
|
||||
* Namespace strategy:
|
||||
* - Dublin Core (dc:) for title, description, creator, publisher, date, subject, language
|
||||
* - XMP Basic (xmp:) for metadata dates, creator tool
|
||||
* - Booklore (booklore:) for series, subtitle, ISBNs, external IDs, ratings, moods, tags, page count
|
||||
*/
|
||||
private byte[] addCustomIdentifiersToXmp(byte[] xmpBytes, BookMetadataEntity metadata, MetadataCopyHelper helper, MetadataClearFlags clear) throws Exception {
|
||||
DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();
|
||||
factory.setNamespaceAware(true);
|
||||
DocumentBuilder builder = factory.newDocumentBuilder();
|
||||
DocumentBuilder builder = org.booklore.util.SecureXmlUtils.createSecureDocumentBuilder(true);
|
||||
Document doc = builder.parse(new ByteArrayInputStream(xmpBytes));
|
||||
|
||||
Element rdfRoot = (Element) doc.getElementsByTagNameNS("http://www.w3.org/1999/02/22-rdf-syntax-ns#", "RDF").item(0);
|
||||
if (rdfRoot == null) throw new IllegalStateException("RDF root missing in XMP");
|
||||
|
||||
Element rdfDescription = doc.createElementNS("http://www.w3.org/1999/02/22-rdf-syntax-ns#", "rdf:Description");
|
||||
rdfDescription.setAttributeNS("http://www.w3.org/2000/xmlns/", "xmlns:xmp", "http://ns.adobe.com/xap/1.0/");
|
||||
rdfDescription.setAttributeNS("http://www.w3.org/2000/xmlns/", "xmlns:xmpidq", "http://ns.adobe.com/xmp/Identifier/qual/1.0/");
|
||||
rdfDescription.setAttributeNS("http://www.w3.org/1999/02/22-rdf-syntax-ns#", "rdf:about", "");
|
||||
// XMP Basic namespace for tool and date info
|
||||
Element xmpBasicDescription = doc.createElementNS("http://www.w3.org/1999/02/22-rdf-syntax-ns#", "rdf:Description");
|
||||
xmpBasicDescription.setAttributeNS("http://www.w3.org/2000/xmlns/", "xmlns:xmp", "http://ns.adobe.com/xap/1.0/");
|
||||
xmpBasicDescription.setAttributeNS("http://www.w3.org/1999/02/22-rdf-syntax-ns#", "rdf:about", "");
|
||||
|
||||
Element xmpIdentifier = doc.createElementNS("http://ns.adobe.com/xap/1.0/", "xmp:Identifier");
|
||||
Element rdfBag = doc.createElementNS("http://www.w3.org/1999/02/22-rdf-syntax-ns#", "rdf:Bag");
|
||||
|
||||
helper.copyGoogleId(clear != null && clear.isGoogleId(), id -> appendIdentifier(doc, rdfBag, "google", id != null ? id : ""));
|
||||
helper.copyGoodreadsId(clear != null && clear.isGoodreadsId(), id -> appendIdentifier(doc, rdfBag, "goodreads", id != null ? id : ""));
|
||||
helper.copyComicvineId(clear != null && clear.isComicvineId(), id -> appendIdentifier(doc, rdfBag, "comicvine", id != null ? id : ""));
|
||||
helper.copyHardcoverId(clear != null && clear.isHardcoverId(), id -> appendIdentifier(doc, rdfBag, "hardcover", id != null ? id : ""));
|
||||
helper.copyRanobedbId(clear != null && clear.isRanobedbId(), id -> appendIdentifier(doc, rdfBag, "ranobedb", id != null ? id : ""));
|
||||
helper.copyAsin(clear != null && clear.isAsin(), id -> appendIdentifier(doc, rdfBag, "amazon", id != null ? id : ""));
|
||||
helper.copyIsbn13(clear != null && clear.isIsbn13(), id -> appendIdentifier(doc, rdfBag, "isbn", id != null ? id : ""));
|
||||
|
||||
if (rdfBag.hasChildNodes()) {
|
||||
xmpIdentifier.appendChild(rdfBag);
|
||||
rdfDescription.appendChild(xmpIdentifier);
|
||||
xmpBasicDescription.appendChild(createXmpElement(doc, "xmp:CreatorTool", "Booklore"));
|
||||
// Use ISO-8601 format for current timestamps
|
||||
String nowIso = ZonedDateTime.now().format(java.time.format.DateTimeFormatter.ISO_INSTANT);
|
||||
xmpBasicDescription.appendChild(createXmpElement(doc, "xmp:MetadataDate", nowIso));
|
||||
xmpBasicDescription.appendChild(createXmpElement(doc, "xmp:ModifyDate", nowIso));
|
||||
if (metadata.getPublishedDate() != null) {
|
||||
// Use date-only format (YYYY-MM-DD) when we only have a date, not a full timestamp
|
||||
xmpBasicDescription.appendChild(createXmpElement(doc, "xmp:CreateDate",
|
||||
metadata.getPublishedDate().toString()));
|
||||
}
|
||||
|
||||
rdfDescription.appendChild(createSimpleElement(doc, "xmp:MetadataDate", ZonedDateTime.now().toString()));
|
||||
rdfDescription.appendChild(createSimpleElement(doc, "xmp:CreateDate", metadata.getPublishedDate() != null
|
||||
? metadata.getPublishedDate().atStartOfDay(ZoneId.systemDefault()).toString()
|
||||
: ZonedDateTime.now().toString()));
|
||||
rdfDescription.appendChild(createSimpleElement(doc, "xmp:CreatorTool", "Booklore"));
|
||||
rdfDescription.appendChild(createSimpleElement(doc, "xmp:ModifyDate", ZonedDateTime.now().toString()));
|
||||
rdfRoot.appendChild(xmpBasicDescription);
|
||||
|
||||
rdfRoot.appendChild(rdfDescription);
|
||||
// Booklore namespace for all custom metadata
|
||||
Element bookloreDescription = doc.createElementNS("http://www.w3.org/1999/02/22-rdf-syntax-ns#", "rdf:Description");
|
||||
bookloreDescription.setAttributeNS("http://www.w3.org/2000/xmlns/", "xmlns:" + BookLoreMetadata.NS_PREFIX, BookLoreMetadata.NS_URI);
|
||||
bookloreDescription.setAttributeNS("http://www.w3.org/1999/02/22-rdf-syntax-ns#", "rdf:about", "");
|
||||
|
||||
Element calibreDescription = doc.createElementNS("http://www.w3.org/1999/02/22-rdf-syntax-ns#", "rdf:Description");
|
||||
calibreDescription.setAttributeNS("http://www.w3.org/2000/xmlns/", "xmlns:calibre", "http://calibre-ebook.com/xmp-namespace");
|
||||
calibreDescription.setAttributeNS("http://www.w3.org/2000/xmlns/", "xmlns:calibreSI", "http://calibre-ebook.com/xmp-namespace-series-index");
|
||||
calibreDescription.setAttributeNS("http://www.w3.org/1999/02/22-rdf-syntax-ns#", "rdf:about", "");
|
||||
// Series Information - ONLY write if BOTH name AND number are valid
|
||||
// A series name without a number is broken/incomplete data
|
||||
if (hasValidSeries(metadata, clear)) {
|
||||
appendBookloreElement(doc, bookloreDescription, "seriesName", metadata.getSeriesName());
|
||||
appendBookloreElement(doc, bookloreDescription, "seriesNumber", formatSeriesNumber(metadata.getSeriesNumber()));
|
||||
|
||||
// Series total is optional, only write if > 0
|
||||
if (metadata.getSeriesTotal() != null && metadata.getSeriesTotal() > 0) {
|
||||
helper.copySeriesTotal(clear != null && clear.isSeriesTotal(), total -> {
|
||||
if (total != null && total > 0) {
|
||||
appendBookloreElement(doc, bookloreDescription, "seriesTotal", total.toString());
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
helper.copySeriesName(clear != null && clear.isSeriesName(), series -> {
|
||||
Element seriesElem = doc.createElementNS("http://calibre-ebook.com/xmp-namespace", "calibre:series");
|
||||
seriesElem.setAttributeNS("http://www.w3.org/2000/xmlns/", "xmlns:calibreSI", "http://calibre-ebook.com/xmp-namespace-series-index");
|
||||
seriesElem.setAttributeNS("http://www.w3.org/1999/02/22-rdf-syntax-ns#", "rdf:parseType", "Resource");
|
||||
|
||||
Element valueElem = doc.createElementNS("http://www.w3.org/1999/02/22-rdf-syntax-ns#", "rdf:value");
|
||||
valueElem.setTextContent(series != null ? series : "");
|
||||
seriesElem.appendChild(valueElem);
|
||||
|
||||
helper.copySeriesNumber(clear != null && clear.isSeriesNumber(), index -> {
|
||||
Element indexElem = doc.createElementNS("http://calibre-ebook.com/xmp-namespace-series-index", "calibreSI:series_index");
|
||||
indexElem.setTextContent(index != null ? String.format("%.2f", index) : "0.00");
|
||||
seriesElem.appendChild(indexElem);
|
||||
});
|
||||
|
||||
calibreDescription.appendChild(seriesElem);
|
||||
// Subtitle
|
||||
helper.copySubtitle(clear != null && clear.isSubtitle(), subtitle -> {
|
||||
if (subtitle != null && !subtitle.isBlank()) {
|
||||
appendBookloreElement(doc, bookloreDescription, "subtitle", subtitle);
|
||||
}
|
||||
});
|
||||
|
||||
rdfRoot.appendChild(calibreDescription);
|
||||
// ISBN Identifiers
|
||||
helper.copyIsbn13(clear != null && clear.isIsbn13(), isbn -> {
|
||||
if (isbn != null && !isbn.isBlank()) {
|
||||
appendBookloreElement(doc, bookloreDescription, "isbn13", isbn);
|
||||
}
|
||||
});
|
||||
|
||||
helper.copyIsbn10(clear != null && clear.isIsbn10(), isbn -> {
|
||||
if (isbn != null && !isbn.isBlank()) {
|
||||
appendBookloreElement(doc, bookloreDescription, "isbn10", isbn);
|
||||
}
|
||||
});
|
||||
|
||||
// External IDs (only if not blank)
|
||||
helper.copyGoogleId(clear != null && clear.isGoogleId(), id -> {
|
||||
if (id != null && !id.isBlank()) {
|
||||
appendBookloreElement(doc, bookloreDescription, "googleId", id);
|
||||
}
|
||||
});
|
||||
|
||||
helper.copyGoodreadsId(clear != null && clear.isGoodreadsId(), id -> {
|
||||
String normalizedId = normalizeGoodreadsId(id);
|
||||
if (normalizedId != null && !normalizedId.isBlank()) {
|
||||
appendBookloreElement(doc, bookloreDescription, "goodreadsId", normalizedId);
|
||||
}
|
||||
});
|
||||
|
||||
helper.copyHardcoverId(clear != null && clear.isHardcoverId(), id -> {
|
||||
if (id != null && !id.isBlank()) {
|
||||
appendBookloreElement(doc, bookloreDescription, "hardcoverId", id);
|
||||
}
|
||||
});
|
||||
|
||||
helper.copyHardcoverBookId(clear != null && clear.isHardcoverBookId(), id -> {
|
||||
if (id != null && !id.isBlank()) {
|
||||
appendBookloreElement(doc, bookloreDescription, "hardcoverBookId", id);
|
||||
}
|
||||
});
|
||||
|
||||
helper.copyAsin(clear != null && clear.isAsin(), id -> {
|
||||
if (id != null && !id.isBlank()) {
|
||||
appendBookloreElement(doc, bookloreDescription, "asin", id);
|
||||
}
|
||||
});
|
||||
|
||||
helper.copyComicvineId(clear != null && clear.isComicvineId(), id -> {
|
||||
if (id != null && !id.isBlank()) {
|
||||
appendBookloreElement(doc, bookloreDescription, "comicvineId", id);
|
||||
}
|
||||
});
|
||||
|
||||
helper.copyLubimyczytacId(clear != null && clear.isLubimyczytacId(), id -> {
|
||||
if (id != null && !id.isBlank()) {
|
||||
appendBookloreElement(doc, bookloreDescription, "lubimyczytacId", id);
|
||||
}
|
||||
});
|
||||
|
||||
helper.copyRanobedbId(clear != null && clear.isRanobedbId(), id -> {
|
||||
if (id != null && !id.isBlank()) {
|
||||
appendBookloreElement(doc, bookloreDescription, "ranobedbId", id);
|
||||
}
|
||||
});
|
||||
|
||||
// Ratings (only if > 0)
|
||||
helper.copyRating(false, rating -> appendBookloreRating(doc, bookloreDescription, "rating", rating));
|
||||
helper.copyHardcoverRating(clear != null && clear.isHardcoverRating(), rating -> appendBookloreRating(doc, bookloreDescription, "hardcoverRating", rating));
|
||||
helper.copyGoodreadsRating(clear != null && clear.isGoodreadsRating(), rating -> appendBookloreRating(doc, bookloreDescription, "goodreadsRating", rating));
|
||||
helper.copyAmazonRating(clear != null && clear.isAmazonRating(), rating -> appendBookloreRating(doc, bookloreDescription, "amazonRating", rating));
|
||||
helper.copyLubimyczytacRating(clear != null && clear.isLubimyczytacRating(), rating -> appendBookloreRating(doc, bookloreDescription, "lubimyczytacRating", rating));
|
||||
helper.copyRanobedbRating(clear != null && clear.isRanobedbRating(), rating -> appendBookloreRating(doc, bookloreDescription, "ranobedbRating", rating));
|
||||
|
||||
// Tags (as RDF Bag)
|
||||
helper.copyTags(clear != null && clear.isTags(), tags -> {
|
||||
if (tags != null && !tags.isEmpty()) {
|
||||
appendBookloreBag(doc, bookloreDescription, "tags", tags);
|
||||
}
|
||||
});
|
||||
|
||||
// Moods (as RDF Bag)
|
||||
helper.copyMoods(clear != null && clear.isMoods(), moods -> {
|
||||
if (moods != null && !moods.isEmpty()) {
|
||||
appendBookloreBag(doc, bookloreDescription, "moods", moods);
|
||||
}
|
||||
});
|
||||
|
||||
// Page Count
|
||||
helper.copyPageCount(false, pageCount -> {
|
||||
if (pageCount != null && pageCount > 0) {
|
||||
appendBookloreElement(doc, bookloreDescription, "pageCount", pageCount.toString());
|
||||
}
|
||||
});
|
||||
|
||||
if (bookloreDescription.hasChildNodes()) {
|
||||
rdfRoot.appendChild(bookloreDescription);
|
||||
}
|
||||
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
Transformer tf = TransformerFactory.newInstance().newTransformer();
|
||||
@@ -260,36 +403,46 @@ public class PdfMetadataWriter implements MetadataWriter {
|
||||
return baos.toByteArray();
|
||||
}
|
||||
|
||||
private void appendIdentifier(Document doc, Element bag, String scheme, String value) {
|
||||
if (StringUtils.isBlank(value)) return;
|
||||
Element li = doc.createElementNS("http://www.w3.org/1999/02/22-rdf-syntax-ns#", "rdf:li");
|
||||
li.setAttributeNS("http://www.w3.org/1999/02/22-rdf-syntax-ns#", "rdf:parseType", "Resource");
|
||||
|
||||
Element schemeElem = doc.createElementNS("http://ns.adobe.com/xmp/Identifier/qual/1.0/", "xmpidq:Scheme");
|
||||
schemeElem.setTextContent(scheme);
|
||||
|
||||
Element valueElem = doc.createElementNS("http://www.w3.org/1999/02/22-rdf-syntax-ns#", "rdf:value");
|
||||
valueElem.setTextContent(value);
|
||||
|
||||
li.appendChild(schemeElem);
|
||||
li.appendChild(valueElem);
|
||||
bag.appendChild(li);
|
||||
}
|
||||
|
||||
private Element createSimpleElement(Document doc, String name, String content) {
|
||||
String namespace = name.startsWith("calibre:")
|
||||
? "http://calibre-ebook.com/xmp-namespace"
|
||||
: "http://ns.adobe.com/xap/1.0/";
|
||||
|
||||
Element el = doc.createElementNS(namespace, name);
|
||||
private Element createXmpElement(Document doc, String name, String content) {
|
||||
Element el = doc.createElementNS("http://ns.adobe.com/xap/1.0/", name);
|
||||
el.setTextContent(content);
|
||||
return el;
|
||||
}
|
||||
|
||||
private void appendBookloreElement(Document doc, Element parent, String localName, String value) {
|
||||
Element elem = doc.createElementNS(BookLoreMetadata.NS_URI, BookLoreMetadata.NS_PREFIX + ":" + localName);
|
||||
elem.setTextContent(value);
|
||||
parent.appendChild(elem);
|
||||
}
|
||||
|
||||
private void appendBookloreRating(Document doc, Element parent, String localName, Double rating) {
|
||||
if (rating != null && rating > 0) {
|
||||
Element elem = doc.createElementNS(BookLoreMetadata.NS_URI, BookLoreMetadata.NS_PREFIX + ":" + localName);
|
||||
elem.setTextContent(String.format(Locale.US, "%.1f", rating));
|
||||
parent.appendChild(elem);
|
||||
}
|
||||
}
|
||||
|
||||
private void appendBookloreBag(Document doc, Element parent, String localName, Set<String> values) {
|
||||
Element elem = doc.createElementNS(BookLoreMetadata.NS_URI, BookLoreMetadata.NS_PREFIX + ":" + localName);
|
||||
Element rdfBag = doc.createElementNS("http://www.w3.org/1999/02/22-rdf-syntax-ns#", "rdf:Bag");
|
||||
|
||||
for (String value : values) {
|
||||
if (value != null && !value.isBlank()) {
|
||||
Element li = doc.createElementNS("http://www.w3.org/1999/02/22-rdf-syntax-ns#", "rdf:li");
|
||||
li.setTextContent(value);
|
||||
rdfBag.appendChild(li);
|
||||
}
|
||||
}
|
||||
|
||||
elem.appendChild(rdfBag);
|
||||
parent.appendChild(elem);
|
||||
}
|
||||
|
||||
private boolean isXmpMetadataDifferent(byte[] existingBytes, byte[] newBytes) {
|
||||
if (existingBytes == null || newBytes == null) return true;
|
||||
try {
|
||||
DocumentBuilder builder = DocumentBuilderFactory.newInstance().newDocumentBuilder();
|
||||
DocumentBuilder builder = org.booklore.util.SecureXmlUtils.createSecureDocumentBuilder(false);
|
||||
Document doc1 = builder.parse(new ByteArrayInputStream(existingBytes));
|
||||
Document doc2 = builder.parse(new ByteArrayInputStream(newBytes));
|
||||
return !Objects.equals(
|
||||
@@ -301,4 +454,72 @@ public class PdfMetadataWriter implements MetadataWriter {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates that both series name AND series number are present and valid.
|
||||
* A series name without a number (or vice versa) is broken/incomplete data and should not be written.
|
||||
*/
|
||||
private boolean hasValidSeries(BookMetadataEntity metadata, MetadataClearFlags clear) {
|
||||
// If clearing series, don't write it
|
||||
if (clear != null && (clear.isSeriesName() || clear.isSeriesNumber())) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if either field is locked - if so, respect the lock
|
||||
if (Boolean.TRUE.equals(metadata.getSeriesNameLocked()) || Boolean.TRUE.equals(metadata.getSeriesNumberLocked())) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Both name AND number must be valid
|
||||
return metadata.getSeriesName() != null
|
||||
&& !metadata.getSeriesName().isBlank()
|
||||
&& metadata.getSeriesNumber() != null
|
||||
&& metadata.getSeriesNumber() > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats series number nicely: "22" for whole numbers, "1.5" for decimals.
|
||||
* Avoids unnecessary ".00" suffix.
|
||||
*/
|
||||
private String formatSeriesNumber(Float number) {
|
||||
if (number == null) return "0";
|
||||
|
||||
// If it's a whole number, don't show decimal places
|
||||
if (number % 1 == 0) {
|
||||
return String.valueOf(number.intValue());
|
||||
}
|
||||
|
||||
// For decimals, show up to 2 decimal places but trim trailing zeros
|
||||
String formatted = String.format(Locale.US, "%.2f", number);
|
||||
// Remove trailing zeros after decimal point: "1.50" -> "1.5"
|
||||
formatted = formatted.replaceAll("0+$", "").replaceAll("\\.$", "");
|
||||
return formatted;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizes Goodreads ID to extract just the numeric part.
|
||||
* Goodreads URLs/IDs can be in formats like:
|
||||
* - "52555538" (just ID)
|
||||
* - "52555538-dead-simple-python" (ID with slug)
|
||||
* The slug can change but the numeric ID is stable.
|
||||
*/
|
||||
private String normalizeGoodreadsId(String goodreadsId) {
|
||||
if (goodreadsId == null || goodreadsId.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Extract numeric ID from slug format "12345678-book-title"
|
||||
int dashIndex = goodreadsId.indexOf('-');
|
||||
if (dashIndex > 0) {
|
||||
String numericPart = goodreadsId.substring(0, dashIndex);
|
||||
// Validate it's actually numeric
|
||||
if (numericPart.matches("\\d+")) {
|
||||
return numericPart;
|
||||
}
|
||||
}
|
||||
|
||||
// Already just the ID, or return as-is if it's all numeric
|
||||
return goodreadsId.matches("\\d+") ? goodreadsId : goodreadsId;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
package org.booklore.service.reader;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.commons.compress.archivers.zip.ZipArchiveEntry;
|
||||
import org.apache.commons.compress.archivers.zip.ZipFile;
|
||||
import org.apache.pdfbox.io.IOUtils;
|
||||
import org.booklore.exception.ApiError;
|
||||
import org.booklore.model.dto.response.EpubBookInfo;
|
||||
import org.booklore.model.dto.response.EpubManifestItem;
|
||||
@@ -10,18 +15,13 @@ import org.booklore.model.entity.BookFileEntity;
|
||||
import org.booklore.model.enums.BookFileType;
|
||||
import org.booklore.repository.BookRepository;
|
||||
import org.booklore.util.FileUtils;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.commons.compress.archivers.zip.ZipArchiveEntry;
|
||||
import org.apache.commons.compress.archivers.zip.ZipFile;
|
||||
import org.apache.pdfbox.io.IOUtils;
|
||||
import org.booklore.util.SecureXmlUtils;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.w3c.dom.Document;
|
||||
import org.w3c.dom.Element;
|
||||
import org.w3c.dom.NodeList;
|
||||
|
||||
import javax.xml.parsers.DocumentBuilder;
|
||||
import javax.xml.parsers.DocumentBuilderFactory;
|
||||
import java.io.FileNotFoundException;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
@@ -583,13 +583,7 @@ public class EpubReaderService {
|
||||
throw new FileNotFoundException("Entry not found: " + entryPath);
|
||||
}
|
||||
|
||||
DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();
|
||||
factory.setNamespaceAware(true);
|
||||
factory.setFeature("http://apache.org/xml/features/nonvalidating/load-external-dtd", false);
|
||||
factory.setFeature("http://xml.org/sax/features/external-general-entities", false);
|
||||
factory.setFeature("http://xml.org/sax/features/external-parameter-entities", false);
|
||||
|
||||
DocumentBuilder builder = factory.newDocumentBuilder();
|
||||
DocumentBuilder builder = SecureXmlUtils.createSecureDocumentBuilder(true);
|
||||
try (InputStream is = zipFile.getInputStream(entry)) {
|
||||
return builder.parse(is);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package org.booklore.task.tasks;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.booklore.exception.APIException;
|
||||
import org.booklore.model.dto.BookLoreUser;
|
||||
import org.booklore.model.dto.Library;
|
||||
@@ -8,11 +10,9 @@ import org.booklore.model.dto.response.TaskCreateResponse;
|
||||
import org.booklore.model.enums.TaskType;
|
||||
import org.booklore.service.library.LibraryRescanHelper;
|
||||
import org.booklore.service.library.LibraryService;
|
||||
import org.booklore.task.options.RescanLibraryContext;
|
||||
import org.booklore.task.TaskCancellationManager;
|
||||
import org.booklore.task.options.LibraryRescanOptions;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.booklore.task.options.RescanLibraryContext;
|
||||
import org.springframework.dao.InvalidDataAccessApiUsageException;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.stereotype.Component;
|
||||
@@ -37,7 +37,7 @@ public class LibraryRescanTask implements Task {
|
||||
|
||||
@Override
|
||||
public TaskCreateResponse execute(TaskCreateRequest request) {
|
||||
LibraryRescanOptions options = request.getOptions(LibraryRescanOptions.class);
|
||||
LibraryRescanOptions options = request.getOptionsAs(LibraryRescanOptions.class);
|
||||
String taskId = request.getTaskId();
|
||||
|
||||
long startTime = System.currentTimeMillis();
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package org.booklore.task.tasks;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.booklore.model.dto.BookLoreUser;
|
||||
import org.booklore.model.dto.request.MetadataRefreshRequest;
|
||||
import org.booklore.model.dto.request.TaskCreateRequest;
|
||||
@@ -7,12 +9,10 @@ import org.booklore.model.dto.response.TaskCreateResponse;
|
||||
import org.booklore.model.enums.TaskType;
|
||||
import org.booklore.service.metadata.MetadataRefreshService;
|
||||
import org.booklore.task.TaskStatus;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import static org.booklore.model.enums.UserPermission.CAN_BULK_AUTO_FETCH_METADATA;
|
||||
import static org.booklore.exception.ApiError.PERMISSION_DENIED;
|
||||
import static org.booklore.model.enums.UserPermission.CAN_BULK_AUTO_FETCH_METADATA;
|
||||
|
||||
@AllArgsConstructor
|
||||
@Component
|
||||
@@ -23,7 +23,7 @@ public class RefreshMetadataTask implements Task {
|
||||
|
||||
@Override
|
||||
public void validatePermissions(BookLoreUser user, TaskCreateRequest request) {
|
||||
MetadataRefreshRequest refreshRequest = request.getOptions(MetadataRefreshRequest.class);
|
||||
MetadataRefreshRequest refreshRequest = request.getOptionsAs(MetadataRefreshRequest.class);
|
||||
|
||||
if (requiresBulkPermission(refreshRequest) &&
|
||||
!CAN_BULK_AUTO_FETCH_METADATA.isGranted(user.getPermissions())) {
|
||||
@@ -40,7 +40,7 @@ public class RefreshMetadataTask implements Task {
|
||||
|
||||
@Override
|
||||
public TaskCreateResponse execute(TaskCreateRequest request) {
|
||||
MetadataRefreshRequest refreshRequest = request.getOptions(MetadataRefreshRequest.class);
|
||||
MetadataRefreshRequest refreshRequest = request.getOptionsAs(MetadataRefreshRequest.class);
|
||||
String taskId = request.getTaskId();
|
||||
|
||||
long startTime = System.currentTimeMillis();
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
package org.booklore.util;
|
||||
|
||||
import lombok.experimental.UtilityClass;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import javax.xml.XMLConstants;
|
||||
import javax.xml.parsers.DocumentBuilder;
|
||||
import javax.xml.parsers.DocumentBuilderFactory;
|
||||
import javax.xml.parsers.ParserConfigurationException;
|
||||
|
||||
@Slf4j
|
||||
@UtilityClass
|
||||
public class SecureXmlUtils {
|
||||
|
||||
public static DocumentBuilderFactory createSecureDocumentBuilderFactory(boolean namespaceAware) {
|
||||
try {
|
||||
DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();
|
||||
factory.setNamespaceAware(namespaceAware);
|
||||
|
||||
// Prevent XXE attacks
|
||||
factory.setFeature(XMLConstants.FEATURE_SECURE_PROCESSING, true);
|
||||
factory.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true);
|
||||
factory.setFeature("http://xml.org/sax/features/external-general-entities", false);
|
||||
factory.setFeature("http://xml.org/sax/features/external-parameter-entities", false);
|
||||
factory.setXIncludeAware(false);
|
||||
factory.setExpandEntityReferences(false);
|
||||
|
||||
return factory;
|
||||
} catch (ParserConfigurationException e) {
|
||||
log.warn("Failed to configure secure XML parser, using defaults: {}", e.getMessage());
|
||||
DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();
|
||||
factory.setNamespaceAware(namespaceAware);
|
||||
return factory;
|
||||
}
|
||||
}
|
||||
|
||||
public static DocumentBuilder createSecureDocumentBuilder(boolean namespaceAware)
|
||||
throws ParserConfigurationException {
|
||||
return createSecureDocumentBuilderFactory(namespaceAware).newDocumentBuilder();
|
||||
}
|
||||
}
|
||||
@@ -1,21 +1,26 @@
|
||||
package org.booklore.mapper;
|
||||
|
||||
import org.booklore.model.dto.Book;
|
||||
import org.booklore.model.entity.BookFileEntity;
|
||||
import org.booklore.model.entity.BookEntity;
|
||||
import org.booklore.model.entity.BookFileEntity;
|
||||
import org.booklore.model.entity.LibraryEntity;
|
||||
import org.booklore.model.entity.LibraryPathEntity;
|
||||
import org.booklore.model.enums.BookFileType;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mapstruct.factory.Mappers;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.test.context.ActiveProfiles;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
@SpringBootTest
|
||||
@ActiveProfiles("test")
|
||||
class BookMapperTest {
|
||||
|
||||
private final BookMapper mapper = Mappers.getMapper(BookMapper.class);
|
||||
@Autowired
|
||||
private BookMapper mapper;
|
||||
|
||||
@Test
|
||||
void shouldMapExistingFieldsCorrectly() {
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
package org.booklore.mapper;
|
||||
|
||||
import org.booklore.model.dto.BookMetadata;
|
||||
import org.booklore.model.entity.BookMetadataEntity;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
public class BookMetadataMapperTest {
|
||||
|
||||
@Mock
|
||||
private AuthorMapper authorMapper;
|
||||
|
||||
@Mock
|
||||
private CategoryMapper categoryMapper;
|
||||
|
||||
@Mock
|
||||
private MoodMapper moodMapper;
|
||||
|
||||
@Mock
|
||||
private TagMapper tagMapper;
|
||||
|
||||
@Mock
|
||||
private ComicMetadataMapper comicMetadataMapper;
|
||||
|
||||
private BookMetadataMapperImpl bookMetadataMapper;
|
||||
|
||||
@org.junit.jupiter.api.BeforeEach
|
||||
void setUp() {
|
||||
bookMetadataMapper = new BookMetadataMapperImpl();
|
||||
org.springframework.test.util.ReflectionTestUtils.setField(bookMetadataMapper, "authorMapper", authorMapper);
|
||||
org.springframework.test.util.ReflectionTestUtils.setField(bookMetadataMapper, "categoryMapper", categoryMapper);
|
||||
org.springframework.test.util.ReflectionTestUtils.setField(bookMetadataMapper, "moodMapper", moodMapper);
|
||||
org.springframework.test.util.ReflectionTestUtils.setField(bookMetadataMapper, "tagMapper", tagMapper);
|
||||
org.springframework.test.util.ReflectionTestUtils.setField(bookMetadataMapper, "comicMetadataMapper", comicMetadataMapper);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testMapping() {
|
||||
BookMetadataEntity entity = new BookMetadataEntity();
|
||||
entity.setTitle("Test Title");
|
||||
entity.setHardcoverId("hc-id");
|
||||
entity.setHardcoverRating(4.5);
|
||||
entity.setGoodreadsId("gr-id");
|
||||
entity.setAuthors(new java.util.HashSet<>());
|
||||
entity.setCategories(new java.util.HashSet<>());
|
||||
entity.setMoods(new java.util.HashSet<>());
|
||||
entity.setTags(new java.util.HashSet<>());
|
||||
|
||||
BookMetadata dto = bookMetadataMapper.toBookMetadata(entity);
|
||||
|
||||
assertEquals("Test Title", dto.getTitle());
|
||||
assertEquals("hc-id", dto.getHardcoverId());
|
||||
assertEquals(4.5, dto.getHardcoverRating());
|
||||
assertEquals("gr-id", dto.getGoodreadsId());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,385 @@
|
||||
package org.booklore.service.metadata;
|
||||
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.apache.pdfbox.pdmodel.PDPage;
|
||||
import org.booklore.model.MetadataClearFlags;
|
||||
import org.booklore.model.dto.BookMetadata;
|
||||
import org.booklore.model.dto.settings.AppSettingKey;
|
||||
import org.booklore.model.dto.settings.MetadataPersistenceSettings;
|
||||
import org.booklore.model.entity.*;
|
||||
import org.booklore.model.enums.BookFileExtension;
|
||||
import org.booklore.model.enums.MetadataProvider;
|
||||
import org.booklore.repository.AppSettingsRepository;
|
||||
import org.booklore.service.appsettings.AppSettingService;
|
||||
import org.booklore.service.metadata.extractor.MetadataExtractorFactory;
|
||||
import org.booklore.service.metadata.writer.MetadataWriter;
|
||||
import org.booklore.service.metadata.writer.MetadataWriterFactory;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.test.context.ActiveProfiles;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import tools.jackson.databind.ObjectMapper;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.FileOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Path;
|
||||
import java.time.LocalDate;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
import java.util.zip.ZipEntry;
|
||||
import java.util.zip.ZipOutputStream;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
@SpringBootTest
|
||||
@ActiveProfiles("test")
|
||||
@Transactional
|
||||
class EndToEndMetadataPersistenceTest {
|
||||
|
||||
@Autowired
|
||||
private MetadataWriterFactory metadataWriterFactory;
|
||||
|
||||
@Autowired
|
||||
private MetadataExtractorFactory metadataExtractorFactory;
|
||||
|
||||
@Autowired
|
||||
private AppSettingService appSettingService;
|
||||
|
||||
@Autowired
|
||||
private AppSettingsRepository appSettingsRepository;
|
||||
|
||||
@Autowired
|
||||
private ObjectMapper objectMapper;
|
||||
|
||||
@TempDir
|
||||
Path tempDir;
|
||||
|
||||
private File pdfFile;
|
||||
private File epubFile;
|
||||
private File cbzFile;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() throws IOException {
|
||||
// Ensure clean state for settings using repository directly (transactional rollout will handle cleanup after test)
|
||||
appSettingsRepository.deleteAll();
|
||||
appSettingsRepository.flush();
|
||||
|
||||
MetadataPersistenceSettings settings = MetadataPersistenceSettings.builder()
|
||||
.saveToOriginalFile(MetadataPersistenceSettings.SaveToOriginalFile.builder()
|
||||
.epub(MetadataPersistenceSettings.FormatSettings.builder().enabled(true).maxFileSizeInMb(500).build())
|
||||
.pdf(MetadataPersistenceSettings.FormatSettings.builder().enabled(true).maxFileSizeInMb(500).build())
|
||||
.cbx(MetadataPersistenceSettings.FormatSettings.builder().enabled(true).maxFileSizeInMb(500).build())
|
||||
.build())
|
||||
.build();
|
||||
|
||||
AppSettingEntity entity = new AppSettingEntity();
|
||||
entity.setName(AppSettingKey.METADATA_PERSISTENCE_SETTINGS.getDbKey());
|
||||
entity.setVal(objectMapper.writeValueAsString(settings));
|
||||
appSettingsRepository.saveAndFlush(entity);
|
||||
|
||||
// Force reload settings cache
|
||||
ReflectionTestUtils.setField(appSettingService, "appSettings", null);
|
||||
|
||||
pdfFile = tempDir.resolve("test.pdf").toFile();
|
||||
createDummyPdf(pdfFile);
|
||||
|
||||
epubFile = tempDir.resolve("test.epub").toFile();
|
||||
createDummyEpub(epubFile);
|
||||
|
||||
cbzFile = tempDir.resolve("test.cbz").toFile();
|
||||
createDummyCbz(cbzFile);
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldPersistAndRetrieveCompleteMetadataForPdf() {
|
||||
verifyMetadataPersistence(pdfFile, BookFileExtension.PDF);
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldPersistAndRetrieveCompleteMetadataForEpub() {
|
||||
verifyMetadataPersistence(epubFile, BookFileExtension.EPUB);
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldPersistAndRetrieveCompleteMetadataForCbz() {
|
||||
verifyMetadataPersistence(cbzFile, BookFileExtension.CBZ);
|
||||
}
|
||||
|
||||
private void verifyMetadataPersistence(File file, BookFileExtension extension) {
|
||||
BookMetadata originalMetadata = createFullMetadata();
|
||||
BookMetadataEntity entity = toEntity(originalMetadata);
|
||||
|
||||
MetadataWriter writer = metadataWriterFactory.getWriter(extension.getType())
|
||||
.orElseThrow(() -> new RuntimeException("No writer found for " + extension));
|
||||
|
||||
writer.saveMetadataToFile(file, entity, originalMetadata.getThumbnailUrl(), new MetadataClearFlags());
|
||||
|
||||
assertThat(file).exists();
|
||||
|
||||
BookMetadata rescannedMetadata = metadataExtractorFactory.extractMetadata(extension, file);
|
||||
|
||||
assertMetadataEquality(originalMetadata, rescannedMetadata, extension);
|
||||
}
|
||||
|
||||
private BookMetadata createFullMetadata() {
|
||||
return BookMetadata.builder()
|
||||
.title("The Ultimate Test Book")
|
||||
.subtitle("A Comprehensive Guide to Metadata")
|
||||
.provider(MetadataProvider.Google)
|
||||
.authors(Set.of("Author One", "Author Two"))
|
||||
.publisher("Test Publisher")
|
||||
.publishedDate(LocalDate.of(2023, 10, 27))
|
||||
.description("This is a description used for testing metadata persistence.")
|
||||
.seriesName("The Test Series")
|
||||
.seriesNumber(1.5f)
|
||||
.seriesTotal(3)
|
||||
.isbn13("978-3-16-148410-0")
|
||||
.isbn10("3-16-148410-0")
|
||||
.asin("B00TESTASI")
|
||||
.pageCount(350)
|
||||
.language("en")
|
||||
.categories(Set.of("Fiction", "Testing", "Science"))
|
||||
.tags(Set.of("Best Seller", "Must Read"))
|
||||
.moods(Set.of("Happy", "Suspenseful"))
|
||||
|
||||
// Ratings
|
||||
.amazonRating(4.5)
|
||||
.goodreadsRating(4.2)
|
||||
.hardcoverRating(85.0)
|
||||
.lubimyczytacRating(7.5)
|
||||
.ranobedbRating(9.0)
|
||||
|
||||
// Identifiers
|
||||
.goodreadsId("12345")
|
||||
.comicvineId("4000-12345")
|
||||
.hardcoverId("test-book-slug")
|
||||
.hardcoverBookId("999")
|
||||
.googleId("google_id_123")
|
||||
.ranobedbId("ranobe-000")
|
||||
.lubimyczytacId("lub-123")
|
||||
.externalUrl("https://booklore.org")
|
||||
|
||||
// Ensure all strict locks are disabled to allow writing
|
||||
.titleLocked(false)
|
||||
.subtitleLocked(false)
|
||||
.publisherLocked(false)
|
||||
.publishedDateLocked(false)
|
||||
.descriptionLocked(false)
|
||||
.seriesNameLocked(false)
|
||||
.seriesNumberLocked(false)
|
||||
.seriesTotalLocked(false)
|
||||
.isbn13Locked(false)
|
||||
.isbn10Locked(false)
|
||||
.asinLocked(false)
|
||||
.goodreadsIdLocked(false)
|
||||
.comicvineIdLocked(false)
|
||||
.hardcoverIdLocked(false)
|
||||
.hardcoverBookIdLocked(false)
|
||||
.doubanIdLocked(false)
|
||||
.googleIdLocked(false)
|
||||
.languageLocked(false)
|
||||
.amazonRatingLocked(false)
|
||||
.amazonReviewCountLocked(false)
|
||||
.goodreadsRatingLocked(false)
|
||||
.goodreadsReviewCountLocked(false)
|
||||
.hardcoverRatingLocked(false)
|
||||
.hardcoverReviewCountLocked(false)
|
||||
.doubanRatingLocked(false)
|
||||
.doubanReviewCountLocked(false)
|
||||
.lubimyczytacIdLocked(false)
|
||||
.lubimyczytacRatingLocked(false)
|
||||
.ranobedbIdLocked(false)
|
||||
.ranobedbRatingLocked(false)
|
||||
.externalUrlLocked(false)
|
||||
.coverLocked(false)
|
||||
.authorsLocked(false)
|
||||
.categoriesLocked(false)
|
||||
.moodsLocked(false)
|
||||
.tagsLocked(false)
|
||||
.reviewsLocked(false)
|
||||
.build();
|
||||
}
|
||||
|
||||
// Manual mapping for test purposes
|
||||
private BookMetadataEntity toEntity(BookMetadata dto) {
|
||||
BookMetadataEntity entity = new BookMetadataEntity();
|
||||
entity.setTitle(dto.getTitle());
|
||||
entity.setSubtitle(dto.getSubtitle());
|
||||
entity.setPublisher(dto.getPublisher());
|
||||
entity.setPublishedDate(dto.getPublishedDate());
|
||||
entity.setDescription(dto.getDescription());
|
||||
entity.setSeriesName(dto.getSeriesName());
|
||||
entity.setSeriesNumber(dto.getSeriesNumber());
|
||||
entity.setSeriesTotal(dto.getSeriesTotal());
|
||||
entity.setIsbn13(dto.getIsbn13());
|
||||
entity.setIsbn10(dto.getIsbn10());
|
||||
entity.setAsin(dto.getAsin());
|
||||
entity.setPageCount(dto.getPageCount());
|
||||
entity.setLanguage(dto.getLanguage());
|
||||
entity.setGoodreadsId(dto.getGoodreadsId());
|
||||
entity.setComicvineId(dto.getComicvineId());
|
||||
entity.setHardcoverId(dto.getHardcoverId());
|
||||
entity.setGoogleId(dto.getGoogleId());
|
||||
entity.setRanobedbId(dto.getRanobedbId());
|
||||
entity.setLubimyczytacId(dto.getLubimyczytacId());
|
||||
|
||||
entity.setAmazonRating(dto.getAmazonRating());
|
||||
entity.setGoodreadsRating(dto.getGoodreadsRating());
|
||||
entity.setHardcoverRating(dto.getHardcoverRating());
|
||||
entity.setHardcoverBookId(dto.getHardcoverBookId());
|
||||
entity.setLubimyczytacRating(dto.getLubimyczytacRating());
|
||||
entity.setRanobedbRating(dto.getRanobedbRating());
|
||||
|
||||
if (dto.getAuthors() != null) {
|
||||
entity.setAuthors(dto.getAuthors().stream().map(name -> {
|
||||
var a = new AuthorEntity();
|
||||
a.setName(name);
|
||||
return a;
|
||||
}).collect(Collectors.toSet()));
|
||||
}
|
||||
if (dto.getCategories() != null) {
|
||||
entity.setCategories(dto.getCategories().stream().map(name -> {
|
||||
var c = new CategoryEntity();
|
||||
c.setName(name);
|
||||
return c;
|
||||
}).collect(Collectors.toSet()));
|
||||
}
|
||||
if (dto.getTags() != null) {
|
||||
entity.setTags(dto.getTags().stream().map(name -> {
|
||||
var t = new TagEntity();
|
||||
t.setName(name);
|
||||
return t;
|
||||
}).collect(Collectors.toSet()));
|
||||
}
|
||||
if (dto.getMoods() != null) {
|
||||
entity.setMoods(dto.getMoods().stream().map(name -> {
|
||||
var m = new MoodEntity();
|
||||
m.setName(name);
|
||||
return m;
|
||||
}).collect(Collectors.toSet()));
|
||||
}
|
||||
return entity;
|
||||
}
|
||||
|
||||
private void assertMetadataEquality(BookMetadata expected, BookMetadata actual, BookFileExtension extension) {
|
||||
// 1. Basic Fields
|
||||
assertThat(actual.getTitle()).as("Title").isEqualTo(expected.getTitle());
|
||||
assertThat(actual.getPublisher()).as("Publisher").isEqualTo(expected.getPublisher());
|
||||
assertThat(actual.getLanguage()).as("Language").isEqualTo(expected.getLanguage());
|
||||
assertThat(actual.getDescription()).as("Description").isEqualTo(expected.getDescription());
|
||||
|
||||
// 2. Series Info
|
||||
assertThat(actual.getSeriesName()).as("SeriesName").isEqualTo(expected.getSeriesName());
|
||||
assertThat(actual.getSeriesNumber()).as("SeriesNumber").isEqualTo(expected.getSeriesNumber());
|
||||
assertThat(actual.getSeriesTotal()).as("SeriesTotal").isEqualTo(expected.getSeriesTotal());
|
||||
|
||||
// 3. Collections
|
||||
assertThat(actual.getAuthors()).as("Authors").containsExactlyInAnyOrderElementsOf(expected.getAuthors());
|
||||
assertThat(actual.getCategories()).as("Categories").containsExactlyInAnyOrderElementsOf(expected.getCategories());
|
||||
|
||||
if (extension != BookFileExtension.CBZ) {
|
||||
assertThat(actual.getTags()).as("Tags").containsExactlyInAnyOrderElementsOf(expected.getTags());
|
||||
assertThat(actual.getMoods()).as("Moods").containsExactlyInAnyOrderElementsOf(expected.getMoods());
|
||||
} else {
|
||||
assertThat(actual.getTags()).as("Tags").containsExactlyInAnyOrderElementsOf(expected.getTags());
|
||||
assertThat(actual.getMoods()).as("Moods").containsExactlyInAnyOrderElementsOf(expected.getMoods());
|
||||
}
|
||||
|
||||
// 4. Identifiers & ISBN Normalization
|
||||
String expectedIsbn13 = expected.getIsbn13() != null ? expected.getIsbn13().replaceAll("[- ]", "") : null;
|
||||
String actualIsbn13 = actual.getIsbn13() != null ? actual.getIsbn13().replaceAll("[- ]", "") : null;
|
||||
assertThat(actualIsbn13).as("ISBN13").isEqualTo(expectedIsbn13);
|
||||
|
||||
String expectedIsbn10 = expected.getIsbn10() != null ? expected.getIsbn10().replaceAll("[- ]", "") : null;
|
||||
String actualIsbn10 = actual.getIsbn10() != null ? actual.getIsbn10().replaceAll("[- ]", "") : null;
|
||||
assertThat(actualIsbn10).as("ISBN10").isEqualTo(expectedIsbn10);
|
||||
|
||||
assertThat(actual.getAsin()).as("ASIN").isEqualTo(expected.getAsin());
|
||||
assertThat(actual.getGoodreadsId()).as("GoodreadsId").isEqualTo(expected.getGoodreadsId());
|
||||
assertThat(actual.getComicvineId()).as("ComicvineId").isEqualTo(expected.getComicvineId());
|
||||
assertThat(actual.getHardcoverId()).as("HardcoverId").isEqualTo(expected.getHardcoverId());
|
||||
assertThat(actual.getHardcoverBookId()).as("HardcoverBookId").isEqualTo(expected.getHardcoverBookId());
|
||||
assertThat(actual.getGoogleId()).as("GoogleId").isEqualTo(expected.getGoogleId());
|
||||
assertThat(actual.getRanobedbId()).as("RanobeDBId").isEqualTo(expected.getRanobedbId());
|
||||
assertThat(actual.getLubimyczytacId()).as("LubimyczytacId").isEqualTo(expected.getLubimyczytacId());
|
||||
|
||||
// 5. Ratings
|
||||
assertThat(actual.getAmazonRating()).as("AmazonRating").isEqualTo(expected.getAmazonRating());
|
||||
assertThat(actual.getGoodreadsRating()).as("GoodreadsRating").isEqualTo(expected.getGoodreadsRating());
|
||||
assertThat(actual.getHardcoverRating()).as("HardcoverRating").isEqualTo(expected.getHardcoverRating());
|
||||
assertThat(actual.getLubimyczytacRating()).as("LubimyczytacRating").isEqualTo(expected.getLubimyczytacRating());
|
||||
assertThat(actual.getRanobedbRating()).as("RanobeDBRating").isEqualTo(expected.getRanobedbRating());
|
||||
|
||||
// 6. Format Specific Limitations
|
||||
if (extension != BookFileExtension.PDF) {
|
||||
// PDF page count corresponds to physical pages, which is 1 in dummy file.
|
||||
assertThat(actual.getPageCount()).as("PageCount").isEqualTo(expected.getPageCount());
|
||||
|
||||
// PDF extractor reads CreationDate which might differ from PublishedDate
|
||||
assertThat(actual.getPublishedDate()).as("PublishedDate").isEqualTo(expected.getPublishedDate());
|
||||
} else {
|
||||
// For PDF, we expect 1 page because the dummy pdf only has 1 page
|
||||
assertThat(actual.getPageCount()).as("PdfPageCount").isEqualTo(1);
|
||||
}
|
||||
|
||||
// 7. Subtitle Check
|
||||
assertThat(actual.getSubtitle()).as("Subtitle").isEqualTo(expected.getSubtitle());
|
||||
}
|
||||
|
||||
private void createDummyPdf(File file) throws IOException {
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
doc.addPage(new PDPage());
|
||||
doc.save(file);
|
||||
}
|
||||
}
|
||||
|
||||
private void createDummyEpub(File file) throws IOException {
|
||||
try (ZipOutputStream zos = new ZipOutputStream(new FileOutputStream(file))) {
|
||||
// mimetype file must be first and uncompressed
|
||||
ZipEntry mimetype = new ZipEntry("mimetype");
|
||||
mimetype.setMethod(ZipEntry.STORED);
|
||||
mimetype.setSize(20);
|
||||
mimetype.setCompressedSize(20);
|
||||
mimetype.setCrc(0x2cab616f); // CRC for "application/epub+zip"
|
||||
zos.putNextEntry(mimetype);
|
||||
zos.write("application/epub+zip".getBytes());
|
||||
zos.closeEntry();
|
||||
|
||||
// META-INF/container.xml
|
||||
zos.putNextEntry(new ZipEntry("META-INF/container.xml"));
|
||||
String containerXml = "<?xml version=\"1.0\"?>\n" +
|
||||
"<container version=\"1.0\" xmlns=\"urn:oasis:names:tc:opendocument:xmlns:container\">\n" +
|
||||
" <rootfiles>\n" +
|
||||
" <rootfile full-path=\"content.opf\" media-type=\"application/oebps-package+xml\"/>\n" +
|
||||
" </rootfiles>\n" +
|
||||
"</container>";
|
||||
zos.write(containerXml.getBytes());
|
||||
zos.closeEntry();
|
||||
|
||||
// content.opf
|
||||
zos.putNextEntry(new ZipEntry("content.opf"));
|
||||
String opf = "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n" +
|
||||
"<package xmlns=\"http://www.idpf.org/2007/opf\" unique-identifier=\"BookId\" version=\"2.0\">\n" +
|
||||
" <metadata xmlns:dc=\"http://purl.org/dc/elements/1.1/\" xmlns:opf=\"http://www.idpf.org/2007/opf\">\n" +
|
||||
" <dc:title>Dummy Title</dc:title>\n" +
|
||||
" </metadata>\n" +
|
||||
"</package>";
|
||||
zos.write(opf.getBytes());
|
||||
zos.closeEntry();
|
||||
}
|
||||
}
|
||||
|
||||
private void createDummyCbz(File file) throws IOException {
|
||||
try (ZipOutputStream zos = new ZipOutputStream(new FileOutputStream(file))) {
|
||||
ZipEntry entry = new ZipEntry("dummy.txt");
|
||||
zos.putNextEntry(entry);
|
||||
zos.write("dummy content".getBytes());
|
||||
zos.closeEntry();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -56,7 +56,7 @@ class CbxMetadataExtractorTest {
|
||||
" <LanguageISO>en</LanguageISO>" +
|
||||
" <Writer>Alice</Writer>" +
|
||||
" <Penciller>Bob</Penciller>" +
|
||||
" <Tags>action;adventure</Tags>" +
|
||||
" <Genre>action;adventure</Genre>" +
|
||||
"</ComicInfo>";
|
||||
|
||||
File cbz = createCbz("with_meta.cbz", new LinkedHashMap<>() {{
|
||||
@@ -94,7 +94,7 @@ class CbxMetadataExtractorTest {
|
||||
" <LanguageISO>en</LanguageISO>" +
|
||||
" <Writer>Alice</Writer>" +
|
||||
" <Penciller>Bob</Penciller>" +
|
||||
" <Tags>action;adventure</Tags>" +
|
||||
" <Genre>action;adventure</Genre>" +
|
||||
"</ComicInfo>";
|
||||
|
||||
File cbz = createCbz("with_meta.cbz", new LinkedHashMap<>() {{
|
||||
@@ -281,6 +281,59 @@ class CbxMetadataExtractorTest {
|
||||
assertEquals(10f, md.getSeriesNumber());
|
||||
}
|
||||
|
||||
@Test
|
||||
void extractMetadata_fromCbz_withBookloreNotes_populatesFields() throws Exception {
|
||||
String xml = "<ComicInfo>" +
|
||||
" <Title>BookLore Comic</Title>" +
|
||||
" <Notes>" +
|
||||
"Some random notes here.\n" +
|
||||
"[BookLore:Subtitle] The Subtitle\n" +
|
||||
"[BookLore:Moods] Dark, Mystery\n" +
|
||||
"[BookLore:Tags] Fiction, Thriller\n" +
|
||||
"[BookLore:ISBN13] 9781234567890\n" +
|
||||
"[BookLore:AmazonRating] 4.5\n" +
|
||||
"[BookLore:GoodreadsRating] 3.8\n" +
|
||||
" </Notes>" +
|
||||
"</ComicInfo>";
|
||||
|
||||
File cbz = createCbz("booklore_notes.cbz", new LinkedHashMap<>() {{
|
||||
put("ComicInfo.xml", xml.getBytes(StandardCharsets.UTF_8));
|
||||
}});
|
||||
|
||||
BookMetadata md = extractor.extractMetadata(cbz);
|
||||
|
||||
assertEquals("BookLore Comic", md.getTitle());
|
||||
assertEquals("The Subtitle", md.getSubtitle());
|
||||
assertEquals("Some random notes here.", md.getDescription()); // Notes extracted as description if summary missing
|
||||
assertEquals("9781234567890", md.getIsbn13());
|
||||
assertEquals(4.5, md.getAmazonRating());
|
||||
assertEquals(3.8, md.getGoodreadsRating());
|
||||
assertTrue(md.getMoods().contains("Dark"));
|
||||
assertTrue(md.getMoods().contains("Mystery"));
|
||||
assertTrue(md.getTags().contains("Fiction"));
|
||||
assertTrue(md.getTags().contains("Thriller"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void extractMetadata_fromCbz_withWebField_populatesIds() throws Exception {
|
||||
String xml = "<ComicInfo>" +
|
||||
" <Title>Web Links Comic</Title>" +
|
||||
" <Web>https://www.goodreads.com/book/show/12345, https://www.amazon.com/dp/B001234567, https://comicvine.gamespot.com/issue/9999, https://hardcover.app/books/hc-id</Web>" +
|
||||
"</ComicInfo>";
|
||||
|
||||
File cbz = createCbz("web_links.cbz", new LinkedHashMap<>() {{
|
||||
put("ComicInfo.xml", xml.getBytes(StandardCharsets.UTF_8));
|
||||
}});
|
||||
|
||||
BookMetadata md = extractor.extractMetadata(cbz);
|
||||
|
||||
assertEquals("Web Links Comic", md.getTitle());
|
||||
assertEquals("12345", md.getGoodreadsId());
|
||||
assertEquals("B001234567", md.getAsin());
|
||||
assertEquals("9999", md.getComicvineId());
|
||||
assertEquals("hc-id", md.getHardcoverId());
|
||||
}
|
||||
|
||||
// ---------- helpers ----------
|
||||
|
||||
private File createCbz(String name, Map<String, byte[]> entries) throws IOException {
|
||||
|
||||
@@ -5,13 +5,10 @@ import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import javax.imageio.ImageIO;
|
||||
import java.awt.*;
|
||||
import java.awt.image.BufferedImage;
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.File;
|
||||
import java.io.FileOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.io.*;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
@@ -22,10 +19,8 @@ import java.util.Map;
|
||||
import java.util.zip.ZipEntry;
|
||||
import java.util.zip.ZipOutputStream;
|
||||
|
||||
import javax.imageio.ImageIO;
|
||||
import java.awt.Color;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
class ComicInfoParsingIssuesTest {
|
||||
|
||||
@@ -160,10 +155,10 @@ class ComicInfoParsingIssuesTest {
|
||||
|
||||
assertTrue(metadata.getAuthors().contains("Frank Miller"));
|
||||
|
||||
assertTrue(metadata.getCategories().contains("Marvel"));
|
||||
assertTrue(metadata.getCategories().contains("Daredevil"));
|
||||
assertTrue(metadata.getTags().contains("Marvel"));
|
||||
assertTrue(metadata.getTags().contains("Daredevil"));
|
||||
assertTrue(metadata.getCategories().contains("Superhero"));
|
||||
assertTrue(metadata.getCategories().contains("Frank Miller"));
|
||||
assertTrue(metadata.getTags().contains("Frank Miller"));
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -641,6 +641,69 @@ class EpubMetadataExtractorTest {
|
||||
return createEpubWithOpf(opfContent, "test-booklore-series-" + System.nanoTime() + ".epub");
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("BookLore Metadata Tests")
|
||||
class BookLoreMetadataTests {
|
||||
|
||||
@Test
|
||||
@DisplayName("Should extract BookLore custom properties from EPUB metadata")
|
||||
void extractMetadata_withBookloreProperties_returnsExtendedMetadata() throws IOException {
|
||||
File epubFile = createEpubWithBookloreMetadata();
|
||||
|
||||
BookMetadata result = extractor.extractMetadata(epubFile);
|
||||
|
||||
assertAll(
|
||||
() -> assertNotNull(result),
|
||||
() -> assertEquals("A Subtitle", result.getSubtitle()),
|
||||
() -> assertEquals(10, result.getSeriesTotal()),
|
||||
() -> assertEquals(4.5, result.getAmazonRating()),
|
||||
() -> assertEquals(4.0, result.getGoodreadsRating()),
|
||||
() -> assertEquals(5.0, result.getHardcoverRating()),
|
||||
() -> assertEquals(3.5, result.getLubimyczytacRating()),
|
||||
() -> assertEquals(2.0, result.getRanobedbRating()),
|
||||
() -> assertEquals("B001", result.getAsin()),
|
||||
() -> assertEquals("1001", result.getGoodreadsId()),
|
||||
() -> assertEquals("2002", result.getComicvineId()),
|
||||
() -> assertEquals("3003", result.getHardcoverId()),
|
||||
() -> assertEquals("4004", result.getRanobedbId()),
|
||||
() -> assertEquals("5005", result.getGoogleId()),
|
||||
() -> assertEquals("6006", result.getLubimyczytacId()),
|
||||
() -> assertTrue(result.getMoods().contains("Dark")),
|
||||
() -> assertTrue(result.getMoods().contains("Mystery")),
|
||||
() -> assertTrue(result.getTags().contains("Fiction")),
|
||||
() -> assertTrue(result.getTags().contains("Thriller"))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private File createEpubWithBookloreMetadata() throws IOException {
|
||||
String opfContent = """
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<package xmlns="http://www.idpf.org/2007/opf" xmlns:booklore="http://booklore.org/metadata" version="3.0">
|
||||
<metadata xmlns:dc="http://purl.org/dc/elements/1.1/">
|
||||
<dc:title>BookLore Test</dc:title>
|
||||
<meta property="booklore:subtitle">A Subtitle</meta>
|
||||
<meta property="booklore:series_total">10</meta>
|
||||
<meta property="booklore:amazon_rating">4.5</meta>
|
||||
<meta property="booklore:goodreads_rating">4.0</meta>
|
||||
<meta property="booklore:hardcover_rating">5.0</meta>
|
||||
<meta property="booklore:lubimyczytac_rating">3.5</meta>
|
||||
<meta property="booklore:ranobedb_rating">2.0</meta>
|
||||
<meta property="booklore:asin">B001</meta>
|
||||
<meta property="booklore:goodreads_id">1001</meta>
|
||||
<meta property="booklore:comicvine_id">2002</meta>
|
||||
<meta property="booklore:hardcover_id">3003</meta>
|
||||
<meta property="booklore:ranobedb_id">4004</meta>
|
||||
<meta property="booklore:google_books_id">5005</meta>
|
||||
<meta property="booklore:lubimyczytac_id">6006</meta>
|
||||
<meta property="booklore:moods">Dark, Mystery</meta>
|
||||
<meta property="booklore:tags">Fiction, Thriller</meta>
|
||||
</metadata>
|
||||
</package>
|
||||
""";
|
||||
return createEpubWithOpf(opfContent, "test-booklore-" + System.nanoTime() + ".epub");
|
||||
}
|
||||
|
||||
private File createEpubWithIsbn(String isbn13, String isbn10) throws IOException {
|
||||
StringBuilder identifiers = new StringBuilder();
|
||||
if (isbn13 != null) {
|
||||
|
||||
@@ -71,9 +71,9 @@ class ExtractFromComicInfoXmlTest {
|
||||
// Verify categories
|
||||
assertTrue(metadata.getCategories().contains("Superhero"));
|
||||
assertTrue(metadata.getCategories().contains("Crime"));
|
||||
assertTrue(metadata.getCategories().contains("Batman"));
|
||||
assertTrue(metadata.getCategories().contains("DC"));
|
||||
assertTrue(metadata.getCategories().contains("Frank Miller"));
|
||||
assertTrue(metadata.getTags().contains("Batman"));
|
||||
assertTrue(metadata.getTags().contains("DC"));
|
||||
assertTrue(metadata.getTags().contains("Frank Miller"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -336,7 +336,6 @@ class ExtractFromComicInfoXmlTest {
|
||||
assertTrue(metadata.getAuthors().contains("Jane Smith"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testExtractFromComicInfoXml_MultipleCategories() throws IOException {
|
||||
String xml = """
|
||||
<ComicInfo>
|
||||
@@ -353,9 +352,9 @@ class ExtractFromComicInfoXmlTest {
|
||||
assertTrue(metadata.getCategories().contains("Action"));
|
||||
assertTrue(metadata.getCategories().contains("Adventure"));
|
||||
assertTrue(metadata.getCategories().contains("Sci-Fi"));
|
||||
assertTrue(metadata.getCategories().contains("robots"));
|
||||
assertTrue(metadata.getCategories().contains("space"));
|
||||
assertTrue(metadata.getCategories().contains("future"));
|
||||
assertTrue(metadata.getTags().contains("robots"));
|
||||
assertTrue(metadata.getTags().contains("space"));
|
||||
assertTrue(metadata.getTags().contains("future"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -736,9 +735,9 @@ class ExtractFromComicInfoXmlTest {
|
||||
// Verify categories
|
||||
assertTrue(metadata.getCategories().contains("Superhero"));
|
||||
assertTrue(metadata.getCategories().contains("Crime"));
|
||||
assertTrue(metadata.getCategories().contains("Batman"));
|
||||
assertTrue(metadata.getCategories().contains("DC"));
|
||||
assertTrue(metadata.getCategories().contains("Frank Miller"));
|
||||
assertTrue(metadata.getTags().contains("Batman"));
|
||||
assertTrue(metadata.getTags().contains("DC"));
|
||||
assertTrue(metadata.getTags().contains("Frank Miller"));
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
package org.booklore.service.metadata.writer;
|
||||
|
||||
import org.apache.xmpbox.XMPMetadata;
|
||||
import org.apache.xmpbox.type.TextType;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Nested;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.Set;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class BookLoreSchemaTest {
|
||||
|
||||
private XMPMetadata xmpMetadata;
|
||||
private BookLoreSchema schema;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
xmpMetadata = XMPMetadata.createXMPMetadata();
|
||||
schema = new BookLoreSchema(xmpMetadata);
|
||||
}
|
||||
|
||||
@Nested
|
||||
class ScalarTextPropertiesTests {
|
||||
@Test
|
||||
void setSubtitle_addsPropertyToSchema() {
|
||||
schema.setSubtitle("A Novel");
|
||||
|
||||
TextType property = (TextType) schema.getProperty(BookLoreSchema.SUBTITLE);
|
||||
assertNotNull(property);
|
||||
assertEquals("A Novel", property.getStringValue());
|
||||
}
|
||||
|
||||
@Test
|
||||
void setSubtitle_whenBlank_doesNotAddProperty() {
|
||||
schema.setSubtitle(" ");
|
||||
|
||||
assertNull(schema.getProperty(BookLoreSchema.SUBTITLE));
|
||||
}
|
||||
|
||||
@Test
|
||||
void setIsbn10_addsPropertyToSchema() {
|
||||
schema.setIsbn10("0123456789");
|
||||
|
||||
TextType property = (TextType) schema.getProperty(BookLoreSchema.ISBN_10);
|
||||
assertNotNull(property);
|
||||
assertEquals("0123456789", property.getStringValue());
|
||||
}
|
||||
|
||||
@Test
|
||||
void setLubimyczytacId_addsPropertyToSchema() {
|
||||
schema.setLubimyczytacId("12345");
|
||||
|
||||
TextType property = (TextType) schema.getProperty(BookLoreSchema.LUBIMYCZYTAC_ID);
|
||||
assertNotNull(property);
|
||||
assertEquals("12345", property.getStringValue());
|
||||
}
|
||||
|
||||
@Test
|
||||
void setHardcoverBookId_addsPropertyToSchema() {
|
||||
schema.setHardcoverBookId(98765);
|
||||
|
||||
TextType property = (TextType) schema.getProperty(BookLoreSchema.HARDCOVER_BOOK_ID);
|
||||
assertNotNull(property);
|
||||
assertEquals("98765", property.getStringValue());
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
class RatingPropertiesTests {
|
||||
@Test
|
||||
void setRating_formatsWithLocaleUS() {
|
||||
schema.setRating(8.5);
|
||||
|
||||
TextType property = (TextType) schema.getProperty(BookLoreSchema.RATING);
|
||||
assertNotNull(property);
|
||||
assertEquals("8.50", property.getStringValue());
|
||||
assertFalse(property.getStringValue().contains(","));
|
||||
}
|
||||
|
||||
@Test
|
||||
void setAmazonRating_formatsCorrectly() {
|
||||
schema.setAmazonRating(4.25);
|
||||
|
||||
TextType property = (TextType) schema.getProperty(BookLoreSchema.AMAZON_RATING);
|
||||
assertNotNull(property);
|
||||
assertEquals("4.25", property.getStringValue());
|
||||
}
|
||||
|
||||
@Test
|
||||
void setGoodreadsRating_formatsCorrectly() {
|
||||
schema.setGoodreadsRating(3.99);
|
||||
|
||||
TextType property = (TextType) schema.getProperty(BookLoreSchema.GOODREADS_RATING);
|
||||
assertNotNull(property);
|
||||
assertEquals("3.99", property.getStringValue());
|
||||
}
|
||||
|
||||
@Test
|
||||
void setRating_whenNull_doesNotAddProperty() {
|
||||
schema.setRating(null);
|
||||
|
||||
assertNull(schema.getProperty(BookLoreSchema.RATING));
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
class TextCollectionPropertiesTests {
|
||||
@Test
|
||||
void setMoods_createsDelimitedText() {
|
||||
schema.setMoods(Set.of("Dark", "Atmospheric", "Suspenseful"));
|
||||
|
||||
TextType property = (TextType) schema.getProperty(BookLoreSchema.MOODS);
|
||||
assertNotNull(property);
|
||||
String value = property.getStringValue();
|
||||
assertTrue(value.contains("Dark"));
|
||||
assertTrue(value.contains("Atmospheric"));
|
||||
assertTrue(value.contains("Suspenseful"));
|
||||
assertTrue(value.contains("; "));
|
||||
}
|
||||
|
||||
@Test
|
||||
void setMoods_whenEmpty_doesNotAddProperty() {
|
||||
schema.setMoods(Set.of());
|
||||
|
||||
assertNull(schema.getProperty(BookLoreSchema.MOODS));
|
||||
}
|
||||
|
||||
@Test
|
||||
void setMoods_whenNull_doesNotAddProperty() {
|
||||
schema.setMoods(null);
|
||||
|
||||
assertNull(schema.getProperty(BookLoreSchema.MOODS));
|
||||
}
|
||||
|
||||
@Test
|
||||
void setTags_createsDelimitedText() {
|
||||
schema.setTags(Set.of("Fantasy", "Epic", "Magic"));
|
||||
|
||||
TextType property = (TextType) schema.getProperty(BookLoreSchema.TAGS);
|
||||
assertNotNull(property);
|
||||
String value = property.getStringValue();
|
||||
assertTrue(value.contains("Fantasy"));
|
||||
assertTrue(value.contains("Epic"));
|
||||
assertTrue(value.contains("Magic"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void setTags_filtersBlankValues() {
|
||||
schema.setTags(Set.of("Fantasy", " ", "Epic"));
|
||||
|
||||
TextType property = (TextType) schema.getProperty(BookLoreSchema.TAGS);
|
||||
assertNotNull(property);
|
||||
assertFalse(property.getStringValue().contains(" "));
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
class NamespaceTests {
|
||||
@Test
|
||||
void namespace_isCorrect() {
|
||||
assertEquals("http://booklore.org/metadata/1.0/", BookLoreSchema.NAMESPACE);
|
||||
}
|
||||
|
||||
@Test
|
||||
void prefix_isCorrect() {
|
||||
assertEquals("booklore", BookLoreSchema.PREFIX);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
package org.booklore.service.metadata.writer;
|
||||
|
||||
import org.booklore.model.MetadataClearFlags;
|
||||
import org.booklore.model.dto.settings.AppSettings;
|
||||
import org.booklore.model.dto.settings.MetadataPersistenceSettings;
|
||||
import org.booklore.model.entity.BookMetadataEntity;
|
||||
import org.booklore.model.entity.MoodEntity;
|
||||
import org.booklore.model.entity.TagEntity;
|
||||
import org.booklore.service.appsettings.AppSettingService;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.Mockito;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.FileOutputStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.time.LocalDate;
|
||||
import java.util.Comparator;
|
||||
import java.util.HashSet;
|
||||
import java.util.Set;
|
||||
import java.util.zip.ZipEntry;
|
||||
import java.util.zip.ZipFile;
|
||||
import java.util.zip.ZipOutputStream;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
class CbxComicInfoComplianceTest {
|
||||
|
||||
private CbxMetadataWriter writer;
|
||||
private Path tempDir;
|
||||
|
||||
@BeforeEach
|
||||
void setup() throws Exception {
|
||||
AppSettingService appSettingService = Mockito.mock(AppSettingService.class);
|
||||
AppSettings settings = new AppSettings();
|
||||
MetadataPersistenceSettings persistence = new MetadataPersistenceSettings();
|
||||
MetadataPersistenceSettings.SaveToOriginalFile save = new MetadataPersistenceSettings.SaveToOriginalFile();
|
||||
MetadataPersistenceSettings.FormatSettings cbx = new MetadataPersistenceSettings.FormatSettings();
|
||||
cbx.setEnabled(true);
|
||||
cbx.setMaxFileSizeInMb(100);
|
||||
save.setCbx(cbx);
|
||||
persistence.setSaveToOriginalFile(save);
|
||||
settings.setMetadataPersistenceSettings(persistence);
|
||||
Mockito.when(appSettingService.getAppSettings()).thenReturn(settings);
|
||||
|
||||
writer = new CbxMetadataWriter(appSettingService);
|
||||
tempDir = Files.createTempDirectory("compliance_test_");
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void cleanup() throws Exception {
|
||||
if (tempDir != null) {
|
||||
Files.walk(tempDir)
|
||||
.sorted(Comparator.reverseOrder())
|
||||
.forEach(p -> {
|
||||
try {
|
||||
Files.deleteIfExists(p);
|
||||
} catch (Exception ignore) {
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void testComicInfoCompliance_OrderingAndFormatting() throws Exception {
|
||||
File cbz = createDummyCbz("compliance.cbz");
|
||||
|
||||
BookMetadataEntity metadata = new BookMetadataEntity();
|
||||
metadata.setTitle("The Boys #70 - The Bloody Doors Off, Part 5");
|
||||
metadata.setSeriesName("The Boys");
|
||||
metadata.setSeriesNumber(70f);
|
||||
metadata.setSeriesTotal(14);
|
||||
metadata.setPublishedDate(LocalDate.of(2012, 9, 5));
|
||||
metadata.setPageCount(170);
|
||||
metadata.setDescription("<p><i>On his own and out of options, Hughie resorts to extreme measures...</i></p>");
|
||||
|
||||
Set<TagEntity> tags = new HashSet<>();
|
||||
TagEntity t1 = new TagEntity(); t1.setName("Superhero");
|
||||
tags.add(t1);
|
||||
metadata.setTags(tags);
|
||||
|
||||
Set<MoodEntity> moods = new HashSet<>();
|
||||
MoodEntity m1 = new MoodEntity(); m1.setName("Dark");
|
||||
moods.add(m1);
|
||||
metadata.setMoods(moods);
|
||||
|
||||
metadata.setHardcoverBookId("547027");
|
||||
metadata.setIsbn13("9781606903735");
|
||||
metadata.setIsbn10("160690373X");
|
||||
metadata.setHardcoverRating(4.0d);
|
||||
|
||||
writer.saveMetadataToFile(cbz, metadata, null, new MetadataClearFlags());
|
||||
|
||||
String xmlContent = readComicInfoFromCbz(cbz);
|
||||
System.out.println("Generated XML:\n" + xmlContent);
|
||||
|
||||
// 1. Verify Element Ordering (Crucial for XSD v2.0)
|
||||
// Title -> Series -> Number -> Count ... -> Summary -> Notes ... -> Web ...
|
||||
assertOrder(xmlContent, "Title", "Series");
|
||||
assertOrder(xmlContent, "Series", "Number");
|
||||
assertOrder(xmlContent, "Number", "Count");
|
||||
assertOrder(xmlContent, "Count", "Summary");
|
||||
assertOrder(xmlContent, "Summary", "Notes");
|
||||
assertOrder(xmlContent, "Notes", "Year");
|
||||
assertOrder(xmlContent, "Year", "Month");
|
||||
assertOrder(xmlContent, "Month", "Day");
|
||||
assertOrder(xmlContent, "Day", "Genre"); // Note: Genre is where we put categories usually, need to check if we set it.
|
||||
// We didn't set categories in this test, so Genre might be missing.
|
||||
assertOrder(xmlContent, "Day", "Web"); // Web comes later
|
||||
assertOrder(xmlContent, "Web", "PageCount");
|
||||
|
||||
// 2. Verify Formatting
|
||||
assertFalse(xmlContent.matches("(?s).*\\n\\s*\\n\\s*<.*"), "Should not have empty lines between elements");
|
||||
|
||||
// 3. Verify HTML Stripping in Summary
|
||||
assertTrue(xmlContent.contains("<Summary>On his own and out of options, Hughie resorts to extreme measures...</Summary>"));
|
||||
assertFalse(xmlContent.contains("<p>"), "HTML tags should be removed from Summary");
|
||||
|
||||
// 4. Verify Single Web URL
|
||||
assertTrue(xmlContent.contains("<Web>https://hardcover.app/books/547027</Web>"));
|
||||
// assertFalse(xmlContent.contains(","), "Web field should not be comma separated"); // Removed as other fields may contain commas
|
||||
|
||||
// 5. Verify Notes Format
|
||||
assertTrue(xmlContent.contains("[BookLore:Moods] Dark"), "Notes should contain formatted custom metadata");
|
||||
|
||||
|
||||
// 6. Verify GTIN (v2.1)
|
||||
assertTrue(xmlContent.contains("<GTIN>9781606903735</GTIN>"));
|
||||
}
|
||||
|
||||
private void assertOrder(String content, String tag1, String tag2) {
|
||||
int idx1 = content.indexOf("<" + tag1 + ">");
|
||||
int idx2 = content.indexOf("<" + tag2 + ">");
|
||||
|
||||
// Skip if one of the tags is missing from output (optional fields)
|
||||
if (idx1 == -1 || idx2 == -1) return;
|
||||
|
||||
assertTrue(idx1 < idx2, "Tag <" + tag1 + "> (pos " + idx1 + ") should appear before <" + tag2 + "> (pos " + idx2 + ")");
|
||||
}
|
||||
|
||||
private File createDummyCbz(String name) throws Exception {
|
||||
File f = tempDir.resolve(name).toFile();
|
||||
try (ZipOutputStream zos = new ZipOutputStream(new FileOutputStream(f))) {
|
||||
ZipEntry ze = new ZipEntry("test.jpg");
|
||||
zos.putNextEntry(ze);
|
||||
zos.write(new byte[]{0});
|
||||
zos.closeEntry();
|
||||
}
|
||||
return f;
|
||||
}
|
||||
|
||||
private String readComicInfoFromCbz(File cbz) throws Exception {
|
||||
try (ZipFile zip = new ZipFile(cbz)) {
|
||||
ZipEntry entry = zip.getEntry("ComicInfo.xml");
|
||||
try (var is = zip.getInputStream(entry)) {
|
||||
return new String(is.readAllBytes(), StandardCharsets.UTF_8);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,10 +3,9 @@ package org.booklore.service.metadata.writer;
|
||||
import org.booklore.model.MetadataClearFlags;
|
||||
import org.booklore.model.dto.settings.AppSettings;
|
||||
import org.booklore.model.dto.settings.MetadataPersistenceSettings;
|
||||
import org.booklore.model.entity.AuthorEntity;
|
||||
import org.booklore.model.entity.BookMetadataEntity;
|
||||
import org.booklore.model.entity.CategoryEntity;
|
||||
import org.booklore.model.entity.*;
|
||||
import org.booklore.model.enums.BookFileType;
|
||||
import org.booklore.model.enums.ComicCreatorRole;
|
||||
import org.booklore.service.appsettings.AppSettingService;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
@@ -164,6 +163,68 @@ class CbxMetadataWriterTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void saveMetadataToFile_cbz_writesTagsRatingAndWebField() throws Exception {
|
||||
File cbz = createCbz(tempDir.resolve("tags_rating.cbz"), new String[]{"page1.jpg"});
|
||||
|
||||
BookMetadataEntity meta = new BookMetadataEntity();
|
||||
meta.setTitle("Rating Test");
|
||||
meta.setRating(8.4);
|
||||
meta.setGoodreadsId("12345");
|
||||
meta.setAsin("B00TEST123");
|
||||
|
||||
Set<TagEntity> tags = new HashSet<>();
|
||||
TagEntity tag1 = new TagEntity();
|
||||
tag1.setId(1L);
|
||||
tag1.setName("Fantasy");
|
||||
TagEntity tag2 = new TagEntity();
|
||||
tag2.setId(2L);
|
||||
tag2.setName("Epic");
|
||||
tags.add(tag1);
|
||||
tags.add(tag2);
|
||||
meta.setTags(tags);
|
||||
|
||||
Set<MoodEntity> moods = new HashSet<>();
|
||||
MoodEntity mood = new MoodEntity();
|
||||
mood.setId(1L);
|
||||
mood.setName("Dark");
|
||||
moods.add(mood);
|
||||
meta.setMoods(moods);
|
||||
|
||||
writer.saveMetadataToFile(cbz, meta, null, new MetadataClearFlags());
|
||||
|
||||
try (ZipFile zip = new ZipFile(cbz)) {
|
||||
ZipEntry ci = zip.getEntry("ComicInfo.xml");
|
||||
assertNotNull(ci);
|
||||
Document doc = parseXml(zip.getInputStream(ci));
|
||||
|
||||
String notesVal = text(doc, "Notes");
|
||||
assertNotNull(notesVal);
|
||||
assertTrue(notesVal.contains("[BookLore:Tags]"));
|
||||
assertTrue(notesVal.contains("Fantasy"));
|
||||
assertTrue(notesVal.contains("Epic"));
|
||||
|
||||
// Tags now written as dedicated element per Anansi v2.1
|
||||
String tagsVal = text(doc, "Tags");
|
||||
assertNotNull(tagsVal, "Tags should be written as standalone element per Anansi v2.1");
|
||||
assertTrue(tagsVal.contains("Fantasy") || tagsVal.contains("Epic"), "Tags should contain Fantasy or Epic");
|
||||
|
||||
String rating = text(doc, "CommunityRating");
|
||||
assertNotNull(rating);
|
||||
assertEquals("4.2", rating);
|
||||
|
||||
String web = text(doc, "Web");
|
||||
assertNotNull(web);
|
||||
assertTrue(web.contains("goodreads.com"));
|
||||
// assertTrue(web.contains("amazon.com")); // Only primary URL is stored in Web field now
|
||||
|
||||
String notes = text(doc, "Notes");
|
||||
assertNotNull(notes);
|
||||
assertTrue(notes.contains("[BookLore:Moods]"));
|
||||
assertTrue(notes.contains("Dark"));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void saveMetadataToFile_cbz_updatesExistingComicInfo() throws Exception {
|
||||
// Create a CBZ *with* an existing ComicInfo.xml
|
||||
@@ -212,6 +273,129 @@ class CbxMetadataWriterTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void saveMetadataToFile_cbz_writesComicSpecificMetadata() throws Exception {
|
||||
File cbz = createCbz(tempDir.resolve("comic_meta.cbz"), new String[]{"page1.jpg"});
|
||||
|
||||
// Create metadata with ComicMetadataEntity
|
||||
BookMetadataEntity meta = new BookMetadataEntity();
|
||||
meta.setTitle("Spider-Man #1");
|
||||
meta.setSeriesName("Spider-Man");
|
||||
meta.setSeriesNumber(1.0f);
|
||||
meta.setAgeRating(13); // Teen rating
|
||||
|
||||
// Create ComicMetadataEntity with all comic-specific fields
|
||||
ComicMetadataEntity comic = ComicMetadataEntity.builder()
|
||||
.volumeNumber(2023)
|
||||
.alternateSeries("Amazing Spider-Man")
|
||||
.alternateIssue("700.1")
|
||||
.storyArc("Superior")
|
||||
.format("Single Issue")
|
||||
.imprint("Marvel Knights")
|
||||
.blackAndWhite(false)
|
||||
.manga(true)
|
||||
.readingDirection("RTL")
|
||||
.build();
|
||||
|
||||
// Characters
|
||||
ComicCharacterEntity char1 = new ComicCharacterEntity();
|
||||
char1.setId(1L);
|
||||
char1.setName("Peter Parker");
|
||||
ComicCharacterEntity char2 = new ComicCharacterEntity();
|
||||
char2.setId(2L);
|
||||
char2.setName("Mary Jane");
|
||||
Set<ComicCharacterEntity> characters = new HashSet<>();
|
||||
characters.add(char1);
|
||||
characters.add(char2);
|
||||
comic.setCharacters(characters);
|
||||
|
||||
// Teams
|
||||
ComicTeamEntity team1 = new ComicTeamEntity();
|
||||
team1.setId(1L);
|
||||
team1.setName("Avengers");
|
||||
Set<ComicTeamEntity> teams = new HashSet<>();
|
||||
teams.add(team1);
|
||||
comic.setTeams(teams);
|
||||
|
||||
// Locations
|
||||
ComicLocationEntity loc1 = new ComicLocationEntity();
|
||||
loc1.setId(1L);
|
||||
loc1.setName("New York City");
|
||||
Set<ComicLocationEntity> locations = new HashSet<>();
|
||||
locations.add(loc1);
|
||||
comic.setLocations(locations);
|
||||
|
||||
// Creators
|
||||
ComicCreatorEntity penciller = new ComicCreatorEntity();
|
||||
penciller.setId(1L);
|
||||
penciller.setName("John Romita Jr.");
|
||||
ComicCreatorEntity inker = new ComicCreatorEntity();
|
||||
inker.setId(2L);
|
||||
inker.setName("Klaus Janson");
|
||||
ComicCreatorMappingEntity pencillerMapping = ComicCreatorMappingEntity.builder()
|
||||
.creator(penciller)
|
||||
.role(ComicCreatorRole.PENCILLER)
|
||||
.comicMetadata(comic)
|
||||
.build();
|
||||
ComicCreatorMappingEntity inkerMapping = ComicCreatorMappingEntity.builder()
|
||||
.creator(inker)
|
||||
.role(ComicCreatorRole.INKER)
|
||||
.comicMetadata(comic)
|
||||
.build();
|
||||
Set<ComicCreatorMappingEntity> creatorMappings = new HashSet<>();
|
||||
creatorMappings.add(pencillerMapping);
|
||||
creatorMappings.add(inkerMapping);
|
||||
comic.setCreatorMappings(creatorMappings);
|
||||
|
||||
meta.setComicMetadata(comic);
|
||||
|
||||
writer.saveMetadataToFile(cbz, meta, null, new MetadataClearFlags());
|
||||
|
||||
try (ZipFile zip = new ZipFile(cbz)) {
|
||||
ZipEntry ci = zip.getEntry("ComicInfo.xml");
|
||||
assertNotNull(ci, "ComicInfo.xml should be present");
|
||||
Document doc = parseXml(zip.getInputStream(ci));
|
||||
|
||||
// Basic metadata
|
||||
assertEquals("Spider-Man #1", text(doc, "Title"));
|
||||
assertEquals("Spider-Man", text(doc, "Series"));
|
||||
|
||||
// Comic-specific fields
|
||||
assertEquals("2023", text(doc, "Volume"));
|
||||
assertEquals("Amazing Spider-Man", text(doc, "AlternateSeries"));
|
||||
assertEquals("700.1", text(doc, "AlternateNumber"));
|
||||
assertEquals("Superior", text(doc, "StoryArc"));
|
||||
assertEquals("Single Issue", text(doc, "Format"));
|
||||
assertEquals("Marvel Knights", text(doc, "Imprint"));
|
||||
assertEquals("No", text(doc, "BlackAndWhite"));
|
||||
assertEquals("YesAndRightToLeft", text(doc, "Manga"));
|
||||
assertEquals("Teen", text(doc, "AgeRating"));
|
||||
|
||||
// Characters, Teams, Locations
|
||||
String characters_str = text(doc, "Characters");
|
||||
assertNotNull(characters_str);
|
||||
assertTrue(characters_str.contains("Peter Parker"));
|
||||
assertTrue(characters_str.contains("Mary Jane"));
|
||||
|
||||
String teams_str = text(doc, "Teams");
|
||||
assertNotNull(teams_str);
|
||||
assertTrue(teams_str.contains("Avengers"));
|
||||
|
||||
String locations_str = text(doc, "Locations");
|
||||
assertNotNull(locations_str);
|
||||
assertTrue(locations_str.contains("New York City"));
|
||||
|
||||
// Creators
|
||||
String penciller_str = text(doc, "Penciller");
|
||||
assertNotNull(penciller_str);
|
||||
assertTrue(penciller_str.contains("John Romita Jr."));
|
||||
|
||||
String inker_str = text(doc, "Inker");
|
||||
assertNotNull(inker_str);
|
||||
assertTrue(inker_str.contains("Klaus Janson"));
|
||||
}
|
||||
}
|
||||
|
||||
// ------------- helpers -------------
|
||||
|
||||
private static File createCbz(Path path, String[] imageNames) throws Exception {
|
||||
|
||||
@@ -0,0 +1,295 @@
|
||||
package org.booklore.service.metadata.writer;
|
||||
|
||||
import org.booklore.model.entity.BookMetadataEntity;
|
||||
import org.booklore.model.entity.MoodEntity;
|
||||
import org.booklore.model.entity.TagEntity;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Nested;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class MetadataCopyHelperTest {
|
||||
|
||||
private BookMetadataEntity metadata;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
metadata = new BookMetadataEntity();
|
||||
}
|
||||
|
||||
@Nested
|
||||
class MoodsTests {
|
||||
@Test
|
||||
void copyMoods_whenNotLocked_callsConsumer() {
|
||||
MoodEntity mood1 = new MoodEntity();
|
||||
mood1.setName("Dark");
|
||||
MoodEntity mood2 = new MoodEntity();
|
||||
mood2.setName("Atmospheric");
|
||||
metadata.setMoods(Set.of(mood1, mood2));
|
||||
metadata.setMoodsLocked(false);
|
||||
|
||||
MetadataCopyHelper helper = new MetadataCopyHelper(metadata);
|
||||
AtomicReference<Set<String>> result = new AtomicReference<>();
|
||||
|
||||
helper.copyMoods(false, result::set);
|
||||
|
||||
assertNotNull(result.get());
|
||||
assertEquals(2, result.get().size());
|
||||
assertTrue(result.get().contains("Dark"));
|
||||
assertTrue(result.get().contains("Atmospheric"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void copyMoods_whenLocked_doesNotCallConsumer() {
|
||||
MoodEntity mood = new MoodEntity();
|
||||
mood.setName("Dark");
|
||||
metadata.setMoods(Set.of(mood));
|
||||
metadata.setMoodsLocked(true);
|
||||
|
||||
MetadataCopyHelper helper = new MetadataCopyHelper(metadata);
|
||||
AtomicReference<Set<String>> result = new AtomicReference<>();
|
||||
|
||||
helper.copyMoods(false, result::set);
|
||||
|
||||
assertNull(result.get());
|
||||
}
|
||||
|
||||
@Test
|
||||
void copyMoods_whenClear_passesEmptySet() {
|
||||
MoodEntity mood = new MoodEntity();
|
||||
mood.setName("Dark");
|
||||
metadata.setMoods(Set.of(mood));
|
||||
metadata.setMoodsLocked(false);
|
||||
|
||||
MetadataCopyHelper helper = new MetadataCopyHelper(metadata);
|
||||
AtomicReference<Set<String>> result = new AtomicReference<>();
|
||||
|
||||
helper.copyMoods(true, result::set);
|
||||
|
||||
assertNotNull(result.get());
|
||||
assertTrue(result.get().isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void copyMoods_filtersBlankNames() {
|
||||
MoodEntity mood1 = new MoodEntity();
|
||||
mood1.setName("Dark");
|
||||
MoodEntity mood2 = new MoodEntity();
|
||||
mood2.setName(" ");
|
||||
MoodEntity mood3 = new MoodEntity();
|
||||
mood3.setName(null);
|
||||
metadata.setMoods(Set.of(mood1, mood2, mood3));
|
||||
metadata.setMoodsLocked(false);
|
||||
|
||||
MetadataCopyHelper helper = new MetadataCopyHelper(metadata);
|
||||
AtomicReference<Set<String>> result = new AtomicReference<>();
|
||||
|
||||
helper.copyMoods(false, result::set);
|
||||
|
||||
assertEquals(1, result.get().size());
|
||||
assertTrue(result.get().contains("Dark"));
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
class TagsTests {
|
||||
@Test
|
||||
void copyTags_whenNotLocked_callsConsumer() {
|
||||
TagEntity tag1 = new TagEntity();
|
||||
tag1.setName("Fantasy");
|
||||
TagEntity tag2 = new TagEntity();
|
||||
tag2.setName("Epic");
|
||||
metadata.setTags(Set.of(tag1, tag2));
|
||||
metadata.setTagsLocked(false);
|
||||
|
||||
MetadataCopyHelper helper = new MetadataCopyHelper(metadata);
|
||||
AtomicReference<Set<String>> result = new AtomicReference<>();
|
||||
|
||||
helper.copyTags(false, result::set);
|
||||
|
||||
assertNotNull(result.get());
|
||||
assertEquals(2, result.get().size());
|
||||
assertTrue(result.get().contains("Fantasy"));
|
||||
assertTrue(result.get().contains("Epic"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void copyTags_whenLocked_doesNotCallConsumer() {
|
||||
TagEntity tag = new TagEntity();
|
||||
tag.setName("Fantasy");
|
||||
metadata.setTags(Set.of(tag));
|
||||
metadata.setTagsLocked(true);
|
||||
|
||||
MetadataCopyHelper helper = new MetadataCopyHelper(metadata);
|
||||
AtomicReference<Set<String>> result = new AtomicReference<>();
|
||||
|
||||
helper.copyTags(false, result::set);
|
||||
|
||||
assertNull(result.get());
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
class RatingTests {
|
||||
@Test
|
||||
void copyRating_whenPresent_callsConsumer() {
|
||||
metadata.setRating(8.5);
|
||||
|
||||
MetadataCopyHelper helper = new MetadataCopyHelper(metadata);
|
||||
AtomicReference<Double> result = new AtomicReference<>();
|
||||
|
||||
helper.copyRating(false, result::set);
|
||||
|
||||
assertEquals(8.5, result.get());
|
||||
}
|
||||
|
||||
@Test
|
||||
void copyRating_whenClear_passesNull() {
|
||||
metadata.setRating(8.5);
|
||||
|
||||
MetadataCopyHelper helper = new MetadataCopyHelper(metadata);
|
||||
AtomicReference<Double> result = new AtomicReference<>(99.0);
|
||||
|
||||
helper.copyRating(true, result::set);
|
||||
|
||||
assertNull(result.get());
|
||||
}
|
||||
|
||||
@Test
|
||||
void copyAmazonRating_whenNotLocked_callsConsumer() {
|
||||
metadata.setAmazonRating(4.5);
|
||||
metadata.setAmazonRatingLocked(false);
|
||||
|
||||
MetadataCopyHelper helper = new MetadataCopyHelper(metadata);
|
||||
AtomicReference<Double> result = new AtomicReference<>();
|
||||
|
||||
helper.copyAmazonRating(false, result::set);
|
||||
|
||||
assertEquals(4.5, result.get());
|
||||
}
|
||||
|
||||
@Test
|
||||
void copyAmazonRating_whenLocked_doesNotCallConsumer() {
|
||||
metadata.setAmazonRating(4.5);
|
||||
metadata.setAmazonRatingLocked(true);
|
||||
|
||||
MetadataCopyHelper helper = new MetadataCopyHelper(metadata);
|
||||
AtomicReference<Double> result = new AtomicReference<>();
|
||||
|
||||
helper.copyAmazonRating(false, result::set);
|
||||
|
||||
assertNull(result.get());
|
||||
}
|
||||
|
||||
@Test
|
||||
void copyGoodreadsRating_whenNotLocked_callsConsumer() {
|
||||
metadata.setGoodreadsRating(4.2);
|
||||
metadata.setGoodreadsRatingLocked(false);
|
||||
|
||||
MetadataCopyHelper helper = new MetadataCopyHelper(metadata);
|
||||
AtomicReference<Double> result = new AtomicReference<>();
|
||||
|
||||
helper.copyGoodreadsRating(false, result::set);
|
||||
|
||||
assertEquals(4.2, result.get());
|
||||
}
|
||||
|
||||
@Test
|
||||
void copyHardcoverRating_whenNotLocked_callsConsumer() {
|
||||
metadata.setHardcoverRating(3.8);
|
||||
metadata.setHardcoverRatingLocked(false);
|
||||
|
||||
MetadataCopyHelper helper = new MetadataCopyHelper(metadata);
|
||||
AtomicReference<Double> result = new AtomicReference<>();
|
||||
|
||||
helper.copyHardcoverRating(false, result::set);
|
||||
|
||||
assertEquals(3.8, result.get());
|
||||
}
|
||||
|
||||
@Test
|
||||
void copyLubimyczytacRating_whenNotLocked_callsConsumer() {
|
||||
metadata.setLubimyczytacRating(7.5);
|
||||
metadata.setLubimyczytacRatingLocked(false);
|
||||
|
||||
MetadataCopyHelper helper = new MetadataCopyHelper(metadata);
|
||||
AtomicReference<Double> result = new AtomicReference<>();
|
||||
|
||||
helper.copyLubimyczytacRating(false, result::set);
|
||||
|
||||
assertEquals(7.5, result.get());
|
||||
}
|
||||
|
||||
@Test
|
||||
void copyRanobedbRating_whenNotLocked_callsConsumer() {
|
||||
metadata.setRanobedbRating(8.0);
|
||||
metadata.setRanobedbRatingLocked(false);
|
||||
|
||||
MetadataCopyHelper helper = new MetadataCopyHelper(metadata);
|
||||
AtomicReference<Double> result = new AtomicReference<>();
|
||||
|
||||
helper.copyRanobedbRating(false, result::set);
|
||||
|
||||
assertEquals(8.0, result.get());
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
class IdentifierTests {
|
||||
@Test
|
||||
void copyLubimyczytacId_whenNotLocked_callsConsumer() {
|
||||
metadata.setLubimyczytacId("12345");
|
||||
metadata.setLubimyczytacIdLocked(false);
|
||||
|
||||
MetadataCopyHelper helper = new MetadataCopyHelper(metadata);
|
||||
AtomicReference<String> result = new AtomicReference<>();
|
||||
|
||||
helper.copyLubimyczytacId(false, result::set);
|
||||
|
||||
assertEquals("12345", result.get());
|
||||
}
|
||||
|
||||
@Test
|
||||
void copyLubimyczytacId_whenLocked_doesNotCallConsumer() {
|
||||
metadata.setLubimyczytacId("12345");
|
||||
metadata.setLubimyczytacIdLocked(true);
|
||||
|
||||
MetadataCopyHelper helper = new MetadataCopyHelper(metadata);
|
||||
AtomicReference<String> result = new AtomicReference<>();
|
||||
|
||||
helper.copyLubimyczytacId(false, result::set);
|
||||
|
||||
assertNull(result.get());
|
||||
}
|
||||
|
||||
@Test
|
||||
void copyHardcoverBookId_whenNotLocked_callsConsumer() {
|
||||
metadata.setHardcoverBookId("98765");
|
||||
metadata.setHardcoverBookIdLocked(false);
|
||||
|
||||
MetadataCopyHelper helper = new MetadataCopyHelper(metadata);
|
||||
AtomicReference<String> result = new AtomicReference<>();
|
||||
|
||||
helper.copyHardcoverBookId(false, result::set);
|
||||
|
||||
assertEquals("98765", result.get());
|
||||
}
|
||||
|
||||
@Test
|
||||
void copyHardcoverBookId_whenLocked_doesNotCallConsumer() {
|
||||
metadata.setHardcoverBookId("98765");
|
||||
metadata.setHardcoverBookIdLocked(true);
|
||||
|
||||
MetadataCopyHelper helper = new MetadataCopyHelper(metadata);
|
||||
AtomicReference<String> result = new AtomicReference<>();
|
||||
|
||||
helper.copyHardcoverBookId(false, result::set);
|
||||
|
||||
assertNull(result.get());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,398 @@
|
||||
package org.booklore.service.metadata.writer;
|
||||
|
||||
import org.apache.pdfbox.Loader;
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.apache.pdfbox.pdmodel.PDPage;
|
||||
import org.apache.pdfbox.pdmodel.common.PDMetadata;
|
||||
import org.booklore.model.dto.BookMetadata;
|
||||
import org.booklore.model.dto.settings.AppSettings;
|
||||
import org.booklore.model.dto.settings.MetadataPersistenceSettings;
|
||||
import org.booklore.model.entity.AuthorEntity;
|
||||
import org.booklore.model.entity.BookMetadataEntity;
|
||||
import org.booklore.model.entity.MoodEntity;
|
||||
import org.booklore.model.entity.TagEntity;
|
||||
import org.booklore.model.enums.BookFileType;
|
||||
import org.booklore.service.appsettings.AppSettingService;
|
||||
import org.booklore.service.metadata.extractor.PdfMetadataExtractor;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.Mockito;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.time.LocalDate;
|
||||
import java.util.Comparator;
|
||||
import java.util.HashSet;
|
||||
import java.util.Set;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class PdfMetadataWriterTest {
|
||||
|
||||
private PdfMetadataWriter writer;
|
||||
private PdfMetadataExtractor extractor;
|
||||
private Path tempDir;
|
||||
|
||||
@BeforeEach
|
||||
void setup() throws Exception {
|
||||
AppSettingService appSettingService = Mockito.mock(AppSettingService.class);
|
||||
AppSettings settings = new AppSettings();
|
||||
MetadataPersistenceSettings persistence = new MetadataPersistenceSettings();
|
||||
MetadataPersistenceSettings.SaveToOriginalFile save = new MetadataPersistenceSettings.SaveToOriginalFile();
|
||||
MetadataPersistenceSettings.FormatSettings pdfSettings = new MetadataPersistenceSettings.FormatSettings();
|
||||
pdfSettings.setEnabled(true);
|
||||
pdfSettings.setMaxFileSizeInMb(100);
|
||||
save.setPdf(pdfSettings);
|
||||
persistence.setSaveToOriginalFile(save);
|
||||
settings.setMetadataPersistenceSettings(persistence);
|
||||
Mockito.when(appSettingService.getAppSettings()).thenReturn(settings);
|
||||
|
||||
writer = new PdfMetadataWriter(appSettingService);
|
||||
extractor = new PdfMetadataExtractor();
|
||||
tempDir = Files.createTempDirectory("pdf_writer_test_");
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void cleanup() throws Exception {
|
||||
if (tempDir != null) {
|
||||
Files.walk(tempDir)
|
||||
.sorted(Comparator.reverseOrder())
|
||||
.forEach(p -> {
|
||||
try {
|
||||
Files.deleteIfExists(p);
|
||||
} catch (Exception ignore) {
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void getSupportedBookType_isPdf() {
|
||||
assertEquals(BookFileType.PDF, writer.getSupportedBookType());
|
||||
}
|
||||
|
||||
@Test
|
||||
void saveAndReadMetadata_basicFields_roundTrip() throws Exception {
|
||||
File pdf = createEmptyPdf("basic.pdf");
|
||||
|
||||
BookMetadataEntity meta = createBasicMetadata();
|
||||
meta.setTitle("Dead Simple Python");
|
||||
meta.setDescription("A Python programming guide");
|
||||
meta.setPublisher("No Starch Press");
|
||||
meta.setPublishedDate(LocalDate.of(2022, 11, 22));
|
||||
meta.setLanguage("en");
|
||||
meta.setPageCount(754);
|
||||
|
||||
Set<AuthorEntity> authors = new HashSet<>();
|
||||
AuthorEntity author = new AuthorEntity();
|
||||
author.setName("Jason C McDonald");
|
||||
authors.add(author);
|
||||
meta.setAuthors(authors);
|
||||
|
||||
writer.saveMetadataToFile(pdf, meta, null, null);
|
||||
|
||||
BookMetadata result = extractor.extractMetadata(pdf);
|
||||
assertEquals("Dead Simple Python", result.getTitle());
|
||||
assertEquals("A Python programming guide", result.getDescription());
|
||||
assertEquals("No Starch Press", result.getPublisher());
|
||||
assertEquals("en", result.getLanguage());
|
||||
assertTrue(result.getAuthors().contains("Jason C McDonald"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void saveAndReadMetadata_validSeries_isWritten() throws Exception {
|
||||
File pdf = createEmptyPdf("series-valid.pdf");
|
||||
|
||||
BookMetadataEntity meta = createBasicMetadata();
|
||||
meta.setTitle("The Walking Dead #22");
|
||||
meta.setSeriesName("The Walking Dead");
|
||||
meta.setSeriesNumber(22f);
|
||||
meta.setSeriesTotal(193);
|
||||
|
||||
writer.saveMetadataToFile(pdf, meta, null, null);
|
||||
|
||||
BookMetadata result = extractor.extractMetadata(pdf);
|
||||
assertEquals("The Walking Dead", result.getSeriesName());
|
||||
assertEquals(22f, result.getSeriesNumber());
|
||||
assertEquals(193, result.getSeriesTotal());
|
||||
}
|
||||
|
||||
@Test
|
||||
void saveAndReadMetadata_seriesNameOnly_notWritten() throws Exception {
|
||||
File pdf = createEmptyPdf("series-name-only.pdf");
|
||||
|
||||
BookMetadataEntity meta = createBasicMetadata();
|
||||
meta.setTitle("Programming Book");
|
||||
meta.setSeriesName("Programming"); // Name without number - BROKEN DATA
|
||||
meta.setSeriesNumber(null);
|
||||
|
||||
writer.saveMetadataToFile(pdf, meta, null, null);
|
||||
|
||||
BookMetadata result = extractor.extractMetadata(pdf);
|
||||
// Series should NOT be written since it's incomplete
|
||||
assertNull(result.getSeriesName(), "Series name should not be written without valid number");
|
||||
assertNull(result.getSeriesNumber(), "Series number should be null");
|
||||
}
|
||||
|
||||
@Test
|
||||
void saveAndReadMetadata_seriesNumberZero_notWritten() throws Exception {
|
||||
File pdf = createEmptyPdf("series-zero.pdf");
|
||||
|
||||
BookMetadataEntity meta = createBasicMetadata();
|
||||
meta.setTitle("Test Book");
|
||||
meta.setSeriesName("Test Series");
|
||||
meta.setSeriesNumber(0f); // Zero is invalid
|
||||
|
||||
writer.saveMetadataToFile(pdf, meta, null, null);
|
||||
|
||||
BookMetadata result = extractor.extractMetadata(pdf);
|
||||
// Series should NOT be written since number is zero
|
||||
assertNull(result.getSeriesName());
|
||||
assertNull(result.getSeriesNumber());
|
||||
}
|
||||
|
||||
@Test
|
||||
void saveAndReadMetadata_seriesNumberFormattedNicely() throws Exception {
|
||||
File pdf = createEmptyPdf("series-format.pdf");
|
||||
|
||||
BookMetadataEntity meta = createBasicMetadata();
|
||||
meta.setSeriesName("Test Series");
|
||||
meta.setSeriesNumber(22f); // Whole number
|
||||
|
||||
writer.saveMetadataToFile(pdf, meta, null, null);
|
||||
|
||||
// Read raw XMP to verify formatting
|
||||
String xmpContent = readXmpContent(pdf);
|
||||
assertTrue(xmpContent.contains("<booklore:seriesNumber>22</booklore:seriesNumber>"),
|
||||
"Series number should be formatted as '22' not '22.00'");
|
||||
}
|
||||
|
||||
@Test
|
||||
void saveAndReadMetadata_seriesDecimalNumber_preserved() throws Exception {
|
||||
File pdf = createEmptyPdf("series-decimal.pdf");
|
||||
|
||||
BookMetadataEntity meta = createBasicMetadata();
|
||||
meta.setSeriesName("Test Series");
|
||||
meta.setSeriesNumber(1.5f);
|
||||
|
||||
writer.saveMetadataToFile(pdf, meta, null, null);
|
||||
|
||||
BookMetadata result = extractor.extractMetadata(pdf);
|
||||
assertEquals(1.5f, result.getSeriesNumber());
|
||||
}
|
||||
|
||||
@Test
|
||||
void saveAndReadMetadata_goodreadsIdNormalized() throws Exception {
|
||||
File pdf = createEmptyPdf("goodreads-id.pdf");
|
||||
|
||||
BookMetadataEntity meta = createBasicMetadata();
|
||||
meta.setGoodreadsId("52555538-dead-simple-python"); // Full slug format
|
||||
|
||||
writer.saveMetadataToFile(pdf, meta, null, null);
|
||||
|
||||
BookMetadata result = extractor.extractMetadata(pdf);
|
||||
assertEquals("52555538", result.getGoodreadsId(),
|
||||
"Goodreads ID should be normalized to just the numeric part");
|
||||
}
|
||||
|
||||
@Test
|
||||
void saveAndReadMetadata_goodreadsIdAlreadyNumeric_unchanged() throws Exception {
|
||||
File pdf = createEmptyPdf("goodreads-numeric.pdf");
|
||||
|
||||
BookMetadataEntity meta = createBasicMetadata();
|
||||
meta.setGoodreadsId("12345678");
|
||||
|
||||
writer.saveMetadataToFile(pdf, meta, null, null);
|
||||
|
||||
BookMetadata result = extractor.extractMetadata(pdf);
|
||||
assertEquals("12345678", result.getGoodreadsId());
|
||||
}
|
||||
|
||||
@Test
|
||||
void saveAndReadMetadata_externalIds_roundTrip() throws Exception {
|
||||
File pdf = createEmptyPdf("external-ids.pdf");
|
||||
|
||||
BookMetadataEntity meta = createBasicMetadata();
|
||||
meta.setIsbn13("9781718500921");
|
||||
meta.setIsbn10("1718500920");
|
||||
meta.setGoogleId("MPBmEAAAQBAJ");
|
||||
meta.setGoodreadsId("52555538");
|
||||
meta.setHardcoverId("dead-simple-python");
|
||||
meta.setHardcoverBookId("547027");
|
||||
meta.setAsin("B08KGS5V1R");
|
||||
meta.setComicvineId("4000-123456");
|
||||
meta.setLubimyczytacId("123456");
|
||||
meta.setRanobedbId("7890");
|
||||
|
||||
writer.saveMetadataToFile(pdf, meta, null, null);
|
||||
|
||||
BookMetadata result = extractor.extractMetadata(pdf);
|
||||
assertEquals("9781718500921", result.getIsbn13());
|
||||
assertEquals("1718500920", result.getIsbn10());
|
||||
assertEquals("MPBmEAAAQBAJ", result.getGoogleId());
|
||||
assertEquals("52555538", result.getGoodreadsId());
|
||||
assertEquals("dead-simple-python", result.getHardcoverId());
|
||||
assertEquals("547027", result.getHardcoverBookId());
|
||||
assertEquals("B08KGS5V1R", result.getAsin());
|
||||
assertEquals("4000-123456", result.getComicvineId());
|
||||
assertEquals("123456", result.getLubimyczytacId());
|
||||
assertEquals("7890", result.getRanobedbId());
|
||||
}
|
||||
|
||||
@Test
|
||||
void saveAndReadMetadata_ratings_roundTrip() throws Exception {
|
||||
File pdf = createEmptyPdf("ratings.pdf");
|
||||
|
||||
BookMetadataEntity meta = createBasicMetadata();
|
||||
meta.setGoodreadsRating(4.4);
|
||||
meta.setHardcoverRating(4.2);
|
||||
meta.setAmazonRating(4.5);
|
||||
meta.setLubimyczytacRating(8.5);
|
||||
meta.setRanobedbRating(7.8);
|
||||
|
||||
writer.saveMetadataToFile(pdf, meta, null, null);
|
||||
|
||||
BookMetadata result = extractor.extractMetadata(pdf);
|
||||
assertEquals(4.4, result.getGoodreadsRating(), 0.01);
|
||||
assertEquals(4.2, result.getHardcoverRating(), 0.01);
|
||||
assertEquals(4.5, result.getAmazonRating(), 0.01);
|
||||
assertEquals(8.5, result.getLubimyczytacRating(), 0.01);
|
||||
assertEquals(7.8, result.getRanobedbRating(), 0.01);
|
||||
}
|
||||
|
||||
@Test
|
||||
void saveAndReadMetadata_zeroRatings_notWritten() throws Exception {
|
||||
File pdf = createEmptyPdf("zero-ratings.pdf");
|
||||
|
||||
BookMetadataEntity meta = createBasicMetadata();
|
||||
meta.setGoodreadsRating(0.0);
|
||||
meta.setHardcoverRating(0.0);
|
||||
|
||||
writer.saveMetadataToFile(pdf, meta, null, null);
|
||||
|
||||
String xmpContent = readXmpContent(pdf);
|
||||
assertFalse(xmpContent.contains("goodreadsRating"),
|
||||
"Zero ratings should not be written to XMP");
|
||||
assertFalse(xmpContent.contains("hardcoverRating"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void saveAndReadMetadata_tagsAndMoods_asRdfBags() throws Exception {
|
||||
File pdf = createEmptyPdf("tags-moods.pdf");
|
||||
|
||||
BookMetadataEntity meta = createBasicMetadata();
|
||||
|
||||
Set<TagEntity> tags = new HashSet<>();
|
||||
TagEntity tag1 = new TagEntity(); tag1.setName("Python");
|
||||
TagEntity tag2 = new TagEntity(); tag2.setName("Programming");
|
||||
tags.add(tag1);
|
||||
tags.add(tag2);
|
||||
meta.setTags(tags);
|
||||
|
||||
Set<MoodEntity> moods = new HashSet<>();
|
||||
MoodEntity mood1 = new MoodEntity(); mood1.setName("Educational");
|
||||
MoodEntity mood2 = new MoodEntity(); mood2.setName("Technical");
|
||||
moods.add(mood1);
|
||||
moods.add(mood2);
|
||||
meta.setMoods(moods);
|
||||
|
||||
writer.saveMetadataToFile(pdf, meta, null, null);
|
||||
|
||||
BookMetadata result = extractor.extractMetadata(pdf);
|
||||
assertTrue(result.getTags().contains("Python"));
|
||||
assertTrue(result.getTags().contains("Programming"));
|
||||
assertTrue(result.getMoods().contains("Educational"));
|
||||
assertTrue(result.getMoods().contains("Technical"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void saveAndReadMetadata_subtitle_roundTrip() throws Exception {
|
||||
File pdf = createEmptyPdf("subtitle.pdf");
|
||||
|
||||
BookMetadataEntity meta = createBasicMetadata();
|
||||
meta.setSubtitle("Idiomatic Python for the Impatient Programmer");
|
||||
|
||||
writer.saveMetadataToFile(pdf, meta, null, null);
|
||||
|
||||
BookMetadata result = extractor.extractMetadata(pdf);
|
||||
assertEquals("Idiomatic Python for the Impatient Programmer", result.getSubtitle());
|
||||
}
|
||||
|
||||
@Test
|
||||
void saveMetadata_dateFormat_isDateOnly() throws Exception {
|
||||
File pdf = createEmptyPdf("date-format.pdf");
|
||||
|
||||
BookMetadataEntity meta = createBasicMetadata();
|
||||
meta.setPublishedDate(LocalDate.of(2021, 2, 17));
|
||||
|
||||
writer.saveMetadataToFile(pdf, meta, null, null);
|
||||
|
||||
String xmpContent = readXmpContent(pdf);
|
||||
// CreateDate should be date-only format: 2021-02-17
|
||||
assertTrue(xmpContent.contains("<xmp:CreateDate>2021-02-17</xmp:CreateDate>"),
|
||||
"CreateDate should use date-only format (YYYY-MM-DD)");
|
||||
}
|
||||
|
||||
@Test
|
||||
void saveMetadata_bookloreNamespaceUsed() throws Exception {
|
||||
File pdf = createEmptyPdf("namespace.pdf");
|
||||
|
||||
BookMetadataEntity meta = createBasicMetadata();
|
||||
meta.setSeriesName("Test");
|
||||
meta.setSeriesNumber(1f);
|
||||
meta.setIsbn13("1234567890123");
|
||||
|
||||
writer.saveMetadataToFile(pdf, meta, null, null);
|
||||
|
||||
String xmpContent = readXmpContent(pdf);
|
||||
assertTrue(xmpContent.contains("xmlns:booklore=\"http://booklore.org/metadata/1.0/\""),
|
||||
"Booklore namespace should be declared");
|
||||
assertTrue(xmpContent.contains("<booklore:seriesName>Test</booklore:seriesName>"),
|
||||
"Series should be in booklore namespace");
|
||||
assertFalse(xmpContent.contains("calibre:"),
|
||||
"Calibre namespace should NOT be used");
|
||||
}
|
||||
|
||||
@Test
|
||||
void saveMetadata_creatorTool_isBooklore() throws Exception {
|
||||
File pdf = createEmptyPdf("creator.pdf");
|
||||
|
||||
BookMetadataEntity meta = createBasicMetadata();
|
||||
|
||||
writer.saveMetadataToFile(pdf, meta, null, null);
|
||||
|
||||
String xmpContent = readXmpContent(pdf);
|
||||
assertTrue(xmpContent.contains("<xmp:CreatorTool>Booklore</xmp:CreatorTool>"));
|
||||
}
|
||||
|
||||
// ========== Helper Methods ==========
|
||||
|
||||
private File createEmptyPdf(String name) throws IOException {
|
||||
File pdf = tempDir.resolve(name).toFile();
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
doc.addPage(new PDPage());
|
||||
doc.save(pdf);
|
||||
}
|
||||
return pdf;
|
||||
}
|
||||
|
||||
private BookMetadataEntity createBasicMetadata() {
|
||||
BookMetadataEntity meta = new BookMetadataEntity();
|
||||
meta.setTitle("Test Book");
|
||||
return meta;
|
||||
}
|
||||
|
||||
private String readXmpContent(File pdf) throws IOException {
|
||||
try (PDDocument doc = Loader.loadPDF(pdf)) {
|
||||
PDMetadata metadata = doc.getDocumentCatalog().getMetadata();
|
||||
if (metadata == null) {
|
||||
return "";
|
||||
}
|
||||
return new String(metadata.toByteArray(), StandardCharsets.UTF_8);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -14,12 +14,12 @@ import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
|
||||
import org.springframework.dao.InvalidDataAccessApiUsageException;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.*;
|
||||
@@ -51,7 +51,7 @@ class LibraryRescanTaskTest {
|
||||
// Lenient stubs because not all tests use them
|
||||
lenient().when(request.getTaskId()).thenReturn("task-123");
|
||||
options = LibraryRescanOptions.builder().build();
|
||||
lenient().when(request.getOptions(LibraryRescanOptions.class)).thenReturn(options);
|
||||
lenient().when(request.getOptionsAs(LibraryRescanOptions.class)).thenReturn(options);
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -19,13 +19,8 @@ import java.util.Collections;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.CancellationException;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.mockito.Mockito.doThrow;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class RefreshMetadataTaskTest {
|
||||
@@ -48,7 +43,7 @@ class RefreshMetadataTaskTest {
|
||||
taskCreateRequest = mock(TaskCreateRequest.class);
|
||||
metadataRefreshRequest = MetadataRefreshRequest.builder().build();
|
||||
|
||||
when(taskCreateRequest.getOptions(MetadataRefreshRequest.class)).thenReturn(metadataRefreshRequest);
|
||||
when(taskCreateRequest.getOptionsAs(MetadataRefreshRequest.class)).thenReturn(metadataRefreshRequest);
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
18
booklore-api/src/test/resources/application-test.yml
Normal file
18
booklore-api/src/test/resources/application-test.yml
Normal file
@@ -0,0 +1,18 @@
|
||||
spring:
|
||||
datasource:
|
||||
url: jdbc:h2:mem:testdb;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE
|
||||
driver-class-name: org.h2.Driver
|
||||
username: sa
|
||||
password:
|
||||
jpa:
|
||||
database-platform: org.hibernate.dialect.H2Dialect
|
||||
hibernate:
|
||||
ddl-auto: create-drop
|
||||
properties:
|
||||
hibernate:
|
||||
format_sql: true
|
||||
flyway:
|
||||
enabled: false
|
||||
app:
|
||||
bookdrop-folder: build/bookdrop
|
||||
path-config: build/app-data
|
||||
Reference in New Issue
Block a user