diff --git a/README.md b/README.md index 32b904d..945aa7e 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ The project is under active development. Core blogging workflows are broadly ava ## Available Features - Native Iced desktop workspace with localized menus, tabs, anchored editor popovers, automatically paged post/media sidebars, dialogs, tasks, embedded Wry previews, and a live Pico CSS theme editor. -- Post and translation authoring with change-aware draft/published/archive lifecycle, desktop archive/unarchive actions, in-place published-frontmatter updates, metadata, tags, categories, links, media, and batch gallery-image import. +- Post and translation authoring with change-aware draft/published/archive lifecycle, canonical draft reopening after manual translation edits, non-disruptive automatic translation, desktop archive/unarchive actions, in-place published-frontmatter updates, metadata, tags, categories, links, media, and batch gallery-image import. - Media import, thumbnails, metadata translations, filters, validation, post assignment, and sequential drag-and-drop insertion into post editors. - WordPress WXR migration with saved analyses, HTML-to-Markdown and shortcode conversion, conflict/taxonomy review, recoverable 500-item execution batches, media-parent linking, progress reporting, and optional AI-assisted taxonomy mapping. - Post, Liquid template, and Lua script editing with dedicated syntax highlighting and explicit syntax-check feedback, including publish-time enforcement of the bDS2 Liquid tag/filter/operator subset, using a custom Ropey/Syntect/Cosmic Text editor and the documented, bDS2-signature-compatible project-scoped [`bds` host API](docs/scripting/API_REFERENCE.md) across utilities, rendered macros, and Blogmark transforms, including airplane-gated Git sync. diff --git a/crates/bds-core/src/engine/auto_translation.rs b/crates/bds-core/src/engine/auto_translation.rs index 8062027..afc0d33 100644 --- a/crates/bds-core/src/engine/auto_translation.rs +++ b/crates/bds-core/src/engine/auto_translation.rs @@ -276,7 +276,7 @@ fn translate_one_post( let mut input = post.clone(); input.content = Some(body); let translated = post_translator(&input, language)?; - let translation = crate::engine::post::upsert_translation( + let translation = crate::engine::post::upsert_automatic_translation( conn, data_dir, &post.id, @@ -442,6 +442,9 @@ mod tests { assert_eq!(requested, vec!["de", "fr"]); assert_eq!(report.translated_posts, 2); + let canonical = crate::db::queries::post::get_post_by_id(db.conn(), &post.id).unwrap(); + assert_eq!(canonical.status, PostStatus::Published); + assert!(canonical.content.is_none()); for language in ["de", "fr"] { let translation = post_translation::get_post_translation_by_post_and_language( db.conn(), diff --git a/crates/bds-core/src/engine/post.rs b/crates/bds-core/src/engine/post.rs index 96a9999..e2bff5f 100644 --- a/crates/bds-core/src/engine/post.rs +++ b/crates/bds-core/src/engine/post.rs @@ -603,69 +603,124 @@ pub fn upsert_translation( excerpt: Option<&str>, content: Option<&str>, ) -> EngineResult { - let post = qp::get_post_by_id(conn, post_id)?; + upsert_translation_with_mode( + conn, data_dir, post_id, language, title, excerpt, content, true, + ) +} + +/// Upsert a translation produced by the automatic translation engine without +/// reopening its published canonical source. +pub(crate) fn upsert_automatic_translation( + conn: &Connection, + data_dir: &Path, + post_id: &str, + language: &str, + title: &str, + excerpt: Option<&str>, + content: Option<&str>, +) -> EngineResult { + upsert_translation_with_mode( + conn, data_dir, post_id, language, title, excerpt, content, false, + ) +} + +#[expect( + clippy::too_many_arguments, + reason = "the final flag distinguishes manual and automatic translation sources" +)] +fn upsert_translation_with_mode( + conn: &Connection, + data_dir: &Path, + post_id: &str, + language: &str, + title: &str, + excerpt: Option<&str>, + content: Option<&str>, + manual_edit: bool, +) -> EngineResult { + let mut post = qp::get_post_by_id(conn, post_id)?; if post.do_not_translate { return Err(EngineError::Validation( "cannot create translation for a do-not-translate post".to_string(), )); } let now = now_unix_ms(); - - // Check if translation already exists - let existing = qt::get_post_translation_by_post_and_language(conn, post_id, language); - match existing { - Ok(mut t) => { - let published_body = if t.status == PostStatus::Published && !t.file_path.is_empty() { - let raw = fs::read_to_string(data_dir.join(&t.file_path))?; - let (_, body) = read_translation_file(&raw).map_err(EngineError::Parse)?; - Some(body) - } else { - t.content.clone() + conn.begin_savepoint()?; + let result = (|| { + let translation = + match qt::get_post_translation_by_post_and_language(conn, post_id, language) { + Ok(mut translation) => { + let published_body = if translation.status == PostStatus::Published + && !translation.file_path.is_empty() + { + let raw = fs::read_to_string(data_dir.join(&translation.file_path))?; + let (_, body) = read_translation_file(&raw).map_err(EngineError::Parse)?; + Some(body) + } else { + translation.content.clone() + }; + let affects_published_content = translation.title != title + || translation.excerpt.as_deref() != excerpt + || content.is_some_and(|value| published_body.as_deref() != Some(value)); + if translation.status == PostStatus::Published && affects_published_content { + translation.status = PostStatus::Draft; + translation.content = published_body; + } + translation.title = title.to_string(); + translation.excerpt = excerpt.map(str::to_string); + if let Some(content) = content { + translation.content = Some(content.to_string()); + } + translation.updated_at = now; + qt::update_post_translation(conn, &translation)?; + translation + } + Err(diesel::result::Error::NotFound) => { + let translation = PostTranslation { + id: Uuid::new_v4().to_string(), + project_id: post.project_id.clone(), + translation_for: post_id.to_string(), + language: language.to_string(), + title: title.to_string(), + excerpt: excerpt.map(str::to_string), + content: content.map(str::to_string), + status: PostStatus::Draft, + file_path: String::new(), + checksum: None, + created_at: now, + updated_at: now, + published_at: None, + }; + qt::insert_post_translation(conn, &translation)?; + translation + } + Err(error) => return Err(error.into()), }; - let affects_published_content = t.title != title - || t.excerpt.as_deref() != excerpt - || content.is_some_and(|value| published_body.as_deref() != Some(value)); - if t.status == PostStatus::Published && affects_published_content { - t.status = PostStatus::Draft; - t.content = published_body; - } - t.title = title.to_string(); - t.excerpt = excerpt.map(|s| s.to_string()); - if let Some(c) = content { - t.content = Some(c.to_string()); - } - t.updated_at = now; - qt::update_post_translation(conn, &t)?; - // Re-index FTS for parent post - fts_index_post(conn, data_dir, &post)?; - - Ok(t) + let source_reopened = + manual_edit && post.status == PostStatus::Published && !post.file_path.is_empty(); + if source_reopened { + post.content = Some(restore_content_for_unarchive(data_dir, &post)); + post.status = PostStatus::Draft; + post.updated_at = now; + qp::update_post(conn, &post)?; } - Err(_) => { - // Create new - let id = Uuid::new_v4().to_string(); - let t = PostTranslation { - id, - project_id: post.project_id.clone(), - translation_for: post_id.to_string(), - language: language.to_string(), - title: title.to_string(), - excerpt: excerpt.map(|s| s.to_string()), - content: content.map(|s| s.to_string()), - status: PostStatus::Draft, - file_path: String::new(), - checksum: None, - created_at: now, - updated_at: now, - published_at: None, - }; - qt::insert_post_translation(conn, &t)?; + fts_index_post(conn, data_dir, &post)?; + Ok((translation, source_reopened)) + })(); - // Re-index FTS for parent post - fts_index_post(conn, data_dir, &post)?; - - Ok(t) + match result { + Ok((translation, source_reopened)) => { + conn.release_savepoint()?; + if source_reopened { + emit_post(&post, NotificationAction::Updated); + crate::engine::embedding::sync_post_best_effort(conn, data_dir, &post); + } + Ok(translation) + } + Err(error) => { + let _ = conn.rollback_savepoint(); + Err(error) } } } @@ -1980,7 +2035,7 @@ mod tests { } #[test] - fn editing_published_translation_reopens_draft() { + fn manual_translation_edit_reopens_translation_and_canonical_drafts() { let (db, dir) = setup(); let post = create_post( db.conn(), @@ -2007,6 +2062,7 @@ mod tests { .unwrap(); publish_post(db.conn(), dir.path(), &post.id).unwrap(); + let events = domain_events::subscribe(); let edited = upsert_translation( db.conn(), dir.path(), @@ -2020,6 +2076,18 @@ mod tests { assert_eq!(edited.status, PostStatus::Draft); assert_eq!(edited.content.as_deref(), Some("Neuer Inhalt")); + let reopened = qp::get_post_by_id(db.conn(), &post.id).unwrap(); + assert_eq!(reopened.status, PostStatus::Draft); + assert_eq!(reopened.content.as_deref(), Some("body")); + assert!(events.drain().iter().any(|event| matches!( + event, + crate::model::DomainEvent::EntityChanged { + entity: DomainEntity::Post, + entity_id, + action: NotificationAction::Updated, + .. + } if entity_id == &post.id + ))); } #[test] diff --git a/crates/bds-ui/src/app.rs b/crates/bds-ui/src/app.rs index 26ef8b9..d87f433 100644 --- a/crates/bds-ui/src/app.rs +++ b/crates/bds-ui/src/app.rs @@ -10766,6 +10766,59 @@ mod tests { assert_eq!(saved.tags, vec!["rust".to_string(), "lua".to_string()]); } + #[test] + fn manual_translation_save_reopens_and_refreshes_published_canonical_editor() { + let (db, project, tmp) = setup(); + let created = post::create_post( + db.conn(), + tmp.path(), + &project.id, + "Canonical", + Some("Canonical body"), + Vec::new(), + vec!["article".to_string()], + None, + Some("en"), + None, + ) + .unwrap(); + post::upsert_translation( + db.conn(), + tmp.path(), + &created.id, + "de", + "Kanonisch", + None, + Some("Deutscher Inhalt"), + ) + .unwrap(); + let published = post::publish_post(db.conn(), tmp.path(), &created.id).unwrap(); + let mut app = make_app(db, project, &tmp); + open_post_editor(&mut app, &published); + let editor = app.post_editors.get_mut(&created.id).unwrap(); + editor.switch_language("de"); + editor.title = "Neu formuliert".to_string(); + editor.content = "Neuer Entwurf".to_string(); + editor.is_dirty = true; + + app.persist_post_editor_state(&created.id).unwrap(); + + let reopened = bds_core::db::queries::post::get_post_by_id( + app.db.as_ref().unwrap().conn(), + &created.id, + ) + .unwrap(); + assert_eq!(reopened.status, PostStatus::Draft); + assert_eq!(reopened.content.as_deref(), Some("Canonical body")); + + let _ = app.update(Message::DomainEventsTick); + let refreshed = &app.post_editors[&created.id]; + assert_eq!(refreshed.status, PostStatus::Draft); + assert_eq!(refreshed.active_language, "de"); + assert_eq!(refreshed.title, "Neu formuliert"); + assert_eq!(refreshed.content, "Neuer Entwurf"); + } + #[test] fn persist_post_editor_state_allows_published_posts_without_slug_conflict() { let (db, project, tmp) = setup();