Cascade template slug changes to references.
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 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, explicit syntax-check feedback, reference-safe template slug changes, 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.
|
||||
|
||||
@@ -53,6 +53,15 @@ impl DbConnection {
|
||||
.batch_execute("ROLLBACK TO bds_operation; RELEASE bds_operation")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn reject_tag_template_updates_for_test(&self) -> diesel::QueryResult<()> {
|
||||
self.0.borrow_mut().batch_execute(
|
||||
"CREATE TRIGGER reject_template_tag_cascade \
|
||||
BEFORE UPDATE OF post_template_slug ON tags \
|
||||
BEGIN SELECT RAISE(ABORT, 'reject cascade'); END",
|
||||
)
|
||||
}
|
||||
|
||||
/// Filesystem database path for sibling surfaces that must open their own
|
||||
/// short-lived connection (gallery workers, preview servers, Lua hosts).
|
||||
pub fn database_path(&self) -> diesel::QueryResult<std::path::PathBuf> {
|
||||
|
||||
@@ -8,7 +8,9 @@ use uuid::Uuid;
|
||||
|
||||
use crate::db::queries::template as qt;
|
||||
use crate::engine::{EngineError, EngineResult, domain_events};
|
||||
use crate::model::{DomainEntity, NotificationAction, Template, TemplateKind, TemplateStatus};
|
||||
use crate::model::{
|
||||
DomainEntity, NotificationAction, Post, Tag, Template, TemplateKind, TemplateStatus,
|
||||
};
|
||||
use crate::util::frontmatter::{TemplateFrontmatter, write_template_file};
|
||||
use crate::util::{atomic_write_str, ensure_unique, now_unix_ms, slugify};
|
||||
|
||||
@@ -73,6 +75,7 @@ pub fn create_template(
|
||||
)]
|
||||
pub fn update_template(
|
||||
conn: &Connection,
|
||||
data_dir: &Path,
|
||||
template_id: &str,
|
||||
project_id: &str,
|
||||
title: Option<&str>,
|
||||
@@ -82,6 +85,10 @@ pub fn update_template(
|
||||
content: Option<&str>,
|
||||
) -> EngineResult<Template> {
|
||||
let mut tpl = qt::get_template_by_id(conn, template_id)?;
|
||||
if tpl.project_id != project_id {
|
||||
return Err(EngineError::NotFound(format!("template {template_id}")));
|
||||
}
|
||||
let original_slug = tpl.slug.clone();
|
||||
|
||||
// Slug uniqueness check
|
||||
if let Some(new_slug) = slug
|
||||
@@ -119,10 +126,59 @@ pub fn update_template(
|
||||
tpl.status = TemplateStatus::Draft;
|
||||
}
|
||||
|
||||
let slug_changed = tpl.slug != original_slug;
|
||||
tpl.version += 1;
|
||||
tpl.updated_at = now_unix_ms();
|
||||
conn.begin_savepoint()?;
|
||||
let cascaded = (|| {
|
||||
qt::update_template(conn, &tpl)?;
|
||||
if slug_changed {
|
||||
cascade_template_slug_change(
|
||||
conn,
|
||||
&tpl.project_id,
|
||||
&original_slug,
|
||||
&tpl.slug,
|
||||
tpl.updated_at,
|
||||
)
|
||||
} else {
|
||||
Ok((Vec::new(), Vec::new()))
|
||||
}
|
||||
})();
|
||||
let (affected_posts, affected_tags) = match cascaded {
|
||||
Ok(affected) => {
|
||||
conn.release_savepoint()?;
|
||||
affected
|
||||
}
|
||||
Err(error) => {
|
||||
let _ = conn.rollback_savepoint();
|
||||
return Err(error);
|
||||
}
|
||||
};
|
||||
|
||||
emit_template(&tpl, NotificationAction::Updated);
|
||||
for post in &affected_posts {
|
||||
domain_events::entity_changed(
|
||||
&post.project_id,
|
||||
DomainEntity::Post,
|
||||
&post.id,
|
||||
NotificationAction::Updated,
|
||||
);
|
||||
}
|
||||
for tag in &affected_tags {
|
||||
domain_events::entity_changed(
|
||||
&tag.project_id,
|
||||
DomainEntity::Tag,
|
||||
&tag.id,
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -490,6 +546,64 @@ fn count_tags_using_template(conn: &Connection, slug: &str) -> EngineResult<usiz
|
||||
Ok(count as usize)
|
||||
}
|
||||
|
||||
fn cascade_template_slug_change(
|
||||
conn: &Connection,
|
||||
project_id: &str,
|
||||
old_slug: &str,
|
||||
new_slug: &str,
|
||||
updated_at: i64,
|
||||
) -> EngineResult<(Vec<Post>, Vec<Tag>)> {
|
||||
let mut affected_posts = conn.with(|c| {
|
||||
posts::table
|
||||
.filter(posts::project_id.eq(project_id))
|
||||
.filter(posts::template_slug.eq(old_slug))
|
||||
.select(Post::as_select())
|
||||
.load(c)
|
||||
})?;
|
||||
let mut affected_tags = conn.with(|c| {
|
||||
tags::table
|
||||
.filter(tags::project_id.eq(project_id))
|
||||
.filter(tags::post_template_slug.eq(old_slug))
|
||||
.select(Tag::as_select())
|
||||
.load(c)
|
||||
})?;
|
||||
|
||||
conn.with(|c| {
|
||||
diesel::update(
|
||||
posts::table
|
||||
.filter(posts::project_id.eq(project_id))
|
||||
.filter(posts::template_slug.eq(old_slug)),
|
||||
)
|
||||
.set((
|
||||
posts::template_slug.eq(new_slug),
|
||||
posts::updated_at.eq(updated_at),
|
||||
))
|
||||
.execute(c)
|
||||
})?;
|
||||
conn.with(|c| {
|
||||
diesel::update(
|
||||
tags::table
|
||||
.filter(tags::project_id.eq(project_id))
|
||||
.filter(tags::post_template_slug.eq(old_slug)),
|
||||
)
|
||||
.set((
|
||||
tags::post_template_slug.eq(new_slug),
|
||||
tags::updated_at.eq(updated_at),
|
||||
))
|
||||
.execute(c)
|
||||
})?;
|
||||
|
||||
for post in &mut affected_posts {
|
||||
post.template_slug = Some(new_slug.to_string());
|
||||
post.updated_at = updated_at;
|
||||
}
|
||||
for tag in &mut affected_tags {
|
||||
tag.post_template_slug = Some(new_slug.to_string());
|
||||
tag.updated_at = updated_at;
|
||||
}
|
||||
Ok((affected_posts, affected_tags))
|
||||
}
|
||||
|
||||
fn null_template_slug_on_posts(conn: &Connection, slug: &str) -> EngineResult<()> {
|
||||
conn.with(|c| {
|
||||
diesel::update(posts::table.filter(posts::template_slug.eq(slug)))
|
||||
@@ -555,10 +669,11 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn update_template_bumps_version() {
|
||||
let (db, _dir) = setup();
|
||||
let (db, dir) = setup();
|
||||
let tpl = create_template(db.conn(), "p1", "Tpl", TemplateKind::Post, "old").unwrap();
|
||||
let updated = update_template(
|
||||
db.conn(),
|
||||
dir.path(),
|
||||
&tpl.id,
|
||||
"p1",
|
||||
Some("New Title"),
|
||||
@@ -575,11 +690,12 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn update_template_slug_conflict() {
|
||||
let (db, _dir) = setup();
|
||||
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(
|
||||
db.conn(),
|
||||
dir.path(),
|
||||
&t2.id,
|
||||
"p1",
|
||||
None,
|
||||
@@ -591,6 +707,149 @@ mod tests {
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_template_slug_cascades_references_files_metadata_and_events() {
|
||||
let (db, dir) = setup();
|
||||
crate::db::fts::ensure_fts_tables(db.conn()).unwrap();
|
||||
let template = create_template(
|
||||
db.conn(),
|
||||
"p1",
|
||||
"Article",
|
||||
TemplateKind::Post,
|
||||
"{{ title }}",
|
||||
)
|
||||
.unwrap();
|
||||
let post = crate::engine::post::create_post(
|
||||
db.conn(),
|
||||
dir.path(),
|
||||
"p1",
|
||||
"Referenced Post",
|
||||
Some("body"),
|
||||
vec![],
|
||||
vec![],
|
||||
None,
|
||||
None,
|
||||
Some(&template.slug),
|
||||
)
|
||||
.unwrap();
|
||||
let mut published =
|
||||
crate::engine::post::publish_post(db.conn(), dir.path(), &post.id).unwrap();
|
||||
published.updated_at = 1;
|
||||
crate::db::queries::post::update_post(db.conn(), &published).unwrap();
|
||||
let mut tag =
|
||||
crate::engine::tag::create_tag(db.conn(), dir.path(), "p1", "featured", None).unwrap();
|
||||
crate::engine::tag::update_tag(
|
||||
db.conn(),
|
||||
dir.path(),
|
||||
&tag.id,
|
||||
None,
|
||||
None,
|
||||
Some(&template.slug),
|
||||
)
|
||||
.unwrap();
|
||||
tag = crate::db::queries::tag::get_tag_by_id(db.conn(), &tag.id).unwrap();
|
||||
tag.updated_at = 1;
|
||||
crate::db::queries::tag::update_tag(db.conn(), &tag).unwrap();
|
||||
let events = domain_events::subscribe();
|
||||
|
||||
update_template(
|
||||
db.conn(),
|
||||
dir.path(),
|
||||
&template.id,
|
||||
"p1",
|
||||
None,
|
||||
Some("renamed-article"),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let cascaded_post = crate::db::queries::post::get_post_by_id(db.conn(), &post.id).unwrap();
|
||||
assert_eq!(
|
||||
cascaded_post.template_slug.as_deref(),
|
||||
Some("renamed-article")
|
||||
);
|
||||
assert!(cascaded_post.updated_at > 1);
|
||||
let cascaded_tag = crate::db::queries::tag::get_tag_by_id(db.conn(), &tag.id).unwrap();
|
||||
assert_eq!(
|
||||
cascaded_tag.post_template_slug.as_deref(),
|
||||
Some("renamed-article")
|
||||
);
|
||||
assert!(cascaded_tag.updated_at > 1);
|
||||
|
||||
let post_file = fs::read_to_string(dir.path().join(&published.file_path)).unwrap();
|
||||
let (frontmatter, _) = crate::util::frontmatter::read_post_file(&post_file).unwrap();
|
||||
assert_eq!(
|
||||
frontmatter.template_slug.as_deref(),
|
||||
Some("renamed-article")
|
||||
);
|
||||
let tags = crate::engine::meta::read_tags_json(dir.path()).unwrap();
|
||||
assert_eq!(
|
||||
tags[0].post_template_slug.as_deref(),
|
||||
Some("renamed-article")
|
||||
);
|
||||
|
||||
let emitted = events.drain();
|
||||
for (entity, id) in [
|
||||
(DomainEntity::Template, template.id.as_str()),
|
||||
(DomainEntity::Post, post.id.as_str()),
|
||||
(DomainEntity::Tag, tag.id.as_str()),
|
||||
] {
|
||||
assert!(emitted.iter().any(|event| matches!(
|
||||
event,
|
||||
crate::model::DomainEvent::EntityChanged {
|
||||
project_id,
|
||||
entity: actual_entity,
|
||||
entity_id,
|
||||
action: NotificationAction::Updated,
|
||||
} if project_id == "p1" && actual_entity == &entity && entity_id == id
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_template_slug_rolls_back_the_whole_reference_cascade() {
|
||||
let (db, dir) = setup();
|
||||
let template =
|
||||
create_template(db.conn(), "p1", "Article", TemplateKind::Post, "body").unwrap();
|
||||
let mut post = crate::db::queries::post::make_test_post("post1", "p1", "post");
|
||||
post.template_slug = Some(template.slug.clone());
|
||||
crate::db::queries::post::insert_post(db.conn(), &post).unwrap();
|
||||
let tag =
|
||||
crate::engine::tag::create_tag(db.conn(), dir.path(), "p1", "featured", None).unwrap();
|
||||
crate::engine::tag::update_tag(
|
||||
db.conn(),
|
||||
dir.path(),
|
||||
&tag.id,
|
||||
None,
|
||||
None,
|
||||
Some(&template.slug),
|
||||
)
|
||||
.unwrap();
|
||||
db.conn().reject_tag_template_updates_for_test().unwrap();
|
||||
|
||||
let result = update_template(
|
||||
db.conn(),
|
||||
dir.path(),
|
||||
&template.id,
|
||||
"p1",
|
||||
None,
|
||||
Some("renamed-article"),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
|
||||
assert!(result.is_err());
|
||||
let unchanged_template = qt::get_template_by_id(db.conn(), &template.id).unwrap();
|
||||
assert_eq!(unchanged_template.slug, "article");
|
||||
let unchanged_post = crate::db::queries::post::get_post_by_id(db.conn(), &post.id).unwrap();
|
||||
assert_eq!(unchanged_post.template_slug.as_deref(), Some("article"));
|
||||
let unchanged_tag = crate::db::queries::tag::get_tag_by_id(db.conn(), &tag.id).unwrap();
|
||||
assert_eq!(unchanged_tag.post_template_slug.as_deref(), Some("article"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn save_template_updates_content() {
|
||||
let (db, _dir) = setup();
|
||||
|
||||
@@ -976,6 +976,7 @@ impl CoreHost {
|
||||
let data = object_arg(args, 1)?;
|
||||
public_template(engine::template::update_template(
|
||||
db.conn(),
|
||||
&self.data_dir,
|
||||
id,
|
||||
&self.project_id,
|
||||
optional_string_field(data, "title"),
|
||||
|
||||
@@ -1224,6 +1224,7 @@ impl TuiApp {
|
||||
let project_id = self.project_id()?.to_owned();
|
||||
engine::template::update_template(
|
||||
db.conn(),
|
||||
self.data_dir()?,
|
||||
&id,
|
||||
&project_id,
|
||||
Some(&title),
|
||||
|
||||
@@ -797,12 +797,14 @@ fn post_preview_url(post: &Post, language: &str, main_language: &str) -> String
|
||||
|
||||
fn save_template_editor_state_impl(
|
||||
db: &Database,
|
||||
data_dir: &Path,
|
||||
project_id: &str,
|
||||
state: &TemplateEditorState,
|
||||
) -> Result<Template, String> {
|
||||
engine::template::validate_template(&state.content)?;
|
||||
engine::template::update_template(
|
||||
db.conn(),
|
||||
data_dir,
|
||||
&state.template_id,
|
||||
project_id,
|
||||
Some(&state.title),
|
||||
@@ -6621,7 +6623,11 @@ impl BdsApp {
|
||||
return Task::none();
|
||||
};
|
||||
|
||||
match save_template_editor_state_impl(db, &project.id, state) {
|
||||
let Some(ref data_dir) = self.data_dir else {
|
||||
return Task::none();
|
||||
};
|
||||
|
||||
match save_template_editor_state_impl(db, data_dir, &project.id, state) {
|
||||
Ok(tmpl) => {
|
||||
let s = self.template_editors.get_mut(template_id).unwrap();
|
||||
s.is_dirty = false;
|
||||
@@ -11322,7 +11328,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn template_editor_save_flow_persists_changes() {
|
||||
let (db, project, _tmp) = setup();
|
||||
let (db, project, tmp) = setup();
|
||||
let created = template::create_template(
|
||||
db.conn(),
|
||||
&project.id,
|
||||
@@ -11338,7 +11344,8 @@ mod tests {
|
||||
editor.title = "Updated Template".to_string();
|
||||
editor.content = "<main>{{ title }}</main>".to_string();
|
||||
|
||||
let saved_template = save_template_editor_state_impl(&db, &project.id, &editor).unwrap();
|
||||
let saved_template =
|
||||
save_template_editor_state_impl(&db, tmp.path(), &project.id, &editor).unwrap();
|
||||
assert_eq!(saved_template.title, "Updated Template");
|
||||
|
||||
let saved =
|
||||
@@ -11349,7 +11356,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn template_editor_save_rejects_invalid_content() {
|
||||
let (db, project, _tmp) = setup();
|
||||
let (db, project, tmp) = setup();
|
||||
let created = template::create_template(
|
||||
db.conn(),
|
||||
&project.id,
|
||||
@@ -11364,7 +11371,8 @@ mod tests {
|
||||
let mut editor = TemplateEditorState::from_template(&template_record);
|
||||
editor.content = "{% if title %}".to_string();
|
||||
|
||||
let error = save_template_editor_state_impl(&db, &project.id, &editor).unwrap_err();
|
||||
let error =
|
||||
save_template_editor_state_impl(&db, tmp.path(), &project.id, &editor).unwrap_err();
|
||||
assert!(error.contains("endif") || error.contains("unclosed") || error.contains("missing"));
|
||||
|
||||
let saved =
|
||||
|
||||
Reference in New Issue
Block a user