mirror of
https://github.com/booklore-app/booklore.git
synced 2026-05-19 17:24:41 -04:00
fix(metadata): ensure EPUB version-aware metadata writing (#2998)
* fix(metadata): ensure EPUB version-aware metadata writing EpubMetadataWriter unconditionally wrote EPUB3-only constructs into all EPUB files regardless of version, producing invalid OPF documents for EPUB3 files (e.g. opf:file-as/opf:role attributes on dc:creator) and writing EPUB3-only elements into EPUB2 files. Changes: - createCreatorElement: EPUB3 uses <meta refines="#id"> for file-as/role; EPUB2 uses opf: attributes on dc:creator - addFolderContentsToZip: mimetype is now STORED (uncompressed) and written as the first ZIP entry per EPUB spec - replaceBelongsToCollection: EPUB3 uses belongs-to-collection with refines; EPUB2 uses calibre:series/calibre:series_index convention - addSubtitleToTitle: EPUB3 uses separate dc:title with title-type refinement; EPUB2 stores subtitle via booklore:subtitle metadata only - addBookloreMetadata/createBookloreMetaElement: EPUB3 uses property attribute with prefix; EPUB2 uses name/content attribute form - removeAllBookloreMetadata: now handles both EPUB3 property and EPUB2 name attributes - cleanupCalibreArtifacts: preserves calibre:series and calibre:series_index metas used for EPUB2 series - organizeMetadataElements: correctly categorizes EPUB2-style series and booklore metas into their respective buckets - addBookloreMetadata: writes booklore:subtitle for round-trip fidelity - Added isEpub3() helper method Closes #2997 * fix(metadata): address review feedback for EPUB version-aware writing - Only preserve calibre:series/calibre:series_index for EPUB2 in cleanupCalibreArtifacts; EPUB3 files now properly remove stale entries - Use isEpub3() helper in createCreatorElement instead of inline detection - Add trim() to isEpub3() to handle whitespace in version attribute - Log warning when mimetype file is missing from extracted EPUB
This commit is contained in:
@@ -4,6 +4,7 @@ import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import net.lingala.zip4j.ZipFile;
|
||||
import net.lingala.zip4j.model.ZipParameters;
|
||||
import net.lingala.zip4j.model.enums.CompressionMethod;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.booklore.model.MetadataClearFlags;
|
||||
import org.booklore.model.dto.settings.MetadataPersistenceSettings;
|
||||
@@ -108,7 +109,7 @@ public class EpubMetadataWriter implements MetadataWriter {
|
||||
String first = parts.length > 1 ? parts[0] : "";
|
||||
String last = parts.length > 1 ? parts[1] : parts[0];
|
||||
String fileAs = last + ", " + first;
|
||||
metadataElement.appendChild(createCreatorElement(opfDoc, name, fileAs, "aut"));
|
||||
metadataElement.appendChild(createCreatorElement(opfDoc, metadataElement, name, fileAs, "aut"));
|
||||
}
|
||||
}
|
||||
hasChanges[0] = true;
|
||||
@@ -523,13 +524,31 @@ public class EpubMetadataWriter implements MetadataWriter {
|
||||
}
|
||||
|
||||
private void addFolderContentsToZip(ZipFile zipFile, File baseDir, File currentDir) throws IOException {
|
||||
// EPUB spec requires mimetype to be the first entry in the ZIP, uncompressed (STORED)
|
||||
if (baseDir.equals(currentDir)) {
|
||||
File mimetypeFile = new File(baseDir, "mimetype");
|
||||
if (mimetypeFile.exists()) {
|
||||
ZipParameters mimetypeParams = new ZipParameters();
|
||||
mimetypeParams.setFileNameInZip("mimetype");
|
||||
mimetypeParams.setCompressionMethod(CompressionMethod.STORE);
|
||||
zipFile.addFile(mimetypeFile, mimetypeParams);
|
||||
} else {
|
||||
log.warn("EPUB mimetype file not found in extracted directory — output may be spec-invalid");
|
||||
}
|
||||
}
|
||||
|
||||
File[] files = Objects.requireNonNull(currentDir.listFiles());
|
||||
for (File file : files) {
|
||||
if (file.isDirectory()) {
|
||||
addFolderContentsToZip(zipFile, baseDir, file);
|
||||
} else {
|
||||
// Skip mimetype — already added as the first entry
|
||||
String relativePath = baseDir.toPath().relativize(file.toPath()).toString().replace(File.separatorChar, '/');
|
||||
if ("mimetype".equals(relativePath)) {
|
||||
continue;
|
||||
}
|
||||
ZipParameters params = new ZipParameters();
|
||||
params.setFileNameInZip(baseDir.toPath().relativize(file.toPath()).toString().replace(File.separatorChar, '/'));
|
||||
params.setFileNameInZip(relativePath);
|
||||
zipFile.addFile(file, params);
|
||||
}
|
||||
}
|
||||
@@ -620,15 +639,43 @@ public class EpubMetadataWriter implements MetadataWriter {
|
||||
}
|
||||
}
|
||||
|
||||
private Element createCreatorElement(Document doc, String fullName, String fileAs, String role) {
|
||||
private Element createCreatorElement(Document doc, Element metadataElement, String fullName, String fileAs, String role) {
|
||||
Element creator = doc.createElementNS("http://purl.org/dc/elements/1.1/", "creator");
|
||||
creator.setPrefix("dc");
|
||||
creator.setTextContent(fullName);
|
||||
if (fileAs != null) {
|
||||
creator.setAttributeNS(OPF_NS, "opf:file-as", fileAs);
|
||||
}
|
||||
if (role != null) {
|
||||
creator.setAttributeNS(OPF_NS, "opf:role", role);
|
||||
|
||||
boolean isEpub3 = isEpub3(doc);
|
||||
|
||||
if (isEpub3) {
|
||||
// EPUB3: use <meta refines="#id"> elements instead of opf: attributes
|
||||
String creatorId = "creator-" + UUID.randomUUID().toString().substring(0, 8);
|
||||
creator.setAttribute("id", creatorId);
|
||||
|
||||
if (fileAs != null) {
|
||||
Element fileAsMeta = doc.createElementNS(OPF_NS, "meta");
|
||||
fileAsMeta.setPrefix("opf");
|
||||
fileAsMeta.setAttribute("refines", "#" + creatorId);
|
||||
fileAsMeta.setAttribute("property", "file-as");
|
||||
fileAsMeta.setTextContent(fileAs);
|
||||
metadataElement.appendChild(fileAsMeta);
|
||||
}
|
||||
if (role != null) {
|
||||
Element roleMeta = doc.createElementNS(OPF_NS, "meta");
|
||||
roleMeta.setPrefix("opf");
|
||||
roleMeta.setAttribute("refines", "#" + creatorId);
|
||||
roleMeta.setAttribute("property", "role");
|
||||
roleMeta.setAttribute("scheme", "marc:relators");
|
||||
roleMeta.setTextContent(role);
|
||||
metadataElement.appendChild(roleMeta);
|
||||
}
|
||||
} else {
|
||||
// EPUB2: use opf: attributes directly on dc:creator
|
||||
if (fileAs != null) {
|
||||
creator.setAttributeNS(OPF_NS, "opf:file-as", fileAs);
|
||||
}
|
||||
if (role != null) {
|
||||
creator.setAttributeNS(OPF_NS, "opf:role", role);
|
||||
}
|
||||
}
|
||||
return creator;
|
||||
}
|
||||
@@ -707,22 +754,32 @@ public class EpubMetadataWriter implements MetadataWriter {
|
||||
}
|
||||
}
|
||||
|
||||
private boolean isEpub3(Document doc) {
|
||||
String version = doc.getDocumentElement().getAttribute("version");
|
||||
return version != null && version.trim().startsWith("3");
|
||||
}
|
||||
|
||||
private void removeAllBookloreMetadata(Element metadataElement) {
|
||||
NodeList metas = metadataElement.getElementsByTagNameNS("*", "meta");
|
||||
for (int i = metas.getLength() - 1; i >= 0; i--) {
|
||||
Element meta = (Element) metas.item(i);
|
||||
String property = meta.getAttribute("property");
|
||||
if (property.startsWith("booklore:")) {
|
||||
String name = meta.getAttribute("name");
|
||||
if (property.startsWith("booklore:") || name.startsWith("booklore:")) {
|
||||
metadataElement.removeChild(meta);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void replaceBelongsToCollection(Element metadataElement, Document doc, String seriesName, Float seriesNumber, boolean[] hasChanges) {
|
||||
boolean epub3 = isEpub3(doc);
|
||||
|
||||
// Remove existing EPUB3 collection metas
|
||||
NodeList metas = metadataElement.getElementsByTagNameNS("*", "meta");
|
||||
for (int i = metas.getLength() - 1; i >= 0; i--) {
|
||||
Element meta = (Element) metas.item(i);
|
||||
String property = meta.getAttribute("property");
|
||||
String name = meta.getAttribute("name");
|
||||
if ("belongs-to-collection".equals(property) || "collection-type".equals(property) || "group-position".equals(property)) {
|
||||
String id = meta.getAttribute("id");
|
||||
metadataElement.removeChild(meta);
|
||||
@@ -730,36 +787,60 @@ public class EpubMetadataWriter implements MetadataWriter {
|
||||
removeMetaByRefines(metadataElement, "#" + id);
|
||||
}
|
||||
}
|
||||
// Also remove EPUB2-style series metas
|
||||
if ("calibre:series".equals(name) || "calibre:series_index".equals(name)) {
|
||||
metadataElement.removeChild(meta);
|
||||
}
|
||||
}
|
||||
|
||||
if (StringUtils.isNotBlank(seriesName)) {
|
||||
String collectionId = "collection-" + UUID.randomUUID().toString().substring(0, 8);
|
||||
|
||||
Element collectionMeta = doc.createElementNS(OPF_NS, "meta");
|
||||
collectionMeta.setPrefix("opf");
|
||||
collectionMeta.setAttribute("id", collectionId);
|
||||
collectionMeta.setAttribute("property", "belongs-to-collection");
|
||||
collectionMeta.setTextContent(seriesName);
|
||||
metadataElement.appendChild(collectionMeta);
|
||||
|
||||
Element typeMeta = doc.createElementNS(OPF_NS, "meta");
|
||||
typeMeta.setPrefix("opf");
|
||||
typeMeta.setAttribute("property", "collection-type");
|
||||
typeMeta.setAttribute("refines", "#" + collectionId);
|
||||
typeMeta.setTextContent("series");
|
||||
metadataElement.appendChild(typeMeta);
|
||||
|
||||
if (seriesNumber != null && seriesNumber > 0) {
|
||||
Element positionMeta = doc.createElementNS(OPF_NS, "meta");
|
||||
positionMeta.setPrefix("opf");
|
||||
positionMeta.setAttribute("property", "group-position");
|
||||
positionMeta.setAttribute("refines", "#" + collectionId);
|
||||
if (seriesNumber % 1.0f == 0) {
|
||||
positionMeta.setTextContent(String.format("%.0f", seriesNumber));
|
||||
} else {
|
||||
positionMeta.setTextContent(String.valueOf(seriesNumber));
|
||||
if (epub3) {
|
||||
// EPUB3: use belongs-to-collection with refines
|
||||
String collectionId = "collection-" + UUID.randomUUID().toString().substring(0, 8);
|
||||
|
||||
Element collectionMeta = doc.createElementNS(OPF_NS, "meta");
|
||||
collectionMeta.setPrefix("opf");
|
||||
collectionMeta.setAttribute("id", collectionId);
|
||||
collectionMeta.setAttribute("property", "belongs-to-collection");
|
||||
collectionMeta.setTextContent(seriesName);
|
||||
metadataElement.appendChild(collectionMeta);
|
||||
|
||||
Element typeMeta = doc.createElementNS(OPF_NS, "meta");
|
||||
typeMeta.setPrefix("opf");
|
||||
typeMeta.setAttribute("property", "collection-type");
|
||||
typeMeta.setAttribute("refines", "#" + collectionId);
|
||||
typeMeta.setTextContent("series");
|
||||
metadataElement.appendChild(typeMeta);
|
||||
|
||||
if (seriesNumber != null && seriesNumber > 0) {
|
||||
Element positionMeta = doc.createElementNS(OPF_NS, "meta");
|
||||
positionMeta.setPrefix("opf");
|
||||
positionMeta.setAttribute("property", "group-position");
|
||||
positionMeta.setAttribute("refines", "#" + collectionId);
|
||||
if (seriesNumber % 1.0f == 0) {
|
||||
positionMeta.setTextContent(String.format("%.0f", seriesNumber));
|
||||
} else {
|
||||
positionMeta.setTextContent(String.valueOf(seriesNumber));
|
||||
}
|
||||
metadataElement.appendChild(positionMeta);
|
||||
}
|
||||
} else {
|
||||
// EPUB2: use calibre:series convention (widely supported by e-readers)
|
||||
Element seriesMeta = doc.createElementNS(doc.getDocumentElement().getNamespaceURI(), "meta");
|
||||
seriesMeta.setAttribute("name", "calibre:series");
|
||||
seriesMeta.setAttribute("content", seriesName);
|
||||
metadataElement.appendChild(seriesMeta);
|
||||
|
||||
if (seriesNumber != null && seriesNumber > 0) {
|
||||
Element indexMeta = doc.createElementNS(doc.getDocumentElement().getNamespaceURI(), "meta");
|
||||
indexMeta.setAttribute("name", "calibre:series_index");
|
||||
if (seriesNumber % 1.0f == 0) {
|
||||
indexMeta.setAttribute("content", String.format("%.0f", seriesNumber));
|
||||
} else {
|
||||
indexMeta.setAttribute("content", String.valueOf(seriesNumber));
|
||||
}
|
||||
metadataElement.appendChild(indexMeta);
|
||||
}
|
||||
metadataElement.appendChild(positionMeta);
|
||||
}
|
||||
|
||||
hasChanges[0] = true;
|
||||
@@ -768,6 +849,9 @@ public class EpubMetadataWriter implements MetadataWriter {
|
||||
|
||||
private void addSubtitleToTitle(Element metadataElement, Document doc, String subtitle) {
|
||||
final String DC_NS = "http://purl.org/dc/elements/1.1/";
|
||||
boolean epub3 = isEpub3(doc);
|
||||
|
||||
// Remove existing subtitle elements (both EPUB2 and EPUB3 forms)
|
||||
NodeList metas = metadataElement.getElementsByTagNameNS("*", "meta");
|
||||
for (int i = metas.getLength() - 1; i >= 0; i--) {
|
||||
Element meta = (Element) metas.item(i);
|
||||
@@ -787,89 +871,102 @@ public class EpubMetadataWriter implements MetadataWriter {
|
||||
metadataElement.removeChild(meta);
|
||||
}
|
||||
}
|
||||
|
||||
String subtitleId = "subtitle-" + UUID.randomUUID().toString().substring(0, 8);
|
||||
Element subtitleElement = doc.createElementNS(DC_NS, "title");
|
||||
subtitleElement.setPrefix("dc");
|
||||
subtitleElement.setAttribute("id", subtitleId);
|
||||
subtitleElement.setTextContent(subtitle);
|
||||
metadataElement.appendChild(subtitleElement);
|
||||
|
||||
Element typeMeta = doc.createElementNS(OPF_NS, "meta");
|
||||
typeMeta.setPrefix("opf");
|
||||
typeMeta.setAttribute("refines", "#" + subtitleId);
|
||||
typeMeta.setAttribute("property", "title-type");
|
||||
typeMeta.setTextContent("subtitle");
|
||||
metadataElement.appendChild(typeMeta);
|
||||
|
||||
if (epub3) {
|
||||
// EPUB3: add subtitle as separate dc:title with title-type refinement
|
||||
String subtitleId = "subtitle-" + UUID.randomUUID().toString().substring(0, 8);
|
||||
Element subtitleElement = doc.createElementNS(DC_NS, "title");
|
||||
subtitleElement.setPrefix("dc");
|
||||
subtitleElement.setAttribute("id", subtitleId);
|
||||
subtitleElement.setTextContent(subtitle);
|
||||
metadataElement.appendChild(subtitleElement);
|
||||
|
||||
Element typeMeta = doc.createElementNS(OPF_NS, "meta");
|
||||
typeMeta.setPrefix("opf");
|
||||
typeMeta.setAttribute("refines", "#" + subtitleId);
|
||||
typeMeta.setAttribute("property", "title-type");
|
||||
typeMeta.setTextContent("subtitle");
|
||||
metadataElement.appendChild(typeMeta);
|
||||
}
|
||||
// EPUB2: subtitle is stored only via booklore:subtitle metadata (written in addBookloreMetadata).
|
||||
// No modification to dc:title is needed — this preserves round-trip fidelity.
|
||||
}
|
||||
|
||||
private void addBookloreMetadata(Element metadataElement, Document doc, BookMetadataEntity metadata) {
|
||||
Element packageElement = doc.getDocumentElement();
|
||||
String existingPrefix = packageElement.getAttribute("prefix");
|
||||
String bookloreNamespace = "booklore: http://booklore.org/metadata/1.0/";
|
||||
|
||||
if (!existingPrefix.contains("booklore:")) {
|
||||
if (existingPrefix.isEmpty()) {
|
||||
packageElement.setAttribute("prefix", bookloreNamespace);
|
||||
} else {
|
||||
packageElement.setAttribute("prefix", existingPrefix.trim() + " " + bookloreNamespace);
|
||||
boolean epub3 = isEpub3(doc);
|
||||
|
||||
if (epub3) {
|
||||
Element packageElement = doc.getDocumentElement();
|
||||
String existingPrefix = packageElement.getAttribute("prefix");
|
||||
String bookloreNamespace = "booklore: http://booklore.org/metadata/1.0/";
|
||||
|
||||
if (!existingPrefix.contains("booklore:")) {
|
||||
if (existingPrefix.isEmpty()) {
|
||||
packageElement.setAttribute("prefix", bookloreNamespace);
|
||||
} else {
|
||||
packageElement.setAttribute("prefix", existingPrefix.trim() + " " + bookloreNamespace);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
removeAllBookloreMetadata(metadataElement);
|
||||
|
||||
if (StringUtils.isNotBlank(metadata.getSubtitle())) {
|
||||
metadataElement.appendChild(createBookloreMetaElement(doc, "subtitle", metadata.getSubtitle(), epub3));
|
||||
}
|
||||
|
||||
if (metadata.getPageCount() != null && metadata.getPageCount() > 0) {
|
||||
metadataElement.appendChild(createBookloreMetaElement(doc, "page_count", String.valueOf(metadata.getPageCount())));
|
||||
metadataElement.appendChild(createBookloreMetaElement(doc, "page_count", String.valueOf(metadata.getPageCount()), epub3));
|
||||
}
|
||||
|
||||
if (metadata.getSeriesTotal() != null && metadata.getSeriesTotal() > 0) {
|
||||
metadataElement.appendChild(createBookloreMetaElement(doc, "series_total", String.valueOf(metadata.getSeriesTotal())));
|
||||
metadataElement.appendChild(createBookloreMetaElement(doc, "series_total", String.valueOf(metadata.getSeriesTotal()), epub3));
|
||||
}
|
||||
|
||||
if (metadata.getAmazonRating() != null && metadata.getAmazonRating() > 0) {
|
||||
metadataElement.appendChild(createBookloreMetaElement(doc, "amazon_rating", String.valueOf(metadata.getAmazonRating())));
|
||||
metadataElement.appendChild(createBookloreMetaElement(doc, "amazon_rating", String.valueOf(metadata.getAmazonRating()), epub3));
|
||||
}
|
||||
|
||||
if (metadata.getAmazonReviewCount() != null && metadata.getAmazonReviewCount() > 0) {
|
||||
metadataElement.appendChild(createBookloreMetaElement(doc, "amazon_review_count", String.valueOf(metadata.getAmazonReviewCount())));
|
||||
metadataElement.appendChild(createBookloreMetaElement(doc, "amazon_review_count", String.valueOf(metadata.getAmazonReviewCount()), epub3));
|
||||
}
|
||||
|
||||
if (metadata.getGoodreadsRating() != null && metadata.getGoodreadsRating() > 0) {
|
||||
metadataElement.appendChild(createBookloreMetaElement(doc, "goodreads_rating", String.valueOf(metadata.getGoodreadsRating())));
|
||||
metadataElement.appendChild(createBookloreMetaElement(doc, "goodreads_rating", String.valueOf(metadata.getGoodreadsRating()), epub3));
|
||||
}
|
||||
|
||||
if (metadata.getGoodreadsReviewCount() != null && metadata.getGoodreadsReviewCount() > 0) {
|
||||
metadataElement.appendChild(createBookloreMetaElement(doc, "goodreads_review_count", String.valueOf(metadata.getGoodreadsReviewCount())));
|
||||
metadataElement.appendChild(createBookloreMetaElement(doc, "goodreads_review_count", String.valueOf(metadata.getGoodreadsReviewCount()), epub3));
|
||||
}
|
||||
|
||||
if (metadata.getHardcoverRating() != null && metadata.getHardcoverRating() > 0) {
|
||||
metadataElement.appendChild(createBookloreMetaElement(doc, "hardcover_rating", String.valueOf(metadata.getHardcoverRating())));
|
||||
metadataElement.appendChild(createBookloreMetaElement(doc, "hardcover_rating", String.valueOf(metadata.getHardcoverRating()), epub3));
|
||||
}
|
||||
|
||||
if (metadata.getHardcoverReviewCount() != null && metadata.getHardcoverReviewCount() > 0) {
|
||||
metadataElement.appendChild(createBookloreMetaElement(doc, "hardcover_review_count", String.valueOf(metadata.getHardcoverReviewCount())));
|
||||
metadataElement.appendChild(createBookloreMetaElement(doc, "hardcover_review_count", String.valueOf(metadata.getHardcoverReviewCount()), epub3));
|
||||
}
|
||||
|
||||
if (metadata.getLubimyczytacRating() != null && metadata.getLubimyczytacRating() > 0) {
|
||||
metadataElement.appendChild(createBookloreMetaElement(doc, "lubimyczytac_rating", String.valueOf(metadata.getLubimyczytacRating())));
|
||||
metadataElement.appendChild(createBookloreMetaElement(doc, "lubimyczytac_rating", String.valueOf(metadata.getLubimyczytacRating()), epub3));
|
||||
}
|
||||
|
||||
if (metadata.getRanobedbRating() != null && metadata.getRanobedbRating() > 0) {
|
||||
metadataElement.appendChild(createBookloreMetaElement(doc, "ranobedb_rating", String.valueOf(metadata.getRanobedbRating())));
|
||||
metadataElement.appendChild(createBookloreMetaElement(doc, "ranobedb_rating", String.valueOf(metadata.getRanobedbRating()), epub3));
|
||||
}
|
||||
|
||||
if (metadata.getMoods() != null && !metadata.getMoods().isEmpty()) {
|
||||
String moodsJson = "[" + String.join(", ", metadata.getMoods().stream()
|
||||
.map(mood -> "\"" + mood.getName().replace("\"", "\\\"") + "\"")
|
||||
.toList()) + "]";
|
||||
metadataElement.appendChild(createBookloreMetaElement(doc, "moods", moodsJson));
|
||||
metadataElement.appendChild(createBookloreMetaElement(doc, "moods", moodsJson, epub3));
|
||||
}
|
||||
|
||||
if (metadata.getTags() != null && !metadata.getTags().isEmpty()) {
|
||||
String tagsJson = "[" + String.join(", ", metadata.getTags().stream()
|
||||
.map(tag -> "\"" + tag.getName().replace("\"", "\\\"") + "\"")
|
||||
.toList()) + "]";
|
||||
metadataElement.appendChild(createBookloreMetaElement(doc, "tags", tagsJson));
|
||||
metadataElement.appendChild(createBookloreMetaElement(doc, "tags", tagsJson, epub3));
|
||||
}
|
||||
|
||||
if (metadata.getAgeRating() != null) {
|
||||
@@ -881,12 +978,20 @@ public class EpubMetadataWriter implements MetadataWriter {
|
||||
}
|
||||
}
|
||||
|
||||
private Element createBookloreMetaElement(Document doc, String property, String value) {
|
||||
Element meta = doc.createElementNS(OPF_NS, "meta");
|
||||
meta.setPrefix("opf");
|
||||
meta.setAttribute("property", "booklore:" + property);
|
||||
meta.setTextContent(value);
|
||||
return meta;
|
||||
private Element createBookloreMetaElement(Document doc, String property, String value, boolean epub3) {
|
||||
if (epub3) {
|
||||
Element meta = doc.createElementNS(OPF_NS, "meta");
|
||||
meta.setPrefix("opf");
|
||||
meta.setAttribute("property", "booklore:" + property);
|
||||
meta.setTextContent(value);
|
||||
return meta;
|
||||
} else {
|
||||
// EPUB2: use name/content attribute form
|
||||
Element meta = doc.createElementNS(doc.getDocumentElement().getNamespaceURI(), "meta");
|
||||
meta.setAttribute("name", "booklore:" + property);
|
||||
meta.setAttribute("content", value);
|
||||
return meta;
|
||||
}
|
||||
}
|
||||
|
||||
private void cleanupCalibreArtifacts(Element metadataElement, Document doc) {
|
||||
@@ -936,7 +1041,8 @@ public class EpubMetadataWriter implements MetadataWriter {
|
||||
String property = meta.getAttribute("property");
|
||||
String name = meta.getAttribute("name");
|
||||
|
||||
if (property.startsWith("calibre:") || name.startsWith("calibre:")) {
|
||||
boolean isCalibreSeries = !isEpub3(doc) && ("calibre:series".equals(name) || "calibre:series_index".equals(name));
|
||||
if (!isCalibreSeries && (property.startsWith("calibre:") || name.startsWith("calibre:"))) {
|
||||
metadataElement.removeChild(meta);
|
||||
}
|
||||
}
|
||||
@@ -980,11 +1086,13 @@ public class EpubMetadataWriter implements MetadataWriter {
|
||||
}
|
||||
} else if ("meta".equals(localName)) {
|
||||
String property = elem.getAttribute("property");
|
||||
if (property.startsWith("booklore:")) {
|
||||
String name = elem.getAttribute("name");
|
||||
if (property.startsWith("booklore:") || name.startsWith("booklore:")) {
|
||||
bookloreMetas.add(elem);
|
||||
} else if (property.equals("dcterms:modified") || property.equals("calibre:timestamp")) {
|
||||
modifiedMetas.add(elem);
|
||||
} else if (property.equals("belongs-to-collection") || property.equals("collection-type") || property.equals("group-position")) {
|
||||
} else if (property.equals("belongs-to-collection") || property.equals("collection-type") || property.equals("group-position")
|
||||
|| "calibre:series".equals(name) || "calibre:series_index".equals(name)) {
|
||||
seriesMetas.add(elem);
|
||||
} else {
|
||||
otherMetas.add(elem);
|
||||
|
||||
@@ -16,16 +16,23 @@ import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
import org.springframework.mock.web.MockMultipartFile;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
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.*;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.Collections;
|
||||
import java.util.Enumeration;
|
||||
import java.util.zip.ZipEntry;
|
||||
import java.util.zip.ZipFile;
|
||||
import java.util.zip.ZipOutputStream;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.Mockito.mock;
|
||||
@@ -184,6 +191,379 @@ class EpubMetadataWriterTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("EPUB3 Creator Metadata Tests")
|
||||
class Epub3CreatorTests {
|
||||
|
||||
@Test
|
||||
@DisplayName("Should use meta refines for file-as in EPUB3")
|
||||
void epub3_shouldUseMetaRefines_forFileAs() throws Exception {
|
||||
String opfContent = """
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<package xmlns="http://www.idpf.org/2007/opf" version="3.0">
|
||||
<metadata xmlns:dc="http://purl.org/dc/elements/1.1/">
|
||||
<dc:title>Test Book</dc:title>
|
||||
</metadata>
|
||||
</package>""";
|
||||
|
||||
File epubFile = createEpubWithOpf(opfContent, "test-epub3-creator-" + System.nanoTime() + ".epub");
|
||||
writer.saveMetadataToFile(epubFile, metadata, null, new MetadataClearFlags());
|
||||
|
||||
Document doc = parseOpf(epubFile);
|
||||
NodeList creators = doc.getElementsByTagNameNS("http://purl.org/dc/elements/1.1/", "creator");
|
||||
assertThat(creators.getLength()).isGreaterThan(0);
|
||||
|
||||
Element creator = (Element) creators.item(0);
|
||||
assertThat(creator.getTextContent()).isEqualTo("Test Author");
|
||||
assertThat(creator.getAttribute("id")).isNotEmpty();
|
||||
|
||||
// Should NOT have opf:file-as or opf:role attributes
|
||||
String fileAsAttr = creator.getAttributeNS("http://www.idpf.org/2007/opf", "file-as");
|
||||
String roleAttr = creator.getAttributeNS("http://www.idpf.org/2007/opf", "role");
|
||||
assertThat(fileAsAttr).isEmpty();
|
||||
assertThat(roleAttr).isEmpty();
|
||||
|
||||
// Should have meta refines elements instead
|
||||
String creatorId = creator.getAttribute("id");
|
||||
String opfContent2 = readOpfContent(epubFile);
|
||||
assertThat(opfContent2).contains("refines=\"#" + creatorId + "\"");
|
||||
assertThat(opfContent2).contains("property=\"file-as\"");
|
||||
assertThat(opfContent2).contains("property=\"role\"");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should include role with marc:relators scheme in EPUB3")
|
||||
void epub3_shouldIncludeRoleScheme() throws Exception {
|
||||
String opfContent = """
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<package xmlns="http://www.idpf.org/2007/opf" version="3.0">
|
||||
<metadata xmlns:dc="http://purl.org/dc/elements/1.1/">
|
||||
<dc:title>Test Book</dc:title>
|
||||
</metadata>
|
||||
</package>""";
|
||||
|
||||
File epubFile = createEpubWithOpf(opfContent, "test-epub3-role-" + System.nanoTime() + ".epub");
|
||||
writer.saveMetadataToFile(epubFile, metadata, null, new MetadataClearFlags());
|
||||
|
||||
String content = readOpfContent(epubFile);
|
||||
assertThat(content).contains("scheme=\"marc:relators\"");
|
||||
assertThat(content).contains(">aut<");
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("EPUB2 Creator Metadata Tests")
|
||||
class Epub2CreatorTests {
|
||||
|
||||
@Test
|
||||
@DisplayName("Should use opf:file-as attribute on dc:creator in EPUB2")
|
||||
void epub2_shouldUseOpfAttributes() throws Exception {
|
||||
String opfContent = """
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<package xmlns="http://www.idpf.org/2007/opf" version="2.0">
|
||||
<metadata xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:opf="http://www.idpf.org/2007/opf">
|
||||
<dc:title>Test Book</dc:title>
|
||||
</metadata>
|
||||
</package>""";
|
||||
|
||||
File epubFile = createEpubWithOpf(opfContent, "test-epub2-creator-" + System.nanoTime() + ".epub");
|
||||
writer.saveMetadataToFile(epubFile, metadata, null, new MetadataClearFlags());
|
||||
|
||||
String content = readOpfContent(epubFile);
|
||||
assertThat(content).contains("opf:file-as=");
|
||||
assertThat(content).contains("opf:role=");
|
||||
// Should NOT have meta refines for creators
|
||||
assertThat(content).doesNotContain("property=\"file-as\"");
|
||||
assertThat(content).doesNotContain("property=\"role\"");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should preserve EPUB2 structure without EPUB3 constructs")
|
||||
void epub2_shouldNotContainEpub3Constructs() throws Exception {
|
||||
String opfContent = """
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<package xmlns="http://www.idpf.org/2007/opf" version="2.0">
|
||||
<metadata xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:opf="http://www.idpf.org/2007/opf">
|
||||
<dc:title>EPUB2 Book</dc:title>
|
||||
</metadata>
|
||||
</package>""";
|
||||
|
||||
metadata.setSubtitle("A Subtitle");
|
||||
metadata.setSeriesName("Test Series");
|
||||
metadata.setSeriesNumber(3.0f);
|
||||
metadata.setPageCount(200);
|
||||
|
||||
File epubFile = createEpubWithOpf(opfContent, "test-epub2-no-epub3-" + System.nanoTime() + ".epub");
|
||||
writer.saveMetadataToFile(epubFile, metadata, null, new MetadataClearFlags());
|
||||
|
||||
String content = readOpfContent(epubFile);
|
||||
// EPUB2 should not have prefix attribute on package
|
||||
assertThat(content).doesNotContain("prefix=");
|
||||
// EPUB2 should not have property= metas for series
|
||||
assertThat(content).doesNotContain("property=\"belongs-to-collection\"");
|
||||
assertThat(content).doesNotContain("property=\"collection-type\"");
|
||||
assertThat(content).doesNotContain("property=\"group-position\"");
|
||||
// EPUB2 should not have property= metas for subtitles
|
||||
assertThat(content).doesNotContain("property=\"title-type\"");
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("Mimetype ZIP Entry Tests")
|
||||
class MimetypeZipTests {
|
||||
|
||||
@Test
|
||||
@DisplayName("Should store mimetype as first uncompressed entry in ZIP")
|
||||
void saveMetadata_shouldStoreMimetypeFirst() throws Exception {
|
||||
String opfContent = """
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<package xmlns="http://www.idpf.org/2007/opf" version="3.0">
|
||||
<metadata xmlns:dc="http://purl.org/dc/elements/1.1/">
|
||||
<dc:title>Original Title</dc:title>
|
||||
</metadata>
|
||||
</package>""";
|
||||
|
||||
File epubFile = createEpubWithOpf(opfContent, "test-mimetype-" + System.nanoTime() + ".epub");
|
||||
writer.saveMetadataToFile(epubFile, metadata, null, new MetadataClearFlags());
|
||||
|
||||
try (ZipFile zf = new ZipFile(epubFile)) {
|
||||
Enumeration<? extends ZipEntry> entries = zf.entries();
|
||||
assertThat(entries.hasMoreElements()).isTrue();
|
||||
|
||||
ZipEntry firstEntry = entries.nextElement();
|
||||
assertThat(firstEntry.getName()).isEqualTo("mimetype");
|
||||
assertThat(firstEntry.getMethod()).isEqualTo(ZipEntry.STORED);
|
||||
|
||||
// Verify mimetype content
|
||||
try (InputStream is = zf.getInputStream(firstEntry)) {
|
||||
String content = new String(is.readAllBytes(), StandardCharsets.UTF_8);
|
||||
assertThat(content).isEqualTo("application/epub+zip");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should not duplicate mimetype entry in ZIP")
|
||||
void saveMetadata_shouldNotDuplicateMimetype() throws Exception {
|
||||
String opfContent = """
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<package xmlns="http://www.idpf.org/2007/opf" version="3.0">
|
||||
<metadata xmlns:dc="http://purl.org/dc/elements/1.1/">
|
||||
<dc:title>Original Title</dc:title>
|
||||
</metadata>
|
||||
</package>""";
|
||||
|
||||
File epubFile = createEpubWithOpf(opfContent, "test-no-dup-mimetype-" + System.nanoTime() + ".epub");
|
||||
writer.saveMetadataToFile(epubFile, metadata, null, new MetadataClearFlags());
|
||||
|
||||
try (ZipFile zf = new ZipFile(epubFile)) {
|
||||
int mimetypeCount = 0;
|
||||
Enumeration<? extends ZipEntry> entries = zf.entries();
|
||||
while (entries.hasMoreElements()) {
|
||||
if ("mimetype".equals(entries.nextElement().getName())) {
|
||||
mimetypeCount++;
|
||||
}
|
||||
}
|
||||
assertThat(mimetypeCount).isEqualTo(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("EPUB3 Series Metadata Tests")
|
||||
class Epub3SeriesTests {
|
||||
|
||||
@Test
|
||||
@DisplayName("Should use belongs-to-collection for series in EPUB3")
|
||||
void epub3_shouldUseBelongsToCollection() throws Exception {
|
||||
String opfContent = """
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<package xmlns="http://www.idpf.org/2007/opf" version="3.0">
|
||||
<metadata xmlns:dc="http://purl.org/dc/elements/1.1/">
|
||||
<dc:title>Series Book</dc:title>
|
||||
</metadata>
|
||||
</package>""";
|
||||
|
||||
metadata.setSeriesName("The Dark Tower");
|
||||
metadata.setSeriesNumber(3.0f);
|
||||
|
||||
File epubFile = createEpubWithOpf(opfContent, "test-epub3-series-" + System.nanoTime() + ".epub");
|
||||
writer.saveMetadataToFile(epubFile, metadata, null, new MetadataClearFlags());
|
||||
|
||||
String content = readOpfContent(epubFile);
|
||||
assertThat(content).contains("property=\"belongs-to-collection\"");
|
||||
assertThat(content).contains("The Dark Tower");
|
||||
assertThat(content).contains("property=\"collection-type\"");
|
||||
assertThat(content).contains("series");
|
||||
assertThat(content).contains("property=\"group-position\"");
|
||||
assertThat(content).contains(">3<");
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("EPUB2 Series Metadata Tests")
|
||||
class Epub2SeriesTests {
|
||||
|
||||
@Test
|
||||
@DisplayName("Should use calibre:series convention for series in EPUB2")
|
||||
void epub2_shouldUseCalibreSeries() throws Exception {
|
||||
String opfContent = """
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<package xmlns="http://www.idpf.org/2007/opf" version="2.0">
|
||||
<metadata xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:opf="http://www.idpf.org/2007/opf">
|
||||
<dc:title>Series Book</dc:title>
|
||||
</metadata>
|
||||
</package>""";
|
||||
|
||||
metadata.setSeriesName("Wheel of Time");
|
||||
metadata.setSeriesNumber(5.0f);
|
||||
|
||||
File epubFile = createEpubWithOpf(opfContent, "test-epub2-series-" + System.nanoTime() + ".epub");
|
||||
writer.saveMetadataToFile(epubFile, metadata, null, new MetadataClearFlags());
|
||||
|
||||
String content = readOpfContent(epubFile);
|
||||
assertThat(content).contains("name=\"calibre:series\"");
|
||||
assertThat(content).contains("content=\"Wheel of Time\"");
|
||||
assertThat(content).contains("name=\"calibre:series_index\"");
|
||||
assertThat(content).contains("content=\"5\"");
|
||||
// Should NOT contain EPUB3 series constructs
|
||||
assertThat(content).doesNotContain("belongs-to-collection");
|
||||
assertThat(content).doesNotContain("collection-type");
|
||||
assertThat(content).doesNotContain("group-position");
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("EPUB3 Subtitle Tests")
|
||||
class Epub3SubtitleTests {
|
||||
|
||||
@Test
|
||||
@DisplayName("Should add subtitle as separate dc:title with title-type refinement in EPUB3")
|
||||
void epub3_shouldAddSubtitleWithRefinement() throws Exception {
|
||||
String opfContent = """
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<package xmlns="http://www.idpf.org/2007/opf" version="3.0">
|
||||
<metadata xmlns:dc="http://purl.org/dc/elements/1.1/">
|
||||
<dc:title>Main Title</dc:title>
|
||||
</metadata>
|
||||
</package>""";
|
||||
|
||||
metadata.setTitle("Main Title");
|
||||
metadata.setSubtitle("A Great Subtitle");
|
||||
|
||||
File epubFile = createEpubWithOpf(opfContent, "test-epub3-subtitle-" + System.nanoTime() + ".epub");
|
||||
writer.saveMetadataToFile(epubFile, metadata, null, new MetadataClearFlags());
|
||||
|
||||
String content = readOpfContent(epubFile);
|
||||
assertThat(content).contains("A Great Subtitle");
|
||||
assertThat(content).contains("property=\"title-type\"");
|
||||
assertThat(content).contains(">subtitle<");
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("EPUB2 Subtitle Tests")
|
||||
class Epub2SubtitleTests {
|
||||
|
||||
@Test
|
||||
@DisplayName("Should store subtitle via booklore:subtitle metadata in EPUB2 without modifying dc:title")
|
||||
void epub2_shouldStoreSubtitleInBookloreMeta() throws Exception {
|
||||
String opfContent = """
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<package xmlns="http://www.idpf.org/2007/opf" version="2.0">
|
||||
<metadata xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:opf="http://www.idpf.org/2007/opf">
|
||||
<dc:title>Main Title</dc:title>
|
||||
</metadata>
|
||||
</package>""";
|
||||
|
||||
metadata.setTitle("Main Title");
|
||||
metadata.setSubtitle("A Great Subtitle");
|
||||
|
||||
File epubFile = createEpubWithOpf(opfContent, "test-epub2-subtitle-" + System.nanoTime() + ".epub");
|
||||
writer.saveMetadataToFile(epubFile, metadata, null, new MetadataClearFlags());
|
||||
|
||||
String content = readOpfContent(epubFile);
|
||||
// Title should remain unchanged (not appended with subtitle)
|
||||
assertThat(content).contains(">Main Title<");
|
||||
assertThat(content).doesNotContain("Main Title: A Great Subtitle");
|
||||
// Subtitle should be stored via booklore:subtitle metadata
|
||||
assertThat(content).contains("name=\"booklore:subtitle\"");
|
||||
assertThat(content).contains("content=\"A Great Subtitle\"");
|
||||
// Should NOT have EPUB3 title-type refinement
|
||||
assertThat(content).doesNotContain("property=\"title-type\"");
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("EPUB3 Booklore Metadata Tests")
|
||||
class Epub3BookloreMetadataTests {
|
||||
|
||||
@Test
|
||||
@DisplayName("Should use property attribute for booklore metadata in EPUB3")
|
||||
void epub3_shouldUsePropertyAttribute() throws Exception {
|
||||
String opfContent = """
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<package xmlns="http://www.idpf.org/2007/opf" version="3.0">
|
||||
<metadata xmlns:dc="http://purl.org/dc/elements/1.1/">
|
||||
<dc:title>Test Book</dc:title>
|
||||
</metadata>
|
||||
</package>""";
|
||||
|
||||
metadata.setPageCount(350);
|
||||
|
||||
File epubFile = createEpubWithOpf(opfContent, "test-epub3-booklore-" + System.nanoTime() + ".epub");
|
||||
writer.saveMetadataToFile(epubFile, metadata, null, new MetadataClearFlags());
|
||||
|
||||
String content = readOpfContent(epubFile);
|
||||
assertThat(content).contains("property=\"booklore:page_count\"");
|
||||
assertThat(content).contains(">350<");
|
||||
assertThat(content).contains("prefix=");
|
||||
assertThat(content).contains("booklore:");
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("EPUB2 Booklore Metadata Tests")
|
||||
class Epub2BookloreMetadataTests {
|
||||
|
||||
@Test
|
||||
@DisplayName("Should use name/content attributes for booklore metadata in EPUB2")
|
||||
void epub2_shouldUseNameContentAttributes() throws Exception {
|
||||
String opfContent = """
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<package xmlns="http://www.idpf.org/2007/opf" version="2.0">
|
||||
<metadata xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:opf="http://www.idpf.org/2007/opf">
|
||||
<dc:title>Test Book</dc:title>
|
||||
</metadata>
|
||||
</package>""";
|
||||
|
||||
metadata.setPageCount(350);
|
||||
|
||||
File epubFile = createEpubWithOpf(opfContent, "test-epub2-booklore-" + System.nanoTime() + ".epub");
|
||||
writer.saveMetadataToFile(epubFile, metadata, null, new MetadataClearFlags());
|
||||
|
||||
String content = readOpfContent(epubFile);
|
||||
assertThat(content).contains("name=\"booklore:page_count\"");
|
||||
assertThat(content).contains("content=\"350\"");
|
||||
// Should NOT have EPUB3 prefix attribute
|
||||
assertThat(content).doesNotContain("prefix=");
|
||||
// Should NOT use property= form
|
||||
assertThat(content).doesNotContain("property=\"booklore:");
|
||||
}
|
||||
}
|
||||
|
||||
private Document parseOpf(File epubFile) throws Exception {
|
||||
try (ZipFile zf = new ZipFile(epubFile)) {
|
||||
ZipEntry ze = zf.getEntry("OEBPS/content.opf");
|
||||
try (InputStream is = zf.getInputStream(ze)) {
|
||||
DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();
|
||||
factory.setNamespaceAware(true);
|
||||
DocumentBuilder builder = factory.newDocumentBuilder();
|
||||
return builder.parse(is);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private String readOpfContent(File epubFile) throws IOException {
|
||||
try (ZipFile zf = new ZipFile(epubFile)) {
|
||||
ZipEntry ze = zf.getEntry("OEBPS/content.opf");
|
||||
|
||||
Reference in New Issue
Block a user