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 }) }