use base64::{engine::general_purpose, Engine as _}; use image::GenericImageView; use serde::{Deserialize, Serialize}; use std::sync::Mutex; use tauri::{AppHandle, Manager}; use uuid::Uuid; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct LibraryItem { pub id: String, pub url: String, pub source_url: String, pub title: String, pub data_url: String, pub hash: String, pub width: u32, pub height: u32, pub colors: Vec, pub tags: Vec, pub created_at: i64, } #[derive(Default)] pub struct LibraryState { pub items: Mutex> } /// Import from a web URL (existing — downloads via reqwest) #[tauri::command] pub async fn library_add_item(app: AppHandle, url: String, source_url: Option, title: Option) -> Result { let bytes = reqwest::get(&url).await.map_err(|e| e.to_string())?.bytes().await.map_err(|e| e.to_string())?; import_bytes(&app, &bytes, &url, source_url, title) } /// Import from a local file path (no network — reads directly from disk) /// Works on all OS: pass absolute path like "C:\Users\...\image.png" or "/home/.../image.png" #[tauri::command] pub fn library_import_local(app: AppHandle, path: String, title: Option) -> Result { let bytes = std::fs::read(&path).map_err(|e| format!("cannot read file: {e}"))?; import_bytes(&app, &bytes, &format!("file://{path}"), None, title.or_else(|| { std::path::Path::new(&path).file_stem().and_then(|s| s.to_str()).map(|s| s.to_string()) })) } /// Import from raw base64 data URL (for frontend file-reader uploads) #[tauri::command] pub fn library_import_data_url(app: AppHandle, data_url: String, title: Option) -> Result { let (_, encoded) = data_url.split_once(",").ok_or("invalid data URL format")?; let bytes = general_purpose::STANDARD.decode(encoded).map_err(|e| format!("base64 decode error: {e}"))?; import_bytes(&app, &bytes, "local-upload", None, title) } /// Shared import logic — deduplicates, extracts palette, persists fn import_bytes(app: &AppHandle, bytes: &[u8], url: &str, source_url: Option, title: Option) -> Result { let hash = blake3::hash(bytes).to_hex().to_string(); // Check for duplicate { let state = app.state::(); let items = state.items.lock().map_err(|_| "library lock poisoned".to_string())?; if let Some(existing) = items.iter().find(|i| i.hash == hash) { return Ok(existing.clone()); } } let img = image::load_from_memory(bytes).map_err(|e| format!("unsupported image format: {e}"))?; let (width, height) = img.dimensions(); let colors = extract_palette(&img, 6); let mime = detect_mime(bytes, url); let data_url_str = format!("data:{mime};base64,{}", general_purpose::STANDARD.encode(bytes)); let item = LibraryItem { id: Uuid::new_v4().to_string(), url: url.to_string(), source_url: source_url.unwrap_or_default(), title: title.unwrap_or_else(|| "Untitled reference".to_string()), data_url: data_url_str, hash, width, height, colors, tags: vec![], created_at: chrono::Utc::now().timestamp(), }; let state = app.state::(); let mut items = state.items.lock().map_err(|_| "library lock poisoned".to_string())?; items.push(item.clone()); crate::persistence::save_json(app, "library.json", &*items)?; Ok(item) } /// Update title and/or tags for an item in one call #[tauri::command] pub fn library_update_metadata(app: AppHandle, id: String, title: Option, tags: Option>) -> Result { let state = app.state::(); let mut items = state.items.lock().map_err(|_| "library lock poisoned".to_string())?; let item = items.iter_mut().find(|i| i.id == id).ok_or("item not found")?; if let Some(t) = title { if !t.trim().is_empty() { item.title = t; } } if let Some(t) = tags { item.tags = t; } let result = item.clone(); crate::persistence::save_json(&app, "library.json", &*items)?; Ok(result) } /// Remove a single tag from an item #[tauri::command] pub fn library_remove_tag(app: AppHandle, id: String, tag: String) -> Result { let state = app.state::(); let mut items = state.items.lock().map_err(|_| "library lock poisoned".to_string())?; let item = items.iter_mut().find(|i| i.id == id).ok_or("item not found")?; item.tags.retain(|t| t != &tag); let result = item.clone(); crate::persistence::save_json(&app, "library.json", &*items)?; Ok(result) } #[tauri::command] pub fn library_load(app: AppHandle) -> Result, String> { let loaded: Vec = crate::persistence::load_json(&app, "library.json")?; let state = app.state::(); let mut items = state.items.lock().map_err(|_| "library lock poisoned".to_string())?; if items.is_empty() && !loaded.is_empty() { *items = loaded; } Ok(items.clone()) } #[tauri::command] pub fn library_items(app: AppHandle) -> Result, String> { library_load(app) } #[tauri::command] pub fn library_search(app: AppHandle, query: String) -> Result, String> { let q = query.to_lowercase(); let items = library_load(app)?; Ok(items.into_iter().filter(|item| item.title.to_lowercase().contains(&q) || item.url.to_lowercase().contains(&q) || item.tags.iter().any(|t| t.to_lowercase().contains(&q))).collect()) } #[tauri::command] pub fn library_remove_item(app: AppHandle, id: String) -> Result<(), String> { let state = app.state::(); let mut items = state.items.lock().map_err(|_| "library lock poisoned".to_string())?; items.retain(|i| i.id != id); crate::persistence::save_json(&app, "library.json", &*items) } #[tauri::command] pub fn library_add_tag(app: AppHandle, id: String, tag: String) -> Result { let state = app.state::(); let mut items = state.items.lock().map_err(|_| "library lock poisoned".to_string())?; let item = items.iter_mut().find(|i| i.id == id).ok_or("item not found")?; if !item.tags.contains(&tag) { item.tags.push(tag); } let result = item.clone(); crate::persistence::save_json(&app, "library.json", &*items)?; Ok(result) } /// Detect MIME from magic bytes or URL extension fn detect_mime(bytes: &[u8], url: &str) -> &'static str { // Magic bytes if bytes.len() >= 8 { if bytes[0..4] == [0x89, 0x50, 0x4E, 0x47] { return "image/png"; } if bytes[0..3] == [0xFF, 0xD8, 0xFF] { return "image/jpeg"; } if bytes[0..4] == [0x47, 0x49, 0x46, 0x38] { return "image/gif"; } if bytes[0..4] == [0x52, 0x49, 0x46, 0x46] && bytes.len() >= 12 && bytes[8..12] == [0x57, 0x45, 0x42, 0x50] { return "image/webp"; } if bytes[0..2] == [0x42, 0x4D] { return "image/bmp"; } } // Fallback to extension let lower = url.to_lowercase(); if lower.ends_with(".png") { "image/png" } else if lower.ends_with(".webp") { "image/webp" } else if lower.ends_with(".gif") { "image/gif" } else if lower.ends_with(".bmp") { "image/bmp" } else { "image/jpeg" } } fn extract_palette(img: &image::DynamicImage, count: usize) -> Vec { let thumb = img.thumbnail(120, 120).to_rgb8(); let mut pixels: Vec<[u8; 3]> = thumb.pixels().filter_map(|p| { let [r, g, b] = [p[0], p[1], p[2]]; if r.max(g).max(b) < 12 { None } else { Some([r, g, b]) } }).collect(); if pixels.is_empty() { pixels = thumb.pixels().map(|p| [p[0], p[1], p[2]]).collect(); } let mut buckets = vec![pixels]; while buckets.len() < count { let Some((idx, axis)) = buckets.iter().enumerate().filter(|(_, b)| b.len() > 1).map(|(i, b)| { let ranges: Vec = (0..3).map(|c| b.iter().map(|p| p[c]).max().unwrap_or(0).saturating_sub(b.iter().map(|p| p[c]).min().unwrap_or(0))).collect(); let axis = ranges.iter().enumerate().max_by_key(|(_, r)| *r).map(|(a, _)| a).unwrap_or(0); (i, axis) }).max_by_key(|(i, axis)| { let b = &buckets[*i]; b.iter().map(|p| p[*axis]).max().unwrap_or(0).saturating_sub(b.iter().map(|p| p[*axis]).min().unwrap_or(0)) }) else { break; }; let mut b = buckets.remove(idx); b.sort_by_key(|p| p[axis]); let right = b.split_off(b.len() / 2); buckets.push(b); buckets.push(right); } buckets.into_iter().filter(|b| !b.is_empty()).map(|b| { let n = b.len() as u32; let (r, g, bl) = b.iter().fold((0u32, 0u32, 0u32), |a, p| (a.0 + p[0] as u32, a.1 + p[1] as u32, a.2 + p[2] as u32)); format!("#{:02X}{:02X}{:02X}", r / n, g / n, bl / n) }).collect() }