Files
verdande/internal/quickadd/quickadd.go
T
kristianwindandClaude Opus 5.5 ed30eb4c36 fix(hurtig tilføjelse): fire ting, der blev læst forkert eller tabt i stilhed
Fundet under arbejdet med genopbygnings-prompten, hvor den rigtige parser
blev kørt over en tabel af sætninger.

- "at" før et 12-timers klokkeslæt blev stående i titlen: "file the VAT
  return tomorrow at 10am" blev opgaven "file the VAT return at". Markøren
  (at, kl, klokken) hører nu med til tiden, også foran "7:30 pm".
- "køb 1/2 liter fløde" fik forfaldsdato 1. februar. En d/m efterfulgt af en
  mængdeenhed (liter, dl, kg, stk, tsk, kop, del, …) er en mængde. Uden enhed
  er det stadig en dato — "3/4" alene, "møde 3/4 med Anders" og også "3/4
  kage", fordi intet i "kage" skiller tredje april fra tre fjerdedele, og en
  dato, der stille forsvinder, er værre end en, man selv sletter.
- Et "#navn" eller "/navn", der ikke fandtes, blev klippet ud af titlen, og
  opgaven landede et andet sted uden et ord. Serveren har længe sendt
  unknown_project og unknown_section; fladen siger dem nu i en besked, også
  når en underopgave beholder sin opgaves projekt.
- Titlen i opgaveruden blev læst om, men gentagelsen blev læst under
  `parsed.recurrence`, et felt der ikke findes, så "hver mandag" forsvandt.
  Den læser nu `recurrence_rule` og tager også klokkeslæt, projekt og alarm
  med, og siger gentagelsen på læserens sprog i stedet for et fast
  "gentagelse". Alarmen lægges før rettelsen, så den skrevne ikke stables
  oven på standardalarmen.

Prøver: to nye Go-prøver i quickadd og to e2e-prøver. Fem plantede fejl,
hver fanget af sin egen prøve.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 00:32:16 +01:00

906 lines
34 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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"
"github.com/kristianwind/verdande/internal/recurrence"
)
// Kind names what a Span was recognised as.
type Kind string
const (
KindDate Kind = "date"
KindTime Kind = "time"
KindPriority Kind = "priority"
KindProject Kind = "project"
KindSection Kind = "section"
KindLabel Kind = "label"
KindRepeat Kind = "repeat"
KindReminder Kind = "reminder"
)
// 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"`
// Section is the name written after '/', resolved by the caller against the
// sections of whichever project the task lands in. A section belongs to one
// project, so it cannot be looked up until the project is known.
Section string `json:"section,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"`
// Recurrence is an RFC 5545 RRULE, empty when the task does not repeat.
Recurrence string `json:"recurrence_rule,omitempty"`
// RecurrenceText is that rule written back out in Danish, for the preview.
RecurrenceText string `json:"recurrence_text,omitempty"`
// ReminderOffsetMin is when to be told, as minutes relative to the task's due
// moment: negative for before it, zero for at it. Nil when the line asked for
// no reminder.
//
// Relative rather than absolute, so that moving the task moves its alarm with
// it. Mind the sign: the store ADDS this to the due time
// (`due_datetime + offset_min * 60`), so "an hour before" is -60. Getting that
// backwards gives an alarm an hour late, which is the one direction a reminder
// must never be wrong in.
ReminderOffsetMin *int `json:"reminder_offset_min,omitempty"`
// ReminderAt is an absolute reminder, "YYYY-MM-DDTHH:MM", set when the line
// gave the reminder a clock of its own. It takes the day the task is due:
// "mind mig kl 8" about something due Friday means Friday at eight, not today.
ReminderAt string `json:"reminder_at,omitempty"`
// NoReminder is the line saying it wants no alarm on this one — "ingen alarm".
//
// It exists because of the account-wide default: with alarms on for everything
// that has a clock, there has to be a way to say "not this one" in the same
// breath as writing it. Without that the only way out is to create the task,
// open it, and delete the alarm that was just made.
NoReminder bool `json:"no_reminder,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.Section, spans = extractSection(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)
// Reminders before the clock and the calendar, and that order is the whole
// trick: "mind mig en time før" has a duration in it and "mind mig kl 8" has a
// clock, and either one read by the time parser first becomes the task's own
// due time instead of its alarm. The phrase is anchored on a keyword the way a
// project is anchored on '#', so consuming it this early cannot take anything
// that was not offered to it.
// "No alarm" before "alarm", or the refusal leaves the very word the stage
// below is looking for standing in the line.
res.NoReminder, spans = extractNoReminder(masked, spans)
masked = mask(masked, spans)
rem, spans := extractReminder(masked, spans)
masked = mask(masked, spans)
// Recurrence before the date parser, and this order is load-bearing: "hver
// mandag" contains a weekday, and a date parser reading it first would claim
// Monday as a one-off due date and leave "hver" stranded in the title.
res.Recurrence, spans = extractRecurrence(masked, spans)
res.RecurrenceText = recurrence.Describe(res.Recurrence)
masked = mask(masked, 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 res.Recurrence != "":
// A repeating task needs somewhere to repeat from. "vand planterne hver
// mandag" typed on a Tuesday means starting the coming Monday, which is
// what advancing from today produces.
//
// This is checked before the time-only case below, and that ordering
// matters: in "hver mandag kl 9" the rule decides the day and the clock
// decides only the hour. Letting the time case win would schedule it for
// tomorrow morning, because nine o'clock has already gone.
if next, err := recurrence.Next(res.Recurrence, now); err == nil {
res.DueDate = next.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)
}
// The reminder is resolved last, because an absolute one needs the due date the
// stages above just worked out.
if rem.found {
if rem.hasClock {
day := res.DueDate
if day == "" {
day = now.Format("2006-01-02")
}
res.ReminderAt = day + "T" + pad2(rem.hour) + ":" + pad2(rem.min)
} else {
off := rem.offsetMin
res.ReminderOffsetMin = &off
}
}
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}_/-]+))`)
// A section, written '/Produktion' or '/"Kunder og ordrer"'.
//
// The name may itself contain a slash — "Kunder/Ordrer" is a real section
// name — so the bare form is greedy and swallows the lot.
//
// The leading `(?:^|\s)` is what keeps this out of ordinary text. A slash
// inside a word never matches, so "km/t" and "3/4" are safe, and the name has
// to start immediately after the slash, so "20 kr / stk" is safe too. Those
// three are the shapes a slash actually takes in a Danish task line.
reSection = 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
}
// Last one wins, as with the project: retyping is a correction, not two sections.
func extractSection(input string, spans []Span) (string, []Span) {
all := reSection.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, KindSection))
}
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" — and may still
// be written after one, "at 10am", "kl 7 pm". The marker is part of the time:
// left behind, "tomorrow at 10am" became the title "… at".
reTimeMeridiem = regexp.MustCompile(`(?i)(?:^|\s)((?:at|kl\.?|klokken)\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[4], m[5])
minute := 0
if m[6] >= 0 {
minute = atoi(input, m[6], m[7])
}
hour = applyMeridiem(hour, strings.ToLower(input[m[8]:m[9]]))
if valid(hour, minute) {
start := m[4]
if m[2] >= 0 {
start = m[2]
}
return hour, minute, true, append(spans, Span{Start: start, End: m[9], 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|$)`)
)
// fractionUnits are the words that make "3/4" a quantity rather than a day.
//
// "køb 1/2 liter fløde" is half a litre, not the first of February — and it is
// the ordinary shape of a shopping list or a recipe. Without a unit after it the
// slash form stays a date: "3/4" alone, "møde 3/4 med Anders", and also
// "3/4 kage", because nothing in "kage" tells the third of April from three
// quarters, and a date quietly dropped is worse than one somebody removes.
var fractionUnits = map[string]bool{
"l": true, "liter": true, "dl": true, "cl": true, "ml": true,
"kg": true, "g": true, "gram": true, "pund": true,
"tsk": true, "spsk": true, "kop": true, "kopper": true, "glas": true,
"stk": true, "stk.": true, "del": true, "dele": true, "pakke": true, "pakker": true,
"time": true, "timer": true, "km": true, "m": true, "cm": true,
"cup": true, "cups": true, "tsp": true, "tbsp": true, "lb": true, "oz": true,
}
// firstNumericDate is the first "d/m" in the input that is not a quantity — see
// fractionUnits. A written year always makes it a date.
func firstNumericDate(input string) []int {
for _, m := range reNumeric.FindAllStringSubmatchIndex(input, -1) {
if m[6] < 0 {
rest := strings.Fields(input[m[5]:])
if len(rest) > 0 && fractionUnits[strings.ToLower(rest[0])] {
continue
}
}
return m
}
return nil
}
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 := firstNumericDate(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)
}
// --- recurrence ---------------------------------------------------------------
// reRepeat finds the repetition phrase inside a longer line. The phrase itself is
// handed to the recurrence package, which decides whether it really is one — so a
// weekday that merely follows the word "hver" is not enough on its own.
//
// The trailing group is bounded rather than greedy: without a limit, "hver mandag
// kl 10 p1" would hand the whole tail to the recurrence parser, which would refuse
// all of it and leave the task not repeating at all.
//
// Mellemleddet må være et ord og ikke kun et tal. "hver anden tirsdag" er den måde,
// folk siger det på — recurrence-pakken har oversat "hver anden" til "hver 2."
// siden den blev skrevet — men her stoppede mønsteret efter "anden" og gav den
// sætning videre. Den er ikke en regel, så "hver anden" blev stående i titlen og
// "tirsdag" blev en forfaldsdato. Sløjfen nedenfor kan kun *fjerne* ord fra
// enden; den kan ikke hente et, mønsteret aldrig tog med.
//
// Fundet af en prøve, der brugte vendingen som selvfølgelig og opdagede, at den
// ikke var det.
var reRepeat = regexp.MustCompile(
`(?i)(?:^|\s)((?:hver|hvert|every|each)\s+(?:\d+\.?\s+|(?:anden|andet|tredje|fjerde|femte|sjette|other)\s+)?[\p{L}]+(?:\s*(?:,|\bog\b|\band\b)\s*[\p{L}]+)*` +
`|hverdage|ugedage|weekdays?|dagligt|daily|ugentligt|weekly|månedligt|maanedligt|monthly|årligt|aarligt|yearly|annually` +
`|den\s+\d{1,2}\.?\s+i\s+måneden|den\s+\d{1,2}\.?\s+i\s+maaneden)(?:\s|$)`)
func extractRecurrence(input string, spans []Span) (string, []Span) {
for _, m := range reRepeat.FindAllStringSubmatchIndex(input, -1) {
phrase := input[m[2]:m[3]]
// The longest match may carry words that are not part of the repetition —
// "hver mandag kl" — so trailing words are dropped one at a time until
// what is left parses, or nothing is.
start, end := m[2], m[3]
for {
if rule := recurrence.Parse(phrase); rule != "" {
return rule, append(spans, Span{Start: start, End: end, Kind: KindRepeat})
}
cut := strings.LastIndexAny(strings.TrimRight(phrase, " "), " ")
if cut <= 0 {
break
}
phrase = phrase[:cut]
end = start + cut
}
}
return "", spans
}
// --- reminders ----------------------------------------------------------------
// reReminderLead finds the words that introduce an alarm. A reminder is anchored
// on a keyword the way a project is anchored on '#': nothing becomes an alarm by
// standing near a clock, because "ring til tandlægen kl 9" asks for a task at nine
// and not to be woken before it.
//
// The trailing \b is load-bearing. Without it the pattern matches the first six
// letters of "alarmen" and of "mind migrationen", takes them out of the title, and
// sets an alarm nobody asked for.
var reReminderLead = regexp.MustCompile(
`(?i)(?:^|\s)(mind\s+mig(?:\s+om\s+det)?|påmind(?:\s+mig)?|paamind(?:\s+mig)?` +
`|remind\s+me(?:\s+about\s+it)?|med\s+alarm)\b`)
// reReminderOffset reads the tail as "this long before": "en time før", "10 min
// før", "et kvarter før", "dagen før", "two days before".
var reReminderOffset = regexp.MustCompile(
`(?i)^[\s,]*(?:om\s+)?(\d+|en|et|a|an|to|two|tre|three)?\s*` +
`(halv\s+time|halvtime|kvarter|minutter|minut|minutes|minute|min\.?` +
`|timer|time|hours|hour|dage|dagen|dag|days|day)\s*` +
`(?:før|foer|inden|before)`)
// reReminderClock reads the tail as a clock of its own: "kl 8", "kl. 7.30", "at 7:30".
var reReminderClock = regexp.MustCompile(`(?i)^[\s,]*(?:kl\.?|klokken|at)\s*(\d{1,2})(?:[.:](\d{2}))?`)
// reminder is what the phrase said. OffsetMin is minutes relative to the due
// moment and negative for "before"; zero means at the due moment, which is what a
// bare "mind mig" asks for.
type reminder struct {
found bool
offsetMin int
hour, min int
hasClock bool
}
// extractReminder reads the alarm out of the line.
//
// The lead-in is matched first and the tail interpreted afterwards, rather than in
// one pattern, for the same reason the recurrence stage does it: a single
// expression able to express all three endings — a duration, a clock, or nothing
// at all — matches far more than the three things it is for.
func extractReminder(input string, spans []Span) (reminder, []Span) {
m := reReminderLead.FindStringSubmatchIndex(input)
if m == nil {
return reminder{}, spans
}
rem := reminder{found: true}
start, end := m[2], m[3]
tail := input[end:]
if t := reReminderOffset.FindStringSubmatchIndex(tail); t != nil {
n := 1
if t[2] >= 0 {
n = spokenNumber(tail[t[2]:t[3]])
}
rem.offsetMin = -n * unitMinutes(tail[t[4]:t[5]])
end += t[1]
} else if t := reReminderClock.FindStringSubmatchIndex(tail); t != nil {
hour := atoi(tail, t[2], t[3])
minute := 0
if t[4] >= 0 {
minute = atoi(tail, t[4], t[5])
}
// An impossible clock is not a reminder. Saying nothing is better than
// setting one for 25:00, which the store would happily keep and never fire.
if !valid(hour, minute) {
return reminder{}, spans
}
rem.hour, rem.min, rem.hasClock = hour, minute, true
end += t[1]
}
return rem, append(spans, Span{Start: start, End: end, Kind: KindReminder})
}
// spokenNumber reads the count in front of a unit. People dictating say the word
// rather than the digit, and "mind mig en time før" is the commonest form there is.
func spokenNumber(w string) int {
switch strings.ToLower(w) {
case "en", "et", "a", "an":
return 1
case "to", "two":
return 2
case "tre", "three":
return 3
}
if n, err := strconv.Atoi(w); err == nil {
return n
}
return 1
}
// unitMinutes turns the unit into minutes. "halv time" and "kvarter" carry their
// own count, so they come first: with a leading "en" they multiply by one and come
// out as themselves.
func unitMinutes(unit string) int {
u := strings.ToLower(strings.Join(strings.Fields(unit), " "))
switch {
case strings.HasPrefix(u, "halv"):
return 30
case strings.HasPrefix(u, "kvarter"):
return 15
case strings.HasPrefix(u, "min"):
return 1
case strings.HasPrefix(u, "time"), strings.HasPrefix(u, "hour"):
return 60
case strings.HasPrefix(u, "dag"), strings.HasPrefix(u, "day"):
return 1440
}
return 0
}
// reNoReminder finds the line asking not to be reminded. The account-wide default
// gives every task with a clock an alarm, and this is how one task opts out while
// it is being written rather than afterwards.
var reNoReminder = regexp.MustCompile(
`(?i)(?:^|\s)(ingen\s+(?:alarm|påmindelse|paamindelse)|uden\s+alarm` +
`|no\s+alarm|no\s+reminder|without\s+an?\s+alarm)\b`)
func extractNoReminder(input string, spans []Span) (bool, []Span) {
m := reNoReminder.FindStringSubmatchIndex(input)
if m == nil {
return false, spans
}
return true, append(spans, Span{Start: m[2], End: m[3], Kind: KindReminder})
}