Compare commits

..

19 commits

Author SHA1 Message Date
7cbce98795
docs/plugins: detail GUI plugin usage; separate server & GUI plugins
Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I2060db637209655390a86facd004bc646a6a6964
2026-03-12 19:41:23 +03:00
0014a1a2a9
chore: fix clippy lints; format
Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: Ib3d98a81c7e41054d27e617394bef63c6a6a6964
2026-03-12 19:41:22 +03:00
e1351e8881
examples/media-stats-ui: fix Transform source key; add file_name column
Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I4c741e4b36708f2078fed8154d7341de6a6a6964
2026-03-12 19:41:21 +03:00
81d1695e11
pinakes-ui: integrate plugin pages into sidebar navigation; sanitize theme-extension CSS eval
Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: Ie87e39c66253a7071f029d52dd5979716a6a6964
2026-03-12 19:41:20 +03:00
63954fdb2f
pinakes-ui: supply local_state to Conditional and Progress; remove last_refresh
Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: Ib513b5846d6c74bfe821da195b7080af6a6a6964
2026-03-12 19:41:19 +03:00
220dfa6506
pinakes-ui: add plugin component stylesheet
Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I05de526f0cea5df269b0fee226ef1edf6a6a6964
2026-03-12 19:41:18 +03:00
7989d4c4dd
pinakes-plugin-api: add reserved-route and required-endpoint validation
Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: Id85a7e729b26af8eb028e19418a5a1706a6a6964
2026-03-12 19:41:17 +03:00
91123fc90e
pinakes-core: use InvalidOperation for nil media_id in upsert_book_metadata
Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I72a80731d926b79660abf20c2c766e8c6a6a6964
2026-03-12 19:41:16 +03:00
185e3b562a
treewide: cleanup
Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: Ia01590cdeed872cc8ebd16f6ca95f3cc6a6a6964
2026-03-12 19:41:15 +03:00
0ba898c881
pinakes-core: check file existence before removal in TempFileGuard drop
Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I800825f5dc3b526d350931ff8f1ed0da6a6a6964
2026-03-12 19:41:14 +03:00
0c9b71346d
pinakes-core: map serde_json errors to Serialization variant in export
Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I77c27639ea1aca03d54702e38fc3ef576a6a6964
2026-03-12 19:41:13 +03:00
15b005cef0
pinakes-core: expose required_endpoints alongside UI pages in plugin manager
Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I32c95a03f106db8fef7eedd0362756a46a6a6964
2026-03-12 19:41:12 +03:00
dc4dc41670
pinakes-plugin-api: consolidate reserved-route check; reject widget data-source refs
Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I042ee31e95822f46520a618de8dcaf786a6a6964
2026-03-12 19:41:11 +03:00
3678edd355
meta: prefer std's OnceLock and LazyLock over once_cell
Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I35d51abfa9a790206391dca891799d956a6a6964
2026-03-12 19:41:10 +03:00
119f6d2e06
examples: add media-stats-ui plugin
Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I7c9ccac175440d278fd129dbd53f04d66a6a6964
2026-03-12 19:41:09 +03:00
cf76d42c33
pinakes-core: add integration tests for batch_update_media
Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I0787bec99f7c1d098c1c1168560a43266a6a6964
2026-03-12 19:41:08 +03:00
592a9bcc47
pinakes-core: add error context to tag and collection writes; map serde_json errors to Serialization variant
pinakes-core: distinguish task panics from cancellations in import error
  handling
Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: Icf5686f34144630ebf1935c47b3979156a6a6964
2026-03-12 19:41:07 +03:00
8f2b44b50c
pinakes-core: unify book metadata extraction; remove ExtractedBookMetadata
Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: Ifd6e66515b9ff78a4bb13eba47b9b2cf6a6a6964
2026-03-12 19:41:06 +03:00
9c67c81a79
pinakes-server: relativize media paths against configured root directories
Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I9f113e6402030c46ad97f636985b5d6c6a6a6964
2026-03-12 19:41:05 +03:00
2 changed files with 475 additions and 62 deletions

View file

@ -21,8 +21,10 @@ use crate::{
/// Check whether a user has access to a playlist.
///
/// * `require_write` when `true` only the playlist owner is allowed (for
/// mutations such as update, delete, add/remove/reorder items). When `false`
/// # Arguments
///
/// * `require_write` - when `true` only the playlist owner is allowed (for
/// mutations such as update, delete, add/remove/reorder items). When `false`
/// the playlist must either be public or owned by the requesting user.
async fn check_playlist_access(
storage: &pinakes_core::storage::DynStorageBackend,

View file

@ -1,17 +1,30 @@
# Plugin System
Pinakes is very powerful on its own, but with a goal as ambitious as "to be the
last media management system you will ever need" I recognize the need for a
plugin system; not everything belongs in the core of Pinakes. Thus, Pinakes
supports WASM-based plugins for extending media type support, metadata
extraction, thumbnail generation, search, event handling, and theming.
Pinakes is first and foremost a _server_ application. This server can be
extended with a plugin system that runs WASM binaries in the server process.
They extend media type detection, metadata extension, thumbnail generation,
search, event handling and theming.
Plugins run in a sandboxed wasmtime runtime with capability-based security, fuel
metering, memory limits, and a circuit breaker for fault isolation.
The first-party GUI for Pinakes, dubbed `pinakes-ui` within this codebase, can
also be extended through a separate plugin system. GUI plugins add pages and
widgets to the desktop/web interface through declarative JSON schemas. No WASM
code runs during rendering.
## How It Works
> [!NOTE]
> While mostly functional, the plugin system is **experimental**. It might
> change at any given time, without notice and without any effort for backwards
> compatibility. Please provide any feedback that you might have!
A plugin is a directory containing:
## Server Plugins
Server plugins run in a sandboxed wasmtime runtime with capability-based
security, fuel metering, memory limits, and a circuit breaker for fault
isolation.
### How It Works
A plugin is a directory containing a WASM binary and a plugin manifest. Usually
in this format:
```plaintext
my-plugin/
@ -19,9 +32,9 @@ my-plugin/
my_plugin.wasm Compiled WASM binary (wasm32-unknown-unknown target)
```
The server discovers plugins in configured directories, validates their
The server discovers plugins from configured directories, validates their
manifests, checks capabilities against the security policy, compiles the WASM
module, and registers the plugin.
module and registers the plugin.
Extension points communicate via JSON-over-WASM. The host writes a JSON request
into the plugin's memory, calls the exported function, and reads the JSON
@ -32,7 +45,7 @@ introduces a little bit of overhead, it's the more debuggable approach and thus
more suitable for the initial plugin system. In the future, this might change.
Plugins go through a priority-ordered pipeline. Each plugin declares a priority
(0999, default 500). Built-in handlers run at implicit priority 100, so plugins
(0-999, default 500). Built-in handlers run at implicit priority 100, so plugins
at priority <100 run _before_ built-ins and plugins at >100 run _after_.
Different extension points use different merge strategies:
@ -49,7 +62,7 @@ Different extension points use different merge strategies:
<!--markdownlint-enable MD013-->
## Plugin Kinds
### Plugin Kinds
A plugin can implement one or more of these roles:
@ -74,7 +87,7 @@ kind = ["media_type", "metadata_extractor", "thumbnail_generator"]
priority = 50
```
## Writing a Plugin
### Writing a Plugin
[dlmalloc]: https://crates.io/crates/dlmalloc
@ -99,6 +112,8 @@ lto = true
Let's go over a minimal example that registers a custom `.mytype` file format:
<!--markdownlint-disable MD013-->
```rust
#![no_std]
@ -156,34 +171,39 @@ pub extern "C" fn can_handle(ptr: i32, len: i32) {
}
```
### Building
<!--markdownlint-enable MD013-->
#### Building
```bash
# Build for the wasm32-unknown-unknown target
$ cargo build --target wasm32-unknown-unknown --release
```
The `RUSTFLAGS=""` override may be needed if your environment sets linker flags
(e.g., `-fuse-ld=lld`) that are incompatible with the WASM target. You can also
specify a compatible linker explicitly. As pinakes uses a `clang` and `lld`
based pipeline, it's necessary to set for, e.g., the test fixtures.
based pipeline, it's necessary to set for, e.g., the test fixtures while
building inside the codebase. In most cases, you will not need it.
The compiled binary will be at
`target/wasm32-unknown-unknown/release/my_plugin.wasm`.
Once the compilation is done, the resulting binary will be in the target
directory. More specifically it will be in
`target/wasm32-unknown-unknown/release` but the path changes based on your
target, and the build mode (dev/release).
### A note on `serde` in `no_std`
> [!NOTE]
> Since `serde_json` requires `std`, you cannot use it in a plugin. Either
> hand-write JSON strings (as shown above) or use a lightweight no_std JSON
> library.
>
> The test fixture plugin in `crates/pinakes-core/tests/fixtures/test-plugin/`
> demonstrates the hand-written approach. It is ugly, but it works, and the
> binaries are tiny (~17KB). You are advised to replace this in your plugins.
Since `serde_json` requires `std`, you cannot use it in a plugin. Either
hand-write JSON strings (as shown above) or use a lightweight no_std JSON
library.
The test fixture plugin in `crates/pinakes-core/tests/fixtures/test-plugin/`
demonstrates the hand-written approach. It is ugly, but it works, and the
binaries are tiny (~17KB).
### Installing
#### Installing
Place the plugin directory in one of the configured plugin directories, or use
the API:
the API to load them while the server is running.
```bash
curl -X POST http://localhost:3000/api/v1/plugins/install \
@ -192,10 +212,10 @@ curl -X POST http://localhost:3000/api/v1/plugins/install \
-d '{"source": "/path/to/my-plugin"}'
```
## Manifest Reference
### Manifest Reference
Plugins are required to provide a manifest with explicit intents for everything.
Here is the expected manifest format as of 0.3.0-dev version of Pinakes:
Here is the expected manifest format as of **0.3.0-dev version** of Pinakes:
```toml
[plugin]
@ -230,7 +250,7 @@ max_memory_mb = 64 # Maximum linear memory (default: 512MB)
max_cpu_time_secs = 5 # Fuel budget per invocation (default: 60s)
```
## Extension Points
### Extension Points
Every plugin must export these three functions:
@ -244,7 +264,7 @@ Beyond those, which functions you export depends on your plugin's kind(s). The
capability enforcer prevents a plugin from being called for functions it hasn't
declared; a `metadata_extractor` plugin cannot have `search` called on it.
### `MediaTypeProvider`
#### `MediaTypeProvider`
Plugins with `kind` containing `"media_type"` export:
@ -272,7 +292,7 @@ In the pipeline first-match-wins. Plugins are checked in priority order. The
first where `can_handle` returns `true` and has a matching definition claims the
file. If no plugin claims it, built-in handlers run as fallback.
### `MetadataExtractor`
#### `MetadataExtractor`
Plugins with `kind` containing `"metadata_extractor"` export:
@ -301,7 +321,7 @@ The pipeline features accumulating merge. All matching plugins run in priority
order. Later plugins' non-null fields overwrite earlier ones. The `extra` maps
are merged (later keys win).
### `ThumbnailGenerator`
#### `ThumbnailGenerator`
Plugins with `kind` containing `"thumbnail_generator"` export:
@ -329,7 +349,7 @@ Same as [MetadataExtractor](#metadataextractor)
First-success-wins. The first plugin to return a successful result produces the
thumbnail.
### `SearchBackend`
#### `SearchBackend`
Plugins with `kind` containing `"search_backend"` export:
@ -371,7 +391,7 @@ Results from all backends are merged, deduplicated by ID (keeping the highest
score), and sorted by score descending. `index_item` and `remove_item` are
fanned out to all backends.
### `EventHandler`
#### `EventHandler`
Plugins with `kind` containing `"event_handler"` export:
@ -394,9 +414,9 @@ Plugins with `kind` containing `"event_handler"` export:
All interested plugins receive the event. Events are dispatched asynchronously
via `tokio::spawn` and do not block the caller. Handler failures are logged but
never propagated. Suffice to say that the pipeline is fan-out.
never propagated. The pipeline is fan-out.
### `ThemeProvider`
#### `ThemeProvider`
> [!IMPORTANT]
> `ThemeProvider` is experimental, and will likely be subject to change.
@ -431,7 +451,7 @@ Plugins with `kind` containing `"theme_provider"` export:
Themes from all providers are accumulated. When loading a specific theme, the
pipeline dispatches to the plugin that registered it.
### System Events
#### System Events
The server emits these events at various points:
@ -450,45 +470,45 @@ The server emits these events at various points:
Plugins can also emit events themselves via `host_emit_event`, enabling
plugin-to-plugin communication.
## Host Functions
### Host Functions
Plugins can call these host functions from within WASM (imported from the
`"env"` module):
### `host_set_result(ptr, len)`
#### `host_set_result(ptr, len)`
Write a JSON response back to the host. The plugin writes the response bytes
into its own linear memory and passes the pointer and length. This is how you
return data from any extension point function.
### `host_emit_event(type_ptr, type_len, payload_ptr, payload_len) -> i32`
#### `host_emit_event(type_ptr, type_len, payload_ptr, payload_len) -> i32`
Emit a system event from within a plugin. Enables plugin-to-plugin
communication.
Returns `0` on success, `-1` on error.
### `host_log(level, ptr, len)`
#### `host_log(level, ptr, len)`
Log a message.
Levels: 0=error, 1=warn, 2=info, 3+=debug. Messages appear in the server's
tracing output.
### `host_read_file(path_ptr, path_len) -> i32`
#### `host_read_file(path_ptr, path_len) -> i32`
Read a file into the exchange buffer. Returns file size on success, `-1` on IO
error, `-2` if the path is outside the plugin's allowed read paths (paths are
canonicalized before checking, so traversal tricks won't work).
### `host_write_file(path_ptr, path_len, data_ptr, data_len) -> i32`
#### `host_write_file(path_ptr, path_len, data_ptr, data_len) -> i32`
Write data to a file.
Returns `0` on success, `-1` on IO error, `-2` if the path is outside allowed
write paths.
### `host_http_request(url_ptr, url_len) -> i32`
#### `host_http_request(url_ptr, url_len) -> i32`
Make an HTTP GET request.
@ -496,12 +516,12 @@ Returns response size on success (body in exchange buffer), `-1` on error, `-2`
if network access is disabled, `-3` if the domain is not in the plugin's
`allowed_domains` list.
### `host_get_config(key_ptr, key_len) -> i32`
#### `host_get_config(key_ptr, key_len) -> i32`
Read a plugin configuration value. Returns JSON value length on success (in
exchange buffer), `-1` if key not found.
### `host_get_env(key_ptr, key_len) -> i32`
#### `host_get_env(key_ptr, key_len) -> i32`
Read an environment variable.
@ -509,7 +529,7 @@ Returns value length on success (in exchange buffer), `-1` if the variable is
not set, `-2` if environment access is disabled or the variable is not in the
plugin's `environment` list.
### `host_get_buffer(dest_ptr, dest_len) -> i32`
#### `host_get_buffer(dest_ptr, dest_len) -> i32`
Copy the exchange buffer into WASM memory.
@ -517,9 +537,9 @@ Returns bytes copied. Use this after `host_read_file`, `host_http_request`,
`host_get_config`, or `host_get_env` to retrieve data the host placed in the
buffer.
## Security Model
### Security Model
### Capabilities
#### Capabilities
Every plugin declares what it needs in `plugin.toml`. The `CapabilityEnforcer`
validates these at load time _and_ at runtime; a plugin declaring
@ -545,7 +565,7 @@ without `network = true` will get `-2` from `host_http_request`.
- **CPU**: Enforced via wasmtime's fuel metering. Each invocation gets a fuel
budget proportional to the configured CPU time limit.
### Isolation
#### Isolation
- Each `call_function` invocation creates a fresh wasmtime `Store`. Plugins
cannot retain WASM runtime state between calls. If you need persistence, use
@ -555,7 +575,7 @@ without `network = true` will get `-2` from `host_http_request`.
- The wasmtime stack is limited to 1MB.
### Timeouts
#### Timeouts
Three tiers of per-call timeouts prevent runaway plugins:
@ -569,7 +589,7 @@ Three tiers of per-call timeouts prevent runaway plugins:
<!--markdownlint-enable MD013-->
### Circuit Breaker
#### Circuit Breaker
If a plugin fails consecutively, the circuit breaker disables it automatically:
@ -579,7 +599,7 @@ If a plugin fails consecutively, the circuit breaker disables it automatically:
- Disabled plugins are skipped in all pipeline stages.
- Reload or toggle the plugin via the API to re-enable.
### Signatures
#### Signatures
Plugins can be signed with Ed25519. The signing flow:
@ -603,7 +623,7 @@ verifies against at least one trusted key. If the signature is missing, invalid,
or matches no trusted key, the plugin is rejected at load time. Set
`allow_unsigned = true` during development to skip this check.
## Plugin Lifecycle
### Plugin Lifecycle
1. **Discovery**: On startup, the plugin manager walks configured plugin
directories looking for `plugin.toml` files.
@ -637,7 +657,7 @@ or matches no trusted key, the plugin is rejected at load time. Set
9. **Hot-reload**: The `/plugins/:id/reload` endpoint reloads the WASM binary
from disk and re-discovers capabilities without restarting the server.
## Plugin API Endpoints
### Plugin API Endpoints
<!-- FIXME: this should be moved to API documentation -->
@ -657,7 +677,7 @@ Reload and toggle operations automatically re-discover the plugin's
capabilities, so changes to supported types or event subscriptions take effect
immediately.
## Configuration
### Configuration
In `pinakes.toml`:
@ -682,7 +702,7 @@ allowed_read_paths = ["/media", "/tmp/pinakes"]
allowed_write_paths = ["/tmp/pinakes/plugin-data"]
```
## Debugging
### Debugging
- **`host_log`**: Call from within your plugin to emit structured log messages.
They appear in the server's tracing output.
@ -693,3 +713,394 @@ allowed_write_paths = ["/tmp/pinakes/plugin-data"]
- **Circuit breaker**: If your plugin is silently skipped, check the server logs
for "circuit breaker tripped" messages. Fix the issue, then re-enable via
`POST /api/v1/plugins/:id/enable`.
---
## GUI Plugins
A plugin declares its UI pages and optional widgets in `plugin.toml`, either
inline or as separate `.json` files. At startup, the server indexes all plugin
manifests and serves the schemas from `GET /api/v1/plugins/ui/pages`. The UI
fetches and validates these schemas, registers them in the `PluginRegistry`, and
adds the pages to the sidebar navigation. Widgets are injected into fixed
locations in host views at registration time.
No WASM code runs during page rendering. The schema is purely declarative JSON.
### Manifest Additions
Add a `[ui]` section to `plugin.toml`:
```toml
[plugin]
name = "my-plugin"
version = "1.0.0"
api_version = "1.0"
kind = ["ui_page"]
# Inline page definition
[[ui.pages]]
id = "stats"
title = "My Stats"
route = "/plugins/my-plugin/stats"
icon = "chart-bar"
[ui.pages.data_sources.summary]
type = "endpoint"
path = "/api/v1/plugins/my-plugin/summary"
poll_interval = 30 # re-fetch every 30 seconds
[ui.pages.layout]
type = "container"
children = []
# File-referenced page
[[ui.pages]]
file = "pages/detail.json" # path relative to plugin directory
# Widget injections
[[ui.widgets]]
id = "my-badge"
target = "library_header"
[ui.widgets.content]
type = "badge"
text = "My Plugin"
variant = "default"
```
Alternatively, pages can be defined entirely in a separate JSON file:
```json
{
"id": "stats",
"title": "My Stats",
"route": "/plugins/my-plugin/stats",
"icon": "chart-bar",
"data_sources": {
"summary": {
"type": "endpoint",
"path": "/api/v1/plugins/my-plugin/summary"
}
},
"layout": {
"type": "container",
"children": []
}
}
```
### `UiPage` Fields
<!--markdownlint-disable MD013-->
| Field | Type | Required | Description |
| -------------- | -------------------------- | -------- | ----------------------------------------------------- |
| `id` | string | Yes | Unique identifier (alphanumeric, dashes, underscores) |
| `title` | string | Yes | Display name shown in navigation |
| `route` | string | Yes | URL path (must start with `/`) |
| `icon` | string | No | Icon name (from dioxus-free-icons) |
| `layout` | `UiElement` | Yes | Root layout element (see Element Reference) |
| `data_sources` | `{name: DataSource}` | No | Named data sources available to this page |
| `actions` | `{name: ActionDefinition}` | No | Named actions referenced by elements |
<!--markdownlint-enable MD013-->
### Data Sources
Data sources populate named slots that elements bind to via their `data` field.
#### `endpoint`: HTTP API call
```toml
[ui.pages.data_sources.items]
type = "endpoint"
method = "GET" # GET (default), POST, PUT, PATCH, DELETE
path = "/api/v1/media" # must start with /
poll_interval = 60 # seconds; 0 = no polling (default)
# Query params for GET, body for other methods
#
# Values are Expressions (see Expression Syntax)
[ui.pages.data_sources.items.params]
limit = 20
offset = 0
```
#### `static`: Inline JSON
```toml
[ui.pages.data_sources.options]
type = "static"
value = ["asc", "desc"]
```
#### `transform`: Derived from another source
```toml
# Evaluate an expression against an already-fetched source
[ui.pages.data_sources.count]
type = "transform"
source = "items" # name of the source to read from
expression = "items.total_count" # path expression into the context
```
Transform sources always run after all non-transform sources, so the named
source is guaranteed to be available in the context.
### Expression Syntax
Expressions appear as values in `params`, `transform`, `TextContent`, and
`Progress.value`.
<!--markdownlint-disable MD013-->
| Form | JSON example | Description |
| --------- | --------------------------------------------------- | -------------------------------------------- |
| Literal | `42`, `"hello"`, `true`, `null` | A fixed JSON value |
| Path | `"users.0.name"`, `"summary.count"` | Dot-separated path into the data context |
| Operation | `{"left":"a","op":"concat","right":"b"}` | Binary expression (see operators table) |
| Call | `{"function":"format","args":["Hello, {}!","Bob"]}` | Built-in function call (see functions table) |
<!--markdownlint-enable MD013-->
Path expressions use dot notation. Array indices are plain numbers:
`"items.0.title"` accesses `items[0].title`.
**Operators:**
| `op` | Result type | Description |
| -------- | ----------- | ------------------------------------------ |
| `eq` | bool | Equal |
| `ne` | bool | Not equal |
| `gt` | bool | Greater than (numeric) |
| `gte` | bool | Greater than or equal |
| `lt` | bool | Less than |
| `lte` | bool | Less than or equal |
| `and` | bool | Logical AND |
| `or` | bool | Logical OR |
| `concat` | string | String concatenation (both sides coerced) |
| `add` | number | f64 addition |
| `sub` | number | f64 subtraction |
| `mul` | number | f64 multiplication |
| `div` | number | f64 division (returns 0 on divide-by-zero) |
**Built-in functions:**
<!--markdownlint-disable MD013-->
| Function | Signature | Description |
| ----------- | -------------------------------------- | -------------------------------------------------------------- |
| `len` | `len(value)` | Length of array, string (chars), or object (key count) |
| `upper` | `upper(str)` | Uppercase string |
| `lower` | `lower(str)` | Lowercase string |
| `trim` | `trim(str)` | Remove leading/trailing whitespace |
| `format` | `format(template, ...args)` | Replace `{}` placeholders left-to-right with args |
| `join` | `join(array, sep)` | Join array elements into a string with separator |
| `contains` | `contains(haystack, needle)` | True if string contains substring, or array contains value |
| `keys` | `keys(object)` | Array of object keys |
| `values` | `values(object)` | Array of object values |
| `abs` | `abs(number)` | Absolute value |
| `round` | `round(number)` | Round to nearest integer |
| `floor` | `floor(number)` | Round down |
| `ceil` | `ceil(number)` | Round up |
| `not` | `not(bool)` | Boolean negation |
| `coalesce` | `coalesce(a, b, ...)` | First non-null argument |
| `to_string` | `to_string(value)` | Convert any value to its display string |
| `to_number` | `to_number(value)` | Parse string to f64; pass through numbers; bool coerces to 0/1 |
| `slice` | `slice(array_or_string, start[, end])` | Sub-array or substring; negative indices count from end |
| `reverse` | `reverse(array_or_string)` | Reversed array or string |
| `if` | `if(cond, then, else)` | Return `then` if `cond` is true, otherwise `else` |
<!--markdownlint-enable MD013-->
### Actions
Actions define what happens when a button is clicked or a form is submitted.
#### Inline action
```json
{
"type": "button",
"label": "Delete",
"action": {
"method": "DELETE",
"path": "/api/v1/media/123",
"success_message": "Deleted!",
"navigate_to": "/library"
}
}
```
#### Named action
Define the action in the page's `actions` map, then reference it by name:
```toml
[ui.pages.actions.delete-item]
method = "DELETE"
path = "/api/v1/media/target"
navigate_to = "/library"
```
Then reference it anywhere an `ActionRef` is accepted:
```json
{ "type": "button", "label": "Delete", "action": "delete-item" }
```
Named actions allow multiple elements to share the same action definition
without repetition. The action name must be a valid identifier (alphanumeric,
dashes, underscores).
#### `ActionDefinition` fields
<!--markdownlint-disable MD013-->
| Field | Type | Default | Description |
| ----------------- | ------ | ------- | ------------------------------------------ |
| `method` | string | `GET` | HTTP method |
| `path` | string | | API path (must start with `/`) |
| `params` | object | `{}` | Fixed params merged with form data on POST |
| `success_message` | string | | Toast message on success |
| `error_message` | string | | Toast message on error |
| `navigate_to` | string | | Route to navigate to after success |
<!--markdownlint-enable MD013-->
### Element Reference
All elements are JSON objects with a `type` field.
<!--markdownlint-disable MD013-->
| Type | Key fields | Description |
| ------------------ | ----------------------------------------------------------------------- | ---------------------------------------- |
| `container` | `children`, `gap`, `padding` | Stacked children with gap/padding |
| `grid` | `children`, `columns` (1-12), `gap` | CSS grid layout |
| `flex` | `children`, `direction`, `justify`, `align`, `gap`, `wrap` | Flexbox layout |
| `split` | `sidebar`, `sidebar_width`, `main` | Sidebar + main content layout |
| `tabs` | `tabs` (array of `{id,label,content[]}`), `default_tab` | Tabbed panels |
| `heading` | `level` (1-6), `content` | Section heading h1-h6 |
| `text` | `content`, `variant`, `allow_html` | Paragraph text |
| `code` | `content`, `language`, `show_line_numbers` | Code block with syntax highlighting |
| `data_table` | `columns`, `data`, `sortable`, `filterable`, `page_size`, `row_actions` | Sortable, filterable data table |
| `card` | `title`, `content`, `footer` | Content card with optional header/footer |
| `media_grid` | `data`, `columns`, `gap` | Responsive image/video grid |
| `list` | `data`, `item_template`, `dividers` | Templated list (loops over data items) |
| `description_list` | `data`, `horizontal` | Key-value pair list (metadata display) |
| `button` | `label`, `variant`, `action`, `disabled` | Clickable button |
| `form` | `fields`, `submit_label`, `submit_action`, `cancel_label` | Input form with submission |
| `link` | `text`, `href`, `external` | Navigation link |
| `progress` | `value` (Expression), `max`, `show_percentage` | Progress bar |
| `badge` | `text`, `variant` | Status badge/chip |
| `loop` | `data`, `item`, `template` | Iterates data array, renders template |
| `conditional` | `condition` (Expression), `then`, `else` | Conditional rendering |
| `chart` | `chart_type`, `data`, `x_key`, `y_key`, `title` | Bar/line/pie/scatter chart |
| `image` | `src`, `alt`, `width`, `height`, `object_fit` | Image element |
| `divider` | | Horizontal rule |
| `spacer` | `size` | Blank vertical space |
| `raw_html` | `html` | Sanitized raw HTML block |
<!--markdownlint-enable MD013-->
### Widget Injection
Widgets are small UI elements injected into fixed locations in the host views.
Unlike pages, widgets have no data sources; they render with static or
expression-based content only.
#### Declaring a widget
```toml
[[ui.widgets]]
id = "status-badge"
target = "library_header"
[ui.widgets.content]
type = "badge"
text = "My Plugin Active"
variant = "success"
```
#### Target locations
| Target string | Where it renders |
| ----------------- | ----------------------------------------- |
| `library_header` | Before the stats grid in the Library view |
| `library_sidebar` | After the stats grid in the Library view |
| `search_filters` | Above the Search component in Search view |
| `detail_panel` | Above the Detail component in Detail view |
Multiple plugins can register widgets at the same target; all are rendered in
registration order.
### Complete Example
A plugin page that lists media items and lets the user trigger a re-scan:
```toml
[plugin]
name = "rescan-page"
version = "1.0.0"
api_version = "1.0"
kind = ["ui_page"]
[[ui.pages]]
id = "rescan"
title = "Re-scan"
route = "/plugins/rescan-page/rescan"
icon = "refresh"
[ui.pages.actions.trigger-scan]
method = "POST"
path = "/api/v1/scan/trigger"
success_message = "Scan started!"
navigate_to = "/plugins/rescan-page/rescan"
[ui.pages.data_sources.media]
type = "endpoint"
path = "/api/v1/media"
poll_interval = 30
[ui.pages.layout]
type = "container"
gap = 16
[[ui.pages.layout.children]]
type = "flex"
direction = "row"
justify = "space-between"
gap = 8
[[ui.pages.layout.children.children]]
type = "heading"
level = 2
content = "Library"
[[ui.pages.layout.children.children]]
type = "button"
label = "Trigger Re-scan"
variant = "primary"
action = "trigger-scan"
[[ui.pages.layout.children]]
type = "data_table"
data = "media"
sortable = true
filterable = true
page_size = 25
[[ui.pages.layout.children.columns]]
key = "title"
label = "Title"
[[ui.pages.layout.children.columns]]
key = "media_type"
label = "Type"
[[ui.pages.layout.children.columns]]
key = "file_size"
label = "Size"
```