From af54720907d10f46bd3e4de000c26285da2bef3d Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 22 Oct 2026 19:42:04 +0200 Subject: [PATCH] issue 1857: let correct say not a task, remove a promise, or set read later (1857.4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../cmd/mesh-mail-reader/correct.go | 39 +++++++ .../cmd/mesh-mail-reader/correctitems_test.go | 102 ++++++++++++++++++ .../cmd/mesh-mail-reader/tools.go | 67 ++++++++++-- 3 files changed, 200 insertions(+), 8 deletions(-) create mode 100644 modules/mesh-mail-reader/cmd/mesh-mail-reader/correctitems_test.go diff --git a/modules/mesh-mail-reader/cmd/mesh-mail-reader/correct.go b/modules/mesh-mail-reader/cmd/mesh-mail-reader/correct.go index 79d46709..4f95b6b7 100644 --- a/modules/mesh-mail-reader/cmd/mesh-mail-reader/correct.go +++ b/modules/mesh-mail-reader/cmd/mesh-mail-reader/correct.go @@ -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, "; ") } diff --git a/modules/mesh-mail-reader/cmd/mesh-mail-reader/correctitems_test.go b/modules/mesh-mail-reader/cmd/mesh-mail-reader/correctitems_test.go new file mode 100644 index 00000000..a933790e --- /dev/null +++ b/modules/mesh-mail-reader/cmd/mesh-mail-reader/correctitems_test.go @@ -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) + } +} diff --git a/modules/mesh-mail-reader/cmd/mesh-mail-reader/tools.go b/modules/mesh-mail-reader/cmd/mesh-mail-reader/tools.go index e57e7dff..d4503f4d 100644 --- a/modules/mesh-mail-reader/cmd/mesh-mail-reader/tools.go +++ b/modules/mesh-mail-reader/cmd/mesh-mail-reader/tools.go @@ -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 {