feat(quickadd): natural-language task parsing in Danish and English
Parses date, time, priority, project and labels out of one typed line, e.g. "betal moms i morgen kl 10 p1 #Firma @regnskab". Both languages are read at once rather than behind a locale switch, because people mix them mid-sentence. Locale settles only one genuine ambiguity: a bare hour after English "at". Each stage reads a copy of the line with earlier matches blanked out, so no two readings can claim the same characters — without it "kl 10.12" is a time and, to the date parser, also the 10th of December. Matches are returned as byte spans so the input box can highlight what it understood as you type. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
40e2cad346
commit
b6a44244c1
@@ -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)
|
||||
}
|
||||
@@ -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 <weekday>" 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user