Merge the list/task-capture marker split (V-520)

This commit is contained in:
2026-08-05 11:44:15 +04:00
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": [
"добавь в задачи",
"добавь в тудушки",
"добавь в список задач",
"добавь в список дел",
"добавь в список",
"добавь задачу",
"запиши в задачи",
"запиши в список дел",
"запиши в список задач",
"запиши в список",
"запиши задачу",
"новая задача",
"поставь задачу",