Implement post editor gallery image imports
This commit is contained in:
@@ -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
|
||||
|
||||
2
Cargo.lock
generated
2
Cargo.lock
generated
@@ -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",
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
601
crates/bds-core/src/engine/gallery_import.rs
Normal file
601
crates/bds-core/src/engine/gallery_import.rs
Normal file
@@ -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<ImportedGalleryImage, String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct GalleryImportReport {
|
||||
pub selected_count: usize,
|
||||
pub outcomes: Vec<GalleryImportOutcome>,
|
||||
}
|
||||
|
||||
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<String> {
|
||||
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<T, F>(
|
||||
paths: Vec<PathBuf>,
|
||||
concurrency: usize,
|
||||
process: F,
|
||||
) -> Result<Vec<T>, 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<PathBuf>,
|
||||
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<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(
|
||||
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<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,
|
||||
&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<String, String> {
|
||||
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::<usize>().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<_>>(),
|
||||
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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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<PathBuf>),
|
||||
ProjectFolderPicked(Option<PathBuf>),
|
||||
MediaFilesPicked(Option<Vec<PathBuf>>),
|
||||
GalleryImagesPicked {
|
||||
post_id: String,
|
||||
result: Result<Option<Vec<PathBuf>>, String>,
|
||||
},
|
||||
GalleryImportFinished {
|
||||
post_id: String,
|
||||
report: engine::gallery_import::GalleryImportReport,
|
||||
},
|
||||
MediaImportFinished {
|
||||
imported: Vec<Media>,
|
||||
errors: Vec<String>,
|
||||
@@ -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::<Vec<Result<Media, String>>>()
|
||||
});
|
||||
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<Message> {
|
||||
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<Message> {
|
||||
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<String, String> {
|
||||
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<String> {
|
||||
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();
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -48,6 +48,31 @@ pub fn pick_media_files(title: String, filter_label: String) -> Task<Message> {
|
||||
)
|
||||
}
|
||||
|
||||
/// Pick images for a persisted post's batch gallery workflow.
|
||||
pub fn pick_gallery_images(post_id: String, title: String, filter_label: String) -> Task<Message> {
|
||||
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,
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user