Detailed changes
@@ -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
}
@@ -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
}
@@ -1,406 +0,0 @@
-// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
-//
-// 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
-}
@@ -0,0 +1,118 @@
+// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
+//
+// 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
+}
@@ -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
}
@@ -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 })
}
@@ -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 })
}
@@ -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 })
}
@@ -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
}