embedded image finishing
This commit is contained in:
@@ -2,6 +2,7 @@ package com.storycove.controller;
|
|||||||
|
|
||||||
import com.storycove.dto.HtmlSanitizationConfigDto;
|
import com.storycove.dto.HtmlSanitizationConfigDto;
|
||||||
import com.storycove.service.HtmlSanitizationService;
|
import com.storycove.service.HtmlSanitizationService;
|
||||||
|
import com.storycove.service.ImageService;
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
@@ -14,13 +15,15 @@ import java.util.Map;
|
|||||||
public class ConfigController {
|
public class ConfigController {
|
||||||
|
|
||||||
private final HtmlSanitizationService htmlSanitizationService;
|
private final HtmlSanitizationService htmlSanitizationService;
|
||||||
|
private final ImageService imageService;
|
||||||
|
|
||||||
@Value("${app.reading.speed.default:200}")
|
@Value("${app.reading.speed.default:200}")
|
||||||
private int defaultReadingSpeed;
|
private int defaultReadingSpeed;
|
||||||
|
|
||||||
@Autowired
|
@Autowired
|
||||||
public ConfigController(HtmlSanitizationService htmlSanitizationService) {
|
public ConfigController(HtmlSanitizationService htmlSanitizationService, ImageService imageService) {
|
||||||
this.htmlSanitizationService = htmlSanitizationService;
|
this.htmlSanitizationService = htmlSanitizationService;
|
||||||
|
this.imageService = imageService;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -51,4 +54,64 @@ public class ConfigController {
|
|||||||
public ResponseEntity<Map<String, Integer>> getReadingSpeed() {
|
public ResponseEntity<Map<String, Integer>> getReadingSpeed() {
|
||||||
return ResponseEntity.ok(Map.of("wordsPerMinute", defaultReadingSpeed));
|
return ResponseEntity.ok(Map.of("wordsPerMinute", defaultReadingSpeed));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Preview orphaned content images cleanup (dry run)
|
||||||
|
*/
|
||||||
|
@PostMapping("/cleanup/images/preview")
|
||||||
|
public ResponseEntity<Map<String, Object>> previewImageCleanup() {
|
||||||
|
try {
|
||||||
|
ImageService.ContentImageCleanupResult result = imageService.cleanupOrphanedContentImages(true);
|
||||||
|
|
||||||
|
Map<String, Object> response = Map.of(
|
||||||
|
"success", true,
|
||||||
|
"orphanedCount", result.getOrphanedImages().size(),
|
||||||
|
"totalSizeBytes", result.getTotalSizeBytes(),
|
||||||
|
"formattedSize", result.getFormattedSize(),
|
||||||
|
"foldersToDelete", result.getFoldersToDelete(),
|
||||||
|
"referencedImagesCount", result.getTotalReferencedImages(),
|
||||||
|
"errors", result.getErrors(),
|
||||||
|
"hasErrors", result.hasErrors(),
|
||||||
|
"dryRun", true
|
||||||
|
);
|
||||||
|
|
||||||
|
return ResponseEntity.ok(response);
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
return ResponseEntity.status(500).body(Map.of(
|
||||||
|
"success", false,
|
||||||
|
"error", "Failed to preview image cleanup: " + e.getMessage()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute orphaned content images cleanup
|
||||||
|
*/
|
||||||
|
@PostMapping("/cleanup/images/execute")
|
||||||
|
public ResponseEntity<Map<String, Object>> executeImageCleanup() {
|
||||||
|
try {
|
||||||
|
ImageService.ContentImageCleanupResult result = imageService.cleanupOrphanedContentImages(false);
|
||||||
|
|
||||||
|
Map<String, Object> response = Map.of(
|
||||||
|
"success", true,
|
||||||
|
"deletedCount", result.getOrphanedImages().size(),
|
||||||
|
"totalSizeBytes", result.getTotalSizeBytes(),
|
||||||
|
"formattedSize", result.getFormattedSize(),
|
||||||
|
"foldersDeleted", result.getFoldersToDelete(),
|
||||||
|
"referencedImagesCount", result.getTotalReferencedImages(),
|
||||||
|
"errors", result.getErrors(),
|
||||||
|
"hasErrors", result.hasErrors(),
|
||||||
|
"dryRun", false
|
||||||
|
);
|
||||||
|
|
||||||
|
return ResponseEntity.ok(response);
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
return ResponseEntity.status(500).body(Map.of(
|
||||||
|
"success", false,
|
||||||
|
"error", "Failed to execute image cleanup: " + e.getMessage()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -73,7 +73,35 @@ public class EPUBImportService {
|
|||||||
Story story = createStoryFromEPUB(book, request);
|
Story story = createStoryFromEPUB(book, request);
|
||||||
|
|
||||||
Story savedStory = storyService.create(story);
|
Story savedStory = storyService.create(story);
|
||||||
|
|
||||||
|
// Process embedded images if content contains any
|
||||||
|
String originalContent = story.getContentHtml();
|
||||||
|
if (originalContent != null && originalContent.contains("<img")) {
|
||||||
|
try {
|
||||||
|
ImageService.ContentImageProcessingResult imageResult =
|
||||||
|
imageService.processContentImages(originalContent, savedStory.getId());
|
||||||
|
|
||||||
|
// Update story content with processed images if changed
|
||||||
|
if (!imageResult.getProcessedContent().equals(originalContent)) {
|
||||||
|
savedStory.setContentHtml(imageResult.getProcessedContent());
|
||||||
|
savedStory = storyService.update(savedStory.getId(), savedStory);
|
||||||
|
|
||||||
|
// Log the image processing results
|
||||||
|
System.out.println("EPUB Import - Image processing completed for story " + savedStory.getId() +
|
||||||
|
". Downloaded " + imageResult.getDownloadedImages().size() + " images.");
|
||||||
|
|
||||||
|
if (imageResult.hasWarnings()) {
|
||||||
|
System.out.println("EPUB Import - Image processing warnings: " +
|
||||||
|
String.join(", ", imageResult.getWarnings()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
// Log error but don't fail the import
|
||||||
|
System.err.println("EPUB Import - Failed to process embedded images for story " +
|
||||||
|
savedStory.getId() + ": " + e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
EPUBImportResponse response = EPUBImportResponse.success(savedStory.getId(), savedStory.getTitle());
|
EPUBImportResponse response = EPUBImportResponse.success(savedStory.getId(), savedStory.getTitle());
|
||||||
response.setWordCount(savedStory.getWordCount());
|
response.setWordCount(savedStory.getWordCount());
|
||||||
response.setTotalChapters(book.getSpine().size());
|
response.setTotalChapters(book.getSpine().size());
|
||||||
|
|||||||
@@ -39,6 +39,9 @@ public class ImageService {
|
|||||||
|
|
||||||
@Autowired
|
@Autowired
|
||||||
private LibraryService libraryService;
|
private LibraryService libraryService;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private StoryService storyService;
|
||||||
|
|
||||||
private String getUploadDir() {
|
private String getUploadDir() {
|
||||||
String libraryPath = libraryService.getCurrentImagePath();
|
String libraryPath = libraryService.getCurrentImagePath();
|
||||||
@@ -421,6 +424,249 @@ public class ImageService {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cleanup orphaned content images that are no longer referenced in any story
|
||||||
|
*/
|
||||||
|
public ContentImageCleanupResult cleanupOrphanedContentImages(boolean dryRun) {
|
||||||
|
logger.info("Starting orphaned content image cleanup (dryRun: {})", dryRun);
|
||||||
|
|
||||||
|
final Set<String> referencedImages;
|
||||||
|
List<String> orphanedImages = new ArrayList<>();
|
||||||
|
List<String> errors = new ArrayList<>();
|
||||||
|
long totalSizeBytes = 0;
|
||||||
|
int foldersToDelete = 0;
|
||||||
|
|
||||||
|
// Step 1: Collect all image references from all story content
|
||||||
|
logger.info("Scanning all story content for image references...");
|
||||||
|
referencedImages = collectAllImageReferences();
|
||||||
|
logger.info("Found {} unique image references in story content", referencedImages.size());
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Step 2: Scan the content images directory
|
||||||
|
Path contentImagesDir = Paths.get(getUploadDir(), ImageType.CONTENT.getDirectory());
|
||||||
|
|
||||||
|
if (!Files.exists(contentImagesDir)) {
|
||||||
|
logger.info("Content images directory does not exist: {}", contentImagesDir);
|
||||||
|
return new ContentImageCleanupResult(orphanedImages, 0, 0, referencedImages.size(), errors, dryRun);
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info("Scanning content images directory: {}", contentImagesDir);
|
||||||
|
|
||||||
|
// Walk through all story directories
|
||||||
|
Files.walk(contentImagesDir, 2)
|
||||||
|
.filter(Files::isDirectory)
|
||||||
|
.filter(path -> !path.equals(contentImagesDir)) // Skip the root content directory
|
||||||
|
.forEach(storyDir -> {
|
||||||
|
try {
|
||||||
|
String storyId = storyDir.getFileName().toString();
|
||||||
|
logger.debug("Checking story directory: {}", storyId);
|
||||||
|
|
||||||
|
// Check if this story still exists
|
||||||
|
boolean storyExists = storyService.findByIdOptional(UUID.fromString(storyId)).isPresent();
|
||||||
|
|
||||||
|
if (!storyExists) {
|
||||||
|
logger.info("Found orphaned story directory (story deleted): {}", storyId);
|
||||||
|
// Mark entire directory for deletion
|
||||||
|
try {
|
||||||
|
Files.walk(storyDir)
|
||||||
|
.filter(Files::isRegularFile)
|
||||||
|
.forEach(file -> {
|
||||||
|
try {
|
||||||
|
long size = Files.size(file);
|
||||||
|
orphanedImages.add(file.toString());
|
||||||
|
// Add to total size (will be updated in main scope)
|
||||||
|
} catch (IOException e) {
|
||||||
|
errors.add("Failed to get size for " + file + ": " + e.getMessage());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (IOException e) {
|
||||||
|
errors.add("Failed to scan orphaned story directory " + storyDir + ": " + e.getMessage());
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check individual files in the story directory
|
||||||
|
try {
|
||||||
|
Files.walk(storyDir)
|
||||||
|
.filter(Files::isRegularFile)
|
||||||
|
.forEach(imageFile -> {
|
||||||
|
try {
|
||||||
|
String imagePath = getRelativeImagePath(imageFile);
|
||||||
|
|
||||||
|
if (!referencedImages.contains(imagePath)) {
|
||||||
|
logger.debug("Found orphaned image: {}", imagePath);
|
||||||
|
orphanedImages.add(imageFile.toString());
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
errors.add("Error checking image file " + imageFile + ": " + e.getMessage());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (IOException e) {
|
||||||
|
errors.add("Failed to scan story directory " + storyDir + ": " + e.getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
errors.add("Error processing story directory " + storyDir + ": " + e.getMessage());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Calculate total size and count empty directories
|
||||||
|
for (String orphanedImage : orphanedImages) {
|
||||||
|
try {
|
||||||
|
Path imagePath = Paths.get(orphanedImage);
|
||||||
|
if (Files.exists(imagePath)) {
|
||||||
|
totalSizeBytes += Files.size(imagePath);
|
||||||
|
}
|
||||||
|
} catch (IOException e) {
|
||||||
|
errors.add("Failed to get size for " + orphanedImage + ": " + e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Count empty directories that would be removed
|
||||||
|
try {
|
||||||
|
foldersToDelete = (int) Files.walk(contentImagesDir)
|
||||||
|
.filter(Files::isDirectory)
|
||||||
|
.filter(path -> !path.equals(contentImagesDir))
|
||||||
|
.filter(this::isDirectoryEmptyOrWillBeEmpty)
|
||||||
|
.count();
|
||||||
|
} catch (IOException e) {
|
||||||
|
errors.add("Failed to count empty directories: " + e.getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 3: Delete orphaned files if not dry run
|
||||||
|
if (!dryRun && !orphanedImages.isEmpty()) {
|
||||||
|
logger.info("Deleting {} orphaned images...", orphanedImages.size());
|
||||||
|
|
||||||
|
Set<Path> directoriesToCheck = new HashSet<>();
|
||||||
|
|
||||||
|
for (String orphanedImage : orphanedImages) {
|
||||||
|
try {
|
||||||
|
Path imagePath = Paths.get(orphanedImage);
|
||||||
|
if (Files.exists(imagePath)) {
|
||||||
|
directoriesToCheck.add(imagePath.getParent());
|
||||||
|
Files.delete(imagePath);
|
||||||
|
logger.debug("Deleted orphaned image: {}", imagePath);
|
||||||
|
}
|
||||||
|
} catch (IOException e) {
|
||||||
|
errors.add("Failed to delete " + orphanedImage + ": " + e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up empty directories
|
||||||
|
for (Path dir : directoriesToCheck) {
|
||||||
|
try {
|
||||||
|
if (Files.exists(dir) && isDirEmpty(dir)) {
|
||||||
|
Files.delete(dir);
|
||||||
|
logger.info("Deleted empty story directory: {}", dir);
|
||||||
|
}
|
||||||
|
} catch (IOException e) {
|
||||||
|
errors.add("Failed to delete empty directory " + dir + ": " + e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info("Orphaned content image cleanup completed. Found {} orphaned files ({} bytes)",
|
||||||
|
orphanedImages.size(), totalSizeBytes);
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
logger.error("Error during orphaned content image cleanup", e);
|
||||||
|
errors.add("General cleanup error: " + e.getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
return new ContentImageCleanupResult(orphanedImages, totalSizeBytes, foldersToDelete, referencedImages.size(), errors, dryRun);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Collect all image references from all story content
|
||||||
|
*/
|
||||||
|
private Set<String> collectAllImageReferences() {
|
||||||
|
Set<String> referencedImages = new HashSet<>();
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Get all stories
|
||||||
|
List<com.storycove.entity.Story> allStories = storyService.findAllWithAssociations();
|
||||||
|
|
||||||
|
// Pattern to match local image URLs in content
|
||||||
|
Pattern imagePattern = Pattern.compile("src=[\"']([^\"']*(?:content/[^\"']*\\.(jpg|jpeg|png)))[\"']", Pattern.CASE_INSENSITIVE);
|
||||||
|
|
||||||
|
for (com.storycove.entity.Story story : allStories) {
|
||||||
|
if (story.getContentHtml() != null) {
|
||||||
|
Matcher matcher = imagePattern.matcher(story.getContentHtml());
|
||||||
|
|
||||||
|
while (matcher.find()) {
|
||||||
|
String imageSrc = matcher.group(1);
|
||||||
|
|
||||||
|
// Convert to relative path format that matches our file system
|
||||||
|
String relativePath = convertSrcToRelativePath(imageSrc);
|
||||||
|
if (relativePath != null) {
|
||||||
|
referencedImages.add(relativePath);
|
||||||
|
logger.debug("Found image reference in story {}: {}", story.getId(), relativePath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
logger.error("Error collecting image references from stories", e);
|
||||||
|
}
|
||||||
|
|
||||||
|
return referencedImages;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert an image src attribute to relative file path
|
||||||
|
*/
|
||||||
|
private String convertSrcToRelativePath(String src) {
|
||||||
|
try {
|
||||||
|
// Handle both /api/files/images/libraryId/content/... and relative content/... paths
|
||||||
|
if (src.contains("/content/")) {
|
||||||
|
int contentIndex = src.indexOf("/content/");
|
||||||
|
return src.substring(contentIndex + 1); // Remove leading slash, keep "content/..."
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
logger.debug("Failed to convert src to relative path: {}", src);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get relative image path from absolute file path
|
||||||
|
*/
|
||||||
|
private String getRelativeImagePath(Path imageFile) {
|
||||||
|
try {
|
||||||
|
Path uploadDir = Paths.get(getUploadDir());
|
||||||
|
Path relativePath = uploadDir.relativize(imageFile);
|
||||||
|
return relativePath.toString().replace('\\', '/'); // Normalize path separators
|
||||||
|
} catch (Exception e) {
|
||||||
|
logger.debug("Failed to get relative path for: {}", imageFile);
|
||||||
|
return imageFile.toString();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if directory is empty or will be empty after cleanup
|
||||||
|
*/
|
||||||
|
private boolean isDirectoryEmptyOrWillBeEmpty(Path dir) {
|
||||||
|
try {
|
||||||
|
return Files.walk(dir)
|
||||||
|
.filter(Files::isRegularFile)
|
||||||
|
.count() == 0;
|
||||||
|
} catch (IOException e) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if directory is empty
|
||||||
|
*/
|
||||||
|
private boolean isDirEmpty(Path dir) {
|
||||||
|
try {
|
||||||
|
return Files.list(dir).count() == 0;
|
||||||
|
} catch (IOException e) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Clean up content images for a story
|
* Clean up content images for a story
|
||||||
*/
|
*/
|
||||||
@@ -458,4 +704,41 @@ public class ImageService {
|
|||||||
public List<String> getDownloadedImages() { return downloadedImages; }
|
public List<String> getDownloadedImages() { return downloadedImages; }
|
||||||
public boolean hasWarnings() { return !warnings.isEmpty(); }
|
public boolean hasWarnings() { return !warnings.isEmpty(); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Result class for orphaned image cleanup
|
||||||
|
*/
|
||||||
|
public static class ContentImageCleanupResult {
|
||||||
|
private final List<String> orphanedImages;
|
||||||
|
private final long totalSizeBytes;
|
||||||
|
private final int foldersToDelete;
|
||||||
|
private final int totalReferencedImages;
|
||||||
|
private final List<String> errors;
|
||||||
|
private final boolean dryRun;
|
||||||
|
|
||||||
|
public ContentImageCleanupResult(List<String> orphanedImages, long totalSizeBytes, int foldersToDelete,
|
||||||
|
int totalReferencedImages, List<String> errors, boolean dryRun) {
|
||||||
|
this.orphanedImages = orphanedImages;
|
||||||
|
this.totalSizeBytes = totalSizeBytes;
|
||||||
|
this.foldersToDelete = foldersToDelete;
|
||||||
|
this.totalReferencedImages = totalReferencedImages;
|
||||||
|
this.errors = errors;
|
||||||
|
this.dryRun = dryRun;
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<String> getOrphanedImages() { return orphanedImages; }
|
||||||
|
public long getTotalSizeBytes() { return totalSizeBytes; }
|
||||||
|
public int getFoldersToDelete() { return foldersToDelete; }
|
||||||
|
public int getTotalReferencedImages() { return totalReferencedImages; }
|
||||||
|
public List<String> getErrors() { return errors; }
|
||||||
|
public boolean isDryRun() { return dryRun; }
|
||||||
|
public boolean hasErrors() { return !errors.isEmpty(); }
|
||||||
|
|
||||||
|
public String getFormattedSize() {
|
||||||
|
if (totalSizeBytes < 1024) return totalSizeBytes + " B";
|
||||||
|
if (totalSizeBytes < 1024 * 1024) return String.format("%.1f KB", totalSizeBytes / 1024.0);
|
||||||
|
if (totalSizeBytes < 1024 * 1024 * 1024) return String.format("%.1f MB", totalSizeBytes / (1024.0 * 1024.0));
|
||||||
|
return String.format("%.1f GB", totalSizeBytes / (1024.0 * 1024.0 * 1024.0));
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -188,29 +188,47 @@ async function processCombinedMode(
|
|||||||
// Check content size to prevent response size issues
|
// Check content size to prevent response size issues
|
||||||
const combinedContentString = combinedContent.join('\n');
|
const combinedContentString = combinedContent.join('\n');
|
||||||
const contentSizeInMB = new Blob([combinedContentString]).size / (1024 * 1024);
|
const contentSizeInMB = new Blob([combinedContentString]).size / (1024 * 1024);
|
||||||
|
|
||||||
console.log(`Combined content size: ${contentSizeInMB.toFixed(2)} MB`);
|
console.log(`Combined content size: ${contentSizeInMB.toFixed(2)} MB`);
|
||||||
console.log(`Combined content character length: ${combinedContentString.length}`);
|
console.log(`Combined content character length: ${combinedContentString.length}`);
|
||||||
console.log(`Combined content parts count: ${combinedContent.length}`);
|
console.log(`Combined content parts count: ${combinedContent.length}`);
|
||||||
|
|
||||||
|
// Handle content truncation if needed
|
||||||
|
let finalContent = contentSizeInMB > 10 ?
|
||||||
|
combinedContentString.substring(0, Math.floor(combinedContentString.length * (10 / contentSizeInMB))) + '\n\n<!-- Content truncated due to size limit -->' :
|
||||||
|
combinedContentString;
|
||||||
|
|
||||||
|
let finalSummary = contentSizeInMB > 10 ? baseSummary + ' (Content truncated due to size limit)' : baseSummary;
|
||||||
|
|
||||||
|
// Check if combined content has images and mark for processing
|
||||||
|
const hasImages = /<img[^>]+src=['"'][^'"']*['"][^>]*>/i.test(finalContent);
|
||||||
|
if (hasImages) {
|
||||||
|
finalSummary += ' (Contains embedded images - will be processed after story creation)';
|
||||||
|
console.log(`Combined story contains embedded images - will need processing after creation`);
|
||||||
|
}
|
||||||
|
|
||||||
// Return the combined story data via progress update
|
// Return the combined story data via progress update
|
||||||
const combinedStory = {
|
const combinedStory = {
|
||||||
title: baseTitle,
|
title: baseTitle,
|
||||||
author: baseAuthor,
|
author: baseAuthor,
|
||||||
content: contentSizeInMB > 10 ?
|
content: finalContent,
|
||||||
combinedContentString.substring(0, Math.floor(combinedContentString.length * (10 / contentSizeInMB))) + '\n\n<!-- Content truncated due to size limit -->' :
|
summary: finalSummary,
|
||||||
combinedContentString,
|
|
||||||
summary: contentSizeInMB > 10 ? baseSummary + ' (Content truncated due to size limit)' : baseSummary,
|
|
||||||
sourceUrl: baseSourceUrl,
|
sourceUrl: baseSourceUrl,
|
||||||
tags: Array.from(combinedTags)
|
tags: Array.from(combinedTags),
|
||||||
|
hasImages: hasImages
|
||||||
};
|
};
|
||||||
|
|
||||||
// Send completion notification for combine mode
|
// Send completion notification for combine mode
|
||||||
|
let completionMessage = `Combined scraping completed: ${totalWordCount.toLocaleString()} words from ${importedCount} stories`;
|
||||||
|
if (hasImages) {
|
||||||
|
completionMessage += ` (embedded images will be processed when story is created)`;
|
||||||
|
}
|
||||||
|
|
||||||
await sendProgressUpdate(sessionId, {
|
await sendProgressUpdate(sessionId, {
|
||||||
type: 'completed',
|
type: 'completed',
|
||||||
current: urls.length,
|
current: urls.length,
|
||||||
total: urls.length,
|
total: urls.length,
|
||||||
message: `Combined scraping completed: ${totalWordCount.toLocaleString()} words from ${importedCount} stories`,
|
message: completionMessage,
|
||||||
totalWordCount: totalWordCount,
|
totalWordCount: totalWordCount,
|
||||||
combinedStory: combinedStory
|
combinedStory: combinedStory
|
||||||
});
|
});
|
||||||
@@ -346,7 +364,62 @@ async function processIndividualMode(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const createdStory = await createResponse.json();
|
const createdStory = await createResponse.json();
|
||||||
|
|
||||||
|
// Process embedded images if content contains images
|
||||||
|
let imageProcessingWarnings: string[] = [];
|
||||||
|
const hasImages = /<img[^>]+src=['"'][^'"']*['"][^>]*>/i.test(scrapedStory.content);
|
||||||
|
|
||||||
|
if (hasImages) {
|
||||||
|
try {
|
||||||
|
console.log(`Processing embedded images for story: ${createdStory.id}`);
|
||||||
|
const imageProcessUrl = `http://backend:8080/api/stories/${createdStory.id}/process-content-images`;
|
||||||
|
const imageProcessResponse = await fetch(imageProcessUrl, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Authorization': authorization,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ htmlContent: scrapedStory.content }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (imageProcessResponse.ok) {
|
||||||
|
const imageResult = await imageProcessResponse.json();
|
||||||
|
if (imageResult.hasWarnings && imageResult.warnings) {
|
||||||
|
imageProcessingWarnings = imageResult.warnings;
|
||||||
|
console.log(`Image processing completed with warnings for story ${createdStory.id}:`, imageResult.warnings);
|
||||||
|
} else {
|
||||||
|
console.log(`Image processing completed successfully for story ${createdStory.id}. Downloaded ${imageResult.downloadedImages?.length || 0} images.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update story content with processed images
|
||||||
|
if (imageResult.processedContent && imageResult.processedContent !== scrapedStory.content) {
|
||||||
|
const updateUrl = `http://backend:8080/api/stories/${createdStory.id}`;
|
||||||
|
const updateResponse = await fetch(updateUrl, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: {
|
||||||
|
'Authorization': authorization,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
contentHtml: imageResult.processedContent
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!updateResponse.ok) {
|
||||||
|
console.warn(`Failed to update story content after image processing for ${createdStory.id}`);
|
||||||
|
imageProcessingWarnings.push('Failed to update story content with processed images');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.warn(`Image processing failed for story ${createdStory.id}:`, imageProcessResponse.status);
|
||||||
|
imageProcessingWarnings.push('Image processing failed');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error processing images for story ${createdStory.id}:`, error);
|
||||||
|
imageProcessingWarnings.push(`Image processing error: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
results.push({
|
results.push({
|
||||||
url: trimmedUrl,
|
url: trimmedUrl,
|
||||||
status: 'imported',
|
status: 'imported',
|
||||||
@@ -356,17 +429,24 @@ async function processIndividualMode(
|
|||||||
});
|
});
|
||||||
importedCount++;
|
importedCount++;
|
||||||
|
|
||||||
console.log(`Successfully imported: ${scrapedStory.title} by ${scrapedStory.author} (ID: ${createdStory.id})`);
|
console.log(`Successfully imported: ${scrapedStory.title} by ${scrapedStory.author} (ID: ${createdStory.id})${hasImages ? ` with ${imageProcessingWarnings.length > 0 ? 'warnings' : 'successful image processing'}` : ''}`);
|
||||||
|
|
||||||
// Send progress update for successful import
|
// Send progress update for successful import
|
||||||
|
let progressMessage = `Imported "${scrapedStory.title}" by ${scrapedStory.author}`;
|
||||||
|
if (hasImages) {
|
||||||
|
progressMessage += imageProcessingWarnings.length > 0 ? ' (with image warnings)' : ' (with images)';
|
||||||
|
}
|
||||||
|
|
||||||
await sendProgressUpdate(sessionId, {
|
await sendProgressUpdate(sessionId, {
|
||||||
type: 'progress',
|
type: 'progress',
|
||||||
current: i + 1,
|
current: i + 1,
|
||||||
total: urls.length,
|
total: urls.length,
|
||||||
message: `Imported "${scrapedStory.title}" by ${scrapedStory.author}`,
|
message: progressMessage,
|
||||||
url: trimmedUrl,
|
url: trimmedUrl,
|
||||||
title: scrapedStory.title,
|
title: scrapedStory.title,
|
||||||
author: scrapedStory.author
|
author: scrapedStory.author,
|
||||||
|
hasImages: hasImages,
|
||||||
|
imageWarnings: imageProcessingWarnings
|
||||||
});
|
});
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -19,6 +19,9 @@ export async function POST(request: NextRequest) {
|
|||||||
const scraper = new StoryScraper();
|
const scraper = new StoryScraper();
|
||||||
const story = await scraper.scrapeStory(url);
|
const story = await scraper.scrapeStory(url);
|
||||||
|
|
||||||
|
// Check if scraped content contains embedded images
|
||||||
|
const hasImages = story.content ? /<img[^>]+src=['"'][^'"']*['"][^>]*>/i.test(story.content) : false;
|
||||||
|
|
||||||
// Debug logging
|
// Debug logging
|
||||||
console.log('Scraped story data:', {
|
console.log('Scraped story data:', {
|
||||||
url: url,
|
url: url,
|
||||||
@@ -28,10 +31,15 @@ export async function POST(request: NextRequest) {
|
|||||||
contentLength: story.content?.length || 0,
|
contentLength: story.content?.length || 0,
|
||||||
contentPreview: story.content?.substring(0, 200) + '...',
|
contentPreview: story.content?.substring(0, 200) + '...',
|
||||||
tags: story.tags,
|
tags: story.tags,
|
||||||
coverImage: story.coverImage
|
coverImage: story.coverImage,
|
||||||
|
hasEmbeddedImages: hasImages
|
||||||
});
|
});
|
||||||
|
|
||||||
return NextResponse.json(story);
|
// Add image processing flag to response for frontend handling
|
||||||
|
return NextResponse.json({
|
||||||
|
...story,
|
||||||
|
hasEmbeddedImages: hasImages
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Story scraping error:', error);
|
console.error('Story scraping error:', error);
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { useState, useEffect } from 'react';
|
|||||||
import AppLayout from '../../components/layout/AppLayout';
|
import AppLayout from '../../components/layout/AppLayout';
|
||||||
import { useTheme } from '../../lib/theme';
|
import { useTheme } from '../../lib/theme';
|
||||||
import Button from '../../components/ui/Button';
|
import Button from '../../components/ui/Button';
|
||||||
import { storyApi, authorApi, databaseApi } from '../../lib/api';
|
import { storyApi, authorApi, databaseApi, configApi } from '../../lib/api';
|
||||||
import { useLibraryLayout, LibraryLayoutType } from '../../hooks/useLibraryLayout';
|
import { useLibraryLayout, LibraryLayoutType } from '../../hooks/useLibraryLayout';
|
||||||
import LibrarySettings from '../../components/library/LibrarySettings';
|
import LibrarySettings from '../../components/library/LibrarySettings';
|
||||||
|
|
||||||
@@ -51,6 +51,13 @@ export default function SettingsPage() {
|
|||||||
completeRestore: { loading: false, message: '' },
|
completeRestore: { loading: false, message: '' },
|
||||||
completeClear: { loading: false, message: '' }
|
completeClear: { loading: false, message: '' }
|
||||||
});
|
});
|
||||||
|
const [cleanupStatus, setCleanupStatus] = 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: '' }
|
||||||
|
});
|
||||||
|
|
||||||
// Load settings from localStorage on mount
|
// Load settings from localStorage on mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -310,6 +317,122 @@ export default function SettingsPage() {
|
|||||||
}, 10000);
|
}, 10000);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleImageCleanupPreview = async () => {
|
||||||
|
setCleanupStatus(prev => ({
|
||||||
|
...prev,
|
||||||
|
preview: { loading: true, message: 'Scanning for orphaned images...', success: undefined }
|
||||||
|
}));
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await configApi.previewImageCleanup();
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
setCleanupStatus(prev => ({
|
||||||
|
...prev,
|
||||||
|
preview: {
|
||||||
|
loading: false,
|
||||||
|
message: `Found ${result.orphanedCount} orphaned images (${result.formattedSize}) and ${result.foldersToDelete} empty folders. Referenced images: ${result.referencedImagesCount}`,
|
||||||
|
success: true,
|
||||||
|
data: result
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
} else {
|
||||||
|
setCleanupStatus(prev => ({
|
||||||
|
...prev,
|
||||||
|
preview: {
|
||||||
|
loading: false,
|
||||||
|
message: result.error || 'Preview failed',
|
||||||
|
success: false
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
setCleanupStatus(prev => ({
|
||||||
|
...prev,
|
||||||
|
preview: {
|
||||||
|
loading: false,
|
||||||
|
message: error.message || 'Network error occurred',
|
||||||
|
success: false
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear message after 10 seconds
|
||||||
|
setTimeout(() => {
|
||||||
|
setCleanupStatus(prev => ({
|
||||||
|
...prev,
|
||||||
|
preview: { loading: false, message: '', success: undefined }
|
||||||
|
}));
|
||||||
|
}, 10000);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleImageCleanupExecute = async () => {
|
||||||
|
if (!cleanupStatus.preview.data || cleanupStatus.preview.data.orphanedCount === 0) {
|
||||||
|
setCleanupStatus(prev => ({
|
||||||
|
...prev,
|
||||||
|
execute: {
|
||||||
|
loading: false,
|
||||||
|
message: 'Please run preview first to see what will be deleted',
|
||||||
|
success: false
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const confirmed = window.confirm(
|
||||||
|
`Are you sure you want to delete ${cleanupStatus.preview.data.orphanedCount} orphaned images (${cleanupStatus.preview.data.formattedSize})? This action cannot be undone!`
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!confirmed) return;
|
||||||
|
|
||||||
|
setCleanupStatus(prev => ({
|
||||||
|
...prev,
|
||||||
|
execute: { loading: true, message: 'Deleting orphaned images...', success: undefined }
|
||||||
|
}));
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await configApi.executeImageCleanup();
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
setCleanupStatus(prev => ({
|
||||||
|
...prev,
|
||||||
|
execute: {
|
||||||
|
loading: false,
|
||||||
|
message: `Successfully deleted ${result.deletedCount} orphaned images (${result.formattedSize}) and ${result.foldersDeleted} empty folders`,
|
||||||
|
success: true
|
||||||
|
},
|
||||||
|
preview: { loading: false, message: '', success: undefined, data: undefined } // Clear preview after successful cleanup
|
||||||
|
}));
|
||||||
|
} else {
|
||||||
|
setCleanupStatus(prev => ({
|
||||||
|
...prev,
|
||||||
|
execute: {
|
||||||
|
loading: false,
|
||||||
|
message: result.error || 'Cleanup failed',
|
||||||
|
success: false
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
setCleanupStatus(prev => ({
|
||||||
|
...prev,
|
||||||
|
execute: {
|
||||||
|
loading: false,
|
||||||
|
message: error.message || 'Network error occurred',
|
||||||
|
success: false
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear message after 10 seconds
|
||||||
|
setTimeout(() => {
|
||||||
|
setCleanupStatus(prev => ({
|
||||||
|
...prev,
|
||||||
|
execute: { loading: false, message: '', success: undefined }
|
||||||
|
}));
|
||||||
|
}, 10000);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AppLayout>
|
<AppLayout>
|
||||||
<div className="max-w-2xl mx-auto space-y-8">
|
<div className="max-w-2xl mx-auto space-y-8">
|
||||||
@@ -670,6 +793,109 @@ export default function SettingsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Storage Management */}
|
||||||
|
<div className="theme-card theme-shadow rounded-lg p-6">
|
||||||
|
<h2 className="text-xl font-semibold theme-header mb-4">Storage Management</h2>
|
||||||
|
<p className="theme-text mb-6">
|
||||||
|
Clean up orphaned content images that are no longer referenced in any story. This can help free up disk space.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Image Cleanup Section */}
|
||||||
|
<div className="border theme-border rounded-lg p-4">
|
||||||
|
<h3 className="text-lg font-semibold theme-header mb-3">🖼️ Content Images Cleanup</h3>
|
||||||
|
<p className="text-sm theme-text mb-4">
|
||||||
|
Scan for and remove orphaned content images that are no longer referenced in any story content. This includes images from deleted stories and unused downloaded images.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="flex flex-col sm:flex-row gap-3 mb-3">
|
||||||
|
<Button
|
||||||
|
onClick={handleImageCleanupPreview}
|
||||||
|
disabled={cleanupStatus.preview.loading}
|
||||||
|
loading={cleanupStatus.preview.loading}
|
||||||
|
variant="ghost"
|
||||||
|
className="flex-1"
|
||||||
|
>
|
||||||
|
{cleanupStatus.preview.loading ? 'Scanning...' : 'Preview Cleanup'}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleImageCleanupExecute}
|
||||||
|
disabled={cleanupStatus.execute.loading || !cleanupStatus.preview.data || cleanupStatus.preview.data.orphanedCount === 0}
|
||||||
|
loading={cleanupStatus.execute.loading}
|
||||||
|
variant="secondary"
|
||||||
|
className="flex-1"
|
||||||
|
>
|
||||||
|
{cleanupStatus.execute.loading ? 'Cleaning...' : 'Execute Cleanup'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Preview Results */}
|
||||||
|
{cleanupStatus.preview.message && (
|
||||||
|
<div className={`text-sm p-3 rounded mb-3 ${
|
||||||
|
cleanupStatus.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'
|
||||||
|
}`}>
|
||||||
|
{cleanupStatus.preview.message}
|
||||||
|
{cleanupStatus.preview.data && cleanupStatus.preview.data.hasErrors && (
|
||||||
|
<div className="mt-2 text-xs">
|
||||||
|
<details>
|
||||||
|
<summary className="cursor-pointer font-medium">View Errors ({cleanupStatus.preview.data.errors.length})</summary>
|
||||||
|
<ul className="mt-1 ml-4 space-y-1">
|
||||||
|
{cleanupStatus.preview.data.errors.map((error: string, index: number) => (
|
||||||
|
<li key={index} className="text-red-600 dark:text-red-400">• {error}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Execute Results */}
|
||||||
|
{cleanupStatus.execute.message && (
|
||||||
|
<div className={`text-sm p-3 rounded mb-3 ${
|
||||||
|
cleanupStatus.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'
|
||||||
|
}`}>
|
||||||
|
{cleanupStatus.execute.message}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Detailed Preview Information */}
|
||||||
|
{cleanupStatus.preview.data && cleanupStatus.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">
|
||||||
|
<div>
|
||||||
|
<span className="font-medium">Orphaned Images:</span> {cleanupStatus.preview.data.orphanedCount}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="font-medium">Total Size:</span> {cleanupStatus.preview.data.formattedSize}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="font-medium">Empty Folders:</span> {cleanupStatus.preview.data.foldersToDelete}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="font-medium">Referenced Images:</span> {cleanupStatus.preview.data.referencedImagesCount}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-sm theme-text bg-blue-50 dark:bg-blue-900/20 p-3 rounded-lg">
|
||||||
|
<p className="font-medium mb-1">📝 How it works:</p>
|
||||||
|
<ul className="text-xs space-y-1 ml-4">
|
||||||
|
<li>• <strong>Preview:</strong> Scans all stories to find images no longer referenced in content</li>
|
||||||
|
<li>• <strong>Execute:</strong> Permanently deletes orphaned images and empty story directories</li>
|
||||||
|
<li>• <strong>Safe:</strong> Only removes images not found in any story content</li>
|
||||||
|
<li>• <strong>Backup recommended:</strong> Consider backing up before large cleanups</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Database Management */}
|
{/* Database Management */}
|
||||||
<div className="theme-card theme-shadow rounded-lg p-6">
|
<div className="theme-card theme-shadow rounded-lg p-6">
|
||||||
<h2 className="text-xl font-semibold theme-header mb-4">Database Management</h2>
|
<h2 className="text-xl font-semibold theme-header mb-4">Database Management</h2>
|
||||||
|
|||||||
@@ -577,6 +577,38 @@ export const configApi = {
|
|||||||
const response = await api.get('/config/html-sanitization');
|
const response = await api.get('/config/html-sanitization');
|
||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
previewImageCleanup: async (): Promise<{
|
||||||
|
success: boolean;
|
||||||
|
orphanedCount: number;
|
||||||
|
totalSizeBytes: number;
|
||||||
|
formattedSize: string;
|
||||||
|
foldersToDelete: number;
|
||||||
|
referencedImagesCount: number;
|
||||||
|
errors: string[];
|
||||||
|
hasErrors: boolean;
|
||||||
|
dryRun: boolean;
|
||||||
|
error?: string;
|
||||||
|
}> => {
|
||||||
|
const response = await api.post('/config/cleanup/images/preview');
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
executeImageCleanup: async (): Promise<{
|
||||||
|
success: boolean;
|
||||||
|
deletedCount: number;
|
||||||
|
totalSizeBytes: number;
|
||||||
|
formattedSize: string;
|
||||||
|
foldersDeleted: number;
|
||||||
|
referencedImagesCount: number;
|
||||||
|
errors: string[];
|
||||||
|
hasErrors: boolean;
|
||||||
|
dryRun: boolean;
|
||||||
|
error?: string;
|
||||||
|
}> => {
|
||||||
|
const response = await api.post('/config/cleanup/images/execute');
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
// Collection endpoints
|
// Collection endpoints
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user