examples: add WASM plugin examples

Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: Id4b791396ab37827caced2c8cc03ec356a6a6964
This commit is contained in:
raf 2026-05-20 21:52:21 +03:00
commit 934fcba8ca
Signed by: NotAShelf
GPG key ID: 29D95B64378DB4BF
16 changed files with 1720 additions and 0 deletions

BIN
examples/plugins/auto-tagger/Cargo.lock generated Normal file

Binary file not shown.

View file

@ -0,0 +1,15 @@
[package]
name = "auto-tagger"
version = "1.0.0"
edition = "2024"
[lib]
crate-type = ["cdylib"]
[dependencies]
dlmalloc = { version = "0.2", features = ["global"] }
[profile.release]
opt-level = "s"
lto = true
strip = true

View file

@ -0,0 +1,13 @@
[plugin]
name = "auto-tagger"
version = "1.0.0"
api_version = "1.0"
description = "Listens for MediaImported events and emits AutoTagSuggested events based on path pattern rules"
kind = ["event_handler"]
priority = 500
[plugin.binary]
wasm = "auto_tagger.wasm"
[capabilities]
network = false

View file

@ -0,0 +1,303 @@
//! Auto-tagger plugin for Pinakes.
//!
//! Listens for `MediaImported` events and, based on configurable path pattern
//! rules, emits `AutoTagSuggested` events. Rules map path substrings to tag
//! names.
//!
//! Configuration key `rules` expects a JSON array of objects:
//! `[{"pattern": "/music/", "tag": "music"}, ...]`
//!
//! If no config is present, built-in defaults are used:
//! - `/music/` -> `music`
//! - `/photos/` -> `photo`
//! - `/videos/` -> `video`
//! - `/books/` -> `book`
//! - `/documents/` -> `document`
//!
//! Build with:
//! RUSTFLAGS="" cargo build --target wasm32-unknown-unknown --release
#![no_std]
extern crate alloc;
use alloc::{format, string::String, vec, vec::Vec};
use core::alloc::Layout;
#[global_allocator]
static ALLOC: dlmalloc::GlobalDlmalloc = dlmalloc::GlobalDlmalloc;
#[panic_handler]
fn panic_handler(_info: &core::panic::PanicInfo) -> ! {
core::arch::wasm32::unreachable()
}
// Host functions provided by the runtime
unsafe extern "C" {
fn host_set_result(ptr: i32, len: i32);
fn host_log(level: i32, ptr: i32, len: i32);
fn host_emit_event(type_ptr: i32, type_len: i32, payload_ptr: i32, payload_len: i32) -> i32;
fn host_get_config(key_ptr: i32, key_len: i32) -> i32;
fn host_get_buffer(dest_ptr: i32, dest_len: i32) -> i32;
}
fn set_response(json: &[u8]) {
unsafe {
host_set_result(json.as_ptr() as i32, json.len() as i32);
}
}
fn log_info(msg: &str) {
unsafe {
host_log(2, msg.as_ptr() as i32, msg.len() as i32);
}
}
unsafe fn read_request(ptr: i32, len: i32) -> Vec<u8> {
if ptr < 0 || len <= 0 {
return Vec::new();
}
let slice = unsafe { core::slice::from_raw_parts(ptr as *const u8, len as usize) };
slice.to_vec()
}
/// Extract a string value from a JSON object for a given key.
fn json_get_str<'a>(json: &'a [u8], key: &str) -> Option<&'a str> {
let json_str = core::str::from_utf8(json).ok()?;
let pattern = format!("\"{}\"", key);
let key_pos = json_str.find(&pattern)?;
let after_key = &json_str[key_pos + pattern.len()..];
let after_colon = after_key.trim_start().strip_prefix(':')?;
let after_colon = after_colon.trim_start();
if after_colon.starts_with('"') {
let value_start = 1;
let value_end = after_colon[value_start..].find('"')?;
Some(&after_colon[value_start..value_start + value_end])
} else {
None
}
}
/// A single tagging rule: match `pattern` in path -> apply `tag`.
struct Rule {
pattern: String,
tag: String,
}
/// Default rules used when no `rules` config key is present.
fn default_rules() -> Vec<Rule> {
vec![
Rule { pattern: String::from("/music/"), tag: String::from("music") },
Rule { pattern: String::from("/photos/"), tag: String::from("photo") },
Rule { pattern: String::from("/videos/"), tag: String::from("video") },
Rule { pattern: String::from("/books/"), tag: String::from("book") },
Rule { pattern: String::from("/documents/"), tag: String::from("document") },
]
}
/// Parse the `rules` JSON array from the config buffer.
/// Expected format: `[{"pattern":"...","tag":"..."},...]`
/// Returns an empty vec on any parse failure (falls back to defaults).
fn parse_rules_json(data: &[u8]) -> Vec<Rule> {
let text = match core::str::from_utf8(data) {
Ok(s) => s,
Err(_) => return Vec::new(),
};
let mut rules = Vec::new();
// Walk through occurrences of "pattern" keys inside object literals.
let mut search = text;
while let Some(p_pos) = search.find("\"pattern\"") {
let after_p = &search[p_pos + 9..];
let after_colon = match after_p.trim_start().strip_prefix(':') {
Some(s) => s.trim_start(),
None => {
search = &search[p_pos + 1..];
continue;
}
};
let pattern = if after_colon.starts_with('"') {
let inner = &after_colon[1..];
match inner.find('"') {
Some(end) => String::from(&inner[..end]),
None => {
search = &search[p_pos + 1..];
continue;
}
}
} else {
search = &search[p_pos + 1..];
continue;
};
// Now search for "tag" after the current pattern position.
let remaining = &search[p_pos..];
let tag = if let Some(t_pos) = remaining.find("\"tag\"") {
let after_t = &remaining[t_pos + 5..];
let after_colon_t = match after_t.trim_start().strip_prefix(':') {
Some(s) => s.trim_start(),
None => {
search = &search[p_pos + 1..];
continue;
}
};
if after_colon_t.starts_with('"') {
let inner = &after_colon_t[1..];
match inner.find('"') {
Some(end) => String::from(&inner[..end]),
None => {
search = &search[p_pos + 1..];
continue;
}
}
} else {
search = &search[p_pos + 1..];
continue;
}
} else {
search = &search[p_pos + 1..];
continue;
};
rules.push(Rule { pattern, tag });
search = &search[p_pos + 1..];
}
rules
}
/// Load rules from config, falling back to defaults.
fn load_rules() -> Vec<Rule> {
let key = b"rules";
let size = unsafe { host_get_config(key.as_ptr() as i32, key.len() as i32) };
if size <= 0 {
return default_rules();
}
let buf_size = size as usize;
let layout = match Layout::from_size_align(buf_size, 1) {
Ok(l) => l,
Err(_) => return default_rules(),
};
let ptr = unsafe { alloc::alloc::alloc(layout) };
if ptr.is_null() {
return default_rules();
}
let copied = unsafe { host_get_buffer(ptr as i32, size) };
if copied <= 0 {
unsafe { alloc::alloc::dealloc(ptr, layout) };
return default_rules();
}
let data = unsafe { core::slice::from_raw_parts(ptr, copied as usize) };
let rules = parse_rules_json(data);
unsafe { alloc::alloc::dealloc(ptr, layout) };
if rules.is_empty() {
default_rules()
} else {
rules
}
}
/// Escape a string for safe inclusion in a JSON string value.
fn json_escape(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for c in s.chars() {
match c {
'"' => out.push_str("\\\""),
'\\' => out.push_str("\\\\"),
'\n' => out.push_str("\\n"),
'\r' => out.push_str("\\r"),
'\t' => out.push_str("\\t"),
_ => out.push(c),
}
}
out
}
#[unsafe(no_mangle)]
pub extern "C" fn alloc(size: i32) -> i32 {
if size <= 0 {
return 0;
}
unsafe {
let layout = match Layout::from_size_align(size as usize, 1) {
Ok(l) => l,
Err(_) => return -1,
};
let ptr = alloc::alloc::alloc(layout);
if ptr.is_null() {
return -1;
}
ptr as i32
}
}
#[unsafe(no_mangle)]
pub extern "C" fn initialize() -> i32 {
log_info("auto-tagger initialized");
0
}
#[unsafe(no_mangle)]
pub extern "C" fn shutdown() -> i32 {
log_info("auto-tagger shutdown");
0
}
/// Returns the event types this handler is interested in.
#[unsafe(no_mangle)]
pub extern "C" fn interested_events(_ptr: i32, _len: i32) {
set_response(br#"["MediaImported"]"#);
}
/// Handle a `MediaImported` event: check path against rules and emit tag events.
#[unsafe(no_mangle)]
pub extern "C" fn handle_event(ptr: i32, len: i32) {
let req = unsafe { read_request(ptr, len) };
let media_id = json_get_str(&req, "media_id").unwrap_or("");
// The payload is nested; attempt to extract `path` from the top-level
// request or from a nested `payload` object.
let path = json_get_str(&req, "path").unwrap_or("");
let rules = load_rules();
let mut matched_count = 0u32;
for rule in &rules {
if !path.is_empty() && path.contains(rule.pattern.as_str()) {
let event_type = b"AutoTagSuggested";
let payload = format!(
r#"{{"media_id":"{}","tag":"{}"}}"#,
json_escape(media_id),
json_escape(&rule.tag),
);
unsafe {
host_emit_event(
event_type.as_ptr() as i32,
event_type.len() as i32,
payload.as_ptr() as i32,
payload.len() as i32,
);
}
matched_count += 1;
}
}
if matched_count > 0 {
let msg = format!(
"auto-tagger: matched {} rule(s) for path: {}",
matched_count,
path,
);
log_info(&msg);
} else {
let msg = format!("auto-tagger: no rules matched for path: {}", path);
log_info(&msg);
}
set_response(b"{}");
}