Implement post editor image drops.

This commit is contained in:
2026-07-20 16:19:14 +02:00
parent 49447ef451
commit 29cdd016b8
15 changed files with 616 additions and 53 deletions

View File

@@ -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)

View File

@@ -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.

View File

@@ -152,32 +152,14 @@ fn import_gallery_image(
offline_mode: bool,
translation_targets: &[String],
) -> Result<ImportedGalleryImage, String> {
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<crate::model::Media> {
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<String> {
) -> Result<String, String> {
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

View File

@@ -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})"
)));

View File

@@ -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();

View File

@@ -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<Media, String>,
},
ImageDropEnriched {
task_id: TaskId,
post_id: String,
path: PathBuf,
result: Result<String, String>,
},
MediaImportFinished {
imported: Vec<Media>,
errors: Vec<String>,
@@ -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<Post>),
Translation(Box<bds_core::model::PostTranslation>),
@@ -910,6 +937,8 @@ pub struct BdsApp {
search_index_rebuild_running: bool,
search_index_rebuild_task_id: Option<TaskId>,
site_generation_workflows: HashMap<String, SiteGenerationWorkflow>,
pending_image_drops: VecDeque<ImageDropRequest>,
image_drop_import_running: bool,
// Platform
_menu_bar: Option<muda::Menu>,
@@ -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<Message> {
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<Message> {
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<Media, String>,
) -> Task<Message> {
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<Message> {
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<String, String>,
) -> Task<Message> {
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<Message> {
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<String> {
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<String> {
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]

View File

@@ -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();

View File

@@ -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

View File

@@ -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.

View File

@@ -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

View File

@@ -386,6 +386,12 @@ editor-galleryPickerFailed = Impossible douvrir le sélecteur dimages : {
editor-galleryImageAdded = { $title } ajouté
editor-galleryImageFailed = Échec du traitement de { $path } : { $error }
editor-galleryImportComplete = { $count } images ajoutées à larticle
editor-imageDropImport = Importer limage déposée : { $name }
editor-imageDropProgress = Importation et association de { $name }
editor-imageDropAdded = Image déposée { $name } ajoutée
editor-imageDropFailed = Échec de limportation de { $path } : { $error }
editor-imageDropEnrichment = Enrichir limage déposée : { $name }
editor-imageDropEnrichmentFailed = Échec de lenrichissement de { $path } : { $error }
sidebar-filter-tags = Tags
sidebar-filter-categories = Catégories
sidebar-filter-calendar = Archives

View File

@@ -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 allarticolo
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

View File

@@ -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)

View File

@@ -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.

View File

@@ -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<InsertMediaResult>