Implement post editor image drops.
This commit is contained in:
@@ -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.
|
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
|
### Key takeaways
|
||||||
|
|
||||||
- Media management includes metadata quality, not only file import.
|
- Media management includes metadata quality, not only file import.
|
||||||
- Add alt text and captions during import, not as a postponed task.
|
- 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.
|
- Commit content and related media in the same change when possible.
|
||||||
|
|
||||||
[↑ Back to In this article](#in-this-article)
|
[↑ Back to In this article](#in-this-article)
|
||||||
|
|||||||
@@ -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.
|
- 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.
|
- 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.
|
- 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.
|
- 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.
|
- SQLite and filesystem persistence with frontmatter, sidecars, rebuild, metadata diff/repair, and FTS5 search.
|
||||||
|
|||||||
@@ -152,32 +152,14 @@ fn import_gallery_image(
|
|||||||
offline_mode: bool,
|
offline_mode: bool,
|
||||||
translation_targets: &[String],
|
translation_targets: &[String],
|
||||||
) -> Result<ImportedGalleryImage, 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 db = Database::open(db_path).map_err(|error| error.to_string())?;
|
||||||
let imported = media::import_media(
|
let imported = import_and_link_image(
|
||||||
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(
|
|
||||||
db.conn(),
|
db.conn(),
|
||||||
data_dir,
|
data_dir,
|
||||||
project_id,
|
project_id,
|
||||||
post_id,
|
post_id,
|
||||||
&imported.id,
|
path,
|
||||||
|
source_language,
|
||||||
sort_order,
|
sort_order,
|
||||||
)
|
)
|
||||||
.map_err(|error| error.to_string())?;
|
.map_err(|error| error.to_string())?;
|
||||||
@@ -190,7 +172,7 @@ fn import_gallery_image(
|
|||||||
offline_mode,
|
offline_mode,
|
||||||
translation_targets,
|
translation_targets,
|
||||||
)
|
)
|
||||||
.unwrap_or_else(|| imported.original_name.clone())
|
.unwrap_or_else(|_| imported.original_name.clone())
|
||||||
} else {
|
} else {
|
||||||
imported.original_name.clone()
|
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
|
/// Apply the shared gallery AI enrichment and translation pipeline to one
|
||||||
/// already-imported image. Returns the generated title when AI was available.
|
/// already-imported image. Returns the generated title when AI was available.
|
||||||
pub fn enrich_imported_image(
|
pub fn enrich_imported_image(
|
||||||
@@ -209,14 +228,13 @@ pub fn enrich_imported_image(
|
|||||||
imported: &crate::model::Media,
|
imported: &crate::model::Media,
|
||||||
offline_mode: bool,
|
offline_mode: bool,
|
||||||
translation_targets: &[String],
|
translation_targets: &[String],
|
||||||
) -> Option<String> {
|
) -> Result<String, String> {
|
||||||
let image_data_url = build_ai_image_data_url(
|
let image_data_url = build_ai_image_data_url(
|
||||||
data_dir,
|
data_dir,
|
||||||
&imported.id,
|
&imported.id,
|
||||||
&imported.file_path,
|
&imported.file_path,
|
||||||
&imported.mime_type,
|
&imported.mime_type,
|
||||||
)
|
)?;
|
||||||
.ok()?;
|
|
||||||
let response = ai::run_one_shot(
|
let response = ai::run_one_shot(
|
||||||
conn,
|
conn,
|
||||||
offline_mode,
|
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 {
|
let ai::OneShotResponse::ImageAnalysis(analysis) = response.0 else {
|
||||||
return None;
|
return Err("AI returned an unexpected response".to_string());
|
||||||
};
|
};
|
||||||
media::update_media(
|
media::update_media(
|
||||||
conn,
|
conn,
|
||||||
@@ -247,7 +265,7 @@ pub fn enrich_imported_image(
|
|||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
)
|
)
|
||||||
.ok()?;
|
.map_err(|error| error.to_string())?;
|
||||||
|
|
||||||
for target in translation_targets {
|
for target in translation_targets {
|
||||||
let Ok((ai::OneShotResponse::MediaTranslation(translation), _)) = ai::run_one_shot(
|
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()
|
imported.original_name.clone()
|
||||||
} else {
|
} else {
|
||||||
analysis.title
|
analysis.title
|
||||||
|
|||||||
@@ -156,6 +156,13 @@ const SUPPORTED_IMAGE_TYPES: &[&str] = &[
|
|||||||
"image/heif",
|
"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.
|
/// Import a media file (image, etc.) into the project.
|
||||||
#[expect(
|
#[expect(
|
||||||
clippy::too_many_arguments,
|
clippy::too_many_arguments,
|
||||||
@@ -217,7 +224,7 @@ pub(crate) fn import_media_at(
|
|||||||
.and_then(|e| e.to_str())
|
.and_then(|e| e.to_str())
|
||||||
.unwrap_or("bin");
|
.unwrap_or("bin");
|
||||||
let mime_type = mime_from_extension(ext).to_string();
|
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!(
|
return Err(EngineError::Validation(format!(
|
||||||
"unsupported file type: {mime_type} (file: {original_name})"
|
"unsupported file type: {mime_type} (file: {original_name})"
|
||||||
)));
|
)));
|
||||||
|
|||||||
@@ -1119,12 +1119,13 @@ pub fn post_insert_link(slug: &str) -> String {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Insert a media reference in the editor buffer.
|
/// Insert a media reference in the editor buffer.
|
||||||
/// Returns the Markdown syntax:  or [name](bds-media://id)
|
/// Returns Markdown with a host-absolute media URL suitable for rendered HTML.
|
||||||
pub fn post_insert_media(media_id: &str, is_image: bool, original_name: &str) -> String {
|
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 {
|
if is_image {
|
||||||
format!("")
|
format!("")
|
||||||
} else {
|
} else {
|
||||||
format!("[{original_name}](bds-media://{media_id})")
|
format!("[{original_name}]({url})")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1177,6 +1178,18 @@ mod tests {
|
|||||||
assert_eq!(fetched.status, PostStatus::Draft);
|
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"),
|
||||||
|
""
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
post_insert_media("/media/2026/07/file.pdf", false, "file.pdf"),
|
||||||
|
"[file.pdf](/media/2026/07/file.pdf)"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn create_post_empty_title_uses_untitled() {
|
fn create_post_empty_title_uses_untitled() {
|
||||||
let (db, dir) = setup();
|
let (db, dir) = setup();
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
use std::collections::{HashMap, HashSet};
|
use std::collections::{HashMap, HashSet, VecDeque};
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
@@ -112,6 +112,23 @@ pub enum Message {
|
|||||||
post_id: String,
|
post_id: String,
|
||||||
report: engine::gallery_import::GalleryImportReport,
|
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 {
|
MediaImportFinished {
|
||||||
imported: Vec<Media>,
|
imported: Vec<Media>,
|
||||||
errors: Vec<String>,
|
errors: Vec<String>,
|
||||||
@@ -404,6 +421,16 @@ struct SiteGenerationWorkflow {
|
|||||||
report: engine::generation::GenerationReport,
|
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 {
|
enum PersistedPostState {
|
||||||
Canonical(Box<Post>),
|
Canonical(Box<Post>),
|
||||||
Translation(Box<bds_core::model::PostTranslation>),
|
Translation(Box<bds_core::model::PostTranslation>),
|
||||||
@@ -910,6 +937,8 @@ pub struct BdsApp {
|
|||||||
search_index_rebuild_running: bool,
|
search_index_rebuild_running: bool,
|
||||||
search_index_rebuild_task_id: Option<TaskId>,
|
search_index_rebuild_task_id: Option<TaskId>,
|
||||||
site_generation_workflows: HashMap<String, SiteGenerationWorkflow>,
|
site_generation_workflows: HashMap<String, SiteGenerationWorkflow>,
|
||||||
|
pending_image_drops: VecDeque<ImageDropRequest>,
|
||||||
|
image_drop_import_running: bool,
|
||||||
|
|
||||||
// Platform
|
// Platform
|
||||||
_menu_bar: Option<muda::Menu>,
|
_menu_bar: Option<muda::Menu>,
|
||||||
@@ -1107,6 +1136,8 @@ impl BdsApp {
|
|||||||
search_index_rebuild_running: false,
|
search_index_rebuild_running: false,
|
||||||
search_index_rebuild_task_id: None,
|
search_index_rebuild_task_id: None,
|
||||||
site_generation_workflows: HashMap::new(),
|
site_generation_workflows: HashMap::new(),
|
||||||
|
pending_image_drops: VecDeque::new(),
|
||||||
|
image_drop_import_running: false,
|
||||||
_menu_bar: Some(menu_bar),
|
_menu_bar: Some(menu_bar),
|
||||||
menu_registry: registry,
|
menu_registry: registry,
|
||||||
native_edit_commands: native_edit::command_queue(),
|
native_edit_commands: native_edit::command_queue(),
|
||||||
@@ -1202,6 +1233,8 @@ impl BdsApp {
|
|||||||
search_index_rebuild_running: false,
|
search_index_rebuild_running: false,
|
||||||
search_index_rebuild_task_id: None,
|
search_index_rebuild_task_id: None,
|
||||||
site_generation_workflows: HashMap::new(),
|
site_generation_workflows: HashMap::new(),
|
||||||
|
pending_image_drops: VecDeque::new(),
|
||||||
|
image_drop_import_running: false,
|
||||||
_menu_bar: None,
|
_menu_bar: None,
|
||||||
menu_registry: MenuRegistry::empty(),
|
menu_registry: MenuRegistry::empty(),
|
||||||
native_edit_commands: native_edit::command_queue(),
|
native_edit_commands: native_edit::command_queue(),
|
||||||
@@ -1845,6 +1878,34 @@ impl BdsApp {
|
|||||||
}
|
}
|
||||||
Task::none()
|
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) => {
|
Message::MediaFilesPicked(paths) => {
|
||||||
let (Some(paths), Some(project), Some(data_dir)) =
|
let (Some(paths), Some(project), Some(data_dir)) =
|
||||||
(paths, self.active_project.as_ref(), self.data_dir.as_ref())
|
(paths, self.active_project.as_ref(), self.data_dir.as_ref())
|
||||||
@@ -3336,6 +3397,13 @@ impl BdsApp {
|
|||||||
Subscription::none()
|
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.
|
// Global mouse tracking for sidebar resize dragging.
|
||||||
// The 4px drag handle mouse_area only fires on_press; move/release
|
// The 4px drag handle mouse_area only fires on_press; move/release
|
||||||
// are captured here so dragging works even when the cursor leaves
|
// are captured here so dragging works even when the cursor leaves
|
||||||
@@ -3388,6 +3456,7 @@ impl BdsApp {
|
|||||||
task_tick,
|
task_tick,
|
||||||
domain_event_tick,
|
domain_event_tick,
|
||||||
toast_tick,
|
toast_tick,
|
||||||
|
file_drop_sub,
|
||||||
drag_sub,
|
drag_sub,
|
||||||
menu_interaction_sub,
|
menu_interaction_sub,
|
||||||
menu_expand_tick,
|
menu_expand_tick,
|
||||||
@@ -5543,6 +5612,257 @@ impl BdsApp {
|
|||||||
Task::none()
|
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> {
|
fn add_gallery_images(&mut self, post_id: &str) -> Task<Message> {
|
||||||
let Some(db) = self.db.as_ref() else {
|
let Some(db) = self.db.as_ref() else {
|
||||||
self.add_output(&t(self.ui_locale, "common.databaseUnavailable"));
|
self.add_output(&t(self.ui_locale, "common.databaseUnavailable"));
|
||||||
@@ -5551,8 +5871,10 @@ impl BdsApp {
|
|||||||
if self.offline_mode
|
if self.offline_mode
|
||||||
&& !engine::gallery_import::active_ai_endpoint_configured(db.conn(), true)
|
&& !engine::gallery_import::active_ai_endpoint_configured(db.conn(), true)
|
||||||
{
|
{
|
||||||
self.add_output(&t(self.ui_locale, "editor.galleryAirplaneGated"));
|
self.notify(
|
||||||
return Task::none();
|
ToastLevel::Warning,
|
||||||
|
&t(self.ui_locale, "editor.galleryAirplaneGated"),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
crate::platform::dialog::pick_gallery_images(
|
crate::platform::dialog::pick_gallery_images(
|
||||||
post_id.to_string(),
|
post_id.to_string(),
|
||||||
@@ -5945,7 +6267,7 @@ impl BdsApp {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let markdown = bds_core::engine::post::post_insert_media(
|
let markdown = bds_core::engine::post::post_insert_media(
|
||||||
&media.id,
|
&media.file_path,
|
||||||
media.mime_type.starts_with("image/"),
|
media.mime_type.starts_with("image/"),
|
||||||
&media.original_name,
|
&media.original_name,
|
||||||
);
|
);
|
||||||
@@ -9007,6 +9329,16 @@ fn content_sample(content: &str, max_len: usize) -> String {
|
|||||||
content.chars().take(max_len).collect()
|
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> {
|
fn split_csv_values(value: &str) -> Vec<String> {
|
||||||
value
|
value
|
||||||
.split(',')
|
.split(',')
|
||||||
@@ -9044,7 +9376,7 @@ fn remote_error_closes_connection(code: &str) -> bool {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::{
|
use super::{
|
||||||
BdsApp, Message, POST_AUTO_SAVE_DELAY_MS, PersistedMediaState, PersistedPostState,
|
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_media_editor_state_impl, persist_post_editor_preview_state_impl,
|
||||||
persist_post_editor_state_impl, remote_error_closes_connection,
|
persist_post_editor_state_impl, remote_error_closes_connection,
|
||||||
save_editor_settings_state_impl, save_script_editor_state_impl,
|
save_editor_settings_state_impl, save_script_editor_state_impl,
|
||||||
@@ -9076,7 +9408,7 @@ mod tests {
|
|||||||
use chrono::{Datelike, TimeZone};
|
use chrono::{Datelike, TimeZone};
|
||||||
use std::io::{Read, Write};
|
use std::io::{Read, Write};
|
||||||
use std::net::TcpListener;
|
use std::net::TcpListener;
|
||||||
use std::path::PathBuf;
|
use std::path::{Path, PathBuf};
|
||||||
use std::thread;
|
use std::thread;
|
||||||
use tempfile::TempDir;
|
use tempfile::TempDir;
|
||||||
|
|
||||||
@@ -9485,7 +9817,150 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[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!("")));
|
||||||
|
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 (db, project, tmp) = setup();
|
||||||
let created = post::create_post(
|
let created = post::create_post(
|
||||||
db.conn(),
|
db.conn(),
|
||||||
@@ -9511,10 +9986,10 @@ mod tests {
|
|||||||
let _ = app.handle_post_editor_msg(PostEditorMsg::AddGalleryImages);
|
let _ = app.handle_post_editor_msg(PostEditorMsg::AddGalleryImages);
|
||||||
|
|
||||||
assert!(!app.post_editors[&created.id].quick_actions_open);
|
assert!(!app.post_editors[&created.id].quick_actions_open);
|
||||||
assert_eq!(
|
assert!(app.toasts.iter().any(|toast| {
|
||||||
app.output_entries.last().unwrap().text,
|
toast.level == ToastLevel::Warning
|
||||||
"Automatic AI actions stay gated by airplane mode."
|
&& toast.message == "Automatic AI actions stay gated by airplane mode."
|
||||||
);
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -218,6 +218,12 @@ impl PostEditorState {
|
|||||||
self.mark_dirty();
|
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) {
|
pub fn set_editor_mode(&mut self, mode: &str) {
|
||||||
self.editor_mode = normalize_editor_mode(mode);
|
self.editor_mode = normalize_editor_mode(mode);
|
||||||
}
|
}
|
||||||
@@ -1218,6 +1224,17 @@ mod tests {
|
|||||||
assert!(state.is_dirty);
|
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 world");
|
||||||
|
assert!(state.is_dirty);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn unsupported_default_mode_falls_back_to_markdown() {
|
fn unsupported_default_mode_falls_back_to_markdown() {
|
||||||
let mut state = sample_state();
|
let mut state = sample_state();
|
||||||
|
|||||||
@@ -386,6 +386,12 @@ editor-galleryPickerFailed = Die Bildauswahl konnte nicht geöffnet werden: { $e
|
|||||||
editor-galleryImageAdded = { $title } hinzugefügt
|
editor-galleryImageAdded = { $title } hinzugefügt
|
||||||
editor-galleryImageFailed = { $path } konnte nicht verarbeitet werden: { $error }
|
editor-galleryImageFailed = { $path } konnte nicht verarbeitet werden: { $error }
|
||||||
editor-galleryImportComplete = { $count } Bilder zum Beitrag hinzugefügt
|
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-tags = Tags
|
||||||
sidebar-filter-categories = Kategorien
|
sidebar-filter-categories = Kategorien
|
||||||
sidebar-filter-calendar = Archiv
|
sidebar-filter-calendar = Archiv
|
||||||
|
|||||||
@@ -445,6 +445,12 @@ editor-galleryPickerFailed = Could not open the image picker: { $error }
|
|||||||
editor-galleryImageAdded = Added { $title }
|
editor-galleryImageAdded = Added { $title }
|
||||||
editor-galleryImageFailed = Failed to process { $path }: { $error }
|
editor-galleryImageFailed = Failed to process { $path }: { $error }
|
||||||
editor-galleryImportComplete = Added { $count } images to post
|
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-noTags = No tags yet
|
||||||
tags-cloudSection = Cloud
|
tags-cloudSection = Cloud
|
||||||
tags-cloudHelp = Select one or more tags to edit, delete, or merge them.
|
tags-cloudHelp = Select one or more tags to edit, delete, or merge them.
|
||||||
|
|||||||
@@ -386,6 +386,12 @@ editor-galleryPickerFailed = No se pudo abrir el selector de imágenes: { $error
|
|||||||
editor-galleryImageAdded = Se añadió { $title }
|
editor-galleryImageAdded = Se añadió { $title }
|
||||||
editor-galleryImageFailed = No se pudo procesar { $path }: { $error }
|
editor-galleryImageFailed = No se pudo procesar { $path }: { $error }
|
||||||
editor-galleryImportComplete = Se añadieron { $count } imágenes a la entrada
|
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-tags = Etiquetas
|
||||||
sidebar-filter-categories = Categorías
|
sidebar-filter-categories = Categorías
|
||||||
sidebar-filter-calendar = Archivo
|
sidebar-filter-calendar = Archivo
|
||||||
|
|||||||
@@ -386,6 +386,12 @@ editor-galleryPickerFailed = Impossible d’ouvrir le sélecteur d’images : {
|
|||||||
editor-galleryImageAdded = { $title } ajouté
|
editor-galleryImageAdded = { $title } ajouté
|
||||||
editor-galleryImageFailed = Échec du traitement de { $path } : { $error }
|
editor-galleryImageFailed = Échec du traitement de { $path } : { $error }
|
||||||
editor-galleryImportComplete = { $count } images ajoutées à l’article
|
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-tags = Tags
|
||||||
sidebar-filter-categories = Catégories
|
sidebar-filter-categories = Catégories
|
||||||
sidebar-filter-calendar = Archives
|
sidebar-filter-calendar = Archives
|
||||||
|
|||||||
@@ -386,6 +386,12 @@ editor-galleryPickerFailed = Impossibile aprire il selettore di immagini: { $err
|
|||||||
editor-galleryImageAdded = Aggiunto { $title }
|
editor-galleryImageAdded = Aggiunto { $title }
|
||||||
editor-galleryImageFailed = Elaborazione di { $path } non riuscita: { $error }
|
editor-galleryImageFailed = Elaborazione di { $path } non riuscita: { $error }
|
||||||
editor-galleryImportComplete = Aggiunte { $count } immagini all’articolo
|
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-tags = Tag
|
||||||
sidebar-filter-categories = Categorie
|
sidebar-filter-categories = Categorie
|
||||||
sidebar-filter-calendar = Archivio
|
sidebar-filter-calendar = Archivio
|
||||||
|
|||||||
@@ -90,7 +90,7 @@ rule MediaMetadataTranslationCascade {
|
|||||||
-- 1. importMedia(file) -> new media record + file copy + base sidecar
|
-- 1. importMedia(file) -> new media record + file copy + base sidecar
|
||||||
-- 2. generateThumbnails(media) -> async start (small/medium/large/ai)
|
-- 2. generateThumbnails(media) -> async start (small/medium/large/ai)
|
||||||
-- 3. linkMediaToPost(media, post) -> update sidecar linkedPostIds
|
-- 3. linkMediaToPost(media, post) -> update sidecar linkedPostIds
|
||||||
-- 4. insertMarkdownImage(cursor) -> insert  at cursor
|
-- 4. insertMarkdownImage(cursor) -> insert  at cursor
|
||||||
|
|
||||||
-- Background steps (non-blocking, results auto-applied):
|
-- Background steps (non-blocking, results auto-applied):
|
||||||
-- 5. If AI available: aiImageAnalysis(media)
|
-- 5. If AI available: aiImageAnalysis(media)
|
||||||
|
|||||||
@@ -290,8 +290,8 @@ rule PostInsertMedia {
|
|||||||
-- Opens InsertMediaModal (media search variant)
|
-- Opens InsertMediaModal (media search variant)
|
||||||
-- Search input, grid of media items with bds-thumb:// thumbnails
|
-- Search input, grid of media items with bds-thumb:// thumbnails
|
||||||
-- Click inserts markdown:
|
-- Click inserts markdown:
|
||||||
-- Images: 
|
-- Images: 
|
||||||
-- Non-images: [originalName](bds-media://id)
|
-- Non-images: [originalName](/media/YYYY/MM/file)
|
||||||
}
|
}
|
||||||
|
|
||||||
rule PostGalleryAction {
|
rule PostGalleryAction {
|
||||||
@@ -308,7 +308,7 @@ rule PostDragDropImage {
|
|||||||
-- 1. Import media file -> media record + file copy + sidecar
|
-- 1. Import media file -> media record + file copy + sidecar
|
||||||
-- 2. Generate thumbnails (async: small/medium/large/ai)
|
-- 2. Generate thumbnails (async: small/medium/large/ai)
|
||||||
-- 3. Link media to post (update sidecar linkedPostIds)
|
-- 3. Link media to post (update sidecar linkedPostIds)
|
||||||
-- 4. Insert markdown image at cursor: 
|
-- 4. Insert markdown image at cursor: 
|
||||||
-- 5. If AI available: AI image analysis (async, auto-applied, no modal)
|
-- 5. If AI available: AI image analysis (async, auto-applied, no modal)
|
||||||
-- 6. If auto-translate enabled: cascade translate media metadata
|
-- 6. If auto-translate enabled: cascade translate media metadata
|
||||||
-- Steps 1-4 synchronous. Steps 5-6 background tasks.
|
-- Steps 1-4 synchronous. Steps 5-6 background tasks.
|
||||||
|
|||||||
@@ -97,8 +97,8 @@ value InsertMediaModal {
|
|||||||
-- Search input filtering by media title and original filename.
|
-- Search input filtering by media title and original filename.
|
||||||
-- Grid of media items: bds-thumb:// thumbnail (medium 400px), title below.
|
-- Grid of media items: bds-thumb:// thumbnail (medium 400px), title below.
|
||||||
-- Click item:
|
-- Click item:
|
||||||
-- Images: inserts  at cursor
|
-- Images: inserts  at cursor
|
||||||
-- Non-images: inserts [originalName](bds-media://id) at cursor
|
-- Non-images: inserts [originalName](/media/YYYY/MM/file) at cursor
|
||||||
-- Closes modal after insertion.
|
-- Closes modal after insertion.
|
||||||
search_query: String
|
search_query: String
|
||||||
results: List<InsertMediaResult>
|
results: List<InsertMediaResult>
|
||||||
|
|||||||
Reference in New Issue
Block a user