From bae3c7789fe73f6c099780b487c6af7a6df61870 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Fri, 8 May 2026 06:43:38 -0400 Subject: [PATCH] Add reading list progress percent --- .../src/shell/internal_pages/reading_list.rs | 4 +- crates/ely_browser_core/src/navigation.rs | 26 ++++++- crates/ely_browser_core/src/state/commands.rs | 10 ++- .../src/state/reading_list.rs | 13 +++- crates/ely_browser_core/tests/reading_list.rs | 70 ++++++++++++++++++- crates/ely_domain/src/error.rs | 3 + crates/ely_domain/src/lib.rs | 2 +- crates/ely_domain/src/reading_list.rs | 56 +++++++++++++-- 8 files changed, 169 insertions(+), 15 deletions(-) diff --git a/crates/ely_app/src/shell/internal_pages/reading_list.rs b/crates/ely_app/src/shell/internal_pages/reading_list.rs index ff8800e..f414abf 100644 --- a/crates/ely_app/src/shell/internal_pages/reading_list.rs +++ b/crates/ely_app/src/shell/internal_pages/reading_list.rs @@ -192,7 +192,7 @@ fn reading_list_space_name(snapshot: &BrowserSnapshot, entry: &ReadingListEntry) .map(|space| space.name().to_string()) } -fn progress_label(progress: &ReadingProgress) -> &'static str { +fn progress_label(progress: &ReadingProgress) -> String { progress.label() } @@ -204,7 +204,7 @@ fn render_progress_action( ) -> AnyElement { let next_progress = progress.toggled(); let icon = match progress { - ReadingProgress::Unread => IconName::CircleCheck, + ReadingProgress::Unread | ReadingProgress::InProgress(_) => IconName::CircleCheck, ReadingProgress::Finished => IconName::Undo2, }; diff --git a/crates/ely_browser_core/src/navigation.rs b/crates/ely_browser_core/src/navigation.rs index 55ffa33..82d066a 100644 --- a/crates/ely_browser_core/src/navigation.rs +++ b/crates/ely_browser_core/src/navigation.rs @@ -1,4 +1,6 @@ -use ely_domain::{BrowserTab, PluginId, SearchEngine, SiteOrigin, UrlText}; +use ely_domain::{ + BrowserTab, DomainError, PluginId, ReadingProgressPercent, SearchEngine, SiteOrigin, UrlText, +}; use url::Url; use crate::CoreError; @@ -67,6 +69,28 @@ pub(crate) fn archive_idle_days(command: &str) -> Option { .and_then(|value| value.parse().ok()) } +pub(crate) fn reading_progress_percent( + command: &str, +) -> Result, CoreError> { + let Some(value) = command_argument( + command, + &[ + "reading-progress ", + "reading progress ", + "set-reading-progress ", + "set reading progress ", + ], + ) else { + return Ok(None); + }; + + let percent_text = value.trim().strip_suffix('%').unwrap_or(value.trim()).trim(); + let percent = percent_text.parse::().map_err(|_| { + DomainError::InvalidReadingProgressPercent { value: value.trim().to_string() } + })?; + ReadingProgressPercent::new(percent).map(Some).map_err(CoreError::from) +} + pub(crate) fn new_profile_name(command: &str) -> Option<&str> { command_argument(command, &["new-profile ", "new profile "]) } diff --git a/crates/ely_browser_core/src/state/commands.rs b/crates/ely_browser_core/src/state/commands.rs index 3c06fc3..45aafb2 100644 --- a/crates/ely_browser_core/src/state/commands.rs +++ b/crates/ely_browser_core/src/state/commands.rs @@ -7,9 +7,9 @@ use crate::{ 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, search_url, settings_page_url, - settings_url, shortcut_settings_url, space_icon, switch_profile_name, sync_status_url, - task_manager_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, task_manager_url, }, }; @@ -123,6 +123,10 @@ impl BrowserCore { self.archive_idle_tabs(SystemTime::now())?; return Ok(true); } + if let Some(percent) = reading_progress_percent(command)? { + self.set_active_tab_reading_progress(percent)?; + return Ok(true); + } match command.to_ascii_lowercase().as_str() { "new-tab" => { diff --git a/crates/ely_browser_core/src/state/reading_list.rs b/crates/ely_browser_core/src/state/reading_list.rs index f168c6a..e7febdc 100644 --- a/crates/ely_browser_core/src/state/reading_list.rs +++ b/crates/ely_browser_core/src/state/reading_list.rs @@ -1,6 +1,8 @@ use std::time::SystemTime; -use ely_domain::{ReadingListEntry, ReadingListId, ReadingProgress, UrlText}; +use ely_domain::{ + ReadingListEntry, ReadingListId, ReadingProgress, ReadingProgressPercent, UrlText, +}; use crate::CoreError; @@ -36,6 +38,15 @@ impl BrowserCore { Ok(()) } + pub fn set_active_tab_reading_progress( + &mut self, + percent: ReadingProgressPercent, + ) -> Result { + let entry_id = self.save_active_tab_to_reading_list()?; + self.set_reading_list_progress(&entry_id, ReadingProgress::InProgress(percent))?; + Ok(entry_id) + } + pub fn remove_reading_list_entry(&mut self, entry_id: &ReadingListId) -> Result<(), CoreError> { let index = self.reading_list_entry_index(entry_id)?; self.reading_list.remove(index); diff --git a/crates/ely_browser_core/tests/reading_list.rs b/crates/ely_browser_core/tests/reading_list.rs index 2cfab78..9a698f8 100644 --- a/crates/ely_browser_core/tests/reading_list.rs +++ b/crates/ely_browser_core/tests/reading_list.rs @@ -1,8 +1,9 @@ use std::error::Error; -use ely_browser_core::{BrowserCore, InitialBrowserConfig}; +use ely_browser_core::{BrowserCore, CoreError, InitialBrowserConfig}; use ely_domain::{ - CommandIntent, CommandScope, ProfileKind, ReadingListId, ReadingProgress, UrlText, + CommandIntent, CommandScope, DomainError, ProfileKind, ReadingListId, ReadingProgress, + ReadingProgressPercent, UrlText, }; #[test] @@ -49,6 +50,18 @@ fn reading_list_progress_updates_entry() -> Result<(), Box> { core.open_tab(UrlText::parse("https://example.com/long-read")?); let entry_id = core.save_active_tab_to_reading_list()?; + core.set_reading_list_progress( + &entry_id, + ReadingProgress::InProgress(ReadingProgressPercent::new(42)?), + )?; + let snapshot = core.snapshot()?; + + assert_eq!(snapshot.reading_list[0].id(), &entry_id); + assert_eq!( + snapshot.reading_list[0].progress(), + &ReadingProgress::InProgress(ReadingProgressPercent::new(42)?) + ); + core.set_reading_list_progress(&entry_id, ReadingProgress::Finished)?; let snapshot = core.snapshot()?; @@ -57,6 +70,59 @@ fn reading_list_progress_updates_entry() -> Result<(), Box> { Ok(()) } +#[test] +fn active_tab_reading_progress_saves_partial_progress() -> Result<(), Box> { + let mut core = BrowserCore::new(InitialBrowserConfig::ely_defaults()?)?; + core.open_tab(UrlText::parse("https://example.com/long-read")?); + + let entry_id = core.set_active_tab_reading_progress(ReadingProgressPercent::new(47)?)?; + let snapshot = core.snapshot()?; + + assert_eq!(snapshot.reading_list.len(), 1); + assert_eq!(snapshot.reading_list[0].id(), &entry_id); + assert_eq!( + snapshot.reading_list[0].progress(), + &ReadingProgress::InProgress(ReadingProgressPercent::new(47)?) + ); + Ok(()) +} + +#[test] +fn reading_progress_command_updates_active_page_entry() -> Result<(), Box> { + let mut core = BrowserCore::new(InitialBrowserConfig::ely_defaults()?)?; + core.open_tab(UrlText::parse("https://example.com/long-read")?); + + core.set_command_query(">reading-progress 42%"); + let intent = core.submit_command()?; + let snapshot = core.snapshot()?; + + assert_eq!(intent, Some(CommandIntent::Command("reading-progress 42%".to_string()))); + assert_eq!(snapshot.command_query, ""); + assert_eq!(snapshot.reading_list.len(), 1); + assert_eq!( + snapshot.reading_list[0].progress(), + &ReadingProgress::InProgress(ReadingProgressPercent::new(42)?) + ); + Ok(()) +} + +#[test] +fn reading_progress_command_rejects_terminal_percent() -> Result<(), Box> { + let mut core = BrowserCore::new(InitialBrowserConfig::ely_defaults()?)?; + core.open_tab(UrlText::parse("https://example.com/long-read")?); + + core.set_command_query(">reading-progress 100"); + let Err(error) = core.submit_command() else { + return Err("expected invalid reading progress error".into()); + }; + + assert_eq!( + error, + CoreError::Domain(DomainError::InvalidReadingProgressPercent { value: "100".to_string() }) + ); + Ok(()) +} + #[test] fn missing_reading_list_progress_update_returns_error() -> Result<(), Box> { let mut core = BrowserCore::new(InitialBrowserConfig::ely_defaults()?)?; diff --git a/crates/ely_domain/src/error.rs b/crates/ely_domain/src/error.rs index 07abb7f..1860838 100644 --- a/crates/ely_domain/src/error.rs +++ b/crates/ely_domain/src/error.rs @@ -29,6 +29,9 @@ pub enum DomainError { #[error("download progress {received_bytes} exceeds total {total_bytes}")] InvalidDownloadProgress { received_bytes: u64, total_bytes: u64 }, + #[error("invalid reading progress percent: {value}")] + InvalidReadingProgressPercent { value: String }, + #[error("invalid plugin manifest: {reason}")] InvalidPluginManifest { reason: String }, diff --git a/crates/ely_domain/src/lib.rs b/crates/ely_domain/src/lib.rs index 12d2141..e8e7076 100644 --- a/crates/ely_domain/src/lib.rs +++ b/crates/ely_domain/src/lib.rs @@ -39,7 +39,7 @@ pub use plugin::{ }; pub use privacy::HistoryRecordingPolicy; pub use profile::{Profile, ProfileKind, ProfileSyncPolicy}; -pub use reading_list::{ReadingListEntry, ReadingProgress}; +pub use reading_list::{ReadingListEntry, ReadingProgress, ReadingProgressPercent}; pub use search::SearchEngine; pub use site_permission::{ SiteOrigin, SitePermissionAuditAction, SitePermissionAuditEvent, SitePermissionDecision, diff --git a/crates/ely_domain/src/reading_list.rs b/crates/ely_domain/src/reading_list.rs index c9d346b..b9816a4 100644 --- a/crates/ely_domain/src/reading_list.rs +++ b/crates/ely_domain/src/reading_list.rs @@ -2,25 +2,44 @@ use std::time::SystemTime; use crate::{DomainError, ProfileId, ReadingListId, SpaceId, UrlText}; +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct ReadingProgressPercent(u8); + +impl ReadingProgressPercent { + pub fn new(value: u8) -> Result { + if !(1..=99).contains(&value) { + return Err(DomainError::InvalidReadingProgressPercent { value: value.to_string() }); + } + Ok(Self(value)) + } + + #[must_use] + pub fn value(self) -> u8 { + self.0 + } +} + #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum ReadingProgress { Unread, + InProgress(ReadingProgressPercent), Finished, } impl ReadingProgress { #[must_use] - pub fn label(self) -> &'static str { + pub fn label(self) -> String { match self { - Self::Unread => "Unread", - Self::Finished => "Read", + Self::Unread => "Unread".to_string(), + Self::InProgress(percent) => format!("{}% read", percent.value()), + Self::Finished => "Read".to_string(), } } #[must_use] pub fn action_label(self) -> &'static str { match self { - Self::Unread => "Mark Read", + Self::Unread | Self::InProgress(_) => "Mark Read", Self::Finished => "Mark Unread", } } @@ -28,7 +47,7 @@ impl ReadingProgress { #[must_use] pub fn toggled(self) -> Self { match self { - Self::Unread => Self::Finished, + Self::Unread | Self::InProgress(_) => Self::Finished, Self::Finished => Self::Unread, } } @@ -118,3 +137,30 @@ fn non_empty_text(field: &'static str, value: String) -> Result Result<(), DomainError> { + let percent = ReadingProgressPercent::new(42)?; + + assert_eq!(percent.value(), 42); + assert_eq!(ReadingProgress::InProgress(percent).label(), "42% read"); + Ok(()) + } + + #[test] + fn reading_progress_percent_rejects_terminal_values() { + assert_eq!( + ReadingProgressPercent::new(0), + Err(DomainError::InvalidReadingProgressPercent { value: "0".to_string() }) + ); + assert_eq!( + ReadingProgressPercent::new(100), + Err(DomainError::InvalidReadingProgressPercent { value: "100".to_string() }) + ); + } +}