diff --git a/cmd/slackdump/internal/base/base.go b/cmd/slackdump/internal/base/base.go index 22cdf764..589742d7 100644 --- a/cmd/slackdump/internal/base/base.go +++ b/cmd/slackdump/internal/base/base.go @@ -11,8 +11,14 @@ package base import ( "context" "flag" + "fmt" + "os" + "strings" + "sync" ) +var CmdName string + // A Command is an implementation of a slackdump command. type Command struct { // Run runs the command. @@ -30,4 +36,68 @@ type Command struct { // Flag is a set of flags specific to this command. Flag flag.FlagSet + + // Commands lists the available commands and help topics. + // The order here is the order in which they are printed by 'go help'. + // Note that subcommands are in general best avoided. + Commands []*Command +} + +var Slackdump = &Command{ + UsageLine: "slackdump", + Long: `Slackdump is a tool for exporting Slack conversations, emojis, users, etc.`, + // Commands initialised in main. +} + +var exitStatus = 0 +var exitMu sync.Mutex + +func SetExitStatus(n int) { + exitMu.Lock() + if exitStatus < n { + exitStatus = n + } + exitMu.Unlock() +} + +func Exit() { + os.Exit(exitStatus) +} + +// Runnable reports whether the command can be run; otherwise +// it is a documentation pseudo-command such as importpath. +func (c *Command) Runnable() bool { + return c.Run != nil +} + +// LongName returns the command's long name: all the words in the usage line between "go" and a flag or argument, +func (c *Command) LongName() string { + name := c.UsageLine + if i := strings.Index(name, " ["); i >= 0 { + name = name[:i] + } + if name == "slackdump" { + return "" + } + return strings.TrimPrefix(name, "slackdump ") +} + +// Name returns the command's short name: the last word in the usage line before a flag or argument. +func (c *Command) Name() string { + name := c.LongName() + if i := strings.LastIndex(name, " "); i >= 0 { + name = name[i+1:] + } + return name +} + +// Usage is the usage-reporting function, filled in by package main +// but here for reference by other packages. +var Usage func() + +func (c *Command) Usage() { + fmt.Fprintf(os.Stderr, "usage: %s\n", c.UsageLine) + fmt.Fprintf(os.Stderr, "Run 'slackdump help %s' for details.\n", c.LongName()) + SetExitStatus(2) + Exit() } diff --git a/cmd/slackdump/internal/help/help.go b/cmd/slackdump/internal/help/help.go new file mode 100644 index 00000000..52bb4b42 --- /dev/null +++ b/cmd/slackdump/internal/help/help.go @@ -0,0 +1,157 @@ +// Copyright 2022 rusq, GPL 3.0. +// +// Copyright 2017 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +// Package help implements "slackdump help" command. +package help + +import ( + "bufio" + "bytes" + "fmt" + "html/template" + "io" + "log" + "os" + "strings" + "unicode" + "unicode/utf8" + + "github.com/rusq/slackdump/v2/cmd/slackdump/internal/base" +) + +func PrintUsage(w io.Writer, cmd *base.Command) { + bw := bufio.NewWriter(w) + tmpl(bw, usageTemplate, cmd) + bw.Flush() +} + +// tmpl executes the given template text on data, writing the result to w. +func tmpl(w io.Writer, text string, data any) { + t := template.New("top") + t.Funcs(template.FuncMap{"trim": strings.TrimSpace, "capitalize": capitalize}) + template.Must(t.Parse(text)) + ew := &errWriter{w: w} + err := t.Execute(ew, data) + if ew.err != nil { + // I/O error writing. Ignore write on closed pipe. + if strings.Contains(ew.err.Error(), "pipe") { + base.SetExitStatus(1) + base.Exit() + } + log.Fatalf("writing output: %v", ew.err) + } + if err != nil { + panic(err) + } +} + +func capitalize(s string) string { + if s == "" { + return s + } + r, n := utf8.DecodeRuneInString(s) + return string(unicode.ToTitle(r)) + s[n:] +} + +// An errWriter wraps a writer, recording whether a write error occurred. +type errWriter struct { + w io.Writer + err error +} + +func (w *errWriter) Write(b []byte) (int, error) { + n, err := w.w.Write(b) + if err != nil { + w.err = err + } + return n, err +} + +// Help implements the 'help' command. +func Help(w io.Writer, args []string) { + // 'go help documentation' generates doc.go. + if len(args) == 1 && args[0] == "documentation" { + fmt.Fprintln(w, "// Copyright 2011 The Go Authors. All rights reserved.") + fmt.Fprintln(w, "// Use of this source code is governed by a BSD-style") + fmt.Fprintln(w, "// license that can be found in the LICENSE file.") + fmt.Fprintln(w) + fmt.Fprintln(w, "// Code generated by mkalldocs.sh; DO NOT EDIT.") + fmt.Fprintln(w, "// Edit the documentation in other files and rerun mkalldocs.sh to generate this one.") + fmt.Fprintln(w) + buf := new(bytes.Buffer) + PrintUsage(buf, base.Slackdump) + usage := &base.Command{Long: buf.String()} + cmds := []*base.Command{usage} + for _, cmd := range base.Slackdump.Commands { + // Avoid duplication of the "get" documentation. + cmds = append(cmds, cmd) + cmds = append(cmds, cmd.Commands...) + } + tmpl(&commentWriter{W: w}, documentationTemplate, cmds) + fmt.Fprintln(w, "package main") + return + } + + cmd := base.Slackdump +Args: + for i, arg := range args { + for _, sub := range cmd.Commands { + if sub.Name() == arg { + cmd = sub + continue Args + } + } + + // helpSuccess is the help command using as many args as possible that would succeed. + helpSuccess := "slackdump help" + if i > 0 { + helpSuccess += " " + strings.Join(args[:i], " ") + } + fmt.Fprintf(os.Stderr, "go help %s: unknown help topic. Run '%s'.\n", strings.Join(args, " "), helpSuccess) + base.SetExitStatus(2) // failed at 'go help cmd' + base.Exit() + } + + if len(cmd.Commands) > 0 { + PrintUsage(os.Stdout, cmd) + } else { + tmpl(os.Stdout, helpTemplate, cmd) + } + // not exit 2: succeeded at 'go help cmd'. + return +} + +// commentWriter writes a Go comment to the underlying io.Writer, +// using line comment form (//). +type commentWriter struct { + W io.Writer + wroteSlashes bool // Wrote "//" at the beginning of the current line. +} + +func (c *commentWriter) Write(p []byte) (int, error) { + var n int + for i, b := range p { + if !c.wroteSlashes { + s := "//" + if b != '\n' { + s = "// " + } + if _, err := io.WriteString(c.W, s); err != nil { + return n, err + } + c.wroteSlashes = true + } + n0, err := c.W.Write(p[i : i+1]) + n += n0 + if err != nil { + return n, err + } + if b == '\n' { + c.wroteSlashes = false + } + } + return len(p), nil +} diff --git a/cmd/slackdump/internal/help/templates.go b/cmd/slackdump/internal/help/templates.go new file mode 100644 index 00000000..10532c30 --- /dev/null +++ b/cmd/slackdump/internal/help/templates.go @@ -0,0 +1,37 @@ +package help + +var ( + helpTemplate = `{{if .Runnable}}usage: {{.UsageLine}} + +{{end}}{{.Long | trim}} +` + usageTemplate = `{{.Long | trim}} + +Usage: + + {{.UsageLine}} [arguments] + +The commands are: +{{range .Commands}}{{if or (.Runnable) .Commands}} + {{.Name | printf "%-11s"}} {{.Short}}{{end}}{{end}} + +Use "slackdump help{{with .LongName}} {{.}}{{end}} " for more information about a command. +{{if eq (.UsageLine) "slackdump"}} +Additional help topics: +{{range .Commands}}{{if and (not .Runnable) (not .Commands)}} + {{.Name | printf "%-15s"}} {{.Short}}{{end}}{{end}} + +Use "slackdump help{{with .LongName}} {{.}}{{end}} " for more information about that topic. +{{end}} +` + documentationTemplate = `{{range .}}{{if .Short}}{{.Short | capitalize}} + +{{end}}{{if .Commands}}` + usageTemplate + `{{else}}{{if .Runnable}}Usage: + + {{.UsageLine}} + +{{end}}{{.Long | trim}} + + +{{end}}{{end}}` +) diff --git a/cmd/slackdump/internal/v1/main.go b/cmd/slackdump/internal/v1/main.go index 5ba74697..a6c8c1eb 100644 --- a/cmd/slackdump/internal/v1/main.go +++ b/cmd/slackdump/internal/v1/main.go @@ -35,6 +35,15 @@ const ( bannerFmt = "Slackdump %[1]s Copyright (c) 2018-%[2]s rusq (build: %s)\n\n" ) +var CmdV1 = &base.Command{ + Run: runV1, + UsageLine: "slackdump v1", + Short: "slackdump legacy mode", + Long: ` +V1 starts slackdump in legacy mode, that supports all legacy flags. + `, +} + // defFilenameTemplate is the default file naming template. const defFilenameTemplate = "{{.ID}}{{ if .ThreadTS}}-{{.ThreadTS}}{{end}}" @@ -67,7 +76,7 @@ func runV1(ctx context.Context, cmd *base.Command, args []string) { banner(os.Stderr) loadSecrets(secrets) - params, err := parseCmdLine(args[1:]) + params, err := parseCmdLine(args[0:]) if err == config.ErrNothingToDo { // if the user hasn't provided any required flags, let's offer // an interactive prompt to fill them. diff --git a/cmd/slackdump/main.go b/cmd/slackdump/main.go new file mode 100644 index 00000000..a438495a --- /dev/null +++ b/cmd/slackdump/main.go @@ -0,0 +1,90 @@ +package main + +import ( + "context" + "flag" + "fmt" + "log" + "os" + "runtime/trace" + "strings" + + "github.com/rusq/slackdump/v2/cmd/slackdump/internal/base" + "github.com/rusq/slackdump/v2/cmd/slackdump/internal/help" + v1 "github.com/rusq/slackdump/v2/cmd/slackdump/internal/v1" +) + +func init() { + base.Slackdump.Commands = []*base.Command{ + v1.CmdV1, + } +} + +func main() { + flag.Usage = base.Usage + flag.Parse() + log.SetFlags(0) + + args := flag.Args() + base.CmdName = args[0] + if args[0] == "help" { + help.Help(os.Stdout, args[1:]) + return + } +BigCmdLoop: + for bigCmd := base.Slackdump; ; { + for _, cmd := range bigCmd.Commands { + if cmd.Name() != args[0] { + continue + } + if len(cmd.Commands) > 0 { + bigCmd = cmd + args = args[1:] + if len(args) == 0 { + help.PrintUsage(os.Stderr, bigCmd) + base.SetExitStatus(2) + base.Exit() + } + if args[0] == "help" { + // Accept 'go mod help' and 'go mod help foo' for 'go help mod' and 'go help mod foo'. + help.Help(os.Stdout, append(strings.Split(base.CmdName, " "), args[1:]...)) + return + } + base.CmdName += " " + args[0] + continue BigCmdLoop + } + if !cmd.Runnable() { + continue + } + invoke(cmd, args) + base.Exit() + return + } + helpArg := "" + if i := strings.LastIndex(base.CmdName, " "); i >= 0 { + helpArg = " " + base.CmdName[:i] + } + fmt.Fprintf(os.Stderr, "slackdump %s: unknown command\nRun 'go help%s' for usage.\n", base.CmdName, helpArg) + base.SetExitStatus(2) + base.Exit() + } +} + +func init() { + base.Usage = mainUsage +} + +func mainUsage() { + help.PrintUsage(os.Stderr, base.Slackdump) + os.Exit(2) +} + +func invoke(cmd *base.Command, args []string) { + cmd.Flag.Usage = func() { cmd.Usage() } + cmd.Flag.Parse(args[1:]) + args = cmd.Flag.Args() + // maybe start trace + ctx, task := trace.NewTask(context.Background(), fmt.Sprint("Running ", cmd.Name(), " command")) + defer task.End() + cmd.Run(ctx, cmd, args) +}