diff --git a/backend/src/main/java/com/storycove/controller/StoryController.java b/backend/src/main/java/com/storycove/controller/StoryController.java index 0632fa1..fa2d9fa 100644 --- a/backend/src/main/java/com/storycove/controller/StoryController.java +++ b/backend/src/main/java/com/storycove/controller/StoryController.java @@ -228,6 +228,38 @@ public class StoryController { Story story = storyService.updateReadingStatus(id, request.getIsRead()); return ResponseEntity.ok(convertToDto(story)); } + + @PostMapping("/{id}/process-content-images") + public ResponseEntity> processContentImages(@PathVariable UUID id, @RequestBody ProcessContentImagesRequest request) { + logger.info("Processing content images for story {}", id); + + try { + // Process the HTML content to download and replace image URLs + ImageService.ContentImageProcessingResult result = imageService.processContentImages(request.getHtmlContent(), id); + + // If there are warnings, let the client decide whether to proceed + if (result.hasWarnings()) { + return ResponseEntity.ok(Map.of( + "processedContent", result.getProcessedContent(), + "warnings", result.getWarnings(), + "downloadedImages", result.getDownloadedImages(), + "hasWarnings", true + )); + } + + // Success - no warnings + return ResponseEntity.ok(Map.of( + "processedContent", result.getProcessedContent(), + "downloadedImages", result.getDownloadedImages(), + "hasWarnings", false + )); + + } catch (Exception e) { + logger.error("Failed to process content images for story {}", id, e); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) + .body(Map.of("error", "Failed to process content images: " + e.getMessage())); + } + } @PostMapping("/reindex") public ResponseEntity manualReindex() { @@ -458,7 +490,14 @@ public class StoryController { story.setDescription(updateReq.getDescription()); } if (updateReq.getContentHtml() != null) { - story.setContentHtml(sanitizationService.sanitize(updateReq.getContentHtml())); + logger.info("Content before sanitization (length: {}): {}", + updateReq.getContentHtml().length(), + updateReq.getContentHtml().substring(0, Math.min(500, updateReq.getContentHtml().length()))); + String sanitizedContent = sanitizationService.sanitize(updateReq.getContentHtml()); + logger.info("Content after sanitization (length: {}): {}", + sanitizedContent.length(), + sanitizedContent.substring(0, Math.min(500, sanitizedContent.length()))); + story.setContentHtml(sanitizedContent); } if (updateReq.getSourceUrl() != null) { story.setSourceUrl(updateReq.getSourceUrl()); diff --git a/backend/src/main/java/com/storycove/dto/ProcessContentImagesRequest.java b/backend/src/main/java/com/storycove/dto/ProcessContentImagesRequest.java new file mode 100644 index 0000000..c972092 --- /dev/null +++ b/backend/src/main/java/com/storycove/dto/ProcessContentImagesRequest.java @@ -0,0 +1,23 @@ +package com.storycove.dto; + +import jakarta.validation.constraints.NotBlank; + +public class ProcessContentImagesRequest { + + @NotBlank(message = "HTML content is required") + private String htmlContent; + + public ProcessContentImagesRequest() {} + + public ProcessContentImagesRequest(String htmlContent) { + this.htmlContent = htmlContent; + } + + public String getHtmlContent() { + return htmlContent; + } + + public void setHtmlContent(String htmlContent) { + this.htmlContent = htmlContent; + } +} \ No newline at end of file diff --git a/backend/src/main/java/com/storycove/service/HtmlSanitizationService.java b/backend/src/main/java/com/storycove/service/HtmlSanitizationService.java index 2a5c227..b984c71 100644 --- a/backend/src/main/java/com/storycove/service/HtmlSanitizationService.java +++ b/backend/src/main/java/com/storycove/service/HtmlSanitizationService.java @@ -54,7 +54,7 @@ public class HtmlSanitizationService { "p", "br", "div", "span", "h1", "h2", "h3", "h4", "h5", "h6", "b", "strong", "i", "em", "u", "s", "strike", "del", "ins", "sup", "sub", "small", "big", "mark", "pre", "code", - "ul", "ol", "li", "dl", "dt", "dd", "a", + "ul", "ol", "li", "dl", "dt", "dd", "a", "img", "table", "thead", "tbody", "tfoot", "tr", "th", "td", "caption", "blockquote", "cite", "q", "hr" )); @@ -65,13 +65,13 @@ public class HtmlSanitizationService { } private void createSafelist() { - this.allowlist = new Safelist(); - + this.allowlist = Safelist.relaxed(); + // Add allowed tags if (config.getAllowedTags() != null) { config.getAllowedTags().forEach(allowlist::addTags); } - + // Add allowed attributes if (config.getAllowedAttributes() != null) { for (Map.Entry> entry : config.getAllowedAttributes().entrySet()) { @@ -82,25 +82,33 @@ public class HtmlSanitizationService { } } } - - // Configure allowed protocols for specific attributes (e.g., href) + + // Special handling for img tags - allow all src attributes and validate later + allowlist.removeProtocols("img", "src", "http", "https"); + // This is the key: preserve relative URLs by not restricting them + allowlist.preserveRelativeLinks(true); + + // Configure allowed protocols for other attributes if (config.getAllowedProtocols() != null) { for (Map.Entry>> tagEntry : config.getAllowedProtocols().entrySet()) { String tag = tagEntry.getKey(); Map> attributeProtocols = tagEntry.getValue(); - + if (attributeProtocols != null) { for (Map.Entry> attrEntry : attributeProtocols.entrySet()) { String attribute = attrEntry.getKey(); List protocols = attrEntry.getValue(); - - if (protocols != null) { + + if (protocols != null && !("img".equals(tag) && "src".equals(attribute))) { + // Skip img src since we handled it above allowlist.addProtocols(tag, attribute, protocols.toArray(new String[0])); } } } } } + + logger.info("Configured Jsoup Safelist with preserveRelativeLinks=true for local image URLs"); // Remove specific attributes if needed (deprecated in favor of protocol control) if (config.getRemovedAttributes() != null) { @@ -133,8 +141,10 @@ public class HtmlSanitizationService { if (html == null || html.trim().isEmpty()) { return ""; } - - return Jsoup.clean(html, allowlist); + logger.info("Content before sanitization: "+html); + String saniztedHtml = Jsoup.clean(html, allowlist.preserveRelativeLinks(true)); + logger.info("Content after sanitization: "+saniztedHtml); + return saniztedHtml; } public String extractPlainText(String html) { diff --git a/backend/src/main/java/com/storycove/service/ImageService.java b/backend/src/main/java/com/storycove/service/ImageService.java index 0f31d4e..be13aa5 100644 --- a/backend/src/main/java/com/storycove/service/ImageService.java +++ b/backend/src/main/java/com/storycove/service/ImageService.java @@ -1,5 +1,7 @@ package com.storycove.service; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; @@ -8,18 +10,22 @@ import org.springframework.web.multipart.MultipartFile; import javax.imageio.ImageIO; import java.awt.*; import java.awt.image.BufferedImage; -import java.io.ByteArrayInputStream; -import java.io.ByteArrayOutputStream; -import java.io.IOException; +import java.io.*; +import java.net.HttpURLConnection; +import java.net.URL; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; -import java.util.Set; -import java.util.UUID; +import java.util.*; +import java.util.List; +import java.util.regex.Matcher; +import java.util.regex.Pattern; @Service 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" ); @@ -53,14 +59,15 @@ public class ImageService { public enum ImageType { COVER("covers"), - AVATAR("avatars"); - + AVATAR("avatars"), + CONTENT("content"); + private final String directory; - + ImageType(String directory) { this.directory = directory; } - + public String getDirectory() { return directory; } @@ -182,6 +189,9 @@ public class ImageService { maxWidth = avatarMaxSize; maxHeight = avatarMaxSize; break; + case CONTENT: + // Content images are not resized + return new Dimension(originalWidth, originalHeight); default: return new Dimension(originalWidth, originalHeight); } @@ -228,4 +238,224 @@ public class ImageService { String extension = getFileExtension(filename); return ALLOWED_EXTENSIONS.contains(extension); } + + // Content image processing methods + + /** + * Process HTML content and download all referenced images, replacing URLs with local paths + */ + public ContentImageProcessingResult processContentImages(String htmlContent, UUID storyId) { + logger.info("Processing content images for story: {}, content length: {}", storyId, + htmlContent != null ? htmlContent.length() : 0); + + List warnings = new ArrayList<>(); + List downloadedImages = new ArrayList<>(); + + if (htmlContent == null || htmlContent.trim().isEmpty()) { + logger.info("No content to process for story: {}", storyId); + return new ContentImageProcessingResult(htmlContent, warnings, downloadedImages); + } + + // Find all img tags with src attributes + Pattern imgPattern = Pattern.compile("]+src=[\"']([^\"']+)[\"'][^>]*>", Pattern.CASE_INSENSITIVE); + Matcher matcher = imgPattern.matcher(htmlContent); + + int imageCount = 0; + int externalImageCount = 0; + + StringBuffer processedContent = new StringBuffer(); + + while (matcher.find()) { + String fullImgTag = matcher.group(0); + String imageUrl = matcher.group(1); + imageCount++; + + logger.info("Found image #{}: {} in tag: {}", imageCount, imageUrl, fullImgTag); + + try { + // Skip if it's already a local path or data URL + if (imageUrl.startsWith("/") || imageUrl.startsWith("data:")) { + logger.info("Skipping local/data URL: {}", imageUrl); + matcher.appendReplacement(processedContent, Matcher.quoteReplacement(fullImgTag)); + continue; + } + + externalImageCount++; + logger.info("Processing external image #{}: {}", externalImageCount, imageUrl); + + // Download and store the image + String localPath = downloadImageFromUrl(imageUrl, storyId); + downloadedImages.add(localPath); + + // Generate local URL + String localUrl = getLocalImageUrl(storyId, localPath); + logger.info("Downloaded image: {} -> {}", imageUrl, localUrl); + + // Replace the src attribute with the local path - handle both single and double quotes + String newImgTag = fullImgTag + .replaceFirst("src=\"" + Pattern.quote(imageUrl) + "\"", "src=\"" + localUrl + "\"") + .replaceFirst("src='" + Pattern.quote(imageUrl) + "'", "src=\"" + localUrl + "\""); + + // If replacement didn't work, try a more generic approach + if (newImgTag.equals(fullImgTag)) { + logger.warn("Standard replacement failed for image URL: {}, trying generic replacement", imageUrl); + newImgTag = fullImgTag.replaceAll("src\\s*=\\s*[\"']?" + Pattern.quote(imageUrl) + "[\"']?", "src=\"" + localUrl + "\""); + } + + logger.info("Replaced img tag: {} -> {}", fullImgTag, newImgTag); + matcher.appendReplacement(processedContent, Matcher.quoteReplacement(newImgTag)); + + } catch (Exception e) { + logger.error("Failed to download image: {} - {}", imageUrl, e.getMessage(), e); + warnings.add("Failed to download image: " + imageUrl + " - " + e.getMessage()); + // Keep original URL in case of failure + matcher.appendReplacement(processedContent, Matcher.quoteReplacement(fullImgTag)); + } + } + + matcher.appendTail(processedContent); + + logger.info("Finished processing images for story: {}. Found {} total images, {} external. Downloaded {} images, {} warnings", + storyId, imageCount, externalImageCount, downloadedImages.size(), warnings.size()); + + return new ContentImageProcessingResult(processedContent.toString(), warnings, downloadedImages); + } + + /** + * Download an image from a URL and store it locally + */ + private String downloadImageFromUrl(String imageUrl, UUID storyId) throws IOException { + URL url = new URL(imageUrl); + HttpURLConnection connection = (HttpURLConnection) url.openConnection(); + + // Set a reasonable user agent to avoid blocks + connection.setRequestProperty("User-Agent", "Mozilla/5.0 (StoryCove Image Processor)"); + connection.setConnectTimeout(30000); // 30 seconds + connection.setReadTimeout(30000); + + try (InputStream inputStream = connection.getInputStream()) { + // Get content type to determine file extension + String contentType = connection.getContentType(); + String extension = getExtensionFromContentType(contentType); + + if (extension == null) { + // Try to extract from URL + extension = getExtensionFromUrl(imageUrl); + } + + if (extension == null || !ALLOWED_EXTENSIONS.contains(extension.toLowerCase())) { + throw new IllegalArgumentException("Unsupported image format: " + contentType); + } + + // Create directories for content images + Path contentDir = Paths.get(getUploadDir(), ImageType.CONTENT.getDirectory(), storyId.toString()); + Files.createDirectories(contentDir); + + // Generate unique filename + String filename = UUID.randomUUID().toString() + "." + extension.toLowerCase(); + Path filePath = contentDir.resolve(filename); + + // Read and validate the image + byte[] imageData = inputStream.readAllBytes(); + ByteArrayInputStream bais = new ByteArrayInputStream(imageData); + BufferedImage image = ImageIO.read(bais); + + if (image == null) { + throw new IOException("Invalid image format"); + } + + // Save the image + Files.write(filePath, imageData); + + // Return relative path + return ImageType.CONTENT.getDirectory() + "/" + storyId.toString() + "/" + filename; + + } finally { + connection.disconnect(); + } + } + + /** + * Generate local image URL for serving + */ + private String getLocalImageUrl(UUID storyId, String imagePath) { + String currentLibraryId = libraryService.getCurrentLibraryId(); + if (currentLibraryId == null || currentLibraryId.trim().isEmpty()) { + logger.warn("Current library ID is null or empty when generating local image URL for story: {}", storyId); + return "/api/files/images/default/" + imagePath; + } + String localUrl = "/api/files/images/" + currentLibraryId + "/" + imagePath; + logger.info("Generated local image URL: {} for story: {}", localUrl, storyId); + return localUrl; + } + + /** + * Get file extension from content type + */ + private String getExtensionFromContentType(String contentType) { + if (contentType == null) return null; + + switch (contentType.toLowerCase()) { + case "image/jpeg": + case "image/jpg": + return "jpg"; + case "image/png": + return "png"; + default: + return null; + } + } + + /** + * Extract file extension from URL + */ + private String getExtensionFromUrl(String url) { + try { + String path = new URL(url).getPath(); + int lastDot = path.lastIndexOf('.'); + if (lastDot > 0 && lastDot < path.length() - 1) { + return path.substring(lastDot + 1).toLowerCase(); + } + } catch (Exception ignored) { + } + return null; + } + + /** + * Clean up content images for a story + */ + public void deleteContentImages(UUID storyId) { + try { + Path contentDir = Paths.get(getUploadDir(), ImageType.CONTENT.getDirectory(), storyId.toString()); + if (Files.exists(contentDir)) { + Files.walk(contentDir) + .sorted(Comparator.reverseOrder()) + .map(Path::toFile) + .forEach(java.io.File::delete); + } + } catch (IOException e) { + // Log but don't throw - this is cleanup + System.err.println("Failed to clean up content images for story " + storyId + ": " + e.getMessage()); + } + } + + /** + * Result class for content image processing + */ + public static class ContentImageProcessingResult { + private final String processedContent; + private final List warnings; + private final List downloadedImages; + + public ContentImageProcessingResult(String processedContent, List warnings, List downloadedImages) { + this.processedContent = processedContent; + this.warnings = warnings; + this.downloadedImages = downloadedImages; + } + + public String getProcessedContent() { return processedContent; } + public List getWarnings() { return warnings; } + public List getDownloadedImages() { return downloadedImages; } + public boolean hasWarnings() { return !warnings.isEmpty(); } + } } \ No newline at end of file diff --git a/backend/src/main/resources/html-sanitization-config.json b/backend/src/main/resources/html-sanitization-config.json index 5dad4e9..fc81311 100644 --- a/backend/src/main/resources/html-sanitization-config.json +++ b/backend/src/main/resources/html-sanitization-config.json @@ -4,7 +4,7 @@ "b", "strong", "i", "em", "u", "s", "strike", "del", "ins", "sup", "sub", "small", "big", "mark", "pre", "code", "kbd", "samp", "var", "ul", "ol", "li", "dl", "dt", "dd", - "a", "table", "thead", "tbody", "tfoot", "tr", "th", "td", "caption", "colgroup", "col", + "a", "img", "table", "thead", "tbody", "tfoot", "tr", "th", "td", "caption", "colgroup", "col", "blockquote", "cite", "q", "hr", "details", "summary" ], "allowedAttributes": { @@ -18,6 +18,7 @@ "h5": ["class", "style"], "h6": ["class", "style"], "a": ["class", "href", "title"], + "img": ["src", "alt", "width", "height", "class", "style"], "table": ["class", "style"], "th": ["class", "style", "colspan", "rowspan"], "td": ["class", "style", "colspan", "rowspan"], @@ -41,6 +42,9 @@ "allowedProtocols": { "a": { "href": ["http", "https", "#", "/"] + }, + "img": { + "src": ["http", "https", "data", "/", "cid"] } }, "description": "HTML sanitization configuration for StoryCove story content. This configuration is shared between frontend (DOMPurify) and backend (Jsoup) to ensure consistency." diff --git a/docker-compose.yml b/docker-compose.yml index 485f1c6..f2bd09b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -52,6 +52,8 @@ services: postgres: image: postgres:15-alpine # No port mapping - only accessible within the Docker network + ports: + - "5432:5432" environment: - POSTGRES_DB=storycove - POSTGRES_USER=storycove diff --git a/frontend/src/app/add-story/page.tsx b/frontend/src/app/add-story/page.tsx index e07942e..58bab98 100644 --- a/frontend/src/app/add-story/page.tsx +++ b/frontend/src/app/add-story/page.tsx @@ -29,6 +29,7 @@ export default function AddStoryPage() { const [coverImage, setCoverImage] = useState(null); const [loading, setLoading] = useState(false); + const [processingImages, setProcessingImages] = useState(false); const [errors, setErrors] = useState>({}); const [duplicateWarning, setDuplicateWarning] = useState<{ show: boolean; @@ -250,9 +251,28 @@ export default function AddStoryPage() { return Object.keys(newErrors).length === 0; }; + // Helper function to detect external images in HTML content + const hasExternalImages = (htmlContent: string): boolean => { + if (!htmlContent) return false; + + // Create a temporary DOM element to parse HTML + const tempDiv = document.createElement('div'); + tempDiv.innerHTML = htmlContent; + + const images = tempDiv.querySelectorAll('img'); + for (let i = 0; i < images.length; i++) { + const img = images[i]; + const src = img.getAttribute('src'); + if (src && (src.startsWith('http://') || src.startsWith('https://'))) { + return true; + } + } + return false; + }; + const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); - + if (!validateForm()) { return; } @@ -275,7 +295,43 @@ export default function AddStoryPage() { }; const story = await storyApi.createStory(storyData); - + + // Process images if there are external images in the content + if (hasExternalImages(formData.contentHtml)) { + try { + setProcessingImages(true); + const imageResult = await storyApi.processContentImages(story.id, formData.contentHtml); + + // If images were processed and content was updated, save the updated content + if (imageResult.processedContent !== formData.contentHtml) { + await storyApi.updateStory(story.id, { + title: formData.title, + summary: formData.summary || undefined, + contentHtml: imageResult.processedContent, + sourceUrl: formData.sourceUrl || undefined, + volume: formData.seriesName ? parseInt(formData.volume) : undefined, + ...(formData.seriesId ? { seriesId: formData.seriesId } : { seriesName: formData.seriesName || undefined }), + ...(formData.authorId ? { authorId: formData.authorId } : { authorName: formData.authorName }), + tagNames: formData.tags.length > 0 ? formData.tags : undefined, + }); + + // Show success message with image processing info + if (imageResult.downloadedImages.length > 0) { + console.log(`Successfully processed ${imageResult.downloadedImages.length} images`); + } + if (imageResult.warnings && imageResult.warnings.length > 0) { + console.warn('Image processing warnings:', imageResult.warnings); + } + } + } catch (imageError) { + console.error('Failed to process images:', imageError); + // Don't fail the entire operation if image processing fails + // The story was created successfully, just without processed images + } finally { + setProcessingImages(false); + } + } + // If there's a cover image, upload it separately if (coverImage) { await storyApi.uploadCover(story.id, coverImage); @@ -404,7 +460,11 @@ export default function AddStoryPage() { onChange={handleContentChange} placeholder="Write or paste your story content here..." error={errors.contentHtml} + enableImageProcessing={false} /> +

+ 💡 Tip: If you paste content with images, they'll be automatically downloaded and stored locally when you save the story. +

{/* Tags */} @@ -450,6 +510,18 @@ export default function AddStoryPage() { placeholder="https://example.com/original-story-url" /> + {/* Image Processing Indicator */} + {processingImages && ( +
+
+
+

+ Processing and downloading images... +

+
+
+ )} + {/* Submit Error */} {errors.submit && (
@@ -473,7 +545,7 @@ export default function AddStoryPage() { loading={loading} disabled={!formData.title || !formData.authorName || !formData.contentHtml} > - Add Story + {processingImages ? 'Processing Images...' : 'Add Story'}
diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css index c863ef3..4ec11b4 100644 --- a/frontend/src/app/globals.css +++ b/frontend/src/app/globals.css @@ -134,6 +134,27 @@ @apply italic; } + /* Image styling for story content */ + .reading-content img { + @apply max-w-full h-auto mx-auto my-6 rounded-lg shadow-sm; + max-height: 80vh; /* Prevent images from being too tall */ + display: block; + } + + .reading-content img[align="left"] { + @apply float-left mr-4 mb-4 ml-0; + max-width: 50%; + } + + .reading-content img[align="right"] { + @apply float-right ml-4 mb-4 mr-0; + max-width: 50%; + } + + .reading-content img[align="center"] { + @apply block mx-auto; + } + /* Editor content styling - same as reading content but for the rich text editor */ .editor-content h1 { @apply text-2xl font-bold mt-8 mb-4 theme-header; @@ -183,4 +204,36 @@ .editor-content em { @apply italic; } + + /* Image styling for editor content */ + .editor-content img { + @apply max-w-full h-auto mx-auto my-4 rounded border; + max-height: 60vh; /* Slightly smaller for editor */ + display: block; + } + + .editor-content img[align="left"] { + @apply float-left mr-4 mb-4 ml-0; + max-width: 50%; + } + + .editor-content img[align="right"] { + @apply float-right ml-4 mb-4 mr-0; + max-width: 50%; + } + + .editor-content img[align="center"] { + @apply block mx-auto; + } + + /* Loading placeholder for images being processed */ + .image-processing-placeholder { + @apply bg-gray-100 dark:bg-gray-800 animate-pulse rounded border-2 border-dashed border-gray-300 dark:border-gray-600 flex items-center justify-center; + min-height: 200px; + } + + .image-processing-placeholder::before { + content: "🖼️ Processing image..."; + @apply text-gray-500 dark:text-gray-400 text-sm; + } } \ No newline at end of file diff --git a/frontend/src/app/stories/[id]/edit/page.tsx b/frontend/src/app/stories/[id]/edit/page.tsx index bc59b66..ee7f8cd 100644 --- a/frontend/src/app/stories/[id]/edit/page.tsx +++ b/frontend/src/app/stories/[id]/edit/page.tsx @@ -342,6 +342,8 @@ export default function EditStoryPage() { onChange={handleContentChange} placeholder="Edit your story content here..." error={errors.contentHtml} + storyId={storyId} + enableImageProcessing={true} /> diff --git a/frontend/src/app/stories/[id]/page.tsx b/frontend/src/app/stories/[id]/page.tsx index 58b72c0..2441e29 100644 --- a/frontend/src/app/stories/[id]/page.tsx +++ b/frontend/src/app/stories/[id]/page.tsx @@ -120,18 +120,30 @@ export default function StoryReadingPage() { // Sanitize story content and add IDs to headings const sanitized = await sanitizeHtml(storyData.contentHtml || ''); - - // Parse and add IDs to headings for TOC functionality - const parser = new DOMParser(); - const doc = parser.parseFromString(sanitized, 'text/html'); - const headings = doc.querySelectorAll('h1, h2, h3, h4, h5, h6'); - - headings.forEach((heading, index) => { - heading.id = `heading-${index}`; - }); - - setSanitizedContent(doc.body.innerHTML); - setHasHeadings(headings.length > 0); + + // Add IDs to headings for TOC functionality using regex instead of DOMParser + // This avoids potential browser-specific sanitization that might strip src attributes + let processedContent = sanitized; + const headingMatches = processedContent.match(/]*>/gi); + let headingCount = 0; + + if (headingMatches) { + processedContent = processedContent.replace(/]*)>/gi, (match, level, attrs) => { + const headingId = `heading-${headingCount++}`; + + // Check if id attribute already exists + if (attrs.includes('id=')) { + // Replace existing id + return match.replace(/id=['"][^'"]*['"]/, `id="${headingId}"`); + } else { + // Add id attribute + return ``; + } + }); + } + + setSanitizedContent(processedContent); + setHasHeadings(headingCount > 0); // Load series stories if part of a series if (storyData.seriesId) { diff --git a/frontend/src/components/stories/RichTextEditor.tsx b/frontend/src/components/stories/RichTextEditor.tsx index bd6f93b..63c14c2 100644 --- a/frontend/src/components/stories/RichTextEditor.tsx +++ b/frontend/src/components/stories/RichTextEditor.tsx @@ -4,19 +4,24 @@ import { useState, useRef, useEffect, useCallback } from 'react'; import { Textarea } from '../ui/Input'; import Button from '../ui/Button'; import { sanitizeHtmlSync } from '../../lib/sanitization'; +import { storyApi } from '../../lib/api'; interface RichTextEditorProps { value: string; onChange: (value: string) => void; placeholder?: string; error?: string; + storyId?: string; // Optional - for image processing (undefined for new stories) + enableImageProcessing?: boolean; // Enable background image processing } -export default function RichTextEditor({ - value, - onChange, +export default function RichTextEditor({ + value, + onChange, placeholder = 'Write your story here...', - error + error, + storyId, + enableImageProcessing = false }: RichTextEditorProps) { const [viewMode, setViewMode] = useState<'visual' | 'html'>('visual'); const [htmlValue, setHtmlValue] = useState(value); @@ -28,6 +33,12 @@ export default function RichTextEditor({ const containerRef = useRef(null); const [isUserTyping, setIsUserTyping] = useState(false); + // Image processing state + const [imageProcessingQueue, setImageProcessingQueue] = useState([]); + const [processedImages, setProcessedImages] = useState>(new Set()); + const [imageWarnings, setImageWarnings] = useState([]); + const imageProcessingTimeoutRef = useRef(null); + // Utility functions for cursor position preservation const saveCursorPosition = () => { const selection = window.getSelection(); @@ -63,6 +74,82 @@ export default function RichTextEditor({ } }; + // Image processing functionality + const findImageUrlsInHtml = (html: string): string[] => { + const imgRegex = /]+src=["']([^"']+)["'][^>]*>/gi; + const urls: string[] = []; + let match; + while ((match = imgRegex.exec(html)) !== null) { + const url = match[1]; + // Skip local URLs and data URLs + if (!url.startsWith('/') && !url.startsWith('data:')) { + urls.push(url); + } + } + return urls; + }; + + const processContentImagesDebounced = useCallback(async (content: string) => { + if (!enableImageProcessing || !storyId) return; + + const imageUrls = findImageUrlsInHtml(content); + if (imageUrls.length === 0) return; + + // Find new URLs that haven't been processed yet + const newUrls = imageUrls.filter(url => !processedImages.has(url)); + if (newUrls.length === 0) return; + + // Add to processing queue + setImageProcessingQueue(prev => [...prev, ...newUrls]); + + try { + // Call the API to process images + const result = await storyApi.processContentImages(storyId, content); + + // Mark URLs as processed + setProcessedImages(prev => new Set([...Array.from(prev), ...newUrls])); + + // Remove from processing queue + setImageProcessingQueue(prev => prev.filter(url => !newUrls.includes(url))); + + // Update content with processed images + if (result.processedContent !== content) { + onChange(result.processedContent); + setHtmlValue(result.processedContent); + } + + // Handle warnings + if (result.hasWarnings && result.warnings) { + setImageWarnings(prev => [...prev, ...result.warnings!]); + // Show brief warning notification - could be enhanced with a toast system + console.warn('Image processing warnings:', result.warnings); + } + + } catch (error) { + console.error('Failed to process content images:', error); + // Remove failed URLs from queue + setImageProcessingQueue(prev => prev.filter(url => !newUrls.includes(url))); + + // Show error message - could be enhanced with user notification + const errorMessage = error instanceof Error ? error.message : String(error); + setImageWarnings(prev => [...prev, `Failed to process some images: ${errorMessage}`]); + } + }, [enableImageProcessing, storyId, processedImages, onChange]); + + const triggerImageProcessing = useCallback((content: string) => { + if (!enableImageProcessing || !storyId) return; + + // Clear existing timeout + if (imageProcessingTimeoutRef.current) { + clearTimeout(imageProcessingTimeoutRef.current); + } + + // Set new timeout to process after user stops typing + imageProcessingTimeoutRef.current = setTimeout(() => { + processContentImagesDebounced(content); + }, 2000); // Wait 2 seconds after user stops typing + }, [enableImageProcessing, storyId, processContentImagesDebounced]); + // Maximize/minimize functionality const toggleMaximize = () => { if (!isMaximized) { @@ -120,9 +207,13 @@ export default function RichTextEditor({ // Update the state setIsUserTyping(true); - onChange(visualDiv.innerHTML); - setHtmlValue(visualDiv.innerHTML); + const newContent = visualDiv.innerHTML; + onChange(newContent); + setHtmlValue(newContent); setTimeout(() => setIsUserTyping(false), 100); + + // Trigger image processing if enabled + triggerImageProcessing(newContent); } } else { // HTML mode - existing logic with improvements @@ -244,6 +335,15 @@ export default function RichTextEditor({ }; }, [isMaximized, formatText]); + // Cleanup image processing timeout on unmount + useEffect(() => { + return () => { + if (imageProcessingTimeoutRef.current) { + clearTimeout(imageProcessingTimeoutRef.current); + } + }; + }, []); + // Set initial content when component mounts useEffect(() => { const div = visualDivRef.current; @@ -284,6 +384,8 @@ export default function RichTextEditor({ if (newHtml !== value) { onChange(newHtml); setHtmlValue(newHtml); + // Trigger image processing if enabled + triggerImageProcessing(newHtml); } // Reset typing state after a short delay @@ -357,13 +459,38 @@ export default function RichTextEditor({ if (htmlContent && htmlContent.trim().length > 0) { console.log('Processing HTML content...'); console.log('Raw HTML:', htmlContent.substring(0, 500)); - - const sanitizedHtml = sanitizeHtmlSync(htmlContent); + + // Check if we have embedded images and image processing is enabled + const hasImages = /]+src=['"'][^'"']*['"][^>]*>/i.test(htmlContent); + let processedHtml = htmlContent; + + if (hasImages && enableImageProcessing && storyId) { + console.log('Found images in pasted content, processing before sanitization...'); + try { + // Process images synchronously before sanitization + const result = await storyApi.processContentImages(storyId, htmlContent); + processedHtml = result.processedContent; + console.log('Image processing completed, processed content length:', processedHtml.length); + + // Update image processing state + if (result.downloadedImages && result.downloadedImages.length > 0) { + setProcessedImages(prev => new Set([...Array.from(prev), ...result.downloadedImages])); + } + if (result.warnings && result.warnings.length > 0) { + setImageWarnings(prev => [...prev, ...result.warnings!]); + } + } catch (error) { + console.error('Image processing failed during paste:', error); + // Continue with original content if image processing fails + } + } + + const sanitizedHtml = sanitizeHtmlSync(processedHtml); console.log('Sanitized HTML length:', sanitizedHtml.length); console.log('Sanitized HTML preview:', sanitizedHtml.substring(0, 500)); - + // Check if sanitization removed too much content - const ratio = sanitizedHtml.length / htmlContent.length; + const ratio = sanitizedHtml.length / processedHtml.length; console.log('Sanitization ratio (kept/original):', ratio.toFixed(3)); if (ratio < 0.1) { console.warn('Sanitization removed >90% of content - this might be too aggressive'); @@ -404,9 +531,12 @@ export default function RichTextEditor({ // Update the state setIsUserTyping(true); - onChange(visualDiv.innerHTML); - setHtmlValue(visualDiv.innerHTML); + const newContent = visualDiv.innerHTML; + onChange(newContent); + setHtmlValue(newContent); setTimeout(() => setIsUserTyping(false), 100); + + // Note: Image processing already completed during paste, no need to trigger again } else if (textarea) { // Fallback for textarea mode (shouldn't happen in visual mode but good to have) const start = textarea.selectionStart; @@ -493,6 +623,9 @@ export default function RichTextEditor({ const html = e.target.value; setHtmlValue(html); onChange(html); + + // Trigger image processing if enabled + triggerImageProcessing(html); }; const getPlainText = (html: string): string => { @@ -532,6 +665,24 @@ export default function RichTextEditor({
+ {/* Image processing status indicator */} + {enableImageProcessing && ( + <> + {imageProcessingQueue.length > 0 && ( +
+
+ Processing {imageProcessingQueue.length} image{imageProcessingQueue.length > 1 ? 's' : ''}... +
+ )} + {imageWarnings.length > 0 && ( +
+ ⚠️ + {imageWarnings.length} warning{imageWarnings.length > 1 ? 's' : ''} +
+ )} + + )} +