From 7404e3bdba05d48130c454b451dd868acdc39f31 Mon Sep 17 00:00:00 2001 From: Vadim Khitrin Date: Sat, 30 Nov 2024 06:16:15 +0200 Subject: [PATCH] feat: Add Sync Status To Accounts Page (#12) --- Cargo.lock | 2 +- README.md | 3 +- i18n/en/cosmicding.ftl | 4 ++ src/app.rs | 81 ++++++++++++++++++++++++++--------------- src/db/mod.rs | 49 ++++++++++++++++++------- src/http/mod.rs | 51 ++++++++++++++++---------- src/models/bookmarks.rs | 25 +++++++++++++ src/pages/accounts.rs | 48 ++++++++++++++++++++++-- 8 files changed, 195 insertions(+), 68 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 9cacefc..85feb89 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1,6 +1,6 @@ # This file is automatically @generated by Cargo. # It is not intended for manual editing. -version = 3 +version = 4 [[package]] name = "ab_glyph" diff --git a/README.md b/README.md index 7b95f07..77e76d7 100644 --- a/README.md +++ b/README.md @@ -65,12 +65,12 @@ Potential improvements: - [Performance] Check performance with high amount of bookmarks spread across multiple instances. - [Application] Refactor codebase to be more organized. - [Application] Allow user-provided TLS certificate. -- [UI] Visual indicator for last sync status. - [UI] Indicators for `archived`, `unread`, `shared` bookmarks. - [UI] Display account information in accounts page. - [Distribution] Flatpack release. - [Distribution] compiled binary in GitHub release. - [UI] Sort bookmarks. +- [UI] Improve `Accounts` page view. Things to consider: @@ -79,6 +79,7 @@ Things to consider: - [UI] Pagination (if possible). - [Application] Consider leveraging linkding's `/check` endpoint when adding bookmarks. - [Application] Do not block on when executing local database queries. +- [UI] Loading indicator when performing long HTTP calls. ## Thanks diff --git a/i18n/en/cosmicding.ftl b/i18n/en/cosmicding.ftl index adbd2ee..f84043b 100644 --- a/i18n/en/cosmicding.ftl +++ b/i18n/en/cosmicding.ftl @@ -23,6 +23,7 @@ edit-account = Edit Account edit-bookmark = Edit Bookmark enabled-public-sharing = Public bookmarks sharing enabled enabled-sharing = Bookmarks sharing enabled +failed = failed failed-to-find-linkding-api-endpoint = Failed to find linkding API endpoint failed-to-parse-response = Failed to parse response file = File @@ -30,6 +31,8 @@ git-description = Git commit {$hash} on {$date} http-error = HTTP error {$http_rc}: {$http_err} instance = Instance invalid-api-token = Invalid API token +last-sync-status = Last sync status +last-sync-time = Last sync time light = Light match-desktop = Match Desktop no-accounts = No accounts configured @@ -54,6 +57,7 @@ setting-managed-externally = This setting can only be managed from Linkding web settings = Settings shared = Shared shared-disabled = Shared (Disabled) +successful = successful tags = Tags tags-helper = Enter any number of tags separated by space. theme = Theme diff --git a/src/app.rs b/src/app.rs index 9923d63..909956a 100644 --- a/src/app.rs +++ b/src/app.rs @@ -4,7 +4,7 @@ use crate::fl; use crate::http::{self}; use crate::key_binds::key_binds; use crate::models::account::{Account, AccountApiResponse}; -use crate::models::bookmarks::Bookmark; +use crate::models::bookmarks::{Bookmark, DetailedResponse}; use crate::nav::NavPage; use crate::pages::accounts::{add_account, edit_account, AccountsMessage, AccountsView}; use crate::pages::bookmarks::{ @@ -73,9 +73,9 @@ pub enum Message { CompleteRemoveDialog(Account, Option), DialogCancel, DialogUpdate(DialogPage), - DoneRefreshAccountProfile(Account, AccountApiResponse), - DoneRefreshBookmarksForAccount(Account, Vec), - DoneRefreshBookmarksForAllAccounts(Vec), + DoneRefreshAccountProfile(Account, Option), + DoneRefreshBookmarksForAccount(Account, Vec), + DoneRefreshBookmarksForAllAccounts(Vec), EditAccount(Account), EditBookmark(Account, Bookmark), EmpptyMessage, @@ -293,7 +293,6 @@ impl Application for Cosmicding { struct ConfigSubscription; struct ThemeSubscription; - // FIXME: (vkhitrin) key bindings are not working properly let subscriptions = vec![ event::listen_with(|event, status, _| match event { Event::Keyboard(KeyEvent::KeyPressed { key, modifiers, .. }) => match status { @@ -518,7 +517,7 @@ impl Application for Cosmicding { Message::BookmarksView(message) => commands.push(self.bookmarks_view.update(message)), Message::StartRefreshBookmarksForAllAccounts => { if !self.bookmarks_view.bookmarks.is_empty() { - let message = |x: Vec| { + let message = |x: Vec| { cosmic::app::Message::App(Message::DoneRefreshBookmarksForAllAccounts(x)) }; if !self.accounts_view.accounts.is_empty() { @@ -536,24 +535,34 @@ impl Application for Cosmicding { } } } - Message::DoneRefreshBookmarksForAllAccounts(remote_bookmarks) => { - block_on(async { - db::SqliteDatabase::cache_all_bookmarks(&mut self.db, &remote_bookmarks).await - }); + Message::DoneRefreshBookmarksForAllAccounts(remote_responses) => { + for response in remote_responses { + block_on(async { + db::SqliteDatabase::cache_bookmarks_for_acount( + &mut self.db, + &response.account, + response.bookmarks.unwrap_or_else(|| Vec::new()), + response.timestamp, + response.successful, + ) + .await + }); + } + commands.push(self.update(Message::LoadAccounts)); commands.push(self.update(Message::LoadBookmarks)); } Message::StartRefreshBookmarksForAccount(account) => { let mut acc_vec = self.accounts_view.accounts.clone(); acc_vec.retain(|acc| acc.id == account.id); let borrowed_acc = acc_vec[0].clone(); - let message = move |x: Vec| { + let message = move |bookmarks: Vec| { cosmic::app::Message::App(Message::DoneRefreshBookmarksForAccount( borrowed_acc.clone(), - x, + bookmarks, )) }; commands.push(self.update(Message::StartRefreshAccountProfile(account.clone()))); - if !self.accounts_view.accounts.is_empty() { + if !acc_vec.is_empty() { commands.push(Task::perform( http::fetch_bookmarks_from_all_accounts(acc_vec.clone()), message, @@ -568,34 +577,46 @@ impl Application for Cosmicding { ); } } - Message::DoneRefreshBookmarksForAccount(account, remote_bookmarks) => { - block_on(async { - db::SqliteDatabase::cache_bookmarks_for_acount( - &mut self.db, - &account, - remote_bookmarks, - ) - .await - }); + Message::DoneRefreshBookmarksForAccount(account, remote_responses) => { + for response in remote_responses { + block_on(async { + db::SqliteDatabase::cache_bookmarks_for_acount( + &mut self.db, + &account, + response.bookmarks.unwrap_or_else(|| Vec::new()), + response.timestamp, + response.successful, + ) + .await + }); + } + commands.push(self.update(Message::LoadAccounts)); commands.push(self.update(Message::LoadBookmarks)); } Message::StartRefreshAccountProfile(account) => { let borrowed_acc = account.clone(); - let message = move |r: Option| { + let message = move |api_response: Option| { cosmic::app::Message::App(Message::DoneRefreshAccountProfile( borrowed_acc.clone(), - r.unwrap(), + api_response, )) }; commands.push(Task::perform(http::fetch_account_details(account), message)); } Message::DoneRefreshAccountProfile(mut account, account_details) => { - account.enable_sharing = account_details.enable_sharing; - account.enable_public_sharing = account_details.enable_public_sharing; - block_on(async { - db::SqliteDatabase::update_account(&mut self.db, &account).await - }); - commands.push(self.update(Message::LoadAccounts)); + if !account_details.is_none() { + let details = account_details.unwrap(); + account.enable_sharing = details.enable_sharing; + account.enable_public_sharing = details.enable_public_sharing; + block_on(async { + db::SqliteDatabase::update_account(&mut self.db, &account).await + }); + commands.push(self.update(Message::LoadAccounts)); + } else { + block_on(async { + db::SqliteDatabase::update_account(&mut self.db, &account).await + }); + } } Message::AddBookmarkForm => { if !self.accounts_view.accounts.is_empty() { diff --git a/src/db/mod.rs b/src/db/mod.rs index 1c1be5d..3775437 100644 --- a/src/db/mod.rs +++ b/src/db/mod.rs @@ -111,27 +111,41 @@ impl SqliteDatabase { .await .unwrap(); } + //NOTE: (vkhitrin) at the moment, this function is no longer required. + // Perhaps it should be removed/refactored. //TODO: (vkhitrin) this is a dumb "cache" that wipes all previous entries. // It should be improved in the future. - pub async fn cache_all_bookmarks(&mut self, bookmarks: &Vec) { - let truncate_query: &str = "DELETE FROM Bookmarks;"; - sqlx::query(truncate_query) - .execute(&mut self.conn) - .await - .unwrap(); - if !bookmarks.is_empty() { - for bookmark in bookmarks { - self.add_bookmark(&bookmark).await; - } - } - } + //pub async fn cache_all_bookmarks(&mut self, bookmarks: &Vec, epoch_timestamp: i64) { + // let truncate_query: &str = "DELETE FROM Bookmarks;"; + // let update_timestamp_query = + // "UPDATE UserAccounts SET last_sync_status=$1, last_sync_timestamp=$2"; + // sqlx::query(truncate_query) + // .execute(&mut self.conn) + // .await + // .unwrap(); + // if !bookmarks.is_empty() { + // for bookmark in bookmarks { + // self.add_bookmark(&bookmark).await; + // } + // } + // sqlx::query(update_timestamp_query) + // .bind(1) + // .bind(epoch_timestamp) + // .execute(&mut self.conn) + // .await + // .unwrap(); + //} pub async fn cache_bookmarks_for_acount( &mut self, account: &Account, bookmarks: Vec, + epoch_timestamp: i64, + response_successful: bool, ) { - let truncate_query: &str = "DELETE FROM Bookmarks where user_account_id = $1;"; - sqlx::query(truncate_query) + let delete_query: &str = "DELETE FROM Bookmarks where user_account_id = $1;"; + let update_timestamp_query = + "UPDATE UserAccounts SET last_sync_status=$2, last_sync_timestamp=$3 WHERE id=$1"; + sqlx::query(delete_query) .bind(account.id) .execute(&mut self.conn) .await @@ -141,6 +155,13 @@ impl SqliteDatabase { self.add_bookmark(&bookmark).await; } } + sqlx::query(update_timestamp_query) + .bind(account.id) + .bind(response_successful) + .bind(epoch_timestamp) + .execute(&mut self.conn) + .await + .unwrap(); } pub async fn add_bookmark(&mut self, bookmark: &Bookmark) { let query: &str = r#" diff --git a/src/http/mod.rs b/src/http/mod.rs index fc57a48..0872975 100644 --- a/src/http/mod.rs +++ b/src/http/mod.rs @@ -1,32 +1,42 @@ use crate::fl; use crate::models::account::{Account, AccountApiResponse}; -use crate::models::bookmarks::{Bookmark, BookmarksApiResponse}; +use crate::models::bookmarks::{Bookmark, BookmarksApiResponse, DetailedResponse}; use anyhow::Result; +use chrono::{DateTime, Utc}; use reqwest::{ header::{HeaderMap, HeaderValue, AUTHORIZATION}, ClientBuilder, StatusCode, }; use serde_json::Value; use std::fmt::Write; +use std::time::{SystemTime, UNIX_EPOCH}; -pub async fn fetch_bookmarks_from_all_accounts(accounts: Vec) -> Vec { - let mut all_bookmarks: Vec = Vec::new(); +pub async fn fetch_bookmarks_from_all_accounts(accounts: Vec) -> Vec { + let mut all_responses: Vec = Vec::new(); for account in accounts { match fetch_bookmarks_for_account(&account).await { - Ok(new_bookmarks) => { - all_bookmarks.extend(new_bookmarks); + Ok(new_response) => { + all_responses.push(new_response); } Err(e) => { + let epoch_timestamp = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("") + .as_secs(); + let error_response = + DetailedResponse::new(account, epoch_timestamp as i64, false, None); + all_responses.push(error_response); log::error!("Error fetching bookmarks: {}", e); } } } - return all_bookmarks; + return all_responses; } pub async fn fetch_bookmarks_for_account( account: &Account, -) -> Result, Box> { +) -> Result> { + let mut detailed_response = DetailedResponse::new(account.clone(), 0, false, None); let mut bookmarks: Vec = Vec::new(); let mut headers = HeaderMap::new(); let rest_api_url: String = account.instance.clone() + "/api/bookmarks/"; @@ -43,7 +53,12 @@ pub async fn fetch_bookmarks_for_account( .headers(headers) .send() .await?; + let parsed_date = response.headers().get("Date").unwrap().to_str().unwrap(); if response.status().is_success() { + detailed_response.successful = true; + let date: DateTime = DateTime::parse_from_rfc2822(parsed_date)?.with_timezone(&Utc); + let unix_timestamp = SystemTime::from(date).duration_since(UNIX_EPOCH)?.as_secs(); + detailed_response.timestamp = unix_timestamp as i64; let response_json = response.json::().await; // Handle the Result match response_json { @@ -72,6 +87,7 @@ pub async fn fetch_bookmarks_for_account( ); bookmarks.push(transformed_bookmark); } + detailed_response.bookmarks = Some(bookmarks); } Err(e) => { log::error!("Error parsing JSON: {:?}", e); @@ -84,7 +100,7 @@ pub async fn fetch_bookmarks_for_account( response.text().await ); } - Ok(bookmarks) + Ok(detailed_response) } pub async fn add_bookmark( @@ -237,17 +253,14 @@ pub async fn edit_bookmark( fl!("failed-to-parse-response"), ))), }, - status => { - // Return an error with the status code - Err(Box::new(std::io::Error::new( - std::io::ErrorKind::Other, - fl!( - "http-error", - http_rc = status.to_string(), - http_err = response.text().await.unwrap() - ), - ))) - } + status => Err(Box::new(std::io::Error::new( + std::io::ErrorKind::Other, + fl!( + "http-error", + http_rc = status.to_string(), + http_err = response.text().await.unwrap() + ), + ))), } } diff --git a/src/models/bookmarks.rs b/src/models/bookmarks.rs index 64dcb58..a5d11c8 100644 --- a/src/models/bookmarks.rs +++ b/src/models/bookmarks.rs @@ -1,3 +1,4 @@ +use crate::models::account::Account; use serde::{Deserialize, Serialize}; #[derive(Debug, Clone, Serialize, Deserialize, Eq, PartialEq)] pub struct Bookmark { @@ -71,3 +72,27 @@ pub struct BookmarksApiResponse { pub previous: Option, pub results: Vec, } + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DetailedResponse { + pub account: Account, + pub timestamp: i64, + pub successful: bool, + pub bookmarks: Option>, +} + +impl DetailedResponse { + pub fn new( + response_account: Account, + response_timestamp: i64, + response_successful: bool, + response_bookmarks: Option>, + ) -> Self { + Self { + account: response_account, + timestamp: response_timestamp, + successful: response_successful, + bookmarks: response_bookmarks, + } + } +} diff --git a/src/pages/accounts.rs b/src/pages/accounts.rs index 28b7127..cda5419 100644 --- a/src/pages/accounts.rs +++ b/src/pages/accounts.rs @@ -1,6 +1,7 @@ use crate::app::Message; use crate::fl; use crate::models::account::Account; +use chrono::{DateTime, Local}; use cosmic::iced::Length; use cosmic::iced_widget::tooltip; use cosmic::{ @@ -67,8 +68,11 @@ impl AccountsView { .spacing(spacing.space_xxxs) .padding([spacing.space_none, spacing.space_xxs]); - // TODO: (vkhitrin) Implement visual indicator for last sync status. for item in self.accounts.iter() { + let local_time: DateTime = DateTime::from( + DateTime::from_timestamp(item.last_sync_timestamp.clone(), 0).expect(""), + ); + // Mandatory first row - details let mut columns = Vec::new(); columns.push( @@ -89,8 +93,46 @@ impl AccountsView { .align_y(Alignment::Center) .into(), ); - // Mandatory third row - actions - let actions_row = widget::row::with_capacity(1) + // Mandatory second row - sync status + columns.push( + widget::row::with_capacity(1) + .spacing(spacing.space_xxs) + .padding([ + spacing.space_xxxs, + spacing.space_xxs, + spacing.space_xxs, + spacing.space_xxxs, + ]) + .push(widget::text::body(format!( + "{}: {}", + fl!("last-sync-status"), + if item.last_sync_status.clone() { + fl!("successful") + } else { + fl!("failed") + } + ))) + .into(), + ); + // Mandatory third row - sync timestamp + columns.push( + widget::row::with_capacity(1) + .spacing(spacing.space_xxs) + .padding([ + spacing.space_xxxs, + spacing.space_xxs, + spacing.space_xxs, + spacing.space_xxxs, + ]) + .push(widget::text::body(format!( + "{}: {}", + fl!("last-sync-time"), + local_time.to_rfc2822() + ))) + .into(), + ); + // Mandatory fourth row - actions + let actions_row = widget::row::with_capacity(3) .spacing(spacing.space_xxs) .push( widget::button::link(fl!("refresh"))