atproto blogging
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() {}