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 handlers := map[string]func() error{
135 "areas": func() error { return manageAreas(cfg) },
136 "notebooks": func() error { return manageNotebooks(cfg) },
137 "habits": func() error { return manageHabits(cfg) },
138 "defaults": func() error { return configureDefaults(cfg) },
139 "ui": func() error { return configureUIPrefs(cfg) },
140 "apikey": func() error { return configureAccessToken(cmd) },
141 "reset": func() error { return resetConfig(cmd, cfg) },
142 }
143
144 for {
145 var choice string
146
147 err := huh.NewSelect[string]().
148 Title("What would you like to configure?").
149 Options(
150 huh.NewOption("Manage areas & goals", "areas"),
151 huh.NewOption("Manage notebooks", "notebooks"),
152 huh.NewOption("Manage habits", "habits"),
153 huh.NewOption("Set defaults", "defaults"),
154 huh.NewOption("UI preferences", "ui"),
155 huh.NewOption("Access token", "apikey"),
156 huh.NewOption("Reset all configuration", "reset"),
157 huh.NewOption("Done", choiceDone),
158 ).
159 Value(&choice).
160 Run()
161 if err != nil {
162 if errors.Is(err, huh.ErrUserAborted) {
163 return errQuit
164 }
165
166 return err
167 }
168
169 if choice == choiceDone {
170 return saveWithSummary(cmd, cfg)
171 }
172
173 if handler, ok := handlers[choice]; ok {
174 if err := handler(); err != nil {
175 return err
176 }
177 }
178 }
179}
180
181func printConfigSummary(out io.Writer, cfg *config.Config) {
182 goalCount := 0
183 for _, area := range cfg.Areas {
184 goalCount += len(area.Goals)
185 }
186
187 fmt.Fprintln(out)
188 fmt.Fprintln(out, ui.Bold.Render("Configuration summary:"))
189 fmt.Fprintf(out, " Areas: %d (%d goals)\n", len(cfg.Areas), goalCount)
190 fmt.Fprintf(out, " Notebooks: %d\n", len(cfg.Notebooks))
191 fmt.Fprintf(out, " Habits: %d\n", len(cfg.Habits))
192
193 if cfg.Defaults.Area != "" {
194 fmt.Fprintf(out, " Default area: %s\n", cfg.Defaults.Area)
195 }
196
197 if cfg.Defaults.Notebook != "" {
198 fmt.Fprintf(out, " Default notebook: %s\n", cfg.Defaults.Notebook)
199 }
200
201 color := cfg.UI.Color
202 if color == "" {
203 color = colorAuto
204 }
205
206 fmt.Fprintf(out, " Color: %s\n", color)
207 fmt.Fprintln(out)
208}
209
210func saveWithSummary(cmd *cobra.Command, cfg *config.Config) error {
211 path, err := config.Path()
212 if err != nil {
213 return err
214 }
215
216 out := cmd.OutOrStdout()
217 printConfigSummary(out, cfg)
218
219 var save bool
220
221 err = huh.NewConfirm().
222 Title("Save configuration?").
223 Affirmative("Save").
224 Negative("Discard").
225 Value(&save).
226 Run()
227 if err != nil {
228 if errors.Is(err, huh.ErrUserAborted) {
229 fmt.Fprintln(out, ui.Warning.Render("Setup cancelled; configuration was not saved."))
230
231 return errQuit
232 }
233
234 return err
235 }
236
237 if save {
238 if err := cfg.Save(); err != nil {
239 return err
240 }
241
242 fmt.Fprintln(out, ui.Success.Render("Config saved to "+path))
243 } else {
244 fmt.Fprintln(out, ui.Warning.Render("Changes discarded."))
245 }
246
247 return nil
248}