Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0b994ff1c3 | |||
| 0b1efe4911 | |||
| 580959f856 |
@@ -65,7 +65,7 @@ func mustLoad() lexiconFile {
|
||||
"day_offsets", "weekdays", "weekdays_english", "months_genitive", "hours_spoken",
|
||||
"not_place_after_v", "parts_of_day", "reminder_verbs", "half_hour",
|
||||
"filler_particles", "task_done_words", "task_drop_words",
|
||||
"confirm_yes", "confirm_no",
|
||||
"confirm_yes", "confirm_no", "hour_units", "minute_units",
|
||||
} {
|
||||
s, ok := f.Sets[name]
|
||||
if !ok || (len(s.Words) == 0 && len(s.Values) == 0) {
|
||||
@@ -139,7 +139,40 @@ func TaskDropWords() []string { return words("task_drop_words") }
|
||||
// making the utterance a request of its own. A caller strips these (along with
|
||||
// the numbers and the other closed time sets) to see whether an utterance
|
||||
// carries any content beside the value it was asked for. See the set's note.
|
||||
func SlotValueFrame() []string { return words("slot_value_frame") }
|
||||
// The hour and the minute nouns are part of the frame and are kept in their own
|
||||
// sets, so there is one copy of each closed class rather than a copy per caller.
|
||||
func SlotValueFrame() []string {
|
||||
out := words("slot_value_frame")
|
||||
out = append(out, HourUnits()...)
|
||||
out = append(out, MinuteUnits()...)
|
||||
return out
|
||||
}
|
||||
|
||||
// HourUnits returns every form of the hour noun, and MinuteUnits every form of
|
||||
// the minute noun. One home for each, because four router sets used to list the
|
||||
// hour and all four stopped at "часу" (V-609). A caller folding time words into
|
||||
// one set reads these; a caller asking about a single word reads IsHourUnit or
|
||||
// IsMinuteUnit.
|
||||
func HourUnits() []string { return words("hour_units") }
|
||||
|
||||
// MinuteUnits — see HourUnits.
|
||||
func MinuteUnits() []string { return words("minute_units") }
|
||||
|
||||
// IsHourUnit reports whether a word is the hour noun in any form.
|
||||
func IsHourUnit(word string) bool { return inSet("hour_units", word) }
|
||||
|
||||
// IsMinuteUnit reports whether a word is the minute noun in any form.
|
||||
func IsMinuteUnit(word string) bool { return inSet("minute_units", word) }
|
||||
|
||||
func inSet(set, word string) bool {
|
||||
w := norm(word)
|
||||
for _, s := range ru.Sets[set].Words {
|
||||
if w == s {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// DialogueCancel returns the ways he calls off the request Maven is assembling.
|
||||
// Distinct from TaskDropWords, which abandons an item that already exists.
|
||||
|
||||
@@ -210,6 +210,20 @@
|
||||
"передумал", "передумала", "неактуально"
|
||||
]
|
||||
},
|
||||
"hour_units": {
|
||||
"note": "Every form of the hour noun, Russian and English (V-609). One home for a closed class that four router sets used to list separately, and all four stopped at \"часу\": \"напомни к двум часам\" lost its hour and the reminder was left asking \"Когда?\". Russian declines, so the dative plural is as ordinary a way to say an hour as the accusative singular. A caller that folds time words into one set reads HourUnits; a caller asking about one word reads IsHourUnit.",
|
||||
"words": [
|
||||
"час", "часа", "часов", "часу", "часам", "часами", "часах",
|
||||
"hour", "hours"
|
||||
]
|
||||
},
|
||||
"minute_units": {
|
||||
"note": "Every form of the minute noun, Russian and English (V-609). Same class as hour_units one noun over, and it had the same gap: the dative plural \"минутам\" was missing everywhere \"минут\" and \"минуты\" were present.",
|
||||
"words": [
|
||||
"минута", "минуты", "минуту", "минут", "минуте", "минутам", "минутами", "минутах",
|
||||
"minute", "minutes"
|
||||
]
|
||||
},
|
||||
"slot_value_frame": {
|
||||
"note": "The words that can stand around a bare slot value without making the utterance a request of its own (Vikunja #560). Prepositions, hedges and the nouns a spoken time is built from: strip these, the numbers, the interrogatives, the filler particles and the other time sets, and whatever is left is the utterance's OWN content. \"а что если в 11:00\" leaves nothing and is an answer; \"какая сейчас погода в Риме\" leaves \"погода\" and \"Риме\" and is not. Closed because each part of it is closed — Russian has a fixed list of prepositions, and a clock is built from a fixed list of nouns. It is not a stopword list: a word goes in only if it can never be the thing he is asking about.",
|
||||
"words": [
|
||||
@@ -217,10 +231,10 @@
|
||||
"at", "on", "in", "by", "to", "till", "until", "after", "before", "about", "for",
|
||||
"нет", "не", "да", "ага", "угу", "ой", "ох", "тогда", "лучше", "может", "можно", "наверное", "наверно", "пожалуй", "точнее", "скорее", "если", "пусть", "прости", "извини", "слушай", "значит", "как-то", "типа", "вообще-то",
|
||||
"no", "yes", "yeah", "ok", "okay", "sorry", "maybe", "actually", "rather", "then", "well",
|
||||
"час", "часа", "часов", "часу", "часам", "минут", "минута", "минуты", "минуту", "минутах", "полдень", "полночь", "полдня",
|
||||
"полдень", "полночь", "полдня",
|
||||
"утра", "утро", "утру", "дня", "день", "днями", "вечера", "вечер", "вечеру", "ночи", "ночь", "ночью",
|
||||
"сейчас", "теперь", "сегодняшний", "ближайший", "ближайшее",
|
||||
"hour", "hours", "minute", "minutes", "noon", "midnight", "am", "pm", "oclock", "now"
|
||||
"noon", "midnight", "am", "pm", "oclock", "now"
|
||||
]
|
||||
},
|
||||
"dialogue_cancel": {
|
||||
|
||||
+22
-4
@@ -265,6 +265,16 @@ func (s *Store) Put(kind Kind, mime, source string, data []byte) (Blob, error) {
|
||||
}
|
||||
}
|
||||
|
||||
// Every return past the reservation has to give it back, so the defer owns
|
||||
// that rather than each error path: a path that forgot over-counted the
|
||||
// store until the next Open re-walked the directory.
|
||||
stored := false
|
||||
defer func() {
|
||||
if fresh && !stored {
|
||||
s.release(b.Size)
|
||||
}
|
||||
}()
|
||||
|
||||
// The sidecar goes first. Written second, a full disk or a crash between
|
||||
// the two left the bytes on disk with no sidecar, and List only sees
|
||||
// sidecars, so Prune could never collect them: Put returned an error and an
|
||||
@@ -274,11 +284,9 @@ func (s *Store) Put(kind Kind, mime, source string, data []byte) (Blob, error) {
|
||||
}
|
||||
if err := writeFile(blobPath, data); err != nil {
|
||||
_ = os.Remove(metaPath)
|
||||
if fresh {
|
||||
s.release(b.Size)
|
||||
}
|
||||
return Blob{}, err
|
||||
}
|
||||
stored = true
|
||||
return b, nil
|
||||
}
|
||||
|
||||
@@ -331,12 +339,22 @@ func (s *Store) PutFile(kind Kind, mime, source, src string) (Blob, error) {
|
||||
return Blob{}, err
|
||||
}
|
||||
}
|
||||
// Same reasoning as Put: the reservation is released by one defer, not by
|
||||
// whichever error path remembered to.
|
||||
stored := false
|
||||
defer func() {
|
||||
if fresh && !stored {
|
||||
s.release(b.Size)
|
||||
}
|
||||
}()
|
||||
|
||||
if err := writeMeta(metaPath, b); err != nil {
|
||||
return Blob{}, err
|
||||
}
|
||||
if !fresh {
|
||||
// Same bytes already here. Drop the spool copy.
|
||||
_ = os.Remove(src)
|
||||
stored = true
|
||||
return b, nil
|
||||
}
|
||||
if err := os.Chmod(src, filePerm); err != nil {
|
||||
@@ -344,9 +362,9 @@ func (s *Store) PutFile(kind Kind, mime, source, src string) (Blob, error) {
|
||||
}
|
||||
if err := os.Rename(src, blobPath); err != nil {
|
||||
_ = os.Remove(metaPath)
|
||||
s.release(b.Size)
|
||||
return Blob{}, fmt.Errorf("media: move spool: %w", err)
|
||||
}
|
||||
stored = true
|
||||
return b, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -287,6 +287,73 @@ func TestPutLeavesNothingWhenTheBytesCannotBeWritten(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// An over-counted store answers ErrStoreFull while the disk has room, and only
|
||||
// the next Open corrects it. So every failed write has to give its reservation
|
||||
// back, not just the one that remembered to.
|
||||
func TestPutReleasesTheBudgetWhenTheSidecarCannotBeWritten(t *testing.T) {
|
||||
s := testStore(t)
|
||||
data := []byte("no sidecar for this")
|
||||
blockSidecar(t, s, KindImage, data)
|
||||
if _, err := s.Put(KindImage, "image/png", "web:upload", data); err == nil {
|
||||
t.Fatal("put must fail")
|
||||
}
|
||||
if s.Total() != 0 {
|
||||
t.Errorf("total = %d, want the failed put not counted", s.Total())
|
||||
}
|
||||
}
|
||||
|
||||
func TestPutFileReleasesTheBudgetWhenTheSidecarCannotBeWritten(t *testing.T) {
|
||||
s := testStore(t)
|
||||
data := []byte("no sidecar for this either")
|
||||
blockSidecar(t, s, KindAudio, data)
|
||||
src := filepath.Join(t.TempDir(), "capture.wav")
|
||||
if err := os.WriteFile(src, data, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := s.PutFile(KindAudio, "audio/wav", "meeting", src); err == nil {
|
||||
t.Fatal("put file must fail")
|
||||
}
|
||||
if s.Total() != 0 {
|
||||
t.Errorf("total = %d, want the failed put not counted", s.Total())
|
||||
}
|
||||
}
|
||||
|
||||
// The chmod arm is PutFile's alone: Put never touches a spool file.
|
||||
func TestPutFileReleasesTheBudgetWhenTheSpoolCannotBeChmodded(t *testing.T) {
|
||||
if os.Geteuid() == 0 {
|
||||
t.Skip("root can chmod a file it does not own")
|
||||
}
|
||||
// A symlink to a file owned by somebody else. Stat and the hash follow it
|
||||
// and succeed; chmod follows it too and is refused.
|
||||
src := filepath.Join(t.TempDir(), "capture.wav")
|
||||
if err := os.Symlink("/etc/hosts", src); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
info, err := os.Stat(src)
|
||||
if err != nil || info.Size() == 0 {
|
||||
t.Skip("no readable /etc/hosts to point at")
|
||||
}
|
||||
s := testStore(t)
|
||||
if _, err := s.PutFile(KindAudio, "audio/wav", "meeting", src); err == nil {
|
||||
t.Fatal("put file must fail")
|
||||
}
|
||||
if s.Total() != 0 {
|
||||
t.Errorf("total = %d, want the failed put not counted", s.Total())
|
||||
}
|
||||
}
|
||||
|
||||
// blockSidecar puts a directory where the sidecar for data has to go, so
|
||||
// writeMeta fails while the blob path is still free.
|
||||
func blockSidecar(t *testing.T, s *Store, kind Kind, data []byte) {
|
||||
t.Helper()
|
||||
sum := sha256.Sum256(data)
|
||||
id := hex.EncodeToString(sum[:])
|
||||
bucket := filepath.Join(s.dir, string(kind), id[:2])
|
||||
if err := os.MkdirAll(filepath.Join(bucket, id+".json"), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// The per-blob cap bounds one call and nothing bounded their sum. 64 MiB per
|
||||
// call times unlimited calls inside a seven-day window fills the disk mavend's
|
||||
// database lives on.
|
||||
|
||||
@@ -45,10 +45,10 @@ try:
|
||||
now = datetime.fromisoformat(sys.argv[2])
|
||||
# Pre-process: replace Russian time qualifiers with AM/PM.
|
||||
# Handles "9 утра", "10 часов утра", "3 часа дня" etc.
|
||||
text = re.sub(r'(\d+)\s+(?:час(?:а|ов)?\s+)?утра\b', r'\1 am', text, flags=re.IGNORECASE)
|
||||
text = re.sub(r'(\d+)\s+(?:час(?:а|ов)?\s+)?вечера\b', r'\1 pm', text, flags=re.IGNORECASE)
|
||||
text = re.sub(r'(\d+)\s+(?:час(?:а|ов)?\s+)?дня\b', r'\1 pm', text, flags=re.IGNORECASE)
|
||||
text = re.sub(r'(\d+)\s+(?:час(?:а|ов)?\s+)?ночи\b', r'\1 am', text, flags=re.IGNORECASE)
|
||||
text = re.sub(r'(\d+)\s+(?:час(?:а|ов|у|ам)?\s+)?утра\b', r'\1 am', text, flags=re.IGNORECASE)
|
||||
text = re.sub(r'(\d+)\s+(?:час(?:а|ов|у|ам)?\s+)?вечера\b', r'\1 pm', text, flags=re.IGNORECASE)
|
||||
text = re.sub(r'(\d+)\s+(?:час(?:а|ов|у|ам)?\s+)?дня\b', r'\1 pm', text, flags=re.IGNORECASE)
|
||||
text = re.sub(r'(\d+)\s+(?:час(?:а|ов|у|ам)?\s+)?ночи\b', r'\1 am', text, flags=re.IGNORECASE)
|
||||
# A bare hour after a preposition is dropped on the floor by dateparser:
|
||||
# "завтра в 7" resolves to tomorrow at the CURRENT clock, and "завтра в 7
|
||||
# часов" is read as seven hours from now. Only a qualifier (already an
|
||||
@@ -56,7 +56,9 @@ try:
|
||||
# English "at 7" fails identically, so both prepositions are rewritten.
|
||||
# "на 9" is the same hour said with the other preposition, and it was not
|
||||
# read at all until V-579: "в 9" set the reminder and "на 9" did not.
|
||||
text = re.sub(r'(?<![\w:])(в|во|на|at)\s+([01]?\d|2[0-3])(?:\s+час(?:а|ов)?)?(?![\d:.\w])',
|
||||
# "к двум часам" is a third preposition and the dative that goes with it,
|
||||
# and it was read as no time at all until V-609.
|
||||
text = re.sub(r'(?<![\w:])(в|во|на|к|ко|at|by)\s+([01]?\d|2[0-3])(?:\s+час(?:а|ов|у|ам)?)?(?![\d:.\w])',
|
||||
lambda m: '%s %02d:00' % (m.group(1), int(m.group(2))), text, flags=re.IGNORECASE)
|
||||
settings = {'PREFER_DATES_FROM': 'future', 'RELATIVE_BASE': now}
|
||||
# Two-step: search_dates finds the date substring in text,
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
package router
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TestDativePluralHourIsAnHour — "напомни к двум часам позвонить маме" reached
|
||||
// the daemon with no time at all and she asked the open "Когда?", while "к трём"
|
||||
// one word over read fine (V-609). The word that lost it was "часам", the dative
|
||||
// plural of "час", which four separate hour sets in this package left out.
|
||||
func TestDativePluralHourIsAnHour(t *testing.T) {
|
||||
const s = "напомни к двум часам позвонить маме"
|
||||
if !MentionsTime(s) {
|
||||
t.Errorf("MentionsTime(%q) = false; the sentence names two o'clock", s)
|
||||
}
|
||||
if !NamesAnHour(s) {
|
||||
t.Errorf("NamesAnHour(%q) = false; the sentence names two o'clock", s)
|
||||
}
|
||||
if got, want := SpellOutDigits(s), "напомни к 2 часам позвонить маме"; got != want {
|
||||
t.Errorf("SpellOutDigits(%q) = %q, want %q", s, got, want)
|
||||
}
|
||||
// The slot itself, which is what the daemon reads. It was empty, so
|
||||
// whenGapOf named the hour missing and she asked "Когда?".
|
||||
now := time.Date(2026, 8, 6, 3, 39, 0, 0, time.UTC)
|
||||
ex := Extractor{Time: StubDateTimeParser{}}
|
||||
got := ex.Extract(context.Background(), IntentReminder, s, now)
|
||||
if !got.HasTime {
|
||||
t.Fatalf("the hour was spoken, so the slot must be filled: %+v", got)
|
||||
}
|
||||
if h := got.Time.Hour(); h != 2 && h != 14 {
|
||||
t.Errorf("fire time = %s, want two o'clock in one half of the day or the other", got.Time.Format("15:04"))
|
||||
}
|
||||
}
|
||||
|
||||
// TestHourUnitReachesEverySite — the four sets that read the hour noun now read
|
||||
// one lexicon key, so a form added there is a form all four know. "часам" is the
|
||||
// form that was missing from every one of them.
|
||||
func TestHourUnitReachesEverySite(t *testing.T) {
|
||||
for _, w := range []string{"час", "часа", "часов", "часу", "часам"} {
|
||||
if !numeralContext[w] {
|
||||
t.Errorf("numeralContext is missing %q", w)
|
||||
}
|
||||
if !hourMarkers[w] {
|
||||
t.Errorf("hourMarkers is missing %q", w)
|
||||
}
|
||||
if !timeMarkers[w] {
|
||||
t.Errorf("timeMarkers is missing %q", w)
|
||||
}
|
||||
if _, ok := unitToDuration(2, w); !ok {
|
||||
t.Errorf("unitToDuration does not know %q", w)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestMinuteUnitHasTheSameForms — the same defect one noun over: "минутам" was
|
||||
// missing everywhere "минут" and "минуты" were present.
|
||||
func TestMinuteUnitHasTheSameForms(t *testing.T) {
|
||||
for _, w := range []string{"минут", "минуты", "минуту", "минутам"} {
|
||||
if !numeralContext[w] {
|
||||
t.Errorf("numeralContext is missing %q", w)
|
||||
}
|
||||
if !hourMarkers[w] {
|
||||
t.Errorf("hourMarkers is missing %q", w)
|
||||
}
|
||||
if !timeMarkers[w] {
|
||||
t.Errorf("timeMarkers is missing %q", w)
|
||||
}
|
||||
if _, ok := unitToDuration(20, w); !ok {
|
||||
t.Errorf("unitToDuration does not know %q", w)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -34,12 +34,21 @@ func numeralDigit(word string) (string, bool) {
|
||||
// numeralContext — the words that make a numeral a time. A numeral is only
|
||||
// rewritten when one of these sits next to it, so "три яблока" in a note is
|
||||
// left alone and "в три часа" is not.
|
||||
var numeralContext = map[string]bool{
|
||||
"в": true, "во": true, "к": true, "около": true, "на": true,
|
||||
"часа": true, "часов": true, "час": true, "часу": true,
|
||||
"утра": true, "вечера": true, "дня": true, "ночи": true,
|
||||
"минут": true, "минуты": true, "минуту": true,
|
||||
"at": true, "by": true,
|
||||
var numeralContext = buildNumeralContext()
|
||||
|
||||
func buildNumeralContext() map[string]bool {
|
||||
m := map[string]bool{
|
||||
"в": true, "во": true, "к": true, "около": true, "на": true,
|
||||
"утра": true, "вечера": true, "дня": true, "ночи": true,
|
||||
"at": true, "by": true,
|
||||
}
|
||||
for _, w := range lexicon.HourUnits() {
|
||||
m[w] = true
|
||||
}
|
||||
for _, w := range lexicon.MinuteUnits() {
|
||||
m[w] = true
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// SpellOutDigits rewrites spoken numbers as digits so the date parsers see the
|
||||
|
||||
@@ -175,10 +175,14 @@ func afterWord(s, w string) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
// hourPrepositions — the words a spoken hour sits behind. Three, and no more:
|
||||
// hourPrepositions — the words a spoken hour sits behind. Five, and no more:
|
||||
// the lexicon's frame set is much wider, and a word goes in here only when the
|
||||
// number after it is an hour of the day rather than a count of anything.
|
||||
var hourPrepositions = map[string]bool{"в": true, "во": true, "на": true}
|
||||
//
|
||||
// "к" and "ко" joined the three on V-609. "напомни к двум часам" named an hour
|
||||
// and parsed to nothing, so the reminder reached the daemon with no time and she
|
||||
// asked the open question about an hour he had just said.
|
||||
var hourPrepositions = map[string]bool{"в": true, "во": true, "на": true, "к": true, "ко": true}
|
||||
|
||||
// StubDateTimeParser — a tiny relative/absolute parser standing in for
|
||||
// `dateparser` until the i18n module lands. Handles "in Nh"/"in Nm"/"in Ns" and
|
||||
@@ -398,10 +402,18 @@ func leadingWordNumber(s string) (int, string, bool) {
|
||||
}
|
||||
|
||||
func unitToDuration(n int, unit string) (time.Duration, bool) {
|
||||
switch unit {
|
||||
case "h", "hour", "hours", "hr", "hrs":
|
||||
// The hour and the minute nouns are closed classes with one home in the
|
||||
// lexicon, and the list here used to be short of the oblique forms (V-609).
|
||||
if lexicon.IsHourUnit(unit) {
|
||||
return time.Duration(n) * time.Hour, true
|
||||
case "m", "min", "mins", "minute", "minutes":
|
||||
}
|
||||
if lexicon.IsMinuteUnit(unit) {
|
||||
return time.Duration(n) * time.Minute, true
|
||||
}
|
||||
switch unit {
|
||||
case "h", "hr", "hrs":
|
||||
return time.Duration(n) * time.Hour, true
|
||||
case "m", "min", "mins":
|
||||
return time.Duration(n) * time.Minute, true
|
||||
case "s", "sec", "secs", "second", "seconds":
|
||||
return time.Duration(n) * time.Second, true
|
||||
@@ -409,10 +421,6 @@ func unitToDuration(n int, unit string) (time.Duration, bool) {
|
||||
case "day", "days":
|
||||
return time.Duration(n) * 24 * time.Hour, true
|
||||
// Russian units (inflected forms)
|
||||
case "час", "часа", "часов":
|
||||
return time.Duration(n) * time.Hour, true
|
||||
case "минута", "минуты", "минут":
|
||||
return time.Duration(n) * time.Minute, true
|
||||
case "день", "дня", "дней":
|
||||
return time.Duration(n) * 24 * time.Hour, true
|
||||
case "неделя", "недели", "недель":
|
||||
|
||||
@@ -206,12 +206,16 @@ var hourMarkers = buildHourMarkers()
|
||||
func buildHourMarkers() map[string]bool {
|
||||
m := map[string]bool{
|
||||
"утра": true, "вечера": true, "дня": true, "ночи": true,
|
||||
"часа": true, "часов": true, "час": true, "часу": true,
|
||||
"минут": true, "минуты": true, "минуту": true,
|
||||
"через": true, "спустя": true, "полчаса": true,
|
||||
"полдень": true, "полночь": true,
|
||||
"am": true, "pm": true, "noon": true, "midnight": true, "in": true,
|
||||
}
|
||||
for _, w := range lexicon.HourUnits() {
|
||||
m[w] = true
|
||||
}
|
||||
for _, w := range lexicon.MinuteUnits() {
|
||||
m[w] = true
|
||||
}
|
||||
for _, w := range lexicon.PartsOfDay() {
|
||||
m[w] = true
|
||||
}
|
||||
@@ -274,11 +278,15 @@ var timeMarkers = buildTimeMarkers()
|
||||
func buildTimeMarkers() map[string]bool {
|
||||
m := map[string]bool{
|
||||
"утра": true, "вечера": true, "дня": true, "ночи": true,
|
||||
"часа": true, "часов": true, "час": true, "часу": true,
|
||||
"минут": true, "минуты": true, "минуту": true,
|
||||
"через": true, "полчаса": true, "сейчас": true,
|
||||
"am": true, "pm": true, "noon": true, "midnight": true,
|
||||
}
|
||||
for _, w := range lexicon.HourUnits() {
|
||||
m[w] = true
|
||||
}
|
||||
for _, w := range lexicon.MinuteUnits() {
|
||||
m[w] = true
|
||||
}
|
||||
for _, w := range lexicon.PartsOfDay() {
|
||||
m[w] = true
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user