diff --git a/crates/ely_browser_core/src/state.rs b/crates/ely_browser_core/src/state.rs index e8db9b5..dbb30f3 100644 --- a/crates/ely_browser_core/src/state.rs +++ b/crates/ely_browser_core/src/state.rs @@ -30,6 +30,7 @@ mod space_exports; mod spaces; mod splits; mod sync; +mod sync_notes; mod tab_group_order; mod tab_groups; mod tab_lifecycle; diff --git a/crates/ely_browser_core/src/state/sync.rs b/crates/ely_browser_core/src/state/sync.rs index 2f130b8..818f359 100644 --- a/crates/ely_browser_core/src/state/sync.rs +++ b/crates/ely_browser_core/src/state/sync.rs @@ -120,6 +120,9 @@ impl BrowserCore { for record in body.bookmarks { self.apply_bookmark_sync_record(record, &mut summary)?; } + for record in body.notes { + self.apply_note_sync_record(record, &mut summary)?; + } Ok(summary) } @@ -396,7 +399,7 @@ impl BrowserCore { Ok(()) } - fn sync_profile_id(&self, raw: &str) -> Result { + pub(super) fn sync_profile_id(&self, raw: &str) -> Result { let profile_id = ProfileId::parse(raw).map_err(snapshot_schema_error)?; if self.profiles.iter().any(|profile| profile.id() == &profile_id) { return Ok(profile_id); @@ -404,7 +407,7 @@ impl BrowserCore { Ok(self.active_profile_id.clone()) } - fn sync_space_id( + pub(super) fn sync_space_id( &self, raw: &str, space_name: Option<&str>, @@ -460,6 +463,6 @@ fn parse_space_id(raw: &str) -> Result { SpaceId::parse(raw).map_err(snapshot_schema_error) } -fn snapshot_schema_error(error: impl ToString) -> SyncClientError { +pub(super) fn snapshot_schema_error(error: impl ToString) -> SyncClientError { SyncClientError::SnapshotSchema(error.to_string()) } diff --git a/crates/ely_browser_core/src/state/sync_notes.rs b/crates/ely_browser_core/src/state/sync_notes.rs new file mode 100644 index 0000000..f7ca8b1 --- /dev/null +++ b/crates/ely_browser_core/src/state/sync_notes.rs @@ -0,0 +1,122 @@ +use std::time::{Duration, UNIX_EPOCH}; + +use ely_domain::{ + NoteEntry, NoteId, NoteTarget, ProfileId, SpaceId, SyncObjectKind, SyncObjectPolicy, TabId, + UrlText, +}; +use ely_sync_client::SyncClientError; + +use super::{BrowserCore, sync::snapshot_schema_error}; +use crate::{ + sync_engine::SyncSnapshotApplySummary, + sync_records::{NoteSyncRecord, NoteTargetSyncRecord}, +}; + +impl BrowserCore { + pub(crate) fn visible_notes_for_sync(&self) -> Vec<&NoteEntry> { + if self.sync_object_policy(SyncObjectKind::Notes) == SyncObjectPolicy::Paused { + return Vec::new(); + } + self.notes + .iter() + .filter(|note| { + self.profiles + .iter() + .any(|profile| profile.id() == note.profile_id() && profile.allows_sync()) + }) + .collect() + } + + pub(super) fn apply_note_sync_record( + &mut self, + record: NoteSyncRecord, + summary: &mut SyncSnapshotApplySummary, + ) -> Result<(), SyncClientError> { + let note_id = NoteId::parse(&record.id).map_err(snapshot_schema_error)?; + let profile_id = self.sync_profile_id(&record.profile_id)?; + let space_id = self.sync_space_id(&record.space_id, record.space_name.as_deref())?; + let source_url = UrlText::parse(&record.source_url).map_err(snapshot_schema_error)?; + let target = + self.resolve_note_sync_target(record.target, &profile_id, &space_id, &source_url)?; + let created_at = UNIX_EPOCH + Duration::from_secs(record.created_at_secs); + let updated_at = UNIX_EPOCH + Duration::from_secs(record.updated_at_secs); + let existing_index = self.note_sync_index(¬e_id, &profile_id, &target); + let id = existing_index + .and_then(|index| self.notes.get(index).map(|note| note.id().clone())) + .unwrap_or(note_id); + let note = NoteEntry::new( + profile_id, + space_id, + target, + record.title, + source_url, + record.body, + created_at, + ) + .map_err(snapshot_schema_error)?; + let note = NoteEntry::restore(id, note, updated_at); + + match existing_index { + Some(index) if self.notes[index] == note => summary.record_skipped(), + Some(index) => { + self.notes[index] = note; + summary.record_updated(); + } + None => { + self.notes.push(note); + summary.record_imported(); + } + } + Ok(()) + } + + fn resolve_note_sync_target( + &self, + target: NoteTargetSyncRecord, + profile_id: &ProfileId, + space_id: &SpaceId, + source_url: &UrlText, + ) -> Result { + match target { + NoteTargetSyncRecord::Url { url } => { + UrlText::parse(&url).map(NoteTarget::Url).map_err(snapshot_schema_error) + } + NoteTargetSyncRecord::Tab { tab_id } => { + let remote_tab_id = TabId::parse(tab_id).map_err(snapshot_schema_error)?; + if let Some(tab) = self.tabs.iter().find(|tab| tab.id() == &remote_tab_id) { + return Ok(NoteTarget::Tab(tab.id().clone())); + } + if let Some(tab) = self.tabs.iter().find(|tab| { + tab.profile_id() == profile_id + && tab.space_id() == space_id + && tab.url() == source_url + }) { + return Ok(NoteTarget::Tab(tab.id().clone())); + } + Ok(NoteTarget::Url(source_url.clone())) + } + } + } + + fn note_sync_index( + &self, + note_id: &NoteId, + profile_id: &ProfileId, + target: &NoteTarget, + ) -> Option { + self.notes.iter().position(|note| note.id() == note_id).or_else(|| { + self.notes.iter().position(|note| { + note.profile_id() == profile_id + && note_targets_match_for_sync(note.target(), target) + }) + }) + } +} + +fn note_targets_match_for_sync(left: &NoteTarget, right: &NoteTarget) -> bool { + match (left, right) { + (NoteTarget::Url(left_url), NoteTarget::Url(right_url)) => left_url == right_url, + (NoteTarget::Tab(left_tab), NoteTarget::Tab(right_tab)) => left_tab == right_tab, + _ => false, + } +} diff --git a/crates/ely_browser_core/src/sync_records.rs b/crates/ely_browser_core/src/sync_records.rs index 5809ca2..3ef49ef 100644 --- a/crates/ely_browser_core/src/sync_records.rs +++ b/crates/ely_browser_core/src/sync_records.rs @@ -1,6 +1,8 @@ use std::time::{SystemTime, UNIX_EPOCH}; -use ely_domain::{ArchivePolicy, BookmarkEntry, BrowserTab, Space, TabFlags}; +use ely_domain::{ + ArchivePolicy, BookmarkEntry, BrowserTab, NoteEntry, NoteTarget, Space, TabFlags, +}; use serde::{Deserialize, Serialize}; use crate::state::BrowserCore; @@ -15,6 +17,8 @@ pub(crate) struct SyncSnapshotBody { pub(crate) bookmarks: Vec, #[serde(default)] pub(crate) tabs: Vec, + #[serde(default)] + pub(crate) notes: Vec, } impl SyncSnapshotBody { @@ -43,6 +47,13 @@ impl SyncSnapshotBody { TabSyncRecord::from_entry(entry, core.sync_space_name_for(entry.space_id())) }) .collect(), + notes: core + .visible_notes_for_sync() + .into_iter() + .map(|entry| { + NoteSyncRecord::from_entry(entry, core.sync_space_name_for(entry.space_id())) + }) + .collect(), } } } @@ -175,6 +186,54 @@ impl TabSyncRecord { } } +#[derive(Clone, Debug, Serialize, Deserialize)] +pub(crate) struct NoteSyncRecord { + pub(crate) id: String, + pub(crate) profile_id: String, + pub(crate) space_id: String, + #[serde(default)] + pub(crate) space_name: Option, + pub(crate) target: NoteTargetSyncRecord, + pub(crate) title: String, + pub(crate) source_url: String, + pub(crate) body: String, + pub(crate) created_at_secs: u64, + pub(crate) updated_at_secs: u64, +} + +impl NoteSyncRecord { + fn from_entry(entry: &NoteEntry, space_name: Option) -> Self { + Self { + id: entry.id().as_str().to_string(), + profile_id: entry.profile_id().as_str().to_string(), + space_id: entry.space_id().as_str().to_string(), + space_name, + target: NoteTargetSyncRecord::from_note_target(entry.target()), + title: entry.title().to_string(), + source_url: entry.source_url().as_str().to_string(), + body: entry.body().to_string(), + created_at_secs: system_time_secs(entry.created_at()), + updated_at_secs: system_time_secs(entry.updated_at()), + } + } +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub(crate) enum NoteTargetSyncRecord { + Url { url: String }, + Tab { tab_id: String }, +} + +impl NoteTargetSyncRecord { + fn from_note_target(target: &NoteTarget) -> Self { + match target { + NoteTarget::Url(url) => Self::Url { url: url.as_str().to_string() }, + NoteTarget::Tab(tab_id) => Self::Tab { tab_id: tab_id.as_str().to_string() }, + } + } +} + fn default_sync_enabled() -> bool { true } diff --git a/crates/ely_browser_core/tests/sync.rs b/crates/ely_browser_core/tests/sync.rs index b7ea925..5442a4a 100644 --- a/crates/ely_browser_core/tests/sync.rs +++ b/crates/ely_browser_core/tests/sync.rs @@ -2,8 +2,8 @@ use std::error::Error; use ely_browser_core::{BrowserCore, InitialBrowserConfig}; use ely_domain::{ - ArchivePolicy, SyncConnectionState, SyncObjectKind, SyncObjectPolicy, SyncObjectState, - SyncObjectStatus, UrlText, + ArchivePolicy, NoteTarget, SyncConnectionState, SyncObjectKind, SyncObjectPolicy, + SyncObjectState, SyncObjectStatus, UrlText, }; #[test] @@ -292,6 +292,122 @@ fn sync_snapshot_omits_paused_bookmarks() -> Result<(), Box> { Ok(()) } +#[test] +fn sync_snapshot_imports_remote_url_notes_into_active_scope() -> Result<(), Box> { + let mut source = BrowserCore::new(InitialBrowserConfig::ely_defaults()?)?; + let source_home_tab_id = source.snapshot()?.active_tab_id; + source.set_tab_sync_enabled(&source_home_tab_id, false)?; + let source_tab_id = source.open_tab(UrlText::parse("https://example.com/research")?); + source.set_tab_sync_enabled(&source_tab_id, false)?; + source.set_tab_title(&source_tab_id, "Research Brief")?; + source.save_active_url_note(" # Finding\r\n- one ")?; + let bytes = source.build_sync_snapshot_bytes()?; + + let mut target = BrowserCore::new(InitialBrowserConfig::ely_defaults()?)?; + let target_profile_id = target.snapshot()?.active_profile_id; + let target_space_id = target.snapshot()?.active_space_id; + let summary = target.apply_sync_snapshot_bytes(&bytes)?; + let snapshot = target.snapshot()?; + let [note] = snapshot.notes.as_slice() else { + return Err(format!("expected 1 note, got {}", snapshot.notes.len()).into()); + }; + + assert_eq!(summary.imported(), 1); + assert_eq!(summary.updated(), 0); + assert_eq!(summary.skipped(), 0); + assert_eq!(note.profile_id(), &target_profile_id); + assert_eq!(note.space_id(), &target_space_id); + assert_eq!(note.title(), "Research Brief"); + assert_eq!(note.body(), "# Finding\n- one"); + assert_eq!(note.source_url().as_str(), "https://example.com/research"); + assert_eq!(note.target(), &NoteTarget::Url(UrlText::parse("https://example.com/research")?)); + Ok(()) +} + +#[test] +fn sync_snapshot_imports_remote_tab_notes_after_tabs() -> Result<(), Box> { + let mut source = BrowserCore::new(InitialBrowserConfig::ely_defaults()?)?; + let source_home_tab_id = source.snapshot()?.active_tab_id; + source.set_tab_sync_enabled(&source_home_tab_id, false)?; + let source_tab_id = source.open_tab(UrlText::parse("https://example.com/research")?); + source.set_tab_title(&source_tab_id, "Research Brief")?; + source.save_active_tab_note("pinned tab context")?; + let bytes = source.build_sync_snapshot_bytes()?; + + let mut target = BrowserCore::new(InitialBrowserConfig::ely_defaults()?)?; + let summary = target.apply_sync_snapshot_bytes(&bytes)?; + let snapshot = target.snapshot()?; + let imported_tab = snapshot + .tabs + .iter() + .find(|tab| tab.url().as_str() == "https://example.com/research") + .ok_or("missing imported tab")?; + let imported_note = snapshot + .notes + .iter() + .find(|note| note.source_url().as_str() == "https://example.com/research") + .ok_or("missing imported note")?; + + assert_eq!(summary.imported(), 2); + assert_eq!(summary.updated(), 0); + assert_eq!(summary.skipped(), 0); + assert_eq!(imported_note.target(), &NoteTarget::Tab(imported_tab.id().clone())); + assert_eq!(imported_note.body(), "pinned tab context"); + Ok(()) +} + +#[test] +fn sync_snapshot_updates_existing_note_body() -> Result<(), Box> { + let mut source = BrowserCore::new(InitialBrowserConfig::ely_defaults()?)?; + let source_home_tab_id = source.snapshot()?.active_tab_id; + source.set_tab_sync_enabled(&source_home_tab_id, false)?; + let source_tab_id = source.open_tab(UrlText::parse("https://example.com/research")?); + source.set_tab_sync_enabled(&source_tab_id, false)?; + source.set_tab_title(&source_tab_id, "Research Brief")?; + source.save_active_url_note("canonical note")?; + let bytes = source.build_sync_snapshot_bytes()?; + + let mut target = BrowserCore::new(InitialBrowserConfig::ely_defaults()?)?; + let target_tab_id = target.open_tab(UrlText::parse("https://example.com/research")?); + target.set_tab_title(&target_tab_id, "Old Title")?; + let target_note_id = target.save_active_url_note("old note")?; + let summary = target.apply_sync_snapshot_bytes(&bytes)?; + let snapshot = target.snapshot()?; + let [note] = snapshot.notes.as_slice() else { + return Err(format!("expected 1 note, got {}", snapshot.notes.len()).into()); + }; + + assert_eq!(summary.imported(), 0); + assert_eq!(summary.updated(), 1); + assert_eq!(summary.skipped(), 0); + assert_eq!(note.id(), &target_note_id); + assert_eq!(note.title(), "Research Brief"); + assert_eq!(note.body(), "canonical note"); + Ok(()) +} + +#[test] +fn sync_snapshot_omits_paused_notes() -> Result<(), Box> { + let mut source = BrowserCore::new(InitialBrowserConfig::ely_defaults()?)?; + let source_home_tab_id = source.snapshot()?.active_tab_id; + source.set_tab_sync_enabled(&source_home_tab_id, false)?; + let source_tab_id = source.open_tab(UrlText::parse("https://example.com/research")?); + source.set_tab_sync_enabled(&source_tab_id, false)?; + source.save_active_url_note("local only")?; + source.set_sync_object_policy(SyncObjectKind::Notes, SyncObjectPolicy::Paused); + let bytes = source.build_sync_snapshot_bytes()?; + + let mut target = BrowserCore::new(InitialBrowserConfig::ely_defaults()?)?; + let summary = target.apply_sync_snapshot_bytes(&bytes)?; + let snapshot = target.snapshot()?; + + assert_eq!(summary.imported(), 0); + assert_eq!(summary.updated(), 0); + assert_eq!(summary.skipped(), 0); + assert!(snapshot.notes.is_empty()); + Ok(()) +} + #[test] fn sync_snapshot_rejects_unknown_schema_rev() -> Result<(), Box> { let mut core = BrowserCore::new(InitialBrowserConfig::ely_defaults()?)?; diff --git a/crates/ely_domain/src/note.rs b/crates/ely_domain/src/note.rs index 1dc68a2..1b76b95 100644 --- a/crates/ely_domain/src/note.rs +++ b/crates/ely_domain/src/note.rs @@ -47,6 +47,13 @@ impl NoteEntry { }) } + #[must_use] + pub fn restore(id: NoteId, mut entry: Self, updated_at: SystemTime) -> Self { + entry.id = id; + entry.updated_at = updated_at; + entry + } + #[must_use] pub fn id(&self) -> &NoteId { &self.id @@ -180,6 +187,35 @@ mod tests { Ok(()) } + #[test] + fn restores_existing_note_identity_and_timestamps() -> Result<(), DomainError> { + let id = crate::NoteId::new(); + let profile_id = ProfileId::new(); + let space_id = SpaceId::new(); + let created_at = SystemTime::UNIX_EPOCH + Duration::from_secs(10); + let updated_at = created_at + Duration::from_secs(20); + let source_url = UrlText::parse("https://example.com/restored")?; + let note = NoteEntry::new( + profile_id.clone(), + space_id.clone(), + NoteTarget::Url(source_url.clone()), + " Restored ", + source_url, + " body\r\ncopy ", + created_at, + )?; + let note = NoteEntry::restore(id.clone(), note, updated_at); + + assert_eq!(note.id(), &id); + assert_eq!(note.profile_id(), &profile_id); + assert_eq!(note.space_id(), &space_id); + assert_eq!(note.title(), "Restored"); + assert_eq!(note.body(), "body\ncopy"); + assert_eq!(note.created_at(), created_at); + assert_eq!(note.updated_at(), updated_at); + Ok(()) + } + #[test] fn rejects_empty_body() -> Result<(), DomainError> { let source_url = UrlText::parse("https://example.com")?;