diff --git a/Cargo.lock b/Cargo.lock
index a969166..257f768 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -1496,9 +1496,9 @@ dependencies = [
[[package]]
name = "cookie_store"
-version = "0.21.1"
+version = "0.22.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "2eac901828f88a5241ee0600950ab981148a18f2f756900ffba1b125ca6a3ef9"
+checksum = "3fc4bff745c9b4c7fb1e97b25d13153da2bc7796260141df62378998d070207f"
dependencies = [
"cookie",
"document-features",
@@ -5567,7 +5567,7 @@ checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c"
[[package]]
name = "jacquard"
version = "0.9.4"
-source = "git+https://tangled.org/@nonbinary.computer/jacquard#bf8f91add8747b64b1d1d74af4b358960f69d6e7"
+source = "git+https://tangled.org/@nonbinary.computer/jacquard#87e15baeadf555a107a56c25c7f2e0008f46a5e2"
dependencies = [
"bytes",
"getrandom 0.2.16",
@@ -5599,7 +5599,7 @@ dependencies = [
[[package]]
name = "jacquard-api"
version = "0.9.2"
-source = "git+https://tangled.org/@nonbinary.computer/jacquard#bf8f91add8747b64b1d1d74af4b358960f69d6e7"
+source = "git+https://tangled.org/@nonbinary.computer/jacquard#87e15baeadf555a107a56c25c7f2e0008f46a5e2"
dependencies = [
"bon",
"bytes",
@@ -5618,7 +5618,7 @@ dependencies = [
[[package]]
name = "jacquard-axum"
version = "0.9.2"
-source = "git+https://tangled.org/@nonbinary.computer/jacquard#bf8f91add8747b64b1d1d74af4b358960f69d6e7"
+source = "git+https://tangled.org/@nonbinary.computer/jacquard#87e15baeadf555a107a56c25c7f2e0008f46a5e2"
dependencies = [
"axum",
"bytes",
@@ -5640,7 +5640,7 @@ dependencies = [
[[package]]
name = "jacquard-common"
version = "0.9.2"
-source = "git+https://tangled.org/@nonbinary.computer/jacquard#bf8f91add8747b64b1d1d74af4b358960f69d6e7"
+source = "git+https://tangled.org/@nonbinary.computer/jacquard#87e15baeadf555a107a56c25c7f2e0008f46a5e2"
dependencies = [
"base64 0.22.1",
"bon",
@@ -5688,7 +5688,7 @@ dependencies = [
[[package]]
name = "jacquard-derive"
version = "0.9.4"
-source = "git+https://tangled.org/@nonbinary.computer/jacquard#bf8f91add8747b64b1d1d74af4b358960f69d6e7"
+source = "git+https://tangled.org/@nonbinary.computer/jacquard#87e15baeadf555a107a56c25c7f2e0008f46a5e2"
dependencies = [
"heck 0.5.0",
"jacquard-lexicon",
@@ -5700,7 +5700,7 @@ dependencies = [
[[package]]
name = "jacquard-identity"
version = "0.9.2"
-source = "git+https://tangled.org/@nonbinary.computer/jacquard#bf8f91add8747b64b1d1d74af4b358960f69d6e7"
+source = "git+https://tangled.org/@nonbinary.computer/jacquard#87e15baeadf555a107a56c25c7f2e0008f46a5e2"
dependencies = [
"bon",
"bytes",
@@ -5729,7 +5729,7 @@ dependencies = [
[[package]]
name = "jacquard-lexicon"
version = "0.9.2"
-source = "git+https://tangled.org/@nonbinary.computer/jacquard#bf8f91add8747b64b1d1d74af4b358960f69d6e7"
+source = "git+https://tangled.org/@nonbinary.computer/jacquard#87e15baeadf555a107a56c25c7f2e0008f46a5e2"
dependencies = [
"cid",
"dashmap 6.1.0",
@@ -5755,7 +5755,7 @@ dependencies = [
[[package]]
name = "jacquard-oauth"
version = "0.9.2"
-source = "git+https://tangled.org/@nonbinary.computer/jacquard#bf8f91add8747b64b1d1d74af4b358960f69d6e7"
+source = "git+https://tangled.org/@nonbinary.computer/jacquard#87e15baeadf555a107a56c25c7f2e0008f46a5e2"
dependencies = [
"base64 0.22.1",
"bytes",
@@ -5788,7 +5788,7 @@ dependencies = [
[[package]]
name = "jacquard-repo"
version = "0.9.4"
-source = "git+https://tangled.org/@nonbinary.computer/jacquard#bf8f91add8747b64b1d1d74af4b358960f69d6e7"
+source = "git+https://tangled.org/@nonbinary.computer/jacquard#87e15baeadf555a107a56c25c7f2e0008f46a5e2"
dependencies = [
"bytes",
"cid",
@@ -6746,7 +6746,7 @@ dependencies = [
[[package]]
name = "mini-moka"
version = "0.10.99"
-source = "git+https://tangled.org/@nonbinary.computer/jacquard#bf8f91add8747b64b1d1d74af4b358960f69d6e7"
+source = "git+https://tangled.org/@nonbinary.computer/jacquard#87e15baeadf555a107a56c25c7f2e0008f46a5e2"
dependencies = [
"crossbeam-channel",
"crossbeam-utils",
@@ -8653,9 +8653,9 @@ checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58"
[[package]]
name = "reqwest"
-version = "0.12.25"
+version = "0.12.26"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b6eff9328d40131d43bd911d42d79eb6a47312002a4daefc9e37f17e74a7701a"
+checksum = "3b4c14b2d9afca6a60277086b0cc6a6ae0b568f6f7916c943a8cdc79f8be240f"
dependencies = [
"base64 0.22.1",
"bytes",
diff --git a/crates/weaver-app/Dockerfile b/crates/weaver-app/Dockerfile
index 50f3476..9982bd5 100644
--- a/crates/weaver-app/Dockerfile
+++ b/crates/weaver-app/Dockerfile
@@ -32,7 +32,7 @@ RUN cp crates/weaver-app/.env-prod crates/weaver-app/.env
# Build wasm workers
RUN RUSTFLAGS='--cfg getrandom_backend="wasm_js"' cargo build -p weaver-app --bin editor_worker --bin embed_worker \
--target wasm32-unknown-unknown --release \
- --no-default-features --features "web"
+ --no-default-features --features "web, use-index"
# Run wasm-bindgen on workers
RUN wasm-bindgen target/wasm32-unknown-unknown/release/editor_worker.wasm \
diff --git a/crates/weaver-app/src/blobcache.rs b/crates/weaver-app/src/blobcache.rs
index 02bb274..d42be00 100644
--- a/crates/weaver-app/src/blobcache.rs
+++ b/crates/weaver-app/src/blobcache.rs
@@ -136,15 +136,29 @@ impl BlobCache {
.build(),
)
.await
- .map_err(|e| CapturedError::from_display(format_smolstr!("Failed to fetch entry: {}", e).as_str().to_string()))?;
+ .map_err(|e| {
+ CapturedError::from_display(
+ format_smolstr!("Failed to fetch entry: {}", e)
+ .as_str()
+ .to_string(),
+ )
+ })?;
- let record = resp
- .into_output()
- .map_err(|e| CapturedError::from_display(format_smolstr!("Failed to parse entry: {}", e).as_str().to_string()))?;
+ let record = resp.into_output().map_err(|e| {
+ CapturedError::from_display(
+ format_smolstr!("Failed to parse entry: {}", e)
+ .as_str()
+ .to_string(),
+ )
+ })?;
// Parse the entry
let entry: Entry = jacquard::from_data(&record.value).map_err(|e| {
- CapturedError::from_display(format_smolstr!("Failed to deserialize entry: {}", e).as_str().to_string())
+ CapturedError::from_display(
+ format_smolstr!("Failed to deserialize entry: {}", e)
+ .as_str()
+ .to_string(),
+ )
})?;
// Find the image by name
@@ -159,7 +173,11 @@ impl BlobCache {
})
.map(|img| img.image.blob().cid().clone().into_static())
.ok_or_else(|| {
- CapturedError::from_display(format_smolstr!("Image '{}' not found in entry", name).as_str().to_string())
+ CapturedError::from_display(
+ format_smolstr!("Image '{}' not found in entry", name)
+ .as_str()
+ .to_string(),
+ )
})?;
// Check cache first
@@ -199,16 +217,28 @@ impl BlobCache {
)
.await
.map_err(|e| {
- CapturedError::from_display(format_smolstr!("Failed to fetch PublishedBlob: {}", e).as_str().to_string())
+ CapturedError::from_display(
+ format_smolstr!("Failed to fetch PublishedBlob: {}", e)
+ .as_str()
+ .to_string(),
+ )
})?;
let record = resp.into_output().map_err(|e| {
- CapturedError::from_display(format_smolstr!("Failed to parse PublishedBlob: {}", e).as_str().to_string())
+ CapturedError::from_display(
+ format_smolstr!("Failed to parse PublishedBlob: {}", e)
+ .as_str()
+ .to_string(),
+ )
})?;
// Parse the PublishedBlob
let published: PublishedBlob = jacquard::from_data(&record.value).map_err(|e| {
- CapturedError::from_display(format_smolstr!("Failed to deserialize PublishedBlob: {}", e).as_str().to_string())
+ CapturedError::from_display(
+ format_smolstr!("Failed to deserialize PublishedBlob: {}", e)
+ .as_str()
+ .to_string(),
+ )
})?;
// Get CID from the upload blob ref
@@ -248,14 +278,24 @@ impl BlobCache {
.get_notebook_by_key(notebook_key)
.await?
.ok_or_else(|| {
- CapturedError::from_display(format_smolstr!("Notebook '{}' not found", notebook_key).as_str().to_string())
+ CapturedError::from_display(
+ format_smolstr!("Notebook '{}' not found", notebook_key)
+ .as_str()
+ .to_string(),
+ )
})?;
let (view, entry_refs) = notebook.as_ref();
// Get the DID from the notebook URI for blob fetching
let notebook_did = jacquard::types::aturi::AtUri::new(view.uri.as_ref())
- .map_err(|e| CapturedError::from_display(format_smolstr!("Invalid notebook URI: {}", e).as_str().to_string()))?
+ .map_err(|e| {
+ CapturedError::from_display(
+ format_smolstr!("Invalid notebook URI: {}", e)
+ .as_str()
+ .to_string(),
+ )
+ })?
.authority()
.clone()
.into_static();
@@ -277,8 +317,14 @@ impl BlobCache {
let client = self.fetcher.get_client();
for entry_ref in entry_refs {
// Parse the entry URI to get rkey
- let entry_uri = jacquard::types::aturi::AtUri::new(entry_ref.uri.as_ref())
- .map_err(|e| CapturedError::from_display(format_smolstr!("Invalid entry URI: {}", e).as_str().to_string()))?;
+ let entry_uri = jacquard::types::aturi::AtUri::new(entry_ref.entry.uri.as_ref())
+ .map_err(|e| {
+ CapturedError::from_display(
+ format_smolstr!("Invalid entry URI: {}", e)
+ .as_str()
+ .to_string(),
+ )
+ })?;
let rkey = entry_uri
.rkey()
.ok_or_else(|| CapturedError::from_display("Entry URI missing rkey"))?;
@@ -319,10 +365,15 @@ impl BlobCache {
}
}
- Err(CapturedError::from_display(format_smolstr!(
- "Image '{}' not found in notebook '{}'",
- image_name, notebook_key
- ).as_str().to_string()))
+ Err(CapturedError::from_display(
+ format_smolstr!(
+ "Image '{}' not found in notebook '{}'",
+ image_name,
+ notebook_key
+ )
+ .as_str()
+ .to_string(),
+ ))
}
/// Insert bytes directly into cache (for pre-warming after upload)
diff --git a/crates/weaver-app/src/components/editor/writer.rs b/crates/weaver-app/src/components/editor/writer.rs
index 9aa5b17..2a049f1 100644
--- a/crates/weaver-app/src/components/editor/writer.rs
+++ b/crates/weaver-app/src/components/editor/writer.rs
@@ -1437,7 +1437,7 @@ impl<
self.last_char_offset = closing_char_end;
}
- Html(html) | InlineHtml(html) => {
+ Html(html) => {
// Track offset mapping for raw HTML
let char_start = self.last_char_offset;
let html_char_len = html.chars().count();
@@ -1449,6 +1449,18 @@ impl<
self.record_mapping(range.clone(), char_start..char_end);
self.last_char_offset = char_end;
}
+ InlineHtml(html) => {
+ // Track offset mapping for raw HTML
+ let char_start = self.last_char_offset;
+ let html_char_len = html.chars().count();
+ let char_end = char_start + html_char_len;
+ self.write(r#""#)?;
+ self.write(&html)?;
+ self.write(" ")?;
+ // Record mapping for inline HTML
+ self.record_mapping(range.clone(), char_start..char_end);
+ self.last_char_offset = char_end;
+ }
SoftBreak => {
// Emit for visual line break, plus a space for cursor positioning.
// This space maps to the \n so the cursor can land here when navigating.
@@ -1743,7 +1755,46 @@ impl<
// Emit the opening tag
match tag {
- Tag::HtmlBlock => Ok(()),
+ // HTML blocks get their own paragraph to try and corral them better
+ Tag::HtmlBlock => {
+ // Record paragraph start for boundary tracking
+ // BUT skip if inside a list - list owns the paragraph boundary
+ if self.list_depth == 0 {
+ self.current_paragraph_start =
+ Some((self.last_byte_offset, self.last_char_offset));
+ }
+ let node_id = self.gen_node_id();
+
+ if self.end_newline {
+ write!(
+ &mut self.writer,
+ r#"
"#,
+ node_id
+ )?;
+ } else {
+ write!(
+ &mut self.writer,
+ r#"\n
"#,
+ node_id
+ )?;
+ }
+ self.begin_node(node_id.clone());
+
+ // Map the start position of the paragraph (before any content)
+ // This allows cursor to be placed at the very beginning
+ let para_start_char = self.last_char_offset;
+ let mapping = OffsetMapping {
+ byte_range: range.start..range.start,
+ char_range: para_start_char..para_start_char,
+ node_id,
+ char_offset_in_node: 0,
+ child_index: Some(0), // position before first child
+ utf16_len: 0,
+ };
+ self.offset_maps.push(mapping);
+
+ Ok(())
+ }
Tag::Paragraph => {
// Record paragraph start for boundary tracking
// BUT skip if inside a list - list owns the paragraph boundary
@@ -2454,7 +2505,20 @@ impl<
// Emit tag HTML first
let result = match tag {
- TagEnd::HtmlBlock => Ok(()),
+ TagEnd::HtmlBlock => {
+ // Record paragraph end for boundary tracking
+ // BUT skip if inside a list - list owns the paragraph boundary
+ if self.list_depth == 0 {
+ if let Some((byte_start, char_start)) = self.current_paragraph_start.take() {
+ let byte_range = byte_start..self.last_byte_offset;
+ let char_range = char_start..self.last_char_offset;
+ self.paragraph_ranges.push((byte_range, char_range));
+ }
+ }
+
+ self.end_node();
+ self.write("
\n")
+ }
TagEnd::Paragraph => {
// Record paragraph end for boundary tracking
// BUT skip if inside a list - list owns the paragraph boundary
diff --git a/crates/weaver-app/src/components/identity.rs b/crates/weaver-app/src/components/identity.rs
index 446d03c..6f2bdca 100644
--- a/crates/weaver-app/src/components/identity.rs
+++ b/crates/weaver-app/src/components/identity.rs
@@ -6,14 +6,83 @@ use dioxus::prelude::*;
use jacquard::{smol_str::SmolStr, types::ident::AtIdentifier};
use std::collections::HashSet;
use weaver_api::com_atproto::repo::strong_ref::StrongRef;
-use weaver_api::sh_weaver::notebook::{EntryView, NotebookView, entry::Entry};
+use weaver_api::sh_weaver::notebook::{
+ BookEntryRef, BookEntryView, EntryView, NotebookView, entry::Entry,
+};
+
+/// Constructs BookEntryViews from notebook entry refs and all available entries.
+///
+/// Matches StrongRefs by URI to find the corresponding EntryView,
+/// then builds BookEntryView with index and prev/next navigation refs.
+fn build_book_entry_views(
+ entry_refs: &[StrongRef<'static>],
+ all_entries: &[(EntryView<'static>, Entry<'static>)],
+) -> Vec> {
+ use jacquard::IntoStatic;
+
+ // Build a lookup map for faster matching
+ let entry_map: std::collections::HashMap<&str, &EntryView<'static>> = all_entries
+ .iter()
+ .map(|(view, _)| (view.uri.as_ref(), view))
+ .collect();
+
+ let mut views = Vec::with_capacity(entry_refs.len());
+
+ for (idx, strong_ref) in entry_refs.iter().enumerate() {
+ let Some(entry_view) = entry_map.get(strong_ref.uri.as_ref()).copied() else {
+ continue;
+ };
+
+ // Build prev ref (if not first)
+ let prev = if idx > 0 {
+ entry_refs
+ .get(idx - 1)
+ .and_then(|prev_ref| entry_map.get(prev_ref.uri.as_ref()).copied())
+ .map(|prev_view| {
+ BookEntryRef::new()
+ .entry(prev_view.clone())
+ .build()
+ .into_static()
+ })
+ } else {
+ None
+ };
+
+ // Build next ref (if not last)
+ let next = if idx + 1 < entry_refs.len() {
+ entry_refs
+ .get(idx + 1)
+ .and_then(|next_ref| entry_map.get(next_ref.uri.as_ref()).copied())
+ .map(|next_view| {
+ BookEntryRef::new()
+ .entry(next_view.clone())
+ .build()
+ .into_static()
+ })
+ } else {
+ None
+ };
+
+ views.push(
+ BookEntryView::new()
+ .entry(entry_view.clone())
+ .index(idx as i64)
+ .maybe_prev(prev)
+ .maybe_next(next)
+ .build()
+ .into_static(),
+ );
+ }
+
+ views
+}
/// A single item in the profile timeline (either notebook or standalone entry)
#[derive(Clone, PartialEq)]
pub enum ProfileTimelineItem {
Notebook {
notebook: NotebookView<'static>,
- entries: Vec>,
+ entries: Vec>,
/// Most recent entry's created_at for sorting
sort_date: jacquard::types::string::Datetime,
},
@@ -93,9 +162,16 @@ pub fn RepositoryIndex(ident: ReadSignal>) -> Element {
let auth_state = use_context::>();
// Use client-only versions to avoid SSR issues with concurrent server futures
- let (_profile_res, profile) = data::use_profile_data_client(ident);
- let (_notebooks_res, notebooks) = data::use_notebooks_for_did_client(ident);
- let (_entries_res, all_entries) = data::use_entries_for_did_client(ident);
+ let (_profile_res, profile) = data::use_profile_data(ident);
+ let (_notebooks_res, notebooks) = data::use_notebooks_for_did(ident);
+ let (_entries_res, all_entries) = data::use_entries_for_did(ident);
+
+ #[cfg(feature = "fullstack-server")]
+ {
+ _profile_res?;
+ _notebooks_res?;
+ _entries_res?;
+ }
// Check if viewing own profile
let is_own_profile = use_memo(move || {
@@ -171,12 +247,13 @@ pub fn RepositoryIndex(ident: ReadSignal>) -> Element {
if let Some(all_ents) = ents.as_ref() {
for (notebook, entry_refs) in nbs {
if is_pinned(notebook.uri.as_ref(), pinned_set) {
- let sort_date = entry_refs
+ let book_entries = build_book_entry_views(entry_refs, all_ents);
+ let sort_date = book_entries
.iter()
- .filter_map(|r| {
+ .filter_map(|bev| {
all_ents
.iter()
- .find(|(v, _)| v.uri.as_ref() == r.uri.as_ref())
+ .find(|(v, _)| v.uri.as_ref() == bev.entry.uri.as_ref())
})
.map(|(_, entry)| entry.created_at.clone())
.max()
@@ -184,7 +261,7 @@ pub fn RepositoryIndex(ident: ReadSignal>) -> Element {
items.push(ProfileTimelineItem::Notebook {
notebook: notebook.clone(),
- entries: entry_refs.clone(),
+ entries: book_entries,
sort_date,
});
}
@@ -231,12 +308,13 @@ pub fn RepositoryIndex(ident: ReadSignal>) -> Element {
if let Some(all_ents) = ents.as_ref() {
for (notebook, entry_refs) in nbs {
if !is_pinned(notebook.uri.as_ref(), pinned_set) {
- let sort_date = entry_refs
+ let book_entries = build_book_entry_views(entry_refs, all_ents);
+ let sort_date = book_entries
.iter()
- .filter_map(|r| {
+ .filter_map(|bev| {
all_ents
.iter()
- .find(|(v, _)| v.uri.as_ref() == r.uri.as_ref())
+ .find(|(v, _)| v.uri.as_ref() == bev.entry.uri.as_ref())
})
.map(|(_, entry)| entry.created_at.clone())
.max()
@@ -244,7 +322,7 @@ pub fn RepositoryIndex(ident: ReadSignal>) -> Element {
items.push(ProfileTimelineItem::Notebook {
notebook: notebook.clone(),
- entries: entry_refs.clone(),
+ entries: book_entries,
sort_date,
});
}
@@ -361,7 +439,7 @@ pub fn RepositoryIndex(ident: ReadSignal>) -> Element {
class: "pinned-item",
NotebookCard {
notebook: notebook.clone(),
- entry_refs: entries.clone(),
+ entries: entries.clone(),
is_pinned: true,
profile_ident: Some(ident()),
}
@@ -409,7 +487,7 @@ pub fn RepositoryIndex(ident: ReadSignal>) -> Element {
key: "notebook-{notebook.cid}",
NotebookCard {
notebook: notebook.clone(),
- entry_refs: entries.clone(),
+ entries: entries.clone(),
is_pinned: false,
profile_ident: Some(ident()),
}
@@ -458,11 +536,14 @@ fn NotebookEntryPreview(
let entry_view = &book_entry_view.entry;
- let entry_title = entry_view.title.as_ref()
+ let entry_title = entry_view
+ .title
+ .as_ref()
.map(|t| t.as_ref())
.unwrap_or("Untitled");
- let entry_path = entry_view.path
+ let entry_path = entry_view
+ .path
.as_ref()
.map(|p| p.as_ref().to_string())
.unwrap_or_else(|| entry_title.to_string());
@@ -476,7 +557,8 @@ fn NotebookEntryPreview(
html_buf
});
- let created_at = parsed_entry.as_ref()
+ let created_at = parsed_entry
+ .as_ref()
.map(|entry| entry.created_at.as_ref().format("%B %d, %Y").to_string());
let entry_uri = entry_view.uri.clone().into_static();
@@ -529,11 +611,12 @@ fn NotebookEntryPreview(
#[component]
pub fn NotebookCard(
notebook: NotebookView<'static>,
- entry_refs: Vec>,
+ entries: Vec>,
#[props(default = false)] is_pinned: bool,
#[props(default)] show_author: Option,
/// Profile identity for context-aware author visibility (hides single author on their own profile)
- #[props(default)] profile_ident: Option>,
+ #[props(default)]
+ profile_ident: Option>,
#[props(default)] on_pinned_changed: Option>,
#[props(default)] on_deleted: Option>,
) -> Element {
@@ -575,19 +658,6 @@ pub fn NotebookCard(
let ident = notebook.uri.authority().clone().into_static();
let book_title: SmolStr = notebook_path.clone().into();
- // Fetch all entries to get first/last
- let ident_for_fetch = ident.clone();
- let book_title_for_fetch = book_title.clone();
- let entries = use_resource(use_reactive!(|(ident_for_fetch, book_title_for_fetch)| {
- let fetcher = fetcher.clone();
- async move {
- fetcher
- .list_notebook_entries(ident_for_fetch, book_title_for_fetch)
- .await
- .ok()
- .flatten()
- }
- }));
rsx! {
div { class: "notebook-card",
div { class: "notebook-card-container",
@@ -642,16 +712,17 @@ pub fn NotebookCard(
}
// Entry previews section
- if let Some(Some(entry_list)) = entries() {
div { class: "notebook-card-previews",
{
use jacquard::from_data;
use weaver_api::sh_weaver::notebook::entry::Entry;
+ tracing::info!("rendering entries: {:?}", entries.iter().map(|e|
+ e.entry.uri.as_ref()).collect::>());
- if entry_list.len() <= 5 {
+ if entries.len() <= 5 {
// Show all entries if 5 or fewer
rsx! {
- for entry_view in entry_list.iter() {
+ for entry_view in entries.iter() {
NotebookEntryPreview {
book_entry_view: entry_view.clone(),
ident: ident.clone(),
@@ -662,7 +733,7 @@ pub fn NotebookCard(
} else {
// Show first, interstitial, and last
rsx! {
- if let Some(first_entry) = entry_list.first() {
+ if let Some(first_entry) = entries.first() {
NotebookEntryPreview {
book_entry_view: first_entry.clone(),
ident: ident.clone(),
@@ -673,7 +744,7 @@ pub fn NotebookCard(
// Interstitial showing count
{
- let middle_count = entry_list.len().saturating_sub(2);
+ let middle_count = entries.len().saturating_sub(2);
rsx! {
div { class: "notebook-entry-interstitial",
"... {middle_count} more "
@@ -683,7 +754,7 @@ pub fn NotebookCard(
}
}
- if let Some(last_entry) = entry_list.last() {
+ if let Some(last_entry) = entries.last() {
NotebookEntryPreview {
book_entry_view: last_entry.clone(),
ident: ident.clone(),
@@ -695,7 +766,7 @@ pub fn NotebookCard(
}
}
}
- }
+
if let Some(ref tags) = notebook.tags {
if !tags.is_empty() {
diff --git a/crates/weaver-app/src/components/login.rs b/crates/weaver-app/src/components/login.rs
index 3edf5a6..16e938c 100644
--- a/crates/weaver-app/src/components/login.rs
+++ b/crates/weaver-app/src/components/login.rs
@@ -112,6 +112,14 @@ pub fn LoginModal(open: Signal) -> Element {
if let Some(err) = error() {
div { class: "error", "{err}" }
}
+ Button {
+ r#type: "submit",
+ disabled: is_loading(),
+ onclick: move |_| {
+ submit_closure2();
+ },
+ if is_loading() { "Authenticating..." } else { "Sign In" }
+ }
Button {
r#type: "button",
onclick: move |_| {
@@ -121,14 +129,7 @@ pub fn LoginModal(open: Signal) -> Element {
variant: ButtonVariant::Secondary,
"Cancel"
}
- Button {
- r#type: "submit",
- disabled: is_loading(),
- onclick: move |_| {
- submit_closure2();
- },
- if is_loading() { "Authenticating..." } else { "Sign In" }
- }
+
}
}
diff --git a/crates/weaver-app/src/data.rs b/crates/weaver-app/src/data.rs
index 650b6b8..1d96bf6 100644
--- a/crates/weaver-app/src/data.rs
+++ b/crates/weaver-app/src/data.rs
@@ -977,7 +977,7 @@ pub fn use_notebook(
book_title: ReadSignal,
) -> (
Result>, RenderError>,
- Memo, Vec>)>>,
+ Memo, Vec>)>>,
) {
let fetcher = use_context::();
let res = use_server_future(use_reactive!(|(ident, book_title)| {
@@ -995,7 +995,7 @@ pub fn use_notebook(
let memo = use_memo(use_reactive!(|res| {
let res = res.as_ref().ok()?;
if let Some(Some(value)) = &*res.read() {
- jacquard::from_json_value::<(NotebookView, Vec)>(value.clone()).ok()
+ jacquard::from_json_value::<(NotebookView, Vec)>(value.clone()).ok()
} else {
None
}
@@ -1009,8 +1009,8 @@ pub fn use_notebook(
ident: ReadSignal>,
book_title: ReadSignal,
) -> (
- Resource, Vec>)>>,
- Memo, Vec>)>>,
+ Resource, Vec>)>>,
+ Memo, Vec>)>>,
) {
let fetcher = use_context::();
let res = use_resource(move || {
diff --git a/crates/weaver-app/src/fetch.rs b/crates/weaver-app/src/fetch.rs
index 9893da8..91b54b2 100644
--- a/crates/weaver-app/src/fetch.rs
+++ b/crates/weaver-app/src/fetch.rs
@@ -9,6 +9,8 @@ use jacquard::client::AgentError;
use jacquard::client::AgentKind;
use jacquard::error::ClientError;
use jacquard::error::XrpcResult;
+use jacquard::from_data;
+use jacquard::from_data_owned;
use jacquard::identity::JacquardResolver;
use jacquard::identity::lexicon_resolver::{
LexiconResolutionError, LexiconSchemaResolver, ResolvedLexiconSchema,
@@ -46,6 +48,7 @@ use weaver_api::{
};
use weaver_common::WeaverError;
use weaver_common::WeaverExt;
+use weaver_common::agent::title_matches;
#[derive(Debug, Clone, Deserialize, Serialize)]
struct UfosRecord {
@@ -363,7 +366,7 @@ pub struct Fetcher {
#[cfg(feature = "server")]
book_cache: cache_impl::Cache<
(AtIdentifier<'static>, SmolStr),
- Arc<(NotebookView<'static>, Vec>)>,
+ Arc<(NotebookView<'static>, Vec>)>,
>,
/// Maps notebook title OR path to ident (book_cache accepts either as key)
#[cfg(feature = "server")]
@@ -453,7 +456,7 @@ impl Fetcher {
&self,
ident: AtIdentifier<'static>,
title: SmolStr,
- ) -> Result, Vec>)>>> {
+ ) -> Result, Vec>)>>> {
#[cfg(feature = "server")]
if let Some(cached) = cache_impl::get(&self.book_cache, &(ident.clone(), title.clone())) {
return Ok(Some(cached));
@@ -490,7 +493,7 @@ impl Fetcher {
pub async fn get_notebook_by_key(
&self,
key: &str,
- ) -> Result, Vec>)>>> {
+ ) -> Result, Vec>)>>> {
let key: SmolStr = key.into();
// Check cache first (key could be title or path)
@@ -500,11 +503,16 @@ impl Fetcher {
// Fallback: query UFOS and populate caches
let notebooks = self.fetch_notebooks_from_ufos().await?;
- Ok(notebooks.into_iter().find(|arc| {
+ let notebook = notebooks.into_iter().find(|arc| {
let (view, _) = arc.as_ref();
view.title.as_deref() == Some(key.as_str())
|| view.path.as_deref() == Some(key.as_str())
- }))
+ });
+ if let Some(notebook) = notebook {
+ let ident = notebook.0.uri.authority().clone().into_static();
+ return self.get_notebook(ident, key).await;
+ }
+ Ok(None)
}
pub async fn get_entry(
@@ -522,13 +530,19 @@ impl Fetcher {
if let Some(result) = self.get_notebook(ident.clone(), book_title).await? {
let (notebook, entries) = result.as_ref();
- let client = self.get_client();
- if let Some(entry) = client
- .entry_by_title(notebook, entries.as_ref(), &entry_title)
- .await
- .map_err(|e| dioxus::CapturedError::from_display(e))?
- {
- let stored = Arc::new(entry);
+ if let Some(entry) = entries.iter().find(|e| {
+ if let Some(path) = e.entry.path.as_deref() {
+ path == entry_title.as_str()
+ } else if let Some(title) = e.entry.title.as_deref() {
+ title_matches(title, &entry_title)
+ } else {
+ false
+ }
+ }) {
+ let stored = Arc::new((
+ entry.clone(),
+ from_data_owned(entry.entry.record.clone()).expect("should deserialize"),
+ ));
#[cfg(feature = "server")]
cache_impl::insert(&self.entry_cache, (ident, entry_title), stored.clone());
Ok(Some(stored))
@@ -583,10 +597,13 @@ impl Fetcher {
#[cfg(feature = "server")]
{
cache_impl::insert(&self.notebook_key_cache, title.clone(), ident.clone());
+ #[cfg(not(feature = "use-index"))]
cache_impl::insert(&self.book_cache, (ident.clone(), title), result.clone());
+
if let Some(path) = result.0.path.as_ref() {
let path: SmolStr = path.as_ref().into();
cache_impl::insert(&self.notebook_key_cache, path.clone(), ident.clone());
+ #[cfg(not(feature = "use-index"))]
cache_impl::insert(&self.book_cache, (ident, path), result.clone());
}
}
@@ -640,6 +657,8 @@ impl Fetcher {
{
// Cache by title
cache_impl::insert(&self.notebook_key_cache, title.clone(), ident.clone());
+
+ #[cfg(not(feature = "use-index"))]
cache_impl::insert(
&self.book_cache,
(ident.clone(), title),
@@ -653,6 +672,8 @@ impl Fetcher {
path.clone(),
ident.clone(),
);
+
+ #[cfg(not(feature = "use-index"))]
cache_impl::insert(&self.book_cache, (ident, path), result.clone());
}
}
@@ -815,11 +836,6 @@ impl Fetcher {
title.clone(),
ident_static.clone(),
);
- cache_impl::insert(
- &self.book_cache,
- (ident_static.clone(), title),
- result.clone(),
- );
if let Some(path) = result.0.path.as_ref() {
let path: SmolStr = path.as_ref().into();
cache_impl::insert(
@@ -827,7 +843,6 @@ impl Fetcher {
path.clone(),
ident_static.clone(),
);
- cache_impl::insert(&self.book_cache, (ident_static, path), result.clone());
}
}
notebooks.push(result);
@@ -1062,50 +1077,8 @@ impl Fetcher {
ident: AtIdentifier<'static>,
book_title: SmolStr,
) -> Result>>> {
- use jacquard::types::aturi::AtUri;
-
if let Some(result) = self.get_notebook(ident.clone(), book_title).await? {
- let (notebook, entry_refs) = result.as_ref();
- let mut book_entries = Vec::new();
- let client = self.get_client();
-
- for (index, entry_ref) in entry_refs.iter().enumerate() {
- // Try to extract rkey from URI
- let rkey = AtUri::new(entry_ref.uri.as_ref())
- .ok()
- .and_then(|uri| uri.rkey().map(|r| SmolStr::new(r.as_ref())));
-
- // Check cache first
- #[cfg(feature = "server")]
- if let Some(ref rkey) = rkey {
- if let Some(cached) =
- cache_impl::get(&self.entry_cache, &(ident.clone(), rkey.clone()))
- {
- book_entries.push(cached.0.clone());
- continue;
- }
- }
-
- // Fetch if not cached
- if let Ok(book_entry) = client.view_entry(notebook, entry_refs, index).await {
- // Try to populate cache by deserializing Entry from the view's record
- #[cfg(feature = "server")]
- if let Some(rkey) = rkey {
- use jacquard::IntoStatic;
- use weaver_api::sh_weaver::notebook::entry::Entry;
- if let Ok(entry) =
- jacquard::from_data::>(&book_entry.entry.record)
- {
- let cached =
- Arc::new((book_entry.clone().into_static(), entry.into_static()));
- cache_impl::insert(&self.entry_cache, (ident.clone(), rkey), cached);
- }
- }
- book_entries.push(book_entry);
- }
- }
-
- Ok(Some(book_entries))
+ Ok(Some(result.as_ref().1.clone()))
} else {
Err(dioxus::CapturedError::from_display("Notebook not found"))
}
@@ -1269,13 +1242,12 @@ impl Fetcher {
};
// Find entry position in notebook
- let book_entry_view = client
- .entry_in_notebook_by_rkey(¬ebook, &entries, &rkey)
- .await
- .map_err(|e| dioxus::CapturedError::from_display(e))?;
+ let book_entry_view = entries
+ .iter()
+ .find(|e| e.entry.uri.rkey().as_ref().map(|k| k.as_ref()) == Some(rkey.as_ref()));
let mut book_entry_view = match book_entry_view {
- Some(bev) => bev,
+ Some(bev) => bev.clone(),
None => {
// Entry not in this notebook's entry list - return basic view without nav
use weaver_api::sh_weaver::notebook::BookEntryView;
diff --git a/crates/weaver-app/src/views/callback.rs b/crates/weaver-app/src/views/callback.rs
index d7712a1..e42534a 100644
--- a/crates/weaver-app/src/views/callback.rs
+++ b/crates/weaver-app/src/views/callback.rs
@@ -8,6 +8,7 @@ use jacquard::{
smol_str::SmolStr,
};
use tracing::{error, info};
+use weaver_api::sh_weaver::actor::profile::Profile as WeaverProfile;
#[component]
pub fn Callback(
@@ -35,8 +36,16 @@ pub fn Callback(
.callback(callback_params)
.await?;
let (did, session_id) = session.session_info().await;
- auth.write().set_authenticated(did, session_id);
+ let did_owned = did.into_static();
+ auth.write()
+ .set_authenticated(did_owned.clone(), session_id);
fetcher.upgrade_to_authenticated(session).await;
+
+ // Create weaver profile if it doesn't exist
+ if let Err(e) = ensure_weaver_profile(&fetcher, &did_owned).await {
+ error!("Failed to ensure weaver profile: {:?}", e);
+ }
+
Ok::<(), OAuthError>(())
}
})
@@ -83,3 +92,53 @@ pub fn Callback(
},
}
}
+
+/// Ensures a weaver profile exists for the authenticated user.
+/// If no weaver profile exists, creates one by mirroring the bsky profile.
+#[cfg(feature = "web")]
+async fn ensure_weaver_profile(
+ fetcher: &Fetcher,
+ did: &jacquard::types::string::Did<'_>,
+) -> Result<(), Box> {
+ use jacquard::{
+ client::AgentSessionExt,
+ types::string::{Datetime, RecordKey},
+ };
+ use weaver_api::app_bsky::actor::profile::Profile as BskyProfile;
+
+ let weaver_uri_str = format!("at://{}/sh.weaver.actor.profile/self", did);
+ let weaver_uri = WeaverProfile::uri(&weaver_uri_str)?;
+
+ // Check if weaver profile already exists
+ if fetcher.fetch_record(&weaver_uri).await.is_ok() {
+ info!("Weaver profile already exists for {}", did);
+ return Ok(());
+ }
+
+ info!(
+ "No weaver profile found for {}, creating from bsky profile",
+ did
+ );
+
+ // Fetch bsky profile
+ let bsky_uri_str = format!("at://{}/app.bsky.actor.profile/self", did);
+ let bsky_uri = BskyProfile::uri(&bsky_uri_str)?;
+ let bsky_record = fetcher.fetch_record(&bsky_uri).await?;
+
+ // Create weaver profile mirroring bsky
+ let weaver_profile = WeaverProfile::new()
+ .maybe_display_name(bsky_record.value.display_name.clone())
+ .maybe_description(bsky_record.value.description.clone())
+ .maybe_avatar(bsky_record.value.avatar.clone())
+ .maybe_banner(bsky_record.value.banner.clone())
+ .bluesky(true)
+ .created_at(Datetime::now())
+ .build();
+
+ let self_rkey = RecordKey::any("self").expect("self is valid record key");
+
+ fetcher.put_record(self_rkey, weaver_profile).await?;
+ info!("Created weaver profile for {}", did);
+
+ Ok(())
+}
diff --git a/crates/weaver-app/src/views/home.rs b/crates/weaver-app/src/views/home.rs
index 81076b5..bbafc01 100644
--- a/crates/weaver-app/src/views/home.rs
+++ b/crates/weaver-app/src/views/home.rs
@@ -153,7 +153,7 @@ fn PinnedNotebookCard(ident: AtIdentifier<'static>, title: SmolStr) -> Element {
Some((view, entries)) => rsx! {
NotebookCard {
notebook: view.clone(),
- entry_refs: entries.clone(),
+ entries: entries.clone(),
show_author: Some(true)
}
},
diff --git a/crates/weaver-common/src/agent.rs b/crates/weaver-common/src/agent.rs
index a228f91..057b03c 100644
--- a/crates/weaver-common/src/agent.rs
+++ b/crates/weaver-common/src/agent.rs
@@ -39,7 +39,7 @@ fn strip_trailing_punctuation(s: &str) -> &str {
}
/// Check if a search term matches a value, with fallback to stripped punctuation
-fn title_matches(value: &str, search: &str) -> bool {
+pub fn title_matches(value: &str, search: &str) -> bool {
// Exact match first
if value == search {
return true;
@@ -682,7 +682,7 @@ pub trait WeaverExt: AgentSessionExt + XrpcExt + Send + Sync + Sized {
ident: &jacquard::types::ident::AtIdentifier<'_>,
title: &str,
) -> impl Future<
- Output = Result, Vec>)>, WeaverError>,
+ Output = Result, Vec>)>, WeaverError>,
>
where
Self: Sized,
@@ -701,22 +701,10 @@ pub trait WeaverExt: AgentSessionExt + XrpcExt + Send + Sync + Sized {
.map_err(|e| AgentError::from(ClientError::from(e)))?;
match resp.into_output() {
- Ok(output) => {
- // Extract StrongRefs from the BookEntryViews for compatibility
- let entries: Vec> = output
- .entries
- .iter()
- .map(|bev| {
- StrongRef::new()
- .uri(bev.entry.uri.clone())
- .cid(bev.entry.cid.clone())
- .build()
- .into_static()
- })
- .collect();
-
- Ok(Some((output.notebook.into_static(), entries)))
- }
+ Ok(output) => Ok(Some((
+ output.notebook.into_static(),
+ output.entries.into_static(),
+ ))),
Err(_) => Ok(None),
}
}
@@ -729,7 +717,7 @@ pub trait WeaverExt: AgentSessionExt + XrpcExt + Send + Sync + Sized {
ident: &jacquard::types::ident::AtIdentifier<'_>,
title: &str,
) -> impl Future<
- Output = Result, Vec>)>, WeaverError>,
+ Output = Result, Vec>)>, WeaverError>,
>
where
Self: Sized,
@@ -817,12 +805,15 @@ pub trait WeaverExt: AgentSessionExt + XrpcExt + Send + Sync + Sized {
);
}
- let entries = notebook
- .entry_list
- .iter()
- .cloned()
- .map(IntoStatic::into_static)
- .collect();
+ // TODO: Fix this - entries building is broken because we need NotebookView
+ // to call view_entry but we're still building the NotebookView
+ let entries = Vec::new(); // Temporarily empty
+
+ // let mut entries = Vec::with_capacity(notebook.entry_list.len());
+ // for (index, _) in notebook.entry_list.iter().enumerate() {
+ // let entry_view = self.view_entry(¬ebook_view, ¬ebook.entry_list, index).await?;
+ // entries.push(entry_view);
+ // }
// Fetch permissions for this notebook
let permissions = self.get_permissions_for_resource(&record.uri).await?;
diff --git a/crates/weaver-index/src/clickhouse/queries/notebooks.rs b/crates/weaver-index/src/clickhouse/queries/notebooks.rs
index 5fdb157..8b34649 100644
--- a/crates/weaver-index/src/clickhouse/queries/notebooks.rs
+++ b/crates/weaver-index/src/clickhouse/queries/notebooks.rs
@@ -39,6 +39,8 @@ pub struct EntryRow {
#[serde(with = "clickhouse::serde::chrono::datetime64::millis")]
pub created_at: chrono::DateTime,
#[serde(with = "clickhouse::serde::chrono::datetime64::millis")]
+ pub updated_at: chrono::DateTime,
+ #[serde(with = "clickhouse::serde::chrono::datetime64::millis")]
pub indexed_at: chrono::DateTime,
pub record: SmolStr,
}
@@ -136,6 +138,7 @@ impl Client {
/// List entries for a specific notebook, ordered by position in the notebook.
///
/// Uses notebook_entries table to get entries that belong to this notebook.
+ /// Deduplicates entries by rkey, keeping the most recently updated version.
pub async fn list_notebook_entries(
&self,
notebook_did: &str,
@@ -143,6 +146,8 @@ impl Client {
limit: u32,
cursor: Option,
) -> Result, IndexError> {
+ use std::collections::HashMap;
+
let query = r#"
SELECT
e.did AS did,
@@ -154,10 +159,11 @@ impl Client {
e.tags AS tags,
e.author_dids AS author_dids,
e.created_at AS created_at,
+ e.updated_at AS updated_at,
e.indexed_at AS indexed_at,
e.record AS record
FROM notebook_entries ne FINAL
- INNER JOIN entries e ON
+ INNER JOIN entries FINAL AS e ON
e.did = ne.entry_did
AND e.rkey = ne.entry_rkey
AND e.deleted_at = toDateTime64(0, 3)
@@ -176,7 +182,8 @@ impl Client {
.bind(notebook_did)
.bind(notebook_rkey)
.bind(cursor_val)
- .bind(limit)
+ // Fetch extra to account for duplicates we'll filter out
+ .bind(limit * 2)
.fetch_all::()
.await
.map_err(|e| ClickHouseError::Query {
@@ -184,7 +191,28 @@ impl Client {
source: e,
})?;
- Ok(rows)
+ // Dedupe by rkey, keeping the most recently updated version
+ let mut seen: HashMap = HashMap::new();
+ let mut deduped: Vec = Vec::with_capacity(rows.len());
+
+ for row in rows {
+ if let Some(&existing_idx) = seen.get(&row.rkey) {
+ // Keep the one with the more recent updated_at
+ if row.updated_at > deduped[existing_idx].updated_at {
+ deduped[existing_idx] = row;
+ }
+ } else {
+ seen.insert(row.rkey.clone(), deduped.len());
+ deduped.push(row);
+ }
+
+ // Stop once we have enough unique entries
+ if deduped.len() >= limit as usize {
+ break;
+ }
+ }
+
+ Ok(deduped)
}
/// Get an entry by rkey, picking the most recent version across collaborators.
@@ -216,6 +244,7 @@ impl Client {
tags,
author_dids,
created_at,
+ updated_at,
indexed_at,
record
FROM entries FINAL
@@ -263,6 +292,7 @@ impl Client {
tags,
author_dids,
created_at,
+ updated_at,
indexed_at,
record
FROM entries FINAL
@@ -305,6 +335,7 @@ impl Client {
tags,
author_dids,
created_at,
+ updated_at,
indexed_at,
record
FROM entries FINAL
@@ -414,7 +445,7 @@ impl Client {
) -> Result, IndexError> {
let query = if cursor.is_some() {
r#"
- SELECT did, rkey, cid, uri, title, path, tags, author_dids, created_at, indexed_at, record
+ SELECT did, rkey, cid, uri, title, path, tags, author_dids, created_at, updated_at, indexed_at, record
FROM (
SELECT did, rkey, cid, uri, title, path, tags, author_dids, created_at, updated_at, indexed_at, record,
ROW_NUMBER() OVER (PARTITION BY rkey ORDER BY updated_at DESC) as rn
@@ -429,7 +460,7 @@ impl Client {
"#
} else {
r#"
- SELECT did, rkey, cid, uri, title, path, tags, author_dids, created_at, indexed_at, record
+ SELECT did, rkey, cid, uri, title, path, tags, author_dids, created_at, updated_at, indexed_at, record
FROM (
SELECT did, rkey, cid, uri, title, path, tags, author_dids, created_at, updated_at, indexed_at, record,
ROW_NUMBER() OVER (PARTITION BY rkey ORDER BY updated_at DESC) as rn
@@ -592,7 +623,7 @@ impl Client {
) -> Result, IndexError> {
let base_query = if tags.is_some() && cursor.is_some() {
r#"
- SELECT did, rkey, cid, uri, title, path, tags, author_dids, created_at, indexed_at, record
+ SELECT did, rkey, cid, uri, title, path, tags, author_dids, created_at, updated_at, indexed_at, record
FROM (
SELECT did, rkey, cid, uri, title, path, tags, author_dids, created_at, updated_at, indexed_at, record,
ROW_NUMBER() OVER (PARTITION BY rkey ORDER BY updated_at DESC) as rn
@@ -607,7 +638,7 @@ impl Client {
"#
} else if tags.is_some() {
r#"
- SELECT did, rkey, cid, uri, title, path, tags, author_dids, created_at, indexed_at, record
+ SELECT did, rkey, cid, uri, title, path, tags, author_dids, created_at, updated_at, indexed_at, record
FROM (
SELECT did, rkey, cid, uri, title, path, tags, author_dids, created_at, updated_at, indexed_at, record,
ROW_NUMBER() OVER (PARTITION BY rkey ORDER BY updated_at DESC) as rn
@@ -621,7 +652,7 @@ impl Client {
"#
} else if cursor.is_some() {
r#"
- SELECT did, rkey, cid, uri, title, path, tags, author_dids, created_at, indexed_at, record
+ SELECT did, rkey, cid, uri, title, path, tags, author_dids, created_at, updated_at, indexed_at, record
FROM (
SELECT did, rkey, cid, uri, title, path, tags, author_dids, created_at, updated_at, indexed_at, record,
ROW_NUMBER() OVER (PARTITION BY rkey ORDER BY updated_at DESC) as rn
@@ -635,7 +666,7 @@ impl Client {
"#
} else {
r#"
- SELECT did, rkey, cid, uri, title, path, tags, author_dids, created_at, indexed_at, record
+ SELECT did, rkey, cid, uri, title, path, tags, author_dids, created_at, updated_at, indexed_at, record
FROM (
SELECT did, rkey, cid, uri, title, path, tags, author_dids, created_at, updated_at, indexed_at, record,
ROW_NUMBER() OVER (PARTITION BY rkey ORDER BY updated_at DESC) as rn
@@ -696,10 +727,11 @@ impl Client {
e.tags AS tags,
e.author_dids AS author_dids,
e.created_at AS created_at,
+ e.updated_at AS updated_at,
e.indexed_at AS indexed_at,
e.record AS record
FROM notebook_entries ne FINAL
- INNER JOIN entries e ON
+ INNER JOIN entries FINAL AS e ON
e.did = ne.entry_did
AND e.rkey = ne.entry_rkey
AND e.deleted_at = toDateTime64(0, 3)
diff --git a/crates/weaver-index/src/endpoints/actor.rs b/crates/weaver-index/src/endpoints/actor.rs
index 4104371..be9ac02 100644
--- a/crates/weaver-index/src/endpoints/actor.rs
+++ b/crates/weaver-index/src/endpoints/actor.rs
@@ -6,6 +6,7 @@ use axum::{Json, extract::State};
use jacquard::IntoStatic;
use jacquard::cowstr::ToCowStr;
use jacquard::identity::resolver::IdentityResolver;
+use jacquard::prelude::*;
use jacquard::types::ident::AtIdentifier;
use jacquard::types::string::{AtUri, Cid, Did, Handle, Uri};
use jacquard_axum::ExtractXrpc;
@@ -52,7 +53,41 @@ pub async fn get_profile(
})?;
let Some(data) = profile_data else {
- return Err(XrpcErrorResponse::not_found("Profile not found"));
+ // get the bluesky profile
+ // TODO: either cache this or yell at tap to start tracking their account!
+ let profile_resp = state
+ .resolver
+ .send(
+ weaver_api::app_bsky::actor::get_profile::GetProfile::new()
+ .actor(did)
+ .build(),
+ )
+ .await
+ .map_err(|e| XrpcErrorResponse::not_found(e.to_string()))?;
+ let bsky_profile = profile_resp
+ .into_output()
+ .map_err(|e| XrpcErrorResponse::not_found(e.to_string()))?
+ .value;
+ let inner_profile = ProfileView::new()
+ .did(bsky_profile.did)
+ .handle(bsky_profile.handle)
+ .maybe_display_name(bsky_profile.display_name)
+ .maybe_description(bsky_profile.description)
+ .maybe_avatar(bsky_profile.avatar)
+ .maybe_banner(bsky_profile.banner)
+ .build();
+
+ let inner = ProfileDataViewInner::ProfileView(Box::new(inner_profile));
+
+ let output = ProfileDataView::new().inner(inner).build();
+
+ return Ok(Json(
+ GetProfileOutput {
+ value: output,
+ extra_data: None,
+ }
+ .into_static(),
+ ));
};
// Build the response
diff --git a/crates/weaver-index/src/endpoints/bsky.rs b/crates/weaver-index/src/endpoints/bsky.rs
index 962b144..9b7b328 100644
--- a/crates/weaver-index/src/endpoints/bsky.rs
+++ b/crates/weaver-index/src/endpoints/bsky.rs
@@ -16,6 +16,7 @@ pub async fn get_profile(
State(state): State,
ExtractXrpc(args): ExtractXrpc,
) -> Result>, XrpcErrorResponse> {
+ // TODO: either cache this or yell at tap to start tracking their account!
let response = state.resolver.send(args).await.map_err(|e| {
tracing::warn!("Appview getProfile failed: {}", e);
XrpcErrorResponse::internal_error("Failed to fetch profile from appview")
diff --git a/crates/weaver-index/src/endpoints/edit.rs b/crates/weaver-index/src/endpoints/edit.rs
index d42640c..7d56fc1 100644
--- a/crates/weaver-index/src/endpoints/edit.rs
+++ b/crates/weaver-index/src/endpoints/edit.rs
@@ -289,13 +289,16 @@ pub async fn get_contributors(
/// Handle sh.weaver.edit.listDrafts
///
-/// Returns draft records for an actor.
+/// Returns draft records for an actor. Requires authentication.
+/// Only returns drafts if viewer is the actor or has collab permission.
pub async fn list_drafts(
State(state): State,
ExtractOptionalServiceAuth(viewer): ExtractOptionalServiceAuth,
ExtractXrpc(args): ExtractXrpc,
) -> Result>, XrpcErrorResponse> {
- let _viewer: Viewer = viewer;
+ // Require authentication
+ let viewer = viewer.ok_or_else(|| XrpcErrorResponse::auth_required("Authentication required"))?;
+ let viewer_did = viewer.did();
let limit = args.limit.unwrap_or(50).min(100).max(1);
@@ -310,6 +313,12 @@ pub async fn list_drafts(
// Resolve actor to DID
let actor_did = resolve_actor(&state, &args.actor).await?;
+ // Permission check: viewer must be the actor (owner access)
+ // TODO: Add collab grant check for draft sharing
+ if viewer_did.as_str() != actor_did.as_str() {
+ return Err(XrpcErrorResponse::forbidden("Cannot view another user's drafts"));
+ }
+
// Fetch drafts
let draft_rows = state
.clickhouse
diff --git a/crates/weaver-index/src/endpoints/notebook.rs b/crates/weaver-index/src/endpoints/notebook.rs
index f3b18d2..6fb4659 100644
--- a/crates/weaver-index/src/endpoints/notebook.rs
+++ b/crates/weaver-index/src/endpoints/notebook.rs
@@ -46,10 +46,7 @@ pub async fn resolve_notebook(
let name = args.name.as_ref();
let limit = args.entry_limit.unwrap_or(50).clamp(1, 100) as u32;
- let cursor: Option = args
- .entry_cursor
- .as_deref()
- .and_then(|c| c.parse().ok());
+ let cursor: Option = args.entry_cursor.as_deref().and_then(|c| c.parse().ok());
// Fetch notebook first to get its rkey
let notebook_row = state
diff --git a/crates/weaver-index/src/endpoints/repo.rs b/crates/weaver-index/src/endpoints/repo.rs
index 0b7ff9c..ae3e642 100644
--- a/crates/weaver-index/src/endpoints/repo.rs
+++ b/crates/weaver-index/src/endpoints/repo.rs
@@ -51,6 +51,22 @@ impl XrpcErrorResponse {
message: Some(message.into()),
}
}
+
+ pub fn auth_required(message: impl Into) -> Self {
+ Self {
+ status: StatusCode::UNAUTHORIZED,
+ error: "AuthRequired".to_string(),
+ message: Some(message.into()),
+ }
+ }
+
+ pub fn forbidden(message: impl Into) -> Self {
+ Self {
+ status: StatusCode::FORBIDDEN,
+ error: "Forbidden".to_string(),
+ message: Some(message.into()),
+ }
+ }
}
impl IntoResponse for XrpcErrorResponse {
diff --git a/crates/weaver-index/src/landing.html b/crates/weaver-index/src/landing.html
index f0626e7..4c59a48 100644
--- a/crates/weaver-index/src/landing.html
+++ b/crates/weaver-index/src/landing.html
@@ -100,16 +100,16 @@
- .dP' db `Yb .dP'
- dP' db db 88 dP'
- 88
- `Yb d888b 'Yb 'Yb 88 d88b d88b 'Yb `Yb dP'
- 88P 88 88 88 88P 8Y 8b 88 Yb dP
- 88 8P 88 88 88 8P 88 88 YbdP
- 88 .dP .8P .8P 88 .dP' .dP' .8P .8P
- .88888888b. 8888888888888b. dP' b
- Y. ,P
- `""'
Weaver Index
AT Protocol Record Index
diff --git a/crates/weaver-index/src/parallel_tap.rs b/crates/weaver-index/src/parallel_tap.rs
index 164d282..30b4e3d 100644
--- a/crates/weaver-index/src/parallel_tap.rs
+++ b/crates/weaver-index/src/parallel_tap.rs
@@ -12,7 +12,9 @@ use crate::clickhouse::{
};
use crate::config::{IndexerConfig, TapConfig};
use crate::error::{ClickHouseError, Result};
-use crate::tap::{TapConfig as TapConsumerConfig, TapConsumer, TapEvent};
+use crate::tap::{
+ RecordAction, TapConfig as TapConsumerConfig, TapConsumer, TapEvent, TapRecordEvent,
+};
/// Tap indexer with multiple parallel websocket connections
///
@@ -183,7 +185,13 @@ async fn run_tap_worker(
);
let raw_data = format!(
r#"{{"did":"{}","collection":"{}","rkey":"{}","cid":"{}","error":"serialization_failed"}}"#,
- record.did, record.collection, record.rkey, record.cid
+ record.did,
+ record.collection,
+ record.rkey,
+ record
+ .cid
+ .as_ref()
+ .unwrap_or(&SmolStr::new_static("no cid"))
);
records
.write_raw_to_dlq(
@@ -208,12 +216,22 @@ async fn run_tap_worker(
"writing record"
);
+ if record.action == RecordAction::Delete {
+ let client = client.clone();
+ let record_clone = record.clone();
+ tokio::spawn(async move {
+ if let Err(e) = handle_delete(&client, record_clone).await {
+ warn!(error = ?e, "delete handling failed");
+ }
+ });
+ }
+
records
.write(RawRecordInsert {
did: record.did.clone(),
collection: record.collection.clone(),
rkey: record.rkey.clone(),
- cid: record.cid.clone(),
+ cid: record.cid.clone().unwrap_or_default(),
rev: record.rev.clone(),
record: json.to_smolstr(),
operation: record.action.as_str().to_smolstr(),
@@ -333,3 +351,65 @@ async fn run_backfill(client: Arc) {
info!("backfill: all incremental MVs processed");
}
+
+#[derive(Debug, Clone, clickhouse::Row, serde::Deserialize)]
+struct LookupRawRecord {
+ #[allow(dead_code)]
+ did: SmolStr,
+ #[allow(dead_code)]
+ collection: SmolStr,
+ #[allow(dead_code)]
+ cid: SmolStr,
+ #[allow(dead_code)]
+ record: SmolStr, // JSON string of the original record
+}
+
+async fn handle_delete(client: &Client, record: TapRecordEvent) -> Result<()> {
+ let deadline = Instant::now() + Duration::from_secs(15);
+
+ loop {
+ // Try to find the record by rkey
+ let query = format!(
+ r#"
+ SELECT did, collection, cid, record
+ FROM raw_records
+ WHERE did = '{}' AND rkey = '{}'
+ ORDER BY event_time DESC
+ LIMIT 1
+ "#,
+ record.did, record.rkey
+ );
+
+ let original: Option = client
+ .inner()
+ .query(&query)
+ .fetch_optional()
+ .await
+ .map_err(|e| crate::error::ClickHouseError::Query {
+ message: "delete lookup failed".into(),
+ source: e,
+ })?;
+
+ if let Some(original) = original {
+ // Found the record - the main insert path already handles creating
+ // the delete row, so we're done. In phase 2, this is where we'd
+ // parse original.record and insert count deltas for denormalized tables.
+ debug!(did = %record.did, cid = %original.cid, "delete found original record");
+ return Ok(());
+ }
+
+ if Instant::now() > deadline {
+ // Gave up - create stub tombstone
+ // The record will be inserted via the main batch path with operation='delete'
+ // and empty record content, which serves as our stub tombstone
+ warn!(
+ did = %record.did,
+ cid = %original.as_ref().map(|o| o.cid.clone()).unwrap_or(SmolStr::new_static("")),
+ "delete timeout, stub tombstone will be created"
+ );
+ return Ok(());
+ }
+
+ tokio::time::sleep(Duration::from_secs(1)).await;
+ }
+}
diff --git a/crates/weaver-index/src/tap/types.rs b/crates/weaver-index/src/tap/types.rs
index 5162f70..b8c0204 100644
--- a/crates/weaver-index/src/tap/types.rs
+++ b/crates/weaver-index/src/tap/types.rs
@@ -41,7 +41,7 @@ pub struct TapRecordEvent {
/// Operation: create, update, delete
pub action: RecordAction,
/// Content identifier
- pub cid: SmolStr,
+ pub cid: Option,
/// The actual record data (only present for create/update)
#[serde(default)]
pub record: Option,
diff --git a/crates/weaver-renderer/src/atproto/writer.rs b/crates/weaver-renderer/src/atproto/writer.rs
index c2c29f7..f258274 100644
--- a/crates/weaver-renderer/src/atproto/writer.rs
+++ b/crates/weaver-renderer/src/atproto/writer.rs
@@ -246,9 +246,14 @@ impl<'a, I: Iterator- >, W: StrWrite, E: EmbedContentProvider>
self.write(&html)?;
}
},
- Html(html) | InlineHtml(html) => {
+ Html(html) => {
self.write(&html)?;
}
+ InlineHtml(html) => {
+ self.write(r#"
"#)?;
+ self.write(&html)?;
+ self.write(" ")?;
+ }
SoftBreak => self.write_newline()?,
HardBreak => self.write(" \n")?,
Rule => {
@@ -281,7 +286,7 @@ impl<'a, I: Iterator- >, W: StrWrite, E: EmbedContentProvider>
fn start_tag(&mut self, tag: Tag<'_>) -> Result<(), W::Error> {
match tag {
- Tag::HtmlBlock => Ok(()),
+ Tag::HtmlBlock => self.write(r#"
"#),
Tag::Paragraph => {
if self.end_newline {
self.write("")
@@ -493,7 +498,10 @@ impl<'a, I: Iterator- >, W: StrWrite, E: EmbedContentProvider>
self.consume_until_end();
return self.write(&html);
} else {
- tracing::debug!("[ClientWriter] No embed content from provider for {}", dest_url);
+ tracing::debug!(
+ "[ClientWriter] No embed content from provider for {}",
+ dest_url
+ );
}
} else {
tracing::debug!("[ClientWriter] No embed provider available");
@@ -575,7 +583,7 @@ impl<'a, I: Iterator
- >, W: StrWrite, E: EmbedContentProvider>
fn end_tag(&mut self, tag: markdown_weaver::TagEnd) -> Result<(), W::Error> {
use markdown_weaver::TagEnd;
match tag {
- TagEnd::HtmlBlock => Ok(()),
+ TagEnd::HtmlBlock => self.write("
\n"),
TagEnd::Paragraph => self.write("\n"),
TagEnd::Heading(level) => {
self.write("")?;
diff --git a/crates/weaver-renderer/src/css.rs b/crates/weaver-renderer/src/css.rs
index 17ce7c8..3ccced4 100644
--- a/crates/weaver-renderer/src/css.rs
+++ b/crates/weaver-renderer/src/css.rs
@@ -307,6 +307,14 @@ img {{
border-radius: 4px;
}}
+/* Hygiene for iframes */
+.html-embed-block {{
+ max-width: 100%;
+ height: auto;
+ display: block;
+ margin: 1rem 0;
+}}
+
/* AT Protocol Embeds - Container */
/* Light mode: paper with shadow, dark mode: blueprint with borders */
.atproto-embed {{
@@ -435,6 +443,8 @@ a.embed-author-name:hover {{
white-space: pre-wrap;
}}
+
+
.embed-description {{
display: block;
color: var(--color-text);
diff --git a/docker-compose.yml b/docker-compose.yml
index 26ff005..beb9f66 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -24,7 +24,8 @@ services:
TAP_LOG_LEVEL: info
TAP_OUTBOX_PARALLELISM: 5
#TAP_FULL_NETWORK: true
- TAP_SIGNAL_COLLECTION: place.stream.chat.profile
+ #TAP_SIGNAL_COLLECTION: place.stream.chat.profile
+ TAP_SIGNAL_COLLECTION: sh.weaver.actor.profile
TAP_COLLECTION_FILTERS: "sh.weaver.*,app.bsky.actor.profile,sh.tangled.*,pub.leaflet.*,net.anisota.*,place.stream.*"
healthcheck:
test: ["CMD", "wget", "-q", "--spider", "http://localhost:2480/health"]
diff --git a/weaver_notes/.obsidian/workspace.json b/weaver_notes/.obsidian/workspace.json
index dd500bd..d67eafa 100644
--- a/weaver_notes/.obsidian/workspace.json
+++ b/weaver_notes/.obsidian/workspace.json
@@ -201,6 +201,8 @@
},
"active": "6029beecc3d03bce",
"lastOpenFiles": [
+ "weaver_index_html.png",
+ "2025-12-14T23:32:19.png",
"diff_record.png",
"Why I rewrote pdsls in Rust (tm).md",
"Writing the AppView Last.md",
@@ -213,8 +215,6 @@
"invalid_record.png",
"Pasted image 20251114125031.png",
"Pasted image 20251114121431.png",
- "json_editor_with_errors.png",
- "pretty_editor.png",
"Arch.md",
"Weaver - Long-form writing.md"
]
diff --git a/weaver_notes/Writing the AppView Last.md b/weaver_notes/Writing the AppView Last.md
index 6a8d9a7..320f8b5 100644
--- a/weaver_notes/Writing the AppView Last.md
+++ b/weaver_notes/Writing the AppView Last.md
@@ -1,3 +1,4 @@
+
If you've been to this site before, you maybe noticed it loaded a fair bit more quickly this time. That's not really because the web server creating this HTML got a whole lot better. It did require some refactoring, but it was mostly in the vein of taking some code and adding new code that did the same thing gated behind a cargo feature. This did, however, have the side effect of, in the final binary, replacing functions that are literally hundreds of lines, that in turn call functions that may also be hundreds of lines, making several cascading network requests, with functions that look like this, which make by and large a single network request and return exactly what is required.
```rust
@@ -30,6 +31,8 @@ Of course the reason is that I finally got round to building the Weaver AppView.
For the uninitiated, because I expect at least some people reading this aren't big into AT Protocol development, an AppView is an instance of the kind of big backend service that Bluesky PBLLC runs which powers essentially every Bluesky client, with a few notable exceptions, such as [Red Dwarf](https://reddwarf.app/), and (partially, eventually more completely) [Blacksky](https://blacksky.community/). It listens to the [Firehose](https://bsky.network/) [event stream](https://atproto.com/specs/event-stream) from the main Bluesky Relay and analyzes the data which comes through that pertains to Bluesky, producing your timeline feeds, figuring out who follows you, who you block and who blocks you (and filtering them out of your view of the app), how many people liked your last post, and so on. Because the records in your PDS (and those of all the other people on Bluesky) need context and relationship and so on to give them meaning, and then that context can be passed along to you without your app having to go collect it all. ![[at://did:plc:uu5axsmbm2or2dngy4gwchec/app.bsky.feed.post/3lsc2tzfsys2f]]
It's a very normal backend with some weird constraints because of the protocol, and in it's practice the thing that separates the day-to-day Bluesky experience from the Mastodon experience the most. It's also by far the most centralising force in the network, because it also does moderation, and because it's quite expensive to run. A full index of all Bluesky activity takes a lot of storage (futur's Zeppelin experiment detailed above took about 16 terabytes of storage using PostgreSQL for the database and cost $200/month to run), and then it takes that much more computing power to calculate all the relationships between the data on the fly as new events come in and then serve personalized versions to everyone that uses it.
+
+
It's not the only AppView out there, most atproto apps have something like this. Tangled, Streamplace, Leaflet, and so on all have substantial backends. Some (like Tangled) actually combine the front end you interact with and the AppView into a single service. But in general these are big, complicated persistent services you have to backfill from existing data to bootstrap, and they really strongly shape your app, whether they're literally part of the same executable or hosted on the same server or not. And when I started building Weaver in earnest, not only did I still have a few big unanswered questions about how I wanted Weaver to work, how it needed to work, I also didn't want to fundamentally tie it to some big server, create this centralising force. I wanted it to be possible for someone else to run it without being dependent on me personally, ideally possible even if all they had access to was a static site host like GitHub Pages or a browser runtime platform like Cloudflare Workers, so long as someone somewhere was running a couple of generic services. I wanted to be able to distribute the fullstack server version as basically just an executable in a directory of files with no other dependencies, which could easily be run in any container hosting environment with zero persistent storage required. Hell, you could technically serve it as a blob or series of blobs from your PDS with the right entry point if I did my job right.
I succeeded.
@@ -57,10 +60,14 @@ One reason why the edit records for Weaver have three link fields (and may get m
In contrast, notebook entry records lack links to other parts of the notebook in and of themselves because calculating them would be challenging, and updating one entry would require not just updating the entry itself and notebook it's in, but also neighbouring entries in said notebook. With the shape of collaborative publishing in Weaver, that would result in up to 4 writes to the PDS when you publish an entry, in addition to any blob uploads. And trying to link the other way in edit history (root to edit head) is similarly challenging.
I anticipated some of these. but others emerged only because I ran into them while building the web app. I've had to manually fix up records more than once because I made breaking changes to my lexicons after discovering I really wanted X piece of metadata or cross-linkage. If I'd built the index first or alongside—particularly if the index remained a separate service from the web app as I intended it to, to keep the web app simple—it would likely have constrained my choices and potentially cut off certain solutions, due to the time it takes to dump the database and re-run backfill even at a very small scale. Building a big chunk of the front end first told me exactly what the index needed to provide easy access to.
+
+You can access it here: [index.weaver.sh](https://index.weaver.sh)
# ClickHAUS
-So what does Weaver's index look like? Well it starts with either the firehose or the new Tap sync tool. The index ingests from either over a WebSocket connection, does a bit of processing (less is required when ingesting from Tap, and that's currently what I've deployed) and then dumps them in the Clickhouse database. I chose it as the primary index database on recommendation from a friend, and after doing a lot of reading. It fits atproto data well, as Graze found. Because it isolates concurrent inserts and selects so that you can just dump data in, while it cleans things up asynchronously after, it does wonderfully when you have a single major input point or a set of them to dump into that fans out, which you can then transform and then read from.
+So what does Weaver's index look like? Well it starts with either the firehose or the new [Tap](https://docs.bsky.app/blog/introducing-tap) sync tool. The index ingests from either over a WebSocket connection, does a bit of processing (less is required when ingesting from Tap, and that's currently what I've deployed) and then dumps them in the Clickhouse database. I chose it as the primary index database on recommendation from a friend, and after doing a lot of reading. It fits atproto data well, as Graze found. Because it isolates concurrent inserts and selects so that you can just dump data in, while it cleans things up asynchronously after, it does wonderfully when you have a single major input point or a set of them to dump into that fans out, which you can then transform and then read from.
-I will not claim that the tables you can find in the weaver repository are especially **good** database design overall, but they work, and we'll see how they scale. This is one of three main input tables. One for record writes, one for identity events, and one for account events.
+I will not claim that the tables you can find in the weaver repository are especially **good** database design overall, but they work, they're very much a work in progress, and we'll see how they scale. Also, Tap makes re-backfilling the data a hell of a lot easier.
+
+This is one of three main input tables. One for record writes, one for identity events, and one for account events.
```SQL
CREATE TABLE IF NOT EXISTS raw_records (
did String,
@@ -92,7 +99,7 @@ CREATE TABLE IF NOT EXISTS raw_records (
ENGINE = MergeTree()
ORDER BY (collection, did, rkey, event_time, indexed_at);
```
-From here we fan out into a cascading series of materialized views and other specialised tables. These break out the different record types, calculate metadata, and pull critical fields out of the record JSON for easier querying. Clickhouse's wild-ass compression means we're not too badly off replicating data on disk this way. Seriously, their JSON type ends up being the same size as a CBOR BLOB on disk in my testing, though it *does* have some quirks, as I discovered when I read back Datetime fields and got...not the format I put in. Thankfully there's a config setting for that. We also build out the list of who contributed to a published entry and determine the canonical record for it, so that fetching a fully hydrated entry with all contributor profiles only takes a couple of `SELECT` queries that themselves avoid performing extensive table scans due to reasonable choices of `ORDER BY` fields in the denormalized tables they query. And then I can do quirky things like power a profile fetch endpoint that will provide either a Weaver or a Bluesky profile, while also unifying fields so that we can easily get at the critical stuff in common. This is a relatively expensive calculation, but people thankfully don't edit their profiles that often, and this is why we don't keep the stats in the same table.
+From here we fan out into a cascading series of materialized views and other specialised tables. These break out the different record types, calculate metadata, and pull critical fields out of the record JSON for easier querying. Clickhouse's wild-ass compression means we're not too badly off replicating data on disk this way. Seriously, their JSON type ends up being the same size as a CBOR BLOB on disk in my testing, though it *does* have some quirks, as I discovered when I read back Datetime fields and got...not the format I put in. Thankfully there's a config setting for that. We also build out the list of who contributed to a published entry and determine the canonical record for it, so that fetching a fully hydrated entry with all contributor profiles only takes a couple of `SELECT` queries that themselves avoid performing extensive table scans due to reasonable choices of `ORDER BY` fields in the denormalized tables they query and are thus very fast. And then I can do quirky things like power a profile fetch endpoint that will provide either a Weaver or a Bluesky profile, while also unifying fields so that we can easily get at the critical stuff in common. This is a relatively expensive calculation, but people thankfully don't edit their profiles that often, and this is why we don't keep the stats in the same table.
However, this is ***also*** why Clickhouse will not be the only database used in the index.
@@ -140,4 +147,11 @@ The exact method of authentication of that side-channel is by far the largest re
If people have ideas, I'm all ears.
-I hope you found this interesting. I enjoyed writing it out.
\ No newline at end of file
+## Future
+Having this available obviously improves the performance of the app, but it also enables a lot of new stuff. I have plans for social features which would have been much harder to implement without it, and can later be backfilled into the non-indexed implementation. I have more substantial rewrites of the data fetching code planned as well, beyond the straightforward replacement I did in this first pass. And there's still a **lot** more to do on the editor before it's done.
+
+I've been joking about all sorts of ambitious things, but legitimately I think Weaver ends up being almost uniquely flexible and powerful among the atproto-based long-form writing platforms with how it's designed, and in particular how it enables people to create things together, and can end up filling some big shoes, given enough time and development effort.
+
+I hope you found this interesting. I enjoyed writing it out. There's still a lot more to do, but this was a big milestone for me.
+
+If you'd like to support this project, there's a GitHub Sponsorship link at the bottom of the page, but honestly I'd love if you used it to write something.
\ No newline at end of file
diff --git a/weaver_notes/weaver_index_html.png b/weaver_notes/weaver_index_html.png
new file mode 100644
index 0000000000000000000000000000000000000000..5ff0a8381858b00946266554aae88f7475c093c8
GIT binary patch
literal 41937
zcmeAS@N?(olHy`uVBq!ia0y~yU^>9S!1#uPje&vT&Pr)}28P8mJY5_^D(1Ys%UU54
zF3$EqSn;5&Qvv6@cVSB^J~ADZ?TViGC{Ooj*KQHbPmkZd6DfBp;NVkq`B&|CBh_4G
z_T3GfyJxB&0E*VcYnKX+%%lp4Ez%awL|pZ@Ifaq@}B
zUw2Oz1lbhQXmvp9+n=YG*T+Tw+5hs&+Lr=W|L!xcir*=^_U6VL8XoIDIy-C4TW@1&
zWpQDOs`81?4|SV5Lbnx&t_ohLX)2atKzmXGBPkU
zWO6h-)IM!~e%UG2?8}#WjhC)!>3hGtzvTbN*vguRzcRh1S*TiCBnWqT#GL=$_B^%f
z^Sh}w`=6Gceet*5|NTqjFYf2xE%eDO7GPjtn9-&s@L{U>t1#>Tcfag$0wtk9-b>{P
zX6u+9&QqCcb8e!cylqw8eGTV1`q6>EHoE9Cz1~^Yk@(0p?Tm&?K!)s+$Vik`f9;Bk3cuSwN@hrUNs41LRPl@;hV
zmhP$g-E{3ugv;XH=cl+8h_9b-R`q}8cjc_u?d@9MbH0Sw&f8L<0XB~BU7lPcC}bHH
z2%f-|7vdBca#+4UnqXgT$i%?V(2@F`v0&Plohzz0D}I{6&imqenGEBN#jDHpPI%<;
z@$vn-E*1XXVSh+|{+FGBchiH)`uE(pH+81-^_Cwu!gyX;bO!BMI`MdIRPQpLm74?@
z7;5TIFfiZXkgG}gwyN{h#pw()b~|X#+|Q)iHQ!}k@w6XTXN#A7JSGXU`oqT4RtHkK
z!xvhK&hpy7LVVqPqpI^aPtL5%y%NvHU-9Vg1T}p*xA%5?R2ixlNM}Vo-+SIVYJ0Zi
z)%8~f-M2`91giCtaBPY|X#sUHzF(=I>*tMxSE%mUDbz
z=@iXezO0C~va!1guW5K(yY=nfx99gv+*421&%P1G##&hNLnz^sN9%Guxn=j&wk&FW
zy)0;Um96HctLrYfZ2D|_%p~-*f$!S+DQ@rg*ZiHs=KIcN|NpPrrfV*>CAsyxGB61I
ze51$ktg7GKM*s5c^7T)C>aNdrdv9M;)o=If*NMlA)4zK>U`ROshxt$D&t;Kcl0VMc
zQuNwp@%JrT_F7xJE^a9lzC88z|C670^XCOQEZG0AR&HfLd0~Mg>+zF9%T?IhPuL#$
z=+SZf-23Ur&*X(_;LvF#s^n3j)O7F_O3QIAoS|#Tn
zWiy3gk=EMW_jd#)NFV&UGNI!C$+z*LjT%!{tB9VC_G3NPXOer+CC3h
zBe5|vfensZJs~2m)|b7ZoU@-!@|y>P=$(?74GNvtVsFyspRW9*HbeM?R8^*
zKUJ7vGSM1ThfGrUbye4wne;FyvhmH)m1ReliAYFzytmu4P}AG(gh%n6{aLrG_G+YV
z+9I;zU#|U*zB2jSD)%4$bBn(I*3WS9@o5uxiLIOMbp3stk)>_eZl~avt27N10@%!F
z{C+?G)6Nrf#J?}R1IeQsORhe7@?)v~)#K;oSELqBJ$`Y{;)|!v?_Js}!qBkx){b)4
zzZc5|RYU)$d(Q8+k-xVn`un<9_s`E>%VE0uYNyZ;H1Ayw2c&{btEe87iKZ)`>Wq+rm8Dhi{<6mzch9MQ
zxxY1die_$^WZ2a9DSsDwn0n1Jm?@)@yyMuERg5-YjMzKtzePLQ9yeX=KfmUrZeS4?
z2hW7c&oHrwvU)t!$^o%g+d{@*jTfq&Hl!*-UIJDGLI+x|Y;+wxKP@?rJw
zPd@Lf+h?>Z?CU1pighC4seV7-|2)0isCT~h_GuTF&zUnvW%B~zTVH-u3W+ZGmSZ^Q
z&)>hAv8Q^nBKuu`?J-(*(y#u%v720dfO@0*sxuPoXKSw>J-j7#vC82yl8+eQs;!vp
z$zS#J*6+_H%mROy8qVIVFPiODa%Hjbs~hXM>q_&gW2gU*Uuhi$Ep{YgZ!kA}b2%BO
z@S|-1;>nY5&VPTa#Ng}S#cO3`KBw!uwH8gAa{aW}DxIs0|6(j6<`q9Sn0xH1^@WX}
zf4_LKv-XlkzSH}kiGKa7jY9My+kdV6zC+9T^8C81_3B2?MQ8ocvOdJ?S#ni7Zcmll
zce%UMxLY4T|1QkXu=Y{+zuR%wUo3kr%EZ9oJtd1pe45FtRr4(^ExqsFd|j@0>T|}$
zm@i*rDm@})#ize`^7yrK|IRFia956(wuj%(m?g))qwZgw&;%8JLE?(65TuHC{Mvi8jRkc~h5r)c_?SuMZ!c+IPQ!qH1txqgm{
zT~X2~AIk7*byG+`fNUKV`ZoHRBP@
z_eCq+3Iq!0%v}_?``6y3+NK+7?>v?JF2%IWbk#M#UCb;ECmM@6U8ZT=Kn89w~Bhe7Ig;SM&bomhVTz7GGbJ`0RqIQIKG?
zU7@+?`EWm}#~+Id=I`R4U-i=d^xYX#15f|BzjgALwX3GA@|KnQw)B(h>dXADt1kU@
zFMo0KwE8874dJH+L-zVG|K#bnJ6@|^Z0_{%D069w@BHYhsSg-UCCXQtRURnHZ211M
zSYPbtCefwe?Ekw}_vcUKd$sf6sqX9NHTJNT&Qalw+F_`$KKR}DAIF7EmkP?SKOP--
z-Deu>caBT%p2r!rl(i<=dn(WRziz(D{C@eA4^!5>EWUWvT3llj%YC+;+x+SnSEJ%o2NhJ+7dT*tE#;9C*NMaQYUlk+c{f>8;`2~h)pb<
zv(f$F_VQ}YnHG<1_zPiOW-V^=B$E2G4Aos=4XsuV1H(KFIfn
zT?vbQ>BjQTYi-)kVmi#Olu4TicqmSH9~%`^2(e>H3DO=%^iaTCOc&rMoV#nJWC`JKwBJp3jad
z-<_$&ZRdHUdv3r~=ipbS>vpo}nVvFJiQbrYTtlvM{
zR9|)ZdP4EgbJ0kpr2AI2FFSe^(lRdYS}8Q))3-CP!m|8ke_Oj~`}_FGTW8*~bH3g0
zoBKX6FhxH7%~ac|m%rOhcVUU{us;wnWfk+8?LHP9r{*qQu&e0Hl*u;v8HziL|F`jI
z%LwjxA9HTgkKey9?wfqu>F=H5sS_X8mB+>|dc8htZL6`%pWAciE*7lic_sei<7f6y
za{teNPO*7o)UTG_d+~0?jc3Q_Z%(qBGxzu6_ex%?{yje<#vfSw`d;YY-<6w17p85s
zI{Bi^rsea(%e#HfDf_LdxU%+T!cN^MTl%7cA%
zPp6p_{&V>I`<#~ZkL<<&o=mjTw*6S=F1xS*E$MWvWKcQg!bq-+y#4fhTt7
z-}$G^Dpz*5e$rCd>tSj9YNj_#lI)czpwr)EVurhZ*fRjum6-g>V<2IdY%8oaBVj!+GJ$oI6r**>L1njyO{S!
zY9BwnHZp&!a*=~YMV5GF{^NU7udF^=@N4z|pe|8kzO{EZy6~w9Hcn5zdhO?x6(l0EFZmm%^+cGYeaqK6
zP_V4-?XkdfK0i}`yy+0SWVYk+1*=&L>e3Hqy?t@1ce7h#_N10~udM&3O0SCFIhAMW
zS@!=Qy3SO#w(Gu$&NukZXZFxtZUHRT`R&?meD{iZ0
z+x&xn7iQeH-rV{%Blnu!ITu#N-1pyN{I>0TvrVYJ;pd_dhB=10Ws)xr{x6o5+IPw*
zWUEiDR^)<3HqYKDURO6t>DAE)f8Z}+zVY9I!`lm)nD$?JSy)&&ahKhh@00m`qpeQ8
z_+4}9+5Y^aUzvYqP08Q+;qBraxymK`9vs+j6|{NQ{!^cN=bJmYO?te`lYd^trJ1MQ
zHI?3k&)l_Y>FbM~-0UxJb{}_pe;3rX_#AK!T*Ygx*je|v7AY-{@sYlTdRB9
zB6wN#@z-rvRzCUex9aKh#D&U641Af=Pu|XKo_DU7{nGlwuch~X|M1ebc0&1=H*%5&
zPorI)?a@iq{w?aOsz>9R%{dp=g^)nq^JbeseM73rRo+`_Qw@LKpZ8Q`;-Rb^r@o5pJQwHkxXoe@
z^C!*;g=YQj;m>>m@8!5={`m6llhysKM}ZGMrRd5eG8(WmZ@=pKciD5%E{%^@L+#C{
zJa{nu<6HG}(+WR4eDp@BDl$b^Y5U4jNt=DS$9P|sFm71;piRU--e%IpUyCm7(Mi<)
zJ&kYr#;&LtqsqMHa$=6@A<*3lf1q6!4I+Y#aX$k
z+DnQLf1XtG_Q=+lZ<;7qzPOmcQQ%iJCWN%yaZo)m8s0T8~{wvE{yKOV~wYPi}I&Ji6?OV~=&)aTFw7>pW
zmTlGI^<=%m_KEW<+3&J(b8sxM7w218X8&DLywrNe+I#yP@;EnNIF
zn}+WdSLkg@TR&&TeEIrm(k4qiW<~8QtN#3eE
zGoL%RI(^#)Ipx_)k4vuI^t|ZTz0-NxAK7P|KX~`7@}If;nLxu@Hzavl2@IL;=4q&0
zvgFgztP>pZb+7Ut-qO6JXj5)|Wmz2msX1oLOWe-xb@qw2>x$d=O)dLzqnef3EBC_W
zqth3<>ff>O{e1n8>R!PoCua1|y7EFJR`{d9vgmK?LH(D26z;Wid~|njZQVX+n*SYN
z{`plWli9!KfBe7f@=I@b4WpZn4~
zn|#aj{@#hYTT=OXIF=OdeI6*k)voj}`}5ObQ|f*#6y%+EYi3U1+^(}JA11tQzjpGm
zmulAK+27Ycsd)JH2cK`8(VU`+CzB64v-xDL{~wpU_5QD4zfOF!DSjc9Wxm5Kv(qSOaDLgv<_jd1f{tE}0O?@Mcg4%r9Z>N+q8t_X;ZLUdvWwmJ{
z5AQ6~+Ptj5-P(W6Hg4^iC8A&E^vZDC%98(wtzCOJ@n&B9m6?*e^@`D)-8C^*+n(ul
z)V((dEOU|LY;#YXwX0+a3(K9Kf1dn1{o=yRyPKR{#DbruWZhh=yXWbVW~mPzVLkk}
zly{$9@XcB2yGttf>lV=TGCF%YXhcwYTK=jfyu1y9`>K!Bw#)y2&v1499j{+P<~Ds-ek@$b6?STM>C**9
zWy(*=-9teEwEjfQaoEu4{JY}o+U<^5<~LtG^~Wb+H)teKw&>cak0vYc3w;WcR(QXn
zc{y8eYW?BQn^IP!@%#(llxm}N>67nf4W*nryC+p`mGGFN=
z?=G%9!CXl@(MqNR
z%-uY5{%E^GLoe!hjr%%S%jo0LtCyW7n>y!7c8cUozsq`{#(K_XhMMX*uUDmhKH$_v}sdePd;rYi67OKQUVs
zwM%a9j)H*r=@-LNd}hZoNv_*{#^QxxNa$TQeQ@K>nw{M*@B04jHJ@{4{rYXcDXwDo
z#fJ;x%Na`_{>prHD`i6nufZRmkfhho*X9dKzMjz@@vPh9?%ek1xa(h>H*a3^>Rji$
zJx~9>(mWeuIPXB2<)pa}d3*S4cC|@O&zmzfdO?lp>C%~gaTZI?`+Ure$U7k@_-WP4
zS4|9gXNq23N<1}rtHIH5NrOCvBkY35w!gGEJxMc{uggd6wC0r5evXH_>g$;ARM&9-
zlr~t&9J{ZyPieO9n&jx4_eyuvJ4>iQ5uit=%iF)6T@12Fhy~-*f+YUao$+>CIo2ucL1qvf8&s?^o$<
zh3l-(6ePgOz|lIjtm~mv@Vw8WlK1^;zW?K`wAEYU^p?Z*R=w&ku6hu{=-u
z>B%4ux#HC~V`g$qc-}l)XhlKo)sT~yPF8PBIX9kQ{)*K+2Z{Z&n6^0#$9KMi~Jw(i~HG8^%qZ(hCSeqt63Dh?LvR=aHY
zT4Z!oY}(zhZQnPjglzYzloUSZ>!WRUerdV&%2fF)g+i*nGkR+N)+|jtdhoJ!=GSekB7#oQ{Wc;6DLL<#6>N{XGyP42=i9IA
zO~YPaxqiJpEN`y6ar?T3WzC1>4($RBLv2?p&py_#J#~d|{=NN5>|f8n{wO#^el78myp|07j05gj#yA
z^p(Y)A*rBD(9uzPruNF~ZH{uf8*~5OWBGRS9aDl_Tj1}-@COVTkBnjO{P-0R^);v9`(xCaTgdEw;3$6;+H+6Zjf$Tf9&*PZSH^f
zpML(tD$jrKme1MK*N)FK-#*8}`1RKp>v#eZbC+F9PRV<}tblEa<5aUrmZ08wNphM+
zPHC^a)TJA7#hH6{Wly?gyLZpAzdN?AyWPC(&%PM91zu~6f)_q)bhsn5Tp>CU+I;ff
zHuHVxyF35iwAgWH-X3K0#4F&q*s+l3PiJo}vhjVjy=BkUTtB-7yz_3k
z-ZM6p+q3oN=Nq4&O%vw7c3|$O*V~-4jkd}u7gw9NGEdAd*9%&l^;Wa?l#j{d`Gvo}
zTO4@wF#G@Z>YaCzp4xe^`~92xDP7ymQ%G%b-eRd>hXjSM8FnT|GK&|SasEDUf7K*-
z^q#q!wl96>_P;yL&qkUCuKM|*E_8LwV*&f=rq1v3`hc+P|+8dyDgPfBWYxUiIqLha;`czp6589&Knfyt;Fp`29-n;;yqze&RJd}VthC_G@Zi^mo3@t+Ps^X@
z8(jb2{nV*~DXM%AO4a9ZU-u8buGYNtRKT$Z{e(+M-Vl`P&K~
zJSp2Gy6lv#(f73jR%U$oxcB+#WVy7mXI&q^
z=sHApO5^Le1z>YJck=gxAsu)W${bHZMW*!{&9mu++GhzMLIpAaqaeBSQK
zoo@`UK1TGuFF#KI{?v3|?qS}qpt^Ww7OXCg%Xoayouyi?+Ro^-rpdBvi*<9}CqC2?
zXf2w$B&qVWXY=gL({86;tLv{g6FlvZ$;$siThG0p`2FTZr2y>zWDa}i7#h
zC3m<_=&sM^sAGJv+kp+Tbn0lH^g-}+OV-bM6(X{7k?af%i^SedS{wWK-X#xVhDCC_
z*D@78y;iyEr?c|nW5PDazMoqA`Nf;3$)Dy#OZ30AjBYD?a&6DFzDBugH$L0QS!`c%
zueyeNZ^hr3xW{>IJMzymPQLYe`s$6dwyj^^uM<5rsB~q`MyX2&cg+74y`!w_i}{JQ
z0k^NbcAReu_Po4bIC(6WS-e;R-da{WoTs+^uN3Qj$)ta>1
zMAmk@|E?bokJ}zMTfAfA<6z&ZhVQI4P3$=|1!;t#BtRZI!m#DrbA?@QH`}&k{56^R
zuFdxUi{H<4qW6nW)r^=~CKD&0^>fCo>3KFkCY(BS%jc~2mZImAH?7^U<<8o(SE^E$
zZ9D(|?7ZoBMS_24f!Z~x+E4%O{jOWQ=7!aDp<}-<`AW0zz0J2S^z|OevwzDctx1;i
zc=Epf*aTaS?B7qiRqW(GyWZOoSN!6{OUfLiv4UGS8O@$L7`l`?RvhJTK$p|7EKudr6GlJh;OUtqi>Nq`pPEM<>{%*}>->#mxv)xBCdPi5!8S^clLnQB+ng~X|+qN%%
zX7lFryVj|e?ud4h3s8bJFH~z_(=`+GxPO*^W^L0oEdTw)+6**yu)sP{^c2&g7<~b6
zvoj0oUL~AnUkd70yW4C!Ch_7~pY3_}&uCTcCoA^y&0&4ZJ^xOduzgqVDaZQ{RyrQe
zzV~5|&dk@6&Htz7NV0#osYx!V33|BD=X|_v=9gbPcK%&*-bpt2vr(wnwY1mC=bxVs
z^NCtqE`41_&nA8=#_m^Gk=F=SbUBHEwlUW
zF1?fCVV1_i&J#uduhnY*FgzRfp=@u~>zb^Pt+GpfA37?heKD!}|Mc~9lZ+zuE^&3m
z-Zxfr&&62%3Z3+%+OYh0&pRn+w$6@IlKxXzU3N0eSbyz&tfik^=BAUn+;czlG@d(T
z{q4`Evzt|!4}X5VN~q_x%*WjSa>=t!t=*C%SGgkmw4lj{^?gOrQ~jA_+pbMrFgJho
zr?sYc1(t@Mz7l@3t?Sc|TXVTf9y}>q{F8Nmk*VqW3#XKn+OoHP-T2&i*;&2qdv@rl
zKkgQHPMlipV!Tx@vSR!B6A9-}=XHTI{Mrj2G8$!;6n{9qW3}Pk6=6D?PW_GkzV`h@
zOY`ghCf{C>^|Gqu(ILm$+N`;^@>7D)G_kGpf@$P$WX<}kU&35+0-1(Em
zc{kr=tGU*;iDCCT<{Zvw31(Z31E>8AW0Fr5pS$uwcVGGY`xBloj=r>*CHz==<<-6y
zKR>9oPG4Es8YU)qaN4nX{tu_MNrh%>-<5uO=ffTsP~X%lp+-B*-0aWy1GZED1nu1$
z80!)I`r@=3pEpIH0;im5eDNMd$j>IXvH0a;+r%ZOom9CTWiX
zw#MU%|G^{hNCW26UUq(Wp0&3~3B}dZjf6urL;JVML?}z9mwjs5_3Auir2koM|DJfe
zrSDba=CFk9Op)DH_go)t?s%eR;L3&iv({)vwsf$e^=eCXWGwL&)L1It9w+Ec+Q4ra40ES5`^1HO*y`
z44+T9fqJNchyOr&sLt7Pn|J%}m78Lj>-Vx|ZgjO@w4Ig{Ti64?f7XyucJ*}A``qS!
zm;ad72Rb+~JcW(3ul`W55<1S_Qhjr+t$y(3*O#+Q{_d_#obv9!f%mVNkew;HFI$`B
zzvr9B-JkiFeSWz~>PZjDpy=hUYxPQ=oienl{wYz*{Z43Q;#D)VyWiL9zKLdt0xeye
z_51yNqfZj{ezOcDfByP;Dqt_W|NA9ZZ;QRKKYiDoFT;M~um3mu_DvP*`^(VwMvvjy
zx|3Pc@_l06%_b#!Y+iQWS2wGC>3y|r(*$QdI_l?Pd(3QV-t>x=Wm2iqmll1!eBxHt
z%2bQ;pF2vPoQqxY=U(EXk_qRPysNe>^XzV$aP7vaeXI=eA6l*C@BdUPiCJ%HbV#%9DeI52GI56A
zHGdSGYphl?^xGA&|5^7-k*o5BkGi`0$(ACv)2g$wTX^G7Tz-1)zhwEBbK%c-EwpaF
zS3LE&Ovk>O$GVS?e>Q4OhXiVJC1j31uxUrMzdJg{gV(WHa6tyo?
z%(bPrIy`8-*x{G!|9Pu^-c@#7=+hsYN9VshU7Q*5eC3yu?MvU%oEn7eOF3H?EsrQ*KOu8jH>%vj#^d}a=%2hhEZlpVQ%*s?j}E5j
z2EM()pU`!e^*~Jh)coJyB`$}!*_?VdvAt@mj3e{^R~p+hH!nHwDcg2U)&9SK(d=!B
zSGR4+dF-=kTbgP8)Lj!FKj&SLaWQ6v`{u+;;?HktA77C_d!tG2O^sc3-zUXf<QZC-fB<;%T|b+=#d`t#o_b@F?g9gZunI)WOmDeHH<(`7G^`D66X
z+_ZR?%&+g8mz{TgTz!7}@q6p%T{|E(A2xc8@3e18}1x;5FpL9;I<-6G=Q
z?JK($Jh{}nxyZZ2cxFuh*C(ycMNMfnzuS&G_j6QUE!n9j_WR4p>7Yecm7Bzt{djfC
zGVTqF&;+AAog+6l`D7M@vbEj%fMv@=r(b2<_x*?1r{krKmK@iVqHk_zc+h>ayY1x0
zmFHW+w*0+d#l-M>!)^u(4S0p{?&r$e|KH!+uXx^lyGHo4rb`>TpN2ZleSUcMm$g4!
z{wH@v9XtK;gBWBKl%Hp(k*LCXc5~H=g0iw@A)sNEPNO81EeAFpdzaqQhHhdZH-w~p=A&WM=4@{-it*J=MYCQR6~
z{qKdUBL6vE$G*9;pgC9cRH+SXnZ?#RG96f!q7{6-tN84|@3~L|n+JAQz-$J3E+8)X~((m=ZtTrrt!`_i)
zAYud>(#+sOU+)Inu5wh5IYI8kf|`v|MmL(F-JtB#&aT^5UT-#9e%A9J$o-acMZ_6xKR
z!_=|r%{pllxvQ&TE4rd(O~lsDcGJx&pZa~r=6@9%g@gJx`mVgb<5|hqbF8ZZw<$Jm
zE94hY{T#l)<>TZN#qa9gT%9dGQR>gDn0=LgY_lHNzq_)}aq6`c?G5>iE_!@bJIfdD
zvNxOj{Ejw1zrp5$zahWmc7!Y1nSBlUU2)MSb;)h-h83sYF(rT&!nx?DKVMLn5gQxx
zRrztj_8>n$zb8K`bxNmataDrZtikMKz`WOGx{H5JjhZz1mf5TDJWp@4Kfd>+`K>2$>VkGl?98IblzE+(#U0`^LK4^_{-`?)()T&*TI4zaO&OA#t(nLOiWuV
z<3IVAu3~4N_s*>dJHO*4Iqm
zw|$Ld(dh-M+nB&M{>_7~H}aZ&sM+nz3(xz~{3j-=RkF>t@MCfj+0ES`eAM4-?W>aM
zw@qq(Yaj-t7T;NRc9NZXsYFzGTh7|w$;CV8f(k#T+{aa`Wj2)N*-WUDjhnwuTrRJo
z=Kar4GumV3?M$)($MpT<$Sqpw#`g=MQ%Y>RA8JMIIL#b5^DgTF8HW#RlVh71&prE?
zJ1fHQalpMbt}mW|i=uYF_lxG~wmnwiee`Uv_Lnb5m8<@KQ?1pXoc=H_pVTZ!$wvLV0P!&uiJsw%mj)CwV;e_s`lz(Do;28&jso
zflq0=KJ)nVg&ZPwJpJ0Qnl*82LG0s{&S21vnN8w1wln1L%RAStyf+Utj+*`Oo_a}d
z6XTXs?~D$V%io#e#%vR38E^XgN8JK@F`h}^x5-EOzv~RXxFfFksn|K;_w6RtPhFTe
zyc=bXY9`Nmb4ls8l+p6@p3K&N9N)5EU)aCTE`@(XKI4sf6@@|pPbZbWICl5+mmfE4
zH?_Sx+kPpwbbI>0TPIl-ghJ;d!a}!})PC}qJoR6c$rJ{LoRdv~vseCK^6krmPIbl%
zQMV@M4u(6kZb#irVPMGioMa$*_tUPn2jV9DuqNK_Y!>!}+;e^xSH%9k_vFC?2L^_1
zE$-{i%-<`)*idSDv~z9&$IXeyu3872JHz^7f42J9^}Vm`Cd=9G)}Q-*`|q9Ne}mTA
zhH88h7CjT#^lx`vklPL>4qt}^hE(7A(N^{R_dZr%o?kb0;zg}OenBm}x|9nXsX6B@
zOFn$zle(L1_SSY|D-+Y7ukVj`3C?aSo9DEbFUr@72cBdSUAny|=+%F3^K%z(e_zkZpmff9{`Ml7TjfcsFTSqXBXu*E
zP1bVv@72k?>}N!~u1=6sIVG|#sbpV@ZdU2fR~2jvOv|P-#%*?<+wwX5Lixi(;m6nC
z+q0nC{G3Pn-;YV^OXQMg2dytW`|{OX?vTYf&P*JqyVe#j6=%`ymbSLLs&jt^+n<~B
zs`~RiyRZ2!7Tdb})&2c7pXcyQI#_?lv#wcdEhmH0HSVc5X725(jyWi+#gM&eH^UFa
zmZ6|uQEOon1K+Rw7PPdqoVd7Z?oOBDqaJ1n)BNYxti5)0!rn*Ls`>Z!PUgZqk67|P?SEKyjM+`aZ$gOP
zsW+a_N-S6Suf7;GS7R1e3%mc@MejCLc#C#KX<4t4KDuttuD!D?bNOa%%MoF8c=Fhc
z+wu53^?2Fr9j}=G|E&9^(YfHk;`mPv5(oR!8KRQ6?{z3BZHF#+dtIJa*ZW%I-`0#7
zJU64hAAh##bMlU`h7GD+^QY8h+s<-3_d@lsV*2M3&b+I)Ed@=1Gwi6pU8tq}C&2DC
zWU4prA3FoX2J^Dp{09Bu|GpemUaWl^wBzt3ce~1uRA{N7%)n;g947jt=qeju-OZ)a
z>7cC;U82T)Hv4N{@3CX%VPHr&zbM2!V_tt=;JP!cPrn(Tp1swe(#|CBM#$0<+f#Fw
z3NU6!-k1Bkw5qzudydn8sc^|tQ_QT77W8VKzr5^(M_t|g7k7V~voJ6SOm$f~@c~j6
zUwZHWG>b1j|0iOP+T6W0J?COA{TQM|_5GMu#cblcyQ4bt{*V6ZgEw}Rv+}++3Vmf<
zEPK#ij7N!qpqnb^gbZ?FD8@&9k|
zUd@EuG6@FjfIT*?olMLxukK9#f=1I%4n5yERI;=lx7QE2BG$}i%{8zzyv!8a=
zE(vzeEk0^Yb2pnj|KikQ7mtWVN;_42oaE)J6hXUl8#of%84DCtwPI(^NjkFVy10Mv
zg8S-TG0U`es(7txx_eyf#a`EspTw5#u>5rS@K$a?hUkvdhA;a|ed?YyPv5nnV*$eq
zraz!9u?k8zQ$;)c@0~xP>8exIC)#ee_mkT59Uq^T{{8=|^zQQmM)TwLE)YNd{bVw`
zdV1IV{X4k4m|q+{$^7g5kNx%Tf~ptn8yFZE7?dW(vK`o`KQHKuNahJo)?JM9wI>W`
zakH*ElepzPqrv`jp(-`;HL?uWM|*q?gO-K?w=%a6Y|cTctBwmj~wu>D)D+}+>HQoCkv
zX0WtyUu>?$Ecf7dpzadpox7GQOlRpl`TwwWRsD0T*6Zq_Y3IxGCmip)nC!tIBO$S%
zUe|)%-Mwq}@8YRbHoJ#Psw{qSGN{N`^yG=(EAC6*dSBo>E9M{5Rr~wL`CmM~+`dW9
zd1+nxmz0$%-fN@9?Y_TXasSV~AIYn7e(*8W+f|rKMBa;G{n;30cK-ZR9tH-6sHE+Z
z3D0D#`$8HM*wdR<)uJGgc
zAGX}PyTfIp@$)4vOSMk_xp{l_^Zl9(+eFxB|Jlv__pgTAE1pXoX9e_6AFhAyJ@ci-
zt{s!A-#!yD&UIUSe&6o8KwteQzLQ>;s;|B_VUemw%9pAI`|bCJ+I>R5!&8KHxb||f$*F3wQ
z|2-?~0jvaKME#
zl9AiJzN&Qp_;*co>Rg^vO1soN-3&v`%rlqn(rT{omtRoy;h3YG`Gcas`5$Y4slJv_
z-rCNxMrMk%-t?uXMPizqmZ~mzd@Lz{{hY=A>*L!b4BXk)=?TWBp0X6+4UmjnexvZ|
z_QDCXH$3TglYHWmy6VL1J@@X#?pJvFNaF0&q|{T2Peo+bKNtBL<>@wS^UnA0?@cgz
z=u>cpCsNb#p^;JVboFnq_kS+AUKQFm^Gf=iE5B1u{{G9E~aGu({oXAv@wJG+d%Pb;4uTZd@Yxm&EgXzr)^Ze)N?0mV?
zkwGTfHa%Z&?!|P?!<&5$9z7l3H1F-r6-@h?m#yY=nZ&;Onb)z<_SNc3GA{Hbtv+zg
zfT8d0#MpoJTM}1t-8ug{zT9p{@1;)eml27$LdIl+$_geI>olhb%ML(a`T`
ztH829_IznkbZMh8D{D=@-rRr7Qn}t8of!M&(T%v-9CbIBpBGoN+Bd(R
zIlX|l(BO2$I$EjzV
zOx9M_iMqUGGFn`@!r`Rj-L&UC$!~OaJ_)l5_kZxRVDI$_d-m6cN-Zq>%9<(g{f*&K
zpUI9)2lRPwAKLG8u&_3M_lKY5ZNKiEd4K%u!fUMQ&v-4ZUfPK~3ZI`BzxrAuyKDFK
zfO+<}H#;)rGB7Y)(AzD`*|*xFfNjR+`FFnSr#D2Fd=L5bf4R6~d83$(w4~HN-t&>p
z>z*a(t?NHiTKM>>u3BWm`*iCsHxpL=6W}VIuDPQj;1!#DXwAciBBzeJXZ&btc^zA9
zDspbk{=H=rL2-2ZjTytUEP*?(8PxwR;W~2LT&Q7J5yP5IE9b}A@qet2S$lBKG5?RB
z#3nwNZhX>z+q!ADzuvjb=pD6^@961ZlOyj7O*uFlLa)tO9a!);{O;?8hUGK2yt{f+
zIV<*0_QmoY8|SX$GrPXlFKVNhZOrx=ARBkT*~Dk?_}8+S`_p&-fAor9>zC^P?dzBF
zN7bFP)t2~qhgZzJiAyi=r2Nvv+1lUZe@m7xJ{l)?UtQ8A_Jg#_m!#hhO2gTn7x+HX
zyF0tkly^S+JVo{=VtT<-KK?9c&wrz~zV!lc)Pl~UM~drr&Jmiv^3&DoKR=h;ncV*A
z)tAJ~6(Pu%T$`D=QP$MqR{Vaw)-oK~K_?|e#}{vCFs>A@cNy+gKXxpvPF
zc$HMVJ^dd0wv(?twXgAQyeI=Yb)z{pyz#kmus>6%6#-uuR_pL#KS&dReFil_YhxtFzGva~!+^Znx8C-iq
z^{pQET#mJM>zAGuk+j%-s&VN%|Kl%A-MOm^IohPT>|%BY)GZIu4Rq8B7k*gR@;9!s
z{x9oSyZzs5?mt`ba1t}~iI1OuRs6m0`DxDo<$>$vYtj#_J$2skuf^)puLob3T}TOe
zaOUZ$%a0vq>~;ke25$?Y)AbAt480p}Yk|(BU|@LIIG9c~3D{LKbA`h`p-(>!w}0PK
z_}PAmD5sFhGNYxdF5LRs{i6ER98Gmq&q+Oea2A7#IXLzOiC>wm{c+7SEP<_1-7$
z-;e*lE72kS#nJBcEydsepODG(oE*Gz($p{KYwhY5%r@7*y!3SX$y*j2+H0R3-T%1y
z!n=h__)$zq0yILXSF|l+T|PAxqtusQ-6L0x_8xo{>Pq|elkyIsU3sgR4v=olXJSi
zy}RvmbMw=sBAS;bZT}u@RaCNlkGyh~B4FJ5nZBM&933=_#%^cNsgs&C|akCoczV
zT=ewI=hflMm;U+x;iB!|wI5tMCwUo2+Se(?DNT*6bxJ=kyY%|{_*1|8`ue6QSJ!#X
zwPN16%Vm1c>*n_FFK%u9|Du`eTEwm`GoOf6zqwKPr0L;7XaAXO_S@9>>Pnw}ozKOi
zJ#GEcy6uxwo>y8HKhtnn89H<4F1PU0-x}YoDlPwbNm4U?nwhVg=J|+?k5-g^bF6zE
z`qkQ^INz^w)@Hvulb0^KvyKr`%>s@h}wSj@d-!%EZ#dEoyX0PHl
z(2qP<@lnWY=A8fUSt{@DU;Lj(KiRGS%_Q6UPhT4AYG1L$zyBkBZmzwbew^L(@3uxM
zOLHPWU%K}8|D^Tr-Mb`>%|cg2%Dt?%_ORs+jgw?lFYfyred7Fi|7klj9&Xx_dV9an
zilPhq3O{po$?G@Ha*MpRE&1&8pDe8JWo)Ktgf-^f-RGt5C#&Gj?tW^9p|z-XkROx$
zG|gl_ua!-E9;<)zUU0NAwlC+WO7O{p$%|S%HRJCsD*1H9v1(nDhPZh6soA?Zo(3Jf
zy7F?n#v+L@&x^Y0msH-BM6fQ3aoc#cNbE#>)4kQlWm?pHX0&{IuI;9N)u__G-fP{(
z#ldYQkt!WmV|iLa=OwNcKIbE@8Pm4z{tgv!W4}JXDW=|eT^t`DsV!j6x;H;0ZHB%d
zm#EI#Yu{_P-l;jRH1p58$LjnZ1@g94Jg25FpP|*g;QoT>_u=d2g}*M9w_Ne{+|{Zs
z@%0X~uY5b?_`^3eZDnxkgzov5)A@qsw({GAIGQ)bGcYu4u$(ma(HebriO`bTtubl!Yk!a{-#Z5|16*Fwq{15it~w5@B4cyUDwC$pU{&z$KvCY(qocNe9w01
z{4kzVx#$0bbyp(!s_YeQ!x^8(o}c~Y+uQ7uDxZo94oC)C+&;~y|35EQ;mqku|I~By
zSFh1yUtRO@&}k|Esq@#ZNwIu?Z^EXkWv6D=U*BDxe@4%L?uBcfXCyYN7S{x(_U>F0
zx9?Nf{GA?x5AXfGt=0Wj`Rl^>cXsNnFJz0`Uh^mV{oUQFuUqO@Ki@n1*ZWUZ9s^5}%UG#eymd{6-($89UJn!x{PVX0DU|?uDt7m**j^JZqyZb`V7+&xE
zF3j0sS+QxEgke(9+WE6iC7(N0IHBnGH`B%E3kp7cT58_9XAWP{>^lJqxH^867Qe7t
zzFJA(i$zA2Ov)=eVF7`RFSdCTzNSj`#p^mZ%$m1&=IMmaQ})*$vaHpSPPgNixs$d*
zhEMDDOn=tw$}j6bKDXE{d#kAE*p&Y~%M<-7+qU^RX`OFKxV(ARfddKt%umhPbdO)(
zf3;v{h2_qilH2nX58wY46)RAfu;w(=?;m^A85kHiX2-G}=oC_&$P?LGWcqaHNzSi%1?a#gxyVCwho;cq)EG%
zFS?xG{{kE;YD+$qlyv-wa!~vmcKA%*h4&>BRw?cJdWM(%^77L&z_n`Wyak
z>mg-lvo1;HX?yZInBDh1J-gTRNS9=ElWb)ytHYg)u4d_V-=)p-m@;jS?vI>q`80E>
z=UYe5$!gQjt9T!COwRgivSXfh`kBj1&N*yMK0bBV{yj1=;t}oA$4=HPK0e_e`#s)*@L!rAM&5dX;;iG_<#D&0^jF%f17Wr
zD}Ju!Q~3DE^wg*Gw0|@mG`R76-@2bl?W^|`%zd?|vJ2EqX*v7O=zvVdhXaD={oY*P
ze=O1{e&SH(f8fto7y>#`w*S??MKf8bM^hw9cg-uIS
zEG>i2G|pakor&*+|6EHQ-dz?NPgaTiiQai}{?ezCQtvDuz4*9yih8*q=aQX@cg{wK
zUv+iXG7>l6c4aERcEo}yfAUW{NchjUox~Wr!<_Hly!l1V4yBIb-;0|a&P|cG+2h<4
z_b#aS`tQr>{4ZZTUw22My!qJEBlDBf&&y8EyYS-?yDg|aG~?NWch3_4RzC!dL@}71
zTIUWq17m~8&^fA(gds-IK_3pmBoDK?cQtuU)$(2)o^SAa^XKRK#=kc|yt=cP->T?|
zj;ik~H-_`@fr$WM!>NqCOvAdLm^yAErL=%S7T>@#~LjW2({w8&NY*URPdFF!r)W_i}B^?Tyl4XvM^
za8H!A=DYO$!HX9j?0j>U9O)DeUKO(P>+#h(k((~I@mg~@iC?RntY2`vZ?4Fz-;Iro
zo8H>i{AkFTy0!1(vz*6!tN9l!cIOM6YZbb(?ynTf#KU^49X_<3WW3C8AF|-o)YU6{
zm#^OQ@6%K7O;;`~WY$=;HezGQzc;ZD54na)?w{~K{rtRj^?wgG|1MgP5WD-^B60n=
z6>rYQRK9!sxl!lhzFKZejbq*7^M8Gn_K~$J36(q?D00=N{-4i3cZ2kOD>ljB<A#O56B!S;W5FMYGw{*E$ES3XzQ3RkAfjXhas;BOQg4OfPxPvEb~LGId<{
z>!)$I`1&bdb8`2r{jke_u9a@DobAMId3Uq7^s?!9FLZVf{`2GG*Z1mPQzEvp#`)zc@7318(UU+-;uyZ+bTj_rnV(r3*{H{JXXJ_WMHLY9n?;HM9wNdY#wQSHHfWja0wC>)zo*
zm#>FLt_WVfZ;kP_9fieG76mi@6^m+xO!{6i_rsnF`v=K0{UV@cbKl=f
z-lYHS&CT%rc4q{yRq4BUDC?^pY!_96IZM*%g#?fALp{WOmBjDTF(K>Yn2o~!
z{^DKP!Npepd#&`8zPj5l
zc4odWuk^J=a<*21x3;{z!s^Ih`Ty?j?UPnMvp?rh^7@*iobXVZLAPqD8QKA4wvdD%*YuSPcaDBpTfc;Nb=mt6@uOd_+I_cv
zdOrTbi{E?haf@iU{QdoX%Iyh<+xeHs*RNS*?-;+o&Nudt?y|VOzfRs=UiDS#6lc!0
zHJK^vHhe1&4-cPZQONZ+^!28|#k*F?)Gza%uD2rj>XY?;XBMvgKG%AC@Vz~(8h&$j
z{5n4^@b>4tueX0XnQl5|tmEDsLm#af#`;Ojhd+{=9Cr-aGSE
ze~Xsvi<)YAyCP!Go_C%Khi|_-Ir+FYfH+>8qbc1uJcd#{GX;B?(?WcNY!gK!?i!J*b~=&
z->|LFPQK9cgZ%vdirbHl*ZjH2`MPFFa@O39aeJ*U->>%%EYI&gSyuD-S*cd=GMUWS
zDQ{Vuj61Whv%M{;G2Nee`M*lV#fJ}O{I{$5v0&XZ;hL|1TR4R$Z~tCwRs2ll=kMRA
z*LME;T0iN{vYmEye>CdX$M5I*_}8NF(Fz9rS-YI9JwWw!^nY2aEkW#>pVxc;U^r2~
ztMUV1!P{G*i`#Vjx^arOaJ|3#qGT!9*|JJLk|2+hbSxBFKy14qy+qY}$^DKJT
zMw?$+8GQVy<$Ml)nH#IO&6RoORo|VGS|Jy`jU_eM#`gWH1oO7QWwVwaJb3i#>giwS
zXI^+9UU~P{_8k?KQs(QvUoF#2E&SNDE8(X0`O1BJ=5EQldhW&Uw$mroPZmAZK6Nm{
zo>%y3W{190*xIOnS6{!poIc_Gd|T-)ayxBShp+bp741pOwiWV8#l$Z5-YOd_aN=Oj
z#YL)L?mxf3EBiX%ulM_l-7Y^C-gxfLPGc3}qcaotF-Y6&STAFLM)cahbB>((-lwPO
zU8zrBS#_pSz5e^v)%yGGw|uR8e9W|T&u(q?;(KnE%^@MF**_0i7@aoOsoCE5eXmQW
zkmTFx2XpW2h-3Ad{^aAHO5>H8o2OlR*6?0n=H}k^*6r@+7>XZAe~~Ue*?Q{VU&qfT
z>2izR`9q&iGXM3r?R4Mjd{w_WQ|8%Li#@a2c&v(_n?2$DJlm<=1^YUlatD@w7g?nf
zyy|{#%zew)U0<|j{E9tgv17xz*}J~qU-h6*SVD1a_&T@aKB2k~Y6HWUPTbyjZ}0Ed
z*RAD)f9%+jd_1gj{`H67-bQPLuiKN-d3m}2sh>R`{~zy@4eZ?1lU8rP=BwN_vvccT
zu8REdIDCEFtNE{6O6@KgZapWF?`%^W7k4i`e)aL1hf7NX%^1FY|889MZuz{=SJ&?>
zdv|BmCi$yZr@pHyt$y4PQ#jwMv}w)Df3^Ch+O?g#nqrqeD-3wHx%_=zX~GB7YN;Bb
z$I&IFp<?_N?4I*?4a7O=;P@f`k+CZyBadVSU^XzFoM8A*bT@nOS1`ajNXh
z;o;)D78#vQK2~EpKX$j%I=@4by$@c`)sNHpxz_RW2mVB~Q+@>24q0^!xXBu~*;U^XbmcV(C_uBbz>aR?jxx+G}m~
z>3`#|1mAo7-*#7k%GLam>WR!#OJD!HYX5ve;h7l^O-f(s{M@_ee3a6fzjLQgel8gl
z%CwI;S!2uBa~1zJVxO^dZu|6HU*+ngb91eor~SD0X^Yt5-1dI%e|3B0UrtkglWU_B
zr)7EQ>U4|51LC(uwZo>ZlG|l<;MtkSUteF>lD_Sz*SF)>s6$#v;J=iGdIcV%}zv)`Wux5|7=dU`5V
z>p^Vp7pZigHJWSw&ON;`^YX9w+J66SZ9Y@Y*`N1HZ=X;*r9W1tCNfI=eq7CKv%2@i
zq8xnhES+a5I69qVke0iCXqNl=dHYu;7&X_lz1P}U`FWZ|t=*c^q~5P`Vtfrd-&v;HL5
z*-g>^uN=uO&-3%tM1Hs8XJ-PoUzIv7T4!83`+UqD<-^N-UqAgOd;i!&Va@baYdZ6%
zJ~=hF-KsoKoBgGl?<>!3EzL>Uj~`6;w_C`%e;Er)O)cwrb+i8ho0$Loi#PI&_dl%J
zlKG?6s$a>qYxRM>`%h+CtbQI7Zn5dbrs9}syX@ClP128doP64{?oWJC_s7$}pG{r9
z^68INI&*Y)|NgL+iK(Jew&zta+yB@-bN1Yqm?pkm`hoBqzW404zxkH9c0W65^}LK}
z8b@~h^QTYVD8*;Ip4P}*;N5RoBlCbK&9HR#Txq4&X>1D6tw-ln7VPy}CwKU`i`~CZ@gL5tU0Is6_N&}9Y4bdf-<{Uw
zd7hhERtj{jMH+jGZz&%EA~yw`T*iG`nfe!FnmndaBe3*8!Dulu-VXKC5HJJYVVa&enK
zsQCGab*^o7RDHuUHsw~XXnTf#V%OKjE_`EIbZXb*O;4@vm%cWez@~cevdmS@BJuCe8H{>StR
z$sK{q-ie9ICzRY~`u<2zKhEawwv4lzPE6G{zq%&UJ+HVZeu~T<$!{|b?r)tw`L*aW
z|M!a9>wkWK@v1E{Xx_q|rLULO%$HtQP+9cMxcXa6O6P`ei#HV9-P12$yX5~Xlgvw=
zk_C^2oc}~;Us<8rsC!@S*@J1;FZ)HcxBW9-8?|-%%Yws_d2bHamKVjA7PoHQUpzNq
z-RhfLv-4LjKY9ArQg8jtHAxY756H+QYz-}Mvq-M^_^96Py{lX0tk(|@{|++kSY7zh
zsDSNS%(I-wi4VJj;^X62&O5ZDZq>t&*XL9g+&vey_0`E%WqJP{Iy?OG3l^>w&R$m@
zY{7iyw*mWsJ9=ux&vZV`Qaxmq|LV@(YWKPApH6&iv0wb!@>oIj<2Rf0qD^uXB*g5N
zeJJd#wttY!b${lQ^&D>7kpP<;C=OX)@;SbIwu~qE4T5Z=hW?@aqo@O{Zz9b
zY?3feYhl?Tv!c+gGkUJ{xBE6lPXu00>-o;QuKh*j`*k{p&(1d2)jNMdCwkk2ORIQp
z(8J>~npZ?(C4txvJhRTjg6@H&-&Ve+%8eb^8?d`Ex4&o8@dd+Ph;%;Q0lPhnMna
z#l62X>F*vHs{-=
zAM#0DXxBM)pSntZF@cNFPPbT8#Gakv6?YOkhu_^hGn4%~x4c0@M~UlEMw9!B4@#wDGF~5<_4=-e|9NYpTsygs|G&S#f1SPN
z8Q1GQD~rFpP|W;em3gUzbINj`zrj*JKe|qTxUpZKq4I8Y(VNF?TR%TPJ87PU#fE3c
za_&5Ky0zuyNuiqZ?Ok4RQ-U~E&aL>gG4u1Y|0k=T$t*q_`~Lo>-u6b~#edJz4QmFs)yo#p=Ko9)j3eOe?wVX+&p>IJyljX#48(1FSv~ckaCS`r7=`^pA^NKXV=s
zTEPSwD0Hkvs2}o*}kf7kaKr;*9!i|zIFYL
zZ?3I-b<`Pdi0@v*^ziv}_taBU7H3{=V!A9}x58oHKKYYjqFNzS?(MI3uly{wV6i)Y
z?bo{B-*i=WbcpNkW#{HrUpPHoKjY)0pRcz63JQ7p
z`0--B6e+O_ylGS&>Bq1^
zzbulq$-nLVJllmjkxhBJ({!Wf`OUG&`!e&@)$EJ*+Se`~Y+jymdt2?7-|r9i7_H8{
zylnBx;PlJ)_8xzde`(2n%}As2zrXM9`Z_Hq`?=rUtGTy~e!bC7Z_zz?^W}?+$rqOS
z{ts>}m?&WX`Oi<|s!vZ0VCFNgs*7)$
z>e?N)%3(M6+1cBB_+%_rZaOEu)Q8tJ_g0r#-W|{1-()AR;^%y%eqq13b(xIj>|bA{
zFU~TZohoH>R>rdErM<9jlnLh}^PC%+r#I%^4a>T>XX{0qAK$jEoAYy9c|oGC`hCCW
z??3(Cn0)-2|LF~@+#+7zl`+k}c6IvmdA9DEUtV-hQE+rxeE!4lJvOf0V#_6spLv(r
zCm-|q_5E;^dSa*8S=ViWdgl+fpT96oxB6=Cqj!^Zli%JhU!)nFCRN+6FJhj1Yw3TV
zOH1-h(%#IUr29Pc!`(Km&{fj4>T60rtv$W+@{YpdNV9wU)codXl~?I}T>JFoX^A~u
z?D^)q{>FxfE{@&pXH*;aUKui~D-#^F)XTU2zis%tZy&zJuD#_iroa3hU-+FJA74D5
zU%xQ*^tB7m&hFM!3R&@Can$$QU(W9oZ}ptKET~^@@5vA5x%c)oUs~#|e?I&Ay4BY%
zCjOjlU9R}_%*@$hdNHd`Jzn7DHD&7goEsZjwZhk}`nFl})r^h3%lzlNo}9egV3xW4
ze49$Ew|90<|MxR}t4{aM)0_+myk(LMyLY$*v)_p=a`K<3v^2$oH~)OmgR85{?V}9p
z%}jG5yk##~dp@|lGMN8wIvcN){iQMeH;mFg-<$j@AS6_**
zyme`rZ~J=tVA?q6SyO84t;X>Xp9u_BQ&{uAQ%sO!@UyI!JIUU)}Guv$;-aue^Vu
z_0-hW8oya??=0SZr|?1PqF}eF|KHuU)>Ke%nD_tNZHIOD9o9yhFO1r%wb~(d6Bko^
zz@sDaP5#B#PHxvx_nu`kbIp1_*{D?q_AZTjX5kh(=TK7bClmKgDJNh4`Dq-oHOunr
z?yL#BtBW}Al;G0?i}jSU*sxz_xz|*!rQf#ah3u=z
zG+n#*S7xE#N(}Pum?6KP;0WZ&?%}%lzuXjg5;{UOY>>~
z+&sZ}hhfr@1yx_ARz0t-D{r%zd?5n;6q-I=Od0_3_i_gp6-dfmnaQdf;
z$H%^E1-#TheLC{xmzTyKmG5qB488aL(=PYh+w&L3?EEC7=>o+1J-Co2Gl)
zv+T#)+vb~OqPOQw>1MoZ!Y6aX`{w570hjM8t$&$USpVyb=9T-&S(ZDcc-Aj>=@dG7
z(q>7i*tIJD({FE^t5z89k*$7nLr^PX!-w$sPw(H`U4DIioO$`&8HeUS-+z65{K?{f
zYp<`1ow}g=?v6s=zsGiKape3z{`|yAN{srmJx&CmD<>o6NKfZpI4jRPIH{P7~ckQ-=bBmU@E|1?;5*hjY
z^mKOL?>|33f93Qj{pbBVPgxj#gq7Xqm6CUe-&>_Q)Aihv5C5%ds;}O^fBwbinhVd|
z(yb<|`%lRUU+NXQG)|Abye4R~+PwIEHLNPdr_?+D|Fd4tv~-cs1c|6U6;tO##KgwF
zI5qXO7PoQFn%2{c5)L|DlKOkP$Z{3KmD4YZYn+Uqyq8~KWaIZMlSSX(K6Kte$+83M
zt8;94|0jHYP?~g8EhEeF?8mjQ=Qg{pt1_JJW$A0v1tG7P4tS3$0->&r8^{}XINAK^S?b(*)
z?|N=T{}T3?Fns3xp4J8XLsue|&cKwb=YENY5fu<*RJ5(
z+hjxCa%Sww(~Yt3d(BaFH}u(~M@o&=9~gFouh-Ms{Uv~D%7smb??3wUv)H6)N#WmP
zAJV?Oxx0N``99sVU)%owt2X-k^LYhl#j22%ZjweRzg}%#cIrp;^^^VfVGGON)=iC*
zu+TnxU3zbo5bE7dGCtco@^?v{mLUJxj)N%;o(NB>kHEzd8OaC{n&Yd>G6c+nKcb&=~uRGm?q5d
zAo@lw!x{D~OTEQU#b5ZWKXV4d$ukoFP0n3;vB|imp}g2zZM(@&Gv)t<4p)|XpI^_U
zSZ{CNy(thhU7&RNaYAQhMarb;ZwK48pFS|Ja61xJqON)PWii`i=J^jAcdP^@lIH@a
zj%=7y@^!{vyYvH0O{u3#=JV?|f6=X&r?}SG`&xB|#p3$6w?v~hCawIpHGF;CMAbDL
z{dQ!X1q}r$1*lg<-rHFmefIF=E>UewUd#I5YpXY8icHQDXba6-V>@S!<>|~F?~M-E
zUX{6a`&jSv!dVWH4`_7$-uHW8RuFSj3R`T)^>)-Sp%NxXWZv}OU
zth}PTvqXrer9^uU|FxaP&p~CL$71or^HdHUQmG4
z?8<#LP7l}c*l%0XYBSw+UChpvX=klIMei*1y1$RPw@QWM^xA0ir8e3jD^$Mb?riiq
z+BJ3muRHqtO{QOa^ym7f)M!qVoqd@Xief@vFP?R$>v^U~xML(}(B*XtGyA{XKgVYl
z6J=anv;2^y~u+Vf#;`MkD7e%@#6zF+u|zEe-V{P6M1=cehpccdnEnO@`6XHPHP
znJB8yK0W>2oyz^cWYQaNuj0{37txRVbN+=bpGDTyRqCbTI}J6%l^@A@mz;@I-}QF#
z_OmYxlWMls&e}XD=5N#V=I^0-DJOW@ck*rKyMFQ6`=YyS0L^6soSjc{;Pt{@)C(y}2KLu`qDxdGWQ@JUmo6%b;!xi&f2s>i_@d
zTD^F;aAolS3EwSGA6R>7ciCIlv$M8NdY*oM-m-^>+3QSo*QuZCeW~UG*+x~_
zX*qoH76r`;bDy3T_f#;>xsk!?VfiC!V^Zh7L|JyI=e1Z6q<7a=Pz
zUD%Sjd1-vkx_bw1Hp4m>QHc(gRG@?YD`;B1}e+r8#mZIwB`W36L>%$=eQ
zyHZX%)owUtYjS4pZilLhAMf`UYgMdY`)$7d-j{D~Yz+T*e@kZbt>RegK2
zSWnE@oB8TQ<@VS2A6p%a-CY*+JE=bF>MGN{6(5~)=XYPNvQNEM#KaKM47p;hhMDhM
z`5Oy`*uK{7+j$s%l+84Ly6AR!Udyk#*Vl4Y8Dh6&FuIDoW8V?J{$0t}SE5|&Kj@_I
z+9Fr-^;RVlgF*YH9^yw)zVZTeFdhTmeUt3iVqhH?ohS67L;HM8AR
z0(TViL8BD{t}UUgN?OjM91I&+Ryi;*xCq|4aPd~`&6~{+V!fFdT38tk7#u`C{yOXJ
zHAiFRvZjQ8YyZstxt*C|26w0e1H+;TDJO#h?^t{^6=o@ZHof`2d%seefg(f4EZ9P)
z1>zi_^<5W4hTg@cN8d3$I5V?I5afP`jCFC=`JZap%gy^8KpQL=E^vf^f|~(xXA|rU
z3s9JWc99I+m8(ZJ{ob@Va~$B|6jE98?X7j-_Po8C8Z=dkGr#ekj=cm@9vd_&f-!w6)Ls-*30`AefC~{I{xjZqbGxEXI)vWm@6gh
z(kIXLWSMV0k17L0hm-(A`+^XvZl*UbL?{J;OQN=FUH%Tbj1M$?^YpZM
z@a=8BD??Tqh3qIW?8@P-lXn)i`o5fd`#T{91_qaP8nO)P#onG9laH%M8;|8nDZ?_W=zyMs1)>|9i$yu-h4U&x*eORJI;@x8Ub
zZ!LOXv|zT1XV6quKc$=Ad<~Wg&lY$tulgGG#rtvE6eCxys_*Y~Wo@fw{QFa^v$y7F
z(0@t31^@q5>!qHb*S%-&{L6bPH@~R=AMHF%BW8!jT${?6RfE$=(6M(O9A&Ye3~^7E7GEQ`XPU;mY69=`MR$)`D%
z#f>pM1rBFJ4S8NGU0)M9UCJa!=k=4z%l)(F$}B2;>~^VJ{GQ^u>z6;Ti!!&|U$r;$
z^0Xk=q%SWL#n$U@Z~3?~e)Ig50o!~;wB1TRRekD7TX)y#zjeWtHSdpiN;dPIo#&A(
zzIKt?)cOTWvZ~DA+b