Skip to content

Commit 9b89ed5

Browse files
committed
timewarrior block
working prototype pad minutes Add some basic doc for functions wip stop_continue Redirect timew output to nulll states changes based on minutes Fix state change when stop Fix for new set_widget API
1 parent 55e9334 commit 9b89ed5

File tree

2 files changed

+210
-0
lines changed

2 files changed

+210
-0
lines changed

src/blocks.rs

+1
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,7 @@ define_blocks!(
131131
taskwarrior,
132132
temperature,
133133
time,
134+
timewarrior,
134135
tea_timer,
135136
toggle,
136137
uptime,

src/blocks/timewarrior.rs

+209
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,209 @@
1+
use super::prelude::*;
2+
use tokio::process::Command;
3+
use chrono::DateTime;
4+
5+
#[derive(Deserialize, Debug, SmartDefault)]
6+
#[serde(default)]
7+
pub struct Config {
8+
#[default(30.into())]
9+
interval : Seconds,
10+
format: FormatConfig,
11+
12+
info: Option<u64>,
13+
good: Option<u64>,
14+
warning: Option<u64>,
15+
critical: Option<u64>,
16+
}
17+
18+
pub async fn run(config: Config, mut api: CommonApi) -> Result<()> {
19+
20+
api.set_default_actions(&[
21+
(MouseButton::Left, None, "stop_continue"),
22+
])
23+
.await?;
24+
25+
let widget = Widget::new().with_format(
26+
config
27+
.format
28+
.with_default(" $icon {$elapsed|}")?,
29+
);
30+
31+
32+
loop {
33+
let mut values = map! {
34+
"icon" => Value::icon(api.get_icon("tasks")?),
35+
};
36+
let mut state = State::Idle;
37+
let mut widget = widget.clone();
38+
39+
let data = process_timewarrior_data(&call_timewarrior().await?);
40+
if let Some(tw) = data {
41+
if tw.end.is_none() {
42+
// only show active tasks
43+
let elapsed = chrono::Utc::now() - tw.start;
44+
45+
// calculate state
46+
for (level, st) in [
47+
(&config.critical, State::Critical),
48+
(&config.warning, State::Warning),
49+
(&config.good, State::Good),
50+
(&config.info, State::Info),
51+
52+
] {
53+
if let Some(value) = level {
54+
if (elapsed.num_minutes() as u64) >= *value {
55+
state = st;
56+
break;
57+
}
58+
}
59+
}
60+
61+
values.insert("tags".into(), Value::text(tw.tags.join(" ")));
62+
63+
let elapsedstr = format!("{}:{:0>2}",
64+
elapsed.num_hours(),
65+
elapsed.num_minutes()%60);
66+
values.insert("elapsed".into(), Value::text(elapsedstr));
67+
68+
if let Some(annotation) = tw.annotation {
69+
values.insert("annotation".into(), Value::text(annotation));
70+
}
71+
}
72+
}
73+
74+
widget.state = state;
75+
widget.set_values(values);
76+
api.set_widget(widget).await?;
77+
78+
select! {
79+
_ = sleep(config.interval.0) => (),
80+
event = api.event() => match event {
81+
UpdateRequest => (),
82+
Action(a) => {
83+
if a == "stop_continue" {
84+
stop_continue().await?;
85+
}
86+
},
87+
}
88+
}
89+
}
90+
}
91+
92+
/// Raw output from timew
93+
#[derive(Deserialize, Debug)]
94+
struct TimewarriorRAW {
95+
pub id : u32,
96+
pub start : String,
97+
pub tags : Vec<String>,
98+
pub annotation : Option<String>,
99+
pub end : Option<String>,
100+
}
101+
102+
/// TimeWarrior entry
103+
#[derive(Debug, PartialEq)]
104+
struct TimewarriorData {
105+
pub id : u32,
106+
pub start : DateTime<chrono::offset::Utc>,
107+
pub tags : Vec<String>,
108+
pub annotation : Option<String>,
109+
pub end : Option<DateTime<chrono::offset::Utc>>,
110+
}
111+
112+
impl From<TimewarriorRAW> for TimewarriorData {
113+
fn from(item:TimewarriorRAW) -> Self {
114+
Self {
115+
id: item.id,
116+
tags: item.tags,
117+
annotation: item.annotation,
118+
start : DateTime::from_utc(
119+
chrono::NaiveDateTime::parse_from_str(&item.start, "%Y%m%dT%H%M%SZ")
120+
.unwrap(),
121+
chrono::Utc),
122+
end : item.end.map(|v| DateTime::from_utc(
123+
chrono::NaiveDateTime::parse_from_str(&v, "%Y%m%dT%H%M%SZ")
124+
.unwrap(),
125+
chrono::Utc)),
126+
}
127+
}
128+
}
129+
130+
/// Format a DateTime given a format string
131+
#[allow(dead_code)]
132+
fn format_datetime(date:&DateTime<chrono::Utc>, format:&str) -> String {
133+
date.format(format).to_string()
134+
}
135+
136+
/// Execute "timew export now" and return the result
137+
async fn call_timewarrior() -> Result<String> {
138+
let out = Command::new("timew")
139+
.args(["export", "now"])
140+
.output()
141+
.await
142+
.error("failed to run timewarrior")?
143+
.stdout;
144+
let output = std::str::from_utf8(&out)
145+
.error("failed to read output from timewarrior (invalid UTF-8)")?
146+
.trim();
147+
Ok(output.to_string())
148+
}
149+
150+
/// Stop or continue a task
151+
async fn stop_continue() -> Result<()> {
152+
let mut execute_continue:bool = true;
153+
if let Some(tw) = process_timewarrior_data(&call_timewarrior().await?) {
154+
// we only execute continue if the current task is stopped
155+
// i.e. has an end defined
156+
execute_continue = tw.end.is_some();
157+
}
158+
159+
// is there a more rust way of doing this?
160+
let args = match execute_continue {
161+
true => "continue",
162+
false => "stop",
163+
};
164+
165+
Command::new("timew")
166+
.args(&[args])
167+
.stdout(std::process::Stdio::null())
168+
.spawn()
169+
.error("Error spawing timew")?
170+
.wait()
171+
.await
172+
.error("Error executing stop/continue")
173+
.map(|_| ())
174+
}
175+
176+
177+
/// Process the output from "timew export" and return the first entry
178+
fn process_timewarrior_data(input:&str) -> Option<TimewarriorData> {
179+
let t : Vec<TimewarriorRAW> = serde_json::from_str(input).unwrap_or_default();
180+
match t.into_iter().next() {
181+
Some(t) => Some(TimewarriorData::from(t)),
182+
None => None,
183+
}
184+
}
185+
186+
#[cfg(test)]
187+
mod tests {
188+
use super::*;
189+
190+
#[test]
191+
fn test_process_timewarrior_data() {
192+
assert_eq!(
193+
process_timewarrior_data(""),
194+
None,
195+
);
196+
197+
assert_eq!(
198+
process_timewarrior_data("[]"),
199+
None,
200+
);
201+
202+
let a = process_timewarrior_data("[{\"id\":1,\"start\":\"20230131T175754Z\",\"tags\":[\"i3status\"],\"annotation\":\"timewarrior plugin\"}]");
203+
assert_eq!(a.is_some(), true);
204+
if let Some(b) = a {
205+
assert_eq!(b.id, 1);
206+
assert_eq!(b.end, None);
207+
}
208+
}
209+
}

0 commit comments

Comments
 (0)