Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

New output formats #146

Draft
wants to merge 9 commits into
base: main
Choose a base branch
from
Draft
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
97 changes: 89 additions & 8 deletions cmd/terminal-to-html/terminal-to-html.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"net/http"
"os"
"runtime"
"slices"
"strconv"
"time"

Expand Down Expand Up @@ -76,7 +77,7 @@ func writePreviewEnd(w io.Writer) error {
return err
}

func webservice(listen string, preview bool, maxLines int) {
func webservice(listen string, preview bool, maxLines int, format, timeFmt string) {
http.HandleFunc("/terminal", func(w http.ResponseWriter, r *http.Request) {
// Process the request body, but write to a buffer before serving it.
// Consuming the body before any writes is necessary because of HTTP
Expand All @@ -86,13 +87,20 @@ func webservice(listen string, preview bool, maxLines int) {
// > Request.Body.
// However, it lets us provide Content-Length in all cases.
b := bytes.NewBuffer(nil)
if _, _, _, err := process(b, r.Body, preview, maxLines); err != nil {
if _, _, _, err := process(b, r.Body, preview, maxLines, format, timeFmt); err != nil {
log.Printf("error starting preview: %v", err)
w.WriteHeader(http.StatusInternalServerError)
fmt.Fprint(w, "Error creating preview.")
}

w.Header().Set("Content-Type", "text/html")
switch format {
case "html":
w.Header().Set("Content-Type", "text/html")
case "plain":
w.Header().Set("Content-Type", "text/plain")
case "json", "json-plain":
w.Header().Set("Content-Type", "application/json")
}
w.Header().Set("Content-Length", strconv.Itoa(b.Len()))
if _, err := w.Write(b.Bytes()); err != nil {
log.Printf("error writing response: %v", err)
Expand Down Expand Up @@ -172,7 +180,7 @@ func (wc *writeCounter) Write(b []byte) (int, error) {

// process streams the src through a terminal renderer to the dst. If preview is
// true, the preview wrapper is added.
func process(dst io.Writer, src io.Reader, preview bool, maxLines int) (in, out int, screen *terminal.Screen, err error) {
func process(dst io.Writer, src io.Reader, preview bool, maxLines int, format, timeFmt string) (in, out int, screen *terminal.Screen, err error) {
// Wrap dst in writeCounter to count bytes written
wc := &writeCounter{out: dst}

Expand All @@ -182,9 +190,33 @@ func process(dst io.Writer, src io.Reader, preview bool, maxLines int) (in, out
}
}

var scrollOutFunc func(*terminal.ScreenLine)
switch format {
case "html":
scrollOutFunc = func(line *terminal.ScreenLine) { fmt.Fprintln(wc, line.AsHTML(true)) }
case "plain":
scrollOutFunc = func(line *terminal.ScreenLine) { fmt.Fprintln(wc, line.AsPlain(timeFmt)) }
case "json":
enc := json.NewEncoder(wc)
scrollOutFunc = func(line *terminal.ScreenLine) {
_ = enc.Encode(map[string]any{
"metadata": line.Metadata,
"content": line.AsHTML(false), // don't include timestamp in content, it's metadata
})
}
case "json-plain":
enc := json.NewEncoder(wc)
scrollOutFunc = func(line *terminal.ScreenLine) {
_ = enc.Encode(map[string]any{
"metadata": line.Metadata,
"content": line.AsPlain(""), // don't include timestamp in content, it's metadata
})
}
}

screen = &terminal.Screen{
MaxLines: maxLines,
ScrollOutFunc: func(line string) { fmt.Fprintln(wc, line) },
ScrollOutFunc: scrollOutFunc,
}
inBytes, err := io.Copy(screen, src)
if err != nil {
Expand All @@ -193,7 +225,16 @@ func process(dst io.Writer, src io.Reader, preview bool, maxLines int) (in, out

// Write what remains in the screen buffer (everything that didn't scroll
// out of the top).
fmt.Fprint(wc, screen.AsHTML())
switch format {
case "html":
fmt.Fprint(wc, screen.AsHTML(true))
case "plain":
fmt.Fprint(wc, screen.AsPlainText(timeFmt))
case "json", "json-plain":
for _, line := range screen.Screen {
scrollOutFunc(&line)
}
}

if preview {
if err := writePreviewEnd(wc); err != nil {
Expand All @@ -203,6 +244,8 @@ func process(dst io.Writer, src io.Reader, preview bool, maxLines int) (in, out
return int(inBytes), wc.counter, screen, nil
}

var allowedFormats = []string{"html", "plain", "json", "json-plain"}

func main() {
cli.AppHelpTemplate = appHelpTemplate

Expand Down Expand Up @@ -230,11 +273,42 @@ func main() {
Value: 300,
Usage: "Sets a limit on the number of lines to hold in the screen buffer, allowing the renderer to operate in a streaming fashion and enabling the processing of large inputs. Setting to 0 disables the limit, causing the renderer to buffer the entire screen before producing any output",
},
&cli.StringFlag{
Name: "format",
Value: "html",
Usage: fmt.Sprintf("Configures output format. Must be one of %v", allowedFormats),
},
&cli.StringFlag{
Name: "timestamp-format",
Value: "rfc3339milli",
Usage: "Changes how timestamps are formatted (in plain format). Either 'none' (no timestamps), 'raw' (milliseconds since Unix epoch), 'rfc3339', 'rfc3339milli', or a custom Go time format string, used to format line timestamps for plain output (see https://pkg.go.dev/time#pkg-constants)",
},
}
app.Action = func(c *cli.Context) error {

format := c.String("format")
if !slices.Contains(allowedFormats, format) {
return fmt.Errorf("invalid format %q - must be one of %v", format, allowedFormats)
}

// The preview HTML should only be added if the output format is HTML
// All other formats do not support being surrounded by extra HTML
preview := c.Bool("preview") && format == "html"

// Timestamp format only applies to plain output.
timeFmt := c.String("timestamp-format")
switch timeFmt {
case "none":
timeFmt = ""
case "rfc3339":
timeFmt = time.RFC3339
case "rfc3339milli":
timeFmt = "2006-01-02T15:04:05.999Z07:00"
}

// Run a web server?
if addr := c.String("http"); addr != "" {
webservice(addr, c.Bool("preview"), c.Int("buffer-max-lines"))
webservice(addr, preview, c.Int("buffer-max-lines"), format, timeFmt)
return nil
}

Expand All @@ -251,7 +325,14 @@ func main() {
input = f
}

in, out, screen, err := process(os.Stdout, input, c.Bool("preview"), c.Int("buffer-max-lines"))
in, out, screen, err := process(
os.Stdout,
input,
preview,
c.Int("buffer-max-lines"),
format,
timeFmt,
)
if err != nil {
return err
}
Expand Down
58 changes: 52 additions & 6 deletions output.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@ import (
"fmt"
"html"
"sort"
"strconv"
"strings"
"time"
)

type outputBuffer struct {
Expand Down Expand Up @@ -66,12 +68,12 @@ func (b *outputBuffer) appendChar(char rune) {
}
}

// asHTML returns the line with HTML formatting.
func (l *screenLine) asHTML() string {
// AsHTML returns the line with HTML formatting.
func (l *ScreenLine) AsHTML(withMetadata bool) string {
var spanOpen bool
var lineBuf outputBuffer

if data, ok := l.metadata[bkNamespace]; ok {
if data, ok := l.Metadata[bkNamespace]; ok && withMetadata {
lineBuf.appendMeta(bkNamespace, data)
}

Expand Down Expand Up @@ -111,15 +113,59 @@ func (l *screenLine) asHTML() string {
return line
}

// asPlain returns the line contents without any added HTML.
func (l *screenLine) asPlain() string {
// AsPlain returns the line contents without any added HTML.
func (l *ScreenLine) AsPlain(timestampFormat string) string {
var buf strings.Builder

if timestampFormat != "" {
buf.WriteString(bkTimestamp(l.Metadata["bk"]["t"], timestampFormat))
buf.WriteRune('\t')
}

for _, node := range l.nodes {
if !node.style.element() {
if node.style.element() {
element := l.elements[node.blob]
switch element.elementType {
case ELEMENT_LINK:
content := element.content
if content == "" {
content = element.url
}
buf.WriteString(content)

case ELEMENT_IMAGE:
content := element.alt
if content == "" {
content = element.url
}
buf.WriteString(content)

case ELEMENT_ITERM_IMAGE:

}

} else {
buf.WriteRune(node.blob)
}
}

return strings.TrimRight(buf.String(), " \t")
}

func bkTimestamp(ts, timeFmt string) string {
if timeFmt == "raw" {
return ts
}

if ts == "" {
return "(no timestamp)"
}

tsint, err := strconv.ParseInt(ts, 10, 64)
if err != nil {
return err.Error()
}

tstime := time.UnixMilli(tsint)
return tstime.Format(timeFmt)
}
2 changes: 1 addition & 1 deletion parser_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@ func assertXY(t *testing.T, s *Screen, x, y int) error {
}

func assertText(t *testing.T, s *Screen, expected string) error {
if actual := s.AsPlainText(); actual != expected {
if actual := s.AsPlainText(""); actual != expected {
return fmt.Errorf("expected text %q, got %q", expected, actual)
}
return nil
Expand Down
Loading