docs/plugins: detail GUI plugin usage; separate server & GUI plugins
Signed-off-by: NotAShelf <raf@notashelf.dev> Change-Id: I2060db637209655390a86facd004bc646a6a6964
This commit is contained in:
parent
ad6d0b646f
commit
be72b6a7ed
1 changed files with 471 additions and 60 deletions
531
docs/plugins.md
531
docs/plugins.md
|
|
@ -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
|
||||
(0–999, 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"
|
||||
```
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue