Compare commits
19 commits
b6e579408f
...
7cbce98795
| Author | SHA1 | Date | |
|---|---|---|---|
|
7cbce98795 |
|||
|
0014a1a2a9 |
|||
|
e1351e8881 |
|||
|
81d1695e11 |
|||
|
63954fdb2f |
|||
|
220dfa6506 |
|||
|
7989d4c4dd |
|||
|
91123fc90e |
|||
|
185e3b562a |
|||
|
0ba898c881 |
|||
|
0c9b71346d |
|||
|
15b005cef0 |
|||
|
dc4dc41670 |
|||
|
3678edd355 |
|||
|
119f6d2e06 |
|||
|
cf76d42c33 |
|||
|
592a9bcc47 |
|||
|
8f2b44b50c |
|||
|
9c67c81a79 |
41 changed files with 3081 additions and 393 deletions
|
|
@ -6,3 +6,10 @@ await-holding-invalid-types = [
|
||||||
"dioxus_signals::WriteLock",
|
"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." },
|
{ 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" },
|
||||||
|
]
|
||||||
|
|
|
||||||
|
|
@ -42,7 +42,9 @@ pub async fn export_library(
|
||||||
match format {
|
match format {
|
||||||
ExportFormat::Json => {
|
ExportFormat::Json => {
|
||||||
let json = serde_json::to_string_pretty(&items).map_err(|e| {
|
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)?;
|
std::fs::write(destination, json)?;
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -498,10 +498,14 @@ fn collect_import_result(
|
||||||
tracing::warn!(path = %path.display(), error = %e, "failed to import file");
|
tracing::warn!(path = %path.display(), error = %e, "failed to import file");
|
||||||
results.push(Err(e));
|
results.push(Err(e));
|
||||||
},
|
},
|
||||||
Err(e) => {
|
Err(join_err) => {
|
||||||
tracing::error!(error = %e, "import task panicked");
|
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!(
|
results.push(Err(PinakesError::InvalidOperation(format!(
|
||||||
"import task panicked: {e}"
|
"import task failed: {join_err}"
|
||||||
))));
|
))));
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -32,7 +32,7 @@ fn extract_pdf(path: &Path) -> Result<ExtractedMetadata> {
|
||||||
.map_err(|e| PinakesError::MetadataExtraction(format!("PDF load: {e}")))?;
|
.map_err(|e| PinakesError::MetadataExtraction(format!("PDF load: {e}")))?;
|
||||||
|
|
||||||
let mut meta = ExtractedMetadata::default();
|
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
|
// Find the Info dictionary via the trailer
|
||||||
if let Ok(info_ref) = doc.trailer.get(b"Info") {
|
if let Ok(info_ref) = doc.trailer.get(b"Info") {
|
||||||
|
|
@ -145,7 +145,7 @@ fn extract_epub(path: &Path) -> Result<ExtractedMetadata> {
|
||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
|
|
||||||
let mut book_meta = crate::model::ExtractedBookMetadata::default();
|
let mut book_meta = crate::model::BookMetadata::default();
|
||||||
|
|
||||||
// Extract basic metadata
|
// Extract basic metadata
|
||||||
if let Some(lang) = doc.mdata("language") {
|
if let Some(lang) = doc.mdata("language") {
|
||||||
|
|
|
||||||
|
|
@ -6,11 +6,7 @@ pub mod video;
|
||||||
|
|
||||||
use std::{collections::HashMap, path::Path};
|
use std::{collections::HashMap, path::Path};
|
||||||
|
|
||||||
use crate::{
|
use crate::{error::Result, media_type::MediaType, model::BookMetadata};
|
||||||
error::Result,
|
|
||||||
media_type::MediaType,
|
|
||||||
model::ExtractedBookMetadata,
|
|
||||||
};
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Default)]
|
#[derive(Debug, Clone, Default)]
|
||||||
pub struct ExtractedMetadata {
|
pub struct ExtractedMetadata {
|
||||||
|
|
@ -22,7 +18,7 @@ pub struct ExtractedMetadata {
|
||||||
pub duration_secs: Option<f64>,
|
pub duration_secs: Option<f64>,
|
||||||
pub description: Option<String>,
|
pub description: Option<String>,
|
||||||
pub extra: HashMap<String, String>,
|
pub extra: HashMap<String, String>,
|
||||||
pub book_metadata: Option<ExtractedBookMetadata>,
|
pub book_metadata: Option<BookMetadata>,
|
||||||
|
|
||||||
// Photo-specific metadata
|
// Photo-specific metadata
|
||||||
pub date_taken: Option<chrono::DateTime<chrono::Utc>>,
|
pub date_taken: Option<chrono::DateTime<chrono::Utc>>,
|
||||||
|
|
|
||||||
|
|
@ -417,6 +417,10 @@ pub struct SavedSearch {
|
||||||
// Book Management Types
|
// Book Management Types
|
||||||
|
|
||||||
/// Metadata for book-type media.
|
/// 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)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct BookMetadata {
|
pub struct BookMetadata {
|
||||||
pub media_id: MediaId,
|
pub media_id: MediaId,
|
||||||
|
|
@ -435,6 +439,28 @@ pub struct BookMetadata {
|
||||||
pub updated_at: DateTime<Utc>,
|
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.
|
/// Information about a book author.
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
pub struct AuthorInfo {
|
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.
|
/// Reading progress for a book.
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct ReadingProgress {
|
pub struct ReadingProgress {
|
||||||
|
|
|
||||||
|
|
@ -602,33 +602,107 @@ impl PluginManager {
|
||||||
/// List all UI pages provided by loaded plugins.
|
/// List all UI pages provided by loaded plugins.
|
||||||
///
|
///
|
||||||
/// Returns a vector of `(plugin_id, page)` tuples for all enabled 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(
|
pub async fn list_ui_pages(
|
||||||
&self,
|
&self,
|
||||||
) -> Vec<(String, pinakes_plugin_api::UiPage)> {
|
) -> 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 registry = self.registry.read().await;
|
||||||
let mut pages = Vec::new();
|
let mut pages = Vec::new();
|
||||||
for plugin in registry.list_all() {
|
for plugin in registry.list_all() {
|
||||||
if !plugin.enabled {
|
if !plugin.enabled {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
let allowed = plugin.manifest.ui.required_endpoints.clone();
|
||||||
|
let plugin_dir = plugin
|
||||||
|
.manifest_path
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|p| p.parent())
|
||||||
|
.map(std::path::Path::to_path_buf);
|
||||||
|
let Some(plugin_dir) = plugin_dir else {
|
||||||
for entry in &plugin.manifest.ui.pages {
|
for entry in &plugin.manifest.ui.pages {
|
||||||
let page = match entry {
|
if let pinakes_plugin_api::manifest::UiPageEntry::Inline(page) = entry
|
||||||
pinakes_plugin_api::manifest::UiPageEntry::Inline(page) => {
|
{
|
||||||
(**page).clone()
|
pages.push((plugin.id.clone(), (**page).clone(), allowed.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;
|
continue;
|
||||||
},
|
|
||||||
};
|
};
|
||||||
pages.push((plugin.id.clone(), page));
|
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
|
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
|
/// Check if a plugin is loaded and enabled
|
||||||
pub async fn is_plugin_enabled(&self, plugin_id: &str) -> bool {
|
pub async fn is_plugin_enabled(&self, plugin_id: &str) -> bool {
|
||||||
let registry = self.registry.read().await;
|
let registry = self.registry.read().await;
|
||||||
|
|
@ -746,6 +820,7 @@ mod tests {
|
||||||
},
|
},
|
||||||
capabilities: Default::default(),
|
capabilities: Default::default(),
|
||||||
config: Default::default(),
|
config: Default::default(),
|
||||||
|
ui: Default::default(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -131,7 +131,7 @@ impl PluginRegistry {
|
||||||
self
|
self
|
||||||
.plugins
|
.plugins
|
||||||
.values()
|
.values()
|
||||||
.filter(|p| p.manifest.plugin.kind.contains(&kind.to_string()))
|
.filter(|p| p.manifest.plugin.kind.iter().any(|k| k == kind))
|
||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -182,6 +182,7 @@ mod tests {
|
||||||
},
|
},
|
||||||
capabilities: ManifestCapabilities::default(),
|
capabilities: ManifestCapabilities::default(),
|
||||||
config: HashMap::new(),
|
config: HashMap::new(),
|
||||||
|
ui: Default::default(),
|
||||||
};
|
};
|
||||||
|
|
||||||
RegisteredPlugin {
|
RegisteredPlugin {
|
||||||
|
|
|
||||||
|
|
@ -4295,6 +4295,11 @@ impl StorageBackend for PostgresBackend {
|
||||||
&self,
|
&self,
|
||||||
metadata: &crate::model::BookMetadata,
|
metadata: &crate::model::BookMetadata,
|
||||||
) -> Result<()> {
|
) -> 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
|
let mut client = self
|
||||||
.pool
|
.pool
|
||||||
.get()
|
.get()
|
||||||
|
|
|
||||||
|
|
@ -1116,7 +1116,8 @@ impl StorageBackend for SqliteBackend {
|
||||||
parent_id.map(|p| p.to_string()),
|
parent_id.map(|p| p.to_string()),
|
||||||
now.to_rfc3339(),
|
now.to_rfc3339(),
|
||||||
],
|
],
|
||||||
)?;
|
)
|
||||||
|
.map_err(crate::error::db_ctx("create_tag", &name))?;
|
||||||
drop(db);
|
drop(db);
|
||||||
Tag {
|
Tag {
|
||||||
id,
|
id,
|
||||||
|
|
@ -1192,7 +1193,8 @@ impl StorageBackend for SqliteBackend {
|
||||||
.lock()
|
.lock()
|
||||||
.map_err(|e| PinakesError::Database(e.to_string()))?;
|
.map_err(|e| PinakesError::Database(e.to_string()))?;
|
||||||
let changed = db
|
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);
|
drop(db);
|
||||||
if changed == 0 {
|
if changed == 0 {
|
||||||
return Err(PinakesError::TagNotFound(id.to_string()));
|
return Err(PinakesError::TagNotFound(id.to_string()));
|
||||||
|
|
@ -1214,7 +1216,11 @@ impl StorageBackend for SqliteBackend {
|
||||||
db.execute(
|
db.execute(
|
||||||
"INSERT OR IGNORE INTO media_tags (media_id, tag_id) VALUES (?1, ?2)",
|
"INSERT OR IGNORE INTO media_tags (media_id, tag_id) VALUES (?1, ?2)",
|
||||||
params![media_id.0.to_string(), tag_id.to_string()],
|
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(())
|
Ok(())
|
||||||
})
|
})
|
||||||
|
|
@ -1232,7 +1238,11 @@ impl StorageBackend for SqliteBackend {
|
||||||
db.execute(
|
db.execute(
|
||||||
"DELETE FROM media_tags WHERE media_id = ?1 AND tag_id = ?2",
|
"DELETE FROM media_tags WHERE media_id = ?1 AND tag_id = ?2",
|
||||||
params![media_id.0.to_string(), tag_id.to_string()],
|
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(())
|
Ok(())
|
||||||
})
|
})
|
||||||
|
|
@ -1323,7 +1333,8 @@ impl StorageBackend for SqliteBackend {
|
||||||
now.to_rfc3339(),
|
now.to_rfc3339(),
|
||||||
now.to_rfc3339(),
|
now.to_rfc3339(),
|
||||||
],
|
],
|
||||||
)?;
|
)
|
||||||
|
.map_err(crate::error::db_ctx("create_collection", &name))?;
|
||||||
drop(db);
|
drop(db);
|
||||||
Collection {
|
Collection {
|
||||||
id,
|
id,
|
||||||
|
|
@ -1406,7 +1417,8 @@ impl StorageBackend for SqliteBackend {
|
||||||
let changed = db
|
let changed = db
|
||||||
.execute("DELETE FROM collections WHERE id = ?1", params![
|
.execute("DELETE FROM collections WHERE id = ?1", params![
|
||||||
id.to_string()
|
id.to_string()
|
||||||
])?;
|
])
|
||||||
|
.map_err(crate::error::db_ctx("delete_collection", id))?;
|
||||||
drop(db);
|
drop(db);
|
||||||
if changed == 0 {
|
if changed == 0 {
|
||||||
return Err(PinakesError::CollectionNotFound(id.to_string()));
|
return Err(PinakesError::CollectionNotFound(id.to_string()));
|
||||||
|
|
@ -1440,7 +1452,11 @@ impl StorageBackend for SqliteBackend {
|
||||||
position,
|
position,
|
||||||
now.to_rfc3339(),
|
now.to_rfc3339(),
|
||||||
],
|
],
|
||||||
)?;
|
)
|
||||||
|
.map_err(crate::error::db_ctx(
|
||||||
|
"add_to_collection",
|
||||||
|
format!("{collection_id} <- {media_id}"),
|
||||||
|
))?;
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
})
|
})
|
||||||
|
|
@ -1463,7 +1479,11 @@ impl StorageBackend for SqliteBackend {
|
||||||
"DELETE FROM collection_members WHERE collection_id = ?1 AND \
|
"DELETE FROM collection_members WHERE collection_id = ?1 AND \
|
||||||
media_id = ?2",
|
media_id = ?2",
|
||||||
params![collection_id.to_string(), media_id.0.to_string()],
|
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(())
|
Ok(())
|
||||||
})
|
})
|
||||||
|
|
@ -1863,20 +1883,29 @@ impl StorageBackend for SqliteBackend {
|
||||||
let db = conn
|
let db = conn
|
||||||
.lock()
|
.lock()
|
||||||
.map_err(|e| PinakesError::Database(e.to_string()))?;
|
.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
|
// Prepare statement once for reuse
|
||||||
let mut stmt = tx.prepare_cached(
|
let mut stmt = tx
|
||||||
"INSERT OR IGNORE INTO media_tags (media_id, tag_id) VALUES (?1, ?2)",
|
.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;
|
let mut count = 0u64;
|
||||||
for mid in &media_ids {
|
for mid in &media_ids {
|
||||||
for tid in &tag_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
|
count += rows as u64; // INSERT OR IGNORE: rows=1 if new, 0 if existed
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
drop(stmt);
|
drop(stmt);
|
||||||
tx.commit()?;
|
tx.commit()
|
||||||
|
.map_err(crate::error::db_ctx("batch_tag_media", &ctx))?;
|
||||||
count
|
count
|
||||||
};
|
};
|
||||||
Ok(count)
|
Ok(count)
|
||||||
|
|
@ -2695,7 +2724,7 @@ impl StorageBackend for SqliteBackend {
|
||||||
let id_str = id.0.to_string();
|
let id_str = id.0.to_string();
|
||||||
let now = chrono::Utc::now();
|
let now = chrono::Utc::now();
|
||||||
let role_str = serde_json::to_string(&role).map_err(|e| {
|
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(
|
tx.execute(
|
||||||
|
|
@ -2714,7 +2743,7 @@ impl StorageBackend for SqliteBackend {
|
||||||
let user_profile = if let Some(prof) = profile.clone() {
|
let user_profile = if let Some(prof) = profile.clone() {
|
||||||
let prefs_json =
|
let prefs_json =
|
||||||
serde_json::to_string(&prof.preferences).map_err(|e| {
|
serde_json::to_string(&prof.preferences).map_err(|e| {
|
||||||
PinakesError::Database(format!(
|
PinakesError::Serialization(format!(
|
||||||
"failed to serialize preferences: {e}"
|
"failed to serialize preferences: {e}"
|
||||||
))
|
))
|
||||||
})?;
|
})?;
|
||||||
|
|
@ -2796,7 +2825,9 @@ impl StorageBackend for SqliteBackend {
|
||||||
if let Some(ref r) = role {
|
if let Some(ref r) = role {
|
||||||
updates.push("role = ?");
|
updates.push("role = ?");
|
||||||
let role_str = serde_json::to_string(r).map_err(|e| {
|
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));
|
params.push(Box::new(role_str));
|
||||||
}
|
}
|
||||||
|
|
@ -2814,7 +2845,7 @@ impl StorageBackend for SqliteBackend {
|
||||||
if let Some(prof) = profile {
|
if let Some(prof) = profile {
|
||||||
let prefs_json =
|
let prefs_json =
|
||||||
serde_json::to_string(&prof.preferences).map_err(|e| {
|
serde_json::to_string(&prof.preferences).map_err(|e| {
|
||||||
PinakesError::Database(format!(
|
PinakesError::Serialization(format!(
|
||||||
"failed to serialize preferences: {e}"
|
"failed to serialize preferences: {e}"
|
||||||
))
|
))
|
||||||
})?;
|
})?;
|
||||||
|
|
@ -2966,7 +2997,9 @@ impl StorageBackend for SqliteBackend {
|
||||||
PinakesError::Database(format!("failed to acquire database lock: {e}"))
|
PinakesError::Database(format!("failed to acquire database lock: {e}"))
|
||||||
})?;
|
})?;
|
||||||
let perm_str = serde_json::to_string(&permission).map_err(|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();
|
let now = chrono::Utc::now();
|
||||||
db.execute(
|
db.execute(
|
||||||
|
|
@ -5055,6 +5088,11 @@ impl StorageBackend for SqliteBackend {
|
||||||
&self,
|
&self,
|
||||||
metadata: &crate::model::BookMetadata,
|
metadata: &crate::model::BookMetadata,
|
||||||
) -> Result<()> {
|
) -> 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 conn = Arc::clone(&self.conn);
|
||||||
let media_id_str = metadata.media_id.to_string();
|
let media_id_str = metadata.media_id.to_string();
|
||||||
let isbn = metadata.isbn.clone();
|
let isbn = metadata.isbn.clone();
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,10 @@ impl TempFileGuard {
|
||||||
|
|
||||||
impl Drop for TempFileGuard {
|
impl Drop for TempFileGuard {
|
||||||
fn drop(&mut self) {
|
fn drop(&mut self) {
|
||||||
let _ = std::fs::remove_file(&self.0);
|
if self.0.exists()
|
||||||
|
&& let Err(e) = std::fs::remove_file(&self.0) {
|
||||||
|
warn!("failed to clean up temp file {}: {e}", self.0.display());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -927,3 +927,124 @@ async fn test_transcode_sessions() {
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(cleaned, 1);
|
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);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -759,11 +759,19 @@ wasm = "plugin.wasm"
|
||||||
|
|
||||||
let manifest = PluginManifest::parse_str(toml).unwrap();
|
let manifest = PluginManifest::parse_str(toml).unwrap();
|
||||||
assert_eq!(
|
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")
|
Some("#ff6b6b")
|
||||||
);
|
);
|
||||||
assert_eq!(
|
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")
|
Some("280px")
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,7 @@
|
||||||
//! "sidebar": {
|
//! "sidebar": {
|
||||||
//! "type": "list",
|
//! "type": "list",
|
||||||
//! "data": "playlists",
|
//! "data": "playlists",
|
||||||
//! "item_template": { "type": "text", "content": "{{title}}" }
|
//! "item_template": { "type": "text", "content": "title" }
|
||||||
//! },
|
//! },
|
||||||
//! "main": {
|
//! "main": {
|
||||||
//! "type": "data_table",
|
//! "type": "data_table",
|
||||||
|
|
@ -40,6 +40,11 @@
|
||||||
//! "playlists": { "type": "endpoint", "path": "/api/v1/collections" }
|
//! "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;
|
use std::collections::HashMap;
|
||||||
|
|
@ -102,6 +107,7 @@ pub type SchemaResult<T> = Result<T, SchemaError>;
|
||||||
/// padding: None,
|
/// padding: None,
|
||||||
/// },
|
/// },
|
||||||
/// data_sources: Default::default(),
|
/// data_sources: Default::default(),
|
||||||
|
/// actions: Default::default(),
|
||||||
/// };
|
/// };
|
||||||
/// ```
|
/// ```
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
|
|
@ -127,6 +133,10 @@ pub struct UiPage {
|
||||||
/// Named data sources available to this page
|
/// Named data sources available to this page
|
||||||
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
|
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
|
||||||
pub data_sources: HashMap<String, DataSource>,
|
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 {
|
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();
|
let depth = self.root_element.depth();
|
||||||
if depth > MAX_ELEMENT_DEPTH {
|
if depth > MAX_ELEMENT_DEPTH {
|
||||||
return Err(SchemaError::DepthLimitExceeded);
|
return Err(SchemaError::DepthLimitExceeded);
|
||||||
|
|
@ -158,6 +175,11 @@ impl UiPage {
|
||||||
|
|
||||||
self.root_element.validate(self)?;
|
self.root_element.validate(self)?;
|
||||||
|
|
||||||
|
for (name, action) in &self.actions {
|
||||||
|
validate_id(name)?;
|
||||||
|
action.validate()?;
|
||||||
|
}
|
||||||
|
|
||||||
for (name, source) in &self.data_sources {
|
for (name, source) in &self.data_sources {
|
||||||
validate_id(name)?;
|
validate_id(name)?;
|
||||||
source.validate()?;
|
source.validate()?;
|
||||||
|
|
@ -246,6 +268,23 @@ pub struct UiWidget {
|
||||||
pub content: UiElement,
|
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.
|
/// String constants for widget injection locations.
|
||||||
///
|
///
|
||||||
/// Use these with `UiWidget::target` in plugin manifests:
|
/// 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 LIBRARY_SIDEBAR: &str = "library_sidebar";
|
||||||
pub const DETAIL_PANEL: &str = "detail_panel";
|
pub const DETAIL_PANEL: &str = "detail_panel";
|
||||||
pub const SEARCH_FILTERS: &str = "search_filters";
|
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
|
/// 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) => {
|
Self::Chart { data, .. } if !page.data_sources.contains_key(data) => {
|
||||||
return Err(SchemaError::ValidationError(format!(
|
return Err(SchemaError::ValidationError(format!(
|
||||||
"Chart references unknown data source: {data}"
|
"Chart references unknown data source: {data}"
|
||||||
|
|
@ -817,11 +852,21 @@ impl UiElement {
|
||||||
Self::Button { action, .. } => {
|
Self::Button { action, .. } => {
|
||||||
action.validate()?;
|
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 {
|
Self::Form {
|
||||||
fields,
|
fields,
|
||||||
submit_action,
|
submit_action,
|
||||||
..
|
..
|
||||||
} => {
|
} => {
|
||||||
|
if fields.is_empty() {
|
||||||
|
return Err(SchemaError::ValidationError(
|
||||||
|
"Form must have at least one field".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
for field in fields {
|
for field in fields {
|
||||||
validate_id(&field.id)?;
|
validate_id(&field.id)?;
|
||||||
if field.label.is_empty() {
|
if field.label.is_empty() {
|
||||||
|
|
@ -1046,7 +1091,7 @@ pub struct ColumnDef {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Row action for `DataTable`
|
/// Row action for `DataTable`
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
pub struct RowAction {
|
pub struct RowAction {
|
||||||
/// Action identifier (unique within this table)
|
/// Action identifier (unique within this table)
|
||||||
pub id: String,
|
pub id: String,
|
||||||
|
|
@ -1290,15 +1335,60 @@ pub enum ChartType {
|
||||||
Scatter,
|
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
|
/// 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)]
|
#[serde(untagged)]
|
||||||
pub enum ActionRef {
|
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)
|
/// Simple action name (references page.actions)
|
||||||
Name(String),
|
Name(String),
|
||||||
|
|
||||||
/// Inline action definition
|
|
||||||
Inline(ActionDefinition),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ActionRef {
|
impl ActionRef {
|
||||||
|
|
@ -1312,6 +1402,26 @@ impl ActionRef {
|
||||||
/// Returns `SchemaError::ValidationError` if validation fails.
|
/// Returns `SchemaError::ValidationError` if validation fails.
|
||||||
pub fn validate(&self) -> SchemaResult<()> {
|
pub fn validate(&self) -> SchemaResult<()> {
|
||||||
match self {
|
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) => {
|
Self::Name(name) => {
|
||||||
if name.is_empty() {
|
if name.is_empty() {
|
||||||
return Err(SchemaError::ValidationError(
|
return Err(SchemaError::ValidationError(
|
||||||
|
|
@ -1376,6 +1486,18 @@ impl ActionDefinition {
|
||||||
self.path
|
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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1462,6 +1584,16 @@ impl DataSource {
|
||||||
"Endpoint path must start with '/': {path}"
|
"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, .. } => {
|
Self::Transform { source_name, .. } => {
|
||||||
validate_id(source_name)?;
|
validate_id(source_name)?;
|
||||||
|
|
@ -1475,16 +1607,31 @@ impl DataSource {
|
||||||
/// Expression for dynamic value evaluation
|
/// Expression for dynamic value evaluation
|
||||||
///
|
///
|
||||||
/// Expressions use JSONPath-like syntax for data access.
|
/// 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)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
#[serde(untagged)]
|
#[serde(untagged)]
|
||||||
pub enum Expression {
|
pub enum Expression {
|
||||||
/// Literal JSON value
|
/// Data path reference: a dotted key sequence walked against the context.
|
||||||
Literal(serde_json::Value),
|
///
|
||||||
|
/// e.g. `"user.name"` resolves to `ctx["user"]["name"]`; `"items.0"` resolves
|
||||||
/// Data path reference (e.g., "$.users[0].name")
|
/// to the first element.
|
||||||
Path(String),
|
Path(String),
|
||||||
|
|
||||||
/// Binary operation
|
/// Binary operation applied to two sub-expressions.
|
||||||
Operation {
|
Operation {
|
||||||
/// Left operand
|
/// Left operand
|
||||||
left: Box<Self>,
|
left: Box<Self>,
|
||||||
|
|
@ -1494,13 +1641,22 @@ pub enum Expression {
|
||||||
right: Box<Self>,
|
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 {
|
Call {
|
||||||
/// Function name
|
/// Function name (see built-in function table in docs)
|
||||||
function: String,
|
function: String,
|
||||||
/// Function arguments
|
/// Positional arguments, each an `Expression`
|
||||||
args: Vec<Self>,
|
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 {
|
impl Default for Expression {
|
||||||
|
|
@ -1579,6 +1735,18 @@ const fn default_http_method() -> HttpMethod {
|
||||||
HttpMethod::Get
|
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
|
/// Validates an identifier string
|
||||||
///
|
///
|
||||||
/// IDs must:
|
/// IDs must:
|
||||||
|
|
@ -1729,6 +1897,7 @@ mod tests {
|
||||||
row_actions: vec![],
|
row_actions: vec![],
|
||||||
},
|
},
|
||||||
data_sources: HashMap::new(),
|
data_sources: HashMap::new(),
|
||||||
|
actions: HashMap::new(),
|
||||||
};
|
};
|
||||||
|
|
||||||
let refs = page.referenced_data_sources();
|
let refs = page.referenced_data_sources();
|
||||||
|
|
@ -1748,6 +1917,7 @@ mod tests {
|
||||||
gap: 16,
|
gap: 16,
|
||||||
},
|
},
|
||||||
data_sources: HashMap::new(),
|
data_sources: HashMap::new(),
|
||||||
|
actions: HashMap::new(),
|
||||||
};
|
};
|
||||||
|
|
||||||
assert!(page.validate().is_err());
|
assert!(page.validate().is_err());
|
||||||
|
|
@ -1766,8 +1936,288 @@ mod tests {
|
||||||
id: None,
|
id: None,
|
||||||
},
|
},
|
||||||
data_sources: HashMap::new(),
|
data_sources: HashMap::new(),
|
||||||
|
actions: HashMap::new(),
|
||||||
};
|
};
|
||||||
|
|
||||||
assert!(page.validate().is_err());
|
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"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -122,6 +122,10 @@ impl SchemaValidator {
|
||||||
|
|
||||||
Self::validate_element(&widget.content, &mut errors);
|
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() {
|
if errors.is_empty() {
|
||||||
Ok(())
|
Ok(())
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -132,19 +136,9 @@ impl SchemaValidator {
|
||||||
/// Recursively validate a [`UiElement`] subtree.
|
/// Recursively validate a [`UiElement`] subtree.
|
||||||
pub fn validate_element(element: &UiElement, errors: &mut Vec<String>) {
|
pub fn validate_element(element: &UiElement, errors: &mut Vec<String>) {
|
||||||
match element {
|
match element {
|
||||||
UiElement::Container { children, .. } => {
|
UiElement::Container { children, .. }
|
||||||
for child in children {
|
| UiElement::Grid { children, .. }
|
||||||
Self::validate_element(child, errors);
|
| UiElement::Flex { children, .. } => {
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
UiElement::Grid { children, .. } => {
|
|
||||||
for child in children {
|
|
||||||
Self::validate_element(child, errors);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
UiElement::Flex { children, .. } => {
|
|
||||||
for child in children {
|
for child in children {
|
||||||
Self::validate_element(child, errors);
|
Self::validate_element(child, errors);
|
||||||
}
|
}
|
||||||
|
|
@ -206,10 +200,15 @@ impl SchemaValidator {
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
UiElement::List { data, .. } => {
|
UiElement::List {
|
||||||
|
data,
|
||||||
|
item_template,
|
||||||
|
..
|
||||||
|
} => {
|
||||||
if data.is_empty() {
|
if data.is_empty() {
|
||||||
errors.push("List 'data' source key must not be empty".to_string());
|
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
|
// 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(
|
fn validate_data_source(
|
||||||
name: &str,
|
name: &str,
|
||||||
source: &DataSource,
|
source: &DataSource,
|
||||||
|
|
@ -243,6 +302,12 @@ impl SchemaValidator {
|
||||||
"Data source '{name}': endpoint path must start with '/': {path}"
|
"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, .. } => {
|
DataSource::Transform { source_name, .. } => {
|
||||||
if source_name.is_empty() {
|
if source_name.is_empty() {
|
||||||
|
|
@ -264,9 +329,11 @@ impl SchemaValidator {
|
||||||
&& chars.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
|
&& 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| {
|
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,
|
padding: None,
|
||||||
},
|
},
|
||||||
data_sources: HashMap::new(),
|
data_sources: HashMap::new(),
|
||||||
|
actions: HashMap::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -580,4 +648,81 @@ mod tests {
|
||||||
};
|
};
|
||||||
assert!(SchemaValidator::validate_page(&page).is_err());
|
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}"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,40 @@
|
||||||
use std::{collections::HashMap, path::PathBuf};
|
use std::{
|
||||||
|
collections::HashMap,
|
||||||
|
path::{Path, PathBuf},
|
||||||
|
};
|
||||||
|
|
||||||
use chrono::{DateTime, Utc};
|
use chrono::{DateTime, Utc};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use uuid::Uuid;
|
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)]
|
#[derive(Debug, Serialize)]
|
||||||
pub struct MediaResponse {
|
pub struct MediaResponse {
|
||||||
pub id: String,
|
pub id: String,
|
||||||
|
|
@ -233,12 +264,16 @@ impl From<pinakes_core::model::ManagedStorageStats>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Conversion helpers
|
impl MediaResponse {
|
||||||
impl From<pinakes_core::model::MediaItem> for MediaResponse {
|
/// Build a `MediaResponse` from a `MediaItem`, stripping the longest
|
||||||
fn from(item: pinakes_core::model::MediaItem) -> Self {
|
/// 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 {
|
Self {
|
||||||
id: item.id.0.to_string(),
|
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,
|
file_name: item.file_name,
|
||||||
media_type: serde_json::to_value(item.media_type)
|
media_type: serde_json::to_value(item.media_type)
|
||||||
.ok()
|
.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
|
// Watch progress
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
pub struct WatchProgressRequest {
|
pub struct WatchProgressRequest {
|
||||||
|
|
|
||||||
|
|
@ -30,12 +30,13 @@ pub async fn get_most_viewed(
|
||||||
) -> Result<Json<Vec<MostViewedResponse>>, ApiError> {
|
) -> Result<Json<Vec<MostViewedResponse>>, ApiError> {
|
||||||
let limit = params.limit.unwrap_or(20).min(MAX_LIMIT);
|
let limit = params.limit.unwrap_or(20).min(MAX_LIMIT);
|
||||||
let results = state.storage.get_most_viewed(limit).await?;
|
let results = state.storage.get_most_viewed(limit).await?;
|
||||||
|
let roots = state.config.read().await.directories.roots.clone();
|
||||||
Ok(Json(
|
Ok(Json(
|
||||||
results
|
results
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|(item, count)| {
|
.map(|(item, count)| {
|
||||||
MostViewedResponse {
|
MostViewedResponse {
|
||||||
media: MediaResponse::from(item),
|
media: MediaResponse::new(item, &roots),
|
||||||
view_count: count,
|
view_count: count,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
@ -51,7 +52,13 @@ pub async fn get_recently_viewed(
|
||||||
let user_id = resolve_user_id(&state.storage, &username).await?;
|
let user_id = resolve_user_id(&state.storage, &username).await?;
|
||||||
let limit = params.limit.unwrap_or(20).min(MAX_LIMIT);
|
let limit = params.limit.unwrap_or(20).min(MAX_LIMIT);
|
||||||
let items = state.storage.get_recently_viewed(user_id, limit).await?;
|
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(
|
pub async fn record_event(
|
||||||
|
|
|
||||||
|
|
@ -194,8 +194,11 @@ pub async fn list_books(
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
let response: Vec<MediaResponse> =
|
let roots = state.config.read().await.directories.roots.clone();
|
||||||
items.into_iter().map(MediaResponse::from).collect();
|
let response: Vec<MediaResponse> = items
|
||||||
|
.into_iter()
|
||||||
|
.map(|item| MediaResponse::new(item, &roots))
|
||||||
|
.collect();
|
||||||
Ok(Json(response))
|
Ok(Json(response))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -223,8 +226,11 @@ pub async fn get_series_books(
|
||||||
Path(series_name): Path<String>,
|
Path(series_name): Path<String>,
|
||||||
) -> Result<impl IntoResponse, ApiError> {
|
) -> Result<impl IntoResponse, ApiError> {
|
||||||
let items = state.storage.get_series_books(&series_name).await?;
|
let items = state.storage.get_series_books(&series_name).await?;
|
||||||
let response: Vec<MediaResponse> =
|
let roots = state.config.read().await.directories.roots.clone();
|
||||||
items.into_iter().map(MediaResponse::from).collect();
|
let response: Vec<MediaResponse> = items
|
||||||
|
.into_iter()
|
||||||
|
.map(|item| MediaResponse::new(item, &roots))
|
||||||
|
.collect();
|
||||||
Ok(Json(response))
|
Ok(Json(response))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -258,8 +264,11 @@ pub async fn get_author_books(
|
||||||
.search_books(None, Some(&author_name), None, None, None, &pagination)
|
.search_books(None, Some(&author_name), None, None, None, &pagination)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
let response: Vec<MediaResponse> =
|
let roots = state.config.read().await.directories.roots.clone();
|
||||||
items.into_iter().map(MediaResponse::from).collect();
|
let response: Vec<MediaResponse> = items
|
||||||
|
.into_iter()
|
||||||
|
.map(|item| MediaResponse::new(item, &roots))
|
||||||
|
.collect();
|
||||||
Ok(Json(response))
|
Ok(Json(response))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -317,8 +326,11 @@ pub async fn get_reading_list(
|
||||||
.get_reading_list(user_id.0, params.status)
|
.get_reading_list(user_id.0, params.status)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
let response: Vec<MediaResponse> =
|
let roots = state.config.read().await.directories.roots.clone();
|
||||||
items.into_iter().map(MediaResponse::from).collect();
|
let response: Vec<MediaResponse> = items
|
||||||
|
.into_iter()
|
||||||
|
.map(|item| MediaResponse::new(item, &roots))
|
||||||
|
.collect();
|
||||||
Ok(Json(response))
|
Ok(Json(response))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -126,5 +126,11 @@ pub async fn get_members(
|
||||||
let items =
|
let items =
|
||||||
pinakes_core::collections::get_members(&state.storage, collection_id)
|
pinakes_core::collections::get_members(&state.storage, collection_id)
|
||||||
.await?;
|
.await?;
|
||||||
Ok(Json(items.into_iter().map(MediaResponse::from).collect()))
|
let roots = state.config.read().await.directories.roots.clone();
|
||||||
|
Ok(Json(
|
||||||
|
items
|
||||||
|
.into_iter()
|
||||||
|
.map(|item| MediaResponse::new(item, &roots))
|
||||||
|
.collect(),
|
||||||
|
))
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ pub async fn list_duplicates(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
) -> Result<Json<Vec<DuplicateGroupResponse>>, ApiError> {
|
) -> Result<Json<Vec<DuplicateGroupResponse>>, ApiError> {
|
||||||
let groups = state.storage.find_duplicates().await?;
|
let groups = state.storage.find_duplicates().await?;
|
||||||
|
let roots = state.config.read().await.directories.roots.clone();
|
||||||
|
|
||||||
let response: Vec<DuplicateGroupResponse> = groups
|
let response: Vec<DuplicateGroupResponse> = groups
|
||||||
.into_iter()
|
.into_iter()
|
||||||
|
|
@ -18,8 +19,10 @@ pub async fn list_duplicates(
|
||||||
.first()
|
.first()
|
||||||
.map(|i| i.content_hash.0.clone())
|
.map(|i| i.content_hash.0.clone())
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
let media_items: Vec<MediaResponse> =
|
let media_items: Vec<MediaResponse> = items
|
||||||
items.into_iter().map(MediaResponse::from).collect();
|
.into_iter()
|
||||||
|
.map(|item| MediaResponse::new(item, &roots))
|
||||||
|
.collect();
|
||||||
DuplicateGroupResponse {
|
DuplicateGroupResponse {
|
||||||
content_hash,
|
content_hash,
|
||||||
items: media_items,
|
items: media_items,
|
||||||
|
|
|
||||||
|
|
@ -120,7 +120,13 @@ pub async fn list_media(
|
||||||
params.sort,
|
params.sort,
|
||||||
);
|
);
|
||||||
let items = state.storage.list_media(&pagination).await?;
|
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(
|
pub async fn get_media(
|
||||||
|
|
@ -128,7 +134,8 @@ pub async fn get_media(
|
||||||
Path(id): Path<Uuid>,
|
Path(id): Path<Uuid>,
|
||||||
) -> Result<Json<MediaResponse>, ApiError> {
|
) -> Result<Json<MediaResponse>, ApiError> {
|
||||||
let item = state.storage.get_media(MediaId(id)).await?;
|
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).
|
/// 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()}),
|
&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(
|
pub async fn delete_media(
|
||||||
|
|
@ -574,12 +582,14 @@ pub async fn preview_directory(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let roots_for_walk = roots.clone();
|
||||||
let files: Vec<DirectoryPreviewFile> =
|
let files: Vec<DirectoryPreviewFile> =
|
||||||
tokio::task::spawn_blocking(move || {
|
tokio::task::spawn_blocking(move || {
|
||||||
let mut result = Vec::new();
|
let mut result = Vec::new();
|
||||||
fn walk_dir(
|
fn walk_dir(
|
||||||
dir: &std::path::Path,
|
dir: &std::path::Path,
|
||||||
recursive: bool,
|
recursive: bool,
|
||||||
|
roots: &[std::path::PathBuf],
|
||||||
result: &mut Vec<DirectoryPreviewFile>,
|
result: &mut Vec<DirectoryPreviewFile>,
|
||||||
) {
|
) {
|
||||||
let Ok(entries) = std::fs::read_dir(dir) else {
|
let Ok(entries) = std::fs::read_dir(dir) else {
|
||||||
|
|
@ -596,7 +606,7 @@ pub async fn preview_directory(
|
||||||
}
|
}
|
||||||
if path.is_dir() {
|
if path.is_dir() {
|
||||||
if recursive {
|
if recursive {
|
||||||
walk_dir(&path, recursive, result);
|
walk_dir(&path, recursive, roots, result);
|
||||||
}
|
}
|
||||||
} else if path.is_file()
|
} else if path.is_file()
|
||||||
&& let Some(mt) =
|
&& let Some(mt) =
|
||||||
|
|
@ -612,7 +622,7 @@ pub async fn preview_directory(
|
||||||
.and_then(|v| v.as_str().map(String::from))
|
.and_then(|v| v.as_str().map(String::from))
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
result.push(DirectoryPreviewFile {
|
result.push(DirectoryPreviewFile {
|
||||||
path: path.to_string_lossy().to_string(),
|
path: crate::dto::relativize_path(&path, roots),
|
||||||
file_name,
|
file_name,
|
||||||
media_type,
|
media_type,
|
||||||
file_size: size,
|
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
|
result
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
|
|
@ -948,7 +958,8 @@ pub async fn rename_media(
|
||||||
)
|
)
|
||||||
.await?;
|
.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(
|
pub async fn move_media_endpoint(
|
||||||
|
|
@ -994,7 +1005,8 @@ pub async fn move_media_endpoint(
|
||||||
)
|
)
|
||||||
.await?;
|
.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(
|
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}),
|
&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(
|
pub async fn list_trash(
|
||||||
|
|
@ -1159,9 +1172,13 @@ pub async fn list_trash(
|
||||||
|
|
||||||
let items = state.storage.list_trash(&pagination).await?;
|
let items = state.storage.list_trash(&pagination).await?;
|
||||||
let count = state.storage.count_trash().await?;
|
let count = state.storage.count_trash().await?;
|
||||||
|
let roots = state.config.read().await.directories.roots.clone();
|
||||||
|
|
||||||
Ok(Json(TrashResponse {
|
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,
|
total_count: count,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -121,13 +121,16 @@ pub async fn get_timeline(
|
||||||
}
|
}
|
||||||
|
|
||||||
// Convert to response format
|
// Convert to response format
|
||||||
|
let roots = state.config.read().await.directories.roots.clone();
|
||||||
let mut timeline: Vec<TimelineGroup> = groups
|
let mut timeline: Vec<TimelineGroup> = groups
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|(date, items)| {
|
.map(|(date, items)| {
|
||||||
let cover_id = items.first().map(|i| i.id.0.to_string());
|
let cover_id = items.first().map(|i| i.id.0.to_string());
|
||||||
let count = items.len();
|
let count = items.len();
|
||||||
let items: Vec<MediaResponse> =
|
let items: Vec<MediaResponse> = items
|
||||||
items.into_iter().map(MediaResponse::from).collect();
|
.into_iter()
|
||||||
|
.map(|item| MediaResponse::new(item, &roots))
|
||||||
|
.collect();
|
||||||
|
|
||||||
TimelineGroup {
|
TimelineGroup {
|
||||||
date,
|
date,
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,9 @@ use crate::{
|
||||||
|
|
||||||
/// Check whether a user has access to a playlist.
|
/// Check whether a user has access to a playlist.
|
||||||
///
|
///
|
||||||
/// * `require_write` – when `true` only the playlist owner is allowed (for
|
/// # Arguments
|
||||||
|
///
|
||||||
|
/// * `require_write` - when `true` only the playlist owner is allowed (for
|
||||||
/// mutations such as update, delete, add/remove/reorder items). When `false`
|
/// mutations such as update, delete, add/remove/reorder items). When `false`
|
||||||
/// the playlist must either be public or owned by the requesting user.
|
/// the playlist must either be public or owned by the requesting user.
|
||||||
async fn check_playlist_access(
|
async fn check_playlist_access(
|
||||||
|
|
@ -185,7 +187,13 @@ pub async fn list_items(
|
||||||
let user_id = resolve_user_id(&state.storage, &username).await?;
|
let user_id = resolve_user_id(&state.storage, &username).await?;
|
||||||
check_playlist_access(&state.storage, id, user_id, false).await?;
|
check_playlist_access(&state.storage, id, user_id, false).await?;
|
||||||
let items = state.storage.get_playlist_items(id).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(
|
pub async fn reorder_item(
|
||||||
|
|
@ -213,5 +221,11 @@ pub async fn shuffle_playlist(
|
||||||
use rand::seq::SliceRandom;
|
use rand::seq::SliceRandom;
|
||||||
let mut items = state.storage.get_playlist_items(id).await?;
|
let mut items = state.storage.get_playlist_items(id).await?;
|
||||||
items.shuffle(&mut rand::rng());
|
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(),
|
||||||
|
))
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -153,10 +153,12 @@ pub async fn list_plugin_ui_pages(
|
||||||
let pages = plugin_manager.list_ui_pages_with_endpoints().await;
|
let pages = plugin_manager.list_ui_pages_with_endpoints().await;
|
||||||
let entries = pages
|
let entries = pages
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|(plugin_id, page, allowed_endpoints)| PluginUiPageEntry {
|
.map(|(plugin_id, page, allowed_endpoints)| {
|
||||||
|
PluginUiPageEntry {
|
||||||
plugin_id,
|
plugin_id,
|
||||||
page,
|
page,
|
||||||
allowed_endpoints,
|
allowed_endpoints,
|
||||||
|
}
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
Ok(Json(entries))
|
Ok(Json(entries))
|
||||||
|
|
|
||||||
|
|
@ -51,9 +51,14 @@ pub async fn search(
|
||||||
};
|
};
|
||||||
|
|
||||||
let results = state.storage.search(&request).await?;
|
let results = state.storage.search(&request).await?;
|
||||||
|
let roots = state.config.read().await.directories.roots.clone();
|
||||||
|
|
||||||
Ok(Json(SearchResponse {
|
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,
|
total_count: results.total_count,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
@ -84,9 +89,14 @@ pub async fn search_post(
|
||||||
};
|
};
|
||||||
|
|
||||||
let results = state.storage.search(&request).await?;
|
let results = state.storage.search(&request).await?;
|
||||||
|
let roots = state.config.read().await.directories.roots.clone();
|
||||||
|
|
||||||
Ok(Json(SearchResponse {
|
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,
|
total_count: results.total_count,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -506,6 +506,7 @@ pub async fn access_shared(
|
||||||
let _ = state.storage.record_share_activity(&activity).await;
|
let _ = state.storage.record_share_activity(&activity).await;
|
||||||
|
|
||||||
// Return the shared content
|
// Return the shared content
|
||||||
|
let roots = state.config.read().await.directories.roots.clone();
|
||||||
match &share.target {
|
match &share.target {
|
||||||
ShareTarget::Media { media_id } => {
|
ShareTarget::Media { media_id } => {
|
||||||
let item = state
|
let item = state
|
||||||
|
|
@ -514,8 +515,8 @@ pub async fn access_shared(
|
||||||
.await
|
.await
|
||||||
.map_err(|e| ApiError::not_found(format!("Media not found: {e}")))?;
|
.map_err(|e| ApiError::not_found(format!("Media not found: {e}")))?;
|
||||||
|
|
||||||
Ok(Json(SharedContentResponse::Single(MediaResponse::from(
|
Ok(Json(SharedContentResponse::Single(MediaResponse::new(
|
||||||
item,
|
item, &roots,
|
||||||
))))
|
))))
|
||||||
},
|
},
|
||||||
ShareTarget::Collection { collection_id } => {
|
ShareTarget::Collection { collection_id } => {
|
||||||
|
|
@ -527,8 +528,10 @@ pub async fn access_shared(
|
||||||
ApiError::not_found(format!("Collection not found: {e}"))
|
ApiError::not_found(format!("Collection not found: {e}"))
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
let items: Vec<MediaResponse> =
|
let items: Vec<MediaResponse> = members
|
||||||
members.into_iter().map(MediaResponse::from).collect();
|
.into_iter()
|
||||||
|
.map(|item| MediaResponse::new(item, &roots))
|
||||||
|
.collect();
|
||||||
|
|
||||||
Ok(Json(SharedContentResponse::Multiple { items }))
|
Ok(Json(SharedContentResponse::Multiple { items }))
|
||||||
},
|
},
|
||||||
|
|
@ -553,8 +556,11 @@ pub async fn access_shared(
|
||||||
.await
|
.await
|
||||||
.map_err(|e| ApiError::internal(format!("Search failed: {e}")))?;
|
.map_err(|e| ApiError::internal(format!("Search failed: {e}")))?;
|
||||||
|
|
||||||
let items: Vec<MediaResponse> =
|
let items: Vec<MediaResponse> = results
|
||||||
results.items.into_iter().map(MediaResponse::from).collect();
|
.items
|
||||||
|
.into_iter()
|
||||||
|
.map(|item| MediaResponse::new(item, &roots))
|
||||||
|
.collect();
|
||||||
|
|
||||||
Ok(Json(SharedContentResponse::Multiple { items }))
|
Ok(Json(SharedContentResponse::Multiple { items }))
|
||||||
},
|
},
|
||||||
|
|
@ -585,8 +591,11 @@ pub async fn access_shared(
|
||||||
.await
|
.await
|
||||||
.map_err(|e| ApiError::internal(format!("Search failed: {e}")))?;
|
.map_err(|e| ApiError::internal(format!("Search failed: {e}")))?;
|
||||||
|
|
||||||
let items: Vec<MediaResponse> =
|
let items: Vec<MediaResponse> = results
|
||||||
results.items.into_iter().map(MediaResponse::from).collect();
|
.items
|
||||||
|
.into_iter()
|
||||||
|
.map(|item| MediaResponse::new(item, &roots))
|
||||||
|
.collect();
|
||||||
|
|
||||||
Ok(Json(SharedContentResponse::Multiple { items }))
|
Ok(Json(SharedContentResponse::Multiple { items }))
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -125,7 +125,13 @@ pub async fn list_favorites(
|
||||||
.storage
|
.storage
|
||||||
.get_user_favorites(user_id, &Pagination::default())
|
.get_user_favorites(user_id, &Pagination::default())
|
||||||
.await?;
|
.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(
|
pub async fn create_share_link(
|
||||||
|
|
@ -205,5 +211,6 @@ pub async fn access_shared_media(
|
||||||
}
|
}
|
||||||
state.storage.increment_share_views(&token).await?;
|
state.storage.increment_share_views(&token).await?;
|
||||||
let item = state.storage.get_media(link.media_id).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
|
|
@ -7,6 +7,20 @@
|
||||||
// The layout rules here consume those properties via var() so the renderer
|
// The layout rules here consume those properties via var() so the renderer
|
||||||
// never injects full CSS rule strings.
|
// 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.
|
// Container: vertical flex column with configurable gap and padding.
|
||||||
.plugin-container {
|
.plugin-container {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
@ -65,20 +79,568 @@
|
||||||
min-width: 0;
|
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 {
|
.plugin-media-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(var(--plugin-columns, 2), 1fr);
|
grid-template-columns: repeat(var(--plugin-columns, 2), 1fr);
|
||||||
gap: var(--plugin-gap, 8px);
|
gap: var(--plugin-gap, 8px);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Table column with a plugin-specified fixed width.
|
.media-grid-item {
|
||||||
// The width is passed as --plugin-col-width on the th element.
|
background: $bg-2;
|
||||||
.plugin-col-constrained {
|
border: 1px solid $border;
|
||||||
width: var(--plugin-col-width);
|
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 {
|
.plugin-progress-bar {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
background: $accent;
|
background: $accent;
|
||||||
|
|
@ -87,8 +649,140 @@
|
||||||
width: var(--plugin-progress, 0%);
|
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.
|
// Chart wrapper: height is driven by --plugin-chart-height.
|
||||||
.plugin-chart {
|
.plugin-chart {
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
height: var(--plugin-chart-height, 200px);
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -369,11 +369,13 @@ pub fn App() -> Element {
|
||||||
spawn(async move {
|
spawn(async move {
|
||||||
let js: String = vars
|
let js: String = vars
|
||||||
.iter()
|
.iter()
|
||||||
.map(|(k, v)| {
|
.filter_map(|(k, v)| {
|
||||||
format!(
|
let k_js = serde_json::to_string(k).ok()?;
|
||||||
"document.documentElement.style.setProperty('{}','{}');",
|
let v_js = serde_json::to_string(v).ok()?;
|
||||||
k, v
|
Some(format!(
|
||||||
)
|
"document.documentElement.style.setProperty({k_js},\
|
||||||
|
{v_js});"
|
||||||
|
))
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
let _ = document::eval(&js).await;
|
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}" }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -96,14 +96,11 @@ async fn execute_inline_action(
|
||||||
action: &ActionDefinition,
|
action: &ActionDefinition,
|
||||||
form_data: Option<&serde_json::Value>,
|
form_data: Option<&serde_json::Value>,
|
||||||
) -> Result<ActionResult, String> {
|
) -> 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
|
// Merge action params with form data into query string for GET, body for
|
||||||
// others
|
// others
|
||||||
let method = to_reqwest_method(&action.method);
|
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
|
// For GET, merge params into query string; for mutating methods, send as
|
||||||
// JSON body
|
// JSON body
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,10 @@
|
||||||
//!
|
//!
|
||||||
//! Provides data fetching and caching for plugin data sources.
|
//! 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::prelude::*;
|
||||||
use dioxus_core::Task;
|
use dioxus_core::Task;
|
||||||
|
|
@ -15,7 +18,7 @@ use crate::client::ApiClient;
|
||||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||||
pub struct PluginPageData {
|
pub struct PluginPageData {
|
||||||
data: HashMap<String, serde_json::Value>,
|
data: HashMap<String, serde_json::Value>,
|
||||||
loading: HashMap<String, bool>,
|
loading: HashSet<String>,
|
||||||
errors: HashMap<String, String>,
|
errors: HashMap<String, String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -29,13 +32,13 @@ impl PluginPageData {
|
||||||
/// Check if a source is currently loading
|
/// Check if a source is currently loading
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn is_loading(&self, source: &str) -> bool {
|
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
|
/// Get error for a specific source
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn error(&self, source: &str) -> Option<&String> {
|
pub fn error(&self, source: &str) -> Option<&str> {
|
||||||
self.errors.get(source)
|
self.errors.get(source).map(String::as_str)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Check if there is data for a specific source
|
/// Check if there is data for a specific source
|
||||||
|
|
@ -52,7 +55,7 @@ impl PluginPageData {
|
||||||
/// Set loading state for a source
|
/// Set loading state for a source
|
||||||
pub fn set_loading(&mut self, source: &str, loading: bool) {
|
pub fn set_loading(&mut self, source: &str, loading: bool) {
|
||||||
if loading {
|
if loading {
|
||||||
self.loading.insert(source.to_string(), true);
|
self.loading.insert(source.to_string());
|
||||||
self.errors.remove(source);
|
self.errors.remove(source);
|
||||||
} else {
|
} else {
|
||||||
self.loading.remove(source);
|
self.loading.remove(source);
|
||||||
|
|
@ -161,9 +164,10 @@ async fn fetch_endpoint(
|
||||||
///
|
///
|
||||||
/// Endpoint sources are deduplicated by `(path, method, params)`: if multiple
|
/// Endpoint sources are deduplicated by `(path, method, params)`: if multiple
|
||||||
/// sources share the same triplet, a single HTTP request is made and the raw
|
/// 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.
|
/// response is shared, with each source's own `transform` applied
|
||||||
/// All unique Endpoint and Static sources are fetched concurrently. Transform
|
/// independently. All unique Endpoint and Static sources are fetched
|
||||||
/// sources are applied after, in iteration order, against the full result set.
|
/// concurrently. Transform sources are applied after, in iteration order,
|
||||||
|
/// against the full result set.
|
||||||
///
|
///
|
||||||
/// # Errors
|
/// # Errors
|
||||||
///
|
///
|
||||||
|
|
@ -263,7 +267,14 @@ pub async fn fetch_page_data(
|
||||||
..
|
..
|
||||||
} => {
|
} => {
|
||||||
let empty_ctx = serde_json::json!({});
|
let empty_ctx = serde_json::json!({});
|
||||||
fetch_endpoint(&client, path, method.clone(), params, &empty_ctx, &allowed)
|
fetch_endpoint(
|
||||||
|
&client,
|
||||||
|
path,
|
||||||
|
method.clone(),
|
||||||
|
params,
|
||||||
|
&empty_ctx,
|
||||||
|
&allowed,
|
||||||
|
)
|
||||||
.await?
|
.await?
|
||||||
},
|
},
|
||||||
DataSource::Static { value } => value.clone(),
|
DataSource::Static { value } => value.clone(),
|
||||||
|
|
@ -296,21 +307,60 @@ pub async fn fetch_page_data(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Process Transform sources sequentially; they reference results above.
|
// Process Transform sources in dependency order. HashMap iteration order is
|
||||||
for (name, source) in data_sources {
|
// non-deterministic, so a Transform referencing another Transform could see
|
||||||
if let DataSource::Transform {
|
// 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,
|
source_name,
|
||||||
expression,
|
expression,
|
||||||
} = source
|
} => 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(
|
let ctx = serde_json::Value::Object(
|
||||||
results
|
results
|
||||||
.iter()
|
.iter()
|
||||||
.map(|(k, v): (&String, &serde_json::Value)| (k.clone(), v.clone()))
|
.map(|(k, v)| (k.clone(), v.clone()))
|
||||||
.collect(),
|
.collect(),
|
||||||
);
|
);
|
||||||
let _ = source_name; // accessible in ctx by its key
|
|
||||||
results.insert(name.clone(), evaluate_expression(expression, &ctx));
|
results.insert(name.clone(), evaluate_expression(expression, &ctx));
|
||||||
|
pending.swap_remove(i);
|
||||||
|
} else {
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if pending.len() == prev_len {
|
||||||
|
// No progress: upstream source is missing (should be caught by
|
||||||
|
// UiPage::validate, but handled defensively here).
|
||||||
|
tracing::warn!(
|
||||||
|
"plugin transform dependency unresolvable; processing remaining in \
|
||||||
|
iteration order"
|
||||||
|
);
|
||||||
|
for (name, _, expression) in pending {
|
||||||
|
let ctx = serde_json::Value::Object(
|
||||||
|
results
|
||||||
|
.iter()
|
||||||
|
.map(|(k, v)| (k.clone(), v.clone()))
|
||||||
|
.collect(),
|
||||||
|
);
|
||||||
|
results.insert(name.clone(), evaluate_expression(expression, &ctx));
|
||||||
|
}
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -446,7 +496,7 @@ mod tests {
|
||||||
|
|
||||||
// Test error state
|
// Test error state
|
||||||
data.set_error("error".to_string(), "oops".to_string());
|
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]
|
#[test]
|
||||||
|
|
@ -522,7 +572,9 @@ mod tests {
|
||||||
value: serde_json::json!(true),
|
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["nums"], serde_json::json!([1, 2, 3]));
|
||||||
assert_eq!(results["flag"], serde_json::json!(true));
|
assert_eq!(results["flag"], serde_json::json!(true));
|
||||||
}
|
}
|
||||||
|
|
@ -544,7 +596,9 @@ mod tests {
|
||||||
value: serde_json::json!({"ok": true}),
|
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}));
|
assert_eq!(results["raw"], serde_json::json!({"ok": true}));
|
||||||
// derived should return the value of "raw" from context
|
// derived should return the value of "raw" from context
|
||||||
assert_eq!(results["derived"], serde_json::json!({"ok": true}));
|
assert_eq!(results["derived"], serde_json::json!({"ok": true}));
|
||||||
|
|
@ -566,13 +620,13 @@ mod tests {
|
||||||
expression: Expression::Literal(serde_json::json!("constant")),
|
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
|
// A Literal expression returns the literal value, not the source data
|
||||||
assert_eq!(results["derived"], serde_json::json!("constant"));
|
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]
|
#[tokio::test]
|
||||||
async fn test_fetch_page_data_deduplicates_identical_endpoints() {
|
async fn test_fetch_page_data_deduplicates_identical_endpoints() {
|
||||||
use pinakes_plugin_api::DataSource;
|
use pinakes_plugin_api::DataSource;
|
||||||
|
|
@ -589,18 +643,18 @@ mod tests {
|
||||||
sources.insert("b".to_string(), DataSource::Static {
|
sources.insert("b".to_string(), DataSource::Static {
|
||||||
value: serde_json::json!(1),
|
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["a"], serde_json::json!(1));
|
||||||
assert_eq!(results["b"], serde_json::json!(1));
|
assert_eq!(results["b"], serde_json::json!(1));
|
||||||
assert_eq!(results.len(), 2);
|
assert_eq!(results.len(), 2);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Test: Endpoint sources with identical (path, method, params) but different
|
// Verifies that endpoint sources with identical (path, method, params) are
|
||||||
// transform expressions each get a correctly transformed result. Because the
|
// deduplicated correctly. Because there is no real server, the allowlist
|
||||||
// test runs without a real server the path is checked against the allowlist
|
// rejection fires before any network call; both names seeing the same error
|
||||||
// before any network call, so we verify the dedup key grouping through the
|
// proves they were grouped and that the single rejection propagated to all.
|
||||||
// allowlist rejection path: both names should see the same error message,
|
|
||||||
// proving they were grouped and the single rejection propagates to all names.
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_dedup_groups_endpoint_sources_with_same_key() {
|
async fn test_dedup_groups_endpoint_sources_with_same_key() {
|
||||||
use pinakes_plugin_api::{DataSource, Expression, HttpMethod};
|
use pinakes_plugin_api::{DataSource, Expression, HttpMethod};
|
||||||
|
|
@ -640,14 +694,12 @@ mod tests {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Test: multiple Transform sources referencing the same upstream Static source
|
// Verifies the transform fan-out behavior: each member of a dedup group
|
||||||
// with different expressions each receive their independently transformed
|
// applies its own transform to the shared raw value independently. This
|
||||||
// result. This exercises the transform fan-out behavior that mirrors what
|
// mirrors what Endpoint dedup does after a single shared HTTP request.
|
||||||
// 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.
|
|
||||||
//
|
//
|
||||||
// Testing the Endpoint dedup success path with real per-member transforms
|
// Testing Endpoint dedup with real per-member transforms requires a mock HTTP
|
||||||
// requires a mock HTTP server and belongs in an integration test.
|
// server and belongs in an integration test.
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_dedup_transform_applied_per_source() {
|
async fn test_dedup_transform_applied_per_source() {
|
||||||
use pinakes_plugin_api::{DataSource, Expression};
|
use pinakes_plugin_api::{DataSource, Expression};
|
||||||
|
|
@ -670,8 +722,9 @@ mod tests {
|
||||||
expression: Expression::Path("raw_data.name".to_string()),
|
expression: Expression::Path("raw_data.name".to_string()),
|
||||||
});
|
});
|
||||||
|
|
||||||
let results =
|
let results = super::fetch_page_data(&client, &sources, &[])
|
||||||
super::fetch_page_data(&client, &sources, &[]).await.unwrap();
|
.await
|
||||||
|
.unwrap();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
results["raw_data"],
|
results["raw_data"],
|
||||||
serde_json::json!({"count": 42, "name": "test"})
|
serde_json::json!({"count": 42, "name": "test"})
|
||||||
|
|
@ -681,8 +734,6 @@ mod tests {
|
||||||
assert_eq!(results.len(), 3);
|
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]
|
#[tokio::test]
|
||||||
async fn test_endpoint_blocked_when_not_in_allowlist() {
|
async fn test_endpoint_blocked_when_not_in_allowlist() {
|
||||||
use pinakes_plugin_api::{DataSource, HttpMethod};
|
use pinakes_plugin_api::{DataSource, HttpMethod};
|
||||||
|
|
@ -705,7 +756,8 @@ mod tests {
|
||||||
|
|
||||||
assert!(
|
assert!(
|
||||||
result.is_err(),
|
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();
|
let msg = result.unwrap_err();
|
||||||
assert!(
|
assert!(
|
||||||
|
|
|
||||||
|
|
@ -35,13 +35,6 @@ pub struct PluginPage {
|
||||||
pub allowed_endpoints: Vec<String>,
|
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
|
/// Registry of all plugin-provided UI pages and widgets
|
||||||
///
|
///
|
||||||
/// This is typically stored as a signal in the Dioxus tree.
|
/// This is typically stored as a signal in the Dioxus tree.
|
||||||
|
|
@ -55,8 +48,6 @@ pub struct PluginRegistry {
|
||||||
widgets: Vec<(String, UiWidget)>,
|
widgets: Vec<(String, UiWidget)>,
|
||||||
/// Merged CSS custom property overrides from all enabled plugins
|
/// Merged CSS custom property overrides from all enabled plugins
|
||||||
theme_vars: HashMap<String, String>,
|
theme_vars: HashMap<String, String>,
|
||||||
/// Last refresh timestamp
|
|
||||||
last_refresh: Option<chrono::DateTime<chrono::Utc>>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl PluginRegistry {
|
impl PluginRegistry {
|
||||||
|
|
@ -67,7 +58,6 @@ impl PluginRegistry {
|
||||||
pages: HashMap::new(),
|
pages: HashMap::new(),
|
||||||
widgets: Vec::new(),
|
widgets: Vec::new(),
|
||||||
theme_vars: HashMap::new(),
|
theme_vars: HashMap::new(),
|
||||||
last_refresh: None,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -109,14 +99,11 @@ impl PluginRegistry {
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
self.pages.insert(
|
self.pages.insert((plugin_id.clone(), page_id), PluginPage {
|
||||||
(plugin_id.clone(), page_id),
|
|
||||||
PluginPage {
|
|
||||||
plugin_id,
|
plugin_id,
|
||||||
page,
|
page,
|
||||||
allowed_endpoints,
|
allowed_endpoints,
|
||||||
},
|
});
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get a specific page by plugin ID and page ID
|
/// Get a specific page by plugin ID and page ID
|
||||||
|
|
@ -179,7 +166,7 @@ impl PluginRegistry {
|
||||||
self
|
self
|
||||||
.pages
|
.pages
|
||||||
.values()
|
.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()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -207,21 +194,17 @@ impl PluginRegistry {
|
||||||
}
|
}
|
||||||
match self.client.get_plugin_ui_theme_extensions().await {
|
match self.client.get_plugin_ui_theme_extensions().await {
|
||||||
Ok(vars) => tmp.theme_vars = vars,
|
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.
|
// Atomic swap: no window where the registry appears empty.
|
||||||
self.pages = tmp.pages;
|
self.pages = tmp.pages;
|
||||||
self.widgets = tmp.widgets;
|
self.widgets = tmp.widgets;
|
||||||
self.theme_vars = tmp.theme_vars;
|
self.theme_vars = tmp.theme_vars;
|
||||||
self.last_refresh = Some(chrono::Utc::now());
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get last refresh time
|
|
||||||
pub const fn last_refresh(&self) -> Option<chrono::DateTime<chrono::Utc>> {
|
|
||||||
self.last_refresh
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for PluginRegistry {
|
impl Default for PluginRegistry {
|
||||||
|
|
@ -354,7 +337,6 @@ mod tests {
|
||||||
let registry = PluginRegistry::default();
|
let registry = PluginRegistry::default();
|
||||||
assert!(registry.is_empty());
|
assert!(registry.is_empty());
|
||||||
assert_eq!(registry.all_pages().len(), 0);
|
assert_eq!(registry.all_pages().len(), 0);
|
||||||
assert!(registry.last_refresh().is_none());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
@ -367,7 +349,7 @@ mod tests {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_page_full_route() {
|
fn test_page_route() {
|
||||||
let client = ApiClient::default();
|
let client = ApiClient::default();
|
||||||
let mut registry = PluginRegistry::new(client);
|
let mut registry = PluginRegistry::new(client);
|
||||||
registry.register_page(
|
registry.register_page(
|
||||||
|
|
@ -376,9 +358,7 @@ mod tests {
|
||||||
vec![],
|
vec![],
|
||||||
);
|
);
|
||||||
let plugin_page = registry.get_page("my-plugin", "demo").unwrap();
|
let plugin_page = registry.get_page("my-plugin", "demo").unwrap();
|
||||||
// full_route() returns page.route directly; create_test_page sets it as
|
assert_eq!(plugin_page.page.route, "/plugins/test/demo");
|
||||||
// "/plugins/test/{id}"
|
|
||||||
assert_eq!(plugin_page.full_route(), "/plugins/test/demo");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
@ -418,8 +398,16 @@ mod tests {
|
||||||
fn test_all_pages_returns_references() {
|
fn test_all_pages_returns_references() {
|
||||||
let client = ApiClient::default();
|
let client = ApiClient::default();
|
||||||
let mut registry = PluginRegistry::new(client);
|
let mut registry = PluginRegistry::new(client);
|
||||||
registry.register_page("p1".to_string(), create_test_page("a", "A"), vec![]);
|
registry.register_page(
|
||||||
registry.register_page("p2".to_string(), create_test_page("b", "B"), vec![]);
|
"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();
|
let pages = registry.all_pages();
|
||||||
assert_eq!(pages.len(), 2);
|
assert_eq!(pages.len(), 2);
|
||||||
|
|
@ -536,7 +524,11 @@ mod tests {
|
||||||
assert_eq!(registry.all_pages().len(), 0);
|
assert_eq!(registry.all_pages().len(), 0);
|
||||||
|
|
||||||
// Valid page; should still register fine
|
// 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);
|
assert_eq!(registry.all_pages().len(), 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -110,8 +110,12 @@ pub fn PluginViewRenderer(props: PluginViewProps) -> Element {
|
||||||
modal,
|
modal,
|
||||||
local_state,
|
local_state,
|
||||||
};
|
};
|
||||||
let page_data =
|
let page_data = use_plugin_data(
|
||||||
use_plugin_data(props.client, data_sources, refresh, props.allowed_endpoints);
|
props.client,
|
||||||
|
data_sources,
|
||||||
|
refresh,
|
||||||
|
props.allowed_endpoints,
|
||||||
|
);
|
||||||
|
|
||||||
// Consume pending navigation requests and forward to the parent
|
// Consume pending navigation requests and forward to the parent
|
||||||
use_effect(move || {
|
use_effect(move || {
|
||||||
|
|
@ -151,7 +155,7 @@ pub fn PluginViewRenderer(props: PluginViewProps) -> Element {
|
||||||
onclick: move |_| modal.set(None),
|
onclick: move |_| modal.set(None),
|
||||||
"×"
|
"×"
|
||||||
}
|
}
|
||||||
{ render_element(&elem, &page_data.read(), &HashMap::new(), ctx) }
|
{ render_element(&elem, &page_data.read(), &actions, ctx) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -318,43 +322,36 @@ fn PluginDataTable(props: PluginDataTableProps) -> Element {
|
||||||
let row_val = row;
|
let row_val = row;
|
||||||
rsx! {
|
rsx! {
|
||||||
tr {
|
tr {
|
||||||
for col in props.columns.clone() {
|
for col in &props.columns {
|
||||||
td { "{extract_cell(&row_val, &col.key)}" }
|
td { "{extract_cell(&row_val, &col.key)}" }
|
||||||
}
|
}
|
||||||
if !props.row_actions.is_empty() {
|
if !props.row_actions.is_empty() {
|
||||||
td { class: "row-actions",
|
td { class: "row-actions",
|
||||||
for act in props.row_actions.clone() {
|
for act in &props.row_actions {
|
||||||
{
|
{
|
||||||
let action = act.action.clone();
|
let action = act.action.clone();
|
||||||
let row_data = row_val.clone();
|
let row_data = row_val.clone();
|
||||||
let variant_class =
|
let variant_class =
|
||||||
button_variant_class(&act.variant);
|
button_variant_class(&act.variant);
|
||||||
let page_actions = props.actions.clone();
|
let page_actions = props.actions.clone();
|
||||||
let success_msg: Option<String> =
|
let (success_msg, error_msg): (
|
||||||
match &act.action {
|
Option<String>,
|
||||||
ActionRef::Special(_) => None,
|
Option<String>,
|
||||||
|
) = match &act.action {
|
||||||
|
ActionRef::Special(_) => (None, None),
|
||||||
ActionRef::Name(name) => props
|
ActionRef::Name(name) => props
|
||||||
.actions
|
.actions
|
||||||
.get(name)
|
.get(name)
|
||||||
.and_then(|a| {
|
.map_or((None, None), |a| {
|
||||||
a.success_message.clone()
|
(
|
||||||
|
a.success_message.clone(),
|
||||||
|
a.error_message.clone(),
|
||||||
|
)
|
||||||
}),
|
}),
|
||||||
ActionRef::Inline(a) => {
|
ActionRef::Inline(a) => (
|
||||||
a.success_message.clone()
|
a.success_message.clone(),
|
||||||
},
|
a.error_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 ctx = props.ctx;
|
let ctx = props.ctx;
|
||||||
// Pre-compute data JSON at render time to
|
// Pre-compute data JSON at render time to
|
||||||
|
|
@ -489,7 +486,8 @@ pub fn render_element(
|
||||||
|| "0".to_string(),
|
|| "0".to_string(),
|
||||||
|p| format!("{}px {}px {}px {}px", p[0], p[1], p[2], p[3]),
|
|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! {
|
rsx! {
|
||||||
div {
|
div {
|
||||||
class: "plugin-container",
|
class: "plugin-container",
|
||||||
|
|
@ -710,7 +708,8 @@ pub fn render_element(
|
||||||
} else if let Some(arr) = items.and_then(|v| v.as_array()) {
|
} else if let Some(arr) = items.and_then(|v| v.as_array()) {
|
||||||
for item in arr {
|
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);
|
let label = media_grid_label(item);
|
||||||
rsx! {
|
rsx! {
|
||||||
div { class: "media-grid-item",
|
div { class: "media-grid-item",
|
||||||
|
|
@ -797,7 +796,16 @@ pub fn render_element(
|
||||||
.map(|obj| {
|
.map(|obj| {
|
||||||
obj
|
obj
|
||||||
.iter()
|
.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()
|
.collect()
|
||||||
})
|
})
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
|
|
@ -829,19 +837,17 @@ pub fn render_element(
|
||||||
let variant_class = button_variant_class(variant);
|
let variant_class = button_variant_class(variant);
|
||||||
let action_ref = action.clone();
|
let action_ref = action.clone();
|
||||||
let page_actions = actions.clone();
|
let page_actions = actions.clone();
|
||||||
let success_msg: Option<String> = match action {
|
let (success_msg, error_msg): (Option<String>, Option<String>) =
|
||||||
ActionRef::Special(_) => None,
|
match action {
|
||||||
|
ActionRef::Special(_) => (None, None),
|
||||||
ActionRef::Name(name) => {
|
ActionRef::Name(name) => {
|
||||||
actions.get(name).and_then(|a| a.success_message.clone())
|
actions.get(name).map_or((None, None), |a| {
|
||||||
|
(a.success_message.clone(), a.error_message.clone())
|
||||||
|
})
|
||||||
},
|
},
|
||||||
ActionRef::Inline(a) => a.success_message.clone(),
|
ActionRef::Inline(a) => {
|
||||||
};
|
(a.success_message.clone(), a.error_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 data_snapshot = build_ctx(data, &ctx.local_state.read());
|
let data_snapshot = build_ctx(data, &ctx.local_state.read());
|
||||||
rsx! {
|
rsx! {
|
||||||
|
|
@ -904,19 +910,17 @@ pub fn render_element(
|
||||||
} => {
|
} => {
|
||||||
let action_ref = submit_action.clone();
|
let action_ref = submit_action.clone();
|
||||||
let page_actions = actions.clone();
|
let page_actions = actions.clone();
|
||||||
let success_msg: Option<String> = match submit_action {
|
let (success_msg, error_msg): (Option<String>, Option<String>) =
|
||||||
ActionRef::Special(_) => None,
|
match submit_action {
|
||||||
|
ActionRef::Special(_) => (None, None),
|
||||||
ActionRef::Name(name) => {
|
ActionRef::Name(name) => {
|
||||||
actions.get(name).and_then(|a| a.success_message.clone())
|
actions.get(name).map_or((None, None), |a| {
|
||||||
|
(a.success_message.clone(), a.error_message.clone())
|
||||||
|
})
|
||||||
},
|
},
|
||||||
ActionRef::Inline(a) => a.success_message.clone(),
|
ActionRef::Inline(a) => {
|
||||||
};
|
(a.success_message.clone(), a.error_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 data_snapshot = build_ctx(data, &ctx.local_state.read());
|
let data_snapshot = build_ctx(data, &ctx.local_state.read());
|
||||||
rsx! {
|
rsx! {
|
||||||
|
|
@ -1050,7 +1054,7 @@ pub fn render_element(
|
||||||
max,
|
max,
|
||||||
show_percentage,
|
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 pct = evaluate_expression_as_f64(value, &eval_ctx);
|
||||||
let fraction = if *max > 0.0 {
|
let fraction = if *max > 0.0 {
|
||||||
(pct / max).clamp(0.0, 1.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_class = chart_type_class(chart_type);
|
||||||
let chart_data = data.get(source_key).cloned();
|
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! {
|
rsx! {
|
||||||
div {
|
div {
|
||||||
class: "plugin-chart {chart_class}",
|
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(x) = x_axis_label { div { class: "chart-x-label", "{x}" } }
|
||||||
if let Some(y) = y_axis_label { div { class: "chart-y-label", "{y}" } }
|
if let Some(y) = y_axis_label { div { class: "chart-y-label", "{y}" } }
|
||||||
div { class: "chart-data-table",
|
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,
|
then,
|
||||||
else_element,
|
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) {
|
if evaluate_expression_as_bool(condition, &eval_ctx) {
|
||||||
render_element(then, data, actions, ctx)
|
render_element(then, data, actions, ctx)
|
||||||
} else if let Some(else_el) = else_element {
|
} else if let Some(else_el) = else_element {
|
||||||
|
|
@ -1252,7 +1254,10 @@ fn render_chart_data(
|
||||||
// MediaGrid helpers
|
// MediaGrid helpers
|
||||||
|
|
||||||
/// Probe a JSON object for common image URL fields.
|
/// 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 &[
|
for key in &[
|
||||||
"thumbnail_url",
|
"thumbnail_url",
|
||||||
"thumbnail",
|
"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
|
None
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Probe a JSON object for a human-readable label.
|
/// Probe a JSON object for a human-readable label.
|
||||||
fn media_grid_label(item: &serde_json::Value) -> String {
|
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 let Some(s) = item.get(*key).and_then(|v| v.as_str()) {
|
||||||
if !s.is_empty() {
|
if !s.is_empty() {
|
||||||
return s.to_string();
|
return s.to_string();
|
||||||
|
|
@ -1609,12 +1624,41 @@ fn safe_col_width_css(w: &str) -> Option<String> {
|
||||||
None
|
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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use pinakes_plugin_api::Expression;
|
use pinakes_plugin_api::Expression;
|
||||||
|
|
||||||
use super::*;
|
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]
|
#[test]
|
||||||
fn test_extract_cell_string() {
|
fn test_extract_cell_string() {
|
||||||
let row = serde_json::json!({ "name": "Alice", "count": 5 });
|
let row = serde_json::json!({ "name": "Alice", "count": 5 });
|
||||||
|
|
|
||||||
531
docs/plugins.md
531
docs/plugins.md
|
|
@ -1,17 +1,30 @@
|
||||||
# Plugin System
|
# Plugin System
|
||||||
|
|
||||||
Pinakes is very powerful on its own, but with a goal as ambitious as "to be the
|
Pinakes is first and foremost a _server_ application. This server can be
|
||||||
last media management system you will ever need" I recognize the need for a
|
extended with a plugin system that runs WASM binaries in the server process.
|
||||||
plugin system; not everything belongs in the core of Pinakes. Thus, Pinakes
|
They extend media type detection, metadata extension, thumbnail generation,
|
||||||
supports WASM-based plugins for extending media type support, metadata
|
search, event handling and theming.
|
||||||
extraction, thumbnail generation, search, event handling, and theming.
|
|
||||||
|
|
||||||
Plugins run in a sandboxed wasmtime runtime with capability-based security, fuel
|
The first-party GUI for Pinakes, dubbed `pinakes-ui` within this codebase, can
|
||||||
metering, memory limits, and a circuit breaker for fault isolation.
|
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
|
```plaintext
|
||||||
my-plugin/
|
my-plugin/
|
||||||
|
|
@ -19,9 +32,9 @@ my-plugin/
|
||||||
my_plugin.wasm Compiled WASM binary (wasm32-unknown-unknown target)
|
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
|
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
|
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
|
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.
|
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
|
Plugins go through a priority-ordered pipeline. Each plugin declares a priority
|
||||||
(0–999, default 500). Built-in handlers run at implicit priority 100, so plugins
|
(0-999, default 500). Built-in handlers run at implicit priority 100, so plugins
|
||||||
at priority <100 run _before_ built-ins and plugins at >100 run _after_.
|
at priority <100 run _before_ built-ins and plugins at >100 run _after_.
|
||||||
Different extension points use different merge strategies:
|
Different extension points use different merge strategies:
|
||||||
|
|
||||||
|
|
@ -49,7 +62,7 @@ Different extension points use different merge strategies:
|
||||||
|
|
||||||
<!--markdownlint-enable MD013-->
|
<!--markdownlint-enable MD013-->
|
||||||
|
|
||||||
## Plugin Kinds
|
### Plugin Kinds
|
||||||
|
|
||||||
A plugin can implement one or more of these roles:
|
A plugin can implement one or more of these roles:
|
||||||
|
|
||||||
|
|
@ -74,7 +87,7 @@ kind = ["media_type", "metadata_extractor", "thumbnail_generator"]
|
||||||
priority = 50
|
priority = 50
|
||||||
```
|
```
|
||||||
|
|
||||||
## Writing a Plugin
|
### Writing a Plugin
|
||||||
|
|
||||||
[dlmalloc]: https://crates.io/crates/dlmalloc
|
[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:
|
Let's go over a minimal example that registers a custom `.mytype` file format:
|
||||||
|
|
||||||
|
<!--markdownlint-disable MD013-->
|
||||||
|
|
||||||
```rust
|
```rust
|
||||||
#![no_std]
|
#![no_std]
|
||||||
|
|
||||||
|
|
@ -156,34 +171,39 @@ pub extern "C" fn can_handle(ptr: i32, len: i32) {
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### Building
|
<!--markdownlint-enable MD013-->
|
||||||
|
|
||||||
|
#### Building
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
# Build for the wasm32-unknown-unknown target
|
||||||
$ cargo build --target wasm32-unknown-unknown --release
|
$ cargo build --target wasm32-unknown-unknown --release
|
||||||
```
|
```
|
||||||
|
|
||||||
The `RUSTFLAGS=""` override may be needed if your environment sets linker flags
|
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
|
(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`
|
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
|
Once the compilation is done, the resulting binary will be in the target
|
||||||
`target/wasm32-unknown-unknown/release/my_plugin.wasm`.
|
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
|
#### Installing
|
||||||
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
|
|
||||||
|
|
||||||
Place the plugin directory in one of the configured plugin directories, or use
|
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
|
```bash
|
||||||
curl -X POST http://localhost:3000/api/v1/plugins/install \
|
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"}'
|
-d '{"source": "/path/to/my-plugin"}'
|
||||||
```
|
```
|
||||||
|
|
||||||
## Manifest Reference
|
### Manifest Reference
|
||||||
|
|
||||||
Plugins are required to provide a manifest with explicit intents for everything.
|
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
|
```toml
|
||||||
[plugin]
|
[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)
|
max_cpu_time_secs = 5 # Fuel budget per invocation (default: 60s)
|
||||||
```
|
```
|
||||||
|
|
||||||
## Extension Points
|
### Extension Points
|
||||||
|
|
||||||
Every plugin must export these three functions:
|
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
|
capability enforcer prevents a plugin from being called for functions it hasn't
|
||||||
declared; a `metadata_extractor` plugin cannot have `search` called on it.
|
declared; a `metadata_extractor` plugin cannot have `search` called on it.
|
||||||
|
|
||||||
### `MediaTypeProvider`
|
#### `MediaTypeProvider`
|
||||||
|
|
||||||
Plugins with `kind` containing `"media_type"` export:
|
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
|
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.
|
file. If no plugin claims it, built-in handlers run as fallback.
|
||||||
|
|
||||||
### `MetadataExtractor`
|
#### `MetadataExtractor`
|
||||||
|
|
||||||
Plugins with `kind` containing `"metadata_extractor"` export:
|
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
|
order. Later plugins' non-null fields overwrite earlier ones. The `extra` maps
|
||||||
are merged (later keys win).
|
are merged (later keys win).
|
||||||
|
|
||||||
### `ThumbnailGenerator`
|
#### `ThumbnailGenerator`
|
||||||
|
|
||||||
Plugins with `kind` containing `"thumbnail_generator"` export:
|
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
|
First-success-wins. The first plugin to return a successful result produces the
|
||||||
thumbnail.
|
thumbnail.
|
||||||
|
|
||||||
### `SearchBackend`
|
#### `SearchBackend`
|
||||||
|
|
||||||
Plugins with `kind` containing `"search_backend"` export:
|
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
|
score), and sorted by score descending. `index_item` and `remove_item` are
|
||||||
fanned out to all backends.
|
fanned out to all backends.
|
||||||
|
|
||||||
### `EventHandler`
|
#### `EventHandler`
|
||||||
|
|
||||||
Plugins with `kind` containing `"event_handler"` export:
|
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
|
All interested plugins receive the event. Events are dispatched asynchronously
|
||||||
via `tokio::spawn` and do not block the caller. Handler failures are logged but
|
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]
|
> [!IMPORTANT]
|
||||||
> `ThemeProvider` is experimental, and will likely be subject to change.
|
> `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
|
Themes from all providers are accumulated. When loading a specific theme, the
|
||||||
pipeline dispatches to the plugin that registered it.
|
pipeline dispatches to the plugin that registered it.
|
||||||
|
|
||||||
### System Events
|
#### System Events
|
||||||
|
|
||||||
The server emits these events at various points:
|
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
|
Plugins can also emit events themselves via `host_emit_event`, enabling
|
||||||
plugin-to-plugin communication.
|
plugin-to-plugin communication.
|
||||||
|
|
||||||
## Host Functions
|
### Host Functions
|
||||||
|
|
||||||
Plugins can call these host functions from within WASM (imported from the
|
Plugins can call these host functions from within WASM (imported from the
|
||||||
`"env"` module):
|
`"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
|
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
|
into its own linear memory and passes the pointer and length. This is how you
|
||||||
return data from any extension point function.
|
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
|
Emit a system event from within a plugin. Enables plugin-to-plugin
|
||||||
communication.
|
communication.
|
||||||
|
|
||||||
Returns `0` on success, `-1` on error.
|
Returns `0` on success, `-1` on error.
|
||||||
|
|
||||||
### `host_log(level, ptr, len)`
|
#### `host_log(level, ptr, len)`
|
||||||
|
|
||||||
Log a message.
|
Log a message.
|
||||||
|
|
||||||
Levels: 0=error, 1=warn, 2=info, 3+=debug. Messages appear in the server's
|
Levels: 0=error, 1=warn, 2=info, 3+=debug. Messages appear in the server's
|
||||||
tracing output.
|
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
|
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
|
error, `-2` if the path is outside the plugin's allowed read paths (paths are
|
||||||
canonicalized before checking, so traversal tricks won't work).
|
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.
|
Write data to a file.
|
||||||
|
|
||||||
Returns `0` on success, `-1` on IO error, `-2` if the path is outside allowed
|
Returns `0` on success, `-1` on IO error, `-2` if the path is outside allowed
|
||||||
write paths.
|
write paths.
|
||||||
|
|
||||||
### `host_http_request(url_ptr, url_len) -> i32`
|
#### `host_http_request(url_ptr, url_len) -> i32`
|
||||||
|
|
||||||
Make an HTTP GET request.
|
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
|
if network access is disabled, `-3` if the domain is not in the plugin's
|
||||||
`allowed_domains` list.
|
`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
|
Read a plugin configuration value. Returns JSON value length on success (in
|
||||||
exchange buffer), `-1` if key not found.
|
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.
|
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
|
not set, `-2` if environment access is disabled or the variable is not in the
|
||||||
plugin's `environment` list.
|
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.
|
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
|
`host_get_config`, or `host_get_env` to retrieve data the host placed in the
|
||||||
buffer.
|
buffer.
|
||||||
|
|
||||||
## Security Model
|
### Security Model
|
||||||
|
|
||||||
### Capabilities
|
#### Capabilities
|
||||||
|
|
||||||
Every plugin declares what it needs in `plugin.toml`. The `CapabilityEnforcer`
|
Every plugin declares what it needs in `plugin.toml`. The `CapabilityEnforcer`
|
||||||
validates these at load time _and_ at runtime; a plugin declaring
|
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
|
- **CPU**: Enforced via wasmtime's fuel metering. Each invocation gets a fuel
|
||||||
budget proportional to the configured CPU time limit.
|
budget proportional to the configured CPU time limit.
|
||||||
|
|
||||||
### Isolation
|
#### Isolation
|
||||||
|
|
||||||
- Each `call_function` invocation creates a fresh wasmtime `Store`. Plugins
|
- Each `call_function` invocation creates a fresh wasmtime `Store`. Plugins
|
||||||
cannot retain WASM runtime state between calls. If you need persistence, use
|
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.
|
- The wasmtime stack is limited to 1MB.
|
||||||
|
|
||||||
### Timeouts
|
#### Timeouts
|
||||||
|
|
||||||
Three tiers of per-call timeouts prevent runaway plugins:
|
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-->
|
<!--markdownlint-enable MD013-->
|
||||||
|
|
||||||
### Circuit Breaker
|
#### Circuit Breaker
|
||||||
|
|
||||||
If a plugin fails consecutively, the circuit breaker disables it automatically:
|
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.
|
- Disabled plugins are skipped in all pipeline stages.
|
||||||
- Reload or toggle the plugin via the API to re-enable.
|
- Reload or toggle the plugin via the API to re-enable.
|
||||||
|
|
||||||
### Signatures
|
#### Signatures
|
||||||
|
|
||||||
Plugins can be signed with Ed25519. The signing flow:
|
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
|
or matches no trusted key, the plugin is rejected at load time. Set
|
||||||
`allow_unsigned = true` during development to skip this check.
|
`allow_unsigned = true` during development to skip this check.
|
||||||
|
|
||||||
## Plugin Lifecycle
|
### Plugin Lifecycle
|
||||||
|
|
||||||
1. **Discovery**: On startup, the plugin manager walks configured plugin
|
1. **Discovery**: On startup, the plugin manager walks configured plugin
|
||||||
directories looking for `plugin.toml` files.
|
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
|
9. **Hot-reload**: The `/plugins/:id/reload` endpoint reloads the WASM binary
|
||||||
from disk and re-discovers capabilities without restarting the server.
|
from disk and re-discovers capabilities without restarting the server.
|
||||||
|
|
||||||
## Plugin API Endpoints
|
### Plugin API Endpoints
|
||||||
|
|
||||||
<!-- FIXME: this should be moved to API documentation -->
|
<!-- 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
|
capabilities, so changes to supported types or event subscriptions take effect
|
||||||
immediately.
|
immediately.
|
||||||
|
|
||||||
## Configuration
|
### Configuration
|
||||||
|
|
||||||
In `pinakes.toml`:
|
In `pinakes.toml`:
|
||||||
|
|
||||||
|
|
@ -682,7 +702,7 @@ allowed_read_paths = ["/media", "/tmp/pinakes"]
|
||||||
allowed_write_paths = ["/tmp/pinakes/plugin-data"]
|
allowed_write_paths = ["/tmp/pinakes/plugin-data"]
|
||||||
```
|
```
|
||||||
|
|
||||||
## Debugging
|
### Debugging
|
||||||
|
|
||||||
- **`host_log`**: Call from within your plugin to emit structured log messages.
|
- **`host_log`**: Call from within your plugin to emit structured log messages.
|
||||||
They appear in the server's tracing output.
|
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
|
- **Circuit breaker**: If your plugin is silently skipped, check the server logs
|
||||||
for "circuit breaker tripped" messages. Fix the issue, then re-enable via
|
for "circuit breaker tripped" messages. Fix the issue, then re-enable via
|
||||||
`POST /api/v1/plugins/:id/enable`.
|
`POST /api/v1/plugins/:id/enable`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## GUI Plugins
|
||||||
|
|
||||||
|
A plugin declares its UI pages and optional widgets in `plugin.toml`, either
|
||||||
|
inline or as separate `.json` files. At startup, the server indexes all plugin
|
||||||
|
manifests and serves the schemas from `GET /api/v1/plugins/ui/pages`. The UI
|
||||||
|
fetches and validates these schemas, registers them in the `PluginRegistry`, and
|
||||||
|
adds the pages to the sidebar navigation. Widgets are injected into fixed
|
||||||
|
locations in host views at registration time.
|
||||||
|
|
||||||
|
No WASM code runs during page rendering. The schema is purely declarative JSON.
|
||||||
|
|
||||||
|
### Manifest Additions
|
||||||
|
|
||||||
|
Add a `[ui]` section to `plugin.toml`:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[plugin]
|
||||||
|
name = "my-plugin"
|
||||||
|
version = "1.0.0"
|
||||||
|
api_version = "1.0"
|
||||||
|
kind = ["ui_page"]
|
||||||
|
|
||||||
|
# Inline page definition
|
||||||
|
[[ui.pages]]
|
||||||
|
id = "stats"
|
||||||
|
title = "My Stats"
|
||||||
|
route = "/plugins/my-plugin/stats"
|
||||||
|
icon = "chart-bar"
|
||||||
|
|
||||||
|
[ui.pages.data_sources.summary]
|
||||||
|
type = "endpoint"
|
||||||
|
path = "/api/v1/plugins/my-plugin/summary"
|
||||||
|
poll_interval = 30 # re-fetch every 30 seconds
|
||||||
|
|
||||||
|
[ui.pages.layout]
|
||||||
|
type = "container"
|
||||||
|
children = []
|
||||||
|
|
||||||
|
# File-referenced page
|
||||||
|
[[ui.pages]]
|
||||||
|
file = "pages/detail.json" # path relative to plugin directory
|
||||||
|
|
||||||
|
# Widget injections
|
||||||
|
[[ui.widgets]]
|
||||||
|
id = "my-badge"
|
||||||
|
target = "library_header"
|
||||||
|
|
||||||
|
[ui.widgets.content]
|
||||||
|
type = "badge"
|
||||||
|
text = "My Plugin"
|
||||||
|
variant = "default"
|
||||||
|
```
|
||||||
|
|
||||||
|
Alternatively, pages can be defined entirely in a separate JSON file:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "stats",
|
||||||
|
"title": "My Stats",
|
||||||
|
"route": "/plugins/my-plugin/stats",
|
||||||
|
"icon": "chart-bar",
|
||||||
|
"data_sources": {
|
||||||
|
"summary": {
|
||||||
|
"type": "endpoint",
|
||||||
|
"path": "/api/v1/plugins/my-plugin/summary"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"layout": {
|
||||||
|
"type": "container",
|
||||||
|
"children": []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### `UiPage` Fields
|
||||||
|
|
||||||
|
<!--markdownlint-disable MD013-->
|
||||||
|
|
||||||
|
| Field | Type | Required | Description |
|
||||||
|
| -------------- | -------------------------- | -------- | ----------------------------------------------------- |
|
||||||
|
| `id` | string | Yes | Unique identifier (alphanumeric, dashes, underscores) |
|
||||||
|
| `title` | string | Yes | Display name shown in navigation |
|
||||||
|
| `route` | string | Yes | URL path (must start with `/`) |
|
||||||
|
| `icon` | string | No | Icon name (from dioxus-free-icons) |
|
||||||
|
| `layout` | `UiElement` | Yes | Root layout element (see Element Reference) |
|
||||||
|
| `data_sources` | `{name: DataSource}` | No | Named data sources available to this page |
|
||||||
|
| `actions` | `{name: ActionDefinition}` | No | Named actions referenced by elements |
|
||||||
|
|
||||||
|
<!--markdownlint-enable MD013-->
|
||||||
|
|
||||||
|
### Data Sources
|
||||||
|
|
||||||
|
Data sources populate named slots that elements bind to via their `data` field.
|
||||||
|
|
||||||
|
#### `endpoint`: HTTP API call
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[ui.pages.data_sources.items]
|
||||||
|
type = "endpoint"
|
||||||
|
method = "GET" # GET (default), POST, PUT, PATCH, DELETE
|
||||||
|
path = "/api/v1/media" # must start with /
|
||||||
|
poll_interval = 60 # seconds; 0 = no polling (default)
|
||||||
|
|
||||||
|
# Query params for GET, body for other methods
|
||||||
|
#
|
||||||
|
# Values are Expressions (see Expression Syntax)
|
||||||
|
[ui.pages.data_sources.items.params]
|
||||||
|
limit = 20
|
||||||
|
offset = 0
|
||||||
|
```
|
||||||
|
|
||||||
|
#### `static`: Inline JSON
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[ui.pages.data_sources.options]
|
||||||
|
type = "static"
|
||||||
|
value = ["asc", "desc"]
|
||||||
|
```
|
||||||
|
|
||||||
|
#### `transform`: Derived from another source
|
||||||
|
|
||||||
|
```toml
|
||||||
|
# Evaluate an expression against an already-fetched source
|
||||||
|
[ui.pages.data_sources.count]
|
||||||
|
type = "transform"
|
||||||
|
source = "items" # name of the source to read from
|
||||||
|
expression = "items.total_count" # path expression into the context
|
||||||
|
```
|
||||||
|
|
||||||
|
Transform sources always run after all non-transform sources, so the named
|
||||||
|
source is guaranteed to be available in the context.
|
||||||
|
|
||||||
|
### Expression Syntax
|
||||||
|
|
||||||
|
Expressions appear as values in `params`, `transform`, `TextContent`, and
|
||||||
|
`Progress.value`.
|
||||||
|
|
||||||
|
<!--markdownlint-disable MD013-->
|
||||||
|
|
||||||
|
| Form | JSON example | Description |
|
||||||
|
| --------- | --------------------------------------------------- | -------------------------------------------- |
|
||||||
|
| Literal | `42`, `"hello"`, `true`, `null` | A fixed JSON value |
|
||||||
|
| Path | `"users.0.name"`, `"summary.count"` | Dot-separated path into the data context |
|
||||||
|
| Operation | `{"left":"a","op":"concat","right":"b"}` | Binary expression (see operators table) |
|
||||||
|
| Call | `{"function":"format","args":["Hello, {}!","Bob"]}` | Built-in function call (see functions table) |
|
||||||
|
|
||||||
|
<!--markdownlint-enable MD013-->
|
||||||
|
|
||||||
|
Path expressions use dot notation. Array indices are plain numbers:
|
||||||
|
`"items.0.title"` accesses `items[0].title`.
|
||||||
|
|
||||||
|
**Operators:**
|
||||||
|
|
||||||
|
| `op` | Result type | Description |
|
||||||
|
| -------- | ----------- | ------------------------------------------ |
|
||||||
|
| `eq` | bool | Equal |
|
||||||
|
| `ne` | bool | Not equal |
|
||||||
|
| `gt` | bool | Greater than (numeric) |
|
||||||
|
| `gte` | bool | Greater than or equal |
|
||||||
|
| `lt` | bool | Less than |
|
||||||
|
| `lte` | bool | Less than or equal |
|
||||||
|
| `and` | bool | Logical AND |
|
||||||
|
| `or` | bool | Logical OR |
|
||||||
|
| `concat` | string | String concatenation (both sides coerced) |
|
||||||
|
| `add` | number | f64 addition |
|
||||||
|
| `sub` | number | f64 subtraction |
|
||||||
|
| `mul` | number | f64 multiplication |
|
||||||
|
| `div` | number | f64 division (returns 0 on divide-by-zero) |
|
||||||
|
|
||||||
|
**Built-in functions:**
|
||||||
|
|
||||||
|
<!--markdownlint-disable MD013-->
|
||||||
|
|
||||||
|
| Function | Signature | Description |
|
||||||
|
| ----------- | -------------------------------------- | -------------------------------------------------------------- |
|
||||||
|
| `len` | `len(value)` | Length of array, string (chars), or object (key count) |
|
||||||
|
| `upper` | `upper(str)` | Uppercase string |
|
||||||
|
| `lower` | `lower(str)` | Lowercase string |
|
||||||
|
| `trim` | `trim(str)` | Remove leading/trailing whitespace |
|
||||||
|
| `format` | `format(template, ...args)` | Replace `{}` placeholders left-to-right with args |
|
||||||
|
| `join` | `join(array, sep)` | Join array elements into a string with separator |
|
||||||
|
| `contains` | `contains(haystack, needle)` | True if string contains substring, or array contains value |
|
||||||
|
| `keys` | `keys(object)` | Array of object keys |
|
||||||
|
| `values` | `values(object)` | Array of object values |
|
||||||
|
| `abs` | `abs(number)` | Absolute value |
|
||||||
|
| `round` | `round(number)` | Round to nearest integer |
|
||||||
|
| `floor` | `floor(number)` | Round down |
|
||||||
|
| `ceil` | `ceil(number)` | Round up |
|
||||||
|
| `not` | `not(bool)` | Boolean negation |
|
||||||
|
| `coalesce` | `coalesce(a, b, ...)` | First non-null argument |
|
||||||
|
| `to_string` | `to_string(value)` | Convert any value to its display string |
|
||||||
|
| `to_number` | `to_number(value)` | Parse string to f64; pass through numbers; bool coerces to 0/1 |
|
||||||
|
| `slice` | `slice(array_or_string, start[, end])` | Sub-array or substring; negative indices count from end |
|
||||||
|
| `reverse` | `reverse(array_or_string)` | Reversed array or string |
|
||||||
|
| `if` | `if(cond, then, else)` | Return `then` if `cond` is true, otherwise `else` |
|
||||||
|
|
||||||
|
<!--markdownlint-enable MD013-->
|
||||||
|
|
||||||
|
### Actions
|
||||||
|
|
||||||
|
Actions define what happens when a button is clicked or a form is submitted.
|
||||||
|
|
||||||
|
#### Inline action
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "button",
|
||||||
|
"label": "Delete",
|
||||||
|
"action": {
|
||||||
|
"method": "DELETE",
|
||||||
|
"path": "/api/v1/media/123",
|
||||||
|
"success_message": "Deleted!",
|
||||||
|
"navigate_to": "/library"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Named action
|
||||||
|
|
||||||
|
Define the action in the page's `actions` map, then reference it by name:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[ui.pages.actions.delete-item]
|
||||||
|
method = "DELETE"
|
||||||
|
path = "/api/v1/media/target"
|
||||||
|
navigate_to = "/library"
|
||||||
|
```
|
||||||
|
|
||||||
|
Then reference it anywhere an `ActionRef` is accepted:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "type": "button", "label": "Delete", "action": "delete-item" }
|
||||||
|
```
|
||||||
|
|
||||||
|
Named actions allow multiple elements to share the same action definition
|
||||||
|
without repetition. The action name must be a valid identifier (alphanumeric,
|
||||||
|
dashes, underscores).
|
||||||
|
|
||||||
|
#### `ActionDefinition` fields
|
||||||
|
|
||||||
|
<!--markdownlint-disable MD013-->
|
||||||
|
|
||||||
|
| Field | Type | Default | Description |
|
||||||
|
| ----------------- | ------ | ------- | ------------------------------------------ |
|
||||||
|
| `method` | string | `GET` | HTTP method |
|
||||||
|
| `path` | string | | API path (must start with `/`) |
|
||||||
|
| `params` | object | `{}` | Fixed params merged with form data on POST |
|
||||||
|
| `success_message` | string | | Toast message on success |
|
||||||
|
| `error_message` | string | | Toast message on error |
|
||||||
|
| `navigate_to` | string | | Route to navigate to after success |
|
||||||
|
|
||||||
|
<!--markdownlint-enable MD013-->
|
||||||
|
|
||||||
|
### Element Reference
|
||||||
|
|
||||||
|
All elements are JSON objects with a `type` field.
|
||||||
|
|
||||||
|
<!--markdownlint-disable MD013-->
|
||||||
|
|
||||||
|
| Type | Key fields | Description |
|
||||||
|
| ------------------ | ----------------------------------------------------------------------- | ---------------------------------------- |
|
||||||
|
| `container` | `children`, `gap`, `padding` | Stacked children with gap/padding |
|
||||||
|
| `grid` | `children`, `columns` (1-12), `gap` | CSS grid layout |
|
||||||
|
| `flex` | `children`, `direction`, `justify`, `align`, `gap`, `wrap` | Flexbox layout |
|
||||||
|
| `split` | `sidebar`, `sidebar_width`, `main` | Sidebar + main content layout |
|
||||||
|
| `tabs` | `tabs` (array of `{id,label,content[]}`), `default_tab` | Tabbed panels |
|
||||||
|
| `heading` | `level` (1-6), `content` | Section heading h1-h6 |
|
||||||
|
| `text` | `content`, `variant`, `allow_html` | Paragraph text |
|
||||||
|
| `code` | `content`, `language`, `show_line_numbers` | Code block with syntax highlighting |
|
||||||
|
| `data_table` | `columns`, `data`, `sortable`, `filterable`, `page_size`, `row_actions` | Sortable, filterable data table |
|
||||||
|
| `card` | `title`, `content`, `footer` | Content card with optional header/footer |
|
||||||
|
| `media_grid` | `data`, `columns`, `gap` | Responsive image/video grid |
|
||||||
|
| `list` | `data`, `item_template`, `dividers` | Templated list (loops over data items) |
|
||||||
|
| `description_list` | `data`, `horizontal` | Key-value pair list (metadata display) |
|
||||||
|
| `button` | `label`, `variant`, `action`, `disabled` | Clickable button |
|
||||||
|
| `form` | `fields`, `submit_label`, `submit_action`, `cancel_label` | Input form with submission |
|
||||||
|
| `link` | `text`, `href`, `external` | Navigation link |
|
||||||
|
| `progress` | `value` (Expression), `max`, `show_percentage` | Progress bar |
|
||||||
|
| `badge` | `text`, `variant` | Status badge/chip |
|
||||||
|
| `loop` | `data`, `item`, `template` | Iterates data array, renders template |
|
||||||
|
| `conditional` | `condition` (Expression), `then`, `else` | Conditional rendering |
|
||||||
|
| `chart` | `chart_type`, `data`, `x_key`, `y_key`, `title` | Bar/line/pie/scatter chart |
|
||||||
|
| `image` | `src`, `alt`, `width`, `height`, `object_fit` | Image element |
|
||||||
|
| `divider` | | Horizontal rule |
|
||||||
|
| `spacer` | `size` | Blank vertical space |
|
||||||
|
| `raw_html` | `html` | Sanitized raw HTML block |
|
||||||
|
|
||||||
|
<!--markdownlint-enable MD013-->
|
||||||
|
|
||||||
|
### Widget Injection
|
||||||
|
|
||||||
|
Widgets are small UI elements injected into fixed locations in the host views.
|
||||||
|
Unlike pages, widgets have no data sources; they render with static or
|
||||||
|
expression-based content only.
|
||||||
|
|
||||||
|
#### Declaring a widget
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[[ui.widgets]]
|
||||||
|
id = "status-badge"
|
||||||
|
target = "library_header"
|
||||||
|
|
||||||
|
[ui.widgets.content]
|
||||||
|
type = "badge"
|
||||||
|
text = "My Plugin Active"
|
||||||
|
variant = "success"
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Target locations
|
||||||
|
|
||||||
|
| Target string | Where it renders |
|
||||||
|
| ----------------- | ----------------------------------------- |
|
||||||
|
| `library_header` | Before the stats grid in the Library view |
|
||||||
|
| `library_sidebar` | After the stats grid in the Library view |
|
||||||
|
| `search_filters` | Above the Search component in Search view |
|
||||||
|
| `detail_panel` | Above the Detail component in Detail view |
|
||||||
|
|
||||||
|
Multiple plugins can register widgets at the same target; all are rendered in
|
||||||
|
registration order.
|
||||||
|
|
||||||
|
### Complete Example
|
||||||
|
|
||||||
|
A plugin page that lists media items and lets the user trigger a re-scan:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[plugin]
|
||||||
|
name = "rescan-page"
|
||||||
|
version = "1.0.0"
|
||||||
|
api_version = "1.0"
|
||||||
|
kind = ["ui_page"]
|
||||||
|
|
||||||
|
[[ui.pages]]
|
||||||
|
id = "rescan"
|
||||||
|
title = "Re-scan"
|
||||||
|
route = "/plugins/rescan-page/rescan"
|
||||||
|
icon = "refresh"
|
||||||
|
|
||||||
|
[ui.pages.actions.trigger-scan]
|
||||||
|
method = "POST"
|
||||||
|
path = "/api/v1/scan/trigger"
|
||||||
|
success_message = "Scan started!"
|
||||||
|
navigate_to = "/plugins/rescan-page/rescan"
|
||||||
|
|
||||||
|
[ui.pages.data_sources.media]
|
||||||
|
type = "endpoint"
|
||||||
|
path = "/api/v1/media"
|
||||||
|
poll_interval = 30
|
||||||
|
|
||||||
|
[ui.pages.layout]
|
||||||
|
type = "container"
|
||||||
|
gap = 16
|
||||||
|
|
||||||
|
[[ui.pages.layout.children]]
|
||||||
|
type = "flex"
|
||||||
|
direction = "row"
|
||||||
|
justify = "space-between"
|
||||||
|
gap = 8
|
||||||
|
|
||||||
|
[[ui.pages.layout.children.children]]
|
||||||
|
type = "heading"
|
||||||
|
level = 2
|
||||||
|
content = "Library"
|
||||||
|
|
||||||
|
[[ui.pages.layout.children.children]]
|
||||||
|
type = "button"
|
||||||
|
label = "Trigger Re-scan"
|
||||||
|
variant = "primary"
|
||||||
|
action = "trigger-scan"
|
||||||
|
|
||||||
|
[[ui.pages.layout.children]]
|
||||||
|
type = "data_table"
|
||||||
|
data = "media"
|
||||||
|
sortable = true
|
||||||
|
filterable = true
|
||||||
|
page_size = 25
|
||||||
|
|
||||||
|
[[ui.pages.layout.children.columns]]
|
||||||
|
key = "title"
|
||||||
|
label = "Title"
|
||||||
|
|
||||||
|
[[ui.pages.layout.children.columns]]
|
||||||
|
key = "media_type"
|
||||||
|
label = "Type"
|
||||||
|
|
||||||
|
[[ui.pages.layout.children.columns]]
|
||||||
|
key = "file_size"
|
||||||
|
label = "Size"
|
||||||
|
```
|
||||||
|
|
|
||||||
48
examples/plugins/media-stats-ui/Cargo.lock
generated
Normal file
48
examples/plugins/media-stats-ui/Cargo.lock
generated
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
# This file is automatically @generated by Cargo.
|
||||||
|
# It is not intended for manual editing.
|
||||||
|
version = 4
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cfg-if"
|
||||||
|
version = "1.0.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "dlmalloc"
|
||||||
|
version = "0.2.12"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6738d2e996274e499bc7b0d693c858b7720b9cd2543a0643a3087e6cb0a4fa16"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
"libc",
|
||||||
|
"windows-sys",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "libc"
|
||||||
|
version = "0.2.183"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "media-stats-ui"
|
||||||
|
version = "1.0.0"
|
||||||
|
dependencies = [
|
||||||
|
"dlmalloc",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-link"
|
||||||
|
version = "0.2.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-sys"
|
||||||
|
version = "0.61.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
|
||||||
|
dependencies = [
|
||||||
|
"windows-link",
|
||||||
|
]
|
||||||
20
examples/plugins/media-stats-ui/Cargo.toml
Normal file
20
examples/plugins/media-stats-ui/Cargo.toml
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
[workspace]
|
||||||
|
|
||||||
|
[package]
|
||||||
|
name = "media-stats-ui"
|
||||||
|
version = "1.0.0"
|
||||||
|
edition = "2024"
|
||||||
|
description = "Library statistics dashboard and tag manager, a UI-only Pinakes plugin"
|
||||||
|
license = "EUPL-1.2"
|
||||||
|
|
||||||
|
[lib]
|
||||||
|
name = "media_stats_ui"
|
||||||
|
crate-type = ["cdylib"]
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
dlmalloc = { version = "0.2.12", features = ["global"] }
|
||||||
|
|
||||||
|
[profile.release]
|
||||||
|
opt-level = "s"
|
||||||
|
lto = true
|
||||||
|
strip = true
|
||||||
132
examples/plugins/media-stats-ui/pages/stats.json
Normal file
132
examples/plugins/media-stats-ui/pages/stats.json
Normal file
|
|
@ -0,0 +1,132 @@
|
||||||
|
{
|
||||||
|
"id": "stats",
|
||||||
|
"title": "Library Statistics",
|
||||||
|
"route": "/plugins/media-stats-ui/stats",
|
||||||
|
"icon": "chart-bar",
|
||||||
|
"layout": {
|
||||||
|
"type": "tabs",
|
||||||
|
"default_tab": 0,
|
||||||
|
"tabs": [
|
||||||
|
{
|
||||||
|
"label": "Overview",
|
||||||
|
"content": {
|
||||||
|
"type": "container",
|
||||||
|
"gap": 24,
|
||||||
|
"children": [
|
||||||
|
{
|
||||||
|
"type": "heading",
|
||||||
|
"level": 2,
|
||||||
|
"content": "Library Statistics"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "text",
|
||||||
|
"content": "Live summary of your media library. Refreshes every 30 seconds.",
|
||||||
|
"variant": "secondary"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "card",
|
||||||
|
"title": "Summary",
|
||||||
|
"content": [
|
||||||
|
{
|
||||||
|
"type": "description_list",
|
||||||
|
"data": "stats",
|
||||||
|
"horizontal": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "chart",
|
||||||
|
"chart_type": "bar",
|
||||||
|
"data": "type-breakdown",
|
||||||
|
"title": "Files by Type",
|
||||||
|
"x_axis_label": "Media Type",
|
||||||
|
"y_axis_label": "Count",
|
||||||
|
"height": 280
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "Recent Files",
|
||||||
|
"content": {
|
||||||
|
"type": "container",
|
||||||
|
"gap": 16,
|
||||||
|
"children": [
|
||||||
|
{
|
||||||
|
"type": "heading",
|
||||||
|
"level": 2,
|
||||||
|
"content": "Recently Added"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "data_table",
|
||||||
|
"data": "recent",
|
||||||
|
"sortable": true,
|
||||||
|
"filterable": true,
|
||||||
|
"page_size": 10,
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"key": "file_name",
|
||||||
|
"header": "Filename"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "title",
|
||||||
|
"header": "Title"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "media_type",
|
||||||
|
"header": "Type"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "file_size",
|
||||||
|
"header": "Size",
|
||||||
|
"data_type": "file_size"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "created_at",
|
||||||
|
"header": "Added",
|
||||||
|
"data_type": "date_time"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "Media Grid",
|
||||||
|
"content": {
|
||||||
|
"type": "container",
|
||||||
|
"gap": 16,
|
||||||
|
"children": [
|
||||||
|
{
|
||||||
|
"type": "heading",
|
||||||
|
"level": 2,
|
||||||
|
"content": "Browse Media"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "media_grid",
|
||||||
|
"data": "recent",
|
||||||
|
"columns": 4,
|
||||||
|
"gap": 12
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"data_sources": {
|
||||||
|
"stats": {
|
||||||
|
"type": "endpoint",
|
||||||
|
"path": "/api/v1/statistics",
|
||||||
|
"poll_interval": 30
|
||||||
|
},
|
||||||
|
"recent": {
|
||||||
|
"type": "endpoint",
|
||||||
|
"path": "/api/v1/media"
|
||||||
|
},
|
||||||
|
"type-breakdown": {
|
||||||
|
"type": "transform",
|
||||||
|
"source": "stats",
|
||||||
|
"expression": "stats.media_by_type"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
126
examples/plugins/media-stats-ui/pages/tag-manager.json
Normal file
126
examples/plugins/media-stats-ui/pages/tag-manager.json
Normal file
|
|
@ -0,0 +1,126 @@
|
||||||
|
{
|
||||||
|
"id": "tag-manager",
|
||||||
|
"title": "Tag Manager",
|
||||||
|
"route": "/plugins/media-stats-ui/tag-manager",
|
||||||
|
"icon": "tag",
|
||||||
|
"layout": {
|
||||||
|
"type": "tabs",
|
||||||
|
"default_tab": 0,
|
||||||
|
"tabs": [
|
||||||
|
{
|
||||||
|
"label": "All Tags",
|
||||||
|
"content": {
|
||||||
|
"type": "container",
|
||||||
|
"gap": 16,
|
||||||
|
"children": [
|
||||||
|
{
|
||||||
|
"type": "heading",
|
||||||
|
"level": 2,
|
||||||
|
"content": "Manage Tags"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "conditional",
|
||||||
|
"condition": {
|
||||||
|
"op": "eq",
|
||||||
|
"left": { "function": "len", "args": ["tags"] },
|
||||||
|
"right": 0
|
||||||
|
},
|
||||||
|
"then": {
|
||||||
|
"type": "text",
|
||||||
|
"content": "No tags yet. Use the 'Create Tag' tab to add one.",
|
||||||
|
"variant": "secondary"
|
||||||
|
},
|
||||||
|
"else": {
|
||||||
|
"type": "data_table",
|
||||||
|
"data": "tags",
|
||||||
|
"sortable": true,
|
||||||
|
"filterable": true,
|
||||||
|
"page_size": 20,
|
||||||
|
"columns": [
|
||||||
|
{ "key": "name", "header": "Tag Name" },
|
||||||
|
{ "key": "color", "header": "Color" },
|
||||||
|
{ "key": "item_count", "header": "Items", "data_type": "number" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "Create Tag",
|
||||||
|
"content": {
|
||||||
|
"type": "container",
|
||||||
|
"gap": 24,
|
||||||
|
"children": [
|
||||||
|
{
|
||||||
|
"type": "heading",
|
||||||
|
"level": 2,
|
||||||
|
"content": "Create New Tag"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "text",
|
||||||
|
"content": "Tags are used to organise media items. Choose a name and an optional colour.",
|
||||||
|
"variant": "secondary"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "form",
|
||||||
|
"submit_label": "Create Tag",
|
||||||
|
"submit_action": "create-tag",
|
||||||
|
"cancel_label": "Reset",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"id": "name",
|
||||||
|
"label": "Tag Name",
|
||||||
|
"type": { "type": "text", "max_length": 64 },
|
||||||
|
"required": true,
|
||||||
|
"placeholder": "e.g. favourite, to-watch, archived",
|
||||||
|
"help_text": "Must be unique. Alphanumeric characters, spaces, and hyphens.",
|
||||||
|
"validation": [
|
||||||
|
{ "type": "min_length", "value": 1 },
|
||||||
|
{ "type": "max_length", "value": 64 },
|
||||||
|
{ "type": "pattern", "regex": "^[a-zA-Z0-9 \\-]+$" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "color",
|
||||||
|
"label": "Colour",
|
||||||
|
"type": {
|
||||||
|
"type": "select",
|
||||||
|
"options": [
|
||||||
|
{ "value": "#ef4444", "label": "Red" },
|
||||||
|
{ "value": "#f97316", "label": "Orange" },
|
||||||
|
{ "value": "#eab308", "label": "Yellow" },
|
||||||
|
{ "value": "#22c55e", "label": "Green" },
|
||||||
|
{ "value": "#3b82f6", "label": "Blue" },
|
||||||
|
{ "value": "#8b5cf6", "label": "Purple" },
|
||||||
|
{ "value": "#ec4899", "label": "Pink" },
|
||||||
|
{ "value": "#6b7280", "label": "Grey" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"required": false,
|
||||||
|
"default_value": "#3b82f6",
|
||||||
|
"help_text": "Optional accent colour shown beside the tag."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"data_sources": {
|
||||||
|
"tags": {
|
||||||
|
"type": "endpoint",
|
||||||
|
"path": "/api/v1/tags",
|
||||||
|
"poll_interval": 0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"actions": {
|
||||||
|
"create-tag": {
|
||||||
|
"method": "POST",
|
||||||
|
"path": "/api/v1/tags",
|
||||||
|
"success_message": "Tag created successfully!",
|
||||||
|
"error_message": "Failed to create tag: the name may already be in use."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
39
examples/plugins/media-stats-ui/plugin.toml
Normal file
39
examples/plugins/media-stats-ui/plugin.toml
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
[plugin]
|
||||||
|
name = "media-stats-ui"
|
||||||
|
version = "1.0.0"
|
||||||
|
api_version = "1.0"
|
||||||
|
author = "Pinakes Contributors"
|
||||||
|
description = "Library statistics dashboard and tag manager UI plugin"
|
||||||
|
homepage = "https://github.com/notashelf/pinakes"
|
||||||
|
license = "EUPL-1.2"
|
||||||
|
kind = ["ui_page"]
|
||||||
|
|
||||||
|
[plugin.binary]
|
||||||
|
wasm = "target/wasm32-unknown-unknown/release/media_stats_ui.wasm"
|
||||||
|
|
||||||
|
[capabilities]
|
||||||
|
network = false
|
||||||
|
|
||||||
|
[capabilities.filesystem]
|
||||||
|
read = []
|
||||||
|
write = []
|
||||||
|
|
||||||
|
[ui]
|
||||||
|
required_endpoints = ["/api/v1/statistics", "/api/v1/media", "/api/v1/tags"]
|
||||||
|
|
||||||
|
# UI pages
|
||||||
|
[[ui.pages]]
|
||||||
|
file = "pages/stats.json"
|
||||||
|
|
||||||
|
[[ui.pages]]
|
||||||
|
file = "pages/tag-manager.json"
|
||||||
|
|
||||||
|
# Widgets injected into host views
|
||||||
|
[[ui.widgets]]
|
||||||
|
id = "stats-badge"
|
||||||
|
target = "library_header"
|
||||||
|
|
||||||
|
[ui.widgets.content]
|
||||||
|
type = "badge"
|
||||||
|
text = "Stats"
|
||||||
|
variant = "info"
|
||||||
101
examples/plugins/media-stats-ui/src/lib.rs
Normal file
101
examples/plugins/media-stats-ui/src/lib.rs
Normal file
|
|
@ -0,0 +1,101 @@
|
||||||
|
//! Media Stats UI - Pinakes plugin
|
||||||
|
//!
|
||||||
|
//! A UI-only plugin that adds a library statistics dashboard and a tag manager
|
||||||
|
//! page. All UI definitions live in `pages/stats.json` and
|
||||||
|
//! `pages/tag-manager.json`; this WASM binary provides the minimum lifecycle
|
||||||
|
//! surface the host runtime requires.
|
||||||
|
//!
|
||||||
|
//! This plugin is kind = ["ui_page"]: no media-type, metadata, thumbnail, or
|
||||||
|
//! event-handler extension points are needed. The host will never call them,
|
||||||
|
//! but exporting them avoids linker warnings if the host performs capability
|
||||||
|
//! discovery via symbol inspection.
|
||||||
|
|
||||||
|
#![no_std]
|
||||||
|
|
||||||
|
extern crate alloc;
|
||||||
|
|
||||||
|
use core::alloc::Layout;
|
||||||
|
|
||||||
|
#[global_allocator]
|
||||||
|
static ALLOC: dlmalloc::GlobalDlmalloc = dlmalloc::GlobalDlmalloc;
|
||||||
|
|
||||||
|
#[panic_handler]
|
||||||
|
fn panic(_: &core::panic::PanicInfo) -> ! {
|
||||||
|
core::arch::wasm32::unreachable()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Host functions provided by the Pinakes runtime.
|
||||||
|
unsafe extern "C" {
|
||||||
|
// Write a result value back to the host (ptr + byte length).
|
||||||
|
fn host_set_result(ptr: i32, len: i32);
|
||||||
|
|
||||||
|
// Emit a structured log message to the host logger.
|
||||||
|
// `level` mirrors tracing severity: 0=trace 1=debug 2=info 3=warn 4=error
|
||||||
|
fn host_log(level: i32, ptr: i32, len: i32);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// # Safety
|
||||||
|
///
|
||||||
|
/// `json` is a valid slice; the host copies the bytes before
|
||||||
|
/// returning so there are no lifetime concerns.
|
||||||
|
fn set_response(json: &[u8]) {
|
||||||
|
unsafe { host_set_result(json.as_ptr() as i32, json.len() as i32) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// # Safety
|
||||||
|
///
|
||||||
|
/// Same as [`set_response`]
|
||||||
|
fn log_info(msg: &[u8]) {
|
||||||
|
unsafe { host_log(2, msg.as_ptr() as i32, msg.len() as i32) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Allocate a buffer for the host to write request data into.
|
||||||
|
///
|
||||||
|
/// # Returns
|
||||||
|
///
|
||||||
|
/// The byte offset of the allocation, or -1 on failure.
|
||||||
|
///
|
||||||
|
/// # Safety
|
||||||
|
///
|
||||||
|
/// Size is positive; Layout construction cannot fail for align=1.
|
||||||
|
#[unsafe(no_mangle)]
|
||||||
|
pub extern "C" fn alloc(size: i32) -> i32 {
|
||||||
|
if size <= 0 {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
unsafe {
|
||||||
|
let layout = Layout::from_size_align_unchecked(size as usize, 1);
|
||||||
|
let ptr = alloc::alloc::alloc(layout);
|
||||||
|
if ptr.is_null() { -1 } else { ptr as i32 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Called once after the plugin is loaded. Returns 0 on success.
|
||||||
|
#[unsafe(no_mangle)]
|
||||||
|
pub extern "C" fn initialize() -> i32 {
|
||||||
|
log_info(b"media-stats-ui: initialized");
|
||||||
|
0
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Called before the plugin is unloaded. Returns 0 on success.
|
||||||
|
#[unsafe(no_mangle)]
|
||||||
|
pub extern "C" fn shutdown() -> i32 {
|
||||||
|
log_info(b"media-stats-ui: shutdown");
|
||||||
|
0
|
||||||
|
}
|
||||||
|
|
||||||
|
/// # Returns
|
||||||
|
///
|
||||||
|
/// an empty JSON array; this plugin adds no custom media types.
|
||||||
|
#[unsafe(no_mangle)]
|
||||||
|
pub extern "C" fn supported_media_types(_ptr: i32, _len: i32) {
|
||||||
|
set_response(b"[]");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// # Returns
|
||||||
|
///
|
||||||
|
/// An empty JSON array; this plugin handles no event types.
|
||||||
|
#[unsafe(no_mangle)]
|
||||||
|
pub extern "C" fn interested_events(_ptr: i32, _len: i32) {
|
||||||
|
set_response(b"[]");
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue