things3_core/
export.rs

1//! Data export functionality for Things 3 data
2
3use 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/// Export format enumeration
11#[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/// Export data structure
34#[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/// Export configuration
58#[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
79/// Data exporter for Things 3 data
80pub 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    /// Export data in the specified format
97    ///
98    /// # Errors
99    ///
100    /// Returns an error if the export format is not supported or if serialization fails.
101    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    /// Export as JSON
111    fn export_json(data: &ExportData) -> Result<String> {
112        Ok(serde_json::to_string_pretty(data)?)
113    }
114
115    /// Export as CSV
116    fn export_csv(data: &ExportData) -> String {
117        let mut csv = String::new();
118
119        // Export tasks
120        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        // Export projects
143        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        // Export areas
164        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    /// Export as OPML
184    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        // Group by areas
199        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                    // Add tasks for this project
217                    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    /// Export as Markdown
241    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        // Export areas
254        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        // Export projects
265        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        // Export tasks
281        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
308/// Helper functions for CSV export
309const 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('&', "&amp;")
346        .replace('<', "&lt;")
347        .replace('>', "&gt;")
348        .replace('"', "&quot;")
349        .replace('\'', "&apos;")
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        // Just test that it can be created
417        // Test passes if we reach this point
418    }
419
420    #[test]
421    fn test_data_exporter_new_default() {
422        let _exporter = DataExporter::new_default();
423        // Just test that it can be created
424        // Test passes if we reach this point
425    }
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        // No special characters
598        assert_eq!(escape_csv("normal text"), "normal text");
599
600        // Contains comma
601        assert_eq!(escape_csv("text,with,comma"), "\"text,with,comma\"");
602
603        // Contains quote
604        assert_eq!(escape_csv("text\"with\"quote"), "\"text\"\"with\"\"quote\"");
605
606        // Contains newline
607        assert_eq!(escape_csv("text\nwith\nnewline"), "\"text\nwith\nnewline\"");
608
609        // Contains multiple special characters
610        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&ampersand"),
621            "text&amp;with&amp;ampersand"
622        );
623        assert_eq!(escape_xml("text<with>tags"), "text&lt;with&gt;tags");
624        assert_eq!(
625            escape_xml("text\"with\"quotes"),
626            "text&quot;with&quot;quotes"
627        );
628        assert_eq!(
629            escape_xml("text'with'apostrophe"),
630            "text&apos;with&apos;apostrophe"
631        );
632        assert_eq!(escape_xml("all<>&\"'"), "all&lt;&gt;&amp;&quot;&apos;");
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        // Test that ExportData can be serialized and deserialized
643        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}