musealpha / src-tauri /src /board.rs
asdf98's picture
Upload 112 files
3d7d9b5 verified
use base64::{engine::general_purpose, Engine as _};
use serde::{Deserialize, Serialize};
use std::sync::Mutex;
use tauri::{AppHandle, Manager};
use uuid::Uuid;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BoardItem {
pub id: String,
pub kind: String,
pub library_id: Option<String>,
pub data_url: Option<String>,
pub text: Option<String>,
pub colors: Vec<String>,
pub x: f64,
pub y: f64,
pub w: f64,
pub h: f64,
pub z: i64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BoardSummary {
pub id: String,
pub title: String,
pub item_count: usize,
pub created_at: i64,
pub updated_at: i64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BoardDocument {
pub id: String,
pub title: String,
pub items: Vec<BoardItem>,
pub created_at: i64,
pub updated_at: i64,
}
impl Default for BoardDocument {
fn default() -> Self {
let now = chrono::Utc::now().timestamp();
Self { id: "default".into(), title: "Reference Board".into(), items: vec![], created_at: now, updated_at: now }
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
struct BoardIndex { boards: Vec<BoardSummary>, active_id: Option<String> }
#[derive(Default)]
pub struct BoardState { pub current: Mutex<Option<BoardDocument>> }
fn board_file(id: &str) -> String { format!("board_{id}.json") }
fn save_index(app: &AppHandle, index: &BoardIndex) -> Result<(), String> { crate::persistence::save_json(app, "boards_index.json", index) }
fn load_index(app: &AppHandle) -> Result<BoardIndex, String> { crate::persistence::load_json(app, "boards_index.json") }
fn save_doc(app: &AppHandle, doc: &BoardDocument) -> Result<(), String> { crate::persistence::save_json(app, &board_file(&doc.id), doc) }
fn load_doc(app: &AppHandle, id: &str) -> Result<BoardDocument, String> { crate::persistence::load_json(app, &board_file(id)) }
fn update_index_for_doc(app: &AppHandle, doc: &BoardDocument, make_active: bool) -> Result<(), String> {
let mut index = load_index(app).unwrap_or_default();
let summary = BoardSummary { id: doc.id.clone(), title: doc.title.clone(), item_count: doc.items.len(), created_at: doc.created_at, updated_at: doc.updated_at };
if let Some(slot) = index.boards.iter_mut().find(|b| b.id == doc.id) { *slot = summary; } else { index.boards.insert(0, summary); }
if make_active { index.active_id = Some(doc.id.clone()); }
save_index(app, &index)
}
fn ensure_current(app: &AppHandle) -> Result<BoardDocument, String> {
let state = app.state::<BoardState>();
if let Some(doc) = state.current.lock().map_err(|_| "board lock poisoned".to_string())?.clone() { return Ok(doc); }
let index = load_index(app).unwrap_or_default();
if let Some(active) = index.active_id.clone().or_else(|| index.boards.first().map(|b| b.id.clone())) {
let doc = load_doc(app, &active).unwrap_or_default();
*state.current.lock().map_err(|_| "board lock poisoned".to_string())? = Some(doc.clone());
return Ok(doc);
}
let doc = BoardDocument::default();
save_doc(app, &doc)?;
let mut idx = load_index(app).unwrap_or_default();
idx.boards.push(BoardSummary { id: doc.id.clone(), title: doc.title.clone(), item_count: 0, created_at: doc.created_at, updated_at: doc.updated_at });
idx.active_id = Some(doc.id.clone());
save_index(app, &idx)?;
*state.current.lock().map_err(|_| "board lock poisoned".to_string())? = Some(doc.clone());
Ok(doc)
}
fn save_current(app: &AppHandle, mut doc: BoardDocument) -> Result<BoardDocument, String> {
doc.updated_at = chrono::Utc::now().timestamp();
save_doc(app, &doc)?;
update_index_for_doc(app, &doc, true)?;
let state = app.state::<BoardState>();
*state.current.lock().map_err(|_| "board lock poisoned".to_string())? = Some(doc.clone());
Ok(doc)
}
#[tauri::command]
pub fn board_list(app: AppHandle) -> Result<Vec<BoardSummary>, String> { Ok(load_index(&app).unwrap_or_default().boards) }
#[tauri::command]
pub fn board_current(app: AppHandle) -> Result<BoardDocument, String> { ensure_current(&app) }
#[tauri::command]
pub fn board_create(app: AppHandle, title: String) -> Result<BoardDocument, String> {
let now = chrono::Utc::now().timestamp();
let doc = BoardDocument { id: Uuid::new_v4().to_string(), title: if title.trim().is_empty() { "Untitled Board".into() } else { title }, items: vec![], created_at: now, updated_at: now };
save_doc(&app, &doc)?; update_index_for_doc(&app, &doc, true)?;
let state = app.state::<BoardState>();
*state.current.lock().map_err(|_| "board lock poisoned".to_string())? = Some(doc.clone());
Ok(doc)
}
#[tauri::command]
pub fn board_open(app: AppHandle, id: String) -> Result<BoardDocument, String> {
let doc = load_doc(&app, &id)?; update_index_for_doc(&app, &doc, true)?;
let state = app.state::<BoardState>();
*state.current.lock().map_err(|_| "board lock poisoned".to_string())? = Some(doc.clone());
Ok(doc)
}
#[tauri::command]
pub fn board_save_as(app: AppHandle, title: String) -> Result<BoardDocument, String> {
let current = ensure_current(&app)?;
let now = chrono::Utc::now().timestamp();
let doc = BoardDocument { id: Uuid::new_v4().to_string(), title: if title.trim().is_empty() { format!("{} copy", current.title) } else { title }, items: current.items, created_at: now, updated_at: now };
save_doc(&app, &doc)?; update_index_for_doc(&app, &doc, true)?;
let state = app.state::<BoardState>();
*state.current.lock().map_err(|_| "board lock poisoned".to_string())? = Some(doc.clone());
Ok(doc)
}
#[tauri::command]
pub fn board_load(app: AppHandle) -> Result<Vec<BoardItem>, String> { Ok(ensure_current(&app)?.items) }
#[tauri::command]
pub fn board_items(app: AppHandle) -> Result<Vec<BoardItem>, String> { board_load(app) }
#[tauri::command]
pub fn board_add_image(app: AppHandle, library_id: Option<String>, data_url: String, x: f64, y: f64, w: f64, h: f64) -> Result<BoardItem, String> {
let mut doc = ensure_current(&app)?;
let item = BoardItem { id: Uuid::new_v4().to_string(), kind: "image".into(), library_id, data_url: Some(data_url), text: None, colors: vec![], x, y, w, h, z: chrono::Utc::now().timestamp_millis() };
doc.items.push(item.clone()); save_current(&app, doc)?; Ok(item)
}
#[tauri::command]
pub fn board_add_note(app: AppHandle, text: String, x: f64, y: f64) -> Result<BoardItem, String> {
let mut doc = ensure_current(&app)?;
let item = BoardItem { id: Uuid::new_v4().to_string(), kind: "note".into(), library_id: None, data_url: None, text: Some(text), colors: vec![], x, y, w: 240.0, h: 160.0, z: chrono::Utc::now().timestamp_millis() };
doc.items.push(item.clone()); save_current(&app, doc)?; Ok(item)
}
#[tauri::command]
pub fn board_add_palette(app: AppHandle, colors: Vec<String>, x: f64, y: f64) -> Result<BoardItem, String> {
if colors.is_empty() { return Err("no colors to add".into()); }
let mut doc = ensure_current(&app)?;
let item = BoardItem { id: Uuid::new_v4().to_string(), kind: "palette".into(), library_id: None, data_url: None, text: None, colors, x, y, w: 260.0, h: 90.0, z: chrono::Utc::now().timestamp_millis() };
doc.items.push(item.clone()); save_current(&app, doc)?; Ok(item)
}
#[tauri::command]
pub async fn board_extract_palette_from_item(app: AppHandle, id: String, count: Option<usize>) -> Result<Vec<String>, String> {
let mut doc = ensure_current(&app)?;
let item = doc.items.iter_mut().find(|i| i.id == id).ok_or("board item not found")?;
let src = item.data_url.clone().ok_or("board item has no image source")?;
let bytes = image_bytes_from_source(&src).await?;
let img = image::load_from_memory(&bytes).map_err(|e| format!("unsupported image: {e}"))?;
let colors = extract_palette(&img, count.unwrap_or(6).clamp(1, 12));
item.colors = colors.clone();
save_current(&app, doc)?; Ok(colors)
}
#[tauri::command]
pub async fn board_add_palette_from_item(app: AppHandle, id: String, x: f64, y: f64) -> Result<BoardItem, String> {
let colors = board_extract_palette_from_item(app.clone(), id, Some(6)).await?;
board_add_palette(app, colors, x, y)
}
#[tauri::command]
pub fn board_update_item(app: AppHandle, item: BoardItem) -> Result<(), String> {
let mut doc = ensure_current(&app)?;
if let Some(slot) = doc.items.iter_mut().find(|i| i.id == item.id) { *slot = item; }
save_current(&app, doc).map(|_| ())
}
#[tauri::command]
pub fn board_delete_item(app: AppHandle, id: String) -> Result<(), String> {
let mut doc = ensure_current(&app)?;
doc.items.retain(|i| i.id != id);
save_current(&app, doc).map(|_| ())
}
async fn image_bytes_from_source(src: &str) -> Result<Vec<u8>, String> {
if let Some((_, encoded)) = src.split_once(",") {
if src.starts_with("data:image/") { return general_purpose::STANDARD.decode(encoded).map_err(|e| e.to_string()); }
}
if src.starts_with("http://") || src.starts_with("https://") {
return Ok(reqwest::get(src).await.map_err(|e| e.to_string())?.bytes().await.map_err(|e| e.to_string())?.to_vec());
}
Err("unsupported image source".into())
}
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()
}