mirror of
https://github.com/photoprism/photoprism.git
synced 2025-09-26 12:51:31 +08:00
CLI: Add "--json" as an additional output format to show commands #5220
Signed-off-by: Michael Mayer <michael@photoprism.app>
This commit is contained in:
@@ -167,6 +167,12 @@ If anything in this file conflicts with the `Makefile` or the Developer Guide, t
|
||||
- CLI command tests: use `RunWithTestContext(cmd, args)` to capture output and avoid `os.Exit`; assert `cli.ExitCoder` codes when you need them.
|
||||
- Reports are quoted: strings in CLI "show" output are rendered with quotes by the report helpers. Prefer `assert.Contains`/regex over strict, fully formatted equality when validating content.
|
||||
|
||||
#### Test Data & Fixtures (storage/testdata)
|
||||
- Shared test files live under `storage/testdata`. The lifecycle is managed by `internal/config/test.go`.
|
||||
- `NewTestConfig("<pkg>")` now calls `InitializeTestData()` so required directories exist (originals, import, cache, temp) before tests run.
|
||||
- If you build a custom `*config.Config`, call `c.InitializeTestData()` (and optionally `c.AssertTestData(t)`) before asserting on filesystem paths.
|
||||
- `InitializeTestData()` deletes existing testdata (`RemoveTestData()`), downloads/unzips fixtures if needed, and then calls `CreateDirectories()` to ensure required directories exist.
|
||||
|
||||
### Roles & ACL
|
||||
|
||||
- Always map roles via the central tables:
|
||||
|
@@ -20,7 +20,7 @@ type healthResponse struct {
|
||||
var ClusterHealthCommand = &cli.Command{
|
||||
Name: "health",
|
||||
Usage: "Shows cluster health (Portal-only)",
|
||||
Flags: append(report.CliFlags, JsonFlag),
|
||||
Flags: report.CliFlags,
|
||||
Action: clusterHealthAction,
|
||||
}
|
||||
|
||||
|
@@ -29,7 +29,7 @@ var ClusterNodesCommands = &cli.Command{
|
||||
var ClusterNodesListCommand = &cli.Command{
|
||||
Name: "ls",
|
||||
Usage: "Lists registered cluster nodes (Portal-only)",
|
||||
Flags: append(append(report.CliFlags, JsonFlag), CountFlag, OffsetFlag),
|
||||
Flags: append(report.CliFlags, CountFlag, OffsetFlag),
|
||||
ArgsUsage: "",
|
||||
Action: clusterNodesListAction,
|
||||
}
|
||||
|
@@ -27,7 +27,7 @@ var ClusterNodesRotateCommand = &cli.Command{
|
||||
Name: "rotate",
|
||||
Usage: "Rotates a node's DB and/or secret via Portal (HTTP)",
|
||||
ArgsUsage: "<id|name>",
|
||||
Flags: append([]cli.Flag{rotateDatabaseFlag, rotateSecretFlag, &cli.BoolFlag{Name: "yes", Aliases: []string{"y"}, Usage: "runs the command non-interactively"}, rotatePortalURL, rotatePortalTok, JsonFlag}, report.CliFlags...),
|
||||
Flags: append([]cli.Flag{rotateDatabaseFlag, rotateSecretFlag, &cli.BoolFlag{Name: "yes", Aliases: []string{"y"}, Usage: "runs the command non-interactively"}, rotatePortalURL, rotatePortalTok}, report.CliFlags...),
|
||||
Action: clusterNodesRotateAction,
|
||||
}
|
||||
|
||||
|
@@ -17,7 +17,7 @@ var ClusterNodesShowCommand = &cli.Command{
|
||||
Name: "show",
|
||||
Usage: "Shows node details (Portal-only)",
|
||||
ArgsUsage: "<id|name>",
|
||||
Flags: append(report.CliFlags, JsonFlag),
|
||||
Flags: report.CliFlags,
|
||||
Action: clusterNodesShowAction,
|
||||
}
|
||||
|
||||
|
@@ -40,7 +40,7 @@ var (
|
||||
var ClusterRegisterCommand = &cli.Command{
|
||||
Name: "register",
|
||||
Usage: "Registers/rotates a node via Portal (HTTP)",
|
||||
Flags: append(append([]cli.Flag{regNameFlag, regRoleFlag, regIntUrlFlag, regLabelFlag, regRotateDatabase, regRotateSec, regPortalURL, regPortalTok, regWriteConf, regForceFlag, JsonFlag}, report.CliFlags...)),
|
||||
Flags: append(append([]cli.Flag{regNameFlag, regRoleFlag, regIntUrlFlag, regLabelFlag, regRotateDatabase, regRotateSec, regPortalURL, regPortalTok, regWriteConf, regForceFlag}, report.CliFlags...)),
|
||||
Action: clusterRegisterAction,
|
||||
}
|
||||
|
||||
|
@@ -17,7 +17,7 @@ import (
|
||||
var ClusterSummaryCommand = &cli.Command{
|
||||
Name: "summary",
|
||||
Usage: "Shows cluster summary (Portal-only)",
|
||||
Flags: append(report.CliFlags, JsonFlag),
|
||||
Flags: report.CliFlags,
|
||||
Action: clusterSummaryAction,
|
||||
}
|
||||
|
||||
|
@@ -36,7 +36,7 @@ var ClusterThemePullCommand = &cli.Command{
|
||||
&cli.StringFlag{Name: "join-token", Usage: "Portal access `TOKEN` (defaults to global config)"},
|
||||
&cli.StringFlag{Name: "client-id", Usage: "Node client `ID` (defaults to NodeID from config)"},
|
||||
&cli.StringFlag{Name: "client-secret", Usage: "Node client `SECRET` (defaults to NodeSecret from config)"},
|
||||
JsonFlag,
|
||||
// JSON output supported via report.CliFlags on parent command where applicable
|
||||
},
|
||||
Action: clusterThemePullAction,
|
||||
},
|
||||
|
@@ -35,7 +35,7 @@ func createFakeYtDlp(t *testing.T) string {
|
||||
b.WriteString("set -euo pipefail\n")
|
||||
b.WriteString("OUT_TPL=\"\"\n")
|
||||
b.WriteString("i=0; while [[ $i -lt $# ]]; do i=$((i+1)); arg=\"${!i}\"; if [[ \"$arg\" == \"--dump-single-json\" ]]; then echo '{\"id\":\"abc\",\"title\":\"Test\",\"url\":\"http://example.com\",\"_type\":\"video\"}'; exit 0; fi; if [[ \"$arg\" == \"--output\" ]]; then i=$((i+1)); OUT_TPL=\"${!i}\"; fi; done\n")
|
||||
b.WriteString("if [[ $* == *'--print '* ]]; then OUT=\"$OUT_TPL\"; OUT=${OUT//%(id)s/abc}; OUT=${OUT//%(ext)s/mp4}; mkdir -p \"$(dirname \"$OUT\")\"; CONTENT=\"${YTDLP_DUMMY_CONTENT:-dummy}\"; echo \"$CONTENT\" > \"$OUT\"; echo \"$OUT\"; exit 0; fi\n")
|
||||
b.WriteString("if [[ $* == *'--print '* ]]; then OUT=\"$OUT_TPL\"; OUT=${OUT//%(id)s/abc}; OUT=${OUT//%(ext)s/mp4}; mkdir -p \"$(dirname \"$OUT\")\"; CONTENT=\"${YTDLP_DUMMY_CONTENT:-dummy}\"; echo \"$CONTENT\" > \"$OUT\"; echo \"$OUT\"; exit 0; fi\n")
|
||||
if err := os.WriteFile(path, []byte(b.String()), 0o755); err != nil {
|
||||
t.Fatalf("failed to write fake yt-dlp: %v", err)
|
||||
}
|
||||
@@ -83,18 +83,18 @@ func TestDownloadImpl_FileMethod_AutoSkipsRemux(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestDownloadImpl_FileMethod_Skip_NoRemux(t *testing.T) {
|
||||
fake := createFakeYtDlp(t)
|
||||
orig := dl.YtDlpBin
|
||||
defer func() { dl.YtDlpBin = orig }()
|
||||
fake := createFakeYtDlp(t)
|
||||
orig := dl.YtDlpBin
|
||||
defer func() { dl.YtDlpBin = orig }()
|
||||
|
||||
dest := "dl-e2e-skip"
|
||||
// Ensure different file content so duplicate detection won't collapse into prior test's file
|
||||
t.Setenv("YTDLP_DUMMY_CONTENT", "dummy2")
|
||||
if c := get.Config(); c != nil {
|
||||
c.Options().FFmpegBin = "/bin/false" // would fail if remux attempted
|
||||
s := c.Settings()
|
||||
s.Index.Convert = false
|
||||
}
|
||||
dest := "dl-e2e-skip"
|
||||
// Ensure different file content so duplicate detection won't collapse into prior test's file
|
||||
t.Setenv("YTDLP_DUMMY_CONTENT", "dummy2")
|
||||
if c := get.Config(); c != nil {
|
||||
c.Options().FFmpegBin = "/bin/false" // would fail if remux attempted
|
||||
s := c.Settings()
|
||||
s.Index.Convert = false
|
||||
}
|
||||
conf := get.Config()
|
||||
if conf == nil {
|
||||
t.Fatalf("missing test config")
|
||||
|
@@ -17,8 +17,8 @@ import (
|
||||
// FindCommand configures the command name, flags, and action.
|
||||
var FindCommand = &cli.Command{
|
||||
Name: "find",
|
||||
Usage: "Searches the index for specific files",
|
||||
ArgsUsage: "[filter]",
|
||||
Usage: "Finds indexed files that match the specified search filters",
|
||||
ArgsUsage: "[filter]...",
|
||||
Flags: append(report.CliFlags, &cli.UintFlag{
|
||||
Name: "count",
|
||||
Aliases: []string{"n"},
|
||||
|
@@ -1,6 +1,7 @@
|
||||
package commands
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
@@ -40,21 +41,34 @@ func showConfigAction(ctx *cli.Context) error {
|
||||
log.Debug(err)
|
||||
}
|
||||
|
||||
format, ferr := report.CliFormatStrict(ctx)
|
||||
if ferr != nil {
|
||||
return ferr
|
||||
}
|
||||
|
||||
if format == report.JSON {
|
||||
type section struct {
|
||||
Title string `json:"title"`
|
||||
Items []map[string]string `json:"items"`
|
||||
}
|
||||
sections := make([]section, 0, len(ConfigReports))
|
||||
for _, rep := range ConfigReports {
|
||||
rows, cols := rep.Report(conf)
|
||||
sections = append(sections, section{Title: rep.Title, Items: report.RowsToObjects(rows, cols)})
|
||||
}
|
||||
b, _ := json.Marshal(map[string]interface{}{"sections": sections})
|
||||
fmt.Println(string(b))
|
||||
return nil
|
||||
}
|
||||
|
||||
for _, rep := range ConfigReports {
|
||||
// Get values.
|
||||
rows, cols := rep.Report(conf)
|
||||
|
||||
// Render report.
|
||||
opt := report.Options{Format: report.CliFormat(ctx), NoWrap: rep.NoWrap}
|
||||
opt := report.Options{Format: format, NoWrap: rep.NoWrap}
|
||||
result, _ := report.Render(rows, cols, opt)
|
||||
|
||||
// Show report.
|
||||
if opt.Format == report.Default {
|
||||
fmt.Printf("\n%s\n\n", strings.ToUpper(rep.Title))
|
||||
}
|
||||
|
||||
fmt.Println(result)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
@@ -1,6 +1,7 @@
|
||||
package commands
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
@@ -25,16 +26,54 @@ func showConfigOptionsAction(ctx *cli.Context) error {
|
||||
conf.SetLogLevel(logrus.FatalLevel)
|
||||
|
||||
rows, cols := config.Flags.Report()
|
||||
format, ferr := report.CliFormatStrict(ctx)
|
||||
if ferr != nil {
|
||||
return ferr
|
||||
}
|
||||
|
||||
// CSV Export?
|
||||
if ctx.Bool("csv") || ctx.Bool("tsv") {
|
||||
result, err := report.RenderFormat(rows, cols, report.CliFormat(ctx))
|
||||
|
||||
// CSV/TSV exports use default single-table rendering
|
||||
if format == report.CSV || format == report.TSV {
|
||||
result, err := report.RenderFormat(rows, cols, format)
|
||||
fmt.Println(result)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// JSON aggregation path
|
||||
if format == report.JSON {
|
||||
type section struct {
|
||||
Title string `json:"title"`
|
||||
Info string `json:"info,omitempty"`
|
||||
Items []map[string]string `json:"items"`
|
||||
}
|
||||
sectionsCfg := config.OptionsReportSections
|
||||
agg := make([]section, 0, len(sectionsCfg))
|
||||
j := 0
|
||||
for i, sec := range sectionsCfg {
|
||||
secRows := make([][]string, 0)
|
||||
for {
|
||||
row := rows[j]
|
||||
if len(row) < 1 {
|
||||
continue
|
||||
}
|
||||
if i < len(sectionsCfg)-1 && sectionsCfg[i+1].Start == row[0] {
|
||||
break
|
||||
}
|
||||
secRows = append(secRows, row)
|
||||
j++
|
||||
if j >= len(rows) {
|
||||
break
|
||||
}
|
||||
}
|
||||
agg = append(agg, section{Title: sec.Title, Info: sec.Info, Items: report.RowsToObjects(secRows, cols)})
|
||||
if j >= len(rows) {
|
||||
break
|
||||
}
|
||||
}
|
||||
b, _ := json.Marshal(map[string]interface{}{"sections": agg})
|
||||
fmt.Println(string(b))
|
||||
return nil
|
||||
}
|
||||
|
||||
markDown := ctx.Bool("md")
|
||||
sections := config.OptionsReportSections
|
||||
|
||||
@@ -74,7 +113,8 @@ func showConfigOptionsAction(ctx *cli.Context) error {
|
||||
}
|
||||
}
|
||||
|
||||
result, err := report.RenderFormat(secRows, cols, report.CliFormat(ctx))
|
||||
// JSON handled earlier; Markdown and default render per section below
|
||||
result, err := report.RenderFormat(secRows, cols, format)
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
|
@@ -1,6 +1,7 @@
|
||||
package commands
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
@@ -25,16 +26,54 @@ func showConfigYamlAction(ctx *cli.Context) error {
|
||||
conf.SetLogLevel(logrus.TraceLevel)
|
||||
|
||||
rows, cols := conf.Options().Report()
|
||||
format, ferr := report.CliFormatStrict(ctx)
|
||||
if ferr != nil {
|
||||
return ferr
|
||||
}
|
||||
|
||||
// CSV Export?
|
||||
if ctx.Bool("csv") || ctx.Bool("tsv") {
|
||||
result, err := report.RenderFormat(rows, cols, report.CliFormat(ctx))
|
||||
|
||||
// CSV/TSV exports use default single-table rendering
|
||||
if format == report.CSV || format == report.TSV {
|
||||
result, err := report.RenderFormat(rows, cols, format)
|
||||
fmt.Println(result)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// JSON aggregation path
|
||||
if format == report.JSON {
|
||||
type section struct {
|
||||
Title string `json:"title"`
|
||||
Info string `json:"info,omitempty"`
|
||||
Items []map[string]string `json:"items"`
|
||||
}
|
||||
sectionsCfg := config.YamlReportSections
|
||||
agg := make([]section, 0, len(sectionsCfg))
|
||||
j := 0
|
||||
for i, sec := range sectionsCfg {
|
||||
secRows := make([][]string, 0)
|
||||
for {
|
||||
row := rows[j]
|
||||
if len(row) < 1 {
|
||||
continue
|
||||
}
|
||||
if i < len(sectionsCfg)-1 && sectionsCfg[i+1].Start == row[0] {
|
||||
break
|
||||
}
|
||||
secRows = append(secRows, row)
|
||||
j++
|
||||
if j >= len(rows) {
|
||||
break
|
||||
}
|
||||
}
|
||||
agg = append(agg, section{Title: sec.Title, Info: sec.Info, Items: report.RowsToObjects(secRows, cols)})
|
||||
if j >= len(rows) {
|
||||
break
|
||||
}
|
||||
}
|
||||
b, _ := json.Marshal(map[string]interface{}{"sections": agg})
|
||||
fmt.Println(string(b))
|
||||
return nil
|
||||
}
|
||||
|
||||
markDown := ctx.Bool("md")
|
||||
sections := config.YamlReportSections
|
||||
|
||||
@@ -74,7 +113,8 @@ func showConfigYamlAction(ctx *cli.Context) error {
|
||||
}
|
||||
}
|
||||
|
||||
result, err := report.RenderFormat(secRows, cols, report.CliFormat(ctx))
|
||||
// JSON handled earlier; Markdown and default render per section below
|
||||
result, err := report.RenderFormat(secRows, cols, format)
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
|
@@ -26,10 +26,11 @@ var ShowFileFormatsCommand = &cli.Command{
|
||||
// showFileFormatsAction displays supported media and sidecar file formats.
|
||||
func showFileFormatsAction(ctx *cli.Context) error {
|
||||
rows, cols := media.Report(fs.Extensions.Types(true), !ctx.Bool("short"), true, true)
|
||||
|
||||
result, err := report.RenderFormat(rows, cols, report.CliFormat(ctx))
|
||||
|
||||
format, ferr := report.CliFormatStrict(ctx)
|
||||
if ferr != nil {
|
||||
return ferr
|
||||
}
|
||||
result, err := report.RenderFormat(rows, cols, format)
|
||||
fmt.Println(result)
|
||||
|
||||
return err
|
||||
}
|
||||
|
197
internal/commands/show_json_test.go
Normal file
197
internal/commands/show_json_test.go
Normal file
@@ -0,0 +1,197 @@
|
||||
package commands
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestShowThumbSizes_JSON(t *testing.T) {
|
||||
out, err := RunWithTestContext(ShowThumbSizesCommand, []string{"thumb-sizes", "--json"})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
var v []map[string]string
|
||||
if err := json.Unmarshal([]byte(out), &v); err != nil {
|
||||
t.Fatalf("invalid json: %v\n%s", err, out)
|
||||
}
|
||||
if len(v) == 0 {
|
||||
t.Fatalf("expected at least one item")
|
||||
}
|
||||
// Expected keys for thumb-sizes detailed report
|
||||
for _, k := range []string{"name", "width", "height", "aspect_ratio", "available", "usage"} {
|
||||
if _, ok := v[0][k]; !ok {
|
||||
t.Fatalf("expected key '%s' in first item", k)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestShowSources_JSON(t *testing.T) {
|
||||
out, err := RunWithTestContext(ShowSourcesCommand, []string{"sources", "--json"})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
var v []map[string]string
|
||||
if err := json.Unmarshal([]byte(out), &v); err != nil {
|
||||
t.Fatalf("invalid json: %v\n%s", err, out)
|
||||
}
|
||||
if len(v) == 0 {
|
||||
t.Fatalf("expected at least one item")
|
||||
}
|
||||
if _, ok := v[0]["source"]; !ok {
|
||||
t.Fatalf("expected key 'source' in first item")
|
||||
}
|
||||
if _, ok := v[0]["priority"]; !ok {
|
||||
t.Fatalf("expected key 'priority' in first item")
|
||||
}
|
||||
}
|
||||
|
||||
func TestShowMetadata_JSON(t *testing.T) {
|
||||
out, err := RunWithTestContext(ShowMetadataCommand, []string{"metadata", "--json"})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
var v struct {
|
||||
Items []map[string]string `json:"items"`
|
||||
Docs []map[string]string `json:"docs"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(out), &v); err != nil {
|
||||
t.Fatalf("invalid json: %v\n%s", err, out)
|
||||
}
|
||||
if len(v.Items) == 0 {
|
||||
t.Fatalf("expected items")
|
||||
}
|
||||
}
|
||||
|
||||
func TestShowConfig_JSON(t *testing.T) {
|
||||
out, err := RunWithTestContext(ShowConfigCommand, []string{"config", "--json"})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
var v struct {
|
||||
Sections []struct {
|
||||
Title string `json:"title"`
|
||||
Items []map[string]string `json:"items"`
|
||||
} `json:"sections"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(out), &v); err != nil {
|
||||
t.Fatalf("invalid json: %v\n%s", err, out)
|
||||
}
|
||||
if len(v.Sections) == 0 || len(v.Sections[0].Items) == 0 {
|
||||
t.Fatalf("expected sections with items")
|
||||
}
|
||||
}
|
||||
|
||||
func TestShowConfigOptions_JSON(t *testing.T) {
|
||||
out, err := RunWithTestContext(ShowConfigOptionsCommand, []string{"config-options", "--json"})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
var v struct {
|
||||
Sections []struct {
|
||||
Title string `json:"title"`
|
||||
Items []map[string]string `json:"items"`
|
||||
} `json:"sections"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(out), &v); err != nil {
|
||||
t.Fatalf("invalid json: %v\n%s", err, out)
|
||||
}
|
||||
if len(v.Sections) == 0 || len(v.Sections[0].Items) == 0 {
|
||||
t.Fatalf("expected sections with items")
|
||||
}
|
||||
}
|
||||
|
||||
func TestShowConfigYaml_JSON(t *testing.T) {
|
||||
out, err := RunWithTestContext(ShowConfigYamlCommand, []string{"config-yaml", "--json"})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
var v struct {
|
||||
Sections []struct {
|
||||
Title string `json:"title"`
|
||||
Items []map[string]string `json:"items"`
|
||||
} `json:"sections"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(out), &v); err != nil {
|
||||
t.Fatalf("invalid json: %v\n%s", err, out)
|
||||
}
|
||||
if len(v.Sections) == 0 || len(v.Sections[0].Items) == 0 {
|
||||
t.Fatalf("expected sections with items")
|
||||
}
|
||||
}
|
||||
|
||||
func TestShowFormatConflict_Error(t *testing.T) {
|
||||
out, err := RunWithTestContext(ShowSourcesCommand, []string{"sources", "--json", "--csv"})
|
||||
if err == nil {
|
||||
t.Fatalf("expected error for conflicting flags, got nil; output=%s", out)
|
||||
}
|
||||
// Expect an ExitCoder with code 2
|
||||
if ec, ok := err.(interface{ ExitCode() int }); ok {
|
||||
if ec.ExitCode() != 2 {
|
||||
t.Fatalf("expected exit code 2, got %d", ec.ExitCode())
|
||||
}
|
||||
} else {
|
||||
t.Fatalf("expected exit coder error, got %T: %v", err, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestShowConfigOptions_MarkdownSections(t *testing.T) {
|
||||
out, err := RunWithTestContext(ShowConfigOptionsCommand, []string{"config-options", "--md"})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !strings.Contains(out, "### Authentication") {
|
||||
t.Fatalf("expected Markdown section heading '### Authentication' in output\n%s", out[:min(400, len(out))])
|
||||
}
|
||||
}
|
||||
|
||||
func min(a, b int) int {
|
||||
if a < b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func TestShowFileFormats_JSON(t *testing.T) {
|
||||
out, err := RunWithTestContext(ShowFileFormatsCommand, []string{"file-formats", "--json"})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
var v []map[string]string
|
||||
if err := json.Unmarshal([]byte(out), &v); err != nil {
|
||||
t.Fatalf("invalid json: %v\n%s", err, out)
|
||||
}
|
||||
if len(v) == 0 {
|
||||
t.Fatalf("expected at least one item")
|
||||
}
|
||||
// Keys depend on report settings in command: should include format, description, type, extensions
|
||||
if _, ok := v[0]["format"]; !ok {
|
||||
t.Fatalf("expected key 'format' in first item")
|
||||
}
|
||||
if _, ok := v[0]["type"]; !ok {
|
||||
t.Fatalf("expected key 'type' in first item")
|
||||
}
|
||||
if _, ok := v[0]["extensions"]; !ok {
|
||||
t.Fatalf("expected key 'extensions' in first item")
|
||||
}
|
||||
}
|
||||
|
||||
func TestShowVideoSizes_JSON(t *testing.T) {
|
||||
out, err := RunWithTestContext(ShowVideoSizesCommand, []string{"video-sizes", "--json"})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
var v []map[string]string
|
||||
if err := json.Unmarshal([]byte(out), &v); err != nil {
|
||||
t.Fatalf("invalid json: %v\n%s", err, out)
|
||||
}
|
||||
if len(v) == 0 {
|
||||
t.Fatalf("expected at least one item")
|
||||
}
|
||||
if _, ok := v[0]["size"]; !ok {
|
||||
t.Fatalf("expected key 'size' in first item")
|
||||
}
|
||||
if _, ok := v[0]["usage"]; !ok {
|
||||
t.Fatalf("expected key 'usage' in first item")
|
||||
}
|
||||
}
|
@@ -1,6 +1,7 @@
|
||||
package commands
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sort"
|
||||
|
||||
@@ -37,20 +38,36 @@ func showMetadataAction(ctx *cli.Context) error {
|
||||
})
|
||||
|
||||
// Output overview of supported metadata tags.
|
||||
format := report.CliFormat(ctx)
|
||||
format, ferr := report.CliFormatStrict(ctx)
|
||||
if ferr != nil {
|
||||
return ferr
|
||||
}
|
||||
if format == report.JSON {
|
||||
resp := struct {
|
||||
Items []map[string]string `json:"items"`
|
||||
Docs []map[string]string `json:"docs,omitempty"`
|
||||
}{
|
||||
Items: report.RowsToObjects(rows, cols),
|
||||
}
|
||||
if !ctx.Bool("short") {
|
||||
resp.Docs = report.RowsToObjects(meta.Docs, []string{"Namespace", "Documentation"})
|
||||
}
|
||||
b, err := json.Marshal(resp)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Println(string(b))
|
||||
return nil
|
||||
}
|
||||
|
||||
result, err := report.RenderFormat(rows, cols, format)
|
||||
|
||||
fmt.Println(result)
|
||||
|
||||
if err != nil || ctx.Bool("short") || format == report.TSV {
|
||||
return err
|
||||
}
|
||||
|
||||
// Documentation links for those who want to delve deeper.
|
||||
result, err = report.RenderFormat(meta.Docs, []string{"Namespace", "Documentation"}, format)
|
||||
|
||||
fmt.Printf("## Metadata Tags by Namespace ##\n\n")
|
||||
fmt.Println(result)
|
||||
|
||||
return err
|
||||
}
|
||||
|
@@ -30,9 +30,11 @@ func showSearchFiltersAction(ctx *cli.Context) error {
|
||||
}
|
||||
})
|
||||
|
||||
result, err := report.RenderFormat(rows, cols, report.CliFormat(ctx))
|
||||
|
||||
format, ferr := report.CliFormatStrict(ctx)
|
||||
if ferr != nil {
|
||||
return ferr
|
||||
}
|
||||
result, err := report.RenderFormat(rows, cols, format)
|
||||
fmt.Println(result)
|
||||
|
||||
return err
|
||||
}
|
||||
|
@@ -20,10 +20,11 @@ var ShowSourcesCommand = &cli.Command{
|
||||
// showSourcesAction displays supported metadata sources.
|
||||
func showSourcesAction(ctx *cli.Context) error {
|
||||
rows, cols := entity.SrcPriority.Report()
|
||||
|
||||
result, err := report.RenderFormat(rows, cols, report.CliFormat(ctx))
|
||||
|
||||
format, ferr := report.CliFormatStrict(ctx)
|
||||
if ferr != nil {
|
||||
return ferr
|
||||
}
|
||||
result, err := report.RenderFormat(rows, cols, format)
|
||||
fmt.Println(result)
|
||||
|
||||
return err
|
||||
}
|
||||
|
@@ -21,10 +21,11 @@ var ShowThumbSizesCommand = &cli.Command{
|
||||
// showThumbSizesAction displays supported standard thumbnail sizes.
|
||||
func showThumbSizesAction(ctx *cli.Context) error {
|
||||
rows, cols := thumb.Report(thumb.Sizes.All(), false)
|
||||
|
||||
result, err := report.RenderFormat(rows, cols, report.CliFormat(ctx))
|
||||
|
||||
format, ferr := report.CliFormatStrict(ctx)
|
||||
if ferr != nil {
|
||||
return ferr
|
||||
}
|
||||
result, err := report.RenderFormat(rows, cols, format)
|
||||
fmt.Println(result)
|
||||
|
||||
return err
|
||||
}
|
||||
|
@@ -20,10 +20,11 @@ var ShowVideoSizesCommand = &cli.Command{
|
||||
// showVideoSizesAction displays supported standard video sizes.
|
||||
func showVideoSizesAction(ctx *cli.Context) error {
|
||||
rows, cols := thumb.Report(thumb.VideoSizes, true)
|
||||
|
||||
result, err := report.RenderFormat(rows, cols, report.CliFormat(ctx))
|
||||
|
||||
format, ferr := report.CliFormatStrict(ctx)
|
||||
if ferr != nil {
|
||||
return ferr
|
||||
}
|
||||
result, err := report.RenderFormat(rows, cols, format)
|
||||
fmt.Println(result)
|
||||
|
||||
return err
|
||||
}
|
||||
|
@@ -444,23 +444,15 @@ func TestConfig_OriginalsPath2(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestConfig_OriginalsDeletable(t *testing.T) {
|
||||
c := NewConfig(CliTestContext())
|
||||
c := TestConfig()
|
||||
|
||||
c.Settings().Features.Delete = true
|
||||
c.options.ReadOnly = false
|
||||
c.Options().ReadOnly = false
|
||||
c.AssertTestData(t)
|
||||
|
||||
assert.True(t, c.OriginalsDeletable())
|
||||
}
|
||||
|
||||
func TestConfig_ImportPath2(t *testing.T) {
|
||||
c := NewConfig(CliTestContext())
|
||||
assert.Equal(t, "/go/src/github.com/photoprism/photoprism/storage/testdata/import", c.ImportPath())
|
||||
c.options.ImportPath = ""
|
||||
if s := c.ImportPath(); s != "" && s != "/photoprism/import" {
|
||||
t.Errorf("unexpected import path: %s", s)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfig_ImportAllow(t *testing.T) {
|
||||
c := NewConfig(CliTestContext())
|
||||
|
||||
|
@@ -161,10 +161,19 @@ func TestConfig_OriginalsPath(t *testing.T) {
|
||||
|
||||
func TestConfig_ImportPath(t *testing.T) {
|
||||
c := NewConfig(CliTestContext())
|
||||
c.AssertTestData(t)
|
||||
|
||||
assert.Equal(t, "/go/src/github.com/photoprism/photoprism/storage/testdata/import", c.ImportPath())
|
||||
result := c.ImportPath()
|
||||
assert.True(t, strings.HasPrefix(result, "/"))
|
||||
assert.True(t, strings.HasSuffix(result, "/storage/testdata/import"))
|
||||
|
||||
c.options.ImportPath = ""
|
||||
if s := c.ImportPath(); s != "" && s != "/photoprism/import" {
|
||||
t.Errorf("unexpected import path: %s", s)
|
||||
}
|
||||
|
||||
c.options.ImportPath = result
|
||||
}
|
||||
|
||||
func TestConfig_CachePath(t *testing.T) {
|
||||
|
@@ -12,3 +12,12 @@ func TestConfig_InitializeTestData(t *testing.T) {
|
||||
err := c.InitializeTestData()
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestConfig_AssertTestData(t *testing.T) {
|
||||
c := NewConfig(CliTestContext())
|
||||
// Ensure fixtures are initialized, then verify required directories.
|
||||
if err := c.InitializeTestData(); err != nil {
|
||||
t.Fatalf("InitializeTestData failed: %v", err)
|
||||
}
|
||||
c.AssertTestData(t)
|
||||
}
|
||||
|
@@ -8,6 +8,7 @@ import (
|
||||
"regexp"
|
||||
"strconv"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/urfave/cli/v2"
|
||||
@@ -22,6 +23,7 @@ import (
|
||||
"github.com/photoprism/photoprism/pkg/clean"
|
||||
"github.com/photoprism/photoprism/pkg/fs"
|
||||
"github.com/photoprism/photoprism/pkg/rnd"
|
||||
"github.com/photoprism/photoprism/pkg/txt/report"
|
||||
)
|
||||
|
||||
// Download URL and ZIP hash for test files.
|
||||
@@ -161,6 +163,9 @@ func TestConfig() *Config {
|
||||
}
|
||||
|
||||
// NewTestConfig returns a valid test config.
|
||||
//
|
||||
// NewTestConfig initializes test data so required directories exist before tests run.
|
||||
// See AGENTS.md (Test Data & Fixtures) and specs/dev/backend-testing.md for guidance.
|
||||
func NewTestConfig(pkg string) *Config {
|
||||
defer log.Debug(capture.Time(time.Now(), "config: new test config created"))
|
||||
|
||||
@@ -187,6 +192,10 @@ func NewTestConfig(pkg string) *Config {
|
||||
log.Fatalf("config: %s", err.Error())
|
||||
}
|
||||
|
||||
if err := c.InitializeTestData(); err != nil {
|
||||
log.Fatalf("config: %s", err.Error())
|
||||
}
|
||||
|
||||
c.RegisterDb()
|
||||
c.InitTestDb()
|
||||
|
||||
@@ -358,26 +367,89 @@ func (c *Config) UnzipTestData() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// InitializeTestData resets the test file directory.
|
||||
// InitializeTestData resets "storage/testdata" to a clean state.
|
||||
//
|
||||
// The function removes prior artifacts, downloads fixtures when missing,
|
||||
// unzips them, and then calls CreateDirectories so required directories exist.
|
||||
// See AGENTS.md (Test Data & Fixtures) for details.
|
||||
func (c *Config) InitializeTestData() (err error) {
|
||||
testDataMutex.Lock()
|
||||
defer testDataMutex.Unlock()
|
||||
|
||||
start := time.Now()
|
||||
|
||||
// Delete existing test files and directories in "storage/testdata".
|
||||
if err = c.RemoveTestData(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// If the test file archive "/tmp/photoprism/testdata.zip" is missing,
|
||||
// download it from https://dl.photoprism.app/qa/testdata.zip.
|
||||
if err = c.DownloadTestData(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Extract "/tmp/photoprism/testdata.zip" in "storage/testdata".
|
||||
if err = c.UnzipTestData(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Make sure all the required directories exist in "storage/testdata.
|
||||
if err = c.CreateDirectories(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
log.Infof("config: initialized test data [%s]", time.Since(start))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// AssertTestData verifies the existence of the required test directories in "storage/testdata".
|
||||
//
|
||||
// Use this helper early in tests when diagnosing fixture setup issues. It logs
|
||||
// presence/emptiness of required directories to testing.T. See the backend testing
|
||||
// guide for additional patterns.
|
||||
func (c *Config) AssertTestData(t *testing.T) {
|
||||
reportDir := func(dir string) {
|
||||
if fs.PathExists(dir) {
|
||||
t.Logf("testdata: dir %s exists (%s)", clean.Log(dir),
|
||||
report.Bool(fs.DirIsEmpty(dir), "empty", "not empty"))
|
||||
} else {
|
||||
t.Logf("testdata: dir %s is missing %s, but required", clean.Log(dir), report.CrossMark)
|
||||
}
|
||||
}
|
||||
|
||||
reportErr := func(funcName string) {
|
||||
t.Errorf("testdata: *Config.%s() must not return an empty string %s", funcName, report.CrossMark)
|
||||
}
|
||||
|
||||
if dir := c.AssetsPath(); dir != "" {
|
||||
reportDir(dir)
|
||||
} else {
|
||||
reportErr("AssetsPath")
|
||||
}
|
||||
|
||||
if dir := c.ConfigPath(); dir != "" {
|
||||
reportDir(dir)
|
||||
} else {
|
||||
reportErr("ConfigPath")
|
||||
}
|
||||
|
||||
if dir := c.ImportPath(); dir != "" {
|
||||
reportDir(dir)
|
||||
} else {
|
||||
reportErr("ImportPath")
|
||||
}
|
||||
|
||||
if dir := c.OriginalsPath(); dir != "" {
|
||||
reportDir(dir)
|
||||
} else {
|
||||
reportErr("OriginalsPath")
|
||||
}
|
||||
|
||||
if dir := c.SidecarPath(); dir != "" {
|
||||
reportDir(dir)
|
||||
} else {
|
||||
reportErr("SidecarPath")
|
||||
}
|
||||
}
|
||||
|
@@ -17,7 +17,8 @@ func TestConvert_FixJpeg(t *testing.T) {
|
||||
}
|
||||
|
||||
cnf := config.TestConfig()
|
||||
cnf.InitializeTestData()
|
||||
initErr := cnf.InitializeTestData()
|
||||
assert.NoError(t, initErr)
|
||||
convert := NewConvert(cnf)
|
||||
|
||||
t.Run("elephants.jpg", func(t *testing.T) {
|
||||
|
@@ -18,7 +18,8 @@ func TestConvert_ToImage(t *testing.T) {
|
||||
}
|
||||
|
||||
cnf := config.TestConfig()
|
||||
cnf.InitializeTestData()
|
||||
initErr := cnf.InitializeTestData()
|
||||
assert.NoError(t, initErr)
|
||||
convert := NewConvert(cnf)
|
||||
|
||||
t.Run("Video", func(t *testing.T) {
|
||||
|
@@ -26,7 +26,8 @@ func TestConvert_Start(t *testing.T) {
|
||||
|
||||
c := config.TestConfig()
|
||||
|
||||
c.InitializeTestData()
|
||||
initErr := c.InitializeTestData()
|
||||
assert.NoError(t, initErr)
|
||||
|
||||
convert := NewConvert(c)
|
||||
|
||||
|
@@ -22,9 +22,8 @@ func TestNewImport(t *testing.T) {
|
||||
func TestImport_DestinationFilename(t *testing.T) {
|
||||
cfg := config.TestConfig()
|
||||
|
||||
if err := cfg.InitializeTestData(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
initErr := cfg.InitializeTestData()
|
||||
assert.NoError(t, initErr)
|
||||
|
||||
convert := NewConvert(cfg)
|
||||
|
||||
@@ -66,7 +65,8 @@ func TestImport_Start(t *testing.T) {
|
||||
|
||||
cfg := config.TestConfig()
|
||||
|
||||
cfg.InitializeTestData()
|
||||
initErr := cfg.InitializeTestData()
|
||||
assert.NoError(t, initErr)
|
||||
|
||||
convert := NewConvert(cfg)
|
||||
|
||||
|
@@ -13,9 +13,8 @@ func TestImportWorker_OriginalFileNames(t *testing.T) {
|
||||
// settings/paths from the code under test.
|
||||
cfg := Config()
|
||||
|
||||
if err := cfg.InitializeTestData(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
initErr := cfg.InitializeTestData()
|
||||
assert.NoError(t, initErr)
|
||||
|
||||
convert := NewConvert(cfg)
|
||||
ind := NewIndex(cfg, convert, NewFiles(), NewPhotos())
|
||||
|
@@ -17,7 +17,8 @@ func TestIndex_MediaFile(t *testing.T) {
|
||||
t.Run("flash.jpg", func(t *testing.T) {
|
||||
cfg := config.TestConfig()
|
||||
|
||||
cfg.InitializeTestData()
|
||||
initErr := cfg.InitializeTestData()
|
||||
assert.NoError(t, initErr)
|
||||
|
||||
convert := NewConvert(cfg)
|
||||
|
||||
@@ -51,7 +52,8 @@ func TestIndex_MediaFile(t *testing.T) {
|
||||
t.Run("blue-go-video.mp4", func(t *testing.T) {
|
||||
cfg := config.TestConfig()
|
||||
|
||||
cfg.InitializeTestData()
|
||||
initErr := cfg.InitializeTestData()
|
||||
assert.NoError(t, initErr)
|
||||
|
||||
convert := NewConvert(cfg)
|
||||
|
||||
@@ -71,7 +73,8 @@ func TestIndex_MediaFile(t *testing.T) {
|
||||
t.Run("Error", func(t *testing.T) {
|
||||
cfg := config.TestConfig()
|
||||
|
||||
cfg.InitializeTestData()
|
||||
initErr := cfg.InitializeTestData()
|
||||
assert.NoError(t, initErr)
|
||||
|
||||
convert := NewConvert(cfg)
|
||||
|
||||
|
@@ -16,7 +16,8 @@ func TestIndex_Start(t *testing.T) {
|
||||
}
|
||||
|
||||
cfg := config.TestConfig()
|
||||
cfg.InitializeTestData()
|
||||
initErr := cfg.InitializeTestData()
|
||||
assert.NoError(t, initErr)
|
||||
|
||||
convert := NewConvert(cfg)
|
||||
ind := NewIndex(cfg, convert, NewFiles(), NewPhotos())
|
||||
@@ -60,7 +61,8 @@ func TestIndex_File(t *testing.T) {
|
||||
}
|
||||
|
||||
cfg := config.TestConfig()
|
||||
cfg.InitializeTestData()
|
||||
initErr := cfg.InitializeTestData()
|
||||
assert.NoError(t, initErr)
|
||||
|
||||
convert := NewConvert(cfg)
|
||||
ind := NewIndex(cfg, convert, NewFiles(), NewPhotos())
|
||||
|
@@ -25,7 +25,8 @@ func TestResample_Start(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
cfg.InitializeTestData()
|
||||
initErr := cfg.InitializeTestData()
|
||||
assert.NoError(t, initErr)
|
||||
|
||||
convert := NewConvert(cfg)
|
||||
ind := NewIndex(cfg, convert, NewFiles(), NewPhotos())
|
||||
|
@@ -1,9 +1,13 @@
|
||||
package report
|
||||
|
||||
import "github.com/urfave/cli/v2"
|
||||
import (
|
||||
"github.com/urfave/cli/v2"
|
||||
)
|
||||
|
||||
func CliFormat(ctx *cli.Context) Format {
|
||||
switch {
|
||||
case ctx.Bool("json"):
|
||||
return JSON
|
||||
case ctx.Bool("md"), ctx.Bool("markdown"):
|
||||
return Markdown
|
||||
case ctx.Bool("tsv"):
|
||||
@@ -15,7 +19,34 @@ func CliFormat(ctx *cli.Context) Format {
|
||||
}
|
||||
}
|
||||
|
||||
// CliFormatStrict selects a single output format from flags and returns
|
||||
// a usage error (exit code 2) if multiple format flags are provided.
|
||||
func CliFormatStrict(ctx *cli.Context) (Format, error) {
|
||||
count := 0
|
||||
if ctx.Bool("json") {
|
||||
count++
|
||||
}
|
||||
if ctx.Bool("md") || ctx.Bool("markdown") {
|
||||
count++
|
||||
}
|
||||
if ctx.Bool("tsv") {
|
||||
count++
|
||||
}
|
||||
if ctx.Bool("csv") {
|
||||
count++
|
||||
}
|
||||
if count > 1 {
|
||||
return Default, cli.Exit("choose exactly one output format: --json | --md | --csv | --tsv", 2)
|
||||
}
|
||||
return CliFormat(ctx), nil
|
||||
}
|
||||
|
||||
var CliFlags = []cli.Flag{
|
||||
&cli.BoolFlag{
|
||||
Name: "json",
|
||||
Aliases: []string{"j"},
|
||||
Usage: "print machine-readable JSON",
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "md",
|
||||
Aliases: []string{"m"},
|
||||
|
@@ -6,4 +6,6 @@ const (
|
||||
Yes = "Yes"
|
||||
No = "No"
|
||||
NotAssigned = "n/a"
|
||||
CheckMark = "✅"
|
||||
CrossMark = "❌"
|
||||
)
|
||||
|
@@ -7,4 +7,5 @@ const (
|
||||
Markdown = "markdown"
|
||||
TSV = "tsv"
|
||||
CSV = "csv"
|
||||
JSON = "json"
|
||||
)
|
||||
|
38
pkg/txt/report/json.go
Normal file
38
pkg/txt/report/json.go
Normal file
@@ -0,0 +1,38 @@
|
||||
package report
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
)
|
||||
|
||||
// RowsToObjects converts a table (rows + column names) into a slice of
|
||||
// objects keyed by canonicalized column names.
|
||||
func RowsToObjects(rows [][]string, cols []string) []map[string]string {
|
||||
out := make([]map[string]string, 0, len(rows))
|
||||
keys := make([]string, len(cols))
|
||||
for i, c := range cols {
|
||||
keys[i] = CanonKey(c)
|
||||
}
|
||||
for _, r := range rows {
|
||||
obj := make(map[string]string, len(keys))
|
||||
for i := range keys {
|
||||
val := ""
|
||||
if i < len(r) {
|
||||
val = r[i]
|
||||
}
|
||||
obj[keys[i]] = val
|
||||
}
|
||||
out = append(out, obj)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// JSONExport returns a JSON string for a single-table report as a top-level
|
||||
// array of objects keyed by canonicalized column names.
|
||||
func JSONExport(rows [][]string, cols []string) (string, error) {
|
||||
data := RowsToObjects(rows, cols)
|
||||
b, err := json.Marshal(data)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(b), nil
|
||||
}
|
24
pkg/txt/report/keys.go
Normal file
24
pkg/txt/report/keys.go
Normal file
@@ -0,0 +1,24 @@
|
||||
package report
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var nonAlnum = regexp.MustCompile(`[^a-z0-9_]+`)
|
||||
var underscores = regexp.MustCompile(`_+`)
|
||||
|
||||
// CanonKey converts a column title into a stable snake_case key suitable
|
||||
// for JSON output. It lowercases, replaces spaces/hyphens/slashes with '_',
|
||||
// removes other punctuation, collapses repeats, and trims edges.
|
||||
func CanonKey(s string) string {
|
||||
k := strings.ToLower(s)
|
||||
k = strings.NewReplacer(" ", "_", "-", "_", "/", "_").Replace(k)
|
||||
k = nonAlnum.ReplaceAllString(k, "_")
|
||||
k = underscores.ReplaceAllString(k, "_")
|
||||
k = strings.Trim(k, "_")
|
||||
if k == "" {
|
||||
return "col"
|
||||
}
|
||||
return k
|
||||
}
|
@@ -10,6 +10,8 @@ import (
|
||||
// so the output can be pasted into the docs.
|
||||
func RenderFormat(rows [][]string, cols []string, format Format) (string, error) {
|
||||
switch format {
|
||||
case JSON:
|
||||
return JSONExport(rows, cols)
|
||||
case CSV:
|
||||
return Render(rows, cols, Options{Format: CSV})
|
||||
case TSV:
|
||||
@@ -27,6 +29,8 @@ func RenderFormat(rows [][]string, cols []string, format Format) (string, error)
|
||||
// so the output can be pasted into the docs.
|
||||
func Render(rows [][]string, cols []string, opt Options) (string, error) {
|
||||
switch opt.Format {
|
||||
case JSON:
|
||||
return JSONExport(rows, cols)
|
||||
case CSV:
|
||||
return CsvExport(rows, cols, ';')
|
||||
case TSV:
|
||||
|
Reference in New Issue
Block a user