| 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<RefsImage>,
|
| pub text_notes: Vec<serde_json::Value>,
|
| pub annotations: Vec<serde_json::Value>,
|
| pub palettes: Vec<serde_json::Value>,
|
| }
|
|
|
| #[derive(Debug, Clone, Serialize, Deserialize)]
|
| pub struct RefsImage {
|
| pub id: String,
|
| pub file: String,
|
| pub source_url: Option<String>,
|
| pub x: f64,
|
| pub y: f64,
|
| pub width: f64,
|
| pub height: f64,
|
| pub aspect_ratio: f64,
|
| pub is_desaturated: Option<bool>,
|
| pub is_flipped_h: Option<bool>,
|
| pub group_id: Option<String>,
|
| }
|
|
|
| #[tauri::command]
|
| pub fn refs_export(state_json: String) -> Result<Vec<u8>, 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<RefsImage> = Vec::new();
|
| let mut written_hashes: std::collections::HashSet<String> = 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<u8>) -> Result<String, String> {
|
| 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<serde_json::Value> = 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<u8>, &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())
|
| }
|
|
|