2026-08-16 22:55:57 +02:00
|
|
|
|
// 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"
|
2026-08-17 06:53:00 +02:00
|
|
|
|
|
|
|
|
|
|
"github.com/kristianwind/verdande/internal/recurrence"
|
2026-08-16 22:55:57 +02:00
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
// Kind names what a Span was recognised as.
|
|
|
|
|
|
type Kind string
|
|
|
|
|
|
|
|
|
|
|
|
const (
|
|
|
|
|
|
KindDate Kind = "date"
|
|
|
|
|
|
KindTime Kind = "time"
|
|
|
|
|
|
KindPriority Kind = "priority"
|
|
|
|
|
|
KindProject Kind = "project"
|
2026-08-18 11:13:10 +02:00
|
|
|
|
KindSection Kind = "section"
|
2026-08-16 22:55:57 +02:00
|
|
|
|
KindLabel Kind = "label"
|
2026-08-17 06:53:00 +02:00
|
|
|
|
KindRepeat Kind = "repeat"
|
2026-10-08 19:16:14 +02:00
|
|
|
|
KindReminder Kind = "reminder"
|
2026-08-16 22:55:57 +02:00
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
// 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.
|
2026-08-18 11:13:10 +02:00
|
|
|
|
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"`
|
2026-08-16 22:55:57 +02:00
|
|
|
|
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"`
|
2026-08-17 06:53:00 +02:00
|
|
|
|
// 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"`
|
2026-10-08 19:16:14 +02:00
|
|
|
|
// 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"`
|
2026-08-16 22:55:57 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 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)
|
2026-08-18 11:13:10 +02:00
|
|
|
|
res.Section, spans = extractSection(input, spans)
|
2026-08-16 22:55:57 +02:00
|
|
|
|
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)
|
|
|
|
|
|
|
2026-10-08 19:16:14 +02:00
|
|
|
|
// 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)
|
|
|
|
|
|
|
2026-08-17 06:53:00 +02:00
|
|
|
|
// 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)
|
|
|
|
|
|
|
2026-08-16 22:55:57 +02:00
|
|
|
|
// 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")
|
2026-08-17 06:53:00 +02:00
|
|
|
|
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")
|
|
|
|
|
|
}
|
2026-08-16 22:55:57 +02:00
|
|
|
|
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)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-08 19:16:14 +02:00
|
|
|
|
// 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
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-16 22:55:57 +02:00
|
|
|
|
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}_/-]+))`)
|
2026-08-18 11:13:10 +02:00
|
|
|
|
// 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}_/-]+))`)
|
2026-08-16 22:55:57 +02:00
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-18 11:13:10 +02:00
|
|
|
|
// 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
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-16 22:55:57 +02:00
|
|
|
|
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)
|
|
|
|
|
|
}
|
2026-08-17 06:53:00 +02:00
|
|
|
|
|
|
|
|
|
|
// --- 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.
|
2026-08-20 22:11:01 +02:00
|
|
|
|
//
|
|
|
|
|
|
// 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.
|
2026-08-17 06:53:00 +02:00
|
|
|
|
var reRepeat = regexp.MustCompile(
|
2026-08-20 22:11:01 +02:00
|
|
|
|
`(?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}]+)*` +
|
2026-08-17 06:53:00 +02:00
|
|
|
|
`|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
|
|
|
|
|
|
}
|
2026-10-08 19:16:14 +02:00
|
|
|
|
|
|
|
|
|
|
// --- 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})
|
|
|
|
|
|
}
|