support webp, some fixes.
This commit is contained in:
@@ -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
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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
|
||||||
|
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);
|
BufferedImage processedImage = processImage(file, imageType);
|
||||||
|
|
||||||
// Save processed image
|
|
||||||
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||||
ImageIO.write(processedImage, extension.equals("jpg") ? "jpeg" : extension, baos);
|
ImageIO.write(processedImage, extension.equals("jpg") ? "jpeg" : extension, baos);
|
||||||
Files.write(filePath, baos.toByteArray());
|
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
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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..."
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user