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	"encoding/json"
 10	"fmt"
 11	"strings"
 12	"time"
 13
 14	"git.sr.ht/~amolith/lunatask-mcp-server/lunatask"
 15	"github.com/mark3labs/mcp-go/mcp"
 16)
 17
 18// CreateTaskArgs defines the arguments for create_task tool call.
 19type CreateTaskArgs struct {
 20	AreaID      string  `json:"area_id"`
 21	GoalID      string  `json:"goal_id,omitempty"`
 22	Name        string  `json:"name"`
 23	Note        string  `json:"note,omitempty"`
 24	Estimate    float64 `json:"estimate,omitempty"`
 25	Priority    string  `json:"priority,omitempty"`
 26	Eisenhower  string  `json:"eisenhower,omitempty"`
 27	Motivation  string  `json:"motivation,omitempty"`
 28	Status      string  `json:"status,omitempty"`
 29	ScheduledOn string  `json:"scheduled_on,omitempty"`
 30}
 31
 32// UpdateTaskArgs defines the arguments for update_task tool call.
 33type UpdateTaskArgs struct {
 34	TaskID      string  `json:"task_id"`
 35	AreaID      string  `json:"area_id,omitempty"`
 36	GoalID      string  `json:"goal_id,omitempty"`
 37	Name        string  `json:"name"`
 38	Note        string  `json:"note,omitempty"`
 39	Estimate    float64 `json:"estimate,omitempty"`
 40	Priority    string  `json:"priority,omitempty"`
 41	Eisenhower  string  `json:"eisenhower,omitempty"`
 42	Motivation  string  `json:"motivation,omitempty"`
 43	Status      string  `json:"status,omitempty"`
 44	ScheduledOn string  `json:"scheduled_on,omitempty"`
 45}
 46
 47// DeleteTaskArgs defines the arguments for delete_task tool call.
 48type DeleteTaskArgs struct {
 49	TaskID string `json:"task_id"`
 50}
 51
 52// HandleCreateTask handles the create_task tool call.
 53func (h *Handlers) HandleCreateTask(ctx context.Context, request mcp.CallToolRequest, args CreateTaskArgs) (*mcp.CallToolResult, error) {
 54	if _, err := LoadLocation(h.config.Timezone); err != nil {
 55		return reportMCPError(err.Error())
 56	}
 57
 58	if args.AreaID == "" {
 59		return reportMCPError("Missing or invalid required argument: area_id")
 60	}
 61
 62	var areaFoundProvider AreaProvider
 63	for _, ap := range h.config.Areas {
 64		if ap.GetID() == args.AreaID {
 65			areaFoundProvider = ap
 66			break
 67		}
 68	}
 69	if areaFoundProvider == nil {
 70		return reportMCPError("Area not found for given area_id")
 71	}
 72
 73	if args.GoalID != "" {
 74		found := false
 75		for _, goal := range areaFoundProvider.GetGoals() {
 76			if goal.GetID() == args.GoalID {
 77				found = true
 78				break
 79			}
 80		}
 81		if !found {
 82			return reportMCPError("Goal not found in specified area for given goal_id")
 83		}
 84	}
 85
 86	// Create a map to store the final arguments for JSON marshaling
 87	finalArgs := make(map[string]any)
 88	finalArgs["area_id"] = args.AreaID
 89	if args.GoalID != "" {
 90		finalArgs["goal_id"] = args.GoalID
 91	}
 92	finalArgs["name"] = args.Name
 93	if args.Note != "" {
 94		finalArgs["note"] = args.Note
 95	}
 96	if args.Estimate > 0 {
 97		finalArgs["estimate"] = int(args.Estimate)
 98	}
 99
100	priorityMap := map[string]int{
101		"lowest":  -2,
102		"low":     -1,
103		"neutral": 0,
104		"high":    1,
105		"highest": 2,
106	}
107
108	if args.Priority != "" {
109		translatedPriority, isValid := priorityMap[strings.ToLower(args.Priority)]
110		if !isValid {
111			return reportMCPError(fmt.Sprintf("Invalid 'priority' value: '%s'. Must be one of 'lowest', 'low', 'neutral', 'high', 'highest'.", args.Priority))
112		}
113		finalArgs["priority"] = translatedPriority
114	}
115
116	eisenhowerMap := map[string]int{
117		"uncategorised":                0,
118		"both urgent and important":    1,
119		"urgent, but not important":    2,
120		"important, but not urgent":    3,
121		"neither urgent nor important": 4,
122	}
123
124	if args.Eisenhower != "" {
125		translatedEisenhower, isValid := eisenhowerMap[strings.ToLower(args.Eisenhower)]
126		if !isValid {
127			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'.", args.Eisenhower))
128		}
129		finalArgs["eisenhower"] = translatedEisenhower
130	}
131
132	if args.Motivation != "" {
133		validMotivations := map[string]bool{"must": true, "should": true, "want": true}
134		if !validMotivations[args.Motivation] {
135			return reportMCPError("'motivation' must be one of 'must', 'should', or 'want'")
136		}
137		finalArgs["motivation"] = args.Motivation
138	}
139
140	if args.Status != "" {
141		validStatus := map[string]bool{"later": true, "next": true, "started": true, "waiting": true, "completed": true}
142		if !validStatus[args.Status] {
143			return reportMCPError("'status' must be one of 'later', 'next', 'started', 'waiting', or 'completed'")
144		}
145		finalArgs["status"] = args.Status
146	}
147
148	if args.ScheduledOn != "" {
149		if _, err := time.Parse(time.RFC3339, args.ScheduledOn); err != nil {
150			return reportMCPError(fmt.Sprintf("Invalid format for scheduled_on: '%s'. Must be RFC3339 timestamp (e.g., YYYY-MM-DDTHH:MM:SSZ). Use get_timestamp tool first.", args.ScheduledOn))
151		}
152		finalArgs["scheduled_on"] = args.ScheduledOn
153	}
154
155	client := lunatask.NewClient(h.config.AccessToken)
156	var task lunatask.CreateTaskRequest
157	argBytes, err := json.Marshal(finalArgs)
158	if err != nil {
159		return reportMCPError(fmt.Sprintf("Failed to process arguments: %v", err))
160	}
161	if err := json.Unmarshal(argBytes, &task); err != nil {
162		return reportMCPError(fmt.Sprintf("Failed to parse arguments: %v", err))
163	}
164
165	response, err := client.CreateTask(ctx, &task)
166	if err != nil {
167		return reportMCPError(fmt.Sprintf("%v", err))
168	}
169
170	if response == nil {
171		return &mcp.CallToolResult{
172			Content: []mcp.Content{
173				mcp.TextContent{
174					Type: "text",
175					Text: "Task already exists (not an error).",
176				},
177			},
178		}, nil
179	}
180
181	return &mcp.CallToolResult{
182		Content: []mcp.Content{
183			mcp.TextContent{
184				Type: "text",
185				Text: fmt.Sprintf("Task created successfully with ID: %s", response.Task.ID),
186			},
187		},
188	}, nil
189}
190
191// HandleUpdateTask handles the update_task tool call.
192func (h *Handlers) HandleUpdateTask(ctx context.Context, request mcp.CallToolRequest, args UpdateTaskArgs) (*mcp.CallToolResult, error) {
193	if args.TaskID == "" {
194		return reportMCPError("Missing or invalid required argument: task_id")
195	}
196
197	if _, err := LoadLocation(h.config.Timezone); err != nil {
198		return reportMCPError(err.Error())
199	}
200
201	updatePayload := lunatask.CreateTaskRequest{}
202
203	var specifiedAreaProvider AreaProvider
204	areaIDProvided := false
205
206	if args.AreaID != "" {
207		updatePayload.AreaID = args.AreaID
208		areaIDProvided = true
209		found := false
210		for _, ap := range h.config.Areas {
211			if ap.GetID() == args.AreaID {
212				specifiedAreaProvider = ap
213				found = true
214				break
215			}
216		}
217		if !found {
218			return reportMCPError(fmt.Sprintf("Area not found for given area_id: %s", args.AreaID))
219		}
220	}
221
222	if args.GoalID != "" {
223		updatePayload.GoalID = args.GoalID
224		if specifiedAreaProvider != nil {
225			foundGoal := false
226			for _, goal := range specifiedAreaProvider.GetGoals() {
227				if goal.GetID() == args.GoalID {
228					foundGoal = true
229					break
230				}
231			}
232			if !foundGoal {
233				return reportMCPError(fmt.Sprintf("Goal not found in specified area '%s' for given goal_id: %s", specifiedAreaProvider.GetName(), args.GoalID))
234			}
235		} else if areaIDProvided {
236			return reportMCPError("Internal error: area_id provided but area details not loaded for goal validation.")
237		}
238		// If area_id is not provided, we're not moving the task to a different area
239		// In this case, the goal validation should be skipped as we don't know the current area
240	}
241
242	updatePayload.Name = args.Name
243	updatePayload.Note = args.Note
244
245	if args.Estimate > 0 {
246		updatePayload.Estimate = int(args.Estimate)
247	}
248
249	if args.Priority != "" {
250		priorityMap := map[string]int{
251			"lowest":  -2,
252			"low":     -1,
253			"neutral": 0,
254			"high":    1,
255			"highest": 2,
256		}
257		translatedPriority, isValid := priorityMap[strings.ToLower(args.Priority)]
258		if !isValid {
259			return reportMCPError(fmt.Sprintf("Invalid 'priority' value: '%s'. Must be one of 'lowest', 'low', 'neutral', 'high', 'highest'.", args.Priority))
260		}
261		updatePayload.Priority = translatedPriority
262	}
263
264	if args.Eisenhower != "" {
265		eisenhowerMap := map[string]int{
266			"uncategorised":                0,
267			"both urgent and important":    1,
268			"urgent, but not important":    2,
269			"important, but not urgent":    3,
270			"neither urgent nor important": 4,
271		}
272		translatedEisenhower, isValid := eisenhowerMap[strings.ToLower(args.Eisenhower)]
273		if !isValid {
274			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'.", args.Eisenhower))
275		}
276		updatePayload.Eisenhower = translatedEisenhower
277	}
278
279	if args.Motivation != "" {
280		validMotivations := map[string]bool{"must": true, "should": true, "want": true}
281		if !validMotivations[args.Motivation] {
282			return reportMCPError("'motivation' must be one of 'must', 'should', or 'want', or empty to clear.")
283		}
284	}
285	updatePayload.Motivation = args.Motivation
286
287	if args.Status != "" {
288		validStatus := map[string]bool{"later": true, "next": true, "started": true, "waiting": true, "completed": true}
289		if !validStatus[args.Status] {
290			return reportMCPError("'status' must be one of 'later', 'next', 'started', 'waiting', 'completed', or empty.")
291		}
292	}
293	updatePayload.Status = args.Status
294
295	if args.ScheduledOn != "" {
296		if _, err := time.Parse(time.RFC3339, args.ScheduledOn); err != nil {
297			return reportMCPError(fmt.Sprintf("Invalid format for scheduled_on: '%s'. Must be RFC3339. Use get_timestamp tool.", args.ScheduledOn))
298		}
299	}
300	updatePayload.ScheduledOn = args.ScheduledOn
301
302	client := lunatask.NewClient(h.config.AccessToken)
303	response, err := client.UpdateTask(ctx, args.TaskID, &updatePayload)
304	if err != nil {
305		return reportMCPError(fmt.Sprintf("Failed to update task: %v", err))
306	}
307
308	return &mcp.CallToolResult{
309		Content: []mcp.Content{
310			mcp.TextContent{
311				Type: "text",
312				Text: fmt.Sprintf("Task updated successfully. ID: %s", response.Task.ID),
313			},
314		},
315	}, nil
316}
317
318// HandleDeleteTask handles the delete_task tool call.
319func (h *Handlers) HandleDeleteTask(ctx context.Context, request mcp.CallToolRequest, args DeleteTaskArgs) (*mcp.CallToolResult, error) {
320	if args.TaskID == "" {
321		return reportMCPError("Missing or invalid required argument: task_id")
322	}
323
324	client := lunatask.NewClient(h.config.AccessToken)
325	_, err := client.DeleteTask(ctx, args.TaskID)
326	if err != nil {
327		return reportMCPError(fmt.Sprintf("Failed to delete task: %v", err))
328	}
329
330	return &mcp.CallToolResult{
331		Content: []mcp.Content{
332			mcp.TextContent{
333				Type: "text",
334				Text: "Task deleted successfully.",
335			},
336		},
337	}, nil
338}