diff --git a/auth/auth_ui/huh.go b/auth/auth_ui/huh.go index 086de9e8..1b9bc4dd 100644 --- a/auth/auth_ui/huh.go +++ b/auth/auth_ui/huh.go @@ -14,6 +14,8 @@ import ( // Huh is the Auth UI that uses the huh library to provide a terminal UI. type Huh struct{} +var Theme = huh.ThemeBase16() + func (h *Huh) RequestWorkspace(w io.Writer) (string, error) { var workspace string err := huh.NewForm(huh.NewGroup( @@ -22,7 +24,7 @@ func (h *Huh) RequestWorkspace(w io.Writer) (string, error) { Value(&workspace). Validate(valWorkspace). Description("The workspace name is the part of the URL that comes before `.slack.com' in\nhttps://.slack.com/. Both workspace name or URL are acceptable."), - )).Run() + )).WithTheme(Theme).Run() if err != nil { return "", err } @@ -44,7 +46,7 @@ func (*Huh) RequestCreds(w io.Writer, workspace string) (email string, passwd st Placeholder("your slack password"). Validate(valRequired).EchoMode(huh.EchoModePassword), ), - ) + ).WithTheme(Theme) err = f.Run() return } @@ -137,7 +139,7 @@ func (*Huh) RequestLoginType(w io.Writer, workspace string) (LoginOpts, error) { return "" } }, &ret.Type)) - if err := huh.NewForm(huh.NewGroup(fields...)).Run(); err != nil { + if err := huh.NewForm(huh.NewGroup(fields...)).WithTheme(Theme).Run(); err != nil { return ret, err } if ret.Type == LUserBrowser { @@ -170,7 +172,7 @@ func chooseBrowser() (string, error) { DescriptionFunc(func() string { return browsers[selection].Path }, &selection), - )).Run() + )).WithTheme(Theme).Run() if err != nil { return "", err } @@ -189,7 +191,7 @@ func (*Huh) ConfirmationCode(email string) (int, error) { Description("Slack did not recognise the browser, and sent a confirmation code. Please enter the confirmation code below."). Value(&strCode). Validate(valSixDigits), - )) + )).WithTheme(Theme) if err := q.Run(); err != nil { return 0, err } diff --git a/auth/auth_ui/validation.go b/auth/auth_ui/validation.go index 01d1c88e..94e019bc 100644 --- a/auth/auth_ui/validation.go +++ b/auth/auth_ui/validation.go @@ -10,28 +10,28 @@ var ( ErrRequired = errors.New("can not be empty") ) -func valURLSafe(s string) error { - for _, c := range s { - if !isRuneURLSafe(c) { - return ErrNotURLSafe - } - } - return nil -} +// func valURLSafe(s string) error { +// for _, c := range s { +// if !isRuneURLSafe(c) { +// return ErrNotURLSafe +// } +// } +// return nil +// } -func isRuneURLSafe(r rune) bool { - switch { - case 'a' <= r && r <= 'z': - return true - case 'A' <= r && r <= 'Z': - return true - case '0' <= r && r <= '9': - return true - case r == '-' || r == '.' || r == '_' || r == '~': - return true - } - return false -} +// func isRuneURLSafe(r rune) bool { +// switch { +// case 'a' <= r && r <= 'z': +// return true +// case 'A' <= r && r <= 'Z': +// return true +// case '0' <= r && r <= '9': +// return true +// case r == '-' || r == '.' || r == '_' || r == '~': +// return true +// } +// return false +// } func valRequired(s string) error { if s == "" { diff --git a/auth/browser/client.go b/auth/browser/client.go index b1842412..0a8a976f 100644 --- a/auth/browser/client.go +++ b/auth/browser/client.go @@ -81,7 +81,11 @@ func (cl *Client) Authenticate(ctx context.Context) (string, []*http.Cookie, err if err != nil { return "", nil, err } - defer pw.Stop() + defer func() { + if err := pw.Stop(); err != nil { + l().Printf("failed to stop playwright: %v", err) + } + }() opts := playwright.BrowserTypeLaunchOptions{ Headless: _b(false), diff --git a/cmd/slackdump/internal/archive/search_wizard.go b/cmd/slackdump/internal/archive/search_wizard.go index a2d3f47a..7835c1bb 100644 --- a/cmd/slackdump/internal/archive/search_wizard.go +++ b/cmd/slackdump/internal/archive/search_wizard.go @@ -26,7 +26,7 @@ func wizSearch(ctx context.Context, cmd *base.Command, args []string) error { return errors.New("select action") } if len(terms) == 0 { - return errors.New("search terms are not specified") + return errors.New("specify search terms in Search Options") } return nil }, @@ -62,7 +62,6 @@ func searchCfg() cfgui.Configuration { cfgui.ParamGroup{ Name: "Other parameters", Params: []cfgui.Parameter{ - { Name: "Scope", Description: "Choose the search scope.", diff --git a/cmd/slackdump/internal/bootstrap/slackdump.go b/cmd/slackdump/internal/bootstrap/slackdump.go index 39e37750..cbfae4c0 100644 --- a/cmd/slackdump/internal/bootstrap/slackdump.go +++ b/cmd/slackdump/internal/bootstrap/slackdump.go @@ -25,7 +25,7 @@ func SlackdumpSession(ctx context.Context, opts ...slackdump.Option) (*slackdump } stdOpts = append(stdOpts, opts...) - return slackdump.New( + return slackdump.NewNoValidate( ctx, prov, stdOpts..., diff --git a/cmd/slackdump/internal/diag/edge.go b/cmd/slackdump/internal/diag/edge.go index 7d669676..c538138a 100644 --- a/cmd/slackdump/internal/diag/edge.go +++ b/cmd/slackdump/internal/diag/edge.go @@ -13,10 +13,10 @@ import ( var CmdEdge = &base.Command{ Run: runEdge, - Wizard: func(ctx context.Context, cmd *base.Command, args []string) error { panic("not implemented") }, UsageLine: "slack tools edge", Short: "Edge test", RequireAuth: true, + HideWizard: true, Long: ` # Slack Edge API test tool diff --git a/cmd/slackdump/internal/diag/encrypt.go b/cmd/slackdump/internal/diag/encrypt.go index a2c591a3..7767de90 100644 --- a/cmd/slackdump/internal/diag/encrypt.go +++ b/cmd/slackdump/internal/diag/encrypt.go @@ -7,11 +7,11 @@ import ( "os" "strings" + "github.com/ProtonMail/go-crypto/openpgp" + "github.com/ProtonMail/go-crypto/openpgp/armor" + "github.com/ProtonMail/go-crypto/openpgp/packet" "github.com/rusq/slackdump/v3/cmd/slackdump/internal/cfg" "github.com/rusq/slackdump/v3/cmd/slackdump/internal/golang/base" - "golang.org/x/crypto/openpgp" - "golang.org/x/crypto/openpgp/armor" - "golang.org/x/crypto/openpgp/packet" ) // pub rsa4096 2020-03-22 [SC] [expires: 2029-03-21] diff --git a/cmd/slackdump/internal/diag/eztest.go b/cmd/slackdump/internal/diag/eztest.go index 68f9a9ac..02d1567e 100644 --- a/cmd/slackdump/internal/diag/eztest.go +++ b/cmd/slackdump/internal/diag/eztest.go @@ -5,6 +5,7 @@ import ( "encoding/json" "errors" "fmt" + "net/http" "os" "github.com/playwright-community/playwright-go" @@ -16,7 +17,6 @@ import ( var CmdEzTest = &base.Command{ Run: runEzLoginTest, - Wizard: func(ctx context.Context, cmd *base.Command, args []string) error { panic("not implemented") }, UsageLine: "slack tools eztest", Short: "EZ-Login 3000 test", Long: ` @@ -30,34 +30,49 @@ You will see "OK" in the end if there were no issues, otherwise an error will be printed and the test will be terminated. `, CustomFlags: true, + PrintFlags: true, } type ezResult struct { - Engine string `json:"engine,omitempty"` - HasToken bool `json:"has_token,omitempty"` - HasCookies bool `json:"has_cookies,omitempty"` - Err *string `json:"error,omitempty"` + Engine string `json:"engine,omitempty"` + HasToken bool `json:"has_token,omitempty"` + HasCookies bool `json:"has_cookies,omitempty"` + Err *string `json:"error,omitempty"` + Credentials *Credentials `json:"credentials,omitempty"` } +type Credentials struct { + Token string `json:"token,omitempty"` + Cookies []*http.Cookie `json:"cookie,omitempty"` +} + +type eztestOpts struct { + printCreds bool + wsp string + legacy bool +} + +var eztestFlags eztestOpts + func init() { CmdEzTest.Flag.Usage = func() { fmt.Fprint(os.Stdout, "usage: slackdump tools eztest [flags]\n\nFlags:\n") CmdEzTest.Flag.PrintDefaults() } + CmdEzTest.Flag.BoolVar(&eztestFlags.printCreds, "p", false, "print credentials") + CmdEzTest.Flag.BoolVar(&eztestFlags.legacy, "legacy-browser", false, "run with playwright") + CmdEzTest.Flag.StringVar(&eztestFlags.wsp, "w", "", "Slack `workspace` to login to.") } func runEzLoginTest(ctx context.Context, cmd *base.Command, args []string) error { lg := logger.FromContext(ctx) - wsp := cmd.Flag.String("w", "", "Slack `workspace` to login to.") - legacy := cmd.Flag.Bool("legacy-browser", false, "run with playwright") - if err := cmd.Flag.Parse(args); err != nil { base.SetExitStatus(base.SInvalidParameters) return err } - if *wsp == "" { + if eztestFlags.wsp == "" { base.SetExitStatus(base.SInvalidParameters) cmd.Flag.Usage() return nil @@ -67,10 +82,10 @@ func runEzLoginTest(ctx context.Context, cmd *base.Command, args []string) error res ezResult ) - if *legacy { - res = tryPlaywrightAuth(ctx, *wsp) + if eztestFlags.legacy { + res = tryPlaywrightAuth(ctx, eztestFlags.wsp, eztestFlags.printCreds) } else { - res = tryRodAuth(ctx, *wsp) + res = tryRodAuth(ctx, eztestFlags.wsp, eztestFlags.printCreds) } enc := json.NewEncoder(os.Stdout) @@ -87,31 +102,36 @@ func runEzLoginTest(ctx context.Context, cmd *base.Command, args []string) error return errors.New(*res.Err) } return nil - } -func tryPlaywrightAuth(ctx context.Context, wsp string) ezResult { - var res = ezResult{Engine: "playwright"} +func tryPlaywrightAuth(ctx context.Context, wsp string, populateCreds bool) ezResult { + var ret = ezResult{Engine: "playwright"} if err := playwright.Install(&playwright.RunOptions{Browsers: []string{"firefox"}}); err != nil { - res.Err = ptr(fmt.Sprintf("playwright installation error: %s", err)) - return res + ret.Err = ptr(fmt.Sprintf("playwright installation error: %s", err)) + return ret } prov, err := auth.NewBrowserAuth(ctx, auth.BrowserWithWorkspace(wsp)) if err != nil { - res.Err = ptr(err.Error()) - return res + ret.Err = ptr(err.Error()) + return ret } - res.HasToken = len(prov.SlackToken()) > 0 - res.HasCookies = len(prov.Cookies()) > 0 - return res + ret.HasToken = len(prov.SlackToken()) > 0 + ret.HasCookies = len(prov.Cookies()) > 0 + if populateCreds { + ret.Credentials = &Credentials{ + Token: prov.SlackToken(), + Cookies: prov.Cookies(), + } + } + return ret } func ptr[T any](t T) *T { return &t } -func tryRodAuth(ctx context.Context, wsp string) ezResult { +func tryRodAuth(ctx context.Context, wsp string, populateCreds bool) ezResult { ret := ezResult{Engine: "rod"} prov, err := auth.NewRODAuth(ctx, auth.BrowserWithWorkspace(wsp)) if err != nil { @@ -120,5 +140,11 @@ func tryRodAuth(ctx context.Context, wsp string) ezResult { } ret.HasCookies = len(prov.Cookies()) > 0 ret.HasToken = len(prov.SlackToken()) > 0 + if populateCreds { + ret.Credentials = &Credentials{ + Token: prov.SlackToken(), + Cookies: prov.Cookies(), + } + } return ret } diff --git a/cmd/slackdump/internal/diag/rawoutput.go b/cmd/slackdump/internal/diag/rawoutput.go index 1878870a..c166feda 100644 --- a/cmd/slackdump/internal/diag/rawoutput.go +++ b/cmd/slackdump/internal/diag/rawoutput.go @@ -22,7 +22,6 @@ import ( var CmdRawOutput = &base.Command{ Run: nil, // populated by init to break the init cycle - Wizard: func(context.Context, *base.Command, []string) error { panic("not implemented") }, UsageLine: "slackdump tools rawoutput [flags] ", Short: "record raw API output", Long: ` @@ -39,6 +38,7 @@ Running this tool may be requested by developers. PrintFlags: true, RequireAuth: true, Commands: nil, + HideWizard: true, } type rawOutputParams struct { @@ -186,7 +186,10 @@ func sendReq(w io.Writer, cl *http.Client, ep string, v url.Values) (bool, error log.Printf("error while retrieving body: %s", err) } if resp.StatusCode != http.StatusOK { - io.Copy(w, bytes.NewReader(data)) + _, err := io.Copy(w, bytes.NewReader(data)) + if err != nil { + return false, err + } return false, fmt.Errorf("server NOT OK: %s", resp.Status) } if len(data) == 0 { diff --git a/cmd/slackdump/internal/diag/record.go b/cmd/slackdump/internal/diag/record.go index df716f0f..58bcc49f 100644 --- a/cmd/slackdump/internal/diag/record.go +++ b/cmd/slackdump/internal/diag/record.go @@ -15,9 +15,10 @@ import ( ) var CmdRecord = &base.Command{ - UsageLine: "slackdump tools record", - Short: "chunk record commands", - Commands: []*base.Command{cmdRecordStream, cmdRecordState}, + UsageLine: "slackdump tools record", + Short: "chunk record commands", + Commands: []*base.Command{cmdRecordStream, cmdRecordState}, + HideWizard: true, } var cmdRecordStream = &base.Command{ diff --git a/cmd/slackdump/internal/diag/search.go b/cmd/slackdump/internal/diag/search.go index d3654256..d5318fc8 100644 --- a/cmd/slackdump/internal/diag/search.go +++ b/cmd/slackdump/internal/diag/search.go @@ -107,7 +107,9 @@ func runSearch(ctx context.Context, cmd *base.Command, args []string) error { lg.Printf("cursor %s", sm.NextCursor) p.Cursor = sm.NextCursor - lim.Wait(ctx) + if err := lim.Wait(ctx); err != nil { + return err + } } return nil diff --git a/cmd/slackdump/internal/diag/thread.go b/cmd/slackdump/internal/diag/thread.go index de8c8a6c..0f2f25aa 100644 --- a/cmd/slackdump/internal/diag/thread.go +++ b/cmd/slackdump/internal/diag/thread.go @@ -108,7 +108,7 @@ func generateThread(ctx context.Context, client *slack.Client, channelID string, l := network.NewLimiter(network.Tier3, network.DefLimits.Tier3.Burst, int(network.DefLimits.Tier3.Boost)) pb := progressbar.Default(int64(numMsg)) pb.Describe("posting messages") - defer pb.Finish() + defer func() { _ = pb.Finish() }() for i := 0; i < numMsg; i++ { if err := network.WithRetry(ctx, l, 3, func() error { _, _, err := client.PostMessageContext(ctx, channelID, slack.MsgOptionTS(ts), slack.MsgOptionText(fmt.Sprintf("message: %d", i), false)) @@ -144,7 +144,7 @@ func delMessages(ctx context.Context, client *slack.Client, channelID string, ms pb := progressbar.Default(int64(len(msgs))) pb.Describe("deleting messages") - defer pb.Finish() + defer func() { _ = pb.Finish() }() l := network.NewLimiter(network.Tier3, network.DefLimits.Tier3.Burst, int(network.DefLimits.Tier3.Boost)) for _, m := range msgs { diff --git a/cmd/slackdump/internal/diag/uninstall.go b/cmd/slackdump/internal/diag/uninstall.go index f62822fb..daf28d11 100644 --- a/cmd/slackdump/internal/diag/uninstall.go +++ b/cmd/slackdump/internal/diag/uninstall.go @@ -12,6 +12,9 @@ import ( "github.com/rusq/slackdump/v3/cmd/slackdump/internal/diag/info" "github.com/rusq/slackdump/v3/cmd/slackdump/internal/golang/base" "github.com/rusq/slackdump/v3/cmd/slackdump/internal/ui" + "github.com/rusq/slackdump/v3/cmd/slackdump/internal/ui/cfgui" + "github.com/rusq/slackdump/v3/cmd/slackdump/internal/ui/dumpui" + "github.com/rusq/slackdump/v3/cmd/slackdump/internal/ui/updaters" "github.com/rusq/slackdump/v3/logger" ) @@ -24,27 +27,30 @@ var CmdUninstall = &base.Command{ PrintFlags: true, } -// uninstallParams holds supported command line parameters -var uninstallParams = struct { +func init() { + CmdUninstall.Wizard = wizUninstall +} + +type uninstOptions struct { legacy bool // playwright dry bool // dry run noConfirm bool // no confirmation from the user -}{} +} + +// uninstParams holds supported command line parameters +var uninstParams = uninstOptions{} func init() { - CmdUninstall.Flag.BoolVar(&uninstallParams.legacy, "legacy-browser", false, "operate on playwright environment (default: rod envronment)") - CmdUninstall.Flag.BoolVar(&uninstallParams.dry, "dry", false, "dry run") - CmdUninstall.Flag.BoolVar(&uninstallParams.noConfirm, "no-confirm", false, "no confirmation from the user") + CmdUninstall.Flag.BoolVar(&uninstParams.legacy, "legacy-browser", false, "operate on playwright environment (default: rod envronment)") + CmdUninstall.Flag.BoolVar(&uninstParams.dry, "dry", false, "dry run") + CmdUninstall.Flag.BoolVar(&uninstParams.noConfirm, "no-confirm", false, "no confirmation from the user") } func runUninstall(ctx context.Context, cmd *base.Command, args []string) error { if len(args) != 0 { base.SetExitStatus(base.SInvalidParameters) } - if uninstallParams.dry { - return nil - } - if !uninstallParams.noConfirm { + if !uninstParams.noConfirm { confirmed, err := ui.Confirm("This will uninstall the EZ-Login browser", true) if err != nil { return err @@ -56,24 +62,34 @@ func runUninstall(ctx context.Context, cmd *base.Command, args []string) error { si := info.CollectRaw() - if uninstallParams.legacy { - return uninstallPlaywright(ctx, si.Playwright) + if uninstParams.legacy { + return uninstallPlaywright(ctx, si.Playwright, uninstParams.dry) } else { - return uninstallRod(ctx, si.Rod) + return uninstallRod(ctx, si.Rod, uninstParams.dry) } } -func uninstallPlaywright(ctx context.Context, si info.PwInfo) error { - if si.Path == "" { - return errors.New("unable to determine playwright path") +func removeFunc(dry bool) func(string) error { + var removeFn = os.RemoveAll + if dry { + removeFn = func(name string) error { + fmt.Printf("Would remove %s\n", name) + return nil + } } + return removeFn +} + +func uninstallPlaywright(ctx context.Context, si info.PwInfo, dry bool) error { + removeFn := removeFunc(dry) lg := logger.FromContext(ctx) lg.Printf("Deleting %s", si.Path) - if err := os.RemoveAll(si.Path); err != nil { + if err := removeFn(si.Path); err != nil { return fmt.Errorf("failed to remove the playwright library: %w", err) } - lg.Printf("Deleting browsers") - if err := os.RemoveAll(si.BrowsersPath); err != nil { + lg.Printf("Deleting browsers in %s", si.BrowsersPath) + + if err := removeFn(si.BrowsersPath); err != nil { return fmt.Errorf("failed to remove the playwright browsers: %w", err) } dir, _ := filepath.Split(si.Path) @@ -81,24 +97,64 @@ func uninstallPlaywright(ctx context.Context, si info.PwInfo) error { return errors.New("unable to reliably determine playwright path") } lg.Printf("Deleting all playwright versions from: %s", dir) - if err := os.RemoveAll(dir); err != nil { + if err := removeFn(dir); err != nil { return fmt.Errorf("failed to remove the playwright versions: %w", err) } return nil } -func uninstallRod(_ context.Context, si info.RodInfo) error { +func uninstallRod(_ context.Context, si info.RodInfo, dry bool) error { + removeFn := removeFunc(dry) if si.Path == "" { return errors.New("unable to determine rod browser path") } lg := cfg.Log lg.Printf("Deleting incognito Browser...") - _ = slackauth.RemoveBrowser() // just to make sure. + if !dry { + _ = slackauth.RemoveBrowser() // just to make sure. + } else { + lg.Printf("Would remove incognito browser") + } + lg.Printf("Deleting %s...", si.Path) - if err := os.RemoveAll(si.Path); err != nil { + if err := removeFn(si.Path); err != nil { return fmt.Errorf("failed to remove the rod browser: %w", err) } return nil } + +func wizUninstall(ctx context.Context, cmd *base.Command, args []string) error { + w := dumpui.Wizard{ + Name: "Uninstall", + Title: "Uninstall Slackdump", + LocalConfig: uninstParams.configuration, + Cmd: CmdUninstall, + } + return w.Run(ctx) +} + +func (p *uninstOptions) configuration() cfgui.Configuration { + p.noConfirm = true + return cfgui.Configuration{ + { + Name: "Uninstall options", + Params: []cfgui.Parameter{ + { + Name: "Playwright", + Value: cfgui.Checkbox(p.legacy), + Description: "Environment to uninstall (if unselected, uninstalls Rod)", + Updater: updaters.NewBool(&p.legacy), + }, + { + Name: "Dry run", + Value: cfgui.Checkbox(p.dry), + Description: "Do not perform the uninstallation, just show what would be done", + Updater: updaters.NewBool(&p.dry), + }, + // TODO: delete slackdump from user cache options. + }, + }, + } +} diff --git a/cmd/slackdump/internal/diag/wizdebug.go b/cmd/slackdump/internal/diag/wizdebug.go index 8ce811d1..097fe76e 100644 --- a/cmd/slackdump/internal/diag/wizdebug.go +++ b/cmd/slackdump/internal/diag/wizdebug.go @@ -56,7 +56,7 @@ func runWizDebug(ctx context.Context, cmd *base.Command, args []string) error { } func debugDumpUI(ctx context.Context) error { - mnu := []menu.MenuItem{ + mnu := []menu.Item{ { ID: "run", Name: "Run", diff --git a/cmd/slackdump/internal/emoji/emojidl/emoji_test.go b/cmd/slackdump/internal/emoji/emojidl/emoji_test.go index 8f3ea24c..94a11e66 100644 --- a/cmd/slackdump/internal/emoji/emojidl/emoji_test.go +++ b/cmd/slackdump/internal/emoji/emojidl/emoji_test.go @@ -13,7 +13,6 @@ import ( "reflect" "sync" "testing" - "time" "go.uber.org/mock/gomock" @@ -37,10 +36,6 @@ func setGlobalFetchFn(fn fetchFunc) { fetchFn = fn } -func init() { - rand.Seed(time.Now().UnixNano()) -} - func Test_fetchEmoji(t *testing.T) { type args struct { ctx context.Context diff --git a/cmd/slackdump/internal/export/v3.go b/cmd/slackdump/internal/export/v3.go index 514f447c..3078980c 100644 --- a/cmd/slackdump/internal/export/v3.go +++ b/cmd/slackdump/internal/export/v3.go @@ -38,7 +38,7 @@ func exportV3(ctx context.Context, sess *slackdump.Session, fsa fsadapter.FS, li } defer chunkdir.Close() if !lg.IsDebug() { - defer chunkdir.RemoveAll() + defer func() { _ = chunkdir.RemoveAll() }() } updFn := func() func(_ *slack.Channel, m *slack.Message) error { // hack: wrapper around the message update function, which does not @@ -63,7 +63,7 @@ func exportV3(ctx context.Context, sess *slackdump.Session, fsa fsadapter.FS, li progressbar.OptionSpinnerType(8)), lg.IsDebug(), ) - pb.RenderBlank() + _ = pb.RenderBlank() stream := sess.Stream( stream.OptOldest(params.Oldest), @@ -71,7 +71,7 @@ func exportV3(ctx context.Context, sess *slackdump.Session, fsa fsadapter.FS, li stream.OptResultFn(func(sr stream.Result) error { lg.Debugf("conversations: %s", sr.String()) pb.Describe(sr.String()) - pb.Add(1) + _ = pb.Add(1) return nil }), ) @@ -90,10 +90,10 @@ func exportV3(ctx context.Context, sess *slackdump.Session, fsa fsadapter.FS, li lg.Print("running export...") if err := ctr.Run(ctx, list); err != nil { - pb.Finish() + _ = pb.Finish() return err } - pb.Finish() + _ = pb.Finish() // at this point no goroutines are running, we are safe to assume that // everything we need is in the chunk directory. if err := conv.WriteIndex(); err != nil { diff --git a/cmd/slackdump/internal/export/v3_test.go b/cmd/slackdump/internal/export/v3_test.go index ed54e6f4..c64adc17 100644 --- a/cmd/slackdump/internal/export/v3_test.go +++ b/cmd/slackdump/internal/export/v3_test.go @@ -1,10 +1,7 @@ package export import ( - "bytes" - "compress/gzip" "context" - "io" "log" "net/http" "os" @@ -93,26 +90,3 @@ func Test_exportV3(t *testing.T) { } }) } - -func load(t *testing.T, filename string) io.ReadSeeker { - absPath, err := filepath.Abs(filename) - if err != nil { - t.Fatal(err) - } - t.Log("test file", absPath) - f, err := os.Open(absPath) - if err != nil { - t.Fatal(err) - } - defer f.Close() - gz, err := gzip.NewReader(f) - if err != nil { - t.Fatal(err) - } - var buf bytes.Buffer - _, err = io.Copy(&buf, gz) - if err != nil { - t.Fatal(err) - } - return bytes.NewReader(buf.Bytes()) -} diff --git a/cmd/slackdump/internal/export/wizard.go b/cmd/slackdump/internal/export/wizard.go index 0e636fdc..fcf6e7f3 100644 --- a/cmd/slackdump/internal/export/wizard.go +++ b/cmd/slackdump/internal/export/wizard.go @@ -2,8 +2,6 @@ package export import ( "context" - "errors" - "regexp" "github.com/charmbracelet/huh" "github.com/rusq/slackdump/v3/cmd/slackdump/internal/golang/base" @@ -63,22 +61,9 @@ func (fl *exportFlags) configuration() cfgui.Configuration { Value: fl.ExportToken, Description: "File export token to append to each of the file URLs", Inline: true, - Updater: updaters.NewString(&fl.ExportToken, "", false, validateToken), + Updater: updaters.NewString(&fl.ExportToken, "", false, structures.ValidateToken), }, }, }, } } - -// tokenRe is a loose regular expression to match Slack API tokens. -// a - app, b - bot, c - client, e - export, p - legacy -var tokenRE = regexp.MustCompile(`xox[abcep]-[0-9]+-[0-9]+-[0-9]+-[0-9a-z]{64}`) - -var errInvalidToken = errors.New("token must start with xoxa-, xoxb-, xoxc- or xoxe- and be followed by 4 numbers and 64 lowercase letters") - -func validateToken(token string) error { - if !tokenRE.MatchString(token) { - return errInvalidToken - } - return nil -} diff --git a/cmd/slackdump/internal/golang/base/base.go b/cmd/slackdump/internal/golang/base/base.go index 572ef235..8c56ac9c 100644 --- a/cmd/slackdump/internal/golang/base/base.go +++ b/cmd/slackdump/internal/golang/base/base.go @@ -64,6 +64,9 @@ type Command struct { // The order here is the order in which they are printed by 'slackdump help'. // Note that subcommands are in general best avoided. Commands []*Command + + //HideWizard if set to true disables the display in wizard. + HideWizard bool } var Slackdump = &Command{ diff --git a/cmd/slackdump/internal/golang/base/ui.go b/cmd/slackdump/internal/golang/base/ui.go index 04609638..6cf2e59d 100644 --- a/cmd/slackdump/internal/golang/base/ui.go +++ b/cmd/slackdump/internal/golang/base/ui.go @@ -15,7 +15,11 @@ func YesNoWR(w io.Writer, r io.Reader, message string) bool { for { fmt.Fprint(w, message, "? (y/N) ") var resp string - fmt.Fscanln(r, &resp) + _, err := fmt.Fscanln(r, &resp) + if err != nil { + fmt.Fprintln(w, "Please answer yes or no and press Enter or Return.") + continue + } resp = strings.TrimSpace(resp) if len(resp) > 0 { switch strings.ToLower(resp)[0] { diff --git a/cmd/slackdump/internal/list/channels.go b/cmd/slackdump/internal/list/channels.go index 6933e96b..7bebcc0e 100644 --- a/cmd/slackdump/internal/list/channels.go +++ b/cmd/slackdump/internal/list/channels.go @@ -3,10 +3,13 @@ package list import ( "context" "fmt" + "log" "runtime/trace" "time" + "github.com/rusq/slack" "github.com/rusq/slackdump/v3" + "github.com/rusq/slackdump/v3/cmd/slackdump/internal/bootstrap" "github.com/rusq/slackdump/v3/cmd/slackdump/internal/cfg" "github.com/rusq/slackdump/v3/cmd/slackdump/internal/golang/base" "github.com/rusq/slackdump/v3/internal/cache" @@ -15,7 +18,7 @@ import ( ) var CmdListChannels = &base.Command{ - Run: listChannels, + Run: runListChannels, UsageLine: "slackdump list channels [flags] [filename]", PrintFlags: true, FlagMask: cfg.OmitDownloadFlag, @@ -32,85 +35,114 @@ workspace has lots of them. The channels are cached, and the cache is valid for %s. Use the -no-chan-cache and -chan-cache-retention flags to control the cache behavior. -`+sectListFormat, chanCacheOpts.Retention), +`+sectListFormat, chanFlags.cache.Retention), RequireAuth: true, } -var noresolve bool +type ( + channelOptions struct { + resolveUsers bool + cache cacheOpts + } -func init() { - CmdListChannels.Flag.BoolVar(&chanCacheOpts.Disabled, "no-chan-cache", chanCacheOpts.Disabled, "disable channel cache") - CmdListChannels.Flag.DurationVar(&chanCacheOpts.Retention, "chan-cache-retention", chanCacheOpts.Retention, "channel cache retention time. After this time, the cache is considered stale and will be refreshed.") - CmdListChannels.Flag.BoolVar(&noresolve, "no-resolve", noresolve, "do not resolve user IDs to names") + cacheOpts struct { + Enabled bool + Retention time.Duration + Filename string + } +) + +var chanFlags = channelOptions{ + resolveUsers: false, + cache: cacheOpts{ + Enabled: false, + Retention: 20 * time.Minute, + Filename: "channels.json", + }, } -func listChannels(ctx context.Context, cmd *base.Command, args []string) error { - if err := list(ctx, func(ctx context.Context, sess *slackdump.Session) (any, string, error) { - ctx, task := trace.NewTask(ctx, "listChannels") - defer task.End() +func init() { + CmdListChannels.Wizard = wizChannels - var filename = makeFilename("channels", sess.Info().TeamID, ".json") - if len(args) > 0 { - filename = args[0] - } - teamID := sess.Info().TeamID - cc, ok := maybeLoadChanCache(cfg.CacheDir(), teamID) - if ok { - // cache hit - trace.Logf(ctx, "cache hit", "teamID=%s", teamID) - return cc, filename, nil - } - // cache miss, load from API - trace.Logf(ctx, "cache miss", "teamID=%s", teamID) - cc, err := sess.GetChannels(ctx) - if err != nil { - return nil, "", fmt.Errorf("error getting channels: %w", err) - } - if err := saveCache(cfg.CacheDir(), teamID, cc); err != nil { - // warn, but don't fail - logger.FromContext(ctx).Printf("failed to save cache: %v", err) - } - return cc, filename, nil - }); err != nil { + CmdListChannels.Flag.BoolVar(&chanFlags.cache.Enabled, "no-chan-cache", chanFlags.cache.Enabled, "disable channel cache") + CmdListChannels.Flag.DurationVar(&chanFlags.cache.Retention, "chan-cache-retention", chanFlags.cache.Retention, "channel cache retention time. After this time, the cache is considered stale and will be refreshed.") + CmdListChannels.Flag.BoolVar(&chanFlags.resolveUsers, "resolve", chanFlags.resolveUsers, "resolve user IDs to names") +} + +func runListChannels(ctx context.Context, cmd *base.Command, args []string) error { + sess, err := bootstrap.SlackdumpSession(ctx) + if err != nil { + base.SetExitStatus(base.SInitializationError) return err } - return nil + var l = &channels{ + opts: chanFlags, + common: commonFlags, + } + + return list(ctx, sess, l, filename) } -type cacheConfig struct { - Disabled bool - Retention time.Duration - Filename string +type channels struct { + channels types.Channels + users types.Users + + opts channelOptions + common commonOpts } -var chanCacheOpts = cacheConfig{ - Disabled: false, - Retention: 20 * time.Minute, - Filename: "channels.json", +func (l *channels) Type() string { + return "channels" } -func maybeLoadChanCache(cacheDir string, teamID string) (types.Channels, bool) { - if chanCacheOpts.Disabled { - // channel cache disabled - return nil, false - } - m, err := cache.NewManager(cacheDir) - if err != nil { - return nil, false - } - cc, err := m.LoadChannels(teamID, chanCacheOpts.Retention) - if err != nil { - return nil, false - } - return cc, true +func (l *channels) Data() types.Channels { + return l.channels } -func saveCache(cacheDir, teamID string, cc types.Channels) error { - m, err := cache.NewManager(cacheDir) +func (l *channels) Users() []slack.User { + return l.users +} + +func (l *channels) Retrieve(ctx context.Context, sess *slackdump.Session, m *cache.Manager) error { + ctx, task := trace.NewTask(ctx, "channels.List") + defer task.End() + lg := cfg.Log + + teamID := sess.Info().TeamID + + usersc := make(chan []slack.User) + go func() { + defer close(usersc) + if l.opts.resolveUsers { + + lg.Println("getting users to resolve DM names") + u, err := fetchUsers(ctx, sess, m, cfg.NoUserCache, teamID) + if err != nil { + log.Printf("error getting users to resolve DM names (ignored): %s", err) + return + } + usersc <- u + } + }() + + if l.opts.cache.Enabled { + var err error + l.channels, err = m.LoadChannels(teamID, l.opts.cache.Retention) + if err == nil { + l.users = <-usersc + return nil + } + } + cc, err := sess.GetChannels(ctx) if err != nil { - return err + return fmt.Errorf("error getting channels: %w", err) + } + l.channels = cc + l.users = <-usersc + if err := m.CacheChannels(teamID, cc); err != nil { + logger.FromContext(ctx).Printf("warning: failed to cache channels (ignored): %s", err) } - return m.CacheChannels(teamID, cc) + return nil } diff --git a/cmd/slackdump/internal/list/common.go b/cmd/slackdump/internal/list/common.go new file mode 100644 index 00000000..f9e259ca --- /dev/null +++ b/cmd/slackdump/internal/list/common.go @@ -0,0 +1,173 @@ +package list + +import ( + "context" + "flag" + "fmt" + "io" + "os" + + "github.com/rusq/slack" + "github.com/rusq/slackdump/v3" + "github.com/rusq/slackdump/v3/cmd/slackdump/internal/cfg" + "github.com/rusq/slackdump/v3/cmd/slackdump/internal/golang/base" + "github.com/rusq/slackdump/v3/internal/cache" + "github.com/rusq/slackdump/v3/internal/format" + "github.com/rusq/slackdump/v3/logger" + "github.com/rusq/slackdump/v3/types" +) + +// CmdList is the list command. The logic is in the subcommands. +var CmdList = &base.Command{ + UsageLine: "slackdump list", + Short: "list users or channels", + Long: fmt.Sprintf(` +# List Command + +List lists users or channels for the Slack Workspace. It may take a while on a +large workspace, as Slack limits the amount of requests on it's own discretion, +which is sometimes unreasonably slow. + +The data is dumped to a JSON file in the base directory, and additionally, +printed on the screen in the requested format. + +- To disable saving data to a file, use '-no-save' flag. +- To disable printing on the screen, use '-q' (quiet) flag. + +## Caching +Channel and User data is cached. Default user cache retention is %s, and +channel cache — %s. This is to speed up consecutive runs of the command. + +The caching can be turned off by using flags "-no-user-cache" and +"-no-chan-cache". +`, cfg.UserCacheRetention, chanFlags.cache.Retention), + Commands: []*base.Command{ + CmdListUsers, + CmdListChannels, + }, +} + +type lister[T any] interface { + // Type should return the type of the lister. + Type() string + // Retrieve should retrieve the data from the API or cache. + Retrieve(ctx context.Context, sess *slackdump.Session, m *cache.Manager) error + // Data should return the retrieved data. + Data() T + // Users should return the users for the data, or nil, which indicates + // that there are no associated users or that the users are not resolved. + Users() []slack.User +} + +// common flags +type commonOpts struct { + listType format.Type + quiet bool // quiet mode: don't print anything on the screen, just save the file + nosave bool // nosave mode: don't save the data to a file, just print it to the screen +} + +var commonFlags = commonOpts{ + listType: format.CText, +} + +func init() { + for _, cmd := range CmdList.Commands { + addCommonFlags(&cmd.Flag) + } +} + +// addCommonFlags adds common flags to the flagset. +func addCommonFlags(fs *flag.FlagSet) { + fs.Var(&commonFlags.listType, "format", fmt.Sprintf("listing format, should be one of: %v", format.All())) + fs.BoolVar(&commonFlags.quiet, "q", false, "quiet mode: don't print anything on the screen, just save the file") + fs.BoolVar(&commonFlags.nosave, "no-json", false, "don't save the data to a file, just print it to the screen") +} + +func list[T any](ctx context.Context, sess *slackdump.Session, l lister[T], filename string) error { + m, err := cache.NewManager(cfg.CacheDir()) + if err != nil { + return err + } + + if err := l.Retrieve(ctx, sess, m); err != nil { + return err + } + + if !commonFlags.quiet { + if err := fmtPrint(ctx, os.Stdout, l.Data(), commonFlags.listType, l.Users()); err != nil { + return err + } + } + + if !commonFlags.nosave { + if filename == "" { + filename = makeFilename(l.Type(), sess.Info().TeamID, extForType(commonFlags.listType)) + } + if err := saveData(ctx, l.Data(), filename, commonFlags.listType, l.Users()); err != nil { + return err + } + } + return nil +} + +func extForType(typ format.Type) string { + switch typ { + case format.CJSON: + return ".json" + case format.CText: + return ".txt" + case format.CCSV: + return ".csv" + default: + return ".json" + } +} + +// saveData saves the given data to the given filename. +func saveData(ctx context.Context, data any, filename string, typ format.Type, users []slack.User) error { + // save to a filesystem. + f, err := os.Create(filename) + if err != nil { + return fmt.Errorf("failed to create file: %w", err) + } + defer f.Close() + if err := fmtPrint(ctx, f, data, typ, users); err != nil { + return err + } + logger.FromContext(ctx).Printf("Data saved to: %q\n", filename) + + return nil +} + +// fmtPrint prints the given data to the given writer, using the given format. +// It should be supplied with prepopulated users, as it may need to look up +// users by ID. +func fmtPrint(ctx context.Context, w io.Writer, a any, typ format.Type, u []slack.User) error { + // get the converter + initFn, ok := format.Converters[typ] + if !ok { + return fmt.Errorf("unknown converter type: %s", typ) + } + cvt := initFn() + + // currently there's no list function for conversations, because it + // requires additional options, and I don't want to clutter the flags - + // there's already too many. + switch val := a.(type) { + case types.Channels: + return cvt.Channels(ctx, w, u, val) + case types.Users: + return cvt.Users(ctx, w, val) + case *types.Conversation: + return cvt.Conversation(ctx, w, u, val) + default: + return fmt.Errorf("unsupported data type: %T", a) + } + // unreachable +} + +// makeFilename makes a filename for the given prefix, teamID and listType for +// channels and users. +func makeFilename(prefix string, teamID string, ext string) string { + return fmt.Sprintf("%s-%s%s", prefix, teamID, ext) +} diff --git a/cmd/slackdump/internal/list/list_test.go b/cmd/slackdump/internal/list/common_test.go similarity index 100% rename from cmd/slackdump/internal/list/list_test.go rename to cmd/slackdump/internal/list/common_test.go diff --git a/cmd/slackdump/internal/list/list.go b/cmd/slackdump/internal/list/list.go deleted file mode 100644 index ce3dcc05..00000000 --- a/cmd/slackdump/internal/list/list.go +++ /dev/null @@ -1,211 +0,0 @@ -package list - -import ( - "context" - "errors" - "flag" - "fmt" - "io" - "os" - "path/filepath" - - "github.com/charmbracelet/huh" - "github.com/rusq/fsadapter" - "github.com/rusq/slack" - "github.com/rusq/slackdump/v3" - "github.com/rusq/slackdump/v3/cmd/slackdump/internal/bootstrap" - "github.com/rusq/slackdump/v3/cmd/slackdump/internal/cfg" - "github.com/rusq/slackdump/v3/cmd/slackdump/internal/golang/base" - "github.com/rusq/slackdump/v3/internal/cache" - "github.com/rusq/slackdump/v3/internal/format" - "github.com/rusq/slackdump/v3/logger" - "github.com/rusq/slackdump/v3/types" -) - -const ( - userCacheBase = "users.cache" -) - -// CmdList is the list command. The logic is in the subcommands. -var CmdList = &base.Command{ - UsageLine: "slackdump list", - Short: "list users or channels", - Long: fmt.Sprintf(` -# List Command - -List lists users or channels for the Slack Workspace. It may take a while on a -large workspace, as Slack limits the amount of requests on it's own discretion, -which is sometimes unreasonably slow. - -The data is dumped to a JSON file in the base directory, and additionally, -printed on the screen in the requested format. - -- To disable saving data to a file, use '-no-save' flag. -- To disable printing on the screen, use '-q' (quiet) flag. - -## Caching -Channel and User data is cached. Default user cache retention is %s, and -channel cache — %s. This is to speed up consecutive runs of the command. - -The caching can be turned off by using flags "-no-user-cache" and -"-no-chan-cache". -`, cfg.UserCacheRetention, chanCacheOpts.Retention), - Commands: []*base.Command{ - CmdListUsers, - CmdListChannels, - }, -} - -// common flags -var ( - listType format.Type = format.CText - quiet bool // quiet mode: don't print anything on the screen, just save the file - nosave bool // nosave mode: don't save the data to a file, just print it to the screen -) - -func init() { - for _, cmd := range CmdList.Commands { - addCommonFlags(&cmd.Flag) - } -} - -// addCommonFlags adds common flags to the flagset. -func addCommonFlags(fs *flag.FlagSet) { - fs.Var(&listType, "format", fmt.Sprintf("listing format, should be one of: %v", format.All())) - fs.BoolVar(&quiet, "q", false, "quiet mode: don't print anything on the screen, just save the file") - fs.BoolVar(&nosave, "no-json", false, "don't save the data to a file, just print it to the screen") -} - -// listFunc is a function that lists something from the Slack API. It should -// return the object from the api, a filename to save the data to and an -// error. -type listFunc func(ctx context.Context, sess *slackdump.Session) (a any, filename string, err error) - -// list authenticates and creates a slackdump instance, then calls a listFn. -// listFn must return the object from the api, a JSON filename and an error. -func list(ctx context.Context, listFn listFunc) error { - // TODO fix users saving JSON to a text file within archive - if listType == format.CUnknown { - return errors.New("unknown listing format, seek help") - } - - // initialize the session. - sess, err := bootstrap.SlackdumpSession(ctx) - if err != nil { - base.SetExitStatus(base.SInitializationError) - return err - } - - data, filename, err := listFn(ctx, sess) - if err != nil { - return err - } - m, err := cache.NewManager(cfg.CacheDir(), cache.WithUserCacheBase(userCacheBase)) - if err != nil { - return err - } - - teamID := sess.Info().TeamID - users, ok := data.(types.Users) // Hax - if !ok && !noresolve { - if cfg.NoUserCache { - users, err = sess.GetUsers(ctx) - } else { - users, err = getCachedUsers(ctx, sess, m, teamID) - } - if err != nil { - return err - } - } - - if !nosave { - fsa, err := fsadapter.New(cfg.Output) - if err != nil { - return err - } - defer fsa.Close() - if err := saveData(ctx, fsa, data, filename, format.CJSON, users); err != nil { - return err - } - } - - if !quiet { - return fmtPrint(ctx, os.Stdout, data, listType, users) - } - - return nil -} - -// saveData saves the given data to the given filename. -func saveData(ctx context.Context, fs fsadapter.FS, data any, filename string, typ format.Type, users []slack.User) error { - // save to a filesystem. - f, err := fs.Create(filename) - if err != nil { - return fmt.Errorf("failed to create file: %w", err) - } - defer f.Close() - if err := fmtPrint(ctx, f, data, typ, users); err != nil { - return err - } - logger.FromContext(ctx).Printf("Data saved to: %q\n", filepath.Join(cfg.Output, filename)) - - return nil -} - -// fmtPrint prints the given data to the given writer, using the given format. -// It should be supplied with prepopulated users, as it may need to look up -// users by ID. -func fmtPrint(ctx context.Context, w io.Writer, a any, typ format.Type, u []slack.User) error { - // get the converter - initFn, ok := format.Converters[typ] - if !ok { - return fmt.Errorf("unknown converter type: %s", typ) - } - cvt := initFn() - - // currently there's no list function for conversations, because it - // requires additional options, and I don't want to clutter the flags - - // there's already too many. - switch val := a.(type) { - case types.Channels: - return cvt.Channels(ctx, w, u, val) - case types.Users: - return cvt.Users(ctx, w, val) - case *types.Conversation: - return cvt.Conversation(ctx, w, u, val) - default: - return fmt.Errorf("unsupported data type: %T", a) - } - // unreachable -} - -// makeFilename makes a filename for the given prefix, teamID and listType for -// channels and users. -func makeFilename(prefix string, teamID string, ext string) string { - return fmt.Sprintf("%s-%s%s", prefix, teamID, ext) -} - -func wizard(context.Context, listFunc) error { - // pick format - var types []string - for _, t := range format.All() { - types = append(types, t.String()) - } - - var listType format.Type - var ot string - - form := huh.NewForm( - huh.NewGroup( - huh.NewSelect[format.Type]().Title("Pick a format").Options(huh.NewOptions(format.All()...)...).Value(&listType), - huh.NewSelect[string]().Title("Pick an output type").Options(huh.NewOptions("screen", "ZIP file", "directory")...).Value(&ot), - )) - if err := form.Run(); err != nil { - return err - } - if ot != "screen" { - return errors.New("not implemented yet") - } - // if file/directory, pick filename - return nil -} diff --git a/cmd/slackdump/internal/list/users.go b/cmd/slackdump/internal/list/users.go index c04308e8..6e3ab60e 100644 --- a/cmd/slackdump/internal/list/users.go +++ b/cmd/slackdump/internal/list/users.go @@ -5,11 +5,11 @@ import ( "errors" "fmt" "os" - "runtime/trace" "time" "github.com/rusq/slack" "github.com/rusq/slackdump/v3" + "github.com/rusq/slackdump/v3/cmd/slackdump/internal/bootstrap" "github.com/rusq/slackdump/v3/cmd/slackdump/internal/cfg" "github.com/rusq/slackdump/v3/cmd/slackdump/internal/golang/base" "github.com/rusq/slackdump/v3/internal/cache" @@ -19,12 +19,12 @@ import ( ) var CmdListUsers = &base.Command{ - Run: listUsers, - Wizard: wizUsers, - UsageLine: "slackdump list users [flags] [filename]", - PrintFlags: true, - FlagMask: cfg.OmitDownloadFlag, - Short: "list workspace users", + Run: runListUsers, + UsageLine: "slackdump list users [flags] [filename]", + PrintFlags: true, + FlagMask: cfg.OmitDownloadFlag, + Short: "list workspace users", + RequireAuth: true, Long: fmt.Sprintf(` # List Users @@ -34,29 +34,51 @@ Users are cached for %v. To disable caching, use '-no-user-cache' flag and '-user-cache-retention' flag to control the caching behaviour. `+ sectListFormat, cfg.UserCacheRetention), - RequireAuth: true, } -func listUsers(ctx context.Context, cmd *base.Command, args []string) error { - if err := list(ctx, func(ctx context.Context, sess *slackdump.Session) (any, string, error) { - var filename = makeFilename("users", sess.Info().TeamID, ".json") - if len(args) > 0 { - filename = args[0] - } - users, err := sess.GetUsers(ctx) - return users, filename, err - }); err != nil { +func init() { + CmdListUsers.Wizard = wizUsers +} + +func runListUsers(ctx context.Context, cmd *base.Command, args []string) error { + sess, err := bootstrap.SlackdumpSession(ctx) + if err != nil { + base.SetExitStatus(base.SInitializationError) return err } + + var l = &users{ + common: commonFlags, + } + + return list(ctx, sess, l, filename) +} + +type users struct { + data types.Users + + common commonOpts +} + +func (u *users) Type() string { + return "users" +} + +func (u *users) Data() types.Users { + return u.data +} + +func (u *users) Users() []slack.User { return nil } -func wizUsers(ctx context.Context, cmd *base.Command, args []string) error { - return wizard(ctx, func(ctx context.Context, sess *slackdump.Session) (any, string, error) { - var filename = makeFilename("users", sess.Info().TeamID, ".json") - users, err := sess.GetUsers(ctx) - return users, filename, err - }) +func (u *users) Retrieve(ctx context.Context, sess *slackdump.Session, m *cache.Manager) error { + users, err := fetchUsers(ctx, sess, m, cfg.NoUserCache, sess.Info().TeamID) + if err != nil { + return err + } + u.data = users + return nil } //go:generate mockgen -source=users.go -destination=mocks_test.go -package=list userGetter,userCacher @@ -70,31 +92,32 @@ type userCacher interface { CacheUsers(teamID string, users []slack.User) error } -func getCachedUsers(ctx context.Context, ug userGetter, m userCacher, teamID string) ([]slack.User, error) { +func fetchUsers(ctx context.Context, ug userGetter, m userCacher, skipCache bool, teamID string) ([]slack.User, error) { lg := logger.FromContext(ctx) - users, err := m.LoadUsers(teamID, cfg.UserCacheRetention) - if err == nil { - return users, nil - } + if !skipCache { + // attempt to load from cache + users, err := m.LoadUsers(teamID, cfg.UserCacheRetention) + if err == nil { + return users, nil + } - // failed to load from cache - if !errors.Is(err, cache.ErrExpired) && !errors.Is(err, cache.ErrEmpty) && !os.IsNotExist(err) && !osext.IsPathError(err) { - // some funky error - return nil, err + // failed to load from cache + if !errors.Is(err, cache.ErrExpired) && !errors.Is(err, cache.ErrEmpty) && !os.IsNotExist(err) && !osext.IsPathError(err) { + // some funky error + return nil, err + } + lg.Println("user cache expired or empty, caching users") } - lg.Println("user cache expired or empty, caching users") - // getting users from API - users, err = ug.GetUsers(ctx) + users, err := ug.GetUsers(ctx) if err != nil { return nil, err } // saving users to cache, will ignore any errors, but notify the user. if err := m.CacheUsers(teamID, users); err != nil { - trace.Logf(ctx, "error", "saving user cache to %q, error: %s", userCacheBase, err) - lg.Printf("warning: failed saving user cache to %q: %s, but nevermind, let's continue", userCacheBase, err) + lg.Printf("warning: failed saving user cache (ignored): %s", err) } return users, nil diff --git a/cmd/slackdump/internal/list/users_test.go b/cmd/slackdump/internal/list/users_test.go index 924276c8..b341c25a 100644 --- a/cmd/slackdump/internal/list/users_test.go +++ b/cmd/slackdump/internal/list/users_test.go @@ -20,8 +20,9 @@ func Test_getCachedUsers(t *testing.T) { } ) type args struct { - ctx context.Context - teamID string + ctx context.Context + skipCache bool + teamID string } tests := []struct { name string @@ -33,7 +34,7 @@ func Test_getCachedUsers(t *testing.T) { /* oh happy days */ { "users loaded from cache", - args{context.Background(), "TEAM1"}, + args{context.Background(), false, "TEAM1"}, func(c *MockuserCacher, g *MockuserGetter) { c.EXPECT().LoadUsers("TEAM1", gomock.Any()).Return(testUsers, nil) }, @@ -42,7 +43,7 @@ func Test_getCachedUsers(t *testing.T) { }, { "getting users from API ok (recoverable cache error)", - args{context.Background(), "TEAM1"}, + args{context.Background(), false, "TEAM1"}, func(c *MockuserCacher, g *MockuserGetter) { c.EXPECT().LoadUsers("TEAM1", gomock.Any()).Return(nil, &fs.PathError{}) g.EXPECT().GetUsers(gomock.Any()).Return(testUsers, nil) @@ -53,7 +54,7 @@ func Test_getCachedUsers(t *testing.T) { }, { "saving cache fails, but we continue", - args{context.Background(), "TEAM1"}, + args{context.Background(), false, "TEAM1"}, func(c *MockuserCacher, g *MockuserGetter) { c.EXPECT().LoadUsers("TEAM1", gomock.Any()).Return(nil, &fs.PathError{}) g.EXPECT().GetUsers(gomock.Any()).Return(testUsers, nil) @@ -65,7 +66,7 @@ func Test_getCachedUsers(t *testing.T) { /* unhappy days */ { "unrecoverable error", - args{context.Background(), "TEAM1"}, + args{context.Background(), false, "TEAM1"}, func(c *MockuserCacher, g *MockuserGetter) { c.EXPECT().LoadUsers("TEAM1", gomock.Any()).Return(nil, errors.New("frobnication error")) }, @@ -74,7 +75,7 @@ func Test_getCachedUsers(t *testing.T) { }, { "getting users from API fails", - args{context.Background(), "TEAM1"}, + args{context.Background(), false, "TEAM1"}, func(c *MockuserCacher, g *MockuserGetter) { c.EXPECT().LoadUsers("TEAM1", gomock.Any()).Return(nil, &fs.PathError{}) g.EXPECT().GetUsers(gomock.Any()).Return(nil, errors.New("blip")) @@ -91,7 +92,7 @@ func Test_getCachedUsers(t *testing.T) { tt.expect(muc, mug) - got, err := getCachedUsers(tt.args.ctx, mug, muc, tt.args.teamID) + got, err := fetchUsers(tt.args.ctx, mug, muc, tt.args.skipCache, tt.args.teamID) if (err != nil) != tt.wantErr { t.Errorf("getCachedUsers() error = %v, wantErr %v", err, tt.wantErr) return diff --git a/cmd/slackdump/internal/list/wizard.go b/cmd/slackdump/internal/list/wizard.go new file mode 100644 index 00000000..7f101d00 --- /dev/null +++ b/cmd/slackdump/internal/list/wizard.go @@ -0,0 +1,155 @@ +package list + +import ( + "context" + + "github.com/charmbracelet/huh" + "github.com/rusq/slackdump/v3/cmd/slackdump/internal/bootstrap" + "github.com/rusq/slackdump/v3/cmd/slackdump/internal/golang/base" + "github.com/rusq/slackdump/v3/cmd/slackdump/internal/ui/cfgui" + "github.com/rusq/slackdump/v3/cmd/slackdump/internal/ui/dumpui" + "github.com/rusq/slackdump/v3/cmd/slackdump/internal/ui/updaters" + "github.com/rusq/slackdump/v3/internal/format" +) + +var filename string + +func wizUsers(ctx context.Context, _ *base.Command, _ []string) error { + sess, err := bootstrap.SlackdumpSession(ctx) + if err != nil { + base.SetExitStatus(base.SInitializationError) + return err + } + + filename = makeFilename("users", sess.Info().TeamID, ".json") + w := dumpui.Wizard{ + Title: "List Users", + Name: "List", + LocalConfig: userConfiguration, + Cmd: CmdListUsers, + ArgsFn: func() []string { + return []string{filename} + }, + } + return w.Run(ctx) +} + +func wizChannels(ctx context.Context, _ *base.Command, _ []string) error { + sess, err := bootstrap.SlackdumpSession(ctx) + if err != nil { + base.SetExitStatus(base.SInitializationError) + return err + } + + filename = makeFilename("channels", sess.Info().TeamID, ".json") + w := dumpui.Wizard{ + Title: "List Channels", + Name: "List", + LocalConfig: chanFlags.configuration, + Cmd: CmdListChannels, + ArgsFn: func() []string { + return []string{filename} + }, + } + return w.Run(ctx) +} + +func userConfiguration() cfgui.Configuration { + c := cfgui.Configuration{ + cfgui.ParamGroup{ + Name: "User List Options", + Params: []cfgui.Parameter{ + filenameParam("users.json"), + }, + }, + } + return append(c, commonFlags.configuration()...) +} + +func filenameParam(placeholder string) cfgui.Parameter { + return cfgui.Parameter{ + Name: "Output Filename", + Value: filename, + Description: "The filename to save the output to", + Inline: true, + Updater: updaters.NewFileNew(&filename, placeholder, false, true), + } +} + +func (o *channelOptions) configuration() cfgui.Configuration { + c := cfgui.Configuration{ + cfgui.ParamGroup{ + Name: "Channel Options", + Params: []cfgui.Parameter{ + filenameParam("channels.json"), + { + Name: "Resolve Users", + Value: cfgui.Checkbox(o.resolveUsers), + Description: "Resolve user IDs to names. Slow on large Slack workspaces.", + Updater: updaters.NewBool(&o.resolveUsers), + }, + }, + }, + cfgui.ParamGroup{ + Name: "Cache Options", + Params: []cfgui.Parameter{ + { + Name: "Disable Cache", + Value: cfgui.Checkbox(o.cache.Enabled), + Description: "Disable channel cache", + Updater: updaters.NewBool(&o.cache.Enabled), + }, + { + Name: "Cache Retention", + Value: o.cache.Retention.String(), + Description: "Channel cache retention time. After this time, the cache is considered stale and will be refreshed.", + Inline: true, + Updater: updaters.NewDuration(&o.cache.Retention, false), + }, + { + Name: "Cache Filename", + Value: o.cache.Filename, + Description: "The filename of the cache", + Inline: true, + Updater: updaters.NewString(&o.cache.Filename, "channels.json", false, huh.ValidateNotEmpty()), + }, + }, + }, + } + return append(c, commonFlags.configuration()...) +} + +func (l *commonOpts) configuration() cfgui.Configuration { + c := cfgui.Configuration{ + cfgui.ParamGroup{ + Name: "Common Options", + Params: []cfgui.Parameter{ + { + Name: "List Type", + Value: l.listType.String(), + Description: "The output list type", + Updater: updaters.NewPicklist(&l.listType, huh.NewSelect[format.Type](). + Title("List Type"). + Options( + huh.NewOption("Text", format.CText), + huh.NewOption("JSON", format.CJSON), + huh.NewOption("CSV", format.CCSV), + )), + }, + { + Name: "Quiet Mode", + Value: cfgui.Checkbox(l.quiet), + Description: "Don't print anything on the screen, just save the file", + Updater: updaters.NewBool(&l.quiet), + }, + { + Name: "Display Only", + Value: cfgui.Checkbox(l.nosave), + Description: "Don't save the data to a file, just print it to the screen", + Updater: updaters.NewBool(&l.nosave), + }, + }, + }, + } + return c +} diff --git a/cmd/slackdump/internal/ui/ask/timerange.go b/cmd/slackdump/internal/ui/ask/timerange.go index c4f58dfd..02e3b91f 100644 --- a/cmd/slackdump/internal/ui/ask/timerange.go +++ b/cmd/slackdump/internal/ui/ask/timerange.go @@ -25,7 +25,6 @@ func TimeRange() (oldest, latest time.Time, err error) { if oldest, err = ui.Time("Earliest message"); err != nil && !errors.Is(err, ui.ErrEmptyOptionalInput) { return } - err = nil if latest, err = ui.Time("Latest message"); err != nil && !errors.Is(err, ui.ErrEmptyOptionalInput) { return } diff --git a/cmd/slackdump/internal/ui/bubbles/btime/btime.go b/cmd/slackdump/internal/ui/bubbles/btime/btime.go index 5c1c38c6..50f2abb9 100644 --- a/cmd/slackdump/internal/ui/bubbles/btime/btime.go +++ b/cmd/slackdump/internal/ui/bubbles/btime/btime.go @@ -187,13 +187,6 @@ func (m *Model) Update(msg tea.Msg) (*Model, tea.Cmd) { return m, nil } -func (m *Model) whatIf(digit int, hasVal int) int { - whatIf := make([]int, len(m.entry)) - copy(whatIf, m.entry[:]) - whatIf[digit] = hasVal - return tupleVal(whatIf, m.cursor/2) -} - func (m *Model) updateTime() { hour := tupleVal(m.entry[:], 0) minute := tupleVal(m.entry[:], 1) diff --git a/cmd/slackdump/internal/ui/bubbles/menu/menuitem.go b/cmd/slackdump/internal/ui/bubbles/menu/menuitem.go index 41fa62b3..8317f2d9 100644 --- a/cmd/slackdump/internal/ui/bubbles/menu/menuitem.go +++ b/cmd/slackdump/internal/ui/bubbles/menu/menuitem.go @@ -1,7 +1,7 @@ package menu -// MenuItem is an item in a menu. -type MenuItem struct { +// Item is an item in a menu. +type Item struct { // ID is an arbitrary ID, up to caller. ID string // Separator is a flag that determines whether the item is a separator or @@ -15,17 +15,20 @@ type MenuItem struct { // Model is any model that should be displayed when the item is selected, // or executed when the user presses enter. Model FocusModel + // Preview suggests that the Model should attempt to show the preview + // of this item. + Preview bool // Validate determines whether the item is disabled or not. It should // complete in reasonable time, as it is called on every render. The // return error is used in the description for the item. Validate func() error // when to enable the item } -func (m MenuItem) IsDisabled() bool { +func (m Item) IsDisabled() bool { return m.Validate != nil && m.Validate() != nil } -func (m MenuItem) DisabledReason() string { +func (m Item) DisabledReason() string { if m.Validate != nil { if err := m.Validate(); err != nil { return err.Error() diff --git a/cmd/slackdump/internal/ui/bubbles/menu/model.go b/cmd/slackdump/internal/ui/bubbles/menu/model.go index 041bd963..032d8092 100644 --- a/cmd/slackdump/internal/ui/bubbles/menu/model.go +++ b/cmd/slackdump/internal/ui/bubbles/menu/model.go @@ -2,11 +2,13 @@ package menu import ( "strings" + "unicode" "github.com/charmbracelet/bubbles/help" "github.com/charmbracelet/bubbles/key" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" + "github.com/rusq/slackdump/v3/cmd/slackdump/internal/ui" "github.com/rusq/slackdump/v3/cmd/slackdump/internal/ui/cfgui" "github.com/rusq/slackdump/v3/cmd/slackdump/internal/ui/updaters" @@ -14,11 +16,11 @@ import ( type Model struct { // Selected will be set to the selected item from the items. - Selected MenuItem + Selected Item Cancelled bool title string - items []MenuItem + items []Item finishing bool focused bool preview bool // preview child model @@ -28,9 +30,18 @@ type Model struct { help help.Model cursor int + last int } -func New(title string, items []MenuItem, preview bool) *Model { +func New(title string, items []Item, preview bool) *Model { + var last = len(items) - 1 + for i := last; i >= 0; i++ { + if !items[i].Separator { + break + } + last-- + } + return &Model{ title: title, items: items, @@ -40,6 +51,8 @@ func New(title string, items []MenuItem, preview bool) *Model { focused: true, preview: preview, finishing: false, + cursor: 0, + last: last, } } @@ -75,21 +88,29 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.Selected = m.items[m.cursor] cmds = append(cmds, tea.Quit) case key.Matches(msg, m.Keymap.Up): - for { - if m.cursor > 0 { - m.cursor-- - } - if !m.items[m.cursor].Separator { - break + if m.cursor == 0 { + m.cursor = m.last + } else { + for { + if m.cursor > 0 { + m.cursor-- + } + if !m.items[m.cursor].Separator { + break + } } } case key.Matches(msg, m.Keymap.Down): - for { - if m.cursor < len(m.items)-1 { - m.cursor++ - } - if !m.items[m.cursor].Separator { - break + if m.cursor == m.last { + m.cursor = 0 + } else { + for { + if m.cursor < m.last { + m.cursor++ + } + if !m.items[m.cursor].Separator { + break + } } } case key.Matches(msg, m.Keymap.Select): @@ -126,10 +147,10 @@ func (m *Model) View() string { if m.finishing { return "" } - if m.items[m.cursor].Model != nil { + if item := m.items[m.cursor]; item.Model != nil { if m.focused { - if m.preview { - return lipgloss.JoinHorizontal(lipgloss.Top, m.view(), m.items[m.cursor].Model.View()) + if item.Preview && m.preview { + return lipgloss.JoinHorizontal(lipgloss.Top, m.view(), item.Model.View()) } else { return m.view() } @@ -143,7 +164,9 @@ func capfirst(s string) string { if s == "" { return "" } - return strings.ToUpper(s[:1]) + s[1:] + r := []rune(s) + r[0] = unicode.ToUpper(r[0]) + return string(r) } func (m *Model) view() string { diff --git a/cmd/slackdump/internal/ui/cfgui/common_params.go b/cmd/slackdump/internal/ui/cfgui/common_params.go index f76767eb..33ce52bd 100644 --- a/cmd/slackdump/internal/ui/cfgui/common_params.go +++ b/cmd/slackdump/internal/ui/cfgui/common_params.go @@ -19,6 +19,6 @@ func ChannelIDs(v *string, required bool) Parameter { Value: *v, Description: descr, Inline: true, - Updater: updaters.NewString(v, "", true, structures.ValidateEntityList), + Updater: updaters.NewString(v, "", false, structures.ValidateEntityList), } } diff --git a/cmd/slackdump/internal/ui/cfgui/configuration.go b/cmd/slackdump/internal/ui/cfgui/configuration.go index 03e769cf..787c9419 100644 --- a/cmd/slackdump/internal/ui/cfgui/configuration.go +++ b/cmd/slackdump/internal/ui/cfgui/configuration.go @@ -10,7 +10,6 @@ import ( tea "github.com/charmbracelet/bubbletea" "github.com/rusq/rbubbles/filemgr" "github.com/rusq/slackdump/v3/cmd/slackdump/internal/apiconfig" - "github.com/rusq/slackdump/v3/cmd/slackdump/internal/bootstrap" "github.com/rusq/slackdump/v3/cmd/slackdump/internal/cfg" "github.com/rusq/slackdump/v3/cmd/slackdump/internal/ui/updaters" ) @@ -32,16 +31,6 @@ type Parameter struct { func globalConfig() Configuration { return Configuration{ - { - Name: "Authentication", - Params: []Parameter{ - { - Name: "Slack Workspace", - Value: bootstrap.CurrentWsp(), - Description: "Currently selected Slack Workspace", - }, - }, - }, { Name: "Timeframe", Params: []Parameter{ @@ -110,6 +99,8 @@ func globalConfig() Configuration { Name: "User Cache Retention", Value: cfg.UserCacheRetention.String(), Description: "For how long user cache is kept, until it is fetched again", + Inline: true, + Updater: updaters.NewDuration(&cfg.UserCacheRetention, false), }, { Name: "Disable User Cache", diff --git a/cmd/slackdump/internal/ui/cfgui/model.go b/cmd/slackdump/internal/ui/cfgui/model.go index 890f5940..06d64f21 100644 --- a/cmd/slackdump/internal/ui/cfgui/model.go +++ b/cmd/slackdump/internal/ui/cfgui/model.go @@ -207,13 +207,15 @@ func (m *Model) view(sty StyleSet) string { if selected && m.state == inline { buf.WriteString(m.child.View() + "\n") } else { - fmt.Fprintf(&buf, valfmt.Render(fmt.Sprintf("%-*s", valLen, nvl(param.Value)))+"\n") + fmt.Fprint(&buf, valfmt.Render(fmt.Sprintf("%-*s", valLen, nvl(param.Value)))+"\n") } line++ } } - buf.WriteString(alignGroup + sty.Description.Render(descr) + "\n") - buf.WriteString(m.help.ShortHelpView(m.keymap.Bindings())) + if m.focused { + buf.WriteString(alignGroup + sty.Description.Render(descr) + "\n") + buf.WriteString(m.help.ShortHelpView(m.keymap.Bindings())) + } return buf.String() } diff --git a/cmd/slackdump/internal/ui/dumpui/dumpui.go b/cmd/slackdump/internal/ui/dumpui/dumpui.go index 08e34f7f..addc80ef 100644 --- a/cmd/slackdump/internal/ui/dumpui/dumpui.go +++ b/cmd/slackdump/internal/ui/dumpui/dumpui.go @@ -44,19 +44,20 @@ var description = map[string]string{ func (w *Wizard) Run(ctx context.Context) error { var menu = func() *menu.Model { - var items []menu.MenuItem + var items []menu.Item if w.LocalConfig != nil { - items = append(items, menu.MenuItem{ - ID: actLocalConfig, - Name: "Configure " + w.Name + "...", - Help: description[actLocalConfig], - Model: cfgui.NewConfigUI(cfgui.DefaultStyle(), w.LocalConfig), + items = append(items, menu.Item{ + ID: actLocalConfig, + Name: w.Name + " Options...", + Help: description[actLocalConfig], + Preview: true, + Model: cfgui.NewConfigUI(cfgui.DefaultStyle(), w.LocalConfig), }) } items = append( items, - menu.MenuItem{ + menu.Item{ ID: actRun, Name: "Run " + w.Name, Help: description[actRun], @@ -69,7 +70,7 @@ func (w *Wizard) Run(ctx context.Context) error { }, ) if w.Help != "" { - items = append(items, menu.MenuItem{ + items = append(items, menu.Item{ ID: "help", Name: "Help", Help: "Read help for " + w.Name, @@ -77,18 +78,18 @@ func (w *Wizard) Run(ctx context.Context) error { } items = append(items, - menu.MenuItem{Separator: true}, - menu.MenuItem{ + menu.Item{Separator: true}, + menu.Item{ ID: actGlobalConfig, Name: "Global Configuration...", Help: description[actGlobalConfig], Model: cfgui.NewConfigUI(cfgui.DefaultStyle(), cfgui.GlobalConfig), // TODO: filthy cast }, - menu.MenuItem{Separator: true}, - menu.MenuItem{ID: actExit, Name: "Exit", Help: description[actExit]}, + menu.Item{Separator: true}, + menu.Item{ID: actExit, Name: "Exit", Help: description[actExit]}, ) - return menu.New(w.Title, items, false) + return menu.New(w.Title, items, true) } LOOP: diff --git a/cmd/slackdump/internal/ui/filepicker.go b/cmd/slackdump/internal/ui/filepicker.go index 3a94df76..492a43df 100644 --- a/cmd/slackdump/internal/ui/filepicker.go +++ b/cmd/slackdump/internal/ui/filepicker.go @@ -21,7 +21,7 @@ func NewFilePicker(prompt string, homedir string, allowedExt ...string) FileSyst fp := filepicker.New() fp.AllowedTypes = allowedExt fp.CurrentDirectory = homedir - fp.Styles.Cursor = HuhTheme.Focused.SelectedOption + fp.Styles.Cursor = HuhTheme().Focused.SelectedOption return FileSystemModel{ filepicker: fp, diff --git a/cmd/slackdump/internal/ui/filesystem.go b/cmd/slackdump/internal/ui/filesystem.go index 4aaec15c..f7bcfe71 100644 --- a/cmd/slackdump/internal/ui/filesystem.go +++ b/cmd/slackdump/internal/ui/filesystem.go @@ -31,7 +31,7 @@ func FileSelector(msg, descr string, opt ...Option) (string, error) { var resp struct { Filename string } - q := huh.NewForm(huh.NewGroup(fieldFileInput(&resp.Filename, msg, descr, *opts))).WithTheme(HuhTheme) + q := huh.NewForm(huh.NewGroup(fieldFileInput(&resp.Filename, msg, descr, *opts))).WithTheme(HuhTheme()) for { if err := q.Run(); err != nil { diff --git a/cmd/slackdump/internal/ui/input.go b/cmd/slackdump/internal/ui/input.go index 29f9ad34..fce14d72 100644 --- a/cmd/slackdump/internal/ui/input.go +++ b/cmd/slackdump/internal/ui/input.go @@ -14,7 +14,7 @@ func Input(msg, help string, validateFn func(s string) error) (string, error) { Title(msg). Description(help). Validate(validateFn). - Value(&resp))).WithTheme(HuhTheme). + Value(&resp))).WithTheme(HuhTheme()). Run(); err != nil { return "", err } diff --git a/cmd/slackdump/internal/ui/keymap.go b/cmd/slackdump/internal/ui/keymap.go new file mode 100644 index 00000000..56df7fb8 --- /dev/null +++ b/cmd/slackdump/internal/ui/keymap.go @@ -0,0 +1,13 @@ +package ui + +import ( + "github.com/charmbracelet/bubbles/key" + "github.com/charmbracelet/huh" +) + +var DefaultHuhKeymap = huh.NewDefaultKeyMap() + +func init() { + // redefinition of some of the default keys. + DefaultHuhKeymap.Quit = key.NewBinding(key.WithKeys("ctrl+c", "esc"), key.WithHelp("esc", "quit")) +} diff --git a/cmd/slackdump/internal/ui/theme.go b/cmd/slackdump/internal/ui/theme.go index 402495b6..a82c533c 100644 --- a/cmd/slackdump/internal/ui/theme.go +++ b/cmd/slackdump/internal/ui/theme.go @@ -6,7 +6,8 @@ import ( "github.com/charmbracelet/lipgloss" ) -var HuhTheme = ThemeBase16Ext() // Theme is the default Wizard theme. +// HuhTheme is the default Wizard theme. +var HuhTheme = ThemeBase16Ext type Theme struct { Focused ControlStyle @@ -127,7 +128,7 @@ func ThemeBase16Ext() *huh.Theme { t.Focused.Title = t.Focused.Title.Foreground(cyan) t.Focused.NoteTitle = t.Focused.NoteTitle.Foreground(cyan) t.Focused.Directory = t.Focused.Directory.Foreground(cyan) - t.Focused.Description = t.Focused.Description.Foreground(gray) + t.Focused.Description = t.Focused.Description.Foreground(white) t.Focused.ErrorIndicator = t.Focused.ErrorIndicator.Foreground(ltred) t.Focused.ErrorMessage = t.Focused.ErrorMessage.Foreground(ltred) t.Focused.SelectSelector = t.Focused.SelectSelector.Foreground(yellow) @@ -138,8 +139,9 @@ func ThemeBase16Ext() *huh.Theme { t.Focused.SelectedOption = t.Focused.SelectedOption.Foreground(black).Background(green) t.Focused.SelectedPrefix = t.Focused.SelectedPrefix.Foreground(green) t.Focused.UnselectedOption = t.Focused.UnselectedOption.Foreground(white) - t.Focused.FocusedButton = t.Focused.FocusedButton.Foreground(white).Background(purple) + t.Focused.FocusedButton = t.Focused.FocusedButton.Foreground(white).Background(green) t.Focused.BlurredButton = t.Focused.BlurredButton.Foreground(white).Background(black) + t.Focused.NoteTitle = t.Focused.NoteTitle.Foreground(cyan) t.Focused.TextInput.Cursor.Foreground(purple) t.Focused.TextInput.Placeholder.Foreground(gray) diff --git a/cmd/slackdump/internal/ui/updaters/duration.go b/cmd/slackdump/internal/ui/updaters/duration.go new file mode 100644 index 00000000..3d883a94 --- /dev/null +++ b/cmd/slackdump/internal/ui/updaters/duration.go @@ -0,0 +1,57 @@ +package updaters + +import ( + "time" + + tea "github.com/charmbracelet/bubbletea" +) + +// DurationModel is a model for updating a time.Duration value. It is a wrapper +// around a StringModel. +type DurationModel struct { + Value *time.Duration + sv string // string value + + m StringModel +} + +func ValidateDuration(s string) error { + _, err := time.ParseDuration(s) + return err +} + +func NewDuration(value *time.Duration, showPrompt bool) DurationModel { + dm := DurationModel{ + Value: value, + sv: value.String(), + } + dm.m = NewString(&dm.sv, "1h20m55s", showPrompt, ValidateDuration) + return dm +} + +func (m DurationModel) Init() tea.Cmd { + return m.m.Init() +} + +func (m DurationModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + var cmds []tea.Cmd + + { + mod, cmd := m.m.Update(msg) + if mod, ok := mod.(StringModel); ok { + m.m = mod + } + cmds = append(cmds, cmd) + } + if m.m.finishing { + // update the value + d, _ := time.ParseDuration(*m.m.Value) + *m.Value = d + } + + return m, tea.Batch(cmds...) +} + +func (m DurationModel) View() string { + return m.m.View() +} diff --git a/cmd/slackdump/internal/ui/updaters/picklist.go b/cmd/slackdump/internal/ui/updaters/picklist.go index 30351bac..69f3be37 100644 --- a/cmd/slackdump/internal/ui/updaters/picklist.go +++ b/cmd/slackdump/internal/ui/updaters/picklist.go @@ -22,7 +22,7 @@ func NewPicklist[T comparable](v *T, s *huh.Select[T]) *Model[T] { m := &Model[T]{ s: s.Value(v). Description("Select an option"). - WithTheme(ui.HuhTheme). + WithTheme(ui.HuhTheme()). WithKeyMap(huh.NewDefaultKeyMap()), help: help.New(), diff --git a/cmd/slackdump/internal/wizard/config.go b/cmd/slackdump/internal/wizard/config.go deleted file mode 100644 index 0e719c34..00000000 --- a/cmd/slackdump/internal/wizard/config.go +++ /dev/null @@ -1,30 +0,0 @@ -package wizard - -import ( - "errors" -) - -// initFlags initializes flags based on the key-value pairs. -// Example: -// -// var ( -// enterpriseMode bool -// downloadFiles bool -// ) -// -// flags, err := initFlags(enterpriseMode, "enterprise", downloadFiles, "files") -// if err != nil { -// return err -// } -func initFlags(keyval ...any) ([]string, error) { - var flags []string - if len(keyval)%2 != 0 { - return flags, errors.New("initFlags: odd number of key-value pairs") - } - for i := 0; i < len(keyval); i += 2 { - if keyval[i].(bool) { - flags = append(flags, keyval[i+1].(string)) - } - } - return flags, nil -} diff --git a/cmd/slackdump/internal/wizard/model.go b/cmd/slackdump/internal/wizard/model.go index 834e5a0a..668e6c51 100644 --- a/cmd/slackdump/internal/wizard/model.go +++ b/cmd/slackdump/internal/wizard/model.go @@ -35,7 +35,7 @@ func newModel(m *menu) model { Description("Slack workspace: " + bootstrap.CurrentWsp()). Options(options...), ), - ).WithTheme(ui.HuhTheme), + ).WithTheme(ui.HuhTheme()), } } diff --git a/cmd/slackdump/internal/wizard/wizard.go b/cmd/slackdump/internal/wizard/wizard.go index 29674e0d..c7aa4d74 100644 --- a/cmd/slackdump/internal/wizard/wizard.go +++ b/cmd/slackdump/internal/wizard/wizard.go @@ -96,14 +96,13 @@ func makeMenu(cmds []*base.Command, parent string, title string) (m *menu) { } for _, cmd := range cmds { hasSubcommands := len(cmd.Commands) > 0 - hasWizard := cmd.Wizard != nil + hasWizard := cmd.Wizard != nil && !cmd.HideWizard isMe := strings.EqualFold(cmd.Name(), CmdWizard.Name()) if !(hasWizard || hasSubcommands) || isMe { continue } name := titlecase.String(cmd.Name()) item := menuitem{ - // Name: parent + name, Name: name, Description: cmd.Short, cmd: cmd, diff --git a/cmd/slackdump/internal/workspace/new.go b/cmd/slackdump/internal/workspace/new.go index 336143fd..13521d91 100644 --- a/cmd/slackdump/internal/workspace/new.go +++ b/cmd/slackdump/internal/workspace/new.go @@ -10,6 +10,7 @@ import ( "github.com/rusq/slackdump/v3/auth" "github.com/rusq/slackdump/v3/cmd/slackdump/internal/cfg" "github.com/rusq/slackdump/v3/cmd/slackdump/internal/golang/base" + "github.com/rusq/slackdump/v3/cmd/slackdump/internal/workspace/workspaceui" "github.com/rusq/slackdump/v3/internal/cache" ) @@ -23,6 +24,7 @@ var CmdWspNew = &base.Command{ `, FlagMask: flagmask &^ cfg.OmitAuthFlags, // only auth flags. PrintFlags: true, + Wizard: workspaceui.WorkspaceNew, } var newParams = struct { diff --git a/cmd/slackdump/internal/workspace/wizard.go b/cmd/slackdump/internal/workspace/wiz_select.go similarity index 84% rename from cmd/slackdump/internal/workspace/wizard.go rename to cmd/slackdump/internal/workspace/wiz_select.go index 7602eab2..53e519f5 100644 --- a/cmd/slackdump/internal/workspace/wizard.go +++ b/cmd/slackdump/internal/workspace/wiz_select.go @@ -6,6 +6,7 @@ import ( "github.com/charmbracelet/bubbles/table" tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" "github.com/rusq/slackdump/v3/cmd/slackdump/internal/cfg" "github.com/rusq/slackdump/v3/cmd/slackdump/internal/golang/base" "github.com/rusq/slackdump/v3/cmd/slackdump/internal/ui" @@ -15,7 +16,37 @@ import ( // TODO: organise as a self-sufficient model with proper error handling. -func WorkspaceSelectModel(ctx context.Context, m *cache.Manager) (tea.Model, error) { +func wizSelect(ctx context.Context, cmd *base.Command, args []string) error { + m, err := cache.NewManager(cfg.CacheDir()) + if err != nil { + base.SetExitStatus(base.SCacheError) + return err + } + + sm, err := workspaceSelectModel(ctx, m) + if err != nil { + return err + } + if sm == nil { + // TODO: handle this case + return nil + } + mod, err := tea.NewProgram(sm).Run() + if err != nil { + return fmt.Errorf("workspace select wizard error: %w", err) + } + if newWsp := mod.(selectModel).selected; newWsp != "" { + if err := m.Select(newWsp); err != nil { + base.SetExitStatus(base.SWorkspaceError) + return fmt.Errorf("error setting the current workspace: %s", err) + } + logger.FromContext(ctx).Debugf("selected workspace: %s", newWsp) + } + + return nil +} + +func workspaceSelectModel(ctx context.Context, m *cache.Manager) (tea.Model, error) { wspList, err := m.List() if err != nil { base.SetExitStatus(base.SCacheError) @@ -38,7 +69,7 @@ func WorkspaceSelectModel(ctx context.Context, m *cache.Manager) (tea.Model, err {Title: "Name", Width: 14}, {Title: "Team", Width: 15}, {Title: "User", Width: 15}, - {Title: "Error", Width: 30}, + {Title: "Status", Width: 30}, } var rows []table.Row @@ -60,45 +91,23 @@ func WorkspaceSelectModel(ctx context.Context, m *cache.Manager) (tea.Model, err } t.SetStyles(s) t.Focus() - return selectModel{table: t}, nil + return selectModel{ + table: t, + style: style{ + FocusedBorder: ui.DefaultTheme().Focused.Border, + }, + }, nil } -func wizSelect(ctx context.Context, cmd *base.Command, args []string) error { - m, err := cache.NewManager(cfg.CacheDir()) - if err != nil { - base.SetExitStatus(base.SCacheError) - return err - } - - sm, err := WorkspaceSelectModel(ctx, m) - if err != nil { - return err - } - if sm == nil { - // TODO: handle this case - return nil - } - mod, err := tea.NewProgram(sm).Run() - if err != nil { - return fmt.Errorf("workspace select wizard error: %w", err) - } - if newWsp := mod.(selectModel).selected; newWsp != "" { - if err := m.Select(newWsp); err != nil { - base.SetExitStatus(base.SWorkspaceError) - return fmt.Errorf("error setting the current workspace: %s", err) - } - logger.FromContext(ctx).Debugf("selected workspace: %s", newWsp) - } - - return nil -} - -var baseStyle = ui.HuhTheme.Form - type selectModel struct { table table.Model selected string finished bool + style style +} + +type style struct { + FocusedBorder lipgloss.Style } func (m selectModel) Init() tea.Cmd { return nil } @@ -125,5 +134,5 @@ func (m selectModel) View() string { if m.finished { return "" // don't render the table if we've selected a workspace } - return baseStyle.Render(m.table.View()) + "\n\n" + ui.HuhTheme.Help.Ellipsis.Render("Select the workspace with arrow keys, press [Enter] to confirm, [Esc] to cancel.") + return m.style.FocusedBorder.Render((m.table.View()) + "\n\n" + ui.HuhTheme().Help.Ellipsis.Render("Select the workspace with arrow keys, press [Enter] to confirm, [Esc] to cancel.")) } diff --git a/cmd/slackdump/internal/workspace/workspaceui/api.go b/cmd/slackdump/internal/workspace/workspaceui/api.go new file mode 100644 index 00000000..ea5ae0c9 --- /dev/null +++ b/cmd/slackdump/internal/workspace/workspaceui/api.go @@ -0,0 +1,34 @@ +package workspaceui + +import ( + "context" + + "github.com/rusq/slackdump/v3/auth" + "github.com/rusq/slackdump/v3/auth/auth_ui" +) + +type manager interface { + SaveProvider(workspace string, p auth.Provider) error + Select(workspace string) error +} + +// createAndSelect creates a new workspace with the given provider and selects it. +// It returns the workspace name on success. +func createAndSelect(ctx context.Context, m manager, prov auth.Provider) (string, error) { + authInfo, err := prov.Test(ctx) + if err != nil { + return "", err + } + + wsp, err := auth_ui.Sanitize(authInfo.URL) + if err != nil { + return "", err + } + if err := m.SaveProvider(wsp, prov); err != nil { + return "", err + } + if err := m.Select(wsp); err != nil { + return "", err + } + return wsp, nil +} diff --git a/cmd/slackdump/internal/workspace/workspaceui/dialogs.go b/cmd/slackdump/internal/workspace/workspaceui/dialogs.go new file mode 100644 index 00000000..aaff0403 --- /dev/null +++ b/cmd/slackdump/internal/workspace/workspaceui/dialogs.go @@ -0,0 +1,34 @@ +package workspaceui + +import ( + "context" + "fmt" + + "github.com/charmbracelet/huh" + "github.com/rusq/slackdump/v3/cmd/slackdump/internal/ui" +) + +func askRetry(ctx context.Context, name string, err error) (retry bool) { + var msg string = fmt.Sprintf("The following error occurred: %s", err) + if name != "" { + msg = fmt.Sprintf("Error creating workspace %q: %s", name, err) + } + + if err := huh.NewForm(huh.NewGroup( + huh.NewConfirm().Title("Error Creating Workspace"). + Description(msg). + Value(&retry).Affirmative("Retry").Negative("Cancel"), + )).WithTheme(ui.HuhTheme()).RunWithContext(ctx); err != nil { + return false + } + return retry +} + +func success(ctx context.Context, workspace string) error { + return huh.NewForm(huh.NewGroup( + huh.NewNote().Title("Great Success!"). + Description(fmt.Sprintf("Workspace %q was added and selected.\n\n", workspace)). + Next(true). + NextLabel("Exit"), + )).WithTheme(ui.HuhTheme()).RunWithContext(ctx) +} diff --git a/cmd/slackdump/internal/workspace/workspaceui/ezlogin3000.go b/cmd/slackdump/internal/workspace/workspaceui/ezlogin3000.go new file mode 100644 index 00000000..cee58ba0 --- /dev/null +++ b/cmd/slackdump/internal/workspace/workspaceui/ezlogin3000.go @@ -0,0 +1,76 @@ +package workspaceui + +import ( + "context" + "errors" + + "github.com/rusq/slackdump/v3/auth" + + "github.com/rusq/slackdump/v3/auth/browser" + "github.com/rusq/slackdump/v3/cmd/slackdump/internal/ui" + + "github.com/charmbracelet/huh" +) + +func ezLogin3000(ctx context.Context, mgr manager) error { + var ( + legacy bool + ) + form := huh.NewForm(huh.NewGroup( + huh.NewConfirm(). + Title("Use legacy EZ-Login?"). + Description("Do you want to use the legacy login?"). + Value(&legacy), + )).WithTheme(ui.HuhTheme()).WithKeyMap(ui.DefaultHuhKeymap) + if err := form.RunWithContext(ctx); err != nil { + if errors.Is(err, huh.ErrUserAborted) { + return nil + } + return err + } + if legacy { + return playwrightLogin(ctx, mgr) + } + return rodLogin(ctx, mgr) + +} + +func playwrightLogin(ctx context.Context, mgr manager) error { + var brws = browser.Bchromium + formBrowser := huh.NewForm(huh.NewGroup( + huh.NewSelect[browser.Browser](). + Options( + huh.NewOption("Chromium", browser.Bchromium), + huh.NewOption("Firefox", browser.Bfirefox), + ). + Value(&brws), + )).WithTheme(ui.HuhTheme()).WithKeyMap(ui.DefaultHuhKeymap) + if err := formBrowser.RunWithContext(ctx); err != nil { + if errors.Is(err, huh.ErrUserAborted) { + return nil + } + return err + } + prov, err := auth.NewBrowserAuth(ctx, auth.BrowserWithBrowser(brws)) + if err != nil { + return err + } + + name, err := createAndSelect(ctx, mgr, prov) + if err != nil { + return err + } + return success(ctx, name) +} + +func rodLogin(ctx context.Context, mgr manager) error { + prov, err := auth.NewRODAuth(ctx) + if err != nil { + return err + } + name, err := createAndSelect(ctx, mgr, prov) + if err != nil { + return err + } + return success(ctx, name) +} diff --git a/cmd/slackdump/internal/workspace/workspaceui/filesecrets.go b/cmd/slackdump/internal/workspace/workspaceui/filesecrets.go new file mode 100644 index 00000000..401e97f2 --- /dev/null +++ b/cmd/slackdump/internal/workspace/workspaceui/filesecrets.go @@ -0,0 +1,84 @@ +package workspaceui + +import ( + "context" + "errors" + "os" + "strings" + + "github.com/charmbracelet/huh" + "github.com/joho/godotenv" + + "github.com/rusq/slackdump/v3/auth" + "github.com/rusq/slackdump/v3/cmd/slackdump/internal/ui" + "github.com/rusq/slackdump/v3/internal/structures" +) + +func fileWithSecrets(ctx context.Context, mgr manager) error { + var filename string + + form := huh.NewForm(huh.NewGroup( + huh.NewFilePicker(). + Title("Choose a file with secrets"). + Description("The one with SLACK_TOKEN and SLACK_COOKIE environment variables"). + ShowHidden(true). + ShowSize(true). + ShowPermissions(true). + Value(&filename). + Validate(validateSecrets), + )).WithTheme(ui.HuhTheme()).WithHeight(10) + if err := form.RunWithContext(ctx); err != nil { + if errors.Is(err, huh.ErrUserAborted) { + return nil + } + } + tok, cookie, err := parseSecretsTxt(filename) + if err != nil { + return err + } + prov, err := auth.NewValueAuth(tok, cookie) + if err != nil { + return err + } + wsp, err := createAndSelect(ctx, mgr, prov) + if err != nil { + return err + } + + return success(ctx, wsp) +} + +func validateSecrets(filename string) error { + _, _, err := parseSecretsTxt(filename) + return err +} + +func parseSecretsTxt(filename string) (string, string, error) { + f, err := os.Open(filename) + if err != nil { + return "", "", err + } + defer f.Close() + secrets, err := godotenv.Parse(f) + if err != nil { + return "", "", errors.New("not a secrets file") + } + token, ok := secrets["SLACK_TOKEN"] + if !ok { + return "", "", errors.New("no SLACK_TOKEN found") + } + if err := structures.ValidateToken(token); err != nil { + return "", "", err + } + if !strings.HasPrefix(token, "xoxc-") { + return token, "", nil + } + cook, ok := secrets["SLACK_COOKIE"] + if !ok { + return "", "", errors.New("no SLACK_COOKIE found") + } + if !strings.HasPrefix(cook, "xoxd-") { + return "", "", errors.New("invalid cookie") + } + return token, cook, nil +} diff --git a/cmd/slackdump/internal/workspace/workspaceui/tokencookie.go b/cmd/slackdump/internal/workspace/workspaceui/tokencookie.go new file mode 100644 index 00000000..f3df4579 --- /dev/null +++ b/cmd/slackdump/internal/workspace/workspaceui/tokencookie.go @@ -0,0 +1,133 @@ +package workspaceui + +import ( + "context" + + "github.com/charmbracelet/huh" + "github.com/rusq/slackdump/v3/auth" + "github.com/rusq/slackdump/v3/cmd/slackdump/internal/ui" + "github.com/rusq/slackdump/v3/internal/structures" +) + +const sampleToken = "xoxc-610187951300-604451271234-3473161557912-4c426dd426a45208707725b710302b32dda0ab002b80ccd8c4c8ac9971a11558" + +func prgTokenCookie(ctx context.Context, m manager) error { + var ( + token string + cookie string + workspace string + confirmed bool + ) + + for !confirmed { + f := huh.NewForm(huh.NewGroup( + huh.NewInput().Title("Token"). + Description("Token value"). + Placeholder(sampleToken). + Value(&token). + Validate(structures.ValidateToken), + huh.NewInput().Title("Cookie"). + Description("Session cookie"). + Placeholder("xoxd-..."). + Value(&cookie), + huh.NewConfirm().Title("Confirm creation of workspace?"). + Description("Once confirmed this will check the credentials for validity, detect the workspace \nand create a new workspace with the provided token and cookie"). + Value(&confirmed). + Validate(makeValidator(ctx, &token, &cookie, auth.NewValueAuth)), + )).WithTheme(ui.HuhTheme()).WithKeyMap(ui.DefaultHuhKeymap) + if err := f.RunWithContext(ctx); err != nil { + return err + } + if !confirmed { + return nil + } + + prov, err := auth.NewValueAuth(token, cookie) + if err != nil { + return err + } + name, err := createAndSelect(ctx, m, prov) + if err != nil { + confirmed = false + retry := askRetry(ctx, name, err) + if !retry { + return nil + } + } else { + workspace = name + break + } + } + + return success(ctx, workspace) +} + +// makeValidator creates a validator function that uses the newProvFn to +// create a new provider and test it. newProvFn should be a function that +// creates a new provider from a token and a value, where value is either a +// cookie or a file with cookies. +func makeValidator[P auth.Provider](ctx context.Context, token *string, val *string, newProvFn func(string, string) (P, error)) func(bool) error { + return func(b bool) error { + if !b { + return nil + } + p, err := newProvFn(*token, *val) + if err != nil { + return err + } + _, err = p.Test(ctx) + if err != nil { + return err + } + return nil + } +} + +func prgTokenCookieFile(ctx context.Context, m manager) error { + var ( + token string + cookiefile string + workspace string + confirmed bool + ) + for !confirmed { + f := huh.NewForm(huh.NewGroup( + huh.NewInput().Title("Token"). + Description("Token value"). + Placeholder(sampleToken). + Value(&token). + Validate(structures.ValidateToken), + huh.NewFilePicker().Title("Cookie File"). + Description("Select a cookies.txt file in Mozilla Format").AllowedTypes([]string{"txt"}). + FileAllowed(true). + ShowSize(true). + ShowPermissions(true). + Value(&cookiefile), + huh.NewConfirm().Title("Is this correct?"). + Description("Once confirmed this will create a new workspace with the provided token and cookie"). + Value(&confirmed). + Validate(makeValidator(ctx, &token, &cookiefile, auth.NewCookieFileAuth)), + )).WithTheme(ui.HuhTheme()).WithKeyMap(ui.DefaultHuhKeymap) + if err := f.Run(); err != nil { + return err + } + + prov, err := auth.NewValueAuth(token, cookiefile) + if err != nil { + return err + } + name, err := createAndSelect(ctx, m, prov) + if err != nil { + confirmed = false + retry := askRetry(ctx, name, err) + if !retry { + return nil + } + } else { + workspace = name + break + } + } + + return success(ctx, workspace) +} diff --git a/cmd/slackdump/internal/workspace/workspaceui/workspaceui.go b/cmd/slackdump/internal/workspace/workspaceui/workspaceui.go new file mode 100644 index 00000000..40fd068f --- /dev/null +++ b/cmd/slackdump/internal/workspace/workspaceui/workspaceui.go @@ -0,0 +1,101 @@ +package workspaceui + +import ( + "context" + "errors" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/huh" + + "github.com/rusq/slackdump/v3/cmd/slackdump/internal/cfg" + "github.com/rusq/slackdump/v3/cmd/slackdump/internal/golang/base" + "github.com/rusq/slackdump/v3/cmd/slackdump/internal/ui/bubbles/menu" + "github.com/rusq/slackdump/v3/internal/cache" +) + +func WorkspaceNew(ctx context.Context, _ *base.Command, _ []string) error { + const ( + actLogin = "ezlogin" + actToken = "token" + actTokenFile = "tokenfile" + actSecrets = "secrets" + actExit = "exit" + ) + + mgr, err := cache.NewManager(cfg.CacheDir()) + if err != nil { + return err + } + + items := []menu.Item{ + { + ID: actLogin, + Name: "Login in Browser", + Help: "Login to Slack in your browser", + }, + { + ID: actToken, + Name: "Token/Cookie", + Help: "Enter token and cookie that you grabbed from the browser", + }, + { + ID: actTokenFile, + Name: "Token/Cookie from file", + Help: "Provide token value and cookies from file", + }, + { + ID: actSecrets, + Name: "From file with secrets", + Help: "Read from secrets.txt or .env file", + }, + { + Separator: true, + }, + { + ID: actExit, + Name: "Exit", + Help: "Exit to main menu", + }, + } + + // new workspace methods + var methods = map[string]func(context.Context, manager) error{ + actLogin: ezLogin3000, + actToken: prgTokenCookie, + actTokenFile: prgTokenCookieFile, + actSecrets: fileWithSecrets, + } + +LOOP: + for { + m := menu.New("New Workspace", items, true) + if _, err := tea.NewProgram(&wizModel{m: m}, tea.WithContext(ctx)).Run(); err != nil { + return err + } + if m.Cancelled { + break LOOP + } + if m.Selected.ID == actExit { + break LOOP + } + fn, ok := methods[m.Selected.ID] + if !ok { + return errors.New("internal error: unhandled login option") + } + if err := fn(ctx, mgr); err != nil { + if errors.Is(err, huh.ErrUserAborted) { + continue + } + return err + } + } + + return nil +} + +// wizModel is a wrapper around the menu. +type wizModel struct{ m *menu.Model } + +func (m *wizModel) Init() tea.Cmd { return m.m.Init() } +func (m *wizModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m.m.Update(msg) } +func (m *wizModel) View() string { return m.m.View() } diff --git a/cmd/slackdump/main.go b/cmd/slackdump/main.go index b8376f00..358c0692 100644 --- a/cmd/slackdump/main.go +++ b/cmd/slackdump/main.go @@ -30,6 +30,7 @@ import ( "github.com/rusq/slackdump/v3/cmd/slackdump/internal/golang/help" "github.com/rusq/slackdump/v3/cmd/slackdump/internal/list" "github.com/rusq/slackdump/v3/cmd/slackdump/internal/man" + "github.com/rusq/slackdump/v3/cmd/slackdump/internal/ui" "github.com/rusq/slackdump/v3/cmd/slackdump/internal/view" "github.com/rusq/slackdump/v3/cmd/slackdump/internal/wizard" "github.com/rusq/slackdump/v3/cmd/slackdump/internal/workspace" @@ -40,6 +41,7 @@ func init() { loadSecrets(secretFiles) base.Slackdump.Commands = []*base.Command{ + workspace.CmdWorkspace, archive.CmdArchive, export.CmdExport, dump.CmdDump, @@ -47,7 +49,6 @@ func init() { convertcmd.CmdConvert, list.CmdList, emoji.CmdEmoji, - workspace.CmdWorkspace, diag.CmdDiag, apiconfig.CmdConfig, format.CmdFormat, @@ -297,13 +298,13 @@ func whatDo() (choice, error) { fmt.Print("\n" + cfg.Version.String() + "\n") var ans choice - err := huh.NewSelect[choice](). + err := huh.NewForm(huh.NewGroup(huh.NewSelect[choice](). Title("What do you want to do?"). Options( huh.NewOption(string(choiceHelp), choiceHelp), huh.NewOption(string(choiceWizard), choiceWizard), huh.NewOption(string(choiceExit), choiceExit), - ).Value(&ans).Run() + ).Value(&ans))).WithTheme(ui.HuhTheme()).Run() return ans, err } diff --git a/doc/tapes/browser_select.tape b/doc/tapes/browser_select.tape new file mode 100644 index 00000000..b98d6db9 --- /dev/null +++ b/doc/tapes/browser_select.tape @@ -0,0 +1,16 @@ +Set Shell zsh +Sleep 1s +Type@50ms "slackdump workspace new" +Sleep 4s +Enter +Sleep 3s +Type@50ms "ora600" +Sleep 3s +Enter +Down@250ms 2 +Sleep 4s +Enter +Sleep 5s +Enter +Sleep 12s + diff --git a/downloader/deprecated_test.go b/downloader/deprecated_test.go index 079216ed..a61ec32b 100644 --- a/downloader/deprecated_test.go +++ b/downloader/deprecated_test.go @@ -33,6 +33,8 @@ var ( file9 = slack.File{ID: "f9", Name: "filename9.ext", URLPrivateDownload: "file9_url", Size: 900} ) +// TODO: figure out why this is deprecated. + func TestSession_SaveFileTo(t *testing.T) { tmpdir := t.TempDir() @@ -73,7 +75,7 @@ func TestSession_SaveFileTo(t *testing.T) { func(mc *mock_downloader.MockDownloader) { mc.EXPECT(). GetFile("file1_url", gomock.Any()). - SetArg(1, *fixtures.FilledFile(file1.Size)). // to mock the file size. + SetArg(1, *fixtures.FilledFile(t, file1.Size)). // to mock the file size. Return(nil) }, int64(file1.Size), @@ -171,7 +173,7 @@ func TestSession_saveFile(t *testing.T) { func(mc *mock_downloader.MockDownloader) { mc.EXPECT(). GetFile("file1_url", gomock.Any()). - SetArg(1, *fixtures.FilledFile(file1.Size)). + SetArg(1, *fixtures.FilledFile(t, file1.Size)). Return(nil) }, int64(file1.Size), diff --git a/go.mod b/go.mod index fdb716a0..0e7152d9 100644 --- a/go.mod +++ b/go.mod @@ -4,6 +4,7 @@ go 1.23 require ( github.com/MercuryEngineering/CookieMonster v0.0.0-20180304172713-1584578b3403 + github.com/ProtonMail/go-crypto v1.1.2 github.com/charmbracelet/bubbles v0.20.0 github.com/charmbracelet/bubbletea v1.1.2 github.com/charmbracelet/huh v0.6.0 @@ -36,7 +37,6 @@ require ( github.com/yuin/goldmark v1.7.8 github.com/yuin/goldmark-emoji v1.0.4 go.uber.org/mock v0.5.0 - golang.org/x/crypto v0.28.0 golang.org/x/sync v0.8.0 golang.org/x/term v0.25.0 golang.org/x/text v0.19.0 @@ -52,6 +52,7 @@ require ( github.com/charmbracelet/x/ansi v0.4.2 // indirect github.com/charmbracelet/x/exp/strings v0.0.0-20241101155414-3df16cb7eefd // indirect github.com/charmbracelet/x/term v0.2.0 // indirect + github.com/cloudflare/circl v1.3.7 // indirect github.com/deckarep/golang-set/v2 v2.6.0 // indirect github.com/denisbrodbeck/machineid v1.0.1 // indirect github.com/dustin/go-humanize v1.0.1 // indirect @@ -81,6 +82,7 @@ require ( github.com/ysmood/gson v0.7.3 // indirect github.com/ysmood/leakless v0.9.0 // indirect go.uber.org/multierr v1.11.0 // indirect + golang.org/x/crypto v0.28.0 // indirect golang.org/x/exp v0.0.0-20241009180824-f66d83c29e7c // indirect golang.org/x/net v0.30.0 // indirect golang.org/x/sys v0.26.0 // indirect diff --git a/go.sum b/go.sum index 13919d6c..c79a3e56 100644 --- a/go.sum +++ b/go.sum @@ -2,6 +2,8 @@ github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE= github.com/MercuryEngineering/CookieMonster v0.0.0-20180304172713-1584578b3403 h1:EtZwYyLbkEcIt+B//6sujwRCnHuTEK3qiSypAX5aJeM= github.com/MercuryEngineering/CookieMonster v0.0.0-20180304172713-1584578b3403/go.mod h1:mM6WvakkX2m+NgMiPCfFFjwfH4KzENC07zeGEqq9U7s= +github.com/ProtonMail/go-crypto v1.1.2 h1:A7JbD57ThNqh7XjmHE+PXpQ3Dqt3BrSAC0AL0Go3KS0= +github.com/ProtonMail/go-crypto v1.1.2/go.mod h1:rA3QumHc/FZ8pAHreoekgiAbzpNsfQAosU5td4SnOrE= github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= @@ -14,8 +16,6 @@ github.com/charmbracelet/bubbles v0.20.0 h1:jSZu6qD8cRQ6k9OMfR1WlM+ruM8fkPWkHvQW github.com/charmbracelet/bubbles v0.20.0/go.mod h1:39slydyswPy+uVOHZ5x/GjwVAFkCsV8IIVy+4MhzwwU= github.com/charmbracelet/bubbletea v1.1.2 h1:naQXF2laRxyLyil/i7fxdpiz1/k06IKquhm4vBfHsIc= github.com/charmbracelet/bubbletea v1.1.2/go.mod h1:9HIU/hBV24qKjlehyj8z1r/tR9TYTQEag+cWZnuXo8E= -github.com/charmbracelet/huh v0.5.3 h1:3KLP4a/K1/S4dq4xFMTNMt3XWhgMl/yx8NYtygQ0bmg= -github.com/charmbracelet/huh v0.5.3/go.mod h1:OZC3lshuF+/y8laj//DoZdFSHxC51OrtXLJI8xWVouQ= github.com/charmbracelet/huh v0.6.0 h1:mZM8VvZGuE0hoDXq6XLxRtgfWyTI3b2jZNKh0xWmax8= github.com/charmbracelet/huh v0.6.0/go.mod h1:GGNKeWCeNzKpEOh/OJD8WBwTQjV3prFAtQPpLv+AVwU= github.com/charmbracelet/huh/spinner v0.0.0-20241028115900-20a4d21717a8 h1:g+Bz64hsMLTf3lAgUqI6Rj1YEAlm/HN39IuhyneCokc= @@ -32,6 +32,8 @@ github.com/charmbracelet/x/term v0.2.0 h1:cNB9Ot9q8I711MyZ7myUR5HFWL/lc3OpU8jZ4h github.com/charmbracelet/x/term v0.2.0/go.mod h1:GVxgxAbjUrmpvIINHIQnJJKpMlHiZ4cktEQCN6GWyF0= github.com/chengxilo/virtualterm v1.0.4 h1:Z6IpERbRVlfB8WkOmtbHiDbBANU7cimRIof7mk9/PwM= github.com/chengxilo/virtualterm v1.0.4/go.mod h1:DyxxBZz/x1iqJjFxTFcr6/x+jSpqN0iwWCOK1q10rlY= +github.com/cloudflare/circl v1.3.7 h1:qlCDlTPz2n9fu58M0Nh1J/JzcFpfgkFHHX3O35r5vcU= +github.com/cloudflare/circl v1.3.7/go.mod h1:sRTcRWXGLrKw6yIGJ+l7amYJFfAXbZG0kBSc8r4zxgA= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= @@ -133,8 +135,6 @@ github.com/rusq/rbubbles v0.0.2 h1:U+rkywxtmBw0fdXABTCyND2YUZW9xydsxE12Co0tsFA= github.com/rusq/rbubbles v0.0.2/go.mod h1:wOrwl1AiCCmaL9fLnjKDajOP4IglSC84fH7a74VsnLk= github.com/rusq/secure v0.0.4 h1:svpiZHfHnx89eEDCCFI9OXG1Y8hL9kUWUG6fJbrWUOI= github.com/rusq/secure v0.0.4/go.mod h1:F1QilMKreuFRjov0UY7DZSIXn77/8RqMVGu2zV0RtqY= -github.com/rusq/slack v0.9.6-0.20240712095442-5a0e2e405a99 h1:dqEcNs9hMc2PiMwhw8+Zi3wF8GNUHIK5OItZ01iM0Vk= -github.com/rusq/slack v0.9.6-0.20240712095442-5a0e2e405a99/go.mod h1:9O0zQAFN6W47z4KpTQbe6vOHOzBO76vMg1+gthPwaTI= github.com/rusq/slack v0.9.6-0.20241104074952-d9b6e02955fa h1:meNaDH2eLwjAqvOxMlgb5+gaLz3Kufm9rVFkALhsCRs= github.com/rusq/slack v0.9.6-0.20241104074952-d9b6e02955fa/go.mod h1:9O0zQAFN6W47z4KpTQbe6vOHOzBO76vMg1+gthPwaTI= github.com/rusq/slackauth v0.5.1 h1:l+Gj96kYzHmljMYglRv76kgzuOJr/QbXDDA8JHyN71Q= diff --git a/internal/cache/auth.go b/internal/cache/auth.go index ca08a569..2245a815 100644 --- a/internal/cache/auth.go +++ b/internal/cache/auth.go @@ -159,7 +159,7 @@ func initProvider(ctx context.Context, cacheDir string, filename string, workspa if creds == nil || creds.IsEmpty() { if prov, err := tryLoad(ctx, credsFile); err != nil { msg := fmt.Sprintf("failed to load saved credentials: %s", err) - trace.Logf(ctx, "warn", msg) + trace.Log(ctx, "warn", msg) if auth.IsInvalidAuthErr(err) { lg.Println("authentication details expired, relogin is necessary") } diff --git a/internal/cache/manager.go b/internal/cache/manager.go index 91006c9a..e46f4d64 100644 --- a/internal/cache/manager.go +++ b/internal/cache/manager.go @@ -140,7 +140,12 @@ func (m *Manager) Auth(ctx context.Context, name string, c Credentials) (auth.Pr // LoadProvider loads the file from disk without any checks. func (m *Manager) LoadProvider(name string) (auth.Provider, error) { - return loadCreds(filer, filepath.Join(m.dir, m.filename(name))) + return loadCreds(filer, m.filepath(name)) +} + +// SaveProvider saves the provider to the file, no questions asked. +func (m *Manager) SaveProvider(name string, p auth.Provider) error { + return saveCreds(filer, m.filepath(name), p) } // ErrWorkspace is the error returned by the workspace manager, it contains the diff --git a/internal/chunk/dirproc/dirproc.go b/internal/chunk/dirproc/dirproc.go index 2654497d..27f88ab1 100644 --- a/internal/chunk/dirproc/dirproc.go +++ b/internal/chunk/dirproc/dirproc.go @@ -37,16 +37,15 @@ func newDirProc(cd *chunk.Directory, name chunk.FileID) (*dirproc, error) { // Close closes the processor and the underlying chunk file. func (p *dirproc) Close() error { - if p.closed.Load() { + if !p.closed.CompareAndSwap(false, true) { return nil } var errs error if err := p.Recorder.Close(); err != nil { - errors.Join(errs, err) + errs = errors.Join(errs, err) } - p.closed.Store(true) if err := p.wc.Close(); err != nil { - errors.Join(errs, err) + errs = errors.Join(errs, err) } return errs } diff --git a/internal/chunk/dirproc/dirproc_test.go b/internal/chunk/dirproc/dirproc_test.go new file mode 100644 index 00000000..ee5005d6 --- /dev/null +++ b/internal/chunk/dirproc/dirproc_test.go @@ -0,0 +1,63 @@ +// Package dirproc is a processor that writes the data into gzipped files in a +// directory. Each conversation is output to a separate gzipped JSONL file. +// If a thread is given, the filename will have the thread ID in it. +package dirproc + +import ( + "sync/atomic" + "testing" + + "github.com/rusq/slackdump/v3/internal/chunk" +) + +type mockWriteCloser struct { + WriteCalled atomic.Bool + CloseCalled atomic.Bool +} + +func (m *mockWriteCloser) Write(p []byte) (n int, err error) { + m.WriteCalled.Store(true) + return 0, nil +} + +func (m *mockWriteCloser) Close() error { + m.CloseCalled.Store(true) + return nil +} + +func Test_dirproc_Close(t *testing.T) { + tests := []struct { + name string + fields *dirproc + prep func(d *dirproc) + wantErr bool + }{ + { + "already closed", + &dirproc{}, + func(d *dirproc) { + d.closed.Store(true) + }, + false, + }, + { + "close ok", + &dirproc{ + Recorder: &chunk.Recorder{}, + wc: &mockWriteCloser{}, + }, + nil, + false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.prep != nil { + tt.prep(tt.fields) + } + if err := tt.fields.Close(); (err != nil) != tt.wantErr { + t.Errorf("dirproc.Close() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} diff --git a/internal/edge/edge.go b/internal/edge/edge.go index 1a22b4a2..265e234c 100644 --- a/internal/edge/edge.go +++ b/internal/edge/edge.go @@ -211,7 +211,9 @@ func (cl *Client) PostForm(ctx context.Context, path string, form url.Values) (* func (cl *Client) record(b []byte) { if cl.tape != nil { - cl.tape.Write(b) + if _, err := cl.tape.Write(b); err != nil { + logger.Default.Printf("error writing to tape: %s", err) + } } } diff --git a/internal/fixtures/api.go b/internal/fixtures/api.go index 6e629729..0fb8ab41 100644 --- a/internal/fixtures/api.go +++ b/internal/fixtures/api.go @@ -23,6 +23,6 @@ func TestServer(t *testing.T, code int, response []byte) *httptest.Server { t.Helper() return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(code) - w.Write(response) + _, _ = w.Write(response) })) } diff --git a/internal/fixtures/fixtures.go b/internal/fixtures/fixtures.go index 33a5e6df..c3cf8077 100644 --- a/internal/fixtures/fixtures.go +++ b/internal/fixtures/fixtures.go @@ -44,13 +44,18 @@ func FilledBuffer(sz int) *bytes.Buffer { } // FilledFile returns a file that filled with sz bytes of 0x00. -func FilledFile(sz int) *os.File { +func FilledFile(t *testing.T, sz int) *os.File { + t.Helper() f, err := os.CreateTemp("", "sdunit*") if err != nil { panic(err) } - f.Write(bytes.Repeat([]byte{0x00}, sz)) - f.Seek(0, io.SeekStart) + if _, err := f.Write(bytes.Repeat([]byte{0x00}, sz)); err != nil { + t.Fatal(err) + } + if _, err := f.Seek(0, io.SeekStart); err != nil { + t.Fatal(err) + } return f } diff --git a/internal/osext/move.go b/internal/osext/move.go index c3e804c9..a7d6d3cc 100644 --- a/internal/osext/move.go +++ b/internal/osext/move.go @@ -45,6 +45,7 @@ func MoveFile(src string, fs fsadapter.FS, dst string) error { // if err := fs.Chmod(dst, si.Mode()); err != nil { // return fmt.Errorf("chmod: %s", err) // } + _ = err // ignore SA9003 in golang-ci-lint } if err := os.Remove(src); err != nil { diff --git a/internal/structures/structures.go b/internal/structures/structures.go index 2f24229b..fe99ab28 100644 --- a/internal/structures/structures.go +++ b/internal/structures/structures.go @@ -1,6 +1,11 @@ // Package structures provides functions to parse Slack data types. package structures +import ( + "errors" + "regexp" +) + const ( LatestReplyNoReplies = "0000000000.000000" ) @@ -8,3 +13,16 @@ const ( const ( SubTypeThreadBroadcast = "thread_broadcast" ) + +// tokenRe is a loose regular expression to match Slack API tokens. +// a - app, b - bot, c - client, e - export, p - legacy +var tokenRE = regexp.MustCompile(`xox[abcep]-[0-9]+-[0-9]+-[0-9]+-[0-9a-f]{64}`) + +var errInvalidToken = errors.New("token must start with xoxa-, xoxb-, xoxc-, xoxe- or xoxp- and be followed by 3 group of numbers and then 64 hexadecimal characters") + +func ValidateToken(token string) error { + if !tokenRE.MatchString(token) { + return errInvalidToken + } + return nil +} diff --git a/cmd/slackdump/internal/export/wizard_test.go b/internal/structures/structures_test.go similarity index 89% rename from cmd/slackdump/internal/export/wizard_test.go rename to internal/structures/structures_test.go index 1a0081e8..7166a83e 100644 --- a/cmd/slackdump/internal/export/wizard_test.go +++ b/internal/structures/structures_test.go @@ -1,4 +1,5 @@ -package export +// Package structures provides functions to parse Slack data types. +package structures import ( "testing" @@ -6,7 +7,7 @@ import ( "github.com/rusq/slackdump/v3/internal/fixtures" ) -func Test_validateToken(t *testing.T) { +func TestValidateToken(t *testing.T) { type args struct { token string } @@ -68,7 +69,7 @@ func Test_validateToken(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - if err := validateToken(tt.args.token); (err != nil) != tt.wantErr { + if err := ValidateToken(tt.args.token); (err != nil) != tt.wantErr { t.Errorf("validateToken() error = %v, wantErr %v", err, tt.wantErr) } }) diff --git a/internal/viewer/renderer/slack.go b/internal/viewer/renderer/slack.go index 3fd0fcff..7f07c92f 100644 --- a/internal/viewer/renderer/slack.go +++ b/internal/viewer/renderer/slack.go @@ -6,6 +6,7 @@ import ( "encoding/json" "fmt" "html/template" + "log" "log/slog" "os" "strings" @@ -118,8 +119,12 @@ func maybeprint(v any) { if debug { enc := json.NewEncoder(os.Stderr) enc.SetIndent("", " ") - enc.Encode(v) - os.Stderr.Sync() + if err := enc.Encode(v); err != nil { + log.Printf("error printing value: %s", err) + } + if err := os.Stderr.Sync(); err != nil { + log.Printf("error flushing stderr: %s", err) + } } } diff --git a/slackdump.go b/slackdump.go index 9b802fd5..8a18926a 100644 --- a/slackdump.go +++ b/slackdump.go @@ -9,7 +9,6 @@ import ( "runtime/trace" "time" - "github.com/go-playground/validator/v10" "github.com/rusq/slack" "golang.org/x/time/rate" @@ -144,6 +143,13 @@ func New(ctx context.Context, prov auth.Provider, opts ...Option) (*Session, err return nil, fmt.Errorf("auth provider validation error: %s", err) } + return NewNoValidate(ctx, prov, opts...) +} + +// NewNoValidate creates new Slackdump session with provided options, and +// populates the internal cache of users and channels for lookups. This +// function does not validate the auth.Provider. +func NewNoValidate(ctx context.Context, prov auth.Provider, opts ...Option) (*Session, error) { sd := &Session{ cfg: defConfig, uc: new(usercache), @@ -154,14 +160,6 @@ func New(ctx context.Context, prov auth.Provider, opts ...Option) (*Session, err opt(sd) } - if err := sd.cfg.limits.Validate(); err != nil { - var vErr validator.ValidationErrors - if errors.As(err, &vErr) { - return nil, fmt.Errorf("API limits failed validation: %s", vErr.Translate(network.OptErrTranslations)) - } - return nil, err - } - if err := sd.initClient(ctx, prov, sd.cfg.forceEnterprise); err != nil { return nil, err } diff --git a/stream/stream_test.go b/stream/stream_test.go index 9578cc5e..c4d6ba7c 100644 --- a/stream/stream_test.go +++ b/stream/stream_test.go @@ -283,7 +283,11 @@ func Test_processLink(t *testing.T) { func TestStream_Users(t *testing.T) { ctx := context.Background() srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Write([]byte(`{"ok":false,"error":"not_authed"}`)) + t.Helper() + _, err := w.Write([]byte(`{"ok":false,"error":"not_authed"}`)) + if err != nil { + t.Error(err) + } })) defer srv.Close() l := rateLimits{