From 1eb8e271f571317bee49f6e76d3e6e7cf053a5d8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Sat, 9 May 2026 02:07:37 -0400 Subject: [PATCH] Add bookmark import export packages --- crates/ely_app/src/shell/bookmark_files.rs | 279 ++++++++++++++++++ crates/ely_app/src/shell/command_actions.rs | 30 +- .../src/shell/internal_pages/bookmarks.rs | 106 +++++-- crates/ely_app/src/shell/mod.rs | 5 + crates/ely_browser_core/src/error.rs | 3 + crates/ely_browser_core/src/lib.rs | 7 +- crates/ely_browser_core/src/state.rs | 4 + .../ely_browser_core/src/state/bookmarks.rs | 190 +++++++++++- crates/ely_browser_core/src/state/commands.rs | 4 + crates/ely_browser_core/tests/bookmarks.rs | 117 +++++++- 10 files changed, 714 insertions(+), 31 deletions(-) create mode 100644 crates/ely_app/src/shell/bookmark_files.rs diff --git a/crates/ely_app/src/shell/bookmark_files.rs b/crates/ely_app/src/shell/bookmark_files.rs new file mode 100644 index 0000000..de7e605 --- /dev/null +++ b/crates/ely_app/src/shell/bookmark_files.rs @@ -0,0 +1,279 @@ +use std::{ + fs, + path::{Path, PathBuf}, +}; + +use directories::UserDirs; +use ely_browser_core::ELYBOOKMARKS_FILE_EXTENSION; +use gpui::{Context, PathPromptOptions, Window}; + +use super::{ElyShell, ShellState}; + +const BOOKMARKS_URL: &str = "ely://bookmarks"; + +impl ElyShell { + pub(super) fn export_bookmarks(&mut self, window: &mut Window, cx: &mut Context) { + self.ensure_bookmarks_surface(window, cx); + self.clear_bookmark_file_message(); + + let export = match &mut self.state { + ShellState::Ready(core) => { + let package_json = match core.export_bookmarks_package_json() { + Ok(package_json) => package_json, + Err(error) => { + self.set_bookmark_file_error(error.to_string(), cx); + return; + } + }; + match core.snapshot() { + Ok(snapshot) => Ok((snapshot.active_profile_name, package_json)), + Err(error) => Err(error.to_string()), + } + } + ShellState::StartupError(message) => Err(message.clone()), + }; + + let (profile_name, package_json) = match export { + Ok(export) => export, + Err(error) => { + self.set_bookmark_file_error(error, cx); + return; + } + }; + let directory = match default_export_directory() { + Ok(directory) => directory, + Err(error) => { + self.set_bookmark_file_error(error, cx); + return; + } + }; + let suggested_name = bookmarks_export_filename(&profile_name); + let prompt = cx.prompt_for_new_path(&directory, Some(&suggested_name)); + + cx.spawn_in(window, async move |shell, window| { + let selected_path = match prompt.await { + Ok(Ok(path)) => path, + Ok(Err(error)) => { + _ = shell.update_in(window, |shell, _, cx| { + shell.set_bookmark_file_error(error.to_string(), cx); + }); + return; + } + Err(error) => { + _ = shell.update_in(window, |shell, _, cx| { + shell.set_bookmark_file_error(error.to_string(), cx); + }); + return; + } + }; + + let Some(path) = selected_path else { + return; + }; + + let result = window + .background_executor() + .spawn(async move { write_bookmarks_package(path, package_json) }) + .await; + _ = shell.update_in(window, |shell, _, cx| { + shell.handle_bookmark_export_result(result, cx); + }); + }) + .detach(); + } + + pub(super) fn choose_bookmark_import(&mut self, window: &mut Window, cx: &mut Context) { + self.ensure_bookmarks_surface(window, cx); + self.clear_bookmark_file_message(); + + let prompt = cx.prompt_for_paths(PathPromptOptions { + files: true, + directories: false, + multiple: false, + prompt: Some("Select .elybookmarks file".into()), + }); + + cx.spawn_in(window, async move |shell, window| { + let selected_path = match prompt.await { + Ok(Ok(Some(paths))) => paths.into_iter().next(), + Ok(Ok(None)) => None, + Ok(Err(error)) => { + _ = shell.update_in(window, |shell, _, cx| { + shell.set_bookmark_file_error(error.to_string(), cx); + }); + return; + } + Err(error) => { + _ = shell.update_in(window, |shell, _, cx| { + shell.set_bookmark_file_error(error.to_string(), cx); + }); + return; + } + }; + + let Some(path) = selected_path else { + return; + }; + + let result = window + .background_executor() + .spawn(async move { read_bookmarks_package(path) }) + .await; + _ = shell.update_in(window, |shell, _, cx| { + shell.handle_bookmark_import_result(result, cx); + }); + }) + .detach(); + } + + fn ensure_bookmarks_surface(&mut self, window: &mut Window, cx: &mut Context) { + if self.active_tab_matches_url(BOOKMARKS_URL) { + return; + } + + self.open_internal_tab(BOOKMARKS_URL, window, cx); + } + + fn clear_bookmark_file_message(&mut self) { + self.bookmark_file_error = None; + self.bookmark_file_notice = None; + } + + fn set_bookmark_file_error(&mut self, message: String, cx: &mut Context) { + self.bookmark_file_error = Some(message); + self.bookmark_file_notice = None; + cx.notify(); + } + + fn set_bookmark_file_notice(&mut self, message: String, cx: &mut Context) { + self.bookmark_file_notice = Some(message); + self.bookmark_file_error = None; + cx.notify(); + } + + fn handle_bookmark_export_result( + &mut self, + result: Result, + cx: &mut Context, + ) { + match result { + Ok(path) => self.set_bookmark_file_notice(format!("Exported {}", path.display()), cx), + Err(error) => self.set_bookmark_file_error(error, cx), + } + } + + fn handle_bookmark_import_result( + &mut self, + result: Result, + cx: &mut Context, + ) { + let package_json = match result { + Ok(package_json) => package_json, + Err(error) => { + self.set_bookmark_file_error(error, cx); + return; + } + }; + + let import_result = match &mut self.state { + ShellState::Ready(core) => core + .import_bookmarks_package_json(&package_json) + .map(|summary| summary.label()) + .map_err(|error| error.to_string()), + ShellState::StartupError(message) => Err(message.clone()), + }; + + match import_result { + Ok(message) => self.set_bookmark_file_notice(message, cx), + Err(error) => self.set_bookmark_file_error(error, cx), + } + } +} + +fn default_export_directory() -> Result { + UserDirs::new() + .and_then(|dirs| dirs.document_dir().map(Path::to_path_buf)) + .ok_or_else(|| "Documents directory is unavailable.".to_string()) +} + +fn write_bookmarks_package(path: PathBuf, package_json: String) -> Result { + let path = normalize_export_path(path)?; + fs::write(&path, package_json) + .map_err(|error| format!("Unable to write {}: {error}", path.display()))?; + Ok(path) +} + +fn read_bookmarks_package(path: PathBuf) -> Result { + if !path_has_elybookmarks_extension(&path) { + return Err("Selected file must use .elybookmarks extension.".to_string()); + } + + fs::read_to_string(&path).map_err(|error| format!("Unable to read {}: {error}", path.display())) +} + +fn normalize_export_path(mut path: PathBuf) -> Result { + if path.extension().is_none() { + path.set_extension(ELYBOOKMARKS_FILE_EXTENSION); + return Ok(path); + } + + if path_has_elybookmarks_extension(&path) { + Ok(path) + } else { + Err("Export path must use .elybookmarks extension.".to_string()) + } +} + +fn path_has_elybookmarks_extension(path: &Path) -> bool { + path.extension().is_some_and(|extension| { + extension.to_string_lossy().eq_ignore_ascii_case(ELYBOOKMARKS_FILE_EXTENSION) + }) +} + +fn bookmarks_export_filename(profile_name: &str) -> String { + let mut stem = String::new(); + let mut previous_separator = false; + + for ch in profile_name.chars() { + if ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_') { + stem.push(ch); + previous_separator = false; + } else if !previous_separator { + stem.push('-'); + previous_separator = true; + } + } + + let stem = stem.trim_matches('-'); + let stem = if stem.is_empty() { "bookmarks" } else { stem }; + format!("{stem}.{ELYBOOKMARKS_FILE_EXTENSION}") +} + +#[cfg(test)] +mod tests { + use std::path::PathBuf; + + use super::{bookmarks_export_filename, normalize_export_path}; + + #[test] + fn bookmarks_export_filename_sanitizes_profile_names() { + assert_eq!(bookmarks_export_filename("Default"), "Default.elybookmarks"); + assert_eq!(bookmarks_export_filename("Work / Research"), "Work-Research.elybookmarks"); + assert_eq!(bookmarks_export_filename(" "), "bookmarks.elybookmarks"); + } + + #[test] + fn normalize_export_path_adds_elybookmarks_extension() -> Result<(), String> { + let path = normalize_export_path(PathBuf::from("Default"))?; + + assert_eq!(path, PathBuf::from("Default.elybookmarks")); + Ok(()) + } + + #[test] + fn normalize_export_path_rejects_other_extensions() { + let error = normalize_export_path(PathBuf::from("Default.json")); + + assert_eq!(error, Err("Export path must use .elybookmarks extension.".to_string())); + } +} diff --git a/crates/ely_app/src/shell/command_actions.rs b/crates/ely_app/src/shell/command_actions.rs index 9f9a0ce..41f4ee0 100644 --- a/crates/ely_app/src/shell/command_actions.rs +++ b/crates/ely_app/src/shell/command_actions.rs @@ -17,6 +17,12 @@ enum ShortcutFileCommand { Import, } +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum BookmarkFileCommand { + Export, + Import, +} + impl ElyShell { pub(super) fn handle_shell_command_intent( &mut self, @@ -48,6 +54,12 @@ impl ElyShell { Some(ShortcutFileCommand::Import) => self.choose_shortcut_import(window, cx), None => {} } + + match bookmark_file_command(command) { + Some(BookmarkFileCommand::Export) => self.export_bookmarks(window, cx), + Some(BookmarkFileCommand::Import) => self.choose_bookmark_import(window, cx), + None => {} + } } } @@ -90,11 +102,19 @@ fn shortcut_file_command(command: &str) -> Option { } } +fn bookmark_file_command(command: &str) -> Option { + match command.trim().to_ascii_lowercase().as_str() { + "export-bookmarks" | "export bookmarks" => Some(BookmarkFileCommand::Export), + "import-bookmarks" | "import bookmarks" => Some(BookmarkFileCommand::Import), + _ => None, + } +} + #[cfg(test)] mod tests { use super::{ - ShortcutFileCommand, SpaceFileCommand, install_plugin_from_file_command, - shortcut_file_command, space_file_command, + BookmarkFileCommand, ShortcutFileCommand, SpaceFileCommand, bookmark_file_command, + install_plugin_from_file_command, shortcut_file_command, space_file_command, }; #[test] @@ -134,4 +154,10 @@ mod tests { assert_eq!(shortcut_file_command("export-shortcuts"), Some(ShortcutFileCommand::Export)); assert_eq!(shortcut_file_command("import keybindings"), Some(ShortcutFileCommand::Import)); } + + #[test] + fn bookmark_file_command_matches_export_and_import_aliases() { + assert_eq!(bookmark_file_command("export-bookmarks"), Some(BookmarkFileCommand::Export)); + assert_eq!(bookmark_file_command("import bookmarks"), Some(BookmarkFileCommand::Import)); + } } diff --git a/crates/ely_app/src/shell/internal_pages/bookmarks.rs b/crates/ely_app/src/shell/internal_pages/bookmarks.rs index 5be0572..ce928cc 100644 --- a/crates/ely_app/src/shell/internal_pages/bookmarks.rs +++ b/crates/ely_app/src/shell/internal_pages/bookmarks.rs @@ -27,7 +27,13 @@ impl ElyShell { .flex() .flex_col() .gap_5() - .child(render_bookmarks_header(snapshot)) + .child(self.render_bookmarks_header(snapshot, cx)) + .when_some(self.bookmark_file_error.clone(), |this, message| { + this.child(render_bookmark_file_message(message, colors::ERROR)) + }) + .when_some(self.bookmark_file_notice.clone(), |this, message| { + this.child(render_bookmark_file_message(message, colors::SUCCESS)) + }) .child(self.render_bookmark_list(snapshot, cx)), ) } @@ -251,30 +257,84 @@ fn render_bookmark_edit_field(label: &'static str, input: &Entity) - .into_any_element() } -fn render_bookmarks_header(snapshot: &BrowserSnapshot) -> AnyElement { +impl ElyShell { + fn render_bookmarks_header( + &mut self, + snapshot: &BrowserSnapshot, + cx: &mut Context, + ) -> AnyElement { + div() + .flex() + .items_end() + .justify_between() + .gap_4() + .child( + div() + .flex() + .flex_col() + .gap_2() + .child( + div().text_size(px(26.0)).text_color(rgb(colors::INK)).child("Bookmarks"), + ) + .child( + div() + .text_sm() + .text_color(rgb(colors::MUTED)) + .child(format!("Profile: {}", snapshot.active_profile_name)), + ), + ) + .child( + div() + .flex() + .items_center() + .gap_2() + .child( + div() + .text_xs() + .text_color(rgb(colors::MUTED)) + .child(bookmark_count_label(snapshot.bookmarks.len())), + ) + .child( + Button::new("export-bookmarks") + .ghost() + .xsmall() + .icon(IconName::File) + .label("Export") + .tooltip("Export Bookmarks") + .on_click(cx.listener(|shell, _, window, cx| { + shell.export_bookmarks(window, cx); + })), + ) + .child( + Button::new("import-bookmarks") + .ghost() + .xsmall() + .icon(IconName::FolderOpen) + .label("Import") + .tooltip("Import Bookmarks") + .on_click(cx.listener(|shell, _, window, cx| { + shell.choose_bookmark_import(window, cx); + })), + ), + ) + .into_any_element() + } +} + +fn render_bookmark_file_message(message: String, color: u32) -> AnyElement { div() + .rounded_md() + .border_1() + .border_color(rgb(color)) + .px_3() + .py_2() .flex() - .items_end() - .justify_between() - .child( - div() - .flex() - .flex_col() - .gap_2() - .child(div().text_size(px(26.0)).text_color(rgb(colors::INK)).child("Bookmarks")) - .child( - div() - .text_sm() - .text_color(rgb(colors::MUTED)) - .child(format!("Profile: {}", snapshot.active_profile_name)), - ), - ) - .child( - div() - .text_xs() - .text_color(rgb(colors::MUTED)) - .child(bookmark_count_label(snapshot.bookmarks.len())), - ) + .items_center() + .gap_2() + .text_xs() + .text_color(rgb(color)) + .child(IconName::Info) + .child(message) .into_any_element() } diff --git a/crates/ely_app/src/shell/mod.rs b/crates/ely_app/src/shell/mod.rs index 1d0e5cc..e43ddd8 100644 --- a/crates/ely_app/src/shell/mod.rs +++ b/crates/ely_app/src/shell/mod.rs @@ -1,4 +1,5 @@ mod archive_labels; +mod bookmark_files; mod bookmarks; mod command_actions; mod downloads; @@ -71,6 +72,8 @@ pub struct ElyShell { shortcut_profile: ShortcutProfile, pending_bookmark_edit: Option, bookmark_edit_error: Option, + bookmark_file_error: Option, + bookmark_file_notice: Option, plugin_install_error: Option, pending_plugin_install: Option, pending_plugin_uninstall: Option, @@ -156,6 +159,8 @@ impl ElyShell { shortcut_profile: ShortcutProfile::default_profile(), pending_bookmark_edit: None, bookmark_edit_error: None, + bookmark_file_error: None, + bookmark_file_notice: None, plugin_install_error: None, pending_plugin_install: None, pending_plugin_uninstall: None, diff --git a/crates/ely_browser_core/src/error.rs b/crates/ely_browser_core/src/error.rs index 4aeae0b..7057405 100644 --- a/crates/ely_browser_core/src/error.rs +++ b/crates/ely_browser_core/src/error.rs @@ -21,6 +21,9 @@ pub enum CoreError { #[error("invalid .elyspace package: {reason}")] InvalidSpacePackage { reason: String }, + #[error("invalid .elybookmarks package: {reason}")] + InvalidBookmarkPackage { 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 c44e407..eff64c8 100644 --- a/crates/ely_browser_core/src/lib.rs +++ b/crates/ely_browser_core/src/lib.rs @@ -4,7 +4,8 @@ mod state; pub use error::CoreError; pub use state::{ - BrowserCore, BrowserSnapshot, ELYSPACE_FILE_EXTENSION, ELYSPACE_SCHEMA_VERSION, - ElySpacePackage, InitialBrowserConfig, InstalledPlugin, PluginAuditAction, PluginAuditEvent, - SiteDataClearance, SpaceImportProfileMapping, TrashedSpace, + BookmarkImportSummary, BrowserCore, BrowserSnapshot, ELYBOOKMARKS_FILE_EXTENSION, + ELYBOOKMARKS_SCHEMA_VERSION, ELYSPACE_FILE_EXTENSION, ELYSPACE_SCHEMA_VERSION, + ElyBookmarksPackage, 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 9011a23..cf1e7d1 100644 --- a/crates/ely_browser_core/src/state.rs +++ b/crates/ely_browser_core/src/state.rs @@ -32,6 +32,10 @@ mod tab_order; mod tab_selection; mod tabs; +pub use bookmarks::{ + BookmarkImportSummary, ELYBOOKMARKS_FILE_EXTENSION, ELYBOOKMARKS_SCHEMA_VERSION, + ElyBookmarksPackage, +}; pub use plugins::{InstalledPlugin, PluginAuditAction, PluginAuditEvent}; pub use site_data::SiteDataClearance; pub use space_exports::{ diff --git a/crates/ely_browser_core/src/state/bookmarks.rs b/crates/ely_browser_core/src/state/bookmarks.rs index c3e06e1..583b360 100644 --- a/crates/ely_browser_core/src/state/bookmarks.rs +++ b/crates/ely_browser_core/src/state/bookmarks.rs @@ -1,11 +1,69 @@ -use std::time::SystemTime; +use std::{collections::BTreeSet, time::SystemTime}; -use ely_domain::{BookmarkEntry, BookmarkId, UrlText}; +use ely_domain::{BookmarkEntry, BookmarkId, SpaceId, UrlText}; +use serde::{Deserialize, Serialize}; use crate::CoreError; use super::BrowserCore; +pub const ELYBOOKMARKS_SCHEMA_VERSION: u16 = 1; +pub const ELYBOOKMARKS_FILE_EXTENSION: &str = "elybookmarks"; + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +pub struct ElyBookmarksPackage { + version: u16, + bookmarks: Vec, +} + +impl ElyBookmarksPackage { + #[must_use] + pub fn version(&self) -> u16 { + self.version + } + + #[must_use] + pub fn bookmark_count(&self) -> usize { + self.bookmarks.len() + } +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +struct ElyBookmarkRecord { + title: String, + url: String, + collection_name: String, + space_name: String, + tags: Vec, + note: Option, + thumbnail_key: Option, +} + +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub struct BookmarkImportSummary { + imported: usize, + skipped: usize, +} + +impl BookmarkImportSummary { + #[must_use] + pub fn imported(self) -> usize { + self.imported + } + + #[must_use] + pub fn skipped(self) -> usize { + self.skipped + } + + #[must_use] + pub fn label(self) -> String { + format!("Imported {} bookmarks, skipped {}", self.imported, self.skipped) + } +} + impl BrowserCore { pub fn bookmark_active_tab(&mut self) -> Result { let active_tab = self.active_tab()?.clone(); @@ -102,6 +160,66 @@ impl BrowserCore { Ok(()) } + pub fn export_bookmarks_package_json(&self) -> Result { + serde_json::to_string_pretty(&self.export_bookmarks_package()?) + .map_err(invalid_bookmark_package) + } + + pub fn export_bookmarks_package(&self) -> Result { + let bookmarks = self + .bookmarks + .iter() + .filter(|bookmark| bookmark.profile_id() == &self.active_profile_id) + .map(|bookmark| { + self.bookmark_space_name(bookmark.space_id()) + .map(|space_name| ElyBookmarkRecord::from_bookmark(bookmark, space_name)) + }) + .collect::, _>>()?; + + Ok(ElyBookmarksPackage { version: ELYBOOKMARKS_SCHEMA_VERSION, bookmarks }) + } + + pub fn import_bookmarks_package_json( + &mut self, + package_json: &str, + ) -> Result { + let package = serde_json::from_str(package_json).map_err(invalid_bookmark_package)?; + self.import_bookmarks_package(package) + } + + pub fn import_bookmarks_package( + &mut self, + package: ElyBookmarksPackage, + ) -> Result { + validate_bookmark_package_version(package.version)?; + + let active_profile_id = self.active_profile_id.clone(); + let mut seen = self + .bookmarks + .iter() + .filter(|bookmark| bookmark.profile_id() == &active_profile_id) + .map(|bookmark| bookmark_identity(bookmark.space_id(), bookmark.url())) + .collect::>(); + let mut imported_bookmarks = Vec::new(); + let mut skipped = 0; + + for record in package.bookmarks { + let space_id = self.import_bookmark_space_id(&record.space_name); + let url = UrlText::parse(&record.url)?; + let identity = bookmark_identity(&space_id, &url); + if !seen.insert(identity) { + skipped += 1; + continue; + } + + imported_bookmarks.push(record.into_bookmark(active_profile_id.clone(), space_id)?); + } + + let imported = imported_bookmarks.len(); + self.bookmarks.extend(imported_bookmarks); + Ok(BookmarkImportSummary { imported, skipped }) + } + pub(super) fn find_bookmark_match(&self, query: &str) -> Option { let normalized_query = query.trim().to_lowercase(); if normalized_query.is_empty() { @@ -130,6 +248,21 @@ impl BrowserCore { .find(|bookmark| bookmark.id() == bookmark_id) .ok_or_else(|| CoreError::BookmarkNotFound { id: bookmark_id.clone() }) } + + fn bookmark_space_name(&self, space_id: &SpaceId) -> Result { + self.spaces + .iter() + .find(|space| space.id() == space_id) + .map(|space| space.name().to_string()) + .ok_or_else(|| CoreError::SpaceNotFound { id: space_id.clone() }) + } + + fn import_bookmark_space_id(&self, space_name: &str) -> SpaceId { + self.spaces + .iter() + .find(|space| space.name().eq_ignore_ascii_case(space_name.trim())) + .map_or_else(|| self.active_space_id.clone(), |space| space.id().clone()) + } } fn bookmark_matches_query(bookmark: &BookmarkEntry, normalized_query: &str) -> bool { @@ -140,3 +273,56 @@ fn bookmark_matches_query(bookmark: &BookmarkEntry, normalized_query: &str) -> b || bookmark.tags().iter().any(|tag| tag.to_lowercase().contains(normalized_query)) || bookmark.note().is_some_and(|note| note.to_lowercase().contains(normalized_query)) } + +impl ElyBookmarkRecord { + fn from_bookmark(bookmark: &BookmarkEntry, space_name: String) -> Self { + Self { + title: bookmark.title().to_string(), + url: bookmark.url().as_str().to_string(), + collection_name: bookmark.collection_name().to_string(), + space_name, + tags: bookmark.tags().to_vec(), + note: bookmark.note().map(str::to_string), + thumbnail_key: bookmark.thumbnail_key().map(str::to_string), + } + } + + fn into_bookmark( + self, + profile_id: ely_domain::ProfileId, + space_id: SpaceId, + ) -> Result { + let mut bookmark = BookmarkEntry::new( + profile_id, + space_id, + self.collection_name, + self.title, + UrlText::parse(self.url)?, + SystemTime::now(), + )?; + bookmark.set_tags(self.tags)?; + if let Some(note) = self.note { + bookmark.set_note(note)?; + } + if let Some(thumbnail_key) = self.thumbnail_key { + bookmark.set_thumbnail_key(thumbnail_key)?; + } + Ok(bookmark) + } +} + +fn bookmark_identity(space_id: &SpaceId, url: &UrlText) -> (SpaceId, String) { + (space_id.clone(), url.as_str().to_string()) +} + +fn validate_bookmark_package_version(version: u16) -> Result<(), CoreError> { + if version == ELYBOOKMARKS_SCHEMA_VERSION { + Ok(()) + } else { + Err(CoreError::InvalidBookmarkPackage { reason: format!("unsupported version {version}") }) + } +} + +fn invalid_bookmark_package(error: impl ToString) -> CoreError { + CoreError::InvalidBookmarkPackage { reason: error.to_string() } +} diff --git a/crates/ely_browser_core/src/state/commands.rs b/crates/ely_browser_core/src/state/commands.rs index 9a021c6..5ce2627 100644 --- a/crates/ely_browser_core/src/state/commands.rs +++ b/crates/ely_browser_core/src/state/commands.rs @@ -247,6 +247,10 @@ impl BrowserCore { self.open_tab(bookmarks_url()?); Ok(true) } + "export-bookmarks" | "export bookmarks" | "import-bookmarks" | "import bookmarks" => { + self.open_tab(bookmarks_url()?); + Ok(true) + } "reading-list" | "open-reading-list" | "open reading list" => { self.open_tab(reading_list_url()?); Ok(true) diff --git a/crates/ely_browser_core/tests/bookmarks.rs b/crates/ely_browser_core/tests/bookmarks.rs index 6f55a6b..2bc3db4 100644 --- a/crates/ely_browser_core/tests/bookmarks.rs +++ b/crates/ely_browser_core/tests/bookmarks.rs @@ -1,7 +1,8 @@ use std::error::Error; -use ely_browser_core::{BrowserCore, CoreError, InitialBrowserConfig}; +use ely_browser_core::{BrowserCore, CoreError, ELYBOOKMARKS_SCHEMA_VERSION, InitialBrowserConfig}; use ely_domain::{BookmarkId, CommandIntent, CommandScope, DomainError, ProfileKind, UrlText}; +use serde_json::Value; #[test] fn bookmark_active_tab_records_current_context() -> Result<(), Box> { @@ -240,3 +241,117 @@ fn open_bookmarks_command_opens_bookmarks_page() -> Result<(), Box> { assert_eq!(core.snapshot()?.command_query, ""); Ok(()) } + +#[test] +fn export_bookmarks_package_json_contains_active_profile_bookmarks() -> Result<(), Box> { + let mut core = BrowserCore::new(InitialBrowserConfig::ely_defaults()?)?; + let default_profile_id = core.snapshot()?.active_profile_id; + core.open_tab(UrlText::parse("https://example.com/research")?); + let bookmark_id = core.bookmark_active_tab()?; + core.set_bookmark_collection_name(&bookmark_id, "Research")?; + core.set_bookmark_tags(&bookmark_id, vec!["rust".to_string(), "gpui".to_string()])?; + core.set_bookmark_note(&bookmark_id, "Servo reference")?; + core.set_bookmark_thumbnail_key(&bookmark_id, "screenshots/example.avif")?; + + core.create_profile("Personal", 0xf54e00, ProfileKind::Standard)?; + core.open_tab(UrlText::parse("https://example.com/personal")?); + core.bookmark_active_tab()?; + core.select_profile(&default_profile_id)?; + + let package = core.export_bookmarks_package()?; + let package_json = core.export_bookmarks_package_json()?; + let value: Value = serde_json::from_str(&package_json)?; + let bookmarks = value["bookmarks"].as_array().ok_or("missing bookmarks array")?; + + assert_eq!(package.version(), ELYBOOKMARKS_SCHEMA_VERSION); + assert_eq!(package.bookmark_count(), 1); + assert_eq!(bookmarks.len(), 1); + assert_eq!(bookmarks[0]["title"], "example.com"); + assert_eq!(bookmarks[0]["url"], "https://example.com/research"); + assert_eq!(bookmarks[0]["collection_name"], "Research"); + assert_eq!(bookmarks[0]["space_name"], "Work"); + assert_eq!(bookmarks[0]["tags"], serde_json::json!(["rust", "gpui"])); + assert_eq!(bookmarks[0]["note"], "Servo reference"); + assert_eq!(bookmarks[0]["thumbnail_key"], "screenshots/example.avif"); + Ok(()) +} + +#[test] +fn import_bookmarks_package_json_creates_active_profile_bookmarks() -> Result<(), Box> { + let mut source = BrowserCore::new(InitialBrowserConfig::ely_defaults()?)?; + source.open_tab(UrlText::parse("https://example.com/research")?); + let bookmark_id = source.bookmark_active_tab()?; + source.set_bookmark_collection_name(&bookmark_id, "Research")?; + source.set_bookmark_tags(&bookmark_id, vec!["rust".to_string()])?; + source.set_bookmark_note(&bookmark_id, "Read later")?; + let package_json = source.export_bookmarks_package_json()?; + + let mut target = BrowserCore::new(InitialBrowserConfig::ely_defaults()?)?; + let active_profile_id = target.snapshot()?.active_profile_id; + let active_space_id = target.snapshot()?.active_space_id; + let summary = target.import_bookmarks_package_json(&package_json)?; + let snapshot = target.snapshot()?; + + assert_eq!(summary.imported(), 1); + assert_eq!(summary.skipped(), 0); + assert_eq!(summary.label(), "Imported 1 bookmarks, skipped 0"); + assert_eq!(snapshot.bookmarks.len(), 1); + assert_eq!(snapshot.bookmarks[0].profile_id(), &active_profile_id); + assert_eq!(snapshot.bookmarks[0].space_id(), &active_space_id); + assert_eq!(snapshot.bookmarks[0].collection_name(), "Research"); + assert_eq!(snapshot.bookmarks[0].tags(), &["rust".to_string()]); + assert_eq!(snapshot.bookmarks[0].note(), Some("Read later")); + Ok(()) +} + +#[test] +fn import_bookmarks_package_skips_duplicate_active_profile_urls() -> Result<(), Box> { + let mut source = BrowserCore::new(InitialBrowserConfig::ely_defaults()?)?; + source.open_tab(UrlText::parse("https://example.com/research")?); + source.bookmark_active_tab()?; + let package_json = source.export_bookmarks_package_json()?; + + let mut target = BrowserCore::new(InitialBrowserConfig::ely_defaults()?)?; + target.open_tab(UrlText::parse("https://example.com/research")?); + target.bookmark_active_tab()?; + let summary = target.import_bookmarks_package_json(&package_json)?; + + assert_eq!(summary.imported(), 0); + assert_eq!(summary.skipped(), 1); + assert_eq!(target.snapshot()?.bookmarks.len(), 1); + Ok(()) +} + +#[test] +fn import_bookmarks_package_rejects_unknown_fields() -> Result<(), Box> { + let mut core = BrowserCore::new(InitialBrowserConfig::ely_defaults()?)?; + let package_json = r#"{ + "version": 1, + "unexpected": true, + "bookmarks": [] + }"#; + + let Err(error) = core.import_bookmarks_package_json(package_json) else { + return Err("expected invalid bookmark package".into()); + }; + + assert!(matches!(error, CoreError::InvalidBookmarkPackage { .. })); + Ok(()) +} + +#[test] +fn bookmark_file_commands_open_bookmarks_page() -> Result<(), Box> { + let mut core = BrowserCore::new(InitialBrowserConfig::ely_defaults()?)?; + + core.set_command_query(">export-bookmarks"); + let export_intent = core.submit_command()?; + assert_eq!(export_intent, Some(CommandIntent::Command("export-bookmarks".to_string()))); + assert_eq!(core.active_tab()?.url().as_str(), "ely://bookmarks"); + + core.set_command_query(">import-bookmarks"); + let import_intent = core.submit_command()?; + assert_eq!(import_intent, Some(CommandIntent::Command("import-bookmarks".to_string()))); + assert_eq!(core.active_tab()?.title(), "Bookmarks"); + assert_eq!(core.snapshot()?.command_query, ""); + Ok(()) +}