diff --git a/internal/quickadd/quickadd.go b/internal/quickadd/quickadd.go new file mode 100644 index 0000000..b62f59c --- /dev/null +++ b/internal/quickadd/quickadd.go @@ -0,0 +1,586 @@ +// Package quickadd turns one line of typed text into a task. +// +// "betal moms i morgen kl 10 p1 #Firma @regnskab" +// → content "betal moms", due tomorrow 10:00, priority 1, project Firma, label regnskab +// +// Danish and English are parsed together rather than behind a locale switch. People +// mix them mid-sentence — "ring til Anders on friday" is a real thing a Dane types — +// and a parser that understood only the configured language would drop half of it. +// Locale is used only to break genuine ties, of which there is currently one: the +// meaning of a bare "10" after "at". +// +// Everything the parser recognises is reported as a Span as well as a value, so the +// input box can highlight the parts it understood while they are being typed. That +// feedback is the whole point: a parser you cannot see working is one you stop +// trusting the moment it is wrong once. +package quickadd + +import ( + "regexp" + "sort" + "strconv" + "strings" + "time" + "unicode" +) + +// Kind names what a Span was recognised as. +type Kind string + +const ( + KindDate Kind = "date" + KindTime Kind = "time" + KindPriority Kind = "priority" + KindProject Kind = "project" + KindLabel Kind = "label" +) + +// Span is a byte range of the input that was consumed, and what it was read as. +type Span struct { + Start int `json:"start"` + End int `json:"end"` + Kind Kind `json:"kind"` +} + +// Result is everything one line of input turned into. +type Result struct { + // Content is the input with every recognised span removed and whitespace + // tidied — the task's actual title. + Content string `json:"content"` + // Project is the name written after '#', not an id: the caller resolves it + // against the user's projects and decides what to do when it matches nothing. + Project string `json:"project,omitempty"` + Labels []string `json:"labels,omitempty"` + // Priority is 1 (highest) to 4. 4 is both the default and "none stated". + Priority int `json:"priority"` + // DueDate is a calendar day, "YYYY-MM-DD". Empty when no date was written. + DueDate string `json:"due_date,omitempty"` + // DueTime is "HH:MM" in 24-hour form, set only when a clock time was written. + // A time with no date means today, and Parse fills the date in. + DueTime string `json:"due_time,omitempty"` + Spans []Span `json:"spans,omitempty"` +} + +// Parse reads input as of the moment `now`, which is also the timezone dates are +// resolved in. Locale is "da" or "en" and only affects bare hour readings. +// +// Parse never fails. Anything it does not recognise stays in Content, which is the +// only behaviour that makes sense for a box people type into quickly: a task with a +// clumsy title beats an error message and a lost thought. +func Parse(input string, now time.Time, locale string) Result { + res := Result{Priority: 4} + var spans []Span + + // Sigils first. '#' and '@' are unambiguous, and consuming them stops a project + // called "Fredag" from later being read as a weekday. + res.Project, spans = extractProject(input, spans) + res.Labels, spans = extractLabels(input, spans) + res.Priority, spans = extractPriority(input, spans) + + // Each stage reads a copy with the earlier stages' matches blanked out, so no + // two of them can claim the same characters. Without this, "kl 10.12" is both + // a time and — to a date parser reading the raw line — the 10th of December. + // mask keeps byte offsets intact, so spans still point into the original input. + masked := mask(input, spans) + + // Time before date, so "kl 15.30" is claimed as a clock time before the date + // parser can offer to read "15.3" as the 15th of March. + hour, minute, hasTime, spans := extractTime(masked, spans, locale) + date, hasDate, spans := extractDate(mask(masked, spans), spans, now) + + switch { + case hasDate: + res.DueDate = date.Format("2006-01-02") + case hasTime: + // A time on its own means today — unless that moment has already passed, + // in which case the person means tomorrow. "kl 8" typed at nine in the + // evening is not a request to schedule something twelve hours ago. + day := now + if hour < now.Hour() || (hour == now.Hour() && minute <= now.Minute()) { + day = now.AddDate(0, 0, 1) + } + res.DueDate = day.Format("2006-01-02") + } + if hasTime { + res.DueTime = pad2(hour) + ":" + pad2(minute) + } + + sort.Slice(spans, func(i, j int) bool { return spans[i].Start < spans[j].Start }) + res.Spans = spans + res.Content = remove(input, spans) + return res +} + +// --- sigils ----------------------------------------------------------------- + +// A project or label name runs until whitespace, or is quoted when it contains +// spaces: #"Q3 rapport". Unicode letters are matched via \p{L} so "#Økonomi" works. +var ( + reProject = regexp.MustCompile(`(?:^|\s)#(?:"([^"]+)"|([\p{L}\p{N}_/-]+))`) + reLabel = regexp.MustCompile(`(?:^|\s)@(?:"([^"]+)"|([\p{L}\p{N}_/-]+))`) +) + +func extractProject(input string, spans []Span) (string, []Span) { + // Last one wins. Retyping "#Work #Home" is a correction, not two projects. + all := reProject.FindAllStringSubmatchIndex(input, -1) + if len(all) == 0 { + return "", spans + } + var name string + for _, m := range all { + name = submatch(input, m) + spans = append(spans, sigilSpan(input, m, KindProject)) + } + return name, spans +} + +func extractLabels(input string, spans []Span) ([]string, []Span) { + var labels []string + seen := map[string]bool{} + for _, m := range reLabel.FindAllStringSubmatchIndex(input, -1) { + name := submatch(input, m) + if key := strings.ToLower(name); !seen[key] { + seen[key] = true + labels = append(labels, name) + } + spans = append(spans, sigilSpan(input, m, KindLabel)) + } + return labels, spans +} + +// submatch returns whichever of the two alternatives (quoted, bare) matched. +func submatch(input string, m []int) string { + if m[2] >= 0 { + return input[m[2]:m[3]] + } + return input[m[4]:m[5]] +} + +// sigilSpan trims the leading whitespace the pattern had to include in order to +// anchor on a word start, so highlighting covers "#Firma" and not " #Firma". +func sigilSpan(input string, m []int, kind Kind) Span { + start := m[0] + for start < len(input) && (input[start] == ' ' || input[start] == '\t') { + start++ + } + return Span{Start: start, End: m[1], Kind: kind} +} + +// --- priority --------------------------------------------------------------- + +// p1..p4 as Todoist writes it. Danish users type the same thing; there is no +// competing Danish convention worth supporting. +var rePriority = regexp.MustCompile(`(?i)(?:^|\s)(p[1-4]|!{1,3})(?:\s|$)`) + +func extractPriority(input string, spans []Span) (int, []Span) { + m := rePriority.FindStringSubmatchIndex(input) + if m == nil { + return 4, spans + } + token := strings.ToLower(input[m[2]:m[3]]) + priority := 4 + switch token { + case "p1", "!!!": + priority = 1 + case "p2", "!!": + priority = 2 + case "p3", "!": + priority = 3 + case "p4": + priority = 4 + } + return priority, append(spans, Span{Start: m[2], End: m[3], Kind: KindPriority}) +} + +// --- time ------------------------------------------------------------------- + +var ( + // An explicit marker: "kl 10", "kl. 10:30", "klokken 16", "at 9.15". + reTimeMarked = regexp.MustCompile(`(?i)(?:^|\s)(kl\.?|klokken|at)\s*(\d{1,2})(?:[:.](\d{2}))?\s*(am|pm)?(?:\s|$)`) + // A 12-hour suffix carries its own marker: "10am", "7:30 pm". + reTimeMeridiem = regexp.MustCompile(`(?i)(?:^|\s)(\d{1,2})(?::(\d{2}))?\s*(am|pm)(?:\s|$)`) + // A bare colon time: "16:30". Only ':' — never '.', because "15.3" is a date. + reTimeColon = regexp.MustCompile(`(?:^|\s)(\d{1,2}):(\d{2})(?:\s|$)`) +) + +func extractTime(input string, spans []Span, locale string) (int, int, bool, []Span) { + if m := reTimeMeridiem.FindStringSubmatchIndex(input); m != nil { + hour := atoi(input, m[2], m[3]) + minute := 0 + if m[4] >= 0 { + minute = atoi(input, m[4], m[5]) + } + hour = applyMeridiem(hour, strings.ToLower(input[m[6]:m[7]])) + if valid(hour, minute) { + return hour, minute, true, append(spans, Span{Start: m[2], End: m[7], Kind: KindTime}) + } + } + + if m := reTimeMarked.FindStringSubmatchIndex(input); m != nil { + hour := atoi(input, m[4], m[5]) + minute := 0 + if m[6] >= 0 { + minute = atoi(input, m[6], m[7]) + } + if m[8] >= 0 { + hour = applyMeridiem(hour, strings.ToLower(input[m[8]:m[9]])) + } else if strings.EqualFold(input[m[2]:m[3]], "at") && locale != "da" { + // "at 8" from an English speaker means the morning; from a Dane + // writing "kl 8" it means exactly eight. Only the English marker with + // no minutes and no am/pm is ambiguous enough to guess at, and the + // guess is the one a person means far more often: 1–6 is afternoon. + if hour >= 1 && hour <= 6 && m[6] < 0 { + hour += 12 + } + } + if valid(hour, minute) { + end := m[5] + if m[7] >= 0 { + end = m[7] + } + if m[9] >= 0 { + end = m[9] + } + return hour, minute, true, append(spans, Span{Start: m[2], End: end, Kind: KindTime}) + } + } + + if m := reTimeColon.FindStringSubmatchIndex(input); m != nil { + hour, minute := atoi(input, m[2], m[3]), atoi(input, m[4], m[5]) + if valid(hour, minute) { + return hour, minute, true, append(spans, Span{Start: m[2], End: m[5], Kind: KindTime}) + } + } + return 0, 0, false, spans +} + +func applyMeridiem(hour int, meridiem string) int { + switch meridiem { + case "am": + if hour == 12 { + return 0 + } + case "pm": + if hour != 12 { + return hour + 12 + } + } + return hour +} + +func valid(hour, minute int) bool { return hour >= 0 && hour <= 23 && minute >= 0 && minute <= 59 } + +// --- date ------------------------------------------------------------------- + +var weekdays = map[string]time.Weekday{ + "mandag": time.Monday, "monday": time.Monday, "man": time.Monday, "mon": time.Monday, + "tirsdag": time.Tuesday, "tuesday": time.Tuesday, "tir": time.Tuesday, "tue": time.Tuesday, + "onsdag": time.Wednesday, "wednesday": time.Wednesday, "ons": time.Wednesday, "wed": time.Wednesday, + "torsdag": time.Thursday, "thursday": time.Thursday, "tor": time.Thursday, "thu": time.Thursday, + "fredag": time.Friday, "friday": time.Friday, "fre": time.Friday, "fri": time.Friday, + "lørdag": time.Saturday, "saturday": time.Saturday, "lør": time.Saturday, "sat": time.Saturday, + "søndag": time.Sunday, "sunday": time.Sunday, "søn": time.Sunday, "sun": time.Sunday, +} + +var months = map[string]time.Month{ + "januar": time.January, "january": time.January, "jan": time.January, + "februar": time.February, "february": time.February, "feb": time.February, + "marts": time.March, "march": time.March, "mar": time.March, + "april": time.April, "apr": time.April, + "maj": time.May, "may": time.May, + "juni": time.June, "june": time.June, "jun": time.June, + "juli": time.July, "july": time.July, "jul": time.July, + "august": time.August, "aug": time.August, + "september": time.September, "sep": time.September, "sept": time.September, + "oktober": time.October, "october": time.October, "okt": time.October, "oct": time.October, + "november": time.November, "nov": time.November, + "december": time.December, "dec": time.December, +} + +var ( + reToday = regexp.MustCompile(`(?i)(?:^|\s)(i\s*dag|today|idag)(?:\s|$)`) + reTomorrow = regexp.MustCompile(`(?i)(?:^|\s)(i\s*morgen|imorgen|tomorrow|tmr)(?:\s|$)`) + reOvermorn = regexp.MustCompile(`(?i)(?:^|\s)(i\s*overmorgen|overmorgen|day\s+after\s+tomorrow)(?:\s|$)`) + // "om 3 dage", "in 2 weeks", "om en uge", "in a month". The plural suffixes are + // grouped rather than written as a trailing `r?`: "måneder?" would mean + // "månede" plus an optional r, which is not a word in any language. + reRelative = regexp.MustCompile(`(?i)(?:^|\s)(om|in)\s+(\d+|en|et|a|an)\s*(dage?|days?|uger?|weeks?|måned(?:er)?|maaned(?:er)?|months?)(?:\s|$)`) + reNextUnit = regexp.MustCompile(`(?i)(?:^|\s)(næste|naeste|next)\s+(uge|week|måned|maaned|month)(?:\s|$)`) + // "næste fredag" / "next friday" — the one in the following week. + reNextDay = regexp.MustCompile(`(?i)(?:^|\s)(næste|naeste|next)\s+(\p{L}+)(?:\s|$)`) + // A weekday on its own, optionally with the Danish "på". + reWeekday = regexp.MustCompile(`(?i)(?:^|\s)(?:på\s+|on\s+)?(\p{L}+)(?:\s|$)`) + // "15. marts", "15 march", "den 3. maj" + reDayMonth = regexp.MustCompile(`(?i)(?:^|\s)(?:den\s+)?(\d{1,2})\.?\s+(\p{L}+)(?:\s|$)`) + // "march 15", "dec 1" + reMonthDay = regexp.MustCompile(`(?i)(?:^|\s)(\p{L}+)\s+(\d{1,2})(?:\.|\b)(?:\s|$)`) + // ISO first, because it is the only unambiguous numeric form. + reISO = regexp.MustCompile(`(?:^|\s)(\d{4})-(\d{2})-(\d{2})(?:\s|$)`) + // "15/3", "15/3-2026", "15-03-2026". Day first: that is how both Danish and + // British English write it, and guessing month-first would silently move a + // date by months rather than failing visibly. + reNumeric = regexp.MustCompile(`(?:^|\s)(\d{1,2})[/.](\d{1,2})(?:[-/](\d{2,4}))?(?:\s|$)`) +) + +func extractDate(input string, spans []Span, now time.Time) (time.Time, bool, []Span) { + today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()) + + // Ordered most-specific first: an explicit date beats a relative phrase, and a + // two-word phrase must be tried before either of its words matches alone. + if m := reISO.FindStringSubmatchIndex(input); m != nil { + y, mo, d := atoi(input, m[2], m[3]), atoi(input, m[4], m[5]), atoi(input, m[6], m[7]) + if date, ok := makeDate(y, time.Month(mo), d, now.Location()); ok { + return date, true, append(spans, Span{Start: m[2], End: m[7], Kind: KindDate}) + } + } + + if m := reNumeric.FindStringSubmatchIndex(input); m != nil { + day, mo := atoi(input, m[2], m[3]), atoi(input, m[4], m[5]) + year := today.Year() + explicitYear := m[6] >= 0 + if explicitYear { + year = atoi(input, m[6], m[7]) + if year < 100 { + year += 2000 + } + } + if date, ok := makeDate(year, time.Month(mo), day, now.Location()); ok { + if !explicitYear && date.Before(today) { + date = date.AddDate(1, 0, 0) + } + end := m[5] + if explicitYear { + end = m[7] + } + return date, true, append(spans, Span{Start: m[2], End: end, Kind: KindDate}) + } + } + + if m := reOvermorn.FindStringSubmatchIndex(input); m != nil { + return today.AddDate(0, 0, 2), true, append(spans, Span{Start: m[2], End: m[3], Kind: KindDate}) + } + if m := reTomorrow.FindStringSubmatchIndex(input); m != nil { + return today.AddDate(0, 0, 1), true, append(spans, Span{Start: m[2], End: m[3], Kind: KindDate}) + } + if m := reToday.FindStringSubmatchIndex(input); m != nil { + return today, true, append(spans, Span{Start: m[2], End: m[3], Kind: KindDate}) + } + + if m := reRelative.FindStringSubmatchIndex(input); m != nil { + n := 1 + if word := strings.ToLower(input[m[4]:m[5]]); !isWordOne(word) { + n = atoi(input, m[4], m[5]) + } + unit := strings.ToLower(input[m[6]:m[7]]) + var date time.Time + switch { + case strings.HasPrefix(unit, "dag"), strings.HasPrefix(unit, "day"): + date = today.AddDate(0, 0, n) + case strings.HasPrefix(unit, "uge"), strings.HasPrefix(unit, "week"): + date = today.AddDate(0, 0, 7*n) + default: + date = today.AddDate(0, n, 0) + } + return date, true, append(spans, Span{Start: m[2], End: m[7], Kind: KindDate}) + } + + if m := reNextUnit.FindStringSubmatchIndex(input); m != nil { + unit := strings.ToLower(input[m[4]:m[5]]) + var date time.Time + if strings.HasPrefix(unit, "uge") || strings.HasPrefix(unit, "week") { + date = startOfNextWeek(today) + } else { + date = today.AddDate(0, 1, 0) + } + return date, true, append(spans, Span{Start: m[2], End: m[5], Kind: KindDate}) + } + + if m := reNextDay.FindStringSubmatchIndex(input); m != nil { + if wd, ok := weekdays[strings.ToLower(input[m[4]:m[5]])]; ok { + // "next friday" is the Friday of next week, counted from Monday — + // not "the next Friday to occur", which on a Thursday would be + // tomorrow and surprise everybody. + date := startOfNextWeek(today).AddDate(0, 0, int(isoIndex(wd))) + return date, true, append(spans, Span{Start: m[2], End: m[5], Kind: KindDate}) + } + } + + if date, ok, s := matchDayMonth(input, spans, today); ok { + return date, true, s + } + + // A bare weekday is tried last: it is the loosest pattern here, so it only gets + // a say once nothing more specific in the line has claimed a date. + // + // This walks words rather than running a regex over the line. A pattern that + // anchors on the whitespace around a word consumes that whitespace, so in a run + // of plain words it matches every other one — and "ring til Anders fredag" + // would find no weekday at all, depending only on how many words came first. + ws := words(input) + for i, w := range ws { + wd, ok := weekdays[strings.ToLower(w.text)] + if !ok { + continue + } + start := w.start + // "på fredag" and "on friday" are one phrase; leaving the preposition + // behind would strand it in the task title as "ring til Anders on". + if i > 0 { + switch strings.ToLower(ws[i-1].text) { + case "på", "paa", "on": + start = ws[i-1].start + } + } + ahead := (int(wd) - int(today.Weekday()) + 7) % 7 + return today.AddDate(0, 0, ahead), true, append(spans, Span{Start: start, End: w.end, Kind: KindDate}) + } + + return time.Time{}, false, spans +} + +type word struct { + text string + start, end int +} + +// words splits on anything that is not a letter or digit, keeping byte offsets. +func words(s string) []word { + var out []word + start := -1 + for i, r := range s { + if unicode.IsLetter(r) || unicode.IsDigit(r) { + if start < 0 { + start = i + } + continue + } + if start >= 0 { + out = append(out, word{text: s[start:i], start: start, end: i}) + start = -1 + } + } + if start >= 0 { + out = append(out, word{text: s[start:], start: start, end: len(s)}) + } + return out +} + +// mask blanks out the byte ranges already claimed, leaving the string the same +// length so every offset computed against it still indexes the original input. +func mask(s string, spans []Span) string { + if len(spans) == 0 { + return s + } + b := []byte(s) + for _, sp := range spans { + for i := sp.Start; i < sp.End && i < len(b); i++ { + b[i] = ' ' + } + } + return string(b) +} + +// matchDayMonth handles the two written forms, "15. marts" and "march 15". +func matchDayMonth(input string, spans []Span, today time.Time) (time.Time, bool, []Span) { + try := func(m []int, dayGroup, monthGroup int) (time.Time, bool, []Span) { + if m == nil { + return time.Time{}, false, spans + } + mo, ok := months[strings.ToLower(input[m[monthGroup*2]:m[monthGroup*2+1]])] + if !ok { + return time.Time{}, false, spans + } + day := atoi(input, m[dayGroup*2], m[dayGroup*2+1]) + date, ok := makeDate(today.Year(), mo, day, today.Location()) + if !ok { + return time.Time{}, false, spans + } + // A date already gone by means next year: nobody schedules backwards. + if date.Before(today) { + date = date.AddDate(1, 0, 0) + } + // The span runs from the first group to the last, which excludes the + // optional "den " prefix and the trailing separator the pattern matched. + return date, true, append(spans, Span{Start: m[2], End: m[5], Kind: KindDate}) + } + + if date, ok, s := try(reDayMonth.FindStringSubmatchIndex(input), 1, 2); ok { + return date, ok, s + } + return try(reMonthDay.FindStringSubmatchIndex(input), 2, 1) +} + +// startOfNextWeek returns the coming Monday. On a Monday that is seven days out, +// not today — "next week" never means "right now". +func startOfNextWeek(today time.Time) time.Time { + return today.AddDate(0, 0, 8-int(isoIndex(today.Weekday()))-1) +} + +// isoIndex maps a weekday to its Monday-first position, 0..6. Go's own numbering +// starts on Sunday, which puts the weekend in the wrong place for week arithmetic. +func isoIndex(wd time.Weekday) int { + if wd == time.Sunday { + return 6 + } + return int(wd) - 1 +} + +// makeDate rejects anything that would silently roll over — 31 February becoming +// 3 March is a wrong date, not a date. +func makeDate(year int, month time.Month, day int, loc *time.Location) (time.Time, bool) { + if month < time.January || month > time.December || day < 1 || day > 31 || year < 1970 || year > 2999 { + return time.Time{}, false + } + date := time.Date(year, month, day, 0, 0, 0, 0, loc) + if date.Day() != day || date.Month() != month { + return time.Time{}, false + } + return date, true +} + +func isWordOne(w string) bool { + switch w { + case "en", "et", "a", "an": + return true + } + return false +} + +// --- assembling the leftover ------------------------------------------------ + +// remove cuts every recognised span out of the input and tidies what is left: +// runs of whitespace collapse to one, and stray separators left dangling by a +// removal are dropped. +func remove(input string, spans []Span) string { + if len(spans) == 0 { + return strings.Join(strings.Fields(input), " ") + } + var b strings.Builder + prev := 0 + for _, s := range spans { + if s.Start < prev { + // Overlapping spans: keep the first, skip what is already gone. + if s.End > prev { + prev = s.End + } + continue + } + b.WriteString(input[prev:s.Start]) + b.WriteByte(' ') + prev = s.End + } + b.WriteString(input[prev:]) + return strings.Join(strings.Fields(b.String()), " ") +} + +func atoi(s string, start, end int) int { + n, _ := strconv.Atoi(s[start:end]) + return n +} + +func pad2(n int) string { + if n < 10 { + return "0" + strconv.Itoa(n) + } + return strconv.Itoa(n) +} diff --git a/internal/quickadd/quickadd_test.go b/internal/quickadd/quickadd_test.go new file mode 100644 index 0000000..05afb60 --- /dev/null +++ b/internal/quickadd/quickadd_test.go @@ -0,0 +1,431 @@ +package quickadd + +import ( + "testing" + "time" +) + +// The reference moment for every test: Tuesday 10 March 2026, 10:00, Copenhagen. +// A Tuesday is deliberate — it sits mid-week, so "next friday" and "monday" resolve +// in opposite directions and a bug in either cannot hide behind the other. +func ref(t *testing.T) time.Time { + t.Helper() + loc, err := time.LoadLocation("Europe/Copenhagen") + if err != nil { + t.Fatalf("load timezone: %v", err) + } + now := time.Date(2026, 3, 10, 10, 0, 0, 0, loc) + if now.Weekday() != time.Tuesday { + t.Fatalf("reference date is a %s; the expectations below assume Tuesday", now.Weekday()) + } + return now +} + +// The example from the brief, end to end. +func TestParseTheBriefExample(t *testing.T) { + got := Parse("betal moms i morgen kl 10 p1 #Firma @regnskab", ref(t), "da") + + if got.Content != "betal moms" { + t.Errorf("Content = %q, want %q", got.Content, "betal moms") + } + if got.DueDate != "2026-03-11" { + t.Errorf("DueDate = %q, want 2026-03-11", got.DueDate) + } + if got.DueTime != "10:00" { + t.Errorf("DueTime = %q, want 10:00", got.DueTime) + } + if got.Priority != 1 { + t.Errorf("Priority = %d, want 1", got.Priority) + } + if got.Project != "Firma" { + t.Errorf("Project = %q, want Firma", got.Project) + } + if len(got.Labels) != 1 || got.Labels[0] != "regnskab" { + t.Errorf("Labels = %v, want [regnskab]", got.Labels) + } +} + +func TestParseDates(t *testing.T) { + now := ref(t) + cases := []struct { + in string + want string + }{ + // Danish relative + {"ring til mor i dag", "2026-03-10"}, + {"ring til mor idag", "2026-03-10"}, + {"ring til mor i morgen", "2026-03-11"}, + {"ring til mor imorgen", "2026-03-11"}, + {"ring til mor i overmorgen", "2026-03-12"}, + {"ring til mor om 3 dage", "2026-03-13"}, + {"ring til mor om en uge", "2026-03-17"}, + {"ring til mor om 2 uger", "2026-03-24"}, + {"ring til mor om en måned", "2026-04-10"}, + {"ring til mor næste uge", "2026-03-16"}, + {"ring til mor næste måned", "2026-04-10"}, + + // English relative + {"call mum today", "2026-03-10"}, + {"call mum tomorrow", "2026-03-11"}, + {"call mum day after tomorrow", "2026-03-12"}, + {"call mum in 3 days", "2026-03-13"}, + {"call mum in a week", "2026-03-17"}, + {"call mum in 2 weeks", "2026-03-24"}, + {"call mum next week", "2026-03-16"}, + {"call mum next month", "2026-04-10"}, + + // Weekdays. From a Tuesday: Friday is this week, Monday is the next one, + // and Tuesday itself is today. + {"møde fredag", "2026-03-13"}, + {"møde på fredag", "2026-03-13"}, + {"møde mandag", "2026-03-16"}, + {"møde tirsdag", "2026-03-10"}, + {"møde søndag", "2026-03-15"}, + {"meeting friday", "2026-03-13"}, + {"meeting on friday", "2026-03-13"}, + + // "next " is next week's, counted from Monday — not merely the + // next one to come round. + {"møde næste fredag", "2026-03-20"}, + {"meeting next friday", "2026-03-20"}, + {"møde næste mandag", "2026-03-16"}, + + // Written dates + {"deadline 15. marts", "2026-03-15"}, + {"deadline 15 marts", "2026-03-15"}, + {"deadline den 15. marts", "2026-03-15"}, + {"deadline march 15", "2026-03-15"}, + {"deadline 1. dec", "2026-12-01"}, + {"deadline dec 1", "2026-12-01"}, + + // Numeric, day-first + {"deadline 15/3", "2026-03-15"}, + {"deadline 15/3-2026", "2026-03-15"}, + {"deadline 2026-12-24", "2026-12-24"}, + + // A date that has already gone by this year means next year. + {"deadline 1. marts", "2027-03-01"}, + {"deadline 1/3", "2027-03-01"}, + } + + for _, tc := range cases { + t.Run(tc.in, func(t *testing.T) { + got := Parse(tc.in, now, "da") + if got.DueDate != tc.want { + t.Errorf("DueDate = %q, want %q (content %q)", got.DueDate, tc.want, got.Content) + } + }) + } +} + +func TestParseTimes(t *testing.T) { + now := ref(t) + cases := []struct { + in string + locale string + want string + }{ + {"møde kl 14", "da", "14:00"}, + {"møde kl. 14", "da", "14:00"}, + {"møde kl 14:30", "da", "14:30"}, + {"møde kl 14.30", "da", "14:30"}, + {"møde klokken 16", "da", "16:00"}, + {"møde 16:30", "da", "16:30"}, + {"meeting at 14", "en", "14:00"}, + {"meeting at 10am", "en", "10:00"}, + {"meeting at 7:30 pm", "en", "19:30"}, + {"meeting 10am", "en", "10:00"}, + {"meeting 12am", "en", "00:00"}, + {"meeting 12pm", "en", "12:00"}, + + // "kl 3" from a Dane is three in the morning — Danish has no am/pm habit, + // and the 24-hour clock is what people mean. English "at 3" is not. + {"møde kl 3", "da", "03:00"}, + {"meeting at 3", "en", "15:00"}, + } + + for _, tc := range cases { + t.Run(tc.in, func(t *testing.T) { + got := Parse(tc.in, now, tc.locale) + if got.DueTime != tc.want { + t.Errorf("DueTime = %q, want %q", got.DueTime, tc.want) + } + }) + } +} + +// A clock time with no day attached means the next time that clock reads it. +func TestTimeWithoutDateRollsForward(t *testing.T) { + now := ref(t) // 10:00 + + later := Parse("møde kl 14", now, "da") + if later.DueDate != "2026-03-10" { + t.Errorf("a time still to come today: DueDate = %q, want 2026-03-10", later.DueDate) + } + + earlier := Parse("møde kl 8", now, "da") + if earlier.DueDate != "2026-03-11" { + t.Errorf("a time already past today: DueDate = %q, want 2026-03-11", earlier.DueDate) + } +} + +func TestParsePriority(t *testing.T) { + now := ref(t) + cases := map[string]int{ + "skriv rapport p1": 1, + "skriv rapport p2": 2, + "skriv rapport p3": 3, + "skriv rapport p4": 4, + "skriv rapport": 4, + "skriv rapport !!!": 1, + "skriv rapport !!": 2, + "skriv rapport !": 3, + // Not a priority: part of a word, or out of range. + "skriv p5 rapport": 4, + "skriv rapportp1": 4, + "deploy p1beta node": 4, + } + for in, want := range cases { + t.Run(in, func(t *testing.T) { + if got := Parse(in, now, "da").Priority; got != want { + t.Errorf("Priority = %d, want %d", got, want) + } + }) + } +} + +func TestParseProjectAndLabels(t *testing.T) { + now := ref(t) + + got := Parse("køb kaffe #Hjem @indkøb @haster", now, "da") + if got.Project != "Hjem" { + t.Errorf("Project = %q, want Hjem", got.Project) + } + if len(got.Labels) != 2 || got.Labels[0] != "indkøb" || got.Labels[1] != "haster" { + t.Errorf("Labels = %v, want [indkøb haster]", got.Labels) + } + if got.Content != "køb kaffe" { + t.Errorf("Content = %q, want %q", got.Content, "køb kaffe") + } + + t.Run("quoted names may contain spaces", func(t *testing.T) { + got := Parse(`send faktura #"Q3 rapport" @"skal godkendes"`, now, "da") + if got.Project != "Q3 rapport" { + t.Errorf("Project = %q, want %q", got.Project, "Q3 rapport") + } + if len(got.Labels) != 1 || got.Labels[0] != "skal godkendes" { + t.Errorf("Labels = %v", got.Labels) + } + if got.Content != "send faktura" { + t.Errorf("Content = %q", got.Content) + } + }) + + t.Run("the last project wins", func(t *testing.T) { + // Typing a second one is a correction, not a second project. + got := Parse("opgave #Arbejde #Privat", now, "da") + if got.Project != "Privat" { + t.Errorf("Project = %q, want Privat", got.Project) + } + if got.Content != "opgave" { + t.Errorf("Content = %q, want %q", got.Content, "opgave") + } + }) + + t.Run("a repeated label is added once", func(t *testing.T) { + got := Parse("opgave @haster @Haster", now, "da") + if len(got.Labels) != 1 { + t.Errorf("Labels = %v, want one entry", got.Labels) + } + }) + + t.Run("danish letters in names", func(t *testing.T) { + got := Parse("bogfør bilag #Økonomi @årsregnskab", now, "da") + if got.Project != "Økonomi" { + t.Errorf("Project = %q, want Økonomi", got.Project) + } + if len(got.Labels) != 1 || got.Labels[0] != "årsregnskab" { + t.Errorf("Labels = %v, want [årsregnskab]", got.Labels) + } + }) +} + +// The parser must not claim the same characters twice. "kl 10.12" is a time; a date +// parser reading the raw line would also see the 10th of December in it. +func TestOverlappingReadingsAreResolvedOnce(t *testing.T) { + now := ref(t) + + got := Parse("møde kl 10.12", now, "da") + if got.DueTime != "10:12" { + t.Errorf("DueTime = %q, want 10:12", got.DueTime) + } + if got.DueDate != "2026-03-10" { + t.Errorf("DueDate = %q, want today — the time is still to come", got.DueDate) + } + if got.Content != "møde" { + t.Errorf("Content = %q, want %q", got.Content, "møde") + } +} + +// A project or label named after a weekday is a name, not a date. +func TestSigilNamesAreNotReadAsDates(t *testing.T) { + now := ref(t) + + got := Parse("planlæg #Fredagsbar", now, "da") + if got.DueDate != "" { + t.Errorf("DueDate = %q, want none", got.DueDate) + } + if got.Project != "Fredagsbar" { + t.Errorf("Project = %q", got.Project) + } + + exact := Parse("drinks @fredag", now, "da") + if exact.DueDate != "" { + t.Errorf("a label spelled like a weekday set DueDate = %q", exact.DueDate) + } +} + +// A weekday must be found wherever it sits in the line. A pattern anchored on the +// whitespace around a word eats that whitespace and then matches every other word. +func TestWeekdayIsFoundAtAnyPosition(t *testing.T) { + now := ref(t) + cases := []string{ + "fredag", + "ring fredag", + "ring til fredag", + "ring til Anders fredag", + "ring til Anders om det der fredag", + "a b c d e f fredag", + } + for _, in := range cases { + t.Run(in, func(t *testing.T) { + if got := Parse(in, now, "da").DueDate; got != "2026-03-13" { + t.Errorf("DueDate = %q, want 2026-03-13", got) + } + }) + } +} + +func TestContentKeepsWhatWasNotUnderstood(t *testing.T) { + now := ref(t) + cases := []struct{ in, want string }{ + {"betal moms i morgen kl 10 p1 #Firma @regnskab", "betal moms"}, + {" ryd op ", "ryd op"}, + {"p1 #Firma", ""}, + {"skriv rapport om kvartalstallene", "skriv rapport om kvartalstallene"}, + {"i morgen ring til tandlægen", "ring til tandlægen"}, + {"ring #Privat til tandlægen i morgen", "ring til tandlægen"}, + } + for _, tc := range cases { + t.Run(tc.in, func(t *testing.T) { + if got := Parse(tc.in, now, "da").Content; got != tc.want { + t.Errorf("Content = %q, want %q", got, tc.want) + } + }) + } +} + +// Danish and English are parsed together: people mix them mid-sentence. +func TestLanguagesMixInOneLine(t *testing.T) { + got := Parse("ring til Anders on friday kl 14 #Arbejde", ref(t), "da") + if got.DueDate != "2026-03-13" { + t.Errorf("DueDate = %q, want 2026-03-13", got.DueDate) + } + if got.DueTime != "14:00" { + t.Errorf("DueTime = %q, want 14:00", got.DueTime) + } + if got.Content != "ring til Anders" { + t.Errorf("Content = %q, want %q", got.Content, "ring til Anders") + } +} + +// Spans drive the live highlighting in the input box, so they have to point at the +// right bytes of the original string — not of some lowercased or rewritten copy. +func TestSpansPointIntoTheOriginalInput(t *testing.T) { + in := "betal moms i morgen kl 10 p1 #Firma @regnskab" + got := Parse(in, ref(t), "da") + + // A span covers the whole phrase that was understood, marker included — the + // highlight should sit under "kl 10", not under a bare "10" with a stray "kl" + // left unhighlighted beside it. + want := map[Kind]string{ + KindDate: "i morgen", + KindTime: "kl 10", + KindPriority: "p1", + KindProject: "#Firma", + KindLabel: "@regnskab", + } + seen := map[Kind]bool{} + for _, s := range got.Spans { + if s.Start < 0 || s.End > len(in) || s.Start >= s.End { + t.Fatalf("span %+v is not a valid range of a %d-byte input", s, len(in)) + } + text := in[s.Start:s.End] + if w, ok := want[s.Kind]; ok && text != w { + t.Errorf("span %s covers %q, want %q", s.Kind, text, w) + } + seen[s.Kind] = true + } + for kind := range want { + if !seen[kind] { + t.Errorf("no span of kind %s", kind) + } + } + + // Spans are handed to the UI in the order they appear. + for i := 1; i < len(got.Spans); i++ { + if got.Spans[i-1].Start > got.Spans[i].Start { + t.Errorf("spans are not sorted: %+v", got.Spans) + } + } + + // And they must not overlap, or highlighting would paint over itself. + for i := 1; i < len(got.Spans); i++ { + if got.Spans[i].Start < got.Spans[i-1].End { + t.Errorf("spans %+v and %+v overlap", got.Spans[i-1], got.Spans[i]) + } + } +} + +// Nothing typed into a quick-add box may produce an invalid date or a panic. +func TestNonsenseInputIsHarmless(t *testing.T) { + now := ref(t) + inputs := []string{ + "", " ", "#", "@", "#\"", "p", "p9", + "31. februar", "32/13", "0/0", "2026-13-45", "99:99", "kl 25:00", + "@@@@", "####", "i morgen i morgen i morgen", + "om dage", "in weeks", "næste", "next", + "1/1/1/1/1", "----", "kl", "at", + } + for _, in := range inputs { + t.Run(in, func(t *testing.T) { + got := Parse(in, now, "da") + if got.Priority < 1 || got.Priority > 4 { + t.Errorf("Priority = %d, out of range", got.Priority) + } + if got.DueDate != "" { + if _, err := time.Parse("2006-01-02", got.DueDate); err != nil { + t.Errorf("DueDate = %q is not a date: %v", got.DueDate, err) + } + } + if got.DueTime != "" { + if _, err := time.Parse("15:04", got.DueTime); err != nil { + t.Errorf("DueTime = %q is not a time: %v", got.DueTime, err) + } + } + }) + } +} + +// An impossible calendar date must be left in the title rather than rolled over +// into a different, plausible-looking one. +func TestImpossibleDatesAreNotSilentlyMoved(t *testing.T) { + now := ref(t) + for _, in := range []string{"deadline 31. februar", "deadline 30/2", "deadline 2026-02-30"} { + t.Run(in, func(t *testing.T) { + if got := Parse(in, now, "da").DueDate; got != "" { + t.Errorf("DueDate = %q, want none — 30/31 February is not a date", got) + } + }) + } +}