1use crate::models::{Area, Project, Task, TaskStatus, TaskType};
4use anyhow::Result;
5use chrono::{DateTime, Utc};
6use serde::{Deserialize, Serialize};
7use std::collections::HashMap;
8use std::fmt::Write;
9
10#[derive(Debug, Clone, Copy, PartialEq, Eq)]
12pub enum ExportFormat {
13 Json,
14 Csv,
15 Opml,
16 Markdown,
17}
18
19impl std::str::FromStr for ExportFormat {
20 type Err = anyhow::Error;
21
22 fn from_str(s: &str) -> Result<Self> {
23 match s.to_lowercase().as_str() {
24 "json" => Ok(Self::Json),
25 "csv" => Ok(Self::Csv),
26 "opml" => Ok(Self::Opml),
27 "markdown" | "md" => Ok(Self::Markdown),
28 _ => Err(anyhow::anyhow!("Unsupported export format: {s}")),
29 }
30 }
31}
32
33#[derive(Debug, Clone, Serialize, Deserialize)]
35pub struct ExportData {
36 pub tasks: Vec<Task>,
37 pub projects: Vec<Project>,
38 pub areas: Vec<Area>,
39 pub exported_at: DateTime<Utc>,
40 pub total_items: usize,
41}
42
43impl ExportData {
44 #[must_use]
45 pub fn new(tasks: Vec<Task>, projects: Vec<Project>, areas: Vec<Area>) -> Self {
46 let total_items = tasks.len() + projects.len() + areas.len();
47 Self {
48 tasks,
49 projects,
50 areas,
51 exported_at: Utc::now(),
52 total_items,
53 }
54 }
55}
56
57#[derive(Debug, Clone)]
59pub struct ExportConfig {
60 pub include_metadata: bool,
61 pub include_notes: bool,
62 pub include_tags: bool,
63 pub date_format: String,
64 pub timezone: String,
65}
66
67impl Default for ExportConfig {
68 fn default() -> Self {
69 Self {
70 include_metadata: true,
71 include_notes: true,
72 include_tags: true,
73 date_format: "%Y-%m-%d %H:%M:%S".to_string(),
74 timezone: "UTC".to_string(),
75 }
76 }
77}
78
79pub struct DataExporter {
81 #[allow(dead_code)]
82 config: ExportConfig,
83}
84
85impl DataExporter {
86 #[must_use]
87 pub const fn new(config: ExportConfig) -> Self {
88 Self { config }
89 }
90
91 #[must_use]
92 pub fn new_default() -> Self {
93 Self::new(ExportConfig::default())
94 }
95
96 pub fn export(&self, data: &ExportData, format: ExportFormat) -> Result<String> {
102 match format {
103 ExportFormat::Json => Self::export_json(data),
104 ExportFormat::Csv => Ok(Self::export_csv(data)),
105 ExportFormat::Opml => Ok(Self::export_opml(data)),
106 ExportFormat::Markdown => Ok(Self::export_markdown(data)),
107 }
108 }
109
110 fn export_json(data: &ExportData) -> Result<String> {
112 Ok(serde_json::to_string_pretty(data)?)
113 }
114
115 fn export_csv(data: &ExportData) -> String {
117 let mut csv = String::new();
118
119 if !data.tasks.is_empty() {
121 csv.push_str("Type,Title,Status,Notes,Start Date,Deadline,Created,Modified,Project,Area,Parent\n");
122 for task in &data.tasks {
123 writeln!(
124 csv,
125 "{},{},{},{},{},{},{},{},{},{},{}",
126 format_task_type_csv(task.task_type),
127 escape_csv(&task.title),
128 format_task_status_csv(task.status),
129 escape_csv(task.notes.as_deref().unwrap_or("")),
130 format_date_csv(task.start_date),
131 format_date_csv(task.deadline),
132 format_datetime_csv(task.created),
133 format_datetime_csv(task.modified),
134 task.project_uuid.map(|u| u.to_string()).unwrap_or_default(),
135 task.area_uuid.map(|u| u.to_string()).unwrap_or_default(),
136 task.parent_uuid.map(|u| u.to_string()).unwrap_or_default(),
137 )
138 .unwrap();
139 }
140 }
141
142 if !data.projects.is_empty() {
144 csv.push_str("\n\nProjects\n");
145 csv.push_str("Title,Status,Notes,Start Date,Deadline,Created,Modified,Area\n");
146 for project in &data.projects {
147 writeln!(
148 csv,
149 "{},{},{},{},{},{},{},{}",
150 escape_csv(&project.title),
151 format_task_status_csv(project.status),
152 escape_csv(project.notes.as_deref().unwrap_or("")),
153 format_date_csv(project.start_date),
154 format_date_csv(project.deadline),
155 format_datetime_csv(project.created),
156 format_datetime_csv(project.modified),
157 project.area_uuid.map(|u| u.to_string()).unwrap_or_default(),
158 )
159 .unwrap();
160 }
161 }
162
163 if !data.areas.is_empty() {
165 csv.push_str("\n\nAreas\n");
166 csv.push_str("Title,Notes,Created,Modified\n");
167 for area in &data.areas {
168 writeln!(
169 csv,
170 "{},{},{},{}",
171 escape_csv(&area.title),
172 escape_csv(area.notes.as_deref().unwrap_or("")),
173 format_datetime_csv(area.created),
174 format_datetime_csv(area.modified),
175 )
176 .unwrap();
177 }
178 }
179
180 csv
181 }
182
183 fn export_opml(data: &ExportData) -> String {
185 let mut opml = String::new();
186 opml.push_str("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n");
187 opml.push_str("<opml version=\"2.0\">\n");
188 opml.push_str(" <head>\n");
189 writeln!(
190 opml,
191 " <title>Things 3 Export - {}</title>",
192 data.exported_at.format("%Y-%m-%d %H:%M:%S")
193 )
194 .unwrap();
195 opml.push_str(" </head>\n");
196 opml.push_str(" <body>\n");
197
198 let mut area_map: HashMap<Option<uuid::Uuid>, Vec<&Project>> = HashMap::new();
200 for project in &data.projects {
201 area_map.entry(project.area_uuid).or_default().push(project);
202 }
203
204 for area in &data.areas {
205 writeln!(opml, " <outline text=\"{}\">", escape_xml(&area.title)).unwrap();
206
207 if let Some(projects) = area_map.get(&Some(area.uuid)) {
208 for project in projects {
209 writeln!(
210 opml,
211 " <outline text=\"{}\" type=\"project\">",
212 escape_xml(&project.title)
213 )
214 .unwrap();
215
216 for task in &data.tasks {
218 if task.project_uuid == Some(project.uuid) {
219 writeln!(
220 opml,
221 " <outline text=\"{}\" type=\"task\"/>",
222 escape_xml(&task.title)
223 )
224 .unwrap();
225 }
226 }
227
228 opml.push_str(" </outline>\n");
229 }
230 }
231
232 opml.push_str(" </outline>\n");
233 }
234
235 opml.push_str(" </body>\n");
236 opml.push_str("</opml>\n");
237 opml
238 }
239
240 fn export_markdown(data: &ExportData) -> String {
242 let mut md = String::new();
243
244 md.push_str("# Things 3 Export\n\n");
245 writeln!(
246 md,
247 "**Exported:** {}",
248 data.exported_at.format("%Y-%m-%d %H:%M:%S UTC")
249 )
250 .unwrap();
251 writeln!(md, "**Total Items:** {}\n", data.total_items).unwrap();
252
253 if !data.areas.is_empty() {
255 md.push_str("## Areas\n\n");
256 for area in &data.areas {
257 writeln!(md, "### {}", area.title).unwrap();
258 if let Some(notes) = &area.notes {
259 writeln!(md, "{notes}\n").unwrap();
260 }
261 }
262 }
263
264 if !data.projects.is_empty() {
266 md.push_str("## Projects\n\n");
267 for project in &data.projects {
268 writeln!(md, "### {}", project.title).unwrap();
269 writeln!(md, "**Status:** {:?}", project.status).unwrap();
270 if let Some(notes) = &project.notes {
271 writeln!(md, "**Notes:** {notes}").unwrap();
272 }
273 if let Some(deadline) = &project.deadline {
274 writeln!(md, "**Deadline:** {deadline}").unwrap();
275 }
276 md.push('\n');
277 }
278 }
279
280 if !data.tasks.is_empty() {
282 md.push_str("## Tasks\n\n");
283 for task in &data.tasks {
284 writeln!(
285 md,
286 "- [{}] {}",
287 if task.status == TaskStatus::Completed {
288 "x"
289 } else {
290 " "
291 },
292 task.title
293 )
294 .unwrap();
295 if let Some(notes) = &task.notes {
296 writeln!(md, " - {notes}").unwrap();
297 }
298 if let Some(deadline) = &task.deadline {
299 writeln!(md, " - **Deadline:** {deadline}").unwrap();
300 }
301 }
302 }
303
304 md
305 }
306}
307
308const fn format_task_type_csv(task_type: TaskType) -> &'static str {
310 match task_type {
311 TaskType::Todo => "Todo",
312 TaskType::Project => "Project",
313 TaskType::Heading => "Heading",
314 TaskType::Area => "Area",
315 }
316}
317
318const fn format_task_status_csv(status: TaskStatus) -> &'static str {
319 match status {
320 TaskStatus::Incomplete => "Incomplete",
321 TaskStatus::Completed => "Completed",
322 TaskStatus::Canceled => "Canceled",
323 TaskStatus::Trashed => "Trashed",
324 }
325}
326
327fn format_date_csv(date: Option<chrono::NaiveDate>) -> String {
328 date.map(|d| d.format("%Y-%m-%d").to_string())
329 .unwrap_or_default()
330}
331
332fn format_datetime_csv(datetime: DateTime<Utc>) -> String {
333 datetime.format("%Y-%m-%d %H:%M:%S").to_string()
334}
335
336fn escape_csv(s: &str) -> String {
337 if s.contains(',') || s.contains('"') || s.contains('\n') {
338 format!("\"{}\"", s.replace('"', "\"\""))
339 } else {
340 s.to_string()
341 }
342}
343
344fn escape_xml(s: &str) -> String {
345 s.replace('&', "&")
346 .replace('<', "<")
347 .replace('>', ">")
348 .replace('"', """)
349 .replace('\'', "'")
350}
351
352#[cfg(test)]
353mod tests {
354 use super::*;
355 use crate::test_utils::{create_mock_areas, create_mock_projects, create_mock_tasks};
356
357 #[test]
358 fn test_export_format_from_str() {
359 assert_eq!("json".parse::<ExportFormat>().unwrap(), ExportFormat::Json);
360 assert_eq!("JSON".parse::<ExportFormat>().unwrap(), ExportFormat::Json);
361 assert_eq!("csv".parse::<ExportFormat>().unwrap(), ExportFormat::Csv);
362 assert_eq!("CSV".parse::<ExportFormat>().unwrap(), ExportFormat::Csv);
363 assert_eq!("opml".parse::<ExportFormat>().unwrap(), ExportFormat::Opml);
364 assert_eq!("OPML".parse::<ExportFormat>().unwrap(), ExportFormat::Opml);
365 assert_eq!(
366 "markdown".parse::<ExportFormat>().unwrap(),
367 ExportFormat::Markdown
368 );
369 assert_eq!(
370 "Markdown".parse::<ExportFormat>().unwrap(),
371 ExportFormat::Markdown
372 );
373 assert_eq!(
374 "md".parse::<ExportFormat>().unwrap(),
375 ExportFormat::Markdown
376 );
377 assert_eq!(
378 "MD".parse::<ExportFormat>().unwrap(),
379 ExportFormat::Markdown
380 );
381
382 assert!("invalid".parse::<ExportFormat>().is_err());
383 assert!("".parse::<ExportFormat>().is_err());
384 }
385
386 #[test]
387 fn test_export_data_new() {
388 let tasks = create_mock_tasks();
389 let projects = create_mock_projects();
390 let areas = create_mock_areas();
391
392 let data = ExportData::new(tasks.clone(), projects.clone(), areas.clone());
393
394 assert_eq!(data.tasks.len(), tasks.len());
395 assert_eq!(data.projects.len(), projects.len());
396 assert_eq!(data.areas.len(), areas.len());
397 assert_eq!(data.total_items, tasks.len() + projects.len() + areas.len());
398 assert!(data.exported_at <= Utc::now());
399 }
400
401 #[test]
402 fn test_export_config_default() {
403 let config = ExportConfig::default();
404
405 assert!(config.include_metadata);
406 assert!(config.include_notes);
407 assert!(config.include_tags);
408 assert_eq!(config.date_format, "%Y-%m-%d %H:%M:%S");
409 assert_eq!(config.timezone, "UTC");
410 }
411
412 #[test]
413 fn test_data_exporter_new() {
414 let config = ExportConfig::default();
415 let _exporter = DataExporter::new(config);
416 }
419
420 #[test]
421 fn test_data_exporter_new_default() {
422 let _exporter = DataExporter::new_default();
423 }
426
427 #[test]
428 fn test_export_json_empty() {
429 let exporter = DataExporter::new_default();
430 let data = ExportData::new(vec![], vec![], vec![]);
431 let result = exporter.export(&data, ExportFormat::Json);
432 assert!(result.is_ok());
433
434 let json = result.unwrap();
435 assert!(json.contains("\"tasks\""));
436 assert!(json.contains("\"projects\""));
437 assert!(json.contains("\"areas\""));
438 assert!(json.contains("\"exported_at\""));
439 assert!(json.contains("\"total_items\""));
440 }
441
442 #[test]
443 fn test_export_json_with_data() {
444 let exporter = DataExporter::new_default();
445 let tasks = create_mock_tasks();
446 let projects = create_mock_projects();
447 let areas = create_mock_areas();
448 let data = ExportData::new(tasks, projects, areas);
449
450 let result = exporter.export(&data, ExportFormat::Json);
451 assert!(result.is_ok());
452
453 let json = result.unwrap();
454 assert!(json.contains("\"Research competitors\""));
455 assert!(json.contains("\"Website Redesign\""));
456 assert!(json.contains("\"Work\""));
457 }
458
459 #[test]
460 fn test_export_csv_empty() {
461 let exporter = DataExporter::new_default();
462 let data = ExportData::new(vec![], vec![], vec![]);
463 let result = exporter.export(&data, ExportFormat::Csv);
464 assert!(result.is_ok());
465
466 let csv = result.unwrap();
467 assert!(csv.is_empty());
468 }
469
470 #[test]
471 fn test_export_csv_with_data() {
472 let exporter = DataExporter::new_default();
473 let tasks = create_mock_tasks();
474 let projects = create_mock_projects();
475 let areas = create_mock_areas();
476 let data = ExportData::new(tasks, projects, areas);
477
478 let result = exporter.export(&data, ExportFormat::Csv);
479 assert!(result.is_ok());
480
481 let csv = result.unwrap();
482 assert!(csv.contains(
483 "Type,Title,Status,Notes,Start Date,Deadline,Created,Modified,Project,Area,Parent"
484 ));
485 assert!(csv.contains("Research competitors"));
486 assert!(csv.contains("Projects"));
487 assert!(csv.contains("Website Redesign"));
488 assert!(csv.contains("Areas"));
489 assert!(csv.contains("Work"));
490 }
491
492 #[test]
493 fn test_export_markdown_empty() {
494 let exporter = DataExporter::new_default();
495 let data = ExportData::new(vec![], vec![], vec![]);
496 let result = exporter.export(&data, ExportFormat::Markdown);
497 assert!(result.is_ok());
498
499 let md = result.unwrap();
500 assert!(md.contains("# Things 3 Export"));
501 assert!(md.contains("**Total Items:** 0"));
502 }
503
504 #[test]
505 fn test_export_markdown_with_data() {
506 let exporter = DataExporter::new_default();
507 let tasks = create_mock_tasks();
508 let projects = create_mock_projects();
509 let areas = create_mock_areas();
510 let data = ExportData::new(tasks, projects, areas);
511
512 let result = exporter.export(&data, ExportFormat::Markdown);
513 assert!(result.is_ok());
514
515 let md = result.unwrap();
516 assert!(md.contains("# Things 3 Export"));
517 assert!(md.contains("## Areas"));
518 assert!(md.contains("### Work"));
519 assert!(md.contains("## Projects"));
520 assert!(md.contains("### Website Redesign"));
521 assert!(md.contains("## Tasks"));
522 assert!(md.contains("- [ ] Research competitors"));
523 }
524
525 #[test]
526 fn test_export_opml_empty() {
527 let exporter = DataExporter::new_default();
528 let data = ExportData::new(vec![], vec![], vec![]);
529 let result = exporter.export(&data, ExportFormat::Opml);
530 assert!(result.is_ok());
531
532 let opml = result.unwrap();
533 assert!(opml.contains("<?xml version=\"1.0\" encoding=\"UTF-8\"?>"));
534 assert!(opml.contains("<opml version=\"2.0\">"));
535 assert!(opml.contains("<head>"));
536 assert!(opml.contains("<body>"));
537 assert!(opml.contains("</opml>"));
538 }
539
540 #[test]
541 fn test_export_opml_with_data() {
542 let exporter = DataExporter::new_default();
543 let tasks = create_mock_tasks();
544 let projects = create_mock_projects();
545 let areas = create_mock_areas();
546 let data = ExportData::new(tasks, projects, areas);
547
548 let result = exporter.export(&data, ExportFormat::Opml);
549 assert!(result.is_ok());
550
551 let opml = result.unwrap();
552 assert!(opml.contains("<?xml version=\"1.0\" encoding=\"UTF-8\"?>"));
553 assert!(opml.contains("<opml version=\"2.0\">"));
554 assert!(opml.contains("Work"));
555 assert!(opml.contains("Website Redesign"));
556 }
557
558 #[test]
559 fn test_format_task_type_csv() {
560 assert_eq!(format_task_type_csv(TaskType::Todo), "Todo");
561 assert_eq!(format_task_type_csv(TaskType::Project), "Project");
562 assert_eq!(format_task_type_csv(TaskType::Heading), "Heading");
563 assert_eq!(format_task_type_csv(TaskType::Area), "Area");
564 }
565
566 #[test]
567 fn test_format_task_status_csv() {
568 assert_eq!(format_task_status_csv(TaskStatus::Incomplete), "Incomplete");
569 assert_eq!(format_task_status_csv(TaskStatus::Completed), "Completed");
570 assert_eq!(format_task_status_csv(TaskStatus::Canceled), "Canceled");
571 assert_eq!(format_task_status_csv(TaskStatus::Trashed), "Trashed");
572 }
573
574 #[test]
575 fn test_format_date_csv() {
576 use chrono::NaiveDate;
577
578 let date = NaiveDate::from_ymd_opt(2023, 12, 25).unwrap();
579 assert_eq!(format_date_csv(Some(date)), "2023-12-25");
580 assert_eq!(format_date_csv(None), "");
581 }
582
583 #[test]
584 fn test_format_datetime_csv() {
585 let datetime = Utc::now();
586 let formatted = format_datetime_csv(datetime);
587 assert!(
588 formatted.contains("2023") || formatted.contains("2024") || formatted.contains("2025")
589 );
590 assert!(formatted.contains('-'));
591 assert!(formatted.contains(' '));
592 assert!(formatted.contains(':'));
593 }
594
595 #[test]
596 fn test_escape_csv() {
597 assert_eq!(escape_csv("normal text"), "normal text");
599
600 assert_eq!(escape_csv("text,with,comma"), "\"text,with,comma\"");
602
603 assert_eq!(escape_csv("text\"with\"quote"), "\"text\"\"with\"\"quote\"");
605
606 assert_eq!(escape_csv("text\nwith\nnewline"), "\"text\nwith\nnewline\"");
608
609 assert_eq!(
611 escape_csv("text,\"with\",\nall"),
612 "\"text,\"\"with\"\",\nall\""
613 );
614 }
615
616 #[test]
617 fn test_escape_xml() {
618 assert_eq!(escape_xml("normal text"), "normal text");
619 assert_eq!(
620 escape_xml("text&with&ersand"),
621 "text&with&ampersand"
622 );
623 assert_eq!(escape_xml("text<with>tags"), "text<with>tags");
624 assert_eq!(
625 escape_xml("text\"with\"quotes"),
626 "text"with"quotes"
627 );
628 assert_eq!(
629 escape_xml("text'with'apostrophe"),
630 "text'with'apostrophe"
631 );
632 assert_eq!(escape_xml("all<>&\"'"), "all<>&"'");
633 }
634
635 #[test]
636 fn test_export_data_serialization() {
637 let tasks = create_mock_tasks();
638 let projects = create_mock_projects();
639 let areas = create_mock_areas();
640 let data = ExportData::new(tasks, projects, areas);
641
642 let json = serde_json::to_string(&data).unwrap();
644 let deserialized: ExportData = serde_json::from_str(&json).unwrap();
645
646 assert_eq!(data.tasks.len(), deserialized.tasks.len());
647 assert_eq!(data.projects.len(), deserialized.projects.len());
648 assert_eq!(data.areas.len(), deserialized.areas.len());
649 assert_eq!(data.total_items, deserialized.total_items);
650 }
651
652 #[test]
653 fn test_export_config_clone() {
654 let config = ExportConfig::default();
655 let cloned = config.clone();
656
657 assert_eq!(config.include_metadata, cloned.include_metadata);
658 assert_eq!(config.include_notes, cloned.include_notes);
659 assert_eq!(config.include_tags, cloned.include_tags);
660 assert_eq!(config.date_format, cloned.date_format);
661 assert_eq!(config.timezone, cloned.timezone);
662 }
663
664 #[test]
665 fn test_export_format_debug() {
666 let formats = vec![
667 ExportFormat::Json,
668 ExportFormat::Csv,
669 ExportFormat::Opml,
670 ExportFormat::Markdown,
671 ];
672
673 for format in formats {
674 let debug_str = format!("{format:?}");
675 assert!(!debug_str.is_empty());
676 }
677 }
678
679 #[test]
680 fn test_export_format_equality() {
681 assert_eq!(ExportFormat::Json, ExportFormat::Json);
682 assert_eq!(ExportFormat::Csv, ExportFormat::Csv);
683 assert_eq!(ExportFormat::Opml, ExportFormat::Opml);
684 assert_eq!(ExportFormat::Markdown, ExportFormat::Markdown);
685
686 assert_ne!(ExportFormat::Json, ExportFormat::Csv);
687 assert_ne!(ExportFormat::Csv, ExportFormat::Opml);
688 assert_ne!(ExportFormat::Opml, ExportFormat::Markdown);
689 assert_ne!(ExportFormat::Markdown, ExportFormat::Json);
690 }
691
692 #[test]
693 fn test_export_data_debug() {
694 let data = ExportData::new(vec![], vec![], vec![]);
695 let debug_str = format!("{data:?}");
696 assert!(!debug_str.is_empty());
697 assert!(debug_str.contains("ExportData"));
698 }
699}