Skip to content
Open
Show file tree
Hide file tree
Changes from 3 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
38 changes: 31 additions & 7 deletions cmd/terminal-to-html/terminal-to-html.go
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@ func webservice(listen string, preview bool, screen *terminal.Screen) {
// > Request.Body.
// However, it lets us provide Content-Length in all cases.
b := bytes.NewBuffer(nil)
if _, _, err := process(b, r.Body, preview, &screen); err != nil {
if _, _, err := process(b, r.Body, preview, "html", false, &screen); err != nil {
log.Printf("error starting preview: %v", err)
w.WriteHeader(http.StatusInternalServerError)
fmt.Fprint(w, "Error creating preview.")
Expand Down Expand Up @@ -180,9 +180,8 @@ func (wc *writeCounter) Write(b []byte) (int, error) {

func (wc *writeCounter) WriteString(s string) { wc.Write([]byte(s)) }

// 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, screen *terminal.Screen) (in, out int, err error) {
// process streams the src through a terminal renderer to the dst.
func process(dst io.Writer, src io.Reader, preview bool, format string, timestamps bool, screen *terminal.Screen) (in, out int, err error) {
// Wrap dst in writeCounter to count bytes written
wc := &writeCounter{out: dst}

Expand All @@ -193,7 +192,12 @@ func process(dst io.Writer, src io.Reader, preview bool, screen *terminal.Screen
}

// Attach the scrollout callback before streaming input.
screen.ScrollOutFunc = wc.WriteString
screen.Timestamps = timestamps
if format == "html" {
screen.ScrollOutFunc = wc.WriteString
} else {
screen.ScrollOutPlainFunc = wc.WriteString
}

inBytes, err := io.Copy(screen, src)
if err != nil {
Expand All @@ -202,7 +206,11 @@ func process(dst io.Writer, src io.Reader, preview bool, screen *terminal.Screen

// Write what remains in the screen buffer (everything that didn't scroll
// out of the top).
wc.WriteString(screen.AsHTML())
if format == "plain" {
wc.WriteString(screen.AsPlainTextWithTimestamps(timestamps))
} else {
wc.WriteString(screen.AsHTMLWithTimestamps(timestamps))
}

if preview {
if err := writePreviewEnd(wc); err != nil {
Expand Down Expand Up @@ -230,6 +238,15 @@ func main() {
Name: "preview",
Usage: "wrap output in HTML & CSS so it can be easily viewed directly in a browser",
},
&cli.StringFlag{
Name: "format",
Value: "html",
Usage: "output format: 'html' or 'plain' for plain text",
},
&cli.BoolFlag{
Name: "no-timestamps",
Usage: "disable timestamps in output",
},
&cli.BoolFlag{
Name: "log-stats-to-stderr",
Usage: "Logs a JSON object to stderr containing resource and processing statistics after successfully processing",
Expand All @@ -256,13 +273,20 @@ func main() {
},
}
app.Action = func(c *cli.Context) error {
// Validate format flag
format := c.String("format")
if format != "html" && format != "plain" {
return fmt.Errorf("invalid format %q: must be 'html' or 'plain'", format)
}

screen, err := terminal.NewScreen(
terminal.WithMaxSize(c.Int("window-max-cols"), c.Int("buffer-max-lines")),
terminal.WithSize(c.Int("window-cols"), c.Int("window-lines")),
)
if err != nil {
return fmt.Errorf("creating screen: %w", err)
}
screen.Timestamps = !c.Bool("no-timestamps")

// Run a web server?
if addr := c.String("http"); addr != "" {
Expand All @@ -283,7 +307,7 @@ func main() {
input = f
}

in, out, err := process(os.Stdout, input, c.Bool("preview"), screen)
in, out, err := process(os.Stdout, input, c.Bool("preview"), format, !c.Bool("no-timestamps"), screen)
if err != nil {
return err
}
Expand Down
42 changes: 40 additions & 2 deletions output.go
Original file line number Diff line number Diff line change
Expand Up @@ -80,15 +80,15 @@ func (b *outputBuffer) appendChar(char rune) {
// lineToHTML joins parts of a line together and renders them in HTML. It
// ignores the newline field (i.e. assumes all parts are !newline except the
// last part). The output string will have a terminating \n.
func lineToHTML(parts []screenLine) string {
func lineToHTML(parts []screenLine, timestamps bool) string {
var buf outputBuffer

// Combine metadata - last metadata wins.
bkmd := make(map[string]string)
for _, l := range parts {
maps.Copy(bkmd, l.metadata[bkNamespace])
}
if len(bkmd) > 0 {
if timestamps && len(bkmd) > 0 {
buf.appendMeta(bkNamespace, bkmd)
}

Expand Down Expand Up @@ -198,3 +198,41 @@ func (l *screenLine) asPlain() string {
}
return line
}

// lineToPlain joins parts of a line together and renders them as plain text,
// optionally with a UTC timestamp prefix. The output string will have a
// terminating \n.
func lineToPlain(parts []screenLine, timestamps bool) string {
var buf strings.Builder

if timestamps {
// Combine metadata - last metadata wins.
bkmd := make(map[string]string)
for _, l := range parts {
maps.Copy(bkmd, l.metadata[bkNamespace])
}
if t, ok := bkmd["t"]; ok {
if millis, err := strconv.ParseInt(t, 10, 64); err == nil {
ts := time.Unix(millis/1000, (millis%1000)*1_000_000).UTC()
buf.WriteString(ts.Format("2006-01-02T15:04:05Z"))
buf.WriteString(" ")
}
}
}

// Render the text content.
for _, l := range parts {
for _, node := range l.nodes {
if !node.style.element() {
buf.WriteRune(node.blob)
}
}
}

line := buf.String()
line = strings.TrimRight(line, " \t")
if line == "" && !timestamps {
return "\n"
}
return line + "\n"
}
4 changes: 2 additions & 2 deletions output_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,9 +38,9 @@ func TestScreenLineAsHTML_Interleaving(t *testing.T) {
t.Fatalf("len(s.screen) = %d, want 1", len(s.screen))
}

got := lineToHTML(s.screen[:1])
got := lineToHTML(s.screen[:1], true)
if diff := cmp.Diff(got, test.want); diff != "" {
t.Errorf("lineToHTML(s.screen[:1]) diff (-got +want):\n%s", diff)
t.Errorf("lineToHTML(s.screen[:1], true) diff (-got +want):\n%s", diff)
}
})
}
Expand Down
48 changes: 44 additions & 4 deletions screen.go
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,16 @@ type Screen struct {
// The line will always have a `\n` suffix.
ScrollOutFunc func(lineHTML string)

// Optional callback for plain text output. If not nil, as each line is
// scrolled out of the top of the buffer, this func is called with plain text.
// The line will always have a `\n` suffix.
// If both ScrollOutFunc and ScrollOutPlainFunc are set, only ScrollOutPlainFunc is used.
ScrollOutPlainFunc func(linePlain string)

// Timestamps controls whether timestamps are included in output.
// Defaults to true (timestamps included).
Timestamps bool

// Processing statistics
LinesScrolledOut int // count of lines that scrolled off the top
CursorUpOOB int // count of times ESC [A or ESC [F tried to move y < 0
Expand Down Expand Up @@ -95,6 +105,7 @@ func NewScreen(opts ...ScreenOption) (*Screen, error) {
parser: parser{
mode: parserModeNormal,
},
Timestamps: true,
}
s.parser.screen = s
for _, o := range opts {
Expand Down Expand Up @@ -257,7 +268,7 @@ func (s *Screen) currentLineForWriting() *screenLine {
// Pass the whole line being scrolled out to ScrollOutFunc if available,
// otherwise just scroll out 1 line to nowhere.
scrollOutTo := 1
if s.ScrollOutFunc != nil {
if s.ScrollOutPlainFunc != nil || s.ScrollOutFunc != nil {
// Whole lines need to be passed to the callback. Find the end of
// the line (the screen line with newline = true).
// The majority of the time this will just be the first screen line.
Expand All @@ -277,7 +288,11 @@ func (s *Screen) currentLineForWriting() *screenLine {
break
}
}
s.ScrollOutFunc(lineToHTML(s.screen[:scrollOutTo]))
if s.ScrollOutPlainFunc != nil {
s.ScrollOutPlainFunc(lineToPlain(s.screen[:scrollOutTo], s.Timestamps))
} else {
s.ScrollOutFunc(lineToHTML(s.screen[:scrollOutTo], s.Timestamps))
}
}
for i := range scrollOutTo {
s.nodeRecycling = append(s.nodeRecycling, s.screen[i].nodes[:0])
Expand Down Expand Up @@ -528,8 +543,13 @@ func (s *Screen) Write(input []byte) (int, error) {
return len(input), nil
}

// AsHTML returns the contents of the current screen buffer as HTML.
// AsHTML returns the contents of the current screen buffer as HTML with timestamps.
func (s *Screen) AsHTML() string {
return s.AsHTMLWithTimestamps(true)
}

// AsHTMLWithTimestamps returns the contents of the current screen buffer as HTML.
func (s *Screen) AsHTMLWithTimestamps(timestamps bool) string {
var sb strings.Builder

screen := s.screen
Expand All @@ -542,7 +562,7 @@ func (s *Screen) AsHTML() string {
break
}
}
sb.WriteString(lineToHTML(screen[:lineEnd]))
sb.WriteString(lineToHTML(screen[:lineEnd], timestamps))
screen = screen[lineEnd:]
}

Expand All @@ -561,6 +581,26 @@ func (s *Screen) AsPlainText() string {
return strings.TrimSuffix(sb.String(), "\n")
}

// AsPlainTextWithTimestamps renders the screen as plain text, optionally
// with UTC timestamp prefixes.
func (s *Screen) AsPlainTextWithTimestamps(timestamps bool) string {
if !timestamps {
return s.AsPlainText()
}

var sb strings.Builder
for i := 0; i < len(s.screen); {
// Find the end of this logical line.
lineEnd := i + 1
for lineEnd < len(s.screen) && !s.screen[lineEnd-1].newline {
lineEnd++
}
sb.WriteString(lineToPlain(s.screen[i:lineEnd], true))
i = lineEnd
}
return strings.TrimSuffix(sb.String(), "\n")
}

func (s *Screen) newLine() {
// Do the carriage return first to ensure that currentLineForWriting can't
// give us the next line if the cursor was placed past the end of the line.
Expand Down