From 29cdd016b8e52fefe7c3a603dfe9221acc06b01c Mon Sep 17 00:00:00 2001 From: Chili Palmer Date: Mon, 20 Jul 2026 16:19:14 +0200 Subject: [PATCH] Implement post editor image drops. --- DOCUMENTATION.md | 5 +- README.md | 2 +- crates/bds-core/src/engine/gallery_import.rs | 76 +-- crates/bds-core/src/engine/media.rs | 9 +- crates/bds-core/src/engine/post.rs | 21 +- crates/bds-ui/src/app.rs | 497 ++++++++++++++++++- crates/bds-ui/src/views/post_editor.rs | 17 + locales/ui/de.ftl | 6 + locales/ui/en.ftl | 6 + locales/ui/es.ftl | 6 + locales/ui/fr.ftl | 6 + locales/ui/it.ftl | 6 + specs/action_patterns.allium | 2 +- specs/editor_post.allium | 6 +- specs/modals.allium | 4 +- 15 files changed, 616 insertions(+), 53 deletions(-) diff --git a/DOCUMENTATION.md b/DOCUMENTATION.md index 847fc9b..bf628a8 100644 --- a/DOCUMENTATION.md +++ b/DOCUMENTATION.md @@ -150,12 +150,15 @@ The Media section is where you import, describe, and maintain assets used by pos When importing media, add metadata while context is still fresh. Alt text should describe meaning for accessibility. Captions should support reader understanding. Media tags should help later retrieval and reuse. -From the post editor you can insert already-imported media at the cursor position, or use the Gallery workflow to import several images at once: RuDS imports them into the media library, links them to the current post, runs the optional AI enrichment for titles and alt text, and inserts the gallery block into the post. +From the post editor you can insert already-imported media at the cursor position, drag supported image files onto an open post editor, or use the Gallery workflow to import several images at once. A dropped image is imported into the media library, linked to the current post, and inserted at the current cursor with a host-absolute `/media/...` URL that remains valid in rendered HTML. Multiple dropped files are processed in order with progress in the Tasks panel. Optional AI enrichment and media-metadata translation continue in the background; when the active AI endpoint is unavailable, the import still completes and RuDS reports that enrichment was skipped. + +The Gallery workflow imports its selected images into the media library, links them to the current post, runs the same optional AI enrichment for titles and alt text, and inserts the gallery block into the post. ### Key takeaways - Media management includes metadata quality, not only file import. - Add alt text and captions during import, not as a postponed task. +- Drag images onto an open post editor for a direct import, link, and cursor insertion. - Commit content and related media in the same change when possible. [↑ Back to In this article](#in-this-article) diff --git a/README.md b/README.md index f0392b8..5d503fd 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ The project is under active development. Core blogging workflows are broadly ava - Native Iced desktop workspace with localized menus, tabs, automatically paged post/media sidebars, dialogs, tasks, embedded Wry previews, and a live Pico CSS theme editor. - Post and translation authoring with draft/published lifecycle, metadata, tags, categories, links, media, and batch gallery-image import. -- Media import, thumbnails, metadata translations, filters, validation, and post assignment. +- Media import, thumbnails, metadata translations, filters, validation, post assignment, and sequential drag-and-drop insertion into post editors. - WordPress WXR migration with saved analyses, HTML-to-Markdown and shortcode conversion, conflict/taxonomy review, recoverable 500-item execution batches, media-parent linking, progress reporting, and optional AI-assisted taxonomy mapping. - Template and Lua script management with explicit syntax-check feedback, using a custom Ropey/Syntect/Cosmic Text editor and the documented, bDS2-signature-compatible project-scoped [`bds` host API](docs/scripting/API_REFERENCE.md) across utilities, rendered macros, and Blogmark transforms, including airplane-gated Git sync. - SQLite and filesystem persistence with frontmatter, sidecars, rebuild, metadata diff/repair, and FTS5 search. diff --git a/crates/bds-core/src/engine/gallery_import.rs b/crates/bds-core/src/engine/gallery_import.rs index 8b5d952..ab7aa0d 100644 --- a/crates/bds-core/src/engine/gallery_import.rs +++ b/crates/bds-core/src/engine/gallery_import.rs @@ -152,32 +152,14 @@ fn import_gallery_image( offline_mode: bool, translation_targets: &[String], ) -> Result { - let original_name = path - .file_name() - .map(|name| name.to_string_lossy().to_string()) - .unwrap_or_else(|| "image".to_string()); let db = Database::open(db_path).map_err(|error| error.to_string())?; - let imported = media::import_media( - db.conn(), - data_dir, - project_id, - path, - &original_name, - None, - None, - None, - None, - Some(source_language), - Vec::new(), - ) - .map_err(|error| error.to_string())?; - - post_media::link_media_to_post( + let imported = import_and_link_image( db.conn(), data_dir, project_id, post_id, - &imported.id, + path, + source_language, sort_order, ) .map_err(|error| error.to_string())?; @@ -190,7 +172,7 @@ fn import_gallery_image( offline_mode, translation_targets, ) - .unwrap_or_else(|| imported.original_name.clone()) + .unwrap_or_else(|_| imported.original_name.clone()) } else { imported.original_name.clone() }; @@ -201,6 +183,43 @@ fn import_gallery_image( }) } +pub fn import_and_link_image( + conn: &crate::db::DbConnection, + data_dir: &Path, + project_id: &str, + post_id: &str, + path: &Path, + source_language: &str, + sort_order: i32, +) -> crate::engine::EngineResult { + let original_name = path + .file_name() + .map(|name| name.to_string_lossy().to_string()) + .unwrap_or_else(|| "image".to_string()); + let imported = media::import_media( + conn, + data_dir, + project_id, + path, + &original_name, + None, + None, + None, + None, + Some(source_language), + Vec::new(), + )?; + post_media::link_media_to_post( + conn, + data_dir, + project_id, + post_id, + &imported.id, + sort_order, + )?; + Ok(imported) +} + /// Apply the shared gallery AI enrichment and translation pipeline to one /// already-imported image. Returns the generated title when AI was available. pub fn enrich_imported_image( @@ -209,14 +228,13 @@ pub fn enrich_imported_image( imported: &crate::model::Media, offline_mode: bool, translation_targets: &[String], -) -> Option { +) -> Result { let image_data_url = build_ai_image_data_url( data_dir, &imported.id, &imported.file_path, &imported.mime_type, - ) - .ok()?; + )?; let response = ai::run_one_shot( conn, offline_mode, @@ -232,9 +250,9 @@ pub fn enrich_imported_image( }), }, ) - .ok()?; + .map_err(|error| error.to_string())?; let ai::OneShotResponse::ImageAnalysis(analysis) = response.0 else { - return None; + return Err("AI returned an unexpected response".to_string()); }; media::update_media( conn, @@ -247,7 +265,7 @@ pub fn enrich_imported_image( None, None, ) - .ok()?; + .map_err(|error| error.to_string())?; for target in translation_targets { let Ok((ai::OneShotResponse::MediaTranslation(translation), _)) = ai::run_one_shot( @@ -277,7 +295,7 @@ pub fn enrich_imported_image( ); } - Some(if analysis.title.is_empty() { + Ok(if analysis.title.is_empty() { imported.original_name.clone() } else { analysis.title diff --git a/crates/bds-core/src/engine/media.rs b/crates/bds-core/src/engine/media.rs index 54b1eb1..1963f7b 100644 --- a/crates/bds-core/src/engine/media.rs +++ b/crates/bds-core/src/engine/media.rs @@ -156,6 +156,13 @@ const SUPPORTED_IMAGE_TYPES: &[&str] = &[ "image/heif", ]; +pub fn is_supported_image_path(path: &Path) -> bool { + path.extension() + .and_then(|extension| extension.to_str()) + .map(mime_from_extension) + .is_some_and(|mime_type| SUPPORTED_IMAGE_TYPES.contains(&mime_type)) +} + /// Import a media file (image, etc.) into the project. #[expect( clippy::too_many_arguments, @@ -217,7 +224,7 @@ pub(crate) fn import_media_at( .and_then(|e| e.to_str()) .unwrap_or("bin"); let mime_type = mime_from_extension(ext).to_string(); - if !SUPPORTED_IMAGE_TYPES.contains(&mime_type.as_str()) { + if !is_supported_image_path(Path::new(original_name)) { return Err(EngineError::Validation(format!( "unsupported file type: {mime_type} (file: {original_name})" ))); diff --git a/crates/bds-core/src/engine/post.rs b/crates/bds-core/src/engine/post.rs index 317ed32..c52a70c 100644 --- a/crates/bds-core/src/engine/post.rs +++ b/crates/bds-core/src/engine/post.rs @@ -1119,12 +1119,13 @@ pub fn post_insert_link(slug: &str) -> String { } /// Insert a media reference in the editor buffer. -/// Returns the Markdown syntax: ![alt](bds-media://id) or [name](bds-media://id) -pub fn post_insert_media(media_id: &str, is_image: bool, original_name: &str) -> String { +/// Returns Markdown with a host-absolute media URL suitable for rendered HTML. +pub fn post_insert_media(media_path: &str, is_image: bool, original_name: &str) -> String { + let url = format!("/{}", media_path.trim_start_matches('/')); if is_image { - format!("![](bds-media://{media_id})") + format!("![]({url})") } else { - format!("[{original_name}](bds-media://{media_id})") + format!("[{original_name}]({url})") } } @@ -1177,6 +1178,18 @@ mod tests { assert_eq!(fetched.status, PostStatus::Draft); } + #[test] + fn inserted_media_uses_host_absolute_renderable_paths() { + assert_eq!( + post_insert_media("media/2026/07/image.png", true, "image.png"), + "![](/media/2026/07/image.png)" + ); + assert_eq!( + post_insert_media("/media/2026/07/file.pdf", false, "file.pdf"), + "[file.pdf](/media/2026/07/file.pdf)" + ); + } + #[test] fn create_post_empty_title_uses_untitled() { let (db, dir) = setup(); diff --git a/crates/bds-ui/src/app.rs b/crates/bds-ui/src/app.rs index c3a66a3..a7f6ac2 100644 --- a/crates/bds-ui/src/app.rs +++ b/crates/bds-ui/src/app.rs @@ -1,4 +1,4 @@ -use std::collections::{HashMap, HashSet}; +use std::collections::{HashMap, HashSet, VecDeque}; use std::path::{Path, PathBuf}; use std::sync::{Arc, Mutex}; @@ -112,6 +112,23 @@ pub enum Message { post_id: String, report: engine::gallery_import::GalleryImportReport, }, + FileDropped(PathBuf), + ImageDropImported { + task_id: TaskId, + post_id: String, + project_id: String, + data_dir: PathBuf, + source_language: String, + offline_mode: bool, + path: PathBuf, + result: Result, + }, + ImageDropEnriched { + task_id: TaskId, + post_id: String, + path: PathBuf, + result: Result, + }, MediaImportFinished { imported: Vec, errors: Vec, @@ -404,6 +421,16 @@ struct SiteGenerationWorkflow { report: engine::generation::GenerationReport, } +#[derive(Debug, Clone)] +struct ImageDropRequest { + post_id: String, + project_id: String, + data_dir: PathBuf, + source_language: String, + offline_mode: bool, + path: PathBuf, +} + enum PersistedPostState { Canonical(Box), Translation(Box), @@ -910,6 +937,8 @@ pub struct BdsApp { search_index_rebuild_running: bool, search_index_rebuild_task_id: Option, site_generation_workflows: HashMap, + pending_image_drops: VecDeque, + image_drop_import_running: bool, // Platform _menu_bar: Option, @@ -1107,6 +1136,8 @@ impl BdsApp { search_index_rebuild_running: false, search_index_rebuild_task_id: None, site_generation_workflows: HashMap::new(), + pending_image_drops: VecDeque::new(), + image_drop_import_running: false, _menu_bar: Some(menu_bar), menu_registry: registry, native_edit_commands: native_edit::command_queue(), @@ -1202,6 +1233,8 @@ impl BdsApp { search_index_rebuild_running: false, search_index_rebuild_task_id: None, site_generation_workflows: HashMap::new(), + pending_image_drops: VecDeque::new(), + image_drop_import_running: false, _menu_bar: None, menu_registry: MenuRegistry::empty(), native_edit_commands: native_edit::command_queue(), @@ -1845,6 +1878,34 @@ impl BdsApp { } Task::none() } + Message::FileDropped(path) => self.enqueue_image_drop(path), + Message::ImageDropImported { + task_id, + post_id, + project_id, + data_dir, + source_language, + offline_mode, + path, + result, + } => self.finish_image_drop_import( + task_id, + ImageDropRequest { + post_id, + project_id, + data_dir, + source_language, + offline_mode, + path, + }, + result, + ), + Message::ImageDropEnriched { + task_id, + post_id, + path, + result, + } => self.finish_image_drop_enrichment(task_id, &post_id, &path, result), Message::MediaFilesPicked(paths) => { let (Some(paths), Some(project), Some(data_dir)) = (paths, self.active_project.as_ref(), self.data_dir.as_ref()) @@ -3336,6 +3397,13 @@ impl BdsApp { Subscription::none() }; + let file_drop_sub = iced::event::listen_with(|event, _status, _id| match event { + iced::Event::Window(window::Event::FileDropped(path)) => { + Some(Message::FileDropped(path)) + } + _ => None, + }); + // Global mouse tracking for sidebar resize dragging. // The 4px drag handle mouse_area only fires on_press; move/release // are captured here so dragging works even when the cursor leaves @@ -3388,6 +3456,7 @@ impl BdsApp { task_tick, domain_event_tick, toast_tick, + file_drop_sub, drag_sub, menu_interaction_sub, menu_expand_tick, @@ -5543,6 +5612,257 @@ impl BdsApp { Task::none() } + fn enqueue_image_drop(&mut self, path: PathBuf) -> Task { + let Some(post_id) = dropped_image_target(self.active_tab.as_deref(), &self.tabs, &path) + else { + return Task::none(); + }; + let (Some(project), Some(data_dir)) = (&self.active_project, &self.data_dir) else { + return Task::none(); + }; + self.pending_image_drops.push_back(ImageDropRequest { + post_id, + project_id: project.id.clone(), + data_dir: data_dir.clone(), + source_language: self.content_language.clone(), + offline_mode: self.offline_mode, + path, + }); + self.start_next_image_drop_import() + } + + fn start_next_image_drop_import(&mut self) -> Task { + if self.image_drop_import_running { + return Task::none(); + } + let Some(request) = self.pending_image_drops.pop_front() else { + return Task::none(); + }; + self.image_drop_import_running = true; + let name = request + .path + .file_name() + .map(|name| name.to_string_lossy().to_string()) + .unwrap_or_else(|| request.path.display().to_string()); + let label = tw(self.ui_locale, "editor.imageDropImport", &[("name", &name)]); + let task_id = self.task_manager.submit(&label); + self.refresh_task_snapshots(); + + let db_path = self.db_path.clone(); + let task_manager = Arc::clone(&self.task_manager); + let locale = self.ui_locale; + let message_request = request.clone(); + Task::perform( + async move { + tokio::task::spawn_blocking(move || { + if !task_manager.wait_until_runnable(task_id) { + return Err("cancelled".to_string()); + } + task_manager.report_progress( + task_id, + Some(0.0), + Some(tw(locale, "editor.imageDropProgress", &[("name", &name)])), + ); + let db = Database::open(&db_path).map_err(|error| error.to_string())?; + let sort_order = + engine::post_media::list_media_for_post(db.conn(), &request.post_id) + .map(|items| items.len() as i32) + .unwrap_or(0); + engine::gallery_import::import_and_link_image( + db.conn(), + &request.data_dir, + &request.project_id, + &request.post_id, + &request.path, + &request.source_language, + sort_order, + ) + .map_err(|error| error.to_string()) + }) + .await + .unwrap_or_else(|error| Err(error.to_string())) + }, + move |result| Message::ImageDropImported { + task_id, + post_id: message_request.post_id.clone(), + project_id: message_request.project_id.clone(), + data_dir: message_request.data_dir.clone(), + source_language: message_request.source_language.clone(), + offline_mode: message_request.offline_mode, + path: message_request.path.clone(), + result, + }, + ) + } + + fn finish_image_drop_import( + &mut self, + task_id: TaskId, + request: ImageDropRequest, + result: Result, + ) -> Task { + self.image_drop_import_running = false; + let cancelled = self.task_manager.status(task_id) == Some(TaskStatus::Cancelled); + let mut tasks = vec![self.start_next_image_drop_import()]; + match result { + Ok(media) => { + if !cancelled { + self.task_manager.complete(task_id); + } + let inserted = self + .post_editors + .get_mut(&request.post_id) + .map(|state| state.insert_dropped_image(&media.file_path)) + .is_some(); + if inserted { + if let Some(tab) = self.tabs.iter_mut().find(|tab| tab.id == request.post_id) { + tab.is_dirty = true; + } + if let Err(error) = self.persist_post_editor_state(&request.post_id) { + self.notify_operation_failed("common.save", error); + } + } + self.refresh_post_relationships(&request.post_id); + let name = request + .path + .file_name() + .map(|name| name.to_string_lossy().to_string()) + .unwrap_or_else(|| request.path.display().to_string()); + self.add_output(&tw( + self.ui_locale, + "editor.imageDropAdded", + &[("name", &name)], + )); + tasks.push(self.start_image_drop_enrichment(&request, media)); + } + Err(error) if !cancelled => { + self.task_manager.fail(task_id, error.clone()); + let path = request + .path + .file_name() + .map(|name| name.to_string_lossy().to_string()) + .unwrap_or_else(|| request.path.display().to_string()); + self.add_output(&tw( + self.ui_locale, + "editor.imageDropFailed", + &[("path", &path), ("error", &error)], + )); + } + Err(_) => {} + } + self.refresh_task_snapshots(); + tasks.push(self.refresh_sidebar_media()); + Task::batch(tasks) + } + + fn start_image_drop_enrichment( + &mut self, + request: &ImageDropRequest, + media: Media, + ) -> Task { + let Some(db) = self.db.as_ref() else { + return Task::none(); + }; + if !engine::gallery_import::active_ai_endpoint_configured(db.conn(), request.offline_mode) { + let key = if request.offline_mode { + "editor.galleryAirplaneGated" + } else { + "chat.unavailable.guidance" + }; + self.notify(ToastLevel::Warning, &t(self.ui_locale, key)); + return Task::none(); + } + let metadata = engine::meta::read_project_json(&request.data_dir).ok(); + let translate = bds_core::db::queries::post::get_post_by_id(db.conn(), &request.post_id) + .is_ok_and(|post| !post.do_not_translate); + let targets = if translate { + engine::gallery_import::translation_targets( + metadata + .as_ref() + .and_then(|metadata| metadata.main_language.as_deref()), + metadata + .as_ref() + .map(|metadata| metadata.blog_languages.as_slice()) + .unwrap_or_default(), + &request.source_language, + ) + } else { + Vec::new() + }; + let name = request + .path + .file_name() + .map(|name| name.to_string_lossy().to_string()) + .unwrap_or_else(|| request.path.display().to_string()); + let label = tw( + self.ui_locale, + "editor.imageDropEnrichment", + &[("name", &name)], + ); + let task_id = self.task_manager.submit(&label); + self.refresh_task_snapshots(); + let db_path = self.db_path.clone(); + let data_dir = request.data_dir.clone(); + let offline_mode = request.offline_mode; + let post_id = request.post_id.clone(); + let path = request.path.clone(); + let task_manager = Arc::clone(&self.task_manager); + Task::perform( + async move { + tokio::task::spawn_blocking(move || { + if !task_manager.wait_until_runnable(task_id) { + return Err("cancelled".to_string()); + } + let db = Database::open(&db_path).map_err(|error| error.to_string())?; + engine::gallery_import::enrich_imported_image( + db.conn(), + &data_dir, + &media, + offline_mode, + &targets, + ) + }) + .await + .unwrap_or_else(|error| Err(error.to_string())) + }, + move |result| Message::ImageDropEnriched { + task_id, + post_id: post_id.clone(), + path: path.clone(), + result, + }, + ) + } + + fn finish_image_drop_enrichment( + &mut self, + task_id: TaskId, + post_id: &str, + path: &Path, + result: Result, + ) -> Task { + let cancelled = self.task_manager.status(task_id) == Some(TaskStatus::Cancelled); + match result { + Ok(_) if !cancelled => self.task_manager.complete(task_id), + Err(error) if !cancelled => { + self.task_manager.fail(task_id, error.clone()); + let path = path + .file_name() + .map(|name| name.to_string_lossy().to_string()) + .unwrap_or_else(|| path.display().to_string()); + self.add_output(&tw( + self.ui_locale, + "editor.imageDropEnrichmentFailed", + &[("path", &path), ("error", &error)], + )); + } + _ => {} + } + self.refresh_task_snapshots(); + self.refresh_post_relationships(post_id); + self.refresh_sidebar_media() + } + fn add_gallery_images(&mut self, post_id: &str) -> Task { let Some(db) = self.db.as_ref() else { self.add_output(&t(self.ui_locale, "common.databaseUnavailable")); @@ -5551,8 +5871,10 @@ impl BdsApp { if self.offline_mode && !engine::gallery_import::active_ai_endpoint_configured(db.conn(), true) { - self.add_output(&t(self.ui_locale, "editor.galleryAirplaneGated")); - return Task::none(); + self.notify( + ToastLevel::Warning, + &t(self.ui_locale, "editor.galleryAirplaneGated"), + ); } crate::platform::dialog::pick_gallery_images( post_id.to_string(), @@ -5945,7 +6267,7 @@ impl BdsApp { } let markdown = bds_core::engine::post::post_insert_media( - &media.id, + &media.file_path, media.mime_type.starts_with("image/"), &media.original_name, ); @@ -9007,6 +9329,16 @@ fn content_sample(content: &str, max_len: usize) -> String { content.chars().take(max_len).collect() } +fn dropped_image_target(active_tab: Option<&str>, tabs: &[Tab], path: &Path) -> Option { + if !engine::media::is_supported_image_path(path) { + return None; + } + let active_tab = active_tab?; + tabs.iter() + .find(|tab| tab.id == active_tab && tab.tab_type == TabType::Post) + .map(|tab| tab.id.clone()) +} + fn split_csv_values(value: &str) -> Vec { value .split(',') @@ -9044,7 +9376,7 @@ fn remote_error_closes_connection(code: &str) -> bool { mod tests { use super::{ BdsApp, Message, POST_AUTO_SAVE_DELAY_MS, PersistedMediaState, PersistedPostState, - PostStatus, SettingsMsg, localize_chat_error, month_abbreviation, + PostStatus, SettingsMsg, dropped_image_target, localize_chat_error, month_abbreviation, persist_media_editor_state_impl, persist_post_editor_preview_state_impl, persist_post_editor_state_impl, remote_error_closes_connection, save_editor_settings_state_impl, save_script_editor_state_impl, @@ -9076,7 +9408,7 @@ mod tests { use chrono::{Datelike, TimeZone}; use std::io::{Read, Write}; use std::net::TcpListener; - use std::path::PathBuf; + use std::path::{Path, PathBuf}; use std::thread; use tempfile::TempDir; @@ -9485,7 +9817,150 @@ mod tests { } #[test] - fn gallery_action_is_gated_in_airplane_mode_without_local_endpoint() { + fn file_drop_routes_only_supported_images_to_the_active_post_tab() { + let tabs = vec![ + Tab { + id: "post-1".to_string(), + tab_type: TabType::Post, + title: "Post".to_string(), + is_transient: false, + is_dirty: false, + }, + Tab { + id: "settings".to_string(), + tab_type: TabType::Settings, + title: "Settings".to_string(), + is_transient: false, + is_dirty: false, + }, + ]; + + assert_eq!( + dropped_image_target(Some("post-1"), &tabs, Path::new("photo.PNG")), + Some("post-1".to_string()) + ); + assert_eq!( + dropped_image_target(Some("post-1"), &tabs, Path::new("notes.txt")), + None + ); + assert_eq!( + dropped_image_target(Some("settings"), &tabs, Path::new("photo.png")), + None + ); + assert_eq!( + dropped_image_target(None, &tabs, Path::new("photo.png")), + None + ); + } + + #[test] + fn multiple_file_drop_events_queue_one_managed_import_at_a_time() { + let (db, project, tmp) = setup(); + let created = post::create_post( + db.conn(), + tmp.path(), + &project.id, + "Drops", + Some("Body"), + vec![], + vec![], + None, + Some("en"), + None, + ) + .unwrap(); + let mut app = make_app(db, project, &tmp); + open_post_editor(&mut app, &created); + + let _ = app.update(Message::FileDropped(tmp.path().join("first.png"))); + let _ = app.update(Message::FileDropped(tmp.path().join("second.png"))); + + assert!(app.image_drop_import_running); + assert_eq!(app.pending_image_drops.len(), 1); + assert_eq!(app.task_manager.snapshots().len(), 1); + assert!(app.task_manager.snapshots()[0].label.contains("first.png")); + } + + #[test] + fn completed_drop_is_linked_persisted_and_skips_unavailable_airplane_ai() { + let (db, project, tmp) = setup(); + let project_id = project.id.clone(); + let created = post::create_post( + db.conn(), + tmp.path(), + &project_id, + "Drop", + Some("Body"), + vec![], + vec![], + None, + Some("en"), + None, + ) + .unwrap(); + let source = tmp.path().join("dropped.png"); + std::fs::write(&source, tiny_png_bytes()).unwrap(); + let imported = bds_core::engine::gallery_import::import_and_link_image( + db.conn(), + tmp.path(), + &project_id, + &created.id, + &source, + "en", + 0, + ) + .unwrap(); + let expected_url = format!("/{}", imported.file_path); + let mut app = make_app(db, project, &tmp); + open_post_editor(&mut app, &created); + app.offline_mode = true; + app.post_editors[&created.id] + .editor_buffer + .borrow_mut() + .set_cursor(0, 2); + let task_id = app.task_manager.submit("drop"); + + let _ = app.update(Message::ImageDropImported { + task_id, + post_id: created.id.clone(), + project_id, + data_dir: tmp.path().to_path_buf(), + source_language: "en".to_string(), + offline_mode: true, + path: source, + result: Ok(imported.clone()), + }); + + let content = &app.post_editors[&created.id].content; + assert!(content.contains(&format!("![]({expected_url})"))); + assert!(!content.contains("bds-media://")); + let saved = bds_core::db::queries::post::get_post_by_id( + app.db.as_ref().unwrap().conn(), + &created.id, + ) + .unwrap(); + assert_eq!(saved.content.as_deref(), Some(content.as_str())); + assert_eq!( + bds_core::engine::post_media::list_media_for_post( + app.db.as_ref().unwrap().conn(), + &created.id, + ) + .unwrap()[0] + .id, + imported.id + ); + assert_eq!( + app.task_manager.status(task_id), + Some(TaskStatus::Completed) + ); + assert!(app.toasts.iter().any(|toast| { + toast.level == ToastLevel::Warning + && toast.message == "Automatic AI actions stay gated by airplane mode." + })); + } + + #[test] + fn gallery_action_warns_but_keeps_import_available_without_a_local_endpoint() { let (db, project, tmp) = setup(); let created = post::create_post( db.conn(), @@ -9511,10 +9986,10 @@ mod tests { let _ = app.handle_post_editor_msg(PostEditorMsg::AddGalleryImages); assert!(!app.post_editors[&created.id].quick_actions_open); - assert_eq!( - app.output_entries.last().unwrap().text, - "Automatic AI actions stay gated by airplane mode." - ); + assert!(app.toasts.iter().any(|toast| { + toast.level == ToastLevel::Warning + && toast.message == "Automatic AI actions stay gated by airplane mode." + })); } #[test] diff --git a/crates/bds-ui/src/views/post_editor.rs b/crates/bds-ui/src/views/post_editor.rs index 1888beb..7421692 100644 --- a/crates/bds-ui/src/views/post_editor.rs +++ b/crates/bds-ui/src/views/post_editor.rs @@ -218,6 +218,12 @@ impl PostEditorState { self.mark_dirty(); } + pub fn insert_dropped_image(&mut self, media_path: &str) { + self.insert_markdown_at_cursor(&bds_core::engine::post::post_insert_media( + media_path, true, "", + )); + } + pub fn set_editor_mode(&mut self, mode: &str) { self.editor_mode = normalize_editor_mode(mode); } @@ -1218,6 +1224,17 @@ mod tests { assert!(state.is_dirty); } + #[test] + fn dropped_image_markdown_is_inserted_at_the_buffer_cursor() { + let mut state = sample_state(); + state.editor_buffer.borrow_mut().set_cursor(0, 5); + + state.insert_dropped_image("media/2026/07/media-1.png"); + + assert_eq!(state.content, "Hello![](/media/2026/07/media-1.png) world"); + assert!(state.is_dirty); + } + #[test] fn unsupported_default_mode_falls_back_to_markdown() { let mut state = sample_state(); diff --git a/locales/ui/de.ftl b/locales/ui/de.ftl index 7cd9c58..b0f0e3b 100644 --- a/locales/ui/de.ftl +++ b/locales/ui/de.ftl @@ -386,6 +386,12 @@ editor-galleryPickerFailed = Die Bildauswahl konnte nicht geöffnet werden: { $e editor-galleryImageAdded = { $title } hinzugefügt editor-galleryImageFailed = { $path } konnte nicht verarbeitet werden: { $error } editor-galleryImportComplete = { $count } Bilder zum Beitrag hinzugefügt +editor-imageDropImport = Abgelegtes Bild importieren: { $name } +editor-imageDropProgress = { $name } wird importiert und verknüpft +editor-imageDropAdded = Abgelegtes Bild { $name } hinzugefügt +editor-imageDropFailed = Import von { $path } fehlgeschlagen: { $error } +editor-imageDropEnrichment = Abgelegtes Bild anreichern: { $name } +editor-imageDropEnrichmentFailed = Anreicherung von { $path } fehlgeschlagen: { $error } sidebar-filter-tags = Tags sidebar-filter-categories = Kategorien sidebar-filter-calendar = Archiv diff --git a/locales/ui/en.ftl b/locales/ui/en.ftl index a27d08f..0d73e3b 100644 --- a/locales/ui/en.ftl +++ b/locales/ui/en.ftl @@ -445,6 +445,12 @@ editor-galleryPickerFailed = Could not open the image picker: { $error } editor-galleryImageAdded = Added { $title } editor-galleryImageFailed = Failed to process { $path }: { $error } editor-galleryImportComplete = Added { $count } images to post +editor-imageDropImport = Import dropped image: { $name } +editor-imageDropProgress = Importing and linking { $name } +editor-imageDropAdded = Added dropped image { $name } +editor-imageDropFailed = Failed to import { $path }: { $error } +editor-imageDropEnrichment = Enrich dropped image: { $name } +editor-imageDropEnrichmentFailed = Failed to enrich { $path }: { $error } tags-noTags = No tags yet tags-cloudSection = Cloud tags-cloudHelp = Select one or more tags to edit, delete, or merge them. diff --git a/locales/ui/es.ftl b/locales/ui/es.ftl index cb92224..fc03fa9 100644 --- a/locales/ui/es.ftl +++ b/locales/ui/es.ftl @@ -386,6 +386,12 @@ editor-galleryPickerFailed = No se pudo abrir el selector de imágenes: { $error editor-galleryImageAdded = Se añadió { $title } editor-galleryImageFailed = No se pudo procesar { $path }: { $error } editor-galleryImportComplete = Se añadieron { $count } imágenes a la entrada +editor-imageDropImport = Importar imagen soltada: { $name } +editor-imageDropProgress = Importando y vinculando { $name } +editor-imageDropAdded = Imagen soltada { $name } añadida +editor-imageDropFailed = No se pudo importar { $path }: { $error } +editor-imageDropEnrichment = Enriquecer imagen soltada: { $name } +editor-imageDropEnrichmentFailed = No se pudo enriquecer { $path }: { $error } sidebar-filter-tags = Etiquetas sidebar-filter-categories = Categorías sidebar-filter-calendar = Archivo diff --git a/locales/ui/fr.ftl b/locales/ui/fr.ftl index 2595393..c39aa6f 100644 --- a/locales/ui/fr.ftl +++ b/locales/ui/fr.ftl @@ -386,6 +386,12 @@ editor-galleryPickerFailed = Impossible d’ouvrir le sélecteur d’images : { editor-galleryImageAdded = { $title } ajouté editor-galleryImageFailed = Échec du traitement de { $path } : { $error } editor-galleryImportComplete = { $count } images ajoutées à l’article +editor-imageDropImport = Importer l’image déposée : { $name } +editor-imageDropProgress = Importation et association de { $name } +editor-imageDropAdded = Image déposée { $name } ajoutée +editor-imageDropFailed = Échec de l’importation de { $path } : { $error } +editor-imageDropEnrichment = Enrichir l’image déposée : { $name } +editor-imageDropEnrichmentFailed = Échec de l’enrichissement de { $path } : { $error } sidebar-filter-tags = Tags sidebar-filter-categories = Catégories sidebar-filter-calendar = Archives diff --git a/locales/ui/it.ftl b/locales/ui/it.ftl index 2fe7c43..5ab1d4a 100644 --- a/locales/ui/it.ftl +++ b/locales/ui/it.ftl @@ -386,6 +386,12 @@ editor-galleryPickerFailed = Impossibile aprire il selettore di immagini: { $err editor-galleryImageAdded = Aggiunto { $title } editor-galleryImageFailed = Elaborazione di { $path } non riuscita: { $error } editor-galleryImportComplete = Aggiunte { $count } immagini all’articolo +editor-imageDropImport = Importa immagine trascinata: { $name } +editor-imageDropProgress = Importazione e collegamento di { $name } +editor-imageDropAdded = Immagine trascinata { $name } aggiunta +editor-imageDropFailed = Impossibile importare { $path }: { $error } +editor-imageDropEnrichment = Arricchisci immagine trascinata: { $name } +editor-imageDropEnrichmentFailed = Impossibile arricchire { $path }: { $error } sidebar-filter-tags = Tag sidebar-filter-categories = Categorie sidebar-filter-calendar = Archivio diff --git a/specs/action_patterns.allium b/specs/action_patterns.allium index ee4adc4..e32726e 100644 --- a/specs/action_patterns.allium +++ b/specs/action_patterns.allium @@ -90,7 +90,7 @@ rule MediaMetadataTranslationCascade { -- 1. importMedia(file) -> new media record + file copy + base sidecar -- 2. generateThumbnails(media) -> async start (small/medium/large/ai) -- 3. linkMediaToPost(media, post) -> update sidecar linkedPostIds --- 4. insertMarkdownImage(cursor) -> insert ![](bds-media://id) at cursor +-- 4. insertMarkdownImage(cursor) -> insert ![](/media/YYYY/MM/file) at cursor -- Background steps (non-blocking, results auto-applied): -- 5. If AI available: aiImageAnalysis(media) diff --git a/specs/editor_post.allium b/specs/editor_post.allium index c28b006..b8ed58c 100644 --- a/specs/editor_post.allium +++ b/specs/editor_post.allium @@ -290,8 +290,8 @@ rule PostInsertMedia { -- Opens InsertMediaModal (media search variant) -- Search input, grid of media items with bds-thumb:// thumbnails -- Click inserts markdown: - -- Images: ![alt](bds-media://id) - -- Non-images: [originalName](bds-media://id) + -- Images: ![alt](/media/YYYY/MM/file) + -- Non-images: [originalName](/media/YYYY/MM/file) } rule PostGalleryAction { @@ -308,7 +308,7 @@ rule PostDragDropImage { -- 1. Import media file -> media record + file copy + sidecar -- 2. Generate thumbnails (async: small/medium/large/ai) -- 3. Link media to post (update sidecar linkedPostIds) - -- 4. Insert markdown image at cursor: ![](bds-media://id) + -- 4. Insert markdown image at cursor: ![](/media/YYYY/MM/file) -- 5. If AI available: AI image analysis (async, auto-applied, no modal) -- 6. If auto-translate enabled: cascade translate media metadata -- Steps 1-4 synchronous. Steps 5-6 background tasks. diff --git a/specs/modals.allium b/specs/modals.allium index a661e96..103acec 100644 --- a/specs/modals.allium +++ b/specs/modals.allium @@ -97,8 +97,8 @@ value InsertMediaModal { -- Search input filtering by media title and original filename. -- Grid of media items: bds-thumb:// thumbnail (medium 400px), title below. -- Click item: - -- Images: inserts ![alt](bds-media://id) at cursor - -- Non-images: inserts [originalName](bds-media://id) at cursor + -- Images: inserts ![alt](/media/YYYY/MM/file) at cursor + -- Non-images: inserts [originalName](/media/YYYY/MM/file) at cursor -- Closes modal after insertion. search_query: String results: List