mirror of
https://github.com/photoprism/photoprism.git
synced 2025-12-12 08:44:04 +01:00
CLI: Add "photoprism show commands" command to generate CLI docs #5220
Signed-off-by: Michael Mayer <michael@photoprism.app>
This commit is contained in:
340
internal/commands/catalog/catalog.go
Normal file
340
internal/commands/catalog/catalog.go
Normal file
@@ -0,0 +1,340 @@
|
|||||||
|
/*
|
||||||
|
Package catalog provides DTOs, builders, and a templated renderer to export the PhotoPrism CLI command tree (and flags) as Markdown or JSON for documentation purposes.
|
||||||
|
|
||||||
|
Copyright (c) 2018 - 2025 PhotoPrism UG. All rights reserved.
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify
|
||||||
|
it under Version 3 of the GNU Affero General Public License (the "AGPL"):
|
||||||
|
<https://docs.photoprism.app/license/agpl>
|
||||||
|
|
||||||
|
This program is distributed in the hope that it will be useful,
|
||||||
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
GNU Affero General Public License for more details.
|
||||||
|
|
||||||
|
The AGPL is supplemented by our Trademark and Brand Guidelines,
|
||||||
|
which describe how our Brand Assets may be used:
|
||||||
|
<https://www.photoprism.app/trademark>
|
||||||
|
|
||||||
|
Feel free to send an email to hello@photoprism.app if you have questions,
|
||||||
|
want to support our work, or just want to say hello.
|
||||||
|
|
||||||
|
Additional information can be found in our Developer Guide:
|
||||||
|
<https://docs.photoprism.app/developer-guide/>
|
||||||
|
*/
|
||||||
|
package catalog
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"text/template"
|
||||||
|
|
||||||
|
"github.com/urfave/cli/v2"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Flag describes a CLI flag.
|
||||||
|
type Flag struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Aliases []string `json:"aliases,omitempty"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
Required bool `json:"required,omitempty"`
|
||||||
|
Default string `json:"default,omitempty"`
|
||||||
|
Env []string `json:"env,omitempty"`
|
||||||
|
Category string `json:"category,omitempty"`
|
||||||
|
Usage string `json:"usage,omitempty"`
|
||||||
|
Hidden bool `json:"hidden"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Command describes a CLI command (flat form).
|
||||||
|
type Command struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
FullName string `json:"full_name"`
|
||||||
|
Parent string `json:"parent,omitempty"`
|
||||||
|
Depth int `json:"depth"`
|
||||||
|
Usage string `json:"usage,omitempty"`
|
||||||
|
Description string `json:"description,omitempty"`
|
||||||
|
Category string `json:"category,omitempty"`
|
||||||
|
Aliases []string `json:"aliases,omitempty"`
|
||||||
|
ArgsUsage string `json:"args_usage,omitempty"`
|
||||||
|
Hidden bool `json:"hidden"`
|
||||||
|
Flags []Flag `json:"flags,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Node is a nested representation of commands.
|
||||||
|
type Node struct {
|
||||||
|
Command
|
||||||
|
Subcommands []Node `json:"subcommands,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// App carries app metadata for top-level JSON/MD.
|
||||||
|
type App struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Edition string `json:"edition"`
|
||||||
|
Version string `json:"version"`
|
||||||
|
Build string `json:"build,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// MarkdownData is the data model used by the Markdown template.
|
||||||
|
type MarkdownData struct {
|
||||||
|
App App
|
||||||
|
GeneratedAt string
|
||||||
|
BaseHeading int
|
||||||
|
Short bool
|
||||||
|
All bool
|
||||||
|
Commands []Command
|
||||||
|
}
|
||||||
|
|
||||||
|
// BuildFlat returns a depth-first flat list of commands starting at c.
|
||||||
|
func BuildFlat(c *cli.Command, depth int, parentFull string, includeHidden bool, global []Flag) []Command {
|
||||||
|
var out []Command
|
||||||
|
info := CommandInfo(c, depth, parentFull, includeHidden, global)
|
||||||
|
out = append(out, info)
|
||||||
|
for _, sub := range c.Subcommands {
|
||||||
|
if sub == nil || (sub.Hidden && !includeHidden) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, BuildFlat(sub, depth+1, info.FullName, includeHidden, global)...)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// BuildNode returns a nested representation of c and its subcommands.
|
||||||
|
func BuildNode(c *cli.Command, depth int, parentFull string, includeHidden bool, global []Flag) Node {
|
||||||
|
info := CommandInfo(c, depth, parentFull, includeHidden, global)
|
||||||
|
node := Node{Command: info}
|
||||||
|
for _, sub := range c.Subcommands {
|
||||||
|
if sub == nil || (sub.Hidden && !includeHidden) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
node.Subcommands = append(node.Subcommands, BuildNode(sub, depth+1, info.FullName, includeHidden, global))
|
||||||
|
}
|
||||||
|
return node
|
||||||
|
}
|
||||||
|
|
||||||
|
// CommandInfo converts a cli.Command to a Command DTO.
|
||||||
|
func CommandInfo(c *cli.Command, depth int, parentFull string, includeHidden bool, global []Flag) Command {
|
||||||
|
pathName := c.Name
|
||||||
|
fullName := strings.TrimSpace(parentFull + " " + pathName)
|
||||||
|
parent := parentFull
|
||||||
|
|
||||||
|
cmd := Command{
|
||||||
|
Name: pathName,
|
||||||
|
FullName: fullName,
|
||||||
|
Parent: parent,
|
||||||
|
Depth: depth,
|
||||||
|
Usage: c.Usage,
|
||||||
|
Description: strings.TrimSpace(c.Description),
|
||||||
|
Category: c.Category,
|
||||||
|
Aliases: c.Aliases,
|
||||||
|
ArgsUsage: c.ArgsUsage,
|
||||||
|
Hidden: c.Hidden,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build set of canonical global flag names to exclude from per-command flags
|
||||||
|
globalSet := map[string]struct{}{}
|
||||||
|
for _, gf := range global {
|
||||||
|
globalSet[strings.TrimLeft(gf.Name, "-")] = struct{}{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert flags and optionally filter hidden/global
|
||||||
|
flags := FlagsToCatalog(c.Flags, includeHidden)
|
||||||
|
keep := make([]Flag, 0, len(flags))
|
||||||
|
for _, f := range flags {
|
||||||
|
name := strings.TrimLeft(f.Name, "-")
|
||||||
|
if _, isGlobal := globalSet[name]; isGlobal {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !includeHidden && f.Hidden {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
keep = append(keep, f)
|
||||||
|
}
|
||||||
|
sort.Slice(keep, func(i, j int) bool { return keep[i].Name < keep[j].Name })
|
||||||
|
cmd.Flags = keep
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
// FlagsToCatalog converts cli flags to Flag DTOs, filtering hidden if needed.
|
||||||
|
func FlagsToCatalog(flags []cli.Flag, includeHidden bool) []Flag {
|
||||||
|
out := make([]Flag, 0, len(flags))
|
||||||
|
for _, f := range flags {
|
||||||
|
if f == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
cf := DescribeFlag(f)
|
||||||
|
if !includeHidden && cf.Hidden {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, cf)
|
||||||
|
}
|
||||||
|
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// DescribeFlag inspects a cli.Flag and returns a Flag with metadata.
|
||||||
|
func DescribeFlag(f cli.Flag) Flag {
|
||||||
|
// Names and aliases
|
||||||
|
names := f.Names()
|
||||||
|
primary := ""
|
||||||
|
aliases := make([]string, 0, len(names))
|
||||||
|
for i, n := range names {
|
||||||
|
if i == 0 {
|
||||||
|
primary = n
|
||||||
|
}
|
||||||
|
if primary == "" || (len(n) > 1 && primary != n) {
|
||||||
|
primary = n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, n := range names {
|
||||||
|
if n == primary {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if len(n) == 1 {
|
||||||
|
aliases = append(aliases, "-"+n)
|
||||||
|
} else {
|
||||||
|
aliases = append(aliases, "--"+n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type hasUsage interface{ GetUsage() string }
|
||||||
|
type hasCategory interface{ GetCategory() string }
|
||||||
|
type hasEnv interface{ GetEnvVars() []string }
|
||||||
|
type hasDefault interface{ GetDefaultText() string }
|
||||||
|
type hasRequired interface{ IsRequired() bool }
|
||||||
|
type hasVisible interface{ IsVisible() bool }
|
||||||
|
|
||||||
|
usage, category, def := "", "", ""
|
||||||
|
env := []string{}
|
||||||
|
required, hidden := false, false
|
||||||
|
if hf, ok := f.(hasUsage); ok {
|
||||||
|
usage = hf.GetUsage()
|
||||||
|
}
|
||||||
|
if hf, ok := f.(hasCategory); ok {
|
||||||
|
category = hf.GetCategory()
|
||||||
|
}
|
||||||
|
if hf, ok := f.(hasEnv); ok {
|
||||||
|
env = append(env, hf.GetEnvVars()...)
|
||||||
|
}
|
||||||
|
if hf, ok := f.(hasDefault); ok {
|
||||||
|
def = hf.GetDefaultText()
|
||||||
|
}
|
||||||
|
if hf, ok := f.(hasRequired); ok {
|
||||||
|
required = hf.IsRequired()
|
||||||
|
}
|
||||||
|
if hv, ok := f.(hasVisible); ok {
|
||||||
|
hidden = !hv.IsVisible()
|
||||||
|
}
|
||||||
|
|
||||||
|
t := flagTypeString(f)
|
||||||
|
name := primary
|
||||||
|
if len(name) == 1 {
|
||||||
|
name = "-" + name
|
||||||
|
} else {
|
||||||
|
name = "--" + name
|
||||||
|
}
|
||||||
|
|
||||||
|
return Flag{
|
||||||
|
Name: name, Aliases: aliases, Type: t, Required: required, Default: def,
|
||||||
|
Env: env, Category: category, Usage: usage, Hidden: hidden,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func flagTypeString(f cli.Flag) string {
|
||||||
|
switch f.(type) {
|
||||||
|
case *cli.BoolFlag:
|
||||||
|
return "bool"
|
||||||
|
case *cli.StringFlag:
|
||||||
|
return "string"
|
||||||
|
case *cli.IntFlag:
|
||||||
|
return "int"
|
||||||
|
case *cli.Int64Flag:
|
||||||
|
return "int64"
|
||||||
|
case *cli.UintFlag:
|
||||||
|
return "uint"
|
||||||
|
case *cli.Uint64Flag:
|
||||||
|
return "uint64"
|
||||||
|
case *cli.Float64Flag:
|
||||||
|
return "float64"
|
||||||
|
case *cli.DurationFlag:
|
||||||
|
return "duration"
|
||||||
|
case *cli.TimestampFlag:
|
||||||
|
return "timestamp"
|
||||||
|
case *cli.PathFlag:
|
||||||
|
return "path"
|
||||||
|
case *cli.StringSliceFlag:
|
||||||
|
return "stringSlice"
|
||||||
|
case *cli.IntSliceFlag:
|
||||||
|
return "intSlice"
|
||||||
|
case *cli.Int64SliceFlag:
|
||||||
|
return "int64Slice"
|
||||||
|
case *cli.Float64SliceFlag:
|
||||||
|
return "float64Slice"
|
||||||
|
case *cli.GenericFlag:
|
||||||
|
return "generic"
|
||||||
|
default:
|
||||||
|
return "unknown"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default Markdown template (adjustable in source via rebuild).
|
||||||
|
var commandsMDTemplate = `# {{ .App.Name }} CLI Commands ({{ .App.Edition }}) — {{ .App.Version }}
|
||||||
|
_Generated: {{ .GeneratedAt }}_
|
||||||
|
|
||||||
|
{{- $base := .BaseHeading -}}
|
||||||
|
{{- range .Commands }}
|
||||||
|
{{ heading (add $base (dec .Depth)) }} {{ .FullName }}
|
||||||
|
|
||||||
|
**Usage:** {{ .Usage }}
|
||||||
|
{{- if .Description }}
|
||||||
|
|
||||||
|
**Description:** {{ .Description }}
|
||||||
|
{{- end }}
|
||||||
|
{{- if .Aliases }}
|
||||||
|
|
||||||
|
**Aliases:** {{ join .Aliases ", " }}
|
||||||
|
{{- end }}
|
||||||
|
{{- if .ArgsUsage }}
|
||||||
|
|
||||||
|
**Args:** ` + "`" + `{{ .ArgsUsage }}` + "`" + `
|
||||||
|
{{- end }}
|
||||||
|
{{- if and (not $.Short) .Flags }}
|
||||||
|
|
||||||
|
| Flag | Aliases | Type | Default | Env | Required |{{ if $.All }} Hidden |{{ end }} Usage |
|
||||||
|
|:-----|:--------|:-----|:--------|:----|:---------|{{ if $.All }}:------:|{{ end }}:------|
|
||||||
|
{{- range .Flags }}
|
||||||
|
| ` + "`" + `{{ .Name }}` + "`" + ` | {{ join .Aliases ", " }} | {{ .Type }} | {{ .Default }} | {{ join .Env ", " }} | {{ .Required }} |{{ if $.All }} {{ .Hidden }} |{{ end }} {{ .Usage }} |
|
||||||
|
{{- end }}
|
||||||
|
{{- end }}
|
||||||
|
|
||||||
|
{{- end }}`
|
||||||
|
|
||||||
|
// RenderMarkdown renders the catalog to Markdown using the embedded template.
|
||||||
|
func RenderMarkdown(data MarkdownData) (string, error) {
|
||||||
|
tmpl, err := template.New("commands").Funcs(template.FuncMap{
|
||||||
|
"heading": func(n int) string {
|
||||||
|
if n < 1 {
|
||||||
|
n = 1
|
||||||
|
} else if n > 6 {
|
||||||
|
n = 6
|
||||||
|
}
|
||||||
|
return strings.Repeat("#", n)
|
||||||
|
},
|
||||||
|
"join": strings.Join,
|
||||||
|
"add": func(a, b int) int { return a + b },
|
||||||
|
"dec": func(a int) int {
|
||||||
|
if a > 0 {
|
||||||
|
return a - 1
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
},
|
||||||
|
}).Parse(commandsMDTemplate)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if err := tmpl.Execute(&buf, data); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return buf.String(), nil
|
||||||
|
}
|
||||||
169
internal/commands/catalog/catalog_test.go
Normal file
169
internal/commands/catalog/catalog_test.go
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
package catalog
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/urfave/cli/v2"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestFlagsToCatalog_Visibility(t *testing.T) {
|
||||||
|
flags := []cli.Flag{
|
||||||
|
&cli.StringFlag{Name: "config-path", Aliases: []string{"c"}, Usage: "config path", EnvVars: []string{"PHOTOPRISM_CONFIG_PATH"}},
|
||||||
|
&cli.BoolFlag{Name: "trace", Usage: "enable trace", Hidden: true},
|
||||||
|
&cli.IntFlag{Name: "count", Usage: "max results", Value: 5, Required: true},
|
||||||
|
}
|
||||||
|
|
||||||
|
vis := FlagsToCatalog(flags, false)
|
||||||
|
if len(vis) != 2 {
|
||||||
|
t.Fatalf("expected 2 visible flags, got %d", len(vis))
|
||||||
|
}
|
||||||
|
all := FlagsToCatalog(flags, true)
|
||||||
|
if len(all) != 3 {
|
||||||
|
t.Fatalf("expected 3 flags with --all, got %d", len(all))
|
||||||
|
}
|
||||||
|
// Check hidden is marked correctly when included
|
||||||
|
var hiddenOK bool
|
||||||
|
for _, f := range all {
|
||||||
|
if f.Name == "--trace" && f.Hidden {
|
||||||
|
hiddenOK = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !hiddenOK {
|
||||||
|
t.Fatalf("expected hidden flag '--trace' with hidden=true")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCommandInfo_GlobalFlagElimination(t *testing.T) {
|
||||||
|
// Define a command with a global-like flag and a local one
|
||||||
|
cmd := &cli.Command{
|
||||||
|
Name: "auth",
|
||||||
|
Flags: []cli.Flag{
|
||||||
|
&cli.BoolFlag{Name: "json"}, // should be filtered out as global
|
||||||
|
&cli.BoolFlag{Name: "force"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
globals := FlagsToCatalog([]cli.Flag{&cli.BoolFlag{Name: "json"}}, false)
|
||||||
|
info := CommandInfo(cmd, 1, "photoprism", false, globals)
|
||||||
|
if len(info.Flags) != 1 || info.Flags[0].Name != "--force" {
|
||||||
|
t.Fatalf("expected only '--force' flag, got %+v", info.Flags)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildFlatAndNode(t *testing.T) {
|
||||||
|
add := &cli.Command{Name: "add"}
|
||||||
|
rmHidden := &cli.Command{Name: "rm", Hidden: true}
|
||||||
|
auth := &cli.Command{Name: "auth", Subcommands: []*cli.Command{add, rmHidden}}
|
||||||
|
|
||||||
|
globals := FlagsToCatalog(nil, false)
|
||||||
|
|
||||||
|
// Flat without hidden
|
||||||
|
flat := BuildFlat(auth, 1, "photoprism", false, globals)
|
||||||
|
if len(flat) != 2 { // auth + add
|
||||||
|
t.Fatalf("expected 2 commands (auth, add), got %d", len(flat))
|
||||||
|
}
|
||||||
|
if flat[0].FullName != "photoprism auth" || flat[0].Depth != 1 {
|
||||||
|
t.Fatalf("unexpected root entry: %+v", flat[0])
|
||||||
|
}
|
||||||
|
if flat[1].FullName != "photoprism auth add" || flat[1].Depth != 2 {
|
||||||
|
t.Fatalf("unexpected child entry: %+v", flat[1])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nested with hidden
|
||||||
|
node := BuildNode(auth, 1, "photoprism", true, globals)
|
||||||
|
if len(node.Subcommands) != 2 {
|
||||||
|
t.Fatalf("expected 2 subcommands when including hidden, got %d", len(node.Subcommands))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRenderMarkdown_Headings(t *testing.T) {
|
||||||
|
data := MarkdownData{
|
||||||
|
App: App{Name: "PhotoPrism", Edition: "ce", Version: "test"},
|
||||||
|
GeneratedAt: "",
|
||||||
|
BaseHeading: 2,
|
||||||
|
Short: true, // hide flags table to focus on headings
|
||||||
|
All: false,
|
||||||
|
Commands: []Command{
|
||||||
|
{Name: "auth", FullName: "photoprism auth", Depth: 1},
|
||||||
|
{Name: "auth add", FullName: "photoprism auth add", Depth: 2},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
out, err := RenderMarkdown(data)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("render failed: %v", err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(out, "## photoprism auth") {
|
||||||
|
t.Fatalf("expected '## photoprism auth' heading, got:\n%s", out)
|
||||||
|
}
|
||||||
|
if !strings.Contains(out, "### photoprism auth add") {
|
||||||
|
t.Fatalf("expected '### photoprism auth add' heading, got:\n%s", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRenderMarkdown_HiddenColumn(t *testing.T) {
|
||||||
|
cmd := Command{
|
||||||
|
Name: "auth",
|
||||||
|
FullName: "photoprism auth",
|
||||||
|
Depth: 1,
|
||||||
|
Flags: []Flag{
|
||||||
|
{Name: "--visible", Type: "bool", Hidden: false, Usage: "visible flag"},
|
||||||
|
{Name: "--secret", Type: "bool", Hidden: true, Usage: "hidden flag"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
base := MarkdownData{
|
||||||
|
App: App{Name: "PhotoPrism", Edition: "ce", Version: "test"},
|
||||||
|
GeneratedAt: "",
|
||||||
|
BaseHeading: 2,
|
||||||
|
Short: false,
|
||||||
|
Commands: []Command{cmd},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default: no Hidden column
|
||||||
|
base.All = false
|
||||||
|
out, err := RenderMarkdown(base)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("render failed: %v", err)
|
||||||
|
}
|
||||||
|
if strings.Contains(out, " Hidden ") {
|
||||||
|
t.Fatalf("did not expect 'Hidden' column without --all:\n%s", out)
|
||||||
|
}
|
||||||
|
|
||||||
|
// With --all: Hidden column and boolean value present
|
||||||
|
base.All = true
|
||||||
|
out, err = RenderMarkdown(base)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("render failed: %v", err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(out, " Hidden ") {
|
||||||
|
t.Fatalf("expected 'Hidden' column with --all:\n%s", out)
|
||||||
|
}
|
||||||
|
if !strings.Contains(out, "hidden flag") || !strings.Contains(out, " true ") {
|
||||||
|
t.Fatalf("expected hidden flag row to include 'true':\n%s", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRenderMarkdown_ShortOmitsFlags(t *testing.T) {
|
||||||
|
cmd := Command{
|
||||||
|
Name: "auth",
|
||||||
|
FullName: "photoprism auth",
|
||||||
|
Depth: 1,
|
||||||
|
Flags: []Flag{
|
||||||
|
{Name: "--json", Type: "bool", Hidden: false, Usage: "json output"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
data := MarkdownData{
|
||||||
|
App: App{Name: "PhotoPrism", Edition: "ce", Version: "test"},
|
||||||
|
GeneratedAt: "",
|
||||||
|
BaseHeading: 2,
|
||||||
|
Short: true,
|
||||||
|
All: false,
|
||||||
|
Commands: []Command{cmd},
|
||||||
|
}
|
||||||
|
out, err := RenderMarkdown(data)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("render failed: %v", err)
|
||||||
|
}
|
||||||
|
if strings.Contains(out, "| Flag | Aliases | Type |") {
|
||||||
|
t.Fatalf("did not expect flags table when Short=true:\n%s", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,5 +18,6 @@ var ShowCommands = &cli.Command{
|
|||||||
ShowThumbSizesCommand,
|
ShowThumbSizesCommand,
|
||||||
ShowVideoSizesCommand,
|
ShowVideoSizesCommand,
|
||||||
ShowMetadataCommand,
|
ShowMetadataCommand,
|
||||||
|
ShowCommandsCommand,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
143
internal/commands/show_commands.go
Normal file
143
internal/commands/show_commands.go
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
package commands
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/urfave/cli/v2"
|
||||||
|
|
||||||
|
"github.com/photoprism/photoprism/internal/commands/catalog"
|
||||||
|
"github.com/photoprism/photoprism/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ShowCommandsCommand configures the command name, flags, and action.
|
||||||
|
var ShowCommandsCommand = &cli.Command{
|
||||||
|
Name: "commands",
|
||||||
|
Usage: "Displays a structured catalog of CLI commands",
|
||||||
|
Flags: []cli.Flag{
|
||||||
|
&cli.BoolFlag{Name: "json", Aliases: []string{"j"}, Usage: "print machine-readable JSON"},
|
||||||
|
&cli.BoolFlag{Name: "all", Usage: "include hidden commands and flags"},
|
||||||
|
&cli.BoolFlag{Name: "short", Usage: "omit flags in Markdown output"},
|
||||||
|
&cli.IntFlag{Name: "base-heading", Value: 2, Usage: "base Markdown heading level"},
|
||||||
|
&cli.BoolFlag{Name: "nested", Usage: "emit nested JSON structure instead of a flat array"},
|
||||||
|
},
|
||||||
|
Action: showCommandsAction,
|
||||||
|
}
|
||||||
|
|
||||||
|
type showCommandsOut struct {
|
||||||
|
App catalog.App `json:"app"`
|
||||||
|
GeneratedAt string `json:"generated_at"`
|
||||||
|
GlobalFlags []catalog.Flag `json:"global_flags,omitempty"`
|
||||||
|
Commands json.RawMessage `json:"commands"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// showCommandsAction displays a structured catalog of CLI commands.
|
||||||
|
func showCommandsAction(ctx *cli.Context) error {
|
||||||
|
// Prefer fast app metadata from the running app; avoid heavy config init in tests
|
||||||
|
includeHidden := ctx.Bool("all")
|
||||||
|
wantJSON := ctx.Bool("json")
|
||||||
|
nested := ctx.Bool("nested")
|
||||||
|
baseHeading := ctx.Int("base-heading")
|
||||||
|
if baseHeading < 1 {
|
||||||
|
baseHeading = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collect the app metadata to be included in the output.
|
||||||
|
app := catalog.App{}
|
||||||
|
|
||||||
|
if ctx != nil && ctx.App != nil && ctx.App.Metadata != nil {
|
||||||
|
if n, ok := ctx.App.Metadata["Name"].(string); ok {
|
||||||
|
app.Name = n
|
||||||
|
}
|
||||||
|
if e, ok := ctx.App.Metadata["Edition"].(string); ok {
|
||||||
|
app.Edition = e
|
||||||
|
}
|
||||||
|
if v, ok := ctx.App.Metadata["Version"].(string); ok {
|
||||||
|
app.Version = v
|
||||||
|
app.Build = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if app.Name == "" || app.Version == "" {
|
||||||
|
conf := config.NewConfig(ctx)
|
||||||
|
app.Name = conf.Name()
|
||||||
|
app.Edition = conf.Edition()
|
||||||
|
app.Version = conf.Version()
|
||||||
|
app.Build = conf.Version()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collect global flags from the running app.
|
||||||
|
var globalFlags []catalog.Flag
|
||||||
|
if ctx != nil && ctx.App != nil {
|
||||||
|
globalFlags = catalog.FlagsToCatalog(ctx.App.Flags, includeHidden)
|
||||||
|
} else {
|
||||||
|
globalFlags = catalog.FlagsToCatalog(config.Flags.Cli(), includeHidden)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Traverse commands registry using runtime app commands to avoid init cycles.
|
||||||
|
var flat []catalog.Command
|
||||||
|
var tree []catalog.Node
|
||||||
|
|
||||||
|
var roots []*cli.Command
|
||||||
|
if ctx != nil && ctx.App != nil {
|
||||||
|
roots = ctx.App.Commands
|
||||||
|
}
|
||||||
|
for _, c := range roots {
|
||||||
|
if c == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if c.Hidden && !includeHidden {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if nested {
|
||||||
|
node := catalog.BuildNode(c, 1, "photoprism", includeHidden, globalFlags)
|
||||||
|
tree = append(tree, node)
|
||||||
|
} else {
|
||||||
|
flat = append(flat, catalog.BuildFlat(c, 1, "photoprism", includeHidden, globalFlags)...)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render JSON output using json.Marshal().
|
||||||
|
if wantJSON {
|
||||||
|
var cmds json.RawMessage
|
||||||
|
var err error
|
||||||
|
if nested {
|
||||||
|
cmds, err = json.Marshal(tree)
|
||||||
|
} else {
|
||||||
|
cmds, err = json.Marshal(flat)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
out := showCommandsOut{
|
||||||
|
App: app,
|
||||||
|
GeneratedAt: time.Now().UTC().Format(time.RFC3339),
|
||||||
|
GlobalFlags: globalFlags,
|
||||||
|
Commands: cmds,
|
||||||
|
}
|
||||||
|
b, err := json.Marshal(out)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
fmt.Println(string(b))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render Markdown using embedded template.
|
||||||
|
data := catalog.MarkdownData{
|
||||||
|
App: app,
|
||||||
|
GeneratedAt: time.Now().UTC().Format(time.RFC3339),
|
||||||
|
BaseHeading: baseHeading,
|
||||||
|
Short: ctx.Bool("short"),
|
||||||
|
All: includeHidden,
|
||||||
|
Commands: flat,
|
||||||
|
}
|
||||||
|
|
||||||
|
md, err := catalog.RenderMarkdown(data)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
fmt.Println(md)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
127
internal/commands/show_commands_test.go
Normal file
127
internal/commands/show_commands_test.go
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
package commands
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
catalogpkg "github.com/photoprism/photoprism/internal/commands/catalog"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestShowCommands_JSON_Flat(t *testing.T) {
|
||||||
|
// Build JSON without capturing stdout to avoid pipe blocking on large outputs
|
||||||
|
ctx := NewTestContext([]string{"commands"})
|
||||||
|
global := catalogpkg.FlagsToCatalog(ctx.App.Flags, false)
|
||||||
|
var flat []catalogpkg.Command
|
||||||
|
for _, c := range ctx.App.Commands {
|
||||||
|
if c.Hidden {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
flat = append(flat, catalogpkg.BuildFlat(c, 1, "photoprism", false, global)...)
|
||||||
|
}
|
||||||
|
out := struct {
|
||||||
|
App catalogpkg.App `json:"app"`
|
||||||
|
GeneratedAt string `json:"generated_at"`
|
||||||
|
GlobalFlags []catalogpkg.Flag `json:"global_flags"`
|
||||||
|
Commands []catalogpkg.Command `json:"commands"`
|
||||||
|
}{
|
||||||
|
App: catalogpkg.App{Name: "PhotoPrism", Edition: "ce", Version: "test"},
|
||||||
|
GeneratedAt: "",
|
||||||
|
GlobalFlags: global,
|
||||||
|
Commands: flat,
|
||||||
|
}
|
||||||
|
b, err := json.Marshal(out)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal failed: %v", err)
|
||||||
|
}
|
||||||
|
// Basic structural checks via unmarshal into a light struct
|
||||||
|
var v struct {
|
||||||
|
Commands []struct {
|
||||||
|
Name, FullName string
|
||||||
|
Depth int
|
||||||
|
} `json:"commands"`
|
||||||
|
GlobalFlags []map[string]interface{} `json:"global_flags"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(b, &v); err != nil {
|
||||||
|
t.Fatalf("invalid json: %v", err)
|
||||||
|
}
|
||||||
|
if len(v.Commands) == 0 {
|
||||||
|
t.Fatalf("expected at least one command")
|
||||||
|
}
|
||||||
|
// Expect at least one top-level auth and at least one subcommand overall
|
||||||
|
var haveAuth, haveAnyChild bool
|
||||||
|
for _, c := range v.Commands {
|
||||||
|
if c.Name == "auth" && c.Depth == 1 {
|
||||||
|
haveAuth = true
|
||||||
|
}
|
||||||
|
if c.Depth >= 2 {
|
||||||
|
haveAnyChild = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !haveAuth {
|
||||||
|
t.Fatalf("expected to find 'auth' top-level command in list")
|
||||||
|
}
|
||||||
|
if !haveAnyChild {
|
||||||
|
t.Fatalf("expected to find at least one subcommand (depth >= 2)")
|
||||||
|
}
|
||||||
|
if len(v.GlobalFlags) == 0 {
|
||||||
|
t.Fatalf("expected non-empty global_flags")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestShowCommands_JSON_Nested(t *testing.T) {
|
||||||
|
ctx := NewTestContext([]string{"commands"})
|
||||||
|
global := catalogpkg.FlagsToCatalog(ctx.App.Flags, false)
|
||||||
|
var tree []catalogpkg.Node
|
||||||
|
for _, c := range ctx.App.Commands {
|
||||||
|
if c.Hidden {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
tree = append(tree, catalogpkg.BuildNode(c, 1, "photoprism", false, global))
|
||||||
|
}
|
||||||
|
b, err := json.Marshal(struct {
|
||||||
|
Commands []catalogpkg.Node `json:"commands"`
|
||||||
|
}{Commands: tree})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal failed: %v", err)
|
||||||
|
}
|
||||||
|
var v struct {
|
||||||
|
Commands []struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Depth int `json:"depth"`
|
||||||
|
Subcommands []struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
} `json:"subcommands"`
|
||||||
|
} `json:"commands"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(b, &v); err != nil {
|
||||||
|
t.Fatalf("invalid json: %v", err)
|
||||||
|
}
|
||||||
|
if len(v.Commands) == 0 {
|
||||||
|
t.Fatalf("expected top-level commands")
|
||||||
|
}
|
||||||
|
var hasAuthWithChild bool
|
||||||
|
for _, c := range v.Commands {
|
||||||
|
if c.Name == "auth" && c.Depth == 1 && len(c.Subcommands) > 0 {
|
||||||
|
hasAuthWithChild = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !hasAuthWithChild {
|
||||||
|
t.Fatalf("expected auth with at least one subcommand")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestShowCommands_Markdown_Default(t *testing.T) {
|
||||||
|
out, err := RunWithTestContext(ShowCommandsCommand, []string{"commands"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
// Expect Markdown headings for commands
|
||||||
|
if !strings.Contains(out, "## photoprism auth") {
|
||||||
|
t.Fatalf("expected '## photoprism auth' heading in output\n%s", out[:min(400, len(out))])
|
||||||
|
}
|
||||||
|
if !strings.Contains(out, "### photoprism auth ") { // subcommand headings begin with parent
|
||||||
|
t.Fatalf("expected '### photoprism auth <sub>' heading in output\n%s", out[:min(600, len(out))])
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user