use base64::{engine::general_purpose, Engine as _}; use serde::{Deserialize, Serialize}; use sha2::{Sha256, Digest}; use std::io::{Read, Write, Cursor}; use zip::{ZipWriter, ZipArchive, CompressionMethod}; use zip::write::SimpleFileOptions; const MIMETYPE: &str = "application/x-refs-board"; const FORMAT_VERSION: u32 = 1; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct RefsManifest { pub version: u32, pub app_version: String, pub title: String, pub created_at: i64, pub zoom: f64, pub pan_x: f64, pub pan_y: f64, pub images: Vec, pub text_notes: Vec, pub annotations: Vec, pub palettes: Vec, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct RefsImage { pub id: String, pub file: String, pub source_url: Option, pub x: f64, pub y: f64, pub width: f64, pub height: f64, pub aspect_ratio: f64, pub is_desaturated: Option, pub is_flipped_h: Option, pub group_id: Option, } #[tauri::command] pub fn refs_export(state_json: String) -> Result, String> { let parsed: serde_json::Value = serde_json::from_str(&state_json).map_err(|e| e.to_string())?; let mut zip = ZipWriter::new(Cursor::new(Vec::new())); let stored = SimpleFileOptions::default().compression_method(CompressionMethod::Stored); let deflated = SimpleFileOptions::default().compression_method(CompressionMethod::Deflated); zip.start_file("mimetype", stored).map_err(|e| e.to_string())?; zip.write_all(MIMETYPE.as_bytes()).map_err(|e| e.to_string())?; let mut manifest_images: Vec = Vec::new(); let mut written_hashes: std::collections::HashSet = std::collections::HashSet::new(); if let Some(images) = parsed.get("images").and_then(|v| v.as_array()) { for img in images { let url = img.get("url").and_then(|v| v.as_str()).unwrap_or(""); let id = img.get("id").and_then(|v| v.as_str()).unwrap_or("").to_string(); let (bytes, ext) = decode_image_url(url)?; let hash = hex_hash(&bytes); let filename = format!("{}.{}", &hash[..12], ext); let archive_path = format!("images/{}", filename); if !written_hashes.contains(&hash) { zip.start_file(&archive_path, stored).map_err(|e| e.to_string())?; zip.write_all(&bytes).map_err(|e| e.to_string())?; written_hashes.insert(hash); } manifest_images.push(RefsImage { id, file: archive_path, source_url: img.get("sourceUrl").and_then(|v| v.as_str()).map(|s| s.to_string()), x: img.get("x").and_then(|v| v.as_f64()).unwrap_or(0.0), y: img.get("y").and_then(|v| v.as_f64()).unwrap_or(0.0), width: img.get("width").and_then(|v| v.as_f64()).unwrap_or(300.0), height: img.get("height").and_then(|v| v.as_f64()).unwrap_or(200.0), aspect_ratio: img.get("aspectRatio").and_then(|v| v.as_f64()).unwrap_or(1.5), is_desaturated: img.get("isDesaturated").and_then(|v| v.as_bool()), is_flipped_h: img.get("isFlippedH").and_then(|v| v.as_bool()), group_id: img.get("groupId").and_then(|v| v.as_str()).map(|s| s.to_string()), }); } } let manifest = RefsManifest { version: FORMAT_VERSION, app_version: "1.0.0-alpha".to_string(), title: parsed.get("title").and_then(|v| v.as_str()).unwrap_or("Untitled Board").to_string(), created_at: chrono::Utc::now().timestamp(), zoom: parsed.get("zoom").and_then(|v| v.as_f64()).unwrap_or(1.0), pan_x: parsed.get("pan").and_then(|v| v.get("x")).and_then(|v| v.as_f64()).unwrap_or(0.0), pan_y: parsed.get("pan").and_then(|v| v.get("y")).and_then(|v| v.as_f64()).unwrap_or(0.0), images: manifest_images, text_notes: parsed.get("textNotes").and_then(|v| v.as_array()).cloned().unwrap_or_default(), annotations: parsed.get("annotations").and_then(|v| v.as_array()).cloned().unwrap_or_default(), palettes: parsed.get("palettes").and_then(|v| v.as_array()).cloned().unwrap_or_default(), }; let manifest_json = serde_json::to_vec_pretty(&manifest).map_err(|e| e.to_string())?; zip.start_file("manifest.json", deflated).map_err(|e| e.to_string())?; zip.write_all(&manifest_json).map_err(|e| e.to_string())?; let cursor = zip.finish().map_err(|e| e.to_string())?; Ok(cursor.into_inner()) } #[tauri::command] pub fn refs_import(data: Vec) -> Result { let mut archive = ZipArchive::new(Cursor::new(&data[..])).map_err(|e| format!("Not a valid .refs file: {e}"))?; { let mut mt = archive.by_name("mimetype").map_err(|_| "Missing mimetype entry — not a .refs file")?; let mut s = String::new(); mt.read_to_string(&mut s).map_err(|e| e.to_string())?; if s.trim() != MIMETYPE { return Err(format!("Invalid mimetype: expected '{MIMETYPE}', got '{s}'")); } } let manifest: RefsManifest = { let mut mf = archive.by_name("manifest.json").map_err(|_| "Missing manifest.json")?; let mut buf = Vec::new(); mf.read_to_end(&mut buf).map_err(|e| e.to_string())?; serde_json::from_slice(&buf).map_err(|e| format!("Invalid manifest: {e}"))? }; let mut frontend_images: Vec = Vec::new(); for img in &manifest.images { let data_url = { let mut f = archive.by_name(&img.file).map_err(|e| format!("Image not found: {} — {e}", img.file))?; let mut buf = Vec::new(); f.read_to_end(&mut buf).map_err(|e| e.to_string())?; let ext = img.file.rsplit('.').next().unwrap_or("png"); let mime = match ext { "jpg" | "jpeg" => "image/jpeg", "webp" => "image/webp", "gif" => "image/gif", _ => "image/png" }; format!("data:{};base64,{}", mime, general_purpose::STANDARD.encode(&buf)) }; let mut entry = serde_json::json!({ "id": img.id, "url": data_url, "x": img.x, "y": img.y, "width": img.width, "height": img.height, "aspectRatio": img.aspect_ratio }); if let Some(src) = &img.source_url { entry["sourceUrl"] = serde_json::json!(src); } if let Some(v) = img.is_desaturated { entry["isDesaturated"] = serde_json::json!(v); } if let Some(v) = img.is_flipped_h { entry["isFlippedH"] = serde_json::json!(v); } if let Some(g) = &img.group_id { entry["groupId"] = serde_json::json!(g); } frontend_images.push(entry); } let state = serde_json::json!({ "title": manifest.title, "zoom": manifest.zoom, "pan": { "x": manifest.pan_x, "y": manifest.pan_y }, "images": frontend_images, "textNotes": manifest.text_notes, "annotations": manifest.annotations, "palettes": manifest.palettes, }); Ok(state.to_string()) } fn decode_image_url(url: &str) -> Result<(Vec, &str), String> { if let Some(rest) = url.strip_prefix("data:") { let (header, encoded) = rest.split_once(',').ok_or("Invalid data URL: no comma")?; let ext = if header.contains("jpeg") || header.contains("jpg") { "jpg" } else if header.contains("webp") { "webp" } else if header.contains("gif") { "gif" } else { "png" }; let bytes = general_purpose::STANDARD.decode(encoded).map_err(|e| format!("Base64 decode failed: {e}"))?; Ok((bytes, ext)) } else { Err(format!("Cannot embed external URL in .refs file: {url}")) } } fn hex_hash(data: &[u8]) -> String { let mut hasher = Sha256::new(); hasher.update(data); format!("{:x}", hasher.finalize()) }