feat(sync): preserve spaces in snapshots
This commit is contained in:
@@ -1,14 +1,15 @@
|
||||
use std::time::{Duration, UNIX_EPOCH};
|
||||
|
||||
use ely_domain::{
|
||||
BookmarkEntry, BookmarkId, BrowserTab, ProfileId, SpaceId, SyncConnectionState, SyncObjectKind,
|
||||
SyncObjectPolicy, SyncObjectState, SyncObjectStatus, SyncStatus, TabId, UrlText,
|
||||
ArchivePolicy, BookmarkEntry, BookmarkId, BrowserTab, ProfileId, Space, SpaceId,
|
||||
SyncConnectionState, SyncObjectKind, SyncObjectPolicy, SyncObjectState, SyncObjectStatus,
|
||||
SyncStatus, TabId, UrlText,
|
||||
};
|
||||
use ely_sync_client::SyncClientError;
|
||||
|
||||
use super::BrowserCore;
|
||||
use crate::sync_engine::{
|
||||
BookmarkSyncRecord, SyncSnapshotApplySummary, SyncSnapshotBody, TabSyncRecord,
|
||||
BookmarkSyncRecord, SpaceSyncRecord, SyncSnapshotApplySummary, SyncSnapshotBody, TabSyncRecord,
|
||||
};
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
@@ -111,6 +112,9 @@ impl BrowserCore {
|
||||
body: SyncSnapshotBody,
|
||||
) -> Result<SyncSnapshotApplySummary, SyncClientError> {
|
||||
let mut summary = SyncSnapshotApplySummary::default();
|
||||
for record in body.spaces {
|
||||
self.apply_space_sync_record(record, &mut summary)?;
|
||||
}
|
||||
for record in body.tabs {
|
||||
self.apply_tab_sync_record(record, &mut summary)?;
|
||||
}
|
||||
@@ -204,6 +208,91 @@ impl BrowserCore {
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub(crate) fn visible_spaces_for_sync(&self) -> Vec<&Space> {
|
||||
if self.sync_object_policy(SyncObjectKind::Spaces) == SyncObjectPolicy::Paused {
|
||||
return Vec::new();
|
||||
}
|
||||
self.spaces.iter().collect()
|
||||
}
|
||||
|
||||
fn apply_space_sync_record(
|
||||
&mut self,
|
||||
record: SpaceSyncRecord,
|
||||
summary: &mut SyncSnapshotApplySummary,
|
||||
) -> Result<(), SyncClientError> {
|
||||
let space_id = parse_space_id(&record.id)?;
|
||||
let default_profile_id = self.sync_profile_id(&record.default_profile_id)?;
|
||||
let archive_policy = ArchivePolicy::from(record.archive_policy.clone());
|
||||
let existing_index =
|
||||
self.spaces.iter().position(|space| space.id() == &space_id).or_else(|| {
|
||||
self.spaces
|
||||
.iter()
|
||||
.position(|space| space.name().eq_ignore_ascii_case(record.name.trim()))
|
||||
});
|
||||
|
||||
match existing_index {
|
||||
Some(index) => {
|
||||
if self.update_space_from_sync_record(
|
||||
index,
|
||||
record,
|
||||
default_profile_id,
|
||||
archive_policy,
|
||||
) {
|
||||
summary.record_updated();
|
||||
}
|
||||
}
|
||||
None => {
|
||||
let mut space = Space::new(
|
||||
record.name,
|
||||
record.icon,
|
||||
record.accent_hex,
|
||||
default_profile_id,
|
||||
record.sort_key,
|
||||
);
|
||||
space.set_archive_policy(archive_policy);
|
||||
space.set_sidebar_width_px(record.sidebar_width_px);
|
||||
self.spaces.push(space);
|
||||
summary.record_imported();
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn update_space_from_sync_record(
|
||||
&mut self,
|
||||
index: usize,
|
||||
record: SpaceSyncRecord,
|
||||
default_profile_id: ProfileId,
|
||||
archive_policy: ArchivePolicy,
|
||||
) -> bool {
|
||||
let space = &mut self.spaces[index];
|
||||
let mut changed = false;
|
||||
if space.name() != record.name
|
||||
|| space.icon() != record.icon
|
||||
|| space.accent_hex() != record.accent_hex
|
||||
{
|
||||
space.set_presentation(record.name, record.icon, record.accent_hex);
|
||||
changed = true;
|
||||
}
|
||||
if space.default_profile_id() != &default_profile_id {
|
||||
space.set_default_profile_id(default_profile_id);
|
||||
changed = true;
|
||||
}
|
||||
if space.archive_policy() != &archive_policy {
|
||||
space.set_archive_policy(archive_policy);
|
||||
changed = true;
|
||||
}
|
||||
if space.sidebar_width_px() != record.sidebar_width_px {
|
||||
space.set_sidebar_width_px(record.sidebar_width_px);
|
||||
changed = true;
|
||||
}
|
||||
if space.sort_key() != record.sort_key {
|
||||
space.set_sort_key(record.sort_key);
|
||||
changed = true;
|
||||
}
|
||||
changed
|
||||
}
|
||||
|
||||
fn apply_tab_sync_record(
|
||||
&mut self,
|
||||
record: TabSyncRecord,
|
||||
@@ -368,6 +457,10 @@ fn parse_tab_id(raw: &str) -> Result<TabId, SyncClientError> {
|
||||
TabId::parse(raw).map_err(snapshot_schema_error)
|
||||
}
|
||||
|
||||
fn parse_space_id(raw: &str) -> Result<SpaceId, SyncClientError> {
|
||||
SpaceId::parse(raw).map_err(snapshot_schema_error)
|
||||
}
|
||||
|
||||
fn snapshot_schema_error(error: impl ToString) -> SyncClientError {
|
||||
SyncClientError::SnapshotSchema(error.to_string())
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ use std::{
|
||||
time::{SystemTime, UNIX_EPOCH},
|
||||
};
|
||||
|
||||
use ely_domain::{BookmarkEntry, BrowserTab, TabFlags};
|
||||
use ely_domain::{ArchivePolicy, BookmarkEntry, BrowserTab, Space, TabFlags};
|
||||
use ely_sync_client::{
|
||||
ApiClientConfig, BearerToken, BearerTokenStore, DeviceIdentity, SnapshotPayload,
|
||||
SnapshotUploadRequest, SyncApiClient, SyncClientError, SyncLatestSnapshotDocument,
|
||||
@@ -12,10 +12,7 @@ use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::state::BrowserCore;
|
||||
|
||||
/// Per-profile sync scaffolding. Holds the persisted device identity
|
||||
/// and the bearer-token store; the API client is only constructed at
|
||||
/// the moment the user runs a manual sync (so a logged-out user
|
||||
/// doesn't pay TLS handshake cost on startup).
|
||||
/// Per-profile sync engine for device identity, bearer-token storage, and snapshot IO.
|
||||
#[derive(Debug)]
|
||||
pub struct SyncEngine {
|
||||
api_config: ApiClientConfig,
|
||||
@@ -281,6 +278,8 @@ fn device_registration_idempotency_key(identity: &DeviceIdentity) -> String {
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub(crate) struct SyncSnapshotBody {
|
||||
pub(crate) schema_rev: u32,
|
||||
#[serde(default)]
|
||||
pub(crate) spaces: Vec<SpaceSyncRecord>,
|
||||
pub(crate) bookmarks: Vec<BookmarkSyncRecord>,
|
||||
#[serde(default)]
|
||||
pub(crate) tabs: Vec<TabSyncRecord>,
|
||||
@@ -290,6 +289,11 @@ impl SyncSnapshotBody {
|
||||
fn from_core(core: &BrowserCore) -> Self {
|
||||
Self {
|
||||
schema_rev: SNAPSHOT_SCHEMA_REV,
|
||||
spaces: core
|
||||
.visible_spaces_for_sync()
|
||||
.into_iter()
|
||||
.map(SpaceSyncRecord::from_space)
|
||||
.collect(),
|
||||
bookmarks: core
|
||||
.visible_bookmarks_for_sync()
|
||||
.into_iter()
|
||||
@@ -311,9 +315,59 @@ impl SyncSnapshotBody {
|
||||
}
|
||||
}
|
||||
|
||||
/// Wire representation of a bookmark. We keep this struct stable so a
|
||||
/// future deserializer can read snapshots written by earlier app
|
||||
/// versions; new fields must default-fill on read.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub(crate) struct SpaceSyncRecord {
|
||||
pub(crate) id: String,
|
||||
pub(crate) name: String,
|
||||
pub(crate) icon: String,
|
||||
pub(crate) accent_hex: u32,
|
||||
pub(crate) default_profile_id: String,
|
||||
pub(crate) archive_policy: SpaceArchivePolicySyncRecord,
|
||||
pub(crate) sidebar_width_px: u16,
|
||||
pub(crate) sort_key: u64,
|
||||
}
|
||||
|
||||
impl SpaceSyncRecord {
|
||||
fn from_space(space: &Space) -> Self {
|
||||
Self {
|
||||
id: space.id().as_str().to_string(),
|
||||
name: space.name().to_string(),
|
||||
icon: space.icon().to_string(),
|
||||
accent_hex: space.accent_hex(),
|
||||
default_profile_id: space.default_profile_id().as_str().to_string(),
|
||||
archive_policy: SpaceArchivePolicySyncRecord::from(space.archive_policy()),
|
||||
sidebar_width_px: space.sidebar_width_px(),
|
||||
sort_key: space.sort_key(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
||||
#[serde(tag = "kind", rename_all = "snake_case")]
|
||||
pub(crate) enum SpaceArchivePolicySyncRecord {
|
||||
Manual,
|
||||
IdleDays { days: u16 },
|
||||
}
|
||||
|
||||
impl From<&ArchivePolicy> for SpaceArchivePolicySyncRecord {
|
||||
fn from(policy: &ArchivePolicy) -> Self {
|
||||
match policy {
|
||||
ArchivePolicy::Manual => Self::Manual,
|
||||
ArchivePolicy::IdleDays(days) => Self::IdleDays { days: *days },
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<SpaceArchivePolicySyncRecord> for ArchivePolicy {
|
||||
fn from(policy: SpaceArchivePolicySyncRecord) -> Self {
|
||||
match policy {
|
||||
SpaceArchivePolicySyncRecord::Manual => Self::Manual,
|
||||
SpaceArchivePolicySyncRecord::IdleDays { days } => Self::IdleDays(days),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Wire representation of a bookmark with default-fill fields for older snapshots.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub(crate) struct BookmarkSyncRecord {
|
||||
pub(crate) id: String,
|
||||
|
||||
@@ -2,8 +2,8 @@ use std::error::Error;
|
||||
|
||||
use ely_browser_core::{BrowserCore, InitialBrowserConfig};
|
||||
use ely_domain::{
|
||||
SyncConnectionState, SyncObjectKind, SyncObjectPolicy, SyncObjectState, SyncObjectStatus,
|
||||
UrlText,
|
||||
ArchivePolicy, SyncConnectionState, SyncObjectKind, SyncObjectPolicy, SyncObjectState,
|
||||
SyncObjectStatus, UrlText,
|
||||
};
|
||||
|
||||
#[test]
|
||||
@@ -155,6 +155,46 @@ fn sync_snapshot_imports_remote_tabs_into_active_scope() -> Result<(), Box<dyn E
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sync_snapshot_imports_remote_spaces_before_tabs() -> 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 research_space_id = source.create_space("Research", "R", 0xf54e00)?;
|
||||
source.set_active_space_archive_policy(ArchivePolicy::IdleDays(14))?;
|
||||
source.set_space_sidebar_width(&research_space_id, 320)?;
|
||||
let research_home_tab_id = source.snapshot()?.active_tab_id;
|
||||
source.set_tab_sync_enabled(&research_home_tab_id, false)?;
|
||||
source.open_tab(UrlText::parse("https://example.com/research")?);
|
||||
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 research_space = snapshot
|
||||
.spaces
|
||||
.iter()
|
||||
.find(|space| space.name() == "Research")
|
||||
.ok_or("missing imported research space")?;
|
||||
let research_space_id = research_space.id().clone();
|
||||
assert_eq!(research_space.archive_policy(), &ArchivePolicy::IdleDays(14));
|
||||
assert_eq!(research_space.sidebar_width_px(), 320);
|
||||
|
||||
target.select_space(&research_space_id)?;
|
||||
let snapshot = target.snapshot()?;
|
||||
let imported_tab = snapshot
|
||||
.tabs
|
||||
.iter()
|
||||
.find(|tab| tab.url().as_str() == "https://example.com/research")
|
||||
.ok_or("missing imported research tab")?;
|
||||
|
||||
assert_eq!(summary.imported(), 2);
|
||||
assert_eq!(summary.updated(), 0);
|
||||
assert_eq!(summary.skipped(), 0);
|
||||
assert_eq!(imported_tab.space_id(), &research_space_id);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sync_snapshot_updates_existing_tab_metadata() -> Result<(), Box<dyn Error>> {
|
||||
let mut source = BrowserCore::new(InitialBrowserConfig::ely_defaults()?)?;
|
||||
|
||||
@@ -80,6 +80,18 @@ impl Space {
|
||||
self.record_update();
|
||||
}
|
||||
|
||||
pub fn set_presentation(
|
||||
&mut self,
|
||||
name: impl Into<String>,
|
||||
icon: impl Into<String>,
|
||||
accent_hex: u32,
|
||||
) {
|
||||
self.name = name.into();
|
||||
self.icon = icon.into();
|
||||
self.accent_hex = accent_hex;
|
||||
self.record_update();
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn archive_policy(&self) -> &ArchivePolicy {
|
||||
&self.archive_policy
|
||||
|
||||
Reference in New Issue
Block a user