diff --git a/booklore-api/build.gradle b/booklore-api/build.gradle
index 8dafdd65f..6457f4307 100644
--- a/booklore-api/build.gradle
+++ b/booklore-api/build.gradle
@@ -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'
diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/settings/KoboSettings.java b/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/settings/KoboSettings.java
index d98d5a992..f9b0e93fc 100644
--- a/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/settings/KoboSettings.java
+++ b/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/settings/KoboSettings.java
@@ -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;
}
diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/service/appsettings/SettingPersistenceHelper.java b/booklore-api/src/main/java/com/adityachandel/booklore/service/appsettings/SettingPersistenceHelper.java
index 30416db33..9548deee8 100644
--- a/booklore-api/src/main/java/com/adityachandel/booklore/service/appsettings/SettingPersistenceHelper.java
+++ b/booklore-api/src/main/java/com/adityachandel/booklore/service/appsettings/SettingPersistenceHelper.java
@@ -253,6 +253,8 @@ public class SettingPersistenceHelper {
return KoboSettings.builder()
.convertToKepub(false)
.conversionLimitInMb(100)
+ .convertCbxToEpub(false)
+ .conversionLimitInMbForCbx(100)
.forceEnableHyphenation(false)
.build();
}
diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/service/book/BookDownloadService.java b/booklore-api/src/main/java/com/adityachandel/booklore/service/book/BookDownloadService.java
index ac0b6630a..a5b935426 100644
--- a/booklore-api/src/main/java/com/adityachandel/booklore/service/book/BookDownloadService.java
+++ b/booklore-api/src/main/java/com/adityachandel/booklore/service/book/BookDownloadService.java
@@ -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 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());
}
diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/service/kobo/CbxConversionService.java b/booklore-api/src/main/java/com/adityachandel/booklore/service/kobo/CbxConversionService.java
new file mode 100644
index 000000000..6767fac6f
--- /dev/null
+++ b/booklore-api/src/main/java/com/adityachandel/booklore/service/kobo/CbxConversionService.java
@@ -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.
+ *
+ * This service supports the following comic book archive formats:
+ *
+ * CBZ - Comic book ZIP archive
+ * CBR - Comic book RAR archive
+ * CB7 - Comic book 7z archive
+ *
+ *
+ *
+ * Supported image formats within archives: JPG, JPEG, PNG, WEBP, GIF, BMP
+ *
+ *
+ * Size Limits
+ *
+ * Maximum individual image size: 50 MB
+ *
+ *
+ * @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.
+ *
+ * The conversion process:
+ *
+ * Extracts all images from the archive to a temporary directory
+ * Creates an EPUB structure with one XHTML page per image
+ * Includes proper EPUB metadata from the book entity
+ * JPEG images are passed through directly; other formats are converted to JPEG (85% quality)
+ *
+ *
+ *
+ * @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 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 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 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 extractImagesFromZip(File cbzFile, Path extractedImagesDir) throws IOException {
+ List 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 extractImagesFromRar(File cbrFile, Path extractedImagesDir) throws IOException, RarException {
+ List 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 extractImagesFrom7z(File cb7File, Path extractedImagesDir) throws IOException {
+ List 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 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 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 addImagesAndPages(ZipArchiveOutputStream zipOut, List imagePaths)
+ throws IOException, TemplateException {
+
+ List 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 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 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 contentGroups) throws IOException, TemplateException {
+
+ Map model = createBookMetadataModel(bookEntity);
+
+ List 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 contentGroups) throws IOException, TemplateException {
+
+ Map 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 contentGroups) throws IOException, TemplateException {
+
+ Map 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 createBookMetadataModel(BookEntity bookEntity) {
+ Map 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 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");
+ }
+
+}
diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/service/kobo/KoboCompatibilityService.java b/booklore-api/src/main/java/com/adityachandel/booklore/service/kobo/KoboCompatibilityService.java
new file mode 100644
index 000000000..1b5548083
--- /dev/null
+++ b/booklore-api/src/main/java/com/adityachandel/booklore/service/kobo/KoboCompatibilityService.java
@@ -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;
+ }
+ }
+}
\ No newline at end of file
diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/service/kobo/KoboEntitlementService.java b/booklore-api/src/main/java/com/adityachandel/booklore/service/kobo/KoboEntitlementService.java
index faf63b4c3..e8d02fe9f 100644
--- a/booklore-api/src/main/java/com/adityachandel/booklore/service/kobo/KoboEntitlementService.java
+++ b/booklore-api/src/main/java/com/adityachandel/booklore/service/kobo/KoboEntitlementService.java
@@ -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 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 generateChangedEntitlements(Set bookIds, String token, boolean removed) {
List 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 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()
diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/service/kobo/KoboLibrarySnapshotService.java b/booklore-api/src/main/java/com/adityachandel/booklore/service/kobo/KoboLibrarySnapshotService.java
index 7ba0b658a..64bf48941 100644
--- a/booklore-api/src/main/java/com/adityachandel/booklore/service/kobo/KoboLibrarySnapshotService.java
+++ b/booklore-api/src/main/java/com/adityachandel/booklore/service/kobo/KoboLibrarySnapshotService.java
@@ -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 findByIdAndUserId(String id, Long userId) {
@@ -118,7 +118,7 @@ public class KoboLibrarySnapshotService {
private List 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);
}
+
}
\ No newline at end of file
diff --git a/booklore-api/src/main/resources/application.yaml b/booklore-api/src/main/resources/application.yaml
index 7405db7f7..5f1280bf6 100644
--- a/booklore-api/src/main/resources/application.yaml
+++ b/booklore-api/src/main/resources/application.yaml
@@ -17,6 +17,8 @@ app:
server:
forward-headers-strategy: native
port: 8080
+ tomcat:
+ relaxed-query-chars: '[,],%,{,},|'
spring:
servlet:
diff --git a/booklore-api/src/main/resources/templates/epub/css/stylesheet.css b/booklore-api/src/main/resources/templates/epub/css/stylesheet.css
new file mode 100644
index 000000000..05aecab04
--- /dev/null
+++ b/booklore-api/src/main/resources/templates/epub/css/stylesheet.css
@@ -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;
+}
\ No newline at end of file
diff --git a/booklore-api/src/main/resources/templates/epub/xml/container.xml.ftl b/booklore-api/src/main/resources/templates/epub/xml/container.xml.ftl
new file mode 100644
index 000000000..282486161
--- /dev/null
+++ b/booklore-api/src/main/resources/templates/epub/xml/container.xml.ftl
@@ -0,0 +1,7 @@
+<#ftl output_format="XML" encoding="UTF-8">
+
+
+
+
+
+
\ No newline at end of file
diff --git a/booklore-api/src/main/resources/templates/epub/xml/content.opf.ftl b/booklore-api/src/main/resources/templates/epub/xml/content.opf.ftl
new file mode 100644
index 000000000..e9c88df16
--- /dev/null
+++ b/booklore-api/src/main/resources/templates/epub/xml/content.opf.ftl
@@ -0,0 +1,110 @@
+<#ftl output_format="XML" encoding="UTF-8">
+
+
+
+ ${title!""}
+ <#if subtitle?has_content>
+ main
+ 1
+ ${subtitle}
+ subtitle
+ 2
+ #if>
+
+ <#if authors?has_content>
+ <#list authors as author>
+ ${author}
+ aut
+ #list>
+ #if>
+
+ ${language!""}
+
+ <#if publisher?has_content>
+ ${publisher}
+ #if>
+
+ <#if publishedDate?has_content>
+ ${publishedDate}
+ #if>
+
+ <#if description?has_content>
+ ${description}
+ #if>
+
+ <#-- Series metadata -->
+ <#if seriesName?has_content>
+ ${seriesName}
+ series
+ <#if seriesNumber?has_content>
+ ${seriesNumber}
+ #if>
+ #if>
+
+ <#-- Identifiers -->
+ ${identifier!""}
+ <#if isbn13?has_content>
+ ${isbn13}
+ 15
+ #if>
+ <#if isbn10?has_content>
+ ${isbn10}
+ 02
+ #if>
+ <#if asin?has_content>
+ ${asin}
+ ASIN
+ #if>
+ <#if goodreadsId?has_content>
+ ${goodreadsId}
+ GOODREADS
+ #if>
+
+ <#-- Categories/Subjects -->
+ <#if categories?has_content>
+ <#list categories as category>
+ ${category}
+ #list>
+ #if>
+
+ <#-- Tags as additional subjects -->
+ <#if tags?has_content>
+ <#list tags as tag>
+ ${tag}
+ #list>
+ #if>
+
+ <#if pageCount?has_content>
+ ${pageCount}
+ #if>
+
+ <#-- EPUB 3.0 required metadata -->
+ ${modified!""}
+
+
+ <#-- Comic-specific metadata -->
+ pre-paginated
+ auto
+ landscape
+
+
+
+
+
+
+
+ <#-- Loop over the content file groups and emit the two items per entry -->
+ <#list contentFileGroups as file>
+
+
+ #list>
+
+
+
+
+
+ <#list contentFileGroups as file>
+
+ #list>
+
+
\ No newline at end of file
diff --git a/booklore-api/src/main/resources/templates/epub/xml/image_page.xhtml.ftl b/booklore-api/src/main/resources/templates/epub/xml/image_page.xhtml.ftl
new file mode 100644
index 000000000..6c8887e1e
--- /dev/null
+++ b/booklore-api/src/main/resources/templates/epub/xml/image_page.xhtml.ftl
@@ -0,0 +1,15 @@
+<#ftl output_format="XML" encoding="UTF-8">
+
+
+
+ Page ${pageNumber}
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/booklore-api/src/main/resources/templates/epub/xml/nav.xhtml.ftl b/booklore-api/src/main/resources/templates/epub/xml/nav.xhtml.ftl
new file mode 100644
index 000000000..37cb891bf
--- /dev/null
+++ b/booklore-api/src/main/resources/templates/epub/xml/nav.xhtml.ftl
@@ -0,0 +1,47 @@
+<#ftl output_format="XML" encoding="UTF-8">
+
+
+
+ Navigation
+
+
+
+
+
+
+ Table of Contents
+
+ <#if contentFileGroups?has_content>
+ <#list contentFileGroups as file>
+
+ Page ${file?counter}
+
+ #list>
+ <#else>
+
+ ${title!'Unknown Comic'}
+
+ #if>
+
+
+
+
+ List of Pages
+
+ <#if contentFileGroups?has_content>
+ <#list contentFileGroups as file>
+
+ ${file?counter}
+
+ #list>
+ #if>
+
+
+
+
\ No newline at end of file
diff --git a/booklore-api/src/main/resources/templates/epub/xml/toc.xml.ftl b/booklore-api/src/main/resources/templates/epub/xml/toc.xml.ftl
new file mode 100644
index 000000000..3853191a9
--- /dev/null
+++ b/booklore-api/src/main/resources/templates/epub/xml/toc.xml.ftl
@@ -0,0 +1,24 @@
+<#ftl output_format="XML" encoding="UTF-8">
+
+
+
+
+
+
+
+
+
+
+ ${title!'Unknown Comic'}
+
+
+
+
+ ${title!'Unknown Comic'}
+
+ <#if contentFileGroups?has_content>
+
+ #if>
+
+
+
\ No newline at end of file
diff --git a/booklore-api/src/test/java/com/adityachandel/booklore/service/KoboEntitlementServiceTest.java b/booklore-api/src/test/java/com/adityachandel/booklore/service/KoboEntitlementServiceTest.java
new file mode 100644
index 000000000..b17776c0d
--- /dev/null
+++ b/booklore-api/src/test/java/com/adityachandel/booklore/service/KoboEntitlementServiceTest.java
@@ -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;
+ }
+}
\ No newline at end of file
diff --git a/booklore-api/src/test/java/com/adityachandel/booklore/service/kobo/CbxConversionIntegrationTest.java b/booklore-api/src/test/java/com/adityachandel/booklore/service/kobo/CbxConversionIntegrationTest.java
new file mode 100644
index 000000000..26c5fbd19
--- /dev/null
+++ b/booklore-api/src/test/java/com/adityachandel/booklore/service/kobo/CbxConversionIntegrationTest.java
@@ -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 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(" 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 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 imageEntries = Collections.list(zipFile.getEntries()).stream()
+ .filter(entry -> entry.getName().startsWith("OEBPS/Images/page-"))
+ .sorted((e1, e2) -> e1.getName().compareTo(e2.getName()))
+ .toList();
+
+ List 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);
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/booklore-api/src/test/java/com/adityachandel/booklore/service/kobo/KoboCompatibilityServiceTest.java b/booklore-api/src/test/java/com/adityachandel/booklore/service/kobo/KoboCompatibilityServiceTest.java
new file mode 100644
index 000000000..565c179e0
--- /dev/null
+++ b/booklore-api/src/test/java/com/adityachandel/booklore/service/kobo/KoboCompatibilityServiceTest.java
@@ -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;
+ }
+}
\ No newline at end of file
diff --git a/booklore-ui/src/app/features/settings/device-settings/component/kobo-sync-settings/kobo-sync-settings-component.html b/booklore-ui/src/app/features/settings/device-settings/component/kobo-sync-settings/kobo-sync-settings-component.html
index e588470db..f6ef9ed4b 100644
--- a/booklore-ui/src/app/features/settings/device-settings/component/kobo-sync-settings/kobo-sync-settings-component.html
+++ b/booklore-ui/src/app/features/settings/device-settings/component/kobo-sync-settings/kobo-sync-settings-component.html
@@ -225,6 +225,46 @@
+
+
+
+
+
+
Convert CBX to EPUB
+
+
+
+
+ Converts CBX files to EPUB during Kobo sync, allowing CBX files to be downloaded and read on Kobo devices.
+
+
+
+
+
+
+
+
CBX Conversion Size Limit: {{ koboSettings.conversionLimitInMbForCbx }} MB
+
+
+
+ Comic book archives can be very large due to high-resolution images. Set a reasonable limit to prevent server overload during conversion.
+
+ Recommended: 100-200MB for most comic libraries. CBX files over this limit will not be converted or synced to the device.
+
+
+
}
diff --git a/booklore-ui/src/app/features/settings/device-settings/component/kobo-sync-settings/kobo-sync-settings-component.ts b/booklore-ui/src/app/features/settings/device-settings/component/kobo-sync-settings/kobo-sync-settings-component.ts
index 12ddfb906..da46da5ab 100644
--- a/booklore-ui/src/app/features/settings/device-settings/component/kobo-sync-settings/kobo-sync-settings-component.ts
+++ b/booklore-ui/src/app/features/settings/device-settings/component/kobo-sync-settings/kobo-sync-settings-component.ts
@@ -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;
});
}
diff --git a/booklore-ui/src/app/shared/model/app-settings.model.ts b/booklore-ui/src/app/shared/model/app-settings.model.ts
index 666c4ca27..ab1b51c30 100644
--- a/booklore-ui/src/app/shared/model/app-settings.model.ts
+++ b/booklore-ui/src/app/shared/model/app-settings.model.ts
@@ -102,6 +102,8 @@ export interface PublicReviewSettings {
export interface KoboSettings {
convertToKepub: boolean;
conversionLimitInMb: number;
+ convertCbxToEpub: boolean;
+ conversionLimitInMbForCbx: number;
forceEnableHyphenation: boolean;
}