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}