1use anyhow::{Context, Result, bail};
16use chrono::{DateTime, FixedOffset, NaiveDate, NaiveDateTime, TimeZone};
17use rusqlite::Connection;
18use sqlx::PgPool;
19use uuid::Uuid;
20
21const AGENT_TIME_FORMAT: &str = "%Y-%m-%d %H:%M:%S";
23
24#[derive(Debug)]
26pub struct AgentDay {
27 pub date: NaiveDate,
28 pub start: NaiveDateTime,
29 pub end: Option<NaiveDateTime>,
30 pub pauses: Vec<AgentPause>,
31 pub tasks: Vec<AgentTask>,
32}
33
34#[derive(Debug)]
35pub struct AgentPause {
36 pub start: NaiveDateTime,
37 pub end: Option<NaiveDateTime>,
38 pub duration_seconds: Option<i32>,
39 pub manual: bool,
43 pub reason: Option<String>,
44}
45
46#[derive(Debug)]
47pub struct AgentTask {
48 pub agent_task_id: i32,
49 pub agent_group_id: i32,
50 pub recorded_at: NaiveDateTime,
51 pub name: String,
52 pub comment: Option<String>,
53 pub completeness: i16,
54}
55
56#[derive(Debug, Default, PartialEq, Eq)]
58pub struct ImportSummary {
59 pub days: usize,
60 pub pauses: usize,
61 pub tasks: usize,
62 pub skipped_deleted_tasks: usize,
65 pub skipped_unreadable: usize,
67}
68
69pub fn read_agent_db(path: &std::path::Path) -> Result<(Vec<AgentDay>, ImportSummary)> {
75 if !path.exists() {
76 bail!("no such file: {}", path.display());
77 }
78
79 let connection = Connection::open_with_flags(path, rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY)
80 .with_context(|| format!("failed to open {} as a SQLite database", path.display()))?;
81
82 let mut summary = ImportSummary::default();
83 let mut days = read_workdays(&connection, &mut summary)?;
84
85 let pauses = read_pauses(&connection, &mut summary)?;
86 let breaks = read_breaks(&connection, &mut summary)?;
87 let tasks = read_tasks(&connection, &mut summary)?;
88
89 for (date, pause) in pauses.into_iter().chain(breaks) {
93 if let Some(day) = days.iter_mut().find(|day| day.date == date) {
94 day.pauses.push(pause);
95 summary.pauses += 1;
96 }
97 }
98 for (date, task) in tasks {
99 if let Some(day) = days.iter_mut().find(|day| day.date == date) {
100 day.tasks.push(task);
101 summary.tasks += 1;
102 }
103 }
104
105 summary.days = days.len();
106 Ok((days, summary))
107}
108
109fn read_workdays(connection: &Connection, summary: &mut ImportSummary) -> Result<Vec<AgentDay>> {
110 let mut statement = connection
111 .prepare("SELECT date, start, end FROM workdays ORDER BY date")
112 .context("failed to read the workdays table; is this a kasl database?")?;
113
114 let rows = statement.query_map([], |row| {
115 Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?, row.get::<_, Option<String>>(2)?))
116 })?;
117
118 let mut days = Vec::new();
119 for row in rows {
120 let (date, start, end) = row?;
121 let (Ok(date), Ok(start)) = (parse_date(&date), parse_time(&start)) else {
122 summary.skipped_unreadable += 1;
123 continue;
124 };
125 days.push(AgentDay {
126 date,
127 start,
128 end: end.as_deref().and_then(|end| parse_time(end).ok()),
129 pauses: Vec::new(),
130 tasks: Vec::new(),
131 });
132 }
133
134 Ok(days)
135}
136
137fn read_pauses(connection: &Connection, summary: &mut ImportSummary) -> Result<Vec<(NaiveDate, AgentPause)>> {
138 let mut statement = connection.prepare("SELECT start, end, duration FROM pauses ORDER BY start")?;
139 let rows = statement.query_map([], |row| {
140 Ok((row.get::<_, String>(0)?, row.get::<_, Option<String>>(1)?, row.get::<_, Option<i32>>(2)?))
141 })?;
142
143 let mut pauses = Vec::new();
144 for row in rows {
145 let (start, end, duration_seconds) = row?;
146 let Ok(start) = parse_time(&start) else {
147 summary.skipped_unreadable += 1;
148 continue;
149 };
150 pauses.push((
151 start.date(),
152 AgentPause {
153 start,
154 end: end.as_deref().and_then(|end| parse_time(end).ok()),
155 duration_seconds,
156 manual: false,
157 reason: None,
158 },
159 ));
160 }
161
162 Ok(pauses)
163}
164
165fn read_breaks(connection: &Connection, summary: &mut ImportSummary) -> Result<Vec<(NaiveDate, AgentPause)>> {
170 let Ok(mut statement) = connection.prepare("SELECT date, start_time, end_time, duration, reason FROM breaks ORDER BY start_time") else {
171 return Ok(Vec::new());
172 };
173
174 let rows = statement.query_map([], |row| {
175 Ok((
176 row.get::<_, String>(0)?,
177 row.get::<_, String>(1)?,
178 row.get::<_, Option<String>>(2)?,
179 row.get::<_, Option<i32>>(3)?,
180 row.get::<_, Option<String>>(4)?,
181 ))
182 })?;
183
184 let mut breaks = Vec::new();
185 for row in rows {
186 let (date, start, end, duration_seconds, reason) = row?;
187 let (Ok(date), Ok(start)) = (parse_date(&date), parse_time(&start)) else {
188 summary.skipped_unreadable += 1;
189 continue;
190 };
191 breaks.push((
192 date,
193 AgentPause {
194 start,
195 end: end.as_deref().and_then(|end| parse_time(end).ok()),
196 duration_seconds,
197 manual: true,
198 reason,
199 },
200 ));
201 }
202
203 Ok(breaks)
204}
205
206fn read_tasks(connection: &Connection, summary: &mut ImportSummary) -> Result<Vec<(NaiveDate, AgentTask)>> {
207 let (sql, filters_deleted) =
211 match connection.prepare("SELECT id, task_id, timestamp, name, comment, completeness FROM tasks WHERE deleted_at IS NULL ORDER BY id") {
212 Ok(_) => (
213 "SELECT id, task_id, timestamp, name, comment, completeness FROM tasks WHERE deleted_at IS NULL ORDER BY id",
214 true,
215 ),
216 Err(_) => ("SELECT id, task_id, timestamp, name, comment, completeness FROM tasks ORDER BY id", false),
217 };
218
219 if filters_deleted {
220 let deleted: i64 = connection
221 .query_row("SELECT count(*) FROM tasks WHERE deleted_at IS NOT NULL", [], |row| row.get(0))
222 .unwrap_or(0);
223 summary.skipped_deleted_tasks = deleted.max(0) as usize;
224 }
225
226 let mut statement = connection.prepare(sql)?;
227 let rows = statement.query_map([], |row| {
228 Ok((
229 row.get::<_, i32>(0)?,
230 row.get::<_, i32>(1)?,
231 row.get::<_, String>(2)?,
232 row.get::<_, String>(3)?,
233 row.get::<_, Option<String>>(4)?,
234 row.get::<_, i32>(5)?,
235 ))
236 })?;
237
238 let mut tasks = Vec::new();
239 for row in rows {
240 let (agent_task_id, task_id, recorded_at, name, comment, completeness) = row?;
241 let Ok(recorded_at) = parse_time(&recorded_at) else {
242 summary.skipped_unreadable += 1;
243 continue;
244 };
245 tasks.push((
246 recorded_at.date(),
247 AgentTask {
248 agent_task_id,
249 agent_group_id: if task_id == 0 { agent_task_id } else { task_id },
252 recorded_at,
253 name,
254 comment,
255 completeness: completeness.clamp(0, 100) as i16,
258 },
259 ));
260 }
261
262 Ok(tasks)
263}
264
265fn parse_date(raw: &str) -> Result<NaiveDate> {
266 NaiveDate::parse_from_str(raw.trim(), "%Y-%m-%d").with_context(|| format!("not a date: {raw}"))
267}
268
269fn parse_time(raw: &str) -> Result<NaiveDateTime> {
270 let raw = raw.trim();
271 NaiveDateTime::parse_from_str(raw, AGENT_TIME_FORMAT)
272 .or_else(|_| NaiveDateTime::parse_from_str(raw, "%Y-%m-%d %H:%M:%S%.f"))
275 .with_context(|| format!("not a timestamp: {raw}"))
276}
277
278pub fn within(days: Vec<AgentDay>, since: Option<NaiveDate>, until: Option<NaiveDate>) -> Vec<AgentDay> {
284 days.into_iter()
285 .filter(|day| since.is_none_or(|since| day.date >= since) && until.is_none_or(|until| day.date <= until))
286 .collect()
287}
288
289pub fn at_offset(time: NaiveDateTime, offset: FixedOffset) -> DateTime<FixedOffset> {
295 offset
298 .from_local_datetime(&time)
299 .single()
300 .expect("a fixed offset maps every local time exactly once")
301}
302
303pub async fn write_days(pool: &PgPool, user_id: Uuid, days: &[AgentDay], offset: FixedOffset) -> Result<usize> {
309 let mut written = 0;
310
311 for day in days {
312 let mut tx = pool.begin().await?;
313
314 let workday_id: Uuid = sqlx::query_scalar(
315 "INSERT INTO workdays (user_id, date, started_at, ended_at) VALUES ($1, $2, $3, $4)
316 ON CONFLICT (user_id, date) DO UPDATE SET started_at = EXCLUDED.started_at, ended_at = EXCLUDED.ended_at
317 RETURNING id",
318 )
319 .bind(user_id)
320 .bind(day.date)
321 .bind(at_offset(day.start, offset))
322 .bind(day.end.map(|end| at_offset(end, offset)))
323 .fetch_one(&mut *tx)
324 .await
325 .with_context(|| format!("failed to write the workday of {}", day.date))?;
326
327 sqlx::query("DELETE FROM pauses WHERE workday_id = $1")
328 .bind(workday_id)
329 .execute(&mut *tx)
330 .await?;
331 for pause in &day.pauses {
332 sqlx::query("INSERT INTO pauses (workday_id, started_at, ended_at, duration_seconds, manual, reason) VALUES ($1, $2, $3, $4, $5, $6)")
333 .bind(workday_id)
334 .bind(at_offset(pause.start, offset))
335 .bind(pause.end.map(|end| at_offset(end, offset)))
336 .bind(pause.duration_seconds)
337 .bind(pause.manual)
338 .bind(pause.reason.as_deref())
339 .execute(&mut *tx)
340 .await?;
341 }
342
343 for task in &day.tasks {
344 sqlx::query(
345 "INSERT INTO tasks (user_id, agent_task_id, agent_group_id, date, recorded_at, name, comment, completeness)
346 VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
347 ON CONFLICT (user_id, agent_task_id) DO UPDATE SET
348 agent_group_id = EXCLUDED.agent_group_id,
349 date = EXCLUDED.date,
350 recorded_at = EXCLUDED.recorded_at,
351 name = EXCLUDED.name,
352 comment = EXCLUDED.comment,
353 completeness = EXCLUDED.completeness",
354 )
355 .bind(user_id)
356 .bind(task.agent_task_id)
357 .bind(task.agent_group_id)
358 .bind(day.date)
359 .bind(at_offset(task.recorded_at, offset))
360 .bind(task.name.trim())
361 .bind(task.comment.as_deref())
362 .bind(task.completeness)
363 .execute(&mut *tx)
364 .await?;
365 }
366
367 tx.commit().await?;
368 written += 1;
369 }
370
371 Ok(written)
372}
373
374pub async fn resolve_user(pool: &PgPool, email: &str) -> Result<Uuid> {
380 let user: Option<Uuid> = sqlx::query_scalar("SELECT id FROM users WHERE lower(email) = lower($1)")
381 .bind(email)
382 .fetch_optional(pool)
383 .await?;
384
385 user.with_context(|| format!("no user with the email {email}; create the account before importing into it"))
386}
387
388#[cfg(test)]
389mod tests {
390 use super::*;
391
392 #[test]
393 fn reads_the_agents_timestamp_format() {
394 let time = parse_time("2026-08-14 09:12:00").expect("the agent's own format must parse");
395 assert_eq!(time.to_string(), "2026-08-14 09:12:00");
396
397 assert!(parse_time("2026-08-14 09:12:00.123").is_ok());
399 assert!(parse_time("2026-08-14T09:12:00-03:00").is_err());
402 }
403
404 #[test]
405 fn the_operators_offset_makes_the_instant_absolute() {
406 let time = parse_time("2026-08-14 09:12:00").unwrap();
407 let offset = FixedOffset::east_opt(-3 * 3600).unwrap();
408
409 let instant = at_offset(time, offset);
410 assert_eq!(instant.to_rfc3339(), "2026-08-14T09:12:00-03:00");
411 assert_eq!(instant.naive_utc().to_string(), "2026-08-14 12:12:00", "09:12-03:00 is 12:12 UTC");
412 }
413
414 #[test]
415 fn a_different_offset_is_a_different_moment() {
416 let time = parse_time("2026-08-14 09:12:00").unwrap();
419 let west = at_offset(time, FixedOffset::east_opt(-3 * 3600).unwrap());
420 let east = at_offset(time, FixedOffset::east_opt(5 * 3600).unwrap());
421
422 assert_ne!(west.naive_utc(), east.naive_utc());
423 assert_eq!(east.naive_utc().to_string(), "2026-08-14 04:12:00");
424 }
425}