issue 1857: let correct say not a task, remove a promise, or set read later (1857.4)

ADR 0516 §4: each is kept as a reading lesson, as a change of mark or mail group is, so later batches with a
mail of that sender follow it; an item number the entry does not hold is refused.
This commit is contained in:
jochen
2026-10-22 19:42:04 +02:00
parent 6b759f708e
commit af54720907
3 changed files with 200 additions and 8 deletions
@@ -22,6 +22,19 @@ type Correction struct {
WhySet bool
DueDate time.Time
DueDateSet bool
// ADR 0516 §4, issue 1857: a task that is not one, a promise removed (each by its number, 0 for none), and
// the read-later mark set or cleared.
NotATask int
RemovePromise int
ReadLater bool
ReadLaterSet bool
}
func onOff(b bool) string {
if b {
return "on"
}
return "off"
}
// ApplyCorrection applies c to entry, answering the updated entry and a change description naming each
@@ -46,6 +59,32 @@ func ApplyCorrection(entry Entry, c Correction, oldGroupLabel string) (Entry, st
changes = append(changes, fmt.Sprintf("due date: %s -> %s", dateOrNone(entry.DueDate), dateOrNone(c.DueDate)))
entry.DueDate = c.DueDate
}
if c.NotATask != 0 {
var kept []Task
for _, t := range entry.Tasks {
if t.N == c.NotATask {
changes = append(changes, "not a task: "+t.Line)
continue
}
kept = append(kept, t)
}
entry.Tasks = kept
}
if c.RemovePromise != 0 {
var kept []Promise
for _, p := range entry.Promises {
if p.N == c.RemovePromise {
changes = append(changes, fmt.Sprintf("not a promise (%s): %s", p.Kind, p.Line))
continue
}
kept = append(kept, p)
}
entry.Promises = kept
}
if c.ReadLaterSet && c.ReadLater != entry.ReadLater {
changes = append(changes, fmt.Sprintf("read later: %s -> %s", onOff(entry.ReadLater), onOff(c.ReadLater)))
entry.ReadLater = c.ReadLater
}
return entry, strings.Join(changes, "; ")
}
@@ -0,0 +1,102 @@
package main
// Issue 1857 (novox/hq ADR 0516 §4): correct also takes not-a-task, the removal of a promise, and read later
// on or off, each kept as a reading lesson the next batch with a mail of that sender is given.
import (
"context"
"strings"
"testing"
"time"
)
func entryWithItems(t *testing.T, store Store) int64 {
t.Helper()
id, err := store.AddEntry(context.Background(), Entry{
Account: "work", Folder: "INBOX", UID: 5, SenderName: "Lena Janssens", SenderAddress: "lena@harbor.example",
Subject: "Hostco renewal", Mark: MarkMatters, Date: time.Date(2026, 10, 21, 8, 0, 0, 0, time.UTC),
Tasks: []Task{{N: 1, Line: "Approve the Hostco renewal"}, {N: 2, Line: "Forward the invoice to finance"}},
Promises: []Promise{{N: 1, Kind: OnItsWay, Line: "The new contract is on its way from Hostco"}},
})
if err != nil {
t.Fatal(err)
}
return id
}
// 1857/AC5: each new kind of correction changes the entry, is kept as a reading lesson, and is offered to the
// next batch with a mail of that sender.
func TestCorrectTakesNotATaskARemovedPromiseAndReadLater(t *testing.T) {
ctx := context.Background()
for _, tc := range []struct {
name string
args map[string]any
check func(Entry) bool
change string
}{
{"not a task", map[string]any{"not-a-task": float64(2)},
func(e Entry) bool { return len(e.Tasks) == 1 && e.Tasks[0].N == 1 }, "not a task: Forward the invoice to finance"},
{"remove a promise", map[string]any{"remove-promise": float64(1)},
func(e Entry) bool { return len(e.Promises) == 0 && len(e.Tasks) == 2 }, "not a promise (on its way): The new contract is on its way from Hostco"},
{"read later on", map[string]any{"read-later": true},
func(e Entry) bool { return e.ReadLater }, "read later: off -> on"},
} {
t.Run(tc.name, func(t *testing.T) {
store := newMemStore()
id := entryWithItems(t, store)
args := map[string]any{"id": float64(id)}
for k, v := range tc.args {
args[k] = v
}
if _, err := testBundle(store, "mesh-mail-web").correctTool(args); err != nil {
t.Fatal(err)
}
e, _, _ := store.Entry(ctx, id)
if !tc.check(e) {
t.Fatalf("entry after the correction: %+v", e)
}
lessons, _ := store.Lessons(ctx)
if len(lessons) != 1 || lessons[0].Change != tc.change || lessons[0].SenderAddress != "lena@harbor.example" {
t.Fatalf("lessons = %+v, want one from lena@harbor.example saying %q", lessons, tc.change)
}
next := BuildQuestion([]QuestionMail{{ID: 1, Account: "work", Folder: "INBOX",
From: []Person{{Name: "Lena Janssens", Address: "lena@harbor.example"}}, Subject: "next"}}, nil, lessons)
if !strings.Contains(next.Text, tc.change) {
t.Fatalf("the next batch with a mail of that sender is not given the lesson %q:\n%s", tc.change, next.Text)
}
})
}
// Read later off, on an entry that has it.
store := newMemStore()
id := entryWithItems(t, store)
e, _, _ := store.Entry(ctx, id)
e.ReadLater = true
_ = store.UpdateEntry(ctx, e)
if _, err := testBundle(store, "mesh-mail-web").correctTool(map[string]any{"id": float64(id), "read-later": false}); err != nil {
t.Fatal(err)
}
if e, _, _ := store.Entry(ctx, id); e.ReadLater {
t.Fatal("read later off left the entry read later")
}
if lessons, _ := store.Lessons(ctx); len(lessons) != 1 || lessons[0].Change != "read later: on -> off" {
t.Fatalf("lessons = %+v", lessons)
}
}
// 1857/AC5: a task or promise the entry does not hold is refused, and nothing is kept.
func TestCorrectRefusesAnItemTheEntryDoesNotHold(t *testing.T) {
store := newMemStore()
id := entryWithItems(t, store)
b := testBundle(store, "mesh-mail-web")
for _, args := range []map[string]any{
{"id": float64(id), "not-a-task": float64(3)},
{"id": float64(id), "remove-promise": float64(2)},
} {
if _, err := b.correctTool(args); err == nil {
t.Fatalf("correct %v: no error", args)
}
}
if lessons, _ := store.Lessons(context.Background()); len(lessons) != 0 {
t.Fatalf("lessons = %+v, want none", lessons)
}
}
@@ -141,16 +141,20 @@ func (b *Bundle) Tools() []stdio.Tool {
}),
Run: b.groupsTool},
{Name: CorrectTool,
Description: "Correct one mail entry's mark, mail group, why or due date (novox/hq ADR 0459 §8): " +
Description: "Correct one mail entry's mark, mail group, why or due date, say one of its tasks is not a " +
"task, remove one of its promises, or set or clear read later (novox/hq ADR 0459 §8, ADR 0516 §4): " +
"kept at once as a reading lesson (the mail's own headers, the change made, when), no operator " +
"Approve needed, and outranking that mail's own words in any later batch. Refused for an unknown " +
"mail id or mark. Answered only to a caller the setting callers names.",
Input: schema([]string{"id"}, map[string]any{
"id": integer("the mail entry's id, as entries answered it"),
"mark": text("the corrected mark: matters, scam warning or neither"),
"group": text("the corrected mail group, by its label (made if none yet carries it)"),
"why": text("the corrected reason, one line"),
"due-date": text("the corrected due date (YYYY-MM-DD), or empty to clear it"),
"id": integer("the mail entry's id, as entries answered it"),
"mark": text("the corrected mark: matters, scam warning or neither"),
"group": text("the corrected mail group, by its label (made if none yet carries it)"),
"why": text("the corrected reason, one line"),
"due-date": text("the corrected due date (YYYY-MM-DD), or empty to clear it"),
"not-a-task": integer("the number of a task of this entry that is not a task"),
"remove-promise": integer("the number of a promise of this entry to remove"),
"read-later": boolean("whether the mail is read later"),
}),
Run: b.correctTool},
{Name: LessonsTool,
@@ -297,7 +301,7 @@ func (b *Bundle) correctTool(args map[string]any) (any, error) {
if _, err := b.checkCaller(args); err != nil {
return nil, err
}
if err := only(args, "id", "mark", "group", "why", "due-date"); err != nil {
if err := only(args, "id", "mark", "group", "why", "due-date", "not-a-task", "remove-promise", "read-later"); err != nil {
return nil, err
}
id, err := number(args, "id", 0)
@@ -360,10 +364,23 @@ func (b *Bundle) correctTool(args map[string]any) (any, error) {
}
c.DueDateSet = true
}
if c.NotATask, err = itemNumber(args, "not-a-task", "task", func(n int) bool { return hasTask(entry, n) }); err != nil {
return nil, err
}
if c.RemovePromise, err = itemNumber(args, "remove-promise", "promise", func(n int) bool { return hasPromise(entry, n) }); err != nil {
return nil, err
}
if v, has := args["read-later"]; has {
on, ok := v.(bool)
if !ok {
return nil, fmt.Errorf("%s needs read-later to be true or false", CorrectTool)
}
c.ReadLater, c.ReadLaterSet = on, true
}
updated, change := ApplyCorrection(entry, c, oldGroupLabel)
if change == "" {
return nil, fmt.Errorf("%s needs a mark, mail group, why or due-date that actually changes the entry", CorrectTool)
return nil, fmt.Errorf("%s needs a mark, mail group, why, due-date, not-a-task, remove-promise or read-later that actually changes the entry", CorrectTool)
}
if err := store.UpdateEntry(ctx, updated); err != nil {
return nil, err
@@ -384,6 +401,40 @@ func (b *Bundle) correctTool(args map[string]any) (any, error) {
return entryOut(updated, newLabels), nil
}
// itemNumber reads a task's or a promise's number (1857/AC5), 0 when left out; a number the entry does not
// hold is refused before anything is written.
func itemNumber(args map[string]any, key, noun string, holds func(int) bool) (int, error) {
if _, has := args[key]; !has {
return 0, nil
}
n, err := number(args, key, 0)
if err != nil {
return 0, err
}
if !holds(n) {
return 0, fmt.Errorf("%s: this mail entry holds no %s %d", CorrectTool, noun, n)
}
return n, nil
}
func hasTask(e Entry, n int) bool {
for _, t := range e.Tasks {
if t.N == n {
return true
}
}
return false
}
func hasPromise(e Entry, n int) bool {
for _, p := range e.Promises {
if p.N == n {
return true
}
}
return false
}
func groupLabels(ctx context.Context, store Store) (map[int64]string, error) {
groups, err := store.Groups(ctx)
if err != nil {