Reopen canonical drafts after manual translation edits.

This commit is contained in:
2026-07-21 22:13:36 +02:00
parent 89a07c885a
commit b0e9223158
4 changed files with 180 additions and 56 deletions

View File

@@ -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.

View File

@@ -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(),

View File

@@ -603,56 +603,87 @@ pub fn upsert_translation(
excerpt: Option<&str>,
content: Option<&str>,
) -> EngineResult<PostTranslation> {
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<PostTranslation> {
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<PostTranslation> {
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))?;
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 {
t.content.clone()
translation.content.clone()
};
let affects_published_content = t.title != title
|| t.excerpt.as_deref() != excerpt
let affects_published_content = translation.title != title
|| translation.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;
if translation.status == PostStatus::Published && affects_published_content {
translation.status = PostStatus::Draft;
translation.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());
translation.title = title.to_string();
translation.excerpt = excerpt.map(str::to_string);
if let Some(content) = content {
translation.content = Some(content.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)
translation.updated_at = now;
qt::update_post_translation(conn, &translation)?;
translation
}
Err(_) => {
// Create new
let id = Uuid::new_v4().to_string();
let t = PostTranslation {
id,
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(|s| s.to_string()),
content: content.map(|s| s.to_string()),
excerpt: excerpt.map(str::to_string),
content: content.map(str::to_string),
status: PostStatus::Draft,
file_path: String::new(),
checksum: None,
@@ -660,12 +691,36 @@ pub fn upsert_translation(
updated_at: now,
published_at: None,
};
qt::insert_post_translation(conn, &t)?;
qt::insert_post_translation(conn, &translation)?;
translation
}
Err(error) => return Err(error.into()),
};
// Re-index FTS for parent post
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)?;
}
fts_index_post(conn, data_dir, &post)?;
Ok((translation, source_reopened))
})();
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]

View File

@@ -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();