Normalize template and script slug changes.

This commit is contained in:
2026-07-21 23:18:12 +02:00
parent e28d42d345
commit 8a6274ef6a
8 changed files with 312 additions and 41 deletions

View File

@@ -8,6 +8,7 @@ use crate::db::queries::script as qs;
use crate::engine::{EngineError, EngineResult, domain_events};
use crate::model::{DomainEntity, NotificationAction, Script, ScriptKind, ScriptStatus};
use crate::util::frontmatter::{ScriptFrontmatter, write_script_file};
use crate::util::paths::script_file_path;
use crate::util::{atomic_write_str, ensure_unique, now_unix_ms, slugify};
/// Create a new draft script. Content stored in DB, no file written.
@@ -64,6 +65,7 @@ pub fn create_script(
)]
pub fn update_script(
conn: &Connection,
data_dir: &Path,
script_id: &str,
project_id: &str,
title: Option<&str>,
@@ -74,23 +76,28 @@ pub fn update_script(
content: Option<&str>,
) -> EngineResult<Script> {
let mut script = qs::get_script_by_id(conn, script_id)?;
if script.project_id != project_id {
return Err(EngineError::NotFound(format!("script {script_id}")));
}
let original_slug = script.slug.clone();
let original_file_path = script.file_path.clone();
// Slug uniqueness
if let Some(new_slug) = slug
&& new_slug != script.slug
&& qs::get_script_by_slug(conn, project_id, new_slug).is_ok()
{
return Err(EngineError::Conflict(format!(
"script slug '{new_slug}' already exists"
)));
if let Some(requested_slug) = slug {
let slug = slugify(requested_slug);
let slug = if slug.is_empty() {
"script".to_string()
} else {
slug
};
script.slug = ensure_unique(&slug, |candidate| {
qs::get_script_by_slug(conn, project_id, candidate)
.is_ok_and(|existing| existing.id != script_id)
});
}
if let Some(t) = title {
script.title = t.to_string();
}
if let Some(s) = slug {
script.slug = s.to_string();
}
if let Some(k) = kind {
script.kind = k;
}
@@ -104,6 +111,16 @@ pub fn update_script(
script.content = Some(c.to_string());
}
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))
} else {
None
};
if 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 {
script.status = ScriptStatus::Draft;
@@ -112,6 +129,9 @@ pub fn update_script(
script.version += 1;
script.updated_at = now_unix_ms();
qs::update_script(conn, &script)?;
if let Some(body) = published_body {
rewrite_renamed_script_file(data_dir, &original_file_path, &script, &body)?;
}
emit_script(&script, NotificationAction::Updated);
Ok(script)
}
@@ -276,6 +296,46 @@ fn script_kind_to_frontmatter(kind: &ScriptKind) -> String {
}
}
fn read_published_script_body(data_dir: &Path, file_path: &str) -> String {
fs::read_to_string(data_dir.join(file_path))
.ok()
.and_then(|file| crate::util::frontmatter::read_script_file(&file).ok())
.map(|(_, body)| body)
.unwrap_or_default()
}
fn rewrite_renamed_script_file(
data_dir: &Path,
original_file_path: &str,
script: &Script,
body: &str,
) -> EngineResult<()> {
let frontmatter = ScriptFrontmatter {
id: script.id.clone(),
project_id: Some(script.project_id.clone()),
slug: script.slug.clone(),
title: script.title.clone(),
kind: script_kind_to_frontmatter(&script.kind),
entrypoint: script.entrypoint.clone(),
enabled: script.enabled,
version: script.version,
created_at: script.created_at,
updated_at: script.updated_at,
};
atomic_write_str(
&data_dir.join(&script.file_path),
&write_script_file(&frontmatter, body),
)?;
if original_file_path != script.file_path {
match fs::remove_file(data_dir.join(original_file_path)) {
Ok(()) => {}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
Err(error) => return Err(error.into()),
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
@@ -339,10 +399,11 @@ mod tests {
#[test]
fn update_script_bumps_version() {
let (db, _dir) = setup();
let (db, dir) = setup();
let s = create_script(db.conn(), "p1", "S", ScriptKind::Macro, "old", None).unwrap();
let updated = update_script(
db.conn(),
dir.path(),
&s.id,
"p1",
Some("New"),
@@ -358,6 +419,83 @@ mod tests {
assert_eq!(updated.version, 2);
}
#[test]
fn update_script_slug_normalizes_and_uniquifies() {
let (db, dir) = setup();
create_script(db.conn(), "p1", "Alpha", ScriptKind::Utility, "", None).unwrap();
let script = create_script(db.conn(), "p1", "Beta", ScriptKind::Utility, "", None).unwrap();
let updated = update_script(
db.conn(),
dir.path(),
&script.id,
"p1",
None,
Some(" Alpha! "),
None,
None,
None,
None,
)
.unwrap();
assert_eq!(updated.slug, "alpha-2");
assert!(updated.file_path.is_empty());
let unchanged = update_script(
db.conn(),
dir.path(),
&script.id,
"p1",
None,
Some(" Alpha 2 "),
None,
None,
None,
None,
)
.unwrap();
assert_eq!(unchanged.slug, "alpha-2");
}
#[test]
fn update_published_script_slug_rewrites_and_renames_file() {
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 old_path = dir.path().join(&published.file_path);
let updated = update_script(
db.conn(),
dir.path(),
&script.id,
"p1",
None,
Some(" Renamed Script! "),
None,
None,
None,
None,
)
.unwrap();
assert_eq!(updated.slug, "renamed-script");
assert_eq!(updated.file_path, "scripts/renamed-script.lua");
assert!(!old_path.exists());
let new_file = fs::read_to_string(dir.path().join(&updated.file_path)).unwrap();
let (frontmatter, body) = crate::util::frontmatter::read_script_file(&new_file).unwrap();
assert_eq!(frontmatter.slug, "renamed-script");
assert_eq!(body, "function main()\nend");
}
#[test]
fn save_script_updates_content() {
let (db, _dir) = setup();

View File

@@ -12,6 +12,7 @@ use crate::model::{
DomainEntity, NotificationAction, Post, Tag, Template, TemplateKind, TemplateStatus,
};
use crate::util::frontmatter::{TemplateFrontmatter, write_template_file};
use crate::util::paths::template_file_path;
use crate::util::{atomic_write_str, ensure_unique, now_unix_ms, slugify};
const ALLOWED_LIQUID_TAGS: &[&str] = &[
@@ -89,23 +90,24 @@ pub fn update_template(
return Err(EngineError::NotFound(format!("template {template_id}")));
}
let original_slug = tpl.slug.clone();
let original_file_path = tpl.file_path.clone();
// Slug uniqueness check
if let Some(new_slug) = slug
&& new_slug != tpl.slug
&& qt::get_template_by_slug(conn, project_id, new_slug).is_ok()
{
return Err(EngineError::Conflict(format!(
"template slug '{new_slug}' already exists"
)));
if let Some(requested_slug) = slug {
let slug = slugify(requested_slug);
let slug = if slug.is_empty() {
"template".to_string()
} else {
slug
};
tpl.slug = ensure_unique(&slug, |candidate| {
qt::get_template_by_slug(conn, project_id, candidate)
.is_ok_and(|existing| existing.id != template_id)
});
}
if let Some(t) = title {
tpl.title = t.to_string();
}
if let Some(s) = slug {
tpl.slug = s.to_string();
}
if let Some(k) = kind {
tpl.kind = k;
}
@@ -127,6 +129,14 @@ pub fn update_template(
}
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))
} else {
None
};
if published_body.is_some() {
tpl.file_path = template_file_path(&tpl.slug);
}
tpl.version += 1;
tpl.updated_at = now_unix_ms();
conn.begin_savepoint()?;
@@ -155,6 +165,16 @@ pub fn update_template(
}
};
for post in &affected_posts {
crate::engine::post::rewrite_published_post(conn, data_dir, &post.id)?;
}
if slug_changed {
crate::engine::tag::rewrite_tags_json(conn, data_dir, &tpl.project_id)?;
}
if let Some(body) = published_body {
rewrite_renamed_template_file(data_dir, &original_file_path, &tpl, &body)?;
}
emit_template(&tpl, NotificationAction::Updated);
for post in &affected_posts {
domain_events::entity_changed(
@@ -172,13 +192,6 @@ pub fn update_template(
NotificationAction::Updated,
);
}
for post in &affected_posts {
crate::engine::post::rewrite_published_post(conn, data_dir, &post.id)?;
}
if slug_changed {
crate::engine::tag::rewrite_tags_json(conn, data_dir, &tpl.project_id)?;
}
Ok(tpl)
}
@@ -361,6 +374,45 @@ fn template_kind_to_frontmatter(kind: &TemplateKind) -> String {
}
}
fn read_published_template_body(data_dir: &Path, file_path: &str) -> String {
fs::read_to_string(data_dir.join(file_path))
.ok()
.and_then(|file| crate::util::frontmatter::read_template_file(&file).ok())
.map(|(_, body)| body)
.unwrap_or_default()
}
fn rewrite_renamed_template_file(
data_dir: &Path,
original_file_path: &str,
template: &Template,
body: &str,
) -> EngineResult<()> {
let frontmatter = TemplateFrontmatter {
id: template.id.clone(),
project_id: Some(template.project_id.clone()),
slug: template.slug.clone(),
title: template.title.clone(),
kind: template_kind_to_frontmatter(&template.kind),
enabled: template.enabled,
version: template.version,
created_at: template.created_at,
updated_at: template.updated_at,
};
atomic_write_str(
&data_dir.join(&template.file_path),
&write_template_file(&frontmatter, body),
)?;
if original_file_path != template.file_path {
match fs::remove_file(data_dir.join(original_file_path)) {
Ok(()) => {}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
Err(error) => return Err(error.into()),
}
}
Ok(())
}
fn validate_liquid_subset(content: &str) -> Result<(), String> {
let mut cursor = 0;
while let Some((offset, is_tag)) = next_liquid_markup(&content[cursor..]) {
@@ -689,22 +741,74 @@ mod tests {
}
#[test]
fn update_template_slug_conflict() {
fn update_template_slug_normalizes_and_uniquifies() {
let (db, dir) = setup();
create_template(db.conn(), "p1", "Alpha", TemplateKind::Post, "").unwrap();
let t2 = create_template(db.conn(), "p1", "Beta", TemplateKind::Post, "").unwrap();
let result = update_template(
let updated = update_template(
db.conn(),
dir.path(),
&t2.id,
"p1",
None,
Some("alpha"),
Some(" Alpha! "),
None,
None,
None,
);
assert!(result.is_err());
)
.unwrap();
assert_eq!(updated.slug, "alpha-2");
assert!(updated.file_path.is_empty());
let unchanged = update_template(
db.conn(),
dir.path(),
&t2.id,
"p1",
None,
Some(" Alpha 2 "),
None,
None,
None,
)
.unwrap();
assert_eq!(unchanged.slug, "alpha-2");
}
#[test]
fn update_published_template_slug_rewrites_and_renames_file() {
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 old_path = dir.path().join(&published.file_path);
let updated = update_template(
db.conn(),
dir.path(),
&template.id,
"p1",
None,
Some(" Renamed Template! "),
None,
None,
None,
)
.unwrap();
assert_eq!(updated.slug, "renamed-template");
assert_eq!(updated.file_path, "templates/renamed-template.liquid");
assert!(!old_path.exists());
let new_file = fs::read_to_string(dir.path().join(&updated.file_path)).unwrap();
let (frontmatter, body) = crate::util::frontmatter::read_template_file(&new_file).unwrap();
assert_eq!(frontmatter.slug, "renamed-template");
assert_eq!(body, "<article>{{ title }}</article>");
}
#[test]

View File

@@ -894,6 +894,7 @@ impl CoreHost {
let data = object_arg(args, 1)?;
public_script(engine::script::update_script(
db.conn(),
&self.data_dir,
id,
&self.project_id,
optional_string_field(data, "title"),

View File

@@ -1246,6 +1246,7 @@ impl TuiApp {
let project_id = self.project_id()?.to_owned();
engine::script::update_script(
db.conn(),
self.data_dir()?,
&id,
&project_id,
Some(&title),

View File

@@ -818,12 +818,14 @@ fn save_template_editor_state_impl(
fn save_script_editor_state_impl(
db: &Database,
data_dir: &Path,
project_id: &str,
state: &ScriptEditorState,
) -> Result<Script, String> {
engine::script::validate_script_syntax(&state.content)?;
engine::script::update_script(
db.conn(),
data_dir,
&state.script_id,
project_id,
Some(&state.title),
@@ -6660,7 +6662,11 @@ impl BdsApp {
return Task::none();
};
match save_script_editor_state_impl(db, &project.id, state) {
let Some(ref data_dir) = self.data_dir else {
return Task::none();
};
match save_script_editor_state_impl(db, data_dir, &project.id, state) {
Ok(script) => {
let s = self.script_editors.get_mut(script_id).unwrap();
s.is_dirty = false;
@@ -11342,15 +11348,18 @@ mod tests {
bds_core::db::queries::template::get_template_by_id(db.conn(), &created.id).unwrap();
let mut editor = TemplateEditorState::from_template(&template_record);
editor.title = "Updated Template".to_string();
editor.slug = " Updated Template! ".to_string();
editor.content = "<main>{{ title }}</main>".to_string();
let saved_template =
save_template_editor_state_impl(&db, tmp.path(), &project.id, &editor).unwrap();
assert_eq!(saved_template.title, "Updated Template");
assert_eq!(saved_template.slug, "updated-template");
let saved =
bds_core::db::queries::template::get_template_by_id(db.conn(), &created.id).unwrap();
assert_eq!(saved.title, "Updated Template");
assert_eq!(saved.slug, "updated-template");
assert_eq!(saved.content.as_deref(), Some("<main>{{ title }}</main>"));
}
@@ -11385,7 +11394,7 @@ mod tests {
#[test]
fn script_editor_save_flow_persists_changes() {
let (db, project, _tmp) = setup();
let (db, project, tmp) = setup();
let created = script::create_script(
db.conn(),
&project.id,
@@ -11400,15 +11409,19 @@ mod tests {
bds_core::db::queries::script::get_script_by_id(db.conn(), &created.id).unwrap();
let mut editor = ScriptEditorState::from_script(&script_record);
editor.title = "Updated Script".to_string();
editor.slug = " Updated Script! ".to_string();
editor.content = "function main()\n return 'lua'\nend".to_string();
editor.entrypoint = "main".to_string();
let saved_script = save_script_editor_state_impl(&db, &project.id, &editor).unwrap();
let saved_script =
save_script_editor_state_impl(&db, tmp.path(), &project.id, &editor).unwrap();
assert_eq!(saved_script.title, "Updated Script");
assert_eq!(saved_script.slug, "updated-script");
let saved =
bds_core::db::queries::script::get_script_by_id(db.conn(), &created.id).unwrap();
assert_eq!(saved.title, "Updated Script");
assert_eq!(saved.slug, "updated-script");
assert_eq!(
saved.content.as_deref(),
Some("function main()\n return 'lua'\nend")
@@ -11417,7 +11430,7 @@ mod tests {
#[test]
fn script_editor_save_rejects_invalid_content() {
let (db, project, _tmp) = setup();
let (db, project, tmp) = setup();
let created = script::create_script(
db.conn(),
&project.id,
@@ -11433,7 +11446,8 @@ mod tests {
let mut editor = ScriptEditorState::from_script(&script_record);
editor.content = "function main()\n return 'oops'".to_string();
let error = save_script_editor_state_impl(&db, &project.id, &editor).unwrap_err();
let error =
save_script_editor_state_impl(&db, tmp.path(), &project.id, &editor).unwrap_err();
assert!(error.contains("end") || error.contains("unclosed") || error.contains("missing"));
let saved =
@@ -12775,6 +12789,7 @@ mod tests {
script::update_script(
app.db.as_ref().unwrap().conn(),
tmp.path(),
&created.id,
&project.id,
Some("Remote title"),