Reopen published code only for content changes.
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user