diff --git a/.gitignore b/.gitignore index fb0460a..9a4c1d6 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,5 @@ target/ **/*.rs.bk *.sqlite + +dabase \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock index cc42ac4..c8bc42c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -145,12 +145,56 @@ dependencies = [ "unicode-width", ] +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + [[package]] name = "anstyle" version = "1.0.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + [[package]] name = "anyhow" version = "1.0.104" @@ -727,6 +771,46 @@ dependencies = [ "libloading", ] +[[package]] +name = "clap" +version = "4.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "473c7e07f409a8d772161724aa8db6a765a2532a70f9667eeb7b49d3d02fbdca" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b48fea5a88e9ae728a2dcbedbfc0e730f7d60da42e1cb049a83c9fb8b789889" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d012d2b9d65aca7f18f4d9878a045bc17899bba951561ba5ec3c2ba1eed9a061" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + [[package]] name = "clipboard-win" version = "5.4.1" @@ -751,6 +835,12 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + [[package]] name = "combine" version = "4.6.7" @@ -2604,6 +2694,7 @@ dependencies = [ name = "inkopslista-srv" version = "0.1.0" dependencies = [ + "clap", "inkopslista-lib", "rocket", "serde", @@ -2704,6 +2795,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + [[package]] name = "itertools" version = "0.13.0" @@ -3824,6 +3921,12 @@ dependencies = [ "portable-atomic", ] +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + [[package]] name = "orbclient" version = "0.3.55" @@ -5468,6 +5571,12 @@ dependencies = [ "float-cmp", ] +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + [[package]] name = "strum" version = "0.28.0" @@ -6241,6 +6350,12 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + [[package]] name = "uuid" version = "1.24.0" diff --git a/gui/src/config.rs b/gui/src/config.rs index c133457..710e880 100644 --- a/gui/src/config.rs +++ b/gui/src/config.rs @@ -18,6 +18,8 @@ pub use fs::*; #[cfg(target_os = "linux")] mod fs { + use crate::runtime::spawn; + use super::Config; use anyhow::{Context, Ok}; @@ -33,7 +35,7 @@ mod fs { .place_config_file(CONFIG_FILE_NAME) .context("unable to create config directory")?; - tokio::spawn(async { + spawn(async { let res = tokio::fs::write(path, conf) .await .context("unable to write config file"); diff --git a/gui/src/lib.rs b/gui/src/lib.rs index fc1d78d..627a480 100644 --- a/gui/src/lib.rs +++ b/gui/src/lib.rs @@ -16,7 +16,7 @@ use std::{ sync::{Arc, Mutex}, }; -use inkopslista_lib::{Item, ItemData}; +use inkopslista_lib::{ButtonItem, Item, ItemData}; use reqwest::Url; use slint::{ComponentHandle, Model, ModelRc, ToSharedString, VecModel}; @@ -35,6 +35,13 @@ async fn get_list(conf: &Mutex) -> Result, anyhow::Error> { Ok(list) } +async fn get_favorites(conf: &Mutex) -> Result, anyhow::Error> { + let base = Url::parse(&conf.lock().unwrap().address)?; + let url = base.join("/api/favorites")?; + let favs = reqwest::get(url).await?.json::>().await?; + Ok(favs) +} + /// Add or update an item in the list async fn put_list(item: &str, data: &ItemData, conf: &Mutex) -> Result<(), anyhow::Error> { let client = reqwest::Client::new(); @@ -57,6 +64,26 @@ async fn delete_item(item: &str, conf: &Mutex) -> Result<(), anyhow::Err Ok(()) } +async fn put_category( + itemname: &str, + category: &str, + conf: &Mutex, +) -> Result<(), anyhow::Error> { + let client = reqwest::Client::new(); + let url: Url = format!( + "{address}/api/favourites/{itemname}/category", + address = conf.lock().unwrap().address + ) + .parse()?; + let _response = client + .put(url) + .body(category.to_string()) + .send() + .await? + .error_for_status()?; + Ok(()) +} + /// Convert shopping list to slint types fn update_slint_list(new_list: Vec, list: &VecModel) { list.clear(); @@ -70,6 +97,17 @@ fn update_slint_list(new_list: Vec, list: &VecModel) { .for_each(|item| list.push(item)); } +fn update_slint_favs(new_favs: Vec, favs: &VecModel) { + favs.clear(); + new_favs + .into_iter() + .map(|item| ui::SButtonItem { + name: item.name.into(), + category: item.category.unwrap_or_default().to_shared_string(), + }) + .for_each(|item| favs.push(item)); +} + fn refresh_list(conf: &Arc>, app_weak: &slint::Weak, io: &Io) { let conf = conf.clone(); let app_weak = app_weak.clone(); @@ -81,6 +119,13 @@ fn refresh_list(conf: &Arc>, app_weak: &slint::Weak, io: list.as_any().downcast_ref().expect("list is a VecModel"); update_slint_list(new_list, list); }); + let new_favs = get_favorites(&conf).await?; + let _ = app_weak.upgrade_in_event_loop(|app| { + let favs: ModelRc = app.global::().get_favorites(); + let favs: &VecModel = + favs.as_any().downcast_ref().expect("favs is a vec model"); + update_slint_favs(new_favs, favs); + }); Ok(()) }); } @@ -90,7 +135,9 @@ pub fn run() -> Result<(), anyhow::Error> { let io_ = Io::new(&app); let state = app.global::(); let list_shared = Rc::new(VecModel::default()); + let fav_shared = Rc::new(VecModel::default()); state.set_list(ModelRc::new(list_shared.clone())); + state.set_favorites(ModelRc::new(fav_shared.clone())); #[cfg(target_os = "linux")] let conf = runtime::RT @@ -275,6 +322,16 @@ pub fn run() -> Result<(), anyhow::Error> { } }); + let conf = conf_shared.clone(); + let io = io_.clone(); + + state.on_set_cat(move |itemname, cat| { + let conf = conf.clone(); + + io.spawn(async move || put_category(&itemname, &cat, &conf).await); + return; + }); + state.on_set_server_address(move |address| { conf_shared.lock().unwrap().address = address.to_string(); #[cfg(target_os = "linux")] diff --git a/gui/ui/app.slint b/gui/ui/app.slint index 96bb579..f5c6663 100644 --- a/gui/ui/app.slint +++ b/gui/ui/app.slint @@ -7,13 +7,14 @@ import { CheckBox, ScrollView, GridBox, + HorizontalBox, + ComboBox, Palette, Spinner, SpinBox, } from "std-widgets.slint"; import { SettingsView } from "settings.slint"; -import { ConnectionStatus, State, SItem } from "state.slint"; +import { ConnectionStatus, State, SItem, SButtonItem } from "state.slint"; export { ConnectionStatus, State, SItem } - component GoodListItem inherits Rectangle { in-out property item; background: Palette.alternate-background; @@ -178,43 +179,25 @@ component GoodsListAdd { } component GoodButtons inherits ScrollView { - in property <[string]> favorites: [ - "Balsam", - "Broccolli", - "Bröd", - "Bullens", - "Krossade Tomater", - "Kyckling", - "Matgrädde", - "Mjölk", - "Nästejp", - "Ost", - "Schampo", - "Smör", - "Spenat", - "Tandkräm", - "Toalettpapper", - "Ägg", - ]; in property button-min-width: 100pt; in property button-height: 60pt; in property button-spacing: 10pt; property count-x: floor(root.width / (button-min-width + button-spacing)); - property count-y: ceil(favorites.length / count-x); + property count-y: ceil(State.favorites.length / count-x); property button-width: ((self.width + button-spacing ) / count-x) - button-spacing; viewport-height: (count-y * (button-height + button-spacing)); mouse-drag-pan-enabled: true; - for item[idx] in favorites: Button { + for item[idx] in State.favorites: Button { x: floor(mod(idx, count-x)) * (root.button-width + root.button-spacing); y: floor(idx / count-x) * (root.button-height + root.button-spacing); width: root.button-width; height: root.button-height; - text: item; + text: item.name; clicked => { - State.add-to-list(item); + State.add-to-list(item.name); } } } @@ -321,6 +304,32 @@ component ShopView inherits VerticalLayout { } } +component CatItem inherits HorizontalBox { + in-out property item; + + Text { + text: item.name; + vertical-alignment: center; + font-size: 16px; + } + + ComboBox { + model: ["kyl", "skafferi", "frys", "övrigt"]; + current-value: item.category; + width: 60%; + selected(category) => { State.set-cat(item.name, category) } + } +} + + +component CatView inherits VerticalLayout { + in-out property <[SButtonItem]> cats; + + for item in cats: CatItem { + item: item; + } +} + export component App inherits Window { title: "Inköpslista"; @@ -344,6 +353,13 @@ export component App inherits Window { } } + Tab { + title: "Categories"; + CatView { + cats <=> State.favorites; + } + } + Tab { title: "Settings"; SettingsView { } diff --git a/gui/ui/state.slint b/gui/ui/state.slint index 14d4f29..c35cffe 100644 --- a/gui/ui/state.slint +++ b/gui/ui/state.slint @@ -4,6 +4,11 @@ export struct SItem { amount: int, } +export struct SButtonItem{ + name: string, + category: string, +} + export enum ConnectionStatus { Idle, Syncing, @@ -22,7 +27,13 @@ export global State { callback delete-checked(); callback inc-amount(string); callback dec-amount(string); + callback set-cat(string, string); in-out property server-address; + in property <[SButtonItem]> favorites: [ + { name: "mjölk", category: "kyl" }, + { name: "ost", category: "kyl" }, + ]; + callback set-server-address(string); set-server-address(address) => { server-address = address; diff --git a/lib/src/lib.rs b/lib/src/lib.rs index 5724275..67920a8 100644 --- a/lib/src/lib.rs +++ b/lib/src/lib.rs @@ -7,9 +7,13 @@ pub struct Item { } #[derive(Serialize, Deserialize, Default, Debug, Clone)] - pub struct ItemData { pub checked: bool, pub amount: i64, - // TODO: add number +} + +#[derive(Serialize, Deserialize, Default, Debug, Clone)] +pub struct ButtonItem { + pub name: String, + pub category: Option, } diff --git a/srv/Cargo.toml b/srv/Cargo.toml index 72af46b..ae6c8b3 100644 --- a/srv/Cargo.toml +++ b/srv/Cargo.toml @@ -9,3 +9,4 @@ serde = { version = "1.0.228", features = ["derive"] } serde_json = "1.0.145" inkopslista-lib = { path = "../lib" } sqlite = "0.37.0" +clap = { version = "4.6.6", features = ["derive", "env"] } diff --git a/srv/src/db/init.sql b/srv/src/db/init.sql index 3111164..da78852 100644 --- a/srv/src/db/init.sql +++ b/srv/src/db/init.sql @@ -3,4 +3,14 @@ CREATE TABLE itemname TEXT NOT NULL PRIMARY KEY, checked INT NOT NULL, -- actually a boolean amount INT NOT NULL - ) STRICT; \ No newline at end of file + ) STRICT; + +CREATE TABLE + IF NOT EXISTS adds ( + itemname TEXT NOT NULL, + date_added INT NOT NULL, + PRIMARY KEY (itemname, date_added) + ) STRICT; + +CREATE TABLE + IF NOT EXISTS categories (itemname TEXT NOT NULL PRIMARY KEY, category TEXT) STRICT; \ No newline at end of file diff --git a/srv/src/main.rs b/srv/src/main.rs index e4c4ccf..a2cd0b0 100644 --- a/srv/src/main.rs +++ b/srv/src/main.rs @@ -1,16 +1,16 @@ #[macro_use] extern crate rocket; -use std::{env, path::Path}; - -use inkopslista_lib::{Item, ItemData}; +use clap::Parser; +use inkopslista_lib::{ButtonItem, Item, ItemData}; use rocket::{ + State, fs::{FileServer, NamedFile}, serde::json::Json, shield::Shield, - State, }; use sqlite::ConnectionThreadSafe; +use std::path::{Path, PathBuf}; /// The directory where the web files for the gui is stored. fn www_path() -> &'static Path { @@ -47,6 +47,44 @@ fn get_list(db: &State) -> Json> { Json(list) } +#[get("/api/favorites")] +fn get_favorites(db: &State) -> Json> { + let opt = Opt::parse(); + + let query = format!("WITH favorites as (SELECT itemname, COUNT(date_added) day_count + FROM adds + WHERE (date(julianday('now')) - date(date_added, 'unixepoch')) < 184 + GROUP BY itemname + HAVING day_count > {num_days}) + SELECT favorites.itemname, category FROM favorites LEFT OUTER JOIN categories ON favorites.itemname = categories.itemname", num_days = opt.num_days); + let list: Vec = db + .prepare(query) + .unwrap() + .into_iter() + .map(|row| { + let row = row.unwrap(); + ButtonItem { + name: row.read::<&str, _>("itemname").to_string(), + category: row + .try_read::<&str, _>("category") + .ok() + .map(ToString::to_string), + } + }) + .collect(); + Json(list) +} + +#[put("/api/favourites//category", data = "")] +fn put_category(db: &State, itemname: &str, category: &str) { + let query = "INSERT INTO categories VALUES (?, ?) ON CONFLICT DO UPDATE SET category = ?"; + let mut statement = db.prepare(query).unwrap(); + statement.bind((1, itemname)).unwrap(); + statement.bind((2, category)).unwrap(); + statement.bind((3, category)).unwrap(); + while let Ok(sqlite::State::Row) = statement.next() {} +} + #[put("/api/list/", data = "")] fn put_list(item: &str, data: Json, db: &State) { // TODO: errorhandling @@ -60,6 +98,10 @@ fn put_list(item: &str, data: Json, db: &State) statement.bind((4, data.checked as i64)).unwrap(); statement.bind((5, data.amount)).unwrap(); while let Ok(sqlite::State::Row) = statement.next() {} + let query = "INSERT INTO adds VALUES(?, strftime('%s','now')) ON CONFLICT DO NOTHING"; + let mut statement = db.prepare(query).unwrap(); + statement.bind((1, item)).unwrap(); + while let Ok(sqlite::State::Row) = statement.next() {} } #[delete("/api/list/")] @@ -71,10 +113,18 @@ fn delete_item(item: &str, db: &State) { while let Ok(sqlite::State::Row) = statement.next() {} } +#[derive(Parser)] +struct Opt { + #[clap(long, env = "DB_PATH")] + db_path: PathBuf, + #[clap(long, env = "NUM_DAYS", default_value = "5")] + num_days: u32, +} + #[launch] fn rocket() -> _ { - let db_path = env::var("DB_PATH").unwrap(); - let db = sqlite::Connection::open_thread_safe(db_path).unwrap(); + let opt = Opt::parse(); + let db = sqlite::Connection::open_thread_safe(opt.db_path).unwrap(); db.execute(include_str!("db/init.sql")) .expect("unable to initialize database"); rocket::build() @@ -84,6 +134,16 @@ fn rocket() -> _ { .disable::(), ) .manage(db) - .mount("/", routes![index, get_list, put_list, delete_item]) + .mount( + "/", + routes![ + index, + get_list, + put_list, + delete_item, + get_favorites, + put_category + ], + ) .mount("/", FileServer::from(www_path())) }