Compare commits

...

18 commits

Author SHA1 Message Date
b6e579408f
chore: fix clippy lints; format
Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: Ib3d98a81c7e41054d27e617394bef63c6a6a6964
2026-03-11 21:31:43 +03:00
9176b764b0
examples/media-stats-ui: fix Transform source key; add file_name column
Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I4c741e4b36708f2078fed8154d7341de6a6a6964
2026-03-11 21:31:42 +03:00
6d42ac0f48
pinakes-ui: integrate plugin pages into sidebar navigation; sanitize theme-extension CSS eval
Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: Ie87e39c66253a7071f029d52dd5979716a6a6964
2026-03-11 21:31:41 +03:00
7b841cbd9a
pinakes-ui: supply local_state to Conditional and Progress; remove last_refresh
Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: Ib513b5846d6c74bfe821da195b7080af6a6a6964
2026-03-11 21:31:40 +03:00
920d2e95ab
pinakes-ui: add plugin component stylesheet
Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I05de526f0cea5df269b0fee226ef1edf6a6a6964
2026-03-11 21:31:00 +03:00
f0fdd2ab91
pinakes-plugin-api: add reserved-route and required-endpoint validation
Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: Id85a7e729b26af8eb028e19418a5a1706a6a6964
2026-03-11 21:30:59 +03:00
bc74bf8730
pinakes-core: use InvalidOperation for nil media_id in upsert_book_metadata
Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I72a80731d926b79660abf20c2c766e8c6a6a6964
2026-03-11 21:30:59 +03:00
df1c46fa5c
treewide: cleanup
Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: Ia01590cdeed872cc8ebd16f6ca95f3cc6a6a6964
2026-03-11 21:30:58 +03:00
3a565689c3
pinakes-core: check file existence before removal in TempFileGuard drop
Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I800825f5dc3b526d350931ff8f1ed0da6a6a6964
2026-03-11 21:30:57 +03:00
02c7b7e2f6
pinakes-core: map serde_json errors to Serialization variant in export
Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I77c27639ea1aca03d54702e38fc3ef576a6a6964
2026-03-11 21:30:56 +03:00
d059263209
pinakes-core: expose required_endpoints alongside UI pages in plugin manager
Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I32c95a03f106db8fef7eedd0362756a46a6a6964
2026-03-11 21:30:55 +03:00
1c351c0f53
pinakes-plugin-api: consolidate reserved-route check; reject widget data-source refs
Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I042ee31e95822f46520a618de8dcaf786a6a6964
2026-03-11 21:30:54 +03:00
9d93a527ca
meta: prefer std's OnceLock and LazyLock over once_cell
Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I35d51abfa9a790206391dca891799d956a6a6964
2026-03-11 21:30:53 +03:00
8ded6fedc8
examples: add media-stats-ui plugin
Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I7c9ccac175440d278fd129dbd53f04d66a6a6964
2026-03-11 21:30:52 +03:00
613f6cab54
pinakes-core: add integration tests for batch_update_media
Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I0787bec99f7c1d098c1c1168560a43266a6a6964
2026-03-11 21:30:51 +03:00
b89c7a5dc5
pinakes-core: add error context to tag and collection writes; map serde_json errors to Serialization variant
pinakes-core: distinguish task panics from cancellations in import error
  handling
Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: Icf5686f34144630ebf1935c47b3979156a6a6964
2026-03-11 21:30:50 +03:00
dda84d148c
pinakes-core: unify book metadata extraction; remove ExtractedBookMetadata
Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: Ifd6e66515b9ff78a4bb13eba47b9b2cf6a6a6964
2026-03-11 21:30:49 +03:00
3aa1503441
pinakes-server: relativize media paths against configured root directories
Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I9f113e6402030c46ad97f636985b5d6c6a6a6964
2026-03-11 21:30:48 +03:00
40 changed files with 2606 additions and 331 deletions

View file

@ -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" },
]

View file

@ -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)?;
}, },

View file

@ -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}"
)))); ))));
}, },
} }

View file

@ -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") {

View file

@ -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>>,

View file

@ -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 {

View file

@ -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(),
} }
} }

View file

@ -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 {

View file

@ -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()

View file

@ -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();

View file

@ -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());
}
} }
} }

View file

@ -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);
}

View file

@ -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")
); );
} }

View file

@ -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"
);
}
} }

View file

@ -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}"
);
}
} }

View file

@ -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 {

View file

@ -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(

View file

@ -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))
} }

View file

@ -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(),
))
} }

View file

@ -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,

View file

@ -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,
})) }))
} }

View file

@ -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,

View file

@ -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(),
))
} }

View file

@ -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))

View file

@ -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,
})) }))
} }

View file

@ -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 }))
}, },

View file

@ -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

View file

@ -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;
}
} }

View file

@ -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}" }
}
}
}
} }
} }

View file

@ -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

View file

@ -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!(

View file

@ -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);
} }

View file

@ -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 });

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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