diff --git a/crates/ely_browser_core/src/state.rs b/crates/ely_browser_core/src/state.rs index dbb30f3..6f7c60f 100644 --- a/crates/ely_browser_core/src/state.rs +++ b/crates/ely_browser_core/src/state.rs @@ -31,6 +31,7 @@ mod spaces; mod splits; mod sync; mod sync_notes; +mod sync_reading_list; 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 818f359..344562a 100644 --- a/crates/ely_browser_core/src/state/sync.rs +++ b/crates/ely_browser_core/src/state/sync.rs @@ -123,6 +123,9 @@ impl BrowserCore { for record in body.notes { self.apply_note_sync_record(record, &mut summary)?; } + for record in body.reading_list { + self.apply_reading_list_sync_record(record, &mut summary)?; + } Ok(summary) } diff --git a/crates/ely_browser_core/src/state/sync_reading_list.rs b/crates/ely_browser_core/src/state/sync_reading_list.rs new file mode 100644 index 0000000..3eec352 --- /dev/null +++ b/crates/ely_browser_core/src/state/sync_reading_list.rs @@ -0,0 +1,89 @@ +use std::time::{Duration, UNIX_EPOCH}; + +use ely_domain::{ + ReadingListEntry, ReadingListId, ReadingProgress, ReadingProgressPercent, SyncObjectKind, + SyncObjectPolicy, UrlText, +}; +use ely_sync_client::SyncClientError; + +use super::{BrowserCore, sync::snapshot_schema_error}; +use crate::{ + sync_engine::SyncSnapshotApplySummary, + sync_records::{ReadingListSyncRecord, ReadingProgressSyncRecord}, +}; + +impl BrowserCore { + pub(crate) fn visible_reading_list_for_sync(&self) -> Vec<&ReadingListEntry> { + if self.sync_object_policy(SyncObjectKind::ReadingList) == SyncObjectPolicy::Paused { + return Vec::new(); + } + self.reading_list + .iter() + .filter(|entry| { + self.profiles + .iter() + .any(|profile| profile.id() == entry.profile_id() && profile.allows_sync()) + }) + .collect() + } + + pub(super) fn apply_reading_list_sync_record( + &mut self, + record: ReadingListSyncRecord, + summary: &mut SyncSnapshotApplySummary, + ) -> 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 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)?; + let added_at = UNIX_EPOCH + Duration::from_secs(record.added_at_secs); + let existing_index = self.reading_list_sync_index(&entry_id, &profile_id, &source_url); + let id = existing_index + .and_then(|index| self.reading_list.get(index).map(|entry| entry.id().clone())) + .unwrap_or(entry_id); + let mut entry = + ReadingListEntry::new(profile_id, space_id, record.title, source_url, added_at) + .map_err(snapshot_schema_error)?; + entry.set_progress(progress); + let entry = ReadingListEntry::restore(id, entry); + + match existing_index { + Some(index) if self.reading_list[index] == entry => summary.record_skipped(), + Some(index) => { + self.reading_list[index] = entry; + summary.record_updated(); + } + None => { + self.reading_list.push(entry); + summary.record_imported(); + } + } + Ok(()) + } + + fn reading_list_sync_index( + &self, + entry_id: &ReadingListId, + profile_id: &ely_domain::ProfileId, + source_url: &UrlText, + ) -> Option { + self.reading_list.iter().position(|entry| entry.id() == entry_id).or_else(|| { + self.reading_list.iter().position(|entry| { + entry.profile_id() == profile_id && entry.source_url() == source_url + }) + }) + } +} + +fn reading_progress_from_sync_record( + record: ReadingProgressSyncRecord, +) -> Result { + match record { + ReadingProgressSyncRecord::Unread => Ok(ReadingProgress::Unread), + ReadingProgressSyncRecord::InProgress { percent } => ReadingProgressPercent::new(percent) + .map(ReadingProgress::InProgress) + .map_err(snapshot_schema_error), + ReadingProgressSyncRecord::Finished => Ok(ReadingProgress::Finished), + } +} diff --git a/crates/ely_browser_core/src/sync_records.rs b/crates/ely_browser_core/src/sync_records.rs index 3ef49ef..e511984 100644 --- a/crates/ely_browser_core/src/sync_records.rs +++ b/crates/ely_browser_core/src/sync_records.rs @@ -1,7 +1,8 @@ use std::time::{SystemTime, UNIX_EPOCH}; use ely_domain::{ - ArchivePolicy, BookmarkEntry, BrowserTab, NoteEntry, NoteTarget, Space, TabFlags, + ArchivePolicy, BookmarkEntry, BrowserTab, NoteEntry, NoteTarget, ReadingListEntry, + ReadingProgress, Space, TabFlags, }; use serde::{Deserialize, Serialize}; @@ -19,6 +20,8 @@ pub(crate) struct SyncSnapshotBody { pub(crate) tabs: Vec, #[serde(default)] pub(crate) notes: Vec, + #[serde(default)] + pub(crate) reading_list: Vec, } impl SyncSnapshotBody { @@ -54,6 +57,16 @@ impl SyncSnapshotBody { NoteSyncRecord::from_entry(entry, core.sync_space_name_for(entry.space_id())) }) .collect(), + reading_list: core + .visible_reading_list_for_sync() + .into_iter() + .map(|entry| { + ReadingListSyncRecord::from_entry( + entry, + core.sync_space_name_for(entry.space_id()), + ) + }) + .collect(), } } } @@ -234,6 +247,52 @@ impl NoteTargetSyncRecord { } } +#[derive(Clone, Debug, Serialize, Deserialize)] +pub(crate) struct ReadingListSyncRecord { + pub(crate) id: String, + pub(crate) profile_id: String, + pub(crate) space_id: String, + #[serde(default)] + pub(crate) space_name: Option, + pub(crate) title: String, + pub(crate) source_url: String, + pub(crate) progress: ReadingProgressSyncRecord, + pub(crate) added_at_secs: u64, +} + +impl ReadingListSyncRecord { + fn from_entry(entry: &ReadingListEntry, 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, + title: entry.title().to_string(), + source_url: entry.source_url().as_str().to_string(), + progress: ReadingProgressSyncRecord::from_progress(*entry.progress()), + added_at_secs: system_time_secs(entry.added_at()), + } + } +} + +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub(crate) enum ReadingProgressSyncRecord { + Unread, + InProgress { percent: u8 }, + Finished, +} + +impl ReadingProgressSyncRecord { + fn from_progress(progress: ReadingProgress) -> Self { + match progress { + ReadingProgress::Unread => Self::Unread, + ReadingProgress::InProgress(percent) => Self::InProgress { percent: percent.value() }, + ReadingProgress::Finished => Self::Finished, + } + } +} + fn default_sync_enabled() -> bool { true } diff --git a/crates/ely_browser_core/tests/sync_reading_list.rs b/crates/ely_browser_core/tests/sync_reading_list.rs new file mode 100644 index 0000000..3319e3b --- /dev/null +++ b/crates/ely_browser_core/tests/sync_reading_list.rs @@ -0,0 +1,98 @@ +use std::error::Error; + +use ely_browser_core::{BrowserCore, InitialBrowserConfig}; +use ely_domain::{ + ReadingProgress, ReadingProgressPercent, SyncObjectKind, SyncObjectPolicy, UrlText, +}; + +#[test] +fn sync_snapshot_imports_remote_reading_list_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/long-read")?); + source.set_tab_sync_enabled(&source_tab_id, false)?; + source.set_tab_title(&source_tab_id, "Long Read")?; + let entry_id = source.save_active_tab_to_reading_list()?; + source.set_reading_list_progress( + &entry_id, + ReadingProgress::InProgress(ReadingProgressPercent::new(42)?), + )?; + 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 [entry] = snapshot.reading_list.as_slice() else { + return Err( + format!("expected 1 reading list entry, got {}", snapshot.reading_list.len()).into() + ); + }; + + assert_eq!(summary.imported(), 1); + assert_eq!(summary.updated(), 0); + assert_eq!(summary.skipped(), 0); + assert_eq!(entry.profile_id(), &target_profile_id); + assert_eq!(entry.space_id(), &target_space_id); + assert_eq!(entry.title(), "Long Read"); + assert_eq!(entry.source_url().as_str(), "https://example.com/long-read"); + assert_eq!(entry.progress(), &ReadingProgress::InProgress(ReadingProgressPercent::new(42)?)); + Ok(()) +} + +#[test] +fn sync_snapshot_updates_existing_reading_list_entry() -> 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/long-read")?); + source.set_tab_sync_enabled(&source_tab_id, false)?; + source.set_tab_title(&source_tab_id, "Canonical Long Read")?; + let source_entry_id = source.save_active_tab_to_reading_list()?; + source.set_reading_list_progress(&source_entry_id, ReadingProgress::Finished)?; + 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/long-read")?); + target.set_tab_title(&target_tab_id, "Old Long Read")?; + let target_entry_id = target.save_active_tab_to_reading_list()?; + let summary = target.apply_sync_snapshot_bytes(&bytes)?; + let snapshot = target.snapshot()?; + let [entry] = snapshot.reading_list.as_slice() else { + return Err( + format!("expected 1 reading list entry, got {}", snapshot.reading_list.len()).into() + ); + }; + + assert_eq!(summary.imported(), 0); + assert_eq!(summary.updated(), 1); + assert_eq!(summary.skipped(), 0); + assert_eq!(entry.id(), &target_entry_id); + assert_eq!(entry.title(), "Canonical Long Read"); + assert_eq!(entry.progress(), &ReadingProgress::Finished); + Ok(()) +} + +#[test] +fn sync_snapshot_omits_paused_reading_list() -> 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/long-read")?); + source.set_tab_sync_enabled(&source_tab_id, false)?; + source.save_active_tab_to_reading_list()?; + source.set_sync_object_policy(SyncObjectKind::ReadingList, 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.reading_list.is_empty()); + Ok(()) +} diff --git a/crates/ely_domain/src/reading_list.rs b/crates/ely_domain/src/reading_list.rs index b9816a4..e28b24b 100644 --- a/crates/ely_domain/src/reading_list.rs +++ b/crates/ely_domain/src/reading_list.rs @@ -85,6 +85,12 @@ impl ReadingListEntry { }) } + #[must_use] + pub fn restore(id: ReadingListId, mut entry: Self) -> Self { + entry.id = id; + entry + } + #[must_use] pub fn id(&self) -> &ReadingListId { &self.id @@ -140,8 +146,10 @@ fn non_empty_text(field: &'static str, value: String) -> Result Result<(), DomainError> { @@ -163,4 +171,27 @@ mod tests { Err(DomainError::InvalidReadingProgressPercent { value: "100".to_string() }) ); } + + #[test] + fn restores_existing_reading_list_identity() -> Result<(), DomainError> { + let id = ReadingListId::new(); + let profile_id = ProfileId::new(); + let space_id = SpaceId::new(); + let added_at = SystemTime::UNIX_EPOCH; + let entry = ReadingListEntry::new( + profile_id.clone(), + space_id.clone(), + " Restored ", + UrlText::parse("https://example.com/read")?, + added_at, + )?; + let entry = ReadingListEntry::restore(id.clone(), entry); + + assert_eq!(entry.id(), &id); + assert_eq!(entry.profile_id(), &profile_id); + assert_eq!(entry.space_id(), &space_id); + assert_eq!(entry.title(), "Restored"); + assert_eq!(entry.added_at(), added_at); + Ok(()) + } }