0990f32808
An add and a crossing-off run at the top of actionNote, next to task capture and before the embedding is paid for; the read-back is a query source sitting beside "tasks", so the recall pass cannot answer "что мне купить?" from an old note about the shop. Crossing off one item claims the turn only when the list actually holds that item, which is what keeps "купил новый ноутбук" a note. These read h.dataStore rather than the CoreAPI: a list is local to the core and nothing outside it writes one. The ipc seam is what it grows through when something outside mavend needs to add to a list.
144 lines
5.1 KiB
Go
144 lines
5.1 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"log"
|
|
"strings"
|
|
|
|
"github.com/kami/maven/internal/router"
|
|
"github.com/kami/maven/internal/store"
|
|
)
|
|
|
|
// Standing lists on the voice path (Vikunja #453).
|
|
//
|
|
// Three halves, mirroring what task capture already does: an add that runs at
|
|
// the top of actionNote, a read-back query source, and a crossing-off that runs
|
|
// on the same note path because "всё купил" is note-shaped.
|
|
//
|
|
// These read h.dataStore rather than the CoreAPI. A list is local to the core
|
|
// and nothing outside it writes one: the web UI has no list page, no reach
|
|
// files groceries, and the digestion worker does not read the table. When
|
|
// something outside mavend needs to add to a list, the ipc seam is what it
|
|
// grows through — the intake rules that CaptureTaskReq documents are about
|
|
// shared intake, and there is none here yet.
|
|
//
|
|
// Nothing here speaks unprompted. A list is answered when asked about.
|
|
|
|
// captureListFromNote claims the turn when the utterance adds to, clears, or
|
|
// crosses one item off a list. ("", false) hands the turn back to the note path.
|
|
func (h *reactiveHandler) captureListFromNote(ctx context.Context, dec router.Decision) (string, bool) {
|
|
if h.dataStore == nil {
|
|
return "", false
|
|
}
|
|
// Clearing is read before removing on purpose: "всё купил" and "купил
|
|
// молоко" start with the same word, and only the second one names an item.
|
|
if list, ok := router.ParseListClear(dec.Utterance); ok {
|
|
n, err := h.dataStore.ClearList(ctx, list, h.now())
|
|
if err != nil {
|
|
log.Printf("voice: clear list: %v", err)
|
|
return "не получилось обновить список.", true
|
|
}
|
|
if n == 0 {
|
|
return "в списке и так ничего не было.", true
|
|
}
|
|
return "вычеркнула всё, список пустой.", true
|
|
}
|
|
if cap, ok := router.ParseListRemove(dec.Utterance); ok {
|
|
if reply, ok := h.removeListItem(ctx, cap); ok {
|
|
return reply, true
|
|
}
|
|
// Nothing on the list by that name. "купил новый ноутбук" is a note and
|
|
// must stay one, so the turn goes back rather than claiming a removal
|
|
// that removed nothing.
|
|
return "", false
|
|
}
|
|
cap, ok := router.ParseListCapture(dec.Utterance)
|
|
if !ok {
|
|
return "", false
|
|
}
|
|
res, err := h.dataStore.AddListItem(ctx, store.ListItem{
|
|
List: cap.List,
|
|
Item: cap.Item,
|
|
Source: "tap:voice",
|
|
CreatedTs: h.now(),
|
|
})
|
|
if err != nil {
|
|
log.Printf("voice: add list item: %v", err)
|
|
return "не получилось добавить в список.", true
|
|
}
|
|
if !res.Created {
|
|
return cap.Item + " уже в списке.", true
|
|
}
|
|
return "добавила в список: " + cap.Item + ".", true
|
|
}
|
|
|
|
// removeListItem crosses one named item off. It reports false when the list
|
|
// holds nothing by that name, which is what keeps the marker words from
|
|
// swallowing ordinary notes.
|
|
func (h *reactiveHandler) removeListItem(ctx context.Context, cap router.ListCapture) (string, bool) {
|
|
items, err := h.dataStore.ListItems(ctx, cap.List, "")
|
|
if err != nil {
|
|
log.Printf("voice: list items: %v", err)
|
|
return "", false
|
|
}
|
|
want := store.NormalizeTaskText(cap.Item)
|
|
for _, li := range items {
|
|
if store.NormalizeTaskText(li.Item) != want {
|
|
continue
|
|
}
|
|
if err := h.dataStore.SetListItemStatus(ctx, li.ID, store.ListItemDone, h.now()); err != nil {
|
|
log.Printf("voice: cross off list item: %v", err)
|
|
return "не получилось обновить список.", true
|
|
}
|
|
return "вычеркнула: " + li.Item + ".", true
|
|
}
|
|
return "", false
|
|
}
|
|
|
|
// queryList — "что в списке покупок?", "что мне купить?".
|
|
//
|
|
// A query source, so it sits in querySources and either claims the turn or
|
|
// passes it on. It is before the recall sources for the reason every specific
|
|
// source is: the notes pass would otherwise answer a list question with
|
|
// whatever note is nearest.
|
|
func (h *reactiveHandler) queryList(ctx context.Context, t *queryTurn) (string, bool) {
|
|
list, ok := router.ParseListQuery(t.dec.Utterance)
|
|
if !ok || h.dataStore == nil {
|
|
return "", false
|
|
}
|
|
items, err := h.dataStore.ListItems(ctx, list, "")
|
|
if err != nil {
|
|
log.Printf("voice: list items: %v", err)
|
|
return "не получилось посмотреть список.", true
|
|
}
|
|
return formatListRU(list, items), true
|
|
}
|
|
|
|
// formatListRU reads a list aloud. One sentence, comma-separated, because a
|
|
// shopping list is heard in a shop and a numbered recital is unusable there.
|
|
func formatListRU(list string, items []store.ListItem) string {
|
|
name := "списке " + listGenitive(list)
|
|
if len(items) == 0 {
|
|
return "в " + name + " пусто."
|
|
}
|
|
names := make([]string, 0, len(items))
|
|
for _, li := range items {
|
|
names = append(names, li.Item)
|
|
}
|
|
return "в " + name + ": " + strings.Join(names, ", ") + "."
|
|
}
|
|
|
|
// listGenitive puts a list tag into the case "список <…>" needs. Russian
|
|
// declines the noun and she must not say "в списке покупки".
|
|
func listGenitive(list string) string {
|
|
switch list {
|
|
case "покупки":
|
|
return "покупок"
|
|
case "аптека":
|
|
return "аптеки"
|
|
case "хозяйство":
|
|
return "хозяйства"
|
|
}
|
|
return list
|
|
}
|