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