From 182bc09d87b86310b076e44a3b6302898cff4efc Mon Sep 17 00:00:00 2001 From: Michael Mayer Date: Sun, 17 Apr 2022 12:30:33 +0200 Subject: [PATCH] CLI: Export reports as CSV/TSV for use in spreadsheets #2247 #2252 #2169 --- internal/commands/show_config.go | 17 ++++---- internal/commands/show_filters.go | 17 ++++---- internal/commands/show_formats.go | 21 +++++----- internal/commands/show_tags.go | 38 +++++++++--------- pkg/report/cli.go | 31 +++++++++++++++ pkg/report/csv.go | 26 ++++++++++++ pkg/report/format.go | 10 +++++ pkg/report/{table.go => markdown.go} | 18 +++------ pkg/report/render.go | 24 +++++++++++ pkg/report/render_test.go | 59 ++++++++++++++++++++++++++++ pkg/report/table_test.go | 28 ------------- 11 files changed, 198 insertions(+), 91 deletions(-) create mode 100644 pkg/report/cli.go create mode 100644 pkg/report/csv.go create mode 100644 pkg/report/format.go rename pkg/report/{table.go => markdown.go} (59%) create mode 100644 pkg/report/render.go create mode 100644 pkg/report/render_test.go delete mode 100644 pkg/report/table_test.go diff --git a/internal/commands/show_config.go b/internal/commands/show_config.go index 6cbe43dec..9e5f08060 100644 --- a/internal/commands/show_config.go +++ b/internal/commands/show_config.go @@ -12,14 +12,9 @@ import ( // ShowConfigCommand configures the command name, flags, and action. var ShowConfigCommand = cli.Command{ - Name: "config", - Usage: "Shows global config option names and values", - Flags: []cli.Flag{ - cli.BoolFlag{ - Name: "md, m", - Usage: "render Markdown without line breaks", - }, - }, + Name: "config", + Usage: "Shows global config option names and values", + Flags: report.CliFlags, Action: showConfigAction, } @@ -30,7 +25,9 @@ func showConfigAction(ctx *cli.Context) error { rows, cols := conf.Report() - fmt.Println(report.Table(rows, cols, ctx.Bool("md"))) + result, err := report.Render(rows, cols, report.CliFormat(ctx)) - return nil + fmt.Println(result) + + return err } diff --git a/internal/commands/show_filters.go b/internal/commands/show_filters.go index 361969e13..46b5880c6 100644 --- a/internal/commands/show_filters.go +++ b/internal/commands/show_filters.go @@ -12,14 +12,9 @@ import ( // ShowFiltersCommand configures the command name, flags, and action. var ShowFiltersCommand = cli.Command{ - Name: "filters", - Usage: "Displays a search filter overview with examples", - Flags: []cli.Flag{ - cli.BoolFlag{ - Name: "md, m", - Usage: "render Markdown without line breaks", - }, - }, + Name: "filters", + Usage: "Displays a search filter overview with examples", + Flags: report.CliFlags, Action: showFiltersAction, } @@ -35,7 +30,9 @@ func showFiltersAction(ctx *cli.Context) error { } }) - fmt.Println(report.Table(rows, cols, ctx.Bool("md"))) + result, err := report.Render(rows, cols, report.CliFormat(ctx)) - return nil + fmt.Println(result) + + return err } diff --git a/internal/commands/show_formats.go b/internal/commands/show_formats.go index e7f6276f7..015326c09 100644 --- a/internal/commands/show_formats.go +++ b/internal/commands/show_formats.go @@ -15,23 +15,20 @@ var ShowFormatsCommand = cli.Command{ Name: "formats", Aliases: []string{"filetypes"}, Usage: "Lists supported media and sidecar file formats", - Flags: []cli.Flag{ - cli.BoolFlag{ - Name: "short, s", - Usage: "hide format descriptions", - }, - cli.BoolFlag{ - Name: "md, m", - Usage: "render Markdown without line breaks", - }, - }, + Flags: append(report.CliFlags, cli.BoolFlag{ + Name: "short, s", + Usage: "hide links to documentation", + }), Action: showFormatsAction, } // showFormatsAction lists supported media and sidecar file formats. func showFormatsAction(ctx *cli.Context) error { rows, cols := media.Report(fs.Extensions.Types(true), !ctx.Bool("short"), true, true) - fmt.Println(report.Table(rows, cols, ctx.Bool("md"))) - return nil + result, err := report.Render(rows, cols, report.CliFormat(ctx)) + + fmt.Println(result) + + return err } diff --git a/internal/commands/show_tags.go b/internal/commands/show_tags.go index c80f8c3c9..f3f9d087b 100644 --- a/internal/commands/show_tags.go +++ b/internal/commands/show_tags.go @@ -4,10 +4,9 @@ import ( "fmt" "sort" - "github.com/photoprism/photoprism/internal/meta" - "github.com/urfave/cli" + "github.com/photoprism/photoprism/internal/meta" "github.com/photoprism/photoprism/pkg/report" ) @@ -16,16 +15,10 @@ var ShowTagsCommand = cli.Command{ Name: "tags", Aliases: []string{"metadata"}, Usage: "Shows an overview of the supported metadata tags", - Flags: []cli.Flag{ - cli.BoolFlag{ - Name: "short, s", - Usage: "hide links to documentation", - }, - cli.BoolFlag{ - Name: "md, m", - Usage: "render Markdown without line breaks", - }, - }, + Flags: append(report.CliFlags, cli.BoolFlag{ + Name: "short, s", + Usage: "hide links to documentation", + }), Action: showTagsAction, } @@ -42,14 +35,21 @@ func showTagsAction(ctx *cli.Context) error { } }) - // Show table with the supported metadata tags. - fmt.Println(report.Table(rows, cols, ctx.Bool("md"))) + // Output overview of supported metadata tags. + format := report.CliFormat(ctx) + result, err := report.Render(rows, cols, format) - // Show documentation links for those who want to delve deeper. - if !ctx.Bool("short") { - fmt.Printf("## Metadata Tags by Namespace ##\n\n") - fmt.Println(report.Table(meta.Docs, []string{"Namespace", "Documentation"}, ctx.Bool("md"))) + fmt.Println(result) + + if err != nil || ctx.Bool("short") || format == report.TSV { + return err } - return nil + // Documentation links for those who want to delve deeper. + result, err = report.Render(meta.Docs, []string{"Namespace", "Documentation"}, format) + + fmt.Printf("## Metadata Tags by Namespace ##\n\n") + fmt.Println(result) + + return err } diff --git a/pkg/report/cli.go b/pkg/report/cli.go new file mode 100644 index 000000000..b9a38230e --- /dev/null +++ b/pkg/report/cli.go @@ -0,0 +1,31 @@ +package report + +import "github.com/urfave/cli" + +func CliFormat(ctx *cli.Context) Format { + switch { + case ctx.Bool("md"), ctx.Bool("markdown"): + return Markdown + case ctx.Bool("tsv"): + return TSV + case ctx.Bool("csv"): + return CSV + default: + return Default + } +} + +var CliFlags = []cli.Flag{ + cli.BoolFlag{ + Name: "md, m", + Usage: "format as machine-readable Markdown", + }, + cli.BoolFlag{ + Name: "csv, c", + Usage: "export as semicolon separated values", + }, + cli.BoolFlag{ + Name: "tsv, t", + Usage: "export as tab separated values", + }, +} diff --git a/pkg/report/csv.go b/pkg/report/csv.go new file mode 100644 index 000000000..9bb9a02f4 --- /dev/null +++ b/pkg/report/csv.go @@ -0,0 +1,26 @@ +package report + +import ( + "bytes" + "encoding/csv" +) + +// CsvExport returns the report as character separated values. +func CsvExport(rows [][]string, cols []string, sep rune) (string, error) { + buf := &bytes.Buffer{} + writer := csv.NewWriter(buf) + + if sep > 0 { + writer.Comma = sep + } + + err := writer.Write(cols) + + if err != nil { + return "", err + } + + err = writer.WriteAll(rows) + + return buf.String(), nil +} diff --git a/pkg/report/format.go b/pkg/report/format.go new file mode 100644 index 000000000..d1bec75a4 --- /dev/null +++ b/pkg/report/format.go @@ -0,0 +1,10 @@ +package report + +type Format string + +const ( + Default = "" + Markdown = "markdown" + TSV = "tsv" + CSV = "csv" +) diff --git a/pkg/report/table.go b/pkg/report/markdown.go similarity index 59% rename from pkg/report/table.go rename to pkg/report/markdown.go index be341253f..2ac1266af 100644 --- a/pkg/report/table.go +++ b/pkg/report/markdown.go @@ -7,17 +7,11 @@ import ( "github.com/olekukonko/tablewriter" ) -// Table returns a text-formatted table, optionally as valid Markdown, +// MarkdownTable returns a text-formatted table with caption, optionally as valid Markdown, // so the output can be pasted into the docs. -func Table(rows [][]string, cols []string, markDown bool) string { - return TableWithCaption(rows, cols, "", markDown) -} - -// TableWithCaption returns a text-formatted table with caption, optionally as valid Markdown, -// so the output can be pasted into the docs. -func TableWithCaption(rows [][]string, cols []string, caption string, markDown bool) string { +func MarkdownTable(rows [][]string, cols []string, caption string, valid bool) string { // Escape Markdown. - if markDown { + if valid { for i := range rows { for j := range rows[i] { if strings.ContainsRune(rows[i][j], '|') { @@ -33,8 +27,8 @@ func TableWithCaption(rows [][]string, cols []string, caption string, markDown b borders := tablewriter.Border{ Left: true, Right: true, - Top: !markDown, - Bottom: !markDown, + Top: !valid, + Bottom: !valid, } // Render. @@ -45,7 +39,7 @@ func TableWithCaption(rows [][]string, cols []string, caption string, markDown b table.SetCaption(true, caption) } - table.SetAutoWrapText(!markDown) + table.SetAutoWrapText(!valid) table.SetAutoFormatHeaders(false) table.SetHeader(cols) table.SetBorders(borders) diff --git a/pkg/report/render.go b/pkg/report/render.go new file mode 100644 index 000000000..c6540a3a6 --- /dev/null +++ b/pkg/report/render.go @@ -0,0 +1,24 @@ +package report + +import ( + "fmt" + + "github.com/photoprism/photoprism/pkg/clean" +) + +// Render returns a text-formatted table, optionally as valid Markdown, +// so the output can be pasted into the docs. +func Render(rows [][]string, cols []string, format Format) (string, error) { + switch format { + case CSV: + return CsvExport(rows, cols, ';') + case TSV: + return CsvExport(rows, cols, '\t') + case Markdown: + return MarkdownTable(rows, cols, "", true), nil + case Default: + return MarkdownTable(rows, cols, "", false), nil + default: + return "", fmt.Errorf("invalid format %s", clean.Log(string(format))) + } +} diff --git a/pkg/report/render_test.go b/pkg/report/render_test.go new file mode 100644 index 000000000..0609bf4f0 --- /dev/null +++ b/pkg/report/render_test.go @@ -0,0 +1,59 @@ +package report + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestTable(t *testing.T) { + cols := []string{"Col1", "Col2"} + rows := [][]string{ + {"foo", "bar" + strings.Repeat(", abc", 30)}, + {"bar", "b & a | z"}} + + t.Run("DefaultTable", func(t *testing.T) { + result, err := Render(rows, cols, Default) + if err != nil { + t.Fatal(err) + } + assert.Contains(t, result, "| bar | b & a | z |") + }) + t.Run("MarkdownTable", func(t *testing.T) { + result, err := Render(rows, cols, Markdown) + if err != nil { + t.Fatal(err) + } + // fmt.Println(result) + assert.Contains(t, result, "| bar | b & a \\| z") + }) + t.Run("CsvExport", func(t *testing.T) { + result, err := Render(rows, cols, CSV) + if err != nil { + t.Fatal(err) + } + + expected := "Col1;Col2\nfoo;bar, abc, abc, abc, abc, abc, abc," + + " abc, abc, abc, abc, abc, abc, abc, abc, abc," + + " abc, abc, abc, abc, abc, abc, abc, abc, abc," + + " abc, abc, abc, abc, abc, abc\nbar;b & a \\| z\n" + + assert.Equal(t, expected, result) + }) + t.Run("TsvExport", func(t *testing.T) { + result, err := Render(rows, cols, TSV) + if err != nil { + t.Fatal(err) + } + + assert.Contains(t, result, "Col1\tCol2\nfoo\tbar, abc, abc") + }) + t.Run("Invalid", func(t *testing.T) { + _, err := Render(rows, cols, Format("invalid")) + + if err == nil { + t.Fatal("error expected") + } + }) +} diff --git a/pkg/report/table_test.go b/pkg/report/table_test.go deleted file mode 100644 index 27edd3a04..000000000 --- a/pkg/report/table_test.go +++ /dev/null @@ -1,28 +0,0 @@ -package report - -import ( - "strings" - "testing" - - "github.com/stretchr/testify/assert" -) - -func TestTable(t *testing.T) { - t.Run("Standard", func(t *testing.T) { - cols := []string{"Col1", "Col2"} - rows := [][]string{ - {"foo", "bar" + strings.Repeat(", abc", 30)}, - {"bar", "b & a | z"}} - result := Table(rows, cols, false) - assert.Contains(t, result, "| bar | b & a | z |") - }) - t.Run("Markdown", func(t *testing.T) { - cols := []string{"Col1", "Col2"} - rows := [][]string{ - {"foo", "bar" + strings.Repeat(", abc", 30)}, - {"bar", "b & a | z"}} - result := Table(rows, cols, true) - // fmt.Println(result) - assert.Contains(t, result, "| bar | b & a \\| z") - }) -}