Normalize template and script slug changes.
This commit is contained in:
@@ -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();
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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"),
|
||||
|
||||
Reference in New Issue
Block a user