Skip to content

Commit

Permalink
Merge pull request #39 from dantleech/filter-dsl
Browse files Browse the repository at this point in the history
Filter dsl
  • Loading branch information
dantleech authored Nov 10, 2024
2 parents e9a0925 + 39d81c8 commit a462511
Show file tree
Hide file tree
Showing 11 changed files with 629 additions and 26 deletions.
67 changes: 58 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,24 +6,73 @@ Strava TUI written in Rust! This is an experimental TUI for Strava.
Features:

- List activities in a comparable way
- Filter activites by name
- Filter activites by with expressions
- Sort listed activities
- Display the route
- Show laps
- Race predictions
- Filter by route similarity ("anchoring")

## Screenshots

### List activities

![image](https://github.com/dantleech/strava-rs/assets/530801/7187befb-65e2-4fbc-b5b4-8710510c5e1a)
*Numbers*
![image](https://github.com/user-attachments/assets/f13ed611-d764-4941-a3df-c95db8636ba7)

### Acivity View

![image](https://github.com/user-attachments/assets/88c9b34a-7cee-409d-9d01-39bd22ef8259)

## Key Map

- `q`: **Quit**: quit!
- `k`: **Up** - select previous activity
- `j`: **Down** - select next activity
- `n`: **Next** - (in activity view) next split
- `p`: **Previous** - (in activity view) previous split
- `o`: **ToggleSortOrder** - switch between ascending and descending order
- `u`: **ToggleUnitSystem** - switch between imperial and metric units
- `s`: **Sort** - show sort dialog
- `S`: **Rank** - choose ranking
- `f`: **Filter** - filter (see filter section below)
- `r`: **Refresh** - reload activities
- `a`: **Anchor** - show activities with similar routes
- `+`: **IncreaseTolerance** - incease the anchor tolerance
- `-`: **DecreaseTolerance** - descrease the ancor tolerance
- `0`: **ToggleLogView** - toggle log view

## Filter

Press `f` on the activity list view to open the filter input.

### Examples

Show all runs that are of a half marathon distance or more:

```
type = "Run" and distance > 21000
```

Show all runs with "Park" in the title:

```
type = "Run" and title ~ "Park"
```

### Filter activities
### Fields

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

### Details
### Operators

![image](https://github.com/dantleech/strava-rs/assets/530801/633ea4ff-12c8-4ead-817b-80db8efcf61a)
*Detailed Maps*
- `>`, `<`: Greater than, Less than (e.g. `distance > 21000`)
- `and`, `or`: Logical operators (e.g. `type = "Run" and time > 0`)
- `=`: Equal to
- `~`: String contains
- `!=`: Not equal to (e.g. `type != "Run"`)
- `!~`: String does not contain (e.g. `title ~ "Parkrun"`)
27 changes: 17 additions & 10 deletions src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,16 @@ use log::info;
use tokio::sync::mpsc::{Receiver, Sender};
use tui::{
backend::{Backend, CrosstermBackend},
widgets::TableState, Terminal,
widgets::TableState,
Terminal,
};
use tui_input::Input;
use tui_logger::TuiWidgetState;

use crate::{
component::{activity_list, unit_formatter::UnitFormatter, log_view::LogView},
component::{activity_list, log_view::LogView, unit_formatter::UnitFormatter},
event::keymap::KeyMap,
expr::evaluator::Evaluator,
store::activity::Activity,
ui,
};
Expand Down Expand Up @@ -138,7 +140,8 @@ impl App<'_> {
pace_table_state: TableState::default(),
selected_split: None,
},
log_view_state: TuiWidgetState::default().set_default_display_level(log::LevelFilter::Debug),
log_view_state: TuiWidgetState::default()
.set_default_display_level(log::LevelFilter::Debug),
filters: ActivityFilters {
sort_by: SortBy::Date,
sort_order: SortOrder::Desc,
Expand Down Expand Up @@ -176,8 +179,8 @@ impl App<'_> {

let mut view: Box<dyn View> = match self.active_page {
ActivePage::ActivityList => Box::new(ActivityList::new()),
ActivePage::Activity => Box::new(ActivityView{}),
ActivePage::LogView => Box::new(LogView::new())
ActivePage::Activity => Box::new(ActivityView {}),
ActivePage::LogView => Box::new(LogView::new()),
};

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

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

pub async fn reload(&mut self) {
let mut activities = self.store.activities().await;
activities = activities.where_title_contains(self.filters.filter.as_str());

let mut evaluator = Evaluator::new();
activities = match evaluator.parse(self.filters.filter.as_str()) {
Ok(expr) => activities.by_expr(&evaluator, &expr),
Err(_) => activities.where_title_contains(self.filters.filter.as_str()),
};

if let Some(activity_type) = self.activity_type.clone() {
activities = activities.having_activity_type(activity_type);
}
Expand Down Expand Up @@ -293,7 +300,7 @@ impl App<'_> {
fn render(
&mut self,
terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
view: &mut dyn View
view: &mut dyn View,
) -> Result<(), anyhow::Error> {
let area = terminal.size().expect("Could not determine terminal size'");
terminal.autoresize()?;
Expand Down
8 changes: 3 additions & 5 deletions src/component/activity_view.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
use tui::{
layout::{Constraint, Direction, Layout, Margin},
prelude::Buffer,
widgets::{Block, Borders, Widget, Paragraph},
widgets::{Block, Borders, Widget},
};

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

if let Some(activity) = &app.activity {
{
let a = Activities::from(activity.clone());
activity_list_table(app, &a).render(rows[0], f);
let desc = Paragraph::new(activity.description.as_str());
desc.render(rows[1], f);
}
}

Expand All @@ -100,7 +98,7 @@ impl View for ActivityView {
]
.as_ref(),
)
.split(rows[2]);
.split(rows[1]);
let col1 = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Percentage(50), Constraint::Percentage(50)].as_ref())
Expand Down
6 changes: 5 additions & 1 deletion src/component/polyline.rs
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,11 @@ pub fn draw(
}

if let Ok(decoded) = activity.polyline() {
let mapped_polyline = ActivityMap::from_polyline(decoded, area.width - 4, area.height - 4);
let mapped_polyline = ActivityMap::from_polyline(
decoded,
area.width.saturating_add(4),
area.height.saturating_sub(4)
);

let length_per_split =
mapped_polyline.length() / ((activity.distance / 1000.0) * KILOMETER_TO_MILE);
Expand Down
128 changes: 128 additions & 0 deletions src/expr/evaluator.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
use std::collections::HashMap;

use super::parser::{Expr, Parser};

pub type Vars = HashMap<String, Evalue>;

pub struct Evaluator {}

#[derive(PartialEq, PartialOrd, Debug, Clone)]
pub enum Evalue {
String(String),
Number(f64),
Bool(bool),
}
impl Evalue {
fn to_bool(&self) -> bool {
match self {
Evalue::String(v) => v != "" && v != "0",
Evalue::Number(n) => *n != 0.0,
Evalue::Bool(b) => *b,
}
}

fn to_string(&self) -> String {
match self {
Evalue::String(v) => v.clone(),
Evalue::Number(n) => format!("{}", *n),
Evalue::Bool(b) => match b {
true => "true".to_string(),
false => "false".to_string(),
},
}
}
}

impl Evaluator {
pub fn new() -> Evaluator {
Evaluator {}
}

pub fn parse(&mut self, expr: &str) -> Result<Expr, String> {
Parser::new(expr).parse()
}

pub fn parse_and_evaluate(&mut self, expr: &str, vars: &Vars) -> Result<bool, String> {
let expr = Parser::new(expr).parse()?;
self.evaluate(&expr, vars)
}

pub fn evaluate(&self, expr: &Expr, vars: &Vars) -> Result<bool, String> {
match self.evaluate_expr(&expr, vars)? {
Evalue::String(_) | Evalue::Number(_) => {
Err(format!("expression must evluate to a boolean, got: {:?}", expr).to_string())
}
Evalue::Bool(b) => Ok(b),
}
}

fn evaluate_expr(&self, expr: &super::parser::Expr, vars: &Vars) -> Result<Evalue, String> {
match expr {
super::parser::Expr::Boolean(b) => Ok(Evalue::Bool(*b)),
super::parser::Expr::String(s) => Ok(Evalue::String(s.clone())),
super::parser::Expr::Binary(lexpr, op, rexpr) => {
let lval = self.evaluate_expr(lexpr, vars)?;
let rval = self.evaluate_expr(rexpr, vars)?;
let eval = match op {
super::lexer::TokenKind::GreaterThan => Ok(lval > rval),
super::lexer::TokenKind::GreaterThanEqual => Ok(lval >= rval),
super::lexer::TokenKind::LessThanEqual => Ok(lval <= rval),
super::lexer::TokenKind::LessThan => Ok(lval < rval),
super::lexer::TokenKind::Equal => Ok(lval == rval),
super::lexer::TokenKind::FuzzyEqual => Ok(lval.to_string().contains(rval.to_string().as_str())),
super::lexer::TokenKind::NotEqual => Ok(lval != rval),
super::lexer::TokenKind::NotFuzzyEqual => Ok(!lval.to_string().contains(rval.to_string().as_str())),
super::lexer::TokenKind::Or => Ok(lval.to_bool() || rval.to_bool()),
super::lexer::TokenKind::And => Ok(lval.to_bool() && rval.to_bool()),
_ => Err(format!("unknown operator: {:?}", op)),
}?;
Ok(Evalue::Bool(eval))
}
super::parser::Expr::Number(n) => Ok(Evalue::Number(*n)),
super::parser::Expr::Variable(v) => match vars.get(v) {
Some(v) => Ok(v.clone()),
None => Err(format!("Unknown variable `{}`", v)),
},
}
}
}

#[cfg(test)]
mod test {
use super::*;

#[test]
fn test_evaluate() {
let result = Evaluator::new().parse_and_evaluate("false", &HashMap::new());
assert_eq!(false, result.unwrap());
let result = Evaluator::new().parse_and_evaluate("20 > 10", &HashMap::new());

assert_eq!(true, result.unwrap());

let result = Evaluator::new().parse_and_evaluate("20 > 10 and false", &HashMap::new());

assert_eq!(false, result.unwrap());
}

#[test]
fn test_evaluate_params() {
let map = HashMap::from([
("distance".to_string(), Evalue::Number(10.0)),
("type".to_string(), Evalue::String("Run".to_string())),
]);
let result = Evaluator::new().parse_and_evaluate("distance > 5", &map);
assert_eq!(true, result.unwrap());
let result = Evaluator::new().parse_and_evaluate("distance < 5", &map);
assert_eq!(false, result.unwrap());
let result = Evaluator::new().parse_and_evaluate("distance = 10", &map);
assert_eq!(true, result.unwrap());
let result = Evaluator::new().parse_and_evaluate("type = 'Run'", &map);
assert_eq!(true, result.unwrap());
let result = Evaluator::new().parse_and_evaluate("type ~ 'Ru'", &map);
assert_eq!(true, result.unwrap());
let result = Evaluator::new().parse_and_evaluate("type !~ 'Rup'", &map);
assert_eq!(true, result.unwrap());
let result = Evaluator::new().parse_and_evaluate("type != 'Run'", &map);
assert_eq!(false, result.unwrap());
}
}
Loading

0 comments on commit a462511

Please sign in to comment.