diff --git a/Cargo.lock b/Cargo.lock index e14e4e8..20a67e0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2263,6 +2263,8 @@ name = "ely_browser_core" version = "0.1.0" dependencies = [ "ely_domain", + "serde", + "serde_json", "thiserror 2.0.18", "url", ] diff --git a/crates/ely_browser_core/Cargo.toml b/crates/ely_browser_core/Cargo.toml index 07d8b25..8c1284f 100644 --- a/crates/ely_browser_core/Cargo.toml +++ b/crates/ely_browser_core/Cargo.toml @@ -7,6 +7,8 @@ rust-version.workspace = true [dependencies] ely_domain = { path = "../ely_domain" } +serde.workspace = true +serde_json.workspace = true thiserror.workspace = true url.workspace = true diff --git a/crates/ely_browser_core/src/error.rs b/crates/ely_browser_core/src/error.rs index 1c8eccf..4aeae0b 100644 --- a/crates/ely_browser_core/src/error.rs +++ b/crates/ely_browser_core/src/error.rs @@ -18,6 +18,9 @@ pub enum CoreError { #[error("space not found: {id}")] SpaceNotFound { id: SpaceId }, + #[error("invalid .elyspace package: {reason}")] + InvalidSpacePackage { reason: String }, + #[error("trashed space not found: {id}")] TrashedSpaceNotFound { id: SpaceId }, diff --git a/crates/ely_browser_core/src/lib.rs b/crates/ely_browser_core/src/lib.rs index 0c473cb..c44e407 100644 --- a/crates/ely_browser_core/src/lib.rs +++ b/crates/ely_browser_core/src/lib.rs @@ -4,6 +4,7 @@ mod state; pub use error::CoreError; pub use state::{ - BrowserCore, BrowserSnapshot, InitialBrowserConfig, InstalledPlugin, PluginAuditAction, - PluginAuditEvent, SiteDataClearance, TrashedSpace, + BrowserCore, BrowserSnapshot, ELYSPACE_FILE_EXTENSION, ELYSPACE_SCHEMA_VERSION, + ElySpacePackage, InitialBrowserConfig, InstalledPlugin, PluginAuditAction, PluginAuditEvent, + SiteDataClearance, SpaceImportProfileMapping, TrashedSpace, }; diff --git a/crates/ely_browser_core/src/state.rs b/crates/ely_browser_core/src/state.rs index fb101f2..d0a074e 100644 --- a/crates/ely_browser_core/src/state.rs +++ b/crates/ely_browser_core/src/state.rs @@ -21,6 +21,7 @@ mod profiles; mod reading_list; mod site_data; mod site_permissions; +mod space_exports; mod spaces; mod splits; mod sync; @@ -33,6 +34,9 @@ mod tabs; pub use plugins::{InstalledPlugin, PluginAuditAction, PluginAuditEvent}; pub use site_data::SiteDataClearance; +pub use space_exports::{ + ELYSPACE_FILE_EXTENSION, ELYSPACE_SCHEMA_VERSION, ElySpacePackage, SpaceImportProfileMapping, +}; pub use spaces::TrashedSpace; #[derive(Clone, Debug)] diff --git a/crates/ely_browser_core/src/state/space_exports.rs b/crates/ely_browser_core/src/state/space_exports.rs new file mode 100644 index 0000000..2e2257a --- /dev/null +++ b/crates/ely_browser_core/src/state/space_exports.rs @@ -0,0 +1,295 @@ +use ely_domain::{ + ArchivePolicy, BrowserTab, ProfileId, ProfileKind, Space, SpaceId, TabId, UrlText, +}; +use serde::{Deserialize, Serialize}; + +use super::BrowserCore; +use crate::CoreError; + +pub const ELYSPACE_SCHEMA_VERSION: u16 = 1; +pub const ELYSPACE_FILE_EXTENSION: &str = "elyspace"; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum SpaceImportProfileMapping { + PreserveExisting, + UseActiveProfile, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +pub struct ElySpacePackage { + version: u16, + space: ElySpaceRecord, + tabs: Vec, +} + +impl ElySpacePackage { + #[must_use] + pub fn version(&self) -> u16 { + self.version + } + + #[must_use] + pub fn space_name(&self) -> &str { + &self.space.name + } + + #[must_use] + pub fn tab_count(&self) -> usize { + self.tabs.len() + } +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +struct ElySpaceRecord { + name: String, + icon: String, + accent_hex: u32, + default_profile_id: String, + archive_policy: ElySpaceArchivePolicy, + sidebar_width_px: u16, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +struct ElySpaceTabRecord { + title: String, + url: String, + profile_id: String, + favicon_key: Option, + pinned: bool, + favorite: bool, + sort_key: u64, + sync_enabled: bool, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +enum ElySpaceArchivePolicy { + Manual, + IdleDays { days: u16 }, +} + +impl BrowserCore { + pub fn export_space_package_json(&self, space_id: &SpaceId) -> Result { + serde_json::to_string_pretty(&self.export_space_package(space_id)?) + .map_err(invalid_space_package) + } + + pub fn export_space_package(&self, space_id: &SpaceId) -> Result { + let space = self + .spaces + .iter() + .find(|space| space.id() == space_id) + .ok_or_else(|| CoreError::SpaceNotFound { id: space_id.clone() })?; + let tabs = self + .tabs + .iter() + .filter(|tab| tab.space_id() == space_id) + .map(ElySpaceTabRecord::from_tab) + .collect(); + + Ok(ElySpacePackage { + version: ELYSPACE_SCHEMA_VERSION, + space: ElySpaceRecord::from_space(space), + tabs, + }) + } + + pub fn import_space_package_json( + &mut self, + package_json: &str, + profile_mapping: SpaceImportProfileMapping, + ) -> Result { + let package = serde_json::from_str(package_json).map_err(invalid_space_package)?; + self.import_space_package(package, profile_mapping) + } + + pub fn import_space_package( + &mut self, + package: ElySpacePackage, + profile_mapping: SpaceImportProfileMapping, + ) -> Result { + validate_package_version(package.version)?; + self.validate_imported_favorites(&package)?; + let default_profile_id = self.imported_default_profile_id(&package, profile_mapping)?; + let sort_key = self.next_space_sort_key(); + let mut space = Space::new( + package.space.name.clone(), + package.space.icon.clone(), + package.space.accent_hex, + default_profile_id.clone(), + sort_key, + ); + space.set_archive_policy(package.space.archive_policy.clone().into()); + space.set_sidebar_width_px(package.space.sidebar_width_px); + + let space_id = space.id().clone(); + let tabs = self.imported_tabs(&space_id, &default_profile_id, &package, profile_mapping)?; + let active_tab_id = tabs[0].id().clone(); + + self.spaces.push(space); + self.tabs.extend(tabs); + self.active_tabs_by_space.insert(space_id.clone(), active_tab_id.clone()); + self.select_tab(&active_tab_id)?; + Ok(space_id) + } + + fn imported_default_profile_id( + &self, + package: &ElySpacePackage, + profile_mapping: SpaceImportProfileMapping, + ) -> Result { + match profile_mapping { + SpaceImportProfileMapping::PreserveExisting => { + let profile_id = ProfileId::parse(&package.space.default_profile_id)?; + self.standard_profile_id(&profile_id)?; + Ok(profile_id) + } + SpaceImportProfileMapping::UseActiveProfile => self.stable_active_profile_id(), + } + } + + fn validate_imported_favorites(&self, package: &ElySpacePackage) -> Result<(), CoreError> { + let imported_favorites = package.tabs.iter().filter(|tab| tab.favorite).count(); + let favorite_limit = self.favorite_limit.value(); + if self.favorites().len() + imported_favorites > favorite_limit { + return Err(CoreError::FavoriteLimitReached { limit: favorite_limit }); + } + Ok(()) + } + + fn imported_tabs( + &self, + space_id: &SpaceId, + default_profile_id: &ProfileId, + package: &ElySpacePackage, + profile_mapping: SpaceImportProfileMapping, + ) -> Result, CoreError> { + let mut tabs = package + .tabs + .iter() + .map(|tab| self.imported_tab(space_id, default_profile_id, tab, profile_mapping)) + .collect::, _>>()?; + + if tabs.is_empty() { + tabs.push(self.build_tab_for( + space_id.clone(), + default_profile_id.clone(), + self.new_tab_url()?, + )); + } + Ok(tabs) + } + + fn imported_tab( + &self, + space_id: &SpaceId, + default_profile_id: &ProfileId, + record: &ElySpaceTabRecord, + profile_mapping: SpaceImportProfileMapping, + ) -> Result { + let profile_id = match profile_mapping { + SpaceImportProfileMapping::PreserveExisting => { + let profile_id = ProfileId::parse(&record.profile_id)?; + self.standard_profile_id(&profile_id)?; + profile_id + } + SpaceImportProfileMapping::UseActiveProfile => default_profile_id.clone(), + }; + let mut tab = BrowserTab::new( + TabId::new(), + space_id.clone(), + profile_id, + record.title.clone(), + UrlText::parse(&record.url)?, + ) + .with_sort_key(record.sort_key); + tab.set_pinned(record.pinned); + tab.set_favorite(record.favorite); + tab.set_sync_enabled(record.sync_enabled); + if let Some(favicon_key) = &record.favicon_key { + tab.set_favicon_key(favicon_key.clone())?; + } + Ok(tab) + } + + fn standard_profile_id(&self, profile_id: &ProfileId) -> Result<(), CoreError> { + let profile = self + .profiles + .iter() + .find(|profile| profile.id() == profile_id) + .ok_or_else(|| CoreError::ProfileNotFound { id: profile_id.clone() })?; + if profile.kind() == &ProfileKind::Private { + return Err(CoreError::PrivateProfileDefaultLocked { id: profile_id.clone() }); + } + Ok(()) + } + + fn stable_active_profile_id(&self) -> Result { + if self.active_profile()?.kind() == &ProfileKind::Private { + Ok(self.active_space()?.default_profile_id().clone()) + } else { + Ok(self.active_profile_id.clone()) + } + } +} + +impl ElySpaceRecord { + fn from_space(space: &Space) -> Self { + Self { + 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: ElySpaceArchivePolicy::from(space.archive_policy()), + sidebar_width_px: space.sidebar_width_px(), + } + } +} + +impl ElySpaceTabRecord { + fn from_tab(tab: &BrowserTab) -> Self { + Self { + title: tab.title().to_string(), + url: tab.url().as_str().to_string(), + profile_id: tab.profile_id().as_str().to_string(), + favicon_key: tab.favicon_key().map(str::to_string), + pinned: tab.flags().pinned, + favorite: tab.flags().favorite, + sort_key: tab.sort_key(), + sync_enabled: tab.sync_enabled(), + } + } +} + +impl From<&ArchivePolicy> for ElySpaceArchivePolicy { + fn from(policy: &ArchivePolicy) -> Self { + match policy { + ArchivePolicy::Manual => Self::Manual, + ArchivePolicy::IdleDays(days) => Self::IdleDays { days: *days }, + } + } +} + +impl From for ArchivePolicy { + fn from(policy: ElySpaceArchivePolicy) -> Self { + match policy { + ElySpaceArchivePolicy::Manual => Self::Manual, + ElySpaceArchivePolicy::IdleDays { days } => Self::IdleDays(days), + } + } +} + +fn validate_package_version(version: u16) -> Result<(), CoreError> { + if version == ELYSPACE_SCHEMA_VERSION { + Ok(()) + } else { + Err(CoreError::InvalidSpacePackage { reason: format!("unsupported version {version}") }) + } +} + +fn invalid_space_package(error: serde_json::Error) -> CoreError { + CoreError::InvalidSpacePackage { reason: error.to_string() } +} diff --git a/crates/ely_browser_core/tests/space_exports.rs b/crates/ely_browser_core/tests/space_exports.rs new file mode 100644 index 0000000..12ce2f6 --- /dev/null +++ b/crates/ely_browser_core/tests/space_exports.rs @@ -0,0 +1,135 @@ +use std::error::Error; + +use ely_browser_core::{ + BrowserCore, CoreError, ELYSPACE_FILE_EXTENSION, ELYSPACE_SCHEMA_VERSION, InitialBrowserConfig, + SpaceImportProfileMapping, +}; +use ely_domain::{ArchivePolicy, ProfileKind, UrlText}; +use serde_json::Value; + +#[test] +fn exports_space_package_json_with_settings_and_tabs() -> Result<(), Box> { + let mut core = BrowserCore::new(InitialBrowserConfig::ely_defaults()?)?; + let space_id = core.snapshot()?.active_space_id; + let active_tab_id = core.snapshot()?.active_tab_id; + + core.set_space_archive_policy(&space_id, ArchivePolicy::IdleDays(14))?; + core.set_space_sidebar_width(&space_id, 320)?; + core.set_tab_favicon_key(&active_tab_id, "favicons/work.ico")?; + core.toggle_active_tab_pinned()?; + core.toggle_active_tab_favorite()?; + core.open_tab(UrlText::parse("https://servo.org/")?); + + let package = core.export_space_package(&space_id)?; + let package_json = core.export_space_package_json(&space_id)?; + let value: Value = serde_json::from_str(&package_json)?; + + assert_eq!(ELYSPACE_FILE_EXTENSION, "elyspace"); + assert_eq!(package.version(), ELYSPACE_SCHEMA_VERSION); + assert_eq!(package.space_name(), "Work"); + assert_eq!(package.tab_count(), 2); + assert_eq!(json_u64(&value, "version")?, 1); + assert_eq!(json_str(json_object(&value, "space")?, "name")?, "Work"); + assert_eq!(json_object_u64(json_object(&value, "space")?, "sidebar_width_px")?, 320); + assert_eq!(json_array(&value, "tabs")?.len(), 2); + Ok(()) +} + +#[test] +fn imports_space_package_with_active_profile_mapping() -> Result<(), Box> { + let mut source = BrowserCore::new(InitialBrowserConfig::ely_defaults()?)?; + let source_space_id = source.snapshot()?.active_space_id; + source.set_space_archive_policy(&source_space_id, ArchivePolicy::IdleDays(7))?; + source.set_space_sidebar_width(&source_space_id, 360)?; + source.open_tab(UrlText::parse("https://example.com")?); + let package_json = source.export_space_package_json(&source_space_id)?; + + let mut target = BrowserCore::new(InitialBrowserConfig::ely_defaults()?)?; + let target_profile_id = target.snapshot()?.active_profile_id; + let imported_space_id = target + .import_space_package_json(&package_json, SpaceImportProfileMapping::UseActiveProfile)?; + let snapshot = target.snapshot()?; + let Some(imported_space) = + snapshot.spaces.iter().find(|space| space.id() == &imported_space_id) + else { + return Err("missing imported space".into()); + }; + + assert_eq!(imported_space.name(), "Work"); + assert_eq!(imported_space.archive_policy(), &ArchivePolicy::IdleDays(7)); + assert_eq!(imported_space.sidebar_width_px(), 360); + assert_eq!(imported_space.default_profile_id(), &target_profile_id); + assert!(snapshot.tabs.iter().all(|tab| tab.profile_id() == &target_profile_id)); + assert!(snapshot.tabs.iter().any(|tab| tab.url().as_str() == "https://example.com")); + Ok(()) +} + +#[test] +fn imports_space_package_with_existing_profile_mapping() -> Result<(), Box> { + let mut core = BrowserCore::new(InitialBrowserConfig::ely_defaults()?)?; + let research_profile_id = core.create_profile("Research", 0x9fc9a2, ProfileKind::Standard)?; + let research_space_id = core.create_space("Research", "R", 0x9fc9a2)?; + core.open_tab(UrlText::parse("https://servo.org/")?); + let package_json = core.export_space_package_json(&research_space_id)?; + + let imported_space_id = + core.import_space_package_json(&package_json, SpaceImportProfileMapping::PreserveExisting)?; + let snapshot = core.snapshot()?; + let Some(imported_space) = + snapshot.spaces.iter().find(|space| space.id() == &imported_space_id) + else { + return Err("missing imported space".into()); + }; + + assert_eq!(imported_space.default_profile_id(), &research_profile_id); + assert!(snapshot.tabs.iter().all(|tab| tab.profile_id() == &research_profile_id)); + Ok(()) +} + +#[test] +fn importing_space_package_rejects_missing_preserved_profile() -> Result<(), Box> { + let source = BrowserCore::new(InitialBrowserConfig::ely_defaults()?)?; + let source_profile_id = source.snapshot()?.active_profile_id; + let source_space_id = source.snapshot()?.active_space_id; + let package_json = source.export_space_package_json(&source_space_id)?; + + let mut target = BrowserCore::new(InitialBrowserConfig::ely_defaults()?)?; + let error = match target + .import_space_package_json(&package_json, SpaceImportProfileMapping::PreserveExisting) + { + Err(error) => error, + Ok(_) => return Err("import should reject missing preserved profile".into()), + }; + + assert_eq!(error, CoreError::ProfileNotFound { id: source_profile_id }); + Ok(()) +} + +fn json_object<'a>( + value: &'a Value, + key: &str, +) -> Result<&'a serde_json::Map, Box> { + value.get(key).and_then(Value::as_object).ok_or_else(|| format!("missing object {key}").into()) +} + +fn json_array<'a>(value: &'a Value, key: &str) -> Result<&'a Vec, Box> { + value.get(key).and_then(Value::as_array).ok_or_else(|| format!("missing array {key}").into()) +} + +fn json_str<'a>( + value: &'a serde_json::Map, + key: &str, +) -> Result<&'a str, Box> { + value.get(key).and_then(Value::as_str).ok_or_else(|| format!("missing string {key}").into()) +} + +fn json_object_u64( + value: &serde_json::Map, + key: &str, +) -> Result> { + value.get(key).and_then(Value::as_u64).ok_or_else(|| format!("missing number {key}").into()) +} + +fn json_u64(value: &Value, key: &str) -> Result> { + value.get(key).and_then(Value::as_u64).ok_or_else(|| format!("missing number {key}").into()) +}