Files
photoprism/internal/commands/catalog/catalog.go
2025-09-21 19:51:22 +02:00

360 lines
9.7 KiB
Go

/*
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 {
// Omit nested 'help' subcommands; keep only top-level 'photoprism help'
if skipHelp(c, parentFull) {
return nil
}
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) || skipHelp(sub, info.FullName) {
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) || skipHelp(sub, info.FullName) {
continue
}
node.Subcommands = append(node.Subcommands, BuildNode(sub, depth+1, info.FullName, includeHidden, global))
}
return node
}
// skipHelp returns true for nested 'help' commands so they are omitted from output.
// Top-level 'photoprism help' remains included.
func skipHelp(c *cli.Command, parentFull string) bool {
if c == nil {
return false
}
if strings.EqualFold(c.Name, "help") {
// Keep only at the root where parent is 'photoprism'
return parentFull != "photoprism"
}
return false
}
// 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
}