From 2bc616674f5c39fd12c35ab97f47d7f7a52d17c6 Mon Sep 17 00:00:00 2001 From: Amolith Date: Fri, 19 Dec 2025 00:15:58 -0700 Subject: [PATCH] feat(lunatask): add People and Timeline APIs People API: ListPeople, GetPerson, CreatePerson, DeletePerson PersonTimelineNote API: CreatePersonTimelineNote Assisted-by: Claude Sonnet 4 via Crush --- lunatask/people.go | 111 +++++++++++++++++++++++++++++++++++++++++++ lunatask/timeline.go | 45 ++++++++++++++++++ 2 files changed, 156 insertions(+) diff --git a/lunatask/people.go b/lunatask/people.go index 19a7f2af7683e79440e77af2861d19e270f54e59..8e86004f2f96455ed8ac49e9f3be9561b38b6ebd 100644 --- a/lunatask/people.go +++ b/lunatask/people.go @@ -3,3 +3,114 @@ // SPDX-License-Identifier: AGPL-3.0-or-later package lunatask + +import ( + "context" + "fmt" + "net/http" + "net/url" + "time" +) + +// Person represents a person/relationship returned from the Lunatask API. +// Note: first_name and last_name are E2EE and not returned by the API. +type Person struct { + ID string `json:"id"` + RelationshipStrength *string `json:"relationship_strength"` + Sources []Source `json:"sources"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// CreatePersonRequest represents the request to create a person in Lunatask. +type CreatePersonRequest struct { + FirstName *string `json:"first_name,omitempty"` + LastName *string `json:"last_name,omitempty"` + RelationshipStrength *string `json:"relationship_strength,omitempty"` + Source *string `json:"source,omitempty"` + SourceID *string `json:"source_id,omitempty"` +} + +// personResponse represents a single person response from the API. +type personResponse struct { + Person Person `json:"person"` +} + +// peopleResponse represents a list of people response from the API. +type peopleResponse struct { + People []Person `json:"people"` +} + +// ListPeopleOptions contains optional filters for listing people. +type ListPeopleOptions struct { + Source *string + SourceID *string +} + +// ListPeople retrieves all people, optionally filtered by source and/or source_id. +func (c *Client) ListPeople(ctx context.Context, opts *ListPeopleOptions) ([]Person, error) { + path := "/people" + + 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()) + } + } + + resp, _, err := doJSON[peopleResponse](c, ctx, http.MethodGet, path, nil) + if err != nil { + return nil, err + } + + return resp.People, nil +} + +// GetPerson retrieves a specific person by ID. +func (c *Client) GetPerson(ctx context.Context, personID string) (*Person, error) { + if personID == "" { + return nil, fmt.Errorf("%w: person ID cannot be empty", ErrBadRequest) + } + + resp, _, err := doJSON[personResponse](c, ctx, http.MethodGet, "/people/"+personID, nil) + if err != nil { + return nil, err + } + + return &resp.Person, nil +} + +// CreatePerson creates a new person/relationship in Lunatask. +// Returns nil, nil if a matching person already exists with the same +// source/source_id (HTTP 204). +func (c *Client) CreatePerson(ctx context.Context, person *CreatePersonRequest) (*Person, error) { + resp, noContent, err := doJSON[personResponse](c, ctx, http.MethodPost, "/people", person) + if err != nil { + return nil, err + } + if noContent { + return nil, nil + } + + return &resp.Person, nil +} + +// DeletePerson deletes a person in Lunatask. +func (c *Client) DeletePerson(ctx context.Context, personID string) (*Person, error) { + if personID == "" { + return nil, fmt.Errorf("%w: person ID cannot be empty", ErrBadRequest) + } + + resp, _, err := doJSON[personResponse](c, ctx, http.MethodDelete, "/people/"+personID, nil) + if err != nil { + return nil, err + } + + return &resp.Person, nil +} diff --git a/lunatask/timeline.go b/lunatask/timeline.go index 19a7f2af7683e79440e77af2861d19e270f54e59..d2bc893950c622170b2c35ae8106fc2d31156faf 100644 --- a/lunatask/timeline.go +++ b/lunatask/timeline.go @@ -3,3 +3,48 @@ // SPDX-License-Identifier: AGPL-3.0-or-later package lunatask + +import ( + "context" + "fmt" + "net/http" + "time" +) + +// PersonTimelineNote represents a note on a person's memory timeline. +// Note: content is E2EE and not returned by the API. +type PersonTimelineNote struct { + ID string `json:"id"` + DateOn *Date `json:"date_on"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// CreatePersonTimelineNoteRequest represents the request to create a timeline note. +type CreatePersonTimelineNoteRequest struct { + // PersonID is the ID of the person to add the note to (required). + PersonID string `json:"person_id"` + // DateOn is the ISO-8601 date for the note (optional, defaults to today). + DateOn *Date `json:"date_on,omitempty"` + // Content is the Markdown content of the note (optional but impractical if empty). + Content *string `json:"content,omitempty"` +} + +// personTimelineNoteResponse represents a single timeline note response from the API. +type personTimelineNoteResponse struct { + PersonTimelineNote PersonTimelineNote `json:"person_timeline_note"` +} + +// CreatePersonTimelineNote creates a new note on a person's memory timeline. +func (c *Client) CreatePersonTimelineNote(ctx context.Context, note *CreatePersonTimelineNoteRequest) (*PersonTimelineNote, error) { + if note.PersonID == "" { + return nil, fmt.Errorf("%w: person_id is required", ErrBadRequest) + } + + resp, _, err := doJSON[personTimelineNoteResponse](c, ctx, http.MethodPost, "/person_timeline_notes", note) + if err != nil { + return nil, err + } + + return &resp.PersonTimelineNote, nil +}