diff --git a/pkg/modules/migration/todoist/todoist.go b/pkg/modules/migration/todoist/todoist.go index d96338197..4e0884de0 100644 --- a/pkg/modules/migration/todoist/todoist.go +++ b/pkg/modules/migration/todoist/todoist.go @@ -22,8 +22,10 @@ import ( "fmt" "net/http" "net/url" + "regexp" "sort" "strconv" + "strings" "time" "code.vikunja.io/api/pkg/config" @@ -253,6 +255,72 @@ func parseDate(dateString string) (date time.Time, err error) { 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) { var pseudoParentID int64 = 1 @@ -358,6 +426,7 @@ func convertTodoistToVikunja(sync *sync, doneItems map[string]*doneItem) (fullVi return nil, err } task.DueDate = dueDate.In(config.GetTimeZone()) + task.RepeatAfter = parseTodoistRepeat(i.Due) } // Put all labels together from earlier diff --git a/pkg/modules/migration/todoist/todoist_test.go b/pkg/modules/migration/todoist/todoist_test.go index 100b4a885..c1c7322c1 100644 --- a/pkg/modules/migration/todoist/todoist_test.go +++ b/pkg/modules/migration/todoist/todoist_test.go @@ -651,3 +651,47 @@ func TestConvertTodoistToVikunja(t *testing.T) { 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)) + }) + } +}