| 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<String>, |
| pub tags: Vec<String>, |
| pub created_at: i64, |
| } |
|
|
| #[derive(Default)] |
| pub struct LibraryState { pub items: Mutex<Vec<LibraryItem>> } |
|
|
| |
| #[tauri::command] |
| pub async fn library_add_item(app: AppHandle, url: String, source_url: Option<String>, title: Option<String>) -> Result<LibraryItem, String> { |
| 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) |
| } |
|
|
| |
| |
| #[tauri::command] |
| pub fn library_import_local(app: AppHandle, path: String, title: Option<String>) -> Result<LibraryItem, String> { |
| 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()) |
| })) |
| } |
|
|
| |
| #[tauri::command] |
| pub fn library_import_data_url(app: AppHandle, data_url: String, title: Option<String>) -> Result<LibraryItem, String> { |
| 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) |
| } |
|
|
| |
| fn import_bytes(app: &AppHandle, bytes: &[u8], url: &str, source_url: Option<String>, title: Option<String>) -> Result<LibraryItem, String> { |
| let hash = blake3::hash(bytes).to_hex().to_string(); |
| |
| { |
| let state = app.state::<LibraryState>(); |
| 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::<LibraryState>(); |
| 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) |
| } |
|
|
| |
| #[tauri::command] |
| pub fn library_update_metadata(app: AppHandle, id: String, title: Option<String>, tags: Option<Vec<String>>) -> Result<LibraryItem, String> { |
| let state = app.state::<LibraryState>(); |
| 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) |
| } |
|
|
| |
| #[tauri::command] |
| pub fn library_remove_tag(app: AppHandle, id: String, tag: String) -> Result<LibraryItem, String> { |
| let state = app.state::<LibraryState>(); |
| 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<Vec<LibraryItem>, String> { |
| let loaded: Vec<LibraryItem> = crate::persistence::load_json(&app, "library.json")?; |
| let state = app.state::<LibraryState>(); |
| 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<Vec<LibraryItem>, String> { library_load(app) } |
|
|
| #[tauri::command] |
| pub fn library_search(app: AppHandle, query: String) -> Result<Vec<LibraryItem>, 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::<LibraryState>(); |
| 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<LibraryItem, String> { |
| let state = app.state::<LibraryState>(); |
| 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) |
| } |
|
|
| |
| fn detect_mime(bytes: &[u8], url: &str) -> &'static str { |
| |
| 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"; } |
| } |
| |
| 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<String> { |
| 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<u8> = (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() |
| } |
|
|