181 lines
4.9 KiB
Go
181 lines
4.9 KiB
Go
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 })
|
||
|
|
}
|