Compare commits
4 commits
a80359343b
...
b6e579408f
| Author | SHA1 | Date | |
|---|---|---|---|
|
b6e579408f |
|||
|
9176b764b0 |
|||
|
6d42ac0f48 |
|||
|
7b841cbd9a |
7 changed files with 85 additions and 52 deletions
|
|
@ -27,11 +27,10 @@ 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());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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}" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -41,15 +41,13 @@ pub struct PluginPage {
|
|||
#[derive(Debug, Clone)]
|
||||
pub struct PluginRegistry {
|
||||
/// API client for fetching pages from server
|
||||
client: ApiClient,
|
||||
client: ApiClient,
|
||||
/// Cached pages: (`plugin_id`, `page_id`) -> `PluginPage`
|
||||
pages: HashMap<(String, String), PluginPage>,
|
||||
pages: HashMap<(String, String), PluginPage>,
|
||||
/// Cached widgets: (`plugin_id`, `widget_id`) -> `UiWidget`
|
||||
widgets: Vec<(String, UiWidget)>,
|
||||
widgets: Vec<(String, UiWidget)>,
|
||||
/// Merged CSS custom property overrides from all enabled plugins
|
||||
theme_vars: HashMap<String, String>,
|
||||
/// Last refresh timestamp
|
||||
last_refresh: Option<chrono::DateTime<chrono::Utc>>,
|
||||
theme_vars: HashMap<String, String>,
|
||||
}
|
||||
|
||||
impl PluginRegistry {
|
||||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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]]
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue