PDF & ZIP IMPORT

This commit is contained in:
Stefan Hardegger
2025-12-05 10:21:03 +01:00
parent b1b5bbbccd
commit 77aec8a849
18 changed files with 3490 additions and 22 deletions

View File

@@ -117,7 +117,12 @@
<artifactId>epublib-core</artifactId>
<version>3.1</version>
</dependency>
<dependency>
<groupId>org.apache.pdfbox</groupId>
<artifactId>pdfbox</artifactId>
<version>3.0.3</version>
</dependency>
<!-- Test dependencies -->
<dependency>
<groupId>org.springframework.boot</groupId>

View File

@@ -44,12 +44,14 @@ public class StoryController {
private final ReadingTimeService readingTimeService;
private final EPUBImportService epubImportService;
private final EPUBExportService epubExportService;
private final PDFImportService pdfImportService;
private final ZIPImportService zipImportService;
private final AsyncImageProcessingService asyncImageProcessingService;
private final ImageProcessingProgressService progressService;
public StoryController(StoryService storyService,
public StoryController(StoryService storyService,
AuthorService authorService,
SeriesService seriesService,
SeriesService seriesService,
HtmlSanitizationService sanitizationService,
ImageService imageService,
CollectionService collectionService,
@@ -57,6 +59,8 @@ public class StoryController {
ReadingTimeService readingTimeService,
EPUBImportService epubImportService,
EPUBExportService epubExportService,
PDFImportService pdfImportService,
ZIPImportService zipImportService,
AsyncImageProcessingService asyncImageProcessingService,
ImageProcessingProgressService progressService) {
this.storyService = storyService;
@@ -69,6 +73,8 @@ public class StoryController {
this.readingTimeService = readingTimeService;
this.epubImportService = epubImportService;
this.epubExportService = epubExportService;
this.pdfImportService = pdfImportService;
this.zipImportService = zipImportService;
this.asyncImageProcessingService = asyncImageProcessingService;
this.progressService = progressService;
}
@@ -907,26 +913,147 @@ public class StoryController {
@PostMapping("/epub/validate")
public ResponseEntity<Map<String, Object>> validateEPUBFile(@RequestParam("file") MultipartFile file) {
logger.info("Validating EPUB file: {}", file.getOriginalFilename());
try {
List<String> errors = epubImportService.validateEPUBFile(file);
Map<String, Object> response = Map.of(
"valid", errors.isEmpty(),
"errors", errors,
"filename", file.getOriginalFilename(),
"size", file.getSize()
);
return ResponseEntity.ok(response);
} catch (Exception e) {
logger.error("Error validating EPUB file: {}", e.getMessage(), e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(Map.of("error", "Failed to validate EPUB file"));
}
}
// PDF Import endpoint
@PostMapping("/pdf/import")
public ResponseEntity<FileImportResponse> importPDF(
@RequestParam("file") MultipartFile file,
@RequestParam(required = false) UUID authorId,
@RequestParam(required = false) String authorName,
@RequestParam(required = false) UUID seriesId,
@RequestParam(required = false) String seriesName,
@RequestParam(required = false) Integer seriesVolume,
@RequestParam(required = false) List<String> tags,
@RequestParam(defaultValue = "true") Boolean createMissingAuthor,
@RequestParam(defaultValue = "true") Boolean createMissingSeries,
@RequestParam(defaultValue = "true") Boolean extractImages) {
logger.info("Importing PDF file: {}", file.getOriginalFilename());
PDFImportRequest request = new PDFImportRequest();
request.setPdfFile(file);
request.setAuthorId(authorId);
request.setAuthorName(authorName);
request.setSeriesId(seriesId);
request.setSeriesName(seriesName);
request.setSeriesVolume(seriesVolume);
request.setTags(tags);
request.setCreateMissingAuthor(createMissingAuthor);
request.setCreateMissingSeries(createMissingSeries);
request.setExtractImages(extractImages);
try {
FileImportResponse response = pdfImportService.importPDF(request);
if (response.isSuccess()) {
logger.info("Successfully imported PDF: {} (Story ID: {})",
response.getStoryTitle(), response.getStoryId());
return ResponseEntity.ok(response);
} else {
logger.warn("PDF import failed: {}", response.getMessage());
return ResponseEntity.badRequest().body(response);
}
} catch (Exception e) {
logger.error("Error importing PDF: {}", e.getMessage(), e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(FileImportResponse.error("Internal server error: " + e.getMessage(), file.getOriginalFilename()));
}
}
// Validate PDF file
@PostMapping("/pdf/validate")
public ResponseEntity<Map<String, Object>> validatePDFFile(@RequestParam("file") MultipartFile file) {
logger.info("Validating PDF file: {}", file.getOriginalFilename());
try {
List<String> errors = pdfImportService.validatePDFFile(file);
Map<String, Object> response = Map.of(
"valid", errors.isEmpty(),
"errors", errors,
"filename", file.getOriginalFilename(),
"size", file.getSize()
);
return ResponseEntity.ok(response);
} catch (Exception e) {
logger.error("Error validating PDF file: {}", e.getMessage(), e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(Map.of("error", "Failed to validate PDF file"));
}
}
// ZIP Analysis endpoint - Step 1: Upload and analyze ZIP contents
@PostMapping("/zip/analyze")
public ResponseEntity<ZIPAnalysisResponse> analyzeZIPFile(@RequestParam("file") MultipartFile file) {
logger.info("Analyzing ZIP file: {}", file.getOriginalFilename());
try {
ZIPAnalysisResponse response = zipImportService.analyzeZIPFile(file);
if (response.isSuccess()) {
logger.info("Successfully analyzed ZIP file: {} ({} files found)",
file.getOriginalFilename(), response.getTotalFiles());
return ResponseEntity.ok(response);
} else {
logger.warn("ZIP analysis failed: {}", response.getMessage());
return ResponseEntity.badRequest().body(response);
}
} catch (Exception e) {
logger.error("Error analyzing ZIP file: {}", e.getMessage(), e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(ZIPAnalysisResponse.error("Internal server error: " + e.getMessage()));
}
}
// ZIP Import endpoint - Step 2: Import selected files from analyzed ZIP
@PostMapping("/zip/import")
public ResponseEntity<ZIPImportResponse> importFromZIP(@Valid @RequestBody ZIPImportRequest request) {
logger.info("Importing files from ZIP session: {}", request.getZipSessionId());
try {
ZIPImportResponse response = zipImportService.importFromZIP(request);
logger.info("ZIP import completed: {} total, {} successful, {} failed",
response.getTotalFiles(), response.getSuccessfulImports(), response.getFailedImports());
if (response.isSuccess()) {
return ResponseEntity.ok(response);
} else {
return ResponseEntity.badRequest().body(response);
}
} catch (Exception e) {
logger.error("Error importing from ZIP: {}", e.getMessage(), e);
ZIPImportResponse errorResponse = new ZIPImportResponse();
errorResponse.setSuccess(false);
errorResponse.setMessage("Internal server error: " + e.getMessage());
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(errorResponse);
}
}
// Request DTOs
public static class CreateStoryRequest {
private String title;

View File

@@ -0,0 +1,132 @@
package com.storycove.dto;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
public class FileImportResponse {
private boolean success;
private String message;
private UUID storyId;
private String storyTitle;
private String fileName;
private String fileType; // "EPUB" or "PDF"
private Integer wordCount;
private Integer extractedImages;
private List<String> warnings;
private List<String> errors;
public FileImportResponse() {
this.warnings = new ArrayList<>();
this.errors = new ArrayList<>();
}
public FileImportResponse(boolean success, String message) {
this();
this.success = success;
this.message = message;
}
public static FileImportResponse success(UUID storyId, String storyTitle, String fileType) {
FileImportResponse response = new FileImportResponse(true, "File imported successfully");
response.setStoryId(storyId);
response.setStoryTitle(storyTitle);
response.setFileType(fileType);
return response;
}
public static FileImportResponse error(String message, String fileName) {
FileImportResponse response = new FileImportResponse(false, message);
response.setFileName(fileName);
return response;
}
public void addWarning(String warning) {
this.warnings.add(warning);
}
public void addError(String error) {
this.errors.add(error);
}
public boolean isSuccess() {
return success;
}
public void setSuccess(boolean success) {
this.success = success;
}
public String getMessage() {
return message;
}
public void setMessage(String message) {
this.message = message;
}
public UUID getStoryId() {
return storyId;
}
public void setStoryId(UUID storyId) {
this.storyId = storyId;
}
public String getStoryTitle() {
return storyTitle;
}
public void setStoryTitle(String storyTitle) {
this.storyTitle = storyTitle;
}
public String getFileName() {
return fileName;
}
public void setFileName(String fileName) {
this.fileName = fileName;
}
public String getFileType() {
return fileType;
}
public void setFileType(String fileType) {
this.fileType = fileType;
}
public Integer getWordCount() {
return wordCount;
}
public void setWordCount(Integer wordCount) {
this.wordCount = wordCount;
}
public Integer getExtractedImages() {
return extractedImages;
}
public void setExtractedImages(Integer extractedImages) {
this.extractedImages = extractedImages;
}
public List<String> getWarnings() {
return warnings;
}
public void setWarnings(List<String> warnings) {
this.warnings = warnings;
}
public List<String> getErrors() {
return errors;
}
public void setErrors(List<String> errors) {
this.errors = errors;
}
}

View File

@@ -0,0 +1,76 @@
package com.storycove.dto;
public class FileInfoDto {
private String fileName;
private String fileType; // "EPUB" or "PDF"
private Long fileSize;
private String extractedTitle;
private String extractedAuthor;
private boolean hasMetadata;
private String error; // If file couldn't be analyzed
public FileInfoDto() {}
public FileInfoDto(String fileName, String fileType, Long fileSize) {
this.fileName = fileName;
this.fileType = fileType;
this.fileSize = fileSize;
}
public String getFileName() {
return fileName;
}
public void setFileName(String fileName) {
this.fileName = fileName;
}
public String getFileType() {
return fileType;
}
public void setFileType(String fileType) {
this.fileType = fileType;
}
public Long getFileSize() {
return fileSize;
}
public void setFileSize(Long fileSize) {
this.fileSize = fileSize;
}
public String getExtractedTitle() {
return extractedTitle;
}
public void setExtractedTitle(String extractedTitle) {
this.extractedTitle = extractedTitle;
}
public String getExtractedAuthor() {
return extractedAuthor;
}
public void setExtractedAuthor(String extractedAuthor) {
this.extractedAuthor = extractedAuthor;
}
public boolean isHasMetadata() {
return hasMetadata;
}
public void setHasMetadata(boolean hasMetadata) {
this.hasMetadata = hasMetadata;
}
public String getError() {
return error;
}
public void setError(String error) {
this.error = error;
}
}

View File

@@ -0,0 +1,113 @@
package com.storycove.dto;
import jakarta.validation.constraints.NotNull;
import org.springframework.web.multipart.MultipartFile;
import java.util.List;
import java.util.UUID;
public class PDFImportRequest {
@NotNull(message = "PDF file is required")
private MultipartFile pdfFile;
private UUID authorId;
private String authorName;
private UUID seriesId;
private String seriesName;
private Integer seriesVolume;
private List<String> tags;
private Boolean createMissingAuthor = true;
private Boolean createMissingSeries = true;
private Boolean extractImages = true;
public PDFImportRequest() {}
public MultipartFile getPdfFile() {
return pdfFile;
}
public void setPdfFile(MultipartFile pdfFile) {
this.pdfFile = pdfFile;
}
public UUID getAuthorId() {
return authorId;
}
public void setAuthorId(UUID authorId) {
this.authorId = authorId;
}
public String getAuthorName() {
return authorName;
}
public void setAuthorName(String authorName) {
this.authorName = authorName;
}
public UUID getSeriesId() {
return seriesId;
}
public void setSeriesId(UUID seriesId) {
this.seriesId = seriesId;
}
public String getSeriesName() {
return seriesName;
}
public void setSeriesName(String seriesName) {
this.seriesName = seriesName;
}
public Integer getSeriesVolume() {
return seriesVolume;
}
public void setSeriesVolume(Integer seriesVolume) {
this.seriesVolume = seriesVolume;
}
public List<String> getTags() {
return tags;
}
public void setTags(List<String> tags) {
this.tags = tags;
}
public Boolean getCreateMissingAuthor() {
return createMissingAuthor;
}
public void setCreateMissingAuthor(Boolean createMissingAuthor) {
this.createMissingAuthor = createMissingAuthor;
}
public Boolean getCreateMissingSeries() {
return createMissingSeries;
}
public void setCreateMissingSeries(Boolean createMissingSeries) {
this.createMissingSeries = createMissingSeries;
}
public Boolean getExtractImages() {
return extractImages;
}
public void setExtractImages(Boolean extractImages) {
this.extractImages = extractImages;
}
}

View File

@@ -0,0 +1,98 @@
package com.storycove.dto;
import java.util.ArrayList;
import java.util.List;
public class ZIPAnalysisResponse {
private boolean success;
private String message;
private String zipFileName;
private int totalFiles;
private int validFiles;
private List<FileInfoDto> files;
private List<String> warnings;
public ZIPAnalysisResponse() {
this.files = new ArrayList<>();
this.warnings = new ArrayList<>();
}
public static ZIPAnalysisResponse success(String zipFileName, List<FileInfoDto> files) {
ZIPAnalysisResponse response = new ZIPAnalysisResponse();
response.setSuccess(true);
response.setMessage("ZIP file analyzed successfully");
response.setZipFileName(zipFileName);
response.setFiles(files);
response.setTotalFiles(files.size());
response.setValidFiles((int) files.stream().filter(f -> f.getError() == null).count());
return response;
}
public static ZIPAnalysisResponse error(String message) {
ZIPAnalysisResponse response = new ZIPAnalysisResponse();
response.setSuccess(false);
response.setMessage(message);
return response;
}
public void addWarning(String warning) {
this.warnings.add(warning);
}
public boolean isSuccess() {
return success;
}
public void setSuccess(boolean success) {
this.success = success;
}
public String getMessage() {
return message;
}
public void setMessage(String message) {
this.message = message;
}
public String getZipFileName() {
return zipFileName;
}
public void setZipFileName(String zipFileName) {
this.zipFileName = zipFileName;
}
public int getTotalFiles() {
return totalFiles;
}
public void setTotalFiles(int totalFiles) {
this.totalFiles = totalFiles;
}
public int getValidFiles() {
return validFiles;
}
public void setValidFiles(int validFiles) {
this.validFiles = validFiles;
}
public List<FileInfoDto> getFiles() {
return files;
}
public void setFiles(List<FileInfoDto> files) {
this.files = files;
}
public List<String> getWarnings() {
return warnings;
}
public void setWarnings(List<String> warnings) {
this.warnings = warnings;
}
}

View File

@@ -0,0 +1,177 @@
package com.storycove.dto;
import jakarta.validation.constraints.NotNull;
import java.util.List;
import java.util.Map;
import java.util.UUID;
public class ZIPImportRequest {
@NotNull(message = "ZIP session ID is required")
private String zipSessionId; // Temporary ID for the uploaded ZIP file
@NotNull(message = "Selected files are required")
private List<String> selectedFiles; // List of file names to import
// Per-file metadata overrides (key = fileName)
private Map<String, FileImportMetadata> fileMetadata;
// Default metadata for all files (if not specified per file)
private UUID defaultAuthorId;
private String defaultAuthorName;
private UUID defaultSeriesId;
private String defaultSeriesName;
private List<String> defaultTags;
private Boolean createMissingAuthor = true;
private Boolean createMissingSeries = true;
private Boolean extractImages = true;
public ZIPImportRequest() {}
public static class FileImportMetadata {
private UUID authorId;
private String authorName;
private UUID seriesId;
private String seriesName;
private Integer seriesVolume;
private List<String> tags;
public UUID getAuthorId() {
return authorId;
}
public void setAuthorId(UUID authorId) {
this.authorId = authorId;
}
public String getAuthorName() {
return authorName;
}
public void setAuthorName(String authorName) {
this.authorName = authorName;
}
public UUID getSeriesId() {
return seriesId;
}
public void setSeriesId(UUID seriesId) {
this.seriesId = seriesId;
}
public String getSeriesName() {
return seriesName;
}
public void setSeriesName(String seriesName) {
this.seriesName = seriesName;
}
public Integer getSeriesVolume() {
return seriesVolume;
}
public void setSeriesVolume(Integer seriesVolume) {
this.seriesVolume = seriesVolume;
}
public List<String> getTags() {
return tags;
}
public void setTags(List<String> tags) {
this.tags = tags;
}
}
public String getZipSessionId() {
return zipSessionId;
}
public void setZipSessionId(String zipSessionId) {
this.zipSessionId = zipSessionId;
}
public List<String> getSelectedFiles() {
return selectedFiles;
}
public void setSelectedFiles(List<String> selectedFiles) {
this.selectedFiles = selectedFiles;
}
public Map<String, FileImportMetadata> getFileMetadata() {
return fileMetadata;
}
public void setFileMetadata(Map<String, FileImportMetadata> fileMetadata) {
this.fileMetadata = fileMetadata;
}
public UUID getDefaultAuthorId() {
return defaultAuthorId;
}
public void setDefaultAuthorId(UUID defaultAuthorId) {
this.defaultAuthorId = defaultAuthorId;
}
public String getDefaultAuthorName() {
return defaultAuthorName;
}
public void setDefaultAuthorName(String defaultAuthorName) {
this.defaultAuthorName = defaultAuthorName;
}
public UUID getDefaultSeriesId() {
return defaultSeriesId;
}
public void setDefaultSeriesId(UUID defaultSeriesId) {
this.defaultSeriesId = defaultSeriesId;
}
public String getDefaultSeriesName() {
return defaultSeriesName;
}
public void setDefaultSeriesName(String defaultSeriesName) {
this.defaultSeriesName = defaultSeriesName;
}
public List<String> getDefaultTags() {
return defaultTags;
}
public void setDefaultTags(List<String> defaultTags) {
this.defaultTags = defaultTags;
}
public Boolean getCreateMissingAuthor() {
return createMissingAuthor;
}
public void setCreateMissingAuthor(Boolean createMissingAuthor) {
this.createMissingAuthor = createMissingAuthor;
}
public Boolean getCreateMissingSeries() {
return createMissingSeries;
}
public void setCreateMissingSeries(Boolean createMissingSeries) {
this.createMissingSeries = createMissingSeries;
}
public Boolean getExtractImages() {
return extractImages;
}
public void setExtractImages(Boolean extractImages) {
this.extractImages = extractImages;
}
}

View File

@@ -0,0 +1,101 @@
package com.storycove.dto;
import java.util.ArrayList;
import java.util.List;
public class ZIPImportResponse {
private boolean success;
private String message;
private int totalFiles;
private int successfulImports;
private int failedImports;
private List<FileImportResponse> results;
private List<String> warnings;
public ZIPImportResponse() {
this.results = new ArrayList<>();
this.warnings = new ArrayList<>();
}
public static ZIPImportResponse create(List<FileImportResponse> results) {
ZIPImportResponse response = new ZIPImportResponse();
response.setResults(results);
response.setTotalFiles(results.size());
response.setSuccessfulImports((int) results.stream().filter(FileImportResponse::isSuccess).count());
response.setFailedImports((int) results.stream().filter(r -> !r.isSuccess()).count());
if (response.getFailedImports() == 0) {
response.setSuccess(true);
response.setMessage("All files imported successfully");
} else if (response.getSuccessfulImports() == 0) {
response.setSuccess(false);
response.setMessage("All file imports failed");
} else {
response.setSuccess(true);
response.setMessage("Partial success: " + response.getSuccessfulImports() + " imported, " + response.getFailedImports() + " failed");
}
return response;
}
public void addWarning(String warning) {
this.warnings.add(warning);
}
public boolean isSuccess() {
return success;
}
public void setSuccess(boolean success) {
this.success = success;
}
public String getMessage() {
return message;
}
public void setMessage(String message) {
this.message = message;
}
public int getTotalFiles() {
return totalFiles;
}
public void setTotalFiles(int totalFiles) {
this.totalFiles = totalFiles;
}
public int getSuccessfulImports() {
return successfulImports;
}
public void setSuccessfulImports(int successfulImports) {
this.successfulImports = successfulImports;
}
public int getFailedImports() {
return failedImports;
}
public void setFailedImports(int failedImports) {
this.failedImports = failedImports;
}
public List<FileImportResponse> getResults() {
return results;
}
public void setResults(List<FileImportResponse> results) {
this.results = results;
}
public List<String> getWarnings() {
return warnings;
}
public void setWarnings(List<String> warnings) {
this.warnings = warnings;
}
}

View File

@@ -0,0 +1,683 @@
package com.storycove.service;
import com.storycove.dto.FileImportResponse;
import com.storycove.dto.PDFImportRequest;
import com.storycove.entity.*;
import com.storycove.service.exception.InvalidFileException;
import com.storycove.service.exception.ResourceNotFoundException;
import org.apache.pdfbox.Loader;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDDocumentInformation;
import org.apache.pdfbox.pdmodel.PDPage;
import org.apache.pdfbox.pdmodel.graphics.image.PDImageXObject;
import org.apache.pdfbox.text.PDFTextStripper;
import org.apache.pdfbox.text.TextPosition;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.multipart.MultipartFile;
import javax.imageio.ImageIO;
import java.awt.image.BufferedImage;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.util.*;
import java.util.regex.Pattern;
@Service
@Transactional
public class PDFImportService {
private static final Logger log = LoggerFactory.getLogger(PDFImportService.class);
private static final Pattern PAGE_NUMBER_PATTERN = Pattern.compile("^\\s*\\d+\\s*$");
private static final int MAX_FILE_SIZE = 300 * 1024 * 1024; // 300MB
private final StoryService storyService;
private final AuthorService authorService;
private final SeriesService seriesService;
private final TagService tagService;
private final HtmlSanitizationService sanitizationService;
private final ImageService imageService;
private final LibraryService libraryService;
@Autowired
public PDFImportService(StoryService storyService,
AuthorService authorService,
SeriesService seriesService,
TagService tagService,
HtmlSanitizationService sanitizationService,
ImageService imageService,
LibraryService libraryService) {
this.storyService = storyService;
this.authorService = authorService;
this.seriesService = seriesService;
this.tagService = tagService;
this.sanitizationService = sanitizationService;
this.imageService = imageService;
this.libraryService = libraryService;
}
public FileImportResponse importPDF(PDFImportRequest request) {
try {
MultipartFile pdfFile = request.getPdfFile();
if (pdfFile == null || pdfFile.isEmpty()) {
return FileImportResponse.error("PDF file is required", null);
}
if (!isValidPDFFile(pdfFile)) {
return FileImportResponse.error("Invalid PDF file format", pdfFile.getOriginalFilename());
}
log.info("Parsing PDF file: {}", pdfFile.getOriginalFilename());
PDDocument document = parsePDFFile(pdfFile);
try {
log.info("Extracting metadata from PDF");
PDFMetadata metadata = extractMetadata(document, pdfFile.getOriginalFilename());
// Validate author is provided
String authorName = determineAuthorName(request, metadata);
if (authorName == null || authorName.trim().isEmpty()) {
return FileImportResponse.error("Author name is required for PDF import. No author found in PDF metadata.", pdfFile.getOriginalFilename());
}
log.info("Extracting content and images from PDF");
PDFContent content = extractContentWithImages(document, request.getExtractImages());
log.info("Creating story entity from PDF");
Story story = createStoryFromPDF(metadata, content, request, authorName);
log.info("Saving story to database: {}", story.getTitle());
Story savedStory = storyService.create(story);
log.info("Story saved successfully with ID: {}", savedStory.getId());
// Process and save embedded images if any were extracted
if (request.getExtractImages() && !content.getImages().isEmpty()) {
try {
log.info("Processing {} embedded images for story: {}", content.getImages().size(), savedStory.getId());
String updatedContent = processAndSaveImages(content, savedStory.getId());
if (!updatedContent.equals(savedStory.getContentHtml())) {
savedStory.setContentHtml(updatedContent);
savedStory = storyService.update(savedStory.getId(), savedStory);
log.info("Story content updated with processed images");
}
} catch (Exception e) {
log.error("Failed to process embedded images for story {}: {}", savedStory.getId(), e.getMessage(), e);
}
}
log.info("PDF import completed successfully for: {}", savedStory.getTitle());
FileImportResponse response = FileImportResponse.success(savedStory.getId(), savedStory.getTitle(), "PDF");
response.setFileName(pdfFile.getOriginalFilename());
response.setWordCount(savedStory.getWordCount());
response.setExtractedImages(content.getImages().size());
return response;
} finally {
document.close();
}
} catch (Exception e) {
log.error("PDF import failed with exception: {}", e.getMessage(), e);
return FileImportResponse.error("Failed to import PDF: " + e.getMessage(),
request.getPdfFile() != null ? request.getPdfFile().getOriginalFilename() : null);
}
}
private boolean isValidPDFFile(MultipartFile file) {
String filename = file.getOriginalFilename();
if (filename == null || !filename.toLowerCase().endsWith(".pdf")) {
return false;
}
if (file.getSize() > MAX_FILE_SIZE) {
log.warn("PDF file size {} exceeds maximum {}", file.getSize(), MAX_FILE_SIZE);
return false;
}
String contentType = file.getContentType();
return "application/pdf".equals(contentType) || contentType == null;
}
private PDDocument parsePDFFile(MultipartFile pdfFile) throws IOException {
try (InputStream inputStream = pdfFile.getInputStream()) {
return Loader.loadPDF(inputStream.readAllBytes());
} catch (Exception e) {
throw new InvalidFileException("Failed to parse PDF file: " + e.getMessage());
}
}
private PDFMetadata extractMetadata(PDDocument document, String fileName) {
PDFMetadata metadata = new PDFMetadata();
PDDocumentInformation info = document.getDocumentInformation();
if (info != null) {
metadata.setTitle(info.getTitle());
metadata.setAuthor(info.getAuthor());
metadata.setSubject(info.getSubject());
metadata.setKeywords(info.getKeywords());
metadata.setCreator(info.getCreator());
}
// Use filename as fallback title
if (metadata.getTitle() == null || metadata.getTitle().trim().isEmpty()) {
String titleFromFilename = fileName.replaceAll("\\.pdf$", "").replaceAll("[_-]", " ");
metadata.setTitle(titleFromFilename);
}
metadata.setPageCount(document.getNumberOfPages());
return metadata;
}
private PDFContent extractContentWithImages(PDDocument document, Boolean extractImages) throws IOException {
PDFContent content = new PDFContent();
StringBuilder htmlContent = new StringBuilder();
List<PDFImage> images = new ArrayList<>();
boolean shouldExtractImages = extractImages != null && extractImages;
// Extract images first to know their positions
if (shouldExtractImages) {
images = extractImagesFromPDF(document);
log.info("Extracted {} images from PDF", images.size());
}
// Extract text with custom stripper to filter headers/footers
CustomPDFTextStripper stripper = new CustomPDFTextStripper();
stripper.setSortByPosition(true);
// Process page by page to insert images at correct positions
for (int pageNum = 0; pageNum < document.getNumberOfPages(); pageNum++) {
stripper.setStartPage(pageNum + 1);
stripper.setEndPage(pageNum + 1);
String pageText = stripper.getText(document);
// Filter out obvious page numbers and headers/footers
pageText = filterHeadersFooters(pageText, pageNum + 1);
if (pageText != null && !pageText.trim().isEmpty()) {
// Convert text to HTML paragraphs
String[] paragraphs = pageText.split("\\n\\s*\\n");
for (String para : paragraphs) {
String trimmed = para.trim();
if (!trimmed.isEmpty() && !isLikelyHeaderFooter(trimmed)) {
htmlContent.append("<p>").append(escapeHtml(trimmed)).append("</p>\n");
}
}
}
// Insert images that belong to this page
if (shouldExtractImages) {
for (PDFImage image : images) {
if (image.getPageNumber() == pageNum) {
// Add placeholder for image (will be replaced with actual path after saving)
htmlContent.append("<img data-pdf-image-id=\"")
.append(image.getImageId())
.append("\" alt=\"Image from PDF\" />\n");
}
}
}
}
content.setHtmlContent(htmlContent.toString());
content.setImages(images);
return content;
}
private List<PDFImage> extractImagesFromPDF(PDDocument document) {
List<PDFImage> images = new ArrayList<>();
int imageCounter = 0;
for (int pageNum = 0; pageNum < document.getNumberOfPages(); pageNum++) {
try {
PDPage page = document.getPage(pageNum);
// Get all images from the page resources
Iterable<org.apache.pdfbox.cos.COSName> names = page.getResources().getXObjectNames();
for (org.apache.pdfbox.cos.COSName name : names) {
try {
org.apache.pdfbox.pdmodel.graphics.PDXObject xObject = page.getResources().getXObject(name);
if (xObject instanceof PDImageXObject) {
PDImageXObject imageObj = (PDImageXObject) xObject;
BufferedImage bImage = imageObj.getImage();
// Skip very small images (likely decorative or icons)
if (bImage.getWidth() < 50 || bImage.getHeight() < 50) {
continue;
}
// Convert BufferedImage to byte array
ByteArrayOutputStream baos = new ByteArrayOutputStream();
ImageIO.write(bImage, "png", baos);
byte[] imageBytes = baos.toByteArray();
PDFImage pdfImage = new PDFImage();
pdfImage.setImageId("pdf-img-" + imageCounter);
pdfImage.setPageNumber(pageNum);
pdfImage.setImageData(imageBytes);
pdfImage.setWidth(bImage.getWidth());
pdfImage.setHeight(bImage.getHeight());
images.add(pdfImage);
imageCounter++;
}
} catch (Exception e) {
log.warn("Failed to extract image '{}' from page {}: {}", name, pageNum, e.getMessage());
}
}
} catch (Exception e) {
log.warn("Failed to process images on page {}: {}", pageNum, e.getMessage());
}
}
return images;
}
private String processAndSaveImages(PDFContent content, UUID storyId) throws IOException {
String htmlContent = content.getHtmlContent();
// Get current library ID for constructing image URLs
String currentLibraryId = libraryService.getCurrentLibraryId();
if (currentLibraryId == null || currentLibraryId.trim().isEmpty()) {
log.warn("Current library ID is null or empty when processing PDF images for story: {}", storyId);
currentLibraryId = "default";
}
for (PDFImage image : content.getImages()) {
try {
// Create a MultipartFile from the image bytes
MultipartFile imageFile = new PDFImageMultipartFile(
image.getImageData(),
"pdf-image-" + image.getImageId() + ".png",
"image/png"
);
// Save the image using ImageService (ImageType.CONTENT saves to content directory)
String imagePath = imageService.uploadImage(imageFile, ImageService.ImageType.CONTENT);
// Construct the full URL with library ID
// imagePath will be like "content/uuid.png"
String imageUrl = "/api/files/images/" + currentLibraryId + "/" + imagePath;
// Replace placeholder with actual image URL
String placeholder = "data-pdf-image-id=\"" + image.getImageId() + "\"";
String replacement = "src=\"" + imageUrl + "\"";
htmlContent = htmlContent.replace(placeholder, replacement);
log.debug("Saved PDF image {} to path: {} (URL: {})", image.getImageId(), imagePath, imageUrl);
} catch (Exception e) {
log.error("Failed to save PDF image {}: {}", image.getImageId(), e.getMessage());
// Remove the placeholder if we failed to save the image
htmlContent = htmlContent.replaceAll(
"<img data-pdf-image-id=\"" + image.getImageId() + "\"[^>]*>",
""
);
}
}
return htmlContent;
}
private String filterHeadersFooters(String text, int pageNumber) {
if (text == null) return "";
String[] lines = text.split("\\n");
if (lines.length <= 2) return text; // Too short to have headers/footers
StringBuilder filtered = new StringBuilder();
// Skip first line if it looks like a header
int startIdx = 0;
if (lines.length > 1 && isLikelyHeaderFooter(lines[0])) {
startIdx = 1;
}
// Skip last line if it looks like a footer or page number
int endIdx = lines.length;
if (lines.length > 1 && isLikelyHeaderFooter(lines[lines.length - 1])) {
endIdx = lines.length - 1;
}
for (int i = startIdx; i < endIdx; i++) {
filtered.append(lines[i]).append("\n");
}
return filtered.toString();
}
private boolean isLikelyHeaderFooter(String line) {
String trimmed = line.trim();
// Check if it's just a page number
if (PAGE_NUMBER_PATTERN.matcher(trimmed).matches()) {
return true;
}
// Check if it's very short (likely header/footer)
if (trimmed.length() < 3) {
return true;
}
// Check for common header/footer patterns
String lower = trimmed.toLowerCase();
if (lower.matches(".*page \\d+.*") ||
lower.matches(".*\\d+ of \\d+.*") ||
lower.matches("chapter \\d+") ||
lower.matches("\\d+")) {
return true;
}
return false;
}
private String determineAuthorName(PDFImportRequest request, PDFMetadata metadata) {
// Priority: request.authorName > request.authorId > metadata.author
if (request.getAuthorName() != null && !request.getAuthorName().trim().isEmpty()) {
return request.getAuthorName().trim();
}
if (request.getAuthorId() != null) {
try {
Author author = authorService.findById(request.getAuthorId());
return author.getName();
} catch (ResourceNotFoundException e) {
log.warn("Author ID {} not found", request.getAuthorId());
}
}
if (metadata.getAuthor() != null && !metadata.getAuthor().trim().isEmpty()) {
return metadata.getAuthor().trim();
}
return null;
}
private Story createStoryFromPDF(PDFMetadata metadata, PDFContent content,
PDFImportRequest request, String authorName) {
Story story = new Story();
story.setTitle(metadata.getTitle() != null ? metadata.getTitle() : "Untitled PDF");
story.setDescription(metadata.getSubject());
story.setContentHtml(sanitizationService.sanitize(content.getHtmlContent()));
// Handle author assignment
try {
if (request.getAuthorId() != null) {
try {
Author author = authorService.findById(request.getAuthorId());
story.setAuthor(author);
} catch (ResourceNotFoundException e) {
if (request.getCreateMissingAuthor()) {
Author newAuthor = createAuthor(authorName);
story.setAuthor(newAuthor);
}
}
} else if (authorName != null && request.getCreateMissingAuthor()) {
Author author = findOrCreateAuthor(authorName);
story.setAuthor(author);
}
} catch (Exception e) {
log.error("Error handling author assignment: {}", e.getMessage(), e);
throw e;
}
// Handle series assignment
try {
if (request.getSeriesId() != null && request.getSeriesVolume() != null) {
try {
Series series = seriesService.findById(request.getSeriesId());
story.setSeries(series);
story.setVolume(request.getSeriesVolume());
} catch (ResourceNotFoundException e) {
if (request.getCreateMissingSeries() && request.getSeriesName() != null) {
Series newSeries = createSeries(request.getSeriesName());
story.setSeries(newSeries);
story.setVolume(request.getSeriesVolume());
}
}
}
} catch (Exception e) {
log.error("Error handling series assignment: {}", e.getMessage(), e);
throw e;
}
// Handle tags
try {
List<String> allTags = new ArrayList<>();
if (request.getTags() != null && !request.getTags().isEmpty()) {
allTags.addAll(request.getTags());
}
// Extract keywords from PDF metadata
if (metadata.getKeywords() != null && !metadata.getKeywords().trim().isEmpty()) {
String[] keywords = metadata.getKeywords().split("[,;]");
for (String keyword : keywords) {
String trimmed = keyword.trim();
if (!trimmed.isEmpty()) {
allTags.add(trimmed);
}
}
}
// Create tags
allTags.stream()
.distinct()
.forEach(tagName -> {
try {
Tag tag = tagService.findOrCreate(tagName.trim());
story.addTag(tag);
} catch (Exception e) {
log.error("Error creating tag '{}': {}", tagName, e.getMessage(), e);
}
});
} catch (Exception e) {
log.error("Error handling tags: {}", e.getMessage(), e);
throw e;
}
return story;
}
private Author findOrCreateAuthor(String authorName) {
Optional<Author> existingAuthor = authorService.findByNameOptional(authorName);
if (existingAuthor.isPresent()) {
return existingAuthor.get();
}
return createAuthor(authorName);
}
private Author createAuthor(String authorName) {
Author author = new Author();
author.setName(authorName);
return authorService.create(author);
}
private Series createSeries(String seriesName) {
Series series = new Series();
series.setName(seriesName);
return seriesService.create(series);
}
private String escapeHtml(String text) {
return text.replace("&", "&amp;")
.replace("<", "&lt;")
.replace(">", "&gt;")
.replace("\"", "&quot;")
.replace("'", "&#39;")
.replace("\n", "<br/>");
}
public List<String> validatePDFFile(MultipartFile file) {
List<String> errors = new ArrayList<>();
if (file == null || file.isEmpty()) {
errors.add("PDF file is required");
return errors;
}
if (!isValidPDFFile(file)) {
errors.add("Invalid PDF file format. Only .pdf files are supported");
}
if (file.getSize() > MAX_FILE_SIZE) {
errors.add("PDF file size exceeds " + (MAX_FILE_SIZE / 1024 / 1024) + "MB limit");
}
try {
PDDocument document = parsePDFFile(file);
try {
if (document.getNumberOfPages() == 0) {
errors.add("PDF file contains no pages");
}
} finally {
document.close();
}
} catch (Exception e) {
errors.add("Failed to parse PDF file: " + e.getMessage());
}
return errors;
}
// Inner classes for data structures
private static class PDFMetadata {
private String title;
private String author;
private String subject;
private String keywords;
private String creator;
private int pageCount;
public String getTitle() { return title; }
public void setTitle(String title) { this.title = title; }
public String getAuthor() { return author; }
public void setAuthor(String author) { this.author = author; }
public String getSubject() { return subject; }
public void setSubject(String subject) { this.subject = subject; }
public String getKeywords() { return keywords; }
public void setKeywords(String keywords) { this.keywords = keywords; }
public String getCreator() { return creator; }
public void setCreator(String creator) { this.creator = creator; }
public int getPageCount() { return pageCount; }
public void setPageCount(int pageCount) { this.pageCount = pageCount; }
}
private static class PDFContent {
private String htmlContent;
private List<PDFImage> images = new ArrayList<>();
public String getHtmlContent() { return htmlContent; }
public void setHtmlContent(String htmlContent) { this.htmlContent = htmlContent; }
public List<PDFImage> getImages() { return images; }
public void setImages(List<PDFImage> images) { this.images = images; }
}
private static class PDFImage {
private String imageId;
private int pageNumber;
private byte[] imageData;
private int width;
private int height;
public String getImageId() { return imageId; }
public void setImageId(String imageId) { this.imageId = imageId; }
public int getPageNumber() { return pageNumber; }
public void setPageNumber(int pageNumber) { this.pageNumber = pageNumber; }
public byte[] getImageData() { return imageData; }
public void setImageData(byte[] imageData) { this.imageData = imageData; }
public int getWidth() { return width; }
public void setWidth(int width) { this.width = width; }
public int getHeight() { return height; }
public void setHeight(int height) { this.height = height; }
}
/**
* Custom PDF text stripper to filter headers/footers
*/
private static class CustomPDFTextStripper extends PDFTextStripper {
public CustomPDFTextStripper() throws IOException {
super();
}
@Override
protected void writeString(String text, List<TextPosition> textPositions) throws IOException {
super.writeString(text, textPositions);
}
}
/**
* Custom MultipartFile implementation for PDF images
*/
private static class PDFImageMultipartFile implements MultipartFile {
private final byte[] data;
private final String filename;
private final String contentType;
public PDFImageMultipartFile(byte[] data, String filename, String contentType) {
this.data = data;
this.filename = filename;
this.contentType = contentType;
}
@Override
public String getName() {
return "image";
}
@Override
public String getOriginalFilename() {
return filename;
}
@Override
public String getContentType() {
return contentType;
}
@Override
public boolean isEmpty() {
return data == null || data.length == 0;
}
@Override
public long getSize() {
return data != null ? data.length : 0;
}
@Override
public byte[] getBytes() {
return data;
}
@Override
public InputStream getInputStream() {
return new ByteArrayInputStream(data);
}
@Override
public void transferTo(java.io.File dest) throws IOException {
try (java.io.FileOutputStream fos = new java.io.FileOutputStream(dest)) {
fos.write(data);
}
}
@Override
public void transferTo(java.nio.file.Path dest) throws IOException {
java.nio.file.Files.write(dest, data);
}
}
}

View File

@@ -0,0 +1,521 @@
package com.storycove.service;
import com.storycove.dto.*;
import com.storycove.service.exception.InvalidFileException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;
import java.io.*;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.*;
import java.util.concurrent.ConcurrentHashMap;
import java.util.zip.ZipEntry;
import java.util.zip.ZipInputStream;
@Service
public class ZIPImportService {
private static final Logger log = LoggerFactory.getLogger(ZIPImportService.class);
private static final long MAX_ZIP_SIZE = 1024L * 1024 * 1024; // 1GB
private static final int MAX_FILES_IN_ZIP = 30;
private static final long ZIP_SESSION_TIMEOUT_MS = 30 * 60 * 1000; // 30 minutes
// Temporary storage for extracted ZIP files (sessionId -> session data)
private final Map<String, ZIPSession> activeSessions = new ConcurrentHashMap<>();
private final EPUBImportService epubImportService;
private final PDFImportService pdfImportService;
@Autowired
public ZIPImportService(EPUBImportService epubImportService,
PDFImportService pdfImportService) {
this.epubImportService = epubImportService;
this.pdfImportService = pdfImportService;
}
/**
* Analyze a ZIP file and return information about its contents
*/
public ZIPAnalysisResponse analyzeZIPFile(MultipartFile zipFile) {
try {
// Validate ZIP file
if (zipFile == null || zipFile.isEmpty()) {
return ZIPAnalysisResponse.error("ZIP file is required");
}
if (!isValidZIPFile(zipFile)) {
return ZIPAnalysisResponse.error("Invalid ZIP file format");
}
if (zipFile.getSize() > MAX_ZIP_SIZE) {
return ZIPAnalysisResponse.error("ZIP file size exceeds " + (MAX_ZIP_SIZE / 1024 / 1024) + "MB limit");
}
log.info("Analyzing ZIP file: {} (size: {} bytes)", zipFile.getOriginalFilename(), zipFile.getSize());
// Create temporary directory for extraction
String sessionId = UUID.randomUUID().toString();
Path tempDir = Files.createTempDirectory("storycove-zip-" + sessionId);
// Extract ZIP contents
List<FileInfoDto> files = extractAndAnalyzeZIP(zipFile, tempDir, sessionId);
if (files.isEmpty()) {
cleanupSession(sessionId);
return ZIPAnalysisResponse.error("No valid EPUB or PDF files found in ZIP");
}
if (files.size() > MAX_FILES_IN_ZIP) {
cleanupSession(sessionId);
return ZIPAnalysisResponse.error("ZIP contains too many files (max " + MAX_FILES_IN_ZIP + ")");
}
// Store session data
ZIPSession session = new ZIPSession(sessionId, tempDir, files);
activeSessions.put(sessionId, session);
// Schedule cleanup
scheduleSessionCleanup(sessionId);
ZIPAnalysisResponse response = ZIPAnalysisResponse.success(zipFile.getOriginalFilename(), files);
response.addWarning("Session ID: " + sessionId + " (valid for 30 minutes)");
log.info("ZIP analysis completed. Session ID: {}, Files found: {}", sessionId, files.size());
return response;
} catch (Exception e) {
log.error("Failed to analyze ZIP file: {}", e.getMessage(), e);
return ZIPAnalysisResponse.error("Failed to analyze ZIP file: " + e.getMessage());
}
}
/**
* Import selected files from a previously analyzed ZIP
*/
public ZIPImportResponse importFromZIP(ZIPImportRequest request) {
try {
// Validate session
ZIPSession session = activeSessions.get(request.getZipSessionId());
if (session == null) {
return createErrorResponse("Invalid or expired session ID");
}
if (session.isExpired()) {
cleanupSession(request.getZipSessionId());
return createErrorResponse("Session has expired. Please re-upload the ZIP file");
}
List<String> selectedFiles = request.getSelectedFiles();
if (selectedFiles == null || selectedFiles.isEmpty()) {
return createErrorResponse("No files selected for import");
}
log.info("Importing {} files from ZIP session: {}", selectedFiles.size(), request.getZipSessionId());
List<FileImportResponse> results = new ArrayList<>();
// Import each selected file
for (String fileName : selectedFiles) {
try {
FileInfoDto fileInfo = session.getFileInfo(fileName);
if (fileInfo == null) {
FileImportResponse errorResult = FileImportResponse.error("File not found in session: " + fileName, fileName);
results.add(errorResult);
continue;
}
if (fileInfo.getError() != null) {
FileImportResponse errorResult = FileImportResponse.error("File has errors: " + fileInfo.getError(), fileName);
results.add(errorResult);
continue;
}
// Get file-specific or default metadata
ZIPImportRequest.FileImportMetadata metadata = getFileMetadata(request, fileName);
// Import based on file type
FileImportResponse result;
if ("EPUB".equals(fileInfo.getFileType())) {
result = importEPUBFromSession(session, fileName, metadata, request);
} else if ("PDF".equals(fileInfo.getFileType())) {
result = importPDFFromSession(session, fileName, metadata, request);
} else {
result = FileImportResponse.error("Unsupported file type: " + fileInfo.getFileType(), fileName);
}
results.add(result);
if (result.isSuccess()) {
log.info("Successfully imported file: {} (Story ID: {})", fileName, result.getStoryId());
} else {
log.warn("Failed to import file: {} - {}", fileName, result.getMessage());
}
} catch (Exception e) {
log.error("Failed to import file {}: {}", fileName, e.getMessage(), e);
FileImportResponse errorResult = FileImportResponse.error("Import failed: " + e.getMessage(), fileName);
results.add(errorResult);
}
}
// Cleanup session after import
cleanupSession(request.getZipSessionId());
log.info("ZIP import completed. Total: {}, Success: {}, Failed: {}",
results.size(),
results.stream().filter(FileImportResponse::isSuccess).count(),
results.stream().filter(r -> !r.isSuccess()).count());
return ZIPImportResponse.create(results);
} catch (Exception e) {
log.error("ZIP import failed: {}", e.getMessage(), e);
return createErrorResponse("Import failed: " + e.getMessage());
}
}
private boolean isValidZIPFile(MultipartFile file) {
String filename = file.getOriginalFilename();
if (filename == null || !filename.toLowerCase().endsWith(".zip")) {
return false;
}
String contentType = file.getContentType();
return "application/zip".equals(contentType) ||
"application/x-zip-compressed".equals(contentType) ||
contentType == null;
}
private List<FileInfoDto> extractAndAnalyzeZIP(MultipartFile zipFile, Path tempDir, String sessionId) throws IOException {
List<FileInfoDto> files = new ArrayList<>();
int fileCount = 0;
try (ZipInputStream zis = new ZipInputStream(zipFile.getInputStream())) {
ZipEntry entry;
while ((entry = zis.getNextEntry()) != null) {
// Skip directories
if (entry.isDirectory()) {
continue;
}
// Only process root-level files
String entryName = entry.getName();
if (entryName.contains("/") || entryName.contains("\\")) {
log.debug("Skipping nested file: {}", entryName);
continue;
}
// Check if it's an EPUB or PDF
String lowerName = entryName.toLowerCase();
if (!lowerName.endsWith(".epub") && !lowerName.endsWith(".pdf")) {
log.debug("Skipping non-EPUB/PDF file: {}", entryName);
continue;
}
fileCount++;
if (fileCount > MAX_FILES_IN_ZIP) {
log.warn("ZIP contains more than {} files, stopping extraction", MAX_FILES_IN_ZIP);
break;
}
// Extract file to temp directory
Path extractedFile = tempDir.resolve(entryName);
Files.copy(zis, extractedFile);
// Analyze the extracted file
FileInfoDto fileInfo = analyzeExtractedFile(extractedFile, entryName);
files.add(fileInfo);
zis.closeEntry();
}
}
return files;
}
private FileInfoDto analyzeExtractedFile(Path filePath, String fileName) {
try {
long fileSize = Files.size(filePath);
String fileType;
String extractedTitle = null;
String extractedAuthor = null;
boolean hasMetadata = false;
if (fileName.toLowerCase().endsWith(".epub")) {
fileType = "EPUB";
// Try to extract EPUB metadata
try {
// Create a temporary MultipartFile for validation
byte[] fileBytes = Files.readAllBytes(filePath);
MultipartFile tempFile = new TempMultipartFile(fileBytes, fileName, "application/epub+zip");
// Use EPUBImportService to extract metadata
// For now, we'll just validate the file
List<String> errors = epubImportService.validateEPUBFile(tempFile);
if (!errors.isEmpty()) {
FileInfoDto errorInfo = new FileInfoDto(fileName, fileType, fileSize);
errorInfo.setError(String.join(", ", errors));
return errorInfo;
}
hasMetadata = true;
// We could extract more metadata here if needed
} catch (Exception e) {
log.warn("Failed to extract EPUB metadata for {}: {}", fileName, e.getMessage());
}
} else if (fileName.toLowerCase().endsWith(".pdf")) {
fileType = "PDF";
// Try to extract PDF metadata
try {
byte[] fileBytes = Files.readAllBytes(filePath);
MultipartFile tempFile = new TempMultipartFile(fileBytes, fileName, "application/pdf");
// Use PDFImportService to validate
List<String> errors = pdfImportService.validatePDFFile(tempFile);
if (!errors.isEmpty()) {
FileInfoDto errorInfo = new FileInfoDto(fileName, fileType, fileSize);
errorInfo.setError(String.join(", ", errors));
return errorInfo;
}
hasMetadata = true;
// We could extract more metadata here if needed
} catch (Exception e) {
log.warn("Failed to extract PDF metadata for {}: {}", fileName, e.getMessage());
}
} else {
FileInfoDto errorInfo = new FileInfoDto(fileName, "UNKNOWN", fileSize);
errorInfo.setError("Unsupported file type");
return errorInfo;
}
FileInfoDto fileInfo = new FileInfoDto(fileName, fileType, fileSize);
fileInfo.setExtractedTitle(extractedTitle);
fileInfo.setExtractedAuthor(extractedAuthor);
fileInfo.setHasMetadata(hasMetadata);
return fileInfo;
} catch (Exception e) {
log.error("Failed to analyze file {}: {}", fileName, e.getMessage(), e);
FileInfoDto errorInfo = new FileInfoDto(fileName, "UNKNOWN", 0L);
errorInfo.setError("Failed to analyze file: " + e.getMessage());
return errorInfo;
}
}
private ZIPImportRequest.FileImportMetadata getFileMetadata(ZIPImportRequest request, String fileName) {
// Check for file-specific metadata first
if (request.getFileMetadata() != null && request.getFileMetadata().containsKey(fileName)) {
return request.getFileMetadata().get(fileName);
}
// Return default metadata
ZIPImportRequest.FileImportMetadata metadata = new ZIPImportRequest.FileImportMetadata();
metadata.setAuthorId(request.getDefaultAuthorId());
metadata.setAuthorName(request.getDefaultAuthorName());
metadata.setSeriesId(request.getDefaultSeriesId());
metadata.setSeriesName(request.getDefaultSeriesName());
metadata.setTags(request.getDefaultTags());
return metadata;
}
private FileImportResponse importEPUBFromSession(ZIPSession session, String fileName,
ZIPImportRequest.FileImportMetadata metadata,
ZIPImportRequest request) throws IOException {
Path filePath = session.getTempDir().resolve(fileName);
byte[] fileBytes = Files.readAllBytes(filePath);
MultipartFile epubFile = new TempMultipartFile(fileBytes, fileName, "application/epub+zip");
EPUBImportRequest epubRequest = new EPUBImportRequest();
epubRequest.setEpubFile(epubFile);
epubRequest.setAuthorId(metadata.getAuthorId());
epubRequest.setAuthorName(metadata.getAuthorName());
epubRequest.setSeriesId(metadata.getSeriesId());
epubRequest.setSeriesName(metadata.getSeriesName());
epubRequest.setSeriesVolume(metadata.getSeriesVolume());
epubRequest.setTags(metadata.getTags());
epubRequest.setCreateMissingAuthor(request.getCreateMissingAuthor());
epubRequest.setCreateMissingSeries(request.getCreateMissingSeries());
epubRequest.setExtractCover(true);
EPUBImportResponse epubResponse = epubImportService.importEPUB(epubRequest);
// Convert EPUBImportResponse to FileImportResponse
if (epubResponse.isSuccess()) {
FileImportResponse response = FileImportResponse.success(epubResponse.getStoryId(), epubResponse.getStoryTitle(), "EPUB");
response.setFileName(fileName);
response.setWordCount(epubResponse.getWordCount());
return response;
} else {
return FileImportResponse.error(epubResponse.getMessage(), fileName);
}
}
private FileImportResponse importPDFFromSession(ZIPSession session, String fileName,
ZIPImportRequest.FileImportMetadata metadata,
ZIPImportRequest request) throws IOException {
Path filePath = session.getTempDir().resolve(fileName);
byte[] fileBytes = Files.readAllBytes(filePath);
MultipartFile pdfFile = new TempMultipartFile(fileBytes, fileName, "application/pdf");
PDFImportRequest pdfRequest = new PDFImportRequest();
pdfRequest.setPdfFile(pdfFile);
pdfRequest.setAuthorId(metadata.getAuthorId());
pdfRequest.setAuthorName(metadata.getAuthorName());
pdfRequest.setSeriesId(metadata.getSeriesId());
pdfRequest.setSeriesName(metadata.getSeriesName());
pdfRequest.setSeriesVolume(metadata.getSeriesVolume());
pdfRequest.setTags(metadata.getTags());
pdfRequest.setCreateMissingAuthor(request.getCreateMissingAuthor());
pdfRequest.setCreateMissingSeries(request.getCreateMissingSeries());
pdfRequest.setExtractImages(request.getExtractImages());
return pdfImportService.importPDF(pdfRequest);
}
private void scheduleSessionCleanup(String sessionId) {
Timer timer = new Timer(true);
timer.schedule(new TimerTask() {
@Override
public void run() {
cleanupSession(sessionId);
}
}, ZIP_SESSION_TIMEOUT_MS);
}
private void cleanupSession(String sessionId) {
ZIPSession session = activeSessions.remove(sessionId);
if (session != null) {
try {
deleteDirectory(session.getTempDir());
log.info("Cleaned up ZIP session: {}", sessionId);
} catch (Exception e) {
log.error("Failed to cleanup ZIP session {}: {}", sessionId, e.getMessage(), e);
}
}
}
private void deleteDirectory(Path directory) throws IOException {
if (Files.exists(directory)) {
Files.walk(directory)
.sorted((a, b) -> -a.compareTo(b)) // Delete files before directories
.forEach(path -> {
try {
Files.delete(path);
} catch (IOException e) {
log.warn("Failed to delete file {}: {}", path, e.getMessage());
}
});
}
}
private ZIPImportResponse createErrorResponse(String message) {
ZIPImportResponse response = new ZIPImportResponse();
response.setSuccess(false);
response.setMessage(message);
return response;
}
// Inner classes
private static class ZIPSession {
private final String sessionId;
private final Path tempDir;
private final Map<String, FileInfoDto> files;
private final long createdAt;
public ZIPSession(String sessionId, Path tempDir, List<FileInfoDto> fileList) {
this.sessionId = sessionId;
this.tempDir = tempDir;
this.files = new HashMap<>();
for (FileInfoDto file : fileList) {
this.files.put(file.getFileName(), file);
}
this.createdAt = System.currentTimeMillis();
}
public Path getTempDir() {
return tempDir;
}
public FileInfoDto getFileInfo(String fileName) {
return files.get(fileName);
}
public boolean isExpired() {
return System.currentTimeMillis() - createdAt > ZIP_SESSION_TIMEOUT_MS;
}
}
/**
* Temporary MultipartFile implementation for extracted files
*/
private static class TempMultipartFile implements MultipartFile {
private final byte[] data;
private final String filename;
private final String contentType;
public TempMultipartFile(byte[] data, String filename, String contentType) {
this.data = data;
this.filename = filename;
this.contentType = contentType;
}
@Override
public String getName() {
return "file";
}
@Override
public String getOriginalFilename() {
return filename;
}
@Override
public String getContentType() {
return contentType;
}
@Override
public boolean isEmpty() {
return data == null || data.length == 0;
}
@Override
public long getSize() {
return data != null ? data.length : 0;
}
@Override
public byte[] getBytes() {
return data;
}
@Override
public InputStream getInputStream() {
return new ByteArrayInputStream(data);
}
@Override
public void transferTo(java.io.File dest) throws IOException {
try (java.io.FileOutputStream fos = new java.io.FileOutputStream(dest)) {
fos.write(data);
}
}
@Override
public void transferTo(java.nio.file.Path dest) throws IOException {
Files.write(dest, data);
}
}
}

View File

@@ -21,8 +21,8 @@ spring:
servlet:
multipart:
max-file-size: 2048MB # 2GB for large backup restore
max-request-size: 2100MB # Slightly higher to account for form data
max-file-size: 4096MB # 4GB for large backup restore
max-request-size: 4150MB # Slightly higher to account for form data
jackson:
serialization:
@@ -33,7 +33,7 @@ spring:
server:
port: 8080
tomcat:
max-http-request-size: 2150MB # Tomcat HTTP request size limit (2GB + overhead)
max-http-request-size: 4200MB # Tomcat HTTP request size limit (4GB + overhead)
storycove:
app:

View File

@@ -0,0 +1,296 @@
package com.storycove.service;
import com.storycove.dto.FileImportResponse;
import com.storycove.dto.PDFImportRequest;
import com.storycove.entity.*;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.mock.web.MockMultipartFile;
import java.util.*;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
/**
* Tests for PDFImportService.
* Note: These tests mock the PDF parsing since Apache PDFBox is complex to test.
* Integration tests should be added separately to test actual PDF file parsing.
*/
@ExtendWith(MockitoExtension.class)
class PDFImportServiceTest {
@Mock
private StoryService storyService;
@Mock
private AuthorService authorService;
@Mock
private SeriesService seriesService;
@Mock
private TagService tagService;
@Mock
private HtmlSanitizationService sanitizationService;
@Mock
private ImageService imageService;
@Mock
private LibraryService libraryService;
@InjectMocks
private PDFImportService pdfImportService;
private PDFImportRequest testRequest;
private Story testStory;
private Author testAuthor;
private Series testSeries;
private UUID storyId;
@BeforeEach
void setUp() {
storyId = UUID.randomUUID();
testStory = new Story();
testStory.setId(storyId);
testStory.setTitle("Test Story");
testStory.setWordCount(1000);
testAuthor = new Author();
testAuthor.setId(UUID.randomUUID());
testAuthor.setName("Test Author");
testSeries = new Series();
testSeries.setId(UUID.randomUUID());
testSeries.setName("Test Series");
testRequest = new PDFImportRequest();
}
// ========================================
// File Validation Tests
// ========================================
@Test
@DisplayName("Should reject null PDF file")
void testNullPDFFile() {
// Arrange
testRequest.setPdfFile(null);
// Act
FileImportResponse response = pdfImportService.importPDF(testRequest);
// Assert
assertFalse(response.isSuccess());
assertEquals("PDF file is required", response.getMessage());
verify(storyService, never()).create(any(Story.class));
}
@Test
@DisplayName("Should reject empty PDF file")
void testEmptyPDFFile() {
// Arrange
MockMultipartFile emptyFile = new MockMultipartFile(
"file", "test.pdf", "application/pdf", new byte[0]
);
testRequest.setPdfFile(emptyFile);
// Act
FileImportResponse response = pdfImportService.importPDF(testRequest);
// Assert
assertFalse(response.isSuccess());
assertEquals("PDF file is required", response.getMessage());
verify(storyService, never()).create(any(Story.class));
}
@Test
@DisplayName("Should reject non-PDF file by extension")
void testInvalidFileExtension() {
// Arrange
MockMultipartFile invalidFile = new MockMultipartFile(
"file", "test.txt", "text/plain", "test content".getBytes()
);
testRequest.setPdfFile(invalidFile);
// Act
FileImportResponse response = pdfImportService.importPDF(testRequest);
// Assert
assertFalse(response.isSuccess());
assertTrue(response.getMessage().contains("Invalid PDF file format"));
verify(storyService, never()).create(any(Story.class));
}
@Test
@DisplayName("Should reject file exceeding 300MB size limit")
void testFileSizeExceedsLimit() {
// Arrange
long fileSize = 301L * 1024 * 1024; // 301 MB
MockMultipartFile largeFile = new MockMultipartFile(
"file", "test.pdf", "application/pdf", new byte[(int)Math.min(fileSize, 1000)]
) {
@Override
public long getSize() {
return fileSize;
}
};
testRequest.setPdfFile(largeFile);
// Act
FileImportResponse response = pdfImportService.importPDF(testRequest);
// Assert
assertFalse(response.isSuccess());
assertTrue(response.getMessage().contains("Invalid PDF file format"));
verify(storyService, never()).create(any(Story.class));
}
// ========================================
// Author Handling Tests
// ========================================
@Test
@DisplayName("Should require author name when not in metadata")
void testRequiresAuthorName() {
// Arrange - Create a minimal valid PDF (will fail parsing but test validation)
MockMultipartFile pdfFile = new MockMultipartFile(
"file", "test.pdf", "application/pdf",
"%PDF-1.4\n%%EOF".getBytes()
);
testRequest.setPdfFile(pdfFile);
testRequest.setAuthorName(null);
testRequest.setAuthorId(null);
// Act
FileImportResponse response = pdfImportService.importPDF(testRequest);
// Assert
assertFalse(response.isSuccess());
// Should fail during import because author is required
verify(storyService, never()).create(any(Story.class));
}
// ========================================
// Validation Method Tests
// ========================================
@Test
@DisplayName("Should validate PDF file successfully")
void testValidatePDFFile_Valid() {
// Arrange
MockMultipartFile pdfFile = new MockMultipartFile(
"file", "test.pdf", "application/pdf",
new byte[100]
);
// Act
List<String> errors = pdfImportService.validatePDFFile(pdfFile);
// Assert - Will have errors because it's not a real PDF, but tests the method exists
assertNotNull(errors);
}
@Test
@DisplayName("Should return errors for null file in validation")
void testValidatePDFFile_Null() {
// Act
List<String> errors = pdfImportService.validatePDFFile(null);
// Assert
assertNotNull(errors);
assertFalse(errors.isEmpty());
assertTrue(errors.get(0).contains("required"));
}
@Test
@DisplayName("Should return errors for empty file in validation")
void testValidatePDFFile_Empty() {
// Arrange
MockMultipartFile emptyFile = new MockMultipartFile(
"file", "test.pdf", "application/pdf", new byte[0]
);
// Act
List<String> errors = pdfImportService.validatePDFFile(emptyFile);
// Assert
assertNotNull(errors);
assertFalse(errors.isEmpty());
assertTrue(errors.get(0).contains("required"));
}
@Test
@DisplayName("Should return errors for oversized file in validation")
void testValidatePDFFile_Oversized() {
// Arrange
long fileSize = 301L * 1024 * 1024; // 301 MB
MockMultipartFile largeFile = new MockMultipartFile(
"file", "test.pdf", "application/pdf", new byte[1000]
) {
@Override
public long getSize() {
return fileSize;
}
};
// Act
List<String> errors = pdfImportService.validatePDFFile(largeFile);
// Assert
assertNotNull(errors);
assertFalse(errors.isEmpty());
assertTrue(errors.stream().anyMatch(e -> e.contains("300MB")));
}
// ========================================
// Integration Tests (Mocked)
// ========================================
@Test
@DisplayName("Should handle extraction images flag")
void testExtractImagesFlag() {
// Arrange
MockMultipartFile pdfFile = new MockMultipartFile(
"file", "test.pdf", "application/pdf",
"%PDF-1.4\n%%EOF".getBytes()
);
testRequest.setPdfFile(pdfFile);
testRequest.setAuthorName("Test Author");
testRequest.setExtractImages(false);
// Act
FileImportResponse response = pdfImportService.importPDF(testRequest);
// Assert - Will fail parsing but tests that the flag is accepted
assertNotNull(response);
}
@Test
@DisplayName("Should accept tags in request")
void testAcceptTags() {
// Arrange
MockMultipartFile pdfFile = new MockMultipartFile(
"file", "test.pdf", "application/pdf",
"%PDF-1.4\n%%EOF".getBytes()
);
testRequest.setPdfFile(pdfFile);
testRequest.setAuthorName("Test Author");
testRequest.setTags(Arrays.asList("tag1", "tag2"));
// Act
FileImportResponse response = pdfImportService.importPDF(testRequest);
// Assert - Will fail parsing but tests that tags are accepted
assertNotNull(response);
}
}

View File

@@ -0,0 +1,310 @@
package com.storycove.service;
import com.storycove.dto.*;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.mock.web.MockMultipartFile;
import java.util.*;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
/**
* Tests for ZIPImportService.
*/
@ExtendWith(MockitoExtension.class)
class ZIPImportServiceTest {
@Mock
private EPUBImportService epubImportService;
@Mock
private PDFImportService pdfImportService;
@InjectMocks
private ZIPImportService zipImportService;
private ZIPImportRequest testImportRequest;
@BeforeEach
void setUp() {
testImportRequest = new ZIPImportRequest();
}
// ========================================
// File Validation Tests
// ========================================
@Test
@DisplayName("Should reject null ZIP file")
void testNullZIPFile() {
// Act
ZIPAnalysisResponse response = zipImportService.analyzeZIPFile(null);
// Assert
assertFalse(response.isSuccess());
assertEquals("ZIP file is required", response.getMessage());
}
@Test
@DisplayName("Should reject empty ZIP file")
void testEmptyZIPFile() {
// Arrange
MockMultipartFile emptyFile = new MockMultipartFile(
"file", "test.zip", "application/zip", new byte[0]
);
// Act
ZIPAnalysisResponse response = zipImportService.analyzeZIPFile(emptyFile);
// Assert
assertFalse(response.isSuccess());
assertEquals("ZIP file is required", response.getMessage());
}
@Test
@DisplayName("Should reject non-ZIP file")
void testInvalidFileType() {
// Arrange
MockMultipartFile invalidFile = new MockMultipartFile(
"file", "test.txt", "text/plain", "test content".getBytes()
);
// Act
ZIPAnalysisResponse response = zipImportService.analyzeZIPFile(invalidFile);
// Assert
assertFalse(response.isSuccess());
assertTrue(response.getMessage().contains("Invalid ZIP file format"));
}
@Test
@DisplayName("Should reject oversized ZIP file")
void testOversizedZIPFile() {
// Arrange
long fileSize = 1025L * 1024 * 1024; // 1025 MB (> 1GB limit)
MockMultipartFile largeFile = new MockMultipartFile(
"file", "test.zip", "application/zip", new byte[1000]
) {
@Override
public long getSize() {
return fileSize;
}
};
// Act
ZIPAnalysisResponse response = zipImportService.analyzeZIPFile(largeFile);
// Assert
assertFalse(response.isSuccess());
assertTrue(response.getMessage().contains("exceeds"));
assertTrue(response.getMessage().contains("1024MB") || response.getMessage().contains("1GB"));
}
// ========================================
// Import Request Validation Tests
// ========================================
@Test
@DisplayName("Should reject import with invalid session ID")
void testInvalidSessionId() {
// Arrange
testImportRequest.setZipSessionId("invalid-session-id");
testImportRequest.setSelectedFiles(Arrays.asList("file1.epub"));
// Act
ZIPImportResponse response = zipImportService.importFromZIP(testImportRequest);
// Assert
assertFalse(response.isSuccess());
assertTrue(response.getMessage().contains("Invalid") || response.getMessage().contains("expired"));
}
@Test
@DisplayName("Should reject import with no selected files")
void testNoSelectedFiles() {
// Arrange
testImportRequest.setZipSessionId("some-session-id");
testImportRequest.setSelectedFiles(Collections.emptyList());
// Act
ZIPImportResponse response = zipImportService.importFromZIP(testImportRequest);
// Assert
assertFalse(response.isSuccess());
assertTrue(response.getMessage().contains("No files selected") || response.getMessage().contains("Invalid"));
}
@Test
@DisplayName("Should reject import with null selected files")
void testNullSelectedFiles() {
// Arrange
testImportRequest.setZipSessionId("some-session-id");
testImportRequest.setSelectedFiles(null);
// Act
ZIPImportResponse response = zipImportService.importFromZIP(testImportRequest);
// Assert
assertFalse(response.isSuccess());
assertTrue(response.getMessage().contains("No files selected") || response.getMessage().contains("Invalid"));
}
// ========================================
// ZIP Analysis Tests
// ========================================
@Test
@DisplayName("Should handle corrupted ZIP file gracefully")
void testCorruptedZIPFile() {
// Arrange
MockMultipartFile corruptedFile = new MockMultipartFile(
"file", "test.zip", "application/zip",
"PK\3\4corrupted data".getBytes()
);
// Act
ZIPAnalysisResponse response = zipImportService.analyzeZIPFile(corruptedFile);
// Assert
assertFalse(response.isSuccess());
assertNotNull(response.getMessage());
}
// ========================================
// Helper Method Tests
// ========================================
@Test
@DisplayName("Should accept default metadata in import request")
void testDefaultMetadata() {
// Arrange
testImportRequest.setZipSessionId("test-session");
testImportRequest.setSelectedFiles(Arrays.asList("file1.epub"));
testImportRequest.setDefaultAuthorName("Default Author");
testImportRequest.setDefaultTags(Arrays.asList("tag1", "tag2"));
// Act - will fail due to invalid session, but tests that metadata is accepted
ZIPImportResponse response = zipImportService.importFromZIP(testImportRequest);
// Assert
assertNotNull(response);
assertFalse(response.isSuccess()); // Expected to fail due to invalid session
}
@Test
@DisplayName("Should accept per-file metadata in import request")
void testPerFileMetadata() {
// Arrange
Map<String, ZIPImportRequest.FileImportMetadata> fileMetadata = new HashMap<>();
ZIPImportRequest.FileImportMetadata metadata = new ZIPImportRequest.FileImportMetadata();
metadata.setAuthorName("Specific Author");
metadata.setTags(Arrays.asList("tag1"));
fileMetadata.put("file1.epub", metadata);
testImportRequest.setZipSessionId("test-session");
testImportRequest.setSelectedFiles(Arrays.asList("file1.epub"));
testImportRequest.setFileMetadata(fileMetadata);
// Act - will fail due to invalid session, but tests that metadata is accepted
ZIPImportResponse response = zipImportService.importFromZIP(testImportRequest);
// Assert
assertNotNull(response);
assertFalse(response.isSuccess()); // Expected to fail due to invalid session
}
@Test
@DisplayName("Should accept createMissing flags")
void testCreateMissingFlags() {
// Arrange
testImportRequest.setZipSessionId("test-session");
testImportRequest.setSelectedFiles(Arrays.asList("file1.epub"));
testImportRequest.setCreateMissingAuthor(false);
testImportRequest.setCreateMissingSeries(false);
testImportRequest.setExtractImages(false);
// Act - will fail due to invalid session, but tests that flags are accepted
ZIPImportResponse response = zipImportService.importFromZIP(testImportRequest);
// Assert
assertNotNull(response);
}
// ========================================
// Response Object Tests
// ========================================
@Test
@DisplayName("ZIPImportResponse should calculate statistics correctly")
void testZIPImportResponseStatistics() {
// Arrange
List<FileImportResponse> results = new ArrayList<>();
FileImportResponse success1 = FileImportResponse.success(UUID.randomUUID(), "Story 1", "EPUB");
FileImportResponse success2 = FileImportResponse.success(UUID.randomUUID(), "Story 2", "PDF");
FileImportResponse failure = FileImportResponse.error("Import failed", "story3.epub");
results.add(success1);
results.add(success2);
results.add(failure);
// Act
ZIPImportResponse response = ZIPImportResponse.create(results);
// Assert
assertNotNull(response);
assertEquals(3, response.getTotalFiles());
assertEquals(2, response.getSuccessfulImports());
assertEquals(1, response.getFailedImports());
assertTrue(response.isSuccess()); // Partial success
assertTrue(response.getMessage().contains("2 imported"));
}
@Test
@DisplayName("ZIPImportResponse should handle all failures")
void testZIPImportResponseAllFailures() {
// Arrange
List<FileImportResponse> results = new ArrayList<>();
results.add(FileImportResponse.error("Error 1", "file1.epub"));
results.add(FileImportResponse.error("Error 2", "file2.pdf"));
// Act
ZIPImportResponse response = ZIPImportResponse.create(results);
// Assert
assertNotNull(response);
assertEquals(2, response.getTotalFiles());
assertEquals(0, response.getSuccessfulImports());
assertEquals(2, response.getFailedImports());
assertFalse(response.isSuccess());
assertTrue(response.getMessage().contains("failed"));
}
@Test
@DisplayName("ZIPImportResponse should handle all successes")
void testZIPImportResponseAllSuccesses() {
// Arrange
List<FileImportResponse> results = new ArrayList<>();
results.add(FileImportResponse.success(UUID.randomUUID(), "Story 1", "EPUB"));
results.add(FileImportResponse.success(UUID.randomUUID(), "Story 2", "PDF"));
// Act
ZIPImportResponse response = ZIPImportResponse.create(results);
// Assert
assertNotNull(response);
assertEquals(2, response.getTotalFiles());
assertEquals(2, response.getSuccessfulImports());
assertEquals(0, response.getFailedImports());
assertTrue(response.isSuccess());
assertTrue(response.getMessage().contains("All files imported successfully"));
}
}