support webp, some fixes.

This commit is contained in:
Stefan Hardegger
2026-06-08 09:19:42 +02:00
parent ca20e54115
commit f4908637b3
7 changed files with 760 additions and 22 deletions

View File

@@ -159,6 +159,68 @@ public class ConfigController {
} }
} }
/**
* Preview migration of flat EPUB content images into story subfolders (dry run)
*/
@PostMapping("/migrate/epub-images/preview")
public ResponseEntity<Map<String, Object>> previewEpubImageMigration() {
try {
logger.info("Starting EPUB image migration preview");
ImageService.EpubImageMigrationResult result = imageService.migrateEpubContentImages(true);
Map<String, Object> response = new HashMap<>();
response.put("success", true);
response.put("movedCount", result.getMovedCount());
response.put("unmatchedCount", result.getUnmatchedCount());
response.put("errors", result.getErrors());
response.put("hasErrors", result.hasErrors());
response.put("dryRun", true);
response.put("movedFiles", result.getMovedFiles());
logger.info("EPUB image migration preview completed: {} to move, {} unmatched",
result.getMovedCount(), result.getUnmatchedCount());
return ResponseEntity.ok(response);
} catch (Exception e) {
logger.error("Failed to preview EPUB image migration", e);
Map<String, Object> errorResponse = new HashMap<>();
errorResponse.put("success", false);
errorResponse.put("error", "Failed to preview migration: " + (e.getMessage() != null ? e.getMessage() : e.getClass().getSimpleName()));
return ResponseEntity.status(500).body(errorResponse);
}
}
/**
* Execute migration of flat EPUB content images into story subfolders
*/
@PostMapping("/migrate/epub-images/execute")
public ResponseEntity<Map<String, Object>> executeEpubImageMigration() {
try {
logger.info("Starting EPUB image migration execution");
ImageService.EpubImageMigrationResult result = imageService.migrateEpubContentImages(false);
Map<String, Object> response = new HashMap<>();
response.put("success", true);
response.put("movedCount", result.getMovedCount());
response.put("unmatchedCount", result.getUnmatchedCount());
response.put("errors", result.getErrors());
response.put("hasErrors", result.hasErrors());
response.put("dryRun", false);
response.put("movedFiles", result.getMovedFiles());
logger.info("EPUB image migration completed: {} moved, {} unmatched, {} errors",
result.getMovedCount(), result.getUnmatchedCount(), result.getErrors().size());
return ResponseEntity.ok(response);
} catch (Exception e) {
logger.error("Failed to execute EPUB image migration", e);
Map<String, Object> errorResponse = new HashMap<>();
errorResponse.put("success", false);
errorResponse.put("error", "Failed to execute migration: " + (e.getMessage() != null ? e.getMessage() : e.getClass().getSimpleName()));
return ResponseEntity.status(500).body(errorResponse);
}
}
/** /**
* Create detailed file information for orphaned image including story relationship * Create detailed file information for orphaned image including story relationship
*/ */

View File

@@ -536,7 +536,7 @@ public class EPUBImportService {
(int) (Math.random() * 100000) + "." + extension; (int) (Math.random() * 100000) + "." + extension;
MultipartFile imageFile = new EPUBCoverMultipartFile(imageData, filename, mediaType); MultipartFile imageFile = new EPUBCoverMultipartFile(imageData, filename, mediaType);
String imagePath = imageService.uploadImage(imageFile, ImageService.ImageType.CONTENT); String imagePath = imageService.uploadContentImage(imageFile, storyId);
String imageUrl = "/api/files/images/" + currentLibraryId + "/" + imagePath; String imageUrl = "/api/files/images/" + currentLibraryId + "/" + imagePath;
img.attr("src", imageUrl); img.attr("src", imageUrl);

View File

@@ -18,6 +18,7 @@ import java.net.URL;
import java.nio.file.Files; import java.nio.file.Files;
import java.nio.file.Path; import java.nio.file.Path;
import java.nio.file.Paths; import java.nio.file.Paths;
import java.nio.file.StandardCopyOption;
import java.util.*; import java.util.*;
import java.util.List; import java.util.List;
import java.util.regex.Matcher; import java.util.regex.Matcher;
@@ -31,11 +32,11 @@ public class ImageService {
private static final Logger logger = LoggerFactory.getLogger(ImageService.class); private static final Logger logger = LoggerFactory.getLogger(ImageService.class);
private static final Set<String> ALLOWED_CONTENT_TYPES = Set.of( private static final Set<String> ALLOWED_CONTENT_TYPES = Set.of(
"image/jpeg", "image/jpg", "image/png" "image/jpeg", "image/jpg", "image/png", "image/webp"
); );
private static final Set<String> ALLOWED_EXTENSIONS = Set.of( private static final Set<String> ALLOWED_EXTENSIONS = Set.of(
"jpg", "jpeg", "png" "jpg", "jpeg", "png", "webp"
); );
@Value("${storycove.images.upload-dir:/app/images}") @Value("${storycove.images.upload-dir:/app/images}")
@@ -102,13 +103,16 @@ public class ImageService {
String filename = UUID.randomUUID().toString() + "." + extension; String filename = UUID.randomUUID().toString() + "." + extension;
Path filePath = typeDir.resolve(filename); Path filePath = typeDir.resolve(filename);
// Process and resize image // Process and save image
BufferedImage processedImage = processImage(file, imageType); if (extension.equals("webp")) {
// ImageIO does not support WebP natively; save verbatim without resize
// Save processed image Files.write(filePath, file.getBytes());
ByteArrayOutputStream baos = new ByteArrayOutputStream(); } else {
ImageIO.write(processedImage, extension.equals("jpg") ? "jpeg" : extension, baos); BufferedImage processedImage = processImage(file, imageType);
Files.write(filePath, baos.toByteArray()); ByteArrayOutputStream baos = new ByteArrayOutputStream();
ImageIO.write(processedImage, extension.equals("jpg") ? "jpeg" : extension, baos);
Files.write(filePath, baos.toByteArray());
}
// Return relative path for database storage // Return relative path for database storage
return imageType.getDirectory() + "/" + filename; return imageType.getDirectory() + "/" + filename;
@@ -255,6 +259,27 @@ public class ImageService {
return ALLOWED_EXTENSIONS.contains(extension); return ALLOWED_EXTENSIONS.contains(extension);
} }
/**
* Upload a content image for a specific story, storing it in the story's subfolder.
* Use this instead of uploadImage(file, ImageType.CONTENT) for story content images.
*/
public String uploadContentImage(MultipartFile file, UUID storyId) throws IOException {
validateFile(file);
Path contentDir = Paths.get(getUploadDir(), ImageType.CONTENT.getDirectory(), storyId.toString());
Files.createDirectories(contentDir);
String originalFilename = file.getOriginalFilename();
String extension = getFileExtension(originalFilename);
String filename = UUID.randomUUID().toString() + "." + extension;
Path filePath = contentDir.resolve(filename);
// Content images are stored verbatim — no resizing needed, preserves all formats including WebP
Files.write(filePath, file.getBytes());
return ImageType.CONTENT.getDirectory() + "/" + storyId.toString() + "/" + filename;
}
// Content image processing methods // Content image processing methods
/** /**
@@ -562,6 +587,8 @@ public class ImageService {
return "jpg"; return "jpg";
case "image/png": case "image/png":
return "png"; return "png";
case "image/webp":
return "webp";
default: default:
return null; return null;
} }
@@ -753,7 +780,7 @@ public class ImageService {
List<com.storycove.entity.Story> allStories = storyService.findAllWithAssociations(); List<com.storycove.entity.Story> allStories = storyService.findAllWithAssociations();
// Pattern to match local image URLs in content // Pattern to match local image URLs in content
Pattern imagePattern = Pattern.compile("src=[\"']([^\"']*(?:content/[^\"']*\\.(jpg|jpeg|png)))[\"']", Pattern.CASE_INSENSITIVE); Pattern imagePattern = Pattern.compile("src=[\"']([^\"']*(?:content/[^\"']*\\.(jpg|jpeg|png|webp)))[\"']", Pattern.CASE_INSENSITIVE);
for (com.storycove.entity.Story story : allStories) { for (com.storycove.entity.Story story : allStories) {
// Add story cover image filename if present // Add story cover image filename if present
@@ -878,7 +905,7 @@ public class ImageService {
// Fallback: just use the filename portion if it's in the right structure // Fallback: just use the filename portion if it's in the right structure
String fileName = absPath.getFileName().toString(); String fileName = absPath.getFileName().toString();
if (fileName.matches(".*\\.(jpg|jpeg|png)$")) { if (fileName.matches(".*\\.(jpg|jpeg|png|webp)$")) {
// Try to preserve directory structure if it looks like covers/ or avatars/ // Try to preserve directory structure if it looks like covers/ or avatars/
Path parent = absPath.getParent(); Path parent = absPath.getParent();
if (parent != null) { if (parent != null) {
@@ -952,6 +979,145 @@ public class ImageService {
} }
} }
/**
* Migrate EPUB content images that were incorrectly stored flat in content/
* instead of content/{storyId}/.
*/
public EpubImageMigrationResult migrateEpubContentImages(boolean dryRun) {
logger.info("Starting EPUB content image migration (dryRun: {})", dryRun);
List<Map<String, Object>> movedFiles = new ArrayList<>();
List<String> errors = new ArrayList<>();
int movedCount = 0;
int unmatchedCount = 0;
try {
Path contentDir = Paths.get(getUploadDir(), ImageType.CONTENT.getDirectory());
if (!Files.exists(contentDir)) {
logger.debug("Content images directory does not exist: {}", contentDir);
return new EpubImageMigrationResult(movedFiles, movedCount, unmatchedCount, errors, dryRun);
}
// Collect flat image files (depth 1 only — files directly in content/, not in subdirs)
List<Path> flatFiles;
try (var stream = Files.list(contentDir)) {
flatFiles = stream
.filter(Files::isRegularFile)
.filter(p -> !isSynologySystemPath(p))
.filter(this::isValidImageFile)
.toList();
}
if (flatFiles.isEmpty()) {
logger.info("No flat EPUB images found to migrate");
return new EpubImageMigrationResult(movedFiles, movedCount, unmatchedCount, errors, dryRun);
}
logger.info("Found {} flat content images to process", flatFiles.size());
// Load all stories once
List<com.storycove.entity.Story> allStories = storyService.findAllWithAssociations();
for (Path flatFile : flatFiles) {
try {
String filename = flatFile.getFileName().toString();
logger.debug("Processing flat image: {}", filename);
// Find stories that reference this file by filename in their content HTML
List<com.storycove.entity.Story> referencingStories = allStories.stream()
.filter(s -> s.getContentHtml() != null && s.getContentHtml().contains("content/" + filename))
.toList();
long fileSize = Files.exists(flatFile) ? Files.size(flatFile) : 0;
if (referencingStories.isEmpty()) {
logger.debug("No story references flat image: {}", filename);
unmatchedCount++;
Map<String, Object> entry = new HashMap<>();
entry.put("fileName", filename);
entry.put("filePath", flatFile.toString());
entry.put("fileSize", fileSize);
entry.put("formattedSize", formatFileSize(fileSize));
entry.put("storyId", null);
entry.put("storyTitle", null);
entry.put("action", "unmatched");
movedFiles.add(entry);
continue;
}
// Move/copy to each referencing story's subdirectory
boolean firstStory = true;
for (com.storycove.entity.Story story : referencingStories) {
String storyId = story.getId().toString();
Path targetDir = contentDir.resolve(storyId);
Path targetFile = targetDir.resolve(filename);
// Build the new URL for this story's library
String currentLibraryId = libraryService.getCurrentLibraryId();
String oldUrl = "/api/files/images/" + currentLibraryId + "/content/" + filename;
String newUrl = "/api/files/images/" + currentLibraryId + "/content/" + storyId + "/" + filename;
Map<String, Object> entry = new HashMap<>();
entry.put("fileName", filename);
entry.put("filePath", flatFile.toString());
entry.put("targetPath", targetFile.toString());
entry.put("fileSize", fileSize);
entry.put("formattedSize", formatFileSize(fileSize));
entry.put("storyId", storyId);
entry.put("storyTitle", story.getTitle());
entry.put("action", referencingStories.size() > 1 ? "copy" : "move");
movedFiles.add(entry);
if (!dryRun) {
Files.createDirectories(targetDir);
if (firstStory && referencingStories.size() == 1) {
Files.move(flatFile, targetFile, StandardCopyOption.REPLACE_EXISTING);
} else {
Files.copy(flatFile, targetFile, StandardCopyOption.REPLACE_EXISTING);
}
// Update story content to point to the new path
String updatedContent = story.getContentHtml().replace(oldUrl, newUrl);
if (!updatedContent.equals(story.getContentHtml())) {
story.setContentHtml(updatedContent);
storyService.update(story.getId(), story);
logger.debug("Updated story {} content with new image path", storyId);
}
}
firstStory = false;
movedCount++;
}
// If multiple stories referenced the same file, delete the original after copying
if (!dryRun && referencingStories.size() > 1 && Files.exists(flatFile)) {
Files.deleteIfExists(flatFile);
}
} catch (Exception e) {
logger.error("Error migrating flat image {}: {}", flatFile.getFileName(), e.getMessage(), e);
errors.add("Failed to migrate " + flatFile.getFileName() + ": " + e.getMessage());
}
}
logger.info("EPUB image migration completed. Moved: {}, Unmatched: {}, Errors: {}",
movedCount, unmatchedCount, errors.size());
} catch (Exception e) {
logger.error("Error during EPUB image migration", e);
errors.add("General migration error: " + e.getMessage());
}
return new EpubImageMigrationResult(movedFiles, movedCount, unmatchedCount, errors, dryRun);
}
private String formatFileSize(long bytes) {
if (bytes < 1024) return bytes + " B";
if (bytes < 1024 * 1024) return String.format("%.1f KB", bytes / 1024.0);
if (bytes < 1024 * 1024 * 1024) return String.format("%.1f MB", bytes / (1024.0 * 1024.0));
return String.format("%.1f GB", bytes / (1024.0 * 1024.0 * 1024.0));
}
/** /**
* Result class for content image processing * Result class for content image processing
*/ */
@@ -1009,6 +1175,33 @@ public class ImageService {
} }
} }
/**
* Result class for EPUB content image migration
*/
public static class EpubImageMigrationResult {
private final List<Map<String, Object>> movedFiles;
private final int movedCount;
private final int unmatchedCount;
private final List<String> errors;
private final boolean dryRun;
public EpubImageMigrationResult(List<Map<String, Object>> movedFiles, int movedCount,
int unmatchedCount, List<String> errors, boolean dryRun) {
this.movedFiles = movedFiles;
this.movedCount = movedCount;
this.unmatchedCount = unmatchedCount;
this.errors = errors;
this.dryRun = dryRun;
}
public List<Map<String, Object>> getMovedFiles() { return movedFiles; }
public int getMovedCount() { return movedCount; }
public int getUnmatchedCount() { return unmatchedCount; }
public List<String> getErrors() { return errors; }
public boolean isDryRun() { return dryRun; }
public boolean hasErrors() { return !errors.isEmpty(); }
}
/** /**
* Check if a path is a Synology system path that should be ignored * Check if a path is a Synology system path that should be ignored
*/ */

View File

@@ -67,9 +67,10 @@ class ImageServiceTest {
"image", "test.png", "image/png", pngData "image", "test.png", "image/png", pngData
); );
// Configure ImageService with test values // Configure ImageService with test values — lenient to avoid UnnecessaryStubbingException
when(libraryService.getCurrentImagePath()).thenReturn("/default"); // in tests that only use one of the two stubs
when(libraryService.getCurrentLibraryId()).thenReturn("default"); lenient().when(libraryService.getCurrentImagePath()).thenReturn("/default");
lenient().when(libraryService.getCurrentLibraryId()).thenReturn("default");
// Set image service properties using reflection // Set image service properties using reflection
ReflectionTestUtils.setField(imageService, "baseUploadDir", tempDir.toString()); ReflectionTestUtils.setField(imageService, "baseUploadDir", tempDir.toString());
@@ -171,6 +172,42 @@ class ImageServiceTest {
assertEquals("image/png", validImageFile.getContentType()); assertEquals("image/png", validImageFile.getContentType());
} }
@Test
@DisplayName("Should accept WebP files")
void testAcceptWebpFile() throws IOException {
// Arrange
MockMultipartFile webpFile = new MockMultipartFile(
"image", "test.webp", "image/webp", createMinimalWebpData()
);
// Act & Assert — uploadContentImage saves raw bytes, so a valid WebP should be accepted and saved
String resultPath = imageService.uploadContentImage(webpFile, testStoryId);
assertTrue(resultPath.endsWith(".webp"), "Result path should end with .webp but was: " + resultPath);
Path savedFile = tempDir.resolve("default").resolve(resultPath);
assertTrue(Files.exists(savedFile), "WebP file should be saved on disk");
}
@Test
@DisplayName("uploadContentImage should save WebP file without ImageIO processing")
void uploadContentImage_acceptsWebpFile() throws IOException {
// Arrange
byte[] webpData = createMinimalWebpData();
MockMultipartFile webpFile = new MockMultipartFile(
"image", "content.webp", "image/webp", webpData
);
// Act
String resultPath = imageService.uploadContentImage(webpFile, testStoryId);
// Assert
assertTrue(resultPath.startsWith("content/" + testStoryId + "/"),
"Path should be content/{storyId}/filename but was: " + resultPath);
assertTrue(resultPath.endsWith(".webp"), "Filename should retain .webp extension");
Path savedFile = tempDir.resolve("default").resolve(resultPath);
assertTrue(Files.exists(savedFile), "Saved file should exist on disk");
assertArrayEquals(webpData, Files.readAllBytes(savedFile), "Saved bytes should match original WebP data");
}
// ======================================== // ========================================
// Image Type Tests // Image Type Tests
// ======================================== // ========================================
@@ -400,8 +437,10 @@ class ImageServiceTest {
void testCollectImageReferences() { void testCollectImageReferences() {
// Arrange // Arrange
Story story = new Story(); Story story = new Story();
// Use a UUID-based filename — collectAllImageReferences() filters by isUuidBasedFilename()
String imageFilename = UUID.randomUUID() + ".jpg";
story.setId(testStoryId); story.setId(testStoryId);
story.setContentHtml("<p><img src=\"/api/files/images/default/content/" + testStoryId + "/test-image.jpg\"></p>"); story.setContentHtml("<p><img src=\"/api/files/images/default/content/" + testStoryId + "/" + imageFilename + "\"></p>");
when(storyService.findAllWithAssociations()).thenReturn(List.of(story)); when(storyService.findAllWithAssociations()).thenReturn(List.of(story));
when(authorService.findAll()).thenReturn(new ArrayList<>()); when(authorService.findAll()).thenReturn(new ArrayList<>());
@@ -590,10 +629,155 @@ class ImageServiceTest {
assertNotNull(result); assertNotNull(result);
} }
// ========================================
// uploadContentImage Tests
// ========================================
@Test
@DisplayName("uploadContentImage should save file into story UUID subfolder")
void uploadContentImage_savesToStorySubfolder() throws IOException {
// Arrange
Path contentDir = tempDir.resolve("default").resolve("content");
// Act
String resultPath = imageService.uploadContentImage(validImageFile, testStoryId);
// Assert
assertTrue(resultPath.startsWith("content/" + testStoryId + "/"),
"Path should be content/{storyId}/filename but was: " + resultPath);
Path savedFile = tempDir.resolve("default").resolve(resultPath);
assertTrue(Files.exists(savedFile), "Saved file should exist on disk");
assertTrue(Files.exists(contentDir.resolve(testStoryId.toString())),
"Story UUID subdirectory should have been created");
}
@Test
@DisplayName("uploadContentImage should reject invalid file")
void uploadContentImage_rejectsInvalidFile() {
MockMultipartFile emptyFile = new MockMultipartFile("image", "test.png", "image/png", new byte[0]);
assertThrows(IllegalArgumentException.class,
() -> imageService.uploadContentImage(emptyFile, testStoryId));
}
// ========================================
// migrateEpubContentImages Tests
// ========================================
@Test
@DisplayName("migrateEpubContentImages dry run should identify flat files with story matches")
void migrateEpubContentImages_dryRun_identifiesCorrectFiles() throws IOException {
// Arrange: place a flat image in content/
Path contentDir = tempDir.resolve("default").resolve("content");
Files.createDirectories(contentDir);
String filename = "epub-img-12345.png";
Files.write(contentDir.resolve(filename), createMinimalPngData());
String libraryId = "default";
Story story = new Story();
story.setId(testStoryId);
story.setTitle("Test Story");
story.setContentHtml("<img src=\"/api/files/images/" + libraryId + "/content/" + filename + "\">");
when(storyService.findAllWithAssociations()).thenReturn(List.of(story));
when(libraryService.getCurrentLibraryId()).thenReturn(libraryId);
// Act
ImageService.EpubImageMigrationResult result = imageService.migrateEpubContentImages(true);
// Assert
assertEquals(1, result.getMovedCount());
assertEquals(0, result.getUnmatchedCount());
assertTrue(result.isDryRun());
assertFalse(result.hasErrors());
// Dry run: file should still be at original location
assertTrue(Files.exists(contentDir.resolve(filename)), "Dry run must not move file");
}
@Test
@DisplayName("migrateEpubContentImages execute should move file and story content is updated")
void migrateEpubContentImages_execute_movesFilesAndUpdatesContent() throws IOException {
// Arrange
Path contentDir = tempDir.resolve("default").resolve("content");
Files.createDirectories(contentDir);
String filename = "epub-img-99999.png";
Files.write(contentDir.resolve(filename), createMinimalPngData());
String libraryId = "default";
String oldUrl = "/api/files/images/" + libraryId + "/content/" + filename;
Story story = new Story();
story.setId(testStoryId);
story.setTitle("Test Story");
story.setContentHtml("<img src=\"" + oldUrl + "\">");
when(storyService.findAllWithAssociations()).thenReturn(List.of(story));
when(libraryService.getCurrentLibraryId()).thenReturn(libraryId);
when(storyService.update(eq(testStoryId), any())).thenReturn(story);
// Act
ImageService.EpubImageMigrationResult result = imageService.migrateEpubContentImages(false);
// Assert
assertEquals(1, result.getMovedCount());
assertFalse(result.isDryRun());
assertFalse(result.hasErrors());
// Original flat file should be gone
assertFalse(Files.exists(contentDir.resolve(filename)), "Flat file should have been moved");
// File should now exist in story subfolder
assertTrue(Files.exists(contentDir.resolve(testStoryId.toString()).resolve(filename)),
"File should now be in story subfolder");
// storyService.update should have been called to update content URLs
verify(storyService).update(eq(testStoryId), any());
}
@Test
@DisplayName("migrateEpubContentImages should leave unmatched files in place")
void migrateEpubContentImages_unmatchedFile_notMoved() throws IOException {
// Arrange: flat image with no story referencing it
Path contentDir = tempDir.resolve("default").resolve("content");
Files.createDirectories(contentDir);
String filename = "orphaned-img.png";
Files.write(contentDir.resolve(filename), createMinimalPngData());
Story story = new Story();
story.setId(testStoryId);
story.setTitle("Unrelated Story");
story.setContentHtml("<p>No images here</p>");
when(storyService.findAllWithAssociations()).thenReturn(List.of(story));
// Act
ImageService.EpubImageMigrationResult result = imageService.migrateEpubContentImages(false);
// Assert
assertEquals(0, result.getMovedCount());
assertEquals(1, result.getUnmatchedCount());
assertTrue(Files.exists(contentDir.resolve(filename)), "Unmatched file must remain");
verify(storyService, never()).update(any(), any());
}
// ======================================== // ========================================
// Helper Methods // Helper Methods
// ======================================== // ========================================
/**
* Create minimal valid WebP data for testing.
* This is the smallest possible WebP file (a 1x1 pixel lossless image).
*/
private byte[] createMinimalWebpData() {
// Minimal WebP file: RIFF header + WEBP marker + VP8L chunk for a 1x1 white pixel
return new byte[]{
'R', 'I', 'F', 'F', // RIFF marker
0x24, 0x00, 0x00, 0x00, // File size minus 8 (36 bytes)
'W', 'E', 'B', 'P', // WEBP marker
'V', 'P', '8', 'L', // VP8L chunk marker (lossless)
0x14, 0x00, 0x00, 0x00, // Chunk size (20 bytes)
0x2F, // Signature byte
0x00, 0x00, 0x00, 0x00, // Width-1=0, Height-1=0, alpha, version
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // Image data (white pixel)
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00
};
}
/** /**
* Create minimal valid PNG data for testing. * Create minimal valid PNG data for testing.
* This is a 1x1 pixel transparent PNG image. * This is a 1x1 pixel transparent PNG image.

View File

@@ -56,6 +56,14 @@ export default function SystemSettings({}: SystemSettingsProps) {
execute: { loading: false, message: '' } execute: { loading: false, message: '' }
}); });
const [migrationStatus, setMigrationStatus] = useState<{
preview: { loading: boolean; message: string; success?: boolean; data?: any };
execute: { loading: boolean; message: string; success?: boolean };
}>({
preview: { loading: false, message: '' },
execute: { loading: false, message: '' }
});
const [hoveredImage, setHoveredImage] = useState<{ src: string; alt: string } | null>(null); const [hoveredImage, setHoveredImage] = useState<{ src: string; alt: string } | null>(null);
const [mousePosition, setMousePosition] = useState<{ x: number; y: number }>({ x: 0, y: 0 }); const [mousePosition, setMousePosition] = useState<{ x: number; y: number }>({ x: 0, y: 0 });
@@ -401,6 +409,115 @@ export default function SystemSettings({}: SystemSettingsProps) {
}, 10000); }, 10000);
}; };
const handleEpubMigrationPreview = async () => {
setMigrationStatus(prev => ({
...prev,
preview: { loading: true, message: 'Scanning for misplaced EPUB images...', success: undefined }
}));
try {
const result = await configApi.previewEpubImageMigration();
if (result.success) {
const matchedCount = result.movedFiles.filter(f => f.action !== 'unmatched').length;
setMigrationStatus(prev => ({
...prev,
preview: {
loading: false,
message: `Found ${matchedCount} image(s) to move into story subfolders, ${result.unmatchedCount} unmatched (no story reference found).`,
success: true,
data: result
}
}));
} else {
setMigrationStatus(prev => ({
...prev,
preview: {
loading: false,
message: result.error || 'Preview failed',
success: false
}
}));
}
} catch (error: any) {
setMigrationStatus(prev => ({
...prev,
preview: {
loading: false,
message: error.message || 'Network error occurred',
success: false
}
}));
}
};
const handleEpubMigrationExecute = async () => {
const matchedCount = migrationStatus.preview.data?.movedFiles?.filter((f: any) => f.action !== 'unmatched').length ?? 0;
if (!migrationStatus.preview.data || matchedCount === 0) {
setMigrationStatus(prev => ({
...prev,
execute: {
loading: false,
message: 'Please run preview first, or there are no images to migrate.',
success: false
}
}));
return;
}
const confirmed = window.confirm(
`Move ${matchedCount} EPUB image(s) into their story subfolders? Story content URLs will be updated automatically.`
);
if (!confirmed) return;
setMigrationStatus(prev => ({
...prev,
execute: { loading: true, message: 'Migrating EPUB images...', success: undefined }
}));
try {
const result = await configApi.executeEpubImageMigration();
if (result.success) {
setMigrationStatus(prev => ({
...prev,
execute: {
loading: false,
message: `Successfully moved ${result.movedCount} image(s). ${result.unmatchedCount} unmatched file(s) left in place.${result.hasErrors ? ` Errors: ${result.errors.length}` : ''}`,
success: true
},
preview: { loading: false, message: '', success: undefined, data: undefined }
}));
} else {
setMigrationStatus(prev => ({
...prev,
execute: {
loading: false,
message: result.error || 'Migration failed',
success: false
}
}));
}
} catch (error: any) {
setMigrationStatus(prev => ({
...prev,
execute: {
loading: false,
message: error.message || 'Network error occurred',
success: false
}
}));
}
setTimeout(() => {
setMigrationStatus(prev => ({
...prev,
execute: { loading: false, message: '', success: undefined }
}));
}, 10000);
};
// Search Engine Management Functions // Search Engine Management Functions
const loadSearchEngineStatus = async () => { const loadSearchEngineStatus = async () => {
try { try {
@@ -856,6 +973,147 @@ export default function SystemSettings({}: SystemSettingsProps) {
<li> <strong>Backup recommended:</strong> Consider backing up before large cleanups</li> <li> <strong>Backup recommended:</strong> Consider backing up before large cleanups</li>
</ul> </ul>
</div> </div>
{/* EPUB Image Migration Section */}
<div className="border theme-border rounded-lg p-4">
<h3 className="text-lg font-semibold theme-header mb-3">📦 EPUB Image Migration</h3>
<p className="text-sm theme-text mb-4">
Move images from EPUB imports into the correct story subfolders. These images are still
referenced by stories and display correctly, but were placed flat in the content directory
due to an import bug. Run this once to reorganise them.
</p>
<div className="flex flex-col sm:flex-row gap-3 mb-3">
<Button
onClick={handleEpubMigrationPreview}
disabled={migrationStatus.preview.loading || migrationStatus.execute.loading}
loading={migrationStatus.preview.loading}
variant="ghost"
className="flex-1"
>
{migrationStatus.preview.loading ? 'Scanning...' : 'Preview Migration'}
</Button>
<Button
onClick={handleEpubMigrationExecute}
disabled={
migrationStatus.execute.loading ||
!migrationStatus.preview.data ||
migrationStatus.preview.data.movedFiles?.filter((f: any) => f.action !== 'unmatched').length === 0
}
loading={migrationStatus.execute.loading}
variant="secondary"
className="flex-1"
>
{migrationStatus.execute.loading ? 'Migrating...' : 'Execute Migration'}
</Button>
{migrationStatus.preview.data && (
<Button
onClick={() => setMigrationStatus(prev => ({
...prev,
preview: { loading: false, message: '', success: undefined, data: undefined }
}))}
variant="ghost"
className="px-4 py-2 text-sm"
>
Clear Preview
</Button>
)}
</div>
{migrationStatus.preview.message && (
<div className={`text-sm p-3 rounded mb-3 ${
migrationStatus.preview.success
? 'bg-blue-50 dark:bg-blue-900/20 text-blue-800 dark:text-blue-200'
: 'bg-red-50 dark:bg-red-900/20 text-red-800 dark:text-red-200'
}`}>
{migrationStatus.preview.message}
</div>
)}
{migrationStatus.execute.message && (
<div className={`text-sm p-3 rounded mb-3 ${
migrationStatus.execute.success
? 'bg-green-50 dark:bg-green-900/20 text-green-800 dark:text-green-200'
: 'bg-red-50 dark:bg-red-900/20 text-red-800 dark:text-red-200'
}`}>
{migrationStatus.execute.message}
</div>
)}
{migrationStatus.preview.data && migrationStatus.preview.success && (
<div className="text-sm theme-text bg-gray-50 dark:bg-gray-800 p-3 rounded border">
<div className="grid grid-cols-2 gap-3 mb-3">
<div>
<span className="font-medium">To Move:</span>{' '}
{migrationStatus.preview.data.movedFiles?.filter((f: any) => f.action !== 'unmatched').length ?? 0}
</div>
<div>
<span className="font-medium">Unmatched:</span>{' '}
{migrationStatus.preview.data.unmatchedCount}
</div>
</div>
{migrationStatus.preview.data.movedFiles?.length > 0 && (
<details>
<summary className="cursor-pointer font-medium text-sm theme-header mb-2">
📁 View Files ({migrationStatus.preview.data.movedFiles.length})
</summary>
<div className="mt-3 max-h-80 overflow-y-auto border theme-border rounded">
<table className="w-full text-xs">
<thead className="bg-gray-100 dark:bg-gray-800 sticky top-0">
<tr>
<th className="text-left p-2 font-medium">File Name</th>
<th className="text-left p-2 font-medium">Size</th>
<th className="text-left p-2 font-medium">Story</th>
<th className="text-left p-2 font-medium">Action</th>
</tr>
</thead>
<tbody>
{migrationStatus.preview.data.movedFiles.map((file: any, index: number) => (
<tr key={index} className="border-t theme-border hover:bg-gray-50 dark:hover:bg-gray-800">
<td className="p-2">
<div className="truncate max-w-xs" title={file.fileName}>
🖼 {file.fileName}
</div>
</td>
<td className="p-2">{file.formattedSize}</td>
<td className="p-2">
{file.storyTitle ? (
<a
href={`/stories/${file.storyId}`}
className="text-blue-600 dark:text-blue-400 hover:underline truncate max-w-xs block"
title={file.storyTitle}
>
{file.storyTitle}
</a>
) : (
<span className="text-gray-400">No match</span>
)}
</td>
<td className="p-2">
{file.action === 'move' && <span className="text-blue-600 dark:text-blue-400">Move</span>}
{file.action === 'copy' && <span className="text-purple-600 dark:text-purple-400">Copy</span>}
{file.action === 'unmatched' && <span className="text-gray-500">Skip</span>}
</td>
</tr>
))}
</tbody>
</table>
</div>
</details>
)}
</div>
)}
<div className="text-sm theme-text bg-blue-50 dark:bg-blue-900/20 p-3 rounded-lg mt-3">
<p className="font-medium mb-1"> Notes:</p>
<ul className="text-xs space-y-1 ml-4">
<li> <strong>Safe to run multiple times</strong> after migration, no flat images remain and the scan returns zero</li>
<li> <strong>Unmatched files</strong> have no story reference and are left in place use the orphaned cleanup to remove them</li>
<li> Story content URLs are updated automatically so images continue to display correctly</li>
</ul>
</div>
</div>
</div> </div>
</div> </div>

View File

@@ -763,6 +763,52 @@ export const configApi = {
const response = await api.post('/config/cleanup/images/execute'); const response = await api.post('/config/cleanup/images/execute');
return response.data; return response.data;
}, },
previewEpubImageMigration: async (): Promise<{
success: boolean;
movedCount: number;
unmatchedCount: number;
errors: string[];
hasErrors: boolean;
dryRun: boolean;
movedFiles: {
fileName: string;
filePath: string;
targetPath?: string;
fileSize: number;
formattedSize: string;
storyId: string | null;
storyTitle: string | null;
action: 'move' | 'copy' | 'unmatched';
}[];
error?: string;
}> => {
const response = await api.post('/config/migrate/epub-images/preview');
return response.data;
},
executeEpubImageMigration: async (): Promise<{
success: boolean;
movedCount: number;
unmatchedCount: number;
errors: string[];
hasErrors: boolean;
dryRun: boolean;
movedFiles: {
fileName: string;
filePath: string;
targetPath?: string;
fileSize: number;
formattedSize: string;
storyId: string | null;
storyTitle: string | null;
action: 'move' | 'copy' | 'unmatched';
}[];
error?: string;
}> => {
const response = await api.post('/config/migrate/epub-images/execute');
return response.data;
},
}; };
// Search Engine Management API // Search Engine Management API

View File

@@ -36,11 +36,6 @@ for dst_rel in "${VOLUMES[@]}"; do
fi fi
done done
echo ""
echo "Stopping StoryCove stack (docker compose down)..."
cd "$(dirname "$0")"
docker compose down
echo "" echo ""
echo "Migrating data..." echo "Migrating data..."