CLI: Flatten config options output when using the "--json" flag #5220

Signed-off-by: Michael Mayer <michael@photoprism.app>
This commit is contained in:
Michael Mayer
2025-09-21 16:52:56 +02:00
parent 25253afcf2
commit f1c57c72d8
14 changed files with 106 additions and 84 deletions

View File

@@ -192,6 +192,10 @@ If anything in this file conflicts with the `Makefile` or the Developer Guide, t
- Capture output with `RunWithTestContext`; usage and report values may be quoted and reordered (e.g., set semantics). Use substring checks or regex for the final ", or <last>" rule from `CliUsageString`.
- Prefer JSON output (`--json`) for stable machine assertions when commands offer it.
- JSON shapes for `show` commands:
- Most return a toplevel array of row objects (keys = snake_case columns).
- `photoprism show config` returns `{ sections: [{ title, items[] }] }`.
- `photoprism show config-options --json` and `photoprism show config-yaml --json` return a flat toplevel array (no `sections`).
### API Development & Config Options

View File

@@ -50,8 +50,8 @@ Configuration & Flags
- If needed: `yaml:"-"` disables YAML processing; `flag:"-"` prevents `ApplyCliContext()` from assigning CLI values (flags/env variables) to a field, without affecting the flags in `internal/config/flags.go`.
- Annotations may include edition tags like `tags:"plus,pro"` to control visibility (see `internal/config/options_report.go` logic).
- Global flags/env: `internal/config/flags.go` (`EnvVars(...)`)
- Available flags/env: `internal/config/cli_flags_report.go` + `internal/config/report_sections.go` → surfaced by `photoprism show config-options --md`
- YAML options mapping: `internal/config/options_report.go` + `internal/config/report_sections.go` → surfaced by `photoprism show config-yaml --md`
- Available flags/env: `internal/config/cli_flags_report.go` + `internal/config/report_sections.go` → surfaced by `photoprism show config-options --md/--json`
- YAML options mapping: `internal/config/options_report.go` + `internal/config/report_sections.go` → surfaced by `photoprism show config-yaml --md/--json`
- Report current values: `internal/config/report.go` → surfaced by `photoprism show config` (alias `photoprism config --md`).
- Precedence: `defaults.yml` < CLI/env < `options.yml` (global options rule). See Agent Tips in `AGENTS.md`.
- Getters are grouped by topic, e.g. DB in `internal/config/config_db.go`, server in `config_server.go`, TLS in `config_tls.go`, etc.

View File

@@ -4,12 +4,6 @@ import (
"github.com/urfave/cli/v2"
)
// JsonFlag enables machine-readable JSON output for cluster commands.
var JsonFlag = &cli.BoolFlag{
Name: "json",
Usage: "print machine-readable JSON",
}
// OffsetFlag for pagination offset (>= 0).
var OffsetFlag = &cli.IntFlag{
Name: "offset",

View File

@@ -41,9 +41,9 @@ func showConfigAction(ctx *cli.Context) error {
log.Debug(err)
}
format, ferr := report.CliFormatStrict(ctx)
if ferr != nil {
return ferr
format, formatErr := report.CliFormatStrict(ctx)
if formatErr != nil {
return formatErr
}
if format == report.JSON {
@@ -65,8 +65,10 @@ func showConfigAction(ctx *cli.Context) error {
rows, cols := rep.Report(conf)
opt := report.Options{Format: format, NoWrap: rep.NoWrap}
result, _ := report.Render(rows, cols, opt)
if opt.Format == report.Default {
fmt.Printf("\n%s\n\n", strings.ToUpper(rep.Title))
if opt.Format == report.Markdown {
fmt.Printf("### %s\n\n", rep.Title)
} else if opt.Format == report.Default {
fmt.Printf("%s\n\n", strings.ToUpper(rep.Title))
}
fmt.Println(result)
}

View File

@@ -1,7 +1,6 @@
package commands
import (
"encoding/json"
"fmt"
"strings"
@@ -14,10 +13,11 @@ import (
// ShowConfigOptionsCommand configures the command name, flags, and action.
var ShowConfigOptionsCommand = &cli.Command{
Name: "config-options",
Usage: "Displays supported environment variables and CLI flags",
Flags: report.CliFlags,
Action: showConfigOptionsAction,
Name: "config-options",
Usage: "Displays supported environment variables and CLI flags",
Description: "For readability, standard and Markdown text output is divided into sections. The --json, --csv, and --tsv options return a flat list.",
Flags: report.CliFlags,
Action: showConfigOptionsAction,
}
// showConfigOptionsAction displays supported environment variables and CLI flags.
@@ -26,20 +26,20 @@ 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
format, formatErr := report.CliFormatStrict(ctx)
if formatErr != nil {
return formatErr
}
// CSV/TSV exports use default single-table rendering
if format == report.CSV || format == report.TSV {
// CSV/TSV/JSON exports use default single-table rendering.
if format == report.CSV || format == report.TSV || format == report.JSON {
result, err := report.RenderFormat(rows, cols, format)
fmt.Println(result)
return err
}
// JSON aggregation path
if format == report.JSON {
// JSON aggregation path (commented out because non-nested output is preferred for now).
/* if format == report.JSON {
type section struct {
Title string `json:"title"`
Info string `json:"info,omitempty"`
@@ -72,7 +72,7 @@ func showConfigOptionsAction(ctx *cli.Context) error {
b, _ := json.Marshal(map[string]interface{}{"sections": agg})
fmt.Println(string(b))
return nil
}
} */
markDown := ctx.Bool("md")
sections := config.OptionsReportSections
@@ -113,7 +113,7 @@ func showConfigOptionsAction(ctx *cli.Context) error {
}
}
// JSON handled earlier; Markdown and default render per section below
// JSON handled earlier; Markdown and default render per section below.
result, err := report.RenderFormat(secRows, cols, format)
if err != nil {

View File

@@ -1,7 +1,6 @@
package commands
import (
"encoding/json"
"fmt"
"strings"
@@ -14,10 +13,11 @@ import (
// ShowConfigYamlCommand configures the command name, flags, and action.
var ShowConfigYamlCommand = &cli.Command{
Name: "config-yaml",
Usage: "Displays supported YAML config options and CLI flags",
Flags: report.CliFlags,
Action: showConfigYamlAction,
Name: "config-yaml",
Usage: "Displays supported YAML config options and CLI flags",
Description: "For readability, standard and Markdown text output is divided into sections. The --json, --csv, and --tsv options return a flat list.",
Flags: report.CliFlags,
Action: showConfigYamlAction,
}
// showConfigYamlAction displays supported YAML config options and CLI flag.
@@ -26,20 +26,20 @@ 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
format, formatErr := report.CliFormatStrict(ctx)
if formatErr != nil {
return formatErr
}
// CSV/TSV exports use default single-table rendering
if format == report.CSV || format == report.TSV {
// CSV/TSV/JSON exports use default single-table rendering.
if format == report.CSV || format == report.TSV || format == report.JSON {
result, err := report.RenderFormat(rows, cols, format)
fmt.Println(result)
return err
}
// JSON aggregation path
if format == report.JSON {
// JSON aggregation path (commented out because non-nested output is preferred for now).
/* if format == report.JSON {
type section struct {
Title string `json:"title"`
Info string `json:"info,omitempty"`
@@ -72,7 +72,7 @@ func showConfigYamlAction(ctx *cli.Context) error {
b, _ := json.Marshal(map[string]interface{}{"sections": agg})
fmt.Println(string(b))
return nil
}
} */
markDown := ctx.Bool("md")
sections := config.YamlReportSections
@@ -113,7 +113,7 @@ func showConfigYamlAction(ctx *cli.Context) error {
}
}
// JSON handled earlier; Markdown and default render per section below
// JSON handled earlier; Markdown and default render per section below.
result, err := report.RenderFormat(secRows, cols, format)
if err != nil {

View File

@@ -26,9 +26,9 @@ 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)
format, ferr := report.CliFormatStrict(ctx)
if ferr != nil {
return ferr
format, formatErr := report.CliFormatStrict(ctx)
if formatErr != nil {
return formatErr
}
result, err := report.RenderFormat(rows, cols, format)
fmt.Println(result)

View File

@@ -8,11 +8,14 @@ import (
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 {
if err = json.Unmarshal([]byte(out), &v); err != nil {
t.Fatalf("invalid json: %v\n%s", err, out)
}
if len(v) == 0 {
@@ -28,11 +31,14 @@ func TestShowThumbSizes_JSON(t *testing.T) {
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 {
if err = json.Unmarshal([]byte(out), &v); err != nil {
t.Fatalf("invalid json: %v\n%s", err, out)
}
if len(v) == 0 {
@@ -48,16 +54,20 @@ func TestShowSources_JSON(t *testing.T) {
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 {
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")
}
@@ -65,18 +75,22 @@ func TestShowMetadata_JSON(t *testing.T) {
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 {
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")
}
@@ -84,47 +98,49 @@ func TestShowConfig_JSON(t *testing.T) {
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 {
type options = []map[string]string
var v = options{}
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 {
if len(v) == 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 {
type options = []map[string]string
var v = options{}
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 {
if len(v) == 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 {
@@ -154,11 +170,14 @@ func min(a, b int) int {
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 {
if err = json.Unmarshal([]byte(out), &v); err != nil {
t.Fatalf("invalid json: %v\n%s", err, out)
}
if len(v) == 0 {
@@ -178,11 +197,14 @@ func TestShowFileFormats_JSON(t *testing.T) {
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 {
if err = json.Unmarshal([]byte(out), &v); err != nil {
t.Fatalf("invalid json: %v\n%s", err, out)
}
if len(v) == 0 {

View File

@@ -38,9 +38,9 @@ func showMetadataAction(ctx *cli.Context) error {
})
// Output overview of supported metadata tags.
format, ferr := report.CliFormatStrict(ctx)
if ferr != nil {
return ferr
format, formatErr := report.CliFormatStrict(ctx)
if formatErr != nil {
return formatErr
}
if format == report.JSON {
resp := struct {

View File

@@ -30,9 +30,9 @@ func showSearchFiltersAction(ctx *cli.Context) error {
}
})
format, ferr := report.CliFormatStrict(ctx)
if ferr != nil {
return ferr
format, formatErr := report.CliFormatStrict(ctx)
if formatErr != nil {
return formatErr
}
result, err := report.RenderFormat(rows, cols, format)
fmt.Println(result)

View File

@@ -20,9 +20,9 @@ var ShowSourcesCommand = &cli.Command{
// showSourcesAction displays supported metadata sources.
func showSourcesAction(ctx *cli.Context) error {
rows, cols := entity.SrcPriority.Report()
format, ferr := report.CliFormatStrict(ctx)
if ferr != nil {
return ferr
format, formatErr := report.CliFormatStrict(ctx)
if formatErr != nil {
return formatErr
}
result, err := report.RenderFormat(rows, cols, format)
fmt.Println(result)

View File

@@ -21,9 +21,9 @@ var ShowThumbSizesCommand = &cli.Command{
// showThumbSizesAction displays supported standard thumbnail sizes.
func showThumbSizesAction(ctx *cli.Context) error {
rows, cols := thumb.Report(thumb.Sizes.All(), false)
format, ferr := report.CliFormatStrict(ctx)
if ferr != nil {
return ferr
format, formatErr := report.CliFormatStrict(ctx)
if formatErr != nil {
return formatErr
}
result, err := report.RenderFormat(rows, cols, format)
fmt.Println(result)

View File

@@ -20,9 +20,9 @@ var ShowVideoSizesCommand = &cli.Command{
// showVideoSizesAction displays supported standard video sizes.
func showVideoSizesAction(ctx *cli.Context) error {
rows, cols := thumb.Report(thumb.VideoSizes, true)
format, ferr := report.CliFormatStrict(ctx)
if ferr != nil {
return ferr
format, formatErr := report.CliFormatStrict(ctx)
if formatErr != nil {
return formatErr
}
result, err := report.RenderFormat(rows, cols, format)
fmt.Println(result)

View File

@@ -50,16 +50,16 @@ var CliFlags = []cli.Flag{
&cli.BoolFlag{
Name: "md",
Aliases: []string{"m"},
Usage: "format as machine-readable Markdown",
Usage: "print machine-readable Markdown",
},
&cli.BoolFlag{
Name: "csv",
Aliases: []string{"c"},
Usage: "export as semicolon separated values",
Usage: "print semicolon separated values",
},
&cli.BoolFlag{
Name: "tsv",
Aliases: []string{"t"},
Usage: "export as tab separated values",
Usage: "print tab separated values",
},
}