diff --git a/e2e/image-upload.spec.ts b/e2e/image-upload.spec.ts new file mode 100644 index 00000000..0781a729 --- /dev/null +++ b/e2e/image-upload.spec.ts @@ -0,0 +1,96 @@ +import { test, expect } from '@playwright/test' +import * as path from 'path' +import * as fs from 'fs' + +// Minimal valid PNG: 1x1 red pixel +const TEST_PNG_BASE64 = + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg==' + +function createTestPng(filepath: string) { + fs.mkdirSync(path.dirname(filepath), { recursive: true }) + fs.writeFileSync(filepath, Buffer.from(TEST_PNG_BASE64, 'base64')) +} + +test('image upload via file picker displays image with data URL', async ({ page }) => { + await page.goto('/') + await page.waitForTimeout(1000) + + // Open a note + await page.locator('[data-testid="type-icon"]').first().click({ timeout: 10000 }) + await page.waitForTimeout(500) + + const editor = page.locator('.bn-editor') + await expect(editor).toBeVisible({ timeout: 10000 }) + await editor.click() + await page.waitForTimeout(200) + + // Insert image block via slash command + await page.keyboard.press('Enter') + await page.waitForTimeout(100) + await page.keyboard.type('/image', { delay: 80 }) + await page.waitForTimeout(500) + + // Select Image from slash menu (press Enter to pick first match) + await page.keyboard.press('Enter') + await page.waitForTimeout(500) + + // Verify Upload tab is available (uploadFile is configured) + const fileInput = page.locator('input[type="file"]') + expect(await fileInput.count()).toBeGreaterThan(0) + + // Upload a test image + const testImagePath = path.join(process.cwd(), 'test-results', 'test-image.png') + createTestPng(testImagePath) + + await fileInput.first().setInputFiles(testImagePath) + await page.waitForTimeout(2000) + + // Verify: image element exists in the editor + const images = page.locator('.bn-editor img') + const imageCount = await images.count() + expect(imageCount).toBeGreaterThan(0) + + // Verify: image uses data URL (stable, survives reload in dev mode) + const src = await images.first().getAttribute('src') + expect(src).toMatch(/^data:/) + + // Verify: no "Loading..." elements remain + const loadingEls = page.locator('.bn-file-loading-preview') + expect(await loadingEls.count()).toBe(0) + + await page.screenshot({ path: 'test-results/image-upload-after.png', fullPage: true }) + + if (fs.existsSync(testImagePath)) fs.unlinkSync(testImagePath) +}) + +test('editor has uploadFile configured (no error on image block insert)', async ({ page }) => { + await page.goto('/') + await page.waitForTimeout(1000) + + // Click first note + await page.locator('[data-testid="type-icon"]').first().click({ timeout: 10000 }) + await page.waitForTimeout(500) + + const editor = page.locator('.bn-editor') + await expect(editor).toBeVisible({ timeout: 10000 }) + + // Capture console errors + const errors: string[] = [] + page.on('pageerror', (err) => errors.push(err.message)) + + // Insert an image block via slash command + await editor.click() + await page.keyboard.press('Enter') + await page.keyboard.type('/image', { delay: 30 }) + await page.waitForTimeout(500) + + // Press Enter to select Image + await page.keyboard.press('Enter') + await page.waitForTimeout(500) + + await page.screenshot({ path: 'test-results/image-block-inserted.png', fullPage: true }) + + // No errors related to upload should have occurred + const uploadErrors = errors.filter(e => e.includes('upload')) + expect(uploadErrors).toHaveLength(0) +}) diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index e6bede52..b812a640 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -1871,6 +1871,7 @@ dependencies = [ name = "laputa" version = "0.1.0" dependencies = [ + "base64 0.22.1", "chrono", "futures-util", "gray_matter", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 9a01f538..c12c4b83 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -27,6 +27,7 @@ chrono = { version = "0.4", features = ["serde"] } reqwest = { version = "0.12", features = ["json", "stream"] } tokio = { version = "1", features = ["rt-multi-thread", "macros"] } futures-util = "0.3" +base64 = "0.22" [dev-dependencies] tempfile = "3" diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 8df5d990..9e2e4f3d 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -58,6 +58,11 @@ async fn ai_chat(request: AiChatRequest) -> Result { ai_chat::send_chat(request).await } +#[tauri::command] +fn save_image(vault_path: String, filename: String, data: String) -> Result { + vault::save_image(&vault_path, &filename, &data) +} + #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { tauri::Builder::default() @@ -81,7 +86,8 @@ pub fn run() { get_file_diff, git_commit, git_push, - ai_chat + ai_chat, + save_image ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/src-tauri/src/vault.rs b/src-tauri/src/vault.rs index ee37b3f8..14f6e479 100644 --- a/src-tauri/src/vault.rs +++ b/src-tauri/src/vault.rs @@ -658,6 +658,47 @@ pub fn delete_frontmatter_property(path: &str, key: &str) -> Result bool { + c.is_alphanumeric() || matches!(c, '.' | '-' | '_') +} + +/// Sanitize a filename by replacing unsafe characters with underscores. +fn sanitize_filename(name: &str) -> String { + name.chars() + .map(|c| if is_safe_filename_char(c) { c } else { '_' }) + .collect() +} + +/// Save an uploaded image to the vault's attachments directory. +/// Returns the absolute path to the saved file. +pub fn save_image(vault_path: &str, filename: &str, data: &str) -> Result { + use base64::Engine; + + let vault = Path::new(vault_path); + let attachments_dir = vault.join("attachments"); + + fs::create_dir_all(&attachments_dir) + .map_err(|e| format!("Failed to create attachments directory: {}", e))?; + + // Generate unique filename to avoid collisions + let timestamp = std::time::SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_millis()) + .unwrap_or(0); + let unique_name = format!("{}-{}", timestamp, sanitize_filename(filename)); + let target_path = attachments_dir.join(&unique_name); + + let bytes = base64::engine::general_purpose::STANDARD + .decode(data) + .map_err(|e| format!("Invalid base64 data: {}", e))?; + + fs::write(&target_path, bytes) + .map_err(|e| format!("Failed to write image: {}", e))?; + + Ok(target_path.to_string_lossy().to_string()) +} + #[cfg(test)] mod tests { use super::*; @@ -1471,4 +1512,62 @@ References: } // Frontmatter update/delete tests are in frontmatter.rs + + // --- save_image tests --- + + #[test] + fn test_sanitize_filename_safe_chars() { + assert_eq!(sanitize_filename("photo.png"), "photo.png"); + assert_eq!(sanitize_filename("my-image_01.jpg"), "my-image_01.jpg"); + } + + #[test] + fn test_sanitize_filename_unsafe_chars() { + assert_eq!(sanitize_filename("my file (1).png"), "my_file__1_.png"); + assert_eq!(sanitize_filename("path/to/img.png"), "path_to_img.png"); + } + + #[test] + fn test_save_image_creates_file() { + use base64::Engine; + + let dir = TempDir::new().unwrap(); + let vault_path = dir.path().to_str().unwrap(); + let data = base64::engine::general_purpose::STANDARD.encode(b"fake image data"); + + let result = save_image(vault_path, "test.png", &data); + assert!(result.is_ok()); + + let saved_path = result.unwrap(); + assert!(std::path::Path::new(&saved_path).exists()); + assert!(saved_path.contains("attachments")); + assert!(saved_path.contains("test.png")); + + let content = fs::read(&saved_path).unwrap(); + assert_eq!(content, b"fake image data"); + } + + #[test] + fn test_save_image_creates_attachments_dir() { + use base64::Engine; + + let dir = TempDir::new().unwrap(); + let vault_path = dir.path().to_str().unwrap(); + let attachments = dir.path().join("attachments"); + assert!(!attachments.exists()); + + let data = base64::engine::general_purpose::STANDARD.encode(b"test"); + save_image(vault_path, "img.png", &data).unwrap(); + assert!(attachments.exists()); + } + + #[test] + fn test_save_image_invalid_base64() { + let dir = TempDir::new().unwrap(); + let vault_path = dir.path().to_str().unwrap(); + + let result = save_image(vault_path, "test.png", "not-valid-base64!!!"); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("Invalid base64")); + } } diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index a332c728..a557e316 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -22,7 +22,11 @@ } ], "security": { - "csp": null + "csp": null, + "assetProtocol": { + "enable": true, + "scope": ["**"] + } } }, "bundle": { diff --git a/src/App.tsx b/src/App.tsx index 33051400..882c9cf1 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -182,6 +182,7 @@ function App() { onAddProperty={notes.handleAddProperty} showAIChat={showAIChat} onToggleAIChat={() => setShowAIChat(c => !c)} + vaultPath={vaultPath} /> diff --git a/src/components/Editor.tsx b/src/components/Editor.tsx index 9250fcab..4230e304 100644 --- a/src/components/Editor.tsx +++ b/src/components/Editor.tsx @@ -4,6 +4,8 @@ import { filterSuggestionItems } from '@blocknote/core/extensions' import { createReactInlineContentSpec, useCreateBlockNote, SuggestionMenuController } from '@blocknote/react' import { BlockNoteView } from '@blocknote/mantine' import '@blocknote/mantine/style.css' +import { invoke, convertFileSrc } from '@tauri-apps/api/core' +import { isTauri } from '../mock-tauri' import type { VaultEntry, GitCommit } from '../types' import { Inspector, type FrontmatterValue } from './Inspector' import { AIChatPanel } from './AIChatPanel' @@ -45,6 +47,7 @@ interface EditorProps { onAddProperty?: (path: string, key: string, value: FrontmatterValue) => Promise showAIChat?: boolean onToggleAIChat?: () => void + vaultPath?: string } // --- Custom Inline Content: WikiLink --- @@ -147,13 +150,43 @@ export const Editor = memo(function Editor({ inspectorEntry, inspectorContent, allContent, gitHistory, onUpdateFrontmatter, onDeleteProperty, onAddProperty, showAIChat, onToggleAIChat, + vaultPath, }: EditorProps) { const [diffMode, setDiffMode] = useState(false) const [diffContent, setDiffContent] = useState(null) const [diffLoading, setDiffLoading] = useState(false) + // Ref for vaultPath so the uploadFile closure always sees the latest value + const vaultPathRef = useRef(vaultPath) + vaultPathRef.current = vaultPath + // Single editor instance — reused across all tabs - const editor = useCreateBlockNote({ schema }) + const editor = useCreateBlockNote({ + schema, + uploadFile: async (file: File) => { + if (isTauri() && vaultPathRef.current) { + // Tauri mode: save to vault/attachments and return a stable asset URL + const buf = await file.arrayBuffer() + const bytes = new Uint8Array(buf) + let binary = '' + for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]) + const base64 = btoa(binary) + const savedPath = await invoke('save_image', { + vaultPath: vaultPathRef.current, + filename: file.name, + data: base64, + }) + return convertFileSrc(savedPath) + } + // Browser dev mode: use data URL (survives reload, acceptable for dev) + return new Promise((resolve, reject) => { + const reader = new FileReader() + reader.onload = () => resolve(reader.result as string) + reader.onerror = () => reject(reader.error) + reader.readAsDataURL(file) + }) + }, + }) // Cache parsed blocks per tab path for instant switching const tabCacheRef = useRef>(new Map()) const prevActivePathRef = useRef(null) diff --git a/src/mock-tauri.ts b/src/mock-tauri.ts index 8df180ad..e6f07b44 100644 --- a/src/mock-tauri.ts +++ b/src/mock-tauri.ts @@ -1259,6 +1259,12 @@ const mockHandlers: Record any> = { stop_reason: 'end_turn', } }, + save_image: (args: { vault_path?: string; filename: string; data: string }) => { + // Return a plausible file path matching the real Rust backend behavior + const vault = args.vault_path ?? '/Users/luca/Laputa' + const timestamp = Date.now() + return `${vault}/attachments/${timestamp}-${args.filename}` + }, } export function isTauri(): boolean {