From c86d9399acfc4be3a1ae02479905aa3451de5313 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Mon, 9 Mar 2026 18:16:20 +0300 Subject: [PATCH 01/46] pinakes-plugin-api: initial UI schema types; manifest extension for GUI plugins Signed-off-by: NotAShelf Change-Id: I508f94798a6eaa800672bd95fa8127d86a6a6964 --- crates/pinakes-core/src/plugin/registry.rs | 2 +- crates/pinakes-core/src/plugin/runtime.rs | 9 +- crates/pinakes-plugin-api/src/lib.rs | 2 + crates/pinakes-plugin-api/src/manifest.rs | 305 ++++ crates/pinakes-plugin-api/src/ui_schema.rs | 1742 ++++++++++++++++++++ 5 files changed, 2053 insertions(+), 7 deletions(-) create mode 100644 crates/pinakes-plugin-api/src/ui_schema.rs diff --git a/crates/pinakes-core/src/plugin/registry.rs b/crates/pinakes-core/src/plugin/registry.rs index 76ef7ad..afa09b1 100644 --- a/crates/pinakes-core/src/plugin/registry.rs +++ b/crates/pinakes-core/src/plugin/registry.rs @@ -158,7 +158,7 @@ impl Default for PluginRegistry { mod tests { use std::collections::HashMap; - use pinakes_plugin_api::Capabilities; + use pinakes_plugin_api::{Capabilities, manifest::ManifestCapabilities}; use super::*; diff --git a/crates/pinakes-core/src/plugin/runtime.rs b/crates/pinakes-core/src/plugin/runtime.rs index 8ad22e3..cb93d73 100644 --- a/crates/pinakes-core/src/plugin/runtime.rs +++ b/crates/pinakes-core/src/plugin/runtime.rs @@ -493,12 +493,9 @@ impl HostFunctions { if let Some(ref allowed) = caller.data().context.capabilities.network.allowed_domains { - let parsed = match url::Url::parse(&url_str) { - Ok(u) => u, - _ => { - tracing::warn!(url = %url_str, "plugin provided invalid URL"); - return -1; - }, + let parsed = if let Ok(u) = url::Url::parse(&url_str) { u } else { + tracing::warn!(url = %url_str, "plugin provided invalid URL"); + return -1; }; let domain = parsed.host_str().unwrap_or(""); diff --git a/crates/pinakes-plugin-api/src/lib.rs b/crates/pinakes-plugin-api/src/lib.rs index 2fb96c6..34e806f 100644 --- a/crates/pinakes-plugin-api/src/lib.rs +++ b/crates/pinakes-plugin-api/src/lib.rs @@ -15,10 +15,12 @@ use thiserror::Error; pub mod manifest; pub mod types; +pub mod ui_schema; pub mod wasm; pub use manifest::PluginManifest; pub use types::*; +pub use ui_schema::*; pub use wasm::host_functions; /// Plugin API version - plugins must match this version diff --git a/crates/pinakes-plugin-api/src/manifest.rs b/crates/pinakes-plugin-api/src/manifest.rs index a334fba..c611546 100644 --- a/crates/pinakes-plugin-api/src/manifest.rs +++ b/crates/pinakes-plugin-api/src/manifest.rs @@ -10,6 +10,7 @@ use crate::{ EnvironmentCapability, FilesystemCapability, NetworkCapability, + UiPage, }; /// Plugin manifest file format (TOML) @@ -22,6 +23,73 @@ pub struct PluginManifest { #[serde(default)] pub config: HashMap, + + /// UI pages provided by this plugin + #[serde(default)] + pub ui: UiSection, +} + +/// UI section of the plugin manifest +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct UiSection { + /// UI pages defined by this plugin + #[serde(default)] + pub pages: Vec, +} + +/// Entry for a UI page in the manifest - can be inline or file reference +#[derive(Debug, Clone)] +pub enum UiPageEntry { + /// Inline UI page definition (boxed to reduce enum size) + Inline(Box), + /// Reference to a JSON file containing the page definition + File { file: String }, +} + +impl<'de> Deserialize<'de> for UiPageEntry { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + use serde::de::Error; + + // First try to deserialize as a file reference (has "file" key) + // We use toml::Value since the manifest is TOML + let value = toml::Value::deserialize(deserializer)?; + + if let Some(file) = value.get("file").and_then(|v| v.as_str()) { + if file.is_empty() { + return Err(D::Error::custom("file path cannot be empty")); + } + return Ok(Self::File { + file: file.to_string(), + }); + } + + // Otherwise try to deserialize as inline UiPage + // Convert toml::Value back to a deserializer for UiPage + let page: UiPage = UiPage::deserialize(value) + .map_err(|e| D::Error::custom(format!("invalid inline UI page: {e}")))?; + + Ok(Self::Inline(Box::new(page))) + } +} + +impl Serialize for UiPageEntry { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + match self { + Self::Inline(page) => page.serialize(serializer), + Self::File { file } => { + use serde::ser::SerializeStruct; + let mut state = serializer.serialize_struct("UiPageEntry", 1)?; + state.serialize_field("file", file)?; + state.end() + }, + } + } } const fn default_priority() -> u16 { @@ -168,6 +236,7 @@ impl PluginManifest { "search_backend", "event_handler", "theme_provider", + "ui_page", ]; for kind in &self.plugin.kind { @@ -193,9 +262,77 @@ impl PluginManifest { )); } + // Validate UI pages + for (idx, page_entry) in self.ui.pages.iter().enumerate() { + match page_entry { + UiPageEntry::Inline(page) => { + if let Err(e) = page.validate() { + return Err(ManifestError::ValidationError(format!( + "UI page {} validation failed: {e}", + idx + 1 + ))); + } + }, + UiPageEntry::File { file } => { + if file.is_empty() { + return Err(ManifestError::ValidationError(format!( + "UI page {} file path cannot be empty", + idx + 1 + ))); + } + }, + } + } + Ok(()) } + /// Load and resolve all UI page definitions + /// + /// For inline pages, returns them directly. For file references, loads + /// and parses the JSON file. + /// + /// # Arguments + /// + /// * `base_path` - Base directory for resolving relative file paths + /// + /// # Errors + /// + /// Returns [`ManifestError::IoError`] if a file cannot be read, or + /// [`ManifestError::ValidationError`] if JSON parsing fails. + pub fn load_ui_pages( + &self, + base_path: &Path, + ) -> Result, ManifestError> { + let mut pages = Vec::with_capacity(self.ui.pages.len()); + + for entry in &self.ui.pages { + let page = match entry { + UiPageEntry::Inline(page) => (**page).clone(), + UiPageEntry::File { file } => { + let file_path = base_path.join(file); + let content = std::fs::read_to_string(&file_path)?; + let page: UiPage = serde_json::from_str(&content).map_err(|e| { + ManifestError::ValidationError(format!( + "Failed to parse UI page from file '{}': {e}", + file_path.display() + )) + })?; + if let Err(e) = page.validate() { + return Err(ManifestError::ValidationError(format!( + "UI page validation failed for file '{}': {e}", + file_path.display() + ))); + } + page + }, + }; + pages.push(page); + } + + Ok(pages) + } + /// Convert manifest capabilities to API capabilities #[must_use] pub fn to_capabilities(&self) -> Capabilities { @@ -353,4 +490,172 @@ wasm = "plugin.wasm" assert!(PluginManifest::parse_str(toml).is_err()); } + + #[test] + fn test_ui_page_inline() { + let toml = r#" +[plugin] +name = "ui-demo" +version = "1.0.0" +api_version = "1.0" +kind = ["ui_page"] + +[plugin.binary] +wasm = "plugin.wasm" + +[[ui.pages]] +id = "demo" +title = "Demo Page" +route = "/plugins/demo" +icon = "star" + +[ui.pages.layout] +type = "container" +children = [] +gap = 16 +"#; + + let manifest = PluginManifest::parse_str(toml).unwrap(); + assert_eq!(manifest.ui.pages.len(), 1); + + match &manifest.ui.pages[0] { + UiPageEntry::Inline(page) => { + assert_eq!(page.id, "demo"); + assert_eq!(page.title, "Demo Page"); + assert_eq!(page.route, "/plugins/demo"); + assert_eq!(page.icon, Some("star".to_string())); + }, + _ => panic!("Expected inline page"), + } + } + + #[test] + fn test_ui_page_file_reference() { + let toml = r#" +[plugin] +name = "ui-demo" +version = "1.0.0" +api_version = "1.0" +kind = ["ui_page"] + +[plugin.binary] +wasm = "plugin.wasm" + +[[ui.pages]] +file = "pages/demo.json" +"#; + + let manifest = PluginManifest::parse_str(toml).unwrap(); + assert_eq!(manifest.ui.pages.len(), 1); + + match &manifest.ui.pages[0] { + UiPageEntry::File { file } => { + assert_eq!(file, "pages/demo.json"); + }, + _ => panic!("Expected file reference"), + } + } + + #[test] + fn test_ui_page_invalid_kind() { + // ui_page must be in valid_kinds list + let toml = r#" +[plugin] +name = "test" +version = "1.0.0" +api_version = "1.0" +kind = ["ui_page"] + +[plugin.binary] +wasm = "plugin.wasm" +"#; + + // Should succeed now that ui_page is in valid_kinds + let manifest = PluginManifest::parse_str(toml); + assert!(manifest.is_ok()); + } + + #[test] + fn test_ui_page_validation_failure() { + let toml = r#" +[plugin] +name = "ui-demo" +version = "1.0.0" +api_version = "1.0" +kind = ["ui_page"] + +[plugin.binary] +wasm = "plugin.wasm" + +[[ui.pages]] +id = "" +title = "Demo" +route = "/plugins/demo" + +[ui.pages.layout] +type = "container" +children = [] +gap = 16 +"#; + + // Empty ID should fail validation + assert!(PluginManifest::parse_str(toml).is_err()); + } + + #[test] + fn test_ui_page_empty_file() { + let toml = r#" +[plugin] +name = "ui-demo" +version = "1.0.0" +api_version = "1.0" +kind = ["ui_page"] + +[plugin.binary] +wasm = "plugin.wasm" + +[[ui.pages]] +file = "" +"#; + + // Empty file path should fail validation + assert!(PluginManifest::parse_str(toml).is_err()); + } + + #[test] + fn test_ui_page_multiple_pages() { + let toml = r#" +[plugin] +name = "ui-demo" +version = "1.0.0" +api_version = "1.0" +kind = ["ui_page"] + +[plugin.binary] +wasm = "plugin.wasm" + +[[ui.pages]] +id = "page1" +title = "Page 1" +route = "/plugins/page1" + +[ui.pages.layout] +type = "container" +children = [] +gap = 16 + +[[ui.pages]] +id = "page2" +title = "Page 2" +route = "/plugins/page2" + +[ui.pages.layout] +type = "container" +children = [] +gap = 16 +"#; + + let manifest = PluginManifest::parse_str(toml).unwrap(); + assert_eq!(manifest.ui.pages.len(), 2); + } } diff --git a/crates/pinakes-plugin-api/src/ui_schema.rs b/crates/pinakes-plugin-api/src/ui_schema.rs new file mode 100644 index 0000000..f6c1e8d --- /dev/null +++ b/crates/pinakes-plugin-api/src/ui_schema.rs @@ -0,0 +1,1742 @@ +//! UI Schema Types for GUI Plugin System +//! +//! This module defines the declarative UI schema that plugins use to define +//! their user interfaces. The schema is JSON-based and rendered by the host UI +//! into Dioxus components. +//! +//! ## Security Model +//! +//! - No arbitrary code execution in UI - only declarative schema +//! - All text content is sanitized through ammonia before rendering +//! - URLs are validated against whitelist +//! - Component tree depth limited to prevent abuse +//! - Action execution goes through typed API layer +//! +//! ## Example Schema +//! +//! ```json +//! { +//! "id": "music-analyzer", +//! "title": "Music Analysis", +//! "route": "/plugins/music-analyzer", +//! "icon": "music", +//! "layout": { +//! "type": "split", +//! "sidebar": { +//! "type": "list", +//! "data": "playlists", +//! "item_template": { "type": "text", "content": "{{title}}" } +//! }, +//! "main": { +//! "type": "data_table", +//! "columns": [ +//! { "key": "title", "header": "Title" }, +//! { "key": "duration", "header": "Duration" } +//! ], +//! "data": "tracks" +//! } +//! }, +//! "data_sources": { +//! "playlists": { "type": "endpoint", "path": "/api/v1/collections" } +//! } +//! } +//! ``` + +use std::collections::HashMap; + +use serde::{Deserialize, Serialize}; +use thiserror::Error; + +/// Maximum allowed depth for nested UI elements +pub const MAX_ELEMENT_DEPTH: usize = 10; + +/// Maximum allowed width percentage +pub const MAX_WIDTH_PERCENT: u32 = 100; + +/// Maximum allowed height in pixels +pub const MAX_HEIGHT_PX: u32 = 10000; + +/// Schema validation and rendering errors +#[derive(Debug, Error, Clone, PartialEq, Eq)] +pub enum SchemaError { + #[error("validation failed: {0}")] + ValidationError(String), + + #[error("invalid data source: {0}")] + InvalidDataSource(String), + + #[error("invalid expression: {0}")] + InvalidExpression(String), + + #[error("depth limit exceeded: max is {MAX_ELEMENT_DEPTH}")] + DepthLimitExceeded, + + #[error("unknown field: {0}")] + UnknownField(String), + + #[error("required field missing: {0}")] + MissingField(String), +} + +/// Result type for schema operations +pub type SchemaResult = Result; + +/// Root page definition for a plugin UI page +/// +/// This is the top-level structure that defines a complete plugin page. +/// Each page has a unique route, title, and layout. +/// +/// # Example +/// +/// ```rust +/// use pinakes_plugin_api::ui_schema::{DataSource, UiElement, UiPage}; +/// +/// let page = UiPage { +/// id: "demo".to_string(), +/// title: "Demo Page".to_string(), +/// route: "/plugins/demo".to_string(), +/// icon: Some("star".to_string()), +/// root_element: UiElement::Container { +/// children: vec![], +/// gap: 16, +/// padding: None, +/// }, +/// data_sources: Default::default(), +/// }; +/// ``` +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct UiPage { + /// Unique identifier for this page (must be unique across all plugins) + pub id: String, + + /// Display title for the page + pub title: String, + + /// URL route for this page (e.g., "/plugins/my-plugin/page1") + /// Must be unique across all pages + pub route: String, + + /// Optional icon identifier (matches icon names in dioxus-free-icons) + #[serde(skip_serializing_if = "Option::is_none")] + pub icon: Option, + + /// Root layout element defining the page structure + #[serde(rename = "layout")] + pub root_element: UiElement, + + /// Named data sources available to this page + #[serde(default, skip_serializing_if = "HashMap::is_empty")] + pub data_sources: HashMap, +} + +impl UiPage { + /// Validates the complete page definition + /// + /// Checks: + /// - ID format (alphanumeric with dashes/underscores) + /// - Route format (must start with "/") + /// - Element tree depth + /// - Data source validity + /// - All data references in elements exist in `data_sources` + /// + /// # Errors + /// + /// Returns `SchemaError::ValidationError` if validation fails + pub fn validate(&self) -> SchemaResult<()> { + validate_id(&self.id)?; + + if !self.route.starts_with('/') { + return Err(SchemaError::ValidationError( + "Route must start with '/'".to_string(), + )); + } + + let depth = self.root_element.depth(); + if depth > MAX_ELEMENT_DEPTH { + return Err(SchemaError::DepthLimitExceeded); + } + + self.root_element.validate(self)?; + + for (name, source) in &self.data_sources { + validate_id(name)?; + source.validate()?; + + // Check that Transform sources reference existing data sources + if let DataSource::Transform { source_name, .. } = source + && !self.data_sources.contains_key(source_name) + { + return Err(SchemaError::ValidationError(format!( + "DataSource '{name}' references unknown source: {source_name}" + ))); + } + } + + // Detect cycles in Transform dependencies using DFS + self.validate_no_cycles()?; + + Ok(()) + } + + /// Validates that there are no cycles in Transform data source dependencies + fn validate_no_cycles(&self) -> SchemaResult<()> { + let mut visited = std::collections::HashSet::new(); + let mut stack = std::collections::HashSet::new(); + + for name in self.data_sources.keys() { + Self::dfs_check_cycles(self, name, &mut visited, &mut stack)?; + } + + Ok(()) + } + + /// Helper function for cycle detection using DFS + fn dfs_check_cycles( + &self, + name: &str, + visited: &mut std::collections::HashSet, + stack: &mut std::collections::HashSet, + ) -> SchemaResult<()> { + if stack.contains(name) { + return Err(SchemaError::ValidationError(format!( + "Cycle detected in data source dependencies: {name}" + ))); + } + if visited.contains(name) { + return Ok(()); + } + + stack.insert(name.to_string()); + + if let Some(DataSource::Transform { source_name, .. }) = + self.data_sources.get(name) + { + Self::dfs_check_cycles(self, source_name, visited, stack)?; + } + + stack.remove(name); + visited.insert(name.to_string()); + Ok(()) + } + + /// Returns all data source names referenced by the layout + #[must_use] + pub fn referenced_data_sources(&self) -> Vec { + let mut refs = Vec::new(); + self.root_element.collect_data_refs(&mut refs); + refs.sort_unstable(); + refs.dedup(); + refs + } +} + +/// Core UI element enum - the building block of all plugin UIs +/// +/// Elements are categorized into groups: +/// - **Layout containers**: Structure the page (Container, Grid, Flex, Split, +/// Tabs) +/// - **Typography**: Text display (Heading, Text, Code) +/// - **Data display**: Show data (`DataTable`, Card, `MediaGrid`, List, +/// `DescriptionList`) +/// - **Interactive**: User input (Button, Form, Link, Progress, Badge) +/// - **Visualization**: Charts and graphs (Chart) +/// - **Conditional**: Dynamic rendering (Conditional, Loop) +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum UiElement { + /// Generic container with configurable layout + /// + /// Use for grouping elements with gap and padding control. + Container { + /// Child elements + children: Vec, + + /// Gap between children in pixels + #[serde(default = "default_gap")] + gap: u32, + + /// Padding inside container (top, right, bottom, left) + #[serde(skip_serializing_if = "Option::is_none")] + padding: Option<[u32; 4]>, + }, + + /// Grid layout with fixed columns + /// + /// Arranges children in a grid with configurable column count. + Grid { + /// Child elements + children: Vec, + + /// Number of columns (1-12) + #[serde(default = "default_grid_columns")] + columns: u8, + + /// Gap between grid items in pixels + #[serde(default = "default_gap")] + gap: u32, + }, + + /// Horizontal or vertical flex layout + /// + /// Use for linear arrangements with alignment control. + Flex { + /// Child elements + children: Vec, + + /// Direction: "row" (horizontal) or "column" (vertical) + #[serde(default)] + direction: FlexDirection, + + /// Main axis alignment + #[serde(default)] + justify: JustifyContent, + + /// Cross axis alignment + #[serde(default)] + align: AlignItems, + + /// Gap between items in pixels + #[serde(default = "default_gap")] + gap: u32, + + /// Whether items should wrap to next line + #[serde(default)] + wrap: bool, + }, + + /// Split view with sidebar and main content + /// + /// Classic sidebar + content layout with resizable divider. + Split { + /// Sidebar content + sidebar: Box, + + /// Sidebar width in pixels + #[serde(default = "default_sidebar_width")] + sidebar_width: u32, + + /// Main content area + main: Box, + }, + + /// Tabbed interface + /// + /// Displays content in tabbed panels. + Tabs { + /// Tab definitions + tabs: Vec, + + /// Initially active tab index + #[serde(default)] + default_tab: usize, + }, + + /// Section heading (h1-h6) + /// + /// Semantic heading for page structure. + Heading { + /// Heading level 1-6 + level: u8, + + /// Heading text (can be static or expression) + #[serde(default)] + content: TextContent, + + /// Optional ID for linking + #[serde(skip_serializing_if = "Option::is_none")] + id: Option, + }, + + /// Paragraph text + /// + /// Multi-line text with optional formatting. + Text { + /// Text content (can be static or expression) + #[serde(default)] + content: TextContent, + + /// Text style variant + #[serde(default)] + variant: TextVariant, + + /// Whether to allow HTML (sanitized) in content + #[serde(default)] + allow_html: bool, + }, + + /// Monospaced code block + /// + /// Displays code with syntax highlighting support. + Code { + /// Code content + content: String, + + /// Language for syntax highlighting (e.g., "rust", "json") + #[serde(skip_serializing_if = "Option::is_none")] + language: Option, + + /// Whether to show line numbers + #[serde(default)] + show_line_numbers: bool, + }, + + /// Data table with sortable columns + /// + /// Displays tabular data with sorting, filtering, and pagination. + DataTable { + /// Column definitions + columns: Vec, + + /// Data source reference (key into `page.data_sources`) + data: String, + + /// Whether to enable sorting + #[serde(default = "default_true")] + sortable: bool, + + /// Whether to enable filtering + #[serde(default)] + filterable: bool, + + /// Items per page (0 = no pagination) + #[serde(default)] + page_size: usize, + + /// Row actions (displayed as buttons per row) + #[serde(default, skip_serializing_if = "Vec::is_empty")] + row_actions: Vec, + }, + + /// Card component for content grouping + /// + /// Displays content in a card with optional header and footer. + Card { + /// Card title + #[serde(skip_serializing_if = "Option::is_none")] + title: Option, + + /// Main content elements + content: Vec, + + /// Footer elements + #[serde(default, skip_serializing_if = "Vec::is_empty")] + footer: Vec, + }, + + /// Media grid for images/videos + /// + /// Displays media items in a responsive grid. + MediaGrid { + /// Data source reference containing media items + data: String, + + /// Number of columns (1-12) + #[serde(default = "default_grid_columns")] + columns: u8, + + /// Gap between items in pixels + #[serde(default = "default_gap")] + gap: u32, + }, + + /// List component with item template + /// + /// Renders a list by applying a template to each item. + List { + /// Data source reference + data: String, + + /// Template for each list item + item_template: Box, + + /// Whether to show dividers between items + #[serde(default = "default_true")] + dividers: bool, + }, + + /// Description list (key-value pairs) + /// + /// Displays key-value pairs, ideal for metadata. + DescriptionList { + /// Data source reference containing key-value pairs + data: String, + + /// Whether to display horizontally + #[serde(default)] + horizontal: bool, + }, + + /// Button component + /// + /// Triggers actions when clicked. + Button { + /// Button label + label: String, + + /// Button variant/style + #[serde(default)] + variant: ButtonVariant, + + /// Action to execute on click + action: ActionRef, + + /// Whether button is disabled + #[serde(default)] + disabled: bool, + }, + + /// Form with fields + /// + /// Input form with validation and submission. + Form { + /// Form field definitions + fields: Vec, + + /// Submit button label + #[serde(default = "default_submit_label")] + submit_label: String, + + /// Action to execute on submit + submit_action: ActionRef, + + /// Cancel button label (None = no cancel button) + #[serde(skip_serializing_if = "Option::is_none")] + cancel_label: Option, + }, + + /// Link component + /// + /// Navigates to internal or external URLs. + Link { + /// Link text + text: String, + + /// Target URL + href: String, + + /// Whether to open in new tab + #[serde(default)] + external: bool, + }, + + /// Progress bar or indicator + /// + /// Shows progress of an operation. + Progress { + /// Current value expression + value: Expression, + + /// Maximum value + #[serde(default = "default_progress_max")] + max: f64, + + /// Whether to show percentage text + #[serde(default = "default_true")] + show_percentage: bool, + }, + + /// Badge/chip component + /// + /// Small label for status, tags, etc. + Badge { + /// Badge text + text: String, + + /// Badge color variant + #[serde(default)] + variant: BadgeVariant, + }, + + /// Chart visualization + /// + /// Displays data as charts (bar, line, pie, etc.). + Chart { + /// Chart type + chart_type: ChartType, + + /// Data source reference + data: String, + + /// Chart title + #[serde(skip_serializing_if = "Option::is_none")] + title: Option, + + /// X-axis label + #[serde(skip_serializing_if = "Option::is_none")] + x_axis_label: Option, + + /// Y-axis label + #[serde(skip_serializing_if = "Option::is_none")] + y_axis_label: Option, + + /// Chart height in pixels + #[serde(default = "default_chart_height")] + height: u32, + }, + + /// Conditional rendering + /// + /// Shows different content based on a condition. + Conditional { + /// Condition expression (evaluates to boolean) + condition: Expression, + + /// Element to render when condition is true + then: Box, + + /// Optional element to render when condition is false + #[serde(rename = "else", skip_serializing_if = "Option::is_none")] + else_element: Option>, + }, + + /// Loop/repeat over data + /// + /// Renders a template for each item in a collection. + Loop { + /// Data source reference (must be an array) + data: String, + + /// Template for each iteration + template: Box, + + /// Content to show when data is empty + #[serde(skip_serializing_if = "Option::is_none")] + empty: Option>, + }, +} + +impl UiElement { + /// Calculates the depth of this element tree + pub fn depth(&self) -> usize { + 1 + match self { + Self::Container { children, .. } + | Self::Grid { children, .. } + | Self::Flex { children, .. } => { + children.iter().map(Self::depth).max().unwrap_or(0) + }, + Self::Split { sidebar, main, .. } => sidebar.depth().max(main.depth()), + Self::Tabs { tabs, .. } => { + tabs.iter().map(|t| t.content.depth()).max().unwrap_or(0) + }, + Self::Card { + content, footer, .. + } => { + content + .iter() + .chain(footer.iter()) + .map(Self::depth) + .max() + .unwrap_or(0) + }, + Self::List { item_template, .. } => item_template.depth(), + Self::Conditional { + then, else_element, .. + } => { + let else_depth = else_element.as_ref().map_or(0, |e| e.depth()); + then.depth().max(else_depth) + }, + Self::Loop { + template, empty, .. + } => { + let empty_depth = empty.as_ref().map_or(0, |e| e.depth()); + template.depth().max(empty_depth) + }, + _ => 0, + } + } + + /// Validates this element and its children + /// + /// # Errors + /// + /// Returns `SchemaError` if validation fails + pub fn validate(&self, page: &UiPage) -> SchemaResult<()> { + match self { + Self::Container { children, .. } | Self::Flex { children, .. } => { + for child in children { + child.validate(page)?; + } + }, + Self::Grid { + children, columns, .. + } => { + if *columns == 0 || *columns > 12 { + return Err(SchemaError::ValidationError(format!( + "Grid columns must be between 1 and 12, got {columns}" + ))); + } + for child in children { + child.validate(page)?; + } + }, + Self::Split { sidebar, main, .. } => { + sidebar.validate(page)?; + main.validate(page)?; + }, + Self::Tabs { + tabs, default_tab, .. + } => { + if tabs.is_empty() { + return Err(SchemaError::ValidationError( + "Tabs array must contain at least one tab".to_string(), + )); + } + if *default_tab >= tabs.len() { + return Err(SchemaError::ValidationError(format!( + "Default tab index {default_tab} out of range (max: {})", + tabs.len().saturating_sub(1) + ))); + } + for tab in tabs { + tab.content.validate(page)?; + } + }, + Self::Heading { level, .. } if *level == 0 || *level > 6 => { + return Err(SchemaError::ValidationError(format!( + "Heading level must be between 1 and 6, got {level}" + ))); + }, + Self::DataTable { columns, data, .. } => { + if columns.is_empty() { + return Err(SchemaError::ValidationError( + "DataTable must have at least one column".to_string(), + )); + } + if !page.data_sources.contains_key(data) { + return Err(SchemaError::ValidationError(format!( + "DataTable references unknown data source: {data}" + ))); + } + }, + Self::Card { + content, footer, .. + } => { + for child in content { + child.validate(page)?; + } + for child in footer { + child.validate(page)?; + } + }, + Self::MediaGrid { columns, data, .. } => { + if *columns == 0 || *columns > 12 { + return Err(SchemaError::ValidationError(format!( + "MediaGrid columns must be between 1 and 12, got {columns}" + ))); + } + if !page.data_sources.contains_key(data) { + return Err(SchemaError::ValidationError(format!( + "MediaGrid references unknown data source: {data}" + ))); + } + }, + 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) => { + return Err(SchemaError::ValidationError(format!( + "Chart references unknown data source: {data}" + ))); + }, + Self::Conditional { + then, else_element, .. + } => { + then.validate(page)?; + if let Some(else_el) = else_element { + else_el.validate(page)?; + } + }, + Self::List { + data, + item_template, + .. + } => { + if !page.data_sources.contains_key(data) { + return Err(SchemaError::ValidationError(format!( + "List references unknown data source: {data}" + ))); + } + item_template.validate(page)?; + }, + Self::DescriptionList { data, .. } + if !page.data_sources.contains_key(data) => + { + return Err(SchemaError::ValidationError(format!( + "DescriptionList references unknown data source: {data}" + ))); + }, + Self::Loop { + template, + empty, + data, + .. + } => { + if !page.data_sources.contains_key(data) { + return Err(SchemaError::ValidationError(format!( + "Loop references unknown data source: {data}" + ))); + } + template.validate(page)?; + if let Some(empty_el) = empty { + empty_el.validate(page)?; + } + }, + Self::Button { action, .. } => { + action.validate()?; + }, + Self::Form { + fields, + submit_action, + .. + } => { + for field in fields { + validate_id(&field.id)?; + if field.label.is_empty() { + return Err(SchemaError::ValidationError(format!( + "Form field '{}' must have a label", + field.id + ))); + } + } + submit_action.validate()?; + }, + _ => {}, + } + Ok(()) + } + + /// Collects all data source references from this element + fn collect_data_refs(&self, refs: &mut Vec) { + match self { + Self::Container { children, .. } + | Self::Grid { children, .. } + | Self::Flex { children, .. } => { + for child in children { + child.collect_data_refs(refs); + } + }, + Self::Split { sidebar, main, .. } => { + sidebar.collect_data_refs(refs); + main.collect_data_refs(refs); + }, + Self::Tabs { tabs, .. } => { + for tab in tabs { + tab.content.collect_data_refs(refs); + } + }, + Self::Card { + content, footer, .. + } => { + for child in content { + child.collect_data_refs(refs); + } + for child in footer { + child.collect_data_refs(refs); + } + }, + Self::DataTable { data, .. } + | Self::MediaGrid { data, .. } + | Self::DescriptionList { data, .. } + | Self::Chart { data, .. } => { + refs.push(data.clone()); + }, + Self::List { + data, + item_template, + .. + } => { + refs.push(data.clone()); + item_template.collect_data_refs(refs); + }, + Self::Loop { + data, + template, + empty, + .. + } => { + refs.push(data.clone()); + template.collect_data_refs(refs); + if let Some(empty_el) = empty { + empty_el.collect_data_refs(refs); + } + }, + Self::Conditional { + then, else_element, .. + } => { + then.collect_data_refs(refs); + if let Some(else_el) = else_element { + else_el.collect_data_refs(refs); + } + }, + _ => {}, + } + } +} + +/// Flex direction for Flex layout +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)] +#[serde(rename_all = "lowercase")] +pub enum FlexDirection { + /// Horizontal layout (left to right) + #[default] + Row, + /// Vertical layout (top to bottom) + Column, +} + +/// Justify content alignment for Flex layout +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)] +#[serde(rename_all = "kebab-case")] +pub enum JustifyContent { + /// Items packed to start + #[default] + FlexStart, + /// Items packed to end + FlexEnd, + /// Items centered + Center, + /// Items evenly distributed with space between + SpaceBetween, + /// Items evenly distributed with space around + SpaceAround, + /// Items evenly distributed with equal space + SpaceEvenly, +} + +/// Align items for Flex layout (cross-axis alignment) +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)] +#[serde(rename_all = "kebab-case")] +pub enum AlignItems { + /// Items aligned to start + #[default] + FlexStart, + /// Items aligned to end + FlexEnd, + /// Items centered + Center, + /// Items stretched to fill + Stretch, + /// Items aligned to baseline + Baseline, +} + +/// Text content can be static or dynamic +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)] +#[serde(untagged)] +pub enum TextContent { + /// Static text string + Static(String), + + /// Dynamic expression that evaluates to text + Expression(Expression), + + /// Default (empty text) + #[default] + Empty, +} + +impl TextContent { + /// Returns true if content is empty + #[must_use] + pub const fn is_empty(&self) -> bool { + matches!(self, Self::Empty) + } +} + +impl From for TextContent { + fn from(s: String) -> Self { + Self::Static(s) + } +} + +impl From<&str> for TextContent { + fn from(s: &str) -> Self { + Self::Static(s.to_string()) + } +} + +impl From for TextContent { + fn from(expr: Expression) -> Self { + Self::Expression(expr) + } +} + +/// Text style variants +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)] +#[serde(rename_all = "snake_case")] +pub enum TextVariant { + /// Default body text + #[default] + Body, + /// Secondary/muted text + Secondary, + /// Success text (green) + Success, + /// Warning text (yellow/orange) + Warning, + /// Error text (red) + Error, + /// Bold text + Bold, + /// Italic text + Italic, + /// Small/caption text + Small, + /// Large/lead text + Large, +} + +/// Column definition for `DataTable` +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct ColumnDef { + /// Data key for this column + pub key: String, + + /// Display header text + pub header: String, + + /// Column width (e.g., "100px", "20%", or "auto") + #[serde(skip_serializing_if = "Option::is_none")] + pub width: Option, + + /// Whether column is sortable + #[serde(default = "default_true")] + pub sortable: bool, + + /// Data type for formatting (affects sorting and display) + #[serde(default)] + pub data_type: ColumnDataType, + + /// Custom format string (e.g., for dates: "YYYY-MM-DD") + #[serde(skip_serializing_if = "Option::is_none")] + pub format: Option, +} + +/// Row action for `DataTable` +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct RowAction { + /// Action identifier (unique within this table) + pub id: String, + + /// Display label + pub label: String, + + /// Action to execute + pub action: ActionRef, + + /// Button variant + #[serde(default)] + pub variant: ButtonVariant, +} + +/// Data type for column formatting +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)] +#[serde(rename_all = "snake_case")] +pub enum ColumnDataType { + /// Generic text + #[default] + Text, + /// Numeric value + Number, + /// Date/time + DateTime, + /// Boolean (shows as checkmark/x) + Boolean, + /// File size (auto-formatted) + FileSize, + /// Duration in seconds (formatted as HH:MM:SS) + Duration, +} + +/// Tab definition for Tabs component +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct TabDefinition { + /// Tab label + pub label: String, + + /// Tab icon (optional) + #[serde(skip_serializing_if = "Option::is_none")] + pub icon: Option, + + /// Tab content + pub content: UiElement, +} + +/// Button style variants +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)] +#[serde(rename_all = "snake_case")] +pub enum ButtonVariant { + /// Primary action button (filled) + #[default] + Primary, + /// Secondary button (outlined) + Secondary, + /// Tertiary button (text only) + Tertiary, + /// Danger/destructive action + Danger, + /// Success/confirm action + Success, + /// Ghost/invisible button + Ghost, +} + +/// Form field definition +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct FormField { + /// Field identifier (form data key) + pub id: String, + + /// Field label + pub label: String, + + /// Field type + #[serde(rename = "type")] + pub field_type: FieldType, + + /// Whether field is required + #[serde(default)] + pub required: bool, + + /// Placeholder text (for text inputs) + #[serde(skip_serializing_if = "Option::is_none")] + pub placeholder: Option, + + /// Default value + #[serde(skip_serializing_if = "Option::is_none")] + pub default_value: Option, + + /// Validation rules + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub validation: Vec, + + /// Help text displayed below field + #[serde(skip_serializing_if = "Option::is_none")] + pub help_text: Option, +} + +/// Form field types +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum FieldType { + /// Single-line text input + Text { + /// Maximum length + #[serde(skip_serializing_if = "Option::is_none")] + max_length: Option, + }, + + /// Multi-line text area + Textarea { + /// Number of rows + #[serde(default = "default_textarea_rows")] + rows: u32, + }, + + /// Email input with validation + Email, + + /// URL input with validation + Url, + + /// Number input + Number { + /// Minimum value + #[serde(skip_serializing_if = "Option::is_none")] + min: Option, + /// Maximum value + #[serde(skip_serializing_if = "Option::is_none")] + max: Option, + /// Step increment + #[serde(skip_serializing_if = "Option::is_none")] + step: Option, + }, + + /// Toggle switch + Switch, + + /// Checkbox + Checkbox { + /// Checkbox label (separate from field label) + #[serde(skip_serializing_if = "Option::is_none")] + checkbox_label: Option, + }, + + /// Select dropdown + Select { + /// Available options + options: Vec, + /// Whether multiple selection is allowed + #[serde(default)] + multiple: bool, + }, + + /// Radio button group + Radio { + /// Available options + options: Vec, + }, + + /// Date picker + Date, + + /// `DateTime` picker + DateTime, + + /// File upload + File { + /// Accepted MIME types (e.g., [`"image/*"`, `"application/pdf"`]) + #[serde(skip_serializing_if = "Option::is_none")] + accept: Option>, + /// Maximum file size in bytes + #[serde(skip_serializing_if = "Option::is_none")] + max_size: Option, + /// Whether multiple files allowed + #[serde(default)] + multiple: bool, + }, +} + +/// Select/Radio option +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct SelectOption { + /// Option value (stored in form data) + pub value: String, + /// Display label + pub label: String, +} + +/// Validation rules for form fields +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum ValidationRule { + /// Minimum length (for text) + MinLength { value: usize }, + /// Maximum length (for text) + MaxLength { value: usize }, + /// Pattern match (regex) + Pattern { regex: String }, + /// Custom validation message + Custom { message: String }, +} + +/// Badge color variants +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)] +#[serde(rename_all = "snake_case")] +pub enum BadgeVariant { + /// Default/neutral + #[default] + Default, + /// Primary color + Primary, + /// Secondary color + Secondary, + /// Success/green + Success, + /// Warning/yellow + Warning, + /// Error/red + Error, + /// Info/blue + Info, +} + +/// Chart types +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum ChartType { + /// Bar chart + Bar, + /// Line chart + Line, + /// Pie/donut chart + Pie, + /// Area chart + Area, + /// Scatter plot + Scatter, +} + +/// Action reference - identifies an action to execute +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(untagged)] +pub enum ActionRef { + /// Simple action name (references page.actions) + Name(String), + + /// Inline action definition + Inline(ActionDefinition), +} + +impl ActionRef { + /// Validates the action reference + /// + /// For inline actions, validates the `ActionDefinition`. + /// For named actions, validates the name is a valid identifier. + /// + /// # Errors + /// + /// Returns `SchemaError::ValidationError` if validation fails. + pub fn validate(&self) -> SchemaResult<()> { + match self { + Self::Name(name) => { + if name.is_empty() { + return Err(SchemaError::ValidationError( + "Action name cannot be empty".to_string(), + )); + } + validate_id(name)?; + }, + Self::Inline(action) => { + action.validate()?; + }, + } + Ok(()) + } +} + +/// Action definition +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct ActionDefinition { + /// HTTP method + #[serde(default = "default_http_method")] + pub method: HttpMethod, + + /// API endpoint path (must start with '/') + pub path: String, + + /// Action parameters (merged with form data on submit) + #[serde(default, skip_serializing_if = "HashMap::is_empty")] + pub params: HashMap, + + /// Success message + #[serde(skip_serializing_if = "Option::is_none")] + pub success_message: Option, + + /// Error message + #[serde(skip_serializing_if = "Option::is_none")] + pub error_message: Option, + + /// Navigation after success (route to navigate to) + #[serde(skip_serializing_if = "Option::is_none")] + pub navigate_to: Option, +} + +impl ActionDefinition { + /// Validates the action definition + /// + /// Checks: + /// - Path is non-empty and starts with '/' + /// + /// # Errors + /// + /// Returns `SchemaError::ValidationError` if validation fails. + pub fn validate(&self) -> SchemaResult<()> { + if self.path.is_empty() { + return Err(SchemaError::ValidationError( + "Action path cannot be empty".to_string(), + )); + } + if !self.path.starts_with('/') { + return Err(SchemaError::ValidationError(format!( + "Action path must start with '/': {}", + self.path + ))); + } + Ok(()) + } +} + +impl Default for ActionDefinition { + fn default() -> Self { + Self { + method: default_http_method(), + path: String::new(), + params: HashMap::new(), + success_message: None, + error_message: None, + navigate_to: None, + } + } +} + +/// HTTP methods for actions +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)] +#[serde(rename_all = "UPPERCASE")] +pub enum HttpMethod { + #[default] + Get, + Post, + Put, + Patch, + Delete, +} + +/// Data source for dynamic content +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum DataSource { + /// API endpoint + Endpoint { + /// HTTP method + #[serde(default = "default_http_method")] + method: HttpMethod, + + /// API path (relative to base URL) + path: String, + + /// Query parameters + #[serde(default, skip_serializing_if = "HashMap::is_empty")] + params: HashMap, + + /// Polling interval in seconds (0 = no polling) + #[serde(default)] + poll_interval: u64, + + /// Transform expression applied to response + #[serde(skip_serializing_if = "Option::is_none")] + transform: Option, + }, + + /// Transform of another data source + Transform { + /// Source data reference + #[serde(rename = "source")] + source_name: String, + + /// Transform expression + expression: Expression, + }, + + /// Static data + Static { + /// Static JSON value + value: serde_json::Value, + }, +} + +impl DataSource { + /// Validates this data source + /// + /// # Errors + /// + /// Returns `SchemaError::InvalidDataSource` if validation fails + pub fn validate(&self) -> SchemaResult<()> { + match self { + Self::Endpoint { path, .. } => { + if !path.starts_with('/') { + return Err(SchemaError::InvalidDataSource(format!( + "Endpoint path must start with '/': {path}" + ))); + } + }, + Self::Transform { source_name, .. } => { + validate_id(source_name)?; + }, + Self::Static { .. } => {}, + } + Ok(()) + } +} + +/// Expression for dynamic value evaluation +/// +/// Expressions use JSONPath-like syntax for data access. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(untagged)] +pub enum Expression { + /// Literal JSON value + Literal(serde_json::Value), + + /// Data path reference (e.g., "$.users[0].name") + Path(String), + + /// Binary operation + Operation { + /// Left operand + left: Box, + /// Operator + op: Operator, + /// Right operand + right: Box, + }, + + /// Function call + Call { + /// Function name + function: String, + /// Function arguments + args: Vec, + }, +} + +impl Default for Expression { + fn default() -> Self { + Self::Literal(serde_json::Value::Null) + } +} + +/// Binary operators +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum Operator { + /// Equal + Eq, + /// Not equal + Ne, + /// Greater than + Gt, + /// Greater than or equal + Gte, + /// Less than + Lt, + /// Less than or equal + Lte, + /// Logical and + And, + /// Logical or + Or, + /// String concatenation + Concat, + /// Addition + Add, + /// Subtraction + Sub, + /// Multiplication + Mul, + /// Division + Div, +} + +// Helper functions for defaults + +const fn default_gap() -> u32 { + 16 +} + +const fn default_sidebar_width() -> u32 { + 280 +} + +const fn default_grid_columns() -> u8 { + 4 +} + +const fn default_true() -> bool { + true +} + +const fn default_textarea_rows() -> u32 { + 3 +} + +const fn default_chart_height() -> u32 { + 300 +} + +const fn default_progress_max() -> f64 { + 100.0 +} + +fn default_submit_label() -> String { + "Submit".to_string() +} + +const fn default_http_method() -> HttpMethod { + HttpMethod::Get +} + +/// Validates an identifier string +/// +/// IDs must: +/// - Start with a letter +/// - Contain only alphanumeric characters, underscores, and dashes +/// - Be between 1 and 64 characters +/// +/// # Errors +/// +/// Returns `SchemaError::ValidationError` if the ID is invalid +fn validate_id(id: &str) -> SchemaResult<()> { + if id.is_empty() { + return Err(SchemaError::ValidationError( + "ID cannot be empty".to_string(), + )); + } + + if id.len() > 64 { + return Err(SchemaError::ValidationError(format!( + "ID exceeds maximum length of 64: {id}" + ))); + } + + let first_char = id.chars().next(); + if first_char.is_none_or(|c| !c.is_ascii_alphabetic()) { + return Err(SchemaError::ValidationError(format!( + "ID must start with a letter: {id}" + ))); + } + + if !id + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-') + { + return Err(SchemaError::ValidationError(format!( + "ID contains invalid characters: {id}" + ))); + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_ui_element_depth() { + let element = UiElement::Container { + children: vec![UiElement::Container { + children: vec![UiElement::Text { + content: TextContent::Static("test".to_string()), + variant: TextVariant::default(), + allow_html: false, + }], + gap: 16, + padding: None, + }], + gap: 16, + padding: None, + }; + + assert_eq!(element.depth(), 3); + } + + #[test] + fn test_validate_id() { + assert!(validate_id("valid-id").is_ok()); + assert!(validate_id("valid_id_123").is_ok()); + assert!(validate_id("ValidId").is_ok()); + + assert!(validate_id("").is_err()); + assert!(validate_id("123-start").is_err()); + assert!(validate_id("invalid.id").is_err()); + assert!(validate_id("a".repeat(65).as_str()).is_err()); + } + + #[test] + fn test_column_data_type_default() { + let column: ColumnDef = + serde_json::from_str(r#"{"key": "name", "header": "Name"}"#).unwrap(); + assert_eq!(column.data_type, ColumnDataType::Text); + assert!(column.sortable); + } + + #[test] + fn test_data_source_endpoint_validation() { + let valid = DataSource::Endpoint { + method: HttpMethod::Get, + path: "/api/test".to_string(), + params: HashMap::new(), + poll_interval: 0, + transform: None, + }; + assert!(valid.validate().is_ok()); + + let invalid = DataSource::Endpoint { + method: HttpMethod::Get, + path: "api/test".to_string(), + params: HashMap::new(), + poll_interval: 0, + transform: None, + }; + assert!(invalid.validate().is_err()); + } + + #[test] + fn test_text_content_from_string() { + let content: TextContent = "hello".into(); + assert_eq!(content, TextContent::Static("hello".to_string())); + } + + #[test] + fn test_expression_default() { + let expr = Expression::default(); + assert_eq!(expr, Expression::Literal(serde_json::Value::Null)); + } + + #[test] + fn test_action_definition_default() { + let action = ActionDefinition::default(); + assert_eq!(action.method, HttpMethod::Get); + assert!(action.path.is_empty()); + } + + #[test] + fn test_data_source_serialization() { + let source = DataSource::Static { + value: serde_json::json!({"key": "value"}), + }; + let json = serde_json::to_string(&source).unwrap(); + assert!(json.contains("static")); + } + + #[test] + fn test_ui_page_referenced_data_sources() { + let page = UiPage { + id: "test".to_string(), + title: "Test".to_string(), + route: "/test".to_string(), + icon: None, + root_element: UiElement::DataTable { + columns: vec![], + data: "items".to_string(), + sortable: true, + filterable: false, + page_size: 0, + row_actions: vec![], + }, + data_sources: HashMap::new(), + }; + + let refs = page.referenced_data_sources(); + assert_eq!(refs, vec!["items"]); + } + + #[test] + fn test_grid_validation() { + let page = UiPage { + id: "test".to_string(), + title: "Test".to_string(), + route: "/test".to_string(), + icon: None, + root_element: UiElement::Grid { + children: vec![], + columns: 13, + gap: 16, + }, + data_sources: HashMap::new(), + }; + + assert!(page.validate().is_err()); + } + + #[test] + fn test_heading_validation() { + let page = UiPage { + id: "test".to_string(), + title: "Test".to_string(), + route: "/test".to_string(), + icon: None, + root_element: UiElement::Heading { + level: 7, + content: TextContent::Static("Title".to_string()), + id: None, + }, + data_sources: HashMap::new(), + }; + + assert!(page.validate().is_err()); + } +} -- 2.43.0 From 0525ea6c608a501b54cf67b06d80e1476c33eb88 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Mon, 9 Mar 2026 19:59:21 +0300 Subject: [PATCH 02/46] pinakes-core: expose plugin UI pages via `PluginManager` Signed-off-by: NotAShelf Change-Id: Ic58a0174e6592303e863f51fb237220e6a6a6964 --- crates/pinakes-core/src/plugin/mod.rs | 30 +++++++++++++++++++++++ crates/pinakes-core/src/plugin/runtime.rs | 4 ++- 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/crates/pinakes-core/src/plugin/mod.rs b/crates/pinakes-core/src/plugin/mod.rs index 8ad12cf..5d47ed3 100644 --- a/crates/pinakes-core/src/plugin/mod.rs +++ b/crates/pinakes-core/src/plugin/mod.rs @@ -599,6 +599,36 @@ impl PluginManager { &self.enforcer } + /// List all UI pages provided by loaded plugins. + /// + /// Returns a vector of `(plugin_id, page)` tuples for all enabled plugins + /// that have the `ui_page` kind and provide pages in their manifests. + pub async fn list_ui_pages( + &self, + ) -> Vec<(String, pinakes_plugin_api::UiPage)> { + let registry = self.registry.read().await; + let mut pages = Vec::new(); + for plugin in registry.list_all() { + if !plugin.enabled { + continue; + } + for entry in &plugin.manifest.ui.pages { + let page = match entry { + pinakes_plugin_api::manifest::UiPageEntry::Inline(page) => { + (**page).clone() + }, + pinakes_plugin_api::manifest::UiPageEntry::File { .. } => { + // File-referenced pages require a base path to resolve; + // skip them here as they should have been loaded at startup. + continue; + }, + }; + pages.push((plugin.id.clone(), page)); + } + } + pages + } + /// Check if a plugin is loaded and enabled pub async fn is_plugin_enabled(&self, plugin_id: &str) -> bool { let registry = self.registry.read().await; diff --git a/crates/pinakes-core/src/plugin/runtime.rs b/crates/pinakes-core/src/plugin/runtime.rs index cb93d73..14fe010 100644 --- a/crates/pinakes-core/src/plugin/runtime.rs +++ b/crates/pinakes-core/src/plugin/runtime.rs @@ -493,7 +493,9 @@ impl HostFunctions { if let Some(ref allowed) = caller.data().context.capabilities.network.allowed_domains { - let parsed = if let Ok(u) = url::Url::parse(&url_str) { u } else { + let parsed = if let Ok(u) = url::Url::parse(&url_str) { + u + } else { tracing::warn!(url = %url_str, "plugin provided invalid URL"); return -1; }; -- 2.43.0 From 29ba24ae0163dcec15fae6883793557c6f3261fe Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Mon, 9 Mar 2026 21:59:22 +0300 Subject: [PATCH 03/46] pinakes-server: add GET `/plugins/ui-pages` endpoint Signed-off-by: NotAShelf Change-Id: Id09d4dce060e0a79586251a16fb6bdbc6a6a6964 --- crates/pinakes-server/src/app.rs | 1 + crates/pinakes-server/src/dto/plugins.rs | 10 +++++++++ crates/pinakes-server/src/routes/plugins.rs | 25 ++++++++++++++++++++- 3 files changed, 35 insertions(+), 1 deletion(-) diff --git a/crates/pinakes-server/src/app.rs b/crates/pinakes-server/src/app.rs index 39a2509..fddc235 100644 --- a/crates/pinakes-server/src/app.rs +++ b/crates/pinakes-server/src/app.rs @@ -480,6 +480,7 @@ pub fn create_router_with_tls( .route("/database/backup", post(routes::backup::create_backup)) // Plugin management .route("/plugins", get(routes::plugins::list_plugins)) + .route("/plugins/ui-pages", get(routes::plugins::list_plugin_ui_pages)) .route("/plugins/{id}", get(routes::plugins::get_plugin)) .route("/plugins/install", post(routes::plugins::install_plugin)) .route("/plugins/{id}", delete(routes::plugins::uninstall_plugin)) diff --git a/crates/pinakes-server/src/dto/plugins.rs b/crates/pinakes-server/src/dto/plugins.rs index 7872217..4ec883d 100644 --- a/crates/pinakes-server/src/dto/plugins.rs +++ b/crates/pinakes-server/src/dto/plugins.rs @@ -1,3 +1,4 @@ +use pinakes_plugin_api::UiPage; use serde::{Deserialize, Serialize}; #[derive(Debug, Serialize)] @@ -21,6 +22,15 @@ pub struct TogglePluginRequest { pub enabled: bool, } +/// A single plugin UI page entry in the list response +#[derive(Debug, Serialize)] +pub struct PluginUiPageEntry { + /// Plugin ID that provides this page + pub plugin_id: String, + /// Full page definition + pub page: UiPage, +} + impl PluginResponse { #[must_use] pub fn new(meta: pinakes_plugin_api::PluginMetadata, enabled: bool) -> Self { diff --git a/crates/pinakes-server/src/routes/plugins.rs b/crates/pinakes-server/src/routes/plugins.rs index 00b77f1..429fb87 100644 --- a/crates/pinakes-server/src/routes/plugins.rs +++ b/crates/pinakes-server/src/routes/plugins.rs @@ -4,7 +4,12 @@ use axum::{ }; use crate::{ - dto::{InstallPluginRequest, PluginResponse, TogglePluginRequest}, + dto::{ + InstallPluginRequest, + PluginResponse, + PluginUiPageEntry, + TogglePluginRequest, + }, error::ApiError, state::AppState, }; @@ -144,6 +149,24 @@ pub async fn toggle_plugin( }))) } +/// List all UI pages provided by loaded plugins +pub async fn list_plugin_ui_pages( + State(state): State, +) -> Result>, ApiError> { + let plugin_manager = state.plugin_manager.as_ref().ok_or_else(|| { + ApiError(pinakes_core::error::PinakesError::InvalidOperation( + "Plugin system is not enabled".to_string(), + )) + })?; + + let pages = plugin_manager.list_ui_pages().await; + let entries = pages + .into_iter() + .map(|(plugin_id, page)| PluginUiPageEntry { plugin_id, page }) + .collect(); + Ok(Json(entries)) +} + /// Reload a plugin (for development) pub async fn reload_plugin( State(state): State, -- 2.43.0 From 5b204dceb51ff27d258cf9f0ca18b1afcd7c2314 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Mon, 9 Mar 2026 21:59:58 +0300 Subject: [PATCH 04/46] pinakes-ui: add `ApiClient` plugin methods; depend on plugin API Signed-off-by: NotAShelf Change-Id: Ica7f913a22d18e59e85f1959a5b336df6a6a6964 --- Cargo.lock | 1 + crates/pinakes-ui/Cargo.toml | 1 + crates/pinakes-ui/src/client.rs | 58 ++++++++++++++++++++++++++++++++- 3 files changed, 59 insertions(+), 1 deletion(-) diff --git a/Cargo.lock b/Cargo.lock index 9dde6a8..682fbca 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5516,6 +5516,7 @@ dependencies = [ "gloo-timers", "grass", "gray_matter", + "pinakes-plugin-api", "pulldown-cmark", "rand 0.10.0", "regex", diff --git a/crates/pinakes-ui/Cargo.toml b/crates/pinakes-ui/Cargo.toml index a8dcc21..6dd8a0e 100644 --- a/crates/pinakes-ui/Cargo.toml +++ b/crates/pinakes-ui/Cargo.toml @@ -26,6 +26,7 @@ dioxus-free-icons = { workspace = true } gloo-timers = { workspace = true } rand = { workspace = true } urlencoding = { workspace = true } +pinakes-plugin-api = { workspace = true } [lints] workspace = true diff --git a/crates/pinakes-ui/src/client.rs b/crates/pinakes-ui/src/client.rs index 64d1c4c..b80bec6 100644 --- a/crates/pinakes-ui/src/client.rs +++ b/crates/pinakes-ui/src/client.rs @@ -19,12 +19,25 @@ pub struct MediaUpdateEvent { pub description: Option, } -#[derive(Clone)] pub struct ApiClient { client: Client, base_url: String, } +impl Clone for ApiClient { + fn clone(&self) -> Self { + Self::new(&self.base_url, None) + } +} + +impl std::fmt::Debug for ApiClient { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("ApiClient") + .field("base_url", &self.base_url) + .finish() + } +} + impl PartialEq for ApiClient { fn eq(&self, other: &Self) -> bool { self.base_url == other.base_url @@ -1598,6 +1611,49 @@ impl ApiClient { .build() .unwrap_or_else(|_| Client::new()); } + + /// List all UI pages provided by loaded plugins. + /// + /// Returns a vector of `(plugin_id, page)` tuples. + pub async fn get_plugin_ui_pages( + &self, + ) -> Result> { + #[derive(Deserialize)] + struct PageEntry { + plugin_id: String, + page: pinakes_plugin_api::UiPage, + } + + let entries: Vec = self + .client + .get(self.url("/plugins/ui-pages")) + .send() + .await? + .error_for_status()? + .json() + .await?; + + Ok(entries.into_iter().map(|e| (e.plugin_id, e.page)).collect()) + } + + /// Make a raw HTTP request to an API path. + /// + /// The `path` is appended to the base URL without any prefix. + /// Use this for plugin action endpoints that specify full API paths. + pub fn raw_request( + &self, + method: reqwest::Method, + path: &str, + ) -> reqwest::RequestBuilder { + let url = format!("{}{}", self.base_url, path); + self.client.request(method, url) + } +} + +impl Default for ApiClient { + fn default() -> Self { + Self::new("", None) + } } #[cfg(test)] -- 2.43.0 From e46a8943cbf164d125da90afd0dbc3ab3e09e26e Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Mon, 9 Mar 2026 22:01:08 +0300 Subject: [PATCH 05/46] pinakes-ui: add plugin data fetching Signed-off-by: NotAShelf Change-Id: Ie28a544cf0df1a23b905e89b69db19c06a6a6964 --- crates/pinakes-ui/src/plugin_ui/data.rs | 273 ++++++++++++++++++++++++ 1 file changed, 273 insertions(+) create mode 100644 crates/pinakes-ui/src/plugin_ui/data.rs diff --git a/crates/pinakes-ui/src/plugin_ui/data.rs b/crates/pinakes-ui/src/plugin_ui/data.rs new file mode 100644 index 0000000..eba6d57 --- /dev/null +++ b/crates/pinakes-ui/src/plugin_ui/data.rs @@ -0,0 +1,273 @@ +//! Data fetching system for plugin UI pages +//! +//! Provides data fetching and caching for plugin data sources. + +use std::collections::HashMap; + +use dioxus::prelude::*; +use pinakes_plugin_api::{DataSource, HttpMethod}; + +use crate::client::ApiClient; + +/// Cached data for a plugin page +#[derive(Debug, Clone, Default)] +pub struct PluginPageData { + data: HashMap, + loading: HashMap, + errors: HashMap, +} + +impl PluginPageData { + /// Get data for a specific source + #[must_use] + pub fn get(&self, source: &str) -> Option<&serde_json::Value> { + self.data.get(source) + } + + /// Check if a source is currently loading + #[must_use] + pub fn is_loading(&self, source: &str) -> bool { + self.loading.get(source).copied().unwrap_or(false) + } + + /// Get error for a specific source + #[must_use] + pub fn error(&self, source: &str) -> Option<&String> { + self.errors.get(source) + } + + /// Check if there's data for a specific source + #[must_use] + pub fn has_data(&self, source: &str) -> bool { + self.data.contains_key(source) + } + + /// Set data for a source + pub fn set_data(&mut self, source: String, value: serde_json::Value) { + self.data.insert(source, value); + } + + /// Set loading state for a source + pub fn set_loading(&mut self, source: &str, loading: bool) { + if loading { + self.loading.insert(source.to_string(), true); + self.errors.remove(source); + } else { + self.loading.remove(source); + } + } + + /// Set error for a source + pub fn set_error(&mut self, source: String, error: String) { + self.errors.insert(source, error); + } + + /// Clear all data + pub fn clear(&mut self) { + self.data.clear(); + self.loading.clear(); + self.errors.clear(); + } +} + +/// Fetch data from an endpoint +async fn fetch_endpoint( + client: &ApiClient, + path: &str, + method: HttpMethod, +) -> Result { + let reqwest_method = match method { + HttpMethod::Get => reqwest::Method::GET, + HttpMethod::Post => reqwest::Method::POST, + HttpMethod::Put => reqwest::Method::PUT, + HttpMethod::Patch => reqwest::Method::PATCH, + HttpMethod::Delete => reqwest::Method::DELETE, + }; + + // Send request and parse response + let response = client + .raw_request(reqwest_method, path) + .send() + .await + .map_err(|e| format!("Request failed: {e}"))?; + + if !response.status().is_success() { + let status = response.status(); + let body = response.text().await.unwrap_or_default(); + return Err(format!("HTTP {status}: {body}")); + } + + response + .json::() + .await + .map_err(|e| format!("Failed to parse JSON: {e}")) +} + +/// Fetch all data sources for a page +/// +/// # Errors +/// +/// Returns an error if any data source fails to fetch +pub async fn fetch_page_data( + client: &ApiClient, + data_sources: &HashMap, +) -> Result, String> { + let mut results = HashMap::new(); + + for (name, source) in data_sources { + let value = match source { + DataSource::Endpoint { path, method, .. } => { + // Fetch from endpoint (ignoring params, poll_interval, transform for + // now) + fetch_endpoint(client, path, method.clone()).await? + }, + DataSource::Static { value } => value.clone(), + DataSource::Transform { + source_name, + expression, + } => { + // Get source data and apply transform + let source_data = results + .get(source_name) + .cloned() + .unwrap_or(serde_json::Value::Null); + // TODO: Actually evaluate expression against source_data + // For now, return source_data unchanged + let _ = expression; + source_data + }, + }; + results.insert(name.clone(), value); + } + + Ok(results) +} + +/// Hook to fetch and cache plugin page data +/// +/// Returns a signal containing the data state +pub fn use_plugin_data( + client: Signal, + data_sources: HashMap, +) -> Signal { + let mut data = use_signal(PluginPageData::default); + + use_effect(move || { + let sources = data_sources.clone(); + + spawn(async move { + // Clear previous data + data.write().clear(); + + // Mark all sources as loading + for name in sources.keys() { + data.write().set_loading(name, true); + } + + // Fetch data + match fetch_page_data(&client.read(), &sources).await { + Ok(results) => { + for (name, value) in results { + data.write().set_data(name, value); + } + }, + Err(e) => { + for name in sources.keys() { + data.write().set_error(name.clone(), e.clone()); + } + }, + } + }); + }); + + data +} + +/// Get a value from JSON by path (dot notation) +/// +/// Supports object keys and array indices +#[must_use] +pub fn get_json_path<'a>( + value: &'a serde_json::Value, + path: &str, +) -> Option<&'a serde_json::Value> { + let mut current = value; + + for key in path.split('.') { + match current { + serde_json::Value::Object(map) => { + current = map.get(key)?; + }, + serde_json::Value::Array(arr) => { + let idx = key.parse::().ok()?; + current = arr.get(idx)?; + }, + _ => return None, + } + } + + Some(current) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_plugin_page_data() { + let mut data = PluginPageData::default(); + + // Test empty state + assert!(!data.has_data("test")); + assert!(!data.is_loading("test")); + assert!(data.error("test").is_none()); + + // Test setting data + data.set_data("test".to_string(), serde_json::json!({"key": "value"})); + assert!(data.has_data("test")); + assert_eq!(data.get("test"), Some(&serde_json::json!({"key": "value"}))); + + // Test loading state + data.set_loading("loading", true); + assert!(data.is_loading("loading")); + data.set_loading("loading", false); + assert!(!data.is_loading("loading")); + + // Test error state + data.set_error("error".to_string(), "oops".to_string()); + assert_eq!(data.error("error"), Some(&"oops".to_string())); + } + + #[test] + fn test_get_json_path_object() { + let data = serde_json::json!({ + "user": { + "name": "John", + "age": 30 + } + }); + + assert_eq!( + get_json_path(&data, "user.name"), + Some(&serde_json::Value::String("John".to_string())) + ); + } + + #[test] + fn test_get_json_path_array() { + let data = serde_json::json!({ + "items": ["a", "b", "c"] + }); + + assert_eq!( + get_json_path(&data, "items.1"), + Some(&serde_json::Value::String("b".to_string())) + ); + } + + #[test] + fn test_get_json_path_invalid() { + let data = serde_json::json!({"foo": "bar"}); + assert!(get_json_path(&data, "nonexistent").is_none()); + } +} -- 2.43.0 From 307375a348b828e66887641aabfbd784bd12bc55 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Mon, 9 Mar 2026 22:01:34 +0300 Subject: [PATCH 06/46] pinakes-ui: add plugin action executor Signed-off-by: NotAShelf Change-Id: Ic6dc2c6e3ef58dacad4829037226b0cf6a6a6964 --- crates/pinakes-ui/src/plugin_ui/actions.rs | 138 +++++++++++++++++++++ 1 file changed, 138 insertions(+) create mode 100644 crates/pinakes-ui/src/plugin_ui/actions.rs diff --git a/crates/pinakes-ui/src/plugin_ui/actions.rs b/crates/pinakes-ui/src/plugin_ui/actions.rs new file mode 100644 index 0000000..6da8aa2 --- /dev/null +++ b/crates/pinakes-ui/src/plugin_ui/actions.rs @@ -0,0 +1,138 @@ +//! Action execution system for plugin UI pages +//! +//! This module provides the action execution system that handles +//! user interactions with plugin UI elements. + +use pinakes_plugin_api::{ActionDefinition, ActionRef, HttpMethod}; + +use crate::client::ApiClient; + +/// Result of an action execution +#[derive(Debug, Clone)] +pub enum ActionResult { + /// Action completed successfully + Success(serde_json::Value), + /// Action failed + Error(String), + /// Navigation action + Navigate(String), + /// No meaningful result (e.g. 204 No Content) + None, +} + +/// Execute an action defined in the UI schema +pub async fn execute_action( + client: &ApiClient, + action_ref: &ActionRef, + form_data: Option<&serde_json::Value>, +) -> Result { + match action_ref { + ActionRef::Name(name) => { + tracing::warn!("Named action '{}' not implemented yet", name); + Ok(ActionResult::None) + }, + ActionRef::Inline(action) => { + execute_inline_action(client, action, form_data).await + }, + } +} + +/// Execute an inline action definition +async fn execute_inline_action( + client: &ApiClient, + action: &ActionDefinition, + form_data: Option<&serde_json::Value>, +) -> Result { + // Build URL from path + let url = action.path.clone(); + + // Merge action params with form data into query string for GET, body for + // others + let method = match action.method { + HttpMethod::Get => reqwest::Method::GET, + HttpMethod::Post => reqwest::Method::POST, + HttpMethod::Put => reqwest::Method::PUT, + HttpMethod::Patch => reqwest::Method::PATCH, + HttpMethod::Delete => reqwest::Method::DELETE, + }; + + let mut request = client.raw_request(method.clone(), &url); + + // For GET, merge params into query string; for mutating methods, send as + // JSON body + if method == reqwest::Method::GET { + let query_pairs: Vec<(String, String)> = action + .params + .iter() + .map(|(k, v)| { + let val = match v { + serde_json::Value::String(s) => s.clone(), + other => other.to_string(), + }; + (k.clone(), val) + }) + .collect(); + if !query_pairs.is_empty() { + request = request.query(&query_pairs); + } + } else { + // Build body: merge action.params with form_data + let mut merged: serde_json::Map = action + .params + .iter() + .map(|(k, v)| (k.clone(), v.clone())) + .collect(); + if let Some(fd) = form_data { + if let Some(obj) = fd.as_object() { + for (k, v) in obj { + merged.insert(k.clone(), v.clone()); + } + } + } + if !merged.is_empty() { + request = request.json(&merged); + } + } + + let response = request.send().await.map_err(|e| e.to_string())?; + let status = response.status(); + + if !status.is_success() { + let error_text = response.text().await.unwrap_or_default(); + return Ok(ActionResult::Error(format!( + "Action failed: {} - {}", + status.as_u16(), + error_text + ))); + } + + if status.as_u16() == 204 { + // Navigate on success if configured + if let Some(route) = &action.navigate_to { + return Ok(ActionResult::Navigate(route.clone())); + } + return Ok(ActionResult::None); + } + + let value: serde_json::Value = + response.json().await.map_err(|e| e.to_string())?; + + if let Some(route) = &action.navigate_to { + return Ok(ActionResult::Navigate(route.clone())); + } + + Ok(ActionResult::Success(value)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_action_result_variants() { + let _ = ActionResult::None; + let _ = ActionResult::Success(serde_json::json!({"ok": true})); + let _ = ActionResult::Error("error".to_string()); + let _ = ActionResult::Navigate("/page".to_string()); + } +} -- 2.43.0 From 901adcb2f0e43a4f83d05c398374ad2dd6950463 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Mon, 9 Mar 2026 22:01:37 +0300 Subject: [PATCH 07/46] pinakes-ui: add plugin schema renderer Signed-off-by: NotAShelf Change-Id: I2c2d99c21a3fdf04dd720286635b98a26a6a6964 --- crates/pinakes-ui/src/plugin_ui/renderer.rs | 1034 +++++++++++++++++++ 1 file changed, 1034 insertions(+) create mode 100644 crates/pinakes-ui/src/plugin_ui/renderer.rs diff --git a/crates/pinakes-ui/src/plugin_ui/renderer.rs b/crates/pinakes-ui/src/plugin_ui/renderer.rs new file mode 100644 index 0000000..b8e3586 --- /dev/null +++ b/crates/pinakes-ui/src/plugin_ui/renderer.rs @@ -0,0 +1,1034 @@ +//! Schema-to-Dioxus renderer for plugin UI pages +//! +//! Converts `UiElement` schemas from plugin manifests into rendered Dioxus +//! elements. Data-driven elements resolve their data from a [`PluginPageData`] +//! context that is populated by the `use_plugin_data` hook. + +use dioxus::prelude::*; +use pinakes_plugin_api::{ + AlignItems, + BadgeVariant, + ButtonVariant, + ChartType, + Expression, + FieldType, + FlexDirection, + JustifyContent, + TextContent, + TextVariant, + UiElement, + UiPage, +}; + +use super::{ + actions::{ActionResult, execute_action}, + data::{PluginPageData, use_plugin_data}, +}; +use crate::client::ApiClient; + +/// Props for [`PluginViewRenderer`] +#[derive(Props, PartialEq, Clone)] +pub struct PluginViewProps { + /// Plugin ID that owns this page + pub plugin_id: String, + /// Page schema to render + pub page: UiPage, + /// API client signal + pub client: Signal, +} + +/// Main component for rendering a plugin page. +/// +/// Fetches all declared data sources and then renders the page layout using +/// the resolved data. +#[component] +pub fn PluginViewRenderer(props: PluginViewProps) -> Element { + let page = props.page.clone(); + let data_sources = page.data_sources.clone(); + let page_data = use_plugin_data(props.client, data_sources); + + rsx! { + div { + class: "plugin-page", + "data-plugin-id": props.plugin_id.clone(), + h2 { class: "plugin-page-title", "{page.title}" } + { render_element(&page.root_element, &page_data.read()) } + } + } +} + +/// Render a single [`UiElement`] with the provided data context. +fn render_element(element: &UiElement, data: &PluginPageData) -> Element { + match element { + // Layout containers + UiElement::Container { + children, + gap, + padding, + } => { + let padding_css = padding.map_or_else( + || "0".to_string(), + |p| format!("{}px {}px {}px {}px", p[0], p[1], p[2], p[3]), + ); + let style = format!( + "display:flex;flex-direction:column;gap:{gap}px;padding:{padding_css};" + ); + rsx! { + div { + class: "plugin-container", + style: "{style}", + for child in children { + { render_element(child, data) } + } + } + } + }, + + UiElement::Grid { + children, + columns, + gap, + } => { + let style = format!( + "display:grid;grid-template-columns:repeat({columns},1fr);gap:{gap}px;" + ); + rsx! { + div { + class: "plugin-grid", + style: "{style}", + for child in children { + { render_element(child, data) } + } + } + } + }, + + UiElement::Flex { + children, + direction, + justify, + align, + gap, + wrap, + } => { + let dir = flex_direction_css(direction); + let jc = justify_content_css(justify); + let ai = align_items_css(align); + let fw = if *wrap { "wrap" } else { "nowrap" }; + let style = format!( + "display:flex;flex-direction:{dir};justify-content:{jc};align-items:\ + {ai};flex-wrap:{fw};gap:{gap}px;" + ); + rsx! { + div { + class: "plugin-flex", + style: "{style}", + for child in children { + { render_element(child, data) } + } + } + } + }, + + UiElement::Split { + sidebar, + sidebar_width, + main, + } => { + rsx! { + div { + class: "plugin-split", + style: "display:flex;", + aside { + class: "plugin-split-sidebar", + style: "width:{sidebar_width}px;flex-shrink:0;", + { render_element(sidebar, data) } + } + main { + class: "plugin-split-main", + style: "flex:1;min-width:0;", + { render_element(main, data) } + } + } + } + }, + + UiElement::Tabs { tabs, default_tab } => { + rsx! { + div { + class: "plugin-tabs", + div { + class: "plugin-tab-list", + role: "tablist", + for (idx, tab) in tabs.iter().enumerate() { + button { + class: if idx == *default_tab { "plugin-tab active" } else { "plugin-tab" }, + role: "tab", + aria_selected: if idx == *default_tab { "true" } else { "false" }, + if let Some(icon) = &tab.icon { + span { class: "tab-icon", "{icon}" } + } + "{tab.label}" + } + } + } + div { + class: "plugin-tab-panels", + for (idx, tab) in tabs.iter().enumerate() { + div { + class: if idx == *default_tab { "plugin-tab-panel active" } else { "plugin-tab-panel" }, + hidden: idx != *default_tab, + { render_element(&tab.content, data) } + } + } + } + } + } + }, + + // Typography + UiElement::Heading { level, content, id } => { + let text = resolve_text_content(content, &serde_json::json!({})); + let class = format!("plugin-heading level-{level}"); + let anchor = id.clone().unwrap_or_default(); + match level.min(&6) { + 1 => rsx! { h1 { class: "{class}", id: "{anchor}", "{text}" } }, + 2 => rsx! { h2 { class: "{class}", id: "{anchor}", "{text}" } }, + 3 => rsx! { h3 { class: "{class}", id: "{anchor}", "{text}" } }, + 4 => rsx! { h4 { class: "{class}", id: "{anchor}", "{text}" } }, + 5 => rsx! { h5 { class: "{class}", id: "{anchor}", "{text}" } }, + _ => rsx! { h6 { class: "{class}", id: "{anchor}", "{text}" } }, + } + }, + + UiElement::Text { + content, + variant, + allow_html, + } => { + let text = resolve_text_content(content, &serde_json::json!({})); + let variant_class = text_variant_class(variant); + if *allow_html { + let sanitized = ammonia::clean(&text); + rsx! { + p { + class: "plugin-text {variant_class}", + dangerous_inner_html: "{sanitized}", + } + } + } else { + rsx! { + p { + class: "plugin-text {variant_class}", + "{text}" + } + } + } + }, + + UiElement::Code { + content, + language, + show_line_numbers, + } => { + let lang = language.as_deref().unwrap_or("plaintext"); + let line_class = if *show_line_numbers { + "show-line-numbers" + } else { + "" + }; + rsx! { + pre { + class: "plugin-code {line_class}", + "data-language": "{lang}", + code { "{content}" } + } + } + }, + + // Data display + UiElement::DataTable { + columns, + data: source_key, + sortable, + filterable: _, + page_size: _, + row_actions, + } => { + let rows = data.get(source_key); + rsx! { + div { class: "plugin-data-table-wrapper", + if data.is_loading(source_key) { + div { class: "plugin-loading", "Loading…" } + } else if let Some(err) = data.error(source_key) { + div { class: "plugin-error", "Error: {err}" } + } else { + table { + class: "plugin-data-table", + "data-sortable": if *sortable { "true" } else { "false" }, + thead { + tr { + for col in columns { + th { + style: col.width.as_ref().map(|w| format!("width:{w};")).unwrap_or_default(), + "{col.header}" + } + } + if !row_actions.is_empty() { + th { "Actions" } + } + } + } + tbody { + if let Some(arr) = rows.and_then(|v| v.as_array()) { + for row in arr { + tr { + for col in columns { + td { "{extract_cell(row, &col.key)}" } + } + if !row_actions.is_empty() { + td { + class: "row-actions", + for act in row_actions { + { + let action = act.action.clone(); + let row_data = row.clone(); + let variant_class = button_variant_class(&act.variant); + rsx! { + button { + class: "plugin-button {variant_class}", + onclick: move |_| { + let a = action.clone(); + let fd = row_data.clone(); + let client = ApiClient::default(); + spawn(async move { + let _ = execute_action(&client, &a, Some(&fd)).await; + }); + }, + "{act.label}" + } + } + } + } + } + } + } + } + } + } + } + } + } + } + }, + + UiElement::Card { + title, + content, + footer, + } => { + rsx! { + div { + class: "plugin-card", + if let Some(t) = title { + div { class: "plugin-card-header", "{t}" } + } + div { + class: "plugin-card-content", + for child in content { + { render_element(child, data) } + } + } + if !footer.is_empty() { + div { + class: "plugin-card-footer", + for child in footer { + { render_element(child, data) } + } + } + } + } + } + }, + + UiElement::MediaGrid { + data: source_key, + columns, + gap, + } => { + let items = data.get(source_key); + let style = format!( + "display:grid;grid-template-columns:repeat({columns},1fr);gap:{gap}px;" + ); + rsx! { + div { class: "plugin-media-grid", style: "{style}", + if data.is_loading(source_key) { + div { class: "plugin-loading", "Loading…" } + } else if let Some(err) = data.error(source_key) { + div { class: "plugin-error", "Error: {err}" } + } else if let Some(arr) = items.and_then(|v| v.as_array()) { + for item in arr { + div { + class: "media-grid-item", + "{extract_cell(item, \"thumbnail\")}" + } + } + } + } + } + }, + + UiElement::List { + data: source_key, + item_template, + dividers, + } => { + let items = data.get(source_key); + rsx! { + div { class: "plugin-list-wrapper", + if data.is_loading(source_key) { + div { class: "plugin-loading", "Loading…" } + } else if let Some(err) = data.error(source_key) { + div { class: "plugin-error", "Error: {err}" } + } else if let Some(arr) = items.and_then(|v| v.as_array()) { + if arr.is_empty() { + div { class: "plugin-list-empty", "No items" } + } else { + ul { class: "plugin-list", + for _item in arr { + li { + class: "plugin-list-item", + { render_element(item_template, data) } + if *dividers { + hr { class: "plugin-list-divider" } + } + } + } + } + } + } + } + } + }, + + UiElement::DescriptionList { + data: source_key, + horizontal, + } => { + let resolved = data.get(source_key); + let class = if *horizontal { + "plugin-description-list horizontal" + } else { + "plugin-description-list" + }; + rsx! { + div { class: "plugin-description-list-wrapper", + if data.is_loading(source_key) { + div { class: "plugin-loading", "Loading…" } + } else if let Some(err) = data.error(source_key) { + div { class: "plugin-error", "Error: {err}" } + } else if let Some(obj) = resolved.and_then(|v| v.as_object()) { + dl { class: "{class}", + for (key, val) in obj { + dt { "{key}" } + dd { "{val}" } + } + } + } + } + } + }, + + // Interactive + UiElement::Button { + label, + variant, + action, + disabled, + } => { + let variant_class = button_variant_class(variant); + let action_ref = action.clone(); + rsx! { + button { + class: "plugin-button {variant_class}", + disabled: *disabled, + onclick: move |_| { + let a = action_ref.clone(); + let client = ApiClient::default(); + spawn(async move { + let _ = execute_action(&client, &a, None).await; + }); + }, + "{label}" + } + } + }, + + UiElement::Form { + fields, + submit_label, + submit_action, + cancel_label, + } => { + let action_ref = submit_action.clone(); + rsx! { + form { + class: "plugin-form", + onsubmit: move |event| { + event.prevent_default(); + let a = action_ref.clone(); + let form_values: serde_json::Value = { + use dioxus::html::FormValue; + let vals = event.data().values(); + let map: serde_json::Map = vals + .into_iter() + .map(|(k, v)| { + let s = match v { + FormValue::Text(s) => s, + FormValue::File(_) => String::new(), + }; + (k, serde_json::Value::String(s)) + }) + .collect(); + serde_json::Value::Object(map) + }; + let client = ApiClient::default(); + spawn(async move { + let _ = execute_action(&client, &a, Some(&form_values)).await; + }); + }, + for field in fields { + div { + class: "form-field", + label { + r#for: "{field.id}", + "{field.label}" + if field.required { + span { class: "required", " *" } + } + } + { render_form_field(field) } + if let Some(help) = &field.help_text { + p { class: "form-help", "{help}" } + } + } + } + div { class: "form-actions", + button { + r#type: "submit", + class: "plugin-button btn-primary", + "{submit_label}" + } + if let Some(cancel) = cancel_label { + button { + r#type: "button", + class: "plugin-button btn-secondary", + "{cancel}" + } + } + } + } + } + }, + + UiElement::Link { + text, + href, + external, + } => { + let target = if *external { "_blank" } else { "_self" }; + let rel = if *external { "noopener noreferrer" } else { "" }; + rsx! { + a { + class: "plugin-link", + href: "{href}", + target: "{target}", + rel: "{rel}", + "{text}" + } + } + }, + + UiElement::Progress { + value, + max, + show_percentage, + } => { + let pct = evaluate_expression_as_f64(value); + let fraction = (pct / max).clamp(0.0, 1.0); + let pct_int = (fraction * 100.0).round() as u32; + rsx! { + div { class: "plugin-progress", + div { + class: "plugin-progress-bar", + role: "progressbar", + aria_valuenow: "{pct_int}", + aria_valuemin: "0", + aria_valuemax: "100", + style: "width:{pct_int}%;", + } + if *show_percentage { + span { class: "plugin-progress-label", "{pct_int}%" } + } + } + } + }, + + UiElement::Badge { text, variant } => { + let variant_class = badge_variant_class(variant); + rsx! { + span { + class: "plugin-badge {variant_class}", + "{text}" + } + } + }, + + // Visualization + UiElement::Chart { + chart_type, + data: source_key, + title, + x_axis_label, + y_axis_label, + height, + } => { + let chart_class = chart_type_class(chart_type); + rsx! { + div { + class: "plugin-chart {chart_class}", + style: "height:{height}px;", + if data.is_loading(source_key) { + div { class: "plugin-loading", "Loading…" } + } else if let Some(err) = data.error(source_key) { + div { class: "plugin-error", "Error: {err}" } + } else { + if let Some(t) = title { div { class: "chart-title", "{t}" } } + 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}" } } + div { class: "chart-canvas", "Chart rendering requires JavaScript" } + } + } + } + }, + + // Conditional & loop + UiElement::Conditional { + condition, + then, + else_element, + } => { + let ctx = serde_json::json!({}); + if evaluate_expression_as_bool(condition, &ctx) { + render_element(then, data) + } else if let Some(else_el) = else_element { + render_element(else_el, data) + } else { + rsx! {} + } + }, + + UiElement::Loop { + data: source_key, + template, + empty, + } => { + let items = data.get(source_key); + if data.is_loading(source_key) { + return rsx! { div { class: "plugin-loading", "Loading…" } }; + } + if let Some(err) = data.error(source_key) { + return rsx! { div { class: "plugin-error", "Error: {err}" } }; + } + if let Some(arr) = items.and_then(|v| v.as_array()) { + if arr.is_empty() { + if let Some(empty_el) = empty { + return render_element(empty_el, data); + } + return rsx! {}; + } + rsx! { + for _item in arr { + { render_element(template, data) } + } + } + } else { + rsx! {} + } + }, + } +} + +// Form field helper + +fn render_form_field(field: &pinakes_plugin_api::FormField) -> Element { + match &field.field_type { + FieldType::Text { .. } => { + rsx! { + input { + r#type: "text", + id: "{field.id}", + name: "{field.id}", + placeholder: field.placeholder.as_deref().unwrap_or(""), + required: field.required, + } + } + }, + FieldType::Textarea { rows } => { + rsx! { + textarea { + id: "{field.id}", + name: "{field.id}", + rows: *rows, + placeholder: field.placeholder.as_deref().unwrap_or(""), + required: field.required, + } + } + }, + FieldType::Number { min, max, step } => { + rsx! { + input { + r#type: "number", + id: "{field.id}", + name: "{field.id}", + min: min.map(|m| m.to_string()), + max: max.map(|m| m.to_string()), + step: step.map(|s| s.to_string()), + required: field.required, + } + } + }, + FieldType::Email => { + rsx! { + input { + r#type: "email", + id: "{field.id}", + name: "{field.id}", + placeholder: field.placeholder.as_deref().unwrap_or(""), + required: field.required, + } + } + }, + FieldType::Url => { + rsx! { + input { + r#type: "url", + id: "{field.id}", + name: "{field.id}", + placeholder: field.placeholder.as_deref().unwrap_or(""), + required: field.required, + } + } + }, + FieldType::Switch | FieldType::Checkbox { .. } => { + rsx! { + input { + r#type: "checkbox", + id: "{field.id}", + name: "{field.id}", + required: field.required, + } + } + }, + FieldType::Select { options, multiple } => { + rsx! { + select { + id: "{field.id}", + name: "{field.id}", + multiple: *multiple, + required: field.required, + option { value: "", disabled: true, "Select…" } + for opt in options { + option { value: "{opt.value}", "{opt.label}" } + } + } + } + }, + FieldType::Radio { options } => { + rsx! { + fieldset { + id: "{field.id}", + for opt in options { + label { + input { + r#type: "radio", + name: "{field.id}", + value: "{opt.value}", + required: field.required, + } + " {opt.label}" + } + } + } + } + }, + FieldType::Date => { + rsx! { + input { + r#type: "date", + id: "{field.id}", + name: "{field.id}", + required: field.required, + } + } + }, + FieldType::DateTime => { + rsx! { + input { + r#type: "datetime-local", + id: "{field.id}", + name: "{field.id}", + required: field.required, + } + } + }, + FieldType::File { + accept, multiple, .. + } => { + rsx! { + input { + r#type: "file", + id: "{field.id}", + name: "{field.id}", + accept: accept.as_ref().map(|a| a.join(",")).unwrap_or_default(), + multiple: *multiple, + required: field.required, + } + } + }, + } +} + +fn flex_direction_css(d: &FlexDirection) -> &'static str { + match d { + FlexDirection::Row => "row", + FlexDirection::Column => "column", + } +} + +fn justify_content_css(j: &JustifyContent) -> &'static str { + match j { + JustifyContent::FlexStart => "flex-start", + JustifyContent::FlexEnd => "flex-end", + JustifyContent::Center => "center", + JustifyContent::SpaceBetween => "space-between", + JustifyContent::SpaceAround => "space-around", + JustifyContent::SpaceEvenly => "space-evenly", + } +} + +fn align_items_css(a: &AlignItems) -> &'static str { + match a { + AlignItems::FlexStart => "flex-start", + AlignItems::FlexEnd => "flex-end", + AlignItems::Center => "center", + AlignItems::Stretch => "stretch", + AlignItems::Baseline => "baseline", + } +} + +fn button_variant_class(v: &ButtonVariant) -> &'static str { + match v { + ButtonVariant::Primary => "btn-primary", + ButtonVariant::Secondary => "btn-secondary", + ButtonVariant::Tertiary => "btn-tertiary", + ButtonVariant::Danger => "btn-danger", + ButtonVariant::Success => "btn-success", + ButtonVariant::Ghost => "btn-ghost", + } +} + +fn badge_variant_class(v: &BadgeVariant) -> &'static str { + match v { + BadgeVariant::Default => "badge-default", + BadgeVariant::Primary => "badge-primary", + BadgeVariant::Secondary => "badge-secondary", + BadgeVariant::Success => "badge-success", + BadgeVariant::Warning => "badge-warning", + BadgeVariant::Error => "badge-error", + BadgeVariant::Info => "badge-info", + } +} + +fn chart_type_class(t: &ChartType) -> &'static str { + match t { + ChartType::Bar => "chart-bar", + ChartType::Line => "chart-line", + ChartType::Pie => "chart-pie", + ChartType::Area => "chart-area", + ChartType::Scatter => "chart-scatter", + } +} + +fn text_variant_class(v: &TextVariant) -> &'static str { + match v { + TextVariant::Body => "", + TextVariant::Secondary => "text-secondary", + TextVariant::Success => "text-success", + TextVariant::Warning => "text-warning", + TextVariant::Error => "text-error", + TextVariant::Bold => "text-bold", + TextVariant::Italic => "text-italic", + TextVariant::Small => "text-small", + TextVariant::Large => "text-large", + } +} + +fn resolve_text_content( + content: &TextContent, + ctx: &serde_json::Value, +) -> String { + match content { + TextContent::Static(s) => s.clone(), + TextContent::Expression(expr) => evaluate_expression(expr, ctx).to_string(), + TextContent::Empty => String::new(), + } +} + +fn evaluate_expression( + expr: &Expression, + ctx: &serde_json::Value, +) -> serde_json::Value { + match expr { + Expression::Literal(v) => v.clone(), + Expression::Path(path) => { + let mut current = ctx; + for key in path.split('.') { + match current { + serde_json::Value::Object(map) => { + if let Some(next) = map.get(key) { + current = next; + } else { + return serde_json::Value::Null; + } + }, + serde_json::Value::Array(arr) => { + if let Ok(idx) = key.parse::() { + if let Some(item) = arr.get(idx) { + current = item; + } else { + return serde_json::Value::Null; + } + } else { + return serde_json::Value::Null; + } + }, + _ => return serde_json::Value::Null, + } + } + current.clone() + }, + Expression::Operation { left, op, right } => { + use pinakes_plugin_api::Operator; + let l = evaluate_expression(left, ctx); + let r = evaluate_expression(right, ctx); + match op { + Operator::Eq => serde_json::Value::Bool(l == r), + Operator::Ne => serde_json::Value::Bool(l != r), + Operator::And => { + serde_json::Value::Bool( + l.as_bool().unwrap_or(false) && r.as_bool().unwrap_or(false), + ) + }, + Operator::Or => { + serde_json::Value::Bool( + l.as_bool().unwrap_or(false) || r.as_bool().unwrap_or(false), + ) + }, + Operator::Gt => { + let lf = l.as_f64().unwrap_or(0.0); + let rf = r.as_f64().unwrap_or(0.0); + serde_json::Value::Bool(lf > rf) + }, + Operator::Gte => { + let lf = l.as_f64().unwrap_or(0.0); + let rf = r.as_f64().unwrap_or(0.0); + serde_json::Value::Bool(lf >= rf) + }, + Operator::Lt => { + let lf = l.as_f64().unwrap_or(0.0); + let rf = r.as_f64().unwrap_or(0.0); + serde_json::Value::Bool(lf < rf) + }, + Operator::Lte => { + let lf = l.as_f64().unwrap_or(0.0); + let rf = r.as_f64().unwrap_or(0.0); + serde_json::Value::Bool(lf <= rf) + }, + _ => serde_json::Value::Null, + } + }, + Expression::Call { .. } => serde_json::Value::Null, + } +} + +fn evaluate_expression_as_bool( + expr: &Expression, + ctx: &serde_json::Value, +) -> bool { + evaluate_expression(expr, ctx).as_bool().unwrap_or(false) +} + +fn evaluate_expression_as_f64(expr: &Expression) -> f64 { + evaluate_expression(expr, &serde_json::json!({})) + .as_f64() + .unwrap_or(0.0) +} + +fn extract_cell(row: &serde_json::Value, key: &str) -> String { + row + .as_object() + .and_then(|obj| obj.get(key)) + .map(|v| { + match v { + serde_json::Value::String(s) => s.clone(), + serde_json::Value::Number(n) => n.to_string(), + serde_json::Value::Bool(b) => b.to_string(), + serde_json::Value::Null => String::new(), + other => other.to_string(), + } + }) + .unwrap_or_default() +} + +#[cfg(test)] +mod tests { + use pinakes_plugin_api::Expression; + + use super::*; + + #[test] + fn test_evaluate_expression_literal() { + let expr = Expression::Literal(serde_json::json!("hello")); + let result = evaluate_expression(&expr, &serde_json::json!({})); + assert_eq!(result, serde_json::json!("hello")); + } + + #[test] + fn test_evaluate_expression_path() { + let expr = Expression::Path("foo.bar".to_string()); + let ctx = serde_json::json!({ "foo": { "bar": 42 } }); + let result = evaluate_expression(&expr, &ctx); + assert_eq!(result, serde_json::json!(42)); + } + + #[test] + fn test_extract_cell_string() { + let row = serde_json::json!({ "name": "Alice", "count": 5 }); + assert_eq!(extract_cell(&row, "name"), "Alice"); + assert_eq!(extract_cell(&row, "count"), "5"); + assert_eq!(extract_cell(&row, "missing"), ""); + } + + #[test] + fn test_resolve_text_content_static() { + let tc = TextContent::Static("Hello".to_string()); + assert_eq!(resolve_text_content(&tc, &serde_json::json!({})), "Hello"); + } + + #[test] + fn test_resolve_text_content_empty() { + let tc = TextContent::Empty; + assert_eq!(resolve_text_content(&tc, &serde_json::json!({})), ""); + } +} -- 2.43.0 From be4305f46e88f7ea51066f97b4d1db376b89d286 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Mon, 9 Mar 2026 22:01:40 +0300 Subject: [PATCH 08/46] pinakes-ui: add plugin page registry Signed-off-by: NotAShelf Change-Id: Ie83791e82c68f757173e5dc53a646b356a6a6964 --- crates/pinakes-ui/src/main.rs | 1 + crates/pinakes-ui/src/plugin_ui/mod.rs | 38 +++ crates/pinakes-ui/src/plugin_ui/registry.rs | 248 ++++++++++++++++++++ 3 files changed, 287 insertions(+) create mode 100644 crates/pinakes-ui/src/plugin_ui/mod.rs create mode 100644 crates/pinakes-ui/src/plugin_ui/registry.rs diff --git a/crates/pinakes-ui/src/main.rs b/crates/pinakes-ui/src/main.rs index 024584d..51b4068 100644 --- a/crates/pinakes-ui/src/main.rs +++ b/crates/pinakes-ui/src/main.rs @@ -4,6 +4,7 @@ use tracing_subscriber::EnvFilter; mod app; mod client; mod components; +mod plugin_ui; mod state; mod styles; diff --git a/crates/pinakes-ui/src/plugin_ui/mod.rs b/crates/pinakes-ui/src/plugin_ui/mod.rs new file mode 100644 index 0000000..fc40360 --- /dev/null +++ b/crates/pinakes-ui/src/plugin_ui/mod.rs @@ -0,0 +1,38 @@ +//! Plugin UI system for Pinakes Desktop/Web UI +//! +//! This module provides a declarative UI plugin system that allows plugins +//! to define custom pages without needing to compile against the UI crate. +//! +//! # Architecture +//! +//! - [`registry`] - Plugin page registry and context provider +//! - [`data`] - Data fetching and caching for plugin data sources +//! - [`actions`] - Action execution system for plugin interactions +//! - [`renderer`] - Schema-to-Dioxus rendering components +//! +//! # Usage +//! +//! Plugins define their UI as JSON schemas in the plugin manifest: +//! +//! ```toml +//! [[ui.pages]] +//! id = "my-plugin-page" +//! title = "My Plugin" +//! route = "/plugins/my-plugin" +//! icon = "cog" +//! +//! [ui.pages.layout] +//! type = "container" +//! # ... more layout definition +//! ``` +//! +//! The UI schema is fetched from the server and rendered using the +//! components in this module. + +pub mod actions; +pub mod data; +pub mod registry; +pub mod renderer; + +pub use registry::{PluginPage, PluginRegistry}; +pub use renderer::PluginViewRenderer; diff --git a/crates/pinakes-ui/src/plugin_ui/registry.rs b/crates/pinakes-ui/src/plugin_ui/registry.rs new file mode 100644 index 0000000..473509d --- /dev/null +++ b/crates/pinakes-ui/src/plugin_ui/registry.rs @@ -0,0 +1,248 @@ +//! Plugin UI Registry +//! +//! Manages plugin-provided UI pages and provides hooks for accessing +//! page definitions at runtime. +//! +//! ## Usage +//! +//! ```rust,ignore +//! // Initialize registry with API client +//! let registry = PluginRegistry::new(api_client); +//! registry.refresh().await?; +//! +//! // Access pages +//! if let Some(page) = registry.get_page("my-plugin", "demo") { +//! println!("Page: {}", page.page.title); +//! } +//! ``` + +use std::collections::HashMap; + +use dioxus::prelude::*; +use pinakes_plugin_api::UiPage; + +use crate::client::ApiClient; + +/// Information about a plugin-provided UI page +#[derive(Debug, Clone)] +pub struct PluginPage { + /// Plugin ID that provides this page + pub plugin_id: String, + /// Page definition from schema + pub page: UiPage, +} + +impl PluginPage { + /// Full route including plugin prefix + pub fn full_route(&self) -> String { + format!("/plugins/{}/{}", self.plugin_id, self.page.id) + } +} + +/// Registry of all plugin-provided UI pages +/// +/// This is typically stored as a context value in the Dioxus tree. +#[derive(Debug, Clone)] +pub struct PluginRegistry { + /// API client for fetching pages from server + client: ApiClient, + /// Cached pages: (plugin_id, page_id) -> PluginPage + pages: HashMap<(String, String), PluginPage>, + /// Last refresh timestamp + last_refresh: Option>, +} + +impl PluginRegistry { + /// Create a new empty registry + pub fn new(client: ApiClient) -> Self { + Self { + client, + pages: HashMap::new(), + last_refresh: None, + } + } + + /// Create a new registry with pre-loaded pages + pub fn with_pages( + client: ApiClient, + pages: Vec<(String, String, UiPage)>, + ) -> Self { + let mut registry = Self::new(client); + for (plugin_id, _page_id, page) in pages { + registry.register_page(plugin_id, page); + } + registry + } + + /// Register a page from a plugin + pub fn register_page(&mut self, plugin_id: String, page: UiPage) { + let page_id = page.id.clone(); + self + .pages + .insert((plugin_id.clone(), page_id), PluginPage { plugin_id, page }); + } + + /// Get a specific page by plugin ID and page ID + pub fn get_page( + &self, + plugin_id: &str, + page_id: &str, + ) -> Option<&PluginPage> { + self + .pages + .get(&(plugin_id.to_string(), page_id.to_string())) + } + + /// Get all pages + pub fn all_pages(&self) -> Vec<&PluginPage> { + self.pages.values().collect() + } + + /// Get all page routes for navigation + pub fn routes(&self) -> Vec<(String, String, String)> { + self + .pages + .values() + .map(|p| (p.plugin_id.clone(), p.page.id.clone(), p.full_route())) + .collect() + } + + /// Check if any pages are registered + pub fn is_empty(&self) -> bool { + self.pages.is_empty() + } + + /// Number of registered pages + pub fn len(&self) -> usize { + self.pages.len() + } + + /// Refresh pages from server + pub async fn refresh(&mut self) -> Result<(), String> { + match self.client.get_plugin_ui_pages().await { + Ok(pages) => { + self.pages.clear(); + for (plugin_id, page) in pages { + self.register_page(plugin_id, page); + } + self.last_refresh = Some(chrono::Utc::now()); + Ok(()) + }, + Err(e) => Err(format!("Failed to refresh plugin pages: {e}")), + } + } + + /// Get last refresh time + pub const fn last_refresh(&self) -> Option> { + self.last_refresh + } +} + +impl Default for PluginRegistry { + fn default() -> Self { + Self::new(ApiClient::default()) + } +} + +#[cfg(test)] +mod tests { + use pinakes_plugin_api::UiElement; + + use super::*; + + fn create_test_page(id: &str, title: &str) -> UiPage { + UiPage { + id: id.to_string(), + title: title.to_string(), + route: format!("/plugins/test/{id}"), + icon: None, + root_element: UiElement::Container { + children: vec![], + gap: 16, + padding: None, + }, + data_sources: HashMap::new(), + } + } + + #[test] + fn test_registry_empty() { + let client = ApiClient::default(); + let registry = PluginRegistry::new(client); + assert!(registry.is_empty()); + assert_eq!(registry.len(), 0); + } + + #[test] + fn test_register_and_get_page() { + let client = ApiClient::default(); + let mut registry = PluginRegistry::new(client); + let page = create_test_page("demo", "Demo Page"); + + registry.register_page("my-plugin".to_string(), page.clone()); + + assert!(!registry.is_empty()); + assert_eq!(registry.len(), 1); + + let retrieved = registry.get_page("my-plugin", "demo"); + assert!(retrieved.is_some()); + assert_eq!(retrieved.unwrap().page.id, "demo"); + assert_eq!(retrieved.unwrap().page.title, "Demo Page"); + } + + #[test] + fn test_get_page_not_found() { + let client = ApiClient::default(); + let registry = PluginRegistry::new(client); + + let result = registry.get_page("nonexistent", "page"); + assert!(result.is_none()); + } + + #[test] + fn test_page_full_route() { + let client = ApiClient::default(); + let mut registry = PluginRegistry::new(client); + let page = create_test_page("demo", "Demo Page"); + + registry.register_page("my-plugin".to_string(), page.clone()); + + let plugin_page = registry.get_page("my-plugin", "demo").unwrap(); + assert_eq!(plugin_page.full_route(), "/plugins/my-plugin/demo"); + } + + #[test] + fn test_all_pages() { + let client = ApiClient::default(); + let mut registry = PluginRegistry::new(client); + + registry.register_page( + "plugin1".to_string(), + create_test_page("page1", "Page 1"), + ); + registry.register_page( + "plugin2".to_string(), + create_test_page("page2", "Page 2"), + ); + + let all = registry.all_pages(); + assert_eq!(all.len(), 2); + } + + #[test] + fn test_routes() { + let client = ApiClient::default(); + let mut registry = PluginRegistry::new(client); + + registry.register_page( + "plugin1".to_string(), + create_test_page("page1", "Page 1"), + ); + + let routes = registry.routes(); + assert_eq!(routes.len(), 1); + assert_eq!(routes[0].0, "plugin1"); + assert_eq!(routes[0].1, "page1"); + assert_eq!(routes[0].2, "/plugins/plugin1/page1"); + } +} -- 2.43.0 From a4bc48214f6009aa091294cb7bd27e69a8bafec5 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Mon, 9 Mar 2026 22:02:06 +0300 Subject: [PATCH 09/46] meta: ignore test configuration Signed-off-by: NotAShelf Change-Id: Id16794aa7425950097f9a20500148c216a6a6964 --- .gitignore | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.gitignore b/.gitignore index 28274bf..fd19e1e 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,5 @@ target/ # Runtime artifacts *.db* +test.toml + -- 2.43.0 From 7ad068b9301c8d85fc9c281036495a141572bca6 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Mon, 9 Mar 2026 22:02:28 +0300 Subject: [PATCH 10/46] pinakes-plugin-api: new UI widget types Signed-off-by: NotAShelf Change-Id: I83a72f3441e5370875239431123b0bbc6a6a6964 --- crates/pinakes-plugin-api/src/ui_schema.rs | 31 ++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/crates/pinakes-plugin-api/src/ui_schema.rs b/crates/pinakes-plugin-api/src/ui_schema.rs index f6c1e8d..783237e 100644 --- a/crates/pinakes-plugin-api/src/ui_schema.rs +++ b/crates/pinakes-plugin-api/src/ui_schema.rs @@ -230,6 +230,37 @@ impl UiPage { } } +/// A widget that plugins can inject into existing host pages. +/// +/// Widgets differ from pages in that they are embedded at a specific +/// `target` location within built-in views rather than occupying a full page. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct UiWidget { + /// Unique identifier for this widget within the plugin + pub id: String, + + /// Target injection point (matches `widget_location` constants) + pub target: String, + + /// Content to render at the injection point + pub content: UiElement, +} + +/// String constants for widget injection locations. +/// +/// Use these with `UiWidget::target` in plugin manifests: +/// ```toml +/// [[ui.widgets]] +/// id = "my-widget" +/// target = "library_header" +/// ``` +pub mod widget_location { + pub const LIBRARY_HEADER: &str = "library_header"; + pub const LIBRARY_SIDEBAR: &str = "library_sidebar"; + pub const DETAIL_PANEL: &str = "detail_panel"; + pub const SEARCH_FILTERS: &str = "search_filters"; +} + /// Core UI element enum - the building block of all plugin UIs /// /// Elements are categorized into groups: -- 2.43.0 From aa2a81e35402dc88ae3f3b91e6dac1fa8db2c393 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Tue, 10 Mar 2026 00:00:40 +0300 Subject: [PATCH 11/46] pinakes-plugin-api: add widgets field to manifest UI section Signed-off-by: NotAShelf Change-Id: I6fae2dc3c09702aa5a54d1e17a8175516a6a6964 --- crates/pinakes-plugin-api/src/manifest.rs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/crates/pinakes-plugin-api/src/manifest.rs b/crates/pinakes-plugin-api/src/manifest.rs index c611546..49c5748 100644 --- a/crates/pinakes-plugin-api/src/manifest.rs +++ b/crates/pinakes-plugin-api/src/manifest.rs @@ -11,6 +11,7 @@ use crate::{ FilesystemCapability, NetworkCapability, UiPage, + UiWidget, }; /// Plugin manifest file format (TOML) @@ -35,6 +36,10 @@ pub struct UiSection { /// UI pages defined by this plugin #[serde(default)] pub pages: Vec, + + /// Widgets to inject into existing host pages + #[serde(default)] + pub widgets: Vec, } /// Entry for a UI page in the manifest - can be inline or file reference -- 2.43.0 From 21572541c3a38c96301d63f60f4ceba64bedf4b6 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Tue, 10 Mar 2026 00:00:55 +0300 Subject: [PATCH 12/46] pinakes-plugin-api: schema validation for page and widget schemas Signed-off-by: NotAShelf Change-Id: I70480786fc8e4a86731560aaca6993ce6a6a6964 --- crates/pinakes-plugin-api/src/lib.rs | 1 + crates/pinakes-plugin-api/src/validation.rs | 583 ++++++++++++++++++++ 2 files changed, 584 insertions(+) create mode 100644 crates/pinakes-plugin-api/src/validation.rs diff --git a/crates/pinakes-plugin-api/src/lib.rs b/crates/pinakes-plugin-api/src/lib.rs index 34e806f..5440669 100644 --- a/crates/pinakes-plugin-api/src/lib.rs +++ b/crates/pinakes-plugin-api/src/lib.rs @@ -16,6 +16,7 @@ use thiserror::Error; pub mod manifest; pub mod types; pub mod ui_schema; +pub mod validation; pub mod wasm; pub use manifest::PluginManifest; diff --git a/crates/pinakes-plugin-api/src/validation.rs b/crates/pinakes-plugin-api/src/validation.rs new file mode 100644 index 0000000..d232f29 --- /dev/null +++ b/crates/pinakes-plugin-api/src/validation.rs @@ -0,0 +1,583 @@ +//! Schema validation for plugin UI pages +//! +//! Provides comprehensive validation of [`UiPage`] and [`UiElement`] trees +//! before they are rendered. Call [`SchemaValidator::validate_page`] before +//! registering a plugin page. + +use thiserror::Error; + +use crate::{DataSource, UiElement, UiPage, UiWidget}; + +/// Reserved routes that plugins cannot use +const RESERVED_ROUTES: &[&str] = &[ + "/", + "/search", + "/settings", + "/admin", + "/library", + "/books", + "/tags", + "/collections", + "/audit", + "/import", + "/duplicates", + "/statistics", + "/tasks", + "/database", + "/graph", +]; + +/// Errors produced by schema validation +#[derive(Debug, Error)] +pub enum ValidationError { + /// A single validation failure + #[error("Validation error: {0}")] + Single(String), + + /// Multiple validation failures collected in one pass + #[error("Validation failed with {} errors: {}", .0.len(), .0.join("; "))] + Multiple(Vec), +} + +/// Validates plugin UI schemas before they are loaded into the registry. +/// +/// # Example +/// +/// ```rust,ignore +/// let page = plugin.manifest.ui.pages[0].clone(); +/// SchemaValidator::validate_page(&page)?; +/// ``` +pub struct SchemaValidator; + +impl SchemaValidator { + /// Validate a complete [`UiPage`] definition. + /// + /// Checks: + /// - Page ID format (alphanumeric + dash/underscore, starts with a letter) + /// - Route starts with `'/'` and is not reserved + /// - `DataTable` elements have at least one column + /// - Form elements have at least one field + /// - Loop and Conditional elements have valid structure + /// + /// # Errors + /// + /// Returns [`ValidationError::Multiple`] containing all collected errors + /// so callers can surface all problems at once. + pub fn validate_page(page: &UiPage) -> Result<(), ValidationError> { + let mut errors = Vec::new(); + + // ID format + if !Self::is_valid_id(&page.id) { + errors.push(format!( + "Invalid page ID '{}': must start with a letter and contain only \ + alphanumeric characters, dashes, or underscores", + page.id + )); + } + + // Route format + if !page.route.starts_with('/') { + errors.push(format!("Route must start with '/': {}", page.route)); + } + + // Reserved routes + if Self::is_reserved_route(&page.route) { + errors.push(format!("Route is reserved by the host: {}", page.route)); + } + + // Validate data sources + for (name, source) in &page.data_sources { + Self::validate_data_source(name, source, &mut errors); + } + + // Recursively validate element tree + Self::validate_element(&page.root_element, &mut errors); + + if errors.is_empty() { + Ok(()) + } else { + Err(ValidationError::Multiple(errors)) + } + } + + /// Validate a [`UiWidget`] definition. + /// + /// # Errors + /// + /// Returns [`ValidationError::Multiple`] with all collected errors. + pub fn validate_widget(widget: &UiWidget) -> Result<(), ValidationError> { + let mut errors = Vec::new(); + + if !Self::is_valid_id(&widget.id) { + errors.push(format!( + "Invalid widget ID '{}': must start with a letter and contain only \ + alphanumeric characters, dashes, or underscores", + widget.id + )); + } + + if widget.target.is_empty() { + errors.push("Widget target must not be empty".to_string()); + } + + Self::validate_element(&widget.content, &mut errors); + + if errors.is_empty() { + Ok(()) + } else { + Err(ValidationError::Multiple(errors)) + } + } + + /// Recursively validate a [`UiElement`] subtree. + pub fn validate_element(element: &UiElement, errors: &mut Vec) { + match element { + UiElement::Container { children, .. } => { + for child in children { + Self::validate_element(child, errors); + } + }, + + UiElement::Grid { children, .. } => { + for child in children { + Self::validate_element(child, errors); + } + }, + + UiElement::Flex { children, .. } => { + for child in children { + Self::validate_element(child, errors); + } + }, + + UiElement::Split { sidebar, main, .. } => { + Self::validate_element(sidebar, errors); + Self::validate_element(main, errors); + }, + + UiElement::Tabs { tabs, .. } => { + if tabs.is_empty() { + errors.push("Tabs element must have at least one tab".to_string()); + } + for tab in tabs { + Self::validate_element(&tab.content, errors); + } + }, + + UiElement::DataTable { data, columns, .. } => { + if data.is_empty() { + errors + .push("DataTable 'data' source key must not be empty".to_string()); + } + if columns.is_empty() { + errors.push("DataTable must have at least one column".to_string()); + } + }, + + UiElement::Form { fields, .. } => { + if fields.is_empty() { + errors.push("Form must have at least one field".to_string()); + } + for field in fields { + if field.id.is_empty() { + errors.push("Form field id must not be empty".to_string()); + } + } + }, + + UiElement::Conditional { + then, else_element, .. + } => { + Self::validate_element(then, errors); + if let Some(else_branch) = else_element { + Self::validate_element(else_branch, errors); + } + }, + + UiElement::Loop { template, .. } => { + Self::validate_element(template, errors); + }, + + UiElement::Card { + content, footer, .. + } => { + for child in content.iter().chain(footer.iter()) { + Self::validate_element(child, errors); + } + }, + + UiElement::List { data, .. } => { + if data.is_empty() { + errors.push("List 'data' source key must not be empty".to_string()); + } + }, + + // Leaf elements with no children to recurse into + UiElement::Heading { .. } + | UiElement::Text { .. } + | UiElement::Code { .. } + | UiElement::MediaGrid { .. } + | UiElement::DescriptionList { .. } + | UiElement::Button { .. } + | UiElement::Link { .. } + | UiElement::Progress { .. } + | UiElement::Badge { .. } + | UiElement::Chart { .. } => {}, + } + } + + fn validate_data_source( + name: &str, + source: &DataSource, + errors: &mut Vec, + ) { + match source { + DataSource::Endpoint { path, .. } => { + if path.is_empty() { + errors.push(format!( + "Data source '{name}': endpoint path must not be empty" + )); + } + if !path.starts_with('/') { + errors.push(format!( + "Data source '{name}': endpoint path must start with '/': {path}" + )); + } + }, + DataSource::Transform { source_name, .. } => { + if source_name.is_empty() { + errors.push(format!( + "Data source '{name}': transform source_name must not be empty" + )); + } + }, + DataSource::Static { .. } => {}, + } + } + + fn is_valid_id(id: &str) -> bool { + if id.is_empty() || id.len() > 64 { + return false; + } + let mut chars = id.chars(); + chars.next().is_some_and(|c| c.is_ascii_alphabetic()) + && chars.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-') + } + + fn is_reserved_route(route: &str) -> bool { + RESERVED_ROUTES.iter().any(|reserved| { + route == *reserved || route.starts_with(&format!("{reserved}/")) + }) + } +} + +#[cfg(test)] +mod tests { + use std::collections::HashMap; + + use super::*; + use crate::UiElement; + + fn make_page(id: &str, route: &str) -> UiPage { + UiPage { + id: id.to_string(), + title: "Test Page".to_string(), + route: route.to_string(), + icon: None, + root_element: UiElement::Container { + children: vec![], + gap: 0, + padding: None, + }, + data_sources: HashMap::new(), + } + } + + #[test] + fn test_valid_page() { + let page = make_page("my-plugin-page", "/plugins/test/page"); + assert!(SchemaValidator::validate_page(&page).is_ok()); + } + + #[test] + fn test_invalid_id_starts_with_digit() { + let page = make_page("1invalid", "/plugins/test/page"); + assert!(SchemaValidator::validate_page(&page).is_err()); + } + + #[test] + fn test_invalid_id_empty() { + let page = make_page("", "/plugins/test/page"); + assert!(SchemaValidator::validate_page(&page).is_err()); + } + + #[test] + fn test_reserved_route() { + let page = make_page("my-page", "/settings"); + assert!(SchemaValidator::validate_page(&page).is_err()); + } + + #[test] + fn test_route_missing_slash() { + let page = make_page("my-page", "plugins/test"); + assert!(SchemaValidator::validate_page(&page).is_err()); + } + + #[test] + fn test_datatable_no_columns() { + let mut page = make_page("my-page", "/plugins/test/page"); + page.root_element = UiElement::DataTable { + data: "items".to_string(), + columns: vec![], + sortable: false, + filterable: false, + page_size: 0, + row_actions: vec![], + }; + let result = SchemaValidator::validate_page(&page); + assert!(result.is_err()); + let err = result.unwrap_err().to_string(); + assert!(err.contains("at least one column")); + } + + #[test] + fn test_form_no_fields() { + let mut page = make_page("my-page", "/plugins/test/page"); + page.root_element = UiElement::Form { + fields: vec![], + submit_action: crate::ActionRef::Name("submit".to_string()), + submit_label: "Submit".to_string(), + cancel_label: None, + }; + let result = SchemaValidator::validate_page(&page); + assert!(result.is_err()); + let err = result.unwrap_err().to_string(); + assert!(err.contains("at least one field")); + } + + #[test] + fn test_multiple_errors_collected() { + let page = make_page("1bad-id", "/settings"); + let result = SchemaValidator::validate_page(&page); + assert!(result.is_err()); + match result.unwrap_err() { + ValidationError::Multiple(errs) => assert!(errs.len() >= 2), + ValidationError::Single(_) => panic!("expected Multiple"), + } + } + + #[test] + fn test_reserved_route_subpath_rejected() { + // A sub-path of a reserved route must also be rejected + for route in &[ + "/settings/theme", + "/admin/users", + "/library/foo", + "/search/advanced", + "/tasks/pending", + ] { + let page = make_page("my-page", route); + let result = SchemaValidator::validate_page(&page); + assert!( + result.is_err(), + "expected error for sub-path of reserved route: {route}" + ); + } + } + + #[test] + fn test_plugin_route_not_reserved() { + // Routes under /plugins/ are allowed (not in RESERVED_ROUTES) + let page = make_page("my-page", "/plugins/my-plugin/page"); + assert!(SchemaValidator::validate_page(&page).is_ok()); + } + + #[test] + fn test_id_max_length_accepted() { + let id = "a".repeat(64); + let page = make_page(&id, "/plugins/test"); + assert!(SchemaValidator::validate_page(&page).is_ok()); + } + + #[test] + fn test_id_too_long_rejected() { + let id = "a".repeat(65); + let page = make_page(&id, "/plugins/test"); + assert!(SchemaValidator::validate_page(&page).is_err()); + } + + #[test] + fn test_id_with_dash_and_underscore() { + let page = make_page("my-plugin_page", "/plugins/test"); + assert!(SchemaValidator::validate_page(&page).is_ok()); + } + + #[test] + fn test_id_with_special_chars_rejected() { + let page = make_page("my page!", "/plugins/test"); + assert!(SchemaValidator::validate_page(&page).is_err()); + } + + #[test] + fn test_datatable_empty_data_key() { + let col: crate::ColumnDef = + serde_json::from_value(serde_json::json!({"key": "id", "header": "ID"})) + .unwrap(); + let mut page = make_page("my-page", "/plugins/test/page"); + page.root_element = UiElement::DataTable { + data: String::new(), + columns: vec![col], + sortable: false, + filterable: false, + page_size: 0, + row_actions: vec![], + }; + assert!(SchemaValidator::validate_page(&page).is_err()); + } + + #[test] + fn test_form_field_empty_id_rejected() { + let field: crate::FormField = serde_json::from_value( + serde_json::json!({"id": "", "label": "Name", "type": {"type": "text"}}), + ) + .unwrap(); + let mut page = make_page("my-page", "/plugins/test/page"); + page.root_element = UiElement::Form { + fields: vec![field], + submit_action: crate::ActionRef::Name("submit".to_string()), + submit_label: "Submit".to_string(), + cancel_label: None, + }; + let result = SchemaValidator::validate_page(&page); + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("field id")); + } + + #[test] + fn test_valid_widget() { + let widget = crate::UiWidget { + id: "my-widget".to_string(), + target: "library_header".to_string(), + content: UiElement::Container { + children: vec![], + gap: 0, + padding: None, + }, + }; + assert!(SchemaValidator::validate_widget(&widget).is_ok()); + } + + #[test] + fn test_widget_invalid_id() { + let widget = crate::UiWidget { + id: "1bad".to_string(), + target: "library_header".to_string(), + content: UiElement::Container { + children: vec![], + gap: 0, + padding: None, + }, + }; + assert!(SchemaValidator::validate_widget(&widget).is_err()); + } + + #[test] + fn test_widget_empty_target() { + let widget = crate::UiWidget { + id: "my-widget".to_string(), + target: String::new(), + content: UiElement::Container { + children: vec![], + gap: 0, + padding: None, + }, + }; + assert!(SchemaValidator::validate_widget(&widget).is_err()); + } + + #[test] + fn test_data_source_empty_endpoint_path() { + use crate::{DataSource, HttpMethod}; + let mut page = make_page("my-page", "/plugins/test"); + page + .data_sources + .insert("items".to_string(), DataSource::Endpoint { + path: String::new(), + method: HttpMethod::Get, + params: Default::default(), + poll_interval: 0, + transform: None, + }); + assert!(SchemaValidator::validate_page(&page).is_err()); + } + + #[test] + fn test_data_source_endpoint_path_no_leading_slash() { + use crate::{DataSource, HttpMethod}; + let mut page = make_page("my-page", "/plugins/test"); + page + .data_sources + .insert("items".to_string(), DataSource::Endpoint { + path: "api/v1/items".to_string(), + method: HttpMethod::Get, + params: Default::default(), + poll_interval: 0, + transform: None, + }); + assert!(SchemaValidator::validate_page(&page).is_err()); + } + + #[test] + fn test_data_source_endpoint_valid() { + use crate::{DataSource, HttpMethod}; + let mut page = make_page("my-page", "/plugins/test"); + page + .data_sources + .insert("items".to_string(), DataSource::Endpoint { + path: "/api/v1/items".to_string(), + method: HttpMethod::Get, + params: Default::default(), + poll_interval: 0, + transform: None, + }); + assert!(SchemaValidator::validate_page(&page).is_ok()); + } + + #[test] + fn test_data_source_transform_empty_source_name() { + use crate::DataSource; + let mut page = make_page("my-page", "/plugins/test"); + page + .data_sources + .insert("derived".to_string(), DataSource::Transform { + source_name: String::new(), + expression: crate::Expression::Literal(serde_json::Value::Null), + }); + assert!(SchemaValidator::validate_page(&page).is_err()); + } + + #[test] + fn test_tabs_empty_rejected() { + let mut page = make_page("my-page", "/plugins/test"); + page.root_element = UiElement::Tabs { + tabs: vec![], + default_tab: 0, + }; + assert!(SchemaValidator::validate_page(&page).is_err()); + } + + #[test] + fn test_list_empty_data_key() { + let mut page = make_page("my-page", "/plugins/test"); + page.root_element = UiElement::List { + data: String::new(), + item_template: Box::new(UiElement::Container { + children: vec![], + gap: 0, + padding: None, + }), + dividers: false, + }; + assert!(SchemaValidator::validate_page(&page).is_err()); + } +} -- 2.43.0 From ed8ad73497d8733127378785368b32b4d0efd5b5 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Tue, 10 Mar 2026 00:01:11 +0300 Subject: [PATCH 13/46] pinakes-core: fix `list_ui_pages\' doc comment Signed-off-by: NotAShelf Change-Id: I5f82a35656aa536effe6a159b0cdd2066a6a6964 --- crates/pinakes-core/src/plugin/mod.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/pinakes-core/src/plugin/mod.rs b/crates/pinakes-core/src/plugin/mod.rs index 5d47ed3..b419e0d 100644 --- a/crates/pinakes-core/src/plugin/mod.rs +++ b/crates/pinakes-core/src/plugin/mod.rs @@ -602,7 +602,7 @@ impl PluginManager { /// List all UI pages provided by loaded plugins. /// /// Returns a vector of `(plugin_id, page)` tuples for all enabled plugins - /// that have the `ui_page` kind and provide pages in their manifests. + /// that provide pages in their manifests. pub async fn list_ui_pages( &self, ) -> Vec<(String, pinakes_plugin_api::UiPage)> { -- 2.43.0 From 62058a7c4d9f2b84b72229ae673ff093dc6254ea Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Tue, 10 Mar 2026 00:01:23 +0300 Subject: [PATCH 14/46] pinakes-ui: fix `ApiClient` clone to preserve auth token Signed-off-by: NotAShelf Change-Id: I2009ce790b3864105082997be12fe5b56a6a6964 --- crates/pinakes-ui/src/client.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/crates/pinakes-ui/src/client.rs b/crates/pinakes-ui/src/client.rs index b80bec6..3aaaf10 100644 --- a/crates/pinakes-ui/src/client.rs +++ b/crates/pinakes-ui/src/client.rs @@ -26,7 +26,10 @@ pub struct ApiClient { impl Clone for ApiClient { fn clone(&self) -> Self { - Self::new(&self.base_url, None) + Self { + client: self.client.clone(), + base_url: self.base_url.clone(), + } } } -- 2.43.0 From 1acff0227c5fb74d4688f2fc98fd0ff3b7b696fa Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Tue, 10 Mar 2026 00:01:39 +0300 Subject: [PATCH 15/46] pinakes-ui: add `WidgetContainer`; basic widget injection system Signed-off-by: NotAShelf Change-Id: I7ca9a47e9b085b8c49869586c90034816a6a6964 --- crates/pinakes-ui/src/plugin_ui/mod.rs | 4 +- crates/pinakes-ui/src/plugin_ui/widget.rs | 118 ++++++++++++++++++++++ 2 files changed, 121 insertions(+), 1 deletion(-) create mode 100644 crates/pinakes-ui/src/plugin_ui/widget.rs diff --git a/crates/pinakes-ui/src/plugin_ui/mod.rs b/crates/pinakes-ui/src/plugin_ui/mod.rs index fc40360..1684cb9 100644 --- a/crates/pinakes-ui/src/plugin_ui/mod.rs +++ b/crates/pinakes-ui/src/plugin_ui/mod.rs @@ -33,6 +33,8 @@ pub mod actions; pub mod data; pub mod registry; pub mod renderer; +pub mod widget; -pub use registry::{PluginPage, PluginRegistry}; +pub use registry::PluginRegistry; pub use renderer::PluginViewRenderer; +pub use widget::{WidgetContainer, WidgetLocation}; diff --git a/crates/pinakes-ui/src/plugin_ui/widget.rs b/crates/pinakes-ui/src/plugin_ui/widget.rs new file mode 100644 index 0000000..35ba4fa --- /dev/null +++ b/crates/pinakes-ui/src/plugin_ui/widget.rs @@ -0,0 +1,118 @@ +//! Widget injection system for plugin UI +//! +//! Allows plugins to inject small UI elements into existing host pages at +//! predefined locations. Unlike full pages, widgets have no data sources of +//! their own and render with empty data context. + +use dioxus::prelude::*; +use pinakes_plugin_api::UiWidget; + +use super::{data::PluginPageData, renderer::render_element}; +use crate::client::ApiClient; + +/// Predefined injection points in the host UI. +/// +/// These correspond to the string constants in +/// `pinakes_plugin_api::widget_location` and determine where a widget is +/// rendered. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum WidgetLocation { + LibraryHeader, + LibrarySidebar, + DetailPanel, + SearchFilters, +} + +impl WidgetLocation { + /// Returns the canonical string identifier used in plugin manifests. + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + Self::LibraryHeader => "library_header", + Self::LibrarySidebar => "library_sidebar", + Self::DetailPanel => "detail_panel", + Self::SearchFilters => "search_filters", + } + } +} + +/// Props for [`WidgetContainer`]. +#[derive(Props, PartialEq, Clone)] +pub struct WidgetContainerProps { + /// Injection point to render widgets for. + pub location: WidgetLocation, + /// All widgets from all plugins (plugin_id, widget) pairs. + pub widgets: Vec<(String, UiWidget)>, + /// API client (unused by widgets themselves but threaded through for + /// consistency with the rest of the plugin UI system). + pub client: Signal, +} + +/// Renders all widgets registered for a specific [`WidgetLocation`]. +/// +/// Returns `None` if no widgets target this location. +/// +/// # Usage +/// +/// ```rust,ignore +/// // In a host component: +/// WidgetContainer { +/// location: WidgetLocation::LibraryHeader, +/// widgets: plugin_registry.read().all_widgets(), +/// client, +/// } +/// ``` +#[component] +pub fn WidgetContainer(props: WidgetContainerProps) -> Element { + let location_str = props.location.as_str(); + let matching: Vec<_> = props + .widgets + .iter() + .filter(|(_, w)| w.target == location_str) + .cloned() + .collect(); + + if matching.is_empty() { + return rsx! {}; + } + + rsx! { + div { class: "plugin-widget-container", "data-location": location_str, + for (plugin_id , widget) in &matching { + WidgetViewRenderer { + plugin_id: plugin_id.clone(), + widget: widget.clone(), + client: props.client, + } + } + } + } +} + +/// Props for [`WidgetViewRenderer`]. +#[derive(Props, PartialEq, Clone)] +pub struct WidgetViewRendererProps { + /// Plugin that owns this widget. + pub plugin_id: String, + /// Widget definition to render. + pub widget: UiWidget, + /// API client signal. + pub client: Signal, +} + +/// Renders a single plugin widget with an empty data context. +/// +/// Widgets do not declare data sources; they render statically (or use +/// inline expressions with no external data). +#[component] +pub fn WidgetViewRenderer(props: WidgetViewRendererProps) -> Element { + let empty_data = PluginPageData::default(); + rsx! { + div { + class: "plugin-widget", + "data-plugin-id": props.plugin_id.clone(), + "data-widget-id": props.widget.id.clone(), + { render_element(&props.widget.content, &empty_data, props.client) } + } + } +} -- 2.43.0 From e55fd5cc989071d539053715b3945db36879ba0c Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Tue, 10 Mar 2026 00:01:56 +0300 Subject: [PATCH 16/46] pinakes-ui: fix plugin page data loading; add `as_json` helper Signed-off-by: NotAShelf Change-Id: I6d80eff06e9ca46f916e643d5d8bb6c86a6a6964 --- crates/pinakes-ui/src/plugin_ui/data.rs | 144 +++++++++++++++++++++++- 1 file changed, 142 insertions(+), 2 deletions(-) diff --git a/crates/pinakes-ui/src/plugin_ui/data.rs b/crates/pinakes-ui/src/plugin_ui/data.rs index eba6d57..2f4e2d0 100644 --- a/crates/pinakes-ui/src/plugin_ui/data.rs +++ b/crates/pinakes-ui/src/plugin_ui/data.rs @@ -10,7 +10,7 @@ use pinakes_plugin_api::{DataSource, HttpMethod}; use crate::client::ApiClient; /// Cached data for a plugin page -#[derive(Debug, Clone, Default)] +#[derive(Debug, Clone, Default, PartialEq)] pub struct PluginPageData { data: HashMap, loading: HashMap, @@ -62,6 +62,19 @@ impl PluginPageData { self.errors.insert(source, error); } + /// Convert all resolved data to a single JSON object for expression + /// evaluation + #[must_use] + pub fn as_json(&self) -> serde_json::Value { + serde_json::Value::Object( + self + .data + .iter() + .map(|(k, v)| (k.clone(), v.clone())) + .collect(), + ) + } + /// Clear all data pub fn clear(&mut self) { self.data.clear(); @@ -114,7 +127,18 @@ pub async fn fetch_page_data( ) -> Result, String> { let mut results = HashMap::new(); - for (name, source) in data_sources { + // Process non-Transform sources first so Transform sources can reference them + let mut ordered: Vec<(&String, &DataSource)> = data_sources + .iter() + .filter(|(_, s)| !matches!(s, DataSource::Transform { .. })) + .collect(); + ordered.extend( + data_sources + .iter() + .filter(|(_, s)| matches!(s, DataSource::Transform { .. })), + ); + + for (name, source) in ordered { let value = match source { DataSource::Endpoint { path, method, .. } => { // Fetch from endpoint (ignoring params, poll_interval, transform for @@ -168,11 +192,13 @@ pub fn use_plugin_data( match fetch_page_data(&client.read(), &sources).await { Ok(results) => { for (name, value) in results { + data.write().set_loading(&name, false); data.write().set_data(name, value); } }, Err(e) => { for name in sources.keys() { + data.write().set_loading(name, false); data.write().set_error(name.clone(), e.clone()); } }, @@ -270,4 +296,118 @@ mod tests { let data = serde_json::json!({"foo": "bar"}); assert!(get_json_path(&data, "nonexistent").is_none()); } + + #[test] + fn test_get_json_path_array_out_of_bounds() { + let data = serde_json::json!({"items": ["a"]}); + assert!(get_json_path(&data, "items.5").is_none()); + } + + #[test] + fn test_get_json_path_non_array_index() { + let data = serde_json::json!({"foo": "bar"}); + assert!(get_json_path(&data, "foo.0").is_none()); + } + + #[test] + fn test_as_json_empty() { + let data = PluginPageData::default(); + assert_eq!(data.as_json(), serde_json::json!({})); + } + + #[test] + fn test_as_json_with_data() { + let mut data = PluginPageData::default(); + data.set_data("users".to_string(), serde_json::json!([{"id": 1}])); + data.set_data("count".to_string(), serde_json::json!(42)); + let json = data.as_json(); + assert_eq!(json["users"], serde_json::json!([{"id": 1}])); + assert_eq!(json["count"], serde_json::json!(42)); + } + + #[test] + fn test_set_loading_true_clears_error() { + let mut data = PluginPageData::default(); + data.set_error("src".to_string(), "oops".to_string()); + assert!(data.error("src").is_some()); + data.set_loading("src", true); + assert!(data.error("src").is_none()); + assert!(data.is_loading("src")); + } + + #[test] + fn test_set_loading_false_removes_flag() { + let mut data = PluginPageData::default(); + data.set_loading("src", true); + assert!(data.is_loading("src")); + data.set_loading("src", false); + assert!(!data.is_loading("src")); + } + + #[test] + fn test_clear_resets_all_state() { + let mut data = PluginPageData::default(); + data.set_data("x".to_string(), serde_json::json!(1)); + data.set_loading("x", true); + data.set_error("y".to_string(), "err".to_string()); + data.clear(); + assert!(!data.has_data("x")); + assert!(!data.is_loading("x")); + assert!(data.error("y").is_none()); + } + + #[test] + fn test_partial_eq() { + let mut a = PluginPageData::default(); + let mut b = PluginPageData::default(); + assert_eq!(a, b); + a.set_data("k".to_string(), serde_json::json!(1)); + assert_ne!(a, b); + b.set_data("k".to_string(), serde_json::json!(1)); + assert_eq!(a, b); + } + + #[tokio::test] + async fn test_fetch_page_data_static_only() { + use pinakes_plugin_api::DataSource; + + use crate::client::ApiClient; + + let client = ApiClient::default(); + let mut sources = HashMap::new(); + sources.insert("nums".to_string(), DataSource::Static { + value: serde_json::json!([1, 2, 3]), + }); + sources.insert("flag".to_string(), DataSource::Static { + value: serde_json::json!(true), + }); + + let results = super::fetch_page_data(&client, &sources).await.unwrap(); + assert_eq!(results["nums"], serde_json::json!([1, 2, 3])); + assert_eq!(results["flag"], serde_json::json!(true)); + } + + #[tokio::test] + async fn test_fetch_page_data_transform_after_static() { + use pinakes_plugin_api::{DataSource, Expression}; + + use crate::client::ApiClient; + + let client = ApiClient::default(); + let mut sources = HashMap::new(); + // Insert Transform before Static in the map to test ordering + sources.insert("derived".to_string(), DataSource::Transform { + source_name: "raw".to_string(), + expression: Expression::Literal(serde_json::Value::Null), + }); + sources.insert("raw".to_string(), DataSource::Static { + value: serde_json::json!({"ok": true}), + }); + + let results = super::fetch_page_data(&client, &sources).await.unwrap(); + // raw must have been processed before derived + assert_eq!(results["raw"], serde_json::json!({"ok": true})); + // derived gets source_data from raw (transform is identity for now) + assert_eq!(results["derived"], serde_json::json!({"ok": true})); + } } -- 2.43.0 From 188f9a7b8d33f04b403d652f58939cf98c1e6e2a Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Tue, 10 Mar 2026 00:02:09 +0300 Subject: [PATCH 17/46] pinakes-ui: fix action param precedence and non-JSON 2xx handling Signed-off-by: NotAShelf Change-Id: Iac5d1f3d2ed5c85c3e1f3d0f259235056a6a6964 --- crates/pinakes-ui/src/plugin_ui/actions.rs | 53 +++++++++++++++++++++- 1 file changed, 51 insertions(+), 2 deletions(-) diff --git a/crates/pinakes-ui/src/plugin_ui/actions.rs b/crates/pinakes-ui/src/plugin_ui/actions.rs index 6da8aa2..f123359 100644 --- a/crates/pinakes-ui/src/plugin_ui/actions.rs +++ b/crates/pinakes-ui/src/plugin_ui/actions.rs @@ -82,10 +82,11 @@ async fn execute_inline_action( .iter() .map(|(k, v)| (k.clone(), v.clone())) .collect(); + // action.params take precedence; form_data only fills in missing keys if let Some(fd) = form_data { if let Some(obj) = fd.as_object() { for (k, v) in obj { - merged.insert(k.clone(), v.clone()); + merged.entry(k.clone()).or_insert_with(|| v.clone()); } } } @@ -115,7 +116,7 @@ async fn execute_inline_action( } let value: serde_json::Value = - response.json().await.map_err(|e| e.to_string())?; + response.json().await.unwrap_or(serde_json::Value::Null); if let Some(route) = &action.navigate_to { return Ok(ActionResult::Navigate(route.clone())); @@ -126,6 +127,8 @@ async fn execute_inline_action( #[cfg(test)] mod tests { + use pinakes_plugin_api::ActionRef; + use super::*; #[test] @@ -135,4 +138,50 @@ mod tests { let _ = ActionResult::Error("error".to_string()); let _ = ActionResult::Navigate("/page".to_string()); } + + #[test] + fn test_action_result_clone() { + let original = ActionResult::Success(serde_json::json!({"key": "value"})); + let cloned = original.clone(); + if let (ActionResult::Success(a), ActionResult::Success(b)) = + (original, cloned) + { + assert_eq!(a, b); + } else { + panic!("clone produced wrong variant"); + } + } + + #[test] + fn test_action_result_error_clone() { + let original = ActionResult::Error("something went wrong".to_string()); + let cloned = original.clone(); + if let (ActionResult::Error(a), ActionResult::Error(b)) = (original, cloned) + { + assert_eq!(a, b); + } else { + panic!("clone produced wrong variant"); + } + } + + #[test] + fn test_action_result_navigate_clone() { + let original = ActionResult::Navigate("/dashboard".to_string()); + let cloned = original.clone(); + if let (ActionResult::Navigate(a), ActionResult::Navigate(b)) = + (original, cloned) + { + assert_eq!(a, b); + } else { + panic!("clone produced wrong variant"); + } + } + + #[tokio::test] + async fn test_named_action_ref_returns_none() { + let client = crate::client::ApiClient::default(); + let action_ref = ActionRef::Name("my-action".to_string()); + let result = execute_action(&client, &action_ref, None).await.unwrap(); + assert!(matches!(result, ActionResult::None)); + } } -- 2.43.0 From de913e54bced0fd54328a2cd6f6bc00f51872946 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Tue, 10 Mar 2026 00:02:16 +0300 Subject: [PATCH 18/46] pinakes-ui: rewrite renderer with interactive tabs; correct data context; per-item loop binding Signed-off-by: NotAShelf Change-Id: Iebe4945e1fcb7c28ff74a76e9d0717276a6a6964 --- crates/pinakes-ui/src/plugin_ui/renderer.rs | 622 +++++++++++++++++--- 1 file changed, 548 insertions(+), 74 deletions(-) diff --git a/crates/pinakes-ui/src/plugin_ui/renderer.rs b/crates/pinakes-ui/src/plugin_ui/renderer.rs index b8e3586..5f6f4c3 100644 --- a/crates/pinakes-ui/src/plugin_ui/renderer.rs +++ b/crates/pinakes-ui/src/plugin_ui/renderer.rs @@ -14,6 +14,7 @@ use pinakes_plugin_api::{ FieldType, FlexDirection, JustifyContent, + TabDefinition, TextContent, TextVariant, UiElement, @@ -21,7 +22,7 @@ use pinakes_plugin_api::{ }; use super::{ - actions::{ActionResult, execute_action}, + actions::execute_action, data::{PluginPageData, use_plugin_data}, }; use crate::client::ApiClient; @@ -52,13 +53,78 @@ pub fn PluginViewRenderer(props: PluginViewProps) -> Element { class: "plugin-page", "data-plugin-id": props.plugin_id.clone(), h2 { class: "plugin-page-title", "{page.title}" } - { render_element(&page.root_element, &page_data.read()) } + { render_element(&page.root_element, &page_data.read(), props.client) } + } + } +} + +/// Props for the interactive [`PluginTabs`] component. +#[derive(Props, PartialEq, Clone)] +struct PluginTabsProps { + tabs: Vec, + default_tab: usize, + data: PluginPageData, + client: Signal, +} + +/// Renders a tabbed interface with interactive tab switching. +#[component] +fn PluginTabs(props: PluginTabsProps) -> Element { + let mut active = use_signal(|| props.default_tab); + let active_idx = *active.read(); + + rsx! { + div { class: "plugin-tabs", + div { class: "plugin-tab-list", role: "tablist", + for (idx, tab) in props.tabs.iter().enumerate() { + { + let tab_label = tab.label.clone(); + let tab_icon = tab.icon.clone(); + let is_active = idx == active_idx; + rsx! { + button { + class: if is_active { "plugin-tab active" } else { "plugin-tab" }, + role: "tab", + aria_selected: if is_active { "true" } else { "false" }, + onclick: move |_| active.set(idx), + if let Some(icon) = tab_icon { + span { class: "tab-icon", "{icon}" } + } + "{tab_label}" + } + } + } + } + } + div { class: "plugin-tab-panels", + for (idx, tab) in props.tabs.iter().enumerate() { + { + let is_active = idx == active_idx; + let content = tab.content.clone(); + rsx! { + div { + class: if is_active { + "plugin-tab-panel active" + } else { + "plugin-tab-panel" + }, + hidden: !is_active, + { render_element(&content, &props.data, props.client) } + } + } + } + } + } } } } /// Render a single [`UiElement`] with the provided data context. -fn render_element(element: &UiElement, data: &PluginPageData) -> Element { +pub(crate) fn render_element( + element: &UiElement, + data: &PluginPageData, + client: Signal, +) -> Element { match element { // Layout containers UiElement::Container { @@ -78,7 +144,7 @@ fn render_element(element: &UiElement, data: &PluginPageData) -> Element { class: "plugin-container", style: "{style}", for child in children { - { render_element(child, data) } + { render_element(child, data, client) } } } } @@ -97,7 +163,7 @@ fn render_element(element: &UiElement, data: &PluginPageData) -> Element { class: "plugin-grid", style: "{style}", for child in children { - { render_element(child, data) } + { render_element(child, data, client) } } } } @@ -124,7 +190,7 @@ fn render_element(element: &UiElement, data: &PluginPageData) -> Element { class: "plugin-flex", style: "{style}", for child in children { - { render_element(child, data) } + { render_element(child, data, client) } } } } @@ -142,12 +208,12 @@ fn render_element(element: &UiElement, data: &PluginPageData) -> Element { aside { class: "plugin-split-sidebar", style: "width:{sidebar_width}px;flex-shrink:0;", - { render_element(sidebar, data) } + { render_element(sidebar, data, client) } } main { class: "plugin-split-main", style: "flex:1;min-width:0;", - { render_element(main, data) } + { render_element(main, data, client) } } } } @@ -155,40 +221,19 @@ fn render_element(element: &UiElement, data: &PluginPageData) -> Element { UiElement::Tabs { tabs, default_tab } => { rsx! { - div { - class: "plugin-tabs", - div { - class: "plugin-tab-list", - role: "tablist", - for (idx, tab) in tabs.iter().enumerate() { - button { - class: if idx == *default_tab { "plugin-tab active" } else { "plugin-tab" }, - role: "tab", - aria_selected: if idx == *default_tab { "true" } else { "false" }, - if let Some(icon) = &tab.icon { - span { class: "tab-icon", "{icon}" } - } - "{tab.label}" - } - } - } - div { - class: "plugin-tab-panels", - for (idx, tab) in tabs.iter().enumerate() { - div { - class: if idx == *default_tab { "plugin-tab-panel active" } else { "plugin-tab-panel" }, - hidden: idx != *default_tab, - { render_element(&tab.content, data) } - } - } - } + PluginTabs { + tabs: tabs.clone(), + default_tab: *default_tab, + data: data.clone(), + client, } } }, // Typography UiElement::Heading { level, content, id } => { - let text = resolve_text_content(content, &serde_json::json!({})); + let ctx = data.as_json(); + let text = resolve_text_content(content, &ctx); let class = format!("plugin-heading level-{level}"); let anchor = id.clone().unwrap_or_default(); match level.min(&6) { @@ -206,7 +251,8 @@ fn render_element(element: &UiElement, data: &PluginPageData) -> Element { variant, allow_html, } => { - let text = resolve_text_content(content, &serde_json::json!({})); + let ctx = data.as_json(); + let text = resolve_text_content(content, &ctx); let variant_class = text_variant_class(variant); if *allow_html { let sanitized = ammonia::clean(&text); @@ -300,9 +346,9 @@ fn render_element(element: &UiElement, data: &PluginPageData) -> Element { onclick: move |_| { let a = action.clone(); let fd = row_data.clone(); - let client = ApiClient::default(); + let c = client.read().clone(); spawn(async move { - let _ = execute_action(&client, &a, Some(&fd)).await; + let _ = execute_action(&c, &a, Some(&fd)).await; }); }, "{act.label}" @@ -336,14 +382,14 @@ fn render_element(element: &UiElement, data: &PluginPageData) -> Element { div { class: "plugin-card-content", for child in content { - { render_element(child, data) } + { render_element(child, data, client) } } } if !footer.is_empty() { div { class: "plugin-card-footer", for child in footer { - { render_element(child, data) } + { render_element(child, data, client) } } } } @@ -394,16 +440,25 @@ fn render_element(element: &UiElement, data: &PluginPageData) -> Element { if arr.is_empty() { div { class: "plugin-list-empty", "No items" } } else { - ul { class: "plugin-list", - for _item in arr { - li { - class: "plugin-list-item", - { render_element(item_template, data) } - if *dividers { - hr { class: "plugin-list-divider" } + { + let dividers = *dividers; + let elements: Vec = arr + .iter() + .map(|item| { + let mut item_data = data.clone(); + item_data.set_data("item".to_string(), item.clone()); + rsx! { + li { + class: "plugin-list-item", + { render_element(item_template, &item_data, client) } + if dividers { + hr { class: "plugin-list-divider" } + } + } } - } - } + }) + .collect(); + rsx! { ul { class: "plugin-list", for el in elements { {el} } } } } } } @@ -454,9 +509,9 @@ fn render_element(element: &UiElement, data: &PluginPageData) -> Element { disabled: *disabled, onclick: move |_| { let a = action_ref.clone(); - let client = ApiClient::default(); + let c = client.read().clone(); spawn(async move { - let _ = execute_action(&client, &a, None).await; + let _ = execute_action(&c, &a, None).await; }); }, "{label}" @@ -482,19 +537,25 @@ fn render_element(element: &UiElement, data: &PluginPageData) -> Element { let vals = event.data().values(); let map: serde_json::Map = vals .into_iter() - .map(|(k, v)| { + .filter_map(|(k, v)| { let s = match v { FormValue::Text(s) => s, - FormValue::File(_) => String::new(), + FormValue::File(_) => { + tracing::warn!( + field = %k, + "file upload not supported in plugin forms" + ); + return None; + }, }; - (k, serde_json::Value::String(s)) + Some((k, serde_json::Value::String(s))) }) .collect(); serde_json::Value::Object(map) }; - let client = ApiClient::default(); + let c = client.read().clone(); spawn(async move { - let _ = execute_action(&client, &a, Some(&form_values)).await; + let _ = execute_action(&c, &a, Some(&form_values)).await; }); }, for field in fields { @@ -521,7 +582,7 @@ fn render_element(element: &UiElement, data: &PluginPageData) -> Element { } if let Some(cancel) = cancel_label { button { - r#type: "button", + r#type: "reset", class: "plugin-button btn-secondary", "{cancel}" } @@ -554,7 +615,8 @@ fn render_element(element: &UiElement, data: &PluginPageData) -> Element { max, show_percentage, } => { - let pct = evaluate_expression_as_f64(value); + let ctx = data.as_json(); + let pct = evaluate_expression_as_f64(value, &ctx); let fraction = (pct / max).clamp(0.0, 1.0); let pct_int = (fraction * 100.0).round() as u32; rsx! { @@ -618,11 +680,11 @@ fn render_element(element: &UiElement, data: &PluginPageData) -> Element { then, else_element, } => { - let ctx = serde_json::json!({}); + let ctx = data.as_json(); if evaluate_expression_as_bool(condition, &ctx) { - render_element(then, data) + render_element(then, data, client) } else if let Some(else_el) = else_element { - render_element(else_el, data) + render_element(else_el, data, client) } else { rsx! {} } @@ -643,15 +705,19 @@ fn render_element(element: &UiElement, data: &PluginPageData) -> Element { if let Some(arr) = items.and_then(|v| v.as_array()) { if arr.is_empty() { if let Some(empty_el) = empty { - return render_element(empty_el, data); + return render_element(empty_el, data, client); } return rsx! {}; } - rsx! { - for _item in arr { - { render_element(template, data) } - } - } + let elements: Vec = arr + .iter() + .map(|item| { + let mut item_data = data.clone(); + item_data.set_data("item".to_string(), item.clone()); + render_element(template, &item_data, client) + }) + .collect(); + rsx! { for el in elements { {el} } } } else { rsx! {} } @@ -737,7 +803,7 @@ fn render_form_field(field: &pinakes_plugin_api::FormField) -> Element { name: "{field.id}", multiple: *multiple, required: field.required, - option { value: "", disabled: true, "Select…" } + option { value: "", disabled: true, selected: true, "Select…" } for opt in options { option { value: "{opt.value}", "{opt.label}" } } @@ -969,10 +1035,11 @@ fn evaluate_expression_as_bool( evaluate_expression(expr, ctx).as_bool().unwrap_or(false) } -fn evaluate_expression_as_f64(expr: &Expression) -> f64 { - evaluate_expression(expr, &serde_json::json!({})) - .as_f64() - .unwrap_or(0.0) +fn evaluate_expression_as_f64( + expr: &Expression, + ctx: &serde_json::Value, +) -> f64 { + evaluate_expression(expr, ctx).as_f64().unwrap_or(0.0) } fn extract_cell(row: &serde_json::Value, key: &str) -> String { @@ -993,10 +1060,26 @@ fn extract_cell(row: &serde_json::Value, key: &str) -> String { #[cfg(test)] mod tests { - use pinakes_plugin_api::Expression; + use pinakes_plugin_api::{Expression, Operator}; use super::*; + fn lit(v: serde_json::Value) -> Box { + Box::new(Expression::Literal(v)) + } + + fn op_expr( + left: serde_json::Value, + op: Operator, + right: serde_json::Value, + ) -> Expression { + Expression::Operation { + left: lit(left), + op, + right: lit(right), + } + } + #[test] fn test_evaluate_expression_literal() { let expr = Expression::Literal(serde_json::json!("hello")); @@ -1004,6 +1087,13 @@ mod tests { assert_eq!(result, serde_json::json!("hello")); } + #[test] + fn test_evaluate_expression_literal_number() { + let expr = Expression::Literal(serde_json::json!(3.14)); + let result = evaluate_expression(&expr, &serde_json::json!({})); + assert_eq!(result, serde_json::json!(3.14)); + } + #[test] fn test_evaluate_expression_path() { let expr = Expression::Path("foo.bar".to_string()); @@ -1012,6 +1102,250 @@ mod tests { assert_eq!(result, serde_json::json!(42)); } + #[test] + fn test_evaluate_expression_path_missing_key() { + let expr = Expression::Path("foo.missing".to_string()); + let ctx = serde_json::json!({ "foo": { "bar": 1 } }); + let result = evaluate_expression(&expr, &ctx); + assert_eq!(result, serde_json::Value::Null); + } + + #[test] + fn test_evaluate_expression_path_array_index() { + let expr = Expression::Path("items.1".to_string()); + let ctx = serde_json::json!({ "items": ["a", "b", "c"] }); + let result = evaluate_expression(&expr, &ctx); + assert_eq!(result, serde_json::json!("b")); + } + + #[test] + fn test_evaluate_expression_path_array_out_of_bounds() { + let expr = Expression::Path("items.9".to_string()); + let ctx = serde_json::json!({ "items": ["a"] }); + let result = evaluate_expression(&expr, &ctx); + assert_eq!(result, serde_json::Value::Null); + } + + #[test] + fn test_evaluate_expression_path_on_scalar() { + let expr = Expression::Path("x.y".to_string()); + let ctx = serde_json::json!({ "x": 42 }); + let result = evaluate_expression(&expr, &ctx); + assert_eq!(result, serde_json::Value::Null); + } + + #[test] + fn test_evaluate_expression_eq_true() { + let expr = + op_expr(serde_json::json!(1), Operator::Eq, serde_json::json!(1)); + let result = evaluate_expression(&expr, &serde_json::json!({})); + assert_eq!(result, serde_json::json!(true)); + } + + #[test] + fn test_evaluate_expression_eq_false() { + let expr = + op_expr(serde_json::json!(1), Operator::Eq, serde_json::json!(2)); + let result = evaluate_expression(&expr, &serde_json::json!({})); + assert_eq!(result, serde_json::json!(false)); + } + + #[test] + fn test_evaluate_expression_ne() { + let expr = + op_expr(serde_json::json!("a"), Operator::Ne, serde_json::json!("b")); + assert_eq!( + evaluate_expression(&expr, &serde_json::json!({})), + serde_json::json!(true) + ); + let expr2 = + op_expr(serde_json::json!("a"), Operator::Ne, serde_json::json!("a")); + assert_eq!( + evaluate_expression(&expr2, &serde_json::json!({})), + serde_json::json!(false) + ); + } + + #[test] + fn test_evaluate_expression_gt() { + let expr = + op_expr(serde_json::json!(5), Operator::Gt, serde_json::json!(3)); + assert_eq!( + evaluate_expression(&expr, &serde_json::json!({})), + serde_json::json!(true) + ); + let expr2 = + op_expr(serde_json::json!(3), Operator::Gt, serde_json::json!(5)); + assert_eq!( + evaluate_expression(&expr2, &serde_json::json!({})), + serde_json::json!(false) + ); + } + + #[test] + fn test_evaluate_expression_gte() { + let expr = + op_expr(serde_json::json!(5), Operator::Gte, serde_json::json!(5)); + assert_eq!( + evaluate_expression(&expr, &serde_json::json!({})), + serde_json::json!(true) + ); + let expr2 = + op_expr(serde_json::json!(4), Operator::Gte, serde_json::json!(5)); + assert_eq!( + evaluate_expression(&expr2, &serde_json::json!({})), + serde_json::json!(false) + ); + } + + #[test] + fn test_evaluate_expression_lt() { + let expr = + op_expr(serde_json::json!(3), Operator::Lt, serde_json::json!(5)); + assert_eq!( + evaluate_expression(&expr, &serde_json::json!({})), + serde_json::json!(true) + ); + let expr2 = + op_expr(serde_json::json!(5), Operator::Lt, serde_json::json!(3)); + assert_eq!( + evaluate_expression(&expr2, &serde_json::json!({})), + serde_json::json!(false) + ); + } + + #[test] + fn test_evaluate_expression_lte() { + let expr = + op_expr(serde_json::json!(5), Operator::Lte, serde_json::json!(5)); + assert_eq!( + evaluate_expression(&expr, &serde_json::json!({})), + serde_json::json!(true) + ); + let expr2 = + op_expr(serde_json::json!(6), Operator::Lte, serde_json::json!(5)); + assert_eq!( + evaluate_expression(&expr2, &serde_json::json!({})), + serde_json::json!(false) + ); + } + + #[test] + fn test_evaluate_expression_and() { + let expr = op_expr( + serde_json::json!(true), + Operator::And, + serde_json::json!(true), + ); + assert_eq!( + evaluate_expression(&expr, &serde_json::json!({})), + serde_json::json!(true) + ); + let expr2 = op_expr( + serde_json::json!(true), + Operator::And, + serde_json::json!(false), + ); + assert_eq!( + evaluate_expression(&expr2, &serde_json::json!({})), + serde_json::json!(false) + ); + } + + #[test] + fn test_evaluate_expression_or() { + let expr = op_expr( + serde_json::json!(false), + Operator::Or, + serde_json::json!(true), + ); + assert_eq!( + evaluate_expression(&expr, &serde_json::json!({})), + serde_json::json!(true) + ); + let expr2 = op_expr( + serde_json::json!(false), + Operator::Or, + serde_json::json!(false), + ); + assert_eq!( + evaluate_expression(&expr2, &serde_json::json!({})), + serde_json::json!(false) + ); + } + + #[test] + fn test_evaluate_expression_unimplemented_ops_return_null() { + for op in [ + Operator::Concat, + Operator::Add, + Operator::Sub, + Operator::Mul, + Operator::Div, + ] { + let expr = op_expr(serde_json::json!(1), op, serde_json::json!(2)); + assert_eq!( + evaluate_expression(&expr, &serde_json::json!({})), + serde_json::Value::Null + ); + } + } + + #[test] + fn test_evaluate_expression_call_returns_null() { + let expr = Expression::Call { + function: "unknown".to_string(), + args: vec![], + }; + assert_eq!( + evaluate_expression(&expr, &serde_json::json!({})), + serde_json::Value::Null + ); + } + + #[test] + fn test_evaluate_expression_as_bool_true() { + let expr = Expression::Literal(serde_json::json!(true)); + assert!(evaluate_expression_as_bool(&expr, &serde_json::json!({}))); + } + + #[test] + fn test_evaluate_expression_as_bool_false() { + let expr = Expression::Literal(serde_json::json!(false)); + assert!(!evaluate_expression_as_bool(&expr, &serde_json::json!({}))); + } + + #[test] + fn test_evaluate_expression_as_bool_non_bool_returns_false() { + let expr = Expression::Literal(serde_json::json!("yes")); + assert!(!evaluate_expression_as_bool(&expr, &serde_json::json!({}))); + } + + #[test] + fn test_evaluate_expression_as_f64() { + let expr = Expression::Literal(serde_json::json!(7.5)); + assert_eq!( + evaluate_expression_as_f64(&expr, &serde_json::json!({})), + 7.5 + ); + } + + #[test] + fn test_evaluate_expression_as_f64_non_numeric_returns_zero() { + let expr = Expression::Literal(serde_json::json!("text")); + assert_eq!( + evaluate_expression_as_f64(&expr, &serde_json::json!({})), + 0.0 + ); + } + + #[test] + fn test_evaluate_expression_as_f64_from_path() { + let expr = Expression::Path("score".to_string()); + let ctx = serde_json::json!({ "score": 42 }); + assert_eq!(evaluate_expression_as_f64(&expr, &ctx), 42.0); + } + #[test] fn test_extract_cell_string() { let row = serde_json::json!({ "name": "Alice", "count": 5 }); @@ -1020,6 +1354,32 @@ mod tests { assert_eq!(extract_cell(&row, "missing"), ""); } + #[test] + fn test_extract_cell_bool() { + let row = serde_json::json!({ "active": true, "deleted": false }); + assert_eq!(extract_cell(&row, "active"), "true"); + assert_eq!(extract_cell(&row, "deleted"), "false"); + } + + #[test] + fn test_extract_cell_null() { + let row = serde_json::json!({ "value": null }); + assert_eq!(extract_cell(&row, "value"), ""); + } + + #[test] + fn test_extract_cell_nested_object() { + let row = serde_json::json!({ "meta": { "x": 1 } }); + let result = extract_cell(&row, "meta"); + assert!(result.contains("x")); + } + + #[test] + fn test_extract_cell_non_object_row() { + let row = serde_json::json!("not an object"); + assert_eq!(extract_cell(&row, "key"), ""); + } + #[test] fn test_resolve_text_content_static() { let tc = TextContent::Static("Hello".to_string()); @@ -1031,4 +1391,118 @@ mod tests { let tc = TextContent::Empty; assert_eq!(resolve_text_content(&tc, &serde_json::json!({})), ""); } + + #[test] + fn test_resolve_text_content_expression() { + let tc = TextContent::Expression(Expression::Path("user.name".to_string())); + let ctx = serde_json::json!({ "user": { "name": "Bob" } }); + // evaluate_expression returns a serde_json::Value; .to_string() + // JSON-encodes it + assert_eq!(resolve_text_content(&tc, &ctx), "\"Bob\""); + } + + #[test] + fn test_resolve_text_content_expression_number() { + let tc = + TextContent::Expression(Expression::Literal(serde_json::json!(42))); + assert_eq!(resolve_text_content(&tc, &serde_json::json!({})), "42"); + } + + #[test] + fn test_resolve_text_content_expression_missing() { + let tc = TextContent::Expression(Expression::Path("missing".to_string())); + assert_eq!(resolve_text_content(&tc, &serde_json::json!({})), "null"); + } + + #[test] + fn test_flex_direction_css() { + assert_eq!(flex_direction_css(&FlexDirection::Row), "row"); + assert_eq!(flex_direction_css(&FlexDirection::Column), "column"); + } + + #[test] + fn test_justify_content_css() { + assert_eq!( + justify_content_css(&JustifyContent::FlexStart), + "flex-start" + ); + assert_eq!(justify_content_css(&JustifyContent::FlexEnd), "flex-end"); + assert_eq!(justify_content_css(&JustifyContent::Center), "center"); + assert_eq!( + justify_content_css(&JustifyContent::SpaceBetween), + "space-between" + ); + assert_eq!( + justify_content_css(&JustifyContent::SpaceAround), + "space-around" + ); + assert_eq!( + justify_content_css(&JustifyContent::SpaceEvenly), + "space-evenly" + ); + } + + #[test] + fn test_align_items_css() { + assert_eq!(align_items_css(&AlignItems::FlexStart), "flex-start"); + assert_eq!(align_items_css(&AlignItems::FlexEnd), "flex-end"); + assert_eq!(align_items_css(&AlignItems::Center), "center"); + assert_eq!(align_items_css(&AlignItems::Stretch), "stretch"); + assert_eq!(align_items_css(&AlignItems::Baseline), "baseline"); + } + + #[test] + fn test_button_variant_class() { + assert_eq!(button_variant_class(&ButtonVariant::Primary), "btn-primary"); + assert_eq!( + button_variant_class(&ButtonVariant::Secondary), + "btn-secondary" + ); + assert_eq!( + button_variant_class(&ButtonVariant::Tertiary), + "btn-tertiary" + ); + assert_eq!(button_variant_class(&ButtonVariant::Danger), "btn-danger"); + assert_eq!(button_variant_class(&ButtonVariant::Success), "btn-success"); + assert_eq!(button_variant_class(&ButtonVariant::Ghost), "btn-ghost"); + } + + #[test] + fn test_badge_variant_class() { + assert_eq!(badge_variant_class(&BadgeVariant::Default), "badge-default"); + assert_eq!(badge_variant_class(&BadgeVariant::Primary), "badge-primary"); + assert_eq!( + badge_variant_class(&BadgeVariant::Secondary), + "badge-secondary" + ); + assert_eq!(badge_variant_class(&BadgeVariant::Success), "badge-success"); + assert_eq!(badge_variant_class(&BadgeVariant::Warning), "badge-warning"); + assert_eq!(badge_variant_class(&BadgeVariant::Error), "badge-error"); + assert_eq!(badge_variant_class(&BadgeVariant::Info), "badge-info"); + } + + #[test] + fn test_chart_type_class() { + assert_eq!(chart_type_class(&ChartType::Bar), "chart-bar"); + assert_eq!(chart_type_class(&ChartType::Line), "chart-line"); + assert_eq!(chart_type_class(&ChartType::Pie), "chart-pie"); + assert_eq!(chart_type_class(&ChartType::Area), "chart-area"); + assert_eq!(chart_type_class(&ChartType::Scatter), "chart-scatter"); + } + + #[test] + fn test_text_variant_class() { + assert_eq!(text_variant_class(&TextVariant::Body), ""); + assert_eq!( + text_variant_class(&TextVariant::Secondary), + "text-secondary" + ); + assert_eq!(text_variant_class(&TextVariant::Success), "text-success"); + assert_eq!(text_variant_class(&TextVariant::Warning), "text-warning"); + assert_eq!(text_variant_class(&TextVariant::Error), "text-error"); + assert_eq!(text_variant_class(&TextVariant::Bold), "text-bold"); + assert_eq!(text_variant_class(&TextVariant::Italic), "text-italic"); + assert_eq!(text_variant_class(&TextVariant::Small), "text-small"); + assert_eq!(text_variant_class(&TextVariant::Large), "text-large"); + } } -- 2.43.0 From 6e442065b1be7aef2cd5f8ad3d300bf8350490f0 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Tue, 10 Mar 2026 00:02:31 +0300 Subject: [PATCH 19/46] pinakes-ui: integrate plugin registry into app navigation and routing Signed-off-by: NotAShelf Change-Id: I7c4593d93693bf08555a0b5f89a67aea6a6a6964 --- crates/pinakes-ui/src/app.rs | 110 ++++++++++++----- crates/pinakes-ui/src/plugin_ui/registry.rs | 126 ++++++++++++++++++-- 2 files changed, 196 insertions(+), 40 deletions(-) diff --git a/crates/pinakes-ui/src/app.rs b/crates/pinakes-ui/src/app.rs index 4ce21e1..972da40 100644 --- a/crates/pinakes-ui/src/app.rs +++ b/crates/pinakes-ui/src/app.rs @@ -59,6 +59,7 @@ use crate::{ tags, tasks, }, + plugin_ui::{PluginRegistry, PluginViewRenderer}, styles, }; @@ -80,6 +81,10 @@ enum View { Settings, Database, Graph, + PluginView { + plugin_id: String, + page_id: String, + }, } impl View { @@ -99,13 +104,13 @@ impl View { Self::Settings => "Settings", Self::Database => "Database", Self::Graph => "Note Graph", + Self::PluginView { .. } => "Plugin", } } } #[component] pub fn App() -> Element { - // Phase 1.3: Auth support let base_url = std::env::var("PINAKES_SERVER_URL") .unwrap_or_else(|_| "http://localhost:3000".into()); let api_key = std::env::var("PINAKES_API_KEY").ok(); @@ -139,7 +144,6 @@ pub fn App() -> Element { let mut viewing_collection = use_signal(|| Option::::None); let mut collection_members = use_signal(Vec::::new); - // Phase 4A: Book management let mut books_list = use_signal(Vec::::new); let mut books_series_list = use_signal(Vec::::new); @@ -160,31 +164,24 @@ pub fn App() -> Element { let mut loading = use_signal(|| true); let mut load_error = use_signal(|| Option::::None); - // Phase 1.4: Toast queue let mut toast_queue = use_signal(Vec::<(String, bool, usize)>::new); - // Phase 5.1: Search pagination let mut search_page = use_signal(|| 0u64); let search_page_size = use_signal(|| 50u64); let mut last_search_query = use_signal(String::new); let mut last_search_sort = use_signal(|| Option::::None); - // Phase 3.6: Saved searches let mut saved_searches = use_signal(Vec::::new); - // Phase 6.1: Audit pagination & filter let mut audit_page = use_signal(|| 0u64); let audit_page_size = use_signal(|| 200u64); let audit_total_count = use_signal(|| 0u64); let mut audit_filter = use_signal(|| "All".to_string()); - // Phase 6.2: Scan progress let mut scan_progress = use_signal(|| Option::::None); - // Phase 7.1: Help overlay let mut show_help = use_signal(|| false); - // Phase 8: Sidebar collapse let mut sidebar_collapsed = use_signal(|| false); // Auth state @@ -195,7 +192,8 @@ pub fn App() -> Element { let mut auto_play_media = use_signal(|| false); let mut play_queue = use_signal(PlayQueue::default); - // Theme state (Phase 3.3) + let mut plugin_registry = use_signal(PluginRegistry::default); + let mut current_theme = use_signal(|| "dark".to_string()); let mut system_prefers_dark = use_signal(|| true); @@ -287,7 +285,7 @@ pub fn App() -> Element { }); }); - // Load initial data (Phase 2.2: pass sort to list_media) + // Load initial data let client_init = client.read().clone(); let init_sort = media_sort.read().clone(); use_effect(move || { @@ -311,7 +309,6 @@ pub fn App() -> Element { if let Ok(c) = client.list_collections().await { collections_list.set(c); } - // Phase 3.6: Load saved searches if let Ok(ss) = client.list_saved_searches().await { saved_searches.set(ss); } @@ -319,7 +316,22 @@ pub fn App() -> Element { }); }); - // Phase 1.4: Toast helper with queue support + use_effect(move || { + let c = client.read().clone(); + spawn(async move { + match c.get_plugin_ui_pages().await { + Ok(pages) => { + let mut reg = PluginRegistry::default(); + for (plugin_id, page) in pages { + reg.register_page(plugin_id, page); + } + plugin_registry.set(reg); + }, + Err(e) => tracing::debug!("Plugin pages unavailable: {e}"), + } + }); + }); + let mut show_toast = move |msg: String, is_error: bool| { let id = TOAST_ID_COUNTER.fetch_add(1, Ordering::Relaxed); toast_queue.write().push((msg, is_error, id)); @@ -334,7 +346,6 @@ pub fn App() -> Element { }); }; - // Helper: refresh media list with current pagination (Phase 2.2: pass sort) let refresh_media = { let client = client.read().clone(); move || { @@ -355,7 +366,6 @@ pub fn App() -> Element { } }; - // Helper: refresh tags let refresh_tags = { let client = client.read().clone(); move || { @@ -368,7 +378,6 @@ pub fn App() -> Element { } }; - // Helper: refresh collections let refresh_collections = { let client = client.read().clone(); move || { @@ -381,7 +390,6 @@ pub fn App() -> Element { } }; - // Helper: refresh audit with pagination and filter (Phase 6.1) let refresh_audit = { let client = client.read().clone(); move || { @@ -440,7 +448,6 @@ pub fn App() -> Element { loading: *login_loading.read(), } } else { - // Phase 7.1: Keyboard shortcuts div { class: if *effective_theme.read() == "light" { "app theme-light" } else { "app" }, tabindex: "0", @@ -744,6 +751,36 @@ pub fn App() -> Element { } } + if !plugin_registry.read().is_empty() { + div { class: "nav-section", + div { class: "nav-label", "Plugins" } + for page in plugin_registry.read().all_pages() { + { + let pid = page.plugin_id.clone(); + let pageid = page.page.id.clone(); + let title = page.page.title.clone(); + let is_active = *current_view.read() + == View::PluginView { + plugin_id: pid.clone(), + page_id: pageid.clone(), + }; + rsx! { + button { + class: if is_active { "nav-item active" } else { "nav-item" }, + onclick: move |_| { + current_view.set(View::PluginView { + plugin_id: pid.clone(), + page_id: pageid.clone(), + }); + }, + span { class: "nav-item-text", "{title}" } + } + } + } + } + } + } + div { class: "sidebar-spacer" } // Show import progress in sidebar when not on import page @@ -881,17 +918,6 @@ pub fn App() -> Element { } { - // Phase 2.2: Sort wiring - actually refetch with sort - // Phase 4.1 + 4.2: Search improvements - // Phase 3.1 + 3.2: Detail view enhancements - // Phase 3.2: Delete from detail navigates back and refreshes - // Phase 5.1: Tags on_delete - confirmation handled inside Tags component - // Phase 5.2: Collections enhancements - // Phase 5.2: Navigate to detail when clicking a collection member - // Phase 5.2: Add member to collection - // Phase 6.1: Audit improvements - // Phase 6.2: Scan progress - // Phase 6.2: Scan with polling for progress // Poll scan status until done // Refresh duplicates list // Reload full config @@ -1178,7 +1204,6 @@ pub fn App() -> Element { }); } }, - // Phase 3.6: Saved searches saved_searches: saved_searches.read().clone(), on_save_search: { let client = client.read().clone(); @@ -2673,12 +2698,34 @@ pub fn App() -> Element { } } } + View::PluginView { + ref plugin_id, + ref page_id, + } => { + let pid = plugin_id.clone(); + let pageid = page_id.clone(); + let page_opt = + plugin_registry.read().get_page(&pid, &pageid).cloned(); + match page_opt { + Some(plugin_page) => rsx! { + PluginViewRenderer { + plugin_id: pid, + page: plugin_page.page, + client, + } + }, + None => rsx! { + div { class: "plugin-not-found", + "Plugin page not found: {pageid}" + } + }, + } + } } } } } - // Phase 7.1: Help overlay if *show_help.read() { div { class: "help-overlay", @@ -2747,7 +2794,6 @@ pub fn App() -> Element { } } // end else (auth not required) - // Phase 1.4: Toast queue - show up to 3 stacked from bottom div { class: "toast-container", { let toasts = toast_queue.read().clone(); diff --git a/crates/pinakes-ui/src/plugin_ui/registry.rs b/crates/pinakes-ui/src/plugin_ui/registry.rs index 473509d..a50c533 100644 --- a/crates/pinakes-ui/src/plugin_ui/registry.rs +++ b/crates/pinakes-ui/src/plugin_ui/registry.rs @@ -19,7 +19,7 @@ use std::collections::HashMap; use dioxus::prelude::*; -use pinakes_plugin_api::UiPage; +use pinakes_plugin_api::{UiPage, UiWidget}; use crate::client::ApiClient; @@ -39,15 +39,17 @@ impl PluginPage { } } -/// Registry of all plugin-provided UI pages +/// Registry of all plugin-provided UI pages and widgets /// -/// This is typically stored as a context value in the Dioxus tree. +/// This is typically stored as a signal in the Dioxus tree. #[derive(Debug, Clone)] pub struct PluginRegistry { /// API client for fetching pages from server client: ApiClient, /// Cached pages: (plugin_id, page_id) -> PluginPage pages: HashMap<(String, String), PluginPage>, + /// Cached widgets: (plugin_id, widget_id) -> UiWidget + widgets: Vec<(String, UiWidget)>, /// Last refresh timestamp last_refresh: Option>, } @@ -58,17 +60,15 @@ impl PluginRegistry { Self { client, pages: HashMap::new(), + widgets: Vec::new(), last_refresh: None, } } /// Create a new registry with pre-loaded pages - pub fn with_pages( - client: ApiClient, - pages: Vec<(String, String, UiPage)>, - ) -> Self { + pub fn with_pages(client: ApiClient, pages: Vec<(String, UiPage)>) -> Self { let mut registry = Self::new(client); - for (plugin_id, _page_id, page) in pages { + for (plugin_id, page) in pages { registry.register_page(plugin_id, page); } registry @@ -93,6 +93,16 @@ impl PluginRegistry { .get(&(plugin_id.to_string(), page_id.to_string())) } + /// Register a widget from a plugin + pub fn register_widget(&mut self, plugin_id: String, widget: UiWidget) { + self.widgets.push((plugin_id, widget)); + } + + /// Get all widgets (for use with WidgetContainer) + pub fn all_widgets(&self) -> Vec<(String, UiWidget)> { + self.widgets.clone() + } + /// Get all pages pub fn all_pages(&self) -> Vec<&PluginPage> { self.pages.values().collect() @@ -122,6 +132,7 @@ impl PluginRegistry { match self.client.get_plugin_ui_pages().await { Ok(pages) => { self.pages.clear(); + self.widgets.clear(); for (plugin_id, page) in pages { self.register_page(plugin_id, page); } @@ -245,4 +256,103 @@ mod tests { assert_eq!(routes[0].1, "page1"); assert_eq!(routes[0].2, "/plugins/plugin1/page1"); } + + #[test] + fn test_register_widget_and_all_widgets() { + let client = ApiClient::default(); + let mut registry = PluginRegistry::new(client); + + let widget: UiWidget = serde_json::from_value(serde_json::json!({ + "id": "my-widget", + "target": "library_header", + "content": { "type": "badge", "text": "hello", "variant": "default" } + })) + .unwrap(); + + assert!(registry.all_widgets().is_empty()); + registry.register_widget("test-plugin".to_string(), widget.clone()); + let widgets = registry.all_widgets(); + assert_eq!(widgets.len(), 1); + assert_eq!(widgets[0].0, "test-plugin"); + assert_eq!(widgets[0].1.id, "my-widget"); + } + + #[test] + fn test_with_pages_builds_registry() { + let client = ApiClient::default(); + let pages = vec![ + ("plugin1".to_string(), create_test_page("page1", "Page 1")), + ("plugin2".to_string(), create_test_page("page2", "Page 2")), + ]; + + let registry = PluginRegistry::with_pages(client, pages); + assert_eq!(registry.len(), 2); + assert!(registry.get_page("plugin1", "page1").is_some()); + assert!(registry.get_page("plugin2", "page2").is_some()); + } + + #[test] + fn test_register_page_overwrites_same_key() { + let client = ApiClient::default(); + let mut registry = PluginRegistry::new(client); + + registry + .register_page("plugin1".to_string(), create_test_page("p", "Original")); + registry + .register_page("plugin1".to_string(), create_test_page("p", "Updated")); + + assert_eq!(registry.len(), 1); + assert_eq!( + registry.get_page("plugin1", "p").unwrap().page.title, + "Updated" + ); + } + + #[test] + fn test_default_registry_is_empty() { + let registry = PluginRegistry::default(); + assert!(registry.is_empty()); + assert_eq!(registry.len(), 0); + assert!(registry.last_refresh().is_none()); + } + + #[test] + fn test_all_pages_returns_references() { + let client = ApiClient::default(); + let mut registry = PluginRegistry::new(client); + registry.register_page("p1".to_string(), create_test_page("a", "A")); + registry.register_page("p2".to_string(), create_test_page("b", "B")); + + let pages = registry.all_pages(); + assert_eq!(pages.len(), 2); + let titles: Vec<&str> = + pages.iter().map(|p| p.page.title.as_str()).collect(); + assert!(titles.contains(&"A")); + assert!(titles.contains(&"B")); + } + + #[test] + fn test_different_plugins_same_page_id_both_stored() { + let client = ApiClient::default(); + let mut registry = PluginRegistry::new(client); + + registry.register_page( + "plugin-a".to_string(), + create_test_page("home", "A Home"), + ); + registry.register_page( + "plugin-b".to_string(), + create_test_page("home", "B Home"), + ); + + assert_eq!(registry.len(), 2); + assert_eq!( + registry.get_page("plugin-a", "home").unwrap().page.title, + "A Home" + ); + assert_eq!( + registry.get_page("plugin-b", "home").unwrap().page.title, + "B Home" + ); + } } -- 2.43.0 From 5a0901ba953e08ece54177088648efc007764e0b Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Tue, 10 Mar 2026 00:02:35 +0300 Subject: [PATCH 20/46] pinakes-plugin-api: add `required_endpoints` and `theme_extensions` to manifest UI section Signed-off-by: NotAShelf Change-Id: I203fabfe07548106abb5bac760bbfec06a6a6964 --- crates/pinakes-plugin-api/Cargo.toml | 1 + crates/pinakes-plugin-api/src/manifest.rs | 121 ++++++++++++++++++++++ 2 files changed, 122 insertions(+) diff --git a/crates/pinakes-plugin-api/Cargo.toml b/crates/pinakes-plugin-api/Cargo.toml index 94f529a..bfc8dd4 100644 --- a/crates/pinakes-plugin-api/Cargo.toml +++ b/crates/pinakes-plugin-api/Cargo.toml @@ -10,6 +10,7 @@ serde = { workspace = true } serde_json = { workspace = true } thiserror = { workspace = true } async-trait = { workspace = true } +tracing = { workspace = true } # For plugin manifest parsing toml = { workspace = true } diff --git a/crates/pinakes-plugin-api/src/manifest.rs b/crates/pinakes-plugin-api/src/manifest.rs index 49c5748..340dc24 100644 --- a/crates/pinakes-plugin-api/src/manifest.rs +++ b/crates/pinakes-plugin-api/src/manifest.rs @@ -40,6 +40,33 @@ pub struct UiSection { /// Widgets to inject into existing host pages #[serde(default)] pub widgets: Vec, + + /// API endpoint paths this plugin's UI requires. + /// Each must start with `/api/`. Informational; host may check availability. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub required_endpoints: Vec, + + /// CSS custom property overrides provided by this plugin. + /// Keys are property names (e.g. `--accent-color`), values are CSS values. + /// The host applies these to `document.documentElement` on startup. + #[serde(default, skip_serializing_if = "HashMap::is_empty")] + pub theme_extensions: HashMap, +} + +impl UiSection { + /// Validate that all declared required endpoints start with `/api/`. + /// + /// # Errors + /// + /// Returns an error string for the first invalid endpoint found. + pub fn validate(&self) -> Result<(), String> { + for ep in &self.required_endpoints { + if !ep.starts_with("/api/") { + return Err(format!("required_endpoint must start with '/api/': {ep}")); + } + } + Ok(()) + } } /// Entry for a UI page in the manifest - can be inline or file reference @@ -267,6 +294,15 @@ impl PluginManifest { )); } + // Validate UI section (required_endpoints format); non-fatal: warn only + if let Err(e) = self.ui.validate() { + tracing::warn!( + plugin = %self.plugin.name, + error = %e, + "plugin UI section has invalid required_endpoints" + ); + } + // Validate UI pages for (idx, page_entry) in self.ui.pages.iter().enumerate() { match page_entry { @@ -663,4 +699,89 @@ gap = 16 let manifest = PluginManifest::parse_str(toml).unwrap(); assert_eq!(manifest.ui.pages.len(), 2); } + + #[test] + fn test_ui_section_validate_accepts_api_paths() { + let section = UiSection { + pages: vec![], + widgets: vec![], + required_endpoints: vec![ + "/api/v1/media".to_string(), + "/api/plugins/my-plugin/data".to_string(), + ], + theme_extensions: HashMap::new(), + }; + assert!(section.validate().is_ok()); + } + + #[test] + fn test_ui_section_validate_rejects_non_api_path() { + let section = UiSection { + pages: vec![], + widgets: vec![], + required_endpoints: vec!["/not-api/something".to_string()], + theme_extensions: HashMap::new(), + }; + assert!(section.validate().is_err()); + } + + #[test] + fn test_ui_section_validate_rejects_empty_sections_with_bad_path() { + let section = UiSection { + pages: vec![], + widgets: vec![], + required_endpoints: vec!["/api/ok".to_string(), "no-slash".to_string()], + theme_extensions: HashMap::new(), + }; + let err = section.validate().unwrap_err(); + assert!( + err.contains("no-slash"), + "error should mention the bad endpoint" + ); + } + + #[test] + fn test_theme_extensions_roundtrip() { + let toml = r##" +[plugin] +name = "theme-plugin" +version = "1.0.0" +api_version = "1.0" +kind = ["theme_provider"] + +[plugin.binary] +wasm = "plugin.wasm" + +[ui.theme_extensions] +"--accent-color" = "#ff6b6b" +"--sidebar-width" = "280px" +"##; + + let manifest = PluginManifest::parse_str(toml).unwrap(); + assert_eq!( + manifest.ui.theme_extensions.get("--accent-color").map(String::as_str), + Some("#ff6b6b") + ); + assert_eq!( + manifest.ui.theme_extensions.get("--sidebar-width").map(String::as_str), + Some("280px") + ); + } + + #[test] + fn test_theme_extensions_empty_by_default() { + let toml = r#" +[plugin] +name = "no-theme" +version = "1.0.0" +api_version = "1.0" +kind = ["media_type"] + +[plugin.binary] +wasm = "plugin.wasm" +"#; + + let manifest = PluginManifest::parse_str(toml).unwrap(); + assert!(manifest.ui.theme_extensions.is_empty()); + } } -- 2.43.0 From 7a6d602eed25839970d576cfaade91d59d56d754 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Wed, 11 Mar 2026 16:49:41 +0300 Subject: [PATCH 21/46] pinakes-plugin-api: add integration and sample plugin tests Signed-off-by: NotAShelf Change-Id: I0de4c3e1e5b49579ae42983f93a2332e6a6a6964 --- .../tests/example_plugin.rs | 61 +++ .../pinakes-plugin-api/tests/integration.rs | 361 ++++++++++++++++++ 2 files changed, 422 insertions(+) create mode 100644 crates/pinakes-plugin-api/tests/example_plugin.rs create mode 100644 crates/pinakes-plugin-api/tests/integration.rs diff --git a/crates/pinakes-plugin-api/tests/example_plugin.rs b/crates/pinakes-plugin-api/tests/example_plugin.rs new file mode 100644 index 0000000..a9e94c8 --- /dev/null +++ b/crates/pinakes-plugin-api/tests/example_plugin.rs @@ -0,0 +1,61 @@ +//! Integration tests that parse and validate the media-stats-ui example. + +use pinakes_plugin_api::{PluginManifest, UiPage}; + +/// Resolve a path relative to the workspace root. +fn workspace_path(rel: &str) -> std::path::PathBuf { + // tests run from the crate root (crates/pinakes-plugin-api) + std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../..") + .join(rel) +} + +#[test] +fn example_plugin_manifest_parses() { + let path = workspace_path("examples/plugins/media-stats-ui/plugin.toml"); + // load_ui_pages needs the manifest validated, but the file-based pages + // just need the paths to exist; we test them separately below. + let content = std::fs::read_to_string(&path).expect("read plugin.toml"); + // parse_str validates the manifest; it returns Err for any violation + let manifest = PluginManifest::parse_str(&content) + .expect("plugin.toml should parse and validate"); + assert_eq!(manifest.plugin.name, "media-stats-ui"); + assert_eq!( + manifest.ui.pages.len(), + 2, + "expected 2 page file references" + ); + assert_eq!(manifest.ui.widgets.len(), 1, "expected 1 widget"); +} + +#[test] +fn example_stats_page_parses_and_validates() { + let path = workspace_path("examples/plugins/media-stats-ui/pages/stats.json"); + let content = std::fs::read_to_string(&path).expect("read stats.json"); + let page: UiPage = + serde_json::from_str(&content).expect("stats.json should deserialise"); + assert_eq!(page.id, "stats"); + page.validate().expect("stats page should pass validation"); +} + +#[test] +fn example_tag_manager_page_parses_and_validates() { + let path = + workspace_path("examples/plugins/media-stats-ui/pages/tag-manager.json"); + let content = std::fs::read_to_string(&path).expect("read tag-manager.json"); + let page: UiPage = serde_json::from_str(&content) + .expect("tag-manager.json should deserialise"); + assert_eq!(page.id, "tag-manager"); + page + .validate() + .expect("tag-manager page should pass validation"); + // Verify the named action and data source are both present + assert!( + page.actions.contains_key("create-tag"), + "create-tag action should be defined" + ); + assert!( + page.data_sources.contains_key("tags"), + "tags data source should be defined" + ); +} diff --git a/crates/pinakes-plugin-api/tests/integration.rs b/crates/pinakes-plugin-api/tests/integration.rs new file mode 100644 index 0000000..a6d92fe --- /dev/null +++ b/crates/pinakes-plugin-api/tests/integration.rs @@ -0,0 +1,361 @@ +//! Integration tests for the plugin validation pipeline. +//! +//! Renderer-level behaviour (e.g., Dioxus components) is out of scope here; +//! that requires a Dioxus runtime and belongs in pinakes-ui tests. + +use std::collections::HashMap; + +use pinakes_plugin_api::{ + DataSource, + HttpMethod, + UiElement, + UiPage, + UiWidget, + validation::SchemaValidator, +}; + +// Build a minimal valid UiPage. +fn make_page(id: &str, route: &str) -> UiPage { + UiPage { + id: id.to_string(), + title: "Integration Test Page".to_string(), + route: route.to_string(), + icon: None, + root_element: UiElement::Container { + children: vec![], + gap: 0, + padding: None, + }, + data_sources: HashMap::new(), + actions: HashMap::new(), + } +} + +// Build a minimal valid UiWidget. +fn make_widget(id: &str, target: &str) -> UiWidget { + UiWidget { + id: id.to_string(), + target: target.to_string(), + content: UiElement::Container { + children: vec![], + gap: 0, + padding: None, + }, + } +} + +// Build a complete valid PluginManifest as TOML string. +const fn valid_manifest_toml() -> &'static str { + r#" +[plugin] +name = "integration-test-plugin" +version = "1.0.0" +api_version = "1.0" +kind = ["ui_page"] + +[plugin.binary] +wasm = "plugin.wasm" + +[[ui.pages]] +id = "stats" +title = "Statistics" +route = "/plugins/integration-test/stats" + +[ui.pages.layout] +type = "container" +children = [] +gap = 0 + +[[ui.widgets]] +id = "status-badge" +target = "library_header" + +[ui.widgets.content] +type = "badge" +text = "online" +"# +} + +// A complete valid manifest (with a page and a widget) passes all +// checks. +#[test] +fn test_full_valid_plugin_manifest_passes_all_checks() { + use pinakes_plugin_api::PluginManifest; + + let manifest = PluginManifest::parse_str(valid_manifest_toml()) + .expect("manifest must parse"); + + assert_eq!(manifest.plugin.name, "integration-test-plugin"); + assert_eq!(manifest.ui.pages.len(), 1); + assert_eq!(manifest.ui.widgets.len(), 1); + + // validate page via UiPage::validate + let page = make_page("my-plugin-page", "/plugins/integration-test/overview"); + page + .validate() + .expect("valid page must pass UiPage::validate"); + + // validate the same page via SchemaValidator + SchemaValidator::validate_page(&page) + .expect("valid page must pass SchemaValidator::validate_page"); + + // validate widget + let widget = make_widget("my-widget", "library_header"); + SchemaValidator::validate_widget(&widget) + .expect("valid widget must pass SchemaValidator::validate_widget"); +} + +// A page with a reserved route is rejected by both UiPage::validate +// and SchemaValidator::validate_page. +// +// The reserved routes exercised here are "/search" and "/admin". +#[test] +fn test_page_with_reserved_route_rejected() { + let reserved = ["/search", "/admin", "/settings", "/library", "/books"]; + + for route in reserved { + // UiPage::validate path + let page = make_page("my-page", route); + let result = page.validate(); + assert!( + result.is_err(), + "UiPage::validate must reject reserved route: {route}" + ); + let msg = result.unwrap_err().to_string(); + assert!( + msg.contains("conflicts with a built-in app route"), + "error for {route} must mention 'conflicts with a built-in app route', \ + got: {msg}" + ); + + // SchemaValidator::validate_page path + let page2 = make_page("my-page", route); + let result2 = SchemaValidator::validate_page(&page2); + assert!( + result2.is_err(), + "SchemaValidator::validate_page must reject reserved route: {route}" + ); + } +} + +// Sub-paths of reserved routes are also rejected. +#[test] +fn test_page_with_reserved_route_subpath_rejected() { + let subpaths = [ + "/search/advanced", + "/admin/users", + "/settings/theme", + "/library/foo", + ]; + for route in subpaths { + let page = make_page("my-page", route); + assert!( + page.validate().is_err(), + "reserved route sub-path must be rejected: {route}" + ); + } +} + +// A UiWidget whose content contains a DataTable fails validation. +// +// Widgets have no data-fetching mechanism; any data source reference in a +// widget content tree must be caught at load time. +#[test] +fn test_widget_with_datatable_fails_validation() { + use pinakes_plugin_api::ColumnDef; + + let col: ColumnDef = + serde_json::from_value(serde_json::json!({"key": "id", "header": "ID"})) + .unwrap(); + + let widget = UiWidget { + id: "bad-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(), + "widget with DataTable content must fail validation" + ); + let msg = result.unwrap_err().to_string(); + assert!( + msg.contains("cannot reference data sources"), + "error must mention data sources, got: {msg}" + ); +} + +// A UiWidget whose content is a Container wrapping a Loop fails +// validation. Basically a recursive data source check. +#[test] +fn test_widget_container_wrapping_loop_fails_validation() { + use pinakes_plugin_api::Expression; + + let widget = 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(), + "widget containing a Loop must fail validation" + ); + let _ = Expression::default(); // Expression should be accessible from the import path +} + +// Endpoint data source with a path that does not start with /api/ fails +// UiPage::validate (DataSource::validate is called there). +#[test] +fn test_endpoint_data_source_non_api_path_rejected() { + let mut page = make_page("my-page", "/plugins/test/page"); + page + .data_sources + .insert("items".to_string(), DataSource::Endpoint { + path: "/v1/media".to_string(), + method: HttpMethod::Get, + params: Default::default(), + poll_interval: 0, + transform: None, + }); + + // DataSource::validate requires /api/ prefix + assert!( + page.validate().is_err(), + "data source path not starting with /api/ must be rejected" + ); + + // Also verify SchemaValidator::validate_page rejects it + let result2 = SchemaValidator::validate_page(&page); + assert!( + result2.is_err(), + "SchemaValidator should reject non-/api/ endpoint path" + ); +} + +// A static data source with any value passes validation on its own. +#[test] +fn test_static_data_source_passes_validation() { + use pinakes_plugin_api::ColumnDef; + + let col: ColumnDef = + serde_json::from_value(serde_json::json!({"key": "n", "header": "N"})) + .unwrap(); + + let mut page = make_page("my-page", "/plugins/test/page"); + page + .data_sources + .insert("nums".to_string(), DataSource::Static { + value: serde_json::json!([1, 2, 3]), + }); + + // Root element references the static data source so DataTable passes + page.root_element = UiElement::DataTable { + data: "nums".to_string(), + columns: vec![col], + sortable: false, + filterable: false, + page_size: 0, + row_actions: vec![], + }; + page + .validate() + .expect("page with static data source must pass validation"); +} + +// Parse TOML string, validate, and load inline pages round-trips without +// errors. +#[test] +fn test_manifest_inline_page_roundtrip() { + use pinakes_plugin_api::{PluginManifest, manifest::UiPageEntry}; + + let toml = r#" +[plugin] +name = "roundtrip-plugin" +version = "2.3.0" +api_version = "1.0" +kind = ["ui_page"] + +[plugin.binary] +wasm = "plugin.wasm" + +[[ui.pages]] +id = "overview" +title = "Overview" +route = "/plugins/roundtrip/overview" + +[ui.pages.layout] +type = "container" +children = [] +gap = 8 +"#; + + let manifest = PluginManifest::parse_str(toml).expect("manifest must parse"); + assert_eq!(manifest.plugin.name, "roundtrip-plugin"); + assert_eq!(manifest.ui.pages.len(), 1); + + match &manifest.ui.pages[0] { + UiPageEntry::Inline(page) => { + assert_eq!(page.id, "overview"); + assert_eq!(page.route, "/plugins/roundtrip/overview"); + // UiPage::validate must also succeed for inline pages + page + .validate() + .expect("inline page must pass UiPage::validate"); + }, + UiPageEntry::File { .. } => { + panic!("expected inline page entry, got file reference"); + }, + } +} + +// warnings by PluginManifest::validate but do NOT cause an error return. +// (The UiSection::validate failure is non-fatal; see manifest.rs.) +#[test] +fn test_manifest_bad_required_endpoint_is_non_fatal() { + use pinakes_plugin_api::PluginManifest; + + let toml = r#" +[plugin] +name = "ep-test" +version = "1.0.0" +api_version = "1.0" +kind = ["ui_page"] + +[plugin.binary] +wasm = "plugin.wasm" + +[ui] +required_endpoints = ["/not-an-api-path"] +"#; + + // PluginManifest::validate emits a tracing::warn for invalid + // required_endpoints but does not return Err - verify the manifest still + // parses successfully. + let result = PluginManifest::parse_str(toml); + assert!( + result.is_ok(), + "bad required_endpoint should be non-fatal for PluginManifest::validate" + ); +} -- 2.43.0 From 4834208f9fbc7776bc8e15f7349f9bf3f0d6eb50 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Wed, 11 Mar 2026 16:55:10 +0300 Subject: [PATCH 22/46] pinakes-core: use checked_sub for Instant arithmetic in pipeline tests Signed-off-by: NotAShelf Change-Id: I785a8e5e521581c78024252fc5baf5616a6a6964 --- crates/pinakes-core/src/plugin/pipeline.rs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/crates/pinakes-core/src/plugin/pipeline.rs b/crates/pinakes-core/src/plugin/pipeline.rs index c2ed430..8add7d1 100644 --- a/crates/pinakes-core/src/plugin/pipeline.rs +++ b/crates/pinakes-core/src/plugin/pipeline.rs @@ -1258,7 +1258,8 @@ mod tests { Instant::now() .checked_sub(CIRCUIT_BREAKER_COOLDOWN) .unwrap() - - Duration::from_secs(1), + .checked_sub(Duration::from_secs(1)) + .unwrap(), ); } @@ -1277,7 +1278,8 @@ mod tests { Instant::now() .checked_sub(CIRCUIT_BREAKER_COOLDOWN) .unwrap() - - Duration::from_secs(1), + .checked_sub(Duration::from_secs(1)) + .unwrap(), ); } assert!(pipeline.is_healthy(plugin_id).await); -- 2.43.0 From ada1c07f667a4e9cae8ca3378f53321db41261d1 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Wed, 11 Mar 2026 16:55:27 +0300 Subject: [PATCH 23/46] pinakes-server: add widget, theme-extension, and event plugin routes; expose `allowed_endpoints` in UI page DTO Signed-off-by: NotAShelf Change-Id: Ia7efa6db85da2d44b59e0e2e57f6e45b6a6a6964 --- crates/pinakes-server/src/app.rs | 3 + crates/pinakes-server/src/dto/plugins.rs | 26 ++++- crates/pinakes-server/src/routes/plugins.rs | 100 ++++++++++++-------- 3 files changed, 89 insertions(+), 40 deletions(-) diff --git a/crates/pinakes-server/src/app.rs b/crates/pinakes-server/src/app.rs index fddc235..f32431c 100644 --- a/crates/pinakes-server/src/app.rs +++ b/crates/pinakes-server/src/app.rs @@ -480,7 +480,10 @@ pub fn create_router_with_tls( .route("/database/backup", post(routes::backup::create_backup)) // Plugin management .route("/plugins", get(routes::plugins::list_plugins)) + .route("/plugins/events", post(routes::plugins::emit_plugin_event)) .route("/plugins/ui-pages", get(routes::plugins::list_plugin_ui_pages)) + .route("/plugins/ui-widgets", get(routes::plugins::list_plugin_ui_widgets)) + .route("/plugins/ui-theme-extensions", get(routes::plugins::list_plugin_ui_theme_extensions)) .route("/plugins/{id}", get(routes::plugins::get_plugin)) .route("/plugins/install", post(routes::plugins::install_plugin)) .route("/plugins/{id}", delete(routes::plugins::uninstall_plugin)) diff --git a/crates/pinakes-server/src/dto/plugins.rs b/crates/pinakes-server/src/dto/plugins.rs index 4ec883d..a80a1f2 100644 --- a/crates/pinakes-server/src/dto/plugins.rs +++ b/crates/pinakes-server/src/dto/plugins.rs @@ -1,4 +1,4 @@ -use pinakes_plugin_api::UiPage; +use pinakes_plugin_api::{UiPage, UiWidget}; use serde::{Deserialize, Serialize}; #[derive(Debug, Serialize)] @@ -26,9 +26,29 @@ pub struct TogglePluginRequest { #[derive(Debug, Serialize)] pub struct PluginUiPageEntry { /// Plugin ID that provides this page - pub plugin_id: String, + pub plugin_id: String, /// Full page definition - pub page: UiPage, + pub page: UiPage, + /// Endpoint paths this plugin is allowed to fetch (empty means no + /// restriction) + pub allowed_endpoints: Vec, +} + +/// A single plugin UI widget entry in the list response +#[derive(Debug, Serialize)] +pub struct PluginUiWidgetEntry { + /// Plugin ID that provides this widget + pub plugin_id: String, + /// Full widget definition + pub widget: UiWidget, +} + +/// Request body for emitting a plugin event +#[derive(Debug, Deserialize)] +pub struct PluginEventRequest { + pub event: String, + #[serde(default)] + pub payload: serde_json::Value, } impl PluginResponse { diff --git a/crates/pinakes-server/src/routes/plugins.rs b/crates/pinakes-server/src/routes/plugins.rs index 429fb87..5653d23 100644 --- a/crates/pinakes-server/src/routes/plugins.rs +++ b/crates/pinakes-server/src/routes/plugins.rs @@ -1,28 +1,39 @@ +use std::{collections::HashMap, sync::Arc}; + use axum::{ Json, extract::{Path, State}, }; +use pinakes_core::plugin::PluginManager; use crate::{ dto::{ InstallPluginRequest, + PluginEventRequest, PluginResponse, PluginUiPageEntry, + PluginUiWidgetEntry, TogglePluginRequest, }, error::ApiError, state::AppState, }; +fn require_plugin_manager( + state: &AppState, +) -> Result, ApiError> { + state.plugin_manager.clone().ok_or_else(|| { + ApiError(pinakes_core::error::PinakesError::InvalidOperation( + "Plugin system is not enabled".to_string(), + )) + }) +} + /// List all installed plugins pub async fn list_plugins( State(state): State, ) -> Result>, ApiError> { - let plugin_manager = state.plugin_manager.as_ref().ok_or_else(|| { - ApiError(pinakes_core::error::PinakesError::InvalidOperation( - "Plugin system is not enabled".to_string(), - )) - })?; + let plugin_manager = require_plugin_manager(&state)?; let plugins = plugin_manager.list_plugins().await; let mut responses = Vec::with_capacity(plugins.len()); @@ -38,11 +49,7 @@ pub async fn get_plugin( State(state): State, Path(id): Path, ) -> Result, ApiError> { - let plugin_manager = state.plugin_manager.as_ref().ok_or_else(|| { - ApiError(pinakes_core::error::PinakesError::InvalidOperation( - "Plugin system is not enabled".to_string(), - )) - })?; + let plugin_manager = require_plugin_manager(&state)?; let plugin = plugin_manager.get_plugin(&id).await.ok_or_else(|| { ApiError(pinakes_core::error::PinakesError::NotFound(format!( @@ -59,11 +66,7 @@ pub async fn install_plugin( State(state): State, Json(req): Json, ) -> Result, ApiError> { - let plugin_manager = state.plugin_manager.as_ref().ok_or_else(|| { - ApiError(pinakes_core::error::PinakesError::InvalidOperation( - "Plugin system is not enabled".to_string(), - )) - })?; + let plugin_manager = require_plugin_manager(&state)?; let plugin_id = plugin_manager @@ -91,11 +94,7 @@ pub async fn uninstall_plugin( State(state): State, Path(id): Path, ) -> Result, ApiError> { - let plugin_manager = state.plugin_manager.as_ref().ok_or_else(|| { - ApiError(pinakes_core::error::PinakesError::InvalidOperation( - "Plugin system is not enabled".to_string(), - )) - })?; + let plugin_manager = require_plugin_manager(&state)?; plugin_manager.uninstall_plugin(&id).await.map_err(|e| { ApiError(pinakes_core::error::PinakesError::InvalidOperation( @@ -112,11 +111,7 @@ pub async fn toggle_plugin( Path(id): Path, Json(req): Json, ) -> Result, ApiError> { - let plugin_manager = state.plugin_manager.as_ref().ok_or_else(|| { - ApiError(pinakes_core::error::PinakesError::InvalidOperation( - "Plugin system is not enabled".to_string(), - )) - })?; + let plugin_manager = require_plugin_manager(&state)?; if req.enabled { plugin_manager.enable_plugin(&id).await.map_err(|e| { @@ -153,30 +148,61 @@ pub async fn toggle_plugin( pub async fn list_plugin_ui_pages( State(state): State, ) -> Result>, ApiError> { - let plugin_manager = state.plugin_manager.as_ref().ok_or_else(|| { - ApiError(pinakes_core::error::PinakesError::InvalidOperation( - "Plugin system is not enabled".to_string(), - )) - })?; + let plugin_manager = require_plugin_manager(&state)?; - let pages = plugin_manager.list_ui_pages().await; + let pages = plugin_manager.list_ui_pages_with_endpoints().await; let entries = pages .into_iter() - .map(|(plugin_id, page)| PluginUiPageEntry { plugin_id, page }) + .map(|(plugin_id, page, allowed_endpoints)| PluginUiPageEntry { + plugin_id, + page, + allowed_endpoints, + }) .collect(); Ok(Json(entries)) } +/// List all UI widgets provided by loaded plugins +pub async fn list_plugin_ui_widgets( + State(state): State, +) -> Result>, ApiError> { + let plugin_manager = require_plugin_manager(&state)?; + + let widgets = plugin_manager.list_ui_widgets().await; + let entries = widgets + .into_iter() + .map(|(plugin_id, widget)| PluginUiWidgetEntry { plugin_id, widget }) + .collect(); + Ok(Json(entries)) +} + +/// Receive a plugin event emitted from the UI and dispatch it to interested +/// server-side event-handler plugins via the pipeline. +pub async fn emit_plugin_event( + State(state): State, + Json(req): Json, +) -> Result, ApiError> { + tracing::info!(event = %req.event, "plugin UI event received"); + state.emit_plugin_event(&req.event, &req.payload); + Ok(Json( + serde_json::json!({ "received": true, "event": req.event }), + )) +} + +/// List merged CSS custom property overrides from all enabled plugins +pub async fn list_plugin_ui_theme_extensions( + State(state): State, +) -> Result>, ApiError> { + let plugin_manager = require_plugin_manager(&state)?; + Ok(Json(plugin_manager.list_ui_theme_extensions().await)) +} + /// Reload a plugin (for development) pub async fn reload_plugin( State(state): State, Path(id): Path, ) -> Result, ApiError> { - let plugin_manager = state.plugin_manager.as_ref().ok_or_else(|| { - ApiError(pinakes_core::error::PinakesError::InvalidOperation( - "Plugin system is not enabled".to_string(), - )) - })?; + let plugin_manager = require_plugin_manager(&state)?; plugin_manager.reload_plugin(&id).await.map_err(|e| { ApiError(pinakes_core::error::PinakesError::InvalidOperation( -- 2.43.0 From 9389af9fda562c085d247a404a3ed7a8558699c5 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Wed, 11 Mar 2026 17:00:37 +0300 Subject: [PATCH 24/46] pinakes-ui: enforce plugin endpoint allowlist; replace inline styles with CSS custom properties Signed-off-by: NotAShelf Change-Id: I751e5c7ec66f045ee1f0bad6c72759416a6a6964 --- Cargo.lock | 2 + Cargo.toml | 2 + crates/pinakes-ui/Cargo.toml | 1 + crates/pinakes-ui/assets/css/main.css | 2 +- crates/pinakes-ui/assets/styles/_plugins.scss | 94 ++ crates/pinakes-ui/assets/styles/main.scss | 1 + crates/pinakes-ui/src/app.rs | 140 +- crates/pinakes-ui/src/client.rs | 85 +- crates/pinakes-ui/src/plugin_ui/data.rs | 561 +++++-- crates/pinakes-ui/src/plugin_ui/registry.rs | 424 ++++-- crates/pinakes-ui/src/plugin_ui/renderer.rs | 1352 ++++++++++------- 11 files changed, 1886 insertions(+), 778 deletions(-) create mode 100644 crates/pinakes-ui/assets/styles/_plugins.scss diff --git a/Cargo.lock b/Cargo.lock index 682fbca..165cf69 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5446,6 +5446,7 @@ dependencies = [ "thiserror 2.0.18", "tokio", "toml 1.0.6+spec-1.1.0", + "tracing", "uuid", "wit-bindgen 0.53.1", ] @@ -5511,6 +5512,7 @@ dependencies = [ "chrono", "clap", "dioxus", + "dioxus-core", "dioxus-free-icons", "futures", "gloo-timers", diff --git a/Cargo.toml b/Cargo.toml index 53a54b3..f0103d3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -100,6 +100,7 @@ crossterm = "0.29.0" # Desktop/Web UI dioxus = { version = "0.7.3", features = ["desktop", "router"] } +dioxus-core = { version = "0.7.3" } # Async trait (dyn-compatible async methods) async-trait = "0.1.89" @@ -187,6 +188,7 @@ undocumented_unsafe_blocks = "warn" unnecessary_safety_comment = "warn" unused_result_ok = "warn" unused_trait_names = "allow" +too_many_arguments = "allow" # False positive: # clippy's build script check doesn't recognize workspace-inherited metadata diff --git a/crates/pinakes-ui/Cargo.toml b/crates/pinakes-ui/Cargo.toml index 6dd8a0e..43bf1c6 100644 --- a/crates/pinakes-ui/Cargo.toml +++ b/crates/pinakes-ui/Cargo.toml @@ -15,6 +15,7 @@ tracing = { workspace = true } tracing-subscriber = { workspace = true } reqwest = { workspace = true } dioxus = { workspace = true } +dioxus-core = { workspace = true } tokio = { workspace = true } futures = { workspace = true } rfd = { workspace = true } diff --git a/crates/pinakes-ui/assets/css/main.css b/crates/pinakes-ui/assets/css/main.css index 3bddc22..30f105a 100644 --- a/crates/pinakes-ui/assets/css/main.css +++ b/crates/pinakes-ui/assets/css/main.css @@ -1 +1 @@ -@media (prefers-reduced-motion: reduce){*,*::before,*::after{animation-duration:.01ms !important;animation-iteration-count:1 !important;transition-duration:.01ms !important}}*{margin:0;padding:0;box-sizing:border-box;scrollbar-width:thin;scrollbar-color:rgba(255,255,255,.06) rgba(0,0,0,0)}*::-webkit-scrollbar{width:5px;height:5px}*::-webkit-scrollbar-track{background:rgba(0,0,0,0)}*::-webkit-scrollbar-thumb{background:rgba(255,255,255,.06);border-radius:3px}*::-webkit-scrollbar-thumb:hover{background:rgba(255,255,255,.14)}:root{--bg-0: #111118;--bg-1: #18181f;--bg-2: #1f1f28;--bg-3: #26263a;--border-subtle: rgba(255,255,255,.06);--border: rgba(255,255,255,.09);--border-strong: rgba(255,255,255,.14);--text-0: #dcdce4;--text-1: #a0a0b8;--text-2: #6c6c84;--accent: #7c7ef5;--accent-dim: rgba(124,126,245,.15);--accent-text: #9698f7;--success: #3ec97a;--error: #e45858;--warning: #d4a037;--radius-sm: 3px;--radius: 5px;--radius-md: 7px;--shadow-sm: 0 1px 3px rgba(0,0,0,.3);--shadow: 0 2px 8px rgba(0,0,0,.35);--shadow-lg: 0 4px 20px rgba(0,0,0,.45)}body{font-family:"Inter",-apple-system,"Segoe UI",system-ui,sans-serif;background:var(--bg-0);color:var(--text-0);font-size:13px;line-height:1.5;-webkit-font-smoothing:antialiased;overflow:hidden}:focus-visible:focus-visible{outline:2px solid #7c7ef5;outline-offset:2px}::selection{background:rgba(124,126,245,.15);color:#9698f7}a{color:#9698f7;text-decoration:none}a:hover{text-decoration:underline}code{padding:1px 5px;border-radius:3px;background:#111118;color:#9698f7;font-family:"JetBrains Mono","Fira Code",ui-monospace,monospace;font-size:11px}ul{list-style:none;padding:0}ul li{padding:3px 0;font-size:12px;color:#a0a0b8}.text-muted{color:#a0a0b8}.text-sm{font-size:11px}.mono{font-family:"JetBrains Mono","Fira Code",ui-monospace,monospace;font-size:12px}.flex-row{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:8px}.flex-between{display:flex;justify-content:space-between;align-items:center}.mb-16{margin-bottom:16px}.mb-8{margin-bottom:12px}@keyframes fade-in{from{opacity:0}to{opacity:1}}@keyframes slide-up{from{opacity:0;transform:translateY(8px)}to{opacity:1;transform:translateY(0)}}@keyframes pulse{0%, 100%{opacity:1}50%{opacity:.3}}@keyframes spin{to{transform:rotate(360deg)}}@keyframes skeleton-pulse{0%{opacity:.6}50%{opacity:.3}100%{opacity:.6}}@keyframes indeterminate{0%{transform:translateX(-100%)}100%{transform:translateX(400%)}}.app{display:flex;flex-direction:row;justify-content:flex-start;align-items:stretch;height:100vh;overflow:hidden}.sidebar{width:220px;min-width:220px;max-width:220px;background:#18181f;border-right:1px solid rgba(255,255,255,.09);display:flex;flex-direction:column;justify-content:flex-start;align-items:stretch;flex-shrink:0;user-select:none;overflow-y:auto;overflow-x:hidden;z-index:10;transition:width .15s,min-width .15s,max-width .15s}.sidebar.collapsed{width:48px;min-width:48px;max-width:48px}.sidebar.collapsed .nav-label,.sidebar.collapsed .sidebar-header .logo,.sidebar.collapsed .sidebar-header .version,.sidebar.collapsed .nav-badge,.sidebar.collapsed .nav-item-text,.sidebar.collapsed .sidebar-footer .status-text,.sidebar.collapsed .user-name,.sidebar.collapsed .role-badge,.sidebar.collapsed .user-info .btn,.sidebar.collapsed .sidebar-import-header span,.sidebar.collapsed .sidebar-import-file{display:none}.sidebar.collapsed .nav-item{justify-content:center;padding:8px;border-left:none;border-radius:3px}.sidebar.collapsed .nav-item.active{border-left:none}.sidebar.collapsed .nav-icon{width:auto;margin:0}.sidebar.collapsed .sidebar-header{padding:12px 8px;justify-content:center}.sidebar.collapsed .nav-section{padding:0 4px}.sidebar.collapsed .sidebar-footer{padding:8px}.sidebar.collapsed .sidebar-footer .user-info{justify-content:center;padding:4px}.sidebar.collapsed .sidebar-import-progress{padding:6px}.sidebar-header{padding:16px 16px 20px;display:flex;flex-direction:row;justify-content:flex-start;align-items:baseline;gap:8px}.sidebar-header .logo{font-size:15px;font-weight:700;letter-spacing:-.4px;color:#dcdce4}.sidebar-header .version{font-size:10px;color:#6c6c84}.sidebar-toggle{background:rgba(0,0,0,0);border:none;color:#6c6c84;padding:8px;font-size:18px;width:100%;text-align:center}.sidebar-toggle:hover{color:#dcdce4}.sidebar-spacer{flex:1}.sidebar-footer{padding:12px;border-top:1px solid rgba(255,255,255,.06);overflow:visible;min-width:0}.nav-section{padding:0 8px;margin-bottom:2px}.nav-label{padding:8px 8px 4px;font-size:10px;font-weight:600;text-transform:uppercase;letter-spacing:.04em;color:#6c6c84}.nav-item{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:8px;padding:6px 8px;border-radius:3px;cursor:pointer;color:#a0a0b8;font-size:13px;font-weight:450;transition:color .1s,background .1s;border:none;background:none;width:100%;text-align:left;border-left:2px solid rgba(0,0,0,0);margin-left:0}.nav-item:hover{color:#dcdce4;background:rgba(255,255,255,.03)}.nav-item.active{color:#9698f7;border-left-color:#7c7ef5;background:rgba(124,126,245,.15)}.nav-item-text{flex:1;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;min-width:0}.sidebar:not(.collapsed) .nav-item-text{overflow:visible}.nav-icon{width:18px;text-align:center;font-size:14px;opacity:.7}.nav-badge{margin-left:auto;font-size:10px;font-weight:600;color:#6c6c84;background:#26263a;padding:1px 6px;border-radius:12px;min-width:20px;text-align:center;font-variant-numeric:tabular-nums}.status-indicator{display:flex;flex-direction:row;justify-content:center;align-items:center;gap:6px;font-size:11px;font-weight:500;min-width:0;overflow:visible}.sidebar:not(.collapsed) .status-indicator{justify-content:flex-start}.status-dot{width:6px;height:6px;border-radius:50%;flex-shrink:0}.status-dot.connected{background:#3ec97a}.status-dot.disconnected{background:#e45858}.status-dot.checking{background:#d4a037;animation:pulse 1.5s infinite}.status-text{color:#6c6c84;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;min-width:0}.sidebar:not(.collapsed) .status-text{overflow:visible}.main{flex:1;display:flex;flex-direction:column;justify-content:flex-start;align-items:stretch;overflow:hidden;min-width:0}.header{height:48px;min-height:48px;border-bottom:1px solid rgba(255,255,255,.06);display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:12px;padding:0 20px;background:#18181f}.page-title{font-size:14px;font-weight:600;color:#dcdce4}.header-spacer{flex:1}.content{flex:1;overflow-y:auto;padding:20px;scrollbar-width:thin;scrollbar-color:rgba(255,255,255,.06) rgba(0,0,0,0)}.sidebar-import-progress{padding:10px 12px;background:#1f1f28;border-top:1px solid rgba(255,255,255,.06);font-size:11px}.sidebar-import-header{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:6px;margin-bottom:4px;color:#a0a0b8}.sidebar-import-file{color:#6c6c84;font-size:10px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;margin-bottom:4px}.sidebar-import-progress .progress-bar{height:3px}.user-info{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:6px;font-size:12px;overflow:hidden;min-width:0}.user-name{font-weight:500;color:#dcdce4;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:90px;flex-shrink:1}.role-badge{display:inline-block;padding:1px 6px;border-radius:3px;font-size:10px;font-weight:600;text-transform:uppercase}.role-badge.role-admin{background:rgba(139,92,246,.1);color:#9d8be0}.role-badge.role-editor{background:rgba(34,160,80,.1);color:#5cb97a}.role-badge.role-viewer{background:rgba(59,120,200,.1);color:#6ca0d4}.btn{padding:5px 12px;border-radius:3px;border:none;cursor:pointer;font-size:12px;font-weight:500;transition:all .1s;display:inline-flex;align-items:center;gap:5px;white-space:nowrap;line-height:1.5}.btn-primary{background:#7c7ef5;color:#fff}.btn-primary:hover{background:#8b8df7}.btn-secondary{background:#26263a;color:#dcdce4;border:1px solid rgba(255,255,255,.09)}.btn-secondary:hover{border-color:rgba(255,255,255,.14);background:rgba(255,255,255,.06)}.btn-danger{background:rgba(0,0,0,0);color:#e45858;border:1px solid rgba(228,88,88,.25)}.btn-danger:hover{background:rgba(228,88,88,.08)}.btn-ghost{background:rgba(0,0,0,0);border:none;color:#a0a0b8;padding:5px 8px}.btn-ghost:hover{color:#dcdce4;background:rgba(255,255,255,.04)}.btn-sm{padding:3px 8px;font-size:11px}.btn-icon{padding:4px;border-radius:3px;background:rgba(0,0,0,0);border:none;color:#6c6c84;cursor:pointer;transition:color .1s;font-size:13px}.btn-icon:hover{color:#dcdce4}.btn:disabled,.btn[disabled]{opacity:.4;cursor:not-allowed;pointer-events:none}.btn.btn-disabled-hint:disabled{opacity:.6;border-style:dashed;pointer-events:auto;cursor:help}.card{background:#1f1f28;border:1px solid rgba(255,255,255,.09);border-radius:5px;padding:16px}.card-header{display:flex;justify-content:space-between;align-items:center;margin-bottom:12px}.card-title{font-size:14px;font-weight:600}.data-table{width:100%;border-collapse:collapse;background:#1f1f28;border:1px solid rgba(255,255,255,.09);border-radius:5px;overflow:hidden}.data-table thead th{padding:8px 14px;text-align:left;font-size:10px;font-weight:600;text-transform:uppercase;letter-spacing:.04em;color:#6c6c84;border-bottom:1px solid rgba(255,255,255,.09);background:#26263a}.data-table tbody td{padding:8px 14px;font-size:13px;border-bottom:1px solid rgba(255,255,255,.06);max-width:300px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.data-table tbody tr{cursor:pointer;transition:background .08s}.data-table tbody tr:hover{background:rgba(255,255,255,.02)}.data-table tbody tr.row-selected{background:rgba(99,102,241,.12)}.data-table tbody tr:last-child td{border-bottom:none}.sortable-header{cursor:pointer;user-select:none;transition:color .1s}.sortable-header:hover{color:#9698f7}input[type=text],textarea,select{padding:6px 10px;border-radius:3px;border:1px solid rgba(255,255,255,.09);background:#111118;color:#dcdce4;font-size:13px;outline:none;transition:border-color .15s;font-family:inherit}input[type=text]::placeholder,textarea::placeholder,select::placeholder{color:#6c6c84}input[type=text]:focus,textarea:focus,select:focus{border-color:#7c7ef5}input[type=text][type=number],textarea[type=number],select[type=number]{width:80px;padding:6px 8px;-moz-appearance:textfield}input[type=text][type=number]::-webkit-outer-spin-button,input[type=text][type=number]::-webkit-inner-spin-button,textarea[type=number]::-webkit-outer-spin-button,textarea[type=number]::-webkit-inner-spin-button,select[type=number]::-webkit-outer-spin-button,select[type=number]::-webkit-inner-spin-button{-webkit-appearance:none;margin:0}textarea{min-height:64px;resize:vertical}select{appearance:none;-webkit-appearance:none;background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='10' viewBox='0 0 10 10'%3E%3Cpath fill='%236c6c84' d='M5 7L1 3h8z'/%3E%3C/svg%3E");background-repeat:no-repeat;background-position:right 8px center;padding-right:26px;min-width:100px}.form-group{margin-bottom:12px}.form-label{display:block;font-size:11px;font-weight:600;color:#a0a0b8;margin-bottom:4px;text-transform:uppercase;letter-spacing:.03em}.form-row{display:flex;flex-direction:row;justify-content:flex-start;align-items:flex-end;gap:8px}.form-row input[type=text]{flex:1}.form-label-row{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:2px;margin-bottom:4px}.form-label-row .form-label{margin-bottom:0}input[type=checkbox]{appearance:none;-webkit-appearance:none;width:16px;height:16px;border:1px solid rgba(255,255,255,.14);border-radius:3px;background:#1f1f28;cursor:pointer;position:relative;flex-shrink:0;transition:all .15s ease}input[type=checkbox]:hover{border-color:#7c7ef5;background:#26263a}input[type=checkbox]:checked{background:#7c7ef5;border-color:#7c7ef5}input[type=checkbox]:checked::after{content:"";position:absolute;left:5px;top:2px;width:4px;height:8px;border:solid #111118;border-width:0 2px 2px 0;transform:rotate(45deg)}input[type=checkbox]:focus-visible:focus-visible{outline:2px solid #7c7ef5;outline-offset:2px}.checkbox-label{display:inline-flex;align-items:center;gap:8px;cursor:pointer;font-size:13px;color:#a0a0b8;user-select:none}.checkbox-label:hover{color:#dcdce4}.checkbox-label input[type=checkbox]{margin:0}.toggle{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:8px;cursor:pointer;font-size:13px;color:#dcdce4}.toggle.disabled{opacity:.4;cursor:not-allowed}.toggle-track{width:32px;height:18px;border-radius:9px;background:#26263a;border:1px solid rgba(255,255,255,.09);position:relative;transition:background .15s;flex-shrink:0}.toggle-track.active{background:#7c7ef5;border-color:#7c7ef5}.toggle-track.active .toggle-thumb{transform:translateX(14px)}.toggle-thumb{width:14px;height:14px;border-radius:50%;background:#dcdce4;position:absolute;top:1px;left:1px;transition:transform .15s}.filter-bar{display:flex;flex-direction:column;justify-content:flex-start;align-items:stretch;gap:12px;padding:12px;background:#111118;border:1px solid rgba(255,255,255,.06);border-radius:5px;margin-bottom:12px}.filter-row{display:flex;flex-wrap:wrap;align-items:center;gap:8px}.filter-label{font-size:11px;font-weight:500;color:#6c6c84;text-transform:uppercase;letter-spacing:.5px;margin-right:4px}.filter-chip{display:inline-flex;align-items:center;gap:6px;padding:5px 10px;background:#1f1f28;border:1px solid rgba(255,255,255,.09);border-radius:14px;cursor:pointer;font-size:11px;color:#a0a0b8;transition:all .15s ease;user-select:none}.filter-chip:hover{background:#26263a;border-color:rgba(255,255,255,.14);color:#dcdce4}.filter-chip.active{background:rgba(124,126,245,.15);border-color:#7c7ef5;color:#9698f7}.filter-chip input[type=checkbox]{width:12px;height:12px;margin:0}.filter-chip input[type=checkbox]:checked::after{left:3px;top:1px;width:3px;height:6px}.filter-group{display:flex;align-items:center;gap:6px}.filter-group label{display:flex;align-items:center;gap:3px;cursor:pointer;color:#a0a0b8;font-size:11px;white-space:nowrap}.filter-group label:hover{color:#dcdce4}.filter-separator{width:1px;height:20px;background:rgba(255,255,255,.09);flex-shrink:0}.view-toggle{display:flex;border:1px solid rgba(255,255,255,.09);border-radius:3px;overflow:hidden}.view-btn{padding:4px 10px;background:#1f1f28;border:none;color:#6c6c84;cursor:pointer;font-size:18px;line-height:1;transition:background .1s,color .1s}.view-btn:first-child{border-right:1px solid rgba(255,255,255,.09)}.view-btn:hover{color:#dcdce4;background:#26263a}.view-btn.active{background:rgba(124,126,245,.15);color:#9698f7}.breadcrumb{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:4px;padding:10px 16px;font-size:.85rem;color:#6c6c84}.breadcrumb-sep{color:#6c6c84;opacity:.5}.breadcrumb-link{color:#9698f7;text-decoration:none;cursor:pointer}.breadcrumb-link:hover{text-decoration:underline}.breadcrumb-current{color:#dcdce4;font-weight:500}.progress-bar{width:100%;height:8px;background:#26263a;border-radius:4px;overflow:hidden;margin-bottom:6px}.progress-fill{height:100%;background:#7c7ef5;border-radius:4px;transition:width .3s ease}.progress-fill.indeterminate{width:30%;animation:indeterminate 1.5s ease-in-out infinite}.loading-overlay{display:flex;align-items:center;justify-content:center;padding:48px 16px;color:#6c6c84;font-size:13px;gap:10px}.spinner{width:18px;height:18px;border:2px solid rgba(255,255,255,.09);border-top-color:#7c7ef5;border-radius:50%;animation:spin .7s linear infinite}.spinner-small{width:14px;height:14px;border:2px solid rgba(255,255,255,.09);border-top-color:#7c7ef5;border-radius:50%;animation:spin .7s linear infinite}.spinner-tiny{width:10px;height:10px;border:1.5px solid rgba(255,255,255,.09);border-top-color:#7c7ef5;border-radius:50%;animation:spin .7s linear infinite}.modal-overlay{position:fixed;inset:0;background:rgba(0,0,0,.5);display:flex;align-items:center;justify-content:center;z-index:100;animation:fade-in .1s ease-out}.modal{background:#1f1f28;border:1px solid rgba(255,255,255,.09);border-radius:7px;padding:20px;min-width:360px;max-width:480px;box-shadow:0 4px 20px rgba(0,0,0,.45)}.modal.wide{max-width:600px;max-height:70vh;overflow-y:auto}.modal-title{font-size:15px;font-weight:600;margin-bottom:6px}.modal-body{font-size:12px;color:#a0a0b8;margin-bottom:16px;line-height:1.5}.modal-actions{display:flex;flex-direction:row;justify-content:flex-end;align-items:center;gap:6px}.tooltip-trigger{display:inline-flex;align-items:center;justify-content:center;width:14px;height:14px;border-radius:50%;background:#26263a;color:#6c6c84;font-size:9px;font-weight:700;cursor:help;position:relative;flex-shrink:0;margin-left:4px}.tooltip-trigger:hover{background:rgba(124,126,245,.15);color:#9698f7}.tooltip-trigger:hover .tooltip-text{display:block}.tooltip-text{display:none;position:absolute;bottom:calc(100% + 6px);left:50%;transform:translateX(-50%);padding:6px 10px;background:#26263a;border:1px solid rgba(255,255,255,.09);border-radius:3px;color:#dcdce4;font-size:11px;font-weight:400;line-height:1.4;white-space:normal;width:220px;text-transform:none;letter-spacing:normal;box-shadow:0 2px 8px rgba(0,0,0,.35);z-index:100;pointer-events:none}.media-player{position:relative;background:#111118;border-radius:5px;overflow:hidden}.media-player:focus{outline:none}.media-player-audio .player-artwork{display:flex;flex-direction:column;justify-content:center;align-items:center;gap:8px;padding:24px 16px 8px}.player-artwork img{max-width:200px;max-height:200px;border-radius:5px;object-fit:cover}.player-artwork-placeholder{width:120px;height:120px;display:flex;align-items:center;justify-content:center;background:#1f1f28;border-radius:5px;font-size:48px;opacity:.3}.player-title{font-size:13px;font-weight:500;color:#dcdce4;text-align:center}.player-controls{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:8px;padding:10px 14px;background:#1f1f28}.media-player-video .player-controls{position:absolute;bottom:0;left:0;right:0;background:rgba(0,0,0,.7);opacity:0;transition:opacity .2s}.media-player-video:hover .player-controls{opacity:1}.play-btn,.mute-btn,.fullscreen-btn{background:none;border:none;color:#dcdce4;cursor:pointer;font-size:18px;padding:4px;line-height:1;transition:color .1s}.play-btn:hover,.mute-btn:hover,.fullscreen-btn:hover{color:#9698f7}.player-time{font-size:11px;color:#6c6c84;font-family:"JetBrains Mono","Fira Code",ui-monospace,monospace;min-width:36px;text-align:center;user-select:none}.seek-bar{flex:1;-webkit-appearance:none;appearance:none;height:4px;border-radius:4px;background:#26263a;outline:none;cursor:pointer}.seek-bar::-webkit-slider-thumb{-webkit-appearance:none;appearance:none;width:12px;height:12px;border-radius:50%;background:#7c7ef5;cursor:pointer;border:none}.seek-bar::-moz-range-thumb{width:12px;height:12px;border-radius:50%;background:#7c7ef5;cursor:pointer;border:none}.volume-slider{width:70px;-webkit-appearance:none;appearance:none;height:4px;border-radius:4px;background:#26263a;outline:none;cursor:pointer}.volume-slider::-webkit-slider-thumb{-webkit-appearance:none;appearance:none;width:10px;height:10px;border-radius:50%;background:#a0a0b8;cursor:pointer;border:none}.volume-slider::-moz-range-thumb{width:10px;height:10px;border-radius:50%;background:#a0a0b8;cursor:pointer;border:none}.image-viewer-overlay{position:fixed;inset:0;background:rgba(0,0,0,.92);z-index:150;display:flex;flex-direction:column;justify-content:flex-start;align-items:stretch;animation:fade-in .15s ease-out}.image-viewer-overlay:focus{outline:none}.image-viewer-toolbar{display:flex;justify-content:space-between;align-items:center;padding:10px 16px;background:rgba(0,0,0,.5);border-bottom:1px solid rgba(255,255,255,.08);z-index:2;user-select:none}.image-viewer-toolbar-left,.image-viewer-toolbar-center,.image-viewer-toolbar-right{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:6px}.iv-btn{background:rgba(255,255,255,.06);border:1px solid rgba(255,255,255,.1);color:#dcdce4;border-radius:3px;padding:4px 10px;font-size:12px;cursor:pointer;transition:background .1s}.iv-btn:hover{background:rgba(255,255,255,.12)}.iv-btn.iv-close{color:#e45858;font-weight:600}.iv-zoom-label{font-size:11px;color:#a0a0b8;min-width:40px;text-align:center;font-family:"JetBrains Mono","Fira Code",ui-monospace,monospace}.image-viewer-canvas{flex:1;display:flex;align-items:center;justify-content:center;overflow:hidden;position:relative}.image-viewer-canvas img{max-width:100%;max-height:100%;object-fit:contain;user-select:none;-webkit-user-drag:none}.pdf-viewer{display:flex;flex-direction:column;justify-content:flex-start;align-items:stretch;height:100%;min-height:500px;background:#111118;border-radius:5px;overflow:hidden}.pdf-toolbar{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:12px;padding:10px 12px;background:#18181f;border-bottom:1px solid rgba(255,255,255,.09)}.pdf-toolbar-group{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:4px}.pdf-toolbar-btn{display:flex;align-items:center;justify-content:center;width:28px;height:28px;background:#1f1f28;border:1px solid rgba(255,255,255,.09);border-radius:3px;color:#a0a0b8;font-size:14px;cursor:pointer;transition:all .15s}.pdf-toolbar-btn:hover:not(:disabled){background:#26263a;color:#dcdce4}.pdf-toolbar-btn:disabled{opacity:.4;cursor:not-allowed}.pdf-zoom-label{min-width:45px;text-align:center;font-size:12px;color:#a0a0b8}.pdf-container{flex:1;position:relative;overflow:hidden;background:#1f1f28}.pdf-object{width:100%;height:100%;border:none}.pdf-loading,.pdf-error{position:absolute;inset:0;display:flex;flex-direction:column;justify-content:center;align-items:center;gap:12px;background:#18181f;color:#a0a0b8}.pdf-error{padding:12px;text-align:center}.pdf-fallback{display:flex;flex-direction:column;justify-content:center;align-items:center;gap:16px;padding:48px 12px;text-align:center;color:#6c6c84}.markdown-viewer{padding:16px;text-align:left;display:flex;flex-direction:column;justify-content:flex-start;align-items:stretch;gap:12px}.markdown-toolbar{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:8px;padding:8px;background:#1f1f28;border-radius:5px;border:1px solid rgba(255,255,255,.09)}.toolbar-btn{padding:6px 12px;border:1px solid rgba(255,255,255,.09);border-radius:3px;background:#18181f;color:#a0a0b8;font-size:13px;font-weight:500;cursor:pointer;transition:all .15s}.toolbar-btn:hover{background:#26263a;border-color:rgba(255,255,255,.14)}.toolbar-btn.active{background:#7c7ef5;color:#fff;border-color:#7c7ef5}.markdown-source{max-width:100%;background:#26263a;border:1px solid rgba(255,255,255,.09);border-radius:5px;padding:16px;overflow-x:auto;font-family:"Menlo","Monaco","Courier New",monospace;font-size:13px;line-height:1.7;color:#dcdce4;white-space:pre-wrap;word-wrap:break-word}.markdown-source code{font-family:inherit;background:none;padding:0;border:none}.markdown-content{max-width:800px;color:#dcdce4;line-height:1.7;font-size:14px;text-align:left}.markdown-content h1{font-size:1.8em;font-weight:700;margin:1em 0 .5em;border-bottom:1px solid rgba(255,255,255,.06);padding-bottom:.3em}.markdown-content h2{font-size:1.5em;font-weight:600;margin:.8em 0 .4em;border-bottom:1px solid rgba(255,255,255,.06);padding-bottom:.2em}.markdown-content h3{font-size:1.25em;font-weight:600;margin:.6em 0 .3em}.markdown-content h4{font-size:1.1em;font-weight:600;margin:.5em 0 .25em}.markdown-content h5,.markdown-content h6{font-size:1em;font-weight:600;margin:.4em 0 .2em;color:#a0a0b8}.markdown-content p{margin:0 0 1em}.markdown-content a{color:#7c7ef5;text-decoration:none}.markdown-content a:hover{text-decoration:underline}.markdown-content pre{background:#26263a;border:1px solid rgba(255,255,255,.09);border-radius:3px;padding:12px 16px;overflow-x:auto;margin:0 0 1em;font-family:"JetBrains Mono","Fira Code",ui-monospace,monospace;font-size:12px;line-height:1.5}.markdown-content code{background:#26263a;padding:1px 5px;border-radius:3px;font-family:"JetBrains Mono","Fira Code",ui-monospace,monospace;font-size:.9em}.markdown-content pre code{background:none;padding:0}.markdown-content blockquote{border-left:3px solid #7c7ef5;padding:4px 16px;margin:0 0 1em;color:#a0a0b8;background:rgba(124,126,245,.04)}.markdown-content table{width:100%;border-collapse:collapse;margin:0 0 1em}.markdown-content th,.markdown-content td{padding:6px 12px;border:1px solid rgba(255,255,255,.09);font-size:13px}.markdown-content th{background:#26263a;font-weight:600;text-align:left}.markdown-content tr:nth-child(even){background:#1f1f28}.markdown-content ul,.markdown-content ol{margin:0 0 1em;padding-left:16px}.markdown-content ul{list-style:disc}.markdown-content ol{list-style:decimal}.markdown-content li{padding:2px 0;font-size:14px;color:#dcdce4}.markdown-content hr{border:none;border-top:1px solid rgba(255,255,255,.09);margin:1.5em 0}.markdown-content img{max-width:100%;border-radius:5px}.markdown-content .footnote-definition{font-size:.85em;color:#a0a0b8;margin-top:.5em;padding-left:1.5em}.markdown-content .footnote-definition sup{color:#7c7ef5;margin-right:4px}.markdown-content sup a{color:#7c7ef5;text-decoration:none;font-size:.8em}.wikilink{color:#9698f7;text-decoration:none;border-bottom:1px dashed #7c7ef5;cursor:pointer;transition:border-color .1s,color .1s}.wikilink:hover{color:#7c7ef5;border-bottom-style:solid}.wikilink-embed{display:inline-block;padding:2px 8px;background:rgba(139,92,246,.08);border:1px dashed rgba(139,92,246,.3);border-radius:3px;color:#9d8be0;font-size:12px;cursor:default}.media-grid{display:grid;grid-template-columns:repeat(auto-fill, minmax(180px, 1fr));gap:12px}.media-card{background:#1f1f28;border:1px solid rgba(255,255,255,.09);border-radius:5px;overflow:hidden;cursor:pointer;transition:border-color .12s,box-shadow .12s;position:relative}.media-card:hover{border-color:rgba(255,255,255,.14);box-shadow:0 1px 3px rgba(0,0,0,.3)}.media-card.selected{border-color:#7c7ef5;box-shadow:0 0 0 1px #7c7ef5}.card-checkbox{position:absolute;top:6px;left:6px;z-index:2;opacity:0;transition:opacity .1s}.card-checkbox input[type=checkbox]{width:16px;height:16px;cursor:pointer;filter:drop-shadow(0 1px 2px rgba(0,0,0,.5))}.media-card:hover .card-checkbox,.media-card.selected .card-checkbox{opacity:1}.card-thumbnail{width:100%;aspect-ratio:1;background:#111118;display:flex;align-items:center;justify-content:center;overflow:hidden;position:relative}.card-thumbnail img,.card-thumbnail .card-thumb-img{width:100%;height:100%;object-fit:cover;position:absolute;top:0;left:0;z-index:1}.card-type-icon{font-size:32px;opacity:.4;display:flex;align-items:center;justify-content:center;width:100%;height:100%;position:absolute;top:0;left:0;z-index:0}.card-info{padding:8px 10px}.card-name{font-size:12px;font-weight:500;color:#dcdce4;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;margin-bottom:4px}.card-title,.card-artist{font-size:10px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;line-height:1.3}.card-meta{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:6px;font-size:10px}.card-size{color:#6c6c84;font-size:10px}.table-thumb-cell{width:36px;padding:4px 6px !important;position:relative}.table-thumb{width:28px;height:28px;object-fit:cover;border-radius:3px;display:block}.table-thumb-overlay{position:absolute;top:4px;left:6px;z-index:1}.table-type-icon{display:flex;align-items:center;justify-content:center;width:28px;height:28px;font-size:14px;opacity:.5;border-radius:3px;background:#111118;z-index:0}.type-badge{display:inline-block;padding:1px 6px;border-radius:3px;font-size:10px;font-weight:600;text-transform:uppercase;letter-spacing:.04em}.type-badge.type-audio{background:rgba(139,92,246,.1);color:#9d8be0}.type-badge.type-video{background:rgba(200,72,130,.1);color:#d07eaa}.type-badge.type-image{background:rgba(34,160,80,.1);color:#5cb97a}.type-badge.type-document{background:rgba(59,120,200,.1);color:#6ca0d4}.type-badge.type-text{background:rgba(200,160,36,.1);color:#c4a840}.type-badge.type-other{background:rgba(128,128,160,.08);color:#6c6c84}.tag-list{display:flex;flex-wrap:wrap;gap:4px}.tag-badge{display:inline-flex;align-items:center;gap:4px;padding:2px 10px;background:rgba(124,126,245,.15);color:#9698f7;border-radius:12px;font-size:11px;font-weight:500}.tag-badge.selected{background:#7c7ef5;color:#fff;cursor:pointer}.tag-badge:not(.selected){cursor:pointer}.tag-badge .tag-remove{cursor:pointer;opacity:.4;font-size:13px;line-height:1;transition:opacity .1s}.tag-badge .tag-remove:hover{opacity:1}.tag-group{margin-bottom:6px}.tag-children{margin-left:16px;margin-top:4px;display:flex;flex-wrap:wrap;gap:4px}.tag-confirm-delete{display:inline-flex;align-items:center;gap:4px;font-size:10px;color:#a0a0b8}.tag-confirm-yes{cursor:pointer;color:#e45858;font-weight:600}.tag-confirm-yes:hover{text-decoration:underline}.tag-confirm-no{cursor:pointer;color:#6c6c84;font-weight:500}.tag-confirm-no:hover{text-decoration:underline}.detail-actions{display:flex;gap:6px;margin-bottom:16px}.detail-grid{display:grid;grid-template-columns:1fr 1fr;gap:8px}.detail-field{padding:10px 12px;background:#111118;border-radius:3px;border:1px solid rgba(255,255,255,.06)}.detail-field.full-width{grid-column:1/-1}.detail-field input[type=text],.detail-field textarea,.detail-field select{width:100%;margin-top:4px}.detail-field textarea{min-height:64px;resize:vertical}.detail-label{font-size:10px;font-weight:600;color:#6c6c84;text-transform:uppercase;letter-spacing:.04em;margin-bottom:2px}.detail-value{font-size:13px;color:#dcdce4;word-break:break-all}.detail-value.mono{font-family:"JetBrains Mono","Fira Code",ui-monospace,monospace;font-size:11px;color:#a0a0b8}.detail-preview{margin-bottom:16px;background:#111118;border:1px solid rgba(255,255,255,.06);border-radius:5px;overflow:hidden;text-align:center}.detail-preview:has(.markdown-viewer){max-height:none;overflow-y:auto;text-align:left}.detail-preview:not(:has(.markdown-viewer)){max-height:450px}.detail-preview img{max-width:100%;max-height:400px;object-fit:contain;display:block;margin:0 auto}.detail-preview audio{width:100%;padding:16px}.detail-preview video{max-width:100%;max-height:400px;display:block;margin:0 auto}.detail-no-preview{padding:16px 16px;text-align:center;display:flex;flex-direction:column;justify-content:center;align-items:center;gap:10px}.frontmatter-card{max-width:800px;background:#1f1f28;border:1px solid rgba(255,255,255,.09);border-radius:5px;padding:12px 16px;margin-bottom:16px}.frontmatter-fields{display:grid;grid-template-columns:auto 1fr;gap:4px 12px;margin:0}.frontmatter-fields dt{font-weight:600;font-size:12px;color:#a0a0b8;text-transform:capitalize}.frontmatter-fields dd{font-size:13px;color:#dcdce4;margin:0}.empty-state{text-align:center;padding:48px 12px;color:#6c6c84}.empty-state .empty-icon{font-size:32px;margin-bottom:12px;opacity:.3}.empty-title{font-size:15px;font-weight:600;color:#a0a0b8;margin-bottom:4px}.empty-subtitle{font-size:12px;max-width:320px;margin:0 auto;line-height:1.5}.toast-container{position:fixed;bottom:16px;right:16px;z-index:300;display:flex;flex-direction:column-reverse;gap:6px;align-items:flex-end}.toast-container .toast{position:static;transform:none}.toast{position:fixed;bottom:16px;right:16px;padding:10px 16px;border-radius:5px;background:#26263a;border:1px solid rgba(255,255,255,.09);color:#dcdce4;font-size:12px;box-shadow:0 2px 8px rgba(0,0,0,.35);z-index:300;animation:slide-up .15s ease-out;max-width:420px}.toast.success{border-left:3px solid #3ec97a}.toast.error{border-left:3px solid #e45858}.offline-banner,.error-banner{background:rgba(228,88,88,.06);border:1px solid rgba(228,88,88,.2);border-radius:3px;padding:10px 12px;margin-bottom:12px;font-size:12px;color:#d47070;display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:8px}.offline-banner .offline-icon,.offline-banner .error-icon,.error-banner .offline-icon,.error-banner .error-icon{font-size:14px;flex-shrink:0}.error-banner{padding:10px 14px}.readonly-banner{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:8px;padding:10px 12px;background:rgba(212,160,55,.06);border:1px solid rgba(212,160,55,.15);border-radius:3px;margin-bottom:16px;font-size:12px;color:#d4a037}.batch-actions{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:8px;padding:8px 10px;background:rgba(124,126,245,.15);border:1px solid rgba(124,126,245,.2);border-radius:3px;margin-bottom:12px;font-size:12px;font-weight:500;color:#9698f7}.select-all-banner{display:flex;flex-direction:row;justify-content:center;align-items:center;gap:8px;padding:10px 16px;background:rgba(99,102,241,.08);border-radius:6px;margin-bottom:8px;font-size:.85rem;color:#a0a0b8}.select-all-banner button{background:none;border:none;color:#7c7ef5;cursor:pointer;font-weight:600;text-decoration:underline;font-size:.85rem;padding:0}.select-all-banner button:hover{color:#dcdce4}.import-status-panel{background:#1f1f28;border:1px solid #7c7ef5;border-radius:5px;padding:12px 16px;margin-bottom:16px}.import-status-header{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:8px;margin-bottom:8px;font-size:13px;color:#dcdce4}.import-current-file{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:2px;margin-bottom:6px;font-size:12px;overflow:hidden}.import-file-label{color:#6c6c84;flex-shrink:0}.import-file-name{color:#dcdce4;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;font-family:monospace;font-size:11px}.import-queue-indicator{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:2px;margin-bottom:8px;font-size:11px}.import-queue-badge{display:flex;align-items:center;justify-content:center;min-width:18px;height:18px;padding:0 6px;background:rgba(124,126,245,.15);color:#9698f7;border-radius:9px;font-weight:600;font-size:10px}.import-queue-text{color:#6c6c84}.import-tabs{display:flex;gap:0;margin-bottom:16px;border-bottom:1px solid rgba(255,255,255,.09)}.import-tab{padding:10px 16px;background:none;border:none;border-bottom:2px solid rgba(0,0,0,0);color:#6c6c84;font-size:12px;font-weight:500;cursor:pointer;transition:color .1s,border-color .1s}.import-tab:hover{color:#dcdce4}.import-tab.active{color:#9698f7;border-bottom-color:#7c7ef5}.queue-panel{display:flex;flex-direction:column;justify-content:flex-start;align-items:stretch;border-left:1px solid rgba(255,255,255,.09);background:#18181f;min-width:280px;max-width:320px}.queue-header{display:flex;justify-content:space-between;align-items:center;padding:12px 16px;border-bottom:1px solid rgba(255,255,255,.06)}.queue-header h3{margin:0;font-size:.9rem;color:#dcdce4}.queue-controls{display:flex;gap:2px}.queue-list{overflow-y:auto;flex:1}.queue-item{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;padding:8px 16px;cursor:pointer;border-bottom:1px solid rgba(255,255,255,.06);transition:background .15s}.queue-item:hover{background:#1f1f28}.queue-item:hover .queue-item-remove{opacity:1}.queue-item-active{background:rgba(124,126,245,.15);border-left:3px solid #7c7ef5}.queue-item-info{flex:1;min-width:0}.queue-item-title{display:block;font-size:.85rem;color:#dcdce4;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.queue-item-artist{display:block;font-size:.75rem;color:#6c6c84}.queue-item-remove{opacity:0;transition:opacity .15s}.queue-empty{padding:16px 16px;text-align:center;color:#6c6c84;font-size:.85rem}.statistics-page{padding:20px}.stats-overview,.stats-grid{display:grid;grid-template-columns:repeat(3, 1fr);gap:16px;margin-bottom:24px}@media (max-width: 768px){.stats-overview,.stats-grid{grid-template-columns:1fr}}.stat-card{background:#1f1f28;border:1px solid rgba(255,255,255,.09);border-radius:5px;padding:20px;display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:16px}.stat-card.stat-primary{border-left:3px solid #7c7ef5}.stat-card.stat-success{border-left:3px solid #3ec97a}.stat-card.stat-info{border-left:3px solid #6ca0d4}.stat-card.stat-warning{border-left:3px solid #d4a037}.stat-card.stat-purple{border-left:3px solid #9d8be0}.stat-card.stat-danger{border-left:3px solid #e45858}.stat-icon{flex-shrink:0;color:#6c6c84}.stat-content{flex:1}.stat-value{font-size:28px;font-weight:700;color:#dcdce4;line-height:1.2;font-variant-numeric:tabular-nums}.stat-label{font-size:12px;color:#6c6c84;margin-top:4px;font-weight:500}.stats-section{background:#1f1f28;border:1px solid rgba(255,255,255,.09);border-radius:5px;padding:16px;margin-bottom:20px}.section-title{font-size:16px;font-weight:600;color:#dcdce4;margin-bottom:20px}.section-title.small{font-size:14px;margin-bottom:12px;padding-bottom:6px;border-bottom:1px solid rgba(255,255,255,.06)}.chart-bars{display:flex;flex-direction:column;justify-content:flex-start;align-items:stretch;gap:16px}.bar-item{display:grid;grid-template-columns:120px 1fr 80px;align-items:center;gap:16px}.bar-label{font-size:13px;font-weight:500;color:#a0a0b8;text-align:right}.bar-track{height:28px;background:#26263a;border-radius:3px;overflow:hidden;position:relative}.bar-fill{height:100%;transition:width .6s cubic-bezier(.4, 0, .2, 1);border-radius:3px}.bar-fill.bar-primary{background:linear-gradient(90deg, #7c7ef5 0%, #7c7ef3 100%)}.bar-fill.bar-success{background:linear-gradient(90deg, #3ec97a 0%, #66bb6a 100%)}.bar-value{font-size:13px;font-weight:600;color:#a0a0b8;text-align:right;font-variant-numeric:tabular-nums}.settings-section{margin-bottom:16px}.settings-card{background:#1f1f28;border:1px solid rgba(255,255,255,.09);border-radius:7px;padding:20px;margin-bottom:16px}.settings-card.danger-card{border:1px solid rgba(228,88,88,.25)}.settings-card-header{display:flex;justify-content:space-between;align-items:center;margin-bottom:16px;padding-bottom:12px;border-bottom:1px solid rgba(255,255,255,.06)}.settings-card-title{font-size:14px;font-weight:600}.settings-card-body{padding-top:2px}.settings-field{display:flex;justify-content:space-between;align-items:center;padding:8px 0;border-bottom:1px solid rgba(255,255,255,.06)}.settings-field:last-child{border-bottom:none}.settings-field select{min-width:120px}.config-path{font-size:11px;color:#6c6c84;margin-bottom:12px;font-family:"JetBrains Mono","Fira Code",ui-monospace,monospace;padding:6px 10px;background:#111118;border-radius:3px;border:1px solid rgba(255,255,255,.06)}.config-status{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:6px;padding:3px 10px;border-radius:12px;font-size:11px;font-weight:600}.config-status.writable{background:rgba(62,201,122,.1);color:#3ec97a}.config-status.readonly{background:rgba(228,88,88,.1);color:#e45858}.root-list{list-style:none}.root-item{display:flex;justify-content:space-between;align-items:center;padding:8px 12px;background:#111118;border:1px solid rgba(255,255,255,.06);border-radius:3px;margin-bottom:4px;font-family:"JetBrains Mono","Fira Code",ui-monospace,monospace;font-size:12px;color:#a0a0b8}.info-row{display:flex;justify-content:space-between;align-items:center;padding:6px 0;border-bottom:1px solid rgba(255,255,255,.06);font-size:13px}.info-row:last-child{border-bottom:none}.info-label{color:#a0a0b8;font-weight:500}.info-value{color:#dcdce4}.tasks-grid{display:grid;grid-template-columns:repeat(auto-fill, minmax(400px, 1fr));gap:16px;padding:12px}.task-card{background:#1f1f28;border:1px solid rgba(255,255,255,.09);border-radius:5px;overflow:hidden;transition:all .2s}.task-card:hover{border-color:rgba(255,255,255,.14);box-shadow:0 4px 12px rgba(0,0,0,.08);transform:translateY(-2px)}.task-card-enabled{border-left:3px solid #3ec97a}.task-card-disabled{border-left:3px solid #4a4a5e;opacity:.7}.task-card-header{display:flex;justify-content:space-between;align-items:center;align-items:flex-start;padding:16px;border-bottom:1px solid rgba(255,255,255,.06)}.task-header-left{flex:1;min-width:0}.task-name{font-size:16px;font-weight:600;color:#dcdce4;margin-bottom:2px}.task-schedule{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:6px;font-size:12px;color:#6c6c84;font-family:"Menlo","Monaco","Courier New",monospace}.schedule-icon{font-size:14px}.task-status-badge{flex-shrink:0}.status-badge{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:6px;padding:2px 10px;border-radius:3px;font-size:10px;font-weight:600;text-transform:uppercase;letter-spacing:.03em}.status-badge.status-enabled{background:rgba(76,175,80,.12);color:#3ec97a}.status-badge.status-enabled .status-dot{animation:pulse 1.5s infinite}.status-badge.status-disabled{background:#26263a;color:#6c6c84}.status-badge .status-dot{width:6px;height:6px;border-radius:50%;flex-shrink:0;background:currentColor}.task-info-grid{display:grid;grid-template-columns:repeat(auto-fit, minmax(120px, 1fr));gap:12px;padding:16px}.task-info-item{display:flex;flex-direction:row;justify-content:flex-start;align-items:flex-start;gap:10px}.task-info-icon{font-size:18px;color:#6c6c84;flex-shrink:0}.task-info-content{flex:1;min-width:0}.task-info-label{font-size:10px;color:#6c6c84;font-weight:600;text-transform:uppercase;letter-spacing:.03em;margin-bottom:2px}.task-info-value{font-size:12px;color:#a0a0b8;font-weight:500;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.task-card-actions{display:flex;gap:8px;padding:10px 16px;background:#18181f;border-top:1px solid rgba(255,255,255,.06)}.task-card-actions button{flex:1}.db-actions{display:flex;flex-direction:column;justify-content:flex-start;align-items:stretch;gap:16px;padding:10px}.db-action-row{display:flex;flex-direction:row;justify-content:space-between;align-items:center;gap:16px;padding:10px;border-radius:6px;background:rgba(0,0,0,.06)}.db-action-info{flex:1}.db-action-info h4{font-size:.95rem;font-weight:600;color:#dcdce4;margin-bottom:2px}.db-action-confirm{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:8px;flex-shrink:0}.library-toolbar{display:flex;justify-content:space-between;align-items:center;padding:8px 0;margin-bottom:12px;gap:12px;flex-wrap:wrap}.toolbar-left{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:10px}.toolbar-right{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:10px}.sort-control select,.page-size-control select{padding:4px 24px 4px 8px;font-size:11px;background:#1f1f28}.page-size-control{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:4px}.library-stats{display:flex;justify-content:space-between;align-items:center;padding:2px 0 6px 0;font-size:11px}.type-filter-row{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:6px;padding:4px 0;margin-bottom:6px;flex-wrap:wrap}.pagination{display:flex;align-items:center;justify-content:center;gap:4px;margin-top:16px;padding:8px 0}.page-btn{min-width:28px;text-align:center;font-variant-numeric:tabular-nums}.page-ellipsis{color:#6c6c84;padding:0 4px;font-size:12px;user-select:none}.audit-controls{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:8px;margin-bottom:12px}.filter-select{padding:4px 24px 4px 8px;font-size:11px;background:#1f1f28}.action-danger{background:rgba(228,88,88,.1);color:#d47070}.action-updated{background:rgba(59,120,200,.1);color:#6ca0d4}.action-collection{background:rgba(34,160,80,.1);color:#5cb97a}.action-collection-remove{background:rgba(212,160,55,.1);color:#c4a840}.action-opened{background:rgba(139,92,246,.1);color:#9d8be0}.action-scanned{background:rgba(128,128,160,.08);color:#6c6c84}.clickable{cursor:pointer;color:#9698f7}.clickable:hover{text-decoration:underline}.clickable-row{cursor:pointer}.clickable-row:hover{background:rgba(255,255,255,.03)}.duplicates-view{padding:0}.duplicates-header{display:flex;justify-content:space-between;align-items:center;margin-bottom:16px}.duplicates-header h3{margin:0}.duplicates-summary{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:12px}.duplicate-group{border:1px solid rgba(255,255,255,.09);border-radius:5px;margin-bottom:8px;overflow:hidden}.duplicate-group-header{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:12px;width:100%;padding:10px 14px;background:#1f1f28;border:none;cursor:pointer;text-align:left;color:#dcdce4;font-size:13px}.duplicate-group-header:hover{background:#26263a}.expand-icon{font-size:10px;width:14px;flex-shrink:0}.group-name{font-weight:600;flex:1;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.group-badge{background:#7c7ef5;color:#fff;padding:2px 8px;border-radius:10px;font-size:11px;font-weight:600;flex-shrink:0}.group-size{flex-shrink:0;font-size:12px}.group-hash{font-size:11px;flex-shrink:0}.duplicate-items{border-top:1px solid rgba(255,255,255,.09)}.duplicate-item{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:12px;padding:10px 14px;border-bottom:1px solid rgba(255,255,255,.06)}.duplicate-item:last-child{border-bottom:none}.duplicate-item-keep{background:rgba(76,175,80,.06)}.dup-thumb{width:48px;height:48px;flex-shrink:0;border-radius:3px;overflow:hidden}.dup-thumb-img{width:100%;height:100%;object-fit:cover}.dup-thumb-placeholder{width:100%;height:100%;display:flex;align-items:center;justify-content:center;background:#26263a;font-size:20px;color:#6c6c84}.dup-info{flex:1;min-width:0}.dup-filename{font-weight:600;font-size:13px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.dup-path{font-size:11px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.dup-meta{font-size:12px;margin-top:2px}.dup-actions{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:6px;flex-shrink:0}.keep-badge{background:rgba(76,175,80,.12);color:#4caf50;padding:2px 10px;border-radius:10px;font-size:11px;font-weight:600}.saved-searches-list{display:flex;flex-direction:column;justify-content:flex-start;align-items:stretch;gap:4px;max-height:300px;overflow-y:auto}.saved-search-item{display:flex;justify-content:space-between;align-items:center;padding:8px 12px;background:#18181f;border-radius:3px;cursor:pointer;transition:background .15s ease}.saved-search-item:hover{background:#1f1f28}.saved-search-info{display:flex;flex-direction:column;justify-content:flex-start;align-items:stretch;gap:2px;flex:1;min-width:0}.saved-search-name{font-weight:500;color:#dcdce4}.saved-search-query{font-size:11px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.backlinks-panel,.outgoing-links-panel{background:#1f1f28;border:1px solid rgba(255,255,255,.09);border-radius:5px;margin-top:16px;overflow:hidden}.backlinks-header,.outgoing-links-header{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:8px;padding:10px 14px;background:#26263a;cursor:pointer;user-select:none;transition:background .1s}.backlinks-header:hover,.outgoing-links-header:hover{background:rgba(255,255,255,.04)}.backlinks-toggle,.outgoing-links-toggle{font-size:10px;color:#6c6c84;width:12px;text-align:center}.backlinks-title,.outgoing-links-title{font-size:12px;font-weight:600;color:#dcdce4;flex:1}.backlinks-count,.outgoing-links-count{font-size:11px;color:#6c6c84}.backlinks-reindex-btn{display:flex;align-items:center;justify-content:center;width:22px;height:22px;padding:0;margin-left:auto;background:rgba(0,0,0,0);border:1px solid rgba(255,255,255,.09);border-radius:3px;color:#6c6c84;font-size:12px;cursor:pointer;transition:background .1s,color .1s,border-color .1s}.backlinks-reindex-btn:hover:not(:disabled){background:#1f1f28;color:#dcdce4;border-color:rgba(255,255,255,.14)}.backlinks-reindex-btn:disabled{opacity:.5;cursor:not-allowed}.backlinks-content,.outgoing-links-content{padding:12px;border-top:1px solid rgba(255,255,255,.06)}.backlinks-loading,.outgoing-links-loading{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:8px;padding:12px;color:#6c6c84;font-size:12px}.backlinks-error,.outgoing-links-error{padding:8px 12px;background:rgba(228,88,88,.06);border:1px solid rgba(228,88,88,.2);border-radius:3px;font-size:12px;color:#e45858}.backlinks-empty,.outgoing-links-empty{padding:16px;text-align:center;color:#6c6c84;font-size:12px;font-style:italic}.backlinks-list,.outgoing-links-list{list-style:none;padding:0;margin:0;display:flex;flex-direction:column;justify-content:flex-start;align-items:stretch;gap:6px}.backlink-item,.outgoing-link-item{padding:10px 12px;background:#111118;border:1px solid rgba(255,255,255,.06);border-radius:3px;cursor:pointer;transition:background .1s,border-color .1s}.backlink-item:hover,.outgoing-link-item:hover{background:#18181f;border-color:rgba(255,255,255,.09)}.backlink-item.unresolved,.outgoing-link-item.unresolved{opacity:.7;border-style:dashed}.backlink-source,.outgoing-link-target{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:8px;margin-bottom:2px}.backlink-title,.outgoing-link-text{font-size:13px;font-weight:500;color:#dcdce4;flex:1;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.backlink-type-badge,.outgoing-link-type-badge{display:inline-block;padding:1px 6px;border-radius:12px;font-size:9px;font-weight:600;text-transform:uppercase;letter-spacing:.03em}.backlink-type-badge.backlink-type-wikilink,.backlink-type-badge.link-type-wikilink,.outgoing-link-type-badge.backlink-type-wikilink,.outgoing-link-type-badge.link-type-wikilink{background:rgba(124,126,245,.15);color:#9698f7}.backlink-type-badge.backlink-type-embed,.backlink-type-badge.link-type-embed,.outgoing-link-type-badge.backlink-type-embed,.outgoing-link-type-badge.link-type-embed{background:rgba(139,92,246,.1);color:#9d8be0}.backlink-type-badge.backlink-type-markdown_link,.backlink-type-badge.link-type-markdown_link,.outgoing-link-type-badge.backlink-type-markdown_link,.outgoing-link-type-badge.link-type-markdown_link{background:rgba(59,120,200,.1);color:#6ca0d4}.backlink-context{font-size:11px;color:#6c6c84;line-height:1.4;overflow:hidden;text-overflow:ellipsis;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical}.backlink-line{color:#a0a0b8;font-weight:500}.unresolved-badge{padding:1px 6px;border-radius:12px;font-size:9px;font-weight:600;background:rgba(212,160,55,.1);color:#d4a037}.outgoing-links-unresolved-badge{margin-left:8px;padding:2px 8px;border-radius:10px;font-size:10px;font-weight:500;background:rgba(212,160,55,.12);color:#d4a037}.outgoing-links-global-unresolved{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:6px;margin-top:12px;padding:10px 12px;background:rgba(212,160,55,.06);border:1px solid rgba(212,160,55,.15);border-radius:3px;font-size:11px;color:#6c6c84}.outgoing-links-global-unresolved .unresolved-icon{color:#d4a037}.backlinks-message{padding:8px 10px;margin-bottom:10px;border-radius:3px;font-size:11px}.backlinks-message.success{background:rgba(62,201,122,.08);border:1px solid rgba(62,201,122,.2);color:#3ec97a}.backlinks-message.error{background:rgba(228,88,88,.06);border:1px solid rgba(228,88,88,.2);color:#e45858}.graph-view{display:flex;flex-direction:column;justify-content:flex-start;align-items:stretch;height:100%;background:#18181f;border-radius:5px;overflow:hidden}.graph-toolbar{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:16px;padding:12px 16px;background:#1f1f28;border-bottom:1px solid rgba(255,255,255,.09)}.graph-title{font-size:14px;font-weight:600;color:#dcdce4}.graph-controls{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:8px;font-size:12px;color:#a0a0b8}.graph-controls select{padding:4px 20px 4px 8px;font-size:11px;background:#26263a}.graph-stats{margin-left:auto;font-size:11px;color:#6c6c84}.graph-container{flex:1;position:relative;display:flex;align-items:center;justify-content:center;overflow:hidden;background:#111118}.graph-loading,.graph-error,.graph-empty{display:flex;flex-direction:column;justify-content:center;align-items:center;gap:10px;padding:48px;color:#6c6c84;font-size:13px;text-align:center}.graph-svg{max-width:100%;max-height:100%;cursor:grab}.graph-svg-container{position:relative;width:100%;height:100%}.graph-zoom-controls{position:absolute;top:16px;left:16px;display:flex;flex-direction:column;justify-content:flex-start;align-items:stretch;gap:8px;z-index:5}.zoom-btn{width:36px;height:36px;border-radius:6px;background:#1f1f28;border:1px solid rgba(255,255,255,.09);color:#dcdce4;font-size:18px;font-weight:bold;display:flex;align-items:center;justify-content:center;cursor:pointer;transition:all .15s;box-shadow:0 1px 3px rgba(0,0,0,.3)}.zoom-btn:hover{background:#26263a;border-color:rgba(255,255,255,.14);transform:scale(1.05)}.zoom-btn:active{transform:scale(.95)}.graph-edges line{stroke:rgba(255,255,255,.14);stroke-width:1;opacity:.6}.graph-edges line.edge-type-wikilink{stroke:#7c7ef5}.graph-edges line.edge-type-embed{stroke:#9d8be0;stroke-dasharray:4 2}.graph-nodes .graph-node{cursor:pointer}.graph-nodes .graph-node circle{fill:#4caf50;stroke:#388e3c;stroke-width:2;transition:fill .15s,stroke .15s}.graph-nodes .graph-node:hover circle{fill:#66bb6a}.graph-nodes .graph-node.selected circle{fill:#7c7ef5;stroke:#5456d6}.graph-nodes .graph-node text{fill:#a0a0b8;font-size:11px;pointer-events:none;text-anchor:middle;dominant-baseline:central;transform:translateY(16px)}.node-details-panel{position:absolute;top:16px;right:16px;width:280px;background:#1f1f28;border:1px solid rgba(255,255,255,.09);border-radius:5px;box-shadow:0 2px 8px rgba(0,0,0,.35);z-index:10}.node-details-header{display:flex;justify-content:space-between;align-items:center;padding:10px 14px;border-bottom:1px solid rgba(255,255,255,.06)}.node-details-header h3{font-size:13px;font-weight:600;color:#dcdce4;margin:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.node-details-header .close-btn{background:none;border:none;color:#6c6c84;cursor:pointer;font-size:14px;padding:2px 6px;line-height:1}.node-details-header .close-btn:hover{color:#dcdce4}.node-details-content{padding:14px}.node-details-content .node-title{font-size:12px;color:#a0a0b8;margin-bottom:12px}.node-stats{display:flex;gap:16px;margin-bottom:12px}.node-stats .stat{font-size:12px;color:#6c6c84}.node-stats .stat strong{color:#dcdce4}.physics-controls-panel{position:absolute;top:16px;right:16px;width:300px;background:#1f1f28;border:1px solid rgba(255,255,255,.09);border-radius:5px;box-shadow:0 2px 8px rgba(0,0,0,.35);padding:16px;z-index:10}.physics-controls-panel h4{font-size:13px;font-weight:600;color:#dcdce4;margin:0 0 16px 0;padding-bottom:8px;border-bottom:1px solid rgba(255,255,255,.06)}.physics-controls-panel .btn{width:100%;margin-top:8px}.control-group{margin-bottom:14px}.control-group label{display:block;font-size:11px;font-weight:500;color:#a0a0b8;margin-bottom:6px;text-transform:uppercase;letter-spacing:.5px}.control-group input[type=range]{width:100%;height:4px;border-radius:4px;background:#26263a;outline:none;-webkit-appearance:none}.control-group input[type=range]::-webkit-slider-thumb{-webkit-appearance:none;appearance:none;width:14px;height:14px;border-radius:50%;background:#7c7ef5;cursor:pointer;transition:transform .1s}.control-group input[type=range]::-webkit-slider-thumb:hover{transform:scale(1.15)}.control-group input[type=range]::-moz-range-thumb{width:14px;height:14px;border-radius:50%;background:#7c7ef5;cursor:pointer;border:none;transition:transform .1s}.control-group input[type=range]::-moz-range-thumb:hover{transform:scale(1.15)}.control-value{display:inline-block;margin-top:2px;font-size:11px;color:#6c6c84;font-family:"JetBrains Mono","Fira Code",ui-monospace,monospace}.theme-light{--bg-0: #f5f5f7;--bg-1: #eeeef0;--bg-2: #fff;--bg-3: #e8e8ec;--border-subtle: rgba(0,0,0,.06);--border: rgba(0,0,0,.1);--border-strong: rgba(0,0,0,.16);--text-0: #1a1a2e;--text-1: #555570;--text-2: #8888a0;--accent: #6366f1;--accent-dim: rgba(99,102,241,.1);--accent-text: #4f52e8;--shadow-sm: 0 1px 3px rgba(0,0,0,.08);--shadow: 0 2px 8px rgba(0,0,0,.1);--shadow-lg: 0 4px 20px rgba(0,0,0,.12)}.theme-light ::-webkit-scrollbar-thumb{background:rgba(0,0,0,.12)}.theme-light ::-webkit-scrollbar-thumb:hover{background:rgba(0,0,0,.08)}.theme-light ::-webkit-scrollbar-track{background:rgba(0,0,0,.06)}.theme-light .graph-nodes .graph-node text{fill:#1a1a2e}.theme-light .graph-edges line{stroke:rgba(0,0,0,.12)}.theme-light .pdf-container{background:#e8e8ec}.skeleton-pulse{animation:skeleton-pulse 1.5s ease-in-out infinite;background:#26263a;border-radius:4px}.skeleton-card{display:flex;flex-direction:column;justify-content:flex-start;align-items:stretch;gap:8px;padding:8px}.skeleton-thumb{width:100%;aspect-ratio:1;border-radius:6px}.skeleton-text{height:14px;width:80%}.skeleton-text-short{width:50%}.skeleton-row{display:flex;gap:12px;padding:10px 16px;align-items:center}.skeleton-cell{height:14px;flex:1;border-radius:4px}.skeleton-cell-icon{width:32px;height:32px;flex:none;border-radius:4px}.skeleton-cell-wide{flex:3}.loading-overlay{position:absolute;inset:0;display:flex;flex-direction:column;justify-content:center;align-items:center;gap:10px;background:rgba(0,0,0,.3);z-index:100;border-radius:8px}.loading-spinner{width:32px;height:32px;border:3px solid rgba(255,255,255,.09);border-top-color:#7c7ef5;border-radius:50%;animation:spin .8s linear infinite}.loading-message{color:#a0a0b8;font-size:.9rem}.login-container{display:flex;align-items:center;justify-content:center;height:100vh;background:#111118}.login-card{background:#1f1f28;border:1px solid rgba(255,255,255,.09);border-radius:7px;padding:24px;width:360px;box-shadow:0 4px 20px rgba(0,0,0,.45)}.login-title{font-size:20px;font-weight:700;color:#dcdce4;text-align:center;margin-bottom:2px}.login-subtitle{font-size:13px;color:#6c6c84;text-align:center;margin-bottom:20px}.login-error{background:rgba(228,88,88,.08);border:1px solid rgba(228,88,88,.2);border-radius:3px;padding:8px 12px;margin-bottom:12px;font-size:12px;color:#e45858}.login-form input[type=text],.login-form input[type=password]{width:100%}.login-btn{width:100%;padding:8px 16px;font-size:13px;margin-top:2px}.pagination{display:flex;flex-direction:row;justify-content:center;align-items:center;gap:2px;margin-top:16px;padding:8px 0}.page-btn{min-width:28px;text-align:center;font-variant-numeric:tabular-nums}.page-ellipsis{color:#6c6c84;padding:0 4px;font-size:12px;user-select:none}.help-overlay{position:fixed;inset:0;background:rgba(0,0,0,.5);display:flex;align-items:center;justify-content:center;z-index:200;animation:fade-in .1s ease-out}.help-dialog{background:#1f1f28;border:1px solid rgba(255,255,255,.09);border-radius:7px;padding:16px;min-width:300px;max-width:400px;box-shadow:0 4px 20px rgba(0,0,0,.45)}.help-dialog h3{font-size:16px;font-weight:600;margin-bottom:16px}.help-shortcuts{display:flex;flex-direction:column;justify-content:flex-start;align-items:stretch;gap:8px;margin-bottom:16px}.shortcut-row{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:12px}.shortcut-row kbd{display:inline-block;padding:2px 8px;background:#111118;border:1px solid rgba(255,255,255,.09);border-radius:3px;font-family:"JetBrains Mono","Fira Code",ui-monospace,monospace;font-size:11px;color:#dcdce4;min-width:32px;text-align:center}.shortcut-row span{font-size:13px;color:#a0a0b8}.help-close{display:block;width:100%;padding:6px 12px;background:#26263a;border:1px solid rgba(255,255,255,.09);border-radius:3px;color:#dcdce4;font-size:12px;cursor:pointer;text-align:center}.help-close:hover{background:rgba(255,255,255,.06)} \ No newline at end of file +@media (prefers-reduced-motion: reduce){*,*::before,*::after{animation-duration:.01ms !important;animation-iteration-count:1 !important;transition-duration:.01ms !important}}*{margin:0;padding:0;box-sizing:border-box;scrollbar-width:thin;scrollbar-color:rgba(255,255,255,.06) rgba(0,0,0,0)}*::-webkit-scrollbar{width:5px;height:5px}*::-webkit-scrollbar-track{background:rgba(0,0,0,0)}*::-webkit-scrollbar-thumb{background:rgba(255,255,255,.06);border-radius:3px}*::-webkit-scrollbar-thumb:hover{background:rgba(255,255,255,.14)}:root{--bg-0: #111118;--bg-1: #18181f;--bg-2: #1f1f28;--bg-3: #26263a;--border-subtle: rgba(255,255,255,.06);--border: rgba(255,255,255,.09);--border-strong: rgba(255,255,255,.14);--text-0: #dcdce4;--text-1: #a0a0b8;--text-2: #6c6c84;--accent: #7c7ef5;--accent-dim: rgba(124,126,245,.15);--accent-text: #9698f7;--success: #3ec97a;--error: #e45858;--warning: #d4a037;--radius-sm: 3px;--radius: 5px;--radius-md: 7px;--shadow-sm: 0 1px 3px rgba(0,0,0,.3);--shadow: 0 2px 8px rgba(0,0,0,.35);--shadow-lg: 0 4px 20px rgba(0,0,0,.45)}body{font-family:"Inter",-apple-system,"Segoe UI",system-ui,sans-serif;background:var(--bg-0);color:var(--text-0);font-size:13px;line-height:1.5;-webkit-font-smoothing:antialiased;overflow:hidden}:focus-visible:focus-visible{outline:2px solid #7c7ef5;outline-offset:2px}::selection{background:rgba(124,126,245,.15);color:#9698f7}a{color:#9698f7;text-decoration:none}a:hover{text-decoration:underline}code{padding:1px 5px;border-radius:3px;background:#111118;color:#9698f7;font-family:"JetBrains Mono","Fira Code",ui-monospace,monospace;font-size:11px}ul{list-style:none;padding:0}ul li{padding:3px 0;font-size:12px;color:#a0a0b8}.text-muted{color:#a0a0b8}.text-sm{font-size:11px}.mono{font-family:"JetBrains Mono","Fira Code",ui-monospace,monospace;font-size:12px}.flex-row{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:8px}.flex-between{display:flex;justify-content:space-between;align-items:center}.mb-16{margin-bottom:16px}.mb-8{margin-bottom:12px}@keyframes fade-in{from{opacity:0}to{opacity:1}}@keyframes slide-up{from{opacity:0;transform:translateY(8px)}to{opacity:1;transform:translateY(0)}}@keyframes pulse{0%, 100%{opacity:1}50%{opacity:.3}}@keyframes spin{to{transform:rotate(360deg)}}@keyframes skeleton-pulse{0%{opacity:.6}50%{opacity:.3}100%{opacity:.6}}@keyframes indeterminate{0%{transform:translateX(-100%)}100%{transform:translateX(400%)}}.app{display:flex;flex-direction:row;justify-content:flex-start;align-items:stretch;height:100vh;overflow:hidden}.sidebar{width:220px;min-width:220px;max-width:220px;background:#18181f;border-right:1px solid rgba(255,255,255,.09);display:flex;flex-direction:column;justify-content:flex-start;align-items:stretch;flex-shrink:0;user-select:none;overflow-y:auto;overflow-x:hidden;z-index:10;transition:width .15s,min-width .15s,max-width .15s}.sidebar.collapsed{width:48px;min-width:48px;max-width:48px}.sidebar.collapsed .nav-label,.sidebar.collapsed .sidebar-header .logo,.sidebar.collapsed .sidebar-header .version,.sidebar.collapsed .nav-badge,.sidebar.collapsed .nav-item-text,.sidebar.collapsed .sidebar-footer .status-text,.sidebar.collapsed .user-name,.sidebar.collapsed .role-badge,.sidebar.collapsed .user-info .btn,.sidebar.collapsed .sidebar-import-header span,.sidebar.collapsed .sidebar-import-file{display:none}.sidebar.collapsed .nav-item{justify-content:center;padding:8px;border-left:none;border-radius:3px}.sidebar.collapsed .nav-item.active{border-left:none}.sidebar.collapsed .nav-icon{width:auto;margin:0}.sidebar.collapsed .sidebar-header{padding:12px 8px;justify-content:center}.sidebar.collapsed .nav-section{padding:0 4px}.sidebar.collapsed .sidebar-footer{padding:8px}.sidebar.collapsed .sidebar-footer .user-info{justify-content:center;padding:4px}.sidebar.collapsed .sidebar-import-progress{padding:6px}.sidebar-header{padding:16px 16px 20px;display:flex;flex-direction:row;justify-content:flex-start;align-items:baseline;gap:8px}.sidebar-header .logo{font-size:15px;font-weight:700;letter-spacing:-.4px;color:#dcdce4}.sidebar-header .version{font-size:10px;color:#6c6c84}.sidebar-toggle{background:rgba(0,0,0,0);border:none;color:#6c6c84;padding:8px;font-size:18px;width:100%;text-align:center}.sidebar-toggle:hover{color:#dcdce4}.sidebar-spacer{flex:1}.sidebar-footer{padding:12px;border-top:1px solid rgba(255,255,255,.06);overflow:visible;min-width:0}.nav-section{padding:0 8px;margin-bottom:2px}.nav-label{padding:8px 8px 4px;font-size:10px;font-weight:600;text-transform:uppercase;letter-spacing:.04em;color:#6c6c84}.nav-item{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:8px;padding:6px 8px;border-radius:3px;cursor:pointer;color:#a0a0b8;font-size:13px;font-weight:450;transition:color .1s,background .1s;border:none;background:none;width:100%;text-align:left;border-left:2px solid rgba(0,0,0,0);margin-left:0}.nav-item:hover{color:#dcdce4;background:rgba(255,255,255,.03)}.nav-item.active{color:#9698f7;border-left-color:#7c7ef5;background:rgba(124,126,245,.15)}.nav-item-text{flex:1;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;min-width:0}.sidebar:not(.collapsed) .nav-item-text{overflow:visible}.nav-icon{width:18px;text-align:center;font-size:14px;opacity:.7}.nav-badge{margin-left:auto;font-size:10px;font-weight:600;color:#6c6c84;background:#26263a;padding:1px 6px;border-radius:12px;min-width:20px;text-align:center;font-variant-numeric:tabular-nums}.status-indicator{display:flex;flex-direction:row;justify-content:center;align-items:center;gap:6px;font-size:11px;font-weight:500;min-width:0;overflow:visible}.sidebar:not(.collapsed) .status-indicator{justify-content:flex-start}.status-dot{width:6px;height:6px;border-radius:50%;flex-shrink:0}.status-dot.connected{background:#3ec97a}.status-dot.disconnected{background:#e45858}.status-dot.checking{background:#d4a037;animation:pulse 1.5s infinite}.status-text{color:#6c6c84;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;min-width:0}.sidebar:not(.collapsed) .status-text{overflow:visible}.main{flex:1;display:flex;flex-direction:column;justify-content:flex-start;align-items:stretch;overflow:hidden;min-width:0}.header{height:48px;min-height:48px;border-bottom:1px solid rgba(255,255,255,.06);display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:12px;padding:0 20px;background:#18181f}.page-title{font-size:14px;font-weight:600;color:#dcdce4}.header-spacer{flex:1}.content{flex:1;overflow-y:auto;padding:20px;scrollbar-width:thin;scrollbar-color:rgba(255,255,255,.06) rgba(0,0,0,0)}.sidebar-import-progress{padding:10px 12px;background:#1f1f28;border-top:1px solid rgba(255,255,255,.06);font-size:11px}.sidebar-import-header{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:6px;margin-bottom:4px;color:#a0a0b8}.sidebar-import-file{color:#6c6c84;font-size:10px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;margin-bottom:4px}.sidebar-import-progress .progress-bar{height:3px}.user-info{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:6px;font-size:12px;overflow:hidden;min-width:0}.user-name{font-weight:500;color:#dcdce4;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:90px;flex-shrink:1}.role-badge{display:inline-block;padding:1px 6px;border-radius:3px;font-size:10px;font-weight:600;text-transform:uppercase}.role-badge.role-admin{background:rgba(139,92,246,.1);color:#9d8be0}.role-badge.role-editor{background:rgba(34,160,80,.1);color:#5cb97a}.role-badge.role-viewer{background:rgba(59,120,200,.1);color:#6ca0d4}.btn{padding:5px 12px;border-radius:3px;border:none;cursor:pointer;font-size:12px;font-weight:500;transition:all .1s;display:inline-flex;align-items:center;gap:5px;white-space:nowrap;line-height:1.5}.btn-primary{background:#7c7ef5;color:#fff}.btn-primary:hover{background:#8b8df7}.btn-secondary{background:#26263a;color:#dcdce4;border:1px solid rgba(255,255,255,.09)}.btn-secondary:hover{border-color:rgba(255,255,255,.14);background:rgba(255,255,255,.06)}.btn-danger{background:rgba(0,0,0,0);color:#e45858;border:1px solid rgba(228,88,88,.25)}.btn-danger:hover{background:rgba(228,88,88,.08)}.btn-ghost{background:rgba(0,0,0,0);border:none;color:#a0a0b8;padding:5px 8px}.btn-ghost:hover{color:#dcdce4;background:rgba(255,255,255,.04)}.btn-sm{padding:3px 8px;font-size:11px}.btn-icon{padding:4px;border-radius:3px;background:rgba(0,0,0,0);border:none;color:#6c6c84;cursor:pointer;transition:color .1s;font-size:13px}.btn-icon:hover{color:#dcdce4}.btn:disabled,.btn[disabled]{opacity:.4;cursor:not-allowed;pointer-events:none}.btn.btn-disabled-hint:disabled{opacity:.6;border-style:dashed;pointer-events:auto;cursor:help}.card{background:#1f1f28;border:1px solid rgba(255,255,255,.09);border-radius:5px;padding:16px}.card-header{display:flex;justify-content:space-between;align-items:center;margin-bottom:12px}.card-title{font-size:14px;font-weight:600}.data-table{width:100%;border-collapse:collapse;background:#1f1f28;border:1px solid rgba(255,255,255,.09);border-radius:5px;overflow:hidden}.data-table thead th{padding:8px 14px;text-align:left;font-size:10px;font-weight:600;text-transform:uppercase;letter-spacing:.04em;color:#6c6c84;border-bottom:1px solid rgba(255,255,255,.09);background:#26263a}.data-table tbody td{padding:8px 14px;font-size:13px;border-bottom:1px solid rgba(255,255,255,.06);max-width:300px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.data-table tbody tr{cursor:pointer;transition:background .08s}.data-table tbody tr:hover{background:rgba(255,255,255,.02)}.data-table tbody tr.row-selected{background:rgba(99,102,241,.12)}.data-table tbody tr:last-child td{border-bottom:none}.sortable-header{cursor:pointer;user-select:none;transition:color .1s}.sortable-header:hover{color:#9698f7}input[type=text],textarea,select{padding:6px 10px;border-radius:3px;border:1px solid rgba(255,255,255,.09);background:#111118;color:#dcdce4;font-size:13px;outline:none;transition:border-color .15s;font-family:inherit}input[type=text]::placeholder,textarea::placeholder,select::placeholder{color:#6c6c84}input[type=text]:focus,textarea:focus,select:focus{border-color:#7c7ef5}input[type=text][type=number],textarea[type=number],select[type=number]{width:80px;padding:6px 8px;-moz-appearance:textfield}input[type=text][type=number]::-webkit-outer-spin-button,input[type=text][type=number]::-webkit-inner-spin-button,textarea[type=number]::-webkit-outer-spin-button,textarea[type=number]::-webkit-inner-spin-button,select[type=number]::-webkit-outer-spin-button,select[type=number]::-webkit-inner-spin-button{-webkit-appearance:none;margin:0}textarea{min-height:64px;resize:vertical}select{appearance:none;-webkit-appearance:none;background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='10' viewBox='0 0 10 10'%3E%3Cpath fill='%236c6c84' d='M5 7L1 3h8z'/%3E%3C/svg%3E");background-repeat:no-repeat;background-position:right 8px center;padding-right:26px;min-width:100px}.form-group{margin-bottom:12px}.form-label{display:block;font-size:11px;font-weight:600;color:#a0a0b8;margin-bottom:4px;text-transform:uppercase;letter-spacing:.03em}.form-row{display:flex;flex-direction:row;justify-content:flex-start;align-items:flex-end;gap:8px}.form-row input[type=text]{flex:1}.form-label-row{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:2px;margin-bottom:4px}.form-label-row .form-label{margin-bottom:0}input[type=checkbox]{appearance:none;-webkit-appearance:none;width:16px;height:16px;border:1px solid rgba(255,255,255,.14);border-radius:3px;background:#1f1f28;cursor:pointer;position:relative;flex-shrink:0;transition:all .15s ease}input[type=checkbox]:hover{border-color:#7c7ef5;background:#26263a}input[type=checkbox]:checked{background:#7c7ef5;border-color:#7c7ef5}input[type=checkbox]:checked::after{content:"";position:absolute;left:5px;top:2px;width:4px;height:8px;border:solid #111118;border-width:0 2px 2px 0;transform:rotate(45deg)}input[type=checkbox]:focus-visible:focus-visible{outline:2px solid #7c7ef5;outline-offset:2px}.checkbox-label{display:inline-flex;align-items:center;gap:8px;cursor:pointer;font-size:13px;color:#a0a0b8;user-select:none}.checkbox-label:hover{color:#dcdce4}.checkbox-label input[type=checkbox]{margin:0}.toggle{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:8px;cursor:pointer;font-size:13px;color:#dcdce4}.toggle.disabled{opacity:.4;cursor:not-allowed}.toggle-track{width:32px;height:18px;border-radius:9px;background:#26263a;border:1px solid rgba(255,255,255,.09);position:relative;transition:background .15s;flex-shrink:0}.toggle-track.active{background:#7c7ef5;border-color:#7c7ef5}.toggle-track.active .toggle-thumb{transform:translateX(14px)}.toggle-thumb{width:14px;height:14px;border-radius:50%;background:#dcdce4;position:absolute;top:1px;left:1px;transition:transform .15s}.filter-bar{display:flex;flex-direction:column;justify-content:flex-start;align-items:stretch;gap:12px;padding:12px;background:#111118;border:1px solid rgba(255,255,255,.06);border-radius:5px;margin-bottom:12px}.filter-row{display:flex;flex-wrap:wrap;align-items:center;gap:8px}.filter-label{font-size:11px;font-weight:500;color:#6c6c84;text-transform:uppercase;letter-spacing:.5px;margin-right:4px}.filter-chip{display:inline-flex;align-items:center;gap:6px;padding:5px 10px;background:#1f1f28;border:1px solid rgba(255,255,255,.09);border-radius:14px;cursor:pointer;font-size:11px;color:#a0a0b8;transition:all .15s ease;user-select:none}.filter-chip:hover{background:#26263a;border-color:rgba(255,255,255,.14);color:#dcdce4}.filter-chip.active{background:rgba(124,126,245,.15);border-color:#7c7ef5;color:#9698f7}.filter-chip input[type=checkbox]{width:12px;height:12px;margin:0}.filter-chip input[type=checkbox]:checked::after{left:3px;top:1px;width:3px;height:6px}.filter-group{display:flex;align-items:center;gap:6px}.filter-group label{display:flex;align-items:center;gap:3px;cursor:pointer;color:#a0a0b8;font-size:11px;white-space:nowrap}.filter-group label:hover{color:#dcdce4}.filter-separator{width:1px;height:20px;background:rgba(255,255,255,.09);flex-shrink:0}.view-toggle{display:flex;border:1px solid rgba(255,255,255,.09);border-radius:3px;overflow:hidden}.view-btn{padding:4px 10px;background:#1f1f28;border:none;color:#6c6c84;cursor:pointer;font-size:18px;line-height:1;transition:background .1s,color .1s}.view-btn:first-child{border-right:1px solid rgba(255,255,255,.09)}.view-btn:hover{color:#dcdce4;background:#26263a}.view-btn.active{background:rgba(124,126,245,.15);color:#9698f7}.breadcrumb{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:4px;padding:10px 16px;font-size:.85rem;color:#6c6c84}.breadcrumb-sep{color:#6c6c84;opacity:.5}.breadcrumb-link{color:#9698f7;text-decoration:none;cursor:pointer}.breadcrumb-link:hover{text-decoration:underline}.breadcrumb-current{color:#dcdce4;font-weight:500}.progress-bar{width:100%;height:8px;background:#26263a;border-radius:4px;overflow:hidden;margin-bottom:6px}.progress-fill{height:100%;background:#7c7ef5;border-radius:4px;transition:width .3s ease}.progress-fill.indeterminate{width:30%;animation:indeterminate 1.5s ease-in-out infinite}.loading-overlay{display:flex;align-items:center;justify-content:center;padding:48px 16px;color:#6c6c84;font-size:13px;gap:10px}.spinner{width:18px;height:18px;border:2px solid rgba(255,255,255,.09);border-top-color:#7c7ef5;border-radius:50%;animation:spin .7s linear infinite}.spinner-small{width:14px;height:14px;border:2px solid rgba(255,255,255,.09);border-top-color:#7c7ef5;border-radius:50%;animation:spin .7s linear infinite}.spinner-tiny{width:10px;height:10px;border:1.5px solid rgba(255,255,255,.09);border-top-color:#7c7ef5;border-radius:50%;animation:spin .7s linear infinite}.modal-overlay{position:fixed;inset:0;background:rgba(0,0,0,.5);display:flex;align-items:center;justify-content:center;z-index:100;animation:fade-in .1s ease-out}.modal{background:#1f1f28;border:1px solid rgba(255,255,255,.09);border-radius:7px;padding:20px;min-width:360px;max-width:480px;box-shadow:0 4px 20px rgba(0,0,0,.45)}.modal.wide{max-width:600px;max-height:70vh;overflow-y:auto}.modal-title{font-size:15px;font-weight:600;margin-bottom:6px}.modal-body{font-size:12px;color:#a0a0b8;margin-bottom:16px;line-height:1.5}.modal-actions{display:flex;flex-direction:row;justify-content:flex-end;align-items:center;gap:6px}.tooltip-trigger{display:inline-flex;align-items:center;justify-content:center;width:14px;height:14px;border-radius:50%;background:#26263a;color:#6c6c84;font-size:9px;font-weight:700;cursor:help;position:relative;flex-shrink:0;margin-left:4px}.tooltip-trigger:hover{background:rgba(124,126,245,.15);color:#9698f7}.tooltip-trigger:hover .tooltip-text{display:block}.tooltip-text{display:none;position:absolute;bottom:calc(100% + 6px);left:50%;transform:translateX(-50%);padding:6px 10px;background:#26263a;border:1px solid rgba(255,255,255,.09);border-radius:3px;color:#dcdce4;font-size:11px;font-weight:400;line-height:1.4;white-space:normal;width:220px;text-transform:none;letter-spacing:normal;box-shadow:0 2px 8px rgba(0,0,0,.35);z-index:100;pointer-events:none}.media-player{position:relative;background:#111118;border-radius:5px;overflow:hidden}.media-player:focus{outline:none}.media-player-audio .player-artwork{display:flex;flex-direction:column;justify-content:center;align-items:center;gap:8px;padding:24px 16px 8px}.player-artwork img{max-width:200px;max-height:200px;border-radius:5px;object-fit:cover}.player-artwork-placeholder{width:120px;height:120px;display:flex;align-items:center;justify-content:center;background:#1f1f28;border-radius:5px;font-size:48px;opacity:.3}.player-title{font-size:13px;font-weight:500;color:#dcdce4;text-align:center}.player-controls{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:8px;padding:10px 14px;background:#1f1f28}.media-player-video .player-controls{position:absolute;bottom:0;left:0;right:0;background:rgba(0,0,0,.7);opacity:0;transition:opacity .2s}.media-player-video:hover .player-controls{opacity:1}.play-btn,.mute-btn,.fullscreen-btn{background:none;border:none;color:#dcdce4;cursor:pointer;font-size:18px;padding:4px;line-height:1;transition:color .1s}.play-btn:hover,.mute-btn:hover,.fullscreen-btn:hover{color:#9698f7}.player-time{font-size:11px;color:#6c6c84;font-family:"JetBrains Mono","Fira Code",ui-monospace,monospace;min-width:36px;text-align:center;user-select:none}.seek-bar{flex:1;-webkit-appearance:none;appearance:none;height:4px;border-radius:4px;background:#26263a;outline:none;cursor:pointer}.seek-bar::-webkit-slider-thumb{-webkit-appearance:none;appearance:none;width:12px;height:12px;border-radius:50%;background:#7c7ef5;cursor:pointer;border:none}.seek-bar::-moz-range-thumb{width:12px;height:12px;border-radius:50%;background:#7c7ef5;cursor:pointer;border:none}.volume-slider{width:70px;-webkit-appearance:none;appearance:none;height:4px;border-radius:4px;background:#26263a;outline:none;cursor:pointer}.volume-slider::-webkit-slider-thumb{-webkit-appearance:none;appearance:none;width:10px;height:10px;border-radius:50%;background:#a0a0b8;cursor:pointer;border:none}.volume-slider::-moz-range-thumb{width:10px;height:10px;border-radius:50%;background:#a0a0b8;cursor:pointer;border:none}.image-viewer-overlay{position:fixed;inset:0;background:rgba(0,0,0,.92);z-index:150;display:flex;flex-direction:column;justify-content:flex-start;align-items:stretch;animation:fade-in .15s ease-out}.image-viewer-overlay:focus{outline:none}.image-viewer-toolbar{display:flex;justify-content:space-between;align-items:center;padding:10px 16px;background:rgba(0,0,0,.5);border-bottom:1px solid rgba(255,255,255,.08);z-index:2;user-select:none}.image-viewer-toolbar-left,.image-viewer-toolbar-center,.image-viewer-toolbar-right{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:6px}.iv-btn{background:rgba(255,255,255,.06);border:1px solid rgba(255,255,255,.1);color:#dcdce4;border-radius:3px;padding:4px 10px;font-size:12px;cursor:pointer;transition:background .1s}.iv-btn:hover{background:rgba(255,255,255,.12)}.iv-btn.iv-close{color:#e45858;font-weight:600}.iv-zoom-label{font-size:11px;color:#a0a0b8;min-width:40px;text-align:center;font-family:"JetBrains Mono","Fira Code",ui-monospace,monospace}.image-viewer-canvas{flex:1;display:flex;align-items:center;justify-content:center;overflow:hidden;position:relative}.image-viewer-canvas img{max-width:100%;max-height:100%;object-fit:contain;user-select:none;-webkit-user-drag:none}.pdf-viewer{display:flex;flex-direction:column;justify-content:flex-start;align-items:stretch;height:100%;min-height:500px;background:#111118;border-radius:5px;overflow:hidden}.pdf-toolbar{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:12px;padding:10px 12px;background:#18181f;border-bottom:1px solid rgba(255,255,255,.09)}.pdf-toolbar-group{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:4px}.pdf-toolbar-btn{display:flex;align-items:center;justify-content:center;width:28px;height:28px;background:#1f1f28;border:1px solid rgba(255,255,255,.09);border-radius:3px;color:#a0a0b8;font-size:14px;cursor:pointer;transition:all .15s}.pdf-toolbar-btn:hover:not(:disabled){background:#26263a;color:#dcdce4}.pdf-toolbar-btn:disabled{opacity:.4;cursor:not-allowed}.pdf-zoom-label{min-width:45px;text-align:center;font-size:12px;color:#a0a0b8}.pdf-container{flex:1;position:relative;overflow:hidden;background:#1f1f28}.pdf-object{width:100%;height:100%;border:none}.pdf-loading,.pdf-error{position:absolute;inset:0;display:flex;flex-direction:column;justify-content:center;align-items:center;gap:12px;background:#18181f;color:#a0a0b8}.pdf-error{padding:12px;text-align:center}.pdf-fallback{display:flex;flex-direction:column;justify-content:center;align-items:center;gap:16px;padding:48px 12px;text-align:center;color:#6c6c84}.markdown-viewer{padding:16px;text-align:left;display:flex;flex-direction:column;justify-content:flex-start;align-items:stretch;gap:12px}.markdown-toolbar{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:8px;padding:8px;background:#1f1f28;border-radius:5px;border:1px solid rgba(255,255,255,.09)}.toolbar-btn{padding:6px 12px;border:1px solid rgba(255,255,255,.09);border-radius:3px;background:#18181f;color:#a0a0b8;font-size:13px;font-weight:500;cursor:pointer;transition:all .15s}.toolbar-btn:hover{background:#26263a;border-color:rgba(255,255,255,.14)}.toolbar-btn.active{background:#7c7ef5;color:#fff;border-color:#7c7ef5}.markdown-source{max-width:100%;background:#26263a;border:1px solid rgba(255,255,255,.09);border-radius:5px;padding:16px;overflow-x:auto;font-family:"Menlo","Monaco","Courier New",monospace;font-size:13px;line-height:1.7;color:#dcdce4;white-space:pre-wrap;word-wrap:break-word}.markdown-source code{font-family:inherit;background:none;padding:0;border:none}.markdown-content{max-width:800px;color:#dcdce4;line-height:1.7;font-size:14px;text-align:left}.markdown-content h1{font-size:1.8em;font-weight:700;margin:1em 0 .5em;border-bottom:1px solid rgba(255,255,255,.06);padding-bottom:.3em}.markdown-content h2{font-size:1.5em;font-weight:600;margin:.8em 0 .4em;border-bottom:1px solid rgba(255,255,255,.06);padding-bottom:.2em}.markdown-content h3{font-size:1.25em;font-weight:600;margin:.6em 0 .3em}.markdown-content h4{font-size:1.1em;font-weight:600;margin:.5em 0 .25em}.markdown-content h5,.markdown-content h6{font-size:1em;font-weight:600;margin:.4em 0 .2em;color:#a0a0b8}.markdown-content p{margin:0 0 1em}.markdown-content a{color:#7c7ef5;text-decoration:none}.markdown-content a:hover{text-decoration:underline}.markdown-content pre{background:#26263a;border:1px solid rgba(255,255,255,.09);border-radius:3px;padding:12px 16px;overflow-x:auto;margin:0 0 1em;font-family:"JetBrains Mono","Fira Code",ui-monospace,monospace;font-size:12px;line-height:1.5}.markdown-content code{background:#26263a;padding:1px 5px;border-radius:3px;font-family:"JetBrains Mono","Fira Code",ui-monospace,monospace;font-size:.9em}.markdown-content pre code{background:none;padding:0}.markdown-content blockquote{border-left:3px solid #7c7ef5;padding:4px 16px;margin:0 0 1em;color:#a0a0b8;background:rgba(124,126,245,.04)}.markdown-content table{width:100%;border-collapse:collapse;margin:0 0 1em}.markdown-content th,.markdown-content td{padding:6px 12px;border:1px solid rgba(255,255,255,.09);font-size:13px}.markdown-content th{background:#26263a;font-weight:600;text-align:left}.markdown-content tr:nth-child(even){background:#1f1f28}.markdown-content ul,.markdown-content ol{margin:0 0 1em;padding-left:16px}.markdown-content ul{list-style:disc}.markdown-content ol{list-style:decimal}.markdown-content li{padding:2px 0;font-size:14px;color:#dcdce4}.markdown-content hr{border:none;border-top:1px solid rgba(255,255,255,.09);margin:1.5em 0}.markdown-content img{max-width:100%;border-radius:5px}.markdown-content .footnote-definition{font-size:.85em;color:#a0a0b8;margin-top:.5em;padding-left:1.5em}.markdown-content .footnote-definition sup{color:#7c7ef5;margin-right:4px}.markdown-content sup a{color:#7c7ef5;text-decoration:none;font-size:.8em}.wikilink{color:#9698f7;text-decoration:none;border-bottom:1px dashed #7c7ef5;cursor:pointer;transition:border-color .1s,color .1s}.wikilink:hover{color:#7c7ef5;border-bottom-style:solid}.wikilink-embed{display:inline-block;padding:2px 8px;background:rgba(139,92,246,.08);border:1px dashed rgba(139,92,246,.3);border-radius:3px;color:#9d8be0;font-size:12px;cursor:default}.media-grid{display:grid;grid-template-columns:repeat(auto-fill, minmax(180px, 1fr));gap:12px}.media-card{background:#1f1f28;border:1px solid rgba(255,255,255,.09);border-radius:5px;overflow:hidden;cursor:pointer;transition:border-color .12s,box-shadow .12s;position:relative}.media-card:hover{border-color:rgba(255,255,255,.14);box-shadow:0 1px 3px rgba(0,0,0,.3)}.media-card.selected{border-color:#7c7ef5;box-shadow:0 0 0 1px #7c7ef5}.card-checkbox{position:absolute;top:6px;left:6px;z-index:2;opacity:0;transition:opacity .1s}.card-checkbox input[type=checkbox]{width:16px;height:16px;cursor:pointer;filter:drop-shadow(0 1px 2px rgba(0,0,0,.5))}.media-card:hover .card-checkbox,.media-card.selected .card-checkbox{opacity:1}.card-thumbnail{width:100%;aspect-ratio:1;background:#111118;display:flex;align-items:center;justify-content:center;overflow:hidden;position:relative}.card-thumbnail img,.card-thumbnail .card-thumb-img{width:100%;height:100%;object-fit:cover;position:absolute;top:0;left:0;z-index:1}.card-type-icon{font-size:32px;opacity:.4;display:flex;align-items:center;justify-content:center;width:100%;height:100%;position:absolute;top:0;left:0;z-index:0}.card-info{padding:8px 10px}.card-name{font-size:12px;font-weight:500;color:#dcdce4;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;margin-bottom:4px}.card-title,.card-artist{font-size:10px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;line-height:1.3}.card-meta{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:6px;font-size:10px}.card-size{color:#6c6c84;font-size:10px}.table-thumb-cell{width:36px;padding:4px 6px !important;position:relative}.table-thumb{width:28px;height:28px;object-fit:cover;border-radius:3px;display:block}.table-thumb-overlay{position:absolute;top:4px;left:6px;z-index:1}.table-type-icon{display:flex;align-items:center;justify-content:center;width:28px;height:28px;font-size:14px;opacity:.5;border-radius:3px;background:#111118;z-index:0}.type-badge{display:inline-block;padding:1px 6px;border-radius:3px;font-size:10px;font-weight:600;text-transform:uppercase;letter-spacing:.04em}.type-badge.type-audio{background:rgba(139,92,246,.1);color:#9d8be0}.type-badge.type-video{background:rgba(200,72,130,.1);color:#d07eaa}.type-badge.type-image{background:rgba(34,160,80,.1);color:#5cb97a}.type-badge.type-document{background:rgba(59,120,200,.1);color:#6ca0d4}.type-badge.type-text{background:rgba(200,160,36,.1);color:#c4a840}.type-badge.type-other{background:rgba(128,128,160,.08);color:#6c6c84}.tag-list{display:flex;flex-wrap:wrap;gap:4px}.tag-badge{display:inline-flex;align-items:center;gap:4px;padding:2px 10px;background:rgba(124,126,245,.15);color:#9698f7;border-radius:12px;font-size:11px;font-weight:500}.tag-badge.selected{background:#7c7ef5;color:#fff;cursor:pointer}.tag-badge:not(.selected){cursor:pointer}.tag-badge .tag-remove{cursor:pointer;opacity:.4;font-size:13px;line-height:1;transition:opacity .1s}.tag-badge .tag-remove:hover{opacity:1}.tag-group{margin-bottom:6px}.tag-children{margin-left:16px;margin-top:4px;display:flex;flex-wrap:wrap;gap:4px}.tag-confirm-delete{display:inline-flex;align-items:center;gap:4px;font-size:10px;color:#a0a0b8}.tag-confirm-yes{cursor:pointer;color:#e45858;font-weight:600}.tag-confirm-yes:hover{text-decoration:underline}.tag-confirm-no{cursor:pointer;color:#6c6c84;font-weight:500}.tag-confirm-no:hover{text-decoration:underline}.detail-actions{display:flex;gap:6px;margin-bottom:16px}.detail-grid{display:grid;grid-template-columns:1fr 1fr;gap:8px}.detail-field{padding:10px 12px;background:#111118;border-radius:3px;border:1px solid rgba(255,255,255,.06)}.detail-field.full-width{grid-column:1/-1}.detail-field input[type=text],.detail-field textarea,.detail-field select{width:100%;margin-top:4px}.detail-field textarea{min-height:64px;resize:vertical}.detail-label{font-size:10px;font-weight:600;color:#6c6c84;text-transform:uppercase;letter-spacing:.04em;margin-bottom:2px}.detail-value{font-size:13px;color:#dcdce4;word-break:break-all}.detail-value.mono{font-family:"JetBrains Mono","Fira Code",ui-monospace,monospace;font-size:11px;color:#a0a0b8}.detail-preview{margin-bottom:16px;background:#111118;border:1px solid rgba(255,255,255,.06);border-radius:5px;overflow:hidden;text-align:center}.detail-preview:has(.markdown-viewer){max-height:none;overflow-y:auto;text-align:left}.detail-preview:not(:has(.markdown-viewer)){max-height:450px}.detail-preview img{max-width:100%;max-height:400px;object-fit:contain;display:block;margin:0 auto}.detail-preview audio{width:100%;padding:16px}.detail-preview video{max-width:100%;max-height:400px;display:block;margin:0 auto}.detail-no-preview{padding:16px 16px;text-align:center;display:flex;flex-direction:column;justify-content:center;align-items:center;gap:10px}.frontmatter-card{max-width:800px;background:#1f1f28;border:1px solid rgba(255,255,255,.09);border-radius:5px;padding:12px 16px;margin-bottom:16px}.frontmatter-fields{display:grid;grid-template-columns:auto 1fr;gap:4px 12px;margin:0}.frontmatter-fields dt{font-weight:600;font-size:12px;color:#a0a0b8;text-transform:capitalize}.frontmatter-fields dd{font-size:13px;color:#dcdce4;margin:0}.empty-state{text-align:center;padding:48px 12px;color:#6c6c84}.empty-state .empty-icon{font-size:32px;margin-bottom:12px;opacity:.3}.empty-title{font-size:15px;font-weight:600;color:#a0a0b8;margin-bottom:4px}.empty-subtitle{font-size:12px;max-width:320px;margin:0 auto;line-height:1.5}.toast-container{position:fixed;bottom:16px;right:16px;z-index:300;display:flex;flex-direction:column-reverse;gap:6px;align-items:flex-end}.toast-container .toast{position:static;transform:none}.toast{position:fixed;bottom:16px;right:16px;padding:10px 16px;border-radius:5px;background:#26263a;border:1px solid rgba(255,255,255,.09);color:#dcdce4;font-size:12px;box-shadow:0 2px 8px rgba(0,0,0,.35);z-index:300;animation:slide-up .15s ease-out;max-width:420px}.toast.success{border-left:3px solid #3ec97a}.toast.error{border-left:3px solid #e45858}.offline-banner,.error-banner{background:rgba(228,88,88,.06);border:1px solid rgba(228,88,88,.2);border-radius:3px;padding:10px 12px;margin-bottom:12px;font-size:12px;color:#d47070;display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:8px}.offline-banner .offline-icon,.offline-banner .error-icon,.error-banner .offline-icon,.error-banner .error-icon{font-size:14px;flex-shrink:0}.error-banner{padding:10px 14px}.readonly-banner{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:8px;padding:10px 12px;background:rgba(212,160,55,.06);border:1px solid rgba(212,160,55,.15);border-radius:3px;margin-bottom:16px;font-size:12px;color:#d4a037}.batch-actions{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:8px;padding:8px 10px;background:rgba(124,126,245,.15);border:1px solid rgba(124,126,245,.2);border-radius:3px;margin-bottom:12px;font-size:12px;font-weight:500;color:#9698f7}.select-all-banner{display:flex;flex-direction:row;justify-content:center;align-items:center;gap:8px;padding:10px 16px;background:rgba(99,102,241,.08);border-radius:6px;margin-bottom:8px;font-size:.85rem;color:#a0a0b8}.select-all-banner button{background:none;border:none;color:#7c7ef5;cursor:pointer;font-weight:600;text-decoration:underline;font-size:.85rem;padding:0}.select-all-banner button:hover{color:#dcdce4}.import-status-panel{background:#1f1f28;border:1px solid #7c7ef5;border-radius:5px;padding:12px 16px;margin-bottom:16px}.import-status-header{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:8px;margin-bottom:8px;font-size:13px;color:#dcdce4}.import-current-file{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:2px;margin-bottom:6px;font-size:12px;overflow:hidden}.import-file-label{color:#6c6c84;flex-shrink:0}.import-file-name{color:#dcdce4;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;font-family:monospace;font-size:11px}.import-queue-indicator{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:2px;margin-bottom:8px;font-size:11px}.import-queue-badge{display:flex;align-items:center;justify-content:center;min-width:18px;height:18px;padding:0 6px;background:rgba(124,126,245,.15);color:#9698f7;border-radius:9px;font-weight:600;font-size:10px}.import-queue-text{color:#6c6c84}.import-tabs{display:flex;gap:0;margin-bottom:16px;border-bottom:1px solid rgba(255,255,255,.09)}.import-tab{padding:10px 16px;background:none;border:none;border-bottom:2px solid rgba(0,0,0,0);color:#6c6c84;font-size:12px;font-weight:500;cursor:pointer;transition:color .1s,border-color .1s}.import-tab:hover{color:#dcdce4}.import-tab.active{color:#9698f7;border-bottom-color:#7c7ef5}.queue-panel{display:flex;flex-direction:column;justify-content:flex-start;align-items:stretch;border-left:1px solid rgba(255,255,255,.09);background:#18181f;min-width:280px;max-width:320px}.queue-header{display:flex;justify-content:space-between;align-items:center;padding:12px 16px;border-bottom:1px solid rgba(255,255,255,.06)}.queue-header h3{margin:0;font-size:.9rem;color:#dcdce4}.queue-controls{display:flex;gap:2px}.queue-list{overflow-y:auto;flex:1}.queue-item{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;padding:8px 16px;cursor:pointer;border-bottom:1px solid rgba(255,255,255,.06);transition:background .15s}.queue-item:hover{background:#1f1f28}.queue-item:hover .queue-item-remove{opacity:1}.queue-item-active{background:rgba(124,126,245,.15);border-left:3px solid #7c7ef5}.queue-item-info{flex:1;min-width:0}.queue-item-title{display:block;font-size:.85rem;color:#dcdce4;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.queue-item-artist{display:block;font-size:.75rem;color:#6c6c84}.queue-item-remove{opacity:0;transition:opacity .15s}.queue-empty{padding:16px 16px;text-align:center;color:#6c6c84;font-size:.85rem}.statistics-page{padding:20px}.stats-overview,.stats-grid{display:grid;grid-template-columns:repeat(3, 1fr);gap:16px;margin-bottom:24px}@media (max-width: 768px){.stats-overview,.stats-grid{grid-template-columns:1fr}}.stat-card{background:#1f1f28;border:1px solid rgba(255,255,255,.09);border-radius:5px;padding:20px;display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:16px}.stat-card.stat-primary{border-left:3px solid #7c7ef5}.stat-card.stat-success{border-left:3px solid #3ec97a}.stat-card.stat-info{border-left:3px solid #6ca0d4}.stat-card.stat-warning{border-left:3px solid #d4a037}.stat-card.stat-purple{border-left:3px solid #9d8be0}.stat-card.stat-danger{border-left:3px solid #e45858}.stat-icon{flex-shrink:0;color:#6c6c84}.stat-content{flex:1}.stat-value{font-size:28px;font-weight:700;color:#dcdce4;line-height:1.2;font-variant-numeric:tabular-nums}.stat-label{font-size:12px;color:#6c6c84;margin-top:4px;font-weight:500}.stats-section{background:#1f1f28;border:1px solid rgba(255,255,255,.09);border-radius:5px;padding:16px;margin-bottom:20px}.section-title{font-size:16px;font-weight:600;color:#dcdce4;margin-bottom:20px}.section-title.small{font-size:14px;margin-bottom:12px;padding-bottom:6px;border-bottom:1px solid rgba(255,255,255,.06)}.chart-bars{display:flex;flex-direction:column;justify-content:flex-start;align-items:stretch;gap:16px}.bar-item{display:grid;grid-template-columns:120px 1fr 80px;align-items:center;gap:16px}.bar-label{font-size:13px;font-weight:500;color:#a0a0b8;text-align:right}.bar-track{height:28px;background:#26263a;border-radius:3px;overflow:hidden;position:relative}.bar-fill{height:100%;transition:width .6s cubic-bezier(.4, 0, .2, 1);border-radius:3px}.bar-fill.bar-primary{background:linear-gradient(90deg, #7c7ef5 0%, #7c7ef3 100%)}.bar-fill.bar-success{background:linear-gradient(90deg, #3ec97a 0%, #66bb6a 100%)}.bar-value{font-size:13px;font-weight:600;color:#a0a0b8;text-align:right;font-variant-numeric:tabular-nums}.settings-section{margin-bottom:16px}.settings-card{background:#1f1f28;border:1px solid rgba(255,255,255,.09);border-radius:7px;padding:20px;margin-bottom:16px}.settings-card.danger-card{border:1px solid rgba(228,88,88,.25)}.settings-card-header{display:flex;justify-content:space-between;align-items:center;margin-bottom:16px;padding-bottom:12px;border-bottom:1px solid rgba(255,255,255,.06)}.settings-card-title{font-size:14px;font-weight:600}.settings-card-body{padding-top:2px}.settings-field{display:flex;justify-content:space-between;align-items:center;padding:8px 0;border-bottom:1px solid rgba(255,255,255,.06)}.settings-field:last-child{border-bottom:none}.settings-field select{min-width:120px}.config-path{font-size:11px;color:#6c6c84;margin-bottom:12px;font-family:"JetBrains Mono","Fira Code",ui-monospace,monospace;padding:6px 10px;background:#111118;border-radius:3px;border:1px solid rgba(255,255,255,.06)}.config-status{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:6px;padding:3px 10px;border-radius:12px;font-size:11px;font-weight:600}.config-status.writable{background:rgba(62,201,122,.1);color:#3ec97a}.config-status.readonly{background:rgba(228,88,88,.1);color:#e45858}.root-list{list-style:none}.root-item{display:flex;justify-content:space-between;align-items:center;padding:8px 12px;background:#111118;border:1px solid rgba(255,255,255,.06);border-radius:3px;margin-bottom:4px;font-family:"JetBrains Mono","Fira Code",ui-monospace,monospace;font-size:12px;color:#a0a0b8}.info-row{display:flex;justify-content:space-between;align-items:center;padding:6px 0;border-bottom:1px solid rgba(255,255,255,.06);font-size:13px}.info-row:last-child{border-bottom:none}.info-label{color:#a0a0b8;font-weight:500}.info-value{color:#dcdce4}.tasks-grid{display:grid;grid-template-columns:repeat(auto-fill, minmax(400px, 1fr));gap:16px;padding:12px}.task-card{background:#1f1f28;border:1px solid rgba(255,255,255,.09);border-radius:5px;overflow:hidden;transition:all .2s}.task-card:hover{border-color:rgba(255,255,255,.14);box-shadow:0 4px 12px rgba(0,0,0,.08);transform:translateY(-2px)}.task-card-enabled{border-left:3px solid #3ec97a}.task-card-disabled{border-left:3px solid #4a4a5e;opacity:.7}.task-card-header{display:flex;justify-content:space-between;align-items:center;align-items:flex-start;padding:16px;border-bottom:1px solid rgba(255,255,255,.06)}.task-header-left{flex:1;min-width:0}.task-name{font-size:16px;font-weight:600;color:#dcdce4;margin-bottom:2px}.task-schedule{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:6px;font-size:12px;color:#6c6c84;font-family:"Menlo","Monaco","Courier New",monospace}.schedule-icon{font-size:14px}.task-status-badge{flex-shrink:0}.status-badge{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:6px;padding:2px 10px;border-radius:3px;font-size:10px;font-weight:600;text-transform:uppercase;letter-spacing:.03em}.status-badge.status-enabled{background:rgba(76,175,80,.12);color:#3ec97a}.status-badge.status-enabled .status-dot{animation:pulse 1.5s infinite}.status-badge.status-disabled{background:#26263a;color:#6c6c84}.status-badge .status-dot{width:6px;height:6px;border-radius:50%;flex-shrink:0;background:currentColor}.task-info-grid{display:grid;grid-template-columns:repeat(auto-fit, minmax(120px, 1fr));gap:12px;padding:16px}.task-info-item{display:flex;flex-direction:row;justify-content:flex-start;align-items:flex-start;gap:10px}.task-info-icon{font-size:18px;color:#6c6c84;flex-shrink:0}.task-info-content{flex:1;min-width:0}.task-info-label{font-size:10px;color:#6c6c84;font-weight:600;text-transform:uppercase;letter-spacing:.03em;margin-bottom:2px}.task-info-value{font-size:12px;color:#a0a0b8;font-weight:500;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.task-card-actions{display:flex;gap:8px;padding:10px 16px;background:#18181f;border-top:1px solid rgba(255,255,255,.06)}.task-card-actions button{flex:1}.db-actions{display:flex;flex-direction:column;justify-content:flex-start;align-items:stretch;gap:16px;padding:10px}.db-action-row{display:flex;flex-direction:row;justify-content:space-between;align-items:center;gap:16px;padding:10px;border-radius:6px;background:rgba(0,0,0,.06)}.db-action-info{flex:1}.db-action-info h4{font-size:.95rem;font-weight:600;color:#dcdce4;margin-bottom:2px}.db-action-confirm{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:8px;flex-shrink:0}.library-toolbar{display:flex;justify-content:space-between;align-items:center;padding:8px 0;margin-bottom:12px;gap:12px;flex-wrap:wrap}.toolbar-left{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:10px}.toolbar-right{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:10px}.sort-control select,.page-size-control select{padding:4px 24px 4px 8px;font-size:11px;background:#1f1f28}.page-size-control{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:4px}.library-stats{display:flex;justify-content:space-between;align-items:center;padding:2px 0 6px 0;font-size:11px}.type-filter-row{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:6px;padding:4px 0;margin-bottom:6px;flex-wrap:wrap}.pagination{display:flex;align-items:center;justify-content:center;gap:4px;margin-top:16px;padding:8px 0}.page-btn{min-width:28px;text-align:center;font-variant-numeric:tabular-nums}.page-ellipsis{color:#6c6c84;padding:0 4px;font-size:12px;user-select:none}.audit-controls{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:8px;margin-bottom:12px}.filter-select{padding:4px 24px 4px 8px;font-size:11px;background:#1f1f28}.action-danger{background:rgba(228,88,88,.1);color:#d47070}.action-updated{background:rgba(59,120,200,.1);color:#6ca0d4}.action-collection{background:rgba(34,160,80,.1);color:#5cb97a}.action-collection-remove{background:rgba(212,160,55,.1);color:#c4a840}.action-opened{background:rgba(139,92,246,.1);color:#9d8be0}.action-scanned{background:rgba(128,128,160,.08);color:#6c6c84}.clickable{cursor:pointer;color:#9698f7}.clickable:hover{text-decoration:underline}.clickable-row{cursor:pointer}.clickable-row:hover{background:rgba(255,255,255,.03)}.duplicates-view{padding:0}.duplicates-header{display:flex;justify-content:space-between;align-items:center;margin-bottom:16px}.duplicates-header h3{margin:0}.duplicates-summary{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:12px}.duplicate-group{border:1px solid rgba(255,255,255,.09);border-radius:5px;margin-bottom:8px;overflow:hidden}.duplicate-group-header{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:12px;width:100%;padding:10px 14px;background:#1f1f28;border:none;cursor:pointer;text-align:left;color:#dcdce4;font-size:13px}.duplicate-group-header:hover{background:#26263a}.expand-icon{font-size:10px;width:14px;flex-shrink:0}.group-name{font-weight:600;flex:1;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.group-badge{background:#7c7ef5;color:#fff;padding:2px 8px;border-radius:10px;font-size:11px;font-weight:600;flex-shrink:0}.group-size{flex-shrink:0;font-size:12px}.group-hash{font-size:11px;flex-shrink:0}.duplicate-items{border-top:1px solid rgba(255,255,255,.09)}.duplicate-item{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:12px;padding:10px 14px;border-bottom:1px solid rgba(255,255,255,.06)}.duplicate-item:last-child{border-bottom:none}.duplicate-item-keep{background:rgba(76,175,80,.06)}.dup-thumb{width:48px;height:48px;flex-shrink:0;border-radius:3px;overflow:hidden}.dup-thumb-img{width:100%;height:100%;object-fit:cover}.dup-thumb-placeholder{width:100%;height:100%;display:flex;align-items:center;justify-content:center;background:#26263a;font-size:20px;color:#6c6c84}.dup-info{flex:1;min-width:0}.dup-filename{font-weight:600;font-size:13px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.dup-path{font-size:11px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.dup-meta{font-size:12px;margin-top:2px}.dup-actions{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:6px;flex-shrink:0}.keep-badge{background:rgba(76,175,80,.12);color:#4caf50;padding:2px 10px;border-radius:10px;font-size:11px;font-weight:600}.saved-searches-list{display:flex;flex-direction:column;justify-content:flex-start;align-items:stretch;gap:4px;max-height:300px;overflow-y:auto}.saved-search-item{display:flex;justify-content:space-between;align-items:center;padding:8px 12px;background:#18181f;border-radius:3px;cursor:pointer;transition:background .15s ease}.saved-search-item:hover{background:#1f1f28}.saved-search-info{display:flex;flex-direction:column;justify-content:flex-start;align-items:stretch;gap:2px;flex:1;min-width:0}.saved-search-name{font-weight:500;color:#dcdce4}.saved-search-query{font-size:11px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.backlinks-panel,.outgoing-links-panel{background:#1f1f28;border:1px solid rgba(255,255,255,.09);border-radius:5px;margin-top:16px;overflow:hidden}.backlinks-header,.outgoing-links-header{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:8px;padding:10px 14px;background:#26263a;cursor:pointer;user-select:none;transition:background .1s}.backlinks-header:hover,.outgoing-links-header:hover{background:rgba(255,255,255,.04)}.backlinks-toggle,.outgoing-links-toggle{font-size:10px;color:#6c6c84;width:12px;text-align:center}.backlinks-title,.outgoing-links-title{font-size:12px;font-weight:600;color:#dcdce4;flex:1}.backlinks-count,.outgoing-links-count{font-size:11px;color:#6c6c84}.backlinks-reindex-btn{display:flex;align-items:center;justify-content:center;width:22px;height:22px;padding:0;margin-left:auto;background:rgba(0,0,0,0);border:1px solid rgba(255,255,255,.09);border-radius:3px;color:#6c6c84;font-size:12px;cursor:pointer;transition:background .1s,color .1s,border-color .1s}.backlinks-reindex-btn:hover:not(:disabled){background:#1f1f28;color:#dcdce4;border-color:rgba(255,255,255,.14)}.backlinks-reindex-btn:disabled{opacity:.5;cursor:not-allowed}.backlinks-content,.outgoing-links-content{padding:12px;border-top:1px solid rgba(255,255,255,.06)}.backlinks-loading,.outgoing-links-loading{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:8px;padding:12px;color:#6c6c84;font-size:12px}.backlinks-error,.outgoing-links-error{padding:8px 12px;background:rgba(228,88,88,.06);border:1px solid rgba(228,88,88,.2);border-radius:3px;font-size:12px;color:#e45858}.backlinks-empty,.outgoing-links-empty{padding:16px;text-align:center;color:#6c6c84;font-size:12px;font-style:italic}.backlinks-list,.outgoing-links-list{list-style:none;padding:0;margin:0;display:flex;flex-direction:column;justify-content:flex-start;align-items:stretch;gap:6px}.backlink-item,.outgoing-link-item{padding:10px 12px;background:#111118;border:1px solid rgba(255,255,255,.06);border-radius:3px;cursor:pointer;transition:background .1s,border-color .1s}.backlink-item:hover,.outgoing-link-item:hover{background:#18181f;border-color:rgba(255,255,255,.09)}.backlink-item.unresolved,.outgoing-link-item.unresolved{opacity:.7;border-style:dashed}.backlink-source,.outgoing-link-target{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:8px;margin-bottom:2px}.backlink-title,.outgoing-link-text{font-size:13px;font-weight:500;color:#dcdce4;flex:1;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.backlink-type-badge,.outgoing-link-type-badge{display:inline-block;padding:1px 6px;border-radius:12px;font-size:9px;font-weight:600;text-transform:uppercase;letter-spacing:.03em}.backlink-type-badge.backlink-type-wikilink,.backlink-type-badge.link-type-wikilink,.outgoing-link-type-badge.backlink-type-wikilink,.outgoing-link-type-badge.link-type-wikilink{background:rgba(124,126,245,.15);color:#9698f7}.backlink-type-badge.backlink-type-embed,.backlink-type-badge.link-type-embed,.outgoing-link-type-badge.backlink-type-embed,.outgoing-link-type-badge.link-type-embed{background:rgba(139,92,246,.1);color:#9d8be0}.backlink-type-badge.backlink-type-markdown_link,.backlink-type-badge.link-type-markdown_link,.outgoing-link-type-badge.backlink-type-markdown_link,.outgoing-link-type-badge.link-type-markdown_link{background:rgba(59,120,200,.1);color:#6ca0d4}.backlink-context{font-size:11px;color:#6c6c84;line-height:1.4;overflow:hidden;text-overflow:ellipsis;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical}.backlink-line{color:#a0a0b8;font-weight:500}.unresolved-badge{padding:1px 6px;border-radius:12px;font-size:9px;font-weight:600;background:rgba(212,160,55,.1);color:#d4a037}.outgoing-links-unresolved-badge{margin-left:8px;padding:2px 8px;border-radius:10px;font-size:10px;font-weight:500;background:rgba(212,160,55,.12);color:#d4a037}.outgoing-links-global-unresolved{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:6px;margin-top:12px;padding:10px 12px;background:rgba(212,160,55,.06);border:1px solid rgba(212,160,55,.15);border-radius:3px;font-size:11px;color:#6c6c84}.outgoing-links-global-unresolved .unresolved-icon{color:#d4a037}.backlinks-message{padding:8px 10px;margin-bottom:10px;border-radius:3px;font-size:11px}.backlinks-message.success{background:rgba(62,201,122,.08);border:1px solid rgba(62,201,122,.2);color:#3ec97a}.backlinks-message.error{background:rgba(228,88,88,.06);border:1px solid rgba(228,88,88,.2);color:#e45858}.graph-view{display:flex;flex-direction:column;justify-content:flex-start;align-items:stretch;height:100%;background:#18181f;border-radius:5px;overflow:hidden}.graph-toolbar{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:16px;padding:12px 16px;background:#1f1f28;border-bottom:1px solid rgba(255,255,255,.09)}.graph-title{font-size:14px;font-weight:600;color:#dcdce4}.graph-controls{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:8px;font-size:12px;color:#a0a0b8}.graph-controls select{padding:4px 20px 4px 8px;font-size:11px;background:#26263a}.graph-stats{margin-left:auto;font-size:11px;color:#6c6c84}.graph-container{flex:1;position:relative;display:flex;align-items:center;justify-content:center;overflow:hidden;background:#111118}.graph-loading,.graph-error,.graph-empty{display:flex;flex-direction:column;justify-content:center;align-items:center;gap:10px;padding:48px;color:#6c6c84;font-size:13px;text-align:center}.graph-svg{max-width:100%;max-height:100%;cursor:grab}.graph-svg-container{position:relative;width:100%;height:100%}.graph-zoom-controls{position:absolute;top:16px;left:16px;display:flex;flex-direction:column;justify-content:flex-start;align-items:stretch;gap:8px;z-index:5}.zoom-btn{width:36px;height:36px;border-radius:6px;background:#1f1f28;border:1px solid rgba(255,255,255,.09);color:#dcdce4;font-size:18px;font-weight:bold;display:flex;align-items:center;justify-content:center;cursor:pointer;transition:all .15s;box-shadow:0 1px 3px rgba(0,0,0,.3)}.zoom-btn:hover{background:#26263a;border-color:rgba(255,255,255,.14);transform:scale(1.05)}.zoom-btn:active{transform:scale(.95)}.graph-edges line{stroke:rgba(255,255,255,.14);stroke-width:1;opacity:.6}.graph-edges line.edge-type-wikilink{stroke:#7c7ef5}.graph-edges line.edge-type-embed{stroke:#9d8be0;stroke-dasharray:4 2}.graph-nodes .graph-node{cursor:pointer}.graph-nodes .graph-node circle{fill:#4caf50;stroke:#388e3c;stroke-width:2;transition:fill .15s,stroke .15s}.graph-nodes .graph-node:hover circle{fill:#66bb6a}.graph-nodes .graph-node.selected circle{fill:#7c7ef5;stroke:#5456d6}.graph-nodes .graph-node text{fill:#a0a0b8;font-size:11px;pointer-events:none;text-anchor:middle;dominant-baseline:central;transform:translateY(16px)}.node-details-panel{position:absolute;top:16px;right:16px;width:280px;background:#1f1f28;border:1px solid rgba(255,255,255,.09);border-radius:5px;box-shadow:0 2px 8px rgba(0,0,0,.35);z-index:10}.node-details-header{display:flex;justify-content:space-between;align-items:center;padding:10px 14px;border-bottom:1px solid rgba(255,255,255,.06)}.node-details-header h3{font-size:13px;font-weight:600;color:#dcdce4;margin:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.node-details-header .close-btn{background:none;border:none;color:#6c6c84;cursor:pointer;font-size:14px;padding:2px 6px;line-height:1}.node-details-header .close-btn:hover{color:#dcdce4}.node-details-content{padding:14px}.node-details-content .node-title{font-size:12px;color:#a0a0b8;margin-bottom:12px}.node-stats{display:flex;gap:16px;margin-bottom:12px}.node-stats .stat{font-size:12px;color:#6c6c84}.node-stats .stat strong{color:#dcdce4}.physics-controls-panel{position:absolute;top:16px;right:16px;width:300px;background:#1f1f28;border:1px solid rgba(255,255,255,.09);border-radius:5px;box-shadow:0 2px 8px rgba(0,0,0,.35);padding:16px;z-index:10}.physics-controls-panel h4{font-size:13px;font-weight:600;color:#dcdce4;margin:0 0 16px 0;padding-bottom:8px;border-bottom:1px solid rgba(255,255,255,.06)}.physics-controls-panel .btn{width:100%;margin-top:8px}.control-group{margin-bottom:14px}.control-group label{display:block;font-size:11px;font-weight:500;color:#a0a0b8;margin-bottom:6px;text-transform:uppercase;letter-spacing:.5px}.control-group input[type=range]{width:100%;height:4px;border-radius:4px;background:#26263a;outline:none;-webkit-appearance:none}.control-group input[type=range]::-webkit-slider-thumb{-webkit-appearance:none;appearance:none;width:14px;height:14px;border-radius:50%;background:#7c7ef5;cursor:pointer;transition:transform .1s}.control-group input[type=range]::-webkit-slider-thumb:hover{transform:scale(1.15)}.control-group input[type=range]::-moz-range-thumb{width:14px;height:14px;border-radius:50%;background:#7c7ef5;cursor:pointer;border:none;transition:transform .1s}.control-group input[type=range]::-moz-range-thumb:hover{transform:scale(1.15)}.control-value{display:inline-block;margin-top:2px;font-size:11px;color:#6c6c84;font-family:"JetBrains Mono","Fira Code",ui-monospace,monospace}.theme-light{--bg-0: #f5f5f7;--bg-1: #eeeef0;--bg-2: #fff;--bg-3: #e8e8ec;--border-subtle: rgba(0,0,0,.06);--border: rgba(0,0,0,.1);--border-strong: rgba(0,0,0,.16);--text-0: #1a1a2e;--text-1: #555570;--text-2: #8888a0;--accent: #6366f1;--accent-dim: rgba(99,102,241,.1);--accent-text: #4f52e8;--shadow-sm: 0 1px 3px rgba(0,0,0,.08);--shadow: 0 2px 8px rgba(0,0,0,.1);--shadow-lg: 0 4px 20px rgba(0,0,0,.12)}.theme-light ::-webkit-scrollbar-thumb{background:rgba(0,0,0,.12)}.theme-light ::-webkit-scrollbar-thumb:hover{background:rgba(0,0,0,.08)}.theme-light ::-webkit-scrollbar-track{background:rgba(0,0,0,.06)}.theme-light .graph-nodes .graph-node text{fill:#1a1a2e}.theme-light .graph-edges line{stroke:rgba(0,0,0,.12)}.theme-light .pdf-container{background:#e8e8ec}.skeleton-pulse{animation:skeleton-pulse 1.5s ease-in-out infinite;background:#26263a;border-radius:4px}.skeleton-card{display:flex;flex-direction:column;justify-content:flex-start;align-items:stretch;gap:8px;padding:8px}.skeleton-thumb{width:100%;aspect-ratio:1;border-radius:6px}.skeleton-text{height:14px;width:80%}.skeleton-text-short{width:50%}.skeleton-row{display:flex;gap:12px;padding:10px 16px;align-items:center}.skeleton-cell{height:14px;flex:1;border-radius:4px}.skeleton-cell-icon{width:32px;height:32px;flex:none;border-radius:4px}.skeleton-cell-wide{flex:3}.loading-overlay{position:absolute;inset:0;display:flex;flex-direction:column;justify-content:center;align-items:center;gap:10px;background:rgba(0,0,0,.3);z-index:100;border-radius:8px}.loading-spinner{width:32px;height:32px;border:3px solid rgba(255,255,255,.09);border-top-color:#7c7ef5;border-radius:50%;animation:spin .8s linear infinite}.loading-message{color:#a0a0b8;font-size:.9rem}.login-container{display:flex;align-items:center;justify-content:center;height:100vh;background:#111118}.login-card{background:#1f1f28;border:1px solid rgba(255,255,255,.09);border-radius:7px;padding:24px;width:360px;box-shadow:0 4px 20px rgba(0,0,0,.45)}.login-title{font-size:20px;font-weight:700;color:#dcdce4;text-align:center;margin-bottom:2px}.login-subtitle{font-size:13px;color:#6c6c84;text-align:center;margin-bottom:20px}.login-error{background:rgba(228,88,88,.08);border:1px solid rgba(228,88,88,.2);border-radius:3px;padding:8px 12px;margin-bottom:12px;font-size:12px;color:#e45858}.login-form input[type=text],.login-form input[type=password]{width:100%}.login-btn{width:100%;padding:8px 16px;font-size:13px;margin-top:2px}.pagination{display:flex;flex-direction:row;justify-content:center;align-items:center;gap:2px;margin-top:16px;padding:8px 0}.page-btn{min-width:28px;text-align:center;font-variant-numeric:tabular-nums}.page-ellipsis{color:#6c6c84;padding:0 4px;font-size:12px;user-select:none}.help-overlay{position:fixed;inset:0;background:rgba(0,0,0,.5);display:flex;align-items:center;justify-content:center;z-index:200;animation:fade-in .1s ease-out}.help-dialog{background:#1f1f28;border:1px solid rgba(255,255,255,.09);border-radius:7px;padding:16px;min-width:300px;max-width:400px;box-shadow:0 4px 20px rgba(0,0,0,.45)}.help-dialog h3{font-size:16px;font-weight:600;margin-bottom:16px}.help-shortcuts{display:flex;flex-direction:column;justify-content:flex-start;align-items:stretch;gap:8px;margin-bottom:16px}.shortcut-row{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:12px}.shortcut-row kbd{display:inline-block;padding:2px 8px;background:#111118;border:1px solid rgba(255,255,255,.09);border-radius:3px;font-family:"JetBrains Mono","Fira Code",ui-monospace,monospace;font-size:11px;color:#dcdce4;min-width:32px;text-align:center}.shortcut-row span{font-size:13px;color:#a0a0b8}.help-close{display:block;width:100%;padding:6px 12px;background:#26263a;border:1px solid rgba(255,255,255,.09);border-radius:3px;color:#dcdce4;font-size:12px;cursor:pointer;text-align:center}.help-close:hover{background:rgba(255,255,255,.06)}.plugin-container{display:flex;flex-direction:column;gap:var(--plugin-gap, 0px);padding:var(--plugin-padding, 0)}.plugin-grid{display:grid;grid-template-columns:repeat(var(--plugin-columns, 1), 1fr);gap:var(--plugin-gap, 0px)}.plugin-flex{display:flex;gap:var(--plugin-gap, 0px)}.plugin-flex[data-direction=row]{flex-direction:row}.plugin-flex[data-direction=column]{flex-direction:column}.plugin-flex[data-justify=flex-start]{justify-content:flex-start}.plugin-flex[data-justify=flex-end]{justify-content:flex-end}.plugin-flex[data-justify=center]{justify-content:center}.plugin-flex[data-justify=space-between]{justify-content:space-between}.plugin-flex[data-justify=space-around]{justify-content:space-around}.plugin-flex[data-justify=space-evenly]{justify-content:space-evenly}.plugin-flex[data-align=flex-start]{align-items:flex-start}.plugin-flex[data-align=flex-end]{align-items:flex-end}.plugin-flex[data-align=center]{align-items:center}.plugin-flex[data-align=stretch]{align-items:stretch}.plugin-flex[data-align=baseline]{align-items:baseline}.plugin-flex[data-wrap=wrap]{flex-wrap:wrap}.plugin-flex[data-wrap=nowrap]{flex-wrap:nowrap}.plugin-split{display:flex}.plugin-split-sidebar{width:var(--plugin-sidebar-width, 200px);flex-shrink:0}.plugin-split-main{flex:1;min-width:0}.plugin-media-grid{display:grid;grid-template-columns:repeat(var(--plugin-columns, 2), 1fr);gap:var(--plugin-gap, 8px)}.plugin-col-constrained{width:var(--plugin-col-width)}.plugin-progress-bar{height:100%;background:#7c7ef5;border-radius:4px;transition:width .3s ease;width:var(--plugin-progress, 0%)}.plugin-chart{overflow:auto;height:var(--plugin-chart-height, 200px)} \ No newline at end of file diff --git a/crates/pinakes-ui/assets/styles/_plugins.scss b/crates/pinakes-ui/assets/styles/_plugins.scss new file mode 100644 index 0000000..c44762a --- /dev/null +++ b/crates/pinakes-ui/assets/styles/_plugins.scss @@ -0,0 +1,94 @@ +@use 'variables' as *; +@use 'mixins' as *; + +// Plugin UI renderer layout classes. +// +// Dynamic values are passed via CSS custom properties set on the element. +// The layout rules here consume those properties via var() so the renderer +// never injects full CSS rule strings. + +// Container: vertical flex column with configurable gap and padding. +.plugin-container { + display: flex; + flex-direction: column; + gap: var(--plugin-gap, 0px); + padding: var(--plugin-padding, 0); +} + +// Grid: CSS grid with a configurable column count and gap. +.plugin-grid { + display: grid; + grid-template-columns: repeat(var(--plugin-columns, 1), 1fr); + gap: var(--plugin-gap, 0px); +} + +// Flex: display:flex driven by data-* attribute selectors. +// The gap is a CSS custom property; direction/justify/align/wrap are +// plain enum strings placed in data attributes by the renderer. +.plugin-flex { + display: flex; + gap: var(--plugin-gap, 0px); + + &[data-direction='row'] { flex-direction: row; } + &[data-direction='column'] { flex-direction: column; } + + &[data-justify='flex-start'] { justify-content: flex-start; } + &[data-justify='flex-end'] { justify-content: flex-end; } + &[data-justify='center'] { justify-content: center; } + &[data-justify='space-between'] { justify-content: space-between; } + &[data-justify='space-around'] { justify-content: space-around; } + &[data-justify='space-evenly'] { justify-content: space-evenly; } + + &[data-align='flex-start'] { align-items: flex-start; } + &[data-align='flex-end'] { align-items: flex-end; } + &[data-align='center'] { align-items: center; } + &[data-align='stretch'] { align-items: stretch; } + &[data-align='baseline'] { align-items: baseline; } + + &[data-wrap='wrap'] { flex-wrap: wrap; } + &[data-wrap='nowrap'] { flex-wrap: nowrap; } +} + +// Split: side-by-side sidebar + main area. +.plugin-split { + display: flex; +} + +// Sidebar width is driven by --plugin-sidebar-width. +.plugin-split-sidebar { + width: var(--plugin-sidebar-width, 200px); + flex-shrink: 0; +} + +.plugin-split-main { + flex: 1; + min-width: 0; +} + +// Media grid reuses the same column/gap variables as .plugin-grid. +.plugin-media-grid { + display: grid; + grid-template-columns: repeat(var(--plugin-columns, 2), 1fr); + gap: var(--plugin-gap, 8px); +} + +// Table column with a plugin-specified fixed width. +// The width is passed as --plugin-col-width on the th element. +.plugin-col-constrained { + width: var(--plugin-col-width); +} + +// Progress bar: the fill element carries --plugin-progress. +.plugin-progress-bar { + height: 100%; + background: $accent; + border-radius: 4px; + transition: width 0.3s ease; + width: var(--plugin-progress, 0%); +} + +// Chart wrapper: height is driven by --plugin-chart-height. +.plugin-chart { + overflow: auto; + height: var(--plugin-chart-height, 200px); +} diff --git a/crates/pinakes-ui/assets/styles/main.scss b/crates/pinakes-ui/assets/styles/main.scss index 930c7f5..7f93cf2 100644 --- a/crates/pinakes-ui/assets/styles/main.scss +++ b/crates/pinakes-ui/assets/styles/main.scss @@ -11,3 +11,4 @@ @use 'audit'; @use 'graph'; @use 'themes'; +@use 'plugins'; diff --git a/crates/pinakes-ui/src/app.rs b/crates/pinakes-ui/src/app.rs index 972da40..11026b9 100644 --- a/crates/pinakes-ui/src/app.rs +++ b/crates/pinakes-ui/src/app.rs @@ -59,7 +59,12 @@ use crate::{ tags, tasks, }, - plugin_ui::{PluginRegistry, PluginViewRenderer}, + plugin_ui::{ + PluginRegistry, + PluginViewRenderer, + WidgetContainer, + WidgetLocation, + }, styles, }; @@ -109,6 +114,41 @@ impl View { } } +/// Parse a route string from a plugin `navigate_to` action into an app +/// [`View`]. +/// +/// Supports all built-in routes (`/library`, `/search`, etc.) and plugin pages +/// (`/plugins/{plugin_id}/{page_id}`). Unknown routes fall back to Library with +/// a warning log. +fn parse_plugin_route(route: &str) -> View { + let parts: Vec<&str> = route.trim_start_matches('/').split('/').collect(); + match parts.as_slice() { + [] | [""] | ["library"] => View::Library, + ["search"] => View::Search, + ["settings"] => View::Settings, + ["tags"] => View::Tags, + ["collections"] => View::Collections, + ["books"] => View::Books, + ["audit"] => View::Audit, + ["import"] => View::Import, + ["duplicates"] => View::Duplicates, + ["statistics"] => View::Statistics, + ["tasks"] => View::Tasks, + ["database"] => View::Database, + ["graph"] => View::Graph, + ["plugins", plugin_id, page_id] => { + View::PluginView { + plugin_id: plugin_id.to_string(), + page_id: page_id.to_string(), + } + }, + _ => { + tracing::warn!(route = %route, "Unknown navigation route from plugin action"); + View::Library + }, + } +} + #[component] pub fn App() -> Element { let base_url = std::env::var("PINAKES_SERVER_URL") @@ -193,6 +233,7 @@ pub fn App() -> Element { let mut play_queue = use_signal(PlayQueue::default); let mut plugin_registry = use_signal(PluginRegistry::default); + let all_widgets = use_memo(move || plugin_registry.read().all_widgets()); let mut current_theme = use_signal(|| "dark".to_string()); let mut system_prefers_dark = use_signal(|| true); @@ -319,15 +360,27 @@ pub fn App() -> Element { use_effect(move || { let c = client.read().clone(); spawn(async move { - match c.get_plugin_ui_pages().await { - Ok(pages) => { - let mut reg = PluginRegistry::default(); - for (plugin_id, page) in pages { - reg.register_page(plugin_id, page); - } + let mut reg = PluginRegistry::new(c.clone()); + match reg.refresh().await { + Ok(()) => { + let vars = reg.theme_vars().clone(); plugin_registry.set(reg); + if !vars.is_empty() { + spawn(async move { + let js: String = vars + .iter() + .map(|(k, v)| { + format!( + "document.documentElement.style.setProperty('{}','{}');", + k, v + ) + }) + .collect(); + let _ = document::eval(&js).await; + }); + } }, - Err(e) => tracing::debug!("Plugin pages unavailable: {e}"), + Err(e) => tracing::debug!("Plugin UI unavailable: {e}"), } }); }); @@ -431,7 +484,19 @@ pub fn App() -> Element { } }; - let view_title = use_memo(move || current_view.read().title()); + let view_title = use_memo(move || { + let view = current_view.read(); + match &*view { + View::PluginView { plugin_id, page_id } => { + plugin_registry + .read() + .get_page(plugin_id, page_id) + .map(|p| p.page.title.clone()) + .unwrap_or_else(|| "Plugin".to_string()) + }, + v => v.title().to_string(), + } + }); let _total_pages = use_memo(move || { let ps = *media_page_size.read(); let tc = *media_total_count.read(); @@ -753,12 +818,17 @@ pub fn App() -> Element { if !plugin_registry.read().is_empty() { div { class: "nav-section", - div { class: "nav-label", "Plugins" } - for page in plugin_registry.read().all_pages() { + div { class: "nav-label", + "Plugins" + span { class: "nav-label-count", " ({plugin_registry.read().len()})" } + } + for (pid, pageid, route) in plugin_registry.read().routes() { { - let pid = page.plugin_id.clone(); - let pageid = page.page.id.clone(); - let title = page.page.title.clone(); + let title = plugin_registry + .read() + .get_page(&pid, &pageid) + .map(|p| p.page.title.clone()) + .unwrap_or_default(); let is_active = *current_view.read() == View::PluginView { plugin_id: pid.clone(), @@ -767,6 +837,7 @@ pub fn App() -> Element { rsx! { button { class: if is_active { "nav-item active" } else { "nav-item" }, + title: "{route}", onclick: move |_| { current_view.set(View::PluginView { plugin_id: pid.clone(), @@ -778,6 +849,17 @@ 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}" } + } + } + } } } @@ -923,6 +1005,11 @@ pub fn App() -> Element { // Reload full config match *current_view.read() { View::Library => rsx! { + WidgetContainer { + location: WidgetLocation::LibraryHeader, + widgets: all_widgets.read().clone(), + client, + } div { class: "stats-grid", div { class: "stat-card", div { class: "stat-value", "{media_total_count}" } @@ -937,6 +1024,11 @@ pub fn App() -> Element { div { class: "stat-label", "Collections" } } } + WidgetContainer { + location: WidgetLocation::LibrarySidebar, + widgets: all_widgets.read().clone(), + client, + } library::Library { media: media_list.read().clone(), tags: tags_list.read().clone(), @@ -1133,6 +1225,11 @@ pub fn App() -> Element { } }, View::Search => rsx! { + WidgetContainer { + location: WidgetLocation::SearchFilters, + widgets: all_widgets.read().clone(), + client, + } search::Search { results: search_results.read().clone(), total_count: *search_total.read(), @@ -1265,6 +1362,11 @@ pub fn App() -> Element { let media_ref = selected_media.read(); match media_ref.as_ref() { Some(media) => rsx! { + WidgetContainer { + location: WidgetLocation::DetailPanel, + widgets: all_widgets.read().clone(), + client, + } detail::Detail { media: media.clone(), media_tags: media_tags.read().clone(), @@ -2558,6 +2660,11 @@ pub fn App() -> Element { let cfg_ref = config_data.read(); match cfg_ref.as_ref() { Some(cfg) => rsx! { + WidgetContainer { + location: WidgetLocation::SettingsSection, + widgets: all_widgets.read().clone(), + client, + } settings::Settings { config: cfg.clone(), on_add_root: { @@ -2712,6 +2819,11 @@ pub fn App() -> Element { plugin_id: pid, page: plugin_page.page, client, + allowed_endpoints: plugin_page.allowed_endpoints.clone(), + on_navigate: move |route: String| { + current_view + .set(parse_plugin_route(&route)); + }, } }, None => rsx! { diff --git a/crates/pinakes-ui/src/client.rs b/crates/pinakes-ui/src/client.rs index 3aaaf10..e411750 100644 --- a/crates/pinakes-ui/src/client.rs +++ b/crates/pinakes-ui/src/client.rs @@ -1617,14 +1617,16 @@ impl ApiClient { /// List all UI pages provided by loaded plugins. /// - /// Returns a vector of `(plugin_id, page)` tuples. + /// Returns a vector of `(plugin_id, page, allowed_endpoints)` tuples. pub async fn get_plugin_ui_pages( &self, - ) -> Result> { + ) -> Result)>> { #[derive(Deserialize)] struct PageEntry { - plugin_id: String, - page: pinakes_plugin_api::UiPage, + plugin_id: String, + page: pinakes_plugin_api::UiPage, + #[serde(default)] + allowed_endpoints: Vec, } let entries: Vec = self @@ -1636,7 +1638,80 @@ impl ApiClient { .json() .await?; - Ok(entries.into_iter().map(|e| (e.plugin_id, e.page)).collect()) + Ok( + entries + .into_iter() + .map(|e| (e.plugin_id, e.page, e.allowed_endpoints)) + .collect(), + ) + } + + /// List all UI widgets provided by loaded plugins. + /// + /// Returns a vector of `(plugin_id, widget)` tuples. + pub async fn get_plugin_ui_widgets( + &self, + ) -> Result> { + #[derive(Deserialize)] + struct WidgetEntry { + plugin_id: String, + widget: pinakes_plugin_api::UiWidget, + } + + let entries: Vec = self + .client + .get(self.url("/plugins/ui-widgets")) + .send() + .await? + .error_for_status()? + .json() + .await?; + + Ok( + entries + .into_iter() + .map(|e| (e.plugin_id, e.widget)) + .collect(), + ) + } + + /// Fetch merged CSS custom property overrides from all enabled plugins. + /// + /// Returns a map of CSS property names to values. + pub async fn get_plugin_ui_theme_extensions( + &self, + ) -> Result> { + Ok( + self + .client + .get(self.url("/plugins/ui-theme-extensions")) + .send() + .await? + .error_for_status()? + .json() + .await?, + ) + } + + /// Emit a plugin event to the server-side event bus. + /// + /// # Errors + /// + /// Returns an error if the request fails or the server returns an error + /// status. + pub async fn post_plugin_event( + &self, + event: &str, + payload: &serde_json::Value, + ) -> Result<()> { + self + .client + .post(self.url("/plugins/events")) + .json(&serde_json::json!({ "event": event, "payload": payload })) + .send() + .await? + .error_for_status()?; + Ok(()) } /// Make a raw HTTP request to an API path. diff --git a/crates/pinakes-ui/src/plugin_ui/data.rs b/crates/pinakes-ui/src/plugin_ui/data.rs index 2f4e2d0..d3f42dc 100644 --- a/crates/pinakes-ui/src/plugin_ui/data.rs +++ b/crates/pinakes-ui/src/plugin_ui/data.rs @@ -2,15 +2,17 @@ //! //! Provides data fetching and caching for plugin data sources. -use std::collections::HashMap; +use std::{collections::HashMap, time::Duration}; use dioxus::prelude::*; -use pinakes_plugin_api::{DataSource, HttpMethod}; +use dioxus_core::Task; +use pinakes_plugin_api::{DataSource, Expression, HttpMethod}; +use super::expr::{evaluate_expression, value_to_display_string}; use crate::client::ApiClient; /// Cached data for a plugin page -#[derive(Debug, Clone, Default, PartialEq)] +#[derive(Debug, Clone, Default, PartialEq, Eq)] pub struct PluginPageData { data: HashMap, loading: HashMap, @@ -36,7 +38,7 @@ impl PluginPageData { self.errors.get(source) } - /// Check if there's data for a specific source + /// Check if there is data for a specific source #[must_use] pub fn has_data(&self, source: &str) -> bool { self.data.contains_key(source) @@ -83,23 +85,62 @@ impl PluginPageData { } } -/// Fetch data from an endpoint -async fn fetch_endpoint( - client: &ApiClient, - path: &str, - method: HttpMethod, -) -> Result { - let reqwest_method = match method { +/// Convert a plugin `HttpMethod` to a `reqwest::Method`. +pub(super) const fn to_reqwest_method(method: &HttpMethod) -> reqwest::Method { + match method { HttpMethod::Get => reqwest::Method::GET, HttpMethod::Post => reqwest::Method::POST, HttpMethod::Put => reqwest::Method::PUT, HttpMethod::Patch => reqwest::Method::PATCH, HttpMethod::Delete => reqwest::Method::DELETE, - }; + } +} + +/// Fetch data from an endpoint, evaluating any params expressions against +/// the given context. +async fn fetch_endpoint( + client: &ApiClient, + path: &str, + method: HttpMethod, + params: &HashMap, + ctx: &serde_json::Value, + allowed_endpoints: &[String], +) -> Result { + if !allowed_endpoints.is_empty() + && !allowed_endpoints.iter().any(|ep| path == ep.as_str()) + { + return Err(format!( + "Endpoint '{path}' is not in plugin's declared required_endpoints" + )); + } + + let reqwest_method = to_reqwest_method(&method); + + let mut request = client.raw_request(reqwest_method.clone(), path); + + if !params.is_empty() { + if reqwest_method == reqwest::Method::GET { + // Evaluate each param expression and add as query string + let query_pairs: Vec<(String, String)> = params + .iter() + .map(|(k, expr)| { + let v = evaluate_expression(expr, ctx); + (k.clone(), value_to_display_string(&v)) + }) + .collect(); + request = request.query(&query_pairs); + } else { + // Evaluate params and send as JSON body + let body: serde_json::Map = params + .iter() + .map(|(k, expr)| (k.clone(), evaluate_expression(expr, ctx))) + .collect(); + request = request.json(&body); + } + } // Send request and parse response - let response = client - .raw_request(reqwest_method, path) + let response = request .send() .await .map_err(|e| format!("Request failed: {e}"))?; @@ -118,50 +159,159 @@ async fn fetch_endpoint( /// Fetch all data sources for a page /// +/// Endpoint sources are deduplicated by `(path, method, params)`: if multiple +/// 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. +/// All unique Endpoint and Static sources are fetched concurrently. Transform +/// sources are applied after, in iteration order, against the full result set. +/// /// # Errors /// /// Returns an error if any data source fails to fetch pub async fn fetch_page_data( client: &ApiClient, data_sources: &HashMap, + allowed_endpoints: &[String], ) -> Result, String> { - let mut results = HashMap::new(); + // Group non-Transform sources into dedup groups. + // + // For Endpoint sources, two entries are in the same group when they share + // the same (path, method, params) - i.e., they would produce an identical + // HTTP request. The per-source `transform` expression is kept separate so + // each name can apply its own transform to the shared raw response. + // + // Static sources never share an HTTP request so each becomes its own group. + // + // Each group is: (names_and_transforms, representative_source) + // where names_and_transforms is Vec<(name, Option)> for Endpoint, + // or Vec<(name, ())> for Static (transform is baked in). + struct Group { + // (source name, per-name transform expression for Endpoint sources) + members: Vec<(String, Option)>, + // The representative source used to fire the request (transform ignored + // for Endpoint - we apply per-member transforms after fetching) + source: DataSource, + } - // Process non-Transform sources first so Transform sources can reference them - let mut ordered: Vec<(&String, &DataSource)> = data_sources - .iter() - .filter(|(_, s)| !matches!(s, DataSource::Transform { .. })) - .collect(); - ordered.extend( - data_sources - .iter() - .filter(|(_, s)| matches!(s, DataSource::Transform { .. })), - ); + let mut groups: Vec = Vec::new(); - for (name, source) in ordered { - let value = match source { - DataSource::Endpoint { path, method, .. } => { - // Fetch from endpoint (ignoring params, poll_interval, transform for - // now) - fetch_endpoint(client, path, method.clone()).await? - }, - DataSource::Static { value } => value.clone(), - DataSource::Transform { - source_name, - expression, + for (name, source) in data_sources { + if matches!(source, DataSource::Transform { .. }) { + continue; + } + + match source { + DataSource::Endpoint { + path, + method, + params, + transform, + poll_interval, } => { - // Get source data and apply transform - let source_data = results - .get(source_name) - .cloned() - .unwrap_or(serde_json::Value::Null); - // TODO: Actually evaluate expression against source_data - // For now, return source_data unchanged - let _ = expression; - source_data + // Find an existing group with the same (path, method, params). + let existing = groups.iter_mut().find(|g| { + matches!( + &g.source, + DataSource::Endpoint { + path: ep, + method: em, + params: epa, + .. + } if ep == path && em == method && epa == params + ) + }); + + if let Some(group) = existing { + group.members.push((name.clone(), transform.clone())); + } else { + groups.push(Group { + members: vec![(name.clone(), transform.clone())], + source: DataSource::Endpoint { + path: path.clone(), + method: method.clone(), + params: params.clone(), + poll_interval: *poll_interval, + transform: None, + }, + }); + } }, - }; - results.insert(name.clone(), value); + DataSource::Static { .. } => { + // Static sources are trivially unique per name; no dedup needed. + groups.push(Group { + members: vec![(name.clone(), None)], + source: source.clone(), + }); + }, + DataSource::Transform { .. } => unreachable!(), + } + } + + // Fire one future per group concurrently. + let futs: Vec<_> = groups + .into_iter() + .map(|group| { + let client = client.clone(); + let allowed = allowed_endpoints.to_vec(); + async move { + // Fetch the raw value for this group. + let raw = match &group.source { + DataSource::Endpoint { + path, + method, + params, + .. + } => { + let empty_ctx = serde_json::json!({}); + fetch_endpoint(&client, path, method.clone(), params, &empty_ctx, &allowed) + .await? + }, + DataSource::Static { value } => value.clone(), + DataSource::Transform { .. } => unreachable!(), + }; + + // Apply per-member transforms and collect (name, value) pairs. + let pairs: Vec<(String, serde_json::Value)> = group + .members + .into_iter() + .map(|(name, transform)| { + let value = if let Some(expr) = &transform { + evaluate_expression(expr, &raw) + } else { + raw.clone() + }; + (name, value) + }) + .collect(); + + Ok::<_, String>(pairs) + } + }) + .collect(); + + let mut results: HashMap = HashMap::new(); + for group_result in futures::future::join_all(futs).await { + for (name, value) in group_result? { + results.insert(name, value); + } + } + + // Process Transform sources sequentially; they reference results above. + for (name, source) in data_sources { + if let DataSource::Transform { + source_name, + expression, + } = source + { + let ctx = serde_json::Value::Object( + results + .iter() + .map(|(k, v): (&String, &serde_json::Value)| (k.clone(), v.clone())) + .collect(), + ); + let _ = source_name; // accessible in ctx by its key + results.insert(name.clone(), evaluate_expression(expression, &ctx)); + } } Ok(results) @@ -169,17 +319,50 @@ pub async fn fetch_page_data( /// Hook to fetch and cache plugin page data /// -/// Returns a signal containing the data state +/// Returns a signal containing the data state. If any data source has a +/// non-zero `poll_interval`, a background loop re-fetches automatically at the +/// minimum interval. The `refresh` counter can be incremented to trigger an +/// immediate re-fetch outside of the polling interval. pub fn use_plugin_data( client: Signal, data_sources: HashMap, + refresh: Signal, + allowed_endpoints: Vec, ) -> Signal { let mut data = use_signal(PluginPageData::default); + let mut poll_task: Signal> = use_signal(|| None); use_effect(move || { + // Subscribe to the refresh counter; incrementing it triggers a re-run. + let _rev = refresh.read(); let sources = data_sources.clone(); + let allowed = allowed_endpoints.clone(); - spawn(async move { + // Cancel the previous polling task before spawning a new one. Use + // write() rather than read() so the effect does not subscribe to + // poll_task and trigger an infinite re-run loop. + if let Some(t) = poll_task.write().take() { + t.cancel(); + } + + // Determine minimum poll interval (0 = no polling) + let min_poll_secs: u64 = sources + .values() + .filter_map(|s| { + if let DataSource::Endpoint { poll_interval, .. } = s { + if *poll_interval > 0 { + Some(*poll_interval) + } else { + None + } + } else { + None + } + }) + .min() + .unwrap_or(0); + + let handle = spawn(async move { // Clear previous data data.write().clear(); @@ -188,8 +371,9 @@ pub fn use_plugin_data( data.write().set_loading(name, true); } - // Fetch data - match fetch_page_data(&client.read(), &sources).await { + // Initial fetch; clone to release the signal read borrow before await. + let cl = client.peek().clone(); + match fetch_page_data(&cl, &sources, &allowed).await { Ok(results) => { for (name, value) in results { data.write().set_loading(&name, false); @@ -203,38 +387,39 @@ pub fn use_plugin_data( } }, } + + // Polling loop; only runs if at least one source has poll_interval > 0 + if min_poll_secs > 0 { + loop { + tokio::time::sleep(Duration::from_secs(min_poll_secs)).await; + + let cl = client.peek().clone(); + match fetch_page_data(&cl, &sources, &allowed).await { + Ok(results) => { + for (name, value) in results { + // Only write if data is new or has changed to avoid spurious + // signal updates that would force a re-render + let changed = !data.read().has_data(&name) + || data.read().get(&name) != Some(&value); + if changed { + data.write().set_data(name, value); + } + } + }, + Err(e) => { + tracing::warn!("Poll fetch failed: {e}"); + }, + } + } + } }); + + *poll_task.write() = Some(handle); }); data } -/// Get a value from JSON by path (dot notation) -/// -/// Supports object keys and array indices -#[must_use] -pub fn get_json_path<'a>( - value: &'a serde_json::Value, - path: &str, -) -> Option<&'a serde_json::Value> { - let mut current = value; - - for key in path.split('.') { - match current { - serde_json::Value::Object(map) => { - current = map.get(key)?; - }, - serde_json::Value::Array(arr) => { - let idx = key.parse::().ok()?; - current = arr.get(idx)?; - }, - _ => return None, - } - } - - Some(current) -} - #[cfg(test)] mod tests { use super::*; @@ -264,51 +449,6 @@ mod tests { assert_eq!(data.error("error"), Some(&"oops".to_string())); } - #[test] - fn test_get_json_path_object() { - let data = serde_json::json!({ - "user": { - "name": "John", - "age": 30 - } - }); - - assert_eq!( - get_json_path(&data, "user.name"), - Some(&serde_json::Value::String("John".to_string())) - ); - } - - #[test] - fn test_get_json_path_array() { - let data = serde_json::json!({ - "items": ["a", "b", "c"] - }); - - assert_eq!( - get_json_path(&data, "items.1"), - Some(&serde_json::Value::String("b".to_string())) - ); - } - - #[test] - fn test_get_json_path_invalid() { - let data = serde_json::json!({"foo": "bar"}); - assert!(get_json_path(&data, "nonexistent").is_none()); - } - - #[test] - fn test_get_json_path_array_out_of_bounds() { - let data = serde_json::json!({"items": ["a"]}); - assert!(get_json_path(&data, "items.5").is_none()); - } - - #[test] - fn test_get_json_path_non_array_index() { - let data = serde_json::json!({"foo": "bar"}); - assert!(get_json_path(&data, "foo.0").is_none()); - } - #[test] fn test_as_json_empty() { let data = PluginPageData::default(); @@ -382,32 +522,195 @@ mod tests { 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["flag"], serde_json::json!(true)); } #[tokio::test] - async fn test_fetch_page_data_transform_after_static() { + async fn test_fetch_page_data_transform_evaluates_expression() { use pinakes_plugin_api::{DataSource, Expression}; use crate::client::ApiClient; let client = ApiClient::default(); let mut sources = HashMap::new(); - // Insert Transform before Static in the map to test ordering + // The Transform expression accesses "raw" from the context sources.insert("derived".to_string(), DataSource::Transform { source_name: "raw".to_string(), - expression: Expression::Literal(serde_json::Value::Null), + expression: Expression::Path("raw".to_string()), }); sources.insert("raw".to_string(), DataSource::Static { value: serde_json::json!({"ok": true}), }); - let results = super::fetch_page_data(&client, &sources).await.unwrap(); - // raw must have been processed before derived + let results = super::fetch_page_data(&client, &sources, &[]).await.unwrap(); assert_eq!(results["raw"], serde_json::json!({"ok": true})); - // derived gets source_data from raw (transform is identity for now) + // derived should return the value of "raw" from context assert_eq!(results["derived"], serde_json::json!({"ok": true})); } + + #[tokio::test] + async fn test_fetch_page_data_transform_literal_expression() { + use pinakes_plugin_api::{DataSource, Expression}; + + use crate::client::ApiClient; + + let client = ApiClient::default(); + let mut sources = HashMap::new(); + sources.insert("raw".to_string(), DataSource::Static { + value: serde_json::json!(42), + }); + sources.insert("derived".to_string(), DataSource::Transform { + source_name: "raw".to_string(), + expression: Expression::Literal(serde_json::json!("constant")), + }); + + let results = super::fetch_page_data(&client, &sources, &[]).await.unwrap(); + // A Literal expression returns the literal value, not the source data + 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] + async fn test_fetch_page_data_deduplicates_identical_endpoints() { + use pinakes_plugin_api::DataSource; + + use crate::client::ApiClient; + + let client = ApiClient::default(); + let mut sources = HashMap::new(); + // Two Static sources with the same payload; dedup is for Endpoint sources, + // but both names must appear in the output regardless. + sources.insert("a".to_string(), DataSource::Static { + value: serde_json::json!(1), + }); + sources.insert("b".to_string(), DataSource::Static { + value: serde_json::json!(1), + }); + let results = super::fetch_page_data(&client, &sources, &[]).await.unwrap(); + assert_eq!(results["a"], serde_json::json!(1)); + assert_eq!(results["b"], serde_json::json!(1)); + assert_eq!(results.len(), 2); + } + + // Test: Endpoint sources with identical (path, method, params) but different + // transform expressions each get a correctly transformed result. Because the + // test runs without a real server the path is checked against the allowlist + // before any network call, so we verify the dedup key grouping through the + // 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] + async fn test_dedup_groups_endpoint_sources_with_same_key() { + use pinakes_plugin_api::{DataSource, Expression, HttpMethod}; + + use crate::client::ApiClient; + + let client = ApiClient::default(); + let mut sources = HashMap::new(); + // Two endpoints with identical (path, method, params=empty) but different + // transforms. Both should produce the same error when the path is blocked. + sources.insert("x".to_string(), DataSource::Endpoint { + path: "/api/v1/media".to_string(), + method: HttpMethod::Get, + params: Default::default(), + poll_interval: 0, + transform: Some(Expression::Literal(serde_json::json!("from_x"))), + }); + sources.insert("y".to_string(), DataSource::Endpoint { + path: "/api/v1/media".to_string(), + method: HttpMethod::Get, + params: Default::default(), + poll_interval: 0, + transform: Some(Expression::Literal(serde_json::json!("from_y"))), + }); + + // Both sources point to the same blocked endpoint; expect an error. + let allowed = vec!["/api/v1/tags".to_string()]; + let result = super::fetch_page_data(&client, &sources, &allowed).await; + assert!( + result.is_err(), + "fetch_page_data must return Err for blocked deduplicated endpoints" + ); + let msg = result.unwrap_err(); + assert!( + msg.contains("not in plugin's declared required_endpoints"), + "unexpected error: {msg}" + ); + } + + // Test: multiple Transform sources referencing the same upstream Static source + // with different expressions each receive their independently transformed + // result. This exercises the transform fan-out behavior that mirrors what + // 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 + // requires a mock HTTP server and belongs in an integration test. + #[tokio::test] + async fn test_dedup_transform_applied_per_source() { + use pinakes_plugin_api::{DataSource, Expression}; + + use crate::client::ApiClient; + + let client = ApiClient::default(); + let mut sources = HashMap::new(); + sources.insert("raw_data".to_string(), DataSource::Static { + value: serde_json::json!({"count": 42, "name": "test"}), + }); + // Two Transform sources referencing "raw_data" with different expressions; + // each must produce its own independently derived value. + sources.insert("derived_count".to_string(), DataSource::Transform { + source_name: "raw_data".to_string(), + expression: Expression::Path("raw_data.count".to_string()), + }); + sources.insert("derived_name".to_string(), DataSource::Transform { + source_name: "raw_data".to_string(), + expression: Expression::Path("raw_data.name".to_string()), + }); + + let results = + super::fetch_page_data(&client, &sources, &[]).await.unwrap(); + assert_eq!( + results["raw_data"], + serde_json::json!({"count": 42, "name": "test"}) + ); + assert_eq!(results["derived_count"], serde_json::json!(42)); + assert_eq!(results["derived_name"], serde_json::json!("test")); + 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] + async fn test_endpoint_blocked_when_not_in_allowlist() { + use pinakes_plugin_api::{DataSource, HttpMethod}; + + use crate::client::ApiClient; + + let client = ApiClient::default(); + let mut sources = HashMap::new(); + sources.insert("items".to_string(), DataSource::Endpoint { + path: "/api/v1/media".to_string(), + method: HttpMethod::Get, + params: Default::default(), + poll_interval: 0, + transform: None, + }); + + // Provide a non-empty allowlist that does NOT include the endpoint path. + let allowed = vec!["/api/v1/tags".to_string()]; + let result = super::fetch_page_data(&client, &sources, &allowed).await; + + assert!( + result.is_err(), + "fetch_page_data must return Err when endpoint is not in allowed_endpoints" + ); + let msg = result.unwrap_err(); + assert!( + msg.contains("not in plugin's declared required_endpoints"), + "error must explain that the endpoint is not declared, got: {msg}" + ); + } } diff --git a/crates/pinakes-ui/src/plugin_ui/registry.rs b/crates/pinakes-ui/src/plugin_ui/registry.rs index a50c533..fb3d1b6 100644 --- a/crates/pinakes-ui/src/plugin_ui/registry.rs +++ b/crates/pinakes-ui/src/plugin_ui/registry.rs @@ -27,15 +27,18 @@ use crate::client::ApiClient; #[derive(Debug, Clone)] pub struct PluginPage { /// Plugin ID that provides this page - pub plugin_id: String, + pub plugin_id: String, /// Page definition from schema - pub page: UiPage, + pub page: UiPage, + /// Endpoint paths this plugin is allowed to fetch (empty means no + /// restriction) + pub allowed_endpoints: Vec, } impl PluginPage { - /// Full route including plugin prefix + /// The canonical route for this page, taken directly from the page schema. pub fn full_route(&self) -> String { - format!("/plugins/{}/{}", self.plugin_id, self.page.id) + self.page.route.clone() } } @@ -46,10 +49,12 @@ impl PluginPage { pub struct PluginRegistry { /// API client for fetching pages from server client: ApiClient, - /// Cached pages: (plugin_id, page_id) -> PluginPage + /// Cached pages: (`plugin_id`, `page_id`) -> `PluginPage` pages: HashMap<(String, String), PluginPage>, - /// Cached widgets: (plugin_id, widget_id) -> UiWidget + /// Cached widgets: (`plugin_id`, `widget_id`) -> `UiWidget` widgets: Vec<(String, UiWidget)>, + /// Merged CSS custom property overrides from all enabled plugins + theme_vars: HashMap, /// Last refresh timestamp last_refresh: Option>, } @@ -61,25 +66,57 @@ impl PluginRegistry { client, pages: HashMap::new(), widgets: Vec::new(), + theme_vars: HashMap::new(), last_refresh: None, } } - /// Create a new registry with pre-loaded pages - pub fn with_pages(client: ApiClient, pages: Vec<(String, UiPage)>) -> Self { - let mut registry = Self::new(client); - for (plugin_id, page) in pages { - registry.register_page(plugin_id, page); - } - registry + /// Get merged CSS custom property overrides from all loaded plugins. + pub fn theme_vars(&self) -> &HashMap { + &self.theme_vars } /// Register a page from a plugin - pub fn register_page(&mut self, plugin_id: String, page: UiPage) { + /// + /// Pages that fail schema validation are silently skipped with a warning log. + pub fn register_page( + &mut self, + plugin_id: String, + page: UiPage, + allowed_endpoints: Vec, + ) { + if let Err(e) = page.validate() { + tracing::warn!( + plugin_id = %plugin_id, + page_id = %page.id, + "Skipping invalid page '{}' from '{}': {e}", + page.id, + plugin_id, + ); + return; + } let page_id = page.id.clone(); - self - .pages - .insert((plugin_id.clone(), page_id), PluginPage { plugin_id, page }); + // Check for duplicate page_id across different plugins. Same-plugin + // re-registration of the same page is allowed to overwrite. + let has_duplicate = self.pages.values().any(|existing| { + existing.page.id == page_id && existing.plugin_id != plugin_id + }); + if has_duplicate { + tracing::warn!( + plugin_id = %plugin_id, + page_id = %page_id, + "skipping plugin page: page ID conflicts with an existing page from another plugin" + ); + return; + } + self.pages.insert( + (plugin_id.clone(), page_id), + PluginPage { + plugin_id, + page, + allowed_endpoints, + }, + ); } /// Get a specific page by plugin ID and page ID @@ -94,29 +131,37 @@ impl PluginRegistry { } /// Register a widget from a plugin + /// + /// Widgets that fail schema validation are silently skipped with a warning + /// log. pub fn register_widget(&mut self, plugin_id: String, widget: UiWidget) { + if let Err(e) = widget.validate() { + tracing::warn!( + plugin_id = %plugin_id, + widget_id = %widget.id, + "Skipping invalid widget '{}' from '{}': {e}", + widget.id, + plugin_id, + ); + return; + } self.widgets.push((plugin_id, widget)); } - /// Get all widgets (for use with WidgetContainer) + /// Get all widgets (for use with `WidgetContainer`) pub fn all_widgets(&self) -> Vec<(String, UiWidget)> { self.widgets.clone() } /// Get all pages + #[allow( + dead_code, + reason = "used in tests and may be needed by future callers" + )] pub fn all_pages(&self) -> Vec<&PluginPage> { self.pages.values().collect() } - /// Get all page routes for navigation - pub fn routes(&self) -> Vec<(String, String, String)> { - self - .pages - .values() - .map(|p| (p.plugin_id.clone(), p.page.id.clone(), p.full_route())) - .collect() - } - /// Check if any pages are registered pub fn is_empty(&self) -> bool { self.pages.is_empty() @@ -127,20 +172,50 @@ impl PluginRegistry { self.pages.len() } - /// Refresh pages from server + /// Get all page routes for navigation + /// + /// Returns `(plugin_id, page_id, full_route)` triples. + pub fn routes(&self) -> Vec<(String, String, String)> { + self + .pages + .values() + .map(|p| (p.plugin_id.clone(), p.page.id.clone(), p.full_route())) + .collect() + } + + /// Refresh pages and widgets from server pub async fn refresh(&mut self) -> Result<(), String> { - match self.client.get_plugin_ui_pages().await { - Ok(pages) => { - self.pages.clear(); - self.widgets.clear(); - for (plugin_id, page) in pages { - self.register_page(plugin_id, page); - } - self.last_refresh = Some(chrono::Utc::now()); - Ok(()) - }, - Err(e) => Err(format!("Failed to refresh plugin pages: {e}")), + let pages = self + .client + .get_plugin_ui_pages() + .await + .map_err(|e| format!("Failed to refresh plugin pages: {e}"))?; + + // Build into a temporary registry to avoid a window where state appears + // empty during the two async fetches. + let mut tmp = Self::new(self.client.clone()); + for (plugin_id, page, endpoints) in pages { + tmp.register_page(plugin_id, page, endpoints); } + match self.client.get_plugin_ui_widgets().await { + Ok(widgets) => { + for (plugin_id, widget) in widgets { + tmp.register_widget(plugin_id, widget); + } + }, + Err(e) => tracing::warn!("Failed to refresh plugin widgets: {e}"), + } + match self.client.get_plugin_ui_theme_extensions().await { + Ok(vars) => tmp.theme_vars = vars, + Err(e) => tracing::warn!("Failed to refresh plugin theme extensions: {e}"), + } + + // Atomic swap: no window where the registry appears empty. + 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 @@ -173,6 +248,7 @@ mod tests { padding: None, }, data_sources: HashMap::new(), + actions: HashMap::new(), } } @@ -181,7 +257,7 @@ mod tests { let client = ApiClient::default(); let registry = PluginRegistry::new(client); assert!(registry.is_empty()); - assert_eq!(registry.len(), 0); + assert_eq!(registry.all_pages().len(), 0); } #[test] @@ -190,10 +266,10 @@ mod tests { let mut registry = PluginRegistry::new(client); let page = create_test_page("demo", "Demo Page"); - registry.register_page("my-plugin".to_string(), page.clone()); + registry.register_page("my-plugin".to_string(), page.clone(), vec![]); assert!(!registry.is_empty()); - assert_eq!(registry.len(), 1); + assert_eq!(registry.all_pages().len(), 1); let retrieved = registry.get_page("my-plugin", "demo"); assert!(retrieved.is_some()); @@ -210,18 +286,6 @@ mod tests { assert!(result.is_none()); } - #[test] - fn test_page_full_route() { - let client = ApiClient::default(); - let mut registry = PluginRegistry::new(client); - let page = create_test_page("demo", "Demo Page"); - - registry.register_page("my-plugin".to_string(), page.clone()); - - let plugin_page = registry.get_page("my-plugin", "demo").unwrap(); - assert_eq!(plugin_page.full_route(), "/plugins/my-plugin/demo"); - } - #[test] fn test_all_pages() { let client = ApiClient::default(); @@ -230,33 +294,18 @@ mod tests { registry.register_page( "plugin1".to_string(), create_test_page("page1", "Page 1"), + vec![], ); registry.register_page( "plugin2".to_string(), create_test_page("page2", "Page 2"), + vec![], ); let all = registry.all_pages(); assert_eq!(all.len(), 2); } - #[test] - fn test_routes() { - let client = ApiClient::default(); - let mut registry = PluginRegistry::new(client); - - registry.register_page( - "plugin1".to_string(), - create_test_page("page1", "Page 1"), - ); - - let routes = registry.routes(); - assert_eq!(routes.len(), 1); - assert_eq!(routes[0].0, "plugin1"); - assert_eq!(routes[0].1, "page1"); - assert_eq!(routes[0].2, "/plugins/plugin1/page1"); - } - #[test] fn test_register_widget_and_all_widgets() { let client = ApiClient::default(); @@ -277,31 +326,23 @@ mod tests { assert_eq!(widgets[0].1.id, "my-widget"); } - #[test] - fn test_with_pages_builds_registry() { - let client = ApiClient::default(); - let pages = vec![ - ("plugin1".to_string(), create_test_page("page1", "Page 1")), - ("plugin2".to_string(), create_test_page("page2", "Page 2")), - ]; - - let registry = PluginRegistry::with_pages(client, pages); - assert_eq!(registry.len(), 2); - assert!(registry.get_page("plugin1", "page1").is_some()); - assert!(registry.get_page("plugin2", "page2").is_some()); - } - #[test] fn test_register_page_overwrites_same_key() { let client = ApiClient::default(); let mut registry = PluginRegistry::new(client); - registry - .register_page("plugin1".to_string(), create_test_page("p", "Original")); - registry - .register_page("plugin1".to_string(), create_test_page("p", "Updated")); + registry.register_page( + "plugin1".to_string(), + create_test_page("p", "Original"), + vec![], + ); + registry.register_page( + "plugin1".to_string(), + create_test_page("p", "Updated"), + vec![], + ); - assert_eq!(registry.len(), 1); + assert_eq!(registry.all_pages().len(), 1); assert_eq!( registry.get_page("plugin1", "p").unwrap().page.title, "Updated" @@ -312,16 +353,73 @@ mod tests { fn test_default_registry_is_empty() { let registry = PluginRegistry::default(); assert!(registry.is_empty()); - assert_eq!(registry.len(), 0); + assert_eq!(registry.all_pages().len(), 0); assert!(registry.last_refresh().is_none()); } + #[test] + fn test_len() { + let client = ApiClient::default(); + let mut registry = PluginRegistry::new(client); + assert_eq!(registry.len(), 0); + registry.register_page("p".to_string(), create_test_page("a", "A"), vec![]); + assert_eq!(registry.len(), 1); + } + + #[test] + fn test_page_full_route() { + let client = ApiClient::default(); + let mut registry = PluginRegistry::new(client); + registry.register_page( + "my-plugin".to_string(), + create_test_page("demo", "Demo Page"), + vec![], + ); + let plugin_page = registry.get_page("my-plugin", "demo").unwrap(); + // full_route() returns page.route directly; create_test_page sets it as + // "/plugins/test/{id}" + assert_eq!(plugin_page.full_route(), "/plugins/test/demo"); + } + + #[test] + fn test_routes() { + let client = ApiClient::default(); + let mut registry = PluginRegistry::new(client); + registry.register_page( + "plugin1".to_string(), + create_test_page("page1", "Page 1"), + vec![], + ); + let routes = registry.routes(); + assert_eq!(routes.len(), 1); + assert_eq!(routes[0].0, "plugin1"); + assert_eq!(routes[0].1, "page1"); + assert_eq!(routes[0].2, "/plugins/test/page1"); + } + + #[test] + fn test_with_pages_builds_registry() { + let client = ApiClient::default(); + let pages = vec![ + ("plugin1".to_string(), create_test_page("page1", "Page 1")), + ("plugin2".to_string(), create_test_page("page2", "Page 2")), + ]; + // Build via register_page loop (equivalent to old with_pages) + let mut registry = PluginRegistry::new(client); + for (plugin_id, page) in pages { + registry.register_page(plugin_id, page, vec![]); + } + assert_eq!(registry.len(), 2); + assert!(registry.get_page("plugin1", "page1").is_some()); + assert!(registry.get_page("plugin2", "page2").is_some()); + } + #[test] fn test_all_pages_returns_references() { let client = ApiClient::default(); let mut registry = PluginRegistry::new(client); - registry.register_page("p1".to_string(), create_test_page("a", "A")); - registry.register_page("p2".to_string(), create_test_page("b", "B")); + registry.register_page("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(); assert_eq!(pages.len(), 2); @@ -332,27 +430,145 @@ mod tests { } #[test] - fn test_different_plugins_same_page_id_both_stored() { + fn test_different_plugins_same_page_id_second_rejected() { + let client = ApiClient::default(); + let mut registry = PluginRegistry::new(client); + + // First plugin registers "stats" - should succeed. + registry.register_page( + "plugin-a".to_string(), + create_test_page("stats", "A Stats"), + vec![], + ); + // Second plugin attempts to register the same page ID "stats" - should be + // rejected to avoid route collisions at /plugins/stats. + registry.register_page( + "plugin-b".to_string(), + create_test_page("stats", "B Stats"), + vec![], + ); + + // Only one page should be registered; the second was rejected. + assert_eq!(registry.all_pages().len(), 1); + assert_eq!( + registry.get_page("plugin-a", "stats").unwrap().page.title, + "A Stats" + ); + assert!( + registry.get_page("plugin-b", "stats").is_none(), + "plugin-b's page with duplicate ID should have been rejected" + ); + } + + #[test] + fn test_same_plugin_same_page_id_overwrites() { + // Same plugin re-registering the same page ID should still be allowed + // (overwrite semantics, not a cross-plugin conflict). let client = ApiClient::default(); let mut registry = PluginRegistry::new(client); registry.register_page( "plugin-a".to_string(), - create_test_page("home", "A Home"), + create_test_page("stats", "A Stats v1"), + vec![], ); registry.register_page( - "plugin-b".to_string(), - create_test_page("home", "B Home"), + "plugin-a".to_string(), + create_test_page("stats", "A Stats v2"), + vec![], ); - assert_eq!(registry.len(), 2); + assert_eq!(registry.all_pages().len(), 1); assert_eq!( - registry.get_page("plugin-a", "home").unwrap().page.title, - "A Home" - ); - assert_eq!( - registry.get_page("plugin-b", "home").unwrap().page.title, - "B Home" + registry.get_page("plugin-a", "stats").unwrap().page.title, + "A Stats v2" ); } + + #[test] + fn test_register_invalid_page_is_skipped() { + use pinakes_plugin_api::UiElement; + + let client = ApiClient::default(); + let mut registry = PluginRegistry::new(client); + + // A page with an empty ID fails validation + let invalid_page = UiPage { + id: String::new(), // invalid: empty + title: "Bad Page".to_string(), + route: "/plugins/bad".to_string(), + icon: None, + root_element: UiElement::Container { + children: vec![], + gap: 16, + padding: None, + }, + data_sources: HashMap::new(), + actions: HashMap::new(), + }; + + registry.register_page("test-plugin".to_string(), invalid_page, vec![]); + assert!(registry.is_empty(), "invalid page should have been skipped"); + } + + #[test] + fn test_register_valid_page_after_invalid() { + let client = ApiClient::default(); + let mut registry = PluginRegistry::new(client); + + use pinakes_plugin_api::UiElement; + + // Invalid page + let invalid_page = UiPage { + id: String::new(), + title: "Bad".to_string(), + route: "/bad".to_string(), + icon: None, + root_element: UiElement::Container { + children: vec![], + gap: 0, + padding: None, + }, + data_sources: HashMap::new(), + actions: HashMap::new(), + }; + registry.register_page("p".to_string(), invalid_page, vec![]); + assert_eq!(registry.all_pages().len(), 0); + + // Valid page; should still register fine + registry.register_page("p".to_string(), create_test_page("good", "Good"), vec![]); + assert_eq!(registry.all_pages().len(), 1); + } + + #[test] + fn test_register_invalid_widget_is_skipped() { + let client = ApiClient::default(); + let mut registry = PluginRegistry::new(client); + + let widget: pinakes_plugin_api::UiWidget = + serde_json::from_value(serde_json::json!({ + "id": "my-widget", + "target": "library_header", + "content": { "type": "badge", "text": "hi", "variant": "default" } + })) + .unwrap(); + + // Mutate: create an invalid widget with empty id + let invalid_widget = pinakes_plugin_api::UiWidget { + id: String::new(), // invalid + target: "library_header".to_string(), + content: widget.content.clone(), + }; + + assert!(registry.all_widgets().is_empty()); + registry.register_widget("test-plugin".to_string(), invalid_widget); + assert!( + registry.all_widgets().is_empty(), + "invalid widget should have been skipped" + ); + + // Valid widget is still accepted + registry.register_widget("test-plugin".to_string(), widget); + assert_eq!(registry.all_widgets().len(), 1); + } } diff --git a/crates/pinakes-ui/src/plugin_ui/renderer.rs b/crates/pinakes-ui/src/plugin_ui/renderer.rs index 5f6f4c3..e8372fd 100644 --- a/crates/pinakes-ui/src/plugin_ui/renderer.rs +++ b/crates/pinakes-ui/src/plugin_ui/renderer.rs @@ -4,13 +4,16 @@ //! elements. Data-driven elements resolve their data from a [`PluginPageData`] //! context that is populated by the `use_plugin_data` hook. +use std::collections::HashMap; + use dioxus::prelude::*; use pinakes_plugin_api::{ + ActionDefinition, + ActionRef, AlignItems, BadgeVariant, ButtonVariant, ChartType, - Expression, FieldType, FlexDirection, JustifyContent, @@ -24,18 +27,65 @@ use pinakes_plugin_api::{ use super::{ actions::execute_action, data::{PluginPageData, use_plugin_data}, + expr::{ + evaluate_expression, + evaluate_expression_as_bool, + evaluate_expression_as_f64, + value_to_display_string, + }, }; use crate::client::ApiClient; +/// Mutable signals threaded through the element tree. +/// +/// All fields are `Signal` (which is `Copy`), so `RenderContext` is `Copy`. +/// `Eq` is not derived because `Signal>` cannot implement it +/// (`UiElement` contains `f64` fields). +#[derive(Clone, Copy, PartialEq)] +#[allow(clippy::derive_partial_eq_without_eq)] +pub struct RenderContext { + pub client: Signal, + pub feedback: Signal>, + pub navigate: Signal>, + pub refresh: Signal, + pub modal: Signal>, + pub local_state: Signal>, +} + +/// Build the expression evaluation context from page data and local state. +fn build_ctx( + data: &PluginPageData, + local_state: &HashMap, +) -> serde_json::Value { + let mut base = data.as_json(); + if let serde_json::Value::Object(ref mut obj) = base { + obj.insert( + "local".to_string(), + serde_json::Value::Object( + local_state + .iter() + .map(|(k, v)| (k.clone(), v.clone())) + .collect(), + ), + ); + } + base +} + /// Props for [`PluginViewRenderer`] #[derive(Props, PartialEq, Clone)] pub struct PluginViewProps { /// Plugin ID that owns this page - pub plugin_id: String, + pub plugin_id: String, /// Page schema to render - pub page: UiPage, + pub page: UiPage, /// API client signal - pub client: Signal, + pub client: Signal, + /// Called when a plugin action requests navigation to a route + pub on_navigate: EventHandler, + /// Endpoint paths this plugin is allowed to fetch (empty means no + /// restriction) + pub allowed_endpoints: Vec, } /// Main component for rendering a plugin page. @@ -46,14 +96,65 @@ pub struct PluginViewProps { pub fn PluginViewRenderer(props: PluginViewProps) -> Element { let page = props.page.clone(); let data_sources = page.data_sources.clone(); - let page_data = use_plugin_data(props.client, data_sources); + let actions = page.actions.clone(); + let mut feedback = use_signal(|| None::<(String, bool)>); + let mut navigate = use_signal(|| None::); + let refresh = use_signal(|| 0u32); + let mut modal = use_signal(|| None::); + let local_state = use_signal(HashMap::::new); + let ctx = RenderContext { + client: props.client, + feedback, + navigate, + refresh, + modal, + local_state, + }; + let page_data = + use_plugin_data(props.client, data_sources, refresh, props.allowed_endpoints); + + // Consume pending navigation requests and forward to the parent + use_effect(move || { + let pending = navigate.read().clone(); + if let Some(route) = pending { + props.on_navigate.call(route); + navigate.set(None); + } + }); rsx! { div { class: "plugin-page", - "data-plugin-id": props.plugin_id.clone(), + "data-plugin-id": props.plugin_id, h2 { class: "plugin-page-title", "{page.title}" } - { render_element(&page.root_element, &page_data.read(), props.client) } + { render_element(&page.root_element, &page_data.read(), &actions, ctx) } + if let Some((msg, is_error)) = feedback.read().as_ref().cloned() { + div { + class: if is_error { "plugin-feedback error" } else { "plugin-feedback success" }, + "{msg}" + button { + class: "plugin-feedback-dismiss", + onclick: move |_| feedback.set(None), + "×" + } + } + } + if let Some(elem) = modal.read().as_ref().cloned() { + div { + class: "plugin-modal-overlay", + onclick: move |_| modal.set(None), + div { + class: "plugin-modal", + onclick: |e| e.stop_propagation(), + button { + class: "plugin-modal-close", + onclick: move |_| modal.set(None), + "×" + } + { render_element(&elem, &page_data.read(), &HashMap::new(), ctx) } + } + } + } } } } @@ -64,7 +165,8 @@ struct PluginTabsProps { tabs: Vec, default_tab: usize, data: PluginPageData, - client: Signal, + actions: HashMap, + ctx: RenderContext, } /// Renders a tabbed interface with interactive tab switching. @@ -101,15 +203,12 @@ fn PluginTabs(props: PluginTabsProps) -> Element { { let is_active = idx == active_idx; let content = tab.content.clone(); + let actions = props.actions.clone(); rsx! { div { - class: if is_active { - "plugin-tab-panel active" - } else { - "plugin-tab-panel" - }, + class: if is_active { "plugin-tab-panel active" } else { "plugin-tab-panel" }, hidden: !is_active, - { render_element(&content, &props.data, props.client) } + { render_element(&content, &props.data, &actions, props.ctx) } } } } @@ -119,11 +218,265 @@ fn PluginTabs(props: PluginTabsProps) -> Element { } } +/// Props for the stateful [`PluginDataTable`] component. +#[derive(Props, PartialEq, Clone)] +struct PluginDataTableProps { + columns: Vec, + source_key: String, + sortable: bool, + filterable: bool, + page_size: usize, + row_actions: Vec, + data: PluginPageData, + actions: HashMap, + ctx: RenderContext, +} + +/// Stateful data table with optional client-side filtering and pagination. +#[component] +fn PluginDataTable(props: PluginDataTableProps) -> Element { + let mut filter_text = use_signal(String::new); + let mut current_page = use_signal(|| 0usize); + + let all_rows = props + .data + .get(&props.source_key) + .and_then(|v| v.as_array()) + .cloned() + .unwrap_or_default(); + + let filter = filter_text.read().to_lowercase(); + let filtered: Vec = if filter.is_empty() { + all_rows + } else { + all_rows + .into_iter() + .filter(|row| { + props.columns.iter().any(|col| { + extract_cell(row, &col.key).to_lowercase().contains(&filter) + }) + }) + .collect() + }; + + let total = filtered.len(); + let (page_rows, total_pages) = if props.page_size > 0 && total > 0 { + let total_pages = total.div_ceil(props.page_size); + let page = (*current_page.read()).min(total_pages.saturating_sub(1)); + let start = page * props.page_size; + let end = (start + props.page_size).min(total); + (filtered[start..end].to_vec(), total_pages) + } else { + (filtered, 1usize) + }; + + let page = *current_page.read(); + + rsx! { + div { class: "plugin-data-table-wrapper", + if props.data.is_loading(&props.source_key) { + div { class: "plugin-loading", "Loading…" } + } else if let Some(err) = props.data.error(&props.source_key) { + div { class: "plugin-error", "Error: {err}" } + } else { + if props.filterable { + div { class: "table-filter", + input { + r#type: "text", + placeholder: "Filter…", + value: "{filter_text}", + oninput: move |e| { + filter_text.set(e.value()); + current_page.set(0); + }, + } + } + } + table { + class: "plugin-data-table", + "data-sortable": if props.sortable { "true" } else { "false" }, + thead { + tr { + {props.columns.iter().map(|col| { + let col_width = col.width.as_deref().and_then(safe_col_width_css); + rsx! { + th { + style: col_width.as_deref().map(|v| format!("--plugin-col-width:{v};")).unwrap_or_default(), + class: if col_width.is_some() { "plugin-col-constrained" } else { "" }, + "{col.header}" + } + } + })} + if !props.row_actions.is_empty() { + th { "Actions" } + } + } + } + tbody { + for row in page_rows { + { + let row_val = row; + rsx! { + tr { + for col in props.columns.clone() { + td { "{extract_cell(&row_val, &col.key)}" } + } + if !props.row_actions.is_empty() { + td { class: "row-actions", + for act in props.row_actions.clone() { + { + let action = act.action.clone(); + let row_data = row_val.clone(); + let variant_class = + button_variant_class(&act.variant); + let page_actions = props.actions.clone(); + let success_msg: Option = + match &act.action { + ActionRef::Special(_) => None, + ActionRef::Name(name) => props + .actions + .get(name) + .and_then(|a| { + a.success_message.clone() + }), + ActionRef::Inline(a) => { + a.success_message.clone() + }, + }; + let error_msg: Option = + match &act.action { + ActionRef::Special(_) => None, + ActionRef::Name(name) => props + .actions + .get(name) + .and_then(|a| { + a.error_message.clone() + }), + ActionRef::Inline(a) => { + a.error_message.clone() + }, + }; + let ctx = props.ctx; + // Pre-compute data JSON at render time to + // avoid moving props.data into closures. + let data_json = props.data.as_json(); + rsx! { + button { + class: "plugin-button {variant_class}", + onclick: move |_| { + let a = action.clone(); + let fd = row_data.clone(); + let c = + ctx.client.read().clone(); + let pa = page_actions.clone(); + let sm = success_msg.clone(); + let em = error_msg.clone(); + // Combine pre-rendered data JSON + // with current local_state. + let mut data_snapshot = + data_json.clone(); + if let serde_json::Value::Object( + ref mut m, + ) = data_snapshot + { + m.insert( + "local".to_string(), + serde_json::Value::Object( + ctx.local_state.read().iter().map(|(k, v)| (k.clone(), v.clone())).collect(), + ), + ); + } + spawn(async move { + let mut ctx = ctx; + match execute_action( + &c, &a, &pa, Some(&fd), + ) + .await + { + Ok(super::actions::ActionResult::Success(body)) => { + tracing::debug!(response = ?body, "plugin action succeeded"); + if let Some(msg) = sm { + ctx.feedback.set(Some((msg, false))); + } + }, + Ok(super::actions::ActionResult::Error(msg)) => { + ctx.feedback.set(Some(( + em.unwrap_or(msg), + true, + ))); + }, + Ok(super::actions::ActionResult::Navigate(route)) => { + ctx.navigate.set(Some(route)); + }, + Ok(super::actions::ActionResult::None) => {}, + Ok(super::actions::ActionResult::Refresh) => { + *ctx.refresh.write() += 1; + }, + Ok(super::actions::ActionResult::UpdateState { key, value_expr }) => { + let evaluated = evaluate_expression(&value_expr, &data_snapshot); + ctx.local_state.write().insert(key, evaluated); + }, + Ok(super::actions::ActionResult::OpenModal(element)) => { + ctx.modal.set(Some(element)); + }, + Ok(super::actions::ActionResult::CloseModal) => { + ctx.modal.set(None); + }, + Err(e) => { + ctx.feedback.set(Some((e, true))); + }, + } + }); + }, + "{act.label}" + } + } + } + } + } + } + } + } + } + } + } + } + if props.page_size > 0 && total_pages > 1 { + div { class: "table-pagination", + button { + class: "plugin-button", + disabled: page == 0, + onclick: move |_| { + let p = *current_page.read(); + if p > 0 { + current_page.set(p - 1); + } + }, + "←" + } + span { "Page {page + 1} of {total_pages} ({total} items)" } + button { + class: "plugin-button", + disabled: page + 1 >= total_pages, + onclick: move |_| { + let p = *current_page.read(); + current_page.set(p + 1); + }, + "→" + } + } + } + } + } + } +} + /// Render a single [`UiElement`] with the provided data context. -pub(crate) fn render_element( +pub fn render_element( element: &UiElement, data: &PluginPageData, - client: Signal, + actions: &HashMap, + ctx: RenderContext, ) -> Element { match element { // Layout containers @@ -136,15 +489,13 @@ pub(crate) fn render_element( || "0".to_string(), |p| format!("{}px {}px {}px {}px", p[0], p[1], p[2], p[3]), ); - let style = format!( - "display:flex;flex-direction:column;gap:{gap}px;padding:{padding_css};" - ); + let style = format!("--plugin-gap:{gap}px;--plugin-padding:{padding_css};"); rsx! { div { class: "plugin-container", style: "{style}", for child in children { - { render_element(child, data, client) } + { render_element(child, data, actions, ctx) } } } } @@ -155,15 +506,13 @@ pub(crate) fn render_element( columns, gap, } => { - let style = format!( - "display:grid;grid-template-columns:repeat({columns},1fr);gap:{gap}px;" - ); + let style = format!("--plugin-columns:{columns};--plugin-gap:{gap}px;"); rsx! { div { class: "plugin-grid", style: "{style}", for child in children { - { render_element(child, data, client) } + { render_element(child, data, actions, ctx) } } } } @@ -181,16 +530,17 @@ pub(crate) fn render_element( let jc = justify_content_css(justify); let ai = align_items_css(align); let fw = if *wrap { "wrap" } else { "nowrap" }; - let style = format!( - "display:flex;flex-direction:{dir};justify-content:{jc};align-items:\ - {ai};flex-wrap:{fw};gap:{gap}px;" - ); + let style = format!("--plugin-gap:{gap}px;"); rsx! { div { class: "plugin-flex", style: "{style}", + "data-direction": "{dir}", + "data-justify": "{jc}", + "data-align": "{ai}", + "data-wrap": "{fw}", for child in children { - { render_element(child, data, client) } + { render_element(child, data, actions, ctx) } } } } @@ -204,16 +554,14 @@ pub(crate) fn render_element( rsx! { div { class: "plugin-split", - style: "display:flex;", aside { class: "plugin-split-sidebar", - style: "width:{sidebar_width}px;flex-shrink:0;", - { render_element(sidebar, data, client) } + style: "--plugin-sidebar-width:{sidebar_width}px;", + { render_element(sidebar, data, actions, ctx) } } main { class: "plugin-split-main", - style: "flex:1;min-width:0;", - { render_element(main, data, client) } + { render_element(main, data, actions, ctx) } } } } @@ -225,15 +573,16 @@ pub(crate) fn render_element( tabs: tabs.clone(), default_tab: *default_tab, data: data.clone(), - client, + actions: actions.clone(), + ctx, } } }, // Typography UiElement::Heading { level, content, id } => { - let ctx = data.as_json(); - let text = resolve_text_content(content, &ctx); + let eval_ctx = data.as_json(); + let text = resolve_text_content(content, &eval_ctx); let class = format!("plugin-heading level-{level}"); let anchor = id.clone().unwrap_or_default(); match level.min(&6) { @@ -251,8 +600,8 @@ pub(crate) fn render_element( variant, allow_html, } => { - let ctx = data.as_json(); - let text = resolve_text_content(content, &ctx); + let eval_ctx = data.as_json(); + let text = resolve_text_content(content, &eval_ctx); let variant_class = text_variant_class(variant); if *allow_html { let sanitized = ammonia::clean(&text); @@ -297,73 +646,21 @@ pub(crate) fn render_element( columns, data: source_key, sortable, - filterable: _, - page_size: _, + filterable, + page_size, row_actions, } => { - let rows = data.get(source_key); rsx! { - div { class: "plugin-data-table-wrapper", - if data.is_loading(source_key) { - div { class: "plugin-loading", "Loading…" } - } else if let Some(err) = data.error(source_key) { - div { class: "plugin-error", "Error: {err}" } - } else { - table { - class: "plugin-data-table", - "data-sortable": if *sortable { "true" } else { "false" }, - thead { - tr { - for col in columns { - th { - style: col.width.as_ref().map(|w| format!("width:{w};")).unwrap_or_default(), - "{col.header}" - } - } - if !row_actions.is_empty() { - th { "Actions" } - } - } - } - tbody { - if let Some(arr) = rows.and_then(|v| v.as_array()) { - for row in arr { - tr { - for col in columns { - td { "{extract_cell(row, &col.key)}" } - } - if !row_actions.is_empty() { - td { - class: "row-actions", - for act in row_actions { - { - let action = act.action.clone(); - let row_data = row.clone(); - let variant_class = button_variant_class(&act.variant); - rsx! { - button { - class: "plugin-button {variant_class}", - onclick: move |_| { - let a = action.clone(); - let fd = row_data.clone(); - let c = client.read().clone(); - spawn(async move { - let _ = execute_action(&c, &a, Some(&fd)).await; - }); - }, - "{act.label}" - } - } - } - } - } - } - } - } - } - } - } - } + PluginDataTable { + columns: columns.clone(), + source_key: source_key.clone(), + sortable: *sortable, + filterable: *filterable, + page_size: *page_size, + row_actions: row_actions.clone(), + data: data.clone(), + actions: actions.clone(), + ctx, } } }, @@ -382,14 +679,14 @@ pub(crate) fn render_element( div { class: "plugin-card-content", for child in content { - { render_element(child, data, client) } + { render_element(child, data, actions, ctx) } } } if !footer.is_empty() { div { class: "plugin-card-footer", for child in footer { - { render_element(child, data, client) } + { render_element(child, data, actions, ctx) } } } } @@ -403,9 +700,7 @@ pub(crate) fn render_element( gap, } => { let items = data.get(source_key); - let style = format!( - "display:grid;grid-template-columns:repeat({columns},1fr);gap:{gap}px;" - ); + let style = format!("--plugin-columns:{columns};--plugin-gap:{gap}px;"); rsx! { div { class: "plugin-media-grid", style: "{style}", if data.is_loading(source_key) { @@ -414,9 +709,30 @@ pub(crate) fn render_element( div { class: "plugin-error", "Error: {err}" } } else if let Some(arr) = items.and_then(|v| v.as_array()) { for item in arr { - div { - class: "media-grid-item", - "{extract_cell(item, \"thumbnail\")}" + { + let url_opt = media_grid_image_url(item); + let label = media_grid_label(item); + rsx! { + div { class: "media-grid-item", + if let Some(url) = url_opt { + if pinakes_plugin_api::ui_schema::is_safe_href(&url) { + img { + class: "media-grid-img", + src: "{url}", + alt: "{label}", + loading: "lazy", + } + } else { + div { class: "media-grid-no-img", "{label}" } + } + } else { + div { class: "media-grid-no-img", "No image" } + } + if !label.is_empty() { + div { class: "media-grid-caption", "{label}" } + } + } + } } } } @@ -450,7 +766,7 @@ pub(crate) fn render_element( rsx! { li { class: "plugin-list-item", - { render_element(item_template, &item_data, client) } + { render_element(item_template, &item_data, actions, ctx) } if dividers { hr { class: "plugin-list-divider" } } @@ -476,15 +792,24 @@ pub(crate) fn render_element( } else { "plugin-description-list" }; + let pairs: Vec<(String, String)> = resolved + .and_then(|v| v.as_object()) + .map(|obj| { + obj + .iter() + .map(|(k, v)| (k.clone(), value_to_display_string(v))) + .collect() + }) + .unwrap_or_default(); rsx! { div { class: "plugin-description-list-wrapper", if data.is_loading(source_key) { div { class: "plugin-loading", "Loading…" } } else if let Some(err) = data.error(source_key) { div { class: "plugin-error", "Error: {err}" } - } else if let Some(obj) = resolved.and_then(|v| v.as_object()) { + } else if !pairs.is_empty() { dl { class: "{class}", - for (key, val) in obj { + for (key, val) in &pairs { dt { "{key}" } dd { "{val}" } } @@ -503,15 +828,67 @@ pub(crate) fn render_element( } => { let variant_class = button_variant_class(variant); let action_ref = action.clone(); + let page_actions = actions.clone(); + let success_msg: Option = match action { + ActionRef::Special(_) => None, + ActionRef::Name(name) => { + actions.get(name).and_then(|a| a.success_message.clone()) + }, + ActionRef::Inline(a) => a.success_message.clone(), + }; + let error_msg: Option = match action { + ActionRef::Special(_) => None, + ActionRef::Name(name) => { + actions.get(name).and_then(|a| a.error_message.clone()) + }, + ActionRef::Inline(a) => a.error_message.clone(), + }; + let data_snapshot = build_ctx(data, &ctx.local_state.read()); rsx! { button { class: "plugin-button {variant_class}", disabled: *disabled, onclick: move |_| { let a = action_ref.clone(); - let c = client.read().clone(); + let c = ctx.client.read().clone(); + let pa = page_actions.clone(); + let success_msg = success_msg.clone(); + let error_msg = error_msg.clone(); + let data_snapshot = data_snapshot.clone(); spawn(async move { - let _ = execute_action(&c, &a, None).await; + let mut ctx = ctx; + match execute_action(&c, &a, &pa, None).await { + Ok(super::actions::ActionResult::Success(body)) => { + tracing::debug!(response = ?body, "plugin action succeeded"); + if let Some(msg) = success_msg { + ctx.feedback.set(Some((msg, false))); + } + }, + Ok(super::actions::ActionResult::Error(msg)) => { + let display = error_msg.unwrap_or(msg); + ctx.feedback.set(Some((display, true))); + }, + Ok(super::actions::ActionResult::Navigate(route)) => { + ctx.navigate.set(Some(route)); + }, + Ok(super::actions::ActionResult::None) => {}, + Ok(super::actions::ActionResult::Refresh) => { + *ctx.refresh.write() += 1; + }, + Ok(super::actions::ActionResult::UpdateState { key, value_expr }) => { + let evaluated = evaluate_expression(&value_expr, &data_snapshot); + ctx.local_state.write().insert(key, evaluated); + }, + Ok(super::actions::ActionResult::OpenModal(element)) => { + ctx.modal.set(Some(element)); + }, + Ok(super::actions::ActionResult::CloseModal) => { + ctx.modal.set(None); + }, + Err(e) => { + ctx.feedback.set(Some((e, true))); + }, + } }); }, "{label}" @@ -526,12 +903,29 @@ pub(crate) fn render_element( cancel_label, } => { let action_ref = submit_action.clone(); + let page_actions = actions.clone(); + let success_msg: Option = match submit_action { + ActionRef::Special(_) => None, + ActionRef::Name(name) => { + actions.get(name).and_then(|a| a.success_message.clone()) + }, + ActionRef::Inline(a) => a.success_message.clone(), + }; + let error_msg: Option = match submit_action { + ActionRef::Special(_) => None, + ActionRef::Name(name) => { + actions.get(name).and_then(|a| a.error_message.clone()) + }, + ActionRef::Inline(a) => a.error_message.clone(), + }; + let data_snapshot = build_ctx(data, &ctx.local_state.read()); rsx! { form { class: "plugin-form", onsubmit: move |event| { event.prevent_default(); let a = action_ref.clone(); + let pa = page_actions.clone(); let form_values: serde_json::Value = { use dioxus::html::FormValue; let vals = event.data().values(); @@ -553,9 +947,44 @@ pub(crate) fn render_element( .collect(); serde_json::Value::Object(map) }; - let c = client.read().clone(); + let c = ctx.client.read().clone(); + let success_msg = success_msg.clone(); + let error_msg = error_msg.clone(); + let data_snapshot = data_snapshot.clone(); spawn(async move { - let _ = execute_action(&c, &a, Some(&form_values)).await; + let mut ctx = ctx; + match execute_action(&c, &a, &pa, Some(&form_values)).await { + Ok(super::actions::ActionResult::Success(body)) => { + tracing::debug!(response = ?body, "plugin action succeeded"); + if let Some(msg) = success_msg { + ctx.feedback.set(Some((msg, false))); + } + }, + Ok(super::actions::ActionResult::Error(msg)) => { + let display = error_msg.unwrap_or(msg); + ctx.feedback.set(Some((display, true))); + }, + Ok(super::actions::ActionResult::Navigate(route)) => { + ctx.navigate.set(Some(route)); + }, + Ok(super::actions::ActionResult::None) => {}, + Ok(super::actions::ActionResult::Refresh) => { + *ctx.refresh.write() += 1; + }, + Ok(super::actions::ActionResult::UpdateState { key, value_expr }) => { + let evaluated = evaluate_expression(&value_expr, &data_snapshot); + ctx.local_state.write().insert(key, evaluated); + }, + Ok(super::actions::ActionResult::OpenModal(element)) => { + ctx.modal.set(Some(element)); + }, + Ok(super::actions::ActionResult::CloseModal) => { + ctx.modal.set(None); + }, + Err(e) => { + ctx.feedback.set(Some((e, true))); + }, + } }); }, for field in fields { @@ -597,6 +1026,12 @@ pub(crate) fn render_element( href, external, } => { + if !pinakes_plugin_api::ui_schema::is_safe_href(href) { + // Refuse to render unsafe schemes (javascript:, data:, etc.) + return rsx! { + span { class: "plugin-link-blocked", title: "Blocked: unsafe URL scheme", "{text}" } + }; + } let target = if *external { "_blank" } else { "_self" }; let rel = if *external { "noopener noreferrer" } else { "" }; rsx! { @@ -615,9 +1050,13 @@ pub(crate) fn render_element( max, show_percentage, } => { - let ctx = data.as_json(); - let pct = evaluate_expression_as_f64(value, &ctx); - let fraction = (pct / max).clamp(0.0, 1.0); + let eval_ctx = data.as_json(); + let pct = evaluate_expression_as_f64(value, &eval_ctx); + let fraction = if *max > 0.0 { + (pct / max).clamp(0.0, 1.0) + } else { + 0.0 + }; let pct_int = (fraction * 100.0).round() as u32; rsx! { div { class: "plugin-progress", @@ -627,7 +1066,7 @@ pub(crate) fn render_element( aria_valuenow: "{pct_int}", aria_valuemin: "0", aria_valuemax: "100", - style: "width:{pct_int}%;", + style: "--plugin-progress:{pct_int}%;", } if *show_percentage { span { class: "plugin-progress-label", "{pct_int}%" } @@ -656,10 +1095,13 @@ pub(crate) fn render_element( height, } => { let chart_class = chart_type_class(chart_type); + 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! { div { class: "plugin-chart {chart_class}", - style: "height:{height}px;", + style: "--plugin-chart-height:{height}px;", if data.is_loading(source_key) { div { class: "plugin-loading", "Loading…" } } else if let Some(err) = data.error(source_key) { @@ -668,7 +1110,9 @@ pub(crate) fn render_element( if let Some(t) = title { div { class: "chart-title", "{t}" } } 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}" } } - div { class: "chart-canvas", "Chart rendering requires JavaScript" } + div { class: "chart-data-table", + { render_chart_data(chart_data.as_ref(), &x_label, &y_label) } + } } } } @@ -680,11 +1124,11 @@ pub(crate) fn render_element( then, else_element, } => { - let ctx = data.as_json(); - if evaluate_expression_as_bool(condition, &ctx) { - render_element(then, data, client) + let eval_ctx = data.as_json(); + if evaluate_expression_as_bool(condition, &eval_ctx) { + render_element(then, data, actions, ctx) } else if let Some(else_el) = else_element { - render_element(else_el, data, client) + render_element(else_el, data, actions, ctx) } else { rsx! {} } @@ -705,7 +1149,7 @@ pub(crate) fn render_element( if let Some(arr) = items.and_then(|v| v.as_array()) { if arr.is_empty() { if let Some(empty_el) = empty { - return render_element(empty_el, data, client); + return render_element(empty_el, data, actions, ctx); } return rsx! {}; } @@ -714,7 +1158,7 @@ pub(crate) fn render_element( .map(|item| { let mut item_data = data.clone(); item_data.set_data("item".to_string(), item.clone()); - render_element(template, &item_data, client) + render_element(template, &item_data, actions, ctx) }) .collect(); rsx! { for el in elements { {el} } } @@ -725,22 +1169,147 @@ pub(crate) fn render_element( } } +// Chart data renderer + +/// Render chart data as an HTML table (best available without a JS chart +/// library). +/// +/// - Array of objects: table with one column per unique key +/// - Array of primitives: two-column table (index, value) +/// - Object: two-column key/value table +fn render_chart_data( + data: Option<&serde_json::Value>, + x_label: &str, + y_label: &str, +) -> Element { + match data { + Some(serde_json::Value::Array(arr)) if !arr.is_empty() => { + if arr.first().map(|v| v.is_object()).unwrap_or(false) { + // Object rows: collect unique keys preserving insertion order + let mut seen = std::collections::HashSet::new(); + let cols: Vec = arr + .iter() + .filter_map(|r| r.as_object()) + .flat_map(|o| o.keys().cloned()) + .filter(|k| seen.insert(k.clone())) + .collect(); + rsx! { + table { class: "plugin-data-table", + thead { tr { for c in &cols { th { "{c}" } } } } + tbody { + for row in arr { + tr { + for c in &cols { + td { + "{value_to_display_string(row.get(c).unwrap_or(&serde_json::Value::Null))}" + } + } + } + } + } + } + } + } else { + // Primitive array: index vs value + let x = if x_label.is_empty() { "Index" } else { x_label }; + let y = if y_label.is_empty() { "Value" } else { y_label }; + rsx! { + table { class: "plugin-data-table", + thead { tr { th { "{x}" } th { "{y}" } } } + tbody { + for (i, v) in arr.iter().enumerate() { + tr { + td { "{i}" } + td { "{value_to_display_string(v)}" } + } + } + } + } + } + } + }, + Some(serde_json::Value::Object(map)) if !map.is_empty() => { + let x = if x_label.is_empty() { "Key" } else { x_label }; + let y = if y_label.is_empty() { "Value" } else { y_label }; + rsx! { + table { class: "plugin-data-table", + thead { tr { th { "{x}" } th { "{y}" } } } + tbody { + for (k, v) in map.iter() { + tr { + td { "{k}" } + td { "{value_to_display_string(v)}" } + } + } + } + } + } + }, + _ => rsx! { div { class: "chart-no-data", "No data available" } }, + } +} + +// MediaGrid helpers + +/// Probe a JSON object for common image URL fields. +fn media_grid_image_url(item: &serde_json::Value) -> Option { + for key in &[ + "thumbnail_url", + "thumbnail", + "url", + "image", + "cover", + "src", + "poster", + ] { + if let Some(url) = item.get(*key).and_then(|v| v.as_str()) { + if !url.is_empty() { + return Some(url.to_string()); + } + } + } + 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"] { + if let Some(s) = item.get(*key).and_then(|v| v.as_str()) { + if !s.is_empty() { + return s.to_string(); + } + } + } + String::new() +} + // Form field helper fn render_form_field(field: &pinakes_plugin_api::FormField) -> Element { match &field.field_type { FieldType::Text { .. } => { + let default = field + .default_value + .as_ref() + .and_then(|v| v.as_str()) + .unwrap_or(""); rsx! { input { r#type: "text", id: "{field.id}", name: "{field.id}", + value: "{default}", placeholder: field.placeholder.as_deref().unwrap_or(""), required: field.required, } } }, FieldType::Textarea { rows } => { + let default = field + .default_value + .as_ref() + .and_then(|v| v.as_str()) + .unwrap_or(""); rsx! { textarea { id: "{field.id}", @@ -748,15 +1317,23 @@ fn render_form_field(field: &pinakes_plugin_api::FormField) -> Element { rows: *rows, placeholder: field.placeholder.as_deref().unwrap_or(""), required: field.required, + "{default}" } } }, FieldType::Number { min, max, step } => { + let default = field + .default_value + .as_ref() + .and_then(|v| v.as_f64()) + .map(|n| n.to_string()) + .unwrap_or_default(); rsx! { input { r#type: "number", id: "{field.id}", name: "{field.id}", + value: "{default}", min: min.map(|m| m.to_string()), max: max.map(|m| m.to_string()), step: step.map(|s| s.to_string()), @@ -765,52 +1342,89 @@ fn render_form_field(field: &pinakes_plugin_api::FormField) -> Element { } }, FieldType::Email => { + let default = field + .default_value + .as_ref() + .and_then(|v| v.as_str()) + .unwrap_or(""); rsx! { input { r#type: "email", id: "{field.id}", name: "{field.id}", + value: "{default}", placeholder: field.placeholder.as_deref().unwrap_or(""), required: field.required, } } }, FieldType::Url => { + let default = field + .default_value + .as_ref() + .and_then(|v| v.as_str()) + .unwrap_or(""); rsx! { input { r#type: "url", id: "{field.id}", name: "{field.id}", + value: "{default}", placeholder: field.placeholder.as_deref().unwrap_or(""), required: field.required, } } }, FieldType::Switch | FieldType::Checkbox { .. } => { + let checked = field + .default_value + .as_ref() + .and_then(|v| v.as_bool()) + .unwrap_or(false); rsx! { input { r#type: "checkbox", id: "{field.id}", name: "{field.id}", + checked, required: field.required, } } }, FieldType::Select { options, multiple } => { + let default = field + .default_value + .as_ref() + .and_then(|v| v.as_str()) + .unwrap_or(""); rsx! { select { id: "{field.id}", name: "{field.id}", multiple: *multiple, required: field.required, - option { value: "", disabled: true, selected: true, "Select…" } + option { + value: "", + disabled: true, + selected: default.is_empty(), + "Select…" + } for opt in options { - option { value: "{opt.value}", "{opt.label}" } + option { + value: "{opt.value}", + selected: opt.value == default, + "{opt.label}" + } } } } }, FieldType::Radio { options } => { + let default = field + .default_value + .as_ref() + .and_then(|v| v.as_str()) + .unwrap_or(""); rsx! { fieldset { id: "{field.id}", @@ -820,6 +1434,7 @@ fn render_form_field(field: &pinakes_plugin_api::FormField) -> Element { r#type: "radio", name: "{field.id}", value: "{opt.value}", + checked: opt.value == default, required: field.required, } " {opt.label}" @@ -829,21 +1444,33 @@ fn render_form_field(field: &pinakes_plugin_api::FormField) -> Element { } }, FieldType::Date => { + let default = field + .default_value + .as_ref() + .and_then(|v| v.as_str()) + .unwrap_or(""); rsx! { input { r#type: "date", id: "{field.id}", name: "{field.id}", + value: "{default}", required: field.required, } } }, FieldType::DateTime => { + let default = field + .default_value + .as_ref() + .and_then(|v| v.as_str()) + .unwrap_or(""); rsx! { input { r#type: "datetime-local", id: "{field.id}", name: "{field.id}", + value: "{default}", required: field.required, } } @@ -851,6 +1478,8 @@ fn render_form_field(field: &pinakes_plugin_api::FormField) -> Element { FieldType::File { accept, multiple, .. } => { + // File inputs cannot carry a default value (browser security + // restriction). rsx! { input { r#type: "file", @@ -944,408 +1573,48 @@ fn resolve_text_content( content: &TextContent, ctx: &serde_json::Value, ) -> String { + use super::expr::evaluate_expression; match content { TextContent::Static(s) => s.clone(), - TextContent::Expression(expr) => evaluate_expression(expr, ctx).to_string(), + TextContent::Expression(expr) => { + value_to_display_string(&evaluate_expression(expr, ctx)) + }, TextContent::Empty => String::new(), } } -fn evaluate_expression( - expr: &Expression, - ctx: &serde_json::Value, -) -> serde_json::Value { - match expr { - Expression::Literal(v) => v.clone(), - Expression::Path(path) => { - let mut current = ctx; - for key in path.split('.') { - match current { - serde_json::Value::Object(map) => { - if let Some(next) = map.get(key) { - current = next; - } else { - return serde_json::Value::Null; - } - }, - serde_json::Value::Array(arr) => { - if let Ok(idx) = key.parse::() { - if let Some(item) = arr.get(idx) { - current = item; - } else { - return serde_json::Value::Null; - } - } else { - return serde_json::Value::Null; - } - }, - _ => return serde_json::Value::Null, - } - } - current.clone() - }, - Expression::Operation { left, op, right } => { - use pinakes_plugin_api::Operator; - let l = evaluate_expression(left, ctx); - let r = evaluate_expression(right, ctx); - match op { - Operator::Eq => serde_json::Value::Bool(l == r), - Operator::Ne => serde_json::Value::Bool(l != r), - Operator::And => { - serde_json::Value::Bool( - l.as_bool().unwrap_or(false) && r.as_bool().unwrap_or(false), - ) - }, - Operator::Or => { - serde_json::Value::Bool( - l.as_bool().unwrap_or(false) || r.as_bool().unwrap_or(false), - ) - }, - Operator::Gt => { - let lf = l.as_f64().unwrap_or(0.0); - let rf = r.as_f64().unwrap_or(0.0); - serde_json::Value::Bool(lf > rf) - }, - Operator::Gte => { - let lf = l.as_f64().unwrap_or(0.0); - let rf = r.as_f64().unwrap_or(0.0); - serde_json::Value::Bool(lf >= rf) - }, - Operator::Lt => { - let lf = l.as_f64().unwrap_or(0.0); - let rf = r.as_f64().unwrap_or(0.0); - serde_json::Value::Bool(lf < rf) - }, - Operator::Lte => { - let lf = l.as_f64().unwrap_or(0.0); - let rf = r.as_f64().unwrap_or(0.0); - serde_json::Value::Bool(lf <= rf) - }, - _ => serde_json::Value::Null, - } - }, - Expression::Call { .. } => serde_json::Value::Null, - } -} - -fn evaluate_expression_as_bool( - expr: &Expression, - ctx: &serde_json::Value, -) -> bool { - evaluate_expression(expr, ctx).as_bool().unwrap_or(false) -} - -fn evaluate_expression_as_f64( - expr: &Expression, - ctx: &serde_json::Value, -) -> f64 { - evaluate_expression(expr, ctx).as_f64().unwrap_or(0.0) -} - fn extract_cell(row: &serde_json::Value, key: &str) -> String { - row - .as_object() - .and_then(|obj| obj.get(key)) - .map(|v| { - match v { - serde_json::Value::String(s) => s.clone(), - serde_json::Value::Number(n) => n.to_string(), - serde_json::Value::Bool(b) => b.to_string(), - serde_json::Value::Null => String::new(), - other => other.to_string(), - } - }) + // Use get_json_path so column keys support dot-notation (e.g. "author.name") + super::expr::get_json_path(row, key) + .map(value_to_display_string) .unwrap_or_default() } +/// Validate and normalize a plugin-supplied column width value. +/// Accepts: bare integer (adds px), `{n}px`, `{n}%`, or `"auto"`. +/// Rejects anything else to prevent CSS injection. +fn safe_col_width_css(w: &str) -> Option { + if w == "auto" { + return Some("auto".to_string()); + } + if let Ok(n) = w.parse::() { + return Some(format!("{n}px")); + } + if let Some(num) = w.strip_suffix("px").and_then(|n| n.parse::().ok()) { + return Some(format!("{num}px")); + } + if let Some(num) = w.strip_suffix('%').and_then(|n| n.parse::().ok()) { + return Some(format!("{num}%")); + } + None +} + #[cfg(test)] mod tests { - use pinakes_plugin_api::{Expression, Operator}; + use pinakes_plugin_api::Expression; use super::*; - fn lit(v: serde_json::Value) -> Box { - Box::new(Expression::Literal(v)) - } - - fn op_expr( - left: serde_json::Value, - op: Operator, - right: serde_json::Value, - ) -> Expression { - Expression::Operation { - left: lit(left), - op, - right: lit(right), - } - } - - #[test] - fn test_evaluate_expression_literal() { - let expr = Expression::Literal(serde_json::json!("hello")); - let result = evaluate_expression(&expr, &serde_json::json!({})); - assert_eq!(result, serde_json::json!("hello")); - } - - #[test] - fn test_evaluate_expression_literal_number() { - let expr = Expression::Literal(serde_json::json!(3.14)); - let result = evaluate_expression(&expr, &serde_json::json!({})); - assert_eq!(result, serde_json::json!(3.14)); - } - - #[test] - fn test_evaluate_expression_path() { - let expr = Expression::Path("foo.bar".to_string()); - let ctx = serde_json::json!({ "foo": { "bar": 42 } }); - let result = evaluate_expression(&expr, &ctx); - assert_eq!(result, serde_json::json!(42)); - } - - #[test] - fn test_evaluate_expression_path_missing_key() { - let expr = Expression::Path("foo.missing".to_string()); - let ctx = serde_json::json!({ "foo": { "bar": 1 } }); - let result = evaluate_expression(&expr, &ctx); - assert_eq!(result, serde_json::Value::Null); - } - - #[test] - fn test_evaluate_expression_path_array_index() { - let expr = Expression::Path("items.1".to_string()); - let ctx = serde_json::json!({ "items": ["a", "b", "c"] }); - let result = evaluate_expression(&expr, &ctx); - assert_eq!(result, serde_json::json!("b")); - } - - #[test] - fn test_evaluate_expression_path_array_out_of_bounds() { - let expr = Expression::Path("items.9".to_string()); - let ctx = serde_json::json!({ "items": ["a"] }); - let result = evaluate_expression(&expr, &ctx); - assert_eq!(result, serde_json::Value::Null); - } - - #[test] - fn test_evaluate_expression_path_on_scalar() { - let expr = Expression::Path("x.y".to_string()); - let ctx = serde_json::json!({ "x": 42 }); - let result = evaluate_expression(&expr, &ctx); - assert_eq!(result, serde_json::Value::Null); - } - - #[test] - fn test_evaluate_expression_eq_true() { - let expr = - op_expr(serde_json::json!(1), Operator::Eq, serde_json::json!(1)); - let result = evaluate_expression(&expr, &serde_json::json!({})); - assert_eq!(result, serde_json::json!(true)); - } - - #[test] - fn test_evaluate_expression_eq_false() { - let expr = - op_expr(serde_json::json!(1), Operator::Eq, serde_json::json!(2)); - let result = evaluate_expression(&expr, &serde_json::json!({})); - assert_eq!(result, serde_json::json!(false)); - } - - #[test] - fn test_evaluate_expression_ne() { - let expr = - op_expr(serde_json::json!("a"), Operator::Ne, serde_json::json!("b")); - assert_eq!( - evaluate_expression(&expr, &serde_json::json!({})), - serde_json::json!(true) - ); - let expr2 = - op_expr(serde_json::json!("a"), Operator::Ne, serde_json::json!("a")); - assert_eq!( - evaluate_expression(&expr2, &serde_json::json!({})), - serde_json::json!(false) - ); - } - - #[test] - fn test_evaluate_expression_gt() { - let expr = - op_expr(serde_json::json!(5), Operator::Gt, serde_json::json!(3)); - assert_eq!( - evaluate_expression(&expr, &serde_json::json!({})), - serde_json::json!(true) - ); - let expr2 = - op_expr(serde_json::json!(3), Operator::Gt, serde_json::json!(5)); - assert_eq!( - evaluate_expression(&expr2, &serde_json::json!({})), - serde_json::json!(false) - ); - } - - #[test] - fn test_evaluate_expression_gte() { - let expr = - op_expr(serde_json::json!(5), Operator::Gte, serde_json::json!(5)); - assert_eq!( - evaluate_expression(&expr, &serde_json::json!({})), - serde_json::json!(true) - ); - let expr2 = - op_expr(serde_json::json!(4), Operator::Gte, serde_json::json!(5)); - assert_eq!( - evaluate_expression(&expr2, &serde_json::json!({})), - serde_json::json!(false) - ); - } - - #[test] - fn test_evaluate_expression_lt() { - let expr = - op_expr(serde_json::json!(3), Operator::Lt, serde_json::json!(5)); - assert_eq!( - evaluate_expression(&expr, &serde_json::json!({})), - serde_json::json!(true) - ); - let expr2 = - op_expr(serde_json::json!(5), Operator::Lt, serde_json::json!(3)); - assert_eq!( - evaluate_expression(&expr2, &serde_json::json!({})), - serde_json::json!(false) - ); - } - - #[test] - fn test_evaluate_expression_lte() { - let expr = - op_expr(serde_json::json!(5), Operator::Lte, serde_json::json!(5)); - assert_eq!( - evaluate_expression(&expr, &serde_json::json!({})), - serde_json::json!(true) - ); - let expr2 = - op_expr(serde_json::json!(6), Operator::Lte, serde_json::json!(5)); - assert_eq!( - evaluate_expression(&expr2, &serde_json::json!({})), - serde_json::json!(false) - ); - } - - #[test] - fn test_evaluate_expression_and() { - let expr = op_expr( - serde_json::json!(true), - Operator::And, - serde_json::json!(true), - ); - assert_eq!( - evaluate_expression(&expr, &serde_json::json!({})), - serde_json::json!(true) - ); - let expr2 = op_expr( - serde_json::json!(true), - Operator::And, - serde_json::json!(false), - ); - assert_eq!( - evaluate_expression(&expr2, &serde_json::json!({})), - serde_json::json!(false) - ); - } - - #[test] - fn test_evaluate_expression_or() { - let expr = op_expr( - serde_json::json!(false), - Operator::Or, - serde_json::json!(true), - ); - assert_eq!( - evaluate_expression(&expr, &serde_json::json!({})), - serde_json::json!(true) - ); - let expr2 = op_expr( - serde_json::json!(false), - Operator::Or, - serde_json::json!(false), - ); - assert_eq!( - evaluate_expression(&expr2, &serde_json::json!({})), - serde_json::json!(false) - ); - } - - #[test] - fn test_evaluate_expression_unimplemented_ops_return_null() { - for op in [ - Operator::Concat, - Operator::Add, - Operator::Sub, - Operator::Mul, - Operator::Div, - ] { - let expr = op_expr(serde_json::json!(1), op, serde_json::json!(2)); - assert_eq!( - evaluate_expression(&expr, &serde_json::json!({})), - serde_json::Value::Null - ); - } - } - - #[test] - fn test_evaluate_expression_call_returns_null() { - let expr = Expression::Call { - function: "unknown".to_string(), - args: vec![], - }; - assert_eq!( - evaluate_expression(&expr, &serde_json::json!({})), - serde_json::Value::Null - ); - } - - #[test] - fn test_evaluate_expression_as_bool_true() { - let expr = Expression::Literal(serde_json::json!(true)); - assert!(evaluate_expression_as_bool(&expr, &serde_json::json!({}))); - } - - #[test] - fn test_evaluate_expression_as_bool_false() { - let expr = Expression::Literal(serde_json::json!(false)); - assert!(!evaluate_expression_as_bool(&expr, &serde_json::json!({}))); - } - - #[test] - fn test_evaluate_expression_as_bool_non_bool_returns_false() { - let expr = Expression::Literal(serde_json::json!("yes")); - assert!(!evaluate_expression_as_bool(&expr, &serde_json::json!({}))); - } - - #[test] - fn test_evaluate_expression_as_f64() { - let expr = Expression::Literal(serde_json::json!(7.5)); - assert_eq!( - evaluate_expression_as_f64(&expr, &serde_json::json!({})), - 7.5 - ); - } - - #[test] - fn test_evaluate_expression_as_f64_non_numeric_returns_zero() { - let expr = Expression::Literal(serde_json::json!("text")); - assert_eq!( - evaluate_expression_as_f64(&expr, &serde_json::json!({})), - 0.0 - ); - } - - #[test] - fn test_evaluate_expression_as_f64_from_path() { - let expr = Expression::Path("score".to_string()); - let ctx = serde_json::json!({ "score": 42 }); - assert_eq!(evaluate_expression_as_f64(&expr, &ctx), 42.0); - } - #[test] fn test_extract_cell_string() { let row = serde_json::json!({ "name": "Alice", "count": 5 }); @@ -1393,12 +1662,11 @@ mod tests { } #[test] - fn test_resolve_text_content_expression() { + fn test_resolve_text_content_expression_string() { + // String values are returned raw (no JSON quoting) let tc = TextContent::Expression(Expression::Path("user.name".to_string())); let ctx = serde_json::json!({ "user": { "name": "Bob" } }); - // evaluate_expression returns a serde_json::Value; .to_string() - // JSON-encodes it - assert_eq!(resolve_text_content(&tc, &ctx), "\"Bob\""); + assert_eq!(resolve_text_content(&tc, &ctx), "Bob"); } #[test] @@ -1409,9 +1677,10 @@ mod tests { } #[test] - fn test_resolve_text_content_expression_missing() { + fn test_resolve_text_content_expression_missing_returns_empty() { + // Null values resolve to empty string (not "null") let tc = TextContent::Expression(Expression::Path("missing".to_string())); - assert_eq!(resolve_text_content(&tc, &serde_json::json!({})), "null"); + assert_eq!(resolve_text_content(&tc, &serde_json::json!({})), ""); } #[test] @@ -1505,4 +1774,37 @@ mod tests { assert_eq!(text_variant_class(&TextVariant::Small), "text-small"); assert_eq!(text_variant_class(&TextVariant::Large), "text-large"); } + + #[test] + fn test_safe_col_width_css_auto() { + assert_eq!(safe_col_width_css("auto"), Some("auto".to_string())); + } + + #[test] + fn test_safe_col_width_css_bare_integer() { + assert_eq!(safe_col_width_css("100"), Some("100px".to_string())); + assert_eq!(safe_col_width_css("0"), Some("0px".to_string())); + } + + #[test] + fn test_safe_col_width_css_px_suffix() { + assert_eq!(safe_col_width_css("150px"), Some("150px".to_string())); + assert_eq!(safe_col_width_css("0px"), Some("0px".to_string())); + } + + #[test] + fn test_safe_col_width_css_percent_suffix() { + assert_eq!(safe_col_width_css("20%"), Some("20%".to_string())); + assert_eq!(safe_col_width_css("100%"), Some("100%".to_string())); + } + + #[test] + fn test_safe_col_width_css_rejects_unsafe() { + assert_eq!(safe_col_width_css(""), None); + assert_eq!(safe_col_width_css("1em"), None); + assert_eq!(safe_col_width_css("1rem"), None); + assert_eq!(safe_col_width_css("expression(alert(1))"), None); + assert_eq!(safe_col_width_css("-1px"), None); + assert_eq!(safe_col_width_css("100px; color: red"), None); + } } -- 2.43.0 From 5d7076426c39042711b0a9c307b2f7bd6aaefe73 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Wed, 11 Mar 2026 17:01:22 +0300 Subject: [PATCH 25/46] pinakes-ui: add special actions; add modal control to action executor Signed-off-by: NotAShelf Change-Id: If2e94d303e1e86f5e6cd7589c9ff58356a6a6964 --- crates/pinakes-ui/src/plugin_ui/actions.rs | 175 +++++++++++++++++++-- 1 file changed, 158 insertions(+), 17 deletions(-) diff --git a/crates/pinakes-ui/src/plugin_ui/actions.rs b/crates/pinakes-ui/src/plugin_ui/actions.rs index f123359..8c9eb64 100644 --- a/crates/pinakes-ui/src/plugin_ui/actions.rs +++ b/crates/pinakes-ui/src/plugin_ui/actions.rs @@ -3,8 +3,17 @@ //! This module provides the action execution system that handles //! user interactions with plugin UI elements. -use pinakes_plugin_api::{ActionDefinition, ActionRef, HttpMethod}; +use std::collections::HashMap; +use pinakes_plugin_api::{ + ActionDefinition, + ActionRef, + Expression, + SpecialAction, + UiElement, +}; + +use super::data::to_reqwest_method; use crate::client::ApiClient; /// Result of an action execution @@ -18,18 +27,62 @@ pub enum ActionResult { Navigate(String), /// No meaningful result (e.g. 204 No Content) None, + /// Re-fetch all data sources for the current page + Refresh, + /// Update a local state key; value is kept as an unevaluated expression so + /// the renderer can resolve it against the full data context. + UpdateState { + key: String, + value_expr: Expression, + }, + /// Open a modal overlay containing the given element + OpenModal(UiElement), + /// Close the currently open modal overlay + CloseModal, } /// Execute an action defined in the UI schema +/// +/// `page_actions` is the map of named actions from the current page definition. +/// `ActionRef::Name` entries are resolved against this map. pub async fn execute_action( client: &ApiClient, action_ref: &ActionRef, + page_actions: &HashMap, form_data: Option<&serde_json::Value>, ) -> Result { match action_ref { + ActionRef::Special(special) => { + match special { + SpecialAction::Refresh => Ok(ActionResult::Refresh), + SpecialAction::Navigate { to } => { + Ok(ActionResult::Navigate(to.clone())) + }, + SpecialAction::Emit { event, payload } => { + if let Err(e) = client.post_plugin_event(event, payload).await { + tracing::warn!(event = %event, "plugin emit failed: {e}"); + } + Ok(ActionResult::None) + }, + SpecialAction::UpdateState { key, value } => { + Ok(ActionResult::UpdateState { + key: key.clone(), + value_expr: value.clone(), + }) + }, + SpecialAction::OpenModal { content } => { + Ok(ActionResult::OpenModal(*content.clone())) + }, + SpecialAction::CloseModal => Ok(ActionResult::CloseModal), + } + }, ActionRef::Name(name) => { - tracing::warn!("Named action '{}' not implemented yet", name); - Ok(ActionResult::None) + if let Some(action) = page_actions.get(name) { + execute_inline_action(client, action, form_data).await + } else { + tracing::warn!(action = %name, "Unknown action - not defined in page actions"); + Ok(ActionResult::None) + } }, ActionRef::Inline(action) => { execute_inline_action(client, action, form_data).await @@ -48,13 +101,7 @@ async fn execute_inline_action( // Merge action params with form data into query string for GET, body for // others - let method = match action.method { - HttpMethod::Get => reqwest::Method::GET, - HttpMethod::Post => reqwest::Method::POST, - HttpMethod::Put => reqwest::Method::PUT, - HttpMethod::Patch => reqwest::Method::PATCH, - HttpMethod::Delete => reqwest::Method::DELETE, - }; + let method = to_reqwest_method(&action.method); let mut request = client.raw_request(method.clone(), &url); @@ -82,12 +129,11 @@ async fn execute_inline_action( .iter() .map(|(k, v)| (k.clone(), v.clone())) .collect(); + // action.params take precedence; form_data only fills in missing keys - if let Some(fd) = form_data { - if let Some(obj) = fd.as_object() { - for (k, v) in obj { - merged.entry(k.clone()).or_insert_with(|| v.clone()); - } + if let Some(obj) = form_data.and_then(serde_json::Value::as_object) { + for (k, v) in obj { + merged.entry(k.clone()).or_insert_with(|| v.clone()); } } if !merged.is_empty() { @@ -178,10 +224,105 @@ mod tests { } #[tokio::test] - async fn test_named_action_ref_returns_none() { + async fn test_named_action_unknown_returns_none() { let client = crate::client::ApiClient::default(); let action_ref = ActionRef::Name("my-action".to_string()); - let result = execute_action(&client, &action_ref, None).await.unwrap(); + let result = execute_action(&client, &action_ref, &HashMap::new(), None) + .await + .unwrap(); assert!(matches!(result, ActionResult::None)); } + + #[tokio::test] + async fn test_named_action_resolves_from_map() { + use pinakes_plugin_api::ActionDefinition; + + let client = crate::client::ApiClient::default(); + let mut page_actions = HashMap::new(); + page_actions.insert("do-thing".to_string(), ActionDefinition { + method: pinakes_plugin_api::HttpMethod::Post, + path: "/api/v1/nonexistent-endpoint".to_string(), + params: HashMap::new(), + success_message: None, + error_message: None, + navigate_to: None, + }); + + let action_ref = ActionRef::Name("do-thing".to_string()); + + // The action is resolved; will error because there's no server, but + // ActionResult::None would mean it was NOT resolved + let result = + execute_action(&client, &action_ref, &page_actions, None).await; + // It should NOT be Ok(None); it should be either Error or a network error + match result { + Ok(ActionResult::None) => { + panic!("Named action was not resolved from page_actions") + }, + _ => {}, /* Any other result (error, network failure) means it was + * resolved */ + } + } + + #[tokio::test] + async fn test_special_action_refresh() { + use pinakes_plugin_api::SpecialAction; + + let client = crate::client::ApiClient::default(); + let action_ref = ActionRef::Special(SpecialAction::Refresh); + let result = execute_action(&client, &action_ref, &HashMap::new(), None) + .await + .unwrap(); + assert!(matches!(result, ActionResult::Refresh)); + } + + #[tokio::test] + async fn test_special_action_navigate() { + use pinakes_plugin_api::SpecialAction; + + let client = crate::client::ApiClient::default(); + let action_ref = ActionRef::Special(SpecialAction::Navigate { + to: "/dashboard".to_string(), + }); + let result = execute_action(&client, &action_ref, &HashMap::new(), None) + .await + .unwrap(); + assert!( + matches!(result, ActionResult::Navigate(ref p) if p == "/dashboard") + ); + } + + #[tokio::test] + async fn test_special_action_update_state_preserves_expression() { + use pinakes_plugin_api::{Expression, SpecialAction}; + + let client = crate::client::ApiClient::default(); + let expr = Expression::Literal(serde_json::json!(42)); + let action_ref = ActionRef::Special(SpecialAction::UpdateState { + key: "count".to_string(), + value: expr.clone(), + }); + let result = execute_action(&client, &action_ref, &HashMap::new(), None) + .await + .unwrap(); + match result { + ActionResult::UpdateState { key, value_expr } => { + assert_eq!(key, "count"); + assert_eq!(value_expr, expr); + }, + other => panic!("expected UpdateState, got {other:?}"), + } + } + + #[tokio::test] + async fn test_special_action_close_modal() { + use pinakes_plugin_api::SpecialAction; + + let client = crate::client::ApiClient::default(); + let action_ref = ActionRef::Special(SpecialAction::CloseModal); + let result = execute_action(&client, &action_ref, &HashMap::new(), None) + .await + .unwrap(); + assert!(matches!(result, ActionResult::CloseModal)); + } } -- 2.43.0 From 0baa57d48da28fd4257131223983199cd6766260 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Wed, 11 Mar 2026 17:02:29 +0300 Subject: [PATCH 26/46] pinakes-ui: add `SettingsSection` widget target; align location strings with schema constants Signed-off-by: NotAShelf Change-Id: I9a5b91457136254fdf5fa582899079e46a6a6964 --- crates/pinakes-ui/src/plugin_ui/widget.rs | 79 +++++++++++++++++++---- 1 file changed, 65 insertions(+), 14 deletions(-) diff --git a/crates/pinakes-ui/src/plugin_ui/widget.rs b/crates/pinakes-ui/src/plugin_ui/widget.rs index 35ba4fa..362367c 100644 --- a/crates/pinakes-ui/src/plugin_ui/widget.rs +++ b/crates/pinakes-ui/src/plugin_ui/widget.rs @@ -4,10 +4,15 @@ //! predefined locations. Unlike full pages, widgets have no data sources of //! their own and render with empty data context. -use dioxus::prelude::*; -use pinakes_plugin_api::UiWidget; +use std::collections::HashMap; -use super::{data::PluginPageData, renderer::render_element}; +use dioxus::prelude::*; +use pinakes_plugin_api::{ActionDefinition, UiWidget, widget_location}; + +use super::{ + data::PluginPageData, + renderer::{RenderContext, render_element}, +}; use crate::client::ApiClient; /// Predefined injection points in the host UI. @@ -21,6 +26,7 @@ pub enum WidgetLocation { LibrarySidebar, DetailPanel, SearchFilters, + SettingsSection, } impl WidgetLocation { @@ -28,10 +34,11 @@ impl WidgetLocation { #[must_use] pub const fn as_str(self) -> &'static str { match self { - Self::LibraryHeader => "library_header", - Self::LibrarySidebar => "library_sidebar", - Self::DetailPanel => "detail_panel", - Self::SearchFilters => "search_filters", + Self::LibraryHeader => widget_location::LIBRARY_HEADER, + Self::LibrarySidebar => widget_location::LIBRARY_SIDEBAR, + Self::DetailPanel => widget_location::DETAIL_PANEL, + Self::SearchFilters => widget_location::SEARCH_FILTERS, + Self::SettingsSection => widget_location::SETTINGS_SECTION, } } } @@ -41,11 +48,13 @@ impl WidgetLocation { pub struct WidgetContainerProps { /// Injection point to render widgets for. pub location: WidgetLocation, - /// All widgets from all plugins (plugin_id, widget) pairs. - pub widgets: Vec<(String, UiWidget)>, - /// API client (unused by widgets themselves but threaded through for - /// consistency with the rest of the plugin UI system). - pub client: Signal, + + /// All widgets from all plugins (`plugin_id`, widget) pairs. + pub widgets: Vec<(String, UiWidget)>, + + /// API client. It is actually unused by widgets themselves but threaded + /// through for consistency with the rest of the plugin UI system. + pub client: Signal, } /// Renders all widgets registered for a specific [`WidgetLocation`]. @@ -107,12 +116,54 @@ pub struct WidgetViewRendererProps { #[component] pub fn WidgetViewRenderer(props: WidgetViewRendererProps) -> Element { let empty_data = PluginPageData::default(); + let feedback = use_signal(|| None::<(String, bool)>); + let navigate = use_signal(|| None::); + let refresh = use_signal(|| 0u32); + let modal = use_signal(|| None::); + let local_state = use_signal(HashMap::::new); + let ctx = RenderContext { + client: props.client, + feedback, + navigate, + refresh, + modal, + local_state, + }; + let empty_actions: HashMap = HashMap::new(); rsx! { div { class: "plugin-widget", "data-plugin-id": props.plugin_id.clone(), - "data-widget-id": props.widget.id.clone(), - { render_element(&props.widget.content, &empty_data, props.client) } + "data-widget-id": props.widget.id, + { render_element(&props.widget.content, &empty_data, &empty_actions, ctx) } } } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_widget_location_settings_section_str() { + assert_eq!(WidgetLocation::SettingsSection.as_str(), "settings_section"); + } + + #[test] + fn test_widget_location_all_variants_unique() { + let locations = [ + WidgetLocation::LibraryHeader, + WidgetLocation::LibrarySidebar, + WidgetLocation::DetailPanel, + WidgetLocation::SearchFilters, + WidgetLocation::SettingsSection, + ]; + let strings: Vec<&str> = locations.iter().map(|l| l.as_str()).collect(); + let unique: std::collections::HashSet<_> = strings.iter().collect(); + assert_eq!( + strings.len(), + unique.len(), + "all location strings must be unique" + ); + } +} -- 2.43.0 From 5077e9f1177baf66cbbd72ff7ca108911e846cbe Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Wed, 11 Mar 2026 17:06:58 +0300 Subject: [PATCH 27/46] pinakes-ui: extract expression evaluation into dedicated module Signed-off-by: NotAShelf Change-Id: I4d4901c4701e8ae446dbc76b457c058d6a6a6964 --- crates/pinakes-ui/src/plugin_ui/expr.rs | 1015 +++++++++++++++++++++++ crates/pinakes-ui/src/plugin_ui/mod.rs | 1 + 2 files changed, 1016 insertions(+) create mode 100644 crates/pinakes-ui/src/plugin_ui/expr.rs diff --git a/crates/pinakes-ui/src/plugin_ui/expr.rs b/crates/pinakes-ui/src/plugin_ui/expr.rs new file mode 100644 index 0000000..055c145 --- /dev/null +++ b/crates/pinakes-ui/src/plugin_ui/expr.rs @@ -0,0 +1,1015 @@ +//! Expression evaluation for plugin UI schemas +//! +//! Provides utilities for evaluating [`Expression`] values against a JSON data +//! context. + +use pinakes_plugin_api::{Expression, Operator}; + +/// Evaluate an expression against a JSON context, returning a JSON value. +/// +/// - `Literal(v)`: returns `v` unchanged +/// - `Path("a.b.1")`: walks the context by dotted key/index segments +/// - `Operation { left, op, right }`: evaluates both sides and applies `op` +/// - `Call { function, args }`: dispatches to a built-in function +pub fn evaluate_expression( + expr: &Expression, + ctx: &serde_json::Value, +) -> serde_json::Value { + match expr { + Expression::Literal(v) => v.clone(), + Expression::Path(path) => { + get_json_path(ctx, path) + .cloned() + .unwrap_or(serde_json::Value::Null) + }, + Expression::Operation { left, op, right } => { + let l = evaluate_expression(left, ctx); + let r = evaluate_expression(right, ctx); + match op { + Operator::Eq => serde_json::Value::Bool(l == r), + Operator::Ne => serde_json::Value::Bool(l != r), + Operator::And => { + serde_json::Value::Bool( + l.as_bool().unwrap_or(false) && r.as_bool().unwrap_or(false), + ) + }, + Operator::Or => { + serde_json::Value::Bool( + l.as_bool().unwrap_or(false) || r.as_bool().unwrap_or(false), + ) + }, + Operator::Gt => { + serde_json::Value::Bool( + l.as_f64().unwrap_or(0.0) > r.as_f64().unwrap_or(0.0), + ) + }, + Operator::Gte => { + serde_json::Value::Bool( + l.as_f64().unwrap_or(0.0) >= r.as_f64().unwrap_or(0.0), + ) + }, + Operator::Lt => { + serde_json::Value::Bool( + l.as_f64().unwrap_or(0.0) < r.as_f64().unwrap_or(0.0), + ) + }, + Operator::Lte => { + serde_json::Value::Bool( + l.as_f64().unwrap_or(0.0) <= r.as_f64().unwrap_or(0.0), + ) + }, + Operator::Add => { + let result = l.as_f64().unwrap_or(0.0) + r.as_f64().unwrap_or(0.0); + serde_json::json!(result) + }, + Operator::Sub => { + let result = l.as_f64().unwrap_or(0.0) - r.as_f64().unwrap_or(0.0); + serde_json::json!(result) + }, + Operator::Mul => { + let result = l.as_f64().unwrap_or(0.0) * r.as_f64().unwrap_or(0.0); + serde_json::json!(result) + }, + Operator::Div => { + let divisor = r.as_f64().unwrap_or(0.0); + if divisor == 0.0 { + serde_json::json!(0.0) + } else { + serde_json::json!(l.as_f64().unwrap_or(0.0) / divisor) + } + }, + Operator::Concat => { + let result = format!( + "{}{}", + value_to_display_string(&l), + value_to_display_string(&r) + ); + serde_json::Value::String(result) + }, + } + }, + Expression::Call { function, args } => { + let evaluated: Vec = + args.iter().map(|a| evaluate_expression(a, ctx)).collect(); + call_builtin(function, &evaluated) + }, + } +} + +/// Dispatch a built-in function call. +/// +/// Built-in functions: +/// - `len(value)`: length of array/string/object +/// - `upper(str)`: uppercase string +/// - `lower(str)`: lowercase string +/// - `trim(str)`: trim leading/trailing whitespace +/// - `format(template, ...args)`: replace `{}` placeholders left-to-right +/// - `join(array, sep)`: join array elements with separator +/// - `contains(haystack, needle)`: true if string contains substring or array +/// contains value +/// - `keys(object)`: array of object keys +/// - `values(object)`: array of object values +/// - `abs(number)`: absolute value +/// - `round(number)`: round to nearest integer +/// - `floor(number)`: round down +/// - `ceil(number)`: round up +/// - `not(value)`: boolean negation +/// - `coalesce(a, b, ...)`: first non-null argument +/// - `to_string(value)`: convert to string +/// - `to_number(value)`: parse string to number, or pass through number +/// - `slice(array_or_string, start[, end])`: sub-array or substring +/// - `reverse(array_or_string)`: reversed array or string +/// - `if(cond, then, else)`: conditional +/// +/// Unknown function names return `null`. +fn call_builtin(name: &str, args: &[serde_json::Value]) -> serde_json::Value { + match name { + "len" => { + let v = args.first().unwrap_or(&serde_json::Value::Null); + match v { + serde_json::Value::String(s) => serde_json::json!(s.chars().count()), + serde_json::Value::Array(a) => serde_json::json!(a.len()), + serde_json::Value::Object(o) => serde_json::json!(o.len()), + _ => serde_json::json!(0), + } + }, + "upper" => { + let s = value_to_display_string( + args.first().unwrap_or(&serde_json::Value::Null), + ); + serde_json::Value::String(s.to_uppercase()) + }, + "lower" => { + let s = value_to_display_string( + args.first().unwrap_or(&serde_json::Value::Null), + ); + serde_json::Value::String(s.to_lowercase()) + }, + "trim" => { + let s = value_to_display_string( + args.first().unwrap_or(&serde_json::Value::Null), + ); + serde_json::Value::String(s.trim().to_string()) + }, + "format" => { + // Replace `{}` placeholders left-to-right with subsequent arguments + let template = value_to_display_string( + args.first().unwrap_or(&serde_json::Value::Null), + ); + let mut result = String::new(); + let mut arg_idx = 1usize; + let mut chars = template.chars().peekable(); + while let Some(ch) = chars.next() { + if ch == '{' && chars.peek() == Some(&'}') { + chars.next(); // consume '}' + let replacement = args + .get(arg_idx) + .map(value_to_display_string) + .unwrap_or_default(); + result.push_str(&replacement); + arg_idx += 1; + } else { + result.push(ch); + } + } + serde_json::Value::String(result) + }, + "join" => { + let arr = args.first().unwrap_or(&serde_json::Value::Null); + let default_sep = serde_json::Value::String(",".to_string()); + let sep = value_to_display_string(args.get(1).unwrap_or(&default_sep)); + match arr { + serde_json::Value::Array(items) => { + let joined = items + .iter() + .map(value_to_display_string) + .collect::>() + .join(&sep); + serde_json::Value::String(joined) + }, + other => serde_json::Value::String(value_to_display_string(other)), + } + }, + "contains" => { + let haystack = args.first().unwrap_or(&serde_json::Value::Null); + let needle = args.get(1).unwrap_or(&serde_json::Value::Null); + match haystack { + serde_json::Value::String(s) => { + serde_json::Value::Bool(s.contains(&value_to_display_string(needle))) + }, + serde_json::Value::Array(arr) => { + serde_json::Value::Bool(arr.contains(needle)) + }, + _ => serde_json::Value::Bool(false), + } + }, + "keys" => { + let obj = args.first().unwrap_or(&serde_json::Value::Null); + match obj { + serde_json::Value::Object(map) => { + serde_json::Value::Array( + map + .keys() + .map(|k| serde_json::Value::String(k.clone())) + .collect(), + ) + }, + _ => serde_json::Value::Array(vec![]), + } + }, + "values" => { + let obj = args.first().unwrap_or(&serde_json::Value::Null); + match obj { + serde_json::Value::Object(map) => { + serde_json::Value::Array(map.values().cloned().collect()) + }, + _ => serde_json::Value::Array(vec![]), + } + }, + "abs" => { + let n = args + .first() + .unwrap_or(&serde_json::Value::Null) + .as_f64() + .unwrap_or(0.0); + serde_json::json!(n.abs()) + }, + "round" => { + let n = args + .first() + .unwrap_or(&serde_json::Value::Null) + .as_f64() + .unwrap_or(0.0); + serde_json::json!(n.round()) + }, + "floor" => { + let n = args + .first() + .unwrap_or(&serde_json::Value::Null) + .as_f64() + .unwrap_or(0.0); + serde_json::json!(n.floor()) + }, + "ceil" => { + let n = args + .first() + .unwrap_or(&serde_json::Value::Null) + .as_f64() + .unwrap_or(0.0); + serde_json::json!(n.ceil()) + }, + "not" => { + let b = args + .first() + .unwrap_or(&serde_json::Value::Null) + .as_bool() + .unwrap_or(false); + serde_json::Value::Bool(!b) + }, + "coalesce" => { + args + .iter() + .find(|v| !v.is_null()) + .cloned() + .unwrap_or(serde_json::Value::Null) + }, + "to_string" => { + let s = value_to_display_string( + args.first().unwrap_or(&serde_json::Value::Null), + ); + serde_json::Value::String(s) + }, + "to_number" => { + let v = args.first().unwrap_or(&serde_json::Value::Null); + match v { + serde_json::Value::Number(_) => v.clone(), + serde_json::Value::String(s) => { + s.parse::() + .map_or(serde_json::Value::Null, |n| serde_json::json!(n)) + }, + serde_json::Value::Bool(b) => { + serde_json::json!(if *b { 1.0 } else { 0.0 }) + }, + _ => serde_json::Value::Null, + } + }, + "slice" => { + let target = args.first().unwrap_or(&serde_json::Value::Null); + let start = args.get(1).and_then(serde_json::Value::as_i64).unwrap_or(0); + let end_opt = args.get(2).and_then(serde_json::Value::as_i64); + match target { + serde_json::Value::Array(arr) => { + let len = i64::try_from(arr.len()).unwrap_or(i64::MAX); + let s = usize::try_from(if start < 0 { + (len + start).max(0) + } else { + start.min(len) + }) + .unwrap_or(0); + let e = end_opt.map_or(arr.len(), |e| { + usize::try_from(if e < 0 { (len + e).max(0) } else { e.min(len) }) + .unwrap_or(0) + }); + if s >= e { + serde_json::Value::Array(vec![]) + } else { + serde_json::Value::Array(arr[s..e].to_vec()) + } + }, + serde_json::Value::String(st) => { + let chars: Vec = st.chars().collect(); + let len = i64::try_from(chars.len()).unwrap_or(i64::MAX); + let s = usize::try_from(if start < 0 { + (len + start).max(0) + } else { + start.min(len) + }) + .unwrap_or(0); + let e = end_opt.map_or(chars.len(), |e| { + usize::try_from(if e < 0 { (len + e).max(0) } else { e.min(len) }) + .unwrap_or(0) + }); + if s >= e { + serde_json::Value::String(String::new()) + } else { + serde_json::Value::String(chars[s..e].iter().collect()) + } + }, + _ => serde_json::Value::Null, + } + }, + "reverse" => { + let v = args.first().unwrap_or(&serde_json::Value::Null); + match v { + serde_json::Value::Array(arr) => { + serde_json::Value::Array(arr.iter().rev().cloned().collect()) + }, + serde_json::Value::String(s) => { + serde_json::Value::String(s.chars().rev().collect()) + }, + _ => v.clone(), + } + }, + "if" => { + let cond = args + .first() + .unwrap_or(&serde_json::Value::Null) + .as_bool() + .unwrap_or(false); + if cond { + args.get(1).cloned().unwrap_or(serde_json::Value::Null) + } else { + args.get(2).cloned().unwrap_or(serde_json::Value::Null) + } + }, + _ => { + tracing::warn!(function = %name, "Unknown built-in function in Call expression"); + serde_json::Value::Null + }, + } +} + +/// Evaluate an expression and coerce the result to `bool`. +/// +/// Returns `false` if the value is not a boolean. +pub fn evaluate_expression_as_bool( + expr: &Expression, + ctx: &serde_json::Value, +) -> bool { + evaluate_expression(expr, ctx).as_bool().unwrap_or(false) +} + +/// Evaluate an expression and coerce the result to `f64`. +/// +/// Returns `0.0` if the value is not numeric. +pub fn evaluate_expression_as_f64( + expr: &Expression, + ctx: &serde_json::Value, +) -> f64 { + evaluate_expression(expr, ctx).as_f64().unwrap_or(0.0) +} + +/// Convert a JSON value to a human-readable display string. +/// +/// - `String(s)`: returns the raw string without JSON quoting +/// - `Null`: returns an empty string +/// - everything else: uses [`serde_json::Value::to_string`] (JSON encoding) +pub fn value_to_display_string(v: &serde_json::Value) -> String { + match v { + serde_json::Value::String(s) => s.clone(), + serde_json::Value::Null => String::new(), + other => other.to_string(), + } +} + +/// Get a value from JSON by dot-notation path. +/// +/// Supports nested object keys and array indices: +/// - `"user.name"` resolves to `json["user"]["name"]` +/// - `"items.0.title"` resolves to `json["items"][0]["title"]` +#[must_use] +pub fn get_json_path<'a>( + value: &'a serde_json::Value, + path: &str, +) -> Option<&'a serde_json::Value> { + let mut current = value; + for key in path.split('.') { + match current { + serde_json::Value::Object(map) => { + current = map.get(key)?; + }, + serde_json::Value::Array(arr) => { + let idx = key.parse::().ok()?; + current = arr.get(idx)?; + }, + _ => return None, + } + } + Some(current) +} + +#[cfg(test)] +mod tests { + use pinakes_plugin_api::Operator; + + use super::*; + + fn lit(v: serde_json::Value) -> Box { + Box::new(Expression::Literal(v)) + } + + fn op_expr( + left: serde_json::Value, + op: Operator, + right: serde_json::Value, + ) -> Expression { + Expression::Operation { + left: lit(left), + op, + right: lit(right), + } + } + + // value_to_display_string + + #[test] + fn test_value_to_display_string_string() { + assert_eq!( + value_to_display_string(&serde_json::json!("hello")), + "hello" + ); + } + + #[test] + fn test_value_to_display_string_null() { + assert_eq!(value_to_display_string(&serde_json::Value::Null), ""); + } + + #[test] + fn test_value_to_display_string_number() { + assert_eq!(value_to_display_string(&serde_json::json!(42)), "42"); + } + + #[test] + fn test_value_to_display_string_bool() { + assert_eq!(value_to_display_string(&serde_json::json!(true)), "true"); + } + + // arithmetic operators + + #[test] + fn test_evaluate_expression_add() { + let expr = op_expr( + serde_json::json!(3.0), + Operator::Add, + serde_json::json!(4.0), + ); + let result = evaluate_expression(&expr, &serde_json::json!({})); + assert_eq!(result.as_f64().unwrap(), 7.0); + } + + #[test] + fn test_evaluate_expression_sub() { + let expr = op_expr( + serde_json::json!(10.0), + Operator::Sub, + serde_json::json!(3.0), + ); + let result = evaluate_expression(&expr, &serde_json::json!({})); + assert_eq!(result.as_f64().unwrap(), 7.0); + } + + #[test] + fn test_evaluate_expression_mul() { + let expr = op_expr( + serde_json::json!(3.0), + Operator::Mul, + serde_json::json!(4.0), + ); + let result = evaluate_expression(&expr, &serde_json::json!({})); + assert_eq!(result.as_f64().unwrap(), 12.0); + } + + #[test] + fn test_evaluate_expression_div() { + let expr = op_expr( + serde_json::json!(10.0), + Operator::Div, + serde_json::json!(2.0), + ); + let result = evaluate_expression(&expr, &serde_json::json!({})); + assert_eq!(result.as_f64().unwrap(), 5.0); + } + + #[test] + fn test_evaluate_expression_div_by_zero() { + let expr = op_expr( + serde_json::json!(10.0), + Operator::Div, + serde_json::json!(0.0), + ); + let result = evaluate_expression(&expr, &serde_json::json!({})); + assert_eq!(result.as_f64().unwrap(), 0.0); + } + + // Concat + + #[test] + fn test_evaluate_expression_concat_strings() { + let expr = op_expr( + serde_json::json!("Hello, "), + Operator::Concat, + serde_json::json!("World!"), + ); + let result = evaluate_expression(&expr, &serde_json::json!({})); + assert_eq!(result, serde_json::json!("Hello, World!")); + } + + #[test] + fn test_evaluate_expression_concat_with_number() { + let expr = op_expr( + serde_json::json!("Count: "), + Operator::Concat, + serde_json::json!(42), + ); + let result = evaluate_expression(&expr, &serde_json::json!({})); + assert_eq!(result, serde_json::json!("Count: 42")); + } + + #[test] + fn test_evaluate_expression_concat_with_null() { + let expr = op_expr( + serde_json::json!("Value: "), + Operator::Concat, + serde_json::Value::Null, + ); + let result = evaluate_expression(&expr, &serde_json::json!({})); + assert_eq!(result, serde_json::json!("Value: ")); + } + + // call built-ins + + fn call(function: &str, args: Vec) -> serde_json::Value { + let expr = Expression::Call { + function: function.to_string(), + args: args.into_iter().map(Expression::Literal).collect(), + }; + evaluate_expression(&expr, &serde_json::json!({})) + } + + #[test] + fn test_call_len_array() { + assert_eq!( + call("len", vec![serde_json::json!([1, 2, 3])]), + serde_json::json!(3) + ); + } + + #[test] + fn test_call_len_string() { + assert_eq!( + call("len", vec![serde_json::json!("hello")]), + serde_json::json!(5) + ); + } + + #[test] + fn test_call_len_object() { + assert_eq!( + call("len", vec![serde_json::json!({"a": 1, "b": 2})]), + serde_json::json!(2) + ); + } + + #[test] + fn test_call_upper() { + assert_eq!( + call("upper", vec![serde_json::json!("hello")]), + serde_json::json!("HELLO") + ); + } + + #[test] + fn test_call_lower() { + assert_eq!( + call("lower", vec![serde_json::json!("WORLD")]), + serde_json::json!("world") + ); + } + + #[test] + fn test_call_trim() { + assert_eq!( + call("trim", vec![serde_json::json!(" hi ")]), + serde_json::json!("hi") + ); + } + + #[test] + fn test_call_format_single_placeholder() { + assert_eq!( + call("format", vec![ + serde_json::json!("Hello, {}!"), + serde_json::json!("Bob") + ]), + serde_json::json!("Hello, Bob!") + ); + } + + #[test] + fn test_call_format_multiple_placeholders() { + assert_eq!( + call("format", vec![ + serde_json::json!("{} + {} = {}"), + serde_json::json!(1), + serde_json::json!(2), + serde_json::json!(3), + ]), + serde_json::json!("1 + 2 = 3") + ); + } + + #[test] + fn test_call_format_no_placeholders() { + assert_eq!( + call("format", vec![serde_json::json!("no placeholders")]), + serde_json::json!("no placeholders") + ); + } + + #[test] + fn test_call_join() { + assert_eq!( + call("join", vec![ + serde_json::json!(["a", "b", "c"]), + serde_json::json!(", ") + ]), + serde_json::json!("a, b, c") + ); + } + + #[test] + fn test_call_join_non_array() { + assert_eq!( + call("join", vec![ + serde_json::json!("hello"), + serde_json::json!(",") + ]), + serde_json::json!("hello") + ); + } + + #[test] + fn test_call_contains_string() { + assert_eq!( + call("contains", vec![ + serde_json::json!("hello world"), + serde_json::json!("world") + ]), + serde_json::json!(true) + ); + assert_eq!( + call("contains", vec![ + serde_json::json!("hello world"), + serde_json::json!("xyz") + ]), + serde_json::json!(false) + ); + } + + #[test] + fn test_call_contains_array() { + assert_eq!( + call("contains", vec![ + serde_json::json!([1, 2, 3]), + serde_json::json!(2) + ]), + serde_json::json!(true) + ); + assert_eq!( + call("contains", vec![ + serde_json::json!([1, 2, 3]), + serde_json::json!(9) + ]), + serde_json::json!(false) + ); + } + + #[test] + fn test_call_keys() { + let result = call("keys", vec![serde_json::json!({"b": 2, "a": 1})]); + let mut keys: Vec = result + .as_array() + .unwrap() + .iter() + .map(|v| v.as_str().unwrap().to_string()) + .collect(); + keys.sort(); + assert_eq!(keys, vec!["a", "b"]); + } + + #[test] + fn test_call_keys_non_object() { + assert_eq!( + call("keys", vec![serde_json::json!(42)]), + serde_json::json!([]) + ); + } + + #[test] + fn test_call_values_non_object() { + assert_eq!( + call("values", vec![serde_json::json!("x")]), + serde_json::json!([]) + ); + } + + #[test] + fn test_call_abs() { + assert_eq!( + call("abs", vec![serde_json::json!(-5.0)]), + serde_json::json!(5.0) + ); + assert_eq!( + call("abs", vec![serde_json::json!(3.0)]), + serde_json::json!(3.0) + ); + } + + #[test] + fn test_call_round() { + assert_eq!( + call("round", vec![serde_json::json!(3.6)]), + serde_json::json!(4.0) + ); + assert_eq!( + call("round", vec![serde_json::json!(3.4)]), + serde_json::json!(3.0) + ); + } + + #[test] + fn test_call_floor() { + assert_eq!( + call("floor", vec![serde_json::json!(3.9)]), + serde_json::json!(3.0) + ); + } + + #[test] + fn test_call_ceil() { + assert_eq!( + call("ceil", vec![serde_json::json!(3.1)]), + serde_json::json!(4.0) + ); + } + + #[test] + fn test_call_not() { + assert_eq!( + call("not", vec![serde_json::json!(true)]), + serde_json::json!(false) + ); + assert_eq!( + call("not", vec![serde_json::json!(false)]), + serde_json::json!(true) + ); + } + + #[test] + fn test_call_coalesce() { + assert_eq!( + call("coalesce", vec![ + serde_json::Value::Null, + serde_json::json!("first"), + serde_json::json!("second"), + ]), + serde_json::json!("first") + ); + } + + #[test] + fn test_call_coalesce_all_null() { + assert_eq!( + call("coalesce", vec![ + serde_json::Value::Null, + serde_json::Value::Null + ]), + serde_json::Value::Null + ); + } + + #[test] + fn test_call_to_string() { + assert_eq!( + call("to_string", vec![serde_json::json!(42)]), + serde_json::json!("42") + ); + assert_eq!( + call("to_string", vec![serde_json::json!("hi")]), + serde_json::json!("hi") + ); + } + + #[test] + fn test_call_to_number_string() { + assert_eq!( + call("to_number", vec![serde_json::json!("3.14")]), + serde_json::json!(3.14) + ); + } + + #[test] + fn test_call_to_number_already_number() { + assert_eq!( + call("to_number", vec![serde_json::json!(42)]), + serde_json::json!(42) + ); + } + + #[test] + fn test_call_to_number_bool() { + assert_eq!( + call("to_number", vec![serde_json::json!(true)]), + serde_json::json!(1.0) + ); + assert_eq!( + call("to_number", vec![serde_json::json!(false)]), + serde_json::json!(0.0) + ); + } + + #[test] + fn test_call_to_number_invalid_string() { + assert_eq!( + call("to_number", vec![serde_json::json!("abc")]), + serde_json::Value::Null + ); + } + + #[test] + fn test_call_slice_array() { + assert_eq!( + call("slice", vec![ + serde_json::json!([1, 2, 3, 4, 5]), + serde_json::json!(1), + serde_json::json!(3) + ]), + serde_json::json!([2, 3]) + ); + } + + #[test] + fn test_call_slice_array_no_end() { + assert_eq!( + call("slice", vec![ + serde_json::json!([1, 2, 3]), + serde_json::json!(1) + ]), + serde_json::json!([2, 3]) + ); + } + + #[test] + fn test_call_slice_array_negative() { + assert_eq!( + call("slice", vec![ + serde_json::json!([1, 2, 3, 4]), + serde_json::json!(-2) + ]), + serde_json::json!([3, 4]) + ); + } + + #[test] + fn test_call_slice_string() { + assert_eq!( + call("slice", vec![ + serde_json::json!("hello"), + serde_json::json!(1), + serde_json::json!(4) + ]), + serde_json::json!("ell") + ); + } + + #[test] + fn test_call_reverse_array() { + assert_eq!( + call("reverse", vec![serde_json::json!([1, 2, 3])]), + serde_json::json!([3, 2, 1]) + ); + } + + #[test] + fn test_call_reverse_string() { + assert_eq!( + call("reverse", vec![serde_json::json!("abc")]), + serde_json::json!("cba") + ); + } + + #[test] + fn test_call_if_true() { + assert_eq!( + call("if", vec![ + serde_json::json!(true), + serde_json::json!("yes"), + serde_json::json!("no"), + ]), + serde_json::json!("yes") + ); + } + + #[test] + fn test_call_if_false() { + assert_eq!( + call("if", vec![ + serde_json::json!(false), + serde_json::json!("yes"), + serde_json::json!("no"), + ]), + serde_json::json!("no") + ); + } + + #[test] + fn test_call_unknown_returns_null() { + assert_eq!( + call("nonexistent_function", vec![]), + serde_json::Value::Null + ); + } + + #[test] + fn test_get_json_path_object() { + let data = serde_json::json!({ + "user": { "name": "John", "age": 30 } + }); + assert_eq!( + get_json_path(&data, "user.name"), + Some(&serde_json::Value::String("John".to_string())) + ); + } + + #[test] + fn test_get_json_path_array() { + let data = serde_json::json!({ "items": ["a", "b", "c"] }); + assert_eq!( + get_json_path(&data, "items.1"), + Some(&serde_json::Value::String("b".to_string())) + ); + } + + #[test] + fn test_get_json_path_invalid() { + let data = serde_json::json!({"foo": "bar"}); + assert!(get_json_path(&data, "nonexistent").is_none()); + } + + #[test] + fn test_get_json_path_array_out_of_bounds() { + let data = serde_json::json!({"items": ["a"]}); + assert!(get_json_path(&data, "items.5").is_none()); + } + + #[test] + fn test_get_json_path_non_array_index() { + let data = serde_json::json!({"foo": "bar"}); + assert!(get_json_path(&data, "foo.0").is_none()); + } + + #[test] + fn test_path_expression_uses_get_json_path() { + let ctx = serde_json::json!({"a": {"b": [1, 2, 3]}}); + let expr = Expression::Path("a.b.1".to_string()); + assert_eq!(evaluate_expression(&expr, &ctx), serde_json::json!(2)); + } + + #[test] + fn test_path_expression_missing_returns_null() { + let ctx = serde_json::json!({"a": 1}); + let expr = Expression::Path("b.c".to_string()); + assert_eq!(evaluate_expression(&expr, &ctx), serde_json::Value::Null); + } +} diff --git a/crates/pinakes-ui/src/plugin_ui/mod.rs b/crates/pinakes-ui/src/plugin_ui/mod.rs index 1684cb9..5e3016d 100644 --- a/crates/pinakes-ui/src/plugin_ui/mod.rs +++ b/crates/pinakes-ui/src/plugin_ui/mod.rs @@ -31,6 +31,7 @@ pub mod actions; pub mod data; +pub mod expr; pub mod registry; pub mod renderer; pub mod widget; -- 2.43.0 From 9c67c81a7976e7126fe5b351962ebf74f148c807 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Wed, 11 Mar 2026 17:07:17 +0300 Subject: [PATCH 28/46] pinakes-server: relativize media paths against configured root directories Signed-off-by: NotAShelf Change-Id: I9f113e6402030c46ad97f636985b5d6c6a6a6964 --- crates/pinakes-server/src/dto/media.rs | 100 +++++++++++++++++- crates/pinakes-server/src/routes/analytics.rs | 11 +- crates/pinakes-server/src/routes/books.rs | 12 ++- .../pinakes-server/src/routes/collections.rs | 8 +- .../pinakes-server/src/routes/duplicates.rs | 7 +- crates/pinakes-server/src/routes/media.rs | 37 +++++-- crates/pinakes-server/src/routes/photos.rs | 7 +- crates/pinakes-server/src/routes/playlists.rs | 22 +++- crates/pinakes-server/src/routes/search.rs | 14 ++- crates/pinakes-server/src/routes/shares.rs | 25 +++-- crates/pinakes-server/src/routes/social.rs | 11 +- 11 files changed, 212 insertions(+), 42 deletions(-) diff --git a/crates/pinakes-server/src/dto/media.rs b/crates/pinakes-server/src/dto/media.rs index 800f951..231bbb9 100644 --- a/crates/pinakes-server/src/dto/media.rs +++ b/crates/pinakes-server/src/dto/media.rs @@ -1,9 +1,39 @@ -use std::{collections::HashMap, path::PathBuf}; +use std::{ + collections::HashMap, + path::{Path, PathBuf}, +}; use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; 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. +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()); + if is_longer { + best = Some(root); + } + } + } + if let Some(root) = best { + if 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::>() + .join("/"); + } + } + full_path.to_string_lossy().into_owned() +} + #[derive(Debug, Serialize)] pub struct MediaResponse { pub id: String, @@ -233,12 +263,18 @@ impl From } } -// Conversion helpers -impl From for MediaResponse { - fn from(item: pinakes_core::model::MediaItem) -> Self { +impl MediaResponse { + /// Build a `MediaResponse` from a `MediaItem`, stripping the longest + /// 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. + pub fn new( + item: pinakes_core::model::MediaItem, + roots: &[PathBuf], + ) -> Self { Self { 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, media_type: serde_json::to_value(item.media_type) .ok() @@ -282,6 +318,60 @@ impl From for MediaResponse { } } +// Conversion helpers +impl From 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 #[derive(Debug, Deserialize)] pub struct WatchProgressRequest { diff --git a/crates/pinakes-server/src/routes/analytics.rs b/crates/pinakes-server/src/routes/analytics.rs index 19a3ef0..1698061 100644 --- a/crates/pinakes-server/src/routes/analytics.rs +++ b/crates/pinakes-server/src/routes/analytics.rs @@ -30,12 +30,13 @@ pub async fn get_most_viewed( ) -> Result>, ApiError> { let limit = params.limit.unwrap_or(20).min(MAX_LIMIT); let results = state.storage.get_most_viewed(limit).await?; + let roots = state.config.read().await.directories.roots.clone(); Ok(Json( results .into_iter() .map(|(item, count)| { MostViewedResponse { - media: MediaResponse::from(item), + media: MediaResponse::new(item, &roots), view_count: count, } }) @@ -51,7 +52,13 @@ pub async fn get_recently_viewed( let user_id = resolve_user_id(&state.storage, &username).await?; let limit = params.limit.unwrap_or(20).min(MAX_LIMIT); 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( diff --git a/crates/pinakes-server/src/routes/books.rs b/crates/pinakes-server/src/routes/books.rs index f513d9c..7ae042f 100644 --- a/crates/pinakes-server/src/routes/books.rs +++ b/crates/pinakes-server/src/routes/books.rs @@ -194,8 +194,9 @@ pub async fn list_books( ) .await?; + let roots = state.config.read().await.directories.roots.clone(); let response: Vec = - items.into_iter().map(MediaResponse::from).collect(); + items.into_iter().map(|item| MediaResponse::new(item, &roots)).collect(); Ok(Json(response)) } @@ -223,8 +224,9 @@ pub async fn get_series_books( Path(series_name): Path, ) -> Result { let items = state.storage.get_series_books(&series_name).await?; + let roots = state.config.read().await.directories.roots.clone(); let response: Vec = - items.into_iter().map(MediaResponse::from).collect(); + items.into_iter().map(|item| MediaResponse::new(item, &roots)).collect(); Ok(Json(response)) } @@ -258,8 +260,9 @@ pub async fn get_author_books( .search_books(None, Some(&author_name), None, None, None, &pagination) .await?; + let roots = state.config.read().await.directories.roots.clone(); let response: Vec = - items.into_iter().map(MediaResponse::from).collect(); + items.into_iter().map(|item| MediaResponse::new(item, &roots)).collect(); Ok(Json(response)) } @@ -317,8 +320,9 @@ pub async fn get_reading_list( .get_reading_list(user_id.0, params.status) .await?; + let roots = state.config.read().await.directories.roots.clone(); let response: Vec = - items.into_iter().map(MediaResponse::from).collect(); + items.into_iter().map(|item| MediaResponse::new(item, &roots)).collect(); Ok(Json(response)) } diff --git a/crates/pinakes-server/src/routes/collections.rs b/crates/pinakes-server/src/routes/collections.rs index 159d125..c746fa8 100644 --- a/crates/pinakes-server/src/routes/collections.rs +++ b/crates/pinakes-server/src/routes/collections.rs @@ -126,5 +126,11 @@ pub async fn get_members( let items = pinakes_core::collections::get_members(&state.storage, collection_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(), + )) } diff --git a/crates/pinakes-server/src/routes/duplicates.rs b/crates/pinakes-server/src/routes/duplicates.rs index 4da2ac8..075b3cc 100644 --- a/crates/pinakes-server/src/routes/duplicates.rs +++ b/crates/pinakes-server/src/routes/duplicates.rs @@ -10,6 +10,7 @@ pub async fn list_duplicates( State(state): State, ) -> Result>, ApiError> { let groups = state.storage.find_duplicates().await?; + let roots = state.config.read().await.directories.roots.clone(); let response: Vec = groups .into_iter() @@ -18,8 +19,10 @@ pub async fn list_duplicates( .first() .map(|i| i.content_hash.0.clone()) .unwrap_or_default(); - let media_items: Vec = - items.into_iter().map(MediaResponse::from).collect(); + let media_items: Vec = items + .into_iter() + .map(|item| MediaResponse::new(item, &roots)) + .collect(); DuplicateGroupResponse { content_hash, items: media_items, diff --git a/crates/pinakes-server/src/routes/media.rs b/crates/pinakes-server/src/routes/media.rs index a2b3a4a..358db29 100644 --- a/crates/pinakes-server/src/routes/media.rs +++ b/crates/pinakes-server/src/routes/media.rs @@ -120,7 +120,13 @@ pub async fn list_media( params.sort, ); 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( @@ -128,7 +134,8 @@ pub async fn get_media( Path(id): Path, ) -> Result, ApiError> { 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). @@ -206,7 +213,8 @@ pub async fn update_media( &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( @@ -574,12 +582,14 @@ pub async fn preview_directory( } } + let roots_for_walk = roots.clone(); let files: Vec = tokio::task::spawn_blocking(move || { let mut result = Vec::new(); fn walk_dir( dir: &std::path::Path, recursive: bool, + roots: &[std::path::PathBuf], result: &mut Vec, ) { let Ok(entries) = std::fs::read_dir(dir) else { @@ -596,7 +606,7 @@ pub async fn preview_directory( } if path.is_dir() { if recursive { - walk_dir(&path, recursive, result); + walk_dir(&path, recursive, roots, result); } } else if path.is_file() && let Some(mt) = @@ -612,7 +622,7 @@ pub async fn preview_directory( .and_then(|v| v.as_str().map(String::from)) .unwrap_or_default(); result.push(DirectoryPreviewFile { - path: path.to_string_lossy().to_string(), + path: crate::dto::relativize_path(&path, roots), file_name, media_type, 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 }) .await @@ -948,7 +958,8 @@ pub async fn rename_media( ) .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( @@ -994,7 +1005,8 @@ pub async fn move_media_endpoint( ) .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( @@ -1144,7 +1156,8 @@ pub async fn restore_media( &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( @@ -1159,9 +1172,13 @@ pub async fn list_trash( let items = state.storage.list_trash(&pagination).await?; let count = state.storage.count_trash().await?; + let roots = state.config.read().await.directories.roots.clone(); 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, })) } diff --git a/crates/pinakes-server/src/routes/photos.rs b/crates/pinakes-server/src/routes/photos.rs index edf04b6..4119774 100644 --- a/crates/pinakes-server/src/routes/photos.rs +++ b/crates/pinakes-server/src/routes/photos.rs @@ -121,13 +121,16 @@ pub async fn get_timeline( } // Convert to response format + let roots = state.config.read().await.directories.roots.clone(); let mut timeline: Vec = groups .into_iter() .map(|(date, items)| { let cover_id = items.first().map(|i| i.id.0.to_string()); let count = items.len(); - let items: Vec = - items.into_iter().map(MediaResponse::from).collect(); + let items: Vec = items + .into_iter() + .map(|item| MediaResponse::new(item, &roots)) + .collect(); TimelineGroup { date, diff --git a/crates/pinakes-server/src/routes/playlists.rs b/crates/pinakes-server/src/routes/playlists.rs index 15df830..f341458 100644 --- a/crates/pinakes-server/src/routes/playlists.rs +++ b/crates/pinakes-server/src/routes/playlists.rs @@ -21,8 +21,10 @@ use crate::{ /// Check whether a user has access to a playlist. /// -/// * `require_write` – when `true` only the playlist owner is allowed (for -/// mutations such as update, delete, add/remove/reorder items). When `false` +/// # Arguments +/// +/// * `require_write` - when `true` only the playlist owner is allowed (for +/// mutations such as update, delete, add/remove/reorder items). When `false` /// the playlist must either be public or owned by the requesting user. async fn check_playlist_access( storage: &pinakes_core::storage::DynStorageBackend, @@ -185,7 +187,13 @@ pub async fn list_items( let user_id = resolve_user_id(&state.storage, &username).await?; check_playlist_access(&state.storage, id, user_id, false).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( @@ -213,5 +221,11 @@ pub async fn shuffle_playlist( use rand::seq::SliceRandom; let mut items = state.storage.get_playlist_items(id).await?; 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(), + )) } diff --git a/crates/pinakes-server/src/routes/search.rs b/crates/pinakes-server/src/routes/search.rs index 3201047..7f0e6b1 100644 --- a/crates/pinakes-server/src/routes/search.rs +++ b/crates/pinakes-server/src/routes/search.rs @@ -51,9 +51,14 @@ pub async fn search( }; let results = state.storage.search(&request).await?; + let roots = state.config.read().await.directories.roots.clone(); 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, })) } @@ -84,9 +89,14 @@ pub async fn search_post( }; let results = state.storage.search(&request).await?; + let roots = state.config.read().await.directories.roots.clone(); 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, })) } diff --git a/crates/pinakes-server/src/routes/shares.rs b/crates/pinakes-server/src/routes/shares.rs index 06b7e6d..76fea3c 100644 --- a/crates/pinakes-server/src/routes/shares.rs +++ b/crates/pinakes-server/src/routes/shares.rs @@ -506,6 +506,7 @@ pub async fn access_shared( let _ = state.storage.record_share_activity(&activity).await; // Return the shared content + let roots = state.config.read().await.directories.roots.clone(); match &share.target { ShareTarget::Media { media_id } => { let item = state @@ -514,8 +515,8 @@ pub async fn access_shared( .await .map_err(|e| ApiError::not_found(format!("Media not found: {e}")))?; - Ok(Json(SharedContentResponse::Single(MediaResponse::from( - item, + Ok(Json(SharedContentResponse::Single(MediaResponse::new( + item, &roots, )))) }, ShareTarget::Collection { collection_id } => { @@ -527,8 +528,10 @@ pub async fn access_shared( ApiError::not_found(format!("Collection not found: {e}")) })?; - let items: Vec = - members.into_iter().map(MediaResponse::from).collect(); + let items: Vec = members + .into_iter() + .map(|item| MediaResponse::new(item, &roots)) + .collect(); Ok(Json(SharedContentResponse::Multiple { items })) }, @@ -553,8 +556,11 @@ pub async fn access_shared( .await .map_err(|e| ApiError::internal(format!("Search failed: {e}")))?; - let items: Vec = - results.items.into_iter().map(MediaResponse::from).collect(); + let items: Vec = results + .items + .into_iter() + .map(|item| MediaResponse::new(item, &roots)) + .collect(); Ok(Json(SharedContentResponse::Multiple { items })) }, @@ -585,8 +591,11 @@ pub async fn access_shared( .await .map_err(|e| ApiError::internal(format!("Search failed: {e}")))?; - let items: Vec = - results.items.into_iter().map(MediaResponse::from).collect(); + let items: Vec = results + .items + .into_iter() + .map(|item| MediaResponse::new(item, &roots)) + .collect(); Ok(Json(SharedContentResponse::Multiple { items })) }, diff --git a/crates/pinakes-server/src/routes/social.rs b/crates/pinakes-server/src/routes/social.rs index b270ae0..f5bc17a 100644 --- a/crates/pinakes-server/src/routes/social.rs +++ b/crates/pinakes-server/src/routes/social.rs @@ -125,7 +125,13 @@ pub async fn list_favorites( .storage .get_user_favorites(user_id, &Pagination::default()) .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( @@ -205,5 +211,6 @@ pub async fn access_shared_media( } state.storage.increment_share_views(&token).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))) } -- 2.43.0 From 8f2b44b50ce4540160009e0f96508324f019fcc6 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Wed, 11 Mar 2026 17:08:02 +0300 Subject: [PATCH 29/46] pinakes-core: unify book metadata extraction; remove ExtractedBookMetadata Signed-off-by: NotAShelf Change-Id: Ifd6e66515b9ff78a4bb13eba47b9b2cf6a6a6964 --- crates/pinakes-core/src/metadata/document.rs | 4 +- crates/pinakes-core/src/metadata/mod.rs | 4 +- crates/pinakes-core/src/model.rs | 42 ++++++++++++-------- 3 files changed, 30 insertions(+), 20 deletions(-) diff --git a/crates/pinakes-core/src/metadata/document.rs b/crates/pinakes-core/src/metadata/document.rs index f284c51..4994020 100644 --- a/crates/pinakes-core/src/metadata/document.rs +++ b/crates/pinakes-core/src/metadata/document.rs @@ -32,7 +32,7 @@ fn extract_pdf(path: &Path) -> Result { .map_err(|e| PinakesError::MetadataExtraction(format!("PDF load: {e}")))?; 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 if let Ok(info_ref) = doc.trailer.get(b"Info") { @@ -145,7 +145,7 @@ fn extract_epub(path: &Path) -> Result { ..Default::default() }; - let mut book_meta = crate::model::ExtractedBookMetadata::default(); + let mut book_meta = crate::model::BookMetadata::default(); // Extract basic metadata if let Some(lang) = doc.mdata("language") { diff --git a/crates/pinakes-core/src/metadata/mod.rs b/crates/pinakes-core/src/metadata/mod.rs index ddb601e..8fcc8b7 100644 --- a/crates/pinakes-core/src/metadata/mod.rs +++ b/crates/pinakes-core/src/metadata/mod.rs @@ -9,7 +9,7 @@ use std::{collections::HashMap, path::Path}; use crate::{ error::Result, media_type::MediaType, - model::ExtractedBookMetadata, + model::BookMetadata, }; #[derive(Debug, Clone, Default)] @@ -22,7 +22,7 @@ pub struct ExtractedMetadata { pub duration_secs: Option, pub description: Option, pub extra: HashMap, - pub book_metadata: Option, + pub book_metadata: Option, // Photo-specific metadata pub date_taken: Option>, diff --git a/crates/pinakes-core/src/model.rs b/crates/pinakes-core/src/model.rs index cedf0ef..19d6e8e 100644 --- a/crates/pinakes-core/src/model.rs +++ b/crates/pinakes-core/src/model.rs @@ -417,6 +417,10 @@ pub struct SavedSearch { // Book Management Types /// 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)] pub struct BookMetadata { pub media_id: MediaId, @@ -435,6 +439,28 @@ pub struct BookMetadata { pub updated_at: DateTime, } +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. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] 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, - pub isbn13: Option, - pub publisher: Option, - pub language: Option, - pub page_count: Option, - pub publication_date: Option, - pub series_name: Option, - pub series_index: Option, - pub format: Option, - pub authors: Vec, - pub identifiers: HashMap>, -} - /// Reading progress for a book. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ReadingProgress { -- 2.43.0 From 592a9bcc47d99bbb61dcd8f92b808a6edb69b7b6 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Wed, 11 Mar 2026 17:08:24 +0300 Subject: [PATCH 30/46] 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 Change-Id: Icf5686f34144630ebf1935c47b3979156a6a6964 --- crates/pinakes-core/src/import.rs | 10 ++- crates/pinakes-core/src/storage/postgres.rs | 5 ++ crates/pinakes-core/src/storage/sqlite.rs | 70 ++++++++++++++++----- 3 files changed, 65 insertions(+), 20 deletions(-) diff --git a/crates/pinakes-core/src/import.rs b/crates/pinakes-core/src/import.rs index 6d3c657..27046e2 100644 --- a/crates/pinakes-core/src/import.rs +++ b/crates/pinakes-core/src/import.rs @@ -498,10 +498,14 @@ fn collect_import_result( tracing::warn!(path = %path.display(), error = %e, "failed to import file"); results.push(Err(e)); }, - Err(e) => { - tracing::error!(error = %e, "import task panicked"); + Err(join_err) => { + 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!( - "import task panicked: {e}" + "import task failed: {join_err}" )))); }, } diff --git a/crates/pinakes-core/src/storage/postgres.rs b/crates/pinakes-core/src/storage/postgres.rs index e0caeee..f9d2a43 100644 --- a/crates/pinakes-core/src/storage/postgres.rs +++ b/crates/pinakes-core/src/storage/postgres.rs @@ -4295,6 +4295,11 @@ impl StorageBackend for PostgresBackend { &self, metadata: &crate::model::BookMetadata, ) -> 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 .pool .get() diff --git a/crates/pinakes-core/src/storage/sqlite.rs b/crates/pinakes-core/src/storage/sqlite.rs index c377d9e..cfe08c9 100644 --- a/crates/pinakes-core/src/storage/sqlite.rs +++ b/crates/pinakes-core/src/storage/sqlite.rs @@ -1116,7 +1116,8 @@ impl StorageBackend for SqliteBackend { parent_id.map(|p| p.to_string()), now.to_rfc3339(), ], - )?; + ) + .map_err(crate::error::db_ctx("create_tag", &name))?; drop(db); Tag { id, @@ -1192,7 +1193,8 @@ impl StorageBackend for SqliteBackend { .lock() .map_err(|e| PinakesError::Database(e.to_string()))?; 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); if changed == 0 { return Err(PinakesError::TagNotFound(id.to_string())); @@ -1214,7 +1216,11 @@ impl StorageBackend for SqliteBackend { db.execute( "INSERT OR IGNORE INTO media_tags (media_id, tag_id) VALUES (?1, ?2)", 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(()) }) @@ -1232,7 +1238,11 @@ impl StorageBackend for SqliteBackend { db.execute( "DELETE FROM media_tags WHERE media_id = ?1 AND tag_id = ?2", 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(()) }) @@ -1323,7 +1333,8 @@ impl StorageBackend for SqliteBackend { now.to_rfc3339(), now.to_rfc3339(), ], - )?; + ) + .map_err(crate::error::db_ctx("create_collection", &name))?; drop(db); Collection { id, @@ -1406,7 +1417,8 @@ impl StorageBackend for SqliteBackend { let changed = db .execute("DELETE FROM collections WHERE id = ?1", params![ id.to_string() - ])?; + ]) + .map_err(crate::error::db_ctx("delete_collection", id))?; drop(db); if changed == 0 { return Err(PinakesError::CollectionNotFound(id.to_string())); @@ -1440,7 +1452,11 @@ impl StorageBackend for SqliteBackend { position, now.to_rfc3339(), ], - )?; + ) + .map_err(crate::error::db_ctx( + "add_to_collection", + format!("{collection_id} <- {media_id}"), + ))?; } Ok(()) }) @@ -1463,7 +1479,11 @@ impl StorageBackend for SqliteBackend { "DELETE FROM collection_members WHERE collection_id = ?1 AND \ media_id = ?2", 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(()) }) @@ -1863,20 +1883,27 @@ impl StorageBackend for SqliteBackend { let db = conn .lock() .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 let mut stmt = tx.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; for mid in &media_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 } } drop(stmt); - tx.commit()?; + tx.commit() + .map_err(crate::error::db_ctx("batch_tag_media", &ctx))?; count }; Ok(count) @@ -2695,7 +2722,7 @@ impl StorageBackend for SqliteBackend { let id_str = id.0.to_string(); let now = chrono::Utc::now(); 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( @@ -2714,7 +2741,7 @@ impl StorageBackend for SqliteBackend { let user_profile = if let Some(prof) = profile.clone() { let prefs_json = serde_json::to_string(&prof.preferences).map_err(|e| { - PinakesError::Database(format!( + PinakesError::Serialization(format!( "failed to serialize preferences: {e}" )) })?; @@ -2796,7 +2823,9 @@ impl StorageBackend for SqliteBackend { if let Some(ref r) = role { updates.push("role = ?"); 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)); } @@ -2814,7 +2843,7 @@ impl StorageBackend for SqliteBackend { if let Some(prof) = profile { let prefs_json = serde_json::to_string(&prof.preferences).map_err(|e| { - PinakesError::Database(format!( + PinakesError::Serialization(format!( "failed to serialize preferences: {e}" )) })?; @@ -2966,7 +2995,9 @@ impl StorageBackend for SqliteBackend { PinakesError::Database(format!("failed to acquire database lock: {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(); db.execute( @@ -5055,6 +5086,11 @@ impl StorageBackend for SqliteBackend { &self, metadata: &crate::model::BookMetadata, ) -> Result<()> { + if metadata.media_id.0.is_nil() { + return Err(PinakesError::Database( + "upsert_book_metadata: media_id must not be nil".to_string(), + )); + } let conn = Arc::clone(&self.conn); let media_id_str = metadata.media_id.to_string(); let isbn = metadata.isbn.clone(); -- 2.43.0 From cf76d42c33b703f7a4150e0edc7f7a6930d040f3 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Wed, 11 Mar 2026 17:09:27 +0300 Subject: [PATCH 31/46] pinakes-core: add integration tests for `batch_update_media` Signed-off-by: NotAShelf Change-Id: I0787bec99f7c1d098c1c1168560a43266a6a6964 --- crates/pinakes-core/tests/integration.rs | 105 +++++++++++++++++++++++ 1 file changed, 105 insertions(+) diff --git a/crates/pinakes-core/tests/integration.rs b/crates/pinakes-core/tests/integration.rs index da1035a..8cc4d4d 100644 --- a/crates/pinakes-core/tests/integration.rs +++ b/crates/pinakes-core/tests/integration.rs @@ -927,3 +927,108 @@ async fn test_transcode_sessions() { .unwrap(); 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); +} -- 2.43.0 From 119f6d2e06ea963c6f62e0c34d8691eaeabceb49 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Wed, 11 Mar 2026 17:09:56 +0300 Subject: [PATCH 32/46] examples: add media-stats-ui plugin Signed-off-by: NotAShelf Change-Id: I7c9ccac175440d278fd129dbd53f04d66a6a6964 --- examples/plugins/media-stats-ui/Cargo.lock | 48 +++++++ examples/plugins/media-stats-ui/Cargo.toml | 20 +++ .../plugins/media-stats-ui/pages/stats.json | 132 ++++++++++++++++++ .../media-stats-ui/pages/tag-manager.json | 126 +++++++++++++++++ examples/plugins/media-stats-ui/plugin.toml | 39 ++++++ examples/plugins/media-stats-ui/src/lib.rs | 101 ++++++++++++++ 6 files changed, 466 insertions(+) create mode 100644 examples/plugins/media-stats-ui/Cargo.lock create mode 100644 examples/plugins/media-stats-ui/Cargo.toml create mode 100644 examples/plugins/media-stats-ui/pages/stats.json create mode 100644 examples/plugins/media-stats-ui/pages/tag-manager.json create mode 100644 examples/plugins/media-stats-ui/plugin.toml create mode 100644 examples/plugins/media-stats-ui/src/lib.rs diff --git a/examples/plugins/media-stats-ui/Cargo.lock b/examples/plugins/media-stats-ui/Cargo.lock new file mode 100644 index 0000000..882e3ef --- /dev/null +++ b/examples/plugins/media-stats-ui/Cargo.lock @@ -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", +] diff --git a/examples/plugins/media-stats-ui/Cargo.toml b/examples/plugins/media-stats-ui/Cargo.toml new file mode 100644 index 0000000..f3004cc --- /dev/null +++ b/examples/plugins/media-stats-ui/Cargo.toml @@ -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 diff --git a/examples/plugins/media-stats-ui/pages/stats.json b/examples/plugins/media-stats-ui/pages/stats.json new file mode 100644 index 0000000..03961a0 --- /dev/null +++ b/examples/plugins/media-stats-ui/pages/stats.json @@ -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": "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": "static", + "value": [ + { "type": "Audio", "count": 0 }, + { "type": "Video", "count": 0 }, + { "type": "Image", "count": 0 }, + { "type": "Document", "count": 0 } + ] + } + } +} diff --git a/examples/plugins/media-stats-ui/pages/tag-manager.json b/examples/plugins/media-stats-ui/pages/tag-manager.json new file mode 100644 index 0000000..30b3c2f --- /dev/null +++ b/examples/plugins/media-stats-ui/pages/tag-manager.json @@ -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." + } + } +} diff --git a/examples/plugins/media-stats-ui/plugin.toml b/examples/plugins/media-stats-ui/plugin.toml new file mode 100644 index 0000000..f65def5 --- /dev/null +++ b/examples/plugins/media-stats-ui/plugin.toml @@ -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 = "media_stats_ui.wasm" + +[capabilities] +network = false + +[capabilities.filesystem] +read = [] +write = [] + +[ui] +required_endpoints = ["/api/v1/statistics", "/api/v1/media"] + +# 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" diff --git a/examples/plugins/media-stats-ui/src/lib.rs b/examples/plugins/media-stats-ui/src/lib.rs new file mode 100644 index 0000000..c11a346 --- /dev/null +++ b/examples/plugins/media-stats-ui/src/lib.rs @@ -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"[]"); +} -- 2.43.0 From 3678edd3555eb956d1dd6cd9c33ba76011d5b91a Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Wed, 11 Mar 2026 17:11:26 +0300 Subject: [PATCH 33/46] meta: prefer std's `OnceLock` and `LazyLock` over once_cell Signed-off-by: NotAShelf Change-Id: I35d51abfa9a790206391dca891799d956a6a6964 --- .clippy.toml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/.clippy.toml b/.clippy.toml index 20d3251..0a3de0a 100644 --- a/.clippy.toml +++ b/.clippy.toml @@ -6,3 +6,10 @@ await-holding-invalid-types = [ "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." }, ] + +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" }, +] -- 2.43.0 From dc4dc416701de65c33225d9cd72fc9cf693e1b24 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Wed, 11 Mar 2026 17:12:07 +0300 Subject: [PATCH 34/46] pinakes-plugin-api: consolidate reserved-route check; reject widget data-source refs Signed-off-by: NotAShelf Change-Id: I042ee31e95822f46520a618de8dcaf786a6a6964 --- crates/pinakes-plugin-api/src/ui_schema.rs | 483 +++++++++++++++++++- crates/pinakes-plugin-api/src/validation.rs | 173 ++++++- 2 files changed, 627 insertions(+), 29 deletions(-) diff --git a/crates/pinakes-plugin-api/src/ui_schema.rs b/crates/pinakes-plugin-api/src/ui_schema.rs index 783237e..02ec93d 100644 --- a/crates/pinakes-plugin-api/src/ui_schema.rs +++ b/crates/pinakes-plugin-api/src/ui_schema.rs @@ -25,7 +25,7 @@ //! "sidebar": { //! "type": "list", //! "data": "playlists", -//! "item_template": { "type": "text", "content": "{{title}}" } +//! "item_template": { "type": "text", "content": "title" } //! }, //! "main": { //! "type": "data_table", @@ -40,6 +40,11 @@ //! "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; @@ -102,6 +107,7 @@ pub type SchemaResult = Result; /// padding: None, /// }, /// data_sources: Default::default(), +/// actions: Default::default(), /// }; /// ``` #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] @@ -127,6 +133,10 @@ pub struct UiPage { /// Named data sources available to this page #[serde(default, skip_serializing_if = "HashMap::is_empty")] pub data_sources: HashMap, + + /// Named actions available to this page (referenced by `ActionRef::Name`) + #[serde(default, skip_serializing_if = "HashMap::is_empty")] + pub actions: HashMap, } 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(); if depth > MAX_ELEMENT_DEPTH { return Err(SchemaError::DepthLimitExceeded); @@ -158,6 +175,11 @@ impl UiPage { self.root_element.validate(self)?; + for (name, action) in &self.actions { + validate_id(name)?; + action.validate()?; + } + for (name, source) in &self.data_sources { validate_id(name)?; source.validate()?; @@ -246,6 +268,28 @@ pub struct UiWidget { pub content: UiElement, } +impl UiWidget { + /// Validates this widget definition + /// + /// # Errors + /// + /// Returns `SchemaError::ValidationError` if validation fails + pub fn validate(&self) -> SchemaResult<()> { + if self.id.is_empty() { + return Err(SchemaError::ValidationError( + "Widget id cannot be empty".to_string(), + )); + } + 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. /// /// Use these with `UiWidget::target` in plugin manifests: @@ -259,6 +303,7 @@ pub mod widget_location { pub const LIBRARY_SIDEBAR: &str = "library_sidebar"; pub const DETAIL_PANEL: &str = "detail_panel"; 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 @@ -817,6 +862,11 @@ impl UiElement { Self::Button { action, .. } => { 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 { fields, submit_action, @@ -1046,7 +1096,7 @@ pub struct ColumnDef { } /// Row action for `DataTable` -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub struct RowAction { /// Action identifier (unique within this table) pub id: String, @@ -1290,15 +1340,60 @@ pub enum ChartType { Scatter, } +/// Client-side action types that do not require an HTTP call. +/// +/// Used as `{"action": "", ...}` 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, + }, + /// Close the currently open modal overlay. + CloseModal, +} + /// 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)] 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) Name(String), - - /// Inline action definition - Inline(ActionDefinition), } impl ActionRef { @@ -1312,6 +1407,26 @@ impl ActionRef { /// Returns `SchemaError::ValidationError` if validation fails. pub fn validate(&self) -> SchemaResult<()> { 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) => { if name.is_empty() { return Err(SchemaError::ValidationError( @@ -1376,6 +1491,18 @@ impl ActionDefinition { 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(()) } } @@ -1462,6 +1589,16 @@ impl DataSource { "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, .. } => { validate_id(source_name)?; @@ -1475,16 +1612,31 @@ impl DataSource { /// Expression for dynamic value evaluation /// /// 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)] #[serde(untagged)] pub enum Expression { - /// Literal JSON value - Literal(serde_json::Value), - - /// Data path reference (e.g., "$.users[0].name") + /// Data path reference: a dotted key sequence walked against the context. + /// + /// e.g. `"user.name"` resolves to `ctx["user"]["name"]`; `"items.0"` resolves + /// to the first element. Path(String), - /// Binary operation + /// Binary operation applied to two sub-expressions. Operation { /// Left operand left: Box, @@ -1494,13 +1646,22 @@ pub enum Expression { right: Box, }, - /// Function call + /// Built-in function call. + /// + /// e.g. `{"function": "len", "args": ["tags"]}` returns the count of items + /// in the `tags` data source. Call { - /// Function name + /// Function name (see built-in function table in docs) function: String, - /// Function arguments + /// Positional arguments, each an `Expression` args: Vec, }, + + /// 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 { @@ -1579,6 +1740,18 @@ const fn default_http_method() -> HttpMethod { 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 /// /// IDs must: @@ -1729,6 +1902,7 @@ mod tests { row_actions: vec![], }, data_sources: HashMap::new(), + actions: HashMap::new(), }; let refs = page.referenced_data_sources(); @@ -1748,6 +1922,7 @@ mod tests { gap: 16, }, data_sources: HashMap::new(), + actions: HashMap::new(), }; assert!(page.validate().is_err()); @@ -1766,8 +1941,288 @@ mod tests { id: None, }, data_sources: HashMap::new(), + actions: HashMap::new(), }; 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,")); + } + + #[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" + ); + } } diff --git a/crates/pinakes-plugin-api/src/validation.rs b/crates/pinakes-plugin-api/src/validation.rs index d232f29..fc060f2 100644 --- a/crates/pinakes-plugin-api/src/validation.rs +++ b/crates/pinakes-plugin-api/src/validation.rs @@ -122,6 +122,10 @@ impl SchemaValidator { 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() { Ok(()) } else { @@ -132,19 +136,9 @@ impl SchemaValidator { /// Recursively validate a [`UiElement`] subtree. pub fn validate_element(element: &UiElement, errors: &mut Vec) { match element { - UiElement::Container { children, .. } => { - for child in children { - Self::validate_element(child, errors); - } - }, - - UiElement::Grid { children, .. } => { - for child in children { - Self::validate_element(child, errors); - } - }, - - UiElement::Flex { children, .. } => { + UiElement::Container { children, .. } + | UiElement::Grid { children, .. } + | UiElement::Flex { children, .. } => { for child in children { Self::validate_element(child, errors); } @@ -206,10 +200,15 @@ impl SchemaValidator { } }, - UiElement::List { data, .. } => { + UiElement::List { + data, + item_template, + .. + } => { if data.is_empty() { 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 @@ -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( name: &str, source: &DataSource, @@ -243,6 +302,12 @@ impl SchemaValidator { "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, .. } => { if source_name.is_empty() { @@ -264,7 +329,7 @@ impl SchemaValidator { && 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| { route == *reserved || route.starts_with(&format!("{reserved}/")) }) @@ -290,6 +355,7 @@ mod tests { padding: None, }, data_sources: HashMap::new(), + actions: HashMap::new(), } } @@ -580,4 +646,81 @@ mod tests { }; 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}" + ); + } } -- 2.43.0 From 15b005cef03bf4d25befad2e2daafcf05ad6230b Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Wed, 11 Mar 2026 17:22:52 +0300 Subject: [PATCH 35/46] pinakes-core: expose `required_endpoints` alongside UI pages in plugin manager Signed-off-by: NotAShelf Change-Id: I32c95a03f106db8fef7eedd0362756a46a6a6964 --- crates/pinakes-core/src/plugin/mod.rs | 131 +++++++++++++++++++-- crates/pinakes-core/src/plugin/registry.rs | 1 + 2 files changed, 119 insertions(+), 13 deletions(-) diff --git a/crates/pinakes-core/src/plugin/mod.rs b/crates/pinakes-core/src/plugin/mod.rs index b419e0d..fc77aed 100644 --- a/crates/pinakes-core/src/plugin/mod.rs +++ b/crates/pinakes-core/src/plugin/mod.rs @@ -602,7 +602,8 @@ impl PluginManager { /// List all UI pages provided by loaded 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( &self, ) -> Vec<(String, pinakes_plugin_api::UiPage)> { @@ -612,23 +613,126 @@ impl PluginManager { if !plugin.enabled { continue; } - for entry in &plugin.manifest.ui.pages { - let page = match entry { - pinakes_plugin_api::manifest::UiPageEntry::Inline(page) => { - (**page).clone() - }, - pinakes_plugin_api::manifest::UiPageEntry::File { .. } => { - // File-referenced pages require a base path to resolve; - // skip them here as they should have been loaded at startup. - continue; - }, - }; - pages.push((plugin.id.clone(), page)); + let plugin_dir = plugin + .manifest_path + .as_ref() + .and_then(|p| p.parent()) + .map(std::path::Path::to_path_buf); + let Some(plugin_dir) = plugin_dir else { + // No manifest path; serve only inline pages. + for entry in &plugin.manifest.ui.pages { + if let pinakes_plugin_api::manifest::UiPageEntry::Inline(page) = entry + { + pages.push((plugin.id.clone(), (**page).clone())); + } + } + continue; + }; + match plugin.manifest.load_ui_pages(&plugin_dir) { + Ok(loaded) => { + for page in loaded { + pages.push((plugin.id.clone(), page)); + } + }, + Err(e) => { + tracing::warn!( + "Failed to load UI pages for plugin '{}': {e}", + plugin.id + ); + }, } } pages } + /// 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)> { + let registry = self.registry.read().await; + let mut pages = Vec::new(); + for plugin in registry.list_all() { + if !plugin.enabled { + continue; + } + let allowed = plugin.manifest.ui.required_endpoints.clone(); + let plugin_dir = plugin + .manifest_path + .as_ref() + .and_then(|p| p.parent()) + .map(std::path::Path::to_path_buf); + let Some(plugin_dir) = plugin_dir else { + for entry in &plugin.manifest.ui.pages { + if let pinakes_plugin_api::manifest::UiPageEntry::Inline(page) = entry + { + pages.push((plugin.id.clone(), (**page).clone(), allowed.clone())); + } + } + 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 + } + + /// 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 { + 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 pub async fn is_plugin_enabled(&self, plugin_id: &str) -> bool { let registry = self.registry.read().await; @@ -746,6 +850,7 @@ mod tests { }, capabilities: Default::default(), config: Default::default(), + ui: Default::default(), } } diff --git a/crates/pinakes-core/src/plugin/registry.rs b/crates/pinakes-core/src/plugin/registry.rs index afa09b1..6e9219e 100644 --- a/crates/pinakes-core/src/plugin/registry.rs +++ b/crates/pinakes-core/src/plugin/registry.rs @@ -182,6 +182,7 @@ mod tests { }, capabilities: ManifestCapabilities::default(), config: HashMap::new(), + ui: Default::default(), }; RegisteredPlugin { -- 2.43.0 From 0c9b71346d2dc07f0cdb4ba89da006913c6cc24a Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Wed, 11 Mar 2026 17:23:08 +0300 Subject: [PATCH 36/46] pinakes-core: map `serde_json` errors to `Serialization` variant in export Signed-off-by: NotAShelf Change-Id: I77c27639ea1aca03d54702e38fc3ef576a6a6964 --- crates/pinakes-core/src/export.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/pinakes-core/src/export.rs b/crates/pinakes-core/src/export.rs index 50542b9..c5f3ce5 100644 --- a/crates/pinakes-core/src/export.rs +++ b/crates/pinakes-core/src/export.rs @@ -42,7 +42,7 @@ pub async fn export_library( match format { ExportFormat::Json => { 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)?; }, -- 2.43.0 From 0ba898c881e0945867d5cc389f3cc47dee1e2a91 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Wed, 11 Mar 2026 17:23:17 +0300 Subject: [PATCH 37/46] pinakes-core: check file existence before removal in `TempFileGuard` drop Signed-off-by: NotAShelf Change-Id: I800825f5dc3b526d350931ff8f1ed0da6a6a6964 --- crates/pinakes-core/src/thumbnail.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/crates/pinakes-core/src/thumbnail.rs b/crates/pinakes-core/src/thumbnail.rs index 1656e2f..e221c76 100644 --- a/crates/pinakes-core/src/thumbnail.rs +++ b/crates/pinakes-core/src/thumbnail.rs @@ -27,7 +27,11 @@ impl TempFileGuard { impl Drop for TempFileGuard { fn drop(&mut self) { - let _ = std::fs::remove_file(&self.0); + if self.0.exists() { + if let Err(e) = std::fs::remove_file(&self.0) { + warn!("failed to clean up temp file {}: {e}", self.0.display()); + } + } } } -- 2.43.0 From 185e3b562ab078d74279f75f588207cc2e495802 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Wed, 11 Mar 2026 17:23:51 +0300 Subject: [PATCH 38/46] treewide: cleanup Signed-off-by: NotAShelf Change-Id: Ia01590cdeed872cc8ebd16f6ca95f3cc6a6a6964 --- crates/pinakes-core/src/export.rs | 4 +- crates/pinakes-core/src/metadata/mod.rs | 6 +- crates/pinakes-core/src/plugin/mod.rs | 42 +----- crates/pinakes-core/src/plugin/registry.rs | 2 +- crates/pinakes-core/src/storage/sqlite.rs | 10 +- crates/pinakes-core/tests/integration.rs | 20 ++- crates/pinakes-plugin-api/src/manifest.rs | 12 +- crates/pinakes-plugin-api/src/ui_schema.rs | 5 - crates/pinakes-plugin-api/src/validation.rs | 4 +- crates/pinakes-server/src/dto/media.rs | 13 +- crates/pinakes-server/src/routes/books.rs | 24 ++-- crates/pinakes-server/src/routes/plugins.rs | 10 +- crates/pinakes-ui/src/plugin_ui/actions.rs | 5 +- crates/pinakes-ui/src/plugin_ui/data.rs | 152 +++++++++++++------- crates/pinakes-ui/src/plugin_ui/registry.rs | 50 +++---- crates/pinakes-ui/src/plugin_ui/renderer.rs | 118 +++++++-------- 16 files changed, 258 insertions(+), 219 deletions(-) diff --git a/crates/pinakes-core/src/export.rs b/crates/pinakes-core/src/export.rs index c5f3ce5..f50ec38 100644 --- a/crates/pinakes-core/src/export.rs +++ b/crates/pinakes-core/src/export.rs @@ -42,7 +42,9 @@ pub async fn export_library( match format { ExportFormat::Json => { let json = serde_json::to_string_pretty(&items).map_err(|e| { - crate::error::PinakesError::Serialization(format!("json serialize: {e}")) + crate::error::PinakesError::Serialization(format!( + "json serialize: {e}" + )) })?; std::fs::write(destination, json)?; }, diff --git a/crates/pinakes-core/src/metadata/mod.rs b/crates/pinakes-core/src/metadata/mod.rs index 8fcc8b7..0ea4da3 100644 --- a/crates/pinakes-core/src/metadata/mod.rs +++ b/crates/pinakes-core/src/metadata/mod.rs @@ -6,11 +6,7 @@ pub mod video; use std::{collections::HashMap, path::Path}; -use crate::{ - error::Result, - media_type::MediaType, - model::BookMetadata, -}; +use crate::{error::Result, media_type::MediaType, model::BookMetadata}; #[derive(Debug, Clone, Default)] pub struct ExtractedMetadata { diff --git a/crates/pinakes-core/src/plugin/mod.rs b/crates/pinakes-core/src/plugin/mod.rs index fc77aed..e43e930 100644 --- a/crates/pinakes-core/src/plugin/mod.rs +++ b/crates/pinakes-core/src/plugin/mod.rs @@ -607,42 +607,12 @@ impl PluginManager { pub async fn list_ui_pages( &self, ) -> Vec<(String, pinakes_plugin_api::UiPage)> { - let registry = self.registry.read().await; - let mut pages = Vec::new(); - for plugin in registry.list_all() { - if !plugin.enabled { - continue; - } - let plugin_dir = plugin - .manifest_path - .as_ref() - .and_then(|p| p.parent()) - .map(std::path::Path::to_path_buf); - let Some(plugin_dir) = plugin_dir else { - // No manifest path; serve only inline pages. - for entry in &plugin.manifest.ui.pages { - if let pinakes_plugin_api::manifest::UiPageEntry::Inline(page) = entry - { - pages.push((plugin.id.clone(), (**page).clone())); - } - } - continue; - }; - match plugin.manifest.load_ui_pages(&plugin_dir) { - Ok(loaded) => { - for page in loaded { - pages.push((plugin.id.clone(), page)); - } - }, - Err(e) => { - tracing::warn!( - "Failed to load UI pages for plugin '{}': {e}", - plugin.id - ); - }, - } - } - pages + 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 diff --git a/crates/pinakes-core/src/plugin/registry.rs b/crates/pinakes-core/src/plugin/registry.rs index 6e9219e..a773164 100644 --- a/crates/pinakes-core/src/plugin/registry.rs +++ b/crates/pinakes-core/src/plugin/registry.rs @@ -131,7 +131,7 @@ impl PluginRegistry { self .plugins .values() - .filter(|p| p.manifest.plugin.kind.contains(&kind.to_string())) + .filter(|p| p.manifest.plugin.kind.iter().any(|k| k == kind)) .collect() } diff --git a/crates/pinakes-core/src/storage/sqlite.rs b/crates/pinakes-core/src/storage/sqlite.rs index cfe08c9..9bd117a 100644 --- a/crates/pinakes-core/src/storage/sqlite.rs +++ b/crates/pinakes-core/src/storage/sqlite.rs @@ -1888,10 +1888,12 @@ impl StorageBackend for SqliteBackend { .unchecked_transaction() .map_err(crate::error::db_ctx("batch_tag_media", &ctx))?; // Prepare statement once for reuse - let mut stmt = tx.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 stmt = tx + .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; for mid in &media_ids { for tid in &tag_ids { diff --git a/crates/pinakes-core/tests/integration.rs b/crates/pinakes-core/tests/integration.rs index 8cc4d4d..927d012 100644 --- a/crates/pinakes-core/tests/integration.rs +++ b/crates/pinakes-core/tests/integration.rs @@ -962,7 +962,15 @@ async fn test_batch_update_media_single_field() { storage.insert_media(&item).await.unwrap(); let count = storage - .batch_update_media(&[item.id], Some("Bulk Title"), None, None, None, None, None) + .batch_update_media( + &[item.id], + Some("Bulk Title"), + None, + None, + None, + None, + None, + ) .await .unwrap(); assert_eq!(count, 1); @@ -1021,7 +1029,15 @@ async fn test_batch_update_media_subset_of_items() { // Only update item_a. let count = storage - .batch_update_media(&[item_a.id], Some("Only A"), None, None, None, None, None) + .batch_update_media( + &[item_a.id], + Some("Only A"), + None, + None, + None, + None, + None, + ) .await .unwrap(); assert_eq!(count, 1); diff --git a/crates/pinakes-plugin-api/src/manifest.rs b/crates/pinakes-plugin-api/src/manifest.rs index 340dc24..a7229c0 100644 --- a/crates/pinakes-plugin-api/src/manifest.rs +++ b/crates/pinakes-plugin-api/src/manifest.rs @@ -759,11 +759,19 @@ wasm = "plugin.wasm" let manifest = PluginManifest::parse_str(toml).unwrap(); 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") ); 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") ); } diff --git a/crates/pinakes-plugin-api/src/ui_schema.rs b/crates/pinakes-plugin-api/src/ui_schema.rs index 02ec93d..f73a5ba 100644 --- a/crates/pinakes-plugin-api/src/ui_schema.rs +++ b/crates/pinakes-plugin-api/src/ui_schema.rs @@ -275,11 +275,6 @@ impl UiWidget { /// /// Returns `SchemaError::ValidationError` if validation fails pub fn validate(&self) -> SchemaResult<()> { - if self.id.is_empty() { - return Err(SchemaError::ValidationError( - "Widget id cannot be empty".to_string(), - )); - } if self.target.is_empty() { return Err(SchemaError::ValidationError( "Widget target cannot be empty".to_string(), diff --git a/crates/pinakes-plugin-api/src/validation.rs b/crates/pinakes-plugin-api/src/validation.rs index fc060f2..b7bb445 100644 --- a/crates/pinakes-plugin-api/src/validation.rs +++ b/crates/pinakes-plugin-api/src/validation.rs @@ -331,7 +331,9 @@ impl SchemaValidator { pub(crate) fn is_reserved_route(route: &str) -> bool { 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'/')) }) } } diff --git a/crates/pinakes-server/src/dto/media.rs b/crates/pinakes-server/src/dto/media.rs index 231bbb9..dc1a155 100644 --- a/crates/pinakes-server/src/dto/media.rs +++ b/crates/pinakes-server/src/dto/media.rs @@ -15,7 +15,8 @@ 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()); + let is_longer = best + .map_or(true, |b| root.components().count() > b.components().count()); if is_longer { best = Some(root); } @@ -268,10 +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. - pub fn new( - item: pinakes_core::model::MediaItem, - roots: &[PathBuf], - ) -> Self { + pub fn new(item: pinakes_core::model::MediaItem, roots: &[PathBuf]) -> Self { Self { id: item.id.0.to_string(), path: relativize_path(&item.path, roots), @@ -358,10 +356,7 @@ mod tests { #[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" - ); + assert_eq!(relativize_path(path, &[]), "/home/user/music/song.mp3"); } #[test] diff --git a/crates/pinakes-server/src/routes/books.rs b/crates/pinakes-server/src/routes/books.rs index 7ae042f..9e3a0bc 100644 --- a/crates/pinakes-server/src/routes/books.rs +++ b/crates/pinakes-server/src/routes/books.rs @@ -195,8 +195,10 @@ pub async fn list_books( .await?; let roots = state.config.read().await.directories.roots.clone(); - let response: Vec = - items.into_iter().map(|item| MediaResponse::new(item, &roots)).collect(); + let response: Vec = items + .into_iter() + .map(|item| MediaResponse::new(item, &roots)) + .collect(); Ok(Json(response)) } @@ -225,8 +227,10 @@ pub async fn get_series_books( ) -> Result { let items = state.storage.get_series_books(&series_name).await?; let roots = state.config.read().await.directories.roots.clone(); - let response: Vec = - items.into_iter().map(|item| MediaResponse::new(item, &roots)).collect(); + let response: Vec = items + .into_iter() + .map(|item| MediaResponse::new(item, &roots)) + .collect(); Ok(Json(response)) } @@ -261,8 +265,10 @@ pub async fn get_author_books( .await?; let roots = state.config.read().await.directories.roots.clone(); - let response: Vec = - items.into_iter().map(|item| MediaResponse::new(item, &roots)).collect(); + let response: Vec = items + .into_iter() + .map(|item| MediaResponse::new(item, &roots)) + .collect(); Ok(Json(response)) } @@ -321,8 +327,10 @@ pub async fn get_reading_list( .await?; let roots = state.config.read().await.directories.roots.clone(); - let response: Vec = - items.into_iter().map(|item| MediaResponse::new(item, &roots)).collect(); + let response: Vec = items + .into_iter() + .map(|item| MediaResponse::new(item, &roots)) + .collect(); Ok(Json(response)) } diff --git a/crates/pinakes-server/src/routes/plugins.rs b/crates/pinakes-server/src/routes/plugins.rs index 5653d23..6748282 100644 --- a/crates/pinakes-server/src/routes/plugins.rs +++ b/crates/pinakes-server/src/routes/plugins.rs @@ -153,10 +153,12 @@ pub async fn list_plugin_ui_pages( let pages = plugin_manager.list_ui_pages_with_endpoints().await; let entries = pages .into_iter() - .map(|(plugin_id, page, allowed_endpoints)| PluginUiPageEntry { - plugin_id, - page, - allowed_endpoints, + .map(|(plugin_id, page, allowed_endpoints)| { + PluginUiPageEntry { + plugin_id, + page, + allowed_endpoints, + } }) .collect(); Ok(Json(entries)) diff --git a/crates/pinakes-ui/src/plugin_ui/actions.rs b/crates/pinakes-ui/src/plugin_ui/actions.rs index 8c9eb64..1c6f553 100644 --- a/crates/pinakes-ui/src/plugin_ui/actions.rs +++ b/crates/pinakes-ui/src/plugin_ui/actions.rs @@ -96,14 +96,11 @@ async fn execute_inline_action( action: &ActionDefinition, form_data: Option<&serde_json::Value>, ) -> Result { - // Build URL from path - let url = action.path.clone(); - // Merge action params with form data into query string for GET, body for // others 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 // JSON body diff --git a/crates/pinakes-ui/src/plugin_ui/data.rs b/crates/pinakes-ui/src/plugin_ui/data.rs index d3f42dc..2244fe6 100644 --- a/crates/pinakes-ui/src/plugin_ui/data.rs +++ b/crates/pinakes-ui/src/plugin_ui/data.rs @@ -2,7 +2,10 @@ //! //! 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_core::Task; @@ -15,7 +18,7 @@ use crate::client::ApiClient; #[derive(Debug, Clone, Default, PartialEq, Eq)] pub struct PluginPageData { data: HashMap, - loading: HashMap, + loading: HashSet, errors: HashMap, } @@ -29,13 +32,13 @@ impl PluginPageData { /// Check if a source is currently loading #[must_use] 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 #[must_use] - pub fn error(&self, source: &str) -> Option<&String> { - self.errors.get(source) + pub fn error(&self, source: &str) -> Option<&str> { + self.errors.get(source).map(String::as_str) } /// Check if there is data for a specific source @@ -52,7 +55,7 @@ impl PluginPageData { /// Set loading state for a source pub fn set_loading(&mut self, source: &str, loading: bool) { if loading { - self.loading.insert(source.to_string(), true); + self.loading.insert(source.to_string()); self.errors.remove(source); } else { self.loading.remove(source); @@ -161,9 +164,10 @@ async fn fetch_endpoint( /// /// Endpoint sources are deduplicated by `(path, method, params)`: if multiple /// 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. -/// All unique Endpoint and Static sources are fetched concurrently. Transform -/// sources are applied after, in iteration order, against the full result set. +/// response is shared, with each source's own `transform` applied +/// independently. All unique Endpoint and Static sources are fetched +/// concurrently. Transform sources are applied after, in iteration order, +/// against the full result set. /// /// # Errors /// @@ -263,8 +267,15 @@ pub async fn fetch_page_data( .. } => { let empty_ctx = serde_json::json!({}); - fetch_endpoint(&client, path, method.clone(), params, &empty_ctx, &allowed) - .await? + fetch_endpoint( + &client, + path, + method.clone(), + params, + &empty_ctx, + &allowed, + ) + .await? }, DataSource::Static { value } => value.clone(), DataSource::Transform { .. } => unreachable!(), @@ -296,21 +307,60 @@ pub async fn fetch_page_data( } } - // Process Transform sources sequentially; they reference results above. - for (name, source) in data_sources { - if let DataSource::Transform { - source_name, - expression, - } = source - { - let ctx = serde_json::Value::Object( - results - .iter() - .map(|(k, v): (&String, &serde_json::Value)| (k.clone(), v.clone())) - .collect(), + // Process Transform sources in dependency order. HashMap iteration order is + // non-deterministic, so a Transform referencing another Transform could see + // null if the upstream was not yet resolved. The pending loop below defers + // any Transform whose upstream is not yet in results, making progress on + // each pass until all are resolved. UiPage::validate guarantees no cycles, + // so the loop always terminates. + let mut pending: Vec<(&String, &String, &Expression)> = data_sources + .iter() + .filter_map(|(name, source)| { + match source { + DataSource::Transform { + source_name, + 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 - results.insert(name.clone(), evaluate_expression(expression, &ctx)); + for (name, _, expression) in pending { + let ctx = serde_json::Value::Object( + results + .iter() + .map(|(k, v)| (k.clone(), v.clone())) + .collect(), + ); + results.insert(name.clone(), evaluate_expression(expression, &ctx)); + } + break; } } @@ -446,7 +496,7 @@ mod tests { // Test error state 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] @@ -522,7 +572,9 @@ mod tests { 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["flag"], serde_json::json!(true)); } @@ -544,7 +596,9 @@ mod tests { 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})); // derived should return the value of "raw" from context assert_eq!(results["derived"], serde_json::json!({"ok": true})); @@ -566,13 +620,13 @@ mod tests { 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 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] async fn test_fetch_page_data_deduplicates_identical_endpoints() { use pinakes_plugin_api::DataSource; @@ -589,18 +643,18 @@ mod tests { sources.insert("b".to_string(), DataSource::Static { 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["b"], serde_json::json!(1)); assert_eq!(results.len(), 2); } - // Test: Endpoint sources with identical (path, method, params) but different - // transform expressions each get a correctly transformed result. Because the - // test runs without a real server the path is checked against the allowlist - // before any network call, so we verify the dedup key grouping through the - // allowlist rejection path: both names should see the same error message, - // proving they were grouped and the single rejection propagates to all names. + // Verifies that endpoint sources with identical (path, method, params) are + // deduplicated correctly. Because there is no real server, the allowlist + // rejection fires before any network call; both names seeing the same error + // proves they were grouped and that the single rejection propagated to all. #[tokio::test] async fn test_dedup_groups_endpoint_sources_with_same_key() { use pinakes_plugin_api::{DataSource, Expression, HttpMethod}; @@ -640,14 +694,12 @@ mod tests { ); } - // Test: multiple Transform sources referencing the same upstream Static source - // with different expressions each receive their independently transformed - // result. This exercises the transform fan-out behavior that mirrors what - // 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. + // Verifies the transform fan-out behavior: each member of a dedup group + // applies its own transform to the shared raw value independently. This + // mirrors what Endpoint dedup does after a single shared HTTP request. // - // Testing the Endpoint dedup success path with real per-member transforms - // requires a mock HTTP server and belongs in an integration test. + // Testing Endpoint dedup with real per-member transforms requires a mock HTTP + // server and belongs in an integration test. #[tokio::test] async fn test_dedup_transform_applied_per_source() { use pinakes_plugin_api::{DataSource, Expression}; @@ -670,8 +722,9 @@ mod tests { expression: Expression::Path("raw_data.name".to_string()), }); - let results = - super::fetch_page_data(&client, &sources, &[]).await.unwrap(); + let results = super::fetch_page_data(&client, &sources, &[]) + .await + .unwrap(); assert_eq!( results["raw_data"], serde_json::json!({"count": 42, "name": "test"}) @@ -681,8 +734,6 @@ mod tests { 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] async fn test_endpoint_blocked_when_not_in_allowlist() { use pinakes_plugin_api::{DataSource, HttpMethod}; @@ -705,7 +756,8 @@ mod tests { assert!( 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(); assert!( diff --git a/crates/pinakes-ui/src/plugin_ui/registry.rs b/crates/pinakes-ui/src/plugin_ui/registry.rs index fb3d1b6..4ad2b5c 100644 --- a/crates/pinakes-ui/src/plugin_ui/registry.rs +++ b/crates/pinakes-ui/src/plugin_ui/registry.rs @@ -35,13 +35,6 @@ pub struct PluginPage { pub allowed_endpoints: Vec, } -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 /// /// This is typically stored as a signal in the Dioxus tree. @@ -109,14 +102,11 @@ impl PluginRegistry { ); return; } - self.pages.insert( - (plugin_id.clone(), page_id), - PluginPage { - plugin_id, - page, - allowed_endpoints, - }, - ); + self.pages.insert((plugin_id.clone(), page_id), PluginPage { + plugin_id, + page, + allowed_endpoints, + }); } /// Get a specific page by plugin ID and page ID @@ -179,7 +169,7 @@ impl PluginRegistry { self .pages .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() } @@ -207,7 +197,9 @@ impl PluginRegistry { } match self.client.get_plugin_ui_theme_extensions().await { 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. @@ -367,7 +359,7 @@ mod tests { } #[test] - fn test_page_full_route() { + fn test_page_route() { let client = ApiClient::default(); let mut registry = PluginRegistry::new(client); registry.register_page( @@ -376,9 +368,7 @@ mod tests { vec![], ); let plugin_page = registry.get_page("my-plugin", "demo").unwrap(); - // full_route() returns page.route directly; create_test_page sets it as - // "/plugins/test/{id}" - assert_eq!(plugin_page.full_route(), "/plugins/test/demo"); + assert_eq!(plugin_page.page.route, "/plugins/test/demo"); } #[test] @@ -418,8 +408,16 @@ mod tests { fn test_all_pages_returns_references() { let client = ApiClient::default(); let mut registry = PluginRegistry::new(client); - registry.register_page("p1".to_string(), create_test_page("a", "A"), vec![]); - registry.register_page("p2".to_string(), create_test_page("b", "B"), vec![]); + registry.register_page( + "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(); assert_eq!(pages.len(), 2); @@ -536,7 +534,11 @@ mod tests { assert_eq!(registry.all_pages().len(), 0); // 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); } diff --git a/crates/pinakes-ui/src/plugin_ui/renderer.rs b/crates/pinakes-ui/src/plugin_ui/renderer.rs index e8372fd..0272e6b 100644 --- a/crates/pinakes-ui/src/plugin_ui/renderer.rs +++ b/crates/pinakes-ui/src/plugin_ui/renderer.rs @@ -110,8 +110,12 @@ pub fn PluginViewRenderer(props: PluginViewProps) -> Element { modal, local_state, }; - let page_data = - use_plugin_data(props.client, data_sources, refresh, props.allowed_endpoints); + let page_data = use_plugin_data( + props.client, + data_sources, + refresh, + props.allowed_endpoints, + ); // Consume pending navigation requests and forward to the parent use_effect(move || { @@ -151,7 +155,7 @@ pub fn PluginViewRenderer(props: PluginViewProps) -> Element { 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; rsx! { tr { - for col in props.columns.clone() { + for col in &props.columns { td { "{extract_cell(&row_val, &col.key)}" } } if !props.row_actions.is_empty() { td { class: "row-actions", - for act in props.row_actions.clone() { + for act in &props.row_actions { { let action = act.action.clone(); let row_data = row_val.clone(); let variant_class = button_variant_class(&act.variant); let page_actions = props.actions.clone(); - let success_msg: Option = - match &act.action { - ActionRef::Special(_) => None, - ActionRef::Name(name) => props - .actions - .get(name) - .and_then(|a| { - a.success_message.clone() - }), - ActionRef::Inline(a) => { - a.success_message.clone() - }, - }; - let error_msg: Option = - match &act.action { - ActionRef::Special(_) => None, - ActionRef::Name(name) => props - .actions - .get(name) - .and_then(|a| { - a.error_message.clone() - }), - ActionRef::Inline(a) => { - a.error_message.clone() - }, - }; + let (success_msg, error_msg): ( + Option, + Option, + ) = match &act.action { + ActionRef::Special(_) => (None, None), + ActionRef::Name(name) => props + .actions + .get(name) + .map_or((None, None), |a| { + ( + a.success_message.clone(), + a.error_message.clone(), + ) + }), + ActionRef::Inline(a) => ( + a.success_message.clone(), + a.error_message.clone(), + ), + }; let ctx = props.ctx; // Pre-compute data JSON at render time to // avoid moving props.data into closures. @@ -489,7 +486,8 @@ pub fn render_element( || "0".to_string(), |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! { div { class: "plugin-container", @@ -829,20 +827,18 @@ pub fn render_element( let variant_class = button_variant_class(variant); let action_ref = action.clone(); let page_actions = actions.clone(); - let success_msg: Option = match action { - ActionRef::Special(_) => None, - ActionRef::Name(name) => { - actions.get(name).and_then(|a| a.success_message.clone()) - }, - ActionRef::Inline(a) => a.success_message.clone(), - }; - let error_msg: Option = match action { - ActionRef::Special(_) => None, - ActionRef::Name(name) => { - actions.get(name).and_then(|a| a.error_message.clone()) - }, - ActionRef::Inline(a) => a.error_message.clone(), - }; + let (success_msg, error_msg): (Option, Option) = + match action { + ActionRef::Special(_) => (None, None), + ActionRef::Name(name) => { + actions.get(name).map_or((None, None), |a| { + (a.success_message.clone(), a.error_message.clone()) + }) + }, + ActionRef::Inline(a) => { + (a.success_message.clone(), a.error_message.clone()) + }, + }; let data_snapshot = build_ctx(data, &ctx.local_state.read()); rsx! { button { @@ -904,20 +900,18 @@ pub fn render_element( } => { let action_ref = submit_action.clone(); let page_actions = actions.clone(); - let success_msg: Option = match submit_action { - ActionRef::Special(_) => None, - ActionRef::Name(name) => { - actions.get(name).and_then(|a| a.success_message.clone()) - }, - ActionRef::Inline(a) => a.success_message.clone(), - }; - let error_msg: Option = match submit_action { - ActionRef::Special(_) => None, - ActionRef::Name(name) => { - actions.get(name).and_then(|a| a.error_message.clone()) - }, - ActionRef::Inline(a) => a.error_message.clone(), - }; + let (success_msg, error_msg): (Option, Option) = + match submit_action { + ActionRef::Special(_) => (None, None), + ActionRef::Name(name) => { + actions.get(name).map_or((None, None), |a| { + (a.success_message.clone(), a.error_message.clone()) + }) + }, + ActionRef::Inline(a) => { + (a.success_message.clone(), a.error_message.clone()) + }, + }; let data_snapshot = build_ctx(data, &ctx.local_state.read()); rsx! { form { @@ -1096,8 +1090,6 @@ pub fn render_element( } => { let chart_class = chart_type_class(chart_type); 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! { div { class: "plugin-chart {chart_class}", @@ -1111,7 +1103,7 @@ pub fn render_element( 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}" } } 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("")) } } } } -- 2.43.0 From 91123fc90eadfd5e059cd3adc4b7cadbaebaa407 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Wed, 11 Mar 2026 18:21:13 +0300 Subject: [PATCH 39/46] pinakes-core: use `InvalidOperation` for nil `media_id` in `upsert_book_metadata` Signed-off-by: NotAShelf Change-Id: I72a80731d926b79660abf20c2c766e8c6a6a6964 --- crates/pinakes-core/src/storage/sqlite.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/pinakes-core/src/storage/sqlite.rs b/crates/pinakes-core/src/storage/sqlite.rs index 9bd117a..847256a 100644 --- a/crates/pinakes-core/src/storage/sqlite.rs +++ b/crates/pinakes-core/src/storage/sqlite.rs @@ -5089,7 +5089,7 @@ impl StorageBackend for SqliteBackend { metadata: &crate::model::BookMetadata, ) -> Result<()> { if metadata.media_id.0.is_nil() { - return Err(PinakesError::Database( + return Err(PinakesError::InvalidOperation( "upsert_book_metadata: media_id must not be nil".to_string(), )); } -- 2.43.0 From 7989d4c4dd9c49f78251e91b3189300e5a2852ed Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Wed, 11 Mar 2026 21:26:20 +0300 Subject: [PATCH 40/46] pinakes-plugin-api: add reserved-route and required-endpoint validation Signed-off-by: NotAShelf Change-Id: Id85a7e729b26af8eb028e19418a5a1706a6a6964 --- crates/pinakes-plugin-api/src/ui_schema.rs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/crates/pinakes-plugin-api/src/ui_schema.rs b/crates/pinakes-plugin-api/src/ui_schema.rs index f73a5ba..c96f1d3 100644 --- a/crates/pinakes-plugin-api/src/ui_schema.rs +++ b/crates/pinakes-plugin-api/src/ui_schema.rs @@ -801,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) => { return Err(SchemaError::ValidationError(format!( "Chart references unknown data source: {data}" @@ -867,6 +862,11 @@ impl UiElement { submit_action, .. } => { + if fields.is_empty() { + return Err(SchemaError::ValidationError( + "Form must have at least one field".to_string(), + )); + } for field in fields { validate_id(&field.id)?; if field.label.is_empty() { -- 2.43.0 From 220dfa6506adb7286508581755c20f265abffa73 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Wed, 11 Mar 2026 21:26:41 +0300 Subject: [PATCH 41/46] pinakes-ui: add plugin component stylesheet Signed-off-by: NotAShelf Change-Id: I05de526f0cea5df269b0fee226ef1edf6a6a6964 --- crates/pinakes-ui/assets/css/main.css | 2 +- crates/pinakes-ui/assets/styles/_plugins.scss | 706 +++++++++++++++++- 2 files changed, 701 insertions(+), 7 deletions(-) diff --git a/crates/pinakes-ui/assets/css/main.css b/crates/pinakes-ui/assets/css/main.css index 30f105a..746e00c 100644 --- a/crates/pinakes-ui/assets/css/main.css +++ b/crates/pinakes-ui/assets/css/main.css @@ -1 +1 @@ -@media (prefers-reduced-motion: reduce){*,*::before,*::after{animation-duration:.01ms !important;animation-iteration-count:1 !important;transition-duration:.01ms !important}}*{margin:0;padding:0;box-sizing:border-box;scrollbar-width:thin;scrollbar-color:rgba(255,255,255,.06) rgba(0,0,0,0)}*::-webkit-scrollbar{width:5px;height:5px}*::-webkit-scrollbar-track{background:rgba(0,0,0,0)}*::-webkit-scrollbar-thumb{background:rgba(255,255,255,.06);border-radius:3px}*::-webkit-scrollbar-thumb:hover{background:rgba(255,255,255,.14)}:root{--bg-0: #111118;--bg-1: #18181f;--bg-2: #1f1f28;--bg-3: #26263a;--border-subtle: rgba(255,255,255,.06);--border: rgba(255,255,255,.09);--border-strong: rgba(255,255,255,.14);--text-0: #dcdce4;--text-1: #a0a0b8;--text-2: #6c6c84;--accent: #7c7ef5;--accent-dim: rgba(124,126,245,.15);--accent-text: #9698f7;--success: #3ec97a;--error: #e45858;--warning: #d4a037;--radius-sm: 3px;--radius: 5px;--radius-md: 7px;--shadow-sm: 0 1px 3px rgba(0,0,0,.3);--shadow: 0 2px 8px rgba(0,0,0,.35);--shadow-lg: 0 4px 20px rgba(0,0,0,.45)}body{font-family:"Inter",-apple-system,"Segoe UI",system-ui,sans-serif;background:var(--bg-0);color:var(--text-0);font-size:13px;line-height:1.5;-webkit-font-smoothing:antialiased;overflow:hidden}:focus-visible:focus-visible{outline:2px solid #7c7ef5;outline-offset:2px}::selection{background:rgba(124,126,245,.15);color:#9698f7}a{color:#9698f7;text-decoration:none}a:hover{text-decoration:underline}code{padding:1px 5px;border-radius:3px;background:#111118;color:#9698f7;font-family:"JetBrains Mono","Fira Code",ui-monospace,monospace;font-size:11px}ul{list-style:none;padding:0}ul li{padding:3px 0;font-size:12px;color:#a0a0b8}.text-muted{color:#a0a0b8}.text-sm{font-size:11px}.mono{font-family:"JetBrains Mono","Fira Code",ui-monospace,monospace;font-size:12px}.flex-row{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:8px}.flex-between{display:flex;justify-content:space-between;align-items:center}.mb-16{margin-bottom:16px}.mb-8{margin-bottom:12px}@keyframes fade-in{from{opacity:0}to{opacity:1}}@keyframes slide-up{from{opacity:0;transform:translateY(8px)}to{opacity:1;transform:translateY(0)}}@keyframes pulse{0%, 100%{opacity:1}50%{opacity:.3}}@keyframes spin{to{transform:rotate(360deg)}}@keyframes skeleton-pulse{0%{opacity:.6}50%{opacity:.3}100%{opacity:.6}}@keyframes indeterminate{0%{transform:translateX(-100%)}100%{transform:translateX(400%)}}.app{display:flex;flex-direction:row;justify-content:flex-start;align-items:stretch;height:100vh;overflow:hidden}.sidebar{width:220px;min-width:220px;max-width:220px;background:#18181f;border-right:1px solid rgba(255,255,255,.09);display:flex;flex-direction:column;justify-content:flex-start;align-items:stretch;flex-shrink:0;user-select:none;overflow-y:auto;overflow-x:hidden;z-index:10;transition:width .15s,min-width .15s,max-width .15s}.sidebar.collapsed{width:48px;min-width:48px;max-width:48px}.sidebar.collapsed .nav-label,.sidebar.collapsed .sidebar-header .logo,.sidebar.collapsed .sidebar-header .version,.sidebar.collapsed .nav-badge,.sidebar.collapsed .nav-item-text,.sidebar.collapsed .sidebar-footer .status-text,.sidebar.collapsed .user-name,.sidebar.collapsed .role-badge,.sidebar.collapsed .user-info .btn,.sidebar.collapsed .sidebar-import-header span,.sidebar.collapsed .sidebar-import-file{display:none}.sidebar.collapsed .nav-item{justify-content:center;padding:8px;border-left:none;border-radius:3px}.sidebar.collapsed .nav-item.active{border-left:none}.sidebar.collapsed .nav-icon{width:auto;margin:0}.sidebar.collapsed .sidebar-header{padding:12px 8px;justify-content:center}.sidebar.collapsed .nav-section{padding:0 4px}.sidebar.collapsed .sidebar-footer{padding:8px}.sidebar.collapsed .sidebar-footer .user-info{justify-content:center;padding:4px}.sidebar.collapsed .sidebar-import-progress{padding:6px}.sidebar-header{padding:16px 16px 20px;display:flex;flex-direction:row;justify-content:flex-start;align-items:baseline;gap:8px}.sidebar-header .logo{font-size:15px;font-weight:700;letter-spacing:-.4px;color:#dcdce4}.sidebar-header .version{font-size:10px;color:#6c6c84}.sidebar-toggle{background:rgba(0,0,0,0);border:none;color:#6c6c84;padding:8px;font-size:18px;width:100%;text-align:center}.sidebar-toggle:hover{color:#dcdce4}.sidebar-spacer{flex:1}.sidebar-footer{padding:12px;border-top:1px solid rgba(255,255,255,.06);overflow:visible;min-width:0}.nav-section{padding:0 8px;margin-bottom:2px}.nav-label{padding:8px 8px 4px;font-size:10px;font-weight:600;text-transform:uppercase;letter-spacing:.04em;color:#6c6c84}.nav-item{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:8px;padding:6px 8px;border-radius:3px;cursor:pointer;color:#a0a0b8;font-size:13px;font-weight:450;transition:color .1s,background .1s;border:none;background:none;width:100%;text-align:left;border-left:2px solid rgba(0,0,0,0);margin-left:0}.nav-item:hover{color:#dcdce4;background:rgba(255,255,255,.03)}.nav-item.active{color:#9698f7;border-left-color:#7c7ef5;background:rgba(124,126,245,.15)}.nav-item-text{flex:1;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;min-width:0}.sidebar:not(.collapsed) .nav-item-text{overflow:visible}.nav-icon{width:18px;text-align:center;font-size:14px;opacity:.7}.nav-badge{margin-left:auto;font-size:10px;font-weight:600;color:#6c6c84;background:#26263a;padding:1px 6px;border-radius:12px;min-width:20px;text-align:center;font-variant-numeric:tabular-nums}.status-indicator{display:flex;flex-direction:row;justify-content:center;align-items:center;gap:6px;font-size:11px;font-weight:500;min-width:0;overflow:visible}.sidebar:not(.collapsed) .status-indicator{justify-content:flex-start}.status-dot{width:6px;height:6px;border-radius:50%;flex-shrink:0}.status-dot.connected{background:#3ec97a}.status-dot.disconnected{background:#e45858}.status-dot.checking{background:#d4a037;animation:pulse 1.5s infinite}.status-text{color:#6c6c84;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;min-width:0}.sidebar:not(.collapsed) .status-text{overflow:visible}.main{flex:1;display:flex;flex-direction:column;justify-content:flex-start;align-items:stretch;overflow:hidden;min-width:0}.header{height:48px;min-height:48px;border-bottom:1px solid rgba(255,255,255,.06);display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:12px;padding:0 20px;background:#18181f}.page-title{font-size:14px;font-weight:600;color:#dcdce4}.header-spacer{flex:1}.content{flex:1;overflow-y:auto;padding:20px;scrollbar-width:thin;scrollbar-color:rgba(255,255,255,.06) rgba(0,0,0,0)}.sidebar-import-progress{padding:10px 12px;background:#1f1f28;border-top:1px solid rgba(255,255,255,.06);font-size:11px}.sidebar-import-header{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:6px;margin-bottom:4px;color:#a0a0b8}.sidebar-import-file{color:#6c6c84;font-size:10px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;margin-bottom:4px}.sidebar-import-progress .progress-bar{height:3px}.user-info{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:6px;font-size:12px;overflow:hidden;min-width:0}.user-name{font-weight:500;color:#dcdce4;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:90px;flex-shrink:1}.role-badge{display:inline-block;padding:1px 6px;border-radius:3px;font-size:10px;font-weight:600;text-transform:uppercase}.role-badge.role-admin{background:rgba(139,92,246,.1);color:#9d8be0}.role-badge.role-editor{background:rgba(34,160,80,.1);color:#5cb97a}.role-badge.role-viewer{background:rgba(59,120,200,.1);color:#6ca0d4}.btn{padding:5px 12px;border-radius:3px;border:none;cursor:pointer;font-size:12px;font-weight:500;transition:all .1s;display:inline-flex;align-items:center;gap:5px;white-space:nowrap;line-height:1.5}.btn-primary{background:#7c7ef5;color:#fff}.btn-primary:hover{background:#8b8df7}.btn-secondary{background:#26263a;color:#dcdce4;border:1px solid rgba(255,255,255,.09)}.btn-secondary:hover{border-color:rgba(255,255,255,.14);background:rgba(255,255,255,.06)}.btn-danger{background:rgba(0,0,0,0);color:#e45858;border:1px solid rgba(228,88,88,.25)}.btn-danger:hover{background:rgba(228,88,88,.08)}.btn-ghost{background:rgba(0,0,0,0);border:none;color:#a0a0b8;padding:5px 8px}.btn-ghost:hover{color:#dcdce4;background:rgba(255,255,255,.04)}.btn-sm{padding:3px 8px;font-size:11px}.btn-icon{padding:4px;border-radius:3px;background:rgba(0,0,0,0);border:none;color:#6c6c84;cursor:pointer;transition:color .1s;font-size:13px}.btn-icon:hover{color:#dcdce4}.btn:disabled,.btn[disabled]{opacity:.4;cursor:not-allowed;pointer-events:none}.btn.btn-disabled-hint:disabled{opacity:.6;border-style:dashed;pointer-events:auto;cursor:help}.card{background:#1f1f28;border:1px solid rgba(255,255,255,.09);border-radius:5px;padding:16px}.card-header{display:flex;justify-content:space-between;align-items:center;margin-bottom:12px}.card-title{font-size:14px;font-weight:600}.data-table{width:100%;border-collapse:collapse;background:#1f1f28;border:1px solid rgba(255,255,255,.09);border-radius:5px;overflow:hidden}.data-table thead th{padding:8px 14px;text-align:left;font-size:10px;font-weight:600;text-transform:uppercase;letter-spacing:.04em;color:#6c6c84;border-bottom:1px solid rgba(255,255,255,.09);background:#26263a}.data-table tbody td{padding:8px 14px;font-size:13px;border-bottom:1px solid rgba(255,255,255,.06);max-width:300px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.data-table tbody tr{cursor:pointer;transition:background .08s}.data-table tbody tr:hover{background:rgba(255,255,255,.02)}.data-table tbody tr.row-selected{background:rgba(99,102,241,.12)}.data-table tbody tr:last-child td{border-bottom:none}.sortable-header{cursor:pointer;user-select:none;transition:color .1s}.sortable-header:hover{color:#9698f7}input[type=text],textarea,select{padding:6px 10px;border-radius:3px;border:1px solid rgba(255,255,255,.09);background:#111118;color:#dcdce4;font-size:13px;outline:none;transition:border-color .15s;font-family:inherit}input[type=text]::placeholder,textarea::placeholder,select::placeholder{color:#6c6c84}input[type=text]:focus,textarea:focus,select:focus{border-color:#7c7ef5}input[type=text][type=number],textarea[type=number],select[type=number]{width:80px;padding:6px 8px;-moz-appearance:textfield}input[type=text][type=number]::-webkit-outer-spin-button,input[type=text][type=number]::-webkit-inner-spin-button,textarea[type=number]::-webkit-outer-spin-button,textarea[type=number]::-webkit-inner-spin-button,select[type=number]::-webkit-outer-spin-button,select[type=number]::-webkit-inner-spin-button{-webkit-appearance:none;margin:0}textarea{min-height:64px;resize:vertical}select{appearance:none;-webkit-appearance:none;background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='10' viewBox='0 0 10 10'%3E%3Cpath fill='%236c6c84' d='M5 7L1 3h8z'/%3E%3C/svg%3E");background-repeat:no-repeat;background-position:right 8px center;padding-right:26px;min-width:100px}.form-group{margin-bottom:12px}.form-label{display:block;font-size:11px;font-weight:600;color:#a0a0b8;margin-bottom:4px;text-transform:uppercase;letter-spacing:.03em}.form-row{display:flex;flex-direction:row;justify-content:flex-start;align-items:flex-end;gap:8px}.form-row input[type=text]{flex:1}.form-label-row{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:2px;margin-bottom:4px}.form-label-row .form-label{margin-bottom:0}input[type=checkbox]{appearance:none;-webkit-appearance:none;width:16px;height:16px;border:1px solid rgba(255,255,255,.14);border-radius:3px;background:#1f1f28;cursor:pointer;position:relative;flex-shrink:0;transition:all .15s ease}input[type=checkbox]:hover{border-color:#7c7ef5;background:#26263a}input[type=checkbox]:checked{background:#7c7ef5;border-color:#7c7ef5}input[type=checkbox]:checked::after{content:"";position:absolute;left:5px;top:2px;width:4px;height:8px;border:solid #111118;border-width:0 2px 2px 0;transform:rotate(45deg)}input[type=checkbox]:focus-visible:focus-visible{outline:2px solid #7c7ef5;outline-offset:2px}.checkbox-label{display:inline-flex;align-items:center;gap:8px;cursor:pointer;font-size:13px;color:#a0a0b8;user-select:none}.checkbox-label:hover{color:#dcdce4}.checkbox-label input[type=checkbox]{margin:0}.toggle{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:8px;cursor:pointer;font-size:13px;color:#dcdce4}.toggle.disabled{opacity:.4;cursor:not-allowed}.toggle-track{width:32px;height:18px;border-radius:9px;background:#26263a;border:1px solid rgba(255,255,255,.09);position:relative;transition:background .15s;flex-shrink:0}.toggle-track.active{background:#7c7ef5;border-color:#7c7ef5}.toggle-track.active .toggle-thumb{transform:translateX(14px)}.toggle-thumb{width:14px;height:14px;border-radius:50%;background:#dcdce4;position:absolute;top:1px;left:1px;transition:transform .15s}.filter-bar{display:flex;flex-direction:column;justify-content:flex-start;align-items:stretch;gap:12px;padding:12px;background:#111118;border:1px solid rgba(255,255,255,.06);border-radius:5px;margin-bottom:12px}.filter-row{display:flex;flex-wrap:wrap;align-items:center;gap:8px}.filter-label{font-size:11px;font-weight:500;color:#6c6c84;text-transform:uppercase;letter-spacing:.5px;margin-right:4px}.filter-chip{display:inline-flex;align-items:center;gap:6px;padding:5px 10px;background:#1f1f28;border:1px solid rgba(255,255,255,.09);border-radius:14px;cursor:pointer;font-size:11px;color:#a0a0b8;transition:all .15s ease;user-select:none}.filter-chip:hover{background:#26263a;border-color:rgba(255,255,255,.14);color:#dcdce4}.filter-chip.active{background:rgba(124,126,245,.15);border-color:#7c7ef5;color:#9698f7}.filter-chip input[type=checkbox]{width:12px;height:12px;margin:0}.filter-chip input[type=checkbox]:checked::after{left:3px;top:1px;width:3px;height:6px}.filter-group{display:flex;align-items:center;gap:6px}.filter-group label{display:flex;align-items:center;gap:3px;cursor:pointer;color:#a0a0b8;font-size:11px;white-space:nowrap}.filter-group label:hover{color:#dcdce4}.filter-separator{width:1px;height:20px;background:rgba(255,255,255,.09);flex-shrink:0}.view-toggle{display:flex;border:1px solid rgba(255,255,255,.09);border-radius:3px;overflow:hidden}.view-btn{padding:4px 10px;background:#1f1f28;border:none;color:#6c6c84;cursor:pointer;font-size:18px;line-height:1;transition:background .1s,color .1s}.view-btn:first-child{border-right:1px solid rgba(255,255,255,.09)}.view-btn:hover{color:#dcdce4;background:#26263a}.view-btn.active{background:rgba(124,126,245,.15);color:#9698f7}.breadcrumb{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:4px;padding:10px 16px;font-size:.85rem;color:#6c6c84}.breadcrumb-sep{color:#6c6c84;opacity:.5}.breadcrumb-link{color:#9698f7;text-decoration:none;cursor:pointer}.breadcrumb-link:hover{text-decoration:underline}.breadcrumb-current{color:#dcdce4;font-weight:500}.progress-bar{width:100%;height:8px;background:#26263a;border-radius:4px;overflow:hidden;margin-bottom:6px}.progress-fill{height:100%;background:#7c7ef5;border-radius:4px;transition:width .3s ease}.progress-fill.indeterminate{width:30%;animation:indeterminate 1.5s ease-in-out infinite}.loading-overlay{display:flex;align-items:center;justify-content:center;padding:48px 16px;color:#6c6c84;font-size:13px;gap:10px}.spinner{width:18px;height:18px;border:2px solid rgba(255,255,255,.09);border-top-color:#7c7ef5;border-radius:50%;animation:spin .7s linear infinite}.spinner-small{width:14px;height:14px;border:2px solid rgba(255,255,255,.09);border-top-color:#7c7ef5;border-radius:50%;animation:spin .7s linear infinite}.spinner-tiny{width:10px;height:10px;border:1.5px solid rgba(255,255,255,.09);border-top-color:#7c7ef5;border-radius:50%;animation:spin .7s linear infinite}.modal-overlay{position:fixed;inset:0;background:rgba(0,0,0,.5);display:flex;align-items:center;justify-content:center;z-index:100;animation:fade-in .1s ease-out}.modal{background:#1f1f28;border:1px solid rgba(255,255,255,.09);border-radius:7px;padding:20px;min-width:360px;max-width:480px;box-shadow:0 4px 20px rgba(0,0,0,.45)}.modal.wide{max-width:600px;max-height:70vh;overflow-y:auto}.modal-title{font-size:15px;font-weight:600;margin-bottom:6px}.modal-body{font-size:12px;color:#a0a0b8;margin-bottom:16px;line-height:1.5}.modal-actions{display:flex;flex-direction:row;justify-content:flex-end;align-items:center;gap:6px}.tooltip-trigger{display:inline-flex;align-items:center;justify-content:center;width:14px;height:14px;border-radius:50%;background:#26263a;color:#6c6c84;font-size:9px;font-weight:700;cursor:help;position:relative;flex-shrink:0;margin-left:4px}.tooltip-trigger:hover{background:rgba(124,126,245,.15);color:#9698f7}.tooltip-trigger:hover .tooltip-text{display:block}.tooltip-text{display:none;position:absolute;bottom:calc(100% + 6px);left:50%;transform:translateX(-50%);padding:6px 10px;background:#26263a;border:1px solid rgba(255,255,255,.09);border-radius:3px;color:#dcdce4;font-size:11px;font-weight:400;line-height:1.4;white-space:normal;width:220px;text-transform:none;letter-spacing:normal;box-shadow:0 2px 8px rgba(0,0,0,.35);z-index:100;pointer-events:none}.media-player{position:relative;background:#111118;border-radius:5px;overflow:hidden}.media-player:focus{outline:none}.media-player-audio .player-artwork{display:flex;flex-direction:column;justify-content:center;align-items:center;gap:8px;padding:24px 16px 8px}.player-artwork img{max-width:200px;max-height:200px;border-radius:5px;object-fit:cover}.player-artwork-placeholder{width:120px;height:120px;display:flex;align-items:center;justify-content:center;background:#1f1f28;border-radius:5px;font-size:48px;opacity:.3}.player-title{font-size:13px;font-weight:500;color:#dcdce4;text-align:center}.player-controls{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:8px;padding:10px 14px;background:#1f1f28}.media-player-video .player-controls{position:absolute;bottom:0;left:0;right:0;background:rgba(0,0,0,.7);opacity:0;transition:opacity .2s}.media-player-video:hover .player-controls{opacity:1}.play-btn,.mute-btn,.fullscreen-btn{background:none;border:none;color:#dcdce4;cursor:pointer;font-size:18px;padding:4px;line-height:1;transition:color .1s}.play-btn:hover,.mute-btn:hover,.fullscreen-btn:hover{color:#9698f7}.player-time{font-size:11px;color:#6c6c84;font-family:"JetBrains Mono","Fira Code",ui-monospace,monospace;min-width:36px;text-align:center;user-select:none}.seek-bar{flex:1;-webkit-appearance:none;appearance:none;height:4px;border-radius:4px;background:#26263a;outline:none;cursor:pointer}.seek-bar::-webkit-slider-thumb{-webkit-appearance:none;appearance:none;width:12px;height:12px;border-radius:50%;background:#7c7ef5;cursor:pointer;border:none}.seek-bar::-moz-range-thumb{width:12px;height:12px;border-radius:50%;background:#7c7ef5;cursor:pointer;border:none}.volume-slider{width:70px;-webkit-appearance:none;appearance:none;height:4px;border-radius:4px;background:#26263a;outline:none;cursor:pointer}.volume-slider::-webkit-slider-thumb{-webkit-appearance:none;appearance:none;width:10px;height:10px;border-radius:50%;background:#a0a0b8;cursor:pointer;border:none}.volume-slider::-moz-range-thumb{width:10px;height:10px;border-radius:50%;background:#a0a0b8;cursor:pointer;border:none}.image-viewer-overlay{position:fixed;inset:0;background:rgba(0,0,0,.92);z-index:150;display:flex;flex-direction:column;justify-content:flex-start;align-items:stretch;animation:fade-in .15s ease-out}.image-viewer-overlay:focus{outline:none}.image-viewer-toolbar{display:flex;justify-content:space-between;align-items:center;padding:10px 16px;background:rgba(0,0,0,.5);border-bottom:1px solid rgba(255,255,255,.08);z-index:2;user-select:none}.image-viewer-toolbar-left,.image-viewer-toolbar-center,.image-viewer-toolbar-right{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:6px}.iv-btn{background:rgba(255,255,255,.06);border:1px solid rgba(255,255,255,.1);color:#dcdce4;border-radius:3px;padding:4px 10px;font-size:12px;cursor:pointer;transition:background .1s}.iv-btn:hover{background:rgba(255,255,255,.12)}.iv-btn.iv-close{color:#e45858;font-weight:600}.iv-zoom-label{font-size:11px;color:#a0a0b8;min-width:40px;text-align:center;font-family:"JetBrains Mono","Fira Code",ui-monospace,monospace}.image-viewer-canvas{flex:1;display:flex;align-items:center;justify-content:center;overflow:hidden;position:relative}.image-viewer-canvas img{max-width:100%;max-height:100%;object-fit:contain;user-select:none;-webkit-user-drag:none}.pdf-viewer{display:flex;flex-direction:column;justify-content:flex-start;align-items:stretch;height:100%;min-height:500px;background:#111118;border-radius:5px;overflow:hidden}.pdf-toolbar{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:12px;padding:10px 12px;background:#18181f;border-bottom:1px solid rgba(255,255,255,.09)}.pdf-toolbar-group{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:4px}.pdf-toolbar-btn{display:flex;align-items:center;justify-content:center;width:28px;height:28px;background:#1f1f28;border:1px solid rgba(255,255,255,.09);border-radius:3px;color:#a0a0b8;font-size:14px;cursor:pointer;transition:all .15s}.pdf-toolbar-btn:hover:not(:disabled){background:#26263a;color:#dcdce4}.pdf-toolbar-btn:disabled{opacity:.4;cursor:not-allowed}.pdf-zoom-label{min-width:45px;text-align:center;font-size:12px;color:#a0a0b8}.pdf-container{flex:1;position:relative;overflow:hidden;background:#1f1f28}.pdf-object{width:100%;height:100%;border:none}.pdf-loading,.pdf-error{position:absolute;inset:0;display:flex;flex-direction:column;justify-content:center;align-items:center;gap:12px;background:#18181f;color:#a0a0b8}.pdf-error{padding:12px;text-align:center}.pdf-fallback{display:flex;flex-direction:column;justify-content:center;align-items:center;gap:16px;padding:48px 12px;text-align:center;color:#6c6c84}.markdown-viewer{padding:16px;text-align:left;display:flex;flex-direction:column;justify-content:flex-start;align-items:stretch;gap:12px}.markdown-toolbar{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:8px;padding:8px;background:#1f1f28;border-radius:5px;border:1px solid rgba(255,255,255,.09)}.toolbar-btn{padding:6px 12px;border:1px solid rgba(255,255,255,.09);border-radius:3px;background:#18181f;color:#a0a0b8;font-size:13px;font-weight:500;cursor:pointer;transition:all .15s}.toolbar-btn:hover{background:#26263a;border-color:rgba(255,255,255,.14)}.toolbar-btn.active{background:#7c7ef5;color:#fff;border-color:#7c7ef5}.markdown-source{max-width:100%;background:#26263a;border:1px solid rgba(255,255,255,.09);border-radius:5px;padding:16px;overflow-x:auto;font-family:"Menlo","Monaco","Courier New",monospace;font-size:13px;line-height:1.7;color:#dcdce4;white-space:pre-wrap;word-wrap:break-word}.markdown-source code{font-family:inherit;background:none;padding:0;border:none}.markdown-content{max-width:800px;color:#dcdce4;line-height:1.7;font-size:14px;text-align:left}.markdown-content h1{font-size:1.8em;font-weight:700;margin:1em 0 .5em;border-bottom:1px solid rgba(255,255,255,.06);padding-bottom:.3em}.markdown-content h2{font-size:1.5em;font-weight:600;margin:.8em 0 .4em;border-bottom:1px solid rgba(255,255,255,.06);padding-bottom:.2em}.markdown-content h3{font-size:1.25em;font-weight:600;margin:.6em 0 .3em}.markdown-content h4{font-size:1.1em;font-weight:600;margin:.5em 0 .25em}.markdown-content h5,.markdown-content h6{font-size:1em;font-weight:600;margin:.4em 0 .2em;color:#a0a0b8}.markdown-content p{margin:0 0 1em}.markdown-content a{color:#7c7ef5;text-decoration:none}.markdown-content a:hover{text-decoration:underline}.markdown-content pre{background:#26263a;border:1px solid rgba(255,255,255,.09);border-radius:3px;padding:12px 16px;overflow-x:auto;margin:0 0 1em;font-family:"JetBrains Mono","Fira Code",ui-monospace,monospace;font-size:12px;line-height:1.5}.markdown-content code{background:#26263a;padding:1px 5px;border-radius:3px;font-family:"JetBrains Mono","Fira Code",ui-monospace,monospace;font-size:.9em}.markdown-content pre code{background:none;padding:0}.markdown-content blockquote{border-left:3px solid #7c7ef5;padding:4px 16px;margin:0 0 1em;color:#a0a0b8;background:rgba(124,126,245,.04)}.markdown-content table{width:100%;border-collapse:collapse;margin:0 0 1em}.markdown-content th,.markdown-content td{padding:6px 12px;border:1px solid rgba(255,255,255,.09);font-size:13px}.markdown-content th{background:#26263a;font-weight:600;text-align:left}.markdown-content tr:nth-child(even){background:#1f1f28}.markdown-content ul,.markdown-content ol{margin:0 0 1em;padding-left:16px}.markdown-content ul{list-style:disc}.markdown-content ol{list-style:decimal}.markdown-content li{padding:2px 0;font-size:14px;color:#dcdce4}.markdown-content hr{border:none;border-top:1px solid rgba(255,255,255,.09);margin:1.5em 0}.markdown-content img{max-width:100%;border-radius:5px}.markdown-content .footnote-definition{font-size:.85em;color:#a0a0b8;margin-top:.5em;padding-left:1.5em}.markdown-content .footnote-definition sup{color:#7c7ef5;margin-right:4px}.markdown-content sup a{color:#7c7ef5;text-decoration:none;font-size:.8em}.wikilink{color:#9698f7;text-decoration:none;border-bottom:1px dashed #7c7ef5;cursor:pointer;transition:border-color .1s,color .1s}.wikilink:hover{color:#7c7ef5;border-bottom-style:solid}.wikilink-embed{display:inline-block;padding:2px 8px;background:rgba(139,92,246,.08);border:1px dashed rgba(139,92,246,.3);border-radius:3px;color:#9d8be0;font-size:12px;cursor:default}.media-grid{display:grid;grid-template-columns:repeat(auto-fill, minmax(180px, 1fr));gap:12px}.media-card{background:#1f1f28;border:1px solid rgba(255,255,255,.09);border-radius:5px;overflow:hidden;cursor:pointer;transition:border-color .12s,box-shadow .12s;position:relative}.media-card:hover{border-color:rgba(255,255,255,.14);box-shadow:0 1px 3px rgba(0,0,0,.3)}.media-card.selected{border-color:#7c7ef5;box-shadow:0 0 0 1px #7c7ef5}.card-checkbox{position:absolute;top:6px;left:6px;z-index:2;opacity:0;transition:opacity .1s}.card-checkbox input[type=checkbox]{width:16px;height:16px;cursor:pointer;filter:drop-shadow(0 1px 2px rgba(0,0,0,.5))}.media-card:hover .card-checkbox,.media-card.selected .card-checkbox{opacity:1}.card-thumbnail{width:100%;aspect-ratio:1;background:#111118;display:flex;align-items:center;justify-content:center;overflow:hidden;position:relative}.card-thumbnail img,.card-thumbnail .card-thumb-img{width:100%;height:100%;object-fit:cover;position:absolute;top:0;left:0;z-index:1}.card-type-icon{font-size:32px;opacity:.4;display:flex;align-items:center;justify-content:center;width:100%;height:100%;position:absolute;top:0;left:0;z-index:0}.card-info{padding:8px 10px}.card-name{font-size:12px;font-weight:500;color:#dcdce4;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;margin-bottom:4px}.card-title,.card-artist{font-size:10px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;line-height:1.3}.card-meta{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:6px;font-size:10px}.card-size{color:#6c6c84;font-size:10px}.table-thumb-cell{width:36px;padding:4px 6px !important;position:relative}.table-thumb{width:28px;height:28px;object-fit:cover;border-radius:3px;display:block}.table-thumb-overlay{position:absolute;top:4px;left:6px;z-index:1}.table-type-icon{display:flex;align-items:center;justify-content:center;width:28px;height:28px;font-size:14px;opacity:.5;border-radius:3px;background:#111118;z-index:0}.type-badge{display:inline-block;padding:1px 6px;border-radius:3px;font-size:10px;font-weight:600;text-transform:uppercase;letter-spacing:.04em}.type-badge.type-audio{background:rgba(139,92,246,.1);color:#9d8be0}.type-badge.type-video{background:rgba(200,72,130,.1);color:#d07eaa}.type-badge.type-image{background:rgba(34,160,80,.1);color:#5cb97a}.type-badge.type-document{background:rgba(59,120,200,.1);color:#6ca0d4}.type-badge.type-text{background:rgba(200,160,36,.1);color:#c4a840}.type-badge.type-other{background:rgba(128,128,160,.08);color:#6c6c84}.tag-list{display:flex;flex-wrap:wrap;gap:4px}.tag-badge{display:inline-flex;align-items:center;gap:4px;padding:2px 10px;background:rgba(124,126,245,.15);color:#9698f7;border-radius:12px;font-size:11px;font-weight:500}.tag-badge.selected{background:#7c7ef5;color:#fff;cursor:pointer}.tag-badge:not(.selected){cursor:pointer}.tag-badge .tag-remove{cursor:pointer;opacity:.4;font-size:13px;line-height:1;transition:opacity .1s}.tag-badge .tag-remove:hover{opacity:1}.tag-group{margin-bottom:6px}.tag-children{margin-left:16px;margin-top:4px;display:flex;flex-wrap:wrap;gap:4px}.tag-confirm-delete{display:inline-flex;align-items:center;gap:4px;font-size:10px;color:#a0a0b8}.tag-confirm-yes{cursor:pointer;color:#e45858;font-weight:600}.tag-confirm-yes:hover{text-decoration:underline}.tag-confirm-no{cursor:pointer;color:#6c6c84;font-weight:500}.tag-confirm-no:hover{text-decoration:underline}.detail-actions{display:flex;gap:6px;margin-bottom:16px}.detail-grid{display:grid;grid-template-columns:1fr 1fr;gap:8px}.detail-field{padding:10px 12px;background:#111118;border-radius:3px;border:1px solid rgba(255,255,255,.06)}.detail-field.full-width{grid-column:1/-1}.detail-field input[type=text],.detail-field textarea,.detail-field select{width:100%;margin-top:4px}.detail-field textarea{min-height:64px;resize:vertical}.detail-label{font-size:10px;font-weight:600;color:#6c6c84;text-transform:uppercase;letter-spacing:.04em;margin-bottom:2px}.detail-value{font-size:13px;color:#dcdce4;word-break:break-all}.detail-value.mono{font-family:"JetBrains Mono","Fira Code",ui-monospace,monospace;font-size:11px;color:#a0a0b8}.detail-preview{margin-bottom:16px;background:#111118;border:1px solid rgba(255,255,255,.06);border-radius:5px;overflow:hidden;text-align:center}.detail-preview:has(.markdown-viewer){max-height:none;overflow-y:auto;text-align:left}.detail-preview:not(:has(.markdown-viewer)){max-height:450px}.detail-preview img{max-width:100%;max-height:400px;object-fit:contain;display:block;margin:0 auto}.detail-preview audio{width:100%;padding:16px}.detail-preview video{max-width:100%;max-height:400px;display:block;margin:0 auto}.detail-no-preview{padding:16px 16px;text-align:center;display:flex;flex-direction:column;justify-content:center;align-items:center;gap:10px}.frontmatter-card{max-width:800px;background:#1f1f28;border:1px solid rgba(255,255,255,.09);border-radius:5px;padding:12px 16px;margin-bottom:16px}.frontmatter-fields{display:grid;grid-template-columns:auto 1fr;gap:4px 12px;margin:0}.frontmatter-fields dt{font-weight:600;font-size:12px;color:#a0a0b8;text-transform:capitalize}.frontmatter-fields dd{font-size:13px;color:#dcdce4;margin:0}.empty-state{text-align:center;padding:48px 12px;color:#6c6c84}.empty-state .empty-icon{font-size:32px;margin-bottom:12px;opacity:.3}.empty-title{font-size:15px;font-weight:600;color:#a0a0b8;margin-bottom:4px}.empty-subtitle{font-size:12px;max-width:320px;margin:0 auto;line-height:1.5}.toast-container{position:fixed;bottom:16px;right:16px;z-index:300;display:flex;flex-direction:column-reverse;gap:6px;align-items:flex-end}.toast-container .toast{position:static;transform:none}.toast{position:fixed;bottom:16px;right:16px;padding:10px 16px;border-radius:5px;background:#26263a;border:1px solid rgba(255,255,255,.09);color:#dcdce4;font-size:12px;box-shadow:0 2px 8px rgba(0,0,0,.35);z-index:300;animation:slide-up .15s ease-out;max-width:420px}.toast.success{border-left:3px solid #3ec97a}.toast.error{border-left:3px solid #e45858}.offline-banner,.error-banner{background:rgba(228,88,88,.06);border:1px solid rgba(228,88,88,.2);border-radius:3px;padding:10px 12px;margin-bottom:12px;font-size:12px;color:#d47070;display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:8px}.offline-banner .offline-icon,.offline-banner .error-icon,.error-banner .offline-icon,.error-banner .error-icon{font-size:14px;flex-shrink:0}.error-banner{padding:10px 14px}.readonly-banner{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:8px;padding:10px 12px;background:rgba(212,160,55,.06);border:1px solid rgba(212,160,55,.15);border-radius:3px;margin-bottom:16px;font-size:12px;color:#d4a037}.batch-actions{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:8px;padding:8px 10px;background:rgba(124,126,245,.15);border:1px solid rgba(124,126,245,.2);border-radius:3px;margin-bottom:12px;font-size:12px;font-weight:500;color:#9698f7}.select-all-banner{display:flex;flex-direction:row;justify-content:center;align-items:center;gap:8px;padding:10px 16px;background:rgba(99,102,241,.08);border-radius:6px;margin-bottom:8px;font-size:.85rem;color:#a0a0b8}.select-all-banner button{background:none;border:none;color:#7c7ef5;cursor:pointer;font-weight:600;text-decoration:underline;font-size:.85rem;padding:0}.select-all-banner button:hover{color:#dcdce4}.import-status-panel{background:#1f1f28;border:1px solid #7c7ef5;border-radius:5px;padding:12px 16px;margin-bottom:16px}.import-status-header{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:8px;margin-bottom:8px;font-size:13px;color:#dcdce4}.import-current-file{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:2px;margin-bottom:6px;font-size:12px;overflow:hidden}.import-file-label{color:#6c6c84;flex-shrink:0}.import-file-name{color:#dcdce4;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;font-family:monospace;font-size:11px}.import-queue-indicator{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:2px;margin-bottom:8px;font-size:11px}.import-queue-badge{display:flex;align-items:center;justify-content:center;min-width:18px;height:18px;padding:0 6px;background:rgba(124,126,245,.15);color:#9698f7;border-radius:9px;font-weight:600;font-size:10px}.import-queue-text{color:#6c6c84}.import-tabs{display:flex;gap:0;margin-bottom:16px;border-bottom:1px solid rgba(255,255,255,.09)}.import-tab{padding:10px 16px;background:none;border:none;border-bottom:2px solid rgba(0,0,0,0);color:#6c6c84;font-size:12px;font-weight:500;cursor:pointer;transition:color .1s,border-color .1s}.import-tab:hover{color:#dcdce4}.import-tab.active{color:#9698f7;border-bottom-color:#7c7ef5}.queue-panel{display:flex;flex-direction:column;justify-content:flex-start;align-items:stretch;border-left:1px solid rgba(255,255,255,.09);background:#18181f;min-width:280px;max-width:320px}.queue-header{display:flex;justify-content:space-between;align-items:center;padding:12px 16px;border-bottom:1px solid rgba(255,255,255,.06)}.queue-header h3{margin:0;font-size:.9rem;color:#dcdce4}.queue-controls{display:flex;gap:2px}.queue-list{overflow-y:auto;flex:1}.queue-item{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;padding:8px 16px;cursor:pointer;border-bottom:1px solid rgba(255,255,255,.06);transition:background .15s}.queue-item:hover{background:#1f1f28}.queue-item:hover .queue-item-remove{opacity:1}.queue-item-active{background:rgba(124,126,245,.15);border-left:3px solid #7c7ef5}.queue-item-info{flex:1;min-width:0}.queue-item-title{display:block;font-size:.85rem;color:#dcdce4;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.queue-item-artist{display:block;font-size:.75rem;color:#6c6c84}.queue-item-remove{opacity:0;transition:opacity .15s}.queue-empty{padding:16px 16px;text-align:center;color:#6c6c84;font-size:.85rem}.statistics-page{padding:20px}.stats-overview,.stats-grid{display:grid;grid-template-columns:repeat(3, 1fr);gap:16px;margin-bottom:24px}@media (max-width: 768px){.stats-overview,.stats-grid{grid-template-columns:1fr}}.stat-card{background:#1f1f28;border:1px solid rgba(255,255,255,.09);border-radius:5px;padding:20px;display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:16px}.stat-card.stat-primary{border-left:3px solid #7c7ef5}.stat-card.stat-success{border-left:3px solid #3ec97a}.stat-card.stat-info{border-left:3px solid #6ca0d4}.stat-card.stat-warning{border-left:3px solid #d4a037}.stat-card.stat-purple{border-left:3px solid #9d8be0}.stat-card.stat-danger{border-left:3px solid #e45858}.stat-icon{flex-shrink:0;color:#6c6c84}.stat-content{flex:1}.stat-value{font-size:28px;font-weight:700;color:#dcdce4;line-height:1.2;font-variant-numeric:tabular-nums}.stat-label{font-size:12px;color:#6c6c84;margin-top:4px;font-weight:500}.stats-section{background:#1f1f28;border:1px solid rgba(255,255,255,.09);border-radius:5px;padding:16px;margin-bottom:20px}.section-title{font-size:16px;font-weight:600;color:#dcdce4;margin-bottom:20px}.section-title.small{font-size:14px;margin-bottom:12px;padding-bottom:6px;border-bottom:1px solid rgba(255,255,255,.06)}.chart-bars{display:flex;flex-direction:column;justify-content:flex-start;align-items:stretch;gap:16px}.bar-item{display:grid;grid-template-columns:120px 1fr 80px;align-items:center;gap:16px}.bar-label{font-size:13px;font-weight:500;color:#a0a0b8;text-align:right}.bar-track{height:28px;background:#26263a;border-radius:3px;overflow:hidden;position:relative}.bar-fill{height:100%;transition:width .6s cubic-bezier(.4, 0, .2, 1);border-radius:3px}.bar-fill.bar-primary{background:linear-gradient(90deg, #7c7ef5 0%, #7c7ef3 100%)}.bar-fill.bar-success{background:linear-gradient(90deg, #3ec97a 0%, #66bb6a 100%)}.bar-value{font-size:13px;font-weight:600;color:#a0a0b8;text-align:right;font-variant-numeric:tabular-nums}.settings-section{margin-bottom:16px}.settings-card{background:#1f1f28;border:1px solid rgba(255,255,255,.09);border-radius:7px;padding:20px;margin-bottom:16px}.settings-card.danger-card{border:1px solid rgba(228,88,88,.25)}.settings-card-header{display:flex;justify-content:space-between;align-items:center;margin-bottom:16px;padding-bottom:12px;border-bottom:1px solid rgba(255,255,255,.06)}.settings-card-title{font-size:14px;font-weight:600}.settings-card-body{padding-top:2px}.settings-field{display:flex;justify-content:space-between;align-items:center;padding:8px 0;border-bottom:1px solid rgba(255,255,255,.06)}.settings-field:last-child{border-bottom:none}.settings-field select{min-width:120px}.config-path{font-size:11px;color:#6c6c84;margin-bottom:12px;font-family:"JetBrains Mono","Fira Code",ui-monospace,monospace;padding:6px 10px;background:#111118;border-radius:3px;border:1px solid rgba(255,255,255,.06)}.config-status{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:6px;padding:3px 10px;border-radius:12px;font-size:11px;font-weight:600}.config-status.writable{background:rgba(62,201,122,.1);color:#3ec97a}.config-status.readonly{background:rgba(228,88,88,.1);color:#e45858}.root-list{list-style:none}.root-item{display:flex;justify-content:space-between;align-items:center;padding:8px 12px;background:#111118;border:1px solid rgba(255,255,255,.06);border-radius:3px;margin-bottom:4px;font-family:"JetBrains Mono","Fira Code",ui-monospace,monospace;font-size:12px;color:#a0a0b8}.info-row{display:flex;justify-content:space-between;align-items:center;padding:6px 0;border-bottom:1px solid rgba(255,255,255,.06);font-size:13px}.info-row:last-child{border-bottom:none}.info-label{color:#a0a0b8;font-weight:500}.info-value{color:#dcdce4}.tasks-grid{display:grid;grid-template-columns:repeat(auto-fill, minmax(400px, 1fr));gap:16px;padding:12px}.task-card{background:#1f1f28;border:1px solid rgba(255,255,255,.09);border-radius:5px;overflow:hidden;transition:all .2s}.task-card:hover{border-color:rgba(255,255,255,.14);box-shadow:0 4px 12px rgba(0,0,0,.08);transform:translateY(-2px)}.task-card-enabled{border-left:3px solid #3ec97a}.task-card-disabled{border-left:3px solid #4a4a5e;opacity:.7}.task-card-header{display:flex;justify-content:space-between;align-items:center;align-items:flex-start;padding:16px;border-bottom:1px solid rgba(255,255,255,.06)}.task-header-left{flex:1;min-width:0}.task-name{font-size:16px;font-weight:600;color:#dcdce4;margin-bottom:2px}.task-schedule{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:6px;font-size:12px;color:#6c6c84;font-family:"Menlo","Monaco","Courier New",monospace}.schedule-icon{font-size:14px}.task-status-badge{flex-shrink:0}.status-badge{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:6px;padding:2px 10px;border-radius:3px;font-size:10px;font-weight:600;text-transform:uppercase;letter-spacing:.03em}.status-badge.status-enabled{background:rgba(76,175,80,.12);color:#3ec97a}.status-badge.status-enabled .status-dot{animation:pulse 1.5s infinite}.status-badge.status-disabled{background:#26263a;color:#6c6c84}.status-badge .status-dot{width:6px;height:6px;border-radius:50%;flex-shrink:0;background:currentColor}.task-info-grid{display:grid;grid-template-columns:repeat(auto-fit, minmax(120px, 1fr));gap:12px;padding:16px}.task-info-item{display:flex;flex-direction:row;justify-content:flex-start;align-items:flex-start;gap:10px}.task-info-icon{font-size:18px;color:#6c6c84;flex-shrink:0}.task-info-content{flex:1;min-width:0}.task-info-label{font-size:10px;color:#6c6c84;font-weight:600;text-transform:uppercase;letter-spacing:.03em;margin-bottom:2px}.task-info-value{font-size:12px;color:#a0a0b8;font-weight:500;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.task-card-actions{display:flex;gap:8px;padding:10px 16px;background:#18181f;border-top:1px solid rgba(255,255,255,.06)}.task-card-actions button{flex:1}.db-actions{display:flex;flex-direction:column;justify-content:flex-start;align-items:stretch;gap:16px;padding:10px}.db-action-row{display:flex;flex-direction:row;justify-content:space-between;align-items:center;gap:16px;padding:10px;border-radius:6px;background:rgba(0,0,0,.06)}.db-action-info{flex:1}.db-action-info h4{font-size:.95rem;font-weight:600;color:#dcdce4;margin-bottom:2px}.db-action-confirm{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:8px;flex-shrink:0}.library-toolbar{display:flex;justify-content:space-between;align-items:center;padding:8px 0;margin-bottom:12px;gap:12px;flex-wrap:wrap}.toolbar-left{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:10px}.toolbar-right{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:10px}.sort-control select,.page-size-control select{padding:4px 24px 4px 8px;font-size:11px;background:#1f1f28}.page-size-control{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:4px}.library-stats{display:flex;justify-content:space-between;align-items:center;padding:2px 0 6px 0;font-size:11px}.type-filter-row{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:6px;padding:4px 0;margin-bottom:6px;flex-wrap:wrap}.pagination{display:flex;align-items:center;justify-content:center;gap:4px;margin-top:16px;padding:8px 0}.page-btn{min-width:28px;text-align:center;font-variant-numeric:tabular-nums}.page-ellipsis{color:#6c6c84;padding:0 4px;font-size:12px;user-select:none}.audit-controls{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:8px;margin-bottom:12px}.filter-select{padding:4px 24px 4px 8px;font-size:11px;background:#1f1f28}.action-danger{background:rgba(228,88,88,.1);color:#d47070}.action-updated{background:rgba(59,120,200,.1);color:#6ca0d4}.action-collection{background:rgba(34,160,80,.1);color:#5cb97a}.action-collection-remove{background:rgba(212,160,55,.1);color:#c4a840}.action-opened{background:rgba(139,92,246,.1);color:#9d8be0}.action-scanned{background:rgba(128,128,160,.08);color:#6c6c84}.clickable{cursor:pointer;color:#9698f7}.clickable:hover{text-decoration:underline}.clickable-row{cursor:pointer}.clickable-row:hover{background:rgba(255,255,255,.03)}.duplicates-view{padding:0}.duplicates-header{display:flex;justify-content:space-between;align-items:center;margin-bottom:16px}.duplicates-header h3{margin:0}.duplicates-summary{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:12px}.duplicate-group{border:1px solid rgba(255,255,255,.09);border-radius:5px;margin-bottom:8px;overflow:hidden}.duplicate-group-header{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:12px;width:100%;padding:10px 14px;background:#1f1f28;border:none;cursor:pointer;text-align:left;color:#dcdce4;font-size:13px}.duplicate-group-header:hover{background:#26263a}.expand-icon{font-size:10px;width:14px;flex-shrink:0}.group-name{font-weight:600;flex:1;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.group-badge{background:#7c7ef5;color:#fff;padding:2px 8px;border-radius:10px;font-size:11px;font-weight:600;flex-shrink:0}.group-size{flex-shrink:0;font-size:12px}.group-hash{font-size:11px;flex-shrink:0}.duplicate-items{border-top:1px solid rgba(255,255,255,.09)}.duplicate-item{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:12px;padding:10px 14px;border-bottom:1px solid rgba(255,255,255,.06)}.duplicate-item:last-child{border-bottom:none}.duplicate-item-keep{background:rgba(76,175,80,.06)}.dup-thumb{width:48px;height:48px;flex-shrink:0;border-radius:3px;overflow:hidden}.dup-thumb-img{width:100%;height:100%;object-fit:cover}.dup-thumb-placeholder{width:100%;height:100%;display:flex;align-items:center;justify-content:center;background:#26263a;font-size:20px;color:#6c6c84}.dup-info{flex:1;min-width:0}.dup-filename{font-weight:600;font-size:13px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.dup-path{font-size:11px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.dup-meta{font-size:12px;margin-top:2px}.dup-actions{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:6px;flex-shrink:0}.keep-badge{background:rgba(76,175,80,.12);color:#4caf50;padding:2px 10px;border-radius:10px;font-size:11px;font-weight:600}.saved-searches-list{display:flex;flex-direction:column;justify-content:flex-start;align-items:stretch;gap:4px;max-height:300px;overflow-y:auto}.saved-search-item{display:flex;justify-content:space-between;align-items:center;padding:8px 12px;background:#18181f;border-radius:3px;cursor:pointer;transition:background .15s ease}.saved-search-item:hover{background:#1f1f28}.saved-search-info{display:flex;flex-direction:column;justify-content:flex-start;align-items:stretch;gap:2px;flex:1;min-width:0}.saved-search-name{font-weight:500;color:#dcdce4}.saved-search-query{font-size:11px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.backlinks-panel,.outgoing-links-panel{background:#1f1f28;border:1px solid rgba(255,255,255,.09);border-radius:5px;margin-top:16px;overflow:hidden}.backlinks-header,.outgoing-links-header{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:8px;padding:10px 14px;background:#26263a;cursor:pointer;user-select:none;transition:background .1s}.backlinks-header:hover,.outgoing-links-header:hover{background:rgba(255,255,255,.04)}.backlinks-toggle,.outgoing-links-toggle{font-size:10px;color:#6c6c84;width:12px;text-align:center}.backlinks-title,.outgoing-links-title{font-size:12px;font-weight:600;color:#dcdce4;flex:1}.backlinks-count,.outgoing-links-count{font-size:11px;color:#6c6c84}.backlinks-reindex-btn{display:flex;align-items:center;justify-content:center;width:22px;height:22px;padding:0;margin-left:auto;background:rgba(0,0,0,0);border:1px solid rgba(255,255,255,.09);border-radius:3px;color:#6c6c84;font-size:12px;cursor:pointer;transition:background .1s,color .1s,border-color .1s}.backlinks-reindex-btn:hover:not(:disabled){background:#1f1f28;color:#dcdce4;border-color:rgba(255,255,255,.14)}.backlinks-reindex-btn:disabled{opacity:.5;cursor:not-allowed}.backlinks-content,.outgoing-links-content{padding:12px;border-top:1px solid rgba(255,255,255,.06)}.backlinks-loading,.outgoing-links-loading{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:8px;padding:12px;color:#6c6c84;font-size:12px}.backlinks-error,.outgoing-links-error{padding:8px 12px;background:rgba(228,88,88,.06);border:1px solid rgba(228,88,88,.2);border-radius:3px;font-size:12px;color:#e45858}.backlinks-empty,.outgoing-links-empty{padding:16px;text-align:center;color:#6c6c84;font-size:12px;font-style:italic}.backlinks-list,.outgoing-links-list{list-style:none;padding:0;margin:0;display:flex;flex-direction:column;justify-content:flex-start;align-items:stretch;gap:6px}.backlink-item,.outgoing-link-item{padding:10px 12px;background:#111118;border:1px solid rgba(255,255,255,.06);border-radius:3px;cursor:pointer;transition:background .1s,border-color .1s}.backlink-item:hover,.outgoing-link-item:hover{background:#18181f;border-color:rgba(255,255,255,.09)}.backlink-item.unresolved,.outgoing-link-item.unresolved{opacity:.7;border-style:dashed}.backlink-source,.outgoing-link-target{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:8px;margin-bottom:2px}.backlink-title,.outgoing-link-text{font-size:13px;font-weight:500;color:#dcdce4;flex:1;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.backlink-type-badge,.outgoing-link-type-badge{display:inline-block;padding:1px 6px;border-radius:12px;font-size:9px;font-weight:600;text-transform:uppercase;letter-spacing:.03em}.backlink-type-badge.backlink-type-wikilink,.backlink-type-badge.link-type-wikilink,.outgoing-link-type-badge.backlink-type-wikilink,.outgoing-link-type-badge.link-type-wikilink{background:rgba(124,126,245,.15);color:#9698f7}.backlink-type-badge.backlink-type-embed,.backlink-type-badge.link-type-embed,.outgoing-link-type-badge.backlink-type-embed,.outgoing-link-type-badge.link-type-embed{background:rgba(139,92,246,.1);color:#9d8be0}.backlink-type-badge.backlink-type-markdown_link,.backlink-type-badge.link-type-markdown_link,.outgoing-link-type-badge.backlink-type-markdown_link,.outgoing-link-type-badge.link-type-markdown_link{background:rgba(59,120,200,.1);color:#6ca0d4}.backlink-context{font-size:11px;color:#6c6c84;line-height:1.4;overflow:hidden;text-overflow:ellipsis;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical}.backlink-line{color:#a0a0b8;font-weight:500}.unresolved-badge{padding:1px 6px;border-radius:12px;font-size:9px;font-weight:600;background:rgba(212,160,55,.1);color:#d4a037}.outgoing-links-unresolved-badge{margin-left:8px;padding:2px 8px;border-radius:10px;font-size:10px;font-weight:500;background:rgba(212,160,55,.12);color:#d4a037}.outgoing-links-global-unresolved{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:6px;margin-top:12px;padding:10px 12px;background:rgba(212,160,55,.06);border:1px solid rgba(212,160,55,.15);border-radius:3px;font-size:11px;color:#6c6c84}.outgoing-links-global-unresolved .unresolved-icon{color:#d4a037}.backlinks-message{padding:8px 10px;margin-bottom:10px;border-radius:3px;font-size:11px}.backlinks-message.success{background:rgba(62,201,122,.08);border:1px solid rgba(62,201,122,.2);color:#3ec97a}.backlinks-message.error{background:rgba(228,88,88,.06);border:1px solid rgba(228,88,88,.2);color:#e45858}.graph-view{display:flex;flex-direction:column;justify-content:flex-start;align-items:stretch;height:100%;background:#18181f;border-radius:5px;overflow:hidden}.graph-toolbar{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:16px;padding:12px 16px;background:#1f1f28;border-bottom:1px solid rgba(255,255,255,.09)}.graph-title{font-size:14px;font-weight:600;color:#dcdce4}.graph-controls{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:8px;font-size:12px;color:#a0a0b8}.graph-controls select{padding:4px 20px 4px 8px;font-size:11px;background:#26263a}.graph-stats{margin-left:auto;font-size:11px;color:#6c6c84}.graph-container{flex:1;position:relative;display:flex;align-items:center;justify-content:center;overflow:hidden;background:#111118}.graph-loading,.graph-error,.graph-empty{display:flex;flex-direction:column;justify-content:center;align-items:center;gap:10px;padding:48px;color:#6c6c84;font-size:13px;text-align:center}.graph-svg{max-width:100%;max-height:100%;cursor:grab}.graph-svg-container{position:relative;width:100%;height:100%}.graph-zoom-controls{position:absolute;top:16px;left:16px;display:flex;flex-direction:column;justify-content:flex-start;align-items:stretch;gap:8px;z-index:5}.zoom-btn{width:36px;height:36px;border-radius:6px;background:#1f1f28;border:1px solid rgba(255,255,255,.09);color:#dcdce4;font-size:18px;font-weight:bold;display:flex;align-items:center;justify-content:center;cursor:pointer;transition:all .15s;box-shadow:0 1px 3px rgba(0,0,0,.3)}.zoom-btn:hover{background:#26263a;border-color:rgba(255,255,255,.14);transform:scale(1.05)}.zoom-btn:active{transform:scale(.95)}.graph-edges line{stroke:rgba(255,255,255,.14);stroke-width:1;opacity:.6}.graph-edges line.edge-type-wikilink{stroke:#7c7ef5}.graph-edges line.edge-type-embed{stroke:#9d8be0;stroke-dasharray:4 2}.graph-nodes .graph-node{cursor:pointer}.graph-nodes .graph-node circle{fill:#4caf50;stroke:#388e3c;stroke-width:2;transition:fill .15s,stroke .15s}.graph-nodes .graph-node:hover circle{fill:#66bb6a}.graph-nodes .graph-node.selected circle{fill:#7c7ef5;stroke:#5456d6}.graph-nodes .graph-node text{fill:#a0a0b8;font-size:11px;pointer-events:none;text-anchor:middle;dominant-baseline:central;transform:translateY(16px)}.node-details-panel{position:absolute;top:16px;right:16px;width:280px;background:#1f1f28;border:1px solid rgba(255,255,255,.09);border-radius:5px;box-shadow:0 2px 8px rgba(0,0,0,.35);z-index:10}.node-details-header{display:flex;justify-content:space-between;align-items:center;padding:10px 14px;border-bottom:1px solid rgba(255,255,255,.06)}.node-details-header h3{font-size:13px;font-weight:600;color:#dcdce4;margin:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.node-details-header .close-btn{background:none;border:none;color:#6c6c84;cursor:pointer;font-size:14px;padding:2px 6px;line-height:1}.node-details-header .close-btn:hover{color:#dcdce4}.node-details-content{padding:14px}.node-details-content .node-title{font-size:12px;color:#a0a0b8;margin-bottom:12px}.node-stats{display:flex;gap:16px;margin-bottom:12px}.node-stats .stat{font-size:12px;color:#6c6c84}.node-stats .stat strong{color:#dcdce4}.physics-controls-panel{position:absolute;top:16px;right:16px;width:300px;background:#1f1f28;border:1px solid rgba(255,255,255,.09);border-radius:5px;box-shadow:0 2px 8px rgba(0,0,0,.35);padding:16px;z-index:10}.physics-controls-panel h4{font-size:13px;font-weight:600;color:#dcdce4;margin:0 0 16px 0;padding-bottom:8px;border-bottom:1px solid rgba(255,255,255,.06)}.physics-controls-panel .btn{width:100%;margin-top:8px}.control-group{margin-bottom:14px}.control-group label{display:block;font-size:11px;font-weight:500;color:#a0a0b8;margin-bottom:6px;text-transform:uppercase;letter-spacing:.5px}.control-group input[type=range]{width:100%;height:4px;border-radius:4px;background:#26263a;outline:none;-webkit-appearance:none}.control-group input[type=range]::-webkit-slider-thumb{-webkit-appearance:none;appearance:none;width:14px;height:14px;border-radius:50%;background:#7c7ef5;cursor:pointer;transition:transform .1s}.control-group input[type=range]::-webkit-slider-thumb:hover{transform:scale(1.15)}.control-group input[type=range]::-moz-range-thumb{width:14px;height:14px;border-radius:50%;background:#7c7ef5;cursor:pointer;border:none;transition:transform .1s}.control-group input[type=range]::-moz-range-thumb:hover{transform:scale(1.15)}.control-value{display:inline-block;margin-top:2px;font-size:11px;color:#6c6c84;font-family:"JetBrains Mono","Fira Code",ui-monospace,monospace}.theme-light{--bg-0: #f5f5f7;--bg-1: #eeeef0;--bg-2: #fff;--bg-3: #e8e8ec;--border-subtle: rgba(0,0,0,.06);--border: rgba(0,0,0,.1);--border-strong: rgba(0,0,0,.16);--text-0: #1a1a2e;--text-1: #555570;--text-2: #8888a0;--accent: #6366f1;--accent-dim: rgba(99,102,241,.1);--accent-text: #4f52e8;--shadow-sm: 0 1px 3px rgba(0,0,0,.08);--shadow: 0 2px 8px rgba(0,0,0,.1);--shadow-lg: 0 4px 20px rgba(0,0,0,.12)}.theme-light ::-webkit-scrollbar-thumb{background:rgba(0,0,0,.12)}.theme-light ::-webkit-scrollbar-thumb:hover{background:rgba(0,0,0,.08)}.theme-light ::-webkit-scrollbar-track{background:rgba(0,0,0,.06)}.theme-light .graph-nodes .graph-node text{fill:#1a1a2e}.theme-light .graph-edges line{stroke:rgba(0,0,0,.12)}.theme-light .pdf-container{background:#e8e8ec}.skeleton-pulse{animation:skeleton-pulse 1.5s ease-in-out infinite;background:#26263a;border-radius:4px}.skeleton-card{display:flex;flex-direction:column;justify-content:flex-start;align-items:stretch;gap:8px;padding:8px}.skeleton-thumb{width:100%;aspect-ratio:1;border-radius:6px}.skeleton-text{height:14px;width:80%}.skeleton-text-short{width:50%}.skeleton-row{display:flex;gap:12px;padding:10px 16px;align-items:center}.skeleton-cell{height:14px;flex:1;border-radius:4px}.skeleton-cell-icon{width:32px;height:32px;flex:none;border-radius:4px}.skeleton-cell-wide{flex:3}.loading-overlay{position:absolute;inset:0;display:flex;flex-direction:column;justify-content:center;align-items:center;gap:10px;background:rgba(0,0,0,.3);z-index:100;border-radius:8px}.loading-spinner{width:32px;height:32px;border:3px solid rgba(255,255,255,.09);border-top-color:#7c7ef5;border-radius:50%;animation:spin .8s linear infinite}.loading-message{color:#a0a0b8;font-size:.9rem}.login-container{display:flex;align-items:center;justify-content:center;height:100vh;background:#111118}.login-card{background:#1f1f28;border:1px solid rgba(255,255,255,.09);border-radius:7px;padding:24px;width:360px;box-shadow:0 4px 20px rgba(0,0,0,.45)}.login-title{font-size:20px;font-weight:700;color:#dcdce4;text-align:center;margin-bottom:2px}.login-subtitle{font-size:13px;color:#6c6c84;text-align:center;margin-bottom:20px}.login-error{background:rgba(228,88,88,.08);border:1px solid rgba(228,88,88,.2);border-radius:3px;padding:8px 12px;margin-bottom:12px;font-size:12px;color:#e45858}.login-form input[type=text],.login-form input[type=password]{width:100%}.login-btn{width:100%;padding:8px 16px;font-size:13px;margin-top:2px}.pagination{display:flex;flex-direction:row;justify-content:center;align-items:center;gap:2px;margin-top:16px;padding:8px 0}.page-btn{min-width:28px;text-align:center;font-variant-numeric:tabular-nums}.page-ellipsis{color:#6c6c84;padding:0 4px;font-size:12px;user-select:none}.help-overlay{position:fixed;inset:0;background:rgba(0,0,0,.5);display:flex;align-items:center;justify-content:center;z-index:200;animation:fade-in .1s ease-out}.help-dialog{background:#1f1f28;border:1px solid rgba(255,255,255,.09);border-radius:7px;padding:16px;min-width:300px;max-width:400px;box-shadow:0 4px 20px rgba(0,0,0,.45)}.help-dialog h3{font-size:16px;font-weight:600;margin-bottom:16px}.help-shortcuts{display:flex;flex-direction:column;justify-content:flex-start;align-items:stretch;gap:8px;margin-bottom:16px}.shortcut-row{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:12px}.shortcut-row kbd{display:inline-block;padding:2px 8px;background:#111118;border:1px solid rgba(255,255,255,.09);border-radius:3px;font-family:"JetBrains Mono","Fira Code",ui-monospace,monospace;font-size:11px;color:#dcdce4;min-width:32px;text-align:center}.shortcut-row span{font-size:13px;color:#a0a0b8}.help-close{display:block;width:100%;padding:6px 12px;background:#26263a;border:1px solid rgba(255,255,255,.09);border-radius:3px;color:#dcdce4;font-size:12px;cursor:pointer;text-align:center}.help-close:hover{background:rgba(255,255,255,.06)}.plugin-container{display:flex;flex-direction:column;gap:var(--plugin-gap, 0px);padding:var(--plugin-padding, 0)}.plugin-grid{display:grid;grid-template-columns:repeat(var(--plugin-columns, 1), 1fr);gap:var(--plugin-gap, 0px)}.plugin-flex{display:flex;gap:var(--plugin-gap, 0px)}.plugin-flex[data-direction=row]{flex-direction:row}.plugin-flex[data-direction=column]{flex-direction:column}.plugin-flex[data-justify=flex-start]{justify-content:flex-start}.plugin-flex[data-justify=flex-end]{justify-content:flex-end}.plugin-flex[data-justify=center]{justify-content:center}.plugin-flex[data-justify=space-between]{justify-content:space-between}.plugin-flex[data-justify=space-around]{justify-content:space-around}.plugin-flex[data-justify=space-evenly]{justify-content:space-evenly}.plugin-flex[data-align=flex-start]{align-items:flex-start}.plugin-flex[data-align=flex-end]{align-items:flex-end}.plugin-flex[data-align=center]{align-items:center}.plugin-flex[data-align=stretch]{align-items:stretch}.plugin-flex[data-align=baseline]{align-items:baseline}.plugin-flex[data-wrap=wrap]{flex-wrap:wrap}.plugin-flex[data-wrap=nowrap]{flex-wrap:nowrap}.plugin-split{display:flex}.plugin-split-sidebar{width:var(--plugin-sidebar-width, 200px);flex-shrink:0}.plugin-split-main{flex:1;min-width:0}.plugin-media-grid{display:grid;grid-template-columns:repeat(var(--plugin-columns, 2), 1fr);gap:var(--plugin-gap, 8px)}.plugin-col-constrained{width:var(--plugin-col-width)}.plugin-progress-bar{height:100%;background:#7c7ef5;border-radius:4px;transition:width .3s ease;width:var(--plugin-progress, 0%)}.plugin-chart{overflow:auto;height:var(--plugin-chart-height, 200px)} \ No newline at end of file +@media (prefers-reduced-motion: reduce){*,*::before,*::after{animation-duration:.01ms !important;animation-iteration-count:1 !important;transition-duration:.01ms !important}}*{margin:0;padding:0;box-sizing:border-box;scrollbar-width:thin;scrollbar-color:rgba(255,255,255,.06) rgba(0,0,0,0)}*::-webkit-scrollbar{width:5px;height:5px}*::-webkit-scrollbar-track{background:rgba(0,0,0,0)}*::-webkit-scrollbar-thumb{background:rgba(255,255,255,.06);border-radius:3px}*::-webkit-scrollbar-thumb:hover{background:rgba(255,255,255,.14)}:root{--bg-0: #111118;--bg-1: #18181f;--bg-2: #1f1f28;--bg-3: #26263a;--border-subtle: rgba(255,255,255,.06);--border: rgba(255,255,255,.09);--border-strong: rgba(255,255,255,.14);--text-0: #dcdce4;--text-1: #a0a0b8;--text-2: #6c6c84;--accent: #7c7ef5;--accent-dim: rgba(124,126,245,.15);--accent-text: #9698f7;--success: #3ec97a;--error: #e45858;--warning: #d4a037;--radius-sm: 3px;--radius: 5px;--radius-md: 7px;--shadow-sm: 0 1px 3px rgba(0,0,0,.3);--shadow: 0 2px 8px rgba(0,0,0,.35);--shadow-lg: 0 4px 20px rgba(0,0,0,.45)}body{font-family:"Inter",-apple-system,"Segoe UI",system-ui,sans-serif;background:var(--bg-0);color:var(--text-0);font-size:13px;line-height:1.5;-webkit-font-smoothing:antialiased;overflow:hidden}:focus-visible:focus-visible{outline:2px solid #7c7ef5;outline-offset:2px}::selection{background:rgba(124,126,245,.15);color:#9698f7}a{color:#9698f7;text-decoration:none}a:hover{text-decoration:underline}code{padding:1px 5px;border-radius:3px;background:#111118;color:#9698f7;font-family:"JetBrains Mono","Fira Code",ui-monospace,monospace;font-size:11px}ul{list-style:none;padding:0}ul li{padding:3px 0;font-size:12px;color:#a0a0b8}.text-muted{color:#a0a0b8}.text-sm{font-size:11px}.mono{font-family:"JetBrains Mono","Fira Code",ui-monospace,monospace;font-size:12px}.flex-row{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:8px}.flex-between{display:flex;justify-content:space-between;align-items:center}.mb-16{margin-bottom:16px}.mb-8{margin-bottom:12px}@keyframes fade-in{from{opacity:0}to{opacity:1}}@keyframes slide-up{from{opacity:0;transform:translateY(8px)}to{opacity:1;transform:translateY(0)}}@keyframes pulse{0%, 100%{opacity:1}50%{opacity:.3}}@keyframes spin{to{transform:rotate(360deg)}}@keyframes skeleton-pulse{0%{opacity:.6}50%{opacity:.3}100%{opacity:.6}}@keyframes indeterminate{0%{transform:translateX(-100%)}100%{transform:translateX(400%)}}.app{display:flex;flex-direction:row;justify-content:flex-start;align-items:stretch;height:100vh;overflow:hidden}.sidebar{width:220px;min-width:220px;max-width:220px;background:#18181f;border-right:1px solid rgba(255,255,255,.09);display:flex;flex-direction:column;justify-content:flex-start;align-items:stretch;flex-shrink:0;user-select:none;overflow-y:auto;overflow-x:hidden;z-index:10;transition:width .15s,min-width .15s,max-width .15s}.sidebar.collapsed{width:48px;min-width:48px;max-width:48px}.sidebar.collapsed .nav-label,.sidebar.collapsed .sidebar-header .logo,.sidebar.collapsed .sidebar-header .version,.sidebar.collapsed .nav-badge,.sidebar.collapsed .nav-item-text,.sidebar.collapsed .sidebar-footer .status-text,.sidebar.collapsed .user-name,.sidebar.collapsed .role-badge,.sidebar.collapsed .user-info .btn,.sidebar.collapsed .sidebar-import-header span,.sidebar.collapsed .sidebar-import-file{display:none}.sidebar.collapsed .nav-item{justify-content:center;padding:8px;border-left:none;border-radius:3px}.sidebar.collapsed .nav-item.active{border-left:none}.sidebar.collapsed .nav-icon{width:auto;margin:0}.sidebar.collapsed .sidebar-header{padding:12px 8px;justify-content:center}.sidebar.collapsed .nav-section{padding:0 4px}.sidebar.collapsed .sidebar-footer{padding:8px}.sidebar.collapsed .sidebar-footer .user-info{justify-content:center;padding:4px}.sidebar.collapsed .sidebar-import-progress{padding:6px}.sidebar-header{padding:16px 16px 20px;display:flex;flex-direction:row;justify-content:flex-start;align-items:baseline;gap:8px}.sidebar-header .logo{font-size:15px;font-weight:700;letter-spacing:-.4px;color:#dcdce4}.sidebar-header .version{font-size:10px;color:#6c6c84}.sidebar-toggle{background:rgba(0,0,0,0);border:none;color:#6c6c84;padding:8px;font-size:18px;width:100%;text-align:center}.sidebar-toggle:hover{color:#dcdce4}.sidebar-spacer{flex:1}.sidebar-footer{padding:12px;border-top:1px solid rgba(255,255,255,.06);overflow:visible;min-width:0}.nav-section{padding:0 8px;margin-bottom:2px}.nav-label{padding:8px 8px 4px;font-size:10px;font-weight:600;text-transform:uppercase;letter-spacing:.04em;color:#6c6c84}.nav-item{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:8px;padding:6px 8px;border-radius:3px;cursor:pointer;color:#a0a0b8;font-size:13px;font-weight:450;transition:color .1s,background .1s;border:none;background:none;width:100%;text-align:left;border-left:2px solid rgba(0,0,0,0);margin-left:0}.nav-item:hover{color:#dcdce4;background:rgba(255,255,255,.03)}.nav-item.active{color:#9698f7;border-left-color:#7c7ef5;background:rgba(124,126,245,.15)}.nav-item-text{flex:1;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;min-width:0}.sidebar:not(.collapsed) .nav-item-text{overflow:visible}.nav-icon{width:18px;text-align:center;font-size:14px;opacity:.7}.nav-badge{margin-left:auto;font-size:10px;font-weight:600;color:#6c6c84;background:#26263a;padding:1px 6px;border-radius:12px;min-width:20px;text-align:center;font-variant-numeric:tabular-nums}.status-indicator{display:flex;flex-direction:row;justify-content:center;align-items:center;gap:6px;font-size:11px;font-weight:500;min-width:0;overflow:visible}.sidebar:not(.collapsed) .status-indicator{justify-content:flex-start}.status-dot{width:6px;height:6px;border-radius:50%;flex-shrink:0}.status-dot.connected{background:#3ec97a}.status-dot.disconnected{background:#e45858}.status-dot.checking{background:#d4a037;animation:pulse 1.5s infinite}.status-text{color:#6c6c84;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;min-width:0}.sidebar:not(.collapsed) .status-text{overflow:visible}.main{flex:1;display:flex;flex-direction:column;justify-content:flex-start;align-items:stretch;overflow:hidden;min-width:0}.header{height:48px;min-height:48px;border-bottom:1px solid rgba(255,255,255,.06);display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:12px;padding:0 20px;background:#18181f}.page-title{font-size:14px;font-weight:600;color:#dcdce4}.header-spacer{flex:1}.content{flex:1;overflow-y:auto;padding:20px;scrollbar-width:thin;scrollbar-color:rgba(255,255,255,.06) rgba(0,0,0,0)}.sidebar-import-progress{padding:10px 12px;background:#1f1f28;border-top:1px solid rgba(255,255,255,.06);font-size:11px}.sidebar-import-header{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:6px;margin-bottom:4px;color:#a0a0b8}.sidebar-import-file{color:#6c6c84;font-size:10px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;margin-bottom:4px}.sidebar-import-progress .progress-bar{height:3px}.user-info{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:6px;font-size:12px;overflow:hidden;min-width:0}.user-name{font-weight:500;color:#dcdce4;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:90px;flex-shrink:1}.role-badge{display:inline-block;padding:1px 6px;border-radius:3px;font-size:10px;font-weight:600;text-transform:uppercase}.role-badge.role-admin{background:rgba(139,92,246,.1);color:#9d8be0}.role-badge.role-editor{background:rgba(34,160,80,.1);color:#5cb97a}.role-badge.role-viewer{background:rgba(59,120,200,.1);color:#6ca0d4}.btn{padding:5px 12px;border-radius:3px;border:none;cursor:pointer;font-size:12px;font-weight:500;transition:all .1s;display:inline-flex;align-items:center;gap:5px;white-space:nowrap;line-height:1.5}.btn-primary{background:#7c7ef5;color:#fff}.btn-primary:hover{background:#8b8df7}.btn-secondary{background:#26263a;color:#dcdce4;border:1px solid rgba(255,255,255,.09)}.btn-secondary:hover{border-color:rgba(255,255,255,.14);background:rgba(255,255,255,.06)}.btn-danger{background:rgba(0,0,0,0);color:#e45858;border:1px solid rgba(228,88,88,.25)}.btn-danger:hover{background:rgba(228,88,88,.08)}.btn-ghost{background:rgba(0,0,0,0);border:none;color:#a0a0b8;padding:5px 8px}.btn-ghost:hover{color:#dcdce4;background:rgba(255,255,255,.04)}.btn-sm{padding:3px 8px;font-size:11px}.btn-icon{padding:4px;border-radius:3px;background:rgba(0,0,0,0);border:none;color:#6c6c84;cursor:pointer;transition:color .1s;font-size:13px}.btn-icon:hover{color:#dcdce4}.btn:disabled,.btn[disabled]{opacity:.4;cursor:not-allowed;pointer-events:none}.btn.btn-disabled-hint:disabled{opacity:.6;border-style:dashed;pointer-events:auto;cursor:help}.card{background:#1f1f28;border:1px solid rgba(255,255,255,.09);border-radius:5px;padding:16px}.card-header{display:flex;justify-content:space-between;align-items:center;margin-bottom:12px}.card-title{font-size:14px;font-weight:600}.data-table{width:100%;border-collapse:collapse;background:#1f1f28;border:1px solid rgba(255,255,255,.09);border-radius:5px;overflow:hidden}.data-table thead th{padding:8px 14px;text-align:left;font-size:10px;font-weight:600;text-transform:uppercase;letter-spacing:.04em;color:#6c6c84;border-bottom:1px solid rgba(255,255,255,.09);background:#26263a}.data-table tbody td{padding:8px 14px;font-size:13px;border-bottom:1px solid rgba(255,255,255,.06);max-width:300px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.data-table tbody tr{cursor:pointer;transition:background .08s}.data-table tbody tr:hover{background:rgba(255,255,255,.02)}.data-table tbody tr.row-selected{background:rgba(99,102,241,.12)}.data-table tbody tr:last-child td{border-bottom:none}.sortable-header{cursor:pointer;user-select:none;transition:color .1s}.sortable-header:hover{color:#9698f7}input[type=text],textarea,select{padding:6px 10px;border-radius:3px;border:1px solid rgba(255,255,255,.09);background:#111118;color:#dcdce4;font-size:13px;outline:none;transition:border-color .15s;font-family:inherit}input[type=text]::placeholder,textarea::placeholder,select::placeholder{color:#6c6c84}input[type=text]:focus,textarea:focus,select:focus{border-color:#7c7ef5}input[type=text][type=number],textarea[type=number],select[type=number]{width:80px;padding:6px 8px;-moz-appearance:textfield}input[type=text][type=number]::-webkit-outer-spin-button,input[type=text][type=number]::-webkit-inner-spin-button,textarea[type=number]::-webkit-outer-spin-button,textarea[type=number]::-webkit-inner-spin-button,select[type=number]::-webkit-outer-spin-button,select[type=number]::-webkit-inner-spin-button{-webkit-appearance:none;margin:0}textarea{min-height:64px;resize:vertical}select{appearance:none;-webkit-appearance:none;background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='10' viewBox='0 0 10 10'%3E%3Cpath fill='%236c6c84' d='M5 7L1 3h8z'/%3E%3C/svg%3E");background-repeat:no-repeat;background-position:right 8px center;padding-right:26px;min-width:100px}.form-group{margin-bottom:12px}.form-label{display:block;font-size:11px;font-weight:600;color:#a0a0b8;margin-bottom:4px;text-transform:uppercase;letter-spacing:.03em}.form-row{display:flex;flex-direction:row;justify-content:flex-start;align-items:flex-end;gap:8px}.form-row input[type=text]{flex:1}.form-label-row{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:2px;margin-bottom:4px}.form-label-row .form-label{margin-bottom:0}input[type=checkbox]{appearance:none;-webkit-appearance:none;width:16px;height:16px;border:1px solid rgba(255,255,255,.14);border-radius:3px;background:#1f1f28;cursor:pointer;position:relative;flex-shrink:0;transition:all .15s ease}input[type=checkbox]:hover{border-color:#7c7ef5;background:#26263a}input[type=checkbox]:checked{background:#7c7ef5;border-color:#7c7ef5}input[type=checkbox]:checked::after{content:"";position:absolute;left:5px;top:2px;width:4px;height:8px;border:solid #111118;border-width:0 2px 2px 0;transform:rotate(45deg)}input[type=checkbox]:focus-visible:focus-visible{outline:2px solid #7c7ef5;outline-offset:2px}.checkbox-label{display:inline-flex;align-items:center;gap:8px;cursor:pointer;font-size:13px;color:#a0a0b8;user-select:none}.checkbox-label:hover{color:#dcdce4}.checkbox-label input[type=checkbox]{margin:0}.toggle{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:8px;cursor:pointer;font-size:13px;color:#dcdce4}.toggle.disabled{opacity:.4;cursor:not-allowed}.toggle-track{width:32px;height:18px;border-radius:9px;background:#26263a;border:1px solid rgba(255,255,255,.09);position:relative;transition:background .15s;flex-shrink:0}.toggle-track.active{background:#7c7ef5;border-color:#7c7ef5}.toggle-track.active .toggle-thumb{transform:translateX(14px)}.toggle-thumb{width:14px;height:14px;border-radius:50%;background:#dcdce4;position:absolute;top:1px;left:1px;transition:transform .15s}.filter-bar{display:flex;flex-direction:column;justify-content:flex-start;align-items:stretch;gap:12px;padding:12px;background:#111118;border:1px solid rgba(255,255,255,.06);border-radius:5px;margin-bottom:12px}.filter-row{display:flex;flex-wrap:wrap;align-items:center;gap:8px}.filter-label{font-size:11px;font-weight:500;color:#6c6c84;text-transform:uppercase;letter-spacing:.5px;margin-right:4px}.filter-chip{display:inline-flex;align-items:center;gap:6px;padding:5px 10px;background:#1f1f28;border:1px solid rgba(255,255,255,.09);border-radius:14px;cursor:pointer;font-size:11px;color:#a0a0b8;transition:all .15s ease;user-select:none}.filter-chip:hover{background:#26263a;border-color:rgba(255,255,255,.14);color:#dcdce4}.filter-chip.active{background:rgba(124,126,245,.15);border-color:#7c7ef5;color:#9698f7}.filter-chip input[type=checkbox]{width:12px;height:12px;margin:0}.filter-chip input[type=checkbox]:checked::after{left:3px;top:1px;width:3px;height:6px}.filter-group{display:flex;align-items:center;gap:6px}.filter-group label{display:flex;align-items:center;gap:3px;cursor:pointer;color:#a0a0b8;font-size:11px;white-space:nowrap}.filter-group label:hover{color:#dcdce4}.filter-separator{width:1px;height:20px;background:rgba(255,255,255,.09);flex-shrink:0}.view-toggle{display:flex;border:1px solid rgba(255,255,255,.09);border-radius:3px;overflow:hidden}.view-btn{padding:4px 10px;background:#1f1f28;border:none;color:#6c6c84;cursor:pointer;font-size:18px;line-height:1;transition:background .1s,color .1s}.view-btn:first-child{border-right:1px solid rgba(255,255,255,.09)}.view-btn:hover{color:#dcdce4;background:#26263a}.view-btn.active{background:rgba(124,126,245,.15);color:#9698f7}.breadcrumb{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:4px;padding:10px 16px;font-size:.85rem;color:#6c6c84}.breadcrumb-sep{color:#6c6c84;opacity:.5}.breadcrumb-link{color:#9698f7;text-decoration:none;cursor:pointer}.breadcrumb-link:hover{text-decoration:underline}.breadcrumb-current{color:#dcdce4;font-weight:500}.progress-bar{width:100%;height:8px;background:#26263a;border-radius:4px;overflow:hidden;margin-bottom:6px}.progress-fill{height:100%;background:#7c7ef5;border-radius:4px;transition:width .3s ease}.progress-fill.indeterminate{width:30%;animation:indeterminate 1.5s ease-in-out infinite}.loading-overlay{display:flex;align-items:center;justify-content:center;padding:48px 16px;color:#6c6c84;font-size:13px;gap:10px}.spinner{width:18px;height:18px;border:2px solid rgba(255,255,255,.09);border-top-color:#7c7ef5;border-radius:50%;animation:spin .7s linear infinite}.spinner-small{width:14px;height:14px;border:2px solid rgba(255,255,255,.09);border-top-color:#7c7ef5;border-radius:50%;animation:spin .7s linear infinite}.spinner-tiny{width:10px;height:10px;border:1.5px solid rgba(255,255,255,.09);border-top-color:#7c7ef5;border-radius:50%;animation:spin .7s linear infinite}.modal-overlay{position:fixed;inset:0;background:rgba(0,0,0,.5);display:flex;align-items:center;justify-content:center;z-index:100;animation:fade-in .1s ease-out}.modal{background:#1f1f28;border:1px solid rgba(255,255,255,.09);border-radius:7px;padding:20px;min-width:360px;max-width:480px;box-shadow:0 4px 20px rgba(0,0,0,.45)}.modal.wide{max-width:600px;max-height:70vh;overflow-y:auto}.modal-title{font-size:15px;font-weight:600;margin-bottom:6px}.modal-body{font-size:12px;color:#a0a0b8;margin-bottom:16px;line-height:1.5}.modal-actions{display:flex;flex-direction:row;justify-content:flex-end;align-items:center;gap:6px}.tooltip-trigger{display:inline-flex;align-items:center;justify-content:center;width:14px;height:14px;border-radius:50%;background:#26263a;color:#6c6c84;font-size:9px;font-weight:700;cursor:help;position:relative;flex-shrink:0;margin-left:4px}.tooltip-trigger:hover{background:rgba(124,126,245,.15);color:#9698f7}.tooltip-trigger:hover .tooltip-text{display:block}.tooltip-text{display:none;position:absolute;bottom:calc(100% + 6px);left:50%;transform:translateX(-50%);padding:6px 10px;background:#26263a;border:1px solid rgba(255,255,255,.09);border-radius:3px;color:#dcdce4;font-size:11px;font-weight:400;line-height:1.4;white-space:normal;width:220px;text-transform:none;letter-spacing:normal;box-shadow:0 2px 8px rgba(0,0,0,.35);z-index:100;pointer-events:none}.media-player{position:relative;background:#111118;border-radius:5px;overflow:hidden}.media-player:focus{outline:none}.media-player-audio .player-artwork{display:flex;flex-direction:column;justify-content:center;align-items:center;gap:8px;padding:24px 16px 8px}.player-artwork img{max-width:200px;max-height:200px;border-radius:5px;object-fit:cover}.player-artwork-placeholder{width:120px;height:120px;display:flex;align-items:center;justify-content:center;background:#1f1f28;border-radius:5px;font-size:48px;opacity:.3}.player-title{font-size:13px;font-weight:500;color:#dcdce4;text-align:center}.player-controls{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:8px;padding:10px 14px;background:#1f1f28}.media-player-video .player-controls{position:absolute;bottom:0;left:0;right:0;background:rgba(0,0,0,.7);opacity:0;transition:opacity .2s}.media-player-video:hover .player-controls{opacity:1}.play-btn,.mute-btn,.fullscreen-btn{background:none;border:none;color:#dcdce4;cursor:pointer;font-size:18px;padding:4px;line-height:1;transition:color .1s}.play-btn:hover,.mute-btn:hover,.fullscreen-btn:hover{color:#9698f7}.player-time{font-size:11px;color:#6c6c84;font-family:"JetBrains Mono","Fira Code",ui-monospace,monospace;min-width:36px;text-align:center;user-select:none}.seek-bar{flex:1;-webkit-appearance:none;appearance:none;height:4px;border-radius:4px;background:#26263a;outline:none;cursor:pointer}.seek-bar::-webkit-slider-thumb{-webkit-appearance:none;appearance:none;width:12px;height:12px;border-radius:50%;background:#7c7ef5;cursor:pointer;border:none}.seek-bar::-moz-range-thumb{width:12px;height:12px;border-radius:50%;background:#7c7ef5;cursor:pointer;border:none}.volume-slider{width:70px;-webkit-appearance:none;appearance:none;height:4px;border-radius:4px;background:#26263a;outline:none;cursor:pointer}.volume-slider::-webkit-slider-thumb{-webkit-appearance:none;appearance:none;width:10px;height:10px;border-radius:50%;background:#a0a0b8;cursor:pointer;border:none}.volume-slider::-moz-range-thumb{width:10px;height:10px;border-radius:50%;background:#a0a0b8;cursor:pointer;border:none}.image-viewer-overlay{position:fixed;inset:0;background:rgba(0,0,0,.92);z-index:150;display:flex;flex-direction:column;justify-content:flex-start;align-items:stretch;animation:fade-in .15s ease-out}.image-viewer-overlay:focus{outline:none}.image-viewer-toolbar{display:flex;justify-content:space-between;align-items:center;padding:10px 16px;background:rgba(0,0,0,.5);border-bottom:1px solid rgba(255,255,255,.08);z-index:2;user-select:none}.image-viewer-toolbar-left,.image-viewer-toolbar-center,.image-viewer-toolbar-right{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:6px}.iv-btn{background:rgba(255,255,255,.06);border:1px solid rgba(255,255,255,.1);color:#dcdce4;border-radius:3px;padding:4px 10px;font-size:12px;cursor:pointer;transition:background .1s}.iv-btn:hover{background:rgba(255,255,255,.12)}.iv-btn.iv-close{color:#e45858;font-weight:600}.iv-zoom-label{font-size:11px;color:#a0a0b8;min-width:40px;text-align:center;font-family:"JetBrains Mono","Fira Code",ui-monospace,monospace}.image-viewer-canvas{flex:1;display:flex;align-items:center;justify-content:center;overflow:hidden;position:relative}.image-viewer-canvas img{max-width:100%;max-height:100%;object-fit:contain;user-select:none;-webkit-user-drag:none}.pdf-viewer{display:flex;flex-direction:column;justify-content:flex-start;align-items:stretch;height:100%;min-height:500px;background:#111118;border-radius:5px;overflow:hidden}.pdf-toolbar{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:12px;padding:10px 12px;background:#18181f;border-bottom:1px solid rgba(255,255,255,.09)}.pdf-toolbar-group{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:4px}.pdf-toolbar-btn{display:flex;align-items:center;justify-content:center;width:28px;height:28px;background:#1f1f28;border:1px solid rgba(255,255,255,.09);border-radius:3px;color:#a0a0b8;font-size:14px;cursor:pointer;transition:all .15s}.pdf-toolbar-btn:hover:not(:disabled){background:#26263a;color:#dcdce4}.pdf-toolbar-btn:disabled{opacity:.4;cursor:not-allowed}.pdf-zoom-label{min-width:45px;text-align:center;font-size:12px;color:#a0a0b8}.pdf-container{flex:1;position:relative;overflow:hidden;background:#1f1f28}.pdf-object{width:100%;height:100%;border:none}.pdf-loading,.pdf-error{position:absolute;inset:0;display:flex;flex-direction:column;justify-content:center;align-items:center;gap:12px;background:#18181f;color:#a0a0b8}.pdf-error{padding:12px;text-align:center}.pdf-fallback{display:flex;flex-direction:column;justify-content:center;align-items:center;gap:16px;padding:48px 12px;text-align:center;color:#6c6c84}.markdown-viewer{padding:16px;text-align:left;display:flex;flex-direction:column;justify-content:flex-start;align-items:stretch;gap:12px}.markdown-toolbar{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:8px;padding:8px;background:#1f1f28;border-radius:5px;border:1px solid rgba(255,255,255,.09)}.toolbar-btn{padding:6px 12px;border:1px solid rgba(255,255,255,.09);border-radius:3px;background:#18181f;color:#a0a0b8;font-size:13px;font-weight:500;cursor:pointer;transition:all .15s}.toolbar-btn:hover{background:#26263a;border-color:rgba(255,255,255,.14)}.toolbar-btn.active{background:#7c7ef5;color:#fff;border-color:#7c7ef5}.markdown-source{max-width:100%;background:#26263a;border:1px solid rgba(255,255,255,.09);border-radius:5px;padding:16px;overflow-x:auto;font-family:"Menlo","Monaco","Courier New",monospace;font-size:13px;line-height:1.7;color:#dcdce4;white-space:pre-wrap;word-wrap:break-word}.markdown-source code{font-family:inherit;background:none;padding:0;border:none}.markdown-content{max-width:800px;color:#dcdce4;line-height:1.7;font-size:14px;text-align:left}.markdown-content h1{font-size:1.8em;font-weight:700;margin:1em 0 .5em;border-bottom:1px solid rgba(255,255,255,.06);padding-bottom:.3em}.markdown-content h2{font-size:1.5em;font-weight:600;margin:.8em 0 .4em;border-bottom:1px solid rgba(255,255,255,.06);padding-bottom:.2em}.markdown-content h3{font-size:1.25em;font-weight:600;margin:.6em 0 .3em}.markdown-content h4{font-size:1.1em;font-weight:600;margin:.5em 0 .25em}.markdown-content h5,.markdown-content h6{font-size:1em;font-weight:600;margin:.4em 0 .2em;color:#a0a0b8}.markdown-content p{margin:0 0 1em}.markdown-content a{color:#7c7ef5;text-decoration:none}.markdown-content a:hover{text-decoration:underline}.markdown-content pre{background:#26263a;border:1px solid rgba(255,255,255,.09);border-radius:3px;padding:12px 16px;overflow-x:auto;margin:0 0 1em;font-family:"JetBrains Mono","Fira Code",ui-monospace,monospace;font-size:12px;line-height:1.5}.markdown-content code{background:#26263a;padding:1px 5px;border-radius:3px;font-family:"JetBrains Mono","Fira Code",ui-monospace,monospace;font-size:.9em}.markdown-content pre code{background:none;padding:0}.markdown-content blockquote{border-left:3px solid #7c7ef5;padding:4px 16px;margin:0 0 1em;color:#a0a0b8;background:rgba(124,126,245,.04)}.markdown-content table{width:100%;border-collapse:collapse;margin:0 0 1em}.markdown-content th,.markdown-content td{padding:6px 12px;border:1px solid rgba(255,255,255,.09);font-size:13px}.markdown-content th{background:#26263a;font-weight:600;text-align:left}.markdown-content tr:nth-child(even){background:#1f1f28}.markdown-content ul,.markdown-content ol{margin:0 0 1em;padding-left:16px}.markdown-content ul{list-style:disc}.markdown-content ol{list-style:decimal}.markdown-content li{padding:2px 0;font-size:14px;color:#dcdce4}.markdown-content hr{border:none;border-top:1px solid rgba(255,255,255,.09);margin:1.5em 0}.markdown-content img{max-width:100%;border-radius:5px}.markdown-content .footnote-definition{font-size:.85em;color:#a0a0b8;margin-top:.5em;padding-left:1.5em}.markdown-content .footnote-definition sup{color:#7c7ef5;margin-right:4px}.markdown-content sup a{color:#7c7ef5;text-decoration:none;font-size:.8em}.wikilink{color:#9698f7;text-decoration:none;border-bottom:1px dashed #7c7ef5;cursor:pointer;transition:border-color .1s,color .1s}.wikilink:hover{color:#7c7ef5;border-bottom-style:solid}.wikilink-embed{display:inline-block;padding:2px 8px;background:rgba(139,92,246,.08);border:1px dashed rgba(139,92,246,.3);border-radius:3px;color:#9d8be0;font-size:12px;cursor:default}.media-grid{display:grid;grid-template-columns:repeat(auto-fill, minmax(180px, 1fr));gap:12px}.media-card{background:#1f1f28;border:1px solid rgba(255,255,255,.09);border-radius:5px;overflow:hidden;cursor:pointer;transition:border-color .12s,box-shadow .12s;position:relative}.media-card:hover{border-color:rgba(255,255,255,.14);box-shadow:0 1px 3px rgba(0,0,0,.3)}.media-card.selected{border-color:#7c7ef5;box-shadow:0 0 0 1px #7c7ef5}.card-checkbox{position:absolute;top:6px;left:6px;z-index:2;opacity:0;transition:opacity .1s}.card-checkbox input[type=checkbox]{width:16px;height:16px;cursor:pointer;filter:drop-shadow(0 1px 2px rgba(0,0,0,.5))}.media-card:hover .card-checkbox,.media-card.selected .card-checkbox{opacity:1}.card-thumbnail{width:100%;aspect-ratio:1;background:#111118;display:flex;align-items:center;justify-content:center;overflow:hidden;position:relative}.card-thumbnail img,.card-thumbnail .card-thumb-img{width:100%;height:100%;object-fit:cover;position:absolute;top:0;left:0;z-index:1}.card-type-icon{font-size:32px;opacity:.4;display:flex;align-items:center;justify-content:center;width:100%;height:100%;position:absolute;top:0;left:0;z-index:0}.card-info{padding:8px 10px}.card-name{font-size:12px;font-weight:500;color:#dcdce4;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;margin-bottom:4px}.card-title,.card-artist{font-size:10px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;line-height:1.3}.card-meta{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:6px;font-size:10px}.card-size{color:#6c6c84;font-size:10px}.table-thumb-cell{width:36px;padding:4px 6px !important;position:relative}.table-thumb{width:28px;height:28px;object-fit:cover;border-radius:3px;display:block}.table-thumb-overlay{position:absolute;top:4px;left:6px;z-index:1}.table-type-icon{display:flex;align-items:center;justify-content:center;width:28px;height:28px;font-size:14px;opacity:.5;border-radius:3px;background:#111118;z-index:0}.type-badge{display:inline-block;padding:1px 6px;border-radius:3px;font-size:10px;font-weight:600;text-transform:uppercase;letter-spacing:.04em}.type-badge.type-audio{background:rgba(139,92,246,.1);color:#9d8be0}.type-badge.type-video{background:rgba(200,72,130,.1);color:#d07eaa}.type-badge.type-image{background:rgba(34,160,80,.1);color:#5cb97a}.type-badge.type-document{background:rgba(59,120,200,.1);color:#6ca0d4}.type-badge.type-text{background:rgba(200,160,36,.1);color:#c4a840}.type-badge.type-other{background:rgba(128,128,160,.08);color:#6c6c84}.tag-list{display:flex;flex-wrap:wrap;gap:4px}.tag-badge{display:inline-flex;align-items:center;gap:4px;padding:2px 10px;background:rgba(124,126,245,.15);color:#9698f7;border-radius:12px;font-size:11px;font-weight:500}.tag-badge.selected{background:#7c7ef5;color:#fff;cursor:pointer}.tag-badge:not(.selected){cursor:pointer}.tag-badge .tag-remove{cursor:pointer;opacity:.4;font-size:13px;line-height:1;transition:opacity .1s}.tag-badge .tag-remove:hover{opacity:1}.tag-group{margin-bottom:6px}.tag-children{margin-left:16px;margin-top:4px;display:flex;flex-wrap:wrap;gap:4px}.tag-confirm-delete{display:inline-flex;align-items:center;gap:4px;font-size:10px;color:#a0a0b8}.tag-confirm-yes{cursor:pointer;color:#e45858;font-weight:600}.tag-confirm-yes:hover{text-decoration:underline}.tag-confirm-no{cursor:pointer;color:#6c6c84;font-weight:500}.tag-confirm-no:hover{text-decoration:underline}.detail-actions{display:flex;gap:6px;margin-bottom:16px}.detail-grid{display:grid;grid-template-columns:1fr 1fr;gap:8px}.detail-field{padding:10px 12px;background:#111118;border-radius:3px;border:1px solid rgba(255,255,255,.06)}.detail-field.full-width{grid-column:1/-1}.detail-field input[type=text],.detail-field textarea,.detail-field select{width:100%;margin-top:4px}.detail-field textarea{min-height:64px;resize:vertical}.detail-label{font-size:10px;font-weight:600;color:#6c6c84;text-transform:uppercase;letter-spacing:.04em;margin-bottom:2px}.detail-value{font-size:13px;color:#dcdce4;word-break:break-all}.detail-value.mono{font-family:"JetBrains Mono","Fira Code",ui-monospace,monospace;font-size:11px;color:#a0a0b8}.detail-preview{margin-bottom:16px;background:#111118;border:1px solid rgba(255,255,255,.06);border-radius:5px;overflow:hidden;text-align:center}.detail-preview:has(.markdown-viewer){max-height:none;overflow-y:auto;text-align:left}.detail-preview:not(:has(.markdown-viewer)){max-height:450px}.detail-preview img{max-width:100%;max-height:400px;object-fit:contain;display:block;margin:0 auto}.detail-preview audio{width:100%;padding:16px}.detail-preview video{max-width:100%;max-height:400px;display:block;margin:0 auto}.detail-no-preview{padding:16px 16px;text-align:center;display:flex;flex-direction:column;justify-content:center;align-items:center;gap:10px}.frontmatter-card{max-width:800px;background:#1f1f28;border:1px solid rgba(255,255,255,.09);border-radius:5px;padding:12px 16px;margin-bottom:16px}.frontmatter-fields{display:grid;grid-template-columns:auto 1fr;gap:4px 12px;margin:0}.frontmatter-fields dt{font-weight:600;font-size:12px;color:#a0a0b8;text-transform:capitalize}.frontmatter-fields dd{font-size:13px;color:#dcdce4;margin:0}.empty-state{text-align:center;padding:48px 12px;color:#6c6c84}.empty-state .empty-icon{font-size:32px;margin-bottom:12px;opacity:.3}.empty-title{font-size:15px;font-weight:600;color:#a0a0b8;margin-bottom:4px}.empty-subtitle{font-size:12px;max-width:320px;margin:0 auto;line-height:1.5}.toast-container{position:fixed;bottom:16px;right:16px;z-index:300;display:flex;flex-direction:column-reverse;gap:6px;align-items:flex-end}.toast-container .toast{position:static;transform:none}.toast{position:fixed;bottom:16px;right:16px;padding:10px 16px;border-radius:5px;background:#26263a;border:1px solid rgba(255,255,255,.09);color:#dcdce4;font-size:12px;box-shadow:0 2px 8px rgba(0,0,0,.35);z-index:300;animation:slide-up .15s ease-out;max-width:420px}.toast.success{border-left:3px solid #3ec97a}.toast.error{border-left:3px solid #e45858}.offline-banner,.error-banner{background:rgba(228,88,88,.06);border:1px solid rgba(228,88,88,.2);border-radius:3px;padding:10px 12px;margin-bottom:12px;font-size:12px;color:#d47070;display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:8px}.offline-banner .offline-icon,.offline-banner .error-icon,.error-banner .offline-icon,.error-banner .error-icon{font-size:14px;flex-shrink:0}.error-banner{padding:10px 14px}.readonly-banner{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:8px;padding:10px 12px;background:rgba(212,160,55,.06);border:1px solid rgba(212,160,55,.15);border-radius:3px;margin-bottom:16px;font-size:12px;color:#d4a037}.batch-actions{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:8px;padding:8px 10px;background:rgba(124,126,245,.15);border:1px solid rgba(124,126,245,.2);border-radius:3px;margin-bottom:12px;font-size:12px;font-weight:500;color:#9698f7}.select-all-banner{display:flex;flex-direction:row;justify-content:center;align-items:center;gap:8px;padding:10px 16px;background:rgba(99,102,241,.08);border-radius:6px;margin-bottom:8px;font-size:.85rem;color:#a0a0b8}.select-all-banner button{background:none;border:none;color:#7c7ef5;cursor:pointer;font-weight:600;text-decoration:underline;font-size:.85rem;padding:0}.select-all-banner button:hover{color:#dcdce4}.import-status-panel{background:#1f1f28;border:1px solid #7c7ef5;border-radius:5px;padding:12px 16px;margin-bottom:16px}.import-status-header{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:8px;margin-bottom:8px;font-size:13px;color:#dcdce4}.import-current-file{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:2px;margin-bottom:6px;font-size:12px;overflow:hidden}.import-file-label{color:#6c6c84;flex-shrink:0}.import-file-name{color:#dcdce4;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;font-family:monospace;font-size:11px}.import-queue-indicator{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:2px;margin-bottom:8px;font-size:11px}.import-queue-badge{display:flex;align-items:center;justify-content:center;min-width:18px;height:18px;padding:0 6px;background:rgba(124,126,245,.15);color:#9698f7;border-radius:9px;font-weight:600;font-size:10px}.import-queue-text{color:#6c6c84}.import-tabs{display:flex;gap:0;margin-bottom:16px;border-bottom:1px solid rgba(255,255,255,.09)}.import-tab{padding:10px 16px;background:none;border:none;border-bottom:2px solid rgba(0,0,0,0);color:#6c6c84;font-size:12px;font-weight:500;cursor:pointer;transition:color .1s,border-color .1s}.import-tab:hover{color:#dcdce4}.import-tab.active{color:#9698f7;border-bottom-color:#7c7ef5}.queue-panel{display:flex;flex-direction:column;justify-content:flex-start;align-items:stretch;border-left:1px solid rgba(255,255,255,.09);background:#18181f;min-width:280px;max-width:320px}.queue-header{display:flex;justify-content:space-between;align-items:center;padding:12px 16px;border-bottom:1px solid rgba(255,255,255,.06)}.queue-header h3{margin:0;font-size:.9rem;color:#dcdce4}.queue-controls{display:flex;gap:2px}.queue-list{overflow-y:auto;flex:1}.queue-item{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;padding:8px 16px;cursor:pointer;border-bottom:1px solid rgba(255,255,255,.06);transition:background .15s}.queue-item:hover{background:#1f1f28}.queue-item:hover .queue-item-remove{opacity:1}.queue-item-active{background:rgba(124,126,245,.15);border-left:3px solid #7c7ef5}.queue-item-info{flex:1;min-width:0}.queue-item-title{display:block;font-size:.85rem;color:#dcdce4;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.queue-item-artist{display:block;font-size:.75rem;color:#6c6c84}.queue-item-remove{opacity:0;transition:opacity .15s}.queue-empty{padding:16px 16px;text-align:center;color:#6c6c84;font-size:.85rem}.statistics-page{padding:20px}.stats-overview,.stats-grid{display:grid;grid-template-columns:repeat(3, 1fr);gap:16px;margin-bottom:24px}@media (max-width: 768px){.stats-overview,.stats-grid{grid-template-columns:1fr}}.stat-card{background:#1f1f28;border:1px solid rgba(255,255,255,.09);border-radius:5px;padding:20px;display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:16px}.stat-card.stat-primary{border-left:3px solid #7c7ef5}.stat-card.stat-success{border-left:3px solid #3ec97a}.stat-card.stat-info{border-left:3px solid #6ca0d4}.stat-card.stat-warning{border-left:3px solid #d4a037}.stat-card.stat-purple{border-left:3px solid #9d8be0}.stat-card.stat-danger{border-left:3px solid #e45858}.stat-icon{flex-shrink:0;color:#6c6c84}.stat-content{flex:1}.stat-value{font-size:28px;font-weight:700;color:#dcdce4;line-height:1.2;font-variant-numeric:tabular-nums}.stat-label{font-size:12px;color:#6c6c84;margin-top:4px;font-weight:500}.stats-section{background:#1f1f28;border:1px solid rgba(255,255,255,.09);border-radius:5px;padding:16px;margin-bottom:20px}.section-title{font-size:16px;font-weight:600;color:#dcdce4;margin-bottom:20px}.section-title.small{font-size:14px;margin-bottom:12px;padding-bottom:6px;border-bottom:1px solid rgba(255,255,255,.06)}.chart-bars{display:flex;flex-direction:column;justify-content:flex-start;align-items:stretch;gap:16px}.bar-item{display:grid;grid-template-columns:120px 1fr 80px;align-items:center;gap:16px}.bar-label{font-size:13px;font-weight:500;color:#a0a0b8;text-align:right}.bar-track{height:28px;background:#26263a;border-radius:3px;overflow:hidden;position:relative}.bar-fill{height:100%;transition:width .6s cubic-bezier(.4, 0, .2, 1);border-radius:3px}.bar-fill.bar-primary{background:linear-gradient(90deg, #7c7ef5 0%, #7c7ef3 100%)}.bar-fill.bar-success{background:linear-gradient(90deg, #3ec97a 0%, #66bb6a 100%)}.bar-value{font-size:13px;font-weight:600;color:#a0a0b8;text-align:right;font-variant-numeric:tabular-nums}.settings-section{margin-bottom:16px}.settings-card{background:#1f1f28;border:1px solid rgba(255,255,255,.09);border-radius:7px;padding:20px;margin-bottom:16px}.settings-card.danger-card{border:1px solid rgba(228,88,88,.25)}.settings-card-header{display:flex;justify-content:space-between;align-items:center;margin-bottom:16px;padding-bottom:12px;border-bottom:1px solid rgba(255,255,255,.06)}.settings-card-title{font-size:14px;font-weight:600}.settings-card-body{padding-top:2px}.settings-field{display:flex;justify-content:space-between;align-items:center;padding:8px 0;border-bottom:1px solid rgba(255,255,255,.06)}.settings-field:last-child{border-bottom:none}.settings-field select{min-width:120px}.config-path{font-size:11px;color:#6c6c84;margin-bottom:12px;font-family:"JetBrains Mono","Fira Code",ui-monospace,monospace;padding:6px 10px;background:#111118;border-radius:3px;border:1px solid rgba(255,255,255,.06)}.config-status{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:6px;padding:3px 10px;border-radius:12px;font-size:11px;font-weight:600}.config-status.writable{background:rgba(62,201,122,.1);color:#3ec97a}.config-status.readonly{background:rgba(228,88,88,.1);color:#e45858}.root-list{list-style:none}.root-item{display:flex;justify-content:space-between;align-items:center;padding:8px 12px;background:#111118;border:1px solid rgba(255,255,255,.06);border-radius:3px;margin-bottom:4px;font-family:"JetBrains Mono","Fira Code",ui-monospace,monospace;font-size:12px;color:#a0a0b8}.info-row{display:flex;justify-content:space-between;align-items:center;padding:6px 0;border-bottom:1px solid rgba(255,255,255,.06);font-size:13px}.info-row:last-child{border-bottom:none}.info-label{color:#a0a0b8;font-weight:500}.info-value{color:#dcdce4}.tasks-grid{display:grid;grid-template-columns:repeat(auto-fill, minmax(400px, 1fr));gap:16px;padding:12px}.task-card{background:#1f1f28;border:1px solid rgba(255,255,255,.09);border-radius:5px;overflow:hidden;transition:all .2s}.task-card:hover{border-color:rgba(255,255,255,.14);box-shadow:0 4px 12px rgba(0,0,0,.08);transform:translateY(-2px)}.task-card-enabled{border-left:3px solid #3ec97a}.task-card-disabled{border-left:3px solid #4a4a5e;opacity:.7}.task-card-header{display:flex;justify-content:space-between;align-items:center;align-items:flex-start;padding:16px;border-bottom:1px solid rgba(255,255,255,.06)}.task-header-left{flex:1;min-width:0}.task-name{font-size:16px;font-weight:600;color:#dcdce4;margin-bottom:2px}.task-schedule{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:6px;font-size:12px;color:#6c6c84;font-family:"Menlo","Monaco","Courier New",monospace}.schedule-icon{font-size:14px}.task-status-badge{flex-shrink:0}.status-badge{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:6px;padding:2px 10px;border-radius:3px;font-size:10px;font-weight:600;text-transform:uppercase;letter-spacing:.03em}.status-badge.status-enabled{background:rgba(76,175,80,.12);color:#3ec97a}.status-badge.status-enabled .status-dot{animation:pulse 1.5s infinite}.status-badge.status-disabled{background:#26263a;color:#6c6c84}.status-badge .status-dot{width:6px;height:6px;border-radius:50%;flex-shrink:0;background:currentColor}.task-info-grid{display:grid;grid-template-columns:repeat(auto-fit, minmax(120px, 1fr));gap:12px;padding:16px}.task-info-item{display:flex;flex-direction:row;justify-content:flex-start;align-items:flex-start;gap:10px}.task-info-icon{font-size:18px;color:#6c6c84;flex-shrink:0}.task-info-content{flex:1;min-width:0}.task-info-label{font-size:10px;color:#6c6c84;font-weight:600;text-transform:uppercase;letter-spacing:.03em;margin-bottom:2px}.task-info-value{font-size:12px;color:#a0a0b8;font-weight:500;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.task-card-actions{display:flex;gap:8px;padding:10px 16px;background:#18181f;border-top:1px solid rgba(255,255,255,.06)}.task-card-actions button{flex:1}.db-actions{display:flex;flex-direction:column;justify-content:flex-start;align-items:stretch;gap:16px;padding:10px}.db-action-row{display:flex;flex-direction:row;justify-content:space-between;align-items:center;gap:16px;padding:10px;border-radius:6px;background:rgba(0,0,0,.06)}.db-action-info{flex:1}.db-action-info h4{font-size:.95rem;font-weight:600;color:#dcdce4;margin-bottom:2px}.db-action-confirm{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:8px;flex-shrink:0}.library-toolbar{display:flex;justify-content:space-between;align-items:center;padding:8px 0;margin-bottom:12px;gap:12px;flex-wrap:wrap}.toolbar-left{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:10px}.toolbar-right{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:10px}.sort-control select,.page-size-control select{padding:4px 24px 4px 8px;font-size:11px;background:#1f1f28}.page-size-control{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:4px}.library-stats{display:flex;justify-content:space-between;align-items:center;padding:2px 0 6px 0;font-size:11px}.type-filter-row{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:6px;padding:4px 0;margin-bottom:6px;flex-wrap:wrap}.pagination{display:flex;align-items:center;justify-content:center;gap:4px;margin-top:16px;padding:8px 0}.page-btn{min-width:28px;text-align:center;font-variant-numeric:tabular-nums}.page-ellipsis{color:#6c6c84;padding:0 4px;font-size:12px;user-select:none}.audit-controls{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:8px;margin-bottom:12px}.filter-select{padding:4px 24px 4px 8px;font-size:11px;background:#1f1f28}.action-danger{background:rgba(228,88,88,.1);color:#d47070}.action-updated{background:rgba(59,120,200,.1);color:#6ca0d4}.action-collection{background:rgba(34,160,80,.1);color:#5cb97a}.action-collection-remove{background:rgba(212,160,55,.1);color:#c4a840}.action-opened{background:rgba(139,92,246,.1);color:#9d8be0}.action-scanned{background:rgba(128,128,160,.08);color:#6c6c84}.clickable{cursor:pointer;color:#9698f7}.clickable:hover{text-decoration:underline}.clickable-row{cursor:pointer}.clickable-row:hover{background:rgba(255,255,255,.03)}.duplicates-view{padding:0}.duplicates-header{display:flex;justify-content:space-between;align-items:center;margin-bottom:16px}.duplicates-header h3{margin:0}.duplicates-summary{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:12px}.duplicate-group{border:1px solid rgba(255,255,255,.09);border-radius:5px;margin-bottom:8px;overflow:hidden}.duplicate-group-header{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:12px;width:100%;padding:10px 14px;background:#1f1f28;border:none;cursor:pointer;text-align:left;color:#dcdce4;font-size:13px}.duplicate-group-header:hover{background:#26263a}.expand-icon{font-size:10px;width:14px;flex-shrink:0}.group-name{font-weight:600;flex:1;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.group-badge{background:#7c7ef5;color:#fff;padding:2px 8px;border-radius:10px;font-size:11px;font-weight:600;flex-shrink:0}.group-size{flex-shrink:0;font-size:12px}.group-hash{font-size:11px;flex-shrink:0}.duplicate-items{border-top:1px solid rgba(255,255,255,.09)}.duplicate-item{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:12px;padding:10px 14px;border-bottom:1px solid rgba(255,255,255,.06)}.duplicate-item:last-child{border-bottom:none}.duplicate-item-keep{background:rgba(76,175,80,.06)}.dup-thumb{width:48px;height:48px;flex-shrink:0;border-radius:3px;overflow:hidden}.dup-thumb-img{width:100%;height:100%;object-fit:cover}.dup-thumb-placeholder{width:100%;height:100%;display:flex;align-items:center;justify-content:center;background:#26263a;font-size:20px;color:#6c6c84}.dup-info{flex:1;min-width:0}.dup-filename{font-weight:600;font-size:13px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.dup-path{font-size:11px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.dup-meta{font-size:12px;margin-top:2px}.dup-actions{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:6px;flex-shrink:0}.keep-badge{background:rgba(76,175,80,.12);color:#4caf50;padding:2px 10px;border-radius:10px;font-size:11px;font-weight:600}.saved-searches-list{display:flex;flex-direction:column;justify-content:flex-start;align-items:stretch;gap:4px;max-height:300px;overflow-y:auto}.saved-search-item{display:flex;justify-content:space-between;align-items:center;padding:8px 12px;background:#18181f;border-radius:3px;cursor:pointer;transition:background .15s ease}.saved-search-item:hover{background:#1f1f28}.saved-search-info{display:flex;flex-direction:column;justify-content:flex-start;align-items:stretch;gap:2px;flex:1;min-width:0}.saved-search-name{font-weight:500;color:#dcdce4}.saved-search-query{font-size:11px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.backlinks-panel,.outgoing-links-panel{background:#1f1f28;border:1px solid rgba(255,255,255,.09);border-radius:5px;margin-top:16px;overflow:hidden}.backlinks-header,.outgoing-links-header{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:8px;padding:10px 14px;background:#26263a;cursor:pointer;user-select:none;transition:background .1s}.backlinks-header:hover,.outgoing-links-header:hover{background:rgba(255,255,255,.04)}.backlinks-toggle,.outgoing-links-toggle{font-size:10px;color:#6c6c84;width:12px;text-align:center}.backlinks-title,.outgoing-links-title{font-size:12px;font-weight:600;color:#dcdce4;flex:1}.backlinks-count,.outgoing-links-count{font-size:11px;color:#6c6c84}.backlinks-reindex-btn{display:flex;align-items:center;justify-content:center;width:22px;height:22px;padding:0;margin-left:auto;background:rgba(0,0,0,0);border:1px solid rgba(255,255,255,.09);border-radius:3px;color:#6c6c84;font-size:12px;cursor:pointer;transition:background .1s,color .1s,border-color .1s}.backlinks-reindex-btn:hover:not(:disabled){background:#1f1f28;color:#dcdce4;border-color:rgba(255,255,255,.14)}.backlinks-reindex-btn:disabled{opacity:.5;cursor:not-allowed}.backlinks-content,.outgoing-links-content{padding:12px;border-top:1px solid rgba(255,255,255,.06)}.backlinks-loading,.outgoing-links-loading{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:8px;padding:12px;color:#6c6c84;font-size:12px}.backlinks-error,.outgoing-links-error{padding:8px 12px;background:rgba(228,88,88,.06);border:1px solid rgba(228,88,88,.2);border-radius:3px;font-size:12px;color:#e45858}.backlinks-empty,.outgoing-links-empty{padding:16px;text-align:center;color:#6c6c84;font-size:12px;font-style:italic}.backlinks-list,.outgoing-links-list{list-style:none;padding:0;margin:0;display:flex;flex-direction:column;justify-content:flex-start;align-items:stretch;gap:6px}.backlink-item,.outgoing-link-item{padding:10px 12px;background:#111118;border:1px solid rgba(255,255,255,.06);border-radius:3px;cursor:pointer;transition:background .1s,border-color .1s}.backlink-item:hover,.outgoing-link-item:hover{background:#18181f;border-color:rgba(255,255,255,.09)}.backlink-item.unresolved,.outgoing-link-item.unresolved{opacity:.7;border-style:dashed}.backlink-source,.outgoing-link-target{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:8px;margin-bottom:2px}.backlink-title,.outgoing-link-text{font-size:13px;font-weight:500;color:#dcdce4;flex:1;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.backlink-type-badge,.outgoing-link-type-badge{display:inline-block;padding:1px 6px;border-radius:12px;font-size:9px;font-weight:600;text-transform:uppercase;letter-spacing:.03em}.backlink-type-badge.backlink-type-wikilink,.backlink-type-badge.link-type-wikilink,.outgoing-link-type-badge.backlink-type-wikilink,.outgoing-link-type-badge.link-type-wikilink{background:rgba(124,126,245,.15);color:#9698f7}.backlink-type-badge.backlink-type-embed,.backlink-type-badge.link-type-embed,.outgoing-link-type-badge.backlink-type-embed,.outgoing-link-type-badge.link-type-embed{background:rgba(139,92,246,.1);color:#9d8be0}.backlink-type-badge.backlink-type-markdown_link,.backlink-type-badge.link-type-markdown_link,.outgoing-link-type-badge.backlink-type-markdown_link,.outgoing-link-type-badge.link-type-markdown_link{background:rgba(59,120,200,.1);color:#6ca0d4}.backlink-context{font-size:11px;color:#6c6c84;line-height:1.4;overflow:hidden;text-overflow:ellipsis;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical}.backlink-line{color:#a0a0b8;font-weight:500}.unresolved-badge{padding:1px 6px;border-radius:12px;font-size:9px;font-weight:600;background:rgba(212,160,55,.1);color:#d4a037}.outgoing-links-unresolved-badge{margin-left:8px;padding:2px 8px;border-radius:10px;font-size:10px;font-weight:500;background:rgba(212,160,55,.12);color:#d4a037}.outgoing-links-global-unresolved{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:6px;margin-top:12px;padding:10px 12px;background:rgba(212,160,55,.06);border:1px solid rgba(212,160,55,.15);border-radius:3px;font-size:11px;color:#6c6c84}.outgoing-links-global-unresolved .unresolved-icon{color:#d4a037}.backlinks-message{padding:8px 10px;margin-bottom:10px;border-radius:3px;font-size:11px}.backlinks-message.success{background:rgba(62,201,122,.08);border:1px solid rgba(62,201,122,.2);color:#3ec97a}.backlinks-message.error{background:rgba(228,88,88,.06);border:1px solid rgba(228,88,88,.2);color:#e45858}.graph-view{display:flex;flex-direction:column;justify-content:flex-start;align-items:stretch;height:100%;background:#18181f;border-radius:5px;overflow:hidden}.graph-toolbar{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:16px;padding:12px 16px;background:#1f1f28;border-bottom:1px solid rgba(255,255,255,.09)}.graph-title{font-size:14px;font-weight:600;color:#dcdce4}.graph-controls{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:8px;font-size:12px;color:#a0a0b8}.graph-controls select{padding:4px 20px 4px 8px;font-size:11px;background:#26263a}.graph-stats{margin-left:auto;font-size:11px;color:#6c6c84}.graph-container{flex:1;position:relative;display:flex;align-items:center;justify-content:center;overflow:hidden;background:#111118}.graph-loading,.graph-error,.graph-empty{display:flex;flex-direction:column;justify-content:center;align-items:center;gap:10px;padding:48px;color:#6c6c84;font-size:13px;text-align:center}.graph-svg{max-width:100%;max-height:100%;cursor:grab}.graph-svg-container{position:relative;width:100%;height:100%}.graph-zoom-controls{position:absolute;top:16px;left:16px;display:flex;flex-direction:column;justify-content:flex-start;align-items:stretch;gap:8px;z-index:5}.zoom-btn{width:36px;height:36px;border-radius:6px;background:#1f1f28;border:1px solid rgba(255,255,255,.09);color:#dcdce4;font-size:18px;font-weight:bold;display:flex;align-items:center;justify-content:center;cursor:pointer;transition:all .15s;box-shadow:0 1px 3px rgba(0,0,0,.3)}.zoom-btn:hover{background:#26263a;border-color:rgba(255,255,255,.14);transform:scale(1.05)}.zoom-btn:active{transform:scale(.95)}.graph-edges line{stroke:rgba(255,255,255,.14);stroke-width:1;opacity:.6}.graph-edges line.edge-type-wikilink{stroke:#7c7ef5}.graph-edges line.edge-type-embed{stroke:#9d8be0;stroke-dasharray:4 2}.graph-nodes .graph-node{cursor:pointer}.graph-nodes .graph-node circle{fill:#4caf50;stroke:#388e3c;stroke-width:2;transition:fill .15s,stroke .15s}.graph-nodes .graph-node:hover circle{fill:#66bb6a}.graph-nodes .graph-node.selected circle{fill:#7c7ef5;stroke:#5456d6}.graph-nodes .graph-node text{fill:#a0a0b8;font-size:11px;pointer-events:none;text-anchor:middle;dominant-baseline:central;transform:translateY(16px)}.node-details-panel{position:absolute;top:16px;right:16px;width:280px;background:#1f1f28;border:1px solid rgba(255,255,255,.09);border-radius:5px;box-shadow:0 2px 8px rgba(0,0,0,.35);z-index:10}.node-details-header{display:flex;justify-content:space-between;align-items:center;padding:10px 14px;border-bottom:1px solid rgba(255,255,255,.06)}.node-details-header h3{font-size:13px;font-weight:600;color:#dcdce4;margin:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.node-details-header .close-btn{background:none;border:none;color:#6c6c84;cursor:pointer;font-size:14px;padding:2px 6px;line-height:1}.node-details-header .close-btn:hover{color:#dcdce4}.node-details-content{padding:14px}.node-details-content .node-title{font-size:12px;color:#a0a0b8;margin-bottom:12px}.node-stats{display:flex;gap:16px;margin-bottom:12px}.node-stats .stat{font-size:12px;color:#6c6c84}.node-stats .stat strong{color:#dcdce4}.physics-controls-panel{position:absolute;top:16px;right:16px;width:300px;background:#1f1f28;border:1px solid rgba(255,255,255,.09);border-radius:5px;box-shadow:0 2px 8px rgba(0,0,0,.35);padding:16px;z-index:10}.physics-controls-panel h4{font-size:13px;font-weight:600;color:#dcdce4;margin:0 0 16px 0;padding-bottom:8px;border-bottom:1px solid rgba(255,255,255,.06)}.physics-controls-panel .btn{width:100%;margin-top:8px}.control-group{margin-bottom:14px}.control-group label{display:block;font-size:11px;font-weight:500;color:#a0a0b8;margin-bottom:6px;text-transform:uppercase;letter-spacing:.5px}.control-group input[type=range]{width:100%;height:4px;border-radius:4px;background:#26263a;outline:none;-webkit-appearance:none}.control-group input[type=range]::-webkit-slider-thumb{-webkit-appearance:none;appearance:none;width:14px;height:14px;border-radius:50%;background:#7c7ef5;cursor:pointer;transition:transform .1s}.control-group input[type=range]::-webkit-slider-thumb:hover{transform:scale(1.15)}.control-group input[type=range]::-moz-range-thumb{width:14px;height:14px;border-radius:50%;background:#7c7ef5;cursor:pointer;border:none;transition:transform .1s}.control-group input[type=range]::-moz-range-thumb:hover{transform:scale(1.15)}.control-value{display:inline-block;margin-top:2px;font-size:11px;color:#6c6c84;font-family:"JetBrains Mono","Fira Code",ui-monospace,monospace}.theme-light{--bg-0: #f5f5f7;--bg-1: #eeeef0;--bg-2: #fff;--bg-3: #e8e8ec;--border-subtle: rgba(0,0,0,.06);--border: rgba(0,0,0,.1);--border-strong: rgba(0,0,0,.16);--text-0: #1a1a2e;--text-1: #555570;--text-2: #8888a0;--accent: #6366f1;--accent-dim: rgba(99,102,241,.1);--accent-text: #4f52e8;--shadow-sm: 0 1px 3px rgba(0,0,0,.08);--shadow: 0 2px 8px rgba(0,0,0,.1);--shadow-lg: 0 4px 20px rgba(0,0,0,.12)}.theme-light ::-webkit-scrollbar-thumb{background:rgba(0,0,0,.12)}.theme-light ::-webkit-scrollbar-thumb:hover{background:rgba(0,0,0,.08)}.theme-light ::-webkit-scrollbar-track{background:rgba(0,0,0,.06)}.theme-light .graph-nodes .graph-node text{fill:#1a1a2e}.theme-light .graph-edges line{stroke:rgba(0,0,0,.12)}.theme-light .pdf-container{background:#e8e8ec}.skeleton-pulse{animation:skeleton-pulse 1.5s ease-in-out infinite;background:#26263a;border-radius:4px}.skeleton-card{display:flex;flex-direction:column;justify-content:flex-start;align-items:stretch;gap:8px;padding:8px}.skeleton-thumb{width:100%;aspect-ratio:1;border-radius:6px}.skeleton-text{height:14px;width:80%}.skeleton-text-short{width:50%}.skeleton-row{display:flex;gap:12px;padding:10px 16px;align-items:center}.skeleton-cell{height:14px;flex:1;border-radius:4px}.skeleton-cell-icon{width:32px;height:32px;flex:none;border-radius:4px}.skeleton-cell-wide{flex:3}.loading-overlay{position:absolute;inset:0;display:flex;flex-direction:column;justify-content:center;align-items:center;gap:10px;background:rgba(0,0,0,.3);z-index:100;border-radius:8px}.loading-spinner{width:32px;height:32px;border:3px solid rgba(255,255,255,.09);border-top-color:#7c7ef5;border-radius:50%;animation:spin .8s linear infinite}.loading-message{color:#a0a0b8;font-size:.9rem}.login-container{display:flex;align-items:center;justify-content:center;height:100vh;background:#111118}.login-card{background:#1f1f28;border:1px solid rgba(255,255,255,.09);border-radius:7px;padding:24px;width:360px;box-shadow:0 4px 20px rgba(0,0,0,.45)}.login-title{font-size:20px;font-weight:700;color:#dcdce4;text-align:center;margin-bottom:2px}.login-subtitle{font-size:13px;color:#6c6c84;text-align:center;margin-bottom:20px}.login-error{background:rgba(228,88,88,.08);border:1px solid rgba(228,88,88,.2);border-radius:3px;padding:8px 12px;margin-bottom:12px;font-size:12px;color:#e45858}.login-form input[type=text],.login-form input[type=password]{width:100%}.login-btn{width:100%;padding:8px 16px;font-size:13px;margin-top:2px}.pagination{display:flex;flex-direction:row;justify-content:center;align-items:center;gap:2px;margin-top:16px;padding:8px 0}.page-btn{min-width:28px;text-align:center;font-variant-numeric:tabular-nums}.page-ellipsis{color:#6c6c84;padding:0 4px;font-size:12px;user-select:none}.help-overlay{position:fixed;inset:0;background:rgba(0,0,0,.5);display:flex;align-items:center;justify-content:center;z-index:200;animation:fade-in .1s ease-out}.help-dialog{background:#1f1f28;border:1px solid rgba(255,255,255,.09);border-radius:7px;padding:16px;min-width:300px;max-width:400px;box-shadow:0 4px 20px rgba(0,0,0,.45)}.help-dialog h3{font-size:16px;font-weight:600;margin-bottom:16px}.help-shortcuts{display:flex;flex-direction:column;justify-content:flex-start;align-items:stretch;gap:8px;margin-bottom:16px}.shortcut-row{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:12px}.shortcut-row kbd{display:inline-block;padding:2px 8px;background:#111118;border:1px solid rgba(255,255,255,.09);border-radius:3px;font-family:"JetBrains Mono","Fira Code",ui-monospace,monospace;font-size:11px;color:#dcdce4;min-width:32px;text-align:center}.shortcut-row span{font-size:13px;color:#a0a0b8}.help-close{display:block;width:100%;padding:6px 12px;background:#26263a;border:1px solid rgba(255,255,255,.09);border-radius:3px;color:#dcdce4;font-size:12px;cursor:pointer;text-align:center}.help-close:hover{background:rgba(255,255,255,.06)}.plugin-page{padding:16px 24px;max-width:100%;overflow-x:hidden}.plugin-page-title{font-size:14px;font-weight:600;color:#dcdce4;margin:0 0 16px}.plugin-container{display:flex;flex-direction:column;gap:var(--plugin-gap, 0px);padding:var(--plugin-padding, 0)}.plugin-grid{display:grid;grid-template-columns:repeat(var(--plugin-columns, 1), 1fr);gap:var(--plugin-gap, 0px)}.plugin-flex{display:flex;gap:var(--plugin-gap, 0px)}.plugin-flex[data-direction=row]{flex-direction:row}.plugin-flex[data-direction=column]{flex-direction:column}.plugin-flex[data-justify=flex-start]{justify-content:flex-start}.plugin-flex[data-justify=flex-end]{justify-content:flex-end}.plugin-flex[data-justify=center]{justify-content:center}.plugin-flex[data-justify=space-between]{justify-content:space-between}.plugin-flex[data-justify=space-around]{justify-content:space-around}.plugin-flex[data-justify=space-evenly]{justify-content:space-evenly}.plugin-flex[data-align=flex-start]{align-items:flex-start}.plugin-flex[data-align=flex-end]{align-items:flex-end}.plugin-flex[data-align=center]{align-items:center}.plugin-flex[data-align=stretch]{align-items:stretch}.plugin-flex[data-align=baseline]{align-items:baseline}.plugin-flex[data-wrap=wrap]{flex-wrap:wrap}.plugin-flex[data-wrap=nowrap]{flex-wrap:nowrap}.plugin-split{display:flex}.plugin-split-sidebar{width:var(--plugin-sidebar-width, 200px);flex-shrink:0}.plugin-split-main{flex:1;min-width:0}.plugin-card{background:#1f1f28;border:1px solid rgba(255,255,255,.09);border-radius:7px;overflow:hidden}.plugin-card-header{padding:12px 16px;font-size:12px;font-weight:600;color:#dcdce4;border-bottom:1px solid rgba(255,255,255,.09);background:#26263a}.plugin-card-content{padding:16px}.plugin-card-footer{padding:12px 16px;border-top:1px solid rgba(255,255,255,.09);background:#18181f}.plugin-heading{color:#dcdce4;margin:0;line-height:1.2}.plugin-heading.level-1{font-size:28px;font-weight:700}.plugin-heading.level-2{font-size:18px;font-weight:600}.plugin-heading.level-3{font-size:16px;font-weight:600}.plugin-heading.level-4{font-size:14px;font-weight:500}.plugin-heading.level-5{font-size:13px;font-weight:500}.plugin-heading.level-6{font-size:12px;font-weight:500}.plugin-text{margin:0;font-size:12px;color:#dcdce4;line-height:1.4}.plugin-text.text-secondary{color:#a0a0b8}.plugin-text.text-error{color:#d47070}.plugin-text.text-success{color:#3ec97a}.plugin-text.text-warning{color:#d4a037}.plugin-text.text-bold{font-weight:600}.plugin-text.text-italic{font-style:italic}.plugin-text.text-small{font-size:10px}.plugin-text.text-large{font-size:15px}.plugin-code{background:#18181f;border:1px solid rgba(255,255,255,.09);border-radius:5px;padding:16px 24px;font-family:"JetBrains Mono","Fira Code",ui-monospace,monospace;font-size:12px;color:#dcdce4;overflow-x:auto;white-space:pre}.plugin-code code{font-family:inherit;font-size:inherit;color:inherit}.plugin-tabs{display:flex;flex-direction:column}.plugin-tab-list{display:flex;gap:2px;border-bottom:1px solid rgba(255,255,255,.09);margin-bottom:16px}.plugin-tab{padding:8px 20px;font-size:12px;font-weight:500;color:#a0a0b8;background:rgba(0,0,0,0);border:none;border-bottom:2px solid rgba(0,0,0,0);cursor:pointer;transition:color .1s,border-color .1s}.plugin-tab:hover{color:#dcdce4}.plugin-tab.active{color:#9698f7;border-bottom-color:#7c7ef5}.plugin-tab .tab-icon{margin-right:4px}.plugin-tab-panel:not(.active){display:none}.plugin-description-list-wrapper{width:100%}.plugin-description-list{display:grid;grid-template-columns:max-content 1fr;gap:4px 16px;margin:0;padding:0}.plugin-description-list dt{font-size:10px;font-weight:500;color:#a0a0b8;text-transform:uppercase;letter-spacing:.5px;padding:6px 0;white-space:nowrap}.plugin-description-list dd{font-size:12px;color:#dcdce4;padding:6px 0;margin:0;word-break:break-word}.plugin-description-list.horizontal{display:flex;flex-wrap:wrap;gap:16px 24px;display:grid;grid-template-columns:repeat(auto-fill, minmax(180px, 1fr))}.plugin-description-list.horizontal dt{width:auto;padding:0}.plugin-description-list.horizontal dd{width:auto;padding:0}.plugin-description-list.horizontal dt,.plugin-description-list.horizontal dd{display:inline}.plugin-description-list.horizontal dt{font-size:9px;text-transform:uppercase;letter-spacing:.5px;color:#6c6c84;margin-bottom:2px}.plugin-description-list.horizontal dd{font-size:13px;font-weight:600;color:#dcdce4}.plugin-data-table-wrapper{overflow-x:auto}.plugin-data-table{width:100%;border-collapse:collapse;font-size:12px}.plugin-data-table thead tr{border-bottom:1px solid rgba(255,255,255,.14)}.plugin-data-table thead th{padding:8px 12px;text-align:left;font-size:10px;font-weight:600;color:#a0a0b8;text-transform:uppercase;letter-spacing:.5px;white-space:nowrap}.plugin-data-table tbody tr{border-bottom:1px solid rgba(255,255,255,.06);transition:background .08s}.plugin-data-table tbody tr:hover{background:rgba(255,255,255,.03)}.plugin-data-table tbody tr:last-child{border-bottom:none}.plugin-data-table tbody td{padding:8px 12px;color:#dcdce4;vertical-align:middle}.plugin-col-constrained{width:var(--plugin-col-width)}.table-filter{margin-bottom:12px}.table-filter input{width:240px;padding:6px 12px;background:#18181f;border:1px solid rgba(255,255,255,.09);border-radius:5px;color:#dcdce4;font-size:12px}.table-filter input::placeholder{color:#6c6c84}.table-filter input:focus{outline:none;border-color:#7c7ef5}.table-pagination{display:flex;align-items:center;gap:12px;padding:8px 0;font-size:12px;color:#a0a0b8}.row-actions{white-space:nowrap;width:1%}.row-actions .plugin-button{padding:4px 8px;font-size:10px;margin-right:4px}.plugin-media-grid{display:grid;grid-template-columns:repeat(var(--plugin-columns, 2), 1fr);gap:var(--plugin-gap, 8px)}.media-grid-item{background:#1f1f28;border:1px solid rgba(255,255,255,.09);border-radius:7px;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:#26263a;display:flex;align-items:center;justify-content:center;font-size:10px;color:#6c6c84}.media-grid-caption{padding:8px 12px;font-size:10px;color:#dcdce4;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.plugin-list{list-style:none;margin:0;padding:0}.plugin-list-item{padding:8px 0}.plugin-list-divider{border:none;border-top:1px solid rgba(255,255,255,.06);margin:0}.plugin-list-empty{padding:16px;text-align:center;color:#6c6c84;font-size:12px}.plugin-button{display:inline-flex;align-items:center;gap:6px;padding:8px 16px;border:1px solid rgba(255,255,255,.09);border-radius:5px;font-size:12px;font-weight:500;cursor:pointer;transition:background .08s,border-color .08s,color .08s;background:#1f1f28;color:#dcdce4}.plugin-button:disabled{opacity:.45;cursor:not-allowed}.plugin-button.btn-primary{background:#7c7ef5;border-color:#7c7ef5;color:#fff}.plugin-button.btn-primary:hover:not(:disabled){background:#8b8df7}.plugin-button.btn-secondary{background:#26263a;border-color:rgba(255,255,255,.14);color:#dcdce4}.plugin-button.btn-secondary:hover:not(:disabled){background:rgba(255,255,255,.04)}.plugin-button.btn-tertiary{background:rgba(0,0,0,0);border-color:rgba(0,0,0,0);color:#9698f7}.plugin-button.btn-tertiary:hover:not(:disabled){background:rgba(124,126,245,.15)}.plugin-button.btn-danger{background:rgba(0,0,0,0);border-color:rgba(228,88,88,.2);color:#d47070}.plugin-button.btn-danger:hover:not(:disabled){background:rgba(228,88,88,.06)}.plugin-button.btn-success{background:rgba(0,0,0,0);border-color:rgba(62,201,122,.2);color:#3ec97a}.plugin-button.btn-success:hover:not(:disabled){background:rgba(62,201,122,.08)}.plugin-button.btn-ghost{background:rgba(0,0,0,0);border-color:rgba(0,0,0,0);color:#a0a0b8}.plugin-button.btn-ghost:hover:not(:disabled){background:rgba(255,255,255,.04)}.plugin-badge{display:inline-flex;align-items:center;padding:2px 8px;border-radius:50%;font-size:9px;font-weight:600;letter-spacing:.5px;text-transform:uppercase}.plugin-badge.badge-default,.plugin-badge.badge-neutral{background:rgba(255,255,255,.04);color:#a0a0b8}.plugin-badge.badge-primary{background:rgba(124,126,245,.15);color:#9698f7}.plugin-badge.badge-secondary{background:rgba(255,255,255,.03);color:#dcdce4}.plugin-badge.badge-success{background:rgba(62,201,122,.08);color:#3ec97a}.plugin-badge.badge-warning{background:rgba(212,160,55,.06);color:#d4a037}.plugin-badge.badge-error{background:rgba(228,88,88,.06);color:#d47070}.plugin-badge.badge-info{background:rgba(99,102,241,.08);color:#9698f7}.plugin-form{display:flex;flex-direction:column;gap:16px}.form-field{display:flex;flex-direction:column;gap:6px}.form-field label{font-size:12px;font-weight:500;color:#dcdce4}.form-field input,.form-field textarea,.form-field select{padding:8px 12px;background:#18181f;border:1px solid rgba(255,255,255,.09);border-radius:5px;color:#dcdce4;font-size:12px;font-family:inherit}.form-field input::placeholder,.form-field textarea::placeholder,.form-field select::placeholder{color:#6c6c84}.form-field input:focus,.form-field textarea:focus,.form-field select:focus{outline:none;border-color:#7c7ef5;box-shadow:0 0 0 2px rgba(124,126,245,.15)}.form-field textarea{min-height:80px;resize:vertical}.form-field 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 12px center;padding-right:32px}.form-help{margin:0;font-size:10px;color:#6c6c84}.form-actions{display:flex;gap:12px;padding-top:8px}.required{color:#e45858}.plugin-link{color:#9698f7;text-decoration:none}.plugin-link:hover{text-decoration:underline}.plugin-link-blocked{color:#6c6c84;text-decoration:line-through;cursor:not-allowed}.plugin-progress{background:#18181f;border:1px solid rgba(255,255,255,.09);border-radius:5px;height:8px;overflow:hidden;display:flex;align-items:center;gap:8px}.plugin-progress-bar{height:100%;background:#7c7ef5;border-radius:4px;transition:width .3s ease;width:var(--plugin-progress, 0%)}.plugin-progress-label{font-size:10px;color:#a0a0b8;white-space:nowrap;flex-shrink:0}.plugin-chart{overflow:auto;height:var(--plugin-chart-height, 200px)}.plugin-chart .chart-title{font-size:13px;font-weight:600;color:#dcdce4;margin-bottom:8px}.plugin-chart .chart-x-label,.plugin-chart .chart-y-label{font-size:10px;color:#6c6c84;margin-bottom:4px}.plugin-chart .chart-data-table{overflow-x:auto}.plugin-chart .chart-no-data{padding:24px;text-align:center;color:#6c6c84;font-size:12px}.plugin-loading{padding:16px;color:#a0a0b8;font-size:12px;font-style:italic}.plugin-error{padding:12px 16px;background:rgba(228,88,88,.06);border:1px solid rgba(228,88,88,.2);border-radius:5px;color:#d47070;font-size:12px}.plugin-feedback{position:sticky;bottom:16px;display:flex;align-items:center;justify-content:space-between;gap:16px;padding:12px 16px;border-radius:7px;font-size:12px;z-index:300;box-shadow:0 4px 20px rgba(0,0,0,.45)}.plugin-feedback.success{background:rgba(62,201,122,.08);border:1px solid rgba(62,201,122,.2);color:#3ec97a}.plugin-feedback.error{background:rgba(228,88,88,.06);border:1px solid rgba(228,88,88,.2);color:#d47070}.plugin-feedback-dismiss{background:rgba(0,0,0,0);border:none;color:inherit;font-size:14px;cursor:pointer;line-height:1;padding:0;opacity:.7}.plugin-feedback-dismiss:hover{opacity:1}.plugin-modal-overlay{position:fixed;inset:0;background:rgba(0,0,0,.65);display:flex;align-items:center;justify-content:center;z-index:100}.plugin-modal{position:relative;background:#1f1f28;border:1px solid rgba(255,255,255,.14);border-radius:12px;padding:32px;min-width:380px;max-width:640px;max-height:80vh;overflow-y:auto;box-shadow:0 4px 20px rgba(0,0,0,.45);z-index:200}.plugin-modal-close{position:absolute;top:16px;right:16px;background:rgba(0,0,0,0);border:none;color:#a0a0b8;font-size:14px;cursor:pointer;line-height:1;padding:4px;border-radius:5px}.plugin-modal-close:hover{background:rgba(255,255,255,.04);color:#dcdce4} \ No newline at end of file diff --git a/crates/pinakes-ui/assets/styles/_plugins.scss b/crates/pinakes-ui/assets/styles/_plugins.scss index c44762a..915180e 100644 --- a/crates/pinakes-ui/assets/styles/_plugins.scss +++ b/crates/pinakes-ui/assets/styles/_plugins.scss @@ -7,6 +7,20 @@ // The layout rules here consume those properties via var() so the renderer // 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. .plugin-container { display: flex; @@ -65,20 +79,568 @@ 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 { display: grid; grid-template-columns: repeat(var(--plugin-columns, 2), 1fr); gap: var(--plugin-gap, 8px); } -// Table column with a plugin-specified fixed width. -// The width is passed as --plugin-col-width on the th element. -.plugin-col-constrained { - width: var(--plugin-col-width); +.media-grid-item { + background: $bg-2; + border: 1px solid $border; + 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 { height: 100%; background: $accent; @@ -87,8 +649,140 @@ 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. .plugin-chart { overflow: auto; 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; + } } -- 2.43.0 From 63954fdb2f2fa104656812cd7352d7f9680f837c Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Wed, 11 Mar 2026 21:26:59 +0300 Subject: [PATCH 42/46] pinakes-ui: supply `local_state` to `Conditional` and `Progress`; remove `last_refresh` Signed-off-by: NotAShelf Change-Id: Ib513b5846d6c74bfe821da195b7080af6a6a6964 --- crates/pinakes-ui/src/plugin_ui/registry.rs | 18 ++---- crates/pinakes-ui/src/plugin_ui/renderer.rs | 64 +++++++++++++++++++-- 2 files changed, 62 insertions(+), 20 deletions(-) diff --git a/crates/pinakes-ui/src/plugin_ui/registry.rs b/crates/pinakes-ui/src/plugin_ui/registry.rs index 4ad2b5c..8fde3d0 100644 --- a/crates/pinakes-ui/src/plugin_ui/registry.rs +++ b/crates/pinakes-ui/src/plugin_ui/registry.rs @@ -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, - /// Last refresh timestamp - last_refresh: Option>, + theme_vars: HashMap, } 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> { - 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] diff --git a/crates/pinakes-ui/src/plugin_ui/renderer.rs b/crates/pinakes-ui/src/plugin_ui/renderer.rs index 0272e6b..fa62f65 100644 --- a/crates/pinakes-ui/src/plugin_ui/renderer.rs +++ b/crates/pinakes-ui/src/plugin_ui/renderer.rs @@ -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 { +fn media_grid_image_url( + item: &serde_json::Value, + base_url: &str, +) -> Option { for key in &[ "thumbnail_url", "thumbnail", @@ -1260,12 +1273,22 @@ fn media_grid_image_url(item: &serde_json::Value) -> Option { } } } + // Pinakes media items: construct absolute thumbnail URL from id when + // has_thumbnail is true. Relative paths don't work for 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 { 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::() + chars.as_str() + }, + } + }) + .collect::>() + .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 }); -- 2.43.0 From 81d1695e11dcd145dfbdf3b32e4e46788f628311 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Wed, 11 Mar 2026 21:27:42 +0300 Subject: [PATCH 43/46] pinakes-ui: integrate plugin pages into sidebar navigation; sanitize theme-extension CSS eval Signed-off-by: NotAShelf Change-Id: Ie87e39c66253a7071f029d52dd5979716a6a6964 --- crates/pinakes-ui/src/app.rs | 23 +++++++---------------- 1 file changed, 7 insertions(+), 16 deletions(-) diff --git a/crates/pinakes-ui/src/app.rs b/crates/pinakes-ui/src/app.rs index 11026b9..43699d2 100644 --- a/crates/pinakes-ui/src/app.rs +++ b/crates/pinakes-ui/src/app.rs @@ -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}" } - } - } - } } } -- 2.43.0 From e1351e88814fbf7f58fb1bcb74168d7e1353e20b Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Wed, 11 Mar 2026 21:27:55 +0300 Subject: [PATCH 44/46] examples/media-stats-ui: fix Transform source key; add file_name column Signed-off-by: NotAShelf Change-Id: I4c741e4b36708f2078fed8154d7341de6a6a6964 --- examples/plugins/media-stats-ui/pages/stats.json | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/examples/plugins/media-stats-ui/pages/stats.json b/examples/plugins/media-stats-ui/pages/stats.json index 03961a0..6f860c5 100644 --- a/examples/plugins/media-stats-ui/pages/stats.json +++ b/examples/plugins/media-stats-ui/pages/stats.json @@ -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" } } } -- 2.43.0 From 0014a1a2a98b7a33721e2040177c604e96c051cb Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Wed, 11 Mar 2026 21:29:24 +0300 Subject: [PATCH 45/46] chore: fix clippy lints; format Signed-off-by: NotAShelf Change-Id: Ib3d98a81c7e41054d27e617394bef63c6a6a6964 --- crates/pinakes-core/src/thumbnail.rs | 5 ++--- crates/pinakes-server/src/dto/media.rs | 9 +++++---- examples/plugins/media-stats-ui/plugin.toml | 4 ++-- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/crates/pinakes-core/src/thumbnail.rs b/crates/pinakes-core/src/thumbnail.rs index e221c76..7e3b799 100644 --- a/crates/pinakes-core/src/thumbnail.rs +++ b/crates/pinakes-core/src/thumbnail.rs @@ -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()); } - } } } diff --git a/crates/pinakes-server/src/dto/media.rs b/crates/pinakes-server/src/dto/media.rs index dc1a155..e404776 100644 --- a/crates/pinakes-server/src/dto/media.rs +++ b/crates/pinakes-server/src/dto/media.rs @@ -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::>() .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(), diff --git a/examples/plugins/media-stats-ui/plugin.toml b/examples/plugins/media-stats-ui/plugin.toml index f65def5..0e8116a 100644 --- a/examples/plugins/media-stats-ui/plugin.toml +++ b/examples/plugins/media-stats-ui/plugin.toml @@ -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]] -- 2.43.0 From 7cbce98795d3baf658f9bf18e043b4866ee4040a Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Wed, 11 Mar 2026 21:30:00 +0300 Subject: [PATCH 46/46] docs/plugins: detail GUI plugin usage; separate server & GUI plugins Signed-off-by: NotAShelf Change-Id: I2060db637209655390a86facd004bc646a6a6964 --- docs/plugins.md | 531 ++++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 471 insertions(+), 60 deletions(-) diff --git a/docs/plugins.md b/docs/plugins.md index 93c87f5..6ed35d1 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -1,17 +1,30 @@ # Plugin System -Pinakes is very powerful on its own, but with a goal as ambitious as "to be the -last media management system you will ever need" I recognize the need for a -plugin system; not everything belongs in the core of Pinakes. Thus, Pinakes -supports WASM-based plugins for extending media type support, metadata -extraction, thumbnail generation, search, event handling, and theming. +Pinakes is first and foremost a _server_ application. This server can be +extended with a plugin system that runs WASM binaries in the server process. +They extend media type detection, metadata extension, thumbnail generation, +search, event handling and theming. -Plugins run in a sandboxed wasmtime runtime with capability-based security, fuel -metering, memory limits, and a circuit breaker for fault isolation. +The first-party GUI for Pinakes, dubbed `pinakes-ui` within this codebase, can +also be extended through a separate plugin system. GUI plugins add pages and +widgets to the desktop/web interface through declarative JSON schemas. No WASM +code runs during rendering. -## How It Works +> [!NOTE] +> While mostly functional, the plugin system is **experimental**. It might +> change at any given time, without notice and without any effort for backwards +> compatibility. Please provide any feedback that you might have! -A plugin is a directory containing: +## Server Plugins + +Server plugins run in a sandboxed wasmtime runtime with capability-based +security, fuel metering, memory limits, and a circuit breaker for fault +isolation. + +### How It Works + +A plugin is a directory containing a WASM binary and a plugin manifest. Usually +in this format: ```plaintext my-plugin/ @@ -19,9 +32,9 @@ my-plugin/ my_plugin.wasm Compiled WASM binary (wasm32-unknown-unknown target) ``` -The server discovers plugins in configured directories, validates their +The server discovers plugins from configured directories, validates their manifests, checks capabilities against the security policy, compiles the WASM -module, and registers the plugin. +module and registers the plugin. Extension points communicate via JSON-over-WASM. The host writes a JSON request into the plugin's memory, calls the exported function, and reads the JSON @@ -32,7 +45,7 @@ introduces a little bit of overhead, it's the more debuggable approach and thus more suitable for the initial plugin system. In the future, this might change. Plugins go through a priority-ordered pipeline. Each plugin declares a priority -(0–999, default 500). Built-in handlers run at implicit priority 100, so plugins +(0-999, default 500). Built-in handlers run at implicit priority 100, so plugins at priority <100 run _before_ built-ins and plugins at >100 run _after_. Different extension points use different merge strategies: @@ -49,7 +62,7 @@ Different extension points use different merge strategies: -## Plugin Kinds +### Plugin Kinds A plugin can implement one or more of these roles: @@ -74,7 +87,7 @@ kind = ["media_type", "metadata_extractor", "thumbnail_generator"] priority = 50 ``` -## Writing a Plugin +### Writing a Plugin [dlmalloc]: https://crates.io/crates/dlmalloc @@ -99,6 +112,8 @@ lto = true Let's go over a minimal example that registers a custom `.mytype` file format: + + ```rust #![no_std] @@ -156,34 +171,39 @@ pub extern "C" fn can_handle(ptr: i32, len: i32) { } ``` -### Building + + +#### Building ```bash +# Build for the wasm32-unknown-unknown target $ cargo build --target wasm32-unknown-unknown --release ``` The `RUSTFLAGS=""` override may be needed if your environment sets linker flags (e.g., `-fuse-ld=lld`) that are incompatible with the WASM target. You can also specify a compatible linker explicitly. As pinakes uses a `clang` and `lld` -based pipeline, it's necessary to set for, e.g., the test fixtures. +based pipeline, it's necessary to set for, e.g., the test fixtures while +building inside the codebase. In most cases, you will not need it. -The compiled binary will be at -`target/wasm32-unknown-unknown/release/my_plugin.wasm`. +Once the compilation is done, the resulting binary will be in the target +directory. More specifically it will be in +`target/wasm32-unknown-unknown/release` but the path changes based on your +target, and the build mode (dev/release). -### A note on `serde` in `no_std` +> [!NOTE] +> Since `serde_json` requires `std`, you cannot use it in a plugin. Either +> hand-write JSON strings (as shown above) or use a lightweight no_std JSON +> library. +> +> The test fixture plugin in `crates/pinakes-core/tests/fixtures/test-plugin/` +> demonstrates the hand-written approach. It is ugly, but it works, and the +> binaries are tiny (~17KB). You are advised to replace this in your plugins. -Since `serde_json` requires `std`, you cannot use it in a plugin. Either -hand-write JSON strings (as shown above) or use a lightweight no_std JSON -library. - -The test fixture plugin in `crates/pinakes-core/tests/fixtures/test-plugin/` -demonstrates the hand-written approach. It is ugly, but it works, and the -binaries are tiny (~17KB). - -### Installing +#### Installing Place the plugin directory in one of the configured plugin directories, or use -the API: +the API to load them while the server is running. ```bash curl -X POST http://localhost:3000/api/v1/plugins/install \ @@ -192,10 +212,10 @@ curl -X POST http://localhost:3000/api/v1/plugins/install \ -d '{"source": "/path/to/my-plugin"}' ``` -## Manifest Reference +### Manifest Reference Plugins are required to provide a manifest with explicit intents for everything. -Here is the expected manifest format as of 0.3.0-dev version of Pinakes: +Here is the expected manifest format as of **0.3.0-dev version** of Pinakes: ```toml [plugin] @@ -230,7 +250,7 @@ max_memory_mb = 64 # Maximum linear memory (default: 512MB) max_cpu_time_secs = 5 # Fuel budget per invocation (default: 60s) ``` -## Extension Points +### Extension Points Every plugin must export these three functions: @@ -244,7 +264,7 @@ Beyond those, which functions you export depends on your plugin's kind(s). The capability enforcer prevents a plugin from being called for functions it hasn't declared; a `metadata_extractor` plugin cannot have `search` called on it. -### `MediaTypeProvider` +#### `MediaTypeProvider` Plugins with `kind` containing `"media_type"` export: @@ -272,7 +292,7 @@ In the pipeline first-match-wins. Plugins are checked in priority order. The first where `can_handle` returns `true` and has a matching definition claims the file. If no plugin claims it, built-in handlers run as fallback. -### `MetadataExtractor` +#### `MetadataExtractor` Plugins with `kind` containing `"metadata_extractor"` export: @@ -301,7 +321,7 @@ The pipeline features accumulating merge. All matching plugins run in priority order. Later plugins' non-null fields overwrite earlier ones. The `extra` maps are merged (later keys win). -### `ThumbnailGenerator` +#### `ThumbnailGenerator` Plugins with `kind` containing `"thumbnail_generator"` export: @@ -329,7 +349,7 @@ Same as [MetadataExtractor](#metadataextractor) First-success-wins. The first plugin to return a successful result produces the thumbnail. -### `SearchBackend` +#### `SearchBackend` Plugins with `kind` containing `"search_backend"` export: @@ -371,7 +391,7 @@ Results from all backends are merged, deduplicated by ID (keeping the highest score), and sorted by score descending. `index_item` and `remove_item` are fanned out to all backends. -### `EventHandler` +#### `EventHandler` Plugins with `kind` containing `"event_handler"` export: @@ -394,9 +414,9 @@ Plugins with `kind` containing `"event_handler"` export: All interested plugins receive the event. Events are dispatched asynchronously via `tokio::spawn` and do not block the caller. Handler failures are logged but -never propagated. Suffice to say that the pipeline is fan-out. +never propagated. The pipeline is fan-out. -### `ThemeProvider` +#### `ThemeProvider` > [!IMPORTANT] > `ThemeProvider` is experimental, and will likely be subject to change. @@ -431,7 +451,7 @@ Plugins with `kind` containing `"theme_provider"` export: Themes from all providers are accumulated. When loading a specific theme, the pipeline dispatches to the plugin that registered it. -### System Events +#### System Events The server emits these events at various points: @@ -450,45 +470,45 @@ The server emits these events at various points: Plugins can also emit events themselves via `host_emit_event`, enabling plugin-to-plugin communication. -## Host Functions +### Host Functions Plugins can call these host functions from within WASM (imported from the `"env"` module): -### `host_set_result(ptr, len)` +#### `host_set_result(ptr, len)` Write a JSON response back to the host. The plugin writes the response bytes into its own linear memory and passes the pointer and length. This is how you return data from any extension point function. -### `host_emit_event(type_ptr, type_len, payload_ptr, payload_len) -> i32` +#### `host_emit_event(type_ptr, type_len, payload_ptr, payload_len) -> i32` Emit a system event from within a plugin. Enables plugin-to-plugin communication. Returns `0` on success, `-1` on error. -### `host_log(level, ptr, len)` +#### `host_log(level, ptr, len)` Log a message. Levels: 0=error, 1=warn, 2=info, 3+=debug. Messages appear in the server's tracing output. -### `host_read_file(path_ptr, path_len) -> i32` +#### `host_read_file(path_ptr, path_len) -> i32` Read a file into the exchange buffer. Returns file size on success, `-1` on IO error, `-2` if the path is outside the plugin's allowed read paths (paths are canonicalized before checking, so traversal tricks won't work). -### `host_write_file(path_ptr, path_len, data_ptr, data_len) -> i32` +#### `host_write_file(path_ptr, path_len, data_ptr, data_len) -> i32` Write data to a file. Returns `0` on success, `-1` on IO error, `-2` if the path is outside allowed write paths. -### `host_http_request(url_ptr, url_len) -> i32` +#### `host_http_request(url_ptr, url_len) -> i32` Make an HTTP GET request. @@ -496,12 +516,12 @@ Returns response size on success (body in exchange buffer), `-1` on error, `-2` if network access is disabled, `-3` if the domain is not in the plugin's `allowed_domains` list. -### `host_get_config(key_ptr, key_len) -> i32` +#### `host_get_config(key_ptr, key_len) -> i32` Read a plugin configuration value. Returns JSON value length on success (in exchange buffer), `-1` if key not found. -### `host_get_env(key_ptr, key_len) -> i32` +#### `host_get_env(key_ptr, key_len) -> i32` Read an environment variable. @@ -509,7 +529,7 @@ Returns value length on success (in exchange buffer), `-1` if the variable is not set, `-2` if environment access is disabled or the variable is not in the plugin's `environment` list. -### `host_get_buffer(dest_ptr, dest_len) -> i32` +#### `host_get_buffer(dest_ptr, dest_len) -> i32` Copy the exchange buffer into WASM memory. @@ -517,9 +537,9 @@ Returns bytes copied. Use this after `host_read_file`, `host_http_request`, `host_get_config`, or `host_get_env` to retrieve data the host placed in the buffer. -## Security Model +### Security Model -### Capabilities +#### Capabilities Every plugin declares what it needs in `plugin.toml`. The `CapabilityEnforcer` validates these at load time _and_ at runtime; a plugin declaring @@ -545,7 +565,7 @@ without `network = true` will get `-2` from `host_http_request`. - **CPU**: Enforced via wasmtime's fuel metering. Each invocation gets a fuel budget proportional to the configured CPU time limit. -### Isolation +#### Isolation - Each `call_function` invocation creates a fresh wasmtime `Store`. Plugins cannot retain WASM runtime state between calls. If you need persistence, use @@ -555,7 +575,7 @@ without `network = true` will get `-2` from `host_http_request`. - The wasmtime stack is limited to 1MB. -### Timeouts +#### Timeouts Three tiers of per-call timeouts prevent runaway plugins: @@ -569,7 +589,7 @@ Three tiers of per-call timeouts prevent runaway plugins: -### Circuit Breaker +#### Circuit Breaker If a plugin fails consecutively, the circuit breaker disables it automatically: @@ -579,7 +599,7 @@ If a plugin fails consecutively, the circuit breaker disables it automatically: - Disabled plugins are skipped in all pipeline stages. - Reload or toggle the plugin via the API to re-enable. -### Signatures +#### Signatures Plugins can be signed with Ed25519. The signing flow: @@ -603,7 +623,7 @@ verifies against at least one trusted key. If the signature is missing, invalid, or matches no trusted key, the plugin is rejected at load time. Set `allow_unsigned = true` during development to skip this check. -## Plugin Lifecycle +### Plugin Lifecycle 1. **Discovery**: On startup, the plugin manager walks configured plugin directories looking for `plugin.toml` files. @@ -637,7 +657,7 @@ or matches no trusted key, the plugin is rejected at load time. Set 9. **Hot-reload**: The `/plugins/:id/reload` endpoint reloads the WASM binary from disk and re-discovers capabilities without restarting the server. -## Plugin API Endpoints +### Plugin API Endpoints @@ -657,7 +677,7 @@ Reload and toggle operations automatically re-discover the plugin's capabilities, so changes to supported types or event subscriptions take effect immediately. -## Configuration +### Configuration In `pinakes.toml`: @@ -682,7 +702,7 @@ allowed_read_paths = ["/media", "/tmp/pinakes"] allowed_write_paths = ["/tmp/pinakes/plugin-data"] ``` -## Debugging +### Debugging - **`host_log`**: Call from within your plugin to emit structured log messages. They appear in the server's tracing output. @@ -693,3 +713,394 @@ allowed_write_paths = ["/tmp/pinakes/plugin-data"] - **Circuit breaker**: If your plugin is silently skipped, check the server logs for "circuit breaker tripped" messages. Fix the issue, then re-enable via `POST /api/v1/plugins/:id/enable`. + +--- + +## GUI Plugins + +A plugin declares its UI pages and optional widgets in `plugin.toml`, either +inline or as separate `.json` files. At startup, the server indexes all plugin +manifests and serves the schemas from `GET /api/v1/plugins/ui/pages`. The UI +fetches and validates these schemas, registers them in the `PluginRegistry`, and +adds the pages to the sidebar navigation. Widgets are injected into fixed +locations in host views at registration time. + +No WASM code runs during page rendering. The schema is purely declarative JSON. + +### Manifest Additions + +Add a `[ui]` section to `plugin.toml`: + +```toml +[plugin] +name = "my-plugin" +version = "1.0.0" +api_version = "1.0" +kind = ["ui_page"] + +# Inline page definition +[[ui.pages]] +id = "stats" +title = "My Stats" +route = "/plugins/my-plugin/stats" +icon = "chart-bar" + +[ui.pages.data_sources.summary] +type = "endpoint" +path = "/api/v1/plugins/my-plugin/summary" +poll_interval = 30 # re-fetch every 30 seconds + +[ui.pages.layout] +type = "container" +children = [] + +# File-referenced page +[[ui.pages]] +file = "pages/detail.json" # path relative to plugin directory + +# Widget injections +[[ui.widgets]] +id = "my-badge" +target = "library_header" + +[ui.widgets.content] +type = "badge" +text = "My Plugin" +variant = "default" +``` + +Alternatively, pages can be defined entirely in a separate JSON file: + +```json +{ + "id": "stats", + "title": "My Stats", + "route": "/plugins/my-plugin/stats", + "icon": "chart-bar", + "data_sources": { + "summary": { + "type": "endpoint", + "path": "/api/v1/plugins/my-plugin/summary" + } + }, + "layout": { + "type": "container", + "children": [] + } +} +``` + +### `UiPage` Fields + + + +| Field | Type | Required | Description | +| -------------- | -------------------------- | -------- | ----------------------------------------------------- | +| `id` | string | Yes | Unique identifier (alphanumeric, dashes, underscores) | +| `title` | string | Yes | Display name shown in navigation | +| `route` | string | Yes | URL path (must start with `/`) | +| `icon` | string | No | Icon name (from dioxus-free-icons) | +| `layout` | `UiElement` | Yes | Root layout element (see Element Reference) | +| `data_sources` | `{name: DataSource}` | No | Named data sources available to this page | +| `actions` | `{name: ActionDefinition}` | No | Named actions referenced by elements | + + + +### Data Sources + +Data sources populate named slots that elements bind to via their `data` field. + +#### `endpoint`: HTTP API call + +```toml +[ui.pages.data_sources.items] +type = "endpoint" +method = "GET" # GET (default), POST, PUT, PATCH, DELETE +path = "/api/v1/media" # must start with / +poll_interval = 60 # seconds; 0 = no polling (default) + +# Query params for GET, body for other methods +# +# Values are Expressions (see Expression Syntax) +[ui.pages.data_sources.items.params] +limit = 20 +offset = 0 +``` + +#### `static`: Inline JSON + +```toml +[ui.pages.data_sources.options] +type = "static" +value = ["asc", "desc"] +``` + +#### `transform`: Derived from another source + +```toml +# Evaluate an expression against an already-fetched source +[ui.pages.data_sources.count] +type = "transform" +source = "items" # name of the source to read from +expression = "items.total_count" # path expression into the context +``` + +Transform sources always run after all non-transform sources, so the named +source is guaranteed to be available in the context. + +### Expression Syntax + +Expressions appear as values in `params`, `transform`, `TextContent`, and +`Progress.value`. + + + +| Form | JSON example | Description | +| --------- | --------------------------------------------------- | -------------------------------------------- | +| Literal | `42`, `"hello"`, `true`, `null` | A fixed JSON value | +| Path | `"users.0.name"`, `"summary.count"` | Dot-separated path into the data context | +| Operation | `{"left":"a","op":"concat","right":"b"}` | Binary expression (see operators table) | +| Call | `{"function":"format","args":["Hello, {}!","Bob"]}` | Built-in function call (see functions table) | + + + +Path expressions use dot notation. Array indices are plain numbers: +`"items.0.title"` accesses `items[0].title`. + +**Operators:** + +| `op` | Result type | Description | +| -------- | ----------- | ------------------------------------------ | +| `eq` | bool | Equal | +| `ne` | bool | Not equal | +| `gt` | bool | Greater than (numeric) | +| `gte` | bool | Greater than or equal | +| `lt` | bool | Less than | +| `lte` | bool | Less than or equal | +| `and` | bool | Logical AND | +| `or` | bool | Logical OR | +| `concat` | string | String concatenation (both sides coerced) | +| `add` | number | f64 addition | +| `sub` | number | f64 subtraction | +| `mul` | number | f64 multiplication | +| `div` | number | f64 division (returns 0 on divide-by-zero) | + +**Built-in functions:** + + + +| Function | Signature | Description | +| ----------- | -------------------------------------- | -------------------------------------------------------------- | +| `len` | `len(value)` | Length of array, string (chars), or object (key count) | +| `upper` | `upper(str)` | Uppercase string | +| `lower` | `lower(str)` | Lowercase string | +| `trim` | `trim(str)` | Remove leading/trailing whitespace | +| `format` | `format(template, ...args)` | Replace `{}` placeholders left-to-right with args | +| `join` | `join(array, sep)` | Join array elements into a string with separator | +| `contains` | `contains(haystack, needle)` | True if string contains substring, or array contains value | +| `keys` | `keys(object)` | Array of object keys | +| `values` | `values(object)` | Array of object values | +| `abs` | `abs(number)` | Absolute value | +| `round` | `round(number)` | Round to nearest integer | +| `floor` | `floor(number)` | Round down | +| `ceil` | `ceil(number)` | Round up | +| `not` | `not(bool)` | Boolean negation | +| `coalesce` | `coalesce(a, b, ...)` | First non-null argument | +| `to_string` | `to_string(value)` | Convert any value to its display string | +| `to_number` | `to_number(value)` | Parse string to f64; pass through numbers; bool coerces to 0/1 | +| `slice` | `slice(array_or_string, start[, end])` | Sub-array or substring; negative indices count from end | +| `reverse` | `reverse(array_or_string)` | Reversed array or string | +| `if` | `if(cond, then, else)` | Return `then` if `cond` is true, otherwise `else` | + + + +### Actions + +Actions define what happens when a button is clicked or a form is submitted. + +#### Inline action + +```json +{ + "type": "button", + "label": "Delete", + "action": { + "method": "DELETE", + "path": "/api/v1/media/123", + "success_message": "Deleted!", + "navigate_to": "/library" + } +} +``` + +#### Named action + +Define the action in the page's `actions` map, then reference it by name: + +```toml +[ui.pages.actions.delete-item] +method = "DELETE" +path = "/api/v1/media/target" +navigate_to = "/library" +``` + +Then reference it anywhere an `ActionRef` is accepted: + +```json +{ "type": "button", "label": "Delete", "action": "delete-item" } +``` + +Named actions allow multiple elements to share the same action definition +without repetition. The action name must be a valid identifier (alphanumeric, +dashes, underscores). + +#### `ActionDefinition` fields + + + +| Field | Type | Default | Description | +| ----------------- | ------ | ------- | ------------------------------------------ | +| `method` | string | `GET` | HTTP method | +| `path` | string | | API path (must start with `/`) | +| `params` | object | `{}` | Fixed params merged with form data on POST | +| `success_message` | string | | Toast message on success | +| `error_message` | string | | Toast message on error | +| `navigate_to` | string | | Route to navigate to after success | + + + +### Element Reference + +All elements are JSON objects with a `type` field. + + + +| Type | Key fields | Description | +| ------------------ | ----------------------------------------------------------------------- | ---------------------------------------- | +| `container` | `children`, `gap`, `padding` | Stacked children with gap/padding | +| `grid` | `children`, `columns` (1-12), `gap` | CSS grid layout | +| `flex` | `children`, `direction`, `justify`, `align`, `gap`, `wrap` | Flexbox layout | +| `split` | `sidebar`, `sidebar_width`, `main` | Sidebar + main content layout | +| `tabs` | `tabs` (array of `{id,label,content[]}`), `default_tab` | Tabbed panels | +| `heading` | `level` (1-6), `content` | Section heading h1-h6 | +| `text` | `content`, `variant`, `allow_html` | Paragraph text | +| `code` | `content`, `language`, `show_line_numbers` | Code block with syntax highlighting | +| `data_table` | `columns`, `data`, `sortable`, `filterable`, `page_size`, `row_actions` | Sortable, filterable data table | +| `card` | `title`, `content`, `footer` | Content card with optional header/footer | +| `media_grid` | `data`, `columns`, `gap` | Responsive image/video grid | +| `list` | `data`, `item_template`, `dividers` | Templated list (loops over data items) | +| `description_list` | `data`, `horizontal` | Key-value pair list (metadata display) | +| `button` | `label`, `variant`, `action`, `disabled` | Clickable button | +| `form` | `fields`, `submit_label`, `submit_action`, `cancel_label` | Input form with submission | +| `link` | `text`, `href`, `external` | Navigation link | +| `progress` | `value` (Expression), `max`, `show_percentage` | Progress bar | +| `badge` | `text`, `variant` | Status badge/chip | +| `loop` | `data`, `item`, `template` | Iterates data array, renders template | +| `conditional` | `condition` (Expression), `then`, `else` | Conditional rendering | +| `chart` | `chart_type`, `data`, `x_key`, `y_key`, `title` | Bar/line/pie/scatter chart | +| `image` | `src`, `alt`, `width`, `height`, `object_fit` | Image element | +| `divider` | | Horizontal rule | +| `spacer` | `size` | Blank vertical space | +| `raw_html` | `html` | Sanitized raw HTML block | + + + +### Widget Injection + +Widgets are small UI elements injected into fixed locations in the host views. +Unlike pages, widgets have no data sources; they render with static or +expression-based content only. + +#### Declaring a widget + +```toml +[[ui.widgets]] +id = "status-badge" +target = "library_header" + +[ui.widgets.content] +type = "badge" +text = "My Plugin Active" +variant = "success" +``` + +#### Target locations + +| Target string | Where it renders | +| ----------------- | ----------------------------------------- | +| `library_header` | Before the stats grid in the Library view | +| `library_sidebar` | After the stats grid in the Library view | +| `search_filters` | Above the Search component in Search view | +| `detail_panel` | Above the Detail component in Detail view | + +Multiple plugins can register widgets at the same target; all are rendered in +registration order. + +### Complete Example + +A plugin page that lists media items and lets the user trigger a re-scan: + +```toml +[plugin] +name = "rescan-page" +version = "1.0.0" +api_version = "1.0" +kind = ["ui_page"] + +[[ui.pages]] +id = "rescan" +title = "Re-scan" +route = "/plugins/rescan-page/rescan" +icon = "refresh" + +[ui.pages.actions.trigger-scan] +method = "POST" +path = "/api/v1/scan/trigger" +success_message = "Scan started!" +navigate_to = "/plugins/rescan-page/rescan" + +[ui.pages.data_sources.media] +type = "endpoint" +path = "/api/v1/media" +poll_interval = 30 + +[ui.pages.layout] +type = "container" +gap = 16 + +[[ui.pages.layout.children]] +type = "flex" +direction = "row" +justify = "space-between" +gap = 8 + +[[ui.pages.layout.children.children]] +type = "heading" +level = 2 +content = "Library" + +[[ui.pages.layout.children.children]] +type = "button" +label = "Trigger Re-scan" +variant = "primary" +action = "trigger-scan" + +[[ui.pages.layout.children]] +type = "data_table" +data = "media" +sortable = true +filterable = true +page_size = 25 + +[[ui.pages.layout.children.columns]] +key = "title" +label = "Title" + +[[ui.pages.layout.children.columns]] +key = "media_type" +label = "Type" + +[[ui.pages.layout.children.columns]] +key = "file_size" +label = "Size" +``` -- 2.43.0