Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
135 changes: 135 additions & 0 deletions packages/core/src/behaviors/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1051,6 +1051,35 @@ impl SchemaNodeBehavior {
validate_schema_field(field)?;
}

// Validate title_template syntax: every '{' must have a matching '}' and a non-empty field name
if let Some(template) = &schema.title_template {
let bytes = template.as_bytes();
let mut i = 0;
while i < bytes.len() {
if bytes[i] == b'{' {
// Find matching '}'
match bytes[i + 1..].iter().position(|&c| c == b'}') {
None => {
return Err(NodeValidationError::InvalidProperties(
"title_template contains an unclosed '{' placeholder".to_string(),
));
}
Some(end) => {
let field_name = &template[i + 1..i + 1 + end];
if field_name.is_empty() {
return Err(NodeValidationError::InvalidProperties(
"title_template contains an empty '{}' placeholder".to_string(),
));
}
i += 1 + end + 1; // skip past '}'
continue;
}
}
}
i += 1;
}
}

Ok(())
}
}
Expand Down Expand Up @@ -2856,6 +2885,112 @@ mod tests {
));
}

// =========================================================================
// title_template Validation Tests (Issue #824)
// =========================================================================

#[test]
fn test_title_template_valid() {
use crate::models::SchemaNode;
let behavior = SchemaNodeBehavior;

let node = Node::new(
"schema".to_string(),
"Customer".to_string(),
json!({
"isCore": false,
"schemaVersion": 1,
"description": "",
"titleTemplate": "{first_name} {last_name}",
"fields": [],
"relationships": []
}),
);
let schema = SchemaNode::from_node(node).unwrap();
assert!(
behavior.validate_schema_node(&schema).is_ok(),
"Valid title_template should pass validation"
);
}

#[test]
fn test_title_template_unclosed_brace_rejected() {
use crate::models::SchemaNode;
let behavior = SchemaNodeBehavior;

let node = Node::new(
"schema".to_string(),
"Customer".to_string(),
json!({
"isCore": false,
"schemaVersion": 1,
"description": "",
"titleTemplate": "{first_name",
"fields": [],
"relationships": []
}),
);
let schema = SchemaNode::from_node(node).unwrap();
let result = behavior.validate_schema_node(&schema);
assert!(result.is_err(), "Unclosed brace should fail validation");
assert!(matches!(
result,
Err(NodeValidationError::InvalidProperties(ref msg))
if msg.contains("unclosed")
));
}

#[test]
fn test_title_template_empty_placeholder_rejected() {
use crate::models::SchemaNode;
let behavior = SchemaNodeBehavior;

let node = Node::new(
"schema".to_string(),
"Customer".to_string(),
json!({
"isCore": false,
"schemaVersion": 1,
"description": "",
"titleTemplate": "{} {last_name}",
"fields": [],
"relationships": []
}),
);
let schema = SchemaNode::from_node(node).unwrap();
let result = behavior.validate_schema_node(&schema);
assert!(result.is_err(), "Empty placeholder should fail validation");
assert!(matches!(
result,
Err(NodeValidationError::InvalidProperties(ref msg))
if msg.contains("empty")
));
}

#[test]
fn test_title_template_none_is_valid() {
use crate::models::SchemaNode;
let behavior = SchemaNodeBehavior;

// No title_template field at all — should pass
let node = Node::new(
"schema".to_string(),
"Widget".to_string(),
json!({
"isCore": false,
"schemaVersion": 1,
"description": "",
"fields": [],
"relationships": []
}),
);
let schema = SchemaNode::from_node(node).unwrap();
assert!(
behavior.validate_schema_node(&schema).is_ok(),
"Schema without title_template should pass validation"
);
}

// =========================================================================
// TaskNode from_node Format Tests
// =========================================================================
Expand Down
12 changes: 12 additions & 0 deletions packages/core/src/models/core_schemas.rs
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,7 @@ pub fn get_core_schemas() -> Vec<SchemaNode> {
},
],
relationships: vec![],
title_template: None,
},
// Text schema - plain text content (no extra fields)
SchemaNode {
Expand All @@ -180,6 +181,7 @@ pub fn get_core_schemas() -> Vec<SchemaNode> {
description: "Plain text content".to_string(),
fields: vec![],
relationships: vec![],
title_template: None,
},
// Date schema - daily note containers (no extra fields)
SchemaNode {
Expand All @@ -193,6 +195,7 @@ pub fn get_core_schemas() -> Vec<SchemaNode> {
description: "Date node schema".to_string(),
fields: vec![],
relationships: vec![],
title_template: None,
},
// Header schema - markdown headers (no extra fields)
SchemaNode {
Expand All @@ -206,6 +209,7 @@ pub fn get_core_schemas() -> Vec<SchemaNode> {
description: "Markdown header (h1-h6)".to_string(),
fields: vec![],
relationships: vec![],
title_template: None,
},
// Code block schema - code with syntax highlighting (no extra fields)
SchemaNode {
Expand All @@ -219,6 +223,7 @@ pub fn get_core_schemas() -> Vec<SchemaNode> {
description: "Code block with syntax highlighting".to_string(),
fields: vec![],
relationships: vec![],
title_template: None,
},
// Quote block schema - blockquotes (no extra fields)
SchemaNode {
Expand All @@ -232,6 +237,7 @@ pub fn get_core_schemas() -> Vec<SchemaNode> {
description: "Blockquote for citations".to_string(),
fields: vec![],
relationships: vec![],
title_template: None,
},
// Ordered list schema - numbered list items (no extra fields)
SchemaNode {
Expand All @@ -245,6 +251,7 @@ pub fn get_core_schemas() -> Vec<SchemaNode> {
description: "Numbered list item".to_string(),
fields: vec![],
relationships: vec![],
title_template: None,
},
// Horizontal line schema - thematic break (no extra fields)
SchemaNode {
Expand All @@ -258,6 +265,7 @@ pub fn get_core_schemas() -> Vec<SchemaNode> {
description: "Horizontal rule / thematic break".to_string(),
fields: vec![],
relationships: vec![],
title_template: None,
},
// Table schema - GFM markdown table (no extra fields)
SchemaNode {
Expand All @@ -271,6 +279,7 @@ pub fn get_core_schemas() -> Vec<SchemaNode> {
description: "GFM markdown table with alignment support".to_string(),
fields: vec![],
relationships: vec![],
title_template: None,
},
// Collection schema - hierarchical labels for organizing nodes
SchemaNode {
Expand All @@ -284,6 +293,7 @@ pub fn get_core_schemas() -> Vec<SchemaNode> {
description: "Hierarchical label for organizing nodes into groups".to_string(),
fields: vec![], // Uses content for name
relationships: vec![], // member_of is a native edge, not schema-defined
title_template: None,
},
// Checkbox schema - pure content node with state encoded in content string
SchemaNode {
Expand All @@ -297,6 +307,7 @@ pub fn get_core_schemas() -> Vec<SchemaNode> {
description: "Checkbox item — markdown annotation, not a managed task".to_string(),
fields: vec![],
relationships: vec![],
title_template: None,
},
// Query schema - saved query definitions
SchemaNode {
Expand Down Expand Up @@ -440,6 +451,7 @@ pub fn get_core_schemas() -> Vec<SchemaNode> {
},
],
relationships: vec![],
title_template: None,
},
]
}
Expand Down
21 changes: 20 additions & 1 deletion packages/core/src/models/schema_node.rs
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,14 @@ pub struct SchemaNode {
/// created when the schema is saved. See [`SchemaRelationship`] for details.
#[serde(default)]
pub relationships: Vec<SchemaRelationship>,

/// Optional template for computing the node's indexed title from its properties.
///
/// Uses `{field_name}` syntax, e.g. `"{first_name} {last_name} ({email})"`.
/// When set, title is interpolated from node properties instead of content.
/// Missing or null fields are replaced with empty strings.
#[serde(skip_serializing_if = "Option::is_none")]
pub title_template: Option<String>,
}

fn default_version() -> i64 {
Expand Down Expand Up @@ -156,6 +164,12 @@ impl SchemaNode {
.and_then(|v| serde_json::from_value(v.clone()).ok())
.unwrap_or_default();

let title_template = node
.properties
.get("titleTemplate")
.and_then(|v| v.as_str())
.map(|s| s.to_string());

Ok(Self {
id: node.id,
content: node.content,
Expand All @@ -167,21 +181,26 @@ impl SchemaNode {
description,
fields,
relationships,
title_template,
})
}

/// Convert to universal Node (for compatibility with existing APIs)
///
/// This creates a Node with properties populated from the strongly-typed fields.
pub fn into_node(self) -> Node {
let properties = serde_json::json!({
let mut properties = serde_json::json!({
"isCore": self.is_core,
"schemaVersion": self.schema_version,
"description": self.description,
"fields": self.fields,
"relationships": self.relationships,
});

if let Some(template) = self.title_template {
properties["titleTemplate"] = serde_json::Value::String(template);
}

Node {
id: self.id,
node_type: "schema".to_string(),
Expand Down
Loading