1use crate::api;
4use chrono::{DateTime, Duration, TimeZone, Utc};
5
6const CREATED_WITH: &str = "github.com/blachniet/tgl";
7
8pub struct Client {
9 c: api::Client,
10 get_now: fn() -> DateTime<Utc>,
11 project_cache: elsa::map::FrozenMap<(i64, i64), Box<Project>>,
12}
13
14impl Client {
15 pub fn new(token: String, get_now: fn() -> DateTime<Utc>) -> Result<Self> {
16 Ok(Self {
17 c: api::Client::new(token)?,
18 get_now,
19 project_cache: elsa::map::FrozenMap::new(),
20 })
21 }
22
23 pub fn get_latest_entries(&self) -> Result<Vec<TimeEntry>> {
24 let api_entries = self.c.get_time_entries(None)?;
25 let entries: Result<Vec<_>> = api_entries
26 .into_iter()
27 .map(|e| self.build_time_entry(e))
28 .collect();
29
30 entries
31 }
32
33 fn build_time_entry(&self, api_entry: api::TimeEntry) -> Result<TimeEntry> {
34 let project_id = api_entry.project_id.map(|pid| pid.as_i64().unwrap());
35 let project = match project_id {
36 Some(pid) => self.get_project(api_entry.workspace_id.as_i64().unwrap(), pid)?,
37 None => None,
38 };
39 let (duration, is_running) = parse_duration((self.get_now)(), api_entry.duration);
40 let start: Option<DateTime<Utc>> = match api_entry.start {
41 Some(s) => Some(s.parse()?),
42 None => None,
43 };
44 let stop: Option<DateTime<Utc>> = match api_entry.stop {
45 Some(s) => Some(s.parse()?),
46 None => None,
47 };
48
49 Ok(TimeEntry {
50 description: api_entry.description,
51 duration,
52 is_running,
53 project_id,
54 project_name: project.map(|p| p.name.to_string()),
55 start,
56 stop,
57 workspace_id: api_entry.workspace_id.as_i64().unwrap(),
58 })
59 }
60
61 pub fn start_time_entry(
62 &self,
63 workspace_id: i64,
64 project_id: Option<i64>,
65 description: Option<&str>,
66 ) -> Result<TimeEntry> {
67 let now = (self.get_now)();
68 let api_entry = self.c.create_time_entry(api::NewTimeEntry {
69 created_with: CREATED_WITH.to_string(),
70 description: description.map(|d| d.to_string()),
71 duration: (-now.timestamp()).into(),
72 project_id: project_id.map(|i| i.into()),
73 start: now.to_rfc3339(),
74 stop: None,
75 task_id: None,
76 workspace_id: workspace_id.into(),
77 })?;
78 let entry = self.build_time_entry(api_entry)?;
79
80 Ok(entry)
81 }
82
83 pub fn stop_current_time_entry(&self) -> Result<Option<TimeEntry>> {
84 if let Some(api_entry) = self.c.get_current_entry()? {
85 let api_entry = self
86 .c
87 .stop_time_entry(&api_entry.workspace_id, &api_entry.id)?;
88 let entry = self.build_time_entry(api_entry)?;
89
90 Ok(Some(entry))
91 } else {
92 Ok(None)
93 }
94 }
95
96 fn get_project(&self, workspace_id: i64, project_id: i64) -> Result<Option<&Project>> {
97 let key = (workspace_id, project_id);
98 if let Some(project) = self.project_cache.get(&key) {
99 return Ok(Some(project));
100 }
101
102 let workspace_id_num = workspace_id.into();
103 let projects = self.c.get_projects(&workspace_id_num)?;
104 for p in projects {
105 self.project_cache.insert(
106 (workspace_id, p.id.as_i64().expect("parse number as i64")),
107 Box::new(Project {
108 active: p.active,
109 id: p.id.as_i64().unwrap(),
110 name: p.name,
111 }),
112 );
113 }
114
115 Ok(self.project_cache.get(&key))
116 }
117
118 pub fn get_projects(&self, workspace_id: i64) -> Result<Vec<Project>> {
119 let api_projects = self.c.get_projects(&workspace_id.into())?;
120 let mut projects = Vec::new();
121
122 for p in api_projects {
123 self.project_cache.insert(
124 (workspace_id, p.id.as_i64().expect("parse number as i64")),
125 Box::new(Project {
126 active: p.active,
127 id: p.id.as_i64().unwrap(),
128 name: p.name.to_string(),
129 }),
130 );
131
132 projects.push(Project {
133 active: p.active,
134 id: p.id.as_i64().unwrap(),
135 name: p.name,
136 });
137 }
138
139 Ok(projects)
140 }
141
142 pub fn get_workspaces(&self) -> Result<Vec<Workspace>> {
143 let workspaces = self.c.get_workspaces()?;
144 Ok(workspaces
145 .into_iter()
146 .map(|w| Workspace {
147 id: w.id.as_i64().unwrap(),
148 name: w.name,
149 })
150 .collect())
151 }
152}
153
154fn parse_duration(now: DateTime<Utc>, duration: serde_json::Number) -> (Duration, bool) {
162 let duration = duration.as_i64().unwrap();
163 if duration < 0 {
164 (
165 now - Utc.timestamp_opt(-duration, 0).unwrap(),
168 true,
169 )
170 } else {
171 (Duration::seconds(duration), false)
172 }
173}
174
175#[derive(thiserror::Error, Debug)]
176pub enum Error {
177 #[error("reqwest error")]
178 Reqwest(#[from] reqwest::Error),
179 #[error("chrono parse error")]
180 ChronoParse(#[from] chrono::ParseError),
181}
182
183type Result<T> = std::result::Result<T, Error>;
184
185#[derive(Debug)]
186pub struct TimeEntry {
187 pub description: Option<String>,
188 pub duration: Duration,
189 pub is_running: bool,
190 pub project_id: Option<i64>,
191 pub project_name: Option<String>,
192 pub start: Option<DateTime<Utc>>,
193 pub stop: Option<DateTime<Utc>>,
194 pub workspace_id: i64,
195}
196
197#[derive(Debug)]
198pub struct Project {
199 pub active: bool,
200 pub id: i64,
201 pub name: String,
202}
203
204#[derive(Debug)]
205pub struct Workspace {
206 pub id: i64,
207 pub name: String,
208}
209
210#[cfg(test)]
211mod tests {
212 use super::*;
213
214 #[test]
215 fn parse_duration_stopped() {
216 let now = Utc.timestamp_opt(1404810600, 0).unwrap();
217 let (dur, is_running) = parse_duration(now, 30.into());
218
219 assert!(!is_running);
220 assert_eq!(30, dur.num_seconds());
221 assert_eq!(0, dur.subsec_nanos());
222 }
223
224 #[test]
225 fn parse_duration_running() {
226 let now = Utc.timestamp_opt(1404810630, 0).unwrap();
227 let (dur, is_running) = parse_duration(now, (-1404810600).into());
228
229 assert!(is_running);
230 assert_eq!(30, dur.num_seconds());
231 assert_eq!(0, dur.subsec_nanos());
232 }
233}