Reopen published code only for content changes.

This commit is contained in:
2026-07-21 23:25:11 +02:00
parent 8a6274ef6a
commit 1410b97aeb
5 changed files with 273 additions and 26 deletions

View File

@@ -10,7 +10,7 @@ The project is under active development. Core blogging workflows are broadly ava
- Post and translation authoring with change-aware draft/published/archive lifecycle, file-backed change discard, canonical draft reopening after manual translation edits, non-disruptive automatic translation, desktop archive/unarchive actions, in-place published-frontmatter updates, metadata, tags, categories, live link/backlink graphs, 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, explicit syntax-check feedback, normalized collision-safe template/script slug changes, reference-safe template renames, and 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, explicit syntax-check feedback, change-aware published/draft lifecycle, normalized collision-safe template/script slug changes, reference-safe template renames, and 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.
- SQLite and filesystem persistence with frontmatter, relationship-safe media sidecars, rebuild, metadata diff/repair, stale post-path cleanup on republish, bDS2-compatible checksums and NFD slug generation, and FTS5 search.
- Optional on-device multilingual semantic search and similar-post tag suggestions backed by a persistent USearch index, plus always-available partial-name tag autocomplete and duplicate-post review in the desktop workspace.
- Read-only in-app browsers in the Help menu for the bundled global `DOCUMENTATION.md`, the generated Lua API reference with public types and runnable examples, the [CLI/server/TUI documentation](CLI.md), and the [MCP server documentation](MCP.md), with safe GFM rendering and confirmed external links.

View File

@@ -81,6 +81,15 @@ pub fn update_script(
}
let original_slug = script.slug.clone();
let original_file_path = script.file_path.clone();
let was_published = script.status == ScriptStatus::Published;
let effective_content = script.content.clone().unwrap_or_else(|| {
if original_file_path.is_empty() {
String::new()
} else {
read_published_script_body(data_dir, &original_file_path)
}
});
let content_changed = content.is_some_and(|new_content| new_content != effective_content);
if let Some(requested_slug) = slug {
let slug = slugify(requested_slug);
@@ -112,17 +121,18 @@ pub fn update_script(
}
let slug_changed = script.slug != original_slug;
let published_body = if slug_changed && !original_file_path.is_empty() {
Some(read_published_script_body(data_dir, &original_file_path))
let published_body = if !original_file_path.is_empty()
&& (slug_changed || (was_published && !content_changed))
{
Some(effective_content)
} else {
None
};
if published_body.is_some() {
if slug_changed && published_body.is_some() {
script.file_path = script_file_path(&script.slug);
}
// If published, transition back to draft on edit
if script.status == ScriptStatus::Published {
if was_published && content_changed {
script.status = ScriptStatus::Draft;
}
@@ -137,17 +147,34 @@ pub fn update_script(
}
/// Save script content (editor save). Updates DB, bumps version.
pub fn save_script(conn: &Connection, script_id: &str, content: &str) -> EngineResult<Script> {
pub fn save_script(
conn: &Connection,
data_dir: &Path,
script_id: &str,
content: &str,
) -> EngineResult<Script> {
let mut script = qs::get_script_by_id(conn, script_id)?;
let was_published = script.status == ScriptStatus::Published;
let effective_content = script.content.clone().unwrap_or_else(|| {
if script.file_path.is_empty() {
String::new()
} else {
read_published_script_body(data_dir, &script.file_path)
}
});
let content_changed = content != effective_content;
script.content = Some(content.to_string());
script.version += 1;
script.updated_at = now_unix_ms();
if script.status == ScriptStatus::Published {
if was_published && content_changed {
script.status = ScriptStatus::Draft;
}
qs::update_script(conn, &script)?;
if was_published && !content_changed && !script.file_path.is_empty() {
rewrite_renamed_script_file(data_dir, &script.file_path, &script, &effective_content)?;
}
emit_script(&script, NotificationAction::Updated);
Ok(script)
}
@@ -494,13 +521,114 @@ mod tests {
let (frontmatter, body) = crate::util::frontmatter::read_script_file(&new_file).unwrap();
assert_eq!(frontmatter.slug, "renamed-script");
assert_eq!(body, "function main()\nend");
assert_eq!(updated.status, ScriptStatus::Published);
}
#[test]
fn published_script_metadata_update_stays_published_and_rewrites_frontmatter() {
let (db, dir) = setup();
let script = create_script(
db.conn(),
"p1",
"Published Script",
ScriptKind::Utility,
"function main()\nend",
None,
)
.unwrap();
let published = publish_script(db.conn(), dir.path(), &script.id).unwrap();
let updated = update_script(
db.conn(),
dir.path(),
&script.id,
"p1",
Some("Renamed title"),
None,
Some(ScriptKind::Transform),
Some("transform"),
Some(false),
None,
)
.unwrap();
assert_eq!(updated.status, ScriptStatus::Published);
let file = fs::read_to_string(dir.path().join(&published.file_path)).unwrap();
let (frontmatter, body) = crate::util::frontmatter::read_script_file(&file).unwrap();
assert_eq!(frontmatter.title, "Renamed title");
assert_eq!(frontmatter.kind, "transform");
assert_eq!(frontmatter.entrypoint, "transform");
assert!(!frontmatter.enabled);
assert_eq!(frontmatter.version, updated.version);
assert_eq!(body, "function main()\nend");
}
#[test]
fn published_script_content_change_reopens_draft_and_preserves_published_file() {
let (db, dir) = setup();
let script = create_script(
db.conn(),
"p1",
"Published Script",
ScriptKind::Utility,
"function main()\n return 'old'\nend",
None,
)
.unwrap();
let published = publish_script(db.conn(), dir.path(), &script.id).unwrap();
let updated = update_script(
db.conn(),
dir.path(),
&script.id,
"p1",
None,
None,
None,
None,
None,
Some("function main()\n return 'new'\nend"),
)
.unwrap();
assert_eq!(updated.status, ScriptStatus::Draft);
assert_eq!(
updated.content.as_deref(),
Some("function main()\n return 'new'\nend")
);
let file = fs::read_to_string(dir.path().join(&published.file_path)).unwrap();
let (_, body) = crate::util::frontmatter::read_script_file(&file).unwrap();
assert_eq!(body, "function main()\n return 'old'\nend");
}
#[test]
fn identical_published_script_save_stays_published() {
let (db, dir) = setup();
let script = create_script(
db.conn(),
"p1",
"Published Script",
ScriptKind::Utility,
"function main()\nend",
None,
)
.unwrap();
let published = publish_script(db.conn(), dir.path(), &script.id).unwrap();
let saved = save_script(db.conn(), dir.path(), &script.id, "function main()\nend").unwrap();
assert_eq!(saved.status, ScriptStatus::Published);
let file = fs::read_to_string(dir.path().join(&published.file_path)).unwrap();
let (frontmatter, body) = crate::util::frontmatter::read_script_file(&file).unwrap();
assert_eq!(frontmatter.version, saved.version);
assert_eq!(body, "function main()\nend");
}
#[test]
fn save_script_updates_content() {
let (db, _dir) = setup();
let (db, dir) = setup();
let s = create_script(db.conn(), "p1", "S", ScriptKind::Macro, "old", None).unwrap();
let saved = save_script(db.conn(), &s.id, "new body").unwrap();
let saved = save_script(db.conn(), dir.path(), &s.id, "new body").unwrap();
assert_eq!(saved.content, Some("new body".to_string()));
assert_eq!(saved.version, 2);
}

View File

@@ -91,6 +91,15 @@ pub fn update_template(
}
let original_slug = tpl.slug.clone();
let original_file_path = tpl.file_path.clone();
let was_published = tpl.status == TemplateStatus::Published;
let effective_content = tpl.content.clone().unwrap_or_else(|| {
if original_file_path.is_empty() {
String::new()
} else {
read_published_template_body(data_dir, &original_file_path)
}
});
let content_changed = content.is_some_and(|new_content| new_content != effective_content);
if let Some(requested_slug) = slug {
let slug = slugify(requested_slug);
@@ -118,23 +127,19 @@ pub fn update_template(
tpl.content = Some(c.to_string());
}
// If published, transition back to draft on edit
if tpl.status == TemplateStatus::Published {
// Reload content from file if needed
if tpl.content.is_none() && !tpl.file_path.is_empty() {
// Content will come from the file when we read for publish
// For update we need the new content from the caller
}
if was_published && content_changed {
tpl.status = TemplateStatus::Draft;
}
let slug_changed = tpl.slug != original_slug;
let published_body = if slug_changed && !original_file_path.is_empty() {
Some(read_published_template_body(data_dir, &original_file_path))
let published_body = if !original_file_path.is_empty()
&& (slug_changed || (was_published && !content_changed))
{
Some(effective_content)
} else {
None
};
if published_body.is_some() {
if slug_changed && published_body.is_some() {
tpl.file_path = template_file_path(&tpl.slug);
}
tpl.version += 1;
@@ -198,20 +203,32 @@ pub fn update_template(
/// Save template content (editor save). Updates DB, bumps version.
pub fn save_template(
conn: &Connection,
data_dir: &Path,
template_id: &str,
content: &str,
) -> EngineResult<Template> {
let mut tpl = qt::get_template_by_id(conn, template_id)?;
let was_published = tpl.status == TemplateStatus::Published;
let effective_content = tpl.content.clone().unwrap_or_else(|| {
if tpl.file_path.is_empty() {
String::new()
} else {
read_published_template_body(data_dir, &tpl.file_path)
}
});
let content_changed = content != effective_content;
tpl.content = Some(content.to_string());
tpl.version += 1;
tpl.updated_at = now_unix_ms();
// If published, transition back to draft
if tpl.status == TemplateStatus::Published {
if was_published && content_changed {
tpl.status = TemplateStatus::Draft;
}
qt::update_template(conn, &tpl)?;
if was_published && !content_changed && !tpl.file_path.is_empty() {
rewrite_renamed_template_file(data_dir, &tpl.file_path, &tpl, &effective_content)?;
}
emit_template(&tpl, NotificationAction::Updated);
Ok(tpl)
}
@@ -809,6 +826,98 @@ mod tests {
let (frontmatter, body) = crate::util::frontmatter::read_template_file(&new_file).unwrap();
assert_eq!(frontmatter.slug, "renamed-template");
assert_eq!(body, "<article>{{ title }}</article>");
assert_eq!(updated.status, TemplateStatus::Published);
}
#[test]
fn published_template_metadata_update_stays_published_and_rewrites_frontmatter() {
let (db, dir) = setup();
let template = create_template(
db.conn(),
"p1",
"Published Template",
TemplateKind::Post,
"<article>{{ title }}</article>",
)
.unwrap();
let published = publish_template(db.conn(), dir.path(), &template.id).unwrap();
let updated = update_template(
db.conn(),
dir.path(),
&template.id,
"p1",
Some("Renamed title"),
None,
Some(TemplateKind::List),
Some(false),
None,
)
.unwrap();
assert_eq!(updated.status, TemplateStatus::Published);
let file = fs::read_to_string(dir.path().join(&published.file_path)).unwrap();
let (frontmatter, body) = crate::util::frontmatter::read_template_file(&file).unwrap();
assert_eq!(frontmatter.title, "Renamed title");
assert_eq!(frontmatter.kind, "list");
assert!(!frontmatter.enabled);
assert_eq!(frontmatter.version, updated.version);
assert_eq!(body, "<article>{{ title }}</article>");
}
#[test]
fn published_template_content_change_reopens_draft_and_preserves_published_file() {
let (db, dir) = setup();
let template = create_template(
db.conn(),
"p1",
"Published Template",
TemplateKind::Post,
"old body",
)
.unwrap();
let published = publish_template(db.conn(), dir.path(), &template.id).unwrap();
let updated = update_template(
db.conn(),
dir.path(),
&template.id,
"p1",
None,
None,
None,
None,
Some("new body"),
)
.unwrap();
assert_eq!(updated.status, TemplateStatus::Draft);
assert_eq!(updated.content.as_deref(), Some("new body"));
let file = fs::read_to_string(dir.path().join(&published.file_path)).unwrap();
let (_, body) = crate::util::frontmatter::read_template_file(&file).unwrap();
assert_eq!(body, "old body");
}
#[test]
fn identical_published_template_save_stays_published() {
let (db, dir) = setup();
let template = create_template(
db.conn(),
"p1",
"Published Template",
TemplateKind::Post,
"same",
)
.unwrap();
let published = publish_template(db.conn(), dir.path(), &template.id).unwrap();
let saved = save_template(db.conn(), dir.path(), &template.id, "same").unwrap();
assert_eq!(saved.status, TemplateStatus::Published);
let file = fs::read_to_string(dir.path().join(&published.file_path)).unwrap();
let (frontmatter, body) = crate::util::frontmatter::read_template_file(&file).unwrap();
assert_eq!(frontmatter.version, saved.version);
assert_eq!(body, "same");
}
#[test]
@@ -956,9 +1065,9 @@ mod tests {
#[test]
fn save_template_updates_content() {
let (db, _dir) = setup();
let (db, dir) = setup();
let tpl = create_template(db.conn(), "p1", "Tpl", TemplateKind::Post, "old").unwrap();
let saved = save_template(db.conn(), &tpl.id, "new content").unwrap();
let saved = save_template(db.conn(), dir.path(), &tpl.id, "new content").unwrap();
assert_eq!(saved.content, Some("new content".to_string()));
assert_eq!(saved.version, 2);
}

View File

@@ -230,6 +230,10 @@ rule UpdateScript {
ensures: script.file_path = format("scripts/{slug}.lua", slug: script.slug)
ensures: ScriptFileWritten(script)
ensures: PreviousScriptFileDeleted(script)
if script.status = published and not script_changes_affect_published_output(changes):
ensures: script.status = published
ensures: ScriptFileWritten(script)
-- Keep published frontmatter synchronized for metadata-only and no-op content updates.
ensures: script.updated_at = now
ensures: script.version = script.version + 1
}
@@ -237,7 +241,8 @@ rule UpdateScript {
rule ReopenPublishedScript {
when: UpdateScriptRequested(script, changes)
requires: script.status = published
requires: script_changes_affect_published_output(changes)
requires: changes.content != null
requires: changes.content != effective_script_content(script)
ensures: script.status = draft
}

View File

@@ -135,6 +135,10 @@ rule UpdateTemplate {
ensures: template.file_path = format("templates/{slug}.liquid", slug: template.slug)
ensures: TemplateFileWritten(template)
ensures: PreviousTemplateFileDeleted(template)
if template.status = published and not template_changes_affect_rendered_output(changes):
ensures: template.status = published
ensures: TemplateFileWritten(template)
-- Keep published frontmatter synchronized for metadata-only and no-op content updates.
ensures: template.updated_at = now
ensures: template.version = template.version + 1
}
@@ -142,7 +146,8 @@ rule UpdateTemplate {
rule ReopenPublishedTemplate {
when: UpdateTemplateRequested(template, changes)
requires: template.status = published
requires: template_changes_affect_rendered_output(changes)
requires: changes.content != null
requires: changes.content != effective_template_content(template)
ensures: template.status = draft
}