Compare commits

...

4 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
7 changed files with 85 additions and 52 deletions

View file

@ -27,12 +27,11 @@ impl TempFileGuard {
impl Drop for TempFileGuard {
fn drop(&mut self) {
if self.0.exists() {
if let Err(e) = 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());
}
}
}
}
/// Generate a thumbnail for a media file and return the path to the thumbnail.

View file

@ -11,19 +11,20 @@ use uuid::Uuid;
/// 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
.map_or(true, |b| root.components().count() > b.components().count());
.is_none_or(|b| root.components().count() > b.components().count());
if is_longer {
best = Some(root);
}
}
}
if let Some(root) = best {
if let Ok(rel) = full_path.strip_prefix(root) {
if let Some(root) = best
&& let Ok(rel) = full_path.strip_prefix(root) {
// Normalise to forward slashes on all platforms.
return rel
.components()
@ -31,7 +32,6 @@ pub fn relativize_path(full_path: &Path, roots: &[PathBuf]) -> String {
.collect::<Vec<_>>()
.join("/");
}
}
full_path.to_string_lossy().into_owned()
}
@ -269,6 +269,7 @@ impl MediaResponse {
/// matching root prefix from the path before serialization. Pass the
/// configured root directories so that clients receive a relative path
/// (e.g. `"Music/song.mp3"`) rather than a full server filesystem path.
#[must_use]
pub fn new(item: pinakes_core::model::MediaItem, roots: &[PathBuf]) -> Self {
Self {
id: item.id.0.to_string(),

View file

@ -369,11 +369,13 @@ pub fn App() -> Element {
spawn(async move {
let js: String = vars
.iter()
.map(|(k, v)| {
format!(
"document.documentElement.style.setProperty('{}','{}');",
k, v
)
.filter_map(|(k, v)| {
let k_js = serde_json::to_string(k).ok()?;
let v_js = serde_json::to_string(v).ok()?;
Some(format!(
"document.documentElement.style.setProperty({k_js},\
{v_js});"
))
})
.collect();
let _ = document::eval(&js).await;
@ -849,17 +851,6 @@ pub fn App() -> Element {
}
}
}
{
let sync_time_opt = plugin_registry
.read()
.last_refresh()
.map(|ts| ts.format("%H:%M").to_string());
rsx! {
if let Some(sync_time) = sync_time_opt {
div { class: "nav-sync-time", "Synced {sync_time}" }
}
}
}
}
}

View file

@ -48,8 +48,6 @@ pub struct PluginRegistry {
widgets: Vec<(String, UiWidget)>,
/// Merged CSS custom property overrides from all enabled plugins
theme_vars: HashMap<String, String>,
/// Last refresh timestamp
last_refresh: Option<chrono::DateTime<chrono::Utc>>,
}
impl PluginRegistry {
@ -60,7 +58,6 @@ impl PluginRegistry {
pages: HashMap::new(),
widgets: Vec::new(),
theme_vars: HashMap::new(),
last_refresh: None,
}
}
@ -206,14 +203,8 @@ impl PluginRegistry {
self.pages = tmp.pages;
self.widgets = tmp.widgets;
self.theme_vars = tmp.theme_vars;
self.last_refresh = Some(chrono::Utc::now());
Ok(())
}
/// Get last refresh time
pub const fn last_refresh(&self) -> Option<chrono::DateTime<chrono::Utc>> {
self.last_refresh
}
}
impl Default for PluginRegistry {
@ -346,7 +337,6 @@ mod tests {
let registry = PluginRegistry::default();
assert!(registry.is_empty());
assert_eq!(registry.all_pages().len(), 0);
assert!(registry.last_refresh().is_none());
}
#[test]

View file

@ -708,7 +708,8 @@ pub fn render_element(
} else if let Some(arr) = items.and_then(|v| v.as_array()) {
for item in arr {
{
let url_opt = media_grid_image_url(item);
let base = ctx.client.peek().base_url().to_string();
let url_opt = media_grid_image_url(item, &base);
let label = media_grid_label(item);
rsx! {
div { class: "media-grid-item",
@ -795,7 +796,16 @@ pub fn render_element(
.map(|obj| {
obj
.iter()
.map(|(k, v)| (k.clone(), value_to_display_string(v)))
.filter_map(|(k, v)| {
match v {
// Skip nested objects and arrays; they are not meaningful as
// single-line description terms.
serde_json::Value::Object(_) | serde_json::Value::Array(_) => {
None
},
_ => Some((format_key_name(k), value_to_display_string(v))),
}
})
.collect()
})
.unwrap_or_default();
@ -1044,7 +1054,7 @@ pub fn render_element(
max,
show_percentage,
} => {
let eval_ctx = data.as_json();
let eval_ctx = build_ctx(data, &ctx.local_state.read());
let pct = evaluate_expression_as_f64(value, &eval_ctx);
let fraction = if *max > 0.0 {
(pct / max).clamp(0.0, 1.0)
@ -1116,7 +1126,7 @@ pub fn render_element(
then,
else_element,
} => {
let eval_ctx = data.as_json();
let eval_ctx = build_ctx(data, &ctx.local_state.read());
if evaluate_expression_as_bool(condition, &eval_ctx) {
render_element(then, data, actions, ctx)
} else if let Some(else_el) = else_element {
@ -1244,7 +1254,10 @@ fn render_chart_data(
// MediaGrid helpers
/// Probe a JSON object for common image URL fields.
fn media_grid_image_url(item: &serde_json::Value) -> Option<String> {
fn media_grid_image_url(
item: &serde_json::Value,
base_url: &str,
) -> Option<String> {
for key in &[
"thumbnail_url",
"thumbnail",
@ -1260,12 +1273,22 @@ fn media_grid_image_url(item: &serde_json::Value) -> Option<String> {
}
}
}
// Pinakes media items: construct absolute thumbnail URL from id when
// has_thumbnail is true. Relative paths don't work for <img src> in the
// desktop WebView context.
if item.get("has_thumbnail").and_then(|v| v.as_bool()) == Some(true) {
if let Some(id) = item.get("id").and_then(|v| v.as_str()) {
if !id.is_empty() {
return Some(format!("{base_url}/api/v1/media/{id}/thumbnail"));
}
}
}
None
}
/// Probe a JSON object for a human-readable label.
fn media_grid_label(item: &serde_json::Value) -> String {
for key in &["title", "name", "label", "caption"] {
for key in &["title", "name", "label", "caption", "file_name"] {
if let Some(s) = item.get(*key).and_then(|v| v.as_str()) {
if !s.is_empty() {
return s.to_string();
@ -1601,12 +1624,41 @@ fn safe_col_width_css(w: &str) -> Option<String> {
None
}
/// Convert a `snake_case` JSON key to a human-readable title.
/// `avg_file_size_bytes` -> `Avg File Size Bytes`
fn format_key_name(key: &str) -> String {
key
.split('_')
.map(|word| {
let mut chars = word.chars();
match chars.next() {
None => String::new(),
Some(first) => {
first.to_uppercase().collect::<String>() + chars.as_str()
},
}
})
.collect::<Vec<_>>()
.join(" ")
}
#[cfg(test)]
mod tests {
use pinakes_plugin_api::Expression;
use super::*;
#[test]
fn test_format_key_name() {
assert_eq!(
format_key_name("avg_file_size_bytes"),
"Avg File Size Bytes"
);
assert_eq!(format_key_name("total_media"), "Total Media");
assert_eq!(format_key_name("id"), "Id");
assert_eq!(format_key_name(""), "");
}
#[test]
fn test_extract_cell_string() {
let row = serde_json::json!({ "name": "Alice", "count": 5 });

View file

@ -64,6 +64,10 @@
"filterable": true,
"page_size": 10,
"columns": [
{
"key": "file_name",
"header": "Filename"
},
{
"key": "title",
"header": "Title"
@ -120,13 +124,9 @@
"path": "/api/v1/media"
},
"type-breakdown": {
"type": "static",
"value": [
{ "type": "Audio", "count": 0 },
{ "type": "Video", "count": 0 },
{ "type": "Image", "count": 0 },
{ "type": "Document", "count": 0 }
]
"type": "transform",
"source": "stats",
"expression": "stats.media_by_type"
}
}
}

View file

@ -9,7 +9,7 @@ license = "EUPL-1.2"
kind = ["ui_page"]
[plugin.binary]
wasm = "media_stats_ui.wasm"
wasm = "target/wasm32-unknown-unknown/release/media_stats_ui.wasm"
[capabilities]
network = false
@ -19,7 +19,7 @@ read = []
write = []
[ui]
required_endpoints = ["/api/v1/statistics", "/api/v1/media"]
required_endpoints = ["/api/v1/statistics", "/api/v1/media", "/api/v1/tags"]
# UI pages
[[ui.pages]]