Compare commits
18 commits
7cbce98795
...
b6e579408f
| Author | SHA1 | Date | |
|---|---|---|---|
|
b6e579408f |
|||
|
9176b764b0 |
|||
|
6d42ac0f48 |
|||
|
7b841cbd9a |
|||
|
920d2e95ab |
|||
|
f0fdd2ab91 |
|||
|
bc74bf8730 |
|||
|
df1c46fa5c |
|||
|
3a565689c3 |
|||
|
02c7b7e2f6 |
|||
|
d059263209 |
|||
|
1c351c0f53 |
|||
|
9d93a527ca |
|||
|
8ded6fedc8 |
|||
|
613f6cab54 |
|||
|
b89c7a5dc5 |
|||
|
dda84d148c |
|||
|
3aa1503441 |
40 changed files with 2606 additions and 331 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;
|
||||||
}
|
}
|
||||||
for entry in &plugin.manifest.ui.pages {
|
let allowed = plugin.manifest.ui.required_endpoints.clone();
|
||||||
let page = match entry {
|
let plugin_dir = plugin
|
||||||
pinakes_plugin_api::manifest::UiPageEntry::Inline(page) => {
|
.manifest_path
|
||||||
(**page).clone()
|
.as_ref()
|
||||||
},
|
.and_then(|p| p.parent())
|
||||||
pinakes_plugin_api::manifest::UiPageEntry::File { .. } => {
|
.map(std::path::Path::to_path_buf);
|
||||||
// File-referenced pages require a base path to resolve;
|
let Some(plugin_dir) = plugin_dir else {
|
||||||
// skip them here as they should have been loaded at startup.
|
for entry in &plugin.manifest.ui.pages {
|
||||||
continue;
|
if let pinakes_plugin_api::manifest::UiPageEntry::Inline(page) = entry
|
||||||
},
|
{
|
||||||
};
|
pages.push((plugin.id.clone(), (**page).clone(), allowed.clone()));
|
||||||
pages.push((plugin.id.clone(), page));
|
}
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
match plugin.manifest.load_ui_pages(&plugin_dir) {
|
||||||
|
Ok(loaded) => {
|
||||||
|
for page in loaded {
|
||||||
|
pages.push((plugin.id.clone(), page, allowed.clone()));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!(
|
||||||
|
"Failed to load UI pages for plugin '{}': {e}",
|
||||||
|
plugin.id
|
||||||
|
);
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
pages
|
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,
|
||||||
|
|
|
||||||
|
|
@ -185,7 +185,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 +219,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)| {
|
||||||
plugin_id,
|
PluginUiPageEntry {
|
||||||
page,
|
plugin_id,
|
||||||
allowed_endpoints,
|
page,
|
||||||
|
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,8 +267,15 @@ 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(
|
||||||
.await?
|
&client,
|
||||||
|
path,
|
||||||
|
method.clone(),
|
||||||
|
params,
|
||||||
|
&empty_ctx,
|
||||||
|
&allowed,
|
||||||
|
)
|
||||||
|
.await?
|
||||||
},
|
},
|
||||||
DataSource::Static { value } => value.clone(),
|
DataSource::Static { value } => value.clone(),
|
||||||
DataSource::Transform { .. } => unreachable!(),
|
DataSource::Transform { .. } => unreachable!(),
|
||||||
|
|
@ -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
|
||||||
source_name,
|
// any Transform whose upstream is not yet in results, making progress on
|
||||||
expression,
|
// each pass until all are resolved. UiPage::validate guarantees no cycles,
|
||||||
} = source
|
// so the loop always terminates.
|
||||||
{
|
let mut pending: Vec<(&String, &String, &Expression)> = data_sources
|
||||||
let ctx = serde_json::Value::Object(
|
.iter()
|
||||||
results
|
.filter_map(|(name, source)| {
|
||||||
.iter()
|
match source {
|
||||||
.map(|(k, v): (&String, &serde_json::Value)| (k.clone(), v.clone()))
|
DataSource::Transform {
|
||||||
.collect(),
|
source_name,
|
||||||
|
expression,
|
||||||
|
} => Some((name, source_name, expression)),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
while !pending.is_empty() {
|
||||||
|
let prev_len = pending.len();
|
||||||
|
let mut i = 0;
|
||||||
|
while i < pending.len() {
|
||||||
|
let (name, source_name, expression) = pending[i];
|
||||||
|
if results.contains_key(source_name.as_str()) {
|
||||||
|
let ctx = serde_json::Value::Object(
|
||||||
|
results
|
||||||
|
.iter()
|
||||||
|
.map(|(k, v)| (k.clone(), v.clone()))
|
||||||
|
.collect(),
|
||||||
|
);
|
||||||
|
results.insert(name.clone(), evaluate_expression(expression, &ctx));
|
||||||
|
pending.swap_remove(i);
|
||||||
|
} else {
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if pending.len() == prev_len {
|
||||||
|
// No progress: upstream source is missing (should be caught by
|
||||||
|
// UiPage::validate, but handled defensively here).
|
||||||
|
tracing::warn!(
|
||||||
|
"plugin transform dependency unresolvable; processing remaining in \
|
||||||
|
iteration order"
|
||||||
);
|
);
|
||||||
let _ = source_name; // accessible in ctx by its key
|
for (name, _, expression) in pending {
|
||||||
results.insert(name.clone(), evaluate_expression(expression, &ctx));
|
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,28 +35,19 @@ 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.
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct PluginRegistry {
|
pub struct PluginRegistry {
|
||||||
/// API client for fetching pages from server
|
/// API client for fetching pages from server
|
||||||
client: ApiClient,
|
client: ApiClient,
|
||||||
/// Cached pages: (`plugin_id`, `page_id`) -> `PluginPage`
|
/// Cached pages: (`plugin_id`, `page_id`) -> `PluginPage`
|
||||||
pages: HashMap<(String, String), PluginPage>,
|
pages: HashMap<(String, String), PluginPage>,
|
||||||
/// Cached widgets: (`plugin_id`, `widget_id`) -> `UiWidget`
|
/// Cached widgets: (`plugin_id`, `widget_id`) -> `UiWidget`
|
||||||
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),
|
plugin_id,
|
||||||
PluginPage {
|
page,
|
||||||
plugin_id,
|
allowed_endpoints,
|
||||||
page,
|
});
|
||||||
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,44 +322,37 @@ 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>,
|
||||||
ActionRef::Name(name) => props
|
) = match &act.action {
|
||||||
.actions
|
ActionRef::Special(_) => (None, None),
|
||||||
.get(name)
|
ActionRef::Name(name) => props
|
||||||
.and_then(|a| {
|
.actions
|
||||||
a.success_message.clone()
|
.get(name)
|
||||||
}),
|
.map_or((None, None), |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::Inline(a) => (
|
||||||
ActionRef::Special(_) => None,
|
a.success_message.clone(),
|
||||||
ActionRef::Name(name) => props
|
a.error_message.clone(),
|
||||||
.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
|
||||||
// avoid moving props.data into closures.
|
// avoid moving props.data into closures.
|
||||||
|
|
@ -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,20 +837,18 @@ 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::Name(name) => {
|
ActionRef::Special(_) => (None, None),
|
||||||
actions.get(name).and_then(|a| a.success_message.clone())
|
ActionRef::Name(name) => {
|
||||||
},
|
actions.get(name).map_or((None, None), |a| {
|
||||||
ActionRef::Inline(a) => a.success_message.clone(),
|
(a.success_message.clone(), a.error_message.clone())
|
||||||
};
|
})
|
||||||
let error_msg: Option<String> = match action {
|
},
|
||||||
ActionRef::Special(_) => None,
|
ActionRef::Inline(a) => {
|
||||||
ActionRef::Name(name) => {
|
(a.success_message.clone(), a.error_message.clone())
|
||||||
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! {
|
||||||
button {
|
button {
|
||||||
|
|
@ -904,20 +910,18 @@ 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::Name(name) => {
|
ActionRef::Special(_) => (None, None),
|
||||||
actions.get(name).and_then(|a| a.success_message.clone())
|
ActionRef::Name(name) => {
|
||||||
},
|
actions.get(name).map_or((None, None), |a| {
|
||||||
ActionRef::Inline(a) => a.success_message.clone(),
|
(a.success_message.clone(), a.error_message.clone())
|
||||||
};
|
})
|
||||||
let error_msg: Option<String> = match submit_action {
|
},
|
||||||
ActionRef::Special(_) => None,
|
ActionRef::Inline(a) => {
|
||||||
ActionRef::Name(name) => {
|
(a.success_message.clone(), a.error_message.clone())
|
||||||
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! {
|
||||||
form {
|
form {
|
||||||
|
|
@ -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 });
|
||||||
|
|
|
||||||
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