Scripting Guide

Learn how to define custom note types, validation, views, and migrations with Rhai scripts.

Scripts in Krillnotes are written in Rhai, a small, fast scripting language embedded in the application. Each script defines schemas (note types) and/or presentation logic (views, hover tooltips, context-menu actions).

User scripts are managed through View → Scripts. The bundled system scripts (TextNote, Contact, Task, Project, etc.) are always available and serve as working examples.


Table of Contents

  1. Script structure
  2. Defining schemas
  3. Field types
  4. Schema options
  5. on_save hook
  6. Field validation
  7. Field groups
  8. register_view
  9. register_hover
  10. on_add_child hook
  11. register_menu
  12. Schema versioning and migrations
  13. Display helpers
  14. Query functions
  15. Utility functions
  16. Introspection functions
  17. Tips and patterns
  18. Built-in script examples

1. Script structure

Scripts are divided into two categories:

CategoryFile extensionAllowed top-level calls
Schema.schema.rhaischema() and optionally register_view/hover/menu()
Library/Presentation.rhairegister_view(), register_hover(), register_menu(), helper functions — not schema()

Calling schema() from a .rhai (presentation) script is a hard error. Scripts in the Script Manager carry a category setting — “Schema” or “Library” — chosen when the script is created.

Loading order

When a workspace opens, scripts run in four phases:

  1. Phase A — Presentation (.rhai scripts by load_order): define helper functions and queue deferred register_* calls.
  2. Phase B — Schema (.schema.rhai scripts by load_order): call schema() to register note types.
  3. Phase C — Resolve bindings: match deferred register_* calls to registered schemas. Unresolved entries show a warning badge in the Script Manager.
  4. Phase D — Migrations: for each schema, find notes with schema_version < current version, run migrate closures, and write back in one transaction per type.

Library-first ordering (Phase A before B) means helper functions defined in .rhai files are available when schema on_save hooks run.

Minimal examples

Schema script (MyType.schema.rhai):

// @name: MyType
// @description: My custom note type

schema("MyType", #{
    version: 1,
    fields: [
        #{ name: "body", type: "textarea", required: false },
    ],
    on_save: |note| {
        commit();
    }
});

Presentation script (MyType.rhai):

// @name: MyType Views
// @description: Views and actions for MyType

register_view("MyType", "Overview", |note| {
    text(note.fields["body"] ?? "")
});

A script can contain any number of schema() or register_*() calls, provided it follows the category rule. Keep related types together in a single file.


2. Defining schemas

schema("TypeName", #{
    // --- required ---
    version: 1,

    // --- optional schema-level options ---
    title_can_view:         true,          // default: true
    title_can_edit:         true,          // default: true
    children_sort:          "asc",         // "asc" | "desc" | "none" (default)
    allowed_parent_types:   ["Folder"],    // default: [] (any parent allowed)
    allowed_children_types: ["Item"],      // default: [] (any child allowed)

    // --- required ---
    fields: [
        #{ name: "field_name", type: "text", required: true },
        // ... more fields ...
    ],

    // --- optional field groups ---
    field_groups: [
        #{ name: "Section title", fields: ["field_name"], visible: |note| true },
    ],

    // --- optional migrations ---
    migrate: #{
        // 2: |note| { ... }
    },

    // --- optional hooks ---
    on_save:      |note| { /* ... */ commit() },
    on_add_child: |parent_note, child_note| { /* ... */ commit() },
});

The version key is required. Omitting it causes the script to fail loading with an error.

View rendering, hover tooltips, and context-menu actions are not defined inside schema(). Use register_view(), register_hover(), and register_menu() in a presentation script instead.

Schema name uniqueness

Schema names must be unique across all scripts. If two scripts register the same name the first to load wins (scripts run in ascending load_order). The second script fails to load and an error is shown in the Script Manager.

Field definition

Each entry in fields is a map:

#{
    name:          "my_field",   // required — snake_case string
    type:          "text",       // required — see Field types below
    required:      false,        // optional — default: false
    can_view:      true,         // optional — show in view mode (default: true)
    can_edit:      true,         // optional — show in edit mode (default: true)
    show_on_hover: false,        // optional — show in hover tooltip (default: false)
    options:       ["A", "B"],   // required for "select" fields
    max:           5,            // required for "rating" fields
    validate:      |v| (),       // optional — return an error string or ()
}

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


3. Field types

TypeStorageNotes
"text"StringSingle-line text input
"textarea"StringMulti-line text input; auto-rendered as markdown in view mode
"number"FloatNumeric input
"boolean"BoolCheckbox
"date"String (ISO YYYY-MM-DD) or nullDate picker
"email"StringEmail input with mailto link in view mode
"select"StringDropdown; requires options: [...]
"rating"FloatStar rating; requires max: N (e.g. max: 5)
"note_link"String (UUID) or nullLink to another note; optional target_type restricts the picker to notes of that schema type
"file"String (UUID) or nullAttachment reference; optional allowed_types restricts the file picker to specific MIME types. In view mode images render as a thumbnail; other files show a paperclip icon and filename.

Reading field values in hooks

Inside a hook, fields are accessed via note.fields["field_name"] or note.fields.field_name. The bracket syntax is safer when the field might not exist:

let val = note.fields["notes"] ?? "";   // returns "" if the field is absent

Dates arrive as a string "YYYY-MM-DD" when set, or as the unit value () when empty:

let d = note.fields["due_date"];
if type_of(d) == "string" && d != "" {
    // safe to use d as a string
}

note_link fields arrive as a UUID string when set, or () when empty:

let linked_id = note.fields["linked_project"];
if linked_id != () {
    let target = get_note(linked_id);
    if target != () {
        field("Project", link_to(target))
    }
}
OptionTypeDescription
target_typeString (optional)If set, the note-picker in edit mode only shows notes of this schema type.

file field options

OptionTypeDescription
allowed_typesArray of strings (optional)MIME type filters for the file picker (e.g. ["image/*", "application/pdf"]).

Inline images in textarea markdown

textarea fields rendered as markdown support an inline image block syntax:

{{image: field:cover, width: 400, alt: My caption}}
{{image: attach:photo.png}}

The field: prefix reads the UUID from a file field. The attach: prefix finds an attachment by filename. width and alt are optional.


4. Schema options

version: N (required)

Declares the current data contract version. Must be an integer ≥ 1. All notes created or saved with this schema will have their schema_version stamped with this value.

See Schema versioning and migrations for details.

title_can_edit: false

Hides the title input in edit mode. Use this when the title is always derived by an on_save hook (e.g. Contacts: "Smith, Jane").

title_can_view: false

Hides the title entirely in view mode. Rarely needed.

children_sort: "asc" | "desc"

Automatically sorts child notes alphabetically by title when displayed in the tree. Default is "none" (manual/insertion order).

allowed_parent_types: [...]

Restricts which note types this type may be placed under. An empty array means no restriction.

allowed_parent_types: ["ContactsFolder"],

allowed_children_types: [...]

Restricts which note types may be placed inside this type.

allowed_children_types: ["Contact"],

Validation order: allowed_parent_types and allowed_children_types are always checked before any hook runs. If validation fails the operation is aborted and no hook fires.

field_groups: [...]

See Field groups.

migrate: #{ N: |note| { ... } }

See Schema versioning and migrations.


5. on_save hook

The on_save hook runs every time a note is saved. It is defined as a key inside schema(). Rather than mutating the note directly and returning it, the hook uses a transactional API: call set_field() and set_title() to queue writes, optionally call reject() to signal errors, then call commit() to apply everything atomically.

schema("TypeName", #{
    version: 1,
    fields: [ /* ... */ ],
    on_save: |note| {
        // Read fields directly from note.fields (read-only access)
        let name = note.fields["name"] ?? "";

        // Queue writes
        set_title(note.id, name);
        set_field(note.id, "summary", "Hello, " + name);

        // Apply all queued writes
        commit();
    }
});

SaveTransaction functions

FunctionDescription
set_field(note_id, field_name, value)Queues a field write. Runs the field’s validate closure immediately (hard error on failure). Read-your-writes: note.fields is updated in place.
set_title(note_id, title)Queues a title write. Updates note.title in place.
reject(message)Records a note-level error. Does not abort immediately — use commit() to trigger the abort.
reject(field_name, message)Records a field-pinned error shown below the named field.
commit()Runs required-field checks on all visible fields. If any reject() calls were made, aborts the save and surfaces all errors. Otherwise applies all queued writes atomically. Always call commit() at the end of on_save.

The hook receives the note as a map for field reading only. All writes must go through set_field or set_title. Both functions provide read-your-writes semantics — calling set_field then reading note.fields["that_field"] gives back the queued value.

The note map inside on_save

KeyTypeNotes
note.idString
note.node_typeString
note.titleStringUpdated by set_title() (read-your-writes)
note.fieldsMapUpdated by set_field() (read-your-writes)
note.tagsArray of stringsRead-only

Example — derived title

schema("Book", #{
    version: 1,
    fields: [
        #{ name: "book_title", type: "text", required: true },
        #{ name: "author",     type: "text", required: false },
    ],
    on_save: |note| {
        let title  = note.fields["book_title"] ?? "";
        let author = note.fields["author"] ?? "";
        let derived = if author != "" && title != "" { author + ": " + title }
                      else if title != "" { title }
                      else { "Untitled Book" };
        set_title(note.id, derived);
        commit();
    }
});

Example — status badge

schema("Task", #{
    version: 1,
    fields: [
        #{ name: "name",   type: "text",   required: true },
        #{ name: "status", type: "select", required: true,
           options: ["TODO", "WIP", "DONE"] },
    ],
    title_can_edit: false,
    on_save: |note| {
        let name   = note.fields["name"] ?? "";
        let status = note.fields["status"] ?? "";
        let symbol = if status == "DONE" { "✓" }
                     else if status == "WIP" { "→" }
                     else { " " };
        set_title(note.id, "[" + symbol + "] " + name);
        commit();
    }
});

Example — reject on invalid input

schema("Invoice", #{
    version: 1,
    fields: [
        #{ name: "amount", type: "number", required: true },
    ],
    on_save: |note| {
        if (note.fields["amount"] ?? 0.0) <= 0.0 {
            reject("amount", "Amount must be greater than zero");
        }
        commit();
    }
});

If reject() is called, commit() aborts and the error is shown to the user. The note is not saved.


6. Field validation

Individual fields can declare a validate closure that returns an error string (on failure) or () (on success):

#{
    name: "email", type: "email", required: false,
    validate: |v| {
        if v == () || v == "" { return (); }  // empty is OK; required: true handles must-have
        if v.contains("@") { () }
        else { "Must be a valid email address" }
    }
}

Validation runs:

  • On blur in the frontend — the error appears inline below the field.
  • Inside set_field() — a failed validate closure is a hard error that aborts on_save immediately (before commit() runs).

The closure receives the raw field value — a string, number, boolean, or () (empty). Always guard against () before type-specific operations unless the field is required: true.


7. Field groups

Field groups visually organise related fields under collapsible sections in the edit panel. Define them via the field_groups key inside schema():

schema("Project", #{
    version: 1,
    fields: [
        #{ name: "name",         type: "text",     required: true },
        #{ name: "status",       type: "select",   required: true,
           options: ["Active", "On Hold", "Done"] },
        #{ name: "completed_at", type: "date",     required: false },
        #{ name: "notes",        type: "textarea", required: false },
    ],
    field_groups: [
        #{
            name:    "Completion details",
            fields:  ["completed_at", "notes"],
            visible: |note| note.fields["status"] == "Done",
        },
    ],
    on_save: |note| { commit(); }
});

Group definition

KeyTypeRequiredDescription
nameStringYesHeader label shown above the group
fieldsArray of stringsYesField names to include in this group
visibleClosure |note| → boolNoReturns false to hide the entire group

Fields not listed in any group are shown ungrouped at the top of the edit panel.

The visible closure receives the current note map and is re-evaluated on every field value change in the frontend, so groups can appear and disappear interactively.


8. register_view

register_view registers a named view tab for a note type. Call it from a presentation script (.rhai). The view renders when the user selects that tab in the detail panel.

// Simple form
register_view("TypeName", "Tab Label", |note| {
    text("Custom view for " + note.title)
});

// With options
register_view("TypeName", "Tab Label", #{ display_first: true }, |note| {
    stack([
        heading(note.title),
        text(note.fields["body"] ?? "")
    ])
});

Parameters

ParameterTypeDescription
typeStringThe schema name to bind this view to
labelStringTab label shown in the UI
optionsMap (optional)#{ display_first: true } pushes the tab to the leftmost position
closure|note| → StringReturns HTML built with display helpers

Tab layout

[ display_first views ] [ other views in order ] [ Fields ]
  • No registered views — no tab bar is shown; the detail panel renders as a plain field grid.
  • Fields tab — always present, always rightmost.
  • Edit mode — clicking “Edit” switches to the Fields tab. Saving or cancelling returns to the previously active tab.

The closure has access to all query functions and display helpers.

Example — folder contact table

register_view("ContactsFolder", "Contacts", #{ display_first: true }, |note| {
    let contacts = get_children(note.id);
    if contacts.len() == 0 {
        return text("No contacts yet. Add one via the context menu.");
    }
    let rows = contacts.map(|c| [
        link_to(c),
        c.fields["email"]  ?? "-",
        c.fields["phone"]  ?? "-",
    ]);
    let notes_val = note.fields["notes"] ?? "";
    let contacts_section = section(
        "Contacts (" + contacts.len() + ")",
        table(["Name", "Email", "Phone"], rows)
    );
    if notes_val == "" { contacts_section }
    else { stack([contacts_section, section("Notes", text(notes_val))]) }
});

Unresolved bindings

If register_view references a type name that no script has registered, the binding is marked unresolved and a warning badge appears next to the script in the Script Manager.


9. register_hover

register_hover registers a hover tooltip renderer for a note type. Call it from a presentation script. One registration per type — last registration wins.

register_hover("TypeName", |note| {
    field("Status", note.fields["status"] ?? "-")
});

Parameters

ParameterTypeDescription
typeStringThe schema name
closure|note| → StringReturns HTML shown in the tooltip

The tooltip appears after ~600 ms of hover. Keep output brief — the tooltip has a fixed max width and is not scrollable.

Simple path — show_on_hover: true

For a quick single-field preview, mark the field with show_on_hover: true and skip the hook entirely. No IPC round-trip is needed — the value is already in the frontend.

schema("Note", #{
    version: 1,
    fields: [
        #{ name: "body", type: "textarea", required: false, show_on_hover: true },
    ],
    on_save: |note| { commit(); }
});

Multiple show_on_hover fields are all shown in definition order.

Priority: A register_hover closure always takes precedence over show_on_hover flags. The flags are only used when no hover registration exists for the type.


10. on_add_child hook

The on_add_child hook runs whenever a note is created as a child — or moved via drag-and-drop — under a note whose schema defines the hook. Both the parent and the child are pre-seeded into the current SaveTransaction.

schema("TypeName", #{
    version: 1,
    fields: [ /* ... */ ],
    on_add_child: |parent_note, child_note| {
        // Modify parent and/or child via the SaveTransaction API
        set_field(parent_note.id, "child_count",
                  (parent_note.fields["child_count"] ?? 0.0) + 1.0);
        commit();
    }
});

Use set_field, set_title, and reject/commit() just like in on_save. Both parent_note and child_note are available by ID.

When it fires

OperationFires?
Note created as a childYes
Note moved under a new parent (drag-and-drop)Yes
Note created at root level (no parent)No

allowed_parent_types and allowed_children_types checks always run before the hook. If either check fails, the operation is aborted and the hook never runs.

Example — child count in parent title

schema("ContactsFolder", #{
    version: 1,
    fields: [
        #{ name: "child_count", type: "number", can_view: true, can_edit: false },
    ],
    on_add_child: |parent_note, child_note| {
        let count = (parent_note.fields["child_count"] ?? 0.0) + 1.0;
        set_field(parent_note.id, "child_count", count);
        set_title(parent_note.id, "Contacts (" + count.to_int().to_string() + ")");
        commit();
    }
});

Note: this count only increases on add. It does not decrease when notes are deleted or moved away. For a live accurate count use register_view with get_children() instead.


11. register_menu

register_menu registers a custom entry in the tree’s right-click context menu. Call it from a presentation script (.rhai).

register_menu(label, target_types, callback)
ParameterTypeDescription
labelStringMenu item text shown to the user
target_typesArray of StringsSchema names for which the item appears
callbackClosure |note| { ... }Called when the user clicks the item

The note argument has the same shape as in on_save. The closure can:

  • Use query functions to read workspace state.
  • Use SaveTransaction functions (set_field, set_title, create_child, commit) to write.
  • Return an array of note ID strings to reorder child notes.
register_menu("Sort Children A→Z", ["Folder"], |note| {
    let children = get_children(note.id);
    children.sort_by(|a, b| a.title <= b.title);
    children.map(|c| c.id)
});

Mutating notes from a menu action

Use create_child(parent_id, type) to create new notes and set_field/set_title to modify them, then call commit():

register_menu("Create Sprint Template", ["TextNote"], |container| {
    let sprint = create_child(container.id, "TextNote");
    set_title(sprint.id, "Sprint 1");
    set_field(sprint.id, "body", "Sprint goals: TBD");

    let t1 = create_child(sprint.id, "Task");
    set_title(t1.id, "[ ] Define scope");
    set_field(t1.id, "name", "Define scope");
    set_field(t1.id, "status", "TODO");

    commit();
});

on_save is not invoked for notes created via create_child. Schemas that derive their title from fields (such as Task) require the title to be set manually.

create_child is only available in register_menu closures and on_add_child hooks. It is not available in on_save or view/hover closures.


12. Schema versioning and migrations

The version key in schema() declares the current data contract version. When you change a schema’s fields in a breaking way (renaming, splitting, or removing a field), bump the version and add a migrate closure so existing notes are updated automatically.

schema("Contact", #{
    version: 2,
    fields: [
        // "phone" renamed to "mobile" in v2
        #{ name: "first_name", type: "text", required: true },
        #{ name: "last_name",  type: "text", required: true },
        #{ name: "mobile",     type: "text", required: false },
    ],
    migrate: #{
        2: |note| {
            note.fields["mobile"] = note.fields["phone"];
            note.fields.remove("phone");
        }
    },
    on_save: |note| {
        set_title(note.id,
            (note.fields["last_name"] ?? "") + ", " + (note.fields["first_name"] ?? ""));
        commit();
    }
});

How it works

When the workspace opens, Phase D runs after all scripts load:

  1. For each registered schema, find all notes with schema_version < current version.
  2. Chain migration closures in order (e.g. a note at v1 with a v3 schema runs the v2 closure then the v3 closure).
  3. Write updated title, fields, and schema_version back in a single transaction per schema type.
  4. Log one UpdateSchema operation recording how many notes were migrated.
  5. A toast notification appears: “Contact schema updated — 12 notes migrated to version 3”.

Migration closure contract

migrate: #{
    2: |note| {
        // note.title — readable and writable
        // note.fields — mutable map of field values
        note.fields["mobile"] = note.fields["phone"];
        note.fields.remove("phone");
        // no return value; do NOT call set_field() or commit()
    }
}

The closure receives a map with title (String) and fields (Map). Mutate in place. Do not call set_field() or commit() — migrations bypass the gated pipeline.

Multi-version jump

schema("Contact", #{
    version: 3,
    fields: [ /* ... */ ],
    migrate: #{
        2: |note| {
            // v1 → v2: rename phone to mobile
            note.fields["mobile"] = note.fields["phone"];
            note.fields.remove("phone");
        },
        3: |note| {
            // v2 → v3: split name into first_name + last_name
            let parts = note.fields["name"].split(" ");
            note.fields["first_name"] = parts[0];
            note.fields["last_name"] = if parts.len() > 1 { parts[1] } else { "" };
            note.fields.remove("name");
        }
    },
    on_save: |note| { /* ... */ commit(); }
});

A note at v1 runs closures 2 then 3. A note at v2 runs only closure 3.

Rules

ConditionBehaviour
version omittedHard error at load time — script fails to register
New version < registered versionHard error — downgrade not allowed
New version == registered versionAllowed — hooks/fields can be updated freely
New version > registered versionAllowed — Phase D migration runs on next open
Migration closure failsEntire batch for that schema type rolls back; error shown in Script Manager

When to bump the version

Only bump when the stored data shape changes in a way old data cannot satisfy the new schema. Examples: renaming a field, splitting one field into two, changing a field’s type. Do not bump for: adding a new optional field, changing on_save logic, updating on_add_child, or modifying view/hover/menu registrations.


13. Display helpers

All helpers return an HTML string. All user-supplied text is HTML-escaped automatically. They are available in register_view, register_hover, and register_menu closures.

text(content)

Whitespace-preserving paragraph.

text("Line one\nLine two")

markdown(text)

Renders a string as CommonMark markdown and returns the resulting HTML.

markdown(note.fields["notes"] ?? "")

In the default view (no registered view) textarea fields are already auto-rendered as markdown. Use markdown() explicitly in register_view closures when you want markdown alongside other helpers.

Inline image blocks in markdown

{{image: field:cover, width: 400, alt: My caption}}
{{image: attach:photo.png}}
ParameterRequiredDescription
first positionalYesfield:fieldName reads the UUID from a file field; attach:filename finds by filename
widthNoPixel width. Omit to use natural width.
altNoAlt text for accessibility.

heading(text)

A bold section heading.

heading("Project Details")

field(label, value)

A single key-value row with a muted label.

field("Email", note.fields["email"] ?? "-")

fields(note)

Renders all fields in the note as key-value rows, skipping empty values. Field key names are humanised ("first_name""First Name").

fields(note)

table(headers, rows)

A table with a header row. headers is an array of strings; rows is an array of arrays.

let rows = contacts.map(|c| [c.title, c.fields["email"] ?? "-"]);
table(["Name", "Email"], rows)

section(title, content)

Wraps content in a titled container with an uppercase small-caps label above.

section("Notes", text(note.fields["notes"] ?? ""))

stack(items)

Lays items out vertically with consistent spacing.

stack([
    section("Overview", fields(note)),
    divider(),
    section("Tasks", list(tasks.map(|t| t.title)))
])

columns(items)

Lays items out as equal-width columns side by side.

columns([
    section("Left", text("...")),
    section("Right", text("..."))
])

list(items)

A bullet list. Items are strings.

list(tasks.map(|t| t.title))

badge(text) / badge(text, color)

A pill badge. Supported colors: "red", "green", "blue", "yellow", "gray", "orange", "purple".

badge("Active")
badge("High", "red")
badge("Done", "green")

render_tags(tags)

Renders an array of tag strings as coloured pill badges.

render_tags(note.tags)

stars(value) / stars(value, max)

Renders a numeric rating as filled (★) and empty (☆) star characters. Default scale is 5. Returns "—" for a zero or negative value.

stars(note.fields["rating"] ?? 0)        // e.g. "★★★☆☆" for 3 out of 5
stars(note.fields["score"] ?? 0, 10)     // out of 10

display_image(uuid, width, alt)

Embeds an attached image inline. The image is base64-encoded server-side and renders synchronously.

display_image(note.fields["cover"], 400, "Cover image")

display_download_link(uuid, label)

Renders a clickable download link for an attachment.

display_download_link(note.fields["document"], "Download PDF")

divider()

A horizontal rule.

divider()

Renders a clickable link that navigates to another note. Pushes the originating note onto the back-navigation stack.

let target = get_note(some_id);
if target != () { link_to(target) }

14. Query functions

Query functions are available inside register_view, register_hover, and register_menu closures. They let you fetch related notes from the workspace without leaving the scripting layer.

get_children(note_id)

Returns an array of direct child notes for the given ID.

let items = get_children(note.id);

get_note(note_id)

Returns a single note by ID, or () if not found.

let parent = get_note(note.parent_id);
if parent != () {
    field("Parent", parent.title)
}

get_notes_of_type(type_name)

Returns all notes in the workspace that match the given schema type.

let all_tasks = get_notes_of_type("Task");
let open = all_tasks.filter(|t| t.fields["status"] != "DONE");

get_notes_for_tag(tags)

Returns all notes that carry any of the given tags (OR semantics). Duplicates removed.

// surface related notes in a view:
let related = get_notes_for_tag(note.tags).filter(|n| n.id != note.id);

Available in register_view and register_menu closures. Not available in on_save or on_add_child.

get_notes_with_link(note_id)

Returns all notes that have any note_link field pointing to the given note ID. Useful for displaying backlinks.

let tasks = get_notes_with_link(note.id);
section("Linked Tasks", table(["Task"], tasks.map(|t| [link_to(t)])))

Available in register_view and register_menu closures. Not available in on_save or on_add_child.

get_attachments(note_id)

Returns an array of attachment metadata maps for the given note ID.

let files = get_attachments(note.id);

Each entry:

KeyTypeDescription
idString (UUID)Attachment ID
filenameStringOriginal filename
mime_typeStringMIME type
size_bytesIntegerFile size in bytes

Available in register_view, register_hover, and register_menu closures.

Note map shape

Each note returned by query functions:

KeyType
note.idString
note.node_typeString
note.titleString
note.fieldsMap of field values
note.tagsArray of strings

15. Utility functions

today()

Returns today’s date as a "YYYY-MM-DD" string.

schema("Journal", #{
    version: 1,
    fields: [
        #{ name: "body", type: "textarea", required: false },
    ],
    on_save: |note| {
        let body  = note.fields["body"] ?? "";
        let first = body.split("\n")[0];
        set_title(note.id, today() + " — " + first);
        commit();
    }
});

16. Introspection functions

schema_exists(name)

Returns true if a schema with the given name is currently registered.

if schema_exists("Project") {
    // safe to reference Project notes
}

get_schema_fields(name)

Returns an array of field-definition maps for the named schema.

let defs = get_schema_fields("Task");
// defs[0].name, defs[0].type, defs[0].required, defs[0].can_view, defs[0].can_edit

17. Tips and patterns

Null-coalescing with ??

Field values may be absent when a note was created before the field was added to the schema. Use ?? to provide a fallback:

let phone = note.fields["phone"] ?? "-";

Conditional sections

let notes_val = note.fields["notes"] ?? "";
if notes_val == "" {
    contacts_section
} else {
    stack([contacts_section, section("Notes", text(notes_val))])
}

Conditional badges based on a field value

let status = note.fields["status"] ?? "";
let color  = if status == "DONE"    { "green" }
             else if status == "WIP" { "blue" }
             else                    { "gray" };
badge(status, color)

Date arithmetic

Date fields are ISO strings ("YYYY-MM-DD") when set. For simple day-difference calculations:

let s_parts = started.split("-");
let f_parts = finished.split("-");
let s_days  = parse_int(s_parts[0]) * 365 + parse_int(s_parts[1]) * 30 + parse_int(s_parts[2]);
let f_days  = parse_int(f_parts[0]) * 365 + parse_int(f_parts[1]) * 30 + parse_int(f_parts[2]);
let diff    = f_days - s_days;
if diff > 0 { diff.to_string() + " days" } else { "" }

This is an approximation suitable for display (not calendar-accurate).

Checking date field presence

Date fields are () (unit) when not set, not an empty string. Always check the type:

let d = note.fields["due_date"];
let label = if type_of(d) == "string" && d != "" { d } else { "Not set" };

title_can_edit: false + on_save title derivation

schema("Contact", #{
    version: 1,
    title_can_edit: false,
    fields: [ /* ... */ ],
    on_save: |note| {
        let last  = note.fields["last_name"]  ?? "";
        let first = note.fields["first_name"] ?? "";
        set_title(note.id, last + ", " + first);
        commit();
    }
});

Folder / item pair

schema("ProjectFolder", #{
    version: 1,
    allowed_children_types: ["Project"],
    fields: [],
    on_save: |note| { commit(); }
});

schema("Project", #{
    version: 1,
    allowed_parent_types: ["ProjectFolder"],
    fields: [ /* ... */ ],
    on_save: |note| { commit(); }
});

Avoiding accidental schema collisions

Schema names are checked for uniqueness across scripts at load time. The safest rule: one schema per script that defines it. Do not copy schema() blocks between scripts.


18. Built-in script examples

The following scripts ship with Krillnotes and can be studied as complete examples.

TextNote — minimal schema, no hooks

00_text_note.schema.rhai:

schema("TextNote", #{
    version: 1,
    fields: [
        #{ name: "body", type: "textarea", required: false },
    ],
    on_save: |note| { commit(); }
});

Task — derived title, status symbol

02_task.schema.rhai:

schema("Task", #{
    version: 1,
    title_can_edit: false,
    fields: [
        #{ name: "name",           type: "text",     required: true  },
        #{ name: "status",         type: "select",   required: true,
           options: ["TODO", "WIP", "DONE"]                           },
        #{ name: "priority",       type: "select",   required: false,
           options: ["low", "medium", "high"]                         },
        #{ name: "due_date",       type: "date",     required: false  },
        #{ name: "assignee",       type: "text",     required: false  },
        #{ name: "notes",          type: "textarea", required: false  },
        #{ name: "priority_label", type: "text",     required: false, can_edit: false },
    ],
    on_save: |note| {
        let name   = note.fields["name"] ?? "";
        let status = note.fields["status"] ?? "";
        let symbol = if status == "DONE" { "✓" }
                     else if status == "WIP" { "→" }
                     else { " " };
        set_title(note.id, "[" + symbol + "] " + name);

        let priority = note.fields["priority"] ?? "";
        set_field(note.id, "priority_label",
            if priority == "high"        { "🔴 High" }
            else if priority == "medium" { "🟡 Medium" }
            else if priority == "low"    { "🟢 Low" }
            else                         { "" });
        commit();
    }
});

Contacts — folder + card with custom table view

Two files: the schema definition and a presentation script for the folder view.

01_contact.schema.rhai:

schema("ContactsFolder", #{
    version: 1,
    children_sort: "asc",
    allowed_children_types: ["Contact"],
    fields: [
        #{ name: "notes", type: "textarea", required: false },
    ],
    on_save: |note| { commit(); }
});

schema("Contact", #{
    version: 1,
    title_can_edit: false,
    allowed_parent_types: ["ContactsFolder"],
    fields: [
        #{ name: "first_name", type: "text",    required: true  },
        #{ name: "last_name",  type: "text",    required: true  },
        #{ name: "email",      type: "email",   required: false },
        #{ name: "phone",      type: "text",    required: false },
        #{ name: "mobile",     type: "text",    required: false },
        #{ name: "birthdate",  type: "date",    required: false },
        #{ name: "is_family",  type: "boolean", required: false },
    ],
    on_save: |note| {
        let last  = note.fields["last_name"]  ?? "";
        let first = note.fields["first_name"] ?? "";
        if last != "" || first != "" {
            set_title(note.id, last + ", " + first);
        }
        commit();
    }
});

01_contact.rhai:

register_view("ContactsFolder", "Contacts", #{ display_first: true }, |note| {
    let contacts = get_children(note.id);
    if contacts.len() == 0 {
        return text("No contacts yet. Add a contact using the context menu.");
    }
    let rows = contacts.map(|c| [
        link_to(c),
        c.fields["email"]  ?? "-",
        c.fields["phone"]  ?? "-",
        c.fields["mobile"] ?? "-"
    ]);
    let contacts_section = section(
        "Contacts (" + contacts.len() + ")",
        table(["Name", "Email", "Phone", "Mobile"], rows)
    );
    let notes_val = note.fields["notes"] ?? "";
    if notes_val == "" { contacts_section }
    else { stack([contacts_section, section("Notes", text(notes_val))]) }
});

A two-file template. Zettel notes are auto-titled with today’s date and the first six words of the body. The body field uses show_on_hover: true so a preview appears on hover without a hook. The Kasten folder shows recent notes and a live child count in hover.

zettelkasten.schema.rhai:

schema("Zettel", #{
    version: 1,
    title_can_edit: false,
    allowed_parent_types: ["Kasten"],
    fields: [
        #{ name: "body", type: "textarea", required: false, show_on_hover: true },
    ],
    on_save: |note| {
        let body  = note.fields["body"] ?? "";
        let words = body.split(" ").filter(|w| w != "");
        let take  = if words.len() > 6 { 6 } else { words.len() };
        let snippet = if take == 0 { "Untitled" } else {
            let s = ""; let i = 0;
            while i < take { s += words[i] + " "; i += 1; }
            s = s.trim();
            if words.len() > 6 { s + " …" } else { s }
        };
        set_title(note.id, today() + " — " + snippet);
        commit();
    }
});

schema("Kasten", #{
    version: 1,
    allowed_children_types: ["Zettel"],
    fields: [],
    on_save: |note| { commit(); }
});

zettelkasten.rhai:

fn tag_list(tags) {
    if tags.len() == 0 { return ""; }
    let s = tags[0];
    let i = 1;
    while i < tags.len() { s += ", " + tags[i]; i += 1; }
    s
}

register_view("Zettel", "Content", #{ display_first: true }, |note| {
    let body_block = markdown(note.fields["body"] ?? "");
    let tags = note.tags;
    if tags.len() == 0 { return body_block; }
    let related = get_notes_for_tag(tags).filter(|n| n.id != note.id);
    if related.len() == 0 { return body_block; }
    let rows = related.map(|n| [link_to(n), tag_list(n.tags)]);
    stack([body_block, section("Related Notes", table(["Note", "Tags"], rows))])
});

register_view("Kasten", "Notes", #{ display_first: true }, |note| {
    let zettel = get_children(note.id);
    if zettel.len() == 0 { return text("No notes yet."); }
    zettel.sort_by(|a, b| a.title >= b.title);
    let recent = if zettel.len() > 10 { zettel.extract(0, 10) } else { zettel };
    let rows = recent.map(|z| [link_to(z), tag_list(z.tags)]);
    section("Recent Notes", table(["Note", "Tags"], rows))
});

register_hover("Kasten", |note| {
    let kids = get_children(note.id);
    field("Notes", kids.len().to_string())
});

register_menu("Sort by Date (Newest First)", ["Kasten"], |note| {
    let children = get_children(note.id);
    children.sort_by(|a, b| a.title >= b.title);
    children.map(|c| c.id)
});

register_menu("Sort by Date (Oldest First)", ["Kasten"], |note| {
    let children = get_children(note.id);
    children.sort_by(|a, b| a.title <= b.title);
    children.map(|c| c.id)
});