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>
This commit is contained in:
co-authored by
Claude Opus 5
parent
661e47ebdf
commit
956e57f234
@@ -37,6 +37,7 @@ const (
|
||||
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.
|
||||
@@ -70,7 +71,28 @@ type Result struct {
|
||||
Recurrence string `json:"recurrence_rule,omitempty"`
|
||||
// RecurrenceText is that rule written back out in Danish, for the preview.
|
||||
RecurrenceText string `json:"recurrence_text,omitempty"`
|
||||
Spans []Span `json:"spans,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
|
||||
@@ -96,6 +118,20 @@ func Parse(input string, now time.Time, locale string) Result {
|
||||
// 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.
|
||||
@@ -137,6 +173,21 @@ func Parse(input string, now time.Time, locale string) Result {
|
||||
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)
|
||||
@@ -687,3 +738,131 @@ func extractRecurrence(input string, spans []Span) (string, []Span) {
|
||||
}
|
||||
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})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user