cleanup

Orual ef7507ec 19fc059b

+121 -417
+21 -13
crates/weaver-app/src/blobcache.rs
··· 58 pds_url: jacquard::url::Url, 59 cid: &Cid<'_>, 60 ) -> Result<Bytes> { 61 - if let Ok(blob_stream) = self 62 .client 63 - .xrpc(pds_url) 64 .send( 65 &GetBlob::new() 66 .cid(cid.clone()) ··· 69 ) 70 .await 71 { 72 - Ok(blob_stream.buffer().clone()) 73 - } else { 74 - // Fallback to Bluesky CDN (works for blobs stored on bsky PDSes) 75 - let bytes = reqwest::get(format!( 76 - "https://cdn.bsky.app/img/feed_fullsize/plain/{}/{}@jpeg", 77 - did, cid 78 - )) 79 - .await? 80 - .bytes() 81 - .await?; 82 - Ok(bytes) 83 } 84 } 85
··· 58 pds_url: jacquard::url::Url, 59 cid: &Cid<'_>, 60 ) -> Result<Bytes> { 61 + match self 62 .client 63 + .xrpc(pds_url.clone()) 64 .send( 65 &GetBlob::new() 66 .cid(cid.clone()) ··· 69 ) 70 .await 71 { 72 + Ok(blob_stream) => Ok(blob_stream.buffer().clone()), 73 + Err(e) => { 74 + tracing::warn!( 75 + did = %did, 76 + cid = %cid, 77 + pds = %pds_url, 78 + error = %e, 79 + "PDS blob fetch failed, falling back to Bluesky CDN" 80 + ); 81 + // Fallback to Bluesky CDN (works for blobs stored on bsky PDSes) 82 + let bytes = reqwest::get(format!( 83 + "https://cdn.bsky.app/img/feed_fullsize/plain/{}/{}@jpeg", 84 + did, cid 85 + )) 86 + .await? 87 + .bytes() 88 + .await?; 89 + Ok(bytes) 90 + } 91 } 92 } 93
-9
crates/weaver-app/src/components/editor/actions.rs
··· 1266 /// This handles keyboard shortcuts only. Text input and deletion 1267 /// are handled by beforeinput. Navigation (arrows, etc.) is passed 1268 /// through to the browser. 1269 - /// 1270 - /// # Arguments 1271 - /// * `doc` - The editor document 1272 - /// * `config` - Keybinding configuration 1273 - /// * `combo` - The key combination from the keyboard event 1274 - /// * `range` - Current cursor position / selection range 1275 - /// 1276 - /// # Returns 1277 - /// Whether the event was handled. 1278 pub fn handle_keydown_with_bindings( 1279 doc: &mut EditorDocument, 1280 config: &KeybindingConfig,
··· 1266 /// This handles keyboard shortcuts only. Text input and deletion 1267 /// are handled by beforeinput. Navigation (arrows, etc.) is passed 1268 /// through to the browser. 1269 pub fn handle_keydown_with_bindings( 1270 doc: &mut EditorDocument, 1271 config: &KeybindingConfig,
-12
crates/weaver-app/src/components/editor/cursor.rs
··· 13 use wasm_bindgen::JsCast; 14 15 /// Restore cursor position in the DOM after re-render. 16 - /// 17 - /// # Arguments 18 - /// - `char_offset`: Cursor position as char offset in document 19 - /// - `offset_map`: Mappings from source to DOM positions 20 - /// - `editor_id`: DOM ID of the contenteditable element 21 - /// - `snap_direction`: Optional direction hint for snapping from invisible content 22 - /// 23 - /// # Algorithm 24 - /// 1. Find offset mapping containing char_offset 25 - /// 2. Get DOM node by mapping.node_id 26 - /// 3. Walk text nodes to find UTF-16 position 27 - /// 4. Set cursor with Selection API 28 #[cfg(all(target_family = "wasm", target_os = "unknown"))] 29 pub fn restore_cursor_position( 30 char_offset: usize,
··· 13 use wasm_bindgen::JsCast; 14 15 /// Restore cursor position in the DOM after re-render. 16 #[cfg(all(target_family = "wasm", target_os = "unknown"))] 17 pub fn restore_cursor_position( 18 char_offset: usize,
+2 -10
crates/weaver-app/src/components/editor/document.rs
··· 319 /// 320 /// MUST be called from within a reactive context (e.g., `use_hook`) to 321 /// properly initialize Dioxus Signals. 322 - /// 323 - /// # Arguments 324 - /// * `entry` - The entry record fetched from PDS 325 - /// * `entry_ref` - StrongRef to the entry (URI + CID) 326 pub fn from_entry(entry: &Entry<'_>, entry_ref: StrongRef<'static>) -> Self { 327 let mut doc = Self::new(entry.content.to_string()); 328 ··· 691 Ok(()) 692 } 693 694 - /// Undo the last operation. 695 - /// Returns true if an undo was performed. 696 - /// Automatically updates cursor position from the Loro cursor. 697 pub fn undo(&mut self) -> LoroResult<bool> { 698 // Sync Loro cursor to current position BEFORE undo 699 // so it tracks through the undo operation ··· 709 Ok(result) 710 } 711 712 - /// Redo the last undone operation. 713 - /// Returns true if a redo was performed. 714 - /// Automatically updates cursor position from the Loro cursor. 715 pub fn redo(&mut self) -> LoroResult<bool> { 716 // Sync Loro cursor to current position BEFORE redo 717 self.sync_loro_cursor();
··· 319 /// 320 /// MUST be called from within a reactive context (e.g., `use_hook`) to 321 /// properly initialize Dioxus Signals. 322 pub fn from_entry(entry: &Entry<'_>, entry_ref: StrongRef<'static>) -> Self { 323 let mut doc = Self::new(entry.content.to_string()); 324 ··· 687 Ok(()) 688 } 689 690 + /// Undo the last operation. Automatically updates cursor position. 691 pub fn undo(&mut self) -> LoroResult<bool> { 692 // Sync Loro cursor to current position BEFORE undo 693 // so it tracks through the undo operation ··· 703 Ok(result) 704 } 705 706 + /// Redo the last undone operation. Automatically updates cursor position. 707 pub fn redo(&mut self) -> LoroResult<bool> { 708 // Sync Loro cursor to current position BEFORE redo 709 self.sync_loro_cursor();
-13
crates/weaver-app/src/components/editor/dom_sync.rs
··· 71 let anchor_offset = selection.anchor_offset() as usize; 72 let focus_offset = selection.focus_offset() as usize; 73 74 - // Convert both DOM positions to rope offsets using cached paragraphs 75 let anchor_rope = dom_position_to_text_offset( 76 &dom_document, 77 &editor_element, ··· 93 (Some(anchor), Some(focus)) => { 94 doc.cursor.write().offset = focus; 95 if anchor != focus { 96 - // There's an actual selection 97 doc.selection.set(Some(Selection { 98 anchor, 99 head: focus, 100 })); 101 } else { 102 - // Collapsed selection (just cursor) 103 doc.selection.set(None); 104 } 105 } ··· 158 159 let node_id = node_id?; 160 161 - // Get the container element 162 let container = dom_document.get_element_by_id(&node_id).or_else(|| { 163 let selector = format!("[data-node-id='{}']", node_id); 164 dom_document.query_selector(&selector).ok().flatten() ··· 180 } 181 } 182 183 - // Look up in offset maps 184 for para in paragraphs { 185 for mapping in &para.offset_map { 186 if mapping.node_id == node_id { ··· 231 _editor_id: &str, 232 _paragraphs: &[ParagraphRender], 233 ) { 234 - // No-op on non-wasm 235 } 236 237 #[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))] ··· 241 _paragraphs: &[ParagraphRender], 242 _direction_hint: Option<SnapDirection>, 243 ) { 244 - // No-op on non-wasm 245 } 246 247 /// Update paragraph DOM elements incrementally. ··· 284 285 let mut cursor_para_updated = false; 286 287 - // Update or create paragraphs 288 for (idx, new_para) in new_paragraphs.iter().enumerate() { 289 let para_id = format!("para-{}", idx); 290 291 if let Some(old_para) = old_paragraphs.get(idx) { 292 - // Paragraph exists - check if changed 293 if new_para.source_hash != old_para.source_hash { 294 // Changed - clear and update innerHTML 295 // We clear first to ensure any browser-added content (from IME composition, ··· 299 elem.set_inner_html(&new_para.html); 300 } 301 302 - // Track if we updated the cursor's paragraph 303 if Some(idx) == cursor_para_idx { 304 cursor_para_updated = true; 305 } 306 } 307 - // Unchanged - do nothing, browser preserves cursor 308 } else { 309 - // New paragraph - create div 310 if let Ok(div) = document.create_element("div") { 311 div.set_id(&para_id); 312 div.set_inner_html(&new_para.html); 313 let _ = editor.append_child(&div); 314 } 315 316 - // Track if we created the cursor's paragraph 317 if Some(idx) == cursor_para_idx { 318 cursor_para_updated = true; 319 }
··· 71 let anchor_offset = selection.anchor_offset() as usize; 72 let focus_offset = selection.focus_offset() as usize; 73 74 let anchor_rope = dom_position_to_text_offset( 75 &dom_document, 76 &editor_element, ··· 92 (Some(anchor), Some(focus)) => { 93 doc.cursor.write().offset = focus; 94 if anchor != focus { 95 doc.selection.set(Some(Selection { 96 anchor, 97 head: focus, 98 })); 99 } else { 100 doc.selection.set(None); 101 } 102 } ··· 155 156 let node_id = node_id?; 157 158 let container = dom_document.get_element_by_id(&node_id).or_else(|| { 159 let selector = format!("[data-node-id='{}']", node_id); 160 dom_document.query_selector(&selector).ok().flatten() ··· 176 } 177 } 178 179 for para in paragraphs { 180 for mapping in &para.offset_map { 181 if mapping.node_id == node_id { ··· 226 _editor_id: &str, 227 _paragraphs: &[ParagraphRender], 228 ) { 229 } 230 231 #[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))] ··· 235 _paragraphs: &[ParagraphRender], 236 _direction_hint: Option<SnapDirection>, 237 ) { 238 } 239 240 /// Update paragraph DOM elements incrementally. ··· 277 278 let mut cursor_para_updated = false; 279 280 for (idx, new_para) in new_paragraphs.iter().enumerate() { 281 let para_id = format!("para-{}", idx); 282 283 if let Some(old_para) = old_paragraphs.get(idx) { 284 if new_para.source_hash != old_para.source_hash { 285 // Changed - clear and update innerHTML 286 // We clear first to ensure any browser-added content (from IME composition, ··· 290 elem.set_inner_html(&new_para.html); 291 } 292 293 if Some(idx) == cursor_para_idx { 294 cursor_para_updated = true; 295 } 296 } 297 } else { 298 if let Ok(div) = document.create_element("div") { 299 div.set_id(&para_id); 300 div.set_inner_html(&new_para.html); 301 let _ = editor.append_child(&div); 302 } 303 304 if Some(idx) == cursor_para_idx { 305 cursor_para_updated = true; 306 }
-8
crates/weaver-app/src/components/editor/offset_map.rs
··· 181 /// If the position is already valid, returns it directly. Otherwise, 182 /// searches in the preferred direction first, falling back to the other 183 /// direction if needed. 184 - /// 185 - /// # Arguments 186 - /// - `offset_map`: The offset mappings for the paragraph 187 - /// - `char_offset`: The target char offset 188 - /// - `preferred_direction`: Which direction to search first when snapping 189 - /// 190 - /// # Returns 191 - /// The snapped position, or None if no valid position exists. 192 pub fn find_nearest_valid_position( 193 offset_map: &[OffsetMapping], 194 char_offset: usize,
··· 181 /// If the position is already valid, returns it directly. Otherwise, 182 /// searches in the preferred direction first, falling back to the other 183 /// direction if needed. 184 pub fn find_nearest_valid_position( 185 offset_map: &[OffsetMapping], 186 char_offset: usize,
+1 -24
crates/weaver-app/src/components/editor/publish.rs
··· 62 } 63 64 /// Result of fetching an entry for editing. 65 - /// Contains the entry data and URI, but NOT an EditorDocument. 66 - /// The document must be created in a reactive context (use_hook) to properly initialize Signals. 67 #[derive(Clone, PartialEq)] 68 pub struct LoadedEntry { 69 pub entry: Entry<'static>, ··· 71 } 72 73 /// Fetch an existing entry from the PDS for editing. 74 - /// 75 - /// Returns the entry data and URI. The caller should create an `EditorDocument` 76 - /// from this data using `EditorDocument::from_entry()` inside a reactive context. 77 - /// 78 - /// # Arguments 79 - /// * `fetcher` - The fetcher for making API calls 80 - /// * `uri` - The AT-URI of the entry to load (e.g., `at://did:plc:xxx/sh.weaver.notebook.entry/rkey`) 81 - /// 82 - /// # Returns 83 - /// The entry and its URI, or an error. 84 pub async fn load_entry_for_editing( 85 fetcher: &Fetcher, 86 uri: &AtUri<'_>, ··· 154 /// - Without notebook but with entry_uri in doc: uses `put_record` to update existing 155 /// - Without notebook and no entry_uri: uses `create_record` for free-floating entry 156 /// 157 - /// Draft image paths (`/image/{did}/draft/{blob_rkey}/{name}`) are rewritten to 158 - /// published paths (`/image/{did}/{entry_rkey}/{name}`) before publishing. 159 - /// 160 /// On successful create, sets `doc.entry_uri` so subsequent publishes update the same record. 161 - /// 162 - /// # Arguments 163 - /// * `fetcher` - The authenticated fetcher/client 164 - /// * `doc` - The editor document containing entry data (mutable to update entry_uri) 165 - /// * `notebook_title` - Optional title of the notebook to publish to 166 - /// * `draft_key` - Storage key for the draft (for cleanup) 167 - /// 168 - /// # Returns 169 - /// The AT-URI of the created/updated entry, or an error. 170 pub async fn publish_entry( 171 fetcher: &Fetcher, 172 doc: &mut EditorDocument,
··· 62 } 63 64 /// Result of fetching an entry for editing. 65 #[derive(Clone, PartialEq)] 66 pub struct LoadedEntry { 67 pub entry: Entry<'static>, ··· 69 } 70 71 /// Fetch an existing entry from the PDS for editing. 72 pub async fn load_entry_for_editing( 73 fetcher: &Fetcher, 74 uri: &AtUri<'_>, ··· 142 /// - Without notebook but with entry_uri in doc: uses `put_record` to update existing 143 /// - Without notebook and no entry_uri: uses `create_record` for free-floating entry 144 /// 145 + /// Draft image paths are rewritten to published paths before publishing. 146 /// On successful create, sets `doc.entry_uri` so subsequent publishes update the same record. 147 pub async fn publish_entry( 148 fetcher: &Fetcher, 149 doc: &mut EditorDocument,
-9
crates/weaver-app/src/components/editor/render.rs
··· 104 /// 105 /// Uses cached paragraph renders when possible, only re-rendering changed paragraphs. 106 /// For "safe" edits (no boundary changes), skips boundary rediscovery entirely. 107 - /// 108 - /// # Arguments 109 - /// - `text`: The document text to render 110 - /// - `cache`: Previous render cache (if any) 111 - /// - `edit`: Information about the most recent edit (if any) 112 - /// - `image_resolver`: Optional resolver for mapping image URLs to data/CDN URLs 113 - /// 114 - /// # Returns 115 - /// Tuple of (rendered paragraphs, updated cache) 116 pub fn render_paragraphs_incremental( 117 text: &LoroText, 118 cache: Option<&RenderCache>,
··· 104 /// 105 /// Uses cached paragraph renders when possible, only re-rendering changed paragraphs. 106 /// For "safe" edits (no boundary changes), skips boundary rediscovery entirely. 107 pub fn render_paragraphs_incremental( 108 text: &LoroText, 109 cache: Option<&RenderCache>,
-13
crates/weaver-app/src/components/editor/storage.rs
··· 71 } 72 73 /// Save editor state to LocalStorage (WASM only). 74 - /// 75 - /// # Arguments 76 - /// * `doc` - The editor document to save 77 - /// * `key` - Storage key (e.g., "new:abc123" for new entries, or AT-URI for existing) 78 #[cfg(all(target_family = "wasm", target_os = "unknown"))] 79 pub fn save_to_storage( 80 doc: &EditorDocument, ··· 103 /// 104 /// Returns an EditorDocument restored from CRDT snapshot if available, 105 /// otherwise falls back to just the text content. 106 - /// 107 - /// # Arguments 108 - /// * `key` - Storage key (e.g., "new:abc123" for new entries, or AT-URI for existing) 109 #[cfg(all(target_family = "wasm", target_os = "unknown"))] 110 pub fn load_from_storage(key: &str) -> Option<EditorDocument> { 111 let snapshot: EditorSnapshot = LocalStorage::get(storage_key(key)).ok()?; ··· 158 /// 159 /// Unlike `load_from_storage`, this doesn't create an EditorDocument and is safe 160 /// to call outside of reactive context. Use with `load_and_merge_document`. 161 - /// 162 - /// # Arguments 163 - /// * `key` - Storage key (e.g., "new:abc123" for new entries, or AT-URI for existing) 164 #[cfg(all(target_family = "wasm", target_os = "unknown"))] 165 pub fn load_snapshot_from_storage(key: &str) -> Option<LocalSnapshotData> { 166 let snapshot: EditorSnapshot = LocalStorage::get(storage_key(key)).ok()?; ··· 195 } 196 197 /// Delete a draft from LocalStorage (WASM only). 198 - /// 199 - /// # Arguments 200 - /// * `key` - Storage key to delete 201 #[cfg(all(target_family = "wasm", target_os = "unknown"))] 202 pub fn delete_draft(key: &str) { 203 LocalStorage::delete(storage_key(key));
··· 71 } 72 73 /// Save editor state to LocalStorage (WASM only). 74 #[cfg(all(target_family = "wasm", target_os = "unknown"))] 75 pub fn save_to_storage( 76 doc: &EditorDocument, ··· 99 /// 100 /// Returns an EditorDocument restored from CRDT snapshot if available, 101 /// otherwise falls back to just the text content. 102 #[cfg(all(target_family = "wasm", target_os = "unknown"))] 103 pub fn load_from_storage(key: &str) -> Option<EditorDocument> { 104 let snapshot: EditorSnapshot = LocalStorage::get(storage_key(key)).ok()?; ··· 151 /// 152 /// Unlike `load_from_storage`, this doesn't create an EditorDocument and is safe 153 /// to call outside of reactive context. Use with `load_and_merge_document`. 154 #[cfg(all(target_family = "wasm", target_os = "unknown"))] 155 pub fn load_snapshot_from_storage(key: &str) -> Option<LocalSnapshotData> { 156 let snapshot: EditorSnapshot = LocalStorage::get(storage_key(key)).ok()?; ··· 185 } 186 187 /// Delete a draft from LocalStorage (WASM only). 188 #[cfg(all(target_family = "wasm", target_os = "unknown"))] 189 pub fn delete_draft(key: &str) { 190 LocalStorage::delete(storage_key(key));
+9 -56
crates/weaver-app/src/components/editor/sync.rs
··· 178 /// 179 /// Uploads the current Loro snapshot as a blob and creates an `sh.weaver.edit.root` 180 /// record referencing the entry (or draft key if unpublished). 181 - /// 182 - /// # Arguments 183 - /// * `fetcher` - The authenticated fetcher 184 - /// * `doc` - The editor document 185 - /// * `draft_key` - The draft key (used for unpublished entries) 186 - /// * `entry_uri` - Optional AT-URI of the published entry 187 - /// * `entry_cid` - Optional CID of the published entry 188 pub async fn create_edit_root( 189 fetcher: &Fetcher, 190 doc: &EditorDocument, ··· 244 } 245 246 /// Create a diff record with updates since the last sync. 247 - /// 248 - /// # Arguments 249 - /// * `fetcher` - The authenticated fetcher 250 - /// * `doc` - The editor document 251 - /// * `root_uri` - URI of the edit root 252 - /// * `root_cid` - CID of the edit root 253 - /// * `prev_diff` - Optional reference to the previous diff 254 - /// * `draft_key` - The draft key (used for doc reference) 255 - /// * `entry_uri` - Optional AT-URI of the published entry 256 - /// * `entry_cid` - Optional CID of the published entry 257 pub async fn create_diff( 258 fetcher: &Fetcher, 259 doc: &EditorDocument, ··· 352 /// 353 /// If no edit root exists, creates one with a full snapshot. 354 /// If a root exists, creates a diff with updates since last sync. 355 - /// 356 /// Updates the document's sync state on success. 357 - /// 358 - /// # Arguments 359 - /// * `fetcher` - The authenticated fetcher 360 - /// * `doc` - The editor document (mutable to update sync state) 361 - /// * `draft_key` - The draft key for this document 362 - /// 363 - /// # Returns 364 - /// The sync result indicating what was created. 365 pub async fn sync_to_pds( 366 fetcher: &Fetcher, 367 doc: &mut EditorDocument, ··· 480 /// 481 /// Finds the edit root via constellation backlinks, fetches all diffs, 482 /// and returns the snapshot + updates needed to reconstruct the document. 483 - /// 484 - /// # Arguments 485 - /// * `fetcher` - The authenticated fetcher 486 - /// * `entry_uri` - The AT-URI of the entry to load edit state for 487 - /// 488 - /// # Returns 489 - /// The edit state if found, or None if no edit root exists for this entry. 490 pub async fn load_edit_state_from_pds( 491 fetcher: &Fetcher, 492 entry_uri: &AtUri<'_>, ··· 500 // Build root URI 501 let root_uri = AtUri::new(&format!( 502 "at://{}/{}/{}", 503 - root_id.did(), 504 ROOT_NSID, 505 - root_id.rkey().as_ref() 506 )) 507 .map_err(|e| WeaverError::InvalidNotebook(format!("Invalid root URI: {}", e)))? 508 .into_static(); ··· 530 // Fetch the root snapshot blob 531 let root_snapshot = fetch_blob( 532 fetcher, 533 - &root_id.did(), 534 root_output.value.snapshot.blob().cid(), 535 ) 536 .await?; ··· 555 > = BTreeMap::new(); 556 557 for diff_id in &diff_ids { 558 - let rkey = diff_id.rkey(); 559 - let rkey_str: &str = rkey.as_ref(); 560 let diff_uri = AtUri::new(&format!( 561 "at://{}/{}/{}", 562 - diff_id.did(), 563 DIFF_NSID, 564 rkey_str 565 )) ··· 600 let diff_bytes = if let Some(ref inline) = diff.inline_diff { 601 inline.clone() 602 } else if let Some(ref snapshot) = diff.snapshot { 603 - fetch_blob(fetcher, &root_id.did(), snapshot.blob().cid()).await? 604 } else { 605 tracing::warn!("Diff has neither inline_diff nor snapshot, skipping"); 606 continue; ··· 622 623 /// Load document state by merging local storage and PDS state. 624 /// 625 - /// This is the main entry point for loading a document with full sync support. 626 - /// It: 627 - /// 1. Loads from localStorage (if available) 628 - /// 2. Loads from PDS (if available) 629 - /// 3. Merges both using Loro's CRDT merge 630 - /// 631 - /// The result is a `LoadedDocState` containing a pre-merged LoroDoc that can be 632 - /// converted to an EditorDocument inside a reactive context using `use_hook`. 633 - /// 634 - /// # Arguments 635 - /// * `fetcher` - The authenticated fetcher 636 - /// * `draft_key` - The localStorage key for this draft 637 - /// * `entry_uri` - Optional AT-URI if editing an existing entry 638 - /// 639 - /// # Returns 640 - /// A `LoadedDocState` with merged state, or None if no state exists anywhere. 641 pub async fn load_and_merge_document( 642 fetcher: &Fetcher, 643 draft_key: &str,
··· 178 /// 179 /// Uploads the current Loro snapshot as a blob and creates an `sh.weaver.edit.root` 180 /// record referencing the entry (or draft key if unpublished). 181 pub async fn create_edit_root( 182 fetcher: &Fetcher, 183 doc: &EditorDocument, ··· 237 } 238 239 /// Create a diff record with updates since the last sync. 240 pub async fn create_diff( 241 fetcher: &Fetcher, 242 doc: &EditorDocument, ··· 335 /// 336 /// If no edit root exists, creates one with a full snapshot. 337 /// If a root exists, creates a diff with updates since last sync. 338 /// Updates the document's sync state on success. 339 pub async fn sync_to_pds( 340 fetcher: &Fetcher, 341 doc: &mut EditorDocument, ··· 454 /// 455 /// Finds the edit root via constellation backlinks, fetches all diffs, 456 /// and returns the snapshot + updates needed to reconstruct the document. 457 pub async fn load_edit_state_from_pds( 458 fetcher: &Fetcher, 459 entry_uri: &AtUri<'_>, ··· 467 // Build root URI 468 let root_uri = AtUri::new(&format!( 469 "at://{}/{}/{}", 470 + root_id.did, 471 ROOT_NSID, 472 + root_id.rkey.as_ref() 473 )) 474 .map_err(|e| WeaverError::InvalidNotebook(format!("Invalid root URI: {}", e)))? 475 .into_static(); ··· 497 // Fetch the root snapshot blob 498 let root_snapshot = fetch_blob( 499 fetcher, 500 + &root_id.did, 501 root_output.value.snapshot.blob().cid(), 502 ) 503 .await?; ··· 522 > = BTreeMap::new(); 523 524 for diff_id in &diff_ids { 525 + let rkey_str: &str = diff_id.rkey.as_ref(); 526 let diff_uri = AtUri::new(&format!( 527 "at://{}/{}/{}", 528 + diff_id.did, 529 DIFF_NSID, 530 rkey_str 531 )) ··· 566 let diff_bytes = if let Some(ref inline) = diff.inline_diff { 567 inline.clone() 568 } else if let Some(ref snapshot) = diff.snapshot { 569 + fetch_blob(fetcher, &root_id.did, snapshot.blob().cid()).await? 570 } else { 571 tracing::warn!("Diff has neither inline_diff nor snapshot, skipping"); 572 continue; ··· 588 589 /// Load document state by merging local storage and PDS state. 590 /// 591 + /// Loads from localStorage and PDS (if available), then merges both using Loro's 592 + /// CRDT merge. The result is a pre-merged LoroDoc that can be converted to an 593 + /// EditorDocument inside a reactive context using `use_hook`. 594 pub async fn load_and_merge_document( 595 fetcher: &Fetcher, 596 draft_key: &str,
-6
crates/weaver-app/src/components/editor/visibility.rs
··· 18 19 impl VisibilityState { 20 /// Calculate visibility based on cursor position and selection. 21 - /// 22 - /// # Arguments 23 - /// - `cursor_offset`: Current cursor position (char offset) 24 - /// - `selection`: Optional selection range 25 - /// - `syntax_spans`: All syntax spans in the document 26 - /// - `paragraphs`: All paragraphs (for block-level visibility lookup) 27 pub fn calculate( 28 cursor_offset: usize, 29 selection: Option<&Selection>,
··· 18 19 impl VisibilityState { 20 /// Calculate visibility based on cursor position and selection. 21 pub fn calculate( 22 cursor_offset: usize, 23 selection: Option<&Selection>,
-25
crates/weaver-app/src/components/editor/writer.rs
··· 176 } 177 178 /// Add a pending image with a data URL for immediate preview. 179 - /// 180 - /// # Arguments 181 - /// * `name` - The image name used in markdown (e.g., "photo.jpg") 182 - /// * `data_url` - The base64 data URL for preview 183 pub fn add_pending(&mut self, name: String, data_url: String) { 184 self.images.insert(name, ResolvedImage::Pending(data_url)); 185 } 186 187 /// Promote a pending image to uploaded (draft) status. 188 - /// 189 - /// # Arguments 190 - /// * `name` - The image name used in markdown 191 - /// * `blob_rkey` - The rkey of the PublishedBlob record 192 - /// * `ident` - The AT identifier (DID or handle) of the blob owner 193 pub fn promote_to_uploaded( 194 &mut self, 195 name: &str, ··· 203 } 204 205 /// Add an already-uploaded draft image. 206 - /// 207 - /// # Arguments 208 - /// * `name` - The name/URL used in markdown (e.g., "photo.jpg") 209 - /// * `blob_rkey` - The rkey of the PublishedBlob record 210 - /// * `ident` - The AT identifier (DID or handle) of the blob owner 211 pub fn add_uploaded( 212 &mut self, 213 name: String, ··· 219 } 220 221 /// Add a published image. 222 - /// 223 - /// # Arguments 224 - /// * `name` - The name/URL used in markdown (e.g., "photo.jpg") 225 - /// * `entry_rkey` - The rkey of the entry record containing this image 226 - /// * `ident` - The AT identifier (DID or handle) of the entry owner 227 pub fn add_published( 228 &mut self, 229 name: String, ··· 240 } 241 242 /// Build a resolver from editor images and user identifier. 243 - /// 244 - /// # Arguments 245 - /// * `images` - Iterator of editor images 246 - /// * `ident` - The AT identifier (DID or handle) of the user 247 - /// * `entry_rkey` - If Some, images are resolved as published (`/image/{ident}/{entry_rkey}/{name}`). 248 - /// If None, images are resolved as drafts using their `published_blob_uri`. 249 /// 250 /// For draft mode (entry_rkey=None), only images with a `published_blob_uri` are included. 251 /// For published mode (entry_rkey=Some), all images are included.
··· 176 } 177 178 /// Add a pending image with a data URL for immediate preview. 179 pub fn add_pending(&mut self, name: String, data_url: String) { 180 self.images.insert(name, ResolvedImage::Pending(data_url)); 181 } 182 183 /// Promote a pending image to uploaded (draft) status. 184 pub fn promote_to_uploaded( 185 &mut self, 186 name: &str, ··· 194 } 195 196 /// Add an already-uploaded draft image. 197 pub fn add_uploaded( 198 &mut self, 199 name: String, ··· 205 } 206 207 /// Add a published image. 208 pub fn add_published( 209 &mut self, 210 name: String, ··· 221 } 222 223 /// Build a resolver from editor images and user identifier. 224 /// 225 /// For draft mode (entry_rkey=None), only images with a `published_blob_uri` are included. 226 /// For published mode (entry_rkey=Some), all images are included.
+17 -69
crates/weaver-app/src/data.rs
··· 101 book_title: ReadSignal<SmolStr>, 102 title: ReadSignal<SmolStr>, 103 ) -> ( 104 - Resource<Option<(serde_json::Value, serde_json::Value)>>, 105 Memo<Option<(BookEntryView<'static>, Entry<'static>)>>, 106 ) { 107 let fetcher = use_context::<crate::fetch::Fetcher>(); ··· 134 } 135 } 136 } 137 - Some(( 138 - serde_json::to_value(entry.0.clone()).unwrap(), 139 - serde_json::to_value(entry.1.clone()).unwrap(), 140 - )) 141 } else { 142 None 143 } 144 } 145 }); 146 - let memo = use_memo(move || { 147 - if let Some(Some((ev, e))) = &*res.read() { 148 - use jacquard::from_json_value; 149 - 150 - let book_entry = from_json_value::<BookEntryView>(ev.clone()).unwrap(); 151 - let entry = from_json_value::<Entry>(e.clone()).unwrap(); 152 - 153 - Some((book_entry, entry)) 154 - } else { 155 - None 156 - } 157 - }); 158 (res, memo) 159 } 160 ··· 496 pub fn use_profile_data( 497 ident: ReadSignal<AtIdentifier<'static>>, 498 ) -> ( 499 - Resource<Option<serde_json::Value>>, 500 Memo<Option<ProfileDataView<'static>>>, 501 ) { 502 let fetcher = use_context::<crate::fetch::Fetcher>(); ··· 507 .fetch_profile(&ident()) 508 .await 509 .ok() 510 - .map(|arc| serde_json::to_value(&*arc).ok()) 511 - .flatten() 512 } 513 }); 514 - let memo = use_memo(move || { 515 - if let Some(Some(value)) = &*res.read() { 516 - jacquard::from_json_value::<ProfileDataView>(value.clone()).ok() 517 - } else { 518 - None 519 - } 520 - }); 521 (res, memo) 522 } 523 ··· 567 pub fn use_notebooks_for_did( 568 ident: ReadSignal<AtIdentifier<'static>>, 569 ) -> ( 570 - Resource<Option<Vec<serde_json::Value>>>, 571 Memo<Option<Vec<(NotebookView<'static>, Vec<StrongRef<'static>>)>>>, 572 ) { 573 let fetcher = use_context::<crate::fetch::Fetcher>(); ··· 581 .map(|notebooks| { 582 notebooks 583 .iter() 584 - .map(|arc| serde_json::to_value(arc.as_ref()).ok()) 585 - .collect::<Option<Vec<_>>>() 586 }) 587 - .flatten() 588 } 589 }); 590 - let memo = use_memo(move || { 591 - if let Some(Some(values)) = &*res.read() { 592 - values 593 - .iter() 594 - .map(|v| { 595 - jacquard::from_json_value::<(NotebookView, Vec<StrongRef>)>(v.clone()).ok() 596 - }) 597 - .collect::<Option<Vec<_>>>() 598 - } else { 599 - None 600 - } 601 - }); 602 (res, memo) 603 } 604 ··· 644 /// Fetches notebooks from UFOS client-side only (no SSR) 645 #[cfg(not(feature = "fullstack-server"))] 646 pub fn use_notebooks_from_ufos() -> ( 647 - Resource<Option<Vec<serde_json::Value>>>, 648 Memo<Option<Vec<(NotebookView<'static>, Vec<StrongRef<'static>>)>>>, 649 ) { 650 let fetcher = use_context::<crate::fetch::Fetcher>(); ··· 658 .map(|notebooks| { 659 notebooks 660 .iter() 661 - .map(|arc| serde_json::to_value(arc.as_ref()).ok()) 662 - .collect::<Option<Vec<_>>>() 663 }) 664 - .flatten() 665 } 666 }); 667 - let memo = use_memo(move || { 668 - if let Some(Some(values)) = &*res.read() { 669 - values 670 - .iter() 671 - .map(|v| { 672 - jacquard::from_json_value::<(NotebookView, Vec<StrongRef>)>(v.clone()).ok() 673 - }) 674 - .collect::<Option<Vec<_>>>() 675 - } else { 676 - None 677 - } 678 - }); 679 (res, memo) 680 } 681 ··· 718 ident: ReadSignal<AtIdentifier<'static>>, 719 book_title: ReadSignal<SmolStr>, 720 ) -> ( 721 - Resource<Option<serde_json::Value>>, 722 Memo<Option<(NotebookView<'static>, Vec<StrongRef<'static>>)>>, 723 ) { 724 let fetcher = use_context::<crate::fetch::Fetcher>(); ··· 730 .await 731 .ok() 732 .flatten() 733 - .map(|arc| serde_json::to_value(arc.as_ref()).ok()) 734 - .flatten() 735 } 736 }); 737 - let memo = use_memo(use_reactive!(|res| { 738 - if let Some(Some(value)) = &*res.read() { 739 - jacquard::from_json_value::<(NotebookView, Vec<StrongRef>)>(value.clone()).ok() 740 - } else { 741 - None 742 - } 743 - })); 744 (res, memo) 745 } 746
··· 101 book_title: ReadSignal<SmolStr>, 102 title: ReadSignal<SmolStr>, 103 ) -> ( 104 + Resource<Option<(BookEntryView<'static>, Entry<'static>)>>, 105 Memo<Option<(BookEntryView<'static>, Entry<'static>)>>, 106 ) { 107 let fetcher = use_context::<crate::fetch::Fetcher>(); ··· 134 } 135 } 136 } 137 + Some(entry) 138 } else { 139 None 140 } 141 } 142 }); 143 + let memo = use_memo(move || res.read().clone().flatten()); 144 (res, memo) 145 } 146 ··· 482 pub fn use_profile_data( 483 ident: ReadSignal<AtIdentifier<'static>>, 484 ) -> ( 485 + Resource<Option<ProfileDataView<'static>>>, 486 Memo<Option<ProfileDataView<'static>>>, 487 ) { 488 let fetcher = use_context::<crate::fetch::Fetcher>(); ··· 493 .fetch_profile(&ident()) 494 .await 495 .ok() 496 + .map(|arc| (*arc).clone()) 497 } 498 }); 499 + let memo = use_memo(move || res.read().clone().flatten()); 500 (res, memo) 501 } 502 ··· 546 pub fn use_notebooks_for_did( 547 ident: ReadSignal<AtIdentifier<'static>>, 548 ) -> ( 549 + Resource<Option<Vec<(NotebookView<'static>, Vec<StrongRef<'static>>)>>>, 550 Memo<Option<Vec<(NotebookView<'static>, Vec<StrongRef<'static>>)>>>, 551 ) { 552 let fetcher = use_context::<crate::fetch::Fetcher>(); ··· 560 .map(|notebooks| { 561 notebooks 562 .iter() 563 + .map(|arc| arc.as_ref().clone()) 564 + .collect::<Vec<_>>() 565 }) 566 } 567 }); 568 + let memo = use_memo(move || res.read().clone().flatten()); 569 (res, memo) 570 } 571 ··· 611 /// Fetches notebooks from UFOS client-side only (no SSR) 612 #[cfg(not(feature = "fullstack-server"))] 613 pub fn use_notebooks_from_ufos() -> ( 614 + Resource<Option<Vec<(NotebookView<'static>, Vec<StrongRef<'static>>)>>>, 615 Memo<Option<Vec<(NotebookView<'static>, Vec<StrongRef<'static>>)>>>, 616 ) { 617 let fetcher = use_context::<crate::fetch::Fetcher>(); ··· 625 .map(|notebooks| { 626 notebooks 627 .iter() 628 + .map(|arc| arc.as_ref().clone()) 629 + .collect::<Vec<_>>() 630 }) 631 } 632 }); 633 + let memo = use_memo(move || res.read().clone().flatten()); 634 (res, memo) 635 } 636 ··· 673 ident: ReadSignal<AtIdentifier<'static>>, 674 book_title: ReadSignal<SmolStr>, 675 ) -> ( 676 + Resource<Option<(NotebookView<'static>, Vec<StrongRef<'static>>)>>, 677 Memo<Option<(NotebookView<'static>, Vec<StrongRef<'static>>)>>, 678 ) { 679 let fetcher = use_context::<crate::fetch::Fetcher>(); ··· 685 .await 686 .ok() 687 .flatten() 688 + .map(|arc| arc.as_ref().clone()) 689 } 690 }); 691 + let memo = use_memo(move || res.read().clone().flatten()); 692 (res, memo) 693 } 694
+71 -134
crates/weaver-app/src/main.rs
··· 321 ([(CONTENT_TYPE, "image/jpg")], bytes).into_response() 322 } 323 324 #[cfg(all(feature = "fullstack-server", feature = "server"))] 325 - #[get("/{notebook}/image/{name}", blob_cache: Extension<Arc<crate::blobcache::BlobCache>>)] 326 - pub async fn image_named(notebook: SmolStr, name: SmolStr) -> Result<axum::response::Response> { 327 use axum::{ 328 http::header::{CACHE_CONTROL, CONTENT_TYPE}, 329 response::IntoResponse, 330 }; 331 use mime_sniffer::MimeTypeSniffer; 332 if let Some(bytes) = blob_cache.get_named(&name) { 333 - let blob = bytes.clone(); 334 - let mime = blob.sniff_mime_type().unwrap_or("image/jpg"); 335 - // Blobs are immutable by CID - cache aggressively 336 - Ok(( 337 - [ 338 - (CONTENT_TYPE, mime), 339 - (CACHE_CONTROL, "public, max-age=31536000, immutable"), 340 - ], 341 - bytes, 342 - ) 343 - .into_response()) 344 } else { 345 - Err(CapturedError::from_display("no image")) 346 } 347 } 348 349 #[cfg(all(feature = "fullstack-server", feature = "server"))] 350 - #[get("/{notebook}/blob/{cid}", blob_cache: Extension<Arc<crate::blobcache::BlobCache>>)] 351 - pub async fn blob(notebook: SmolStr, cid: SmolStr) -> Result<axum::response::Response> { 352 - use axum::{ 353 - http::header::{CACHE_CONTROL, CONTENT_TYPE}, 354 - response::IntoResponse, 355 - }; 356 - use mime_sniffer::MimeTypeSniffer; 357 - if let Some(bytes) = blob_cache.get_cid(&Cid::new_owned(cid.as_bytes())?) { 358 - let blob = bytes.clone(); 359 - let mime = blob.sniff_mime_type().unwrap_or("application/octet-stream"); 360 - // Blobs are immutable by CID - cache aggressively 361 - Ok(( 362 - [ 363 - (CONTENT_TYPE, mime), 364 - (CACHE_CONTROL, "public, max-age=31536000, immutable"), 365 - ], 366 - bytes, 367 - ) 368 - .into_response()) 369 - } else { 370 - Err(CapturedError::from_display("no blob")) 371 } 372 } 373 374 - // New image routes with unified /image/ prefix 375 // Route: /image/{notebook}/{name} - notebook entry image by name 376 #[cfg(all(feature = "fullstack-server", feature = "server"))] 377 #[get("/image/{notebook}/{name}", blob_cache: Extension<Arc<crate::blobcache::BlobCache>>)] 378 pub async fn image_notebook(notebook: SmolStr, name: SmolStr) -> Result<axum::response::Response> { 379 - use axum::{ 380 - http::header::{CACHE_CONTROL, CONTENT_TYPE}, 381 - response::IntoResponse, 382 - }; 383 - use mime_sniffer::MimeTypeSniffer; 384 - 385 // Try name-based lookup first (backwards compat with cached entries) 386 if let Some(bytes) = blob_cache.get_named(&name) { 387 - let blob = bytes.clone(); 388 - let mime = blob.sniff_mime_type().unwrap_or("image/jpg"); 389 - return Ok(( 390 - [ 391 - (CONTENT_TYPE, mime), 392 - (CACHE_CONTROL, "public, max-age=31536000, immutable"), 393 - ], 394 - bytes, 395 - ) 396 - .into_response()); 397 } 398 - 399 // Try to resolve from notebook 400 match blob_cache.resolve_from_notebook(&notebook, &name).await { 401 - Ok(bytes) => { 402 - let blob = bytes.clone(); 403 - let mime = blob.sniff_mime_type().unwrap_or("image/jpg"); 404 - Ok(( 405 - [ 406 - (CONTENT_TYPE, mime), 407 - (CACHE_CONTROL, "public, max-age=31536000, immutable"), 408 - ], 409 - bytes, 410 - ) 411 - .into_response()) 412 - } 413 - Err(e) => Err(e), 414 } 415 } 416 417 // Route: /image/{ident}/draft/{blob_rkey} - draft image (unpublished) 418 - // Route: /image/{ident}/draft/{blob_rkey}/{name} - draft image with name 419 #[cfg(all(feature = "fullstack-server", feature = "server"))] 420 #[get("/image/{ident}/draft/{blob_rkey}", blob_cache: Extension<Arc<crate::blobcache::BlobCache>>)] 421 pub async fn image_draft(ident: SmolStr, blob_rkey: SmolStr) -> Result<axum::response::Response> { 422 - use axum::{ 423 - http::header::{CACHE_CONTROL, CONTENT_TYPE}, 424 - response::IntoResponse, 425 }; 426 - use mime_sniffer::MimeTypeSniffer; 427 - 428 - let at_ident = AtIdentifier::new_owned(ident.clone()) 429 - .map_err(|e| CapturedError::from_display(format!("Invalid identifier '{}': {}", ident, e)))?; 430 - 431 match blob_cache.resolve_from_draft(&at_ident, &blob_rkey).await { 432 - Ok(bytes) => { 433 - let blob = bytes.clone(); 434 - let mime = blob.sniff_mime_type().unwrap_or("image/jpg"); 435 - Ok(( 436 - [ 437 - (CONTENT_TYPE, mime), 438 - (CACHE_CONTROL, "public, max-age=31536000, immutable"), 439 - ], 440 - bytes, 441 - ) 442 - .into_response()) 443 - } 444 - Err(e) => Err(e), 445 } 446 } 447 448 #[cfg(all(feature = "fullstack-server", feature = "server"))] 449 - #[get("/image/{ident}/draft/{blob_rkey}/{name}", blob_cache: Extension<Arc<crate::blobcache::BlobCache>>)] 450 - pub async fn image_draft_named(ident: SmolStr, blob_rkey: SmolStr, name: SmolStr) -> Result<axum::response::Response> { 451 - use axum::{ 452 - http::header::{CACHE_CONTROL, CONTENT_TYPE}, 453 - response::IntoResponse, 454 }; 455 - use mime_sniffer::MimeTypeSniffer; 456 - 457 - // Name is optional/decorative for drafts, just use the blob_rkey 458 - let at_ident = AtIdentifier::new_owned(ident.clone()) 459 - .map_err(|e| CapturedError::from_display(format!("Invalid identifier '{}': {}", ident, e)))?; 460 - 461 match blob_cache.resolve_from_draft(&at_ident, &blob_rkey).await { 462 - Ok(bytes) => { 463 - let blob = bytes.clone(); 464 - let mime = blob.sniff_mime_type().unwrap_or("image/jpg"); 465 - Ok(( 466 - [ 467 - (CONTENT_TYPE, mime), 468 - (CACHE_CONTROL, "public, max-age=31536000, immutable"), 469 - ], 470 - bytes, 471 - ) 472 - .into_response()) 473 - } 474 - Err(e) => Err(e), 475 } 476 } 477 478 // Route: /image/{ident}/{rkey}/{name} - published entry image 479 #[cfg(all(feature = "fullstack-server", feature = "server"))] 480 #[get("/image/{ident}/{rkey}/{name}", blob_cache: Extension<Arc<crate::blobcache::BlobCache>>)] 481 - pub async fn image_entry(ident: SmolStr, rkey: SmolStr, name: SmolStr) -> Result<axum::response::Response> { 482 - use axum::{ 483 - http::header::{CACHE_CONTROL, CONTENT_TYPE}, 484 - response::IntoResponse, 485 }; 486 - use mime_sniffer::MimeTypeSniffer; 487 - 488 - let at_ident = AtIdentifier::new_owned(ident.clone()) 489 - .map_err(|e| CapturedError::from_display(format!("Invalid identifier '{}': {}", ident, e)))?; 490 - 491 match blob_cache.resolve_from_entry(&at_ident, &rkey, &name).await { 492 - Ok(bytes) => { 493 - let blob = bytes.clone(); 494 - let mime = blob.sniff_mime_type().unwrap_or("image/jpg"); 495 - Ok(( 496 - [ 497 - (CONTENT_TYPE, mime), 498 - (CACHE_CONTROL, "public, max-age=31536000, immutable"), 499 - ], 500 - bytes, 501 - ) 502 - .into_response()) 503 - } 504 - Err(e) => Err(e), 505 } 506 } 507
··· 321 ([(CONTENT_TYPE, "image/jpg")], bytes).into_response() 322 } 323 324 + /// Build an image response with appropriate headers for immutable blobs. 325 #[cfg(all(feature = "fullstack-server", feature = "server"))] 326 + fn build_image_response(bytes: jacquard::bytes::Bytes) -> axum::response::Response { 327 use axum::{ 328 http::header::{CACHE_CONTROL, CONTENT_TYPE}, 329 response::IntoResponse, 330 }; 331 use mime_sniffer::MimeTypeSniffer; 332 + 333 + let mime = bytes.sniff_mime_type().unwrap_or("image/jpg").to_string(); 334 + ( 335 + [ 336 + (CONTENT_TYPE, mime), 337 + ( 338 + CACHE_CONTROL, 339 + "public, max-age=31536000, immutable".to_string(), 340 + ), 341 + ], 342 + bytes, 343 + ) 344 + .into_response() 345 + } 346 + 347 + /// Return a 404 response for missing images. 348 + #[cfg(all(feature = "fullstack-server", feature = "server"))] 349 + fn image_not_found() -> axum::response::Response { 350 + use axum::{http::StatusCode, response::IntoResponse}; 351 + (StatusCode::NOT_FOUND, "Image not found").into_response() 352 + } 353 + 354 + #[cfg(all(feature = "fullstack-server", feature = "server"))] 355 + #[get("/{_notebook}/image/{name}", blob_cache: Extension<Arc<crate::blobcache::BlobCache>>)] 356 + pub async fn image_named(_notebook: SmolStr, name: SmolStr) -> Result<axum::response::Response> { 357 if let Some(bytes) = blob_cache.get_named(&name) { 358 + Ok(build_image_response(bytes)) 359 } else { 360 + Ok(image_not_found()) 361 } 362 } 363 364 #[cfg(all(feature = "fullstack-server", feature = "server"))] 365 + #[get("/{_notebook}/blob/{cid}", blob_cache: Extension<Arc<crate::blobcache::BlobCache>>)] 366 + pub async fn blob(_notebook: SmolStr, cid: SmolStr) -> Result<axum::response::Response> { 367 + match Cid::new_owned(cid.as_bytes()) { 368 + Ok(cid) => { 369 + if let Some(bytes) = blob_cache.get_cid(&cid) { 370 + Ok(build_image_response(bytes)) 371 + } else { 372 + Ok(image_not_found()) 373 + } 374 + } 375 + Err(_) => Ok(image_not_found()), 376 } 377 } 378 379 // Route: /image/{notebook}/{name} - notebook entry image by name 380 #[cfg(all(feature = "fullstack-server", feature = "server"))] 381 #[get("/image/{notebook}/{name}", blob_cache: Extension<Arc<crate::blobcache::BlobCache>>)] 382 pub async fn image_notebook(notebook: SmolStr, name: SmolStr) -> Result<axum::response::Response> { 383 // Try name-based lookup first (backwards compat with cached entries) 384 if let Some(bytes) = blob_cache.get_named(&name) { 385 + return Ok(build_image_response(bytes)); 386 } 387 + 388 // Try to resolve from notebook 389 match blob_cache.resolve_from_notebook(&notebook, &name).await { 390 + Ok(bytes) => Ok(build_image_response(bytes)), 391 + Err(_) => Ok(image_not_found()), 392 } 393 } 394 395 // Route: /image/{ident}/draft/{blob_rkey} - draft image (unpublished) 396 #[cfg(all(feature = "fullstack-server", feature = "server"))] 397 #[get("/image/{ident}/draft/{blob_rkey}", blob_cache: Extension<Arc<crate::blobcache::BlobCache>>)] 398 pub async fn image_draft(ident: SmolStr, blob_rkey: SmolStr) -> Result<axum::response::Response> { 399 + let Ok(at_ident) = AtIdentifier::new_owned(ident.clone()) else { 400 + return Ok(image_not_found()); 401 }; 402 + 403 match blob_cache.resolve_from_draft(&at_ident, &blob_rkey).await { 404 + Ok(bytes) => Ok(build_image_response(bytes)), 405 + Err(_) => Ok(image_not_found()), 406 } 407 } 408 409 + // Route: /image/{ident}/draft/{blob_rkey}/{name} - draft image with name (name is decorative) 410 #[cfg(all(feature = "fullstack-server", feature = "server"))] 411 + #[get("/image/{ident}/draft/{blob_rkey}/{_name}", blob_cache: Extension<Arc<crate::blobcache::BlobCache>>)] 412 + pub async fn image_draft_named( 413 + ident: SmolStr, 414 + blob_rkey: SmolStr, 415 + _name: SmolStr, 416 + ) -> Result<axum::response::Response> { 417 + let Ok(at_ident) = AtIdentifier::new_owned(ident.clone()) else { 418 + return Ok(image_not_found()); 419 }; 420 + 421 match blob_cache.resolve_from_draft(&at_ident, &blob_rkey).await { 422 + Ok(bytes) => Ok(build_image_response(bytes)), 423 + Err(_) => Ok(image_not_found()), 424 } 425 } 426 427 // Route: /image/{ident}/{rkey}/{name} - published entry image 428 #[cfg(all(feature = "fullstack-server", feature = "server"))] 429 #[get("/image/{ident}/{rkey}/{name}", blob_cache: Extension<Arc<crate::blobcache::BlobCache>>)] 430 + pub async fn image_entry( 431 + ident: SmolStr, 432 + rkey: SmolStr, 433 + name: SmolStr, 434 + ) -> Result<axum::response::Response> { 435 + let Ok(at_ident) = AtIdentifier::new_owned(ident.clone()) else { 436 + return Ok(image_not_found()); 437 }; 438 + 439 match blob_cache.resolve_from_entry(&at_ident, &rkey, &name).await { 440 + Ok(bytes) => Ok(build_image_response(bytes)), 441 + Err(_) => Ok(image_not_found()), 442 } 443 } 444
-13
crates/weaver-common/src/constellation.rs
··· 9 use serde::{Deserialize, Serialize}; 10 11 const DEFAULT_CURSOR_LIMIT: u64 = 16; 12 - #[allow(unused)] 13 - const DEFAULT_CURSOR_LIMIT_MAX: u64 = 100; 14 15 fn get_default_cursor_limit() -> u64 { 16 DEFAULT_CURSOR_LIMIT ··· 59 pub rkey: RecordKey<Rkey<'a>>, 60 } 61 62 - impl RecordId<'_> { 63 - pub fn did(&self) -> Did<'_> { 64 - self.did.clone() 65 - } 66 - pub fn collection(&self) -> Nsid<'_> { 67 - self.collection.clone() 68 - } 69 - pub fn rkey(&self) -> RecordKey<Rkey<'_>> { 70 - self.rkey.clone() 71 - } 72 - }
··· 9 use serde::{Deserialize, Serialize}; 10 11 const DEFAULT_CURSOR_LIMIT: u64 = 16; 12 13 fn get_default_cursor_limit() -> u64 { 14 DEFAULT_CURSOR_LIMIT ··· 57 pub rkey: RecordKey<Rkey<'a>>, 58 } 59
-3
crates/weaver-common/src/lib.rs
··· 32 pub entries: Vec<StrongRef<'a>>, 33 } 34 35 - /// too many cows, so we have conversions 36 pub fn mcow_to_cow(cow: CowStr<'_>) -> std::borrow::Cow<'_, str> { 37 match cow { 38 CowStr::Borrowed(s) => std::borrow::Cow::Borrowed(s), ··· 40 } 41 } 42 43 - /// too many cows, so we have conversions 44 pub fn cow_to_mcow(cow: std::borrow::Cow<'_, str>) -> CowStr<'_> { 45 match cow { 46 std::borrow::Cow::Borrowed(s) => CowStr::Borrowed(s), ··· 48 } 49 } 50 51 - /// too many cows, so we have conversions 52 pub fn mdcow_to_cow(cow: markdown_weaver::CowStr<'_>) -> std::borrow::Cow<'_, str> { 53 match cow { 54 markdown_weaver::CowStr::Borrowed(s) => std::borrow::Cow::Borrowed(s),
··· 32 pub entries: Vec<StrongRef<'a>>, 33 } 34 35 pub fn mcow_to_cow(cow: CowStr<'_>) -> std::borrow::Cow<'_, str> { 36 match cow { 37 CowStr::Borrowed(s) => std::borrow::Cow::Borrowed(s), ··· 39 } 40 } 41 42 pub fn cow_to_mcow(cow: std::borrow::Cow<'_, str>) -> CowStr<'_> { 43 match cow { 44 std::borrow::Cow::Borrowed(s) => CowStr::Borrowed(s), ··· 46 } 47 } 48 49 pub fn mdcow_to_cow(cow: markdown_weaver::CowStr<'_>) -> std::borrow::Cow<'_, str> { 50 match cow { 51 markdown_weaver::CowStr::Borrowed(s) => std::borrow::Cow::Borrowed(s),