mirror of
https://github.com/adityachandelgit/BookLore.git
synced 2026-01-08 02:09:49 -06:00
Feature: Add EPUB conversion support for CBX files to Kobo Sync (#1538)
* Feature/kobo sync cbx support (#3) * Feature: Add CBX syncing support to Kobo sync (#2) * feat: add cbx sync support * fix: phrasing of setting description to align with CBX terminology * fix: resolve issue with CBX setting persistence * fix: updated dev docker compose to align with Dockerfile * feat: add cbx files as allowed entitlements * fix: relax tomcat query characters for kobo sync * fix: remove hardcoded epub restrictions * fix: reduce image size and resolve relative content paths * fix: resolve issues with the image file encoding * feat: add rar and cb7 support * feat: bring generated epub more in line with epub standard * fix: resolve issues with extra metadata fields * fix: make css background colour white to improve e-ink performance * cleanup: code comments * feat: add cbx unit tests * fix: update zipfile to use builder pattern as current method is deprecated * fix: remove comments * fix: replace regex pattern to a simpler check * fix: remove unused imports * fix: update epub template to use standard language string * fix: missing line end on css * fix: remove duplicate query characters * fix: grammer issue * fix: resolve typo in the entitlement service test * fix: redundant imports and update long types * chore: update test descriptions for cbx conversion * fix: add check for larger images in archive files. unlikely to occur. * fix: merge temp dir logic for the book download * fix: update kobo entitlement to always report the format as epub3 * fix: implement suggested changes around javadocs and some edge cases to handle gracefully * chore: cleanup variable names to be clearer in download service * feat: change function to extract to disk instead of storing images in memory * fix: add temp file clean up into the conversion service * fix: add visual divider to seperate CBX and EPUB settings * fix: update lambdas to use method references * fix: reword setting description for cbx to epub sync --------- Co-authored-by: Aditya Chandel <8075870+adityachandelgit@users.noreply.github.com>
This commit is contained in:
@@ -76,6 +76,9 @@ dependencies {
|
||||
implementation 'org.apache.commons:commons-compress:1.28.0'
|
||||
implementation 'org.apache.commons:commons-text:1.14.0'
|
||||
|
||||
// --- Template Engine ---
|
||||
implementation 'org.freemarker:freemarker:2.3.33'
|
||||
|
||||
// --- Test Dependencies ---
|
||||
testImplementation 'org.springframework.boot:spring-boot-starter-test'
|
||||
testImplementation 'org.assertj:assertj-core:3.27.3'
|
||||
|
||||
@@ -12,5 +12,7 @@ import lombok.NoArgsConstructor;
|
||||
public class KoboSettings {
|
||||
private boolean convertToKepub;
|
||||
private int conversionLimitInMb;
|
||||
private boolean convertCbxToEpub;
|
||||
private int conversionLimitInMbForCbx;
|
||||
private boolean forceEnableHyphenation;
|
||||
}
|
||||
|
||||
@@ -253,6 +253,8 @@ public class SettingPersistenceHelper {
|
||||
return KoboSettings.builder()
|
||||
.convertToKepub(false)
|
||||
.conversionLimitInMb(100)
|
||||
.convertCbxToEpub(false)
|
||||
.conversionLimitInMbForCbx(100)
|
||||
.forceEnableHyphenation(false)
|
||||
.build();
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import com.adityachandel.booklore.model.enums.BookFileType;
|
||||
import com.adityachandel.booklore.repository.BookRepository;
|
||||
import com.adityachandel.booklore.service.appsettings.AppSettingService;
|
||||
import com.adityachandel.booklore.service.kobo.KepubConversionService;
|
||||
import com.adityachandel.booklore.service.kobo.CbxConversionService;
|
||||
import com.adityachandel.booklore.util.FileUtils;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import lombok.AllArgsConstructor;
|
||||
@@ -40,6 +41,7 @@ public class BookDownloadService {
|
||||
|
||||
private final BookRepository bookRepository;
|
||||
private final KepubConversionService kepubConversionService;
|
||||
private final CbxConversionService cbxConversionService;
|
||||
private final AppSettingService appSettingService;
|
||||
|
||||
public ResponseEntity<Resource> downloadBook(Long bookId) {
|
||||
@@ -78,9 +80,12 @@ public class BookDownloadService {
|
||||
|
||||
public void downloadKoboBook(Long bookId, HttpServletResponse response) {
|
||||
BookEntity bookEntity = bookRepository.findById(bookId).orElseThrow(() -> ApiError.BOOK_NOT_FOUND.createException(bookId));
|
||||
|
||||
boolean isEpub = bookEntity.getBookType() == BookFileType.EPUB;
|
||||
boolean isCbx = bookEntity.getBookType() == BookFileType.CBX;
|
||||
|
||||
if (bookEntity.getBookType() != BookFileType.EPUB) {
|
||||
throw ApiError.GENERIC_BAD_REQUEST.createException("The requested book is not an EPUB file.");
|
||||
if (!isEpub && !isCbx) {
|
||||
throw ApiError.GENERIC_BAD_REQUEST.createException("The requested book is not an EPUB or CBX file.");
|
||||
}
|
||||
|
||||
KoboSettings koboSettings = appSettingService.getAppSettings().getKoboSettings();
|
||||
@@ -88,16 +93,23 @@ public class BookDownloadService {
|
||||
throw ApiError.GENERIC_BAD_REQUEST.createException("Kobo settings not found.");
|
||||
}
|
||||
|
||||
|
||||
boolean asKepub = koboSettings.isConvertToKepub() && bookEntity.getFileSizeKb() <= (long) koboSettings.getConversionLimitInMb() * 1024;
|
||||
boolean convertEpubToKepub = isEpub && koboSettings.isConvertToKepub() && bookEntity.getFileSizeKb() <= (long) koboSettings.getConversionLimitInMb() * 1024;
|
||||
boolean convertCbxToEpub = isCbx && koboSettings.isConvertCbxToEpub() && bookEntity.getFileSizeKb() <= (long) koboSettings.getConversionLimitInMbForCbx() * 1024;
|
||||
|
||||
Path tempDir = null;
|
||||
try {
|
||||
File inputFile = new File(FileUtils.getBookFullPath(bookEntity));
|
||||
File fileToSend = inputFile;
|
||||
|
||||
if (asKepub) {
|
||||
tempDir = Files.createTempDirectory("kepub-output");
|
||||
if (convertCbxToEpub || convertEpubToKepub) {
|
||||
tempDir = Files.createTempDirectory("kobo-conversion");
|
||||
}
|
||||
|
||||
if (convertCbxToEpub) {
|
||||
fileToSend = cbxConversionService.convertCbxToEpub(inputFile, tempDir.toFile(), bookEntity);
|
||||
}
|
||||
|
||||
if (convertEpubToKepub) {
|
||||
fileToSend = kepubConversionService.convertEpubToKepub(inputFile, tempDir.toFile(),
|
||||
koboSettings.isForceEnableHyphenation());
|
||||
}
|
||||
|
||||
@@ -0,0 +1,631 @@
|
||||
package com.adityachandel.booklore.service.kobo;
|
||||
|
||||
import com.adityachandel.booklore.model.entity.AuthorEntity;
|
||||
import com.adityachandel.booklore.model.entity.BookEntity;
|
||||
import com.adityachandel.booklore.model.entity.CategoryEntity;
|
||||
import com.adityachandel.booklore.model.entity.TagEntity;
|
||||
import freemarker.cache.ClassTemplateLoader;
|
||||
import freemarker.template.Configuration;
|
||||
import freemarker.template.Template;
|
||||
import freemarker.template.TemplateException;
|
||||
import freemarker.template.TemplateExceptionHandler;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.commons.compress.archivers.zip.ZipArchiveEntry;
|
||||
import org.apache.commons.compress.archivers.zip.ZipArchiveOutputStream;
|
||||
import org.apache.commons.compress.archivers.zip.ZipFile;
|
||||
import org.apache.commons.compress.archivers.sevenz.SevenZArchiveEntry;
|
||||
import org.apache.commons.compress.archivers.sevenz.SevenZFile;
|
||||
import com.github.junrar.Archive;
|
||||
import com.github.junrar.rarfile.FileHeader;
|
||||
import com.github.junrar.exception.RarException;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.util.FileSystemUtils;
|
||||
|
||||
import javax.imageio.ImageIO;
|
||||
import javax.imageio.ImageWriteParam;
|
||||
import javax.imageio.ImageWriter;
|
||||
import javax.imageio.IIOImage;
|
||||
import javax.imageio.stream.ImageOutputStream;
|
||||
import java.awt.image.BufferedImage;
|
||||
import java.io.*;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import java.time.Instant;
|
||||
import java.util.*;
|
||||
|
||||
/**
|
||||
* Service for converting comic book archive files (CBX) to EPUB format.
|
||||
* <p>
|
||||
* This service supports the following comic book archive formats:
|
||||
* <ul>
|
||||
* <li><b>CBZ</b> - Comic book ZIP archive</li>
|
||||
* <li><b>CBR</b> - Comic book RAR archive</li>
|
||||
* <li><b>CB7</b> - Comic book 7z archive</li>
|
||||
* </ul>
|
||||
* </p>
|
||||
* <p>
|
||||
* Supported image formats within archives: JPG, JPEG, PNG, WEBP, GIF, BMP
|
||||
* </p>
|
||||
*
|
||||
* <h3>Size Limits</h3>
|
||||
* <ul>
|
||||
* <li>Maximum individual image size: 50 MB</li>
|
||||
* </ul>
|
||||
*
|
||||
* @see KepubConversionService
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
public class CbxConversionService {
|
||||
|
||||
private static final String IMAGE_ROOT_PATH = "OEBPS/Images/";
|
||||
private static final String HTML_ROOT_PATH = "OEBPS/Text/";
|
||||
private static final String CONTENT_OPF_PATH = "OEBPS/content.opf";
|
||||
private static final String NAV_XHTML_PATH = "OEBPS/nav.xhtml";
|
||||
private static final String TOC_NCX_PATH = "OEBPS/toc.ncx";
|
||||
private static final String STYLESHEET_CSS_PATH = "OEBPS/Styles/stylesheet.css";
|
||||
private static final String COVER_IMAGE_PATH = "OEBPS/Images/cover.jpg";
|
||||
private static final String MIMETYPE_CONTENT = "application/epub+zip";
|
||||
private static final long MAX_IMAGE_SIZE_BYTES = 50L * 1024 * 1024;
|
||||
private static final String EXTRACTED_IMAGES_SUBDIR = "cbx_extracted_images";
|
||||
|
||||
private final Configuration freemarkerConfig;
|
||||
|
||||
public CbxConversionService() {
|
||||
this.freemarkerConfig = initializeFreemarkerConfiguration();
|
||||
}
|
||||
|
||||
public record EpubContentFileGroup(String contentKey, String imagePath, String htmlPath) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a comic book archive (CBZ, CBR, or CB7) to EPUB format.
|
||||
* <p>
|
||||
* The conversion process:
|
||||
* <ol>
|
||||
* <li>Extracts all images from the archive to a temporary directory</li>
|
||||
* <li>Creates an EPUB structure with one XHTML page per image</li>
|
||||
* <li>Includes proper EPUB metadata from the book entity</li>
|
||||
* <li>JPEG images are passed through directly; other formats are converted to JPEG (85% quality)</li>
|
||||
* </ol>
|
||||
* </p>
|
||||
*
|
||||
* @param cbxFile the comic book archive file (must be CBZ, CBR, or CB7)
|
||||
* @param tempDir the temporary directory where the output EPUB will be created
|
||||
* @param bookEntity the book metadata to include in the EPUB
|
||||
* @return the converted EPUB file
|
||||
* @throws IOException if file I/O operations fail
|
||||
* @throws TemplateException if EPUB template processing fails
|
||||
* @throws RarException if RAR extraction fails (for CBR files)
|
||||
* @throws IllegalArgumentException if the file format is not supported
|
||||
* @throws IllegalStateException if no valid images are found in the archive
|
||||
*/
|
||||
public File convertCbxToEpub(File cbxFile, File tempDir, BookEntity bookEntity)
|
||||
throws IOException, TemplateException, RarException {
|
||||
validateInputs(cbxFile, tempDir);
|
||||
|
||||
log.info("Starting CBX to EPUB conversion for: {}", cbxFile.getName());
|
||||
|
||||
File outputFile = executeCbxConversion(cbxFile, tempDir, bookEntity);
|
||||
|
||||
log.info("Successfully converted {} to {} (size: {} bytes)",
|
||||
cbxFile.getName(), outputFile.getName(), outputFile.length());
|
||||
return outputFile;
|
||||
}
|
||||
|
||||
private File executeCbxConversion(File cbxFile, File tempDir, BookEntity bookEntity)
|
||||
throws IOException, TemplateException, RarException {
|
||||
|
||||
Path epubFilePath = Paths.get(tempDir.getAbsolutePath(), cbxFile.getName() + ".epub");
|
||||
File epubFile = epubFilePath.toFile();
|
||||
|
||||
Path extractedImagesDir = Paths.get(tempDir.getAbsolutePath(), EXTRACTED_IMAGES_SUBDIR);
|
||||
Files.createDirectories(extractedImagesDir);
|
||||
|
||||
List<Path> imagePaths = extractImagesFromCbx(cbxFile, extractedImagesDir);
|
||||
if (imagePaths.isEmpty()) {
|
||||
throw new IllegalStateException("No valid images found in CBX file: " + cbxFile.getName());
|
||||
}
|
||||
|
||||
log.debug("Extracted {} images from CBX file to disk", imagePaths.size());
|
||||
|
||||
try (ZipArchiveOutputStream zipOut = new ZipArchiveOutputStream(new FileOutputStream(epubFile))) {
|
||||
addMimetypeEntry(zipOut);
|
||||
addMetaInfContainer(zipOut);
|
||||
addStylesheet(zipOut);
|
||||
|
||||
List<EpubContentFileGroup> contentGroups = addImagesAndPages(zipOut, imagePaths);
|
||||
|
||||
addContentOpf(zipOut, bookEntity, contentGroups);
|
||||
addTocNcx(zipOut, bookEntity, contentGroups);
|
||||
addNavXhtml(zipOut, bookEntity, contentGroups);
|
||||
}
|
||||
|
||||
deleteDirectory(extractedImagesDir);
|
||||
|
||||
return epubFile;
|
||||
}
|
||||
|
||||
private void deleteDirectory(Path directory) {
|
||||
try {
|
||||
FileSystemUtils.deleteRecursively(directory);
|
||||
} catch (IOException e) {
|
||||
log.warn("Failed to delete directory {}: {}", directory, e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private void validateInputs(File cbxFile, File tempDir) {
|
||||
if (cbxFile == null || !cbxFile.isFile()) {
|
||||
throw new IllegalArgumentException("Invalid CBX file: " + cbxFile);
|
||||
}
|
||||
|
||||
if (!isSupportedCbxFormat(cbxFile.getName())) {
|
||||
throw new IllegalArgumentException("Unsupported file format: " + cbxFile.getName() +
|
||||
". Supported formats: CBZ, CBR, CB7");
|
||||
}
|
||||
|
||||
if (tempDir == null || !tempDir.isDirectory()) {
|
||||
throw new IllegalArgumentException("Invalid temp directory: " + tempDir);
|
||||
}
|
||||
}
|
||||
|
||||
private Configuration initializeFreemarkerConfiguration() {
|
||||
Configuration config = new Configuration(Configuration.VERSION_2_3_33);
|
||||
config.setTemplateLoader(new ClassTemplateLoader(this.getClass(), "/templates/epub"));
|
||||
config.setDefaultEncoding(StandardCharsets.UTF_8.name());
|
||||
config.setTemplateExceptionHandler(TemplateExceptionHandler.RETHROW_HANDLER);
|
||||
config.setLogTemplateExceptions(false);
|
||||
config.setWrapUncheckedExceptions(true);
|
||||
return config;
|
||||
}
|
||||
|
||||
private List<Path> extractImagesFromCbx(File cbxFile, Path extractedImagesDir) throws IOException, RarException {
|
||||
String fileName = cbxFile.getName().toLowerCase();
|
||||
|
||||
if (fileName.endsWith(".cbz")) {
|
||||
return extractImagesFromZip(cbxFile, extractedImagesDir);
|
||||
} else if (fileName.endsWith(".cbr")) {
|
||||
return extractImagesFromRar(cbxFile, extractedImagesDir);
|
||||
} else if (fileName.endsWith(".cb7")) {
|
||||
return extractImagesFrom7z(cbxFile, extractedImagesDir);
|
||||
} else {
|
||||
throw new IllegalArgumentException("Unsupported archive format: " + fileName);
|
||||
}
|
||||
}
|
||||
|
||||
private List<Path> extractImagesFromZip(File cbzFile, Path extractedImagesDir) throws IOException {
|
||||
List<Path> imagePaths = new ArrayList<>();
|
||||
|
||||
try (ZipFile zipFile = ZipFile.builder().setFile(cbzFile).get()) {
|
||||
for (ZipArchiveEntry entry : Collections.list(zipFile.getEntries())) {
|
||||
if (entry.isDirectory() || !isImageFile(entry.getName())) {
|
||||
continue;
|
||||
}
|
||||
|
||||
validateImageSize(entry.getName(), entry.getSize());
|
||||
|
||||
try (InputStream inputStream = zipFile.getInputStream(entry)) {
|
||||
Path outputPath = extractedImagesDir.resolve(extractFileName(entry.getName()));
|
||||
Files.copy(inputStream, outputPath);
|
||||
imagePaths.add(outputPath);
|
||||
} catch (Exception e) {
|
||||
log.warn("Error extracting image {}: {}", entry.getName(), e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
log.debug("Found {} image entries in CBZ file", imagePaths.size());
|
||||
imagePaths.sort(Comparator.comparing(path -> path.getFileName().toString().toLowerCase()));
|
||||
return imagePaths;
|
||||
}
|
||||
|
||||
private List<Path> extractImagesFromRar(File cbrFile, Path extractedImagesDir) throws IOException, RarException {
|
||||
List<Path> imagePaths = new ArrayList<>();
|
||||
|
||||
try (Archive rarFile = new Archive(cbrFile)) {
|
||||
for (FileHeader fileHeader : rarFile) {
|
||||
if (fileHeader.isDirectory() || !isImageFile(fileHeader.getFileName())) {
|
||||
continue;
|
||||
}
|
||||
|
||||
validateImageSize(fileHeader.getFileName(), fileHeader.getFullUnpackSize());
|
||||
|
||||
try (InputStream inputStream = rarFile.getInputStream(fileHeader)) {
|
||||
Path outputPath = extractedImagesDir.resolve(extractFileName(fileHeader.getFileName()));
|
||||
Files.copy(inputStream, outputPath);
|
||||
imagePaths.add(outputPath);
|
||||
} catch (Exception e) {
|
||||
log.warn("Error extracting image {}: {}", fileHeader.getFileName(), e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
log.debug("Found {} image entries in CBR file", imagePaths.size());
|
||||
imagePaths.sort(Comparator.comparing(path -> path.getFileName().toString().toLowerCase()));
|
||||
return imagePaths;
|
||||
}
|
||||
|
||||
private List<Path> extractImagesFrom7z(File cb7File, Path extractedImagesDir) throws IOException {
|
||||
List<Path> imagePaths = new ArrayList<>();
|
||||
|
||||
try (SevenZFile sevenZFile = SevenZFile.builder().setFile(cb7File).get()) {
|
||||
SevenZArchiveEntry entry;
|
||||
while ((entry = sevenZFile.getNextEntry()) != null) {
|
||||
if (entry.isDirectory() || !isImageFile(entry.getName())) {
|
||||
continue;
|
||||
}
|
||||
|
||||
validateImageSize(entry.getName(), entry.getSize());
|
||||
|
||||
try {
|
||||
Path outputPath = extractedImagesDir.resolve(extractFileName(entry.getName()));
|
||||
try (InputStream entryInputStream = sevenZFile.getInputStream(entry);
|
||||
OutputStream fileOutputStream = Files.newOutputStream(outputPath)) {
|
||||
entryInputStream.transferTo(fileOutputStream);
|
||||
}
|
||||
imagePaths.add(outputPath);
|
||||
} catch (Exception e) {
|
||||
log.warn("Error extracting image {}: {}", entry.getName(), e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
log.debug("Found {} image entries in CB7 file", imagePaths.size());
|
||||
imagePaths.sort(Comparator.comparing(path -> path.getFileName().toString().toLowerCase()));
|
||||
return imagePaths;
|
||||
}
|
||||
|
||||
private String extractFileName(String entryPath) {
|
||||
return Path.of(entryPath).getFileName().toString();
|
||||
}
|
||||
|
||||
private void validateImageSize(String imageName, long size) throws IOException {
|
||||
if (size > MAX_IMAGE_SIZE_BYTES) {
|
||||
throw new IOException(String.format("Image '%s' exceeds maximum size limit: %d bytes (max: %d bytes)",
|
||||
imageName, size, MAX_IMAGE_SIZE_BYTES));
|
||||
}
|
||||
}
|
||||
|
||||
private boolean isImageFile(String fileName) {
|
||||
String lowerName = fileName.toLowerCase();
|
||||
|
||||
boolean isImage = lowerName.endsWith(".jpg") || lowerName.endsWith(".jpeg") ||
|
||||
lowerName.endsWith(".png") || lowerName.endsWith(".webp") ||
|
||||
lowerName.endsWith(".gif") || lowerName.endsWith(".bmp");
|
||||
|
||||
return isImage;
|
||||
}
|
||||
|
||||
private boolean isJpegFile(Path path) {
|
||||
Set<String> jpegExtensions = Set.of(".jpg", ".jpeg");
|
||||
String fileName = path.getFileName().toString().toLowerCase();
|
||||
int lastDot = fileName.lastIndexOf('.');
|
||||
if (lastDot > 0) {
|
||||
String extension = fileName.substring(lastDot);
|
||||
return jpegExtensions.contains(extension);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private void addMimetypeEntry(ZipArchiveOutputStream zipOut) throws IOException {
|
||||
byte[] mimetypeBytes = MIMETYPE_CONTENT.getBytes(StandardCharsets.UTF_8);
|
||||
ZipArchiveEntry mimetypeEntry = new ZipArchiveEntry("mimetype");
|
||||
mimetypeEntry.setMethod(ZipArchiveEntry.STORED);
|
||||
mimetypeEntry.setSize(mimetypeBytes.length);
|
||||
mimetypeEntry.setCrc(calculateCrc32(mimetypeBytes));
|
||||
|
||||
zipOut.putArchiveEntry(mimetypeEntry);
|
||||
zipOut.write(mimetypeBytes);
|
||||
zipOut.closeArchiveEntry();
|
||||
}
|
||||
|
||||
private void addMetaInfContainer(ZipArchiveOutputStream zipOut) throws IOException, TemplateException {
|
||||
Map<String, Object> model = new HashMap<>();
|
||||
model.put("contentOpfPath", CONTENT_OPF_PATH);
|
||||
|
||||
String containerXml = processTemplate("xml/container.xml.ftl", model);
|
||||
|
||||
ZipArchiveEntry containerEntry = new ZipArchiveEntry("META-INF/container.xml");
|
||||
zipOut.putArchiveEntry(containerEntry);
|
||||
zipOut.write(containerXml.getBytes(StandardCharsets.UTF_8));
|
||||
zipOut.closeArchiveEntry();
|
||||
}
|
||||
|
||||
private void addStylesheet(ZipArchiveOutputStream zipOut) throws IOException {
|
||||
String stylesheetContent = loadResourceAsString("/templates/epub/css/stylesheet.css");
|
||||
|
||||
ZipArchiveEntry stylesheetEntry = new ZipArchiveEntry(STYLESHEET_CSS_PATH);
|
||||
zipOut.putArchiveEntry(stylesheetEntry);
|
||||
zipOut.write(stylesheetContent.getBytes(StandardCharsets.UTF_8));
|
||||
zipOut.closeArchiveEntry();
|
||||
}
|
||||
|
||||
private List<EpubContentFileGroup> addImagesAndPages(ZipArchiveOutputStream zipOut, List<Path> imagePaths)
|
||||
throws IOException, TemplateException {
|
||||
|
||||
List<EpubContentFileGroup> contentGroups = new ArrayList<>();
|
||||
|
||||
if (!imagePaths.isEmpty()) {
|
||||
addImageToZipFromPath(zipOut, COVER_IMAGE_PATH, imagePaths.get(0));
|
||||
}
|
||||
|
||||
for (int i = 0; i < imagePaths.size(); i++) {
|
||||
Path imageSourcePath = imagePaths.get(i);
|
||||
String contentKey = String.format("page-%04d", i + 1);
|
||||
String imageFileName = contentKey + ".jpg";
|
||||
String htmlFileName = contentKey + ".xhtml";
|
||||
|
||||
String imagePath = IMAGE_ROOT_PATH + imageFileName;
|
||||
String htmlPath = HTML_ROOT_PATH + htmlFileName;
|
||||
|
||||
addImageToZipFromPath(zipOut, imagePath, imageSourcePath);
|
||||
|
||||
String htmlContent = generatePageHtml(imageFileName, i + 1);
|
||||
ZipArchiveEntry htmlEntry = new ZipArchiveEntry(htmlPath);
|
||||
zipOut.putArchiveEntry(htmlEntry);
|
||||
zipOut.write(htmlContent.getBytes(StandardCharsets.UTF_8));
|
||||
zipOut.closeArchiveEntry();
|
||||
|
||||
contentGroups.add(new EpubContentFileGroup(contentKey, imagePath, htmlPath));
|
||||
}
|
||||
|
||||
return contentGroups;
|
||||
}
|
||||
|
||||
private void addImageToZipFromPath(ZipArchiveOutputStream zipOut, String epubImagePath, Path sourceImagePath)
|
||||
throws IOException {
|
||||
ZipArchiveEntry imageEntry = new ZipArchiveEntry(epubImagePath);
|
||||
zipOut.putArchiveEntry(imageEntry);
|
||||
|
||||
if (isJpegFile(sourceImagePath)) {
|
||||
try (InputStream fis = Files.newInputStream(sourceImagePath)) {
|
||||
fis.transferTo(zipOut);
|
||||
}
|
||||
} else {
|
||||
try (InputStream fis = Files.newInputStream(sourceImagePath)) {
|
||||
BufferedImage image = ImageIO.read(fis);
|
||||
if (image != null) {
|
||||
writeJpegImage(image, zipOut, 0.85f);
|
||||
} else {
|
||||
log.warn("Could not decode image {}, copying raw bytes", sourceImagePath.getFileName());
|
||||
try (InputStream rawStream = Files.newInputStream(sourceImagePath)) {
|
||||
rawStream.transferTo(zipOut);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
zipOut.closeArchiveEntry();
|
||||
}
|
||||
|
||||
private void writeJpegImage(BufferedImage image, ZipArchiveOutputStream zipOut, float quality)
|
||||
throws IOException {
|
||||
BufferedImage rgbImage = image;
|
||||
if (image.getType() != BufferedImage.TYPE_INT_RGB) {
|
||||
rgbImage = new BufferedImage(image.getWidth(), image.getHeight(), BufferedImage.TYPE_INT_RGB);
|
||||
rgbImage.getGraphics().drawImage(image, 0, 0, null);
|
||||
rgbImage.getGraphics().dispose();
|
||||
}
|
||||
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
|
||||
Iterator<ImageWriter> writers = ImageIO.getImageWritersByFormatName("jpg");
|
||||
if (!writers.hasNext()) {
|
||||
throw new IOException("No JPEG image writer available");
|
||||
}
|
||||
ImageWriter writer = writers.next();
|
||||
|
||||
ImageWriteParam param = writer.getDefaultWriteParam();
|
||||
|
||||
if (param.canWriteCompressed()) {
|
||||
param.setCompressionMode(ImageWriteParam.MODE_EXPLICIT);
|
||||
param.setCompressionQuality(quality);
|
||||
}
|
||||
|
||||
try (ImageOutputStream ios = ImageIO.createImageOutputStream(baos)) {
|
||||
writer.setOutput(ios);
|
||||
writer.write(null, new IIOImage(rgbImage, null, null), param);
|
||||
} finally {
|
||||
writer.dispose();
|
||||
}
|
||||
|
||||
zipOut.write(baos.toByteArray());
|
||||
}
|
||||
|
||||
private String generatePageHtml(String imageFileName, int pageNumber) throws IOException, TemplateException {
|
||||
Map<String, Object> model = new HashMap<>();
|
||||
model.put("imageFileName", "../Images/" + imageFileName);
|
||||
model.put("pageNumber", pageNumber);
|
||||
model.put("stylesheetPath", "../Styles/stylesheet.css");
|
||||
|
||||
return processTemplate("xml/image_page.xhtml.ftl", model);
|
||||
}
|
||||
|
||||
private void addContentOpf(ZipArchiveOutputStream zipOut, BookEntity bookEntity,
|
||||
List<EpubContentFileGroup> contentGroups) throws IOException, TemplateException {
|
||||
|
||||
Map<String, Object> model = createBookMetadataModel(bookEntity);
|
||||
|
||||
List<EpubContentFileGroup> relativeContentGroups = contentGroups.stream()
|
||||
.map(group -> new EpubContentFileGroup(
|
||||
group.contentKey(),
|
||||
makeRelativeToOebps(group.imagePath()),
|
||||
makeRelativeToOebps(group.htmlPath())
|
||||
))
|
||||
.toList();
|
||||
|
||||
model.put("contentFileGroups", relativeContentGroups);
|
||||
model.put("coverImagePath", makeRelativeToOebps(COVER_IMAGE_PATH));
|
||||
model.put("tocNcxPath", makeRelativeToOebps(TOC_NCX_PATH));
|
||||
model.put("navXhtmlPath", makeRelativeToOebps(NAV_XHTML_PATH));
|
||||
model.put("stylesheetCssPath", makeRelativeToOebps(STYLESHEET_CSS_PATH));
|
||||
model.put("firstPageId", contentGroups.isEmpty() ? "" : "page_" + contentGroups.get(0).contentKey());
|
||||
|
||||
String contentOpf = processTemplate("xml/content.opf.ftl", model);
|
||||
|
||||
ZipArchiveEntry contentEntry = new ZipArchiveEntry(CONTENT_OPF_PATH);
|
||||
zipOut.putArchiveEntry(contentEntry);
|
||||
zipOut.write(contentOpf.getBytes(StandardCharsets.UTF_8));
|
||||
zipOut.closeArchiveEntry();
|
||||
}
|
||||
|
||||
private void addTocNcx(ZipArchiveOutputStream zipOut, BookEntity bookEntity,
|
||||
List<EpubContentFileGroup> contentGroups) throws IOException, TemplateException {
|
||||
|
||||
Map<String, Object> model = createBookMetadataModel(bookEntity);
|
||||
model.put("contentFileGroups", contentGroups);
|
||||
|
||||
String tocNcx = processTemplate("xml/toc.xml.ftl", model);
|
||||
|
||||
ZipArchiveEntry tocEntry = new ZipArchiveEntry(TOC_NCX_PATH);
|
||||
zipOut.putArchiveEntry(tocEntry);
|
||||
zipOut.write(tocNcx.getBytes(StandardCharsets.UTF_8));
|
||||
zipOut.closeArchiveEntry();
|
||||
}
|
||||
|
||||
private void addNavXhtml(ZipArchiveOutputStream zipOut, BookEntity bookEntity,
|
||||
List<EpubContentFileGroup> contentGroups) throws IOException, TemplateException {
|
||||
|
||||
Map<String, Object> model = createBookMetadataModel(bookEntity);
|
||||
model.put("contentFileGroups", contentGroups);
|
||||
|
||||
String navXhtml = processTemplate("xml/nav.xhtml.ftl", model);
|
||||
|
||||
ZipArchiveEntry navEntry = new ZipArchiveEntry(NAV_XHTML_PATH);
|
||||
zipOut.putArchiveEntry(navEntry);
|
||||
zipOut.write(navXhtml.getBytes(StandardCharsets.UTF_8));
|
||||
zipOut.closeArchiveEntry();
|
||||
}
|
||||
|
||||
private Map<String, Object> createBookMetadataModel(BookEntity bookEntity) {
|
||||
Map<String, Object> model = new HashMap<>();
|
||||
|
||||
if (bookEntity != null && bookEntity.getMetadata() != null) {
|
||||
var metadata = bookEntity.getMetadata();
|
||||
|
||||
model.put("title", metadata.getTitle() != null ? metadata.getTitle() : "Unknown Comic");
|
||||
model.put("language", metadata.getLanguage() != null ? metadata.getLanguage() : "en");
|
||||
|
||||
if (metadata.getSubtitle() != null && !metadata.getSubtitle().trim().isEmpty()) {
|
||||
model.put("subtitle", metadata.getSubtitle());
|
||||
}
|
||||
if (metadata.getDescription() != null && !metadata.getDescription().trim().isEmpty()) {
|
||||
model.put("description", metadata.getDescription());
|
||||
}
|
||||
|
||||
if (metadata.getSeriesName() != null && !metadata.getSeriesName().trim().isEmpty()) {
|
||||
model.put("seriesName", metadata.getSeriesName());
|
||||
}
|
||||
if (metadata.getSeriesNumber() != null) {
|
||||
model.put("seriesNumber", metadata.getSeriesNumber());
|
||||
}
|
||||
if (metadata.getSeriesTotal() != null) {
|
||||
model.put("seriesTotal", metadata.getSeriesTotal());
|
||||
}
|
||||
|
||||
if (metadata.getPublisher() != null && !metadata.getPublisher().trim().isEmpty()) {
|
||||
model.put("publisher", metadata.getPublisher());
|
||||
}
|
||||
if (metadata.getPublishedDate() != null) {
|
||||
model.put("publishedDate", metadata.getPublishedDate().toString());
|
||||
}
|
||||
if (metadata.getPageCount() != null && metadata.getPageCount() > 0) {
|
||||
model.put("pageCount", metadata.getPageCount());
|
||||
}
|
||||
|
||||
if (metadata.getIsbn13() != null && !metadata.getIsbn13().trim().isEmpty()) {
|
||||
model.put("isbn13", metadata.getIsbn13());
|
||||
}
|
||||
if (metadata.getIsbn10() != null && !metadata.getIsbn10().trim().isEmpty()) {
|
||||
model.put("isbn10", metadata.getIsbn10());
|
||||
}
|
||||
if (metadata.getAsin() != null && !metadata.getAsin().trim().isEmpty()) {
|
||||
model.put("asin", metadata.getAsin());
|
||||
}
|
||||
if (metadata.getGoodreadsId() != null && !metadata.getGoodreadsId().trim().isEmpty()) {
|
||||
model.put("goodreadsId", metadata.getGoodreadsId());
|
||||
}
|
||||
|
||||
if (metadata.getAuthors() != null && !metadata.getAuthors().isEmpty()) {
|
||||
model.put("authors", metadata.getAuthors().stream()
|
||||
.map(AuthorEntity::getName)
|
||||
.toList());
|
||||
}
|
||||
|
||||
if (metadata.getCategories() != null && !metadata.getCategories().isEmpty()) {
|
||||
model.put("categories", metadata.getCategories().stream()
|
||||
.map(CategoryEntity::getName)
|
||||
.toList());
|
||||
}
|
||||
|
||||
if (metadata.getTags() != null && !metadata.getTags().isEmpty()) {
|
||||
model.put("tags", metadata.getTags().stream()
|
||||
.map(TagEntity::getName)
|
||||
.toList());
|
||||
}
|
||||
|
||||
model.put("identifier", "urn:uuid:" + UUID.randomUUID());
|
||||
} else {
|
||||
model.put("title", "Unknown Comic");
|
||||
model.put("language", "en");
|
||||
model.put("identifier", "urn:uuid:" + UUID.randomUUID());
|
||||
}
|
||||
|
||||
model.put("modified", Instant.now().toString());
|
||||
|
||||
return model;
|
||||
}
|
||||
|
||||
private String processTemplate(String templateName, Map<String, Object> model)
|
||||
throws IOException, TemplateException {
|
||||
try {
|
||||
Template template = freemarkerConfig.getTemplate(templateName);
|
||||
StringWriter writer = new StringWriter();
|
||||
template.process(model, writer);
|
||||
return writer.toString();
|
||||
} catch (IOException e) {
|
||||
throw new IOException("Failed to load template: " + templateName, e);
|
||||
} catch (TemplateException e) {
|
||||
throw new TemplateException("Failed to process template: " + templateName, e, null);
|
||||
}
|
||||
}
|
||||
|
||||
private String loadResourceAsString(String resourcePath) throws IOException {
|
||||
try (InputStream inputStream = getClass().getResourceAsStream(resourcePath)) {
|
||||
if (inputStream == null) {
|
||||
throw new IOException("Resource not found: " + resourcePath);
|
||||
}
|
||||
return new String(inputStream.readAllBytes(), StandardCharsets.UTF_8);
|
||||
}
|
||||
}
|
||||
|
||||
private String makeRelativeToOebps(String fullPath) {
|
||||
Path oebpsPath = Paths.get("OEBPS");
|
||||
Path targetPath = Paths.get(fullPath);
|
||||
|
||||
if (targetPath.startsWith(oebpsPath)) {
|
||||
return oebpsPath.relativize(targetPath).toString().replace('\\', '/');
|
||||
}
|
||||
|
||||
return fullPath;
|
||||
}
|
||||
|
||||
private long calculateCrc32(byte[] data) {
|
||||
java.util.zip.CRC32 crc32 = new java.util.zip.CRC32();
|
||||
crc32.update(data);
|
||||
return crc32.getValue();
|
||||
}
|
||||
|
||||
public boolean isSupportedCbxFormat(String fileName) {
|
||||
if (fileName == null) {
|
||||
return false;
|
||||
}
|
||||
String lowerName = fileName.toLowerCase();
|
||||
return lowerName.endsWith(".cbz") ||
|
||||
lowerName.endsWith(".cbr") ||
|
||||
lowerName.endsWith(".cb7");
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package com.adityachandel.booklore.service.kobo;
|
||||
|
||||
import com.adityachandel.booklore.model.dto.settings.KoboSettings;
|
||||
import com.adityachandel.booklore.model.entity.BookEntity;
|
||||
import com.adityachandel.booklore.model.enums.BookFileType;
|
||||
import com.adityachandel.booklore.service.appsettings.AppSettingService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class KoboCompatibilityService {
|
||||
|
||||
private final AppSettingService appSettingService;
|
||||
|
||||
public boolean isBookSupportedForKobo(BookEntity book) {
|
||||
if (book == null) {
|
||||
throw new IllegalArgumentException("Book cannot be null");
|
||||
}
|
||||
|
||||
BookFileType bookType = book.getBookType();
|
||||
if (bookType == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (bookType == BookFileType.EPUB) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (bookType == BookFileType.CBX) {
|
||||
return isCbxConversionEnabled() && meetsCbxConversionSizeLimit(book);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public boolean isCbxConversionEnabled() {
|
||||
try {
|
||||
KoboSettings koboSettings = appSettingService.getAppSettings().getKoboSettings();
|
||||
return koboSettings != null && koboSettings.isConvertCbxToEpub();
|
||||
} catch (Exception e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public boolean meetsCbxConversionSizeLimit(BookEntity book) {
|
||||
if (book == null || book.getBookType() != BookFileType.CBX) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
KoboSettings koboSettings = appSettingService.getAppSettings().getKoboSettings();
|
||||
if (koboSettings == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
long fileSizeKb = book.getFileSizeKb() != null ? book.getFileSizeKb() : 0;
|
||||
long limitKb = (long) koboSettings.getConversionLimitInMbForCbx() * 1024;
|
||||
|
||||
return fileSizeKb <= limitKb;
|
||||
} catch (Exception e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,14 +2,12 @@ package com.adityachandel.booklore.service.kobo;
|
||||
|
||||
import com.adityachandel.booklore.config.security.service.AuthenticationService;
|
||||
import com.adityachandel.booklore.mapper.KoboReadingStateMapper;
|
||||
import com.adityachandel.booklore.model.dto.BookLoreUser;
|
||||
import com.adityachandel.booklore.model.dto.kobo.*;
|
||||
import com.adityachandel.booklore.model.dto.settings.KoboSettings;
|
||||
import com.adityachandel.booklore.model.entity.AuthorEntity;
|
||||
import com.adityachandel.booklore.model.entity.BookEntity;
|
||||
import com.adityachandel.booklore.model.entity.BookMetadataEntity;
|
||||
import com.adityachandel.booklore.model.entity.CategoryEntity;
|
||||
import com.adityachandel.booklore.model.entity.UserBookProgressEntity;
|
||||
import com.adityachandel.booklore.model.enums.BookFileType;
|
||||
import com.adityachandel.booklore.model.enums.KoboBookFormat;
|
||||
import com.adityachandel.booklore.model.enums.KoboReadStatus;
|
||||
@@ -40,6 +38,7 @@ public class KoboEntitlementService {
|
||||
private final KoboUrlBuilder koboUrlBuilder;
|
||||
private final BookQueryService bookQueryService;
|
||||
private final AppSettingService appSettingService;
|
||||
private final KoboCompatibilityService koboCompatibilityService;
|
||||
private final UserBookProgressRepository progressRepository;
|
||||
private final KoboReadingStateRepository readingStateRepository;
|
||||
private final KoboReadingStateMapper readingStateMapper;
|
||||
@@ -50,7 +49,7 @@ public class KoboEntitlementService {
|
||||
List<BookEntity> books = bookQueryService.findAllWithMetadataByIds(bookIds);
|
||||
|
||||
return books.stream()
|
||||
.filter(bookEntity -> bookEntity.getBookType() == BookFileType.EPUB)
|
||||
.filter(koboCompatibilityService::isBookSupportedForKobo)
|
||||
.map(book -> NewEntitlement.builder()
|
||||
.newEntitlement(BookEntitlementContainer.builder()
|
||||
.bookEntitlement(buildBookEntitlement(book, removed))
|
||||
@@ -64,7 +63,7 @@ public class KoboEntitlementService {
|
||||
public List<ChangedEntitlement> generateChangedEntitlements(Set<Long> bookIds, String token, boolean removed) {
|
||||
List<BookEntity> books = bookQueryService.findAllWithMetadataByIds(bookIds);
|
||||
return books.stream()
|
||||
.filter(bookEntity -> bookEntity.getBookType() == BookFileType.EPUB)
|
||||
.filter(koboCompatibilityService::isBookSupportedForKobo)
|
||||
.map(book -> {
|
||||
KoboBookMetadata metadata;
|
||||
if (removed) {
|
||||
@@ -180,7 +179,7 @@ public class KoboEntitlementService {
|
||||
public KoboBookMetadata getMetadataForBook(long bookId, String token) {
|
||||
List<BookEntity> books = bookQueryService.findAllWithMetadataByIds(Set.of(bookId))
|
||||
.stream()
|
||||
.filter(bookEntity -> bookEntity.getBookType() == BookFileType.EPUB)
|
||||
.filter(koboCompatibilityService::isBookSupportedForKobo)
|
||||
.toList();
|
||||
return mapToKoboMetadata(books.getFirst(), token);
|
||||
}
|
||||
@@ -215,8 +214,16 @@ public class KoboEntitlementService {
|
||||
|
||||
KoboBookFormat bookFormat = KoboBookFormat.EPUB3;
|
||||
KoboSettings koboSettings = appSettingService.getAppSettings().getKoboSettings();
|
||||
if (koboSettings != null && koboSettings.isConvertToKepub()) {
|
||||
bookFormat = KoboBookFormat.KEPUB;
|
||||
|
||||
boolean isEpubFile = book.getBookType() == BookFileType.EPUB;
|
||||
boolean isCbxFile = book.getBookType() == BookFileType.CBX;
|
||||
|
||||
if (koboSettings != null) {
|
||||
if (isEpubFile && koboSettings.isConvertToKepub()) {
|
||||
bookFormat = KoboBookFormat.KEPUB;
|
||||
} else if (isCbxFile && koboSettings.isConvertCbxToEpub()) {
|
||||
bookFormat = KoboBookFormat.EPUB3;
|
||||
}
|
||||
}
|
||||
|
||||
return KoboBookMetadata.builder()
|
||||
|
||||
@@ -5,7 +5,6 @@ import com.adityachandel.booklore.model.entity.KoboDeletedBookProgressEntity;
|
||||
import com.adityachandel.booklore.model.entity.KoboSnapshotBookEntity;
|
||||
import com.adityachandel.booklore.model.entity.ShelfEntity;
|
||||
import com.adityachandel.booklore.model.entity.KoboLibrarySnapshotEntity;
|
||||
import com.adityachandel.booklore.model.enums.BookFileType;
|
||||
import com.adityachandel.booklore.model.enums.ShelfType;
|
||||
import com.adityachandel.booklore.repository.KoboDeletedBookProgressRepository;
|
||||
import com.adityachandel.booklore.repository.ShelfRepository;
|
||||
@@ -29,6 +28,7 @@ public class KoboLibrarySnapshotService {
|
||||
private final ShelfRepository shelfRepository;
|
||||
private final BookEntityToKoboSnapshotBookMapper mapper;
|
||||
private final KoboDeletedBookProgressRepository koboDeletedBookProgressRepository;
|
||||
private final KoboCompatibilityService koboCompatibilityService;
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public Optional<KoboLibrarySnapshotEntity> findByIdAndUserId(String id, Long userId) {
|
||||
@@ -118,7 +118,7 @@ public class KoboLibrarySnapshotService {
|
||||
|
||||
private List<KoboSnapshotBookEntity> mapBooksToKoboSnapshotBook(ShelfEntity shelf, KoboLibrarySnapshotEntity snapshot) {
|
||||
return shelf.getBookEntities().stream()
|
||||
.filter(bookEntity -> bookEntity.getBookType() == BookFileType.EPUB)
|
||||
.filter(koboCompatibilityService::isBookSupportedForKobo)
|
||||
.map(book -> {
|
||||
KoboSnapshotBookEntity snapshotBook = mapper.toKoboSnapshotBook(book);
|
||||
snapshotBook.setSnapshot(snapshot);
|
||||
@@ -130,4 +130,5 @@ public class KoboLibrarySnapshotService {
|
||||
public void deleteById(String id) {
|
||||
koboLibrarySnapshotRepository.deleteById(id);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -17,6 +17,8 @@ app:
|
||||
server:
|
||||
forward-headers-strategy: native
|
||||
port: 8080
|
||||
tomcat:
|
||||
relaxed-query-chars: '[,],%,{,},|'
|
||||
|
||||
spring:
|
||||
servlet:
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
/* EPUB 3.0 compliant stylesheet for comic books - optimized for e-ink readers */
|
||||
@page {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background-color: #ffffff;
|
||||
}
|
||||
|
||||
html, body {
|
||||
height: 100vh;
|
||||
width: 100vw;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
background-color: #ffffff;
|
||||
}
|
||||
|
||||
body {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background-color: #ffffff;
|
||||
}
|
||||
|
||||
div {
|
||||
height: 100vh;
|
||||
width: 100vw;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background-color: #ffffff;
|
||||
}
|
||||
|
||||
img {
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
object-fit: contain;
|
||||
display: block;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
<#ftl output_format="XML" encoding="UTF-8">
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<container version="1.0" xmlns="urn:oasis:names:tc:opendocument:xmlns:container">
|
||||
<rootfiles>
|
||||
<rootfile full-path="${contentOpfPath}" media-type="application/oebps-package+xml"/>
|
||||
</rootfiles>
|
||||
</container>
|
||||
@@ -0,0 +1,110 @@
|
||||
<#ftl output_format="XML" encoding="UTF-8">
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<package version="3.0" unique-identifier="BookID" xmlns="http://www.idpf.org/2007/opf">
|
||||
<metadata xmlns:opf="http://www.idpf.org/2007/opf" xmlns:dc="http://purl.org/dc/elements/1.1/">
|
||||
<dc:title id="title">${title!""}</dc:title>
|
||||
<#if subtitle?has_content>
|
||||
<meta property="title-type" refines="#title">main</meta>
|
||||
<meta property="display-seq" refines="#title">1</meta>
|
||||
<dc:title id="subtitle">${subtitle}</dc:title>
|
||||
<meta property="title-type" refines="#subtitle">subtitle</meta>
|
||||
<meta property="display-seq" refines="#subtitle">2</meta>
|
||||
</#if>
|
||||
|
||||
<#if authors?has_content>
|
||||
<#list authors as author>
|
||||
<dc:creator id="creator${author?index}">${author}</dc:creator>
|
||||
<meta property="role" refines="#creator${author?index}" scheme="marc:relators">aut</meta>
|
||||
</#list>
|
||||
</#if>
|
||||
|
||||
<dc:language>${language!""}</dc:language>
|
||||
|
||||
<#if publisher?has_content>
|
||||
<dc:publisher>${publisher}</dc:publisher>
|
||||
</#if>
|
||||
|
||||
<#if publishedDate?has_content>
|
||||
<dc:date>${publishedDate}</dc:date>
|
||||
</#if>
|
||||
|
||||
<#if description?has_content>
|
||||
<dc:description>${description}</dc:description>
|
||||
</#if>
|
||||
|
||||
<#-- Series metadata -->
|
||||
<#if seriesName?has_content>
|
||||
<meta property="belongs-to-collection" id="series">${seriesName}</meta>
|
||||
<meta property="collection-type" refines="#series">series</meta>
|
||||
<#if seriesNumber?has_content>
|
||||
<meta property="group-position" refines="#series">${seriesNumber}</meta>
|
||||
</#if>
|
||||
</#if>
|
||||
|
||||
<#-- Identifiers -->
|
||||
<dc:identifier id="BookID">${identifier!""}</dc:identifier>
|
||||
<#if isbn13?has_content>
|
||||
<dc:identifier id="isbn13">${isbn13}</dc:identifier>
|
||||
<meta refines="#isbn13" property="identifier-type" scheme="onix:codelist5">15</meta>
|
||||
</#if>
|
||||
<#if isbn10?has_content>
|
||||
<dc:identifier id="isbn10">${isbn10}</dc:identifier>
|
||||
<meta refines="#isbn10" property="identifier-type" scheme="onix:codelist5">02</meta>
|
||||
</#if>
|
||||
<#if asin?has_content>
|
||||
<dc:identifier id="asin">${asin}</dc:identifier>
|
||||
<meta refines="#asin" property="identifier-type">ASIN</meta>
|
||||
</#if>
|
||||
<#if goodreadsId?has_content>
|
||||
<dc:identifier id="goodreads">${goodreadsId}</dc:identifier>
|
||||
<meta refines="#goodreads" property="identifier-type">GOODREADS</meta>
|
||||
</#if>
|
||||
|
||||
<#-- Categories/Subjects -->
|
||||
<#if categories?has_content>
|
||||
<#list categories as category>
|
||||
<dc:subject>${category}</dc:subject>
|
||||
</#list>
|
||||
</#if>
|
||||
|
||||
<#-- Tags as additional subjects -->
|
||||
<#if tags?has_content>
|
||||
<#list tags as tag>
|
||||
<dc:subject>${tag}</dc:subject>
|
||||
</#list>
|
||||
</#if>
|
||||
|
||||
<#if pageCount?has_content>
|
||||
<meta property="schema:numberOfPages">${pageCount}</meta>
|
||||
</#if>
|
||||
|
||||
<#-- EPUB 3.0 required metadata -->
|
||||
<meta property="dcterms:modified">${modified!""}</meta>
|
||||
<meta name="cover" content="cover" />
|
||||
|
||||
<#-- Comic-specific metadata -->
|
||||
<meta property="rendition:layout">pre-paginated</meta>
|
||||
<meta property="rendition:orientation">auto</meta>
|
||||
<meta property="rendition:spread">landscape</meta>
|
||||
</metadata>
|
||||
|
||||
<manifest>
|
||||
<item id="cover" href="${coverImagePath}" media-type="image/jpeg" properties="cover-image" />
|
||||
<item id="ncx" href="${tocNcxPath}" media-type="application/x-dtbncx+xml" />
|
||||
<item id="nav" href="${navXhtmlPath}" properties="nav" media-type="application/xhtml+xml" />
|
||||
|
||||
<#-- Loop over the content file groups and emit the two items per entry -->
|
||||
<#list contentFileGroups as file>
|
||||
<item id="${'page_' + file.contentKey}" href="${file.htmlPath}" media-type="application/xhtml+xml" />
|
||||
<item id="${'img_' + file.contentKey}" href="${file.imagePath}" media-type="image/jpeg" />
|
||||
</#list>
|
||||
|
||||
<item id="css" href="${stylesheetCssPath}" media-type="text/css" />
|
||||
</manifest>
|
||||
|
||||
<spine page-progression-direction="ltr" toc="ncx">
|
||||
<#list contentFileGroups as file>
|
||||
<itemref idref="page_${file.contentKey}" properties="page-spread-center" />
|
||||
</#list>
|
||||
</spine>
|
||||
</package>
|
||||
@@ -0,0 +1,15 @@
|
||||
<#ftl output_format="XML" encoding="UTF-8">
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:epub="http://www.idpf.org/2007/ops">
|
||||
<head>
|
||||
<title>Page ${pageNumber}</title>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link rel="stylesheet" type="text/css" href="${stylesheetPath}" />
|
||||
</head>
|
||||
<body>
|
||||
<div>
|
||||
<img src="${imageFileName}" alt="Comic page ${pageNumber}" role="img" aria-label="Comic book page ${pageNumber}" />
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,47 @@
|
||||
<#ftl output_format="XML" encoding="UTF-8">
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:epub="http://www.idpf.org/2007/ops">
|
||||
<head>
|
||||
<title>Navigation</title>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<style>
|
||||
body { font-family: sans-serif; margin: 20px; }
|
||||
nav h1 { color: #333; }
|
||||
nav ol { list-style-type: decimal; }
|
||||
nav a { text-decoration: none; color: #0066cc; }
|
||||
nav a:hover { text-decoration: underline; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<nav epub:type="toc" id="toc">
|
||||
<h1>Table of Contents</h1>
|
||||
<ol>
|
||||
<#if contentFileGroups?has_content>
|
||||
<#list contentFileGroups as file>
|
||||
<li>
|
||||
<a href="${file.htmlPath?replace('OEBPS/', '')}">Page ${file?counter}</a>
|
||||
</li>
|
||||
</#list>
|
||||
<#else>
|
||||
<li>
|
||||
<a href="Text/page-0001.xhtml">${title!'Unknown Comic'}</a>
|
||||
</li>
|
||||
</#if>
|
||||
</ol>
|
||||
</nav>
|
||||
|
||||
<nav epub:type="page-list" id="page-list">
|
||||
<h1>List of Pages</h1>
|
||||
<ol>
|
||||
<#if contentFileGroups?has_content>
|
||||
<#list contentFileGroups as file>
|
||||
<li>
|
||||
<a href="${file.htmlPath?replace('OEBPS/', '')}" epub:type="pagebreak">${file?counter}</a>
|
||||
</li>
|
||||
</#list>
|
||||
</#if>
|
||||
</ol>
|
||||
</nav>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,24 @@
|
||||
<#ftl output_format="XML" encoding="UTF-8">
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<ncx version="2005-1" xml:lang="${language!'en'}" xmlns="http://www.daisy.org/z3986/2005/ncx/">
|
||||
<head>
|
||||
<meta name="dtb:uid" content="${identifier}" />
|
||||
<meta name="dtb:depth" content="1" />
|
||||
<meta name="dtb:totalPageCount" content="${contentFileGroups?size}" />
|
||||
<meta name="dtb:maxPageNumber" content="${contentFileGroups?size}" />
|
||||
<meta name="generated" content="true" />
|
||||
</head>
|
||||
<docTitle>
|
||||
<text>${title!'Unknown Comic'}</text>
|
||||
</docTitle>
|
||||
<navMap>
|
||||
<navPoint id="cover" playOrder="1">
|
||||
<navLabel>
|
||||
<text>${title!'Unknown Comic'}</text>
|
||||
</navLabel>
|
||||
<#if contentFileGroups?has_content>
|
||||
<content src="${contentFileGroups[0].htmlPath?replace('OEBPS/', '')}" />
|
||||
</#if>
|
||||
</navPoint>
|
||||
</navMap>
|
||||
</ncx>
|
||||
@@ -0,0 +1,114 @@
|
||||
package com.adityachandel.booklore.service;
|
||||
|
||||
import com.adityachandel.booklore.model.dto.kobo.KoboBookMetadata;
|
||||
import com.adityachandel.booklore.model.dto.settings.KoboSettings;
|
||||
import com.adityachandel.booklore.model.entity.BookEntity;
|
||||
import com.adityachandel.booklore.model.entity.BookMetadataEntity;
|
||||
import com.adityachandel.booklore.model.enums.BookFileType;
|
||||
import com.adityachandel.booklore.model.enums.KoboBookFormat;
|
||||
import com.adityachandel.booklore.service.appsettings.AppSettingService;
|
||||
import com.adityachandel.booklore.service.book.BookQueryService;
|
||||
import com.adityachandel.booklore.service.kobo.KoboCompatibilityService;
|
||||
import com.adityachandel.booklore.service.kobo.KoboEntitlementService;
|
||||
import com.adityachandel.booklore.util.kobo.KoboUrlBuilder;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class KoboEntitlementServiceTest {
|
||||
|
||||
@Mock
|
||||
private KoboUrlBuilder koboUrlBuilder;
|
||||
|
||||
@Mock
|
||||
private BookQueryService bookQueryService;
|
||||
|
||||
@Mock
|
||||
private AppSettingService appSettingService;
|
||||
|
||||
@Mock
|
||||
private KoboCompatibilityService koboCompatibilityService;
|
||||
|
||||
@InjectMocks
|
||||
private KoboEntitlementService koboEntitlementService;
|
||||
|
||||
@Test
|
||||
void getMetadataForBook_shouldUseCompatibilityServiceFilter() {
|
||||
long bookId = 1L;
|
||||
String token = "test-token";
|
||||
|
||||
BookEntity cbxBook = createCbxBookEntity(bookId);
|
||||
when(bookQueryService.findAllWithMetadataByIds(Set.of(bookId)))
|
||||
.thenReturn(List.of(cbxBook));
|
||||
when(koboCompatibilityService.isBookSupportedForKobo(cbxBook))
|
||||
.thenReturn(true);
|
||||
when(koboUrlBuilder.downloadUrl(token, bookId))
|
||||
.thenReturn("http://test.com/download/" + bookId);
|
||||
when(appSettingService.getAppSettings())
|
||||
.thenReturn(createAppSettingsWithKoboSettings());
|
||||
|
||||
KoboBookMetadata result = koboEntitlementService.getMetadataForBook(bookId, token);
|
||||
|
||||
assertNotNull(result);
|
||||
assertEquals("Test CBX Book", result.getTitle());
|
||||
verify(koboCompatibilityService).isBookSupportedForKobo(cbxBook);
|
||||
}
|
||||
|
||||
@Test
|
||||
void mapToKoboMetadata_cbxBookWithConversionEnabled_shouldReturnEpubFormat() {
|
||||
long bookId = 1L;
|
||||
BookEntity cbxBook = createCbxBookEntity(bookId);
|
||||
String token = "test-token";
|
||||
|
||||
when(bookQueryService.findAllWithMetadataByIds(Set.of(bookId)))
|
||||
.thenReturn(List.of(cbxBook));
|
||||
when(koboCompatibilityService.isBookSupportedForKobo(cbxBook))
|
||||
.thenReturn(true);
|
||||
when(koboUrlBuilder.downloadUrl(token, cbxBook.getId()))
|
||||
.thenReturn("http://test.com/download/" + cbxBook.getId());
|
||||
when(appSettingService.getAppSettings())
|
||||
.thenReturn(createAppSettingsWithKoboSettings());
|
||||
|
||||
KoboBookMetadata result = koboEntitlementService.getMetadataForBook(bookId, token);
|
||||
|
||||
assertNotNull(result);
|
||||
assertEquals(1, result.getDownloadUrls().size());
|
||||
assertEquals(KoboBookFormat.EPUB3.toString(), result.getDownloadUrls().get(0).getFormat());
|
||||
}
|
||||
|
||||
private BookEntity createCbxBookEntity(Long id) {
|
||||
BookEntity book = new BookEntity();
|
||||
book.setId(id);
|
||||
book.setBookType(BookFileType.CBX);
|
||||
book.setFileSizeKb(1024L);
|
||||
|
||||
BookMetadataEntity metadata = new BookMetadataEntity();
|
||||
metadata.setTitle("Test CBX Book");
|
||||
metadata.setDescription("A test CBX comic book");
|
||||
metadata.setBookId(id);
|
||||
book.setMetadata(metadata);
|
||||
|
||||
return book;
|
||||
}
|
||||
|
||||
private com.adityachandel.booklore.model.dto.settings.AppSettings createAppSettingsWithKoboSettings() {
|
||||
var appSettings = new com.adityachandel.booklore.model.dto.settings.AppSettings();
|
||||
KoboSettings koboSettings = KoboSettings.builder()
|
||||
.convertCbxToEpub(true)
|
||||
.conversionLimitInMbForCbx(50)
|
||||
.convertToKepub(false)
|
||||
.conversionLimitInMb(50)
|
||||
.build();
|
||||
appSettings.setKoboSettings(koboSettings);
|
||||
return appSettings;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
package com.adityachandel.booklore.service.kobo;
|
||||
|
||||
import com.adityachandel.booklore.model.entity.BookEntity;
|
||||
import com.adityachandel.booklore.model.entity.BookMetadataEntity;
|
||||
import freemarker.template.TemplateException;
|
||||
import com.github.junrar.exception.RarException;
|
||||
import org.apache.commons.compress.archivers.zip.ZipArchiveEntry;
|
||||
import org.apache.commons.compress.archivers.zip.ZipArchiveOutputStream;
|
||||
import org.apache.commons.compress.archivers.zip.ZipFile;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
import javax.imageio.ImageIO;
|
||||
import java.awt.*;
|
||||
import java.awt.image.BufferedImage;
|
||||
import java.io.*;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
|
||||
import static org.assertj.core.api.Assertions.*;
|
||||
|
||||
@DisplayName("CBX Conversion Integration Test")
|
||||
class CbxConversionIntegrationTest {
|
||||
|
||||
@TempDir
|
||||
Path tempDir;
|
||||
|
||||
private CbxConversionService conversionService;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
conversionService = new CbxConversionService();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should successfully convert CBZ to EPUB with valid structure")
|
||||
void convertCbzToEpub_MainConversionTest() throws IOException, TemplateException, RarException {
|
||||
File testCbzFile = createTestComicCbzFile();
|
||||
BookEntity bookMetadata = createTestBookMetadata();
|
||||
|
||||
File epubFile = conversionService.convertCbxToEpub(testCbzFile, tempDir.toFile(), bookMetadata);
|
||||
|
||||
assertThat(epubFile)
|
||||
.exists()
|
||||
.hasExtension("epub");
|
||||
|
||||
assertThat(epubFile.length())
|
||||
.as("EPUB file should not be empty")
|
||||
.isGreaterThan(0);
|
||||
|
||||
verifyEpubContents(epubFile, bookMetadata);
|
||||
}
|
||||
|
||||
private File createTestComicCbzFile() throws IOException {
|
||||
File cbzFile = Files.createFile(tempDir.resolve("test-comic.cbz")).toFile();
|
||||
|
||||
try (ZipArchiveOutputStream zipOut = new ZipArchiveOutputStream(new FileOutputStream(cbzFile))) {
|
||||
String[] pageNames = {"cover.jpg", "page01.png", "page02.jpg"};
|
||||
Color[] colors = {Color.RED, Color.GREEN, Color.BLUE};
|
||||
|
||||
for (int i = 0; i < 3; i++) {
|
||||
BufferedImage pageImage = createComicPageImage(400, 600, colors[i], "Page " + (i + 1));
|
||||
|
||||
ZipArchiveEntry imageEntry = new ZipArchiveEntry(pageNames[i]);
|
||||
zipOut.putArchiveEntry(imageEntry);
|
||||
|
||||
String format = pageNames[i].endsWith(".png") ? "png" : "jpg";
|
||||
ImageIO.write(pageImage, format, zipOut);
|
||||
zipOut.closeArchiveEntry();
|
||||
}
|
||||
}
|
||||
|
||||
return cbzFile;
|
||||
}
|
||||
|
||||
private BufferedImage createComicPageImage(int width, int height, Color bgColor, String text) {
|
||||
BufferedImage image = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB);
|
||||
Graphics2D g2d = image.createGraphics();
|
||||
|
||||
g2d.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
|
||||
g2d.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_ON);
|
||||
|
||||
g2d.setColor(bgColor);
|
||||
g2d.fillRect(0, 0, width, height);
|
||||
|
||||
g2d.setColor(Color.BLACK);
|
||||
g2d.setStroke(new BasicStroke(3));
|
||||
g2d.drawRect(10, 10, width - 20, height - 20);
|
||||
|
||||
g2d.setFont(new Font("Arial", Font.BOLD, 24));
|
||||
FontMetrics fm = g2d.getFontMetrics();
|
||||
int textWidth = fm.stringWidth(text);
|
||||
int textHeight = fm.getHeight();
|
||||
|
||||
g2d.drawString(text, (width - textWidth) / 2, (height + textHeight) / 2);
|
||||
|
||||
g2d.dispose();
|
||||
return image;
|
||||
}
|
||||
|
||||
private BookEntity createTestBookMetadata() {
|
||||
BookEntity book = new BookEntity();
|
||||
BookMetadataEntity metadata = new BookMetadataEntity();
|
||||
|
||||
metadata.setTitle("Amazing Comic Adventures");
|
||||
metadata.setLanguage("en");
|
||||
|
||||
book.setMetadata(metadata);
|
||||
return book;
|
||||
}
|
||||
|
||||
private void verifyEpubContents(File epubFile, BookEntity expectedMetadata) throws IOException {
|
||||
try (ZipFile zipFile = ZipFile.builder().setFile(epubFile).get()) {
|
||||
List<String> entryNames = Collections.list(zipFile.getEntries())
|
||||
.stream()
|
||||
.map(ZipArchiveEntry::getName)
|
||||
.toList();
|
||||
|
||||
assertThat(entryNames)
|
||||
.as("EPUB should contain required structure files")
|
||||
.contains(
|
||||
"mimetype",
|
||||
"META-INF/container.xml",
|
||||
"OEBPS/content.opf",
|
||||
"OEBPS/toc.ncx",
|
||||
"OEBPS/nav.xhtml",
|
||||
"OEBPS/Styles/stylesheet.css"
|
||||
);
|
||||
|
||||
verifyMimetypeEntry(zipFile);
|
||||
|
||||
long imageCount = entryNames.stream()
|
||||
.filter(name -> name.startsWith("OEBPS/Images/") && name.endsWith(".jpg"))
|
||||
.count();
|
||||
|
||||
long pageCount = entryNames.stream()
|
||||
.filter(name -> name.startsWith("OEBPS/Text/") && name.endsWith(".xhtml"))
|
||||
.count();
|
||||
|
||||
// Note: The EPUB contains 4 images because the conversion service duplicates the first image:
|
||||
// once as 'cover.jpg' (for the cover, referenced in the manifest but not in the spine)
|
||||
// and once as 'page-0001.jpg' (for the first comic page). Only 3 HTML pages are created,
|
||||
// one for each comic page image, since the cover image is not given its own HTML page.
|
||||
assertThat(imageCount)
|
||||
.as("Should have converted all comic pages to images")
|
||||
.isEqualTo(4);
|
||||
|
||||
assertThat(pageCount)
|
||||
.as("Should have created HTML page for each image (excluding cover)")
|
||||
.isEqualTo(3);
|
||||
|
||||
verifyContentOpf(zipFile, expectedMetadata);
|
||||
}
|
||||
}
|
||||
|
||||
private void verifyMimetypeEntry(ZipFile zipFile) throws IOException {
|
||||
ZipArchiveEntry mimetypeEntry = zipFile.getEntry("mimetype");
|
||||
|
||||
assertThat(mimetypeEntry)
|
||||
.as("Mimetype entry should exist")
|
||||
.isNotNull();
|
||||
|
||||
assertThat(mimetypeEntry.getMethod())
|
||||
.as("Mimetype should be stored uncompressed")
|
||||
.isEqualTo(ZipArchiveEntry.STORED);
|
||||
|
||||
try (InputStream stream = zipFile.getInputStream(mimetypeEntry)) {
|
||||
String content = new String(stream.readAllBytes());
|
||||
assertThat(content)
|
||||
.as("Mimetype content should be correct")
|
||||
.isEqualTo("application/epub+zip");
|
||||
}
|
||||
}
|
||||
|
||||
private void verifyContentOpf(ZipFile zipFile, BookEntity expectedMetadata) throws IOException {
|
||||
ZipArchiveEntry contentOpfEntry = zipFile.getEntry("OEBPS/content.opf");
|
||||
|
||||
assertThat(contentOpfEntry)
|
||||
.as("Content.opf should exist")
|
||||
.isNotNull();
|
||||
|
||||
try (InputStream stream = zipFile.getInputStream(contentOpfEntry)) {
|
||||
String content = new String(stream.readAllBytes());
|
||||
|
||||
assertThat(content)
|
||||
.as("Content.opf should contain book title")
|
||||
.contains(expectedMetadata.getMetadata().getTitle());
|
||||
|
||||
assertThat(content)
|
||||
.as("Content.opf should contain language")
|
||||
.contains(expectedMetadata.getMetadata().getLanguage());
|
||||
|
||||
assertThat(content)
|
||||
.as("Content.opf should reference image files")
|
||||
.contains("media-type=\"image/jpeg\"");
|
||||
|
||||
assertThat(content)
|
||||
.as("Content.opf should reference HTML pages")
|
||||
.contains("media-type=\"application/xhtml+xml\"");
|
||||
|
||||
assertThat(content)
|
||||
.as("Content.opf should have spine entries")
|
||||
.contains("<spine")
|
||||
.contains("itemref");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,262 @@
|
||||
package com.adityachandel.booklore.service.kobo;
|
||||
|
||||
import com.adityachandel.booklore.model.entity.BookEntity;
|
||||
import com.adityachandel.booklore.model.entity.BookMetadataEntity;
|
||||
import freemarker.template.TemplateException;
|
||||
import com.github.junrar.exception.RarException;
|
||||
import org.apache.commons.compress.archivers.zip.ZipArchiveEntry;
|
||||
import org.apache.commons.compress.archivers.zip.ZipArchiveOutputStream;
|
||||
import org.apache.commons.compress.archivers.zip.ZipFile;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
import javax.imageio.ImageIO;
|
||||
import java.awt.*;
|
||||
import java.awt.image.BufferedImage;
|
||||
import java.io.*;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
|
||||
import static org.assertj.core.api.Assertions.*;
|
||||
|
||||
@DisplayName("CBX Conversion Service Tests")
|
||||
class CbxConversionServiceTest {
|
||||
|
||||
@TempDir
|
||||
Path tempDir;
|
||||
|
||||
private CbxConversionService cbxConversionService;
|
||||
private File testCbzFile;
|
||||
private BookEntity testBookEntity;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() throws IOException {
|
||||
cbxConversionService = new CbxConversionService();
|
||||
testCbzFile = createTestCbzFile();
|
||||
testBookEntity = createTestBookEntity();
|
||||
}
|
||||
|
||||
@Test
|
||||
void convertCbxToEpub_WithValidCbzFile_ShouldGenerateValidEpub() throws IOException, TemplateException, RarException {
|
||||
File epubFile = cbxConversionService.convertCbxToEpub(testCbzFile, tempDir.toFile(), testBookEntity);
|
||||
|
||||
assertThat(epubFile).exists();
|
||||
assertThat(epubFile.getName()).endsWith(".epub");
|
||||
assertThat(epubFile.length()).isGreaterThan(0);
|
||||
|
||||
verifyEpubStructure(epubFile);
|
||||
}
|
||||
|
||||
@Test
|
||||
void convertCbxToEpub_WithNullCbxFile_ShouldThrowException() {
|
||||
assertThatThrownBy(() -> cbxConversionService.convertCbxToEpub(null, tempDir.toFile(), testBookEntity))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("Invalid CBX file");
|
||||
}
|
||||
|
||||
@Test
|
||||
void convertCbxToEpub_WithNonExistentFile_ShouldThrowException() {
|
||||
File nonExistentFile = new File(tempDir.toFile(), "non-existent.cbz");
|
||||
|
||||
assertThatThrownBy(() -> cbxConversionService.convertCbxToEpub(nonExistentFile, tempDir.toFile(), testBookEntity))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("Invalid CBX file");
|
||||
}
|
||||
|
||||
@Test
|
||||
void convertCbxToEpub_WithUnsupportedFileFormat_ShouldThrowException() throws IOException {
|
||||
File unsupportedFile = Files.createFile(tempDir.resolve("test.txt")).toFile();
|
||||
|
||||
assertThatThrownBy(() -> cbxConversionService.convertCbxToEpub(unsupportedFile, tempDir.toFile(), testBookEntity))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("Unsupported file format");
|
||||
}
|
||||
|
||||
@Test
|
||||
void convertCbxToEpub_WithNullTempDir_ShouldThrowException() {
|
||||
assertThatThrownBy(() -> cbxConversionService.convertCbxToEpub(testCbzFile, null, testBookEntity))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("Invalid temp directory");
|
||||
}
|
||||
|
||||
@Test
|
||||
void convertCbxToEpub_WithEmptyCbzFile_ShouldThrowException() throws IOException {
|
||||
File emptyCbzFile = createEmptyCbzFile();
|
||||
|
||||
assertThatThrownBy(() -> cbxConversionService.convertCbxToEpub(emptyCbzFile, tempDir.toFile(), testBookEntity))
|
||||
.isInstanceOf(IllegalStateException.class)
|
||||
.hasMessageContaining("No valid images found");
|
||||
}
|
||||
|
||||
@Test
|
||||
void isSupportedCbxFormat_WithSupportedFiles_ShouldReturnTrue() {
|
||||
assertThat(cbxConversionService.isSupportedCbxFormat("test.cbz")).isTrue();
|
||||
assertThat(cbxConversionService.isSupportedCbxFormat("test.CBZ")).isTrue();
|
||||
assertThat(cbxConversionService.isSupportedCbxFormat("path/to/test.cbz")).isTrue();
|
||||
assertThat(cbxConversionService.isSupportedCbxFormat("test.cbr")).isTrue();
|
||||
assertThat(cbxConversionService.isSupportedCbxFormat("test.CBR")).isTrue();
|
||||
assertThat(cbxConversionService.isSupportedCbxFormat("path/to/test.cbr")).isTrue();
|
||||
assertThat(cbxConversionService.isSupportedCbxFormat("test.cb7")).isTrue();
|
||||
assertThat(cbxConversionService.isSupportedCbxFormat("test.CB7")).isTrue();
|
||||
assertThat(cbxConversionService.isSupportedCbxFormat("path/to/test.cb7")).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void isSupportedCbxFormat_WithUnsupportedFormats_ShouldReturnFalse() {
|
||||
assertThat(cbxConversionService.isSupportedCbxFormat("test.zip")).isFalse();
|
||||
assertThat(cbxConversionService.isSupportedCbxFormat("test.pdf")).isFalse();
|
||||
assertThat(cbxConversionService.isSupportedCbxFormat("test.epub")).isFalse();
|
||||
assertThat(cbxConversionService.isSupportedCbxFormat("test.txt")).isFalse();
|
||||
assertThat(cbxConversionService.isSupportedCbxFormat("test")).isFalse();
|
||||
assertThat(cbxConversionService.isSupportedCbxFormat(null)).isFalse();
|
||||
assertThat(cbxConversionService.isSupportedCbxFormat("")).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void convertCbxToEpub_WithNullBookEntity_ShouldUseDefaultMetadata() throws IOException, TemplateException, RarException {
|
||||
File epubFile = cbxConversionService.convertCbxToEpub(testCbzFile, tempDir.toFile(), null);
|
||||
|
||||
assertThat(epubFile).exists();
|
||||
verifyEpubStructure(epubFile);
|
||||
}
|
||||
|
||||
@Test
|
||||
void convertCbxToEpub_WithMultipleImages_ShouldPreservePageOrder() throws IOException, TemplateException, RarException {
|
||||
File multiPageCbzFile = createMultiPageCbzFile();
|
||||
|
||||
File epubFile = cbxConversionService.convertCbxToEpub(multiPageCbzFile, tempDir.toFile(), testBookEntity);
|
||||
|
||||
assertThat(epubFile).exists();
|
||||
verifyPageOrderInEpub(epubFile, 5);
|
||||
}
|
||||
|
||||
private File createTestCbzFile() throws IOException {
|
||||
File cbzFile = Files.createFile(tempDir.resolve("test-comic.cbz")).toFile();
|
||||
|
||||
try (ZipArchiveOutputStream zipOut = new ZipArchiveOutputStream(new FileOutputStream(cbzFile))) {
|
||||
BufferedImage testImage = createTestImage("Page 1", Color.RED);
|
||||
|
||||
ZipArchiveEntry imageEntry = new ZipArchiveEntry("page01.png");
|
||||
zipOut.putArchiveEntry(imageEntry);
|
||||
ImageIO.write(testImage, "png", zipOut);
|
||||
zipOut.closeArchiveEntry();
|
||||
}
|
||||
|
||||
return cbzFile;
|
||||
}
|
||||
|
||||
private File createEmptyCbzFile() throws IOException {
|
||||
File cbzFile = Files.createFile(tempDir.resolve("empty-comic.cbz")).toFile();
|
||||
|
||||
try (ZipArchiveOutputStream zipOut = new ZipArchiveOutputStream(new FileOutputStream(cbzFile))) {
|
||||
ZipArchiveEntry textEntry = new ZipArchiveEntry("readme.txt");
|
||||
zipOut.putArchiveEntry(textEntry);
|
||||
zipOut.write("This is not an image".getBytes());
|
||||
zipOut.closeArchiveEntry();
|
||||
}
|
||||
|
||||
return cbzFile;
|
||||
}
|
||||
|
||||
private File createMultiPageCbzFile() throws IOException {
|
||||
File cbzFile = Files.createFile(tempDir.resolve("multi-page-comic.cbz")).toFile();
|
||||
|
||||
try (ZipArchiveOutputStream zipOut = new ZipArchiveOutputStream(new FileOutputStream(cbzFile))) {
|
||||
Color[] colors = {Color.RED, Color.GREEN, Color.BLUE, Color.YELLOW, Color.MAGENTA};
|
||||
|
||||
for (int i = 0; i < 5; i++) {
|
||||
BufferedImage testImage = createTestImage("Page " + (i + 1), colors[i]);
|
||||
|
||||
ZipArchiveEntry imageEntry = new ZipArchiveEntry(String.format("page%02d.png", i + 1));
|
||||
zipOut.putArchiveEntry(imageEntry);
|
||||
ImageIO.write(testImage, "png", zipOut);
|
||||
zipOut.closeArchiveEntry();
|
||||
}
|
||||
}
|
||||
|
||||
return cbzFile;
|
||||
}
|
||||
|
||||
private BufferedImage createTestImage(String text, Color backgroundColor) {
|
||||
BufferedImage image = new BufferedImage(200, 300, BufferedImage.TYPE_INT_RGB);
|
||||
Graphics2D g2d = image.createGraphics();
|
||||
|
||||
g2d.setColor(backgroundColor);
|
||||
g2d.fillRect(0, 0, 200, 300);
|
||||
|
||||
g2d.setColor(Color.BLACK);
|
||||
g2d.setFont(new Font("Arial", Font.BOLD, 16));
|
||||
g2d.drawString(text, 50, 150);
|
||||
|
||||
g2d.dispose();
|
||||
return image;
|
||||
}
|
||||
|
||||
private BookEntity createTestBookEntity() {
|
||||
BookEntity bookEntity = new BookEntity();
|
||||
BookMetadataEntity metadata = new BookMetadataEntity();
|
||||
metadata.setTitle("Test Comic Book");
|
||||
metadata.setLanguage("en");
|
||||
bookEntity.setMetadata(metadata);
|
||||
return bookEntity;
|
||||
}
|
||||
|
||||
private void verifyEpubStructure(File epubFile) throws IOException {
|
||||
try (ZipFile zipFile = ZipFile.builder().setFile(epubFile).get()) {
|
||||
List<ZipArchiveEntry> entries = Collections.list(zipFile.getEntries());
|
||||
|
||||
assertThat(entries).extracting(ZipArchiveEntry::getName)
|
||||
.contains(
|
||||
"mimetype",
|
||||
"META-INF/container.xml",
|
||||
"OEBPS/content.opf",
|
||||
"OEBPS/toc.ncx",
|
||||
"OEBPS/nav.xhtml",
|
||||
"OEBPS/Styles/stylesheet.css"
|
||||
);
|
||||
|
||||
ZipArchiveEntry mimetypeEntry = zipFile.getEntry("mimetype");
|
||||
assertThat(mimetypeEntry).isNotNull();
|
||||
assertThat(mimetypeEntry.getMethod()).isEqualTo(ZipArchiveEntry.STORED);
|
||||
|
||||
try (InputStream mimetypeStream = zipFile.getInputStream(mimetypeEntry)) {
|
||||
String mimetypeContent = new String(mimetypeStream.readAllBytes());
|
||||
assertThat(mimetypeContent).isEqualTo("application/epub+zip");
|
||||
}
|
||||
|
||||
assertThat(entries).anyMatch(entry -> entry.getName().startsWith("OEBPS/Images/"));
|
||||
assertThat(entries).anyMatch(entry -> entry.getName().startsWith("OEBPS/Text/"));
|
||||
assertThat(entries).anyMatch(entry -> entry.getName().endsWith(".jpg"));
|
||||
assertThat(entries).anyMatch(entry -> entry.getName().endsWith(".xhtml"));
|
||||
}
|
||||
}
|
||||
|
||||
private void verifyPageOrderInEpub(File epubFile, int expectedPageCount) throws IOException {
|
||||
try (ZipFile zipFile = ZipFile.builder().setFile(epubFile).get()) {
|
||||
List<ZipArchiveEntry> imageEntries = Collections.list(zipFile.getEntries()).stream()
|
||||
.filter(entry -> entry.getName().startsWith("OEBPS/Images/page-"))
|
||||
.sorted((e1, e2) -> e1.getName().compareTo(e2.getName()))
|
||||
.toList();
|
||||
|
||||
List<ZipArchiveEntry> htmlEntries = Collections.list(zipFile.getEntries()).stream()
|
||||
.filter(entry -> entry.getName().startsWith("OEBPS/Text/page-"))
|
||||
.sorted((e1, e2) -> e1.getName().compareTo(e2.getName()))
|
||||
.toList();
|
||||
|
||||
assertThat(imageEntries).hasSize(expectedPageCount);
|
||||
assertThat(htmlEntries).hasSize(expectedPageCount);
|
||||
|
||||
for (int i = 0; i < expectedPageCount; i++) {
|
||||
String expectedImageName = String.format("OEBPS/Images/page-%04d.jpg", i + 1);
|
||||
String expectedHtmlName = String.format("OEBPS/Text/page-%04d.xhtml", i + 1);
|
||||
|
||||
assertThat(imageEntries.get(i).getName()).isEqualTo(expectedImageName);
|
||||
assertThat(htmlEntries.get(i).getName()).isEqualTo(expectedHtmlName);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,296 @@
|
||||
package com.adityachandel.booklore.service.kobo;
|
||||
|
||||
import com.adityachandel.booklore.model.dto.settings.AppSettings;
|
||||
import com.adityachandel.booklore.model.dto.settings.KoboSettings;
|
||||
import com.adityachandel.booklore.model.entity.BookEntity;
|
||||
import com.adityachandel.booklore.model.enums.BookFileType;
|
||||
import com.adityachandel.booklore.service.appsettings.AppSettingService;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
|
||||
import static org.assertj.core.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
@DisplayName("Kobo Compatibility Service Tests")
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class KoboCompatibilityServiceTest {
|
||||
|
||||
@Mock
|
||||
private AppSettingService appSettingService;
|
||||
|
||||
@InjectMocks
|
||||
private KoboCompatibilityService koboCompatibilityService;
|
||||
|
||||
private AppSettings appSettings;
|
||||
private KoboSettings koboSettings;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
koboSettings = KoboSettings.builder()
|
||||
.convertToKepub(false)
|
||||
.conversionLimitInMb(100)
|
||||
.convertCbxToEpub(false)
|
||||
.conversionLimitInMbForCbx(50)
|
||||
.build();
|
||||
|
||||
appSettings = new AppSettings();
|
||||
appSettings.setKoboSettings(koboSettings);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should always support EPUB files regardless of settings")
|
||||
void shouldAlwaysSupportEpubFiles() {
|
||||
BookEntity epubBook = createBookEntity(1L, BookFileType.EPUB, 1000L);
|
||||
|
||||
boolean isSupported = koboCompatibilityService.isBookSupportedForKobo(epubBook);
|
||||
|
||||
assertThat(isSupported).isTrue();
|
||||
verifyNoInteractions(appSettingService);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should support CBX files when CBX conversion is enabled and within size limit")
|
||||
void shouldSupportCbxWhenConversionEnabledAndWithinSizeLimit() {
|
||||
BookEntity cbxBook = createBookEntity(1L, BookFileType.CBX, 1000L);
|
||||
koboSettings.setConvertCbxToEpub(true);
|
||||
koboSettings.setConversionLimitInMbForCbx(50);
|
||||
when(appSettingService.getAppSettings()).thenReturn(appSettings);
|
||||
|
||||
boolean isSupported = koboCompatibilityService.isBookSupportedForKobo(cbxBook);
|
||||
|
||||
assertThat(isSupported).isTrue();
|
||||
verify(appSettingService, atLeastOnce()).getAppSettings();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should not support CBX files when CBX conversion is disabled")
|
||||
void shouldNotSupportCbxWhenConversionDisabled() {
|
||||
BookEntity cbxBook = createBookEntity(1L, BookFileType.CBX, 1000L);
|
||||
koboSettings.setConvertCbxToEpub(false);
|
||||
when(appSettingService.getAppSettings()).thenReturn(appSettings);
|
||||
|
||||
boolean isSupported = koboCompatibilityService.isBookSupportedForKobo(cbxBook);
|
||||
|
||||
assertThat(isSupported).isFalse();
|
||||
verify(appSettingService).getAppSettings();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should not support CBX files when they exceed the size limit")
|
||||
void shouldNotSupportCbxWhenExceedsSizeLimit() {
|
||||
BookEntity largeCbxBook = createBookEntity(1L, BookFileType.CBX, 75_000L);
|
||||
koboSettings.setConvertCbxToEpub(true);
|
||||
koboSettings.setConversionLimitInMbForCbx(50);
|
||||
when(appSettingService.getAppSettings()).thenReturn(appSettings);
|
||||
|
||||
boolean isSupported = koboCompatibilityService.isBookSupportedForKobo(largeCbxBook);
|
||||
|
||||
assertThat(isSupported).isFalse();
|
||||
verify(appSettingService, atLeastOnce()).getAppSettings();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should support CBX files that exactly meet the size limit")
|
||||
void shouldSupportCbxAtExactSizeLimit() {
|
||||
BookEntity cbxBookAtLimit = createBookEntity(1L, BookFileType.CBX, 51_200L);
|
||||
koboSettings.setConvertCbxToEpub(true);
|
||||
koboSettings.setConversionLimitInMbForCbx(50);
|
||||
when(appSettingService.getAppSettings()).thenReturn(appSettings);
|
||||
|
||||
boolean isSupported = koboCompatibilityService.isBookSupportedForKobo(cbxBookAtLimit);
|
||||
|
||||
|
||||
assertThat(isSupported).isTrue();
|
||||
verify(appSettingService, atLeastOnce()).getAppSettings();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should not support CBX files when size limit check fails due to settings error")
|
||||
void shouldNotSupportCbxWhenSizeLimitCheckFails() {
|
||||
|
||||
BookEntity cbxBook = createBookEntity(1L, BookFileType.CBX, 1000L);
|
||||
koboSettings.setConvertCbxToEpub(true);
|
||||
// First call succeeds for conversion check, second call fails for size check
|
||||
when(appSettingService.getAppSettings())
|
||||
.thenReturn(appSettings)
|
||||
.thenThrow(new RuntimeException("Settings error"));
|
||||
|
||||
|
||||
boolean isSupported = koboCompatibilityService.isBookSupportedForKobo(cbxBook);
|
||||
|
||||
|
||||
assertThat(isSupported).isFalse();
|
||||
verify(appSettingService, atLeastOnce()).getAppSettings();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should not support CBX files when Kobo settings are null")
|
||||
void shouldNotSupportCbxWhenSettingsAreNull() {
|
||||
|
||||
BookEntity cbxBook = createBookEntity(1L, BookFileType.CBX, 1000L);
|
||||
appSettings.setKoboSettings(null);
|
||||
when(appSettingService.getAppSettings()).thenReturn(appSettings);
|
||||
|
||||
|
||||
boolean isSupported = koboCompatibilityService.isBookSupportedForKobo(cbxBook);
|
||||
|
||||
|
||||
assertThat(isSupported).isFalse();
|
||||
verify(appSettingService, atLeastOnce()).getAppSettings();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should not support PDF files")
|
||||
void shouldNotSupportPdfFiles() {
|
||||
|
||||
BookEntity pdfBook = createBookEntity(1L, BookFileType.PDF, 1000L);
|
||||
|
||||
|
||||
boolean isSupported = koboCompatibilityService.isBookSupportedForKobo(pdfBook);
|
||||
|
||||
|
||||
assertThat(isSupported).isFalse();
|
||||
verifyNoInteractions(appSettingService);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should handle null book gracefully")
|
||||
void shouldHandleNullBookGracefully() {
|
||||
assertThatThrownBy(() -> koboCompatibilityService.isBookSupportedForKobo(null))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessage("Book cannot be null");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should handle null book type gracefully")
|
||||
void shouldHandleNullBookTypeGracefully() {
|
||||
|
||||
BookEntity bookWithNullType = new BookEntity();
|
||||
bookWithNullType.setId(1L);
|
||||
bookWithNullType.setBookType(null);
|
||||
|
||||
|
||||
boolean isSupported = koboCompatibilityService.isBookSupportedForKobo(bookWithNullType);
|
||||
|
||||
|
||||
assertThat(isSupported).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should return true when CBX conversion is enabled")
|
||||
void shouldReturnTrueWhenCbxConversionEnabled() {
|
||||
|
||||
koboSettings.setConvertCbxToEpub(true);
|
||||
when(appSettingService.getAppSettings()).thenReturn(appSettings);
|
||||
|
||||
|
||||
boolean isEnabled = koboCompatibilityService.isCbxConversionEnabled();
|
||||
|
||||
|
||||
assertThat(isEnabled).isTrue();
|
||||
verify(appSettingService).getAppSettings();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should return false when CBX conversion is disabled")
|
||||
void shouldReturnFalseWhenCbxConversionDisabled() {
|
||||
|
||||
koboSettings.setConvertCbxToEpub(false);
|
||||
when(appSettingService.getAppSettings()).thenReturn(appSettings);
|
||||
|
||||
|
||||
boolean isEnabled = koboCompatibilityService.isCbxConversionEnabled();
|
||||
|
||||
|
||||
assertThat(isEnabled).isFalse();
|
||||
verify(appSettingService).getAppSettings();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should handle settings exception gracefully")
|
||||
void shouldHandleSettingsExceptionGracefully() {
|
||||
|
||||
when(appSettingService.getAppSettings()).thenThrow(new RuntimeException("Settings error"));
|
||||
|
||||
|
||||
boolean isEnabled = koboCompatibilityService.isCbxConversionEnabled();
|
||||
|
||||
|
||||
assertThat(isEnabled).isFalse();
|
||||
verify(appSettingService).getAppSettings();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should validate CBX file meets size limit")
|
||||
void shouldValidateCbxFileMeetsSizeLimit() {
|
||||
|
||||
BookEntity smallCbxBook = createBookEntity(1L, BookFileType.CBX, 1000L);
|
||||
koboSettings.setConversionLimitInMbForCbx(50);
|
||||
when(appSettingService.getAppSettings()).thenReturn(appSettings);
|
||||
|
||||
|
||||
boolean meetsLimit = koboCompatibilityService.meetsCbxConversionSizeLimit(smallCbxBook);
|
||||
|
||||
|
||||
assertThat(meetsLimit).isTrue();
|
||||
verify(appSettingService).getAppSettings();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should validate CBX file exceeds size limit")
|
||||
void shouldValidateCbxFileExceedsSizeLimit() {
|
||||
|
||||
BookEntity largeCbxBook = createBookEntity(1L, BookFileType.CBX, 100_000L);
|
||||
koboSettings.setConversionLimitInMbForCbx(50);
|
||||
when(appSettingService.getAppSettings()).thenReturn(appSettings);
|
||||
|
||||
|
||||
boolean meetsLimit = koboCompatibilityService.meetsCbxConversionSizeLimit(largeCbxBook);
|
||||
|
||||
|
||||
assertThat(meetsLimit).isFalse();
|
||||
verify(appSettingService).getAppSettings();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should return false for size limit check on non-CBX book")
|
||||
void shouldReturnFalseForSizeLimitCheckOnNonCbxBook() {
|
||||
|
||||
BookEntity epubBook = createBookEntity(1L, BookFileType.EPUB, 1000L);
|
||||
|
||||
|
||||
boolean meetsLimit = koboCompatibilityService.meetsCbxConversionSizeLimit(epubBook);
|
||||
|
||||
|
||||
assertThat(meetsLimit).isFalse();
|
||||
verifyNoInteractions(appSettingService);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should handle null file size gracefully")
|
||||
void shouldHandleNullFileSizeGracefully() {
|
||||
|
||||
BookEntity cbxBook = createBookEntity(1L, BookFileType.CBX, null);
|
||||
when(appSettingService.getAppSettings()).thenReturn(appSettings);
|
||||
|
||||
|
||||
boolean meetsLimit = koboCompatibilityService.meetsCbxConversionSizeLimit(cbxBook);
|
||||
|
||||
|
||||
assertThat(meetsLimit).isTrue();
|
||||
verify(appSettingService).getAppSettings();
|
||||
}
|
||||
|
||||
private BookEntity createBookEntity(Long id, BookFileType bookType, Long fileSizeKb) {
|
||||
BookEntity book = new BookEntity();
|
||||
book.setId(id);
|
||||
book.setBookType(bookType);
|
||||
book.setFileSizeKb(fileSizeKb);
|
||||
return book;
|
||||
}
|
||||
}
|
||||
@@ -225,6 +225,46 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p-divider></p-divider>
|
||||
|
||||
<div class="setting-item">
|
||||
<div class="setting-info">
|
||||
<div class="setting-label-row">
|
||||
<label class="setting-label">Convert CBX to EPUB</label>
|
||||
<p-toggle-switch
|
||||
id="convertCbxToEpub"
|
||||
[(ngModel)]="koboSettings.convertCbxToEpub"
|
||||
(ngModelChange)="onToggleChange()">
|
||||
</p-toggle-switch>
|
||||
</div>
|
||||
<p class="setting-description">
|
||||
Converts CBX files to EPUB during Kobo sync, allowing CBX files to be downloaded and read on Kobo devices.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="setting-item">
|
||||
<div class="setting-info">
|
||||
<div class="setting-label-row">
|
||||
<label class="setting-label">CBX Conversion Size Limit: {{ koboSettings.conversionLimitInMbForCbx }} MB</label>
|
||||
<div class="slider-container">
|
||||
<p-slider
|
||||
id="conversionLimitForCbx"
|
||||
[(ngModel)]="koboSettings.conversionLimitInMbForCbx"
|
||||
[min]="1"
|
||||
[max]="500"
|
||||
[step]="1"
|
||||
(ngModelChange)="onSliderChange()">
|
||||
</p-slider>
|
||||
</div>
|
||||
</div>
|
||||
<p class="setting-description">
|
||||
Comic book archives can be very large due to high-resolution images. Set a reasonable limit to prevent server overload during conversion.
|
||||
<br>
|
||||
<strong>Recommended:</strong> 100-200MB for most comic libraries. CBX files over this limit will not be converted or synced to the device.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import {Subject} from 'rxjs';
|
||||
import {debounceTime, filter, take, takeUntil} from 'rxjs/operators';
|
||||
import {ToggleSwitch} from 'primeng/toggleswitch';
|
||||
import {Slider} from 'primeng/slider';
|
||||
import {Divider} from 'primeng/divider';
|
||||
import {AppSettingsService} from '../../../../../shared/service/app-settings.service';
|
||||
import {SettingsHelperService} from '../../../../../shared/service/settings-helper.service';
|
||||
import {AppSettingKey, KoboSettings} from '../../../../../shared/model/app-settings.model';
|
||||
@@ -22,7 +23,7 @@ import {ExternalDocLinkComponent} from '../../../../../shared/components/externa
|
||||
standalone: true,
|
||||
templateUrl: './kobo-sync-settings-component.html',
|
||||
styleUrl: './kobo-sync-settings-component.scss',
|
||||
imports: [FormsModule, Button, InputText, ConfirmDialog, ToggleSwitch, Slider, ExternalDocLinkComponent],
|
||||
imports: [FormsModule, Button, InputText, ConfirmDialog, ToggleSwitch, Slider, Divider, ExternalDocLinkComponent],
|
||||
providers: [MessageService, ConfirmationService]
|
||||
})
|
||||
export class KoboSyncSettingsComponent implements OnInit, OnDestroy {
|
||||
@@ -46,6 +47,8 @@ export class KoboSyncSettingsComponent implements OnInit, OnDestroy {
|
||||
koboSettings: KoboSettings = {
|
||||
convertToKepub: false,
|
||||
conversionLimitInMb: 100,
|
||||
convertCbxToEpub: false,
|
||||
conversionLimitInMbForCbx: 100,
|
||||
forceEnableHyphenation: false
|
||||
};
|
||||
|
||||
@@ -123,6 +126,8 @@ export class KoboSyncSettingsComponent implements OnInit, OnDestroy {
|
||||
.subscribe(settings => {
|
||||
this.koboSettings.convertToKepub = settings?.koboSettings?.convertToKepub ?? true;
|
||||
this.koboSettings.conversionLimitInMb = settings?.koboSettings?.conversionLimitInMb ?? 100;
|
||||
this.koboSettings.convertCbxToEpub = settings?.koboSettings?.convertCbxToEpub ?? false;
|
||||
this.koboSettings.conversionLimitInMbForCbx = settings?.koboSettings?.conversionLimitInMbForCbx ?? 100;
|
||||
this.koboSettings.forceEnableHyphenation = settings?.koboSettings?.forceEnableHyphenation ?? false;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -102,6 +102,8 @@ export interface PublicReviewSettings {
|
||||
export interface KoboSettings {
|
||||
convertToKepub: boolean;
|
||||
conversionLimitInMb: number;
|
||||
convertCbxToEpub: boolean;
|
||||
conversionLimitInMbForCbx: number;
|
||||
forceEnableHyphenation: boolean;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user