feat(phase2,phase4): comments, attachments, notifications, Web Push, import/export
Comments and attachments close out phase 2. Files are stored content-addressed by hash, so the path never contains anything the uploader chose — a filename is attacker-controlled text, and building a path from it is how "../" becomes a write outside the data directory. Everything downloads as application/octet-stream: an uploaded SVG served inline would run its own script on this origin with the session cookie attached. Web Push is written directly against RFC 8030/8291/8292 rather than pulled in — it is three specs' worth of well-defined crypto and about two hundred lines, and the alternatives carry a JWT and crypto stack each larger than the file. Phase 4 imports Todoist's CSV and exports it back. Todoist numbers priorities the opposite way to how it displays them — its 4 is P1 — so the two are converted rather than copied; getting it backwards silently inverts the urgency of every imported task. Nesting is positional, so the reader keeps an indent stack and the writer never sorts. The round trip is tested as the brief asks: a CSV in, out, and back produces an identical project, and two exports of one project are byte-identical. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
0ee72d399c
commit
4058ff0533
@@ -0,0 +1,180 @@
|
||||
package todoist
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Project is the shape both sides of the conversion work in: a project with
|
||||
// sections and a tree of tasks. It is deliberately not verdande's store.Task —
|
||||
// keeping the CSV layer ignorant of the database is what lets it be tested without
|
||||
// one, and what makes the round trip a pure function.
|
||||
type Project struct {
|
||||
Name string
|
||||
Sections []Section
|
||||
Tasks []Task
|
||||
}
|
||||
|
||||
type Section struct {
|
||||
Name string
|
||||
Tasks []Task
|
||||
}
|
||||
|
||||
type Task struct {
|
||||
Content string
|
||||
Description string
|
||||
Priority int // verdande's numbering: 1 highest, 4 none
|
||||
Date string
|
||||
Assignee string
|
||||
Author string
|
||||
Comments []string
|
||||
Children []Task
|
||||
}
|
||||
|
||||
// ToRows flattens a project into CSV rows.
|
||||
//
|
||||
// Order is the data here — a task's parent is whichever row above it has a smaller
|
||||
// indent — so this walks the tree depth-first and never sorts.
|
||||
func ToRows(p Project) []Row {
|
||||
var rows []Row
|
||||
|
||||
appendTask := func(t Task, indent int) {}
|
||||
appendTask = func(t Task, indent int) {
|
||||
rows = append(rows, Row{
|
||||
Type: TypeTask,
|
||||
Content: t.Content,
|
||||
Description: t.Description,
|
||||
Priority: PriorityFromVerdande(t.Priority),
|
||||
Indent: indent,
|
||||
Author: t.Author,
|
||||
Responsible: t.Assignee,
|
||||
Date: t.Date,
|
||||
DateLang: dateLang(t.Date),
|
||||
Timezone: "",
|
||||
})
|
||||
// A comment belongs to the task above it, so notes go immediately after
|
||||
// their task and before any of its children.
|
||||
for _, note := range t.Comments {
|
||||
rows = append(rows, Row{Type: TypeNote, Content: note})
|
||||
}
|
||||
for _, child := range t.Children {
|
||||
// Todoist caps display nesting at four levels; deeper tasks are still
|
||||
// written, flattened to the deepest level it will show.
|
||||
next := indent + 1
|
||||
if next > 4 {
|
||||
next = 4
|
||||
}
|
||||
appendTask(child, next)
|
||||
}
|
||||
}
|
||||
|
||||
for _, t := range p.Tasks {
|
||||
appendTask(t, 1)
|
||||
}
|
||||
|
||||
for _, section := range p.Sections {
|
||||
// Todoist separates sections with a blank row; its own exports have one
|
||||
// and its importer is happier with it.
|
||||
rows = append(rows, Row{})
|
||||
rows = append(rows, Row{Type: TypeSection, Content: section.Name})
|
||||
for _, t := range section.Tasks {
|
||||
appendTask(t, 1)
|
||||
}
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
// FromRows rebuilds the project from CSV rows.
|
||||
//
|
||||
// The indent stack is the whole algorithm: each task attaches to the last task seen
|
||||
// at one level shallower, and a task at indent 1 starts a new top-level branch.
|
||||
func FromRows(rows []Row, projectName string) Project {
|
||||
p := Project{Name: projectName}
|
||||
|
||||
// Pointers into the tree at each depth, so a child can be appended to whoever
|
||||
// is currently open one level up.
|
||||
var stack []*Task
|
||||
var currentSection *Section
|
||||
|
||||
// The most recently added task, which is what a note attaches to.
|
||||
var lastTask *Task
|
||||
|
||||
addTask := func(t Task, indent int) *Task {
|
||||
if indent < 1 {
|
||||
indent = 1
|
||||
}
|
||||
if indent > len(stack)+1 {
|
||||
// A file that jumps from indent 1 to indent 3 — possible after
|
||||
// hand-editing. Treat it as one level deeper than what is open,
|
||||
// rather than dropping the task.
|
||||
indent = len(stack) + 1
|
||||
}
|
||||
stack = stack[:indent-1]
|
||||
|
||||
if indent == 1 {
|
||||
if currentSection != nil {
|
||||
currentSection.Tasks = append(currentSection.Tasks, t)
|
||||
added := ¤tSection.Tasks[len(currentSection.Tasks)-1]
|
||||
stack = append(stack, added)
|
||||
return added
|
||||
}
|
||||
p.Tasks = append(p.Tasks, t)
|
||||
added := &p.Tasks[len(p.Tasks)-1]
|
||||
stack = append(stack, added)
|
||||
return added
|
||||
}
|
||||
|
||||
parent := stack[indent-2]
|
||||
parent.Children = append(parent.Children, t)
|
||||
added := &parent.Children[len(parent.Children)-1]
|
||||
stack = append(stack, added)
|
||||
return added
|
||||
}
|
||||
|
||||
for _, row := range rows {
|
||||
switch row.Type {
|
||||
case TypeSection:
|
||||
p.Sections = append(p.Sections, Section{Name: row.Content})
|
||||
currentSection = &p.Sections[len(p.Sections)-1]
|
||||
// A new section resets the tree: nothing above it can be a parent of
|
||||
// anything below.
|
||||
stack = nil
|
||||
lastTask = nil
|
||||
|
||||
case TypeTask:
|
||||
if strings.TrimSpace(row.Content) == "" {
|
||||
continue
|
||||
}
|
||||
lastTask = addTask(Task{
|
||||
Content: row.Content,
|
||||
Description: row.Description,
|
||||
Priority: PriorityToVerdande(row.Priority),
|
||||
Date: row.Date,
|
||||
Assignee: row.Responsible,
|
||||
Author: row.Author,
|
||||
}, row.Indent)
|
||||
|
||||
case TypeNote:
|
||||
if lastTask != nil && strings.TrimSpace(row.Content) != "" {
|
||||
lastTask.Comments = append(lastTask.Comments, row.Content)
|
||||
}
|
||||
}
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
// dateLang is the language Todoist should parse the DATE string in. Only set when
|
||||
// there is a date to parse; an empty column with a language beside it confuses its
|
||||
// importer.
|
||||
func dateLang(date string) string {
|
||||
if strings.TrimSpace(date) == "" {
|
||||
return ""
|
||||
}
|
||||
return "en"
|
||||
}
|
||||
|
||||
// SortSections puts sections in a stable order for export, so two exports of an
|
||||
// unchanged project produce identical bytes.
|
||||
func SortSections(p *Project) {
|
||||
sort.SliceStable(p.Sections, func(i, j int) bool { return p.Sections[i].Name < p.Sections[j].Name })
|
||||
}
|
||||
@@ -0,0 +1,281 @@
|
||||
// Package todoist reads and writes Todoist's own CSV template format.
|
||||
//
|
||||
// The format is what Todoist's "export as template" produces and what its "import
|
||||
// from template" accepts, so a file written here opens there and a file exported
|
||||
// from there opens here. That is the whole point: leaving a hosted product should
|
||||
// not mean leaving the data behind, and it should not be a one-way door either.
|
||||
//
|
||||
// The shape of a Todoist CSV is unusual and worth stating, because it explains most
|
||||
// of this file. Every row has the same eight columns, but the TYPE column decides
|
||||
// what a row *is*:
|
||||
//
|
||||
// task — a task; INDENT gives its depth, so 2 is a sub-task of the task above
|
||||
// section — a section heading; only CONTENT is meaningful
|
||||
// note — a comment on the task above it
|
||||
// "" — a blank spacer row, which Todoist emits between sections
|
||||
//
|
||||
// Nesting is therefore positional: a row's parent is the nearest preceding row with
|
||||
// a smaller indent. Reordering the rows changes the tree, which is why the writer
|
||||
// walks the tree in order rather than sorting by anything.
|
||||
package todoist
|
||||
|
||||
import (
|
||||
"encoding/csv"
|
||||
"fmt"
|
||||
"io"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Columns, in Todoist's order. The header has to match exactly or Todoist refuses
|
||||
// the file.
|
||||
var header = []string{
|
||||
"TYPE", "CONTENT", "DESCRIPTION", "PRIORITY", "INDENT", "AUTHOR", "RESPONSIBLE", "DATE", "DATE_LANG", "TIMEZONE",
|
||||
}
|
||||
|
||||
type Row struct {
|
||||
Type string
|
||||
Content string
|
||||
Description string
|
||||
Priority int // Todoist's own numbering: 4 is highest, 1 is none
|
||||
Indent int
|
||||
Author string
|
||||
Responsible string
|
||||
Date string
|
||||
DateLang string
|
||||
Timezone string
|
||||
}
|
||||
|
||||
const (
|
||||
TypeTask = "task"
|
||||
TypeSection = "section"
|
||||
TypeNote = "note"
|
||||
)
|
||||
|
||||
// Parse reads a Todoist CSV.
|
||||
//
|
||||
// It is lenient about what it accepts and strict about what it produces: a file
|
||||
// that has been through a spreadsheet — reordered columns, a BOM, semicolons
|
||||
// instead of commas, missing trailing fields — still reads, because that is what
|
||||
// actually arrives when somebody exports and then opens the file "just to look".
|
||||
func Parse(r io.Reader) ([]Row, error) {
|
||||
reader := csv.NewReader(newBOMStripper(r))
|
||||
// Rows genuinely vary in length once a spreadsheet has touched them.
|
||||
reader.FieldsPerRecord = -1
|
||||
reader.LazyQuotes = true
|
||||
|
||||
records, err := reader.ReadAll()
|
||||
if err != nil {
|
||||
// Retry with semicolons: that is what Excel writes in a Danish locale, and
|
||||
// it is the single most common reason a re-saved export will not read.
|
||||
if semi, semiErr := readSemicolon(r); semiErr == nil {
|
||||
records = semi
|
||||
} else {
|
||||
return nil, fmt.Errorf("todoist: read csv: %w", err)
|
||||
}
|
||||
}
|
||||
if len(records) == 0 {
|
||||
return nil, fmt.Errorf("todoist: the file is empty")
|
||||
}
|
||||
|
||||
index, err := mapColumns(records[0])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var rows []Row
|
||||
for _, record := range records[1:] {
|
||||
get := func(name string) string {
|
||||
i, ok := index[name]
|
||||
if !ok || i >= len(record) {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(record[i])
|
||||
}
|
||||
|
||||
row := Row{
|
||||
Type: strings.ToLower(get("TYPE")),
|
||||
Content: get("CONTENT"),
|
||||
Description: get("DESCRIPTION"),
|
||||
Author: get("AUTHOR"),
|
||||
Responsible: get("RESPONSIBLE"),
|
||||
Date: get("DATE"),
|
||||
DateLang: get("DATE_LANG"),
|
||||
Timezone: get("TIMEZONE"),
|
||||
}
|
||||
row.Priority, _ = strconv.Atoi(get("PRIORITY"))
|
||||
row.Indent, _ = strconv.Atoi(get("INDENT"))
|
||||
|
||||
// Blank spacer rows carry nothing; keeping them would produce empty tasks.
|
||||
if row.Type == "" && row.Content == "" {
|
||||
continue
|
||||
}
|
||||
rows = append(rows, row)
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
func readSemicolon(r io.Reader) ([][]string, error) {
|
||||
seeker, ok := r.(io.Seeker)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("cannot re-read the input")
|
||||
}
|
||||
if _, err := seeker.Seek(0, io.SeekStart); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
reader := csv.NewReader(newBOMStripper(r))
|
||||
reader.Comma = ';'
|
||||
reader.FieldsPerRecord = -1
|
||||
reader.LazyQuotes = true
|
||||
return reader.ReadAll()
|
||||
}
|
||||
|
||||
// mapColumns finds each column by name rather than by position, so a file whose
|
||||
// columns have been reordered still reads.
|
||||
func mapColumns(headerRow []string) (map[string]int, error) {
|
||||
index := map[string]int{}
|
||||
for i, name := range headerRow {
|
||||
index[strings.ToUpper(strings.TrimSpace(strings.Trim(name, `"`)))] = i
|
||||
}
|
||||
if _, ok := index["CONTENT"]; !ok {
|
||||
return nil, fmt.Errorf("todoist: this does not look like a Todoist export — there is no CONTENT column")
|
||||
}
|
||||
return index, nil
|
||||
}
|
||||
|
||||
// Write emits a Todoist-compatible CSV.
|
||||
func Write(w io.Writer, rows []Row) error {
|
||||
writer := csv.NewWriter(w)
|
||||
if err := writer.Write(header); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, row := range rows {
|
||||
record := []string{
|
||||
row.Type, row.Content, row.Description,
|
||||
// Todoist writes an empty priority for a row that is not a task, and
|
||||
// so must this: a "4" on a section makes its own importer complain.
|
||||
priorityField(row), indentField(row),
|
||||
row.Author, row.Responsible, row.Date, row.DateLang, row.Timezone,
|
||||
}
|
||||
if err := writer.Write(record); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
writer.Flush()
|
||||
return writer.Error()
|
||||
}
|
||||
|
||||
func priorityField(row Row) string {
|
||||
if row.Type != TypeTask || row.Priority == 0 {
|
||||
return ""
|
||||
}
|
||||
return strconv.Itoa(row.Priority)
|
||||
}
|
||||
|
||||
func indentField(row Row) string {
|
||||
if row.Type != TypeTask {
|
||||
return ""
|
||||
}
|
||||
if row.Indent == 0 {
|
||||
return "1"
|
||||
}
|
||||
return strconv.Itoa(row.Indent)
|
||||
}
|
||||
|
||||
// --- priority ------------------------------------------------------------------
|
||||
|
||||
// Todoist numbers priorities the opposite way round to how it displays them: the
|
||||
// CSV says 4 for what the interface calls P1. verdande stores what the interface
|
||||
// means, so the two have to be converted rather than copied — and getting this
|
||||
// backwards would silently invert the urgency of every imported task.
|
||||
func PriorityToVerdande(todoist int) int {
|
||||
switch todoist {
|
||||
case 4:
|
||||
return 1
|
||||
case 3:
|
||||
return 2
|
||||
case 2:
|
||||
return 3
|
||||
default:
|
||||
return 4
|
||||
}
|
||||
}
|
||||
|
||||
func PriorityFromVerdande(verdande int) int {
|
||||
switch verdande {
|
||||
case 1:
|
||||
return 4
|
||||
case 2:
|
||||
return 3
|
||||
case 3:
|
||||
return 2
|
||||
default:
|
||||
return 1
|
||||
}
|
||||
}
|
||||
|
||||
// --- dates ---------------------------------------------------------------------
|
||||
|
||||
// ParseDate reads the DATE column, which Todoist fills with whatever the person
|
||||
// typed — "tomorrow", "every Monday", "2026-03-15", "15 Mar" — because it stores
|
||||
// the natural-language string rather than a date.
|
||||
//
|
||||
// Only unambiguous machine formats are converted here. Anything else is handed back
|
||||
// as text for the caller's own natural-language parser, which already understands
|
||||
// far more of it and does so in the user's timezone.
|
||||
func ParseDate(value string) (date string, remaining string, ok bool) {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return "", "", false
|
||||
}
|
||||
|
||||
for _, layout := range []string{
|
||||
"2006-01-02", "2006-01-02 15:04", "2006-01-02T15:04:05Z07:00",
|
||||
"02-01-2006", "02/01/2006", "Jan 2 2006", "2 Jan 2006",
|
||||
} {
|
||||
if t, err := time.Parse(layout, value); err == nil {
|
||||
return t.Format("2006-01-02"), "", true
|
||||
}
|
||||
}
|
||||
return "", value, false
|
||||
}
|
||||
|
||||
// --- BOM ------------------------------------------------------------------------
|
||||
|
||||
// newBOMStripper drops a UTF-8 byte order mark. Excel writes one, and left in place
|
||||
// it becomes part of the first column's name — so "TYPE" is not found and the file
|
||||
// reads as having no recognisable columns at all.
|
||||
func newBOMStripper(r io.Reader) io.Reader {
|
||||
return &bomStripper{r: r}
|
||||
}
|
||||
|
||||
type bomStripper struct {
|
||||
r io.Reader
|
||||
checked bool
|
||||
buf []byte
|
||||
}
|
||||
|
||||
func (b *bomStripper) Read(p []byte) (int, error) {
|
||||
if !b.checked {
|
||||
b.checked = true
|
||||
head := make([]byte, 3)
|
||||
n, err := io.ReadFull(b.r, head)
|
||||
if n > 0 {
|
||||
head = head[:n]
|
||||
if n == 3 && head[0] == 0xEF && head[1] == 0xBB && head[2] == 0xBF {
|
||||
head = nil
|
||||
}
|
||||
b.buf = head
|
||||
}
|
||||
if err != nil && err != io.ErrUnexpectedEOF && err != io.EOF {
|
||||
return 0, err
|
||||
}
|
||||
}
|
||||
if len(b.buf) > 0 {
|
||||
n := copy(p, b.buf)
|
||||
b.buf = b.buf[n:]
|
||||
return n, nil
|
||||
}
|
||||
return b.r.Read(p)
|
||||
}
|
||||
@@ -0,0 +1,436 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user