feat(sync): preserve spaces in snapshots
This commit is contained in:
@@ -1,14 +1,15 @@
|
|||||||
use std::time::{Duration, UNIX_EPOCH};
|
use std::time::{Duration, UNIX_EPOCH};
|
||||||
|
|
||||||
use ely_domain::{
|
use ely_domain::{
|
||||||
BookmarkEntry, BookmarkId, BrowserTab, ProfileId, SpaceId, SyncConnectionState, SyncObjectKind,
|
ArchivePolicy, BookmarkEntry, BookmarkId, BrowserTab, ProfileId, Space, SpaceId,
|
||||||
SyncObjectPolicy, SyncObjectState, SyncObjectStatus, SyncStatus, TabId, UrlText,
|
SyncConnectionState, SyncObjectKind, SyncObjectPolicy, SyncObjectState, SyncObjectStatus,
|
||||||
|
SyncStatus, TabId, UrlText,
|
||||||
};
|
};
|
||||||
use ely_sync_client::SyncClientError;
|
use ely_sync_client::SyncClientError;
|
||||||
|
|
||||||
use super::BrowserCore;
|
use super::BrowserCore;
|
||||||
use crate::sync_engine::{
|
use crate::sync_engine::{
|
||||||
BookmarkSyncRecord, SyncSnapshotApplySummary, SyncSnapshotBody, TabSyncRecord,
|
BookmarkSyncRecord, SpaceSyncRecord, SyncSnapshotApplySummary, SyncSnapshotBody, TabSyncRecord,
|
||||||
};
|
};
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
@@ -111,6 +112,9 @@ impl BrowserCore {
|
|||||||
body: SyncSnapshotBody,
|
body: SyncSnapshotBody,
|
||||||
) -> Result<SyncSnapshotApplySummary, SyncClientError> {
|
) -> Result<SyncSnapshotApplySummary, SyncClientError> {
|
||||||
let mut summary = SyncSnapshotApplySummary::default();
|
let mut summary = SyncSnapshotApplySummary::default();
|
||||||
|
for record in body.spaces {
|
||||||
|
self.apply_space_sync_record(record, &mut summary)?;
|
||||||
|
}
|
||||||
for record in body.tabs {
|
for record in body.tabs {
|
||||||
self.apply_tab_sync_record(record, &mut summary)?;
|
self.apply_tab_sync_record(record, &mut summary)?;
|
||||||
}
|
}
|
||||||
@@ -204,6 +208,91 @@ impl BrowserCore {
|
|||||||
.collect()
|
.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(
|
fn apply_tab_sync_record(
|
||||||
&mut self,
|
&mut self,
|
||||||
record: TabSyncRecord,
|
record: TabSyncRecord,
|
||||||
@@ -368,6 +457,10 @@ fn parse_tab_id(raw: &str) -> Result<TabId, SyncClientError> {
|
|||||||
TabId::parse(raw).map_err(snapshot_schema_error)
|
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 {
|
fn snapshot_schema_error(error: impl ToString) -> SyncClientError {
|
||||||
SyncClientError::SnapshotSchema(error.to_string())
|
SyncClientError::SnapshotSchema(error.to_string())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ use std::{
|
|||||||
time::{SystemTime, UNIX_EPOCH},
|
time::{SystemTime, UNIX_EPOCH},
|
||||||
};
|
};
|
||||||
|
|
||||||
use ely_domain::{BookmarkEntry, BrowserTab, TabFlags};
|
use ely_domain::{ArchivePolicy, BookmarkEntry, BrowserTab, Space, TabFlags};
|
||||||
use ely_sync_client::{
|
use ely_sync_client::{
|
||||||
ApiClientConfig, BearerToken, BearerTokenStore, DeviceIdentity, SnapshotPayload,
|
ApiClientConfig, BearerToken, BearerTokenStore, DeviceIdentity, SnapshotPayload,
|
||||||
SnapshotUploadRequest, SyncApiClient, SyncClientError, SyncLatestSnapshotDocument,
|
SnapshotUploadRequest, SyncApiClient, SyncClientError, SyncLatestSnapshotDocument,
|
||||||
@@ -12,10 +12,7 @@ use serde::{Deserialize, Serialize};
|
|||||||
|
|
||||||
use crate::state::BrowserCore;
|
use crate::state::BrowserCore;
|
||||||
|
|
||||||
/// Per-profile sync scaffolding. Holds the persisted device identity
|
/// Per-profile sync engine for device identity, bearer-token storage, and snapshot IO.
|
||||||
/// 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).
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub struct SyncEngine {
|
pub struct SyncEngine {
|
||||||
api_config: ApiClientConfig,
|
api_config: ApiClientConfig,
|
||||||
@@ -281,6 +278,8 @@ fn device_registration_idempotency_key(identity: &DeviceIdentity) -> String {
|
|||||||
#[derive(Serialize, Deserialize)]
|
#[derive(Serialize, Deserialize)]
|
||||||
pub(crate) struct SyncSnapshotBody {
|
pub(crate) struct SyncSnapshotBody {
|
||||||
pub(crate) schema_rev: u32,
|
pub(crate) schema_rev: u32,
|
||||||
|
#[serde(default)]
|
||||||
|
pub(crate) spaces: Vec<SpaceSyncRecord>,
|
||||||
pub(crate) bookmarks: Vec<BookmarkSyncRecord>,
|
pub(crate) bookmarks: Vec<BookmarkSyncRecord>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub(crate) tabs: Vec<TabSyncRecord>,
|
pub(crate) tabs: Vec<TabSyncRecord>,
|
||||||
@@ -290,6 +289,11 @@ impl SyncSnapshotBody {
|
|||||||
fn from_core(core: &BrowserCore) -> Self {
|
fn from_core(core: &BrowserCore) -> Self {
|
||||||
Self {
|
Self {
|
||||||
schema_rev: SNAPSHOT_SCHEMA_REV,
|
schema_rev: SNAPSHOT_SCHEMA_REV,
|
||||||
|
spaces: core
|
||||||
|
.visible_spaces_for_sync()
|
||||||
|
.into_iter()
|
||||||
|
.map(SpaceSyncRecord::from_space)
|
||||||
|
.collect(),
|
||||||
bookmarks: core
|
bookmarks: core
|
||||||
.visible_bookmarks_for_sync()
|
.visible_bookmarks_for_sync()
|
||||||
.into_iter()
|
.into_iter()
|
||||||
@@ -311,9 +315,59 @@ impl SyncSnapshotBody {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Wire representation of a bookmark. We keep this struct stable so a
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||||
/// future deserializer can read snapshots written by earlier app
|
pub(crate) struct SpaceSyncRecord {
|
||||||
/// versions; new fields must default-fill on read.
|
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)]
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||||
pub(crate) struct BookmarkSyncRecord {
|
pub(crate) struct BookmarkSyncRecord {
|
||||||
pub(crate) id: String,
|
pub(crate) id: String,
|
||||||
|
|||||||
@@ -2,8 +2,8 @@ use std::error::Error;
|
|||||||
|
|
||||||
use ely_browser_core::{BrowserCore, InitialBrowserConfig};
|
use ely_browser_core::{BrowserCore, InitialBrowserConfig};
|
||||||
use ely_domain::{
|
use ely_domain::{
|
||||||
SyncConnectionState, SyncObjectKind, SyncObjectPolicy, SyncObjectState, SyncObjectStatus,
|
ArchivePolicy, SyncConnectionState, SyncObjectKind, SyncObjectPolicy, SyncObjectState,
|
||||||
UrlText,
|
SyncObjectStatus, UrlText,
|
||||||
};
|
};
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -155,6 +155,46 @@ fn sync_snapshot_imports_remote_tabs_into_active_scope() -> Result<(), Box<dyn E
|
|||||||
Ok(())
|
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]
|
#[test]
|
||||||
fn sync_snapshot_updates_existing_tab_metadata() -> Result<(), Box<dyn Error>> {
|
fn sync_snapshot_updates_existing_tab_metadata() -> Result<(), Box<dyn Error>> {
|
||||||
let mut source = BrowserCore::new(InitialBrowserConfig::ely_defaults()?)?;
|
let mut source = BrowserCore::new(InitialBrowserConfig::ely_defaults()?)?;
|
||||||
|
|||||||
@@ -80,6 +80,18 @@ impl Space {
|
|||||||
self.record_update();
|
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]
|
#[must_use]
|
||||||
pub fn archive_policy(&self) -> &ArchivePolicy {
|
pub fn archive_policy(&self) -> &ArchivePolicy {
|
||||||
&self.archive_policy
|
&self.archive_policy
|
||||||
|
|||||||
Reference in New Issue
Block a user