PDF & ZIP IMPORT
This commit is contained in:
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
132
backend/src/main/java/com/storycove/dto/FileImportResponse.java
Normal file
132
backend/src/main/java/com/storycove/dto/FileImportResponse.java
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
76
backend/src/main/java/com/storycove/dto/FileInfoDto.java
Normal file
76
backend/src/main/java/com/storycove/dto/FileInfoDto.java
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
113
backend/src/main/java/com/storycove/dto/PDFImportRequest.java
Normal file
113
backend/src/main/java/com/storycove/dto/PDFImportRequest.java
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
177
backend/src/main/java/com/storycove/dto/ZIPImportRequest.java
Normal file
177
backend/src/main/java/com/storycove/dto/ZIPImportRequest.java
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
101
backend/src/main/java/com/storycove/dto/ZIPImportResponse.java
Normal file
101
backend/src/main/java/com/storycove/dto/ZIPImportResponse.java
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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("&", "&")
|
||||||
|
.replace("<", "<")
|
||||||
|
.replace(">", ">")
|
||||||
|
.replace("\"", """)
|
||||||
|
.replace("'", "'")
|
||||||
|
.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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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:
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
829
frontend/src/app/import/file/page.tsx
Normal file
829
frontend/src/app/import/file/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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',
|
||||||
|
|||||||
@@ -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
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user