diff --git a/i18n/en/cosmicding.ftl b/i18n/en/cosmicding.ftl index 362d6ad..feedf10 100644 --- a/i18n/en/cosmicding.ftl +++ b/i18n/en/cosmicding.ftl @@ -57,8 +57,10 @@ import-bookmarks = Import Bookmarks import-bookmarks-body = Select account to import bookmarks to: import-bookmarks-error = Failed to import bookmarks: {$error} import-bookmarks-file-not-found = Import file not found at {$path}. Please place your bookmarks HTML file at this location. +import-bookmarks-finished = Successfully imported {$count} bookmarks import-bookmarks-no-path = Please select a file path for import import-bookmarks-started = Importing {$count} bookmarks... +importing-bookmarks = Importing Bookmarks instance = Instance invalid-api-token = Invalid API token items-per-page = Items Per Page - {{$count}} @@ -81,6 +83,7 @@ refresh = Refresh refresh-bookmarks = Refresh Bookmarks refreshed-bookmarks = Refreshed bookmarks refreshed-bookmarks-for-account = Refreshed account {$acc} +refreshing-accounts = Refreshing Accounts remove = Remove remove-account-confirm = Are you sure you wish to delete this account? remove-bookmark-confirm = Are you sure you wish to delete this bookmark? diff --git a/i18n/sv/cosmicding.ftl b/i18n/sv/cosmicding.ftl index f082dcd..9dfc1e6 100644 --- a/i18n/sv/cosmicding.ftl +++ b/i18n/sv/cosmicding.ftl @@ -57,8 +57,10 @@ import-bookmarks = Importera bokmärken import-bookmarks-body = Välj konto att importera bokmärken till: import-bookmarks-error = Kunde inte importera bokmärken: {$error} import-bookmarks-file-not-found = Importfilen hittades inte på {$path}. Placera din bokmärkes-HTML-fil på den här platsen. +import-bookmarks-finished = Importerade {$count} bokmärken import-bookmarks-no-path = Välj en filsökväg för import import-bookmarks-started = Importerar {$count} bokmärken... +importing-bookmarks = Importerar bokmärken instance = Instans invalid-api-token = Ogiltig API-token items-per-page = Poster per sida - {$count} @@ -81,6 +83,7 @@ refresh = Uppdatera refresh-bookmarks = Uppdatera bokmärken refreshed-bookmarks = Uppdaterat bokmärken refreshed-bookmarks-for-account = Uppdaterat konto {$acc} +refreshing-accounts = Uppdaterar konton remove = Ta bort remove-account-confirm = Är du säker på att du vill ta bort det här kontot? remove-bookmark-confirm = Är du säker på att du vill ta bort det här bokmärket? diff --git a/src/app.rs b/src/app.rs index 82dd9df..bf50ce0 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1,6 +1,6 @@ use crate::{ app::{ - actions::ApplicationAction, + actions::{ApplicationAction, ImportAction}, config::{AppTheme, CosmicConfig, SortOption}, context::ContextPage, dialog::DialogPage, @@ -17,6 +17,7 @@ use crate::{ }, db_cursor::{AccountsPaginationCursor, BookmarksPaginationCursor, Pagination}, favicon_cache::Favicon, + operation::OperationProgress, sync_status::SyncStatus, }, pages::{ @@ -127,7 +128,7 @@ pub struct Cosmicding { timeline: Timeline, sync_status: SyncStatus, toasts: widget::toaster::Toasts, - pending_import_count: usize, + operation_progress: Option, } #[derive(Debug, Clone, Copy)] @@ -228,7 +229,7 @@ impl Application for Cosmicding { timeline, sync_status: SyncStatus::default(), toasts: widget::toaster::Toasts::new(ApplicationAction::CloseToast), - pending_import_count: 0, + operation_progress: None, }; app.bookmarks_cursor.items_per_page = app.config.items_per_page; @@ -903,16 +904,39 @@ impl Application for Cosmicding { self.state = ApplicationState::NoEnabledAccounts; } else { self.state = ApplicationState::Refreshing; - let message = |x: Vec| { - cosmic::Action::App( - ApplicationAction::DoneRefreshBookmarksForAllAccounts(x), - ) - }; - if !self.accounts_view.accounts.is_empty() { + + let enabled_accounts: Vec = self + .accounts_view + .accounts + .iter() + .filter(|a| a.enabled) + .cloned() + .collect(); + + if !enabled_accounts.is_empty() { + let total_accounts = enabled_accounts.len(); + self.operation_progress = Some(OperationProgress { + operation_id: 0, + total: total_accounts, + current: 0, + operation_label: fl!("refreshing-accounts"), + cancellable: false, + }); + + let first_account = enabled_accounts[0].clone(); + let remaining_accounts = enabled_accounts[1..].to_vec(); + + let message = move |response: DetailedResponse| { + cosmic::Action::App( + ApplicationAction::DoneRefreshSingleAccount( + response, + remaining_accounts.clone(), + ), + ) + }; + commands.push(Task::perform( - http::fetch_bookmarks_from_all_accounts( - self.accounts_view.accounts.clone(), - ), + http::fetch_bookmarks_for_single_account(first_account), message, )); } @@ -920,78 +944,87 @@ impl Application for Cosmicding { } } } - ApplicationAction::DoneRefreshBookmarksForAllAccounts(remote_responses) => { - let mut failed_accounts: Vec = Vec::new(); + ApplicationAction::DoneRefreshSingleAccount(response, remaining_accounts) => { if let Some(ref mut database) = &mut self.bookmarks_cursor.database { - for response in remote_responses.iter().cloned() { - if !response.successful { - failed_accounts.push(response.account.display_name.clone()); - } - block_on(async { - db::SqliteDatabase::aggregate_bookmarks_for_account( - database, - &response.account, - response.bookmarks.unwrap_or_else(Vec::new), - response.timestamp, - response.successful, - ) - .await; - }); + if !response.successful { + log::error!( + "Failed to refresh account: {}", + response.account.display_name + ); } - commands.push(self.update(ApplicationAction::LoadAccounts)); - commands.push(self.update(ApplicationAction::LoadBookmarks)); - self.state = ApplicationState::Ready; - if failed_accounts.is_empty() { + block_on(async { + db::SqliteDatabase::aggregate_bookmarks_for_account( + database, + &response.account, + response.bookmarks.unwrap_or_else(Vec::new), + response.timestamp, + response.successful, + ) + .await; + }); + } + + if let Some(ref mut progress) = self.operation_progress { + progress.current += 1; + + if remaining_accounts.is_empty() { + self.operation_progress = None; + commands.push(self.update(ApplicationAction::LoadAccounts)); + commands.push(self.update(ApplicationAction::LoadBookmarks)); + self.state = ApplicationState::Ready; self.sync_status = SyncStatus::Successful; commands.push( self.toasts .push(widget::toaster::Toast::new(fl!("refreshed-bookmarks"))) .map(cosmic::Action::App), ); - } else if remote_responses.len() == failed_accounts.len() { - self.sync_status = SyncStatus::Failed; - commands.push( - self.toasts - .push(widget::toaster::Toast::new(fl!( - "failed-refreshing-all-accounts" - ))) - .map(cosmic::Action::App), - ); } else { - self.sync_status = SyncStatus::Warning; - commands.push( - self.toasts - .push(widget::toaster::Toast::new(fl!( - "failed-refreshing-accounts", - accounts = failed_accounts.join(", ") - ))) - .map(cosmic::Action::App), - ); + let next_account = remaining_accounts[0].clone(); + let remaining = remaining_accounts[1..].to_vec(); + + let message = move |response: DetailedResponse| { + cosmic::Action::App(ApplicationAction::DoneRefreshSingleAccount( + response, + remaining.clone(), + )) + }; + + commands.push(Task::perform( + http::fetch_bookmarks_for_single_account(next_account), + message, + )); } } } + ApplicationAction::StartRefreshBookmarksForAccount(account) => { if let ApplicationState::Refreshing = self.state { } else if account.enabled { self.state = ApplicationState::Refreshing; - let mut acc_vec = self.accounts_view.accounts.clone(); - acc_vec.retain(|acc| acc.id == account.id); - let cloned_acc = acc_vec[0].clone(); - let message = move |bookmarks: Vec| { - cosmic::Action::App(ApplicationAction::DoneRefreshBookmarksForAccount( - cloned_acc.clone(), - bookmarks, + + self.operation_progress = Some(OperationProgress { + operation_id: 0, + total: 1, + current: 0, + operation_label: fl!("refreshing-accounts"), + cancellable: false, + }); + + let cloned_account = account.clone(); + let message = move |response: DetailedResponse| { + cosmic::Action::App(ApplicationAction::DoneRefreshSingleAccount( + response, + Vec::new(), )) }; + commands.push(self.update(ApplicationAction::StartRefreshAccountProfile( account.clone(), ))); - if !acc_vec.is_empty() { - commands.push(Task::perform( - http::fetch_bookmarks_from_all_accounts(acc_vec.clone()), - message, - )); - } + commands.push(Task::perform( + http::fetch_bookmarks_for_single_account(cloned_account), + message, + )); } else { if self.accounts_view.accounts.iter().all(|item| !item.enabled) { self.state = ApplicationState::NoEnabledAccounts; @@ -999,48 +1032,7 @@ impl Application for Cosmicding { commands.push(self.update(ApplicationAction::LoadBookmarks)); } } - ApplicationAction::DoneRefreshBookmarksForAccount(account, remote_responses) => { - let mut failure_refreshing = false; - if let Some(ref mut database) = &mut self.bookmarks_cursor.database { - for response in remote_responses { - if !response.successful { - failure_refreshing = true; - } - block_on(async { - db::SqliteDatabase::aggregate_bookmarks_for_account( - database, - &account, - response.bookmarks.unwrap_or_else(Vec::new), - response.timestamp, - response.successful, - ) - .await; - }); - } - commands.push(self.update(ApplicationAction::LoadAccounts)); - commands.push(self.update(ApplicationAction::LoadBookmarks)); - self.state = ApplicationState::Ready; - if failure_refreshing { - commands.push( - self.toasts - .push(widget::toaster::Toast::new(fl!( - "failed-refreshing-bookmarks-for-account", - account = account.display_name - ))) - .map(cosmic::Action::App), - ); - } else { - commands.push( - self.toasts - .push(widget::toaster::Toast::new(fl!( - "refreshed-bookmarks-for-account", - acc = account.display_name - ))) - .map(cosmic::Action::App), - ); - } - } - } + ApplicationAction::StartRefreshAccountProfile(account) => { if let ApplicationState::Refreshing = self.state { } else if account.enabled { @@ -1198,21 +1190,52 @@ impl Application for Cosmicding { // NOTE: (vkhitrin) during creation, linkding doesn't populate 'favicon_url'. // In order to display the new favicon, users are required to wait a // bit, and then perform a manual refresh. - ApplicationAction::StartAddBookmark(account, bookmark) => { + ApplicationAction::StartAddBookmark( + account, + bookmark, + import_context, + remaining_bookmarks, + ) => { + if let Some(ctx) = &import_context { + let is_cancelled = match &self.operation_progress { + None => true, + Some(progress) => progress.operation_id != ctx.import_id, + }; + + if is_cancelled { + log::debug!( + "Skipping bookmark {} - import {} was cancelled", + bookmark.url, + ctx.import_id + ); + commands.push(self.update(ApplicationAction::DoneImportBookmarks(0))); + return Task::batch(commands); + } + } + let cloned_acc = account.clone(); + let cloned_context = import_context.clone(); + let cloned_remaining = remaining_bookmarks.clone(); let message = move |api_response: Option| { cosmic::Action::App(ApplicationAction::DoneAddBookmark( cloned_acc.clone(), api_response, + cloned_context.clone(), + cloned_remaining.clone(), )) }; commands.push(Task::perform( - http::populate_bookmark(account, bookmark, true), + http::populate_bookmark(account, bookmark, true, true), message, )); self.core.window.show_context = false; } - ApplicationAction::DoneAddBookmark(account, api_response) => { + ApplicationAction::DoneAddBookmark( + account, + api_response, + import_context, + mut remaining_bookmarks, + ) => { if let Some(ref mut database) = &mut self.bookmarks_cursor.database { if let Some(response) = api_response { if response.error.is_none() { @@ -1222,15 +1245,18 @@ impl Application for Cosmicding { block_on(async { db::SqliteDatabase::add_bookmark(database, &bkmrk).await; }); - commands.push( - self.toasts - .push(widget::toaster::Toast::new(fl!( - "added-bookmark-to-account", - bkmrk = bkmrk.url.clone(), - acc = account.display_name - ))) - .map(cosmic::Action::App), - ); + // Only show toast if not importing + if import_context.is_none() { + commands.push( + self.toasts + .push(widget::toaster::Toast::new(fl!( + "added-bookmark-to-account", + bkmrk = bkmrk.url.clone(), + acc = account.display_name.clone() + ))) + .map(cosmic::Action::App), + ); + } } else { block_on(async { db::SqliteDatabase::update_bookmark( @@ -1238,32 +1264,65 @@ impl Application for Cosmicding { ) .await; }); - commands.push( - self.toasts - .push(widget::toaster::Toast::new(fl!( - "updated-bookmark-in-account", - bkmrk = bkmrk.url, - acc = account.display_name.clone() - ))) - .map(cosmic::Action::App), - ); + // Only show toast if not importing + if import_context.is_none() { + commands.push( + self.toasts + .push(widget::toaster::Toast::new(fl!( + "updated-bookmark-in-account", + bkmrk = bkmrk.url, + acc = account.display_name.clone() + ))) + .map(cosmic::Action::App), + ); + } } commands.push(self.update(ApplicationAction::LoadBookmarks)); + + if !remaining_bookmarks.is_empty() && import_context.is_some() { + if let Some(ref mut progress) = self.operation_progress { + progress.current += 1; + } + + let (next_bookmark, next_context) = + remaining_bookmarks.remove(0); + commands.push(self.update( + ApplicationAction::StartAddBookmark( + account.clone(), + next_bookmark, + Some(next_context), + remaining_bookmarks.clone(), + ), + )); + } else if let Some(ctx) = &import_context { + self.operation_progress = None; + commands.push(self.update( + ApplicationAction::DoneImportBookmarks(ctx.total_count), + )); + } } } else { commands.push( self.toasts - .push(widget::toaster::Toast::new(response.error.unwrap())) + .push(widget::toaster::Toast::new( + response.error.clone().unwrap(), + )) .map(cosmic::Action::App), ); - } - } - } - if self.pending_import_count > 0 { - self.pending_import_count -= 1; - if self.pending_import_count == 0 { - commands.push(self.update(ApplicationAction::DoneImportBookmarks)); + // Cancel remaining imports + if let Some(ctx) = &import_context { + let remaining_count = remaining_bookmarks.len(); + log::error!( + "Bookmark import failed: {}. Cancelling remaining {} imports.", + response.error.unwrap(), + remaining_count + ); + commands.push(self.update( + ApplicationAction::CancelImportBookmarks(ctx.import_id), + )); + } + } } } } @@ -1345,13 +1404,13 @@ impl Application for Cosmicding { ApplicationAction::StartEditBookmark(account, bookmark) => { let cloned_acc = account.clone(); let message = move |api_response: Option| { - cosmic::Action::App(ApplicationAction::DoneAddBookmark( + cosmic::Action::App(ApplicationAction::DoneEditBookmark( cloned_acc.clone(), api_response, )) }; commands.push(Task::perform( - http::populate_bookmark(account, bookmark, false), + http::populate_bookmark(account, bookmark, false, false), message, )); self.core.window.show_context = false; @@ -1664,23 +1723,72 @@ impl Application for Cosmicding { ) { Ok(bookmarks) => { let import_count = bookmarks.len(); - self.pending_import_count = import_count; - for bookmark in bookmarks { + if import_count == 0 { + commands.push( + self.toasts + .push(widget::toaster::Toast::new(fl!( + "import-bookmarks-error", + error = "No bookmarks found" + ))) + .map(cosmic::Action::App), + ); + commands.push( + self.update( + ApplicationAction::DoneImportBookmarks(0), + ), + ); + } else { + let import_id = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos() + as u64; + + let mut bookmarks_with_context: Vec<( + Bookmark, + ImportAction, + )> = bookmarks + .into_iter() + .enumerate() + .map(|(index, bookmark)| { + let import_context = ImportAction { + import_id, + total_count: import_count, + current_index: index, + }; + (bookmark, import_context) + }) + .collect(); + + let (first_bookmark, first_context) = + bookmarks_with_context.remove(0); + + self.operation_progress = Some(OperationProgress { + operation_id: import_id, + total: import_count, + current: 0, + operation_label: fl!("importing-bookmarks"), + cancellable: true, + }); + commands.push(self.update( ApplicationAction::StartAddBookmark( account.clone(), - bookmark, + first_bookmark, + Some(first_context), + bookmarks_with_context, ), )); + + commands.push( + self.toasts + .push(widget::toaster::Toast::new(fl!( + "import-bookmarks-started", + count = import_count + ))) + .map(cosmic::Action::App), + ); } - commands.push( - self.toasts - .push(widget::toaster::Toast::new(fl!( - "import-bookmarks-started", - count = import_count - ))) - .map(cosmic::Action::App), - ); } Err(e) => { commands.push( @@ -1692,7 +1800,7 @@ impl Application for Cosmicding { .map(cosmic::Action::App), ); commands.push( - self.update(ApplicationAction::DoneImportBookmarks), + self.update(ApplicationAction::DoneImportBookmarks(0)), ); } } @@ -1706,7 +1814,8 @@ impl Application for Cosmicding { ))) .map(cosmic::Action::App), ); - commands.push(self.update(ApplicationAction::DoneImportBookmarks)); + commands + .push(self.update(ApplicationAction::DoneImportBookmarks(0))); } } } else { @@ -1718,7 +1827,7 @@ impl Application for Cosmicding { ))) .map(cosmic::Action::App), ); - commands.push(self.update(ApplicationAction::DoneImportBookmarks)); + commands.push(self.update(ApplicationAction::DoneImportBookmarks(0))); } } else { commands.push( @@ -1726,11 +1835,30 @@ impl Application for Cosmicding { .push(widget::toaster::Toast::new(fl!("import-bookmarks-no-path"))) .map(cosmic::Action::App), ); - commands.push(self.update(ApplicationAction::DoneImportBookmarks)); + commands.push(self.update(ApplicationAction::DoneImportBookmarks(0))); } } - ApplicationAction::DoneImportBookmarks => { + ApplicationAction::CancelImportBookmarks(import_id) => { + log::info!("Import {} cancelled", import_id); + + self.operation_progress = None; + + commands.push(self.update(ApplicationAction::DoneImportBookmarks(0))); + } + ApplicationAction::DoneImportBookmarks(count) => { self.state = ApplicationState::Ready; + self.operation_progress = None; + + if count > 0 { + commands.push( + self.toasts + .push(widget::toaster::Toast::new(fl!( + "import-bookmarks-finished", + count = count + ))) + .map(cosmic::Action::App), + ); + } } ApplicationAction::CloseToast(id) => { self.toasts.remove(id); diff --git a/src/app/actions.rs b/src/app/actions.rs index 24c2c94..e51bb8c 100644 --- a/src/app/actions.rs +++ b/src/app/actions.rs @@ -33,13 +33,17 @@ pub enum ApplicationAction { DialogCancel, DialogUpdate(DialogPage), DoneAddAccount(Account, Option), - DoneAddBookmark(Account, Option), + DoneAddBookmark( + Account, + Option, + Option, + Vec<(Bookmark, ImportAction)>, + ), DoneEditAccount(Account, Option), DoneEditBookmark(Account, Option), DoneFetchFaviconForBookmark(String, Bytes), DoneRefreshAccountProfile(Account, Option), - DoneRefreshBookmarksForAccount(Account, Vec), - DoneRefreshBookmarksForAllAccounts(Vec), + DoneRefreshSingleAccount(DetailedResponse, Vec), DoneRemoveBookmark(Account, Bookmark, Option), EditAccountForm(Account), EditBookmarkForm(i64, Bookmark), @@ -55,7 +59,8 @@ pub enum ApplicationAction { SetImportPath(Option), PerformExportBookmarks(Vec), PerformImportBookmarks(Account), - DoneImportBookmarks, + CancelImportBookmarks(u64), + DoneImportBookmarks(usize), IncrementPageIndex(String), InputBookmarkDescription(widget::text_editor::Action), InputBookmarkNotes(widget::text_editor::Action), @@ -86,7 +91,12 @@ pub enum ApplicationAction { SetItemsPerPage(u8), SortOption(SortOption), StartAddAccount(Account), - StartAddBookmark(Account, Bookmark), + StartAddBookmark( + Account, + Bookmark, + Option, + Vec<(Bookmark, ImportAction)>, + ), StartEditAccount(Account), StartEditBookmark(Account, Bookmark), StartFetchFaviconForBookmark(Bookmark), @@ -105,6 +115,7 @@ pub enum ApplicationAction { #[derive(Debug, Clone)] pub enum AccountsAction { AddAccount, + CancelImport(u64), DecrementPageIndex, DeleteAccount(Account), EditAccount(Account), @@ -116,6 +127,7 @@ pub enum AccountsAction { #[derive(Debug, Clone)] pub enum BookmarksAction { AddBookmark, + CancelImport(u64), ClearSearch, DecrementPageIndex, DeleteBookmark(i64, Bookmark), @@ -128,3 +140,10 @@ pub enum BookmarksAction { SearchBookmarks(String), ViewNotes(Bookmark), } + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct ImportAction { + pub import_id: u64, + pub total_count: usize, + pub current_index: usize, +} diff --git a/src/app/menu.rs b/src/app/menu.rs index a1e4fb2..22bcba1 100644 --- a/src/app/menu.rs +++ b/src/app/menu.rs @@ -65,7 +65,13 @@ pub fn menu_bar<'a>( menu::items( key_binds, vec![ - Item::Button(fl!("add-account"), None, MenuAction::AddAccount), + if accounts_present && matches!(app_state, ApplicationState::Ready) + || matches!(app_state, ApplicationState::NoEnabledAccounts) + { + Item::Button(fl!("add-account"), None, MenuAction::AddAccount) + } else { + Item::ButtonDisabled(fl!("add-account"), None, MenuAction::AddAccount) + }, if accounts_present && matches!(app_state, ApplicationState::Ready) { Item::Button(fl!("add-bookmark"), None, MenuAction::AddBookmark) } else { diff --git a/src/app/nav.rs b/src/app/nav.rs index b207039..16fc177 100644 --- a/src/app/nav.rs +++ b/src/app/nav.rs @@ -41,6 +41,7 @@ impl AppNavPage { app.sync_status, &app.accounts_cursor, &app.timeline, + app.operation_progress.as_ref(), ) .map(ApplicationAction::AccountsView), AppNavPage::BookmarksView => app @@ -51,6 +52,7 @@ impl AppNavPage { &app.bookmarks_cursor, app.accounts_cursor.total_entries == 0, &app.timeline, + app.operation_progress.as_ref(), ) .map(ApplicationAction::BookmarksView), } diff --git a/src/http/mod.rs b/src/http/mod.rs index 2da0e46..8ee9e49 100644 --- a/src/http/mod.rs +++ b/src/http/mod.rs @@ -23,29 +23,21 @@ use std::{ }; use urlencoding::encode; -pub async fn fetch_bookmarks_from_all_accounts(accounts: Vec) -> Vec { - let mut all_responses: Vec = Vec::new(); - for account in accounts { - if account.enabled { - match fetch_bookmarks_for_account(&account).await { - Ok(new_response) => { - all_responses.push(new_response); - } - Err(e) => { - let epoch_timestamp = SystemTime::now() - .duration_since(UNIX_EPOCH) - .expect("") - .as_secs(); - #[allow(clippy::cast_possible_wrap)] - let error_response = - DetailedResponse::new(account, epoch_timestamp as i64, false, None); - all_responses.push(error_response); - log::error!("Error fetching bookmarks: {e}"); - } - } +pub async fn fetch_bookmarks_for_single_account(account: Account) -> DetailedResponse { + match fetch_bookmarks_for_account(&account).await { + Ok(response) => response, + Err(e) => { + let epoch_timestamp = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("") + .as_secs(); + #[allow(clippy::cast_possible_wrap)] + let error_response = + DetailedResponse::new(account, epoch_timestamp as i64, false, None); + log::error!("Error fetching bookmarks: {e}"); + error_response } } - all_responses } // NOTE: (vkhitrin) perhaps this method should be split into three: @@ -273,6 +265,7 @@ pub async fn populate_bookmark( account: Account, bookmark: Bookmark, check_remote: bool, + disable_scraping: bool, ) -> Option { let rest_api_url: String = account.instance.clone() + "/api/bookmarks/"; let mut headers = HeaderMap::new(); @@ -304,7 +297,7 @@ pub async fn populate_bookmark( let bookmark_url = parse_serde_json_value_to_raw_string(transformed_json_value.get("url").unwrap()); if check_remote { - match check_bookmark_on_instance(&account, bookmark_url.clone()).await { + match check_bookmark_on_instance(&account, bookmark_url.clone(), disable_scraping).await { Ok(check) => { let metadata = check.metadata; if check.bookmark.is_some() { @@ -354,45 +347,92 @@ pub async fn populate_bookmark( api_response.bookmark = Some(bookmark); } if api_response.is_new { - let response: reqwest::Response = http_client - .post(rest_api_url) - .headers(headers) - .json(&transformed_json_value) - .send() - .await - .unwrap(); + let max_retries = 3; + let mut retry_count = 0; + let mut last_error: Option = None; - match response.status() { - StatusCode::CREATED => match response.json::().await { - Ok(mut value) => { - value.linkding_internal_id = value.id; - value.user_account_id = account.id; - value.id = None; - api_response.bookmark = Some(value); - api_response.successful = true; - } - Err(_e) => api_response.error = Some(fl!("failed-to-parse-response")), - }, - status => { - api_response.error = Some(fl!( - "http-error", - http_rc = status.to_string(), - http_err = response.text().await.unwrap() - )); - log::error!( - "Error adding bookmark: {}", - api_response.error.as_ref().unwrap() + while retry_count <= max_retries { + if retry_count > 0 { + let backoff_ms = 1000 * u64::pow(2, retry_count - 1); + log::warn!( + "Retrying bookmark creation (attempt {}/{}) after {}ms backoff", + retry_count, + max_retries, + backoff_ms ); + tokio::time::sleep(Duration::from_millis(backoff_ms)).await; + } + + let response_result = http_client + .post(&rest_api_url) + .headers(headers.clone()) + .json(&transformed_json_value) + .send() + .await; + + match response_result { + Ok(response) => match response.status() { + StatusCode::CREATED => match response.json::().await { + Ok(mut value) => { + value.linkding_internal_id = value.id; + value.user_account_id = account.id; + value.id = None; + api_response.bookmark = Some(value); + api_response.successful = true; + break; + } + Err(_e) => { + api_response.error = Some(fl!("failed-to-parse-response")); + break; + } + }, + StatusCode::SERVICE_UNAVAILABLE => { + let error_msg = response.text().await.unwrap_or_default(); + last_error = Some(fl!( + "http-error", + http_rc = StatusCode::SERVICE_UNAVAILABLE.to_string(), + http_err = error_msg + )); + log::error!( + "Error adding bookmark (503): {}", + last_error.as_ref().unwrap() + ); + retry_count += 1; + } + status => { + api_response.error = Some(fl!( + "http-error", + http_rc = status.to_string(), + http_err = response.text().await.unwrap_or_default() + )); + log::error!( + "Error adding bookmark: {}", + api_response.error.as_ref().unwrap() + ); + break; + } + }, + Err(e) => { + api_response.error = Some(format!("Request failed: {e}")); + log::error!("Error sending request: {e}"); + break; + } } } - } else { - match edit_bookmark(&account, &api_response.bookmark.clone().unwrap().clone()).await { + + if retry_count > max_retries && api_response.error.is_none() { + api_response.error = last_error; + } + } else if let Some(bookmark) = &api_response.bookmark { + match edit_bookmark(&account, bookmark).await { Ok(value) => { api_response.bookmark = Some(value); api_response.successful = true; } Err(_e) => api_response.error = Some(fl!("failed-to-parse-response")), } + } else { + api_response.error = Some(fl!("failed-to-parse-response")); } Some(api_response) } @@ -576,15 +616,26 @@ pub async fn check_account_on_instance( pub async fn check_bookmark_on_instance( account: &Account, url: String, + disable_scraping: bool, ) -> Result> { let mut rest_api_url: String = String::new(); let encoded_bookmark_url = encode(&url); - write!( - &mut rest_api_url, - "{}/api/bookmarks/check/?url={}", - account.instance, encoded_bookmark_url - ) - .unwrap(); + + if disable_scraping { + write!( + &mut rest_api_url, + "{}/api/bookmarks/check/?url={}&disable_scraping=true", + account.instance, encoded_bookmark_url + ) + .unwrap(); + } else { + write!( + &mut rest_api_url, + "{}/api/bookmarks/check/?url={}", + account.instance, encoded_bookmark_url + ) + .unwrap(); + } let mut headers = HeaderMap::new(); let http_client = ClientBuilder::new() .danger_accept_invalid_certs(account.trust_invalid_certs) diff --git a/src/main.rs b/src/main.rs index 4abf557..0aed982 100644 --- a/src/main.rs +++ b/src/main.rs @@ -6,6 +6,7 @@ mod models; mod pages; mod style; mod utils; +mod widgets; use core::settings; diff --git a/src/models/mod.rs b/src/models/mod.rs index 9e1321c..646eb12 100644 --- a/src/models/mod.rs +++ b/src/models/mod.rs @@ -2,4 +2,5 @@ pub mod account; pub mod bookmarks; pub mod db_cursor; pub mod favicon_cache; +pub mod operation; pub mod sync_status; diff --git a/src/models/operation.rs b/src/models/operation.rs new file mode 100644 index 0000000..5de89d6 --- /dev/null +++ b/src/models/operation.rs @@ -0,0 +1,8 @@ +#[derive(Debug, Clone)] +pub struct OperationProgress { + pub operation_id: u64, + pub total: usize, + pub current: usize, + pub operation_label: String, + pub cancellable: bool, +} diff --git a/src/pages/accounts.rs b/src/pages/accounts.rs index 18601d4..126eb0d 100644 --- a/src/pages/accounts.rs +++ b/src/pages/accounts.rs @@ -4,8 +4,12 @@ use crate::app::{ }; use crate::fl; use crate::{ - models::{account::Account, db_cursor::AccountsPaginationCursor, sync_status::SyncStatus}, + models::{ + account::Account, db_cursor::AccountsPaginationCursor, operation::OperationProgress, + sync_status::SyncStatus, + }, style::button::ButtonStyle, + widgets::progress_info::{operation_progress_widget, ProgressInfo}, }; use chrono::{DateTime, Local}; use cosmic::{ @@ -33,8 +37,15 @@ impl PageAccountsView { sync_status: SyncStatus, accounts_cursor: &AccountsPaginationCursor, refresh_animation: &Timeline, + operation_progress: Option<&OperationProgress>, ) -> Element<'_, AccountsAction> { let spacing = theme::active().cosmic().spacing; + let add_button = match app_state { + ApplicationState::Ready | ApplicationState::NoEnabledAccounts => { + widget::button::standard(fl!("add-account")).on_press(AccountsAction::AddAccount) + } + _ => widget::button::standard(fl!("add-account")), + }; if self.accounts.is_empty() { let container = widget::container( widget::column::with_children(vec![ @@ -42,9 +53,7 @@ impl PageAccountsView { .size(64) .into(), widget::text::title3(fl!("no-accounts")).into(), - widget::button::standard(fl!("add-account")) - .on_press(AccountsAction::AddAccount) - .into(), + add_button.into(), ]) .spacing(20) .align_x(Alignment::Center), @@ -300,7 +309,7 @@ impl PageAccountsView { }, }; - widget::container( + let mut main_column = widget::column::with_children(vec![widget::row::with_capacity(2) .align_y(Alignment::Center) .push(widget::text::title3(fl!( @@ -316,17 +325,37 @@ impl PageAccountsView { ]) .push(animation_widget) .push(widget::horizontal_space()) - .push( - widget::button::standard(fl!("add-account")) - .on_press(AccountsAction::AddAccount), - ) + .push(add_button) .width(Length::Fill) .apply(widget::container) - .into()]) + .into()]); + + // Add operation progress widget if any operation is active + if let Some(progress) = operation_progress { + let progress_info = ProgressInfo { + total: progress.total, + current: progress.current, + label: progress.operation_label.clone(), + cancellable: progress.cancellable, + }; + + let progress_widget = operation_progress_widget( + &progress_info, + if progress.cancellable { + Some(AccountsAction::CancelImport(progress.operation_id)) + } else { + None + }, + ); + + main_column = main_column.push(progress_widget); + } + + main_column = main_column .push(accounts_widget) - .push(page_navigation_widget), - ) - .into() + .push(page_navigation_widget); + + widget::container(main_column).into() } } pub fn update(&mut self, message: AccountsAction) -> Task { @@ -337,6 +366,11 @@ impl PageAccountsView { cosmic::Action::App(ApplicationAction::AddAccountForm) })); } + AccountsAction::CancelImport(import_id) => { + commands.push(Task::perform(async {}, move |()| { + cosmic::Action::App(ApplicationAction::CancelImportBookmarks(import_id)) + })); + } AccountsAction::EditAccount(account) => { self.account_placeholder = Some(account.clone()); commands.push(Task::perform(async {}, move |()| { diff --git a/src/pages/bookmarks.rs b/src/pages/bookmarks.rs index b0b4349..b45624b 100644 --- a/src/pages/bookmarks.rs +++ b/src/pages/bookmarks.rs @@ -6,9 +6,10 @@ use crate::{ fl, models::{ account::Account, bookmarks::Bookmark, db_cursor::BookmarksPaginationCursor, - sync_status::SyncStatus, + operation::OperationProgress, sync_status::SyncStatus, }, style::{button::ButtonStyle, text_editor::text_editor_class}, + widgets::progress_info::{operation_progress_widget, ProgressInfo}, }; use chrono::{DateTime, Local}; use cosmic::{ @@ -39,6 +40,7 @@ impl PageBookmarksView { bookmarks_cursor: &BookmarksPaginationCursor, no_accounts: bool, refresh_animation: &Timeline, + operation_progress: Option<&OperationProgress>, ) -> Element<'_, BookmarksAction> { let spacing = theme::active().cosmic().spacing; if no_accounts { @@ -385,7 +387,7 @@ impl PageBookmarksView { .into(), ])); - widget::container( + let mut main_column = widget::column::with_children(vec![widget::row::with_capacity(2) .align_y(Alignment::Center) .push(widget::text::title3(fl!( @@ -405,11 +407,34 @@ impl PageBookmarksView { .push(new_bookmark_button) .width(Length::Fill) .apply(widget::container) - .into()]) + .into()]); + + // Add operation progress widget if any operation is active + if let Some(progress) = operation_progress { + let progress_info = ProgressInfo { + total: progress.total, + current: progress.current, + label: progress.operation_label.clone(), + cancellable: progress.cancellable, + }; + + let progress_widget = operation_progress_widget( + &progress_info, + if progress.cancellable { + Some(BookmarksAction::CancelImport(progress.operation_id)) + } else { + None + }, + ); + + main_column = main_column.push(progress_widget); + } + + main_column = main_column .push(bookmarks_widget) - .push(page_navigation_widget), - ) - .into() + .push(page_navigation_widget); + + widget::container(main_column).into() } } pub fn update(&mut self, message: BookmarksAction) -> Task { @@ -430,6 +455,11 @@ impl PageBookmarksView { cosmic::Action::App(ApplicationAction::AddBookmarkForm) })); } + BookmarksAction::CancelImport(import_id) => { + commands.push(Task::perform(async {}, move |()| { + cosmic::Action::App(ApplicationAction::CancelImportBookmarks(import_id)) + })); + } BookmarksAction::DeleteBookmark(account_id, bookmark) => { commands.push(Task::perform(async {}, move |()| { cosmic::Action::App(ApplicationAction::OpenRemoveBookmarkDialog( @@ -561,12 +591,16 @@ where } else { fl!("shared-disabled") }); - let buttons_widget_container = - widget::container(widget::button::standard(fl!("save")).on_press( - ApplicationAction::StartAddBookmark(accounts[selected_account_index].clone(), bookmark), - )) - .width(Length::Fill) - .align_x(Alignment::Center); + let buttons_widget_container = widget::container( + widget::button::standard(fl!("save")).on_press(ApplicationAction::StartAddBookmark( + accounts[selected_account_index].clone(), + bookmark, + None, + Vec::new(), + )), + ) + .width(Length::Fill) + .align_x(Alignment::Center); widget::column() .spacing(space_xxs) diff --git a/src/widgets/mod.rs b/src/widgets/mod.rs new file mode 100644 index 0000000..e45e2d2 --- /dev/null +++ b/src/widgets/mod.rs @@ -0,0 +1 @@ +pub mod progress_info; diff --git a/src/widgets/progress_info.rs b/src/widgets/progress_info.rs new file mode 100644 index 0000000..23c29e8 --- /dev/null +++ b/src/widgets/progress_info.rs @@ -0,0 +1,58 @@ +use cosmic::{ + iced::{Alignment, Length}, + theme, widget, Apply, Element, +}; + +#[derive(Debug, Clone)] +pub struct ProgressInfo { + pub total: usize, + pub current: usize, + pub label: String, + pub cancellable: bool, +} + +pub fn operation_progress_widget<'a, Message: 'a + 'static + Clone>( + progress: &ProgressInfo, + on_cancel: Option, +) -> Element<'a, Message> { + let spacing = theme::active().cosmic().spacing; + let progress_text = format!( + "{}: {} / {}", + progress.label, + progress.current + 1, + progress.total + ); + let progress_percentage = ((progress.current + 1) as f32 / progress.total as f32) * 100.0; + + let mut row = widget::row::with_capacity(3) + .spacing(spacing.space_xxs) + .padding([ + spacing.space_none, + spacing.space_none, + spacing.space_xxs, + spacing.space_none, + ]) + .align_y(Alignment::Center) + .push(widget::text::body(progress_text).size(13)) + .push(widget::horizontal_space()); + + if progress.cancellable { + if let Some(cancel_action) = on_cancel { + row = row.push( + widget::button::text("Cancel") + .on_press(cancel_action) + .class(cosmic::theme::Button::Destructive) + .padding([spacing.space_xxxs, spacing.space_xxs]) + .font_size(12), + ); + } + } + + widget::column::with_capacity(2) + .spacing(spacing.space_xxxs) + .push(row) + .push(widget::progress_bar(0.0..=100.0, progress_percentage).height(Length::Fixed(4.0))) + .apply(widget::container) + .class(cosmic::theme::Container::Background) + .into() +}