* feat: add copy_image_to_vault Rust command for native drag-drop Adds a new Tauri command that copies an image file from a source path (provided by Tauri's drag-drop event) directly into vault/attachments/. More efficient than base64 encoding for filesystem drag-drop. Also adds core:webview:allow-on-drag-drop-event permission. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: handle Tauri native drag-drop for filesystem images Listen for Tauri's onDragDropEvent to intercept OS-level file drops that bypass the webview's HTML5 DnD API. When image files are dropped: 1. Copy to vault/attachments via copy_image_to_vault command 2. Insert image block into BlockNote at cursor position Also passes vaultPath through Editor → EditorContent → SingleEditorView so the hook can invoke the Tauri command. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: remove nonexistent drag-drop permission from capabilities The onDragDropEvent API works through core:event:default (included in core:default), not a separate webview permission. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: correct DragDropEvent type handling for 'over' events Tauri's DragDropEvent discriminated union only provides `paths` on 'drop' events, not 'over'. Show drag overlay unconditionally on 'over' since we can't filter by image paths at that stage. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * design: drag-drop-images wireframes (idle + drag-over overlay) * style: rustfmt mcp.rs and image.rs --------- Co-authored-by: Test <test@test.com> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
197 lines
6.9 KiB
Rust
197 lines
6.9 KiB
Rust
use std::fs;
|
|
use std::path::Path;
|
|
use std::time::UNIX_EPOCH;
|
|
|
|
/// Check if a character is safe for use in filenames (alphanumeric, dot, dash, underscore).
|
|
fn is_safe_filename_char(c: char) -> 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()
|
|
}
|
|
|
|
/// Image file extensions considered valid for drag-drop import.
|
|
const IMAGE_EXTENSIONS: &[&str] = &["jpg", "jpeg", "png", "gif", "webp", "svg", "bmp", "tiff"];
|
|
|
|
/// Prepare the attachments directory and generate a unique target path.
|
|
fn prepare_attachment_path(vault_path: &str, filename: &str) -> Result<std::path::PathBuf, String> {
|
|
let attachments_dir = Path::new(vault_path).join("attachments");
|
|
fs::create_dir_all(&attachments_dir)
|
|
.map_err(|e| format!("Failed to create attachments directory: {}", e))?;
|
|
|
|
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));
|
|
Ok(attachments_dir.join(unique_name))
|
|
}
|
|
|
|
/// 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<String, String> {
|
|
use base64::Engine;
|
|
|
|
let target_path = prepare_attachment_path(vault_path, filename)?;
|
|
|
|
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())
|
|
}
|
|
|
|
/// Copy an image file from a source path into the vault's attachments directory.
|
|
/// Used for Tauri native drag-drop which provides absolute file paths.
|
|
/// Returns the absolute path to the saved file.
|
|
pub fn copy_image_to_vault(vault_path: &str, source_path: &str) -> Result<String, String> {
|
|
let source = Path::new(source_path);
|
|
if !source.exists() {
|
|
return Err(format!("Source file does not exist: {}", source_path));
|
|
}
|
|
|
|
let ext = source
|
|
.extension()
|
|
.and_then(|e| e.to_str())
|
|
.unwrap_or("")
|
|
.to_lowercase();
|
|
if !IMAGE_EXTENSIONS.contains(&ext.as_str()) {
|
|
return Err(format!("Not a supported image format: {}", source_path));
|
|
}
|
|
|
|
let filename = source
|
|
.file_name()
|
|
.and_then(|n| n.to_str())
|
|
.unwrap_or("image");
|
|
let target_path = prepare_attachment_path(vault_path, filename)?;
|
|
|
|
fs::copy(source, &target_path).map_err(|e| format!("Failed to copy image: {}", e))?;
|
|
|
|
Ok(target_path.to_string_lossy().to_string())
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use tempfile::TempDir;
|
|
|
|
#[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"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_copy_image_to_vault_success() {
|
|
let dir = TempDir::new().unwrap();
|
|
let vault_path = dir.path().to_str().unwrap();
|
|
|
|
// Create a source image file
|
|
let source_path = dir.path().join("source.png");
|
|
fs::write(&source_path, b"fake png data").unwrap();
|
|
|
|
let result = copy_image_to_vault(vault_path, source_path.to_str().unwrap());
|
|
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("source.png"));
|
|
|
|
let content = fs::read(&saved_path).unwrap();
|
|
assert_eq!(content, b"fake png data");
|
|
}
|
|
|
|
#[test]
|
|
fn test_copy_image_to_vault_nonexistent_source() {
|
|
let dir = TempDir::new().unwrap();
|
|
let vault_path = dir.path().to_str().unwrap();
|
|
|
|
let result = copy_image_to_vault(vault_path, "/nonexistent/photo.png");
|
|
assert!(result.is_err());
|
|
assert!(result.unwrap_err().contains("does not exist"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_copy_image_to_vault_rejects_non_image() {
|
|
let dir = TempDir::new().unwrap();
|
|
let vault_path = dir.path().to_str().unwrap();
|
|
|
|
let source_path = dir.path().join("document.pdf");
|
|
fs::write(&source_path, b"fake pdf").unwrap();
|
|
|
|
let result = copy_image_to_vault(vault_path, source_path.to_str().unwrap());
|
|
assert!(result.is_err());
|
|
assert!(result.unwrap_err().contains("Not a supported image"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_copy_image_to_vault_accepts_all_extensions() {
|
|
let dir = TempDir::new().unwrap();
|
|
let vault_path = dir.path().to_str().unwrap();
|
|
|
|
for ext in &["jpg", "jpeg", "png", "gif", "webp", "svg", "bmp", "tiff"] {
|
|
let source_path = dir.path().join(format!("img.{}", ext));
|
|
fs::write(&source_path, b"data").unwrap();
|
|
let result = copy_image_to_vault(vault_path, source_path.to_str().unwrap());
|
|
assert!(result.is_ok(), "failed for extension: {}", ext);
|
|
}
|
|
}
|
|
}
|