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