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