Various improvements & Epub support
This commit is contained in:
@@ -42,6 +42,8 @@ public class StoryController {
|
||||
private final TypesenseService typesenseService;
|
||||
private final CollectionService collectionService;
|
||||
private final ReadingTimeService readingTimeService;
|
||||
private final EPUBImportService epubImportService;
|
||||
private final EPUBExportService epubExportService;
|
||||
|
||||
public StoryController(StoryService storyService,
|
||||
AuthorService authorService,
|
||||
@@ -50,7 +52,9 @@ public class StoryController {
|
||||
ImageService imageService,
|
||||
CollectionService collectionService,
|
||||
@Autowired(required = false) TypesenseService typesenseService,
|
||||
ReadingTimeService readingTimeService) {
|
||||
ReadingTimeService readingTimeService,
|
||||
EPUBImportService epubImportService,
|
||||
EPUBExportService epubExportService) {
|
||||
this.storyService = storyService;
|
||||
this.authorService = authorService;
|
||||
this.seriesService = seriesService;
|
||||
@@ -59,6 +63,8 @@ public class StoryController {
|
||||
this.collectionService = collectionService;
|
||||
this.typesenseService = typesenseService;
|
||||
this.readingTimeService = readingTimeService;
|
||||
this.epubImportService = epubImportService;
|
||||
this.epubExportService = epubExportService;
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
@@ -533,6 +539,117 @@ public class StoryController {
|
||||
}
|
||||
}
|
||||
|
||||
// EPUB Import endpoint
|
||||
@PostMapping("/epub/import")
|
||||
public ResponseEntity<EPUBImportResponse> importEPUB(
|
||||
@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 preserveReadingPosition,
|
||||
@RequestParam(defaultValue = "false") Boolean overwriteExisting,
|
||||
@RequestParam(defaultValue = "true") Boolean createMissingAuthor,
|
||||
@RequestParam(defaultValue = "true") Boolean createMissingSeries) {
|
||||
|
||||
logger.info("Importing EPUB file: {}", file.getOriginalFilename());
|
||||
|
||||
EPUBImportRequest request = new EPUBImportRequest();
|
||||
request.setEpubFile(file);
|
||||
request.setAuthorId(authorId);
|
||||
request.setAuthorName(authorName);
|
||||
request.setSeriesId(seriesId);
|
||||
request.setSeriesName(seriesName);
|
||||
request.setSeriesVolume(seriesVolume);
|
||||
request.setTags(tags);
|
||||
request.setPreserveReadingPosition(preserveReadingPosition);
|
||||
request.setOverwriteExisting(overwriteExisting);
|
||||
request.setCreateMissingAuthor(createMissingAuthor);
|
||||
request.setCreateMissingSeries(createMissingSeries);
|
||||
|
||||
try {
|
||||
EPUBImportResponse response = epubImportService.importEPUB(request);
|
||||
|
||||
if (response.isSuccess()) {
|
||||
logger.info("Successfully imported EPUB: {} (Story ID: {})",
|
||||
response.getStoryTitle(), response.getStoryId());
|
||||
return ResponseEntity.ok(response);
|
||||
} else {
|
||||
logger.warn("EPUB import failed: {}", response.getMessage());
|
||||
return ResponseEntity.badRequest().body(response);
|
||||
}
|
||||
|
||||
} catch (Exception e) {
|
||||
logger.error("Error importing EPUB: {}", e.getMessage(), e);
|
||||
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||
.body(EPUBImportResponse.error("Internal server error: " + e.getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
// EPUB Export endpoint
|
||||
@PostMapping("/epub/export")
|
||||
public ResponseEntity<org.springframework.core.io.Resource> exportEPUB(
|
||||
@Valid @RequestBody EPUBExportRequest request) {
|
||||
|
||||
logger.info("Exporting story {} to EPUB", request.getStoryId());
|
||||
|
||||
try {
|
||||
if (!epubExportService.canExportStory(request.getStoryId())) {
|
||||
return ResponseEntity.badRequest().build();
|
||||
}
|
||||
|
||||
org.springframework.core.io.Resource resource = epubExportService.exportStoryAsEPUB(request);
|
||||
Story story = storyService.findById(request.getStoryId());
|
||||
String filename = epubExportService.getEPUBFilename(story);
|
||||
|
||||
logger.info("Successfully exported EPUB: {}", filename);
|
||||
|
||||
return ResponseEntity.ok()
|
||||
.header("Content-Disposition", "attachment; filename=\"" + filename + "\"")
|
||||
.header("Content-Type", "application/epub+zip")
|
||||
.body(resource);
|
||||
|
||||
} catch (Exception e) {
|
||||
logger.error("Error exporting EPUB: {}", e.getMessage(), e);
|
||||
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
|
||||
}
|
||||
}
|
||||
|
||||
// EPUB Export by story ID (GET endpoint)
|
||||
@GetMapping("/{id}/epub")
|
||||
public ResponseEntity<org.springframework.core.io.Resource> exportStoryAsEPUB(@PathVariable UUID id) {
|
||||
logger.info("Exporting story {} to EPUB via GET", id);
|
||||
|
||||
EPUBExportRequest request = new EPUBExportRequest(id);
|
||||
return exportEPUB(request);
|
||||
}
|
||||
|
||||
// Validate EPUB file
|
||||
@PostMapping("/epub/validate")
|
||||
public ResponseEntity<Map<String, Object>> validateEPUBFile(@RequestParam("file") MultipartFile file) {
|
||||
logger.info("Validating EPUB file: {}", file.getOriginalFilename());
|
||||
|
||||
try {
|
||||
List<String> errors = epubImportService.validateEPUBFile(file);
|
||||
|
||||
Map<String, Object> response = Map.of(
|
||||
"valid", errors.isEmpty(),
|
||||
"errors", errors,
|
||||
"filename", file.getOriginalFilename(),
|
||||
"size", file.getSize()
|
||||
);
|
||||
|
||||
return ResponseEntity.ok(response);
|
||||
|
||||
} catch (Exception e) {
|
||||
logger.error("Error validating EPUB file: {}", e.getMessage(), e);
|
||||
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||
.body(Map.of("error", "Failed to validate EPUB file"));
|
||||
}
|
||||
}
|
||||
|
||||
// Request DTOs
|
||||
public static class CreateStoryRequest {
|
||||
private String title;
|
||||
|
||||
115
backend/src/main/java/com/storycove/dto/EPUBExportRequest.java
Normal file
115
backend/src/main/java/com/storycove/dto/EPUBExportRequest.java
Normal file
@@ -0,0 +1,115 @@
|
||||
package com.storycove.dto;
|
||||
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
public class EPUBExportRequest {
|
||||
|
||||
@NotNull(message = "Story ID is required")
|
||||
private UUID storyId;
|
||||
|
||||
private String customTitle;
|
||||
|
||||
private String customAuthor;
|
||||
|
||||
private Boolean includeReadingPosition = true;
|
||||
|
||||
private Boolean includeCoverImage = true;
|
||||
|
||||
private Boolean includeMetadata = true;
|
||||
|
||||
private List<String> customMetadata;
|
||||
|
||||
private String language = "en";
|
||||
|
||||
private Boolean splitByChapters = false;
|
||||
|
||||
private Integer maxWordsPerChapter;
|
||||
|
||||
public EPUBExportRequest() {}
|
||||
|
||||
public EPUBExportRequest(UUID storyId) {
|
||||
this.storyId = storyId;
|
||||
}
|
||||
|
||||
public UUID getStoryId() {
|
||||
return storyId;
|
||||
}
|
||||
|
||||
public void setStoryId(UUID storyId) {
|
||||
this.storyId = storyId;
|
||||
}
|
||||
|
||||
public String getCustomTitle() {
|
||||
return customTitle;
|
||||
}
|
||||
|
||||
public void setCustomTitle(String customTitle) {
|
||||
this.customTitle = customTitle;
|
||||
}
|
||||
|
||||
public String getCustomAuthor() {
|
||||
return customAuthor;
|
||||
}
|
||||
|
||||
public void setCustomAuthor(String customAuthor) {
|
||||
this.customAuthor = customAuthor;
|
||||
}
|
||||
|
||||
public Boolean getIncludeReadingPosition() {
|
||||
return includeReadingPosition;
|
||||
}
|
||||
|
||||
public void setIncludeReadingPosition(Boolean includeReadingPosition) {
|
||||
this.includeReadingPosition = includeReadingPosition;
|
||||
}
|
||||
|
||||
public Boolean getIncludeCoverImage() {
|
||||
return includeCoverImage;
|
||||
}
|
||||
|
||||
public void setIncludeCoverImage(Boolean includeCoverImage) {
|
||||
this.includeCoverImage = includeCoverImage;
|
||||
}
|
||||
|
||||
public Boolean getIncludeMetadata() {
|
||||
return includeMetadata;
|
||||
}
|
||||
|
||||
public void setIncludeMetadata(Boolean includeMetadata) {
|
||||
this.includeMetadata = includeMetadata;
|
||||
}
|
||||
|
||||
public List<String> getCustomMetadata() {
|
||||
return customMetadata;
|
||||
}
|
||||
|
||||
public void setCustomMetadata(List<String> customMetadata) {
|
||||
this.customMetadata = customMetadata;
|
||||
}
|
||||
|
||||
public String getLanguage() {
|
||||
return language;
|
||||
}
|
||||
|
||||
public void setLanguage(String language) {
|
||||
this.language = language;
|
||||
}
|
||||
|
||||
public Boolean getSplitByChapters() {
|
||||
return splitByChapters;
|
||||
}
|
||||
|
||||
public void setSplitByChapters(Boolean splitByChapters) {
|
||||
this.splitByChapters = splitByChapters;
|
||||
}
|
||||
|
||||
public Integer getMaxWordsPerChapter() {
|
||||
return maxWordsPerChapter;
|
||||
}
|
||||
|
||||
public void setMaxWordsPerChapter(Integer maxWordsPerChapter) {
|
||||
this.maxWordsPerChapter = maxWordsPerChapter;
|
||||
}
|
||||
}
|
||||
123
backend/src/main/java/com/storycove/dto/EPUBImportRequest.java
Normal file
123
backend/src/main/java/com/storycove/dto/EPUBImportRequest.java
Normal file
@@ -0,0 +1,123 @@
|
||||
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 EPUBImportRequest {
|
||||
|
||||
@NotNull(message = "EPUB file is required")
|
||||
private MultipartFile epubFile;
|
||||
|
||||
private UUID authorId;
|
||||
|
||||
private String authorName;
|
||||
|
||||
private UUID seriesId;
|
||||
|
||||
private String seriesName;
|
||||
|
||||
private Integer seriesVolume;
|
||||
|
||||
private List<String> tags;
|
||||
|
||||
private Boolean preserveReadingPosition = true;
|
||||
|
||||
private Boolean overwriteExisting = false;
|
||||
|
||||
private Boolean createMissingAuthor = true;
|
||||
|
||||
private Boolean createMissingSeries = true;
|
||||
|
||||
public EPUBImportRequest() {}
|
||||
|
||||
public MultipartFile getEpubFile() {
|
||||
return epubFile;
|
||||
}
|
||||
|
||||
public void setEpubFile(MultipartFile epubFile) {
|
||||
this.epubFile = epubFile;
|
||||
}
|
||||
|
||||
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 getPreserveReadingPosition() {
|
||||
return preserveReadingPosition;
|
||||
}
|
||||
|
||||
public void setPreserveReadingPosition(Boolean preserveReadingPosition) {
|
||||
this.preserveReadingPosition = preserveReadingPosition;
|
||||
}
|
||||
|
||||
public Boolean getOverwriteExisting() {
|
||||
return overwriteExisting;
|
||||
}
|
||||
|
||||
public void setOverwriteExisting(Boolean overwriteExisting) {
|
||||
this.overwriteExisting = overwriteExisting;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
107
backend/src/main/java/com/storycove/dto/EPUBImportResponse.java
Normal file
107
backend/src/main/java/com/storycove/dto/EPUBImportResponse.java
Normal file
@@ -0,0 +1,107 @@
|
||||
package com.storycove.dto;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
public class EPUBImportResponse {
|
||||
|
||||
private boolean success;
|
||||
private String message;
|
||||
private UUID storyId;
|
||||
private String storyTitle;
|
||||
private Integer totalChapters;
|
||||
private Integer wordCount;
|
||||
private ReadingPositionDto readingPosition;
|
||||
private List<String> warnings;
|
||||
private List<String> errors;
|
||||
|
||||
public EPUBImportResponse() {}
|
||||
|
||||
public EPUBImportResponse(boolean success, String message) {
|
||||
this.success = success;
|
||||
this.message = message;
|
||||
}
|
||||
|
||||
public static EPUBImportResponse success(UUID storyId, String storyTitle) {
|
||||
EPUBImportResponse response = new EPUBImportResponse(true, "EPUB imported successfully");
|
||||
response.setStoryId(storyId);
|
||||
response.setStoryTitle(storyTitle);
|
||||
return response;
|
||||
}
|
||||
|
||||
public static EPUBImportResponse error(String message) {
|
||||
return new EPUBImportResponse(false, message);
|
||||
}
|
||||
|
||||
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 Integer getTotalChapters() {
|
||||
return totalChapters;
|
||||
}
|
||||
|
||||
public void setTotalChapters(Integer totalChapters) {
|
||||
this.totalChapters = totalChapters;
|
||||
}
|
||||
|
||||
public Integer getWordCount() {
|
||||
return wordCount;
|
||||
}
|
||||
|
||||
public void setWordCount(Integer wordCount) {
|
||||
this.wordCount = wordCount;
|
||||
}
|
||||
|
||||
public ReadingPositionDto getReadingPosition() {
|
||||
return readingPosition;
|
||||
}
|
||||
|
||||
public void setReadingPosition(ReadingPositionDto readingPosition) {
|
||||
this.readingPosition = readingPosition;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
124
backend/src/main/java/com/storycove/dto/ReadingPositionDto.java
Normal file
124
backend/src/main/java/com/storycove/dto/ReadingPositionDto.java
Normal file
@@ -0,0 +1,124 @@
|
||||
package com.storycove.dto;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.UUID;
|
||||
|
||||
public class ReadingPositionDto {
|
||||
|
||||
private UUID id;
|
||||
private UUID storyId;
|
||||
private Integer chapterIndex;
|
||||
private String chapterTitle;
|
||||
private Integer wordPosition;
|
||||
private Integer characterPosition;
|
||||
private Double percentageComplete;
|
||||
private String epubCfi;
|
||||
private String contextBefore;
|
||||
private String contextAfter;
|
||||
private LocalDateTime createdAt;
|
||||
private LocalDateTime updatedAt;
|
||||
|
||||
public ReadingPositionDto() {}
|
||||
|
||||
public ReadingPositionDto(UUID storyId, Integer chapterIndex, Integer wordPosition) {
|
||||
this.storyId = storyId;
|
||||
this.chapterIndex = chapterIndex;
|
||||
this.wordPosition = wordPosition;
|
||||
}
|
||||
|
||||
public UUID getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
public void setId(UUID id) {
|
||||
this.id = id;
|
||||
}
|
||||
|
||||
public UUID getStoryId() {
|
||||
return storyId;
|
||||
}
|
||||
|
||||
public void setStoryId(UUID storyId) {
|
||||
this.storyId = storyId;
|
||||
}
|
||||
|
||||
public Integer getChapterIndex() {
|
||||
return chapterIndex;
|
||||
}
|
||||
|
||||
public void setChapterIndex(Integer chapterIndex) {
|
||||
this.chapterIndex = chapterIndex;
|
||||
}
|
||||
|
||||
public String getChapterTitle() {
|
||||
return chapterTitle;
|
||||
}
|
||||
|
||||
public void setChapterTitle(String chapterTitle) {
|
||||
this.chapterTitle = chapterTitle;
|
||||
}
|
||||
|
||||
public Integer getWordPosition() {
|
||||
return wordPosition;
|
||||
}
|
||||
|
||||
public void setWordPosition(Integer wordPosition) {
|
||||
this.wordPosition = wordPosition;
|
||||
}
|
||||
|
||||
public Integer getCharacterPosition() {
|
||||
return characterPosition;
|
||||
}
|
||||
|
||||
public void setCharacterPosition(Integer characterPosition) {
|
||||
this.characterPosition = characterPosition;
|
||||
}
|
||||
|
||||
public Double getPercentageComplete() {
|
||||
return percentageComplete;
|
||||
}
|
||||
|
||||
public void setPercentageComplete(Double percentageComplete) {
|
||||
this.percentageComplete = percentageComplete;
|
||||
}
|
||||
|
||||
public String getEpubCfi() {
|
||||
return epubCfi;
|
||||
}
|
||||
|
||||
public void setEpubCfi(String epubCfi) {
|
||||
this.epubCfi = epubCfi;
|
||||
}
|
||||
|
||||
public String getContextBefore() {
|
||||
return contextBefore;
|
||||
}
|
||||
|
||||
public void setContextBefore(String contextBefore) {
|
||||
this.contextBefore = contextBefore;
|
||||
}
|
||||
|
||||
public String getContextAfter() {
|
||||
return contextAfter;
|
||||
}
|
||||
|
||||
public void setContextAfter(String contextAfter) {
|
||||
this.contextAfter = contextAfter;
|
||||
}
|
||||
|
||||
public LocalDateTime getCreatedAt() {
|
||||
return createdAt;
|
||||
}
|
||||
|
||||
public void setCreatedAt(LocalDateTime createdAt) {
|
||||
this.createdAt = createdAt;
|
||||
}
|
||||
|
||||
public LocalDateTime getUpdatedAt() {
|
||||
return updatedAt;
|
||||
}
|
||||
|
||||
public void setUpdatedAt(LocalDateTime updatedAt) {
|
||||
this.updatedAt = updatedAt;
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,7 @@ public class StorySearchDto {
|
||||
|
||||
// Reading status
|
||||
private Boolean isRead;
|
||||
private LocalDateTime lastReadAt;
|
||||
|
||||
// Author info
|
||||
private UUID authorId;
|
||||
@@ -120,6 +121,14 @@ public class StorySearchDto {
|
||||
this.isRead = isRead;
|
||||
}
|
||||
|
||||
public LocalDateTime getLastReadAt() {
|
||||
return lastReadAt;
|
||||
}
|
||||
|
||||
public void setLastReadAt(LocalDateTime lastReadAt) {
|
||||
this.lastReadAt = lastReadAt;
|
||||
}
|
||||
|
||||
public UUID getAuthorId() {
|
||||
return authorId;
|
||||
}
|
||||
|
||||
230
backend/src/main/java/com/storycove/entity/ReadingPosition.java
Normal file
230
backend/src/main/java/com/storycove/entity/ReadingPosition.java
Normal file
@@ -0,0 +1,230 @@
|
||||
package com.storycove.entity;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import org.hibernate.annotations.CreationTimestamp;
|
||||
import org.hibernate.annotations.UpdateTimestamp;
|
||||
import com.fasterxml.jackson.annotation.JsonBackReference;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.UUID;
|
||||
|
||||
@Entity
|
||||
@Table(name = "reading_positions", indexes = {
|
||||
@Index(name = "idx_reading_position_story", columnList = "story_id")
|
||||
})
|
||||
public class ReadingPosition {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.UUID)
|
||||
private UUID id;
|
||||
|
||||
@NotNull
|
||||
@ManyToOne(fetch = FetchType.LAZY)
|
||||
@JoinColumn(name = "story_id", nullable = false)
|
||||
@JsonBackReference("story-reading-positions")
|
||||
private Story story;
|
||||
|
||||
@Column(name = "chapter_index")
|
||||
private Integer chapterIndex;
|
||||
|
||||
@Column(name = "chapter_title")
|
||||
private String chapterTitle;
|
||||
|
||||
@Column(name = "word_position")
|
||||
private Integer wordPosition;
|
||||
|
||||
@Column(name = "character_position")
|
||||
private Integer characterPosition;
|
||||
|
||||
@Column(name = "percentage_complete")
|
||||
private Double percentageComplete;
|
||||
|
||||
@Column(name = "epub_cfi", columnDefinition = "TEXT")
|
||||
private String epubCfi;
|
||||
|
||||
@Column(name = "context_before", length = 500)
|
||||
private String contextBefore;
|
||||
|
||||
@Column(name = "context_after", length = 500)
|
||||
private String contextAfter;
|
||||
|
||||
@CreationTimestamp
|
||||
@Column(name = "created_at", nullable = false, updatable = false)
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
@UpdateTimestamp
|
||||
@Column(name = "updated_at", nullable = false)
|
||||
private LocalDateTime updatedAt;
|
||||
|
||||
public ReadingPosition() {}
|
||||
|
||||
public ReadingPosition(Story story) {
|
||||
this.story = story;
|
||||
this.chapterIndex = 0;
|
||||
this.wordPosition = 0;
|
||||
this.characterPosition = 0;
|
||||
this.percentageComplete = 0.0;
|
||||
}
|
||||
|
||||
public ReadingPosition(Story story, Integer chapterIndex, Integer wordPosition) {
|
||||
this.story = story;
|
||||
this.chapterIndex = chapterIndex;
|
||||
this.wordPosition = wordPosition;
|
||||
this.characterPosition = 0;
|
||||
this.percentageComplete = 0.0;
|
||||
}
|
||||
|
||||
public void updatePosition(Integer chapterIndex, Integer wordPosition, Integer characterPosition) {
|
||||
this.chapterIndex = chapterIndex;
|
||||
this.wordPosition = wordPosition;
|
||||
this.characterPosition = characterPosition;
|
||||
calculatePercentageComplete();
|
||||
}
|
||||
|
||||
public void updatePositionWithCfi(String epubCfi, Integer chapterIndex, Integer wordPosition) {
|
||||
this.epubCfi = epubCfi;
|
||||
this.chapterIndex = chapterIndex;
|
||||
this.wordPosition = wordPosition;
|
||||
calculatePercentageComplete();
|
||||
}
|
||||
|
||||
private void calculatePercentageComplete() {
|
||||
if (story != null && story.getWordCount() != null && story.getWordCount() > 0) {
|
||||
int totalWords = story.getWordCount();
|
||||
int currentPosition = (chapterIndex != null ? chapterIndex * 1000 : 0) +
|
||||
(wordPosition != null ? wordPosition : 0);
|
||||
this.percentageComplete = Math.min(100.0, (double) currentPosition / totalWords * 100);
|
||||
}
|
||||
}
|
||||
|
||||
public boolean isAtBeginning() {
|
||||
return (chapterIndex == null || chapterIndex == 0) &&
|
||||
(wordPosition == null || wordPosition == 0);
|
||||
}
|
||||
|
||||
public boolean isCompleted() {
|
||||
return percentageComplete != null && percentageComplete >= 95.0;
|
||||
}
|
||||
|
||||
// Getters and Setters
|
||||
public UUID getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
public void setId(UUID id) {
|
||||
this.id = id;
|
||||
}
|
||||
|
||||
public Story getStory() {
|
||||
return story;
|
||||
}
|
||||
|
||||
public void setStory(Story story) {
|
||||
this.story = story;
|
||||
}
|
||||
|
||||
public Integer getChapterIndex() {
|
||||
return chapterIndex;
|
||||
}
|
||||
|
||||
public void setChapterIndex(Integer chapterIndex) {
|
||||
this.chapterIndex = chapterIndex;
|
||||
}
|
||||
|
||||
public String getChapterTitle() {
|
||||
return chapterTitle;
|
||||
}
|
||||
|
||||
public void setChapterTitle(String chapterTitle) {
|
||||
this.chapterTitle = chapterTitle;
|
||||
}
|
||||
|
||||
public Integer getWordPosition() {
|
||||
return wordPosition;
|
||||
}
|
||||
|
||||
public void setWordPosition(Integer wordPosition) {
|
||||
this.wordPosition = wordPosition;
|
||||
}
|
||||
|
||||
public Integer getCharacterPosition() {
|
||||
return characterPosition;
|
||||
}
|
||||
|
||||
public void setCharacterPosition(Integer characterPosition) {
|
||||
this.characterPosition = characterPosition;
|
||||
}
|
||||
|
||||
public Double getPercentageComplete() {
|
||||
return percentageComplete;
|
||||
}
|
||||
|
||||
public void setPercentageComplete(Double percentageComplete) {
|
||||
this.percentageComplete = percentageComplete;
|
||||
}
|
||||
|
||||
public String getEpubCfi() {
|
||||
return epubCfi;
|
||||
}
|
||||
|
||||
public void setEpubCfi(String epubCfi) {
|
||||
this.epubCfi = epubCfi;
|
||||
}
|
||||
|
||||
public String getContextBefore() {
|
||||
return contextBefore;
|
||||
}
|
||||
|
||||
public void setContextBefore(String contextBefore) {
|
||||
this.contextBefore = contextBefore;
|
||||
}
|
||||
|
||||
public String getContextAfter() {
|
||||
return contextAfter;
|
||||
}
|
||||
|
||||
public void setContextAfter(String contextAfter) {
|
||||
this.contextAfter = contextAfter;
|
||||
}
|
||||
|
||||
public LocalDateTime getCreatedAt() {
|
||||
return createdAt;
|
||||
}
|
||||
|
||||
public void setCreatedAt(LocalDateTime createdAt) {
|
||||
this.createdAt = createdAt;
|
||||
}
|
||||
|
||||
public LocalDateTime getUpdatedAt() {
|
||||
return updatedAt;
|
||||
}
|
||||
|
||||
public void setUpdatedAt(LocalDateTime updatedAt) {
|
||||
this.updatedAt = updatedAt;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean equals(Object o) {
|
||||
if (this == o) return true;
|
||||
if (!(o instanceof ReadingPosition)) return false;
|
||||
ReadingPosition that = (ReadingPosition) o;
|
||||
return id != null && id.equals(that.id);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
return getClass().hashCode();
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return "ReadingPosition{" +
|
||||
"id=" + id +
|
||||
", storyId=" + (story != null ? story.getId() : null) +
|
||||
", chapterIndex=" + chapterIndex +
|
||||
", wordPosition=" + wordPosition +
|
||||
", percentageComplete=" + percentageComplete +
|
||||
'}';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package com.storycove.repository;
|
||||
|
||||
import com.storycove.entity.ReadingPosition;
|
||||
import com.storycove.entity.Story;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
@Repository
|
||||
public interface ReadingPositionRepository extends JpaRepository<ReadingPosition, UUID> {
|
||||
|
||||
Optional<ReadingPosition> findByStoryId(UUID storyId);
|
||||
|
||||
Optional<ReadingPosition> findByStory(Story story);
|
||||
|
||||
List<ReadingPosition> findByStoryIdIn(List<UUID> storyIds);
|
||||
|
||||
@Query("SELECT rp FROM ReadingPosition rp WHERE rp.story.id = :storyId ORDER BY rp.updatedAt DESC")
|
||||
List<ReadingPosition> findByStoryIdOrderByUpdatedAtDesc(@Param("storyId") UUID storyId);
|
||||
|
||||
@Query("SELECT rp FROM ReadingPosition rp WHERE rp.percentageComplete >= :minPercentage")
|
||||
List<ReadingPosition> findByMinimumPercentageComplete(@Param("minPercentage") Double minPercentage);
|
||||
|
||||
@Query("SELECT rp FROM ReadingPosition rp WHERE rp.percentageComplete >= 95.0")
|
||||
List<ReadingPosition> findCompletedReadings();
|
||||
|
||||
@Query("SELECT rp FROM ReadingPosition rp WHERE rp.percentageComplete > 0 AND rp.percentageComplete < 95.0")
|
||||
List<ReadingPosition> findInProgressReadings();
|
||||
|
||||
@Query("SELECT rp FROM ReadingPosition rp WHERE rp.updatedAt >= :since ORDER BY rp.updatedAt DESC")
|
||||
List<ReadingPosition> findRecentlyUpdated(@Param("since") LocalDateTime since);
|
||||
|
||||
@Query("SELECT rp FROM ReadingPosition rp ORDER BY rp.updatedAt DESC")
|
||||
List<ReadingPosition> findAllOrderByUpdatedAtDesc();
|
||||
|
||||
@Query("SELECT COUNT(rp) FROM ReadingPosition rp WHERE rp.percentageComplete >= 95.0")
|
||||
long countCompletedReadings();
|
||||
|
||||
@Query("SELECT COUNT(rp) FROM ReadingPosition rp WHERE rp.percentageComplete > 0 AND rp.percentageComplete < 95.0")
|
||||
long countInProgressReadings();
|
||||
|
||||
@Query("SELECT AVG(rp.percentageComplete) FROM ReadingPosition rp WHERE rp.percentageComplete > 0")
|
||||
Double findAverageReadingProgress();
|
||||
|
||||
@Query("SELECT rp FROM ReadingPosition rp WHERE rp.epubCfi IS NOT NULL")
|
||||
List<ReadingPosition> findPositionsWithEpubCfi();
|
||||
|
||||
boolean existsByStoryId(UUID storyId);
|
||||
|
||||
void deleteByStoryId(UUID storyId);
|
||||
}
|
||||
@@ -0,0 +1,386 @@
|
||||
package com.storycove.service;
|
||||
|
||||
import com.storycove.dto.EPUBExportRequest;
|
||||
import com.storycove.entity.ReadingPosition;
|
||||
import com.storycove.entity.Story;
|
||||
import com.storycove.repository.ReadingPositionRepository;
|
||||
import com.storycove.service.exception.ResourceNotFoundException;
|
||||
|
||||
import nl.siegmann.epublib.domain.*;
|
||||
import nl.siegmann.epublib.epub.EpubWriter;
|
||||
|
||||
import org.jsoup.Jsoup;
|
||||
import org.jsoup.nodes.Document;
|
||||
import org.jsoup.nodes.Element;
|
||||
import org.jsoup.select.Elements;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.core.io.ByteArrayResource;
|
||||
import org.springframework.core.io.Resource;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.FileInputStream;
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
@Service
|
||||
@Transactional
|
||||
public class EPUBExportService {
|
||||
|
||||
private final StoryService storyService;
|
||||
private final ReadingPositionRepository readingPositionRepository;
|
||||
|
||||
@Autowired
|
||||
public EPUBExportService(StoryService storyService,
|
||||
ReadingPositionRepository readingPositionRepository) {
|
||||
this.storyService = storyService;
|
||||
this.readingPositionRepository = readingPositionRepository;
|
||||
}
|
||||
|
||||
public Resource exportStoryAsEPUB(EPUBExportRequest request) throws IOException {
|
||||
Story story = storyService.findById(request.getStoryId());
|
||||
|
||||
Book book = createEPUBBook(story, request);
|
||||
|
||||
ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
|
||||
EpubWriter epubWriter = new EpubWriter();
|
||||
epubWriter.write(book, outputStream);
|
||||
|
||||
return new ByteArrayResource(outputStream.toByteArray());
|
||||
}
|
||||
|
||||
private Book createEPUBBook(Story story, EPUBExportRequest request) throws IOException {
|
||||
Book book = new Book();
|
||||
|
||||
setupMetadata(book, story, request);
|
||||
|
||||
addCoverImage(book, story, request);
|
||||
|
||||
addContent(book, story, request);
|
||||
|
||||
addReadingPosition(book, story, request);
|
||||
|
||||
return book;
|
||||
}
|
||||
|
||||
private void setupMetadata(Book book, Story story, EPUBExportRequest request) {
|
||||
Metadata metadata = book.getMetadata();
|
||||
|
||||
String title = request.getCustomTitle() != null ?
|
||||
request.getCustomTitle() : story.getTitle();
|
||||
metadata.addTitle(title);
|
||||
|
||||
String authorName = request.getCustomAuthor() != null ?
|
||||
request.getCustomAuthor() :
|
||||
(story.getAuthor() != null ? story.getAuthor().getName() : "Unknown Author");
|
||||
metadata.addAuthor(new Author(authorName));
|
||||
|
||||
metadata.setLanguage(request.getLanguage() != null ? request.getLanguage() : "en");
|
||||
|
||||
metadata.addIdentifier(new Identifier("storycove", story.getId().toString()));
|
||||
|
||||
if (story.getDescription() != null) {
|
||||
metadata.addDescription(story.getDescription());
|
||||
}
|
||||
|
||||
if (request.getIncludeMetadata()) {
|
||||
metadata.addDate(new Date(java.util.Date.from(
|
||||
story.getCreatedAt().atZone(java.time.ZoneId.systemDefault()).toInstant()
|
||||
), Date.Event.CREATION));
|
||||
|
||||
if (story.getSeries() != null) {
|
||||
// Add series and metadata info to description instead of using addMeta
|
||||
StringBuilder description = new StringBuilder();
|
||||
if (story.getDescription() != null) {
|
||||
description.append(story.getDescription()).append("\n\n");
|
||||
}
|
||||
|
||||
description.append("Series: ").append(story.getSeries().getName());
|
||||
if (story.getVolume() != null) {
|
||||
description.append(" (Volume ").append(story.getVolume()).append(")");
|
||||
}
|
||||
description.append("\n");
|
||||
|
||||
if (story.getWordCount() != null) {
|
||||
description.append("Word Count: ").append(story.getWordCount()).append("\n");
|
||||
}
|
||||
|
||||
if (story.getRating() != null) {
|
||||
description.append("Rating: ").append(story.getRating()).append("/5\n");
|
||||
}
|
||||
|
||||
if (!story.getTags().isEmpty()) {
|
||||
String tags = story.getTags().stream()
|
||||
.map(tag -> tag.getName())
|
||||
.reduce((a, b) -> a + ", " + b)
|
||||
.orElse("");
|
||||
description.append("Tags: ").append(tags).append("\n");
|
||||
}
|
||||
|
||||
description.append("\nGenerated by StoryCove on ")
|
||||
.append(LocalDateTime.now().format(DateTimeFormatter.ISO_LOCAL_DATE_TIME));
|
||||
|
||||
metadata.addDescription(description.toString());
|
||||
}
|
||||
}
|
||||
|
||||
if (request.getCustomMetadata() != null && !request.getCustomMetadata().isEmpty()) {
|
||||
// Add custom metadata to description since addMeta doesn't exist
|
||||
StringBuilder customDesc = new StringBuilder();
|
||||
for (String customMeta : request.getCustomMetadata()) {
|
||||
String[] parts = customMeta.split(":", 2);
|
||||
if (parts.length == 2) {
|
||||
customDesc.append(parts[0].trim()).append(": ").append(parts[1].trim()).append("\n");
|
||||
}
|
||||
}
|
||||
if (customDesc.length() > 0) {
|
||||
String existingDesc = metadata.getDescriptions().isEmpty() ? "" : metadata.getDescriptions().get(0);
|
||||
metadata.addDescription(existingDesc + "\n" + customDesc.toString());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void addCoverImage(Book book, Story story, EPUBExportRequest request) {
|
||||
if (!request.getIncludeCoverImage() || story.getCoverPath() == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
Path coverPath = Paths.get(story.getCoverPath());
|
||||
if (Files.exists(coverPath)) {
|
||||
byte[] coverImageData = Files.readAllBytes(coverPath);
|
||||
String mimeType = Files.probeContentType(coverPath);
|
||||
if (mimeType == null) {
|
||||
mimeType = "image/jpeg";
|
||||
}
|
||||
|
||||
nl.siegmann.epublib.domain.Resource coverResource =
|
||||
new nl.siegmann.epublib.domain.Resource(coverImageData, "cover.jpg");
|
||||
|
||||
book.setCoverImage(coverResource);
|
||||
}
|
||||
} catch (IOException e) {
|
||||
// Skip cover image on error
|
||||
}
|
||||
}
|
||||
|
||||
private void addContent(Book book, Story story, EPUBExportRequest request) {
|
||||
String content = story.getContentHtml();
|
||||
if (content == null) {
|
||||
content = story.getContentPlain() != null ?
|
||||
"<p>" + story.getContentPlain().replace("\n", "</p><p>") + "</p>" :
|
||||
"<p>No content available</p>";
|
||||
}
|
||||
|
||||
if (request.getSplitByChapters()) {
|
||||
addChapterizedContent(book, content, request);
|
||||
} else {
|
||||
addSingleChapterContent(book, content, story);
|
||||
}
|
||||
}
|
||||
|
||||
private void addSingleChapterContent(Book book, String content, Story story) {
|
||||
String html = createChapterHTML(story.getTitle(), content);
|
||||
|
||||
nl.siegmann.epublib.domain.Resource chapterResource =
|
||||
new nl.siegmann.epublib.domain.Resource(html.getBytes(), "chapter.html");
|
||||
|
||||
book.addSection(story.getTitle(), chapterResource);
|
||||
}
|
||||
|
||||
private void addChapterizedContent(Book book, String content, EPUBExportRequest request) {
|
||||
Document doc = Jsoup.parse(content);
|
||||
Elements chapters = doc.select("div.chapter, h1, h2, h3");
|
||||
|
||||
if (chapters.isEmpty()) {
|
||||
List<String> paragraphs = splitByWords(content,
|
||||
request.getMaxWordsPerChapter() != null ? request.getMaxWordsPerChapter() : 2000);
|
||||
|
||||
for (int i = 0; i < paragraphs.size(); i++) {
|
||||
String chapterTitle = "Chapter " + (i + 1);
|
||||
String html = createChapterHTML(chapterTitle, paragraphs.get(i));
|
||||
|
||||
nl.siegmann.epublib.domain.Resource chapterResource =
|
||||
new nl.siegmann.epublib.domain.Resource(html.getBytes(), "chapter" + (i + 1) + ".html");
|
||||
|
||||
book.addSection(chapterTitle, chapterResource);
|
||||
}
|
||||
} else {
|
||||
for (int i = 0; i < chapters.size(); i++) {
|
||||
Element chapter = chapters.get(i);
|
||||
String chapterTitle = chapter.text();
|
||||
if (chapterTitle.trim().isEmpty()) {
|
||||
chapterTitle = "Chapter " + (i + 1);
|
||||
}
|
||||
|
||||
String chapterContent = chapter.html();
|
||||
String html = createChapterHTML(chapterTitle, chapterContent);
|
||||
|
||||
nl.siegmann.epublib.domain.Resource chapterResource =
|
||||
new nl.siegmann.epublib.domain.Resource(html.getBytes(), "chapter" + (i + 1) + ".html");
|
||||
|
||||
book.addSection(chapterTitle, chapterResource);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private List<String> splitByWords(String content, int maxWordsPerChapter) {
|
||||
String[] words = content.split("\\s+");
|
||||
List<String> chapters = new ArrayList<>();
|
||||
StringBuilder currentChapter = new StringBuilder();
|
||||
int wordCount = 0;
|
||||
|
||||
for (String word : words) {
|
||||
currentChapter.append(word).append(" ");
|
||||
wordCount++;
|
||||
|
||||
if (wordCount >= maxWordsPerChapter) {
|
||||
chapters.add(currentChapter.toString().trim());
|
||||
currentChapter = new StringBuilder();
|
||||
wordCount = 0;
|
||||
}
|
||||
}
|
||||
|
||||
if (currentChapter.length() > 0) {
|
||||
chapters.add(currentChapter.toString().trim());
|
||||
}
|
||||
|
||||
return chapters;
|
||||
}
|
||||
|
||||
private String createChapterHTML(String title, String content) {
|
||||
return "<?xml version=\"1.0\" encoding=\"UTF-8\"?>" +
|
||||
"<!DOCTYPE html PUBLIC \"-//W3C//DTD XHTML 1.1//EN\" " +
|
||||
"\"http://www.w3.org/TR/xhtml11/DTD/xhtml11.dtd\">" +
|
||||
"<html xmlns=\"http://www.w3.org/1999/xhtml\">" +
|
||||
"<head>" +
|
||||
"<title>" + escapeHtml(title) + "</title>" +
|
||||
"<style type=\"text/css\">" +
|
||||
"body { font-family: serif; margin: 1em; }" +
|
||||
"h1 { text-align: center; }" +
|
||||
"p { text-indent: 1em; margin: 0.5em 0; }" +
|
||||
"</style>" +
|
||||
"</head>" +
|
||||
"<body>" +
|
||||
"<h1>" + escapeHtml(title) + "</h1>" +
|
||||
fixHtmlForXhtml(content) +
|
||||
"</body>" +
|
||||
"</html>";
|
||||
}
|
||||
|
||||
private void addReadingPosition(Book book, Story story, EPUBExportRequest request) {
|
||||
if (!request.getIncludeReadingPosition()) {
|
||||
return;
|
||||
}
|
||||
|
||||
Optional<ReadingPosition> positionOpt = readingPositionRepository.findByStoryId(story.getId());
|
||||
if (positionOpt.isPresent()) {
|
||||
ReadingPosition position = positionOpt.get();
|
||||
Metadata metadata = book.getMetadata();
|
||||
|
||||
// Add reading position to description since addMeta doesn't exist
|
||||
StringBuilder positionDesc = new StringBuilder();
|
||||
if (position.getEpubCfi() != null) {
|
||||
positionDesc.append("EPUB CFI: ").append(position.getEpubCfi()).append("\n");
|
||||
}
|
||||
|
||||
if (position.getChapterIndex() != null && position.getWordPosition() != null) {
|
||||
positionDesc.append("Reading Position: Chapter ")
|
||||
.append(position.getChapterIndex())
|
||||
.append(", Word ").append(position.getWordPosition()).append("\n");
|
||||
}
|
||||
|
||||
if (position.getPercentageComplete() != null) {
|
||||
positionDesc.append("Reading Progress: ")
|
||||
.append(String.format("%.1f%%", position.getPercentageComplete())).append("\n");
|
||||
}
|
||||
|
||||
positionDesc.append("Last Read: ")
|
||||
.append(position.getUpdatedAt().format(DateTimeFormatter.ISO_LOCAL_DATE_TIME));
|
||||
|
||||
String existingDesc = metadata.getDescriptions().isEmpty() ? "" : metadata.getDescriptions().get(0);
|
||||
metadata.addDescription(existingDesc + "\n\n--- Reading Position ---\n" + positionDesc.toString());
|
||||
}
|
||||
}
|
||||
|
||||
private String fixHtmlForXhtml(String html) {
|
||||
if (html == null) return "";
|
||||
|
||||
// Fix common XHTML validation issues
|
||||
String fixed = html
|
||||
// Fix self-closing tags to be XHTML compliant
|
||||
.replaceAll("<br>", "<br />")
|
||||
.replaceAll("<hr>", "<hr />")
|
||||
.replaceAll("<img([^>]*)>", "<img$1 />")
|
||||
.replaceAll("<input([^>]*)>", "<input$1 />")
|
||||
.replaceAll("<area([^>]*)>", "<area$1 />")
|
||||
.replaceAll("<base([^>]*)>", "<base$1 />")
|
||||
.replaceAll("<col([^>]*)>", "<col$1 />")
|
||||
.replaceAll("<embed([^>]*)>", "<embed$1 />")
|
||||
.replaceAll("<link([^>]*)>", "<link$1 />")
|
||||
.replaceAll("<meta([^>]*)>", "<meta$1 />")
|
||||
.replaceAll("<param([^>]*)>", "<param$1 />")
|
||||
.replaceAll("<source([^>]*)>", "<source$1 />")
|
||||
.replaceAll("<track([^>]*)>", "<track$1 />")
|
||||
.replaceAll("<wbr([^>]*)>", "<wbr$1 />");
|
||||
|
||||
return fixed;
|
||||
}
|
||||
|
||||
private String escapeHtml(String text) {
|
||||
if (text == null) return "";
|
||||
return text.replace("&", "&")
|
||||
.replace("<", "<")
|
||||
.replace(">", ">")
|
||||
.replace("\"", """)
|
||||
.replace("'", "'");
|
||||
}
|
||||
|
||||
public String getEPUBFilename(Story story) {
|
||||
StringBuilder filename = new StringBuilder();
|
||||
|
||||
if (story.getAuthor() != null) {
|
||||
filename.append(sanitizeFilename(story.getAuthor().getName()))
|
||||
.append(" - ");
|
||||
}
|
||||
|
||||
filename.append(sanitizeFilename(story.getTitle()));
|
||||
|
||||
if (story.getSeries() != null && story.getVolume() != null) {
|
||||
filename.append(" (")
|
||||
.append(sanitizeFilename(story.getSeries().getName()))
|
||||
.append(" ")
|
||||
.append(story.getVolume())
|
||||
.append(")");
|
||||
}
|
||||
|
||||
filename.append(".epub");
|
||||
|
||||
return filename.toString();
|
||||
}
|
||||
|
||||
private String sanitizeFilename(String filename) {
|
||||
if (filename == null) return "unknown";
|
||||
return filename.replaceAll("[^a-zA-Z0-9._\\- ]", "")
|
||||
.trim()
|
||||
.replaceAll("\\s+", "_");
|
||||
}
|
||||
|
||||
public boolean canExportStory(UUID storyId) {
|
||||
try {
|
||||
Story story = storyService.findById(storyId);
|
||||
return story.getContentHtml() != null || story.getContentPlain() != null;
|
||||
} catch (ResourceNotFoundException e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,327 @@
|
||||
package com.storycove.service;
|
||||
|
||||
import com.storycove.dto.EPUBImportRequest;
|
||||
import com.storycove.dto.EPUBImportResponse;
|
||||
import com.storycove.dto.ReadingPositionDto;
|
||||
import com.storycove.entity.*;
|
||||
import com.storycove.repository.ReadingPositionRepository;
|
||||
import com.storycove.service.exception.InvalidFileException;
|
||||
import com.storycove.service.exception.ResourceNotFoundException;
|
||||
|
||||
import nl.siegmann.epublib.domain.Book;
|
||||
import nl.siegmann.epublib.domain.Metadata;
|
||||
import nl.siegmann.epublib.domain.Resource;
|
||||
import nl.siegmann.epublib.domain.SpineReference;
|
||||
import nl.siegmann.epublib.epub.EpubReader;
|
||||
|
||||
import org.jsoup.Jsoup;
|
||||
import org.jsoup.nodes.Document;
|
||||
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 java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@Service
|
||||
@Transactional
|
||||
public class EPUBImportService {
|
||||
|
||||
private final StoryService storyService;
|
||||
private final AuthorService authorService;
|
||||
private final SeriesService seriesService;
|
||||
private final TagService tagService;
|
||||
private final ReadingPositionRepository readingPositionRepository;
|
||||
private final HtmlSanitizationService sanitizationService;
|
||||
|
||||
@Autowired
|
||||
public EPUBImportService(StoryService storyService,
|
||||
AuthorService authorService,
|
||||
SeriesService seriesService,
|
||||
TagService tagService,
|
||||
ReadingPositionRepository readingPositionRepository,
|
||||
HtmlSanitizationService sanitizationService) {
|
||||
this.storyService = storyService;
|
||||
this.authorService = authorService;
|
||||
this.seriesService = seriesService;
|
||||
this.tagService = tagService;
|
||||
this.readingPositionRepository = readingPositionRepository;
|
||||
this.sanitizationService = sanitizationService;
|
||||
}
|
||||
|
||||
public EPUBImportResponse importEPUB(EPUBImportRequest request) {
|
||||
try {
|
||||
MultipartFile epubFile = request.getEpubFile();
|
||||
|
||||
if (epubFile == null || epubFile.isEmpty()) {
|
||||
return EPUBImportResponse.error("EPUB file is required");
|
||||
}
|
||||
|
||||
if (!isValidEPUBFile(epubFile)) {
|
||||
return EPUBImportResponse.error("Invalid EPUB file format");
|
||||
}
|
||||
|
||||
Book book = parseEPUBFile(epubFile);
|
||||
|
||||
Story story = createStoryFromEPUB(book, request);
|
||||
|
||||
Story savedStory = storyService.create(story);
|
||||
|
||||
EPUBImportResponse response = EPUBImportResponse.success(savedStory.getId(), savedStory.getTitle());
|
||||
response.setWordCount(savedStory.getWordCount());
|
||||
response.setTotalChapters(book.getSpine().size());
|
||||
|
||||
if (request.getPreserveReadingPosition() != null && request.getPreserveReadingPosition()) {
|
||||
ReadingPosition readingPosition = extractReadingPosition(book, savedStory);
|
||||
if (readingPosition != null) {
|
||||
ReadingPosition savedPosition = readingPositionRepository.save(readingPosition);
|
||||
response.setReadingPosition(convertToDto(savedPosition));
|
||||
}
|
||||
}
|
||||
|
||||
return response;
|
||||
|
||||
} catch (Exception e) {
|
||||
return EPUBImportResponse.error("Failed to import EPUB: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private boolean isValidEPUBFile(MultipartFile file) {
|
||||
String filename = file.getOriginalFilename();
|
||||
if (filename == null || !filename.toLowerCase().endsWith(".epub")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
String contentType = file.getContentType();
|
||||
return "application/epub+zip".equals(contentType) ||
|
||||
"application/zip".equals(contentType) ||
|
||||
contentType == null;
|
||||
}
|
||||
|
||||
private Book parseEPUBFile(MultipartFile epubFile) throws IOException {
|
||||
try (InputStream inputStream = epubFile.getInputStream()) {
|
||||
EpubReader epubReader = new EpubReader();
|
||||
return epubReader.readEpub(inputStream);
|
||||
} catch (Exception e) {
|
||||
throw new InvalidFileException("Failed to parse EPUB file: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private Story createStoryFromEPUB(Book book, EPUBImportRequest request) {
|
||||
Metadata metadata = book.getMetadata();
|
||||
|
||||
String title = extractTitle(metadata);
|
||||
String authorName = extractAuthorName(metadata, request);
|
||||
String description = extractDescription(metadata);
|
||||
String content = extractContent(book);
|
||||
|
||||
Story story = new Story();
|
||||
story.setTitle(title);
|
||||
story.setDescription(description);
|
||||
story.setContentHtml(sanitizationService.sanitize(content));
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
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());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (request.getTags() != null && !request.getTags().isEmpty()) {
|
||||
for (String tagName : request.getTags()) {
|
||||
Tag tag = tagService.findOrCreate(tagName);
|
||||
story.addTag(tag);
|
||||
}
|
||||
}
|
||||
|
||||
return story;
|
||||
}
|
||||
|
||||
private String extractTitle(Metadata metadata) {
|
||||
List<String> titles = metadata.getTitles();
|
||||
if (titles != null && !titles.isEmpty()) {
|
||||
return titles.get(0);
|
||||
}
|
||||
return "Untitled EPUB";
|
||||
}
|
||||
|
||||
private String extractAuthorName(Metadata metadata, EPUBImportRequest request) {
|
||||
if (request.getAuthorName() != null && !request.getAuthorName().trim().isEmpty()) {
|
||||
return request.getAuthorName().trim();
|
||||
}
|
||||
|
||||
if (metadata.getAuthors() != null && !metadata.getAuthors().isEmpty()) {
|
||||
return metadata.getAuthors().get(0).getFirstname() + " " + metadata.getAuthors().get(0).getLastname();
|
||||
}
|
||||
|
||||
return "Unknown Author";
|
||||
}
|
||||
|
||||
private String extractDescription(Metadata metadata) {
|
||||
List<String> descriptions = metadata.getDescriptions();
|
||||
if (descriptions != null && !descriptions.isEmpty()) {
|
||||
return descriptions.get(0);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private String extractContent(Book book) {
|
||||
StringBuilder contentBuilder = new StringBuilder();
|
||||
|
||||
List<SpineReference> spine = book.getSpine().getSpineReferences();
|
||||
for (SpineReference spineRef : spine) {
|
||||
try {
|
||||
Resource resource = spineRef.getResource();
|
||||
if (resource != null && resource.getData() != null) {
|
||||
String html = new String(resource.getData(), "UTF-8");
|
||||
|
||||
Document doc = Jsoup.parse(html);
|
||||
doc.select("script, style").remove();
|
||||
|
||||
String chapterContent = doc.body() != null ? doc.body().html() : doc.html();
|
||||
|
||||
contentBuilder.append("<div class=\"chapter\">")
|
||||
.append(chapterContent)
|
||||
.append("</div>");
|
||||
}
|
||||
} catch (Exception e) {
|
||||
// Skip this chapter on error
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
return contentBuilder.toString();
|
||||
}
|
||||
|
||||
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 ReadingPosition extractReadingPosition(Book book, Story story) {
|
||||
try {
|
||||
Metadata metadata = book.getMetadata();
|
||||
|
||||
String positionMeta = metadata.getMetaAttribute("reading-position");
|
||||
String cfiMeta = metadata.getMetaAttribute("epub-cfi");
|
||||
|
||||
ReadingPosition position = new ReadingPosition(story);
|
||||
|
||||
if (cfiMeta != null) {
|
||||
position.setEpubCfi(cfiMeta);
|
||||
}
|
||||
|
||||
if (positionMeta != null) {
|
||||
try {
|
||||
String[] parts = positionMeta.split(":");
|
||||
if (parts.length >= 2) {
|
||||
position.setChapterIndex(Integer.parseInt(parts[0]));
|
||||
position.setWordPosition(Integer.parseInt(parts[1]));
|
||||
}
|
||||
} catch (NumberFormatException e) {
|
||||
// Ignore invalid position format
|
||||
}
|
||||
}
|
||||
|
||||
return position;
|
||||
|
||||
} catch (Exception e) {
|
||||
// Return null if no reading position found
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private ReadingPositionDto convertToDto(ReadingPosition position) {
|
||||
if (position == null) return null;
|
||||
|
||||
ReadingPositionDto dto = new ReadingPositionDto();
|
||||
dto.setId(position.getId());
|
||||
dto.setStoryId(position.getStory().getId());
|
||||
dto.setChapterIndex(position.getChapterIndex());
|
||||
dto.setChapterTitle(position.getChapterTitle());
|
||||
dto.setWordPosition(position.getWordPosition());
|
||||
dto.setCharacterPosition(position.getCharacterPosition());
|
||||
dto.setPercentageComplete(position.getPercentageComplete());
|
||||
dto.setEpubCfi(position.getEpubCfi());
|
||||
dto.setContextBefore(position.getContextBefore());
|
||||
dto.setContextAfter(position.getContextAfter());
|
||||
dto.setCreatedAt(position.getCreatedAt());
|
||||
dto.setUpdatedAt(position.getUpdatedAt());
|
||||
|
||||
return dto;
|
||||
}
|
||||
|
||||
public List<String> validateEPUBFile(MultipartFile file) {
|
||||
List<String> errors = new ArrayList<>();
|
||||
|
||||
if (file == null || file.isEmpty()) {
|
||||
errors.add("EPUB file is required");
|
||||
return errors;
|
||||
}
|
||||
|
||||
if (!isValidEPUBFile(file)) {
|
||||
errors.add("Invalid EPUB file format. Only .epub files are supported");
|
||||
}
|
||||
|
||||
if (file.getSize() > 100 * 1024 * 1024) { // 100MB limit
|
||||
errors.add("EPUB file size exceeds 100MB limit");
|
||||
}
|
||||
|
||||
try {
|
||||
Book book = parseEPUBFile(file);
|
||||
if (book.getMetadata() == null) {
|
||||
errors.add("EPUB file contains no metadata");
|
||||
}
|
||||
if (book.getSpine() == null || book.getSpine().isEmpty()) {
|
||||
errors.add("EPUB file contains no readable content");
|
||||
}
|
||||
} catch (Exception e) {
|
||||
errors.add("Failed to parse EPUB file: " + e.getMessage());
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
}
|
||||
@@ -20,11 +20,11 @@ import java.util.UUID;
|
||||
public class ImageService {
|
||||
|
||||
private static final Set<String> ALLOWED_CONTENT_TYPES = Set.of(
|
||||
"image/jpeg", "image/jpg", "image/png", "image/webp"
|
||||
"image/jpeg", "image/jpg", "image/png"
|
||||
);
|
||||
|
||||
private static final Set<String> ALLOWED_EXTENSIONS = Set.of(
|
||||
"jpg", "jpeg", "png", "webp"
|
||||
"jpg", "jpeg", "png"
|
||||
);
|
||||
|
||||
@Value("${storycove.images.upload-dir:/app/images}")
|
||||
|
||||
@@ -82,6 +82,7 @@ public class TypesenseService {
|
||||
new Field().name("wordCount").type("int32").facet(true).sort(true).optional(true),
|
||||
new Field().name("volume").type("int32").facet(true).sort(true).optional(true),
|
||||
new Field().name("createdAt").type("int64").facet(false).sort(true),
|
||||
new Field().name("lastReadAt").type("int64").facet(false).sort(true).optional(true),
|
||||
new Field().name("sourceUrl").type("string").facet(false).optional(true),
|
||||
new Field().name("coverPath").type("string").facet(false).optional(true)
|
||||
);
|
||||
@@ -392,6 +393,10 @@ public class TypesenseService {
|
||||
story.getCreatedAt().toEpochSecond(java.time.ZoneOffset.UTC) :
|
||||
java.time.LocalDateTime.now().toEpochSecond(java.time.ZoneOffset.UTC));
|
||||
|
||||
if (story.getLastReadAt() != null) {
|
||||
document.put("lastReadAt", story.getLastReadAt().toEpochSecond(java.time.ZoneOffset.UTC));
|
||||
}
|
||||
|
||||
if (story.getSourceUrl() != null) {
|
||||
document.put("sourceUrl", story.getSourceUrl());
|
||||
}
|
||||
@@ -517,6 +522,12 @@ public class TypesenseService {
|
||||
timestamp, 0, java.time.ZoneOffset.UTC));
|
||||
}
|
||||
|
||||
if (doc.get("lastReadAt") != null) {
|
||||
long timestamp = ((Number) doc.get("lastReadAt")).longValue();
|
||||
dto.setLastReadAt(java.time.LocalDateTime.ofEpochSecond(
|
||||
timestamp, 0, java.time.ZoneOffset.UTC));
|
||||
}
|
||||
|
||||
// Set search-specific fields - handle null for wildcard queries
|
||||
Long textMatch = hit.getTextMatch();
|
||||
dto.setSearchScore(textMatch != null ? textMatch : 0L);
|
||||
@@ -665,6 +676,11 @@ public class TypesenseService {
|
||||
case "created_at":
|
||||
case "date":
|
||||
return "createdAt";
|
||||
case "lastread":
|
||||
case "last_read":
|
||||
case "lastreadat":
|
||||
case "last_read_at":
|
||||
return "lastReadAt";
|
||||
case "rating":
|
||||
return "rating";
|
||||
case "wordcount":
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
package com.storycove.service.exception;
|
||||
|
||||
public class InvalidFileException extends RuntimeException {
|
||||
|
||||
public InvalidFileException(String message) {
|
||||
super(message);
|
||||
}
|
||||
|
||||
public InvalidFileException(String message, Throwable cause) {
|
||||
super(message, cause);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user