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