Feature: Add EPUB conversion support for CBX files to Kobo Sync (#1538)

* Feature/kobo sync cbx support (#3)

* Feature: Add CBX syncing support to Kobo sync (#2)

* feat: add cbx sync support

* fix: phrasing of setting description to align with CBX terminology

* fix: resolve issue with CBX setting persistence

* fix: updated dev docker compose to align with Dockerfile

* feat: add cbx files as allowed entitlements

* fix: relax tomcat query characters for kobo sync

* fix: remove hardcoded epub restrictions

* fix: reduce image size and resolve relative content paths

* fix: resolve issues with the image file encoding

* feat: add rar and cb7 support

* feat: bring generated epub more in line with epub standard

* fix: resolve issues with extra metadata fields

* fix: make css background colour white to improve e-ink performance

* cleanup: code comments

* feat: add cbx unit tests

* fix: update zipfile to use builder pattern as current method is deprecated

* fix: remove comments

* fix: replace regex pattern to a simpler check

* fix: remove unused imports

* fix: update epub template to use standard language string

* fix: missing line end on css

* fix: remove duplicate query characters

* fix: grammer issue

* fix: resolve typo in the entitlement service test

* fix: redundant imports and update long types

* chore: update test descriptions for cbx conversion

* fix: add check for larger images in archive files. unlikely to occur.

* fix: merge temp dir logic for the book download

* fix: update kobo entitlement to always report the format as epub3

* fix: implement suggested changes around javadocs and some edge cases to handle gracefully

* chore: cleanup variable names to be clearer in download service

* feat: change function to extract to disk instead of storing images in memory

* fix: add temp file clean up into the conversion service

* fix: add visual divider to seperate CBX and EPUB settings

* fix: update lambdas to use method references

* fix: reword setting description for cbx to epub sync

---------

Co-authored-by: Aditya Chandel <8075870+adityachandelgit@users.noreply.github.com>
This commit is contained in:
CounterClops
2025-12-01 00:53:26 +08:00
committed by GitHub
parent e47046aa20
commit dfb73d50d9
22 changed files with 1912 additions and 16 deletions

View File

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

View File

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

View File

@@ -253,6 +253,8 @@ public class SettingPersistenceHelper {
return KoboSettings.builder()
.convertToKepub(false)
.conversionLimitInMb(100)
.convertCbxToEpub(false)
.conversionLimitInMbForCbx(100)
.forceEnableHyphenation(false)
.build();
}

View File

@@ -7,6 +7,7 @@ import com.adityachandel.booklore.model.enums.BookFileType;
import com.adityachandel.booklore.repository.BookRepository;
import com.adityachandel.booklore.service.appsettings.AppSettingService;
import com.adityachandel.booklore.service.kobo.KepubConversionService;
import com.adityachandel.booklore.service.kobo.CbxConversionService;
import com.adityachandel.booklore.util.FileUtils;
import jakarta.servlet.http.HttpServletResponse;
import lombok.AllArgsConstructor;
@@ -40,6 +41,7 @@ public class BookDownloadService {
private final BookRepository bookRepository;
private final KepubConversionService kepubConversionService;
private final CbxConversionService cbxConversionService;
private final AppSettingService appSettingService;
public ResponseEntity<Resource> downloadBook(Long bookId) {
@@ -78,9 +80,12 @@ public class BookDownloadService {
public void downloadKoboBook(Long bookId, HttpServletResponse response) {
BookEntity bookEntity = bookRepository.findById(bookId).orElseThrow(() -> ApiError.BOOK_NOT_FOUND.createException(bookId));
boolean isEpub = bookEntity.getBookType() == BookFileType.EPUB;
boolean isCbx = bookEntity.getBookType() == BookFileType.CBX;
if (bookEntity.getBookType() != BookFileType.EPUB) {
throw ApiError.GENERIC_BAD_REQUEST.createException("The requested book is not an EPUB file.");
if (!isEpub && !isCbx) {
throw ApiError.GENERIC_BAD_REQUEST.createException("The requested book is not an EPUB or CBX file.");
}
KoboSettings koboSettings = appSettingService.getAppSettings().getKoboSettings();
@@ -88,16 +93,23 @@ public class BookDownloadService {
throw ApiError.GENERIC_BAD_REQUEST.createException("Kobo settings not found.");
}
boolean asKepub = koboSettings.isConvertToKepub() && bookEntity.getFileSizeKb() <= (long) koboSettings.getConversionLimitInMb() * 1024;
boolean convertEpubToKepub = isEpub && koboSettings.isConvertToKepub() && bookEntity.getFileSizeKb() <= (long) koboSettings.getConversionLimitInMb() * 1024;
boolean convertCbxToEpub = isCbx && koboSettings.isConvertCbxToEpub() && bookEntity.getFileSizeKb() <= (long) koboSettings.getConversionLimitInMbForCbx() * 1024;
Path tempDir = null;
try {
File inputFile = new File(FileUtils.getBookFullPath(bookEntity));
File fileToSend = inputFile;
if (asKepub) {
tempDir = Files.createTempDirectory("kepub-output");
if (convertCbxToEpub || convertEpubToKepub) {
tempDir = Files.createTempDirectory("kobo-conversion");
}
if (convertCbxToEpub) {
fileToSend = cbxConversionService.convertCbxToEpub(inputFile, tempDir.toFile(), bookEntity);
}
if (convertEpubToKepub) {
fileToSend = kepubConversionService.convertEpubToKepub(inputFile, tempDir.toFile(),
koboSettings.isForceEnableHyphenation());
}

View File

@@ -0,0 +1,631 @@
package com.adityachandel.booklore.service.kobo;
import com.adityachandel.booklore.model.entity.AuthorEntity;
import com.adityachandel.booklore.model.entity.BookEntity;
import com.adityachandel.booklore.model.entity.CategoryEntity;
import com.adityachandel.booklore.model.entity.TagEntity;
import freemarker.cache.ClassTemplateLoader;
import freemarker.template.Configuration;
import freemarker.template.Template;
import freemarker.template.TemplateException;
import freemarker.template.TemplateExceptionHandler;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.compress.archivers.zip.ZipArchiveEntry;
import org.apache.commons.compress.archivers.zip.ZipArchiveOutputStream;
import org.apache.commons.compress.archivers.zip.ZipFile;
import org.apache.commons.compress.archivers.sevenz.SevenZArchiveEntry;
import org.apache.commons.compress.archivers.sevenz.SevenZFile;
import com.github.junrar.Archive;
import com.github.junrar.rarfile.FileHeader;
import com.github.junrar.exception.RarException;
import org.springframework.stereotype.Service;
import org.springframework.util.FileSystemUtils;
import javax.imageio.ImageIO;
import javax.imageio.ImageWriteParam;
import javax.imageio.ImageWriter;
import javax.imageio.IIOImage;
import javax.imageio.stream.ImageOutputStream;
import java.awt.image.BufferedImage;
import java.io.*;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.time.Instant;
import java.util.*;
/**
* Service for converting comic book archive files (CBX) to EPUB format.
* <p>
* This service supports the following comic book archive formats:
* <ul>
* <li><b>CBZ</b> - Comic book ZIP archive</li>
* <li><b>CBR</b> - Comic book RAR archive</li>
* <li><b>CB7</b> - Comic book 7z archive</li>
* </ul>
* </p>
* <p>
* Supported image formats within archives: JPG, JPEG, PNG, WEBP, GIF, BMP
* </p>
*
* <h3>Size Limits</h3>
* <ul>
* <li>Maximum individual image size: 50 MB</li>
* </ul>
*
* @see KepubConversionService
*/
@Slf4j
@Service
public class CbxConversionService {
private static final String IMAGE_ROOT_PATH = "OEBPS/Images/";
private static final String HTML_ROOT_PATH = "OEBPS/Text/";
private static final String CONTENT_OPF_PATH = "OEBPS/content.opf";
private static final String NAV_XHTML_PATH = "OEBPS/nav.xhtml";
private static final String TOC_NCX_PATH = "OEBPS/toc.ncx";
private static final String STYLESHEET_CSS_PATH = "OEBPS/Styles/stylesheet.css";
private static final String COVER_IMAGE_PATH = "OEBPS/Images/cover.jpg";
private static final String MIMETYPE_CONTENT = "application/epub+zip";
private static final long MAX_IMAGE_SIZE_BYTES = 50L * 1024 * 1024;
private static final String EXTRACTED_IMAGES_SUBDIR = "cbx_extracted_images";
private final Configuration freemarkerConfig;
public CbxConversionService() {
this.freemarkerConfig = initializeFreemarkerConfiguration();
}
public record EpubContentFileGroup(String contentKey, String imagePath, String htmlPath) {
}
/**
* Converts a comic book archive (CBZ, CBR, or CB7) to EPUB format.
* <p>
* The conversion process:
* <ol>
* <li>Extracts all images from the archive to a temporary directory</li>
* <li>Creates an EPUB structure with one XHTML page per image</li>
* <li>Includes proper EPUB metadata from the book entity</li>
* <li>JPEG images are passed through directly; other formats are converted to JPEG (85% quality)</li>
* </ol>
* </p>
*
* @param cbxFile the comic book archive file (must be CBZ, CBR, or CB7)
* @param tempDir the temporary directory where the output EPUB will be created
* @param bookEntity the book metadata to include in the EPUB
* @return the converted EPUB file
* @throws IOException if file I/O operations fail
* @throws TemplateException if EPUB template processing fails
* @throws RarException if RAR extraction fails (for CBR files)
* @throws IllegalArgumentException if the file format is not supported
* @throws IllegalStateException if no valid images are found in the archive
*/
public File convertCbxToEpub(File cbxFile, File tempDir, BookEntity bookEntity)
throws IOException, TemplateException, RarException {
validateInputs(cbxFile, tempDir);
log.info("Starting CBX to EPUB conversion for: {}", cbxFile.getName());
File outputFile = executeCbxConversion(cbxFile, tempDir, bookEntity);
log.info("Successfully converted {} to {} (size: {} bytes)",
cbxFile.getName(), outputFile.getName(), outputFile.length());
return outputFile;
}
private File executeCbxConversion(File cbxFile, File tempDir, BookEntity bookEntity)
throws IOException, TemplateException, RarException {
Path epubFilePath = Paths.get(tempDir.getAbsolutePath(), cbxFile.getName() + ".epub");
File epubFile = epubFilePath.toFile();
Path extractedImagesDir = Paths.get(tempDir.getAbsolutePath(), EXTRACTED_IMAGES_SUBDIR);
Files.createDirectories(extractedImagesDir);
List<Path> imagePaths = extractImagesFromCbx(cbxFile, extractedImagesDir);
if (imagePaths.isEmpty()) {
throw new IllegalStateException("No valid images found in CBX file: " + cbxFile.getName());
}
log.debug("Extracted {} images from CBX file to disk", imagePaths.size());
try (ZipArchiveOutputStream zipOut = new ZipArchiveOutputStream(new FileOutputStream(epubFile))) {
addMimetypeEntry(zipOut);
addMetaInfContainer(zipOut);
addStylesheet(zipOut);
List<EpubContentFileGroup> contentGroups = addImagesAndPages(zipOut, imagePaths);
addContentOpf(zipOut, bookEntity, contentGroups);
addTocNcx(zipOut, bookEntity, contentGroups);
addNavXhtml(zipOut, bookEntity, contentGroups);
}
deleteDirectory(extractedImagesDir);
return epubFile;
}
private void deleteDirectory(Path directory) {
try {
FileSystemUtils.deleteRecursively(directory);
} catch (IOException e) {
log.warn("Failed to delete directory {}: {}", directory, e.getMessage());
}
}
private void validateInputs(File cbxFile, File tempDir) {
if (cbxFile == null || !cbxFile.isFile()) {
throw new IllegalArgumentException("Invalid CBX file: " + cbxFile);
}
if (!isSupportedCbxFormat(cbxFile.getName())) {
throw new IllegalArgumentException("Unsupported file format: " + cbxFile.getName() +
". Supported formats: CBZ, CBR, CB7");
}
if (tempDir == null || !tempDir.isDirectory()) {
throw new IllegalArgumentException("Invalid temp directory: " + tempDir);
}
}
private Configuration initializeFreemarkerConfiguration() {
Configuration config = new Configuration(Configuration.VERSION_2_3_33);
config.setTemplateLoader(new ClassTemplateLoader(this.getClass(), "/templates/epub"));
config.setDefaultEncoding(StandardCharsets.UTF_8.name());
config.setTemplateExceptionHandler(TemplateExceptionHandler.RETHROW_HANDLER);
config.setLogTemplateExceptions(false);
config.setWrapUncheckedExceptions(true);
return config;
}
private List<Path> extractImagesFromCbx(File cbxFile, Path extractedImagesDir) throws IOException, RarException {
String fileName = cbxFile.getName().toLowerCase();
if (fileName.endsWith(".cbz")) {
return extractImagesFromZip(cbxFile, extractedImagesDir);
} else if (fileName.endsWith(".cbr")) {
return extractImagesFromRar(cbxFile, extractedImagesDir);
} else if (fileName.endsWith(".cb7")) {
return extractImagesFrom7z(cbxFile, extractedImagesDir);
} else {
throw new IllegalArgumentException("Unsupported archive format: " + fileName);
}
}
private List<Path> extractImagesFromZip(File cbzFile, Path extractedImagesDir) throws IOException {
List<Path> imagePaths = new ArrayList<>();
try (ZipFile zipFile = ZipFile.builder().setFile(cbzFile).get()) {
for (ZipArchiveEntry entry : Collections.list(zipFile.getEntries())) {
if (entry.isDirectory() || !isImageFile(entry.getName())) {
continue;
}
validateImageSize(entry.getName(), entry.getSize());
try (InputStream inputStream = zipFile.getInputStream(entry)) {
Path outputPath = extractedImagesDir.resolve(extractFileName(entry.getName()));
Files.copy(inputStream, outputPath);
imagePaths.add(outputPath);
} catch (Exception e) {
log.warn("Error extracting image {}: {}", entry.getName(), e.getMessage());
}
}
}
log.debug("Found {} image entries in CBZ file", imagePaths.size());
imagePaths.sort(Comparator.comparing(path -> path.getFileName().toString().toLowerCase()));
return imagePaths;
}
private List<Path> extractImagesFromRar(File cbrFile, Path extractedImagesDir) throws IOException, RarException {
List<Path> imagePaths = new ArrayList<>();
try (Archive rarFile = new Archive(cbrFile)) {
for (FileHeader fileHeader : rarFile) {
if (fileHeader.isDirectory() || !isImageFile(fileHeader.getFileName())) {
continue;
}
validateImageSize(fileHeader.getFileName(), fileHeader.getFullUnpackSize());
try (InputStream inputStream = rarFile.getInputStream(fileHeader)) {
Path outputPath = extractedImagesDir.resolve(extractFileName(fileHeader.getFileName()));
Files.copy(inputStream, outputPath);
imagePaths.add(outputPath);
} catch (Exception e) {
log.warn("Error extracting image {}: {}", fileHeader.getFileName(), e.getMessage());
}
}
}
log.debug("Found {} image entries in CBR file", imagePaths.size());
imagePaths.sort(Comparator.comparing(path -> path.getFileName().toString().toLowerCase()));
return imagePaths;
}
private List<Path> extractImagesFrom7z(File cb7File, Path extractedImagesDir) throws IOException {
List<Path> imagePaths = new ArrayList<>();
try (SevenZFile sevenZFile = SevenZFile.builder().setFile(cb7File).get()) {
SevenZArchiveEntry entry;
while ((entry = sevenZFile.getNextEntry()) != null) {
if (entry.isDirectory() || !isImageFile(entry.getName())) {
continue;
}
validateImageSize(entry.getName(), entry.getSize());
try {
Path outputPath = extractedImagesDir.resolve(extractFileName(entry.getName()));
try (InputStream entryInputStream = sevenZFile.getInputStream(entry);
OutputStream fileOutputStream = Files.newOutputStream(outputPath)) {
entryInputStream.transferTo(fileOutputStream);
}
imagePaths.add(outputPath);
} catch (Exception e) {
log.warn("Error extracting image {}: {}", entry.getName(), e.getMessage());
}
}
}
log.debug("Found {} image entries in CB7 file", imagePaths.size());
imagePaths.sort(Comparator.comparing(path -> path.getFileName().toString().toLowerCase()));
return imagePaths;
}
private String extractFileName(String entryPath) {
return Path.of(entryPath).getFileName().toString();
}
private void validateImageSize(String imageName, long size) throws IOException {
if (size > MAX_IMAGE_SIZE_BYTES) {
throw new IOException(String.format("Image '%s' exceeds maximum size limit: %d bytes (max: %d bytes)",
imageName, size, MAX_IMAGE_SIZE_BYTES));
}
}
private boolean isImageFile(String fileName) {
String lowerName = fileName.toLowerCase();
boolean isImage = lowerName.endsWith(".jpg") || lowerName.endsWith(".jpeg") ||
lowerName.endsWith(".png") || lowerName.endsWith(".webp") ||
lowerName.endsWith(".gif") || lowerName.endsWith(".bmp");
return isImage;
}
private boolean isJpegFile(Path path) {
Set<String> jpegExtensions = Set.of(".jpg", ".jpeg");
String fileName = path.getFileName().toString().toLowerCase();
int lastDot = fileName.lastIndexOf('.');
if (lastDot > 0) {
String extension = fileName.substring(lastDot);
return jpegExtensions.contains(extension);
}
return false;
}
private void addMimetypeEntry(ZipArchiveOutputStream zipOut) throws IOException {
byte[] mimetypeBytes = MIMETYPE_CONTENT.getBytes(StandardCharsets.UTF_8);
ZipArchiveEntry mimetypeEntry = new ZipArchiveEntry("mimetype");
mimetypeEntry.setMethod(ZipArchiveEntry.STORED);
mimetypeEntry.setSize(mimetypeBytes.length);
mimetypeEntry.setCrc(calculateCrc32(mimetypeBytes));
zipOut.putArchiveEntry(mimetypeEntry);
zipOut.write(mimetypeBytes);
zipOut.closeArchiveEntry();
}
private void addMetaInfContainer(ZipArchiveOutputStream zipOut) throws IOException, TemplateException {
Map<String, Object> model = new HashMap<>();
model.put("contentOpfPath", CONTENT_OPF_PATH);
String containerXml = processTemplate("xml/container.xml.ftl", model);
ZipArchiveEntry containerEntry = new ZipArchiveEntry("META-INF/container.xml");
zipOut.putArchiveEntry(containerEntry);
zipOut.write(containerXml.getBytes(StandardCharsets.UTF_8));
zipOut.closeArchiveEntry();
}
private void addStylesheet(ZipArchiveOutputStream zipOut) throws IOException {
String stylesheetContent = loadResourceAsString("/templates/epub/css/stylesheet.css");
ZipArchiveEntry stylesheetEntry = new ZipArchiveEntry(STYLESHEET_CSS_PATH);
zipOut.putArchiveEntry(stylesheetEntry);
zipOut.write(stylesheetContent.getBytes(StandardCharsets.UTF_8));
zipOut.closeArchiveEntry();
}
private List<EpubContentFileGroup> addImagesAndPages(ZipArchiveOutputStream zipOut, List<Path> imagePaths)
throws IOException, TemplateException {
List<EpubContentFileGroup> contentGroups = new ArrayList<>();
if (!imagePaths.isEmpty()) {
addImageToZipFromPath(zipOut, COVER_IMAGE_PATH, imagePaths.get(0));
}
for (int i = 0; i < imagePaths.size(); i++) {
Path imageSourcePath = imagePaths.get(i);
String contentKey = String.format("page-%04d", i + 1);
String imageFileName = contentKey + ".jpg";
String htmlFileName = contentKey + ".xhtml";
String imagePath = IMAGE_ROOT_PATH + imageFileName;
String htmlPath = HTML_ROOT_PATH + htmlFileName;
addImageToZipFromPath(zipOut, imagePath, imageSourcePath);
String htmlContent = generatePageHtml(imageFileName, i + 1);
ZipArchiveEntry htmlEntry = new ZipArchiveEntry(htmlPath);
zipOut.putArchiveEntry(htmlEntry);
zipOut.write(htmlContent.getBytes(StandardCharsets.UTF_8));
zipOut.closeArchiveEntry();
contentGroups.add(new EpubContentFileGroup(contentKey, imagePath, htmlPath));
}
return contentGroups;
}
private void addImageToZipFromPath(ZipArchiveOutputStream zipOut, String epubImagePath, Path sourceImagePath)
throws IOException {
ZipArchiveEntry imageEntry = new ZipArchiveEntry(epubImagePath);
zipOut.putArchiveEntry(imageEntry);
if (isJpegFile(sourceImagePath)) {
try (InputStream fis = Files.newInputStream(sourceImagePath)) {
fis.transferTo(zipOut);
}
} else {
try (InputStream fis = Files.newInputStream(sourceImagePath)) {
BufferedImage image = ImageIO.read(fis);
if (image != null) {
writeJpegImage(image, zipOut, 0.85f);
} else {
log.warn("Could not decode image {}, copying raw bytes", sourceImagePath.getFileName());
try (InputStream rawStream = Files.newInputStream(sourceImagePath)) {
rawStream.transferTo(zipOut);
}
}
}
}
zipOut.closeArchiveEntry();
}
private void writeJpegImage(BufferedImage image, ZipArchiveOutputStream zipOut, float quality)
throws IOException {
BufferedImage rgbImage = image;
if (image.getType() != BufferedImage.TYPE_INT_RGB) {
rgbImage = new BufferedImage(image.getWidth(), image.getHeight(), BufferedImage.TYPE_INT_RGB);
rgbImage.getGraphics().drawImage(image, 0, 0, null);
rgbImage.getGraphics().dispose();
}
ByteArrayOutputStream baos = new ByteArrayOutputStream();
Iterator<ImageWriter> writers = ImageIO.getImageWritersByFormatName("jpg");
if (!writers.hasNext()) {
throw new IOException("No JPEG image writer available");
}
ImageWriter writer = writers.next();
ImageWriteParam param = writer.getDefaultWriteParam();
if (param.canWriteCompressed()) {
param.setCompressionMode(ImageWriteParam.MODE_EXPLICIT);
param.setCompressionQuality(quality);
}
try (ImageOutputStream ios = ImageIO.createImageOutputStream(baos)) {
writer.setOutput(ios);
writer.write(null, new IIOImage(rgbImage, null, null), param);
} finally {
writer.dispose();
}
zipOut.write(baos.toByteArray());
}
private String generatePageHtml(String imageFileName, int pageNumber) throws IOException, TemplateException {
Map<String, Object> model = new HashMap<>();
model.put("imageFileName", "../Images/" + imageFileName);
model.put("pageNumber", pageNumber);
model.put("stylesheetPath", "../Styles/stylesheet.css");
return processTemplate("xml/image_page.xhtml.ftl", model);
}
private void addContentOpf(ZipArchiveOutputStream zipOut, BookEntity bookEntity,
List<EpubContentFileGroup> contentGroups) throws IOException, TemplateException {
Map<String, Object> model = createBookMetadataModel(bookEntity);
List<EpubContentFileGroup> relativeContentGroups = contentGroups.stream()
.map(group -> new EpubContentFileGroup(
group.contentKey(),
makeRelativeToOebps(group.imagePath()),
makeRelativeToOebps(group.htmlPath())
))
.toList();
model.put("contentFileGroups", relativeContentGroups);
model.put("coverImagePath", makeRelativeToOebps(COVER_IMAGE_PATH));
model.put("tocNcxPath", makeRelativeToOebps(TOC_NCX_PATH));
model.put("navXhtmlPath", makeRelativeToOebps(NAV_XHTML_PATH));
model.put("stylesheetCssPath", makeRelativeToOebps(STYLESHEET_CSS_PATH));
model.put("firstPageId", contentGroups.isEmpty() ? "" : "page_" + contentGroups.get(0).contentKey());
String contentOpf = processTemplate("xml/content.opf.ftl", model);
ZipArchiveEntry contentEntry = new ZipArchiveEntry(CONTENT_OPF_PATH);
zipOut.putArchiveEntry(contentEntry);
zipOut.write(contentOpf.getBytes(StandardCharsets.UTF_8));
zipOut.closeArchiveEntry();
}
private void addTocNcx(ZipArchiveOutputStream zipOut, BookEntity bookEntity,
List<EpubContentFileGroup> contentGroups) throws IOException, TemplateException {
Map<String, Object> model = createBookMetadataModel(bookEntity);
model.put("contentFileGroups", contentGroups);
String tocNcx = processTemplate("xml/toc.xml.ftl", model);
ZipArchiveEntry tocEntry = new ZipArchiveEntry(TOC_NCX_PATH);
zipOut.putArchiveEntry(tocEntry);
zipOut.write(tocNcx.getBytes(StandardCharsets.UTF_8));
zipOut.closeArchiveEntry();
}
private void addNavXhtml(ZipArchiveOutputStream zipOut, BookEntity bookEntity,
List<EpubContentFileGroup> contentGroups) throws IOException, TemplateException {
Map<String, Object> model = createBookMetadataModel(bookEntity);
model.put("contentFileGroups", contentGroups);
String navXhtml = processTemplate("xml/nav.xhtml.ftl", model);
ZipArchiveEntry navEntry = new ZipArchiveEntry(NAV_XHTML_PATH);
zipOut.putArchiveEntry(navEntry);
zipOut.write(navXhtml.getBytes(StandardCharsets.UTF_8));
zipOut.closeArchiveEntry();
}
private Map<String, Object> createBookMetadataModel(BookEntity bookEntity) {
Map<String, Object> model = new HashMap<>();
if (bookEntity != null && bookEntity.getMetadata() != null) {
var metadata = bookEntity.getMetadata();
model.put("title", metadata.getTitle() != null ? metadata.getTitle() : "Unknown Comic");
model.put("language", metadata.getLanguage() != null ? metadata.getLanguage() : "en");
if (metadata.getSubtitle() != null && !metadata.getSubtitle().trim().isEmpty()) {
model.put("subtitle", metadata.getSubtitle());
}
if (metadata.getDescription() != null && !metadata.getDescription().trim().isEmpty()) {
model.put("description", metadata.getDescription());
}
if (metadata.getSeriesName() != null && !metadata.getSeriesName().trim().isEmpty()) {
model.put("seriesName", metadata.getSeriesName());
}
if (metadata.getSeriesNumber() != null) {
model.put("seriesNumber", metadata.getSeriesNumber());
}
if (metadata.getSeriesTotal() != null) {
model.put("seriesTotal", metadata.getSeriesTotal());
}
if (metadata.getPublisher() != null && !metadata.getPublisher().trim().isEmpty()) {
model.put("publisher", metadata.getPublisher());
}
if (metadata.getPublishedDate() != null) {
model.put("publishedDate", metadata.getPublishedDate().toString());
}
if (metadata.getPageCount() != null && metadata.getPageCount() > 0) {
model.put("pageCount", metadata.getPageCount());
}
if (metadata.getIsbn13() != null && !metadata.getIsbn13().trim().isEmpty()) {
model.put("isbn13", metadata.getIsbn13());
}
if (metadata.getIsbn10() != null && !metadata.getIsbn10().trim().isEmpty()) {
model.put("isbn10", metadata.getIsbn10());
}
if (metadata.getAsin() != null && !metadata.getAsin().trim().isEmpty()) {
model.put("asin", metadata.getAsin());
}
if (metadata.getGoodreadsId() != null && !metadata.getGoodreadsId().trim().isEmpty()) {
model.put("goodreadsId", metadata.getGoodreadsId());
}
if (metadata.getAuthors() != null && !metadata.getAuthors().isEmpty()) {
model.put("authors", metadata.getAuthors().stream()
.map(AuthorEntity::getName)
.toList());
}
if (metadata.getCategories() != null && !metadata.getCategories().isEmpty()) {
model.put("categories", metadata.getCategories().stream()
.map(CategoryEntity::getName)
.toList());
}
if (metadata.getTags() != null && !metadata.getTags().isEmpty()) {
model.put("tags", metadata.getTags().stream()
.map(TagEntity::getName)
.toList());
}
model.put("identifier", "urn:uuid:" + UUID.randomUUID());
} else {
model.put("title", "Unknown Comic");
model.put("language", "en");
model.put("identifier", "urn:uuid:" + UUID.randomUUID());
}
model.put("modified", Instant.now().toString());
return model;
}
private String processTemplate(String templateName, Map<String, Object> model)
throws IOException, TemplateException {
try {
Template template = freemarkerConfig.getTemplate(templateName);
StringWriter writer = new StringWriter();
template.process(model, writer);
return writer.toString();
} catch (IOException e) {
throw new IOException("Failed to load template: " + templateName, e);
} catch (TemplateException e) {
throw new TemplateException("Failed to process template: " + templateName, e, null);
}
}
private String loadResourceAsString(String resourcePath) throws IOException {
try (InputStream inputStream = getClass().getResourceAsStream(resourcePath)) {
if (inputStream == null) {
throw new IOException("Resource not found: " + resourcePath);
}
return new String(inputStream.readAllBytes(), StandardCharsets.UTF_8);
}
}
private String makeRelativeToOebps(String fullPath) {
Path oebpsPath = Paths.get("OEBPS");
Path targetPath = Paths.get(fullPath);
if (targetPath.startsWith(oebpsPath)) {
return oebpsPath.relativize(targetPath).toString().replace('\\', '/');
}
return fullPath;
}
private long calculateCrc32(byte[] data) {
java.util.zip.CRC32 crc32 = new java.util.zip.CRC32();
crc32.update(data);
return crc32.getValue();
}
public boolean isSupportedCbxFormat(String fileName) {
if (fileName == null) {
return false;
}
String lowerName = fileName.toLowerCase();
return lowerName.endsWith(".cbz") ||
lowerName.endsWith(".cbr") ||
lowerName.endsWith(".cb7");
}
}

View File

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

View File

@@ -2,14 +2,12 @@ package com.adityachandel.booklore.service.kobo;
import com.adityachandel.booklore.config.security.service.AuthenticationService;
import com.adityachandel.booklore.mapper.KoboReadingStateMapper;
import com.adityachandel.booklore.model.dto.BookLoreUser;
import com.adityachandel.booklore.model.dto.kobo.*;
import com.adityachandel.booklore.model.dto.settings.KoboSettings;
import com.adityachandel.booklore.model.entity.AuthorEntity;
import com.adityachandel.booklore.model.entity.BookEntity;
import com.adityachandel.booklore.model.entity.BookMetadataEntity;
import com.adityachandel.booklore.model.entity.CategoryEntity;
import com.adityachandel.booklore.model.entity.UserBookProgressEntity;
import com.adityachandel.booklore.model.enums.BookFileType;
import com.adityachandel.booklore.model.enums.KoboBookFormat;
import com.adityachandel.booklore.model.enums.KoboReadStatus;
@@ -40,6 +38,7 @@ public class KoboEntitlementService {
private final KoboUrlBuilder koboUrlBuilder;
private final BookQueryService bookQueryService;
private final AppSettingService appSettingService;
private final KoboCompatibilityService koboCompatibilityService;
private final UserBookProgressRepository progressRepository;
private final KoboReadingStateRepository readingStateRepository;
private final KoboReadingStateMapper readingStateMapper;
@@ -50,7 +49,7 @@ public class KoboEntitlementService {
List<BookEntity> books = bookQueryService.findAllWithMetadataByIds(bookIds);
return books.stream()
.filter(bookEntity -> bookEntity.getBookType() == BookFileType.EPUB)
.filter(koboCompatibilityService::isBookSupportedForKobo)
.map(book -> NewEntitlement.builder()
.newEntitlement(BookEntitlementContainer.builder()
.bookEntitlement(buildBookEntitlement(book, removed))
@@ -64,7 +63,7 @@ public class KoboEntitlementService {
public List<ChangedEntitlement> generateChangedEntitlements(Set<Long> bookIds, String token, boolean removed) {
List<BookEntity> books = bookQueryService.findAllWithMetadataByIds(bookIds);
return books.stream()
.filter(bookEntity -> bookEntity.getBookType() == BookFileType.EPUB)
.filter(koboCompatibilityService::isBookSupportedForKobo)
.map(book -> {
KoboBookMetadata metadata;
if (removed) {
@@ -180,7 +179,7 @@ public class KoboEntitlementService {
public KoboBookMetadata getMetadataForBook(long bookId, String token) {
List<BookEntity> books = bookQueryService.findAllWithMetadataByIds(Set.of(bookId))
.stream()
.filter(bookEntity -> bookEntity.getBookType() == BookFileType.EPUB)
.filter(koboCompatibilityService::isBookSupportedForKobo)
.toList();
return mapToKoboMetadata(books.getFirst(), token);
}
@@ -215,8 +214,16 @@ public class KoboEntitlementService {
KoboBookFormat bookFormat = KoboBookFormat.EPUB3;
KoboSettings koboSettings = appSettingService.getAppSettings().getKoboSettings();
if (koboSettings != null && koboSettings.isConvertToKepub()) {
bookFormat = KoboBookFormat.KEPUB;
boolean isEpubFile = book.getBookType() == BookFileType.EPUB;
boolean isCbxFile = book.getBookType() == BookFileType.CBX;
if (koboSettings != null) {
if (isEpubFile && koboSettings.isConvertToKepub()) {
bookFormat = KoboBookFormat.KEPUB;
} else if (isCbxFile && koboSettings.isConvertCbxToEpub()) {
bookFormat = KoboBookFormat.EPUB3;
}
}
return KoboBookMetadata.builder()

View File

@@ -5,7 +5,6 @@ import com.adityachandel.booklore.model.entity.KoboDeletedBookProgressEntity;
import com.adityachandel.booklore.model.entity.KoboSnapshotBookEntity;
import com.adityachandel.booklore.model.entity.ShelfEntity;
import com.adityachandel.booklore.model.entity.KoboLibrarySnapshotEntity;
import com.adityachandel.booklore.model.enums.BookFileType;
import com.adityachandel.booklore.model.enums.ShelfType;
import com.adityachandel.booklore.repository.KoboDeletedBookProgressRepository;
import com.adityachandel.booklore.repository.ShelfRepository;
@@ -29,6 +28,7 @@ public class KoboLibrarySnapshotService {
private final ShelfRepository shelfRepository;
private final BookEntityToKoboSnapshotBookMapper mapper;
private final KoboDeletedBookProgressRepository koboDeletedBookProgressRepository;
private final KoboCompatibilityService koboCompatibilityService;
@Transactional(readOnly = true)
public Optional<KoboLibrarySnapshotEntity> findByIdAndUserId(String id, Long userId) {
@@ -118,7 +118,7 @@ public class KoboLibrarySnapshotService {
private List<KoboSnapshotBookEntity> mapBooksToKoboSnapshotBook(ShelfEntity shelf, KoboLibrarySnapshotEntity snapshot) {
return shelf.getBookEntities().stream()
.filter(bookEntity -> bookEntity.getBookType() == BookFileType.EPUB)
.filter(koboCompatibilityService::isBookSupportedForKobo)
.map(book -> {
KoboSnapshotBookEntity snapshotBook = mapper.toKoboSnapshotBook(book);
snapshotBook.setSnapshot(snapshot);
@@ -130,4 +130,5 @@ public class KoboLibrarySnapshotService {
public void deleteById(String id) {
koboLibrarySnapshotRepository.deleteById(id);
}
}

View File

@@ -17,6 +17,8 @@ app:
server:
forward-headers-strategy: native
port: 8080
tomcat:
relaxed-query-chars: '[,],%,{,},|'
spring:
servlet:

View File

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

View File

@@ -0,0 +1,7 @@
<#ftl output_format="XML" encoding="UTF-8">
<?xml version="1.0" encoding="UTF-8"?>
<container version="1.0" xmlns="urn:oasis:names:tc:opendocument:xmlns:container">
<rootfiles>
<rootfile full-path="${contentOpfPath}" media-type="application/oebps-package+xml"/>
</rootfiles>
</container>

View File

@@ -0,0 +1,110 @@
<#ftl output_format="XML" encoding="UTF-8">
<?xml version="1.0" encoding="UTF-8"?>
<package version="3.0" unique-identifier="BookID" xmlns="http://www.idpf.org/2007/opf">
<metadata xmlns:opf="http://www.idpf.org/2007/opf" xmlns:dc="http://purl.org/dc/elements/1.1/">
<dc:title id="title">${title!""}</dc:title>
<#if subtitle?has_content>
<meta property="title-type" refines="#title">main</meta>
<meta property="display-seq" refines="#title">1</meta>
<dc:title id="subtitle">${subtitle}</dc:title>
<meta property="title-type" refines="#subtitle">subtitle</meta>
<meta property="display-seq" refines="#subtitle">2</meta>
</#if>
<#if authors?has_content>
<#list authors as author>
<dc:creator id="creator${author?index}">${author}</dc:creator>
<meta property="role" refines="#creator${author?index}" scheme="marc:relators">aut</meta>
</#list>
</#if>
<dc:language>${language!""}</dc:language>
<#if publisher?has_content>
<dc:publisher>${publisher}</dc:publisher>
</#if>
<#if publishedDate?has_content>
<dc:date>${publishedDate}</dc:date>
</#if>
<#if description?has_content>
<dc:description>${description}</dc:description>
</#if>
<#-- Series metadata -->
<#if seriesName?has_content>
<meta property="belongs-to-collection" id="series">${seriesName}</meta>
<meta property="collection-type" refines="#series">series</meta>
<#if seriesNumber?has_content>
<meta property="group-position" refines="#series">${seriesNumber}</meta>
</#if>
</#if>
<#-- Identifiers -->
<dc:identifier id="BookID">${identifier!""}</dc:identifier>
<#if isbn13?has_content>
<dc:identifier id="isbn13">${isbn13}</dc:identifier>
<meta refines="#isbn13" property="identifier-type" scheme="onix:codelist5">15</meta>
</#if>
<#if isbn10?has_content>
<dc:identifier id="isbn10">${isbn10}</dc:identifier>
<meta refines="#isbn10" property="identifier-type" scheme="onix:codelist5">02</meta>
</#if>
<#if asin?has_content>
<dc:identifier id="asin">${asin}</dc:identifier>
<meta refines="#asin" property="identifier-type">ASIN</meta>
</#if>
<#if goodreadsId?has_content>
<dc:identifier id="goodreads">${goodreadsId}</dc:identifier>
<meta refines="#goodreads" property="identifier-type">GOODREADS</meta>
</#if>
<#-- Categories/Subjects -->
<#if categories?has_content>
<#list categories as category>
<dc:subject>${category}</dc:subject>
</#list>
</#if>
<#-- Tags as additional subjects -->
<#if tags?has_content>
<#list tags as tag>
<dc:subject>${tag}</dc:subject>
</#list>
</#if>
<#if pageCount?has_content>
<meta property="schema:numberOfPages">${pageCount}</meta>
</#if>
<#-- EPUB 3.0 required metadata -->
<meta property="dcterms:modified">${modified!""}</meta>
<meta name="cover" content="cover" />
<#-- Comic-specific metadata -->
<meta property="rendition:layout">pre-paginated</meta>
<meta property="rendition:orientation">auto</meta>
<meta property="rendition:spread">landscape</meta>
</metadata>
<manifest>
<item id="cover" href="${coverImagePath}" media-type="image/jpeg" properties="cover-image" />
<item id="ncx" href="${tocNcxPath}" media-type="application/x-dtbncx+xml" />
<item id="nav" href="${navXhtmlPath}" properties="nav" media-type="application/xhtml+xml" />
<#-- Loop over the content file groups and emit the two items per entry -->
<#list contentFileGroups as file>
<item id="${'page_' + file.contentKey}" href="${file.htmlPath}" media-type="application/xhtml+xml" />
<item id="${'img_' + file.contentKey}" href="${file.imagePath}" media-type="image/jpeg" />
</#list>
<item id="css" href="${stylesheetCssPath}" media-type="text/css" />
</manifest>
<spine page-progression-direction="ltr" toc="ncx">
<#list contentFileGroups as file>
<itemref idref="page_${file.contentKey}" properties="page-spread-center" />
</#list>
</spine>
</package>

View File

@@ -0,0 +1,15 @@
<#ftl output_format="XML" encoding="UTF-8">
<?xml version="1.0" encoding="UTF-8"?>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:epub="http://www.idpf.org/2007/ops">
<head>
<title>Page ${pageNumber}</title>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="stylesheet" type="text/css" href="${stylesheetPath}" />
</head>
<body>
<div>
<img src="${imageFileName}" alt="Comic page ${pageNumber}" role="img" aria-label="Comic book page ${pageNumber}" />
</div>
</body>
</html>

View File

@@ -0,0 +1,47 @@
<#ftl output_format="XML" encoding="UTF-8">
<?xml version="1.0" encoding="UTF-8"?>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:epub="http://www.idpf.org/2007/ops">
<head>
<title>Navigation</title>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<style>
body { font-family: sans-serif; margin: 20px; }
nav h1 { color: #333; }
nav ol { list-style-type: decimal; }
nav a { text-decoration: none; color: #0066cc; }
nav a:hover { text-decoration: underline; }
</style>
</head>
<body>
<nav epub:type="toc" id="toc">
<h1>Table of Contents</h1>
<ol>
<#if contentFileGroups?has_content>
<#list contentFileGroups as file>
<li>
<a href="${file.htmlPath?replace('OEBPS/', '')}">Page ${file?counter}</a>
</li>
</#list>
<#else>
<li>
<a href="Text/page-0001.xhtml">${title!'Unknown Comic'}</a>
</li>
</#if>
</ol>
</nav>
<nav epub:type="page-list" id="page-list">
<h1>List of Pages</h1>
<ol>
<#if contentFileGroups?has_content>
<#list contentFileGroups as file>
<li>
<a href="${file.htmlPath?replace('OEBPS/', '')}" epub:type="pagebreak">${file?counter}</a>
</li>
</#list>
</#if>
</ol>
</nav>
</body>
</html>

View File

@@ -0,0 +1,24 @@
<#ftl output_format="XML" encoding="UTF-8">
<?xml version="1.0" encoding="UTF-8"?>
<ncx version="2005-1" xml:lang="${language!'en'}" xmlns="http://www.daisy.org/z3986/2005/ncx/">
<head>
<meta name="dtb:uid" content="${identifier}" />
<meta name="dtb:depth" content="1" />
<meta name="dtb:totalPageCount" content="${contentFileGroups?size}" />
<meta name="dtb:maxPageNumber" content="${contentFileGroups?size}" />
<meta name="generated" content="true" />
</head>
<docTitle>
<text>${title!'Unknown Comic'}</text>
</docTitle>
<navMap>
<navPoint id="cover" playOrder="1">
<navLabel>
<text>${title!'Unknown Comic'}</text>
</navLabel>
<#if contentFileGroups?has_content>
<content src="${contentFileGroups[0].htmlPath?replace('OEBPS/', '')}" />
</#if>
</navPoint>
</navMap>
</ncx>

View File

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

View File

@@ -0,0 +1,211 @@
package com.adityachandel.booklore.service.kobo;
import com.adityachandel.booklore.model.entity.BookEntity;
import com.adityachandel.booklore.model.entity.BookMetadataEntity;
import freemarker.template.TemplateException;
import com.github.junrar.exception.RarException;
import org.apache.commons.compress.archivers.zip.ZipArchiveEntry;
import org.apache.commons.compress.archivers.zip.ZipArchiveOutputStream;
import org.apache.commons.compress.archivers.zip.ZipFile;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import javax.imageio.ImageIO;
import java.awt.*;
import java.awt.image.BufferedImage;
import java.io.*;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Collections;
import java.util.List;
import static org.assertj.core.api.Assertions.*;
@DisplayName("CBX Conversion Integration Test")
class CbxConversionIntegrationTest {
@TempDir
Path tempDir;
private CbxConversionService conversionService;
@BeforeEach
void setUp() {
conversionService = new CbxConversionService();
}
@Test
@DisplayName("Should successfully convert CBZ to EPUB with valid structure")
void convertCbzToEpub_MainConversionTest() throws IOException, TemplateException, RarException {
File testCbzFile = createTestComicCbzFile();
BookEntity bookMetadata = createTestBookMetadata();
File epubFile = conversionService.convertCbxToEpub(testCbzFile, tempDir.toFile(), bookMetadata);
assertThat(epubFile)
.exists()
.hasExtension("epub");
assertThat(epubFile.length())
.as("EPUB file should not be empty")
.isGreaterThan(0);
verifyEpubContents(epubFile, bookMetadata);
}
private File createTestComicCbzFile() throws IOException {
File cbzFile = Files.createFile(tempDir.resolve("test-comic.cbz")).toFile();
try (ZipArchiveOutputStream zipOut = new ZipArchiveOutputStream(new FileOutputStream(cbzFile))) {
String[] pageNames = {"cover.jpg", "page01.png", "page02.jpg"};
Color[] colors = {Color.RED, Color.GREEN, Color.BLUE};
for (int i = 0; i < 3; i++) {
BufferedImage pageImage = createComicPageImage(400, 600, colors[i], "Page " + (i + 1));
ZipArchiveEntry imageEntry = new ZipArchiveEntry(pageNames[i]);
zipOut.putArchiveEntry(imageEntry);
String format = pageNames[i].endsWith(".png") ? "png" : "jpg";
ImageIO.write(pageImage, format, zipOut);
zipOut.closeArchiveEntry();
}
}
return cbzFile;
}
private BufferedImage createComicPageImage(int width, int height, Color bgColor, String text) {
BufferedImage image = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB);
Graphics2D g2d = image.createGraphics();
g2d.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
g2d.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_ON);
g2d.setColor(bgColor);
g2d.fillRect(0, 0, width, height);
g2d.setColor(Color.BLACK);
g2d.setStroke(new BasicStroke(3));
g2d.drawRect(10, 10, width - 20, height - 20);
g2d.setFont(new Font("Arial", Font.BOLD, 24));
FontMetrics fm = g2d.getFontMetrics();
int textWidth = fm.stringWidth(text);
int textHeight = fm.getHeight();
g2d.drawString(text, (width - textWidth) / 2, (height + textHeight) / 2);
g2d.dispose();
return image;
}
private BookEntity createTestBookMetadata() {
BookEntity book = new BookEntity();
BookMetadataEntity metadata = new BookMetadataEntity();
metadata.setTitle("Amazing Comic Adventures");
metadata.setLanguage("en");
book.setMetadata(metadata);
return book;
}
private void verifyEpubContents(File epubFile, BookEntity expectedMetadata) throws IOException {
try (ZipFile zipFile = ZipFile.builder().setFile(epubFile).get()) {
List<String> entryNames = Collections.list(zipFile.getEntries())
.stream()
.map(ZipArchiveEntry::getName)
.toList();
assertThat(entryNames)
.as("EPUB should contain required structure files")
.contains(
"mimetype",
"META-INF/container.xml",
"OEBPS/content.opf",
"OEBPS/toc.ncx",
"OEBPS/nav.xhtml",
"OEBPS/Styles/stylesheet.css"
);
verifyMimetypeEntry(zipFile);
long imageCount = entryNames.stream()
.filter(name -> name.startsWith("OEBPS/Images/") && name.endsWith(".jpg"))
.count();
long pageCount = entryNames.stream()
.filter(name -> name.startsWith("OEBPS/Text/") && name.endsWith(".xhtml"))
.count();
// Note: The EPUB contains 4 images because the conversion service duplicates the first image:
// once as 'cover.jpg' (for the cover, referenced in the manifest but not in the spine)
// and once as 'page-0001.jpg' (for the first comic page). Only 3 HTML pages are created,
// one for each comic page image, since the cover image is not given its own HTML page.
assertThat(imageCount)
.as("Should have converted all comic pages to images")
.isEqualTo(4);
assertThat(pageCount)
.as("Should have created HTML page for each image (excluding cover)")
.isEqualTo(3);
verifyContentOpf(zipFile, expectedMetadata);
}
}
private void verifyMimetypeEntry(ZipFile zipFile) throws IOException {
ZipArchiveEntry mimetypeEntry = zipFile.getEntry("mimetype");
assertThat(mimetypeEntry)
.as("Mimetype entry should exist")
.isNotNull();
assertThat(mimetypeEntry.getMethod())
.as("Mimetype should be stored uncompressed")
.isEqualTo(ZipArchiveEntry.STORED);
try (InputStream stream = zipFile.getInputStream(mimetypeEntry)) {
String content = new String(stream.readAllBytes());
assertThat(content)
.as("Mimetype content should be correct")
.isEqualTo("application/epub+zip");
}
}
private void verifyContentOpf(ZipFile zipFile, BookEntity expectedMetadata) throws IOException {
ZipArchiveEntry contentOpfEntry = zipFile.getEntry("OEBPS/content.opf");
assertThat(contentOpfEntry)
.as("Content.opf should exist")
.isNotNull();
try (InputStream stream = zipFile.getInputStream(contentOpfEntry)) {
String content = new String(stream.readAllBytes());
assertThat(content)
.as("Content.opf should contain book title")
.contains(expectedMetadata.getMetadata().getTitle());
assertThat(content)
.as("Content.opf should contain language")
.contains(expectedMetadata.getMetadata().getLanguage());
assertThat(content)
.as("Content.opf should reference image files")
.contains("media-type=\"image/jpeg\"");
assertThat(content)
.as("Content.opf should reference HTML pages")
.contains("media-type=\"application/xhtml+xml\"");
assertThat(content)
.as("Content.opf should have spine entries")
.contains("<spine")
.contains("itemref");
}
}
}

View File

@@ -0,0 +1,262 @@
package com.adityachandel.booklore.service.kobo;
import com.adityachandel.booklore.model.entity.BookEntity;
import com.adityachandel.booklore.model.entity.BookMetadataEntity;
import freemarker.template.TemplateException;
import com.github.junrar.exception.RarException;
import org.apache.commons.compress.archivers.zip.ZipArchiveEntry;
import org.apache.commons.compress.archivers.zip.ZipArchiveOutputStream;
import org.apache.commons.compress.archivers.zip.ZipFile;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import javax.imageio.ImageIO;
import java.awt.*;
import java.awt.image.BufferedImage;
import java.io.*;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Collections;
import java.util.List;
import static org.assertj.core.api.Assertions.*;
@DisplayName("CBX Conversion Service Tests")
class CbxConversionServiceTest {
@TempDir
Path tempDir;
private CbxConversionService cbxConversionService;
private File testCbzFile;
private BookEntity testBookEntity;
@BeforeEach
void setUp() throws IOException {
cbxConversionService = new CbxConversionService();
testCbzFile = createTestCbzFile();
testBookEntity = createTestBookEntity();
}
@Test
void convertCbxToEpub_WithValidCbzFile_ShouldGenerateValidEpub() throws IOException, TemplateException, RarException {
File epubFile = cbxConversionService.convertCbxToEpub(testCbzFile, tempDir.toFile(), testBookEntity);
assertThat(epubFile).exists();
assertThat(epubFile.getName()).endsWith(".epub");
assertThat(epubFile.length()).isGreaterThan(0);
verifyEpubStructure(epubFile);
}
@Test
void convertCbxToEpub_WithNullCbxFile_ShouldThrowException() {
assertThatThrownBy(() -> cbxConversionService.convertCbxToEpub(null, tempDir.toFile(), testBookEntity))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("Invalid CBX file");
}
@Test
void convertCbxToEpub_WithNonExistentFile_ShouldThrowException() {
File nonExistentFile = new File(tempDir.toFile(), "non-existent.cbz");
assertThatThrownBy(() -> cbxConversionService.convertCbxToEpub(nonExistentFile, tempDir.toFile(), testBookEntity))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("Invalid CBX file");
}
@Test
void convertCbxToEpub_WithUnsupportedFileFormat_ShouldThrowException() throws IOException {
File unsupportedFile = Files.createFile(tempDir.resolve("test.txt")).toFile();
assertThatThrownBy(() -> cbxConversionService.convertCbxToEpub(unsupportedFile, tempDir.toFile(), testBookEntity))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("Unsupported file format");
}
@Test
void convertCbxToEpub_WithNullTempDir_ShouldThrowException() {
assertThatThrownBy(() -> cbxConversionService.convertCbxToEpub(testCbzFile, null, testBookEntity))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("Invalid temp directory");
}
@Test
void convertCbxToEpub_WithEmptyCbzFile_ShouldThrowException() throws IOException {
File emptyCbzFile = createEmptyCbzFile();
assertThatThrownBy(() -> cbxConversionService.convertCbxToEpub(emptyCbzFile, tempDir.toFile(), testBookEntity))
.isInstanceOf(IllegalStateException.class)
.hasMessageContaining("No valid images found");
}
@Test
void isSupportedCbxFormat_WithSupportedFiles_ShouldReturnTrue() {
assertThat(cbxConversionService.isSupportedCbxFormat("test.cbz")).isTrue();
assertThat(cbxConversionService.isSupportedCbxFormat("test.CBZ")).isTrue();
assertThat(cbxConversionService.isSupportedCbxFormat("path/to/test.cbz")).isTrue();
assertThat(cbxConversionService.isSupportedCbxFormat("test.cbr")).isTrue();
assertThat(cbxConversionService.isSupportedCbxFormat("test.CBR")).isTrue();
assertThat(cbxConversionService.isSupportedCbxFormat("path/to/test.cbr")).isTrue();
assertThat(cbxConversionService.isSupportedCbxFormat("test.cb7")).isTrue();
assertThat(cbxConversionService.isSupportedCbxFormat("test.CB7")).isTrue();
assertThat(cbxConversionService.isSupportedCbxFormat("path/to/test.cb7")).isTrue();
}
@Test
void isSupportedCbxFormat_WithUnsupportedFormats_ShouldReturnFalse() {
assertThat(cbxConversionService.isSupportedCbxFormat("test.zip")).isFalse();
assertThat(cbxConversionService.isSupportedCbxFormat("test.pdf")).isFalse();
assertThat(cbxConversionService.isSupportedCbxFormat("test.epub")).isFalse();
assertThat(cbxConversionService.isSupportedCbxFormat("test.txt")).isFalse();
assertThat(cbxConversionService.isSupportedCbxFormat("test")).isFalse();
assertThat(cbxConversionService.isSupportedCbxFormat(null)).isFalse();
assertThat(cbxConversionService.isSupportedCbxFormat("")).isFalse();
}
@Test
void convertCbxToEpub_WithNullBookEntity_ShouldUseDefaultMetadata() throws IOException, TemplateException, RarException {
File epubFile = cbxConversionService.convertCbxToEpub(testCbzFile, tempDir.toFile(), null);
assertThat(epubFile).exists();
verifyEpubStructure(epubFile);
}
@Test
void convertCbxToEpub_WithMultipleImages_ShouldPreservePageOrder() throws IOException, TemplateException, RarException {
File multiPageCbzFile = createMultiPageCbzFile();
File epubFile = cbxConversionService.convertCbxToEpub(multiPageCbzFile, tempDir.toFile(), testBookEntity);
assertThat(epubFile).exists();
verifyPageOrderInEpub(epubFile, 5);
}
private File createTestCbzFile() throws IOException {
File cbzFile = Files.createFile(tempDir.resolve("test-comic.cbz")).toFile();
try (ZipArchiveOutputStream zipOut = new ZipArchiveOutputStream(new FileOutputStream(cbzFile))) {
BufferedImage testImage = createTestImage("Page 1", Color.RED);
ZipArchiveEntry imageEntry = new ZipArchiveEntry("page01.png");
zipOut.putArchiveEntry(imageEntry);
ImageIO.write(testImage, "png", zipOut);
zipOut.closeArchiveEntry();
}
return cbzFile;
}
private File createEmptyCbzFile() throws IOException {
File cbzFile = Files.createFile(tempDir.resolve("empty-comic.cbz")).toFile();
try (ZipArchiveOutputStream zipOut = new ZipArchiveOutputStream(new FileOutputStream(cbzFile))) {
ZipArchiveEntry textEntry = new ZipArchiveEntry("readme.txt");
zipOut.putArchiveEntry(textEntry);
zipOut.write("This is not an image".getBytes());
zipOut.closeArchiveEntry();
}
return cbzFile;
}
private File createMultiPageCbzFile() throws IOException {
File cbzFile = Files.createFile(tempDir.resolve("multi-page-comic.cbz")).toFile();
try (ZipArchiveOutputStream zipOut = new ZipArchiveOutputStream(new FileOutputStream(cbzFile))) {
Color[] colors = {Color.RED, Color.GREEN, Color.BLUE, Color.YELLOW, Color.MAGENTA};
for (int i = 0; i < 5; i++) {
BufferedImage testImage = createTestImage("Page " + (i + 1), colors[i]);
ZipArchiveEntry imageEntry = new ZipArchiveEntry(String.format("page%02d.png", i + 1));
zipOut.putArchiveEntry(imageEntry);
ImageIO.write(testImage, "png", zipOut);
zipOut.closeArchiveEntry();
}
}
return cbzFile;
}
private BufferedImage createTestImage(String text, Color backgroundColor) {
BufferedImage image = new BufferedImage(200, 300, BufferedImage.TYPE_INT_RGB);
Graphics2D g2d = image.createGraphics();
g2d.setColor(backgroundColor);
g2d.fillRect(0, 0, 200, 300);
g2d.setColor(Color.BLACK);
g2d.setFont(new Font("Arial", Font.BOLD, 16));
g2d.drawString(text, 50, 150);
g2d.dispose();
return image;
}
private BookEntity createTestBookEntity() {
BookEntity bookEntity = new BookEntity();
BookMetadataEntity metadata = new BookMetadataEntity();
metadata.setTitle("Test Comic Book");
metadata.setLanguage("en");
bookEntity.setMetadata(metadata);
return bookEntity;
}
private void verifyEpubStructure(File epubFile) throws IOException {
try (ZipFile zipFile = ZipFile.builder().setFile(epubFile).get()) {
List<ZipArchiveEntry> entries = Collections.list(zipFile.getEntries());
assertThat(entries).extracting(ZipArchiveEntry::getName)
.contains(
"mimetype",
"META-INF/container.xml",
"OEBPS/content.opf",
"OEBPS/toc.ncx",
"OEBPS/nav.xhtml",
"OEBPS/Styles/stylesheet.css"
);
ZipArchiveEntry mimetypeEntry = zipFile.getEntry("mimetype");
assertThat(mimetypeEntry).isNotNull();
assertThat(mimetypeEntry.getMethod()).isEqualTo(ZipArchiveEntry.STORED);
try (InputStream mimetypeStream = zipFile.getInputStream(mimetypeEntry)) {
String mimetypeContent = new String(mimetypeStream.readAllBytes());
assertThat(mimetypeContent).isEqualTo("application/epub+zip");
}
assertThat(entries).anyMatch(entry -> entry.getName().startsWith("OEBPS/Images/"));
assertThat(entries).anyMatch(entry -> entry.getName().startsWith("OEBPS/Text/"));
assertThat(entries).anyMatch(entry -> entry.getName().endsWith(".jpg"));
assertThat(entries).anyMatch(entry -> entry.getName().endsWith(".xhtml"));
}
}
private void verifyPageOrderInEpub(File epubFile, int expectedPageCount) throws IOException {
try (ZipFile zipFile = ZipFile.builder().setFile(epubFile).get()) {
List<ZipArchiveEntry> imageEntries = Collections.list(zipFile.getEntries()).stream()
.filter(entry -> entry.getName().startsWith("OEBPS/Images/page-"))
.sorted((e1, e2) -> e1.getName().compareTo(e2.getName()))
.toList();
List<ZipArchiveEntry> htmlEntries = Collections.list(zipFile.getEntries()).stream()
.filter(entry -> entry.getName().startsWith("OEBPS/Text/page-"))
.sorted((e1, e2) -> e1.getName().compareTo(e2.getName()))
.toList();
assertThat(imageEntries).hasSize(expectedPageCount);
assertThat(htmlEntries).hasSize(expectedPageCount);
for (int i = 0; i < expectedPageCount; i++) {
String expectedImageName = String.format("OEBPS/Images/page-%04d.jpg", i + 1);
String expectedHtmlName = String.format("OEBPS/Text/page-%04d.xhtml", i + 1);
assertThat(imageEntries.get(i).getName()).isEqualTo(expectedImageName);
assertThat(htmlEntries.get(i).getName()).isEqualTo(expectedHtmlName);
}
}
}
}

View File

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

View File

@@ -225,6 +225,46 @@
</div>
</div>
<p-divider></p-divider>
<div class="setting-item">
<div class="setting-info">
<div class="setting-label-row">
<label class="setting-label">Convert CBX to EPUB</label>
<p-toggle-switch
id="convertCbxToEpub"
[(ngModel)]="koboSettings.convertCbxToEpub"
(ngModelChange)="onToggleChange()">
</p-toggle-switch>
</div>
<p class="setting-description">
Converts CBX files to EPUB during Kobo sync, allowing CBX files to be downloaded and read on Kobo devices.
</p>
</div>
</div>
<div class="setting-item">
<div class="setting-info">
<div class="setting-label-row">
<label class="setting-label">CBX Conversion Size Limit: {{ koboSettings.conversionLimitInMbForCbx }} MB</label>
<div class="slider-container">
<p-slider
id="conversionLimitForCbx"
[(ngModel)]="koboSettings.conversionLimitInMbForCbx"
[min]="1"
[max]="500"
[step]="1"
(ngModelChange)="onSliderChange()">
</p-slider>
</div>
</div>
<p class="setting-description">
Comic book archives can be very large due to high-resolution images. Set a reasonable limit to prevent server overload during conversion.
<br>
<strong>Recommended:</strong> 100-200MB for most comic libraries. CBX files over this limit will not be converted or synced to the device.
</p>
</div>
</div>
</div>
</div>
}

View File

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

View File

@@ -102,6 +102,8 @@ export interface PublicReviewSettings {
export interface KoboSettings {
convertToKepub: boolean;
conversionLimitInMb: number;
convertCbxToEpub: boolean;
conversionLimitInMbForCbx: number;
forceEnableHyphenation: boolean;
}