musealpha / src-tauri /src /library.rs
asdf98's picture
feat: library_import_local, library_update_metadata, library_remove_tag Rust commands for local file import and metadata editing
2140350 verified
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>> }
/// Import from a web URL (existing — downloads via reqwest)
#[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)
}
/// 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<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())
}))
}
/// 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<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)
}
/// Shared import logic — deduplicates, extracts palette, persists
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();
// Check for duplicate
{
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)
}
/// Update title and/or tags for an item in one call
#[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)
}
/// Remove a single tag from an item
#[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)
}
/// 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<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()
}