mirror of
https://github.com/warmbly/warmbly.git
synced 2026-10-03 08:02:04 +00:00
fix: read German return dates after a weekday with a comma ("ab Montag, 12.10.", "ab Montag, den 12.10.") for every workday instead of only "ab Montag den", read calendar weeks ("ab KW 42" as that week's Monday, "bis KW 41" and "bis KW 40/41" as the Monday after the last week, a week under way as back now), and take the end of an "ab ... bis ..." range instead of its first day unless a sentence or a return word comes between, so these out-of-office replies hold a lead until the day they name instead of the flat fallback or the start of the absence
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
b18e11b35b
commit
618099d689
@@ -155,7 +155,7 @@ A lead is **Processing** only while steps remain, so a finished campaign reads a
|
||||
|
||||
When a recipient's mailbox answers a step with an away message, that is not a reply: Warmbly never counts an auto-reply as a human one, so stop on reply does not fire and, left alone, the next step would go out on schedule to an empty desk. Warmbly holds the lead instead, in every campaign that contact is still a lead of rather than only the one that was answered. An empty desk is an empty desk, so a contact you are running two sequences to is held in both. Completed campaigns are left alone, since nothing in them will send again.
|
||||
|
||||
The hold reads the return date out of the away message when it can. English, German, French, Spanish, Portuguese, Italian and Dutch phrasings are recognised (`back on 8 September`, `zurück am 09.09.2026`, `ab dem 10.9.`, `jusqu'au 12 septembre`), in ISO and day-dot-month form as well as written months. Two kinds of date are deliberately not read, because a wrong one parks a live lead for weeks: slash dates, since `9/8/2026` means two different days on two sides of the Atlantic, and a month with no day (`until October`), since there is no way to tell which day in it was meant. The lead resumes one working day after the date it found, so the follow-up does not land in the backlog of their first morning back. When no date can be read, the hold is the workspace's fallback, `7` days by default, set under **Settings > Sending > Out of office**. The same section turns the whole behaviour off.
|
||||
The hold reads the return date out of the away message when it can. English, German, French, Spanish, Portuguese, Italian and Dutch phrasings are recognised (`back on 8 September`, `zurück am 09.09.2026`, `ab dem 10.9.`, `jusqu'au 12 septembre`), in ISO and day-dot-month form as well as written months. German calendar weeks count too: `ab KW 42` resumes from that week's Monday, `bis KW 41` from the Monday after it. Two kinds of date are deliberately not read, because a wrong one parks a live lead for weeks: slash dates, since `9/8/2026` means two different days on two sides of the Atlantic, and a month with no day (`until October`), since there is no way to tell which day in it was meant. The lead resumes one working day after the date it found, so the follow-up does not land in the backlog of their first morning back. When no date can be read, the hold is the workspace's fallback, `7` days by default, set under **Settings > Sending > Out of office**. The same section turns the whole behaviour off.
|
||||
|
||||
A hold does not spend the step's wait. A follow-up three days behind its step, held for a week, goes out three days after the contact is back rather than the moment they return, so the sequence keeps its pacing instead of arriving all at once. Only the part of the hold that overlapped that wait counts, so a hold the lead has already served is never charged to a later step. A campaign whose remaining leads are all paused does not finish: it stays active and waits for them, and its activity log says so once rather than every pass. Resuming any of them puts the campaign straight back to work.
|
||||
|
||||
|
||||
@@ -70,9 +70,14 @@ var baseRules = langRules{
|
||||
`returns on`, `available again on`, `reachable again on`, `back from`,
|
||||
`until`, `till`, `through`,
|
||||
// German
|
||||
`zurueck am`, `zurueck ab`, `wieder am`, `wieder ab`, `ab dem`, `ab montag den`,
|
||||
`zurueck am`, `zurueck ab`, `wieder am`, `wieder ab`, `ab dem`,
|
||||
// "ab Montag den 12.10.", "ab Montag, den 12.10." and "ab Montag,
|
||||
// 12.10.": the weekday needs "den" or a comma before the date.
|
||||
`ab (?:montag|dienstag|mittwoch|donnerstag|freitag)(?:,? de[nm]|, ?)`,
|
||||
`wieder erreichbar am`, `wieder erreichbar ab`, `wieder im buero am`,
|
||||
`bis einschliesslich`, `bis zum`, `bis`,
|
||||
// A calendar week ("ab KW 42"); "Kalenderwoche" reads as "kw".
|
||||
`ab kw`, `ab der kw`,
|
||||
// French
|
||||
`de retour le`, `jusqu'au`, `jusqu au`, `a partir du`,
|
||||
// Spanish / Portuguese
|
||||
|
||||
@@ -29,18 +29,54 @@ func ParseReturnDate(subject, body string, now time.Time) (time.Time, bool) {
|
||||
return time.Time{}, false
|
||||
}
|
||||
rs := rulesFor(nil)
|
||||
for _, m := range rs.cue.FindAllStringIndex(text, -1) {
|
||||
at := func(m []int) (time.Time, bool) {
|
||||
// Only the span right after the cue is considered: an auto-reply is
|
||||
// mostly prose, and the first date anywhere in it is usually not the
|
||||
// one that matters.
|
||||
tail := text[m[1]:min(m[1]+returnCueWindow, len(text))]
|
||||
d, ok := firstDate(tail, now, rs.months)
|
||||
cue := text[m[0]:m[1]]
|
||||
start := m[1]
|
||||
if strings.HasSuffix(cue, "kw") {
|
||||
// Leave the week marker in the span for the week pattern.
|
||||
start -= len("kw")
|
||||
}
|
||||
tail := text[start:min(m[1]+returnCueWindow, len(text))]
|
||||
d, kind, ok := firstDate(tail, now, rs.months, endCues[cue])
|
||||
if !ok {
|
||||
return time.Time{}, false
|
||||
}
|
||||
switch {
|
||||
case kind == weekDate && endCues[cue]:
|
||||
// "bis KW 41" and "bis KW 40/41" are away through the last week:
|
||||
// back the Monday after it.
|
||||
d = d.AddDate(0, 0, 7)
|
||||
case kind == weekDate:
|
||||
// A week already under way means back now, not on its Monday.
|
||||
if today := now.UTC().Truncate(24 * time.Hour); d.Before(today) {
|
||||
d = today
|
||||
}
|
||||
case rs.inclusive[cue]:
|
||||
// The cue named the last day AWAY, not the day back.
|
||||
d = d.AddDate(0, 0, 1)
|
||||
}
|
||||
return d, true
|
||||
}
|
||||
cues := rs.cue.FindAllStringIndex(text, -1)
|
||||
for i, m := range cues {
|
||||
d, ok := at(m)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if rs.inclusive[text[m[0]:m[1]]] {
|
||||
// The cue named the last day AWAY, not the day back.
|
||||
d = d.AddDate(0, 0, 1)
|
||||
// "ab Freitag, 4.9., bis 18.9." names the first day away, and the
|
||||
// "bis" right behind it the end of the absence. Not when a sentence
|
||||
// or a return word comes between: "ab dem 14.9. wieder erreichbar,
|
||||
// bis zum 30.9. nur eingeschränkt" is back on the 14th.
|
||||
if strings.HasPrefix(text[m[0]:m[1]], "ab ") && i+1 < len(cues) {
|
||||
if next := cues[i+1]; next[0] < m[1]+returnCueWindow && endCues[text[next[0]:next[1]]] &&
|
||||
!rangeBreak.MatchString(rangeNoise.ReplaceAllString(text[m[1]:next[0]], "")) {
|
||||
if end, ok := at(next); ok && end.After(d) {
|
||||
return end, true
|
||||
}
|
||||
}
|
||||
}
|
||||
return d, true
|
||||
}
|
||||
@@ -67,6 +103,28 @@ func NextBusinessDay(d time.Time) time.Time {
|
||||
// returnCueWindow is how much text after a cue phrase may hold the date.
|
||||
const returnCueWindow = 48
|
||||
|
||||
// endCues name the end of an absence: the last week away when a calendar
|
||||
// week follows them, and the end of an "ab ... bis ..." range.
|
||||
var endCues = map[string]bool{"bis": true, "bis zum": true, "bis einschliesslich": true}
|
||||
|
||||
// rangeBreak is a sentence end or a return word between an "ab" date and a
|
||||
// "bis" date, which makes them two statements instead of one range.
|
||||
var rangeBreak = regexp.MustCompile(`[a-z][.!?;:](\s|$)|[!?;]|\b(wieder|zurueck|erreichbar|buero|da)\b`)
|
||||
|
||||
// rangeNoise is taken out before rangeBreak reads the text: an absence said
|
||||
// as a negated return word ("ab dem 7.9. nicht erreichbar, bis 18.9.") and a
|
||||
// month abbreviation's dot ("ab dem 4. Sept. bis zum 18. Sept.") are not
|
||||
// breaks.
|
||||
var rangeNoise = regexp.MustCompile(`\bnicht (mehr )?(im )?(buero|erreichbar|da)\b|\b(jan|feb|mrz|apr|jun|jul|aug|sep|sept|okt|nov|dez)\.`)
|
||||
|
||||
// dateKind tells a day from a calendar week, returned as its Monday.
|
||||
type dateKind int
|
||||
|
||||
const (
|
||||
dayDate dateKind = iota
|
||||
weekDate
|
||||
)
|
||||
|
||||
// dateFormats are the unambiguous written forms, tried in order against the
|
||||
// text right after a cue.
|
||||
var (
|
||||
@@ -76,17 +134,21 @@ var (
|
||||
// The day group ends on a word boundary, or "October 2026" would read the
|
||||
// "20" of the year as a day of the month and invent a return date.
|
||||
nameThenDay = regexp.MustCompile(`\b([a-z]{3,12})\.?\s+(\d{1,2})\b(?:st|nd|rd|th|\.)?(?:,?\s+(\d{4}))?`)
|
||||
// A German calendar week, "KW 42" or "KW42", or a range, "KW 41/42".
|
||||
calendarWeek = regexp.MustCompile(`\bkw\s?(\d{1,2})(?:\s?[-/–]\s?(\d{1,2}))?\b`)
|
||||
)
|
||||
|
||||
// firstDate returns the EARLIEST date the span yields, in text order rather
|
||||
// than in the order the formats happen to be tried. A cue window holds prose as
|
||||
// well as the date ("until 10 September; ref 2026-10-01"), and scanning ISO
|
||||
// first would answer with the reference number's date and park the lead three
|
||||
// weeks too long.
|
||||
func firstDate(span string, now time.Time, monthByName map[string]int) (time.Time, bool) {
|
||||
// weeks too long. kind tells a day from a calendar week; lastWeek reads a
|
||||
// range of weeks by its last week rather than its first.
|
||||
func firstDate(span string, now time.Time, monthByName map[string]int, lastWeek bool) (time.Time, dateKind, bool) {
|
||||
type hit struct {
|
||||
at int
|
||||
d time.Time
|
||||
at int
|
||||
d time.Time
|
||||
kind dateKind
|
||||
}
|
||||
var hits []hit
|
||||
add := func(re *regexp.Regexp, parse func(m []string) (time.Time, bool)) {
|
||||
@@ -95,7 +157,11 @@ func firstDate(span string, now time.Time, monthByName map[string]int) (time.Tim
|
||||
at := re.FindAllStringIndex(span, -1)
|
||||
for i, m := range re.FindAllStringSubmatch(span, -1) {
|
||||
if d, ok := parse(m); ok {
|
||||
hits = append(hits, hit{at[i][0], d})
|
||||
kind := dayDate
|
||||
if re == calendarWeek {
|
||||
kind = weekDate
|
||||
}
|
||||
hits = append(hits, hit{at[i][0], d, kind})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -120,6 +186,12 @@ func firstDate(span string, now time.Time, monthByName map[string]int) (time.Tim
|
||||
}
|
||||
return resolve(atoi(m[2]), mon, yearOf(m[3]), now, m[3] != "")
|
||||
})
|
||||
add(calendarWeek, func(m []string) (time.Time, bool) {
|
||||
if lastWeek && m[2] != "" {
|
||||
return resolveWeek(atoi(m[2]), now)
|
||||
}
|
||||
return resolveWeek(atoi(m[1]), now)
|
||||
})
|
||||
|
||||
best := -1
|
||||
for i := range hits {
|
||||
@@ -128,9 +200,34 @@ func firstDate(span string, now time.Time, monthByName map[string]int) (time.Tim
|
||||
}
|
||||
}
|
||||
if best < 0 {
|
||||
return time.Time{}, dayDate, false
|
||||
}
|
||||
return hits[best].d, hits[best].kind, true
|
||||
}
|
||||
|
||||
// resolveWeek is the Monday of ISO week w: this year's while that week is not
|
||||
// over, else next year's. The Monday has to land inside the sanity window,
|
||||
// unless the week is the one under way.
|
||||
func resolveWeek(w int, now time.Time) (time.Time, bool) {
|
||||
if w < 1 || w > 53 {
|
||||
return time.Time{}, false
|
||||
}
|
||||
return hits[best].d, true
|
||||
today := now.UTC().Truncate(24 * time.Hour)
|
||||
year, _ := today.ISOWeek()
|
||||
for _, y := range []int{year, year + 1} {
|
||||
// 4 January always falls in week 1.
|
||||
jan4 := time.Date(y, time.January, 4, 0, 0, 0, 0, time.UTC)
|
||||
monday := jan4.AddDate(0, 0, 7*(w-1)-(int(jan4.Weekday())+6)%7)
|
||||
// Week 53 of a year that has 52 is the next year's week 1.
|
||||
if _, got := monday.ISOWeek(); got != w {
|
||||
continue
|
||||
}
|
||||
if monday.AddDate(0, 0, 6).Before(today) || monday.After(today.AddDate(0, 0, ReturnWindowDays)) {
|
||||
continue
|
||||
}
|
||||
return monday, true
|
||||
}
|
||||
return time.Time{}, false
|
||||
}
|
||||
|
||||
// resolve builds the date and applies the sanity window. When the reply wrote
|
||||
@@ -218,5 +315,11 @@ func foldAccents(s string) string {
|
||||
return strings.Join(strings.Fields(s), " ")
|
||||
}
|
||||
|
||||
// normalizeForDates is foldAccents under the name the date scanner reads it by.
|
||||
func normalizeForDates(s string) string { return foldAccents(s) }
|
||||
// normalizeForDates is foldAccents with "Kalenderwoche" shortened to the
|
||||
// "KW" the week cues and pattern read, and "KW42" spaced so a cue ending in
|
||||
// "kw" still ends on a word boundary.
|
||||
func normalizeForDates(s string) string {
|
||||
return weekDigits.ReplaceAllString(strings.ReplaceAll(foldAccents(s), "kalenderwoche", "kw"), "kw $1")
|
||||
}
|
||||
|
||||
var weekDigits = regexp.MustCompile(`\bkw(\d)`)
|
||||
|
||||
@@ -52,6 +52,39 @@ func TestParseReturnDate(t *testing.T) {
|
||||
// lead on it.
|
||||
{name: "a bare month and year is not a date", subject: "Out of office", body: "I'm out of the office until October 2026."},
|
||||
{name: "a bare month and year, back on", subject: "Out of office", body: "I'll be back on November 2026."},
|
||||
// A weekday before the date takes "den" or a comma, the way German
|
||||
// writes it.
|
||||
{name: "german ab montag den", subject: "Abwesenheitsnotiz", body: "Ab Montag den 14.09. bin ich wieder im Büro.", want: "2026-09-14"},
|
||||
{name: "german ab montag comma den", subject: "Abwesenheitsnotiz", body: "Ab Montag, den 14.09., bin ich wieder im Büro.", want: "2026-09-14"},
|
||||
{name: "german ab montag comma", subject: "Abwesenheitsnotiz", body: "Ab Montag, 14.9., bin ich wieder im Büro.", want: "2026-09-14"},
|
||||
{name: "german ab dienstag comma", subject: "Abwesenheitsnotiz", body: "Ab Dienstag, 15. September, bin ich wieder erreichbar.", want: "2026-09-15"},
|
||||
// A calendar week reads as its Monday; after "bis" it is the last
|
||||
// week away, so the return is the Monday after it.
|
||||
{name: "german ab kw", subject: "Abwesenheitsnotiz", body: "Ich bin ab KW 38 wieder erreichbar.", want: "2026-09-14"},
|
||||
{name: "german ab kw without a space", subject: "Abwesenheitsnotiz", body: "Ab KW38 wieder im Büro.", want: "2026-09-14"},
|
||||
{name: "german ab der kalenderwoche", subject: "Abwesenheitsnotiz", body: "Ab der Kalenderwoche 38 bin ich wieder im Büro.", want: "2026-09-14"},
|
||||
{name: "german bis kw is the last week away", subject: "Abwesenheitsnotiz", body: "Ich bin bis KW37 im Urlaub.", want: "2026-09-14"},
|
||||
{name: "german bis einschliesslich kw", subject: "Abwesenheitsnotiz", body: "Ich bin bis einschließlich KW 37 nicht im Büro.", want: "2026-09-14"},
|
||||
{name: "a week under way means back now", subject: "Abwesenheitsnotiz", body: "Ab KW 36 bin ich wieder da.", want: "2026-09-04"},
|
||||
{name: "german bis kw range is away through the last week", subject: "Abwesenheitsnotiz", body: "Ich bin bis KW 37/38 im Urlaub.", want: "2026-09-21"},
|
||||
{name: "german kw range with an en dash", subject: "Abwesenheitsnotiz", body: "Ich bin bis KW 37\u201338 im Urlaub.", want: "2026-09-21"},
|
||||
{name: "german ab kw range starts with its first week", subject: "Abwesenheitsnotiz", body: "Ab KW 38/39 bin ich wieder erreichbar.", want: "2026-09-14"},
|
||||
// "ab ... bis ..." names the first day away and then the end.
|
||||
{name: "german ab weekday bis is a range", subject: "Abwesenheitsnotiz", body: "Ich bin ab Freitag, 4.9., bis 18.9. im Urlaub.", want: "2026-09-18"},
|
||||
{name: "german ab dem bis zum is a range", subject: "Abwesenheitsnotiz", body: "Ich bin ab dem 4.9. bis zum 18.9. nicht im Büro.", want: "2026-09-18"},
|
||||
{name: "a negated return word keeps the range", subject: "Abwesenheitsnotiz", body: "Ab dem 7.9. bin ich nicht erreichbar, bis einschließlich 18.9. vertritt mich Frau Klein.", want: "2026-09-19"},
|
||||
{name: "a month abbreviation keeps the range", subject: "Abwesenheitsnotiz", body: "Ich bin ab dem 4. Sept. bis zum 18. Sept. im Urlaub.", want: "2026-09-18"},
|
||||
{name: "a return word ends the range", subject: "Abwesenheitsnotiz", body: "Ab dem 14.9. bin ich wieder erreichbar, bis zum 30.9. jedoch nur eingeschränkt.", want: "2026-09-14"},
|
||||
{name: "a sentence ends the range", subject: "Abwesenheitsnotiz", body: "Ab Montag, 14.9., bin ich im Haus. Bis 30.9. gilt unser Aktionspreis.", want: "2026-09-14"},
|
||||
{name: "a bis before the return date is not its end", subject: "Abwesenheitsnotiz", body: "Ab dem 14.9. wieder da. Bis zum 11.9. vertritt mich Frau Klein.", want: "2026-09-14"},
|
||||
{name: "a week already over is not a return date", subject: "Abwesenheitsnotiz", body: "Ich bin bis KW 30 im Urlaub."},
|
||||
{name: "a week that does not exist", subject: "Abwesenheitsnotiz", body: "Ich bin ab KW 60 wieder da."},
|
||||
{
|
||||
name: "week rolls into next year", subject: "Abwesenheitsnotiz",
|
||||
body: "Ab KW 2 bin ich wieder im Büro.",
|
||||
now: time.Date(2026, 12, 21, 9, 0, 0, 0, time.UTC),
|
||||
want: "2027-01-11",
|
||||
},
|
||||
// Mail clients autocorrect the apostrophe; the cue has to survive it.
|
||||
{name: "french typographic apostrophe", subject: "Réponse automatique", body: "Je suis absent jusqu\u2019au 12 septembre.", want: "2026-09-12"},
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user