update.go

  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}