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