GUI plugins #9

Merged
NotAShelf merged 46 commits from notashelf/push-mytsqvppsvxu into main 2026-03-12 16:53:43 +00:00
59 changed files with 11295 additions and 230 deletions

View file

@ -6,3 +6,10 @@ await-holding-invalid-types = [
"dioxus_signals::WriteLock",
{ path = "dioxus_signals::WriteLock", reason = "Write should not be held over an await point. This will cause any reads or writes to fail while the await is pending since the write borrow is still active." },
]
disallowed-methods = [
{ path = "once_cell::unsync::OnceCell::get_or_init", reason = "use `std::cell::OnceCell` instead, unless you need get_or_try_init in which case #[expect] this lint" },
{ path = "once_cell::sync::OnceCell::get_or_init", reason = "use `std::sync::OnceLock` instead, unless you need get_or_try_init in which case #[expect] this lint" },
{ path = "once_cell::unsync::Lazy::new", reason = "use `std::cell::LazyCell` instead, unless you need into_value" },
{ path = "once_cell::sync::Lazy::new", reason = "use `std::sync::LazyLock` instead, unless you need into_value" },
]

2
.gitignore vendored
View file

@ -7,3 +7,5 @@ target/
# Runtime artifacts
*.db*
test.toml

3
Cargo.lock generated
View file

@ -5446,6 +5446,7 @@ dependencies = [
"thiserror 2.0.18",
"tokio",
"toml 1.0.6+spec-1.1.0",
"tracing",
"uuid",
"wit-bindgen 0.53.1",
]
@ -5511,11 +5512,13 @@ dependencies = [
"chrono",
"clap",
"dioxus",
"dioxus-core",
"dioxus-free-icons",
"futures",
"gloo-timers",
"grass",
"gray_matter",
"pinakes-plugin-api",
"pulldown-cmark",
"rand 0.10.0",
"regex",

View file

@ -100,6 +100,7 @@ crossterm = "0.29.0"
# Desktop/Web UI
dioxus = { version = "0.7.3", features = ["desktop", "router"] }
dioxus-core = { version = "0.7.3" }
# Async trait (dyn-compatible async methods)
async-trait = "0.1.89"
@ -187,6 +188,7 @@ undocumented_unsafe_blocks = "warn"
unnecessary_safety_comment = "warn"
unused_result_ok = "warn"
unused_trait_names = "allow"
too_many_arguments = "allow"
# False positive:
# clippy's build script check doesn't recognize workspace-inherited metadata

View file

@ -42,7 +42,9 @@ pub async fn export_library(
match format {
ExportFormat::Json => {
let json = serde_json::to_string_pretty(&items).map_err(|e| {
crate::error::PinakesError::Config(format!("json serialize: {e}"))
crate::error::PinakesError::Serialization(format!(
"json serialize: {e}"
))
})?;
std::fs::write(destination, json)?;
},

View file

@ -498,10 +498,14 @@ fn collect_import_result(
tracing::warn!(path = %path.display(), error = %e, "failed to import file");
results.push(Err(e));
},
Err(e) => {
tracing::error!(error = %e, "import task panicked");
Err(join_err) => {
if join_err.is_panic() {
tracing::error!(error = %join_err, "import task panicked");
} else {
tracing::warn!(error = %join_err, "import task was cancelled");
}
results.push(Err(PinakesError::InvalidOperation(format!(
"import task panicked: {e}"
"import task failed: {join_err}"
))));
},
}

View file

@ -32,7 +32,7 @@ fn extract_pdf(path: &Path) -> Result<ExtractedMetadata> {
.map_err(|e| PinakesError::MetadataExtraction(format!("PDF load: {e}")))?;
let mut meta = ExtractedMetadata::default();
let mut book_meta = crate::model::ExtractedBookMetadata::default();
let mut book_meta = crate::model::BookMetadata::default();
// Find the Info dictionary via the trailer
if let Ok(info_ref) = doc.trailer.get(b"Info") {
@ -145,7 +145,7 @@ fn extract_epub(path: &Path) -> Result<ExtractedMetadata> {
..Default::default()
};
let mut book_meta = crate::model::ExtractedBookMetadata::default();
let mut book_meta = crate::model::BookMetadata::default();
// Extract basic metadata
if let Some(lang) = doc.mdata("language") {

View file

@ -6,11 +6,7 @@ pub mod video;
use std::{collections::HashMap, path::Path};
use crate::{
error::Result,
media_type::MediaType,
model::ExtractedBookMetadata,
};
use crate::{error::Result, media_type::MediaType, model::BookMetadata};
#[derive(Debug, Clone, Default)]
pub struct ExtractedMetadata {
@ -22,7 +18,7 @@ pub struct ExtractedMetadata {
pub duration_secs: Option<f64>,
pub description: Option<String>,
pub extra: HashMap<String, String>,
pub book_metadata: Option<ExtractedBookMetadata>,
pub book_metadata: Option<BookMetadata>,
// Photo-specific metadata
pub date_taken: Option<chrono::DateTime<chrono::Utc>>,

View file

@ -417,6 +417,10 @@ pub struct SavedSearch {
// Book Management Types
/// Metadata for book-type media.
///
/// Used both as a DB record (with populated `media_id`, `created_at`,
/// `updated_at`) and as an extraction result (with placeholder values for
/// those fields when the record has not yet been persisted).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BookMetadata {
pub media_id: MediaId,
@ -435,6 +439,28 @@ pub struct BookMetadata {
pub updated_at: DateTime<Utc>,
}
impl Default for BookMetadata {
fn default() -> Self {
let now = Utc::now();
Self {
media_id: MediaId(uuid::Uuid::nil()),
isbn: None,
isbn13: None,
publisher: None,
language: None,
page_count: None,
publication_date: None,
series_name: None,
series_index: None,
format: None,
authors: Vec::new(),
identifiers: HashMap::new(),
created_at: now,
updated_at: now,
}
}
}
/// Information about a book author.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct AuthorInfo {
@ -476,22 +502,6 @@ impl AuthorInfo {
}
}
/// Book metadata extracted from files (without database-specific fields)
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct ExtractedBookMetadata {
pub isbn: Option<String>,
pub isbn13: Option<String>,
pub publisher: Option<String>,
pub language: Option<String>,
pub page_count: Option<i32>,
pub publication_date: Option<chrono::NaiveDate>,
pub series_name: Option<String>,
pub series_index: Option<f64>,
pub format: Option<String>,
pub authors: Vec<AuthorInfo>,
pub identifiers: HashMap<String, Vec<String>>,
}
/// Reading progress for a book.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ReadingProgress {

View file

@ -599,6 +599,110 @@ impl PluginManager {
&self.enforcer
}
/// List all UI pages provided by loaded plugins.
///
/// Returns a vector of `(plugin_id, page)` tuples for all enabled plugins
/// that provide pages in their manifests. Both inline and file-referenced
/// page entries are resolved.
pub async fn list_ui_pages(
&self,
) -> Vec<(String, pinakes_plugin_api::UiPage)> {
self
.list_ui_pages_with_endpoints()
.await
.into_iter()
.map(|(id, page, _)| (id, page))
.collect()
}
/// List all UI pages provided by loaded plugins, including each plugin's
/// declared endpoint allowlist.
///
/// Returns a vector of `(plugin_id, page, allowed_endpoints)` tuples. The
/// `allowed_endpoints` list mirrors the `required_endpoints` field from the
/// plugin manifest's `[ui]` section.
pub async fn list_ui_pages_with_endpoints(
&self,
) -> Vec<(String, pinakes_plugin_api::UiPage, Vec<String>)> {
let registry = self.registry.read().await;
let mut pages = Vec::new();
for plugin in registry.list_all() {
if !plugin.enabled {
continue;
}
let allowed = plugin.manifest.ui.required_endpoints.clone();
let plugin_dir = plugin
.manifest_path
.as_ref()
.and_then(|p| p.parent())
.map(std::path::Path::to_path_buf);
let Some(plugin_dir) = plugin_dir else {
for entry in &plugin.manifest.ui.pages {
if let pinakes_plugin_api::manifest::UiPageEntry::Inline(page) = entry
{
pages.push((plugin.id.clone(), (**page).clone(), allowed.clone()));
}
}
continue;
};
match plugin.manifest.load_ui_pages(&plugin_dir) {
Ok(loaded) => {
for page in loaded {
pages.push((plugin.id.clone(), page, allowed.clone()));
}
},
Err(e) => {
tracing::warn!(
"Failed to load UI pages for plugin '{}': {e}",
plugin.id
);
},
}
}
pages
}
/// Collect CSS custom property overrides declared by all enabled plugins.
///
/// When multiple plugins declare the same property name, later-loaded plugins
/// overwrite earlier ones. Returns an empty map if no plugins are loaded or
/// none declare theme extensions.
pub async fn list_ui_theme_extensions(
&self,
) -> std::collections::HashMap<String, String> {
let registry = self.registry.read().await;
let mut merged = std::collections::HashMap::new();
for plugin in registry.list_all() {
if !plugin.enabled {
continue;
}
for (k, v) in &plugin.manifest.ui.theme_extensions {
merged.insert(k.clone(), v.clone());
}
}
merged
}
/// List all UI widgets provided by loaded plugins.
///
/// Returns a vector of `(plugin_id, widget)` tuples for all enabled plugins
/// that provide widgets in their manifests.
pub async fn list_ui_widgets(
&self,
) -> Vec<(String, pinakes_plugin_api::UiWidget)> {
let registry = self.registry.read().await;
let mut widgets = Vec::new();
for plugin in registry.list_all() {
if !plugin.enabled {
continue;
}
for widget in &plugin.manifest.ui.widgets {
widgets.push((plugin.id.clone(), widget.clone()));
}
}
widgets
}
/// Check if a plugin is loaded and enabled
pub async fn is_plugin_enabled(&self, plugin_id: &str) -> bool {
let registry = self.registry.read().await;
@ -716,6 +820,7 @@ mod tests {
},
capabilities: Default::default(),
config: Default::default(),
ui: Default::default(),
}
}

View file

@ -1258,7 +1258,8 @@ mod tests {
Instant::now()
.checked_sub(CIRCUIT_BREAKER_COOLDOWN)
.unwrap()
- Duration::from_secs(1),
.checked_sub(Duration::from_secs(1))
.unwrap(),
);
}
@ -1277,7 +1278,8 @@ mod tests {
Instant::now()
.checked_sub(CIRCUIT_BREAKER_COOLDOWN)
.unwrap()
- Duration::from_secs(1),
.checked_sub(Duration::from_secs(1))
.unwrap(),
);
}
assert!(pipeline.is_healthy(plugin_id).await);

View file

@ -131,7 +131,7 @@ impl PluginRegistry {
self
.plugins
.values()
.filter(|p| p.manifest.plugin.kind.contains(&kind.to_string()))
.filter(|p| p.manifest.plugin.kind.iter().any(|k| k == kind))
.collect()
}
@ -158,7 +158,7 @@ impl Default for PluginRegistry {
mod tests {
use std::collections::HashMap;
use pinakes_plugin_api::Capabilities;
use pinakes_plugin_api::{Capabilities, manifest::ManifestCapabilities};
use super::*;
@ -182,6 +182,7 @@ mod tests {
},
capabilities: ManifestCapabilities::default(),
config: HashMap::new(),
ui: Default::default(),
};
RegisteredPlugin {

View file

@ -493,12 +493,11 @@ impl HostFunctions {
if let Some(ref allowed) =
caller.data().context.capabilities.network.allowed_domains
{
let parsed = match url::Url::parse(&url_str) {
Ok(u) => u,
_ => {
tracing::warn!(url = %url_str, "plugin provided invalid URL");
return -1;
},
let parsed = if let Ok(u) = url::Url::parse(&url_str) {
u
} else {
tracing::warn!(url = %url_str, "plugin provided invalid URL");
return -1;
};
let domain = parsed.host_str().unwrap_or("");

View file

@ -4295,6 +4295,11 @@ impl StorageBackend for PostgresBackend {
&self,
metadata: &crate::model::BookMetadata,
) -> Result<()> {
if metadata.media_id.0.is_nil() {
return Err(PinakesError::Database(
"upsert_book_metadata: media_id must not be nil".to_string(),
));
}
let mut client = self
.pool
.get()

View file

@ -1116,7 +1116,8 @@ impl StorageBackend for SqliteBackend {
parent_id.map(|p| p.to_string()),
now.to_rfc3339(),
],
)?;
)
.map_err(crate::error::db_ctx("create_tag", &name))?;
drop(db);
Tag {
id,
@ -1192,7 +1193,8 @@ impl StorageBackend for SqliteBackend {
.lock()
.map_err(|e| PinakesError::Database(e.to_string()))?;
let changed = db
.execute("DELETE FROM tags WHERE id = ?1", params![id.to_string()])?;
.execute("DELETE FROM tags WHERE id = ?1", params![id.to_string()])
.map_err(crate::error::db_ctx("delete_tag", id))?;
drop(db);
if changed == 0 {
return Err(PinakesError::TagNotFound(id.to_string()));
@ -1214,7 +1216,11 @@ impl StorageBackend for SqliteBackend {
db.execute(
"INSERT OR IGNORE INTO media_tags (media_id, tag_id) VALUES (?1, ?2)",
params![media_id.0.to_string(), tag_id.to_string()],
)?;
)
.map_err(crate::error::db_ctx(
"tag_media",
format!("{media_id} x {tag_id}"),
))?;
}
Ok(())
})
@ -1232,7 +1238,11 @@ impl StorageBackend for SqliteBackend {
db.execute(
"DELETE FROM media_tags WHERE media_id = ?1 AND tag_id = ?2",
params![media_id.0.to_string(), tag_id.to_string()],
)?;
)
.map_err(crate::error::db_ctx(
"untag_media",
format!("{media_id} x {tag_id}"),
))?;
}
Ok(())
})
@ -1323,7 +1333,8 @@ impl StorageBackend for SqliteBackend {
now.to_rfc3339(),
now.to_rfc3339(),
],
)?;
)
.map_err(crate::error::db_ctx("create_collection", &name))?;
drop(db);
Collection {
id,
@ -1406,7 +1417,8 @@ impl StorageBackend for SqliteBackend {
let changed = db
.execute("DELETE FROM collections WHERE id = ?1", params![
id.to_string()
])?;
])
.map_err(crate::error::db_ctx("delete_collection", id))?;
drop(db);
if changed == 0 {
return Err(PinakesError::CollectionNotFound(id.to_string()));
@ -1440,7 +1452,11 @@ impl StorageBackend for SqliteBackend {
position,
now.to_rfc3339(),
],
)?;
)
.map_err(crate::error::db_ctx(
"add_to_collection",
format!("{collection_id} <- {media_id}"),
))?;
}
Ok(())
})
@ -1463,7 +1479,11 @@ impl StorageBackend for SqliteBackend {
"DELETE FROM collection_members WHERE collection_id = ?1 AND \
media_id = ?2",
params![collection_id.to_string(), media_id.0.to_string()],
)?;
)
.map_err(crate::error::db_ctx(
"remove_from_collection",
format!("{collection_id} <- {media_id}"),
))?;
}
Ok(())
})
@ -1863,20 +1883,29 @@ impl StorageBackend for SqliteBackend {
let db = conn
.lock()
.map_err(|e| PinakesError::Database(e.to_string()))?;
let tx = db.unchecked_transaction()?;
let ctx = format!("{} media x {} tags", media_ids.len(), tag_ids.len());
let tx = db
.unchecked_transaction()
.map_err(crate::error::db_ctx("batch_tag_media", &ctx))?;
// Prepare statement once for reuse
let mut stmt = tx.prepare_cached(
"INSERT OR IGNORE INTO media_tags (media_id, tag_id) VALUES (?1, ?2)",
)?;
let mut stmt = tx
.prepare_cached(
"INSERT OR IGNORE INTO media_tags (media_id, tag_id) VALUES (?1, \
?2)",
)
.map_err(crate::error::db_ctx("batch_tag_media", &ctx))?;
let mut count = 0u64;
for mid in &media_ids {
for tid in &tag_ids {
let rows = stmt.execute(params![mid, tid])?;
let rows = stmt
.execute(params![mid, tid])
.map_err(crate::error::db_ctx("batch_tag_media", &ctx))?;
count += rows as u64; // INSERT OR IGNORE: rows=1 if new, 0 if existed
}
}
drop(stmt);
tx.commit()?;
tx.commit()
.map_err(crate::error::db_ctx("batch_tag_media", &ctx))?;
count
};
Ok(count)
@ -2695,7 +2724,7 @@ impl StorageBackend for SqliteBackend {
let id_str = id.0.to_string();
let now = chrono::Utc::now();
let role_str = serde_json::to_string(&role).map_err(|e| {
PinakesError::Database(format!("failed to serialize role: {e}"))
PinakesError::Serialization(format!("failed to serialize role: {e}"))
})?;
tx.execute(
@ -2714,7 +2743,7 @@ impl StorageBackend for SqliteBackend {
let user_profile = if let Some(prof) = profile.clone() {
let prefs_json =
serde_json::to_string(&prof.preferences).map_err(|e| {
PinakesError::Database(format!(
PinakesError::Serialization(format!(
"failed to serialize preferences: {e}"
))
})?;
@ -2796,7 +2825,9 @@ impl StorageBackend for SqliteBackend {
if let Some(ref r) = role {
updates.push("role = ?");
let role_str = serde_json::to_string(r).map_err(|e| {
PinakesError::Database(format!("failed to serialize role: {e}"))
PinakesError::Serialization(format!(
"failed to serialize role: {e}"
))
})?;
params.push(Box::new(role_str));
}
@ -2814,7 +2845,7 @@ impl StorageBackend for SqliteBackend {
if let Some(prof) = profile {
let prefs_json =
serde_json::to_string(&prof.preferences).map_err(|e| {
PinakesError::Database(format!(
PinakesError::Serialization(format!(
"failed to serialize preferences: {e}"
))
})?;
@ -2966,7 +2997,9 @@ impl StorageBackend for SqliteBackend {
PinakesError::Database(format!("failed to acquire database lock: {e}"))
})?;
let perm_str = serde_json::to_string(&permission).map_err(|e| {
PinakesError::Database(format!("failed to serialize permission: {e}"))
PinakesError::Serialization(format!(
"failed to serialize permission: {e}"
))
})?;
let now = chrono::Utc::now();
db.execute(
@ -5055,6 +5088,11 @@ impl StorageBackend for SqliteBackend {
&self,
metadata: &crate::model::BookMetadata,
) -> Result<()> {
if metadata.media_id.0.is_nil() {
return Err(PinakesError::InvalidOperation(
"upsert_book_metadata: media_id must not be nil".to_string(),
));
}
let conn = Arc::clone(&self.conn);
let media_id_str = metadata.media_id.to_string();
let isbn = metadata.isbn.clone();

View file

@ -27,7 +27,10 @@ impl TempFileGuard {
impl Drop for TempFileGuard {
fn drop(&mut self) {
let _ = std::fs::remove_file(&self.0);
if self.0.exists()
&& let Err(e) = std::fs::remove_file(&self.0) {
warn!("failed to clean up temp file {}: {e}", self.0.display());
}
}
}

View file

@ -927,3 +927,124 @@ async fn test_transcode_sessions() {
.unwrap();
assert_eq!(cleaned, 1);
}
#[tokio::test]
async fn test_batch_update_media_empty() {
let storage = setup().await;
// Empty ID slice must return 0 without error.
let count = storage
.batch_update_media(&[], Some("title"), None, None, None, None, None)
.await
.unwrap();
assert_eq!(count, 0);
}
#[tokio::test]
async fn test_batch_update_media_no_fields() {
let storage = setup().await;
let item = make_test_media("bum_nofield");
storage.insert_media(&item).await.unwrap();
// No fields to change: implementation returns 0 (only updated_at would
// shift, but the bulk path short-circuits when no real fields are given).
let count = storage
.batch_update_media(&[item.id], None, None, None, None, None, None)
.await
.unwrap();
assert_eq!(count, 0);
}
#[tokio::test]
async fn test_batch_update_media_single_field() {
let storage = setup().await;
let item = make_test_media("bum_single");
storage.insert_media(&item).await.unwrap();
let count = storage
.batch_update_media(
&[item.id],
Some("Bulk Title"),
None,
None,
None,
None,
None,
)
.await
.unwrap();
assert_eq!(count, 1);
let fetched = storage.get_media(item.id).await.unwrap();
assert_eq!(fetched.title.as_deref(), Some("Bulk Title"));
// Fields not touched must remain unchanged.
assert_eq!(fetched.artist.as_deref(), Some("Test Artist"));
}
#[tokio::test]
async fn test_batch_update_media_multiple_items() {
let storage = setup().await;
let item_a = make_test_media("bum_multi_a");
let item_b = make_test_media("bum_multi_b");
let item_c = make_test_media("bum_multi_c");
storage.insert_media(&item_a).await.unwrap();
storage.insert_media(&item_b).await.unwrap();
storage.insert_media(&item_c).await.unwrap();
let ids = [item_a.id, item_b.id, item_c.id];
let count = storage
.batch_update_media(
&ids,
Some("Shared Title"),
Some("Shared Artist"),
Some("Shared Album"),
Some("Jazz"),
Some(2025),
Some("Batch desc"),
)
.await
.unwrap();
assert_eq!(count, 3);
for id in &ids {
let fetched = storage.get_media(*id).await.unwrap();
assert_eq!(fetched.title.as_deref(), Some("Shared Title"));
assert_eq!(fetched.artist.as_deref(), Some("Shared Artist"));
assert_eq!(fetched.album.as_deref(), Some("Shared Album"));
assert_eq!(fetched.genre.as_deref(), Some("Jazz"));
assert_eq!(fetched.year, Some(2025));
assert_eq!(fetched.description.as_deref(), Some("Batch desc"));
}
}
#[tokio::test]
async fn test_batch_update_media_subset_of_items() {
let storage = setup().await;
let item_a = make_test_media("bum_subset_a");
let item_b = make_test_media("bum_subset_b");
storage.insert_media(&item_a).await.unwrap();
storage.insert_media(&item_b).await.unwrap();
// Only update item_a.
let count = storage
.batch_update_media(
&[item_a.id],
Some("Only A"),
None,
None,
None,
None,
None,
)
.await
.unwrap();
assert_eq!(count, 1);
let fetched_a = storage.get_media(item_a.id).await.unwrap();
let fetched_b = storage.get_media(item_b.id).await.unwrap();
assert_eq!(fetched_a.title.as_deref(), Some("Only A"));
// item_b must be untouched.
assert_eq!(fetched_b.title, item_b.title);
}

View file

@ -10,6 +10,7 @@ serde = { workspace = true }
serde_json = { workspace = true }
thiserror = { workspace = true }
async-trait = { workspace = true }
tracing = { workspace = true }
# For plugin manifest parsing
toml = { workspace = true }

View file

@ -15,10 +15,13 @@ use thiserror::Error;
pub mod manifest;
pub mod types;
pub mod ui_schema;
pub mod validation;
pub mod wasm;
pub use manifest::PluginManifest;
pub use types::*;
pub use ui_schema::*;
pub use wasm::host_functions;
/// Plugin API version - plugins must match this version

View file

@ -10,6 +10,8 @@ use crate::{
EnvironmentCapability,
FilesystemCapability,
NetworkCapability,
UiPage,
UiWidget,
};
/// Plugin manifest file format (TOML)
@ -22,6 +24,104 @@ pub struct PluginManifest {
#[serde(default)]
pub config: HashMap<String, toml::Value>,
/// UI pages provided by this plugin
#[serde(default)]
pub ui: UiSection,
}
/// UI section of the plugin manifest
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct UiSection {
/// UI pages defined by this plugin
#[serde(default)]
pub pages: Vec<UiPageEntry>,
/// Widgets to inject into existing host pages
#[serde(default)]
pub widgets: Vec<UiWidget>,
/// API endpoint paths this plugin's UI requires.
/// Each must start with `/api/`. Informational; host may check availability.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub required_endpoints: Vec<String>,
/// CSS custom property overrides provided by this plugin.
/// Keys are property names (e.g. `--accent-color`), values are CSS values.
/// The host applies these to `document.documentElement` on startup.
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub theme_extensions: HashMap<String, String>,
}
impl UiSection {
/// Validate that all declared required endpoints start with `/api/`.
///
/// # Errors
///
/// Returns an error string for the first invalid endpoint found.
pub fn validate(&self) -> Result<(), String> {
for ep in &self.required_endpoints {
if !ep.starts_with("/api/") {
return Err(format!("required_endpoint must start with '/api/': {ep}"));
}
}
Ok(())
}
}
/// Entry for a UI page in the manifest - can be inline or file reference
#[derive(Debug, Clone)]
pub enum UiPageEntry {
/// Inline UI page definition (boxed to reduce enum size)
Inline(Box<UiPage>),
/// Reference to a JSON file containing the page definition
File { file: String },
}
impl<'de> Deserialize<'de> for UiPageEntry {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
use serde::de::Error;
// First try to deserialize as a file reference (has "file" key)
// We use toml::Value since the manifest is TOML
let value = toml::Value::deserialize(deserializer)?;
if let Some(file) = value.get("file").and_then(|v| v.as_str()) {
if file.is_empty() {
return Err(D::Error::custom("file path cannot be empty"));
}
return Ok(Self::File {
file: file.to_string(),
});
}
// Otherwise try to deserialize as inline UiPage
// Convert toml::Value back to a deserializer for UiPage
let page: UiPage = UiPage::deserialize(value)
.map_err(|e| D::Error::custom(format!("invalid inline UI page: {e}")))?;
Ok(Self::Inline(Box::new(page)))
}
}
impl Serialize for UiPageEntry {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
match self {
Self::Inline(page) => page.serialize(serializer),
Self::File { file } => {
use serde::ser::SerializeStruct;
let mut state = serializer.serialize_struct("UiPageEntry", 1)?;
state.serialize_field("file", file)?;
state.end()
},
}
}
}
const fn default_priority() -> u16 {
@ -168,6 +268,7 @@ impl PluginManifest {
"search_backend",
"event_handler",
"theme_provider",
"ui_page",
];
for kind in &self.plugin.kind {
@ -193,9 +294,86 @@ impl PluginManifest {
));
}
// Validate UI section (required_endpoints format); non-fatal: warn only
if let Err(e) = self.ui.validate() {
tracing::warn!(
plugin = %self.plugin.name,
error = %e,
"plugin UI section has invalid required_endpoints"
);
}
// Validate UI pages
for (idx, page_entry) in self.ui.pages.iter().enumerate() {
match page_entry {
UiPageEntry::Inline(page) => {
if let Err(e) = page.validate() {
return Err(ManifestError::ValidationError(format!(
"UI page {} validation failed: {e}",
idx + 1
)));
}
},
UiPageEntry::File { file } => {
if file.is_empty() {
return Err(ManifestError::ValidationError(format!(
"UI page {} file path cannot be empty",
idx + 1
)));
}
},
}
}
Ok(())
}
/// Load and resolve all UI page definitions
///
/// For inline pages, returns them directly. For file references, loads
/// and parses the JSON file.
///
/// # Arguments
///
/// * `base_path` - Base directory for resolving relative file paths
///
/// # Errors
///
/// Returns [`ManifestError::IoError`] if a file cannot be read, or
/// [`ManifestError::ValidationError`] if JSON parsing fails.
pub fn load_ui_pages(
&self,
base_path: &Path,
) -> Result<Vec<UiPage>, ManifestError> {
let mut pages = Vec::with_capacity(self.ui.pages.len());
for entry in &self.ui.pages {
let page = match entry {
UiPageEntry::Inline(page) => (**page).clone(),
UiPageEntry::File { file } => {
let file_path = base_path.join(file);
let content = std::fs::read_to_string(&file_path)?;
let page: UiPage = serde_json::from_str(&content).map_err(|e| {
ManifestError::ValidationError(format!(
"Failed to parse UI page from file '{}': {e}",
file_path.display()
))
})?;
if let Err(e) = page.validate() {
return Err(ManifestError::ValidationError(format!(
"UI page validation failed for file '{}': {e}",
file_path.display()
)));
}
page
},
};
pages.push(page);
}
Ok(pages)
}
/// Convert manifest capabilities to API capabilities
#[must_use]
pub fn to_capabilities(&self) -> Capabilities {
@ -353,4 +531,265 @@ wasm = "plugin.wasm"
assert!(PluginManifest::parse_str(toml).is_err());
}
#[test]
fn test_ui_page_inline() {
let toml = r#"
[plugin]
name = "ui-demo"
version = "1.0.0"
api_version = "1.0"
kind = ["ui_page"]
[plugin.binary]
wasm = "plugin.wasm"
[[ui.pages]]
id = "demo"
title = "Demo Page"
route = "/plugins/demo"
icon = "star"
[ui.pages.layout]
type = "container"
children = []
gap = 16
"#;
let manifest = PluginManifest::parse_str(toml).unwrap();
assert_eq!(manifest.ui.pages.len(), 1);
match &manifest.ui.pages[0] {
UiPageEntry::Inline(page) => {
assert_eq!(page.id, "demo");
assert_eq!(page.title, "Demo Page");
assert_eq!(page.route, "/plugins/demo");
assert_eq!(page.icon, Some("star".to_string()));
},
_ => panic!("Expected inline page"),
}
}
#[test]
fn test_ui_page_file_reference() {
let toml = r#"
[plugin]
name = "ui-demo"
version = "1.0.0"
api_version = "1.0"
kind = ["ui_page"]
[plugin.binary]
wasm = "plugin.wasm"
[[ui.pages]]
file = "pages/demo.json"
"#;
let manifest = PluginManifest::parse_str(toml).unwrap();
assert_eq!(manifest.ui.pages.len(), 1);
match &manifest.ui.pages[0] {
UiPageEntry::File { file } => {
assert_eq!(file, "pages/demo.json");
},
_ => panic!("Expected file reference"),
}
}
#[test]
fn test_ui_page_invalid_kind() {
// ui_page must be in valid_kinds list
let toml = r#"
[plugin]
name = "test"
version = "1.0.0"
api_version = "1.0"
kind = ["ui_page"]
[plugin.binary]
wasm = "plugin.wasm"
"#;
// Should succeed now that ui_page is in valid_kinds
let manifest = PluginManifest::parse_str(toml);
assert!(manifest.is_ok());
}
#[test]
fn test_ui_page_validation_failure() {
let toml = r#"
[plugin]
name = "ui-demo"
version = "1.0.0"
api_version = "1.0"
kind = ["ui_page"]
[plugin.binary]
wasm = "plugin.wasm"
[[ui.pages]]
id = ""
title = "Demo"
route = "/plugins/demo"
[ui.pages.layout]
type = "container"
children = []
gap = 16
"#;
// Empty ID should fail validation
assert!(PluginManifest::parse_str(toml).is_err());
}
#[test]
fn test_ui_page_empty_file() {
let toml = r#"
[plugin]
name = "ui-demo"
version = "1.0.0"
api_version = "1.0"
kind = ["ui_page"]
[plugin.binary]
wasm = "plugin.wasm"
[[ui.pages]]
file = ""
"#;
// Empty file path should fail validation
assert!(PluginManifest::parse_str(toml).is_err());
}
#[test]
fn test_ui_page_multiple_pages() {
let toml = r#"
[plugin]
name = "ui-demo"
version = "1.0.0"
api_version = "1.0"
kind = ["ui_page"]
[plugin.binary]
wasm = "plugin.wasm"
[[ui.pages]]
id = "page1"
title = "Page 1"
route = "/plugins/page1"
[ui.pages.layout]
type = "container"
children = []
gap = 16
[[ui.pages]]
id = "page2"
title = "Page 2"
route = "/plugins/page2"
[ui.pages.layout]
type = "container"
children = []
gap = 16
"#;
let manifest = PluginManifest::parse_str(toml).unwrap();
assert_eq!(manifest.ui.pages.len(), 2);
}
#[test]
fn test_ui_section_validate_accepts_api_paths() {
let section = UiSection {
pages: vec![],
widgets: vec![],
required_endpoints: vec![
"/api/v1/media".to_string(),
"/api/plugins/my-plugin/data".to_string(),
],
theme_extensions: HashMap::new(),
};
assert!(section.validate().is_ok());
}
#[test]
fn test_ui_section_validate_rejects_non_api_path() {
let section = UiSection {
pages: vec![],
widgets: vec![],
required_endpoints: vec!["/not-api/something".to_string()],
theme_extensions: HashMap::new(),
};
assert!(section.validate().is_err());
}
#[test]
fn test_ui_section_validate_rejects_empty_sections_with_bad_path() {
let section = UiSection {
pages: vec![],
widgets: vec![],
required_endpoints: vec!["/api/ok".to_string(), "no-slash".to_string()],
theme_extensions: HashMap::new(),
};
let err = section.validate().unwrap_err();
assert!(
err.contains("no-slash"),
"error should mention the bad endpoint"
);
}
#[test]
fn test_theme_extensions_roundtrip() {
let toml = r##"
[plugin]
name = "theme-plugin"
version = "1.0.0"
api_version = "1.0"
kind = ["theme_provider"]
[plugin.binary]
wasm = "plugin.wasm"
[ui.theme_extensions]
"--accent-color" = "#ff6b6b"
"--sidebar-width" = "280px"
"##;
let manifest = PluginManifest::parse_str(toml).unwrap();
assert_eq!(
manifest
.ui
.theme_extensions
.get("--accent-color")
.map(String::as_str),
Some("#ff6b6b")
);
assert_eq!(
manifest
.ui
.theme_extensions
.get("--sidebar-width")
.map(String::as_str),
Some("280px")
);
}
#[test]
fn test_theme_extensions_empty_by_default() {
let toml = r#"
[plugin]
name = "no-theme"
version = "1.0.0"
api_version = "1.0"
kind = ["media_type"]
[plugin.binary]
wasm = "plugin.wasm"
"#;
let manifest = PluginManifest::parse_str(toml).unwrap();
assert!(manifest.ui.theme_extensions.is_empty());
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,728 @@
//! Schema validation for plugin UI pages
//!
//! Provides comprehensive validation of [`UiPage`] and [`UiElement`] trees
//! before they are rendered. Call [`SchemaValidator::validate_page`] before
//! registering a plugin page.
use thiserror::Error;
use crate::{DataSource, UiElement, UiPage, UiWidget};
/// Reserved routes that plugins cannot use
const RESERVED_ROUTES: &[&str] = &[
"/",
"/search",
"/settings",
"/admin",
"/library",
"/books",
"/tags",
"/collections",
"/audit",
"/import",
"/duplicates",
"/statistics",
"/tasks",
"/database",
"/graph",
];
/// Errors produced by schema validation
#[derive(Debug, Error)]
pub enum ValidationError {
/// A single validation failure
#[error("Validation error: {0}")]
Single(String),
/// Multiple validation failures collected in one pass
#[error("Validation failed with {} errors: {}", .0.len(), .0.join("; "))]
Multiple(Vec<String>),
}
/// Validates plugin UI schemas before they are loaded into the registry.
///
/// # Example
///
/// ```rust,ignore
/// let page = plugin.manifest.ui.pages[0].clone();
/// SchemaValidator::validate_page(&page)?;
/// ```
pub struct SchemaValidator;
impl SchemaValidator {
/// Validate a complete [`UiPage`] definition.
///
/// Checks:
/// - Page ID format (alphanumeric + dash/underscore, starts with a letter)
/// - Route starts with `'/'` and is not reserved
/// - `DataTable` elements have at least one column
/// - Form elements have at least one field
/// - Loop and Conditional elements have valid structure
///
/// # Errors
///
/// Returns [`ValidationError::Multiple`] containing all collected errors
/// so callers can surface all problems at once.
pub fn validate_page(page: &UiPage) -> Result<(), ValidationError> {
let mut errors = Vec::new();
// ID format
if !Self::is_valid_id(&page.id) {
errors.push(format!(
"Invalid page ID '{}': must start with a letter and contain only \
alphanumeric characters, dashes, or underscores",
page.id
));
}
// Route format
if !page.route.starts_with('/') {
errors.push(format!("Route must start with '/': {}", page.route));
}
// Reserved routes
if Self::is_reserved_route(&page.route) {
errors.push(format!("Route is reserved by the host: {}", page.route));
}
// Validate data sources
for (name, source) in &page.data_sources {
Self::validate_data_source(name, source, &mut errors);
}
// Recursively validate element tree
Self::validate_element(&page.root_element, &mut errors);
if errors.is_empty() {
Ok(())
} else {
Err(ValidationError::Multiple(errors))
}
}
/// Validate a [`UiWidget`] definition.
///
/// # Errors
///
/// Returns [`ValidationError::Multiple`] with all collected errors.
pub fn validate_widget(widget: &UiWidget) -> Result<(), ValidationError> {
let mut errors = Vec::new();
if !Self::is_valid_id(&widget.id) {
errors.push(format!(
"Invalid widget ID '{}': must start with a letter and contain only \
alphanumeric characters, dashes, or underscores",
widget.id
));
}
if widget.target.is_empty() {
errors.push("Widget target must not be empty".to_string());
}
Self::validate_element(&widget.content, &mut errors);
if Self::element_references_data_source(&widget.content) {
errors.push("widgets cannot reference data sources".to_string());
}
if errors.is_empty() {
Ok(())
} else {
Err(ValidationError::Multiple(errors))
}
}
/// Recursively validate a [`UiElement`] subtree.
pub fn validate_element(element: &UiElement, errors: &mut Vec<String>) {
match element {
UiElement::Container { children, .. }
| UiElement::Grid { children, .. }
| UiElement::Flex { children, .. } => {
for child in children {
Self::validate_element(child, errors);
}
},
UiElement::Split { sidebar, main, .. } => {
Self::validate_element(sidebar, errors);
Self::validate_element(main, errors);
},
UiElement::Tabs { tabs, .. } => {
if tabs.is_empty() {
errors.push("Tabs element must have at least one tab".to_string());
}
for tab in tabs {
Self::validate_element(&tab.content, errors);
}
},
UiElement::DataTable { data, columns, .. } => {
if data.is_empty() {
errors
.push("DataTable 'data' source key must not be empty".to_string());
}
if columns.is_empty() {
errors.push("DataTable must have at least one column".to_string());
}
},
UiElement::Form { fields, .. } => {
if fields.is_empty() {
errors.push("Form must have at least one field".to_string());
}
for field in fields {
if field.id.is_empty() {
errors.push("Form field id must not be empty".to_string());
}
}
},
UiElement::Conditional {
then, else_element, ..
} => {
Self::validate_element(then, errors);
if let Some(else_branch) = else_element {
Self::validate_element(else_branch, errors);
}
},
UiElement::Loop { template, .. } => {
Self::validate_element(template, errors);
},
UiElement::Card {
content, footer, ..
} => {
for child in content.iter().chain(footer.iter()) {
Self::validate_element(child, errors);
}
},
UiElement::List {
data,
item_template,
..
} => {
if data.is_empty() {
errors.push("List 'data' source key must not be empty".to_string());
}
Self::validate_element(item_template, errors);
},
// Leaf elements with no children to recurse into
UiElement::Heading { .. }
| UiElement::Text { .. }
| UiElement::Code { .. }
| UiElement::MediaGrid { .. }
| UiElement::DescriptionList { .. }
| UiElement::Button { .. }
| UiElement::Link { .. }
| UiElement::Progress { .. }
| UiElement::Badge { .. }
| UiElement::Chart { .. } => {},
}
}
/// Returns true if any element in the tree references a named data source.
///
/// Widgets have no data-fetching mechanism, so any data source reference
/// in a widget content tree is invalid and must be rejected at load time.
fn element_references_data_source(element: &UiElement) -> bool {
match element {
// Variants that reference a data source by name
UiElement::DataTable { .. }
| UiElement::MediaGrid { .. }
| UiElement::DescriptionList { .. }
| UiElement::Chart { .. }
| UiElement::Loop { .. }
| UiElement::List { .. } => true,
// Container variants - recurse into children
UiElement::Container { children, .. }
| UiElement::Grid { children, .. }
| UiElement::Flex { children, .. } => {
children.iter().any(Self::element_references_data_source)
},
UiElement::Split { sidebar, main, .. } => {
Self::element_references_data_source(sidebar)
|| Self::element_references_data_source(main)
},
UiElement::Tabs { tabs, .. } => {
tabs
.iter()
.any(|tab| Self::element_references_data_source(&tab.content))
},
UiElement::Card {
content, footer, ..
} => {
content.iter().any(Self::element_references_data_source)
|| footer.iter().any(Self::element_references_data_source)
},
UiElement::Conditional {
then, else_element, ..
} => {
Self::element_references_data_source(then)
|| else_element
.as_ref()
.is_some_and(|e| Self::element_references_data_source(e))
},
// Leaf elements with no data source references
UiElement::Heading { .. }
| UiElement::Text { .. }
| UiElement::Code { .. }
| UiElement::Button { .. }
| UiElement::Form { .. }
| UiElement::Link { .. }
| UiElement::Progress { .. }
| UiElement::Badge { .. } => false,
}
}
fn validate_data_source(
name: &str,
source: &DataSource,
errors: &mut Vec<String>,
) {
match source {
DataSource::Endpoint { path, .. } => {
if path.is_empty() {
errors.push(format!(
"Data source '{name}': endpoint path must not be empty"
));
}
if !path.starts_with('/') {
errors.push(format!(
"Data source '{name}': endpoint path must start with '/': {path}"
));
}
if !path.starts_with("/api/") {
errors.push(format!(
"DataSource '{name}': endpoint path must start with /api/ (got \
'{path}')"
));
}
},
DataSource::Transform { source_name, .. } => {
if source_name.is_empty() {
errors.push(format!(
"Data source '{name}': transform source_name must not be empty"
));
}
},
DataSource::Static { .. } => {},
}
}
fn is_valid_id(id: &str) -> bool {
if id.is_empty() || id.len() > 64 {
return false;
}
let mut chars = id.chars();
chars.next().is_some_and(|c| c.is_ascii_alphabetic())
&& chars.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
}
pub(crate) fn is_reserved_route(route: &str) -> bool {
RESERVED_ROUTES.iter().any(|reserved| {
route == *reserved
|| (route.starts_with(reserved)
&& route.as_bytes().get(reserved.len()) == Some(&b'/'))
})
}
}
#[cfg(test)]
mod tests {
use std::collections::HashMap;
use super::*;
use crate::UiElement;
fn make_page(id: &str, route: &str) -> UiPage {
UiPage {
id: id.to_string(),
title: "Test Page".to_string(),
route: route.to_string(),
icon: None,
root_element: UiElement::Container {
children: vec![],
gap: 0,
padding: None,
},
data_sources: HashMap::new(),
actions: HashMap::new(),
}
}
#[test]
fn test_valid_page() {
let page = make_page("my-plugin-page", "/plugins/test/page");
assert!(SchemaValidator::validate_page(&page).is_ok());
}
#[test]
fn test_invalid_id_starts_with_digit() {
let page = make_page("1invalid", "/plugins/test/page");
assert!(SchemaValidator::validate_page(&page).is_err());
}
#[test]
fn test_invalid_id_empty() {
let page = make_page("", "/plugins/test/page");
assert!(SchemaValidator::validate_page(&page).is_err());
}
#[test]
fn test_reserved_route() {
let page = make_page("my-page", "/settings");
assert!(SchemaValidator::validate_page(&page).is_err());
}
#[test]
fn test_route_missing_slash() {
let page = make_page("my-page", "plugins/test");
assert!(SchemaValidator::validate_page(&page).is_err());
}
#[test]
fn test_datatable_no_columns() {
let mut page = make_page("my-page", "/plugins/test/page");
page.root_element = UiElement::DataTable {
data: "items".to_string(),
columns: vec![],
sortable: false,
filterable: false,
page_size: 0,
row_actions: vec![],
};
let result = SchemaValidator::validate_page(&page);
assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(err.contains("at least one column"));
}
#[test]
fn test_form_no_fields() {
let mut page = make_page("my-page", "/plugins/test/page");
page.root_element = UiElement::Form {
fields: vec![],
submit_action: crate::ActionRef::Name("submit".to_string()),
submit_label: "Submit".to_string(),
cancel_label: None,
};
let result = SchemaValidator::validate_page(&page);
assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(err.contains("at least one field"));
}
#[test]
fn test_multiple_errors_collected() {
let page = make_page("1bad-id", "/settings");
let result = SchemaValidator::validate_page(&page);
assert!(result.is_err());
match result.unwrap_err() {
ValidationError::Multiple(errs) => assert!(errs.len() >= 2),
ValidationError::Single(_) => panic!("expected Multiple"),
}
}
#[test]
fn test_reserved_route_subpath_rejected() {
// A sub-path of a reserved route must also be rejected
for route in &[
"/settings/theme",
"/admin/users",
"/library/foo",
"/search/advanced",
"/tasks/pending",
] {
let page = make_page("my-page", route);
let result = SchemaValidator::validate_page(&page);
assert!(
result.is_err(),
"expected error for sub-path of reserved route: {route}"
);
}
}
#[test]
fn test_plugin_route_not_reserved() {
// Routes under /plugins/ are allowed (not in RESERVED_ROUTES)
let page = make_page("my-page", "/plugins/my-plugin/page");
assert!(SchemaValidator::validate_page(&page).is_ok());
}
#[test]
fn test_id_max_length_accepted() {
let id = "a".repeat(64);
let page = make_page(&id, "/plugins/test");
assert!(SchemaValidator::validate_page(&page).is_ok());
}
#[test]
fn test_id_too_long_rejected() {
let id = "a".repeat(65);
let page = make_page(&id, "/plugins/test");
assert!(SchemaValidator::validate_page(&page).is_err());
}
#[test]
fn test_id_with_dash_and_underscore() {
let page = make_page("my-plugin_page", "/plugins/test");
assert!(SchemaValidator::validate_page(&page).is_ok());
}
#[test]
fn test_id_with_special_chars_rejected() {
let page = make_page("my page!", "/plugins/test");
assert!(SchemaValidator::validate_page(&page).is_err());
}
#[test]
fn test_datatable_empty_data_key() {
let col: crate::ColumnDef =
serde_json::from_value(serde_json::json!({"key": "id", "header": "ID"}))
.unwrap();
let mut page = make_page("my-page", "/plugins/test/page");
page.root_element = UiElement::DataTable {
data: String::new(),
columns: vec![col],
sortable: false,
filterable: false,
page_size: 0,
row_actions: vec![],
};
assert!(SchemaValidator::validate_page(&page).is_err());
}
#[test]
fn test_form_field_empty_id_rejected() {
let field: crate::FormField = serde_json::from_value(
serde_json::json!({"id": "", "label": "Name", "type": {"type": "text"}}),
)
.unwrap();
let mut page = make_page("my-page", "/plugins/test/page");
page.root_element = UiElement::Form {
fields: vec![field],
submit_action: crate::ActionRef::Name("submit".to_string()),
submit_label: "Submit".to_string(),
cancel_label: None,
};
let result = SchemaValidator::validate_page(&page);
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("field id"));
}
#[test]
fn test_valid_widget() {
let widget = crate::UiWidget {
id: "my-widget".to_string(),
target: "library_header".to_string(),
content: UiElement::Container {
children: vec![],
gap: 0,
padding: None,
},
};
assert!(SchemaValidator::validate_widget(&widget).is_ok());
}
#[test]
fn test_widget_invalid_id() {
let widget = crate::UiWidget {
id: "1bad".to_string(),
target: "library_header".to_string(),
content: UiElement::Container {
children: vec![],
gap: 0,
padding: None,
},
};
assert!(SchemaValidator::validate_widget(&widget).is_err());
}
#[test]
fn test_widget_empty_target() {
let widget = crate::UiWidget {
id: "my-widget".to_string(),
target: String::new(),
content: UiElement::Container {
children: vec![],
gap: 0,
padding: None,
},
};
assert!(SchemaValidator::validate_widget(&widget).is_err());
}
#[test]
fn test_data_source_empty_endpoint_path() {
use crate::{DataSource, HttpMethod};
let mut page = make_page("my-page", "/plugins/test");
page
.data_sources
.insert("items".to_string(), DataSource::Endpoint {
path: String::new(),
method: HttpMethod::Get,
params: Default::default(),
poll_interval: 0,
transform: None,
});
assert!(SchemaValidator::validate_page(&page).is_err());
}
#[test]
fn test_data_source_endpoint_path_no_leading_slash() {
use crate::{DataSource, HttpMethod};
let mut page = make_page("my-page", "/plugins/test");
page
.data_sources
.insert("items".to_string(), DataSource::Endpoint {
path: "api/v1/items".to_string(),
method: HttpMethod::Get,
params: Default::default(),
poll_interval: 0,
transform: None,
});
assert!(SchemaValidator::validate_page(&page).is_err());
}
#[test]
fn test_data_source_endpoint_valid() {
use crate::{DataSource, HttpMethod};
let mut page = make_page("my-page", "/plugins/test");
page
.data_sources
.insert("items".to_string(), DataSource::Endpoint {
path: "/api/v1/items".to_string(),
method: HttpMethod::Get,
params: Default::default(),
poll_interval: 0,
transform: None,
});
assert!(SchemaValidator::validate_page(&page).is_ok());
}
#[test]
fn test_data_source_transform_empty_source_name() {
use crate::DataSource;
let mut page = make_page("my-page", "/plugins/test");
page
.data_sources
.insert("derived".to_string(), DataSource::Transform {
source_name: String::new(),
expression: crate::Expression::Literal(serde_json::Value::Null),
});
assert!(SchemaValidator::validate_page(&page).is_err());
}
#[test]
fn test_tabs_empty_rejected() {
let mut page = make_page("my-page", "/plugins/test");
page.root_element = UiElement::Tabs {
tabs: vec![],
default_tab: 0,
};
assert!(SchemaValidator::validate_page(&page).is_err());
}
#[test]
fn test_list_empty_data_key() {
let mut page = make_page("my-page", "/plugins/test");
page.root_element = UiElement::List {
data: String::new(),
item_template: Box::new(UiElement::Container {
children: vec![],
gap: 0,
padding: None,
}),
dividers: false,
};
assert!(SchemaValidator::validate_page(&page).is_err());
}
#[test]
fn test_widget_badge_content_passes_validation() {
let widget = crate::UiWidget {
id: "status-badge".to_string(),
target: "library_header".to_string(),
content: UiElement::Badge {
text: "active".to_string(),
variant: Default::default(),
},
};
assert!(
SchemaValidator::validate_widget(&widget).is_ok(),
"a widget with Badge content should pass validation"
);
}
#[test]
fn test_widget_datatable_fails_validation() {
let col: crate::ColumnDef =
serde_json::from_value(serde_json::json!({"key": "id", "header": "ID"}))
.unwrap();
let widget = crate::UiWidget {
id: "my-widget".to_string(),
target: "library_header".to_string(),
content: UiElement::DataTable {
data: "items".to_string(),
columns: vec![col],
sortable: false,
filterable: false,
page_size: 0,
row_actions: vec![],
},
};
let result = SchemaValidator::validate_widget(&widget);
assert!(
result.is_err(),
"DataTable in widget should fail validation"
);
let err = result.unwrap_err().to_string();
assert!(
err.contains("cannot reference data sources"),
"error message should mention data sources: {err}"
);
}
#[test]
fn test_widget_container_with_loop_fails_validation() {
// Container whose child is a Loop - recursive check must catch it
let widget = crate::UiWidget {
id: "loop-widget".to_string(),
target: "library_header".to_string(),
content: UiElement::Container {
children: vec![UiElement::Loop {
data: "items".to_string(),
template: Box::new(UiElement::Text {
content: Default::default(),
variant: Default::default(),
allow_html: false,
}),
empty: None,
}],
gap: 0,
padding: None,
},
};
let result = SchemaValidator::validate_widget(&widget);
assert!(
result.is_err(),
"Container wrapping a Loop should fail widget validation"
);
let err = result.unwrap_err().to_string();
assert!(
err.contains("cannot reference data sources"),
"error message should mention data sources: {err}"
);
}
}

View file

@ -0,0 +1,61 @@
//! Integration tests that parse and validate the media-stats-ui example.
use pinakes_plugin_api::{PluginManifest, UiPage};
/// Resolve a path relative to the workspace root.
fn workspace_path(rel: &str) -> std::path::PathBuf {
// tests run from the crate root (crates/pinakes-plugin-api)
std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("../..")
.join(rel)
}
#[test]
fn example_plugin_manifest_parses() {
let path = workspace_path("examples/plugins/media-stats-ui/plugin.toml");
// load_ui_pages needs the manifest validated, but the file-based pages
// just need the paths to exist; we test them separately below.
let content = std::fs::read_to_string(&path).expect("read plugin.toml");
// parse_str validates the manifest; it returns Err for any violation
let manifest = PluginManifest::parse_str(&content)
.expect("plugin.toml should parse and validate");
assert_eq!(manifest.plugin.name, "media-stats-ui");
assert_eq!(
manifest.ui.pages.len(),
2,
"expected 2 page file references"
);
assert_eq!(manifest.ui.widgets.len(), 1, "expected 1 widget");
}
#[test]
fn example_stats_page_parses_and_validates() {
let path = workspace_path("examples/plugins/media-stats-ui/pages/stats.json");
let content = std::fs::read_to_string(&path).expect("read stats.json");
let page: UiPage =
serde_json::from_str(&content).expect("stats.json should deserialise");
assert_eq!(page.id, "stats");
page.validate().expect("stats page should pass validation");
}
#[test]
fn example_tag_manager_page_parses_and_validates() {
let path =
workspace_path("examples/plugins/media-stats-ui/pages/tag-manager.json");
let content = std::fs::read_to_string(&path).expect("read tag-manager.json");
let page: UiPage = serde_json::from_str(&content)
.expect("tag-manager.json should deserialise");
assert_eq!(page.id, "tag-manager");
page
.validate()
.expect("tag-manager page should pass validation");
// Verify the named action and data source are both present
assert!(
page.actions.contains_key("create-tag"),
"create-tag action should be defined"
);
assert!(
page.data_sources.contains_key("tags"),
"tags data source should be defined"
);
}

View file

@ -0,0 +1,361 @@
//! Integration tests for the plugin validation pipeline.
//!
//! Renderer-level behaviour (e.g., Dioxus components) is out of scope here;
//! that requires a Dioxus runtime and belongs in pinakes-ui tests.
use std::collections::HashMap;
use pinakes_plugin_api::{
DataSource,
HttpMethod,
UiElement,
UiPage,
UiWidget,
validation::SchemaValidator,
};
// Build a minimal valid UiPage.
fn make_page(id: &str, route: &str) -> UiPage {
UiPage {
id: id.to_string(),
title: "Integration Test Page".to_string(),
route: route.to_string(),
icon: None,
root_element: UiElement::Container {
children: vec![],
gap: 0,
padding: None,
},
data_sources: HashMap::new(),
actions: HashMap::new(),
}
}
// Build a minimal valid UiWidget.
fn make_widget(id: &str, target: &str) -> UiWidget {
UiWidget {
id: id.to_string(),
target: target.to_string(),
content: UiElement::Container {
children: vec![],
gap: 0,
padding: None,
},
}
}
// Build a complete valid PluginManifest as TOML string.
const fn valid_manifest_toml() -> &'static str {
r#"
[plugin]
name = "integration-test-plugin"
version = "1.0.0"
api_version = "1.0"
kind = ["ui_page"]
[plugin.binary]
wasm = "plugin.wasm"
[[ui.pages]]
id = "stats"
title = "Statistics"
route = "/plugins/integration-test/stats"
[ui.pages.layout]
type = "container"
children = []
gap = 0
[[ui.widgets]]
id = "status-badge"
target = "library_header"
[ui.widgets.content]
type = "badge"
text = "online"
"#
}
// A complete valid manifest (with a page and a widget) passes all
// checks.
#[test]
fn test_full_valid_plugin_manifest_passes_all_checks() {
use pinakes_plugin_api::PluginManifest;
let manifest = PluginManifest::parse_str(valid_manifest_toml())
.expect("manifest must parse");
assert_eq!(manifest.plugin.name, "integration-test-plugin");
assert_eq!(manifest.ui.pages.len(), 1);
assert_eq!(manifest.ui.widgets.len(), 1);
// validate page via UiPage::validate
let page = make_page("my-plugin-page", "/plugins/integration-test/overview");
page
.validate()
.expect("valid page must pass UiPage::validate");
// validate the same page via SchemaValidator
SchemaValidator::validate_page(&page)
.expect("valid page must pass SchemaValidator::validate_page");
// validate widget
let widget = make_widget("my-widget", "library_header");
SchemaValidator::validate_widget(&widget)
.expect("valid widget must pass SchemaValidator::validate_widget");
}
// A page with a reserved route is rejected by both UiPage::validate
// and SchemaValidator::validate_page.
//
// The reserved routes exercised here are "/search" and "/admin".
#[test]
fn test_page_with_reserved_route_rejected() {
let reserved = ["/search", "/admin", "/settings", "/library", "/books"];
for route in reserved {
// UiPage::validate path
let page = make_page("my-page", route);
let result = page.validate();
assert!(
result.is_err(),
"UiPage::validate must reject reserved route: {route}"
);
let msg = result.unwrap_err().to_string();
assert!(
msg.contains("conflicts with a built-in app route"),
"error for {route} must mention 'conflicts with a built-in app route', \
got: {msg}"
);
// SchemaValidator::validate_page path
let page2 = make_page("my-page", route);
let result2 = SchemaValidator::validate_page(&page2);
assert!(
result2.is_err(),
"SchemaValidator::validate_page must reject reserved route: {route}"
);
}
}
// Sub-paths of reserved routes are also rejected.
#[test]
fn test_page_with_reserved_route_subpath_rejected() {
let subpaths = [
"/search/advanced",
"/admin/users",
"/settings/theme",
"/library/foo",
];
for route in subpaths {
let page = make_page("my-page", route);
assert!(
page.validate().is_err(),
"reserved route sub-path must be rejected: {route}"
);
}
}
// A UiWidget whose content contains a DataTable fails validation.
//
// Widgets have no data-fetching mechanism; any data source reference in a
// widget content tree must be caught at load time.
#[test]
fn test_widget_with_datatable_fails_validation() {
use pinakes_plugin_api::ColumnDef;
let col: ColumnDef =
serde_json::from_value(serde_json::json!({"key": "id", "header": "ID"}))
.unwrap();
let widget = UiWidget {
id: "bad-widget".to_string(),
target: "library_header".to_string(),
content: UiElement::DataTable {
data: "items".to_string(),
columns: vec![col],
sortable: false,
filterable: false,
page_size: 0,
row_actions: vec![],
},
};
let result = SchemaValidator::validate_widget(&widget);
assert!(
result.is_err(),
"widget with DataTable content must fail validation"
);
let msg = result.unwrap_err().to_string();
assert!(
msg.contains("cannot reference data sources"),
"error must mention data sources, got: {msg}"
);
}
// A UiWidget whose content is a Container wrapping a Loop fails
// validation. Basically a recursive data source check.
#[test]
fn test_widget_container_wrapping_loop_fails_validation() {
use pinakes_plugin_api::Expression;
let widget = UiWidget {
id: "loop-widget".to_string(),
target: "library_header".to_string(),
content: UiElement::Container {
children: vec![UiElement::Loop {
data: "items".to_string(),
template: Box::new(UiElement::Text {
content: Default::default(),
variant: Default::default(),
allow_html: false,
}),
empty: None,
}],
gap: 0,
padding: None,
},
};
let result = SchemaValidator::validate_widget(&widget);
assert!(
result.is_err(),
"widget containing a Loop must fail validation"
);
let _ = Expression::default(); // Expression should be accessible from the import path
}
// Endpoint data source with a path that does not start with /api/ fails
// UiPage::validate (DataSource::validate is called there).
#[test]
fn test_endpoint_data_source_non_api_path_rejected() {
let mut page = make_page("my-page", "/plugins/test/page");
page
.data_sources
.insert("items".to_string(), DataSource::Endpoint {
path: "/v1/media".to_string(),
method: HttpMethod::Get,
params: Default::default(),
poll_interval: 0,
transform: None,
});
// DataSource::validate requires /api/ prefix
assert!(
page.validate().is_err(),
"data source path not starting with /api/ must be rejected"
);
// Also verify SchemaValidator::validate_page rejects it
let result2 = SchemaValidator::validate_page(&page);
assert!(
result2.is_err(),
"SchemaValidator should reject non-/api/ endpoint path"
);
}
// A static data source with any value passes validation on its own.
#[test]
fn test_static_data_source_passes_validation() {
use pinakes_plugin_api::ColumnDef;
let col: ColumnDef =
serde_json::from_value(serde_json::json!({"key": "n", "header": "N"}))
.unwrap();
let mut page = make_page("my-page", "/plugins/test/page");
page
.data_sources
.insert("nums".to_string(), DataSource::Static {
value: serde_json::json!([1, 2, 3]),
});
// Root element references the static data source so DataTable passes
page.root_element = UiElement::DataTable {
data: "nums".to_string(),
columns: vec![col],
sortable: false,
filterable: false,
page_size: 0,
row_actions: vec![],
};
page
.validate()
.expect("page with static data source must pass validation");
}
// Parse TOML string, validate, and load inline pages round-trips without
// errors.
#[test]
fn test_manifest_inline_page_roundtrip() {
use pinakes_plugin_api::{PluginManifest, manifest::UiPageEntry};
let toml = r#"
[plugin]
name = "roundtrip-plugin"
version = "2.3.0"
api_version = "1.0"
kind = ["ui_page"]
[plugin.binary]
wasm = "plugin.wasm"
[[ui.pages]]
id = "overview"
title = "Overview"
route = "/plugins/roundtrip/overview"
[ui.pages.layout]
type = "container"
children = []
gap = 8
"#;
let manifest = PluginManifest::parse_str(toml).expect("manifest must parse");
assert_eq!(manifest.plugin.name, "roundtrip-plugin");
assert_eq!(manifest.ui.pages.len(), 1);
match &manifest.ui.pages[0] {
UiPageEntry::Inline(page) => {
assert_eq!(page.id, "overview");
assert_eq!(page.route, "/plugins/roundtrip/overview");
// UiPage::validate must also succeed for inline pages
page
.validate()
.expect("inline page must pass UiPage::validate");
},
UiPageEntry::File { .. } => {
panic!("expected inline page entry, got file reference");
},
}
}
// warnings by PluginManifest::validate but do NOT cause an error return.
// (The UiSection::validate failure is non-fatal; see manifest.rs.)
#[test]
fn test_manifest_bad_required_endpoint_is_non_fatal() {
use pinakes_plugin_api::PluginManifest;
let toml = r#"
[plugin]
name = "ep-test"
version = "1.0.0"
api_version = "1.0"
kind = ["ui_page"]
[plugin.binary]
wasm = "plugin.wasm"
[ui]
required_endpoints = ["/not-an-api-path"]
"#;
// PluginManifest::validate emits a tracing::warn for invalid
// required_endpoints but does not return Err - verify the manifest still
// parses successfully.
let result = PluginManifest::parse_str(toml);
assert!(
result.is_ok(),
"bad required_endpoint should be non-fatal for PluginManifest::validate"
);
}

View file

@ -480,6 +480,10 @@ pub fn create_router_with_tls(
.route("/database/backup", post(routes::backup::create_backup))
// Plugin management
.route("/plugins", get(routes::plugins::list_plugins))
.route("/plugins/events", post(routes::plugins::emit_plugin_event))
.route("/plugins/ui-pages", get(routes::plugins::list_plugin_ui_pages))
.route("/plugins/ui-widgets", get(routes::plugins::list_plugin_ui_widgets))
.route("/plugins/ui-theme-extensions", get(routes::plugins::list_plugin_ui_theme_extensions))
.route("/plugins/{id}", get(routes::plugins::get_plugin))
.route("/plugins/install", post(routes::plugins::install_plugin))
.route("/plugins/{id}", delete(routes::plugins::uninstall_plugin))

View file

@ -1,9 +1,40 @@
use std::{collections::HashMap, path::PathBuf};
use std::{
collections::HashMap,
path::{Path, PathBuf},
};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
/// Strip the longest matching root prefix from `full_path`, returning a
/// forward-slash-separated relative path string. Falls back to the full path
/// string when no root matches. If `roots` is empty, returns the full path as a
/// string so internal callers that have not yet migrated still work.
#[must_use]
pub fn relativize_path(full_path: &Path, roots: &[PathBuf]) -> String {
let mut best: Option<&PathBuf> = None;
for root in roots {
if full_path.starts_with(root) {
let is_longer = best
.is_none_or(|b| root.components().count() > b.components().count());
if is_longer {
best = Some(root);
}
}
}
if let Some(root) = best
&& let Ok(rel) = full_path.strip_prefix(root) {
// Normalise to forward slashes on all platforms.
return rel
.components()
.map(|c| c.as_os_str().to_string_lossy())
.collect::<Vec<_>>()
.join("/");
}
full_path.to_string_lossy().into_owned()
}
#[derive(Debug, Serialize)]
pub struct MediaResponse {
pub id: String,
@ -233,12 +264,16 @@ impl From<pinakes_core::model::ManagedStorageStats>
}
}
// Conversion helpers
impl From<pinakes_core::model::MediaItem> for MediaResponse {
fn from(item: pinakes_core::model::MediaItem) -> Self {
impl MediaResponse {
/// Build a `MediaResponse` from a `MediaItem`, stripping the longest
/// matching root prefix from the path before serialization. Pass the
/// configured root directories so that clients receive a relative path
/// (e.g. `"Music/song.mp3"`) rather than a full server filesystem path.
#[must_use]
pub fn new(item: pinakes_core::model::MediaItem, roots: &[PathBuf]) -> Self {
Self {
id: item.id.0.to_string(),
path: item.path.to_string_lossy().to_string(),
path: relativize_path(&item.path, roots),
file_name: item.file_name,
media_type: serde_json::to_value(item.media_type)
.ok()
@ -282,6 +317,57 @@ impl From<pinakes_core::model::MediaItem> for MediaResponse {
}
}
// Conversion helpers
impl From<pinakes_core::model::MediaItem> for MediaResponse {
/// Convert using no root stripping. Prefer `MediaResponse::new(item, roots)`
/// at route-handler call sites where roots are available.
fn from(item: pinakes_core::model::MediaItem) -> Self {
Self::new(item, &[])
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn relativize_path_strips_matching_root() {
let roots = vec![PathBuf::from("/home/user/music")];
let path = Path::new("/home/user/music/artist/song.mp3");
assert_eq!(relativize_path(path, &roots), "artist/song.mp3");
}
#[test]
fn relativize_path_picks_longest_root() {
let roots = vec![
PathBuf::from("/home/user"),
PathBuf::from("/home/user/music"),
];
let path = Path::new("/home/user/music/song.mp3");
assert_eq!(relativize_path(path, &roots), "song.mp3");
}
#[test]
fn relativize_path_no_match_returns_full() {
let roots = vec![PathBuf::from("/home/user/music")];
let path = Path::new("/srv/videos/movie.mkv");
assert_eq!(relativize_path(path, &roots), "/srv/videos/movie.mkv");
}
#[test]
fn relativize_path_empty_roots_returns_full() {
let path = Path::new("/home/user/music/song.mp3");
assert_eq!(relativize_path(path, &[]), "/home/user/music/song.mp3");
}
#[test]
fn relativize_path_exact_root_match() {
let roots = vec![PathBuf::from("/media/library")];
let path = Path::new("/media/library/file.mp3");
assert_eq!(relativize_path(path, &roots), "file.mp3");
}
}
// Watch progress
#[derive(Debug, Deserialize)]
pub struct WatchProgressRequest {

View file

@ -1,3 +1,4 @@
use pinakes_plugin_api::{UiPage, UiWidget};
use serde::{Deserialize, Serialize};
#[derive(Debug, Serialize)]
@ -21,6 +22,35 @@ pub struct TogglePluginRequest {
pub enabled: bool,
}
/// A single plugin UI page entry in the list response
#[derive(Debug, Serialize)]
pub struct PluginUiPageEntry {
/// Plugin ID that provides this page
pub plugin_id: String,
/// Full page definition
pub page: UiPage,
/// Endpoint paths this plugin is allowed to fetch (empty means no
/// restriction)
pub allowed_endpoints: Vec<String>,
}
/// A single plugin UI widget entry in the list response
#[derive(Debug, Serialize)]
pub struct PluginUiWidgetEntry {
/// Plugin ID that provides this widget
pub plugin_id: String,
/// Full widget definition
pub widget: UiWidget,
}
/// Request body for emitting a plugin event
#[derive(Debug, Deserialize)]
pub struct PluginEventRequest {
pub event: String,
#[serde(default)]
pub payload: serde_json::Value,
}
impl PluginResponse {
#[must_use]
pub fn new(meta: pinakes_plugin_api::PluginMetadata, enabled: bool) -> Self {

View file

@ -30,12 +30,13 @@ pub async fn get_most_viewed(
) -> Result<Json<Vec<MostViewedResponse>>, ApiError> {
let limit = params.limit.unwrap_or(20).min(MAX_LIMIT);
let results = state.storage.get_most_viewed(limit).await?;
let roots = state.config.read().await.directories.roots.clone();
Ok(Json(
results
.into_iter()
.map(|(item, count)| {
MostViewedResponse {
media: MediaResponse::from(item),
media: MediaResponse::new(item, &roots),
view_count: count,
}
})
@ -51,7 +52,13 @@ pub async fn get_recently_viewed(
let user_id = resolve_user_id(&state.storage, &username).await?;
let limit = params.limit.unwrap_or(20).min(MAX_LIMIT);
let items = state.storage.get_recently_viewed(user_id, limit).await?;
Ok(Json(items.into_iter().map(MediaResponse::from).collect()))
let roots = state.config.read().await.directories.roots.clone();
Ok(Json(
items
.into_iter()
.map(|item| MediaResponse::new(item, &roots))
.collect(),
))
}
pub async fn record_event(

View file

@ -194,8 +194,11 @@ pub async fn list_books(
)
.await?;
let response: Vec<MediaResponse> =
items.into_iter().map(MediaResponse::from).collect();
let roots = state.config.read().await.directories.roots.clone();
let response: Vec<MediaResponse> = items
.into_iter()
.map(|item| MediaResponse::new(item, &roots))
.collect();
Ok(Json(response))
}
@ -223,8 +226,11 @@ pub async fn get_series_books(
Path(series_name): Path<String>,
) -> Result<impl IntoResponse, ApiError> {
let items = state.storage.get_series_books(&series_name).await?;
let response: Vec<MediaResponse> =
items.into_iter().map(MediaResponse::from).collect();
let roots = state.config.read().await.directories.roots.clone();
let response: Vec<MediaResponse> = items
.into_iter()
.map(|item| MediaResponse::new(item, &roots))
.collect();
Ok(Json(response))
}
@ -258,8 +264,11 @@ pub async fn get_author_books(
.search_books(None, Some(&author_name), None, None, None, &pagination)
.await?;
let response: Vec<MediaResponse> =
items.into_iter().map(MediaResponse::from).collect();
let roots = state.config.read().await.directories.roots.clone();
let response: Vec<MediaResponse> = items
.into_iter()
.map(|item| MediaResponse::new(item, &roots))
.collect();
Ok(Json(response))
}
@ -317,8 +326,11 @@ pub async fn get_reading_list(
.get_reading_list(user_id.0, params.status)
.await?;
let response: Vec<MediaResponse> =
items.into_iter().map(MediaResponse::from).collect();
let roots = state.config.read().await.directories.roots.clone();
let response: Vec<MediaResponse> = items
.into_iter()
.map(|item| MediaResponse::new(item, &roots))
.collect();
Ok(Json(response))
}

View file

@ -126,5 +126,11 @@ pub async fn get_members(
let items =
pinakes_core::collections::get_members(&state.storage, collection_id)
.await?;
Ok(Json(items.into_iter().map(MediaResponse::from).collect()))
let roots = state.config.read().await.directories.roots.clone();
Ok(Json(
items
.into_iter()
.map(|item| MediaResponse::new(item, &roots))
.collect(),
))
}

View file

@ -10,6 +10,7 @@ pub async fn list_duplicates(
State(state): State<AppState>,
) -> Result<Json<Vec<DuplicateGroupResponse>>, ApiError> {
let groups = state.storage.find_duplicates().await?;
let roots = state.config.read().await.directories.roots.clone();
let response: Vec<DuplicateGroupResponse> = groups
.into_iter()
@ -18,8 +19,10 @@ pub async fn list_duplicates(
.first()
.map(|i| i.content_hash.0.clone())
.unwrap_or_default();
let media_items: Vec<MediaResponse> =
items.into_iter().map(MediaResponse::from).collect();
let media_items: Vec<MediaResponse> = items
.into_iter()
.map(|item| MediaResponse::new(item, &roots))
.collect();
DuplicateGroupResponse {
content_hash,
items: media_items,

View file

@ -120,7 +120,13 @@ pub async fn list_media(
params.sort,
);
let items = state.storage.list_media(&pagination).await?;
Ok(Json(items.into_iter().map(MediaResponse::from).collect()))
let roots = state.config.read().await.directories.roots.clone();
Ok(Json(
items
.into_iter()
.map(|item| MediaResponse::new(item, &roots))
.collect(),
))
}
pub async fn get_media(
@ -128,7 +134,8 @@ pub async fn get_media(
Path(id): Path<Uuid>,
) -> Result<Json<MediaResponse>, ApiError> {
let item = state.storage.get_media(MediaId(id)).await?;
Ok(Json(MediaResponse::from(item)))
let roots = state.config.read().await.directories.roots.clone();
Ok(Json(MediaResponse::new(item, &roots)))
}
/// Maximum length for short text fields (title, artist, album, genre).
@ -206,7 +213,8 @@ pub async fn update_media(
&serde_json::json!({"media_id": item.id.to_string()}),
);
Ok(Json(MediaResponse::from(item)))
let roots = state.config.read().await.directories.roots.clone();
Ok(Json(MediaResponse::new(item, &roots)))
}
pub async fn delete_media(
@ -574,12 +582,14 @@ pub async fn preview_directory(
}
}
let roots_for_walk = roots.clone();
let files: Vec<DirectoryPreviewFile> =
tokio::task::spawn_blocking(move || {
let mut result = Vec::new();
fn walk_dir(
dir: &std::path::Path,
recursive: bool,
roots: &[std::path::PathBuf],
result: &mut Vec<DirectoryPreviewFile>,
) {
let Ok(entries) = std::fs::read_dir(dir) else {
@ -596,7 +606,7 @@ pub async fn preview_directory(
}
if path.is_dir() {
if recursive {
walk_dir(&path, recursive, result);
walk_dir(&path, recursive, roots, result);
}
} else if path.is_file()
&& let Some(mt) =
@ -612,7 +622,7 @@ pub async fn preview_directory(
.and_then(|v| v.as_str().map(String::from))
.unwrap_or_default();
result.push(DirectoryPreviewFile {
path: path.to_string_lossy().to_string(),
path: crate::dto::relativize_path(&path, roots),
file_name,
media_type,
file_size: size,
@ -620,7 +630,7 @@ pub async fn preview_directory(
}
}
}
walk_dir(&dir, recursive, &mut result);
walk_dir(&dir, recursive, &roots_for_walk, &mut result);
result
})
.await
@ -948,7 +958,8 @@ pub async fn rename_media(
)
.await?;
Ok(Json(MediaResponse::from(item)))
let roots = state.config.read().await.directories.roots.clone();
Ok(Json(MediaResponse::new(item, &roots)))
}
pub async fn move_media_endpoint(
@ -994,7 +1005,8 @@ pub async fn move_media_endpoint(
)
.await?;
Ok(Json(MediaResponse::from(item)))
let roots = state.config.read().await.directories.roots.clone();
Ok(Json(MediaResponse::new(item, &roots)))
}
pub async fn batch_move_media(
@ -1144,7 +1156,8 @@ pub async fn restore_media(
&serde_json::json!({"media_id": media_id.to_string(), "restored": true}),
);
Ok(Json(MediaResponse::from(item)))
let roots = state.config.read().await.directories.roots.clone();
Ok(Json(MediaResponse::new(item, &roots)))
}
pub async fn list_trash(
@ -1159,9 +1172,13 @@ pub async fn list_trash(
let items = state.storage.list_trash(&pagination).await?;
let count = state.storage.count_trash().await?;
let roots = state.config.read().await.directories.roots.clone();
Ok(Json(TrashResponse {
items: items.into_iter().map(MediaResponse::from).collect(),
items: items
.into_iter()
.map(|item| MediaResponse::new(item, &roots))
.collect(),
total_count: count,
}))
}

View file

@ -121,13 +121,16 @@ pub async fn get_timeline(
}
// Convert to response format
let roots = state.config.read().await.directories.roots.clone();
let mut timeline: Vec<TimelineGroup> = groups
.into_iter()
.map(|(date, items)| {
let cover_id = items.first().map(|i| i.id.0.to_string());
let count = items.len();
let items: Vec<MediaResponse> =
items.into_iter().map(MediaResponse::from).collect();
let items: Vec<MediaResponse> = items
.into_iter()
.map(|item| MediaResponse::new(item, &roots))
.collect();
TimelineGroup {
date,

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,
@ -185,7 +187,13 @@ pub async fn list_items(
let user_id = resolve_user_id(&state.storage, &username).await?;
check_playlist_access(&state.storage, id, user_id, false).await?;
let items = state.storage.get_playlist_items(id).await?;
Ok(Json(items.into_iter().map(MediaResponse::from).collect()))
let roots = state.config.read().await.directories.roots.clone();
Ok(Json(
items
.into_iter()
.map(|item| MediaResponse::new(item, &roots))
.collect(),
))
}
pub async fn reorder_item(
@ -213,5 +221,11 @@ pub async fn shuffle_playlist(
use rand::seq::SliceRandom;
let mut items = state.storage.get_playlist_items(id).await?;
items.shuffle(&mut rand::rng());
Ok(Json(items.into_iter().map(MediaResponse::from).collect()))
let roots = state.config.read().await.directories.roots.clone();
Ok(Json(
items
.into_iter()
.map(|item| MediaResponse::new(item, &roots))
.collect(),
))
}

View file

@ -1,23 +1,39 @@
use std::{collections::HashMap, sync::Arc};
use axum::{
Json,
extract::{Path, State},
};
use pinakes_core::plugin::PluginManager;
use crate::{
dto::{InstallPluginRequest, PluginResponse, TogglePluginRequest},
dto::{
InstallPluginRequest,
PluginEventRequest,
PluginResponse,
PluginUiPageEntry,
PluginUiWidgetEntry,
TogglePluginRequest,
},
error::ApiError,
state::AppState,
};
fn require_plugin_manager(
state: &AppState,
) -> Result<Arc<PluginManager>, ApiError> {
state.plugin_manager.clone().ok_or_else(|| {
ApiError(pinakes_core::error::PinakesError::InvalidOperation(
"Plugin system is not enabled".to_string(),
))
})
}
/// List all installed plugins
pub async fn list_plugins(
State(state): State<AppState>,
) -> Result<Json<Vec<PluginResponse>>, ApiError> {
let plugin_manager = state.plugin_manager.as_ref().ok_or_else(|| {
ApiError(pinakes_core::error::PinakesError::InvalidOperation(
"Plugin system is not enabled".to_string(),
))
})?;
let plugin_manager = require_plugin_manager(&state)?;
let plugins = plugin_manager.list_plugins().await;
let mut responses = Vec::with_capacity(plugins.len());
@ -33,11 +49,7 @@ pub async fn get_plugin(
State(state): State<AppState>,
Path(id): Path<String>,
) -> Result<Json<PluginResponse>, ApiError> {
let plugin_manager = state.plugin_manager.as_ref().ok_or_else(|| {
ApiError(pinakes_core::error::PinakesError::InvalidOperation(
"Plugin system is not enabled".to_string(),
))
})?;
let plugin_manager = require_plugin_manager(&state)?;
let plugin = plugin_manager.get_plugin(&id).await.ok_or_else(|| {
ApiError(pinakes_core::error::PinakesError::NotFound(format!(
@ -54,11 +66,7 @@ pub async fn install_plugin(
State(state): State<AppState>,
Json(req): Json<InstallPluginRequest>,
) -> Result<Json<PluginResponse>, ApiError> {
let plugin_manager = state.plugin_manager.as_ref().ok_or_else(|| {
ApiError(pinakes_core::error::PinakesError::InvalidOperation(
"Plugin system is not enabled".to_string(),
))
})?;
let plugin_manager = require_plugin_manager(&state)?;
let plugin_id =
plugin_manager
@ -86,11 +94,7 @@ pub async fn uninstall_plugin(
State(state): State<AppState>,
Path(id): Path<String>,
) -> Result<Json<serde_json::Value>, ApiError> {
let plugin_manager = state.plugin_manager.as_ref().ok_or_else(|| {
ApiError(pinakes_core::error::PinakesError::InvalidOperation(
"Plugin system is not enabled".to_string(),
))
})?;
let plugin_manager = require_plugin_manager(&state)?;
plugin_manager.uninstall_plugin(&id).await.map_err(|e| {
ApiError(pinakes_core::error::PinakesError::InvalidOperation(
@ -107,11 +111,7 @@ pub async fn toggle_plugin(
Path(id): Path<String>,
Json(req): Json<TogglePluginRequest>,
) -> Result<Json<serde_json::Value>, ApiError> {
let plugin_manager = state.plugin_manager.as_ref().ok_or_else(|| {
ApiError(pinakes_core::error::PinakesError::InvalidOperation(
"Plugin system is not enabled".to_string(),
))
})?;
let plugin_manager = require_plugin_manager(&state)?;
if req.enabled {
plugin_manager.enable_plugin(&id).await.map_err(|e| {
@ -144,16 +144,67 @@ pub async fn toggle_plugin(
})))
}
/// List all UI pages provided by loaded plugins
pub async fn list_plugin_ui_pages(
State(state): State<AppState>,
) -> Result<Json<Vec<PluginUiPageEntry>>, ApiError> {
let plugin_manager = require_plugin_manager(&state)?;
let pages = plugin_manager.list_ui_pages_with_endpoints().await;
let entries = pages
.into_iter()
.map(|(plugin_id, page, allowed_endpoints)| {
PluginUiPageEntry {
plugin_id,
page,
allowed_endpoints,
}
})
.collect();
Ok(Json(entries))
}
/// List all UI widgets provided by loaded plugins
pub async fn list_plugin_ui_widgets(
State(state): State<AppState>,
) -> Result<Json<Vec<PluginUiWidgetEntry>>, ApiError> {
let plugin_manager = require_plugin_manager(&state)?;
let widgets = plugin_manager.list_ui_widgets().await;
let entries = widgets
.into_iter()
.map(|(plugin_id, widget)| PluginUiWidgetEntry { plugin_id, widget })
.collect();
Ok(Json(entries))
}
/// Receive a plugin event emitted from the UI and dispatch it to interested
/// server-side event-handler plugins via the pipeline.
pub async fn emit_plugin_event(
State(state): State<AppState>,
Json(req): Json<PluginEventRequest>,
) -> Result<Json<serde_json::Value>, ApiError> {
tracing::info!(event = %req.event, "plugin UI event received");
state.emit_plugin_event(&req.event, &req.payload);
Ok(Json(
serde_json::json!({ "received": true, "event": req.event }),
))
}
/// List merged CSS custom property overrides from all enabled plugins
pub async fn list_plugin_ui_theme_extensions(
State(state): State<AppState>,
) -> Result<Json<HashMap<String, String>>, ApiError> {
let plugin_manager = require_plugin_manager(&state)?;
Ok(Json(plugin_manager.list_ui_theme_extensions().await))
}
/// Reload a plugin (for development)
pub async fn reload_plugin(
State(state): State<AppState>,
Path(id): Path<String>,
) -> Result<Json<serde_json::Value>, ApiError> {
let plugin_manager = state.plugin_manager.as_ref().ok_or_else(|| {
ApiError(pinakes_core::error::PinakesError::InvalidOperation(
"Plugin system is not enabled".to_string(),
))
})?;
let plugin_manager = require_plugin_manager(&state)?;
plugin_manager.reload_plugin(&id).await.map_err(|e| {
ApiError(pinakes_core::error::PinakesError::InvalidOperation(

View file

@ -51,9 +51,14 @@ pub async fn search(
};
let results = state.storage.search(&request).await?;
let roots = state.config.read().await.directories.roots.clone();
Ok(Json(SearchResponse {
items: results.items.into_iter().map(MediaResponse::from).collect(),
items: results
.items
.into_iter()
.map(|item| MediaResponse::new(item, &roots))
.collect(),
total_count: results.total_count,
}))
}
@ -84,9 +89,14 @@ pub async fn search_post(
};
let results = state.storage.search(&request).await?;
let roots = state.config.read().await.directories.roots.clone();
Ok(Json(SearchResponse {
items: results.items.into_iter().map(MediaResponse::from).collect(),
items: results
.items
.into_iter()
.map(|item| MediaResponse::new(item, &roots))
.collect(),
total_count: results.total_count,
}))
}

View file

@ -506,6 +506,7 @@ pub async fn access_shared(
let _ = state.storage.record_share_activity(&activity).await;
// Return the shared content
let roots = state.config.read().await.directories.roots.clone();
match &share.target {
ShareTarget::Media { media_id } => {
let item = state
@ -514,8 +515,8 @@ pub async fn access_shared(
.await
.map_err(|e| ApiError::not_found(format!("Media not found: {e}")))?;
Ok(Json(SharedContentResponse::Single(MediaResponse::from(
item,
Ok(Json(SharedContentResponse::Single(MediaResponse::new(
item, &roots,
))))
},
ShareTarget::Collection { collection_id } => {
@ -527,8 +528,10 @@ pub async fn access_shared(
ApiError::not_found(format!("Collection not found: {e}"))
})?;
let items: Vec<MediaResponse> =
members.into_iter().map(MediaResponse::from).collect();
let items: Vec<MediaResponse> = members
.into_iter()
.map(|item| MediaResponse::new(item, &roots))
.collect();
Ok(Json(SharedContentResponse::Multiple { items }))
},
@ -553,8 +556,11 @@ pub async fn access_shared(
.await
.map_err(|e| ApiError::internal(format!("Search failed: {e}")))?;
let items: Vec<MediaResponse> =
results.items.into_iter().map(MediaResponse::from).collect();
let items: Vec<MediaResponse> = results
.items
.into_iter()
.map(|item| MediaResponse::new(item, &roots))
.collect();
Ok(Json(SharedContentResponse::Multiple { items }))
},
@ -585,8 +591,11 @@ pub async fn access_shared(
.await
.map_err(|e| ApiError::internal(format!("Search failed: {e}")))?;
let items: Vec<MediaResponse> =
results.items.into_iter().map(MediaResponse::from).collect();
let items: Vec<MediaResponse> = results
.items
.into_iter()
.map(|item| MediaResponse::new(item, &roots))
.collect();
Ok(Json(SharedContentResponse::Multiple { items }))
},

View file

@ -125,7 +125,13 @@ pub async fn list_favorites(
.storage
.get_user_favorites(user_id, &Pagination::default())
.await?;
Ok(Json(items.into_iter().map(MediaResponse::from).collect()))
let roots = state.config.read().await.directories.roots.clone();
Ok(Json(
items
.into_iter()
.map(|item| MediaResponse::new(item, &roots))
.collect(),
))
}
pub async fn create_share_link(
@ -205,5 +211,6 @@ pub async fn access_shared_media(
}
state.storage.increment_share_views(&token).await?;
let item = state.storage.get_media(link.media_id).await?;
Ok(Json(MediaResponse::from(item)))
let roots = state.config.read().await.directories.roots.clone();
Ok(Json(MediaResponse::new(item, &roots)))
}

View file

@ -15,6 +15,7 @@ tracing = { workspace = true }
tracing-subscriber = { workspace = true }
reqwest = { workspace = true }
dioxus = { workspace = true }
dioxus-core = { workspace = true }
tokio = { workspace = true }
futures = { workspace = true }
rfd = { workspace = true }
@ -26,6 +27,7 @@ dioxus-free-icons = { workspace = true }
gloo-timers = { workspace = true }
rand = { workspace = true }
urlencoding = { workspace = true }
pinakes-plugin-api = { workspace = true }
[lints]
workspace = true

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,788 @@
@use 'variables' as *;
@use 'mixins' as *;
// Plugin UI renderer layout classes.
//
// Dynamic values are passed via CSS custom properties set on the element.
// The layout rules here consume those properties via var() so the renderer
// never injects full CSS rule strings.
// Page wrapper
.plugin-page {
padding: $space-8 $space-12;
max-width: 100%;
overflow-x: hidden;
}
.plugin-page-title {
font-size: $font-size-xl;
font-weight: $font-weight-semibold;
color: $text-0;
margin: 0 0 $space-8;
}
// Container: vertical flex column with configurable gap and padding.
.plugin-container {
display: flex;
flex-direction: column;
gap: var(--plugin-gap, 0px);
padding: var(--plugin-padding, 0);
}
// Grid: CSS grid with a configurable column count and gap.
.plugin-grid {
display: grid;
grid-template-columns: repeat(var(--plugin-columns, 1), 1fr);
gap: var(--plugin-gap, 0px);
}
// Flex: display:flex driven by data-* attribute selectors.
// The gap is a CSS custom property; direction/justify/align/wrap are
// plain enum strings placed in data attributes by the renderer.
.plugin-flex {
display: flex;
gap: var(--plugin-gap, 0px);
&[data-direction='row'] { flex-direction: row; }
&[data-direction='column'] { flex-direction: column; }
&[data-justify='flex-start'] { justify-content: flex-start; }
&[data-justify='flex-end'] { justify-content: flex-end; }
&[data-justify='center'] { justify-content: center; }
&[data-justify='space-between'] { justify-content: space-between; }
&[data-justify='space-around'] { justify-content: space-around; }
&[data-justify='space-evenly'] { justify-content: space-evenly; }
&[data-align='flex-start'] { align-items: flex-start; }
&[data-align='flex-end'] { align-items: flex-end; }
&[data-align='center'] { align-items: center; }
&[data-align='stretch'] { align-items: stretch; }
&[data-align='baseline'] { align-items: baseline; }
&[data-wrap='wrap'] { flex-wrap: wrap; }
&[data-wrap='nowrap'] { flex-wrap: nowrap; }
}
// Split: side-by-side sidebar + main area.
.plugin-split {
display: flex;
}
// Sidebar width is driven by --plugin-sidebar-width.
.plugin-split-sidebar {
width: var(--plugin-sidebar-width, 200px);
flex-shrink: 0;
}
.plugin-split-main {
flex: 1;
min-width: 0;
}
// Card
.plugin-card {
background: $bg-2;
border: 1px solid $border;
border-radius: $radius-md;
overflow: hidden;
}
.plugin-card-header {
padding: $space-6 $space-8;
font-size: $font-size-md;
font-weight: $font-weight-semibold;
color: $text-0;
border-bottom: 1px solid $border;
background: $bg-3;
}
.plugin-card-content {
padding: $space-8;
}
.plugin-card-footer {
padding: $space-6 $space-8;
border-top: 1px solid $border;
background: $bg-1;
}
// Typography
.plugin-heading {
color: $text-0;
margin: 0;
line-height: $line-height-tight;
&.level-1 { font-size: $font-size-6xl; font-weight: $font-weight-bold; }
&.level-2 { font-size: $font-size-4xl; font-weight: $font-weight-semibold; }
&.level-3 { font-size: $font-size-3xl; font-weight: $font-weight-semibold; }
&.level-4 { font-size: $font-size-xl; font-weight: $font-weight-medium; }
&.level-5 { font-size: $font-size-lg; font-weight: $font-weight-medium; }
&.level-6 { font-size: $font-size-md; font-weight: $font-weight-medium; }
}
.plugin-text {
margin: 0;
font-size: $font-size-md;
color: $text-0;
line-height: $line-height-normal;
&.text-secondary { color: $text-1; }
&.text-error { color: $error-text; }
&.text-success { color: $success; }
&.text-warning { color: $warning; }
&.text-bold { font-weight: $font-weight-semibold; }
&.text-italic { font-style: italic; }
&.text-small { font-size: $font-size-sm; }
&.text-large { font-size: $font-size-2xl; }
}
.plugin-code {
background: $bg-1;
border: 1px solid $border;
border-radius: $radius;
padding: $space-8 $space-12;
font-family: $font-family-mono;
font-size: $font-size-md;
color: $text-0;
overflow-x: auto;
white-space: pre;
code {
font-family: inherit;
font-size: inherit;
color: inherit;
}
}
// Tabs
.plugin-tabs {
display: flex;
flex-direction: column;
}
.plugin-tab-list {
display: flex;
gap: 2px;
border-bottom: 1px solid $border;
margin-bottom: $space-8;
}
.plugin-tab {
padding: $space-4 $space-10;
font-size: $font-size-md;
font-weight: $font-weight-medium;
color: $text-1;
background: transparent;
border: none;
border-bottom: 2px solid transparent;
cursor: pointer;
transition: color $transition-base, border-color $transition-base;
&:hover {
color: $text-0;
}
&.active {
color: $accent-text;
border-bottom-color: $accent;
}
.tab-icon {
margin-right: $space-2;
}
}
.plugin-tab-panels {}
.plugin-tab-panel {
&:not(.active) { display: none; }
}
// Description list
.plugin-description-list-wrapper {
width: 100%;
}
.plugin-description-list {
display: grid;
grid-template-columns: max-content 1fr;
gap: $space-2 $space-8;
margin: 0;
padding: 0;
dt {
font-size: $font-size-sm;
font-weight: $font-weight-medium;
color: $text-1;
text-transform: uppercase;
letter-spacing: $letter-spacing-uppercase;
padding: $space-3 0;
white-space: nowrap;
}
dd {
font-size: $font-size-md;
color: $text-0;
padding: $space-3 0;
margin: 0;
word-break: break-word;
}
&.horizontal {
display: flex;
flex-wrap: wrap;
gap: $space-8 $space-12;
dt {
width: auto;
padding: 0;
}
dd {
width: auto;
padding: 0;
}
// Pair dt+dd side by side
dt, dd {
display: inline;
}
// Each dt/dd pair sits in its own flex group via a wrapper approach.
// Since we can't group them, use a two-column repeat trick instead.
display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
dt {
font-size: $font-size-xs;
text-transform: uppercase;
letter-spacing: $letter-spacing-uppercase;
color: $text-2;
margin-bottom: $space-1;
}
dd {
font-size: $font-size-lg;
font-weight: $font-weight-semibold;
color: $text-0;
}
}
}
// Data table
.plugin-data-table-wrapper {
overflow-x: auto;
}
.plugin-data-table {
width: 100%;
border-collapse: collapse;
font-size: $font-size-md;
thead {
tr {
border-bottom: 1px solid $border-strong;
}
th {
padding: $space-4 $space-6;
text-align: left;
font-size: $font-size-sm;
font-weight: $font-weight-semibold;
color: $text-1;
text-transform: uppercase;
letter-spacing: $letter-spacing-uppercase;
white-space: nowrap;
}
}
tbody {
tr {
border-bottom: 1px solid $border-subtle;
transition: background $transition-fast;
&:hover {
background: $overlay-light;
}
&:last-child {
border-bottom: none;
}
}
td {
padding: $space-4 $space-6;
color: $text-0;
vertical-align: middle;
}
}
}
// Table column with a plugin-specified fixed width.
.plugin-col-constrained {
width: var(--plugin-col-width);
}
.table-filter {
margin-bottom: $space-6;
input {
width: 240px;
padding: $space-3 $space-6;
background: $bg-1;
border: 1px solid $border;
border-radius: $radius;
color: $text-0;
font-size: $font-size-md;
&::placeholder { color: $text-2; }
&:focus {
outline: none;
border-color: $accent;
}
}
}
.table-pagination {
display: flex;
align-items: center;
gap: $space-6;
padding: $space-4 0;
font-size: $font-size-md;
color: $text-1;
}
.row-actions {
white-space: nowrap;
width: 1%;
.plugin-button {
padding: $space-2 $space-4;
font-size: $font-size-sm;
margin-right: $space-2;
}
}
// Media grid: reuses column/gap variables from plugin-grid.
.plugin-media-grid {
display: grid;
grid-template-columns: repeat(var(--plugin-columns, 2), 1fr);
gap: var(--plugin-gap, 8px);
}
.media-grid-item {
background: $bg-2;
border: 1px solid $border;
border-radius: $radius-md;
overflow: hidden;
display: flex;
flex-direction: column;
}
.media-grid-img {
width: 100%;
aspect-ratio: 16 / 9;
object-fit: cover;
display: block;
}
.media-grid-no-img {
width: 100%;
aspect-ratio: 16 / 9;
background: $bg-3;
display: flex;
align-items: center;
justify-content: center;
font-size: $font-size-sm;
color: $text-2;
}
.media-grid-caption {
padding: $space-4 $space-6;
font-size: $font-size-sm;
color: $text-0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
// List
.plugin-list-wrapper {}
.plugin-list {
list-style: none;
margin: 0;
padding: 0;
}
.plugin-list-item {
padding: $space-4 0;
}
.plugin-list-divider {
border: none;
border-top: 1px solid $border-subtle;
margin: 0;
}
.plugin-list-empty {
padding: $space-8;
text-align: center;
color: $text-2;
font-size: $font-size-md;
}
// Interactive: buttons
.plugin-button {
display: inline-flex;
align-items: center;
gap: $space-3;
padding: $space-4 $space-8;
border: 1px solid $border;
border-radius: $radius;
font-size: $font-size-md;
font-weight: $font-weight-medium;
cursor: pointer;
transition: background $transition-fast, border-color $transition-fast,
color $transition-fast;
background: $bg-2;
color: $text-0;
&:disabled {
opacity: 0.45;
cursor: not-allowed;
}
&.btn-primary {
background: $accent;
border-color: $accent;
color: #fff;
&:hover:not(:disabled) { background: $accent-hover; }
}
&.btn-secondary {
background: $bg-3;
border-color: $border-strong;
color: $text-0;
&:hover:not(:disabled) { background: $overlay-medium; }
}
&.btn-tertiary {
background: transparent;
border-color: transparent;
color: $accent-text;
&:hover:not(:disabled) { background: $accent-dim; }
}
&.btn-danger {
background: transparent;
border-color: $error-border;
color: $error-text;
&:hover:not(:disabled) { background: $error-bg; }
}
&.btn-success {
background: transparent;
border-color: $success-border;
color: $success;
&:hover:not(:disabled) { background: $success-bg; }
}
&.btn-ghost {
background: transparent;
border-color: transparent;
color: $text-1;
&:hover:not(:disabled) { background: $btn-ghost-hover; }
}
}
// Badges
.plugin-badge {
display: inline-flex;
align-items: center;
padding: $space-1 $space-4;
border-radius: $radius-full;
font-size: $font-size-xs;
font-weight: $font-weight-semibold;
letter-spacing: $letter-spacing-uppercase;
text-transform: uppercase;
&.badge-default, &.badge-neutral {
background: $overlay-medium;
color: $text-1;
}
&.badge-primary {
background: $accent-dim;
color: $accent-text;
}
&.badge-secondary {
background: $overlay-light;
color: $text-0;
}
&.badge-success {
background: $success-bg;
color: $success;
}
&.badge-warning {
background: $warning-bg;
color: $warning;
}
&.badge-error {
background: $error-bg;
color: $error-text;
}
&.badge-info {
background: $info-bg;
color: $accent-text;
}
}
// Form
.plugin-form {
display: flex;
flex-direction: column;
gap: $space-8;
}
.form-field {
display: flex;
flex-direction: column;
gap: $space-3;
label {
font-size: $font-size-md;
font-weight: $font-weight-medium;
color: $text-0;
}
input, textarea, select {
padding: $space-4 $space-6;
background: $bg-1;
border: 1px solid $border;
border-radius: $radius;
color: $text-0;
font-size: $font-size-md;
font-family: inherit;
&::placeholder { color: $text-2; }
&:focus {
outline: none;
border-color: $accent;
box-shadow: 0 0 0 2px $accent-dim;
}
}
textarea {
min-height: 80px;
resize: vertical;
}
select {
appearance: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='8' viewBox='0 0 12 8'%3E%3Cpath d='M1 1l5 5 5-5' stroke='%23a0a0b8' stroke-width='1.5' fill='none' stroke-linecap='round'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right $space-6 center;
padding-right: $space-16;
}
}
.form-help {
margin: 0;
font-size: $font-size-sm;
color: $text-2;
}
.form-actions {
display: flex;
gap: $space-6;
padding-top: $space-4;
}
.required {
color: $error;
}
// Link
.plugin-link {
color: $accent-text;
text-decoration: none;
&:hover { text-decoration: underline; }
}
.plugin-link-blocked {
color: $text-2;
text-decoration: line-through;
cursor: not-allowed;
}
// Progress
.plugin-progress {
background: $bg-1;
border: 1px solid $border;
border-radius: $radius;
height: 8px;
overflow: hidden;
display: flex;
align-items: center;
gap: $space-4;
}
.plugin-progress-bar {
height: 100%;
background: $accent;
border-radius: 4px;
transition: width 0.3s ease;
width: var(--plugin-progress, 0%);
}
.plugin-progress-label {
font-size: $font-size-sm;
color: $text-1;
white-space: nowrap;
flex-shrink: 0;
}
// Chart wrapper: height is driven by --plugin-chart-height.
.plugin-chart {
overflow: auto;
height: var(--plugin-chart-height, 200px);
.chart-title {
font-size: $font-size-lg;
font-weight: $font-weight-semibold;
color: $text-0;
margin-bottom: $space-4;
}
.chart-x-label, .chart-y-label {
font-size: $font-size-sm;
color: $text-2;
margin-bottom: $space-2;
}
.chart-data-table {
overflow-x: auto;
}
.chart-no-data {
padding: $space-12;
text-align: center;
color: $text-2;
font-size: $font-size-md;
}
}
// Loading / error states
.plugin-loading {
padding: $space-8;
color: $text-1;
font-size: $font-size-md;
font-style: italic;
}
.plugin-error {
padding: $space-6 $space-8;
background: $error-bg;
border: 1px solid $error-border;
border-radius: $radius;
color: $error-text;
font-size: $font-size-md;
}
// Feedback toast
.plugin-feedback {
position: sticky;
bottom: $space-8;
display: flex;
align-items: center;
justify-content: space-between;
gap: $space-8;
padding: $space-6 $space-8;
border-radius: $radius-md;
font-size: $font-size-md;
z-index: $z-toast;
box-shadow: $shadow-lg;
&.success {
background: $success-bg;
border: 1px solid $success-border;
color: $success;
}
&.error {
background: $error-bg;
border: 1px solid $error-border;
color: $error-text;
}
}
.plugin-feedback-dismiss {
background: transparent;
border: none;
color: inherit;
font-size: $font-size-xl;
cursor: pointer;
line-height: 1;
padding: 0;
opacity: 0.7;
&:hover { opacity: 1; }
}
// Modal
.plugin-modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.65);
display: flex;
align-items: center;
justify-content: center;
z-index: $z-modal-backdrop;
}
.plugin-modal {
position: relative;
background: $bg-2;
border: 1px solid $border-strong;
border-radius: $radius-xl;
padding: $space-16;
min-width: 380px;
max-width: 640px;
max-height: 80vh;
overflow-y: auto;
box-shadow: $shadow-lg;
z-index: $z-modal;
}
.plugin-modal-close {
position: absolute;
top: $space-8;
right: $space-8;
background: transparent;
border: none;
color: $text-1;
font-size: $font-size-xl;
cursor: pointer;
line-height: 1;
padding: $space-2;
border-radius: $radius;
&:hover {
background: $overlay-medium;
color: $text-0;
}
}

View file

@ -11,3 +11,4 @@
@use 'audit';
@use 'graph';
@use 'themes';
@use 'plugins';

View file

@ -59,6 +59,12 @@ use crate::{
tags,
tasks,
},
plugin_ui::{
PluginRegistry,
PluginViewRenderer,
WidgetContainer,
WidgetLocation,
},
styles,
};
@ -80,6 +86,10 @@ enum View {
Settings,
Database,
Graph,
PluginView {
plugin_id: String,
page_id: String,
},
}
impl View {
@ -99,13 +109,48 @@ impl View {
Self::Settings => "Settings",
Self::Database => "Database",
Self::Graph => "Note Graph",
Self::PluginView { .. } => "Plugin",
}
}
}
/// Parse a route string from a plugin `navigate_to` action into an app
/// [`View`].
///
/// Supports all built-in routes (`/library`, `/search`, etc.) and plugin pages
/// (`/plugins/{plugin_id}/{page_id}`). Unknown routes fall back to Library with
/// a warning log.
fn parse_plugin_route(route: &str) -> View {
let parts: Vec<&str> = route.trim_start_matches('/').split('/').collect();
match parts.as_slice() {
[] | [""] | ["library"] => View::Library,
["search"] => View::Search,
["settings"] => View::Settings,
["tags"] => View::Tags,
["collections"] => View::Collections,
["books"] => View::Books,
["audit"] => View::Audit,
["import"] => View::Import,
["duplicates"] => View::Duplicates,
["statistics"] => View::Statistics,
["tasks"] => View::Tasks,
["database"] => View::Database,
["graph"] => View::Graph,
["plugins", plugin_id, page_id] => {
View::PluginView {
plugin_id: plugin_id.to_string(),
page_id: page_id.to_string(),
}
},
_ => {
tracing::warn!(route = %route, "Unknown navigation route from plugin action");
View::Library
},
}
}
#[component]
pub fn App() -> Element {
// Phase 1.3: Auth support
let base_url = std::env::var("PINAKES_SERVER_URL")
.unwrap_or_else(|_| "http://localhost:3000".into());
let api_key = std::env::var("PINAKES_API_KEY").ok();
@ -139,7 +184,6 @@ pub fn App() -> Element {
let mut viewing_collection = use_signal(|| Option::<String>::None);
let mut collection_members = use_signal(Vec::<MediaResponse>::new);
// Phase 4A: Book management
let mut books_list = use_signal(Vec::<MediaResponse>::new);
let mut books_series_list =
use_signal(Vec::<crate::client::SeriesSummary>::new);
@ -160,31 +204,24 @@ pub fn App() -> Element {
let mut loading = use_signal(|| true);
let mut load_error = use_signal(|| Option::<String>::None);
// Phase 1.4: Toast queue
let mut toast_queue = use_signal(Vec::<(String, bool, usize)>::new);
// Phase 5.1: Search pagination
let mut search_page = use_signal(|| 0u64);
let search_page_size = use_signal(|| 50u64);
let mut last_search_query = use_signal(String::new);
let mut last_search_sort = use_signal(|| Option::<String>::None);
// Phase 3.6: Saved searches
let mut saved_searches = use_signal(Vec::<SavedSearchResponse>::new);
// Phase 6.1: Audit pagination & filter
let mut audit_page = use_signal(|| 0u64);
let audit_page_size = use_signal(|| 200u64);
let audit_total_count = use_signal(|| 0u64);
let mut audit_filter = use_signal(|| "All".to_string());
// Phase 6.2: Scan progress
let mut scan_progress = use_signal(|| Option::<ScanStatusResponse>::None);
// Phase 7.1: Help overlay
let mut show_help = use_signal(|| false);
// Phase 8: Sidebar collapse
let mut sidebar_collapsed = use_signal(|| false);
// Auth state
@ -195,7 +232,9 @@ pub fn App() -> Element {
let mut auto_play_media = use_signal(|| false);
let mut play_queue = use_signal(PlayQueue::default);
// Theme state (Phase 3.3)
let mut plugin_registry = use_signal(PluginRegistry::default);
let all_widgets = use_memo(move || plugin_registry.read().all_widgets());
let mut current_theme = use_signal(|| "dark".to_string());
let mut system_prefers_dark = use_signal(|| true);
@ -287,7 +326,7 @@ pub fn App() -> Element {
});
});
// Load initial data (Phase 2.2: pass sort to list_media)
// Load initial data
let client_init = client.read().clone();
let init_sort = media_sort.read().clone();
use_effect(move || {
@ -311,7 +350,6 @@ pub fn App() -> Element {
if let Ok(c) = client.list_collections().await {
collections_list.set(c);
}
// Phase 3.6: Load saved searches
if let Ok(ss) = client.list_saved_searches().await {
saved_searches.set(ss);
}
@ -319,7 +357,36 @@ pub fn App() -> Element {
});
});
// Phase 1.4: Toast helper with queue support
use_effect(move || {
let c = client.read().clone();
spawn(async move {
let mut reg = PluginRegistry::new(c.clone());
match reg.refresh().await {
Ok(()) => {
let vars = reg.theme_vars().clone();
plugin_registry.set(reg);
if !vars.is_empty() {
spawn(async move {
let js: String = vars
.iter()
.filter_map(|(k, v)| {
let k_js = serde_json::to_string(k).ok()?;
let v_js = serde_json::to_string(v).ok()?;
Some(format!(
"document.documentElement.style.setProperty({k_js},\
{v_js});"
))
})
.collect();
let _ = document::eval(&js).await;
});
}
},
Err(e) => tracing::debug!("Plugin UI unavailable: {e}"),
}
});
});
let mut show_toast = move |msg: String, is_error: bool| {
let id = TOAST_ID_COUNTER.fetch_add(1, Ordering::Relaxed);
toast_queue.write().push((msg, is_error, id));
@ -334,7 +401,6 @@ pub fn App() -> Element {
});
};
// Helper: refresh media list with current pagination (Phase 2.2: pass sort)
let refresh_media = {
let client = client.read().clone();
move || {
@ -355,7 +421,6 @@ pub fn App() -> Element {
}
};
// Helper: refresh tags
let refresh_tags = {
let client = client.read().clone();
move || {
@ -368,7 +433,6 @@ pub fn App() -> Element {
}
};
// Helper: refresh collections
let refresh_collections = {
let client = client.read().clone();
move || {
@ -381,7 +445,6 @@ pub fn App() -> Element {
}
};
// Helper: refresh audit with pagination and filter (Phase 6.1)
let refresh_audit = {
let client = client.read().clone();
move || {
@ -423,7 +486,19 @@ pub fn App() -> Element {
}
};
let view_title = use_memo(move || current_view.read().title());
let view_title = use_memo(move || {
let view = current_view.read();
match &*view {
View::PluginView { plugin_id, page_id } => {
plugin_registry
.read()
.get_page(plugin_id, page_id)
.map(|p| p.page.title.clone())
.unwrap_or_else(|| "Plugin".to_string())
},
v => v.title().to_string(),
}
});
let _total_pages = use_memo(move || {
let ps = *media_page_size.read();
let tc = *media_total_count.read();
@ -440,7 +515,6 @@ pub fn App() -> Element {
loading: *login_loading.read(),
}
} else {
// Phase 7.1: Keyboard shortcuts
div {
class: if *effective_theme.read() == "light" { "app theme-light" } else { "app" },
tabindex: "0",
@ -744,6 +818,42 @@ pub fn App() -> Element {
}
}
if !plugin_registry.read().is_empty() {
div { class: "nav-section",
div { class: "nav-label",
"Plugins"
span { class: "nav-label-count", " ({plugin_registry.read().len()})" }
}
for (pid, pageid, route) in plugin_registry.read().routes() {
{
let title = plugin_registry
.read()
.get_page(&pid, &pageid)
.map(|p| p.page.title.clone())
.unwrap_or_default();
let is_active = *current_view.read()
== View::PluginView {
plugin_id: pid.clone(),
page_id: pageid.clone(),
};
rsx! {
button {
class: if is_active { "nav-item active" } else { "nav-item" },
title: "{route}",
onclick: move |_| {
current_view.set(View::PluginView {
plugin_id: pid.clone(),
page_id: pageid.clone(),
});
},
span { class: "nav-item-text", "{title}" }
}
}
}
}
}
}
div { class: "sidebar-spacer" }
// Show import progress in sidebar when not on import page
@ -881,22 +991,16 @@ pub fn App() -> Element {
}
{
// Phase 2.2: Sort wiring - actually refetch with sort
// Phase 4.1 + 4.2: Search improvements
// Phase 3.1 + 3.2: Detail view enhancements
// Phase 3.2: Delete from detail navigates back and refreshes
// Phase 5.1: Tags on_delete - confirmation handled inside Tags component
// Phase 5.2: Collections enhancements
// Phase 5.2: Navigate to detail when clicking a collection member
// Phase 5.2: Add member to collection
// Phase 6.1: Audit improvements
// Phase 6.2: Scan progress
// Phase 6.2: Scan with polling for progress
// Poll scan status until done
// Refresh duplicates list
// Reload full config
match *current_view.read() {
View::Library => rsx! {
WidgetContainer {
location: WidgetLocation::LibraryHeader,
widgets: all_widgets.read().clone(),
client,
}
div { class: "stats-grid",
div { class: "stat-card",
div { class: "stat-value", "{media_total_count}" }
@ -911,6 +1015,11 @@ pub fn App() -> Element {
div { class: "stat-label", "Collections" }
}
}
WidgetContainer {
location: WidgetLocation::LibrarySidebar,
widgets: all_widgets.read().clone(),
client,
}
library::Library {
media: media_list.read().clone(),
tags: tags_list.read().clone(),
@ -1107,6 +1216,11 @@ pub fn App() -> Element {
}
},
View::Search => rsx! {
WidgetContainer {
location: WidgetLocation::SearchFilters,
widgets: all_widgets.read().clone(),
client,
}
search::Search {
results: search_results.read().clone(),
total_count: *search_total.read(),
@ -1178,7 +1292,6 @@ pub fn App() -> Element {
});
}
},
// Phase 3.6: Saved searches
saved_searches: saved_searches.read().clone(),
on_save_search: {
let client = client.read().clone();
@ -1240,6 +1353,11 @@ pub fn App() -> Element {
let media_ref = selected_media.read();
match media_ref.as_ref() {
Some(media) => rsx! {
WidgetContainer {
location: WidgetLocation::DetailPanel,
widgets: all_widgets.read().clone(),
client,
}
detail::Detail {
media: media.clone(),
media_tags: media_tags.read().clone(),
@ -2533,6 +2651,11 @@ pub fn App() -> Element {
let cfg_ref = config_data.read();
match cfg_ref.as_ref() {
Some(cfg) => rsx! {
WidgetContainer {
location: WidgetLocation::SettingsSection,
widgets: all_widgets.read().clone(),
client,
}
settings::Settings {
config: cfg.clone(),
on_add_root: {
@ -2673,12 +2796,39 @@ pub fn App() -> Element {
}
}
}
View::PluginView {
ref plugin_id,
ref page_id,
} => {
let pid = plugin_id.clone();
let pageid = page_id.clone();
let page_opt =
plugin_registry.read().get_page(&pid, &pageid).cloned();
match page_opt {
Some(plugin_page) => rsx! {
PluginViewRenderer {
plugin_id: pid,
page: plugin_page.page,
client,
allowed_endpoints: plugin_page.allowed_endpoints.clone(),
on_navigate: move |route: String| {
current_view
.set(parse_plugin_route(&route));
},
}
},
None => rsx! {
div { class: "plugin-not-found",
"Plugin page not found: {pageid}"
}
},
}
}
}
}
}
}
// Phase 7.1: Help overlay
if *show_help.read() {
div {
class: "help-overlay",
@ -2747,7 +2897,6 @@ pub fn App() -> Element {
}
} // end else (auth not required)
// Phase 1.4: Toast queue - show up to 3 stacked from bottom
div { class: "toast-container",
{
let toasts = toast_queue.read().clone();

View file

@ -19,12 +19,28 @@ pub struct MediaUpdateEvent {
pub description: Option<String>,
}
#[derive(Clone)]
pub struct ApiClient {
client: Client,
base_url: String,
}
impl Clone for ApiClient {
fn clone(&self) -> Self {
Self {
client: self.client.clone(),
base_url: self.base_url.clone(),
}
}
}
impl std::fmt::Debug for ApiClient {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("ApiClient")
.field("base_url", &self.base_url)
.finish()
}
}
impl PartialEq for ApiClient {
fn eq(&self, other: &Self) -> bool {
self.base_url == other.base_url
@ -1598,6 +1614,124 @@ impl ApiClient {
.build()
.unwrap_or_else(|_| Client::new());
}
/// List all UI pages provided by loaded plugins.
///
/// Returns a vector of `(plugin_id, page, allowed_endpoints)` tuples.
pub async fn get_plugin_ui_pages(
&self,
) -> Result<Vec<(String, pinakes_plugin_api::UiPage, Vec<String>)>> {
#[derive(Deserialize)]
struct PageEntry {
plugin_id: String,
page: pinakes_plugin_api::UiPage,
#[serde(default)]
allowed_endpoints: Vec<String>,
}
let entries: Vec<PageEntry> = self
.client
.get(self.url("/plugins/ui-pages"))
.send()
.await?
.error_for_status()?
.json()
.await?;
Ok(
entries
.into_iter()
.map(|e| (e.plugin_id, e.page, e.allowed_endpoints))
.collect(),
)
}
/// List all UI widgets provided by loaded plugins.
///
/// Returns a vector of `(plugin_id, widget)` tuples.
pub async fn get_plugin_ui_widgets(
&self,
) -> Result<Vec<(String, pinakes_plugin_api::UiWidget)>> {
#[derive(Deserialize)]
struct WidgetEntry {
plugin_id: String,
widget: pinakes_plugin_api::UiWidget,
}
let entries: Vec<WidgetEntry> = self
.client
.get(self.url("/plugins/ui-widgets"))
.send()
.await?
.error_for_status()?
.json()
.await?;
Ok(
entries
.into_iter()
.map(|e| (e.plugin_id, e.widget))
.collect(),
)
}
/// Fetch merged CSS custom property overrides from all enabled plugins.
///
/// Returns a map of CSS property names to values.
pub async fn get_plugin_ui_theme_extensions(
&self,
) -> Result<HashMap<String, String>> {
Ok(
self
.client
.get(self.url("/plugins/ui-theme-extensions"))
.send()
.await?
.error_for_status()?
.json()
.await?,
)
}
/// Emit a plugin event to the server-side event bus.
///
/// # Errors
///
/// Returns an error if the request fails or the server returns an error
/// status.
pub async fn post_plugin_event(
&self,
event: &str,
payload: &serde_json::Value,
) -> Result<()> {
self
.client
.post(self.url("/plugins/events"))
.json(&serde_json::json!({ "event": event, "payload": payload }))
.send()
.await?
.error_for_status()?;
Ok(())
}
/// Make a raw HTTP request to an API path.
///
/// The `path` is appended to the base URL without any prefix.
/// Use this for plugin action endpoints that specify full API paths.
pub fn raw_request(
&self,
method: reqwest::Method,
path: &str,
) -> reqwest::RequestBuilder {
let url = format!("{}{}", self.base_url, path);
self.client.request(method, url)
}
}
impl Default for ApiClient {
fn default() -> Self {
Self::new("", None)
}
}
#[cfg(test)]

View file

@ -4,6 +4,7 @@ use tracing_subscriber::EnvFilter;
mod app;
mod client;
mod components;
mod plugin_ui;
mod state;
mod styles;

View file

@ -0,0 +1,325 @@
//! Action execution system for plugin UI pages
//!
//! This module provides the action execution system that handles
//! user interactions with plugin UI elements.
use std::collections::HashMap;
use pinakes_plugin_api::{
ActionDefinition,
ActionRef,
Expression,
SpecialAction,
UiElement,
};
use super::data::to_reqwest_method;
use crate::client::ApiClient;
/// Result of an action execution
#[derive(Debug, Clone)]
pub enum ActionResult {
/// Action completed successfully
Success(serde_json::Value),
/// Action failed
Error(String),
/// Navigation action
Navigate(String),
/// No meaningful result (e.g. 204 No Content)
None,
/// Re-fetch all data sources for the current page
Refresh,
/// Update a local state key; value is kept as an unevaluated expression so
/// the renderer can resolve it against the full data context.
UpdateState {
key: String,
value_expr: Expression,
},
/// Open a modal overlay containing the given element
OpenModal(UiElement),
/// Close the currently open modal overlay
CloseModal,
}
/// Execute an action defined in the UI schema
///
/// `page_actions` is the map of named actions from the current page definition.
/// `ActionRef::Name` entries are resolved against this map.
pub async fn execute_action(
client: &ApiClient,
action_ref: &ActionRef,
page_actions: &HashMap<String, ActionDefinition>,
form_data: Option<&serde_json::Value>,
) -> Result<ActionResult, String> {
match action_ref {
ActionRef::Special(special) => {
match special {
SpecialAction::Refresh => Ok(ActionResult::Refresh),
SpecialAction::Navigate { to } => {
Ok(ActionResult::Navigate(to.clone()))
},
SpecialAction::Emit { event, payload } => {
if let Err(e) = client.post_plugin_event(event, payload).await {
tracing::warn!(event = %event, "plugin emit failed: {e}");
}
Ok(ActionResult::None)
},
SpecialAction::UpdateState { key, value } => {
Ok(ActionResult::UpdateState {
key: key.clone(),
value_expr: value.clone(),
})
},
SpecialAction::OpenModal { content } => {
Ok(ActionResult::OpenModal(*content.clone()))
},
SpecialAction::CloseModal => Ok(ActionResult::CloseModal),
}
},
ActionRef::Name(name) => {
if let Some(action) = page_actions.get(name) {
execute_inline_action(client, action, form_data).await
} else {
tracing::warn!(action = %name, "Unknown action - not defined in page actions");
Ok(ActionResult::None)
}
},
ActionRef::Inline(action) => {
execute_inline_action(client, action, form_data).await
},
}
}
/// Execute an inline action definition
async fn execute_inline_action(
client: &ApiClient,
action: &ActionDefinition,
form_data: Option<&serde_json::Value>,
) -> Result<ActionResult, String> {
// Merge action params with form data into query string for GET, body for
// others
let method = to_reqwest_method(&action.method);
let mut request = client.raw_request(method.clone(), &action.path);
// For GET, merge params into query string; for mutating methods, send as
// JSON body
if method == reqwest::Method::GET {
let query_pairs: Vec<(String, String)> = action
.params
.iter()
.map(|(k, v)| {
let val = match v {
serde_json::Value::String(s) => s.clone(),
other => other.to_string(),
};
(k.clone(), val)
})
.collect();
if !query_pairs.is_empty() {
request = request.query(&query_pairs);
}
} else {
// Build body: merge action.params with form_data
let mut merged: serde_json::Map<String, serde_json::Value> = action
.params
.iter()
.map(|(k, v)| (k.clone(), v.clone()))
.collect();
// action.params take precedence; form_data only fills in missing keys
if let Some(obj) = form_data.and_then(serde_json::Value::as_object) {
for (k, v) in obj {
merged.entry(k.clone()).or_insert_with(|| v.clone());
}
}
if !merged.is_empty() {
request = request.json(&merged);
}
}
let response = request.send().await.map_err(|e| e.to_string())?;
let status = response.status();
if !status.is_success() {
let error_text = response.text().await.unwrap_or_default();
return Ok(ActionResult::Error(format!(
"Action failed: {} - {}",
status.as_u16(),
error_text
)));
}
if status.as_u16() == 204 {
// Navigate on success if configured
if let Some(route) = &action.navigate_to {
return Ok(ActionResult::Navigate(route.clone()));
}
return Ok(ActionResult::None);
}
let value: serde_json::Value =
response.json().await.unwrap_or(serde_json::Value::Null);
if let Some(route) = &action.navigate_to {
return Ok(ActionResult::Navigate(route.clone()));
}
Ok(ActionResult::Success(value))
}
#[cfg(test)]
mod tests {
use pinakes_plugin_api::ActionRef;
use super::*;
#[test]
fn test_action_result_variants() {
let _ = ActionResult::None;
let _ = ActionResult::Success(serde_json::json!({"ok": true}));
let _ = ActionResult::Error("error".to_string());
let _ = ActionResult::Navigate("/page".to_string());
}
#[test]
fn test_action_result_clone() {
let original = ActionResult::Success(serde_json::json!({"key": "value"}));
let cloned = original.clone();
if let (ActionResult::Success(a), ActionResult::Success(b)) =
(original, cloned)
{
assert_eq!(a, b);
} else {
panic!("clone produced wrong variant");
}
}
#[test]
fn test_action_result_error_clone() {
let original = ActionResult::Error("something went wrong".to_string());
let cloned = original.clone();
if let (ActionResult::Error(a), ActionResult::Error(b)) = (original, cloned)
{
assert_eq!(a, b);
} else {
panic!("clone produced wrong variant");
}
}
#[test]
fn test_action_result_navigate_clone() {
let original = ActionResult::Navigate("/dashboard".to_string());
let cloned = original.clone();
if let (ActionResult::Navigate(a), ActionResult::Navigate(b)) =
(original, cloned)
{
assert_eq!(a, b);
} else {
panic!("clone produced wrong variant");
}
}
#[tokio::test]
async fn test_named_action_unknown_returns_none() {
let client = crate::client::ApiClient::default();
let action_ref = ActionRef::Name("my-action".to_string());
let result = execute_action(&client, &action_ref, &HashMap::new(), None)
.await
.unwrap();
assert!(matches!(result, ActionResult::None));
}
#[tokio::test]
async fn test_named_action_resolves_from_map() {
use pinakes_plugin_api::ActionDefinition;
let client = crate::client::ApiClient::default();
let mut page_actions = HashMap::new();
page_actions.insert("do-thing".to_string(), ActionDefinition {
method: pinakes_plugin_api::HttpMethod::Post,
path: "/api/v1/nonexistent-endpoint".to_string(),
params: HashMap::new(),
success_message: None,
error_message: None,
navigate_to: None,
});
let action_ref = ActionRef::Name("do-thing".to_string());
// The action is resolved; will error because there's no server, but
// ActionResult::None would mean it was NOT resolved
let result =
execute_action(&client, &action_ref, &page_actions, None).await;
// It should NOT be Ok(None); it should be either Error or a network error
match result {
Ok(ActionResult::None) => {
panic!("Named action was not resolved from page_actions")
},
_ => {}, /* Any other result (error, network failure) means it was
* resolved */
}
}
#[tokio::test]
async fn test_special_action_refresh() {
use pinakes_plugin_api::SpecialAction;
let client = crate::client::ApiClient::default();
let action_ref = ActionRef::Special(SpecialAction::Refresh);
let result = execute_action(&client, &action_ref, &HashMap::new(), None)
.await
.unwrap();
assert!(matches!(result, ActionResult::Refresh));
}
#[tokio::test]
async fn test_special_action_navigate() {
use pinakes_plugin_api::SpecialAction;
let client = crate::client::ApiClient::default();
let action_ref = ActionRef::Special(SpecialAction::Navigate {
to: "/dashboard".to_string(),
});
let result = execute_action(&client, &action_ref, &HashMap::new(), None)
.await
.unwrap();
assert!(
matches!(result, ActionResult::Navigate(ref p) if p == "/dashboard")
);
}
#[tokio::test]
async fn test_special_action_update_state_preserves_expression() {
use pinakes_plugin_api::{Expression, SpecialAction};
let client = crate::client::ApiClient::default();
let expr = Expression::Literal(serde_json::json!(42));
let action_ref = ActionRef::Special(SpecialAction::UpdateState {
key: "count".to_string(),
value: expr.clone(),
});
let result = execute_action(&client, &action_ref, &HashMap::new(), None)
.await
.unwrap();
match result {
ActionResult::UpdateState { key, value_expr } => {
assert_eq!(key, "count");
assert_eq!(value_expr, expr);
},
other => panic!("expected UpdateState, got {other:?}"),
}
}
#[tokio::test]
async fn test_special_action_close_modal() {
use pinakes_plugin_api::SpecialAction;
let client = crate::client::ApiClient::default();
let action_ref = ActionRef::Special(SpecialAction::CloseModal);
let result = execute_action(&client, &action_ref, &HashMap::new(), None)
.await
.unwrap();
assert!(matches!(result, ActionResult::CloseModal));
}
}

View file

@ -0,0 +1,768 @@
//! Data fetching system for plugin UI pages
//!
//! Provides data fetching and caching for plugin data sources.
use std::{
collections::{HashMap, HashSet},
time::Duration,
};
use dioxus::prelude::*;
use dioxus_core::Task;
use pinakes_plugin_api::{DataSource, Expression, HttpMethod};
use super::expr::{evaluate_expression, value_to_display_string};
use crate::client::ApiClient;
/// Cached data for a plugin page
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct PluginPageData {
data: HashMap<String, serde_json::Value>,
loading: HashSet<String>,
errors: HashMap<String, String>,
}
impl PluginPageData {
/// Get data for a specific source
#[must_use]
pub fn get(&self, source: &str) -> Option<&serde_json::Value> {
self.data.get(source)
}
/// Check if a source is currently loading
#[must_use]
pub fn is_loading(&self, source: &str) -> bool {
self.loading.contains(source)
}
/// Get error for a specific source
#[must_use]
pub fn error(&self, source: &str) -> Option<&str> {
self.errors.get(source).map(String::as_str)
}
/// Check if there is data for a specific source
#[must_use]
pub fn has_data(&self, source: &str) -> bool {
self.data.contains_key(source)
}
/// Set data for a source
pub fn set_data(&mut self, source: String, value: serde_json::Value) {
self.data.insert(source, value);
}
/// Set loading state for a source
pub fn set_loading(&mut self, source: &str, loading: bool) {
if loading {
self.loading.insert(source.to_string());
self.errors.remove(source);
} else {
self.loading.remove(source);
}
}
/// Set error for a source
pub fn set_error(&mut self, source: String, error: String) {
self.errors.insert(source, error);
}
/// Convert all resolved data to a single JSON object for expression
/// evaluation
#[must_use]
pub fn as_json(&self) -> serde_json::Value {
serde_json::Value::Object(
self
.data
.iter()
.map(|(k, v)| (k.clone(), v.clone()))
.collect(),
)
}
/// Clear all data
pub fn clear(&mut self) {
self.data.clear();
self.loading.clear();
self.errors.clear();
}
}
/// Convert a plugin `HttpMethod` to a `reqwest::Method`.
pub(super) const fn to_reqwest_method(method: &HttpMethod) -> reqwest::Method {
match method {
HttpMethod::Get => reqwest::Method::GET,
HttpMethod::Post => reqwest::Method::POST,
HttpMethod::Put => reqwest::Method::PUT,
HttpMethod::Patch => reqwest::Method::PATCH,
HttpMethod::Delete => reqwest::Method::DELETE,
}
}
/// Fetch data from an endpoint, evaluating any params expressions against
/// the given context.
async fn fetch_endpoint(
client: &ApiClient,
path: &str,
method: HttpMethod,
params: &HashMap<String, Expression>,
ctx: &serde_json::Value,
allowed_endpoints: &[String],
) -> Result<serde_json::Value, String> {
if !allowed_endpoints.is_empty()
&& !allowed_endpoints.iter().any(|ep| path == ep.as_str())
{
return Err(format!(
"Endpoint '{path}' is not in plugin's declared required_endpoints"
));
}
let reqwest_method = to_reqwest_method(&method);
let mut request = client.raw_request(reqwest_method.clone(), path);
if !params.is_empty() {
if reqwest_method == reqwest::Method::GET {
// Evaluate each param expression and add as query string
let query_pairs: Vec<(String, String)> = params
.iter()
.map(|(k, expr)| {
let v = evaluate_expression(expr, ctx);
(k.clone(), value_to_display_string(&v))
})
.collect();
request = request.query(&query_pairs);
} else {
// Evaluate params and send as JSON body
let body: serde_json::Map<String, serde_json::Value> = params
.iter()
.map(|(k, expr)| (k.clone(), evaluate_expression(expr, ctx)))
.collect();
request = request.json(&body);
}
}
// Send request and parse response
let response = request
.send()
.await
.map_err(|e| format!("Request failed: {e}"))?;
if !response.status().is_success() {
let status = response.status();
let body = response.text().await.unwrap_or_default();
return Err(format!("HTTP {status}: {body}"));
}
response
.json::<serde_json::Value>()
.await
.map_err(|e| format!("Failed to parse JSON: {e}"))
}
/// Fetch all data sources for a page
///
/// Endpoint sources are deduplicated by `(path, method, params)`: if multiple
/// sources share the same triplet, a single HTTP request is made and the raw
/// response is shared, with each source's own `transform` applied
/// independently. All unique Endpoint and Static sources are fetched
/// concurrently. Transform sources are applied after, in iteration order,
/// against the full result set.
///
/// # Errors
///
/// Returns an error if any data source fails to fetch
pub async fn fetch_page_data(
client: &ApiClient,
data_sources: &HashMap<String, DataSource>,
allowed_endpoints: &[String],
) -> Result<HashMap<String, serde_json::Value>, String> {
// Group non-Transform sources into dedup groups.
//
// For Endpoint sources, two entries are in the same group when they share
// the same (path, method, params) - i.e., they would produce an identical
// HTTP request. The per-source `transform` expression is kept separate so
// each name can apply its own transform to the shared raw response.
//
// Static sources never share an HTTP request so each becomes its own group.
//
// Each group is: (names_and_transforms, representative_source)
// where names_and_transforms is Vec<(name, Option<Expression>)> for Endpoint,
// or Vec<(name, ())> for Static (transform is baked in).
struct Group {
// (source name, per-name transform expression for Endpoint sources)
members: Vec<(String, Option<Expression>)>,
// The representative source used to fire the request (transform ignored
// for Endpoint - we apply per-member transforms after fetching)
source: DataSource,
}
let mut groups: Vec<Group> = Vec::new();
for (name, source) in data_sources {
if matches!(source, DataSource::Transform { .. }) {
continue;
}
match source {
DataSource::Endpoint {
path,
method,
params,
transform,
poll_interval,
} => {
// Find an existing group with the same (path, method, params).
let existing = groups.iter_mut().find(|g| {
matches!(
&g.source,
DataSource::Endpoint {
path: ep,
method: em,
params: epa,
..
} if ep == path && em == method && epa == params
)
});
if let Some(group) = existing {
group.members.push((name.clone(), transform.clone()));
} else {
groups.push(Group {
members: vec![(name.clone(), transform.clone())],
source: DataSource::Endpoint {
path: path.clone(),
method: method.clone(),
params: params.clone(),
poll_interval: *poll_interval,
transform: None,
},
});
}
},
DataSource::Static { .. } => {
// Static sources are trivially unique per name; no dedup needed.
groups.push(Group {
members: vec![(name.clone(), None)],
source: source.clone(),
});
},
DataSource::Transform { .. } => unreachable!(),
}
}
// Fire one future per group concurrently.
let futs: Vec<_> = groups
.into_iter()
.map(|group| {
let client = client.clone();
let allowed = allowed_endpoints.to_vec();
async move {
// Fetch the raw value for this group.
let raw = match &group.source {
DataSource::Endpoint {
path,
method,
params,
..
} => {
let empty_ctx = serde_json::json!({});
fetch_endpoint(
&client,
path,
method.clone(),
params,
&empty_ctx,
&allowed,
)
.await?
},
DataSource::Static { value } => value.clone(),
DataSource::Transform { .. } => unreachable!(),
};
// Apply per-member transforms and collect (name, value) pairs.
let pairs: Vec<(String, serde_json::Value)> = group
.members
.into_iter()
.map(|(name, transform)| {
let value = if let Some(expr) = &transform {
evaluate_expression(expr, &raw)
} else {
raw.clone()
};
(name, value)
})
.collect();
Ok::<_, String>(pairs)
}
})
.collect();
let mut results: HashMap<String, serde_json::Value> = HashMap::new();
for group_result in futures::future::join_all(futs).await {
for (name, value) in group_result? {
results.insert(name, value);
}
}
// Process Transform sources in dependency order. HashMap iteration order is
// non-deterministic, so a Transform referencing another Transform could see
// null if the upstream was not yet resolved. The pending loop below defers
// any Transform whose upstream is not yet in results, making progress on
// each pass until all are resolved. UiPage::validate guarantees no cycles,
// so the loop always terminates.
let mut pending: Vec<(&String, &String, &Expression)> = data_sources
.iter()
.filter_map(|(name, source)| {
match source {
DataSource::Transform {
source_name,
expression,
} => Some((name, source_name, expression)),
_ => None,
}
})
.collect();
while !pending.is_empty() {
let prev_len = pending.len();
let mut i = 0;
while i < pending.len() {
let (name, source_name, expression) = pending[i];
if results.contains_key(source_name.as_str()) {
let ctx = serde_json::Value::Object(
results
.iter()
.map(|(k, v)| (k.clone(), v.clone()))
.collect(),
);
results.insert(name.clone(), evaluate_expression(expression, &ctx));
pending.swap_remove(i);
} else {
i += 1;
}
}
if pending.len() == prev_len {
// No progress: upstream source is missing (should be caught by
// UiPage::validate, but handled defensively here).
tracing::warn!(
"plugin transform dependency unresolvable; processing remaining in \
iteration order"
);
for (name, _, expression) in pending {
let ctx = serde_json::Value::Object(
results
.iter()
.map(|(k, v)| (k.clone(), v.clone()))
.collect(),
);
results.insert(name.clone(), evaluate_expression(expression, &ctx));
}
break;
}
}
Ok(results)
}
/// Hook to fetch and cache plugin page data
///
/// Returns a signal containing the data state. If any data source has a
/// non-zero `poll_interval`, a background loop re-fetches automatically at the
/// minimum interval. The `refresh` counter can be incremented to trigger an
/// immediate re-fetch outside of the polling interval.
pub fn use_plugin_data(
client: Signal<ApiClient>,
data_sources: HashMap<String, DataSource>,
refresh: Signal<u32>,
allowed_endpoints: Vec<String>,
) -> Signal<PluginPageData> {
let mut data = use_signal(PluginPageData::default);
let mut poll_task: Signal<Option<Task>> = use_signal(|| None);
use_effect(move || {
// Subscribe to the refresh counter; incrementing it triggers a re-run.
let _rev = refresh.read();
let sources = data_sources.clone();
let allowed = allowed_endpoints.clone();
// Cancel the previous polling task before spawning a new one. Use
// write() rather than read() so the effect does not subscribe to
// poll_task and trigger an infinite re-run loop.
if let Some(t) = poll_task.write().take() {
t.cancel();
}
// Determine minimum poll interval (0 = no polling)
let min_poll_secs: u64 = sources
.values()
.filter_map(|s| {
if let DataSource::Endpoint { poll_interval, .. } = s {
if *poll_interval > 0 {
Some(*poll_interval)
} else {
None
}
} else {
None
}
})
.min()
.unwrap_or(0);
let handle = spawn(async move {
// Clear previous data
data.write().clear();
// Mark all sources as loading
for name in sources.keys() {
data.write().set_loading(name, true);
}
// Initial fetch; clone to release the signal read borrow before await.
let cl = client.peek().clone();
match fetch_page_data(&cl, &sources, &allowed).await {
Ok(results) => {
for (name, value) in results {
data.write().set_loading(&name, false);
data.write().set_data(name, value);
}
},
Err(e) => {
for name in sources.keys() {
data.write().set_loading(name, false);
data.write().set_error(name.clone(), e.clone());
}
},
}
// Polling loop; only runs if at least one source has poll_interval > 0
if min_poll_secs > 0 {
loop {
tokio::time::sleep(Duration::from_secs(min_poll_secs)).await;
let cl = client.peek().clone();
match fetch_page_data(&cl, &sources, &allowed).await {
Ok(results) => {
for (name, value) in results {
// Only write if data is new or has changed to avoid spurious
// signal updates that would force a re-render
let changed = !data.read().has_data(&name)
|| data.read().get(&name) != Some(&value);
if changed {
data.write().set_data(name, value);
}
}
},
Err(e) => {
tracing::warn!("Poll fetch failed: {e}");
},
}
}
}
});
*poll_task.write() = Some(handle);
});
data
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_plugin_page_data() {
let mut data = PluginPageData::default();
// Test empty state
assert!(!data.has_data("test"));
assert!(!data.is_loading("test"));
assert!(data.error("test").is_none());
// Test setting data
data.set_data("test".to_string(), serde_json::json!({"key": "value"}));
assert!(data.has_data("test"));
assert_eq!(data.get("test"), Some(&serde_json::json!({"key": "value"})));
// Test loading state
data.set_loading("loading", true);
assert!(data.is_loading("loading"));
data.set_loading("loading", false);
assert!(!data.is_loading("loading"));
// Test error state
data.set_error("error".to_string(), "oops".to_string());
assert_eq!(data.error("error"), Some("oops"));
}
#[test]
fn test_as_json_empty() {
let data = PluginPageData::default();
assert_eq!(data.as_json(), serde_json::json!({}));
}
#[test]
fn test_as_json_with_data() {
let mut data = PluginPageData::default();
data.set_data("users".to_string(), serde_json::json!([{"id": 1}]));
data.set_data("count".to_string(), serde_json::json!(42));
let json = data.as_json();
assert_eq!(json["users"], serde_json::json!([{"id": 1}]));
assert_eq!(json["count"], serde_json::json!(42));
}
#[test]
fn test_set_loading_true_clears_error() {
let mut data = PluginPageData::default();
data.set_error("src".to_string(), "oops".to_string());
assert!(data.error("src").is_some());
data.set_loading("src", true);
assert!(data.error("src").is_none());
assert!(data.is_loading("src"));
}
#[test]
fn test_set_loading_false_removes_flag() {
let mut data = PluginPageData::default();
data.set_loading("src", true);
assert!(data.is_loading("src"));
data.set_loading("src", false);
assert!(!data.is_loading("src"));
}
#[test]
fn test_clear_resets_all_state() {
let mut data = PluginPageData::default();
data.set_data("x".to_string(), serde_json::json!(1));
data.set_loading("x", true);
data.set_error("y".to_string(), "err".to_string());
data.clear();
assert!(!data.has_data("x"));
assert!(!data.is_loading("x"));
assert!(data.error("y").is_none());
}
#[test]
fn test_partial_eq() {
let mut a = PluginPageData::default();
let mut b = PluginPageData::default();
assert_eq!(a, b);
a.set_data("k".to_string(), serde_json::json!(1));
assert_ne!(a, b);
b.set_data("k".to_string(), serde_json::json!(1));
assert_eq!(a, b);
}
#[tokio::test]
async fn test_fetch_page_data_static_only() {
use pinakes_plugin_api::DataSource;
use crate::client::ApiClient;
let client = ApiClient::default();
let mut sources = HashMap::new();
sources.insert("nums".to_string(), DataSource::Static {
value: serde_json::json!([1, 2, 3]),
});
sources.insert("flag".to_string(), DataSource::Static {
value: serde_json::json!(true),
});
let results = super::fetch_page_data(&client, &sources, &[])
.await
.unwrap();
assert_eq!(results["nums"], serde_json::json!([1, 2, 3]));
assert_eq!(results["flag"], serde_json::json!(true));
}
#[tokio::test]
async fn test_fetch_page_data_transform_evaluates_expression() {
use pinakes_plugin_api::{DataSource, Expression};
use crate::client::ApiClient;
let client = ApiClient::default();
let mut sources = HashMap::new();
// The Transform expression accesses "raw" from the context
sources.insert("derived".to_string(), DataSource::Transform {
source_name: "raw".to_string(),
expression: Expression::Path("raw".to_string()),
});
sources.insert("raw".to_string(), DataSource::Static {
value: serde_json::json!({"ok": true}),
});
let results = super::fetch_page_data(&client, &sources, &[])
.await
.unwrap();
assert_eq!(results["raw"], serde_json::json!({"ok": true}));
// derived should return the value of "raw" from context
assert_eq!(results["derived"], serde_json::json!({"ok": true}));
}
#[tokio::test]
async fn test_fetch_page_data_transform_literal_expression() {
use pinakes_plugin_api::{DataSource, Expression};
use crate::client::ApiClient;
let client = ApiClient::default();
let mut sources = HashMap::new();
sources.insert("raw".to_string(), DataSource::Static {
value: serde_json::json!(42),
});
sources.insert("derived".to_string(), DataSource::Transform {
source_name: "raw".to_string(),
expression: Expression::Literal(serde_json::json!("constant")),
});
let results = super::fetch_page_data(&client, &sources, &[])
.await
.unwrap();
// A Literal expression returns the literal value, not the source data
assert_eq!(results["derived"], serde_json::json!("constant"));
}
#[tokio::test]
async fn test_fetch_page_data_deduplicates_identical_endpoints() {
use pinakes_plugin_api::DataSource;
use crate::client::ApiClient;
let client = ApiClient::default();
let mut sources = HashMap::new();
// Two Static sources with the same payload; dedup is for Endpoint sources,
// but both names must appear in the output regardless.
sources.insert("a".to_string(), DataSource::Static {
value: serde_json::json!(1),
});
sources.insert("b".to_string(), DataSource::Static {
value: serde_json::json!(1),
});
let results = super::fetch_page_data(&client, &sources, &[])
.await
.unwrap();
assert_eq!(results["a"], serde_json::json!(1));
assert_eq!(results["b"], serde_json::json!(1));
assert_eq!(results.len(), 2);
}
// Verifies that endpoint sources with identical (path, method, params) are
// deduplicated correctly. Because there is no real server, the allowlist
// rejection fires before any network call; both names seeing the same error
// proves they were grouped and that the single rejection propagated to all.
#[tokio::test]
async fn test_dedup_groups_endpoint_sources_with_same_key() {
use pinakes_plugin_api::{DataSource, Expression, HttpMethod};
use crate::client::ApiClient;
let client = ApiClient::default();
let mut sources = HashMap::new();
// Two endpoints with identical (path, method, params=empty) but different
// transforms. Both should produce the same error when the path is blocked.
sources.insert("x".to_string(), DataSource::Endpoint {
path: "/api/v1/media".to_string(),
method: HttpMethod::Get,
params: Default::default(),
poll_interval: 0,
transform: Some(Expression::Literal(serde_json::json!("from_x"))),
});
sources.insert("y".to_string(), DataSource::Endpoint {
path: "/api/v1/media".to_string(),
method: HttpMethod::Get,
params: Default::default(),
poll_interval: 0,
transform: Some(Expression::Literal(serde_json::json!("from_y"))),
});
// Both sources point to the same blocked endpoint; expect an error.
let allowed = vec!["/api/v1/tags".to_string()];
let result = super::fetch_page_data(&client, &sources, &allowed).await;
assert!(
result.is_err(),
"fetch_page_data must return Err for blocked deduplicated endpoints"
);
let msg = result.unwrap_err();
assert!(
msg.contains("not in plugin's declared required_endpoints"),
"unexpected error: {msg}"
);
}
// Verifies the transform fan-out behavior: each member of a dedup group
// applies its own transform to the shared raw value independently. This
// mirrors what Endpoint dedup does after a single shared HTTP request.
//
// Testing Endpoint dedup with real per-member transforms requires a mock HTTP
// server and belongs in an integration test.
#[tokio::test]
async fn test_dedup_transform_applied_per_source() {
use pinakes_plugin_api::{DataSource, Expression};
use crate::client::ApiClient;
let client = ApiClient::default();
let mut sources = HashMap::new();
sources.insert("raw_data".to_string(), DataSource::Static {
value: serde_json::json!({"count": 42, "name": "test"}),
});
// Two Transform sources referencing "raw_data" with different expressions;
// each must produce its own independently derived value.
sources.insert("derived_count".to_string(), DataSource::Transform {
source_name: "raw_data".to_string(),
expression: Expression::Path("raw_data.count".to_string()),
});
sources.insert("derived_name".to_string(), DataSource::Transform {
source_name: "raw_data".to_string(),
expression: Expression::Path("raw_data.name".to_string()),
});
let results = super::fetch_page_data(&client, &sources, &[])
.await
.unwrap();
assert_eq!(
results["raw_data"],
serde_json::json!({"count": 42, "name": "test"})
);
assert_eq!(results["derived_count"], serde_json::json!(42));
assert_eq!(results["derived_name"], serde_json::json!("test"));
assert_eq!(results.len(), 3);
}
#[tokio::test]
async fn test_endpoint_blocked_when_not_in_allowlist() {
use pinakes_plugin_api::{DataSource, HttpMethod};
use crate::client::ApiClient;
let client = ApiClient::default();
let mut sources = HashMap::new();
sources.insert("items".to_string(), DataSource::Endpoint {
path: "/api/v1/media".to_string(),
method: HttpMethod::Get,
params: Default::default(),
poll_interval: 0,
transform: None,
});
// Provide a non-empty allowlist that does NOT include the endpoint path.
let allowed = vec!["/api/v1/tags".to_string()];
let result = super::fetch_page_data(&client, &sources, &allowed).await;
assert!(
result.is_err(),
"fetch_page_data must return Err when endpoint is not in \
allowed_endpoints"
);
let msg = result.unwrap_err();
assert!(
msg.contains("not in plugin's declared required_endpoints"),
"error must explain that the endpoint is not declared, got: {msg}"
);
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,41 @@
//! Plugin UI system for Pinakes Desktop/Web UI
//!
//! This module provides a declarative UI plugin system that allows plugins
//! to define custom pages without needing to compile against the UI crate.
//!
//! # Architecture
//!
//! - [`registry`] - Plugin page registry and context provider
//! - [`data`] - Data fetching and caching for plugin data sources
//! - [`actions`] - Action execution system for plugin interactions
//! - [`renderer`] - Schema-to-Dioxus rendering components
//!
//! # Usage
//!
//! Plugins define their UI as JSON schemas in the plugin manifest:
//!
//! ```toml
//! [[ui.pages]]
//! id = "my-plugin-page"
//! title = "My Plugin"
//! route = "/plugins/my-plugin"
//! icon = "cog"
//!
//! [ui.pages.layout]
//! type = "container"
//! # ... more layout definition
//! ```
//!
//! The UI schema is fetched from the server and rendered using the
//! components in this module.
pub mod actions;
pub mod data;
pub mod expr;
pub mod registry;
pub mod renderer;
pub mod widget;
pub use registry::PluginRegistry;
pub use renderer::PluginViewRenderer;
pub use widget::{WidgetContainer, WidgetLocation};

View file

@ -0,0 +1,566 @@
//! Plugin UI Registry
//!
//! Manages plugin-provided UI pages and provides hooks for accessing
//! page definitions at runtime.
//!
//! ## Usage
//!
//! ```rust,ignore
//! // Initialize registry with API client
//! let registry = PluginRegistry::new(api_client);
//! registry.refresh().await?;
//!
//! // Access pages
//! if let Some(page) = registry.get_page("my-plugin", "demo") {
//! println!("Page: {}", page.page.title);
//! }
//! ```
use std::collections::HashMap;
use dioxus::prelude::*;
use pinakes_plugin_api::{UiPage, UiWidget};
use crate::client::ApiClient;
/// Information about a plugin-provided UI page
#[derive(Debug, Clone)]
pub struct PluginPage {
/// Plugin ID that provides this page
pub plugin_id: String,
/// Page definition from schema
pub page: UiPage,
/// Endpoint paths this plugin is allowed to fetch (empty means no
/// restriction)
pub allowed_endpoints: Vec<String>,
}
/// Registry of all plugin-provided UI pages and widgets
///
/// This is typically stored as a signal in the Dioxus tree.
#[derive(Debug, Clone)]
pub struct PluginRegistry {
/// API client for fetching pages from server
client: ApiClient,
/// Cached pages: (`plugin_id`, `page_id`) -> `PluginPage`
pages: HashMap<(String, String), PluginPage>,
/// Cached widgets: (`plugin_id`, `widget_id`) -> `UiWidget`
widgets: Vec<(String, UiWidget)>,
/// Merged CSS custom property overrides from all enabled plugins
theme_vars: HashMap<String, String>,
}
impl PluginRegistry {
/// Create a new empty registry
pub fn new(client: ApiClient) -> Self {
Self {
client,
pages: HashMap::new(),
widgets: Vec::new(),
theme_vars: HashMap::new(),
}
}
/// Get merged CSS custom property overrides from all loaded plugins.
pub fn theme_vars(&self) -> &HashMap<String, String> {
&self.theme_vars
}
/// Register a page from a plugin
///
/// Pages that fail schema validation are silently skipped with a warning log.
pub fn register_page(
&mut self,
plugin_id: String,
page: UiPage,
allowed_endpoints: Vec<String>,
) {
if let Err(e) = page.validate() {
tracing::warn!(
plugin_id = %plugin_id,
page_id = %page.id,
"Skipping invalid page '{}' from '{}': {e}",
page.id,
plugin_id,
);
return;
}
let page_id = page.id.clone();
// Check for duplicate page_id across different plugins. Same-plugin
// re-registration of the same page is allowed to overwrite.
let has_duplicate = self.pages.values().any(|existing| {
existing.page.id == page_id && existing.plugin_id != plugin_id
});
if has_duplicate {
tracing::warn!(
plugin_id = %plugin_id,
page_id = %page_id,
"skipping plugin page: page ID conflicts with an existing page from another plugin"
);
return;
}
self.pages.insert((plugin_id.clone(), page_id), PluginPage {
plugin_id,
page,
allowed_endpoints,
});
}
/// Get a specific page by plugin ID and page ID
pub fn get_page(
&self,
plugin_id: &str,
page_id: &str,
) -> Option<&PluginPage> {
self
.pages
.get(&(plugin_id.to_string(), page_id.to_string()))
}
/// Register a widget from a plugin
///
/// Widgets that fail schema validation are silently skipped with a warning
/// log.
pub fn register_widget(&mut self, plugin_id: String, widget: UiWidget) {
if let Err(e) = widget.validate() {
tracing::warn!(
plugin_id = %plugin_id,
widget_id = %widget.id,
"Skipping invalid widget '{}' from '{}': {e}",
widget.id,
plugin_id,
);
return;
}
self.widgets.push((plugin_id, widget));
}
/// Get all widgets (for use with `WidgetContainer`)
pub fn all_widgets(&self) -> Vec<(String, UiWidget)> {
self.widgets.clone()
}
/// Get all pages
#[allow(
dead_code,
reason = "used in tests and may be needed by future callers"
)]
pub fn all_pages(&self) -> Vec<&PluginPage> {
self.pages.values().collect()
}
/// Check if any pages are registered
pub fn is_empty(&self) -> bool {
self.pages.is_empty()
}
/// Number of registered pages
pub fn len(&self) -> usize {
self.pages.len()
}
/// Get all page routes for navigation
///
/// Returns `(plugin_id, page_id, full_route)` triples.
pub fn routes(&self) -> Vec<(String, String, String)> {
self
.pages
.values()
.map(|p| (p.plugin_id.clone(), p.page.id.clone(), p.page.route.clone()))
.collect()
}
/// Refresh pages and widgets from server
pub async fn refresh(&mut self) -> Result<(), String> {
let pages = self
.client
.get_plugin_ui_pages()
.await
.map_err(|e| format!("Failed to refresh plugin pages: {e}"))?;
// Build into a temporary registry to avoid a window where state appears
// empty during the two async fetches.
let mut tmp = Self::new(self.client.clone());
for (plugin_id, page, endpoints) in pages {
tmp.register_page(plugin_id, page, endpoints);
}
match self.client.get_plugin_ui_widgets().await {
Ok(widgets) => {
for (plugin_id, widget) in widgets {
tmp.register_widget(plugin_id, widget);
}
},
Err(e) => tracing::warn!("Failed to refresh plugin widgets: {e}"),
}
match self.client.get_plugin_ui_theme_extensions().await {
Ok(vars) => tmp.theme_vars = vars,
Err(e) => {
tracing::warn!("Failed to refresh plugin theme extensions: {e}")
},
}
// Atomic swap: no window where the registry appears empty.
self.pages = tmp.pages;
self.widgets = tmp.widgets;
self.theme_vars = tmp.theme_vars;
Ok(())
}
}
impl Default for PluginRegistry {
fn default() -> Self {
Self::new(ApiClient::default())
}
}
#[cfg(test)]
mod tests {
use pinakes_plugin_api::UiElement;
use super::*;
fn create_test_page(id: &str, title: &str) -> UiPage {
UiPage {
id: id.to_string(),
title: title.to_string(),
route: format!("/plugins/test/{id}"),
icon: None,
root_element: UiElement::Container {
children: vec![],
gap: 16,
padding: None,
},
data_sources: HashMap::new(),
actions: HashMap::new(),
}
}
#[test]
fn test_registry_empty() {
let client = ApiClient::default();
let registry = PluginRegistry::new(client);
assert!(registry.is_empty());
assert_eq!(registry.all_pages().len(), 0);
}
#[test]
fn test_register_and_get_page() {
let client = ApiClient::default();
let mut registry = PluginRegistry::new(client);
let page = create_test_page("demo", "Demo Page");
registry.register_page("my-plugin".to_string(), page.clone(), vec![]);
assert!(!registry.is_empty());
assert_eq!(registry.all_pages().len(), 1);
let retrieved = registry.get_page("my-plugin", "demo");
assert!(retrieved.is_some());
assert_eq!(retrieved.unwrap().page.id, "demo");
assert_eq!(retrieved.unwrap().page.title, "Demo Page");
}
#[test]
fn test_get_page_not_found() {
let client = ApiClient::default();
let registry = PluginRegistry::new(client);
let result = registry.get_page("nonexistent", "page");
assert!(result.is_none());
}
#[test]
fn test_all_pages() {
let client = ApiClient::default();
let mut registry = PluginRegistry::new(client);
registry.register_page(
"plugin1".to_string(),
create_test_page("page1", "Page 1"),
vec![],
);
registry.register_page(
"plugin2".to_string(),
create_test_page("page2", "Page 2"),
vec![],
);
let all = registry.all_pages();
assert_eq!(all.len(), 2);
}
#[test]
fn test_register_widget_and_all_widgets() {
let client = ApiClient::default();
let mut registry = PluginRegistry::new(client);
let widget: UiWidget = serde_json::from_value(serde_json::json!({
"id": "my-widget",
"target": "library_header",
"content": { "type": "badge", "text": "hello", "variant": "default" }
}))
.unwrap();
assert!(registry.all_widgets().is_empty());
registry.register_widget("test-plugin".to_string(), widget.clone());
let widgets = registry.all_widgets();
assert_eq!(widgets.len(), 1);
assert_eq!(widgets[0].0, "test-plugin");
assert_eq!(widgets[0].1.id, "my-widget");
}
#[test]
fn test_register_page_overwrites_same_key() {
let client = ApiClient::default();
let mut registry = PluginRegistry::new(client);
registry.register_page(
"plugin1".to_string(),
create_test_page("p", "Original"),
vec![],
);
registry.register_page(
"plugin1".to_string(),
create_test_page("p", "Updated"),
vec![],
);
assert_eq!(registry.all_pages().len(), 1);
assert_eq!(
registry.get_page("plugin1", "p").unwrap().page.title,
"Updated"
);
}
#[test]
fn test_default_registry_is_empty() {
let registry = PluginRegistry::default();
assert!(registry.is_empty());
assert_eq!(registry.all_pages().len(), 0);
}
#[test]
fn test_len() {
let client = ApiClient::default();
let mut registry = PluginRegistry::new(client);
assert_eq!(registry.len(), 0);
registry.register_page("p".to_string(), create_test_page("a", "A"), vec![]);
assert_eq!(registry.len(), 1);
}
#[test]
fn test_page_route() {
let client = ApiClient::default();
let mut registry = PluginRegistry::new(client);
registry.register_page(
"my-plugin".to_string(),
create_test_page("demo", "Demo Page"),
vec![],
);
let plugin_page = registry.get_page("my-plugin", "demo").unwrap();
assert_eq!(plugin_page.page.route, "/plugins/test/demo");
}
#[test]
fn test_routes() {
let client = ApiClient::default();
let mut registry = PluginRegistry::new(client);
registry.register_page(
"plugin1".to_string(),
create_test_page("page1", "Page 1"),
vec![],
);
let routes = registry.routes();
assert_eq!(routes.len(), 1);
assert_eq!(routes[0].0, "plugin1");
assert_eq!(routes[0].1, "page1");
assert_eq!(routes[0].2, "/plugins/test/page1");
}
#[test]
fn test_with_pages_builds_registry() {
let client = ApiClient::default();
let pages = vec![
("plugin1".to_string(), create_test_page("page1", "Page 1")),
("plugin2".to_string(), create_test_page("page2", "Page 2")),
];
// Build via register_page loop (equivalent to old with_pages)
let mut registry = PluginRegistry::new(client);
for (plugin_id, page) in pages {
registry.register_page(plugin_id, page, vec![]);
}
assert_eq!(registry.len(), 2);
assert!(registry.get_page("plugin1", "page1").is_some());
assert!(registry.get_page("plugin2", "page2").is_some());
}
#[test]
fn test_all_pages_returns_references() {
let client = ApiClient::default();
let mut registry = PluginRegistry::new(client);
registry.register_page(
"p1".to_string(),
create_test_page("a", "A"),
vec![],
);
registry.register_page(
"p2".to_string(),
create_test_page("b", "B"),
vec![],
);
let pages = registry.all_pages();
assert_eq!(pages.len(), 2);
let titles: Vec<&str> =
pages.iter().map(|p| p.page.title.as_str()).collect();
assert!(titles.contains(&"A"));
assert!(titles.contains(&"B"));
}
#[test]
fn test_different_plugins_same_page_id_second_rejected() {
let client = ApiClient::default();
let mut registry = PluginRegistry::new(client);
// First plugin registers "stats" - should succeed.
registry.register_page(
"plugin-a".to_string(),
create_test_page("stats", "A Stats"),
vec![],
);
// Second plugin attempts to register the same page ID "stats" - should be
// rejected to avoid route collisions at /plugins/stats.
registry.register_page(
"plugin-b".to_string(),
create_test_page("stats", "B Stats"),
vec![],
);
// Only one page should be registered; the second was rejected.
assert_eq!(registry.all_pages().len(), 1);
assert_eq!(
registry.get_page("plugin-a", "stats").unwrap().page.title,
"A Stats"
);
assert!(
registry.get_page("plugin-b", "stats").is_none(),
"plugin-b's page with duplicate ID should have been rejected"
);
}
#[test]
fn test_same_plugin_same_page_id_overwrites() {
// Same plugin re-registering the same page ID should still be allowed
// (overwrite semantics, not a cross-plugin conflict).
let client = ApiClient::default();
let mut registry = PluginRegistry::new(client);
registry.register_page(
"plugin-a".to_string(),
create_test_page("stats", "A Stats v1"),
vec![],
);
registry.register_page(
"plugin-a".to_string(),
create_test_page("stats", "A Stats v2"),
vec![],
);
assert_eq!(registry.all_pages().len(), 1);
assert_eq!(
registry.get_page("plugin-a", "stats").unwrap().page.title,
"A Stats v2"
);
}
#[test]
fn test_register_invalid_page_is_skipped() {
use pinakes_plugin_api::UiElement;
let client = ApiClient::default();
let mut registry = PluginRegistry::new(client);
// A page with an empty ID fails validation
let invalid_page = UiPage {
id: String::new(), // invalid: empty
title: "Bad Page".to_string(),
route: "/plugins/bad".to_string(),
icon: None,
root_element: UiElement::Container {
children: vec![],
gap: 16,
padding: None,
},
data_sources: HashMap::new(),
actions: HashMap::new(),
};
registry.register_page("test-plugin".to_string(), invalid_page, vec![]);
assert!(registry.is_empty(), "invalid page should have been skipped");
}
#[test]
fn test_register_valid_page_after_invalid() {
let client = ApiClient::default();
let mut registry = PluginRegistry::new(client);
use pinakes_plugin_api::UiElement;
// Invalid page
let invalid_page = UiPage {
id: String::new(),
title: "Bad".to_string(),
route: "/bad".to_string(),
icon: None,
root_element: UiElement::Container {
children: vec![],
gap: 0,
padding: None,
},
data_sources: HashMap::new(),
actions: HashMap::new(),
};
registry.register_page("p".to_string(), invalid_page, vec![]);
assert_eq!(registry.all_pages().len(), 0);
// Valid page; should still register fine
registry.register_page(
"p".to_string(),
create_test_page("good", "Good"),
vec![],
);
assert_eq!(registry.all_pages().len(), 1);
}
#[test]
fn test_register_invalid_widget_is_skipped() {
let client = ApiClient::default();
let mut registry = PluginRegistry::new(client);
let widget: pinakes_plugin_api::UiWidget =
serde_json::from_value(serde_json::json!({
"id": "my-widget",
"target": "library_header",
"content": { "type": "badge", "text": "hi", "variant": "default" }
}))
.unwrap();
// Mutate: create an invalid widget with empty id
let invalid_widget = pinakes_plugin_api::UiWidget {
id: String::new(), // invalid
target: "library_header".to_string(),
content: widget.content.clone(),
};
assert!(registry.all_widgets().is_empty());
registry.register_widget("test-plugin".to_string(), invalid_widget);
assert!(
registry.all_widgets().is_empty(),
"invalid widget should have been skipped"
);
// Valid widget is still accepted
registry.register_widget("test-plugin".to_string(), widget);
assert_eq!(registry.all_widgets().len(), 1);
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,169 @@
//! Widget injection system for plugin UI
//!
//! Allows plugins to inject small UI elements into existing host pages at
//! predefined locations. Unlike full pages, widgets have no data sources of
//! their own and render with empty data context.
use std::collections::HashMap;
use dioxus::prelude::*;
use pinakes_plugin_api::{ActionDefinition, UiWidget, widget_location};
use super::{
data::PluginPageData,
renderer::{RenderContext, render_element},
};
use crate::client::ApiClient;
/// Predefined injection points in the host UI.
///
/// These correspond to the string constants in
/// `pinakes_plugin_api::widget_location` and determine where a widget is
/// rendered.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum WidgetLocation {
LibraryHeader,
LibrarySidebar,
DetailPanel,
SearchFilters,
SettingsSection,
}
impl WidgetLocation {
/// Returns the canonical string identifier used in plugin manifests.
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::LibraryHeader => widget_location::LIBRARY_HEADER,
Self::LibrarySidebar => widget_location::LIBRARY_SIDEBAR,
Self::DetailPanel => widget_location::DETAIL_PANEL,
Self::SearchFilters => widget_location::SEARCH_FILTERS,
Self::SettingsSection => widget_location::SETTINGS_SECTION,
}
}
}
/// Props for [`WidgetContainer`].
#[derive(Props, PartialEq, Clone)]
pub struct WidgetContainerProps {
/// Injection point to render widgets for.
pub location: WidgetLocation,
/// All widgets from all plugins (`plugin_id`, widget) pairs.
pub widgets: Vec<(String, UiWidget)>,
/// API client. It is actually unused by widgets themselves but threaded
/// through for consistency with the rest of the plugin UI system.
pub client: Signal<ApiClient>,
}
/// Renders all widgets registered for a specific [`WidgetLocation`].
///
/// Returns `None` if no widgets target this location.
///
/// # Usage
///
/// ```rust,ignore
/// // In a host component:
/// WidgetContainer {
/// location: WidgetLocation::LibraryHeader,
/// widgets: plugin_registry.read().all_widgets(),
/// client,
/// }
/// ```
#[component]
pub fn WidgetContainer(props: WidgetContainerProps) -> Element {
let location_str = props.location.as_str();
let matching: Vec<_> = props
.widgets
.iter()
.filter(|(_, w)| w.target == location_str)
.cloned()
.collect();
if matching.is_empty() {
return rsx! {};
}
rsx! {
div { class: "plugin-widget-container", "data-location": location_str,
for (plugin_id , widget) in &matching {
WidgetViewRenderer {
plugin_id: plugin_id.clone(),
widget: widget.clone(),
client: props.client,
}
}
}
}
}
/// Props for [`WidgetViewRenderer`].
#[derive(Props, PartialEq, Clone)]
pub struct WidgetViewRendererProps {
/// Plugin that owns this widget.
pub plugin_id: String,
/// Widget definition to render.
pub widget: UiWidget,
/// API client signal.
pub client: Signal<ApiClient>,
}
/// Renders a single plugin widget with an empty data context.
///
/// Widgets do not declare data sources; they render statically (or use
/// inline expressions with no external data).
#[component]
pub fn WidgetViewRenderer(props: WidgetViewRendererProps) -> Element {
let empty_data = PluginPageData::default();
let feedback = use_signal(|| None::<(String, bool)>);
let navigate = use_signal(|| None::<String>);
let refresh = use_signal(|| 0u32);
let modal = use_signal(|| None::<pinakes_plugin_api::UiElement>);
let local_state = use_signal(HashMap::<String, serde_json::Value>::new);
let ctx = RenderContext {
client: props.client,
feedback,
navigate,
refresh,
modal,
local_state,
};
let empty_actions: HashMap<String, ActionDefinition> = HashMap::new();
rsx! {
div {
class: "plugin-widget",
"data-plugin-id": props.plugin_id.clone(),
"data-widget-id": props.widget.id,
{ render_element(&props.widget.content, &empty_data, &empty_actions, ctx) }
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_widget_location_settings_section_str() {
assert_eq!(WidgetLocation::SettingsSection.as_str(), "settings_section");
}
#[test]
fn test_widget_location_all_variants_unique() {
let locations = [
WidgetLocation::LibraryHeader,
WidgetLocation::LibrarySidebar,
WidgetLocation::DetailPanel,
WidgetLocation::SearchFilters,
WidgetLocation::SettingsSection,
];
let strings: Vec<&str> = locations.iter().map(|l| l.as_str()).collect();
let unique: std::collections::HashSet<_> = strings.iter().collect();
assert_eq!(
strings.len(),
unique.len(),
"all location strings must be unique"
);
}
}

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"
```

View file

@ -0,0 +1,48 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "cfg-if"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "dlmalloc"
version = "0.2.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6738d2e996274e499bc7b0d693c858b7720b9cd2543a0643a3087e6cb0a4fa16"
dependencies = [
"cfg-if",
"libc",
"windows-sys",
]
[[package]]
name = "libc"
version = "0.2.183"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d"
[[package]]
name = "media-stats-ui"
version = "1.0.0"
dependencies = [
"dlmalloc",
]
[[package]]
name = "windows-link"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
[[package]]
name = "windows-sys"
version = "0.61.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
dependencies = [
"windows-link",
]

View file

@ -0,0 +1,20 @@
[workspace]
[package]
name = "media-stats-ui"
version = "1.0.0"
edition = "2024"
description = "Library statistics dashboard and tag manager, a UI-only Pinakes plugin"
license = "EUPL-1.2"
[lib]
name = "media_stats_ui"
crate-type = ["cdylib"]
[dependencies]
dlmalloc = { version = "0.2.12", features = ["global"] }
[profile.release]
opt-level = "s"
lto = true
strip = true

View file

@ -0,0 +1,132 @@
{
"id": "stats",
"title": "Library Statistics",
"route": "/plugins/media-stats-ui/stats",
"icon": "chart-bar",
"layout": {
"type": "tabs",
"default_tab": 0,
"tabs": [
{
"label": "Overview",
"content": {
"type": "container",
"gap": 24,
"children": [
{
"type": "heading",
"level": 2,
"content": "Library Statistics"
},
{
"type": "text",
"content": "Live summary of your media library. Refreshes every 30 seconds.",
"variant": "secondary"
},
{
"type": "card",
"title": "Summary",
"content": [
{
"type": "description_list",
"data": "stats",
"horizontal": true
}
]
},
{
"type": "chart",
"chart_type": "bar",
"data": "type-breakdown",
"title": "Files by Type",
"x_axis_label": "Media Type",
"y_axis_label": "Count",
"height": 280
}
]
}
},
{
"label": "Recent Files",
"content": {
"type": "container",
"gap": 16,
"children": [
{
"type": "heading",
"level": 2,
"content": "Recently Added"
},
{
"type": "data_table",
"data": "recent",
"sortable": true,
"filterable": true,
"page_size": 10,
"columns": [
{
"key": "file_name",
"header": "Filename"
},
{
"key": "title",
"header": "Title"
},
{
"key": "media_type",
"header": "Type"
},
{
"key": "file_size",
"header": "Size",
"data_type": "file_size"
},
{
"key": "created_at",
"header": "Added",
"data_type": "date_time"
}
]
}
]
}
},
{
"label": "Media Grid",
"content": {
"type": "container",
"gap": 16,
"children": [
{
"type": "heading",
"level": 2,
"content": "Browse Media"
},
{
"type": "media_grid",
"data": "recent",
"columns": 4,
"gap": 12
}
]
}
}
]
},
"data_sources": {
"stats": {
"type": "endpoint",
"path": "/api/v1/statistics",
"poll_interval": 30
},
"recent": {
"type": "endpoint",
"path": "/api/v1/media"
},
"type-breakdown": {
"type": "transform",
"source": "stats",
"expression": "stats.media_by_type"
}
}
}

View file

@ -0,0 +1,126 @@
{
"id": "tag-manager",
"title": "Tag Manager",
"route": "/plugins/media-stats-ui/tag-manager",
"icon": "tag",
"layout": {
"type": "tabs",
"default_tab": 0,
"tabs": [
{
"label": "All Tags",
"content": {
"type": "container",
"gap": 16,
"children": [
{
"type": "heading",
"level": 2,
"content": "Manage Tags"
},
{
"type": "conditional",
"condition": {
"op": "eq",
"left": { "function": "len", "args": ["tags"] },
"right": 0
},
"then": {
"type": "text",
"content": "No tags yet. Use the 'Create Tag' tab to add one.",
"variant": "secondary"
},
"else": {
"type": "data_table",
"data": "tags",
"sortable": true,
"filterable": true,
"page_size": 20,
"columns": [
{ "key": "name", "header": "Tag Name" },
{ "key": "color", "header": "Color" },
{ "key": "item_count", "header": "Items", "data_type": "number" }
]
}
}
]
}
},
{
"label": "Create Tag",
"content": {
"type": "container",
"gap": 24,
"children": [
{
"type": "heading",
"level": 2,
"content": "Create New Tag"
},
{
"type": "text",
"content": "Tags are used to organise media items. Choose a name and an optional colour.",
"variant": "secondary"
},
{
"type": "form",
"submit_label": "Create Tag",
"submit_action": "create-tag",
"cancel_label": "Reset",
"fields": [
{
"id": "name",
"label": "Tag Name",
"type": { "type": "text", "max_length": 64 },
"required": true,
"placeholder": "e.g. favourite, to-watch, archived",
"help_text": "Must be unique. Alphanumeric characters, spaces, and hyphens.",
"validation": [
{ "type": "min_length", "value": 1 },
{ "type": "max_length", "value": 64 },
{ "type": "pattern", "regex": "^[a-zA-Z0-9 \\-]+$" }
]
},
{
"id": "color",
"label": "Colour",
"type": {
"type": "select",
"options": [
{ "value": "#ef4444", "label": "Red" },
{ "value": "#f97316", "label": "Orange" },
{ "value": "#eab308", "label": "Yellow" },
{ "value": "#22c55e", "label": "Green" },
{ "value": "#3b82f6", "label": "Blue" },
{ "value": "#8b5cf6", "label": "Purple" },
{ "value": "#ec4899", "label": "Pink" },
{ "value": "#6b7280", "label": "Grey" }
]
},
"required": false,
"default_value": "#3b82f6",
"help_text": "Optional accent colour shown beside the tag."
}
]
}
]
}
}
]
},
"data_sources": {
"tags": {
"type": "endpoint",
"path": "/api/v1/tags",
"poll_interval": 0
}
},
"actions": {
"create-tag": {
"method": "POST",
"path": "/api/v1/tags",
"success_message": "Tag created successfully!",
"error_message": "Failed to create tag: the name may already be in use."
}
}
}

View file

@ -0,0 +1,39 @@
[plugin]
name = "media-stats-ui"
version = "1.0.0"
api_version = "1.0"
author = "Pinakes Contributors"
description = "Library statistics dashboard and tag manager UI plugin"
homepage = "https://github.com/notashelf/pinakes"
license = "EUPL-1.2"
kind = ["ui_page"]
[plugin.binary]
wasm = "target/wasm32-unknown-unknown/release/media_stats_ui.wasm"
[capabilities]
network = false
[capabilities.filesystem]
read = []
write = []
[ui]
required_endpoints = ["/api/v1/statistics", "/api/v1/media", "/api/v1/tags"]
# UI pages
[[ui.pages]]
file = "pages/stats.json"
[[ui.pages]]
file = "pages/tag-manager.json"
# Widgets injected into host views
[[ui.widgets]]
id = "stats-badge"
target = "library_header"
[ui.widgets.content]
type = "badge"
text = "Stats"
variant = "info"

View file

@ -0,0 +1,101 @@
//! Media Stats UI - Pinakes plugin
//!
//! A UI-only plugin that adds a library statistics dashboard and a tag manager
//! page. All UI definitions live in `pages/stats.json` and
//! `pages/tag-manager.json`; this WASM binary provides the minimum lifecycle
//! surface the host runtime requires.
//!
//! This plugin is kind = ["ui_page"]: no media-type, metadata, thumbnail, or
//! event-handler extension points are needed. The host will never call them,
//! but exporting them avoids linker warnings if the host performs capability
//! discovery via symbol inspection.
#![no_std]
extern crate alloc;
use core::alloc::Layout;
#[global_allocator]
static ALLOC: dlmalloc::GlobalDlmalloc = dlmalloc::GlobalDlmalloc;
#[panic_handler]
fn panic(_: &core::panic::PanicInfo) -> ! {
core::arch::wasm32::unreachable()
}
// Host functions provided by the Pinakes runtime.
unsafe extern "C" {
// Write a result value back to the host (ptr + byte length).
fn host_set_result(ptr: i32, len: i32);
// Emit a structured log message to the host logger.
// `level` mirrors tracing severity: 0=trace 1=debug 2=info 3=warn 4=error
fn host_log(level: i32, ptr: i32, len: i32);
}
/// # Safety
///
/// `json` is a valid slice; the host copies the bytes before
/// returning so there are no lifetime concerns.
fn set_response(json: &[u8]) {
unsafe { host_set_result(json.as_ptr() as i32, json.len() as i32) }
}
/// # Safety
///
/// Same as [`set_response`]
fn log_info(msg: &[u8]) {
unsafe { host_log(2, msg.as_ptr() as i32, msg.len() as i32) }
}
/// Allocate a buffer for the host to write request data into.
///
/// # Returns
///
/// The byte offset of the allocation, or -1 on failure.
///
/// # Safety
///
/// Size is positive; Layout construction cannot fail for align=1.
#[unsafe(no_mangle)]
pub extern "C" fn alloc(size: i32) -> i32 {
if size <= 0 {
return 0;
}
unsafe {
let layout = Layout::from_size_align_unchecked(size as usize, 1);
let ptr = alloc::alloc::alloc(layout);
if ptr.is_null() { -1 } else { ptr as i32 }
}
}
/// Called once after the plugin is loaded. Returns 0 on success.
#[unsafe(no_mangle)]
pub extern "C" fn initialize() -> i32 {
log_info(b"media-stats-ui: initialized");
0
}
/// Called before the plugin is unloaded. Returns 0 on success.
#[unsafe(no_mangle)]
pub extern "C" fn shutdown() -> i32 {
log_info(b"media-stats-ui: shutdown");
0
}
/// # Returns
///
/// an empty JSON array; this plugin adds no custom media types.
#[unsafe(no_mangle)]
pub extern "C" fn supported_media_types(_ptr: i32, _len: i32) {
set_response(b"[]");
}
/// # Returns
///
/// An empty JSON array; this plugin handles no event types.
#[unsafe(no_mangle)]
pub extern "C" fn interested_events(_ptr: i32, _len: i32) {
set_response(b"[]");
}