Reopen canonical drafts after manual translation edits.
This commit is contained in:
@@ -7,7 +7,7 @@ The project is under active development. Core blogging workflows are broadly ava
|
|||||||
## Available Features
|
## 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.
|
- 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.
|
- 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.
|
- 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.
|
- 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.
|
||||||
|
|||||||
@@ -276,7 +276,7 @@ fn translate_one_post(
|
|||||||
let mut input = post.clone();
|
let mut input = post.clone();
|
||||||
input.content = Some(body);
|
input.content = Some(body);
|
||||||
let translated = post_translator(&input, language)?;
|
let translated = post_translator(&input, language)?;
|
||||||
let translation = crate::engine::post::upsert_translation(
|
let translation = crate::engine::post::upsert_automatic_translation(
|
||||||
conn,
|
conn,
|
||||||
data_dir,
|
data_dir,
|
||||||
&post.id,
|
&post.id,
|
||||||
@@ -442,6 +442,9 @@ mod tests {
|
|||||||
|
|
||||||
assert_eq!(requested, vec!["de", "fr"]);
|
assert_eq!(requested, vec!["de", "fr"]);
|
||||||
assert_eq!(report.translated_posts, 2);
|
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"] {
|
for language in ["de", "fr"] {
|
||||||
let translation = post_translation::get_post_translation_by_post_and_language(
|
let translation = post_translation::get_post_translation_by_post_and_language(
|
||||||
db.conn(),
|
db.conn(),
|
||||||
|
|||||||
@@ -603,56 +603,87 @@ pub fn upsert_translation(
|
|||||||
excerpt: Option<&str>,
|
excerpt: Option<&str>,
|
||||||
content: Option<&str>,
|
content: Option<&str>,
|
||||||
) -> EngineResult<PostTranslation> {
|
) -> 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 {
|
if post.do_not_translate {
|
||||||
return Err(EngineError::Validation(
|
return Err(EngineError::Validation(
|
||||||
"cannot create translation for a do-not-translate post".to_string(),
|
"cannot create translation for a do-not-translate post".to_string(),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
let now = now_unix_ms();
|
let now = now_unix_ms();
|
||||||
|
conn.begin_savepoint()?;
|
||||||
// Check if translation already exists
|
let result = (|| {
|
||||||
let existing = qt::get_post_translation_by_post_and_language(conn, post_id, language);
|
let translation =
|
||||||
match existing {
|
match qt::get_post_translation_by_post_and_language(conn, post_id, language) {
|
||||||
Ok(mut t) => {
|
Ok(mut translation) => {
|
||||||
let published_body = if t.status == PostStatus::Published && !t.file_path.is_empty() {
|
let published_body = if translation.status == PostStatus::Published
|
||||||
let raw = fs::read_to_string(data_dir.join(&t.file_path))?;
|
&& !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)?;
|
let (_, body) = read_translation_file(&raw).map_err(EngineError::Parse)?;
|
||||||
Some(body)
|
Some(body)
|
||||||
} else {
|
} else {
|
||||||
t.content.clone()
|
translation.content.clone()
|
||||||
};
|
};
|
||||||
let affects_published_content = t.title != title
|
let affects_published_content = translation.title != title
|
||||||
|| t.excerpt.as_deref() != excerpt
|
|| translation.excerpt.as_deref() != excerpt
|
||||||
|| content.is_some_and(|value| published_body.as_deref() != Some(value));
|
|| content.is_some_and(|value| published_body.as_deref() != Some(value));
|
||||||
if t.status == PostStatus::Published && affects_published_content {
|
if translation.status == PostStatus::Published && affects_published_content {
|
||||||
t.status = PostStatus::Draft;
|
translation.status = PostStatus::Draft;
|
||||||
t.content = published_body;
|
translation.content = published_body;
|
||||||
}
|
}
|
||||||
t.title = title.to_string();
|
translation.title = title.to_string();
|
||||||
t.excerpt = excerpt.map(|s| s.to_string());
|
translation.excerpt = excerpt.map(str::to_string);
|
||||||
if let Some(c) = content {
|
if let Some(content) = content {
|
||||||
t.content = Some(c.to_string());
|
translation.content = Some(content.to_string());
|
||||||
}
|
}
|
||||||
t.updated_at = now;
|
translation.updated_at = now;
|
||||||
qt::update_post_translation(conn, &t)?;
|
qt::update_post_translation(conn, &translation)?;
|
||||||
|
translation
|
||||||
// Re-index FTS for parent post
|
|
||||||
fts_index_post(conn, data_dir, &post)?;
|
|
||||||
|
|
||||||
Ok(t)
|
|
||||||
}
|
}
|
||||||
Err(_) => {
|
Err(diesel::result::Error::NotFound) => {
|
||||||
// Create new
|
let translation = PostTranslation {
|
||||||
let id = Uuid::new_v4().to_string();
|
id: Uuid::new_v4().to_string(),
|
||||||
let t = PostTranslation {
|
|
||||||
id,
|
|
||||||
project_id: post.project_id.clone(),
|
project_id: post.project_id.clone(),
|
||||||
translation_for: post_id.to_string(),
|
translation_for: post_id.to_string(),
|
||||||
language: language.to_string(),
|
language: language.to_string(),
|
||||||
title: title.to_string(),
|
title: title.to_string(),
|
||||||
excerpt: excerpt.map(|s| s.to_string()),
|
excerpt: excerpt.map(str::to_string),
|
||||||
content: content.map(|s| s.to_string()),
|
content: content.map(str::to_string),
|
||||||
status: PostStatus::Draft,
|
status: PostStatus::Draft,
|
||||||
file_path: String::new(),
|
file_path: String::new(),
|
||||||
checksum: None,
|
checksum: None,
|
||||||
@@ -660,12 +691,36 @@ pub fn upsert_translation(
|
|||||||
updated_at: now,
|
updated_at: now,
|
||||||
published_at: None,
|
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)?;
|
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]
|
#[test]
|
||||||
fn editing_published_translation_reopens_draft() {
|
fn manual_translation_edit_reopens_translation_and_canonical_drafts() {
|
||||||
let (db, dir) = setup();
|
let (db, dir) = setup();
|
||||||
let post = create_post(
|
let post = create_post(
|
||||||
db.conn(),
|
db.conn(),
|
||||||
@@ -2007,6 +2062,7 @@ mod tests {
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
publish_post(db.conn(), dir.path(), &post.id).unwrap();
|
publish_post(db.conn(), dir.path(), &post.id).unwrap();
|
||||||
|
|
||||||
|
let events = domain_events::subscribe();
|
||||||
let edited = upsert_translation(
|
let edited = upsert_translation(
|
||||||
db.conn(),
|
db.conn(),
|
||||||
dir.path(),
|
dir.path(),
|
||||||
@@ -2020,6 +2076,18 @@ mod tests {
|
|||||||
|
|
||||||
assert_eq!(edited.status, PostStatus::Draft);
|
assert_eq!(edited.status, PostStatus::Draft);
|
||||||
assert_eq!(edited.content.as_deref(), Some("Neuer Inhalt"));
|
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]
|
#[test]
|
||||||
|
|||||||
@@ -10766,6 +10766,59 @@ mod tests {
|
|||||||
assert_eq!(saved.tags, vec!["rust".to_string(), "lua".to_string()]);
|
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]
|
#[test]
|
||||||
fn persist_post_editor_state_allows_published_posts_without_slug_conflict() {
|
fn persist_post_editor_state_allows_published_posts_without_slug_conflict() {
|
||||||
let (db, project, tmp) = setup();
|
let (db, project, tmp) = setup();
|
||||||
|
|||||||
Reference in New Issue
Block a user