diff --git a/api/api.go b/api/api.go index 7590df7..108a3c3 100644 --- a/api/api.go +++ b/api/api.go @@ -1,50 +1,105 @@ package api import ( + "bytes" + "compress/gzip" + "encoding/json" + "fmt" "io" "os" + "time" ) +type Frame struct { + Delay float64 + Data []byte +} + type Api interface { - CreateAsciicast(*Asciicast) (string, error) + CreateAsciicast([]Frame, time.Duration, int, int, string, string) (string, error) } func New(url, token string) *AsciinemaApi { return &AsciinemaApi{ url: url, token: token, + http: &HttpClient{}, } } type AsciinemaApi struct { url string token string + http HTTP } -func (a *AsciinemaApi) CreateAsciicast(asciicast *Asciicast) (string, error) { - return "/foo", nil -} - -type Asciicast struct { - Command string - Title string - Rows int - Cols int - Shell string - Username string - Term string - Stdout io.Reader -} - -func NewAsciicast(command, title string, rows, cols int, stdout io.Reader) *Asciicast { - return &Asciicast{ - Command: command, - Title: title, - Rows: rows, - Cols: cols, - Shell: os.Getenv("SHELL"), - Username: os.Getenv("USER"), - Term: os.Getenv("TERM"), - Stdout: stdout, +func (a *AsciinemaApi) CreateAsciicast(frames []Frame, duration time.Duration, cols, rows int, command, title string) (string, error) { + files := map[string]io.Reader{ + "asciicast[stdout]:stdout": gzippedDataReader(frames), + "asciicast[stdout_timing]:stdout.time": gzippedTimingReader(frames), + "asciicast[meta]:meta.json": metadataReader(duration, cols, rows, command, title), } + // TODO: set proper user agent + + response, err := a.http.PostForm(a.url+"/api/asciicasts", os.Getenv("USER"), a.token, files) + if err != nil { + return "", err + } + defer response.Body.Close() + + body := &bytes.Buffer{} + _, err = body.ReadFrom(response.Body) + if err != nil { + return "", err + } + + // TODO: handle non-200 statuses + + return body.String(), nil +} + +func gzippedDataReader(frames []Frame) io.Reader { + data := &bytes.Buffer{} + w := gzip.NewWriter(data) + + for _, frame := range frames { + w.Write(frame.Data) + } + + w.Close() + + return data +} + +func gzippedTimingReader(frames []Frame) io.Reader { + timing := &bytes.Buffer{} + w := gzip.NewWriter(timing) + + for _, frame := range frames { + w.Write([]byte(fmt.Sprintf("%f %d\n", frame.Delay, len(frame.Data)))) + } + + w.Close() + + return timing +} + +func metadataReader(duration time.Duration, cols, rows int, command, title string) io.Reader { + metadata := map[string]interface{}{ + "duration": duration.Seconds(), + "title": title, + "command": command, + "shell": os.Getenv("SHELL"), + "term": map[string]interface{}{ + "type": os.Getenv("TERM"), + "columns": cols, + "lines": rows, + }, + } + + buf := &bytes.Buffer{} + encoder := json.NewEncoder(buf) + encoder.Encode(metadata) + + return buf } diff --git a/api/http.go b/api/http.go new file mode 100644 index 0000000..7ca48f9 --- /dev/null +++ b/api/http.go @@ -0,0 +1,64 @@ +package api + +import ( + "bytes" + "io" + "mime/multipart" + "net/http" + "strings" +) + +type HTTP interface { + PostForm(string, string, string, map[string]io.Reader) (*http.Response, error) +} + +type HttpClient struct{} + +func (c *HttpClient) PostForm(url, username, password string, files map[string]io.Reader) (*http.Response, error) { + body, contentType, err := c.multiPartBody(url, files) + if err != nil { + return nil, err + } + + client := &http.Client{} + + req, err := http.NewRequest("POST", url, body) + if err != nil { + return nil, err + } + + req.Header.Set("Content-Type", contentType) + req.SetBasicAuth(username, password) + + return client.Do(req) +} + +func (c *HttpClient) multiPartBody(url string, files map[string]io.Reader) (io.Reader, string, error) { + body := &bytes.Buffer{} + writer := multipart.NewWriter(body) + + if files != nil { + for name, reader := range files { + items := strings.Split(name, ":") + fieldname := items[0] + filename := items[1] + + part, err := writer.CreateFormFile(fieldname, filename) + if err != nil { + return nil, "", err + } + + _, err = io.Copy(part, reader) + if err != nil { + return nil, "", err + } + } + } + + err := writer.Close() + if err != nil { + return nil, "", err + } + + return body, writer.FormDataContentType(), nil +} diff --git a/commands/rec.go b/commands/rec.go index a017e6d..4ca8293 100644 --- a/commands/rec.go +++ b/commands/rec.go @@ -1,11 +1,10 @@ package commands import ( - "bytes" "flag" "fmt" - "io" "os" + "time" "github.com/asciinema/asciinema-cli/api" "github.com/asciinema/asciinema-cli/cli" @@ -61,15 +60,17 @@ func (c *RecordCommand) Execute(args []string) error { } util.Printf("Asciicast recording started.") - util.Printf("Hit ctrl-d or type \"exit\" to finish.") + util.Printf(`Hit ctrl-d or type "exit" to finish.`) - stdout := &StdoutStream{} + stdout := NewStream() err := c.Terminal.Record(c.Command, stdout) if err != nil { return err } + stdout.Close() + util.Printf("Asciicast recording finished.") if !c.NoConfirm { @@ -78,9 +79,8 @@ func (c *RecordCommand) Execute(args []string) error { } rows, cols, _ = c.Terminal.Size() - asciicast := api.NewAsciicast(c.Command, c.Title, rows, cols, stdout.Reader()) - url, err := c.Api.CreateAsciicast(asciicast) + url, err := c.Api.CreateAsciicast(stdout.Frames, stdout.Duration(), cols, rows, c.Command, c.Title) if err != nil { return err } @@ -102,15 +102,36 @@ func defaultRecCommand(recCommand string) string { return recCommand } -type StdoutStream struct { - data []byte +type Stream struct { + Frames []api.Frame + startTime time.Time + lastWriteTime time.Time } -func (s *StdoutStream) Write(p []byte) (int, error) { - s.data = append(s.data, p...) +func NewStream() *Stream { + now := time.Now() + + return &Stream{ + startTime: now, + lastWriteTime: now, + } +} + +func (s *Stream) Write(p []byte) (int, error) { + now := time.Now() + frame := api.Frame{} + frame.Delay = now.Sub(s.lastWriteTime).Seconds() + frame.Data = make([]byte, len(p)) + copy(frame.Data, p) + s.Frames = append(s.Frames, frame) + s.lastWriteTime = now return len(p), nil } -func (s *StdoutStream) Reader() io.Reader { - return bytes.NewReader(s.data) +func (s *Stream) Close() { + s.lastWriteTime = time.Now() +} + +func (s *Stream) Duration() time.Duration { + return s.lastWriteTime.Sub(s.startTime) } diff --git a/commands/rec_test.go b/commands/rec_test.go index d3911b7..8ee32b0 100644 --- a/commands/rec_test.go +++ b/commands/rec_test.go @@ -1,10 +1,10 @@ package commands_test import ( - "bytes" "errors" "io" "testing" + "time" "github.com/asciinema/asciinema-cli/api" "github.com/asciinema/asciinema-cli/commands" @@ -24,6 +24,8 @@ func (t *testTerminal) Record(command string, stdoutCopy io.Writer) error { } stdoutCopy.Write([]byte("hello")) + stdoutCopy.Write([]byte("world")) + return nil } @@ -32,28 +34,31 @@ type testApi struct { t *testing.T } -func (a *testApi) CreateAsciicast(asciicast *api.Asciicast) (string, error) { - if asciicast.Command != "ls" { +func (a *testApi) CreateAsciicast(frames []api.Frame, duration time.Duration, cols, rows int, command, title string) (string, error) { + if command != "ls" { a.t.Errorf("expected command to be set on asciicast") } - if asciicast.Title != "listing" { + if title != "listing" { a.t.Errorf("expected title to be set on asciicast") } - if asciicast.Rows != 15 { + if rows != 15 { a.t.Errorf("expected rows to be set on asciicast") } - if asciicast.Cols != 40 { + if cols != 40 { a.t.Errorf("expected cols to be set on asciicast") } - buf := new(bytes.Buffer) - buf.ReadFrom(asciicast.Stdout) - stdout := buf.String() + stdout := string(frames[0].Data) if stdout != "hello" { - a.t.Errorf("expected recorded stdout to be set on asciicast") + a.t.Errorf(`expected frame data "%v", got "%v"`, "hello", stdout) + } + + stdout = string(frames[1].Data) + if stdout != "world" { + a.t.Errorf(`expected frame data "%v", got "%v"`, "world", stdout) } if a.err != nil {