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