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