Files
verdande/internal/todoist/convert.go
T
Kristian WindandClaude Opus 5 4058ff0533 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>
2026-08-17 07:42:55 +02:00

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 := &currentSection.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 })
}