diff --git a/AGENTS.md b/AGENTS.md index 1087b38..75654a4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -26,6 +26,7 @@ Invariants and behaviours in the allium spec should be covered by unit tests of ## Important facts - update `README.md` whenever a user-visible feature is added, removed, or materially changed; keep it a compact overview with relevant pointers +- update `RUST_PLAN_CORE.md` and/or `RUST_PLAN_EXTENSION.md` whenever implementation changes their tracked status; plan-document updates are part of the definition of done - published posts don't have body in the database, the body content is only in the file - functionality you implement have to be tied to UI - UI you implement has to be tied to functionality diff --git a/Cargo.lock b/Cargo.lock index 19d6d6f..d761592 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -844,7 +844,6 @@ name = "bds-ui" version = "0.1.0" dependencies = [ "anyhow", - "base64", "bds-core", "bds-editor", "chrono", @@ -856,7 +855,6 @@ dependencies = [ "objc2-app-kit 0.3.2", "objc2-foundation 0.3.2", "open", - "rayon", "rfd", "serde_json", "syn 2.0.117", diff --git a/README.md b/README.md index fb7fc16..a21adce 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ The project is under active development. Core blogging workflows are broadly ava ## Available Features - Native Iced desktop workspace with localized menus, tabs, sidebars, dialogs, tasks, and embedded Wry previews. -- Post and translation authoring with draft/published lifecycle, metadata, tags, categories, links, and media. +- 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. - Template and Lua script management 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. - SQLite and filesystem persistence with frontmatter, sidecars, rebuild, metadata diff/repair, and FTS5 search. diff --git a/RUST_PLAN_CORE.md b/RUST_PLAN_CORE.md index 0e63e0c..573aeda 100644 --- a/RUST_PLAN_CORE.md +++ b/RUST_PLAN_CORE.md @@ -64,22 +64,18 @@ Open: - No known large core block. New menu commands must continue to be wired through both localization layers and the native intercept. -### Authoring and editor — Mostly done +### Authoring and editor — Done Available: - Dashboard and editors for posts, translations, media, tags, templates, scripts, and settings. - Post create, edit, publish, unpublish, discard, and delete flows. -- Media import, replacement, metadata editing, translations, thumbnails, filters, and post assignment. +- Media import, replacement, metadata editing, translations, thumbnails, filters, post assignment, and the post-editor batch gallery-image workflow. - Template and Lua script creation, editing, validation, publication, and deletion. - Rope-based editing with syntax highlighting, selection, clipboard, undo/redo, word/line/page movement, line numbers, soft wrapping, mouse selection, and committed IME input. - Automatic translation flows with airplane-mode gating and media translation propagation. - Functional post-links panel with backlinks, outlinks, and navigation to linked posts. -Open: - -- Add the specified post-editor "Add Gallery Images" batch workflow. - ### Rendering, preview, and generation — Mostly done Available: @@ -145,8 +141,7 @@ Available: ## Remaining Core Blocks -1. Finish the linked-image authoring workflow. -2. Add generation section-task grouping. -3. Return normalized token accounting from one-shot AI calls. +1. Add generation section-task grouping. +2. Return normalized token accounting from one-shot AI calls. Core is feature-complete when these blocks are closed and the implementation continues to satisfy the Allium and bDS2 compatibility contracts. diff --git a/RUST_PLAN_EXTENSION.md b/RUST_PLAN_EXTENSION.md index 5a6abf2..ed08b61 100644 --- a/RUST_PLAN_EXTENSION.md +++ b/RUST_PLAN_EXTENSION.md @@ -83,6 +83,7 @@ Open: - Implement `bds-cli`; its current binary is only a stub. - Commands from `cli.allium` and `cli_sync.allium` using the same project, database, engines, and settings as the desktop app. +- Reuse the core gallery batch-import engine already used by the desktop post editor for the CLI `gallery` command. - MCP tools/resources and proposal-based writes from `mcp.allium`. - Domain event bus from `events.allium` for desktop, CLI, TUI, and future remote clients. - Replace the MCP settings placeholder. diff --git a/crates/bds-core/src/engine/gallery_import.rs b/crates/bds-core/src/engine/gallery_import.rs new file mode 100644 index 0000000..a0471a2 --- /dev/null +++ b/crates/bds-core/src/engine/gallery_import.rs @@ -0,0 +1,601 @@ +use std::collections::HashSet; +use std::path::{Path, PathBuf}; + +use base64::Engine as _; +use rayon::prelude::*; +use serde_json::json; + +use crate::db::Database; +use crate::engine::{ai, media, post_media}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ImportedGalleryImage { + pub media_id: String, + pub title: String, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct GalleryImportOutcome { + pub path: PathBuf, + pub result: Result, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct GalleryImportReport { + pub selected_count: usize, + pub outcomes: Vec, +} + +pub fn active_ai_endpoint_configured(conn: &crate::db::DbConnection, offline_mode: bool) -> bool { + ai::active_endpoint(conn, offline_mode) + .is_ok_and(|endpoint| !endpoint.url.trim().is_empty() && !endpoint.model.trim().is_empty()) +} + +pub fn translation_targets( + main_language: Option<&str>, + blog_languages: &[String], + source_language: &str, +) -> Vec { + let mut seen = HashSet::new(); + main_language + .into_iter() + .chain(blog_languages.iter().map(String::as_str)) + .filter(|language| !language.is_empty() && *language != source_language) + .filter(|language| seen.insert((*language).to_string())) + .map(str::to_string) + .collect() +} + +pub fn process_paths_concurrently( + paths: Vec, + concurrency: usize, + process: F, +) -> Result, String> +where + T: Send, + F: Fn(usize, PathBuf) -> T + Send + Sync, +{ + rayon::ThreadPoolBuilder::new() + .num_threads(concurrency.clamp(1, 8)) + .build() + .map_err(|error| error.to_string()) + .map(|pool| { + pool.install(|| { + paths + .into_par_iter() + .enumerate() + .map(|(index, path)| process(index, path)) + .collect() + }) + }) +} + +#[expect( + clippy::too_many_arguments, + reason = "the shared gallery workflow needs its persisted post and project context" +)] +pub fn import_gallery_images( + db_path: &Path, + data_dir: &Path, + project_id: &str, + post_id: &str, + paths: Vec, + source_language: &str, + offline_mode: bool, +) -> GalleryImportReport { + let selected_count = paths.len(); + let metadata = crate::engine::meta::read_project_json(data_dir).ok(); + let concurrency = metadata + .as_ref() + .map(|metadata| metadata.image_import_concurrency.clamp(1, 8) as usize) + .unwrap_or(4); + let targets = 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(), + source_language, + ); + let ai_available = Database::open(db_path) + .ok() + .is_some_and(|db| active_ai_endpoint_configured(db.conn(), offline_mode)); + let first_sort_order = Database::open(db_path) + .ok() + .and_then(|db| post_media::list_media_for_post(db.conn(), post_id).ok()) + .map(|media| media.len() as i32) + .unwrap_or(0); + + let process_path = |index: usize, path: PathBuf| GalleryImportOutcome { + result: import_gallery_image( + db_path, + data_dir, + project_id, + post_id, + &path, + source_language, + first_sort_order + index as i32, + ai_available, + offline_mode, + &targets, + ), + path, + }; + let outcomes = match process_paths_concurrently(paths.clone(), concurrency, process_path) { + Ok(outcomes) => outcomes, + Err(error) => paths + .into_iter() + .map(|path| GalleryImportOutcome { + path, + result: Err(error.clone()), + }) + .collect(), + }; + + GalleryImportReport { + selected_count, + outcomes, + } +} + +#[expect( + clippy::too_many_arguments, + reason = "one worker receives the immutable batch context" +)] +fn import_gallery_image( + db_path: &Path, + data_dir: &Path, + project_id: &str, + post_id: &str, + path: &Path, + source_language: &str, + sort_order: i32, + ai_available: bool, + 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( + db.conn(), + data_dir, + project_id, + post_id, + &imported.id, + sort_order, + ) + .map_err(|error| error.to_string())?; + + let title = if ai_available { + enrich_image( + db.conn(), + data_dir, + &imported, + offline_mode, + translation_targets, + ) + .unwrap_or_else(|| imported.original_name.clone()) + } else { + imported.original_name.clone() + }; + + Ok(ImportedGalleryImage { + media_id: imported.id, + title, + }) +} + +fn enrich_image( + conn: &crate::db::DbConnection, + data_dir: &Path, + imported: &crate::model::Media, + offline_mode: bool, + translation_targets: &[String], +) -> Option { + 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, + &ai::OneShotRequest { + operation: ai::OneShotOperation::AnalyzeImage, + content: json!({ + "title": imported.title, + "alt": imported.alt, + "caption": imported.caption, + "filename": imported.original_name, + "mime_type": imported.mime_type, + "image_data_url": image_data_url, + }), + }, + ) + .ok()?; + let ai::OneShotResponse::ImageAnalysis(analysis) = response.0 else { + return None; + }; + media::update_media( + conn, + data_dir, + &imported.id, + Some(Some(&analysis.title)), + Some(Some(&analysis.alt)), + Some(Some(&analysis.caption)), + None, + None, + None, + ) + .ok()?; + + for target in translation_targets { + let Ok((ai::OneShotResponse::MediaTranslation(translation), _)) = ai::run_one_shot( + conn, + offline_mode, + &ai::OneShotRequest { + operation: ai::OneShotOperation::TranslateMedia { + target_language: target.clone(), + }, + content: json!({ + "title": analysis.title, + "alt": analysis.alt, + "caption": analysis.caption, + }), + }, + ) else { + continue; + }; + let _ = media::upsert_media_translation( + conn, + data_dir, + &imported.id, + target, + Some(&translation.title), + Some(&translation.alt), + Some(&translation.caption), + ); + } + + Some(if analysis.title.is_empty() { + imported.original_name.clone() + } else { + analysis.title + }) +} + +pub fn build_ai_image_data_url( + data_dir: &Path, + media_id: &str, + file_path: &str, + mime_type: &str, +) -> Result { + if !mime_type.starts_with("image/") { + return Err("AI image analysis requires an image".to_string()); + } + + let source_path = data_dir.join(file_path.trim_start_matches('/')); + let thumbnail_path = data_dir.join(crate::util::thumbnail_path(media_id, "ai", "jpg")); + if !thumbnail_path.exists() { + crate::util::thumbnail::generate_all_thumbnails( + &source_path, + &data_dir.join("thumbnails"), + media_id, + ) + .map_err(|error| format!("failed to generate AI thumbnail: {error}"))?; + } + let bytes = std::fs::read(&thumbnail_path) + .map_err(|error| format!("failed to read AI thumbnail: {error}"))?; + Ok(format!( + "data:image/jpeg;base64,{}", + base64::engine::general_purpose::STANDARD.encode(bytes) + )) +} + +#[cfg(test)] +mod tests { + use std::fs; + use std::io::{Read, Write}; + use std::net::TcpListener; + use std::path::PathBuf; + use std::sync::Arc; + use std::sync::atomic::{AtomicUsize, Ordering}; + use std::thread; + use std::time::Duration; + + use tempfile::TempDir; + + use crate::db::queries::post::insert_post; + use crate::db::queries::post::make_test_post; + use crate::db::queries::post_media::{list_post_media_by_post, unlink_media}; + use crate::db::queries::project::{insert_project, make_test_project}; + use crate::db::{Database, fts::ensure_fts_tables}; + use crate::engine::ai::{AiEndpointConfig, AiEndpointKind, save_endpoint}; + use crate::engine::media::rebuild_media_from_filesystem; + use crate::model::metadata::ProjectMetadata; + + use super::{import_gallery_images, process_paths_concurrently, translation_targets}; + + fn setup() -> (Database, TempDir) { + let dir = TempDir::new().unwrap(); + let db = Database::open(&dir.path().join("bds.db")).unwrap(); + db.migrate().unwrap(); + ensure_fts_tables(db.conn()).unwrap(); + insert_project(db.conn(), &make_test_project("p1", "Gallery")).unwrap(); + insert_post(db.conn(), &make_test_post("post1", "p1", "gallery")).unwrap(); + crate::engine::meta::write_project_json( + dir.path(), + &ProjectMetadata { + name: "Gallery".to_string(), + description: None, + public_url: None, + main_language: Some("en".to_string()), + default_author: None, + max_posts_per_page: 50, + image_import_concurrency: 2, + blogmark_category: None, + pico_theme: None, + semantic_similarity_enabled: false, + blog_languages: vec![ + "de".to_string(), + "en".to_string(), + "fr".to_string(), + "de".to_string(), + ], + }, + ) + .unwrap(); + (db, dir) + } + + fn spawn_ai_server() -> String { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let address = listener.local_addr().unwrap(); + thread::spawn(move || { + for stream in listener.incoming().take(3) { + let mut stream = stream.unwrap(); + let mut request = Vec::new(); + let mut buffer = [0_u8; 16_384]; + loop { + let read = stream.read(&mut buffer).unwrap(); + if read == 0 { + break; + } + request.extend_from_slice(&buffer[..read]); + let Some(header_end) = request.windows(4).position(|part| part == b"\r\n\r\n") + else { + continue; + }; + let headers = String::from_utf8_lossy(&request[..header_end]); + let content_length = headers + .lines() + .find_map(|line| { + line.to_ascii_lowercase() + .strip_prefix("content-length: ") + .and_then(|value| value.parse::().ok()) + }) + .unwrap_or(0); + if request.len() >= header_end + 4 + content_length { + break; + } + } + let request = String::from_utf8_lossy(&request); + let content = if request.contains("Translate the media metadata into de") { + r#"{"title":"Berg","alt":"Ein Berg","caption":"Morgenlicht"}"# + } else if request.contains("Translate the media metadata into fr") { + r#"{"title":"Montagne","alt":"Une montagne","caption":"Lumière du matin"}"# + } else { + r#"{"title":"Mountain","alt":"A mountain","caption":"Morning light"}"# + }; + let body = serde_json::json!({ + "choices": [{"message": {"content": content}}] + }) + .to_string(); + let response = format!( + "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}", + body.len(), + body, + ); + stream.write_all(response.as_bytes()).unwrap(); + } + }); + format!("http://{address}") + } + + #[test] + fn targets_are_unique_and_exclude_source_language() { + assert_eq!( + translation_targets( + Some("en"), + &["de".to_string(), "en".to_string(), "de".to_string()], + "fr", + ), + vec!["en".to_string(), "de".to_string()] + ); + } + + #[test] + fn partial_failure_keeps_successful_image_linked_and_rebuildable_without_ai() { + let (db, dir) = setup(); + let image = dir.path().join("photo.jpg"); + fs::write(&image, b"jpeg data").unwrap(); + let invalid = dir.path().join("notes.txt"); + fs::write(&invalid, b"not an image").unwrap(); + + let report = import_gallery_images( + &dir.path().join("bds.db"), + dir.path(), + "p1", + "post1", + vec![image, invalid], + "en", + false, + ); + + assert_eq!(report.selected_count, 2); + assert!(report.outcomes[0].result.is_ok()); + assert!(report.outcomes[1].result.is_err()); + + let links = list_post_media_by_post(db.conn(), "post1").unwrap(); + assert_eq!(links.len(), 1); + let media_id = links[0].media_id.clone(); + let sidecar = fs::read_to_string( + dir.path().join( + crate::db::queries::media::get_media_by_id(db.conn(), &media_id) + .unwrap() + .sidecar_path, + ), + ) + .unwrap(); + assert!(sidecar.contains("linkedPostIds: [\"post1\"]")); + + unlink_media(db.conn(), "post1", &media_id).unwrap(); + rebuild_media_from_filesystem(db.conn(), dir.path(), "p1").unwrap(); + assert_eq!( + list_post_media_by_post(db.conn(), "post1").unwrap().len(), + 1 + ); + } + + #[test] + fn configured_local_ai_enriches_metadata_and_unique_translation_targets() { + let (db, dir) = setup(); + save_endpoint( + db.conn(), + &AiEndpointConfig { + kind: AiEndpointKind::Airplane, + url: spawn_ai_server(), + model: "local-vision".to_string(), + api_key: None, + }, + ) + .unwrap(); + let image = dir.path().join("mountain.png"); + image::DynamicImage::new_rgb8(8, 8).save(&image).unwrap(); + + let report = import_gallery_images( + &dir.path().join("bds.db"), + dir.path(), + "p1", + "post1", + vec![image], + "en", + true, + ); + + let imported = report.outcomes[0].result.as_ref().unwrap(); + assert_eq!(imported.title, "Mountain"); + let media = + crate::db::queries::media::get_media_by_id(db.conn(), &imported.media_id).unwrap(); + assert_eq!(media.title.as_deref(), Some("Mountain")); + assert_eq!(media.alt.as_deref(), Some("A mountain")); + assert_eq!(media.caption.as_deref(), Some("Morning light")); + let translations = crate::db::queries::media_translation::list_media_translations_by_media( + db.conn(), + &media.id, + ) + .unwrap(); + assert_eq!( + translations + .iter() + .map(|translation| translation.language.as_str()) + .collect::>(), + vec!["de", "fr"] + ); + for language in ["de", "fr"] { + assert!( + dir.path() + .join(crate::util::media_translation_sidecar_path( + &media.file_path, + language, + )) + .is_file() + ); + } + } + + #[test] + fn analysis_failure_does_not_remove_the_post_link() { + let (db, dir) = setup(); + save_endpoint( + db.conn(), + &AiEndpointConfig { + kind: AiEndpointKind::Airplane, + url: "http://127.0.0.1:9".to_string(), + model: "unavailable-local-model".to_string(), + api_key: None, + }, + ) + .unwrap(); + let image = dir.path().join("linked.png"); + image::DynamicImage::new_rgb8(8, 8).save(&image).unwrap(); + + let report = import_gallery_images( + &dir.path().join("bds.db"), + dir.path(), + "p1", + "post1", + vec![image], + "en", + true, + ); + + assert!(report.outcomes[0].result.is_ok()); + assert_eq!( + list_post_media_by_post(db.conn(), "post1").unwrap().len(), + 1 + ); + } + + #[test] + fn concurrent_processor_clamps_worker_count_to_one_through_eight() { + let active = Arc::new(AtomicUsize::new(0)); + let maximum = Arc::new(AtomicUsize::new(0)); + let paths = (0..24) + .map(|index| PathBuf::from(format!("{index}.jpg"))) + .collect(); + let results = process_paths_concurrently(paths, 99, { + let active = Arc::clone(&active); + let maximum = Arc::clone(&maximum); + move |_index, path| { + let current = active.fetch_add(1, Ordering::SeqCst) + 1; + maximum.fetch_max(current, Ordering::SeqCst); + thread::sleep(Duration::from_millis(5)); + active.fetch_sub(1, Ordering::SeqCst); + path + } + }) + .unwrap(); + assert_eq!(results.len(), 24); + assert!((1..=8).contains(&maximum.load(Ordering::SeqCst))); + + assert_eq!( + process_paths_concurrently(vec![PathBuf::from("one.jpg")], 0, |_, path| path) + .unwrap() + .len(), + 1 + ); + } +} diff --git a/crates/bds-core/src/engine/mod.rs b/crates/bds-core/src/engine/mod.rs index 794c6e0..58a4fa5 100644 --- a/crates/bds-core/src/engine/mod.rs +++ b/crates/bds-core/src/engine/mod.rs @@ -3,6 +3,7 @@ pub mod auto_translation; pub mod blogmark; pub mod calendar; pub mod error; +pub mod gallery_import; pub mod generation; pub mod media; pub mod menu; diff --git a/crates/bds-ui/Cargo.toml b/crates/bds-ui/Cargo.toml index 34e9fff..c202549 100644 --- a/crates/bds-ui/Cargo.toml +++ b/crates/bds-ui/Cargo.toml @@ -11,14 +11,12 @@ iced = { workspace = true } muda = { workspace = true } rfd = { workspace = true } serde_json = { workspace = true } -base64 = { workspace = true } dirs = { workspace = true } chrono = { workspace = true } open = { workspace = true } anyhow = { workspace = true } tokio = { workspace = true } uuid = { workspace = true } -rayon = { workspace = true } wry = "0.55" [target.'cfg(target_os = "macos")'.dependencies] diff --git a/crates/bds-ui/src/app.rs b/crates/bds-ui/src/app.rs index 84d6317..31d7d78 100644 --- a/crates/bds-ui/src/app.rs +++ b/crates/bds-ui/src/app.rs @@ -2,10 +2,8 @@ use std::collections::{HashMap, HashSet}; use std::path::{Path, PathBuf}; use std::sync::{Arc, Mutex}; -use base64::Engine as _; use bds_core::db::DbQueryError as SqlError; use iced::{Element, Subscription, Task, window}; -use rayon::prelude::*; use serde_json::json; use uuid::Uuid; @@ -98,6 +96,14 @@ pub enum Message { FolderPicked(Option), ProjectFolderPicked(Option), MediaFilesPicked(Option>), + GalleryImagesPicked { + post_id: String, + result: Result>, String>, + }, + GalleryImportFinished { + post_id: String, + report: engine::gallery_import::GalleryImportReport, + }, MediaImportFinished { imported: Vec, errors: Vec, @@ -1361,41 +1367,35 @@ impl BdsApp { Task::perform( async move { tokio::task::spawn_blocking(move || { - let pool = match rayon::ThreadPoolBuilder::new() - .num_threads(concurrency) - .build() - { - Ok(pool) => pool, - Err(error) => return (Vec::new(), vec![error.to_string()]), + let results = match engine::gallery_import::process_paths_concurrently( + paths, + concurrency, + |_index, path| { + 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| format!("{}: {error}", path.display()))?; + engine::media::import_media( + db.conn(), + &data_dir, + &project_id, + &path, + &original_name, + None, + None, + None, + None, + Some(&language), + Vec::new(), + ) + .map_err(|error| format!("{}: {error}", path.display())) + }, + ) { + Ok(results) => results, + Err(error) => return (Vec::new(), vec![error]), }; - let results = pool.install(|| { - paths - .into_par_iter() - .map(|path| { - 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| { - format!("{}: {error}", path.display()) - })?; - engine::media::import_media( - db.conn(), - &data_dir, - &project_id, - &path, - &original_name, - None, - None, - None, - None, - Some(&language), - Vec::new(), - ) - .map_err(|error| format!("{}: {error}", path.display())) - }) - .collect::>>() - }); let mut imported = Vec::new(); let mut errors = Vec::new(); for result in results { @@ -1412,6 +1412,104 @@ impl BdsApp { |(imported, errors)| Message::MediaImportFinished { imported, errors }, ) } + Message::GalleryImagesPicked { post_id, result } => match result { + Ok(Some(paths)) if !paths.is_empty() => { + let (Some(project), Some(data_dir)) = + (self.active_project.as_ref(), self.data_dir.as_ref()) + else { + return Task::none(); + }; + let db_path = self.db_path.clone(); + let data_dir = data_dir.clone(); + let project_id = project.id.clone(); + let source_language = self.content_language.clone(); + let offline_mode = self.offline_mode; + let result_post_id = post_id.clone(); + let failed_paths = paths.clone(); + let selected_count = paths.len(); + Task::perform( + async move { + tokio::task::spawn_blocking(move || { + engine::gallery_import::import_gallery_images( + &db_path, + &data_dir, + &project_id, + &post_id, + paths, + &source_language, + offline_mode, + ) + }) + .await + .unwrap_or_else(|error| { + engine::gallery_import::GalleryImportReport { + selected_count, + outcomes: failed_paths + .into_iter() + .map(|path| engine::gallery_import::GalleryImportOutcome { + path, + result: Err(error.to_string()), + }) + .collect(), + } + }) + }, + move |report| Message::GalleryImportFinished { + post_id: result_post_id.clone(), + report, + }, + ) + } + Ok(_) => Task::none(), + Err(error) => { + self.add_output(&tw( + self.ui_locale, + "editor.galleryPickerFailed", + &[("error", &error)], + )); + Task::none() + } + }, + Message::GalleryImportFinished { post_id, report } => { + for outcome in report.outcomes { + match outcome.result { + Ok(image) => self.add_output(&tw( + self.ui_locale, + "editor.galleryImageAdded", + &[("title", &image.title)], + )), + Err(error) => { + let path = outcome + .path + .file_name() + .map(|name| name.to_string_lossy().to_string()) + .unwrap_or_else(|| outcome.path.display().to_string()); + self.add_output(&tw( + self.ui_locale, + "editor.galleryImageFailed", + &[("path", &path), ("error", &error)], + )); + } + } + } + + if let Some(state) = self.post_editors.get_mut(&post_id) { + state.insert_markdown_at_cursor("\n[[gallery]]\n"); + if let Some(tab) = self.tabs.iter_mut().find(|tab| tab.id == post_id) { + tab.is_dirty = true; + } + if let Err(error) = self.persist_post_editor_state(&post_id) { + self.notify_operation_failed("common.save", error); + } + self.refresh_post_relationships(&post_id); + } + self.add_output(&tw( + self.ui_locale, + "editor.galleryImportComplete", + &[("count", &report.selected_count.to_string())], + )); + self.refresh_sidebar_media() + } Message::MediaImportFinished { imported, errors } => { if !imported.is_empty() { self.sidebar_view = SidebarView::Media; @@ -2978,6 +3076,24 @@ impl BdsApp { Task::none() } + 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")); + return Task::none(); + }; + 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(); + } + crate::platform::dialog::pick_gallery_images( + post_id.to_string(), + t(self.ui_locale, "editor.addGalleryImages"), + t(self.ui_locale, "dialog.imageFilter"), + ) + } + fn publish_post_editor(&mut self, post_id: &str) -> Task { if let Err(e) = self.persist_post_editor_state(post_id) { self.notify_operation_failed("editor.publish", e); @@ -5823,7 +5939,7 @@ impl BdsApp { let Some(state) = self.media_editors.get(media_id).cloned() else { return Task::none(); }; - let image_data_url = match build_ai_image_data_url( + let image_data_url = match engine::gallery_import::build_ai_image_data_url( data_dir, &state.media_id, &state.file_path, @@ -6082,35 +6198,6 @@ fn content_sample(content: &str, max_len: usize) -> String { content.chars().take(max_len).collect() } -fn build_ai_image_data_url( - data_dir: &Path, - media_id: &str, - file_path: &str, - mime_type: &str, -) -> Result { - if !mime_type.starts_with("image/") { - return Err("AI image analysis requires an image".to_string()); - } - - let source_path = data_dir.join(file_path.trim_start_matches('/')); - let thumbnail_relative = bds_core::util::thumbnail_path(media_id, "ai", "jpg"); - let thumbnail_path = data_dir.join(&thumbnail_relative); - - if !thumbnail_path.exists() { - bds_core::util::thumbnail::generate_all_thumbnails( - &source_path, - &data_dir.join("thumbnails"), - media_id, - ) - .map_err(|error| format!("failed to generate AI thumbnail: {error}"))?; - } - - let bytes = std::fs::read(&thumbnail_path) - .map_err(|error| format!("failed to read AI thumbnail: {error}"))?; - let encoded = base64::engine::general_purpose::STANDARD.encode(bytes); - Ok(format!("data:image/jpeg;base64,{encoded}")) -} - fn split_csv_values(value: &str) -> Vec { value .split(',') @@ -6142,7 +6229,7 @@ mod tests { use crate::state::sidebar_filter::{MediaFilter, PostFilter}; use crate::views::media_editor::{MediaEditorMsg, MediaEditorState}; use crate::views::modal; - use crate::views::post_editor::PostEditorState; + use crate::views::post_editor::{PostEditorMsg, PostEditorState}; use crate::views::script_editor::ScriptEditorState; use crate::views::settings_view::SettingsViewState; use crate::views::template_editor::TemplateEditorState; @@ -6154,6 +6241,7 @@ mod tests { use chrono::{Datelike, TimeZone}; use std::io::{Read, Write}; use std::net::TcpListener; + use std::path::PathBuf; use std::thread; use tempfile::TempDir; @@ -6229,6 +6317,122 @@ mod tests { BdsApp::new_for_tests(db, project, tmp.path().to_path_buf()) } + fn open_post_editor(app: &mut BdsApp, post: &bds_core::model::Post) { + let tab = crate::state::tabs::Tab { + id: post.id.clone(), + tab_type: crate::state::tabs::TabType::Post, + title: post.title.clone(), + is_transient: false, + is_dirty: false, + }; + app.tabs.push(tab.clone()); + app.active_tab = Some(post.id.clone()); + app.load_editor_for_tab(&tab); + } + + #[test] + fn gallery_action_is_gated_in_airplane_mode_without_local_endpoint() { + let (db, project, tmp) = setup(); + let created = post::create_post( + db.conn(), + tmp.path(), + &project.id, + "Gallery", + Some("Body"), + vec![], + vec![], + None, + Some("en"), + None, + ) + .unwrap(); + let mut app = make_app(db, project, &tmp); + open_post_editor(&mut app, &created); + app.offline_mode = true; + app.post_editors + .get_mut(&created.id) + .unwrap() + .quick_actions_open = true; + + 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." + ); + } + + #[test] + fn gallery_completion_reports_every_path_inserts_macro_and_refreshes_editor() { + let (db, project, tmp) = setup(); + let created = post::create_post( + db.conn(), + tmp.path(), + &project.id, + "Gallery", + 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::GalleryImportFinished { + post_id: created.id.clone(), + report: bds_core::engine::gallery_import::GalleryImportReport { + selected_count: 2, + outcomes: vec![ + bds_core::engine::gallery_import::GalleryImportOutcome { + path: PathBuf::from("first.jpg"), + result: Ok(bds_core::engine::gallery_import::ImportedGalleryImage { + media_id: "m1".to_string(), + title: "First".to_string(), + }), + }, + bds_core::engine::gallery_import::GalleryImportOutcome { + path: PathBuf::from("broken.jpg"), + result: Err("bad image".to_string()), + }, + ], + }, + }); + + assert!( + app.post_editors[&created.id] + .content + .contains("\n[[gallery]]\n") + ); + assert!(!app.post_editors[&created.id].is_dirty); + let saved = bds_core::db::queries::post::get_post_by_id( + app.db.as_ref().unwrap().conn(), + &created.id, + ) + .unwrap(); + assert!(saved.content.unwrap().contains("\n[[gallery]]\n")); + assert_eq!(app.output_entries.len(), 3); + assert_eq!(app.output_entries[0].text, "Added First"); + assert!(app.output_entries[1].text.contains("broken.jpg")); + assert_eq!(app.output_entries[2].text, "Added 2 images to post"); + } + + #[test] + fn cancelling_gallery_picker_is_a_no_op() { + let (db, project, tmp) = setup(); + let mut app = make_app(db, project, &tmp); + + let _ = app.update(Message::GalleryImagesPicked { + post_id: "post1".to_string(), + result: Ok(None), + }); + + assert!(app.output_entries.is_empty()); + } + #[test] fn search_index_rebuild_requires_confirmation_and_blocks_editing() { let (db, project, tmp) = setup(); diff --git a/crates/bds-ui/src/app/editor_handlers.rs b/crates/bds-ui/src/app/editor_handlers.rs index 393e5a9..2b04bdb 100644 --- a/crates/bds-ui/src/app/editor_handlers.rs +++ b/crates/bds-ui/src/app/editor_handlers.rs @@ -7,6 +7,7 @@ impl BdsApp { SyncEmbeddedPreview, Analyze(String), AnalyzeTaxonomy(String), + AddGalleryImages(String), DetectLanguage(String), OpenTranslate(String), TranslateTo { @@ -69,6 +70,10 @@ impl BdsApp { state.quick_actions_open = false; deferred = DeferredPostAction::AnalyzeTaxonomy(tab_id.clone()); } + PostEditorMsg::AddGalleryImages => { + state.quick_actions_open = false; + deferred = DeferredPostAction::AddGalleryImages(state.post_id.clone()); + } PostEditorMsg::SwitchEditorMode(mode) => { state.set_editor_mode(&mode); deferred = DeferredPostAction::SyncEmbeddedPreview; @@ -268,6 +273,7 @@ impl BdsApp { DeferredPostAction::SyncEmbeddedPreview => self.sync_embedded_preview_for_active_post(), DeferredPostAction::Analyze(tab_id) => self.run_post_ai_analysis(&tab_id), DeferredPostAction::AnalyzeTaxonomy(tab_id) => self.run_post_taxonomy_analysis(&tab_id), + DeferredPostAction::AddGalleryImages(post_id) => self.add_gallery_images(&post_id), DeferredPostAction::DetectLanguage(tab_id) => self.detect_post_language(&tab_id), DeferredPostAction::OpenTranslate(tab_id) => self.open_post_translation_modal(&tab_id), DeferredPostAction::TranslateTo { diff --git a/crates/bds-ui/src/platform/dialog.rs b/crates/bds-ui/src/platform/dialog.rs index f46fc5e..abb0a33 100644 --- a/crates/bds-ui/src/platform/dialog.rs +++ b/crates/bds-ui/src/platform/dialog.rs @@ -48,6 +48,31 @@ pub fn pick_media_files(title: String, filter_label: String) -> Task { ) } +/// Pick images for a persisted post's batch gallery workflow. +pub fn pick_gallery_images(post_id: String, title: String, filter_label: String) -> Task { + Task::perform( + async move { + let result = tokio::task::spawn_blocking(move || { + std::panic::catch_unwind(|| { + rfd::FileDialog::new() + .set_title(&title) + .add_filter( + &filter_label, + &["jpg", "jpeg", "png", "gif", "webp", "tiff", "bmp"], + ) + .pick_files() + }) + .map_err(|_| "native image picker failed".to_string()) + }) + .await + .map_err(|error| error.to_string()) + .and_then(|result| result); + (post_id, result) + }, + |(post_id, result)| Message::GalleryImagesPicked { post_id, result }, + ) +} + /// Pick a replacement image for an existing media item. pub fn pick_media_replacement( media_id: String, diff --git a/crates/bds-ui/src/views/post_editor.rs b/crates/bds-ui/src/views/post_editor.rs index 921f539..03c22b0 100644 --- a/crates/bds-ui/src/views/post_editor.rs +++ b/crates/bds-ui/src/views/post_editor.rs @@ -324,6 +324,7 @@ pub enum PostEditorMsg { ToggleQuickActions, AnalyzeWithAi, AnalyzeTaxonomy, + AddGalleryImages, DetectLanguage, Translate, TranslateTo(String), @@ -393,7 +394,7 @@ pub fn view<'a>( .size(13) .shaping(Shaping::Advanced), ) - .on_press_maybe(ai_enabled.then_some(Message::PostEditor(PostEditorMsg::ToggleQuickActions))) + .on_press(Message::PostEditor(PostEditorMsg::ToggleQuickActions)) .padding([6, 16]) .style(inputs::secondary_button) .into(); @@ -496,6 +497,12 @@ pub fn view<'a>( PostEditorMsg::DetectLanguage, ai_enabled ), + quick_action_item( + locale, + t(locale, "editor.addGalleryImages"), + PostEditorMsg::AddGalleryImages, + true + ), ] .spacing(4) ) diff --git a/locales/ui/de.ftl b/locales/ui/de.ftl index 88443ad..42b2b9b 100644 --- a/locales/ui/de.ftl +++ b/locales/ui/de.ftl @@ -242,6 +242,12 @@ sidebar-filter-to = Bis (JJJJ-MM-TT) editor-insertLink = Link einfügen editor-insertMedia = Medium einfügen editor-gallery = Galerie +editor-addGalleryImages = Galeriebilder hinzufügen +editor-galleryAirplaneGated = Automatische KI-Aktionen bleiben im Flugmodus gesperrt. +editor-galleryPickerFailed = Die Bildauswahl konnte nicht geöffnet werden: { $error } +editor-galleryImageAdded = { $title } hinzugefügt +editor-galleryImageFailed = { $path } konnte nicht verarbeitet werden: { $error } +editor-galleryImportComplete = { $count } Bilder zum Beitrag hinzugefügt 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 47e8133..0098939 100644 --- a/locales/ui/en.ftl +++ b/locales/ui/en.ftl @@ -298,6 +298,12 @@ editor-publishedAt = Published editor-insertLink = Insert Link editor-insertMedia = Insert Media editor-gallery = Gallery +editor-addGalleryImages = Add Gallery Images +editor-galleryAirplaneGated = Automatic AI actions stay gated by airplane mode. +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 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 ec87874..9d32487 100644 --- a/locales/ui/es.ftl +++ b/locales/ui/es.ftl @@ -242,6 +242,12 @@ sidebar-filter-to = Hasta (AAAA-MM-DD) editor-insertLink = Insertar enlace editor-insertMedia = Insertar medio editor-gallery = Galería +editor-addGalleryImages = Añadir imágenes a la galería +editor-galleryAirplaneGated = Las acciones automáticas de IA siguen bloqueadas en modo avión. +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 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 c2cab65..2e4197a 100644 --- a/locales/ui/fr.ftl +++ b/locales/ui/fr.ftl @@ -242,6 +242,12 @@ sidebar-filter-to = Jusqu'au (AAAA-MM-JJ) editor-insertLink = Insérer un lien editor-insertMedia = Insérer un média editor-gallery = Galerie +editor-addGalleryImages = Ajouter des images à la galerie +editor-galleryAirplaneGated = Les actions automatiques d’IA restent bloquées en mode avion. +editor-galleryPickerFailed = Impossible d’ouvrir le sélecteur d’images : { $error } +editor-galleryImageAdded = { $title } ajouté +editor-galleryImageFailed = Échec du traitement de { $path } : { $error } +editor-galleryImportComplete = { $count } images ajoutées à l’article 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 dc9baf2..7592960 100644 --- a/locales/ui/it.ftl +++ b/locales/ui/it.ftl @@ -242,6 +242,12 @@ sidebar-filter-to = A (AAAA-MM-GG) editor-insertLink = Inserisci link editor-insertMedia = Inserisci media editor-gallery = Galleria +editor-addGalleryImages = Aggiungi immagini alla galleria +editor-galleryAirplaneGated = Le azioni AI automatiche restano bloccate in modalità aereo. +editor-galleryPickerFailed = Impossibile aprire il selettore di immagini: { $error } +editor-galleryImageAdded = Aggiunto { $title } +editor-galleryImageFailed = Elaborazione di { $path } non riuscita: { $error } +editor-galleryImportComplete = Aggiunte { $count } immagini all’articolo sidebar-filter-tags = Tag sidebar-filter-categories = Categorie sidebar-filter-calendar = Archivio