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
64 changes: 59 additions & 5 deletions SCRIPTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -161,10 +161,17 @@ Each entry in `fields` is a map:
show_on_hover: false, // optional — show in hover tooltip (default: false)
options: ["A", "B"], // required for "select" fields
max: 5, // required for "rating" fields
default_value: "Draft", // optional — initial value for new notes (any matching type)
min: 0, // optional — minimum for "number" fields
max: 100, // optional — maximum for "number" fields (also star count for "rating")
pattern: "^[A-Z]+$", // optional — regex for "text"/"email" fields
pattern_message: "...", // optional — custom error shown when pattern fails
validate: |v| (), // optional — return an error string or ()
}
```

> **Note:** `default` is a reserved word in Rhai, so the key is `default_value`.

`can_edit: false` marks a derived/computed field — it can be written by an `on_save` hook
but users cannot change it directly.

Expand All @@ -174,12 +181,12 @@ but users cannot change it directly.

| Type | Storage | Notes |
|---|---|---|
| `"text"` | String | Single-line text input |
| `"text"` | String | Single-line text input. Optional `pattern` for regex validation. |
| `"textarea"` | String | Multi-line text input; auto-rendered as markdown in view mode |
| `"number"` | Float | Numeric input |
| `"number"` | Float | Numeric input. Optional `min` / `max` for range validation. |
| `"boolean"` | Bool | Checkbox |
| `"date"` | String (ISO `YYYY-MM-DD`) or `null` | Date picker |
| `"email"` | String | Email input with mailto link in view mode |
| `"email"` | String | Email input with mailto link in view mode. Optional `pattern` for regex validation. |
| `"select"` | String | Dropdown; requires `options: [...]` |
| `"rating"` | Float | Star rating; requires `max: N` (e.g. `max: 5`) |
| `"note_link"` | String (UUID) or `null` | Link to another note; optional `target_schema` restricts the picker to notes of that schema type |
Expand Down Expand Up @@ -490,8 +497,55 @@ not saved.

## 6. Field validation

Individual fields can declare a `validate` closure that returns an error string (on failure)
or `()` (on success):
### Built-in validation shortcuts

For the most common validations, declarative options avoid the need for a `validate` closure:

| Option | Applies to | Description |
|---|---|---|
| `min: N` | `"number"` fields | Rejects values below `N` |
| `max: N` | `"number"` fields | Rejects values above `N` |
| `pattern: "regex"` | `"text"`, `"email"` fields | Rejects non-empty values that don't match the regex |
| `pattern_message: "..."` | `"text"`, `"email"` fields | Custom error shown when `pattern` fails; defaults to showing the regex |

```rhai
// Range validation — no closure needed
#{ name: "price", type: "number", min: 0, max: 99999 }

// Regex validation with custom error message
#{ name: "sku", type: "text",
pattern: "^[A-Z]{2,4}-\\d{3,6}$",
pattern_message: "SKU must be 2-4 letters, a dash, then 3-6 digits (e.g. AB-123)" }
```

Built-in checks run **before** any `validate` closure. If a built-in check fails, the closure
is skipped. If both are present and the built-in passes, the closure runs next — errors from
either path are shown to the user.

Empty strings are not checked against `pattern` — use `required: true` to enforce non-empty.

> **Note:** For `"rating"` fields, `max` is the star count (e.g. `max: 10` gives a 10-star
> widget), not a validation bound. For `"number"` fields, `max` is a validation bound.

### Default values

Any field can specify a `default_value` that pre-populates the field when a new note is created:

```rhai
#{ name: "status", type: "select", options: ["Draft", "Active"], default_value: "Draft" }
#{ name: "stock", type: "number", default_value: 0, min: 0 }
#{ name: "urgent", type: "boolean", default_value: true }
```

Without `default_value`, fields start at their zero-value (empty string, 0, false, null).

> **Note:** The Rhai key is `default_value` (not `default`) because `default` is a reserved
> word in Rhai.

### Custom validation closures

For validations that can't be expressed with `min`/`max`/`pattern`, declare a `validate`
closure that returns an error string (on failure) or `()` (on success):

```rhai
#{
Expand Down
16 changes: 12 additions & 4 deletions example-scripts/product-inventory/product.schema.rhai
Original file line number Diff line number Diff line change
Expand Up @@ -6,16 +6,24 @@

// @name: Product
// @description: An inventory item with auto-formatted title and stock status.
//
// Demonstrates: default_value, min/max range validation, pattern regex validation.

schema("Product", #{
version: 1,
title_can_edit: false,
fields: [
#{ name: "product_name", type: "text", required: true },
#{ name: "sku", type: "text", required: false },
#{ name: "price", type: "number", required: false },
#{ name: "stock", type: "number", required: false },
#{ name: "category", type: "text", required: false },
#{ name: "sku", type: "text", required: false,
pattern: "^[A-Z]{2,4}-\\d{3,6}$",
pattern_message: "SKU must be 2-4 uppercase letters, a dash, then 3-6 digits (e.g. AB-123)" },
#{ name: "price", type: "number", required: false,
min: 0, max: 999999, default_value: 0 },
#{ name: "stock", type: "number", required: false,
min: 0, default_value: 0 },
#{ name: "category", type: "select", required: false,
options: ["Electronics", "Books", "Clothing", "Food", "Other"],
default_value: "Other" },
#{ name: "description", type: "textarea", required: false },
#{ name: "stock_status", type: "text", required: false, can_edit: false },
],
Expand Down
30 changes: 30 additions & 0 deletions krillnotes-core/src/core/scripting/display_helpers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -961,6 +961,11 @@ mod tests {
show_on_hover: false,
allowed_types: vec![],
validate: None,
default_value: None,
min_value: None,
max_value: None,
pattern: None,
pattern_message: None,
}],
title_can_view: true,
title_can_edit: true,
Expand Down Expand Up @@ -1029,6 +1034,11 @@ mod tests {
show_on_hover: false,
allowed_types: vec![],
validate: None,
default_value: None,
min_value: None,
max_value: None,
pattern: None,
pattern_message: None,
}],
title_can_view: true,
title_can_edit: true,
Expand Down Expand Up @@ -1088,6 +1098,11 @@ mod tests {
show_on_hover: false,
allowed_types: vec![],
validate: None,
default_value: None,
min_value: None,
max_value: None,
pattern: None,
pattern_message: None,
}],
title_can_view: true,
title_can_edit: true,
Expand Down Expand Up @@ -1156,6 +1171,11 @@ mod tests {
show_on_hover: false,
allowed_types: vec![],
validate: None,
default_value: None,
min_value: None,
max_value: None,
pattern: None,
pattern_message: None,
}],
title_can_view: true,
title_can_edit: true,
Expand Down Expand Up @@ -1245,6 +1265,11 @@ mod tests {
show_on_hover: false,
allowed_types: vec![],
validate: None,
default_value: None,
min_value: None,
max_value: None,
pattern: None,
pattern_message: None,
}],
title_can_view: true,
title_can_edit: true,
Expand Down Expand Up @@ -1708,6 +1733,11 @@ mod tests {
show_on_hover: false,
allowed_types: vec![],
validate: None,
default_value: None,
min_value: None,
max_value: None,
pattern: None,
pattern_message: None,
}],
title_can_view: true,
title_can_edit: true,
Expand Down
89 changes: 78 additions & 11 deletions krillnotes-core/src/core/scripting/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -574,20 +574,18 @@ impl ScriptRegistry {
self.schema_registry.exists(name)
}

/// Runs the `validate` closure for a single field, if one is registered.
/// Runs built-in validation (min/max/pattern) and the `validate` closure
/// for a single field, if applicable.
///
/// Returns `Ok(None)` when the field is valid or has no validate closure.
/// Returns `Ok(Some(msg))` when the closure returns an error message.
/// Returns `Ok(None)` when the field is valid or has no validation rules.
/// Returns `Ok(Some(msg))` when validation fails.
pub fn validate_field(
&self,
schema_name: &str,
field_name: &str,
value: &crate::core::note::FieldValue,
) -> crate::Result<Option<String>> {
let schema = self.schema_registry.get(schema_name)?;
let Some(ast) = schema.ast.as_ref() else {
return Ok(None);
};

let field = schema
.all_fields()
Expand All @@ -596,6 +594,16 @@ impl ScriptRegistry {
let Some(field_def) = field else {
return Ok(None);
};

// Built-in validations (min/max/pattern)
if let Some(msg) = Self::run_builtin_validation(field_def, value) {
return Ok(Some(msg));
}

// Closure-based validation
let Some(ast) = schema.ast.as_ref() else {
return Ok(None);
};
let Some(fn_ptr) = field_def.validate.as_ref() else {
return Ok(None);
};
Expand All @@ -617,7 +625,8 @@ impl ScriptRegistry {
}
}

/// Runs `validate` closures for all fields that have them and have a value.
/// Runs built-in validation (min/max/pattern) and `validate` closures for
/// all fields that have them and have a value.
///
/// Returns a map of `field_name → error_message` for each invalid field.
pub fn validate_fields(
Expand All @@ -627,15 +636,23 @@ impl ScriptRegistry {
) -> crate::Result<std::collections::BTreeMap<String, String>> {
let mut errors = std::collections::BTreeMap::new();
let schema = self.schema_registry.get(schema_name)?;
let Some(ast) = schema.ast.as_ref() else {
return Ok(errors);
};

for field_def in schema.all_fields() {
let Some(value) = fields.get(&field_def.name) else {
continue;
};

// Built-in validations first
if let Some(msg) = Self::run_builtin_validation(field_def, value) {
errors.insert(field_def.name.clone(), msg);
continue; // skip closure if built-in fails
}

// Then closure validation
let Some(fn_ptr) = field_def.validate.as_ref() else {
continue;
};
let Some(value) = fields.get(&field_def.name) else {
let Some(ast) = schema.ast.as_ref() else {
continue;
};

Expand All @@ -656,6 +673,56 @@ impl ScriptRegistry {
Ok(errors)
}

/// Runs built-in validation rules (min/max for numbers, pattern for
/// text/email) on a single field value.
///
/// Returns `Some(error_message)` if validation fails, `None` if it passes.
fn run_builtin_validation(
field_def: &schema::FieldDefinition,
value: &crate::core::note::FieldValue,
) -> Option<String> {
use crate::core::note::FieldValue;

// min/max for number fields
if let FieldValue::Number(n) = value {
if let Some(min) = field_def.min_value {
if *n < min {
return Some(format!("Value must be at least {min}"));
}
}
if let Some(max) = field_def.max_value {
if *n > max {
return Some(format!("Value must be at most {max}"));
}
}
}

// pattern for text and email fields
if let Some(ref pat) = field_def.pattern {
let text = match value {
FieldValue::Text(s) | FieldValue::Email(s) => s.as_str(),
_ => return None,
};
// Only validate non-empty strings (empty is handled by `required`)
if !text.is_empty() {
match regex::Regex::new(pat) {
Ok(re) => {
if !re.is_match(text) {
return Some(field_def.pattern_message.clone().unwrap_or_else(|| {
format!("Value does not match pattern: {pat}")
}));
}
}
Err(e) => {
return Some(format!("Invalid pattern '{pat}': {e}"));
}
}
}
}

None
}

/// Evaluates the `visible` closure for each `FieldGroup`.
///
/// Returns a map of `group_name → bool`.
Expand Down
Loading
Loading