embedded image finishing

This commit is contained in:
Stefan Hardegger
2025-09-17 10:28:35 +02:00
parent e5596b5a17
commit c0b3ae3b72
8 changed files with 740 additions and 20 deletions

View File

@@ -2,6 +2,7 @@ package com.storycove.controller;
import com.storycove.dto.HtmlSanitizationConfigDto;
import com.storycove.service.HtmlSanitizationService;
import com.storycove.service.ImageService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.ResponseEntity;
@@ -14,13 +15,15 @@ import java.util.Map;
public class ConfigController {
private final HtmlSanitizationService htmlSanitizationService;
private final ImageService imageService;
@Value("${app.reading.speed.default:200}")
private int defaultReadingSpeed;
@Autowired
public ConfigController(HtmlSanitizationService htmlSanitizationService) {
public ConfigController(HtmlSanitizationService htmlSanitizationService, ImageService imageService) {
this.htmlSanitizationService = htmlSanitizationService;
this.imageService = imageService;
}
/**
@@ -51,4 +54,64 @@ public class ConfigController {
public ResponseEntity<Map<String, Integer>> getReadingSpeed() {
return ResponseEntity.ok(Map.of("wordsPerMinute", defaultReadingSpeed));
}
/**
* Preview orphaned content images cleanup (dry run)
*/
@PostMapping("/cleanup/images/preview")
public ResponseEntity<Map<String, Object>> previewImageCleanup() {
try {
ImageService.ContentImageCleanupResult result = imageService.cleanupOrphanedContentImages(true);
Map<String, Object> response = Map.of(
"success", true,
"orphanedCount", result.getOrphanedImages().size(),
"totalSizeBytes", result.getTotalSizeBytes(),
"formattedSize", result.getFormattedSize(),
"foldersToDelete", result.getFoldersToDelete(),
"referencedImagesCount", result.getTotalReferencedImages(),
"errors", result.getErrors(),
"hasErrors", result.hasErrors(),
"dryRun", true
);
return ResponseEntity.ok(response);
} catch (Exception e) {
return ResponseEntity.status(500).body(Map.of(
"success", false,
"error", "Failed to preview image cleanup: " + e.getMessage()
));
}
}
/**
* Execute orphaned content images cleanup
*/
@PostMapping("/cleanup/images/execute")
public ResponseEntity<Map<String, Object>> executeImageCleanup() {
try {
ImageService.ContentImageCleanupResult result = imageService.cleanupOrphanedContentImages(false);
Map<String, Object> response = Map.of(
"success", true,
"deletedCount", result.getOrphanedImages().size(),
"totalSizeBytes", result.getTotalSizeBytes(),
"formattedSize", result.getFormattedSize(),
"foldersDeleted", result.getFoldersToDelete(),
"referencedImagesCount", result.getTotalReferencedImages(),
"errors", result.getErrors(),
"hasErrors", result.hasErrors(),
"dryRun", false
);
return ResponseEntity.ok(response);
} catch (Exception e) {
return ResponseEntity.status(500).body(Map.of(
"success", false,
"error", "Failed to execute image cleanup: " + e.getMessage()
));
}
}
}

View File

@@ -73,7 +73,35 @@ public class EPUBImportService {
Story story = createStoryFromEPUB(book, request);
Story savedStory = storyService.create(story);
// Process embedded images if content contains any
String originalContent = story.getContentHtml();
if (originalContent != null && originalContent.contains("<img")) {
try {
ImageService.ContentImageProcessingResult imageResult =
imageService.processContentImages(originalContent, savedStory.getId());
// Update story content with processed images if changed
if (!imageResult.getProcessedContent().equals(originalContent)) {
savedStory.setContentHtml(imageResult.getProcessedContent());
savedStory = storyService.update(savedStory.getId(), savedStory);
// Log the image processing results
System.out.println("EPUB Import - Image processing completed for story " + savedStory.getId() +
". Downloaded " + imageResult.getDownloadedImages().size() + " images.");
if (imageResult.hasWarnings()) {
System.out.println("EPUB Import - Image processing warnings: " +
String.join(", ", imageResult.getWarnings()));
}
}
} catch (Exception e) {
// Log error but don't fail the import
System.err.println("EPUB Import - Failed to process embedded images for story " +
savedStory.getId() + ": " + e.getMessage());
}
}
EPUBImportResponse response = EPUBImportResponse.success(savedStory.getId(), savedStory.getTitle());
response.setWordCount(savedStory.getWordCount());
response.setTotalChapters(book.getSpine().size());

View File

@@ -39,6 +39,9 @@ public class ImageService {
@Autowired
private LibraryService libraryService;
@Autowired
private StoryService storyService;
private String getUploadDir() {
String libraryPath = libraryService.getCurrentImagePath();
@@ -421,6 +424,249 @@ public class ImageService {
return null;
}
/**
* Cleanup orphaned content images that are no longer referenced in any story
*/
public ContentImageCleanupResult cleanupOrphanedContentImages(boolean dryRun) {
logger.info("Starting orphaned content image cleanup (dryRun: {})", dryRun);
final Set<String> referencedImages;
List<String> orphanedImages = new ArrayList<>();
List<String> errors = new ArrayList<>();
long totalSizeBytes = 0;
int foldersToDelete = 0;
// Step 1: Collect all image references from all story content
logger.info("Scanning all story content for image references...");
referencedImages = collectAllImageReferences();
logger.info("Found {} unique image references in story content", referencedImages.size());
try {
// Step 2: Scan the content images directory
Path contentImagesDir = Paths.get(getUploadDir(), ImageType.CONTENT.getDirectory());
if (!Files.exists(contentImagesDir)) {
logger.info("Content images directory does not exist: {}", contentImagesDir);
return new ContentImageCleanupResult(orphanedImages, 0, 0, referencedImages.size(), errors, dryRun);
}
logger.info("Scanning content images directory: {}", contentImagesDir);
// Walk through all story directories
Files.walk(contentImagesDir, 2)
.filter(Files::isDirectory)
.filter(path -> !path.equals(contentImagesDir)) // Skip the root content directory
.forEach(storyDir -> {
try {
String storyId = storyDir.getFileName().toString();
logger.debug("Checking story directory: {}", storyId);
// Check if this story still exists
boolean storyExists = storyService.findByIdOptional(UUID.fromString(storyId)).isPresent();
if (!storyExists) {
logger.info("Found orphaned story directory (story deleted): {}", storyId);
// Mark entire directory for deletion
try {
Files.walk(storyDir)
.filter(Files::isRegularFile)
.forEach(file -> {
try {
long size = Files.size(file);
orphanedImages.add(file.toString());
// Add to total size (will be updated in main scope)
} catch (IOException e) {
errors.add("Failed to get size for " + file + ": " + e.getMessage());
}
});
} catch (IOException e) {
errors.add("Failed to scan orphaned story directory " + storyDir + ": " + e.getMessage());
}
return;
}
// Check individual files in the story directory
try {
Files.walk(storyDir)
.filter(Files::isRegularFile)
.forEach(imageFile -> {
try {
String imagePath = getRelativeImagePath(imageFile);
if (!referencedImages.contains(imagePath)) {
logger.debug("Found orphaned image: {}", imagePath);
orphanedImages.add(imageFile.toString());
}
} catch (Exception e) {
errors.add("Error checking image file " + imageFile + ": " + e.getMessage());
}
});
} catch (IOException e) {
errors.add("Failed to scan story directory " + storyDir + ": " + e.getMessage());
}
} catch (Exception e) {
errors.add("Error processing story directory " + storyDir + ": " + e.getMessage());
}
});
// Calculate total size and count empty directories
for (String orphanedImage : orphanedImages) {
try {
Path imagePath = Paths.get(orphanedImage);
if (Files.exists(imagePath)) {
totalSizeBytes += Files.size(imagePath);
}
} catch (IOException e) {
errors.add("Failed to get size for " + orphanedImage + ": " + e.getMessage());
}
}
// Count empty directories that would be removed
try {
foldersToDelete = (int) Files.walk(contentImagesDir)
.filter(Files::isDirectory)
.filter(path -> !path.equals(contentImagesDir))
.filter(this::isDirectoryEmptyOrWillBeEmpty)
.count();
} catch (IOException e) {
errors.add("Failed to count empty directories: " + e.getMessage());
}
// Step 3: Delete orphaned files if not dry run
if (!dryRun && !orphanedImages.isEmpty()) {
logger.info("Deleting {} orphaned images...", orphanedImages.size());
Set<Path> directoriesToCheck = new HashSet<>();
for (String orphanedImage : orphanedImages) {
try {
Path imagePath = Paths.get(orphanedImage);
if (Files.exists(imagePath)) {
directoriesToCheck.add(imagePath.getParent());
Files.delete(imagePath);
logger.debug("Deleted orphaned image: {}", imagePath);
}
} catch (IOException e) {
errors.add("Failed to delete " + orphanedImage + ": " + e.getMessage());
}
}
// Clean up empty directories
for (Path dir : directoriesToCheck) {
try {
if (Files.exists(dir) && isDirEmpty(dir)) {
Files.delete(dir);
logger.info("Deleted empty story directory: {}", dir);
}
} catch (IOException e) {
errors.add("Failed to delete empty directory " + dir + ": " + e.getMessage());
}
}
}
logger.info("Orphaned content image cleanup completed. Found {} orphaned files ({} bytes)",
orphanedImages.size(), totalSizeBytes);
} catch (Exception e) {
logger.error("Error during orphaned content image cleanup", e);
errors.add("General cleanup error: " + e.getMessage());
}
return new ContentImageCleanupResult(orphanedImages, totalSizeBytes, foldersToDelete, referencedImages.size(), errors, dryRun);
}
/**
* Collect all image references from all story content
*/
private Set<String> collectAllImageReferences() {
Set<String> referencedImages = new HashSet<>();
try {
// Get all stories
List<com.storycove.entity.Story> allStories = storyService.findAllWithAssociations();
// Pattern to match local image URLs in content
Pattern imagePattern = Pattern.compile("src=[\"']([^\"']*(?:content/[^\"']*\\.(jpg|jpeg|png)))[\"']", Pattern.CASE_INSENSITIVE);
for (com.storycove.entity.Story story : allStories) {
if (story.getContentHtml() != null) {
Matcher matcher = imagePattern.matcher(story.getContentHtml());
while (matcher.find()) {
String imageSrc = matcher.group(1);
// Convert to relative path format that matches our file system
String relativePath = convertSrcToRelativePath(imageSrc);
if (relativePath != null) {
referencedImages.add(relativePath);
logger.debug("Found image reference in story {}: {}", story.getId(), relativePath);
}
}
}
}
} catch (Exception e) {
logger.error("Error collecting image references from stories", e);
}
return referencedImages;
}
/**
* Convert an image src attribute to relative file path
*/
private String convertSrcToRelativePath(String src) {
try {
// Handle both /api/files/images/libraryId/content/... and relative content/... paths
if (src.contains("/content/")) {
int contentIndex = src.indexOf("/content/");
return src.substring(contentIndex + 1); // Remove leading slash, keep "content/..."
}
} catch (Exception e) {
logger.debug("Failed to convert src to relative path: {}", src);
}
return null;
}
/**
* Get relative image path from absolute file path
*/
private String getRelativeImagePath(Path imageFile) {
try {
Path uploadDir = Paths.get(getUploadDir());
Path relativePath = uploadDir.relativize(imageFile);
return relativePath.toString().replace('\\', '/'); // Normalize path separators
} catch (Exception e) {
logger.debug("Failed to get relative path for: {}", imageFile);
return imageFile.toString();
}
}
/**
* Check if directory is empty or will be empty after cleanup
*/
private boolean isDirectoryEmptyOrWillBeEmpty(Path dir) {
try {
return Files.walk(dir)
.filter(Files::isRegularFile)
.count() == 0;
} catch (IOException e) {
return false;
}
}
/**
* Check if directory is empty
*/
private boolean isDirEmpty(Path dir) {
try {
return Files.list(dir).count() == 0;
} catch (IOException e) {
return false;
}
}
/**
* Clean up content images for a story
*/
@@ -458,4 +704,41 @@ public class ImageService {
public List<String> getDownloadedImages() { return downloadedImages; }
public boolean hasWarnings() { return !warnings.isEmpty(); }
}
/**
* Result class for orphaned image cleanup
*/
public static class ContentImageCleanupResult {
private final List<String> orphanedImages;
private final long totalSizeBytes;
private final int foldersToDelete;
private final int totalReferencedImages;
private final List<String> errors;
private final boolean dryRun;
public ContentImageCleanupResult(List<String> orphanedImages, long totalSizeBytes, int foldersToDelete,
int totalReferencedImages, List<String> errors, boolean dryRun) {
this.orphanedImages = orphanedImages;
this.totalSizeBytes = totalSizeBytes;
this.foldersToDelete = foldersToDelete;
this.totalReferencedImages = totalReferencedImages;
this.errors = errors;
this.dryRun = dryRun;
}
public List<String> getOrphanedImages() { return orphanedImages; }
public long getTotalSizeBytes() { return totalSizeBytes; }
public int getFoldersToDelete() { return foldersToDelete; }
public int getTotalReferencedImages() { return totalReferencedImages; }
public List<String> getErrors() { return errors; }
public boolean isDryRun() { return dryRun; }
public boolean hasErrors() { return !errors.isEmpty(); }
public String getFormattedSize() {
if (totalSizeBytes < 1024) return totalSizeBytes + " B";
if (totalSizeBytes < 1024 * 1024) return String.format("%.1f KB", totalSizeBytes / 1024.0);
if (totalSizeBytes < 1024 * 1024 * 1024) return String.format("%.1f MB", totalSizeBytes / (1024.0 * 1024.0));
return String.format("%.1f GB", totalSizeBytes / (1024.0 * 1024.0 * 1024.0));
}
}
}