From 3aa1503441385dcf93a8ab7eb5e4a67c34bdbe95 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Wed, 11 Mar 2026 17:07:17 +0300 Subject: [PATCH 01/37] 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 | 16 ++- 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, 208 insertions(+), 40 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..d4c4d9f 100644 --- a/crates/pinakes-server/src/routes/playlists.rs +++ b/crates/pinakes-server/src/routes/playlists.rs @@ -185,7 +185,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 +219,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))) } From dda84d148ca494050d93373bc9f19a8e79be4a73 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Wed, 11 Mar 2026 17:08:02 +0300 Subject: [PATCH 02/37] 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 { From b89c7a5dc5110f43d791ab7b89af45ad066d29cc Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Wed, 11 Mar 2026 17:08:24 +0300 Subject: [PATCH 03/37] 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(); From 613f6cab54f9d1d47368c4d7414efed64977f753 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Wed, 11 Mar 2026 17:09:27 +0300 Subject: [PATCH 04/37] 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); +} From 8ded6fedc8365322d8eead3572e3bd0b3a83eebc Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Wed, 11 Mar 2026 17:09:56 +0300 Subject: [PATCH 05/37] 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"[]"); +} From 9d93a527cab5d12d958ab22705972d897998a853 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Wed, 11 Mar 2026 17:11:26 +0300 Subject: [PATCH 06/37] 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" }, +] From 1c351c0f53f0cb552f393ce707615cb96e7079bb Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Wed, 11 Mar 2026 17:12:07 +0300 Subject: [PATCH 07/37] 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}" + ); + } } From d059263209d263ecd0cce1800a95efe244c0f16f Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Wed, 11 Mar 2026 17:22:52 +0300 Subject: [PATCH 08/37] 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 { From 02c7b7e2f6daed6578c5d5c05e30a950298ce93e Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Wed, 11 Mar 2026 17:23:08 +0300 Subject: [PATCH 09/37] 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)?; }, From 3a565689c378d91f62ba681a6cbae07074ee5a57 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Wed, 11 Mar 2026 17:23:17 +0300 Subject: [PATCH 10/37] 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()); + } + } } } From df1c46fa5cd2dad2a0b2419fa26195179caf102e Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Wed, 11 Mar 2026 17:23:51 +0300 Subject: [PATCH 11/37] 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("")) } } } } From bc74bf8730e770b3ed8aff42b74700729ee00def Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Wed, 11 Mar 2026 18:21:13 +0300 Subject: [PATCH 12/37] 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(), )); } From f0fdd2ab91c8fe9b9904692a2edea6a389f32f23 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Wed, 11 Mar 2026 21:26:20 +0300 Subject: [PATCH 13/37] 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() { From 920d2e95ab766c987c71efc452c494ab2d2a4817 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Wed, 11 Mar 2026 21:26:41 +0300 Subject: [PATCH 14/37] 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; + } } From 7b841cbd9ad347292590ec02ae2cbf19b8aea0cd Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Wed, 11 Mar 2026 21:26:59 +0300 Subject: [PATCH 15/37] 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 }); From 6d42ac0f48484970958824476708423903e102e3 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Wed, 11 Mar 2026 21:27:42 +0300 Subject: [PATCH 16/37] 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}" } - } - } - } } } From 9176b764b09285d70af135e12322a6a95c015dac Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Wed, 11 Mar 2026 21:27:55 +0300 Subject: [PATCH 17/37] 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" } } } From b6e579408f181fdd3560888d72a9f03e2670c9f6 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Wed, 11 Mar 2026 21:29:24 +0300 Subject: [PATCH 18/37] 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]] From 9c67c81a7976e7126fe5b351962ebf74f148c807 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Wed, 11 Mar 2026 17:07:17 +0300 Subject: [PATCH 19/37] 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))) } From 8f2b44b50ce4540160009e0f96508324f019fcc6 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Wed, 11 Mar 2026 17:08:02 +0300 Subject: [PATCH 20/37] 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 { From 592a9bcc47d99bbb61dcd8f92b808a6edb69b7b6 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Wed, 11 Mar 2026 17:08:24 +0300 Subject: [PATCH 21/37] 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(); From cf76d42c33b703f7a4150e0edc7f7a6930d040f3 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Wed, 11 Mar 2026 17:09:27 +0300 Subject: [PATCH 22/37] 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); +} From 119f6d2e06ea963c6f62e0c34d8691eaeabceb49 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Wed, 11 Mar 2026 17:09:56 +0300 Subject: [PATCH 23/37] 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"[]"); +} From 3678edd3555eb956d1dd6cd9c33ba76011d5b91a Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Wed, 11 Mar 2026 17:11:26 +0300 Subject: [PATCH 24/37] 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" }, +] From dc4dc416701de65c33225d9cd72fc9cf693e1b24 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Wed, 11 Mar 2026 17:12:07 +0300 Subject: [PATCH 25/37] 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}" + ); + } } From 15b005cef03bf4d25befad2e2daafcf05ad6230b Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Wed, 11 Mar 2026 17:22:52 +0300 Subject: [PATCH 26/37] 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 { From 0c9b71346d2dc07f0cdb4ba89da006913c6cc24a Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Wed, 11 Mar 2026 17:23:08 +0300 Subject: [PATCH 27/37] 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)?; }, From 0ba898c881e0945867d5cc389f3cc47dee1e2a91 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Wed, 11 Mar 2026 17:23:17 +0300 Subject: [PATCH 28/37] 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()); + } + } } } From 185e3b562ab078d74279f75f588207cc2e495802 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Wed, 11 Mar 2026 17:23:51 +0300 Subject: [PATCH 29/37] 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("")) } } } } From 91123fc90eadfd5e059cd3adc4b7cadbaebaa407 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Wed, 11 Mar 2026 18:21:13 +0300 Subject: [PATCH 30/37] 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(), )); } From 7989d4c4dd9c49f78251e91b3189300e5a2852ed Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Wed, 11 Mar 2026 21:26:20 +0300 Subject: [PATCH 31/37] 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() { From 220dfa6506adb7286508581755c20f265abffa73 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Wed, 11 Mar 2026 21:26:41 +0300 Subject: [PATCH 32/37] 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; + } } From 63954fdb2f2fa104656812cd7352d7f9680f837c Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Wed, 11 Mar 2026 21:26:59 +0300 Subject: [PATCH 33/37] 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 }); From 81d1695e11dcd145dfbdf3b32e4e46788f628311 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Wed, 11 Mar 2026 21:27:42 +0300 Subject: [PATCH 34/37] 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}" } - } - } - } } } From e1351e88814fbf7f58fb1bcb74168d7e1353e20b Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Wed, 11 Mar 2026 21:27:55 +0300 Subject: [PATCH 35/37] 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" } } } From 0014a1a2a98b7a33721e2040177c604e96c051cb Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Wed, 11 Mar 2026 21:29:24 +0300 Subject: [PATCH 36/37] 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]] From 7cbce98795d3baf658f9bf18e043b4866ee4040a Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Wed, 11 Mar 2026 21:30:00 +0300 Subject: [PATCH 37/37] 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" +```