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 } // listFloor — the keyword test behind topicList, in the shape turnIsAbout takes. func listFloor(u string) bool { _, ok := router.ParseListQuery(u) return ok } // 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) { if h.dataStore == nil { return "", false } // The seeds decide the subject and listQueryPrefixes is the floor behind // them (V-522). Which list he named is a noun lookup either way. if !h.turnIsAbout(ctx, t, topicList, listFloor) { return "", false } list := router.ListNamedIn(t.dec.Utterance) 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 }