feat: library_import_local, library_update_metadata, library_remove_tag Rust commands for local file import and metadata editing
Browse files- src-tauri/src/library.rs +189 -107
src-tauri/src/library.rs
CHANGED
|
@@ -1,107 +1,189 @@
|
|
| 1 |
-
use base64::{engine::general_purpose, Engine as _};
|
| 2 |
-
use image::GenericImageView;
|
| 3 |
-
use serde::{Deserialize, Serialize};
|
| 4 |
-
use std::sync::Mutex;
|
| 5 |
-
use tauri::{AppHandle, Manager};
|
| 6 |
-
use uuid::Uuid;
|
| 7 |
-
|
| 8 |
-
#[derive(Debug, Clone, Serialize, Deserialize)]
|
| 9 |
-
pub struct LibraryItem {
|
| 10 |
-
pub id: String,
|
| 11 |
-
pub url: String,
|
| 12 |
-
pub source_url: String,
|
| 13 |
-
pub title: String,
|
| 14 |
-
pub data_url: String,
|
| 15 |
-
pub hash: String,
|
| 16 |
-
pub width: u32,
|
| 17 |
-
pub height: u32,
|
| 18 |
-
pub colors: Vec<String>,
|
| 19 |
-
pub tags: Vec<String>,
|
| 20 |
-
pub created_at: i64,
|
| 21 |
-
}
|
| 22 |
-
|
| 23 |
-
#[derive(Default)]
|
| 24 |
-
pub struct LibraryState { pub items: Mutex<Vec<LibraryItem>> }
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
let
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
let
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
#[tauri::command]
|
| 45 |
-
pub fn
|
| 46 |
-
let
|
| 47 |
-
let
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
let
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
let
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
if
|
| 90 |
-
let
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
use base64::{engine::general_purpose, Engine as _};
|
| 2 |
+
use image::GenericImageView;
|
| 3 |
+
use serde::{Deserialize, Serialize};
|
| 4 |
+
use std::sync::Mutex;
|
| 5 |
+
use tauri::{AppHandle, Manager};
|
| 6 |
+
use uuid::Uuid;
|
| 7 |
+
|
| 8 |
+
#[derive(Debug, Clone, Serialize, Deserialize)]
|
| 9 |
+
pub struct LibraryItem {
|
| 10 |
+
pub id: String,
|
| 11 |
+
pub url: String,
|
| 12 |
+
pub source_url: String,
|
| 13 |
+
pub title: String,
|
| 14 |
+
pub data_url: String,
|
| 15 |
+
pub hash: String,
|
| 16 |
+
pub width: u32,
|
| 17 |
+
pub height: u32,
|
| 18 |
+
pub colors: Vec<String>,
|
| 19 |
+
pub tags: Vec<String>,
|
| 20 |
+
pub created_at: i64,
|
| 21 |
+
}
|
| 22 |
+
|
| 23 |
+
#[derive(Default)]
|
| 24 |
+
pub struct LibraryState { pub items: Mutex<Vec<LibraryItem>> }
|
| 25 |
+
|
| 26 |
+
/// Import from a web URL (existing — downloads via reqwest)
|
| 27 |
+
#[tauri::command]
|
| 28 |
+
pub async fn library_add_item(app: AppHandle, url: String, source_url: Option<String>, title: Option<String>) -> Result<LibraryItem, String> {
|
| 29 |
+
let bytes = reqwest::get(&url).await.map_err(|e| e.to_string())?.bytes().await.map_err(|e| e.to_string())?;
|
| 30 |
+
import_bytes(&app, &bytes, &url, source_url, title)
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
/// Import from a local file path (no network — reads directly from disk)
|
| 34 |
+
/// Works on all OS: pass absolute path like "C:\Users\...\image.png" or "/home/.../image.png"
|
| 35 |
+
#[tauri::command]
|
| 36 |
+
pub fn library_import_local(app: AppHandle, path: String, title: Option<String>) -> Result<LibraryItem, String> {
|
| 37 |
+
let bytes = std::fs::read(&path).map_err(|e| format!("cannot read file: {e}"))?;
|
| 38 |
+
import_bytes(&app, &bytes, &format!("file://{path}"), None, title.or_else(|| {
|
| 39 |
+
std::path::Path::new(&path).file_stem().and_then(|s| s.to_str()).map(|s| s.to_string())
|
| 40 |
+
}))
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
/// Import from raw base64 data URL (for frontend file-reader uploads)
|
| 44 |
+
#[tauri::command]
|
| 45 |
+
pub fn library_import_data_url(app: AppHandle, data_url: String, title: Option<String>) -> Result<LibraryItem, String> {
|
| 46 |
+
let (_, encoded) = data_url.split_once(",").ok_or("invalid data URL format")?;
|
| 47 |
+
let bytes = general_purpose::STANDARD.decode(encoded).map_err(|e| format!("base64 decode error: {e}"))?;
|
| 48 |
+
import_bytes(&app, &bytes, "local-upload", None, title)
|
| 49 |
+
}
|
| 50 |
+
|
| 51 |
+
/// Shared import logic — deduplicates, extracts palette, persists
|
| 52 |
+
fn import_bytes(app: &AppHandle, bytes: &[u8], url: &str, source_url: Option<String>, title: Option<String>) -> Result<LibraryItem, String> {
|
| 53 |
+
let hash = blake3::hash(bytes).to_hex().to_string();
|
| 54 |
+
// Check for duplicate
|
| 55 |
+
{
|
| 56 |
+
let state = app.state::<LibraryState>();
|
| 57 |
+
let items = state.items.lock().map_err(|_| "library lock poisoned".to_string())?;
|
| 58 |
+
if let Some(existing) = items.iter().find(|i| i.hash == hash) { return Ok(existing.clone()); }
|
| 59 |
+
}
|
| 60 |
+
let img = image::load_from_memory(bytes).map_err(|e| format!("unsupported image format: {e}"))?;
|
| 61 |
+
let (width, height) = img.dimensions();
|
| 62 |
+
let colors = extract_palette(&img, 6);
|
| 63 |
+
let mime = detect_mime(bytes, url);
|
| 64 |
+
let data_url_str = format!("data:{mime};base64,{}", general_purpose::STANDARD.encode(bytes));
|
| 65 |
+
let item = LibraryItem {
|
| 66 |
+
id: Uuid::new_v4().to_string(),
|
| 67 |
+
url: url.to_string(),
|
| 68 |
+
source_url: source_url.unwrap_or_default(),
|
| 69 |
+
title: title.unwrap_or_else(|| "Untitled reference".to_string()),
|
| 70 |
+
data_url: data_url_str,
|
| 71 |
+
hash,
|
| 72 |
+
width, height, colors,
|
| 73 |
+
tags: vec![],
|
| 74 |
+
created_at: chrono::Utc::now().timestamp(),
|
| 75 |
+
};
|
| 76 |
+
let state = app.state::<LibraryState>();
|
| 77 |
+
let mut items = state.items.lock().map_err(|_| "library lock poisoned".to_string())?;
|
| 78 |
+
items.push(item.clone());
|
| 79 |
+
crate::persistence::save_json(app, "library.json", &*items)?;
|
| 80 |
+
Ok(item)
|
| 81 |
+
}
|
| 82 |
+
|
| 83 |
+
/// Update title and/or tags for an item in one call
|
| 84 |
+
#[tauri::command]
|
| 85 |
+
pub fn library_update_metadata(app: AppHandle, id: String, title: Option<String>, tags: Option<Vec<String>>) -> Result<LibraryItem, String> {
|
| 86 |
+
let state = app.state::<LibraryState>();
|
| 87 |
+
let mut items = state.items.lock().map_err(|_| "library lock poisoned".to_string())?;
|
| 88 |
+
let item = items.iter_mut().find(|i| i.id == id).ok_or("item not found")?;
|
| 89 |
+
if let Some(t) = title { if !t.trim().is_empty() { item.title = t; } }
|
| 90 |
+
if let Some(t) = tags { item.tags = t; }
|
| 91 |
+
let result = item.clone();
|
| 92 |
+
crate::persistence::save_json(&app, "library.json", &*items)?;
|
| 93 |
+
Ok(result)
|
| 94 |
+
}
|
| 95 |
+
|
| 96 |
+
/// Remove a single tag from an item
|
| 97 |
+
#[tauri::command]
|
| 98 |
+
pub fn library_remove_tag(app: AppHandle, id: String, tag: String) -> Result<LibraryItem, String> {
|
| 99 |
+
let state = app.state::<LibraryState>();
|
| 100 |
+
let mut items = state.items.lock().map_err(|_| "library lock poisoned".to_string())?;
|
| 101 |
+
let item = items.iter_mut().find(|i| i.id == id).ok_or("item not found")?;
|
| 102 |
+
item.tags.retain(|t| t != &tag);
|
| 103 |
+
let result = item.clone();
|
| 104 |
+
crate::persistence::save_json(&app, "library.json", &*items)?;
|
| 105 |
+
Ok(result)
|
| 106 |
+
}
|
| 107 |
+
|
| 108 |
+
#[tauri::command]
|
| 109 |
+
pub fn library_load(app: AppHandle) -> Result<Vec<LibraryItem>, String> {
|
| 110 |
+
let loaded: Vec<LibraryItem> = crate::persistence::load_json(&app, "library.json")?;
|
| 111 |
+
let state = app.state::<LibraryState>();
|
| 112 |
+
let mut items = state.items.lock().map_err(|_| "library lock poisoned".to_string())?;
|
| 113 |
+
if items.is_empty() && !loaded.is_empty() { *items = loaded; }
|
| 114 |
+
Ok(items.clone())
|
| 115 |
+
}
|
| 116 |
+
|
| 117 |
+
#[tauri::command]
|
| 118 |
+
pub fn library_items(app: AppHandle) -> Result<Vec<LibraryItem>, String> { library_load(app) }
|
| 119 |
+
|
| 120 |
+
#[tauri::command]
|
| 121 |
+
pub fn library_search(app: AppHandle, query: String) -> Result<Vec<LibraryItem>, String> {
|
| 122 |
+
let q = query.to_lowercase();
|
| 123 |
+
let items = library_load(app)?;
|
| 124 |
+
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())
|
| 125 |
+
}
|
| 126 |
+
|
| 127 |
+
#[tauri::command]
|
| 128 |
+
pub fn library_remove_item(app: AppHandle, id: String) -> Result<(), String> {
|
| 129 |
+
let state = app.state::<LibraryState>();
|
| 130 |
+
let mut items = state.items.lock().map_err(|_| "library lock poisoned".to_string())?;
|
| 131 |
+
items.retain(|i| i.id != id);
|
| 132 |
+
crate::persistence::save_json(&app, "library.json", &*items)
|
| 133 |
+
}
|
| 134 |
+
|
| 135 |
+
#[tauri::command]
|
| 136 |
+
pub fn library_add_tag(app: AppHandle, id: String, tag: String) -> Result<LibraryItem, String> {
|
| 137 |
+
let state = app.state::<LibraryState>();
|
| 138 |
+
let mut items = state.items.lock().map_err(|_| "library lock poisoned".to_string())?;
|
| 139 |
+
let item = items.iter_mut().find(|i| i.id == id).ok_or("item not found")?;
|
| 140 |
+
if !item.tags.contains(&tag) { item.tags.push(tag); }
|
| 141 |
+
let result = item.clone();
|
| 142 |
+
crate::persistence::save_json(&app, "library.json", &*items)?;
|
| 143 |
+
Ok(result)
|
| 144 |
+
}
|
| 145 |
+
|
| 146 |
+
/// Detect MIME from magic bytes or URL extension
|
| 147 |
+
fn detect_mime(bytes: &[u8], url: &str) -> &'static str {
|
| 148 |
+
// Magic bytes
|
| 149 |
+
if bytes.len() >= 8 {
|
| 150 |
+
if bytes[0..4] == [0x89, 0x50, 0x4E, 0x47] { return "image/png"; }
|
| 151 |
+
if bytes[0..3] == [0xFF, 0xD8, 0xFF] { return "image/jpeg"; }
|
| 152 |
+
if bytes[0..4] == [0x47, 0x49, 0x46, 0x38] { return "image/gif"; }
|
| 153 |
+
if bytes[0..4] == [0x52, 0x49, 0x46, 0x46] && bytes.len() >= 12 && bytes[8..12] == [0x57, 0x45, 0x42, 0x50] { return "image/webp"; }
|
| 154 |
+
if bytes[0..2] == [0x42, 0x4D] { return "image/bmp"; }
|
| 155 |
+
}
|
| 156 |
+
// Fallback to extension
|
| 157 |
+
let lower = url.to_lowercase();
|
| 158 |
+
if lower.ends_with(".png") { "image/png" }
|
| 159 |
+
else if lower.ends_with(".webp") { "image/webp" }
|
| 160 |
+
else if lower.ends_with(".gif") { "image/gif" }
|
| 161 |
+
else if lower.ends_with(".bmp") { "image/bmp" }
|
| 162 |
+
else { "image/jpeg" }
|
| 163 |
+
}
|
| 164 |
+
|
| 165 |
+
fn extract_palette(img: &image::DynamicImage, count: usize) -> Vec<String> {
|
| 166 |
+
let thumb = img.thumbnail(120, 120).to_rgb8();
|
| 167 |
+
let mut pixels: Vec<[u8; 3]> = thumb.pixels().filter_map(|p| {
|
| 168 |
+
let [r, g, b] = [p[0], p[1], p[2]];
|
| 169 |
+
if r.max(g).max(b) < 12 { None } else { Some([r, g, b]) }
|
| 170 |
+
}).collect();
|
| 171 |
+
if pixels.is_empty() { pixels = thumb.pixels().map(|p| [p[0], p[1], p[2]]).collect(); }
|
| 172 |
+
let mut buckets = vec![pixels];
|
| 173 |
+
while buckets.len() < count {
|
| 174 |
+
let Some((idx, axis)) = buckets.iter().enumerate().filter(|(_, b)| b.len() > 1).map(|(i, b)| {
|
| 175 |
+
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();
|
| 176 |
+
let axis = ranges.iter().enumerate().max_by_key(|(_, r)| *r).map(|(a, _)| a).unwrap_or(0);
|
| 177 |
+
(i, axis)
|
| 178 |
+
}).max_by_key(|(i, axis)| {
|
| 179 |
+
let b = &buckets[*i];
|
| 180 |
+
b.iter().map(|p| p[*axis]).max().unwrap_or(0).saturating_sub(b.iter().map(|p| p[*axis]).min().unwrap_or(0))
|
| 181 |
+
}) else { break; };
|
| 182 |
+
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);
|
| 183 |
+
}
|
| 184 |
+
buckets.into_iter().filter(|b| !b.is_empty()).map(|b| {
|
| 185 |
+
let n = b.len() as u32;
|
| 186 |
+
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));
|
| 187 |
+
format!("#{:02X}{:02X}{:02X}", r / n, g / n, bl / n)
|
| 188 |
+
}).collect()
|
| 189 |
+
}
|