tasks.go

  1// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
  2//
  3// SPDX-License-Identifier: AGPL-3.0-or-later
  4
  5package tools
  6
  7import (
  8	"context"
  9	"fmt"
 10	"strings"
 11
 12	"git.secluded.site/go-lunatask"
 13	"github.com/mark3labs/mcp-go/mcp"
 14)
 15
 16// HandleCreateTask handles the create_task tool call.
 17func (h *Handlers) HandleCreateTask(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
 18	arguments := request.Params.Arguments
 19
 20	if _, err := LoadLocation(h.config.Timezone); err != nil {
 21		return reportMCPError(err.Error())
 22	}
 23
 24	areaID, ok := arguments["area_id"].(string)
 25	if !ok || areaID == "" {
 26		return reportMCPError("Missing or invalid required argument: area_id")
 27	}
 28
 29	var areaFoundProvider AreaProvider
 30
 31	for _, ap := range h.config.Areas {
 32		if ap.GetID() == areaID {
 33			areaFoundProvider = ap
 34
 35			break
 36		}
 37	}
 38
 39	if areaFoundProvider == nil {
 40		return reportMCPError("Area not found for given area_id")
 41	}
 42
 43	var goalID *string
 44	if goalIDStr, exists := arguments["goal_id"].(string); exists && goalIDStr != "" {
 45		found := false
 46
 47		for _, goal := range areaFoundProvider.GetGoals() {
 48			if goal.GetID() == goalIDStr {
 49				found = true
 50
 51				break
 52			}
 53		}
 54
 55		if !found {
 56			return reportMCPError("Goal not found in specified area for given goal_id")
 57		}
 58
 59		goalID = &goalIDStr
 60	}
 61
 62	name, ok := arguments["name"].(string)
 63	if !ok || name == "" {
 64		return reportMCPError("Missing or invalid required argument: name")
 65	}
 66
 67	if len(name) > 100 {
 68		return reportMCPError("'name' must be 100 characters or fewer")
 69	}
 70
 71	task := lunatask.CreateTaskRequest{
 72		Name:   name,
 73		AreaID: &areaID,
 74		GoalID: goalID,
 75	}
 76
 77	if noteVal, exists := arguments["note"].(string); exists {
 78		task.Note = &noteVal
 79	}
 80
 81	if priorityArg, exists := arguments["priority"]; exists && priorityArg != nil {
 82		priorityStr, ok := priorityArg.(string)
 83		if !ok {
 84			return reportMCPError("Invalid type for 'priority' argument: expected string.")
 85		}
 86
 87		priorityMap := map[string]int{
 88			"lowest":  -2,
 89			"low":     -1,
 90			"neutral": 0,
 91			"high":    1,
 92			"highest": 2,
 93		}
 94
 95		translatedPriority, isValid := priorityMap[strings.ToLower(priorityStr)]
 96		if !isValid {
 97			return reportMCPError(fmt.Sprintf("Invalid 'priority' value: '%s'. Must be one of 'lowest', 'low', 'neutral', 'high', 'highest'.", priorityStr))
 98		}
 99
100		task.Priority = &translatedPriority
101	}
102
103	if eisenhowerArg, exists := arguments["eisenhower"]; exists && eisenhowerArg != nil {
104		eisenhowerStr, ok := eisenhowerArg.(string)
105		if !ok {
106			return reportMCPError("Invalid type for 'eisenhower' argument: expected string.")
107		}
108
109		eisenhowerMap := map[string]int{
110			"uncategorised":                0,
111			"both urgent and important":    1,
112			"urgent, but not important":    2,
113			"important, but not urgent":    3,
114			"neither urgent nor important": 4,
115		}
116
117		translatedEisenhower, isValid := eisenhowerMap[strings.ToLower(eisenhowerStr)]
118		if !isValid {
119			return reportMCPError(fmt.Sprintf("Invalid 'eisenhower' value: '%s'. Must be one of 'uncategorised', 'both urgent and important', 'urgent, but not important', 'important, but not urgent', 'neither urgent nor important'.", eisenhowerStr))
120		}
121
122		task.Eisenhower = &translatedEisenhower
123	}
124
125	if motivationVal, exists := arguments["motivation"]; exists && motivationVal != nil {
126		motivation, ok := motivationVal.(string)
127		if !ok {
128			return reportMCPError("'motivation' must be a string")
129		}
130
131		if motivation != "" {
132			validMotivations := map[string]bool{"must": true, "should": true, "want": true}
133			if !validMotivations[motivation] {
134				return reportMCPError("'motivation' must be one of 'must', 'should', or 'want'")
135			}
136
137			task.Motivation = &motivation
138		}
139	}
140
141	if statusVal, exists := arguments["status"]; exists && statusVal != nil {
142		status, ok := statusVal.(string)
143		if !ok {
144			return reportMCPError("'status' must be a string")
145		}
146
147		if status != "" {
148			validStatus := map[string]bool{"later": true, "next": true, "started": true, "waiting": true, "completed": true}
149			if !validStatus[status] {
150				return reportMCPError("'status' must be one of 'later', 'next', 'started', 'waiting', or 'completed'")
151			}
152
153			task.Status = &status
154		}
155	}
156
157	if estimateArg, exists := arguments["estimate"]; exists && estimateArg != nil {
158		estimateVal, ok := estimateArg.(float64)
159		if !ok {
160			return reportMCPError("Invalid type for 'estimate' argument: expected number.")
161		}
162
163		estimate := int(estimateVal)
164		if estimate < 0 || estimate > 720 {
165			return reportMCPError("'estimate' must be between 0 and 720 minutes")
166		}
167
168		task.Estimate = &estimate
169	}
170
171	if scheduledOnArg, exists := arguments["scheduled_on"]; exists {
172		scheduledOnStr, ok := scheduledOnArg.(string)
173		if !ok {
174			return reportMCPError("Invalid type for scheduled_on argument: expected string.")
175		}
176
177		if scheduledOnStr != "" {
178			date, err := lunatask.ParseDate(scheduledOnStr)
179			if err != nil {
180				return reportMCPError(fmt.Sprintf("Invalid format for scheduled_on: '%s'. Must be YYYY-MM-DD.", scheduledOnStr))
181			}
182
183			task.ScheduledOn = &date
184		}
185	}
186
187	if sourceArg, exists := arguments["source"].(string); exists && sourceArg != "" {
188		task.Source = &sourceArg
189	}
190
191	if sourceIDArg, exists := arguments["source_id"].(string); exists && sourceIDArg != "" {
192		task.SourceID = &sourceIDArg
193	}
194
195	client := lunatask.NewClient(h.config.AccessToken)
196
197	response, err := client.CreateTask(ctx, &task)
198	if err != nil {
199		return reportMCPError(fmt.Sprintf("%v", err))
200	}
201
202	if response == nil {
203		return &mcp.CallToolResult{
204			Content: []mcp.Content{
205				mcp.TextContent{
206					Type: "text",
207					Text: "Task already exists (not an error).",
208				},
209			},
210		}, nil
211	}
212
213	return &mcp.CallToolResult{
214		Content: []mcp.Content{
215			mcp.TextContent{
216				Type: "text",
217				Text: "Task created successfully with ID: " + response.ID,
218			},
219		},
220	}, nil
221}
222
223// HandleUpdateTask handles the update_task tool call.
224func (h *Handlers) HandleUpdateTask(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
225	arguments := request.Params.Arguments
226
227	taskID, ok := arguments["task_id"].(string)
228	if !ok || taskID == "" {
229		return reportMCPError("Missing or invalid required argument: task_id")
230	}
231
232	if _, err := LoadLocation(h.config.Timezone); err != nil {
233		return reportMCPError(err.Error())
234	}
235
236	updatePayload := lunatask.UpdateTaskRequest{}
237
238	var specifiedAreaProvider AreaProvider
239
240	areaIDProvided := false
241
242	if areaIDArg, exists := arguments["area_id"]; exists {
243		areaIDStr, ok := areaIDArg.(string)
244		if !ok && areaIDArg != nil {
245			return reportMCPError("Invalid type for area_id argument: expected string.")
246		}
247
248		if ok && areaIDStr != "" {
249			updatePayload.AreaID = &areaIDStr
250			areaIDProvided = true
251			found := false
252
253			for _, ap := range h.config.Areas {
254				if ap.GetID() == areaIDStr {
255					specifiedAreaProvider = ap
256					found = true
257
258					break
259				}
260			}
261
262			if !found {
263				return reportMCPError("Area not found for given area_id: " + areaIDStr)
264			}
265		}
266	}
267
268	if goalIDArg, exists := arguments["goal_id"]; exists {
269		goalIDStr, ok := goalIDArg.(string)
270		if !ok && goalIDArg != nil {
271			return reportMCPError("Invalid type for goal_id argument: expected string.")
272		}
273
274		if ok && goalIDStr != "" {
275			updatePayload.GoalID = &goalIDStr
276
277			if specifiedAreaProvider != nil {
278				foundGoal := false
279
280				for _, goal := range specifiedAreaProvider.GetGoals() {
281					if goal.GetID() == goalIDStr {
282						foundGoal = true
283
284						break
285					}
286				}
287
288				if !foundGoal {
289					return reportMCPError(fmt.Sprintf("Goal not found in specified area '%s' for given goal_id: %s", specifiedAreaProvider.GetName(), goalIDStr))
290				}
291			} else if areaIDProvided {
292				return reportMCPError("Internal error: area_id provided but area details not loaded for goal validation.")
293			}
294		}
295	}
296
297	nameArg := arguments["name"]
298	if nameStr, ok := nameArg.(string); ok {
299		if len(nameStr) > 100 {
300			return reportMCPError("'name' must be 100 characters or fewer")
301		}
302
303		updatePayload.Name = &nameStr
304	} else {
305		return reportMCPError("Invalid type for name argument: expected string.")
306	}
307
308	if noteArg, exists := arguments["note"]; exists {
309		noteStr, ok := noteArg.(string)
310		if !ok && noteArg != nil {
311			return reportMCPError("Invalid type for note argument: expected string.")
312		}
313
314		if ok {
315			updatePayload.Note = &noteStr
316		}
317	}
318
319	if estimateArg, exists := arguments["estimate"]; exists && estimateArg != nil {
320		estimateVal, ok := estimateArg.(float64)
321		if !ok {
322			return reportMCPError("Invalid type for estimate argument: expected number.")
323		}
324
325		estimate := int(estimateVal)
326		if estimate < 0 || estimate > 720 {
327			return reportMCPError("'estimate' must be between 0 and 720 minutes")
328		}
329
330		updatePayload.Estimate = &estimate
331	}
332
333	if priorityArg, exists := arguments["priority"]; exists && priorityArg != nil {
334		priorityStr, ok := priorityArg.(string)
335		if !ok {
336			return reportMCPError("Invalid type for 'priority' argument: expected string.")
337		}
338
339		priorityMap := map[string]int{
340			"lowest":  -2,
341			"low":     -1,
342			"neutral": 0,
343			"high":    1,
344			"highest": 2,
345		}
346
347		translatedPriority, isValid := priorityMap[strings.ToLower(priorityStr)]
348		if !isValid {
349			return reportMCPError(fmt.Sprintf("Invalid 'priority' value: '%s'. Must be one of 'lowest', 'low', 'neutral', 'high', 'highest'.", priorityStr))
350		}
351
352		updatePayload.Priority = &translatedPriority
353	}
354
355	if eisenhowerArg, exists := arguments["eisenhower"]; exists && eisenhowerArg != nil {
356		eisenhowerStr, ok := eisenhowerArg.(string)
357		if !ok {
358			return reportMCPError("Invalid type for 'eisenhower' argument: expected string.")
359		}
360
361		eisenhowerMap := map[string]int{
362			"uncategorised":                0,
363			"both urgent and important":    1,
364			"urgent, but not important":    2,
365			"important, but not urgent":    3,
366			"neither urgent nor important": 4,
367		}
368
369		translatedEisenhower, isValid := eisenhowerMap[strings.ToLower(eisenhowerStr)]
370		if !isValid {
371			return reportMCPError(fmt.Sprintf("Invalid 'eisenhower' value: '%s'. Must be one of 'uncategorised', 'both urgent and important', 'urgent, but not important', 'important, but not urgent', 'neither urgent nor important'.", eisenhowerStr))
372		}
373
374		updatePayload.Eisenhower = &translatedEisenhower
375	}
376
377	if motivationArg, exists := arguments["motivation"]; exists {
378		motivationStr, ok := motivationArg.(string)
379		if !ok && motivationArg != nil {
380			return reportMCPError("Invalid type for motivation argument: expected string.")
381		}
382
383		if ok {
384			if motivationStr != "" {
385				validMotivations := map[string]bool{"must": true, "should": true, "want": true}
386				if !validMotivations[motivationStr] {
387					return reportMCPError("'motivation' must be one of 'must', 'should', or 'want', or empty to clear.")
388				}
389			}
390
391			updatePayload.Motivation = &motivationStr
392		}
393	}
394
395	if statusArg, exists := arguments["status"]; exists {
396		statusStr, ok := statusArg.(string)
397		if !ok && statusArg != nil {
398			return reportMCPError("Invalid type for status argument: expected string.")
399		}
400
401		if ok {
402			if statusStr != "" {
403				validStatus := map[string]bool{"later": true, "next": true, "started": true, "waiting": true, "completed": true}
404				if !validStatus[statusStr] {
405					return reportMCPError("'status' must be one of 'later', 'next', 'started', 'waiting', 'completed', or empty.")
406				}
407			}
408
409			updatePayload.Status = &statusStr
410		}
411	}
412
413	if scheduledOnArg, exists := arguments["scheduled_on"]; exists {
414		scheduledOnStr, ok := scheduledOnArg.(string)
415		if !ok && scheduledOnArg != nil {
416			return reportMCPError("Invalid type for scheduled_on argument: expected string.")
417		}
418
419		if ok && scheduledOnStr != "" {
420			date, err := lunatask.ParseDate(scheduledOnStr)
421			if err != nil {
422				return reportMCPError(fmt.Sprintf("Invalid format for scheduled_on: '%s'. Must be YYYY-MM-DD.", scheduledOnStr))
423			}
424
425			updatePayload.ScheduledOn = &date
426		}
427	}
428
429	client := lunatask.NewClient(h.config.AccessToken)
430
431	response, err := client.UpdateTask(ctx, taskID, &updatePayload)
432	if err != nil {
433		return reportMCPError(fmt.Sprintf("Failed to update task: %v", err))
434	}
435
436	return &mcp.CallToolResult{
437		Content: []mcp.Content{
438			mcp.TextContent{
439				Type: "text",
440				Text: "Task updated successfully. ID: " + response.ID,
441			},
442		},
443	}, nil
444}
445
446// HandleDeleteTask handles the delete_task tool call.
447func (h *Handlers) HandleDeleteTask(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
448	taskID, ok := request.Params.Arguments["task_id"].(string)
449	if !ok || taskID == "" {
450		return reportMCPError("Missing or invalid required argument: task_id")
451	}
452
453	client := lunatask.NewClient(h.config.AccessToken)
454
455	_, err := client.DeleteTask(ctx, taskID)
456	if err != nil {
457		return reportMCPError(fmt.Sprintf("Failed to delete task: %v", err))
458	}
459
460	return &mcp.CallToolResult{
461		Content: []mcp.Content{
462			mcp.TextContent{
463				Type: "text",
464				Text: "Task deleted successfully.",
465			},
466		},
467	}, nil
468}