Image Handling in Epub Import/export

This commit is contained in:
Stefan Hardegger
2025-08-08 14:50:49 +02:00
parent 379c8c170f
commit 5b3a9d183e
7 changed files with 524 additions and 18 deletions

View File

@@ -6,6 +6,7 @@ import com.storycove.entity.CollectionStory;
import com.storycove.entity.Story;
import com.storycove.entity.Tag;
import com.storycove.service.CollectionService;
import com.storycove.service.EPUBExportService;
import com.storycove.service.ImageService;
import com.storycove.service.ReadingTimeService;
import com.storycove.service.TypesenseService;
@@ -32,16 +33,19 @@ public class CollectionController {
private final ImageService imageService;
private final TypesenseService typesenseService;
private final ReadingTimeService readingTimeService;
private final EPUBExportService epubExportService;
@Autowired
public CollectionController(CollectionService collectionService,
ImageService imageService,
@Autowired(required = false) TypesenseService typesenseService,
ReadingTimeService readingTimeService) {
ReadingTimeService readingTimeService,
EPUBExportService epubExportService) {
this.collectionService = collectionService;
this.imageService = imageService;
this.typesenseService = typesenseService;
this.readingTimeService = readingTimeService;
this.epubExportService = epubExportService;
}
/**
@@ -310,6 +314,85 @@ public class CollectionController {
}
}
/**
* GET /api/collections/{id}/epub - Export collection as EPUB
*/
@GetMapping("/{id}/epub")
public ResponseEntity<org.springframework.core.io.Resource> exportCollectionAsEPUB(@PathVariable UUID id) {
logger.info("Exporting collection {} to EPUB", id);
try {
Collection collection = collectionService.findById(id);
List<Story> stories = collection.getCollectionStories().stream()
.sorted((cs1, cs2) -> Integer.compare(cs1.getPosition(), cs2.getPosition()))
.map(cs -> cs.getStory())
.collect(java.util.stream.Collectors.toList());
if (stories.isEmpty()) {
logger.warn("Collection {} contains no stories for export", id);
return ResponseEntity.badRequest()
.body(null);
}
EPUBExportRequest request = new EPUBExportRequest();
request.setIncludeCoverImage(true);
request.setIncludeMetadata(true);
request.setIncludeReadingPosition(false); // Collections don't have reading positions
org.springframework.core.io.Resource resource = epubExportService.exportCollectionAsEPUB(id, request);
String filename = epubExportService.getCollectionEPUBFilename(collection);
logger.info("Successfully exported collection 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 collection EPUB: {}", e.getMessage(), e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
}
}
/**
* POST /api/collections/{id}/epub - Export collection as EPUB with custom options
*/
@PostMapping("/{id}/epub")
public ResponseEntity<org.springframework.core.io.Resource> exportCollectionAsEPUBWithOptions(
@PathVariable UUID id,
@Valid @RequestBody EPUBExportRequest request) {
logger.info("Exporting collection {} to EPUB with custom options", id);
try {
Collection collection = collectionService.findById(id);
List<Story> stories = collection.getCollectionStories().stream()
.sorted((cs1, cs2) -> Integer.compare(cs1.getPosition(), cs2.getPosition()))
.map(cs -> cs.getStory())
.collect(java.util.stream.Collectors.toList());
if (stories.isEmpty()) {
logger.warn("Collection {} contains no stories for export", id);
return ResponseEntity.badRequest()
.body(null);
}
org.springframework.core.io.Resource resource = epubExportService.exportCollectionAsEPUB(id, request);
String filename = epubExportService.getCollectionEPUBFilename(collection);
logger.info("Successfully exported collection EPUB with options: {}", 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 collection EPUB: {}", e.getMessage(), e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
}
}
// Mapper methods
private CollectionDto mapToCollectionDto(Collection collection) {

View File

@@ -31,6 +31,8 @@ public class EPUBImportRequest {
private Boolean createMissingSeries = true;
private Boolean extractCover = true;
public EPUBImportRequest() {}
public MultipartFile getEpubFile() {
@@ -120,4 +122,12 @@ public class EPUBImportRequest {
public void setCreateMissingSeries(Boolean createMissingSeries) {
this.createMissingSeries = createMissingSeries;
}
public Boolean getExtractCover() {
return extractCover;
}
public void setExtractCover(Boolean extractCover) {
this.extractCover = extractCover;
}
}

View File

@@ -1,6 +1,7 @@
package com.storycove.service;
import com.storycove.dto.EPUBExportRequest;
import com.storycove.entity.Collection;
import com.storycove.entity.ReadingPosition;
import com.storycove.entity.Story;
import com.storycove.repository.ReadingPositionRepository;
@@ -31,6 +32,7 @@ import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
import java.util.stream.Collectors;
@Service
@Transactional
@@ -38,12 +40,15 @@ public class EPUBExportService {
private final StoryService storyService;
private final ReadingPositionRepository readingPositionRepository;
private final CollectionService collectionService;
@Autowired
public EPUBExportService(StoryService storyService,
ReadingPositionRepository readingPositionRepository) {
ReadingPositionRepository readingPositionRepository,
CollectionService collectionService) {
this.storyService = storyService;
this.readingPositionRepository = readingPositionRepository;
this.collectionService = collectionService;
}
public Resource exportStoryAsEPUB(EPUBExportRequest request) throws IOException {
@@ -58,6 +63,26 @@ public class EPUBExportService {
return new ByteArrayResource(outputStream.toByteArray());
}
public Resource exportCollectionAsEPUB(UUID collectionId, EPUBExportRequest request) throws IOException {
Collection collection = collectionService.findById(collectionId);
List<Story> stories = collection.getCollectionStories().stream()
.sorted((cs1, cs2) -> Integer.compare(cs1.getPosition(), cs2.getPosition()))
.map(cs -> cs.getStory())
.collect(Collectors.toList());
if (stories.isEmpty()) {
throw new ResourceNotFoundException("Collection contains no stories to export");
}
Book book = createCollectionEPUBBook(collection, stories, 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();
@@ -72,6 +97,18 @@ public class EPUBExportService {
return book;
}
private Book createCollectionEPUBBook(Collection collection, List<Story> stories, EPUBExportRequest request) throws IOException {
Book book = new Book();
setupCollectionMetadata(book, collection, stories, request);
addCollectionCoverImage(book, collection, request);
addCollectionContent(book, stories, request);
return book;
}
private void setupMetadata(Book book, Story story, EPUBExportRequest request) {
Metadata metadata = book.getMetadata();
@@ -375,6 +412,161 @@ public class EPUBExportService {
.replaceAll("\\s+", "_");
}
private void setupCollectionMetadata(Book book, Collection collection, List<Story> stories, EPUBExportRequest request) {
Metadata metadata = book.getMetadata();
String title = request.getCustomTitle() != null ?
request.getCustomTitle() : collection.getName();
metadata.addTitle(title);
// Use collection creator as author, or combine story authors
String authorName = "Collection";
if (stories.size() == 1) {
Story story = stories.get(0);
authorName = story.getAuthor() != null ? story.getAuthor().getName() : "Unknown Author";
} else {
// For multiple stories, use "Various Authors" or collection name
authorName = "Various Authors";
}
if (request.getCustomAuthor() != null) {
authorName = request.getCustomAuthor();
}
metadata.addAuthor(new Author(authorName));
metadata.setLanguage(request.getLanguage() != null ? request.getLanguage() : "en");
metadata.addIdentifier(new Identifier("storycove-collection", collection.getId().toString()));
// Create description from collection description and story list
StringBuilder description = new StringBuilder();
if (collection.getDescription() != null && !collection.getDescription().trim().isEmpty()) {
description.append(collection.getDescription()).append("\n\n");
}
description.append("This collection contains ").append(stories.size()).append(" stories:\n");
for (int i = 0; i < stories.size() && i < 10; i++) {
Story story = stories.get(i);
description.append((i + 1)).append(". ").append(story.getTitle());
if (story.getAuthor() != null) {
description.append(" by ").append(story.getAuthor().getName());
}
description.append("\n");
}
if (stories.size() > 10) {
description.append("... and ").append(stories.size() - 10).append(" more stories.");
}
metadata.addDescription(description.toString());
if (request.getIncludeMetadata()) {
metadata.addDate(new Date(java.util.Date.from(
collection.getCreatedAt().atZone(java.time.ZoneId.systemDefault()).toInstant()
), Date.Event.CREATION));
// Add collection statistics to description
int totalWordCount = stories.stream().mapToInt(s -> s.getWordCount() != null ? s.getWordCount() : 0).sum();
description.append("\n\nTotal Word Count: ").append(totalWordCount);
description.append("\nGenerated by StoryCove on ")
.append(LocalDateTime.now().format(DateTimeFormatter.ISO_LOCAL_DATE_TIME));
metadata.addDescription(description.toString());
}
}
private void addCollectionCoverImage(Book book, Collection collection, EPUBExportRequest request) {
if (!request.getIncludeCoverImage()) {
return;
}
try {
// Try to use collection cover first
if (collection.getCoverImagePath() != null) {
Path coverPath = Paths.get(collection.getCoverImagePath());
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, "collection-cover.jpg");
book.setCoverImage(coverResource);
return;
}
}
// TODO: Could generate a composite cover from story covers
// For now, skip cover if collection doesn't have one
} catch (IOException e) {
// Skip cover image on error
}
}
private void addCollectionContent(Book book, List<Story> stories, EPUBExportRequest request) {
// Create table of contents chapter
StringBuilder tocContent = new StringBuilder();
tocContent.append("<h1>Table of Contents</h1>\n<ul>\n");
for (int i = 0; i < stories.size(); i++) {
Story story = stories.get(i);
tocContent.append("<li><a href=\"#story").append(i + 1).append("\">")
.append(escapeHtml(story.getTitle()));
if (story.getAuthor() != null) {
tocContent.append(" by ").append(escapeHtml(story.getAuthor().getName()));
}
tocContent.append("</a></li>\n");
}
tocContent.append("</ul>\n");
String tocHtml = createChapterHTML("Table of Contents", tocContent.toString());
nl.siegmann.epublib.domain.Resource tocResource =
new nl.siegmann.epublib.domain.Resource(tocHtml.getBytes(), "toc.html");
book.addSection("Table of Contents", tocResource);
// Add each story as a chapter
for (int i = 0; i < stories.size(); i++) {
Story story = stories.get(i);
String storyContent = story.getContentHtml();
if (storyContent == null) {
storyContent = story.getContentPlain() != null ?
"<p>" + story.getContentPlain().replace("\n", "</p><p>") + "</p>" :
"<p>No content available</p>";
}
// Add story metadata header
StringBuilder storyHtml = new StringBuilder();
storyHtml.append("<div id=\"story").append(i + 1).append("\">\n");
storyHtml.append("<h1>").append(escapeHtml(story.getTitle())).append("</h1>\n");
if (story.getAuthor() != null) {
storyHtml.append("<p><em>by ").append(escapeHtml(story.getAuthor().getName())).append("</em></p>\n");
}
if (story.getDescription() != null && !story.getDescription().trim().isEmpty()) {
storyHtml.append("<div class=\"summary\">\n")
.append("<p>").append(escapeHtml(story.getDescription())).append("</p>\n")
.append("</div>\n");
}
storyHtml.append("<hr />\n");
storyHtml.append(fixHtmlForXhtml(storyContent));
storyHtml.append("</div>\n");
String chapterTitle = story.getTitle();
if (story.getAuthor() != null) {
chapterTitle += " by " + story.getAuthor().getName();
}
String html = createChapterHTML(chapterTitle, storyHtml.toString());
nl.siegmann.epublib.domain.Resource storyResource =
new nl.siegmann.epublib.domain.Resource(html.getBytes(), "story" + (i + 1) + ".html");
book.addSection(chapterTitle, storyResource);
}
}
public boolean canExportStory(UUID storyId) {
try {
Story story = storyService.findById(storyId);
@@ -383,4 +575,11 @@ public class EPUBExportService {
return false;
}
}
public String getCollectionEPUBFilename(Collection collection) {
StringBuilder filename = new StringBuilder();
filename.append(sanitizeFilename(collection.getName()));
filename.append("_collection.epub");
return filename.toString();
}
}

View File

@@ -39,6 +39,7 @@ public class EPUBImportService {
private final TagService tagService;
private final ReadingPositionRepository readingPositionRepository;
private final HtmlSanitizationService sanitizationService;
private final ImageService imageService;
@Autowired
public EPUBImportService(StoryService storyService,
@@ -46,13 +47,15 @@ public class EPUBImportService {
SeriesService seriesService,
TagService tagService,
ReadingPositionRepository readingPositionRepository,
HtmlSanitizationService sanitizationService) {
HtmlSanitizationService sanitizationService,
ImageService imageService) {
this.storyService = storyService;
this.authorService = authorService;
this.seriesService = seriesService;
this.tagService = tagService;
this.readingPositionRepository = readingPositionRepository;
this.sanitizationService = sanitizationService;
this.imageService = imageService;
}
public EPUBImportResponse importEPUB(EPUBImportRequest request) {
@@ -126,6 +129,14 @@ public class EPUBImportService {
story.setDescription(description);
story.setContentHtml(sanitizationService.sanitize(content));
// Extract and process cover image
if (request.getExtractCover() == null || request.getExtractCover()) {
String coverPath = extractAndSaveCoverImage(book);
if (coverPath != null) {
story.setCoverPath(coverPath);
}
}
if (request.getAuthorId() != null) {
try {
Author author = authorService.findById(request.getAuthorId());
@@ -155,13 +166,29 @@ public class EPUBImportService {
}
}
// Handle tags from request or extract from EPUB metadata
List<String> allTags = new ArrayList<>();
if (request.getTags() != null && !request.getTags().isEmpty()) {
for (String tagName : request.getTags()) {
Tag tag = tagService.findOrCreate(tagName);
story.addTag(tag);
}
allTags.addAll(request.getTags());
}
// Extract subjects/keywords from EPUB metadata
List<String> epubTags = extractTags(metadata);
if (epubTags != null && !epubTags.isEmpty()) {
allTags.addAll(epubTags);
}
// Remove duplicates and create tags
allTags.stream()
.distinct()
.forEach(tagName -> {
Tag tag = tagService.findOrCreate(tagName.trim());
story.addTag(tag);
});
// Extract additional metadata for potential future use
extractAdditionalMetadata(metadata, story);
return story;
}
@@ -193,6 +220,70 @@ public class EPUBImportService {
return null;
}
private List<String> extractTags(Metadata metadata) {
List<String> tags = new ArrayList<>();
// Extract subjects (main source of tags in EPUB)
List<String> subjects = metadata.getSubjects();
if (subjects != null && !subjects.isEmpty()) {
tags.addAll(subjects);
}
// Extract keywords from meta tags
String keywords = metadata.getMetaAttribute("keywords");
if (keywords != null && !keywords.trim().isEmpty()) {
String[] keywordArray = keywords.split("[,;]");
for (String keyword : keywordArray) {
String trimmed = keyword.trim();
if (!trimmed.isEmpty()) {
tags.add(trimmed);
}
}
}
// Extract genre information
String genre = metadata.getMetaAttribute("genre");
if (genre != null && !genre.trim().isEmpty()) {
tags.add(genre.trim());
}
return tags;
}
private void extractAdditionalMetadata(Metadata metadata, Story story) {
// Extract language (could be useful for future i18n)
String language = metadata.getLanguage();
if (language != null && !language.trim().isEmpty()) {
// Store as metadata in story description if needed
// For now, we'll just log it for potential future use
System.out.println("EPUB Language: " + language);
}
// Extract publisher information
List<String> publishers = metadata.getPublishers();
if (publishers != null && !publishers.isEmpty()) {
String publisher = publishers.get(0);
// Could append to description or store separately in future
System.out.println("EPUB Publisher: " + publisher);
}
// Extract publication date
List<nl.siegmann.epublib.domain.Date> dates = metadata.getDates();
if (dates != null && !dates.isEmpty()) {
for (nl.siegmann.epublib.domain.Date date : dates) {
System.out.println("EPUB Date (" + date.getEvent() + "): " + date.getValue());
}
}
// Extract ISBN or other identifiers
List<nl.siegmann.epublib.domain.Identifier> identifiers = metadata.getIdentifiers();
if (identifiers != null && !identifiers.isEmpty()) {
for (nl.siegmann.epublib.domain.Identifier identifier : identifiers) {
System.out.println("EPUB Identifier (" + identifier.getScheme() + "): " + identifier.getValue());
}
}
}
private String extractContent(Book book) {
StringBuilder contentBuilder = new StringBuilder();
@@ -274,6 +365,48 @@ public class EPUBImportService {
}
}
private String extractAndSaveCoverImage(Book book) {
try {
Resource coverResource = book.getCoverImage();
if (coverResource != null && coverResource.getData() != null) {
// Create a temporary MultipartFile from the EPUB cover data
byte[] imageData = coverResource.getData();
String mediaType = coverResource.getMediaType() != null ?
coverResource.getMediaType().toString() : "image/jpeg";
// Determine file extension from media type
String extension = getExtensionFromMediaType(mediaType);
String filename = "epub_cover_" + System.currentTimeMillis() + "." + extension;
// Create a custom MultipartFile implementation for the cover image
MultipartFile coverFile = new EPUBCoverMultipartFile(imageData, filename, mediaType);
// Use ImageService to process and save the cover
return imageService.uploadImage(coverFile, ImageService.ImageType.COVER);
}
} catch (Exception e) {
// Log error but don't fail the import
System.err.println("Failed to extract cover image: " + e.getMessage());
}
return null;
}
private String getExtensionFromMediaType(String mediaType) {
switch (mediaType.toLowerCase()) {
case "image/jpeg":
case "image/jpg":
return "jpg";
case "image/png":
return "png";
case "image/gif":
return "gif";
case "image/webp":
return "webp";
default:
return "jpg"; // Default fallback
}
}
private ReadingPositionDto convertToDto(ReadingPosition position) {
if (position == null) return null;
@@ -324,4 +457,66 @@ public class EPUBImportService {
return errors;
}
/**
* Custom MultipartFile implementation for EPUB cover images
*/
private static class EPUBCoverMultipartFile implements MultipartFile {
private final byte[] data;
private final String filename;
private final String contentType;
public EPUBCoverMultipartFile(byte[] data, String filename, String contentType) {
this.data = data;
this.filename = filename;
this.contentType = contentType;
}
@Override
public String getName() {
return "coverImage";
}
@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 java.io.ByteArrayInputStream(data);
}
@Override
public void transferTo(java.io.File dest) throws IOException {
try (java.io.FileOutputStream fos = new java.io.FileOutputStream(dest)) {
fos.write(data);
}
}
@Override
public void transferTo(java.nio.file.Path dest) throws IOException {
java.nio.file.Files.write(dest, data);
}
}
}

View File

@@ -4,6 +4,7 @@ import com.storycove.entity.Author;
import com.storycove.entity.Series;
import com.storycove.entity.Story;
import com.storycove.entity.Tag;
import com.storycove.repository.ReadingPositionRepository;
import com.storycove.repository.StoryRepository;
import com.storycove.repository.TagRepository;
import com.storycove.service.exception.DuplicateResourceException;
@@ -31,6 +32,7 @@ public class StoryService {
private final StoryRepository storyRepository;
private final TagRepository tagRepository;
private final ReadingPositionRepository readingPositionRepository;
private final AuthorService authorService;
private final TagService tagService;
private final SeriesService seriesService;
@@ -40,6 +42,7 @@ public class StoryService {
@Autowired
public StoryService(StoryRepository storyRepository,
TagRepository tagRepository,
ReadingPositionRepository readingPositionRepository,
AuthorService authorService,
TagService tagService,
SeriesService seriesService,
@@ -47,6 +50,7 @@ public class StoryService {
@Autowired(required = false) TypesenseService typesenseService) {
this.storyRepository = storyRepository;
this.tagRepository = tagRepository;
this.readingPositionRepository = readingPositionRepository;
this.authorService = authorService;
this.tagService = tagService;
this.seriesService = seriesService;
@@ -432,6 +436,9 @@ public class StoryService {
public void delete(UUID id) {
Story story = findById(id);
// Clean up reading positions first (to avoid foreign key constraint violations)
readingPositionRepository.deleteByStoryId(id);
// Remove from series if part of one
if (story.getSeries() != null) {
story.getSeries().removeStory(story);