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:
Balázs Szücs
2026-02-10 17:29:25 +01:00
committed by GitHub
parent 53403c168d
commit 697de9052f
40 changed files with 3660 additions and 704 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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