Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions internal/presenter/format.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import (
"fmt"
"strings"
"time"

"github.com/basecamp/basecamp-cli/internal/richtext"
)

// FormatField formats a field value according to its FieldSpec using the given locale.
Expand Down Expand Up @@ -127,6 +129,19 @@ func formatPeople(val any) string {
return strings.Join(names, ", ")
}

// singleLine returns the first non-empty line from s, trimmed.
func singleLine(s string) string {
if strings.IndexByte(s, '\n') == -1 {
return strings.TrimSpace(s)
}
for _, line := range strings.Split(s, "\n") {
if trimmed := strings.TrimSpace(line); trimmed != "" {
return trimmed
}
}
return ""
}

// formatText converts any value to a string representation.
// Numbers are rendered raw (no locale grouping) so IDs and other numeric
// values remain copy-paste safe. Use format: "number" for locale-aware output.
Expand All @@ -135,6 +150,9 @@ func formatText(val any) string {
case nil:
return ""
case string:
if richtext.IsHTML(v) {
return richtext.HTMLToMarkdown(v)
}
return v
case bool:
if v {
Expand Down
235 changes: 235 additions & 0 deletions internal/presenter/presenter_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1551,3 +1551,238 @@ func TestRenderTaskItemCommaInAssigneeName(t *testing.T) {
t.Errorf("Should preserve full name with comma, got:\n%s", out)
}
}

// =============================================================================
// HTML → Markdown Conversion Tests
// =============================================================================

func TestFormatTextHTMLConversion(t *testing.T) {
got := formatText("<p>Hello <strong>world</strong></p>")
if !strings.Contains(got, "Hello") || !strings.Contains(got, "**world**") {
t.Errorf("formatText(HTML) should convert to markdown, got: %q", got)
}
if strings.Contains(got, "<p>") || strings.Contains(got, "<strong>") {
t.Errorf("formatText(HTML) should not contain HTML tags, got: %q", got)
}
}

func TestFormatTextPlainPassthrough(t *testing.T) {
input := "plain text without HTML"
got := formatText(input)
if got != input {
t.Errorf("formatText(plain) = %q, want %q", got, input)
}
}

func TestFormatTextBcAttachmentOnly(t *testing.T) {
input := `<bc-attachment sgid="BAh7CEkiCG" content-type="application/vnd.basecamp.mention">@Alice</bc-attachment>`
got := formatText(input)
if strings.Contains(got, "bc-attachment") {
t.Errorf("formatText(bc-attachment) should not contain raw tags, got: %q", got)
}
if !strings.Contains(got, "Alice") {
t.Errorf("formatText(bc-attachment) should preserve mention name, got: %q", got)
}
}

func TestSingleLineSkipsLeadingBlanks(t *testing.T) {
got := singleLine("\n\nfirst\nsecond")
if got != "first" {
t.Errorf("singleLine = %q, want %q", got, "first")
}
}

func TestSingleLinePlainText(t *testing.T) {
got := singleLine("no newlines")
if got != "no newlines" {
t.Errorf("singleLine = %q, want %q", got, "no newlines")
}
}

func TestRenderHeadlineHTMLContent(t *testing.T) {
schema := &EntitySchema{
Identity: Identity{Label: "content"},
}
data := map[string]any{
"content": "<div>Title<br>subtitle</div>",
}
got := RenderHeadline(schema, data)
if strings.Contains(got, "<") {
t.Errorf("RenderHeadline should strip HTML tags, got: %q", got)
}
if got != "Title" {
t.Errorf("RenderHeadline = %q, want %q", got, "Title")
}
}

func TestRenderHeadlineHTMLStrongUnwrapped(t *testing.T) {
schema := &EntitySchema{
Identity: Identity{Label: "content"},
}
data := map[string]any{
"content": "<p>Important <strong>task</strong> here</p>",
}
got := RenderHeadline(schema, data)
// Bold pairs are unwrapped because headlines are always rendered
// in a bold/primary context; nested ** would produce ****task****
if strings.Contains(got, "**") {
t.Errorf("RenderHeadline should unwrap bold markers, got: %q", got)
}
if got != "Important task here" {
t.Errorf("RenderHeadline = %q, want %q", got, "Important task here")
}
}

func TestRenderHeadlineHTMLPreservesLiteralAsterisks(t *testing.T) {
schema := &EntitySchema{
Identity: Identity{Label: "content"},
}
data := map[string]any{
"content": "<p>2**10 = 1024</p>",
}
got := RenderHeadline(schema, data)
// Literal ** that aren't bold pairs should be preserved
if got != "2**10 = 1024" {
t.Errorf("RenderHeadline = %q, want %q", got, "2**10 = 1024")
}
}

func TestRenderTaskItemHTMLContent(t *testing.T) {
schema := LookupByName("todo")
if schema == nil {
t.Fatal("Expected todo schema")
}

data := []map[string]any{
{
"content": "<div>Buy <strong>groceries</strong><br>from the store</div>",
"completed": false,
"due_on": "",
"assignees": []any{},
},
}

var buf strings.Builder
if err := RenderListMarkdown(&buf, schema, data, enUS, ""); err != nil {
t.Fatalf("RenderListMarkdown failed: %v", err)
}

out := buf.String()
if strings.Contains(out, "<div>") || strings.Contains(out, "<strong>") {
t.Errorf("Task item should not contain HTML tags, got:\n%s", out)
}
if !strings.Contains(out, "- [ ]") {
t.Errorf("Task item should have checkbox, got:\n%s", out)
}
// Should be single line (content collapsed)
lines := strings.Split(strings.TrimSpace(out), "\n")
if len(lines) != 1 {
t.Errorf("Task item should be single line, got %d lines:\n%s", len(lines), out)
}
}

func TestRenderListRowHTMLContent(t *testing.T) {
schema := LookupByName("todo")
if schema == nil {
t.Fatal("Expected todo schema")
}

styles := NewStyles(tui.NoColorTheme(), false)
data := []map[string]any{
{
"content": "<p>Hello <strong>world</strong></p>",
"completed": false,
"due_on": "",
"assignees": []any{},
},
}

var buf strings.Builder
if err := RenderList(&buf, schema, data, styles, enUS); err != nil {
t.Fatalf("RenderList failed: %v", err)
}

out := buf.String()
if strings.Contains(out, "<p>") || strings.Contains(out, "<strong>") {
t.Errorf("List row should not contain HTML tags, got:\n%s", out)
}
}

func TestRenderTaskItemMetadataHTML(t *testing.T) {
schema := &EntitySchema{
Fields: map[string]FieldSpec{
"content": {Role: "title"},
"completed": {Format: "boolean"},
"notes": {Role: "detail"},
},
Views: ViewSpecs{
List: ListView{
Columns: []string{"content", "completed", "notes"},
Markdown: &MarkdownListView{Style: "tasklist"},
},
},
}

data := []map[string]any{
{
"content": "Fix bug",
"completed": false,
"notes": "<p>See <strong>details</strong><br>on the next line</p>",
},
}

var buf strings.Builder
if err := RenderListMarkdown(&buf, schema, data, enUS, ""); err != nil {
t.Fatalf("RenderListMarkdown failed: %v", err)
}

out := buf.String()
if strings.Contains(out, "<p>") || strings.Contains(out, "<strong>") {
t.Errorf("Metadata should not contain HTML tags, got:\n%s", out)
}
// Metadata should be single-line
lines := strings.Split(strings.TrimSpace(out), "\n")
if len(lines) != 1 {
t.Errorf("Task item with metadata should be single line, got %d lines:\n%s", len(lines), out)
}
}

func TestRenderTableMarkdownHTMLCell(t *testing.T) {
schema := &EntitySchema{
Fields: map[string]FieldSpec{
"title": {Role: "title"},
"notes": {Role: "detail"},
},
Views: ViewSpecs{
List: ListView{
Columns: []string{"title", "notes"},
},
},
}

data := []map[string]any{
{
"title": "Item 1",
"notes": "<p>Some <strong>bold</strong> note</p>",
},
}

var buf strings.Builder
if err := renderTableMarkdown(&buf, schema, data, enUS); err != nil {
t.Fatalf("renderTableMarkdown failed: %v", err)
}

out := buf.String()
if strings.Contains(out, "<p>") || strings.Contains(out, "<strong>") {
t.Errorf("Table cell should not contain HTML tags, got:\n%s", out)
}
if !strings.Contains(out, "**bold**") {
t.Errorf("Table cell should contain markdown bold, got:\n%s", out)
}
// Each data row should be a single pipe-table line
lines := strings.Split(strings.TrimSpace(out), "\n")
// header + divider + 1 data row = 3
if len(lines) != 3 {
t.Errorf("Table should have 3 lines (header, divider, data), got %d:\n%s", len(lines), out)
}
}
8 changes: 4 additions & 4 deletions internal/presenter/render.go
Original file line number Diff line number Diff line change
Expand Up @@ -294,7 +294,7 @@ func renderListRow(b *strings.Builder, schema *EntitySchema, columns []string, d
for _, col := range columns {
spec := schema.Fields[col]
val := data[col]
formatted := FormatField(spec, col, val, locale)
formatted := singleLine(FormatField(spec, col, val, locale))

style := resolveEmphasis(spec, col, val, styles)
parts = append(parts, style.Render(formatted))
Expand Down Expand Up @@ -435,7 +435,7 @@ func renderTableMarkdown(w io.Writer, schema *EntitySchema, data []map[string]an
for _, col := range columns {
spec := schema.Fields[col]
val := item[col]
cells = append(cells, escapePipe(FormatField(spec, col, val, locale)))
cells = append(cells, escapePipe(singleLine(FormatField(spec, col, val, locale))))
}
b.WriteString("| " + strings.Join(cells, " | ") + " |\n")
}
Expand Down Expand Up @@ -490,7 +490,7 @@ func renderTaskItem(b *strings.Builder, schema *EntitySchema, item map[string]an
checkbox = "- [x] "
}

content := FormatField(schema.Fields["content"], "content", item["content"], locale)
content := singleLine(FormatField(schema.Fields["content"], "content", item["content"], locale))
b.WriteString(checkbox + content)

// Inline metadata from columns (excluding content and completed, which are structural)
Expand All @@ -501,7 +501,7 @@ func renderTaskItem(b *strings.Builder, schema *EntitySchema, item map[string]an
}
spec := schema.Fields[col]
val := item[col]
formatted := FormatField(spec, col, val, locale)
formatted := singleLine(FormatField(spec, col, val, locale))
if formatted == "" {
continue
}
Expand Down
21 changes: 21 additions & 0 deletions internal/presenter/template.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,15 @@ import (
"bytes"
"fmt"
"math"
"regexp"
"text/template"

"github.com/basecamp/basecamp-cli/internal/richtext"
)

// reBoldWrap matches **...** pairs produced by HTMLToMarkdown from <strong> tags.
var reBoldWrap = regexp.MustCompile(`\*\*(.+?)\*\*`)

// templateFuncs provides helper functions for schema templates.
var templateFuncs = template.FuncMap{
"not": func(v any) bool {
Expand Down Expand Up @@ -60,7 +66,22 @@ func sanitizeNumericValues(data map[string]any) map[string]any {
}

// RenderHeadline selects and renders the appropriate headline for the data.
// If the raw headline contains HTML, it is converted to markdown and collapsed
// to a single line so it stays compact in list and detail views.
// Inline bold markers (**...**) produced by HTMLToMarkdown are unwrapped
// because headlines are always rendered in a bold/primary context (lipgloss
// or an outer **...** wrapper), so nested markers would produce visual noise
// like ****word****.
func RenderHeadline(schema *EntitySchema, data map[string]any) string {
raw := renderHeadlineRaw(schema, data)
if richtext.IsHTML(raw) {
md := singleLine(richtext.HTMLToMarkdown(raw))
return reBoldWrap.ReplaceAllString(md, "$1")
}
return raw
}

func renderHeadlineRaw(schema *EntitySchema, data map[string]any) string {
if schema.Headline == nil {
// Fall back to identity label
if label := schema.Identity.Label; label != "" {
Expand Down
2 changes: 1 addition & 1 deletion internal/richtext/richtext.go
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ var (
)

// Pre-compiled regexes for IsHTML detection
var reSafeTag = regexp.MustCompile(`<(p|div|span|a|strong|b|em|i|code|pre|ul|ol|li|h[1-6]|blockquote|br|hr|img)\b[^>]*>`)
var reSafeTag = regexp.MustCompile(`<(p|div|span|a|strong|b|em|i|code|pre|ul|ol|li|h[1-6]|blockquote|br|hr|img|bc-attachment)\b[^>]*>`)

// Pre-compiled regexes for IsMarkdown detection
var reMarkdownPatterns = []*regexp.Regexp{
Expand Down
10 changes: 10 additions & 0 deletions internal/richtext/richtext_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -433,6 +433,16 @@ func TestIsHTML(t *testing.T) {
input: "This is **bold**",
expected: false,
},
{
name: "bc-attachment mention",
input: `<bc-attachment sgid="BAh7CEkiCG" content-type="application/vnd.basecamp.mention">@Alice</bc-attachment>`,
expected: true,
},
{
name: "bc-attachment file",
input: `<bc-attachment sgid="BAh7" content-type="application/pdf" filename="report.pdf"></bc-attachment>`,
expected: true,
},
}

for _, tt := range tests {
Expand Down
Loading