github.com/pluswing/datasync@v1.1.1-0.20240218052257-9077f6fc4ae3/cmd/init.go (about)

     1  package cmd
     2  
     3  import (
     4  	"errors"
     5  	"fmt"
     6  	"os"
     7  	"path/filepath"
     8  	"strconv"
     9  	"strings"
    10  	"time"
    11  
    12  	"github.com/charmbracelet/bubbles/filepicker"
    13  	"github.com/charmbracelet/bubbles/textinput"
    14  	tea "github.com/charmbracelet/bubbletea"
    15  	"github.com/charmbracelet/lipgloss"
    16  	"github.com/go-yaml/yaml"
    17  	"github.com/pluswing/datasync/data"
    18  	"github.com/pluswing/datasync/file"
    19  	"github.com/spf13/cobra"
    20  )
    21  
    22  var initCmd = &cobra.Command{
    23  	Use:   "init",
    24  	Short: "generate datasync.yaml",
    25  	Long:  ``,
    26  	Run: func(cmd *cobra.Command, args []string) {
    27  
    28  		_, err := file.FindCurrentDir()
    29  		if err == nil {
    30  			fmt.Println("already datasync.yaml file.")
    31  			return
    32  		}
    33  
    34  		if _, err := tea.NewProgram(initialModel()).Run(); err != nil {
    35  			fmt.Printf("could not start program: %s\n", err)
    36  			os.Exit(1)
    37  		}
    38  	},
    39  }
    40  
    41  func init() {
    42  	rootCmd.AddCommand(initCmd)
    43  }
    44  
    45  var (
    46  	focusedStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("205"))
    47  	cursorStyle  = focusedStyle.Copy()
    48  	noStyle      = lipgloss.NewStyle()
    49  )
    50  
    51  type ScreenType int
    52  
    53  const (
    54  	SelectTargetKind ScreenType = iota
    55  	InputMysql
    56  	InputFile
    57  	ConfirmAddTarget
    58  	ConfirmSetupRemote
    59  	// SelectRemoteKind
    60  	InputGcs
    61  )
    62  
    63  type clearErrorMsg struct{}
    64  
    65  func clearErrorAfter(t time.Duration) tea.Cmd {
    66  	return tea.Tick(t, func(_ time.Time) tea.Msg {
    67  		return clearErrorMsg{}
    68  	})
    69  }
    70  
    71  type model struct {
    72  	screenType ScreenType
    73  
    74  	// 入力共有
    75  	focusIndex int
    76  	inputs     []textinput.Model
    77  
    78  	// ファイル選択
    79  	filepicker filepicker.Model
    80  	err        error
    81  
    82  	targets []data.TargetType
    83  	storage data.StorageType
    84  }
    85  
    86  func initialModel() model {
    87  	m := model{
    88  		screenType: SelectTargetKind,
    89  		targets:    make([]data.TargetType, 0),
    90  	}
    91  	return m
    92  }
    93  
    94  func (m model) Init() tea.Cmd {
    95  	return textinput.Blink
    96  }
    97  
    98  func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
    99  	switch msg := msg.(type) {
   100  	case tea.KeyMsg:
   101  		switch msg.String() {
   102  		case "ctrl+c", "esc":
   103  			return m, tea.Quit
   104  		}
   105  	}
   106  
   107  	switch m.screenType {
   108  	case SelectTargetKind:
   109  		return updateSelectTargetKind(m, msg)
   110  	case InputMysql:
   111  		return updateInputMysql(m, msg)
   112  	case InputFile:
   113  		return updateInputFile(m, msg)
   114  	case ConfirmAddTarget:
   115  		return updateConfirmAddTarget(m, msg)
   116  	case ConfirmSetupRemote:
   117  		return updateConfirmSetupRemote(m, msg)
   118  	case InputGcs:
   119  		return updateInputGcs(m, msg)
   120  	default:
   121  		panic("invalid screenType")
   122  	}
   123  }
   124  
   125  func writeConfig(m model) error {
   126  	cwd, err := os.Getwd()
   127  	if err != nil {
   128  		return err
   129  	}
   130  	s := data.SettingType{
   131  		Targets: m.targets,
   132  		Storage: m.storage,
   133  	}
   134  
   135  	b, err := yaml.Marshal(s)
   136  	if err != nil {
   137  		return err
   138  	}
   139  	err = os.WriteFile(filepath.Join(cwd, "datasync.yaml"), b, os.ModePerm)
   140  	if err != nil {
   141  		return err
   142  	}
   143  	return nil
   144  }
   145  
   146  func finish() {
   147  	fmt.Println("created datasync.yaml.")
   148  	os.Exit(0)
   149  }
   150  
   151  func updateConfirmAddTarget(m model, msg tea.Msg) (tea.Model, tea.Cmd) {
   152  	switch msg := msg.(type) {
   153  	case tea.KeyMsg:
   154  		switch msg.String() {
   155  		case "up":
   156  			if m.focusIndex == 1 {
   157  				m.focusIndex = 0
   158  			}
   159  		case "down":
   160  			if m.focusIndex == 0 {
   161  				m.focusIndex = 1
   162  			}
   163  		case "enter":
   164  			if m.focusIndex == 0 {
   165  				m.screenType = SelectTargetKind
   166  				m.focusIndex = 0
   167  			} else {
   168  				m.screenType = ConfirmSetupRemote
   169  				m.focusIndex = 0
   170  			}
   171  		}
   172  	}
   173  	cmd := m.updateInputs(msg)
   174  	return m, cmd
   175  }
   176  
   177  func updateSelectTargetKind(m model, msg tea.Msg) (tea.Model, tea.Cmd) {
   178  	switch msg := msg.(type) {
   179  	case tea.KeyMsg:
   180  		switch msg.String() {
   181  		case "up":
   182  			if m.focusIndex == 1 {
   183  				m.focusIndex = 0
   184  			}
   185  		case "down":
   186  			if m.focusIndex == 0 {
   187  				m.focusIndex = 1
   188  			}
   189  		case "enter":
   190  			if m.focusIndex == 0 {
   191  				m.screenType = InputMysql
   192  				m.focusIndex = 0
   193  				m.inputs = makeMysqlInputs()
   194  			} else {
   195  				m.screenType = InputFile
   196  				m.focusIndex = 0
   197  				m.inputs = make([]textinput.Model, 0)
   198  				fp := filepicker.New()
   199  				fp.DirAllowed = true
   200  				fp.CurrentDirectory, _ = os.Getwd()
   201  				fp.Height = 10
   202  				fp.ShowHidden = true
   203  				cmd := fp.Init()
   204  				m.filepicker = fp
   205  				return m, cmd
   206  			}
   207  		}
   208  	}
   209  	cmd := m.updateInputs(msg)
   210  	return m, cmd
   211  }
   212  
   213  func updateInputMysql(m model, msg tea.Msg) (tea.Model, tea.Cmd) {
   214  	switch msg := msg.(type) {
   215  	case tea.KeyMsg:
   216  		switch msg.String() {
   217  		case "enter", "up", "down":
   218  			s := msg.String()
   219  			if s == "up" {
   220  				m.focusIndex -= 1
   221  				if m.focusIndex < 0 {
   222  					m.focusIndex = 0
   223  				}
   224  			} else if s == "down" {
   225  				m.focusIndex += 1
   226  				if m.focusIndex >= len(m.inputs) {
   227  					m.focusIndex = len(m.inputs) - 1
   228  				}
   229  			} else if s == "enter" {
   230  				if m.focusIndex == len(m.inputs)-1 {
   231  					port, err := strconv.Atoi(m.inputs[1].Value())
   232  					if err != nil {
   233  						fmt.Println("invalut port")
   234  					}
   235  					var t = data.TargetType{
   236  						Kind: "mysql",
   237  						Config: data.TargetMysqlType{
   238  							Host:     m.inputs[0].Value(),
   239  							Port:     port,
   240  							User:     m.inputs[2].Value(),
   241  							Password: m.inputs[3].Value(),
   242  							Database: m.inputs[4].Value(),
   243  						},
   244  					}
   245  					m.targets = append(m.targets, t)
   246  					// 次のスクリーンに行く。
   247  					m.screenType = ConfirmAddTarget
   248  					m.focusIndex = 1
   249  					m.inputs = make([]textinput.Model, 0)
   250  				} else {
   251  					m.focusIndex += 1
   252  					if m.focusIndex >= len(m.inputs) {
   253  						m.focusIndex = len(m.inputs) - 1
   254  					}
   255  				}
   256  			}
   257  			return updateInputFocus(m)
   258  		}
   259  	}
   260  	cmd := m.updateInputs(msg)
   261  	return m, cmd
   262  }
   263  
   264  func updateInputFile(m model, msg tea.Msg) (tea.Model, tea.Cmd) {
   265  	switch msg.(type) {
   266  	case clearErrorMsg:
   267  		m.err = nil
   268  	}
   269  
   270  	// TODO CWDより上にはいけないように制御したい。
   271  	// => msgをここで取って、←と→の回数をカウントしておいて、うまいことやる
   272  
   273  	var cmd tea.Cmd
   274  	m.filepicker, cmd = m.filepicker.Update(msg)
   275  
   276  	if didSelect, path := m.filepicker.DidSelectFile(msg); didSelect {
   277  		cwd, _ := os.Getwd()
   278  		path = "." + strings.Replace(path, cwd, "", 1)
   279  		var t = data.TargetType{
   280  			Kind: "file",
   281  			Config: data.TargetFileType{
   282  				Path: path,
   283  			},
   284  		}
   285  		m.targets = append(m.targets, t)
   286  		m.screenType = ConfirmAddTarget
   287  		m.focusIndex = 1
   288  		m.inputs = make([]textinput.Model, 0)
   289  	}
   290  
   291  	if didSelect, path := m.filepicker.DidSelectDisabledFile(msg); didSelect {
   292  		m.err = errors.New(path + " is not valid.")
   293  		return m, tea.Batch(cmd, clearErrorAfter(2*time.Second))
   294  	}
   295  
   296  	return m, cmd
   297  }
   298  
   299  func updateConfirmSetupRemote(m model, msg tea.Msg) (tea.Model, tea.Cmd) {
   300  	switch msg := msg.(type) {
   301  	case tea.KeyMsg:
   302  		switch msg.String() {
   303  		case "up":
   304  			if m.focusIndex == 1 {
   305  				m.focusIndex = 0
   306  			}
   307  		case "down":
   308  			if m.focusIndex == 0 {
   309  				m.focusIndex = 1
   310  			}
   311  		case "enter":
   312  			if m.focusIndex == 0 {
   313  				m.screenType = InputGcs
   314  				m.focusIndex = 0
   315  				m.inputs = makeGcsInputs()
   316  			} else {
   317  				err := writeConfig(m)
   318  				cobra.CheckErr(err)
   319  				finish()
   320  			}
   321  		}
   322  	}
   323  	cmd := m.updateInputs(msg)
   324  	return m, cmd
   325  }
   326  
   327  func updateInputGcs(m model, msg tea.Msg) (tea.Model, tea.Cmd) {
   328  	switch msg := msg.(type) {
   329  	case tea.KeyMsg:
   330  		switch msg.String() {
   331  		case "enter", "up", "down":
   332  			s := msg.String()
   333  			if s == "up" {
   334  				m.focusIndex -= 1
   335  				if m.focusIndex < 0 {
   336  					m.focusIndex = 0
   337  				}
   338  			} else if s == "down" {
   339  				m.focusIndex += 1
   340  				if m.focusIndex >= len(m.inputs) {
   341  					m.focusIndex = len(m.inputs) - 1
   342  				}
   343  			} else if s == "enter" {
   344  				if m.focusIndex == len(m.inputs)-1 {
   345  					var s = data.StorageType{
   346  						Kind: "gcs",
   347  						Config: data.StorageGcsType{
   348  							Bucket: m.inputs[0].Value(),
   349  							Dir:    m.inputs[1].Value(),
   350  						},
   351  					}
   352  					m.storage = s
   353  					err := writeConfig(m)
   354  					cobra.CheckErr(err)
   355  					finish()
   356  				} else {
   357  					m.focusIndex += 1
   358  					if m.focusIndex >= len(m.inputs) {
   359  						m.focusIndex = len(m.inputs) - 1
   360  					}
   361  				}
   362  			}
   363  			return updateInputFocus(m)
   364  		}
   365  	}
   366  	cmd := m.updateInputs(msg)
   367  	return m, cmd
   368  }
   369  
   370  func updateInputFocus(m model) (tea.Model, tea.Cmd) {
   371  	cmds := make([]tea.Cmd, len(m.inputs))
   372  	for i := 0; i <= len(m.inputs)-1; i++ {
   373  		if i == m.focusIndex {
   374  			cmds[i] = m.inputs[i].Focus()
   375  			m.inputs[i].PromptStyle = focusedStyle
   376  			m.inputs[i].TextStyle = focusedStyle
   377  			continue
   378  		}
   379  		m.inputs[i].Blur()
   380  		m.inputs[i].PromptStyle = noStyle
   381  		m.inputs[i].TextStyle = noStyle
   382  	}
   383  	return m, tea.Batch(cmds...)
   384  }
   385  
   386  func makeMysqlInputs() []textinput.Model {
   387  	var inputs []textinput.Model
   388  	var t textinput.Model
   389  	inputs = make([]textinput.Model, 0)
   390  
   391  	t = textinput.New()
   392  	t.Cursor.Style = cursorStyle
   393  	t.Placeholder = "Hostname (default: localhost)"
   394  	t.Focus()
   395  	t.PromptStyle = focusedStyle
   396  	t.TextStyle = focusedStyle
   397  	inputs = append(inputs, t)
   398  
   399  	// TODO 数値のみにしたい
   400  	t = textinput.New()
   401  	t.Placeholder = "port (default: 3306)"
   402  	inputs = append(inputs, t)
   403  
   404  	t = textinput.New()
   405  	t.Placeholder = "user (default: root)"
   406  	inputs = append(inputs, t)
   407  
   408  	t = textinput.New()
   409  	t.Placeholder = "password (default: '')"
   410  	inputs = append(inputs, t)
   411  
   412  	t = textinput.New()
   413  	t.Placeholder = "database"
   414  	inputs = append(inputs, t)
   415  
   416  	return inputs
   417  }
   418  
   419  func makeGcsInputs() []textinput.Model {
   420  	var inputs []textinput.Model
   421  	var t textinput.Model
   422  	inputs = make([]textinput.Model, 0)
   423  
   424  	t = textinput.New()
   425  	t.Cursor.Style = cursorStyle
   426  	t.Placeholder = "Bucket"
   427  	t.Focus()
   428  	t.PromptStyle = focusedStyle
   429  	t.TextStyle = focusedStyle
   430  	inputs = append(inputs, t)
   431  
   432  	t = textinput.New()
   433  	t.Placeholder = "Path"
   434  	inputs = append(inputs, t)
   435  
   436  	return inputs
   437  }
   438  
   439  func (m *model) updateInputs(msg tea.Msg) tea.Cmd {
   440  	cmds := make([]tea.Cmd, len(m.inputs))
   441  	for i := range m.inputs {
   442  		m.inputs[i], cmds[i] = m.inputs[i].Update(msg)
   443  	}
   444  
   445  	return tea.Batch(cmds...)
   446  }
   447  
   448  func (m model) View() string {
   449  	var b strings.Builder
   450  	switch m.screenType {
   451  	case SelectTargetKind:
   452  		b.WriteString("? How kind of dump target? …\n")
   453  		viewSelect(&b, m.focusIndex, []string{"MySQL", "File(s)"})
   454  	case InputMysql:
   455  		b.WriteString("Input mysql setting …\n") // FIXME これだけ残る。なんとかする。
   456  		viewInputs(&b, m.inputs)
   457  	case InputFile:
   458  		viewFilePicker(&b, m.filepicker, m.err)
   459  	case ConfirmAddTarget:
   460  		b.WriteString("? Add dump target? …\n")
   461  		viewSelect(&b, m.focusIndex, []string{"Yes", "No"})
   462  	case ConfirmSetupRemote:
   463  		b.WriteString("? Setup remote server? …\n")
   464  		viewSelect(&b, m.focusIndex, []string{"Yes", "No"})
   465  	case InputGcs:
   466  		b.WriteString("Input GCS setting …\n")
   467  		viewInputs(&b, m.inputs)
   468  	}
   469  	return b.String()
   470  }
   471  
   472  func viewFilePicker(b *strings.Builder, filepicker filepicker.Model, err error) {
   473  	if err != nil {
   474  		b.WriteString(filepicker.Styles.DisabledFile.Render(err.Error()))
   475  	} else {
   476  		b.WriteString("Pick a file or directory:\n")
   477  		// TODO 使い方を出したい。
   478  		// b.WriteString("<- : Parent Directory, -> : Dig Directory, enter : select")
   479  	}
   480  	b.WriteString("\n\n" + filepicker.View() + "\n")
   481  }
   482  
   483  func viewSelect(b *strings.Builder, focusIndex int, texts []string) {
   484  	for i, t := range texts {
   485  		if focusIndex == i {
   486  			b.WriteString(focusedStyle.Render(fmt.Sprintf("❯ %s\n", t)))
   487  		} else {
   488  			b.WriteString(fmt.Sprintf("\r  %s\n", t))
   489  		}
   490  	}
   491  }
   492  
   493  func viewInputs(b *strings.Builder, inputs []textinput.Model) {
   494  	for i := range inputs {
   495  		b.WriteString(inputs[i].View())
   496  		if i < len(inputs)-1 {
   497  			b.WriteRune('\n')
   498  		}
   499  	}
   500  }
   501  
   502  /*
   503  ? How kind of dump target? …
   504  ❯ MySQL
   505    File(s)
   506  
   507  -- mysql
   508  ? MySQL server hostname / port / username / password / databasename
   509  >
   510  
   511  -- file
   512  ? Select directory or file
   513  > picker
   514  
   515  
   516  ? Add dump target?
   517    Yes
   518  ❯ No
   519  
   520  
   521  ? Setup remote server?
   522  ❯ Yes
   523    No
   524  
   525  ? Remote server type?
   526  ❯ Google Cloud Storage
   527    Amazon S3
   528  	Samba
   529  
   530  -- GCS
   531  ? GCS bucket / path
   532  >
   533  
   534  */