feat(sync): include reading list in snapshots
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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<usize> {
|
||||
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<ReadingProgress, SyncClientError> {
|
||||
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),
|
||||
}
|
||||
}
|
||||
@@ -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<TabSyncRecord>,
|
||||
#[serde(default)]
|
||||
pub(crate) notes: Vec<NoteSyncRecord>,
|
||||
#[serde(default)]
|
||||
pub(crate) reading_list: Vec<ReadingListSyncRecord>,
|
||||
}
|
||||
|
||||
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<String>,
|
||||
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<String>) -> 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
|
||||
}
|
||||
|
||||
@@ -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<dyn Error>> {
|
||||
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<dyn Error>> {
|
||||
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<dyn Error>> {
|
||||
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(())
|
||||
}
|
||||
Reference in New Issue
Block a user