diff --git a/auth/auth_ui/auth_ui.go b/auth/auth_ui/auth_ui.go index 9d5092b2..887c88d6 100644 --- a/auth/auth_ui/auth_ui.go +++ b/auth/auth_ui/auth_ui.go @@ -14,8 +14,8 @@ const ( LInteractive LoginType = iota // LHeadless is the email/password login type. LHeadless - // LGoogleAuth is the google auth option - LGoogleAuth + // LUserBrowser is the google auth option + LUserBrowser // LCancel should be returned if the user cancels the login intent. LCancel ) diff --git a/auth/auth_ui/cli.go b/auth/auth_ui/cli.go index 156452a2..93260af2 100644 --- a/auth/auth_ui/cli.go +++ b/auth/auth_ui/cli.go @@ -49,7 +49,7 @@ func (cl *CLI) RequestLoginType(w io.Writer) (LoginType, error) { value LoginType }{ {"Email", LHeadless}, - {"Google", LGoogleAuth}, + {"Google", LUserBrowser}, {"Apple", LInteractive}, {"Login with Single-Sign-On (SSO)", LInteractive}, {"Other/Manual", LInteractive}, diff --git a/auth/auth_ui/huh.go b/auth/auth_ui/huh.go index e4cc5fcd..5e4d03f7 100644 --- a/auth/auth_ui/huh.go +++ b/auth/auth_ui/huh.go @@ -8,6 +8,7 @@ import ( "strconv" "github.com/charmbracelet/huh" + "github.com/rusq/slackauth" ) // Huh is the Auth UI that uses the huh library to provide a terminal UI. @@ -48,20 +49,52 @@ func (*Huh) RequestCreds(w io.Writer, workspace string) (email string, passwd st return } -func (*Huh) RequestLoginType(w io.Writer) (LoginType, error) { +type methodMenuItem struct { + MenuItem string + ShortDesc string + Type LoginType +} + +func (m methodMenuItem) String() string { + return fmt.Sprintf("%-20s - %s", m.MenuItem, m.ShortDesc) +} + +var methods = []methodMenuItem{ + { + "Manual", + "Works with most authentication schemes, except Google.", + LInteractive, + }, + { + "Automatic", + "Only suitable for email/password auth", + LHeadless, + }, + { + "User's Browser", + "Loads your user profile, works with Google Auth", + LUserBrowser, + }, +} + +type LoginOpts struct { + Type LoginType + BrowserPath string +} + +func (*Huh) RequestLoginType(w io.Writer) (LoginOpts, error) { + var opts = make([]huh.Option[LoginType], 0, len(methods)) + for _, m := range methods { + opts = append(opts, huh.NewOption(m.String(), m.Type)) + } + opts = append(opts, + huh.NewOption("------", LoginType(-1)), + huh.NewOption("Cancel", LCancel), + ) var loginType LoginType err := huh.NewForm(huh.NewGroup( huh.NewSelect[LoginType]().Title("Select login type"). - Options( - huh.NewOption("Email (manual)", LInteractive), - huh.NewOption("Email (automatic)", LHeadless), - huh.NewOption("Google", LGoogleAuth), - huh.NewOption("Apple", LInteractive), - huh.NewOption("Login with Single-Sign-On (SSO)", LInteractive), - huh.NewOption("Other/Manual", LInteractive), - huh.NewOption("", LoginType(-1)), - huh.NewOption("Cancel", LCancel), - ). + Options(opts...). Value(&loginType). Validate(valSepEaster()). DescriptionFunc(func() string { @@ -70,7 +103,7 @@ func (*Huh) RequestLoginType(w io.Writer) (LoginType, error) { return "Clean browser will open on a Slack Login page." case LHeadless: return "You will be prompted to enter your email and password, login is automated." - case LGoogleAuth: + case LUserBrowser: return "System browser will open on a Slack Login page." case LCancel: return "Cancel the login process." @@ -79,7 +112,46 @@ func (*Huh) RequestLoginType(w io.Writer) (LoginType, error) { } }, &loginType), )).Run() - return loginType, err + if err != nil { + return LoginOpts{Type: LCancel}, err + } + if loginType == LUserBrowser { + path, err := chooseBrowser() + if err != nil { + return LoginOpts{Type: LCancel}, err + } + return LoginOpts{ + Type: LUserBrowser, + BrowserPath: path, + }, err + } + return LoginOpts{Type: loginType}, nil +} + +func chooseBrowser() (string, error) { + browsers, err := slackauth.ListBrowsers() + if err != nil { + return "", err + } + var opts = make([]huh.Option[int], 0, len(browsers)) + for i, b := range browsers { + opts = append(opts, huh.NewOption(b.Name, i)) + } + + var selection int + err = huh.NewForm(huh.NewGroup( + huh.NewSelect[int](). + Title("Detected browsers on your system"). + Options(opts...). + Value(&selection). + DescriptionFunc(func() string { + return browsers[selection].Path + }, &selection), + )).Run() + if err != nil { + return "", err + } + return browsers[selection].Path, nil } // ConfirmationCode asks the user to input the confirmation code, does some diff --git a/auth/rod.go b/auth/rod.go index ddf6da44..4ace7da5 100644 --- a/auth/rod.go +++ b/auth/rod.go @@ -64,7 +64,7 @@ type browserAuthUIExt interface { // RequestLoginType should request the login type from the user and return // one of the [auth_ui.LoginType] constants. The implementation should // provide a way to cancel the login flow, returning [auth_ui.LoginCancel]. - RequestLoginType(w io.Writer) (auth_ui.LoginType, error) + RequestLoginType(w io.Writer) (auth_ui.LoginOpts, error) // RequestCreds should request the user's email and password and return // them. RequestCreds(w io.Writer, workspace string) (email string, passwd string, err error) @@ -110,10 +110,10 @@ func NewRODAuth(ctx context.Context, opts ...Option) (RodAuth, error) { return r, err } sopts := r.opts.slackauthOpts() - if resp == auth_ui.LGoogleAuth { + if resp.Type == auth_ui.LUserBrowser { // it doesn't need to know that this browser is just a puppet in the // masterful hands. - sopts = append(sopts, slackauth.WithForceUser()) + sopts = append(sopts, slackauth.WithForceUser(), slackauth.WithLocalBrowser(resp.BrowserPath)) } cl, err := slackauth.New( @@ -128,8 +128,8 @@ func NewRODAuth(ctx context.Context, opts ...Option) (RodAuth, error) { lg := logger.FromContext(ctx) t := time.Now() var sp simpleProvider - switch resp { - case auth_ui.LInteractive, auth_ui.LGoogleAuth: + switch resp.Type { + case auth_ui.LInteractive, auth_ui.LUserBrowser: lg.Printf("ℹ️ Initialising browser, once the browser appears, login as usual") var err error sp.Token, sp.Cookie, err = cl.Manual(ctx) diff --git a/cmd/slackdump/internal/archive/wizard.go b/cmd/slackdump/internal/archive/wizard.go index 041f8048..8596e49b 100644 --- a/cmd/slackdump/internal/archive/wizard.go +++ b/cmd/slackdump/internal/archive/wizard.go @@ -9,9 +9,9 @@ import ( func archiveWizard(ctx context.Context, cmd *base.Command, args []string) error { w := &dumpui.Wizard{ - Title: "Archive Slack Workspace", - Particulars: "Archive", - Cmd: cmd, + Title: "Archive Slack Workspace", + Name: "Archive", + Cmd: cmd, } return w.Run(ctx) } diff --git a/cmd/slackdump/internal/diag/tools.go b/cmd/slackdump/internal/diag/tools.go index bc2909eb..126f50dd 100644 --- a/cmd/slackdump/internal/diag/tools.go +++ b/cmd/slackdump/internal/diag/tools.go @@ -30,5 +30,6 @@ Tools command contains different tools, running which may be requested if you op CmdRecord, cmdSearch, CmdThread, + CmdWizDebug, }, } diff --git a/cmd/slackdump/internal/diag/wizdebug.go b/cmd/slackdump/internal/diag/wizdebug.go new file mode 100644 index 00000000..1465081e --- /dev/null +++ b/cmd/slackdump/internal/diag/wizdebug.go @@ -0,0 +1,93 @@ +package diag + +import ( + "context" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/huh" + "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" +) + +var CmdWizDebug = &base.Command{ + UsageLine: "slackdump tools wizdebug", + Short: "run the wizard debug command", + Run: runWizDebug, + PrintFlags: true, +} + +type wdWhat int + +const ( + wdExit wdWhat = iota + wdDumpUI + wdConfigUI +) + +func runWizDebug(ctx context.Context, cmd *base.Command, args []string) error { + var action wdWhat + for { + form := huh.NewForm( + huh.NewGroup( + huh.NewSelect[wdWhat]().Options( + huh.NewOption("Dump UI", wdDumpUI), + huh.NewOption("Global Config UI", wdConfigUI), + ).Value(&action), + ).WithHeight(10), + ) + + if err := form.RunWithContext(ctx); err != nil { + return err + } + switch action { + case wdDumpUI: + if err := debugDumpUI(ctx); err != nil { + return err + } + case wdConfigUI: + if err := debugConfigUI(ctx); err != nil { + return err + } + case wdExit: + return nil + } + } +} + +func debugDumpUI(ctx context.Context) error { + menu := []dumpui.MenuItem{ + { + ID: "run", + Name: "Run", + Help: "Run the command", + }, + { + Name: "Global Configuration...", + Help: "Set global configuration options", + Model: cfgui.NewConfigUI(cfgui.DefaultStyle(), cfgui.GlobalConfig), + }, + { + Name: "Local Configuration...", + Help: "Set command specific configuration options", + }, + { + Separator: true, + }, + { + Name: "Exit", + Help: "Exit to main menu", + }, + } + w := dumpui.NewModel("Wizard Debug", menu, false) + + if _, err := tea.NewProgram(w, tea.WithContext(ctx)).Run(); err != nil { + return err + } + + return nil +} + +func debugConfigUI(ctx context.Context) error { + return cfgui.Global(ctx) +} diff --git a/cmd/slackdump/internal/dump/dump.go b/cmd/slackdump/internal/dump/dump.go index cf252b9f..e030920f 100644 --- a/cmd/slackdump/internal/dump/dump.go +++ b/cmd/slackdump/internal/dump/dump.go @@ -14,9 +14,8 @@ import ( "time" "github.com/rusq/fsadapter" - "github.com/rusq/slackdump/v3/cmd/slackdump/internal/bootstrap" - "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/downloader" diff --git a/cmd/slackdump/internal/dump/wizard.go b/cmd/slackdump/internal/dump/wizard.go index 343c82af..ee813c8d 100644 --- a/cmd/slackdump/internal/dump/wizard.go +++ b/cmd/slackdump/internal/dump/wizard.go @@ -2,16 +2,71 @@ package dump import ( "context" + "strings" "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/nametmpl" + "github.com/rusq/slackdump/v3/internal/structures" ) func WizDump(ctx context.Context, cmd *base.Command, args []string) error { w := dumpui.Wizard{ - Title: "Dump Slackdump channels", - Particulars: "Dump", + Title: "Dump Slack Channels", + Name: "Dump", + LocalConfig: opts.configuration, Cmd: cmd, + ArgsFn: func() []string { + return splitEntryList(entryList) + }, + ValidateParamsFn: func() error { + return structures.ValidateEntityList(entryList) + }, } return w.Run(ctx) } + +var entryList string + +func splitEntryList(s string) []string { + return strings.Split(s, " ") +} + +func (o *options) configuration() cfgui.Configuration { + return cfgui.Configuration{ + { + Name: "Required", + Params: []cfgui.Parameter{ + cfgui.ChannelIDs(&entryList), + }, + }, { + Name: "Optional", + Params: []cfgui.Parameter{ + { + Name: "File naming template", + Value: o.nameTemplate, + Description: "Output file naming template", + Inline: true, + Updater: updaters.NewString(&o.nameTemplate, nametmpl.Default, false, func(s string) error { + _, err := nametmpl.New(s) + return err + }), + }, + { + Name: "V2 Compatibility mode", + Value: cfgui.Checkbox(o.compat), + Description: "Use V2 compatibility mode (slower)", + Updater: updaters.NewBool(&o.compat), + }, + { + Name: "Update links", + Value: cfgui.Checkbox(o.updateLinks), + Description: "Update file links to point to the downloaded files", + Updater: updaters.NewBool(&o.updateLinks), + }, + }, + }, + } +} diff --git a/cmd/slackdump/internal/export/wizard.go b/cmd/slackdump/internal/export/wizard.go index c36e3665..950fd83c 100644 --- a/cmd/slackdump/internal/export/wizard.go +++ b/cmd/slackdump/internal/export/wizard.go @@ -2,16 +2,65 @@ package export import ( "context" + "errors" + "regexp" "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" ) func wizExport(ctx context.Context, cmd *base.Command, args []string) error { w := &dumpui.Wizard{ Title: "Export Slack Workspace", - Particulars: "Export", + Name: "Export", Cmd: cmd, + LocalConfig: options.configuration, } return w.Run(ctx) } + +func (fl *exportFlags) configuration() cfgui.Configuration { + return cfgui.Configuration{ + { + Name: "Optional", + Params: []cfgui.Parameter{ + { + Name: "Export Storage Type", + Value: fl.ExportStorageType.String(), + Description: "Export file storage type", + Inline: true, + // TODO: V3 Implement Updater for ExportStorageType + }, + { + Name: "Member Only", + Value: cfgui.Checkbox(fl.MemberOnly), + Description: "Export only channels, which current user belongs to", + Inline: true, + Updater: updaters.NewBool(&fl.MemberOnly), + }, + { + Name: "Export Token", + Value: fl.ExportToken, + Description: "File export token to append to each of the file URLs", + Inline: true, + Updater: updaters.NewString(&fl.ExportToken, "", false, 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/export/wizard_test.go b/cmd/slackdump/internal/export/wizard_test.go new file mode 100644 index 00000000..1a0081e8 --- /dev/null +++ b/cmd/slackdump/internal/export/wizard_test.go @@ -0,0 +1,76 @@ +package export + +import ( + "testing" + + "github.com/rusq/slackdump/v3/internal/fixtures" +) + +func Test_validateToken(t *testing.T) { + type args struct { + token string + } + tests := []struct { + name string + args args + wantErr bool + }{ + { + name: "app token", + args: args{token: fixtures.TestAppToken}, + wantErr: false, + }, + { + name: "bot token", + args: args{token: fixtures.TestBotToken}, + wantErr: false, + }, + { + name: "client token", + args: args{token: fixtures.TestClientToken}, + wantErr: false, + }, + { + name: "export token", + args: args{token: fixtures.TestExportToken}, + wantErr: false, + }, + { + name: "legacy token", + args: args{token: fixtures.TestPersonalToken}, + wantErr: false, + }, + { + name: "invalid prefix", + args: args{token: "xoxz-123456789012-123456789012-123456789012-12345678901234567890123456789012"}, + wantErr: true, + }, + { + name: "short token", + args: args{token: "xoxa-123456789012-123456789012-123456789012-1234567890123456789012345678901"}, + wantErr: true, + }, + { + name: "long token", + args: args{token: "xoxa-123456789012-123456789012-123456789012-123456789012345678901234567890123"}, + wantErr: true, + }, + { + name: "non-numeric sections", + args: args{token: "xoxa-123456789012-abcdefg-123456789012-12345678901234567890123456789012"}, + wantErr: true, + }, + { + name: "non-alphanumeric suffix", + args: args{token: "xoxa-123456789012-123456789012-123456789012-1234567890123456789012345678901!"}, + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if err := validateToken(tt.args.token); (err != nil) != tt.wantErr { + t.Errorf("validateToken() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} diff --git a/cmd/slackdump/internal/ui/cfgui/cfgui.go b/cmd/slackdump/internal/ui/cfgui/cfgui.go index 98174218..4985577c 100644 --- a/cmd/slackdump/internal/ui/cfgui/cfgui.go +++ b/cmd/slackdump/internal/ui/cfgui/cfgui.go @@ -4,35 +4,66 @@ import ( "context" tea "github.com/charmbracelet/bubbletea" - "github.com/rusq/slackdump/v3/cmd/slackdump/internal/ui" + "github.com/rusq/slackdump/v3/cmd/slackdump/internal/ui/updaters" ) -// Show initialises and runs the configuration UI. -func Show(ctx context.Context) error { - p := tea.NewProgram(New()) - _, err := p.Run() - return err +// programWrap wraps the UI model to implement the tea.Model interface with +// tea.Quit message emitted when the user presses ESC or Ctrl+C. +type programWrap struct { + m *Model + finishing bool +} + +func newProgramWrap(m *Model) tea.Model { + return programWrap{m: m} +} + +func (m programWrap) Init() tea.Cmd { + return m.m.Init() } -func New() configmodel { - cfg := effectiveConfig() - end := 0 - for _, group := range cfg { - end += len(group.params) +func (m programWrap) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + var cmds []tea.Cmd + + switch msg := msg.(type) { + case updaters.WMClose: + if msg.WndID == ModelID { + m.finishing = true + cmds = append(cmds, tea.Quit) + } + } + + mod, cmd := m.m.Update(msg) + if mod, ok := mod.(*Model); ok { + m.m = mod } - end-- - return configmodel{ - cfg: effectiveConfig(), - end: end, - Style: Style{ - Border: ui.DefaultTheme().Focused.Border, - Title: ui.DefaultTheme().Focused.Options.Section, - Description: ui.DefaultTheme().Focused.Description, - Name: ui.DefaultTheme().Focused.Options.Name, - ValueEnabled: ui.DefaultTheme().Focused.Options.EnabledValue, - ValueDisabled: ui.DefaultTheme().Focused.Options.DisabledValue, - SelectedName: ui.DefaultTheme().Focused.Options.SelectedName, - Cursor: ui.DefaultTheme().Focused.Cursor, - }, + cmds = append(cmds, cmd) + + return m, tea.Batch(cmds...) +} + +func (m programWrap) View() string { + if m.finishing { + return "" } + return m.m.View() +} + +// Global initialises and runs the configuration UI. +func Global(ctx context.Context) error { + m := NewConfigUI(DefaultStyle(), globalConfig) + m.SetFocus(true) + p := tea.NewProgram(newProgramWrap(m)) + _, err := p.Run() + return err +} + +func GlobalConfig() Configuration { + return globalConfig() +} + +func Local(ctx context.Context, cfgFn func() Configuration) error { + p := tea.NewProgram(newProgramWrap(NewConfigUI(DefaultStyle(), cfgFn))) + _, err := p.Run() + return err } diff --git a/cmd/slackdump/internal/ui/cfgui/common_params.go b/cmd/slackdump/internal/ui/cfgui/common_params.go new file mode 100644 index 00000000..44797b01 --- /dev/null +++ b/cmd/slackdump/internal/ui/cfgui/common_params.go @@ -0,0 +1,18 @@ +package cfgui + +import ( + "github.com/rusq/slackdump/v3/cmd/slackdump/internal/ui/updaters" + "github.com/rusq/slackdump/v3/internal/structures" +) + +// Common reusable parameters + +func ChannelIDs(v *string) Parameter { + return Parameter{ + Name: "* Channel IDs or URLs", + Value: *v, + Description: "List of channel IDs or URLs to dump (REQUIRED)", + Inline: true, + Updater: updaters.NewString(v, "", true, structures.ValidateEntityList), + } +} diff --git a/cmd/slackdump/internal/ui/cfgui/configuration.go b/cmd/slackdump/internal/ui/cfgui/configuration.go index 1d463374..03e769cf 100644 --- a/cmd/slackdump/internal/ui/cfgui/configuration.go +++ b/cmd/slackdump/internal/ui/cfgui/configuration.go @@ -1,8 +1,10 @@ package cfgui import ( + "context" "errors" "os" + "runtime/trace" "time" tea "github.com/charmbracelet/bubbletea" @@ -10,29 +12,29 @@ import ( "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/cfgui/updaters" + "github.com/rusq/slackdump/v3/cmd/slackdump/internal/ui/updaters" ) -type configuration []group +type Configuration []ParamGroup -type group struct { - name string - params []parameter +type ParamGroup struct { + Name string + Params []Parameter } -type parameter struct { +type Parameter struct { Name string Value string Description string Inline bool - Model tea.Model + Updater tea.Model } -func effectiveConfig() configuration { - return configuration{ +func globalConfig() Configuration { + return Configuration{ { - name: "Authentication", - params: []parameter{ + Name: "Authentication", + Params: []Parameter{ { Name: "Slack Workspace", Value: bootstrap.CurrentWsp(), @@ -41,64 +43,64 @@ func effectiveConfig() configuration { }, }, { - name: "Timeframe", - params: []parameter{ + Name: "Timeframe", + Params: []Parameter{ { Name: "Start date", Value: cfg.Oldest.String(), Description: "The oldest message to fetch", - Model: updaters.NewDTTM((*time.Time)(&cfg.Oldest)), + Updater: updaters.NewDTTM((*time.Time)(&cfg.Oldest)), }, { Name: "End date", Value: cfg.Latest.String(), Description: "The newest message to fetch", - Model: updaters.NewDTTM((*time.Time)(&cfg.Latest)), + Updater: updaters.NewDTTM((*time.Time)(&cfg.Latest)), }, }, }, { - name: "Output", - params: []parameter{ + Name: "Output", + Params: []Parameter{ { Name: "Output", Value: cfg.Output, Inline: true, Description: "Output directory", - Model: updaters.NewFileNew(&cfg.Output, "ZIP or Directory", false, true), + Updater: updaters.NewFileNew(&cfg.Output, "ZIP or Directory", false, true), }, }, }, { - name: "API options", - params: []parameter{ + Name: "API options", + Params: []Parameter{ + { + Name: "Download files", + Value: Checkbox(cfg.DownloadFiles), + Description: "Download files", + Updater: updaters.NewBool(&cfg.DownloadFiles), + }, { Name: "Enterprise mode", - Value: checkbox(cfg.ForceEnterprise), + Value: Checkbox(cfg.ForceEnterprise), Description: "Force enterprise mode", - Model: updaters.NewBool(&cfg.ForceEnterprise), + Updater: updaters.NewBool(&cfg.ForceEnterprise), }, { Name: "API limits file", Value: cfg.ConfigFile, Description: "API limits file", - Model: updaters.NewExistingFile( + Updater: updaters.NewFilepickModel( &cfg.ConfigFile, filemgr.New(os.DirFS("."), ".", 15, "*.yaml", "*.yml"), validateAPIconfig, ), }, - { - Name: "Download files", - Value: checkbox(cfg.DownloadFiles), - Description: "Download files", - Model: updaters.NewBool(&cfg.DownloadFiles), - }, }, }, { - name: "Cache Control", - params: []parameter{ + Name: "Cache Control", + Params: []Parameter{ { Name: "Local Cache Directory", Value: cfg.LocalCacheDir, @@ -111,15 +113,15 @@ func effectiveConfig() configuration { }, { Name: "Disable User Cache", - Value: checkbox(cfg.NoUserCache), + Value: Checkbox(cfg.NoUserCache), Description: "Disable User Cache", - Model: updaters.NewBool(&cfg.NoUserCache), + Updater: updaters.NewBool(&cfg.NoUserCache), }, { Name: "Disable Chunk Cache", - Value: checkbox(cfg.NoChunkCache), + Value: Checkbox(cfg.NoChunkCache), Description: "Disable Chunk Cache", - Model: updaters.NewBool(&cfg.NoChunkCache), + Updater: updaters.NewBool(&cfg.NoChunkCache), }, }, }, @@ -127,6 +129,8 @@ func effectiveConfig() configuration { } func validateAPIconfig(s string) error { + _, task := trace.NewTask(context.Background(), "validateAPIconfig") + defer task.End() if s == "" { return nil } diff --git a/cmd/slackdump/internal/ui/cfgui/keymap.go b/cmd/slackdump/internal/ui/cfgui/keymap.go new file mode 100644 index 00000000..d0b40548 --- /dev/null +++ b/cmd/slackdump/internal/ui/cfgui/keymap.go @@ -0,0 +1,29 @@ +package cfgui + +import "github.com/charmbracelet/bubbles/key" + +type Keymap struct { + Up key.Binding + Down key.Binding + Home key.Binding + End key.Binding + Refresh key.Binding + Select key.Binding + Quit key.Binding +} + +func DefaultKeymap() *Keymap { + return &Keymap{ + Up: key.NewBinding(key.WithKeys("up", "k"), key.WithHelp("↑", "up")), + Down: key.NewBinding(key.WithKeys("down", "j"), key.WithHelp("↓", "down")), + Home: key.NewBinding(key.WithKeys("home"), key.WithHelp("home/end", "top/bottom")), + End: key.NewBinding(key.WithKeys("end")), + Refresh: key.NewBinding(key.WithKeys("f5", "ctrl+r"), key.WithHelp("f5", "refresh")), + Select: key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "submit")), + Quit: key.NewBinding(key.WithKeys("q", "esc", "ctrl+c"), key.WithHelp("q", "quit")), + } +} + +func (k *Keymap) Bindings() []key.Binding { + return []key.Binding{k.Up, k.Down, k.Home, k.Refresh, k.Select, k.Quit} +} diff --git a/cmd/slackdump/internal/ui/cfgui/model.go b/cmd/slackdump/internal/ui/cfgui/model.go index adda7927..890f5940 100644 --- a/cmd/slackdump/internal/ui/cfgui/model.go +++ b/cmd/slackdump/internal/ui/cfgui/model.go @@ -1,15 +1,20 @@ package cfgui import ( + "context" "fmt" + "regexp" + "runtime/trace" "strings" + "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/updaters" + "github.com/rusq/slackdump/v3/cmd/slackdump/internal/ui/updaters" ) +const ModelID = "cfgui" + const ( sEmpty = "" sTrue = "[x]" @@ -21,29 +26,37 @@ const ( notFound = -1 ) -type Style struct { - Border lipgloss.Style - Title lipgloss.Style - Description lipgloss.Style - Name lipgloss.Style - ValueEnabled lipgloss.Style - ValueDisabled lipgloss.Style - SelectedName lipgloss.Style - Cursor lipgloss.Style -} - -type configmodel struct { +type Model struct { finished bool - cfg configuration + focused bool cursor int - end int - Style Style + last int + state state + help help.Model + + style *Style + keymap *Keymap child tea.Model - state state + cfgFn func() Configuration } -func (m configmodel) Init() tea.Cmd { +func NewConfigUI(sty *Style, cfgFn func() Configuration) *Model { + end := 0 + for _, group := range cfgFn() { + end += len(group.Params) + } + end-- + return &Model{ + cfgFn: cfgFn, + last: end, + keymap: DefaultKeymap(), + style: sty, + help: help.New(), + } +} + +func (m *Model) Init() tea.Cmd { return nil } @@ -55,11 +68,20 @@ const ( inline ) -func (m configmodel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { +func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + ctx, task := trace.NewTask(context.Background(), "cfgui.Update") + defer task.End() + + if !m.focused { + return m, nil + } + var cmds []tea.Cmd if _, ok := msg.(updaters.WMClose); m.child != nil && !ok && m.state != selecting { + rgn := trace.StartRegion(ctx, "child.Update") child, cmd := m.child.Update(msg) + rgn.End() m.child = child return m, cmd } @@ -67,95 +89,119 @@ func (m configmodel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case updaters.WMClose: // child sends a close message - m.state = selecting - m.child = nil - cmds = append(cmds, refreshCfgCmd) - case wmRefresh: - m.cfg = msg.cfg + if msg.WndID == updaters.ModelID { + m.state = selecting + m.child = nil + cmds = append(cmds, refreshCfgCmd) + } else if msg.WndID == ModelID { + m.finished = true + } case tea.KeyMsg: - switch msg.String() { - case "up", "k": + switch { + case key.Matches(msg, m.keymap.Up): if m.cursor > 0 { m.cursor-- } else { // wrap around - m.cursor = m.end + m.cursor = m.last } - case "down", "j": - if m.cursor < m.end { + case key.Matches(msg, m.keymap.Down): + if m.cursor < m.last { m.cursor++ } else { // wrap around m.cursor = 0 } - case "home": + case key.Matches(msg, m.keymap.Home): m.cursor = 0 - case "end": - m.cursor = m.end - case "f5": + case key.Matches(msg, m.keymap.End): + m.cursor = m.last + case key.Matches(msg, m.keymap.Refresh): cmds = append(cmds, refreshCfgCmd) - case "enter": - i, j := locateParam(m.cfg, m.cursor) + case key.Matches(msg, m.keymap.Select): + i, j := locateParam(m.cfgFn(), m.cursor) if i == notFound || j == notFound { return m, nil } - if params := m.cfg[i].params[j]; params.Model != nil { + if params := m.cfgFn()[i].Params[j]; params.Updater != nil { if params.Inline { m.state = inline } else { m.state = editing } - m.child = params.Model + m.child = params.Updater cmds = append(cmds, m.child.Init()) } - case "q", "esc", "ctrl+c": - // child is active - if m.state != selecting { - break - } + case key.Matches(msg, m.keymap.Quit): m.finished = true - return m, tea.Quit + cmds = append(cmds, updaters.CmdClose(ModelID)) + case reNumber.MatchString(msg.String()): + if 0 < m.cursor || m.cursor < m.last { + m.cursor = int(msg.String()[0] - '1') + } } } return m, tea.Batch(cmds...) } -func (m configmodel) View() string { +var reNumber = regexp.MustCompile(`^[1-9]$`) + +func (m *Model) SetFocus(b bool) { + m.focused = b +} + +func (m *Model) IsFocused() bool { + return m.focused +} + +func (m *Model) Reset() { + m.finished = false + m.state = selecting + m.child = nil +} + +func (m *Model) View() string { + _, task := trace.NewTask(context.Background(), "cfgui.View") + defer task.End() if m.finished { return "" } + var sty = m.style.Focused + if !m.focused { + sty = m.style.Blurred + } if m.child != nil && len(m.child.View()) > 0 && m.state == editing { return m.child.View() } - return ui.DefaultTheme().Focused.Border.Render(m.view()) + return sty.Border.Render(m.view(sty)) } -func (m configmodel) view() string { +func (m *Model) view(sty StyleSet) string { var buf strings.Builder line := 0 descr := "" - for i, group := range m.cfg { - buf.WriteString(alignGroup + m.Style.Title.Render(group.name)) + for i, group := range m.cfgFn() { + buf.WriteString(alignGroup + sty.Title.Render(group.Name)) buf.WriteString("\n") keyLen, valLen := group.maxLen() - for j, param := range group.params { + for j, param := range group.Params { selected := line == m.cursor if selected { - buf.WriteString(m.Style.Cursor.Render(cursorChar)) - descr = m.cfg[i].params[j].Description + buf.WriteString(sty.Cursor.Render(cursorChar)) + descr = m.cfgFn()[i].Params[j].Description } else { buf.WriteString(" ") } - valfmt := m.Style.ValueDisabled - if param.Model != nil { - valfmt = m.Style.ValueEnabled + valfmt := sty.ValueDisabled + if param.Updater != nil { + valfmt = sty.ValueEnabled } - namefmt := m.Style.Name + namefmt := sty.Name if selected { - namefmt = m.Style.SelectedName + namefmt = sty.SelectedName } fmt.Fprintf(&buf, alignParam+namefmt.Render(fmt.Sprintf("% *s", keyLen, param.Name))+" ") if selected && m.state == inline { @@ -166,7 +212,8 @@ func (m configmodel) view() string { line++ } } - buf.WriteString(alignGroup + m.Style.Description.Render(descr)) + buf.WriteString(alignGroup + sty.Description.Render(descr) + "\n") + buf.WriteString(m.help.ShortHelpView(m.keymap.Bindings())) return buf.String() } @@ -178,8 +225,8 @@ func nvl(s string) string { return s } -func (g group) maxLen() (key int, val int) { - for _, param := range g.params { +func (g ParamGroup) maxLen() (key int, val int) { + for _, param := range g.Params { if l := len(param.Name); l > key { key = l } @@ -190,7 +237,7 @@ func (g group) maxLen() (key int, val int) { return key, val } -func checkbox(b bool) string { +func Checkbox(b bool) string { if b { return sTrue } @@ -199,19 +246,19 @@ func checkbox(b bool) string { // commands func refreshCfgCmd() tea.Msg { - return wmRefresh{effectiveConfig()} + return wmRefresh{globalConfig()} } type wmRefresh struct { - cfg configuration + cfg Configuration } -func locateParam(cfg configuration, line int) (int, int) { +func locateParam(cfg Configuration, line int) (int, int) { end := 0 for i, group := range cfg { - end += len(group.params) + end += len(group.Params) if line < end { - return i, line - (end - len(group.params)) + return i, line - (end - len(group.Params)) } } return notFound, notFound diff --git a/cmd/slackdump/internal/ui/cfgui/style.go b/cmd/slackdump/internal/ui/cfgui/style.go new file mode 100644 index 00000000..e440e8d4 --- /dev/null +++ b/cmd/slackdump/internal/ui/cfgui/style.go @@ -0,0 +1,48 @@ +package cfgui + +import ( + "github.com/charmbracelet/lipgloss" + "github.com/rusq/slackdump/v3/cmd/slackdump/internal/ui" +) + +type Style struct { + Focused StyleSet + Blurred StyleSet +} + +type StyleSet struct { + Border lipgloss.Style + Title lipgloss.Style + Description lipgloss.Style + Name lipgloss.Style + ValueEnabled lipgloss.Style + ValueDisabled lipgloss.Style + SelectedName lipgloss.Style + Cursor lipgloss.Style +} + +func DefaultStyle() *Style { + t := ui.DefaultTheme() + return &Style{ + Focused: StyleSet{ + Border: t.Focused.Border, + Title: t.Focused.Options.Section, + Description: t.Focused.Description, + Name: t.Focused.Options.Name, + ValueEnabled: t.Focused.Options.EnabledValue, + ValueDisabled: t.Focused.Options.DisabledValue, + SelectedName: t.Focused.Options.SelectedName, + Cursor: t.Focused.Cursor, + }, + Blurred: StyleSet{ + Border: t.Blurred.Border, + Title: t.Blurred.Options.Section, + Description: t.Blurred.Description, + Name: t.Blurred.Options.Name, + ValueEnabled: t.Blurred.Options.EnabledValue, + ValueDisabled: t.Blurred.Options.DisabledValue, + SelectedName: t.Blurred.Options.SelectedName, + Cursor: t.Blurred.Cursor, + }, + } +} diff --git a/cmd/slackdump/internal/ui/dumpui/dumpui.go b/cmd/slackdump/internal/ui/dumpui/dumpui.go index dd7bc4a3..9e9b0679 100644 --- a/cmd/slackdump/internal/ui/dumpui/dumpui.go +++ b/cmd/slackdump/internal/ui/dumpui/dumpui.go @@ -4,57 +4,100 @@ package dumpui import ( "context" - "github.com/charmbracelet/huh" - "github.com/rusq/slackdump/v3/cmd/slackdump/internal/cfg" + tea "github.com/charmbracelet/bubbletea" "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" ) +// Wizard is a universal wizard for running dump-family commands. type Wizard struct { - Title string - Particulars string - Cmd *base.Command + // Title is the title of the command. + Title string + // Name is the name of the command. + Name string + // LocalConfig should return a configuration for the command. + LocalConfig func() cfgui.Configuration + // ArgsFn should return a slice of arguments to pass to the command. + ArgsFn func() []string + // ValidateParamsFn should return true if the parameters are OK. + ValidateParamsFn func() error + // Cmd is the command to run. + Cmd *base.Command } const ( - actRun = "run" - actConfig = "config" - actExit = "exit" + actRun = "run" + actGlobalConfig = "config" + actLocalConfig = "localconfig" + actExit = "exit" ) +var description = map[string]string{ + actRun: "Run the command", + actGlobalConfig: "Set global configuration options", + actLocalConfig: "Set command specific configuration options", + actExit: "Exit to main menu", +} + func (w *Wizard) Run(ctx context.Context) error { - var ( - action string = actRun - ) + var menu = func() *Model { + items := []MenuItem{ + { + ID: actRun, + Name: "Run " + w.Name, + Help: description[actRun], + Validate: func() error { + if w.ValidateParamsFn != nil { + return w.ValidateParamsFn() + } + return nil + }, + }, + } + if w.LocalConfig != nil { + items = append(items, MenuItem{ + ID: actLocalConfig, + Name: w.Name + " Configuration...", + Help: description[actLocalConfig], + Model: cfgui.NewConfigUI(cfgui.DefaultStyle(), w.LocalConfig), + }) + } + items = append( + items, + MenuItem{ + ID: actGlobalConfig, + Name: "Global Configuration...", + Help: description[actGlobalConfig], + Model: cfgui.NewConfigUI(cfgui.DefaultStyle(), cfgui.GlobalConfig), // TODO: filthy cast + }, + MenuItem{Separator: true}, + MenuItem{ID: actExit, Name: "Exit", Help: description[actExit]}, + ) - menu := func() *huh.Form { - return huh.NewForm( - huh.NewGroup( - huh.NewSelect[string](). - Title(w.Title). - Options( - huh.NewOption("Run "+w.Particulars, actRun), - huh.NewOption("Configuration...", actConfig), - huh.NewOption(ui.MenuSeparator, ""), - huh.NewOption("<< Exit to Main Menu", actExit), - ).Value(&action), - ), - ).WithTheme(ui.HuhTheme).WithAccessible(cfg.AccessibleMode) + return NewModel(w.Title, items, false) } LOOP: for { - if err := menu().RunWithContext(ctx); err != nil { + m := menu() + if _, err := tea.NewProgram(m, tea.WithContext(ctx)).Run(); err != nil { return err } - switch action { + if m.Cancelled { + break + } + switch m.Selected.ID { case actRun: - if err := w.Cmd.Run(ctx, w.Cmd, nil); err != nil { - return err + if w.ValidateParamsFn != nil { + if err := w.ValidateParamsFn(); err != nil { + continue + } + } + var args []string + if w.ArgsFn != nil { + args = w.ArgsFn() } - case actConfig: - if err := cfgui.Show(ctx); err != nil { + if err := w.Cmd.Run(ctx, w.Cmd, args); err != nil { return err } case actExit: diff --git a/cmd/slackdump/internal/ui/dumpui/focusmodel.go b/cmd/slackdump/internal/ui/dumpui/focusmodel.go new file mode 100644 index 00000000..b531d8d8 --- /dev/null +++ b/cmd/slackdump/internal/ui/dumpui/focusmodel.go @@ -0,0 +1,11 @@ +package dumpui + +import tea "github.com/charmbracelet/bubbletea" + +type FocusModel interface { + tea.Model + SetFocus(bool) + IsFocused() bool + // Reset should reset the model to its initial state. + Reset() +} diff --git a/cmd/slackdump/internal/ui/dumpui/keymap.go b/cmd/slackdump/internal/ui/dumpui/keymap.go new file mode 100644 index 00000000..f8fb7dbb --- /dev/null +++ b/cmd/slackdump/internal/ui/dumpui/keymap.go @@ -0,0 +1,23 @@ +package dumpui + +import "github.com/charmbracelet/bubbles/key" + +type Keymap struct { + Up key.Binding + Down key.Binding + Select key.Binding + Quit key.Binding +} + +func DefaultKeymap() *Keymap { + return &Keymap{ + Up: key.NewBinding(key.WithKeys("up", "k"), key.WithHelp("↑", "up")), + Down: key.NewBinding(key.WithKeys("down", "j"), key.WithHelp("↓", "down")), + Select: key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "submit")), + Quit: key.NewBinding(key.WithKeys("q", "ctrl+c", "esc"), key.WithHelp("q", "quit")), + } +} + +func (k *Keymap) Bindings() []key.Binding { + return []key.Binding{k.Up, k.Down, k.Select, k.Quit} +} diff --git a/cmd/slackdump/internal/ui/dumpui/menuitem.go b/cmd/slackdump/internal/ui/dumpui/menuitem.go new file mode 100644 index 00000000..7d202ce5 --- /dev/null +++ b/cmd/slackdump/internal/ui/dumpui/menuitem.go @@ -0,0 +1,35 @@ +package dumpui + +// MenuItem is an item in a menu. +type MenuItem struct { + // ID is an arbitrary ID, up to caller. + ID string + // Separator is a flag that determines whether the item is a separator or + // not. + Separator bool + // Name is the name of the Item, will be displayed in the menu. + Name string + // Help is the help text for the item, that will be shown when the + // item is highlighted. + Help string + // Model is any model that should be displayed when the item is selected, + // or executed when the user presses enter. + Model FocusModel + // 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 { + return m.Validate != nil && m.Validate() != nil +} + +func (m MenuItem) DisabledReason() string { + if m.Validate != nil { + if err := m.Validate(); err != nil { + return err.Error() + } + } + return "" +} diff --git a/cmd/slackdump/internal/ui/dumpui/model.go b/cmd/slackdump/internal/ui/dumpui/model.go new file mode 100644 index 00000000..d2a34679 --- /dev/null +++ b/cmd/slackdump/internal/ui/dumpui/model.go @@ -0,0 +1,199 @@ +package dumpui + +import ( + "strings" + + "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" +) + +type Model struct { + // Selected will be set to the selected item from the items. + Selected MenuItem + Cancelled bool + + title string + items []MenuItem + finishing bool + focused bool + preview bool // preview child model + Style *Style + Keymap *Keymap + + help help.Model + + cursor int +} + +func NewModel(title string, items []MenuItem, preview bool) *Model { + return &Model{ + title: title, + items: items, + Style: DefaultStyle(), + Keymap: DefaultKeymap(), + help: help.New(), + focused: true, + preview: preview, + finishing: false, + } +} + +func (m *Model) Init() tea.Cmd { + return nil +} + +func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + var cmds []tea.Cmd + child := m.items[m.cursor].Model + + if !m.focused { + if wmclose, ok := msg.(updaters.WMClose); ok && wmclose.WndID == cfgui.ModelID { + child.Reset() + child.SetFocus(false) + m.SetFocus(true) + return m, nil + } + ch, cmd := child.Update(msg) + if ch, ok := ch.(FocusModel); ok { + m.items[m.cursor].Model = ch + } + cmds = append(cmds, cmd) + return m, tea.Batch(cmds...) + } + + switch msg := msg.(type) { + case tea.KeyMsg: + switch { + case key.Matches(msg, m.Keymap.Quit): + m.finishing = true + m.Cancelled = true + 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 + } + } + case key.Matches(msg, m.Keymap.Down): + for { + if m.cursor < len(m.items)-1 { + m.cursor++ + } + if !m.items[m.cursor].Separator { + break + } + } + case key.Matches(msg, m.Keymap.Select): + validate := m.items[m.cursor].Validate + if m.items[m.cursor].Separator || (validate != nil && validate() != nil) { + // do nothing + } else { + if child := m.items[m.cursor].Model; child != nil { + // If there is a child model, focus it. + m.SetFocus(false) + child.SetFocus(true) + cmds = append(cmds, child.Init()) + } else { + // otherwise, return selected item and quit + m.Selected = m.items[m.cursor] + m.finishing = true + cmds = append(cmds, tea.Quit) + } + } + } + } + return m, tea.Batch(cmds...) +} + +func (m *Model) SetFocus(b bool) { + m.focused = b +} + +func (m *Model) IsFocused() bool { + return m.focused +} + +func (m *Model) View() string { + if m.finishing { + return "" + } + if m.items[m.cursor].Model != nil { + if m.focused { + if m.preview { + return lipgloss.JoinHorizontal(lipgloss.Top, m.view(), m.items[m.cursor].Model.View()) + } else { + return m.view() + } + } + return m.items[m.cursor].Model.View() + } + return m.view() +} + +func (m *Model) view() string { + var b strings.Builder + + sty := m.Style.Focused + if !m.focused { + sty = m.Style.Blurred + } + + currentItem := m.items[m.cursor] + currentDisabled := currentItem.Validate != nil && currentItem.Validate() != nil + + p := b.WriteString + // Header + p(sty.Title.Render(m.title) + "\n") + if currentDisabled { + p(sty.Description.Render(currentItem.Validate().Error())) + } else { + p(sty.Description.Render(m.items[m.cursor].Help)) + } + const ( + padding = " " + pointer = "> " + ) + for i, itm := range m.items { + p("\n") + if itm.Separator { + p(padding + ui.MenuSeparator) + continue + } + + var ( + current = i == m.cursor + disabled = itm.Validate != nil && itm.Validate() != nil + ) + if disabled { + p(sty.ItemDisabled.Render(iftrue(current, pointer, padding) + itm.Name)) + continue + } + p(iftrue( + current, + sty.Cursor.Render(pointer)+sty.ItemSelected.Render(itm.Name), + sty.Item.Render(padding+itm.Name), + )) + } + b.WriteString("\n" + m.footer()) + return sty.Border.Render(b.String()) +} + +func iftrue(t bool, a, b string) string { + if t { + return a + } + return b +} + +func (m *Model) footer() string { + return m.help.ShortHelpView(m.Keymap.Bindings()) +} diff --git a/cmd/slackdump/internal/ui/dumpui/style.go b/cmd/slackdump/internal/ui/dumpui/style.go new file mode 100644 index 00000000..2b1f216a --- /dev/null +++ b/cmd/slackdump/internal/ui/dumpui/style.go @@ -0,0 +1,45 @@ +package dumpui + +import ( + "github.com/charmbracelet/lipgloss" + "github.com/rusq/slackdump/v3/cmd/slackdump/internal/ui" +) + +type Style struct { + Focused StyleSet + Blurred StyleSet +} + +type StyleSet struct { + Border lipgloss.Style + Title lipgloss.Style + Description lipgloss.Style + Cursor lipgloss.Style + Item lipgloss.Style + ItemSelected lipgloss.Style + ItemDisabled lipgloss.Style +} + +func DefaultStyle() *Style { + t := ui.DefaultTheme() + return &Style{ + Focused: StyleSet{ + Border: t.Focused.Border, + Title: t.Focused.Title, + Description: t.Focused.Description, + Cursor: t.Focused.Cursor, + Item: t.Focused.Text, + ItemSelected: t.Focused.SelectedLine, + ItemDisabled: t.Blurred.Text, + }, + Blurred: StyleSet{ + Border: t.Blurred.Border, + Title: t.Blurred.Title, + Description: t.Blurred.Description, + Cursor: t.Blurred.Cursor, + Item: t.Blurred.Text, + ItemSelected: t.Blurred.SelectedLine, + ItemDisabled: t.Blurred.Text, + }, + } +} diff --git a/cmd/slackdump/internal/ui/theme.go b/cmd/slackdump/internal/ui/theme.go index ff2f48aa..9b711455 100644 --- a/cmd/slackdump/internal/ui/theme.go +++ b/cmd/slackdump/internal/ui/theme.go @@ -27,11 +27,11 @@ type ControlStyle struct { Text lipgloss.Style - Cursor lipgloss.Style + // Cursor is the pointer to the selected item, i.e. the ">" in a list. + Cursor lipgloss.Style + // SelectedLine is the style for the selected line in a list, next to the pointer. SelectedLine lipgloss.Style - - Selected lipgloss.Style - Unselected lipgloss.Style + Unselected lipgloss.Style SelectedFile lipgloss.Style UnselectedFile lipgloss.Style @@ -68,10 +68,9 @@ func DefaultTheme() Theme { Focused: ControlStyle{ Border: lipgloss.NewStyle().BorderLeft(true).BorderForeground(cyan).BorderStyle(lipgloss.ThickBorder()).Padding(0, 1), Title: lipgloss.NewStyle().Foreground(green).Bold(true), - Description: lipgloss.NewStyle().Foreground(white), + Description: lipgloss.NewStyle().Foreground(gray), Cursor: lipgloss.NewStyle().Foreground(yellow), SelectedLine: lipgloss.NewStyle().Background(green).Foreground(black), - Selected: lipgloss.NewStyle().Foreground(green), Unselected: lipgloss.NewStyle().Foreground(white), SelectedFile: lipgloss.NewStyle().Foreground(green), UnselectedFile: lipgloss.NewStyle().Foreground(white), @@ -92,7 +91,6 @@ func DefaultTheme() Theme { Description: lipgloss.NewStyle().Foreground(gray), Cursor: lipgloss.NewStyle().Foreground(gray), SelectedLine: lipgloss.NewStyle().Background(gray).Foreground(black), - Selected: lipgloss.NewStyle().Foreground(gray), Unselected: lipgloss.NewStyle().Foreground(gray), SelectedFile: lipgloss.NewStyle().Foreground(gray), UnselectedFile: lipgloss.NewStyle().Foreground(gray), diff --git a/cmd/slackdump/internal/ui/cfgui/updaters/bool.go b/cmd/slackdump/internal/ui/updaters/bool.go similarity index 100% rename from cmd/slackdump/internal/ui/cfgui/updaters/bool.go rename to cmd/slackdump/internal/ui/updaters/bool.go diff --git a/cmd/slackdump/internal/ui/cfgui/updaters/bool_test.go b/cmd/slackdump/internal/ui/updaters/bool_test.go similarity index 100% rename from cmd/slackdump/internal/ui/cfgui/updaters/bool_test.go rename to cmd/slackdump/internal/ui/updaters/bool_test.go diff --git a/cmd/slackdump/internal/ui/cfgui/updaters/commands.go b/cmd/slackdump/internal/ui/updaters/commands.go similarity index 64% rename from cmd/slackdump/internal/ui/cfgui/updaters/commands.go rename to cmd/slackdump/internal/ui/updaters/commands.go index 3ca49c49..84650554 100644 --- a/cmd/slackdump/internal/ui/cfgui/updaters/commands.go +++ b/cmd/slackdump/internal/ui/updaters/commands.go @@ -2,15 +2,23 @@ package updaters import tea "github.com/charmbracelet/bubbletea" -type WMClose struct{} +const ModelID = "updater" + +type WMClose struct { + // WndID is the window ID to close. If empty, the current window + // will be closed. + WndID string +} // OnClose defines the global command to close the program. It is set // by default to [CmdClose], but if running standalone, one must set it // to [tea.Quit], otherwise the program will not exit. -var OnClose = CmdClose +var OnClose = CmdClose(ModelID) -func CmdClose() tea.Msg { - return WMClose{} +func CmdClose(id string) func() tea.Msg { + return func() tea.Msg { + return WMClose{id} + } } // WMError is sent when an error occurs, for example, a validation error, diff --git a/cmd/slackdump/internal/ui/cfgui/updaters/date.go b/cmd/slackdump/internal/ui/updaters/date.go similarity index 71% rename from cmd/slackdump/internal/ui/cfgui/updaters/date.go rename to cmd/slackdump/internal/ui/updaters/date.go index 299cc708..bd002dda 100644 --- a/cmd/slackdump/internal/ui/cfgui/updaters/date.go +++ b/cmd/slackdump/internal/ui/updaters/date.go @@ -4,6 +4,8 @@ import ( "strings" "time" + "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" @@ -20,6 +22,8 @@ type DateModel struct { finishing bool timeEnabled bool state state + keymap dateKeymap + help help.Model } func NewDTTM(ptrTime *time.Time) DateModel { @@ -40,7 +44,36 @@ func NewDTTM(ptrTime *time.Time) DateModel { tm: t, focusstyle: ui.DefaultTheme().Focused.Border, blurstyle: ui.DefaultTheme().Blurred.Border, + keymap: defaultDateKeymap(), timeEnabled: true, + help: help.New(), + } +} + +type dateKeymap struct { + NextField key.Binding + PrevField key.Binding + Arrows key.Binding + Select key.Binding + Cancel key.Binding + Clear key.Binding +} + +func defaultDateKeymap() dateKeymap { + return dateKeymap{ + NextField: key.NewBinding(key.WithKeys("tab"), key.WithHelp("↹", "next")), + PrevField: key.NewBinding(key.WithKeys("shift+tab"), key.WithHelp("⇧ + ↹", "prev")), + Arrows: key.NewBinding(key.WithKeys("esc", "ctrl+c", "q"), key.WithHelp("←↑↓→", "move")), + Select: key.NewBinding(key.WithKeys("enter"), key.WithHelp("↵", "select")), + Cancel: key.NewBinding(key.WithKeys("esc", "ctrl+c", "q"), key.WithHelp("Esc", "cancel")), + Clear: key.NewBinding(key.WithKeys("backspace"), key.WithHelp("backspace", "clear")), + } +} + +func (m dateKeymap) keybindings() [][]key.Binding { + return [][]key.Binding{ + {m.NextField, m.PrevField, m.Arrows}, + {m.Select, m.Cancel, m.Clear}, } } @@ -127,7 +160,7 @@ func (m DateModel) View() string { var b strings.Builder - help := ui.DefaultTheme().Help.Ellipsis.Render("arrow keys: adjust • tab/shift+tab: switch fields\nenter: select • backspace: clear • esc: cancel") + help := m.help.FullHelpView(m.keymap.keybindings()) var dateStyle lipgloss.Style var timeStyle lipgloss.Style @@ -147,11 +180,7 @@ func (m DateModel) View() string { timeStyle.Render(m.tm.View()), )) } else { - b.WriteString(lipgloss.JoinVertical( - lipgloss.Center, - dateStyle.Render(m.dm.View()), - help, - )) + b.WriteString(dateStyle.Render(m.dm.View())) } - return b.String() + return lipgloss.JoinVertical(lipgloss.Left, b.String(), help) } diff --git a/cmd/slackdump/internal/ui/cfgui/updaters/examples/date/main.go b/cmd/slackdump/internal/ui/updaters/examples/date/main.go similarity index 80% rename from cmd/slackdump/internal/ui/cfgui/updaters/examples/date/main.go rename to cmd/slackdump/internal/ui/updaters/examples/date/main.go index 45b8aa7c..15934838 100644 --- a/cmd/slackdump/internal/ui/cfgui/updaters/examples/date/main.go +++ b/cmd/slackdump/internal/ui/updaters/examples/date/main.go @@ -6,7 +6,7 @@ import ( tea "github.com/charmbracelet/bubbletea" - "github.com/rusq/slackdump/v3/cmd/slackdump/internal/ui/cfgui/updaters" + "github.com/rusq/slackdump/v3/cmd/slackdump/internal/ui/updaters" ) func main() { diff --git a/cmd/slackdump/internal/ui/cfgui/updaters/examples/filenew/main.go b/cmd/slackdump/internal/ui/updaters/examples/filenew/main.go similarity index 81% rename from cmd/slackdump/internal/ui/cfgui/updaters/examples/filenew/main.go rename to cmd/slackdump/internal/ui/updaters/examples/filenew/main.go index ce89760c..3598b1cc 100644 --- a/cmd/slackdump/internal/ui/cfgui/updaters/examples/filenew/main.go +++ b/cmd/slackdump/internal/ui/updaters/examples/filenew/main.go @@ -5,7 +5,7 @@ import ( tea "github.com/charmbracelet/bubbletea" - "github.com/rusq/slackdump/v3/cmd/slackdump/internal/ui/cfgui/updaters" + "github.com/rusq/slackdump/v3/cmd/slackdump/internal/ui/updaters" ) func main() { diff --git a/cmd/slackdump/internal/ui/cfgui/updaters/examples/string/main.go b/cmd/slackdump/internal/ui/updaters/examples/string/main.go similarity index 85% rename from cmd/slackdump/internal/ui/cfgui/updaters/examples/string/main.go rename to cmd/slackdump/internal/ui/updaters/examples/string/main.go index e3eea431..b5e70e69 100644 --- a/cmd/slackdump/internal/ui/cfgui/updaters/examples/string/main.go +++ b/cmd/slackdump/internal/ui/updaters/examples/string/main.go @@ -6,7 +6,7 @@ import ( tea "github.com/charmbracelet/bubbletea" "github.com/rusq/slackdump/v3/cmd/slackdump/internal/ui" - "github.com/rusq/slackdump/v3/cmd/slackdump/internal/ui/cfgui/updaters" + "github.com/rusq/slackdump/v3/cmd/slackdump/internal/ui/updaters" ) func main() { diff --git a/cmd/slackdump/internal/ui/cfgui/updaters/examples/time/main.go b/cmd/slackdump/internal/ui/updaters/examples/time/main.go similarity index 100% rename from cmd/slackdump/internal/ui/cfgui/updaters/examples/time/main.go rename to cmd/slackdump/internal/ui/updaters/examples/time/main.go diff --git a/cmd/slackdump/internal/ui/cfgui/updaters/filepick.go b/cmd/slackdump/internal/ui/updaters/filepick.go similarity index 82% rename from cmd/slackdump/internal/ui/cfgui/updaters/filepick.go rename to cmd/slackdump/internal/ui/updaters/filepick.go index 0bf64d7e..0277115d 100644 --- a/cmd/slackdump/internal/ui/cfgui/updaters/filepick.go +++ b/cmd/slackdump/internal/ui/updaters/filepick.go @@ -9,7 +9,7 @@ import ( "github.com/rusq/slackdump/v3/cmd/slackdump/internal/ui" ) -type FileModel struct { +type FilepickModel struct { fp filemgr.Model v *string validate func(s string) error @@ -18,7 +18,7 @@ type FileModel struct { errStyle lipgloss.Style } -func NewExistingFile(ptrStr *string, f filemgr.Model, validateFn func(s string) error) FileModel { +func NewFilepickModel(ptrStr *string, f filemgr.Model, validateFn func(s string) error) FilepickModel { f.Focus() f.ShowHelp = true f.Style = filemgr.Style{ @@ -26,7 +26,7 @@ func NewExistingFile(ptrStr *string, f filemgr.Model, validateFn func(s string) Directory: ui.DefaultTheme().Focused.Directory, Inverted: ui.DefaultTheme().Focused.SelectedFile, } - return FileModel{ + return FilepickModel{ fp: f, v: ptrStr, validate: validateFn, @@ -35,11 +35,11 @@ func NewExistingFile(ptrStr *string, f filemgr.Model, validateFn func(s string) } } -func (m FileModel) Init() tea.Cmd { +func (m FilepickModel) Init() tea.Cmd { return m.fp.Init() } -func (m FileModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { +func (m FilepickModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { var cmd tea.Cmd var cmds []tea.Cmd @@ -68,7 +68,7 @@ func (m FileModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, tea.Batch(cmds...) } -func (m FileModel) View() string { +func (m FilepickModel) View() string { var buf strings.Builder buf.WriteString(m.fp.View()) if m.err != nil { diff --git a/cmd/slackdump/internal/ui/cfgui/updaters/filepick_new.go b/cmd/slackdump/internal/ui/updaters/filepick_new.go similarity index 100% rename from cmd/slackdump/internal/ui/cfgui/updaters/filepick_new.go rename to cmd/slackdump/internal/ui/updaters/filepick_new.go diff --git a/cmd/slackdump/internal/ui/cfgui/updaters/string.go b/cmd/slackdump/internal/ui/updaters/string.go similarity index 81% rename from cmd/slackdump/internal/ui/cfgui/updaters/string.go rename to cmd/slackdump/internal/ui/updaters/string.go index bc95e78f..39b80b86 100644 --- a/cmd/slackdump/internal/ui/cfgui/updaters/string.go +++ b/cmd/slackdump/internal/ui/updaters/string.go @@ -1,6 +1,8 @@ package updaters import ( + "context" + "runtime/trace" "strings" "github.com/charmbracelet/bubbles/textinput" @@ -10,11 +12,10 @@ import ( ) type StringModel struct { - Value *string - m textinput.Model - errStyle lipgloss.Style - borderStyle lipgloss.Style - finishing bool + Value *string + m textinput.Model + errStyle lipgloss.Style + finishing bool } // NewString creates a new stringUpdateModel @@ -45,6 +46,9 @@ func (m StringModel) Init() tea.Cmd { } func (m StringModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + _, task := trace.NewTask(context.Background(), "updaters.StringModel.Update") + defer task.End() + var cmd tea.Cmd var cmds []tea.Cmd @@ -55,6 +59,9 @@ func (m StringModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.finishing = true return m, OnClose case "enter": + if m.m.Err != nil { // if there is an error, don't allow to finish + return m, nil + } m.finishing = true *m.Value = m.m.Value() return m, OnClose @@ -68,6 +75,8 @@ func (m StringModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } func (m StringModel) View() string { + _, task := trace.NewTask(context.Background(), "updaters.StringModel.View") + defer task.End() if m.finishing { return "" } diff --git a/cmd/slackdump/internal/ui/updaters/updaters.go b/cmd/slackdump/internal/ui/updaters/updaters.go new file mode 100644 index 00000000..66f695a8 --- /dev/null +++ b/cmd/slackdump/internal/ui/updaters/updaters.go @@ -0,0 +1,3 @@ +// Package updaters contains the models that wrap the variable and provide the +// UI for changing their values. +package updaters diff --git a/go.mod b/go.mod index 463b1c0f..6fff1163 100644 --- a/go.mod +++ b/go.mod @@ -5,13 +5,13 @@ go 1.23 require ( github.com/MercuryEngineering/CookieMonster v0.0.0-20180304172713-1584578b3403 github.com/charmbracelet/bubbles v0.20.0 - github.com/charmbracelet/bubbletea v1.1.1 + github.com/charmbracelet/bubbletea v1.1.2 github.com/charmbracelet/huh v0.6.0 - github.com/charmbracelet/lipgloss v0.13.0 + github.com/charmbracelet/lipgloss v1.0.0 github.com/davecgh/go-spew v1.1.1 github.com/enescakir/emoji v1.0.0 - github.com/fatih/color v1.17.0 - github.com/go-chi/chi/v5 v5.0.12 + github.com/fatih/color v1.18.0 + github.com/go-chi/chi/v5 v5.1.0 github.com/go-playground/locales v0.14.1 github.com/go-playground/universal-translator v0.18.1 github.com/go-playground/validator/v10 v10.22.1 @@ -27,35 +27,33 @@ require ( github.com/rusq/osenv/v2 v2.0.1 github.com/rusq/rbubbles v0.0.2 github.com/rusq/slack v0.9.6-0.20240712095442-5a0e2e405a99 - github.com/rusq/slackauth v0.4.0 + github.com/rusq/slackauth v0.5.1 github.com/rusq/tracer v1.0.1 - github.com/schollz/progressbar/v3 v3.13.0 + github.com/schollz/progressbar/v3 v3.17.0 github.com/stretchr/testify v1.9.0 - github.com/yuin/goldmark v1.7.0 - github.com/yuin/goldmark-emoji v1.0.2 - go.uber.org/mock v0.4.0 + 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/exp v0.0.0-20241009180824-f66d83c29e7c golang.org/x/sync v0.8.0 golang.org/x/term v0.25.0 golang.org/x/text v0.19.0 golang.org/x/time v0.7.0 gopkg.in/yaml.v3 v3.0.1 - src.elv.sh v0.19.2 + src.elv.sh v0.21.0 ) require ( github.com/atotto/clipboard v0.1.4 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/catppuccin/go v0.2.0 // indirect - github.com/charmbracelet/x/ansi v0.3.2 // indirect - github.com/charmbracelet/x/exp/strings v0.0.0-20241017213443-f2394f742aee // indirect + 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/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 github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect - github.com/ethanefung/bubble-datepicker v0.1.0 // indirect github.com/gabriel-vasile/mimetype v1.4.6 // indirect github.com/go-jose/go-jose/v3 v3.0.3 // indirect github.com/go-stack/stack v1.8.1 // indirect @@ -81,6 +79,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/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 dcd0ed1a..649c2978 100644 --- a/go.sum +++ b/go.sum @@ -10,26 +10,28 @@ github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWp github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA= github.com/catppuccin/go v0.2.0 h1:ktBeIrIP42b/8FGiScP9sgrWOss3lw0Z5SktRoithGA= github.com/catppuccin/go v0.2.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc= -github.com/charmbracelet/bubbles v0.19.0 h1:gKZkKXPP6GlDk6EcfujDK19PCQqRjaJZQ7QRERx1UF0= -github.com/charmbracelet/bubbles v0.19.0/go.mod h1:WILteEqZ+krG5c3ntGEMeG99nCupcuIk7V0/zOP0tOA= github.com/charmbracelet/bubbles v0.20.0 h1:jSZu6qD8cRQ6k9OMfR1WlM+ruM8fkPWkHvQWD9LIutE= github.com/charmbracelet/bubbles v0.20.0/go.mod h1:39slydyswPy+uVOHZ5x/GjwVAFkCsV8IIVy+4MhzwwU= -github.com/charmbracelet/bubbletea v1.1.1 h1:KJ2/DnmpfqFtDNVTvYZ6zpPFL9iRCRr0qqKOCvppbPY= -github.com/charmbracelet/bubbletea v1.1.1/go.mod h1:9Ogk0HrdbHolIKHdjfFpyXJmiCzGwy+FesYkZr7hYU4= -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/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.6.0 h1:mZM8VvZGuE0hoDXq6XLxRtgfWyTI3b2jZNKh0xWmax8= github.com/charmbracelet/huh v0.6.0/go.mod h1:GGNKeWCeNzKpEOh/OJD8WBwTQjV3prFAtQPpLv+AVwU= -github.com/charmbracelet/lipgloss v0.13.0 h1:4X3PPeoWEDCMvzDvGmTajSyYPcZM4+y8sCA/SsA3cjw= -github.com/charmbracelet/lipgloss v0.13.0/go.mod h1:nw4zy0SBX/F/eAO1cWdcvy6qnkDUxr8Lw7dvFrAIbbY= -github.com/charmbracelet/x/ansi v0.3.2 h1:wsEwgAN+C9U06l9dCVMX0/L3x7ptvY1qmjMwyfE6USY= -github.com/charmbracelet/x/ansi v0.3.2/go.mod h1:dk73KoMTT5AX5BsX0KrqhsTqAnhZZoCBjs7dGWp4Ktw= +github.com/charmbracelet/lipgloss v0.13.1 h1:Oik/oqDTMVA01GetT4JdEC033dNzWoQHdWnHnQmXE2A= +github.com/charmbracelet/lipgloss v0.13.1/go.mod h1:zaYVJ2xKSKEnTEEbX6uAHabh2d975RJ+0yfkFpRBz5U= +github.com/charmbracelet/lipgloss v1.0.0 h1:O7VkGDvqEdGi93X+DeqsQ7PKHDgtQfF8j8/O2qFMQNg= +github.com/charmbracelet/lipgloss v1.0.0/go.mod h1:U5fy9Z+C38obMs+T+tJqst9VGzlOYGj4ri9reL3qUlo= +github.com/charmbracelet/x/ansi v0.4.0 h1:NqwHA4B23VwsDn4H3VcNX1W1tOmgnvY1NDx5tOXdnOU= +github.com/charmbracelet/x/ansi v0.4.0/go.mod h1:dk73KoMTT5AX5BsX0KrqhsTqAnhZZoCBjs7dGWp4Ktw= +github.com/charmbracelet/x/ansi v0.4.2 h1:0JM6Aj/g/KC154/gOP4vfxun0ff6itogDYk41kof+qk= +github.com/charmbracelet/x/ansi v0.4.2/go.mod h1:dk73KoMTT5AX5BsX0KrqhsTqAnhZZoCBjs7dGWp4Ktw= github.com/charmbracelet/x/exp/golden v0.0.0-20240815200342-61de596daa2b h1:MnAMdlwSltxJyULnrYbkZpp4k58Co7Tah3ciKhSNo0Q= github.com/charmbracelet/x/exp/golden v0.0.0-20240815200342-61de596daa2b/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= -github.com/charmbracelet/x/exp/strings v0.0.0-20240823163159-c7af7c9754f2 h1:MKJO91Pn7Bhq6H9pnvGfoLk3n3Tg8CZaMoc9dgJ2Q9g= -github.com/charmbracelet/x/exp/strings v0.0.0-20240823163159-c7af7c9754f2/go.mod h1:pBhA0ybfXv6hDjQUZ7hk1lVxBiUbupdw5R31yPUViVQ= github.com/charmbracelet/x/exp/strings v0.0.0-20241017213443-f2394f742aee h1:/tPkdvFmQ+5LOvHYMiVbqjW5JQdehwRHPFfzYClDU18= github.com/charmbracelet/x/exp/strings v0.0.0-20241017213443-f2394f742aee/go.mod h1:pBhA0ybfXv6hDjQUZ7hk1lVxBiUbupdw5R31yPUViVQ= +github.com/charmbracelet/x/exp/strings v0.0.0-20241025155609-902b1d1de0be h1:iNAmt6rYy0dDis7zrsD2thCIVMc/EFNmuqRQ/AZ0VUQ= +github.com/charmbracelet/x/exp/strings v0.0.0-20241025155609-902b1d1de0be/go.mod h1:pBhA0ybfXv6hDjQUZ7hk1lVxBiUbupdw5R31yPUViVQ= +github.com/charmbracelet/x/exp/strings v0.0.0-20241101155414-3df16cb7eefd h1:rRLv6mV/2l/bWmEEJkT2IqmemkW3/k2tUX+qw6U5GhM= +github.com/charmbracelet/x/exp/strings v0.0.0-20241101155414-3df16cb7eefd/go.mod h1:pBhA0ybfXv6hDjQUZ7hk1lVxBiUbupdw5R31yPUViVQ= github.com/charmbracelet/x/term v0.2.0 h1:cNB9Ot9q8I711MyZ7myUR5HFWL/lc3OpU8jZ4hwm0x0= github.com/charmbracelet/x/term v0.2.0/go.mod h1:GVxgxAbjUrmpvIINHIQnJJKpMlHiZ4cktEQCN6GWyF0= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= @@ -46,16 +48,16 @@ github.com/enescakir/emoji v1.0.0 h1:W+HsNql8swfCQFtioDGDHCHri8nudlK1n5p2rHCJoog github.com/enescakir/emoji v1.0.0/go.mod h1:Bt1EKuLnKDTYpLALApstIkAjdDrS/8IAgTkKp+WKFD0= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= -github.com/ethanefung/bubble-datepicker v0.1.0 h1:dOD6msw3cWZv8O8fvHIPwFWIldtfWT6AfiSsVvZgWWo= -github.com/ethanefung/bubble-datepicker v0.1.0/go.mod h1:8nxOYB9Oqays5U0JHKcIsbT7ZP/TwuJz8Uju9n5ueVU= github.com/fatih/color v1.17.0 h1:GlRw1BRJxkpqUCBKzKOw098ed57fEsKeNjpTe3cSjK4= github.com/fatih/color v1.17.0/go.mod h1:YZ7TlrGPkiz6ku9fK3TLD/pl3CpsiFyu8N92HLgmosI= -github.com/gabriel-vasile/mimetype v1.4.5 h1:J7wGKdGu33ocBOhGy0z653k/lFKLFDPJMG8Gql0kxn4= -github.com/gabriel-vasile/mimetype v1.4.5/go.mod h1:ibHel+/kbxn9x2407k1izTA1S81ku1z/DlgOW2QE0M4= +github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= +github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= github.com/gabriel-vasile/mimetype v1.4.6 h1:3+PzJTKLkvgjeTbts6msPJt4DixhT4YtFNf1gtGe3zc= github.com/gabriel-vasile/mimetype v1.4.6/go.mod h1:JX1qVKqZd40hUPpAfiNTe0Sne7hdfKSbOqqmkq8GCXc= github.com/go-chi/chi/v5 v5.0.12 h1:9euLV5sTrTNTRUU9POmDUvfxyj6LAABLUcEWO+JJb4s= github.com/go-chi/chi/v5 v5.0.12/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8= +github.com/go-chi/chi/v5 v5.1.0 h1:acVI1TYaD+hhedDJ3r54HyA6sExp3HfXq7QWEEY/xMw= +github.com/go-chi/chi/v5 v5.1.0/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8= github.com/go-jose/go-jose/v3 v3.0.3 h1:fFKWeig/irsp7XD2zBxvnmA/XaRWp5V3CBsZXJF7G7k= github.com/go-jose/go-jose/v3 v3.0.3/go.mod h1:5b+7YgP7ZICgJDBdfjZaIt+H/9L9T/YQrVfLAMboGkQ= github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= @@ -64,8 +66,6 @@ github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/o github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= -github.com/go-playground/validator/v10 v10.22.0 h1:k6HsTZ0sTnROkhS//R0O+55JgM8C4Bx7ia+JlgcnOao= -github.com/go-playground/validator/v10 v10.22.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM= github.com/go-playground/validator/v10 v10.22.1 h1:40JcKH+bBNGFczGuoBYgX4I6m/i27HYW8P9FDk5PbgA= github.com/go-playground/validator/v10 v10.22.1/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM= github.com/go-rod/rod v0.116.2 h1:A5t2Ky2A+5eD/ZJQr1EfsQSe5rms5Xof/qj296e+ZqA= @@ -119,8 +119,6 @@ github.com/muesli/termenv v0.15.3-0.20240618155329-98d742f6907a h1:2MaM6YC3mGu54 github.com/muesli/termenv v0.15.3-0.20240618155329-98d742f6907a/go.mod h1:hxSnBBYLK21Vtq/PHd0S2FYCxBXzBua8ov5s1RobyRQ= github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ= github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU= -github.com/playwright-community/playwright-go v0.4501.1 h1:kz8SIfR6nEI8blk77nTVD0K5/i37QP5rY/o8a1fG+4c= -github.com/playwright-community/playwright-go v0.4501.1/go.mod h1:bpArn5TqNzmP0jroCgw4poSOG9gSeQg490iLqWAaa7w= github.com/playwright-community/playwright-go v0.4702.0 h1:3CwNpk4RoA42tyhmlgPDMxYEYtMydaeEqMYiW0RNlSY= github.com/playwright-community/playwright-go v0.4702.0/go.mod h1:bpArn5TqNzmP0jroCgw4poSOG9gSeQg490iLqWAaa7w= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= @@ -149,10 +147,16 @@ github.com/rusq/slack v0.9.6-0.20240712095442-5a0e2e405a99 h1:dqEcNs9hMc2PiMwhw8 github.com/rusq/slack v0.9.6-0.20240712095442-5a0e2e405a99/go.mod h1:9O0zQAFN6W47z4KpTQbe6vOHOzBO76vMg1+gthPwaTI= github.com/rusq/slackauth v0.4.0 h1:hNOEWw6Tji24MRJ8rsepgb4w47v0wPGxjhGpvkQJQJU= github.com/rusq/slackauth v0.4.0/go.mod h1:wAtNCbeKH0pnaZnqJjG5RKY3e5BF9F2L/YTzhOjBIb0= +github.com/rusq/slackauth v0.5.0 h1:CvEuGTfH6/vF+yh1hlfsPVao97fL30X2ZJmHPu181C4= +github.com/rusq/slackauth v0.5.0/go.mod h1:wAtNCbeKH0pnaZnqJjG5RKY3e5BF9F2L/YTzhOjBIb0= +github.com/rusq/slackauth v0.5.1 h1:l+Gj96kYzHmljMYglRv76kgzuOJr/QbXDDA8JHyN71Q= +github.com/rusq/slackauth v0.5.1/go.mod h1:wAtNCbeKH0pnaZnqJjG5RKY3e5BF9F2L/YTzhOjBIb0= github.com/rusq/tracer v1.0.1 h1:5u4PCV8NGO97VuAINQA4gOVRkPoqHimLE2jpezRVNMU= github.com/rusq/tracer v1.0.1/go.mod h1:Rqu48C3/K8bA5NPmF20Hft73v431MQIdM+Co+113pME= github.com/schollz/progressbar/v3 v3.13.0 h1:9TeeWRcjW2qd05I8Kf9knPkW4vLM/hYoa6z9ABvxje8= github.com/schollz/progressbar/v3 v3.13.0/go.mod h1:ZBYnSuLAX2LU8P8UiKN/KgF2DY58AJC8yfVYLPC8Ly4= +github.com/schollz/progressbar/v3 v3.17.0 h1:Fv+vG6O6jnJwdjCelvfyYO7sF2jaUGQVmdH4CxcZdsQ= +github.com/schollz/progressbar/v3 v3.17.0/go.mod h1:5H4fLgifX+KeQCsEJnZTOepgZLe1jFF1lpPXb68IJTA= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= @@ -177,10 +181,17 @@ github.com/yuin/goldmark v1.3.7/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1 github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= github.com/yuin/goldmark v1.7.0 h1:EfOIvIMZIzHdB/R/zVrikYLPPwJlfMcNczJFMs1m6sA= github.com/yuin/goldmark v1.7.0/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E= +github.com/yuin/goldmark v1.7.1/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E= +github.com/yuin/goldmark v1.7.8 h1:iERMLn0/QJeHFhxSt3p6PeN9mGnvIKSpG9YYorDMnic= +github.com/yuin/goldmark v1.7.8/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E= github.com/yuin/goldmark-emoji v1.0.2 h1:c/RgTShNgHTtc6xdz2KKI74jJr6rWi7FPgnP9GAsO5s= github.com/yuin/goldmark-emoji v1.0.2/go.mod h1:RhP/RWpexdp+KHs7ghKnifRoIs/Bq4nDS7tRbCkOwKY= +github.com/yuin/goldmark-emoji v1.0.4 h1:vCwMkPZSNefSUnOW2ZKRUjBSD5Ok3W78IXhGxxAEF90= +github.com/yuin/goldmark-emoji v1.0.4/go.mod h1:tTkZEbwu5wkPmgTcitqddVxY9osFZiavD+r4AzQrh1U= go.uber.org/mock v0.4.0 h1:VcM4ZOtdbR4f6VXfiOpwpVJDL6lCReaZ6mw31wqh7KU= go.uber.org/mock v0.4.0/go.mod h1:a6FSlNadKUHUa9IP5Vyt1zh4fC7uAwxMutEAscFbkZc= +go.uber.org/mock v0.5.0 h1:KAMbZvZPyBPWgD14IrIQ38QCyjwpvVVV6K/bHl1IwQU= +go.uber.org/mock v0.5.0/go.mod h1:ge71pBPLYDk7QIi1LupWxdAykm7KIEFchiOqd6z7qMM= go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= @@ -188,8 +199,6 @@ golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5y golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= golang.org/x/crypto v0.28.0 h1:GBDwsMXVQi34v5CCYUm2jkJvu4cbtru2U4TN2PSyQnw= golang.org/x/crypto v0.28.0/go.mod h1:rmgy+3RHxRZMyY0jjAJShp2zgEdOqj2AO7U0pYmeQ7U= -golang.org/x/exp v0.0.0-20240823005443-9b4947da3948 h1:kx6Ds3MlpiUHKj7syVnbp57++8WpuKPcR5yjLBjvLEA= -golang.org/x/exp v0.0.0-20240823005443-9b4947da3948/go.mod h1:akd2r19cwCdwSwWeIdzYQGa/EZZyqcOdwWiwj5L5eKQ= golang.org/x/exp v0.0.0-20241009180824-f66d83c29e7c h1:7dEasQXItcW1xKJ2+gg5VOiBnqWrJc+rq0DPKyvvdbY= golang.org/x/exp v0.0.0-20241009180824-f66d83c29e7c/go.mod h1:NQtJDoLvd6faHhE7m4T/1IY708gDefGGjR/iUW8yQQ8= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= @@ -237,8 +246,6 @@ golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.19.0 h1:kTxAhCbGbxhK0IwgSKiMO5awPoDQ0RpfiVYBfK860YM= golang.org/x/text v0.19.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY= -golang.org/x/time v0.6.0 h1:eTDhh4ZXt5Qf0augr54TN6suAUudPcawVZeIAPU7D4U= -golang.org/x/time v0.6.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= golang.org/x/time v0.7.0 h1:ntUhktv3OPE6TgYxXWv9vKvUSJyIFJlyohwbkEwPrKQ= golang.org/x/time v0.7.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= @@ -255,3 +262,5 @@ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= src.elv.sh v0.19.2 h1:VRjWiz2oFnJS11Ge9z/pt9nQ6DhrDbsejKxxBM+1xIs= src.elv.sh v0.19.2/go.mod h1:5kwyA5V8vpnWkXOgpiOoAtim7BX3pG/eFGRn6sz5bK8= +src.elv.sh v0.21.0 h1:DXtdzaaGoc+VctRnDmeS8Xv1bknbRWTRMDZf2DI3sGI= +src.elv.sh v0.21.0/go.mod h1:SCiBbiD5+gVCBPfY17ixCBrce+7jAMFHRz2eh90aCig= diff --git a/internal/fixtures/fixtures.go b/internal/fixtures/fixtures.go index 0f7c643a..33a5e6df 100644 --- a/internal/fixtures/fixtures.go +++ b/internal/fixtures/fixtures.go @@ -11,7 +11,10 @@ import ( ) const ( + TestAppToken = "xoxa-888888888888-888888888888-8888888888888-fffffffffffffffa915fe069d70a8ad81743b0ec4ee9c81540af43f5e143264b" + TestBotToken = "xoxb-888888888888-888888888888-8888888888888-fffffffffffffffa915fe069d70a8ad81743b0ec4ee9c81540af43f5e143264b" TestClientToken = "xoxc-888888888888-888888888888-8888888888888-fffffffffffffffa915fe069d70a8ad81743b0ec4ee9c81540af43f5e143264b" + TestExportToken = "xoxe-888888888888-888888888888-8888888888888-fffffffffffffffa915fe069d70a8ad81743b0ec4ee9c81540af43f5e143264b" TestPersonalToken = "xoxp-777777777777-888888888888-8888888888888-fffffffffffffffa915fe069d70a8ad81743b0ec4ee9c81540af43f5e143264b" ) diff --git a/internal/structures/entity_list.go b/internal/structures/entity_list.go index 10454716..e4595955 100644 --- a/internal/structures/entity_list.go +++ b/internal/structures/entity_list.go @@ -25,6 +25,7 @@ const ( var ( ErrMaxFileSize = errors.New("maximum file size exceeded") + ErrEmptyList = errors.New("empty list") ) // EntityList is an Inclusion/Exclusion list @@ -55,6 +56,25 @@ func NewEntityList(entities []string) (*EntityList, error) { return &el, nil } +// NewEntityListFromString creates an EntityList from a space-separated list of +// entities. +func NewEntityListFromString(s string) (*EntityList, error) { + if len(s) == 0 { + return nil, ErrEmptyList + } + ee := strings.Split(s, " ") + if len(ee) == 0 { + return nil, ErrEmptyList + } + return NewEntityList(ee) +} + +// ValidateEntityList validates a space-separated list of entities. +func ValidateEntityList(s string) error { + _, err := NewEntityListFromString(s) + return err +} + // LoadEntityList creates an EntityList from a slice of IDs or URLs (entites). func LoadEntityList(filename string) (*EntityList, error) { f, err := os.Open(filename) diff --git a/internal/structures/entity_list_test.go b/internal/structures/entity_list_test.go index 475f28a3..e302465a 100644 --- a/internal/structures/entity_list_test.go +++ b/internal/structures/entity_list_test.go @@ -466,3 +466,136 @@ func Test_buildEntityIndex(t *testing.T) { }) } } + +func TestValidateEntityList(t *testing.T) { + type args struct { + s string + } + tests := []struct { + name string + args args + wantErr bool + }{ + { + "ok", + args{"C123 C234 ^C345 C456"}, + false, + }, + { + "empty", + args{""}, + true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if err := ValidateEntityList(tt.args.s); (err != nil) != tt.wantErr { + t.Errorf("ValidateEntityList() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} + +func TestNewEntityListFromString(t *testing.T) { + type args struct { + s string + } + tests := []struct { + name string + args args + want *EntityList + wantErr bool + }{ + { + "ok", + args{"C123 C234 ^C345 C456"}, + &EntityList{ + Include: []string{"C123", "C234", "C456"}, + Exclude: []string{"C345"}, + }, + false, + }, + { + "empty", + args{""}, + nil, + true, + }, + { + "only includes", + args{"one two three"}, + &EntityList{ + Include: []string{"one", "three", "two"}, + }, + false, + }, + { + "only excludes", + args{"^one ^two ^three"}, + &EntityList{ + Exclude: []string{"one", "three", "two"}, + }, + false, + }, + { + "mixed", + args{"one ^two three"}, + &EntityList{ + Include: []string{"one", "three"}, + Exclude: []string{"two"}, + }, + false, + }, + { + "same element included and excluded", + args{"one ^two three two"}, + &EntityList{ + Include: []string{"one", "three"}, + Exclude: []string{"two"}, + }, + false, + }, + { + "duplicate element", + args{"one ^two three one"}, + &EntityList{ + Include: []string{"one", "three"}, + Exclude: []string{"two"}, + }, + false, + }, + { + "empty element", + args{"one ^two three four ^"}, + &EntityList{ + Include: []string{"four", "one", "three"}, + Exclude: []string{"two"}, + }, + false, + }, + { + "everything is empty", + args{""}, + nil, + true, + }, + { + "nil", + args{""}, + nil, + true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := NewEntityListFromString(tt.args.s) + if (err != nil) != tt.wantErr { + t.Errorf("NewEntityListFromString() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("NewEntityListFromString() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/internal/viewer/handlers.go b/internal/viewer/handlers.go index 4ccca27c..8d52dc55 100644 --- a/internal/viewer/handlers.go +++ b/internal/viewer/handlers.go @@ -6,11 +6,11 @@ import ( "net/http" "os" "path/filepath" + "slices" "github.com/davecgh/go-spew/spew" "github.com/rusq/slack" "github.com/rusq/slackdump/v3/internal/fasttime" - "golang.org/x/exp/slices" ) func (v *Viewer) indexHandler(w http.ResponseWriter, r *http.Request) {