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> <artifactId>epublib-core</artifactId>
<version>3.1</version> <version>3.1</version>
</dependency> </dependency>
<dependency>
<groupId>org.apache.pdfbox</groupId>
<artifactId>pdfbox</artifactId>
<version>3.0.3</version>
</dependency>
<!-- Test dependencies --> <!-- Test dependencies -->
<dependency> <dependency>
<groupId>org.springframework.boot</groupId> <groupId>org.springframework.boot</groupId>

View File

@@ -44,12 +44,14 @@ public class StoryController {
private final ReadingTimeService readingTimeService; private final ReadingTimeService readingTimeService;
private final EPUBImportService epubImportService; private final EPUBImportService epubImportService;
private final EPUBExportService epubExportService; private final EPUBExportService epubExportService;
private final PDFImportService pdfImportService;
private final ZIPImportService zipImportService;
private final AsyncImageProcessingService asyncImageProcessingService; private final AsyncImageProcessingService asyncImageProcessingService;
private final ImageProcessingProgressService progressService; private final ImageProcessingProgressService progressService;
public StoryController(StoryService storyService, public StoryController(StoryService storyService,
AuthorService authorService, AuthorService authorService,
SeriesService seriesService, SeriesService seriesService,
HtmlSanitizationService sanitizationService, HtmlSanitizationService sanitizationService,
ImageService imageService, ImageService imageService,
CollectionService collectionService, CollectionService collectionService,
@@ -57,6 +59,8 @@ public class StoryController {
ReadingTimeService readingTimeService, ReadingTimeService readingTimeService,
EPUBImportService epubImportService, EPUBImportService epubImportService,
EPUBExportService epubExportService, EPUBExportService epubExportService,
PDFImportService pdfImportService,
ZIPImportService zipImportService,
AsyncImageProcessingService asyncImageProcessingService, AsyncImageProcessingService asyncImageProcessingService,
ImageProcessingProgressService progressService) { ImageProcessingProgressService progressService) {
this.storyService = storyService; this.storyService = storyService;
@@ -69,6 +73,8 @@ public class StoryController {
this.readingTimeService = readingTimeService; this.readingTimeService = readingTimeService;
this.epubImportService = epubImportService; this.epubImportService = epubImportService;
this.epubExportService = epubExportService; this.epubExportService = epubExportService;
this.pdfImportService = pdfImportService;
this.zipImportService = zipImportService;
this.asyncImageProcessingService = asyncImageProcessingService; this.asyncImageProcessingService = asyncImageProcessingService;
this.progressService = progressService; this.progressService = progressService;
} }
@@ -907,26 +913,147 @@ public class StoryController {
@PostMapping("/epub/validate") @PostMapping("/epub/validate")
public ResponseEntity<Map<String, Object>> validateEPUBFile(@RequestParam("file") MultipartFile file) { public ResponseEntity<Map<String, Object>> validateEPUBFile(@RequestParam("file") MultipartFile file) {
logger.info("Validating EPUB file: {}", file.getOriginalFilename()); logger.info("Validating EPUB file: {}", file.getOriginalFilename());
try { try {
List<String> errors = epubImportService.validateEPUBFile(file); List<String> errors = epubImportService.validateEPUBFile(file);
Map<String, Object> response = Map.of( Map<String, Object> response = Map.of(
"valid", errors.isEmpty(), "valid", errors.isEmpty(),
"errors", errors, "errors", errors,
"filename", file.getOriginalFilename(), "filename", file.getOriginalFilename(),
"size", file.getSize() "size", file.getSize()
); );
return ResponseEntity.ok(response); return ResponseEntity.ok(response);
} catch (Exception e) { } catch (Exception e) {
logger.error("Error validating EPUB file: {}", e.getMessage(), e); logger.error("Error validating EPUB file: {}", e.getMessage(), e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(Map.of("error", "Failed to validate EPUB file")); .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 // Request DTOs
public static class CreateStoryRequest { public static class CreateStoryRequest {
private String title; 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: servlet:
multipart: multipart:
max-file-size: 2048MB # 2GB for large backup restore max-file-size: 4096MB # 4GB for large backup restore
max-request-size: 2100MB # Slightly higher to account for form data max-request-size: 4150MB # Slightly higher to account for form data
jackson: jackson:
serialization: serialization:
@@ -33,7 +33,7 @@ spring:
server: server:
port: 8080 port: 8080
tomcat: 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: storycove:
app: 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"));
}
}

View File

@@ -0,0 +1,829 @@
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { DocumentArrowUpIcon, CheckCircleIcon, XCircleIcon } from '@heroicons/react/24/outline';
import Button from '@/components/ui/Button';
import { Input } from '@/components/ui/Input';
import ImportLayout from '@/components/layout/ImportLayout';
import AuthorSelector from '@/components/stories/AuthorSelector';
import SeriesSelector from '@/components/stories/SeriesSelector';
type FileType = 'epub' | 'pdf' | 'zip' | null;
interface ImportResponse {
success: boolean;
message: string;
storyId?: string;
storyTitle?: string;
fileName?: string;
fileType?: string;
wordCount?: number;
extractedImages?: number;
warnings?: string[];
errors?: string[];
}
interface ZIPAnalysisResponse {
success: boolean;
message: string;
zipFileName?: string;
totalFiles?: number;
validFiles?: number;
files?: FileInfo[];
warnings?: string[];
}
interface FileInfo {
fileName: string;
fileType: string;
fileSize: number;
extractedTitle?: string;
extractedAuthor?: string;
hasMetadata: boolean;
error?: string;
}
interface ZIPImportResponse {
success: boolean;
message: string;
totalFiles: number;
successfulImports: number;
failedImports: number;
results: ImportResponse[];
warnings?: string[];
}
export default function FileImportPage() {
const router = useRouter();
const [selectedFile, setSelectedFile] = useState<File | null>(null);
const [fileType, setFileType] = useState<FileType>(null);
const [isLoading, setIsLoading] = useState(false);
const [isValidating, setIsValidating] = useState(false);
const [validationResult, setValidationResult] = useState<any>(null);
const [importResult, setImportResult] = useState<ImportResponse | null>(null);
const [error, setError] = useState<string | null>(null);
// ZIP-specific state
const [zipAnalysis, setZipAnalysis] = useState<ZIPAnalysisResponse | null>(null);
const [zipSessionId, setZipSessionId] = useState<string | null>(null);
const [selectedZipFiles, setSelectedZipFiles] = useState<Set<string>>(new Set());
const [fileMetadata, setFileMetadata] = useState<Map<string, any>>(new Map());
const [zipImportResult, setZipImportResult] = useState<ZIPImportResponse | null>(null);
// Import options
const [authorName, setAuthorName] = useState<string>('');
const [authorId, setAuthorId] = useState<string | undefined>(undefined);
const [seriesName, setSeriesName] = useState<string>('');
const [seriesId, setSeriesId] = useState<string | undefined>(undefined);
const [seriesVolume, setSeriesVolume] = useState<string>('');
const [tags, setTags] = useState<string>('');
const [createMissingAuthor, setCreateMissingAuthor] = useState(true);
const [createMissingSeries, setCreateMissingSeries] = useState(true);
const [extractImages, setExtractImages] = useState(true);
const [preserveReadingPosition, setPreserveReadingPosition] = useState(true);
const detectFileType = (file: File): FileType => {
const filename = file.name.toLowerCase();
if (filename.endsWith('.epub')) return 'epub';
if (filename.endsWith('.pdf')) return 'pdf';
if (filename.endsWith('.zip')) return 'zip';
return null;
};
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
setSelectedFile(file);
setValidationResult(null);
setImportResult(null);
setZipAnalysis(null);
setZipSessionId(null);
setSelectedZipFiles(new Set());
setZipImportResult(null);
setError(null);
const detectedType = detectFileType(file);
setFileType(detectedType);
if (!detectedType) {
setError('Unsupported file type. Please select an EPUB, PDF, or ZIP file.');
return;
}
if (detectedType === 'zip') {
await analyzeZipFile(file);
} else {
await validateFile(file, detectedType);
}
}
};
const validateFile = async (file: File, type: FileType) => {
if (type === 'zip') return; // ZIP has its own analysis flow
setIsValidating(true);
try {
const token = localStorage.getItem('auth-token');
const formData = new FormData();
formData.append('file', file);
const endpoint = type === 'epub' ? '/api/stories/epub/validate' : '/api/stories/pdf/validate';
const response = await fetch(endpoint, {
method: 'POST',
headers: {
'Authorization': token ? `Bearer ${token}` : '',
},
body: formData,
});
if (response.ok) {
const result = await response.json();
setValidationResult(result);
if (!result.valid) {
setError(`${type?.toUpperCase() || 'File'} validation failed: ` + result.errors.join(', '));
}
} else if (response.status === 401 || response.status === 403) {
setError('Authentication required. Please log in.');
} else {
setError(`Failed to validate ${type?.toUpperCase() || 'file'}`);
}
} catch (err) {
setError(`Error validating ${type?.toUpperCase() || 'file'}: ` + (err as Error).message);
} finally {
setIsValidating(false);
}
};
const analyzeZipFile = async (file: File) => {
setIsLoading(true);
try {
const token = localStorage.getItem('auth-token');
const formData = new FormData();
formData.append('file', file);
const response = await fetch('/api/stories/zip/analyze', {
method: 'POST',
headers: {
'Authorization': token ? `Bearer ${token}` : '',
},
body: formData,
});
if (response.ok) {
const result: ZIPAnalysisResponse = await response.json();
setZipAnalysis(result);
if (result.success && result.warnings && result.warnings.length > 0) {
// Extract session ID from warnings
const sessionWarning = result.warnings.find(w => w.includes('Session ID:'));
if (sessionWarning) {
const match = sessionWarning.match(/Session ID: ([a-f0-9-]+)/);
if (match) {
setZipSessionId(match[1]);
}
}
}
if (!result.success) {
setError(result.message);
} else if (result.files && result.files.length === 0) {
setError('No valid EPUB or PDF files found in ZIP');
}
} else if (response.status === 401 || response.status === 403) {
setError('Authentication required. Please log in.');
} else {
setError('Failed to analyze ZIP file');
}
} catch (err) {
setError('Error analyzing ZIP file: ' + (err as Error).message);
} finally {
setIsLoading(false);
}
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!selectedFile) {
setError('Please select a file');
return;
}
if (fileType === 'zip') {
await handleZipImport();
return;
}
if (validationResult && !validationResult.valid) {
setError(`Cannot import invalid ${fileType?.toUpperCase()} file`);
return;
}
// Check PDF requires author
if (fileType === 'pdf' && !authorName.trim()) {
setError('PDF import requires an author name. Please provide an author name or ensure the PDF has author metadata.');
return;
}
setIsLoading(true);
setError(null);
try {
const token = localStorage.getItem('auth-token');
const formData = new FormData();
formData.append('file', selectedFile);
if (authorId) {
formData.append('authorId', authorId);
} else if (authorName) {
formData.append('authorName', authorName);
}
if (seriesId) {
formData.append('seriesId', seriesId);
} else if (seriesName) {
formData.append('seriesName', seriesName);
}
if (seriesVolume) formData.append('seriesVolume', seriesVolume);
if (tags) {
const tagList = tags.split(',').map(t => t.trim()).filter(t => t.length > 0);
tagList.forEach(tag => formData.append('tags', tag));
}
formData.append('createMissingAuthor', createMissingAuthor.toString());
formData.append('createMissingSeries', createMissingSeries.toString());
if (fileType === 'epub') {
formData.append('preserveReadingPosition', preserveReadingPosition.toString());
} else if (fileType === 'pdf') {
formData.append('extractImages', extractImages.toString());
}
const endpoint = fileType === 'epub' ? '/api/stories/epub/import' : '/api/stories/pdf/import';
const response = await fetch(endpoint, {
method: 'POST',
headers: {
'Authorization': token ? `Bearer ${token}` : '',
},
body: formData,
});
const result = await response.json();
if (response.ok && result.success) {
setImportResult(result);
} else if (response.status === 401 || response.status === 403) {
setError('Authentication required. Please log in.');
} else {
setError(result.message || `Failed to import ${fileType?.toUpperCase()}`);
}
} catch (err) {
setError(`Error importing ${fileType?.toUpperCase()}: ` + (err as Error).message);
} finally {
setIsLoading(false);
}
};
const handleZipImport = async () => {
if (!zipSessionId) {
setError('ZIP session expired. Please re-upload the ZIP file.');
return;
}
if (selectedZipFiles.size === 0) {
setError('Please select at least one file to import');
return;
}
setIsLoading(true);
setError(null);
try {
const token = localStorage.getItem('auth-token');
const requestBody: any = {
zipSessionId: zipSessionId,
selectedFiles: Array.from(selectedZipFiles),
defaultAuthorId: authorId || undefined,
defaultAuthorName: authorName || undefined,
defaultSeriesId: seriesId || undefined,
defaultSeriesName: seriesName || undefined,
defaultTags: tags ? tags.split(',').map(t => t.trim()).filter(t => t.length > 0) : undefined,
createMissingAuthor,
createMissingSeries,
extractImages,
};
// Add per-file metadata if any
if (fileMetadata.size > 0) {
const metadata: any = {};
fileMetadata.forEach((value, key) => {
metadata[key] = value;
});
requestBody.fileMetadata = metadata;
}
const response = await fetch('/api/stories/zip/import', {
method: 'POST',
headers: {
'Authorization': token ? `Bearer ${token}` : '',
'Content-Type': 'application/json',
},
body: JSON.stringify(requestBody),
});
const result: ZIPImportResponse = await response.json();
if (response.ok) {
setZipImportResult(result);
} else if (response.status === 401 || response.status === 403) {
setError('Authentication required. Please log in.');
} else {
setError(result.message || 'Failed to import files from ZIP');
}
} catch (err) {
setError('Error importing from ZIP: ' + (err as Error).message);
} finally {
setIsLoading(false);
}
};
const toggleZipFileSelection = (fileName: string) => {
const newSelection = new Set(selectedZipFiles);
if (newSelection.has(fileName)) {
newSelection.delete(fileName);
} else {
newSelection.add(fileName);
}
setSelectedZipFiles(newSelection);
};
const selectAllZipFiles = () => {
if (zipAnalysis?.files) {
const validFiles = zipAnalysis.files.filter(f => !f.error);
setSelectedZipFiles(new Set(validFiles.map(f => f.fileName)));
}
};
const deselectAllZipFiles = () => {
setSelectedZipFiles(new Set());
};
const resetForm = () => {
setSelectedFile(null);
setFileType(null);
setValidationResult(null);
setImportResult(null);
setZipAnalysis(null);
setZipSessionId(null);
setSelectedZipFiles(new Set());
setFileMetadata(new Map());
setZipImportResult(null);
setError(null);
setAuthorName('');
setAuthorId(undefined);
setSeriesName('');
setSeriesId(undefined);
setSeriesVolume('');
setTags('');
};
const handleAuthorChange = (name: string, id?: string) => {
setAuthorName(name);
setAuthorId(id);
};
const handleSeriesChange = (name: string, id?: string) => {
setSeriesName(name);
setSeriesId(id);
};
// Show success screen for single file import
if (importResult?.success) {
return (
<ImportLayout
title="Import Successful"
description="Your file has been successfully imported into StoryCove"
>
<div className="space-y-6">
<div className="bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-lg p-6">
<h2 className="text-xl font-semibold text-green-600 dark:text-green-400 mb-2">Import Completed</h2>
<p className="theme-text">
Your {importResult.fileType || fileType?.toUpperCase()} file has been successfully imported.
</p>
</div>
<div className="theme-card theme-shadow rounded-lg p-6">
<div className="space-y-4">
<div>
<span className="font-semibold theme-header">Story Title:</span>
<p className="theme-text">{importResult.storyTitle}</p>
</div>
{importResult.wordCount && (
<div>
<span className="font-semibold theme-header">Word Count:</span>
<p className="theme-text">{importResult.wordCount.toLocaleString()} words</p>
</div>
)}
{importResult.extractedImages !== undefined && importResult.extractedImages > 0 && (
<div>
<span className="font-semibold theme-header">Extracted Images:</span>
<p className="theme-text">{importResult.extractedImages}</p>
</div>
)}
{importResult.warnings && importResult.warnings.length > 0 && (
<div className="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg p-4">
<strong className="text-yellow-800 dark:text-yellow-200">Warnings:</strong>
<ul className="list-disc list-inside mt-2 text-yellow-700 dark:text-yellow-300">
{importResult.warnings.map((warning, index) => (
<li key={index}>{warning}</li>
))}
</ul>
</div>
)}
<div className="flex gap-4 mt-6">
<Button
onClick={() => router.push(`/stories/${importResult.storyId}`)}
>
View Story
</Button>
<Button
onClick={resetForm}
variant="secondary"
>
Import Another File
</Button>
</div>
</div>
</div>
</div>
</ImportLayout>
);
}
// Show success screen for ZIP import
if (zipImportResult) {
return (
<ImportLayout
title="ZIP Import Complete"
description="Import results from your ZIP file"
>
<div className="space-y-6">
<div className={`border rounded-lg p-6 ${
zipImportResult.failedImports === 0
? 'bg-green-50 dark:bg-green-900/20 border-green-200 dark:border-green-800'
: 'bg-yellow-50 dark:bg-yellow-900/20 border-yellow-200 dark:border-yellow-800'
}`}>
<h2 className={`text-xl font-semibold mb-2 ${
zipImportResult.failedImports === 0
? 'text-green-600 dark:text-green-400'
: 'text-yellow-600 dark:text-yellow-400'
}`}>
{zipImportResult.message}
</h2>
<p className="theme-text">
{zipImportResult.successfulImports} of {zipImportResult.totalFiles} files imported successfully
</p>
</div>
<div className="theme-card theme-shadow rounded-lg p-6">
<h3 className="text-lg font-semibold theme-header mb-4">Import Results</h3>
<div className="space-y-3">
{zipImportResult.results.map((result, index) => (
<div key={index} className={`p-4 rounded-lg border ${
result.success
? 'bg-green-50 dark:bg-green-900/10 border-green-200 dark:border-green-800'
: 'bg-red-50 dark:bg-red-900/10 border-red-200 dark:border-red-800'
}`}>
<div className="flex items-start gap-3">
{result.success ? (
<CheckCircleIcon className="h-5 w-5 text-green-600 dark:text-green-400 flex-shrink-0 mt-0.5" />
) : (
<XCircleIcon className="h-5 w-5 text-red-600 dark:text-red-400 flex-shrink-0 mt-0.5" />
)}
<div className="flex-1">
<p className="font-medium theme-header">
{result.fileName || result.storyTitle || 'Unknown file'}
</p>
{result.success && result.storyTitle && (
<p className="text-sm theme-text">
Imported as: {result.storyTitle}
{result.storyId && (
<button
onClick={() => router.push(`/stories/${result.storyId}`)}
className="ml-2 text-xs text-blue-600 dark:text-blue-400 hover:underline"
>
View
</button>
)}
</p>
)}
{!result.success && (
<p className="text-sm text-red-600 dark:text-red-400">{result.message}</p>
)}
</div>
</div>
</div>
))}
</div>
<div className="flex gap-4 mt-6">
<Button
onClick={() => router.push('/library')}
>
Go to Library
</Button>
<Button
onClick={resetForm}
variant="secondary"
>
Import Another File
</Button>
</div>
</div>
</div>
</ImportLayout>
);
}
return (
<ImportLayout
title="Import from File"
description="Upload an EPUB, PDF, or ZIP file to import stories into your library"
>
{error && (
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4 mb-6">
<p className="text-red-800 dark:text-red-200">{error}</p>
</div>
)}
<form onSubmit={handleSubmit} className="space-y-6">
{/* File Upload */}
<div className="theme-card theme-shadow rounded-lg p-6">
<div className="mb-4">
<h3 className="text-lg font-semibold theme-header mb-2">Select File</h3>
<p className="theme-text">
Choose an EPUB, PDF, or ZIP file from your device to import.
</p>
</div>
<div className="space-y-4">
<div>
<label htmlFor="import-file" className="block text-sm font-medium theme-header mb-1">
File (EPUB, PDF, or ZIP)
</label>
<Input
id="import-file"
type="file"
accept=".epub,.pdf,.zip,application/epub+zip,application/pdf,application/zip"
onChange={handleFileChange}
disabled={isLoading || isValidating}
/>
</div>
{selectedFile && (
<div className="flex items-center gap-2">
<DocumentArrowUpIcon className="h-5 w-5 theme-text" />
<span className="text-sm theme-text">
{selectedFile.name} ({(selectedFile.size / 1024 / 1024).toFixed(2)} MB)
{fileType && <span className="ml-2 inline-flex items-center px-2 py-1 rounded text-xs font-medium bg-blue-100 dark:bg-blue-900/20 text-blue-800 dark:text-blue-200">
{fileType.toUpperCase()}
</span>}
</span>
</div>
)}
{isValidating && (
<div className="text-sm theme-accent">
Validating file...
</div>
)}
{validationResult && fileType !== 'zip' && (
<div className="text-sm">
{validationResult.valid ? (
<span className="inline-flex items-center px-2 py-1 rounded text-xs font-medium bg-green-100 dark:bg-green-900/20 text-green-800 dark:text-green-200">
Valid {fileType?.toUpperCase()}
</span>
) : (
<span className="inline-flex items-center px-2 py-1 rounded text-xs font-medium bg-red-100 dark:bg-red-900/20 text-red-800 dark:text-red-200">
Invalid {fileType?.toUpperCase()}
</span>
)}
</div>
)}
</div>
</div>
{/* ZIP File Selection */}
{fileType === 'zip' && zipAnalysis?.success && zipAnalysis.files && (
<div className="theme-card theme-shadow rounded-lg p-6">
<div className="mb-4 flex items-center justify-between">
<div>
<h3 className="text-lg font-semibold theme-header mb-2">Select Files to Import</h3>
<p className="theme-text">
{zipAnalysis.validFiles} valid files found in ZIP ({zipAnalysis.totalFiles} total)
</p>
</div>
<div className="flex gap-2">
<Button
type="button"
variant="secondary"
size="sm"
onClick={selectAllZipFiles}
>
Select All
</Button>
<Button
type="button"
variant="ghost"
size="sm"
onClick={deselectAllZipFiles}
>
Deselect All
</Button>
</div>
</div>
<div className="space-y-2 max-h-96 overflow-y-auto">
{zipAnalysis.files.map((file, index) => (
<div
key={index}
className={`p-3 rounded-lg border ${
file.error
? 'bg-red-50 dark:bg-red-900/10 border-red-200 dark:border-red-800 opacity-50'
: selectedZipFiles.has(file.fileName)
? 'bg-blue-50 dark:bg-blue-900/10 border-blue-300 dark:border-blue-700'
: 'theme-card border-gray-200 dark:border-gray-700'
}`}
>
<div className="flex items-start gap-3">
{!file.error && (
<input
type="checkbox"
checked={selectedZipFiles.has(file.fileName)}
onChange={() => toggleZipFileSelection(file.fileName)}
className="mt-1"
/>
)}
<div className="flex-1">
<p className="font-medium theme-header">{file.fileName}</p>
<p className="text-xs theme-text mt-1">
{file.fileType} {(file.fileSize / 1024).toFixed(2)} KB
{file.extractedTitle && `${file.extractedTitle}`}
</p>
{file.error && (
<p className="text-xs text-red-600 dark:text-red-400 mt-1">{file.error}</p>
)}
</div>
</div>
</div>
))}
</div>
</div>
)}
{/* Import Options - Show for all file types */}
{fileType && (!zipAnalysis || (zipAnalysis && selectedZipFiles.size > 0)) && (
<div className="theme-card theme-shadow rounded-lg p-6">
<div className="mb-4">
<h3 className="text-lg font-semibold theme-header mb-2">Import Options</h3>
<p className="theme-text">
Configure how the {fileType === 'zip' ? 'files' : 'file'} should be imported.
{fileType === 'zip' && ' These settings apply to all selected files.'}
</p>
</div>
<div className="space-y-4">
<AuthorSelector
value={authorName}
onChange={handleAuthorChange}
placeholder={fileType === 'epub' ? 'Leave empty to use file metadata' : 'Required for PDF import'}
required={fileType === 'pdf'}
label={`Author${fileType === 'pdf' ? ' *' : ''}${fileType === 'zip' ? ' (Default)' : ''}`}
error={fileType === 'pdf' && !authorName ? 'PDF import requires an author name. Select an existing author or enter a new one.' : undefined}
/>
<SeriesSelector
value={seriesName}
onChange={handleSeriesChange}
placeholder="Optional: Add to a series"
label={`Series${fileType === 'zip' ? ' (Default)' : ''}`}
authorId={authorId}
/>
{seriesName && (
<div>
<label htmlFor="series-volume" className="block text-sm font-medium theme-header mb-1">Series Volume</label>
<Input
id="series-volume"
type="number"
value={seriesVolume}
onChange={(e) => setSeriesVolume(e.target.value)}
placeholder="Volume number in series"
/>
</div>
)}
<div>
<label htmlFor="tags" className="block text-sm font-medium theme-header mb-1">
Tags {fileType === 'zip' && '(Default)'}
</label>
<Input
id="tags"
value={tags}
onChange={(e) => setTags(e.target.value)}
placeholder="Comma-separated tags (e.g., fantasy, adventure, romance)"
/>
</div>
<div className="space-y-3">
{fileType === 'epub' && (
<div className="flex items-center">
<input
type="checkbox"
id="preserve-reading-position"
checked={preserveReadingPosition}
onChange={(e) => setPreserveReadingPosition(e.target.checked)}
className="mr-2"
/>
<label htmlFor="preserve-reading-position" className="text-sm theme-text">
Preserve reading position from EPUB metadata
</label>
</div>
)}
{(fileType === 'pdf' || fileType === 'zip') && (
<div className="flex items-center">
<input
type="checkbox"
id="extract-images"
checked={extractImages}
onChange={(e) => setExtractImages(e.target.checked)}
className="mr-2"
/>
<label htmlFor="extract-images" className="text-sm theme-text">
Extract and store embedded images from PDFs
</label>
</div>
)}
<div className="flex items-center">
<input
type="checkbox"
id="create-missing-author"
checked={createMissingAuthor}
onChange={(e) => setCreateMissingAuthor(e.target.checked)}
className="mr-2"
/>
<label htmlFor="create-missing-author" className="text-sm theme-text">
Create author if not found
</label>
</div>
<div className="flex items-center">
<input
type="checkbox"
id="create-missing-series"
checked={createMissingSeries}
onChange={(e) => setCreateMissingSeries(e.target.checked)}
className="mr-2"
/>
<label htmlFor="create-missing-series" className="text-sm theme-text">
Create series if not found
</label>
</div>
</div>
</div>
</div>
)}
{/* Submit Button */}
{fileType && fileType !== 'zip' && (
<div className="flex justify-end">
<Button
type="submit"
disabled={!selectedFile || isLoading || isValidating || (validationResult && !validationResult.valid)}
loading={isLoading}
>
{isLoading ? 'Importing...' : `Import ${fileType.toUpperCase()}`}
</Button>
</div>
)}
{fileType === 'zip' && zipAnalysis?.success && (
<div className="flex justify-end">
<Button
type="submit"
disabled={selectedZipFiles.size === 0 || isLoading}
loading={isLoading}
>
{isLoading ? 'Importing...' : `Import ${selectedZipFiles.size} File${selectedZipFiles.size !== 1 ? 's' : ''}`}
</Button>
</div>
)}
</form>
</ImportLayout>
);
}

View File

@@ -27,9 +27,9 @@ export default function Header() {
description: 'Import a single story from a website' description: 'Import a single story from a website'
}, },
{ {
href: '/import/epub', href: '/import/file',
label: 'Import EPUB', label: 'Import from File',
description: 'Import a story from an EPUB file' description: 'Import from EPUB, PDF, or ZIP file'
}, },
{ {
href: '/import/bulk', href: '/import/bulk',

View File

@@ -31,10 +31,10 @@ const importTabs: ImportTab[] = [
description: 'Import a single story from a website' description: 'Import a single story from a website'
}, },
{ {
id: 'epub', id: 'file',
label: 'Import EPUB', label: 'Import from File',
href: '/import/epub', href: '/import/file',
description: 'Import a story from an EPUB file' description: 'Import from EPUB, PDF, or ZIP file'
}, },
{ {
id: 'bulk', id: 'bulk',

File diff suppressed because one or more lines are too long

View File

@@ -13,7 +13,7 @@ http {
server { server {
listen 80; listen 80;
client_max_body_size 2048M; # 2GB for large backup uploads client_max_body_size 4096M; # 4GB for large backup uploads
# Frontend routes # Frontend routes
location / { location / {
@@ -55,8 +55,8 @@ http {
proxy_connect_timeout 900s; proxy_connect_timeout 900s;
proxy_send_timeout 900s; proxy_send_timeout 900s;
proxy_read_timeout 900s; proxy_read_timeout 900s;
# Large upload settings (2GB for backups) # Large upload settings (4GB for backups)
client_max_body_size 2048M; client_max_body_size 4096M;
proxy_request_buffering off; proxy_request_buffering off;
proxy_max_temp_file_size 0; proxy_max_temp_file_size 0;
} }