Skip to content

Commit

Permalink
chore: Utilize /bookmarks/check API Endpoint
Browse files Browse the repository at this point in the history
Before adding a bookmark, use `/bookmarks/check` endpoint to check if a
bookmark already exists.
If it exists, update the current entry. Resolves #40.

Bump dependencies.
  • Loading branch information
vkhitrin committed Feb 22, 2025
1 parent 88cf08f commit da4dd9e
Show file tree
Hide file tree
Showing 11 changed files with 287 additions and 111 deletions.
123 changes: 62 additions & 61 deletions Cargo.lock

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ serde = "1.0.210"
serde_json = "1.0.128"
sqlx = { version = "0.8.2", features = ["sqlite", "runtime-tokio", "chrono"] }
tokio = { version = "1.40.0", features = ["full"] }
urlencoding = "2.1.3"

[dependencies.i18n-embed]
version = "0.15"
Expand Down
2 changes: 1 addition & 1 deletion i18n/en/cosmicding.ftl
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ token = Token
unexpected-http-return-code = Unexpected HTTP return code {$http_rc}
unread = Unread
updated-account = Updated account {$acc}
updated-bookmark-in-account = Updated bookmark in account {$acc}
updated-bookmark-in-account = Updated bookmark {$bkmrk} in account {$acc}
url = URL
view = View
yes = Yes
2 changes: 1 addition & 1 deletion i18n/sv/cosmicding.ftl
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ token = Token
unexpected-http-return-code = Oväntad HTTP-returkod {$http_rc}
unread = Oläst
updated-account = Uppdaterat konto {$acc}
updated-bookmark-in-account = Uppdaterat bokmärke på kontot {$acc}
updated-bookmark-in-account = Uppdaterat bokmärke {$bkmrk} på kontot {$acc}
url = URL
view = Visa
yes = Ja
57 changes: 36 additions & 21 deletions src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -938,20 +938,34 @@ impl Application for Cosmicding {
}
Message::AddBookmark(account, bookmark) => {
let mut new_bkmrk: Option<Bookmark> = None;
let mut bookmark_exists = false;
if let Some(ref mut database) = &mut self.bookmarks_cursor.database {
block_on(async {
match http::add_bookmark(&account, &bookmark).await {
Ok(value) => {
new_bkmrk = Some(value);
commands.push(
self.toasts
.push(widget::toaster::Toast::new(fl!(
"added-bookmark-to-account",
bkmrk = bookmark.url.clone(),
acc = account.display_name.clone()
)))
.map(cosmic::app::Message::App),
);
new_bkmrk = Some(value.bookmark);
if value.is_new {
commands.push(
self.toasts
.push(widget::toaster::Toast::new(fl!(
"added-bookmark-to-account",
bkmrk = bookmark.url.clone(),
acc = account.display_name.clone()
)))
.map(cosmic::app::Message::App),
);
} else {
bookmark_exists = true;
commands.push(
self.toasts
.push(widget::toaster::Toast::new(fl!(
"updated-bookmark-in-account",
bkmrk = bookmark.url,
acc = account.display_name.clone()
)))
.map(cosmic::app::Message::App),
);
}
}
Err(e) => {
log::error!("Error adding bookmark: {}", e);
Expand All @@ -964,15 +978,18 @@ impl Application for Cosmicding {
}
});
if let Some(bkmrk) = new_bkmrk {
block_on(async {
db::SqliteDatabase::add_bookmark(database, &bkmrk).await;
});
if bookmark_exists {
block_on(async {
db::SqliteDatabase::update_bookmark(database, &bkmrk, &bkmrk).await;
});
} else {
block_on(async {
db::SqliteDatabase::add_bookmark(database, &bkmrk).await;
});
}
commands.push(self.update(Message::LoadBookmarks));
}
};
block_on(async {
self.bookmarks_cursor.refresh_count().await;
});
self.core.window.show_context = false;
}
Message::RemoveBookmark(account_id, bookmark) => {
Expand Down Expand Up @@ -1013,10 +1030,7 @@ impl Application for Cosmicding {
db::SqliteDatabase::delete_bookmark(database, &bookmark).await;
});
}
block_on(async {
self.bookmarks_cursor.refresh_count().await;
self.bookmarks_cursor.fetch_next_results().await;
});
commands.push(self.update(Message::LoadBookmarks));
self.bookmarks_view.bookmarks = self.bookmarks_cursor.result.clone().unwrap();
self.core.window.show_context = false;
}
Expand Down Expand Up @@ -1048,6 +1062,7 @@ impl Application for Cosmicding {
self.toasts
.push(widget::toaster::Toast::new(fl!(
"updated-bookmark-in-account",
bkmrk = bookmark.url.clone(),
acc = account.display_name.clone()
)))
.map(cosmic::app::Message::App),
Expand All @@ -1071,7 +1086,7 @@ impl Application for Cosmicding {
.position(|x| x.id == bookmark.id)
.unwrap();
block_on(async {
db::SqliteDatabase::update_bookmark(database, &bookmark, &bkmrk).await;
db::SqliteDatabase::update_bookmark(database, &bkmrk, &bkmrk).await;
});
self.bookmarks_view.bookmarks[index] = bkmrk;
}
Expand Down
2 changes: 1 addition & 1 deletion src/db/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -328,7 +328,7 @@ impl SqliteDatabase {
.bind(&new_bookmark.date_modified)
.bind(&new_bookmark.website_title)
.bind(&new_bookmark.website_description)
.bind(old_bookmark.id)
.bind(old_bookmark.linkding_internal_id)
.execute(&self.conn)
.await
.unwrap();
Expand Down
167 changes: 141 additions & 26 deletions src/http/mod.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
use crate::fl;
use crate::models::account::{Account, LinkdingAccountApiResponse};
use crate::models::bookmarks::{Bookmark, DetailedResponse, LinkdingBookmarksApiResponse};
use crate::models::bookmarks::{
Bookmark, CheckDetailsResponse, DetailedResponse, LinkdingBookmarksApiCheckResponse,
LinkdingBookmarksApiResponse,
};
use crate::utils::json::parse_serde_json_value_to_raw_string;
use anyhow::Result;
use chrono::{DateTime, Utc};
use reqwest::{
Expand All @@ -10,6 +14,7 @@ use reqwest::{
use serde_json::Value;
use std::fmt::Write;
use std::time::{SystemTime, UNIX_EPOCH};
use urlencoding::encode;

pub async fn fetch_bookmarks_from_all_accounts(accounts: Vec<Account>) -> Vec<DetailedResponse> {
let mut all_responses: Vec<DetailedResponse> = Vec::new();
Expand Down Expand Up @@ -175,10 +180,11 @@ pub async fn fetch_bookmarks_for_account(
Ok(detailed_response)
}

#[allow(clippy::too_many_lines)]
pub async fn add_bookmark(
account: &Account,
bookmark: &Bookmark,
) -> Result<Bookmark, Box<dyn std::error::Error>> {
) -> Result<CheckDetailsResponse, Box<dyn std::error::Error>> {
let rest_api_url: String = account.instance.clone() + "/api/bookmarks/";
let mut headers = HeaderMap::new();
let http_client = ClientBuilder::new()
Expand All @@ -202,33 +208,95 @@ pub async fn add_bookmark(
obj.remove("date_added");
obj.remove("date_modified");
}
let response: reqwest::Response = http_client
.post(rest_api_url)
.headers(headers)
.json(&transformed_json_value)
.send()
.await?;
// NOTE: (vkhitrin) I was not able to get serde_json::value:RawValue to omit quotes
//let bookmark_url = transformed_json_value["url"].to_string().replace('"', "");
let bookmark_url =
parse_serde_json_value_to_raw_string(transformed_json_value.get("url").unwrap());
match check_bookmark_on_instance(account, bookmark_url.to_string()).await {
Ok(check) => {
let metadata = check.metadata;
if check.bookmark.is_some() {
let mut bkmrk = check.bookmark.unwrap();
bkmrk.linkding_internal_id = bkmrk.id;
bkmrk.user_account_id = account.id;
bkmrk.id = None;
if let Some(obj) = transformed_json_value.as_object() {
bkmrk.title = match parse_serde_json_value_to_raw_string(
transformed_json_value.get("title").unwrap(),
) {
ref s if !s.is_empty() => s.to_string(),
_ => metadata.title.unwrap(),
};
bkmrk.description = match parse_serde_json_value_to_raw_string(
transformed_json_value.get("description").unwrap(),
) {
ref s if !s.is_empty() => s.to_string(),
_ => metadata.description.unwrap_or_default(),
};
bkmrk.notes = match parse_serde_json_value_to_raw_string(
transformed_json_value.get("notes").unwrap(),
) {
ref s if !s.is_empty() => s.to_string(),
_ => String::new(),
};
bkmrk.tag_names = if let Value::Array(arr) = &obj["tag_names"] {
let tags: Vec<String> = arr
.iter()
.filter_map(|item| item.as_str().map(std::string::ToString::to_string))
.collect();
tags
} else {
Vec::new()
}
}
match edit_bookmark(account, &bkmrk).await {
Ok(value) => Ok(CheckDetailsResponse {
bookmark: value,
is_new: false,
}),
Err(_e) => Err(Box::new(std::io::Error::new(
std::io::ErrorKind::Other,
fl!("failed-to-parse-response"),
))),
}
} else {
let response: reqwest::Response = http_client
.post(rest_api_url)
.headers(headers)
.json(&transformed_json_value)
.send()
.await?;

match response.status() {
StatusCode::CREATED => match response.json::<Bookmark>().await {
Ok(mut value) => {
value.linkding_internal_id = value.id;
value.user_account_id = account.id;
value.id = None;
Ok(value)
match response.status() {
StatusCode::CREATED => match response.json::<Bookmark>().await {
Ok(mut value) => {
value.linkding_internal_id = value.id;
value.user_account_id = account.id;
value.id = None;
Ok(CheckDetailsResponse {
bookmark: value,
is_new: true,
})
}
Err(_e) => Err(Box::new(std::io::Error::new(
std::io::ErrorKind::Other,
fl!("failed-to-parse-response"),
))),
},
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()
),
))),
}
}
Err(_e) => Err(Box::new(std::io::Error::new(
std::io::ErrorKind::Other,
fl!("failed-to-parse-response"),
))),
},
status => Err(Box::new(std::io::Error::new(
}
Err(_e) => 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()
),
fl!("failed-to-parse-response"),
))),
}
}
Expand Down Expand Up @@ -392,3 +460,50 @@ pub async fn check_account_on_instance(
))),
}
}

pub async fn check_bookmark_on_instance(
account: &Account,
url: String,
) -> Result<LinkdingBookmarksApiCheckResponse, Box<dyn std::error::Error>> {
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();
let mut headers = HeaderMap::new();
let http_client = ClientBuilder::new()
.danger_accept_invalid_certs(account.tls)
.build()?;
headers.insert(
AUTHORIZATION,
HeaderValue::from_str(&format!("Token {}", account.api_token)).unwrap(),
);
let response: reqwest::Response = http_client
.get(rest_api_url)
.headers(headers)
.send()
.await?;
match response.status() {
StatusCode::OK => match response.json::<LinkdingBookmarksApiCheckResponse>().await {
Ok(value) => Ok(value),
Err(_e) => Err(Box::new(std::io::Error::new(
std::io::ErrorKind::Other,
fl!("failed-to-find-linkding-api-endpoint"),
))),
},
StatusCode::UNAUTHORIZED => Err(Box::new(std::io::Error::new(
std::io::ErrorKind::Other,
fl!("invalid-api-token"),
))),
_ => Err(Box::new(std::io::Error::new(
std::io::ErrorKind::Other,
fl!(
"unexpected-http-return-code",
http_rc = response.status().to_string()
),
))),
}
}
1 change: 1 addition & 0 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ mod http;
mod models;
mod pages;
mod style;
mod utils;

use core::settings;

Expand Down
21 changes: 21 additions & 0 deletions src/models/bookmarks.rs
Original file line number Diff line number Diff line change
Expand Up @@ -98,3 +98,24 @@ impl DetailedResponse {
}
}
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LinkdingBookmarksApiCheckMetadata {
pub url: Option<String>,
pub title: Option<String>,
pub description: Option<String>,
pub preview_image: Option<String>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LinkdingBookmarksApiCheckResponse {
pub bookmark: Option<Bookmark>,
pub metadata: LinkdingBookmarksApiCheckMetadata,
pub auto_tags: Vec<String>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CheckDetailsResponse {
pub bookmark: Bookmark,
pub is_new: bool,
}
21 changes: 21 additions & 0 deletions src/utils/json.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
use serde_json::Value;

// NOTE: I was not able to correctly parse serde_json::Value into a raw value
pub fn parse_serde_json_value_to_raw_string(v: &Value) -> String {
let mut parsed_string = v.to_string();
// Trim leading double quote
if parsed_string.starts_with('"') {
parsed_string.remove(0);
}
// Trim trailing double quote
if parsed_string.ends_with('"') {
parsed_string.pop();
}
// Reset string if escaped newline provided
// This can occur when clicking on a cosmic::widget::text_editor widget
if parsed_string == "\\n" {
parsed_string = String::new();
}
// "Unescape" escaped new lines
parsed_string.replace("\\n", "\n")
}
1 change: 1 addition & 0 deletions src/utils/mod.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
pub mod json;

0 comments on commit da4dd9e

Please sign in to comment.