From c5d2d01f2b5937a640e6795f6b7d867bd91a45c2 Mon Sep 17 00:00:00 2001 From: Amolith Date: Fri, 19 Dec 2025 14:24:35 -0700 Subject: [PATCH] refactor: use direct Create/Update on builders - Add generic CRUD helpers in crud.go to reduce duplication - Move builders from builders.go into their respective domain files - Builders now call Create/Update directly instead of returning request structs - Make request types unexported (createTaskRequest, etc.) - Update examples in README and AGENTS.md Assisted-by: Claude Sonnet 4 via Crush --- AGENTS.md | 2 +- README.md | 6 +- builders.go | 406 ---------------------------------------------------- crud.go | 118 +++++++++++++++ journal.go | 41 +++++- notes.go | 175 +++++++++++++--------- people.go | 132 +++++++++-------- tasks.go | 277 ++++++++++++++++++++++++++--------- timeline.go | 47 ++++-- 9 files changed, 585 insertions(+), 619 deletions(-) delete mode 100644 builders.go create mode 100644 crud.go diff --git a/AGENTS.md b/AGENTS.md index 8e53a7742cf71e8b7dabfc60e7ccf464f111e530..b1863818367c03db05aa3864317c91f956c3dfb9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -24,7 +24,7 @@ task # Include vulnerability and copyright checks Create methods return `(nil, nil)` when a matching entity already exists (HTTP 204). This is intentional API behavior, not an error: ```go -task, err := client.CreateTask(ctx, req) +task, err := lunatask.NewTask("Review PR").Create(ctx, client) if err != nil { return err // actual error } diff --git a/README.md b/README.md index 47a92ef23e5ea2594d51edb157cc0ec9ffd7c5f8..1d84c2c67352bf01279db1469e90b3f2d7b976d8 100644 --- a/README.md +++ b/README.md @@ -52,9 +52,7 @@ func main() { } // Create a task - task, err := client.CreateTask(context.Background(), &lunatask.CreateTaskRequest{ - Name: "Review pull requests", - }) + task, err := lunatask.NewTask("Review pull requests").Create(context.Background(), client) if err != nil { log.Fatal(err) } @@ -71,7 +69,7 @@ exists. This is intentional API behavior on Lunatask's part because of its end-to-end encryption. ```go -task, err := client.CreateTask(ctx, req) +task, err := lunatask.NewTask("Review PR").Create(ctx, client) if err != nil { return err // actual error } diff --git a/builders.go b/builders.go deleted file mode 100644 index 9f9d558a1446b25ac3a9f7d799a5b6a4f599d339..0000000000000000000000000000000000000000 --- a/builders.go +++ /dev/null @@ -1,406 +0,0 @@ -// SPDX-FileCopyrightText: Amolith -// -// SPDX-License-Identifier: AGPL-3.0-or-later - -package lunatask - -import "time" - -// TaskBuilder constructs a [CreateTaskRequest] via method chaining. -// -// req := lunatask.NewTask("Review PR"). -// InArea(areaID). -// WithStatus(lunatask.StatusNext). -// WithEstimate(30). -// Build() -// task, err := client.CreateTask(ctx, req) -type TaskBuilder struct { - req CreateTaskRequest -} - -// NewTask starts building a task with the given name. -func NewTask(name string) *TaskBuilder { - return &TaskBuilder{req: CreateTaskRequest{Name: name}} //nolint:exhaustruct -} - -// InArea assigns the task to an area. IDs are in the area's settings in the app. -func (b *TaskBuilder) InArea(areaID string) *TaskBuilder { - b.req.AreaID = &areaID - - return b -} - -// InGoal assigns the task to a goal. IDs are in the goal's settings in the app. -func (b *TaskBuilder) InGoal(goalID string) *TaskBuilder { - b.req.GoalID = &goalID - - return b -} - -// WithNote attaches a Markdown note to the task. -func (b *TaskBuilder) WithNote(note string) *TaskBuilder { - b.req.Note = ¬e - - return b -} - -// WithStatus sets the workflow status. -// Use one of the Status* constants (e.g., [StatusNext]). -func (b *TaskBuilder) WithStatus(status TaskStatus) *TaskBuilder { - b.req.Status = &status - - return b -} - -// WithMotivation sets why this task matters. -// Use one of the Motivation* constants (e.g., [MotivationMust]). -func (b *TaskBuilder) WithMotivation(motivation Motivation) *TaskBuilder { - b.req.Motivation = &motivation - - return b -} - -// WithEstimate sets the expected duration in minutes (0–720). -func (b *TaskBuilder) WithEstimate(minutes int) *TaskBuilder { - b.req.Estimate = &minutes - - return b -} - -// WithPriority sets importance from -2 (lowest) to 2 (highest). -func (b *TaskBuilder) WithPriority(priority int) *TaskBuilder { - b.req.Priority = &priority - - return b -} - -// WithEisenhower sets the matrix quadrant (0–4). -func (b *TaskBuilder) WithEisenhower(eisenhower int) *TaskBuilder { - b.req.Eisenhower = &eisenhower - - return b -} - -// ScheduledOn sets when the task should appear on your schedule. -func (b *TaskBuilder) ScheduledOn(date Date) *TaskBuilder { - b.req.ScheduledOn = &date - - return b -} - -// CompletedAt marks the task completed at a specific time. -func (b *TaskBuilder) CompletedAt(t time.Time) *TaskBuilder { - b.req.CompletedAt = &t - - return b -} - -// FromSource tags the task with a free-form origin identifier, useful for -// tracking tasks created by scripts or external integrations. -func (b *TaskBuilder) FromSource(source, sourceID string) *TaskBuilder { - b.req.Source = &source - b.req.SourceID = &sourceID - - return b -} - -// Build returns the constructed request. -func (b *TaskBuilder) Build() *CreateTaskRequest { - return &b.req -} - -// TaskUpdateBuilder constructs an [UpdateTaskRequest] via method chaining. -// Only fields you set will be modified; others remain unchanged. -// -// req := lunatask.NewTaskUpdate(). -// WithStatus(lunatask.StatusCompleted). -// CompletedAt(time.Now()). -// Build() -// task, err := client.UpdateTask(ctx, taskID, req) -type TaskUpdateBuilder struct { - req UpdateTaskRequest -} - -// NewTaskUpdate starts building a task update. -func NewTaskUpdate() *TaskUpdateBuilder { - return &TaskUpdateBuilder{} //nolint:exhaustruct -} - -// Name changes the task's name. -func (b *TaskUpdateBuilder) Name(name string) *TaskUpdateBuilder { - b.req.Name = &name - - return b -} - -// InArea moves the task to an area. IDs are in the area's settings in the app. -func (b *TaskUpdateBuilder) InArea(areaID string) *TaskUpdateBuilder { - b.req.AreaID = &areaID - - return b -} - -// InGoal moves the task to a goal. IDs are in the goal's settings in the app. -func (b *TaskUpdateBuilder) InGoal(goalID string) *TaskUpdateBuilder { - b.req.GoalID = &goalID - - return b -} - -// WithNote replaces the task's Markdown note. -func (b *TaskUpdateBuilder) WithNote(note string) *TaskUpdateBuilder { - b.req.Note = ¬e - - return b -} - -// WithStatus sets the workflow status. -// Use one of the Status* constants (e.g., [StatusNext]). -func (b *TaskUpdateBuilder) WithStatus(status TaskStatus) *TaskUpdateBuilder { - b.req.Status = &status - - return b -} - -// WithMotivation sets why this task matters. -// Use one of the Motivation* constants (e.g., [MotivationMust]). -func (b *TaskUpdateBuilder) WithMotivation(motivation Motivation) *TaskUpdateBuilder { - b.req.Motivation = &motivation - - return b -} - -// WithEstimate sets the expected duration in minutes (0–720). -func (b *TaskUpdateBuilder) WithEstimate(minutes int) *TaskUpdateBuilder { - b.req.Estimate = &minutes - - return b -} - -// WithPriority sets importance from -2 (lowest) to 2 (highest). -func (b *TaskUpdateBuilder) WithPriority(priority int) *TaskUpdateBuilder { - b.req.Priority = &priority - - return b -} - -// WithEisenhower sets the matrix quadrant (0–4). -func (b *TaskUpdateBuilder) WithEisenhower(eisenhower int) *TaskUpdateBuilder { - b.req.Eisenhower = &eisenhower - - return b -} - -// ScheduledOn sets when the task should appear on your schedule. -func (b *TaskUpdateBuilder) ScheduledOn(date Date) *TaskUpdateBuilder { - b.req.ScheduledOn = &date - - return b -} - -// CompletedAt marks the task completed at a specific time. -func (b *TaskUpdateBuilder) CompletedAt(t time.Time) *TaskUpdateBuilder { - b.req.CompletedAt = &t - - return b -} - -// Build returns the constructed request. -func (b *TaskUpdateBuilder) Build() *UpdateTaskRequest { - return &b.req -} - -// NoteBuilder constructs a [CreateNoteRequest] via method chaining. -// Note fields are encrypted client-side by Lunatask; the API accepts them -// on create but returns null on read. -// -// req := lunatask.NewNote(). -// WithName("Meeting notes"). -// WithContent("# Summary\n\n..."). -// InNotebook(notebookID). -// Build() -// note, err := client.CreateNote(ctx, req) -type NoteBuilder struct { - req CreateNoteRequest -} - -// NewNote starts building a note. -func NewNote() *NoteBuilder { - return &NoteBuilder{} //nolint:exhaustruct -} - -// WithName sets the note's title. -func (b *NoteBuilder) WithName(name string) *NoteBuilder { - b.req.Name = &name - - return b -} - -// WithContent sets the Markdown body. -func (b *NoteBuilder) WithContent(content string) *NoteBuilder { - b.req.Content = &content - - return b -} - -// InNotebook places the note in a notebook. IDs are in the notebook's settings in the app. -func (b *NoteBuilder) InNotebook(notebookID string) *NoteBuilder { - b.req.NotebookID = ¬ebookID - - return b -} - -// FromSource tags the note with a free-form origin identifier, useful for -// tracking notes created by scripts or external integrations. -func (b *NoteBuilder) FromSource(source, sourceID string) *NoteBuilder { - b.req.Source = &source - b.req.SourceID = &sourceID - - return b -} - -// Build returns the constructed request. -func (b *NoteBuilder) Build() *CreateNoteRequest { - return &b.req -} - -// JournalEntryBuilder constructs a [CreateJournalEntryRequest] via method chaining. -// Journal content is encrypted client-side; the API accepts it on create but -// returns null on read. -// -// req := lunatask.NewJournalEntry(lunatask.Today()). -// WithContent("Shipped the new feature!"). -// Build() -// entry, err := client.CreateJournalEntry(ctx, req) -type JournalEntryBuilder struct { - req CreateJournalEntryRequest -} - -// NewJournalEntry starts building a journal entry for the given date. -func NewJournalEntry(date Date) *JournalEntryBuilder { - return &JournalEntryBuilder{req: CreateJournalEntryRequest{DateOn: date}} //nolint:exhaustruct -} - -// WithName sets the entry's title. Defaults to the weekday name if omitted. -func (b *JournalEntryBuilder) WithName(name string) *JournalEntryBuilder { - b.req.Name = &name - - return b -} - -// WithContent sets the Markdown body. -func (b *JournalEntryBuilder) WithContent(content string) *JournalEntryBuilder { - b.req.Content = &content - - return b -} - -// Build returns the constructed request. -func (b *JournalEntryBuilder) Build() *CreateJournalEntryRequest { - return &b.req -} - -// PersonBuilder constructs a [CreatePersonRequest] via method chaining. -// Name fields are encrypted client-side; the API accepts them on create but -// returns null on read. -// -// req := lunatask.NewPerson(). -// WithFirstName("Ada"). -// WithLastName("Lovelace"). -// WithRelationshipStrength(lunatask.RelationshipCloseFriend). -// Build() -// person, err := client.CreatePerson(ctx, req) -type PersonBuilder struct { - req CreatePersonRequest -} - -// NewPerson starts building a person entry. -func NewPerson() *PersonBuilder { - return &PersonBuilder{} //nolint:exhaustruct -} - -// WithFirstName sets the person's first name. -func (b *PersonBuilder) WithFirstName(name string) *PersonBuilder { - b.req.FirstName = &name - - return b -} - -// WithLastName sets the person's last name. -func (b *PersonBuilder) WithLastName(name string) *PersonBuilder { - b.req.LastName = &name - - return b -} - -// WithRelationshipStrength categorizes the closeness of the relationship. -// Use one of the Relationship* constants (e.g., [RelationshipCloseFriend]). -func (b *PersonBuilder) WithRelationshipStrength(strength RelationshipStrength) *PersonBuilder { - b.req.RelationshipStrength = &strength - - return b -} - -// FromSource tags the person with a free-form origin identifier, useful for -// tracking entries created by scripts or external integrations. -func (b *PersonBuilder) FromSource(source, sourceID string) *PersonBuilder { - b.req.Source = &source - b.req.SourceID = &sourceID - - return b -} - -// WithCustomField sets an arbitrary custom field. Lunatask supports "email", -// "birthday", and "phone" out of the box; other fields must first be defined -// in the app or [ErrUnprocessableEntity] is returned. -func (b *PersonBuilder) WithCustomField(key string, value any) *PersonBuilder { - if b.req.CustomFields == nil { - b.req.CustomFields = make(map[string]any) - } - - b.req.CustomFields[key] = value - - return b -} - -// Build returns the constructed request. -func (b *PersonBuilder) Build() *CreatePersonRequest { - return &b.req -} - -// TimelineNoteBuilder constructs a [CreatePersonTimelineNoteRequest] via method chaining. -// Content is encrypted client-side; the API accepts it on create but returns null on read. -// -// req := lunatask.NewTimelineNote(personID). -// OnDate(lunatask.Today()). -// WithContent("Had coffee, discussed the project."). -// Build() -// note, err := client.CreatePersonTimelineNote(ctx, req) -type TimelineNoteBuilder struct { - req CreatePersonTimelineNoteRequest -} - -// NewTimelineNote starts building a timeline note for the given person. -// Get person IDs from [Client.ListPeople]. -func NewTimelineNote(personID string) *TimelineNoteBuilder { - return &TimelineNoteBuilder{req: CreatePersonTimelineNoteRequest{PersonID: personID}} //nolint:exhaustruct -} - -// OnDate sets when this interaction occurred. -func (b *TimelineNoteBuilder) OnDate(date Date) *TimelineNoteBuilder { - b.req.DateOn = &date - - return b -} - -// WithContent sets the Markdown body describing the interaction. -func (b *TimelineNoteBuilder) WithContent(content string) *TimelineNoteBuilder { - b.req.Content = &content - - return b -} - -// Build returns the constructed request. -func (b *TimelineNoteBuilder) Build() *CreatePersonTimelineNoteRequest { - return &b.req -} diff --git a/crud.go b/crud.go new file mode 100644 index 0000000000000000000000000000000000000000..e0a22f06d666d67614d44859739cf9fe446d9d49 --- /dev/null +++ b/crud.go @@ -0,0 +1,118 @@ +// SPDX-FileCopyrightText: Amolith +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +package lunatask + +import ( + "context" + "fmt" + "net/http" + "net/url" +) + +// unwrap extracts an entity from a response wrapper. +type unwrap[E, R any] func(R) E + +// unwrapSlice extracts a slice of entities from a response wrapper. +type unwrapSlice[E, R any] func(R) []E + +// SourceFilter provides optional source filtering for list operations. +type SourceFilter interface { + GetSource() *string + GetSourceID() *string +} + +// get fetches a single entity by ID. +func get[E, R any](ctx context.Context, c *Client, path, id, name string, extract unwrap[E, R]) (*E, error) { + if id == "" { + return nil, fmt.Errorf("%w: %s ID cannot be empty", ErrBadRequest, name) + } + + resp, _, err := doJSON[R](ctx, c, http.MethodGet, path+"/"+id, nil) + if err != nil { + return nil, err + } + + e := extract(*resp) + + return &e, nil +} + +// list fetches all entities, optionally filtered by source. +func list[E, R any]( + ctx context.Context, c *Client, path string, opts SourceFilter, extract unwrapSlice[E, R], +) ([]E, error) { + if opts != nil { + params := url.Values{} + + if s := opts.GetSource(); s != nil && *s != "" { + params.Set("source", *s) + } + + if s := opts.GetSourceID(); s != nil && *s != "" { + params.Set("source_id", *s) + } + + if len(params) > 0 { + path = fmt.Sprintf("%s?%s", path, params.Encode()) + } + } + + resp, _, err := doJSON[R](ctx, c, http.MethodGet, path, nil) + if err != nil { + return nil, err + } + + return extract(*resp), nil +} + +// create adds a new entity. Returns (nil, nil) if duplicate exists (HTTP 204). +func create[E, R any](ctx context.Context, c *Client, path string, body any, extract unwrap[E, R]) (*E, error) { + resp, noContent, err := doJSON[R](ctx, c, http.MethodPost, path, body) + if err != nil { + return nil, err + } + + if noContent { + return nil, nil //nolint:nilnil + } + + e := extract(*resp) + + return &e, nil +} + +// update modifies an entity by ID. +func update[E, R any]( + ctx context.Context, c *Client, path, id, name string, body any, extract unwrap[E, R], +) (*E, error) { + if id == "" { + return nil, fmt.Errorf("%w: %s ID cannot be empty", ErrBadRequest, name) + } + + resp, _, err := doJSON[R](ctx, c, http.MethodPut, path+"/"+id, body) + if err != nil { + return nil, err + } + + e := extract(*resp) + + return &e, nil +} + +// del removes an entity by ID and returns its final state. +func del[E, R any](ctx context.Context, c *Client, path, id, name string, extract unwrap[E, R]) (*E, error) { + if id == "" { + return nil, fmt.Errorf("%w: %s ID cannot be empty", ErrBadRequest, name) + } + + resp, _, err := doJSON[R](ctx, c, http.MethodDelete, path+"/"+id, nil) + if err != nil { + return nil, err + } + + e := extract(*resp) + + return &e, nil +} diff --git a/journal.go b/journal.go index e55530001d2348ce4d1399663e8e026c953aa23a..6321e99fa4a579558bd155bbe8564c8edaf5b58a 100644 --- a/journal.go +++ b/journal.go @@ -19,9 +19,8 @@ type JournalEntry struct { UpdatedAt time.Time `json:"updated_at"` } -// CreateJournalEntryRequest defines a new journal entry. -// Use [JournalEntryBuilder] for a fluent construction API. -type CreateJournalEntryRequest struct { +// createJournalEntryRequest defines a new journal entry for JSON serialization. +type createJournalEntryRequest struct { // DateOn is the date for the journal entry (required). DateOn Date `json:"date_on"` // Name is the title for the entry (optional, defaults to weekday name like "Tuesday, July 1st"). @@ -35,10 +34,40 @@ type journalEntryResponse struct { JournalEntry JournalEntry `json:"journal_entry"` } -// CreateJournalEntry adds a journal entry. Returns the created entry's metadata; +// JournalEntryBuilder constructs and creates a journal entry via method chaining. +// Journal content is encrypted client-side; the API accepts it on create but +// returns null on read. +// +// entry, err := lunatask.NewJournalEntry(lunatask.Today()). +// WithContent("Shipped the new feature!"). +// Create(ctx, client) +type JournalEntryBuilder struct { + req createJournalEntryRequest +} + +// NewJournalEntry starts building a journal entry for the given date. +func NewJournalEntry(date Date) *JournalEntryBuilder { + return &JournalEntryBuilder{req: createJournalEntryRequest{DateOn: date}} //nolint:exhaustruct +} + +// WithName sets the entry's title. Defaults to the weekday name if omitted. +func (b *JournalEntryBuilder) WithName(name string) *JournalEntryBuilder { + b.req.Name = &name + + return b +} + +// WithContent sets the Markdown body. +func (b *JournalEntryBuilder) WithContent(content string) *JournalEntryBuilder { + b.req.Content = &content + + return b +} + +// Create sends the journal entry to Lunatask. Returns the created entry's metadata; // Name and Content won't round-trip due to E2EE. -func (c *Client) CreateJournalEntry(ctx context.Context, entry *CreateJournalEntryRequest) (*JournalEntry, error) { - resp, _, err := doJSON[journalEntryResponse](ctx, c, http.MethodPost, "/journal_entries", entry) +func (b *JournalEntryBuilder) Create(ctx context.Context, c *Client) (*JournalEntry, error) { + resp, _, err := doJSON[journalEntryResponse](ctx, c, http.MethodPost, "/journal_entries", b.req) if err != nil { return nil, err } diff --git a/notes.go b/notes.go index 4168991e3e460ef24c8fc17a8bebdcb7571bdc45..81d1d3961dc086508f1faa8b2f0349365d6fce0f 100644 --- a/notes.go +++ b/notes.go @@ -6,9 +6,6 @@ package lunatask import ( "context" - "fmt" - "net/http" - "net/url" "time" ) @@ -23,9 +20,8 @@ type Note struct { UpdatedAt time.Time `json:"updated_at"` } -// CreateNoteRequest defines a new note. -// Use [NoteBuilder] for a fluent construction API. -type CreateNoteRequest struct { +// createNoteRequest defines a new note for JSON serialization. +type createNoteRequest struct { Name *string `json:"name,omitempty"` Content *string `json:"content,omitempty"` NotebookID *string `json:"notebook_id,omitempty"` @@ -33,9 +29,8 @@ type CreateNoteRequest struct { SourceID *string `json:"source_id,omitempty"` } -// UpdateNoteRequest specifies which fields to change on a note. -// Only non-nil fields are updated. Content replaces entirely (E2EE prevents appending). -type UpdateNoteRequest struct { +// updateNoteRequest specifies which fields to change on a note. +type updateNoteRequest struct { Name *string `json:"name,omitempty"` Content *string `json:"content,omitempty"` NotebookID *string `json:"notebook_id,omitempty"` @@ -58,87 +53,131 @@ type ListNotesOptions struct { SourceID *string } +// GetSource implements [SourceFilter]. +func (o *ListNotesOptions) GetSource() *string { return o.Source } + +// GetSourceID implements [SourceFilter]. +func (o *ListNotesOptions) GetSourceID() *string { return o.SourceID } + // ListNotes returns all notes, optionally filtered. Pass nil for all notes. func (c *Client) ListNotes(ctx context.Context, opts *ListNotesOptions) ([]Note, error) { - path := "/notes" - + var filter SourceFilter if opts != nil { - params := url.Values{} - if opts.Source != nil && *opts.Source != "" { - params.Set("source", *opts.Source) - } - - if opts.SourceID != nil && *opts.SourceID != "" { - params.Set("source_id", *opts.SourceID) - } - - if len(params) > 0 { - path = fmt.Sprintf("%s?%s", path, params.Encode()) - } + filter = opts } - resp, _, err := doJSON[notesResponse](ctx, c, http.MethodGet, path, nil) - if err != nil { - return nil, err - } - - return resp.Notes, nil + return list(ctx, c, "/notes", filter, func(r notesResponse) []Note { return r.Notes }) } // GetNote fetches a note by ID. Name and Content will be null (E2EE). func (c *Client) GetNote(ctx context.Context, noteID string) (*Note, error) { - if noteID == "" { - return nil, fmt.Errorf("%w: note ID cannot be empty", ErrBadRequest) - } + return get(ctx, c, "/notes", noteID, "note", func(r noteResponse) Note { return r.Note }) +} - resp, _, err := doJSON[noteResponse](ctx, c, http.MethodGet, "/notes/"+noteID, nil) - if err != nil { - return nil, err - } +// DeleteNote removes a note and returns its final state. +func (c *Client) DeleteNote(ctx context.Context, noteID string) (*Note, error) { + return del(ctx, c, "/notes", noteID, "note", func(r noteResponse) Note { return r.Note }) +} - return &resp.Note, nil +// NoteBuilder constructs and creates a note via method chaining. +// Note fields are encrypted client-side by Lunatask; the API accepts them +// on create but returns null on read. +// +// note, err := lunatask.NewNote(). +// WithName("Meeting notes"). +// WithContent("# Summary\n\n..."). +// InNotebook(notebookID). +// Create(ctx, client) +type NoteBuilder struct { + req createNoteRequest +} + +// NewNote starts building a note. +func NewNote() *NoteBuilder { + return &NoteBuilder{} //nolint:exhaustruct +} + +// WithName sets the note's title. +func (b *NoteBuilder) WithName(name string) *NoteBuilder { + b.req.Name = &name + + return b } -// CreateNote adds a note. Returns (nil, nil) if a duplicate exists +// WithContent sets the Markdown body. +func (b *NoteBuilder) WithContent(content string) *NoteBuilder { + b.req.Content = &content + + return b +} + +// InNotebook places the note in a notebook. IDs are in the notebook's settings in the app. +func (b *NoteBuilder) InNotebook(notebookID string) *NoteBuilder { + b.req.NotebookID = ¬ebookID + + return b +} + +// FromSource tags the note with a free-form origin identifier, useful for +// tracking notes created by scripts or external integrations. +func (b *NoteBuilder) FromSource(source, sourceID string) *NoteBuilder { + b.req.Source = &source + b.req.SourceID = &sourceID + + return b +} + +// Create sends the note to Lunatask. Returns (nil, nil) if a duplicate exists // in the same notebook with matching source/source_id. -func (c *Client) CreateNote(ctx context.Context, note *CreateNoteRequest) (*Note, error) { - resp, noContent, err := doJSON[noteResponse](ctx, c, http.MethodPost, "/notes", note) - if err != nil { - return nil, err - } +func (b *NoteBuilder) Create(ctx context.Context, c *Client) (*Note, error) { + return create(ctx, c, "/notes", b.req, func(r noteResponse) Note { return r.Note }) +} - if noContent { - // Intentional: duplicate exists (HTTP 204), not an error - return nil, nil //nolint:nilnil - } +// NoteUpdateBuilder constructs and updates a note via method chaining. +// Only fields you set will be modified; others remain unchanged. +// +// note, err := lunatask.NewNoteUpdate(noteID). +// WithContent("# Updated content"). +// Update(ctx, client) +type NoteUpdateBuilder struct { + noteID string + req updateNoteRequest +} - return &resp.Note, nil +// NewNoteUpdate starts building a note update for the given note ID. +func NewNoteUpdate(noteID string) *NoteUpdateBuilder { + return &NoteUpdateBuilder{noteID: noteID} //nolint:exhaustruct } -// UpdateNote modifies a note. Only non-nil fields in the request are changed. -func (c *Client) UpdateNote(ctx context.Context, noteID string, note *UpdateNoteRequest) (*Note, error) { - if noteID == "" { - return nil, fmt.Errorf("%w: note ID cannot be empty", ErrBadRequest) - } +// WithName sets the note's title. +func (b *NoteUpdateBuilder) WithName(name string) *NoteUpdateBuilder { + b.req.Name = &name - resp, _, err := doJSON[noteResponse](ctx, c, http.MethodPut, "/notes/"+noteID, note) - if err != nil { - return nil, err - } + return b +} + +// WithContent sets the Markdown body. +func (b *NoteUpdateBuilder) WithContent(content string) *NoteUpdateBuilder { + b.req.Content = &content - return &resp.Note, nil + return b } -// DeleteNote removes a note and returns its final state. -func (c *Client) DeleteNote(ctx context.Context, noteID string) (*Note, error) { - if noteID == "" { - return nil, fmt.Errorf("%w: note ID cannot be empty", ErrBadRequest) - } +// InNotebook moves the note to a notebook. +func (b *NoteUpdateBuilder) InNotebook(notebookID string) *NoteUpdateBuilder { + b.req.NotebookID = ¬ebookID - resp, _, err := doJSON[noteResponse](ctx, c, http.MethodDelete, "/notes/"+noteID, nil) - if err != nil { - return nil, err - } + return b +} + +// OnDate sets the date for the note. +func (b *NoteUpdateBuilder) OnDate(date Date) *NoteUpdateBuilder { + b.req.DateOn = &date + + return b +} - return &resp.Note, nil +// Update sends the changes to Lunatask. +func (b *NoteUpdateBuilder) Update(ctx context.Context, c *Client) (*Note, error) { + return update(ctx, c, "/notes", b.noteID, "note", b.req, func(r noteResponse) Note { return r.Note }) } diff --git a/people.go b/people.go index b89265b55045860168bede28999bb090cb4087d3..9ba6ec6c4ac904a2b0973da845cba7822a6c595b 100644 --- a/people.go +++ b/people.go @@ -9,8 +9,6 @@ import ( "encoding/json" "fmt" "maps" - "net/http" - "net/url" "time" ) @@ -24,14 +22,13 @@ type Person struct { UpdatedAt time.Time `json:"updated_at"` } -// CreatePersonRequest defines a new person. -// Use [PersonBuilder] for a fluent construction API. +// createPersonRequest defines a new person for JSON serialization. // // CustomFields allows setting arbitrary custom fields. Lunatask supports // "email", "birthday", and "phone" out of the box; other fields must first be // defined in the app or [ErrUnprocessableEntity] is returned. These are // flattened to top-level JSON fields when marshaled. -type CreatePersonRequest struct { +type createPersonRequest struct { FirstName *string `json:"first_name,omitempty"` LastName *string `json:"last_name,omitempty"` RelationshipStrength *RelationshipStrength `json:"relationship_strength,omitempty"` @@ -41,9 +38,9 @@ type CreatePersonRequest struct { } // MarshalJSON flattens CustomFields to top-level JSON fields. -func (r CreatePersonRequest) MarshalJSON() ([]byte, error) { +func (r createPersonRequest) MarshalJSON() ([]byte, error) { // Alias to avoid infinite recursion - type plain CreatePersonRequest + type plain createPersonRequest base, err := json.Marshal(plain(r)) if err != nil { @@ -86,73 +83,96 @@ type ListPeopleOptions struct { SourceID *string } +// GetSource implements [SourceFilter]. +func (o *ListPeopleOptions) GetSource() *string { return o.Source } + +// GetSourceID implements [SourceFilter]. +func (o *ListPeopleOptions) GetSourceID() *string { return o.SourceID } + // ListPeople returns all people, optionally filtered. Pass nil for all. func (c *Client) ListPeople(ctx context.Context, opts *ListPeopleOptions) ([]Person, error) { - path := "/people" - + var filter SourceFilter if opts != nil { - params := url.Values{} - if opts.Source != nil && *opts.Source != "" { - params.Set("source", *opts.Source) - } - - if opts.SourceID != nil && *opts.SourceID != "" { - params.Set("source_id", *opts.SourceID) - } - - if len(params) > 0 { - path = fmt.Sprintf("%s?%s", path, params.Encode()) - } + filter = opts } - resp, _, err := doJSON[peopleResponse](ctx, c, http.MethodGet, path, nil) - if err != nil { - return nil, err - } - - return resp.People, nil + return list(ctx, c, "/people", filter, func(r peopleResponse) []Person { return r.People }) } // GetPerson fetches a person by ID. Name fields will be null (E2EE). func (c *Client) GetPerson(ctx context.Context, personID string) (*Person, error) { - if personID == "" { - return nil, fmt.Errorf("%w: person ID cannot be empty", ErrBadRequest) - } + return get(ctx, c, "/people", personID, "person", func(r personResponse) Person { return r.Person }) +} - resp, _, err := doJSON[personResponse](ctx, c, http.MethodGet, "/people/"+personID, nil) - if err != nil { - return nil, err - } +// DeletePerson removes a person and returns their final state. +func (c *Client) DeletePerson(ctx context.Context, personID string) (*Person, error) { + return del(ctx, c, "/people", personID, "person", func(r personResponse) Person { return r.Person }) +} - return &resp.Person, nil +// PersonBuilder constructs and creates a person via method chaining. +// Name fields are encrypted client-side; the API accepts them on create but +// returns null on read. +// +// person, err := lunatask.NewPerson(). +// WithFirstName("Ada"). +// WithLastName("Lovelace"). +// WithRelationshipStrength(lunatask.RelationshipCloseFriend). +// Create(ctx, client) +type PersonBuilder struct { + req createPersonRequest } -// CreatePerson adds a person. Returns (nil, nil) if a duplicate exists -// with matching source/source_id. -func (c *Client) CreatePerson(ctx context.Context, person *CreatePersonRequest) (*Person, error) { - resp, noContent, err := doJSON[personResponse](ctx, c, http.MethodPost, "/people", person) - if err != nil { - return nil, err - } +// NewPerson starts building a person entry. +func NewPerson() *PersonBuilder { + return &PersonBuilder{} //nolint:exhaustruct +} - if noContent { - // Intentional: duplicate exists (HTTP 204), not an error - return nil, nil //nolint:nilnil - } +// WithFirstName sets the person's first name. +func (b *PersonBuilder) WithFirstName(name string) *PersonBuilder { + b.req.FirstName = &name - return &resp.Person, nil + return b } -// DeletePerson removes a person and returns their final state. -func (c *Client) DeletePerson(ctx context.Context, personID string) (*Person, error) { - if personID == "" { - return nil, fmt.Errorf("%w: person ID cannot be empty", ErrBadRequest) - } +// WithLastName sets the person's last name. +func (b *PersonBuilder) WithLastName(name string) *PersonBuilder { + b.req.LastName = &name - resp, _, err := doJSON[personResponse](ctx, c, http.MethodDelete, "/people/"+personID, nil) - if err != nil { - return nil, err + return b +} + +// WithRelationshipStrength categorizes the closeness of the relationship. +// Use one of the Relationship* constants (e.g., [RelationshipCloseFriend]). +func (b *PersonBuilder) WithRelationshipStrength(strength RelationshipStrength) *PersonBuilder { + b.req.RelationshipStrength = &strength + + return b +} + +// FromSource tags the person with a free-form origin identifier, useful for +// tracking entries created by scripts or external integrations. +func (b *PersonBuilder) FromSource(source, sourceID string) *PersonBuilder { + b.req.Source = &source + b.req.SourceID = &sourceID + + return b +} + +// WithCustomField sets an arbitrary custom field. Lunatask supports "email", +// "birthday", and "phone" out of the box; other fields must first be defined +// in the app or [ErrUnprocessableEntity] is returned. +func (b *PersonBuilder) WithCustomField(key string, value any) *PersonBuilder { + if b.req.CustomFields == nil { + b.req.CustomFields = make(map[string]any) } - return &resp.Person, nil + b.req.CustomFields[key] = value + + return b +} + +// Create sends the person to Lunatask. Returns (nil, nil) if a duplicate exists +// with matching source/source_id. +func (b *PersonBuilder) Create(ctx context.Context, c *Client) (*Person, error) { + return create(ctx, c, "/people", b.req, func(r personResponse) Person { return r.Person }) } diff --git a/tasks.go b/tasks.go index 4cfe735cd9421a8e78cfd223f1276f87439d5edc..2b40b15a651177fdbe89e07fdcc261340e91069c 100644 --- a/tasks.go +++ b/tasks.go @@ -7,8 +7,6 @@ package lunatask import ( "context" "fmt" - "net/http" - "net/url" "time" ) @@ -34,9 +32,8 @@ type Task struct { UpdatedAt time.Time `json:"updated_at"` } -// CreateTaskRequest defines a new task. -// Use [TaskBuilder] for a fluent construction API. -type CreateTaskRequest struct { +// createTaskRequest defines a new task for JSON serialization. +type createTaskRequest struct { Name string `json:"name"` AreaID *string `json:"area_id,omitempty"` GoalID *string `json:"goal_id,omitempty"` @@ -52,9 +49,8 @@ type CreateTaskRequest struct { SourceID *string `json:"source_id,omitempty"` } -// UpdateTaskRequest specifies which fields to change on a task. -// Only non-nil fields are updated. Use [TaskUpdateBuilder] for fluent construction. -type UpdateTaskRequest struct { +// updateTaskRequest specifies which fields to change on a task. +type updateTaskRequest struct { Name *string `json:"name,omitempty"` AreaID *string `json:"area_id,omitempty"` GoalID *string `json:"goal_id,omitempty"` @@ -84,91 +80,236 @@ type ListTasksOptions struct { SourceID *string } +// GetSource implements [SourceFilter]. +func (o *ListTasksOptions) GetSource() *string { return o.Source } + +// GetSourceID implements [SourceFilter]. +func (o *ListTasksOptions) GetSourceID() *string { return o.SourceID } + // ListTasks returns all tasks, optionally filtered. Pass nil for all. func (c *Client) ListTasks(ctx context.Context, opts *ListTasksOptions) ([]Task, error) { - path := "/tasks" - + var filter SourceFilter if opts != nil { - params := url.Values{} - if opts.Source != nil && *opts.Source != "" { - params.Set("source", *opts.Source) - } - - if opts.SourceID != nil && *opts.SourceID != "" { - params.Set("source_id", *opts.SourceID) - } - - if len(params) > 0 { - path = fmt.Sprintf("%s?%s", path, params.Encode()) - } + filter = opts } - resp, _, err := doJSON[tasksResponse](ctx, c, http.MethodGet, path, nil) - if err != nil { - return nil, err - } - - return resp.Tasks, nil + return list(ctx, c, "/tasks", filter, func(r tasksResponse) []Task { return r.Tasks }) } // GetTask fetches a task by ID. Name and Note will be null (E2EE). func (c *Client) GetTask(ctx context.Context, taskID string) (*Task, error) { - if taskID == "" { - return nil, fmt.Errorf("%w: task ID cannot be empty", ErrBadRequest) - } + return get(ctx, c, "/tasks", taskID, "task", func(r taskResponse) Task { return r.Task }) +} - resp, _, err := doJSON[taskResponse](ctx, c, http.MethodGet, "/tasks/"+taskID, nil) - if err != nil { - return nil, err - } +// DeleteTask removes a task and returns its final state. +func (c *Client) DeleteTask(ctx context.Context, taskID string) (*Task, error) { + return del(ctx, c, "/tasks", taskID, "task", func(r taskResponse) Task { return r.Task }) +} - return &resp.Task, nil +// TaskBuilder constructs and creates a task via method chaining. +// +// task, err := lunatask.NewTask("Review PR"). +// InArea(areaID). +// WithStatus(lunatask.StatusNext). +// WithEstimate(30). +// Create(ctx, client) +type TaskBuilder struct { + req createTaskRequest +} + +// NewTask starts building a task with the given name. +func NewTask(name string) *TaskBuilder { + return &TaskBuilder{req: createTaskRequest{Name: name}} //nolint:exhaustruct +} + +// InArea assigns the task to an area. IDs are in the area's settings in the app. +func (b *TaskBuilder) InArea(areaID string) *TaskBuilder { + b.req.AreaID = &areaID + + return b } -// CreateTask adds a task. Returns (nil, nil) if a duplicate exists +// InGoal assigns the task to a goal. IDs are in the goal's settings in the app. +func (b *TaskBuilder) InGoal(goalID string) *TaskBuilder { + b.req.GoalID = &goalID + + return b +} + +// WithNote attaches a Markdown note to the task. +func (b *TaskBuilder) WithNote(note string) *TaskBuilder { + b.req.Note = ¬e + + return b +} + +// WithStatus sets the workflow status. +// Use one of the Status* constants (e.g., [StatusNext]). +func (b *TaskBuilder) WithStatus(status TaskStatus) *TaskBuilder { + b.req.Status = &status + + return b +} + +// WithMotivation sets why this task matters. +// Use one of the Motivation* constants (e.g., [MotivationMust]). +func (b *TaskBuilder) WithMotivation(motivation Motivation) *TaskBuilder { + b.req.Motivation = &motivation + + return b +} + +// WithEstimate sets the expected duration in minutes (0–720). +func (b *TaskBuilder) WithEstimate(minutes int) *TaskBuilder { + b.req.Estimate = &minutes + + return b +} + +// WithPriority sets importance from -2 (lowest) to 2 (highest). +func (b *TaskBuilder) WithPriority(priority int) *TaskBuilder { + b.req.Priority = &priority + + return b +} + +// WithEisenhower sets the matrix quadrant (0–4). +func (b *TaskBuilder) WithEisenhower(eisenhower int) *TaskBuilder { + b.req.Eisenhower = &eisenhower + + return b +} + +// ScheduledOn sets when the task should appear on your schedule. +func (b *TaskBuilder) ScheduledOn(date Date) *TaskBuilder { + b.req.ScheduledOn = &date + + return b +} + +// CompletedAt marks the task completed at a specific time. +func (b *TaskBuilder) CompletedAt(t time.Time) *TaskBuilder { + b.req.CompletedAt = &t + + return b +} + +// FromSource tags the task with a free-form origin identifier, useful for +// tracking tasks created by scripts or external integrations. +func (b *TaskBuilder) FromSource(source, sourceID string) *TaskBuilder { + b.req.Source = &source + b.req.SourceID = &sourceID + + return b +} + +// Create sends the task to Lunatask. Returns (nil, nil) if a duplicate exists // with matching source/source_id. -func (c *Client) CreateTask(ctx context.Context, task *CreateTaskRequest) (*Task, error) { - if task.Name == "" { +func (b *TaskBuilder) Create(ctx context.Context, c *Client) (*Task, error) { + if b.req.Name == "" { return nil, fmt.Errorf("%w: name is required", ErrBadRequest) } - resp, noContent, err := doJSON[taskResponse](ctx, c, http.MethodPost, "/tasks", task) - if err != nil { - return nil, err - } + return create(ctx, c, "/tasks", b.req, func(r taskResponse) Task { return r.Task }) +} - if noContent { - // Intentional: duplicate exists (HTTP 204), not an error - return nil, nil //nolint:nilnil - } +// TaskUpdateBuilder constructs and updates a task via method chaining. +// Only fields you set will be modified; others remain unchanged. +// +// task, err := lunatask.NewTaskUpdate(taskID). +// WithStatus(lunatask.StatusCompleted). +// CompletedAt(time.Now()). +// Update(ctx, client) +type TaskUpdateBuilder struct { + taskID string + req updateTaskRequest +} - return &resp.Task, nil +// NewTaskUpdate starts building a task update for the given task ID. +func NewTaskUpdate(taskID string) *TaskUpdateBuilder { + return &TaskUpdateBuilder{taskID: taskID} //nolint:exhaustruct } -// UpdateTask modifies a task. Only non-nil fields in the request are changed. -func (c *Client) UpdateTask(ctx context.Context, taskID string, task *UpdateTaskRequest) (*Task, error) { - if taskID == "" { - return nil, fmt.Errorf("%w: task ID cannot be empty", ErrBadRequest) - } +// Name changes the task's name. +func (b *TaskUpdateBuilder) Name(name string) *TaskUpdateBuilder { + b.req.Name = &name - resp, _, err := doJSON[taskResponse](ctx, c, http.MethodPut, "/tasks/"+taskID, task) - if err != nil { - return nil, err - } + return b +} + +// InArea moves the task to an area. IDs are in the area's settings in the app. +func (b *TaskUpdateBuilder) InArea(areaID string) *TaskUpdateBuilder { + b.req.AreaID = &areaID - return &resp.Task, nil + return b } -// DeleteTask removes a task and returns its final state. -func (c *Client) DeleteTask(ctx context.Context, taskID string) (*Task, error) { - if taskID == "" { - return nil, fmt.Errorf("%w: task ID cannot be empty", ErrBadRequest) - } +// InGoal moves the task to a goal. IDs are in the goal's settings in the app. +func (b *TaskUpdateBuilder) InGoal(goalID string) *TaskUpdateBuilder { + b.req.GoalID = &goalID - resp, _, err := doJSON[taskResponse](ctx, c, http.MethodDelete, "/tasks/"+taskID, nil) - if err != nil { - return nil, err - } + return b +} + +// WithNote replaces the task's Markdown note. +func (b *TaskUpdateBuilder) WithNote(note string) *TaskUpdateBuilder { + b.req.Note = ¬e + + return b +} + +// WithStatus sets the workflow status. +// Use one of the Status* constants (e.g., [StatusNext]). +func (b *TaskUpdateBuilder) WithStatus(status TaskStatus) *TaskUpdateBuilder { + b.req.Status = &status + + return b +} + +// WithMotivation sets why this task matters. +// Use one of the Motivation* constants (e.g., [MotivationMust]). +func (b *TaskUpdateBuilder) WithMotivation(motivation Motivation) *TaskUpdateBuilder { + b.req.Motivation = &motivation + + return b +} + +// WithEstimate sets the expected duration in minutes (0–720). +func (b *TaskUpdateBuilder) WithEstimate(minutes int) *TaskUpdateBuilder { + b.req.Estimate = &minutes + + return b +} + +// WithPriority sets importance from -2 (lowest) to 2 (highest). +func (b *TaskUpdateBuilder) WithPriority(priority int) *TaskUpdateBuilder { + b.req.Priority = &priority + + return b +} + +// WithEisenhower sets the matrix quadrant (0–4). +func (b *TaskUpdateBuilder) WithEisenhower(eisenhower int) *TaskUpdateBuilder { + b.req.Eisenhower = &eisenhower + + return b +} + +// ScheduledOn sets when the task should appear on your schedule. +func (b *TaskUpdateBuilder) ScheduledOn(date Date) *TaskUpdateBuilder { + b.req.ScheduledOn = &date + + return b +} + +// CompletedAt marks the task completed at a specific time. +func (b *TaskUpdateBuilder) CompletedAt(t time.Time) *TaskUpdateBuilder { + b.req.CompletedAt = &t + + return b +} - return &resp.Task, nil +// Update sends the changes to Lunatask. +func (b *TaskUpdateBuilder) Update(ctx context.Context, c *Client) (*Task, error) { + return update(ctx, c, "/tasks", b.taskID, "task", b.req, func(r taskResponse) Task { return r.Task }) } diff --git a/timeline.go b/timeline.go index e5a6de9aaaea292be49a8cdb87d07d29f6f70f07..52ca4d64e0dc6b0ee848d079b22f98ca06fd59d3 100644 --- a/timeline.go +++ b/timeline.go @@ -20,9 +20,8 @@ type PersonTimelineNote struct { UpdatedAt time.Time `json:"updated_at"` } -// CreatePersonTimelineNoteRequest defines a timeline note for a person. -// Use [TimelineNoteBuilder] for a fluent construction API. -type CreatePersonTimelineNoteRequest struct { +// createPersonTimelineNoteRequest defines a timeline note for JSON serialization. +type createPersonTimelineNoteRequest struct { // PersonID is the ID of the person to add the note to (required). PersonID string `json:"person_id"` // DateOn is the date for the note (optional, defaults to today). @@ -36,17 +35,45 @@ type personTimelineNoteResponse struct { PersonTimelineNote PersonTimelineNote `json:"person_timeline_note"` } -// CreatePersonTimelineNote adds a note to a person's memory timeline. +// TimelineNoteBuilder constructs and creates a timeline note via method chaining. +// Content is encrypted client-side; the API accepts it on create but returns null on read. +// +// note, err := lunatask.NewTimelineNote(personID). +// OnDate(lunatask.Today()). +// WithContent("Had coffee, discussed the project."). +// Create(ctx, client) +type TimelineNoteBuilder struct { + req createPersonTimelineNoteRequest +} + +// NewTimelineNote starts building a timeline note for the given person. +// Get person IDs from [Client.ListPeople]. +func NewTimelineNote(personID string) *TimelineNoteBuilder { + return &TimelineNoteBuilder{req: createPersonTimelineNoteRequest{PersonID: personID}} //nolint:exhaustruct +} + +// OnDate sets when this interaction occurred. +func (b *TimelineNoteBuilder) OnDate(date Date) *TimelineNoteBuilder { + b.req.DateOn = &date + + return b +} + +// WithContent sets the Markdown body describing the interaction. +func (b *TimelineNoteBuilder) WithContent(content string) *TimelineNoteBuilder { + b.req.Content = &content + + return b +} + +// Create sends the timeline note to Lunatask. // Get person IDs from [Client.ListPeople]. -func (c *Client) CreatePersonTimelineNote( - ctx context.Context, - note *CreatePersonTimelineNoteRequest, -) (*PersonTimelineNote, error) { - if note.PersonID == "" { +func (b *TimelineNoteBuilder) Create(ctx context.Context, c *Client) (*PersonTimelineNote, error) { + if b.req.PersonID == "" { return nil, fmt.Errorf("%w: person_id is required", ErrBadRequest) } - resp, _, err := doJSON[personTimelineNoteResponse](ctx, c, http.MethodPost, "/person_timeline_notes", note) + resp, _, err := doJSON[personTimelineNoteResponse](ctx, c, http.MethodPost, "/person_timeline_notes", b.req) if err != nil { return nil, err }