Files
verdande/internal/quickadd/quickadd.go
T
kristianwindandClaude Opus 5 956e57f234 feat(opgaver): alarmen kan tales ind, og kan være standard
To halvdele af det samme: at en opgave med et klokkeslæt også kan få en
alarm, uden at man går en omvej hver gang.

Påmindelser har virket længe og skulle sættes én ad gangen. En opgave
dikteret som "i morgen kl 9" fik en tid og INGEN alarm — `CreateReminder`
blev kun kaldt fra sit eget endepunkt, så intet skabte en påmindelse af sig
selv. Derefter skulle man åbne opgaven, finde Påmindelser og vælge et
klokkeslæt, og den omvej er længere end at skrive opgaven.

SÆTNINGEN

Hurtig tilføjelse forstår nu alarmen:

    ring til tandlægen i morgen kl 9 #hjem mind mig en time før

Formerne er `mind mig` / `påmind` / `remind me` / `med alarm`, efterfulgt af
en varighed (`en time før`, `10 min før`, `et kvarter før`, `en halv time
før`, `dagen før`, `to dage før`) eller et klokkeslæt (`mind mig kl 8`).
Bar "mind mig" betyder på selve tidspunktet. Dansk og engelsk i samme
linje, som resten af parseren.

Det gælder uanset hvor stemmen kommer ind: tastaturets diktering, en
genvej, eller noget tredje ender som tekst gennem den samme parser. Derfor
er syntaksen forudsætningen for dem alle frem for én af dem.

Trinnet ligger FØR klokke og dato, og den rækkefølge er hele tricket: "mind
mig en time før" har en varighed i sig og "mind mig kl 8" har en klokke, og
begge læst af tidsparseren først bliver opgavens egen forfaldstid i stedet
for dens alarm.

STANDARDEN

En indstilling under Notifikationer giver opgaver med både dato og
klokkeslæt en alarm uden at blive bedt om det, med et valgfrit varsel.

Den rammer alle tre veje, en opgave kan få et klokkeslæt — oprettet,
redigeret og hurtig-tilføjet. Redigeringsvejen er den halvdel, løftet om
"alle opgaver med en dato og tid" ellers taber: en opgave skrevet nu og
givet en tid i eftermiddag er den samme opgave. CalDAV er bevidst udenfor:
en opgave fra Apple Påmindelser bærer sin egen alarm, og to alarmer for én
opgave er værre end ingen.

Slået FRA for alle, der findes i dag. At tænde den i en migrering ville
give hver eksisterende bruger alarmer, de aldrig har bedt om, på opgaver de
har liggende — og det her er software, andre selv driver.

Og standarden skabte et behov for en udvej, så den fik en: "ingen alarm" i
linjen springer en enkelt opgave over, mens man skriver den, frem for at
oprette den, åbne den og slette alarmen der lige blev lavet. En udtrykkelig
alarm slår også standarden — den lægger sig ikke oven på.

TRE TING LÆSNINGEN FANGEDE

* Fortegnet. Lageret LÆGGER forskydningen til forfaldstiden
  (`due_datetime + offset_min * 60`), så "en time før" er -60. Gættet
  havde givet en alarm en time for sent, og det er den ene retning en
  påmindelse ikke må tage fejl i. Samme konvention hele vejen —
  indstillingen, parseren og kolonnen — for to fortegnsregler for det
  samme begreb viser sig kun som en alarm på et forkert tidspunkt.

* Den stille døde alarm. `DueReminders` vælger forskydningsformen kun
  `AND t.due_datetime IS NOT NULL`. Så "mind mig en time før" på en opgave
  med en dag men ingen klokketid ville have lagt en række, der aldrig
  fyrer. Nu bliver opgaven oprettet, alarmen ikke, og det bliver sagt.
  Det er den ene slags fejl en påmindelse ikke må have, fordi intet
  bagefter afslører den: der står ingen række at savne.

* "-60 min." på skærmen. TaskDetail viste forskydninger råt. Før nu kunne
  de kun laves gennem API'et, så grenen stod reelt urørt — dette er det
  første, der bruger den i praksis. Den skriver nu "1 time før".

PRØVET

Prøverne spørger `DueReminders` frem for om rækken findes: en række med
forkert fortegn findes lige så overbevisende og ringer en time for sent. Et
minut før den er forfalden må alarmen ikke være i listen, et minut efter
skal den.

Hver plantet fejl er kontrolleret til at falde i præcis den prøve, der
findes for den — fortegnet vendt, ordgrænsen fjernet, trinnet flyttet efter
dato, umulig klokke accepteret, dobbelt-vagten slået fra, "ingen alarm"
ignoreret, positivt tal accepteret, råt fortegn vist.

Og prøvens eget navn er en rettelse: de første fejlede i hele suiten men
bestod isoleret. Suiten kører med én arbejder mod ÉN database, og "ring til
tandlægen" findes fire steder i filen, så `.first()` ramte en anden prøves
opgave uden alarm.

Go-suiten grøn. E2E: 131 bestod, 0 netafbrydelser.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-08 19:16:14 +02:00

869 lines
32 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".
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)
}
// --- 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})
}