asdf98 commited on
Commit
2140350
·
verified ·
1 Parent(s): 2443464

feat: library_import_local, library_update_metadata, library_remove_tag Rust commands for local file import and metadata editing

Browse files
Files changed (1) hide show
  1. 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
- #[tauri::command]
27
- pub async fn library_add_item(app: AppHandle, url: String, source_url: Option<String>, title: Option<String>) -> Result<LibraryItem, String> {
28
- let bytes = reqwest::get(&url).await.map_err(|e| e.to_string())?.bytes().await.map_err(|e| e.to_string())?;
29
- let hash = blake3::hash(&bytes).to_hex().to_string();
30
- let img = image::load_from_memory(&bytes).map_err(|e| format!("unsupported image: {e}"))?;
31
- let (width, height) = img.dimensions();
32
- let colors = extract_palette(&img, 6);
33
- let mime = if url.to_lowercase().ends_with(".png") { "image/png" } else if url.to_lowercase().ends_with(".webp") { "image/webp" } else if url.to_lowercase().ends_with(".gif") { "image/gif" } else { "image/jpeg" };
34
- let data_url = format!("data:{mime};base64,{}", general_purpose::STANDARD.encode(&bytes));
35
- let item = LibraryItem { id: Uuid::new_v4().to_string(), url, source_url: source_url.unwrap_or_default(), title: title.unwrap_or_else(|| "Untitled reference".to_string()), data_url, hash, width, height, colors, tags: vec![], created_at: chrono::Utc::now().timestamp() };
36
- let state = app.state::<LibraryState>();
37
- let mut items = state.items.lock().map_err(|_| "library lock poisoned".to_string())?;
38
- if let Some(existing) = items.iter().find(|i| i.hash == item.hash) { return Ok(existing.clone()); }
39
- items.push(item.clone());
40
- crate::persistence::save_json(&app, "library.json", &*items)?;
41
- Ok(item)
42
- }
43
-
44
- #[tauri::command]
45
- pub fn library_load(app: AppHandle) -> Result<Vec<LibraryItem>, String> {
46
- let loaded: Vec<LibraryItem> = crate::persistence::load_json(&app, "library.json")?;
47
- let state = app.state::<LibraryState>();
48
- let mut items = state.items.lock().map_err(|_| "library lock poisoned".to_string())?;
49
- if items.is_empty() && !loaded.is_empty() { *items = loaded; }
50
- Ok(items.clone())
51
- }
52
-
53
- #[tauri::command]
54
- pub fn library_items(app: AppHandle) -> Result<Vec<LibraryItem>, String> { library_load(app) }
55
-
56
- #[tauri::command]
57
- pub fn library_search(app: AppHandle, query: String) -> Result<Vec<LibraryItem>, String> {
58
- let q = query.to_lowercase();
59
- let items = library_load(app)?;
60
- 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())
61
- }
62
-
63
- #[tauri::command]
64
- pub fn library_remove_item(app: AppHandle, id: String) -> Result<(), String> {
65
- let state = app.state::<LibraryState>();
66
- let mut items = state.items.lock().map_err(|_| "library lock poisoned".to_string())?;
67
- items.retain(|i| i.id != id);
68
- crate::persistence::save_json(&app, "library.json", &*items)
69
- }
70
-
71
- #[tauri::command]
72
- pub fn library_add_tag(app: AppHandle, id: String, tag: String) -> Result<LibraryItem, String> {
73
- let state = app.state::<LibraryState>();
74
- let mut items = state.items.lock().map_err(|_| "library lock poisoned".to_string())?;
75
- let item = items.iter_mut().find(|i| i.id == id).ok_or("item not found")?;
76
- if !item.tags.contains(&tag) { item.tags.push(tag); }
77
- let result = item.clone();
78
- crate::persistence::save_json(&app, "library.json", &*items)?;
79
- Ok(result)
80
- }
81
-
82
- fn extract_palette(img: &image::DynamicImage, count: usize) -> Vec<String> {
83
- let thumb = img.thumbnail(120, 120).to_rgb8();
84
- let mut pixels: Vec<[u8; 3]> = thumb.pixels().filter_map(|p| {
85
- let [r, g, b] = [p[0], p[1], p[2]];
86
- let max = r.max(g).max(b);
87
- if max < 12 { None } else { Some([r, g, b]) }
88
- }).collect();
89
- if pixels.is_empty() { pixels = thumb.pixels().map(|p| [p[0], p[1], p[2]]).collect(); }
90
- let mut buckets = vec![pixels];
91
- while buckets.len() < count {
92
- let Some((idx, axis)) = buckets.iter().enumerate().filter(|(_, b)| b.len() > 1).map(|(i, b)| {
93
- 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();
94
- let axis = ranges.iter().enumerate().max_by_key(|(_, r)| *r).map(|(a, _)| a).unwrap_or(0);
95
- (i, axis)
96
- }).max_by_key(|(i, axis)| {
97
- let b = &buckets[*i];
98
- b.iter().map(|p| p[*axis]).max().unwrap_or(0).saturating_sub(b.iter().map(|p| p[*axis]).min().unwrap_or(0))
99
- }) else { break; };
100
- 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);
101
- }
102
- buckets.into_iter().filter(|b| !b.is_empty()).map(|b| {
103
- let n = b.len() as u32;
104
- 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));
105
- format!("#{:02X}{:02X}{:02X}", r / n, g / n, bl / n)
106
- }).collect()
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
+ }