File size: 8,892 Bytes
2140350
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
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()
}