Compare commits

...

19 commits

Author SHA1 Message Date
7cbce98795
docs/plugins: detail GUI plugin usage; separate server & GUI plugins
Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I2060db637209655390a86facd004bc646a6a6964
2026-03-12 19:41:23 +03:00
0014a1a2a9
chore: fix clippy lints; format
Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: Ib3d98a81c7e41054d27e617394bef63c6a6a6964
2026-03-12 19:41:22 +03:00
e1351e8881
examples/media-stats-ui: fix Transform source key; add file_name column
Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I4c741e4b36708f2078fed8154d7341de6a6a6964
2026-03-12 19:41:21 +03:00
81d1695e11
pinakes-ui: integrate plugin pages into sidebar navigation; sanitize theme-extension CSS eval
Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: Ie87e39c66253a7071f029d52dd5979716a6a6964
2026-03-12 19:41:20 +03:00
63954fdb2f
pinakes-ui: supply local_state to Conditional and Progress; remove last_refresh
Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: Ib513b5846d6c74bfe821da195b7080af6a6a6964
2026-03-12 19:41:19 +03:00
220dfa6506
pinakes-ui: add plugin component stylesheet
Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I05de526f0cea5df269b0fee226ef1edf6a6a6964
2026-03-12 19:41:18 +03:00
7989d4c4dd
pinakes-plugin-api: add reserved-route and required-endpoint validation
Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: Id85a7e729b26af8eb028e19418a5a1706a6a6964
2026-03-12 19:41:17 +03:00
91123fc90e
pinakes-core: use InvalidOperation for nil media_id in upsert_book_metadata
Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I72a80731d926b79660abf20c2c766e8c6a6a6964
2026-03-12 19:41:16 +03:00
185e3b562a
treewide: cleanup
Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: Ia01590cdeed872cc8ebd16f6ca95f3cc6a6a6964
2026-03-12 19:41:15 +03:00
0ba898c881
pinakes-core: check file existence before removal in TempFileGuard drop
Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I800825f5dc3b526d350931ff8f1ed0da6a6a6964
2026-03-12 19:41:14 +03:00
0c9b71346d
pinakes-core: map serde_json errors to Serialization variant in export
Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I77c27639ea1aca03d54702e38fc3ef576a6a6964
2026-03-12 19:41:13 +03:00
15b005cef0
pinakes-core: expose required_endpoints alongside UI pages in plugin manager
Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I32c95a03f106db8fef7eedd0362756a46a6a6964
2026-03-12 19:41:12 +03:00
dc4dc41670
pinakes-plugin-api: consolidate reserved-route check; reject widget data-source refs
Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I042ee31e95822f46520a618de8dcaf786a6a6964
2026-03-12 19:41:11 +03:00
3678edd355
meta: prefer std's OnceLock and LazyLock over once_cell
Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I35d51abfa9a790206391dca891799d956a6a6964
2026-03-12 19:41:10 +03:00
119f6d2e06
examples: add media-stats-ui plugin
Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I7c9ccac175440d278fd129dbd53f04d66a6a6964
2026-03-12 19:41:09 +03:00
cf76d42c33
pinakes-core: add integration tests for batch_update_media
Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I0787bec99f7c1d098c1c1168560a43266a6a6964
2026-03-12 19:41:08 +03:00
592a9bcc47
pinakes-core: add error context to tag and collection writes; map serde_json errors to Serialization variant
pinakes-core: distinguish task panics from cancellations in import error
  handling
Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: Icf5686f34144630ebf1935c47b3979156a6a6964
2026-03-12 19:41:07 +03:00
8f2b44b50c
pinakes-core: unify book metadata extraction; remove ExtractedBookMetadata
Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: Ifd6e66515b9ff78a4bb13eba47b9b2cf6a6a6964
2026-03-12 19:41:06 +03:00
9c67c81a79
pinakes-server: relativize media paths against configured root directories
Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I9f113e6402030c46ad97f636985b5d6c6a6a6964
2026-03-12 19:41:05 +03:00
41 changed files with 3081 additions and 393 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -602,33 +602,107 @@ impl PluginManager {
/// 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.
/// 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;
}
for entry in &plugin.manifest.ui.pages {
let page = match entry {
pinakes_plugin_api::manifest::UiPageEntry::Inline(page) => {
(**page).clone()
},
pinakes_plugin_api::manifest::UiPageEntry::File { .. } => {
// File-referenced pages require a base path to resolve;
// skip them here as they should have been loaded at startup.
continue;
},
};
pages.push((plugin.id.clone(), page));
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;
@ -746,6 +820,7 @@ mod tests {
},
capabilities: Default::default(),
config: Default::default(),
ui: Default::default(),
}
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -759,11 +759,19 @@ wasm = "plugin.wasm"
let manifest = PluginManifest::parse_str(toml).unwrap();
assert_eq!(
manifest.ui.theme_extensions.get("--accent-color").map(String::as_str),
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),
manifest
.ui
.theme_extensions
.get("--sidebar-width")
.map(String::as_str),
Some("280px")
);
}

View file

@ -25,7 +25,7 @@
//! "sidebar": {
//! "type": "list",
//! "data": "playlists",
//! "item_template": { "type": "text", "content": "{{title}}" }
//! "item_template": { "type": "text", "content": "title" }
//! },
//! "main": {
//! "type": "data_table",
@ -40,6 +40,11 @@
//! "playlists": { "type": "endpoint", "path": "/api/v1/collections" }
//! }
//! }
//!
//! Note: expression values are `Expression::Path` strings, not mustache
//! templates. A bare string like `"title"` resolves the `title` field in the
//! current item context. Nested fields use dotted segments: `"artist.name"`.
//! Array indices use the same notation: `"items.0.title"`.
//! ```
use std::collections::HashMap;
@ -102,6 +107,7 @@ pub type SchemaResult<T> = Result<T, SchemaError>;
/// padding: None,
/// },
/// data_sources: Default::default(),
/// actions: Default::default(),
/// };
/// ```
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
@ -127,6 +133,10 @@ pub struct UiPage {
/// Named data sources available to this page
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub data_sources: HashMap<String, DataSource>,
/// Named actions available to this page (referenced by `ActionRef::Name`)
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub actions: HashMap<String, ActionDefinition>,
}
impl UiPage {
@ -151,6 +161,13 @@ impl UiPage {
));
}
if crate::validation::SchemaValidator::is_reserved_route(&self.route) {
return Err(SchemaError::ValidationError(format!(
"Route '{}' conflicts with a built-in app route",
self.route
)));
}
let depth = self.root_element.depth();
if depth > MAX_ELEMENT_DEPTH {
return Err(SchemaError::DepthLimitExceeded);
@ -158,6 +175,11 @@ impl UiPage {
self.root_element.validate(self)?;
for (name, action) in &self.actions {
validate_id(name)?;
action.validate()?;
}
for (name, source) in &self.data_sources {
validate_id(name)?;
source.validate()?;
@ -246,6 +268,23 @@ pub struct UiWidget {
pub content: UiElement,
}
impl UiWidget {
/// Validates this widget definition
///
/// # Errors
///
/// Returns `SchemaError::ValidationError` if validation fails
pub fn validate(&self) -> SchemaResult<()> {
if self.target.is_empty() {
return Err(SchemaError::ValidationError(
"Widget target cannot be empty".to_string(),
));
}
validate_id(&self.id)?;
Ok(())
}
}
/// String constants for widget injection locations.
///
/// Use these with `UiWidget::target` in plugin manifests:
@ -259,6 +298,7 @@ pub mod widget_location {
pub const LIBRARY_SIDEBAR: &str = "library_sidebar";
pub const DETAIL_PANEL: &str = "detail_panel";
pub const SEARCH_FILTERS: &str = "search_filters";
pub const SETTINGS_SECTION: &str = "settings_section";
}
/// Core UI element enum - the building block of all plugin UIs
@ -761,11 +801,6 @@ impl UiElement {
)));
}
},
Self::Form { fields, .. } if fields.is_empty() => {
return Err(SchemaError::ValidationError(
"Form must have at least one field".to_string(),
));
},
Self::Chart { data, .. } if !page.data_sources.contains_key(data) => {
return Err(SchemaError::ValidationError(format!(
"Chart references unknown data source: {data}"
@ -817,11 +852,21 @@ impl UiElement {
Self::Button { action, .. } => {
action.validate()?;
},
Self::Link { href, .. } if !is_safe_href(href) => {
return Err(SchemaError::ValidationError(format!(
"Link href has a disallowed scheme (must be '/', 'http://', or 'https://'): {href}"
)));
},
Self::Form {
fields,
submit_action,
..
} => {
if fields.is_empty() {
return Err(SchemaError::ValidationError(
"Form must have at least one field".to_string(),
));
}
for field in fields {
validate_id(&field.id)?;
if field.label.is_empty() {
@ -1046,7 +1091,7 @@ pub struct ColumnDef {
}
/// Row action for `DataTable`
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct RowAction {
/// Action identifier (unique within this table)
pub id: String,
@ -1290,15 +1335,60 @@ pub enum ChartType {
Scatter,
}
/// Client-side action types that do not require an HTTP call.
///
/// Used as `{"action": "<kind>", ...}` in JSON.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(tag = "action", rename_all = "snake_case")]
pub enum SpecialAction {
/// Trigger a data refresh (re-runs all data sources for the current page).
Refresh,
/// Navigate to a different route.
Navigate {
/// Target route path (must start with `/`)
to: String,
},
/// Emit a named event to the server-side plugin event bus.
Emit {
/// Event name
event: String,
/// Optional payload (any JSON value)
#[serde(default)]
payload: serde_json::Value,
},
/// Update a local state key (resolved against the current data context).
UpdateState {
/// State key name
key: String,
/// Expression whose value is stored at `key`
value: Expression,
},
/// Open a modal overlay containing the given element.
OpenModal {
/// Element to render inside the modal
content: Box<UiElement>,
},
/// Close the currently open modal overlay.
CloseModal,
}
/// Action reference - identifies an action to execute
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
///
/// Deserialization order for `#[serde(untagged)]`:
/// 1. `Special` - JSON objects with an `"action"` string key
/// 2. `Inline` - JSON objects with a `"path"` key
/// 3. `Name` - bare JSON strings
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(untagged)]
pub enum ActionRef {
/// Client-side special action (no HTTP call required)
Special(SpecialAction),
/// Inline action definition (HTTP call)
Inline(ActionDefinition),
/// Simple action name (references page.actions)
Name(String),
/// Inline action definition
Inline(ActionDefinition),
}
impl ActionRef {
@ -1312,6 +1402,26 @@ impl ActionRef {
/// Returns `SchemaError::ValidationError` if validation fails.
pub fn validate(&self) -> SchemaResult<()> {
match self {
Self::Special(s) => {
match s {
SpecialAction::Navigate { to } if to.is_empty() => {
return Err(SchemaError::ValidationError(
"Navigate.to cannot be empty".to_string(),
));
},
SpecialAction::UpdateState { key, .. } if key.is_empty() => {
return Err(SchemaError::ValidationError(
"UpdateState.key cannot be empty".to_string(),
));
},
SpecialAction::Emit { event, .. } if event.is_empty() => {
return Err(SchemaError::ValidationError(
"Emit.event cannot be empty".to_string(),
));
},
_ => {},
}
},
Self::Name(name) => {
if name.is_empty() {
return Err(SchemaError::ValidationError(
@ -1376,6 +1486,18 @@ impl ActionDefinition {
self.path
)));
}
if !self.path.starts_with("/api/") {
return Err(SchemaError::ValidationError(format!(
"Action path must start with '/api/': {}",
self.path
)));
}
if self.path.contains("..") {
return Err(SchemaError::ValidationError(format!(
"Action path contains invalid traversal sequence: {}",
self.path
)));
}
Ok(())
}
}
@ -1462,6 +1584,16 @@ impl DataSource {
"Endpoint path must start with '/': {path}"
)));
}
if !path.starts_with("/api/") {
return Err(SchemaError::InvalidDataSource(format!(
"Endpoint path must start with '/api/': {path}"
)));
}
if path.contains("..") {
return Err(SchemaError::InvalidDataSource(format!(
"Endpoint path contains invalid traversal sequence: {path}"
)));
}
},
Self::Transform { source_name, .. } => {
validate_id(source_name)?;
@ -1475,16 +1607,31 @@ impl DataSource {
/// Expression for dynamic value evaluation
///
/// Expressions use JSONPath-like syntax for data access.
///
/// ## JSON representation (serde untagged; order matters)
///
/// Variants are tried in declaration order during deserialization:
///
/// | JSON shape | Deserializes as |
/// |---------------------------------------------------|-----------------|
/// | `"users.0.name"` (string) | `Path` |
/// | `{"left":…,"op":"eq","right":…}` (object) | `Operation` |
/// | `{"function":"len","args":[…]}` (object) | `Call` |
/// | `42`, `true`, `null`, `[…]`, `{other fields}` … | `Literal` |
///
/// `Literal` is intentionally last so that the more specific variants take
/// priority. A bare JSON string is always a **path reference**; to embed a
/// literal string value use `DataSource::Static` or a `Call` expression.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(untagged)]
pub enum Expression {
/// Literal JSON value
Literal(serde_json::Value),
/// Data path reference (e.g., "$.users[0].name")
/// Data path reference: a dotted key sequence walked against the context.
///
/// e.g. `"user.name"` resolves to `ctx["user"]["name"]`; `"items.0"` resolves
/// to the first element.
Path(String),
/// Binary operation
/// Binary operation applied to two sub-expressions.
Operation {
/// Left operand
left: Box<Self>,
@ -1494,13 +1641,22 @@ pub enum Expression {
right: Box<Self>,
},
/// Function call
/// Built-in function call.
///
/// e.g. `{"function": "len", "args": ["tags"]}` returns the count of items
/// in the `tags` data source.
Call {
/// Function name
/// Function name (see built-in function table in docs)
function: String,
/// Function arguments
/// Positional arguments, each an `Expression`
args: Vec<Self>,
},
/// Literal JSON value: a constant that is returned unchanged.
///
/// Matches numbers, booleans, null, arrays, and objects that do not match
/// the `Operation` or `Call` shapes above.
Literal(serde_json::Value),
}
impl Default for Expression {
@ -1579,6 +1735,18 @@ const fn default_http_method() -> HttpMethod {
HttpMethod::Get
}
/// Returns `true` if `href` uses a scheme safe to render in an anchor element.
///
/// Allows relative paths (`/`), plain `http://`, and `https://`. Rejects
/// `javascript:`, `data:`, `vbscript:`, and any other scheme that could be
/// used for script injection or data exfiltration.
#[must_use]
pub fn is_safe_href(href: &str) -> bool {
href.starts_with('/')
|| href.starts_with("https://")
|| href.starts_with("http://")
}
/// Validates an identifier string
///
/// IDs must:
@ -1729,6 +1897,7 @@ mod tests {
row_actions: vec![],
},
data_sources: HashMap::new(),
actions: HashMap::new(),
};
let refs = page.referenced_data_sources();
@ -1748,6 +1917,7 @@ mod tests {
gap: 16,
},
data_sources: HashMap::new(),
actions: HashMap::new(),
};
assert!(page.validate().is_err());
@ -1766,8 +1936,288 @@ mod tests {
id: None,
},
data_sources: HashMap::new(),
actions: HashMap::new(),
};
assert!(page.validate().is_err());
}
// Expression JSON round-trip tests
/// A JSON string must deserialise as Path, not Literal.
#[test]
fn test_expression_string_deserialises_as_path() {
let expr: Expression = serde_json::from_str(r#""user.name""#).unwrap();
assert_eq!(expr, Expression::Path("user.name".to_string()));
}
/// A JSON number must deserialise as Literal, not Path.
#[test]
fn test_expression_number_deserialises_as_literal() {
let expr: Expression = serde_json::from_str("42").unwrap();
assert_eq!(expr, Expression::Literal(serde_json::json!(42)));
}
/// An Operation object is correctly deserialised.
#[test]
fn test_expression_operation_deserialises() {
let json = r#"{"left": "count", "op": "gt", "right": 0}"#;
let expr: Expression = serde_json::from_str(json).unwrap();
match expr {
Expression::Operation { left, op, right } => {
assert_eq!(*left, Expression::Path("count".to_string()));
assert_eq!(op, Operator::Gt);
assert_eq!(*right, Expression::Literal(serde_json::json!(0)));
},
other => panic!("expected Operation, got {other:?}"),
}
}
/// A Call object is correctly deserialised.
#[test]
fn test_expression_call_deserialises() {
let json = r#"{"function": "len", "args": ["items"]}"#;
let expr: Expression = serde_json::from_str(json).unwrap();
match expr {
Expression::Call { function, args } => {
assert_eq!(function, "len");
assert_eq!(args, vec![Expression::Path("items".to_string())]);
},
other => panic!("expected Call, got {other:?}"),
}
}
/// Path expressions survive a full JSON round-trip.
#[test]
fn test_expression_path_round_trip() {
let original = Expression::Path("a.b.c".to_string());
let json = serde_json::to_string(&original).unwrap();
let recovered: Expression = serde_json::from_str(&json).unwrap();
assert_eq!(original, recovered);
}
// DataSource/ActionDefinition security validation tests
#[test]
fn test_endpoint_path_must_start_with_api() {
let bad = DataSource::Endpoint {
method: HttpMethod::Get,
path: "/not-api/something".to_string(),
params: HashMap::new(),
poll_interval: 0,
transform: None,
};
assert!(bad.validate().is_err());
}
#[test]
fn test_endpoint_path_rejects_traversal() {
let bad = DataSource::Endpoint {
method: HttpMethod::Get,
path: "/api/v1/../admin".to_string(),
params: HashMap::new(),
poll_interval: 0,
transform: None,
};
assert!(bad.validate().is_err());
}
#[test]
fn test_action_path_must_start_with_api() {
let bad = ActionDefinition {
method: HttpMethod::Post,
path: "/admin/reset".to_string(),
..ActionDefinition::default()
};
assert!(bad.validate().is_err());
}
#[test]
fn test_action_path_rejects_traversal() {
let bad = ActionDefinition {
method: HttpMethod::Post,
path: "/api/v1/tags/../../auth/login".to_string(),
..ActionDefinition::default()
};
assert!(bad.validate().is_err());
}
// Link href safety tests
#[test]
fn test_is_safe_href_allows_relative() {
assert!(is_safe_href("/some/path"));
}
#[test]
fn test_is_safe_href_allows_https() {
assert!(is_safe_href("https://example.com/page"));
}
#[test]
fn test_is_safe_href_allows_http() {
assert!(is_safe_href("http://example.com/page"));
}
#[test]
fn test_is_safe_href_rejects_javascript() {
assert!(!is_safe_href("javascript:alert(1)"));
}
#[test]
fn test_is_safe_href_rejects_data_uri() {
assert!(!is_safe_href("data:text/html,<script>alert(1)</script>"));
}
#[test]
fn test_is_safe_href_rejects_vbscript() {
assert!(!is_safe_href("vbscript:msgbox(1)"));
}
#[test]
fn test_link_validation_rejects_unsafe_href() {
use std::collections::HashMap as HM;
let page = UiPage {
id: "p".to_string(),
title: "P".to_string(),
route: "/api/plugins/p/p".to_string(),
icon: None,
root_element: UiElement::Link {
text: "click".to_string(),
href: "javascript:alert(1)".to_string(),
external: false,
},
data_sources: HM::new(),
actions: HM::new(),
};
assert!(page.validate().is_err());
}
#[test]
fn test_reserved_route_rejected() {
use std::collections::HashMap as HM;
let page = UiPage {
id: "search-page".to_string(),
title: "Search".to_string(),
route: "/search".to_string(),
icon: None,
root_element: UiElement::Container {
children: vec![],
gap: 0,
padding: None,
},
data_sources: HM::new(),
actions: HM::new(),
};
let err = page.validate().unwrap_err();
assert!(
matches!(err, SchemaError::ValidationError(_)),
"expected ValidationError, got {err:?}"
);
assert!(
format!("{err}").contains("/search"),
"error should mention the conflicting route"
);
}
// --- SpecialAction JSON round-trips ---
#[test]
fn test_special_action_refresh_roundtrip() {
let action = SpecialAction::Refresh;
let json = serde_json::to_value(&action).unwrap();
assert_eq!(json["action"], "refresh");
let back: SpecialAction = serde_json::from_value(json).unwrap();
assert_eq!(back, SpecialAction::Refresh);
}
#[test]
fn test_special_action_navigate_roundtrip() {
let action = SpecialAction::Navigate {
to: "/foo".to_string(),
};
let json = serde_json::to_value(&action).unwrap();
assert_eq!(json["action"], "navigate");
assert_eq!(json["to"], "/foo");
let back: SpecialAction = serde_json::from_value(json).unwrap();
assert_eq!(back, SpecialAction::Navigate {
to: "/foo".to_string(),
});
}
#[test]
fn test_special_action_emit_roundtrip() {
let action = SpecialAction::Emit {
event: "my-event".to_string(),
payload: serde_json::json!({"key": "val"}),
};
let json = serde_json::to_value(&action).unwrap();
assert_eq!(json["action"], "emit");
assert_eq!(json["event"], "my-event");
let back: SpecialAction = serde_json::from_value(json).unwrap();
assert_eq!(back, action);
}
#[test]
fn test_special_action_update_state_roundtrip() {
let action = SpecialAction::UpdateState {
key: "my-key".to_string(),
value: Expression::Literal(serde_json::json!(42)),
};
let json = serde_json::to_value(&action).unwrap();
assert_eq!(json["action"], "update_state");
assert_eq!(json["key"], "my-key");
let back: SpecialAction = serde_json::from_value(json).unwrap();
assert_eq!(back, action);
}
#[test]
fn test_special_action_close_modal_roundtrip() {
let action = SpecialAction::CloseModal;
let json = serde_json::to_value(&action).unwrap();
assert_eq!(json["action"], "close_modal");
let back: SpecialAction = serde_json::from_value(json).unwrap();
assert_eq!(back, SpecialAction::CloseModal);
}
// --- ActionRef deserialization ordering ---
#[test]
fn test_action_ref_special_refresh_deserializes() {
let json = serde_json::json!({"action": "refresh"});
let action_ref: ActionRef = serde_json::from_value(json).unwrap();
assert!(matches!(
action_ref,
ActionRef::Special(SpecialAction::Refresh)
));
}
#[test]
fn test_action_ref_special_navigate_deserializes() {
let json = serde_json::json!({"action": "navigate", "to": "/foo"});
let action_ref: ActionRef = serde_json::from_value(json).unwrap();
assert!(matches!(
action_ref,
ActionRef::Special(SpecialAction::Navigate { to }) if to == "/foo"
));
}
#[test]
fn test_action_ref_name_still_works() {
let json = serde_json::json!("my-action");
let action_ref: ActionRef = serde_json::from_value(json).unwrap();
assert!(matches!(action_ref, ActionRef::Name(n) if n == "my-action"));
}
#[test]
fn test_action_ref_special_takes_priority_over_inline() {
// An object with "action":"refresh" must be SpecialAction, not
// misinterpreted as ActionDefinition.
let json = serde_json::json!({"action": "refresh"});
let action_ref: ActionRef = serde_json::from_value(json).unwrap();
assert!(
matches!(action_ref, ActionRef::Special(_)),
"SpecialAction must be matched before ActionDefinition"
);
}
}

View file

@ -122,6 +122,10 @@ impl SchemaValidator {
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 {
@ -132,19 +136,9 @@ impl SchemaValidator {
/// Recursively validate a [`UiElement`] subtree.
pub fn validate_element(element: &UiElement, errors: &mut Vec<String>) {
match element {
UiElement::Container { children, .. } => {
for child in children {
Self::validate_element(child, errors);
}
},
UiElement::Grid { children, .. } => {
for child in children {
Self::validate_element(child, errors);
}
},
UiElement::Flex { children, .. } => {
UiElement::Container { children, .. }
| UiElement::Grid { children, .. }
| UiElement::Flex { children, .. } => {
for child in children {
Self::validate_element(child, errors);
}
@ -206,10 +200,15 @@ impl SchemaValidator {
}
},
UiElement::List { data, .. } => {
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
@ -226,6 +225,66 @@ impl SchemaValidator {
}
}
/// 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,
@ -243,6 +302,12 @@ impl SchemaValidator {
"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() {
@ -264,9 +329,11 @@ impl SchemaValidator {
&& chars.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
}
fn is_reserved_route(route: &str) -> bool {
pub(crate) fn is_reserved_route(route: &str) -> bool {
RESERVED_ROUTES.iter().any(|reserved| {
route == *reserved || route.starts_with(&format!("{reserved}/"))
route == *reserved
|| (route.starts_with(reserved)
&& route.as_bytes().get(reserved.len()) == Some(&b'/'))
})
}
}
@ -290,6 +357,7 @@ mod tests {
padding: None,
},
data_sources: HashMap::new(),
actions: HashMap::new(),
}
}
@ -580,4 +648,81 @@ mod tests {
};
assert!(SchemaValidator::validate_page(&page).is_err());
}
#[test]
fn test_widget_badge_content_passes_validation() {
let widget = crate::UiWidget {
id: "status-badge".to_string(),
target: "library_header".to_string(),
content: UiElement::Badge {
text: "active".to_string(),
variant: Default::default(),
},
};
assert!(
SchemaValidator::validate_widget(&widget).is_ok(),
"a widget with Badge content should pass validation"
);
}
#[test]
fn test_widget_datatable_fails_validation() {
let col: crate::ColumnDef =
serde_json::from_value(serde_json::json!({"key": "id", "header": "ID"}))
.unwrap();
let widget = crate::UiWidget {
id: "my-widget".to_string(),
target: "library_header".to_string(),
content: UiElement::DataTable {
data: "items".to_string(),
columns: vec![col],
sortable: false,
filterable: false,
page_size: 0,
row_actions: vec![],
},
};
let result = SchemaValidator::validate_widget(&widget);
assert!(
result.is_err(),
"DataTable in widget should fail validation"
);
let err = result.unwrap_err().to_string();
assert!(
err.contains("cannot reference data sources"),
"error message should mention data sources: {err}"
);
}
#[test]
fn test_widget_container_with_loop_fails_validation() {
// Container whose child is a Loop - recursive check must catch it
let widget = crate::UiWidget {
id: "loop-widget".to_string(),
target: "library_header".to_string(),
content: UiElement::Container {
children: vec![UiElement::Loop {
data: "items".to_string(),
template: Box::new(UiElement::Text {
content: Default::default(),
variant: Default::default(),
allow_html: false,
}),
empty: None,
}],
gap: 0,
padding: None,
},
};
let result = SchemaValidator::validate_widget(&widget);
assert!(
result.is_err(),
"Container wrapping a Loop should fail widget validation"
);
let err = result.unwrap_err().to_string();
assert!(
err.contains("cannot reference data sources"),
"error message should mention data sources: {err}"
);
}
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -21,8 +21,10 @@ use crate::{
/// Check whether a user has access to a playlist.
///
/// * `require_write` when `true` only the playlist owner is allowed (for
/// mutations such as update, delete, add/remove/reorder items). When `false`
/// # Arguments
///
/// * `require_write` - when `true` only the playlist owner is allowed (for
/// mutations such as update, delete, add/remove/reorder items). When `false`
/// the playlist must either be public or owned by the requesting user.
async fn check_playlist_access(
storage: &pinakes_core::storage::DynStorageBackend,
@ -185,7 +187,13 @@ pub async fn list_items(
let user_id = resolve_user_id(&state.storage, &username).await?;
check_playlist_access(&state.storage, id, user_id, false).await?;
let items = state.storage.get_playlist_items(id).await?;
Ok(Json(items.into_iter().map(MediaResponse::from).collect()))
let roots = state.config.read().await.directories.roots.clone();
Ok(Json(
items
.into_iter()
.map(|item| MediaResponse::new(item, &roots))
.collect(),
))
}
pub async fn reorder_item(
@ -213,5 +221,11 @@ pub async fn shuffle_playlist(
use rand::seq::SliceRandom;
let mut items = state.storage.get_playlist_items(id).await?;
items.shuffle(&mut rand::rng());
Ok(Json(items.into_iter().map(MediaResponse::from).collect()))
let roots = state.config.read().await.directories.roots.clone();
Ok(Json(
items
.into_iter()
.map(|item| MediaResponse::new(item, &roots))
.collect(),
))
}

View file

@ -153,10 +153,12 @@ pub async fn list_plugin_ui_pages(
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,
.map(|(plugin_id, page, allowed_endpoints)| {
PluginUiPageEntry {
plugin_id,
page,
allowed_endpoints,
}
})
.collect();
Ok(Json(entries))

View file

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

View file

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

View file

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

File diff suppressed because one or more lines are too long

View file

@ -7,6 +7,20 @@
// 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;
@ -65,20 +79,568 @@
min-width: 0;
}
// Media grid reuses the same column/gap variables as .plugin-grid.
// 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);
}
// Table column with a plugin-specified fixed width.
// The width is passed as --plugin-col-width on the th element.
.plugin-col-constrained {
width: var(--plugin-col-width);
.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;
}
// Progress bar: the fill element carries --plugin-progress.
.plugin-progress-bar {
height: 100%;
background: $accent;
@ -87,8 +649,140 @@
width: var(--plugin-progress, 0%);
}
.plugin-progress-label {
font-size: $font-size-sm;
color: $text-1;
white-space: nowrap;
flex-shrink: 0;
}
// Chart wrapper: height is driven by --plugin-chart-height.
.plugin-chart {
overflow: auto;
height: var(--plugin-chart-height, 200px);
.chart-title {
font-size: $font-size-lg;
font-weight: $font-weight-semibold;
color: $text-0;
margin-bottom: $space-4;
}
.chart-x-label, .chart-y-label {
font-size: $font-size-sm;
color: $text-2;
margin-bottom: $space-2;
}
.chart-data-table {
overflow-x: auto;
}
.chart-no-data {
padding: $space-12;
text-align: center;
color: $text-2;
font-size: $font-size-md;
}
}
// Loading / error states
.plugin-loading {
padding: $space-8;
color: $text-1;
font-size: $font-size-md;
font-style: italic;
}
.plugin-error {
padding: $space-6 $space-8;
background: $error-bg;
border: 1px solid $error-border;
border-radius: $radius;
color: $error-text;
font-size: $font-size-md;
}
// Feedback toast
.plugin-feedback {
position: sticky;
bottom: $space-8;
display: flex;
align-items: center;
justify-content: space-between;
gap: $space-8;
padding: $space-6 $space-8;
border-radius: $radius-md;
font-size: $font-size-md;
z-index: $z-toast;
box-shadow: $shadow-lg;
&.success {
background: $success-bg;
border: 1px solid $success-border;
color: $success;
}
&.error {
background: $error-bg;
border: 1px solid $error-border;
color: $error-text;
}
}
.plugin-feedback-dismiss {
background: transparent;
border: none;
color: inherit;
font-size: $font-size-xl;
cursor: pointer;
line-height: 1;
padding: 0;
opacity: 0.7;
&:hover { opacity: 1; }
}
// Modal
.plugin-modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.65);
display: flex;
align-items: center;
justify-content: center;
z-index: $z-modal-backdrop;
}
.plugin-modal {
position: relative;
background: $bg-2;
border: 1px solid $border-strong;
border-radius: $radius-xl;
padding: $space-16;
min-width: 380px;
max-width: 640px;
max-height: 80vh;
overflow-y: auto;
box-shadow: $shadow-lg;
z-index: $z-modal;
}
.plugin-modal-close {
position: absolute;
top: $space-8;
right: $space-8;
background: transparent;
border: none;
color: $text-1;
font-size: $font-size-xl;
cursor: pointer;
line-height: 1;
padding: $space-2;
border-radius: $radius;
&:hover {
background: $overlay-medium;
color: $text-0;
}
}

View file

@ -369,11 +369,13 @@ pub fn App() -> Element {
spawn(async move {
let js: String = vars
.iter()
.map(|(k, v)| {
format!(
"document.documentElement.style.setProperty('{}','{}');",
k, v
)
.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;
@ -849,17 +851,6 @@ pub fn App() -> Element {
}
}
}
{
let sync_time_opt = plugin_registry
.read()
.last_refresh()
.map(|ts| ts.format("%H:%M").to_string());
rsx! {
if let Some(sync_time) = sync_time_opt {
div { class: "nav-sync-time", "Synced {sync_time}" }
}
}
}
}
}

View file

@ -96,14 +96,11 @@ async fn execute_inline_action(
action: &ActionDefinition,
form_data: Option<&serde_json::Value>,
) -> Result<ActionResult, String> {
// Build URL from path
let url = action.path.clone();
// 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(), &url);
let mut request = client.raw_request(method.clone(), &action.path);
// For GET, merge params into query string; for mutating methods, send as
// JSON body

View file

@ -2,7 +2,10 @@
//!
//! Provides data fetching and caching for plugin data sources.
use std::{collections::HashMap, time::Duration};
use std::{
collections::{HashMap, HashSet},
time::Duration,
};
use dioxus::prelude::*;
use dioxus_core::Task;
@ -15,7 +18,7 @@ use crate::client::ApiClient;
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct PluginPageData {
data: HashMap<String, serde_json::Value>,
loading: HashMap<String, bool>,
loading: HashSet<String>,
errors: HashMap<String, String>,
}
@ -29,13 +32,13 @@ impl PluginPageData {
/// Check if a source is currently loading
#[must_use]
pub fn is_loading(&self, source: &str) -> bool {
self.loading.get(source).copied().unwrap_or(false)
self.loading.contains(source)
}
/// Get error for a specific source
#[must_use]
pub fn error(&self, source: &str) -> Option<&String> {
self.errors.get(source)
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
@ -52,7 +55,7 @@ impl PluginPageData {
/// Set loading state for a source
pub fn set_loading(&mut self, source: &str, loading: bool) {
if loading {
self.loading.insert(source.to_string(), true);
self.loading.insert(source.to_string());
self.errors.remove(source);
} else {
self.loading.remove(source);
@ -161,9 +164,10 @@ async fn fetch_endpoint(
///
/// 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.
/// 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
///
@ -263,8 +267,15 @@ pub async fn fetch_page_data(
..
} => {
let empty_ctx = serde_json::json!({});
fetch_endpoint(&client, path, method.clone(), params, &empty_ctx, &allowed)
.await?
fetch_endpoint(
&client,
path,
method.clone(),
params,
&empty_ctx,
&allowed,
)
.await?
},
DataSource::Static { value } => value.clone(),
DataSource::Transform { .. } => unreachable!(),
@ -296,21 +307,60 @@ pub async fn fetch_page_data(
}
}
// Process Transform sources sequentially; they reference results above.
for (name, source) in data_sources {
if let DataSource::Transform {
source_name,
expression,
} = source
{
let ctx = serde_json::Value::Object(
results
.iter()
.map(|(k, v): (&String, &serde_json::Value)| (k.clone(), v.clone()))
.collect(),
// 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"
);
let _ = source_name; // accessible in ctx by its key
results.insert(name.clone(), evaluate_expression(expression, &ctx));
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;
}
}
@ -446,7 +496,7 @@ mod tests {
// Test error state
data.set_error("error".to_string(), "oops".to_string());
assert_eq!(data.error("error"), Some(&"oops".to_string()));
assert_eq!(data.error("error"), Some("oops"));
}
#[test]
@ -522,7 +572,9 @@ mod tests {
value: serde_json::json!(true),
});
let results = super::fetch_page_data(&client, &sources, &[]).await.unwrap();
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));
}
@ -544,7 +596,9 @@ mod tests {
value: serde_json::json!({"ok": true}),
});
let results = super::fetch_page_data(&client, &sources, &[]).await.unwrap();
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}));
@ -566,13 +620,13 @@ mod tests {
expression: Expression::Literal(serde_json::json!("constant")),
});
let results = super::fetch_page_data(&client, &sources, &[]).await.unwrap();
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"));
}
// Test: multiple Static sources with the same value each get their own
// result; dedup logic does not collapse distinct-named Static sources.
#[tokio::test]
async fn test_fetch_page_data_deduplicates_identical_endpoints() {
use pinakes_plugin_api::DataSource;
@ -589,18 +643,18 @@ mod tests {
sources.insert("b".to_string(), DataSource::Static {
value: serde_json::json!(1),
});
let results = super::fetch_page_data(&client, &sources, &[]).await.unwrap();
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);
}
// Test: Endpoint sources with identical (path, method, params) but different
// transform expressions each get a correctly transformed result. Because the
// test runs without a real server the path is checked against the allowlist
// before any network call, so we verify the dedup key grouping through the
// allowlist rejection path: both names should see the same error message,
// proving they were grouped and the single rejection propagates to all names.
// 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};
@ -640,14 +694,12 @@ mod tests {
);
}
// Test: multiple Transform sources referencing the same upstream Static source
// with different expressions each receive their independently transformed
// result. This exercises the transform fan-out behavior that mirrors what
// the Endpoint dedup group does after a single shared HTTP request completes:
// each member of a group applies its own transform to the shared raw value.
// 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 the Endpoint dedup success path with real per-member transforms
// requires a mock HTTP server and belongs in an integration test.
// 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};
@ -670,8 +722,9 @@ mod tests {
expression: Expression::Path("raw_data.name".to_string()),
});
let results =
super::fetch_page_data(&client, &sources, &[]).await.unwrap();
let results = super::fetch_page_data(&client, &sources, &[])
.await
.unwrap();
assert_eq!(
results["raw_data"],
serde_json::json!({"count": 42, "name": "test"})
@ -681,8 +734,6 @@ mod tests {
assert_eq!(results.len(), 3);
}
// Test: fetch_page_data returns an error when the endpoint data source path is
// not listed in the allowed_endpoints slice.
#[tokio::test]
async fn test_endpoint_blocked_when_not_in_allowlist() {
use pinakes_plugin_api::{DataSource, HttpMethod};
@ -705,7 +756,8 @@ mod tests {
assert!(
result.is_err(),
"fetch_page_data must return Err when endpoint is not in allowed_endpoints"
"fetch_page_data must return Err when endpoint is not in \
allowed_endpoints"
);
let msg = result.unwrap_err();
assert!(

View file

@ -35,28 +35,19 @@ pub struct PluginPage {
pub allowed_endpoints: Vec<String>,
}
impl PluginPage {
/// The canonical route for this page, taken directly from the page schema.
pub fn full_route(&self) -> String {
self.page.route.clone()
}
}
/// 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,
client: ApiClient,
/// Cached pages: (`plugin_id`, `page_id`) -> `PluginPage`
pages: HashMap<(String, String), PluginPage>,
pages: HashMap<(String, String), PluginPage>,
/// Cached widgets: (`plugin_id`, `widget_id`) -> `UiWidget`
widgets: Vec<(String, UiWidget)>,
widgets: Vec<(String, UiWidget)>,
/// Merged CSS custom property overrides from all enabled plugins
theme_vars: HashMap<String, String>,
/// Last refresh timestamp
last_refresh: Option<chrono::DateTime<chrono::Utc>>,
theme_vars: HashMap<String, String>,
}
impl PluginRegistry {
@ -67,7 +58,6 @@ impl PluginRegistry {
pages: HashMap::new(),
widgets: Vec::new(),
theme_vars: HashMap::new(),
last_refresh: None,
}
}
@ -109,14 +99,11 @@ impl PluginRegistry {
);
return;
}
self.pages.insert(
(plugin_id.clone(), page_id),
PluginPage {
plugin_id,
page,
allowed_endpoints,
},
);
self.pages.insert((plugin_id.clone(), page_id), PluginPage {
plugin_id,
page,
allowed_endpoints,
});
}
/// Get a specific page by plugin ID and page ID
@ -179,7 +166,7 @@ impl PluginRegistry {
self
.pages
.values()
.map(|p| (p.plugin_id.clone(), p.page.id.clone(), p.full_route()))
.map(|p| (p.plugin_id.clone(), p.page.id.clone(), p.page.route.clone()))
.collect()
}
@ -207,21 +194,17 @@ impl PluginRegistry {
}
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}"),
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;
self.last_refresh = Some(chrono::Utc::now());
Ok(())
}
/// Get last refresh time
pub const fn last_refresh(&self) -> Option<chrono::DateTime<chrono::Utc>> {
self.last_refresh
}
}
impl Default for PluginRegistry {
@ -354,7 +337,6 @@ mod tests {
let registry = PluginRegistry::default();
assert!(registry.is_empty());
assert_eq!(registry.all_pages().len(), 0);
assert!(registry.last_refresh().is_none());
}
#[test]
@ -367,7 +349,7 @@ mod tests {
}
#[test]
fn test_page_full_route() {
fn test_page_route() {
let client = ApiClient::default();
let mut registry = PluginRegistry::new(client);
registry.register_page(
@ -376,9 +358,7 @@ mod tests {
vec![],
);
let plugin_page = registry.get_page("my-plugin", "demo").unwrap();
// full_route() returns page.route directly; create_test_page sets it as
// "/plugins/test/{id}"
assert_eq!(plugin_page.full_route(), "/plugins/test/demo");
assert_eq!(plugin_page.page.route, "/plugins/test/demo");
}
#[test]
@ -418,8 +398,16 @@ mod tests {
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![]);
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);
@ -536,7 +524,11 @@ mod tests {
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![]);
registry.register_page(
"p".to_string(),
create_test_page("good", "Good"),
vec![],
);
assert_eq!(registry.all_pages().len(), 1);
}

View file

@ -110,8 +110,12 @@ pub fn PluginViewRenderer(props: PluginViewProps) -> Element {
modal,
local_state,
};
let page_data =
use_plugin_data(props.client, data_sources, refresh, props.allowed_endpoints);
let page_data = use_plugin_data(
props.client,
data_sources,
refresh,
props.allowed_endpoints,
);
// Consume pending navigation requests and forward to the parent
use_effect(move || {
@ -151,7 +155,7 @@ pub fn PluginViewRenderer(props: PluginViewProps) -> Element {
onclick: move |_| modal.set(None),
"×"
}
{ render_element(&elem, &page_data.read(), &HashMap::new(), ctx) }
{ render_element(&elem, &page_data.read(), &actions, ctx) }
}
}
}
@ -318,44 +322,37 @@ fn PluginDataTable(props: PluginDataTableProps) -> Element {
let row_val = row;
rsx! {
tr {
for col in props.columns.clone() {
for col in &props.columns {
td { "{extract_cell(&row_val, &col.key)}" }
}
if !props.row_actions.is_empty() {
td { class: "row-actions",
for act in props.row_actions.clone() {
for act in &props.row_actions {
{
let action = act.action.clone();
let row_data = row_val.clone();
let variant_class =
button_variant_class(&act.variant);
let page_actions = props.actions.clone();
let success_msg: Option<String> =
match &act.action {
ActionRef::Special(_) => None,
ActionRef::Name(name) => props
.actions
.get(name)
.and_then(|a| {
a.success_message.clone()
}),
ActionRef::Inline(a) => {
a.success_message.clone()
},
};
let error_msg: Option<String> =
match &act.action {
ActionRef::Special(_) => None,
ActionRef::Name(name) => props
.actions
.get(name)
.and_then(|a| {
a.error_message.clone()
}),
ActionRef::Inline(a) => {
a.error_message.clone()
},
};
let (success_msg, error_msg): (
Option<String>,
Option<String>,
) = match &act.action {
ActionRef::Special(_) => (None, None),
ActionRef::Name(name) => props
.actions
.get(name)
.map_or((None, None), |a| {
(
a.success_message.clone(),
a.error_message.clone(),
)
}),
ActionRef::Inline(a) => (
a.success_message.clone(),
a.error_message.clone(),
),
};
let ctx = props.ctx;
// Pre-compute data JSON at render time to
// avoid moving props.data into closures.
@ -489,7 +486,8 @@ pub fn render_element(
|| "0".to_string(),
|p| format!("{}px {}px {}px {}px", p[0], p[1], p[2], p[3]),
);
let style = format!("--plugin-gap:{gap}px;--plugin-padding:{padding_css};");
let style =
format!("--plugin-gap:{gap}px;--plugin-padding:{padding_css};");
rsx! {
div {
class: "plugin-container",
@ -710,7 +708,8 @@ pub fn render_element(
} else if let Some(arr) = items.and_then(|v| v.as_array()) {
for item in arr {
{
let url_opt = media_grid_image_url(item);
let base = ctx.client.peek().base_url().to_string();
let url_opt = media_grid_image_url(item, &base);
let label = media_grid_label(item);
rsx! {
div { class: "media-grid-item",
@ -797,7 +796,16 @@ pub fn render_element(
.map(|obj| {
obj
.iter()
.map(|(k, v)| (k.clone(), value_to_display_string(v)))
.filter_map(|(k, v)| {
match v {
// Skip nested objects and arrays; they are not meaningful as
// single-line description terms.
serde_json::Value::Object(_) | serde_json::Value::Array(_) => {
None
},
_ => Some((format_key_name(k), value_to_display_string(v))),
}
})
.collect()
})
.unwrap_or_default();
@ -829,20 +837,18 @@ pub fn render_element(
let variant_class = button_variant_class(variant);
let action_ref = action.clone();
let page_actions = actions.clone();
let success_msg: Option<String> = match action {
ActionRef::Special(_) => None,
ActionRef::Name(name) => {
actions.get(name).and_then(|a| a.success_message.clone())
},
ActionRef::Inline(a) => a.success_message.clone(),
};
let error_msg: Option<String> = match action {
ActionRef::Special(_) => None,
ActionRef::Name(name) => {
actions.get(name).and_then(|a| a.error_message.clone())
},
ActionRef::Inline(a) => a.error_message.clone(),
};
let (success_msg, error_msg): (Option<String>, Option<String>) =
match action {
ActionRef::Special(_) => (None, None),
ActionRef::Name(name) => {
actions.get(name).map_or((None, None), |a| {
(a.success_message.clone(), a.error_message.clone())
})
},
ActionRef::Inline(a) => {
(a.success_message.clone(), a.error_message.clone())
},
};
let data_snapshot = build_ctx(data, &ctx.local_state.read());
rsx! {
button {
@ -904,20 +910,18 @@ pub fn render_element(
} => {
let action_ref = submit_action.clone();
let page_actions = actions.clone();
let success_msg: Option<String> = match submit_action {
ActionRef::Special(_) => None,
ActionRef::Name(name) => {
actions.get(name).and_then(|a| a.success_message.clone())
},
ActionRef::Inline(a) => a.success_message.clone(),
};
let error_msg: Option<String> = match submit_action {
ActionRef::Special(_) => None,
ActionRef::Name(name) => {
actions.get(name).and_then(|a| a.error_message.clone())
},
ActionRef::Inline(a) => a.error_message.clone(),
};
let (success_msg, error_msg): (Option<String>, Option<String>) =
match submit_action {
ActionRef::Special(_) => (None, None),
ActionRef::Name(name) => {
actions.get(name).map_or((None, None), |a| {
(a.success_message.clone(), a.error_message.clone())
})
},
ActionRef::Inline(a) => {
(a.success_message.clone(), a.error_message.clone())
},
};
let data_snapshot = build_ctx(data, &ctx.local_state.read());
rsx! {
form {
@ -1050,7 +1054,7 @@ pub fn render_element(
max,
show_percentage,
} => {
let eval_ctx = data.as_json();
let eval_ctx = build_ctx(data, &ctx.local_state.read());
let pct = evaluate_expression_as_f64(value, &eval_ctx);
let fraction = if *max > 0.0 {
(pct / max).clamp(0.0, 1.0)
@ -1096,8 +1100,6 @@ pub fn render_element(
} => {
let chart_class = chart_type_class(chart_type);
let chart_data = data.get(source_key).cloned();
let x_label = x_axis_label.as_deref().unwrap_or("").to_string();
let y_label = y_axis_label.as_deref().unwrap_or("").to_string();
rsx! {
div {
class: "plugin-chart {chart_class}",
@ -1111,7 +1113,7 @@ pub fn render_element(
if let Some(x) = x_axis_label { div { class: "chart-x-label", "{x}" } }
if let Some(y) = y_axis_label { div { class: "chart-y-label", "{y}" } }
div { class: "chart-data-table",
{ render_chart_data(chart_data.as_ref(), &x_label, &y_label) }
{ render_chart_data(chart_data.as_ref(), x_axis_label.as_deref().unwrap_or(""), y_axis_label.as_deref().unwrap_or("")) }
}
}
}
@ -1124,7 +1126,7 @@ pub fn render_element(
then,
else_element,
} => {
let eval_ctx = data.as_json();
let eval_ctx = build_ctx(data, &ctx.local_state.read());
if evaluate_expression_as_bool(condition, &eval_ctx) {
render_element(then, data, actions, ctx)
} else if let Some(else_el) = else_element {
@ -1252,7 +1254,10 @@ fn render_chart_data(
// MediaGrid helpers
/// Probe a JSON object for common image URL fields.
fn media_grid_image_url(item: &serde_json::Value) -> Option<String> {
fn media_grid_image_url(
item: &serde_json::Value,
base_url: &str,
) -> Option<String> {
for key in &[
"thumbnail_url",
"thumbnail",
@ -1268,12 +1273,22 @@ fn media_grid_image_url(item: &serde_json::Value) -> Option<String> {
}
}
}
// Pinakes media items: construct absolute thumbnail URL from id when
// has_thumbnail is true. Relative paths don't work for <img src> in the
// desktop WebView context.
if item.get("has_thumbnail").and_then(|v| v.as_bool()) == Some(true) {
if let Some(id) = item.get("id").and_then(|v| v.as_str()) {
if !id.is_empty() {
return Some(format!("{base_url}/api/v1/media/{id}/thumbnail"));
}
}
}
None
}
/// Probe a JSON object for a human-readable label.
fn media_grid_label(item: &serde_json::Value) -> String {
for key in &["title", "name", "label", "caption"] {
for key in &["title", "name", "label", "caption", "file_name"] {
if let Some(s) = item.get(*key).and_then(|v| v.as_str()) {
if !s.is_empty() {
return s.to_string();
@ -1609,12 +1624,41 @@ fn safe_col_width_css(w: &str) -> Option<String> {
None
}
/// Convert a `snake_case` JSON key to a human-readable title.
/// `avg_file_size_bytes` -> `Avg File Size Bytes`
fn format_key_name(key: &str) -> String {
key
.split('_')
.map(|word| {
let mut chars = word.chars();
match chars.next() {
None => String::new(),
Some(first) => {
first.to_uppercase().collect::<String>() + chars.as_str()
},
}
})
.collect::<Vec<_>>()
.join(" ")
}
#[cfg(test)]
mod tests {
use pinakes_plugin_api::Expression;
use super::*;
#[test]
fn test_format_key_name() {
assert_eq!(
format_key_name("avg_file_size_bytes"),
"Avg File Size Bytes"
);
assert_eq!(format_key_name("total_media"), "Total Media");
assert_eq!(format_key_name("id"), "Id");
assert_eq!(format_key_name(""), "");
}
#[test]
fn test_extract_cell_string() {
let row = serde_json::json!({ "name": "Alice", "count": 5 });

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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