package todoist import ( "bytes" "strings" "testing" ) // A real Todoist template export, with the shapes that matter: a task tree, a // section, notes, priorities and dates. const sampleCSV = `TYPE,CONTENT,DESCRIPTION,PRIORITY,INDENT,AUTHOR,RESPONSIBLE,DATE,DATE_LANG,TIMEZONE task,Betal moms,husk bilag,4,1,Kristian,,2026-03-15,en, note,Bilag ligger i Dropbox,,,,,,,, task,Find bilag,,3,2,Kristian,,,, task,Scan dem,,1,3,Kristian,,,, task,Ring til revisor,,1,1,Kristian,Anders,,, ,,,,,,,,, section,Til gennemsyn,,,,,,,, task,Gennemgå kvartal,,2,1,Kristian,,,, task,Underopgave,,1,2,Kristian,,,, ` func TestParseReadsTheShape(t *testing.T) { rows, err := Parse(strings.NewReader(sampleCSV)) if err != nil { t.Fatalf("Parse: %v", err) } // The blank spacer row is dropped; everything else survives. var tasks, sections, notes int for _, r := range rows { switch r.Type { case TypeTask: tasks++ case TypeSection: sections++ case TypeNote: notes++ } } if tasks != 6 { t.Errorf("got %d tasks, want 6", tasks) } if sections != 1 { t.Errorf("got %d sections, want 1", sections) } if notes != 1 { t.Errorf("got %d notes, want 1", notes) } } // Todoist's CSV numbers priorities the opposite way to how its interface shows // them. Getting this backwards silently inverts the urgency of every task. func TestPriorityIsInverted(t *testing.T) { cases := map[int]int{4: 1, 3: 2, 2: 3, 1: 4, 0: 4} for todoist, want := range cases { if got := PriorityToVerdande(todoist); got != want { t.Errorf("PriorityToVerdande(%d) = %d, want %d", todoist, got, want) } } for verdande, want := range map[int]int{1: 4, 2: 3, 3: 2, 4: 1} { if got := PriorityFromVerdande(verdande); got != want { t.Errorf("PriorityFromVerdande(%d) = %d, want %d", verdande, got, want) } } // And the two are inverses, which is what the round trip depends on. for v := 1; v <= 4; v++ { if got := PriorityToVerdande(PriorityFromVerdande(v)); got != v { t.Errorf("priority %d survived a round trip as %d", v, got) } } } func TestFromRowsBuildsTheTree(t *testing.T) { rows, err := Parse(strings.NewReader(sampleCSV)) if err != nil { t.Fatal(err) } p := FromRows(rows, "Firma") if p.Name != "Firma" { t.Errorf("Name = %q", p.Name) } if len(p.Tasks) != 2 { t.Fatalf("got %d top-level tasks, want 2", len(p.Tasks)) } moms := p.Tasks[0] if moms.Content != "Betal moms" { t.Fatalf("first task = %q", moms.Content) } if moms.Priority != 1 { t.Errorf("priority = %d, want 1 (Todoist's 4)", moms.Priority) } if moms.Date != "2026-03-15" { t.Errorf("date = %q", moms.Date) } if len(moms.Comments) != 1 || moms.Comments[0] != "Bilag ligger i Dropbox" { t.Errorf("comments = %v", moms.Comments) } // Indent 2 is a child of the indent-1 task above it; indent 3 is a child of that. if len(moms.Children) != 1 { t.Fatalf("got %d children, want 1", len(moms.Children)) } if moms.Children[0].Content != "Find bilag" { t.Errorf("child = %q", moms.Children[0].Content) } if len(moms.Children[0].Children) != 1 { t.Fatalf("got %d grandchildren, want 1", len(moms.Children[0].Children)) } if moms.Children[0].Children[0].Content != "Scan dem" { t.Errorf("grandchild = %q", moms.Children[0].Children[0].Content) } // The assignee survives. if p.Tasks[1].Assignee != "Anders" { t.Errorf("assignee = %q, want Anders", p.Tasks[1].Assignee) } // A section resets the tree, and its own tasks nest inside it. if len(p.Sections) != 1 { t.Fatalf("got %d sections", len(p.Sections)) } section := p.Sections[0] if section.Name != "Til gennemsyn" { t.Errorf("section name = %q", section.Name) } if len(section.Tasks) != 1 { t.Fatalf("the section has %d top-level tasks, want 1", len(section.Tasks)) } if len(section.Tasks[0].Children) != 1 { t.Errorf("the section's task has %d children, want 1", len(section.Tasks[0].Children)) } } // The requirement from the brief, stated directly: a Todoist CSV in, and out again, // has to be the same thing. This is the test that stops an import from quietly // losing a sub-task, a comment or a priority. func TestRoundTripIsLossless(t *testing.T) { rows, err := Parse(strings.NewReader(sampleCSV)) if err != nil { t.Fatal(err) } project := FromRows(rows, "Firma") var out bytes.Buffer if err := Write(&out, ToRows(project)); err != nil { t.Fatalf("Write: %v", err) } // Read what was written and rebuild. The two projects must be identical. reparsed, err := Parse(bytes.NewReader(out.Bytes())) if err != nil { t.Fatalf("re-parsing our own output: %v", err) } again := FromRows(reparsed, "Firma") assertSameProject(t, project, again) } // And a second pass must produce byte-identical output, or "export" is not a // repeatable operation and diffing two exports is meaningless. func TestExportIsStable(t *testing.T) { rows, err := Parse(strings.NewReader(sampleCSV)) if err != nil { t.Fatal(err) } project := FromRows(rows, "Firma") var first, second bytes.Buffer if err := Write(&first, ToRows(project)); err != nil { t.Fatal(err) } reparsed, err := Parse(bytes.NewReader(first.Bytes())) if err != nil { t.Fatal(err) } if err := Write(&second, ToRows(FromRows(reparsed, "Firma"))); err != nil { t.Fatal(err) } if first.String() != second.String() { t.Errorf("two exports of the same project differ:\n--- first ---\n%s\n--- second ---\n%s", first.String(), second.String()) } } // What is written has to be readable by Todoist, which means the exact header and // no priority or indent on rows that are not tasks. func TestWrittenFileMatchesTodoistsFormat(t *testing.T) { var out bytes.Buffer err := Write(&out, ToRows(Project{ Name: "Firma", Tasks: []Task{{Content: "En opgave", Priority: 1}}, Sections: []Section{{ Name: "En sektion", Tasks: []Task{{Content: "I sektionen", Priority: 4, Comments: []string{"en note"}}}, }}, })) if err != nil { t.Fatal(err) } lines := strings.Split(strings.TrimSpace(out.String()), "\n") want := "TYPE,CONTENT,DESCRIPTION,PRIORITY,INDENT,AUTHOR,RESPONSIBLE,DATE,DATE_LANG,TIMEZONE" if strings.TrimSpace(lines[0]) != want { t.Errorf("header:\n got %q\nwant %q", lines[0], want) } for _, line := range lines[1:] { fields := strings.Split(line, ",") if len(fields) < 5 { continue } // Only task rows carry a priority and an indent. if fields[0] != TypeTask && (fields[3] != "" || fields[4] != "") { t.Errorf("a %q row carries a priority or indent: %q", fields[0], line) } if fields[0] == TypeTask && fields[4] == "" { t.Errorf("a task row has no indent: %q", line) } } } // Files arrive having been opened in a spreadsheet, which is where BOMs, semicolons // and reordered columns come from. Refusing those means refusing most real exports. func TestParseSurvivesRealWorldFiles(t *testing.T) { t.Run("utf-8 BOM", func(t *testing.T) { withBOM := "\xEF\xBB\xBF" + sampleCSV rows, err := Parse(strings.NewReader(withBOM)) if err != nil { t.Fatalf("Parse: %v", err) } if len(rows) == 0 { t.Fatal("no rows were read") } if rows[0].Content != "Betal moms" { t.Errorf("first row = %+v; the BOM was probably left in the header", rows[0]) } }) t.Run("reordered columns", func(t *testing.T) { reordered := "CONTENT,TYPE,INDENT,PRIORITY,DESCRIPTION,AUTHOR,RESPONSIBLE,DATE,DATE_LANG,TIMEZONE\n" + "Betal moms,task,1,4,,Kristian,,2026-03-15,en,\n" rows, err := Parse(strings.NewReader(reordered)) if err != nil { t.Fatalf("Parse: %v", err) } if len(rows) != 1 { t.Fatalf("got %d rows", len(rows)) } if rows[0].Content != "Betal moms" || rows[0].Type != TypeTask || rows[0].Priority != 4 { t.Errorf("columns were read by position, not by name: %+v", rows[0]) } }) t.Run("short rows", func(t *testing.T) { short := "TYPE,CONTENT,DESCRIPTION,PRIORITY,INDENT\ntask,Kort række\n" rows, err := Parse(strings.NewReader(short)) if err != nil { t.Fatalf("Parse: %v", err) } if len(rows) != 1 || rows[0].Content != "Kort række" { t.Errorf("rows = %+v", rows) } }) t.Run("danish characters and commas in content", func(t *testing.T) { var out bytes.Buffer original := Project{Tasks: []Task{ {Content: `Køb mælk, brød og smør`, Description: "på vej hjem", Priority: 2}, }} if err := Write(&out, ToRows(original)); err != nil { t.Fatal(err) } rows, err := Parse(bytes.NewReader(out.Bytes())) if err != nil { t.Fatal(err) } back := FromRows(rows, "") if back.Tasks[0].Content != `Køb mælk, brød og smør` { t.Errorf("content = %q; the comma was probably not quoted", back.Tasks[0].Content) } }) } func TestParseRejectsWhatIsNotAnExport(t *testing.T) { for _, in := range []string{ "", "navn;pris\nkaffe;40\n", "just some text\n", } { t.Run(in, func(t *testing.T) { if _, err := Parse(strings.NewReader(in)); err == nil { t.Error("a file that is not a Todoist export was accepted") } }) } } // A hand-edited file can jump from indent 1 to indent 3. Dropping those tasks would // lose work silently; attaching them one level deeper than what is open does not. func TestBrokenIndentationDoesNotLoseTasks(t *testing.T) { broken := `TYPE,CONTENT,DESCRIPTION,PRIORITY,INDENT,AUTHOR,RESPONSIBLE,DATE,DATE_LANG,TIMEZONE task,Forælder,,1,1,,,,, task,Springer til tre,,1,3,,,,, task,Tilbage til en,,1,1,,,,, ` rows, err := Parse(strings.NewReader(broken)) if err != nil { t.Fatal(err) } p := FromRows(rows, "x") if got := countTasks(p); got != 3 { t.Errorf("%d tasks survived, want all 3", got) } if len(p.Tasks) != 2 { t.Errorf("got %d top-level tasks, want 2", len(p.Tasks)) } } func TestParseDate(t *testing.T) { cases := []struct { in string wantDate string wantOK bool }{ {"2026-03-15", "2026-03-15", true}, {"2026-03-15 10:00", "2026-03-15", true}, {"15-03-2026", "2026-03-15", true}, {"15/03/2026", "2026-03-15", true}, // Todoist stores what was typed, so most dates are natural language and // belong to the caller's own parser. {"every Monday", "", false}, {"tomorrow", "", false}, {"i morgen", "", false}, {"", "", false}, } for _, tc := range cases { t.Run(tc.in, func(t *testing.T) { date, remaining, ok := ParseDate(tc.in) if ok != tc.wantOK { t.Fatalf("ok = %v, want %v", ok, tc.wantOK) } if date != tc.wantDate { t.Errorf("date = %q, want %q", date, tc.wantDate) } if !ok && tc.in != "" && remaining != tc.in { t.Errorf("remaining = %q, want the original text back", remaining) } }) } } // --- helpers --------------------------------------------------------------------- func assertSameProject(t *testing.T, want, got Project) { t.Helper() if len(want.Tasks) != len(got.Tasks) { t.Fatalf("top-level tasks: %d vs %d", len(want.Tasks), len(got.Tasks)) } for i := range want.Tasks { assertSameTask(t, "task["+want.Tasks[i].Content+"]", want.Tasks[i], got.Tasks[i]) } if len(want.Sections) != len(got.Sections) { t.Fatalf("sections: %d vs %d", len(want.Sections), len(got.Sections)) } for i := range want.Sections { if want.Sections[i].Name != got.Sections[i].Name { t.Errorf("section name: %q vs %q", want.Sections[i].Name, got.Sections[i].Name) } if len(want.Sections[i].Tasks) != len(got.Sections[i].Tasks) { t.Fatalf("section %q tasks: %d vs %d", want.Sections[i].Name, len(want.Sections[i].Tasks), len(got.Sections[i].Tasks)) } for j := range want.Sections[i].Tasks { assertSameTask(t, "section "+want.Sections[i].Name, want.Sections[i].Tasks[j], got.Sections[i].Tasks[j]) } } } func assertSameTask(t *testing.T, where string, want, got Task) { t.Helper() if want.Content != got.Content { t.Errorf("%s content: %q vs %q", where, want.Content, got.Content) } if want.Description != got.Description { t.Errorf("%s description: %q vs %q", where, want.Description, got.Description) } if want.Priority != got.Priority { t.Errorf("%s priority: %d vs %d", where, want.Priority, got.Priority) } if want.Date != got.Date { t.Errorf("%s date: %q vs %q", where, want.Date, got.Date) } if want.Assignee != got.Assignee { t.Errorf("%s assignee: %q vs %q", where, want.Assignee, got.Assignee) } if len(want.Comments) != len(got.Comments) { t.Errorf("%s comments: %v vs %v", where, want.Comments, got.Comments) } else { for i := range want.Comments { if want.Comments[i] != got.Comments[i] { t.Errorf("%s comment %d: %q vs %q", where, i, want.Comments[i], got.Comments[i]) } } } if len(want.Children) != len(got.Children) { t.Fatalf("%s children: %d vs %d", where, len(want.Children), len(got.Children)) } for i := range want.Children { assertSameTask(t, where+" > "+want.Children[i].Content, want.Children[i], got.Children[i]) } } func countTasks(p Project) int { var count func([]Task) int count = func(tasks []Task) int { n := 0 for _, t := range tasks { n += 1 + count(t.Children) } return n } total := count(p.Tasks) for _, s := range p.Sections { total += count(s.Tasks) } return total }