From a2092ab7464f2f5cfe7bcf0332071596b34220a5 Mon Sep 17 00:00:00 2001 From: Owais Jamil Date: Tue, 24 Mar 2026 11:25:24 -0500 Subject: [PATCH] refactor: images in file browser --- CHANGELOG.md | 2 + crates/core/src/lib.rs | 11 - crates/store/src/file_utils.rs | 5 +- crates/store/src/lib.rs | 216 ++++++++---------- docs/specs/image-handling.md | 204 ++++++----------- docs/tasks/image-handling.md | 121 ++-------- docs/tasks/parking-lot.md | 6 +- src-tauri/src/commands/images.rs | 48 ++-- src-tauri/src/lib.rs | 1 - src-tauri/src/locations.rs | 8 +- src/__tests__/ports.test.ts | 50 +--- src/__tests__/useEditorImageHandlers.test.ts | 52 ++--- .../controllers/useWorkspaceViewController.ts | 6 +- src/hooks/useEditorImageHandlers.ts | 23 +- src/hooks/useImageController.ts | 13 +- src/ports/commands.ts | 10 +- src/ports/invoke.ts | 20 -- src/ports/types.ts | 11 +- 18 files changed, 274 insertions(+), 533 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e8b2030..5a004a7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,8 @@ - AT Protocol integration (login with your [internet handle](https://internethandle.org/)) - Import strings (snippets) from [Tangled](https://tangled.org/) - Import [standard.site](https://standard.site/) posts ([Leaflet](https://leaflet.pub)) +- Image handling in locations +- Image rendering in PDFs ### Changed diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs index 9de5c19..4e54aae 100644 --- a/crates/core/src/lib.rs +++ b/crates/core/src/lib.rs @@ -9,23 +9,12 @@ pub use nlp::{ StyleScanInput, scan_style_matches, }; -/// Directory name for local image assets within a location root -pub const ASSETS_DIR_NAME: &str = ".writer-assets"; - /// Supported image file extensions for import pub const SUPPORTED_IMAGE_EXTENSIONS: &[&str] = &["png", "jpg", "jpeg", "gif", "webp", "svg"]; /// Maximum allowed image file size (10 MiB) pub const IMAGE_SIZE_LIMIT_BYTES: u64 = 10 * 1024 * 1024; -/// Metadata for a locally-stored image asset -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] -pub struct ImageAsset { - pub filename: String, - pub size_bytes: u64, - pub extension: String, -} - /// Unique identifier for a document within a location /// Combines location_id + rel_path for stable identity #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)] diff --git a/crates/store/src/file_utils.rs b/crates/store/src/file_utils.rs index ae0af21..a6536e1 100644 --- a/crates/store/src/file_utils.rs +++ b/crates/store/src/file_utils.rs @@ -35,7 +35,10 @@ pub fn collect_file_paths_recursive(dir: &Path, files: &mut Vec) -> Res if path.is_file() { files.push(path); } else if path.is_dir() { - collect_file_paths_recursive(&path, files)?; + let is_hidden = entry.file_name().to_str().map(|n| n.starts_with('.')).unwrap_or(false); + if !is_hidden { + collect_file_paths_recursive(&path, files)?; + } } } diff --git a/crates/store/src/lib.rs b/crates/store/src/lib.rs index 882f5c9..ad26f51 100644 --- a/crates/store/src/lib.rs +++ b/crates/store/src/lib.rs @@ -2238,25 +2238,18 @@ impl Store { Ok(hits) } - fn assets_dir(root_path: &Path) -> PathBuf { - root_path.join(writer_core::ASSETS_DIR_NAME) - } - - fn ensure_assets_dir(root_path: &Path) -> Result { - let dir = Self::assets_dir(root_path); - std::fs::create_dir_all(&dir) - .map_err(|e| AppError::io(format!("Failed to create assets directory {:?}: {}", dir, e)))?; - Ok(dir) - } - - /// Imports an image file into `.writer-assets/` under the given location. + /// Imports an image file into `target_dir` under the given location. /// /// Validates format and size, hashes the file bytes with blake3, and copies - /// the file to `.writer-assets/.`. If the hash already exists the - /// existing path is returned without copying (dedup). + /// the file to `/.`. Dedup within `target_dir`: if + /// the hash file already exists there the existing path is returned. /// - /// Returns the relative asset path, e.g. `.writer-assets/abc123.png`. - pub fn image_import(&self, location_id: LocationId, source_path: &Path) -> Result { + /// `target_dir` is relative to the location root (use `""` for the root). + /// Returns the relative path from the location root, e.g. `abc123.png` or + /// `drafts/abc123.png`. + pub fn image_import( + &self, location_id: LocationId, source_path: &Path, target_dir: &str, + ) -> Result { let location = self .location_get(location_id)? .ok_or_else(|| AppError::not_found(format!("Location not found: {:?}", location_id)))?; @@ -2298,34 +2291,49 @@ impl Store { let hash_hex = hash.to_hex(); let dest_filename = format!("{}.{}", hash_hex, ext); - let assets_dir = Self::ensure_assets_dir(&location.root_path)?; - let dest_path = assets_dir.join(&dest_filename); + let dest_dir = if target_dir.is_empty() { + location.root_path.clone() + } else { + let normalized = normalize_relative_path(Path::new(target_dir))?; + location.root_path.join(&normalized) + }; + + std::fs::create_dir_all(&dest_dir) + .map_err(|e| AppError::io(format!("Failed to create target directory: {}", e)))?; + + let dest_path = dest_dir.join(&dest_filename); + + let rel = if target_dir.is_empty() { + dest_filename.clone() + } else { + format!("{}/{}", target_dir, dest_filename) + }; if dest_path.exists() { - let rel = format!("{}/{}", writer_core::ASSETS_DIR_NAME, dest_filename); log::debug!("image_import dedup hit: {}", rel); return Ok(rel); } - std::fs::copy(source_path, &dest_path) - .map_err(|e| AppError::io(format!("Failed to copy image to assets dir: {}", e)))?; + std::fs::copy(source_path, &dest_path).map_err(|e| AppError::io(format!("Failed to copy image: {}", e)))?; - let rel = format!("{}/{}", writer_core::ASSETS_DIR_NAME, dest_filename); log::info!("image_import: stored {} as {}", source_path.display(), rel); Ok(rel) } - /// Deletes an image asset from `.writer-assets/`. + /// Deletes an image at `rel_path` within the given location. /// - /// Returns `true` if the file was removed, `false` if it did not exist. - /// Returns an error if `asset_path` does not start with `.writer-assets/` - /// (path-traversal guard). - pub fn image_delete(&self, location_id: LocationId, asset_path: &str) -> Result { - let expected_prefix = format!("{}/", writer_core::ASSETS_DIR_NAME); - if !asset_path.starts_with(&expected_prefix) { + /// Validates that `rel_path` has a supported image extension and does not + /// escape the location root. + pub fn image_delete(&self, location_id: LocationId, rel_path: &str) -> Result { + let ext = Path::new(rel_path) + .extension() + .and_then(|e| e.to_str()) + .map(|e| e.to_lowercase()) + .unwrap_or_default(); + if !writer_core::SUPPORTED_IMAGE_EXTENSIONS.contains(&ext.as_str()) { return Err(AppError::invalid_path(format!( - "asset_path must start with '{}'", - expected_prefix + "rel_path does not point to a supported image file: {}", + rel_path ))); } @@ -2333,7 +2341,8 @@ impl Store { .location_get(location_id)? .ok_or_else(|| AppError::not_found(format!("Location not found: {:?}", location_id)))?; - let full_path = location.root_path.join(asset_path); + let normalized = normalize_relative_path(Path::new(rel_path))?; + let full_path = location.root_path.join(&normalized); if !full_path.exists() { log::debug!("image_delete: file not found: {:?}", full_path); @@ -2341,7 +2350,7 @@ impl Store { } std::fs::remove_file(&full_path) - .map_err(|e| AppError::io(format!("Failed to delete asset {:?}: {}", full_path, e)))?; + .map_err(|e| AppError::io(format!("Failed to delete image {:?}: {}", full_path, e)))?; log::info!("image_delete: removed {:?}", full_path); Ok(true) @@ -2368,54 +2377,15 @@ impl Store { let normalized = normalize_relative_path(&combined)?; let resolved = location.root_path.join(&normalized); - if !is_path_within_location(&resolved, &location.root_path) { - return Err(AppError::invalid_path("Resolved asset path escaped the location root")); - } - if !resolved.exists() { return Err(AppError::not_found(format!("Asset not found: {}", trimmed_asset_path))); } - Ok(resolved) - } - - /// Lists all image assets in `.writer-assets/` for the given location. - /// - /// Returns an empty vec if the directory does not exist yet. - pub fn image_list(&self, location_id: LocationId) -> Result, AppError> { - let location = self - .location_get(location_id)? - .ok_or_else(|| AppError::not_found(format!("Location not found: {:?}", location_id)))?; - - let assets_dir = Self::assets_dir(&location.root_path); - - if !assets_dir.exists() { - return Ok(Vec::new()); - } - - let entries = std::fs::read_dir(&assets_dir) - .map_err(|e| AppError::io(format!("Failed to read assets directory: {}", e)))?; - - let mut assets = Vec::new(); - for entry in entries { - let entry = entry.map_err(|e| AppError::io(format!("Failed to read assets entry: {}", e)))?; - let path = entry.path(); - - if !path.is_file() { - continue; - } - - let filename = path.file_name().and_then(|n| n.to_str()).unwrap_or("").to_string(); - - let extension = path.extension().and_then(|e| e.to_str()).unwrap_or("").to_lowercase(); - - let size_bytes = std::fs::metadata(&path).map(|m| m.len()).unwrap_or(0); - - assets.push(writer_core::ImageAsset { filename, size_bytes, extension }); + if !is_path_within_location(&resolved, &location.root_path) { + return Err(AppError::invalid_path("Resolved asset path escaped the location root")); } - log::debug!("image_list: {} assets in location {:?}", assets.len(), location_id); - Ok(assets) + Ok(resolved) } /// Converts an SVG file at the given absolute path to a PNG data URL. @@ -3418,39 +3388,55 @@ mod tests { #[test] fn test_image_import_png() { - let (store, _tmp) = create_test_store(); + let (store, tmp) = create_test_store(); let (location_id, location_dir) = create_test_location(&store); - let src = write_test_image(location_dir.path(), "photo.png", b"\x89PNG\r\n\x1a\nfake"); - let rel = store.image_import(location_id, &src).unwrap(); + let src = write_test_image(tmp.path(), "photo.png", b"\x89PNG\r\n\x1a\nfake"); + let rel = store.image_import(location_id, &src, "").unwrap(); - assert!(rel.starts_with(".writer-assets/")); + assert!(!rel.contains('/'), "root import should be a bare filename"); assert!(rel.ends_with(".png")); let full = location_dir.path().join(&rel); assert!(full.exists(), "imported file should be on disk"); } + #[test] + fn test_image_import_into_subdir() { + let (store, _tmp) = create_test_store(); + let (location_id, location_dir) = create_test_location(&store); + let src = write_test_image(_tmp.path(), "photo.jpg", b"fakejpeg"); + let rel = store.image_import(location_id, &src, "drafts").unwrap(); + + assert!(rel.starts_with("drafts/")); + assert!(rel.ends_with(".jpg")); + + let full = location_dir.path().join(&rel); + assert!(full.exists(), "imported file should be on disk in subdir"); + } + #[test] fn test_image_import_dedup() { let (store, _tmp) = create_test_store(); let (location_id, location_dir) = create_test_location(&store); - let src = write_test_image(location_dir.path(), "same.jpg", b"fakejpeg"); - let rel1 = store.image_import(location_id, &src).unwrap(); - let rel2 = store.image_import(location_id, &src).unwrap(); + let src = write_test_image(_tmp.path(), "same.jpg", b"fakejpeg"); + let rel1 = store.image_import(location_id, &src, "").unwrap(); + let rel2 = store.image_import(location_id, &src, "").unwrap(); assert_eq!(rel1, rel2, "same content should produce same path"); - let assets_dir = location_dir.path().join(".writer-assets"); - let count = std::fs::read_dir(assets_dir).unwrap().count(); + let count = std::fs::read_dir(location_dir.path()) + .unwrap() + .filter(|e| e.as_ref().map(|e| e.path().is_file()).unwrap_or(false)) + .count(); assert_eq!(count, 1, "only one file should be on disk after dedup"); } #[test] fn test_image_import_invalid_format() { let (store, _tmp) = create_test_store(); - let (location_id, location_dir) = create_test_location(&store); - let src = write_test_image(location_dir.path(), "program.exe", b"MZ"); - let result = store.image_import(location_id, &src); + let (location_id, _location_dir) = create_test_location(&store); + let src = write_test_image(_tmp.path(), "program.exe", b"MZ"); + let result = store.image_import(location_id, &src, ""); assert!(result.is_err()); assert_eq!(result.unwrap_err().code, ErrorCode::InvalidPath); @@ -3459,10 +3445,10 @@ mod tests { #[test] fn test_image_import_size_limit() { let (store, _tmp) = create_test_store(); - let (location_id, location_dir) = create_test_location(&store); + let (location_id, _location_dir) = create_test_location(&store); let oversized: Vec = vec![0u8; (writer_core::IMAGE_SIZE_LIMIT_BYTES + 1) as usize]; - let src = write_test_image(location_dir.path(), "big.png", &oversized); - let result = store.image_import(location_id, &src); + let src = write_test_image(_tmp.path(), "big.png", &oversized); + let result = store.image_import(location_id, &src, ""); assert!(result.is_err()); assert_eq!(result.unwrap_err().code, ErrorCode::InvalidPath); @@ -3473,8 +3459,8 @@ mod tests { let (store, _tmp) = create_test_store(); let (location_id, location_dir) = create_test_location(&store); - let src = write_test_image(location_dir.path(), "delete_me.gif", b"GIF89a"); - let rel = store.image_import(location_id, &src).unwrap(); + let src = write_test_image(_tmp.path(), "delete_me.gif", b"GIF89a"); + let rel = store.image_import(location_id, &src, "").unwrap(); let removed = store.image_delete(location_id, &rel).unwrap(); assert!(removed, "should return true when file was deleted"); @@ -3488,9 +3474,7 @@ mod tests { let (store, _tmp) = create_test_store(); let (location_id, _location_dir) = create_test_location(&store); - let removed = store - .image_delete(location_id, ".writer-assets/doesnotexist.png") - .unwrap(); + let removed = store.image_delete(location_id, "doesnotexist.png").unwrap(); assert!(!removed, "should return false when file was not found"); } @@ -3503,14 +3487,23 @@ mod tests { assert_eq!(result.unwrap_err().code, ErrorCode::InvalidPath); } + #[test] + fn test_image_delete_traversal_guard_with_image_ext() { + let (store, _tmp) = create_test_store(); + let (location_id, _location_dir) = create_test_location(&store); + let result = store.image_delete(location_id, "../secret.png"); + assert!(result.is_err()); + assert_eq!(result.unwrap_err().code, ErrorCode::InvalidPath); + } + #[test] fn test_asset_resolve_root_relative_asset() { let (store, _tmp) = create_test_store(); let (location_id, location_dir) = create_test_location(&store); - let expected = write_test_image(location_dir.path(), ".writer-assets/photo.png", b"\x89PNG"); + let expected = write_test_image(location_dir.path(), "photo.png", b"\x89PNG"); let resolved = store - .asset_resolve(location_id, Path::new("doc.md"), ".writer-assets/photo.png") + .asset_resolve(location_id, Path::new("doc.md"), "photo.png") .unwrap(); assert_eq!(resolved, expected); @@ -3551,36 +3544,9 @@ mod tests { let (location_id, _location_dir) = create_test_location(&store); let error = store - .asset_resolve(location_id, Path::new("doc.md"), ".writer-assets/missing.png") + .asset_resolve(location_id, Path::new("doc.md"), "missing.png") .unwrap_err(); assert_eq!(error.code, ErrorCode::NotFound); } - - #[test] - fn test_image_list_empty() { - let (store, _tmp) = create_test_store(); - let (location_id, _location_dir) = create_test_location(&store); - - let assets = store.image_list(location_id).unwrap(); - assert!(assets.is_empty(), "no assets dir yet → empty list"); - } - - #[test] - fn test_image_list_multiple() { - let (store, _tmp) = create_test_store(); - let (location_id, location_dir) = create_test_location(&store); - - let s1 = write_test_image(location_dir.path(), "a.png", b"\x89PNG one"); - let s2 = write_test_image(location_dir.path(), "b.webp", b"RIFF webp"); - store.image_import(location_id, &s1).unwrap(); - store.image_import(location_id, &s2).unwrap(); - - let assets = store.image_list(location_id).unwrap(); - assert_eq!(assets.len(), 2, "should list both imported images"); - - let exts: Vec<&str> = assets.iter().map(|a| a.extension.as_str()).collect(); - assert!(exts.contains(&"png"), "png should be present"); - assert!(exts.contains(&"webp"), "webp should be present"); - } } diff --git a/docs/specs/image-handling.md b/docs/specs/image-handling.md index a1e706b..d183e30 100644 --- a/docs/specs/image-handling.md +++ b/docs/specs/image-handling.md @@ -3,44 +3,49 @@ title: Image Handling Spec updated: 2026-03-24 --- -> Goal: Support local image embedding in markdown documents with storage, preview, lifecycle management, AT Protocol blob sync, and PDF export. +> Goal: Support local image embedding in markdown documents with inline storage, preview, AT Protocol blob sync, and PDF export. ## Problem -Images in documents are currently only supported through AT Protocol Leaflet blob references (`at://blob/CID`). There is no local image support — no upload, no storage, no preview, no drag-and-drop. Users cannot embed images in their markdown files. +Images in documents are currently stored in a hidden `.writer-assets/` directory at the location root. This directory is excluded from the file browser and file watcher, making images invisible to the user. Images should be regular files — visible in the sidebar, stored alongside documents, and managed like any other file. ## Design ### Storage Model -Each location gets an asset directory at its root: +Images are regular files within the location. No dedicated asset directory. ```sh / - .writer-assets/ - .png - .jpg - ... my-document.md + abc123def.png ← imported via paste/drop drafts/ another-doc.md + f9e8d7c6b.jpg ← imported while editing another-doc.md ``` -- **Directory**: `.writer-assets/` — hidden by convention, one per location. -- **Naming**: content-addressed (`blake3` hash of file bytes + original extension). Prevents duplicates and naming collisions. -- **Formats**: PNG, JPEG, GIF, WebP, SVG. Reject anything else at the command boundary. -- **Size limit**: 10 MB per image. Enforced in the Tauri command. +- **Location**: imported images are placed in the same directory as the active document. +- **Naming**: content-addressed (`blake3` hash of file bytes + original extension). Prevents duplicates and collisions from paste/drag-drop imports. User-placed images keep their original names. +- **Formats**: PNG, JPEG, GIF, WebP, SVG. Validated at the import command boundary. +- **Size limit**: 10 MB per image, enforced in the Tauri command. +- **Visibility**: images appear in the file browser alongside documents. The indexer catalogs them in the `documents` table (metadata only, no FTS). ### Markdown Reference Format Standard markdown image syntax with a relative path: ```markdown -![Alt text](.writer-assets/abc123def.png) +![Alt text](abc123def.png) +![Photo](../photos/image.jpg) ``` -- Relative to the document's location root, not the document's own directory. -- When a document is in a subdirectory (`drafts/doc.md`), the path is still relative to location root: `![img](../.writer-assets/abc123.png)` — or the editor resolves it at render time. +Paths are relative to the document's own directory, following standard markdown conventions. + +### File Browser + +The sidebar shows all non-hidden files in the location. Image files appear alongside documents. No special filtering — the existing `reconcile_location_index` already indexes all files and `doc_list` returns them. + +Hidden directories (names starting with `.`) are skipped during file collection. This replaces the previous `.writer-assets/`-specific skip in the file watcher with a general hidden-directory exclusion. ### Tauri Commands (Rust) @@ -48,98 +53,89 @@ Standard markdown image syntax with a relative path: ```rust #[tauri::command] -pub fn image_import(location_id: LocationId, source_path: PathBuf) -> Result +pub fn image_import( + location_id: LocationId, + source_path: PathBuf, + target_dir: String, // relative to location root, e.g. "" or "drafts" +) -> Result ``` -- Copies source file into `.writer-assets/`. -- Hashes contents, derives filename. -- Returns the relative asset path string (e.g., `.writer-assets/abc123.png`). -- If hash already exists, returns existing path (dedup). -- Validates format and size before copying. +- Validates format and size. +- Hashes contents with blake3, derives filename as `.`. +- Copies to `//.`. +- Dedup: if hash-named file already exists in target dir, returns existing path. +- Returns the relative path from location root (e.g., `abc123.png` or `drafts/abc123.png`). #### `image_delete` ```rust #[tauri::command] -pub fn image_delete(location_id: LocationId, asset_path: String) -> Result +pub fn image_delete(location_id: LocationId, rel_path: String) -> Result ``` -- Removes the file from `.writer-assets/`. -- Does **not** scan documents for dangling references (user's responsibility, or future cleanup pass). +- Validates `rel_path` resolves within the location root (path-traversal guard). +- Validates the file has a supported image extension. +- Removes the file. Returns `true` if removed, `false` if not found. -#### `image_list` +#### `asset_resolve` ```rust #[tauri::command] -pub fn image_list(location_id: LocationId) -> Result, Error> +pub fn asset_resolve( + location_id: LocationId, + doc_rel_path: PathBuf, + asset_path: String, +) -> Result ``` -- Returns all images in `.writer-assets/` with metadata (filename, size, dimensions if cheaply available). +Unchanged. Resolves a markdown-relative path against the source document's directory. Rejects traversal outside the location root. -#### `asset_resolve` +### Removed Commands -```rust -#[tauri::command] -pub fn asset_resolve(location_id: LocationId, doc_rel_path: PathBuf, asset_path: String) -> Result -``` - -- Resolves a markdown-local path against the source document's directory. -- Rejects traversal outside the location root. -- Returns an absolute location-scoped path for preview/export consumers. +- **`image_list`**: no longer needed. Images are in the `documents` table; use `doc_list` with an extension filter. ### Frontend #### Editor Integration -- **Paste**: intercept clipboard paste events containing image data. Call `image_import` with a temp file, insert markdown reference at cursor. -- **Drag-and-drop**: intercept file drop on the editor area. Same flow as paste. -- **Toolbar button**: "Insert Image" opens a file picker dialog (Tauri `dialog::open`), imports, inserts reference. +- **Paste**: intercept clipboard paste with image data. Write to temp file, call `image_import` with `target_dir` set to the active document's directory. Insert `![image](hash.ext)` at cursor. +- **Drag-and-drop**: intercept file drop on editor area. Same import flow as paste. +- **Toolbar button**: file picker dialog filtered to image types. Import, insert reference. #### Preview Rendering -- The markdown preview resolves any local markdown image or file link that stays within the active location root, not just `.writer-assets/` imports. -- Resolution happens through `asset_resolve`, then image URLs are converted to Tauri `asset:` URLs for display. -- Images render inline with `max-width: 100%` and click-to-zoom. -- Local file links open through the system opener instead of navigating the webview. +Unchanged. The preview resolves any relative image path via `asset_resolve`, converts to a Tauri `asset:` URL. Images render inline with `max-width: 100%` and click-to-zoom. #### State -No dedicated image store slice needed. Images are embedded in document text as markdown. The `image_list` command is called on-demand when needed (e.g., an asset manager UI, if ever built). +No dedicated image state. Images are markdown references in document text and regular entries in the document index. ### Indexing -The document index (`documents` table in SQLite) does **not** track images. Images are filesystem-only artifacts referenced by markdown text. This keeps the model simple — no foreign keys, no orphan tracking. +All non-hidden files are indexed in the `documents` table during `reconcile_location_index`. Image files get metadata (filename, size, mtime) but no FTS content — `is_indexable_text_path` already gates FTS to md/markdown/mdx/txt extensions. -### Cleanup - -Orphaned images (not referenced by any document) accumulate over time. A future `image_cleanup` command can scan all documents in a location and remove unreferenced assets. This is **out of scope** for the initial implementation. +Hidden directories (dotfiles) are excluded from both file collection and file watcher events. This is a general policy, not image-specific. ### AT Protocol Blob Sync -Bridges local `.writer-assets/` images and AT Protocol blob references (`at://blob/CID`) so images survive publish and import round-trips. +Bridges local image references and AT Protocol blob references (`at://blob/CID`) for publish/import round-trips. #### Publish direction (local → remote) -When a document containing `.writer-assets/` image references is published to Leaflet/Standard.Site: - -1. **Scan** the markdown for `.writer-assets/` image paths. -2. **Read** each referenced file from the location's `.writer-assets/` directory. -3. **Upload** each file via `com.atproto.repo.uploadBlob` on the user's PDS. The PDS returns a `BlobRef` with a populated CID, MIME type, and size. -4. **Rewrite** the markdown image references from `.writer-assets/.` to `at://blob/` before building the Leaflet document. -5. **Populate** the `Image` block's blob metadata correctly (MIME type from extension, actual file size, aspect ratio if cheaply available) instead of the current hardcoded values (`application/octet-stream`, size 0). +1. **Scan** markdown for local image paths (any relative path pointing to an image file). +2. **Resolve** each path via `asset_resolve` to get the absolute file location. +3. **Upload** each via `com.atproto.repo.uploadBlob`. PDS returns a `BlobRef` (CID, MIME type, size). +4. **Rewrite** image references to `at://blob/` in-memory before building the Leaflet document. +5. **Populate** `Image` block metadata from the `BlobRef` instead of hardcoded values. -The rewrite is transient — it happens in-memory during the publish pipeline. The local document retains its `.writer-assets/` references. +The rewrite is transient — the local document retains its relative paths. #### Import direction (remote → local) -When a Leaflet/Standard.Site post containing blob images is imported: - -1. **Detect** `at://blob/` image references in the converted markdown. -2. **Download** each blob from the author's PDS via `com.atproto.sync.getBlob` (params: DID + CID). -3. **Import** the downloaded bytes through the existing `image_import` flow (hash, dedup, store in `.writer-assets/`). -4. **Rewrite** the markdown image references from `at://blob/` to `.writer-assets/.`. - -This means imported posts get fully local images that render in preview without network access. +1. **Detect** `at://blob/` image references in converted markdown. +2. **Download** each blob via `com.atproto.sync.getBlob` (DID + CID). +3. **Import** downloaded bytes through `image_import` (hash, dedup, store in document's directory). +4. **Rewrite** `at://blob/` → `.` in the markdown. #### Tauri Commands @@ -149,14 +145,14 @@ This means imported posts get fully local images that render in preview without #[tauri::command] pub async fn blob_upload( location_id: LocationId, - asset_path: String, + asset_rel_path: String, auth: AuthSession, ) -> Result ``` -- Reads the file from `.writer-assets/`, determines MIME type from extension. -- Calls `com.atproto.repo.uploadBlob` with the file bytes. -- Returns the `BlobRef` (CID, MIME type, size) for use in Leaflet document construction. +- Resolves `asset_rel_path` within the location root, reads file bytes, determines MIME type. +- Calls `com.atproto.repo.uploadBlob`. +- Returns `BlobRef` for Leaflet document construction. ##### `blob_download` @@ -166,70 +162,17 @@ pub async fn blob_download( location_id: LocationId, did: String, cid: String, + target_dir: String, ) -> Result ``` -- Calls `com.atproto.sync.getBlob` with the DID and CID. -- Writes the response bytes through `image_import` (hash, dedup, store). -- Returns the local `.writer-assets/.` path. - -#### Metadata Accuracy - -The current `image_from_url` in `leaflet.rs` hardcodes blob metadata: - -```rust -mime_type: MimeType::new_static("application/octet-stream"), -size: 0, -``` - -With blob sync, `blob_upload` returns real metadata from the PDS. The publish pipeline must thread this metadata into the `Image` block construction so Leaflet clients render images correctly. +- Downloads blob via `com.atproto.sync.getBlob`. +- Pipes bytes through `image_import` to `target_dir`. +- Returns the local relative path. ### PDF Export with Embedded Images -The PDF pipeline (`crates/markdown` → `@react-pdf/renderer`) currently has no image support. Extending it requires changes at both layers. - -#### Rust: PdfNode Image Variant - -Add an `Image` variant to `PdfNode`: - -```rust -pub enum PdfNode { - // ... existing variants ... - Image { src: String, alt: String }, -} -``` - -- `src`: the `.writer-assets/.` path as written in markdown. -- `alt`: the alt text from `![alt](src)`. - -Update `MarkdownTransformer::transform_to_pdf_nodes()` to emit `PdfNode::Image` when it encounters a Comrak image node, instead of silently dropping it. - -#### Frontend: Path Resolution - -The `@react-pdf/renderer` `` component accepts a `src` that can be a URL, a file path, or a base64 data URL. Since Tauri asset protocol URLs may not work inside the PDF renderer's internal fetch: - -- Resolve any location-scoped local image path to **base64 data URLs** before passing to the renderer. -- Use `asset_resolve` to validate and normalize the path first, then `convertFileSrc()` to fetch raster bytes via the webview. -- This is similar to the font preloading strategy already in `src/pdf/fonts.ts`. -- SVG images are routed through `svg_to_png(location_id, doc_rel_path, asset_path)` so the backend performs the path resolution and rasterization in one scoped flow. - -#### Frontend: MarkdownPdfDocument Rendering - -Add an `Image` case to the node renderer in `MarkdownPdfDocument.tsx`: - -```tsx -case "Image": - return ; -``` - -- Respect page margins — images should not overflow the content area. -- Preserve aspect ratio. -- Preserve images inside list items by keeping list item content as nested PDF nodes instead of flattening it to text. - -#### Limitations - -- SVG images may not be supported by `@react-pdf/renderer`. Fall back to a placeholder or skip with a warning. -- Very large images should be resized to reasonable print dimensions to avoid bloating PDF file size. This can be deferred — initial implementation embeds at original resolution. +No changes needed for the refactor. The PDF pipeline already resolves image paths via `asset_resolve` and converts to base64 data URLs. The `PdfNode::Image` variant carries the `src` as written in markdown; the frontend resolver handles any relative path. --- @@ -237,13 +180,14 @@ case "Image": **In scope:** -- Import from file picker, paste, drag-and-drop -- Content-addressed storage in `.writer-assets/` -- Markdown reference insertion +- Import from file picker, paste, drag-and-drop into document's directory +- Content-hash naming for imported images +- Images visible in file browser +- Markdown reference insertion with relative paths - Preview rendering via Tauri asset protocol -- Single image delete command - AT Protocol blob sync (upload on publish, download on import) - PDF export with embedded images +- Hidden-directory exclusion from indexing/watching **Out of scope (future):** diff --git a/docs/tasks/image-handling.md b/docs/tasks/image-handling.md index 7d4cbb9..8272817 100644 --- a/docs/tasks/image-handling.md +++ b/docs/tasks/image-handling.md @@ -1,135 +1,44 @@ --- title: Image Handling -updated: 2026-03-21 +updated: 2026-03-24 --- -## Backend (Tauri + Rust) - -- [x] Add `.writer-assets/` directory creation on location init - - Create directory if missing when a location is opened - - Add to `.gitignore`-style ignore list for file watcher (don't index asset files as documents) -- [x] Implement `image_import` command - - Validate format (PNG, JPEG, GIF, WebP, SVG) and size (≤10 MB) - - Hash file contents with blake3, derive filename - - Copy to `.writer-assets/.` - - Dedup: if hash exists, return existing path - - Return relative asset path string -- [x] Implement `image_delete` command - - Remove file from `.writer-assets/` - - No dangling reference scan -- [x] Implement `image_list` command - - List all files in `.writer-assets/` - - Return filename, size, extension -- [x] Register commands in `lib.rs` and expose via Tauri - -## Frontend Ports & State - -- [x] Add command builders in `src/ports/commands.ts` - - `imageImport(locationId, sourcePath, onOk, onErr)` - - `imageDelete(locationId, assetPath, onOk, onErr)` - - `imageList(locationId, onOk, onErr)` -- [x] Add controller hook `useImageController` - - `importImage(locationId, sourcePath)` — call `image_import`, return asset path - - `deleteImage(locationId, assetPath)` — call `image_delete` -- [x] Add `useEditorImageHandlers` hook - - `handleImageFilePaste(file)` — write temp file, call `image_import`, set `insertAt` - - `handlePickAndInsertImage()` — Tauri file picker dialog, call `image_import`, set `insertAt` - - Window drag-drop listener: imports image files dropped on editor area - -## Editor Integration - -- [x] Paste handler - - Intercept clipboard paste with image data (`Editor.tsx` `onPaste`) - - Write to temp file, call `image_import`, insert `![image](.writer-assets/hash.ext)` at cursor -- [x] Drag-and-drop handler - - Intercept file drop on editor area (Tauri window `onDragDropEvent`) - - Filter to supported image formats - - Same import + insert flow as paste -- [x] Toolbar "Insert Image" button - - Open Tauri file picker dialog filtered to image types - - Import selected file, insert reference - -## Preview Rendering - -- [x] Resolve `.writer-assets/` paths in markdown preview - - Use `convertFileSrc()` or Tauri asset protocol to create displayable URLs - - Handle relative path resolution for documents in subdirectories -- [x] Image display styling - - `max-width: 100%`, responsive within content column - - Maintain aspect ratio -- [x] Click-to-zoom - - Click image in preview to open full-size overlay - ## AT Protocol Blob Sync ### Backend (Rust) - [ ] Implement `blob_upload` command - - Read file from `.writer-assets/`, determine MIME type from extension + - Resolve `asset_rel_path` within location root, determine MIME type - Call `com.atproto.repo.uploadBlob` on user's PDS - Return `BlobRef` (CID, MIME type, size) - [ ] Implement `blob_download` command - Call `com.atproto.sync.getBlob` with DID + CID - - Pipe response bytes through `image_import` (hash, dedup, store) - - Return local `.writer-assets/.` path + - Pipe response bytes through `image_import` with `target_dir` + - Return local relative path - [ ] Fix `image_from_url` metadata in `leaflet.rs` - - Replace hardcoded `application/octet-stream` / size 0 with real values from `blob_upload` response - - Thread `BlobRef` metadata through publish pipeline into `Image` block construction + - Replace hardcoded `application/octet-stream` / size 0 with real `BlobRef` values + - Thread metadata through publish pipeline into `Image` block construction - [ ] Register `blob_upload` and `blob_download` in `lib.rs` ### Publish Pipeline Integration - [ ] Add image rewrite step to publish flow - - Scan markdown for `.writer-assets/` image paths before Leaflet conversion - - Upload each via `blob_upload`, collect CID mapping - - Rewrite `.writer-assets/.` → `at://blob/` in-memory (don't mutate local document) + - Scan markdown for local image paths before Leaflet conversion + - Resolve each via `asset_resolve`, upload via `blob_upload`, collect CID mapping + - Rewrite local paths → `at://blob/` in-memory (don't mutate local document) - Pass rewritten markdown to `markdown_to_leaflet_document` ### Import Pipeline Integration - [ ] Add blob download step to Standard.Site post import - - After `post_get_markdown`, scan result for `at://blob/` image refs - - For each, call `blob_download` with author DID + CID - - Rewrite `at://blob/` → `.writer-assets/.` in the markdown - - Save the rewritten markdown to disk + - After `post_get_markdown`, scan for `at://blob/` image refs + - For each, call `blob_download` with author DID + CID + target dir + - Rewrite `at://blob/` → `.` in the markdown + - Save rewritten markdown to disk ### Frontend - [ ] Add command builders in `src/ports/commands.ts` - - `blobUpload(locationId, assetPath, auth, onOk, onErr)` - - `blobDownload(locationId, did, cid, onOk, onErr)` + - `blobUpload(locationId, assetRelPath, auth, onOk, onErr)` + - `blobDownload(locationId, did, cid, targetDir, onOk, onErr)` - [ ] Update Standard.Site import controller to call blob download + rewrite - -## PDF Export with Embedded Images - -### Backend (Rust) - -- [x] Add `Image` variant to `PdfNode` enum in `crates/markdown/src/lib.rs` - - Fields: `src: String`, `alt: String` -- [x] Update `transform_to_pdf_nodes()` in `crates/markdown/src/transformer.rs` - - Handle Comrak image nodes → emit `PdfNode::Image` -- [x] Update `PdfRenderResult` serialization to include new variant - -### Frontend - -- [x] Add image path resolution for PDF renderer - - Resolve `.writer-assets/` paths to base64 data URLs (similar to font preloading in `src/pdf/fonts.ts`) - - Use `convertFileSrc()` → fetch bytes → encode as `data:;base64,...` -- [x] Add `Image` case to `MarkdownPdfDocument.tsx` node renderer - - Render `` with `maxWidth: 100%`, preserve aspect ratio -- [x] Update `usePdfExport.tsx` to preload images before render - - Scan PdfNodes for Image variants, resolve all paths, then render -- [x] Handle SVG gracefully - convert to PNG on the backend - -## Test/QA Plan - -- [ ] Test import with each supported format -- [ ] Test dedup (import same image twice) -- [ ] Test paste and drag-and-drop flows -- [ ] Test preview rendering with nested document paths -- [ ] Test blob upload round-trip (local image → upload → verify CID returned) -- [ ] Test blob download round-trip (CID → download → verify stored in `.writer-assets/`) -- [ ] Test publish with images (`.writer-assets/` refs rewritten to `at://blob/` in output) -- [ ] Test import with images (`at://blob/` refs rewritten to `.writer-assets/` in saved markdown) -- [ ] Test PDF export with embedded images (images render in output PDF) -- [ ] Test PDF export with missing image (graceful fallback, no crash) diff --git a/docs/tasks/parking-lot.md b/docs/tasks/parking-lot.md index 0d55e2f..1338565 100644 --- a/docs/tasks/parking-lot.md +++ b/docs/tasks/parking-lot.md @@ -2,7 +2,11 @@ title: "Parking Lot" description: > A collection of ideas/proposals for new features and quick bug notes. -updated: 2026-03-23 +updated: 2026-03-24 +--- + +- Consider using [ignore](https://crates.io/crates/ignore) crate for directory walking. + --- 1. **CJK Font Support**[^1][^2][^3] ✅ diff --git a/src-tauri/src/commands/images.rs b/src-tauri/src/commands/images.rs index 0e91c76..de7b5fa 100644 --- a/src-tauri/src/commands/images.rs +++ b/src-tauri/src/commands/images.rs @@ -1,23 +1,31 @@ use super::{AppState, CommandResponse}; use std::path::PathBuf; use tauri::State; -use writer_core::{AppError, CommandResult, ImageAsset, LocationId}; +use writer_core::{CommandResult, LocationId}; -/// Imports an image file into `.writer-assets/` for the given location. +/// Imports an image into the given location, placing it in `target_dir`. /// /// Validates format (PNG, JPEG, GIF, WebP, SVG) and size (≤ 10 MiB), -/// hashes the file with blake3, and copies it to `.writer-assets/.`. -/// If the hash already exists the call is a no-op and returns the existing path. +/// hashes the file with blake3, and copies to `/.`. +/// If the hash already exists in `target_dir` the call is a no-op. /// -/// Returns the relative asset path, e.g. `.writer-assets/abc123.png`. +/// `target_dir` is relative to the location root; pass `""` to place in the root. +/// Returns the relative path from the location root, e.g. `abc123.png` or `drafts/abc123.png`. #[tauri::command] -pub fn image_import(state: State<'_, AppState>, location_id: i64, source_path: String) -> CommandResponse { +pub fn image_import( + state: State<'_, AppState>, location_id: i64, source_path: String, target_dir: String, +) -> CommandResponse { let location_id = LocationId(location_id); let source = PathBuf::from(&source_path); - log::debug!("image_import: location={:?}, source={:?}", location_id, source); + log::debug!( + "image_import: location={:?}, source={:?}, target_dir={}", + location_id, + source, + target_dir + ); - match state.store.image_import(location_id, &source) { + match state.store.image_import(location_id, &source, &target_dir) { Ok(rel_path) => { log::info!("image_import: ok → {}", rel_path); Ok(CommandResult::ok(rel_path)) @@ -112,27 +120,3 @@ pub fn svg_to_png( } } } - -/// Lists all image assets in `.writer-assets/` for the given location. -/// -/// Returns an empty vec if the assets directory does not exist yet. -#[tauri::command] -pub fn image_list(state: State<'_, AppState>, location_id: i64) -> CommandResponse> { - let location_id = LocationId(location_id); - - log::debug!("image_list: location={:?}", location_id); - - match state.store.image_list(location_id) { - Ok(assets) => { - log::debug!("image_list: {} assets", assets.len()); - Ok(CommandResult::ok(assets)) - } - Err(e) => { - log::error!("image_list failed: {}", e); - Ok(CommandResult::err(AppError::io(format!( - "Failed to list images: {}", - e - )))) - } - } -} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 144e5a9..a61e2c6 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -150,7 +150,6 @@ pub fn run() { cmd::markdown_help_get, cmd::image_import, cmd::image_delete, - cmd::image_list, cmd::asset_resolve, cmd::svg_to_png, ]) diff --git a/src-tauri/src/locations.rs b/src-tauri/src/locations.rs index 18163bd..f17e36f 100644 --- a/src-tauri/src/locations.rs +++ b/src-tauri/src/locations.rs @@ -256,10 +256,12 @@ pub(super) fn handle_watcher_event( None => continue, }; - // TODO: verify that effect of not indexing asset files is acceptable - if rel_path.starts_with(writer_core::ASSETS_DIR_NAME) { + let in_hidden_dir = rel_path + .components() + .any(|c| c.as_os_str().to_str().map(|s| s.starts_with('.')).unwrap_or(false)); + if in_hidden_dir { log::debug!( - "Watcher skipping asset path: {:?} in location {:?}", + "Watcher skipping hidden path: {:?} in location {:?}", rel_path, location_id ); diff --git a/src/__tests__/ports.test.ts b/src/__tests__/ports.test.ts index 22f3da9..a7ddf1f 100644 --- a/src/__tests__/ports.test.ts +++ b/src/__tests__/ports.test.ts @@ -19,7 +19,6 @@ import { globalCaptureValidateShortcut, imageDelete, imageImport, - imageList, invokeCmd, isErr, isOk, @@ -1283,11 +1282,11 @@ describe("global capture Commands", () => { it("imageImport builds correct InvokeCmd", () => { const onOk = vi.fn(); const onErr = vi.fn(); - const cmd = imageImport(7, "/tmp/photo.png", onOk, onErr) as InvokeCmd; + const cmd = imageImport(7, "/tmp/photo.png", "drafts", onOk, onErr) as InvokeCmd; expect(cmd.type).toBe("Invoke"); expect(cmd.command).toBe("image_import"); - expect(cmd.payload).toStrictEqual({ locationId: 7, sourcePath: "/tmp/photo.png" }); + expect(cmd.payload).toStrictEqual({ locationId: 7, sourcePath: "/tmp/photo.png", targetDir: "drafts" }); }); it("imageDelete builds correct InvokeCmd", () => { @@ -1299,50 +1298,5 @@ describe("global capture Commands", () => { expect(cmd.command).toBe("image_delete"); expect(cmd.payload).toStrictEqual({ locationId: 7, assetPath: ".writer-assets/abc123.png" }); }); - - it("imageList builds correct InvokeCmd", () => { - const onOk = vi.fn(); - const onErr = vi.fn(); - const cmd = imageList(7, onOk, onErr) as InvokeCmd; - - expect(cmd.type).toBe("Invoke"); - expect(cmd.command).toBe("image_list"); - expect(cmd.payload).toStrictEqual({ locationId: 7 }); - }); - - it("runCmd + image_list normalizes size_bytes to sizeBytes", async () => { - const onOk = vi.fn(); - const onErr = vi.fn(); - - vi.mocked(invoke).mockResolvedValueOnce({ - type: "ok", - value: [{ filename: "abc123.png", size_bytes: 204_800, extension: "png" }, { - filename: "def456.jpg", - size_bytes: 512_000, - extension: "jpg", - }], - }); - - await runCmd(imageList(7, onOk, onErr)); - - expect(onOk).toHaveBeenCalledWith([{ filename: "abc123.png", sizeBytes: 204_800, extension: "png" }, { - filename: "def456.jpg", - sizeBytes: 512_000, - extension: "jpg", - }]); - expect(onErr).not.toHaveBeenCalled(); - }); - - it("runCmd + image_list returns empty array for non-array response", async () => { - const onOk = vi.fn(); - const onErr = vi.fn(); - - vi.mocked(invoke).mockResolvedValueOnce({ type: "ok", value: null }); - - await runCmd(imageList(7, onOk, onErr)); - - expect(onOk).toHaveBeenCalledWith([]); - expect(onErr).not.toHaveBeenCalled(); - }); }); }); diff --git a/src/__tests__/useEditorImageHandlers.test.ts b/src/__tests__/useEditorImageHandlers.test.ts index d1f24d4..ad74365 100644 --- a/src/__tests__/useEditorImageHandlers.test.ts +++ b/src/__tests__/useEditorImageHandlers.test.ts @@ -21,7 +21,7 @@ describe("useEditorImageHandlers", () => { describe("handleImageFilePaste", () => { it("returns false and skips when locationId is null", async () => { - const { result } = renderHook(() => useEditorImageHandlers(null)); + const { result } = renderHook(() => useEditorImageHandlers(null, null)); const file = new File([new Uint8Array([1, 2, 3])], "photo.png", { type: "image/png" }); @@ -37,7 +37,7 @@ describe("useEditorImageHandlers", () => { }); it("skips unsupported MIME types", async () => { - const { result } = renderHook(() => useEditorImageHandlers(1)); + const { result } = renderHook(() => useEditorImageHandlers(1, "doc.md")); const file = new File([new Uint8Array([1])], "doc.pdf", { type: "application/pdf" }); @@ -53,9 +53,9 @@ describe("useEditorImageHandlers", () => { }); it("writes PNG to temp file, calls importImage, and sets insertAt", async () => { - mockImportImage.mockResolvedValueOnce(".writer-assets/abc123.png"); + mockImportImage.mockResolvedValueOnce("abc123.png"); - const { result } = renderHook(() => useEditorImageHandlers(7)); + const { result } = renderHook(() => useEditorImageHandlers(7, "photo.md")); const bytes = new Uint8Array([137, 80, 78, 71]); const file = new File([bytes], "photo.png", { type: "image/png" }); @@ -72,15 +72,15 @@ describe("useEditorImageHandlers", () => { expect(writtenPath).toMatch(/^\/tmp\/writer-paste-\d+\.png$/); expect(writtenBytes).toBeInstanceOf(Uint8Array); - expect(mockImportImage).toHaveBeenCalledWith(7, expect.stringMatching(/\.png$/)); + expect(mockImportImage).toHaveBeenCalledWith(7, expect.stringMatching(/\.png$/), ""); - expect(result.current.insertAt).toStrictEqual({ text: "![image](.writer-assets/abc123.png)", requestId: 1 }); + expect(result.current.insertAt).toStrictEqual({ text: "![image](abc123.png)", requestId: 1 }); }); it("maps JPEG MIME type to .jpg extension", async () => { - mockImportImage.mockResolvedValueOnce(".writer-assets/abc.jpg"); + mockImportImage.mockResolvedValueOnce("abc.jpg"); - const { result } = renderHook(() => useEditorImageHandlers(3)); + const { result } = renderHook(() => useEditorImageHandlers(3, "drafts/doc.md")); const file = new File([new Uint8Array([1])], "photo.jpg", { type: "image/jpeg" }); @@ -98,7 +98,7 @@ describe("useEditorImageHandlers", () => { it("does not update insertAt when importImage returns false", async () => { mockImportImage.mockResolvedValueOnce(false); - const { result } = renderHook(() => useEditorImageHandlers(1)); + const { result } = renderHook(() => useEditorImageHandlers(1, "doc.md")); const file = new File([new Uint8Array([1])], "photo.png", { type: "image/png" }); @@ -113,11 +113,9 @@ describe("useEditorImageHandlers", () => { }); it("increments requestId on each paste", async () => { - mockImportImage.mockResolvedValueOnce(".writer-assets/first.png").mockResolvedValueOnce( - ".writer-assets/second.png", - ); + mockImportImage.mockResolvedValueOnce("first.png").mockResolvedValueOnce("second.png"); - const { result } = renderHook(() => useEditorImageHandlers(1)); + const { result } = renderHook(() => useEditorImageHandlers(1, "doc.md")); const file = new File([new Uint8Array([1])], "a.png", { type: "image/png" }); @@ -143,7 +141,7 @@ describe("useEditorImageHandlers", () => { describe("handlePickAndInsertImage", () => { it("does nothing when locationId is null", async () => { - const { result } = renderHook(() => useEditorImageHandlers(null)); + const { result } = renderHook(() => useEditorImageHandlers(null, null)); await act(async () => { await result.current.handlePickAndInsertImage(); @@ -156,7 +154,7 @@ describe("useEditorImageHandlers", () => { it("does nothing when dialog is cancelled", async () => { vi.mocked(open).mockResolvedValueOnce(null); - const { result } = renderHook(() => useEditorImageHandlers(5)); + const { result } = renderHook(() => useEditorImageHandlers(5, "doc.md")); await act(async () => { await result.current.handlePickAndInsertImage(); @@ -168,9 +166,9 @@ describe("useEditorImageHandlers", () => { it("opens dialog with image filters and inserts markdown on success", async () => { vi.mocked(open).mockResolvedValueOnce("/Users/me/photo.png"); - mockImportImage.mockResolvedValueOnce(".writer-assets/hash.png"); + mockImportImage.mockResolvedValueOnce("hash.png"); - const { result } = renderHook(() => useEditorImageHandlers(4)); + const { result } = renderHook(() => useEditorImageHandlers(4, "drafts/doc.md")); await act(async () => { await result.current.handlePickAndInsertImage(); @@ -180,15 +178,15 @@ describe("useEditorImageHandlers", () => { multiple: false, filters: [{ name: "Images", extensions: ["png", "jpg", "jpeg", "gif", "webp", "svg"] }], }); - expect(mockImportImage).toHaveBeenCalledWith(4, "/Users/me/photo.png"); - expect(result.current.insertAt).toStrictEqual({ text: "![image](.writer-assets/hash.png)", requestId: 1 }); + expect(mockImportImage).toHaveBeenCalledWith(4, "/Users/me/photo.png", "drafts"); + expect(result.current.insertAt).toStrictEqual({ text: "![image](hash.png)", requestId: 1 }); }); it("does not set insertAt when importImage returns false", async () => { vi.mocked(open).mockResolvedValueOnce("/Users/me/photo.png"); mockImportImage.mockResolvedValueOnce(false); - const { result } = renderHook(() => useEditorImageHandlers(4)); + const { result } = renderHook(() => useEditorImageHandlers(4, "doc.md")); await act(async () => { await result.current.handlePickAndInsertImage(); @@ -208,7 +206,7 @@ describe("useEditorImageHandlers", () => { }), } as never); - renderHook(() => useEditorImageHandlers(1)); + renderHook(() => useEditorImageHandlers(1, "doc.md")); await act(async () => { await dropListener?.({ payload: { type: "drop", position: { x: 0, y: 0 }, paths: ["/tmp/photo.png"] } }); @@ -231,9 +229,9 @@ describe("useEditorImageHandlers", () => { document.body.append(editorEl); vi.spyOn(document, "elementFromPoint").mockReturnValue(editorEl); - mockImportImage.mockResolvedValueOnce(".writer-assets/drop.png"); + mockImportImage.mockResolvedValueOnce("drop.png"); - const { result } = renderHook(() => useEditorImageHandlers(2)); + const { result } = renderHook(() => useEditorImageHandlers(2, "drafts/doc.md")); await act(async () => { await dropListener?.({ @@ -241,8 +239,8 @@ describe("useEditorImageHandlers", () => { }); }); - expect(mockImportImage).toHaveBeenCalledWith(2, "/home/user/drop.png"); - expect(result.current.insertAt).toStrictEqual({ text: "![image](.writer-assets/drop.png)", requestId: 1 }); + expect(mockImportImage).toHaveBeenCalledWith(2, "/home/user/drop.png", "drafts"); + expect(result.current.insertAt).toStrictEqual({ text: "![image](drop.png)", requestId: 1 }); editorEl.remove(); }); @@ -261,7 +259,7 @@ describe("useEditorImageHandlers", () => { document.body.append(editorEl); vi.spyOn(document, "elementFromPoint").mockReturnValue(editorEl); - renderHook(() => useEditorImageHandlers(1)); + renderHook(() => useEditorImageHandlers(1, "doc.md")); await act(async () => { await dropListener?.({ @@ -283,7 +281,7 @@ describe("useEditorImageHandlers", () => { }), } as never); - renderHook(() => useEditorImageHandlers(1)); + renderHook(() => useEditorImageHandlers(1, "doc.md")); await act(async () => { await dropListener?.({ payload: { type: "enter", position: { x: 0, y: 0 }, paths: ["/tmp/a.png"] } }); diff --git a/src/hooks/controllers/useWorkspaceViewController.ts b/src/hooks/controllers/useWorkspaceViewController.ts index c9047fb..13c1774 100644 --- a/src/hooks/controllers/useWorkspaceViewController.ts +++ b/src/hooks/controllers/useWorkspaceViewController.ts @@ -115,7 +115,11 @@ export function useWorkspaceViewController(): WorkspaceViewController { }); const imageLocationId = editorModel.docRef?.location_id ?? null; - const { insertAt, handleImageFilePaste, handlePickAndInsertImage } = useEditorImageHandlers(imageLocationId); + const imageDocRelPath = editorModel.docRef?.rel_path ?? null; + const { insertAt, handleImageFilePaste, handlePickAndInsertImage } = useEditorImageHandlers( + imageLocationId, + imageDocRelPath, + ); const { handleOpenPdfExport, handleExportPdf, previewResult, activeDocLocationId, activeDocRelPath } = usePdfExportUI( { activeTab, text: editorModel.text, editorFontFamily: editorPresentation.fontFamily, exportPdf }, ); diff --git a/src/hooks/useEditorImageHandlers.ts b/src/hooks/useEditorImageHandlers.ts index b40dadd..ce3ce2e 100644 --- a/src/hooks/useEditorImageHandlers.ts +++ b/src/hooks/useEditorImageHandlers.ts @@ -19,7 +19,15 @@ const MIME_TO_EXT: Record = { export type EditorInsertAction = { text: string; requestId: number }; function imageMarkdown(assetPath: string): string { - return `![image](${assetPath})`; + const filename = assetPath.split("/").pop() ?? assetPath; + return `![image](${filename})`; +} + +function targetDirFromRelPath(relPath: string | null): string { + if (!relPath) return ""; + const parts = relPath.split("/"); + parts.pop(); + return parts.join("/"); } function isImagePath(filePath: string): boolean { @@ -35,6 +43,7 @@ function isImagePath(filePath: string): boolean { */ export function useEditorImageHandlers( locationId: number | null, + docRelPath: string | null, ): { insertAt: EditorInsertAction | null; handleImageFilePaste: (file: File) => void; @@ -65,7 +74,7 @@ export function useEditorImageHandlers( const tempPath = `/tmp/writer-paste-${Date.now()}.${ext}`; const bytes = new Uint8Array(await file.arrayBuffer()); await writeFile(tempPath, bytes); - const assetPath = await importImage(locationId, tempPath); + const assetPath = await importImage(locationId, tempPath, targetDirFromRelPath(docRelPath)); if (assetPath) { triggerInsert(assetPath); } @@ -73,7 +82,7 @@ export function useEditorImageHandlers( void logger.error(f("Image paste failed", { error: String(err) })); } })(); - }, [locationId, importImage, triggerInsert]); + }, [locationId, docRelPath, importImage, triggerInsert]); const handlePickAndInsertImage = useCallback(async () => { if (!locationId) { @@ -87,14 +96,14 @@ export function useEditorImageHandlers( return; } - const assetPath = await importImage(locationId, selected); + const assetPath = await importImage(locationId, selected, targetDirFromRelPath(docRelPath)); if (assetPath) { triggerInsert(assetPath); } } catch (err) { void logger.error(f("Image pick failed", { error: String(err) })); } - }, [locationId, importImage, triggerInsert]); + }, [locationId, docRelPath, importImage, triggerInsert]); useEffect(() => { let unlisten: (() => void) | null = null; @@ -116,7 +125,7 @@ export function useEditorImageHandlers( return; } - const assetPath = await importImage(locationId, imagePaths[0]); + const assetPath = await importImage(locationId, imagePaths[0], targetDirFromRelPath(docRelPath)); if (assetPath) { triggerInsert(assetPath); } @@ -129,7 +138,7 @@ export function useEditorImageHandlers( return () => { unlisten?.(); }; - }, [locationId, importImage, triggerInsert]); + }, [locationId, docRelPath, importImage, triggerInsert]); return { insertAt, handleImageFilePaste, handlePickAndInsertImage }; } diff --git a/src/hooks/useImageController.ts b/src/hooks/useImageController.ts index e37965c..13e8f8a 100644 --- a/src/hooks/useImageController.ts +++ b/src/hooks/useImageController.ts @@ -5,20 +5,17 @@ import * as logger from "@tauri-apps/plugin-log"; import { useCallback, useMemo } from "react"; /** - * Controller hook for local image asset operations. - * - * Covers `importImage` and `deleteImage`. - * Image listing is available via the `imageList` port directly when needed on-demand. + * Controller hook for local image asset operations: `importImage` and `deleteImage`. */ export function useImageController() { const importImage = useCallback( - (locationId: number, sourcePath: string): Promise => + (locationId: number, sourcePath: string, targetDir: string): Promise => new Promise((resolve) => { - runCmd(imageImport(locationId, sourcePath, (assetPath) => { - logger.info(f("Image imported", { locationId, sourcePath, assetPath })); + runCmd(imageImport(locationId, sourcePath, targetDir, (assetPath) => { + logger.info(f("Image imported", { locationId, sourcePath, targetDir, assetPath })); resolve(assetPath); }, (error: AppError) => { - logger.error(f("Failed to import image", { locationId, sourcePath, error })); + logger.error(f("Failed to import image", { locationId, sourcePath, targetDir, error })); resolve(false); })); }), diff --git a/src/ports/commands.ts b/src/ports/commands.ts index 6c6d728..54e428d 100644 --- a/src/ports/commands.ts +++ b/src/ports/commands.ts @@ -6,7 +6,6 @@ import type { DocContent, DocMeta, GlobalCaptureSettings, - ImageAsset, LocationDescriptor, LocationId, MarkdownProfile, @@ -46,7 +45,6 @@ import type { GlobalCaptureValidateShortcutParams, ImageDeleteParams, ImageImportParams, - ImageListParams, LocParams, PersistedSidebarTreeState, PersistedStyleCheckSettings, @@ -478,18 +476,14 @@ export function appVersionGet(...[onOk, onErr]: LocParams): Cmd { return invokeCmd("app_version_get", {}, onOk, onErr); } -export function imageImport(...[locationId, sourcePath, onOk, onErr]: ImageImportParams): Cmd { - return invokeCmd("image_import", { locationId, sourcePath }, onOk, onErr); +export function imageImport(...[locationId, sourcePath, targetDir, onOk, onErr]: ImageImportParams): Cmd { + return invokeCmd("image_import", { locationId, sourcePath, targetDir }, onOk, onErr); } export function imageDelete(...[locationId, assetPath, onOk, onErr]: ImageDeleteParams): Cmd { return invokeCmd("image_delete", { locationId, assetPath }, onOk, onErr); } -export function imageList(...[locationId, onOk, onErr]: ImageListParams): Cmd { - return invokeCmd("image_list", { locationId }, onOk, onErr); -} - export function assetResolve(...[locationId, docRelPath, assetPath, onOk, onErr]: AssetResolveParams): Cmd { return invokeCmd("asset_resolve", { locationId, docRelPath, assetPath }, onOk, onErr); } diff --git a/src/ports/invoke.ts b/src/ports/invoke.ts index 8ffb3cd..85f5679 100644 --- a/src/ports/invoke.ts +++ b/src/ports/invoke.ts @@ -8,7 +8,6 @@ import type { DocRef, ErrorCode, GlobalCaptureSettings, - ImageAsset, PublicationListResult, PublicationRecord, SearchHit, @@ -310,18 +309,6 @@ function normalizePublicationListResult(value: unknown): PublicationListResult { }; } -function normalizeImageAsset(value: unknown): ImageAsset { - if (!isRecord(value)) { - return { filename: "", sizeBytes: 0, extension: "" }; - } - - return { - filename: typeof value.filename === "string" ? value.filename : "", - sizeBytes: typeof value.size_bytes === "number" ? value.size_bytes : 0, - extension: typeof value.extension === "string" ? value.extension : "", - }; -} - function normalizeSessionState(value: unknown): SessionState { if (!isRecord(value) || !Array.isArray(value.tabs)) { return { tabs: [], activeTabId: null }; @@ -410,13 +397,6 @@ function normalizeCommandValue(command: string, value: unknown): unknown { case "publication_list": { return normalizePublicationListResult(value); } - case "image_list": { - if (!Array.isArray(value)) { - return []; - } - - return value.map((asset) => normalizeImageAsset(asset)); - } case "style_check_scan": { if (!Array.isArray(value)) { return []; diff --git a/src/ports/types.ts b/src/ports/types.ts index 6df3479..1c8eb30 100644 --- a/src/ports/types.ts +++ b/src/ports/types.ts @@ -332,11 +332,14 @@ export type PostGetMarkdownParams = Parameters< >; export type ImageImportParams = Parameters< - (locationId: LocationId, sourcePath: string, onOk: SuccessCallback, onErr: ErrorCallback) => void + ( + locationId: LocationId, + sourcePath: string, + targetDir: string, + onOk: SuccessCallback, + onErr: ErrorCallback, + ) => void >; export type ImageDeleteParams = Parameters< (locationId: LocationId, assetPath: string, onOk: SuccessCallback, onErr: ErrorCallback) => void >; -export type ImageListParams = Parameters< - (locationId: LocationId, onOk: SuccessCallback, onErr: ErrorCallback) => void ->; -- 2.51.2