Skip to content

Commit a462511

Browse files
authored
Merge pull request #39 from dantleech/filter-dsl
Filter dsl
2 parents e9a0925 + 39d81c8 commit a462511

File tree

11 files changed

+629
-26
lines changed

11 files changed

+629
-26
lines changed

README.md

Lines changed: 58 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -6,24 +6,73 @@ Strava TUI written in Rust! This is an experimental TUI for Strava.
66
Features:
77

88
- List activities in a comparable way
9-
- Filter activites by name
9+
- Filter activites by with expressions
1010
- Sort listed activities
1111
- Display the route
1212
- Show laps
1313
- Race predictions
1414
- Filter by route similarity ("anchoring")
1515

16+
## Screenshots
17+
1618
### List activities
1719

18-
![image](https://github.com/dantleech/strava-rs/assets/530801/7187befb-65e2-4fbc-b5b4-8710510c5e1a)
19-
*Numbers*
20+
![image](https://github.com/user-attachments/assets/f13ed611-d764-4941-a3df-c95db8636ba7)
21+
22+
### Acivity View
23+
24+
![image](https://github.com/user-attachments/assets/88c9b34a-7cee-409d-9d01-39bd22ef8259)
25+
26+
## Key Map
27+
28+
- `q`: **Quit**: quit!
29+
- `k`: **Up** - select previous activity
30+
- `j`: **Down** - select next activity
31+
- `n`: **Next** - (in activity view) next split
32+
- `p`: **Previous** - (in activity view) previous split
33+
- `o`: **ToggleSortOrder** - switch between ascending and descending order
34+
- `u`: **ToggleUnitSystem** - switch between imperial and metric units
35+
- `s`: **Sort** - show sort dialog
36+
- `S`: **Rank** - choose ranking
37+
- `f`: **Filter** - filter (see filter section below)
38+
- `r`: **Refresh** - reload activities
39+
- `a`: **Anchor** - show activities with similar routes
40+
- `+`: **IncreaseTolerance** - incease the anchor tolerance
41+
- `-`: **DecreaseTolerance** - descrease the ancor tolerance
42+
- `0`: **ToggleLogView** - toggle log view
43+
44+
## Filter
45+
46+
Press `f` on the activity list view to open the filter input.
47+
48+
### Examples
49+
50+
Show all runs that are of a half marathon distance or more:
51+
52+
```
53+
type = "Run" and distance > 21000
54+
```
55+
56+
Show all runs with "Park" in the title:
57+
58+
```
59+
type = "Run" and title ~ "Park"
60+
```
2061

21-
### Filter activities
62+
### Fields
2263

23-
![image](https://github.com/dantleech/strava-rs/assets/530801/42a5a2e2-0925-4d1f-a780-e1a5d11b0ab1)
24-
*Chronological*
64+
- `distance`: Distance (in meters)
65+
- `type`: `Run`, `Ride` etc.
66+
- `heartrate`: Heart rate in BPM.
67+
- `title`: Activity title
68+
- `elevation`: Elevation (in meters)
69+
- `time`: Time (in seconds, 3600 = 1 hour)
2570

26-
### Details
71+
### Operators
2772

28-
![image](https://github.com/dantleech/strava-rs/assets/530801/633ea4ff-12c8-4ead-817b-80db8efcf61a)
29-
*Detailed Maps*
73+
- `>`, `<`: Greater than, Less than (e.g. `distance > 21000`)
74+
- `and`, `or`: Logical operators (e.g. `type = "Run" and time > 0`)
75+
- `=`: Equal to
76+
- `~`: String contains
77+
- `!=`: Not equal to (e.g. `type != "Run"`)
78+
- `!~`: String does not contain (e.g. `title ~ "Parkrun"`)

src/app.rs

Lines changed: 17 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -8,14 +8,16 @@ use log::info;
88
use tokio::sync::mpsc::{Receiver, Sender};
99
use tui::{
1010
backend::{Backend, CrosstermBackend},
11-
widgets::TableState, Terminal,
11+
widgets::TableState,
12+
Terminal,
1213
};
1314
use tui_input::Input;
1415
use tui_logger::TuiWidgetState;
1516

1617
use crate::{
17-
component::{activity_list, unit_formatter::UnitFormatter, log_view::LogView},
18+
component::{activity_list, log_view::LogView, unit_formatter::UnitFormatter},
1819
event::keymap::KeyMap,
20+
expr::evaluator::Evaluator,
1921
store::activity::Activity,
2022
ui,
2123
};
@@ -138,7 +140,8 @@ impl App<'_> {
138140
pace_table_state: TableState::default(),
139141
selected_split: None,
140142
},
141-
log_view_state: TuiWidgetState::default().set_default_display_level(log::LevelFilter::Debug),
143+
log_view_state: TuiWidgetState::default()
144+
.set_default_display_level(log::LevelFilter::Debug),
142145
filters: ActivityFilters {
143146
sort_by: SortBy::Date,
144147
sort_order: SortOrder::Desc,
@@ -176,8 +179,8 @@ impl App<'_> {
176179

177180
let mut view: Box<dyn View> = match self.active_page {
178181
ActivePage::ActivityList => Box::new(ActivityList::new()),
179-
ActivePage::Activity => Box::new(ActivityView{}),
180-
ActivePage::LogView => Box::new(LogView::new())
182+
ActivePage::Activity => Box::new(ActivityView {}),
183+
ActivePage::LogView => Box::new(LogView::new()),
181184
};
182185

183186
if let Some(message) = &self.info_message {
@@ -194,9 +197,7 @@ impl App<'_> {
194197
while self.event_queue.len() > 1 {
195198
let event = self.event_queue.pop().unwrap();
196199
info!("Sending event: {:?}", event);
197-
self.event_sender
198-
.send(event)
199-
.await?;
200+
self.event_sender.send(event).await?;
200201
}
201202

202203
if let Some(event) = self.event_receiver.recv().await {
@@ -227,7 +228,13 @@ impl App<'_> {
227228

228229
pub async fn reload(&mut self) {
229230
let mut activities = self.store.activities().await;
230-
activities = activities.where_title_contains(self.filters.filter.as_str());
231+
232+
let mut evaluator = Evaluator::new();
233+
activities = match evaluator.parse(self.filters.filter.as_str()) {
234+
Ok(expr) => activities.by_expr(&evaluator, &expr),
235+
Err(_) => activities.where_title_contains(self.filters.filter.as_str()),
236+
};
237+
231238
if let Some(activity_type) = self.activity_type.clone() {
232239
activities = activities.having_activity_type(activity_type);
233240
}
@@ -293,7 +300,7 @@ impl App<'_> {
293300
fn render(
294301
&mut self,
295302
terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
296-
view: &mut dyn View
303+
view: &mut dyn View,
297304
) -> Result<(), anyhow::Error> {
298305
let area = terminal.size().expect("Could not determine terminal size'");
299306
terminal.autoresize()?;

src/component/activity_view.rs

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
use tui::{
22
layout::{Constraint, Direction, Layout, Margin},
33
prelude::Buffer,
4-
widgets::{Block, Borders, Widget, Paragraph},
4+
widgets::{Block, Borders, Widget},
55
};
66

77
use crate::{
@@ -78,15 +78,13 @@ impl View for ActivityView {
7878
fn draw(&mut self, app: &mut App, f: &mut Buffer, area: tui::layout::Rect) {
7979
let rows = Layout::default()
8080
.direction(Direction::Vertical)
81-
.constraints([Constraint::Length(3), Constraint::Min(1), Constraint::Length(2)].as_ref())
81+
.constraints([Constraint::Length(4), Constraint::Length(2)].as_ref())
8282
.split(area);
8383

8484
if let Some(activity) = &app.activity {
8585
{
8686
let a = Activities::from(activity.clone());
8787
activity_list_table(app, &a).render(rows[0], f);
88-
let desc = Paragraph::new(activity.description.as_str());
89-
desc.render(rows[1], f);
9088
}
9189
}
9290

@@ -100,7 +98,7 @@ impl View for ActivityView {
10098
]
10199
.as_ref(),
102100
)
103-
.split(rows[2]);
101+
.split(rows[1]);
104102
let col1 = Layout::default()
105103
.direction(Direction::Vertical)
106104
.constraints([Constraint::Percentage(50), Constraint::Percentage(50)].as_ref())

src/component/polyline.rs

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,11 @@ pub fn draw(
2929
}
3030

3131
if let Ok(decoded) = activity.polyline() {
32-
let mapped_polyline = ActivityMap::from_polyline(decoded, area.width - 4, area.height - 4);
32+
let mapped_polyline = ActivityMap::from_polyline(
33+
decoded,
34+
area.width.saturating_add(4),
35+
area.height.saturating_sub(4)
36+
);
3337

3438
let length_per_split =
3539
mapped_polyline.length() / ((activity.distance / 1000.0) * KILOMETER_TO_MILE);

src/expr/evaluator.rs

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
use std::collections::HashMap;
2+
3+
use super::parser::{Expr, Parser};
4+
5+
pub type Vars = HashMap<String, Evalue>;
6+
7+
pub struct Evaluator {}
8+
9+
#[derive(PartialEq, PartialOrd, Debug, Clone)]
10+
pub enum Evalue {
11+
String(String),
12+
Number(f64),
13+
Bool(bool),
14+
}
15+
impl Evalue {
16+
fn to_bool(&self) -> bool {
17+
match self {
18+
Evalue::String(v) => v != "" && v != "0",
19+
Evalue::Number(n) => *n != 0.0,
20+
Evalue::Bool(b) => *b,
21+
}
22+
}
23+
24+
fn to_string(&self) -> String {
25+
match self {
26+
Evalue::String(v) => v.clone(),
27+
Evalue::Number(n) => format!("{}", *n),
28+
Evalue::Bool(b) => match b {
29+
true => "true".to_string(),
30+
false => "false".to_string(),
31+
},
32+
}
33+
}
34+
}
35+
36+
impl Evaluator {
37+
pub fn new() -> Evaluator {
38+
Evaluator {}
39+
}
40+
41+
pub fn parse(&mut self, expr: &str) -> Result<Expr, String> {
42+
Parser::new(expr).parse()
43+
}
44+
45+
pub fn parse_and_evaluate(&mut self, expr: &str, vars: &Vars) -> Result<bool, String> {
46+
let expr = Parser::new(expr).parse()?;
47+
self.evaluate(&expr, vars)
48+
}
49+
50+
pub fn evaluate(&self, expr: &Expr, vars: &Vars) -> Result<bool, String> {
51+
match self.evaluate_expr(&expr, vars)? {
52+
Evalue::String(_) | Evalue::Number(_) => {
53+
Err(format!("expression must evluate to a boolean, got: {:?}", expr).to_string())
54+
}
55+
Evalue::Bool(b) => Ok(b),
56+
}
57+
}
58+
59+
fn evaluate_expr(&self, expr: &super::parser::Expr, vars: &Vars) -> Result<Evalue, String> {
60+
match expr {
61+
super::parser::Expr::Boolean(b) => Ok(Evalue::Bool(*b)),
62+
super::parser::Expr::String(s) => Ok(Evalue::String(s.clone())),
63+
super::parser::Expr::Binary(lexpr, op, rexpr) => {
64+
let lval = self.evaluate_expr(lexpr, vars)?;
65+
let rval = self.evaluate_expr(rexpr, vars)?;
66+
let eval = match op {
67+
super::lexer::TokenKind::GreaterThan => Ok(lval > rval),
68+
super::lexer::TokenKind::GreaterThanEqual => Ok(lval >= rval),
69+
super::lexer::TokenKind::LessThanEqual => Ok(lval <= rval),
70+
super::lexer::TokenKind::LessThan => Ok(lval < rval),
71+
super::lexer::TokenKind::Equal => Ok(lval == rval),
72+
super::lexer::TokenKind::FuzzyEqual => Ok(lval.to_string().contains(rval.to_string().as_str())),
73+
super::lexer::TokenKind::NotEqual => Ok(lval != rval),
74+
super::lexer::TokenKind::NotFuzzyEqual => Ok(!lval.to_string().contains(rval.to_string().as_str())),
75+
super::lexer::TokenKind::Or => Ok(lval.to_bool() || rval.to_bool()),
76+
super::lexer::TokenKind::And => Ok(lval.to_bool() && rval.to_bool()),
77+
_ => Err(format!("unknown operator: {:?}", op)),
78+
}?;
79+
Ok(Evalue::Bool(eval))
80+
}
81+
super::parser::Expr::Number(n) => Ok(Evalue::Number(*n)),
82+
super::parser::Expr::Variable(v) => match vars.get(v) {
83+
Some(v) => Ok(v.clone()),
84+
None => Err(format!("Unknown variable `{}`", v)),
85+
},
86+
}
87+
}
88+
}
89+
90+
#[cfg(test)]
91+
mod test {
92+
use super::*;
93+
94+
#[test]
95+
fn test_evaluate() {
96+
let result = Evaluator::new().parse_and_evaluate("false", &HashMap::new());
97+
assert_eq!(false, result.unwrap());
98+
let result = Evaluator::new().parse_and_evaluate("20 > 10", &HashMap::new());
99+
100+
assert_eq!(true, result.unwrap());
101+
102+
let result = Evaluator::new().parse_and_evaluate("20 > 10 and false", &HashMap::new());
103+
104+
assert_eq!(false, result.unwrap());
105+
}
106+
107+
#[test]
108+
fn test_evaluate_params() {
109+
let map = HashMap::from([
110+
("distance".to_string(), Evalue::Number(10.0)),
111+
("type".to_string(), Evalue::String("Run".to_string())),
112+
]);
113+
let result = Evaluator::new().parse_and_evaluate("distance > 5", &map);
114+
assert_eq!(true, result.unwrap());
115+
let result = Evaluator::new().parse_and_evaluate("distance < 5", &map);
116+
assert_eq!(false, result.unwrap());
117+
let result = Evaluator::new().parse_and_evaluate("distance = 10", &map);
118+
assert_eq!(true, result.unwrap());
119+
let result = Evaluator::new().parse_and_evaluate("type = 'Run'", &map);
120+
assert_eq!(true, result.unwrap());
121+
let result = Evaluator::new().parse_and_evaluate("type ~ 'Ru'", &map);
122+
assert_eq!(true, result.unwrap());
123+
let result = Evaluator::new().parse_and_evaluate("type !~ 'Rup'", &map);
124+
assert_eq!(true, result.unwrap());
125+
let result = Evaluator::new().parse_and_evaluate("type != 'Run'", &map);
126+
assert_eq!(false, result.unwrap());
127+
}
128+
}

0 commit comments

Comments
 (0)