package router import ( "fmt" "strings" "github.com/kami/maven/internal/lexicon" ) // Half-past and quarter-to, rewritten to digits before either date parser sees // the sentence. // // Russian says a half hour as the ordinal of the hour being ENTERED, in the // genitive: "половина восьмого" is 07:30, not 08:30. Neither python dateparser // nor the stub reads that shape, so "напомни в половине восьмого" parsed to // nothing (V-538). Off by one in the wrong direction is a missed dose, so the // rewrite is the only place the arithmetic lives, and it is minus one every // time. // // "без четверти восемь" counts the other way — it is 07:45, the hour NOT yet // reached, spoken as a cardinal. Same minus one, different minutes. // // Both shapes come out as "в H:MM", with the preposition kept when the sentence // had one and added when it did not, because the stub scans for "в" before a // clock and the contracted "полвосьмого" carries no preposition at all. // The forms of "половина" a spoken time uses are a closed class and live in the // lexicon as half_hour, because the ordinal-selection turn has to decline the // same ordinal this file reads (V-522). Ask lexicon.IsHalfHour. // minutesTo — the words that name the minutes in a "без X" hour. "четверти" is // the only one that is not a number; the rest are cardinals and are read as // cardinals. var minutesTo = map[string]int{"четверти": 15, "quarter": 15} // rewriteHalfPast turns a spoken half hour or quarter-to hour into digits. // Returns text unchanged when it holds neither shape. func rewriteHalfPast(text string) string { toks := strings.Fields(text) if len(toks) < 2 { return text } out := make([]string, 0, len(toks)) for i := 0; i < len(toks); i++ { if n, width, ok := halfPastAt(toks, i); ok { out = appendClock(out, n, 30) i += width - 1 continue } if h, m, width, ok := quarterToAt(toks, i); ok { out = appendClock(out, h, m) i += width - 1 continue } out = append(out, toks[i]) } return strings.Join(out, " ") } // halfPastAt reads a half hour starting at i, returning the hour it names and // how many tokens it spans. func halfPastAt(toks []string, i int) (hour, width int, ok bool) { head := cleanWord(toks[i]) // The contracted one word: "полвосьмого", "пол-восьмого". for _, prefix := range []string{"пол-", "пол"} { if !strings.HasPrefix(head, prefix) { continue } if h, ok := enteredHour(strings.TrimPrefix(head, prefix)); ok { return h, 1, true } } if !lexicon.IsHalfHour(head) || i+1 >= len(toks) { return 0, 0, false } h, ok := enteredHour(cleanWord(toks[i+1])) if !ok { return 0, 0, false } return h, 2, true } // quarterToAt reads a "без четверти восемь" / "без двадцати восемь" hour // starting at i. func quarterToAt(toks []string, i int) (hour, minute, width int, ok bool) { if cleanWord(toks[i]) != "без" || i+2 >= len(toks) { return 0, 0, 0, false } to, ok := minutesBefore(cleanWord(toks[i+1])) if !ok { return 0, 0, 0, false } h, ok := comingHour(cleanWord(toks[i+2])) if !ok { return 0, 0, 0, false } return h, 60 - to, 3, true } // minutesBefore reads the minutes still to run in a "без X" hour. A cardinal is // only minutes when it divides the hour the way a spoken clock does, so "без // семи восемь" is not a time anyone says and is left alone. func minutesBefore(word string) (int, bool) { if m, ok := minutesTo[word]; ok { return m, true } n, ok := lexicon.Cardinal(word) if !ok { return 0, false } switch n { case 5, 10, 15, 20, 25: return n, true } return 0, false } // enteredHour maps the ordinal of the hour being entered to the hour on the // clock: "восьмого" is the eighth hour, which is 07:xx. "первого" is 12:xx, // because the hour before one is twelve and not zero. func enteredHour(word string) (int, bool) { n, ok := lexicon.Ordinal(word) if !ok || n < 1 || n > 12 { return 0, false } return clockHourBefore(n), true } // comingHour maps the cardinal of the hour not yet reached, which counts the // same way: "без четверти восемь" is 07:45. func comingHour(word string) (int, bool) { d, ok := numeralDigit(word) if !ok { return 0, false } var n int if _, err := fmt.Sscanf(d, "%d", &n); err != nil || n < 1 || n > 12 { return 0, false } return clockHourBefore(n), true } // clockHourBefore is the whole of the arithmetic: the hour before n, with // twelve rather than zero before one. func clockHourBefore(n int) int { if n == 1 { return 12 } return n - 1 } // appendClock writes the clock, keeping a preposition the sentence already had // and adding "в" when it had none. The stub parser scans for "в" before a // clock, and the contracted form carries no preposition. func appendClock(out []string, hour, minute int) []string { if n := len(out); n == 0 || !clockPrepositions[cleanWord(out[n-1])] { out = append(out, "в") } return append(out, fmt.Sprintf("%d:%02d", hour, minute)) } // clockPrepositions — the prepositions that already introduce a time, so the // rewrite does not put a second one in front of them. var clockPrepositions = map[string]bool{ "в": true, "во": true, "к": true, "около": true, "на": true, "at": true, "by": true, } // cleanWord — one token, lowercased and stripped of the punctuation a spoken // sentence carries, the same way SpellOutDigits reads its tokens. func cleanWord(tok string) string { return strings.ToLower(strings.Trim(tok, ".,!?;:«»\"'")) }