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>
282 lines
7.9 KiB
Go
282 lines
7.9 KiB
Go
// 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)
|
|
}
|