From 5c6e8f215de6366e5e90d3bcf18557376390ef7e Mon Sep 17 00:00:00 2001 From: Gamosoft Date: Sun, 18 Jan 2026 17:32:22 +0100 Subject: [PATCH] added options to preview/export --- backend/export.py | 74 ++++++++++++++++++++++++++--------------------- frontend/app.js | 56 ++++++++++++++++++++++++++++++++++- 2 files changed, 96 insertions(+), 34 deletions(-) diff --git a/backend/export.py b/backend/export.py index 34cfd4a..04c8836 100644 --- a/backend/export.py +++ b/backend/export.py @@ -143,25 +143,22 @@ def find_image_in_attachments(image_name: str, note_folder: Path, notes_dir: Pat return find_media_in_attachments(image_name, note_folder, notes_dir) -def generate_media_html(base64_url: str, media_type: str, alt_text: str) -> str: - """Generate appropriate HTML for embedded media based on type.""" - safe_alt = alt_text.replace('"', '"') +def generate_media_placeholder(media_type: str, alt_text: str) -> str: + """Generate a placeholder for non-embeddable media (audio, video, PDF).""" + safe_alt = alt_text.replace('"', '"').replace('<', '<').replace('>', '>') - if media_type == 'audio': - return f'''
- -{safe_alt} + icons = {'audio': '🎵', 'video': '🎬', 'document': '📄'} + labels = {'audio': 'Audio file', 'video': 'Video file', 'document': 'PDF document'} + icon = icons.get(media_type, '📎') + label = labels.get(media_type, 'Media file') + + return f'''
+{icon} +
+
{safe_alt}
+
{label} — not available in exported view
+
''' - elif media_type == 'video': - return f'''
- -
''' - elif media_type == 'document': - return f'''
- -
''' - else: # image - return f'![{alt_text}]({base64_url})' def embed_media_as_base64(markdown_content: str, note_folder: Path, notes_dir: Path) -> str: @@ -182,19 +179,23 @@ def embed_media_as_base64(markdown_content: str, note_folder: Path, notes_dir: P media_name = match.group(1).strip() alt_text = match.group(2).strip() if match.group(2) else media_name.split('/')[-1].rsplit('.', 1)[0] - # Find the media file + # Check media type first + media_type = get_media_type(media_name) + + # For non-image media (audio, video, PDF), show placeholder without embedding + if media_type in ('audio', 'video', 'document'): + return generate_media_placeholder(media_type, alt_text) + + # For images, embed as base64 resolved_path = find_media_in_attachments(media_name, note_folder, notes_dir) if resolved_path: - result = get_media_as_base64(resolved_path) - if result: - base64_url, media_type = result - return generate_media_html(base64_url, media_type, alt_text) + base64_url = get_image_as_base64(resolved_path) + if base64_url: + return f'![{alt_text}]({base64_url})' - # Media not found, convert to placeholder - media_type = get_media_type(media_name) - icon = '🎵' if media_type == 'audio' else '🎬' if media_type == 'video' else '📄' if media_type == 'document' else '🖼️' - return f'{icon} {alt_text}' + # Image not found + return f'🖼️ {alt_text}' markdown_content = re.sub(wikilink_pattern, replace_wikilink_media, markdown_content) @@ -213,6 +214,15 @@ def embed_media_as_base64(markdown_content: str, note_folder: Path, notes_dir: P if not media_path: return match.group(0) + # Check media type first + media_type = get_media_type(media_path) + display_alt = alt_text or Path(media_path).stem + + # For non-image media (audio, video, PDF), show placeholder without embedding + if media_type in ('audio', 'video', 'document'): + return generate_media_placeholder(media_type, display_alt) + + # For images, proceed with base64 embedding # Handle /api/media/ or legacy /api/images/ paths (convert to filesystem paths) if media_path.startswith('/api/media/'): relative_path = media_path[len('/api/media/'):] @@ -240,14 +250,12 @@ def embed_media_as_base64(markdown_content: str, note_folder: Path, notes_dir: P # Path is outside notes_dir, skip return match.group(0) - # Get base64 data - result = get_media_as_base64(resolved_path) - if result: - base64_url, media_type = result - display_alt = alt_text or resolved_path.stem - return generate_media_html(base64_url, media_type, display_alt) + # Get base64 data for image + base64_url = get_image_as_base64(resolved_path) + if base64_url: + return f'![{display_alt}]({base64_url})' - # Media not found, keep original + # Image not found, keep original return match.group(0) markdown_content = re.sub(img_pattern, replace_media, markdown_content) diff --git a/frontend/app.js b/frontend/app.js index ec63ceb..503e0db 100644 --- a/frontend/app.js +++ b/frontend/app.js @@ -2711,6 +2711,7 @@ function noteApp() { this.currentNote = notePath; this._lastRenderedContent = ''; // Clear render cache for new note this._cachedRenderedHTML = ''; + this._initializedVideoSources = new Set(); // Clear video cache for new note this.noteContent = data.content; this.currentNoteName = notePath.split('/').pop().replace('.md', ''); this.currentMedia = ''; // Clear image viewer when loading a note @@ -4188,6 +4189,17 @@ function noteApp() { this.addCopyButtonToCodeBlock(pre); } }); + + // Enable video metadata loading (for first frame preview) + // Track by source URL to prevent duplicate requests on re-renders + if (!this._initializedVideoSources) this._initializedVideoSources = new Set(); + previewEl.querySelectorAll('video[preload="none"]').forEach((video) => { + const src = video.getAttribute('src'); + if (src && !this._initializedVideoSources.has(src)) { + this._initializedVideoSources.add(src); + video.preload = 'metadata'; + } + }); } }, 0); @@ -4969,12 +4981,54 @@ function noteApp() { // Get current rendered HTML (this already has markdown converted and will have LaTeX delimiters) let renderedHTML = this.renderedMarkdown; + // Convert non-image media (audio, video, PDF) to placeholders first + // These shouldn't be embedded as base64 (too large) + // Use CSS variables with fallbacks for theme-aware styling (matches backend export.py) + const mediaPlaceholder = (type, name) => { + const icons = { audio: '🎵', video: '🎬', document: '📄' }; + const labels = { audio: 'Audio file', video: 'Video file', document: 'PDF document' }; + const icon = icons[type] || '📎'; + const label = labels[type] || 'Media file'; + return `
+${icon} +
+
${name}
+
${label} — not available in exported view
+
+
`; + }; + + // Replace audio embeds with placeholders + renderedHTML = renderedHTML.replace( + /
.*?]*src="[^"]*\/([^"\/]+)"[^>]*>.*?<\/div>/gs, + (match, filename) => mediaPlaceholder('audio', decodeURIComponent(filename).replace(/\.[^.]+$/, '')) + ); + + // Replace video embeds with placeholders + renderedHTML = renderedHTML.replace( + /
.*?]*src="[^"]*\/([^"\/]+)"[^>]*>.*?<\/div>/gs, + (match, filename) => mediaPlaceholder('video', decodeURIComponent(filename).replace(/\.[^.]+$/, '')) + ); + + // Replace PDF embeds with placeholders + renderedHTML = renderedHTML.replace( + /
.*?]*src="[^"]*\/([^"\/]+)"[^>]*>.*?<\/div>/gs, + (match, filename) => mediaPlaceholder('document', decodeURIComponent(filename).replace(/\.[^.]+$/, '')) + ); + // Embed local images as base64 for fully self-contained HTML - const imgRegex = /src="\/api\/images\/([^"]+)"/g; + // Handle both /api/media/ and legacy /api/images/ paths + const imgRegex = /src="\/api\/(?:media|images)\/([^"]+)"/g; const imgMatches = [...renderedHTML.matchAll(imgRegex)]; for (const match of imgMatches) { const encodedPath = match[1]; + // Skip non-image files (already handled above) + const ext = encodedPath.split('.').pop().toLowerCase(); + if (!['jpg', 'jpeg', 'png', 'gif', 'webp'].includes(ext)) { + continue; + } + try { // Fetch the image const imgResponse = await fetch(`/api/media/${encodedPath}`);