Skip to content

Commit

Permalink
feat: Add Sync Status To Accounts Page (#12)
Browse files Browse the repository at this point in the history
vkhitrin authored Nov 30, 2024

Verified

This commit was created on GitHub.com and signed with GitHub’s verified signature.
1 parent aae33c9 commit 7404e3b
Showing 8 changed files with 195 additions and 68 deletions.
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -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

4 changes: 4 additions & 0 deletions i18n/en/cosmicding.ftl
Original file line number Diff line number Diff line change
@@ -23,13 +23,16 @@ 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
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
81 changes: 51 additions & 30 deletions src/app.rs
Original file line number Diff line number Diff line change
@@ -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<Bookmark>),
DialogCancel,
DialogUpdate(DialogPage),
DoneRefreshAccountProfile(Account, AccountApiResponse),
DoneRefreshBookmarksForAccount(Account, Vec<Bookmark>),
DoneRefreshBookmarksForAllAccounts(Vec<Bookmark>),
DoneRefreshAccountProfile(Account, Option<AccountApiResponse>),
DoneRefreshBookmarksForAccount(Account, Vec<DetailedResponse>),
DoneRefreshBookmarksForAllAccounts(Vec<DetailedResponse>),
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<Bookmark>| {
let message = |x: Vec<DetailedResponse>| {
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<Bookmark>| {
let message = move |bookmarks: Vec<DetailedResponse>| {
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<AccountApiResponse>| {
let message = move |api_response: Option<AccountApiResponse>| {
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() {
49 changes: 35 additions & 14 deletions src/db/mod.rs
Original file line number Diff line number Diff line change
@@ -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<Bookmark>) {
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<Bookmark>, 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<Bookmark>,
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#"
51 changes: 32 additions & 19 deletions src/http/mod.rs
Original file line number Diff line number Diff line change
@@ -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<Account>) -> Vec<Bookmark> {
let mut all_bookmarks: Vec<Bookmark> = Vec::new();
pub async fn fetch_bookmarks_from_all_accounts(accounts: Vec<Account>) -> Vec<DetailedResponse> {
let mut all_responses: Vec<DetailedResponse> = 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<Vec<Bookmark>, Box<dyn std::error::Error>> {
) -> Result<DetailedResponse, Box<dyn std::error::Error>> {
let mut detailed_response = DetailedResponse::new(account.clone(), 0, false, None);
let mut bookmarks: Vec<Bookmark> = 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<Utc> = 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::<BookmarksApiResponse>().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()
),
))),
}
}

25 changes: 25 additions & 0 deletions src/models/bookmarks.rs
Original file line number Diff line number Diff line change
@@ -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<String>,
pub results: Vec<Bookmark>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DetailedResponse {
pub account: Account,
pub timestamp: i64,
pub successful: bool,
pub bookmarks: Option<Vec<Bookmark>>,
}

impl DetailedResponse {
pub fn new(
response_account: Account,
response_timestamp: i64,
response_successful: bool,
response_bookmarks: Option<Vec<Bookmark>>,
) -> Self {
Self {
account: response_account,
timestamp: response_timestamp,
successful: response_successful,
bookmarks: response_bookmarks,
}
}
}
48 changes: 45 additions & 3 deletions src/pages/accounts.rs
Original file line number Diff line number Diff line change
@@ -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<Local> = 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"))

0 comments on commit 7404e3b

Please sign in to comment.