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:
Michael Mayer
2025-09-21 13:46:59 +02:00
parent ecdec6b408
commit 6901225a2b
38 changed files with 605 additions and 95 deletions

View File

@@ -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:

View File

@@ -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,
}

View File

@@ -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,
}

View File

@@ -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,
}

View File

@@ -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,
}

View File

@@ -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,
}

View File

@@ -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,
}

View File

@@ -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,
},

View File

@@ -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")

View File

@@ -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"},

View File

@@ -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
}

View File

@@ -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

View File

@@ -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

View File

@@ -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
}

View 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")
}
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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())

View File

@@ -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) {

View File

@@ -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)
}

View File

@@ -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")
}
}

View File

@@ -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) {

View File

@@ -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) {

View File

@@ -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)

View File

@@ -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)

View File

@@ -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())

View File

@@ -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)

View File

@@ -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())

View File

@@ -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())

View File

@@ -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"},

View File

@@ -6,4 +6,6 @@ const (
Yes = "Yes"
No = "No"
NotAssigned = "n/a"
CheckMark = "✅"
CrossMark = "❌"
)

View File

@@ -7,4 +7,5 @@ const (
Markdown = "markdown"
TSV = "tsv"
CSV = "csv"
JSON = "json"
)

38
pkg/txt/report/json.go Normal file
View 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
View 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
}

View File

@@ -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: