areas.go

  1// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
  2//
  3// SPDX-License-Identifier: AGPL-3.0-or-later
  4
  5package config
  6
  7import (
  8	"errors"
  9	"fmt"
 10
 11	"github.com/charmbracelet/huh"
 12
 13	"git.secluded.site/lunatask-mcp-server/internal/config"
 14)
 15
 16func manageAreas(cfg *config.Config) error {
 17	nav := manageAreasAsStep(cfg)
 18	if nav == navQuit {
 19		return errQuit
 20	}
 21
 22	return nil
 23}
 24
 25// manageAreasAsStep runs areas management as a wizard step with Back/Next navigation.
 26func manageAreasAsStep(cfg *config.Config) wizardNav {
 27	for {
 28		options := buildAreaStepOptions(cfg.Areas)
 29
 30		choice, err := runListSelect(
 31			"Areas & Goals",
 32			"Areas organize your life (Work, Health, etc). Goals belong to areas.",
 33			options,
 34		)
 35		if err != nil {
 36			return navQuit
 37		}
 38
 39		switch choice {
 40		case choiceBack:
 41			return navBack
 42		case choiceNext:
 43			return navNext
 44		case choiceAdd:
 45			if err := addArea(cfg); errors.Is(err, errQuit) {
 46				return navQuit
 47			}
 48		default:
 49			idx, ok := parseEditIndex(choice)
 50			if !ok {
 51				continue
 52			}
 53
 54			if err := manageAreaActions(cfg, idx); errors.Is(err, errQuit) {
 55				return navQuit
 56			}
 57		}
 58	}
 59}
 60
 61func buildAreaStepOptions(areas []config.Area) []huh.Option[string] {
 62	options := []huh.Option[string]{
 63		huh.NewOption("Add new area", choiceAdd),
 64	}
 65
 66	for idx, area := range areas {
 67		goalCount := len(area.Goals)
 68		label := fmt.Sprintf("%s (%s) - %d goal(s)", area.Name, area.Key, goalCount)
 69		options = append(options, huh.NewOption(label, fmt.Sprintf("edit:%d", idx)))
 70	}
 71
 72	options = append(options,
 73		huh.NewOption("← Back", choiceBack),
 74		huh.NewOption("Next →", choiceNext),
 75	)
 76
 77	return options
 78}
 79
 80func addArea(cfg *config.Config) error {
 81	area, err := editArea(nil, cfg)
 82	if err != nil {
 83		if errors.Is(err, errBack) {
 84			return nil
 85		}
 86
 87		return err
 88	}
 89
 90	cfg.Areas = append(cfg.Areas, *area)
 91
 92	return maybeManageGoals(cfg, len(cfg.Areas)-1)
 93}
 94
 95func manageAreaActions(cfg *config.Config, idx int) error {
 96	if idx < 0 || idx >= len(cfg.Areas) {
 97		return fmt.Errorf("%w: area %d", errIndexOutRange, idx)
 98	}
 99
100	area := &cfg.Areas[idx]
101
102	action, err := runActionSelect(fmt.Sprintf("Area: %s (%s)", area.Name, area.Key), true)
103	if err != nil {
104		return err
105	}
106
107	return handleAreaAction(cfg, idx, area, action)
108}
109
110func handleAreaAction(cfg *config.Config, idx int, area *config.Area, action itemAction) error {
111	switch action {
112	case itemActionEdit:
113		updated, err := editArea(area, cfg)
114		if err != nil {
115			if errors.Is(err, errBack) {
116				return nil
117			}
118
119			return err
120		}
121
122		if updated != nil {
123			cfg.Areas[idx] = *updated
124		}
125	case itemActionGoals:
126		return manageGoals(cfg, idx)
127	case itemActionDelete:
128		return deleteArea(cfg, idx)
129	case itemActionNone:
130		// User cancelled or went back
131	}
132
133	return nil
134}
135
136func editArea(existing *config.Area, cfg *config.Config) (*config.Area, error) {
137	area := config.Area{} //nolint:exhaustruct // fields populated by form
138	if existing != nil {
139		area = *existing
140	}
141
142	err := runItemForm(&area.Name, &area.Key, &area.ID, itemFormConfig{
143		itemType:        "area",
144		namePlaceholder: "Personal",
145		keyPlaceholder:  "personal",
146		keyValidator:    validateAreaKey(cfg, existing),
147	})
148	if err != nil {
149		return nil, err
150	}
151
152	return &area, nil
153}
154
155func validateAreaKey(cfg *config.Config, existing *config.Area) func(string) error {
156	return func(input string) error {
157		if err := validateKeyFormat(input); err != nil {
158			return err
159		}
160
161		for idx := range cfg.Areas {
162			if existing != nil && &cfg.Areas[idx] == existing {
163				continue
164			}
165
166			if cfg.Areas[idx].Key == input {
167				return errKeyDuplicate
168			}
169		}
170
171		return nil
172	}
173}
174
175func maybeManageGoals(cfg *config.Config, areaIdx int) error {
176	var manage bool
177
178	err := huh.NewConfirm().
179		Title("Add goals for this area?").
180		Affirmative("Yes").
181		Negative("Not now").
182		Value(&manage).
183		Run()
184	if err != nil {
185		if errors.Is(err, huh.ErrUserAborted) {
186			return errQuit
187		}
188
189		return err
190	}
191
192	if manage {
193		return manageGoals(cfg, areaIdx)
194	}
195
196	return nil
197}
198
199func manageGoals(cfg *config.Config, areaIdx int) error {
200	if areaIdx < 0 || areaIdx >= len(cfg.Areas) {
201		return fmt.Errorf("%w: area %d", errIndexOutRange, areaIdx)
202	}
203
204	area := &cfg.Areas[areaIdx]
205
206	for {
207		options := buildGoalOptions(area.Goals)
208
209		choice, err := runListSelect(
210			fmt.Sprintf("Goals for: %s (%s)", area.Name, area.Key),
211			"",
212			options,
213		)
214		if err != nil {
215			return err
216		}
217
218		if choice == choiceDone {
219			return nil
220		}
221
222		if choice == choiceAdd {
223			if err := addGoal(area); err != nil {
224				return err
225			}
226
227			continue
228		}
229
230		idx, ok := parseEditIndex(choice)
231		if !ok {
232			continue
233		}
234
235		if err := manageGoalActions(area, idx); err != nil {
236			return err
237		}
238	}
239}
240
241func buildGoalOptions(goals []config.Goal) []huh.Option[string] {
242	options := []huh.Option[string]{
243		huh.NewOption("Add new goal", choiceAdd),
244	}
245
246	for idx, goal := range goals {
247		label := fmt.Sprintf("%s (%s)", goal.Name, goal.Key)
248		options = append(options, huh.NewOption(label, fmt.Sprintf("edit:%d", idx)))
249	}
250
251	options = append(options, huh.NewOption("Done", choiceDone))
252
253	return options
254}
255
256func addGoal(area *config.Area) error {
257	goal, err := editGoal(nil, area)
258	if err != nil {
259		if errors.Is(err, errBack) {
260			return nil
261		}
262
263		return err
264	}
265
266	area.Goals = append(area.Goals, *goal)
267
268	return nil
269}
270
271func manageGoalActions(area *config.Area, idx int) error {
272	if idx < 0 || idx >= len(area.Goals) {
273		return fmt.Errorf("%w: goal %d", errIndexOutRange, idx)
274	}
275
276	goal := &area.Goals[idx]
277
278	action, err := runActionSelect(fmt.Sprintf("Goal: %s (%s)", goal.Name, goal.Key), false)
279	if err != nil {
280		return err
281	}
282
283	switch action {
284	case itemActionEdit:
285		updated, err := editGoal(goal, area)
286		if err != nil {
287			if errors.Is(err, errBack) {
288				return nil
289			}
290
291			return err
292		}
293
294		if updated != nil {
295			area.Goals[idx] = *updated
296		}
297	case itemActionDelete:
298		return deleteGoal(area, idx)
299	case itemActionNone, itemActionGoals:
300		// User cancelled or went back; goals not applicable here
301	}
302
303	return nil
304}
305
306func editGoal(existing *config.Goal, area *config.Area) (*config.Goal, error) {
307	goal := config.Goal{} //nolint:exhaustruct // fields populated by form
308	if existing != nil {
309		goal = *existing
310	}
311
312	err := runItemForm(&goal.Name, &goal.Key, &goal.ID, itemFormConfig{
313		itemType:        "goal",
314		namePlaceholder: "Learn Gaelic",
315		keyPlaceholder:  "gaelic",
316		keyValidator:    validateGoalKey(area, existing),
317	})
318	if err != nil {
319		return nil, err
320	}
321
322	return &goal, nil
323}
324
325func validateGoalKey(area *config.Area, existing *config.Goal) func(string) error {
326	return func(input string) error {
327		if err := validateKeyFormat(input); err != nil {
328			return err
329		}
330
331		for idx := range area.Goals {
332			if existing != nil && &area.Goals[idx] == existing {
333				continue
334			}
335
336			if area.Goals[idx].Key == input {
337				return errKeyDuplicate
338			}
339		}
340
341		return nil
342	}
343}
344
345func deleteArea(cfg *config.Config, idx int) error {
346	if idx < 0 || idx >= len(cfg.Areas) {
347		return fmt.Errorf("%w: area %d", errIndexOutRange, idx)
348	}
349
350	area := cfg.Areas[idx]
351
352	var confirm bool
353
354	err := huh.NewConfirm().
355		Title(fmt.Sprintf("Delete area '%s'?", area.Name)).
356		Description(fmt.Sprintf("This will also remove %d goal(s). This cannot be undone.", len(area.Goals))).
357		Affirmative("Delete").
358		Negative("Cancel").
359		Value(&confirm).
360		Run()
361	if err != nil {
362		if errors.Is(err, huh.ErrUserAborted) {
363			return errQuit
364		}
365
366		return err
367	}
368
369	if confirm {
370		cfg.Areas = append(cfg.Areas[:idx], cfg.Areas[idx+1:]...)
371	}
372
373	return nil
374}
375
376func deleteGoal(area *config.Area, idx int) error {
377	if idx < 0 || idx >= len(area.Goals) {
378		return fmt.Errorf("%w: goal %d", errIndexOutRange, idx)
379	}
380
381	goal := area.Goals[idx]
382
383	var confirm bool
384
385	err := huh.NewConfirm().
386		Title(fmt.Sprintf("Delete goal '%s'?", goal.Name)).
387		Description("This cannot be undone.").
388		Affirmative("Delete").
389		Negative("Cancel").
390		Value(&confirm).
391		Run()
392	if err != nil {
393		if errors.Is(err, huh.ErrUserAborted) {
394			return errQuit
395		}
396
397		return err
398	}
399
400	if confirm {
401		area.Goals = append(area.Goals[:idx], area.Goals[idx+1:]...)
402	}
403
404	return nil
405}