feat(migration): import recurring tasks from todoist

This commit is contained in:
kolaente 2026-06-26 13:59:46 +02:00 committed by kolaente
parent 7691f282cf
commit 330b94c3c4
2 changed files with 113 additions and 0 deletions

View File

@ -22,8 +22,10 @@ import (
"fmt" "fmt"
"net/http" "net/http"
"net/url" "net/url"
"regexp"
"sort" "sort"
"strconv" "strconv"
"strings"
"time" "time"
"code.vikunja.io/api/pkg/config" "code.vikunja.io/api/pkg/config"
@ -253,6 +255,72 @@ func parseDate(dateString string) (date time.Time, err error) {
return date, err return date, err
} }
// Matching the existing migration importers, months are treated as 30 days and years as 365.
const (
secondsPerDay int64 = 60 * 60 * 24
secondsPerWeek = secondsPerDay * 7
secondsPerMonth = secondsPerDay * 30
secondsPerYear = secondsPerDay * 365
)
var repeatUnitSeconds = map[string]int64{
"day": secondsPerDay,
"week": secondsPerWeek,
"month": secondsPerMonth,
"year": secondsPerYear,
}
var (
todoistRepeatRegex = regexp.MustCompile(`^(?:every\s+)?(?:(\d+)\s+|(other)\s+)?(day|week|month|year)s?$`)
todoistRepeatTimeRegex = regexp.MustCompile(`\s+(?:at|@)\s+.*$`)
)
// parseTodoistRepeat translates Todoist's recurrence into a repeat interval in seconds.
// Todoist exposes recurrence only as free text (e.g. "every 3 weeks"), so we parse the
// common, unambiguous interval phrases. Patterns we can't represent (specific weekdays,
// days of the month, non-English strings) return 0, leaving the task non-repeating. Only
// the cadence is kept - the due date already anchors the actual day and time.
func parseTodoistRepeat(due *dueDate) int64 {
if due == nil || !due.IsRecurring {
return 0
}
s := strings.ToLower(strings.TrimSpace(due.String))
// The time of day is already on the due date, drop it so "every day at 9am" still matches.
s = todoistRepeatTimeRegex.ReplaceAllString(s, "")
switch s {
case "daily":
return secondsPerDay
case "weekly":
return secondsPerWeek
case "monthly":
return secondsPerMonth
case "yearly", "annually":
return secondsPerYear
}
matches := todoistRepeatRegex.FindStringSubmatch(s)
if matches == nil {
log.Debugf("[Todoist Migration] Could not parse recurrence %q, leaving task non-repeating", due.String)
return 0
}
interval := int64(1)
switch {
case matches[1] != "":
n, err := strconv.ParseInt(matches[1], 10, 64)
if err != nil || n < 1 {
return 0
}
interval = n
case matches[2] == "other":
interval = 2
}
return interval * repeatUnitSeconds[matches[3]]
}
func convertTodoistToVikunja(sync *sync, doneItems map[string]*doneItem) (fullVikunjaHierachie []*models.ProjectWithTasksAndBuckets, err error) { func convertTodoistToVikunja(sync *sync, doneItems map[string]*doneItem) (fullVikunjaHierachie []*models.ProjectWithTasksAndBuckets, err error) {
var pseudoParentID int64 = 1 var pseudoParentID int64 = 1
@ -358,6 +426,7 @@ func convertTodoistToVikunja(sync *sync, doneItems map[string]*doneItem) (fullVi
return nil, err return nil, err
} }
task.DueDate = dueDate.In(config.GetTimeZone()) task.DueDate = dueDate.In(config.GetTimeZone())
task.RepeatAfter = parseTodoistRepeat(i.Due)
} }
// Put all labels together from earlier // Put all labels together from earlier

View File

@ -651,3 +651,47 @@ func TestConvertTodoistToVikunja(t *testing.T) {
t.Errorf("converted todoist data = %v, want %v, diff: %v", hierachie, expectedHierachie, diff) t.Errorf("converted todoist data = %v, want %v, diff: %v", hierachie, expectedHierachie, diff)
} }
} }
func TestParseTodoistRepeat(t *testing.T) {
tests := []struct {
name string
due *dueDate
want int64
}{
{name: "nil due", due: nil, want: 0},
{name: "not recurring", due: &dueDate{String: "every day", IsRecurring: false}, want: 0},
{name: "every day", due: &dueDate{String: "every day", IsRecurring: true}, want: secondsPerDay},
{name: "daily", due: &dueDate{String: "daily", IsRecurring: true}, want: secondsPerDay},
{name: "every other day", due: &dueDate{String: "every other day", IsRecurring: true}, want: 2 * secondsPerDay},
{name: "every 3 days", due: &dueDate{String: "every 3 days", IsRecurring: true}, want: 3 * secondsPerDay},
{name: "every week", due: &dueDate{String: "every week", IsRecurring: true}, want: secondsPerWeek},
{name: "weekly", due: &dueDate{String: "weekly", IsRecurring: true}, want: secondsPerWeek},
{name: "every other week", due: &dueDate{String: "every other week", IsRecurring: true}, want: 2 * secondsPerWeek},
{name: "every 2 weeks", due: &dueDate{String: "every 2 weeks", IsRecurring: true}, want: 2 * secondsPerWeek},
{name: "every month", due: &dueDate{String: "every month", IsRecurring: true}, want: secondsPerMonth},
{name: "monthly", due: &dueDate{String: "monthly", IsRecurring: true}, want: secondsPerMonth},
{name: "every 3 months", due: &dueDate{String: "every 3 months", IsRecurring: true}, want: 3 * secondsPerMonth},
{name: "every year", due: &dueDate{String: "every year", IsRecurring: true}, want: secondsPerYear},
{name: "yearly", due: &dueDate{String: "yearly", IsRecurring: true}, want: secondsPerYear},
{name: "annually", due: &dueDate{String: "annually", IsRecurring: true}, want: secondsPerYear},
{name: "case insensitive", due: &dueDate{String: "Every Day", IsRecurring: true}, want: secondsPerDay},
{name: "time of day stripped", due: &dueDate{String: "every day at 9am", IsRecurring: true}, want: secondsPerDay},
// Tier 1 doesn't understand these, so the task stays non-repeating.
{name: "specific weekday", due: &dueDate{String: "every monday", IsRecurring: true}, want: 0},
{name: "day of month", due: &dueDate{String: "every 27th", IsRecurring: true}, want: 0},
{name: "non-english", due: &dueDate{String: "cada día", IsRecurring: true}, want: 0},
{name: "gibberish", due: &dueDate{String: "whenever", IsRecurring: true}, want: 0},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
assert.Equal(t, tt.want, parseTodoistRepeat(tt.due))
})
}
}