feat: more work on M4
This commit is contained in:
87
Cargo.lock
generated
87
Cargo.lock
generated
@@ -442,6 +442,58 @@ dependencies = [
|
||||
"arrayvec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "axum"
|
||||
version = "0.8.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8b52af3cb4058c895d37317bb27508dccc8e5f2d39454016b297bf4a400597b8"
|
||||
dependencies = [
|
||||
"axum-core",
|
||||
"bytes",
|
||||
"form_urlencoded",
|
||||
"futures-util",
|
||||
"http",
|
||||
"http-body",
|
||||
"http-body-util",
|
||||
"hyper",
|
||||
"hyper-util",
|
||||
"itoa",
|
||||
"matchit",
|
||||
"memchr",
|
||||
"mime",
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"serde_core",
|
||||
"serde_json",
|
||||
"serde_path_to_error",
|
||||
"serde_urlencoded",
|
||||
"sync_wrapper",
|
||||
"tokio",
|
||||
"tower",
|
||||
"tower-layer",
|
||||
"tower-service",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "axum-core"
|
||||
version = "0.5.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"futures-core",
|
||||
"http",
|
||||
"http-body",
|
||||
"http-body-util",
|
||||
"mime",
|
||||
"pin-project-lite",
|
||||
"sync_wrapper",
|
||||
"tower-layer",
|
||||
"tower-service",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "base64"
|
||||
version = "0.22.1"
|
||||
@@ -461,6 +513,7 @@ dependencies = [
|
||||
name = "bds-core"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"axum",
|
||||
"chrono",
|
||||
"deunicode",
|
||||
"image 0.25.10",
|
||||
@@ -479,6 +532,7 @@ dependencies = [
|
||||
"sys-locale",
|
||||
"tempfile",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
"uuid",
|
||||
"walkdir",
|
||||
]
|
||||
@@ -2232,6 +2286,12 @@ version = "1.10.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
|
||||
|
||||
[[package]]
|
||||
name = "httpdate"
|
||||
version = "1.0.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
|
||||
|
||||
[[package]]
|
||||
name = "hyper"
|
||||
version = "1.9.0"
|
||||
@@ -2245,6 +2305,7 @@ dependencies = [
|
||||
"http",
|
||||
"http-body",
|
||||
"httparse",
|
||||
"httpdate",
|
||||
"itoa",
|
||||
"pin-project-lite",
|
||||
"smallvec",
|
||||
@@ -3161,6 +3222,12 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "matchit"
|
||||
version = "0.8.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3"
|
||||
|
||||
[[package]]
|
||||
name = "maybe-rayon"
|
||||
version = "0.1.1"
|
||||
@@ -3210,6 +3277,12 @@ dependencies = [
|
||||
"paste",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mime"
|
||||
version = "0.3.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
|
||||
|
||||
[[package]]
|
||||
name = "miniz_oxide"
|
||||
version = "0.8.9"
|
||||
@@ -4454,7 +4527,7 @@ dependencies = [
|
||||
"once_cell",
|
||||
"socket2",
|
||||
"tracing",
|
||||
"windows-sys 0.59.0",
|
||||
"windows-sys 0.60.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5148,6 +5221,17 @@ dependencies = [
|
||||
"zmij",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_path_to_error"
|
||||
version = "0.1.20"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457"
|
||||
dependencies = [
|
||||
"itoa",
|
||||
"serde",
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_repr"
|
||||
version = "0.1.20"
|
||||
@@ -5890,6 +5974,7 @@ dependencies = [
|
||||
"tokio",
|
||||
"tower-layer",
|
||||
"tower-service",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -26,6 +26,7 @@ deunicode = "1"
|
||||
thiserror = "2"
|
||||
anyhow = "1"
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
axum = "0.8"
|
||||
walkdir = "2"
|
||||
image = "0.25"
|
||||
rust-stemmers = "1"
|
||||
|
||||
@@ -24,6 +24,8 @@ liquid = { workspace = true }
|
||||
liquid-core = { workspace = true }
|
||||
reqwest = { workspace = true }
|
||||
keyring = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
axum = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3"
|
||||
|
||||
@@ -1,7 +1,110 @@
|
||||
use crate::engine::{EngineError, EngineResult};
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::thread;
|
||||
|
||||
use axum::extract::{Path as AxumPath, Query, State};
|
||||
use axum::http::{StatusCode, Uri, header};
|
||||
use axum::response::{Html, IntoResponse, Response};
|
||||
use axum::routing::get;
|
||||
use axum::Router;
|
||||
use serde::Deserialize;
|
||||
use tokio::sync::oneshot;
|
||||
|
||||
use crate::db::{Database, queries};
|
||||
use crate::engine::generation::PublishedPostSource;
|
||||
use crate::model::ProjectMetadata;
|
||||
use crate::engine::{EngineError, EngineResult};
|
||||
use crate::model::{Post, PostStatus, ProjectMetadata};
|
||||
use crate::render::{build_canonical_post_path, render_starter_list_page, render_starter_single_post_page};
|
||||
use crate::util::frontmatter::{read_post_file, read_translation_file};
|
||||
|
||||
pub const PREVIEW_HOST: &str = "127.0.0.1";
|
||||
pub const PREVIEW_PORT: u16 = 4123;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct PreviewServerHandle {
|
||||
shutdown: Option<oneshot::Sender<()>>,
|
||||
thread: Option<thread::JoinHandle<()>>,
|
||||
}
|
||||
|
||||
impl PreviewServerHandle {
|
||||
pub fn stop(mut self) -> EngineResult<()> {
|
||||
if let Some(shutdown) = self.shutdown.take() {
|
||||
let _ = shutdown.send(());
|
||||
}
|
||||
if let Some(thread) = self.thread.take() {
|
||||
let _ = thread.join();
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for PreviewServerHandle {
|
||||
fn drop(&mut self) {
|
||||
if let Some(shutdown) = self.shutdown.take() {
|
||||
let _ = shutdown.send(());
|
||||
}
|
||||
if let Some(thread) = self.thread.take() {
|
||||
let _ = thread.join();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct PreviewServerState {
|
||||
db_path: PathBuf,
|
||||
data_dir: PathBuf,
|
||||
project_id: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Default)]
|
||||
struct DraftPreviewQuery {
|
||||
language: Option<String>,
|
||||
}
|
||||
|
||||
pub fn start_preview_server(
|
||||
db_path: PathBuf,
|
||||
data_dir: PathBuf,
|
||||
project_id: String,
|
||||
) -> EngineResult<PreviewServerHandle> {
|
||||
let state = PreviewServerState {
|
||||
db_path,
|
||||
data_dir,
|
||||
project_id,
|
||||
};
|
||||
let listener = std::net::TcpListener::bind((PREVIEW_HOST, PREVIEW_PORT)).map_err(|error| {
|
||||
if error.kind() == std::io::ErrorKind::AddrInUse {
|
||||
EngineError::Conflict(format!("preview server already running on {PREVIEW_HOST}:{PREVIEW_PORT}"))
|
||||
} else {
|
||||
EngineError::Io(error)
|
||||
}
|
||||
})?;
|
||||
listener.set_nonblocking(true)?;
|
||||
|
||||
let (shutdown_tx, shutdown_rx) = oneshot::channel();
|
||||
let thread = thread::spawn(move || {
|
||||
let runtime = tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
.expect("preview runtime");
|
||||
runtime.block_on(async move {
|
||||
let listener = tokio::net::TcpListener::from_std(listener).expect("preview listener");
|
||||
let app = Router::new()
|
||||
.route("/__draft/{post_id}", get(handle_draft_preview))
|
||||
.route("/", get(handle_preview_request))
|
||||
.route("/{*path}", get(handle_preview_request))
|
||||
.with_state(state);
|
||||
let server = axum::serve(listener, app).with_graceful_shutdown(async {
|
||||
let _ = shutdown_rx.await;
|
||||
});
|
||||
let _ = server.await;
|
||||
});
|
||||
});
|
||||
|
||||
Ok(PreviewServerHandle {
|
||||
shutdown: Some(shutdown_tx),
|
||||
thread: Some(thread),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn render_preview_path(
|
||||
path: &str,
|
||||
@@ -33,6 +136,192 @@ pub fn render_preview_path(
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
async fn handle_preview_request(
|
||||
State(state): State<PreviewServerState>,
|
||||
uri: Uri,
|
||||
) -> Response {
|
||||
match render_preview_response(&state, uri.path(), None) {
|
||||
Ok(response) => response,
|
||||
Err(error) => error_response(StatusCode::INTERNAL_SERVER_ERROR, &error.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_draft_preview(
|
||||
State(state): State<PreviewServerState>,
|
||||
AxumPath(post_id): AxumPath<String>,
|
||||
Query(query): Query<DraftPreviewQuery>,
|
||||
) -> Response {
|
||||
match render_preview_response(&state, &format!("/__draft/{post_id}"), query.language.as_deref()) {
|
||||
Ok(response) => response,
|
||||
Err(error) => error_response(StatusCode::INTERNAL_SERVER_ERROR, &error.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
fn render_preview_response(
|
||||
state: &PreviewServerState,
|
||||
path: &str,
|
||||
requested_language: Option<&str>,
|
||||
) -> EngineResult<Response> {
|
||||
if let Some(post_id) = path.strip_prefix("/__draft/") {
|
||||
let html = render_draft_preview(state, post_id, requested_language)?;
|
||||
return Ok(Html(html).into_response());
|
||||
}
|
||||
|
||||
if let Some(file_response) = serve_project_file(&state.data_dir, path)? {
|
||||
return Ok(file_response);
|
||||
}
|
||||
|
||||
let metadata = crate::engine::meta::read_project_json(&state.data_dir)?;
|
||||
let published_posts = collect_published_posts(state, &metadata)?;
|
||||
match render_preview_path(path, &metadata, &published_posts)? {
|
||||
Some(html) => Ok(Html(html).into_response()),
|
||||
None => Ok(error_response(StatusCode::NOT_FOUND, "preview not found")),
|
||||
}
|
||||
}
|
||||
|
||||
fn render_draft_preview(
|
||||
state: &PreviewServerState,
|
||||
post_id: &str,
|
||||
requested_language: Option<&str>,
|
||||
) -> EngineResult<String> {
|
||||
let db = Database::open(&state.db_path)?;
|
||||
let metadata = crate::engine::meta::read_project_json(&state.data_dir)?;
|
||||
let post = queries::post::get_post_by_id(db.conn(), post_id)?;
|
||||
let canonical_language = post.language.as_deref().unwrap_or_else(|| metadata.main_language.as_deref().unwrap_or("en"));
|
||||
let target_language = requested_language.unwrap_or(canonical_language);
|
||||
|
||||
if target_language != canonical_language {
|
||||
if let Ok(translation) = queries::post_translation::get_post_translation_by_post_and_language(
|
||||
db.conn(),
|
||||
post_id,
|
||||
target_language,
|
||||
) {
|
||||
let mut translated_post = post.clone();
|
||||
translated_post.title = translation.title.clone();
|
||||
translated_post.excerpt = translation.excerpt.clone();
|
||||
translated_post.language = Some(translation.language.clone());
|
||||
translated_post.status = translation.status.clone();
|
||||
translated_post.file_path = translation.file_path.clone();
|
||||
translated_post.published_at = translation.published_at.or(post.published_at);
|
||||
let body = load_translation_body(&state.data_dir, &translation)?;
|
||||
return render_starter_single_post_page(&translated_post, &body, &metadata, target_language)
|
||||
.map(|page| page.html)
|
||||
.map_err(|error| EngineError::Parse(error.to_string()));
|
||||
}
|
||||
}
|
||||
|
||||
let body = load_post_body(&state.data_dir, &post)?;
|
||||
render_starter_single_post_page(&post, &body, &metadata, canonical_language)
|
||||
.map(|page| page.html)
|
||||
.map_err(|error| EngineError::Parse(error.to_string()))
|
||||
}
|
||||
|
||||
fn collect_published_posts(
|
||||
state: &PreviewServerState,
|
||||
metadata: &ProjectMetadata,
|
||||
) -> EngineResult<Vec<PublishedPostSource>> {
|
||||
let db = Database::open(&state.db_path)?;
|
||||
let posts = queries::post::list_posts_by_project(db.conn(), &state.project_id)?;
|
||||
let mut published = Vec::new();
|
||||
for post in posts.into_iter().filter(|post| matches!(post.status, PostStatus::Published)) {
|
||||
published.push(PublishedPostSource {
|
||||
body_markdown: load_post_body(&state.data_dir, &post)?,
|
||||
post,
|
||||
});
|
||||
}
|
||||
let main_language = metadata.main_language.as_deref().unwrap_or("en");
|
||||
published.sort_by_key(|source| build_canonical_post_path(&source.post, main_language, main_language));
|
||||
Ok(published)
|
||||
}
|
||||
|
||||
fn load_post_body(data_dir: &Path, post: &Post) -> EngineResult<String> {
|
||||
if let Some(content) = &post.content {
|
||||
return Ok(content.clone());
|
||||
}
|
||||
if let Some(content) = &post.published_content {
|
||||
return Ok(content.clone());
|
||||
}
|
||||
load_markdown_body(data_dir, &post.file_path, false)
|
||||
}
|
||||
|
||||
fn load_translation_body(
|
||||
data_dir: &Path,
|
||||
translation: &crate::model::PostTranslation,
|
||||
) -> EngineResult<String> {
|
||||
if let Some(content) = &translation.content {
|
||||
return Ok(content.clone());
|
||||
}
|
||||
load_markdown_body(data_dir, &translation.file_path, true)
|
||||
}
|
||||
|
||||
fn load_markdown_body(data_dir: &Path, relative_path: &str, translation: bool) -> EngineResult<String> {
|
||||
let raw = fs::read_to_string(data_dir.join(relative_path.trim_start_matches('/')))?;
|
||||
let body = if translation {
|
||||
read_translation_file(&raw).map(|(_, body)| body)
|
||||
} else {
|
||||
read_post_file(&raw).map(|(_, body)| body)
|
||||
}
|
||||
.map_err(EngineError::Parse)?;
|
||||
Ok(body)
|
||||
}
|
||||
|
||||
fn serve_project_file(data_dir: &Path, path: &str) -> EngineResult<Option<Response>> {
|
||||
if let Some(response) = serve_scoped_file(data_dir, path, "/media/", "media")? {
|
||||
return Ok(Some(response));
|
||||
}
|
||||
if let Some(response) = serve_scoped_file(data_dir, path, "/assets/", "assets")? {
|
||||
return Ok(Some(response));
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
fn serve_scoped_file(
|
||||
data_dir: &Path,
|
||||
path: &str,
|
||||
prefix: &str,
|
||||
scope_dir: &str,
|
||||
) -> EngineResult<Option<Response>> {
|
||||
let Some(relative) = path.strip_prefix(prefix) else {
|
||||
return Ok(None);
|
||||
};
|
||||
let scope_root = data_dir.join(scope_dir);
|
||||
let candidate = scope_root.join(relative);
|
||||
if !candidate.exists() || !candidate.is_file() {
|
||||
return Ok(Some(error_response(StatusCode::NOT_FOUND, "preview asset not found")));
|
||||
}
|
||||
let canonical_candidate = candidate.canonicalize()?;
|
||||
let canonical_scope_root = scope_root.canonicalize().unwrap_or(scope_root);
|
||||
if !canonical_candidate.starts_with(&canonical_scope_root) {
|
||||
return Ok(Some(error_response(StatusCode::NOT_FOUND, "preview asset not found")));
|
||||
}
|
||||
let bytes = fs::read(&canonical_candidate)?;
|
||||
let mime = guess_content_type(&canonical_candidate);
|
||||
Ok(Some((
|
||||
StatusCode::OK,
|
||||
[(header::CONTENT_TYPE, mime)],
|
||||
bytes,
|
||||
)
|
||||
.into_response()))
|
||||
}
|
||||
|
||||
fn guess_content_type(path: &Path) -> &'static str {
|
||||
match path.extension().and_then(|ext| ext.to_str()).unwrap_or_default() {
|
||||
"css" => "text/css; charset=utf-8",
|
||||
"js" => "application/javascript; charset=utf-8",
|
||||
"json" => "application/json; charset=utf-8",
|
||||
"svg" => "image/svg+xml",
|
||||
"png" => "image/png",
|
||||
"jpg" | "jpeg" => "image/jpeg",
|
||||
"gif" => "image/gif",
|
||||
"webp" => "image/webp",
|
||||
_ => "application/octet-stream",
|
||||
}
|
||||
}
|
||||
|
||||
fn error_response(status: StatusCode, message: &str) -> Response {
|
||||
(status, [(header::CONTENT_TYPE, "text/plain; charset=utf-8")], message.to_string()).into_response()
|
||||
}
|
||||
|
||||
fn split_language_prefix(path: &str, metadata: &ProjectMetadata) -> (String, String) {
|
||||
let trimmed = path.trim_start_matches('/');
|
||||
let mut segments = trimmed.split('/');
|
||||
@@ -51,7 +340,43 @@ fn split_language_prefix(path: &str, metadata: &ProjectMetadata) -> (String, Str
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::model::{Post, PostStatus};
|
||||
use crate::db::queries;
|
||||
use crate::engine::meta;
|
||||
use crate::model::{Post, Project, ProjectMetadata, PostStatus};
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
|
||||
fn preview_port_guard() -> &'static Mutex<()> {
|
||||
static GUARD: OnceLock<Mutex<()>> = OnceLock::new();
|
||||
GUARD.get_or_init(|| Mutex::new(()))
|
||||
}
|
||||
|
||||
fn setup_preview_fixture() -> (tempfile::TempDir, Database) {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
std::fs::create_dir_all(dir.path().join("meta")).unwrap();
|
||||
std::fs::create_dir_all(dir.path().join("posts/2024/03")).unwrap();
|
||||
std::fs::create_dir_all(dir.path().join("media")).unwrap();
|
||||
|
||||
meta::write_project_json(dir.path(), &make_metadata()).unwrap();
|
||||
|
||||
let db_path = dir.path().join("bds.db");
|
||||
let mut db = Database::open(&db_path).unwrap();
|
||||
db.migrate().unwrap();
|
||||
queries::project::insert_project(
|
||||
db.conn(),
|
||||
&Project {
|
||||
id: "project-1".into(),
|
||||
name: "Blog".into(),
|
||||
slug: "blog".into(),
|
||||
description: None,
|
||||
data_path: Some(dir.path().to_string_lossy().to_string()),
|
||||
is_active: true,
|
||||
created_at: 1_710_000_000_000,
|
||||
updated_at: 1_710_000_000_000,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
(dir, db)
|
||||
}
|
||||
|
||||
fn make_metadata() -> ProjectMetadata {
|
||||
ProjectMetadata {
|
||||
@@ -99,6 +424,34 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
fn make_draft_post() -> Post {
|
||||
Post {
|
||||
id: "post-1".into(),
|
||||
project_id: "project-1".into(),
|
||||
title: "Hello".into(),
|
||||
slug: "hello".into(),
|
||||
excerpt: Some("Excerpt".into()),
|
||||
content: Some("Draft **body**".into()),
|
||||
status: PostStatus::Draft,
|
||||
author: None,
|
||||
language: Some("en".into()),
|
||||
do_not_translate: false,
|
||||
template_slug: None,
|
||||
file_path: "posts/2024/03/hello.md".into(),
|
||||
checksum: None,
|
||||
tags: vec![],
|
||||
categories: vec![],
|
||||
published_title: None,
|
||||
published_content: None,
|
||||
published_tags: None,
|
||||
published_categories: None,
|
||||
published_excerpt: None,
|
||||
created_at: 1_710_000_000_000,
|
||||
updated_at: 1_710_000_000_000,
|
||||
published_at: Some(1_710_000_000_000),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn root_preview_renders_index_page() {
|
||||
let html = render_preview_path("/", &make_metadata(), &[make_post()])
|
||||
@@ -123,4 +476,59 @@ mod tests {
|
||||
.unwrap();
|
||||
assert!(html.contains("lang=\"de\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preview_server_serves_draft_post_from_localhost() {
|
||||
let _guard = preview_port_guard().lock().unwrap();
|
||||
let (dir, db) = setup_preview_fixture();
|
||||
queries::post::insert_post(db.conn(), &make_draft_post()).unwrap();
|
||||
|
||||
let server = start_preview_server(
|
||||
dir.path().join("bds.db"),
|
||||
dir.path().to_path_buf(),
|
||||
"project-1".into(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let client = reqwest::blocking::Client::new();
|
||||
let mut body = None;
|
||||
for _ in 0..20 {
|
||||
if let Ok(response) = client.get(format!("http://{PREVIEW_HOST}:{PREVIEW_PORT}/__draft/post-1")).send() {
|
||||
if response.status().is_success() {
|
||||
body = Some(response.text().unwrap());
|
||||
break;
|
||||
}
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(50));
|
||||
}
|
||||
server.stop().unwrap();
|
||||
|
||||
let body = body.expect("draft preview response");
|
||||
assert!(body.contains("<h1>Hello</h1>"));
|
||||
assert!(body.contains("<strong>body</strong>"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preview_server_blocks_media_path_traversal() {
|
||||
let _guard = preview_port_guard().lock().unwrap();
|
||||
let (dir, _db) = setup_preview_fixture();
|
||||
std::fs::write(dir.path().join("outside.txt"), "nope").unwrap();
|
||||
std::fs::write(dir.path().join("media/ok.txt"), "ok").unwrap();
|
||||
|
||||
let server = start_preview_server(
|
||||
dir.path().join("bds.db"),
|
||||
dir.path().to_path_buf(),
|
||||
"project-1".into(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let client = reqwest::blocking::Client::new();
|
||||
let response = client
|
||||
.get(format!("http://{PREVIEW_HOST}:{PREVIEW_PORT}/media/../outside.txt"))
|
||||
.send()
|
||||
.unwrap();
|
||||
server.stop().unwrap();
|
||||
|
||||
assert_eq!(response.status(), StatusCode::NOT_FOUND);
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ use std::sync::Arc;
|
||||
|
||||
use chrono::Datelike;
|
||||
use iced::{Element, Subscription, Task};
|
||||
use serde_json::json;
|
||||
|
||||
use bds_core::db::Database;
|
||||
use bds_core::engine::task::{TaskId, TaskManager, TaskStatus};
|
||||
@@ -118,6 +119,8 @@ pub enum Message {
|
||||
ShowModal(modal::ModalState),
|
||||
DismissModal,
|
||||
ConfirmModal(modal::ConfirmAction),
|
||||
ToggleAiSuggestionField(usize, bool),
|
||||
ApplyAiSuggestions(modal::AiEntityTarget, Vec<modal::AiSuggestionField>),
|
||||
|
||||
// Blog actions (dispatched to engine)
|
||||
RebuildDatabase,
|
||||
@@ -217,6 +220,11 @@ enum PersistedMediaState {
|
||||
Translation,
|
||||
}
|
||||
|
||||
struct PreviewSession {
|
||||
project_id: String,
|
||||
handle: engine::preview::PreviewServerHandle,
|
||||
}
|
||||
|
||||
fn persist_media_editor_state_impl(
|
||||
db: &Database,
|
||||
data_dir: &Path,
|
||||
@@ -1162,6 +1170,9 @@ pub struct BdsApp {
|
||||
// Modal
|
||||
active_modal: Option<modal::ModalState>,
|
||||
|
||||
// Local preview
|
||||
preview_session: Option<PreviewSession>,
|
||||
|
||||
// Editor states (keyed by entity id)
|
||||
post_editors: HashMap<String, PostEditorState>,
|
||||
media_editors: HashMap<String, MediaEditorState>,
|
||||
@@ -1288,6 +1299,7 @@ impl BdsApp {
|
||||
theme_badge: String::from("pico"),
|
||||
toasts: Vec::new(),
|
||||
active_modal: None,
|
||||
preview_session: None,
|
||||
post_editors: HashMap::new(),
|
||||
media_editors: HashMap::new(),
|
||||
template_editors: HashMap::new(),
|
||||
@@ -1342,6 +1354,7 @@ impl BdsApp {
|
||||
theme_badge: String::from("pico"),
|
||||
toasts: Vec::new(),
|
||||
active_modal: None,
|
||||
preview_session: None,
|
||||
post_editors: HashMap::new(),
|
||||
media_editors: HashMap::new(),
|
||||
template_editors: HashMap::new(),
|
||||
@@ -1512,6 +1525,7 @@ impl BdsApp {
|
||||
match engine::project::set_active_project(db.conn(), &project_id) {
|
||||
Ok(()) => {
|
||||
self.active_project = self.projects.iter().find(|p| p.id == project_id).cloned();
|
||||
self.preview_session = None;
|
||||
self.data_dir = self
|
||||
.active_project
|
||||
.as_ref()
|
||||
@@ -1562,6 +1576,7 @@ impl BdsApp {
|
||||
let _ = engine::project::set_active_project(db.conn(), &project.id);
|
||||
self.projects = engine::project::list_projects(db.conn()).unwrap_or_default();
|
||||
self.active_project = Some(project.clone());
|
||||
self.preview_session = None;
|
||||
self.data_dir = project.data_path.as_ref().map(PathBuf::from);
|
||||
let msg = tw(self.ui_locale, "projectSelector.toast.created", &[("name", &project.name)]);
|
||||
self.notify(ToastLevel::Success, &msg);
|
||||
@@ -2042,11 +2057,30 @@ impl BdsApp {
|
||||
}
|
||||
}
|
||||
}
|
||||
Message::ToggleAiSuggestionField(index, accepted) => {
|
||||
if let Some(modal::ModalState::AISuggestions { fields, .. }) = self.active_modal.as_mut() {
|
||||
if let Some(field) = fields.get_mut(index) {
|
||||
if !field.locked {
|
||||
field.accepted = accepted;
|
||||
}
|
||||
}
|
||||
}
|
||||
Task::none()
|
||||
}
|
||||
Message::ApplyAiSuggestions(target, fields) => {
|
||||
self.active_modal = None;
|
||||
self.apply_ai_suggestions(target, &fields)
|
||||
}
|
||||
|
||||
// ── Editor view messages ──
|
||||
Message::PostEditor(msg) => {
|
||||
enum DeferredPostAction {
|
||||
None,
|
||||
Analyze(String),
|
||||
AnalyzeTaxonomy(String),
|
||||
DetectLanguage(String),
|
||||
OpenTranslate(String),
|
||||
TranslateTo { post_id: String, target_language: String },
|
||||
Save(String),
|
||||
Publish(String),
|
||||
Duplicate(String),
|
||||
@@ -2076,6 +2110,21 @@ impl BdsApp {
|
||||
if let Some(tab_id) = self.active_tab.clone() {
|
||||
if let Some(state) = self.post_editors.get_mut(&tab_id) {
|
||||
match msg {
|
||||
PostEditorMsg::AnalyzeWithAi => {
|
||||
deferred = DeferredPostAction::Analyze(tab_id.clone());
|
||||
}
|
||||
PostEditorMsg::AnalyzeTaxonomy => {
|
||||
deferred = DeferredPostAction::AnalyzeTaxonomy(tab_id.clone());
|
||||
}
|
||||
PostEditorMsg::DetectLanguage => {
|
||||
deferred = DeferredPostAction::DetectLanguage(tab_id.clone());
|
||||
}
|
||||
PostEditorMsg::Translate => {
|
||||
deferred = DeferredPostAction::OpenTranslate(tab_id.clone());
|
||||
}
|
||||
PostEditorMsg::TranslateTo(target_language) => {
|
||||
deferred = DeferredPostAction::TranslateTo { post_id: tab_id.clone(), target_language };
|
||||
}
|
||||
PostEditorMsg::TitleChanged(s) => { state.title = s; state.mark_dirty(); }
|
||||
PostEditorMsg::SlugChanged(s) => { state.slug = s; state.mark_dirty(); }
|
||||
PostEditorMsg::ExcerptChanged(s) => { state.excerpt = s; state.mark_dirty(); }
|
||||
@@ -2216,6 +2265,13 @@ impl BdsApp {
|
||||
|
||||
match deferred {
|
||||
DeferredPostAction::None => Task::none(),
|
||||
DeferredPostAction::Analyze(tab_id) => self.run_post_ai_analysis(&tab_id),
|
||||
DeferredPostAction::AnalyzeTaxonomy(tab_id) => self.run_post_taxonomy_analysis(&tab_id),
|
||||
DeferredPostAction::DetectLanguage(tab_id) => self.detect_post_language(&tab_id),
|
||||
DeferredPostAction::OpenTranslate(tab_id) => self.open_post_translation_modal(&tab_id),
|
||||
DeferredPostAction::TranslateTo { post_id, target_language } => {
|
||||
self.translate_post_to(&post_id, &target_language)
|
||||
}
|
||||
DeferredPostAction::Save(tab_id) => self.save_post_editor(&tab_id),
|
||||
DeferredPostAction::Publish(tab_id) => self.publish_post_editor(&tab_id),
|
||||
DeferredPostAction::Duplicate(tab_id) => self.duplicate_post_editor(&tab_id),
|
||||
@@ -2331,6 +2387,10 @@ impl BdsApp {
|
||||
Message::MediaEditor(msg) => {
|
||||
enum DeferredMediaAction {
|
||||
None,
|
||||
Analyze(String),
|
||||
DetectLanguage(String),
|
||||
OpenTranslate(String),
|
||||
TranslateTo { media_id: String, target_language: String },
|
||||
LinkPost { media_id: String, post_id: String },
|
||||
OpenLinkedPost(String),
|
||||
UnlinkPost { media_id: String, post_id: String },
|
||||
@@ -2343,6 +2403,18 @@ impl BdsApp {
|
||||
if let Some(tab_id) = self.active_tab.clone() {
|
||||
if let Some(state) = self.media_editors.get_mut(&tab_id) {
|
||||
match msg {
|
||||
MediaEditorMsg::AnalyzeWithAi => {
|
||||
deferred = DeferredMediaAction::Analyze(tab_id.clone());
|
||||
}
|
||||
MediaEditorMsg::DetectLanguage => {
|
||||
deferred = DeferredMediaAction::DetectLanguage(tab_id.clone());
|
||||
}
|
||||
MediaEditorMsg::TranslateMetadata => {
|
||||
deferred = DeferredMediaAction::OpenTranslate(tab_id.clone());
|
||||
}
|
||||
MediaEditorMsg::TranslateTo(target_language) => {
|
||||
deferred = DeferredMediaAction::TranslateTo { media_id: tab_id.clone(), target_language };
|
||||
}
|
||||
MediaEditorMsg::TitleChanged(s) => { state.title = s; state.is_dirty = true; }
|
||||
MediaEditorMsg::AltChanged(s) => { state.alt = s; state.is_dirty = true; }
|
||||
MediaEditorMsg::CaptionChanged(s) => { state.caption = s; state.is_dirty = true; }
|
||||
@@ -2403,6 +2475,12 @@ impl BdsApp {
|
||||
}
|
||||
match deferred {
|
||||
DeferredMediaAction::None => Task::none(),
|
||||
DeferredMediaAction::Analyze(media_id) => self.run_media_ai_analysis(&media_id),
|
||||
DeferredMediaAction::DetectLanguage(media_id) => self.detect_media_language(&media_id),
|
||||
DeferredMediaAction::OpenTranslate(media_id) => self.open_media_translation_modal(&media_id),
|
||||
DeferredMediaAction::TranslateTo { media_id, target_language } => {
|
||||
self.translate_media_to(&media_id, &target_language)
|
||||
}
|
||||
DeferredMediaAction::LinkPost { media_id, post_id } => self.link_media_to_post(&media_id, &post_id),
|
||||
DeferredMediaAction::OpenLinkedPost(post_id) => {
|
||||
let title = self
|
||||
@@ -2704,7 +2782,7 @@ impl BdsApp {
|
||||
t(self.ui_locale, "dialog.imageFilter"),
|
||||
),
|
||||
MenuAction::Save => Task::none(), // Disabled in M2
|
||||
MenuAction::OpenInBrowser => Task::none(),
|
||||
MenuAction::OpenInBrowser => self.preview_active_post(),
|
||||
MenuAction::OpenDataFolder => {
|
||||
if let Some(ref dir) = self.data_dir {
|
||||
let _ = open::that(dir);
|
||||
@@ -2733,7 +2811,7 @@ impl BdsApp {
|
||||
}
|
||||
// Blog
|
||||
MenuAction::PublishSelected => Task::none(), // Disabled in M2
|
||||
MenuAction::PreviewPost => Task::none(), // Disabled in M2
|
||||
MenuAction::PreviewPost => self.preview_active_post(),
|
||||
MenuAction::EditMenu => {
|
||||
self.open_singleton_tab(TabType::MenuEditor, "tabBar.menuEditor");
|
||||
Task::none()
|
||||
@@ -3603,13 +3681,17 @@ impl BdsApp {
|
||||
/// offline toggle) so that menu items reflect what's actually possible.
|
||||
fn sync_menu_state(&self) {
|
||||
let has_project = self.active_project.is_some();
|
||||
let has_tab = self.active_tab.is_some();
|
||||
let active_tab_type = self.active_tab.as_ref().and_then(|id| {
|
||||
self.tabs.iter().find(|t| t.id == *id).map(|t| &t.tab_type)
|
||||
});
|
||||
let has_tab = active_tab_type.is_some();
|
||||
let has_post_tab = active_tab_type == Some(&TabType::Post);
|
||||
|
||||
// File group: need active project for most, need open tab for Save
|
||||
self.menu_registry.set_enabled(MenuAction::NewPost, has_project);
|
||||
self.menu_registry.set_enabled(MenuAction::ImportMedia, has_project);
|
||||
self.menu_registry.set_enabled(MenuAction::Save, has_tab);
|
||||
self.menu_registry.set_enabled(MenuAction::OpenInBrowser, has_tab);
|
||||
self.menu_registry.set_enabled(MenuAction::OpenInBrowser, has_project && has_post_tab);
|
||||
self.menu_registry.set_enabled(MenuAction::OpenDataFolder, has_project);
|
||||
|
||||
// Edit: Find/Replace need an open tab
|
||||
@@ -3618,7 +3700,7 @@ impl BdsApp {
|
||||
|
||||
// Blog group: need active project
|
||||
self.menu_registry.set_enabled(MenuAction::PublishSelected, has_project && has_tab);
|
||||
self.menu_registry.set_enabled(MenuAction::PreviewPost, has_project && has_tab);
|
||||
self.menu_registry.set_enabled(MenuAction::PreviewPost, has_project && has_post_tab);
|
||||
self.menu_registry.set_enabled(MenuAction::EditMenu, has_project);
|
||||
self.menu_registry.set_enabled(MenuAction::RebuildDatabase, has_project);
|
||||
self.menu_registry.set_enabled(MenuAction::ReindexText, has_project);
|
||||
@@ -5575,4 +5657,562 @@ impl BdsApp {
|
||||
api_key,
|
||||
})
|
||||
}
|
||||
|
||||
fn preview_active_post(&mut self) -> Task<Message> {
|
||||
let Some(active_id) = self.active_tab.clone() else {
|
||||
return Task::none();
|
||||
};
|
||||
if !self
|
||||
.tabs
|
||||
.iter()
|
||||
.any(|tab| tab.id == active_id && tab.tab_type == TabType::Post)
|
||||
{
|
||||
return Task::none();
|
||||
}
|
||||
if let Err(error) = self.persist_post_editor_state(&active_id) {
|
||||
self.notify(ToastLevel::Error, &error);
|
||||
return Task::none();
|
||||
}
|
||||
|
||||
let Some(project) = self.active_project.as_ref() else {
|
||||
self.notify(ToastLevel::Error, &t(self.ui_locale, "engine.generateSiteNoProject"));
|
||||
return Task::none();
|
||||
};
|
||||
let Some(data_dir) = self.data_dir.clone() else {
|
||||
self.notify(ToastLevel::Error, &t(self.ui_locale, "engine.previewDataDirUnavailable"));
|
||||
return Task::none();
|
||||
};
|
||||
|
||||
let should_restart = self
|
||||
.preview_session
|
||||
.as_ref()
|
||||
.map(|session| session.project_id != project.id)
|
||||
.unwrap_or(true);
|
||||
if should_restart {
|
||||
self.preview_session = None;
|
||||
match engine::preview::start_preview_server(
|
||||
self.db_path.clone(),
|
||||
data_dir,
|
||||
project.id.clone(),
|
||||
) {
|
||||
Ok(handle) => {
|
||||
self.preview_session = Some(PreviewSession {
|
||||
project_id: project.id.clone(),
|
||||
handle,
|
||||
});
|
||||
}
|
||||
Err(engine::EngineError::Conflict(_)) if self.preview_session.is_none() => {
|
||||
self.notify(ToastLevel::Error, &t(self.ui_locale, "engine.previewPortInUse"));
|
||||
return Task::none();
|
||||
}
|
||||
Err(error) => {
|
||||
self.notify(ToastLevel::Error, &error.to_string());
|
||||
return Task::none();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(session) = &self.preview_session {
|
||||
let _ = &session.handle;
|
||||
}
|
||||
|
||||
let language = self
|
||||
.post_editors
|
||||
.get(&active_id)
|
||||
.map(|editor| editor.active_language.clone())
|
||||
.filter(|language| !language.is_empty())
|
||||
.unwrap_or_else(|| self.content_language.clone());
|
||||
let url = format!(
|
||||
"http://{}:{}/__draft/{}?language={}",
|
||||
engine::preview::PREVIEW_HOST,
|
||||
engine::preview::PREVIEW_PORT,
|
||||
active_id,
|
||||
language
|
||||
);
|
||||
if let Err(error) = open::that(&url) {
|
||||
self.notify(ToastLevel::Error, &error.to_string());
|
||||
}
|
||||
Task::none()
|
||||
}
|
||||
|
||||
fn run_post_ai_analysis(&mut self, post_id: &str) -> Task<Message> {
|
||||
let Some(db) = &self.db else {
|
||||
self.notify(ToastLevel::Error, "database unavailable");
|
||||
return Task::none();
|
||||
};
|
||||
let Some(state) = self.post_editors.get(post_id).cloned() else {
|
||||
return Task::none();
|
||||
};
|
||||
let request = ai::OneShotRequest {
|
||||
operation: ai::OneShotOperation::AnalyzePost,
|
||||
content: json!({
|
||||
"title": state.title,
|
||||
"excerpt": state.excerpt,
|
||||
"content": content_sample(&state.content, 2000),
|
||||
}),
|
||||
};
|
||||
match ai::run_one_shot(db.conn(), self.offline_mode, &request) {
|
||||
Ok(ai::OneShotResponse::PostAnalysis(result)) => {
|
||||
self.active_modal = Some(modal::ModalState::AISuggestions {
|
||||
target: modal::AiEntityTarget::Post(post_id.to_string()),
|
||||
fields: vec![
|
||||
modal::AiSuggestionField {
|
||||
key: "title".to_string(),
|
||||
label: t(self.ui_locale, "editor.title"),
|
||||
current_value: state.title,
|
||||
suggested_value: result.title,
|
||||
accepted: true,
|
||||
locked: false,
|
||||
},
|
||||
modal::AiSuggestionField {
|
||||
key: "excerpt".to_string(),
|
||||
label: t(self.ui_locale, "editor.excerpt"),
|
||||
current_value: state.excerpt,
|
||||
suggested_value: result.excerpt,
|
||||
accepted: true,
|
||||
locked: false,
|
||||
},
|
||||
modal::AiSuggestionField {
|
||||
key: "slug".to_string(),
|
||||
label: t(self.ui_locale, "editor.slug"),
|
||||
current_value: state.slug,
|
||||
suggested_value: result.slug,
|
||||
accepted: state.published_at.is_none(),
|
||||
locked: state.published_at.is_some(),
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
Ok(_) => {}
|
||||
Err(error) => self.notify(ToastLevel::Error, &error.to_string()),
|
||||
}
|
||||
Task::none()
|
||||
}
|
||||
|
||||
fn run_post_taxonomy_analysis(&mut self, post_id: &str) -> Task<Message> {
|
||||
let Some(db) = &self.db else {
|
||||
self.notify(ToastLevel::Error, "database unavailable");
|
||||
return Task::none();
|
||||
};
|
||||
let Some(state) = self.post_editors.get(post_id).cloned() else {
|
||||
return Task::none();
|
||||
};
|
||||
let request = ai::OneShotRequest {
|
||||
operation: ai::OneShotOperation::AnalyzeTaxonomy,
|
||||
content: json!({
|
||||
"title": state.title,
|
||||
"excerpt": state.excerpt,
|
||||
"content": content_sample(&state.content, 2000),
|
||||
"tags": state.tags,
|
||||
"categories": state.categories,
|
||||
}),
|
||||
};
|
||||
match ai::run_one_shot(db.conn(), self.offline_mode, &request) {
|
||||
Ok(ai::OneShotResponse::Taxonomy(result)) => {
|
||||
self.active_modal = Some(modal::ModalState::AISuggestions {
|
||||
target: modal::AiEntityTarget::Post(post_id.to_string()),
|
||||
fields: vec![
|
||||
modal::AiSuggestionField {
|
||||
key: "tags".to_string(),
|
||||
label: t(self.ui_locale, "sidebar.filter.tags"),
|
||||
current_value: state.tags.join(", "),
|
||||
suggested_value: result.tags.join(", "),
|
||||
accepted: true,
|
||||
locked: false,
|
||||
},
|
||||
modal::AiSuggestionField {
|
||||
key: "categories".to_string(),
|
||||
label: t(self.ui_locale, "sidebar.filter.categories"),
|
||||
current_value: state.categories.join(", "),
|
||||
suggested_value: result.categories.join(", "),
|
||||
accepted: true,
|
||||
locked: false,
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
Ok(_) => {}
|
||||
Err(error) => self.notify(ToastLevel::Error, &error.to_string()),
|
||||
}
|
||||
Task::none()
|
||||
}
|
||||
|
||||
fn detect_post_language(&mut self, post_id: &str) -> Task<Message> {
|
||||
let Some(db) = &self.db else {
|
||||
self.notify(ToastLevel::Error, "database unavailable");
|
||||
return Task::none();
|
||||
};
|
||||
let Some(state) = self.post_editors.get(post_id).cloned() else {
|
||||
return Task::none();
|
||||
};
|
||||
let request = ai::OneShotRequest {
|
||||
operation: ai::OneShotOperation::DetectLanguage,
|
||||
content: json!({
|
||||
"text": format!("{}\n\n{}\n\n{}", state.title, state.excerpt, content_sample(&state.content, 2000)),
|
||||
}),
|
||||
};
|
||||
match ai::run_one_shot(db.conn(), self.offline_mode, &request) {
|
||||
Ok(ai::OneShotResponse::LanguageDetection(result)) => {
|
||||
if let Some(editor) = self.post_editors.get_mut(post_id) {
|
||||
editor.language = result.language_code;
|
||||
editor.mark_dirty();
|
||||
}
|
||||
if let Err(error) = self.persist_post_editor_state(post_id) {
|
||||
self.notify(ToastLevel::Error, &error);
|
||||
} else {
|
||||
self.notify(ToastLevel::Success, &t(self.ui_locale, "editor.saved"));
|
||||
}
|
||||
}
|
||||
Ok(_) => {}
|
||||
Err(error) => self.notify(ToastLevel::Error, &error.to_string()),
|
||||
}
|
||||
Task::none()
|
||||
}
|
||||
|
||||
fn open_post_translation_modal(&mut self, post_id: &str) -> Task<Message> {
|
||||
let Some(state) = self.post_editors.get(post_id) else {
|
||||
return Task::none();
|
||||
};
|
||||
let targets = self
|
||||
.blog_languages
|
||||
.iter()
|
||||
.filter(|language| **language != state.active_language)
|
||||
.map(|language| modal::LanguageTarget {
|
||||
code: language.clone(),
|
||||
name: language_label(self.ui_locale, language),
|
||||
flag_emoji: bds_core::i18n::normalize_language(language).flag_emoji().to_string(),
|
||||
has_existing_translation: state.translation_drafts.contains_key(language),
|
||||
existing_status: state.translation_drafts.get(language).map(|draft| match draft.status {
|
||||
PostStatus::Draft => "draft".to_string(),
|
||||
PostStatus::Published => "published".to_string(),
|
||||
PostStatus::Archived => "archived".to_string(),
|
||||
}),
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
self.active_modal = Some(modal::ModalState::LanguagePicker {
|
||||
target: modal::AiEntityTarget::Post(post_id.to_string()),
|
||||
source_language: state.active_language.clone(),
|
||||
available_targets: targets,
|
||||
});
|
||||
Task::none()
|
||||
}
|
||||
|
||||
fn translate_post_to(&mut self, post_id: &str, target_language: &str) -> Task<Message> {
|
||||
self.active_modal = None;
|
||||
let Some(db) = &self.db else {
|
||||
self.notify(ToastLevel::Error, "database unavailable");
|
||||
return Task::none();
|
||||
};
|
||||
let Some(data_dir) = &self.data_dir else {
|
||||
self.notify(ToastLevel::Error, "project data directory unavailable");
|
||||
return Task::none();
|
||||
};
|
||||
let Some(state) = self.post_editors.get(post_id).cloned() else {
|
||||
return Task::none();
|
||||
};
|
||||
|
||||
if state.active_language == state.canonical_language && state.status == PostStatus::Published {
|
||||
match engine::post::update_post(
|
||||
db.conn(),
|
||||
data_dir,
|
||||
post_id,
|
||||
Some(&state.title),
|
||||
None,
|
||||
Some(Some(state.excerpt.as_str())),
|
||||
Some(&state.content),
|
||||
Some(state.tags.clone()),
|
||||
Some(state.categories.clone()),
|
||||
Some(Some(state.author.as_str())),
|
||||
Some(Some(state.language.as_str())),
|
||||
Some(Some(state.template_slug.as_str())),
|
||||
Some(state.do_not_translate),
|
||||
) {
|
||||
Ok(updated) => {
|
||||
if let Some(editor) = self.post_editors.get_mut(post_id) {
|
||||
editor.status = updated.status;
|
||||
}
|
||||
}
|
||||
Err(error) => {
|
||||
self.notify(ToastLevel::Error, &error.to_string());
|
||||
return Task::none();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let request = ai::OneShotRequest {
|
||||
operation: ai::OneShotOperation::TranslatePost { target_language: target_language.to_string() },
|
||||
content: json!({
|
||||
"title": state.title,
|
||||
"excerpt": state.excerpt,
|
||||
"content": state.content,
|
||||
}),
|
||||
};
|
||||
match ai::run_one_shot(db.conn(), self.offline_mode, &request) {
|
||||
Ok(ai::OneShotResponse::Translation(result)) => {
|
||||
if let Some(editor) = self.post_editors.get_mut(post_id) {
|
||||
editor.switch_language(target_language);
|
||||
editor.title = result.title.clone();
|
||||
editor.excerpt = result.excerpt.clone();
|
||||
editor.content = result.content.clone();
|
||||
editor.editor_buffer = std::cell::RefCell::new(bds_editor::EditorBuffer::new(&result.content));
|
||||
editor.mark_dirty();
|
||||
}
|
||||
if let Err(error) = self.persist_post_editor_state(post_id) {
|
||||
self.notify(ToastLevel::Error, &error);
|
||||
} else {
|
||||
self.notify(ToastLevel::Success, &t(self.ui_locale, "editor.saved"));
|
||||
}
|
||||
}
|
||||
Ok(_) => {}
|
||||
Err(error) => self.notify(ToastLevel::Error, &error.to_string()),
|
||||
}
|
||||
Task::none()
|
||||
}
|
||||
|
||||
fn run_media_ai_analysis(&mut self, media_id: &str) -> Task<Message> {
|
||||
let Some(db) = &self.db else {
|
||||
self.notify(ToastLevel::Error, "database unavailable");
|
||||
return Task::none();
|
||||
};
|
||||
let Some(state) = self.media_editors.get(media_id).cloned() else {
|
||||
return Task::none();
|
||||
};
|
||||
let request = ai::OneShotRequest {
|
||||
operation: ai::OneShotOperation::AnalyzeImage,
|
||||
content: json!({
|
||||
"title": state.title,
|
||||
"alt": state.alt,
|
||||
"caption": state.caption,
|
||||
"filename": state.original_name,
|
||||
"mime_type": state.mime_type,
|
||||
}),
|
||||
};
|
||||
match ai::run_one_shot(db.conn(), self.offline_mode, &request) {
|
||||
Ok(ai::OneShotResponse::ImageAnalysis(result)) => {
|
||||
self.active_modal = Some(modal::ModalState::AISuggestions {
|
||||
target: modal::AiEntityTarget::Media(media_id.to_string()),
|
||||
fields: vec![
|
||||
modal::AiSuggestionField {
|
||||
key: "title".to_string(),
|
||||
label: t(self.ui_locale, "editor.title"),
|
||||
current_value: state.title,
|
||||
suggested_value: result.title,
|
||||
accepted: true,
|
||||
locked: false,
|
||||
},
|
||||
modal::AiSuggestionField {
|
||||
key: "alt".to_string(),
|
||||
label: t(self.ui_locale, "editor.alt"),
|
||||
current_value: state.alt,
|
||||
suggested_value: result.alt,
|
||||
accepted: true,
|
||||
locked: false,
|
||||
},
|
||||
modal::AiSuggestionField {
|
||||
key: "caption".to_string(),
|
||||
label: t(self.ui_locale, "editor.caption"),
|
||||
current_value: state.caption,
|
||||
suggested_value: result.caption,
|
||||
accepted: true,
|
||||
locked: false,
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
Ok(_) => {}
|
||||
Err(error) => self.notify(ToastLevel::Error, &error.to_string()),
|
||||
}
|
||||
Task::none()
|
||||
}
|
||||
|
||||
fn detect_media_language(&mut self, media_id: &str) -> Task<Message> {
|
||||
let Some(db) = &self.db else {
|
||||
self.notify(ToastLevel::Error, "database unavailable");
|
||||
return Task::none();
|
||||
};
|
||||
let Some(state) = self.media_editors.get(media_id).cloned() else {
|
||||
return Task::none();
|
||||
};
|
||||
let request = ai::OneShotRequest {
|
||||
operation: ai::OneShotOperation::DetectLanguage,
|
||||
content: json!({
|
||||
"text": format!("{}\n{}\n{}", state.title, state.alt, state.caption),
|
||||
}),
|
||||
};
|
||||
match ai::run_one_shot(db.conn(), self.offline_mode, &request) {
|
||||
Ok(ai::OneShotResponse::LanguageDetection(result)) => {
|
||||
if let Some(editor) = self.media_editors.get_mut(media_id) {
|
||||
editor.language = result.language_code;
|
||||
editor.is_dirty = true;
|
||||
}
|
||||
if let Err(error) = self.persist_media_editor_state(media_id) {
|
||||
self.notify(ToastLevel::Error, &error);
|
||||
} else {
|
||||
self.notify(ToastLevel::Success, &t(self.ui_locale, "editor.saved"));
|
||||
}
|
||||
}
|
||||
Ok(_) => {}
|
||||
Err(error) => self.notify(ToastLevel::Error, &error.to_string()),
|
||||
}
|
||||
Task::none()
|
||||
}
|
||||
|
||||
fn open_media_translation_modal(&mut self, media_id: &str) -> Task<Message> {
|
||||
let Some(state) = self.media_editors.get(media_id) else {
|
||||
return Task::none();
|
||||
};
|
||||
let targets = self
|
||||
.blog_languages
|
||||
.iter()
|
||||
.filter(|language| **language != state.active_language)
|
||||
.map(|language| modal::LanguageTarget {
|
||||
code: language.clone(),
|
||||
name: language_label(self.ui_locale, language),
|
||||
flag_emoji: bds_core::i18n::normalize_language(language).flag_emoji().to_string(),
|
||||
has_existing_translation: state.translation_drafts.contains_key(language),
|
||||
existing_status: state.translation_drafts.get(language).map(|_| "draft".to_string()),
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
self.active_modal = Some(modal::ModalState::LanguagePicker {
|
||||
target: modal::AiEntityTarget::Media(media_id.to_string()),
|
||||
source_language: state.active_language.clone(),
|
||||
available_targets: targets,
|
||||
});
|
||||
Task::none()
|
||||
}
|
||||
|
||||
fn translate_media_to(&mut self, media_id: &str, target_language: &str) -> Task<Message> {
|
||||
self.active_modal = None;
|
||||
let Some(db) = &self.db else {
|
||||
self.notify(ToastLevel::Error, "database unavailable");
|
||||
return Task::none();
|
||||
};
|
||||
let Some(state) = self.media_editors.get(media_id).cloned() else {
|
||||
return Task::none();
|
||||
};
|
||||
let source_language = if state.language.is_empty() {
|
||||
match ai::run_one_shot(
|
||||
db.conn(),
|
||||
self.offline_mode,
|
||||
&ai::OneShotRequest {
|
||||
operation: ai::OneShotOperation::DetectLanguage,
|
||||
content: json!({ "text": format!("{}\n{}\n{}", state.title, state.alt, state.caption) }),
|
||||
},
|
||||
) {
|
||||
Ok(ai::OneShotResponse::LanguageDetection(result)) => result.language_code,
|
||||
Ok(_) => String::new(),
|
||||
Err(error) => {
|
||||
self.notify(ToastLevel::Error, &error.to_string());
|
||||
return Task::none();
|
||||
}
|
||||
}
|
||||
} else {
|
||||
state.language.clone()
|
||||
};
|
||||
if let Some(editor) = self.media_editors.get_mut(media_id) {
|
||||
if editor.language.is_empty() {
|
||||
editor.language = source_language;
|
||||
}
|
||||
}
|
||||
let request = ai::OneShotRequest {
|
||||
operation: ai::OneShotOperation::TranslateMedia { target_language: target_language.to_string() },
|
||||
content: json!({
|
||||
"title": state.title,
|
||||
"alt": state.alt,
|
||||
"caption": state.caption,
|
||||
}),
|
||||
};
|
||||
match ai::run_one_shot(db.conn(), self.offline_mode, &request) {
|
||||
Ok(ai::OneShotResponse::MediaTranslation(result)) => {
|
||||
if let Some(editor) = self.media_editors.get_mut(media_id) {
|
||||
editor.switch_language(target_language);
|
||||
editor.title = result.title.clone();
|
||||
editor.alt = result.alt.clone();
|
||||
editor.caption = result.caption.clone();
|
||||
editor.is_dirty = true;
|
||||
}
|
||||
if let Err(error) = self.persist_media_editor_state(media_id) {
|
||||
self.notify(ToastLevel::Error, &error);
|
||||
} else {
|
||||
self.notify(ToastLevel::Success, &t(self.ui_locale, "editor.saved"));
|
||||
}
|
||||
}
|
||||
Ok(_) => {}
|
||||
Err(error) => self.notify(ToastLevel::Error, &error.to_string()),
|
||||
}
|
||||
Task::none()
|
||||
}
|
||||
|
||||
fn apply_ai_suggestions(
|
||||
&mut self,
|
||||
target: modal::AiEntityTarget,
|
||||
fields: &[modal::AiSuggestionField],
|
||||
) -> Task<Message> {
|
||||
match target {
|
||||
modal::AiEntityTarget::Post(post_id) => {
|
||||
if let Some(editor) = self.post_editors.get_mut(&post_id) {
|
||||
for field in fields.iter().filter(|field| field.accepted && !field.locked) {
|
||||
match field.key.as_str() {
|
||||
"title" => editor.title = field.suggested_value.clone(),
|
||||
"excerpt" => editor.excerpt = field.suggested_value.clone(),
|
||||
"slug" => editor.slug = field.suggested_value.clone(),
|
||||
"tags" => {
|
||||
editor.tags = split_csv_values(&field.suggested_value);
|
||||
editor.tags_input.clear();
|
||||
}
|
||||
"categories" => {
|
||||
editor.categories = split_csv_values(&field.suggested_value);
|
||||
editor.categories_input.clear();
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
editor.mark_dirty();
|
||||
}
|
||||
if let Err(error) = self.persist_post_editor_state(&post_id) {
|
||||
self.notify(ToastLevel::Error, &error);
|
||||
} else {
|
||||
self.notify(ToastLevel::Success, &t(self.ui_locale, "editor.saved"));
|
||||
}
|
||||
}
|
||||
modal::AiEntityTarget::Media(media_id) => {
|
||||
if let Some(editor) = self.media_editors.get_mut(&media_id) {
|
||||
for field in fields.iter().filter(|field| field.accepted && !field.locked) {
|
||||
match field.key.as_str() {
|
||||
"title" => editor.title = field.suggested_value.clone(),
|
||||
"alt" => editor.alt = field.suggested_value.clone(),
|
||||
"caption" => editor.caption = field.suggested_value.clone(),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
editor.is_dirty = true;
|
||||
}
|
||||
if let Err(error) = self.persist_media_editor_state(&media_id) {
|
||||
self.notify(ToastLevel::Error, &error);
|
||||
} else {
|
||||
self.notify(ToastLevel::Success, &t(self.ui_locale, "editor.saved"));
|
||||
}
|
||||
}
|
||||
}
|
||||
Task::none()
|
||||
}
|
||||
}
|
||||
|
||||
fn content_sample(content: &str, max_len: usize) -> String {
|
||||
content.chars().take(max_len).collect()
|
||||
}
|
||||
|
||||
fn split_csv_values(value: &str) -> Vec<String> {
|
||||
value
|
||||
.split(',')
|
||||
.map(str::trim)
|
||||
.filter(|item| !item.is_empty())
|
||||
.map(|item| item.to_string())
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn language_label(locale: UiLocale, code: &str) -> String {
|
||||
let key = format!("language.{code}");
|
||||
let value = t(locale, &key);
|
||||
if value == key { code.to_string() } else { value }
|
||||
}
|
||||
|
||||
@@ -197,6 +197,10 @@ impl MediaEditorState {
|
||||
/// Media editor messages.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum MediaEditorMsg {
|
||||
AnalyzeWithAi,
|
||||
DetectLanguage,
|
||||
TranslateMetadata,
|
||||
TranslateTo(String),
|
||||
TitleChanged(String),
|
||||
AltChanged(String),
|
||||
CaptionChanged(String),
|
||||
@@ -224,6 +228,22 @@ pub fn view<'a>(
|
||||
text(state.original_name.clone()).size(18).into(),
|
||||
],
|
||||
vec![
|
||||
if state.mime_type.starts_with("image/") {
|
||||
button(text(t(locale, "editor.aiAnalyze")).size(13))
|
||||
.on_press(Message::MediaEditor(MediaEditorMsg::AnalyzeWithAi))
|
||||
.padding([6, 16])
|
||||
.into()
|
||||
} else {
|
||||
Space::new(0, 0).into()
|
||||
},
|
||||
button(text(t(locale, "editor.detectLanguage")).size(13))
|
||||
.on_press(Message::MediaEditor(MediaEditorMsg::DetectLanguage))
|
||||
.padding([6, 16])
|
||||
.into(),
|
||||
button(text(t(locale, "editor.translate")).size(13))
|
||||
.on_press(Message::MediaEditor(MediaEditorMsg::TranslateMetadata))
|
||||
.padding([6, 16])
|
||||
.into(),
|
||||
button(text(t(locale, "common.save")).size(13))
|
||||
.on_press(Message::MediaEditor(MediaEditorMsg::Save))
|
||||
.style(inputs::primary_button)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use std::path::Path;
|
||||
|
||||
use iced::widget::text::Shaping;
|
||||
use iced::widget::{button, column, container, image, row, text, text_input, Space};
|
||||
use iced::widget::{button, checkbox, column, container, image, row, scrollable, text, text_input, Space};
|
||||
use iced::{Alignment, Background, Border, Color, Element, Length, Theme};
|
||||
|
||||
use bds_core::i18n::UiLocale;
|
||||
@@ -20,6 +20,31 @@ pub struct InsertLinkResult {
|
||||
pub canonical_url: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum AiEntityTarget {
|
||||
Post(String),
|
||||
Media(String),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct AiSuggestionField {
|
||||
pub key: String,
|
||||
pub label: String,
|
||||
pub current_value: String,
|
||||
pub suggested_value: String,
|
||||
pub accepted: bool,
|
||||
pub locked: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct LanguageTarget {
|
||||
pub code: String,
|
||||
pub name: String,
|
||||
pub flag_emoji: String,
|
||||
pub has_existing_translation: bool,
|
||||
pub existing_status: Option<String>,
|
||||
}
|
||||
|
||||
/// Active modal state. Only one modal at a time.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum ModalState {
|
||||
@@ -60,6 +85,15 @@ pub enum ModalState {
|
||||
media_list: Vec<bds_core::model::Media>,
|
||||
selected_index: Option<usize>,
|
||||
},
|
||||
AISuggestions {
|
||||
target: AiEntityTarget,
|
||||
fields: Vec<AiSuggestionField>,
|
||||
},
|
||||
LanguagePicker {
|
||||
target: AiEntityTarget,
|
||||
source_language: String,
|
||||
available_targets: Vec<LanguageTarget>,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
@@ -874,6 +908,119 @@ pub fn view(state: ModalState, locale: UiLocale, data_dir: Option<&Path>) -> Ele
|
||||
.style(modal_box_style)
|
||||
.into()
|
||||
}
|
||||
|
||||
ModalState::AISuggestions { target, fields } => {
|
||||
let title = text(t(locale, "modal.aiSuggestions.title"))
|
||||
.size(16)
|
||||
.shaping(Shaping::Advanced)
|
||||
.color(Color::WHITE);
|
||||
|
||||
let rows = fields.iter().enumerate().map(|(index, field)| {
|
||||
let toggle = checkbox(field.label.clone(), field.accepted)
|
||||
.on_toggle_maybe((!field.locked)
|
||||
.then_some(move |value| Message::ToggleAiSuggestionField(index, value)))
|
||||
.size(16)
|
||||
.text_size(13);
|
||||
container(column![
|
||||
toggle,
|
||||
row![
|
||||
container(text(field.current_value.clone()).size(12).color(Color::from_rgb(0.58, 0.58, 0.64))).width(Length::FillPortion(1)),
|
||||
text("→").size(14).color(Color::from_rgb(0.62, 0.66, 0.74)),
|
||||
container(text(field.suggested_value.clone()).size(12).color(Color::WHITE)).width(Length::FillPortion(1)),
|
||||
]
|
||||
.spacing(10)
|
||||
.align_y(Alignment::Center),
|
||||
]
|
||||
.spacing(8))
|
||||
.padding(10)
|
||||
.style(|_: &Theme| container::Style {
|
||||
background: Some(Background::Color(Color::from_rgb(0.18, 0.20, 0.25))),
|
||||
border: Border {
|
||||
color: Color::from_rgb(0.30, 0.30, 0.35),
|
||||
width: 1.0,
|
||||
radius: 6.0.into(),
|
||||
},
|
||||
..container::Style::default()
|
||||
})
|
||||
.into()
|
||||
}).collect::<Vec<Element<'static, Message>>>();
|
||||
|
||||
let buttons = row![
|
||||
button(text(t(locale, "common.cancel")).size(13))
|
||||
.on_press(Message::DismissModal)
|
||||
.padding([6, 16])
|
||||
.style(cancel_button_style),
|
||||
Space::with_width(Length::Fill),
|
||||
button(text(t(locale, "modal.aiSuggestions.applySelected")).size(13))
|
||||
.on_press(Message::ApplyAiSuggestions(target, fields))
|
||||
.padding([6, 16])
|
||||
.style(confirm_button_style),
|
||||
];
|
||||
|
||||
let content = column![
|
||||
title,
|
||||
Space::with_height(12.0),
|
||||
scrollable(column(rows).spacing(8)).height(Length::Fixed(320.0)),
|
||||
Space::with_height(16.0),
|
||||
buttons,
|
||||
]
|
||||
.spacing(4);
|
||||
|
||||
container(content.padding(20))
|
||||
.width(Length::Fixed(640.0))
|
||||
.style(modal_box_style)
|
||||
.into()
|
||||
}
|
||||
|
||||
ModalState::LanguagePicker { target, source_language, available_targets } => {
|
||||
let title = text(t(locale, "modal.languagePicker.title"))
|
||||
.size(16)
|
||||
.shaping(Shaping::Advanced)
|
||||
.color(Color::WHITE);
|
||||
let subtitle = text(format!("{}: {}", t(locale, "editor.language"), source_language))
|
||||
.size(12)
|
||||
.shaping(Shaping::Advanced)
|
||||
.color(Color::from_rgb(0.60, 0.60, 0.68));
|
||||
|
||||
let rows = if available_targets.is_empty() {
|
||||
vec![text(t(locale, "common.noResults")).size(12).into()]
|
||||
} else {
|
||||
available_targets.into_iter().map(|language| {
|
||||
let message = match &target {
|
||||
AiEntityTarget::Post(_) => Message::PostEditor(PostEditorMsg::TranslateTo(language.code.clone())),
|
||||
AiEntityTarget::Media(_) => Message::MediaEditor(crate::views::media_editor::MediaEditorMsg::TranslateTo(language.code.clone())),
|
||||
};
|
||||
let status = language.existing_status.clone().unwrap_or_default();
|
||||
button(row![
|
||||
text(format!("{} {}", language.flag_emoji, language.name)).size(13).color(Color::WHITE),
|
||||
Space::with_width(Length::Fill),
|
||||
text(status).size(11).color(Color::from_rgb(0.60, 0.60, 0.68)),
|
||||
].align_y(Alignment::Center))
|
||||
.on_press(message)
|
||||
.padding([8, 12])
|
||||
.style(cancel_button_style)
|
||||
.into()
|
||||
}).collect::<Vec<Element<'static, Message>>>()
|
||||
};
|
||||
|
||||
let content = column![
|
||||
title,
|
||||
subtitle,
|
||||
Space::with_height(12.0),
|
||||
column(rows).spacing(6),
|
||||
Space::with_height(16.0),
|
||||
button(text(t(locale, "common.cancel")).size(13))
|
||||
.on_press(Message::DismissModal)
|
||||
.padding([6, 16])
|
||||
.style(cancel_button_style),
|
||||
]
|
||||
.spacing(4);
|
||||
|
||||
container(content.padding(20))
|
||||
.width(Length::Fixed(420.0))
|
||||
.style(modal_box_style)
|
||||
.into()
|
||||
}
|
||||
};
|
||||
|
||||
// Center the modal in a full-screen backdrop
|
||||
|
||||
@@ -322,6 +322,11 @@ impl PostEditorState {
|
||||
/// Post editor messages.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum PostEditorMsg {
|
||||
AnalyzeWithAi,
|
||||
AnalyzeTaxonomy,
|
||||
DetectLanguage,
|
||||
Translate,
|
||||
TranslateTo(String),
|
||||
TitleChanged(String),
|
||||
SlugChanged(String),
|
||||
ExcerptChanged(String),
|
||||
@@ -389,6 +394,18 @@ pub fn view<'a>(
|
||||
.into()],
|
||||
vec![
|
||||
status_badge(&state.status),
|
||||
button(text(t(locale, "editor.aiAnalyze")).size(13).shaping(Shaping::Advanced))
|
||||
.on_press(Message::PostEditor(PostEditorMsg::AnalyzeWithAi))
|
||||
.padding([6, 16])
|
||||
.into(),
|
||||
button(text(t(locale, "editor.suggestTaxonomy")).size(13).shaping(Shaping::Advanced))
|
||||
.on_press(Message::PostEditor(PostEditorMsg::AnalyzeTaxonomy))
|
||||
.padding([6, 16])
|
||||
.into(),
|
||||
button(text(t(locale, "editor.translate")).size(13).shaping(Shaping::Advanced))
|
||||
.on_press(Message::PostEditor(PostEditorMsg::Translate))
|
||||
.padding([6, 16])
|
||||
.into(),
|
||||
button(
|
||||
text(t(locale, "common.save"))
|
||||
.size(13)
|
||||
@@ -523,6 +540,9 @@ pub fn view<'a>(
|
||||
Some(&state.language),
|
||||
|lang| Message::PostEditor(PostEditorMsg::LanguageChanged(lang)),
|
||||
);
|
||||
let detect_language = button(text(t(locale, "editor.detectLanguage")).size(12).shaping(Shaping::Advanced))
|
||||
.on_press(Message::PostEditor(PostEditorMsg::DetectLanguage))
|
||||
.padding([6, 12]);
|
||||
let template_input = inputs::labeled_input(
|
||||
&t(locale, "editor.templateSlug"),
|
||||
"",
|
||||
@@ -534,7 +554,8 @@ pub fn view<'a>(
|
||||
state.do_not_translate,
|
||||
|b| Message::PostEditor(PostEditorMsg::ToggleDoNotTranslate(b)),
|
||||
);
|
||||
let meta_row2 = row![author_input, language_input, template_input, dnt]
|
||||
let language_block = column![language_input, detect_language].spacing(6);
|
||||
let meta_row2 = row![author_input, language_block, template_input, dnt]
|
||||
.spacing(16)
|
||||
.width(Length::Fill);
|
||||
|
||||
|
||||
@@ -144,6 +144,8 @@
|
||||
"engine.uploadOffline": "Hochladen nicht möglich: Flugmodus ist aktiv",
|
||||
"engine.generateSiteStarted": "Seite wird generiert...",
|
||||
"engine.generateSiteNoProject": "Kein aktives Projekt",
|
||||
"engine.previewDataDirUnavailable": "Projekt-Datenverzeichnis nicht verfügbar",
|
||||
"engine.previewPortInUse": "Der Port des Vorschau-Servers wird bereits verwendet",
|
||||
"dialog.selectFolder": "Ordner auswählen",
|
||||
"dialog.importMedia": "Medien importieren",
|
||||
"dialog.imageFilter": "Bilder",
|
||||
@@ -193,6 +195,9 @@
|
||||
"modal.postGallery.close": "Schließen",
|
||||
"modal.postGallery.backToGrid": "Zurück zur Übersicht",
|
||||
"modal.postGallery.unavailable": "Vorschau nicht verfügbar",
|
||||
"modal.aiSuggestions.title": "KI-Vorschläge",
|
||||
"modal.aiSuggestions.applySelected": "Auswahl anwenden",
|
||||
"modal.languagePicker.title": "Übersetzen nach",
|
||||
"sidebar.filter.search": "Suchen...",
|
||||
"sidebar.filter.toggle": "Filter",
|
||||
"sidebar.filter.clearAll": "Alle Filter zurücksetzen",
|
||||
@@ -218,6 +223,10 @@
|
||||
"editor.excerptPlaceholder": "Kurze Zusammenfassung...",
|
||||
"editor.author": "Autor",
|
||||
"editor.language": "Sprache",
|
||||
"editor.aiAnalyze": "Mit KI analysieren",
|
||||
"editor.detectLanguage": "Sprache erkennen",
|
||||
"editor.suggestTaxonomy": "Taxonomie vorschlagen",
|
||||
"editor.translate": "Übersetzen",
|
||||
"editor.templateSlug": "Vorlage",
|
||||
"editor.doNotTranslate": "Nicht übersetzen",
|
||||
"editor.publish": "Veröffentlichen",
|
||||
|
||||
@@ -144,6 +144,8 @@
|
||||
"engine.uploadOffline": "Cannot upload: airplane mode is active",
|
||||
"engine.generateSiteStarted": "Generating site...",
|
||||
"engine.generateSiteNoProject": "No active project",
|
||||
"engine.previewDataDirUnavailable": "Project data directory unavailable",
|
||||
"engine.previewPortInUse": "Preview server port is already in use",
|
||||
"dialog.selectFolder": "Select Folder",
|
||||
"dialog.importMedia": "Import Media",
|
||||
"dialog.imageFilter": "Images",
|
||||
@@ -193,6 +195,9 @@
|
||||
"modal.postGallery.close": "Close",
|
||||
"modal.postGallery.backToGrid": "Back to Grid",
|
||||
"modal.postGallery.unavailable": "Preview unavailable",
|
||||
"modal.aiSuggestions.title": "AI Suggestions",
|
||||
"modal.aiSuggestions.applySelected": "Apply Selected",
|
||||
"modal.languagePicker.title": "Translate To",
|
||||
"sidebar.filter.search": "Search...",
|
||||
"sidebar.filter.toggle": "Filters",
|
||||
"sidebar.filter.clearAll": "Clear All Filters",
|
||||
@@ -215,6 +220,10 @@
|
||||
"editor.excerptPlaceholder": "Brief summary...",
|
||||
"editor.author": "Author",
|
||||
"editor.language": "Language",
|
||||
"editor.aiAnalyze": "Analyze with AI",
|
||||
"editor.detectLanguage": "Detect Language",
|
||||
"editor.suggestTaxonomy": "Suggest Taxonomy",
|
||||
"editor.translate": "Translate",
|
||||
"editor.templateSlug": "Template",
|
||||
"editor.doNotTranslate": "Do Not Translate",
|
||||
"editor.publish": "Publish",
|
||||
|
||||
@@ -144,6 +144,8 @@
|
||||
"engine.uploadOffline": "No se puede subir: el modo avión está activo",
|
||||
"engine.generateSiteStarted": "Generando sitio...",
|
||||
"engine.generateSiteNoProject": "No hay proyecto activo",
|
||||
"engine.previewDataDirUnavailable": "El directorio de datos del proyecto no está disponible",
|
||||
"engine.previewPortInUse": "El puerto del servidor de vista previa ya está en uso",
|
||||
"dialog.selectFolder": "Seleccionar carpeta",
|
||||
"dialog.importMedia": "Importar medios",
|
||||
"dialog.imageFilter": "Imágenes",
|
||||
@@ -193,6 +195,9 @@
|
||||
"modal.postGallery.close": "Cerrar",
|
||||
"modal.postGallery.backToGrid": "Volver a la cuadrícula",
|
||||
"modal.postGallery.unavailable": "Vista previa no disponible",
|
||||
"modal.aiSuggestions.title": "Sugerencias de IA",
|
||||
"modal.aiSuggestions.applySelected": "Aplicar selección",
|
||||
"modal.languagePicker.title": "Traducir a",
|
||||
"sidebar.filter.search": "Buscar...",
|
||||
"sidebar.filter.toggle": "Filtros",
|
||||
"sidebar.filter.clearAll": "Borrar todos los filtros",
|
||||
@@ -218,6 +223,10 @@
|
||||
"editor.excerptPlaceholder": "Breve resumen...",
|
||||
"editor.author": "Autor",
|
||||
"editor.language": "Idioma",
|
||||
"editor.aiAnalyze": "Analizar con IA",
|
||||
"editor.detectLanguage": "Detectar idioma",
|
||||
"editor.suggestTaxonomy": "Sugerir taxonomía",
|
||||
"editor.translate": "Traducir",
|
||||
"editor.templateSlug": "Plantilla",
|
||||
"editor.doNotTranslate": "No traducir",
|
||||
"editor.publish": "Publicar",
|
||||
|
||||
@@ -144,6 +144,8 @@
|
||||
"engine.uploadOffline": "Impossible de publier : le mode avion est actif",
|
||||
"engine.generateSiteStarted": "Génération du site...",
|
||||
"engine.generateSiteNoProject": "Aucun projet actif",
|
||||
"engine.previewDataDirUnavailable": "Le répertoire de données du projet est indisponible",
|
||||
"engine.previewPortInUse": "Le port du serveur d'aperçu est déjà utilisé",
|
||||
"dialog.selectFolder": "Sélectionner un dossier",
|
||||
"dialog.importMedia": "Importer des médias",
|
||||
"dialog.imageFilter": "Images",
|
||||
@@ -193,6 +195,9 @@
|
||||
"modal.postGallery.close": "Fermer",
|
||||
"modal.postGallery.backToGrid": "Retour à la grille",
|
||||
"modal.postGallery.unavailable": "Aperçu indisponible",
|
||||
"modal.aiSuggestions.title": "Suggestions IA",
|
||||
"modal.aiSuggestions.applySelected": "Appliquer la sélection",
|
||||
"modal.languagePicker.title": "Traduire vers",
|
||||
"sidebar.filter.search": "Rechercher...",
|
||||
"sidebar.filter.toggle": "Filtres",
|
||||
"sidebar.filter.clearAll": "Effacer tous les filtres",
|
||||
@@ -218,6 +223,10 @@
|
||||
"editor.excerptPlaceholder": "Bref résumé...",
|
||||
"editor.author": "Auteur",
|
||||
"editor.language": "Langue",
|
||||
"editor.aiAnalyze": "Analyser avec l'IA",
|
||||
"editor.detectLanguage": "Détecter la langue",
|
||||
"editor.suggestTaxonomy": "Suggérer la taxonomie",
|
||||
"editor.translate": "Traduire",
|
||||
"editor.templateSlug": "Modèle",
|
||||
"editor.doNotTranslate": "Ne pas traduire",
|
||||
"editor.publish": "Publier",
|
||||
|
||||
@@ -144,6 +144,8 @@
|
||||
"engine.uploadOffline": "Impossibile caricare: la modalità aereo è attiva",
|
||||
"engine.generateSiteStarted": "Generazione del sito...",
|
||||
"engine.generateSiteNoProject": "Nessun progetto attivo",
|
||||
"engine.previewDataDirUnavailable": "Directory dati del progetto non disponibile",
|
||||
"engine.previewPortInUse": "La porta del server di anteprima è già in uso",
|
||||
"dialog.selectFolder": "Seleziona cartella",
|
||||
"dialog.importMedia": "Importa media",
|
||||
"dialog.imageFilter": "Immagini",
|
||||
@@ -193,6 +195,9 @@
|
||||
"modal.postGallery.close": "Chiudi",
|
||||
"modal.postGallery.backToGrid": "Torna alla griglia",
|
||||
"modal.postGallery.unavailable": "Anteprima non disponibile",
|
||||
"modal.aiSuggestions.title": "Suggerimenti IA",
|
||||
"modal.aiSuggestions.applySelected": "Applica selezione",
|
||||
"modal.languagePicker.title": "Traduci in",
|
||||
"sidebar.filter.search": "Cerca...",
|
||||
"sidebar.filter.toggle": "Filtri",
|
||||
"sidebar.filter.clearAll": "Cancella tutti i filtri",
|
||||
@@ -218,6 +223,10 @@
|
||||
"editor.excerptPlaceholder": "Breve riassunto...",
|
||||
"editor.author": "Autore",
|
||||
"editor.language": "Lingua",
|
||||
"editor.aiAnalyze": "Analizza con IA",
|
||||
"editor.detectLanguage": "Rileva lingua",
|
||||
"editor.suggestTaxonomy": "Suggerisci tassonomia",
|
||||
"editor.translate": "Traduci",
|
||||
"editor.templateSlug": "Modello",
|
||||
"editor.doNotTranslate": "Non tradurre",
|
||||
"editor.publish": "Pubblica",
|
||||
|
||||
Reference in New Issue
Block a user