Compare commits
191 Commits
e814bb10f8
...
a5990a2a30
Author | SHA1 | Date |
---|---|---|
Savanni D'Gerinel | a5990a2a30 | |
Savanni D'Gerinel | bd6d5b62e3 | |
Savanni D'Gerinel | 82c1765513 | |
Savanni D'Gerinel | de54ec676f | |
Savanni D'Gerinel | 5612c89a61 | |
Savanni D'Gerinel | c3c144e035 | |
Savanni D'Gerinel | 05a6dcf3af | |
Savanni D'Gerinel | b98e0bdcea | |
Savanni D'Gerinel | 3d4a298dc1 | |
Savanni D'Gerinel | 2d7fbb9a4b | |
Savanni D'Gerinel | 3a5cb17e09 | |
Savanni D'Gerinel | f6c82cbcb0 | |
Savanni D'Gerinel | 74b00d94b1 | |
Savanni D'Gerinel | ddf83b3018 | |
Savanni D'Gerinel | a1f41a440f | |
Savanni D'Gerinel | e838f601ca | |
Savanni D'Gerinel | 1b0a90a332 | |
Savanni D'Gerinel | 4dc6e3151b | |
Savanni D'Gerinel | 79cec6e21d | |
Savanni D'Gerinel | 32cc74edfa | |
Savanni D'Gerinel | d3d3260091 | |
Savanni D'Gerinel | a1441f7bb1 | |
Savanni D'Gerinel | 9e7350b087 | |
Savanni D'Gerinel | 0032f16422 | |
Savanni D'Gerinel | a5d51dab70 | |
Savanni D'Gerinel | c24a5f515f | |
Savanni D'Gerinel | d70ca08db2 | |
Savanni D'Gerinel | 55b6327d42 | |
Savanni D'Gerinel | 1c2f40c868 | |
Savanni D'Gerinel | 1527942f9c | |
Savanni D'Gerinel | 7ba758b325 | |
Savanni D'Gerinel | aed4735209 | |
Savanni D'Gerinel | c14b20b79e | |
Savanni D'Gerinel | 56ff5527ba | |
Savanni D'Gerinel | f7f55d74fd | |
Savanni D'Gerinel | 843924afef | |
Savanni D'Gerinel | 86d7ca0b01 | |
Savanni D'Gerinel | d137ee2481 | |
Savanni D'Gerinel | 74dbb58ed9 | |
Savanni D'Gerinel | 9a18496c95 | |
Savanni D'Gerinel | 01776a534e | |
Savanni D'Gerinel | b33d17c256 | |
Savanni D'Gerinel | ab59eedef5 | |
Savanni D'Gerinel | 4dd6afeae7 | |
Savanni D'Gerinel | cb2bec4287 | |
Savanni D'Gerinel | 5a93c4fdcd | |
Savanni D'Gerinel | 6e5cbc0930 | |
Savanni D'Gerinel | 73a5ab89a3 | |
Savanni D'Gerinel | 291dc32fe5 | |
Savanni D'Gerinel | b5c42e3ac3 | |
Savanni D'Gerinel | 6394d89331 | |
Savanni D'Gerinel | 38b1e62b60 | |
Savanni D'Gerinel | 4acf034b8d | |
Savanni D'Gerinel | 1aff203afc | |
Savanni D'Gerinel | 9fc9d2b758 | |
Savanni D'Gerinel | 76f4b31466 | |
Savanni D'Gerinel | 73052a0694 | |
Savanni D'Gerinel | 2c42c35dfe | |
Savanni D'Gerinel | afe693fe10 | |
Savanni D'Gerinel | af1422d523 | |
Savanni D'Gerinel | 792e20d44b | |
Savanni D'Gerinel | 8016188b29 | |
Savanni D'Gerinel | 74df2880bb | |
Savanni D'Gerinel | 96c4201680 | |
Savanni D'Gerinel | ecdd38ebbc | |
Savanni D'Gerinel | 2fb8728856 | |
Savanni D'Gerinel | a7d43ef184 | |
Savanni D'Gerinel | 9727d35116 | |
Savanni D'Gerinel | 1d6155d9e5 | |
Savanni D'Gerinel | a8bf540517 | |
Savanni D'Gerinel | 3db870d790 | |
Savanni D'Gerinel | 24276d172b | |
Savanni D'Gerinel | 96317f5692 | |
Savanni D'Gerinel | c1e797f3ae | |
Savanni D'Gerinel | 304008c674 | |
Savanni D'Gerinel | 772188b470 | |
Savanni D'Gerinel | bc31522c95 | |
Savanni D'Gerinel | 6c68564a77 | |
Savanni D'Gerinel | 55c1a6372f | |
Savanni D'Gerinel | 2cbd539bf4 | |
Savanni D'Gerinel | 7d14308def | |
Savanni D'Gerinel | dcd6301bb9 | |
Savanni D'Gerinel | 69567db486 | |
Savanni D'Gerinel | f8d66bbb69 | |
Savanni D'Gerinel | dce11dde2b | |
Savanni D'Gerinel | 3f9a7072eb | |
Savanni D'Gerinel | 7ec48ded5d | |
Savanni D'Gerinel | 9461c387fe | |
Savanni D'Gerinel | d4c48c4443 | |
Savanni D'Gerinel | 9bedb7a76c | |
Savanni D'Gerinel | 1fe318068b | |
Savanni D'Gerinel | 18e7e4fe2f | |
Savanni D'Gerinel | 1c2c4982a1 | |
Savanni D'Gerinel | c075b7ed6e | |
Savanni D'Gerinel | 56d0a53666 | |
Savanni D'Gerinel | b00acc64a3 | |
Savanni D'Gerinel | 104760c754 | |
Savanni D'Gerinel | 1e6555ef61 | |
Savanni D'Gerinel | 2e2ff6b47e | |
Savanni D'Gerinel | 2d22397382 | |
Savanni D'Gerinel | 2c7666304a | |
Savanni D'Gerinel | 0007522b26 | |
Savanni D'Gerinel | b7b9b1b29f | |
Savanni D'Gerinel | 2e3d5fc5a4 | |
Savanni D'Gerinel | a25b76d230 | |
Savanni D'Gerinel | 9970161c30 | |
Savanni D'Gerinel | 7bd4885b09 | |
Savanni D'Gerinel | b5dcee3737 | |
Savanni D'Gerinel | 0c3ae062c8 | |
Savanni D'Gerinel | f422e233a1 | |
Savanni D'Gerinel | 7a6e902fdd | |
Savanni D'Gerinel | 6d9e2ea382 | |
Savanni D'Gerinel | 04a48574d3 | |
Savanni D'Gerinel | e13e7cf4c3 | |
Savanni D'Gerinel | 383f809191 | |
Savanni D'Gerinel | d269924827 | |
Savanni D'Gerinel | 8049859816 | |
Savanni D'Gerinel | ac343a2af6 | |
Savanni D'Gerinel | 5cd0e822c6 | |
Savanni D'Gerinel | fe5e4ed044 | |
Savanni D'Gerinel | e30668ca8e | |
Savanni D'Gerinel | 149587f0bd | |
Savanni D'Gerinel | d2f4ec97c0 | |
Savanni D'Gerinel | c94b7db484 | |
Savanni D'Gerinel | 85e2494c3b | |
Savanni D'Gerinel | af8f9b0244 | |
Savanni D'Gerinel | 1b3ca7439d | |
Savanni D'Gerinel | 3dc8be0d26 | |
Savanni D'Gerinel | 43cd408e2c | |
Savanni D'Gerinel | 3a728a51b4 | |
Savanni D'Gerinel | f19090311b | |
Savanni D'Gerinel | dedcc76df0 | |
Savanni D'Gerinel | 6678ab9852 | |
Savanni D'Gerinel | 9c200f555c | |
Savanni D'Gerinel | 3ca8bf64cc | |
Savanni D'Gerinel | 87994012fa | |
Savanni D'Gerinel | 50268ffadc | |
Savanni D'Gerinel | beedeba8dc | |
Savanni D'Gerinel | db188ea75a | |
Savanni D'Gerinel | 104ffc5782 | |
Savanni D'Gerinel | 38db3d6780 | |
Savanni D'Gerinel | 0dd0a5f7cc | |
Savanni D'Gerinel | acdf9ec150 | |
Savanni D'Gerinel | 0ebdcd7c2a | |
Savanni D'Gerinel | baf652173c | |
Savanni D'Gerinel | c4befcc6de | |
Savanni D'Gerinel | a7d6d82ec2 | |
Savanni D'Gerinel | f3a453d151 | |
Savanni D'Gerinel | b9aa434278 | |
Savanni D'Gerinel | 83a4839b1d | |
Savanni D'Gerinel | 0e0d67a9ac | |
Savanni D'Gerinel | e5fb605816 | |
Savanni D'Gerinel | f9db002464 | |
Savanni D'Gerinel | 0ac9bb74a6 | |
Savanni D'Gerinel | f034dfcb8b | |
Savanni D'Gerinel | 7abb33c4fe | |
Savanni D'Gerinel | 581979fc54 | |
Savanni D'Gerinel | bf93625225 | |
Savanni D'Gerinel | 778da0b651 | |
Savanni D'Gerinel | 8b53114d0d | |
Savanni D'Gerinel | 42e931d780 | |
Savanni D'Gerinel | 532210db03 | |
Savanni D'Gerinel | 37f6334c9f | |
Savanni D'Gerinel | 3310c460ba | |
Savanni D'Gerinel | 6d14cdbe2a | |
Savanni D'Gerinel | c46ab1b389 | |
Savanni D'Gerinel | 168ba6eb40 | |
Savanni D'Gerinel | 7e3ee9a5b7 | |
Savanni D'Gerinel | 86a6d386d2 | |
Savanni D'Gerinel | e461cb9908 | |
Savanni D'Gerinel | 942e91009e | |
Savanni D'Gerinel | 48113d6ccb | |
Savanni D'Gerinel | d878f4e82c | |
Savanni D'Gerinel | 7949033857 | |
Savanni D'Gerinel | ce874e1d30 | |
Savanni D'Gerinel | 07b8bb7bfe | |
Savanni D'Gerinel | a403c1b1b3 | |
Savanni D'Gerinel | 9a014af75a | |
Savanni D'Gerinel | 448231739b | |
Savanni D'Gerinel | b0027032a4 | |
Savanni D'Gerinel | 41bbfa14f3 | |
Savanni D'Gerinel | 66876e41c0 | |
Savanni D'Gerinel | ee348c29cb | |
Savanni D'Gerinel | e96b8087e2 | |
Savanni D'Gerinel | 12df1f4b9b | |
Savanni D'Gerinel | c2e34db79c | |
Savanni D'Gerinel | 0fbfb4f1ad | |
Savanni D'Gerinel | c2e78d7c54 | |
Savanni D'Gerinel | 2ceccbf38d | |
Savanni D'Gerinel | fbf6a9e76e | |
Savanni D'Gerinel | 52f814e663 |
|
@ -5,7 +5,7 @@ dist
|
|||
result
|
||||
*.tgz
|
||||
*.tar.gz
|
||||
file-service/*.sqlite
|
||||
file-service/*.sqlite-shm
|
||||
file-service/*.sqlite-wal
|
||||
*.sqlite
|
||||
*.sqlite-shm
|
||||
*.sqlite-wal
|
||||
file-service/var
|
||||
|
|
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"rust-analyzer.showUnlinkedFileNotification": false
|
||||
}
|
File diff suppressed because it is too large
Load Diff
|
@ -1,5 +1,7 @@
|
|||
[workspace]
|
||||
resolver = "2"
|
||||
members = [
|
||||
"authdb",
|
||||
"changeset",
|
||||
"config",
|
||||
"config-derive",
|
||||
|
@ -8,10 +10,13 @@ members = [
|
|||
"dashboard",
|
||||
"emseries",
|
||||
"file-service",
|
||||
"fitnesstrax/core",
|
||||
"fitnesstrax/app",
|
||||
"fluent-ergonomics",
|
||||
"geo-types",
|
||||
"gm-control-panel",
|
||||
"hex-grid",
|
||||
"icon-test",
|
||||
"ifc",
|
||||
"kifu/core",
|
||||
"kifu/gtk",
|
||||
|
@ -20,4 +25,7 @@ members = [
|
|||
"result-extended",
|
||||
"screenplay",
|
||||
"sgf",
|
||||
"timezone-testing",
|
||||
"tree",
|
||||
"visions/server",
|
||||
]
|
||||
|
|
30
Makefile
30
Makefile
|
@ -1,30 +0,0 @@
|
|||
|
||||
all: test bin
|
||||
|
||||
test: kifu-core/test-oneshot sgf/test-oneshot
|
||||
|
||||
bin: kifu-gtk
|
||||
|
||||
kifu-core/dev:
|
||||
cd kifu/core && make test
|
||||
|
||||
kifu-core/test:
|
||||
cd kifu/core && make test
|
||||
|
||||
kifu-core/test-oneshot:
|
||||
cd kifu/core && make test-oneshot
|
||||
|
||||
kifu-gtk:
|
||||
cd kifu/gtk && make release
|
||||
|
||||
kifu-gtk/dev:
|
||||
cd kifu/gtk && make dev
|
||||
|
||||
kifu-pwa:
|
||||
cd kifu/pwa && make release
|
||||
|
||||
kifu-pwa/dev:
|
||||
pushd kifu/pwa && make dev
|
||||
|
||||
kifu-pwa/server:
|
||||
pushd kifu/pwa && make server
|
|
@ -0,0 +1,27 @@
|
|||
[package]
|
||||
name = "authdb"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||
|
||||
[lib]
|
||||
name = "authdb"
|
||||
path = "src/lib.rs"
|
||||
|
||||
[[bin]]
|
||||
name = "auth-cli"
|
||||
path = "src/bin/cli.rs"
|
||||
|
||||
[dependencies]
|
||||
base64ct = { version = "1", features = [ "alloc" ] }
|
||||
clap = { version = "4", features = [ "derive" ] }
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
sha2 = { version = "0.10" }
|
||||
sqlx = { version = "0.7", features = [ "runtime-tokio", "sqlite" ] }
|
||||
thiserror = { version = "1" }
|
||||
tokio = { version = "1", features = [ "full" ] }
|
||||
uuid = { version = "0.4", features = [ "serde", "v4" ] }
|
||||
|
||||
[dev-dependencies]
|
||||
cool_asserts = "*"
|
|
@ -1,5 +1,5 @@
|
|||
use authdb::{AuthDB, Username};
|
||||
use clap::{Parser, Subcommand};
|
||||
use file_service::{AuthDB, Username};
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[derive(Subcommand, Debug)]
|
|
@ -0,0 +1,302 @@
|
|||
use base64ct::{Base64, Encoding};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
use sqlx::{
|
||||
sqlite::{SqlitePool, SqliteRow},
|
||||
Row,
|
||||
};
|
||||
use std::ops::Deref;
|
||||
use std::path::PathBuf;
|
||||
use thiserror::Error;
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum AuthError {
|
||||
#[error("authentication token is duplicated")]
|
||||
DuplicateAuthToken,
|
||||
|
||||
#[error("session token is duplicated")]
|
||||
DuplicateSessionToken,
|
||||
|
||||
#[error("database failed")]
|
||||
SqlError(sqlx::Error),
|
||||
}
|
||||
|
||||
impl From<sqlx::Error> for AuthError {
|
||||
fn from(err: sqlx::Error) -> AuthError {
|
||||
AuthError::SqlError(err)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, Hash, PartialEq, Eq)]
|
||||
pub struct Username(String);
|
||||
|
||||
impl From<String> for Username {
|
||||
fn from(s: String) -> Self {
|
||||
Self(s)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&str> for Username {
|
||||
fn from(s: &str) -> Self {
|
||||
Self(s.to_owned())
|
||||
}
|
||||
}
|
||||
|
||||
impl From<Username> for String {
|
||||
fn from(s: Username) -> Self {
|
||||
Self::from(&s)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&Username> for String {
|
||||
fn from(s: &Username) -> Self {
|
||||
let Username(s) = s;
|
||||
Self::from(s)
|
||||
}
|
||||
}
|
||||
|
||||
impl Deref for Username {
|
||||
type Target = String;
|
||||
fn deref(&self) -> &Self::Target {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl sqlx::FromRow<'_, SqliteRow> for Username {
|
||||
fn from_row(row: &SqliteRow) -> sqlx::Result<Self> {
|
||||
let name: String = row.try_get("username")?;
|
||||
Ok(Username::from(name))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, Hash, PartialEq, Eq)]
|
||||
pub struct AuthToken(String);
|
||||
|
||||
impl From<String> for AuthToken {
|
||||
fn from(s: String) -> Self {
|
||||
Self(s)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&str> for AuthToken {
|
||||
fn from(s: &str) -> Self {
|
||||
Self(s.to_owned())
|
||||
}
|
||||
}
|
||||
|
||||
impl From<AuthToken> for PathBuf {
|
||||
fn from(s: AuthToken) -> Self {
|
||||
Self::from(&s)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&AuthToken> for PathBuf {
|
||||
fn from(s: &AuthToken) -> Self {
|
||||
let AuthToken(s) = s;
|
||||
Self::from(s)
|
||||
}
|
||||
}
|
||||
|
||||
impl Deref for AuthToken {
|
||||
type Target = String;
|
||||
fn deref(&self) -> &Self::Target {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, Hash, PartialEq, Eq)]
|
||||
pub struct SessionToken(String);
|
||||
|
||||
impl From<String> for SessionToken {
|
||||
fn from(s: String) -> Self {
|
||||
Self(s)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&str> for SessionToken {
|
||||
fn from(s: &str) -> Self {
|
||||
Self(s.to_owned())
|
||||
}
|
||||
}
|
||||
|
||||
impl From<SessionToken> for PathBuf {
|
||||
fn from(s: SessionToken) -> Self {
|
||||
Self::from(&s)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&SessionToken> for PathBuf {
|
||||
fn from(s: &SessionToken) -> Self {
|
||||
let SessionToken(s) = s;
|
||||
Self::from(s)
|
||||
}
|
||||
}
|
||||
|
||||
impl Deref for SessionToken {
|
||||
type Target = String;
|
||||
fn deref(&self) -> &Self::Target {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct AuthDB {
|
||||
pool: SqlitePool,
|
||||
}
|
||||
|
||||
impl AuthDB {
|
||||
pub async fn new(path: PathBuf) -> Result<Self, sqlx::Error> {
|
||||
let migrator = sqlx::migrate!("./migrations");
|
||||
let pool = SqlitePool::connect(&format!("sqlite://{}", path.to_str().unwrap())).await?;
|
||||
migrator.run(&pool).await?;
|
||||
Ok(Self { pool })
|
||||
}
|
||||
|
||||
pub async fn add_user(&self, username: Username) -> Result<AuthToken, AuthError> {
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(Uuid::new_v4().hyphenated().to_string());
|
||||
hasher.update(username.to_string());
|
||||
let auth_token = Base64::encode_string(&hasher.finalize());
|
||||
|
||||
let _ = sqlx::query("INSERT INTO users (username, token) VALUES ($1, $2)")
|
||||
.bind(username.to_string())
|
||||
.bind(auth_token.clone())
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
|
||||
Ok(AuthToken::from(auth_token))
|
||||
}
|
||||
|
||||
pub async fn list_users(&self) -> Result<Vec<Username>, AuthError> {
|
||||
let usernames = sqlx::query_as::<_, Username>("SELECT (username) FROM users")
|
||||
.fetch_all(&self.pool)
|
||||
.await?;
|
||||
|
||||
Ok(usernames)
|
||||
}
|
||||
|
||||
pub async fn authenticate(&self, token: AuthToken) -> Result<Option<SessionToken>, AuthError> {
|
||||
let results = sqlx::query("SELECT * FROM users WHERE token = $1")
|
||||
.bind(token.to_string())
|
||||
.fetch_all(&self.pool)
|
||||
.await?;
|
||||
|
||||
if results.len() > 1 {
|
||||
return Err(AuthError::DuplicateAuthToken);
|
||||
}
|
||||
|
||||
if results.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let user_id: i64 = results[0].try_get("id")?;
|
||||
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(Uuid::new_v4().hyphenated().to_string());
|
||||
hasher.update(token.to_string());
|
||||
let session_token = Base64::encode_string(&hasher.finalize());
|
||||
|
||||
let _ = sqlx::query("INSERT INTO sessions (token, user_id) VALUES ($1, $2)")
|
||||
.bind(session_token.clone())
|
||||
.bind(user_id)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
|
||||
Ok(Some(SessionToken::from(session_token)))
|
||||
}
|
||||
|
||||
pub async fn validate_session(
|
||||
&self,
|
||||
token: SessionToken,
|
||||
) -> Result<Option<Username>, AuthError> {
|
||||
let rows = sqlx::query(
|
||||
"SELECT users.username FROM sessions INNER JOIN users ON sessions.user_id = users.id WHERE sessions.token = $1",
|
||||
)
|
||||
.bind(token.to_string())
|
||||
.fetch_all(&self.pool)
|
||||
.await?;
|
||||
if rows.len() > 1 {
|
||||
return Err(AuthError::DuplicateSessionToken);
|
||||
}
|
||||
|
||||
if rows.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let username: String = rows[0].try_get("username")?;
|
||||
Ok(Some(Username::from(username)))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use cool_asserts::assert_matches;
|
||||
use std::collections::HashSet;
|
||||
|
||||
#[tokio::test]
|
||||
async fn can_create_and_list_users() {
|
||||
let db = AuthDB::new(PathBuf::from(":memory:"))
|
||||
.await
|
||||
.expect("a memory-only database will be created");
|
||||
let _ = db
|
||||
.add_user(Username::from("savanni"))
|
||||
.await
|
||||
.expect("user to be created");
|
||||
assert_matches!(db.list_users().await, Ok(names) => {
|
||||
let names = names.into_iter().collect::<HashSet<Username>>();
|
||||
assert!(names.contains(&Username::from("savanni")));
|
||||
})
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn unknown_auth_token_returns_nothing() {
|
||||
let db = AuthDB::new(PathBuf::from(":memory:"))
|
||||
.await
|
||||
.expect("a memory-only database will be created");
|
||||
let _ = db
|
||||
.add_user(Username::from("savanni"))
|
||||
.await
|
||||
.expect("user to be created");
|
||||
|
||||
let token = AuthToken::from("0000000000");
|
||||
|
||||
assert_matches!(db.authenticate(token).await, Ok(None));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn auth_token_becomes_session_token() {
|
||||
let db = AuthDB::new(PathBuf::from(":memory:"))
|
||||
.await
|
||||
.expect("a memory-only database will be created");
|
||||
let token = db
|
||||
.add_user(Username::from("savanni"))
|
||||
.await
|
||||
.expect("user to be created");
|
||||
|
||||
assert_matches!(db.authenticate(token).await, Ok(_));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn can_validate_session_token() {
|
||||
let db = AuthDB::new(PathBuf::from(":memory:"))
|
||||
.await
|
||||
.expect("a memory-only database will be created");
|
||||
let token = db
|
||||
.add_user(Username::from("savanni"))
|
||||
.await
|
||||
.expect("user to be created");
|
||||
let session = db
|
||||
.authenticate(token)
|
||||
.await
|
||||
.expect("token authentication should succeed")
|
||||
.expect("session token should be found");
|
||||
|
||||
assert_matches!(
|
||||
db.validate_session(session).await,
|
||||
Ok(Some(username)) => {
|
||||
assert_eq!(username, Username::from("savanni"));
|
||||
});
|
||||
}
|
||||
}
|
71
build.sh
71
build.sh
|
@ -1,71 +0,0 @@
|
|||
#!/usr/bin/env bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
RUST_ALL_TARGETS=(
|
||||
"changeset"
|
||||
"config"
|
||||
"config-derive"
|
||||
"coordinates"
|
||||
"cyberpunk-splash"
|
||||
"dashboard"
|
||||
"emseries"
|
||||
"file-service"
|
||||
"fluent-ergonomics"
|
||||
"geo-types"
|
||||
"gm-control-panel"
|
||||
"hex-grid"
|
||||
"ifc"
|
||||
"kifu-core"
|
||||
"kifu-gtk"
|
||||
"memorycache"
|
||||
"nom-training"
|
||||
"result-extended"
|
||||
"screenplay"
|
||||
"sgf"
|
||||
)
|
||||
|
||||
build_rust_targets() {
|
||||
local CMD=$1
|
||||
local TARGETS=${@/$CMD}
|
||||
|
||||
for target in $TARGETS; do
|
||||
MODULE=$target CMD=$CMD ./builders/rust.sh
|
||||
done
|
||||
}
|
||||
|
||||
build_dist() {
|
||||
local TARGETS=${@/$CMD}
|
||||
|
||||
for target in $TARGETS; do
|
||||
if [ -f $target/dist.sh ]; then
|
||||
build_rust_targets release ${TARGETS[*]}
|
||||
cd $target && ./dist.sh
|
||||
fi
|
||||
done
|
||||
}
|
||||
|
||||
export CARGO=`which cargo`
|
||||
|
||||
if [ -z "${TARGET-}" ]; then
|
||||
TARGET="all"
|
||||
fi
|
||||
|
||||
if [ -z "${CMD-}" ]; then
|
||||
CMD="test release"
|
||||
fi
|
||||
|
||||
if [ "${CMD}" == "clean" ]; then
|
||||
cargo clean
|
||||
exit 0
|
||||
fi
|
||||
|
||||
for cmd in $CMD; do
|
||||
if [ "${CMD}" == "dist" ]; then
|
||||
build_dist $TARGET
|
||||
elif [ "${TARGET}" == "all" ]; then
|
||||
build_rust_targets $cmd ${RUST_ALL_TARGETS[*]}
|
||||
else
|
||||
build_rust_targets $cmd $TARGET
|
||||
fi
|
||||
done
|
|
@ -1,41 +0,0 @@
|
|||
#!/usr/bin/env bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
if [ ! -z "$MODULE" ]; then
|
||||
MODULE="-p $MODULE"
|
||||
fi
|
||||
|
||||
if [ -z "${PARAMS-}" ]; then
|
||||
PARAMS=""
|
||||
fi
|
||||
|
||||
case $CMD in
|
||||
build)
|
||||
$CARGO build $MODULE $PARAMS
|
||||
;;
|
||||
lint)
|
||||
$CARGO clippy $MODULE $PARAMS -- -Dwarnings
|
||||
;;
|
||||
test)
|
||||
$CARGO test $MODULE $PARAMS
|
||||
;;
|
||||
run)
|
||||
$CARGO run $MODULE $PARAMS
|
||||
;;
|
||||
release)
|
||||
$CARGO clippy $MODULE $PARAMS -- -Dwarnings
|
||||
$CARGO build --release $MODULE $PARAMS
|
||||
$CARGO test --release $MODULE $PARAMS
|
||||
;;
|
||||
clean)
|
||||
$CARGO clean $MODULE
|
||||
;;
|
||||
"")
|
||||
echo "No command specified. Use build | lint | test | run | release | clean"
|
||||
;;
|
||||
*)
|
||||
echo "$CMD is unknown. Use build | lint | test | run | release | clean"
|
||||
;;
|
||||
esac
|
||||
|
|
@ -7,7 +7,7 @@ license = "GPL-3.0-only"
|
|||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||
|
||||
[dependencies]
|
||||
cairo-rs = { version = "0.17" }
|
||||
gio = { version = "0.17" }
|
||||
glib = { version = "0.17" }
|
||||
gtk = { version = "0.6", package = "gtk4" }
|
||||
cairo-rs = { version = "0.18" }
|
||||
gio = { version = "0.18" }
|
||||
glib = { version = "0.18" }
|
||||
gtk = { version = "0.7", package = "gtk4" }
|
||||
|
|
|
@ -703,7 +703,7 @@ fn main() {
|
|||
|
||||
app.connect_activate(move |app| {
|
||||
let (gtk_tx, gtk_rx) =
|
||||
gtk::glib::MainContext::channel::<State>(gtk::glib::PRIORITY_DEFAULT);
|
||||
gtk::glib::MainContext::channel::<State>(gtk::glib::Priority::DEFAULT);
|
||||
|
||||
let window = gtk::ApplicationWindow::new(app);
|
||||
window.present();
|
||||
|
@ -736,7 +736,7 @@ fn main() {
|
|||
|
||||
gtk_rx.attach(None, move |state| {
|
||||
splash.set_state(state);
|
||||
Continue(true)
|
||||
glib::ControlFlow::Continue
|
||||
});
|
||||
|
||||
std::thread::spawn({
|
||||
|
|
|
@ -1,22 +1,22 @@
|
|||
[package]
|
||||
name = "dashboard"
|
||||
version = "0.1.1"
|
||||
version = "0.1.2"
|
||||
edition = "2018"
|
||||
|
||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||
|
||||
[dependencies]
|
||||
adw = { version = "0.4", package = "libadwaita", features = [ "v1_2" ] }
|
||||
cairo-rs = { version = "0.17" }
|
||||
adw = { version = "0.5", package = "libadwaita", features = [ "v1_2" ] }
|
||||
cairo-rs = { version = "0.18" }
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
fluent-ergonomics = { path = "../fluent-ergonomics/" }
|
||||
fluent = { version = "0.16" }
|
||||
futures = { version = "0.3" }
|
||||
geo-types = { path = "../geo-types/" }
|
||||
gio = { version = "0.17" }
|
||||
glib = { version = "0.17" }
|
||||
gdk = { version = "0.6", package = "gdk4" }
|
||||
gtk = { version = "0.6", package = "gtk4" }
|
||||
gio = { version = "0.18" }
|
||||
glib = { version = "0.18" }
|
||||
gdk = { version = "0.7", package = "gdk4" }
|
||||
gtk = { version = "0.7", package = "gtk4" }
|
||||
ifc = { path = "../ifc/" }
|
||||
lazy_static = { version = "1.4" }
|
||||
memorycache = { path = "../memorycache/" }
|
||||
|
@ -28,5 +28,5 @@ tokio = { version = "1", features = ["full"] }
|
|||
unic-langid = { version = "0.9" }
|
||||
|
||||
[build-dependencies]
|
||||
glib-build-tools = "0.16"
|
||||
glib-build-tools = "0.18"
|
||||
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
fn main() {
|
||||
glib_build_tools::compile_resources(
|
||||
"resources",
|
||||
"resources/gresources.xml",
|
||||
&["resources"],
|
||||
"gresources.xml",
|
||||
"com.luminescent-dreams.dashboard.gresource",
|
||||
);
|
||||
}
|
||||
|
|
|
@ -11,10 +11,11 @@ pub struct DatePrivate {
|
|||
|
||||
impl Default for DatePrivate {
|
||||
fn default() -> Self {
|
||||
let date = chrono::Local::now().date_naive();
|
||||
let year = date.year();
|
||||
let date = date.with_year(year + 10000).unwrap();
|
||||
Self {
|
||||
date: Rc::new(RefCell::new(IFC::from(
|
||||
chrono::Local::now().date_naive().with_year(12023).unwrap(),
|
||||
))),
|
||||
date: Rc::new(RefCell::new(IFC::from(date))),
|
||||
label: Rc::new(RefCell::new(gtk::Label::new(None))),
|
||||
}
|
||||
}
|
||||
|
|
|
@ -102,7 +102,7 @@ pub fn main() {
|
|||
|
||||
let now = Local::now();
|
||||
let state = State {
|
||||
date: IFC::from(now.date_naive().with_year(12023).unwrap()),
|
||||
date: IFC::from(now.date_naive().with_year(now.year() + 10000).unwrap()),
|
||||
next_event: EVENTS.next_event(now.with_timezone(&Utc)).unwrap(),
|
||||
events: EVENTS.yearly_events(now.year()).unwrap(),
|
||||
transit: Some(transit),
|
||||
|
@ -120,7 +120,7 @@ pub fn main() {
|
|||
|
||||
app.connect_activate(move |app| {
|
||||
let (gtk_tx, gtk_rx) =
|
||||
gtk::glib::MainContext::channel::<Message>(gtk::glib::PRIORITY_DEFAULT);
|
||||
gtk::glib::MainContext::channel::<Message>(gtk::glib::Priority::DEFAULT);
|
||||
|
||||
*core.tx.write().unwrap() = Some(gtk_tx);
|
||||
|
||||
|
@ -133,7 +133,7 @@ pub fn main() {
|
|||
let Message::Refresh(state) = msg;
|
||||
ApplicationWindow::update_state(&window, state);
|
||||
|
||||
Continue(true)
|
||||
glib::ControlFlow::Continue
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
@ -1,198 +0,0 @@
|
|||
/*
|
||||
Copyright 2020-2023, Savanni D'Gerinel <savanni@luminescent-dreams.com>
|
||||
|
||||
This file is part of the Luminescent Dreams Tools.
|
||||
|
||||
Luminescent Dreams Tools is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version.
|
||||
|
||||
Luminescent Dreams Tools is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License along with Lumeto. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
extern crate chrono;
|
||||
extern crate chrono_tz;
|
||||
|
||||
use chrono::SecondsFormat;
|
||||
use chrono_tz::Etc::UTC;
|
||||
use serde::de::{self, Deserialize, Deserializer, Visitor};
|
||||
use serde::ser::{Serialize, Serializer};
|
||||
use std::{fmt, str::FromStr};
|
||||
|
||||
/// This is a wrapper around date time objects, using timezones from the chroon-tz database and
|
||||
/// providing string representation and parsing of the form "<RFC3339> <Timezone Name>", i.e.,
|
||||
/// "2019-05-15T14:30:00Z US/Central". The to_string method, and serde serialization will
|
||||
/// produce a string of this format. The parser will accept an RFC3339-only string of the forms
|
||||
/// "2019-05-15T14:30:00Z", "2019-05-15T14:30:00+00:00", and also an "RFC3339 Timezone Name"
|
||||
/// string.
|
||||
///
|
||||
/// The function here is to generate as close to unambiguous time/date strings, (for earth's
|
||||
/// gravitational frame of reference), as possible. Clumping together the time, offset from UTC,
|
||||
/// and the named time zone allows future parsers to know the exact interpretation of the time in
|
||||
/// the frame of reference of the original recording.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
|
||||
pub struct DateTimeTz(pub chrono::DateTime<chrono_tz::Tz>);
|
||||
|
||||
impl fmt::Display for DateTimeTz {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> Result<(), fmt::Error> {
|
||||
if self.0.timezone() == UTC {
|
||||
write!(f, "{}", self.0.to_rfc3339_opts(SecondsFormat::Secs, true))
|
||||
} else {
|
||||
write!(
|
||||
f,
|
||||
"{} {}",
|
||||
self.0
|
||||
.with_timezone(&chrono_tz::Etc::UTC)
|
||||
.to_rfc3339_opts(SecondsFormat::Secs, true,),
|
||||
self.0.timezone().name()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl DateTimeTz {
|
||||
pub fn map<F>(&self, f: F) -> DateTimeTz
|
||||
where
|
||||
F: FnOnce(chrono::DateTime<chrono_tz::Tz>) -> chrono::DateTime<chrono_tz::Tz>,
|
||||
{
|
||||
DateTimeTz(f(self.0))
|
||||
}
|
||||
}
|
||||
|
||||
impl std::str::FromStr for DateTimeTz {
|
||||
type Err = chrono::ParseError;
|
||||
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
let v: Vec<&str> = s.split_terminator(' ').collect();
|
||||
if v.len() == 2 {
|
||||
let tz = v[1].parse::<chrono_tz::Tz>().unwrap();
|
||||
chrono::DateTime::parse_from_rfc3339(v[0]).map(|ts| DateTimeTz(ts.with_timezone(&tz)))
|
||||
} else {
|
||||
chrono::DateTime::parse_from_rfc3339(v[0]).map(|ts| DateTimeTz(ts.with_timezone(&UTC)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<chrono::DateTime<chrono_tz::Tz>> for DateTimeTz {
|
||||
fn from(dt: chrono::DateTime<chrono_tz::Tz>) -> DateTimeTz {
|
||||
DateTimeTz(dt)
|
||||
}
|
||||
}
|
||||
|
||||
struct DateTimeTzVisitor;
|
||||
|
||||
impl<'de> Visitor<'de> for DateTimeTzVisitor {
|
||||
type Value = DateTimeTz;
|
||||
|
||||
fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
|
||||
formatter.write_str("a string date time representation that can be parsed")
|
||||
}
|
||||
|
||||
fn visit_str<E: de::Error>(self, s: &str) -> Result<Self::Value, E> {
|
||||
DateTimeTz::from_str(s).or(Err(E::custom(
|
||||
"string is not a parsable datetime representation".to_owned(),
|
||||
)))
|
||||
}
|
||||
}
|
||||
|
||||
impl Serialize for DateTimeTz {
|
||||
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
|
||||
serializer.serialize_str(&self.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for DateTimeTz {
|
||||
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
|
||||
deserializer.deserialize_str(DateTimeTzVisitor)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod test {
|
||||
extern crate serde_json;
|
||||
|
||||
use super::*;
|
||||
use chrono::TimeZone;
|
||||
use chrono_tz::America::Phoenix;
|
||||
use chrono_tz::Etc::UTC;
|
||||
use chrono_tz::US::{Arizona, Central};
|
||||
use std::str::FromStr;
|
||||
|
||||
#[test]
|
||||
fn it_creates_timestamp_with_z() {
|
||||
let t = DateTimeTz(UTC.with_ymd_and_hms(2019, 5, 15, 12, 0, 0).unwrap());
|
||||
assert_eq!(t.to_string(), "2019-05-15T12:00:00Z");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn it_parses_utc_rfc3339_z() {
|
||||
let t = DateTimeTz::from_str("2019-05-15T12:00:00Z").unwrap();
|
||||
assert_eq!(
|
||||
t,
|
||||
DateTimeTz(UTC.with_ymd_and_hms(2019, 5, 15, 12, 0, 0).unwrap())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn it_parses_rfc3339_with_offset() {
|
||||
let t = DateTimeTz::from_str("2019-05-15T12:00:00-06:00").unwrap();
|
||||
assert_eq!(
|
||||
t,
|
||||
DateTimeTz(UTC.with_ymd_and_hms(2019, 5, 15, 18, 0, 0).unwrap())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn it_parses_rfc3339_with_tz() {
|
||||
let t = DateTimeTz::from_str("2019-06-15T19:00:00Z US/Arizona").unwrap();
|
||||
assert_eq!(
|
||||
t,
|
||||
DateTimeTz(UTC.with_ymd_and_hms(2019, 6, 15, 19, 0, 0).unwrap())
|
||||
);
|
||||
assert_eq!(
|
||||
t,
|
||||
DateTimeTz(Arizona.with_ymd_and_hms(2019, 6, 15, 12, 0, 0).unwrap())
|
||||
);
|
||||
assert_eq!(
|
||||
t,
|
||||
DateTimeTz(Central.with_ymd_and_hms(2019, 6, 15, 14, 0, 0).unwrap())
|
||||
);
|
||||
assert_eq!(t.to_string(), "2019-06-15T19:00:00Z US/Arizona");
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct DemoStruct {
|
||||
id: String,
|
||||
dt: DateTimeTz,
|
||||
}
|
||||
|
||||
// I used Arizona here specifically because large parts of Arizona do not honor DST, and so
|
||||
// that adds in more ambiguity of the -0700 offset with Pacific time.
|
||||
#[test]
|
||||
fn it_json_serializes() {
|
||||
let t = DateTimeTz::from_str("2019-06-15T19:00:00Z America/Phoenix").unwrap();
|
||||
assert_eq!(
|
||||
serde_json::to_string(&t).unwrap(),
|
||||
"\"2019-06-15T19:00:00Z America/Phoenix\""
|
||||
);
|
||||
|
||||
let demo = DemoStruct {
|
||||
id: String::from("abcdefg"),
|
||||
dt: t,
|
||||
};
|
||||
assert_eq!(
|
||||
serde_json::to_string(&demo).unwrap(),
|
||||
"{\"id\":\"abcdefg\",\"dt\":\"2019-06-15T19:00:00Z America/Phoenix\"}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn it_json_parses() {
|
||||
let t =
|
||||
serde_json::from_str::<DateTimeTz>("\"2019-06-15T19:00:00Z America/Phoenix\"").unwrap();
|
||||
assert_eq!(
|
||||
t,
|
||||
DateTimeTz(Phoenix.with_ymd_and_hms(2019, 6, 15, 12, 0, 0).unwrap())
|
||||
);
|
||||
}
|
||||
}
|
|
@ -71,11 +71,9 @@ extern crate thiserror;
|
|||
extern crate uuid;
|
||||
|
||||
mod criteria;
|
||||
mod date_time_tz;
|
||||
mod series;
|
||||
mod types;
|
||||
|
||||
pub use criteria::*;
|
||||
pub use date_time_tz::DateTimeTz;
|
||||
pub use series::Series;
|
||||
pub use types::{EmseriesReadError, EmseriesWriteError, Recordable, Timestamp, UniqueId};
|
||||
pub use types::{EmseriesReadError, EmseriesWriteError, Record, RecordId, Recordable, Timestamp};
|
||||
|
|
|
@ -18,13 +18,51 @@ use serde::de::DeserializeOwned;
|
|||
use serde::ser::Serialize;
|
||||
use std::cmp::Ordering;
|
||||
use std::collections::HashMap;
|
||||
use std::convert::TryFrom;
|
||||
use std::fs::File;
|
||||
use std::fs::OpenOptions;
|
||||
use std::io::{BufRead, BufReader, LineWriter, Write};
|
||||
use std::iter::Iterator;
|
||||
|
||||
use criteria::Criteria;
|
||||
use types::{EmseriesReadError, EmseriesWriteError, Record, Recordable, UniqueId};
|
||||
use types::{EmseriesReadError, EmseriesWriteError, Record, RecordId, Recordable};
|
||||
|
||||
// A RecordOnDisk, a private data structure, is useful for handling all of the on-disk
|
||||
// representations of a record. Unlike [Record], this one can accept an empty data value to
|
||||
// represent that the data may have been deleted. This is not made public because, so far as the
|
||||
// user is concerned, any record in the system must have data associated with it.
|
||||
#[derive(Clone, Deserialize, Serialize)]
|
||||
struct RecordOnDisk<T: Clone + Recordable> {
|
||||
id: RecordId,
|
||||
data: Option<T>,
|
||||
}
|
||||
|
||||
/*
|
||||
impl<T> FromStr for RecordOnDisk<T>
|
||||
where
|
||||
T: Clone + Recordable + DeserializeOwned + Serialize,
|
||||
{
|
||||
type Err = EmseriesReadError;
|
||||
|
||||
fn from_str(line: &str) -> Result<Self, Self::Err> {
|
||||
serde_json::from_str(line).map_err(EmseriesReadError::JSONParseError)
|
||||
}
|
||||
}
|
||||
*/
|
||||
|
||||
impl<T: Clone + Recordable> TryFrom<RecordOnDisk<T>> for Record<T> {
|
||||
type Error = EmseriesReadError;
|
||||
|
||||
fn try_from(disk_record: RecordOnDisk<T>) -> Result<Self, Self::Error> {
|
||||
match disk_record.data {
|
||||
Some(data) => Ok(Record {
|
||||
id: disk_record.id,
|
||||
data,
|
||||
}),
|
||||
None => Err(Self::Error::RecordDeleted(disk_record.id)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// An open time series database.
|
||||
///
|
||||
|
@ -33,7 +71,7 @@ use types::{EmseriesReadError, EmseriesWriteError, Record, Recordable, UniqueId}
|
|||
pub struct Series<T: Clone + Recordable + DeserializeOwned + Serialize> {
|
||||
//path: String,
|
||||
writer: LineWriter<File>,
|
||||
records: HashMap<UniqueId, T>,
|
||||
records: HashMap<RecordId, Record<T>>,
|
||||
}
|
||||
|
||||
impl<T> Series<T>
|
||||
|
@ -42,7 +80,7 @@ where
|
|||
{
|
||||
/// Open a time series database at the specified path. `path` is the full path and filename for
|
||||
/// the database.
|
||||
pub fn open(path: &str) -> Result<Series<T>, EmseriesReadError> {
|
||||
pub fn open<P: AsRef<std::path::Path>>(path: P) -> Result<Series<T>, EmseriesReadError> {
|
||||
let f = OpenOptions::new()
|
||||
.read(true)
|
||||
.append(true)
|
||||
|
@ -62,20 +100,18 @@ where
|
|||
}
|
||||
|
||||
/// Load a file and return all of the records in it.
|
||||
fn load_file(f: &File) -> Result<HashMap<UniqueId, T>, EmseriesReadError> {
|
||||
let mut records: HashMap<UniqueId, T> = HashMap::new();
|
||||
fn load_file(f: &File) -> Result<HashMap<RecordId, Record<T>>, EmseriesReadError> {
|
||||
let mut records: HashMap<RecordId, Record<T>> = HashMap::new();
|
||||
let reader = BufReader::new(f);
|
||||
for line in reader.lines() {
|
||||
match line {
|
||||
Ok(line_) => {
|
||||
/* Can't create a JSONParseError because I can't actually create the underlying error.
|
||||
fail_point!("parse-line", Err(Error::JSONParseError()))
|
||||
*/
|
||||
match line_.parse::<Record<T>>() {
|
||||
Ok(record) => match record.data {
|
||||
Some(val) => records.insert(record.id.clone(), val),
|
||||
None => records.remove(&record.id.clone()),
|
||||
},
|
||||
match serde_json::from_str::<RecordOnDisk<T>>(line_.as_ref())
|
||||
.map_err(EmseriesReadError::JSONParseError)
|
||||
.and_then(Record::try_from)
|
||||
{
|
||||
Ok(record) => records.insert(record.id, record.clone()),
|
||||
Err(EmseriesReadError::RecordDeleted(id)) => records.remove(&id),
|
||||
Err(err) => return Err(err),
|
||||
};
|
||||
}
|
||||
|
@ -87,18 +123,20 @@ where
|
|||
|
||||
/// Put a new record into the database. A unique id will be assigned to the record and
|
||||
/// returned.
|
||||
pub fn put(&mut self, entry: T) -> Result<UniqueId, EmseriesWriteError> {
|
||||
let uuid = UniqueId::default();
|
||||
self.update(uuid.clone(), entry).map(|_| uuid)
|
||||
pub fn put(&mut self, entry: T) -> Result<RecordId, EmseriesWriteError> {
|
||||
let id = RecordId::default();
|
||||
let record = Record { id, data: entry };
|
||||
self.update(record)?;
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
/// Update an existing record. The `UniqueId` of the record passed into this function must match
|
||||
/// the `UniqueId` of a record already in the database.
|
||||
pub fn update(&mut self, uuid: UniqueId, entry: T) -> Result<(), EmseriesWriteError> {
|
||||
self.records.insert(uuid.clone(), entry.clone());
|
||||
let write_res = match serde_json::to_string(&Record {
|
||||
id: uuid,
|
||||
data: Some(entry),
|
||||
/// Update an existing record. The [RecordId] of the record passed into this function must match
|
||||
/// the [RecordId] of a record already in the database.
|
||||
pub fn update(&mut self, record: Record<T>) -> Result<(), EmseriesWriteError> {
|
||||
self.records.insert(record.id, record.clone());
|
||||
let write_res = match serde_json::to_string(&RecordOnDisk {
|
||||
id: record.id,
|
||||
data: Some(record.data),
|
||||
}) {
|
||||
Ok(rec_str) => self
|
||||
.writer
|
||||
|
@ -118,14 +156,14 @@ where
|
|||
/// Future note: while this deletes a record from the view, it only adds an entry to the
|
||||
/// database that indicates `data: null`. If record histories ever become important, the record
|
||||
/// and its entire history (including this delete) will still be available.
|
||||
pub fn delete(&mut self, uuid: &UniqueId) -> Result<(), EmseriesWriteError> {
|
||||
pub fn delete(&mut self, uuid: &RecordId) -> Result<(), EmseriesWriteError> {
|
||||
if !self.records.contains_key(uuid) {
|
||||
return Ok(());
|
||||
};
|
||||
self.records.remove(uuid);
|
||||
|
||||
let rec: Record<T> = Record {
|
||||
id: uuid.clone(),
|
||||
let rec: RecordOnDisk<T> = RecordOnDisk {
|
||||
id: *uuid,
|
||||
data: None,
|
||||
};
|
||||
match serde_json::to_string(&rec) {
|
||||
|
@ -138,8 +176,8 @@ where
|
|||
}
|
||||
|
||||
/// Get all of the records in the database.
|
||||
pub fn records(&self) -> impl Iterator<Item = (&UniqueId, &T)> {
|
||||
self.records.iter()
|
||||
pub fn records(&self) -> impl Iterator<Item = &Record<T>> {
|
||||
self.records.values()
|
||||
}
|
||||
|
||||
/* The point of having Search is so that a lot of internal optimizations can happen once the
|
||||
|
@ -148,29 +186,29 @@ where
|
|||
pub fn search<'s>(
|
||||
&'s self,
|
||||
criteria: impl Criteria + 's,
|
||||
) -> impl Iterator<Item = (&'s UniqueId, &'s T)> + 's {
|
||||
self.records().filter(move |&tr| criteria.apply(tr.1))
|
||||
) -> impl Iterator<Item = &'s Record<T>> + 's {
|
||||
self.records().filter(move |&tr| criteria.apply(&tr.data))
|
||||
}
|
||||
|
||||
/// Perform a search and sort the resulting records based on the comparison.
|
||||
pub fn search_sorted<'s, C, CMP>(&'s self, criteria: C, compare: CMP) -> Vec<(&UniqueId, &T)>
|
||||
pub fn search_sorted<'s, C, CMP>(&'s self, criteria: C, compare: CMP) -> Vec<&'s Record<T>>
|
||||
where
|
||||
C: Criteria + 's,
|
||||
CMP: FnMut(&(&UniqueId, &T), &(&UniqueId, &T)) -> Ordering,
|
||||
CMP: FnMut(&&Record<T>, &&Record<T>) -> Ordering,
|
||||
{
|
||||
let search_iter = self.search(criteria);
|
||||
let mut records: Vec<(&UniqueId, &T)> = search_iter.collect();
|
||||
let mut records: Vec<&Record<T>> = search_iter.collect();
|
||||
records.sort_by(compare);
|
||||
records
|
||||
}
|
||||
|
||||
/// Get an exact record from the database based on unique id.
|
||||
pub fn get(&self, uuid: &UniqueId) -> Option<T> {
|
||||
pub fn get(&self, uuid: &RecordId) -> Option<Record<T>> {
|
||||
self.records.get(uuid).cloned()
|
||||
}
|
||||
|
||||
/*
|
||||
pub fn remove(&self, uuid: UniqueId) -> Result<(), EmseriesError> {
|
||||
pub fn remove(&self, uuid: RecordId) -> Result<(), EmseriesError> {
|
||||
unimplemented!()
|
||||
}
|
||||
*/
|
||||
|
|
|
@ -10,10 +10,7 @@ Luminescent Dreams Tools is distributed in the hope that it will be useful, but
|
|||
You should have received a copy of the GNU General Public License along with Lumeto. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
use chrono::NaiveDate;
|
||||
use date_time_tz::DateTimeTz;
|
||||
use serde::de::DeserializeOwned;
|
||||
use serde::ser::Serialize;
|
||||
use chrono::{DateTime, FixedOffset, NaiveDate};
|
||||
use std::{cmp::Ordering, fmt, io, str};
|
||||
use thiserror::Error;
|
||||
use uuid::Uuid;
|
||||
|
@ -28,6 +25,9 @@ pub enum EmseriesReadError {
|
|||
#[error("Error parsing JSON: {0}")]
|
||||
JSONParseError(serde_json::error::Error),
|
||||
|
||||
#[error("Record was deleted")]
|
||||
RecordDeleted(RecordId),
|
||||
|
||||
/// Indicates a general IO error
|
||||
#[error("IO Error: {0}")]
|
||||
IOError(io::Error),
|
||||
|
@ -44,17 +44,47 @@ pub enum EmseriesWriteError {
|
|||
JSONWriteError(serde_json::error::Error),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(untagged)]
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
/// A Timestamp, stored with reference to human reckoning. This could be either a Naive Date or a
|
||||
/// date and a time with a timezone. The idea of the "human reckoning" is that, no matter what
|
||||
/// timezone the record was created in, we want to group things based on the date that the human
|
||||
/// was perceiving at the time it was recorded.
|
||||
pub enum Timestamp {
|
||||
DateTime(DateTimeTz),
|
||||
DateTime(DateTime<FixedOffset>),
|
||||
Date(NaiveDate),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(untagged)]
|
||||
pub enum TimestampJS {
|
||||
DateTime(String),
|
||||
Date(String),
|
||||
}
|
||||
|
||||
impl From<Timestamp> for TimestampJS {
|
||||
fn from(s: Timestamp) -> TimestampJS {
|
||||
match s {
|
||||
Timestamp::DateTime(ts) => TimestampJS::DateTime(ts.to_rfc3339()),
|
||||
Timestamp::Date(ts) => TimestampJS::Date(ts.to_string()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<TimestampJS> for Timestamp {
|
||||
fn from(s: TimestampJS) -> Timestamp {
|
||||
match s {
|
||||
TimestampJS::DateTime(ts) => {
|
||||
Timestamp::DateTime(DateTime::parse_from_rfc3339(&ts).unwrap())
|
||||
}
|
||||
TimestampJS::Date(ts) => Timestamp::Date(ts.parse::<NaiveDate>().unwrap()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl str::FromStr for Timestamp {
|
||||
type Err = chrono::ParseError;
|
||||
fn from_str(line: &str) -> Result<Self, Self::Err> {
|
||||
DateTimeTz::from_str(line)
|
||||
DateTime::parse_from_rfc3339(line)
|
||||
.map(Timestamp::DateTime)
|
||||
.or(NaiveDate::from_str(line).map(Timestamp::Date))
|
||||
}
|
||||
|
@ -70,25 +100,13 @@ impl Ord for Timestamp {
|
|||
fn cmp(&self, other: &Timestamp) -> Ordering {
|
||||
match (self, other) {
|
||||
(Timestamp::DateTime(dt1), Timestamp::DateTime(dt2)) => dt1.cmp(dt2),
|
||||
(Timestamp::DateTime(dt1), Timestamp::Date(dt2)) => dt1.0.date_naive().cmp(dt2),
|
||||
(Timestamp::Date(dt1), Timestamp::DateTime(dt2)) => dt1.cmp(&dt2.0.date_naive()),
|
||||
(Timestamp::DateTime(dt1), Timestamp::Date(dt2)) => dt1.date_naive().cmp(dt2),
|
||||
(Timestamp::Date(dt1), Timestamp::DateTime(dt2)) => dt1.cmp(&dt2.date_naive()),
|
||||
(Timestamp::Date(dt1), Timestamp::Date(dt2)) => dt1.cmp(dt2),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<DateTimeTz> for Timestamp {
|
||||
fn from(d: DateTimeTz) -> Self {
|
||||
Self::DateTime(d)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<NaiveDate> for Timestamp {
|
||||
fn from(d: NaiveDate) -> Self {
|
||||
Self::Date(d)
|
||||
}
|
||||
}
|
||||
|
||||
/// Any element to be put into the database needs to be Recordable. This is the common API that
|
||||
/// will aid in searching and later in indexing records.
|
||||
pub trait Recordable {
|
||||
|
@ -102,75 +120,88 @@ pub trait Recordable {
|
|||
/// Uniquely identifies a record.
|
||||
///
|
||||
/// This is a wrapper around a basic uuid with some extra convenience methods.
|
||||
#[derive(Clone, Debug, Eq, Hash, PartialEq, Deserialize, Serialize)]
|
||||
pub struct UniqueId(Uuid);
|
||||
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq, Deserialize, Serialize)]
|
||||
pub struct RecordId(Uuid);
|
||||
|
||||
impl Default for UniqueId {
|
||||
impl Default for RecordId {
|
||||
fn default() -> Self {
|
||||
Self(Uuid::new_v4())
|
||||
}
|
||||
}
|
||||
|
||||
impl str::FromStr for UniqueId {
|
||||
impl str::FromStr for RecordId {
|
||||
type Err = EmseriesReadError;
|
||||
|
||||
/// Parse a UniqueId from a string. Raise UUIDParseError if the parsing fails.
|
||||
/// Parse a RecordId from a string. Raise UUIDParseError if the parsing fails.
|
||||
fn from_str(val: &str) -> Result<Self, Self::Err> {
|
||||
Uuid::parse_str(val)
|
||||
.map(UniqueId)
|
||||
.map(RecordId)
|
||||
.map_err(EmseriesReadError::UUIDParseError)
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for UniqueId {
|
||||
impl fmt::Display for RecordId {
|
||||
/// Convert to a hyphenated string
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> Result<(), std::fmt::Error> {
|
||||
write!(f, "{}", self.0.to_hyphenated())
|
||||
}
|
||||
}
|
||||
|
||||
/// Every record contains a unique ID and then the primary data, which itself must implementd the
|
||||
/// Recordable trait.
|
||||
#[derive(Clone, Deserialize, Serialize)]
|
||||
/// A record represents data that actually exists in the database. Users cannot make the record
|
||||
/// directly, as the database will create them.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize)]
|
||||
pub struct Record<T: Clone + Recordable> {
|
||||
pub id: UniqueId,
|
||||
pub data: Option<T>,
|
||||
pub id: RecordId,
|
||||
pub data: T,
|
||||
}
|
||||
|
||||
impl<T> str::FromStr for Record<T>
|
||||
where
|
||||
T: Clone + Recordable + DeserializeOwned + Serialize,
|
||||
{
|
||||
type Err = EmseriesReadError;
|
||||
impl<T: Clone + Recordable> Record<T> {
|
||||
pub fn date(&self) -> NaiveDate {
|
||||
match self.data.timestamp() {
|
||||
Timestamp::DateTime(dt) => dt.date_naive(),
|
||||
Timestamp::Date(dt) => dt,
|
||||
}
|
||||
}
|
||||
|
||||
fn from_str(line: &str) -> Result<Self, Self::Err> {
|
||||
serde_json::from_str(line).map_err(EmseriesReadError::JSONParseError)
|
||||
pub fn timestamp(&self) -> Timestamp {
|
||||
self.data.timestamp()
|
||||
}
|
||||
|
||||
pub fn map<Map, U>(self, map: Map) -> Record<U>
|
||||
where
|
||||
Map: Fn(T) -> U,
|
||||
U: Clone + Recordable,
|
||||
{
|
||||
Record {
|
||||
id: self.id,
|
||||
data: map(self.data),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod test {
|
||||
extern crate dimensioned;
|
||||
|
||||
extern crate serde_json;
|
||||
|
||||
use self::dimensioned::si::{Kilogram, KG};
|
||||
use super::*;
|
||||
use chrono::TimeZone;
|
||||
use chrono_tz::{Etc::UTC, US::Central};
|
||||
use date_time_tz::DateTimeTz;
|
||||
use chrono_tz::Etc::UTC;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)]
|
||||
pub struct Weight(Kilogram<f64>);
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)]
|
||||
pub struct WeightRecord {
|
||||
pub date: Timestamp,
|
||||
pub date: NaiveDate,
|
||||
pub weight: Weight,
|
||||
}
|
||||
|
||||
impl Recordable for WeightRecord {
|
||||
fn timestamp(&self) -> Timestamp {
|
||||
self.date.clone()
|
||||
Timestamp::Date(self.date)
|
||||
}
|
||||
|
||||
fn tags(&self) -> Vec<String> {
|
||||
|
@ -179,12 +210,14 @@ mod test {
|
|||
}
|
||||
|
||||
#[test]
|
||||
fn timestamp_parses_datetimetz_without_timezone() {
|
||||
fn timestamp_parses_utc_time() {
|
||||
assert_eq!(
|
||||
"2003-11-10T06:00:00Z".parse::<Timestamp>().unwrap(),
|
||||
Timestamp::DateTime(DateTimeTz(
|
||||
UTC.with_ymd_and_hms(2003, 11, 10, 6, 0, 0).unwrap()
|
||||
)),
|
||||
Timestamp::DateTime(
|
||||
UTC.with_ymd_and_hms(2003, 11, 10, 6, 0, 0)
|
||||
.unwrap()
|
||||
.with_timezone(&FixedOffset::east_opt(0).unwrap())
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -196,9 +229,10 @@ mod test {
|
|||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
/*
|
||||
#[ignore]
|
||||
fn v_alpha_serialization() {
|
||||
const WEIGHT_ENTRY: &str = "{\"data\":{\"weight\":77.79109,\"date\":\"2003-11-10T06:00:00.000000000000Z\"},\"id\":\"3330c5b0-783f-4919-b2c4-8169c38f65ff\"}";
|
||||
const WEIGHT_ENTRY: &str = "{\"data\":{\"weight\":77.79109},\"date\":\"2003-11-10\",\"id\":\"3330c5b0-783f-4919-b2c4-8169c38f65ff\"}";
|
||||
|
||||
let rec: Record<WeightRecord> = WEIGHT_ENTRY
|
||||
.parse()
|
||||
|
@ -209,66 +243,64 @@ mod test {
|
|||
);
|
||||
assert_eq!(
|
||||
rec.data,
|
||||
Some(WeightRecord {
|
||||
date: Timestamp::DateTime(DateTimeTz(
|
||||
UTC.with_ymd_and_hms(2003, 11, 10, 6, 0, 0).unwrap()
|
||||
)),
|
||||
WeightRecord {
|
||||
date: NaiveDate::from_ymd_opt(2003, 11, 10).unwrap(),
|
||||
weight: Weight(77.79109 * KG),
|
||||
})
|
||||
}
|
||||
);
|
||||
}
|
||||
*/
|
||||
|
||||
#[test]
|
||||
fn serialization_output() {
|
||||
let rec = WeightRecord {
|
||||
date: Timestamp::DateTime(DateTimeTz(
|
||||
UTC.with_ymd_and_hms(2003, 11, 10, 6, 0, 0).unwrap(),
|
||||
)),
|
||||
date: NaiveDate::from_ymd_opt(2003, 11, 10).unwrap(),
|
||||
weight: Weight(77.0 * KG),
|
||||
};
|
||||
assert_eq!(
|
||||
serde_json::to_string(&rec).unwrap(),
|
||||
"{\"date\":\"2003-11-10T06:00:00Z\",\"weight\":77.0}"
|
||||
"{\"date\":\"2003-11-10\",\"weight\":77.0}"
|
||||
);
|
||||
|
||||
let rec2 = WeightRecord {
|
||||
date: Timestamp::DateTime(
|
||||
Central
|
||||
.with_ymd_and_hms(2003, 11, 10, 0, 0, 0)
|
||||
.unwrap()
|
||||
.into(),
|
||||
),
|
||||
date: NaiveDate::from_ymd_opt(2003, 11, 10).unwrap(),
|
||||
weight: Weight(77.0 * KG),
|
||||
};
|
||||
assert_eq!(
|
||||
serde_json::to_string(&rec2).unwrap(),
|
||||
"{\"date\":\"2003-11-10T06:00:00Z US/Central\",\"weight\":77.0}"
|
||||
"{\"date\":\"2003-11-10\",\"weight\":77.0}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_datetimes_can_be_compared() {
|
||||
let time1 = Timestamp::DateTime(DateTimeTz(
|
||||
UTC.with_ymd_and_hms(2003, 11, 10, 6, 0, 0).unwrap(),
|
||||
));
|
||||
let time2 = Timestamp::DateTime(DateTimeTz(
|
||||
UTC.with_ymd_and_hms(2003, 11, 11, 6, 0, 0).unwrap(),
|
||||
));
|
||||
let time1 = Timestamp::DateTime(
|
||||
UTC.with_ymd_and_hms(2003, 11, 10, 6, 0, 0)
|
||||
.unwrap()
|
||||
.with_timezone(&FixedOffset::east_opt(0).unwrap()),
|
||||
);
|
||||
let time2 = Timestamp::DateTime(
|
||||
UTC.with_ymd_and_hms(2003, 11, 11, 6, 0, 0)
|
||||
.unwrap()
|
||||
.with_timezone(&FixedOffset::east_opt(0).unwrap()),
|
||||
);
|
||||
assert!(time1 < time2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_dates_can_be_compared() {
|
||||
let time1 = Timestamp::Date(NaiveDate::from_ymd_opt(2003, 11, 10).unwrap());
|
||||
let time2 = Timestamp::Date(NaiveDate::from_ymd_opt(2003, 11, 11).unwrap());
|
||||
let time1: Timestamp = Timestamp::Date(NaiveDate::from_ymd_opt(2003, 11, 10).unwrap());
|
||||
let time2: Timestamp = Timestamp::Date(NaiveDate::from_ymd_opt(2003, 11, 11).unwrap());
|
||||
assert!(time1 < time2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn datetime_and_date_can_be_compared() {
|
||||
let time1 = Timestamp::DateTime(DateTimeTz(
|
||||
UTC.with_ymd_and_hms(2003, 11, 10, 6, 0, 0).unwrap(),
|
||||
));
|
||||
let time1 = Timestamp::DateTime(
|
||||
UTC.with_ymd_and_hms(2003, 11, 10, 6, 0, 0)
|
||||
.unwrap()
|
||||
.with_timezone(&FixedOffset::east_opt(0).unwrap()),
|
||||
);
|
||||
let time2 = Timestamp::Date(NaiveDate::from_ymd_opt(2003, 11, 11).unwrap());
|
||||
assert!(time1 < time2)
|
||||
}
|
||||
|
|
|
@ -20,7 +20,7 @@ extern crate emseries;
|
|||
|
||||
#[cfg(test)]
|
||||
mod test {
|
||||
use chrono::prelude::*;
|
||||
use chrono::{prelude::*};
|
||||
use chrono_tz::Etc::UTC;
|
||||
use dimensioned::si::{Kilogram, Meter, Second, M, S};
|
||||
|
||||
|
@ -34,7 +34,7 @@ mod test {
|
|||
|
||||
#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)]
|
||||
struct BikeTrip {
|
||||
datetime: DateTimeTz,
|
||||
datetime: DateTime<FixedOffset>,
|
||||
distance: Distance,
|
||||
duration: Duration,
|
||||
comments: String,
|
||||
|
@ -42,7 +42,7 @@ mod test {
|
|||
|
||||
impl Recordable for BikeTrip {
|
||||
fn timestamp(&self) -> Timestamp {
|
||||
self.datetime.clone().into()
|
||||
Timestamp::DateTime(self.datetime)
|
||||
}
|
||||
fn tags(&self) -> Vec<String> {
|
||||
Vec::new()
|
||||
|
@ -52,31 +52,46 @@ mod test {
|
|||
fn mk_trips() -> [BikeTrip; 5] {
|
||||
[
|
||||
BikeTrip {
|
||||
datetime: DateTimeTz(UTC.with_ymd_and_hms(2011, 10, 29, 0, 0, 0).unwrap()),
|
||||
datetime: UTC
|
||||
.with_ymd_and_hms(2011, 10, 29, 0, 0, 0)
|
||||
.unwrap()
|
||||
.with_timezone(&FixedOffset::east_opt(0).unwrap()),
|
||||
distance: Distance(58741.055 * M),
|
||||
duration: Duration(11040.0 * S),
|
||||
comments: String::from("long time ago"),
|
||||
},
|
||||
BikeTrip {
|
||||
datetime: DateTimeTz(UTC.with_ymd_and_hms(2011, 10, 31, 0, 0, 0).unwrap()),
|
||||
datetime: UTC
|
||||
.with_ymd_and_hms(2011, 10, 31, 0, 0, 0)
|
||||
.unwrap()
|
||||
.with_timezone(&FixedOffset::east_opt(0).unwrap()),
|
||||
distance: Distance(17702.0 * M),
|
||||
duration: Duration(2880.0 * S),
|
||||
comments: String::from("day 2"),
|
||||
},
|
||||
BikeTrip {
|
||||
datetime: DateTimeTz(UTC.with_ymd_and_hms(2011, 11, 02, 0, 0, 0).unwrap()),
|
||||
datetime: UTC
|
||||
.with_ymd_and_hms(2011, 11, 02, 0, 0, 0)
|
||||
.unwrap()
|
||||
.with_timezone(&FixedOffset::east_opt(0).unwrap()),
|
||||
distance: Distance(41842.945 * M),
|
||||
duration: Duration(7020.0 * S),
|
||||
comments: String::from("Do Some Distance!"),
|
||||
},
|
||||
BikeTrip {
|
||||
datetime: DateTimeTz(UTC.with_ymd_and_hms(2011, 11, 04, 0, 0, 0).unwrap()),
|
||||
datetime: UTC
|
||||
.with_ymd_and_hms(2011, 11, 04, 0, 0, 0)
|
||||
.unwrap()
|
||||
.with_timezone(&FixedOffset::east_opt(0).unwrap()),
|
||||
distance: Distance(34600.895 * M),
|
||||
duration: Duration(5580.0 * S),
|
||||
comments: String::from("I did a lot of distance back then"),
|
||||
},
|
||||
BikeTrip {
|
||||
datetime: DateTimeTz(UTC.with_ymd_and_hms(2011, 11, 05, 0, 0, 0).unwrap()),
|
||||
datetime: UTC
|
||||
.with_ymd_and_hms(2011, 11, 05, 0, 0, 0)
|
||||
.unwrap()
|
||||
.with_timezone(&FixedOffset::east_opt(0).unwrap()),
|
||||
distance: Distance(6437.376 * M),
|
||||
duration: Duration(960.0 * S),
|
||||
comments: String::from("day 5"),
|
||||
|
@ -84,7 +99,7 @@ mod test {
|
|||
]
|
||||
}
|
||||
|
||||
fn run_test<T>(test: T) -> ()
|
||||
fn run_test<T>(test: T)
|
||||
where
|
||||
T: FnOnce(tempfile::TempPath),
|
||||
{
|
||||
|
@ -93,14 +108,14 @@ mod test {
|
|||
test(tmp_path);
|
||||
}
|
||||
|
||||
fn run<T>(test: T) -> ()
|
||||
fn run<T>(test: T)
|
||||
where
|
||||
T: FnOnce(Series<BikeTrip>),
|
||||
{
|
||||
let tmp_file = tempfile::NamedTempFile::new().expect("temporary path created");
|
||||
let tmp_path = tmp_file.into_temp_path();
|
||||
let ts: Series<BikeTrip> = Series::open(&tmp_path.to_string_lossy())
|
||||
.expect("the time series should open correctly");
|
||||
let ts: Series<BikeTrip> =
|
||||
Series::open(&tmp_path).expect("the time series should open correctly");
|
||||
test(ts);
|
||||
}
|
||||
|
||||
|
@ -122,11 +137,15 @@ mod test {
|
|||
Some(tr) => {
|
||||
assert_eq!(
|
||||
tr.timestamp(),
|
||||
DateTimeTz(UTC.with_ymd_and_hms(2011, 10, 29, 0, 0, 0).unwrap()).into()
|
||||
Timestamp::DateTime(
|
||||
UTC.with_ymd_and_hms(2011, 10, 29, 0, 0, 0)
|
||||
.unwrap()
|
||||
.with_timezone(&FixedOffset::east_opt(0).unwrap())
|
||||
)
|
||||
);
|
||||
assert_eq!(tr.duration, Duration(11040.0 * S));
|
||||
assert_eq!(tr.comments, String::from("long time ago"));
|
||||
assert_eq!(tr, trips[0]);
|
||||
assert_eq!(tr.data.duration, Duration(11040.0 * S));
|
||||
assert_eq!(tr.data.comments, String::from("long time ago"));
|
||||
assert_eq!(tr.data, trips[0]);
|
||||
}
|
||||
}
|
||||
})
|
||||
|
@ -136,20 +155,22 @@ mod test {
|
|||
pub fn can_search_for_an_entry_with_exact_time() {
|
||||
run_test(|path| {
|
||||
let trips = mk_trips();
|
||||
let mut ts: Series<BikeTrip> = Series::open(&path.to_string_lossy())
|
||||
.expect("expect the time series to open correctly");
|
||||
let mut ts: Series<BikeTrip> =
|
||||
Series::open(&path).expect("expect the time series to open correctly");
|
||||
|
||||
for trip in &trips[0..=4] {
|
||||
ts.put(trip.clone()).expect("expect a successful put");
|
||||
}
|
||||
|
||||
let v: Vec<(&UniqueId, &BikeTrip)> = ts
|
||||
.search(exact_time(
|
||||
DateTimeTz(UTC.with_ymd_and_hms(2011, 10, 31, 0, 0, 0).unwrap()).into(),
|
||||
))
|
||||
let v: Vec<&Record<BikeTrip>> = ts
|
||||
.search(exact_time(Timestamp::DateTime(
|
||||
UTC.with_ymd_and_hms(2011, 10, 31, 0, 0, 0)
|
||||
.unwrap()
|
||||
.with_timezone(&FixedOffset::east_opt(0).unwrap()),
|
||||
)))
|
||||
.collect();
|
||||
assert_eq!(v.len(), 1);
|
||||
assert_eq!(*v[0].1, trips[1]);
|
||||
assert_eq!(v[0].data, trips[1]);
|
||||
})
|
||||
}
|
||||
|
||||
|
@ -157,26 +178,34 @@ mod test {
|
|||
pub fn can_get_entries_in_time_range() {
|
||||
run_test(|path| {
|
||||
let trips = mk_trips();
|
||||
let mut ts: Series<BikeTrip> = Series::open(&path.to_string_lossy())
|
||||
.expect("expect the time series to open correctly");
|
||||
let mut ts: Series<BikeTrip> =
|
||||
Series::open(&path).expect("expect the time series to open correctly");
|
||||
|
||||
for trip in &trips[0..=4] {
|
||||
ts.put(trip.clone()).expect("expect a successful put");
|
||||
}
|
||||
|
||||
let v: Vec<(&UniqueId, &BikeTrip)> = ts.search_sorted(
|
||||
let v: Vec<&Record<BikeTrip>> = ts.search_sorted(
|
||||
time_range(
|
||||
DateTimeTz(UTC.with_ymd_and_hms(2011, 10, 31, 0, 0, 0).unwrap()).into(),
|
||||
Timestamp::DateTime(
|
||||
UTC.with_ymd_and_hms(2011, 10, 31, 0, 0, 0)
|
||||
.unwrap()
|
||||
.with_timezone(&FixedOffset::east_opt(0).unwrap()),
|
||||
),
|
||||
true,
|
||||
DateTimeTz(UTC.with_ymd_and_hms(2011, 11, 04, 0, 0, 0).unwrap()).into(),
|
||||
Timestamp::DateTime(
|
||||
UTC.with_ymd_and_hms(2011, 11, 04, 0, 0, 0)
|
||||
.unwrap()
|
||||
.with_timezone(&FixedOffset::east_opt(0).unwrap()),
|
||||
),
|
||||
true,
|
||||
),
|
||||
|l, r| l.1.timestamp().cmp(&r.1.timestamp()),
|
||||
|l, r| l.timestamp().cmp(&r.timestamp()),
|
||||
);
|
||||
assert_eq!(v.len(), 3);
|
||||
assert_eq!(*v[0].1, trips[1]);
|
||||
assert_eq!(*v[1].1, trips[2]);
|
||||
assert_eq!(*v[2].1, trips[3]);
|
||||
assert_eq!(v[0].data, trips[1]);
|
||||
assert_eq!(v[1].data, trips[2]);
|
||||
assert_eq!(v[2].data, trips[3]);
|
||||
})
|
||||
}
|
||||
|
||||
|
@ -186,8 +215,8 @@ mod test {
|
|||
let trips = mk_trips();
|
||||
|
||||
{
|
||||
let mut ts: Series<BikeTrip> = Series::open(&path.to_string_lossy())
|
||||
.expect("expect the time series to open correctly");
|
||||
let mut ts: Series<BikeTrip> =
|
||||
Series::open(&path).expect("expect the time series to open correctly");
|
||||
|
||||
for trip in &trips[0..=4] {
|
||||
ts.put(trip.clone()).expect("expect a successful put");
|
||||
|
@ -195,21 +224,29 @@ mod test {
|
|||
}
|
||||
|
||||
{
|
||||
let ts: Series<BikeTrip> = Series::open(&path.to_string_lossy())
|
||||
.expect("expect the time series to open correctly");
|
||||
let v: Vec<(&UniqueId, &BikeTrip)> = ts.search_sorted(
|
||||
let ts: Series<BikeTrip> =
|
||||
Series::open(&path).expect("expect the time series to open correctly");
|
||||
let v: Vec<&Record<BikeTrip>> = ts.search_sorted(
|
||||
time_range(
|
||||
DateTimeTz(UTC.with_ymd_and_hms(2011, 10, 31, 0, 0, 0).unwrap()).into(),
|
||||
Timestamp::DateTime(
|
||||
UTC.with_ymd_and_hms(2011, 10, 31, 0, 0, 0)
|
||||
.unwrap()
|
||||
.with_timezone(&FixedOffset::east_opt(0).unwrap()),
|
||||
),
|
||||
true,
|
||||
DateTimeTz(UTC.with_ymd_and_hms(2011, 11, 04, 0, 0, 0).unwrap()).into(),
|
||||
Timestamp::DateTime(
|
||||
UTC.with_ymd_and_hms(2011, 11, 04, 0, 0, 0)
|
||||
.unwrap()
|
||||
.with_timezone(&FixedOffset::east_opt(0).unwrap()),
|
||||
),
|
||||
true,
|
||||
),
|
||||
|l, r| l.1.timestamp().cmp(&r.1.timestamp()),
|
||||
|l, r| l.timestamp().cmp(&r.timestamp()),
|
||||
);
|
||||
assert_eq!(v.len(), 3);
|
||||
assert_eq!(*v[0].1, trips[1]);
|
||||
assert_eq!(*v[1].1, trips[2]);
|
||||
assert_eq!(*v[2].1, trips[3]);
|
||||
assert_eq!(v[0].data, trips[1]);
|
||||
assert_eq!(v[1].data, trips[2]);
|
||||
assert_eq!(v[2].data, trips[3]);
|
||||
}
|
||||
})
|
||||
}
|
||||
|
@ -220,8 +257,8 @@ mod test {
|
|||
let trips = mk_trips();
|
||||
|
||||
{
|
||||
let mut ts: Series<BikeTrip> = Series::open(&path.to_string_lossy())
|
||||
.expect("expect the time series to open correctly");
|
||||
let mut ts: Series<BikeTrip> =
|
||||
Series::open(&path).expect("expect the time series to open correctly");
|
||||
|
||||
for trip in &trips[0..=2] {
|
||||
ts.put(trip.clone()).expect("expect a successful put");
|
||||
|
@ -229,41 +266,57 @@ mod test {
|
|||
}
|
||||
|
||||
{
|
||||
let mut ts: Series<BikeTrip> = Series::open(&path.to_string_lossy())
|
||||
.expect("expect the time series to open correctly");
|
||||
let v: Vec<(&UniqueId, &BikeTrip)> = ts.search_sorted(
|
||||
let mut ts: Series<BikeTrip> =
|
||||
Series::open(&path).expect("expect the time series to open correctly");
|
||||
let v: Vec<&Record<BikeTrip>> = ts.search_sorted(
|
||||
time_range(
|
||||
DateTimeTz(UTC.with_ymd_and_hms(2011, 10, 31, 0, 0, 0).unwrap()).into(),
|
||||
Timestamp::DateTime(
|
||||
UTC.with_ymd_and_hms(2011, 10, 31, 0, 0, 0)
|
||||
.unwrap()
|
||||
.with_timezone(&FixedOffset::east_opt(0).unwrap()),
|
||||
),
|
||||
true,
|
||||
DateTimeTz(UTC.with_ymd_and_hms(2011, 11, 04, 0, 0, 0).unwrap()).into(),
|
||||
Timestamp::DateTime(
|
||||
UTC.with_ymd_and_hms(2011, 11, 04, 0, 0, 0)
|
||||
.unwrap()
|
||||
.with_timezone(&FixedOffset::east_opt(0).unwrap()),
|
||||
),
|
||||
true,
|
||||
),
|
||||
|l, r| l.1.timestamp().cmp(&r.1.timestamp()),
|
||||
|l, r| l.timestamp().cmp(&r.timestamp()),
|
||||
);
|
||||
assert_eq!(v.len(), 2);
|
||||
assert_eq!(*v[0].1, trips[1]);
|
||||
assert_eq!(*v[1].1, trips[2]);
|
||||
assert_eq!(v[0].data, trips[1]);
|
||||
assert_eq!(v[1].data, trips[2]);
|
||||
ts.put(trips[3].clone()).expect("expect a successful put");
|
||||
ts.put(trips[4].clone()).expect("expect a successful put");
|
||||
}
|
||||
|
||||
{
|
||||
let ts: Series<BikeTrip> = Series::open(&path.to_string_lossy())
|
||||
.expect("expect the time series to open correctly");
|
||||
let v: Vec<(&UniqueId, &BikeTrip)> = ts.search_sorted(
|
||||
let ts: Series<BikeTrip> =
|
||||
Series::open(&path).expect("expect the time series to open correctly");
|
||||
let v: Vec<&Record<BikeTrip>> = ts.search_sorted(
|
||||
time_range(
|
||||
DateTimeTz(UTC.with_ymd_and_hms(2011, 10, 31, 0, 0, 0).unwrap()).into(),
|
||||
Timestamp::DateTime(
|
||||
UTC.with_ymd_and_hms(2011, 10, 31, 0, 0, 0)
|
||||
.unwrap()
|
||||
.with_timezone(&FixedOffset::east_opt(0).unwrap()),
|
||||
),
|
||||
true,
|
||||
DateTimeTz(UTC.with_ymd_and_hms(2011, 11, 05, 0, 0, 0).unwrap()).into(),
|
||||
Timestamp::DateTime(
|
||||
UTC.with_ymd_and_hms(2011, 11, 05, 0, 0, 0)
|
||||
.unwrap()
|
||||
.with_timezone(&FixedOffset::east_opt(0).unwrap()),
|
||||
),
|
||||
true,
|
||||
),
|
||||
|l, r| l.1.timestamp().cmp(&r.1.timestamp()),
|
||||
|l, r| l.timestamp().cmp(&r.timestamp()),
|
||||
);
|
||||
assert_eq!(v.len(), 4);
|
||||
assert_eq!(*v[0].1, trips[1]);
|
||||
assert_eq!(*v[1].1, trips[2]);
|
||||
assert_eq!(*v[2].1, trips[3]);
|
||||
assert_eq!(*v[3].1, trips[4]);
|
||||
assert_eq!(v[0].data, trips[1]);
|
||||
assert_eq!(v[1].data, trips[2]);
|
||||
assert_eq!(v[2].data, trips[3]);
|
||||
assert_eq!(v[3].data, trips[4]);
|
||||
}
|
||||
})
|
||||
}
|
||||
|
@ -273,8 +326,8 @@ mod test {
|
|||
run_test(|path| {
|
||||
let trips = mk_trips();
|
||||
|
||||
let mut ts: Series<BikeTrip> = Series::open(&path.to_string_lossy())
|
||||
.expect("expect the time series to open correctly");
|
||||
let mut ts: Series<BikeTrip> =
|
||||
Series::open(&path).expect("expect the time series to open correctly");
|
||||
|
||||
ts.put(trips[0].clone()).expect("expect a successful put");
|
||||
ts.put(trips[1].clone()).expect("expect a successful put");
|
||||
|
@ -283,9 +336,8 @@ mod test {
|
|||
match ts.get(&trip_id) {
|
||||
None => assert!(false, "record not found"),
|
||||
Some(mut trip) => {
|
||||
trip.distance = Distance(50000.0 * M);
|
||||
ts.update(trip_id.clone(), trip)
|
||||
.expect("expect record to update");
|
||||
trip.data.distance = Distance(50000.0 * M);
|
||||
ts.update(trip).expect("expect record to update");
|
||||
}
|
||||
};
|
||||
|
||||
|
@ -293,12 +345,12 @@ mod test {
|
|||
None => assert!(false, "record not found"),
|
||||
Some(trip) => {
|
||||
assert_eq!(
|
||||
trip.datetime,
|
||||
DateTimeTz(UTC.with_ymd_and_hms(2011, 11, 02, 0, 0, 0).unwrap())
|
||||
trip.data.datetime,
|
||||
UTC.with_ymd_and_hms(2011, 11, 02, 0, 0, 0).unwrap()
|
||||
);
|
||||
assert_eq!(trip.distance, Distance(50000.0 * M));
|
||||
assert_eq!(trip.duration, Duration(7020.0 * S));
|
||||
assert_eq!(trip.comments, String::from("Do Some Distance!"));
|
||||
assert_eq!(trip.data.distance, Distance(50000.0 * M));
|
||||
assert_eq!(trip.data.duration, Duration(7020.0 * S));
|
||||
assert_eq!(trip.data.comments, String::from("Do Some Distance!"));
|
||||
}
|
||||
}
|
||||
})
|
||||
|
@ -310,8 +362,8 @@ mod test {
|
|||
let trips = mk_trips();
|
||||
|
||||
{
|
||||
let mut ts: Series<BikeTrip> = Series::open(&path.to_string_lossy())
|
||||
.expect("expect the time series to open correctly");
|
||||
let mut ts: Series<BikeTrip> =
|
||||
Series::open(&path).expect("expect the time series to open correctly");
|
||||
|
||||
ts.put(trips[0].clone()).expect("expect a successful put");
|
||||
ts.put(trips[1].clone()).expect("expect a successful put");
|
||||
|
@ -320,32 +372,36 @@ mod test {
|
|||
match ts.get(&trip_id) {
|
||||
None => assert!(false, "record not found"),
|
||||
Some(mut trip) => {
|
||||
trip.distance = Distance(50000.0 * M);
|
||||
ts.update(trip_id, trip).expect("expect record to update");
|
||||
trip.data.distance = Distance(50000.0 * M);
|
||||
ts.update(trip).expect("expect record to update");
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
{
|
||||
let ts: Series<BikeTrip> = Series::open(&path.to_string_lossy())
|
||||
.expect("expect the time series to open correctly");
|
||||
let ts: Series<BikeTrip> =
|
||||
Series::open(&path).expect("expect the time series to open correctly");
|
||||
|
||||
let trips: Vec<(&UniqueId, &BikeTrip)> = ts.records().collect();
|
||||
let trips: Vec<&Record<BikeTrip>> = ts.records().collect();
|
||||
assert_eq!(trips.len(), 3);
|
||||
|
||||
let trips: Vec<(&UniqueId, &BikeTrip)> = ts
|
||||
.search(exact_time(
|
||||
DateTimeTz(UTC.with_ymd_and_hms(2011, 11, 02, 0, 0, 0).unwrap()).into(),
|
||||
))
|
||||
let trips: Vec<&Record<BikeTrip>> = ts
|
||||
.search(exact_time(Timestamp::DateTime(
|
||||
UTC.with_ymd_and_hms(2011, 11, 02, 0, 0, 0)
|
||||
.unwrap()
|
||||
.with_timezone(&FixedOffset::east_opt(0).unwrap()),
|
||||
)))
|
||||
.collect();
|
||||
assert_eq!(trips.len(), 1);
|
||||
assert_eq!(
|
||||
trips[0].1.datetime,
|
||||
DateTimeTz(UTC.with_ymd_and_hms(2011, 11, 02, 0, 0, 0).unwrap())
|
||||
trips[0].data.datetime,
|
||||
UTC.with_ymd_and_hms(2011, 11, 02, 0, 0, 0)
|
||||
.unwrap()
|
||||
.with_timezone(&FixedOffset::east_opt(0).unwrap())
|
||||
);
|
||||
assert_eq!(trips[0].1.distance, Distance(50000.0 * M));
|
||||
assert_eq!(trips[0].1.duration, Duration(7020.0 * S));
|
||||
assert_eq!(trips[0].1.comments, String::from("Do Some Distance!"));
|
||||
assert_eq!(trips[0].data.distance, Distance(50000.0 * M));
|
||||
assert_eq!(trips[0].data.duration, Duration(7020.0 * S));
|
||||
assert_eq!(trips[0].data.comments, String::from("Do Some Distance!"));
|
||||
}
|
||||
})
|
||||
}
|
||||
|
@ -356,21 +412,21 @@ mod test {
|
|||
let trips = mk_trips();
|
||||
|
||||
{
|
||||
let mut ts: Series<BikeTrip> = Series::open(&path.to_string_lossy())
|
||||
.expect("expect the time series to open correctly");
|
||||
let mut ts: Series<BikeTrip> =
|
||||
Series::open(&path).expect("expect the time series to open correctly");
|
||||
let trip_id = ts.put(trips[0].clone()).expect("expect a successful put");
|
||||
ts.put(trips[1].clone()).expect("expect a successful put");
|
||||
ts.put(trips[2].clone()).expect("expect a successful put");
|
||||
ts.delete(&trip_id).expect("successful delete");
|
||||
|
||||
let recs: Vec<(&UniqueId, &BikeTrip)> = ts.records().collect();
|
||||
let recs: Vec<&Record<BikeTrip>> = ts.records().collect();
|
||||
assert_eq!(recs.len(), 2);
|
||||
}
|
||||
|
||||
{
|
||||
let ts: Series<BikeTrip> = Series::open(&path.to_string_lossy())
|
||||
.expect("expect the time series to open correctly");
|
||||
let recs: Vec<(&UniqueId, &BikeTrip)> = ts.records().collect();
|
||||
let ts: Series<BikeTrip> =
|
||||
Series::open(&path).expect("expect the time series to open correctly");
|
||||
let recs: Vec<&Record<BikeTrip>> = ts.records().collect();
|
||||
assert_eq!(recs.len(), 2);
|
||||
}
|
||||
})
|
||||
|
@ -387,7 +443,7 @@ mod test {
|
|||
|
||||
impl Recordable for WeightRecord {
|
||||
fn timestamp(&self) -> Timestamp {
|
||||
self.date.into()
|
||||
Timestamp::Date(self.date)
|
||||
}
|
||||
|
||||
fn tags(&self) -> Vec<String> {
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
[package]
|
||||
name = "file-service"
|
||||
version = "0.1.1"
|
||||
version = "0.2.0"
|
||||
authors = ["savanni@luminescent-dreams.com"]
|
||||
edition = "2018"
|
||||
|
||||
|
@ -14,13 +14,10 @@ path = "src/lib.rs"
|
|||
name = "file-service"
|
||||
path = "src/main.rs"
|
||||
|
||||
[[bin]]
|
||||
name = "auth-cli"
|
||||
path = "src/bin/cli.rs"
|
||||
|
||||
[target.auth-cli.dependencies]
|
||||
|
||||
[dependencies]
|
||||
authdb = { path = "../authdb/" }
|
||||
base64ct = { version = "1", features = [ "alloc" ] }
|
||||
build_html = { version = "2" }
|
||||
bytes = { version = "1" }
|
||||
|
@ -38,9 +35,8 @@ mime_guess = "2.0.3"
|
|||
pretty_env_logger = { version = "0.5" }
|
||||
serde_json = "*"
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
sha2 = "0.10"
|
||||
sqlx = { version = "0.7", features = [ "runtime-tokio", "sqlite" ] }
|
||||
thiserror = "1.0.20"
|
||||
sha2 = { version = "0.10" }
|
||||
thiserror = { version = "1" }
|
||||
tokio = { version = "1", features = [ "full" ] }
|
||||
uuid = { version = "0.4", features = [ "serde", "v4" ] }
|
||||
warp = { version = "0.3" }
|
||||
|
|
|
@ -87,7 +87,7 @@ pub async fn handle_auth(
|
|||
app: App,
|
||||
form: HashMap<String, String>,
|
||||
) -> Result<http::Response<String>, Error> {
|
||||
match form.get("token") {
|
||||
match form.get("password") {
|
||||
Some(token) => match app.authenticate(AuthToken::from(token.clone())).await {
|
||||
Ok(Some(session_token)) => Response::builder()
|
||||
.header("location", "/")
|
||||
|
@ -134,6 +134,25 @@ pub async fn handle_upload(
|
|||
}
|
||||
}
|
||||
|
||||
pub async fn handle_delete(
|
||||
app: App,
|
||||
token: SessionToken,
|
||||
id: FileId,
|
||||
) -> Result<http::Response<String>, Error> {
|
||||
match app.validate_session(token).await {
|
||||
Ok(Some(_)) => match app.delete_file(id).await {
|
||||
Ok(_) => Response::builder()
|
||||
.header("location", "/")
|
||||
.status(StatusCode::SEE_OTHER)
|
||||
.body("".to_owned()),
|
||||
Err(_) => unimplemented!(),
|
||||
},
|
||||
_ => Response::builder()
|
||||
.status(StatusCode::UNAUTHORIZED)
|
||||
.body("".to_owned()),
|
||||
}
|
||||
}
|
||||
|
||||
fn serve_file<F>(
|
||||
info: FileInfo,
|
||||
file: F,
|
||||
|
|
|
@ -74,7 +74,7 @@ impl Html for Form {
|
|||
None => "".to_owned(),
|
||||
};
|
||||
format!(
|
||||
"<form action=\"{path}\" method=\"{method}\" {encoding}\n{elements}\n</form>\n",
|
||||
"<form action=\"{path}\" method=\"{method}\" {encoding}>\n{elements}\n</form>\n",
|
||||
path = self.path,
|
||||
method = self.method,
|
||||
encoding = encoding,
|
||||
|
@ -137,11 +137,6 @@ impl Input {
|
|||
self
|
||||
}
|
||||
|
||||
pub fn with_value(mut self, val: &str) -> Self {
|
||||
self.value = Some(val.to_owned());
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_attributes<'a>(
|
||||
mut self,
|
||||
values: impl IntoIterator<Item = (&'a str, &'a str)>,
|
||||
|
@ -156,31 +151,6 @@ impl Input {
|
|||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Label {
|
||||
target: String,
|
||||
text: String,
|
||||
}
|
||||
|
||||
impl Label {
|
||||
pub fn new(target: &str, text: &str) -> Self {
|
||||
Self {
|
||||
target: target.to_owned(),
|
||||
text: text.to_owned(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Html for Label {
|
||||
fn to_html_string(&self) -> String {
|
||||
format!(
|
||||
"<label for=\"{target}\">{text}</label>",
|
||||
target = self.target,
|
||||
text = self.text
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Button {
|
||||
ty: Option<String>,
|
||||
|
@ -236,41 +206,3 @@ impl Html for Button {
|
|||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Image {
|
||||
path: String,
|
||||
attributes: Attributes,
|
||||
}
|
||||
|
||||
impl Image {
|
||||
pub fn new(path: &str) -> Self {
|
||||
Self {
|
||||
path: path.to_owned(),
|
||||
attributes: Attributes::default(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_attributes<'a>(
|
||||
mut self,
|
||||
values: impl IntoIterator<Item = (&'a str, &'a str)>,
|
||||
) -> Self {
|
||||
self.attributes = Attributes(
|
||||
values
|
||||
.into_iter()
|
||||
.map(|(a, b)| (a.to_owned(), b.to_owned()))
|
||||
.collect::<Vec<(String, String)>>(),
|
||||
);
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl Html for Image {
|
||||
fn to_html_string(&self) -> String {
|
||||
format!(
|
||||
"<img src={path} {attrs} />",
|
||||
path = self.path,
|
||||
attrs = self.attributes.to_string()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
mod store;
|
||||
|
||||
pub use store::{
|
||||
AuthDB, AuthError, AuthToken, FileHandle, FileId, FileInfo, ReadFileError, SessionToken, Store,
|
||||
Username, WriteFileError,
|
||||
DeleteFileError, FileHandle, FileId, FileInfo, ReadFileError, Store, WriteFileError,
|
||||
};
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
extern crate log;
|
||||
|
||||
use cookie::Cookie;
|
||||
use handlers::{file, handle_auth, handle_css, handle_upload, thumbnail};
|
||||
use handlers::{file, handle_auth, handle_css, handle_delete, handle_upload, thumbnail};
|
||||
use std::{
|
||||
collections::{HashMap, HashSet},
|
||||
convert::Infallible,
|
||||
|
@ -18,9 +18,10 @@ mod pages;
|
|||
|
||||
const MAX_UPLOAD: u64 = 15 * 1024 * 1024;
|
||||
|
||||
pub use file_service::{
|
||||
AuthDB, AuthError, AuthToken, FileHandle, FileId, FileInfo, ReadFileError, SessionToken, Store,
|
||||
Username, WriteFileError,
|
||||
use authdb::{AuthDB, AuthError, AuthToken, SessionToken, Username};
|
||||
|
||||
use file_service::{
|
||||
DeleteFileError, FileHandle, FileId, FileInfo, ReadFileError, Store, WriteFileError,
|
||||
};
|
||||
pub use handlers::handle_index;
|
||||
|
||||
|
@ -64,6 +65,11 @@ impl App {
|
|||
) -> Result<FileHandle, WriteFileError> {
|
||||
self.store.write().await.add_file(filename, content)
|
||||
}
|
||||
|
||||
pub async fn delete_file(&self, id: FileId) -> Result<(), DeleteFileError> {
|
||||
self.store.write().await.delete_file(&id)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn with_app(app: App) -> impl Filter<Extract = (App,), Error = Infallible> + Clone {
|
||||
|
@ -134,6 +140,12 @@ pub async fn main() {
|
|||
.and(warp::multipart::form().max_length(MAX_UPLOAD))
|
||||
.then(handle_upload);
|
||||
|
||||
let delete_via_form = warp::path!("delete" / String)
|
||||
.and(warp::post())
|
||||
.and(with_app(app.clone()))
|
||||
.and(with_session())
|
||||
.then(|id, app, token| handle_delete(app, token, FileId::from(id)));
|
||||
|
||||
let thumbnail = warp::path!(String / "tn")
|
||||
.and(warp::get())
|
||||
.and(warp::header::optional::<String>("if-none-match"))
|
||||
|
@ -150,6 +162,7 @@ pub async fn main() {
|
|||
root.or(styles)
|
||||
.or(auth)
|
||||
.or(upload_via_form)
|
||||
.or(delete_via_form)
|
||||
.or(thumbnail)
|
||||
.or(file)
|
||||
.with(log),
|
||||
|
|
|
@ -1,35 +1,38 @@
|
|||
use crate::html::*;
|
||||
use build_html::{self, Container, ContainerType, Html, HtmlContainer};
|
||||
use file_service::{FileHandle, FileId, ReadFileError};
|
||||
use file_service::{FileHandle, FileInfo, ReadFileError};
|
||||
|
||||
pub fn auth(_message: Option<String>) -> build_html::HtmlPage {
|
||||
build_html::HtmlPage::new()
|
||||
.with_title("Authentication")
|
||||
.with_title("Sign In")
|
||||
.with_stylesheet("/css")
|
||||
.with_container(
|
||||
Container::new(ContainerType::Div)
|
||||
.with_attributes([("class", "authentication-page")])
|
||||
.with_container(auth_form()),
|
||||
)
|
||||
}
|
||||
|
||||
fn auth_form() -> Container {
|
||||
Container::default()
|
||||
.with_attributes([("class", "card authentication-form")])
|
||||
.with_html(
|
||||
Form::new()
|
||||
.with_path("/auth")
|
||||
.with_method("post")
|
||||
.with_container(
|
||||
Container::new(ContainerType::Div)
|
||||
.with_attributes([("class", "card authentication-form")])
|
||||
.with_html(
|
||||
Form::new()
|
||||
.with_path("/auth")
|
||||
.with_method("post")
|
||||
.with_container(
|
||||
Container::new(ContainerType::Div)
|
||||
.with_attributes([("class", "authentication-form__label")])
|
||||
.with_html(Label::new("for-token-input", "Authentication")),
|
||||
)
|
||||
.with_container(
|
||||
Container::new(ContainerType::Div)
|
||||
.with_attributes([("class", "authentication-form__input")])
|
||||
.with_html(
|
||||
Input::new("token", "token")
|
||||
.with_id("for-token-input")
|
||||
.with_attributes([("size", "50")]),
|
||||
),
|
||||
),
|
||||
Input::new("password", "password")
|
||||
.with_id("for-token-input")
|
||||
.with_attributes([
|
||||
("size", "50"),
|
||||
("class", "authentication-form__input"),
|
||||
]),
|
||||
)
|
||||
.with_html(
|
||||
Button::new("Sign In")
|
||||
.with_attributes([("class", "authentication-form__button")]),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
@ -49,14 +52,7 @@ pub fn gallery(handles: Vec<Result<FileHandle, ReadFileError>>) -> build_html::H
|
|||
let mut gallery = Container::new(ContainerType::Div).with_attributes([("class", "gallery")]);
|
||||
for handle in handles {
|
||||
let container = match handle {
|
||||
Ok(ref handle) => thumbnail(&handle.id).with_html(
|
||||
Form::new()
|
||||
.with_path(&format!("/{}", *handle.id))
|
||||
.with_method("post")
|
||||
.with_html(Input::new("hidden", "_method").with_value("delete"))
|
||||
.with_html(Button::new("Delete")),
|
||||
),
|
||||
|
||||
Ok(ref handle) => thumbnail(&handle.info),
|
||||
Err(err) => Container::new(ContainerType::Div)
|
||||
.with_attributes(vec![("class", "file")])
|
||||
.with_paragraph(format!("{:?}", err)),
|
||||
|
@ -88,15 +84,31 @@ pub fn upload_form() -> Form {
|
|||
)
|
||||
}
|
||||
|
||||
pub fn thumbnail(id: &FileId) -> Container {
|
||||
pub fn thumbnail(info: &FileInfo) -> Container {
|
||||
Container::new(ContainerType::Div)
|
||||
.with_attributes(vec![("class", "card thumbnail")])
|
||||
.with_html(
|
||||
Container::new(ContainerType::Div).with_link(
|
||||
format!("/{}", **id),
|
||||
Image::new(&format!("{}/tn", **id))
|
||||
.with_attributes([("class", "thumbnail__image")])
|
||||
format!("/{}", *info.id),
|
||||
Container::default()
|
||||
.with_attributes([("class", "thumbnail")])
|
||||
.with_image(format!("{}/tn", *info.id), "test data")
|
||||
.to_html_string(),
|
||||
),
|
||||
)
|
||||
.with_html(
|
||||
Container::new(ContainerType::Div)
|
||||
.with_html(
|
||||
Container::new(ContainerType::UnorderedList)
|
||||
.with_attributes(vec![("class", "thumbnail__metadata")])
|
||||
.with_html(info.name.clone())
|
||||
.with_html(format!("{}", info.created.format("%Y-%m-%d"))),
|
||||
)
|
||||
.with_html(
|
||||
Form::new()
|
||||
.with_path(&format!("/delete/{}", *info.id))
|
||||
.with_method("post")
|
||||
.with_html(Button::new("Delete")),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
|
|
@ -120,8 +120,13 @@ impl FileHandle {
|
|||
/// Create a new entry in the database
|
||||
pub fn new(filename: String, root: PathBuf) -> Result<Self, WriteFileError> {
|
||||
let id = FileId::from(Uuid::new_v4().hyphenated().to_string());
|
||||
let path = PathBuf::from(filename);
|
||||
|
||||
let extension = PathBuf::from(filename)
|
||||
let name = path
|
||||
.file_stem()
|
||||
.and_then(|s| s.to_str().map(|s| s.to_owned()))
|
||||
.ok_or(WriteFileError::InvalidPath)?;
|
||||
let extension = path
|
||||
.extension()
|
||||
.and_then(|s| s.to_str().map(|s| s.to_owned()))
|
||||
.ok_or(WriteFileError::InvalidPath)?;
|
||||
|
@ -138,6 +143,7 @@ impl FileHandle {
|
|||
|
||||
let info = FileInfo {
|
||||
id: id.clone(),
|
||||
name,
|
||||
size: 0,
|
||||
created: Utc::now(),
|
||||
file_type,
|
||||
|
@ -233,6 +239,17 @@ mod test {
|
|||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn it_creates_file_info() {
|
||||
let tmp = TempDir::new("var").unwrap();
|
||||
let handle =
|
||||
FileHandle::new("rawr.png".to_owned(), PathBuf::from(tmp.path())).expect("to succeed");
|
||||
assert_eq!(handle.info.name, "rawr");
|
||||
assert_eq!(handle.info.size, 0);
|
||||
assert_eq!(handle.info.file_type, "image/png");
|
||||
assert_eq!(handle.info.extension, "png");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn it_opens_a_file() {
|
||||
let tmp = TempDir::new("var").unwrap();
|
||||
|
|
|
@ -11,6 +11,12 @@ use std::{
|
|||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct FileInfo {
|
||||
pub id: FileId,
|
||||
|
||||
// Early versions of the application didn't support a name field, so it is possible that
|
||||
// metadata won't contain the name. We can just default to an empty string when loading the
|
||||
// metadata, as all future versions will require a filename when the file gets uploaded.
|
||||
#[serde(default)]
|
||||
pub name: String,
|
||||
pub size: usize,
|
||||
pub created: DateTime<Utc>,
|
||||
pub file_type: String,
|
||||
|
@ -50,6 +56,7 @@ mod test {
|
|||
|
||||
let info = FileInfo {
|
||||
id: FileId("temp-id".to_owned()),
|
||||
name: "test-image".to_owned(),
|
||||
size: 23777,
|
||||
created,
|
||||
file_type: "image/png".to_owned(),
|
||||
|
|
|
@ -1,13 +1,6 @@
|
|||
use base64ct::{Base64, Encoding};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
use sqlx::{
|
||||
sqlite::{SqlitePool, SqliteRow},
|
||||
Row,
|
||||
};
|
||||
use std::{collections::HashSet, ops::Deref, path::PathBuf};
|
||||
use thiserror::Error;
|
||||
use uuid::Uuid;
|
||||
|
||||
mod filehandle;
|
||||
mod fileinfo;
|
||||
|
@ -53,9 +46,6 @@ pub enum ReadFileError {
|
|||
#[error("permission denied")]
|
||||
PermissionDenied,
|
||||
|
||||
#[error("invalid path")]
|
||||
InvalidPath,
|
||||
|
||||
#[error("JSON error")]
|
||||
JSONError(#[from] serde_json::error::Error),
|
||||
|
||||
|
@ -64,132 +54,32 @@ pub enum ReadFileError {
|
|||
}
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum AuthError {
|
||||
#[error("authentication token is duplicated")]
|
||||
DuplicateAuthToken,
|
||||
pub enum DeleteFileError {
|
||||
#[error("file not found")]
|
||||
FileNotFound(PathBuf),
|
||||
|
||||
#[error("session token is duplicated")]
|
||||
DuplicateSessionToken,
|
||||
#[error("metadata path is not a file")]
|
||||
NotAFile,
|
||||
|
||||
#[error("database failed")]
|
||||
SqlError(sqlx::Error),
|
||||
#[error("cannot read metadata")]
|
||||
PermissionDenied,
|
||||
|
||||
#[error("invalid metadata path")]
|
||||
MetadataParseError(serde_json::error::Error),
|
||||
|
||||
#[error("IO error")]
|
||||
IOError(#[from] std::io::Error),
|
||||
}
|
||||
|
||||
impl From<sqlx::Error> for AuthError {
|
||||
fn from(err: sqlx::Error) -> AuthError {
|
||||
AuthError::SqlError(err)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, Hash, PartialEq, Eq)]
|
||||
pub struct Username(String);
|
||||
|
||||
impl From<String> for Username {
|
||||
fn from(s: String) -> Self {
|
||||
Self(s)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&str> for Username {
|
||||
fn from(s: &str) -> Self {
|
||||
Self(s.to_owned())
|
||||
}
|
||||
}
|
||||
|
||||
impl From<Username> for String {
|
||||
fn from(s: Username) -> Self {
|
||||
Self::from(&s)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&Username> for String {
|
||||
fn from(s: &Username) -> Self {
|
||||
let Username(s) = s;
|
||||
Self::from(s)
|
||||
}
|
||||
}
|
||||
|
||||
impl Deref for Username {
|
||||
type Target = String;
|
||||
fn deref(&self) -> &Self::Target {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl sqlx::FromRow<'_, SqliteRow> for Username {
|
||||
fn from_row(row: &SqliteRow) -> sqlx::Result<Self> {
|
||||
let name: String = row.try_get("username")?;
|
||||
Ok(Username::from(name))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, Hash, PartialEq, Eq)]
|
||||
pub struct AuthToken(String);
|
||||
|
||||
impl From<String> for AuthToken {
|
||||
fn from(s: String) -> Self {
|
||||
Self(s)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&str> for AuthToken {
|
||||
fn from(s: &str) -> Self {
|
||||
Self(s.to_owned())
|
||||
}
|
||||
}
|
||||
|
||||
impl From<AuthToken> for PathBuf {
|
||||
fn from(s: AuthToken) -> Self {
|
||||
Self::from(&s)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&AuthToken> for PathBuf {
|
||||
fn from(s: &AuthToken) -> Self {
|
||||
let AuthToken(s) = s;
|
||||
Self::from(s)
|
||||
}
|
||||
}
|
||||
|
||||
impl Deref for AuthToken {
|
||||
type Target = String;
|
||||
fn deref(&self) -> &Self::Target {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, Hash, PartialEq, Eq)]
|
||||
pub struct SessionToken(String);
|
||||
|
||||
impl From<String> for SessionToken {
|
||||
fn from(s: String) -> Self {
|
||||
Self(s)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&str> for SessionToken {
|
||||
fn from(s: &str) -> Self {
|
||||
Self(s.to_owned())
|
||||
}
|
||||
}
|
||||
|
||||
impl From<SessionToken> for PathBuf {
|
||||
fn from(s: SessionToken) -> Self {
|
||||
Self::from(&s)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&SessionToken> for PathBuf {
|
||||
fn from(s: &SessionToken) -> Self {
|
||||
let SessionToken(s) = s;
|
||||
Self::from(s)
|
||||
}
|
||||
}
|
||||
|
||||
impl Deref for SessionToken {
|
||||
type Target = String;
|
||||
fn deref(&self) -> &Self::Target {
|
||||
&self.0
|
||||
impl From<ReadFileError> for DeleteFileError {
|
||||
fn from(err: ReadFileError) -> Self {
|
||||
match err {
|
||||
ReadFileError::FileNotFound(path) => DeleteFileError::FileNotFound(path),
|
||||
ReadFileError::NotAFile => DeleteFileError::NotAFile,
|
||||
ReadFileError::PermissionDenied => DeleteFileError::PermissionDenied,
|
||||
ReadFileError::JSONError(err) => DeleteFileError::MetadataParseError(err),
|
||||
ReadFileError::IOError(err) => DeleteFileError::IOError(err),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -240,95 +130,6 @@ impl FileRoot for Context {
|
|||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct AuthDB {
|
||||
pool: SqlitePool,
|
||||
}
|
||||
|
||||
impl AuthDB {
|
||||
pub async fn new(path: PathBuf) -> Result<Self, sqlx::Error> {
|
||||
let migrator = sqlx::migrate!("./migrations");
|
||||
let pool = SqlitePool::connect(&format!("sqlite://{}", path.to_str().unwrap())).await?;
|
||||
migrator.run(&pool).await?;
|
||||
Ok(Self { pool })
|
||||
}
|
||||
|
||||
pub async fn add_user(&self, username: Username) -> Result<AuthToken, AuthError> {
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(Uuid::new_v4().hyphenated().to_string());
|
||||
hasher.update(username.to_string());
|
||||
let auth_token = Base64::encode_string(&hasher.finalize());
|
||||
|
||||
let _ = sqlx::query("INSERT INTO users (username, token) VALUES ($1, $2)")
|
||||
.bind(username.to_string())
|
||||
.bind(auth_token.clone())
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
|
||||
Ok(AuthToken::from(auth_token))
|
||||
}
|
||||
|
||||
pub async fn list_users(&self) -> Result<Vec<Username>, AuthError> {
|
||||
let usernames = sqlx::query_as::<_, Username>("SELECT (username) FROM users")
|
||||
.fetch_all(&self.pool)
|
||||
.await?;
|
||||
|
||||
Ok(usernames)
|
||||
}
|
||||
|
||||
pub async fn authenticate(&self, token: AuthToken) -> Result<Option<SessionToken>, AuthError> {
|
||||
let results = sqlx::query("SELECT * FROM users WHERE token = $1")
|
||||
.bind(token.to_string())
|
||||
.fetch_all(&self.pool)
|
||||
.await?;
|
||||
|
||||
if results.len() > 1 {
|
||||
return Err(AuthError::DuplicateAuthToken);
|
||||
}
|
||||
|
||||
if results.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let user_id: i64 = results[0].try_get("id")?;
|
||||
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(Uuid::new_v4().hyphenated().to_string());
|
||||
hasher.update(token.to_string());
|
||||
let session_token = Base64::encode_string(&hasher.finalize());
|
||||
|
||||
let _ = sqlx::query("INSERT INTO sessions (token, user_id) VALUES ($1, $2)")
|
||||
.bind(session_token.clone())
|
||||
.bind(user_id)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
|
||||
Ok(Some(SessionToken::from(session_token)))
|
||||
}
|
||||
|
||||
pub async fn validate_session(
|
||||
&self,
|
||||
token: SessionToken,
|
||||
) -> Result<Option<Username>, AuthError> {
|
||||
let rows = sqlx::query(
|
||||
"SELECT users.username FROM sessions INNER JOIN users ON sessions.user_id = users.id WHERE sessions.token = $1",
|
||||
)
|
||||
.bind(token.to_string())
|
||||
.fetch_all(&self.pool)
|
||||
.await?;
|
||||
if rows.len() > 1 {
|
||||
return Err(AuthError::DuplicateSessionToken);
|
||||
}
|
||||
|
||||
if rows.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let username: String = rows[0].try_get("username")?;
|
||||
Ok(Some(Username::from(username)))
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Store {
|
||||
files_root: PathBuf,
|
||||
}
|
||||
|
@ -369,7 +170,7 @@ impl Store {
|
|||
FileHandle::load(id, &self.files_root)
|
||||
}
|
||||
|
||||
pub fn delete_file(&mut self, id: &FileId) -> Result<(), WriteFileError> {
|
||||
pub fn delete_file(&mut self, id: &FileId) -> Result<(), DeleteFileError> {
|
||||
let handle = FileHandle::load(id, &self.files_root)?;
|
||||
handle.delete();
|
||||
Ok(())
|
||||
|
@ -466,74 +267,3 @@ mod test {
|
|||
});
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod authdb_test {
|
||||
use super::*;
|
||||
use cool_asserts::assert_matches;
|
||||
|
||||
#[tokio::test]
|
||||
async fn can_create_and_list_users() {
|
||||
let db = AuthDB::new(PathBuf::from(":memory:"))
|
||||
.await
|
||||
.expect("a memory-only database will be created");
|
||||
let _ = db
|
||||
.add_user(Username::from("savanni"))
|
||||
.await
|
||||
.expect("user to be created");
|
||||
assert_matches!(db.list_users().await, Ok(names) => {
|
||||
let names = names.into_iter().collect::<HashSet<Username>>();
|
||||
assert!(names.contains(&Username::from("savanni")));
|
||||
})
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn unknown_auth_token_returns_nothing() {
|
||||
let db = AuthDB::new(PathBuf::from(":memory:"))
|
||||
.await
|
||||
.expect("a memory-only database will be created");
|
||||
let _ = db
|
||||
.add_user(Username::from("savanni"))
|
||||
.await
|
||||
.expect("user to be created");
|
||||
|
||||
let token = AuthToken::from("0000000000");
|
||||
|
||||
assert_matches!(db.authenticate(token).await, Ok(None));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn auth_token_becomes_session_token() {
|
||||
let db = AuthDB::new(PathBuf::from(":memory:"))
|
||||
.await
|
||||
.expect("a memory-only database will be created");
|
||||
let token = db
|
||||
.add_user(Username::from("savanni"))
|
||||
.await
|
||||
.expect("user to be created");
|
||||
|
||||
assert_matches!(db.authenticate(token).await, Ok(_));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn can_validate_session_token() {
|
||||
let db = AuthDB::new(PathBuf::from(":memory:"))
|
||||
.await
|
||||
.expect("a memory-only database will be created");
|
||||
let token = db
|
||||
.add_user(Username::from("savanni"))
|
||||
.await
|
||||
.expect("user to be created");
|
||||
let session = db
|
||||
.authenticate(token)
|
||||
.await
|
||||
.expect("token authentication should succeed")
|
||||
.expect("session token should be found");
|
||||
|
||||
assert_matches!(
|
||||
db.validate_session(session).await,
|
||||
Ok(Some(username)) => {
|
||||
assert_eq!(username, Username::from("savanni"));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
@ -29,6 +29,7 @@ body {
|
|||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 200px;
|
||||
margin: 8px;
|
||||
}
|
||||
|
||||
.authentication-form {
|
||||
|
@ -77,6 +78,10 @@ body {
|
|||
border: none;
|
||||
}
|
||||
|
||||
.thumbnail__metadata {
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
/*
|
||||
[type="submit"] {
|
||||
border-radius: 1em;
|
||||
|
@ -129,6 +134,16 @@ body {
|
|||
|
||||
.authentication-form {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.authentication-form__input {
|
||||
font-size: x-large;
|
||||
}
|
||||
|
||||
.authentication-form__button {
|
||||
font-size: x-large;
|
||||
}
|
||||
|
||||
.upload-form__selector {
|
||||
|
|
|
@ -0,0 +1,26 @@
|
|||
[package]
|
||||
name = "fitnesstrax"
|
||||
version = "0.6.0"
|
||||
edition = "2021"
|
||||
|
||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||
|
||||
[dependencies]
|
||||
adw = { version = "0.5", package = "libadwaita", features = [ "v1_4" ] }
|
||||
async-channel = { version = "2.1" }
|
||||
async-trait = { version = "0.1" }
|
||||
chrono = { version = "0.4" }
|
||||
chrono-tz = { version = "0.8" }
|
||||
dimensioned = { version = "0.8", features = [ "serde" ] }
|
||||
emseries = { path = "../../emseries" }
|
||||
ft-core = { path = "../core" }
|
||||
gio = { version = "0.18" }
|
||||
glib = { version = "0.18" }
|
||||
gdk = { version = "0.7", package = "gdk4" }
|
||||
gtk = { version = "0.7", package = "gtk4", features = [ "v4_10" ] }
|
||||
thiserror = { version = "1.0" }
|
||||
tokio = { version = "1.34", features = [ "full" ] }
|
||||
|
||||
[build-dependencies]
|
||||
glib-build-tools = "0.18"
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
fn main() {
|
||||
glib_build_tools::compile_resources(
|
||||
&["resources"],
|
||||
"gresources.xml",
|
||||
"com.luminescent-dreams.fitnesstrax.gresource",
|
||||
);
|
||||
}
|
|
@ -0,0 +1,12 @@
|
|||
#!/usr/bin/env bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
VERSION=`cat Cargo.toml | grep "^version =" | sed -r 's/^version = "(.+)"$/\1/'`
|
||||
|
||||
mkdir -p dist
|
||||
cp ../../target/release/fitnesstrax dist
|
||||
cp resources/com.luminescent-dreams.fitnesstrax.gschema.xml resources/fitnesstrax.desktop dist
|
||||
strip dist/fitnesstrax
|
||||
tar -czf fitnesstrax-${VERSION}.tgz dist/
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<gresources>
|
||||
<gresource prefix="/com/luminescent-dreams/fitnesstrax/">
|
||||
<file>style.css</file>
|
||||
</gresource>
|
||||
|
||||
<gresource prefix="/com/luminescent-dreams/fitnesstrax/icons/scalable/actions">
|
||||
<file preprocess="xml-stripblanks">cycling-symbolic.svg</file>
|
||||
</gresource>
|
||||
|
||||
<gresource prefix="/com/luminescent-dreams/fitnesstrax/icons/scalable/actions">
|
||||
<file preprocess="xml-stripblanks">running-symbolic.svg</file>
|
||||
</gresource>
|
||||
|
||||
<gresource prefix="/com/luminescent-dreams/fitnesstrax/icons/scalable/actions/">
|
||||
<file preprocess="xml-stripblanks">walking-symbolic.svg</file>
|
||||
</gresource>
|
||||
</gresources>
|
|
@ -0,0 +1,14 @@
|
|||
{ gtkNativeInputs }:
|
||||
attrs: {
|
||||
nativeBuildInputs = gtkNativeInputs;
|
||||
postInstall = ''
|
||||
install -Dt $out/share/applications resources/fitnesstrax.desktop
|
||||
install -Dt $out/gsettings-schemas/${attrs.crateName}-${attrs.version}/glib-2.0/schemas resources/com.luminescent-dreams.fitnesstrax.gschema.xml
|
||||
glib-compile-schemas $out/gsettings-schemas/${attrs.crateName}-${attrs.version}/glib-2.0/schemas
|
||||
'';
|
||||
preFixup = ''
|
||||
gappsWrapperArgs+=(
|
||||
--prefix XDG_DATA_DIRS : $out/gsettings-schemas/${attrs.crateName}-${attrs.version}
|
||||
)
|
||||
'';
|
||||
}
|
|
@ -0,0 +1,9 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<schemalist>
|
||||
<schema id="com.luminescent-dreams.fitnesstrax.dev" path="/com/luminescent-dreams/fitnesstrax/dev/">
|
||||
<key name="series-path" type="s">
|
||||
<default>""</default>
|
||||
<summary>Path to the series</summary>
|
||||
</key>
|
||||
</schema>
|
||||
</schemalist>
|
|
@ -0,0 +1,9 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<schemalist>
|
||||
<schema id="com.luminescent-dreams.fitnesstrax" path="/com/luminescent-dreams/fitnesstrax/">
|
||||
<key name="series-path" type="s">
|
||||
<default>""</default>
|
||||
<summary>Path to the series</summary>
|
||||
</key>
|
||||
</schema>
|
||||
</schemalist>
|
|
@ -0,0 +1,2 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" height="16px" viewBox="0 0 16 16" width="16px"><filter id="a" height="100%" width="100%" x="0%" y="0%"><feColorMatrix color-interpolation-filters="sRGB" values="0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 1 0"/></filter><mask id="b"><g filter="url(#a)"><path d="m -1.6 -1.6 h 19.2 v 19.2 h -19.2 z" fill-opacity="0.5"/></g></mask><clipPath id="c"><path d="m 0 0 h 1600 v 1200 h -1600 z"/></clipPath><mask id="d"><g filter="url(#a)"><path d="m -1.6 -1.6 h 19.2 v 19.2 h -19.2 z" fill-opacity="0.7"/></g></mask><clipPath id="e"><path d="m 0 0 h 1600 v 1200 h -1600 z"/></clipPath><mask id="f"><g filter="url(#a)"><path d="m -1.6 -1.6 h 19.2 v 19.2 h -19.2 z" fill-opacity="0.35"/></g></mask><clipPath id="g"><path d="m 0 0 h 1600 v 1200 h -1600 z"/></clipPath><path d="m 9.5 2 c -0.828125 0 -1.5 0.671875 -1.5 1.5 s 0.671875 1.5 1.5 1.5 s 1.5 -0.671875 1.5 -1.5 s -0.671875 -1.5 -1.5 -1.5 z m 0 0"/><path d="m 4.285156 13 c 0 0.703125 -0.582031 1.285156 -1.285156 1.285156 s -1.285156 -0.582031 -1.285156 -1.285156 s 0.582031 -1.285156 1.285156 -1.285156 s 1.285156 0.582031 1.285156 1.285156 z m -4.285156 0 c 0 1.675781 1.324219 3 3 3 s 3 -1.324219 3 -3 s -1.324219 -3 -3 -3 s -3 1.324219 -3 3 z m 0 0"/><path d="m 8.992188 13.007812 v -3.003906 c 0 -0.359375 -0.1875 -0.6875 -0.5 -0.867187 l -2.558594 -1.476563 l 0.363281 1.363282 l 1.671875 -2.890626 l -1.367188 0.363282 l 0.910157 0.527344 l -0.40625 -0.4375 c 0.773437 1.621093 1.96875 1.933593 1.96875 1.933593 s 0.578125 0.242188 1.9375 0.429688 c 0.546875 0.074219 1.050781 -0.304688 1.128906 -0.851563 c 0.074219 -0.550781 -0.308594 -1.054687 -0.855469 -1.128906 c -1.179687 -0.164062 -1.601562 -0.355469 -1.601562 -0.355469 s -0.425782 -0.164062 -0.769532 -0.886719 c -0.089843 -0.183593 -0.226562 -0.335937 -0.402343 -0.4375 l -0.910157 -0.523437 c -0.476562 -0.277344 -1.089843 -0.113281 -1.363281 0.367187 l -1.671875 2.890626 c -0.277344 0.480468 -0.113281 1.089843 0.367188 1.367187 l 2.558594 1.480469 l -0.5 -0.867188 v 3.003906 c 0 0.550782 0.449218 1 1 1 c 0.554687 0 1 -0.449218 1 -1 z m 0 0"/><path d="m 14.285156 13 c 0 0.703125 -0.582031 1.285156 -1.285156 1.285156 s -1.285156 -0.582031 -1.285156 -1.285156 s 0.582031 -1.285156 1.285156 -1.285156 s 1.285156 0.582031 1.285156 1.285156 z m -4.285156 0 c 0 1.675781 1.324219 3 3 3 s 3 -1.324219 3 -3 s -1.324219 -3 -3 -3 s -3 1.324219 -3 3 z m 0 0"/><g mask="url(#b)"><g clip-path="url(#c)" transform="matrix(1 0 0 1 -600 -120)"><path d="m 550 182 c -0.351562 0.003906 -0.695312 0.101562 -1 0.28125 v 3.4375 c 0.304688 0.179688 0.648438 0.277344 1 0.28125 c 1.105469 0 2 -0.894531 2 -2 s -0.894531 -2 -2 -2 z m 0 5 c -0.339844 0 -0.679688 0.058594 -1 0.175781 v 6.824219 h 4 v -4 c 0 -1.65625 -1.34375 -3 -3 -3 z m 0 0"/></g></g><g mask="url(#d)"><g clip-path="url(#e)" transform="matrix(1 0 0 1 -600 -120)"><path d="m 569 182 v 4 c 1.105469 0 2 -0.894531 2 -2 s -0.894531 -2 -2 -2 z m 0 5 v 7 h 3 v -4 c 0 -1.65625 -1.34375 -3 -3 -3 z m 0 0"/></g></g><g mask="url(#f)"><g clip-path="url(#g)" transform="matrix(1 0 0 1 -600 -120)"><path d="m 573 182.269531 v 3.449219 c 0.613281 -0.355469 0.996094 -1.007812 1 -1.71875 c 0 -0.714844 -0.382812 -1.375 -1 -1.730469 z m 0 4.90625 v 6.824219 h 2 v -4 c 0 -1.269531 -0.800781 -2.402344 -2 -2.824219 z m 0 0"/></g></g></svg>
|
After Width: | Height: | Size: 3.3 KiB |
|
@ -0,0 +1,5 @@
|
|||
[Desktop Entry]
|
||||
Version=0.2
|
||||
Type=Application
|
||||
Name=FitnessTrax
|
||||
Exec=fitnesstrax
|
|
@ -0,0 +1,2 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" height="16px" viewBox="0 0 16 16" width="16px"><filter id="a" height="100%" width="100%" x="0%" y="0%"><feColorMatrix color-interpolation-filters="sRGB" values="0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 1 0"/></filter><mask id="b"><g filter="url(#a)"><path d="m -1.6 -1.6 h 19.2 v 19.2 h -19.2 z" fill-opacity="0.5"/></g></mask><clipPath id="c"><path d="m 0 0 h 1600 v 1200 h -1600 z"/></clipPath><mask id="d"><g filter="url(#a)"><path d="m -1.6 -1.6 h 19.2 v 19.2 h -19.2 z" fill-opacity="0.7"/></g></mask><clipPath id="e"><path d="m 0 0 h 1600 v 1200 h -1600 z"/></clipPath><mask id="f"><g filter="url(#a)"><path d="m -1.6 -1.6 h 19.2 v 19.2 h -19.2 z" fill-opacity="0.35"/></g></mask><clipPath id="g"><path d="m 0 0 h 1600 v 1200 h -1600 z"/></clipPath><path d="m 8.5 0 c -0.828125 0 -1.5 0.671875 -1.5 1.5 s 0.671875 1.5 1.5 1.5 s 1.5 -0.671875 1.5 -1.5 s -0.671875 -1.5 -1.5 -1.5 z m -2.5 4 c -0.117188 0 -0.230469 0.027344 -0.335938 0.082031 l -2 1 c -0.144531 0.070313 -0.261718 0.1875 -0.332031 0.332031 l -1 2 c -0.1875 0.371094 -0.039062 0.820313 0.332031 1.007813 c 0.371094 0.183594 0.820313 0.035156 1.003907 -0.335937 l 0.890625 -1.777344 l 1.5625 -0.773438 c -0.042969 0.074219 -0.726563 2.835938 -0.726563 2.835938 c -0.230469 0.949218 0.398438 1.523437 0.398438 1.523437 l 3.351562 2.703125 l 0.90625 2.71875 c 0.175781 0.523438 0.742188 0.808594 1.265625 0.632813 c 0.523438 -0.175781 0.808594 -0.742188 0.632813 -1.265625 l -1 -3 c -0.0625 -0.183594 -0.171875 -0.34375 -0.324219 -0.464844 l -2 -1.597656 l 0.679688 -2.714844 l 0.25 0.625 c 0.085937 0.222656 0.28125 0.390625 0.515624 0.449219 l 2 0.5 c 0.402344 0.097656 0.808594 -0.144531 0.910157 -0.546875 c 0.097656 -0.40625 -0.144531 -0.8125 -0.546875 -0.910156 l -1.628906 -0.40625 l -0.855469 -2.144532 c -0.117188 -0.285156 -0.390625 -0.472656 -0.699219 -0.472656 z m -1.164062 6.328125 l -0.710938 2.128906 l -1.832031 1.835938 c -0.390625 0.390625 -0.390625 1.023437 0 1.414062 s 1.023437 0.390625 1.414062 0 l 2 -2 c 0.109375 -0.109375 0.191407 -0.242187 0.242188 -0.390625 l 0.542969 -1.628906 z m 0 0"/><g mask="url(#b)"><g clip-path="url(#c)" transform="matrix(1 0 0 1 -620 -120)"><path d="m 550 182 c -0.351562 0.003906 -0.695312 0.101562 -1 0.28125 v 3.4375 c 0.304688 0.179688 0.648438 0.277344 1 0.28125 c 1.105469 0 2 -0.894531 2 -2 s -0.894531 -2 -2 -2 z m 0 5 c -0.339844 0 -0.679688 0.058594 -1 0.175781 v 6.824219 h 4 v -4 c 0 -1.65625 -1.34375 -3 -3 -3 z m 0 0"/></g></g><g mask="url(#d)"><g clip-path="url(#e)" transform="matrix(1 0 0 1 -620 -120)"><path d="m 569 182 v 4 c 1.105469 0 2 -0.894531 2 -2 s -0.894531 -2 -2 -2 z m 0 5 v 7 h 3 v -4 c 0 -1.65625 -1.34375 -3 -3 -3 z m 0 0"/></g></g><g mask="url(#f)"><g clip-path="url(#g)" transform="matrix(1 0 0 1 -620 -120)"><path d="m 573 182.269531 v 3.449219 c 0.613281 -0.355469 0.996094 -1.007812 1 -1.71875 c 0 -0.714844 -0.382812 -1.375 -1 -1.730469 z m 0 4.90625 v 6.824219 h 2 v -4 c 0 -1.269531 -0.800781 -2.402344 -2 -2.824219 z m 0 0"/></g></g></svg>
|
After Width: | Height: | Size: 3.0 KiB |
|
@ -0,0 +1,85 @@
|
|||
.welcome {
|
||||
margin: 64px;
|
||||
}
|
||||
|
||||
.welcome__title {
|
||||
font-size: x-large;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.welcome__content {
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.historical {
|
||||
margin: 32px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.date-range-picker {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
/*
|
||||
.date-range-picker > box:not(:last-child) {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
*/
|
||||
|
||||
.date-range-picker__date-field {
|
||||
margin: 8px;
|
||||
}
|
||||
|
||||
.date-range-picker__search-button {
|
||||
margin: 8px;
|
||||
}
|
||||
|
||||
.date-range-picker__range-button {
|
||||
margin: 8px;
|
||||
}
|
||||
|
||||
.date-field__year {
|
||||
margin: 0px 4px 0px 0px;
|
||||
}
|
||||
|
||||
.date-field__month {
|
||||
margin: 0px 4px 0px 4px;
|
||||
}
|
||||
|
||||
.date-field__day {
|
||||
margin: 0px 0px 0px 4px;
|
||||
}
|
||||
|
||||
.day-summary {
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.day-summary > *:not(:last-child) {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.day-summary__date {
|
||||
font-size: x-large;
|
||||
}
|
||||
|
||||
.day-summary__weight {
|
||||
margin: 4px;
|
||||
}
|
||||
|
||||
.weight-view {
|
||||
padding: 8px;
|
||||
margin: 8px;
|
||||
}
|
||||
|
||||
.step-view {
|
||||
padding: 8px;
|
||||
margin: 8px;
|
||||
}
|
||||
|
||||
.about__content {
|
||||
padding: 32px;
|
||||
}
|
||||
|
||||
.about label {
|
||||
margin-bottom: 16px;
|
||||
}
|
|
@ -0,0 +1,2 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" height="16px" viewBox="0 0 16 16" width="16px"><filter id="a" height="100%" width="100%" x="0%" y="0%"><feColorMatrix color-interpolation-filters="sRGB" values="0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 1 0"/></filter><mask id="b"><g filter="url(#a)"><path d="m -1.6 -1.6 h 19.2 v 19.2 h -19.2 z" fill-opacity="0.5"/></g></mask><clipPath id="c"><path d="m 0 0 h 1600 v 1200 h -1600 z"/></clipPath><mask id="d"><g filter="url(#a)"><path d="m -1.6 -1.6 h 19.2 v 19.2 h -19.2 z" fill-opacity="0.7"/></g></mask><clipPath id="e"><path d="m 0 0 h 1600 v 1200 h -1600 z"/></clipPath><mask id="f"><g filter="url(#a)"><path d="m -1.6 -1.6 h 19.2 v 19.2 h -19.2 z" fill-opacity="0.35"/></g></mask><clipPath id="g"><path d="m 0 0 h 1600 v 1200 h -1600 z"/></clipPath><path d="m 9.5 1.5 c 0 0.828125 -0.671875 1.5 -1.5 1.5 s -1.5 -0.671875 -1.5 -1.5 s 0.671875 -1.5 1.5 -1.5 s 1.5 0.671875 1.5 1.5 z m 0 0"/><path d="m 7 4 c -0.550781 0 -1 0.449219 -1 1 v 4 c 0 0.265625 0.105469 0.519531 0.292969 0.707031 l 0.445312 0.449219 l -2.59375 4.328125 c -0.285156 0.476563 -0.132812 1.089844 0.34375 1.375 c 0.472657 0.28125 1.085938 0.128906 1.367188 -0.34375 l 2.34375 -3.902344 l 0.925781 0.929688 l 0.925781 2.773437 c 0.082031 0.25 0.265625 0.460938 0.5 0.578125 c 0.238281 0.121094 0.515625 0.140625 0.765625 0.054688 c 0.25 -0.082031 0.460938 -0.265625 0.578125 -0.5 c 0.121094 -0.238281 0.140625 -0.515625 0.054688 -0.765625 l -1 -3 c -0.050781 -0.148438 -0.132813 -0.28125 -0.242188 -0.390625 l -1.707031 -1.707031 v -4.585938 c 0 -0.550781 -0.449219 -1 -1 -1 z m 0 0"/><path d="m 6 4 c -0.101562 0 -0.207031 0.019531 -0.300781 0.0625 c 0 0 -2.113281 0.847656 -2.199219 2.90625 v 0.03125 v 2.25 c 0 0.414062 0.335938 0.75 0.75 0.75 s 0.75 -0.335938 0.75 -0.75 v -2.21875 c 0.039062 -0.894531 1.050781 -1.449219 1.207031 -1.53125 h 2.332031 l 1.042969 2.085938 c 0.097657 0.195312 0.273438 0.339843 0.488281 0.394531 l 2 0.5 c 0.191407 0.046875 0.394532 0.015625 0.566407 -0.085938 c 0.171875 -0.101562 0.292969 -0.269531 0.34375 -0.460937 c 0.046875 -0.195313 0.015625 -0.398438 -0.085938 -0.570313 c -0.101562 -0.171875 -0.269531 -0.292969 -0.464843 -0.34375 l -1.664063 -0.414062 l -1.097656 -2.191407 c -0.125 -0.253906 -0.382813 -0.414062 -0.667969 -0.414062 z m 0 0"/><g mask="url(#b)"><g clip-path="url(#c)" transform="matrix(1 0 0 1 -620 -100)"><path d="m 550 182 c -0.351562 0.003906 -0.695312 0.101562 -1 0.28125 v 3.4375 c 0.304688 0.179688 0.648438 0.277344 1 0.28125 c 1.105469 0 2 -0.894531 2 -2 s -0.894531 -2 -2 -2 z m 0 5 c -0.339844 0 -0.679688 0.058594 -1 0.175781 v 6.824219 h 4 v -4 c 0 -1.65625 -1.34375 -3 -3 -3 z m 0 0"/></g></g><g mask="url(#d)"><g clip-path="url(#e)" transform="matrix(1 0 0 1 -620 -100)"><path d="m 569 182 v 4 c 1.105469 0 2 -0.894531 2 -2 s -0.894531 -2 -2 -2 z m 0 5 v 7 h 3 v -4 c 0 -1.65625 -1.34375 -3 -3 -3 z m 0 0"/></g></g><g mask="url(#f)"><g clip-path="url(#g)" transform="matrix(1 0 0 1 -620 -100)"><path d="m 573 182.269531 v 3.449219 c 0.613281 -0.355469 0.996094 -1.007812 1 -1.71875 c 0 -0.714844 -0.382812 -1.375 -1 -1.730469 z m 0 4.90625 v 6.824219 h 2 v -4 c 0 -1.269531 -0.800781 -2.402344 -2 -2.824219 z m 0 0"/></g></g></svg>
|
After Width: | Height: | Size: 3.2 KiB |
|
@ -0,0 +1,80 @@
|
|||
/*
|
||||
Copyright 2023 - 2024, Savanni D'Gerinel <savanni@luminescent-dreams.com>
|
||||
|
||||
This file is part of FitnessTrax.
|
||||
|
||||
FitnessTrax is free software: you can redistribute it and/or modify it under the terms of the GNU
|
||||
General Public License as published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
FitnessTrax is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without
|
||||
even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
|
||||
General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License along with FitnessTrax. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
use glib::Object;
|
||||
use gtk::{prelude::*, subclass::prelude::*};
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct AboutWindowPrivate {}
|
||||
|
||||
#[glib::object_subclass]
|
||||
impl ObjectSubclass for AboutWindowPrivate {
|
||||
const NAME: &'static str = "AboutWindow";
|
||||
type Type = AboutWindow;
|
||||
type ParentType = gtk::Window;
|
||||
}
|
||||
|
||||
impl ObjectImpl for AboutWindowPrivate {}
|
||||
impl WidgetImpl for AboutWindowPrivate {}
|
||||
impl WindowImpl for AboutWindowPrivate {}
|
||||
|
||||
glib::wrapper! {
|
||||
pub struct AboutWindow(ObjectSubclass<AboutWindowPrivate>) @extends gtk::Window, gtk::Widget;
|
||||
}
|
||||
|
||||
impl Default for AboutWindow {
|
||||
fn default() -> Self {
|
||||
let s: Self = Object::builder().build();
|
||||
s.set_width_request(600);
|
||||
s.set_height_request(700);
|
||||
s.add_css_class("about");
|
||||
|
||||
s.set_title(Some("About Fitnesstrax"));
|
||||
let copyright = gtk::Label::builder()
|
||||
.label("Copyright 2023-2024, Savanni D'Gerinel <savanni@luminescent-dreams.com>")
|
||||
.halign(gtk::Align::Start)
|
||||
.build();
|
||||
|
||||
let gtk_rs_thanks = gtk::Label::builder()
|
||||
.label("I owe a huge debt of gratitude to the GTK-RS project (https://gtk-rs.org/), which makes it possible for me to write this application to begin with. Further, I owe a particular debt to Julian Hofer and his book, GUI development with Rust and GTK 4 (https://gtk-rs.org/gtk4-rs/stable/latest/book/). Without this book, I would have continued to stumble around writing bad user interfaces with even worse code.")
|
||||
.halign(gtk::Align::Start).wrap(true)
|
||||
.build();
|
||||
|
||||
let dependencies = gtk::Label::builder()
|
||||
.label("This application depends on many libraries, most of which are licensed under the BSD-3 or GPL-3 licenses.")
|
||||
.halign(gtk::Align::Start).wrap(true)
|
||||
.build();
|
||||
|
||||
let content = gtk::Box::builder()
|
||||
.orientation(gtk::Orientation::Vertical)
|
||||
.css_classes(["about__content"])
|
||||
.build();
|
||||
content.append(©right);
|
||||
content.append(>k_rs_thanks);
|
||||
content.append(&dependencies);
|
||||
|
||||
let scroller = gtk::ScrolledWindow::builder()
|
||||
.child(&content)
|
||||
.hexpand(true)
|
||||
.vexpand(true)
|
||||
.hscrollbar_policy(gtk::PolicyType::Never)
|
||||
.build();
|
||||
|
||||
s.set_child(Some(&scroller));
|
||||
|
||||
s
|
||||
}
|
||||
}
|
|
@ -0,0 +1,167 @@
|
|||
/*
|
||||
Copyright 2023, Savanni D'Gerinel <savanni@luminescent-dreams.com>
|
||||
|
||||
This file is part of FitnessTrax.
|
||||
|
||||
FitnessTrax is free software: you can redistribute it and/or modify it under the terms of the GNU
|
||||
General Public License as published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
FitnessTrax is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without
|
||||
even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
|
||||
General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License along with FitnessTrax. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
use async_trait::async_trait;
|
||||
use chrono::NaiveDate;
|
||||
use emseries::{time_range, Record, RecordId, Series, Timestamp};
|
||||
use ft_core::TraxRecord;
|
||||
use std::{
|
||||
path::PathBuf,
|
||||
sync::{Arc, RwLock},
|
||||
};
|
||||
use thiserror::Error;
|
||||
use tokio::runtime::Runtime;
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum AppError {
|
||||
#[error("no database loaded")]
|
||||
NoDatabase,
|
||||
#[error("failed to open the database")]
|
||||
FailedToOpenDatabase,
|
||||
#[error("unhandled error")]
|
||||
Unhandled,
|
||||
}
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum ReadError {
|
||||
#[error("no database loaded")]
|
||||
NoDatabase,
|
||||
}
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum WriteError {
|
||||
#[error("no database loaded")]
|
||||
NoDatabase,
|
||||
#[error("unhandled error")]
|
||||
Unhandled,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait RecordProvider: Send + Sync {
|
||||
async fn records(
|
||||
&self,
|
||||
start: NaiveDate,
|
||||
end: NaiveDate,
|
||||
) -> Result<Vec<Record<TraxRecord>>, ReadError>;
|
||||
async fn put_record(&self, record: TraxRecord) -> Result<RecordId, WriteError>;
|
||||
async fn update_record(&self, record: Record<TraxRecord>) -> Result<(), WriteError>;
|
||||
async fn delete_record(&self, id: RecordId) -> Result<(), WriteError>;
|
||||
}
|
||||
|
||||
/// The real, headless application. This is where all of the logic will reside.
|
||||
#[derive(Clone)]
|
||||
pub struct App {
|
||||
runtime: Arc<Runtime>,
|
||||
database: Arc<RwLock<Option<Series<TraxRecord>>>>,
|
||||
}
|
||||
|
||||
impl App {
|
||||
pub fn new(db_path: Option<PathBuf>) -> Self {
|
||||
let database = db_path.map(|path| Series::open(path).unwrap());
|
||||
let runtime = Arc::new(
|
||||
tokio::runtime::Builder::new_multi_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
.unwrap(),
|
||||
);
|
||||
|
||||
Self {
|
||||
runtime,
|
||||
database: Arc::new(RwLock::new(database)),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn open_db(&self, path: PathBuf) -> Result<(), AppError> {
|
||||
let db_ref = self.database.clone();
|
||||
self.runtime
|
||||
.spawn_blocking(move || {
|
||||
let db = Series::open(path).map_err(|_| AppError::FailedToOpenDatabase)?;
|
||||
*db_ref.write().unwrap() = Some(db);
|
||||
Ok(())
|
||||
})
|
||||
.await
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
pub fn database_is_open(&self) -> bool {
|
||||
self.database.read().unwrap().is_some()
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl RecordProvider for App {
|
||||
async fn records(
|
||||
&self,
|
||||
start: NaiveDate,
|
||||
end: NaiveDate,
|
||||
) -> Result<Vec<Record<TraxRecord>>, ReadError> {
|
||||
let db = self.database.clone();
|
||||
self.runtime
|
||||
.spawn_blocking(move || {
|
||||
if let Some(ref db) = *db.read().unwrap() {
|
||||
let records = db
|
||||
.search(time_range(
|
||||
Timestamp::Date(start),
|
||||
true,
|
||||
Timestamp::Date(end),
|
||||
true,
|
||||
))
|
||||
.cloned()
|
||||
.collect::<Vec<Record<TraxRecord>>>();
|
||||
Ok(records)
|
||||
} else {
|
||||
Err(ReadError::NoDatabase)
|
||||
}
|
||||
})
|
||||
.await
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
async fn put_record(&self, record: TraxRecord) -> Result<RecordId, WriteError> {
|
||||
let db = self.database.clone();
|
||||
self.runtime
|
||||
.spawn_blocking(move || {
|
||||
if let Some(ref mut db) = *db.write().unwrap() {
|
||||
let id = db.put(record).unwrap();
|
||||
Ok(id)
|
||||
} else {
|
||||
Err(AppError::NoDatabase)
|
||||
}
|
||||
})
|
||||
.await
|
||||
.unwrap()
|
||||
.map_err(|_| WriteError::Unhandled)
|
||||
}
|
||||
|
||||
async fn update_record(&self, record: Record<TraxRecord>) -> Result<(), WriteError> {
|
||||
let db = self.database.clone();
|
||||
self.runtime
|
||||
.spawn_blocking(move || {
|
||||
if let Some(ref mut db) = *db.write().unwrap() {
|
||||
db.update(record).map_err(|_| AppError::Unhandled)
|
||||
} else {
|
||||
Err(AppError::NoDatabase)
|
||||
}
|
||||
})
|
||||
.await
|
||||
.unwrap()
|
||||
.map_err(|_| WriteError::Unhandled)
|
||||
}
|
||||
|
||||
async fn delete_record(&self, _id: RecordId) -> Result<(), WriteError> {
|
||||
unimplemented!()
|
||||
}
|
||||
}
|
|
@ -0,0 +1,215 @@
|
|||
/*
|
||||
Copyright 2023-2024, Savanni D'Gerinel <savanni@luminescent-dreams.com>
|
||||
|
||||
This file is part of FitnessTrax.
|
||||
|
||||
FitnessTrax is free software: you can redistribute it and/or modify it under the terms of the GNU
|
||||
General Public License as published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
FitnessTrax is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without
|
||||
even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
|
||||
General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License along with FitnessTrax. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
use crate::{
|
||||
app::App,
|
||||
types::DayInterval,
|
||||
view_models::DayDetailViewModel,
|
||||
views::{DayDetailView, HistoricalView, PlaceholderView, View, WelcomeView},
|
||||
};
|
||||
use adw::prelude::*;
|
||||
use chrono::{Duration, Local};
|
||||
|
||||
use gio::resources_lookup_data;
|
||||
use gtk::STYLE_PROVIDER_PRIORITY_USER;
|
||||
use std::{cell::RefCell, path::PathBuf, rc::Rc};
|
||||
|
||||
/// The application window, or the main window, is the main user interface for the app. Almost
|
||||
/// everything occurs here.
|
||||
#[derive(Clone)]
|
||||
pub struct AppWindow {
|
||||
app: App,
|
||||
layout: gtk::Box,
|
||||
current_view: Rc<RefCell<View>>,
|
||||
settings: gio::Settings,
|
||||
navigation: adw::NavigationView,
|
||||
}
|
||||
|
||||
impl AppWindow {
|
||||
/// Construct a new App Window.
|
||||
///
|
||||
/// adw_app is an Adwaita application. Application windows need to have access to this, but
|
||||
/// otherwise we don't use this.
|
||||
///
|
||||
/// app is a core [crate::app::App] object which encapsulates all of the basic logic.
|
||||
pub fn new(
|
||||
app_id: &str,
|
||||
resource_path: &str,
|
||||
adw_app: &adw::Application,
|
||||
ft_app: App,
|
||||
) -> AppWindow {
|
||||
let window = adw::ApplicationWindow::builder()
|
||||
.application(adw_app)
|
||||
.width_request(800)
|
||||
.height_request(746)
|
||||
.build();
|
||||
window.connect_destroy(|s| {
|
||||
let _ = gtk::prelude::WidgetExt::activate_action(s, "app.quit", None);
|
||||
});
|
||||
|
||||
let stylesheet = String::from_utf8(
|
||||
resources_lookup_data(
|
||||
&format!("{}style.css", resource_path),
|
||||
gio::ResourceLookupFlags::NONE,
|
||||
)
|
||||
.expect("stylesheet must be available in the resources")
|
||||
.to_vec(),
|
||||
)
|
||||
.expect("to parse stylesheet");
|
||||
|
||||
let provider = gtk::CssProvider::new();
|
||||
provider.load_from_data(&stylesheet);
|
||||
|
||||
#[allow(deprecated)]
|
||||
let context = window.style_context();
|
||||
#[allow(deprecated)]
|
||||
context.add_provider(&provider, STYLE_PROVIDER_PRIORITY_USER);
|
||||
|
||||
let navigation = adw::NavigationView::new();
|
||||
|
||||
let layout = gtk::Box::builder()
|
||||
.orientation(gtk::Orientation::Vertical)
|
||||
.build();
|
||||
|
||||
let initial_view = View::Placeholder(PlaceholderView::default().upcast());
|
||||
|
||||
let header_bar = adw::HeaderBar::new();
|
||||
|
||||
let main_menu = gio::Menu::new();
|
||||
main_menu.append(Some("About"), Some("app.about"));
|
||||
main_menu.append(Some("Quit"), Some("app.quit"));
|
||||
let main_menu_button = gtk::MenuButton::builder()
|
||||
.icon_name("open-menu")
|
||||
.direction(gtk::ArrowType::Down)
|
||||
.halign(gtk::Align::End)
|
||||
.menu_model(&main_menu)
|
||||
.build();
|
||||
header_bar.pack_end(&main_menu_button);
|
||||
|
||||
layout.append(&initial_view.widget());
|
||||
|
||||
let nav_layout = gtk::Box::new(gtk::Orientation::Vertical, 0);
|
||||
nav_layout.append(&header_bar);
|
||||
nav_layout.append(&layout);
|
||||
navigation.push(
|
||||
&adw::NavigationPage::builder()
|
||||
.can_pop(false)
|
||||
.title("FitnessTrax")
|
||||
.child(&nav_layout)
|
||||
.build(),
|
||||
);
|
||||
|
||||
window.set_content(Some(&navigation));
|
||||
window.present();
|
||||
|
||||
let s = Self {
|
||||
app: ft_app,
|
||||
layout,
|
||||
current_view: Rc::new(RefCell::new(initial_view)),
|
||||
settings: gio::Settings::new(app_id),
|
||||
navigation,
|
||||
};
|
||||
|
||||
s.load_records();
|
||||
|
||||
s.navigation.connect_popped({
|
||||
let s = s.clone();
|
||||
move |_, _| {
|
||||
if let View::Historical(_) = *s.current_view.borrow() {
|
||||
s.load_records();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
s
|
||||
}
|
||||
|
||||
fn show_welcome_view(&self) {
|
||||
let view = View::Welcome(WelcomeView::new({
|
||||
let s = self.clone();
|
||||
move |path| s.on_apply_config(path)
|
||||
}));
|
||||
self.swap_main(view);
|
||||
}
|
||||
|
||||
fn show_historical_view(&self, interval: DayInterval) {
|
||||
let on_select_day = {
|
||||
let s = self.clone();
|
||||
move |date| {
|
||||
let s = s.clone();
|
||||
glib::spawn_future_local(async move {
|
||||
let view_model = DayDetailViewModel::new(date, s.app.clone()).await.unwrap();
|
||||
let layout = gtk::Box::new(gtk::Orientation::Vertical, 0);
|
||||
layout.append(&adw::HeaderBar::new());
|
||||
// layout.append(&DayDetailView::new(date, records, s.app.clone()));
|
||||
layout.append(&DayDetailView::new(view_model));
|
||||
let page = &adw::NavigationPage::builder()
|
||||
.title(date.format("%Y-%m-%d").to_string())
|
||||
.child(&layout)
|
||||
.build();
|
||||
s.navigation.push(page);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
let view = View::Historical(HistoricalView::new(
|
||||
self.app.clone(),
|
||||
interval,
|
||||
Rc::new(on_select_day),
|
||||
));
|
||||
self.swap_main(view);
|
||||
}
|
||||
|
||||
fn load_records(&self) {
|
||||
glib::spawn_future_local({
|
||||
let s = self.clone();
|
||||
async move {
|
||||
if s.app.database_is_open() {
|
||||
let end = Local::now().date_naive();
|
||||
let start = end - Duration::days(7);
|
||||
s.show_historical_view(DayInterval { start, end });
|
||||
} else {
|
||||
s.show_welcome_view();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Switch views.
|
||||
//
|
||||
// This function only replaces the old view with the one which matches the current view state.
|
||||
// It is responsible for ensuring that the new view goes into the layout in the correct
|
||||
// position.
|
||||
fn swap_main(&self, view: View) {
|
||||
let mut current_widget = self.current_view.borrow_mut();
|
||||
self.layout.remove(¤t_widget.widget());
|
||||
*current_widget = view;
|
||||
self.layout.append(¤t_widget.widget());
|
||||
}
|
||||
|
||||
#[allow(unused)]
|
||||
fn on_apply_config(&self, path: PathBuf) {
|
||||
glib::spawn_future_local({
|
||||
let s = self.clone();
|
||||
async move {
|
||||
if s.app.open_db(path.clone()).await.is_ok() {
|
||||
let _ = s.settings.set("series-path", path.to_str().unwrap());
|
||||
s.load_records();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
|
@ -0,0 +1,137 @@
|
|||
/*
|
||||
Copyright 2024, Savanni D'Gerinel <savanni@luminescent-dreams.com>
|
||||
|
||||
This file is part of FitnessTrax.
|
||||
|
||||
FitnessTrax is free software: you can redistribute it and/or modify it under the terms of the GNU
|
||||
General Public License as published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
FitnessTrax is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without
|
||||
even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
|
||||
General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License along with FitnessTrax. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
//! ActionGroup and related structures
|
||||
|
||||
use glib::Object;
|
||||
use gtk::{prelude::*, subclass::prelude::*};
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct ActionGroupPrivate;
|
||||
|
||||
#[glib::object_subclass]
|
||||
impl ObjectSubclass for ActionGroupPrivate {
|
||||
const NAME: &'static str = "ActionGroup";
|
||||
type Type = ActionGroup;
|
||||
type ParentType = gtk::Box;
|
||||
}
|
||||
|
||||
impl ObjectImpl for ActionGroupPrivate {}
|
||||
impl WidgetImpl for ActionGroupPrivate {}
|
||||
impl BoxImpl for ActionGroupPrivate {}
|
||||
|
||||
glib::wrapper! {
|
||||
pub struct ActionGroup(ObjectSubclass<ActionGroupPrivate>) @extends gtk::Box, gtk::Widget, @implements gtk::Orientable;
|
||||
}
|
||||
|
||||
impl ActionGroup {
|
||||
fn new(builder: ActionGroupBuilder) -> Self {
|
||||
let s: Self = Object::builder().build();
|
||||
s.set_orientation(builder.orientation);
|
||||
|
||||
let primary_button = builder.primary_action.button();
|
||||
let secondary_button = builder.secondary_action.map(|action| action.button());
|
||||
let tertiary_button = builder.tertiary_action.map(|action| action.button());
|
||||
|
||||
if let Some(button) = tertiary_button {
|
||||
s.append(&button);
|
||||
}
|
||||
|
||||
s.set_halign(gtk::Align::End);
|
||||
if let Some(button) = secondary_button {
|
||||
s.append(&button);
|
||||
}
|
||||
s.append(&primary_button);
|
||||
|
||||
s
|
||||
}
|
||||
|
||||
pub fn builder() -> ActionGroupBuilder {
|
||||
ActionGroupBuilder {
|
||||
orientation: gtk::Orientation::Horizontal,
|
||||
primary_action: Action {
|
||||
label: "Ok".to_owned(),
|
||||
action: Box::new(|| {}),
|
||||
},
|
||||
secondary_action: None,
|
||||
tertiary_action: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct Action {
|
||||
label: String,
|
||||
action: Box<dyn Fn()>,
|
||||
}
|
||||
|
||||
impl Action {
|
||||
fn button(self) -> gtk::Button {
|
||||
let button = gtk::Button::builder().label(self.label).build();
|
||||
button.connect_clicked(move |_| (self.action)());
|
||||
button
|
||||
}
|
||||
}
|
||||
|
||||
pub struct ActionGroupBuilder {
|
||||
orientation: gtk::Orientation,
|
||||
primary_action: Action,
|
||||
secondary_action: Option<Action>,
|
||||
tertiary_action: Option<Action>,
|
||||
}
|
||||
|
||||
impl ActionGroupBuilder {
|
||||
pub fn orientation(mut self, orientation: gtk::Orientation) -> Self {
|
||||
self.orientation = orientation;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn primary_action<A>(mut self, label: &str, action: A) -> Self
|
||||
where
|
||||
A: Fn() + 'static,
|
||||
{
|
||||
self.primary_action = Action {
|
||||
label: label.to_owned(),
|
||||
action: Box::new(action),
|
||||
};
|
||||
self
|
||||
}
|
||||
|
||||
pub fn secondary_action<A>(mut self, label: &str, action: A) -> Self
|
||||
where
|
||||
A: Fn() + 'static,
|
||||
{
|
||||
self.secondary_action = Some(Action {
|
||||
label: label.to_owned(),
|
||||
action: Box::new(action),
|
||||
});
|
||||
self
|
||||
}
|
||||
|
||||
pub fn tertiary_action<A>(mut self, label: &str, action: A) -> Self
|
||||
where
|
||||
A: Fn() + 'static,
|
||||
{
|
||||
self.tertiary_action = Some(Action {
|
||||
label: label.to_owned(),
|
||||
action: Box::new(action),
|
||||
});
|
||||
self
|
||||
}
|
||||
|
||||
pub fn build(self) -> ActionGroup {
|
||||
ActionGroup::new(self)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,175 @@
|
|||
/*
|
||||
Copyright 2024, Savanni D'Gerinel <savanni@luminescent-dreams.com>
|
||||
|
||||
This file is part of FitnessTrax.
|
||||
|
||||
FitnessTrax is free software: you can redistribute it and/or modify it under the terms of the GNU
|
||||
General Public License as published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
FitnessTrax is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without
|
||||
even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
|
||||
General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License along with FitnessTrax. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
use crate::{components::{i32_field_builder, TextEntry, month_field_builder}, types::ParseError};
|
||||
use chrono::{Datelike, Local};
|
||||
use glib::Object;
|
||||
use gtk::{prelude::*, subclass::prelude::*};
|
||||
use std::{cell::RefCell, rc::Rc};
|
||||
|
||||
pub struct DateFieldPrivate {
|
||||
date: Rc<RefCell<chrono::NaiveDate>>,
|
||||
year: TextEntry<i32>,
|
||||
month: TextEntry<u32>,
|
||||
day: TextEntry<u32>,
|
||||
}
|
||||
|
||||
#[glib::object_subclass]
|
||||
impl ObjectSubclass for DateFieldPrivate {
|
||||
const NAME: &'static str = "DateField";
|
||||
type Type = DateField;
|
||||
type ParentType = gtk::Box;
|
||||
|
||||
fn new() -> Self {
|
||||
let date = Rc::new(RefCell::new(Local::now().date_naive()));
|
||||
|
||||
let year = i32_field_builder()
|
||||
.with_value(date.borrow().year())
|
||||
.with_on_update(
|
||||
{
|
||||
let date = date.clone();
|
||||
move |value| {
|
||||
if let Some(year) = value {
|
||||
let mut date = date.borrow_mut();
|
||||
if let Some(new_date) = date.with_year(year) {
|
||||
*date = new_date;
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
.with_length(4)
|
||||
.with_css_classes(vec!["date-field__year".to_owned()]).build();
|
||||
|
||||
let month = month_field_builder()
|
||||
.with_value(date.borrow().month())
|
||||
.with_on_update(
|
||||
{
|
||||
let date = date.clone();
|
||||
move |value| {
|
||||
if let Some(month) = value {
|
||||
let mut date = date.borrow_mut();
|
||||
if let Some(new_date) = date.with_month(month) {
|
||||
*date = new_date;
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
.with_css_classes(vec!["date-field__month".to_owned()])
|
||||
.build();
|
||||
|
||||
/* Modify this so that it enforces the number of days per month */
|
||||
let day = TextEntry::builder()
|
||||
.with_placeholder("day".to_owned())
|
||||
.with_value(date.borrow().day())
|
||||
.with_renderer(|v| format!("{}", v))
|
||||
.with_parser(|v| v.parse::<u32>().map_err(|_| ParseError))
|
||||
.with_on_update({
|
||||
let date = date.clone();
|
||||
move |value| {
|
||||
if let Some(day) = value {
|
||||
let mut date = date.borrow_mut();
|
||||
if let Some(new_date) = date.with_day(day) {
|
||||
*date = new_date;
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
.with_css_classes(vec!["date-field__day".to_owned()])
|
||||
.build();
|
||||
|
||||
Self {
|
||||
date,
|
||||
year,
|
||||
month,
|
||||
day,
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
impl ObjectImpl for DateFieldPrivate {}
|
||||
impl WidgetImpl for DateFieldPrivate {}
|
||||
impl BoxImpl for DateFieldPrivate {}
|
||||
|
||||
glib::wrapper! {
|
||||
pub struct DateField(ObjectSubclass<DateFieldPrivate>) @extends gtk::Box, gtk::Widget;
|
||||
}
|
||||
|
||||
/* Render a date in the format 2006 Jan 01. The entire date is editable. When the user moves to one part of the date, it will be erased and replaced with a grey placeholder.
|
||||
*/
|
||||
impl DateField {
|
||||
pub fn new(date: chrono::NaiveDate) -> Self {
|
||||
let s: Self = Object::builder().build();
|
||||
s.add_css_class("date-field");
|
||||
|
||||
s.append(&s.imp().year.widget());
|
||||
s.append(>k::Label::new(Some("-")));
|
||||
s.append(&s.imp().month.widget());
|
||||
s.append(>k::Label::new(Some("-")));
|
||||
s.append(&s.imp().day.widget());
|
||||
|
||||
s.set_date(date);
|
||||
|
||||
s
|
||||
}
|
||||
|
||||
pub fn set_date(&self, date: chrono::NaiveDate) {
|
||||
self.imp().year.set_value(Some(date.year()));
|
||||
self.imp().month.set_value(Some(date.month()));
|
||||
self.imp().day.set_value(Some(date.day()));
|
||||
|
||||
*self.imp().date.borrow_mut() = date;
|
||||
}
|
||||
|
||||
pub fn date(&self) -> chrono::NaiveDate {
|
||||
*self.imp().date.borrow()
|
||||
}
|
||||
/*
|
||||
pub fn is_valid(&self) -> bool {
|
||||
false
|
||||
}
|
||||
*/
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod test {
|
||||
use super::*;
|
||||
// use crate::gtk_init::gtk_init;
|
||||
|
||||
// Enabling this test pushes tests on the TextField into an infinite loop. That likely indicates a bad interaction within the TextField itself, and that is going to need to be fixed.
|
||||
#[test]
|
||||
#[ignore]
|
||||
fn it_allows_valid_dates() {
|
||||
let reference = chrono::NaiveDate::from_ymd_opt(2006, 01, 02).unwrap();
|
||||
let field = DateField::new(reference);
|
||||
field.imp().year.set_value(Some(2023));
|
||||
field.imp().month.set_value(Some(10));
|
||||
field.imp().day.set_value(Some(13));
|
||||
// assert!(field.is_valid());
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore]
|
||||
fn it_disallows_out_of_range_months() {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore]
|
||||
fn it_allows_days_within_range_for_month() {
|
||||
unimplemented!()
|
||||
}
|
||||
}
|
|
@ -0,0 +1,189 @@
|
|||
/*
|
||||
Copyright 2024, Savanni D'Gerinel <savanni@luminescent-dreams.com>
|
||||
|
||||
This file is part of FitnessTrax.
|
||||
|
||||
FitnessTrax is free software: you can redistribute it and/or modify it under the terms of the GNU
|
||||
General Public License as published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
FitnessTrax is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without
|
||||
even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
|
||||
General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License along with FitnessTrax. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
use crate::{components::DateField, types::DayInterval};
|
||||
use chrono::{Duration, Local, Months};
|
||||
use glib::Object;
|
||||
use gtk::{prelude::*, subclass::prelude::*};
|
||||
use std::cell::RefCell;
|
||||
|
||||
type OnSearch = dyn Fn(DayInterval) + 'static;
|
||||
|
||||
pub struct DateRangePickerPrivate {
|
||||
start: DateField,
|
||||
end: DateField,
|
||||
|
||||
on_search: RefCell<Box<OnSearch>>,
|
||||
}
|
||||
|
||||
#[glib::object_subclass]
|
||||
impl ObjectSubclass for DateRangePickerPrivate {
|
||||
const NAME: &'static str = "DateRangePicker";
|
||||
type Type = DateRangePicker;
|
||||
type ParentType = gtk::Box;
|
||||
|
||||
fn new() -> Self {
|
||||
let default_date = Local::now().date_naive();
|
||||
let start = DateField::new(default_date);
|
||||
start.add_css_class("date-range-picker__date-field");
|
||||
let end = DateField::new(default_date);
|
||||
end.add_css_class("date-range-picker__date-field");
|
||||
|
||||
Self {
|
||||
start,
|
||||
end,
|
||||
on_search: RefCell::new(Box::new(|_| {})),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ObjectImpl for DateRangePickerPrivate {}
|
||||
impl WidgetImpl for DateRangePickerPrivate {}
|
||||
impl BoxImpl for DateRangePickerPrivate {}
|
||||
|
||||
glib::wrapper! {
|
||||
pub struct DateRangePicker(ObjectSubclass<DateRangePickerPrivate>) @extends gtk::Box, gtk::Widget, @implements gtk::Orientable;
|
||||
}
|
||||
|
||||
impl DateRangePicker {
|
||||
pub fn connect_on_search<OnSearch>(&self, f: OnSearch)
|
||||
where
|
||||
OnSearch: Fn(DayInterval) + 'static,
|
||||
{
|
||||
*self.imp().on_search.borrow_mut() = Box::new(f);
|
||||
}
|
||||
|
||||
pub fn set_interval(&self, start: chrono::NaiveDate, end: chrono::NaiveDate) {
|
||||
self.imp().start.set_date(start);
|
||||
self.imp().end.set_date(end);
|
||||
}
|
||||
|
||||
pub fn interval(&self) -> DayInterval {
|
||||
DayInterval {
|
||||
start: self.imp().start.date(),
|
||||
end: self.imp().end.date(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for DateRangePicker {
|
||||
fn default() -> Self {
|
||||
let s: Self = Object::builder().build();
|
||||
s.set_orientation(gtk::Orientation::Vertical);
|
||||
s.add_css_class("date-range-picker");
|
||||
|
||||
let search_button = gtk::Button::builder()
|
||||
.css_classes(["date-range-picker__search-button"])
|
||||
.label("Search")
|
||||
.build();
|
||||
search_button.connect_clicked({
|
||||
let s = s.clone();
|
||||
move |_| (s.imp().on_search.borrow())(s.interval())
|
||||
});
|
||||
|
||||
let last_week_button = gtk::Button::builder()
|
||||
.css_classes(["date-range-picker__range-button"])
|
||||
.label("week")
|
||||
.build();
|
||||
last_week_button.connect_clicked({
|
||||
let s = s.clone();
|
||||
move |_| {
|
||||
let end = Local::now().date_naive();
|
||||
let start = end - Duration::days(7);
|
||||
s.set_interval(start, end);
|
||||
(s.imp().on_search.borrow())(s.interval());
|
||||
}
|
||||
});
|
||||
|
||||
let two_weeks_button = gtk::Button::builder()
|
||||
.css_classes(["date-range-picker__range-button"])
|
||||
.label("two weeks")
|
||||
.build();
|
||||
two_weeks_button.connect_clicked({
|
||||
let s = s.clone();
|
||||
move |_| {
|
||||
let end = Local::now().date_naive();
|
||||
let start = end - Duration::days(14);
|
||||
s.set_interval(start, end);
|
||||
(s.imp().on_search.borrow())(s.interval());
|
||||
}
|
||||
});
|
||||
|
||||
let last_month_button = gtk::Button::builder()
|
||||
.css_classes(["date-range-picker__range-button"])
|
||||
.label("month")
|
||||
.build();
|
||||
last_month_button.connect_clicked({
|
||||
let s = s.clone();
|
||||
move |_| {
|
||||
let end = Local::now().date_naive();
|
||||
let start = end - Months::new(1);
|
||||
s.set_interval(start, end);
|
||||
(s.imp().on_search.borrow())(s.interval());
|
||||
}
|
||||
});
|
||||
|
||||
let six_months_button = gtk::Button::builder()
|
||||
.css_classes(["date-range-picker__range-button"])
|
||||
.label("six months")
|
||||
.build();
|
||||
six_months_button.connect_clicked({
|
||||
let s = s.clone();
|
||||
move |_| {
|
||||
let end = Local::now().date_naive();
|
||||
let start = end - Months::new(6);
|
||||
s.set_interval(start, end);
|
||||
(s.imp().on_search.borrow())(s.interval());
|
||||
}
|
||||
});
|
||||
|
||||
let last_year_button = gtk::Button::builder()
|
||||
.css_classes(["date-range-picker__range-button"])
|
||||
.label("year")
|
||||
.build();
|
||||
last_year_button.connect_clicked({
|
||||
let s = s.clone();
|
||||
move |_| {
|
||||
let end = Local::now().date_naive();
|
||||
let start = end - Months::new(12);
|
||||
s.set_interval(start, end);
|
||||
(s.imp().on_search.borrow())(s.interval());
|
||||
}
|
||||
});
|
||||
|
||||
let date_row = gtk::Box::builder()
|
||||
.orientation(gtk::Orientation::Horizontal)
|
||||
.build();
|
||||
date_row.append(&s.imp().start);
|
||||
date_row.append(>k::Label::new(Some("to")));
|
||||
date_row.append(&s.imp().end);
|
||||
date_row.append(&search_button);
|
||||
|
||||
let quick_picker = gtk::Box::builder()
|
||||
.orientation(gtk::Orientation::Horizontal)
|
||||
.build();
|
||||
quick_picker.append(&last_week_button);
|
||||
quick_picker.append(&two_weeks_button);
|
||||
quick_picker.append(&last_month_button);
|
||||
quick_picker.append(&six_months_button);
|
||||
quick_picker.append(&last_year_button);
|
||||
|
||||
s.append(&date_row);
|
||||
s.append(&quick_picker);
|
||||
|
||||
s
|
||||
}
|
||||
}
|
|
@ -0,0 +1,400 @@
|
|||
/*
|
||||
Copyright 2023-2024, Savanni D'Gerinel <savanni@luminescent-dreams.com>
|
||||
|
||||
This file is part of FitnessTrax.
|
||||
|
||||
FitnessTrax is free software: you can redistribute it and/or modify it under the terms of the GNU
|
||||
General Public License as published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
FitnessTrax is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without
|
||||
even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
|
||||
General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License along with FitnessTrax. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
// use chrono::NaiveDate;
|
||||
// use ft_core::TraxRecord;
|
||||
use crate::{
|
||||
components::{
|
||||
steps_editor, time_distance_summary, weight_field, ActionGroup, Steps, WeightLabel,
|
||||
},
|
||||
types::{DistanceFormatter, DurationFormatter, WeightFormatter},
|
||||
view_models::DayDetailViewModel,
|
||||
};
|
||||
use emseries::{Record, RecordId};
|
||||
use ft_core::{TimeDistanceActivity, TraxRecord, TIME_DISTANCE_ACTIVITIES};
|
||||
use glib::Object;
|
||||
use gtk::{prelude::*, subclass::prelude::*};
|
||||
use std::{cell::RefCell, rc::Rc};
|
||||
|
||||
use super::{time_distance::TimeDistanceEdit, time_distance_detail};
|
||||
|
||||
pub struct DaySummaryPrivate {
|
||||
date: gtk::Label,
|
||||
}
|
||||
|
||||
#[glib::object_subclass]
|
||||
impl ObjectSubclass for DaySummaryPrivate {
|
||||
const NAME: &'static str = "DaySummary";
|
||||
type Type = DaySummary;
|
||||
type ParentType = gtk::Box;
|
||||
|
||||
fn new() -> Self {
|
||||
let date = gtk::Label::builder()
|
||||
.css_classes(["day-summary__date"])
|
||||
.halign(gtk::Align::Start)
|
||||
.build();
|
||||
Self { date }
|
||||
}
|
||||
}
|
||||
|
||||
impl ObjectImpl for DaySummaryPrivate {}
|
||||
impl WidgetImpl for DaySummaryPrivate {}
|
||||
impl BoxImpl for DaySummaryPrivate {}
|
||||
|
||||
glib::wrapper! {
|
||||
/// The DaySummary displays one day's activities in a narrative style. This is meant to give
|
||||
/// an overall feel of everything that happened during the day without going into details.
|
||||
pub struct DaySummary(ObjectSubclass<DaySummaryPrivate>) @extends gtk::Box, gtk::Widget, @implements gtk::Orientable;
|
||||
}
|
||||
|
||||
impl Default for DaySummary {
|
||||
fn default() -> Self {
|
||||
let s: Self = Object::builder().build();
|
||||
s.set_orientation(gtk::Orientation::Vertical);
|
||||
s.set_css_classes(&["day-summary"]);
|
||||
|
||||
s.append(&s.imp().date);
|
||||
|
||||
s
|
||||
}
|
||||
}
|
||||
|
||||
impl DaySummary {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
pub fn set_data(&self, view_model: DayDetailViewModel) {
|
||||
self.imp()
|
||||
.date
|
||||
.set_text(&view_model.date.format("%Y-%m-%d").to_string());
|
||||
|
||||
let row = gtk::Box::builder().build();
|
||||
|
||||
let weight_label = gtk::Label::builder()
|
||||
.halign(gtk::Align::Start)
|
||||
.css_classes(["day-summary__weight"])
|
||||
.build();
|
||||
if let Some(w) = view_model.weight() {
|
||||
weight_label.set_label(&w.to_string())
|
||||
}
|
||||
|
||||
let steps_label = gtk::Label::builder()
|
||||
.halign(gtk::Align::Start)
|
||||
.css_classes(["day-summary__steps"])
|
||||
.build();
|
||||
if let Some(s) = view_model.steps() {
|
||||
steps_label.set_label(&format!("{} steps", s));
|
||||
}
|
||||
|
||||
row.append(&weight_label);
|
||||
row.append(&steps_label);
|
||||
self.append(&row);
|
||||
|
||||
for activity in TIME_DISTANCE_ACTIVITIES {
|
||||
let summary = view_model.time_distance_summary(activity);
|
||||
if let Some(label) = time_distance_summary(
|
||||
activity,
|
||||
DistanceFormatter::from(summary.0),
|
||||
DurationFormatter::from(summary.1),
|
||||
) {
|
||||
self.append(&label);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct DayDetailPrivate {}
|
||||
|
||||
#[glib::object_subclass]
|
||||
impl ObjectSubclass for DayDetailPrivate {
|
||||
const NAME: &'static str = "DayDetail";
|
||||
type Type = DayDetail;
|
||||
type ParentType = gtk::Box;
|
||||
}
|
||||
|
||||
impl ObjectImpl for DayDetailPrivate {}
|
||||
impl WidgetImpl for DayDetailPrivate {}
|
||||
impl BoxImpl for DayDetailPrivate {}
|
||||
|
||||
glib::wrapper! {
|
||||
pub struct DayDetail(ObjectSubclass<DayDetailPrivate>) @extends gtk::Box, gtk::Widget, @implements gtk::Orientable;
|
||||
}
|
||||
|
||||
impl DayDetail {
|
||||
pub fn new<OnEdit>(view_model: DayDetailViewModel, on_edit: OnEdit) -> Self
|
||||
where
|
||||
OnEdit: Fn() + 'static,
|
||||
{
|
||||
let s: Self = Object::builder().build();
|
||||
s.set_orientation(gtk::Orientation::Vertical);
|
||||
s.set_hexpand(true);
|
||||
|
||||
s.append(
|
||||
&ActionGroup::builder()
|
||||
.primary_action("Edit", Box::new(on_edit))
|
||||
.build(),
|
||||
);
|
||||
|
||||
let top_row = gtk::Box::builder()
|
||||
.orientation(gtk::Orientation::Horizontal)
|
||||
.build();
|
||||
let weight_view = WeightLabel::new(view_model.weight().map(WeightFormatter::from));
|
||||
top_row.append(&weight_view.widget());
|
||||
|
||||
let steps_view = Steps::new(view_model.steps());
|
||||
top_row.append(&steps_view.widget());
|
||||
|
||||
s.append(&top_row);
|
||||
|
||||
let records = view_model.time_distance_records();
|
||||
for emseries::Record { data, .. } in records {
|
||||
s.append(&time_distance_detail(data));
|
||||
}
|
||||
|
||||
s
|
||||
}
|
||||
}
|
||||
|
||||
pub struct DayEditPrivate {
|
||||
on_finished: RefCell<Box<dyn Fn()>>,
|
||||
#[allow(unused)]
|
||||
workout_rows: RefCell<gtk::Box>,
|
||||
view_model: RefCell<Option<DayDetailViewModel>>,
|
||||
}
|
||||
|
||||
impl Default for DayEditPrivate {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
on_finished: RefCell::new(Box::new(|| {})),
|
||||
workout_rows: RefCell::new(
|
||||
gtk::Box::builder()
|
||||
.orientation(gtk::Orientation::Vertical)
|
||||
.hexpand(true)
|
||||
.build(),
|
||||
),
|
||||
view_model: RefCell::new(None),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[glib::object_subclass]
|
||||
impl ObjectSubclass for DayEditPrivate {
|
||||
const NAME: &'static str = "DayEdit";
|
||||
type Type = DayEdit;
|
||||
type ParentType = gtk::Box;
|
||||
}
|
||||
|
||||
impl ObjectImpl for DayEditPrivate {}
|
||||
impl WidgetImpl for DayEditPrivate {}
|
||||
impl BoxImpl for DayEditPrivate {}
|
||||
|
||||
glib::wrapper! {
|
||||
pub struct DayEdit(ObjectSubclass<DayEditPrivate>) @extends gtk::Box, gtk::Widget, @implements gtk::Orientable;
|
||||
}
|
||||
|
||||
impl DayEdit {
|
||||
pub fn new<OnFinished>(view_model: DayDetailViewModel, on_finished: OnFinished) -> Self
|
||||
where
|
||||
OnFinished: Fn() + 'static,
|
||||
{
|
||||
let s: Self = Object::builder().build();
|
||||
s.set_orientation(gtk::Orientation::Vertical);
|
||||
s.set_hexpand(true);
|
||||
*s.imp().on_finished.borrow_mut() = Box::new(on_finished);
|
||||
*s.imp().view_model.borrow_mut() = Some(view_model.clone());
|
||||
|
||||
let workout_buttons = workout_buttons(view_model.clone(), {
|
||||
let s = s.clone();
|
||||
move |workout| s.add_row(workout)
|
||||
});
|
||||
|
||||
view_model
|
||||
.records()
|
||||
.into_iter()
|
||||
.filter_map({
|
||||
let s = s.clone();
|
||||
move |record| match record.data {
|
||||
TraxRecord::TimeDistance(workout) => Some(TimeDistanceEdit::new(workout, {
|
||||
let s = s.clone();
|
||||
move |data| {
|
||||
s.update_workout(record.id, data);
|
||||
}
|
||||
})),
|
||||
_ => None,
|
||||
}
|
||||
})
|
||||
.for_each(|row| s.imp().workout_rows.borrow().append(&row));
|
||||
|
||||
s.append(&control_buttons(&s, &view_model));
|
||||
s.append(&weight_and_steps_row(&view_model));
|
||||
s.append(&*s.imp().workout_rows.borrow());
|
||||
s.append(&workout_buttons);
|
||||
|
||||
s
|
||||
}
|
||||
|
||||
fn finish(&self) {
|
||||
glib::spawn_future_local({
|
||||
let s = self.clone();
|
||||
async move {
|
||||
let view_model = {
|
||||
let view_model = s.imp().view_model.borrow();
|
||||
view_model
|
||||
.as_ref()
|
||||
.expect("DayEdit has not been initialized with the view model")
|
||||
.clone()
|
||||
};
|
||||
let _ = view_model.async_save().await;
|
||||
(s.imp().on_finished.borrow())()
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn add_row(&self, workout: Record<TraxRecord>) {
|
||||
let workout_rows = self.imp().workout_rows.borrow();
|
||||
|
||||
#[allow(clippy::single_match)]
|
||||
match workout.data {
|
||||
TraxRecord::TimeDistance(r) => workout_rows.append(&TimeDistanceEdit::new(r, {
|
||||
let s = self.clone();
|
||||
move |data| {
|
||||
println!("update workout callback on workout: {:?}", workout.id);
|
||||
s.update_workout(workout.id, data)
|
||||
}
|
||||
})),
|
||||
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn update_workout(&self, id: RecordId, data: ft_core::TimeDistance) {
|
||||
if let Some(ref view_model) = *self.imp().view_model.borrow() {
|
||||
let record = Record {
|
||||
id,
|
||||
data: TraxRecord::TimeDistance(data),
|
||||
};
|
||||
view_model.update_record(record);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn control_buttons(s: &DayEdit, view_model: &DayDetailViewModel) -> ActionGroup {
|
||||
ActionGroup::builder()
|
||||
.primary_action("Save", {
|
||||
let s = s.clone();
|
||||
move || s.finish()
|
||||
})
|
||||
.secondary_action("Cancel", {
|
||||
let s = s.clone();
|
||||
let view_model = view_model.clone();
|
||||
move || {
|
||||
let s = s.clone();
|
||||
let view_model = view_model.clone();
|
||||
glib::spawn_future_local(async move {
|
||||
view_model.revert().await;
|
||||
s.finish();
|
||||
});
|
||||
}
|
||||
})
|
||||
.build()
|
||||
}
|
||||
|
||||
fn weight_and_steps_row(view_model: &DayDetailViewModel) -> gtk::Box {
|
||||
let row = gtk::Box::builder()
|
||||
.orientation(gtk::Orientation::Horizontal)
|
||||
.build();
|
||||
row.append(
|
||||
&weight_field(view_model.weight().map(WeightFormatter::from), {
|
||||
let view_model = view_model.clone();
|
||||
move |w| match w {
|
||||
Some(w) => view_model.set_weight(*w),
|
||||
None => eprintln!("have not implemented record delete"),
|
||||
}
|
||||
})
|
||||
.widget(),
|
||||
);
|
||||
|
||||
row.append(
|
||||
&steps_editor(view_model.steps(), {
|
||||
let view_model = view_model.clone();
|
||||
move |s| match s {
|
||||
Some(s) => view_model.set_steps(s),
|
||||
None => eprintln!("have not implemented record delete"),
|
||||
}
|
||||
})
|
||||
.widget(),
|
||||
);
|
||||
|
||||
row
|
||||
}
|
||||
|
||||
fn workout_buttons<AddRow>(view_model: DayDetailViewModel, add_row: AddRow) -> gtk::Box
|
||||
where
|
||||
AddRow: Fn(Record<TraxRecord>) + 'static,
|
||||
{
|
||||
let add_row = Rc::new(add_row);
|
||||
|
||||
let layout = gtk::Box::builder()
|
||||
.orientation(gtk::Orientation::Horizontal)
|
||||
.build();
|
||||
|
||||
for (activity, icon, label) in [
|
||||
(
|
||||
TimeDistanceActivity::Biking,
|
||||
"cycling-symbolic",
|
||||
"Bike Ride",
|
||||
),
|
||||
(TimeDistanceActivity::Rowing, "rowing-symbolic", "Rowing"),
|
||||
(TimeDistanceActivity::Running, "running-symbolic", "Run"),
|
||||
(TimeDistanceActivity::Swimming, "swimming-symbolic", "Swim"),
|
||||
(TimeDistanceActivity::Walking, "walking-symbolic", "Walk"),
|
||||
] {
|
||||
let button = workout_button(activity, icon, label, view_model.clone(), {
|
||||
let add_row = add_row.clone();
|
||||
move |record| add_row(record)
|
||||
});
|
||||
layout.append(&button);
|
||||
}
|
||||
|
||||
layout
|
||||
}
|
||||
|
||||
fn workout_button<AddRow>(
|
||||
activity: TimeDistanceActivity,
|
||||
_icon: &str,
|
||||
label: &str,
|
||||
view_model: DayDetailViewModel,
|
||||
add_row: AddRow,
|
||||
) -> gtk::Button
|
||||
where
|
||||
AddRow: Fn(Record<TraxRecord>) + 'static,
|
||||
{
|
||||
let button = gtk::Button::builder()
|
||||
.label(label)
|
||||
.width_request(64)
|
||||
.height_request(64)
|
||||
.build();
|
||||
button.connect_clicked({
|
||||
let view_model = view_model.clone();
|
||||
move |_| {
|
||||
let workout = view_model.new_time_distance(activity);
|
||||
add_row(workout.map(TraxRecord::TimeDistance));
|
||||
}
|
||||
});
|
||||
button
|
||||
}
|
|
@ -0,0 +1,155 @@
|
|||
/*
|
||||
Copyright 2023-2024, Savanni D'Gerinel <savanni@luminescent-dreams.com>
|
||||
|
||||
This file is part of FitnessTrax.
|
||||
|
||||
FitnessTrax is free software: you can redistribute it and/or modify it under the terms of the GNU
|
||||
General Public License as published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
FitnessTrax is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without
|
||||
even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
|
||||
General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License along with FitnessTrax. If not,
|
||||
see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
mod action_group;
|
||||
pub use action_group::ActionGroup;
|
||||
|
||||
mod day;
|
||||
pub use day::{DayDetail, DayEdit, DaySummary};
|
||||
|
||||
mod date_field;
|
||||
pub use date_field::DateField;
|
||||
|
||||
mod date_range;
|
||||
pub use date_range::DateRangePicker;
|
||||
|
||||
mod singleton;
|
||||
pub use singleton::{Singleton, SingletonImpl};
|
||||
|
||||
mod steps;
|
||||
pub use steps::{steps_editor, Steps};
|
||||
|
||||
mod text_entry;
|
||||
pub use text_entry::{distance_field, duration_field, time_field, weight_field, i32_field_builder, month_field_builder, TextEntry};
|
||||
|
||||
mod time_distance;
|
||||
pub use time_distance::{time_distance_detail, time_distance_summary};
|
||||
|
||||
mod weight;
|
||||
pub use weight::WeightLabel;
|
||||
|
||||
use glib::Object;
|
||||
use gtk::{prelude::*, subclass::prelude::*};
|
||||
use std::{cell::RefCell, path::PathBuf, rc::Rc};
|
||||
|
||||
pub struct FileChooserRowPrivate {
|
||||
path: RefCell<Option<PathBuf>>,
|
||||
label: gtk::Label,
|
||||
}
|
||||
|
||||
#[glib::object_subclass]
|
||||
impl ObjectSubclass for FileChooserRowPrivate {
|
||||
const NAME: &'static str = "FileChooser";
|
||||
type Type = FileChooserRow;
|
||||
type ParentType = gtk::Box;
|
||||
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
path: RefCell::new(None),
|
||||
label: gtk::Label::builder().hexpand(true).build(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ObjectImpl for FileChooserRowPrivate {}
|
||||
impl WidgetImpl for FileChooserRowPrivate {}
|
||||
impl BoxImpl for FileChooserRowPrivate {}
|
||||
|
||||
glib::wrapper! {
|
||||
pub struct FileChooserRow(ObjectSubclass<FileChooserRowPrivate>) @extends gtk::Box, gtk::Widget, @implements gtk::Orientable;
|
||||
}
|
||||
|
||||
impl FileChooserRow {
|
||||
pub fn new<F>(on_selected: F) -> Self
|
||||
where
|
||||
F: Fn(PathBuf) + 'static,
|
||||
{
|
||||
let s: Self = Object::builder().build();
|
||||
|
||||
s.set_css_classes(&["dialog-row", "card"]);
|
||||
s.set_orientation(gtk::Orientation::Horizontal);
|
||||
s.set_spacing(8);
|
||||
|
||||
// The database selection row should be a box that shows a default database path, along with a
|
||||
// button that triggers a file chooser dialog. Once the dialog returns, the box should be
|
||||
// updated to reflect the chosen path.
|
||||
s.imp().label.set_text("No database selected");
|
||||
|
||||
let on_selected = Rc::new(Box::new(on_selected));
|
||||
|
||||
let import_button = gtk::Button::builder().label("Import a Database").build();
|
||||
|
||||
let handle_file_selection = Rc::new(Box::new({
|
||||
let s = s.clone();
|
||||
let on_selected = on_selected.clone();
|
||||
move |file_id: Result<gio::File, glib::Error>| match file_id {
|
||||
Ok(file_id) => match file_id.path() {
|
||||
Some(path) => {
|
||||
s.imp().label.set_text(path.to_str().unwrap());
|
||||
on_selected(path.clone());
|
||||
*s.imp().path.borrow_mut() = Some(path);
|
||||
}
|
||||
None => {
|
||||
*s.imp().path.borrow_mut() = None;
|
||||
s.imp().label.set_text("No database selected");
|
||||
}
|
||||
},
|
||||
Err(err) => println!("file opening failed: {}", err),
|
||||
}
|
||||
}));
|
||||
|
||||
import_button.connect_clicked({
|
||||
let handle_file_selection = handle_file_selection.clone();
|
||||
move |_| {
|
||||
let no_window: Option<>k::Window> = None;
|
||||
let not_cancellable: Option<&gio::Cancellable> = None;
|
||||
let handle_file_selection = handle_file_selection.clone();
|
||||
gtk::FileDialog::builder().build().open(
|
||||
no_window,
|
||||
not_cancellable,
|
||||
move |file_id| handle_file_selection(file_id),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
let new_button = gtk::Button::builder().label("Create Database").build();
|
||||
new_button.connect_clicked({
|
||||
let handle_file_selection = handle_file_selection.clone();
|
||||
move |_| {
|
||||
let no_window: Option<>k::Window> = None;
|
||||
let not_cancellable: Option<&gio::Cancellable> = None;
|
||||
let handle_file_selection = handle_file_selection.clone();
|
||||
gtk::FileDialog::builder().build().save(
|
||||
no_window,
|
||||
not_cancellable,
|
||||
move |file_id| handle_file_selection(file_id),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
s.imp().label.set_halign(gtk::Align::Start);
|
||||
s.append(&s.imp().label);
|
||||
s.append(&import_button);
|
||||
s.append(&new_button);
|
||||
|
||||
s
|
||||
}
|
||||
|
||||
pub fn path(&self) -> Option<PathBuf> {
|
||||
self.imp().path.borrow().clone()
|
||||
}
|
||||
}
|
|
@ -0,0 +1,71 @@
|
|||
/*
|
||||
Copyright 2024, Savanni D'Gerinel <savanni@luminescent-dreams.com>
|
||||
|
||||
This file is part of FitnessTrax.
|
||||
|
||||
FitnessTrax is free software: you can redistribute it and/or modify it under the terms of the GNU
|
||||
General Public License as published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
FitnessTrax is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without
|
||||
even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
|
||||
General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License along with FitnessTrax. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
//! A Widget container for a single components
|
||||
use glib::Object;
|
||||
use gtk::{prelude::*, subclass::prelude::*};
|
||||
use std::cell::RefCell;
|
||||
|
||||
pub struct SingletonPrivate {
|
||||
widget: RefCell<gtk::Widget>,
|
||||
}
|
||||
|
||||
impl Default for SingletonPrivate {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
widget: RefCell::new(gtk::Label::new(None).upcast()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[glib::object_subclass]
|
||||
impl ObjectSubclass for SingletonPrivate {
|
||||
const NAME: &'static str = "Singleton";
|
||||
type Type = Singleton;
|
||||
type ParentType = gtk::Box;
|
||||
}
|
||||
|
||||
impl ObjectImpl for SingletonPrivate {}
|
||||
impl WidgetImpl for SingletonPrivate {}
|
||||
impl BoxImpl for SingletonPrivate {}
|
||||
|
||||
glib::wrapper! {
|
||||
/// The Singleton component contains exactly one child widget. The swap function makes it easy
|
||||
/// to handle the job of swapping that child out for a different one.
|
||||
pub struct Singleton(ObjectSubclass<SingletonPrivate>) @extends gtk::Box, gtk::Widget;
|
||||
}
|
||||
|
||||
impl Default for Singleton {
|
||||
fn default() -> Self {
|
||||
let s: Self = Object::builder().build();
|
||||
|
||||
s.append(&*s.imp().widget.borrow());
|
||||
|
||||
s
|
||||
}
|
||||
}
|
||||
|
||||
impl Singleton {
|
||||
pub fn swap(&self, new_widget: &impl IsA<gtk::Widget>) {
|
||||
let new_widget = new_widget.clone().upcast();
|
||||
self.remove(&*self.imp().widget.borrow());
|
||||
self.append(&new_widget);
|
||||
*self.imp().widget.borrow_mut() = new_widget;
|
||||
}
|
||||
}
|
||||
|
||||
pub trait SingletonImpl: WidgetImpl + BoxImpl {}
|
||||
unsafe impl<T: SingletonImpl> IsSubclassable<T> for Singleton {}
|
|
@ -0,0 +1,60 @@
|
|||
/*
|
||||
Copyright 2024, Savanni D'Gerinel <savanni@luminescent-dreams.com>
|
||||
|
||||
This file is part of FitnessTrax.
|
||||
|
||||
FitnessTrax is free software: you can redistribute it and/or modify it under the terms of the GNU
|
||||
General Public License as published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
FitnessTrax is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without
|
||||
even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
|
||||
General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License along with FitnessTrax. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
use crate::{components::TextEntry, types::ParseError};
|
||||
use gtk::prelude::*;
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct Steps {
|
||||
label: gtk::Label,
|
||||
}
|
||||
|
||||
impl Steps {
|
||||
pub fn new(steps: Option<u32>) -> Self {
|
||||
let label = gtk::Label::builder()
|
||||
.css_classes(["card", "step-view"])
|
||||
.can_focus(true)
|
||||
.build();
|
||||
|
||||
match steps {
|
||||
Some(s) => label.set_text(&format!("{}", s)),
|
||||
None => label.set_text("No steps recorded"),
|
||||
}
|
||||
|
||||
Self { label }
|
||||
}
|
||||
|
||||
pub fn widget(&self) -> gtk::Widget {
|
||||
self.label.clone().upcast()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn steps_editor<OnUpdate>(value: Option<u32>, on_update: OnUpdate) -> TextEntry<u32>
|
||||
where
|
||||
OnUpdate: Fn(Option<u32>) + 'static,
|
||||
{
|
||||
let text_entry = TextEntry::builder()
|
||||
.with_placeholder( "0".to_owned())
|
||||
.with_renderer(|v| format!("{}", v))
|
||||
.with_parser(|v| v.parse::<u32>().map_err(|_| ParseError))
|
||||
.with_on_update(on_update);
|
||||
|
||||
if let Some(time) = value {
|
||||
text_entry.with_value(time)
|
||||
} else {
|
||||
text_entry
|
||||
}.build()
|
||||
}
|
|
@ -0,0 +1,373 @@
|
|||
/*
|
||||
Copyright 2023-2024, Savanni D'Gerinel <savanni@luminescent-dreams.com>
|
||||
|
||||
This file is part of FitnessTrax.
|
||||
|
||||
FitnessTrax is free software: you can redistribute it and/or modify it under the terms of the GNU
|
||||
General Public License as published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
FitnessTrax is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without
|
||||
even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
|
||||
General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License along with FitnessTrax. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
use crate::types::{
|
||||
DistanceFormatter, DurationFormatter, FormatOption, ParseError, TimeFormatter, WeightFormatter,
|
||||
};
|
||||
use gtk::prelude::*;
|
||||
use std::{cell::RefCell, rc::Rc};
|
||||
|
||||
pub type Parser<T> = dyn Fn(&str) -> Result<T, ParseError>;
|
||||
pub type OnUpdate<T> = dyn Fn(Option<T>);
|
||||
|
||||
// TextEntry is not a proper widget because I was never able to figure out how to do a type parameterization on a GTK widget.
|
||||
#[derive(Clone)]
|
||||
pub struct TextEntry<T: Clone + std::fmt::Debug> {
|
||||
value: Rc<RefCell<Option<T>>>,
|
||||
|
||||
widget: gtk::Entry,
|
||||
renderer: Rc<dyn Fn(&T) -> String>,
|
||||
parser: Rc<Parser<T>>,
|
||||
on_update: Rc<OnUpdate<T>>,
|
||||
}
|
||||
|
||||
impl<T: Clone + std::fmt::Debug> std::fmt::Debug for TextEntry<T> {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> Result<(), std::fmt::Error> {
|
||||
write!(
|
||||
f,
|
||||
"{{ value: {:?}, widget: {:?} }}",
|
||||
self.value, self.widget
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// I do not understand why the data should be 'static.
|
||||
impl<T: Clone + std::fmt::Debug + 'static> TextEntry<T> {
|
||||
fn from_builder(builder: TextEntryBuilder<T>) -> TextEntry<T> {
|
||||
let widget = gtk::Entry::builder()
|
||||
.placeholder_text(builder.placeholder)
|
||||
.build();
|
||||
if let Some(ref v) = builder.value {
|
||||
widget.set_text(&(builder.renderer)(v))
|
||||
}
|
||||
|
||||
let s = Self {
|
||||
value: Rc::new(RefCell::new(builder.value)),
|
||||
widget,
|
||||
renderer: Rc::new(builder.renderer),
|
||||
parser: Rc::new(builder.parser),
|
||||
on_update: Rc::new(builder.on_update),
|
||||
};
|
||||
|
||||
s.widget.buffer().connect_text_notify({
|
||||
let s = s.clone();
|
||||
move |buffer| s.handle_text_change(buffer)
|
||||
});
|
||||
|
||||
if let Some(length) = builder.length {
|
||||
s.widget.set_max_length(length.try_into().unwrap());
|
||||
}
|
||||
|
||||
// let classes: Vec<&str> = builder.css_classes.iter(|v| v.as_ref()).collect();
|
||||
let classes: Vec<&str> = builder.css_classes.iter().map(AsRef::as_ref).collect();
|
||||
s.widget.set_css_classes(&classes);
|
||||
|
||||
s
|
||||
}
|
||||
|
||||
pub fn builder() -> TextEntryBuilder<T> {
|
||||
TextEntryBuilder::default()
|
||||
}
|
||||
|
||||
pub fn set_value(&self, val: Option<T>) {
|
||||
if let Some(ref v) = val {
|
||||
self.widget.set_text(&(self.renderer)(v));
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_text_change(&self, buffer: >k::EntryBuffer) {
|
||||
if buffer.text().is_empty() {
|
||||
*self.value.borrow_mut() = None;
|
||||
self.widget.remove_css_class("error");
|
||||
(self.on_update)(None);
|
||||
return;
|
||||
}
|
||||
match (self.parser)(buffer.text().as_str()) {
|
||||
Ok(v) => {
|
||||
*self.value.borrow_mut() = Some(v.clone());
|
||||
self.widget.remove_css_class("error");
|
||||
(self.on_update)(Some(v));
|
||||
}
|
||||
// need to change the border to provide a visual indicator of an error
|
||||
Err(_) => {
|
||||
self.widget.add_css_class("error");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn widget(&self) -> gtk::Widget {
|
||||
self.widget.clone().upcast::<gtk::Widget>()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn has_parse_error(&self) -> bool {
|
||||
self.widget.has_css_class("error")
|
||||
}
|
||||
}
|
||||
|
||||
pub struct TextEntryBuilder<T: Clone + std::fmt::Debug + 'static> {
|
||||
placeholder: String,
|
||||
value: Option<T>,
|
||||
length: Option<usize>,
|
||||
css_classes: Vec<String>,
|
||||
renderer: Box<dyn Fn(&T) -> String>,
|
||||
parser: Box<Parser<T>>,
|
||||
on_update: Box<OnUpdate<T>>,
|
||||
}
|
||||
|
||||
impl<T: Clone + std::fmt::Debug + 'static> Default for TextEntryBuilder<T> {
|
||||
fn default() -> TextEntryBuilder<T> {
|
||||
TextEntryBuilder {
|
||||
placeholder: "".to_owned(),
|
||||
value: None,
|
||||
length: None,
|
||||
css_classes: vec![],
|
||||
renderer: Box::new(|_| "".to_owned()),
|
||||
parser: Box::new(|_| Err(ParseError)),
|
||||
on_update: Box::new(|_| {}),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: Clone + std::fmt::Debug + 'static> TextEntryBuilder<T> {
|
||||
pub fn build(self) -> TextEntry<T> {
|
||||
TextEntry::from_builder(self)
|
||||
}
|
||||
|
||||
pub fn with_placeholder(self, placeholder: String) -> Self {
|
||||
Self {
|
||||
placeholder,
|
||||
..self
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_value(self, value: T) -> Self {
|
||||
Self {
|
||||
value: Some(value),
|
||||
..self
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_length(self, length: usize) -> Self {
|
||||
Self {
|
||||
length: Some(length),
|
||||
..self
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_css_classes(self, classes: Vec<String>) -> Self {
|
||||
Self {
|
||||
css_classes: classes,
|
||||
..self
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_renderer(self, renderer: impl Fn(&T) -> String + 'static) -> Self {
|
||||
Self {
|
||||
renderer: Box::new(renderer),
|
||||
..self
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_parser(self, parser: impl Fn(&str) -> Result<T, ParseError> + 'static) -> Self {
|
||||
Self {
|
||||
parser: Box::new(parser),
|
||||
..self
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_on_update(self, on_update: impl Fn(Option<T>) + 'static) -> Self {
|
||||
Self {
|
||||
on_update: Box::new(on_update),
|
||||
..self
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn time_field<OnUpdate>(
|
||||
value: Option<TimeFormatter>,
|
||||
on_update: OnUpdate,
|
||||
) -> TextEntry<TimeFormatter>
|
||||
where
|
||||
OnUpdate: Fn(Option<TimeFormatter>) + 'static,
|
||||
{
|
||||
let text_entry = TextEntry::builder()
|
||||
.with_placeholder("HH:MM".to_owned())
|
||||
.with_renderer(|val: &TimeFormatter| val.format(FormatOption::Abbreviated))
|
||||
.with_parser(TimeFormatter::parse)
|
||||
.with_on_update(on_update);
|
||||
|
||||
if let Some(time) = value {
|
||||
text_entry.with_value(time)
|
||||
} else {
|
||||
text_entry
|
||||
}
|
||||
.build()
|
||||
}
|
||||
|
||||
pub fn distance_field<OnUpdate>(
|
||||
value: Option<DistanceFormatter>,
|
||||
on_update: OnUpdate,
|
||||
) -> TextEntry<DistanceFormatter>
|
||||
where
|
||||
OnUpdate: Fn(Option<DistanceFormatter>) + 'static,
|
||||
{
|
||||
let text_entry = TextEntry::builder()
|
||||
.with_placeholder("0 km".to_owned())
|
||||
.with_renderer(|val: &DistanceFormatter| val.format(FormatOption::Abbreviated))
|
||||
.with_parser(DistanceFormatter::parse)
|
||||
.with_on_update(on_update);
|
||||
|
||||
if let Some(distance) = value {
|
||||
text_entry.with_value(distance)
|
||||
} else {
|
||||
text_entry
|
||||
}
|
||||
.build()
|
||||
}
|
||||
|
||||
pub fn duration_field<OnUpdate>(
|
||||
value: Option<DurationFormatter>,
|
||||
on_update: OnUpdate,
|
||||
) -> TextEntry<DurationFormatter>
|
||||
where
|
||||
OnUpdate: Fn(Option<DurationFormatter>) + 'static,
|
||||
{
|
||||
let text_entry = TextEntry::builder()
|
||||
.with_placeholder("0 m".to_owned())
|
||||
.with_renderer(|val: &DurationFormatter| val.format(FormatOption::Abbreviated))
|
||||
.with_parser(DurationFormatter::parse)
|
||||
.with_on_update(on_update);
|
||||
|
||||
if let Some(duration) = value {
|
||||
text_entry.with_value(duration)
|
||||
} else {
|
||||
text_entry
|
||||
}
|
||||
.build()
|
||||
}
|
||||
pub fn weight_field<OnUpdate>(
|
||||
weight: Option<WeightFormatter>,
|
||||
on_update: OnUpdate,
|
||||
) -> TextEntry<WeightFormatter>
|
||||
where
|
||||
OnUpdate: Fn(Option<WeightFormatter>) + 'static,
|
||||
{
|
||||
let text_entry = TextEntry::builder()
|
||||
.with_placeholder("0 kg".to_owned())
|
||||
.with_renderer(|val: &WeightFormatter| val.format(FormatOption::Abbreviated))
|
||||
.with_parser(WeightFormatter::parse)
|
||||
.with_on_update(on_update);
|
||||
if let Some(weight) = weight {
|
||||
text_entry.with_value(weight)
|
||||
} else {
|
||||
text_entry
|
||||
}
|
||||
.build()
|
||||
}
|
||||
|
||||
pub fn i32_field_builder() -> TextEntryBuilder<i32>
|
||||
{
|
||||
TextEntry::builder()
|
||||
.with_placeholder("0".to_owned())
|
||||
.with_renderer(|val| format!("{}", val))
|
||||
.with_parser(|v| v.parse::<i32>().map_err(|_| ParseError))
|
||||
}
|
||||
|
||||
pub fn month_field_builder() -> TextEntryBuilder<u32>
|
||||
{
|
||||
TextEntry::builder()
|
||||
.with_placeholder("0".to_owned())
|
||||
.with_renderer(|val| format!("{}", val))
|
||||
.with_parser(|v| {
|
||||
let val = v.parse::<u32>().map_err(|_| ParseError)?;
|
||||
if val == 0 || val > 12 {
|
||||
return Err(ParseError);
|
||||
}
|
||||
Ok(val)
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod test {
|
||||
use super::*;
|
||||
use crate::gtk_init::gtk_init;
|
||||
|
||||
fn setup_u32_entry() -> (Rc<RefCell<Option<u32>>>, TextEntry<u32>) {
|
||||
let current_value = Rc::new(RefCell::new(None));
|
||||
|
||||
let entry = TextEntry::builder()
|
||||
.with_placeholder("step count".to_owned())
|
||||
.with_renderer(|steps| format!("{}", steps))
|
||||
.with_parser(|v| v.parse::<u32>().map_err(|_| ParseError))
|
||||
.with_on_update({
|
||||
let current_value = current_value.clone();
|
||||
move |v| *current_value.borrow_mut() = v
|
||||
})
|
||||
.build();
|
||||
|
||||
(current_value, entry)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn it_responds_to_field_changes() {
|
||||
gtk_init();
|
||||
let (current_value, entry) = setup_u32_entry();
|
||||
let buffer = entry.widget.buffer();
|
||||
|
||||
buffer.set_text("1");
|
||||
assert_eq!(*current_value.borrow(), Some(1));
|
||||
|
||||
buffer.set_text("15");
|
||||
assert_eq!(*current_value.borrow(), Some(15));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn it_preserves_last_value_in_parse_error() {
|
||||
crate::gtk_init::gtk_init();
|
||||
let (current_value, entry) = setup_u32_entry();
|
||||
let buffer = entry.widget.buffer();
|
||||
|
||||
buffer.set_text("1");
|
||||
assert_eq!(*current_value.borrow(), Some(1));
|
||||
|
||||
buffer.set_text("a5");
|
||||
assert_eq!(*current_value.borrow(), Some(1));
|
||||
assert!(entry.has_parse_error());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn it_update_on_empty_strings() {
|
||||
gtk_init();
|
||||
let (current_value, entry) = setup_u32_entry();
|
||||
let buffer = entry.widget.buffer();
|
||||
|
||||
buffer.set_text("1");
|
||||
assert_eq!(*current_value.borrow(), Some(1));
|
||||
buffer.set_text("");
|
||||
assert_eq!(*current_value.borrow(), None);
|
||||
|
||||
buffer.set_text("1");
|
||||
assert_eq!(*current_value.borrow(), Some(1));
|
||||
buffer.set_text("1a");
|
||||
assert_eq!(*current_value.borrow(), Some(1));
|
||||
assert!(entry.has_parse_error());
|
||||
|
||||
buffer.set_text("");
|
||||
assert_eq!(*current_value.borrow(), None);
|
||||
assert!(!entry.has_parse_error());
|
||||
}
|
||||
}
|
|
@ -0,0 +1,271 @@
|
|||
/*
|
||||
Copyright 2023-2024, Savanni D'Gerinel <savanni@luminescent-dreams.com>
|
||||
|
||||
This file is part of FitnessTrax.
|
||||
|
||||
FitnessTrax is free software: you can redistribute it and/or modify it under the terms of the GNU
|
||||
General Public License as published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
FitnessTrax is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without
|
||||
even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
|
||||
General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License along with FitnessTrax. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
use crate::{
|
||||
components::{distance_field, duration_field, time_field},
|
||||
types::{DistanceFormatter, DurationFormatter, FormatOption, TimeFormatter},
|
||||
};
|
||||
use dimensioned::si;
|
||||
use ft_core::{TimeDistance, TimeDistanceActivity, TIME_DISTANCE_ACTIVITIES};
|
||||
use glib::Object;
|
||||
use gtk::{prelude::*, subclass::prelude::*};
|
||||
use std::{cell::RefCell, rc::Rc};
|
||||
|
||||
pub fn time_distance_summary(
|
||||
activity: TimeDistanceActivity,
|
||||
distance: DistanceFormatter,
|
||||
duration: DurationFormatter,
|
||||
) -> Option<gtk::Label> {
|
||||
let text = match (*distance > si::M, *duration > si::S) {
|
||||
(true, true) => Some(format!(
|
||||
"{} of {:?} in {}",
|
||||
distance.format(FormatOption::Full),
|
||||
activity,
|
||||
duration.format(FormatOption::Full)
|
||||
)),
|
||||
(true, false) => Some(format!(
|
||||
"{} of {:?}",
|
||||
distance.format(FormatOption::Full),
|
||||
activity
|
||||
)),
|
||||
(false, true) => Some(format!(
|
||||
"{} of {:?}",
|
||||
duration.format(FormatOption::Full),
|
||||
activity
|
||||
)),
|
||||
(false, false) => None,
|
||||
};
|
||||
|
||||
text.map(|text| gtk::Label::builder().halign(gtk::Align::Start).label(&text).build())
|
||||
}
|
||||
|
||||
pub fn time_distance_detail(record: ft_core::TimeDistance) -> gtk::Box {
|
||||
let layout = gtk::Box::builder()
|
||||
.orientation(gtk::Orientation::Vertical)
|
||||
.hexpand(true)
|
||||
.build();
|
||||
let first_row = gtk::Box::builder().homogeneous(true).build();
|
||||
|
||||
first_row.append(
|
||||
>k::Label::builder()
|
||||
.halign(gtk::Align::Start)
|
||||
.label(record.datetime.format("%H:%M").to_string())
|
||||
.build(),
|
||||
);
|
||||
|
||||
first_row.append(
|
||||
>k::Label::builder()
|
||||
.halign(gtk::Align::Start)
|
||||
.label(format!("{:?}", record.activity))
|
||||
.build(),
|
||||
);
|
||||
|
||||
first_row.append(
|
||||
>k::Label::builder()
|
||||
.halign(gtk::Align::Start)
|
||||
.label(
|
||||
record
|
||||
.distance
|
||||
.map(|dist| DistanceFormatter::from(dist).format(FormatOption::Abbreviated))
|
||||
.unwrap_or("".to_owned()),
|
||||
)
|
||||
.build(),
|
||||
);
|
||||
|
||||
first_row.append(
|
||||
>k::Label::builder()
|
||||
.halign(gtk::Align::Start)
|
||||
.label(
|
||||
record
|
||||
.duration
|
||||
.map(|duration| {
|
||||
DurationFormatter::from(duration).format(FormatOption::Abbreviated)
|
||||
})
|
||||
.unwrap_or("".to_owned()),
|
||||
)
|
||||
.build(),
|
||||
);
|
||||
|
||||
layout.append(&first_row);
|
||||
|
||||
layout.append(
|
||||
>k::Label::builder()
|
||||
.halign(gtk::Align::Start)
|
||||
.label(
|
||||
record
|
||||
.comments
|
||||
.map(|comments| comments.to_string())
|
||||
.unwrap_or("".to_owned()),
|
||||
)
|
||||
.build(),
|
||||
);
|
||||
|
||||
layout
|
||||
}
|
||||
|
||||
type OnUpdate = Rc<RefCell<Box<dyn Fn(TimeDistance)>>>;
|
||||
|
||||
pub struct TimeDistanceEditPrivate {
|
||||
#[allow(unused)]
|
||||
workout: RefCell<ft_core::TimeDistance>,
|
||||
on_update: OnUpdate,
|
||||
}
|
||||
|
||||
impl Default for TimeDistanceEditPrivate {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
workout: RefCell::new(TimeDistance {
|
||||
datetime: chrono::Utc::now().into(),
|
||||
activity: TimeDistanceActivity::Biking,
|
||||
duration: None,
|
||||
distance: None,
|
||||
comments: None,
|
||||
}),
|
||||
on_update: Rc::new(RefCell::new(Box::new(|_| {}))),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[glib::object_subclass]
|
||||
impl ObjectSubclass for TimeDistanceEditPrivate {
|
||||
const NAME: &'static str = "TimeDistanceEdit";
|
||||
type Type = TimeDistanceEdit;
|
||||
type ParentType = gtk::Box;
|
||||
}
|
||||
|
||||
impl ObjectImpl for TimeDistanceEditPrivate {}
|
||||
impl WidgetImpl for TimeDistanceEditPrivate {}
|
||||
impl BoxImpl for TimeDistanceEditPrivate {}
|
||||
|
||||
glib::wrapper! {
|
||||
pub struct TimeDistanceEdit(ObjectSubclass<TimeDistanceEditPrivate>) @extends gtk::Box, gtk::Widget, @implements gtk::Orientable;
|
||||
}
|
||||
|
||||
impl Default for TimeDistanceEdit {
|
||||
fn default() -> Self {
|
||||
let s: Self = Object::builder().build();
|
||||
s.set_orientation(gtk::Orientation::Vertical);
|
||||
s.set_hexpand(true);
|
||||
s.set_css_classes(&["time-distance-edit"]);
|
||||
|
||||
s
|
||||
}
|
||||
}
|
||||
|
||||
impl TimeDistanceEdit {
|
||||
pub fn new<OnUpdate>(workout: TimeDistance, on_update: OnUpdate) -> Self
|
||||
where
|
||||
OnUpdate: Fn(TimeDistance) + 'static,
|
||||
{
|
||||
let s = Self::default();
|
||||
|
||||
*s.imp().workout.borrow_mut() = workout.clone();
|
||||
*s.imp().on_update.borrow_mut() = Box::new(on_update);
|
||||
|
||||
let details_row = gtk::Box::builder()
|
||||
.orientation(gtk::Orientation::Horizontal)
|
||||
.build();
|
||||
|
||||
details_row.append(
|
||||
&time_field(
|
||||
Some(TimeFormatter::from(workout.datetime.naive_local().time())),
|
||||
{
|
||||
let s = s.clone();
|
||||
move |t| s.update_time(t)
|
||||
},
|
||||
)
|
||||
.widget(),
|
||||
);
|
||||
details_row.append(&s.activity_menu(workout.activity));
|
||||
details_row.append(
|
||||
&distance_field(workout.distance.map(DistanceFormatter::from), {
|
||||
let s = s.clone();
|
||||
move |d| s.update_distance(d)
|
||||
})
|
||||
.widget(),
|
||||
);
|
||||
details_row.append(
|
||||
&duration_field(workout.duration.map(DurationFormatter::from), {
|
||||
let s = s.clone();
|
||||
move |d| s.update_duration(d)
|
||||
})
|
||||
.widget(),
|
||||
);
|
||||
s.append(&details_row);
|
||||
s.append(>k::Entry::new());
|
||||
|
||||
s
|
||||
}
|
||||
|
||||
fn update_time(&self, time: Option<TimeFormatter>) {
|
||||
if let Some(time_formatter) = time {
|
||||
let mut workout = self.imp().workout.borrow_mut();
|
||||
let tz = workout.datetime.timezone();
|
||||
let new_time = workout
|
||||
.datetime
|
||||
.date_naive()
|
||||
.and_time(*time_formatter)
|
||||
.and_local_timezone(tz)
|
||||
.unwrap()
|
||||
.fixed_offset();
|
||||
workout.datetime = new_time;
|
||||
(self.imp().on_update.borrow())(workout.clone());
|
||||
}
|
||||
}
|
||||
|
||||
fn update_workout_type(&self, type_: TimeDistanceActivity) {
|
||||
let mut workout = self.imp().workout.borrow_mut();
|
||||
workout.activity = type_;
|
||||
(self.imp().on_update.borrow())(workout.clone())
|
||||
}
|
||||
|
||||
fn update_distance(&self, distance: Option<DistanceFormatter>) {
|
||||
let mut workout = self.imp().workout.borrow_mut();
|
||||
workout.distance = distance.map(|d| *d);
|
||||
(self.imp().on_update.borrow())(workout.clone());
|
||||
}
|
||||
|
||||
fn update_duration(&self, duration: Option<DurationFormatter>) {
|
||||
let mut workout = self.imp().workout.borrow_mut();
|
||||
workout.duration = duration.map(|d| *d);
|
||||
(self.imp().on_update.borrow())(workout.clone());
|
||||
}
|
||||
|
||||
fn activity_menu(&self, selected: TimeDistanceActivity) -> gtk::DropDown {
|
||||
let options = TIME_DISTANCE_ACTIVITIES
|
||||
.iter()
|
||||
.map(|item| format!("{:?}", item))
|
||||
.collect::<Vec<String>>();
|
||||
|
||||
let options = options.iter().map(|o| o.as_ref()).collect::<Vec<&str>>();
|
||||
|
||||
let selected_idx = TIME_DISTANCE_ACTIVITIES
|
||||
.iter()
|
||||
.position(|&v| v == selected)
|
||||
.unwrap_or(0);
|
||||
|
||||
let menu = gtk::DropDown::from_strings(&options);
|
||||
menu.set_selected(selected_idx as u32);
|
||||
menu.connect_selected_item_notify({
|
||||
let s = self.clone();
|
||||
move |menu| {
|
||||
let new_item = TIME_DISTANCE_ACTIVITIES[menu.selected() as usize];
|
||||
s.update_workout_type(new_item);
|
||||
}
|
||||
});
|
||||
menu
|
||||
}
|
||||
}
|
|
@ -0,0 +1,42 @@
|
|||
/*
|
||||
Copyright 2023-2024, Savanni D'Gerinel <savanni@luminescent-dreams.com>
|
||||
|
||||
This file is part of FitnessTrax.
|
||||
|
||||
FitnessTrax is free software: you can redistribute it and/or modify it under the terms of the GNU
|
||||
General Public License as published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
FitnessTrax is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without
|
||||
even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
|
||||
General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License along with FitnessTrax. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
use crate::types::{FormatOption, WeightFormatter};
|
||||
use gtk::prelude::*;
|
||||
|
||||
pub struct WeightLabel {
|
||||
label: gtk::Label,
|
||||
}
|
||||
|
||||
impl WeightLabel {
|
||||
pub fn new(weight: Option<WeightFormatter>) -> Self {
|
||||
let label = gtk::Label::builder()
|
||||
.css_classes(["card", "weight-view"])
|
||||
.can_focus(true)
|
||||
.build();
|
||||
|
||||
match weight {
|
||||
Some(w) => label.set_text(&w.format(FormatOption::Abbreviated)),
|
||||
None => label.set_text("No weight recorded"),
|
||||
}
|
||||
|
||||
Self { label }
|
||||
}
|
||||
|
||||
pub fn widget(&self) -> gtk::Widget {
|
||||
self.label.clone().upcast()
|
||||
}
|
||||
}
|
|
@ -0,0 +1,10 @@
|
|||
use std::sync::Once;
|
||||
|
||||
static INITIALIZED: Once = Once::new();
|
||||
|
||||
pub fn gtk_init() {
|
||||
INITIALIZED.call_once(|| {
|
||||
eprintln!("initializing GTK");
|
||||
let _ = gtk::init();
|
||||
});
|
||||
}
|
|
@ -0,0 +1,101 @@
|
|||
/*
|
||||
Copyright 2023 - 2024, Savanni D'Gerinel <savanni@luminescent-dreams.com>
|
||||
|
||||
This file is part of FitnessTrax.
|
||||
|
||||
FitnessTrax is free software: you can redistribute it and/or modify it under the terms of the GNU
|
||||
General Public License as published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
FitnessTrax is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without
|
||||
even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
|
||||
General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License along with FitnessTrax. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
mod about;
|
||||
mod app;
|
||||
mod app_window;
|
||||
mod components;
|
||||
#[cfg(test)]
|
||||
mod gtk_init;
|
||||
mod types;
|
||||
mod view_models;
|
||||
mod views;
|
||||
|
||||
use adw::prelude::*;
|
||||
use app_window::AppWindow;
|
||||
use gio::ActionEntry;
|
||||
use std::{env, path::PathBuf};
|
||||
|
||||
const APP_ID_DEV: &str = "com.luminescent-dreams.fitnesstrax.dev";
|
||||
const APP_ID_PROD: &str = "com.luminescent-dreams.fitnesstrax";
|
||||
|
||||
const RESOURCE_BASE_PATH: &str = "/com/luminescent-dreams/fitnesstrax/";
|
||||
|
||||
fn setup_app_about_action(app: &adw::Application) {
|
||||
let action = ActionEntry::builder("about")
|
||||
.activate(|_app: &adw::Application, _, _| {
|
||||
let window = about::AboutWindow::default();
|
||||
window.present();
|
||||
}).build();
|
||||
app.add_action_entries([action]);
|
||||
}
|
||||
|
||||
/// Sets up an application-global action, `app.quit`, which will terminate the application.
|
||||
fn setup_app_close_action(app: &adw::Application) {
|
||||
let action = ActionEntry::builder("quit")
|
||||
.activate(|app: &adw::Application, _, _| {
|
||||
// right now, stopping the application is dirt simple. But we could use this
|
||||
// block to add extra code that does additional shutdown steps if we ever want
|
||||
// some states that shouldn't be discarded.
|
||||
app.quit();
|
||||
})
|
||||
.build();
|
||||
app.add_action_entries([action]);
|
||||
app.set_accels_for_action("app.quit", &["<Ctrl>Q"]);
|
||||
}
|
||||
|
||||
fn main() {
|
||||
// I still don't fully understand gio resources. resources_register_include! is convenient
|
||||
// because I don't have to deal with filesystem locations at runtime. However, I think other
|
||||
// GTK applications do that rather than compiling the resources directly into the app. So, I'm
|
||||
// unclear as to how I want to handle this.
|
||||
gio::resources_register_include!("com.luminescent-dreams.fitnesstrax.gresource")
|
||||
.expect("to register resources");
|
||||
|
||||
let app_id = if std::env::var_os("ENV") == Some("dev".into()) {
|
||||
APP_ID_DEV
|
||||
} else {
|
||||
APP_ID_PROD
|
||||
};
|
||||
|
||||
let settings = gio::Settings::new(app_id);
|
||||
let ft_app = app::App::new({
|
||||
let path = settings.string("series-path");
|
||||
if path.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(PathBuf::from(path))
|
||||
}
|
||||
});
|
||||
|
||||
let adw_app = adw::Application::builder()
|
||||
.application_id(app_id)
|
||||
.resource_base_path(RESOURCE_BASE_PATH)
|
||||
.build();
|
||||
|
||||
adw_app.connect_activate(move |adw_app| {
|
||||
let icon_theme = gtk::IconTheme::for_display(&gdk::Display::default().unwrap());
|
||||
icon_theme.add_resource_path(&(RESOURCE_BASE_PATH.to_owned() + "/icons/scalable/actions"));
|
||||
|
||||
setup_app_about_action(adw_app);
|
||||
setup_app_close_action(adw_app);
|
||||
|
||||
AppWindow::new(app_id, RESOURCE_BASE_PATH, adw_app, ft_app.clone());
|
||||
});
|
||||
|
||||
let args: Vec<String> = env::args().collect();
|
||||
ApplicationExtManual::run_with_args(&adw_app, &args);
|
||||
}
|
|
@ -0,0 +1,349 @@
|
|||
use chrono::{Local, NaiveDate};
|
||||
use dimensioned::si;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct ParseError;
|
||||
|
||||
// This interval doesn't feel right, either. The idea that I have a specific interval type for just
|
||||
// NaiveDate is odd. This should be genericized, as should the iterator. Also, it shouldn't live
|
||||
// here, but in utilities.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct DayInterval {
|
||||
pub start: NaiveDate,
|
||||
pub end: NaiveDate,
|
||||
}
|
||||
|
||||
impl Default for DayInterval {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
start: (Local::now() - chrono::Duration::days(7)).date_naive(),
|
||||
end: Local::now().date_naive(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl DayInterval {
|
||||
pub fn days(&self) -> impl Iterator<Item = NaiveDate> {
|
||||
DayIterator {
|
||||
current: self.start,
|
||||
end: self.end,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct DayIterator {
|
||||
current: NaiveDate,
|
||||
end: NaiveDate,
|
||||
}
|
||||
|
||||
impl Iterator for DayIterator {
|
||||
type Item = NaiveDate;
|
||||
|
||||
fn next(&mut self) -> Option<Self::Item> {
|
||||
if self.current <= self.end {
|
||||
let val = self.current;
|
||||
self.current += chrono::Duration::days(1);
|
||||
Some(val)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum FormatOption {
|
||||
Abbreviated,
|
||||
#[allow(unused)]
|
||||
Full,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct TimeFormatter(chrono::NaiveTime);
|
||||
|
||||
impl TimeFormatter {
|
||||
#[allow(unused)]
|
||||
pub fn format(&self, option: FormatOption) -> String {
|
||||
match option {
|
||||
FormatOption::Abbreviated => self.0.format("%H:%M"),
|
||||
FormatOption::Full => self.0.format("%H:%M:%S"),
|
||||
}
|
||||
.to_string()
|
||||
}
|
||||
|
||||
#[allow(unused)]
|
||||
pub fn parse(s: &str) -> Result<TimeFormatter, ParseError> {
|
||||
let parts = s
|
||||
.split(':')
|
||||
.map(|part| part.parse::<u32>().map_err(|_| ParseError))
|
||||
.collect::<Result<Vec<u32>, ParseError>>()?;
|
||||
match parts.len() {
|
||||
0 => Err(ParseError),
|
||||
1 => Err(ParseError),
|
||||
2 => chrono::NaiveTime::from_hms_opt(parts[0], parts[1], 0)
|
||||
.map(|v| TimeFormatter(v))
|
||||
.ok_or(ParseError),
|
||||
3 => chrono::NaiveTime::from_hms_opt(parts[0], parts[1], parts[2])
|
||||
.map(|v| TimeFormatter(v))
|
||||
.ok_or(ParseError),
|
||||
_ => Err(ParseError),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::ops::Deref for TimeFormatter {
|
||||
type Target = chrono::NaiveTime;
|
||||
fn deref(&self) -> &Self::Target {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl From<chrono::NaiveTime> for TimeFormatter {
|
||||
fn from(value: chrono::NaiveTime) -> Self {
|
||||
Self(value)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, PartialOrd)]
|
||||
pub struct WeightFormatter(si::Kilogram<f64>);
|
||||
|
||||
impl WeightFormatter {
|
||||
#[allow(unused)]
|
||||
pub fn format(&self, option: FormatOption) -> String {
|
||||
match option {
|
||||
FormatOption::Abbreviated => format!("{} kg", self.0.value_unsafe),
|
||||
FormatOption::Full => format!("{} kilograms", self.0.value_unsafe),
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(unused)]
|
||||
pub fn parse(s: &str) -> Result<WeightFormatter, ParseError> {
|
||||
s.parse::<f64>()
|
||||
.map(|w| WeightFormatter(w * si::KG))
|
||||
.map_err(|_| ParseError)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::ops::Add for WeightFormatter {
|
||||
type Output = WeightFormatter;
|
||||
fn add(self, rside: Self) -> Self::Output {
|
||||
Self::Output::from(self.0 + rside.0)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::ops::Sub for WeightFormatter {
|
||||
type Output = WeightFormatter;
|
||||
fn sub(self, rside: Self) -> Self::Output {
|
||||
Self::Output::from(self.0 - rside.0)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::ops::Deref for WeightFormatter {
|
||||
type Target = si::Kilogram<f64>;
|
||||
fn deref(&self) -> &Self::Target {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl From<si::Kilogram<f64>> for WeightFormatter {
|
||||
fn from(value: si::Kilogram<f64>) -> Self {
|
||||
Self(value)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, PartialOrd)]
|
||||
pub struct DistanceFormatter(si::Meter<f64>);
|
||||
|
||||
impl DistanceFormatter {
|
||||
#[allow(unused)]
|
||||
pub fn format(&self, option: FormatOption) -> String {
|
||||
match option {
|
||||
FormatOption::Abbreviated => format!("{} km", self.0.value_unsafe / 1000.),
|
||||
FormatOption::Full => format!("{} kilometers", self.0.value_unsafe / 1000.),
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(unused)]
|
||||
pub fn parse(s: &str) -> Result<DistanceFormatter, ParseError> {
|
||||
let value = s.parse::<f64>().map_err(|_| ParseError)?;
|
||||
Ok(DistanceFormatter(value * 1000. * si::M))
|
||||
}
|
||||
}
|
||||
|
||||
impl std::ops::Add for DistanceFormatter {
|
||||
type Output = DistanceFormatter;
|
||||
fn add(self, rside: Self) -> Self::Output {
|
||||
Self::Output::from(self.0 + rside.0)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::ops::Sub for DistanceFormatter {
|
||||
type Output = DistanceFormatter;
|
||||
fn sub(self, rside: Self) -> Self::Output {
|
||||
Self::Output::from(self.0 - rside.0)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::ops::Deref for DistanceFormatter {
|
||||
type Target = si::Meter<f64>;
|
||||
fn deref(&self) -> &Self::Target {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl From<si::Meter<f64>> for DistanceFormatter {
|
||||
fn from(value: si::Meter<f64>) -> Self {
|
||||
Self(value)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, PartialOrd)]
|
||||
pub struct DurationFormatter(si::Second<f64>);
|
||||
|
||||
impl DurationFormatter {
|
||||
#[allow(unused)]
|
||||
pub fn format(&self, option: FormatOption) -> String {
|
||||
let (hours, minutes) = self.hours_and_minutes();
|
||||
let (h, m) = match option {
|
||||
FormatOption::Abbreviated => ("h", "m"),
|
||||
FormatOption::Full => (" hours", " minutes"),
|
||||
};
|
||||
if hours > 0 {
|
||||
format!("{}{} {}{}", hours, h, minutes, m)
|
||||
} else {
|
||||
format!("{}{}", minutes, m)
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(unused)]
|
||||
pub fn parse(s: &str) -> Result<DurationFormatter, ParseError> {
|
||||
let value = s.parse::<f64>().map_err(|_| ParseError)?;
|
||||
Ok(DurationFormatter(value * 60. * si::S))
|
||||
}
|
||||
|
||||
#[allow(unused)]
|
||||
fn hours_and_minutes(&self) -> (i64, i64) {
|
||||
let minutes: i64 = (self.0.value_unsafe / 60.).round() as i64;
|
||||
let hours: i64 = minutes / 60;
|
||||
let minutes = minutes - (hours * 60);
|
||||
(hours, minutes)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::ops::Add for DurationFormatter {
|
||||
type Output = DurationFormatter;
|
||||
fn add(self, rside: Self) -> Self::Output {
|
||||
Self::Output::from(self.0 + rside.0)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::ops::Sub for DurationFormatter {
|
||||
type Output = DurationFormatter;
|
||||
fn sub(self, rside: Self) -> Self::Output {
|
||||
Self::Output::from(self.0 - rside.0)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::ops::Deref for DurationFormatter {
|
||||
type Target = si::Second<f64>;
|
||||
fn deref(&self) -> &Self::Target {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl From<si::Second<f64>> for DurationFormatter {
|
||||
fn from(value: si::Second<f64>) -> Self {
|
||||
Self(value)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod test {
|
||||
use super::*;
|
||||
use dimensioned::si;
|
||||
|
||||
#[test]
|
||||
fn it_parses_weight_values() {
|
||||
assert_eq!(
|
||||
WeightFormatter::parse("15.3"),
|
||||
Ok(WeightFormatter(15.3 * si::KG))
|
||||
);
|
||||
assert_eq!(WeightFormatter::parse("15.ab"), Err(ParseError));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn it_formats_weight_values() {
|
||||
assert_eq!(
|
||||
WeightFormatter::from(15.3 * si::KG).format(FormatOption::Abbreviated),
|
||||
"15.3 kg"
|
||||
);
|
||||
assert_eq!(
|
||||
WeightFormatter::from(15.3 * si::KG).format(FormatOption::Full),
|
||||
"15.3 kilograms"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn it_parses_distance_values() {
|
||||
assert_eq!(
|
||||
DistanceFormatter::parse("70"),
|
||||
Ok(DistanceFormatter(70000. * si::M))
|
||||
);
|
||||
assert_eq!(DistanceFormatter::parse("15.ab"), Err(ParseError));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn it_formats_distance_values() {
|
||||
assert_eq!(
|
||||
DistanceFormatter::from(70000. * si::M).format(FormatOption::Abbreviated),
|
||||
"70 km"
|
||||
);
|
||||
assert_eq!(
|
||||
DistanceFormatter::from(70000. * si::M).format(FormatOption::Full),
|
||||
"70 kilometers"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn it_parses_duration_values() {
|
||||
assert_eq!(
|
||||
DurationFormatter::parse("70"),
|
||||
Ok(DurationFormatter(4200. * si::S))
|
||||
);
|
||||
assert_eq!(DurationFormatter::parse("15.ab"), Err(ParseError));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn it_formats_duration_values() {
|
||||
assert_eq!(
|
||||
DurationFormatter::from(4200. * si::S).format(FormatOption::Abbreviated),
|
||||
"1h 10m"
|
||||
);
|
||||
assert_eq!(
|
||||
DurationFormatter::from(4200. * si::S).format(FormatOption::Full),
|
||||
"1 hours 10 minutes"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn it_parses_time_values() {
|
||||
assert_eq!(
|
||||
TimeFormatter::parse("13:25"),
|
||||
Ok(TimeFormatter::from(
|
||||
chrono::NaiveTime::from_hms_opt(13, 25, 0).unwrap()
|
||||
)),
|
||||
);
|
||||
assert_eq!(
|
||||
TimeFormatter::parse("13:25:50"),
|
||||
Ok(TimeFormatter::from(
|
||||
chrono::NaiveTime::from_hms_opt(13, 25, 50).unwrap()
|
||||
)),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn it_formats_time_values() {
|
||||
let time = TimeFormatter::from(chrono::NaiveTime::from_hms_opt(13, 25, 50).unwrap());
|
||||
assert_eq!(time.format(FormatOption::Abbreviated), "13:25".to_owned());
|
||||
assert_eq!(time.format(FormatOption::Full), "13:25:50".to_owned());
|
||||
}
|
||||
}
|
|
@ -0,0 +1,657 @@
|
|||
/*
|
||||
Copyright 2024, Savanni D'Gerinel <savanni@luminescent-dreams.com>
|
||||
|
||||
This file is part of FitnessTrax.
|
||||
|
||||
FitnessTrax is free software: you can redistribute it and/or modify it under the terms of the GNU
|
||||
General Public License as published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
FitnessTrax is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without
|
||||
even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
|
||||
General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License along with FitnessTrax. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
use crate::app::{ReadError, RecordProvider};
|
||||
use dimensioned::si;
|
||||
use emseries::{Record, RecordId, Recordable};
|
||||
use ft_core::{TimeDistance, TimeDistanceActivity, TraxRecord};
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
ops::Deref,
|
||||
sync::{Arc, RwLock},
|
||||
};
|
||||
|
||||
// These are actually a used imports. Clippy isn't detecting their use, probably because of complexity around the async trait macros.
|
||||
#[allow(unused_imports)]
|
||||
use crate::app::WriteError;
|
||||
#[allow(unused_imports)]
|
||||
use chrono::NaiveDate;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
enum RecordState<T: Clone + Recordable> {
|
||||
Original(Record<T>),
|
||||
New(Record<T>),
|
||||
Updated(Record<T>),
|
||||
Deleted(Record<T>),
|
||||
}
|
||||
|
||||
impl<T: Clone + emseries::Recordable> RecordState<T> {
|
||||
fn exists(&self) -> bool {
|
||||
match self {
|
||||
RecordState::Original(_) => true,
|
||||
RecordState::New(_) => true,
|
||||
RecordState::Updated(_) => true,
|
||||
RecordState::Deleted(_) => false,
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(unused)]
|
||||
fn data(&self) -> Option<&Record<T>> {
|
||||
match self {
|
||||
RecordState::Original(ref r) => Some(r),
|
||||
RecordState::New(ref r) => None,
|
||||
RecordState::Updated(ref r) => Some(r),
|
||||
RecordState::Deleted(ref r) => Some(r),
|
||||
}
|
||||
}
|
||||
|
||||
fn set_value(&mut self, value: T) {
|
||||
*self = match self {
|
||||
RecordState::Original(r) => RecordState::Updated(Record { data: value, ..*r }),
|
||||
RecordState::New(r) => RecordState::New(Record { data: value, ..*r }),
|
||||
RecordState::Updated(r) => RecordState::Updated(Record { data: value, ..*r }),
|
||||
RecordState::Deleted(r) => RecordState::Updated(Record { data: value, ..*r }),
|
||||
};
|
||||
}
|
||||
|
||||
fn with_value(mut self, value: T) -> RecordState<T> {
|
||||
self.set_value(value);
|
||||
self
|
||||
}
|
||||
|
||||
#[allow(unused)]
|
||||
fn with_delete(self) -> Option<RecordState<T>> {
|
||||
match self {
|
||||
RecordState::Original(r) => Some(RecordState::Deleted(r)),
|
||||
RecordState::New(r) => None,
|
||||
RecordState::Updated(r) => Some(RecordState::Deleted(r)),
|
||||
RecordState::Deleted(r) => Some(RecordState::Deleted(r)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: Clone + emseries::Recordable> Deref for RecordState<T> {
|
||||
type Target = T;
|
||||
fn deref(&self) -> &Self::Target {
|
||||
match self {
|
||||
RecordState::Original(ref r) => &r.data,
|
||||
RecordState::New(ref r) => &r.data,
|
||||
RecordState::Updated(ref r) => &r.data,
|
||||
RecordState::Deleted(ref r) => &r.data,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: Clone + emseries::Recordable> std::ops::DerefMut for RecordState<T> {
|
||||
fn deref_mut(&mut self) -> &mut Self::Target {
|
||||
match self {
|
||||
RecordState::Original(ref mut r) => &mut r.data,
|
||||
RecordState::New(ref mut r) => &mut r.data,
|
||||
RecordState::Updated(ref mut r) => &mut r.data,
|
||||
RecordState::Deleted(ref mut r) => &mut r.data,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct DayDetailViewModel {
|
||||
provider: Arc<dyn RecordProvider>,
|
||||
pub date: chrono::NaiveDate,
|
||||
weight: Arc<RwLock<Option<RecordState<ft_core::Weight>>>>,
|
||||
steps: Arc<RwLock<Option<RecordState<ft_core::Steps>>>>,
|
||||
records: Arc<RwLock<HashMap<RecordId, RecordState<TraxRecord>>>>,
|
||||
}
|
||||
|
||||
impl DayDetailViewModel {
|
||||
pub async fn new(
|
||||
date: chrono::NaiveDate,
|
||||
provider: impl RecordProvider + 'static,
|
||||
) -> Result<Self, ReadError> {
|
||||
let s = Self {
|
||||
provider: Arc::new(provider),
|
||||
date,
|
||||
weight: Arc::new(RwLock::new(None)),
|
||||
steps: Arc::new(RwLock::new(None)),
|
||||
records: Arc::new(RwLock::new(HashMap::new())),
|
||||
};
|
||||
s.populate_records().await;
|
||||
Ok(s)
|
||||
}
|
||||
|
||||
pub fn weight(&self) -> Option<si::Kilogram<f64>> {
|
||||
(*self.weight.read().unwrap()).as_ref().map(|w| w.weight)
|
||||
}
|
||||
|
||||
pub fn set_weight(&self, new_weight: si::Kilogram<f64>) {
|
||||
let mut record = self.weight.write().unwrap();
|
||||
let new_record = match *record {
|
||||
Some(ref rstate) => rstate.clone().with_value(ft_core::Weight {
|
||||
date: self.date,
|
||||
weight: new_weight,
|
||||
}),
|
||||
None => RecordState::New(Record {
|
||||
id: RecordId::default(),
|
||||
data: ft_core::Weight {
|
||||
date: self.date,
|
||||
weight: new_weight,
|
||||
},
|
||||
}),
|
||||
};
|
||||
*record = Some(new_record);
|
||||
}
|
||||
|
||||
pub fn steps(&self) -> Option<u32> {
|
||||
(*self.steps.read().unwrap()).as_ref().map(|w| w.count)
|
||||
}
|
||||
|
||||
pub fn set_steps(&self, new_count: u32) {
|
||||
let mut record = self.steps.write().unwrap();
|
||||
let new_record = match *record {
|
||||
Some(ref rstate) => rstate.clone().with_value(ft_core::Steps {
|
||||
date: self.date,
|
||||
count: new_count,
|
||||
}),
|
||||
None => RecordState::New(Record {
|
||||
id: RecordId::default(),
|
||||
data: ft_core::Steps {
|
||||
date: self.date,
|
||||
count: new_count,
|
||||
},
|
||||
}),
|
||||
};
|
||||
*record = Some(new_record);
|
||||
}
|
||||
|
||||
pub fn new_time_distance(&self, activity: TimeDistanceActivity) -> Record<TimeDistance> {
|
||||
let now = chrono::Local::now();
|
||||
let base_time = now.time();
|
||||
let tz = now.timezone();
|
||||
let datetime = self
|
||||
.date
|
||||
.clone()
|
||||
.and_time(base_time)
|
||||
.and_local_timezone(tz)
|
||||
.unwrap()
|
||||
.into();
|
||||
|
||||
let id = RecordId::default();
|
||||
let workout = TimeDistance {
|
||||
datetime,
|
||||
activity,
|
||||
distance: None,
|
||||
duration: None,
|
||||
comments: None,
|
||||
};
|
||||
let tr = TraxRecord::from(workout.clone());
|
||||
self.records
|
||||
.write()
|
||||
.unwrap()
|
||||
.insert(id, RecordState::New(Record { id, data: tr }));
|
||||
Record { id, data: workout }
|
||||
}
|
||||
|
||||
pub fn time_distance_records(&self) -> Vec<Record<TimeDistance>> {
|
||||
self.records
|
||||
.read()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.filter(|(_, record)| record.exists())
|
||||
.filter_map(|(id, record_state)| match **record_state {
|
||||
TraxRecord::TimeDistance(ref workout) => Some(Record {
|
||||
id: *id,
|
||||
data: workout.clone(),
|
||||
}),
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn time_distance_summary(
|
||||
&self,
|
||||
activity: TimeDistanceActivity,
|
||||
) -> (si::Meter<f64>, si::Second<f64>) {
|
||||
self.time_distance_records()
|
||||
.into_iter()
|
||||
.filter(|rec| rec.data.activity == activity)
|
||||
.fold(
|
||||
(0. * si::M, 0. * si::S),
|
||||
|(distance, duration), workout| match (workout.data.distance, workout.data.duration)
|
||||
{
|
||||
(Some(distance_), Some(duration_)) => {
|
||||
(distance + distance_, duration + duration_)
|
||||
}
|
||||
(Some(distance_), None) => (distance + distance_, duration),
|
||||
(None, Some(duration_)) => (distance, duration + duration_),
|
||||
(None, None) => (distance, duration),
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
pub fn update_record(&self, update: Record<TraxRecord>) {
|
||||
let mut records = self.records.write().unwrap();
|
||||
records
|
||||
.entry(update.id)
|
||||
.and_modify(|record| record.set_value(update.data));
|
||||
}
|
||||
|
||||
pub fn records(&self) -> Vec<Record<TraxRecord>> {
|
||||
let read_lock = self.records.read().unwrap();
|
||||
read_lock
|
||||
.iter()
|
||||
.filter_map(|(_, record_state)| record_state.data())
|
||||
.cloned()
|
||||
.collect::<Vec<Record<TraxRecord>>>()
|
||||
}
|
||||
|
||||
#[allow(unused)]
|
||||
fn get_record(&self, id: &RecordId) -> Option<Record<TraxRecord>> {
|
||||
let record_set = self.records.read().unwrap();
|
||||
record_set.get(id).map(|record| Record {
|
||||
id: *id,
|
||||
data: (**record).clone(),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn remove_record(&self, id: RecordId) {
|
||||
let mut record_set = self.records.write().unwrap();
|
||||
let updated_record = match record_set.remove(&id) {
|
||||
Some(RecordState::Original(r)) => Some(RecordState::Deleted(r)),
|
||||
Some(RecordState::New(_)) => None,
|
||||
Some(RecordState::Updated(r)) => Some(RecordState::Deleted(r)),
|
||||
Some(RecordState::Deleted(r)) => Some(RecordState::Deleted(r)),
|
||||
None => None,
|
||||
};
|
||||
if let Some(updated_record) = updated_record {
|
||||
record_set.insert(id, updated_record);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn save(&self) {
|
||||
let s = self.clone();
|
||||
|
||||
glib::spawn_future(async move { s.async_save().await });
|
||||
}
|
||||
|
||||
pub async fn async_save(&self) {
|
||||
let weight_record = self.weight.read().unwrap().clone();
|
||||
match weight_record {
|
||||
Some(RecordState::New(data)) => {
|
||||
let _ = self
|
||||
.provider
|
||||
.put_record(TraxRecord::Weight(data.data))
|
||||
.await;
|
||||
}
|
||||
Some(RecordState::Original(_)) => {}
|
||||
Some(RecordState::Updated(weight)) => {
|
||||
let _ = self
|
||||
.provider
|
||||
.update_record(Record {
|
||||
id: weight.id,
|
||||
data: TraxRecord::Weight(weight.data),
|
||||
})
|
||||
.await;
|
||||
}
|
||||
Some(RecordState::Deleted(_)) => {}
|
||||
None => {}
|
||||
}
|
||||
|
||||
let steps_record = self.steps.read().unwrap().clone();
|
||||
match steps_record {
|
||||
Some(RecordState::New(data)) => {
|
||||
let _ = self.provider.put_record(TraxRecord::Steps(data.data)).await;
|
||||
}
|
||||
Some(RecordState::Original(_)) => {}
|
||||
Some(RecordState::Updated(steps)) => {
|
||||
let _ = self
|
||||
.provider
|
||||
.update_record(Record {
|
||||
id: steps.id,
|
||||
data: TraxRecord::Steps(steps.data),
|
||||
})
|
||||
.await;
|
||||
}
|
||||
Some(RecordState::Deleted(_)) => {}
|
||||
None => {}
|
||||
}
|
||||
|
||||
let records = self
|
||||
.records
|
||||
.write()
|
||||
.unwrap()
|
||||
.drain()
|
||||
.map(|(_, record)| record)
|
||||
.collect::<Vec<RecordState<TraxRecord>>>();
|
||||
|
||||
for record in records {
|
||||
println!("saving record: {:?}", record);
|
||||
match record {
|
||||
RecordState::New(data) => {
|
||||
let _ = self.provider.put_record(data.data).await;
|
||||
}
|
||||
RecordState::Original(_) => {}
|
||||
RecordState::Updated(r) => {
|
||||
let _ = self.provider.update_record(r.clone()).await;
|
||||
}
|
||||
RecordState::Deleted(r) => {
|
||||
let _ = self.provider.delete_record(r.id).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
self.populate_records().await;
|
||||
}
|
||||
|
||||
pub async fn revert(&self) {
|
||||
self.populate_records().await;
|
||||
}
|
||||
|
||||
async fn populate_records(&self) {
|
||||
let records = self.provider.records(self.date, self.date).await.unwrap();
|
||||
|
||||
let (weight_records, records): (Vec<Record<TraxRecord>>, Vec<Record<TraxRecord>>) =
|
||||
records.into_iter().partition(|r| r.data.is_weight());
|
||||
let (step_records, records): (Vec<Record<TraxRecord>>, Vec<Record<TraxRecord>>) =
|
||||
records.into_iter().partition(|r| r.data.is_steps());
|
||||
|
||||
*self.weight.write().unwrap() = weight_records
|
||||
.first()
|
||||
.and_then(|r| match r.data {
|
||||
TraxRecord::Weight(ref w) => Some((r.id, w.clone())),
|
||||
_ => None,
|
||||
})
|
||||
.map(|(id, w)| RecordState::Original(Record { id, data: w }));
|
||||
|
||||
*self.steps.write().unwrap() = step_records
|
||||
.first()
|
||||
.and_then(|r| match r.data {
|
||||
TraxRecord::Steps(ref w) => Some((r.id, w.clone())),
|
||||
_ => None,
|
||||
})
|
||||
.map(|(id, w)| RecordState::Original(Record { id, data: w }));
|
||||
|
||||
*self.records.write().unwrap() = records
|
||||
.into_iter()
|
||||
.map(|r| (r.id, RecordState::Original(r)))
|
||||
.collect::<HashMap<RecordId, RecordState<TraxRecord>>>();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod test {
|
||||
use super::*;
|
||||
use async_trait::async_trait;
|
||||
use chrono::{DateTime, FixedOffset};
|
||||
use dimensioned::si;
|
||||
use emseries::Record;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
struct MockProvider {
|
||||
records: Arc<RwLock<HashMap<RecordId, Record<TraxRecord>>>>,
|
||||
|
||||
put_records: Arc<RwLock<Vec<Record<TraxRecord>>>>,
|
||||
updated_records: Arc<RwLock<Vec<Record<TraxRecord>>>>,
|
||||
deleted_records: Arc<RwLock<Vec<RecordId>>>,
|
||||
}
|
||||
|
||||
impl MockProvider {
|
||||
fn new(records: Vec<Record<TraxRecord>>) -> Self {
|
||||
let record_map = records
|
||||
.into_iter()
|
||||
.map(|r| (r.id, r))
|
||||
.collect::<HashMap<RecordId, Record<TraxRecord>>>();
|
||||
Self {
|
||||
records: Arc::new(RwLock::new(record_map)),
|
||||
put_records: Arc::new(RwLock::new(vec![])),
|
||||
updated_records: Arc::new(RwLock::new(vec![])),
|
||||
deleted_records: Arc::new(RwLock::new(vec![])),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl RecordProvider for MockProvider {
|
||||
async fn records(
|
||||
&self,
|
||||
start: NaiveDate,
|
||||
end: NaiveDate,
|
||||
) -> Result<Vec<Record<TraxRecord>>, ReadError> {
|
||||
let start = emseries::Timestamp::Date(start);
|
||||
let end = emseries::Timestamp::Date(end);
|
||||
Ok(self
|
||||
.records
|
||||
.read()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|(_, r)| r)
|
||||
.filter(|r| r.timestamp() >= start && r.timestamp() <= end)
|
||||
.cloned()
|
||||
.collect::<Vec<Record<TraxRecord>>>())
|
||||
}
|
||||
|
||||
async fn put_record(&self, record: TraxRecord) -> Result<RecordId, WriteError> {
|
||||
let id = RecordId::default();
|
||||
let record = Record {
|
||||
id: id,
|
||||
data: record,
|
||||
};
|
||||
self.put_records.write().unwrap().push(record.clone());
|
||||
self.records.write().unwrap().insert(id, record);
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
async fn update_record(&self, record: Record<TraxRecord>) -> Result<(), WriteError> {
|
||||
println!("updated record: {:?}", record);
|
||||
self.updated_records.write().unwrap().push(record.clone());
|
||||
self.records.write().unwrap().insert(record.id, record);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn delete_record(&self, id: RecordId) -> Result<(), WriteError> {
|
||||
self.deleted_records.write().unwrap().push(id);
|
||||
let _ = self.records.write().unwrap().remove(&id);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
async fn create_empty_view_model() -> (DayDetailViewModel, MockProvider) {
|
||||
let provider = MockProvider::new(vec![]);
|
||||
|
||||
let oct_13 = chrono::NaiveDate::from_ymd_opt(2023, 10, 13).unwrap();
|
||||
let model = DayDetailViewModel::new(oct_13, provider.clone())
|
||||
.await
|
||||
.unwrap();
|
||||
(model, provider)
|
||||
}
|
||||
|
||||
async fn create_view_model() -> (DayDetailViewModel, MockProvider) {
|
||||
let oct_12 = chrono::NaiveDate::from_ymd_opt(2023, 10, 12).unwrap();
|
||||
let oct_13 = chrono::NaiveDate::from_ymd_opt(2023, 10, 13).unwrap();
|
||||
let oct_13_am: DateTime<FixedOffset> = oct_13
|
||||
.clone()
|
||||
.and_hms_opt(3, 28, 0)
|
||||
.unwrap()
|
||||
.and_utc()
|
||||
.with_timezone(&FixedOffset::east_opt(5 * 3600).unwrap());
|
||||
let provider = MockProvider::new(vec![
|
||||
Record {
|
||||
id: RecordId::default(),
|
||||
data: TraxRecord::Weight(ft_core::Weight {
|
||||
date: oct_12,
|
||||
weight: 93. * si::KG,
|
||||
}),
|
||||
},
|
||||
Record {
|
||||
id: RecordId::default(),
|
||||
data: TraxRecord::Weight(ft_core::Weight {
|
||||
date: oct_13,
|
||||
weight: 95. * si::KG,
|
||||
}),
|
||||
},
|
||||
Record {
|
||||
id: RecordId::default(),
|
||||
data: TraxRecord::Steps(ft_core::Steps {
|
||||
date: oct_13,
|
||||
count: 2500,
|
||||
}),
|
||||
},
|
||||
Record {
|
||||
id: RecordId::default(),
|
||||
data: TraxRecord::TimeDistance(ft_core::TimeDistance {
|
||||
datetime: oct_13_am.clone(),
|
||||
activity: TimeDistanceActivity::Biking,
|
||||
distance: Some(15000. * si::M),
|
||||
duration: Some(3600. * si::S),
|
||||
comments: Some("somecomments present".to_owned()),
|
||||
}),
|
||||
},
|
||||
]);
|
||||
let model = DayDetailViewModel::new(oct_13, provider.clone())
|
||||
.await
|
||||
.unwrap();
|
||||
(model, provider)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn it_honors_only_the_first_weight_and_step_record() {
|
||||
let (view_model, _provider) = create_view_model().await;
|
||||
assert_eq!(view_model.weight(), Some(95. * si::KG));
|
||||
assert_eq!(view_model.steps(), Some(2500));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn it_can_create_a_weight_and_stepcount() {
|
||||
let (view_model, provider) = create_empty_view_model().await;
|
||||
assert_eq!(view_model.weight(), None);
|
||||
assert_eq!(view_model.steps(), None);
|
||||
|
||||
view_model.set_weight(95. * si::KG);
|
||||
view_model.set_steps(250);
|
||||
|
||||
assert_eq!(view_model.weight(), Some(95. * si::KG));
|
||||
assert_eq!(view_model.steps(), Some(250));
|
||||
|
||||
view_model.set_weight(93. * si::KG);
|
||||
view_model.set_steps(255);
|
||||
|
||||
assert_eq!(view_model.weight(), Some(93. * si::KG));
|
||||
assert_eq!(view_model.steps(), Some(255));
|
||||
|
||||
view_model.async_save().await;
|
||||
|
||||
println!("provider: {:?}", provider);
|
||||
assert_eq!(provider.put_records.read().unwrap().len(), 2);
|
||||
assert_eq!(provider.updated_records.read().unwrap().len(), 0);
|
||||
assert_eq!(provider.deleted_records.read().unwrap().len(), 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn it_can_construct_new_records() {
|
||||
let (view_model, provider) = create_empty_view_model().await;
|
||||
assert_eq!(
|
||||
view_model.time_distance_summary(TimeDistanceActivity::Biking),
|
||||
(0. * si::M, 0. * si::S)
|
||||
);
|
||||
|
||||
let mut record = view_model.new_time_distance(TimeDistanceActivity::Biking);
|
||||
record.data.duration = Some(60. * si::S);
|
||||
view_model.async_save().await;
|
||||
|
||||
assert_eq!(provider.put_records.read().unwrap().len(), 1);
|
||||
assert_eq!(provider.updated_records.read().unwrap().len(), 0);
|
||||
assert_eq!(provider.deleted_records.read().unwrap().len(), 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn it_can_update_a_new_record_before_saving() {
|
||||
let (view_model, provider) = create_empty_view_model().await;
|
||||
assert_eq!(
|
||||
view_model.time_distance_summary(TimeDistanceActivity::Biking),
|
||||
(0. * si::M, 0. * si::S)
|
||||
);
|
||||
|
||||
let mut record = view_model.new_time_distance(TimeDistanceActivity::Biking);
|
||||
record.data.duration = Some(60. * si::S);
|
||||
let record = record.map(TraxRecord::TimeDistance);
|
||||
view_model.update_record(record.clone());
|
||||
assert_eq!(view_model.get_record(&record.id), Some(record));
|
||||
assert_eq!(
|
||||
view_model.time_distance_summary(TimeDistanceActivity::Biking),
|
||||
(0. * si::M, 60. * si::S)
|
||||
);
|
||||
assert_eq!(
|
||||
view_model.time_distance_summary(TimeDistanceActivity::Running),
|
||||
(0. * si::M, 0. * si::S)
|
||||
);
|
||||
view_model.async_save().await;
|
||||
|
||||
assert_eq!(provider.put_records.read().unwrap().len(), 1);
|
||||
assert_eq!(provider.updated_records.read().unwrap().len(), 0);
|
||||
assert_eq!(provider.deleted_records.read().unwrap().len(), 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn it_can_update_an_existing_record() {
|
||||
let (view_model, provider) = create_view_model().await;
|
||||
let mut workout = view_model.time_distance_records().first().cloned().unwrap();
|
||||
|
||||
workout.data.duration = Some(1800. * si::S);
|
||||
view_model.update_record(workout.map(TraxRecord::TimeDistance));
|
||||
|
||||
assert_eq!(
|
||||
view_model.time_distance_summary(TimeDistanceActivity::Biking),
|
||||
(15000. * si::M, 1800. * si::S)
|
||||
);
|
||||
|
||||
view_model.async_save().await;
|
||||
|
||||
assert_eq!(provider.put_records.read().unwrap().len(), 0);
|
||||
assert_eq!(provider.updated_records.read().unwrap().len(), 1);
|
||||
assert_eq!(provider.deleted_records.read().unwrap().len(), 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn it_can_remove_a_new_record() {
|
||||
let (view_model, provider) = create_empty_view_model().await;
|
||||
assert_eq!(
|
||||
view_model.time_distance_summary(TimeDistanceActivity::Biking),
|
||||
(0. * si::M, 0. * si::S)
|
||||
);
|
||||
|
||||
let record = view_model.new_time_distance(TimeDistanceActivity::Biking);
|
||||
view_model.remove_record(record.id);
|
||||
view_model.save();
|
||||
|
||||
assert_eq!(provider.put_records.read().unwrap().len(), 0);
|
||||
assert_eq!(provider.updated_records.read().unwrap().len(), 0);
|
||||
assert_eq!(provider.deleted_records.read().unwrap().len(), 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn it_can_delete_an_existing_record() {
|
||||
let (view_model, provider) = create_view_model().await;
|
||||
let workout = view_model.time_distance_records().first().cloned().unwrap();
|
||||
|
||||
view_model.remove_record(workout.id);
|
||||
assert_eq!(
|
||||
view_model.time_distance_summary(TimeDistanceActivity::Biking),
|
||||
(0. * si::M, 0. * si::S)
|
||||
);
|
||||
view_model.async_save().await;
|
||||
|
||||
assert_eq!(provider.put_records.read().unwrap().len(), 0);
|
||||
assert_eq!(provider.updated_records.read().unwrap().len(), 0);
|
||||
assert_eq!(provider.deleted_records.read().unwrap().len(), 1);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,18 @@
|
|||
/*
|
||||
Copyright 2024, Savanni D'Gerinel <savanni@luminescent-dreams.com>
|
||||
|
||||
This file is part of FitnessTrax.
|
||||
|
||||
FitnessTrax is free software: you can redistribute it and/or modify it under the terms of the GNU
|
||||
General Public License as published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
FitnessTrax is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without
|
||||
even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
|
||||
General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License along with FitnessTrax. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
mod day_detail;
|
||||
pub use day_detail::DayDetailViewModel;
|
|
@ -0,0 +1,84 @@
|
|||
/*
|
||||
Copyright 2024, Savanni D'Gerinel <savanni@luminescent-dreams.com>
|
||||
|
||||
This file is part of FitnessTrax.
|
||||
|
||||
FitnessTrax is free software: you can redistribute it and/or modify it under the terms of the GNU
|
||||
General Public License as published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
FitnessTrax is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without
|
||||
even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
|
||||
General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License along with FitnessTrax. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
use crate::{
|
||||
components::{DayDetail, DayEdit, Singleton, SingletonImpl},
|
||||
view_models::DayDetailViewModel,
|
||||
};
|
||||
use glib::Object;
|
||||
use gtk::{prelude::*, subclass::prelude::*};
|
||||
use std::cell::RefCell;
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct DayDetailViewPrivate {
|
||||
container: Singleton,
|
||||
view_model: RefCell<Option<DayDetailViewModel>>,
|
||||
}
|
||||
|
||||
#[glib::object_subclass]
|
||||
impl ObjectSubclass for DayDetailViewPrivate {
|
||||
const NAME: &'static str = "DayDetailView";
|
||||
type Type = DayDetailView;
|
||||
type ParentType = gtk::Box;
|
||||
}
|
||||
|
||||
impl ObjectImpl for DayDetailViewPrivate {}
|
||||
impl WidgetImpl for DayDetailViewPrivate {}
|
||||
impl BoxImpl for DayDetailViewPrivate {}
|
||||
impl SingletonImpl for DayDetailViewPrivate {}
|
||||
|
||||
glib::wrapper! {
|
||||
pub struct DayDetailView(ObjectSubclass<DayDetailViewPrivate>) @extends gtk::Box, gtk::Widget;
|
||||
}
|
||||
|
||||
impl DayDetailView {
|
||||
pub fn new(view_model: DayDetailViewModel) -> Self {
|
||||
let s: Self = Object::builder().build();
|
||||
*s.imp().view_model.borrow_mut() = Some(view_model);
|
||||
|
||||
s.append(&s.imp().container);
|
||||
|
||||
s.view();
|
||||
|
||||
s
|
||||
}
|
||||
|
||||
fn view(&self) {
|
||||
let view_model = self.imp().view_model.borrow();
|
||||
let view_model = view_model
|
||||
.as_ref()
|
||||
.expect("DayDetailView has not been initialized with a view_model")
|
||||
.clone();
|
||||
|
||||
self.imp().container.swap(&DayDetail::new(view_model, {
|
||||
let s = self.clone();
|
||||
move || s.edit()
|
||||
}));
|
||||
}
|
||||
|
||||
fn edit(&self) {
|
||||
let view_model = self.imp().view_model.borrow();
|
||||
let view_model = view_model
|
||||
.as_ref()
|
||||
.expect("DayDetailView has not been initialized with a view_model")
|
||||
.clone();
|
||||
|
||||
self.imp().container.swap(&DayEdit::new(view_model, {
|
||||
let s = self.clone();
|
||||
move || s.view()
|
||||
}));
|
||||
}
|
||||
}
|
|
@ -0,0 +1,182 @@
|
|||
/*
|
||||
Copyright 2023, Savanni D'Gerinel <savanni@luminescent-dreams.com>
|
||||
|
||||
This file is part of FitnessTrax.
|
||||
|
||||
FitnessTrax is free software: you can redistribute it and/or modify it under the terms of the GNU
|
||||
General Public License as published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
FitnessTrax is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without
|
||||
even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
|
||||
General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License along with FitnessTrax. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
use crate::{
|
||||
app::App,
|
||||
components::{DateRangePicker, DaySummary},
|
||||
types::DayInterval,
|
||||
view_models::DayDetailViewModel,
|
||||
};
|
||||
use glib::Object;
|
||||
use gtk::{prelude::*, subclass::prelude::*};
|
||||
use std::{cell::RefCell, rc::Rc};
|
||||
|
||||
/// The historical view will show a window into the main database. It will show some version of
|
||||
/// daily summaries, daily details, and will provide all functions the user may need for editing
|
||||
/// records.
|
||||
pub struct HistoricalViewPrivate {
|
||||
app: Rc<RefCell<Option<App>>>,
|
||||
list_view: gtk::ListView,
|
||||
date_range_picker: DateRangePicker,
|
||||
}
|
||||
|
||||
#[glib::object_subclass]
|
||||
impl ObjectSubclass for HistoricalViewPrivate {
|
||||
const NAME: &'static str = "HistoricalView";
|
||||
type Type = HistoricalView;
|
||||
type ParentType = gtk::Box;
|
||||
|
||||
fn new() -> Self {
|
||||
let factory = gtk::SignalListItemFactory::new();
|
||||
factory.connect_setup(move |_, list_item| {
|
||||
list_item
|
||||
.downcast_ref::<gtk::ListItem>()
|
||||
.expect("should be a ListItem")
|
||||
.set_child(Some(&DaySummary::new()));
|
||||
});
|
||||
|
||||
let date_range_picker = DateRangePicker::default();
|
||||
|
||||
let s = Self {
|
||||
app: Rc::new(RefCell::new(None)),
|
||||
list_view: gtk::ListView::builder()
|
||||
.factory(&factory)
|
||||
.single_click_activate(true)
|
||||
.show_separators(true)
|
||||
.build(),
|
||||
date_range_picker,
|
||||
};
|
||||
|
||||
factory.connect_bind({
|
||||
let app = s.app.clone();
|
||||
move |_, list_item| {
|
||||
let date = list_item
|
||||
.downcast_ref::<gtk::ListItem>()
|
||||
.expect("should be a ListItem")
|
||||
.item()
|
||||
.and_downcast::<Date>()
|
||||
.expect("should be a Date");
|
||||
|
||||
let summary = list_item
|
||||
.downcast_ref::<gtk::ListItem>()
|
||||
.expect("should be a ListItem")
|
||||
.child()
|
||||
.and_downcast::<DaySummary>()
|
||||
.expect("should be a DaySummary");
|
||||
|
||||
if let Some(app) = app.borrow().clone() {
|
||||
glib::spawn_future_local(async move {
|
||||
let view_model = DayDetailViewModel::new(date.date(), app.clone())
|
||||
.await
|
||||
.unwrap();
|
||||
summary.set_data(view_model);
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
s
|
||||
}
|
||||
}
|
||||
|
||||
impl ObjectImpl for HistoricalViewPrivate {}
|
||||
impl WidgetImpl for HistoricalViewPrivate {}
|
||||
impl BoxImpl for HistoricalViewPrivate {}
|
||||
|
||||
glib::wrapper! {
|
||||
pub struct HistoricalView(ObjectSubclass<HistoricalViewPrivate>) @extends gtk::Box, gtk::Widget, @implements gtk::Orientable;
|
||||
}
|
||||
|
||||
impl HistoricalView {
|
||||
pub fn new<SelectFn>(app: App, interval: DayInterval, on_select_day: Rc<SelectFn>) -> Self
|
||||
where
|
||||
SelectFn: Fn(chrono::NaiveDate) + 'static,
|
||||
{
|
||||
let s: Self = Object::builder().build();
|
||||
s.set_orientation(gtk::Orientation::Vertical);
|
||||
s.set_css_classes(&["historical"]);
|
||||
|
||||
*s.imp().app.borrow_mut() = Some(app);
|
||||
|
||||
s.imp().date_range_picker.connect_on_search({
|
||||
let s = s.clone();
|
||||
move |interval| s.set_interval(interval)
|
||||
});
|
||||
s.set_interval(interval);
|
||||
|
||||
s.imp().list_view.connect_activate({
|
||||
let on_select_day = on_select_day.clone();
|
||||
move |s, idx| {
|
||||
// This gets triggered whenever the user clicks on an item on the list.
|
||||
let item = s.model().unwrap().item(idx).unwrap();
|
||||
let date = item.downcast_ref::<Date>().unwrap();
|
||||
on_select_day(date.date());
|
||||
}
|
||||
});
|
||||
|
||||
let scroller = gtk::ScrolledWindow::builder()
|
||||
.child(&s.imp().list_view)
|
||||
.hexpand(true)
|
||||
.vexpand(true)
|
||||
.hscrollbar_policy(gtk::PolicyType::Never)
|
||||
.build();
|
||||
|
||||
s.append(&s.imp().date_range_picker);
|
||||
s.append(&scroller);
|
||||
|
||||
s
|
||||
}
|
||||
|
||||
pub fn set_interval(&self, interval: DayInterval) {
|
||||
let mut model = gio::ListStore::new::<Date>();
|
||||
let mut days = interval.days().map(Date::new).collect::<Vec<Date>>();
|
||||
days.reverse();
|
||||
model.extend(days.into_iter());
|
||||
self.imp()
|
||||
.list_view
|
||||
.set_model(Some(>k::NoSelection::new(Some(model))));
|
||||
self.imp().date_range_picker.set_interval(interval.start, interval.end);
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct DatePrivate {
|
||||
date: RefCell<chrono::NaiveDate>,
|
||||
}
|
||||
|
||||
#[glib::object_subclass]
|
||||
impl ObjectSubclass for DatePrivate {
|
||||
const NAME: &'static str = "Date";
|
||||
type Type = Date;
|
||||
}
|
||||
|
||||
impl ObjectImpl for DatePrivate {}
|
||||
|
||||
glib::wrapper! {
|
||||
pub struct Date(ObjectSubclass<DatePrivate>);
|
||||
}
|
||||
|
||||
impl Date {
|
||||
pub fn new(date: chrono::NaiveDate) -> Self {
|
||||
let s: Self = Object::builder().build();
|
||||
*s.imp().date.borrow_mut() = date;
|
||||
s
|
||||
}
|
||||
|
||||
pub fn date(&self) -> chrono::NaiveDate {
|
||||
*self.imp().date.borrow()
|
||||
}
|
||||
}
|
|
@ -0,0 +1,45 @@
|
|||
/*
|
||||
Copyright 2023, Savanni D'Gerinel <savanni@luminescent-dreams.com>
|
||||
|
||||
This file is part of FitnessTrax.
|
||||
|
||||
FitnessTrax is free software: you can redistribute it and/or modify it under the terms of the GNU
|
||||
General Public License as published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
FitnessTrax is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without
|
||||
even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
|
||||
General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License along with FitnessTrax. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
use gtk::prelude::*;
|
||||
|
||||
mod day_detail_view;
|
||||
pub use day_detail_view::DayDetailView;
|
||||
|
||||
mod historical_view;
|
||||
pub use historical_view::HistoricalView;
|
||||
|
||||
mod placeholder_view;
|
||||
pub use placeholder_view::PlaceholderView;
|
||||
|
||||
mod welcome_view;
|
||||
pub use welcome_view::WelcomeView;
|
||||
|
||||
pub enum View {
|
||||
Placeholder(PlaceholderView),
|
||||
Welcome(WelcomeView),
|
||||
Historical(HistoricalView),
|
||||
}
|
||||
|
||||
impl View {
|
||||
pub fn widget(&self) -> gtk::Widget {
|
||||
match self {
|
||||
View::Placeholder(widget) => widget.clone().upcast::<gtk::Widget>(),
|
||||
View::Welcome(widget) => widget.clone().upcast::<gtk::Widget>(),
|
||||
View::Historical(widget) => widget.clone().upcast::<gtk::Widget>(),
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,46 @@
|
|||
/*
|
||||
Copyright 2023, Savanni D'Gerinel <savanni@luminescent-dreams.com>
|
||||
|
||||
This file is part of FitnessTrax.
|
||||
|
||||
FitnessTrax is free software: you can redistribute it and/or modify it under the terms of the GNU
|
||||
General Public License as published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
FitnessTrax is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without
|
||||
even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
|
||||
General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License along with FitnessTrax. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
use glib::Object;
|
||||
use gtk::subclass::prelude::*;
|
||||
|
||||
pub struct PlaceholderViewPrivate {}
|
||||
|
||||
#[glib::object_subclass]
|
||||
impl ObjectSubclass for PlaceholderViewPrivate {
|
||||
const NAME: &'static str = "PlaceholderView";
|
||||
type Type = PlaceholderView;
|
||||
type ParentType = gtk::Box;
|
||||
|
||||
fn new() -> Self {
|
||||
Self {}
|
||||
}
|
||||
}
|
||||
|
||||
impl ObjectImpl for PlaceholderViewPrivate {}
|
||||
impl WidgetImpl for PlaceholderViewPrivate {}
|
||||
impl BoxImpl for PlaceholderViewPrivate {}
|
||||
|
||||
glib::wrapper! {
|
||||
pub struct PlaceholderView(ObjectSubclass<PlaceholderViewPrivate>) @extends gtk::Box, gtk::Widget;
|
||||
}
|
||||
|
||||
impl Default for PlaceholderView {
|
||||
fn default() -> Self {
|
||||
let s: Self = Object::builder().build();
|
||||
s
|
||||
}
|
||||
}
|
|
@ -0,0 +1,97 @@
|
|||
/*
|
||||
Copyright 2023, Savanni D'Gerinel <savanni@luminescent-dreams.com>
|
||||
|
||||
This file is part of FitnessTrax.
|
||||
|
||||
FitnessTrax is free software: you can redistribute it and/or modify it under the terms of the GNU
|
||||
General Public License as published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
FitnessTrax is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without
|
||||
even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
|
||||
General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License along with FitnessTrax. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
use crate::components::FileChooserRow;
|
||||
use glib::Object;
|
||||
use gtk::{prelude::*, subclass::prelude::*};
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// This is the view to show if the application has not yet been configured. It will walk the user
|
||||
/// through the most critical setup steps so that we can move on to the other views in the app.
|
||||
pub struct WelcomeViewPrivate {}
|
||||
|
||||
#[glib::object_subclass]
|
||||
impl ObjectSubclass for WelcomeViewPrivate {
|
||||
const NAME: &'static str = "WelcomeView";
|
||||
type Type = WelcomeView;
|
||||
type ParentType = gtk::Box;
|
||||
|
||||
fn new() -> Self {
|
||||
Self {}
|
||||
}
|
||||
}
|
||||
|
||||
impl ObjectImpl for WelcomeViewPrivate {}
|
||||
impl WidgetImpl for WelcomeViewPrivate {}
|
||||
impl BoxImpl for WelcomeViewPrivate {}
|
||||
|
||||
glib::wrapper! {
|
||||
pub struct WelcomeView(ObjectSubclass<WelcomeViewPrivate>) @extends gtk::Box, gtk::Widget, @implements gtk::Orientable;
|
||||
}
|
||||
|
||||
impl WelcomeView {
|
||||
pub fn new<OnSave>(on_save: OnSave) -> Self
|
||||
where
|
||||
OnSave: Fn(PathBuf) + 'static,
|
||||
{
|
||||
let s: Self = Object::builder().build();
|
||||
s.set_orientation(gtk::Orientation::Vertical);
|
||||
s.set_css_classes(&["welcome"]);
|
||||
|
||||
// Replace this with the welcome screen that we set up in the fitnesstrax/unconfigured-page
|
||||
// branch.
|
||||
let title = gtk::Label::builder()
|
||||
.label("Welcome to FitnessTrax")
|
||||
.css_classes(["welcome__title"])
|
||||
.build();
|
||||
|
||||
let content = gtk::Box::builder()
|
||||
.orientation(gtk::Orientation::Vertical)
|
||||
.vexpand(true)
|
||||
.build();
|
||||
|
||||
let save_button = gtk::Button::builder()
|
||||
.label("Save Settings")
|
||||
.sensitive(false)
|
||||
.build();
|
||||
|
||||
// The database selection row should be a box that shows a default database path, along with a
|
||||
// button that triggers a file chooser dialog. Once the dialog returns, the box should be
|
||||
// updated to reflect the chosen path.
|
||||
let db_row = FileChooserRow::new({
|
||||
let save_button = save_button.clone();
|
||||
move |_| save_button.set_sensitive(true)
|
||||
});
|
||||
|
||||
content.append(>k::Label::new(Some("Welcome to FitnessTrax. The application has not yet been configured, so I will walk you through that. Let's start out by selecting your database.")));
|
||||
content.append(&db_row);
|
||||
|
||||
save_button.connect_clicked({
|
||||
let db_row = db_row.clone();
|
||||
move |_| {
|
||||
if let Some(path) = db_row.path() {
|
||||
on_save(path);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
s.append(&title);
|
||||
s.append(&content);
|
||||
s.append(&save_button);
|
||||
|
||||
s
|
||||
}
|
||||
}
|
|
@ -0,0 +1,19 @@
|
|||
[package]
|
||||
name = "ft-core"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||
|
||||
[dependencies]
|
||||
chrono = { version = "0.4" }
|
||||
chrono-tz = { version = "0.8" }
|
||||
dimensioned = { version = "0.8", features = [ "serde" ] }
|
||||
emseries = { path = "../../emseries" }
|
||||
serde = { version = "1", features = [ "derive" ] }
|
||||
serde_json = { version = "1" }
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "*"
|
||||
|
||||
|
|
@ -0,0 +1,328 @@
|
|||
/*
|
||||
Copyright 2024, Savanni D'Gerinel <savanni@luminescent-dreams.com>
|
||||
|
||||
This file is part of FitnessTrax.
|
||||
|
||||
FitnessTrax is free software: you can redistribute it and/or modify it under the terms of the GNU
|
||||
General Public License as published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
FitnessTrax is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without
|
||||
even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
|
||||
General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License along with FitnessTrax. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
use chrono::SecondsFormat;
|
||||
use chrono_tz::Etc::UTC;
|
||||
use dimensioned::si;
|
||||
use emseries::{Record, RecordId, Series, Timestamp};
|
||||
use ft_core::{self, DurationWorkout, DurationWorkoutActivity, SetRepActivity, TraxRecord};
|
||||
use serde::{
|
||||
de::{self, Visitor},
|
||||
Deserialize, Deserializer, Serialize, Serializer,
|
||||
};
|
||||
use std::{
|
||||
fmt,
|
||||
fs::File,
|
||||
io::{BufRead, BufReader, Read},
|
||||
str::FromStr,
|
||||
};
|
||||
|
||||
/// This is a wrapper around date time objects, using timezones from the chroon-tz database and
|
||||
/// providing string representation and parsing of the form "<RFC3339> <Timezone Name>", i.e.,
|
||||
/// "2019-05-15T14:30:00Z US/Central". The to_string method, and serde serialization will
|
||||
/// produce a string of this format. The parser will accept an RFC3339-only string of the forms
|
||||
/// "2019-05-15T14:30:00Z", "2019-05-15T14:30:00+00:00", and also an "RFC3339 Timezone Name"
|
||||
/// string.
|
||||
///
|
||||
/// The function here is to generate as close to unambiguous time/date strings, (for earth's
|
||||
/// gravitational frame of reference), as possible. Clumping together the time, offset from UTC,
|
||||
/// and the named time zone allows future parsers to know the exact interpretation of the time in
|
||||
/// the frame of reference of the original recording.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
|
||||
pub struct DateTimeTz(pub chrono::DateTime<chrono_tz::Tz>);
|
||||
|
||||
impl fmt::Display for DateTimeTz {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> Result<(), fmt::Error> {
|
||||
if self.0.timezone() == UTC {
|
||||
write!(f, "{}", self.0.to_rfc3339_opts(SecondsFormat::Secs, true))
|
||||
} else {
|
||||
write!(
|
||||
f,
|
||||
"{} {}",
|
||||
self.0
|
||||
.with_timezone(&chrono_tz::Etc::UTC)
|
||||
.to_rfc3339_opts(SecondsFormat::Secs, true,),
|
||||
self.0.timezone().name()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl DateTimeTz {
|
||||
pub fn map<F>(&self, f: F) -> DateTimeTz
|
||||
where
|
||||
F: FnOnce(chrono::DateTime<chrono_tz::Tz>) -> chrono::DateTime<chrono_tz::Tz>,
|
||||
{
|
||||
DateTimeTz(f(self.0))
|
||||
}
|
||||
}
|
||||
|
||||
impl std::str::FromStr for DateTimeTz {
|
||||
type Err = chrono::ParseError;
|
||||
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
let v: Vec<&str> = s.split_terminator(' ').collect();
|
||||
if v.len() == 2 {
|
||||
let tz = v[1].parse::<chrono_tz::Tz>().unwrap();
|
||||
chrono::DateTime::parse_from_rfc3339(v[0]).map(|ts| DateTimeTz(ts.with_timezone(&tz)))
|
||||
} else {
|
||||
chrono::DateTime::parse_from_rfc3339(v[0]).map(|ts| DateTimeTz(ts.with_timezone(&UTC)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<chrono::DateTime<chrono_tz::Tz>> for DateTimeTz {
|
||||
fn from(dt: chrono::DateTime<chrono_tz::Tz>) -> DateTimeTz {
|
||||
DateTimeTz(dt)
|
||||
}
|
||||
}
|
||||
|
||||
struct DateTimeTzVisitor;
|
||||
|
||||
impl<'de> Visitor<'de> for DateTimeTzVisitor {
|
||||
type Value = DateTimeTz;
|
||||
|
||||
fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
|
||||
formatter.write_str("a string date time representation that can be parsed")
|
||||
}
|
||||
|
||||
fn visit_str<E: de::Error>(self, s: &str) -> Result<Self::Value, E> {
|
||||
DateTimeTz::from_str(s).or(Err(E::custom(
|
||||
"string is not a parsable datetime representation".to_owned(),
|
||||
)))
|
||||
}
|
||||
}
|
||||
|
||||
impl Serialize for DateTimeTz {
|
||||
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
|
||||
serializer.serialize_str(&self.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for DateTimeTz {
|
||||
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
|
||||
deserializer.deserialize_str(DateTimeTzVisitor)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
struct Steps {
|
||||
date: DateTimeTz,
|
||||
steps: u32,
|
||||
}
|
||||
|
||||
impl From<Steps> for ft_core::Steps {
|
||||
fn from(s: Steps) -> Self {
|
||||
Self {
|
||||
date: s.date.0.naive_utc().date(),
|
||||
count: s.steps,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
struct Weight {
|
||||
date: DateTimeTz,
|
||||
weight: f64,
|
||||
}
|
||||
|
||||
impl From<Weight> for ft_core::Weight {
|
||||
fn from(w: Weight) -> Self {
|
||||
Self {
|
||||
date: w.date.0.naive_utc().date(),
|
||||
weight: w.weight * si::KG,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
enum TDActivity {
|
||||
Cycling,
|
||||
Rowing,
|
||||
Running,
|
||||
Swimming,
|
||||
Walking,
|
||||
}
|
||||
|
||||
impl From<TDActivity> for ft_core::TimeDistanceActivity {
|
||||
fn from(activity: TDActivity) -> Self {
|
||||
match activity {
|
||||
TDActivity::Cycling => ft_core::TimeDistanceActivity::Biking,
|
||||
TDActivity::Rowing => ft_core::TimeDistanceActivity::Rowing,
|
||||
TDActivity::Running => ft_core::TimeDistanceActivity::Running,
|
||||
TDActivity::Swimming => ft_core::TimeDistanceActivity::Swimming,
|
||||
TDActivity::Walking => ft_core::TimeDistanceActivity::Walking,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
struct TimeDistance {
|
||||
date: DateTimeTz,
|
||||
activity: TDActivity,
|
||||
comments: Option<String>,
|
||||
distance: Option<f64>,
|
||||
duration: Option<f64>,
|
||||
}
|
||||
|
||||
impl From<TimeDistance> for ft_core::TimeDistance {
|
||||
fn from(td: TimeDistance) -> Self {
|
||||
Self {
|
||||
datetime: td.date.0.fixed_offset(),
|
||||
activity: td.activity.into(),
|
||||
comments: td.comments,
|
||||
distance: td.distance.map(|d| d * si::M),
|
||||
duration: td.duration.map(|d| d * si::S),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
enum SRActivity {
|
||||
Pushups,
|
||||
Situps,
|
||||
}
|
||||
|
||||
impl From<SRActivity> for SetRepActivity {
|
||||
fn from(activity: SRActivity) -> Self {
|
||||
match activity {
|
||||
SRActivity::Pushups => Self::Pushups,
|
||||
SRActivity::Situps => Self::Situps,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
struct SetRep {
|
||||
date: DateTimeTz,
|
||||
activity: SRActivity,
|
||||
sets: Vec<u32>,
|
||||
comments: Option<String>,
|
||||
}
|
||||
|
||||
impl From<SetRep> for ft_core::SetRep {
|
||||
fn from(sr: SetRep) -> Self {
|
||||
Self {
|
||||
date: sr.date.0.naive_utc().date(),
|
||||
activity: sr.activity.into(),
|
||||
sets: sr.sets,
|
||||
comments: sr.comments,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
enum RDActivity {
|
||||
MartialArts,
|
||||
}
|
||||
|
||||
impl From<RDActivity> for DurationWorkoutActivity {
|
||||
fn from(_: RDActivity) -> Self {
|
||||
Self::MartialArts
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
struct RepDuration {
|
||||
date: DateTimeTz,
|
||||
activity: RDActivity,
|
||||
sets: Vec<f64>,
|
||||
}
|
||||
|
||||
impl From<RepDuration> for DurationWorkout {
|
||||
fn from(rd: RepDuration) -> Self {
|
||||
Self {
|
||||
datetime: rd.date.0.fixed_offset(),
|
||||
activity: rd.activity.into(),
|
||||
duration: rd.sets.into_iter().map(|d| d * si::S).next(),
|
||||
comments: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
enum LegacyData {
|
||||
RepDuration(RepDuration),
|
||||
SetRep(SetRep),
|
||||
Steps(Steps),
|
||||
TimeDistance(TimeDistance),
|
||||
Weight(Weight),
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
struct LegacyRecord {
|
||||
id: RecordId,
|
||||
data: LegacyData,
|
||||
}
|
||||
|
||||
impl From<LegacyRecord> for Record<TraxRecord> {
|
||||
fn from(record: LegacyRecord) -> Self {
|
||||
match record.data {
|
||||
LegacyData::RepDuration(rd) => Record {
|
||||
id: record.id,
|
||||
data: TraxRecord::DurationWorkout(rd.into()),
|
||||
},
|
||||
LegacyData::SetRep(sr) => Record {
|
||||
id: record.id,
|
||||
data: TraxRecord::SetRep(sr.into()),
|
||||
},
|
||||
LegacyData::Steps(s) => Record {
|
||||
id: record.id,
|
||||
data: TraxRecord::Steps(s.into()),
|
||||
},
|
||||
LegacyData::TimeDistance(td) => Record {
|
||||
id: record.id,
|
||||
data: TraxRecord::TimeDistance(td.into()),
|
||||
},
|
||||
LegacyData::Weight(weight) => Record {
|
||||
id: record.id,
|
||||
data: TraxRecord::Weight(weight.into()),
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let mut args = std::env::args();
|
||||
let _ = args.next().unwrap();
|
||||
let input_filename = args.next().unwrap();
|
||||
println!("input filename: {}", input_filename);
|
||||
// let output: Series<ft_core::TraxRecord> = Series::open_file("import.fitnesstrax").unwrap();
|
||||
|
||||
let input_file = File::open(input_filename).unwrap();
|
||||
let mut buf_reader = BufReader::new(input_file);
|
||||
// let mut contents = String::new();
|
||||
// buf_reader.read_(&mut contents).unwrap();
|
||||
|
||||
let mut count = 0;
|
||||
|
||||
loop {
|
||||
let mut line = String::new();
|
||||
let res = buf_reader.read_line(&mut line);
|
||||
match res {
|
||||
Err(err) => {
|
||||
panic!("failed after {} lines: {:?}", count, err);
|
||||
}
|
||||
Ok(0) => std::process::exit(0),
|
||||
Ok(_) => {
|
||||
let record = serde_json::from_str::<LegacyRecord>(&line).unwrap();
|
||||
let record: Record<TraxRecord> = record.into();
|
||||
println!("{}", serde_json::to_string(&record).unwrap());
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,26 @@
|
|||
#[cfg(test)]
|
||||
mod test {
|
||||
#[test]
|
||||
#[ignore]
|
||||
fn read_a_legacy_set_rep_record() {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore]
|
||||
fn read_a_legacy_steps_record() {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore]
|
||||
fn read_a_legacy_time_distance_record() {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore]
|
||||
fn read_a_legacy_weight_record() {
|
||||
unimplemented!()
|
||||
}
|
||||
}
|
|
@ -0,0 +1,8 @@
|
|||
mod legacy;
|
||||
|
||||
mod types;
|
||||
pub use types::{
|
||||
DurationWorkout, DurationWorkoutActivity, SetRep, SetRepActivity, Steps, TimeDistance,
|
||||
TimeDistanceActivity, TraxRecord, Weight, DURATION_WORKOUT_ACTIVITIES, SET_REP_ACTIVITIES,
|
||||
TIME_DISTANCE_ACTIVITIES,
|
||||
};
|
|
@ -0,0 +1,261 @@
|
|||
/*
|
||||
Copyright 2023-2024, Savanni D'Gerinel <savanni@luminescent-dreams.com>
|
||||
|
||||
This file is part of FitnessTrax.
|
||||
|
||||
FitnessTrax is free software: you can redistribute it and/or modify it under the terms of the GNU
|
||||
General Public License as published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
FitnessTrax is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without
|
||||
even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
|
||||
General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License along with FitnessTrax. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
use chrono::{DateTime, FixedOffset, NaiveDate};
|
||||
use dimensioned::si;
|
||||
use emseries::{Recordable, Timestamp};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub enum SetRepActivity {
|
||||
Pushups,
|
||||
Situps,
|
||||
}
|
||||
|
||||
pub const SET_REP_ACTIVITIES: [SetRepActivity; 2] =
|
||||
[SetRepActivity::Pushups, SetRepActivity::Situps];
|
||||
|
||||
/// SetRep represents workouts like pushups or situps, which involve doing a "set" of a number of
|
||||
/// actions, resting, and then doing another set.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct SetRep {
|
||||
/// I assume that a set/rep workout is only done once in a day.
|
||||
pub date: NaiveDate,
|
||||
/// The activity involved
|
||||
pub activity: SetRepActivity,
|
||||
/// Each set entry represents the number of times that the action was performed in a set. So, a
|
||||
/// pushup workout that involved five sets would have five entries. Each entry would be x
|
||||
/// number of pushups. A viable workout would be something like [6, 6, 4, 4, 5].
|
||||
pub sets: Vec<u32>,
|
||||
pub comments: Option<String>,
|
||||
}
|
||||
|
||||
impl Recordable for SetRep {
|
||||
fn timestamp(&self) -> Timestamp {
|
||||
Timestamp::Date(self.date)
|
||||
}
|
||||
|
||||
fn tags(&self) -> Vec<String> {
|
||||
vec![]
|
||||
}
|
||||
}
|
||||
|
||||
/// The number of steps one takes in a single day.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Steps {
|
||||
pub date: NaiveDate,
|
||||
pub count: u32,
|
||||
}
|
||||
|
||||
impl Recordable for Steps {
|
||||
fn timestamp(&self) -> Timestamp {
|
||||
Timestamp::Date(self.date)
|
||||
}
|
||||
|
||||
fn tags(&self) -> Vec<String> {
|
||||
vec![]
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub enum TimeDistanceActivity {
|
||||
Biking,
|
||||
Running,
|
||||
Rowing,
|
||||
Swimming,
|
||||
Walking,
|
||||
}
|
||||
|
||||
pub const TIME_DISTANCE_ACTIVITIES: [TimeDistanceActivity; 5] = [
|
||||
TimeDistanceActivity::Biking,
|
||||
TimeDistanceActivity::Rowing,
|
||||
TimeDistanceActivity::Running,
|
||||
TimeDistanceActivity::Swimming,
|
||||
TimeDistanceActivity::Walking,
|
||||
];
|
||||
|
||||
/// TimeDistance represents workouts characterized by a duration and a distance travelled. These
|
||||
/// sorts of workouts can occur many times a day, depending on how one records things. I might
|
||||
/// record a single 30-km workout if I go on a long-distanec ride. Or I might record multiple 5km
|
||||
/// workouts if I am out running errands. Distance and Duration are both optional because different
|
||||
/// people have different priorities and may choose to measure different things.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct TimeDistance {
|
||||
/// The precise time (and the relevant timezone) of the workout. One of the edge cases that I
|
||||
/// account for is that a ride which occurred at 11pm on one day in one timezone would then
|
||||
/// count as 1am on the next day if the user moves two timezones to the east. While technically
|
||||
/// correct, for most users this would throw off many years of metrics in ways that can be very
|
||||
/// hard to understand. Keeping the fixed offset means that we can have the most precise time
|
||||
/// in the database, but we can still get a Naive Date from the DateTime, which will still read
|
||||
/// as the original day.
|
||||
pub datetime: DateTime<FixedOffset>,
|
||||
/// The activity
|
||||
pub activity: TimeDistanceActivity,
|
||||
/// The distance travelled. This is optional because such a workout makes sense even without
|
||||
/// the distance.
|
||||
pub distance: Option<si::Meter<f64>>,
|
||||
/// The duration of the workout, which is also optional. Some people may keep track of the
|
||||
/// amount of distance travelled without tracking the duration.
|
||||
pub duration: Option<si::Second<f64>>,
|
||||
pub comments: Option<String>,
|
||||
}
|
||||
|
||||
impl Recordable for TimeDistance {
|
||||
fn timestamp(&self) -> Timestamp {
|
||||
Timestamp::DateTime(self.datetime)
|
||||
}
|
||||
|
||||
fn tags(&self) -> Vec<String> {
|
||||
vec![]
|
||||
}
|
||||
}
|
||||
|
||||
/// A singular daily weight measurement. Weight changes slowly enough that it seems unlikely to
|
||||
/// need to track more than a single weight in a day.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Weight {
|
||||
pub date: NaiveDate,
|
||||
pub weight: si::Kilogram<f64>,
|
||||
}
|
||||
|
||||
impl Recordable for Weight {
|
||||
fn timestamp(&self) -> Timestamp {
|
||||
Timestamp::Date(self.date)
|
||||
}
|
||||
|
||||
fn tags(&self) -> Vec<String> {
|
||||
vec![]
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub enum DurationWorkoutActivity {
|
||||
MartialArts,
|
||||
Yoga,
|
||||
}
|
||||
|
||||
pub const DURATION_WORKOUT_ACTIVITIES: [DurationWorkoutActivity; 2] = [
|
||||
DurationWorkoutActivity::MartialArts,
|
||||
DurationWorkoutActivity::Yoga,
|
||||
];
|
||||
|
||||
/// Generic workouts for which only duration really matters. This is for things
|
||||
/// such as Martial Arts or Yoga, which are activities done for an amount of
|
||||
/// time, but with no other details.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct DurationWorkout {
|
||||
pub datetime: DateTime<FixedOffset>,
|
||||
pub activity: DurationWorkoutActivity,
|
||||
pub duration: Option<si::Second<f64>>,
|
||||
pub comments: Option<String>,
|
||||
}
|
||||
|
||||
impl Recordable for DurationWorkout {
|
||||
fn timestamp(&self) -> Timestamp {
|
||||
Timestamp::DateTime(self.datetime)
|
||||
}
|
||||
|
||||
fn tags(&self) -> Vec<String> {
|
||||
vec![]
|
||||
}
|
||||
}
|
||||
|
||||
/// The unified data structure for all records that are part of the app.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub enum TraxRecord {
|
||||
DurationWorkout(DurationWorkout),
|
||||
SetRep(SetRep),
|
||||
Steps(Steps),
|
||||
TimeDistance(TimeDistance),
|
||||
Weight(Weight),
|
||||
}
|
||||
|
||||
impl TraxRecord {
|
||||
pub fn is_weight(&self) -> bool {
|
||||
matches!(self, TraxRecord::Weight(_))
|
||||
}
|
||||
|
||||
pub fn is_steps(&self) -> bool {
|
||||
matches!(self, TraxRecord::Steps(_))
|
||||
}
|
||||
|
||||
pub fn is_time_distance(&self) -> bool {
|
||||
matches!(
|
||||
self,
|
||||
TraxRecord::TimeDistance(TimeDistance {
|
||||
activity: TimeDistanceActivity::Biking,
|
||||
..
|
||||
}) | TraxRecord::TimeDistance(TimeDistance {
|
||||
activity: TimeDistanceActivity::Running,
|
||||
..
|
||||
}) | TraxRecord::TimeDistance(TimeDistance {
|
||||
activity: TimeDistanceActivity::Rowing,
|
||||
..
|
||||
}) | TraxRecord::TimeDistance(TimeDistance {
|
||||
activity: TimeDistanceActivity::Swimming,
|
||||
..
|
||||
}) | TraxRecord::TimeDistance(TimeDistance {
|
||||
activity: TimeDistanceActivity::Walking,
|
||||
..
|
||||
})
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl Recordable for TraxRecord {
|
||||
fn timestamp(&self) -> Timestamp {
|
||||
match self {
|
||||
TraxRecord::TimeDistance(rec) => Timestamp::DateTime(rec.datetime),
|
||||
TraxRecord::SetRep(rec) => rec.timestamp(),
|
||||
TraxRecord::Steps(rec) => rec.timestamp(),
|
||||
TraxRecord::Weight(rec) => rec.timestamp(),
|
||||
TraxRecord::DurationWorkout(rec) => rec.timestamp(),
|
||||
}
|
||||
}
|
||||
|
||||
fn tags(&self) -> Vec<String> {
|
||||
vec![]
|
||||
}
|
||||
}
|
||||
|
||||
impl From<TimeDistance> for TraxRecord {
|
||||
fn from(td: TimeDistance) -> Self {
|
||||
Self::TimeDistance(td)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod test {
|
||||
use super::*;
|
||||
use emseries::Series;
|
||||
|
||||
#[test]
|
||||
fn can_record_records() {
|
||||
let file = tempfile::NamedTempFile::new().expect("a temporary file");
|
||||
let path = file.into_temp_path();
|
||||
let mut series: Series<TraxRecord> = Series::open(&path).unwrap();
|
||||
|
||||
let record = TraxRecord::Steps(Steps {
|
||||
date: NaiveDate::from_ymd_opt(2023, 1, 1).unwrap(),
|
||||
count: 1000,
|
||||
});
|
||||
|
||||
let id = series.put(record.clone()).unwrap();
|
||||
|
||||
let record_ = series.get(&id).unwrap();
|
||||
assert_eq!(record_.data, record);
|
||||
}
|
||||
}
|
118
flake.lock
118
flake.lock
|
@ -1,37 +1,6 @@
|
|||
{
|
||||
"nodes": {
|
||||
"flake-compat": {
|
||||
"flake": false,
|
||||
"locked": {
|
||||
"lastModified": 1650374568,
|
||||
"narHash": "sha256-Z+s0J8/r907g149rllvwhb4pKi8Wam5ij0st8PwAh+E=",
|
||||
"owner": "edolstra",
|
||||
"repo": "flake-compat",
|
||||
"rev": "b4a34015c698c7793d592d66adbab377907a2be8",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "edolstra",
|
||||
"repo": "flake-compat",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"flake-utils": {
|
||||
"locked": {
|
||||
"lastModified": 1653893745,
|
||||
"narHash": "sha256-0jntwV3Z8//YwuOjzhV2sgJJPt+HY6KhU7VZUL0fKZQ=",
|
||||
"owner": "numtide",
|
||||
"repo": "flake-utils",
|
||||
"rev": "1ed9fb1935d260de5fe1c2f7ee0ebaae17ed2fa1",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "numtide",
|
||||
"repo": "flake-utils",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"flake-utils_2": {
|
||||
"inputs": {
|
||||
"systems": "systems"
|
||||
},
|
||||
|
@ -51,36 +20,20 @@
|
|||
},
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1691421349,
|
||||
"narHash": "sha256-RRJyX0CUrs4uW4gMhd/X4rcDG8PTgaaCQM5rXEJOx6g=",
|
||||
"lastModified": 1704732714,
|
||||
"narHash": "sha256-ABqK/HggMYA/jMUXgYyqVAcQ8QjeMyr1jcXfTpSHmps=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "011567f35433879aae5024fc6ec53f2a0568a6c4",
|
||||
"rev": "6723fa4e4f1a30d42a633bef5eb01caeb281adc3",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"id": "nixpkgs",
|
||||
"ref": "nixos-23.05",
|
||||
"ref": "nixos-23.11",
|
||||
"type": "indirect"
|
||||
}
|
||||
},
|
||||
"nixpkgs_2": {
|
||||
"locked": {
|
||||
"lastModified": 1654275867,
|
||||
"narHash": "sha256-pt14ZE4jVPGvfB2NynGsl34pgXfOqum5YJNpDK4+b9E=",
|
||||
"owner": "nixos",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "7a20c208aacf4964c19186dcad51f89165dc7ed0",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "nixos",
|
||||
"ref": "release-22.05",
|
||||
"repo": "nixpkgs",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nixpkgs_3": {
|
||||
"locked": {
|
||||
"lastModified": 1681303793,
|
||||
"narHash": "sha256-JEdQHsYuCfRL2PICHlOiH/2ue3DwoxUX7DJ6zZxZXFk=",
|
||||
|
@ -95,60 +48,13 @@
|
|||
"type": "indirect"
|
||||
}
|
||||
},
|
||||
"pkgs-cargo2nix": {
|
||||
"inputs": {
|
||||
"flake-compat": "flake-compat",
|
||||
"flake-utils": "flake-utils",
|
||||
"nixpkgs": "nixpkgs_2",
|
||||
"rust-overlay": "rust-overlay"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1682891040,
|
||||
"narHash": "sha256-hjajsi7lq24uYitUh4o04UJi1g0Qe6ruPL0s5DgPQMY=",
|
||||
"owner": "cargo2nix",
|
||||
"repo": "cargo2nix",
|
||||
"rev": "0167b39f198d72acdf009265634504fd6f5ace15",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "cargo2nix",
|
||||
"repo": "cargo2nix",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"root": {
|
||||
"inputs": {
|
||||
"nixpkgs": "nixpkgs",
|
||||
"pkgs-cargo2nix": "pkgs-cargo2nix",
|
||||
"typeshare": "typeshare",
|
||||
"unstable": "unstable"
|
||||
}
|
||||
},
|
||||
"rust-overlay": {
|
||||
"inputs": {
|
||||
"flake-utils": [
|
||||
"pkgs-cargo2nix",
|
||||
"flake-utils"
|
||||
],
|
||||
"nixpkgs": [
|
||||
"pkgs-cargo2nix",
|
||||
"nixpkgs"
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1653878966,
|
||||
"narHash": "sha256-T51Gck/vrJZi1m+uTbhEFTRgZmE59sydVONadADv358=",
|
||||
"owner": "oxalica",
|
||||
"repo": "rust-overlay",
|
||||
"rev": "8526d618af012a923ca116be9603e818b502a8db",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "oxalica",
|
||||
"repo": "rust-overlay",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"systems": {
|
||||
"locked": {
|
||||
"lastModified": 1681028828,
|
||||
|
@ -166,15 +72,15 @@
|
|||
},
|
||||
"typeshare": {
|
||||
"inputs": {
|
||||
"flake-utils": "flake-utils_2",
|
||||
"nixpkgs": "nixpkgs_3"
|
||||
"flake-utils": "flake-utils",
|
||||
"nixpkgs": "nixpkgs_2"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1690502632,
|
||||
"narHash": "sha256-+k81RrxfphDUD5kekWbQ4xuZIHBEAQf67uivaQ34Afs=",
|
||||
"lastModified": 1698205128,
|
||||
"narHash": "sha256-jP+81TkldLtda8bzmsBWahETGsyFkoDOCT244YkA+S4=",
|
||||
"owner": "1Password",
|
||||
"repo": "typeshare",
|
||||
"rev": "9f74772af53759aee2f53e64478523e53083719e",
|
||||
"rev": "c3ee2ad8f27774c45db7af4f2ba746c4ae11de21",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
|
@ -185,11 +91,11 @@
|
|||
},
|
||||
"unstable": {
|
||||
"locked": {
|
||||
"lastModified": 1690367991,
|
||||
"narHash": "sha256-2VwOn1l8y6+cu7zjNE8MgeGJNNz1eat1HwHrINeogFA=",
|
||||
"lastModified": 1704722960,
|
||||
"narHash": "sha256-mKGJ3sPsT6//s+Knglai5YflJUF2DGj7Ai6Ynopz0kI=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "c9cf0708f00fbe553319258e48ca89ff9a413703",
|
||||
"rev": "317484b1ead87b9c1b8ac5261a8d2dd748a0492d",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
|
|
69
flake.nix
69
flake.nix
|
@ -2,13 +2,12 @@
|
|||
description = "Lumenescent Dreams Tools";
|
||||
|
||||
inputs = {
|
||||
nixpkgs.url = "nixpkgs/nixos-23.05";
|
||||
nixpkgs.url = "nixpkgs/nixos-23.11";
|
||||
unstable.url = "nixpkgs/nixos-unstable";
|
||||
pkgs-cargo2nix.url = "github:cargo2nix/cargo2nix";
|
||||
typeshare.url = "github:1Password/typeshare";
|
||||
};
|
||||
|
||||
outputs = { self, nixpkgs, unstable, pkgs-cargo2nix, typeshare, ... }:
|
||||
outputs = { self, nixpkgs, unstable, typeshare, ... }:
|
||||
let
|
||||
version = builtins.string 0 8 self.lastModifiedDate;
|
||||
supportedSystems = [ "x86_64-linux" ];
|
||||
|
@ -18,15 +17,13 @@
|
|||
let
|
||||
pkgs = import nixpkgs { system = "x86_64-linux"; };
|
||||
pkgs-unstable = import unstable { system = "x86_64-linux"; };
|
||||
cargo2nix = pkgs-cargo2nix.packages."x86_64-linux";
|
||||
in
|
||||
pkgs.mkShell {
|
||||
name = "ld-tools-devshell";
|
||||
buildInputs = [
|
||||
pkgs.libadwaita
|
||||
pkgs.cargo-nextest
|
||||
pkgs.clang
|
||||
pkgs.entr
|
||||
pkgs.glade
|
||||
pkgs.crate2nix
|
||||
pkgs.glib
|
||||
pkgs.gst_all_1.gst-plugins-bad
|
||||
pkgs.gst_all_1.gst-plugins-base
|
||||
|
@ -34,19 +31,73 @@
|
|||
pkgs.gst_all_1.gst-plugins-ugly
|
||||
pkgs.gst_all_1.gstreamer
|
||||
pkgs.gtk4
|
||||
pkgs.libadwaita
|
||||
pkgs.librsvg
|
||||
pkgs.nodejs
|
||||
pkgs.openssl
|
||||
pkgs.pipewire
|
||||
pkgs.pkg-config
|
||||
pkgs.sqlite
|
||||
pkgs.rustup
|
||||
pkgs.sqlite
|
||||
pkgs.cargo-nextest
|
||||
pkgs.crate2nix
|
||||
pkgs.wasm-pack
|
||||
pkgs.sqlx-cli
|
||||
pkgs.udev
|
||||
pkgs.wasm-pack
|
||||
typeshare.packages."x86_64-linux".default
|
||||
];
|
||||
LIBCLANG_PATH="${pkgs.llvmPackages.libclang.lib}/lib";
|
||||
ENV = "dev";
|
||||
};
|
||||
|
||||
packages."x86_64-linux" =
|
||||
let
|
||||
pkgs = import nixpkgs { system = "x86_64-linux"; };
|
||||
|
||||
gtkNativeInputs = [
|
||||
pkgs.pkg-config
|
||||
pkgs.gtk4
|
||||
pkgs.libadwaita
|
||||
pkgs.wrapGAppsHook4
|
||||
];
|
||||
|
||||
cargoOverrides = pkgs: pkgs.buildRustCrate.override {
|
||||
defaultCrateOverrides = pkgs.defaultCrateOverrides // {
|
||||
gobject-sys = attrs: { nativeBuildInputs = gtkNativeInputs; };
|
||||
gio-sys = attrs: { nativeBuildInputs = gtkNativeInputs; };
|
||||
gdk-pixbuf-sys = attrs: { nativeBuildInputs = gtkNativeInputs; };
|
||||
libadwaita-sys = attrs: { nativeBuildInputs = gtkNativeInputs; };
|
||||
|
||||
dashboard = attrs: { nativeBuildInputs = gtkNativeInputs; };
|
||||
fitnesstrax = import ./fitnesstrax/app/override.nix { gtkNativeInputs = gtkNativeInputs; };
|
||||
kifu-gtk = import ./kifu/gtk/override.nix { gtkNativeInputs = gtkNativeInputs; };
|
||||
};
|
||||
};
|
||||
|
||||
cargo_nix = pkgs.callPackage ./Cargo.nix {
|
||||
nixpkgs = nixpkgs;
|
||||
buildRustCrateForPkgs = cargoOverrides;
|
||||
};
|
||||
|
||||
in rec {
|
||||
cyberpunk-splash = cargo_nix.workspaceMembers.cyberpunk-splash.build;
|
||||
dashboard = cargo_nix.workspaceMembers.dashboard.build;
|
||||
file-service = cargo_nix.workspaceMembers.file-service.build;
|
||||
fitnesstrax = cargo_nix.workspaceMembers.fitnesstrax.build;
|
||||
kifu-gtk = cargo_nix.workspaceMembers.kifu-gtk.build;
|
||||
|
||||
all = pkgs.symlinkJoin {
|
||||
name = "all";
|
||||
paths = [
|
||||
cyberpunk-splash
|
||||
dashboard
|
||||
file-service
|
||||
fitnesstrax
|
||||
kifu-gtk
|
||||
];
|
||||
};
|
||||
|
||||
default = all;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
|
|
@ -6,14 +6,14 @@ edition = "2021"
|
|||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||
|
||||
[dependencies]
|
||||
adw = { version = "0.4", package = "libadwaita", features = [ "v1_2", "gtk_v4_6" ] }
|
||||
adw = { version = "0.5", package = "libadwaita", features = [ "v1_2", "gtk_v4_6" ] }
|
||||
config = { path = "../config" }
|
||||
config-derive = { path = "../config-derive" }
|
||||
futures = { version = "0.3" }
|
||||
gio = { version = "0.17" }
|
||||
glib = { version = "0.17" }
|
||||
gdk = { version = "0.6", package = "gdk4" }
|
||||
gtk = { version = "0.6", package = "gtk4", features = [ "v4_6" ] }
|
||||
gio = { version = "0.18" }
|
||||
glib = { version = "0.18" }
|
||||
gdk = { version = "0.7", package = "gdk4" }
|
||||
gtk = { version = "0.7", package = "gtk4", features = [ "v4_6" ] }
|
||||
serde = { version = "1" }
|
||||
serde_json = { version = "*" }
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
use glib::{Continue, Sender};
|
||||
use glib::Sender;
|
||||
use gtk::prelude::*;
|
||||
use std::{
|
||||
env,
|
||||
|
@ -43,14 +43,14 @@ pub fn main() {
|
|||
|
||||
app.connect_activate(move |app| {
|
||||
let (gtk_tx, gtk_rx) =
|
||||
gtk::glib::MainContext::channel::<Message>(gtk::glib::PRIORITY_DEFAULT);
|
||||
gtk::glib::MainContext::channel::<Message>(gtk::glib::Priority::DEFAULT);
|
||||
|
||||
*core.tx.write().unwrap() = Some(gtk_tx);
|
||||
|
||||
let window = ApplicationWindow::new(app);
|
||||
window.window.present();
|
||||
|
||||
gtk_rx.attach(None, move |_msg| Continue(true));
|
||||
gtk_rx.attach(None, move |_msg| glib::ControlFlow::Continue);
|
||||
});
|
||||
|
||||
let args: Vec<String> = env::args().collect();
|
||||
|
|
|
@ -7,12 +7,12 @@ license = "GPL-3.0-only"
|
|||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||
|
||||
[dependencies]
|
||||
cairo-rs = "0.17"
|
||||
gio = "0.17"
|
||||
glib = "0.17"
|
||||
gtk = { version = "0.6", package = "gtk4" }
|
||||
cairo-rs = "0.18"
|
||||
gio = "0.18"
|
||||
glib = "0.18"
|
||||
gtk = { version = "0.7", package = "gtk4" }
|
||||
coordinates = { path = "../coordinates" }
|
||||
image = { version = "0.24" }
|
||||
|
||||
[build-dependencies]
|
||||
glib-build-tools = "0.16"
|
||||
glib-build-tools = "0.18"
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
fn main() {
|
||||
glib_build_tools::compile_resources(
|
||||
"resources",
|
||||
&["resources"],
|
||||
"resources/resources.gresources.xml",
|
||||
"com.luminescent-dreams.hex-grid.gresource",
|
||||
);
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
use crate::utilities;
|
||||
use cairo::Context;
|
||||
use gtk::{gdk_pixbuf::Pixbuf, prelude::*};
|
||||
use gtk::gdk_pixbuf::Pixbuf;
|
||||
use image::DynamicImage;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
|
@ -89,7 +89,8 @@ impl Tile {
|
|||
88.,
|
||||
));
|
||||
context.clip();
|
||||
context.set_source_pixbuf(&self.image, translate_x, translate_y);
|
||||
// panic!("content.set_source_pixbuf got deprecated and I haven't figured out the replacement yet.");
|
||||
// context.set_source_pixbuf(&self.image, translate_x, translate_y);
|
||||
context.paint().expect("paint should succeed");
|
||||
context.restore().unwrap();
|
||||
}
|
||||
|
|
|
@ -0,0 +1,12 @@
|
|||
[package]
|
||||
name = "icon-test"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||
|
||||
[dependencies]
|
||||
adw = { version = "0.5", package = "libadwaita", features = [ "v1_4" ] }
|
||||
gio = { version = "0.18" }
|
||||
glib = { version = "0.18" }
|
||||
gtk = { version = "0.7", package = "gtk4", features = [ "v4_10" ] }
|
|
@ -0,0 +1,38 @@
|
|||
use adw::prelude::*;
|
||||
|
||||
fn main() {
|
||||
let adw_app = adw::Application::builder().build();
|
||||
|
||||
adw_app.connect_activate(move |adw_app| {
|
||||
let window = gtk::ApplicationWindow::builder()
|
||||
.application(adw_app)
|
||||
.width_request(400)
|
||||
.height_request(400)
|
||||
.build();
|
||||
|
||||
let sunrise_button = gtk::Button::builder()
|
||||
.icon_name("daytime-sunrise-symbolic")
|
||||
.width_request(64)
|
||||
.height_request(64)
|
||||
.build();
|
||||
|
||||
let walking_button = gtk::Button::builder()
|
||||
.icon_name("walking2-symbolic")
|
||||
.width_request(64)
|
||||
.height_request(64)
|
||||
.build();
|
||||
|
||||
let layout = gtk::Box::builder()
|
||||
.orientation(gtk::Orientation::Horizontal)
|
||||
.valign(gtk::Align::Start)
|
||||
.build();
|
||||
layout.append(&sunrise_button);
|
||||
layout.append(&walking_button);
|
||||
|
||||
window.set_child(Some(&layout));
|
||||
window.present();
|
||||
});
|
||||
|
||||
let args: Vec<String> = std::env::args().collect();
|
||||
ApplicationExtManual::run_with_args(&adw_app, &args);
|
||||
}
|
|
@ -1,6 +0,0 @@
|
|||
|
||||
SOURCES = $(shell find ../core -name "*.rs")
|
||||
dist/index.ts: $(SOURCES)
|
||||
mkdir -p dist
|
||||
typeshare ../core --lang=typescript --output-file=dist/index.ts
|
||||
|
|
@ -1,13 +0,0 @@
|
|||
{
|
||||
"name": "core-types",
|
||||
"version": "0.0.1",
|
||||
"description": "",
|
||||
"types": "dist/index.ts",
|
||||
"main": "dist/index.ts",
|
||||
"scripts": {
|
||||
"build": "make",
|
||||
"test": "echo \"Error: no test specified\" && exit 1"
|
||||
},
|
||||
"author": "Savanni D'Gerinel <savanni@luminescent-dreams.com>",
|
||||
"license": "GPL-3.0-or-later"
|
||||
}
|
|
@ -6,6 +6,7 @@ edition = "2021"
|
|||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||
|
||||
[dependencies]
|
||||
async-std = { version = "1" }
|
||||
chrono = { version = "0.4" }
|
||||
config = { path = "../../config" }
|
||||
config-derive = { path = "../../config-derive" }
|
||||
|
@ -14,7 +15,6 @@ grid = { version = "0.9" }
|
|||
serde_json = { version = "1" }
|
||||
serde = { version = "1", features = [ "derive" ] }
|
||||
thiserror = { version = "1" }
|
||||
typeshare = { version = "1" }
|
||||
|
||||
[dev-dependencies]
|
||||
cool_asserts = { version = "2" }
|
||||
|
|
|
@ -1,17 +1,24 @@
|
|||
use crate::{
|
||||
types::{AppState, Config, ConfigOption, DatabasePath, GameState, Player, Rank},
|
||||
ui::{configuration, home, playing_field, ConfigurationView, HomeView, PlayingFieldView},
|
||||
database::Database,
|
||||
types::{AppState, Config, ConfigOption, GameState, LibraryPath, Player, Rank},
|
||||
};
|
||||
use async_std::{
|
||||
channel::{Receiver, Sender},
|
||||
stream,
|
||||
task::spawn,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::{
|
||||
future::Future,
|
||||
path::PathBuf,
|
||||
sync::{Arc, RwLock},
|
||||
sync::{Arc, RwLock, RwLockReadGuard},
|
||||
};
|
||||
use typeshare::typeshare;
|
||||
|
||||
pub trait Observable<T> {
|
||||
fn subscribe(&self) -> Receiver<T>;
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[typeshare]
|
||||
#[serde(tag = "type", content = "content")]
|
||||
pub enum CoreRequest {
|
||||
ChangeSetting(ChangeSettingRequest),
|
||||
CreateGame(CreateGameRequest),
|
||||
|
@ -23,34 +30,28 @@ pub enum CoreRequest {
|
|||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[typeshare]
|
||||
#[serde(tag = "type", content = "content")]
|
||||
pub enum ChangeSettingRequest {
|
||||
LibraryPath(String),
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[typeshare]
|
||||
pub struct PlayStoneRequest {
|
||||
pub column: u8,
|
||||
pub row: u8,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[typeshare]
|
||||
pub struct CreateGameRequest {
|
||||
pub black_player: PlayerInfoRequest,
|
||||
pub white_player: PlayerInfoRequest,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[typeshare]
|
||||
pub enum PlayerInfoRequest {
|
||||
Hotseat(HotseatPlayerRequest),
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[typeshare]
|
||||
pub struct HotseatPlayerRequest {
|
||||
pub name: String,
|
||||
pub rank: Option<String>,
|
||||
|
@ -65,47 +66,78 @@ impl From<HotseatPlayerRequest> for Player {
|
|||
}
|
||||
}
|
||||
|
||||
/*
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
#[typeshare]
|
||||
#[serde(tag = "type", content = "content")]
|
||||
pub enum CoreResponse {
|
||||
ConfigurationView(ConfigurationView),
|
||||
HomeView(HomeView),
|
||||
PlayingFieldView(PlayingFieldView),
|
||||
UpdatedConfigurationView(ConfigurationView),
|
||||
}
|
||||
*/
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct CoreApp {
|
||||
config: Arc<RwLock<Config>>,
|
||||
state: Arc<RwLock<AppState>>,
|
||||
pub enum CoreNotification {
|
||||
ConfigurationUpdated(Config),
|
||||
BoardUpdated,
|
||||
}
|
||||
|
||||
impl CoreApp {
|
||||
pub fn new(config_path: std::path::PathBuf) -> Self {
|
||||
let config = Config::from_path(config_path).expect("configuration to open");
|
||||
|
||||
let db_path: DatabasePath = config.get().unwrap();
|
||||
let state = Arc::new(RwLock::new(AppState::new(db_path)));
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Core {
|
||||
config: Arc<RwLock<Config>>,
|
||||
// state: Arc<RwLock<AppState>>,
|
||||
library: Arc<RwLock<Option<Database>>>,
|
||||
subscribers: Arc<RwLock<Vec<Sender<CoreNotification>>>>,
|
||||
}
|
||||
|
||||
impl Core {
|
||||
pub fn new(config: Config) -> Self {
|
||||
Self {
|
||||
config: Arc::new(RwLock::new(config)),
|
||||
state,
|
||||
// state,
|
||||
library: Arc::new(RwLock::new(None)),
|
||||
subscribers: Arc::new(RwLock::new(vec![])),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_config(&self) -> Config {
|
||||
self.config.read().unwrap().clone()
|
||||
}
|
||||
|
||||
/// Change the configuration of the Core. This function will update any relevant core
|
||||
/// functions, especially the contents of the library, and it will notify any subscribed objects
|
||||
/// that the configuration has changed.
|
||||
///
|
||||
/// It will not handle persisting the new configuration, as the backing store for the
|
||||
/// configuration is not a decision for the core library.
|
||||
pub async fn set_config(&self, config: Config) {
|
||||
*self.config.write().unwrap() = config.clone();
|
||||
let subscribers = self.subscribers.read().unwrap().clone();
|
||||
for subscriber in subscribers {
|
||||
let subscriber = subscriber.clone();
|
||||
let _ = subscriber.send(CoreNotification::ConfigurationUpdated(config.clone())).await;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn library<'a>(&'a self) -> RwLockReadGuard<'_, Option<Database>> {
|
||||
self.library.read().unwrap()
|
||||
}
|
||||
|
||||
/*
|
||||
pub async fn dispatch(&self, request: CoreRequest) -> CoreResponse {
|
||||
match request {
|
||||
CoreRequest::ChangeSetting(request) => match request {
|
||||
ChangeSettingRequest::LibraryPath(path) => {
|
||||
let mut config = self.config.write().unwrap();
|
||||
config.set(ConfigOption::DatabasePath(DatabasePath(PathBuf::from(
|
||||
path,
|
||||
))));
|
||||
CoreResponse::UpdatedConfigurationView(configuration(&config))
|
||||
// let mut config = self.config.write().unwrap();
|
||||
// config.set(ConfigOption::DatabasePath(DatabasePath(PathBuf::from(
|
||||
// path,
|
||||
// ))));
|
||||
// CoreResponse::UpdatedConfigurationView(configuration(&config))
|
||||
unimplemented!()
|
||||
}
|
||||
},
|
||||
CoreRequest::CreateGame(create_request) => {
|
||||
/*
|
||||
let mut app_state = self.state.write().unwrap();
|
||||
let white_player = {
|
||||
match create_request.white_player {
|
||||
|
@ -124,30 +156,48 @@ impl CoreApp {
|
|||
});
|
||||
let game_state = app_state.game.as_ref().unwrap();
|
||||
CoreResponse::PlayingFieldView(playing_field(game_state))
|
||||
*/
|
||||
unimplemented!()
|
||||
}
|
||||
CoreRequest::Home => {
|
||||
CoreResponse::HomeView(home(self.state.read().unwrap().database.all_games()))
|
||||
// CoreResponse::HomeView(home(self.state.read().unwrap().database.all_games()))
|
||||
unimplemented!()
|
||||
}
|
||||
CoreRequest::OpenConfiguration => {
|
||||
CoreResponse::ConfigurationView(configuration(&self.config.read().unwrap()))
|
||||
// CoreResponse::ConfigurationView(configuration(&self.config.read().unwrap()))
|
||||
unimplemented!()
|
||||
}
|
||||
CoreRequest::PlayingField => {
|
||||
let app_state = self.state.read().unwrap();
|
||||
let game = app_state.game.as_ref().unwrap();
|
||||
CoreResponse::PlayingFieldView(playing_field(game))
|
||||
// let app_state = self.state.read().unwrap();
|
||||
// let game = app_state.game.as_ref().unwrap();
|
||||
// CoreResponse::PlayingFieldView(playing_field(game))
|
||||
unimplemented!()
|
||||
}
|
||||
CoreRequest::PlayStone(request) => {
|
||||
let mut app_state = self.state.write().unwrap();
|
||||
app_state.place_stone(request);
|
||||
// let mut app_state = self.state.write().unwrap();
|
||||
// app_state.place_stone(request);
|
||||
|
||||
let game = app_state.game.as_ref().unwrap();
|
||||
CoreResponse::PlayingFieldView(playing_field(game))
|
||||
// let game = app_state.game.as_ref().unwrap();
|
||||
// CoreResponse::PlayingFieldView(playing_field(game))
|
||||
unimplemented!()
|
||||
}
|
||||
CoreRequest::StartGame => {
|
||||
unimplemented!()
|
||||
}
|
||||
}
|
||||
}
|
||||
*/
|
||||
|
||||
pub async fn run(&self) {}
|
||||
// pub async fn run(&self) {}
|
||||
}
|
||||
|
||||
impl Observable<CoreNotification> for Core {
|
||||
fn subscribe(&self) -> Receiver<CoreNotification> {
|
||||
let mut subscribers = self.subscribers.write().unwrap();
|
||||
|
||||
let (sender, receiver) = async_std::channel::unbounded();
|
||||
subscribers.push(sender);
|
||||
|
||||
receiver
|
||||
}
|
||||
}
|
||||
|
|
|
@ -2,12 +2,12 @@ use crate::{BoardError, Color, Size};
|
|||
use std::collections::HashSet;
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct Board {
|
||||
pub struct Goban {
|
||||
pub size: Size,
|
||||
pub groups: Vec<Group>,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for Board {
|
||||
impl std::fmt::Display for Goban {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> Result<(), std::fmt::Error> {
|
||||
write!(f, " ")?;
|
||||
// for c in 'A'..'U' {
|
||||
|
@ -31,7 +31,7 @@ impl std::fmt::Display for Board {
|
|||
}
|
||||
}
|
||||
|
||||
impl PartialEq for Board {
|
||||
impl PartialEq for Goban {
|
||||
fn eq(&self, other: &Self) -> bool {
|
||||
if self.size != other.size {
|
||||
return false;
|
||||
|
@ -51,7 +51,7 @@ impl PartialEq for Board {
|
|||
}
|
||||
}
|
||||
|
||||
impl Board {
|
||||
impl Goban {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
size: Size {
|
||||
|
@ -77,7 +77,7 @@ pub struct Coordinate {
|
|||
pub row: u8,
|
||||
}
|
||||
|
||||
impl Board {
|
||||
impl Goban {
|
||||
pub fn place_stone(mut self, coordinate: Coordinate, color: Color) -> Result<Self, BoardError> {
|
||||
if self.stone(&coordinate).is_some() {
|
||||
return Err(BoardError::InvalidPosition);
|
||||
|
@ -224,8 +224,8 @@ mod test {
|
|||
* A stone placed in a suicidal position is legal if it captures other stones first.
|
||||
*/
|
||||
|
||||
fn with_example_board(test: impl FnOnce(Board)) {
|
||||
let board = Board::from_coordinates(
|
||||
fn with_example_board(test: impl FnOnce(Goban)) {
|
||||
let board = Goban::from_coordinates(
|
||||
vec![
|
||||
(Coordinate { column: 3, row: 3 }, Color::White),
|
||||
(Coordinate { column: 3, row: 4 }, Color::White),
|
||||
|
@ -284,7 +284,7 @@ mod test {
|
|||
|
||||
#[test]
|
||||
fn it_gets_adjacencies_for_coordinate() {
|
||||
let board = Board::new();
|
||||
let board = Goban::new();
|
||||
for column in 0..19 {
|
||||
for row in 0..19 {
|
||||
for coordinate in board.adjacencies(&Coordinate { column, row }) {
|
||||
|
@ -302,7 +302,7 @@ mod test {
|
|||
|
||||
#[test]
|
||||
fn it_counts_individual_liberties() {
|
||||
let board = Board::from_coordinates(
|
||||
let board = Goban::from_coordinates(
|
||||
vec![
|
||||
(Coordinate { column: 3, row: 3 }, Color::White),
|
||||
(Coordinate { column: 0, row: 3 }, Color::White),
|
||||
|
@ -357,7 +357,7 @@ mod test {
|
|||
|
||||
#[test]
|
||||
fn stones_share_liberties() {
|
||||
with_example_board(|board: Board| {
|
||||
with_example_board(|board: Goban| {
|
||||
let test_cases = vec![
|
||||
(
|
||||
board.clone(),
|
||||
|
@ -567,11 +567,11 @@ mod test {
|
|||
#[test]
|
||||
fn validate_group_comparisons() {
|
||||
{
|
||||
let b1 = Board::from_coordinates(
|
||||
let b1 = Goban::from_coordinates(
|
||||
vec![(Coordinate { column: 7, row: 9 }, Color::White)].into_iter(),
|
||||
)
|
||||
.unwrap();
|
||||
let b2 = Board::from_coordinates(
|
||||
let b2 = Goban::from_coordinates(
|
||||
vec![(Coordinate { column: 7, row: 9 }, Color::White)].into_iter(),
|
||||
)
|
||||
.unwrap();
|
||||
|
@ -580,7 +580,7 @@ mod test {
|
|||
}
|
||||
|
||||
{
|
||||
let b1 = Board::from_coordinates(
|
||||
let b1 = Goban::from_coordinates(
|
||||
vec![
|
||||
(Coordinate { column: 7, row: 9 }, Color::White),
|
||||
(Coordinate { column: 8, row: 10 }, Color::White),
|
||||
|
@ -588,7 +588,7 @@ mod test {
|
|||
.into_iter(),
|
||||
)
|
||||
.unwrap();
|
||||
let b2 = Board::from_coordinates(
|
||||
let b2 = Goban::from_coordinates(
|
||||
vec![
|
||||
(Coordinate { column: 8, row: 10 }, Color::White),
|
||||
(Coordinate { column: 7, row: 9 }, Color::White),
|
||||
|
@ -603,7 +603,7 @@ mod test {
|
|||
|
||||
#[test]
|
||||
fn two_boards_can_be_compared() {
|
||||
let board = Board::from_coordinates(
|
||||
let board = Goban::from_coordinates(
|
||||
vec![
|
||||
(Coordinate { column: 7, row: 9 }, Color::White),
|
||||
(Coordinate { column: 8, row: 8 }, Color::White),
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
use std::{io::Read, path::PathBuf};
|
||||
|
||||
use sgf::{go, parse_sgf, Game};
|
||||
use sgf::{parse_sgf, Game};
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Error, Debug)]
|
||||
|
@ -21,12 +21,12 @@ impl From<std::io::Error> for Error {
|
|||
|
||||
#[derive(Debug)]
|
||||
pub struct Database {
|
||||
games: Vec<go::Game>,
|
||||
games: Vec<Game>,
|
||||
}
|
||||
|
||||
impl Database {
|
||||
pub fn open_path(path: PathBuf) -> Result<Database, Error> {
|
||||
let mut games: Vec<go::Game> = Vec::new();
|
||||
let mut games: Vec<Game> = Vec::new();
|
||||
|
||||
let extension = PathBuf::from("sgf").into_os_string();
|
||||
|
||||
|
@ -43,10 +43,7 @@ impl Database {
|
|||
match parse_sgf(&buffer) {
|
||||
Ok(sgfs) => {
|
||||
for sgf in sgfs {
|
||||
match sgf {
|
||||
Game::Go(game) => games.push(game),
|
||||
Game::Unsupported(_) => {}
|
||||
}
|
||||
games.push(sgf);
|
||||
}
|
||||
}
|
||||
Err(err) => println!("Error parsing {:?}: {:?}", entry.path(), err),
|
||||
|
@ -60,7 +57,7 @@ impl Database {
|
|||
Ok(Database { games })
|
||||
}
|
||||
|
||||
pub fn all_games(&self) -> impl Iterator<Item = &go::Game> {
|
||||
pub fn all_games(&self) -> impl Iterator<Item = &Game> {
|
||||
self.games.iter()
|
||||
}
|
||||
}
|
||||
|
@ -78,6 +75,7 @@ mod test {
|
|||
assert_eq!(db.all_games().count(), 0);
|
||||
}
|
||||
|
||||
#[ignore]
|
||||
#[test]
|
||||
fn it_reads_five_games_from_database() {
|
||||
let db =
|
||||
|
|
|
@ -1,10 +1,7 @@
|
|||
extern crate config_derive;
|
||||
|
||||
mod api;
|
||||
pub use api::{
|
||||
ChangeSettingRequest, CoreApp, CoreRequest, CoreResponse, CreateGameRequest,
|
||||
HotseatPlayerRequest, PlayerInfoRequest,
|
||||
};
|
||||
pub use api::{Core, CoreNotification, Observable};
|
||||
|
||||
mod board;
|
||||
pub use board::*;
|
||||
|
@ -12,6 +9,5 @@ pub use board::*;
|
|||
mod database;
|
||||
|
||||
mod types;
|
||||
pub use types::{BoardError, Color, Rank, Size};
|
||||
pub use types::{BoardError, Color, Config, ConfigOption, LibraryPath, Player, Rank, Size};
|
||||
|
||||
pub mod ui;
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
use crate::{
|
||||
api::PlayStoneRequest,
|
||||
board::{Board, Coordinate},
|
||||
board::{Coordinate, Goban},
|
||||
database::Database,
|
||||
};
|
||||
use config::define_config;
|
||||
|
@ -8,23 +8,28 @@ use config_derive::ConfigOption;
|
|||
use serde::{Deserialize, Serialize};
|
||||
use std::{path::PathBuf, time::Duration};
|
||||
use thiserror::Error;
|
||||
use typeshare::typeshare;
|
||||
|
||||
define_config! {
|
||||
DatabasePath(DatabasePath),
|
||||
LibraryPath(LibraryPath),
|
||||
Me(Me),
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, ConfigOption)]
|
||||
pub struct DatabasePath(pub PathBuf);
|
||||
pub struct LibraryPath(pub PathBuf);
|
||||
|
||||
impl std::ops::Deref for DatabasePath {
|
||||
impl std::ops::Deref for LibraryPath {
|
||||
type Target = PathBuf;
|
||||
fn deref(&self) -> &Self::Target {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl From<String> for LibraryPath {
|
||||
fn from(s: String) -> Self {
|
||||
Self(PathBuf::from(s))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, ConfigOption)]
|
||||
pub struct Me(Player);
|
||||
|
||||
|
@ -46,14 +51,12 @@ pub enum BoardError {
|
|||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Hash, Eq, Serialize, Deserialize)]
|
||||
#[typeshare]
|
||||
pub enum Color {
|
||||
Black,
|
||||
White,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[typeshare]
|
||||
pub struct Size {
|
||||
pub width: u8,
|
||||
pub height: u8,
|
||||
|
@ -75,7 +78,7 @@ pub struct AppState {
|
|||
}
|
||||
|
||||
impl AppState {
|
||||
pub fn new(database_path: DatabasePath) -> Self {
|
||||
pub fn new(database_path: LibraryPath) -> Self {
|
||||
Self {
|
||||
game: Some(GameState::default()),
|
||||
database: Database::open_path(database_path.to_path_buf()).unwrap(),
|
||||
|
@ -93,7 +96,6 @@ impl AppState {
|
|||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[typeshare]
|
||||
pub enum Rank {
|
||||
Kyu(u8),
|
||||
Dan(u8),
|
||||
|
@ -126,8 +128,8 @@ pub struct Player {
|
|||
|
||||
#[derive(Debug)]
|
||||
pub struct GameState {
|
||||
pub board: Board,
|
||||
pub past_positions: Vec<Board>,
|
||||
pub board: Goban,
|
||||
pub past_positions: Vec<Goban>,
|
||||
|
||||
pub conversation: Vec<String>,
|
||||
pub current_player: Color,
|
||||
|
@ -142,7 +144,7 @@ pub struct GameState {
|
|||
impl Default for GameState {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
board: Board::new(),
|
||||
board: Goban::new(),
|
||||
past_positions: vec![],
|
||||
conversation: vec![],
|
||||
current_player: Color::Black,
|
||||
|
@ -194,7 +196,7 @@ mod test {
|
|||
#[test]
|
||||
fn current_player_remains_the_same_after_self_capture() {
|
||||
let mut state = GameState::default();
|
||||
state.board = Board::from_coordinates(
|
||||
state.board = Goban::from_coordinates(
|
||||
vec![
|
||||
(Coordinate { column: 17, row: 0 }, Color::White),
|
||||
(Coordinate { column: 17, row: 1 }, Color::White),
|
||||
|
@ -215,7 +217,7 @@ mod test {
|
|||
#[test]
|
||||
fn ko_rules_are_enforced() {
|
||||
let mut state = GameState::default();
|
||||
state.board = Board::from_coordinates(
|
||||
state.board = Goban::from_coordinates(
|
||||
vec![
|
||||
(Coordinate { column: 7, row: 9 }, Color::White),
|
||||
(Coordinate { column: 8, row: 8 }, Color::White),
|
||||
|
|
|
@ -1,24 +0,0 @@
|
|||
use crate::{
|
||||
types::{Config, DatabasePath},
|
||||
ui::Field,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use typeshare::typeshare;
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
#[typeshare]
|
||||
pub struct ConfigurationView {
|
||||
pub library: Field<()>,
|
||||
}
|
||||
|
||||
pub fn configuration(config: &Config) -> ConfigurationView {
|
||||
let path: Option<DatabasePath> = config.get();
|
||||
ConfigurationView {
|
||||
library: Field {
|
||||
id: "library-path-field".to_owned(),
|
||||
label: "Library".to_owned(),
|
||||
value: path.map(|path| path.to_string_lossy().into_owned()),
|
||||
action: (),
|
||||
},
|
||||
}
|
||||
}
|
|
@ -1,9 +1,7 @@
|
|||
use serde::{Deserialize, Serialize};
|
||||
use sgf::go::{Game, GameResult, Win};
|
||||
use typeshare::typeshare;
|
||||
use sgf::{Game, GameResult, Win};
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
#[typeshare]
|
||||
pub struct GamePreviewElement {
|
||||
pub date: String,
|
||||
pub name: String,
|
||||
|
@ -23,13 +21,13 @@ impl GamePreviewElement {
|
|||
None => "unknown".to_owned(),
|
||||
};
|
||||
|
||||
let black_player = match game.info.black_rank {
|
||||
Some(rank) => format!("{} ({})", black_player, rank.to_string()),
|
||||
let black_player = match &game.info.black_rank {
|
||||
Some(rank) => format!("{} ({})", black_player, rank),
|
||||
None => black_player,
|
||||
};
|
||||
|
||||
let white_player = match game.info.white_rank {
|
||||
Some(rank) => format!("{} ({})", white_player, rank.to_string()),
|
||||
let white_player = match &game.info.white_rank {
|
||||
Some(rank) => format!("{} ({})", white_player, rank),
|
||||
None => white_player,
|
||||
};
|
||||
|
||||
|
@ -43,10 +41,11 @@ impl GamePreviewElement {
|
|||
Win::Time => "Timeout".to_owned(),
|
||||
Win::Forfeit => "Forfeit".to_owned(),
|
||||
Win::Score(score) => format!("{:.1}", score),
|
||||
Win::Unknown => "Unknown".to_owned(),
|
||||
};
|
||||
|
||||
let result = match game.info.result {
|
||||
Some(GameResult::Annulled) => "Annulled".to_owned(),
|
||||
Some(GameResult::Void) => "Annulled".to_owned(),
|
||||
Some(GameResult::Draw) => "Draw".to_owned(),
|
||||
Some(GameResult::Black(ref win)) => format!("Black by {}", format_win(win)),
|
||||
Some(GameResult::White(ref win)) => format!("White by {}", format_win(win)),
|
||||
|
|
|
@ -1,8 +1,6 @@
|
|||
use serde::{Deserialize, Serialize};
|
||||
use typeshare::typeshare;
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
#[typeshare]
|
||||
pub struct Menu<T: PartialEq + Eq> {
|
||||
current: Option<T>,
|
||||
options: Vec<T>,
|
||||
|
|
|
@ -1,11 +1,9 @@
|
|||
use serde::{Deserialize, Serialize};
|
||||
use typeshare::typeshare;
|
||||
|
||||
pub mod game_preview;
|
||||
pub mod menu;
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
#[typeshare]
|
||||
pub struct Action<A> {
|
||||
pub id: String,
|
||||
pub label: String,
|
||||
|
@ -13,7 +11,6 @@ pub struct Action<A> {
|
|||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
#[typeshare]
|
||||
pub struct Toggle<A> {
|
||||
pub id: String,
|
||||
pub label: String,
|
||||
|
@ -22,7 +19,6 @@ pub struct Toggle<A> {
|
|||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
#[typeshare]
|
||||
pub struct Field<A> {
|
||||
pub id: String,
|
||||
pub label: String,
|
||||
|
|
|
@ -1,7 +1,6 @@
|
|||
use crate::ui::{Action, GamePreviewElement};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sgf::go::Game;
|
||||
use typeshare::typeshare;
|
||||
use sgf::Game;
|
||||
|
||||
fn rank_strings() -> Vec<String> {
|
||||
vec![
|
||||
|
@ -18,7 +17,6 @@ fn rank_strings() -> Vec<String> {
|
|||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
#[typeshare]
|
||||
pub enum PlayerElement {
|
||||
Hotseat(HotseatPlayerElement),
|
||||
// Remote(RemotePlayerElement),
|
||||
|
@ -32,7 +30,6 @@ impl Default for PlayerElement {
|
|||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, Default)]
|
||||
#[typeshare]
|
||||
pub struct HotseatPlayerElement {
|
||||
pub placeholder: Option<String>,
|
||||
pub default_rank: Option<String>,
|
||||
|
@ -40,15 +37,12 @@ pub struct HotseatPlayerElement {
|
|||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
#[typeshare]
|
||||
pub struct RemotePlayerElement {}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
#[typeshare]
|
||||
pub struct BotPlayerElement {}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
#[typeshare]
|
||||
pub struct HomeView {
|
||||
pub black_player: PlayerElement,
|
||||
pub white_player: PlayerElement,
|
||||
|
|
|
@ -3,10 +3,8 @@ use crate::{
|
|||
ui::types,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use typeshare::typeshare;
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
#[typeshare]
|
||||
pub struct PlayingFieldView {
|
||||
pub board: types::BoardElement,
|
||||
pub player_card_black: types::PlayerCardElement,
|
||||
|
|
|
@ -1,20 +1,17 @@
|
|||
use crate::types::{Color, Size};
|
||||
use crate::{
|
||||
api::{CoreRequest, PlayStoneRequest},
|
||||
Board, Coordinate,
|
||||
Coordinate, Goban,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use typeshare::typeshare;
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[typeshare]
|
||||
pub struct Jitter {
|
||||
pub x: i8,
|
||||
pub y: i8,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[typeshare]
|
||||
pub struct StoneElement {
|
||||
pub color: Color,
|
||||
pub jitter: Jitter,
|
||||
|
@ -32,8 +29,6 @@ impl StoneElement {
|
|||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[typeshare]
|
||||
#[serde(tag = "type", content = "content")]
|
||||
pub enum IntersectionElement {
|
||||
Unplayable,
|
||||
Empty(CoreRequest),
|
||||
|
@ -41,7 +36,6 @@ pub enum IntersectionElement {
|
|||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
#[typeshare]
|
||||
pub struct BoardElement {
|
||||
pub size: Size,
|
||||
pub spaces: Vec<IntersectionElement>,
|
||||
|
@ -80,8 +74,8 @@ impl BoardElement {
|
|||
}
|
||||
}
|
||||
|
||||
impl From<&Board> for BoardElement {
|
||||
fn from(board: &Board) -> Self {
|
||||
impl From<&Goban> for BoardElement {
|
||||
fn from(board: &Goban) -> Self {
|
||||
let spaces: Vec<IntersectionElement> =
|
||||
(0..board.size.height)
|
||||
.map(|row| {
|
||||
|
@ -114,7 +108,6 @@ impl Default for BoardElement {
|
|||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
#[typeshare]
|
||||
pub struct PlayerCardElement {
|
||||
pub color: Color,
|
||||
pub name: String,
|
||||
|
@ -123,11 +116,9 @@ pub struct PlayerCardElement {
|
|||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
#[typeshare]
|
||||
pub struct ChatElement {
|
||||
pub messages: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
#[typeshare]
|
||||
pub struct TextFieldElement {}
|
||||
|
|
|
@ -1,47 +0,0 @@
|
|||
<!doctype html>
|
||||
<html>
|
||||
<meta charset="utf-8">
|
||||
<link rel="stylesheet" type="text/css" href="styles.css" />
|
||||
|
||||
<body>
|
||||
<div class="menu">
|
||||
<ul>
|
||||
<li> <a href="index.html">Index</a> </li>
|
||||
<li> <a href="playing.html">Game Board for playing</a> </li>
|
||||
<li> <a href="database.html">Game database</a> </li>
|
||||
<li> <a href="analysis.html">Game board for analysis</a> </li>
|
||||
<li> Connection management </li>
|
||||
<li> Challenge list </li>
|
||||
<li> Friends list </li>
|
||||
<li> Open challenges </li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="widget">
|
||||
<div> US Go Congress 2023, DDK Tournament, Round 1, Board 25 </div>
|
||||
<div class="game-analysis">
|
||||
<div class="game-analysis__board">
|
||||
<img src="game-screen.jpg" />
|
||||
</div>
|
||||
|
||||
<div class="game-analysis__tree">
|
||||
<div>
|
||||
<img src="game-tree.jpg" />
|
||||
</div>
|
||||
<p> Shoring up the wall. Any chance to capture that group was lost a couple of moves back and Savanni knows it. </p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="game-info">
|
||||
<div class="player-info">
|
||||
<div> Savanni (10k) </div>
|
||||
</div>
|
||||
<div class="player-info">
|
||||
<div> Opal (10k) </div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
|
|
@ -1,118 +0,0 @@
|
|||
<!doctype html>
|
||||
<html>
|
||||
<meta charset="utf-8">
|
||||
<link rel="stylesheet" type="text/css" href="styles.css" />
|
||||
|
||||
<body>
|
||||
<div class="menu">
|
||||
<ul>
|
||||
<li> <a href="index.html">Index</a> </li>
|
||||
<li> <a href="playing.html">Game Board for playing</a> </li>
|
||||
<li> <a href="database.html">Game database</a> </li>
|
||||
<li> Game board for analysis </li>
|
||||
<li> Connection management </li>
|
||||
<li> Challenge list </li>
|
||||
<li> Friends list </li>
|
||||
<li> Open challenges </li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="widget">
|
||||
<div class="game-filter">
|
||||
<input class="game-filter__term" placeholder="date" />
|
||||
<input class="game-filter__term" placeholder="player name" />
|
||||
<input class="game-filter__term" placeholder="minimum strength" />
|
||||
<input class="game-filter__term" placeholder="maximum strength" />
|
||||
</div>
|
||||
|
||||
<div class="game-database">
|
||||
<div class="game-entry">
|
||||
<a href="analysis.html"><img class="game-entry__icon" src="game-thumbnail.jpg" /></a>
|
||||
<div class="game-entry__info-card">
|
||||
<div class="game-entry__info-row">
|
||||
2016-06-15
|
||||
</div>
|
||||
|
||||
<div class="game-entry__info-row">
|
||||
(B) Alpha Go (w)
|
||||
</div>
|
||||
|
||||
<div class="game-entry__info-row">
|
||||
(W) Lee Sedol
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="game-entry">
|
||||
<img class="game-entry__icon" src="game-thumbnail.jpg" />
|
||||
<div class="game-entry__info-card">
|
||||
<div class="game-entry__info-row">
|
||||
2016-06-15
|
||||
</div>
|
||||
|
||||
<div class="game-entry__info-row">
|
||||
(B) Alpha Go (w)
|
||||
</div>
|
||||
|
||||
<div class="game-entry__info-row">
|
||||
(W) Lee Sedol
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="game-entry">
|
||||
<img class="game-entry__icon" src="game-thumbnail.jpg" />
|
||||
<div class="game-entry__info-card">
|
||||
<div class="game-entry__info-row">
|
||||
2016-06-15
|
||||
</div>
|
||||
|
||||
<div class="game-entry__info-row">
|
||||
(B) Alpha Go (w)
|
||||
</div>
|
||||
|
||||
<div class="game-entry__info-row">
|
||||
(W) Lee Sedol
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="game-entry">
|
||||
<img class="game-entry__icon" src="game-thumbnail.jpg" />
|
||||
<div class="game-entry__info-card">
|
||||
<div class="game-entry__info-row">
|
||||
2016-06-15
|
||||
</div>
|
||||
|
||||
<div class="game-entry__info-row">
|
||||
(B) Alpha Go (w)
|
||||
</div>
|
||||
|
||||
<div class="game-entry__info-row">
|
||||
(W) Lee Sedol
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="game-entry">
|
||||
<img class="game-entry__icon" src="game-thumbnail.jpg" />
|
||||
<div class="game-entry__info-card">
|
||||
<div class="game-entry__info-row">
|
||||
2016-06-15
|
||||
</div>
|
||||
|
||||
<div class="game-entry__info-row">
|
||||
(B) Alpha Go (w)
|
||||
</div>
|
||||
|
||||
<div class="game-entry__info-row">
|
||||
(W) Lee Sedol
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue