From fd84490647b2c4386f1ba4297db4e4e46d15161c Mon Sep 17 00:00:00 2001 From: Rob Zolkos Date: Sat, 26 Sep 2026 14:47:33 -0400 Subject: [PATCH] Keep a contact note's formatting when the TUI edits it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The TUI's note editor was handed HEY's plain `note`, which to_plain_text has flattened: bold is gone and a list item is a "•". Saving sent that text back as the whole note, so editing a note written in HEY's web app, even without changing a word, stripped its bold, lists and links. The editor now works the way `hey contact note set` does: it edits the note's rich text as Markdown, read from `note_html` by htmlutil.ToMarkdown, and saves it as HTML through htmlutil.FromMarkdown. The detail view renders that Markdown with markdown.Render, as the journal and thread bodies do, rather than printing the plain text. Both go through the sealed Markdown type, so the note is stripped of escape sequences before it is shown or edited; the editor used to be handed the plain note raw. A note containing an attachment or other markup Markdown cannot preserve is still shown, but the TUI now refuses to open it in the Markdown editor. That keeps the TUI in line with the CLI's lossless guard and prevents an unchanged save from discarding unsupported content. --- docs/tui.md | 2 + internal/tui/contacts.go | 109 ++++++++++++------ internal/tui/contacts_test.go | 211 +++++++++++++++++++++++++++++++++- 3 files changed, 283 insertions(+), 39 deletions(-) diff --git a/docs/tui.md b/docs/tui.md index 0d6d9f87..bb56ed4a 100644 --- a/docs/tui.md +++ b/docs/tui.md @@ -140,6 +140,8 @@ Thread attachments always appear with their filename, media type, and size. Use Press Shift+O to open Contacts. Use Enter to view a contact, `a` to add, `e` to edit, `n` to edit the private note, `x` twice to delete a note, `h` to hide, and `u` to show the most recently hidden contact again. Escape or `q` goes back. +The private note is shown formatted and edited as Markdown, the way `hey contact note set` takes it, so a note written in HEY keeps its bold, italics, lists, links and headings when you edit it here. Press ctrl+s to save it. A note holding an attachment or other formatting Markdown cannot preserve is shown but refused by the editor rather than losing that content; edit it in HEY or use `hey contact note set --note-html`. + ## Calendar Press Shift+C to open Calendar, then `c` to manage time track categories. Create a category with `n`, rename the selected category with Enter or `r`, and press `x` twice to delete it. Time tracks in a deleted category become uncategorized. diff --git a/internal/tui/contacts.go b/internal/tui/contacts.go index b41a554c..d056dac5 100644 --- a/internal/tui/contacts.go +++ b/internal/tui/contacts.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + stdhtml "html" "strings" "charm.land/bubbles/v2/viewport" @@ -13,6 +14,8 @@ import ( "github.com/basecamp/hey-sdk/go/pkg/generated" hey "github.com/basecamp/hey-sdk/go/pkg/hey" + "github.com/basecamp/hey-cli/internal/htmlutil" + "github.com/basecamp/hey-cli/internal/markdown" "github.com/basecamp/hey-cli/internal/terminal" ) @@ -49,8 +52,9 @@ type contactsAppendedMsg struct { type contactDetailLoadedMsg struct { requestResult - contact Contact - note string + contact Contact + note htmlutil.Markdown + noteMarkdownLossless bool } type contactSavedMsg struct { @@ -72,28 +76,30 @@ type contactRevealedMsg struct { type contactNoteSavedMsg struct { requestResult - note string - deleted bool + note htmlutil.Markdown + noteMarkdownLossless bool + deleted bool } type contactsView struct { vc *viewContext - list contactList - loaded bool - nextPage int // the page after the contacts on screen, zero at the last one - loadingMore bool // the page below is already on its way - detail Contact - note string - inDetail bool - detailView viewport.Model - contactForm *contactForm - noteForm *contactNoteForm - lastHiddenID int64 - pendingSavedContact bool - pendingOriginalID int64 - confirmNoteDelete bool - notice string + list contactList + loaded bool + nextPage int // the page after the contacts on screen, zero at the last one + loadingMore bool // the page below is already on its way + detail Contact + note htmlutil.Markdown // the private note's rich text, read as Markdown + noteMarkdownLossless bool // the note can make a trip through the Markdown editor unchanged + inDetail bool + detailView viewport.Model + contactForm *contactForm + noteForm *contactNoteForm + lastHiddenID int64 + pendingSavedContact bool + pendingOriginalID int64 + confirmNoteDelete bool + notice string requests requestLane[contactRequestKind] moreRequestID uint64 // identifies the only page-below read allowed to grow the list @@ -162,6 +168,7 @@ func (v *contactsView) Update(msg tea.Msg) (tea.Cmd, bool) { } v.detail = msg.contact v.note = msg.note + v.noteMarkdownLossless = msg.noteMarkdownLossless v.inDetail = true v.refreshDetailView() return nil, true @@ -206,7 +213,8 @@ func (v *contactsView) Update(msg tea.Msg) (tea.Cmd, bool) { v.list.remove(msg.contact.ID) v.inDetail = false v.detail = Contact{} - v.note = "" + v.note = htmlutil.Markdown{} + v.noteMarkdownLossless = true return tea.Batch(notify("Contact hidden"), v.loadMoreContacts()), true case contactRevealedMsg: @@ -232,6 +240,7 @@ func (v *contactsView) Update(msg tea.Msg) (tea.Cmd, bool) { } v.noteForm = nil v.note = msg.note + v.noteMarkdownLossless = msg.noteMarkdownLossless saved := "Private note saved" if msg.deleted { saved = "Private note deleted" @@ -282,7 +291,7 @@ func (v *contactsView) HelpBindings() []helpBinding { } if v.inDetail { bindings := []helpBinding{{"e", "edit"}, {"n", "edit note"}, {"h", "hide"}} - if v.note != "" { + if !v.note.IsEmpty() { label := "delete note" if v.confirmNoteDelete { label = "confirm delete" @@ -344,7 +353,7 @@ func (v *contactsView) HandleContentKey(msg tea.KeyPressMsg) tea.Cmd { case "h": return v.hideContact() case "x": - if v.note != "" { + if !v.note.IsEmpty() { if !v.confirmNoteDelete { v.confirmNoteDelete = true v.notice = "Press x again to permanently delete this note" @@ -405,7 +414,8 @@ func (v *contactsView) ExitThread() { v.contactForm = nil v.noteForm = nil v.detail = Contact{} - v.note = "" + v.note = htmlutil.Markdown{} + v.noteMarkdownLossless = true v.confirmNoteDelete = false v.requests.cancel() } @@ -503,8 +513,14 @@ func (v *contactsView) startEditContact() tea.Cmd { return v.contactForm.init() } +// startNote edits the note as Markdown, which saveNote turns back into HTML. Refuse a +// note carrying anything Markdown cannot represent rather than dropping it on save. func (v *contactsView) startNote() tea.Cmd { - v.noteForm = newContactNoteForm(v.detail.ID, v.note, v.vc.styles) + if !v.noteMarkdownLossless { + v.notice = "This note contains formatting Markdown cannot preserve. Edit it in HEY or with `hey contact note set --note-html`." + return nil + } + v.noteForm = newContactNoteForm(v.detail.ID, v.note.String(), v.vc.styles) v.noteForm.resize(v.vc.width, v.vc.height) return v.noteForm.init() } @@ -558,7 +574,7 @@ func (v *contactsView) revealContact(contactID int64) tea.Cmd { func (v *contactsView) saveNote() tea.Cmd { form := v.noteForm - content := strings.TrimSpace(form.input.Value()) + content := htmlutil.FromMarkdown(strings.TrimSpace(form.input.Value())) requestID, ctx := v.requests.begin(v.vc.ctx, contactRequestMutation) return func() tea.Msg { note, err := v.vc.sdk.Contacts().SetNote(ctx, form.contactID, content) @@ -568,7 +584,11 @@ func (v *contactsView) saveNote() tea.Cmd { if err != nil { return contactNoteSavedMsg{requestResult: newRequestResult(requestID, err)} } - return contactNoteSavedMsg{requestResult: newRequestResult(requestID, nil), note: note.Note} + return contactNoteSavedMsg{ + requestResult: newRequestResult(requestID, nil), + note: contactNoteMarkdown(note), + noteMarkdownLossless: contactNoteMarkdownLossless(note), + } } } @@ -577,7 +597,7 @@ func (v *contactsView) deleteNote() tea.Cmd { requestID, ctx := v.requests.begin(v.vc.ctx, contactRequestMutation) return func() tea.Msg { err := v.vc.sdk.Contacts().DeleteNote(ctx, contactID) - return contactNoteSavedMsg{requestResult: newRequestResult(requestID, err), deleted: true} + return contactNoteSavedMsg{requestResult: newRequestResult(requestID, err), noteMarkdownLossless: true, deleted: true} } } @@ -611,10 +631,10 @@ func (v *contactsView) renderContactDetail() string { b.WriteString("\n") b.WriteString(v.vc.styles.entryFrom.Render("Private note")) b.WriteString("\n") - if v.note == "" { + if v.note.IsEmpty() { b.WriteString(v.vc.styles.entryDate.Render("(empty)")) } else { - b.WriteString(terminal.Sanitize(v.note)) + b.WriteString(markdown.Render(v.note, max(v.vc.width-4, 40))) } b.WriteString("\n") return b.String() @@ -681,14 +701,37 @@ func (v *contactsView) fetchContactDetail(ctx context.Context, requestID uint64, if err != nil { return contactDetailLoadedMsg{requestResult: newRequestResult(requestID, err)} } - content := "" - if note != nil { - content = note.Note + source := contactNoteHTML(note) + return contactDetailLoadedMsg{ + requestResult: newRequestResult(requestID, nil), + contact: sdkContactDetailToModel(*detail), + note: htmlutil.ToMarkdown(source), + noteMarkdownLossless: htmlutil.MarkdownIsLossless(source), } - return contactDetailLoadedMsg{requestResult: newRequestResult(requestID, nil), contact: sdkContactDetailToModel(*detail), note: content} } } +// contactNoteHTML reads a note from its rich text, which keeps the bold, lists and +// links that the plain `note` flattens away. HEY serves both or neither; plain text on +// its own is escaped into HTML first, so it is shown and saved again as the text it is. +func contactNoteHTML(note *generated.ContactNote) string { + if note == nil { + return "" + } + if note.NoteHtml != "" { + return note.NoteHtml + } + return strings.ReplaceAll(stdhtml.EscapeString(note.Note), "\n", "
") +} + +func contactNoteMarkdown(note *generated.ContactNote) htmlutil.Markdown { + return htmlutil.ToMarkdown(contactNoteHTML(note)) +} + +func contactNoteMarkdownLossless(note *generated.ContactNote) bool { + return htmlutil.MarkdownIsLossless(contactNoteHTML(note)) +} + func contactSaveFailure(err error) string { var conflict *hey.ContactConflictError if errors.As(err, &conflict) { diff --git a/internal/tui/contacts_test.go b/internal/tui/contacts_test.go index 418c10d0..d72fdd94 100644 --- a/internal/tui/contacts_test.go +++ b/internal/tui/contacts_test.go @@ -12,9 +12,12 @@ import ( "testing" tea "charm.land/bubbletea/v2" + "github.com/charmbracelet/x/ansi" "github.com/basecamp/hey-sdk/go/pkg/generated" hey "github.com/basecamp/hey-sdk/go/pkg/hey" + + "github.com/basecamp/hey-cli/internal/htmlutil" ) type recordedTUIContacts struct { @@ -287,7 +290,7 @@ func TestContactsViewOpensDetailWithAliasesAndNote(t *testing.T) { view, _ := contactsWithTestServer(t) loadTUIContacts(t, view) openTUIContact(t, view) - if !view.inDetail || view.detail.ID != 7 || len(view.detail.Aliases) != 1 || view.note != "Prefers email" { + if !view.inDetail || view.detail.ID != 7 || len(view.detail.Aliases) != 1 || view.note.String() != "Prefers email" { t.Errorf("detail = open:%v contact:%+v note:%q", view.inDetail, view.detail, view.note) } rendered := view.View() @@ -492,8 +495,8 @@ func TestContactsViewEditsAndDeletesNote(t *testing.T) { view.noteForm.input.SetValue("Prefers a call") save := view.HandleContentKey(keyPress("ctrl+s")) saved, _ := view.Update(save()) - if toast := deliverToView(view, saved); view.noteForm != nil || view.note != "Prefers a call" || toast != "Private note saved" { - t.Errorf("note save state = form:%v note:%q toast:%q", view.noteForm, view.note, toast) + if toast := deliverToView(view, saved); view.noteForm != nil || view.note.String() != "Prefers a call" || !view.noteMarkdownLossless || toast != "Private note saved" { + t.Errorf("note save state = form:%v note:%q lossless:%v toast:%q", view.noteForm, view.note, view.noteMarkdownLossless, toast) } if deleteCmd := view.HandleContentKey(keyPress("x")); deleteCmd != nil || !view.confirmNoteDelete || !strings.Contains(view.notice, "permanently delete") { t.Fatal("first x should request note deletion confirmation") @@ -503,8 +506,8 @@ func TestContactsViewEditsAndDeletesNote(t *testing.T) { t.Fatal("second x should delete the note") } deleted, _ := view.Update(deleteCmd()) - if toast := deliverToView(view, deleted); view.note != "" || toast != "Private note deleted" { - t.Errorf("note delete state = note:%q toast:%q", view.note, toast) + if toast := deliverToView(view, deleted); !view.note.IsEmpty() || !view.noteMarkdownLossless || toast != "Private note deleted" { + t.Errorf("note delete state = note:%q lossless:%v toast:%q", view.note, view.noteMarkdownLossless, toast) } requests, _ := recorded.snapshot() joined := strings.Join(requests, "\n") @@ -513,6 +516,202 @@ func TestContactsViewEditsAndDeletesNote(t *testing.T) { } } +// anniversaryNoteHTML is a note as HEY serves one written in its web app: Trix HTML inside +// the trix-content wrapper, with the plain `note` beside it flattened by to_plain_text. +const ( + anniversaryNoteHTML = "
\n
Anniversary: June 12

\n\n
\n" + anniversaryNotePlain = "Anniversary: June 12\n\n• Prefers texts after six" +) + +// contactNoteServer serves contact 31 with the given note and answers a note write the +// way HEY does, with the HTML it was sent wrapped for Trix. It keeps each note written. +func contactNoteServer(t *testing.T, note, noteHTML string) (*contactsView, func() []string) { + t.Helper() + var mu sync.Mutex + var written []string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch { + case req.Method == http.MethodGet && req.URL.Path == "/contacts/31.json": + _, _ = w.Write([]byte(`{"id":31,"name":"Maria Lopez","email_address":"maria@example.com","aliases":[]}`)) + case req.Method == http.MethodGet && req.URL.Path == "/contacts/31/note.json": + _ = json.NewEncoder(w).Encode(map[string]any{"contact_id": 31, "note": note, "note_html": noteHTML}) + case req.Method == http.MethodPatch && req.URL.Path == "/contacts/31/note.json": + var body struct { + Contact struct { + Note string `json:"note"` + } `json:"contact"` + } + _ = json.NewDecoder(req.Body).Decode(&body) + mu.Lock() + written = append(written, body.Contact.Note) + mu.Unlock() + sent := htmlutil.ToText(body.Contact.Note) + _ = json.NewEncoder(w).Encode(map[string]any{"contact_id": 31, "note": sent, "note_html": "
\n " + body.Contact.Note + "\n
\n"}) + default: + http.NotFound(w, req) + } + })) + t.Cleanup(server.Close) + + vc := testVC() + vc.sdk = hey.NewClient(&hey.Config{BaseURL: server.URL}, &hey.StaticTokenProvider{Token: "test-token"}, hey.WithMaxRetries(0)) + view := newContactsView(vc) + view.Resize(vc.width, vc.height) + view.Update(view.requestContactDetail(31)()) + if !view.inDetail || view.detail.ID != 31 { + t.Fatalf("contact 31 did not open: %+v", view.detail) + } + return view, func() []string { + mu.Lock() + defer mu.Unlock() + return append([]string(nil), written...) + } +} + +func saveContactNote(t *testing.T, view *contactsView) { + t.Helper() + save := view.HandleContentKey(keyPress("ctrl+s")) + if save == nil { + t.Fatalf("ctrl+s did not save the note: %q", view.noteForm.status) + } + saved, _ := view.Update(save()) + if toast := deliverToView(view, saved); toast != "Private note saved" || view.noteForm != nil { + t.Fatalf("note save = toast:%q form open:%v", toast, view.noteForm != nil) + } +} + +func TestContactsViewNoteEditorKeepsFormattingWrittenInHEY(t *testing.T) { + view, written := contactNoteServer(t, anniversaryNotePlain, anniversaryNoteHTML) + view.HandleContentKey(keyPress("n")) + edited := view.noteForm.input.Value() + for _, want := range []string{"**Anniversary:** June 12", "- Prefers texts after six"} { + if !strings.Contains(edited, want) { + t.Errorf("note editor = %q, want it to hold %q", edited, want) + } + } + + saveContactNote(t, view) + notes := written() + if len(notes) != 1 { + t.Fatalf("notes written = %q", notes) + } + for _, want := range []string{"Anniversary: June 12", "