diff --git a/crates/ely_browser_core/src/state.rs b/crates/ely_browser_core/src/state.rs index 6f7c60f..efcd061 100644 --- a/crates/ely_browser_core/src/state.rs +++ b/crates/ely_browser_core/src/state.rs @@ -30,7 +30,9 @@ mod space_exports; mod spaces; mod splits; mod sync; +mod sync_context; mod sync_notes; +mod sync_profiles; mod sync_reading_list; mod tab_group_order; mod tab_groups; diff --git a/crates/ely_browser_core/src/state/bookmarks.rs b/crates/ely_browser_core/src/state/bookmarks.rs index 79caa77..8792b29 100644 --- a/crates/ely_browser_core/src/state/bookmarks.rs +++ b/crates/ely_browser_core/src/state/bookmarks.rs @@ -249,7 +249,10 @@ impl BrowserCore { if self.sync_object_policy(SyncObjectKind::Bookmarks) == SyncObjectPolicy::Paused { return Vec::new(); } - self.bookmarks.iter().collect() + self.bookmarks + .iter() + .filter(|bookmark| self.profile_allows_cloud_sync(bookmark.profile_id())) + .collect() } fn bookmark_mut(&mut self, bookmark_id: &BookmarkId) -> Result<&mut BookmarkEntry, CoreError> { diff --git a/crates/ely_browser_core/src/state/sync.rs b/crates/ely_browser_core/src/state/sync.rs index 344562a..8f1ac74 100644 --- a/crates/ely_browser_core/src/state/sync.rs +++ b/crates/ely_browser_core/src/state/sync.rs @@ -7,7 +7,7 @@ use ely_domain::{ }; use ely_sync_client::SyncClientError; -use super::BrowserCore; +use super::{BrowserCore, sync_context::SyncSnapshotApplyContext}; use crate::sync_engine::SyncSnapshotApplySummary; use crate::sync_records::{BookmarkSyncRecord, SpaceSyncRecord, SyncSnapshotBody, TabSyncRecord}; @@ -111,20 +111,24 @@ impl BrowserCore { body: SyncSnapshotBody, ) -> Result { let mut summary = SyncSnapshotApplySummary::default(); + let mut context = SyncSnapshotApplyContext::default(); + for record in body.profiles { + self.apply_profile_sync_record(record, &mut summary, &mut context)?; + } for record in body.spaces { - self.apply_space_sync_record(record, &mut summary)?; + self.apply_space_sync_record(record, &mut summary, &context)?; } for record in body.tabs { - self.apply_tab_sync_record(record, &mut summary)?; + self.apply_tab_sync_record(record, &mut summary, &context)?; } for record in body.bookmarks { - self.apply_bookmark_sync_record(record, &mut summary)?; + self.apply_bookmark_sync_record(record, &mut summary, &context)?; } for record in body.notes { - self.apply_note_sync_record(record, &mut summary)?; + self.apply_note_sync_record(record, &mut summary, &context)?; } for record in body.reading_list { - self.apply_reading_list_sync_record(record, &mut summary)?; + self.apply_reading_list_sync_record(record, &mut summary, &context)?; } Ok(summary) } @@ -203,13 +207,7 @@ impl BrowserCore { } self.tabs .iter() - .filter(|tab| { - tab.sync_enabled() - && self - .profiles - .iter() - .any(|profile| profile.id() == tab.profile_id() && profile.allows_sync()) - }) + .filter(|tab| tab.sync_enabled() && self.profile_allows_cloud_sync(tab.profile_id())) .collect() } @@ -224,9 +222,10 @@ impl BrowserCore { &mut self, record: SpaceSyncRecord, summary: &mut SyncSnapshotApplySummary, + context: &SyncSnapshotApplyContext, ) -> Result<(), SyncClientError> { let space_id = parse_space_id(&record.id)?; - let default_profile_id = self.sync_profile_id(&record.default_profile_id)?; + let default_profile_id = self.sync_profile_id(&record.default_profile_id, context)?; let archive_policy = ArchivePolicy::from(record.archive_policy.clone()); let existing_index = self.spaces.iter().position(|space| space.id() == &space_id).or_else(|| { @@ -302,9 +301,10 @@ impl BrowserCore { &mut self, record: TabSyncRecord, summary: &mut SyncSnapshotApplySummary, + context: &SyncSnapshotApplyContext, ) -> Result<(), SyncClientError> { let tab_id = parse_tab_id(&record.id)?; - let profile_id = self.sync_profile_id(&record.profile_id)?; + let profile_id = self.sync_profile_id(&record.profile_id, context)?; let space_id = self.sync_space_id(&record.space_id, record.space_name.as_deref())?; let url = UrlText::parse(&record.url).map_err(snapshot_schema_error)?; let created_at = UNIX_EPOCH + Duration::from_secs(record.created_at_secs); @@ -350,9 +350,10 @@ impl BrowserCore { &mut self, record: BookmarkSyncRecord, summary: &mut SyncSnapshotApplySummary, + context: &SyncSnapshotApplyContext, ) -> Result<(), SyncClientError> { let bookmark_id = parse_bookmark_id(&record.id)?; - let profile_id = self.sync_profile_id(&record.profile_id)?; + let profile_id = self.sync_profile_id(&record.profile_id, context)?; let space_id = self.sync_space_id(&record.space_id, record.space_name.as_deref())?; let url = UrlText::parse(&record.url).map_err(snapshot_schema_error)?; let added_at = UNIX_EPOCH + Duration::from_secs(record.added_at_secs); @@ -402,14 +403,29 @@ impl BrowserCore { Ok(()) } - pub(super) fn sync_profile_id(&self, raw: &str) -> Result { + pub(super) fn sync_profile_id( + &self, + raw: &str, + context: &SyncSnapshotApplyContext, + ) -> Result { let profile_id = ProfileId::parse(raw).map_err(snapshot_schema_error)?; + if let Some(local_profile_id) = context.profile_alias(&profile_id) { + return Ok(local_profile_id); + } if self.profiles.iter().any(|profile| profile.id() == &profile_id) { return Ok(profile_id); } Ok(self.active_profile_id.clone()) } + pub(super) fn profile_allows_cloud_sync(&self, profile_id: &ProfileId) -> bool { + self.profiles.iter().any(|profile| { + profile.id() == profile_id + && profile.allows_sync() + && profile.sync_policy() == ely_domain::ProfileSyncPolicy::Enabled + }) + } + pub(super) fn sync_space_id( &self, raw: &str, diff --git a/crates/ely_browser_core/src/state/sync_context.rs b/crates/ely_browser_core/src/state/sync_context.rs new file mode 100644 index 0000000..0525ffe --- /dev/null +++ b/crates/ely_browser_core/src/state/sync_context.rs @@ -0,0 +1,18 @@ +use std::collections::BTreeMap; + +use ely_domain::ProfileId; + +#[derive(Default)] +pub(super) struct SyncSnapshotApplyContext { + profile_aliases: BTreeMap, +} + +impl SyncSnapshotApplyContext { + pub(super) fn register_profile_alias(&mut self, remote_id: ProfileId, local_id: ProfileId) { + self.profile_aliases.insert(remote_id, local_id); + } + + pub(super) fn profile_alias(&self, remote_id: &ProfileId) -> Option { + self.profile_aliases.get(remote_id).cloned() + } +} diff --git a/crates/ely_browser_core/src/state/sync_notes.rs b/crates/ely_browser_core/src/state/sync_notes.rs index f7ca8b1..ffeb141 100644 --- a/crates/ely_browser_core/src/state/sync_notes.rs +++ b/crates/ely_browser_core/src/state/sync_notes.rs @@ -6,7 +6,7 @@ use ely_domain::{ }; use ely_sync_client::SyncClientError; -use super::{BrowserCore, sync::snapshot_schema_error}; +use super::{BrowserCore, sync::snapshot_schema_error, sync_context::SyncSnapshotApplyContext}; use crate::{ sync_engine::SyncSnapshotApplySummary, sync_records::{NoteSyncRecord, NoteTargetSyncRecord}, @@ -17,23 +17,17 @@ impl BrowserCore { 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() + self.notes.iter().filter(|note| self.profile_allows_cloud_sync(note.profile_id())).collect() } pub(super) fn apply_note_sync_record( &mut self, record: NoteSyncRecord, summary: &mut SyncSnapshotApplySummary, + context: &SyncSnapshotApplyContext, ) -> 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 profile_id = self.sync_profile_id(&record.profile_id, context)?; 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 = diff --git a/crates/ely_browser_core/src/state/sync_profiles.rs b/crates/ely_browser_core/src/state/sync_profiles.rs new file mode 100644 index 0000000..a7973b6 --- /dev/null +++ b/crates/ely_browser_core/src/state/sync_profiles.rs @@ -0,0 +1,55 @@ +use ely_domain::{Profile, ProfileId, ProfileKind, SyncObjectKind, SyncObjectPolicy}; +use ely_sync_client::SyncClientError; + +use super::{BrowserCore, sync::snapshot_schema_error, sync_context::SyncSnapshotApplyContext}; +use crate::{sync_engine::SyncSnapshotApplySummary, sync_records::ProfileSyncRecord}; + +impl BrowserCore { + pub(crate) fn visible_profiles_for_sync(&self) -> Vec<&Profile> { + if self.sync_object_policy(SyncObjectKind::Profiles) == SyncObjectPolicy::Paused { + return Vec::new(); + } + self.profiles.iter().filter(|profile| profile.allows_sync()).collect() + } + + pub(super) fn apply_profile_sync_record( + &mut self, + record: ProfileSyncRecord, + summary: &mut SyncSnapshotApplySummary, + context: &mut SyncSnapshotApplyContext, + ) -> Result<(), SyncClientError> { + let profile_id = ProfileId::parse(&record.id).map_err(snapshot_schema_error)?; + let kind = ProfileKind::from(record.kind); + if kind == ProfileKind::Private { + summary.record_skipped(); + return Ok(()); + } + + let existing_index = + self.profiles.iter().position(|profile| profile.id() == &profile_id).or_else(|| { + self.profiles + .iter() + .position(|profile| profile.name().eq_ignore_ascii_case(record.name.trim())) + }); + let id = existing_index + .and_then(|index| self.profiles.get(index).map(|profile| profile.id().clone())) + .unwrap_or_else(|| profile_id.clone()); + context.register_profile_alias(profile_id, id.clone()); + let mut profile = Profile::new(record.name, record.color_hex, kind); + profile.set_sync_policy(record.sync_policy.into()); + let profile = Profile::restore(id, profile); + + match existing_index { + Some(index) if self.profiles[index] == profile => {} + Some(index) => { + self.profiles[index] = profile; + summary.record_updated(); + } + None => { + self.profiles.push(profile); + summary.record_imported(); + } + } + Ok(()) + } +} diff --git a/crates/ely_browser_core/src/state/sync_reading_list.rs b/crates/ely_browser_core/src/state/sync_reading_list.rs index 3eec352..78dbc66 100644 --- a/crates/ely_browser_core/src/state/sync_reading_list.rs +++ b/crates/ely_browser_core/src/state/sync_reading_list.rs @@ -6,7 +6,7 @@ use ely_domain::{ }; use ely_sync_client::SyncClientError; -use super::{BrowserCore, sync::snapshot_schema_error}; +use super::{BrowserCore, sync::snapshot_schema_error, sync_context::SyncSnapshotApplyContext}; use crate::{ sync_engine::SyncSnapshotApplySummary, sync_records::{ReadingListSyncRecord, ReadingProgressSyncRecord}, @@ -19,11 +19,7 @@ impl BrowserCore { } self.reading_list .iter() - .filter(|entry| { - self.profiles - .iter() - .any(|profile| profile.id() == entry.profile_id() && profile.allows_sync()) - }) + .filter(|entry| self.profile_allows_cloud_sync(entry.profile_id())) .collect() } @@ -31,9 +27,10 @@ impl BrowserCore { &mut self, record: ReadingListSyncRecord, summary: &mut SyncSnapshotApplySummary, + context: &SyncSnapshotApplyContext, ) -> Result<(), SyncClientError> { let entry_id = ReadingListId::parse(&record.id).map_err(snapshot_schema_error)?; - let profile_id = self.sync_profile_id(&record.profile_id)?; + let profile_id = self.sync_profile_id(&record.profile_id, context)?; 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 progress = reading_progress_from_sync_record(record.progress)?; diff --git a/crates/ely_browser_core/src/sync_records.rs b/crates/ely_browser_core/src/sync_records.rs index e511984..6aef27e 100644 --- a/crates/ely_browser_core/src/sync_records.rs +++ b/crates/ely_browser_core/src/sync_records.rs @@ -1,8 +1,8 @@ use std::time::{SystemTime, UNIX_EPOCH}; use ely_domain::{ - ArchivePolicy, BookmarkEntry, BrowserTab, NoteEntry, NoteTarget, ReadingListEntry, - ReadingProgress, Space, TabFlags, + ArchivePolicy, BookmarkEntry, BrowserTab, NoteEntry, NoteTarget, Profile, ProfileKind, + ProfileSyncPolicy, ReadingListEntry, ReadingProgress, Space, TabFlags, }; use serde::{Deserialize, Serialize}; @@ -14,6 +14,8 @@ pub(crate) const SNAPSHOT_SCHEMA_REV: u32 = 1; pub(crate) struct SyncSnapshotBody { pub(crate) schema_rev: u32, #[serde(default)] + pub(crate) profiles: Vec, + #[serde(default)] pub(crate) spaces: Vec, pub(crate) bookmarks: Vec, #[serde(default)] @@ -28,6 +30,11 @@ impl SyncSnapshotBody { pub(crate) fn from_core(core: &BrowserCore) -> Self { Self { schema_rev: SNAPSHOT_SCHEMA_REV, + profiles: core + .visible_profiles_for_sync() + .into_iter() + .map(ProfileSyncRecord::from_profile) + .collect(), spaces: core .visible_spaces_for_sync() .into_iter() @@ -71,6 +78,79 @@ impl SyncSnapshotBody { } } +#[derive(Clone, Debug, Serialize, Deserialize)] +pub(crate) struct ProfileSyncRecord { + pub(crate) id: String, + pub(crate) name: String, + pub(crate) color_hex: u32, + pub(crate) kind: ProfileKindSyncRecord, + pub(crate) sync_policy: ProfileSyncPolicySyncRecord, +} + +impl ProfileSyncRecord { + fn from_profile(profile: &Profile) -> Self { + Self { + id: profile.id().as_str().to_string(), + name: profile.name().to_string(), + color_hex: profile.color_hex(), + kind: ProfileKindSyncRecord::from_profile_kind(profile.kind()), + sync_policy: ProfileSyncPolicySyncRecord::from_profile_sync_policy( + profile.sync_policy(), + ), + } + } +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "snake_case")] +pub(crate) enum ProfileKindSyncRecord { + Standard, + Private, +} + +impl ProfileKindSyncRecord { + fn from_profile_kind(kind: &ProfileKind) -> Self { + match kind { + ProfileKind::Standard => Self::Standard, + ProfileKind::Private => Self::Private, + } + } +} + +impl From for ProfileKind { + fn from(kind: ProfileKindSyncRecord) -> Self { + match kind { + ProfileKindSyncRecord::Standard => Self::Standard, + ProfileKindSyncRecord::Private => Self::Private, + } + } +} + +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "snake_case")] +pub(crate) enum ProfileSyncPolicySyncRecord { + Enabled, + Paused, +} + +impl ProfileSyncPolicySyncRecord { + fn from_profile_sync_policy(policy: ProfileSyncPolicy) -> Self { + match policy { + ProfileSyncPolicy::Enabled => Self::Enabled, + ProfileSyncPolicy::Paused => Self::Paused, + } + } +} + +impl From for ProfileSyncPolicy { + fn from(policy: ProfileSyncPolicySyncRecord) -> Self { + match policy { + ProfileSyncPolicySyncRecord::Enabled => Self::Enabled, + ProfileSyncPolicySyncRecord::Paused => Self::Paused, + } + } +} + #[derive(Clone, Debug, Serialize, Deserialize)] pub(crate) struct SpaceSyncRecord { pub(crate) id: String, diff --git a/crates/ely_browser_core/tests/sync_profiles.rs b/crates/ely_browser_core/tests/sync_profiles.rs new file mode 100644 index 0000000..fdca504 --- /dev/null +++ b/crates/ely_browser_core/tests/sync_profiles.rs @@ -0,0 +1,96 @@ +use std::error::Error; + +use ely_browser_core::{BrowserCore, InitialBrowserConfig}; +use ely_domain::{ProfileKind, ProfileSyncPolicy, UrlText}; + +#[test] +fn sync_snapshot_imports_remote_profiles_before_spaces() -> 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 profile_id = source.create_profile("Research", 0x9fc9a2, ProfileKind::Standard)?; + source.set_profile_sync_policy(&profile_id, ProfileSyncPolicy::Paused)?; + source.create_space("Research Space", "R", 0x4477aa)?; + 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_profile = snapshot + .profiles + .iter() + .find(|profile| profile.name() == "Research") + .ok_or("missing imported profile")?; + let imported_space = snapshot + .spaces + .iter() + .find(|space| space.name() == "Research Space") + .ok_or("missing imported space")?; + + assert_eq!(summary.imported(), 2); + assert_eq!(summary.updated(), 0); + assert_eq!(summary.skipped(), 0); + assert_eq!(imported_profile.color_hex(), 0x9fc9a2); + assert_eq!(imported_profile.kind(), &ProfileKind::Standard); + assert_eq!(imported_profile.sync_policy(), ProfileSyncPolicy::Paused); + assert_eq!(imported_space.default_profile_id(), imported_profile.id()); + Ok(()) +} + +#[test] +fn sync_snapshot_updates_existing_profile_metadata() -> 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_profile_id = source.create_profile("Research", 0x9fc9a2, ProfileKind::Standard)?; + source.set_profile_sync_policy(&source_profile_id, ProfileSyncPolicy::Paused)?; + let bytes = source.build_sync_snapshot_bytes()?; + + let mut target = BrowserCore::new(InitialBrowserConfig::ely_defaults()?)?; + let target_profile_id = target.create_profile("Research", 0x111111, ProfileKind::Standard)?; + let summary = target.apply_sync_snapshot_bytes(&bytes)?; + let snapshot = target.snapshot()?; + let updated_profile = snapshot + .profiles + .iter() + .find(|profile| profile.id() == &target_profile_id) + .ok_or("missing updated profile")?; + + assert_eq!(summary.imported(), 0); + assert_eq!(summary.updated(), 1); + assert_eq!(summary.skipped(), 0); + assert_eq!(updated_profile.name(), "Research"); + assert_eq!(updated_profile.color_hex(), 0x9fc9a2); + assert_eq!(updated_profile.sync_policy(), ProfileSyncPolicy::Paused); + Ok(()) +} + +#[test] +fn paused_profile_data_is_omitted_from_sync_snapshots() -> 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 profile_id = source.create_profile("Research", 0x9fc9a2, ProfileKind::Standard)?; + source.set_profile_sync_policy(&profile_id, ProfileSyncPolicy::Paused)?; + source.open_tab(UrlText::parse("https://example.com/paused-profile")?); + source.bookmark_active_tab()?; + source.save_active_url_note("profile paused")?; + source.save_active_tab_to_reading_list()?; + 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(), 1); + assert_eq!(summary.updated(), 0); + assert_eq!(summary.skipped(), 0); + assert!(snapshot.profiles.iter().any(|profile| profile.name() == "Research")); + assert!( + snapshot.tabs.iter().all(|tab| tab.url().as_str() != "https://example.com/paused-profile") + ); + assert!(snapshot.bookmarks.is_empty()); + assert!(snapshot.notes.is_empty()); + assert!(snapshot.reading_list.is_empty()); + Ok(()) +} diff --git a/crates/ely_domain/src/profile.rs b/crates/ely_domain/src/profile.rs index a2fcc5e..2875ccd 100644 --- a/crates/ely_domain/src/profile.rs +++ b/crates/ely_domain/src/profile.rs @@ -67,6 +67,12 @@ impl Profile { } } + #[must_use] + pub fn restore(id: ProfileId, mut profile: Self) -> Self { + profile.id = id; + profile + } + #[must_use] pub fn id(&self) -> &ProfileId { &self.id @@ -106,6 +112,11 @@ impl Profile { self.download_policy = download_policy; } + pub fn set_presentation(&mut self, name: impl Into, color_hex: u32) { + self.name = name.into(); + self.color_hex = color_hex; + } + pub fn set_sync_policy(&mut self, sync_policy: ProfileSyncPolicy) { self.sync_policy = match self.kind { ProfileKind::Standard => sync_policy, @@ -113,3 +124,39 @@ impl Profile { }; } } + +#[cfg(test)] +mod tests { + use super::{Profile, ProfileKind, ProfileSyncPolicy}; + use crate::ProfileId; + + #[test] + fn restores_existing_profile_identity() { + let id = ProfileId::new(); + let profile = Profile::new("Research", 0x9fc9a2, ProfileKind::Standard); + let profile = Profile::restore(id.clone(), profile); + + assert_eq!(profile.id(), &id); + assert_eq!(profile.name(), "Research"); + assert_eq!(profile.color_hex(), 0x9fc9a2); + } + + #[test] + fn updates_profile_presentation() { + let mut profile = Profile::new("Old", 0x111111, ProfileKind::Standard); + + profile.set_presentation("New", 0x222222); + + assert_eq!(profile.name(), "New"); + assert_eq!(profile.color_hex(), 0x222222); + } + + #[test] + fn private_profile_sync_policy_stays_paused() { + let mut profile = Profile::new("Private", 0x807d72, ProfileKind::Private); + + profile.set_sync_policy(ProfileSyncPolicy::Enabled); + + assert_eq!(profile.sync_policy(), ProfileSyncPolicy::Paused); + } +}