list: a named task list is not a grocery item (V-520)

"добавь в список" was a marker in two places: task_phrases.json for task
capture, and listCapturePrefixes for the grocery list. ListGrammars is wired
before TaskCaptureGrammar in buildRouter, so the list claimed every one of
them, and takeListTag does not know "дел" as a list name — "добавь в список
дел хлеб" 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 now
declines in ParseListCapture, ParseListQuery and ParseListRemove, so the turn
falls through to task capture. The bare forms are gone from task_phrases.json,
so the data says what the code does rather than being shadowed by grammar
order.

Reversible if he asks for the other default: move the two bare phrases back and
the list will need to decline them instead.
This commit is contained in:
2026-08-05 11:44:07 +04:00
parent c586346a60
commit 7b4fb6229a
3 changed files with 95 additions and 3 deletions
+50
View File
@@ -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 "} {
+38
View File
@@ -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)
}
}
+7 -3
View File
@@ -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": [
"добавь в задачи",
"добавь в тудушки",
"добавь в список задач",
"добавь в список дел",
"добавь в список",
"добавь задачу",
"запиши в задачи",
"запиши в список дел",
"запиши в список задач",
"запиши в список",
"запиши задачу",
"новая задача",
"поставь задачу",