cleanup

Orual ef7507ec 19fc059b

+121 -417
+21 -13
crates/weaver-app/src/blobcache.rs
··· 58 58 pds_url: jacquard::url::Url, 59 59 cid: &Cid<'_>, 60 60 ) -> Result<Bytes> { 61 - if let Ok(blob_stream) = self 61 + match self 62 62 .client 63 - .xrpc(pds_url) 63 + .xrpc(pds_url.clone()) 64 64 .send( 65 65 &GetBlob::new() 66 66 .cid(cid.clone()) ··· 69 69 ) 70 70 .await 71 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) 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 + } 83 91 } 84 92 } 85 93
-9
crates/weaver-app/src/components/editor/actions.rs
··· 1266 1266 /// This handles keyboard shortcuts only. Text input and deletion 1267 1267 /// are handled by beforeinput. Navigation (arrows, etc.) is passed 1268 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 1269 pub fn handle_keydown_with_bindings( 1279 1270 doc: &mut EditorDocument, 1280 1271 config: &KeybindingConfig,
-12
crates/weaver-app/src/components/editor/cursor.rs
··· 13 13 use wasm_bindgen::JsCast; 14 14 15 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 16 #[cfg(all(target_family = "wasm", target_os = "unknown"))] 29 17 pub fn restore_cursor_position( 30 18 char_offset: usize,
+2 -10
crates/weaver-app/src/components/editor/document.rs
··· 319 319 /// 320 320 /// MUST be called from within a reactive context (e.g., `use_hook`) to 321 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 322 pub fn from_entry(entry: &Entry<'_>, entry_ref: StrongRef<'static>) -> Self { 327 323 let mut doc = Self::new(entry.content.to_string()); 328 324 ··· 691 687 Ok(()) 692 688 } 693 689 694 - /// Undo the last operation. 695 - /// Returns true if an undo was performed. 696 - /// Automatically updates cursor position from the Loro cursor. 690 + /// Undo the last operation. Automatically updates cursor position. 697 691 pub fn undo(&mut self) -> LoroResult<bool> { 698 692 // Sync Loro cursor to current position BEFORE undo 699 693 // so it tracks through the undo operation ··· 709 703 Ok(result) 710 704 } 711 705 712 - /// Redo the last undone operation. 713 - /// Returns true if a redo was performed. 714 - /// Automatically updates cursor position from the Loro cursor. 706 + /// Redo the last undone operation. Automatically updates cursor position. 715 707 pub fn redo(&mut self) -> LoroResult<bool> { 716 708 // Sync Loro cursor to current position BEFORE redo 717 709 self.sync_loro_cursor();
-13
crates/weaver-app/src/components/editor/dom_sync.rs
··· 71 71 let anchor_offset = selection.anchor_offset() as usize; 72 72 let focus_offset = selection.focus_offset() as usize; 73 73 74 - // Convert both DOM positions to rope offsets using cached paragraphs 75 74 let anchor_rope = dom_position_to_text_offset( 76 75 &dom_document, 77 76 &editor_element, ··· 93 92 (Some(anchor), Some(focus)) => { 94 93 doc.cursor.write().offset = focus; 95 94 if anchor != focus { 96 - // There's an actual selection 97 95 doc.selection.set(Some(Selection { 98 96 anchor, 99 97 head: focus, 100 98 })); 101 99 } else { 102 - // Collapsed selection (just cursor) 103 100 doc.selection.set(None); 104 101 } 105 102 } ··· 158 155 159 156 let node_id = node_id?; 160 157 161 - // Get the container element 162 158 let container = dom_document.get_element_by_id(&node_id).or_else(|| { 163 159 let selector = format!("[data-node-id='{}']", node_id); 164 160 dom_document.query_selector(&selector).ok().flatten() ··· 180 176 } 181 177 } 182 178 183 - // Look up in offset maps 184 179 for para in paragraphs { 185 180 for mapping in &para.offset_map { 186 181 if mapping.node_id == node_id { ··· 231 226 _editor_id: &str, 232 227 _paragraphs: &[ParagraphRender], 233 228 ) { 234 - // No-op on non-wasm 235 229 } 236 230 237 231 #[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))] ··· 241 235 _paragraphs: &[ParagraphRender], 242 236 _direction_hint: Option<SnapDirection>, 243 237 ) { 244 - // No-op on non-wasm 245 238 } 246 239 247 240 /// Update paragraph DOM elements incrementally. ··· 284 277 285 278 let mut cursor_para_updated = false; 286 279 287 - // Update or create paragraphs 288 280 for (idx, new_para) in new_paragraphs.iter().enumerate() { 289 281 let para_id = format!("para-{}", idx); 290 282 291 283 if let Some(old_para) = old_paragraphs.get(idx) { 292 - // Paragraph exists - check if changed 293 284 if new_para.source_hash != old_para.source_hash { 294 285 // Changed - clear and update innerHTML 295 286 // We clear first to ensure any browser-added content (from IME composition, ··· 299 290 elem.set_inner_html(&new_para.html); 300 291 } 301 292 302 - // Track if we updated the cursor's paragraph 303 293 if Some(idx) == cursor_para_idx { 304 294 cursor_para_updated = true; 305 295 } 306 296 } 307 - // Unchanged - do nothing, browser preserves cursor 308 297 } else { 309 - // New paragraph - create div 310 298 if let Ok(div) = document.create_element("div") { 311 299 div.set_id(&para_id); 312 300 div.set_inner_html(&new_para.html); 313 301 let _ = editor.append_child(&div); 314 302 } 315 303 316 - // Track if we created the cursor's paragraph 317 304 if Some(idx) == cursor_para_idx { 318 305 cursor_para_updated = true; 319 306 }
-8
crates/weaver-app/src/components/editor/offset_map.rs
··· 181 181 /// If the position is already valid, returns it directly. Otherwise, 182 182 /// searches in the preferred direction first, falling back to the other 183 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 184 pub fn find_nearest_valid_position( 193 185 offset_map: &[OffsetMapping], 194 186 char_offset: usize,
+1 -24
crates/weaver-app/src/components/editor/publish.rs
··· 62 62 } 63 63 64 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 65 #[derive(Clone, PartialEq)] 68 66 pub struct LoadedEntry { 69 67 pub entry: Entry<'static>, ··· 71 69 } 72 70 73 71 /// 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 72 pub async fn load_entry_for_editing( 85 73 fetcher: &Fetcher, 86 74 uri: &AtUri<'_>, ··· 154 142 /// - Without notebook but with entry_uri in doc: uses `put_record` to update existing 155 143 /// - Without notebook and no entry_uri: uses `create_record` for free-floating entry 156 144 /// 157 - /// Draft image paths (`/image/{did}/draft/{blob_rkey}/{name}`) are rewritten to 158 - /// published paths (`/image/{did}/{entry_rkey}/{name}`) before publishing. 159 - /// 145 + /// Draft image paths are rewritten to published paths before publishing. 160 146 /// 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 147 pub async fn publish_entry( 171 148 fetcher: &Fetcher, 172 149 doc: &mut EditorDocument,
-9
crates/weaver-app/src/components/editor/render.rs
··· 104 104 /// 105 105 /// Uses cached paragraph renders when possible, only re-rendering changed paragraphs. 106 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 107 pub fn render_paragraphs_incremental( 117 108 text: &LoroText, 118 109 cache: Option<&RenderCache>,
-13
crates/weaver-app/src/components/editor/storage.rs
··· 71 71 } 72 72 73 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 74 #[cfg(all(target_family = "wasm", target_os = "unknown"))] 79 75 pub fn save_to_storage( 80 76 doc: &EditorDocument, ··· 103 99 /// 104 100 /// Returns an EditorDocument restored from CRDT snapshot if available, 105 101 /// 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 102 #[cfg(all(target_family = "wasm", target_os = "unknown"))] 110 103 pub fn load_from_storage(key: &str) -> Option<EditorDocument> { 111 104 let snapshot: EditorSnapshot = LocalStorage::get(storage_key(key)).ok()?; ··· 158 151 /// 159 152 /// Unlike `load_from_storage`, this doesn't create an EditorDocument and is safe 160 153 /// 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 154 #[cfg(all(target_family = "wasm", target_os = "unknown"))] 165 155 pub fn load_snapshot_from_storage(key: &str) -> Option<LocalSnapshotData> { 166 156 let snapshot: EditorSnapshot = LocalStorage::get(storage_key(key)).ok()?; ··· 195 185 } 196 186 197 187 /// Delete a draft from LocalStorage (WASM only). 198 - /// 199 - /// # Arguments 200 - /// * `key` - Storage key to delete 201 188 #[cfg(all(target_family = "wasm", target_os = "unknown"))] 202 189 pub fn delete_draft(key: &str) { 203 190 LocalStorage::delete(storage_key(key));
+9 -56
crates/weaver-app/src/components/editor/sync.rs
··· 178 178 /// 179 179 /// Uploads the current Loro snapshot as a blob and creates an `sh.weaver.edit.root` 180 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 181 pub async fn create_edit_root( 189 182 fetcher: &Fetcher, 190 183 doc: &EditorDocument, ··· 244 237 } 245 238 246 239 /// 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 240 pub async fn create_diff( 258 241 fetcher: &Fetcher, 259 242 doc: &EditorDocument, ··· 352 335 /// 353 336 /// If no edit root exists, creates one with a full snapshot. 354 337 /// If a root exists, creates a diff with updates since last sync. 355 - /// 356 338 /// 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 339 pub async fn sync_to_pds( 366 340 fetcher: &Fetcher, 367 341 doc: &mut EditorDocument, ··· 480 454 /// 481 455 /// Finds the edit root via constellation backlinks, fetches all diffs, 482 456 /// 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 457 pub async fn load_edit_state_from_pds( 491 458 fetcher: &Fetcher, 492 459 entry_uri: &AtUri<'_>, ··· 500 467 // Build root URI 501 468 let root_uri = AtUri::new(&format!( 502 469 "at://{}/{}/{}", 503 - root_id.did(), 470 + root_id.did, 504 471 ROOT_NSID, 505 - root_id.rkey().as_ref() 472 + root_id.rkey.as_ref() 506 473 )) 507 474 .map_err(|e| WeaverError::InvalidNotebook(format!("Invalid root URI: {}", e)))? 508 475 .into_static(); ··· 530 497 // Fetch the root snapshot blob 531 498 let root_snapshot = fetch_blob( 532 499 fetcher, 533 - &root_id.did(), 500 + &root_id.did, 534 501 root_output.value.snapshot.blob().cid(), 535 502 ) 536 503 .await?; ··· 555 522 > = BTreeMap::new(); 556 523 557 524 for diff_id in &diff_ids { 558 - let rkey = diff_id.rkey(); 559 - let rkey_str: &str = rkey.as_ref(); 525 + let rkey_str: &str = diff_id.rkey.as_ref(); 560 526 let diff_uri = AtUri::new(&format!( 561 527 "at://{}/{}/{}", 562 - diff_id.did(), 528 + diff_id.did, 563 529 DIFF_NSID, 564 530 rkey_str 565 531 )) ··· 600 566 let diff_bytes = if let Some(ref inline) = diff.inline_diff { 601 567 inline.clone() 602 568 } else if let Some(ref snapshot) = diff.snapshot { 603 - fetch_blob(fetcher, &root_id.did(), snapshot.blob().cid()).await? 569 + fetch_blob(fetcher, &root_id.did, snapshot.blob().cid()).await? 604 570 } else { 605 571 tracing::warn!("Diff has neither inline_diff nor snapshot, skipping"); 606 572 continue; ··· 622 588 623 589 /// Load document state by merging local storage and PDS state. 624 590 /// 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. 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`. 641 594 pub async fn load_and_merge_document( 642 595 fetcher: &Fetcher, 643 596 draft_key: &str,
-6
crates/weaver-app/src/components/editor/visibility.rs
··· 18 18 19 19 impl VisibilityState { 20 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 21 pub fn calculate( 28 22 cursor_offset: usize, 29 23 selection: Option<&Selection>,
-25
crates/weaver-app/src/components/editor/writer.rs
··· 176 176 } 177 177 178 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 179 pub fn add_pending(&mut self, name: String, data_url: String) { 184 180 self.images.insert(name, ResolvedImage::Pending(data_url)); 185 181 } 186 182 187 183 /// 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 184 pub fn promote_to_uploaded( 194 185 &mut self, 195 186 name: &str, ··· 203 194 } 204 195 205 196 /// 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 197 pub fn add_uploaded( 212 198 &mut self, 213 199 name: String, ··· 219 205 } 220 206 221 207 /// 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 208 pub fn add_published( 228 209 &mut self, 229 210 name: String, ··· 240 221 } 241 222 242 223 /// 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 224 /// 250 225 /// For draft mode (entry_rkey=None), only images with a `published_blob_uri` are included. 251 226 /// For published mode (entry_rkey=Some), all images are included.
+17 -69
crates/weaver-app/src/data.rs
··· 101 101 book_title: ReadSignal<SmolStr>, 102 102 title: ReadSignal<SmolStr>, 103 103 ) -> ( 104 - Resource<Option<(serde_json::Value, serde_json::Value)>>, 104 + Resource<Option<(BookEntryView<'static>, Entry<'static>)>>, 105 105 Memo<Option<(BookEntryView<'static>, Entry<'static>)>>, 106 106 ) { 107 107 let fetcher = use_context::<crate::fetch::Fetcher>(); ··· 134 134 } 135 135 } 136 136 } 137 - Some(( 138 - serde_json::to_value(entry.0.clone()).unwrap(), 139 - serde_json::to_value(entry.1.clone()).unwrap(), 140 - )) 137 + Some(entry) 141 138 } else { 142 139 None 143 140 } 144 141 } 145 142 }); 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 - }); 143 + let memo = use_memo(move || res.read().clone().flatten()); 158 144 (res, memo) 159 145 } 160 146 ··· 496 482 pub fn use_profile_data( 497 483 ident: ReadSignal<AtIdentifier<'static>>, 498 484 ) -> ( 499 - Resource<Option<serde_json::Value>>, 485 + Resource<Option<ProfileDataView<'static>>>, 500 486 Memo<Option<ProfileDataView<'static>>>, 501 487 ) { 502 488 let fetcher = use_context::<crate::fetch::Fetcher>(); ··· 507 493 .fetch_profile(&ident()) 508 494 .await 509 495 .ok() 510 - .map(|arc| serde_json::to_value(&*arc).ok()) 511 - .flatten() 496 + .map(|arc| (*arc).clone()) 512 497 } 513 498 }); 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 - }); 499 + let memo = use_memo(move || res.read().clone().flatten()); 521 500 (res, memo) 522 501 } 523 502 ··· 567 546 pub fn use_notebooks_for_did( 568 547 ident: ReadSignal<AtIdentifier<'static>>, 569 548 ) -> ( 570 - Resource<Option<Vec<serde_json::Value>>>, 549 + Resource<Option<Vec<(NotebookView<'static>, Vec<StrongRef<'static>>)>>>, 571 550 Memo<Option<Vec<(NotebookView<'static>, Vec<StrongRef<'static>>)>>>, 572 551 ) { 573 552 let fetcher = use_context::<crate::fetch::Fetcher>(); ··· 581 560 .map(|notebooks| { 582 561 notebooks 583 562 .iter() 584 - .map(|arc| serde_json::to_value(arc.as_ref()).ok()) 585 - .collect::<Option<Vec<_>>>() 563 + .map(|arc| arc.as_ref().clone()) 564 + .collect::<Vec<_>>() 586 565 }) 587 - .flatten() 588 566 } 589 567 }); 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 - }); 568 + let memo = use_memo(move || res.read().clone().flatten()); 602 569 (res, memo) 603 570 } 604 571 ··· 644 611 /// Fetches notebooks from UFOS client-side only (no SSR) 645 612 #[cfg(not(feature = "fullstack-server"))] 646 613 pub fn use_notebooks_from_ufos() -> ( 647 - Resource<Option<Vec<serde_json::Value>>>, 614 + Resource<Option<Vec<(NotebookView<'static>, Vec<StrongRef<'static>>)>>>, 648 615 Memo<Option<Vec<(NotebookView<'static>, Vec<StrongRef<'static>>)>>>, 649 616 ) { 650 617 let fetcher = use_context::<crate::fetch::Fetcher>(); ··· 658 625 .map(|notebooks| { 659 626 notebooks 660 627 .iter() 661 - .map(|arc| serde_json::to_value(arc.as_ref()).ok()) 662 - .collect::<Option<Vec<_>>>() 628 + .map(|arc| arc.as_ref().clone()) 629 + .collect::<Vec<_>>() 663 630 }) 664 - .flatten() 665 631 } 666 632 }); 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 - }); 633 + let memo = use_memo(move || res.read().clone().flatten()); 679 634 (res, memo) 680 635 } 681 636 ··· 718 673 ident: ReadSignal<AtIdentifier<'static>>, 719 674 book_title: ReadSignal<SmolStr>, 720 675 ) -> ( 721 - Resource<Option<serde_json::Value>>, 676 + Resource<Option<(NotebookView<'static>, Vec<StrongRef<'static>>)>>, 722 677 Memo<Option<(NotebookView<'static>, Vec<StrongRef<'static>>)>>, 723 678 ) { 724 679 let fetcher = use_context::<crate::fetch::Fetcher>(); ··· 730 685 .await 731 686 .ok() 732 687 .flatten() 733 - .map(|arc| serde_json::to_value(arc.as_ref()).ok()) 734 - .flatten() 688 + .map(|arc| arc.as_ref().clone()) 735 689 } 736 690 }); 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 - })); 691 + let memo = use_memo(move || res.read().clone().flatten()); 744 692 (res, memo) 745 693 } 746 694
+71 -134
crates/weaver-app/src/main.rs
··· 321 321 ([(CONTENT_TYPE, "image/jpg")], bytes).into_response() 322 322 } 323 323 324 + /// Build an image response with appropriate headers for immutable blobs. 324 325 #[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> { 326 + fn build_image_response(bytes: jacquard::bytes::Bytes) -> axum::response::Response { 327 327 use axum::{ 328 328 http::header::{CACHE_CONTROL, CONTENT_TYPE}, 329 329 response::IntoResponse, 330 330 }; 331 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> { 332 357 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()) 358 + Ok(build_image_response(bytes)) 344 359 } else { 345 - Err(CapturedError::from_display("no image")) 360 + Ok(image_not_found()) 346 361 } 347 362 } 348 363 349 364 #[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")) 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()), 371 376 } 372 377 } 373 378 374 - // New image routes with unified /image/ prefix 375 379 // Route: /image/{notebook}/{name} - notebook entry image by name 376 380 #[cfg(all(feature = "fullstack-server", feature = "server"))] 377 381 #[get("/image/{notebook}/{name}", blob_cache: Extension<Arc<crate::blobcache::BlobCache>>)] 378 382 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 383 // Try name-based lookup first (backwards compat with cached entries) 386 384 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()); 385 + return Ok(build_image_response(bytes)); 397 386 } 398 - 387 + 399 388 // Try to resolve from notebook 400 389 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), 390 + Ok(bytes) => Ok(build_image_response(bytes)), 391 + Err(_) => Ok(image_not_found()), 414 392 } 415 393 } 416 394 417 395 // Route: /image/{ident}/draft/{blob_rkey} - draft image (unpublished) 418 - // Route: /image/{ident}/draft/{blob_rkey}/{name} - draft image with name 419 396 #[cfg(all(feature = "fullstack-server", feature = "server"))] 420 397 #[get("/image/{ident}/draft/{blob_rkey}", blob_cache: Extension<Arc<crate::blobcache::BlobCache>>)] 421 398 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, 399 + let Ok(at_ident) = AtIdentifier::new_owned(ident.clone()) else { 400 + return Ok(image_not_found()); 425 401 }; 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 - 402 + 431 403 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), 404 + Ok(bytes) => Ok(build_image_response(bytes)), 405 + Err(_) => Ok(image_not_found()), 445 406 } 446 407 } 447 408 409 + // Route: /image/{ident}/draft/{blob_rkey}/{name} - draft image with name (name is decorative) 448 410 #[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, 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()); 454 419 }; 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 - 420 + 461 421 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), 422 + Ok(bytes) => Ok(build_image_response(bytes)), 423 + Err(_) => Ok(image_not_found()), 475 424 } 476 425 } 477 426 478 427 // Route: /image/{ident}/{rkey}/{name} - published entry image 479 428 #[cfg(all(feature = "fullstack-server", feature = "server"))] 480 429 #[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, 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()); 485 437 }; 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 - 438 + 491 439 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), 440 + Ok(bytes) => Ok(build_image_response(bytes)), 441 + Err(_) => Ok(image_not_found()), 505 442 } 506 443 } 507 444
-13
crates/weaver-common/src/constellation.rs
··· 9 9 use serde::{Deserialize, Serialize}; 10 10 11 11 const DEFAULT_CURSOR_LIMIT: u64 = 16; 12 - #[allow(unused)] 13 - const DEFAULT_CURSOR_LIMIT_MAX: u64 = 100; 14 12 15 13 fn get_default_cursor_limit() -> u64 { 16 14 DEFAULT_CURSOR_LIMIT ··· 59 57 pub rkey: RecordKey<Rkey<'a>>, 60 58 } 61 59 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 - }
-3
crates/weaver-common/src/lib.rs
··· 32 32 pub entries: Vec<StrongRef<'a>>, 33 33 } 34 34 35 - /// too many cows, so we have conversions 36 35 pub fn mcow_to_cow(cow: CowStr<'_>) -> std::borrow::Cow<'_, str> { 37 36 match cow { 38 37 CowStr::Borrowed(s) => std::borrow::Cow::Borrowed(s), ··· 40 39 } 41 40 } 42 41 43 - /// too many cows, so we have conversions 44 42 pub fn cow_to_mcow(cow: std::borrow::Cow<'_, str>) -> CowStr<'_> { 45 43 match cow { 46 44 std::borrow::Cow::Borrowed(s) => CowStr::Borrowed(s), ··· 48 46 } 49 47 } 50 48 51 - /// too many cows, so we have conversions 52 49 pub fn mdcow_to_cow(cow: markdown_weaver::CowStr<'_>) -> std::borrow::Cow<'_, str> { 53 50 match cow { 54 51 markdown_weaver::CowStr::Borrowed(s) => std::borrow::Cow::Borrowed(s),