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("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{@1l+$_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+Oeawm{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~JXp9u_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?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+b1>A^*O8Te7Y*&RX?O3 z@SuJ3wK9b`xA4!~zhv3QIeX&w8)jRkeS7O8%_q0Gm%ab;zWv89UfAiK`Tb;ZNzFZ# zRX=YRRDJkj`Sj$Jc~vHBd?PO}U38$q;n(|Tva>dPiQiSDo)KLbc>2a#N8eu?J?gw< z^!4`E{O-H<-rj5Szex4l>Cb+Pn#z7lUYS;f)j)%7iQ zq9-S|F5128!d&+L%xk~aZuxq`^KZG9sIJ4>tdJ#QhyO3>D_s@%v#Ih$Q`9uM`m<`q zTUSPQzFKYMEo3ieTluj|Ebo@5_x87jzkE)=5?vWN^^=IR?(xQA@yrWnj($0Jyq$sJ zz&@9kJRd$k_kVHYV{OQmg2-;;gK=@+RUSM!@cqfj0QOvR8+!Tv&hq?Af_`F&4|2zHxoI>_7F%*7*9jZqJH}iYER4`}ePN z&3xv2A0Pi;r0mWY)XcVZMcv=|FRrY7Y*O)oV^fRe>BtQUinB~IWlZy8rfEYbg zGlgMpq2&iw#`Tk}N>>G~4%{nU@u>RQ~$;`(^$7tvp#*JD9HSk7w&&?iI`K zwZVLgZ!+f^9YOOjpH(;0Ekv{LPX0E>#{aR^c9W|83ER$SIoSG@{_dS}U>fu0LpP02 zS6g0MzR+$}&%c;`)3nOvJ$TuBm%h-uxMK0Gu(PFq?=f@Edwjglbz`#I@-Lcqo|^wy zz2M73B-Ud+{XKIrXzw}8Ejio!E9<6|Dfd?$X8hatIQ>hxblcWkC;6*KeJ(E)URiZs(Io$I znbgH~E2HM6xSf9e;(iJ?7lGi)VgS|9;0i z*QWNwBZV2vvF8}rSq~il%D6#~d5$J%)CDp;vdX(4pGn$m&0-!&o1SHx3m;pZZ2I*5 z+L}mDe))ULzr3_P6UTS%O_9LagYP?qkFQKvs1#rO?90o^LH5&`L;n50u~GTS!-;|a z?b5yYiFHYBwU%~TH?Q5OdCNaK{S*v*N9%?PV`o4OB(b2dq#m~z$^}T0oo+E9( zZu%Re^t|V1XL~MT(!I-i^zCi)r%5f>Pd+~0|Ki%(*+F8{^w^^}r8MrEa64H8Je5wi=mpHYRGlUH&aP&W-1`MfUX{OMy0x{MKX|gDu!{8Q zP&n{$h-`UT_EqJxJ z&Er|@^OvWuZwX4hX?yk4yq#sishj#=oR(%_c+(vl*0>_-XS(Qji-<3>>gwsI=C8MS zdwc)UHRr2WHcXY?Tlk^m*%{4M5gRvM*?;vaUsChB+rKB!V(!a{B`H{n}7Mvymr>tC-+a(JG1pK zsrktrG{>Uw%CABdd+yiUXKkL-7MW3Z|9<~z;}^y;etXVYe4b-j99GmTH+PQA+$C9F z6?MTCd+UD38Lbr!nKs#Y*`BpUp$y*JZ!$E0dT{>~&*{VZKeioBeJE1md)l#hTgl4I zkoIRn4sPXzfvFEY+CvWH=R}2Dvi~TE5NSsSx@0@$_|)wQ4_P1@2AX z>BGoy;NOi@hCOwMgSzeJYXkYqB6U5ZU5%!*N%kB38{Lm;olN_rE`9@?uIRKueM)cUAB<5KxV-o(@M6v_T_C= zUtc-N+tn;E$-H!Fg5qKgqrz!*mzL~*`F_9p<@xn}(;Jq0P0iBMmap12JO3&>|DOG# z*W;hAdAT66`{mEo3w~`o-S&0ii6b{7*B$ZOXb@z6Qzby6djLfBp5(ui|f8vpzaqSsBMGwY7ltZqVh&7W?Wy&+oN6 zeN`bUY_*1?75kyvp$==ff0X|Ezt_R&c-y>vHK||zOs@YSE%J%g`Ly8V6$#gRzBEU% zy-VsdJ~;Q7|Mc$+3<_DDpI&lrUvg42f8L@?0`Hp7S8v|`{Xye&mcsmdHJJuaUc68^ zD0?}T!&2ee-=lr9?Vp}%XPC$yPmokP*AO{LQa54)hwtov(?bj&>|g3F9<(swszOt` zr{(Y0`2su=Y0asUbLKj)JufZv@9e($HA}rbJSM3YFTb+#(uE%%7ccGJxVt=GI6QsB zmF;@!<^oSvX=sP(EREitccN%d)z?!`%6Ht`n7G(gHZDJAUEjkKA4Jbio%-)zx$xXQ zHLb1A_ zPS*YN0~refB3bw~ewO~z3$N9fZ9hHmEBnm;Ti4_ER(#+qU>U zKH8^x)y(3xb=pO*mH#Fv_~ooh`4G*(pzx~W5@SKp_bXGh!xgP6GTL|9=GA>TZQ=1X zR6T3m`BOXmUR_@9|Mh+Gf~nf!d8;Bf^DXt5*aez7t^Linq(`!MbMMtvSEpq0+?ijn zHtOq%ySA+?>CYZ$iQl=A_kZ8rUkbl3Ec_d!e&d76x!xyTBAR-oi9i2OQayd4SNi(b z_r+{G%ijLl#Ir}I^wpm=_x8Izd&KbM%E`x<4m)|o?X6mAbbsHKC7#8a-CHs*2mbxl z+hccluT?>L-pNU-)1S-ud@dFj4_~dTUcT{I>E_c@G}S>PJMAH{oV@8@>VGV>nQQT} z^gU0nwE6O8cK?&VHeV5bKRsl9jps?GcEJGPfSD1XND(~Vhv{UyJ&1Y+dBzh#$yb$Q7;6A$m{Hj-cSV%)0|IDX7ISo`PE z(o?UVExRLm`E=2gQ?r{trT*K+XOli-r#nad*87G2LTx+TJNdS*_GmWI^UqszEi!eg zrNefS?+F`gKV9!NUcJ)i>m$Xz8u`n3g0An1IF}QYKD)XZbo7$CS9$X>$@|Rg+gCog zzGs1=Mc$5Mz1Jkz54F{Ph}%&x?^uuI!lr#gee>HwQ{BnaeFGgi~n!$y;u9= zQfSzuABCB(mpuIS{AOsv@z~b0aoeXZTk~tvy>(Sp7pKI@swnLK{K4?+%%pz~8t!^A z3teZId%hQzshPjCYO-Iziq)e z69$GG{E)GL-AC`%9qo~v?AFUwIU_9g|E|*4VaxN6M!xf#TNNTPvUU_xCSf8*P5^e*J${#yz?hzcQauK3aGlbhJ}KY@ii0&$Xj>`~UvR z&Yl|EyY7AU^?L>Wf~MJFliGUkU%CuB9^r;Q%Yg(#KBd|>C114n?UkFyR>i=;AW*u1 z3$)RnA%n~40BF==fhI=-Xh{kg`_4!mt~ol9fnm*(s*{Jd{5+osKHzT~+ii7*7YYn* z91~5m&2H`e&5;@ScGJ<9e=fcJw7vM1#-_G6FE3x%SXn=5?ds575^Halw=gj?Kdo$= z^3G&yxVx3rcYn=(=gsPuZp24Viazv1B=XUb9>vdQ>GPI;em3QNn08=?!Yj^wwM-3V ziVS886A!Cx$-5h7Rq#M%ZN$cTTH)*b0s}Xtyfdl$6ThYSxguwjxL%Yauat?JxOn&` zcNrbIu*PfOH~qZ8x$44B`>!*t`nRa0UOF;GznJaU{l>+gY-VjXKKt(a)V@FEk*Ace z>&t>#@?TE;X7zn}voYu5?CJ$e7$ zU(%=|;QYMWmv?qv26e-kPXGAvYf}IA^<0bYO`29v_Vz@p_Lmnwxhu~}hTr#}pYd{= z?XU9{`|F%1|BH0rf98L8OzfhI?)Uvuy#3<$I)X+(1irG|w_|j;y{LrY&&l)CyToC7HrTj#(|M}fhj8e6{W*AKTXMA<_^+oYg=8LCj23_1;o*%lb zLjLd1=Mi5mG^)2`T=a_CmZO+^cbDgX*>fBI{mRy^O^96Rq?&bUiE8Pa8O@dRhPcL4uAS{ZM3;Y{=Gk6o}HE6T%Y|h zNp)rM^?6h7*s|W;Ssa~uE?oNT;X6;C+_$d&rgUpt?q;qtzbC8KX-t@{R+_)O{@nIW zH~$%nuejW@;P}tY6#?7XOf|i4g@~Lz*{OH$(~^ta;TKw?%b)(W^L-UK=||y7&%NPB z{$*9~KTiyMYvm9eo_oQ6dugxy%8UJ}ntp2}O5Pj_-BtNtG1cSYy7}#vS<^M$&aMt! z5aK!cLiE-+{<-^IlEv0uVn4g|)3XI}`>!k%@f5namc9Rqk&KEr>-^*QEqr;VFwSt*C~m zt1CQo+x~mXz7ARZiS_e^*4gDNj>m6Kjm-}_{o(NXzfYVRQ+=k+na2O#JJsr+TYhNS zEgAc%T28vF!t}OY)KPBXE-aS2@)04k1o^dfY^TZp~Ur!&cGEmo#U(7nY zY}VHGZ1Lx;`Qves@qQPz11qWPC6U)r(zVvF9| zl|k3 zSNi^5yH)8c$K>OD>fRPsRu?WTOa_gvXnnpFdG>who_DWac?qlgE##HHcJakU=c}Jz zgscbZ)*Ok5+L$zx<+q;J=IeVZr5C@9Otw5+^x}@{&a3k@OTDCSuT;JI%Gf9FdaC5y z(16q1@BL)?b9>9rqM#?gb}3t%ES_J*f7xF>y=}{tx;G)OY*YDCJtudieSPhut{1yF zS$w@0!`*#lp}%UMDeYR)^z6vT#}^yKcwam;&NmXD&r`kX!ME>{+%I3n>UT+7iA83jhSnkx}&uwRDOCibx+>OeBD{v+OFk=fsR*puD{p4 zzAk>^o0%`$`)V`4e=!tdkoKBqAvkwu+2nt5n?0M>dD`9z?0zNsMO^mqEwhPMv)8)$ zE&48RAGC4K&5WAo*FqLw7pVGB8F^?H+vDzedG+U}9PbT%V79K<``1SAfC-Pke%Z-o z^V=(6U$WPUuiFmKI=&#R_r&${{dJNy^J?sVfjSW>wnd^7mg~pAT>cn3Aaqjks6CtB;?-!@&0@0}*&2ERKIhAY$NFG!YUzti?qa@vM(p?8al zCfp2(olqNadZu|Xo7Ba1nz6y+=`vf|L+7Vnem+7>#A zez`yWe9PALn>Jkgy>0RG6-#%0c=W02@4LM<%VQdCwmkkJ8OgR!_>eez@s)q(4rklX zvu`~uvRw1k)~;1OOgyi&)xD=Qv~Bs<8us2&Lww!&xP3|t9P+z)8s^Te+;}$o=sGQndSbHUmLr7*1P-vi%jQ%hq4~pFMjjx)z$w2i`~wi z2zq&GX*lDD_rG}7^zE)L>XNnQ^PT^1x}Ep07Z;gV&P&`Ov%Bo=g8%=xtv1-#{pskF zu~fM#u|{>x{a4SX`80jLxq12P>BT=k9Szx%!Pz~1GV>cD)lcoKC!TUI-TimtcW=%s zS6H5GRE*oU>#zGo<XBxb&HgUC?Az8=CyMPDi;rdB+Asa}>CTML zdM6h|8S%%R>t*kM9Ur|fS>JSZ)W6l|kA+I^JtzGC->XvZI_>*$&l2VupFC)3Z9Q@R zFIkzK!fUn`>x63pQiE?!7T3>Q&c#!d?z6t$e)TWiz3S($rXPN1SL4*HJ$35+72G!t z&0>pQuxY`r^XCH`b7V_D9=_?b?)&Mdf8IRJp8Au2Z=_4aX9=Tp&ApZ19akOL(0+ce zYQbZlWr+-P6kUy^K741|RdoIJ>dJx*M-EQZWB9i)A&=AH>{Qd-tjKg}KAFqYLwQOS zW3~MGn$Ney&eOlYZ*s$jxx1o2{CZpc_0L)^g~gI*yQL?;tb4ucF8h_EL87&lZK`W( zL$}X~{XfIge2?6T;(ZPoOW%1c2`^XRVNM8sx>ESox5~Sp8&k^e3LTxkz`piV$|j%d zHqA^AHhc>eyH@o)^KR{z7O&^0HD9;BKK6OvolK_-cE@Md?|bo-yLc|^?6MWRMMYII zo|P_~?5pcPcb{_ayBr3GA2(7NVzz!dFIJxX_{kHWpPxmS*Z$r<aS!wN~-LuefB=T#^C9%&3=k=9vnV&RlA9OzvJfznXFQwf)!t1iEhok zedd92-<4gZ)l&;|*Vgy0mR=M6T|d%0V!!EFU!j-OZN@?gRAeEq(6H=4Ls z-JV(#J%96ox`WTe1$2HqyZz(bd}ify0>9W-MRVZ`wxG7{kHh_#>;8`%1iE_lDxO+XK~=-#>W~qP2pD`KDWu2 z)sx=O@c8F1_TN4cmoL<+|48=!c74OMgN;eYcU@iKv~bT97uLX`-yy2WwF|qM{jpe+W0gf>;FyeApY3Z zzgC}lcQxVQ+sg^t|N2fDAfS^Y63H&Of5U&ag$}RC)8WX(pCW&jyD`9jg6M zng8v(MR4TWDBr)u`R|{Ddeo;TDsQ)${-%HO$FI!k_B_TZjEu)56VGv2SVm7Oe3WVF zam-OYZcl|4_ueZHj2XWK-p%{`Y1i`qpFf;%d3kAOz4yHS!+d&y( zj9+UNb<;nbpS^odkXzQRzP^X|O`11dj<27;T5O5uv?*@7Kis8Q?$&)c@ks2UV(KZK zvsEvRN@A8ed_OK}f6mMJWQ8S*_W6KQ$}9HB>_27Hwq?$oqkQvza%Wqf{kc}gVa;9X zs`=*`wO*eSm*<;bTs}v3vd)s3GmGz)HKng}K2_3oRI}yNmS=a_)}{aYzJKoxf#%foT(H;7rvgcm5nkzsK`J-tp|M_#2j5pX|AKn{|KFDu>ps z>y3)9S%=?z@V@9O`#HDbWUs&Pd3d7IWA-|k_g{Z+f1%&?voM3p91U3p^>TmTokgOH zn%m1J^s^q+e|(SEs_c!^(eCM!95y5!Jr%OZC9_ucTK4sI0pDaSR|YLz^{EcJ-s)0w z^SQOr<}1_A?wX?NZRR!Cig#IHl@)>AT_T!(CnqmokaX0`qda$G(owT&yLt1_ z`HLzFpMF!be`Y^dyZoK!>i4_sr%ZVIU|#_H_B@xF!ON$7-#JmzeA&;fex_3w`l+oq zUmK)%?&)H_OQT+GmwL2DQeU0_#e^uX#e{hTz3_eqb^ zdgFDsGd1^#o_AdBzUoQIgEy@L_QJQS_SYN_z83jasCDU@9sA7epLN^IW=~D;Ud8q_ za&=c=<-IRe9;%<8ElAqA>EyP$BhOY^)8qS(dlr*?W1BA8nxIcK!FY#p+AlIqcX9_a@1%yM4xX^Oww^ z@IKEy=fCI0xa&1B86>bfv~*tD>rim)`+sCV-K~gW#X$sXV}+o z<5D`i{+(gT3x-!07wey$v(v%o;JokeE*)wue);U|?6kjO@8;z`II!=^-s=3UOG{Yq z?yYXWwk|gH3dcFA)0ujChW0i0ieCNt>~G@2x^UOJsXtUM*Z*RAd9KTUO-%WR`?l;) zU6R)Yhv!{5Q#$+Uw)8g=7hkrV`Xysk9w_zfMM-vqx8Dn^Xm!@DQ_kPsGI>tK&WS?b zk6dtKRouTl$0Pp==k#W|cU@hDS9|U+J2zQaiCycCLjKcr%wncZj~gZ%-+h}EHt|>Q zof(xq(_^kSv9YhqobO1@*Jslb*LJw8e%8qP0dL-eGp?7;UTJEy zU7~o+cdGAe(@H+ufW~Ik9fp^t#L1qp4Rq4HY!|$bC*+W=e$T55G5>4cy}$e9|F*^I z-j6kkdFF2JY1@|Tv)Y|^fq4GCOLzX~PA=Lm!k#MA^Qm>h(!VPI%OCUg&e$JQ#ryEU z=bO*-E-d@MJMh0`_Ehz7O^&rT*X1p=#AEAAcwh0hNiUfDH|F`(fQ;W-kF{9;&i?OH z%vLfv@A-~hPUc%>3$N!rGum4Fs$j#pyE``C=9ddTBN5NWz?B`J`fOeKxi`fO=X>w+ zthWDP_3ZwcyK?K~Y#sUp=lH%et<+<5I08B{^?<6{0rLk6Q#ZTBs-0V~;@pSrMFE?# zZ)Lo)OnAtgzhlkLz|g`6T5QI^ z;1E24kqtDk=}Y^Aw{Q=Dws}mvrOTQ7Cwaxc%B^zECqI7-W@2Dq=*Zz{I2^FGLQ(4O zM$4^oU&UqjSA_nrdb{aprpN2}eZT%Jy*=;BgYSkZeCq1%pec!eKda~WZ+U*jQp)nb zeAVxR?Q1lvf0>xjEaK-Twm!?h z<2SWibb%+E;mZE?`adgvpGyAU;86AP*;0qBMa5_<1f9s%bxjd z*V<42_*siCFM0R&%V(?oCA8)eh3yuJYy2)oiQkx6`zy%TD^3R%x_!xg2kH@U@ba8Ua&}-+aA2 zxm>F|-Q~83ayZHi^SD9)! z-nlV}x6eb~{^!KalP`y_FJBk$%=)FyB;{LB)sHjL|En%*JzWvgXmch17gN=eIREK3 zlDyfsTlUPeS*+Xcze?w;>dLAKh`|$`47+{vzg&KBo}G;?X!XR&Fa93y*SbBo_gHzJ zuW1$ULiVg#mbRBJ9hlae@#4i>N4G&gV4>`k700UX}7SY+mN8 z+xWhX`S_Rj#|uiA22~cF_$=Zn6!>O_VaBJfuObVc9bC5lo`?0ao<$a~zM8+c2OXB! zuy(^~h8gP^8UM#-e!}dW3M?-kO zoI~TQ`Po}CN;8cv zfZ@I#tXY31asuecXFI$1XaB{`^O1b<^mh4`swXOy?;gBxSnc+0`4`Pep2~*Vj~#mC1 zUdHf8cip|Olk7VEV|OYxmfU)h7h+bg_pY%Fk6oF+Co<{v9vS=D){a_SK*h^zwkg_TdG}mDK02~!?-I?AScAd%(Rj{hLSHv0Aexy753h%80GrL#BZ=Rod@PM4iT~(>OA&hnFPZlr9 zPx^Mh*V-iMUr|=@=1Dy-zP%|n&$#^muEzV~=>_}y=DXQSU!8WWI7WQN{rBIF`~3ZL zTJ((bVk?vD_Vd42m;c=V_QlDPFNZ`yMMj4XY@aj(!vaAGE=iClOa^|T@2CqHaL2-b Z_M*KdCk0Oa11)c6@O1TaS?83{1OUPu=PdvL literal 0 HcmV?d00001