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: + *

+ *

+ *

+ * Supported image formats within archives: JPG, JPEG, PNG, WEBP, GIF, BMP + *

+ * + *

Size Limits

+ * + * + * @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: + *

    + *
  1. Extracts all images from the archive to a temporary directory
  2. + *
  3. Creates an EPUB structure with one XHTML page per image
  4. + *
  5. Includes proper EPUB metadata from the book entity
  6. + *
  7. JPEG images are passed through directly; other formats are converted to JPEG (85% quality)
  8. + *
+ *

+ * + * @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 authors?has_content> + <#list authors as author> + ${author} + aut + + + + ${language!""} + + <#if publisher?has_content> + ${publisher} + + + <#if publishedDate?has_content> + ${publishedDate} + + + <#if description?has_content> + ${description} + + + <#-- Series metadata --> + <#if seriesName?has_content> + ${seriesName} + series + <#if seriesNumber?has_content> + ${seriesNumber} + + + + <#-- Identifiers --> + ${identifier!""} + <#if isbn13?has_content> + ${isbn13} + 15 + + <#if isbn10?has_content> + ${isbn10} + 02 + + <#if asin?has_content> + ${asin} + ASIN + + <#if goodreadsId?has_content> + ${goodreadsId} + GOODREADS + + + <#-- Categories/Subjects --> + <#if categories?has_content> + <#list categories as category> + ${category} + + + + <#-- Tags as additional subjects --> + <#if tags?has_content> + <#list tags as tag> + ${tag} + + + + <#if pageCount?has_content> + ${pageCount} + + + <#-- 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 contentFileGroups as file> + + + + \ 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} + + + + + +
+ Comic 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 + + + + + + + + + + \ 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> + + + + + \ 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 @@ + + +
+
+
+ + + +
+

+ Converts CBX files to EPUB during Kobo sync, allowing CBX files to be downloaded and read on Kobo devices. +

+
+
+ +
+
+
+ +
+ + +
+
+

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