1// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
2//
3// SPDX-License-Identifier: AGPL-3.0-or-later
4
5package init
6
7import (
8 "errors"
9 "fmt"
10 "os"
11 "regexp"
12 "strconv"
13 "strings"
14
15 "github.com/charmbracelet/huh"
16 "github.com/spf13/cobra"
17
18 "git.secluded.site/lune/internal/config"
19 "git.secluded.site/lune/internal/deeplink"
20 "git.secluded.site/lune/internal/ui"
21)
22
23const (
24 choiceBack = "back"
25 choiceNext = "next"
26 choiceAdd = "add"
27 choiceDone = "done"
28 actionEdit = "edit"
29 actionDelete = "delete"
30 actionGoals = "goals"
31 colorAuto = "auto"
32)
33
34var (
35 errNameRequired = errors.New("name is required")
36 errKeyRequired = errors.New("key is required")
37 errTokenRequired = errors.New("access token is required")
38 errKeyFormat = errors.New("key must be lowercase letters, numbers, and hyphens (e.g. 'work' or 'q1-goals')")
39 errKeyDuplicate = errors.New("this key is already in use")
40 errRefRequired = errors.New("reference is required")
41 errRefFormat = errors.New("expected UUID or lunatask:// deep link")
42 errIndexOutRange = errors.New("index out of range")
43 errBack = errors.New("user went back")
44)
45
46func configureUIPrefs(cfg *config.Config) error {
47 color := cfg.UI.Color
48 if color == "" {
49 color = colorAuto
50 }
51
52 err := huh.NewSelect[string]().
53 Title("Color output").
54 Description("When should lune use colored output?").
55 Options(
56 huh.NewOption("Auto (detect terminal capability)", colorAuto),
57 huh.NewOption("Always", "always"),
58 huh.NewOption("Never", "never"),
59 ).
60 Value(&color).
61 Run()
62 if err != nil {
63 if errors.Is(err, huh.ErrUserAborted) {
64 return errQuit
65 }
66
67 return err
68 }
69
70 cfg.UI.Color = color
71
72 return nil
73}
74
75func resetConfig(cmd *cobra.Command, cfg *config.Config) error {
76 path, err := config.Path()
77 if err != nil {
78 return err
79 }
80
81 var confirm bool
82
83 err = huh.NewConfirm().
84 Title("Reset all configuration?").
85 Description(fmt.Sprintf("This will delete %s and restart the setup wizard.", path)).
86 Affirmative("Reset").
87 Negative("Cancel").
88 Value(&confirm).
89 Run()
90 if err != nil {
91 if errors.Is(err, huh.ErrUserAborted) {
92 return errQuit
93 }
94
95 return err
96 }
97
98 if confirm {
99 if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
100 return fmt.Errorf("removing config: %w", err)
101 }
102
103 fmt.Fprintln(cmd.OutOrStdout(), ui.Success.Render("Configuration reset."))
104 fmt.Fprintln(cmd.OutOrStdout())
105
106 *cfg = config.Config{}
107
108 return errReset
109 }
110
111 return nil
112}
113
114func runListSelect(title, description string, options []huh.Option[string]) (string, error) {
115 var choice string
116
117 selector := huh.NewSelect[string]().
118 Title(title).
119 Options(options...).
120 Value(&choice)
121
122 if description != "" {
123 selector = selector.Description(description)
124 }
125
126 if err := selector.Run(); err != nil {
127 if errors.Is(err, huh.ErrUserAborted) {
128 return "", errQuit
129 }
130
131 return "", err
132 }
133
134 return choice, nil
135}
136
137func parseEditIndex(choice string) (int, bool) {
138 if !strings.HasPrefix(choice, "edit:") {
139 return 0, false
140 }
141
142 idx, err := strconv.Atoi(strings.TrimPrefix(choice, "edit:"))
143 if err != nil {
144 return 0, false
145 }
146
147 return idx, true
148}
149
150var keyPattern = regexp.MustCompile(`^[a-z0-9]+(-[a-z0-9]+)*$`)
151
152func validateKeyFormat(input string) error {
153 if input == "" {
154 return errKeyRequired
155 }
156
157 if !keyPattern.MatchString(input) {
158 return errKeyFormat
159 }
160
161 return nil
162}
163
164func validateReference(input string, supportsDeepLink bool) (string, error) {
165 if input == "" {
166 return "", errRefRequired
167 }
168
169 if supportsDeepLink {
170 id, err := deeplink.ParseID(input)
171 if err != nil {
172 return "", errRefFormat
173 }
174
175 return id, nil
176 }
177
178 // Habits don't support deep links, only raw UUIDs
179 id, err := deeplink.ParseID(input)
180 if err != nil {
181 return "", errRefFormat
182 }
183
184 return id, nil
185}
186
187type itemFormConfig struct {
188 itemType string
189 namePlaceholder string
190 keyPlaceholder string
191 keyValidator func(string) error
192 supportsDeepLink bool
193}
194
195func titleCase(s string) string {
196 if s == "" {
197 return s
198 }
199
200 return strings.ToUpper(s[:1]) + s[1:]
201}
202
203// itemAction represents the result of an action selection dialog.
204type itemAction int
205
206const (
207 itemActionNone itemAction = iota
208 itemActionEdit
209 itemActionDelete
210 itemActionGoals
211)
212
213// runActionSelect shows an action selection dialog for an item and returns the chosen action.
214func runActionSelect(title string, includeGoals bool) (itemAction, error) {
215 var action string
216
217 options := []huh.Option[string]{
218 huh.NewOption("Edit", actionEdit),
219 }
220
221 if includeGoals {
222 options = append(options, huh.NewOption("Manage goals", actionGoals))
223 }
224
225 options = append(options,
226 huh.NewOption("Delete", actionDelete),
227 huh.NewOption("Back", choiceBack),
228 )
229
230 err := huh.NewSelect[string]().
231 Title(title).
232 Options(options...).
233 Value(&action).
234 Run()
235 if err != nil {
236 if errors.Is(err, huh.ErrUserAborted) {
237 return itemActionNone, errQuit
238 }
239
240 return itemActionNone, err
241 }
242
243 switch action {
244 case actionEdit:
245 return itemActionEdit, nil
246 case actionDelete:
247 return itemActionDelete, nil
248 case actionGoals:
249 return itemActionGoals, nil
250 default:
251 return itemActionNone, nil
252 }
253}
254
255func runItemForm(name, key, itemID *string, cfg itemFormConfig) error {
256 var save bool
257
258 requireNonEmpty := false
259
260 for {
261 form := buildItemFormGroup(name, key, itemID, cfg, &requireNonEmpty, &save)
262
263 if err := form.Run(); err != nil {
264 if errors.Is(err, huh.ErrUserAborted) {
265 return errBack
266 }
267
268 return err
269 }
270
271 if !save {
272 return errBack
273 }
274
275 // If any required field is empty, re-run with strict validation
276 if *name == "" || *key == "" || *itemID == "" {
277 requireNonEmpty = true
278
279 continue
280 }
281
282 // Normalise reference to UUID
283 parsedID, _ := validateReference(*itemID, cfg.supportsDeepLink)
284 *itemID = parsedID
285
286 return nil
287 }
288}
289
290func buildItemFormGroup(
291 name, key, itemID *string,
292 cfg itemFormConfig,
293 requireNonEmpty *bool,
294 save *bool,
295) *huh.Form {
296 refDescription := "Settings → 'Copy " + titleCase(cfg.itemType) + " ID' (bottom left)."
297 if cfg.supportsDeepLink {
298 refDescription = "Paste UUID or lunatask:// deep link."
299 }
300
301 return huh.NewForm(
302 huh.NewGroup(
303 huh.NewInput().
304 Title("Name").
305 Description("Display name for this "+cfg.itemType+".").
306 Placeholder(cfg.namePlaceholder).
307 Value(name).
308 Validate(makeNameValidator(requireNonEmpty)),
309 huh.NewInput().
310 Title("Key").
311 Description("Short alias for CLI use (lowercase, no spaces).").
312 Placeholder(cfg.keyPlaceholder).
313 Value(key).
314 Validate(makeKeyValidator(requireNonEmpty, cfg.keyValidator)),
315 huh.NewInput().
316 Title("Reference").
317 Description(refDescription).
318 Placeholder("xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx").
319 Value(itemID).
320 Validate(makeRefValidator(requireNonEmpty, cfg.supportsDeepLink)),
321 huh.NewConfirm().
322 Title("Save this "+cfg.itemType+"?").
323 Affirmative("Save").
324 Negative("Cancel").
325 Value(save),
326 ),
327 )
328}
329
330func makeNameValidator(requireNonEmpty *bool) func(string) error {
331 return func(input string) error {
332 if *requireNonEmpty && input == "" {
333 return errNameRequired
334 }
335
336 return nil
337 }
338}
339
340func makeKeyValidator(requireNonEmpty *bool, formatValidator func(string) error) func(string) error {
341 return func(input string) error {
342 if input == "" {
343 if *requireNonEmpty {
344 return errKeyRequired
345 }
346
347 return nil
348 }
349
350 return formatValidator(input)
351 }
352}
353
354func makeRefValidator(requireNonEmpty *bool, supportsDeepLink bool) func(string) error {
355 return func(input string) error {
356 if input == "" {
357 if *requireNonEmpty {
358 return errRefRequired
359 }
360
361 return nil
362 }
363
364 _, err := validateReference(input, supportsDeepLink)
365
366 return err
367 }
368}