diff --git a/internal/router/list.go b/internal/router/list.go index fe269ec..40ad637 100644 --- a/internal/router/list.go +++ b/internal/router/list.go @@ -34,6 +34,46 @@ var listTags = []struct{ word, list string }{ {"хозяйство", "хозяйство"}, } +// taskListTags — the list names that belong to task capture, not here. One +// dictionary form each, read the same way listTags are (Vikunja #520). +// +// "добавь в список" is a marker on both sides: task capture has it in +// task_phrases.json and listCapturePrefixes has it below. ListGrammars is wired +// before TaskCaptureGrammar, so the list claimed every one of them, and +// takeListTag does not recognise "дел" as a list name — so "добавь в список дел +// хлеб" filed a grocery item called "дел хлеб". The bare marker stays a grocery +// item, because an unnamed list already defaults to покупки and the task side +// always names its list. A named task list refuses here and falls through. +var taskListTags = []string{"дело", "задача", "task", "todo", "todos"} + +// namesTaskList reports whether the remainder after a list marker names a task +// list rather than one of the standing lists. +func namesTaskList(rest string) bool { + fields := strings.Fields(rest) + // The list noun and the prepositions around it are skipped, so the three + // callers can ask this before their own trimming: the remove path leaves + // "из списка дел хлеб" and the capture path leaves "список дел хлеб". + for len(fields) > 0 { + head := strings.ToLower(strings.Trim(fields[0], listTrimCut)) + if morph.SameWord(head, "список") || head == "list" || head == "of" || head == "the" || + head == "из" || head == "со" || head == "в" { + fields = fields[1:] + continue + } + break + } + if len(fields) == 0 { + return false + } + head := strings.ToLower(strings.Trim(fields[0], listTrimCut)) + for _, w := range taskListTags { + if morph.SameWord(head, w) || head == w { + return true + } + } + return false +} + var listTagsEN = []struct{ word, list string }{ {"shopping", "покупки"}, {"groceries", "покупки"}, @@ -121,6 +161,10 @@ func ParseListCapture(text string) (ListCapture, bool) { if !ok { return ListCapture{}, false } + // A named task list is task capture's, not the grocery list's (#520). + if namesTaskList(rest) { + return ListCapture{}, false + } list, rest := takeListTag(rest) rest = strings.Trim(rest, listTrimCut) if rest == "" { @@ -135,6 +179,9 @@ func ParseListQuery(text string) (string, bool) { if !ok { return "", false } + if namesTaskList(rest) { + return "", false + } list, _ := takeListTag(rest) return list, true } @@ -162,6 +209,9 @@ func ParseListRemove(text string) (ListCapture, bool) { if !ok { return ListCapture{}, false } + if namesTaskList(rest) { + return ListCapture{}, false + } list, rest := takeListTag(rest) rest = strings.Trim(rest, listTrimCut) for _, lead := range []string{"из списка ", "со списка ", "из ", "from the list "} { diff --git a/internal/router/list_test.go b/internal/router/list_test.go index c2054a3..a3f3ec9 100644 --- a/internal/router/list_test.go +++ b/internal/router/list_test.go @@ -108,3 +108,41 @@ func TestListTagIsAWordNotAPrefix(t *testing.T) { } } } + +// TestNamedTaskListIsNotAGroceryItem — "добавь в список" is a marker on both +// sides, and ListGrammars is wired before TaskCaptureGrammar, so the list used +// to claim every one of them (Vikunja #520). takeListTag does not know "дел" as +// a list name, so the item filed was "дел хлеб". The bare marker stays a +// grocery item; a named task list falls through to task capture. +func TestNamedTaskListIsNotAGroceryItem(t *testing.T) { + for _, in := range []string{ + "добавь в список дел хлеб", + "добавь в список задач позвонить врачу", + "запиши в список дел купить молоко", + "add to the list of todos milk", + } { + if got, ok := ParseListCapture(in); ok { + t.Errorf("ParseListCapture(%q) claimed it as list %q item %q, want a pass to task capture", + in, got.List, got.Item) + } + } + // The bare form is still the grocery list, and the tagged forms are untouched. + for _, tc := range []struct{ in, list, item string }{ + {"добавь в список хлеб", "покупки", "хлеб"}, + {"добавь в список покупок молоко", "покупки", "молоко"}, + {"добавь в список аптеку витамины", "аптека", "витамины"}, + } { + got, ok := ParseListCapture(tc.in) + if !ok || got.List != tc.list || got.Item != tc.item { + t.Errorf("ParseListCapture(%q) = (%q, %q, %v), want (%q, %q, true)", + tc.in, got.List, got.Item, ok, tc.list, tc.item) + } + } + // A read-back and a cross-off name the same lists and must split the same way. + if _, ok := ParseListQuery("покажи список дел"); ok { + t.Error(`ParseListQuery("покажи список дел") claimed a task list`) + } + if got, ok := ParseListRemove("вычеркни из списка дел хлеб"); ok { + t.Errorf("ParseListRemove claimed a task list: %q / %q", got.List, got.Item) + } +} diff --git a/internal/router/task_phrases.json b/internal/router/task_phrases.json index 6ed9250..85a4f6f 100644 --- a/internal/router/task_phrases.json +++ b/internal/router/task_phrases.json @@ -17,19 +17,23 @@ "", "list_nouns are the nouns that make a question be about the list.", "list_shortcut_nouns are the subset that stand alone as a whole utterance", - "('задачи'), which the longer nouns do not ('дел')." + "('задачи'), which the longer nouns do not ('дел').", + "", + "The bare 'добавь в список' and 'запиши в список' are deliberately absent", + "(Vikunja #520). They are markers on the grocery list too, in list.go, and an", + "unnamed list already means покупки there, so the bare form is a grocery item", + "and the task side always names its list. The list declines a named task list", + "instead of shadowing these by grammar order." ], "capture_prefixes": [ "добавь в задачи", "добавь в тудушки", "добавь в список задач", "добавь в список дел", - "добавь в список", "добавь задачу", "запиши в задачи", "запиши в список дел", "запиши в список задач", - "запиши в список", "запиши задачу", "новая задача", "поставь задачу",