1// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
2//
3// SPDX-License-Identifier: AGPL-3.0-or-later
4
5package crud
6
7import (
8 "context"
9
10 "git.secluded.site/go-lunatask"
11 "git.secluded.site/lune/internal/dateutil"
12 "git.secluded.site/lune/internal/mcp/shared"
13 "git.secluded.site/lune/internal/validate"
14 "github.com/modelcontextprotocol/go-sdk/mcp"
15)
16
17// UpdateToolName is the name of the consolidated update tool.
18const UpdateToolName = "update"
19
20// UpdateToolDescription describes the update tool for LLMs.
21const UpdateToolDescription = `Updates an existing entity in Lunatask.
22
23Required:
24- entity: Type to update (task, note, person)
25- id: Entity UUID or lunatask:// deep link
26
27Entity-specific fields (only provided fields are modified):
28
29**task**:
30- name: New task title
31- area_id: Move to area (UUID, deep link, or config key)
32- goal_id: Move to goal (UUID, deep link, or config key; requires area_id)
33- status: later, next, started, waiting, completed
34- note: New markdown note (replaces existing)
35- priority: lowest, low, normal, high, highest
36- estimate: Time estimate in minutes (0-720)
37- motivation: must, should, want
38- important: true/false for Eisenhower matrix
39- urgent: true/false for Eisenhower matrix
40- scheduled_on: Date to schedule (YYYY-MM-DD or natural language)
41
42**note**:
43- name: New note title
44- notebook_id: Move to notebook (UUID)
45- content: Replace content (Markdown)
46- date: Note date (YYYY-MM-DD or natural language)
47
48**person**:
49- first_name: New first name
50- last_name: New last name
51- relationship: New relationship strength (family, intimate-friends, close-friends,
52 casual-friends, acquaintances, business-contacts, almost-strangers)
53
54Returns the updated entity's deep link.`
55
56// UpdateInput is the input schema for the consolidated update tool.
57type UpdateInput struct {
58 Entity string `json:"entity" jsonschema:"required"`
59 ID string `json:"id" jsonschema:"required"`
60
61 // Common fields
62 Name *string `json:"name,omitempty"`
63 Content *string `json:"content,omitempty"`
64
65 // Task-specific fields
66 AreaID *string `json:"area_id,omitempty"`
67 GoalID *string `json:"goal_id,omitempty"`
68 Status *string `json:"status,omitempty"`
69 Note *string `json:"note,omitempty"`
70 Priority *string `json:"priority,omitempty"`
71 Estimate *int `json:"estimate,omitempty"`
72 Motivation *string `json:"motivation,omitempty"`
73 Important *bool `json:"important,omitempty"`
74 Urgent *bool `json:"urgent,omitempty"`
75 ScheduledOn *string `json:"scheduled_on,omitempty"`
76
77 // Note-specific fields
78 NotebookID *string `json:"notebook_id,omitempty"`
79 Date *string `json:"date,omitempty"`
80
81 // Person-specific fields
82 FirstName *string `json:"first_name,omitempty"`
83 LastName *string `json:"last_name,omitempty"`
84 Relationship *string `json:"relationship,omitempty"`
85}
86
87// UpdateOutput is the output schema for the consolidated update tool.
88type UpdateOutput struct {
89 Entity string `json:"entity"`
90 DeepLink string `json:"deep_link"`
91}
92
93// HandleUpdate updates an existing entity based on the entity type.
94func (h *Handler) HandleUpdate(
95 ctx context.Context,
96 _ *mcp.CallToolRequest,
97 input UpdateInput,
98) (*mcp.CallToolResult, UpdateOutput, error) {
99 switch input.Entity {
100 case EntityTask:
101 return h.updateTask(ctx, input)
102 case EntityNote:
103 return h.updateNote(ctx, input)
104 case EntityPerson:
105 return h.updatePerson(ctx, input)
106 default:
107 return shared.ErrorResult("invalid entity: must be task, note, or person"),
108 UpdateOutput{Entity: input.Entity}, nil
109 }
110}
111
112// parsedTaskUpdateInput holds validated and parsed task update input fields.
113type parsedTaskUpdateInput struct {
114 ID string
115 Name *string
116 AreaID *string
117 GoalID *string
118 Status *lunatask.TaskStatus
119 Note *string
120 Priority *lunatask.Priority
121 Estimate *int
122 Motivation *lunatask.Motivation
123 Important *bool
124 Urgent *bool
125 ScheduledOn *lunatask.Date
126}
127
128func (h *Handler) updateTask(
129 ctx context.Context,
130 input UpdateInput,
131) (*mcp.CallToolResult, UpdateOutput, error) {
132 parsed, errResult := h.parseTaskUpdateInput(input)
133 if errResult != nil {
134 return errResult, UpdateOutput{Entity: input.Entity}, nil
135 }
136
137 builder := h.client.NewTaskUpdate(parsed.ID)
138 applyToTaskUpdateBuilder(builder, parsed)
139
140 task, err := builder.Update(ctx)
141 if err != nil {
142 return shared.ErrorResult(err.Error()), UpdateOutput{Entity: input.Entity}, nil
143 }
144
145 deepLink, _ := lunatask.BuildDeepLink(lunatask.ResourceTask, task.ID)
146
147 return &mcp.CallToolResult{
148 Content: []mcp.Content{&mcp.TextContent{
149 Text: "Task updated: " + deepLink,
150 }},
151 }, UpdateOutput{Entity: input.Entity, DeepLink: deepLink}, nil
152}
153
154//nolint:cyclop,funlen
155func (h *Handler) parseTaskUpdateInput(input UpdateInput) (*parsedTaskUpdateInput, *mcp.CallToolResult) {
156 _, id, err := lunatask.ParseReference(input.ID)
157 if err != nil {
158 return nil, shared.ErrorResult("invalid ID: expected UUID or lunatask:// deep link")
159 }
160
161 parsed := &parsedTaskUpdateInput{
162 ID: id,
163 Name: input.Name,
164 Note: input.Note,
165 Estimate: input.Estimate,
166 Important: input.Important,
167 Urgent: input.Urgent,
168 }
169
170 if input.AreaID != nil {
171 areaID, err := validate.AreaRef(h.cfg, *input.AreaID)
172 if err != nil {
173 return nil, shared.ErrorResult(err.Error())
174 }
175
176 parsed.AreaID = &areaID
177 }
178
179 if input.GoalID != nil {
180 areaID := ""
181 if parsed.AreaID != nil {
182 areaID = *parsed.AreaID
183 }
184
185 goalID, err := validate.GoalRef(h.cfg, areaID, *input.GoalID)
186 if err != nil {
187 return nil, shared.ErrorResult(err.Error())
188 }
189
190 parsed.GoalID = &goalID
191 }
192
193 if input.Estimate != nil {
194 if err := shared.ValidateEstimate(*input.Estimate); err != nil {
195 return nil, shared.ErrorResult(err.Error())
196 }
197 }
198
199 if input.Status != nil {
200 status, err := lunatask.ParseTaskStatus(*input.Status)
201 if err != nil {
202 return nil, shared.ErrorResult(err.Error())
203 }
204
205 parsed.Status = &status
206 }
207
208 if input.Priority != nil {
209 priority, err := lunatask.ParsePriority(*input.Priority)
210 if err != nil {
211 return nil, shared.ErrorResult(err.Error())
212 }
213
214 parsed.Priority = &priority
215 }
216
217 if input.Motivation != nil {
218 motivation, err := lunatask.ParseMotivation(*input.Motivation)
219 if err != nil {
220 return nil, shared.ErrorResult(err.Error())
221 }
222
223 parsed.Motivation = &motivation
224 }
225
226 if input.ScheduledOn != nil {
227 date, err := dateutil.Parse(*input.ScheduledOn)
228 if err != nil {
229 return nil, shared.ErrorResult(err.Error())
230 }
231
232 parsed.ScheduledOn = &date
233 }
234
235 return parsed, nil
236}
237
238//nolint:cyclop
239func applyToTaskUpdateBuilder(builder *lunatask.TaskUpdateBuilder, parsed *parsedTaskUpdateInput) {
240 if parsed.Name != nil {
241 builder.Name(*parsed.Name)
242 }
243
244 if parsed.AreaID != nil {
245 builder.InArea(*parsed.AreaID)
246 }
247
248 if parsed.GoalID != nil {
249 builder.InGoal(*parsed.GoalID)
250 }
251
252 if parsed.Status != nil {
253 builder.WithStatus(*parsed.Status)
254 }
255
256 if parsed.Note != nil {
257 builder.WithNote(*parsed.Note)
258 }
259
260 if parsed.Priority != nil {
261 builder.Priority(*parsed.Priority)
262 }
263
264 if parsed.Estimate != nil {
265 builder.WithEstimate(*parsed.Estimate)
266 }
267
268 if parsed.Motivation != nil {
269 builder.WithMotivation(*parsed.Motivation)
270 }
271
272 if parsed.Important != nil {
273 if *parsed.Important {
274 builder.Important()
275 } else {
276 builder.NotImportant()
277 }
278 }
279
280 if parsed.Urgent != nil {
281 if *parsed.Urgent {
282 builder.Urgent()
283 } else {
284 builder.NotUrgent()
285 }
286 }
287
288 if parsed.ScheduledOn != nil {
289 builder.ScheduledOn(*parsed.ScheduledOn)
290 }
291}
292
293func (h *Handler) updateNote(
294 ctx context.Context,
295 input UpdateInput,
296) (*mcp.CallToolResult, UpdateOutput, error) {
297 _, id, err := lunatask.ParseReference(input.ID)
298 if err != nil {
299 return shared.ErrorResult("invalid ID: expected UUID or lunatask:// deep link"),
300 UpdateOutput{Entity: input.Entity}, nil
301 }
302
303 if input.NotebookID != nil {
304 if err := lunatask.ValidateUUID(*input.NotebookID); err != nil {
305 return shared.ErrorResult("invalid notebook_id: expected UUID"),
306 UpdateOutput{Entity: input.Entity}, nil
307 }
308 }
309
310 builder := h.client.NewNoteUpdate(id)
311
312 if input.Name != nil {
313 builder.WithName(*input.Name)
314 }
315
316 if input.NotebookID != nil {
317 builder.InNotebook(*input.NotebookID)
318 }
319
320 if input.Content != nil {
321 builder.WithContent(*input.Content)
322 }
323
324 if input.Date != nil {
325 date, err := dateutil.Parse(*input.Date)
326 if err != nil {
327 return shared.ErrorResult(err.Error()), UpdateOutput{Entity: input.Entity}, nil
328 }
329
330 builder.OnDate(date)
331 }
332
333 note, err := builder.Update(ctx)
334 if err != nil {
335 return shared.ErrorResult(err.Error()), UpdateOutput{Entity: input.Entity}, nil
336 }
337
338 deepLink, _ := lunatask.BuildDeepLink(lunatask.ResourceNote, note.ID)
339
340 return &mcp.CallToolResult{
341 Content: []mcp.Content{&mcp.TextContent{
342 Text: "Note updated: " + deepLink,
343 }},
344 }, UpdateOutput{Entity: input.Entity, DeepLink: deepLink}, nil
345}
346
347func (h *Handler) updatePerson(
348 ctx context.Context,
349 input UpdateInput,
350) (*mcp.CallToolResult, UpdateOutput, error) {
351 _, id, err := lunatask.ParseReference(input.ID)
352 if err != nil {
353 return shared.ErrorResult("invalid ID: expected UUID or lunatask:// deep link"),
354 UpdateOutput{Entity: input.Entity}, nil
355 }
356
357 builder := h.client.NewPersonUpdate(id)
358
359 if input.FirstName != nil {
360 builder.FirstName(*input.FirstName)
361 }
362
363 if input.LastName != nil {
364 builder.LastName(*input.LastName)
365 }
366
367 if input.Relationship != nil {
368 rel, err := lunatask.ParseRelationshipStrength(*input.Relationship)
369 if err != nil {
370 return shared.ErrorResult(err.Error()), UpdateOutput{Entity: input.Entity}, nil
371 }
372
373 builder.WithRelationshipStrength(rel)
374 }
375
376 person, err := builder.Update(ctx)
377 if err != nil {
378 return shared.ErrorResult(err.Error()), UpdateOutput{Entity: input.Entity}, nil
379 }
380
381 deepLink, _ := lunatask.BuildDeepLink(lunatask.ResourcePerson, person.ID)
382
383 return &mcp.CallToolResult{
384 Content: []mcp.Content{&mcp.TextContent{
385 Text: "Person updated: " + deepLink,
386 }},
387 }, UpdateOutput{Entity: input.Entity, DeepLink: deepLink}, nil
388}