mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-09-10 23:15:40 +00:00
b9381bf5f1
Completes #898 and fixes #1076. Markdown on the seven surfaces left out of #1070, so `--format markdown` is now honestly global and the flag help collapses to "table, json, markdown": - `item comments`, `item deps`, `project activity`, `attachment list`, `library list`, `role list`, `workspace members`. Two of those are not tabular, and markdown follows the terminal shape rather than forcing a table onto them: - `item comments` keeps the attribution-line-then-body form, and the body is emitted VERBATIM. A comment body is authored as markdown; escaping it would turn its lists and code fences into literal text. Only the attribution line, which we construct, is sanitized. - `item deps` keeps its two sections as `## Blocks` / `## Blocked by` lists. Colour carried the direction in the terminal (yellow out, red in); headings carry it here. New shared spine: `cli.RenderMarkdownTable(w, headers, rows)`. Every cell is escaped, and ragged rows are padded or truncated to the header width so a short or long row can't shift the column count and break the table. Wiring a surface is now naming columns and mapping rows. #1076 — ANSI stripping covered only SGR (`ESC[…m`), so non-SGR CSI sequences, OSC-8 hyperlinks, and stray C0 controls survived, both in the table width maths and in markdown output whose doc comment promised escape-free text. Replaced `sgrPattern` with `ansiPattern` + `stripANSI` covering OSC, CSI, two-character Fe escapes, and stray C0/DEL, with TAB/LF/CR deliberately preserved for callers that normalize them. `displayWidth` now uses it too: a control sequence is zero-width, so counting it was a column-alignment bug of the same family. Tests: 12 stripping cases, 4 table-helper cases (including ragged rows), 4 renderer cases for the two non-tabular surfaces, and the routing test extended to 8 subtests — one per surface, driven through cobra against an httptest server. Also covers the two gaps named in #1076: `item starred` and the scoped `item list <collection>` path. Each new guard was proven by mutating the source and watching it fail, not just by passing. Gates: go build ./... PASS; go vet PASS; gofmt clean; golangci-lint 0 issues. Both touched packages show the same 6+2 pre-existing Windows failures as clean main under an identical sandboxed run.
194 lines
6.1 KiB
Go
194 lines
6.1 KiB
Go
package cli
|
|
|
|
import (
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"github.com/PerpetualSoftware/pad/internal/models"
|
|
)
|
|
|
|
// Markdown list rendering (#898).
|
|
//
|
|
// The `--format markdown` branch of the list commands writes a GitHub-flavored
|
|
// markdown table. Two rules separate it from the table renderer:
|
|
//
|
|
// 1. No ANSI. Markdown output is destined for a file, a PR body, or an agent's
|
|
// context — never a terminal — so the colorized helpers (ColorizedStatus,
|
|
// PriorityColor, Dim) are deliberately NOT reused here. Raw field values go
|
|
// in and the reader's renderer does the styling.
|
|
// 2. Every cell is escaped. An unescaped `|` in a title silently adds a column
|
|
// and corrupts the row; a newline ends the row early.
|
|
//
|
|
// Widths are not computed — markdown renderers lay the table out themselves, so
|
|
// the width-aware column math in renderItemTable has no counterpart here.
|
|
|
|
// SanitizeMarkdownText makes an arbitrary string safe to interpolate into
|
|
// markdown OUTSIDE a table cell — a heading, say. ANSI escapes are stripped and
|
|
// newlines collapse to spaces, so a value carrying a line break can't inject
|
|
// document structure. Pipes are left alone: they're only special inside a table.
|
|
func SanitizeMarkdownText(s string) string {
|
|
s = stripANSI(s)
|
|
s = strings.NewReplacer("\r\n", " ", "\n", " ", "\r", " ").Replace(s)
|
|
return strings.TrimSpace(s)
|
|
}
|
|
|
|
// escapeMarkdownCell makes an arbitrary string safe INSIDE a table cell: it
|
|
// sanitizes as above, then escapes backslashes and pipes.
|
|
//
|
|
// Order matters. Escaping the pipe alone is not enough: a title containing a
|
|
// backslash immediately before a pipe ("…\|…") would become "…\\|…", which GFM
|
|
// reads as an escaped backslash followed by a LIVE pipe, so the row still gains
|
|
// a column. Escaping backslashes FIRST turns it into "…\\\|…" — an escaped
|
|
// backslash then an escaped pipe — which renders back to the literal text.
|
|
// Doing it the other way round would double-escape the backslashes we just
|
|
// introduced. (Caught by @xarmian reviewing PR #1070.)
|
|
func escapeMarkdownCell(s string) string {
|
|
s = SanitizeMarkdownText(s)
|
|
s = strings.ReplaceAll(s, `\`, `\\`)
|
|
s = strings.ReplaceAll(s, "|", `\|`)
|
|
return s
|
|
}
|
|
|
|
// writeMarkdownRow writes one pipe-delimited row. Cells are expected to be
|
|
// pre-escaped.
|
|
func writeMarkdownRow(w io.Writer, cells ...string) {
|
|
fmt.Fprintf(w, "| %s |\n", strings.Join(cells, " | "))
|
|
}
|
|
|
|
// writeMarkdownSeparator writes the header/body separator for n columns.
|
|
func writeMarkdownSeparator(w io.Writer, n int) {
|
|
cells := make([]string, n)
|
|
for i := range cells {
|
|
cells[i] = "---"
|
|
}
|
|
writeMarkdownRow(w, cells...)
|
|
}
|
|
|
|
// RenderMarkdownTable writes headers, a separator, and one row per entry as a
|
|
// GitHub-flavored markdown table. Every cell is escaped, so callers pass raw
|
|
// values and never pre-format. Rows shorter than the header are padded with
|
|
// empty cells and longer ones are truncated, so a ragged row can't shift the
|
|
// column count and corrupt the table.
|
|
//
|
|
// This is the shared spine for the list commands' markdown output; a command
|
|
// only has to name its columns and map its rows.
|
|
func RenderMarkdownTable(w io.Writer, headers []string, rows [][]string) {
|
|
if len(headers) == 0 {
|
|
return
|
|
}
|
|
|
|
escaped := make([]string, len(headers))
|
|
for i, h := range headers {
|
|
escaped[i] = escapeMarkdownCell(h)
|
|
}
|
|
writeMarkdownRow(w, escaped...)
|
|
writeMarkdownSeparator(w, len(headers))
|
|
|
|
for _, row := range rows {
|
|
cells := make([]string, len(headers))
|
|
for i := range cells {
|
|
if i < len(row) {
|
|
cells[i] = escapeMarkdownCell(row[i])
|
|
}
|
|
}
|
|
writeMarkdownRow(w, cells...)
|
|
}
|
|
}
|
|
|
|
// PrintItemMarkdown prints items as a markdown table on stdout.
|
|
func PrintItemMarkdown(items []models.Item) {
|
|
RenderItemMarkdown(os.Stdout, items)
|
|
}
|
|
|
|
// RenderItemMarkdown is the writer-taking core of PrintItemMarkdown. Columns
|
|
// mirror the table renderer's — Ref · Title · Status · Priority · Collection ·
|
|
// Updated — so switching format changes the styling, not the information.
|
|
// Exported because cmd/pad composes it per collection group.
|
|
func RenderItemMarkdown(w io.Writer, items []models.Item) {
|
|
if len(items) == 0 {
|
|
fmt.Fprintln(w, "No items found.")
|
|
return
|
|
}
|
|
|
|
writeMarkdownRow(w, "Ref", "Title", "Status", "Priority", "Collection", "Updated")
|
|
writeMarkdownSeparator(w, 6)
|
|
|
|
for _, item := range items {
|
|
// The ref is the item's handle for every other command, so fall back to
|
|
// the slug rather than emitting an unusable empty first cell.
|
|
ref := ItemRef(item)
|
|
if ref == "" {
|
|
ref = item.Slug
|
|
}
|
|
if item.Pinned {
|
|
// The table marks pins with a yellow "*"; colour is unavailable
|
|
// here, so the marker has to be a glyph.
|
|
ref = "📌 " + ref
|
|
}
|
|
|
|
title := item.Title
|
|
if item.DeletedAt != nil {
|
|
title += " (archived)"
|
|
}
|
|
|
|
status, priority := itemStatusPriority(item.Fields)
|
|
if status == "" {
|
|
status = "—"
|
|
}
|
|
if priority == "" {
|
|
priority = "—"
|
|
}
|
|
|
|
collection := item.CollectionName
|
|
if item.CollectionIcon != "" {
|
|
collection = item.CollectionIcon + " " + collection
|
|
}
|
|
|
|
writeMarkdownRow(w,
|
|
escapeMarkdownCell(ref),
|
|
escapeMarkdownCell(title),
|
|
escapeMarkdownCell(status),
|
|
escapeMarkdownCell(priority),
|
|
escapeMarkdownCell(collection),
|
|
escapeMarkdownCell(RelativeTime(item.UpdatedAt)),
|
|
)
|
|
}
|
|
}
|
|
|
|
// PrintCollectionMarkdown prints collections as a markdown table on stdout.
|
|
func PrintCollectionMarkdown(collections []models.Collection) {
|
|
RenderCollectionMarkdown(os.Stdout, collections)
|
|
}
|
|
|
|
// RenderCollectionMarkdown is the writer-taking core of
|
|
// PrintCollectionMarkdown. Columns mirror PrintCollectionTable's.
|
|
func RenderCollectionMarkdown(w io.Writer, collections []models.Collection) {
|
|
if len(collections) == 0 {
|
|
fmt.Fprintln(w, "No collections found.")
|
|
return
|
|
}
|
|
|
|
writeMarkdownRow(w, "Name", "Slug", "Items", "Default")
|
|
writeMarkdownSeparator(w, 4)
|
|
|
|
for _, col := range collections {
|
|
name := col.Name
|
|
if col.Icon != "" {
|
|
name = col.Icon + " " + name
|
|
}
|
|
def := ""
|
|
if col.IsDefault {
|
|
def = "yes"
|
|
}
|
|
writeMarkdownRow(w,
|
|
escapeMarkdownCell(name),
|
|
escapeMarkdownCell(col.Slug),
|
|
strconv.Itoa(col.ItemCount),
|
|
def,
|
|
)
|
|
}
|
|
}
|