Compare commits

...
Sign in to create a new pull request.

25 commits

Author SHA1 Message Date
fd39e3b967 feat(url): update api calls in items_list.rs 2025-02-22 02:27:34 +03:00
ad9942a44f feat(routes): edit API routes to use URL parameter 2025-02-21 16:43:45 +03:00
585a4a6eb7 fix(debug): add logging to solve db saving issues 2025-02-21 15:11:11 +03:00
e90a6be010 feat (items_list.rs): simplify deserialization and remove unnecessary conversions 2025-02-20 16:26:43 +03:00
63aaa57fa1 refactor(imports): remove unused imports 2025-02-20 15:57:02 +03:00
a35d4d557d feat(ssr): added get_current_url function for SSR support 2025-02-20 15:38:43 +03:00
2bcdea79dc fead(db): initialize database first 2025-02-20 15:36:33 +03:00
a379e93f44 feat(db): add proper error handling to schema table creation 2025-02-20 15:01:29 +03:00
a8d8e9a131 feat(db): migrate to relational property storage
-Removed JSON (de)serialization from DbItem struct
-Added direct Item struct handling in database operations
-remove insert_item function
2025-02-19 22:45:24 +03:00
af1e6d949f feat(item_list): remove JSON deserialization of custom properties in items loader 2025-02-19 22:11:13 +03:00
5815c9fe10 feat(item_list): make DbItem struct public. 2025-02-18 23:44:45 +03:00
8e3c87f315 feat(main): edit main function and API handlers
* Removed redundant imports and unused variables
* Simplified `create_item_handler` to use `ItemRequest` struct
* Removed unnecessary cloning of database Arc
* Improved code organization and readability
2025-02-18 23:43:38 +03:00
b6b1ebde9c refactor(api): simplify API handlers and request bodies
* Introduced `ItemRequest` struct to encapsulate URL and item data
* Updated `create_item` handler to accept `ItemRequest` instead of separate URL and item parameters
* Removed redundant error handling and logging in API handlers
* Improved code organization and readability
2025-02-18 23:41:18 +03:00
74bd1a89e5 feat(db): edit database schema and item insertion logic
* Added a junction table for custom properties to improve data normalization
* Modified the `insert_item_by_url` function to handle custom properties through the junction table
* Introduced `get_url_id` and `get_or_create_property` helper functions for improved code organization
* Updated the `insert_item_by_url` function to use the new helper functions and handle custom properties correctly
* Improved error handling and logging throughout the database module
2025-02-18 23:38:45 +03:00
9beb997125 feat(item_list): update save_item_to_db function to take current_url as an argument.
Changes:

* Replaced `let current_url = get_current_url();` with `let current_url = Rc::new(get_current_url());` to share the current URL between closures.
* Wrapped `add_property` and `update_item` functions in Arc to share them between closures.
* Updated `save_item_to_db` function to take `current_url` as an argument and use it to construct the ItemToSend struct.
* Updated `fetch_property_labels` function to use the `property_ids` vector instead of a single property ID.
* Updated `fetch_item_properties` function to use the `wikidata_id` parameter instead of a hardcoded value.
* Updated `WikidataResponse` struct to use a `Vec<WikidataSuggestion>` instead of a single `WikidataSuggestion`.
2025-02-18 20:01:45 +03:00
63f11f6a2d feat(db): move function to lead items from db outside the items list component. 2025-02-17 20:19:54 +03:00
eba20abf5a feat(app): add router and dynamic item loading to app component 2025-02-17 17:04:43 +03:00
ecc991cc24 feat(db): add properties table and junction table for custom properties 2025-02-17 17:04:16 +03:00
8860ace51f feat(url): Added API handlers for item management by URL 2025-02-14 17:50:58 +03:00
7939c9e7b6 feat(url): add server-side rendering feature with URL routing 2025-02-13 23:07:27 +03:00
fddec7f728 feat(url): edit items list component to include current URL in database query 2025-02-13 23:06:26 +03:00
1a5c245250 feat(api): add URL parameter support to API endpoints 2025-02-12 16:12:33 +03:00
e72ed778a2 feat(item): remove reviews from item struct. 2025-02-12 15:56:11 +03:00
bfded464c9 feat(db): update db.rs to save url specific items and properties 2025-02-12 15:55:17 +03:00
ce1e93fc49 feat(db): create new URL table and update the existing table to include a foreign key to the URLs table 2025-02-11 23:36:43 +03:00
6 changed files with 530 additions and 183 deletions

View file

@ -1,16 +1,34 @@
#[cfg(feature = "ssr")] #[cfg(feature = "ssr")]
use actix_web::{web, HttpResponse}; use actix_web::{web, HttpResponse};
#[cfg(feature = "ssr")] #[cfg(feature = "ssr")]
use crate::db::{Database, DbItem}; use crate::db::Database;
#[cfg(feature = "ssr")] #[cfg(feature = "ssr")]
use std::sync::Arc; use std::sync::Arc;
#[cfg(feature = "ssr")] #[cfg(feature = "ssr")]
use tokio::sync::Mutex; use tokio::sync::Mutex;
#[cfg(feature = "ssr")]
use crate::models::item::Item;
#[cfg(feature = "ssr")]
use std::collections::HashMap;
#[cfg(feature = "ssr")]
use leptos::logging::log;
#[cfg(feature = "ssr")] #[cfg(feature = "ssr")]
pub async fn get_items(db: web::Data<Arc<Mutex<Database>>>) -> HttpResponse { use serde::{Deserialize, Serialize};
#[cfg(feature = "ssr")]
#[derive(Serialize, Deserialize)]
pub struct ItemRequest {
pub url: String,
pub item: Item,
}
#[cfg(feature = "ssr")]
pub async fn get_items(
db: web::Data<Arc<Mutex<Database>>>,
url: web::Query<String>,
) -> HttpResponse {
let db = db.lock().await; let db = db.lock().await;
match db.get_items().await { match db.get_items_by_url(&url).await {
Ok(items) => HttpResponse::Ok().json(items), Ok(items) => HttpResponse::Ok().json(items),
Err(err) => { Err(err) => {
leptos::logging::error!("Failed to fetch items: {:?}", err); leptos::logging::error!("Failed to fetch items: {:?}", err);
@ -22,14 +40,28 @@ pub async fn get_items(db: web::Data<Arc<Mutex<Database>>>) -> HttpResponse {
#[cfg(feature = "ssr")] #[cfg(feature = "ssr")]
pub async fn create_item( pub async fn create_item(
db: web::Data<Arc<Mutex<Database>>>, db: web::Data<Arc<Mutex<Database>>>,
item: web::Json<DbItem>, request: web::Json<ItemRequest>,
) -> HttpResponse { ) -> HttpResponse {
let db = db.lock().await; let db = db.lock().await;
match db.insert_item(&item.into_inner()).await { let url = request.url.clone();
Ok(_) => HttpResponse::Ok().body("Item inserted"), let item = request.item.clone();
Err(err) => { let item_id = request.item.id.clone();
leptos::logging::error!("Failed to insert item: {:?}", err); // request logging
HttpResponse::InternalServerError().body("Failed to insert item") log!("[API] Received item request - URL: {}, Item ID: {}",
request.url, request.item.id);
// raw JSON logging
let raw_json = serde_json::to_string(&request.into_inner()).unwrap();
log!("[API] Raw request JSON: {}", raw_json);
match db.insert_item_by_url(&url, &item).await {
Ok(_) => {
log!("[API] Successfully saved item ID: {}", item_id);
HttpResponse::Ok().json(item)
},
Err(e) => {
log!("[API] Database error: {:?}", e);
HttpResponse::BadRequest().body(format!("Database error: {}", e))
} }
} }
} }
@ -37,10 +69,11 @@ pub async fn create_item(
#[cfg(feature = "ssr")] #[cfg(feature = "ssr")]
pub async fn delete_item( pub async fn delete_item(
db: web::Data<Arc<Mutex<Database>>>, db: web::Data<Arc<Mutex<Database>>>,
url: web::Query<String>,
item_id: web::Path<String>, item_id: web::Path<String>,
) -> HttpResponse { ) -> HttpResponse {
let db = db.lock().await; let db = db.lock().await;
match db.delete_item(&item_id).await { match db.delete_item_by_url(&url, &item_id).await {
Ok(_) => HttpResponse::Ok().body("Item deleted"), Ok(_) => HttpResponse::Ok().body("Item deleted"),
Err(err) => { Err(err) => {
leptos::logging::error!("Failed to delete item: {:?}", err); leptos::logging::error!("Failed to delete item: {:?}", err);
@ -52,14 +85,63 @@ pub async fn delete_item(
#[cfg(feature = "ssr")] #[cfg(feature = "ssr")]
pub async fn delete_property( pub async fn delete_property(
db: web::Data<Arc<Mutex<Database>>>, db: web::Data<Arc<Mutex<Database>>>,
url: web::Query<String>,
property: web::Path<String>, property: web::Path<String>,
) -> HttpResponse { ) -> HttpResponse {
let db = db.lock().await; let db = db.lock().await;
match db.delete_property(&property).await { match db.delete_property_by_url(&url, &property).await {
Ok(_) => HttpResponse::Ok().body("Property deleted"), Ok(_) => HttpResponse::Ok().body("Property deleted"),
Err(err) => { Err(err) => {
leptos::logging::error!("Failed to delete property: {:?}", err); leptos::logging::error!("Failed to delete property: {:?}", err);
HttpResponse::InternalServerError().body("Failed to delete property") HttpResponse::InternalServerError().body("Failed to delete property")
} }
} }
}
#[cfg(feature = "ssr")]
pub async fn get_items_by_url(
db: web::Data<Arc<Mutex<Database>>>,
query: web::Query<HashMap<String, String>>,
) -> HttpResponse {
let url = query.get("url").unwrap_or(&String::new()).to_string();
let db = db.lock().await;
match db.get_items_by_url(&url).await {
Ok(items) => HttpResponse::Ok().json(items),
Err(err) => {
leptos::logging::error!("Failed to fetch items by URL: {:?}", err);
HttpResponse::InternalServerError().body("Failed to fetch items by URL")
}
}
}
#[cfg(feature = "ssr")]
pub async fn delete_item_by_url(
db: web::Data<Arc<Mutex<Database>>>,
url: web::Path<String>,
item_id: web::Path<String>,
) -> HttpResponse {
let db = db.lock().await;
match db.delete_item_by_url(&url, &item_id).await {
Ok(_) => HttpResponse::Ok().body("Item deleted"),
Err(err) => {
leptos::logging::error!("Failed to delete item by URL: {:?}", err);
HttpResponse::InternalServerError().body("Failed to delete item by URL")
}
}
}
#[cfg(feature = "ssr")]
pub async fn delete_property_by_url(
db: web::Data<Arc<Mutex<Database>>>,
url: web::Path<String>,
property: web::Path<String>,
) -> HttpResponse {
let db = db.lock().await;
match db.delete_property_by_url(&url, &property).await {
Ok(_) => HttpResponse::Ok().body("Property deleted"),
Err(err) => {
leptos::logging::error!("Failed to delete property by URL: {:?}", err);
HttpResponse::InternalServerError().body("Failed to delete property by URL")
}
}
} }

View file

@ -1,6 +1,8 @@
use leptos::*; use leptos::*;
use leptos_meta::*; use leptos_meta::*;
use crate::components::items_list::ItemsList; use leptos_router::*;
use leptos::logging::log;
use crate::components::items_list::{ItemsList, load_items_from_db};
use crate::models::item::Item; use crate::models::item::Item;
use crate::nostr::NostrClient; use crate::nostr::NostrClient;
use tokio::sync::mpsc; use tokio::sync::mpsc;
@ -26,13 +28,36 @@ pub fn App() -> impl IntoView {
} }
} }
}); });
view! { view! {
<Stylesheet href="/assets/style.css" /> <Router>
<Stylesheet href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.1.1/css/all.min.css" /> <Routes>
<div> <Route path="/:url?" view=move || {
<h1>{ "CompareWare" }</h1> let params = use_params_map();
<ItemsList items=items_signal set_items=set_items /> let url = move || params.with(|params| params.get("url").cloned().unwrap_or_default());
</div>
// This effect will re-run when URL changes
create_effect(move |_| {
let current_url = url();
spawn_local(async move {
// Load items for new URL
match load_items_from_db(&current_url).await {
Ok(loaded_items) => {
set_items.set(loaded_items);
}
Err(err) => log!("Error loading items: {}", err),
}
});
});
view! {
<Stylesheet href="/assets/style.css" />
<Stylesheet href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.1.1/css/all.min.css" />
<div>
<h1>{ "CompareWare" }</h1>
<ItemsList items=items_signal set_items=set_items />
</div>
}
}/>
</Routes>
</Router>
} }
} }

View file

@ -8,10 +8,8 @@ use crate::models::item::Item;
use std::collections::HashMap; use std::collections::HashMap;
use std::sync::Arc; use std::sync::Arc;
use wasm_bindgen::JsCast; use wasm_bindgen::JsCast;
use std::rc::Rc;
use urlencoding::encode; use urlencoding::encode;
use gloo_net::http::Request;
use serde_json::Value;
#[derive(Deserialize, Clone, Debug)] #[derive(Deserialize, Clone, Debug)]
struct WikidataSuggestion { struct WikidataSuggestion {
id: String, id: String,
@ -19,14 +17,28 @@ struct WikidataSuggestion {
description: Option<String>, description: Option<String>,
} }
//function to load items from database
pub async fn load_items_from_db(current_url: &str) -> Result<Vec<Item>, String> {
let encoded_url = encode(&current_url);
let api_url = format!("/api/urls/{}/items", encoded_url);
let response = gloo_net::http::Request::get(&api_url)
.send()
.await
.map_err(|err| format!("Failed to fetch items: {:?}", err))?;
#[derive(Deserialize, Debug)] if response.status() == 200 {
struct DbItem { // Deserialize into Vec<Item>
id: String, log!("Loading items from DB...");
name: String, let items = response
description: String, .json::<Vec<Item>>()
wikidata_id: Option<String>, .await
custom_properties: String, .map_err(|err| format!("Failed to parse items: {:?}", err))?;
Ok(items)
} else {
let body = response.text().await.unwrap_or_default();
Err(format!("Server error ({}): {}", response.status(), body))
}
} }
#[component] #[component]
@ -52,8 +64,29 @@ pub fn ItemsList(
// Signal to store the fetched property labels // Signal to store the fetched property labels
let (property_labels, set_property_labels) = create_signal(HashMap::<String, String>::new()); let (property_labels, set_property_labels) = create_signal(HashMap::<String, String>::new());
spawn_local(async move { #[cfg(feature = "ssr")]
match load_items_from_db().await { fn get_current_url() -> String {
use leptos::use_context;
use actix_web::HttpRequest;
use_context::<HttpRequest>()
.map(|req| req.uri().to_string())
.unwrap_or_default()
}
#[cfg(not(feature = "ssr"))]
fn get_current_url() -> String {
web_sys::window()
.and_then(|win| win.location().href().ok())
.unwrap_or_else(|| "".to_string())
}
let current_url = Rc::new(get_current_url());
spawn_local({
let current_url = Rc::clone(&current_url);
async move {
match load_items_from_db(&current_url).await {
Ok(loaded_items) => { Ok(loaded_items) => {
// Set the loaded items // Set the loaded items
if loaded_items.is_empty() { if loaded_items.is_empty() {
@ -107,7 +140,7 @@ pub fn ItemsList(
log!("Error loading items: {}", err); log!("Error loading items: {}", err);
} }
} }
}); }});
// Ensure there's an initial empty row // Ensure there's an initial empty row
@ -116,49 +149,54 @@ pub fn ItemsList(
id: Uuid::new_v4().to_string(), id: Uuid::new_v4().to_string(),
name: String::new(), name: String::new(),
description: String::new(), description: String::new(),
// reviews: vec![],
wikidata_id: None, wikidata_id: None,
custom_properties: HashMap::new(), custom_properties: HashMap::new(),
}]); }]);
} }
// Function to send an item to the backend API // Function to send an item to the backend API
async fn save_item_to_db(item: Item, selected_properties: ReadSignal<HashMap<String, bool>>) { async fn save_item_to_db(item: Item, selected_properties: ReadSignal<HashMap<String, bool>>, current_url: String) {
let custom_props = item.custom_properties.clone();
// Use a reactive closure to access `selected_properties` // Use a reactive closure to access `selected_properties`
let custom_properties: HashMap<String, String> = (move || { let custom_properties: HashMap<String, String> = (move || {
let selected_props = selected_properties.get(); // Access the signal inside a reactive closure let selected_props = selected_properties.get(); // Access the signal inside a reactive closure
item.custom_properties custom_props
.into_iter() .into_iter()
.filter(|(key, _)| selected_props.contains_key(key)) // Use the extracted value .filter(|(key, _)| selected_props.contains_key(key)) // Use the extracted value
.collect() .collect()
})(); })();
// Serialize `custom_properties` to a JSON string
let custom_properties = serde_json::to_string(&custom_properties).unwrap();
// Create a new struct to send to the backend // Create a new struct to send to the backend
#[derive(Serialize, Debug)] #[derive(Serialize, Debug)]
struct ItemToSend { struct ItemRequest {
id: String, url: String,
name: String, item: Item,
description: String,
wikidata_id: Option<String>,
custom_properties: String, // JSON-encoded string
} }
log!("[FRONTEND] Saving item - ID: {}, Name: '{}', Properties: {:?}",
item.id, item.name, item.custom_properties);
let item_to_send = ItemToSend { let item_to_send = ItemRequest {
id: item.id, url: current_url.to_string(),
name: item.name, item: Item {
description: item.description, id: item.id,
wikidata_id: item.wikidata_id, name: item.name,
custom_properties, // Use the serialized string description: item.description,
wikidata_id: item.wikidata_id,
custom_properties, // Use the filtered HashMap
},
}; };
let encoded_url = encode(&current_url);
let response = gloo_net::http::Request::post("/api/items") let api_url = format!("/api/urls/{}/items", encoded_url);
let response = gloo_net::http::Request::post(&api_url)
.json(&item_to_send) .json(&item_to_send)
.unwrap() .unwrap()
.send() .send()
.await; .await;
log!("[FRONTEND] Save response status: {:?}", response.as_ref().map(|r| r.status()));
match response { match response {
Ok(resp) => { Ok(resp) => {
@ -172,51 +210,6 @@ pub fn ItemsList(
} }
} }
//function to load items from database
async fn load_items_from_db() -> Result<Vec<Item>, String> {
let response = gloo_net::http::Request::get("/api/items")
.send()
.await
.map_err(|err| format!("Failed to fetch items: {:?}", err))?;
if response.status() == 200 {
// Deserialize into Vec<DbItem>
log!("Loading items from DB...");
let db_items = response
.json::<Vec<DbItem>>()
.await
.map_err(|err| format!("Failed to parse items: {:?}", err))?;
// log!("Deserialized DB items: {:?}", db_items);
// Convert DbItem to Item
let items = db_items
.into_iter()
.map(|db_item| {
// Deserialize `custom_properties` from a JSON string to a HashMap
let custom_properties: HashMap<String, String> =
serde_json::from_str(&db_item.custom_properties)
.unwrap_or_default(); // Fallback to an empty HashMap if deserialization fails
log!("Loaded item: {:?}", db_item.id);
log!("Custom properties: {:?}", custom_properties);
Item {
id: db_item.id,
name: db_item.name,
description: db_item.description,
wikidata_id: db_item.wikidata_id,
custom_properties, // Deserialized HashMap
}
})
.collect();
// log!("Converted items: {:?}", items);
Ok(items)
} else {
Err(format!("Failed to fetch items: {}", response.status_text()))
}
}
// Function to remove an item // Function to remove an item
let remove_item = move |index: usize| { let remove_item = move |index: usize| {
let item_id = items.get()[index].id.clone(); let item_id = items.get()[index].id.clone();
@ -434,7 +427,10 @@ pub fn ItemsList(
} }
// Add a new custom property // Add a new custom property
let add_property = move |property: String| { let add_property = {
let current_url = Rc::clone(&current_url);
let set_items = set_items.clone();
Arc::new(move |property: String| {
// Normalize the property ID // Normalize the property ID
let normalized_property = property.replace("http://www.wikidata.org/prop/", ""); let normalized_property = property.replace("http://www.wikidata.org/prop/", "");
@ -454,8 +450,11 @@ pub fn ItemsList(
// Save the updated item to the database // Save the updated item to the database
let item_clone = item.clone(); let item_clone = item.clone();
spawn_local(async move { spawn_local({
save_item_to_db(item_clone, selected_properties).await; let current_url = Rc::clone(&current_url);
async move {
save_item_to_db(item_clone, selected_properties, current_url.to_string()).await;
}
}); });
} }
}); });
@ -504,11 +503,16 @@ pub fn ItemsList(
} }
} }
}); });
}; })};
// Update item fields // Update item fields
let update_item = move |index: usize, field: &str, value: String| { let update_item = {
set_items.update(|items| { let set_items = set_items.clone();
let current_url = Rc::clone(&current_url);
Arc::new(move |index: usize, field: &str, value: String| {
let set_items = set_items.clone();
let current_url = Rc::clone(&current_url);
set_items.update(move|items| {
if let Some(item) = items.get_mut(index) { if let Some(item) = items.get_mut(index) {
match field { match field {
"name" => { "name" => {
@ -519,8 +523,6 @@ pub fn ItemsList(
if !value.is_empty() { if !value.is_empty() {
if let Some(wikidata_id) = &item.wikidata_id { if let Some(wikidata_id) = &item.wikidata_id {
let wikidata_id = wikidata_id.clone(); let wikidata_id = wikidata_id.clone();
let set_fetched_properties = set_fetched_properties.clone();
let set_property_labels = set_property_labels.clone();
spawn_local(async move { spawn_local(async move {
let properties = fetch_item_properties(&wikidata_id).await; let properties = fetch_item_properties(&wikidata_id).await;
log!("Fetched properties for index {}: {:?}", index, properties); log!("Fetched properties for index {}: {:?}", index, properties);
@ -539,8 +541,11 @@ pub fn ItemsList(
// Save the updated item to the database // Save the updated item to the database
let item_clone = item.clone(); let item_clone = item.clone();
spawn_local(async move { spawn_local({
save_item_to_db(item_clone, selected_properties).await; let current_url = Rc::clone(&current_url);
async move {
save_item_to_db(item_clone, selected_properties, current_url.to_string()).await;
}
}); });
} }
// Automatically add a new row when editing the last row // Automatically add a new row when editing the last row
@ -556,13 +561,16 @@ pub fn ItemsList(
items.push(new_item.clone()); items.push(new_item.clone());
// Save the new item to the database // Save the new item to the database
spawn_local(async move { spawn_local({
save_item_to_db(new_item, selected_properties).await; let current_url = Rc::clone(&current_url);
async move {
save_item_to_db(new_item, selected_properties, current_url.to_string()).await;
}
}); });
} }
log!("Items updated: {:?}", items); log!("Items updated: {:?}", items);
}); });
}; })};
// List of properties to display as rows // List of properties to display as rows
let properties = vec!["Name", "Description"]; let properties = vec!["Name", "Description"];
@ -586,11 +594,13 @@ pub fn ItemsList(
</thead> </thead>
<tbody> <tbody>
{properties.into_iter().map(|property| { {properties.into_iter().map(|property| {
let update_item_cloned = Arc::clone(&update_item);
log!("Rendering property: {}", property); log!("Rendering property: {}", property);
view! { view! {
<tr> <tr>
<td>{ property }</td> <td>{ property }</td>
{move || items.get().iter().enumerate().map(|(index, item)| { {move || items.get().iter().enumerate().map(|(index, item)| {
let update_item_clone = Arc::clone(&update_item_cloned);
view! { view! {
<td> <td>
{match property { {match property {
@ -599,7 +609,7 @@ pub fn ItemsList(
<EditableCell <EditableCell
value=item.name.clone() value=item.name.clone()
on_input=move |value| { on_input=move |value| {
update_item(index, "name", value.clone()); update_item_clone(index, "name", value.clone());
fetch_wikidata_suggestions(format!("name-{}", index), value); fetch_wikidata_suggestions(format!("name-{}", index), value);
} }
key=Arc::new(format!("name-{}", index)) key=Arc::new(format!("name-{}", index))
@ -661,8 +671,6 @@ pub fn ItemsList(
// Fetch additional properties from Wikidata // Fetch additional properties from Wikidata
let wikidata_id = id.clone(); let wikidata_id = id.clone();
let set_fetched_properties = set_fetched_properties.clone();
let set_property_labels = set_property_labels.clone();
spawn_local(async move { spawn_local(async move {
let properties = fetch_item_properties(&wikidata_id).await; let properties = fetch_item_properties(&wikidata_id).await;
// log!("Fetched properties for Wikidata ID {}: {:?}", wikidata_id, properties); // log!("Fetched properties for Wikidata ID {}: {:?}", wikidata_id, properties);
@ -702,7 +710,7 @@ pub fn ItemsList(
"Description" => view! { "Description" => view! {
<EditableCell <EditableCell
value=item.description.clone() value=item.description.clone()
on_input=move |value| update_item(index, "description", value) on_input=move |value| update_item_clone(index, "description", value)
key=Arc::new(format!("description-{}", index)) key=Arc::new(format!("description-{}", index))
focused_cell=focused_cell focused_cell=focused_cell
set_focused_cell=set_focused_cell.clone() set_focused_cell=set_focused_cell.clone()
@ -726,9 +734,14 @@ pub fn ItemsList(
} }
}).collect::<Vec<_>>()} }).collect::<Vec<_>>()}
// Dynamically adding custom properties as columns // Dynamically adding custom properties as columns
{move || { {{
let update_item_outer = Arc::clone(&update_item);
move || {
let update_item = Arc::clone(&update_item_outer);
let custom_props = custom_properties.get().clone(); let custom_props = custom_properties.get().clone();
custom_props.into_iter().map(move |property| { custom_props.into_iter().map(move |property| {
let update_item_inner = Arc::clone(&update_item);
let normalized_property = property.replace("http://www.wikidata.org/prop/", ""); let normalized_property = property.replace("http://www.wikidata.org/prop/", "");
let property_label = property_labels.get().get(&normalized_property).cloned().unwrap_or_else(|| normalized_property.clone()); let property_label = property_labels.get().get(&normalized_property).cloned().unwrap_or_else(|| normalized_property.clone());
log!("Rendering property: {} -> {}", normalized_property, property_label); log!("Rendering property: {} -> {}", normalized_property, property_label);
@ -754,14 +767,16 @@ pub fn ItemsList(
}>{ "Delete" }</button> }>{ "Delete" }</button>
</td> </td>
{move || { {move || {
let update_item_cell = Arc::clone(&update_item_inner);
let property_clone_for_cells = normalized_property.clone(); let property_clone_for_cells = normalized_property.clone();
items.get().iter().enumerate().map(move |(index, item)| { items.get().iter().enumerate().map(move |(index, item)| {
let property_clone_for_closure = property_clone_for_cells.clone(); let update_item_cell = Arc::clone(&update_item_cell);
let property_clone_for_closure = property_clone_for_cells.clone();
view! { view! {
<td> <td>
<EditableCell <EditableCell
value=item.custom_properties.get(&property_clone_for_closure).cloned().unwrap_or_default() value=item.custom_properties.get(&property_clone_for_closure).cloned().unwrap_or_default()
on_input=move |value| update_item(index, &property_clone_for_closure, value) on_input=move |value| update_item_cell(index, &property_clone_for_closure, value)
key=Arc::new(format!("custom-{}-{}", property_clone_for_cells, index)) key=Arc::new(format!("custom-{}-{}", property_clone_for_cells, index))
focused_cell=focused_cell focused_cell=focused_cell
set_focused_cell=set_focused_cell.clone() set_focused_cell=set_focused_cell.clone()
@ -777,7 +792,7 @@ pub fn ItemsList(
} }
</tr> </tr>
} }
}).collect::<Vec<_>>() }).collect::<Vec<_>>()}
}} }}
</tbody> </tbody>
</table> </table>

272
src/db.rs
View file

@ -5,6 +5,9 @@ mod db_impl {
use std::sync::Arc; use std::sync::Arc;
use tokio::sync::Mutex; use tokio::sync::Mutex;
use leptos::logging; use leptos::logging;
use std::collections::HashMap;
use crate::models::item::Item;
use leptos::logging::log;
// Define a struct to represent a database connection // Define a struct to represent a database connection
#[derive(Debug)] #[derive(Debug)]
@ -25,41 +28,70 @@ mod db_impl {
// Create the database schema // Create the database schema
pub async fn create_schema(&self) -> Result<(), Error> { pub async fn create_schema(&self) -> Result<(), Error> {
let conn = self.conn.lock().await; let conn = self.conn.lock().await;
// 1. Properties table
conn.execute_batch(
"CREATE TABLE IF NOT EXISTS properties (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
global_usage_count INTEGER DEFAULT 0
);"
).map_err(|e| {
eprintln!("Failed creating properties table: {}", e);
e
})?;
// 2. URLs table
conn.execute_batch(
"CREATE TABLE IF NOT EXISTS urls (
id INTEGER PRIMARY KEY,
url TEXT NOT NULL UNIQUE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);",
).map_err(|e| {
eprintln!("Failed creating urls table: {}", e);
e
})?;
// 3. Items table
conn.execute_batch( conn.execute_batch(
"CREATE TABLE IF NOT EXISTS items ( "CREATE TABLE IF NOT EXISTS items (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
url_id INTEGER NOT NULL,
name TEXT NOT NULL, name TEXT NOT NULL,
description TEXT, description TEXT,
wikidata_id TEXT, wikidata_id TEXT,
custom_properties TEXT FOREIGN KEY (url_id) REFERENCES urls(id) ON DELETE CASCADE
);", );",
)?; ).map_err(|e| {
logging::log!("Database schema created or verified"); eprintln!("Failed creating items table: {}", e);
e
})?;
// 4. Junction table for custom properties
conn.execute_batch(
"CREATE TABLE IF NOT EXISTS item_properties (
item_id TEXT NOT NULL,
property_id INTEGER NOT NULL,
value TEXT NOT NULL,
PRIMARY KEY (item_id, property_id),
FOREIGN KEY (item_id) REFERENCES items(id) ON DELETE CASCADE,
FOREIGN KEY (property_id) REFERENCES properties(id) ON DELETE CASCADE
);"
).map_err(|e| {
eprintln!("Failed creating item_properties table: {}", e);
e
})?;
Ok(()) Ok(())
} }
// Insert a new item into the database // Insert a new URL into the database
pub async fn insert_item(&self, item: &DbItem) -> Result<(), Error> { pub async fn insert_url(&self, url: &str) -> Result<i64, Error> {
let conn = self.conn.lock().await; let conn = self.conn.lock().await;
let wikidata_id = item.wikidata_id.as_ref().map(|s| s.as_str()).unwrap_or(""); let mut stmt = conn.prepare("INSERT INTO urls (url) VALUES (?)")?;
conn.execute( let url_id = stmt.insert(&[url])?;
"INSERT INTO items (id, name, description, wikidata_id, custom_properties) logging::log!("URL inserted: {}", url);
VALUES (?, ?, ?, ?, ?) Ok(url_id)
ON CONFLICT(id) DO UPDATE SET
name = excluded.name,
description = excluded.description,
wikidata_id = excluded.wikidata_id,
custom_properties = excluded.custom_properties;",
&[
&item.id,
&item.name,
&item.description,
&wikidata_id.to_string(),
&item.custom_properties,
],
)?;
logging::log!("Item inserted: {}", item.id);
Ok(())
} }
pub async fn delete_item(&self, item_id: &str) -> Result<(), Error> { pub async fn delete_item(&self, item_id: &str) -> Result<(), Error> {
@ -87,7 +119,6 @@ mod db_impl {
name: row.get(1)?, name: row.get(1)?,
description: row.get(2)?, description: row.get(2)?,
wikidata_id: row.get(3)?, wikidata_id: row.get(3)?,
custom_properties: row.get(4)?,
}) })
})?; })?;
let mut result = Vec::new(); let mut result = Vec::new();
@ -97,6 +128,172 @@ mod db_impl {
logging::log!("Fetched {} items from the database", result.len()); // Log with Leptos logging::log!("Fetched {} items from the database", result.len()); // Log with Leptos
Ok(result) Ok(result)
} }
// Retrieve all items from the database for a specific URL
pub async fn get_items_by_url(&self, url: &str) -> Result<Vec<Item>, Error> {
let conn = self.conn.lock().await;
let url_id: i64 = conn.query_row("SELECT id FROM urls WHERE url = ?", &[url], |row| row.get(0))?;
let mut stmt = conn.prepare(
"SELECT i.id, i.name, i.description, i.wikidata_id,
p.name AS prop_name, ip.value
FROM items i
LEFT JOIN item_properties ip ON i.id = ip.item_id
LEFT JOIN properties p ON ip.property_id = p.id
WHERE i.url_id = ?"
)?;
let mut items: HashMap<String, Item> = HashMap::new();
let rows = stmt.query_map([url_id], |row| {
Ok((
row.get::<_, String>(0)?, // id
row.get::<_, String>(1)?, // name
row.get::<_, String>(2)?, // description
row.get::<_, Option<String>>(3)?, // wikidata_id
row.get::<_, Option<String>>(4)?, // prop_name
row.get::<_, Option<String>>(5)?, // value
))
})?;
for row in rows {
let (id, name, desc, wd_id, prop, val) = row?;
let item = items.entry(id.clone()).or_insert(Item {
id,
name,
description: desc,
wikidata_id: wd_id,
custom_properties: HashMap::new(),
});
if let (Some(p), Some(v)) = (prop, val) {
item.custom_properties.insert(p, v);
}
}
Ok(items.into_values().collect())
}
async fn get_url_id(&self, url: &str) -> Result<Option<i64>, Error> {
let conn = self.conn.lock().await;
conn.query_row(
"SELECT id FROM urls WHERE url = ?",
&[url],
|row| row.get(0)
)
}
async fn get_or_create_property(&self, prop: &str) -> Result<i64, Error> {
let conn = self.conn.lock().await;
// Check existing
let exists: Result<i64, _> = conn.query_row(
"SELECT id FROM properties WHERE name = ?",
&[prop],
|row| row.get(0)
);
match exists {
Ok(id) => Ok(id),
Err(_) => {
conn.execute(
"INSERT INTO properties (name) VALUES (?)",
&[prop],
)?;
Ok(conn.last_insert_rowid())
}
}
}
// Insert a new item into the database for a specific URL
pub async fn insert_item_by_url(
&self,
url: &str,
item: &Item
) -> Result<(), Error> {
// Log before DB operations
log!("[DATABASE] Inserting item - ID: {}, Name: '{}'", item.id, item.name);
let conn = self.conn.lock().await;
// Get or create URL record
let url_id = match self.get_url_id(url).await {
Ok(Some(id)) => id,
_ => self.insert_url(url).await?,
};
// Log final SQL parameters
log!("[DATABASE] SQL params - ID: {}, URL ID: {}, Name: '{}'",
item.id, url_id, item.name);
// Insert item with URL relationship
conn.execute(
"INSERT INTO items (id, url_id, name, description, wikidata_id)
VALUES (?, ?, ?, ?, ?)",
&[&item.id, &url_id.to_string(), &item.name,
&item.description, &item.wikidata_id.as_ref().unwrap_or(&String::new())],
)?;
// Handle properties through junction table
for (prop, value) in &item.custom_properties {
let prop_id = self.get_or_create_property(&prop).await?;
conn.execute(
"INSERT INTO item_properties (item_id, property_id, value)
VALUES (?, ?, ?)",
&[&item.id, &prop_id.to_string(), &value],
)?;
}
log!("[DATABASE] Successfully inserted item ID: {}", item.id);
Ok(())
}
// Delete an item from the database for a specific URL
pub async fn delete_item_by_url(&self, url: &str, item_id: &str) -> Result<(), Error> {
let conn = self.conn.lock().await;
let url_id: i64 = conn.query_row("SELECT id FROM urls WHERE url = ?", &[url], |row| row.get(0))?;
conn.execute("DELETE FROM items WHERE id = ? AND url_id = ?", &[item_id, &url_id.to_string()])?;
logging::log!("Item deleted from the database for URL: {}", url);
Ok(())
}
// Delete a property from the database for a specific URL
pub async fn delete_property_by_url(&self, url: &str, property: &str) -> Result<(), Error> {
let conn = self.conn.lock().await;
let url_id: i64 = conn.query_row("SELECT id FROM urls WHERE url = ?", &[url], |row| row.get(0))?;
// Delete from junction table instead of JSON
conn.execute(
"DELETE FROM item_properties
WHERE property_id IN (
SELECT id FROM properties WHERE name = ?
) AND item_id IN (
SELECT id FROM items WHERE url_id = ?
)",
&[property, &url_id.to_string()],
)?;
logging::log!("Property deleted from the database for URL: {}", url);
Ok(())
}
// function to log database state
pub async fn debug_dump(&self) -> Result<(), Error> {
let conn = self.conn.lock().await;
log!("[DATABASE DEBUG] URLs:");
let mut stmt = conn.prepare("SELECT id, url FROM urls")?;
let urls = stmt.query_map([], |row| {
Ok(format!("ID: {}, URL: {}", row.get::<_, i64>(0)?, row.get::<_, String>(1)?))
})?;
for url in urls {
log!("[DATABASE DEBUG] {}", url?);
}
log!("[DATABASE DEBUG] Items:");
let mut stmt = conn.prepare("SELECT id, name FROM items")?;
let items = stmt.query_map([], |row| {
Ok(format!("ID: {}, Name: '{}'", row.get::<_, String>(0)?, row.get::<_, String>(1)?))
})?;
for item in items {
log!("[DATABASE DEBUG] {}", item?);
}
Ok(())
}
} }
// Define a struct to represent an item in the database // Define a struct to represent an item in the database
@ -106,31 +303,8 @@ mod db_impl {
pub name: String, pub name: String,
pub description: String, pub description: String,
pub wikidata_id: Option<String>, pub wikidata_id: Option<String>,
pub custom_properties: String,
}
// Implement conversion from DbItem to a JSON-friendly format
#[derive(Debug, Deserialize, Serialize, Clone)]
pub struct ItemResponse {
pub id: String,
pub name: String,
pub description: String,
pub wikidata_id: Option<String>,
pub custom_properties: String,
}
impl From<DbItem> for ItemResponse {
fn from(item: DbItem) -> Self {
ItemResponse {
id: item.id,
name: item.name,
description: item.description,
wikidata_id: item.wikidata_id,
custom_properties: item.custom_properties,
}
}
} }
} }
#[cfg(feature = "ssr")] #[cfg(feature = "ssr")]
pub use db_impl::{Database, DbItem, ItemResponse}; pub use db_impl::{Database, DbItem};

View file

@ -1,4 +1,11 @@
#[cfg(feature = "ssr")] #[cfg(feature = "ssr")]
use actix_web::{web, HttpResponse, Responder};
use std::sync::Arc;
use tokio::sync::Mutex;
use compareware::db::Database;
use compareware::api::{ItemRequest,create_item, get_items, delete_item_by_url};
use compareware::models::item::Item;
#[actix_web::main] #[actix_web::main]
async fn main() -> std::io::Result<()> { async fn main() -> std::io::Result<()> {
use actix_files::Files; use actix_files::Files;
@ -6,19 +13,21 @@ async fn main() -> std::io::Result<()> {
use leptos::*; use leptos::*;
use leptos_actix::{generate_route_list, LeptosRoutes}; use leptos_actix::{generate_route_list, LeptosRoutes};
use compareware::app::*; use compareware::app::*;
use compareware::db::{Database, DbItem}; use compareware::db::Database;
use compareware::api::{get_items, create_item, delete_item, delete_property}; // Import API handlers use compareware::api::{get_items, create_item, delete_item, delete_property}; // Import API handlers
use std::sync::Arc; use std::sync::Arc;
use tokio::sync::Mutex; use tokio::sync::Mutex;
// Load configuration
let conf = get_configuration(None).await.unwrap();
let addr = conf.leptos_options.site_addr;
// Initialize the database // Initialize the database
let db = Database::new("compareware.db").unwrap(); let db = Database::new("compareware.db").unwrap();
db.create_schema().await.unwrap(); // Ensure the schema is created db.create_schema().await.unwrap(); // Ensure the schema is created
let db = Arc::new(Mutex::new(db)); // Wrap the database in an Arc<Mutex<T>> for shared state let db = Arc::new(Mutex::new(db)); // Wrap the database in an Arc<Mutex<T>> for shared state
println!("Schema created successfully!");
// Load configuration
let conf = get_configuration(None).await.unwrap();
let addr = conf.leptos_options.site_addr;
// Generate the list of routes in your Leptos App // Generate the list of routes in your Leptos App
let routes = generate_route_list(App); let routes = generate_route_list(App);
@ -32,12 +41,16 @@ async fn main() -> std::io::Result<()> {
App::new() App::new()
.app_data(web::Data::new(db.clone()))
// Register custom API routes BEFORE Leptos server functions // Register custom API routes BEFORE Leptos server functions
.service( .service(
web::scope("/api") web::scope("/api")
.route("/items", web::get().to(get_items)) // GET /api/items .service(
.route("/items", web::post().to(create_item)) // POST /api/items web::scope("/urls/{url}")
.route("/items/{id}", web::delete().to(delete_item)) // DELETE /api/items/{id} .route("/items", web::get().to(get_items_handler)) // GET items by URL
.route("/items", web::post().to(create_item_handler)) // Create item for URL
.route("/items/{item_id}", web::delete().to(delete_item_handler)) // Delete item
)
.route("/properties/{property}", web::delete().to(delete_property)), // DELETE /api/properties/{property} .route("/properties/{property}", web::delete().to(delete_property)), // DELETE /api/properties/{property}
) )
// Register server functions // Register server functions
@ -55,12 +68,57 @@ async fn main() -> std::io::Result<()> {
//.wrap(middleware::Compress::default()) //.wrap(middleware::Compress::default())
// Pass the database as shared state // Pass the database as shared state
.app_data(web::Data::new(db)) .app_data(web::Data::new(db))
// Register URL routing
.service(web::resource("/").route(web::get().to(index)))
.service(web::resource("/{url}").route(web::get().to(url_handler)))
}) })
.bind(&addr)? .bind(&addr)?
.run() .run()
.await .await
} }
// Handler to get items for a specific URL
async fn get_items_handler(
db: web::Data<Arc<Mutex<Database>>>,
url: web::Path<String>,
) -> impl Responder {
get_items(db, web::Query(url.into_inner())).await
}
// Handler to create an item for a specific URL
async fn create_item_handler(
db: web::Data<Arc<Mutex<Database>>>,
url: web::Path<String>,
item: web::Json<Item>,
) -> impl Responder {
let request = ItemRequest {
url: url.into_inner(),
item: item.into_inner()
};
create_item(db, web::Json(request)).await
}
// Handler to delete an item for a specific URL
async fn delete_item_handler(
db: web::Data<Arc<Mutex<Database>>>,
path: web::Path<(String, String)>,
) -> impl Responder {
let (url, item_id) = path.into_inner();
delete_item_by_url(db, web::Path::from(url), web::Path::from(item_id)).await
}
#[cfg(feature = "ssr")]
// Define the index handler
async fn index() -> HttpResponse {
HttpResponse::Ok().body("Welcome to CompareWare!")
}
#[cfg(feature = "ssr")]
// Define the URL handler
async fn url_handler(url: web::Path<String>) -> HttpResponse {
let url = url.into_inner();
// TO DO: Implement URL-based content storage and editing functionality
HttpResponse::Ok().body(format!("You are viewing the content at {}", url))
}
#[cfg(feature = "ssr")] #[cfg(feature = "ssr")]
#[actix_web::get("favicon.ico")] #[actix_web::get("favicon.ico")]
async fn favicon( async fn favicon(

View file

@ -7,13 +7,6 @@ pub struct Item {
pub id: String, pub id: String,
pub name: String, pub name: String,
pub description: String, pub description: String,
// pub reviews: Vec<ReviewWithRating>,
pub wikidata_id: Option<String>, pub wikidata_id: Option<String>,
pub custom_properties: HashMap<String, String>, pub custom_properties: HashMap<String, String>,
} }
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct ReviewWithRating {
pub content: String,
pub rating: u8, // Ratings from 1 to 5
}