more resiliency

Orual f79bbd8d cd388c2e

+529 -102
+14
crates/weaver-app/assets/styling/entry-card.css
··· 111 111 margin-left: auto; 112 112 } 113 113 114 + .entry-card-stats { 115 + display: flex; 116 + gap: 0.5rem; 117 + font-size: 0.75rem; 118 + color: var(--color-muted); 119 + margin-top: 0.5rem; 120 + } 121 + 122 + .entry-card-stats .word-count::after { 123 + content: "·"; 124 + margin-left: 0.5rem; 125 + } 126 + 114 127 .entry-card-tags { 115 128 display: flex; 116 129 gap: 0.4rem; 117 130 flex-wrap: wrap; 131 + margin-top: 0.5rem; 118 132 } 119 133 120 134 .entry-card-tag {
+25 -4
crates/weaver-app/assets/styling/entry.css
··· 100 100 101 101 /* Entry metadata header */ 102 102 .entry-metadata { 103 - margin-bottom: calc(2rem * var(--spacing-scale, 1.5)); 104 - padding-bottom: calc(1rem * var(--spacing-scale, 1.5)); 103 + margin-bottom: calc(1rem * var(--spacing-scale, 1.5)); 104 + padding-bottom: calc(0.5rem * var(--spacing-scale, 1.5)); 105 105 border-bottom: 2px solid var(--color-border); 106 106 } 107 107 ··· 122 122 .entry-meta-info { 123 123 display: flex; 124 124 flex-wrap: wrap; 125 - gap: calc(1.5rem * var(--spacing-scale, 1.5)); 125 + gap: calc(0.25rem * var(--spacing-scale, 1.5)) calc(1rem * var(--spacing-scale, 1.5)); 126 126 font-size: 0.95rem; 127 127 color: var(--color-subtle); 128 128 } 129 129 130 130 .entry-authors, 131 131 .entry-date, 132 - .entry-tags { 132 + .entry-tags, 133 + .entry-reading-stats { 133 134 display: flex; 134 135 align-items: center; 135 136 font-family: var(--font-ui); 136 137 gap: 0.5rem; 137 138 } 138 139 140 + .entry-reading-stats { 141 + color: var(--color-subtle); 142 + margin-left: auto; 143 + margin-top: 0.25rem; 144 + } 145 + 146 + .entry-reading-stats .word-count::after { 147 + content: "·"; 148 + margin-left: 0.5rem; 149 + } 150 + 139 151 .entry-date { 140 152 margin-left: auto; 141 153 font-weight: 400; 154 + align-items: last baseline; 142 155 color: var(--color-subtle); 143 156 } 144 157 ··· 157 170 .author-name:hover { 158 171 color: var(--color-emphasis); 159 172 text-decoration: underline; 173 + } 174 + 175 + .entry-meta-secondary { 176 + display: flex; 177 + flex-wrap: wrap; 178 + align-items: center; 179 + gap: 0.5rem 1rem; 180 + flex-basis: 100%; 160 181 } 161 182 162 183 .entry-tags {
+14 -14
crates/weaver-app/public/editor_worker.js
··· 364 364 const ret = arg0.node; 365 365 return ret; 366 366 }; 367 - imports.wbg.__wbg_now_8a87c5466cc7d560 = function() { 368 - const ret = Date.now(); 369 - return ret; 370 - }; 371 367 imports.wbg.__wbg_now_8cf15d6e317793e1 = function(arg0) { 372 368 const ret = arg0.now(); 369 + return ret; 370 + }; 371 + imports.wbg.__wbg_now_c8bdc8efc8c495eb = function() { 372 + const ret = Date.now(); 373 373 return ret; 374 374 }; 375 375 imports.wbg.__wbg_performance_c77a440eff2efd9b = function(arg0) { ··· 442 442 const ret = arg0.versions; 443 443 return ret; 444 444 }; 445 - imports.wbg.__wbindgen_cast_1511eb630aa228f5 = function(arg0, arg1) { 446 - // Cast intrinsic for `Closure(Closure { dtor_idx: 940, function: Function { arguments: [Externref], shim_idx: 941, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`. 447 - const ret = makeMutClosure(arg0, arg1, wasm.wasm_bindgen_1add006a0ed82fd3___closure__destroy___dyn_core_b125d98f3949a913___ops__function__FnMut__wasm_bindgen_1add006a0ed82fd3___JsValue____Output_______, wasm_bindgen_1add006a0ed82fd3___convert__closures_____invoke___wasm_bindgen_1add006a0ed82fd3___JsValue_____); 448 - return ret; 449 - }; 450 445 imports.wbg.__wbindgen_cast_2241b6af4c4b2941 = function(arg0, arg1) { 451 446 // Cast intrinsic for `Ref(String) -> Externref`. 452 447 const ret = getStringFromWasm0(arg0, arg1); 453 448 return ret; 454 449 }; 455 - imports.wbg.__wbindgen_cast_cb9088102bce6b30 = function(arg0, arg1) { 456 - // Cast intrinsic for `Ref(Slice(U8)) -> NamedExternref("Uint8Array")`. 457 - const ret = getArrayU8FromWasm0(arg0, arg1); 450 + imports.wbg.__wbindgen_cast_3fda284bdcf7704e = function(arg0, arg1) { 451 + // Cast intrinsic for `Closure(Closure { dtor_idx: 941, function: Function { arguments: [Externref], shim_idx: 942, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`. 452 + const ret = makeMutClosure(arg0, arg1, wasm.wasm_bindgen_1add006a0ed82fd3___closure__destroy___dyn_core_b125d98f3949a913___ops__function__FnMut__wasm_bindgen_1add006a0ed82fd3___JsValue____Output_______, wasm_bindgen_1add006a0ed82fd3___convert__closures_____invoke___wasm_bindgen_1add006a0ed82fd3___JsValue_____); 458 453 return ret; 459 454 }; 460 - imports.wbg.__wbindgen_cast_ce6245619dc560a7 = function(arg0, arg1) { 461 - // Cast intrinsic for `Closure(Closure { dtor_idx: 102, function: Function { arguments: [NamedExternref("MessageEvent")], shim_idx: 103, ret: Unit, inner_ret: Some(Unit) }, mutable: false }) -> Externref`. 455 + imports.wbg.__wbindgen_cast_56c1ebb4e8528c2a = function(arg0, arg1) { 456 + // Cast intrinsic for `Closure(Closure { dtor_idx: 132, function: Function { arguments: [NamedExternref("MessageEvent")], shim_idx: 133, ret: Unit, inner_ret: Some(Unit) }, mutable: false }) -> Externref`. 462 457 const ret = makeClosure(arg0, arg1, wasm.wasm_bindgen_1add006a0ed82fd3___closure__destroy___dyn_core_b125d98f3949a913___ops__function__Fn__web_sys_8b37e013ecf136cb___features__gen_MessageEvent__MessageEvent____Output_______, wasm_bindgen_1add006a0ed82fd3___convert__closures_____invoke___web_sys_8b37e013ecf136cb___features__gen_MessageEvent__MessageEvent_____); 458 + return ret; 459 + }; 460 + imports.wbg.__wbindgen_cast_cb9088102bce6b30 = function(arg0, arg1) { 461 + // Cast intrinsic for `Ref(Slice(U8)) -> NamedExternref("Uint8Array")`. 462 + const ret = getArrayU8FromWasm0(arg0, arg1); 463 463 return ret; 464 464 }; 465 465 imports.wbg.__wbindgen_init_externref_table = function() {
+26 -26
crates/weaver-app/public/embed_worker.js
··· 232 232 233 233 let WASM_VECTOR_LEN = 0; 234 234 235 + function wasm_bindgen_1add006a0ed82fd3___convert__closures_____invoke_______1_(arg0, arg1) { 236 + wasm.wasm_bindgen_1add006a0ed82fd3___convert__closures_____invoke_______1_(arg0, arg1); 237 + } 238 + 235 239 function wasm_bindgen_1add006a0ed82fd3___convert__closures_____invoke___web_sys_8b37e013ecf136cb___features__gen_MessageEvent__MessageEvent_____(arg0, arg1, arg2) { 236 240 wasm.wasm_bindgen_1add006a0ed82fd3___convert__closures_____invoke___web_sys_8b37e013ecf136cb___features__gen_MessageEvent__MessageEvent_____(arg0, arg1, arg2); 237 241 } 238 242 239 243 function wasm_bindgen_1add006a0ed82fd3___convert__closures_____invoke______(arg0, arg1) { 240 244 wasm.wasm_bindgen_1add006a0ed82fd3___convert__closures_____invoke______(arg0, arg1); 241 - } 242 - 243 - function wasm_bindgen_1add006a0ed82fd3___convert__closures_____invoke_______1_(arg0, arg1) { 244 - wasm.wasm_bindgen_1add006a0ed82fd3___convert__closures_____invoke_______1_(arg0, arg1); 245 245 } 246 246 247 247 function wasm_bindgen_1add006a0ed82fd3___convert__closures_____invoke___wasm_bindgen_1add006a0ed82fd3___JsValue_____(arg0, arg1, arg2) { ··· 362 362 imports.wbg.__wbg_clearTimeout_15dfc3d1dcb635c6 = function() { return handleError(function (arg0, arg1) { 363 363 arg0.clearTimeout(arg1); 364 364 }, arguments) }; 365 - imports.wbg.__wbg_clearTimeout_7a42b49784aea641 = function(arg0) { 365 + imports.wbg.__wbg_clearTimeout_3b6a9a13d4bde075 = function(arg0) { 366 366 const ret = clearTimeout(arg0); 367 367 return ret; 368 368 }; ··· 388 388 wasm.__wbindgen_free(deferred0_0, deferred0_1, 1); 389 389 } 390 390 }; 391 - imports.wbg.__wbg_fetch_74a3e84ebd2c9a0e = function(arg0) { 392 - const ret = fetch(arg0); 393 - return ret; 394 - }; 395 391 imports.wbg.__wbg_fetch_90447c28cc0b095e = function(arg0, arg1) { 396 392 const ret = arg0.fetch(arg1); 393 + return ret; 394 + }; 395 + imports.wbg.__wbg_fetch_df3fa17a5772dafb = function(arg0) { 396 + const ret = fetch(arg0); 397 397 return ret; 398 398 }; 399 399 imports.wbg.__wbg_getTime_ad1e9878a735af08 = function(arg0) { ··· 517 517 const ret = Promise.resolve(arg0); 518 518 return ret; 519 519 }; 520 + imports.wbg.__wbg_setTimeout_35a07631c669fbee = function(arg0, arg1) { 521 + const ret = setTimeout(arg0, arg1); 522 + return ret; 523 + }; 520 524 imports.wbg.__wbg_setTimeout_4eb823e8b72fbe79 = function() { return handleError(function (arg0, arg1, arg2) { 521 525 const ret = arg0.setTimeout(arg1, arg2); 522 526 return ret; 523 527 }, arguments) }; 524 - imports.wbg.__wbg_setTimeout_7bb3429662ab1e70 = function(arg0, arg1) { 525 - const ret = setTimeout(arg0, arg1); 526 - return ret; 527 - }; 528 528 imports.wbg.__wbg_set_body_8e743242d6076a4f = function(arg0, arg1) { 529 529 arg0.body = arg1; 530 530 }; ··· 607 607 const ret = arg0.value; 608 608 return ret; 609 609 }; 610 + imports.wbg.__wbindgen_cast_067e3c0104867449 = function(arg0, arg1) { 611 + // Cast intrinsic for `Closure(Closure { dtor_idx: 225, function: Function { arguments: [NamedExternref("MessageEvent")], shim_idx: 226, ret: Unit, inner_ret: Some(Unit) }, mutable: false }) -> Externref`. 612 + const ret = makeClosure(arg0, arg1, wasm.wasm_bindgen_1add006a0ed82fd3___closure__destroy___dyn_core_b125d98f3949a913___ops__function__Fn__web_sys_8b37e013ecf136cb___features__gen_MessageEvent__MessageEvent____Output_______, wasm_bindgen_1add006a0ed82fd3___convert__closures_____invoke___web_sys_8b37e013ecf136cb___features__gen_MessageEvent__MessageEvent_____); 613 + return ret; 614 + }; 610 615 imports.wbg.__wbindgen_cast_2241b6af4c4b2941 = function(arg0, arg1) { 611 616 // Cast intrinsic for `Ref(String) -> Externref`. 612 617 const ret = getStringFromWasm0(arg0, arg1); 613 618 return ret; 614 619 }; 615 - imports.wbg.__wbindgen_cast_36dddc5933837ecc = function(arg0, arg1) { 616 - // Cast intrinsic for `Closure(Closure { dtor_idx: 2216, function: Function { arguments: [Externref], shim_idx: 2217, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`. 617 - const ret = makeMutClosure(arg0, arg1, wasm.wasm_bindgen_1add006a0ed82fd3___closure__destroy___dyn_core_b125d98f3949a913___ops__function__FnMut__wasm_bindgen_1add006a0ed82fd3___JsValue____Output_______, wasm_bindgen_1add006a0ed82fd3___convert__closures_____invoke___wasm_bindgen_1add006a0ed82fd3___JsValue_____); 618 - return ret; 619 - }; 620 - imports.wbg.__wbindgen_cast_3eeb44a0158730cb = function(arg0, arg1) { 621 - // Cast intrinsic for `Closure(Closure { dtor_idx: 1188, function: Function { arguments: [], shim_idx: 1189, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`. 620 + imports.wbg.__wbindgen_cast_4c40eebfb345262b = function(arg0, arg1) { 621 + // Cast intrinsic for `Closure(Closure { dtor_idx: 1169, function: Function { arguments: [], shim_idx: 1170, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`. 622 622 const ret = makeMutClosure(arg0, arg1, wasm.wasm_bindgen_1add006a0ed82fd3___closure__destroy___dyn_core_b125d98f3949a913___ops__function__FnMut_____Output_______, wasm_bindgen_1add006a0ed82fd3___convert__closures_____invoke______); 623 623 return ret; 624 624 }; 625 - imports.wbg.__wbindgen_cast_b5fa8180acb99032 = function(arg0, arg1) { 626 - // Cast intrinsic for `Closure(Closure { dtor_idx: 1448, function: Function { arguments: [], shim_idx: 1449, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`. 627 - const ret = makeMutClosure(arg0, arg1, wasm.wasm_bindgen_1add006a0ed82fd3___closure__destroy___dyn_core_b125d98f3949a913___ops__function__FnMut_____Output________1_, wasm_bindgen_1add006a0ed82fd3___convert__closures_____invoke_______1_); 625 + imports.wbg.__wbindgen_cast_ba06f0048889102c = function(arg0, arg1) { 626 + // Cast intrinsic for `Closure(Closure { dtor_idx: 2202, function: Function { arguments: [Externref], shim_idx: 2203, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`. 627 + const ret = makeMutClosure(arg0, arg1, wasm.wasm_bindgen_1add006a0ed82fd3___closure__destroy___dyn_core_b125d98f3949a913___ops__function__FnMut__wasm_bindgen_1add006a0ed82fd3___JsValue____Output_______, wasm_bindgen_1add006a0ed82fd3___convert__closures_____invoke___wasm_bindgen_1add006a0ed82fd3___JsValue_____); 628 628 return ret; 629 629 }; 630 - imports.wbg.__wbindgen_cast_d976f3c9b97a6409 = function(arg0, arg1) { 631 - // Cast intrinsic for `Closure(Closure { dtor_idx: 272, function: Function { arguments: [NamedExternref("MessageEvent")], shim_idx: 273, ret: Unit, inner_ret: Some(Unit) }, mutable: false }) -> Externref`. 632 - const ret = makeClosure(arg0, arg1, wasm.wasm_bindgen_1add006a0ed82fd3___closure__destroy___dyn_core_b125d98f3949a913___ops__function__Fn__web_sys_8b37e013ecf136cb___features__gen_MessageEvent__MessageEvent____Output_______, wasm_bindgen_1add006a0ed82fd3___convert__closures_____invoke___web_sys_8b37e013ecf136cb___features__gen_MessageEvent__MessageEvent_____); 630 + imports.wbg.__wbindgen_cast_dd8568660229206d = function(arg0, arg1) { 631 + // Cast intrinsic for `Closure(Closure { dtor_idx: 1434, function: Function { arguments: [], shim_idx: 1435, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`. 632 + const ret = makeMutClosure(arg0, arg1, wasm.wasm_bindgen_1add006a0ed82fd3___closure__destroy___dyn_core_b125d98f3949a913___ops__function__FnMut_____Output________1_, wasm_bindgen_1add006a0ed82fd3___convert__closures_____invoke_______1_); 633 633 return ret; 634 634 }; 635 635 imports.wbg.__wbindgen_init_externref_table = function() {
+4
crates/weaver-app/src/components/author_list/author.css
··· 35 35 color: inherit; 36 36 } 37 37 38 + .author-block .embed-author { 39 + padding-bottom: 0px !important; 40 + } 41 + 38 42 .author-block:hover .embed-author-name { 39 43 color: var(--color-secondary); 40 44 }
+56 -14
crates/weaver-app/src/components/entry.rs
··· 81 81 } 82 82 } 83 83 84 + /// Calculate word count and estimated reading time (in minutes) for content 85 + pub fn calculate_reading_stats(content: &str) -> (usize, usize) { 86 + let word_count = content.split_whitespace().count(); 87 + let reading_time_mins = (word_count + 199) / 200; // ~200 wpm, rounded up 88 + (word_count, reading_time_mins.max(1)) 89 + } 90 + 84 91 /// Extract a plain-text preview from markdown content (first ~160 chars) 85 92 pub fn extract_preview(content: &str, max_len: usize) -> String { 86 93 // Simple extraction: skip markdown syntax, get plain text ··· 232 239 // Main content area 233 240 div { class: "entry-content-main notebook-content", 234 241 // Metadata header 235 - EntryMetadata { 236 - entry_view: entry_view.clone(), 237 - created_at: entry_record().created_at.clone(), 238 - entry_uri: entry_view.uri.clone().into_static(), 239 - book_title: Some(book_title()), 240 - ident: ident() 242 + { 243 + let (word_count, reading_time_mins) = calculate_reading_stats(&entry_record().content); 244 + rsx! { 245 + EntryMetadata { 246 + entry_view: entry_view.clone(), 247 + created_at: entry_record().created_at.clone(), 248 + entry_uri: entry_view.uri.clone().into_static(), 249 + book_title: Some(book_title()), 250 + ident: ident(), 251 + word_count: Some(word_count), 252 + reading_time_mins: Some(reading_time_mins) 253 + } 254 + } 241 255 } 242 256 243 257 // Rendered markdown ··· 339 353 html_buf 340 354 }); 341 355 356 + // Calculate reading stats 357 + let reading_stats = parsed_entry 358 + .as_ref() 359 + .map(|entry| calculate_reading_stats(&entry.content)); 360 + 342 361 rsx! { 343 362 div { class: "entry-card", 344 363 div { class: "entry-card-meta", ··· 388 407 } 389 408 } 390 409 } 410 + if let Some((words, mins)) = reading_stats { 411 + div { class: "entry-card-stats", 412 + span { class: "word-count", "{words} words" } 413 + span { class: "reading-time", "{mins} min read" } 414 + } 415 + } 391 416 } 392 417 } 393 418 } ··· 463 488 html_buf 464 489 }; 465 490 491 + // Calculate reading stats 492 + let (word_count, reading_time_mins) = calculate_reading_stats(&entry.content); 493 + 466 494 rsx! { 467 495 div { class: "entry-card feed-entry-card", 468 496 // Header: title (and date if no author) ··· 518 546 } 519 547 } 520 548 } 549 + } 550 + div { class: "entry-card-stats", 551 + span { class: "word-count", "{word_count} words" } 552 + span { class: "reading-time", "{reading_time_mins} min read" } 521 553 } 522 554 } 523 555 } 524 556 } 525 557 526 - /// Metadata header showing title, authors, date, tags 558 + /// Metadata header showing title, authors, date, tags, reading stats 527 559 #[component] 528 560 pub fn EntryMetadata( 529 561 entry_view: EntryView<'static>, ··· 531 563 entry_uri: AtUri<'static>, 532 564 book_title: Option<SmolStr>, 533 565 ident: AtIdentifier<'static>, 566 + #[props(default)] word_count: Option<usize>, 567 + #[props(default)] reading_time_mins: Option<usize>, 534 568 ) -> Element { 535 569 let navigator = use_navigator(); 536 570 ··· 592 626 } 593 627 } 594 628 595 - // Tags 596 - if let Some(ref tags) = entry_view.tags { 597 - div { class: "entry-tags", 598 - // TODO: Parse tags structure 599 - span { class: "meta-label", "Tags:" } 600 - for tag in tags.iter() { 601 - span { class: "entry-tag", "{tag}" } 629 + // Tags and reading stats on their own line 630 + div { class: "entry-meta-secondary", 631 + if let Some(ref tags) = entry_view.tags { 632 + div { class: "entry-tags", 633 + span { class: "meta-label", "Tags:" } 634 + for tag in tags.iter() { 635 + span { class: "entry-tag", "{tag}" } 636 + } 637 + } 638 + } 639 + 640 + if let (Some(words), Some(mins)) = (word_count, reading_time_mins) { 641 + div { class: "entry-reading-stats", 642 + span { class: "word-count", "{words} words" } 643 + span { class: "reading-time", "{mins} min read" } 602 644 } 603 645 } 604 646 }
+1 -1
crates/weaver-app/src/components/mod.rs
··· 9 9 #[allow(unused_imports)] 10 10 pub use entry::{ 11 11 ENTRY_CSS, EntryCard, EntryMarkdown, EntryMetadata, EntryOgMeta, EntryPage, FeedEntryCard, 12 - NavButton, extract_preview, 12 + NavButton, calculate_reading_stats, extract_preview, 13 13 }; 14 14 15 15 pub mod identity;
+41 -20
crates/weaver-app/src/views/entry.rs
··· 14 14 rkey: ReadSignal<SmolStr>, 15 15 ) -> Element { 16 16 use crate::components::{ 17 - ENTRY_CSS, EntryMarkdown, EntryMetadata, EntryOgMeta, NavButton, extract_preview, 17 + ENTRY_CSS, EntryMarkdown, EntryMetadata, EntryOgMeta, NavButton, calculate_reading_stats, extract_preview, 18 18 }; 19 19 use weaver_api::sh_weaver::actor::ProfileDataViewInner; 20 20 ··· 90 90 } 91 91 92 92 div { class: "entry-content-main notebook-content", 93 - EntryMetadata { 94 - entry_view: entry_view.clone(), 95 - created_at: entry_record.created_at.clone(), 96 - entry_uri: entry_view.uri.clone(), 97 - book_title: Some(book_title.clone()), 98 - ident: ident() 93 + { 94 + let (word_count, reading_time_mins) = calculate_reading_stats(&entry_record.content); 95 + rsx! { 96 + EntryMetadata { 97 + entry_view: entry_view.clone(), 98 + created_at: entry_record.created_at.clone(), 99 + entry_uri: entry_view.uri.clone(), 100 + book_title: Some(book_title.clone()), 101 + ident: ident(), 102 + word_count: Some(word_count), 103 + reading_time_mins: Some(reading_time_mins) 104 + } 105 + } 99 106 } 100 107 EntryMarkdown { content: entry_signal, ident } 101 108 } ··· 128 135 129 136 div { class: "entry-page-layout", 130 137 div { class: "entry-content-main notebook-content", 131 - EntryMetadata { 132 - entry_view: entry_view.clone(), 133 - created_at: entry_record.created_at.clone(), 134 - entry_uri: entry_view.uri.clone(), 135 - book_title: None, 136 - ident: ident() 138 + { 139 + let (word_count, reading_time_mins) = calculate_reading_stats(&entry_record.content); 140 + rsx! { 141 + EntryMetadata { 142 + entry_view: entry_view.clone(), 143 + created_at: entry_record.created_at.clone(), 144 + entry_uri: entry_view.uri.clone(), 145 + book_title: None, 146 + ident: ident(), 147 + word_count: Some(word_count), 148 + reading_time_mins: Some(reading_time_mins) 149 + } 150 + } 137 151 } 138 152 EntryMarkdown { content: entry_signal, ident } 139 153 } ··· 153 167 rkey: ReadSignal<SmolStr>, 154 168 ) -> Element { 155 169 use crate::components::{ 156 - ENTRY_CSS, EntryMarkdown, EntryMetadata, EntryOgMeta, NavButton, extract_preview, 170 + ENTRY_CSS, EntryMarkdown, EntryMetadata, EntryOgMeta, NavButton, calculate_reading_stats, extract_preview, 157 171 }; 158 172 use weaver_api::sh_weaver::actor::ProfileDataViewInner; 159 173 ··· 233 247 } 234 248 235 249 div { class: "entry-content-main notebook-content", 236 - EntryMetadata { 237 - entry_view: entry_view.clone(), 238 - created_at: entry_record.created_at.clone(), 239 - entry_uri: entry_view.uri.clone(), 240 - book_title: Some(book_title()), 241 - ident: ident() 250 + { 251 + let (word_count, reading_time_mins) = calculate_reading_stats(&entry_record.content); 252 + rsx! { 253 + EntryMetadata { 254 + entry_view: entry_view.clone(), 255 + created_at: entry_record.created_at.clone(), 256 + entry_uri: entry_view.uri.clone(), 257 + book_title: Some(book_title()), 258 + ident: ident(), 259 + word_count: Some(word_count), 260 + reading_time_mins: Some(reading_time_mins) 261 + } 262 + } 242 263 } 243 264 EntryMarkdown { content: entry_signal, ident } 244 265 }
+1 -1
crates/weaver-index/migrations/clickhouse/012_profiles_weaver_mv.sql
··· 8 8 coalesce(record.description, '') as description, 9 9 coalesce(record.avatar.ref.`$link`, '') as avatar_cid, 10 10 coalesce(record.banner.ref.`$link`, '') as banner_cid, 11 - coalesce(toDateTime64(record.createdAt, 3), toDateTime64(0, 3)) as created_at, 11 + parseDateTime64BestEffortOrZero(toString(record.createdAt), 3) as created_at, 12 12 event_time, 13 13 now64(3) as indexed_at, 14 14 if(operation = 'delete', event_time, toDateTime64(0, 3)) as deleted_at
+1 -1
crates/weaver-index/migrations/clickhouse/014_profiles_bsky_mv.sql
··· 8 8 coalesce(record.description, '') as description, 9 9 coalesce(record.avatar.ref.`$link`, '') as avatar_cid, 10 10 coalesce(record.banner.ref.`$link`, '') as banner_cid, 11 - coalesce(toDateTime64(record.createdAt, 3), toDateTime64(0, 3)) as created_at, 11 + parseDateTime64BestEffortOrZero(toString(record.createdAt), 3) as created_at, 12 12 event_time, 13 13 now64(3) as indexed_at, 14 14 if(operation = 'delete', event_time, toDateTime64(0, 3)) as deleted_at
+2 -2
crates/weaver-index/migrations/clickhouse/018_notebooks_mv.sql
··· 12 12 if(record.publishGlobal = true, 1, 0) as publish_global, 13 13 arrayMap(x -> JSONExtractString(x, 'did'), JSONExtractArrayRaw(toString(record), 'authors')) as author_dids, 14 14 length(JSONExtractArrayRaw(toString(record), 'entryList')) as entry_count, 15 - coalesce(toDateTime64(record.createdAt, 3), toDateTime64(0, 3)) as created_at, 16 - coalesce(toDateTime64(record.updatedAt, 3), toDateTime64(0, 3)) as updated_at, 15 + parseDateTime64BestEffortOrZero(toString(record.createdAt), 3) as created_at, 16 + parseDateTime64BestEffortOrZero(toString(record.updatedAt), 3) as updated_at, 17 17 event_time, 18 18 now64(3) as indexed_at, 19 19 if(operation = 'delete', event_time, toDateTime64(0, 3)) as deleted_at
+2 -2
crates/weaver-index/migrations/clickhouse/021_entries_mv.sql
··· 11 11 JSONExtract(toString(record), 'tags', 'Array(String)') as tags, 12 12 arrayMap(x -> JSONExtractString(x, 'did'), JSONExtractArrayRaw(toString(record), 'authors')) as author_dids, 13 13 substring(coalesce(record.content, ''), 1, 500) as content_preview, 14 - coalesce(toDateTime64(record.createdAt, 3), toDateTime64(0, 3)) as created_at, 15 - coalesce(toDateTime64(record.updatedAt, 3), toDateTime64(0, 3)) as updated_at, 14 + parseDateTime64BestEffortOrZero(toString(record.createdAt), 3) as created_at, 15 + parseDateTime64BestEffortOrZero(toString(record.updatedAt), 3) as updated_at, 16 16 event_time, 17 17 now64(3) as indexed_at, 18 18 if(operation = 'delete', event_time, toDateTime64(0, 3)) as deleted_at
+1 -1
crates/weaver-index/migrations/clickhouse/024_drafts_mv.sql
··· 5 5 did, 6 6 rkey, 7 7 cid, 8 - coalesce(toDateTime64(record.createdAt, 3), toDateTime64(0, 3)) as created_at, 8 + parseDateTime64BestEffortOrZero(toString(record.createdAt), 3) as created_at, 9 9 event_time, 10 10 now64(3) as indexed_at, 11 11 if(operation = 'delete', event_time, toDateTime64(0, 3)) as deleted_at
+1 -1
crates/weaver-index/migrations/clickhouse/027_edit_diffs_mv.sql
··· 60 60 if(length(toString(record.inlineDiff)) > 0, 1, 0) as has_inline_diff, 61 61 if(toString(record.snapshot.ref.`$link`) != '', 1, 0) as has_snapshot, 62 62 63 - coalesce(toDateTime64(record.createdAt, 3), event_time) as created_at, 63 + coalesce(parseDateTime64BestEffortOrNull(toString(record.createdAt), 3), event_time) as created_at, 64 64 event_time, 65 65 now64(3) as indexed_at, 66 66 if(operation = 'delete', event_time, toDateTime64(0, 3)) as deleted_at
+2 -2
crates/weaver-index/migrations/clickhouse/030_collab_invites_mv.sql
··· 14 14 toString(record.invitee) as invitee_did, 15 15 coalesce(toString(record.scope), '') as scope, 16 16 coalesce(toString(record.message), '') as message, 17 - coalesce(toDateTime64(record.expiresAt, 3), toDateTime64(0, 3)) as expires_at, 18 - coalesce(toDateTime64(record.createdAt, 3), event_time) as created_at, 17 + parseDateTime64BestEffortOrZero(toString(record.expiresAt), 3) as expires_at, 18 + coalesce(parseDateTime64BestEffortOrNull(toString(record.createdAt), 3), event_time) as created_at, 19 19 event_time, 20 20 now64(3) as indexed_at, 21 21 if(operation = 'delete', event_time, toDateTime64(0, 3)) as deleted_at
+1 -1
crates/weaver-index/migrations/clickhouse/032_collab_accepts_mv.sql
··· 16 16 splitByChar('/', replaceOne(toString(record.resource), 'at://', ''))[2] as resource_collection, 17 17 splitByChar('/', replaceOne(toString(record.resource), 'at://', ''))[3] as resource_rkey, 18 18 19 - coalesce(toDateTime64(record.createdAt, 3), event_time) as created_at, 19 + coalesce(parseDateTime64BestEffortOrNull(toString(record.createdAt), 3), event_time) as created_at, 20 20 event_time, 21 21 now64(3) as indexed_at, 22 22 if(operation = 'delete', event_time, toDateTime64(0, 3)) as deleted_at
+2 -2
crates/weaver-index/migrations/clickhouse/034_collab_sessions_mv.sql
··· 13 13 14 14 toString(record.nodeId) as node_id, 15 15 coalesce(toString(record.relayUrl), '') as relay_url, 16 - coalesce(toDateTime64(record.createdAt, 3), event_time) as created_at, 17 - coalesce(toDateTime64(record.expiresAt, 3), toDateTime64(0, 3)) as expires_at, 16 + coalesce(parseDateTime64BestEffortOrNull(toString(record.createdAt), 3), event_time) as created_at, 17 + parseDateTime64BestEffortOrZero(toString(record.expiresAt), 3) as expires_at, 18 18 event_time, 19 19 now64(3) as indexed_at, 20 20 if(operation = 'delete', event_time, toDateTime64(0, 3)) as deleted_at
+2
crates/weaver-index/src/clickhouse.rs
··· 1 1 mod client; 2 2 mod migrations; 3 + mod resilient_inserter; 3 4 mod schema; 4 5 5 6 pub use client::{Client, TableSize}; 6 7 pub use migrations::{DbObject, MigrationResult, Migrator, ObjectType}; 8 + pub use resilient_inserter::{InserterConfig, ResilientRecordInserter}; 7 9 pub use schema::{ 8 10 AccountRevState, FirehoseCursor, RawAccountEvent, RawEventDlq, RawIdentityEvent, 9 11 RawRecordInsert, Tables,
+326
crates/weaver-index/src/clickhouse/resilient_inserter.rs
··· 1 + use std::time::Duration; 2 + 3 + use clickhouse::inserter::{Inserter, Quantities}; 4 + use smol_str::{SmolStr, ToSmolStr}; 5 + use tracing::{debug, warn}; 6 + 7 + use super::schema::{RawEventDlq, RawRecordInsert, Tables}; 8 + use crate::error::{ClickHouseError, IndexError}; 9 + 10 + /// An inserter wrapper for RawRecordInsert that handles failures gracefully 11 + /// by retrying individual rows and sending failures to a dead-letter queue. 12 + /// 13 + /// This is specifically for raw record inserts since that's where untrusted 14 + /// input (arbitrary JSON from the firehose) enters the system. 15 + /// 16 + /// When a batch insert fails, this wrapper: 17 + /// 1. Creates a fresh inserter (since the old one is poisoned after error) 18 + /// 2. Retries each pending row individually 19 + /// 3. Sends failures to the DLQ with error details and the original row data 20 + /// 4. Continues processing without crashing 21 + pub struct ResilientRecordInserter { 22 + client: clickhouse::Client, 23 + inner: Inserter<RawRecordInsert>, 24 + pending: Vec<RawRecordInsert>, 25 + dlq: Inserter<RawEventDlq>, 26 + config: InserterConfig, 27 + } 28 + 29 + /// Configuration for the inserter thresholds 30 + #[derive(Clone)] 31 + pub struct InserterConfig { 32 + pub max_rows: u64, 33 + pub max_bytes: u64, 34 + pub period: Option<Duration>, 35 + pub period_bias: f64, 36 + } 37 + 38 + impl Default for InserterConfig { 39 + fn default() -> Self { 40 + Self { 41 + max_rows: 1000, 42 + max_bytes: 1_048_576, // 1MB 43 + period: Some(Duration::from_secs(1)), 44 + period_bias: 0.1, 45 + } 46 + } 47 + } 48 + 49 + impl ResilientRecordInserter { 50 + /// Create a new resilient inserter for raw records 51 + pub fn new(client: clickhouse::Client, config: InserterConfig) -> Self { 52 + let inner = Self::create_inserter(&client, &config); 53 + let dlq = Self::create_dlq_inserter(&client, &config); 54 + 55 + Self { 56 + client, 57 + inner, 58 + pending: Vec::new(), 59 + dlq, 60 + config, 61 + } 62 + } 63 + 64 + fn create_inserter( 65 + client: &clickhouse::Client, 66 + config: &InserterConfig, 67 + ) -> Inserter<RawRecordInsert> { 68 + let mut inserter = client 69 + .inserter(Tables::RAW_RECORDS) 70 + .with_max_rows(config.max_rows) 71 + .with_max_bytes(config.max_bytes) 72 + .with_period_bias(config.period_bias); 73 + 74 + if let Some(period) = config.period { 75 + inserter = inserter.with_period(Some(period)); 76 + } 77 + 78 + inserter 79 + } 80 + 81 + fn create_dlq_inserter( 82 + client: &clickhouse::Client, 83 + config: &InserterConfig, 84 + ) -> Inserter<RawEventDlq> { 85 + let mut inserter = client 86 + .inserter(Tables::RAW_EVENTS_DLQ) 87 + .with_max_rows(config.max_rows) 88 + .with_max_bytes(config.max_bytes) 89 + .with_period_bias(config.period_bias); 90 + 91 + if let Some(period) = config.period { 92 + inserter = inserter.with_period(Some(period)); 93 + } 94 + 95 + inserter 96 + } 97 + 98 + /// Write a row to the inserter 99 + /// 100 + /// The row is buffered both in the underlying inserter and in our 101 + /// pending queue for retry on failure. 102 + pub async fn write(&mut self, row: RawRecordInsert) -> Result<(), IndexError> { 103 + self.inner 104 + .write(&row) 105 + .await 106 + .map_err(|e| ClickHouseError::Insert { 107 + message: "write failed".into(), 108 + source: e, 109 + })?; 110 + 111 + self.pending.push(row); 112 + Ok(()) 113 + } 114 + 115 + /// Commit pending data if thresholds are met 116 + /// 117 + /// On success, clears the pending buffer if rows were actually flushed. 118 + /// On failure, retries rows individually and sends failures to DLQ. 119 + pub async fn commit(&mut self) -> Result<Quantities, IndexError> { 120 + match self.inner.commit().await { 121 + Ok(q) => { 122 + if q.rows > 0 { 123 + debug!( 124 + rows = q.rows, 125 + bytes = q.bytes, 126 + "batch committed successfully" 127 + ); 128 + self.pending.clear(); 129 + } 130 + Ok(q) 131 + } 132 + Err(e) => { 133 + warn!( 134 + error = ?e, 135 + pending = self.pending.len(), 136 + "batch commit failed, retrying individually" 137 + ); 138 + self.handle_batch_failure(e).await 139 + } 140 + } 141 + } 142 + 143 + /// Force commit all pending data 144 + /// 145 + /// Same semantics as commit() but unconditionally flushes. 146 + pub async fn force_commit(&mut self) -> Result<Quantities, IndexError> { 147 + match self.inner.force_commit().await { 148 + Ok(q) => { 149 + if q.rows > 0 { 150 + debug!( 151 + rows = q.rows, 152 + bytes = q.bytes, 153 + "batch force-committed successfully" 154 + ); 155 + self.pending.clear(); 156 + } 157 + Ok(q) 158 + } 159 + Err(e) => { 160 + warn!( 161 + error = ?e, 162 + pending = self.pending.len(), 163 + "batch force-commit failed, retrying individually" 164 + ); 165 + self.handle_batch_failure(e).await 166 + } 167 + } 168 + } 169 + 170 + /// End the inserter, flushing all remaining data 171 + /// 172 + /// Consumes self. On failure, retries rows individually. 173 + pub async fn end(mut self) -> Result<Quantities, IndexError> { 174 + // Take ownership of inner to end it 175 + let inner_result = self.inner.end().await; 176 + 177 + match inner_result { 178 + Ok(q) => { 179 + debug!( 180 + rows = q.rows, 181 + bytes = q.bytes, 182 + "inserter ended successfully" 183 + ); 184 + // Flush DLQ too 185 + self.dlq.end().await.map_err(|e| ClickHouseError::Insert { 186 + message: "DLQ end failed".into(), 187 + source: e, 188 + })?; 189 + Ok(q) 190 + } 191 + Err(e) => { 192 + warn!( 193 + error = ?e, 194 + pending = self.pending.len(), 195 + "inserter end failed, retrying individually" 196 + ); 197 + // Need a fresh inserter for recovery since old one is consumed 198 + self.inner = Self::create_inserter(&self.client, &self.config); 199 + let result = self.handle_batch_failure(e).await; 200 + // Flush DLQ 201 + self.dlq.end().await.map_err(|e| ClickHouseError::Insert { 202 + message: "DLQ end failed".into(), 203 + source: e, 204 + })?; 205 + result 206 + } 207 + } 208 + } 209 + 210 + /// Get statistics on pending (unbuffered) data in the underlying inserter 211 + pub fn pending(&self) -> &Quantities { 212 + self.inner.pending() 213 + } 214 + 215 + /// Get count of rows in our retry buffer 216 + pub fn pending_retry_count(&self) -> usize { 217 + self.pending.len() 218 + } 219 + 220 + /// Handle a batch failure by retrying rows individually 221 + async fn handle_batch_failure( 222 + &mut self, 223 + original_error: clickhouse::error::Error, 224 + ) -> Result<Quantities, IndexError> { 225 + // Take pending rows 226 + let rows = std::mem::take(&mut self.pending); 227 + let total = rows.len(); 228 + 229 + if rows.is_empty() { 230 + // Nothing to retry, just propagate the error context 231 + return Err(ClickHouseError::Insert { 232 + message: "batch failed with no pending rows".into(), 233 + source: original_error, 234 + } 235 + .into()); 236 + } 237 + 238 + // Create fresh inserter (old one is poisoned after error) 239 + self.inner = Self::create_inserter(&self.client, &self.config); 240 + 241 + let mut succeeded = 0u64; 242 + let mut failed = 0u64; 243 + 244 + for row in rows { 245 + match self.try_single_insert(&row).await { 246 + Ok(()) => { 247 + succeeded += 1; 248 + } 249 + Err(e) => { 250 + failed += 1; 251 + warn!( 252 + did = %row.did, 253 + collection = %row.collection, 254 + rkey = %row.rkey, 255 + seq = row.seq, 256 + error = ?e, 257 + "row insert failed, sending to DLQ" 258 + ); 259 + self.send_to_dlq(&row, &e).await?; 260 + } 261 + } 262 + } 263 + 264 + debug!(total, succeeded, failed, "batch failure recovery complete"); 265 + 266 + Ok(Quantities { 267 + rows: succeeded, 268 + bytes: 0, 269 + transactions: 0, 270 + }) 271 + } 272 + 273 + /// Try to insert a single row using a fresh one-shot inserter 274 + async fn try_single_insert( 275 + &self, 276 + row: &RawRecordInsert, 277 + ) -> Result<(), clickhouse::error::Error> { 278 + let mut inserter: Inserter<RawRecordInsert> = 279 + self.client.inserter(Tables::RAW_RECORDS).with_max_rows(1); 280 + 281 + inserter.write(row).await?; 282 + inserter.end().await?; 283 + Ok(()) 284 + } 285 + 286 + /// Send a failed row to the dead-letter queue 287 + async fn send_to_dlq( 288 + &mut self, 289 + row: &RawRecordInsert, 290 + error: &clickhouse::error::Error, 291 + ) -> Result<(), IndexError> { 292 + let raw_data = serde_json::to_string(row) 293 + .unwrap_or_else(|e| format!("{{\"serialization_error\": \"{}\"}}", e)); 294 + 295 + let dlq_row = RawEventDlq { 296 + event_type: row.operation.clone(), 297 + raw_data: raw_data.to_smolstr(), 298 + error_message: error.to_smolstr(), 299 + seq: row.seq, 300 + }; 301 + 302 + self.dlq 303 + .write(&dlq_row) 304 + .await 305 + .map_err(|e| ClickHouseError::Insert { 306 + message: "DLQ write failed".into(), 307 + source: e, 308 + })?; 309 + 310 + // Force commit DLQ to ensure failures are persisted immediately 311 + self.dlq 312 + .force_commit() 313 + .await 314 + .map_err(|e| ClickHouseError::Insert { 315 + message: "DLQ commit failed".into(), 316 + source: e, 317 + })?; 318 + 319 + Ok(()) 320 + } 321 + } 322 + 323 + #[cfg(test)] 324 + mod tests { 325 + // TODO: Add tests with mock clickhouse client 326 + }
+1 -1
crates/weaver-index/src/indexer.rs
··· 690 690 source: e, 691 691 })?; 692 692 records.commit().await.map_err(|e| ClickHouseError::Query { 693 - message: format!("record commit failed for id {}", event_id), 693 + message: format!("record commit failed for id {}:\n{}", event_id, json), 694 694 source: e, 695 695 })?; 696 696
+1 -1
crates/weaver-renderer/src/css.rs
··· 350 350 display: flex; 351 351 align-items: center; 352 352 gap: 0.75rem; 353 - margin-bottom: 0.75rem; 353 + padding-bottom: 0.5rem; 354 354 }} 355 355 356 356 .embed-avatar {{
+5 -8
docker-compose.yml
··· 3 3 # Build from local indigo checkout, or use pre-built image 4 4 tap: 5 5 container_name: weaver-tap 6 - build: 7 - # Build from local indigo checkout on sync-tool branch 8 - # git clone https://github.com/bluesky-social/indigo.git && git checkout sync-tool 9 - context: ../../Git_Repos/indigo 10 - dockerfile: cmd/tap/Dockerfile 6 + image: ghcr.io/bluesky-social/indigo/tap:latest 11 7 ports: 12 8 - "2480:2480" 13 9 volumes: ··· 18 14 TAP_DISABLE_ACKS: "false" 19 15 TAP_LOG_LEVEL: info 20 16 # Filter to weaver collections only 21 - TAP_SIGNAL_COLLECTION: sh.weaver.edit.root 22 - TAP_COLLECTION_FILTERS: "sh.weaver.*,app.bsky.actor.profile" 17 + #TAP_SIGNAL_COLLECTION: sh.weaver.edit.root 18 + TAP_SIGNAL_COLLECTION: sh.tangled.actor.profile 19 + TAP_COLLECTION_FILTERS: "sh.weaver.*,app.bsky.actor.profile,sh.tangled.*,pub.leaflet.*" 23 20 healthcheck: 24 21 test: ["CMD", "wget", "-q", "--spider", "http://localhost:2480/health"] 25 22 interval: 20s ··· 50 47 # Firehose connection (when INDEXER_SOURCE=firehose) 51 48 FIREHOSE_RELAY_URL: wss://bsky.network 52 49 # Collection filters 53 - INDEXER_COLLECTIONS: "sh.weaver.*,app.bsky.actor.profile" 50 + INDEXER_COLLECTIONS: "sh.weaver.*,app.bsky.actor.profile,sh.tangled.*,pub.leaflet.*" 54 51 depends_on: 55 52 tap: 56 53 condition: service_healthy