···131 "attemptId"
132 ],
133 "properties": {
0000134 "attemptId": {
135 "type": "string",
136 "description": "The unique identifier for this instance of the age assurance flow, in UUID format."
···143 "type": "string",
144 "description": "The user agent used when completing the AA flow."
145 },
0000146 "createdAt": {
147 "type": "string",
148 "description": "The date and time of this write operation.",
···156 "type": "string",
157 "description": "The user agent used when initiating the AA flow."
158 },
0000159 "status": {
160 "type": "string",
161- "description": "The status of the age assurance process.",
162 "knownValues": [
163 "unknown",
164 "pending",
···175 "status"
176 ],
177 "properties": {
0000178 "comment": {
179 "type": "string",
180 "description": "Comment describing the reason for the override."
···1321 "subjectReviewState": {
1322 "type": "string",
1323 "knownValues": [
1324- "#reviewOpen",
1325- "#reviewEscalated",
1326- "#reviewClosed",
1327- "#reviewNone"
1328 ]
1329 },
1330 "subjectStatusView": {
···131 "attemptId"
132 ],
133 "properties": {
134+ "access": {
135+ "type": "ref",
136+ "ref": "app.bsky.ageassurance.defs#access"
137+ },
138 "attemptId": {
139 "type": "string",
140 "description": "The unique identifier for this instance of the age assurance flow, in UUID format."
···147 "type": "string",
148 "description": "The user agent used when completing the AA flow."
149 },
150+ "countryCode": {
151+ "type": "string",
152+ "description": "The ISO 3166-1 alpha-2 country code provided when beginning the Age Assurance flow."
153+ },
154 "createdAt": {
155 "type": "string",
156 "description": "The date and time of this write operation.",
···164 "type": "string",
165 "description": "The user agent used when initiating the AA flow."
166 },
167+ "regionCode": {
168+ "type": "string",
169+ "description": "The ISO 3166-2 region code provided when beginning the Age Assurance flow."
170+ },
171 "status": {
172 "type": "string",
173+ "description": "The status of the Age Assurance process.",
174 "knownValues": [
175 "unknown",
176 "pending",
···187 "status"
188 ],
189 "properties": {
190+ "access": {
191+ "type": "ref",
192+ "ref": "app.bsky.ageassurance.defs#access"
193+ },
194 "comment": {
195 "type": "string",
196 "description": "Comment describing the reason for the override."
···1337 "subjectReviewState": {
1338 "type": "string",
1339 "knownValues": [
1340+ "tools.ozone.moderation.defs#reviewOpen",
1341+ "tools.ozone.moderation.defs#reviewEscalated",
1342+ "tools.ozone.moderation.defs#reviewClosed",
1343+ "tools.ozone.moderation.defs#reviewNone"
1344 ]
1345 },
1346 "subjectStatusView": {
···4// Any manual changes will be overwritten on the next regeneration.
56pub mod actor;
07pub mod bookmark;
8pub mod embed;
9pub mod feed;
···4// Any manual changes will be overwritten on the next regeneration.
56pub mod actor;
7+pub mod ageassurance;
8pub mod bookmark;
9pub mod embed;
10pub mod feed;
···10//! - Content changes (via `last_edit`) trigger paragraph memo re-evaluation
11//! - The document struct itself is NOT wrapped in a Signal - use `use_hook`
12013use std::cell::RefCell;
14use std::rc::Rc;
1516use dioxus::prelude::*;
17use loro::{
18- ExportMode, LoroDoc, LoroList, LoroMap, LoroResult, LoroText, LoroValue, ToJson, UndoManager,
019 cursor::{Cursor, Side},
20};
2122use jacquard::IntoStatic;
23use jacquard::from_json_value;
24use jacquard::types::string::AtUri;
025use weaver_api::sh_weaver::embed::images::Image;
02627/// Helper for working with editor images.
28/// Constructed from LoroMap data, NOT serialized directly.
···80 /// Contains nested containers: images (LoroList), externals (LoroList), etc.
81 embeds: LoroMap,
8283- // --- Entry tracking ---
84- /// AT-URI of the entry if editing an existing record.
85 /// None for new entries that haven't been published yet.
86- entry_uri: Option<AtUri<'static>>,
00000000000000008788 // --- Editor state (non-reactive) ---
89 /// Undo manager for the document.
···244 created_at,
245 tags,
246 embeds,
247- entry_uri: None,
000248 undo_mgr: Rc::new(RefCell::new(undo_mgr)),
249 loro_cursor,
250 // Reactive editor state - wrapped in Signals
···260 }
261 }
26200000000000000000000000000000000000000263 /// Generate current datetime as ISO 8601 string.
264 #[cfg(target_family = "wasm")]
265 fn current_datetime_string() -> String {
···359 self.created_at.insert(0, datetime).ok();
360 }
361362- // --- Entry URI accessors ---
363364- /// Get the AT-URI of the entry if editing an existing record.
365- pub fn entry_uri(&self) -> Option<&AtUri<'static>> {
366- self.entry_uri.as_ref()
367 }
368369- /// Set the AT-URI when editing an existing entry.
370- pub fn set_entry_uri(&mut self, uri: Option<AtUri<'static>>) {
371- self.entry_uri = uri;
372 }
373374 // --- Tags accessors ---
···689690 /// Get the current state frontiers for change detection.
691 /// Frontiers represent the "version" of the document state.
692- pub fn state_frontiers(&self) -> loro::Frontiers {
693 self.doc.state_frontiers()
694 }
69500000696 /// Get the last edit info for incremental rendering.
697 /// Reading this creates a reactive dependency on content changes.
698 pub fn last_edit(&self) -> Option<EditInfo> {
699 self.last_edit.read().clone()
700 }
70100000000000000000000000000000000000000000000000000000000000000000000000000000000000702 /// Create a new EditorDocument from a binary snapshot.
703 /// Falls back to empty document if import fails.
704 ///
···765 created_at,
766 tags,
767 embeds,
768- entry_uri: None,
000769 undo_mgr: Rc::new(RefCell::new(undo_mgr)),
770 loro_cursor,
771 // Reactive editor state - wrapped in Signals
···10//! - Content changes (via `last_edit`) trigger paragraph memo re-evaluation
11//! - The document struct itself is NOT wrapped in a Signal - use `use_hook`
1213+use std::borrow::Cow;
14use std::cell::RefCell;
15use std::rc::Rc;
1617use dioxus::prelude::*;
18use loro::{
19+ ExportMode, Frontiers, LoroDoc, LoroList, LoroMap, LoroResult, LoroText, LoroValue, ToJson,
20+ UndoManager, VersionVector,
21 cursor::{Cursor, Side},
22};
2324use jacquard::IntoStatic;
25use jacquard::from_json_value;
26use jacquard::types::string::AtUri;
27+use weaver_api::com_atproto::repo::strong_ref::StrongRef;
28use weaver_api::sh_weaver::embed::images::Image;
29+use weaver_api::sh_weaver::notebook::entry::Entry;
3031/// Helper for working with editor images.
32/// Constructed from LoroMap data, NOT serialized directly.
···84 /// Contains nested containers: images (LoroList), externals (LoroList), etc.
85 embeds: LoroMap,
8687+ // --- Entry tracking (reactive) ---
88+ /// StrongRef to the entry if editing an existing record.
89 /// None for new entries that haven't been published yet.
90+ /// Signal so cloned docs share the same state after publish.
91+ pub entry_ref: Signal<Option<StrongRef<'static>>>,
92+93+ // --- Edit sync state (for PDS sync) ---
94+ /// StrongRef to the sh.weaver.edit.root record for this edit session.
95+ /// None if we haven't synced to PDS yet.
96+ pub edit_root: Signal<Option<StrongRef<'static>>>,
97+98+ /// StrongRef to the most recent sh.weaver.edit.diff record.
99+ /// Used for the `prev` field when creating new diffs.
100+ /// None if no diffs have been created yet (only root exists).
101+ pub last_diff: Signal<Option<StrongRef<'static>>>,
102+103+ /// Version vector at the time of last sync to PDS.
104+ /// Used to export only changes since last sync.
105+ /// None if never synced.
106+ last_synced_version: Option<VersionVector>,
107108 // --- Editor state (non-reactive) ---
109 /// Undo manager for the document.
···264 created_at,
265 tags,
266 embeds,
267+ entry_ref: Signal::new(None),
268+ edit_root: Signal::new(None),
269+ last_diff: Signal::new(None),
270+ last_synced_version: None,
271 undo_mgr: Rc::new(RefCell::new(undo_mgr)),
272 loro_cursor,
273 // Reactive editor state - wrapped in Signals
···283 }
284 }
285286+ /// Create an EditorDocument from a fetched Entry.
287+ ///
288+ /// MUST be called from within a reactive context (e.g., `use_hook`) to
289+ /// properly initialize Dioxus Signals.
290+ ///
291+ /// # Arguments
292+ /// * `entry` - The entry record fetched from PDS
293+ /// * `entry_ref` - StrongRef to the entry (URI + CID)
294+ pub fn from_entry(entry: &Entry<'_>, entry_ref: StrongRef<'static>) -> Self {
295+ let mut doc = Self::new(entry.content.to_string());
296+297+ // Set metadata
298+ doc.set_title(&entry.title);
299+ doc.set_path(&entry.path);
300+ doc.set_created_at(&entry.created_at.to_string());
301+302+ // Add tags
303+ if let Some(ref tags) = entry.tags {
304+ for tag in tags.iter() {
305+ doc.add_tag(tag.as_ref());
306+ }
307+ }
308+309+ // Add existing images (no published_blob_uri needed - they're already in the entry)
310+ if let Some(ref embeds) = entry.embeds {
311+ if let Some(ref images) = embeds.images {
312+ for img in &images.images {
313+ doc.add_image(&img.clone().into_static(), None);
314+ }
315+ }
316+ }
317+318+ // Set the entry_ref so subsequent publishes update this record
319+ doc.set_entry_ref(Some(entry_ref));
320+321+ doc
322+ }
323+324 /// Generate current datetime as ISO 8601 string.
325 #[cfg(target_family = "wasm")]
326 fn current_datetime_string() -> String {
···420 self.created_at.insert(0, datetime).ok();
421 }
422423+ // --- Entry ref accessors ---
424425+ /// Get the StrongRef to the entry if editing an existing record.
426+ pub fn entry_ref(&self) -> Option<StrongRef<'static>> {
427+ self.entry_ref.read().clone()
428 }
429430+ /// Set the StrongRef when editing an existing entry.
431+ pub fn set_entry_ref(&mut self, entry: Option<StrongRef<'static>>) {
432+ self.entry_ref.set(entry);
433 }
434435 // --- Tags accessors ---
···750751 /// Get the current state frontiers for change detection.
752 /// Frontiers represent the "version" of the document state.
753+ pub fn state_frontiers(&self) -> Frontiers {
754 self.doc.state_frontiers()
755 }
756757+ /// Get the current version vector.
758+ pub fn version_vector(&self) -> VersionVector {
759+ self.doc.oplog_vv()
760+ }
761+762 /// Get the last edit info for incremental rendering.
763 /// Reading this creates a reactive dependency on content changes.
764 pub fn last_edit(&self) -> Option<EditInfo> {
765 self.last_edit.read().clone()
766 }
767768+ // --- Edit sync methods ---
769+770+ /// Get the edit root StrongRef if set.
771+ pub fn edit_root(&self) -> Option<StrongRef<'static>> {
772+ self.edit_root.read().clone()
773+ }
774+775+ /// Set the edit root after creating or finding the root record.
776+ pub fn set_edit_root(&mut self, root: Option<StrongRef<'static>>) {
777+ self.edit_root.set(root);
778+ }
779+780+ /// Get the last diff StrongRef if set.
781+ pub fn last_diff(&self) -> Option<StrongRef<'static>> {
782+ self.last_diff.read().clone()
783+ }
784+785+ /// Set the last diff after creating a new diff record.
786+ pub fn set_last_diff(&mut self, diff: Option<StrongRef<'static>>) {
787+ self.last_diff.set(diff);
788+ }
789+790+ /// Check if there are unsynchronized changes since the last sync.
791+ pub fn has_unsync_changes(&self) -> bool {
792+ match &self.last_synced_version {
793+ Some(synced_vv) => self.doc.oplog_vv() != *synced_vv,
794+ None => true, // Never synced, so there are changes
795+ }
796+ }
797+798+ /// Export updates since the last sync.
799+ /// Returns None if there are no changes to export.
800+ /// After successful upload, call `mark_synced()` to update the sync marker.
801+ pub fn export_updates_since_sync(&self) -> Option<Vec<u8>> {
802+ let from_vv = self.last_synced_version.clone().unwrap_or_default();
803+ let current_vv = self.doc.oplog_vv();
804+805+ // No changes since last sync
806+ if from_vv == current_vv {
807+ return None;
808+ }
809+810+ let updates = self
811+ .doc
812+ .export(ExportMode::Updates {
813+ from: Cow::Owned(from_vv),
814+ })
815+ .ok()?;
816+817+ // Don't return empty updates
818+ if updates.is_empty() {
819+ return None;
820+ }
821+822+ Some(updates)
823+ }
824+825+ /// Mark the current state as synced.
826+ /// Call this after successfully uploading a diff to the PDS.
827+ pub fn mark_synced(&mut self) {
828+ self.last_synced_version = Some(self.doc.oplog_vv());
829+ }
830+831+ /// Import updates from a PDS diff blob.
832+ /// Used when loading edit history from the PDS.
833+ pub fn import_updates(&mut self, updates: &[u8]) -> LoroResult<()> {
834+ self.doc.import(updates)?;
835+ Ok(())
836+ }
837+838+ /// Set the sync state when loading from PDS.
839+ /// This sets the version marker to the current state so we don't
840+ /// re-upload what we just downloaded.
841+ pub fn set_synced_from_pds(
842+ &mut self,
843+ edit_root: StrongRef<'static>,
844+ last_diff: Option<StrongRef<'static>>,
845+ ) {
846+ self.edit_root.set(Some(edit_root));
847+ self.last_diff.set(last_diff);
848+ self.last_synced_version = Some(self.doc.oplog_vv());
849+ }
850+851 /// Create a new EditorDocument from a binary snapshot.
852 /// Falls back to empty document if import fails.
853 ///
···914 created_at,
915 tags,
916 embeds,
917+ entry_ref: Signal::new(None),
918+ edit_root: Signal::new(None),
919+ last_diff: Signal::new(None),
920+ last_synced_version: None,
921 undo_mgr: Rc::new(RefCell::new(undo_mgr)),
922 loro_cursor,
923 // Reactive editor state - wrapped in Signals
+27-1
crates/weaver-app/src/components/editor/input.rs
···26 _ => {}
27 }
28 }
000029 // Let browser handle other Ctrl/Cmd shortcuts (paste, copy, cut, etc.)
30 return false;
31 }
···151 doc.cursor.write().offset = start;
152 } else if doc.cursor.read().offset > 0 {
153 let cursor_offset = doc.cursor.read().offset;
00000000000154 // Check if we're about to delete a newline
155 let prev_char = get_char_at(doc.loro_text(), cursor_offset - 1);
156···213 doc.cursor.write().offset = start;
214 } else {
215 let cursor_offset = doc.cursor.read().offset;
216- if cursor_offset < doc.len_chars() {
00000000000217 // Delete next char
218 let _ = doc.remove_tracked(cursor_offset, 1);
219 }
···26 _ => {}
27 }
28 }
29+ // Intercept Cmd+Backspace (delete to start of line) and Cmd+Delete (delete to end)
30+ if matches!(key, Key::Backspace | Key::Delete) {
31+ return true;
32+ }
33 // Let browser handle other Ctrl/Cmd shortcuts (paste, copy, cut, etc.)
34 return false;
35 }
···155 doc.cursor.write().offset = start;
156 } else if doc.cursor.read().offset > 0 {
157 let cursor_offset = doc.cursor.read().offset;
158+159+ // Cmd+Backspace: delete to start of line
160+ if mods.meta() || mods.ctrl() {
161+ let line_start = find_line_start(doc.loro_text(), cursor_offset);
162+ if line_start < cursor_offset {
163+ let _ = doc.remove_tracked(line_start, cursor_offset - line_start);
164+ doc.cursor.write().offset = line_start;
165+ }
166+ return;
167+ }
168+169 // Check if we're about to delete a newline
170 let prev_char = get_char_at(doc.loro_text(), cursor_offset - 1);
171···228 doc.cursor.write().offset = start;
229 } else {
230 let cursor_offset = doc.cursor.read().offset;
231+ let doc_len = doc.len_chars();
232+233+ // Cmd+Delete: delete to end of line
234+ if mods.meta() || mods.ctrl() {
235+ let line_end = find_line_end(doc.loro_text(), cursor_offset);
236+ if cursor_offset < line_end {
237+ let _ = doc.remove_tracked(cursor_offset, line_end - cursor_offset);
238+ }
239+ return;
240+ }
241+242+ if cursor_offset < doc_len {
243 // Delete next char
244 let _ = doc.remove_tracked(cursor_offset, 1);
245 }
···56/// Editor page view.
7///
8-/// Displays the markdown editor at the /editor route for testing during development.
9-/// Eventually this will be integrated into the notebook editing workflow.
10#[component]
11-pub fn Editor() -> Element {
12 rsx! {
13 EditorCss {}
14 div { class: "editor-page",
15- MarkdownEditor { initial_content: None }
16 }
17 }
18}
···56/// Editor page view.
7///
8+/// Displays the markdown editor at the /editor route.
9+/// Optionally loads an existing entry for editing via `?entry={at-uri}`.
10#[component]
11+pub fn Editor(entry: Option<String>) -> Element {
12 rsx! {
13 EditorCss {}
14 div { class: "editor-page",
15+ MarkdownEditor { entry_uri: entry }
16 }
17 }
18}
+24-13
crates/weaver-common/src/agent.rs
···216 /// 3. If found: update the entry with new content
217 /// 4. If not found: create new entry and append to notebook's entry_list
218 ///
219- /// Returns (entry_uri, was_created)
220 fn upsert_entry(
221 &self,
222 notebook_title: &str,
223 entry_title: &str,
224 entry: entry::Entry<'_>,
225- ) -> impl Future<Output = Result<(AtUri<'static>, bool), WeaverError>>
226 where
227 Self: Sized,
228 {
···244 if let Ok(existing_entry) = existing.parse() {
245 if existing_entry.value.title == entry_title {
246 // Update existing entry
247- self.update_record::<entry::Entry>(&entry_ref.uri, |e| {
248- e.content = entry.content.clone();
249- e.embeds = entry.embeds.clone();
250- e.tags = entry.tags.clone();
251- })
252- .await?;
253- return Ok((entry_ref.uri.clone().into_static(), false));
00000254 }
255 }
256 }
257258 // Entry doesn't exist, create it
259 let response = self.create_record(entry, None).await?;
260- let entry_uri = response.uri.clone();
000261262 // Add to notebook's entry_list
263 use weaver_api::sh_weaver::notebook::book::Book;
264- let new_ref = StrongRef::new().uri(response.uri).cid(response.cid).build();
000265266 self.update_record::<Book>(¬ebook_uri, |book| {
267- book.entry_list.push(new_ref);
268 })
269 .await?;
270271- Ok((entry_uri, true))
272 }
273 }
274
···216 /// 3. If found: update the entry with new content
217 /// 4. If not found: create new entry and append to notebook's entry_list
218 ///
219+ /// Returns (entry_ref, was_created)
220 fn upsert_entry(
221 &self,
222 notebook_title: &str,
223 entry_title: &str,
224 entry: entry::Entry<'_>,
225+ ) -> impl Future<Output = Result<(StrongRef<'static>, bool), WeaverError>>
226 where
227 Self: Sized,
228 {
···244 if let Ok(existing_entry) = existing.parse() {
245 if existing_entry.value.title == entry_title {
246 // Update existing entry
247+ let output = self
248+ .update_record::<entry::Entry>(&entry_ref.uri, |e| {
249+ e.content = entry.content.clone();
250+ e.embeds = entry.embeds.clone();
251+ e.tags = entry.tags.clone();
252+ })
253+ .await?;
254+ let updated_ref = StrongRef::new()
255+ .uri(output.uri.into_static())
256+ .cid(output.cid.into_static())
257+ .build();
258+ return Ok((updated_ref, false));
259 }
260 }
261 }
262263 // Entry doesn't exist, create it
264 let response = self.create_record(entry, None).await?;
265+ let new_ref = StrongRef::new()
266+ .uri(response.uri.clone().into_static())
267+ .cid(response.cid.clone().into_static())
268+ .build();
269270 // Add to notebook's entry_list
271 use weaver_api::sh_weaver::notebook::book::Book;
272+ let notebook_entry_ref = StrongRef::new()
273+ .uri(response.uri.into_static())
274+ .cid(response.cid.into_static())
275+ .build();
276277 self.update_record::<Book>(¬ebook_uri, |book| {
278+ book.entry_list.push(notebook_entry_ref);
279 })
280 .await?;
281282+ Ok((new_ref, true))
283 }
284 }
285
+8-8
crates/weaver-common/src/constellation.rs
···26 /// The link target
27 ///
28 /// can be an AT-URI, plain DID, or regular URI
29- subject: jacquard::types::uri::Uri<'a>,
30 /// Filter links only from this link source
31 ///
32 /// eg.: `app.bsky.feed.like:subject.uri`
33- source: CowStr<'a>,
34 #[serde(borrow)]
35- cursor: Option<CowStr<'a>>,
36 /// Filter links only from these DIDs
37 ///
38 /// include multiple times to filter by multiple source DIDs
39 #[serde(default)]
40- did: Vec<Did<'a>>,
41 /// Set the max number of links to return per page of results
42 #[serde(default = "get_default_cursor_limit")]
43- limit: u64,
44 // TODO: allow reverse (er, forward) order as well
45}
46#[derive(Deserialize, Serialize, IntoStatic)]
47pub struct GetBacklinksResponse<'a> {
48- total: u64,
49 #[serde(borrow)]
50- records: Vec<RecordId<'a>>,
51- cursor: Option<CowStr<'a>>,
52}
5354#[derive(Debug, PartialEq, Serialize, Deserialize, IntoStatic)]
···26 /// The link target
27 ///
28 /// can be an AT-URI, plain DID, or regular URI
29+ pub subject: jacquard::types::uri::Uri<'a>,
30 /// Filter links only from this link source
31 ///
32 /// eg.: `app.bsky.feed.like:subject.uri`
33+ pub source: CowStr<'a>,
34 #[serde(borrow)]
35+ pub cursor: Option<CowStr<'a>>,
36 /// Filter links only from these DIDs
37 ///
38 /// include multiple times to filter by multiple source DIDs
39 #[serde(default)]
40+ pub did: Vec<Did<'a>>,
41 /// Set the max number of links to return per page of results
42 #[serde(default = "get_default_cursor_limit")]
43+ pub limit: u64,
44 // TODO: allow reverse (er, forward) order as well
45}
46#[derive(Deserialize, Serialize, IntoStatic)]
47pub struct GetBacklinksResponse<'a> {
48+ pub total: u64,
49 #[serde(borrow)]
50+ pub records: Vec<RecordId<'a>>,
51+ pub cursor: Option<CowStr<'a>>,
52}
5354#[derive(Debug, PartialEq, Serialize, Deserialize, IntoStatic)]