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	"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}