diff --git a/crates/ely_app/src/shell/internal_pages.rs b/crates/ely_app/src/shell/internal_pages.rs index 730b0fe..f23a870 100644 --- a/crates/ely_app/src/shell/internal_pages.rs +++ b/crates/ely_app/src/shell/internal_pages.rs @@ -6,6 +6,7 @@ mod download_settings; mod downloads; mod general; mod history; +mod notes; mod plugin_catalog; mod plugin_details; mod plugins; @@ -42,6 +43,7 @@ impl ElyShell { ) -> AnyElement { match tab.url().as_str() { "ely://bookmarks" => self.render_bookmarks_page(snapshot, cx), + "ely://notes" => self.render_notes_page(snapshot, cx), "ely://reading-list" => self.render_reading_list_page(snapshot, cx), "ely://downloads" => self.render_downloads_page(snapshot, cx), "ely://history" => self.render_history_page(snapshot, cx), diff --git a/crates/ely_app/src/shell/internal_pages/notes.rs b/crates/ely_app/src/shell/internal_pages/notes.rs new file mode 100644 index 0000000..083783b --- /dev/null +++ b/crates/ely_app/src/shell/internal_pages/notes.rs @@ -0,0 +1,293 @@ +use std::time::{Duration, SystemTime}; + +use ely_browser_core::BrowserSnapshot; +use ely_design_system::colors; +use ely_domain::NoteEntry; +use gpui::prelude::FluentBuilder; +use gpui::{ + AnyElement, Context, InteractiveElement, IntoElement, ParentElement, SharedString, + StatefulInteractiveElement, Styled, div, px, rgb, +}; +use gpui_component::{ + IconName, Sizable, StyledExt, + button::{Button, ButtonVariants}, + scroll::ScrollableElement, +}; + +use super::{ElyShell, render_canvas_surface}; + +impl ElyShell { + pub(super) fn render_notes_page( + &mut self, + snapshot: &BrowserSnapshot, + cx: &mut Context, + ) -> AnyElement { + render_canvas_surface( + div() + .size_full() + .p_8() + .flex() + .flex_col() + .gap_5() + .child(render_notes_header(snapshot)) + .child(self.render_note_entries(snapshot, cx)), + ) + } + + fn render_note_entries( + &mut self, + snapshot: &BrowserSnapshot, + cx: &mut Context, + ) -> AnyElement { + if snapshot.notes.is_empty() { + return div() + .flex_1() + .border_t_1() + .border_color(rgb(colors::HAIRLINE)) + .pt_5() + .text_sm() + .text_color(rgb(colors::MUTED)) + .child("Notes are empty for this Profile.") + .into_any_element(); + } + + div() + .flex_1() + .min_h_0() + .flex() + .flex_col() + .overflow_y_scrollbar() + .border_t_1() + .border_color(rgb(colors::HAIRLINE)) + .children( + snapshot + .notes + .iter() + .rev() + .enumerate() + .map(|(index, note)| self.render_note_row(index, snapshot, note, cx)), + ) + .into_any_element() + } + + fn render_note_row( + &mut self, + index: usize, + snapshot: &BrowserSnapshot, + note: &NoteEntry, + cx: &mut Context, + ) -> AnyElement { + let url = note.source_url().clone(); + let open_url = note.source_url().clone(); + let space_name = note_space_name(snapshot, note); + + div() + .id(SharedString::from(format!("note-{}", note.id().as_str()))) + .py_3() + .border_b_1() + .border_color(rgb(colors::HAIRLINE)) + .flex() + .items_center() + .justify_between() + .gap_4() + .child( + div() + .id(SharedString::from(format!("note-open-{}", note.id().as_str()))) + .min_w_0() + .flex_1() + .flex() + .items_center() + .gap_3() + .cursor_pointer() + .hover(|style| style.bg(rgb(colors::CANVAS_SOFT))) + .active(|style| style.opacity(0.82)) + .on_click(cx.listener(move |shell, _, window, cx| { + shell.open_url(url.clone(), window, cx); + })) + .child(div().text_color(rgb(colors::MUTED_SOFT)).child(IconName::File)) + .child( + div() + .min_w_0() + .flex() + .flex_col() + .gap_1() + .child( + div() + .text_sm() + .font_semibold() + .truncate() + .text_color(rgb(colors::INK)) + .child(note.title().to_string()), + ) + .child( + div() + .text_xs() + .truncate() + .text_color(rgb(colors::MUTED)) + .child(note.display_url()), + ) + .child( + div() + .text_xs() + .truncate() + .text_color(rgb(colors::MUTED_SOFT)) + .child(markdown_preview(note.body())), + ), + ), + ) + .child( + div() + .max_w(px(260.0)) + .flex() + .items_center() + .justify_end() + .gap_2() + .text_xs() + .font_semibold() + .text_color(rgb(colors::MUTED)) + .when_some(space_name, |this, space_name| { + this.child(div().max_w(px(110.0)).truncate().child(space_name)) + }) + .child(note.target_label()) + .child(updated_at_label(note.updated_at())), + ) + .child( + Button::new(("open-note-source", index)) + .ghost() + .xsmall() + .label("Open") + .tooltip("Open Note Source") + .on_click(cx.listener(move |shell, _, window, cx| { + shell.open_url(open_url.clone(), window, cx); + })), + ) + .into_any_element() + } +} + +fn render_notes_header(snapshot: &BrowserSnapshot) -> AnyElement { + div() + .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("Notes")) + .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(notes_count_label(snapshot.notes.len())), + ) + .into_any_element() +} + +fn note_space_name(snapshot: &BrowserSnapshot, note: &NoteEntry) -> Option { + snapshot + .spaces + .iter() + .find(|space| space.id() == note.space_id()) + .map(|space| space.name().to_string()) +} + +fn notes_count_label(count: usize) -> String { + match count { + 1 => "1 note".to_string(), + count => format!("{count} notes"), + } +} + +fn markdown_preview(body: &str) -> String { + let preview = body + .lines() + .find_map(|line| { + let trimmed = line.trim(); + (!trimmed.is_empty()).then_some(trimmed) + }) + .unwrap_or(body.trim()); + + truncate_chars(preview, 120) +} + +fn truncate_chars(value: &str, limit: usize) -> String { + let mut chars = value.chars(); + let truncated: String = chars.by_ref().take(limit).collect(); + if chars.next().is_some() { format!("{truncated}...") } else { truncated } +} + +fn updated_at_label(updated_at: SystemTime) -> String { + updated_at_label_for(updated_at, SystemTime::now()) +} + +fn updated_at_label_for(updated_at: SystemTime, now: SystemTime) -> String { + let age = now.duration_since(updated_at).unwrap_or(Duration::ZERO); + if age < Duration::from_secs(60) { + return "Updated just now".to_string(); + } + if age < Duration::from_secs(3_600) { + return format!("Updated {} mins ago", age.as_secs() / 60); + } + if age < Duration::from_secs(86_400) { + return format!("Updated {} hrs ago", age.as_secs() / 3_600); + } + if age < Duration::from_secs(604_800) { + return format!("Updated {} days ago", age.as_secs() / 86_400); + } + "Updated earlier".to_string() +} + +#[cfg(test)] +mod tests { + use std::time::{Duration, SystemTime}; + + use super::{markdown_preview, updated_at_label_for}; + + #[test] + fn markdown_preview_uses_first_non_empty_line() { + assert_eq!(markdown_preview("\n # Heading\n- detail"), "# Heading"); + } + + #[test] + fn markdown_preview_truncates_long_lines() { + let preview = markdown_preview(&"a".repeat(130)); + + assert_eq!(preview.len(), 123); + assert!(preview.ends_with("...")); + } + + #[test] + fn updated_at_label_formats_recent_entries() { + let now = SystemTime::UNIX_EPOCH + Duration::from_secs(10_000); + + assert_eq!(updated_at_label_for(now - Duration::from_secs(20), now), "Updated just now"); + } + + #[test] + fn updated_at_label_formats_minutes_hours_days_and_older_entries() { + let now = SystemTime::UNIX_EPOCH + Duration::from_secs(1_000_000); + + assert_eq!(updated_at_label_for(now - Duration::from_secs(120), now), "Updated 2 mins ago"); + assert_eq!( + updated_at_label_for(now - Duration::from_secs(10_800), now), + "Updated 3 hrs ago" + ); + assert_eq!( + updated_at_label_for(now - Duration::from_secs(345_600), now), + "Updated 4 days ago" + ); + assert_eq!( + updated_at_label_for(now - Duration::from_secs(691_200), now), + "Updated earlier" + ); + } +} diff --git a/crates/ely_app/src/shell/internal_pages/sync.rs b/crates/ely_app/src/shell/internal_pages/sync.rs index c407d6f..d6aa855 100644 --- a/crates/ely_app/src/shell/internal_pages/sync.rs +++ b/crates/ely_app/src/shell/internal_pages/sync.rs @@ -216,6 +216,7 @@ fn sync_object_kind_label(kind: SyncObjectKind) -> &'static str { SyncObjectKind::Spaces => "Spaces", SyncObjectKind::Tabs => "Tabs", SyncObjectKind::Bookmarks => "Bookmarks", + SyncObjectKind::Notes => "Notes", SyncObjectKind::ReadingList => "Reading List", SyncObjectKind::Profiles => "Profiles", SyncObjectKind::SitePermissions => "Site permissions", diff --git a/crates/ely_browser_core/src/navigation.rs b/crates/ely_browser_core/src/navigation.rs index 82d066a..fca5d7c 100644 --- a/crates/ely_browser_core/src/navigation.rs +++ b/crates/ely_browser_core/src/navigation.rs @@ -17,6 +17,7 @@ fn internal_page_title(url: &str) -> Option<&'static str> { match url { "ely://new-tab" => Some("New Tab"), "ely://bookmarks" => Some("Bookmarks"), + "ely://notes" => Some("Notes"), "ely://reading-list" => Some("Reading List"), "ely://downloads" => Some("Downloads"), "ely://history" => Some("History"), @@ -91,6 +92,14 @@ pub(crate) fn reading_progress_percent( ReadingProgressPercent::new(percent).map(Some).map_err(CoreError::from) } +pub(crate) fn note_body(command: &str) -> Option<&str> { + command_argument(command, &["note ", "add-note ", "add note "]) +} + +pub(crate) fn tab_note_body(command: &str) -> Option<&str> { + command_argument(command, &["tab-note ", "tab note ", "note-tab ", "note tab "]) +} + pub(crate) fn new_profile_name(command: &str) -> Option<&str> { command_argument(command, &["new-profile ", "new profile "]) } @@ -134,6 +143,10 @@ pub(crate) fn reading_list_url() -> Result { internal_page_url("ely://reading-list") } +pub(crate) fn notes_url() -> Result { + internal_page_url("ely://notes") +} + pub(crate) fn history_url() -> Result { internal_page_url("ely://history") } diff --git a/crates/ely_browser_core/src/state.rs b/crates/ely_browser_core/src/state.rs index 586857a..81e051b 100644 --- a/crates/ely_browser_core/src/state.rs +++ b/crates/ely_browser_core/src/state.rs @@ -3,8 +3,9 @@ use std::{collections::BTreeMap, time::SystemTime}; use ely_domain::{ ArchivePolicy, ArchivedTab, BookmarkEntry, BrowserTab, DomainError, DownloadEntry, DownloadPolicy, FavoriteLimit, HistoryEntry, HistoryRecordingPolicy, NewTabDestination, - Profile, ProfileId, ProfileKind, ReadingListEntry, SearchEngine, SitePermissionAuditEvent, - SitePermissionEntry, Space, SpaceId, SplitLayout, SyncStatus, TabId, UrlText, + NoteEntry, Profile, ProfileId, ProfileKind, ReadingListEntry, SearchEngine, + SitePermissionAuditEvent, SitePermissionEntry, Space, SpaceId, SplitLayout, SyncStatus, TabId, + UrlText, }; use crate::{CoreError, navigation::tab_title}; @@ -14,6 +15,7 @@ mod bookmarks; mod commands; mod downloads; mod history; +mod notes; mod plugins; mod profiles; mod reading_list; @@ -51,6 +53,7 @@ pub struct BrowserSnapshot { pub pinned_tabs: Vec, pub archived_tabs: Vec, pub bookmarks: Vec, + pub notes: Vec, pub reading_list: Vec, pub site_permissions: Vec, pub site_permission_audit_events: Vec, @@ -83,6 +86,7 @@ pub struct BrowserCore { tabs: Vec, archived_tabs: Vec, bookmarks: Vec, + notes: Vec, reading_list: Vec, site_permissions: Vec, site_permission_audit_events: Vec, @@ -151,6 +155,7 @@ impl BrowserCore { tabs: vec![tab], archived_tabs: Vec::new(), bookmarks: Vec::new(), + notes: Vec::new(), reading_list: Vec::new(), site_permissions: Vec::new(), site_permission_audit_events: Vec::new(), @@ -342,6 +347,7 @@ impl BrowserCore { pinned_tabs: self.pinned_tabs(), archived_tabs: self.archived_tabs.clone(), bookmarks: self.visible_bookmarks(), + notes: self.visible_notes(), reading_list: self.visible_reading_list(), site_permissions: self.visible_site_permissions(), site_permission_audit_events: self.visible_site_permission_audit_events(), diff --git a/crates/ely_browser_core/src/state/commands.rs b/crates/ely_browser_core/src/state/commands.rs index 45aafb2..982accf 100644 --- a/crates/ely_browser_core/src/state/commands.rs +++ b/crates/ely_browser_core/src/state/commands.rs @@ -6,10 +6,10 @@ use crate::{ CoreError, navigation::{ about_url, archive_idle_days, archive_url, bookmarks_url, downloads_url, history_url, - move_tab_space_name, new_private_profile_name, new_profile_name, new_space_name, - plugin_detail_url, plugins_url, reading_list_url, reading_progress_percent, search_url, - settings_page_url, settings_url, shortcut_settings_url, space_icon, switch_profile_name, - sync_status_url, task_manager_url, + move_tab_space_name, new_private_profile_name, new_profile_name, new_space_name, note_body, + notes_url, plugin_detail_url, plugins_url, reading_list_url, reading_progress_percent, + search_url, settings_page_url, settings_url, shortcut_settings_url, space_icon, + switch_profile_name, sync_status_url, tab_note_body, task_manager_url, }, }; @@ -61,6 +61,12 @@ impl BrowserCore { self.command_query.clear(); } } + CommandIntent::ScopedSearch { scope: CommandScope::Notes, query } => { + if let Some(url) = self.find_note_match(query) { + self.open_tab(url); + self.command_query.clear(); + } + } CommandIntent::ScopedSearch { scope: CommandScope::ReadingList, query } => { if let Some(url) = self.find_reading_list_match(query) { self.open_tab(url); @@ -127,6 +133,14 @@ impl BrowserCore { self.set_active_tab_reading_progress(percent)?; return Ok(true); } + if let Some(body) = tab_note_body(command) { + self.save_active_tab_note(body)?; + return Ok(true); + } + if let Some(body) = note_body(command) { + self.save_active_url_note(body)?; + return Ok(true); + } match command.to_ascii_lowercase().as_str() { "new-tab" => { @@ -150,6 +164,10 @@ impl BrowserCore { self.open_tab(reading_list_url()?); Ok(true) } + "notes" | "open-notes" | "open notes" => { + self.open_tab(notes_url()?); + Ok(true) + } "history" | "open-history" | "open history" => { self.open_tab(history_url()?); Ok(true) diff --git a/crates/ely_browser_core/src/state/notes.rs b/crates/ely_browser_core/src/state/notes.rs new file mode 100644 index 0000000..228d615 --- /dev/null +++ b/crates/ely_browser_core/src/state/notes.rs @@ -0,0 +1,114 @@ +use std::time::SystemTime; + +use ely_domain::{NoteEntry, NoteId, NoteTarget, UrlText}; + +use crate::CoreError; + +use super::BrowserCore; + +impl BrowserCore { + pub fn save_active_url_note(&mut self, body: impl Into) -> Result { + let active_tab = self.active_tab()?.clone(); + let now = SystemTime::now(); + let target = NoteTarget::Url(active_tab.url().clone()); + + if let Some(index) = self.note_index_for_target(active_tab.profile_id(), &target) { + self.notes[index].update( + active_tab.title(), + active_tab.url().clone(), + body.into(), + now, + )?; + return Ok(self.notes[index].id().clone()); + } + + let entry = NoteEntry::new( + active_tab.profile_id().clone(), + active_tab.space_id().clone(), + target, + active_tab.title(), + active_tab.url().clone(), + body, + now, + )?; + let entry_id = entry.id().clone(); + self.notes.push(entry); + Ok(entry_id) + } + + pub fn save_active_tab_note(&mut self, body: impl Into) -> Result { + let active_tab = self.active_tab()?.clone(); + let now = SystemTime::now(); + let target = NoteTarget::Tab(active_tab.id().clone()); + + if let Some(index) = self.note_index_for_target(active_tab.profile_id(), &target) { + self.notes[index].update( + active_tab.title(), + active_tab.url().clone(), + body.into(), + now, + )?; + return Ok(self.notes[index].id().clone()); + } + + let entry = NoteEntry::new( + active_tab.profile_id().clone(), + active_tab.space_id().clone(), + target, + active_tab.title(), + active_tab.url().clone(), + body, + now, + )?; + let entry_id = entry.id().clone(); + self.notes.push(entry); + Ok(entry_id) + } + + pub(super) fn find_note_match(&self, query: &str) -> Option { + let normalized_query = query.trim().to_lowercase(); + if normalized_query.is_empty() { + return None; + } + + self.notes + .iter() + .rev() + .filter(|entry| entry.profile_id() == &self.active_profile_id) + .find(|entry| note_entry_matches_query(entry, &normalized_query)) + .map(|entry| entry.source_url().clone()) + } + + pub(super) fn visible_notes(&self) -> Vec { + self.notes + .iter() + .filter(|entry| entry.profile_id() == &self.active_profile_id) + .cloned() + .collect() + } + + fn note_index_for_target( + &self, + profile_id: &ely_domain::ProfileId, + target: &NoteTarget, + ) -> Option { + self.notes.iter().position(|entry| { + entry.profile_id() == profile_id && note_targets_match(entry.target(), target) + }) + } +} + +fn note_targets_match(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, + } +} + +fn note_entry_matches_query(entry: &NoteEntry, normalized_query: &str) -> bool { + entry.title().to_lowercase().contains(normalized_query) + || entry.source_url().as_str().to_lowercase().contains(normalized_query) + || entry.display_url().to_lowercase().contains(normalized_query) + || entry.body().to_lowercase().contains(normalized_query) +} diff --git a/crates/ely_browser_core/src/state/sync.rs b/crates/ely_browser_core/src/state/sync.rs index 1c0d526..4d22cd3 100644 --- a/crates/ely_browser_core/src/state/sync.rs +++ b/crates/ely_browser_core/src/state/sync.rs @@ -7,6 +7,7 @@ pub(super) struct SyncObjectPolicies { spaces: SyncObjectPolicy, tabs: SyncObjectPolicy, bookmarks: SyncObjectPolicy, + notes: SyncObjectPolicy, reading_list: SyncObjectPolicy, profiles: SyncObjectPolicy, site_permissions: SyncObjectPolicy, @@ -20,6 +21,7 @@ impl Default for SyncObjectPolicies { spaces: SyncObjectPolicy::Enabled, tabs: SyncObjectPolicy::Enabled, bookmarks: SyncObjectPolicy::Enabled, + notes: SyncObjectPolicy::Enabled, reading_list: SyncObjectPolicy::Enabled, profiles: SyncObjectPolicy::Enabled, site_permissions: SyncObjectPolicy::Enabled, @@ -35,6 +37,7 @@ impl SyncObjectPolicies { SyncObjectKind::Spaces => self.spaces, SyncObjectKind::Tabs => self.tabs, SyncObjectKind::Bookmarks => self.bookmarks, + SyncObjectKind::Notes => self.notes, SyncObjectKind::ReadingList => self.reading_list, SyncObjectKind::Profiles => self.profiles, SyncObjectKind::SitePermissions => self.site_permissions, @@ -48,6 +51,7 @@ impl SyncObjectPolicies { SyncObjectKind::Spaces => self.spaces = policy, SyncObjectKind::Tabs => self.tabs = policy, SyncObjectKind::Bookmarks => self.bookmarks = policy, + SyncObjectKind::Notes => self.notes = policy, SyncObjectKind::ReadingList => self.reading_list = policy, SyncObjectKind::Profiles => self.profiles = policy, SyncObjectKind::SitePermissions => self.site_permissions = policy, @@ -84,6 +88,11 @@ impl BrowserCore { self.bookmarks.len(), SyncObjectState::LocalOnly, ), + self.sync_object_status( + SyncObjectKind::Notes, + self.notes.len(), + SyncObjectState::LocalOnly, + ), self.sync_object_status( SyncObjectKind::ReadingList, self.reading_list.len(), diff --git a/crates/ely_browser_core/tests/notes.rs b/crates/ely_browser_core/tests/notes.rs new file mode 100644 index 0000000..045e82a --- /dev/null +++ b/crates/ely_browser_core/tests/notes.rs @@ -0,0 +1,145 @@ +use std::error::Error; + +use ely_browser_core::{BrowserCore, InitialBrowserConfig}; +use ely_domain::{CommandIntent, CommandScope, NoteTarget, ProfileKind, UrlText}; + +#[test] +fn url_note_records_active_page_context() -> Result<(), Box> { + let mut core = BrowserCore::new(InitialBrowserConfig::ely_defaults()?)?; + let tab_id = core.open_tab(UrlText::parse("https://example.com/notes-url")?); + let active_profile_id = core.active_tab()?.profile_id().clone(); + let active_space_id = core.active_tab()?.space_id().clone(); + + let note_id = core.save_active_url_note("# Research\n- fact")?; + let snapshot = core.snapshot()?; + let [note] = snapshot.notes.as_slice() else { + return Err(format!("expected 1 note, got {}", snapshot.notes.len()).into()); + }; + + assert_eq!(snapshot.active_tab_id, tab_id); + assert_eq!(note.id(), ¬e_id); + assert_eq!(note.profile_id(), &active_profile_id); + assert_eq!(note.space_id(), &active_space_id); + assert_eq!(note.target(), &NoteTarget::Url(UrlText::parse("https://example.com/notes-url")?)); + assert_eq!(note.title(), "example.com"); + assert_eq!(note.body(), "# Research\n- fact"); + Ok(()) +} + +#[test] +fn tab_note_records_tab_target() -> Result<(), Box> { + let mut core = BrowserCore::new(InitialBrowserConfig::ely_defaults()?)?; + let tab_id = core.open_tab(UrlText::parse("https://example.com/tab-note")?); + + core.save_active_tab_note("- tab detail")?; + let snapshot = core.snapshot()?; + let [note] = snapshot.notes.as_slice() else { + return Err(format!("expected 1 note, got {}", snapshot.notes.len()).into()); + }; + + assert_eq!(note.target(), &NoteTarget::Tab(tab_id)); + assert_eq!(note.target_label(), "Tab note"); + assert_eq!(note.source_url().as_str(), "https://example.com/tab-note"); + Ok(()) +} + +#[test] +fn url_note_command_updates_existing_note() -> Result<(), Box> { + let mut core = BrowserCore::new(InitialBrowserConfig::ely_defaults()?)?; + core.open_tab(UrlText::parse("https://example.com/research")?); + + core.set_command_query(">note first"); + core.submit_command()?; + core.set_command_query(">note second"); + let intent = core.submit_command()?; + let snapshot = core.snapshot()?; + + assert_eq!(intent, Some(CommandIntent::Command("note second".to_string()))); + assert_eq!(snapshot.command_query, ""); + assert_eq!(snapshot.notes.len(), 1); + assert_eq!(snapshot.notes[0].body(), "second"); + Ok(()) +} + +#[test] +fn tab_note_command_records_tab_target() -> Result<(), Box> { + let mut core = BrowserCore::new(InitialBrowserConfig::ely_defaults()?)?; + let tab_id = core.open_tab(UrlText::parse("https://example.com/pinned-detail")?); + + core.set_command_query(">tab-note - pinned detail"); + let intent = core.submit_command()?; + let snapshot = core.snapshot()?; + + assert_eq!(intent, Some(CommandIntent::Command("tab-note - pinned detail".to_string()))); + assert_eq!(snapshot.command_query, ""); + assert_eq!(snapshot.notes.len(), 1); + assert_eq!(snapshot.notes[0].target(), &NoteTarget::Tab(tab_id)); + assert_eq!(snapshot.notes[0].body(), "- pinned detail"); + Ok(()) +} + +#[test] +fn notes_scoped_search_opens_matching_note_url() -> Result<(), Box> { + let mut core = BrowserCore::new(InitialBrowserConfig::ely_defaults()?)?; + core.open_tab(UrlText::parse("https://example.com/matching-note")?); + core.save_active_url_note("# Citation\nunique cue")?; + core.open_tab(UrlText::parse("https://example.com/other")?); + + core.set_command_query("@notes unique cue"); + let intent = core.submit_command()?; + let snapshot = core.snapshot()?; + + assert_eq!( + intent, + Some(CommandIntent::ScopedSearch { + scope: CommandScope::Notes, + query: "unique cue".to_string() + }) + ); + assert_eq!(core.active_tab()?.url().as_str(), "https://example.com/matching-note"); + assert_eq!(snapshot.command_query, ""); + Ok(()) +} + +#[test] +fn notes_stay_with_active_profile() -> Result<(), Box> { + let mut core = BrowserCore::new(InitialBrowserConfig::ely_defaults()?)?; + let default_profile_id = core.active_tab()?.profile_id().clone(); + let personal_profile_id = core.create_profile("Personal", 0xf54e00, ProfileKind::Standard)?; + + core.open_tab(UrlText::parse("https://example.com/personal-note")?); + core.save_active_url_note("private cue")?; + core.select_profile(&default_profile_id)?; + + core.set_command_query("@notes private cue"); + let intent = core.submit_command()?; + let snapshot = core.snapshot()?; + + assert_eq!( + intent, + Some(CommandIntent::ScopedSearch { + scope: CommandScope::Notes, + query: "private cue".to_string() + }) + ); + assert_eq!(core.active_tab()?.profile_id(), &default_profile_id); + assert_ne!(core.active_tab()?.profile_id(), &personal_profile_id); + assert!(snapshot.notes.is_empty()); + assert_eq!(snapshot.command_query, "@notes private cue"); + Ok(()) +} + +#[test] +fn open_notes_command_opens_notes_page() -> Result<(), Box> { + let mut core = BrowserCore::new(InitialBrowserConfig::ely_defaults()?)?; + + core.set_command_query(">open-notes"); + let intent = core.submit_command()?; + let active_tab = core.active_tab()?; + + assert_eq!(intent, Some(CommandIntent::Command("open-notes".to_string()))); + assert_eq!(active_tab.title(), "Notes"); + assert_eq!(active_tab.url().as_str(), "ely://notes"); + assert_eq!(core.snapshot()?.command_query, ""); + Ok(()) +} diff --git a/crates/ely_browser_core/tests/sync.rs b/crates/ely_browser_core/tests/sync.rs index 938566a..b676f15 100644 --- a/crates/ely_browser_core/tests/sync.rs +++ b/crates/ely_browser_core/tests/sync.rs @@ -12,6 +12,7 @@ fn default_sync_status_reflects_local_browser_state() -> Result<(), Box Result<(), Box Option<(CommandScope, &str)> { "@spaces" => CommandScope::Spaces, "@tabs" => CommandScope::Tabs, "@bookmarks" => CommandScope::Bookmarks, + "@notes" => CommandScope::Notes, "@reading-list" | "@reading" => CommandScope::ReadingList, "@history" => CommandScope::History, "@settings" => CommandScope::Settings, diff --git a/crates/ely_domain/src/identifiers.rs b/crates/ely_domain/src/identifiers.rs index fb29a12..a3351fa 100644 --- a/crates/ely_domain/src/identifiers.rs +++ b/crates/ely_domain/src/identifiers.rs @@ -41,3 +41,4 @@ entity_id!(WebViewId, "webview"); entity_id!(DownloadId, "download"); entity_id!(BookmarkId, "bookmark"); entity_id!(ReadingListId, "reading"); +entity_id!(NoteId, "note"); diff --git a/crates/ely_domain/src/lib.rs b/crates/ely_domain/src/lib.rs index e8e7076..b69e06a 100644 --- a/crates/ely_domain/src/lib.rs +++ b/crates/ely_domain/src/lib.rs @@ -7,6 +7,7 @@ mod favorite; mod history; mod identifiers; mod new_tab; +mod note; mod plugin; mod privacy; mod profile; @@ -30,9 +31,10 @@ pub use error::DomainError; pub use favorite::FavoriteLimit; pub use history::HistoryEntry; pub use identifiers::{ - BookmarkId, DownloadId, ProfileId, ReadingListId, SpaceId, SplitId, TabId, WebViewId, + BookmarkId, DownloadId, NoteId, ProfileId, ReadingListId, SpaceId, SplitId, TabId, WebViewId, }; pub use new_tab::NewTabDestination; +pub use note::{NoteEntry, NoteTarget}; pub use plugin::{ PluginContributionPoint, PluginId, PluginManifest, PluginPermission, PluginPermissionRisk, PluginSignature, PluginSignatureAlgorithm, diff --git a/crates/ely_domain/src/note.rs b/crates/ely_domain/src/note.rs new file mode 100644 index 0000000..1dc68a2 --- /dev/null +++ b/crates/ely_domain/src/note.rs @@ -0,0 +1,228 @@ +use std::time::SystemTime; + +use crate::{DomainError, NoteId, ProfileId, SpaceId, TabId, UrlText}; + +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum NoteTarget { + Url(UrlText), + Tab(TabId), +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct NoteEntry { + id: NoteId, + profile_id: ProfileId, + space_id: SpaceId, + target: NoteTarget, + title: String, + source_url: UrlText, + body: String, + created_at: SystemTime, + updated_at: SystemTime, +} + +impl NoteEntry { + pub fn new( + profile_id: ProfileId, + space_id: SpaceId, + target: NoteTarget, + title: impl Into, + source_url: UrlText, + body: impl Into, + now: SystemTime, + ) -> Result { + let title = non_empty_text("note title", title.into())?; + let body = normalize_body(body.into())?; + + Ok(Self { + id: NoteId::new(), + profile_id, + space_id, + target, + title, + source_url, + body, + created_at: now, + updated_at: now, + }) + } + + #[must_use] + pub fn id(&self) -> &NoteId { + &self.id + } + + #[must_use] + pub fn profile_id(&self) -> &ProfileId { + &self.profile_id + } + + #[must_use] + pub fn space_id(&self) -> &SpaceId { + &self.space_id + } + + #[must_use] + pub fn target(&self) -> &NoteTarget { + &self.target + } + + #[must_use] + pub fn target_label(&self) -> &'static str { + match &self.target { + NoteTarget::Url(_) => "URL note", + NoteTarget::Tab(_) => "Tab note", + } + } + + #[must_use] + pub fn title(&self) -> &str { + &self.title + } + + #[must_use] + pub fn source_url(&self) -> &UrlText { + &self.source_url + } + + #[must_use] + pub fn display_url(&self) -> String { + self.source_url.display_url() + } + + #[must_use] + pub fn body(&self) -> &str { + &self.body + } + + #[must_use] + pub fn created_at(&self) -> SystemTime { + self.created_at + } + + #[must_use] + pub fn updated_at(&self) -> SystemTime { + self.updated_at + } + + pub fn update( + &mut self, + title: impl Into, + source_url: UrlText, + body: impl Into, + updated_at: SystemTime, + ) -> Result<(), DomainError> { + self.title = non_empty_text("note title", title.into())?; + self.source_url = source_url; + self.body = normalize_body(body.into())?; + self.updated_at = updated_at; + Ok(()) + } +} + +fn normalize_body(value: String) -> Result { + let normalized = value.replace("\r\n", "\n").replace('\r', "\n"); + non_empty_text("note body", normalized) +} + +fn non_empty_text(field: &'static str, value: String) -> Result { + let trimmed = value.trim(); + if trimmed.is_empty() { + return Err(DomainError::EmptyField { field }); + } + Ok(trimmed.to_string()) +} + +#[cfg(test)] +mod tests { + use std::time::{Duration, SystemTime}; + + use super::{NoteEntry, NoteTarget}; + use crate::{DomainError, ProfileId, SpaceId, TabId, UrlText}; + + #[test] + fn creates_url_note_with_markdown_body() -> Result<(), DomainError> { + let now = SystemTime::UNIX_EPOCH + Duration::from_secs(10); + let source_url = UrlText::parse("https://example.com/article")?; + let note = NoteEntry::new( + ProfileId::new(), + SpaceId::new(), + NoteTarget::Url(source_url.clone()), + "Example", + source_url, + " # Heading\r\n- item ", + now, + )?; + + assert_eq!(note.title(), "Example"); + assert_eq!(note.body(), "# Heading\n- item"); + assert_eq!(note.target_label(), "URL note"); + assert_eq!(note.created_at(), now); + assert_eq!(note.updated_at(), now); + Ok(()) + } + + #[test] + fn creates_tab_note_target() -> Result<(), DomainError> { + let tab_id = TabId::new(); + let note = NoteEntry::new( + ProfileId::new(), + SpaceId::new(), + NoteTarget::Tab(tab_id.clone()), + "Example", + UrlText::parse("https://example.com/tab")?, + "- detail", + SystemTime::UNIX_EPOCH, + )?; + + assert_eq!(note.target(), &NoteTarget::Tab(tab_id)); + assert_eq!(note.target_label(), "Tab note"); + Ok(()) + } + + #[test] + fn rejects_empty_body() -> Result<(), DomainError> { + let source_url = UrlText::parse("https://example.com")?; + let result = NoteEntry::new( + ProfileId::new(), + SpaceId::new(), + NoteTarget::Url(source_url.clone()), + "Example", + source_url, + " ", + SystemTime::UNIX_EPOCH, + ); + + assert_eq!(result, Err(DomainError::EmptyField { field: "note body" })); + Ok(()) + } + + #[test] + fn updates_body_and_timestamp() -> Result<(), DomainError> { + let created_at = SystemTime::UNIX_EPOCH + Duration::from_secs(10); + let updated_at = created_at + Duration::from_secs(30); + let mut note = NoteEntry::new( + ProfileId::new(), + SpaceId::new(), + NoteTarget::Url(UrlText::parse("https://example.com/old")?), + "Old", + UrlText::parse("https://example.com/old")?, + "first", + created_at, + )?; + + note.update( + "New", + UrlText::parse("https://example.com/new")?, + " second\rline ", + updated_at, + )?; + + assert_eq!(note.title(), "New"); + assert_eq!(note.source_url().as_str(), "https://example.com/new"); + assert_eq!(note.body(), "second\nline"); + assert_eq!(note.created_at(), created_at); + assert_eq!(note.updated_at(), updated_at); + Ok(()) + } +} diff --git a/crates/ely_domain/src/sync.rs b/crates/ely_domain/src/sync.rs index d7aa834..af61043 100644 --- a/crates/ely_domain/src/sync.rs +++ b/crates/ely_domain/src/sync.rs @@ -8,6 +8,7 @@ pub enum SyncObjectKind { Spaces, Tabs, Bookmarks, + Notes, ReadingList, Profiles, SitePermissions,