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