Skip to main content

max / goingson

5.0 KB · 172 lines History Blame Raw
1 //! CSV export utilities for tasks.
2 //!
3 //! Generates spreadsheet-compatible CSV files from task data.
4
5 use std::io::Write;
6
7 use goingson_core::{Project, Task};
8
9 /// Writes tasks to CSV format.
10 ///
11 /// # CSV Columns
12 ///
13 /// - id: Task UUID
14 /// - project: Project name (empty if no project)
15 /// - description: Task description
16 /// - status: Pending/Started/Completed
17 /// - priority: H/M/L
18 /// - due: ISO 8601 date or empty
19 /// - tags: Comma-separated tags
20 /// - subtasks_done: Number of completed subtasks
21 /// - subtasks_total: Total number of subtasks
22 /// - recurrence: Daily/Weekly/Monthly or empty
23 /// - created_at: ISO 8601 timestamp
24 pub fn write_tasks_csv<W: Write>(
25 tasks: &[Task],
26 projects: &[Project],
27 mut writer: W,
28 ) -> Result<usize, std::io::Error> {
29 let mut csv_writer = csv::Writer::from_writer(&mut writer);
30
31 // Write header
32 csv_writer.write_record([
33 "id",
34 "project",
35 "description",
36 "status",
37 "priority",
38 "due",
39 "tags",
40 "subtasks_done",
41 "subtasks_total",
42 "recurrence",
43 "created_at",
44 ])?;
45
46 // Build project lookup map
47 let project_map: std::collections::HashMap<goingson_core::ProjectId, &str> = projects
48 .iter()
49 .map(|p| (p.id, p.name.as_str()))
50 .collect();
51
52 // Write task rows
53 for task in tasks {
54 let project_name = task
55 .project_id
56 .and_then(|pid| project_map.get(&pid).copied())
57 .unwrap_or("");
58
59 let due = task
60 .due
61 .map(|d| d.to_rfc3339())
62 .unwrap_or_default();
63
64 let tags = task.tags.iter().map(|t| sanitize_csv_field(t)).collect::<Vec<_>>().join(",");
65
66 let subtasks_done = task.subtasks.iter().filter(|s| s.is_completed).count();
67 let subtasks_total = task.subtasks.len();
68
69 let recurrence = match task.recurrence {
70 goingson_core::Recurrence::None => "",
71 goingson_core::Recurrence::Daily => "Daily",
72 goingson_core::Recurrence::Weekly => "Weekly",
73 goingson_core::Recurrence::Monthly => "Monthly",
74 };
75
76 csv_writer.write_record([
77 task.id.to_string(),
78 sanitize_csv_field(project_name),
79 sanitize_csv_field(&task.description),
80 task.status.as_str().to_string(),
81 task.priority.as_str().to_string(),
82 due,
83 tags,
84 subtasks_done.to_string(),
85 subtasks_total.to_string(),
86 recurrence.to_string(),
87 task.created_at.to_rfc3339(),
88 ])?;
89 }
90
91 csv_writer.flush()?;
92 Ok(tasks.len())
93 }
94
95 /// Prefix cell values that spreadsheets would interpret as formulas.
96 fn sanitize_csv_field(value: &str) -> String {
97 if value.starts_with('=') || value.starts_with('+') || value.starts_with('-') || value.starts_with('@')
98 || value.starts_with('\t') || value.starts_with('\r') || value.starts_with('\n')
99 || value.starts_with(';') || value.starts_with('|')
100 {
101 format!("'{}", value)
102 } else {
103 value.to_string()
104 }
105 }
106
107 #[cfg(test)]
108 mod tests {
109 use super::*;
110 use chrono::Utc;
111 use goingson_core::{Priority, Recurrence, TaskId, TaskStatus};
112
113 fn make_task(desc: &str) -> Task {
114 Task {
115 id: TaskId::new(),
116 project_id: None,
117 project_name: None,
118 contact_id: None,
119 contact_name: None,
120 milestone_id: None,
121 description: desc.to_string(),
122 status: TaskStatus::Pending,
123 priority: Priority::Medium,
124 due: None,
125 tags: vec![],
126 urgency: 0.0,
127 recurrence: Recurrence::None,
128 recurrence_rule: None,
129 recurrence_parent_id: None,
130 source_email_id: None,
131 snoozed_until: None,
132 waiting_for_response: false,
133 waiting_since: None,
134 expected_response_date: None,
135 scheduled_start: None,
136 scheduled_duration: None,
137 annotations: vec![],
138 subtasks: vec![],
139 created_at: Utc::now(),
140 completed_at: None,
141 is_focus: false,
142 focus_set_at: None,
143 estimated_minutes: None,
144 actual_minutes: 0,
145 active_session: None,
146 }
147 }
148
149 #[test]
150 fn test_write_empty_tasks() {
151 let mut buffer = Vec::new();
152 let count = write_tasks_csv(&[], &[], &mut buffer).unwrap();
153 assert_eq!(count, 0);
154
155 let output = String::from_utf8(buffer).unwrap();
156 assert!(output.contains("id,project,description"));
157 }
158
159 #[test]
160 fn test_write_tasks_with_tags() {
161 let mut task = make_task("Test task");
162 task.tags = vec!["tag1".to_string(), "tag2".to_string()];
163
164 let mut buffer = Vec::new();
165 let count = write_tasks_csv(&[task], &[], &mut buffer).unwrap();
166 assert_eq!(count, 1);
167
168 let output = String::from_utf8(buffer).unwrap();
169 assert!(output.contains("tag1,tag2"));
170 }
171 }
172