examples: add WASM plugin examples
Signed-off-by: NotAShelf <raf@notashelf.dev> Change-Id: Id4b791396ab37827caced2c8cc03ec356a6a6964
This commit is contained in:
parent
011e8edb28
commit
934fcba8ca
16 changed files with 1720 additions and 0 deletions
BIN
examples/plugins/auto-tagger/Cargo.lock
generated
Normal file
BIN
examples/plugins/auto-tagger/Cargo.lock
generated
Normal file
Binary file not shown.
15
examples/plugins/auto-tagger/Cargo.toml
Normal file
15
examples/plugins/auto-tagger/Cargo.toml
Normal 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
|
||||
13
examples/plugins/auto-tagger/plugin.toml
Normal file
13
examples/plugins/auto-tagger/plugin.toml
Normal 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
|
||||
303
examples/plugins/auto-tagger/src/lib.rs
Normal file
303
examples/plugins/auto-tagger/src/lib.rs
Normal 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"{}");
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue