at main 357 lines 13 kB view raw
1//! LocalStorage persistence for the editor. 2//! 3//! Stores both human-readable content (for debugging) and the full CRDT 4//! snapshot (for undo history preservation across sessions). 5//! 6//! ## Storage key strategy (localStorage) 7//! 8//! - New entries: `"new:{tid}"` where tid is a timestamp-based ID 9//! - Editing existing: `"{at-uri}"` the full AT-URI of the entry 10//! 11//! ## PDS canonical format 12//! 13//! When syncing to PDS via DraftRef, keys are transformed to canonical 14//! format: `"{did}:{rkey}"` for discoverability and topic derivation. 15//! This transformation happens in sync.rs `build_doc_ref()`. 16 17#[cfg(all(target_family = "wasm", target_os = "unknown"))] 18use base64::{Engine, engine::general_purpose::STANDARD as BASE64}; 19use dioxus::prelude::*; 20#[cfg(all(target_family = "wasm", target_os = "unknown"))] 21use gloo_storage::{LocalStorage, Storage}; 22#[allow(unused_imports)] 23use jacquard::IntoStatic; 24#[allow(unused_imports)] 25use jacquard::smol_str::{SmolStr, ToSmolStr}; 26#[allow(unused_imports)] 27use jacquard::types::string::{AtUri, Cid}; 28use loro::cursor::Cursor; 29use serde::{Deserialize, Serialize}; 30use weaver_api::com_atproto::repo::strong_ref::StrongRef; 31 32use super::document::SignalEditorDocument; 33 34/// Prefix for all draft storage keys. 35pub const DRAFT_KEY_PREFIX: &str = "weaver_draft:"; 36 37/// Editor snapshot for persistence. 38/// 39/// Stores both human-readable content and CRDT snapshot for best of both worlds: 40/// - `content`: Human-readable text for debugging 41/// - `title`: Entry title for debugging/display in drafts list 42/// - `snapshot`: Base64-encoded CRDT state for document history (includes all embeds) 43/// - `cursor`: Loro Cursor (serialized as JSON) for stable cursor position 44/// - `cursor_offset`: Fallback cursor position if Loro cursor can't be restored 45/// - `editing_uri`: AT-URI if editing an existing entry 46/// 47/// Note: Undo/redo is session-only (UndoManager state is ephemeral). 48/// For cross-session "undo", use time travel via `doc.checkout(frontiers)`. 49#[derive(Serialize, Deserialize, Clone, Debug)] 50pub struct EditorSnapshot { 51 /// Human-readable document content (for debugging/fallback) 52 pub content: String, 53 54 /// Entry title (for debugging/display in drafts list) 55 #[serde(default)] 56 pub title: SmolStr, 57 58 /// Base64-encoded CRDT snapshot (contains ALL fields including embeds) 59 #[serde(default, skip_serializing_if = "Option::is_none")] 60 pub snapshot: Option<String>, 61 62 /// Loro Cursor for stable cursor position tracking 63 #[serde(default, skip_serializing_if = "Option::is_none")] 64 pub cursor: Option<Cursor>, 65 66 /// Fallback cursor offset (used if Loro cursor can't be restored) 67 #[serde(default)] 68 pub cursor_offset: usize, 69 70 /// AT-URI if editing an existing entry (None for new entries) 71 #[serde(default, skip_serializing_if = "Option::is_none")] 72 pub editing_uri: Option<SmolStr>, 73 74 /// CID of the entry if editing an existing entry 75 #[serde(default, skip_serializing_if = "Option::is_none")] 76 pub editing_cid: Option<SmolStr>, 77 78 /// AT-URI of the notebook this draft belongs to (for re-publishing) 79 #[serde(default, skip_serializing_if = "Option::is_none")] 80 pub notebook_uri: Option<SmolStr>, 81} 82 83/// Build the full storage key from a draft key. 84#[allow(dead_code)] 85fn storage_key(key: &str) -> String { 86 format!("{}{}", DRAFT_KEY_PREFIX, key) 87} 88 89/// Save editor state to LocalStorage (WASM only). 90#[cfg(all(target_family = "wasm", target_os = "unknown"))] 91pub fn save_to_storage( 92 doc: &SignalEditorDocument, 93 key: &str, 94) -> Result<(), gloo_storage::errors::StorageError> { 95 let export_start = crate::perf::now(); 96 let snapshot_bytes = doc.export_snapshot(); 97 let export_ms = crate::perf::now() - export_start; 98 99 let encode_start = crate::perf::now(); 100 let snapshot_b64 = if snapshot_bytes.is_empty() { 101 None 102 } else { 103 Some(BASE64.encode(&snapshot_bytes)) 104 }; 105 let encode_ms = crate::perf::now() - encode_start; 106 107 let snapshot = EditorSnapshot { 108 content: doc.content(), 109 title: doc.title().into(), 110 snapshot: snapshot_b64, 111 cursor: doc.loro_cursor(), 112 cursor_offset: doc.cursor.read().offset, 113 editing_uri: doc.entry_ref().map(|r| r.uri.to_smolstr()), 114 editing_cid: doc.entry_ref().map(|r| r.cid.to_smolstr()), 115 notebook_uri: doc.notebook_uri(), 116 }; 117 118 let write_start = crate::perf::now(); 119 let result = LocalStorage::set(storage_key(key), &snapshot); 120 let write_ms = crate::perf::now() - write_start; 121 122 tracing::debug!( 123 export_ms, 124 encode_ms, 125 write_ms, 126 bytes = snapshot_bytes.len(), 127 "save_to_storage timing" 128 ); 129 130 result 131} 132 133/// Load editor state from LocalStorage (WASM only). 134/// 135/// Returns an SignalEditorDocument restored from CRDT snapshot if available, 136/// otherwise falls back to just the text content. 137#[cfg(all(target_family = "wasm", target_os = "unknown"))] 138pub fn load_from_storage(key: &str) -> Option<SignalEditorDocument> { 139 let snapshot: EditorSnapshot = LocalStorage::get(storage_key(key)).ok()?; 140 141 // Parse entry_ref from the snapshot (requires both URI and CID) 142 let entry_ref = snapshot 143 .editing_uri 144 .as_ref() 145 .zip(snapshot.editing_cid.as_ref()) 146 .and_then(|(uri_str, cid_str)| { 147 let uri = AtUri::new(uri_str).ok()?.into_static(); 148 let cid = Cid::new(cid_str.as_bytes()).ok()?.into_static(); 149 Some(StrongRef::new().uri(uri).cid(cid).build()) 150 }); 151 152 // Try to restore from CRDT snapshot first 153 if let Some(ref snapshot_b64) = snapshot.snapshot { 154 if let Ok(snapshot_bytes) = BASE64.decode(snapshot_b64) { 155 let mut doc = SignalEditorDocument::from_snapshot( 156 &snapshot_bytes, 157 snapshot.cursor.clone(), 158 snapshot.cursor_offset, 159 ); 160 // Verify the content matches (sanity check) 161 if doc.content() == snapshot.content { 162 doc.set_entry_ref(entry_ref.clone()); 163 if let Some(notebook_uri) = snapshot.notebook_uri { 164 doc.set_notebook_uri(Some(notebook_uri)); 165 } 166 return Some(doc); 167 } 168 tracing::warn!("Snapshot content mismatch, falling back to text content"); 169 } 170 } 171 172 // Fallback: create new doc from text content 173 let mut doc = SignalEditorDocument::new(snapshot.content); 174 doc.cursor.write().offset = snapshot.cursor_offset.min(doc.len_chars()); 175 doc.sync_loro_cursor(); 176 doc.set_entry_ref(entry_ref); 177 if let Some(notebook_uri) = snapshot.notebook_uri { 178 doc.set_notebook_uri(Some(notebook_uri)); 179 } 180 Some(doc) 181} 182 183/// Data loaded from localStorage snapshot. 184pub struct LocalSnapshotData { 185 /// The raw CRDT snapshot bytes 186 pub snapshot: Vec<u8>, 187 /// Entry StrongRef if editing an existing entry 188 pub entry_ref: Option<StrongRef<'static>>, 189 /// Notebook URI for re-publishing 190 pub notebook_uri: Option<SmolStr>, 191} 192 193/// Load snapshot data from LocalStorage (WASM only). 194/// 195/// Unlike `load_from_storage`, this doesn't create an SignalEditorDocument and is safe 196/// to call outside of reactive context. Use with `load_and_merge_document`. 197#[cfg(all(target_family = "wasm", target_os = "unknown"))] 198pub fn load_snapshot_from_storage(key: &str) -> Option<LocalSnapshotData> { 199 let snapshot: EditorSnapshot = LocalStorage::get(storage_key(key)).ok()?; 200 201 // Try to get CRDT snapshot bytes 202 let snapshot_bytes = snapshot 203 .snapshot 204 .as_ref() 205 .and_then(|b64| BASE64.decode(b64).ok())?; 206 207 // Try to reconstruct entry_ref from stored URI + CID 208 let entry_ref = snapshot 209 .editing_uri 210 .as_ref() 211 .zip(snapshot.editing_cid.as_ref()) 212 .and_then(|(uri_str, cid_str)| { 213 let uri = AtUri::new(uri_str).ok()?.into_static(); 214 let cid = Cid::new(cid_str.as_bytes()).ok()?.into_static(); 215 Some(StrongRef::new().uri(uri).cid(cid).build()) 216 }); 217 218 Some(LocalSnapshotData { 219 snapshot: snapshot_bytes, 220 entry_ref, 221 notebook_uri: snapshot.notebook_uri, 222 }) 223} 224 225/// Load snapshot data from LocalStorage (non-WASM stub). 226#[cfg(not(all(target_family = "wasm", target_os = "unknown")))] 227pub fn load_snapshot_from_storage(_key: &str) -> Option<LocalSnapshotData> { 228 None 229} 230 231/// Delete a draft from LocalStorage (WASM only). 232#[cfg(all(target_family = "wasm", target_os = "unknown"))] 233pub fn delete_draft(key: &str) { 234 LocalStorage::delete(storage_key(key)); 235} 236 237/// List all draft keys from LocalStorage (WASM only). 238/// 239/// Returns a list of (key, title, editing_uri) tuples for all saved drafts. 240#[cfg(all(target_family = "wasm", target_os = "unknown"))] 241pub fn list_drafts() -> Vec<(String, String, Option<String>)> { 242 let mut drafts = Vec::new(); 243 244 // gloo_storage doesn't have a direct way to iterate keys, 245 // so we use web_sys directly 246 if let Some(storage) = web_sys::window() 247 .and_then(|w| w.local_storage().ok()) 248 .flatten() 249 { 250 let len = storage.length().unwrap_or(0); 251 for i in 0..len { 252 if let Ok(Some(key)) = storage.key(i) { 253 if key.starts_with(DRAFT_KEY_PREFIX) { 254 // Try to load just the metadata 255 if let Ok(snapshot) = LocalStorage::get::<EditorSnapshot>(&key) { 256 let draft_key = key.strip_prefix(DRAFT_KEY_PREFIX).unwrap_or(&key); 257 drafts.push(( 258 draft_key.to_string(), 259 snapshot.title.to_string(), 260 snapshot.editing_uri.map(|s| s.to_string()), 261 )); 262 } 263 } 264 } 265 } 266 } 267 268 drafts 269} 270 271/// Delete a draft stub record from PDS. 272/// 273/// This deletes the sh.weaver.edit.draft record, making the draft 274/// invisible in listDrafts. Edit history (edit.root, edit.diff) is 275/// preserved for potential recovery. 276#[cfg(all(target_family = "wasm", target_os = "unknown"))] 277pub async fn delete_draft_from_pds( 278 fetcher: &crate::fetch::Fetcher, 279 draft_key: &str, 280) -> Result<(), weaver_common::WeaverError> { 281 use jacquard::client::AgentSessionExt; 282 use jacquard::types::recordkey::RecordKey; 283 use weaver_api::sh_weaver::edit::draft::Draft; 284 285 // Only delete if authenticated 286 if fetcher.current_did().await.is_none() { 287 tracing::debug!("Not authenticated, skipping PDS draft deletion"); 288 return Ok(()); 289 } 290 291 // Extract rkey from draft_key. 292 let rkey_str = if let Some(tid) = draft_key.strip_prefix("new:") { 293 tid.to_string() 294 } else if draft_key.starts_with("at://") { 295 draft_key.split('/').last().unwrap_or(draft_key).to_string() 296 } else { 297 draft_key.to_string() 298 }; 299 300 let rkey = RecordKey::any(&rkey_str) 301 .map_err(|e| weaver_common::WeaverError::InvalidNotebook(e.to_string()))?; 302 303 // Execute deletion using delete_record helper. 304 let client = fetcher.get_client(); 305 match client.delete_record::<Draft>(rkey).await { 306 Ok(_) => { 307 tracing::info!("Deleted draft stub from PDS: {}", draft_key); 308 Ok(()) 309 } 310 Err(e) => { 311 // Log but don't fail - draft may not exist on PDS. 312 tracing::warn!("Failed to delete draft from PDS (may not exist): {}", e); 313 Ok(()) 314 } 315 } 316} 317 318/// Non-WASM stub for delete_draft_from_pds 319#[cfg(not(all(target_family = "wasm", target_os = "unknown")))] 320pub async fn delete_draft_from_pds( 321 _fetcher: &crate::fetch::Fetcher, 322 _draft_key: &str, 323) -> Result<(), weaver_common::WeaverError> { 324 Ok(()) 325} 326 327/// Clear all editor drafts from LocalStorage (WASM only). 328#[cfg(all(target_family = "wasm", target_os = "unknown"))] 329#[allow(dead_code)] 330pub fn clear_all_drafts() { 331 for (key, _, _) in list_drafts() { 332 delete_draft(&key); 333 } 334} 335 336// Stub implementations for non-WASM targets 337#[cfg(not(all(target_family = "wasm", target_os = "unknown")))] 338pub fn save_to_storage(_doc: &SignalEditorDocument, _key: &str) -> Result<(), String> { 339 Ok(()) 340} 341 342#[cfg(not(all(target_family = "wasm", target_os = "unknown")))] 343pub fn load_from_storage(_key: &str) -> Option<SignalEditorDocument> { 344 None 345} 346 347#[cfg(not(all(target_family = "wasm", target_os = "unknown")))] 348pub fn delete_draft(_key: &str) {} 349 350#[cfg(not(all(target_family = "wasm", target_os = "unknown")))] 351pub fn list_drafts() -> Vec<(String, String, Option<String>)> { 352 Vec::new() 353} 354 355#[cfg(not(all(target_family = "wasm", target_os = "unknown")))] 356#[allow(dead_code)] 357pub fn clear_all_drafts() {}