musealpha / src-tauri /src /refs_format.rs
asdf98's picture
Upload 112 files
3d7d9b5 verified
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())
}