diff --git a/backend/src/main/java/com/storycove/controller/ConfigController.java b/backend/src/main/java/com/storycove/controller/ConfigController.java index 1858fd1..1a75830 100644 --- a/backend/src/main/java/com/storycove/controller/ConfigController.java +++ b/backend/src/main/java/com/storycove/controller/ConfigController.java @@ -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> previewEpubImageMigration() { + try { + logger.info("Starting EPUB image migration preview"); + ImageService.EpubImageMigrationResult result = imageService.migrateEpubContentImages(true); + + Map 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 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> executeEpubImageMigration() { + try { + logger.info("Starting EPUB image migration execution"); + ImageService.EpubImageMigrationResult result = imageService.migrateEpubContentImages(false); + + Map 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 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 */ diff --git a/backend/src/main/java/com/storycove/service/EPUBImportService.java b/backend/src/main/java/com/storycove/service/EPUBImportService.java index 4a0d508..54536b1 100644 --- a/backend/src/main/java/com/storycove/service/EPUBImportService.java +++ b/backend/src/main/java/com/storycove/service/EPUBImportService.java @@ -536,7 +536,7 @@ public class EPUBImportService { (int) (Math.random() * 100000) + "." + extension; 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; img.attr("src", imageUrl); diff --git a/backend/src/main/java/com/storycove/service/ImageService.java b/backend/src/main/java/com/storycove/service/ImageService.java index 40bb048..1725a04 100644 --- a/backend/src/main/java/com/storycove/service/ImageService.java +++ b/backend/src/main/java/com/storycove/service/ImageService.java @@ -18,6 +18,7 @@ import java.net.URL; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; +import java.nio.file.StandardCopyOption; import java.util.*; import java.util.List; import java.util.regex.Matcher; @@ -31,11 +32,11 @@ public class ImageService { private static final Logger logger = LoggerFactory.getLogger(ImageService.class); private static final Set ALLOWED_CONTENT_TYPES = Set.of( - "image/jpeg", "image/jpg", "image/png" + "image/jpeg", "image/jpg", "image/png", "image/webp" ); - + private static final Set ALLOWED_EXTENSIONS = Set.of( - "jpg", "jpeg", "png" + "jpg", "jpeg", "png", "webp" ); @Value("${storycove.images.upload-dir:/app/images}") @@ -102,13 +103,16 @@ public class ImageService { String filename = UUID.randomUUID().toString() + "." + extension; Path filePath = typeDir.resolve(filename); - // Process and resize image - BufferedImage processedImage = processImage(file, imageType); - - // Save processed image - ByteArrayOutputStream baos = new ByteArrayOutputStream(); - ImageIO.write(processedImage, extension.equals("jpg") ? "jpeg" : extension, baos); - Files.write(filePath, baos.toByteArray()); + // Process and save image + if (extension.equals("webp")) { + // ImageIO does not support WebP natively; save verbatim without resize + Files.write(filePath, file.getBytes()); + } else { + BufferedImage processedImage = processImage(file, imageType); + 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 imageType.getDirectory() + "/" + filename; @@ -255,6 +259,27 @@ public class ImageService { 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 /** @@ -562,6 +587,8 @@ public class ImageService { return "jpg"; case "image/png": return "png"; + case "image/webp": + return "webp"; default: return null; } @@ -753,7 +780,7 @@ public class ImageService { List allStories = storyService.findAllWithAssociations(); // 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) { // 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 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/ Path parent = absPath.getParent(); 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> movedFiles = new ArrayList<>(); + List 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 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 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 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 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 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 */ @@ -1009,6 +1175,33 @@ public class ImageService { } } + /** + * Result class for EPUB content image migration + */ + public static class EpubImageMigrationResult { + private final List> movedFiles; + private final int movedCount; + private final int unmatchedCount; + private final List errors; + private final boolean dryRun; + + public EpubImageMigrationResult(List> movedFiles, int movedCount, + int unmatchedCount, List errors, boolean dryRun) { + this.movedFiles = movedFiles; + this.movedCount = movedCount; + this.unmatchedCount = unmatchedCount; + this.errors = errors; + this.dryRun = dryRun; + } + + public List> getMovedFiles() { return movedFiles; } + public int getMovedCount() { return movedCount; } + public int getUnmatchedCount() { return unmatchedCount; } + public List 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 */ diff --git a/backend/src/test/java/com/storycove/service/ImageServiceTest.java b/backend/src/test/java/com/storycove/service/ImageServiceTest.java index 935fe20..684a9c8 100644 --- a/backend/src/test/java/com/storycove/service/ImageServiceTest.java +++ b/backend/src/test/java/com/storycove/service/ImageServiceTest.java @@ -67,9 +67,10 @@ class ImageServiceTest { "image", "test.png", "image/png", pngData ); - // Configure ImageService with test values - when(libraryService.getCurrentImagePath()).thenReturn("/default"); - when(libraryService.getCurrentLibraryId()).thenReturn("default"); + // Configure ImageService with test values — lenient to avoid UnnecessaryStubbingException + // in tests that only use one of the two stubs + lenient().when(libraryService.getCurrentImagePath()).thenReturn("/default"); + lenient().when(libraryService.getCurrentLibraryId()).thenReturn("default"); // Set image service properties using reflection ReflectionTestUtils.setField(imageService, "baseUploadDir", tempDir.toString()); @@ -171,6 +172,42 @@ class ImageServiceTest { 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 // ======================================== @@ -400,8 +437,10 @@ class ImageServiceTest { void testCollectImageReferences() { // Arrange Story story = new Story(); + // Use a UUID-based filename — collectAllImageReferences() filters by isUuidBasedFilename() + String imageFilename = UUID.randomUUID() + ".jpg"; story.setId(testStoryId); - story.setContentHtml("

"); + story.setContentHtml("

"); when(storyService.findAllWithAssociations()).thenReturn(List.of(story)); when(authorService.findAll()).thenReturn(new ArrayList<>()); @@ -590,10 +629,155 @@ class ImageServiceTest { 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(""); + + 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(""); + + 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("

No images here

"); + + 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 // ======================================== + /** + * 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. * This is a 1x1 pixel transparent PNG image. diff --git a/frontend/src/components/settings/SystemSettings.tsx b/frontend/src/components/settings/SystemSettings.tsx index 3bef91c..5dcd642 100644 --- a/frontend/src/components/settings/SystemSettings.tsx +++ b/frontend/src/components/settings/SystemSettings.tsx @@ -56,6 +56,14 @@ export default function SystemSettings({}: SystemSettingsProps) { 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 [mousePosition, setMousePosition] = useState<{ x: number; y: number }>({ x: 0, y: 0 }); @@ -401,6 +409,115 @@ export default function SystemSettings({}: SystemSettingsProps) { }, 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 const loadSearchEngineStatus = async () => { try { @@ -856,6 +973,147 @@ export default function SystemSettings({}: SystemSettingsProps) {
  • Backup recommended: Consider backing up before large cleanups
  • + + {/* EPUB Image Migration Section */} +
    +

    📦 EPUB Image Migration

    +

    + 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. +

    + +
    + + + {migrationStatus.preview.data && ( + + )} +
    + + {migrationStatus.preview.message && ( +
    + {migrationStatus.preview.message} +
    + )} + + {migrationStatus.execute.message && ( +
    + {migrationStatus.execute.message} +
    + )} + + {migrationStatus.preview.data && migrationStatus.preview.success && ( +
    +
    +
    + To Move:{' '} + {migrationStatus.preview.data.movedFiles?.filter((f: any) => f.action !== 'unmatched').length ?? 0} +
    +
    + Unmatched:{' '} + {migrationStatus.preview.data.unmatchedCount} +
    +
    + + {migrationStatus.preview.data.movedFiles?.length > 0 && ( +
    + + 📁 View Files ({migrationStatus.preview.data.movedFiles.length}) + +
    + + + + + + + + + + + {migrationStatus.preview.data.movedFiles.map((file: any, index: number) => ( + + + + + + + ))} + +
    File NameSizeStoryAction
    +
    + 🖼️ {file.fileName} +
    +
    {file.formattedSize} + {file.storyTitle ? ( + + {file.storyTitle} + + ) : ( + No match + )} + + {file.action === 'move' && Move} + {file.action === 'copy' && Copy} + {file.action === 'unmatched' && Skip} +
    +
    +
    + )} +
    + )} + +
    +

    ℹ️ Notes:

    +
      +
    • Safe to run multiple times — after migration, no flat images remain and the scan returns zero
    • +
    • Unmatched files have no story reference and are left in place — use the orphaned cleanup to remove them
    • +
    • • Story content URLs are updated automatically so images continue to display correctly
    • +
    +
    +
    diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 4d3a2a4..c02daf1 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -763,6 +763,52 @@ export const configApi = { const response = await api.post('/config/cleanup/images/execute'); 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 diff --git a/migrate-volumes.sh b/migrate-volumes.sh index ea5e340..9ff3da4 100644 --- a/migrate-volumes.sh +++ b/migrate-volumes.sh @@ -36,11 +36,6 @@ for dst_rel in "${VOLUMES[@]}"; do fi done -echo "" -echo "Stopping StoryCove stack (docker compose down)..." -cd "$(dirname "$0")" -docker compose down - echo "" echo "Migrating data..."