feat(migration): import recurring tasks from todoist
This commit is contained in:
parent
7691f282cf
commit
330b94c3c4
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue