Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Filter dsl #39

Merged
merged 12 commits into from
Nov 10, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading