feat(sync): include notes in snapshots

This commit is contained in:
2026-05-16 06:07:44 -04:00
parent 7d5e936ca4
commit b2b3d5deca
6 changed files with 343 additions and 6 deletions
+1
View File
@@ -30,6 +30,7 @@ mod space_exports;
mod spaces;
mod splits;
mod sync;
mod sync_notes;
mod tab_group_order;
mod tab_groups;
mod tab_lifecycle;
+6 -3
View File
@@ -120,6 +120,9 @@ impl BrowserCore {
for record in body.bookmarks {
self.apply_bookmark_sync_record(record, &mut summary)?;
}
for record in body.notes {
self.apply_note_sync_record(record, &mut summary)?;
}
Ok(summary)
}
@@ -396,7 +399,7 @@ impl BrowserCore {
Ok(())
}
fn sync_profile_id(&self, raw: &str) -> Result<ProfileId, SyncClientError> {
pub(super) fn sync_profile_id(&self, raw: &str) -> Result<ProfileId, SyncClientError> {
let profile_id = ProfileId::parse(raw).map_err(snapshot_schema_error)?;
if self.profiles.iter().any(|profile| profile.id() == &profile_id) {
return Ok(profile_id);
@@ -404,7 +407,7 @@ impl BrowserCore {
Ok(self.active_profile_id.clone())
}
fn sync_space_id(
pub(super) fn sync_space_id(
&self,
raw: &str,
space_name: Option<&str>,
@@ -460,6 +463,6 @@ 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 {
pub(super) fn snapshot_schema_error(error: impl ToString) -> SyncClientError {
SyncClientError::SnapshotSchema(error.to_string())
}
@@ -0,0 +1,122 @@
use std::time::{Duration, UNIX_EPOCH};
use ely_domain::{
NoteEntry, NoteId, NoteTarget, ProfileId, SpaceId, SyncObjectKind, SyncObjectPolicy, TabId,
UrlText,
};
use ely_sync_client::SyncClientError;
use super::{BrowserCore, sync::snapshot_schema_error};
use crate::{
sync_engine::SyncSnapshotApplySummary,
sync_records::{NoteSyncRecord, NoteTargetSyncRecord},
};
impl BrowserCore {
pub(crate) fn visible_notes_for_sync(&self) -> Vec<&NoteEntry> {
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()
}
pub(super) fn apply_note_sync_record(
&mut self,
record: NoteSyncRecord,
summary: &mut SyncSnapshotApplySummary,
) -> 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 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 =
self.resolve_note_sync_target(record.target, &profile_id, &space_id, &source_url)?;
let created_at = UNIX_EPOCH + Duration::from_secs(record.created_at_secs);
let updated_at = UNIX_EPOCH + Duration::from_secs(record.updated_at_secs);
let existing_index = self.note_sync_index(&note_id, &profile_id, &target);
let id = existing_index
.and_then(|index| self.notes.get(index).map(|note| note.id().clone()))
.unwrap_or(note_id);
let note = NoteEntry::new(
profile_id,
space_id,
target,
record.title,
source_url,
record.body,
created_at,
)
.map_err(snapshot_schema_error)?;
let note = NoteEntry::restore(id, note, updated_at);
match existing_index {
Some(index) if self.notes[index] == note => summary.record_skipped(),
Some(index) => {
self.notes[index] = note;
summary.record_updated();
}
None => {
self.notes.push(note);
summary.record_imported();
}
}
Ok(())
}
fn resolve_note_sync_target(
&self,
target: NoteTargetSyncRecord,
profile_id: &ProfileId,
space_id: &SpaceId,
source_url: &UrlText,
) -> Result<NoteTarget, SyncClientError> {
match target {
NoteTargetSyncRecord::Url { url } => {
UrlText::parse(&url).map(NoteTarget::Url).map_err(snapshot_schema_error)
}
NoteTargetSyncRecord::Tab { tab_id } => {
let remote_tab_id = TabId::parse(tab_id).map_err(snapshot_schema_error)?;
if let Some(tab) = self.tabs.iter().find(|tab| tab.id() == &remote_tab_id) {
return Ok(NoteTarget::Tab(tab.id().clone()));
}
if let Some(tab) = self.tabs.iter().find(|tab| {
tab.profile_id() == profile_id
&& tab.space_id() == space_id
&& tab.url() == source_url
}) {
return Ok(NoteTarget::Tab(tab.id().clone()));
}
Ok(NoteTarget::Url(source_url.clone()))
}
}
}
fn note_sync_index(
&self,
note_id: &NoteId,
profile_id: &ProfileId,
target: &NoteTarget,
) -> Option<usize> {
self.notes.iter().position(|note| note.id() == note_id).or_else(|| {
self.notes.iter().position(|note| {
note.profile_id() == profile_id
&& note_targets_match_for_sync(note.target(), target)
})
})
}
}
fn note_targets_match_for_sync(left: &NoteTarget, right: &NoteTarget) -> bool {
match (left, right) {
(NoteTarget::Url(left_url), NoteTarget::Url(right_url)) => left_url == right_url,
(NoteTarget::Tab(left_tab), NoteTarget::Tab(right_tab)) => left_tab == right_tab,
_ => false,
}
}
+60 -1
View File
@@ -1,6 +1,8 @@
use std::time::{SystemTime, UNIX_EPOCH};
use ely_domain::{ArchivePolicy, BookmarkEntry, BrowserTab, Space, TabFlags};
use ely_domain::{
ArchivePolicy, BookmarkEntry, BrowserTab, NoteEntry, NoteTarget, Space, TabFlags,
};
use serde::{Deserialize, Serialize};
use crate::state::BrowserCore;
@@ -15,6 +17,8 @@ pub(crate) struct SyncSnapshotBody {
pub(crate) bookmarks: Vec<BookmarkSyncRecord>,
#[serde(default)]
pub(crate) tabs: Vec<TabSyncRecord>,
#[serde(default)]
pub(crate) notes: Vec<NoteSyncRecord>,
}
impl SyncSnapshotBody {
@@ -43,6 +47,13 @@ impl SyncSnapshotBody {
TabSyncRecord::from_entry(entry, core.sync_space_name_for(entry.space_id()))
})
.collect(),
notes: core
.visible_notes_for_sync()
.into_iter()
.map(|entry| {
NoteSyncRecord::from_entry(entry, core.sync_space_name_for(entry.space_id()))
})
.collect(),
}
}
}
@@ -175,6 +186,54 @@ impl TabSyncRecord {
}
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub(crate) struct NoteSyncRecord {
pub(crate) id: String,
pub(crate) profile_id: String,
pub(crate) space_id: String,
#[serde(default)]
pub(crate) space_name: Option<String>,
pub(crate) target: NoteTargetSyncRecord,
pub(crate) title: String,
pub(crate) source_url: String,
pub(crate) body: String,
pub(crate) created_at_secs: u64,
pub(crate) updated_at_secs: u64,
}
impl NoteSyncRecord {
fn from_entry(entry: &NoteEntry, 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,
target: NoteTargetSyncRecord::from_note_target(entry.target()),
title: entry.title().to_string(),
source_url: entry.source_url().as_str().to_string(),
body: entry.body().to_string(),
created_at_secs: system_time_secs(entry.created_at()),
updated_at_secs: system_time_secs(entry.updated_at()),
}
}
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub(crate) enum NoteTargetSyncRecord {
Url { url: String },
Tab { tab_id: String },
}
impl NoteTargetSyncRecord {
fn from_note_target(target: &NoteTarget) -> Self {
match target {
NoteTarget::Url(url) => Self::Url { url: url.as_str().to_string() },
NoteTarget::Tab(tab_id) => Self::Tab { tab_id: tab_id.as_str().to_string() },
}
}
}
fn default_sync_enabled() -> bool {
true
}