init.go

  1// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
  2//
  3// SPDX-License-Identifier: AGPL-3.0-or-later
  4
  5// Package init provides the interactive setup wizard for lune.
  6package init
  7
  8import (
  9	"errors"
 10	"fmt"
 11
 12	"github.com/charmbracelet/huh"
 13	"github.com/spf13/cobra"
 14
 15	"git.secluded.site/lune/internal/config"
 16	"git.secluded.site/lune/internal/ui"
 17)
 18
 19// errQuit signals that the user wants to exit the wizard entirely.
 20var errQuit = errors.New("user quit")
 21
 22// errReset signals that the user reset configuration and should run fresh setup.
 23var errReset = errors.New("configuration reset")
 24
 25// wizardNav represents navigation direction in the wizard.
 26type wizardNav int
 27
 28const (
 29	navNext wizardNav = iota
 30	navBack
 31	navQuit
 32)
 33
 34// Cmd is the init command for interactive setup.
 35var Cmd = &cobra.Command{
 36	Use:   "init",
 37	Short: "Interactive setup wizard",
 38	Long: `Configure lune interactively.
 39
 40This command will guide you through:
 41  - Adding areas, goals, notebooks, and habits from Lunatask
 42  - Setting default area and notebook
 43  - Configuring and verifying your access token`,
 44	RunE: runInit,
 45}
 46
 47func runInit(cmd *cobra.Command, _ []string) error {
 48	cfg, err := config.Load()
 49	if errors.Is(err, config.ErrNotFound) {
 50		cfg = &config.Config{}
 51
 52		err = runFreshSetup(cmd, cfg)
 53		if errors.Is(err, errQuit) {
 54			fmt.Fprintln(cmd.OutOrStdout(), ui.Warning.Render("Setup cancelled."))
 55
 56			return nil
 57		}
 58
 59		return err
 60	}
 61
 62	if err != nil {
 63		return fmt.Errorf("loading config: %w", err)
 64	}
 65
 66	err = runReconfigure(cmd, cfg)
 67	if errors.Is(err, errQuit) {
 68		return nil
 69	}
 70
 71	if errors.Is(err, errReset) {
 72		return runFreshSetup(cmd, cfg)
 73	}
 74
 75	return err
 76}
 77
 78// wizardStep is a function that runs a wizard step and returns navigation direction.
 79type wizardStep func() wizardNav
 80
 81func runFreshSetup(cmd *cobra.Command, cfg *config.Config) error {
 82	printWelcome(cmd)
 83
 84	steps := []wizardStep{
 85		func() wizardNav { return runUIPrefsStep(cfg) },
 86		func() wizardNav { return runAreasStep(cfg) },
 87		func() wizardNav { return runNotebooksStep(cfg) },
 88		func() wizardNav { return runHabitsStep(cfg) },
 89		func() wizardNav { return runDefaultsStep(cfg) },
 90		func() wizardNav { return runAccessTokenStep(cmd) },
 91	}
 92
 93	step := 0
 94	for step < len(steps) {
 95		nav := steps[step]()
 96
 97		switch nav {
 98		case navNext:
 99			step++
100		case navBack:
101			if step > 0 {
102				step--
103			}
104		case navQuit:
105			return errQuit
106		}
107	}
108
109	return saveWithSummary(cmd, cfg)
110}
111
112func printWelcome(cmd *cobra.Command) {
113	out := cmd.OutOrStdout()
114	fmt.Fprintln(out, ui.Bold.Render("Welcome to lune!"))
115	fmt.Fprintln(out)
116	fmt.Fprintln(out, "This wizard will help you configure lune for use with Lunatask.")
117	fmt.Fprintln(out)
118	fmt.Fprintln(out, "Since Lunatask is end-to-end encrypted, lune can't fetch your")
119	fmt.Fprintln(out, "areas, goals, notebooks, or habits automatically. You'll need to")
120	fmt.Fprintln(out, "copy IDs from the Lunatask app.")
121	fmt.Fprintln(out)
122	fmt.Fprintln(out, ui.Bold.Render("Where to find IDs:"))
123	fmt.Fprintln(out, "  Open any item's settings modal → click 'Copy [Item] ID' (bottom left)")
124	fmt.Fprintln(out)
125	fmt.Fprintln(out, "You can run 'lune init' again anytime to add or modify config.")
126	fmt.Fprintln(out)
127}
128
129func runReconfigure(cmd *cobra.Command, cfg *config.Config) error {
130	fmt.Fprintln(cmd.OutOrStdout(), ui.Bold.Render("lune configuration"))
131	fmt.Fprintln(cmd.OutOrStdout())
132
133	handlers := map[string]func() error{
134		"areas":     func() error { return manageAreas(cfg) },
135		"notebooks": func() error { return manageNotebooks(cfg) },
136		"habits":    func() error { return manageHabits(cfg) },
137		"defaults":  func() error { return configureDefaults(cfg) },
138		"ui":        func() error { return configureUIPrefs(cfg) },
139		"apikey":    func() error { return configureAccessToken(cmd) },
140		"reset":     func() error { return resetConfig(cmd, cfg) },
141	}
142
143	for {
144		var choice string
145
146		err := huh.NewSelect[string]().
147			Title("What would you like to configure?").
148			Options(
149				huh.NewOption("Manage areas & goals", "areas"),
150				huh.NewOption("Manage notebooks", "notebooks"),
151				huh.NewOption("Manage habits", "habits"),
152				huh.NewOption("Set defaults", "defaults"),
153				huh.NewOption("UI preferences", "ui"),
154				huh.NewOption("Access token", "apikey"),
155				huh.NewOption("Reset all configuration", "reset"),
156				huh.NewOption("Done", choiceDone),
157			).
158			Value(&choice).
159			Run()
160		if err != nil {
161			if errors.Is(err, huh.ErrUserAborted) {
162				return errQuit
163			}
164
165			return err
166		}
167
168		if choice == choiceDone {
169			return saveWithSummary(cmd, cfg)
170		}
171
172		if handler, ok := handlers[choice]; ok {
173			if err := handler(); err != nil {
174				return err
175			}
176		}
177	}
178}
179
180func saveWithSummary(cmd *cobra.Command, cfg *config.Config) error {
181	path, err := config.Path()
182	if err != nil {
183		return err
184	}
185
186	goalCount := 0
187	for _, area := range cfg.Areas {
188		goalCount += len(area.Goals)
189	}
190
191	out := cmd.OutOrStdout()
192	fmt.Fprintln(out)
193	fmt.Fprintln(out, ui.Bold.Render("Configuration summary:"))
194	fmt.Fprintf(out, "  Areas:     %d (%d goals)\n", len(cfg.Areas), goalCount)
195	fmt.Fprintf(out, "  Notebooks: %d\n", len(cfg.Notebooks))
196	fmt.Fprintf(out, "  Habits:    %d\n", len(cfg.Habits))
197
198	if cfg.Defaults.Area != "" {
199		fmt.Fprintf(out, "  Default area: %s\n", cfg.Defaults.Area)
200	}
201
202	if cfg.Defaults.Notebook != "" {
203		fmt.Fprintf(out, "  Default notebook: %s\n", cfg.Defaults.Notebook)
204	}
205
206	fmt.Fprintf(out, "  Color: %s\n", cfg.UI.Color)
207	fmt.Fprintln(out)
208
209	var save bool
210
211	err = huh.NewConfirm().
212		Title("Save configuration?").
213		Affirmative("Save").
214		Negative("Discard").
215		Value(&save).
216		Run()
217	if err != nil {
218		if errors.Is(err, huh.ErrUserAborted) {
219			fmt.Fprintln(out, ui.Warning.Render("Changes discarded."))
220
221			return errQuit
222		}
223
224		return err
225	}
226
227	if save {
228		if err := cfg.Save(); err != nil {
229			return err
230		}
231
232		fmt.Fprintln(out, ui.Success.Render("Config saved to "+path))
233	} else {
234		fmt.Fprintln(out, ui.Warning.Render("Changes discarded."))
235	}
236
237	return nil
238}