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