Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ spec/build/
conformance/runner/go/conformance-runner
conformance/runner/go/go
conformance/runner/ruby/Gemfile.lock
conformance/runner/typescript/node_modules/
conformance/runner/typescript/node_modules

# Claude Code session files (except skills, which are committed)
.claude/*
Expand Down
47 changes: 32 additions & 15 deletions go/pkg/basecamp/search.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,23 @@ type SearchResult struct {
Parent *Parent `json:"parent,omitempty"`
Bucket *Bucket `json:"bucket,omitempty"`
Creator *Person `json:"creator,omitempty"`
Content string `json:"content,omitempty"`
Description string `json:"description,omitempty"`
// Content and Description are always present on the wire and always null:
// api/searches/show.json.jbuilder renders the recording's own partial and
// then unconditionally overwrites both with nil to keep the large HTML body
// out of the search payload. They are modeled as pointers so the guaranteed
// null round-trips faithfully rather than collapsing to "". Read
// PlainTextContent and PlainTextDescription instead.
Content *string `json:"content"`
Description *string `json:"description"`
// PlainTextContent and PlainTextDescription are highlighted, truncated
// excerpts — NOT plain text despite the name. BC3 converts the rich text
// with to_plain_text, escapes it with html_escape_once, wraps each query
// match in <mark class="circled-text"><span></span>…</mark>, and truncates
// to 300 characters. Treat them as HTML fragments. Optional and
// non-nullable: a result whose recordable has no such attribute omits the
// key rather than sending null.
PlainTextContent string `json:"plain_text_content,omitempty"`
PlainTextDescription string `json:"plain_text_description,omitempty"`
// ContentAttachments and DescriptionAttachments are the rich text companion
// arrays carried through the polymorphic search projection. A given result
// is one recording type, so it carries only the array matching its rich text
Expand Down Expand Up @@ -316,19 +331,21 @@ func searchTypesFromGenerated(gts []generated.SearchType) []SearchType {
// searchResultFromGenerated converts a generated SearchResult to our clean SearchResult type.
func searchResultFromGenerated(gsr generated.SearchResult) SearchResult {
sr := SearchResult{
Status: gsr.Status,
VisibleToClients: gsr.VisibleToClients,
CreatedAt: gsr.CreatedAt,
UpdatedAt: gsr.UpdatedAt,
Title: gsr.Title,
InheritsStatus: gsr.InheritsStatus,
Type: gsr.Type,
URL: gsr.Url,
AppURL: gsr.AppUrl,
BookmarkURL: gsr.BookmarkUrl,
Content: gsr.Content,
Description: gsr.Description,
Subject: gsr.Subject,
Status: gsr.Status,
VisibleToClients: gsr.VisibleToClients,
CreatedAt: gsr.CreatedAt,
UpdatedAt: gsr.UpdatedAt,
Title: gsr.Title,
InheritsStatus: gsr.InheritsStatus,
Type: gsr.Type,
URL: gsr.Url,
AppURL: gsr.AppUrl,
BookmarkURL: gsr.BookmarkUrl,
Content: gsr.Content,
Description: gsr.Description,
PlainTextContent: gsr.PlainTextContent,
PlainTextDescription: gsr.PlainTextDescription,
Subject: gsr.Subject,
}

if gsr.Id != 0 {
Expand Down
40 changes: 34 additions & 6 deletions go/pkg/basecamp/search_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"os"
"path/filepath"
"reflect"
"strings"
"testing"

"github.com/basecamp/basecamp-sdk/go/pkg/generated"
Expand Down Expand Up @@ -56,8 +57,23 @@ func TestSearchResult_UnmarshalResults(t *testing.T) {
if r1.Subject != "We won Leto!" {
t.Errorf("expected subject 'We won Leto!', got %q", r1.Subject)
}
if r1.Content != "<div>Hello everyone! We got the Leto Laptop project! Time to get started.</div>" {
t.Errorf("unexpected content: %q", r1.Content)
// Content is always present on the wire and always null — the search
// projection strips the HTML body. A non-nil pointer would mean the
// projection changed.
if r1.Content != nil {
t.Errorf("expected nil Content on a search result, got %q", *r1.Content)
}
if r1.Description != nil {
t.Errorf("expected nil Description on a search result, got %q", *r1.Description)
}
if r1.PlainTextContent == "" {
t.Error("expected PlainTextContent to carry the highlighted excerpt")
}
if !strings.Contains(r1.PlainTextContent, `<mark class="circled-text">`) {
t.Errorf("PlainTextContent is an HTML fragment with highlighted matches, got %q", r1.PlainTextContent)
}
if r1.PlainTextDescription != "" {
t.Errorf("a Message has no description attribute, so PlainTextDescription should be absent, got %q", r1.PlainTextDescription)
}
if r1.URL != "https://3.basecampapi.com/195539477/buckets/2085958499/messages/1069479351.json" {
t.Errorf("unexpected URL: %q", r1.URL)
Expand Down Expand Up @@ -116,8 +132,17 @@ func TestSearchResult_UnmarshalResults(t *testing.T) {
if r2.Title != "Design specs for Leto display" {
t.Errorf("expected title 'Design specs for Leto display', got %q", r2.Title)
}
if r2.Description != "Create detailed specifications for the Leto laptop display panel" {
t.Errorf("unexpected description: %q", r2.Description)
if r2.Content != nil {
t.Errorf("expected nil Content on a search result, got %q", *r2.Content)
}
if r2.Description != nil {
t.Errorf("expected nil Description on a search result, got %q", *r2.Description)
}
if !strings.Contains(r2.PlainTextDescription, `<mark class="circled-text">`) {
t.Errorf("expected a highlighted excerpt in PlainTextDescription, got %q", r2.PlainTextDescription)
}
if r2.PlainTextContent != "" {
t.Errorf("a Todo has no content attribute, so PlainTextContent should be absent, got %q", r2.PlainTextContent)
}
if r2.Parent == nil {
t.Fatal("expected Parent to be non-nil for second result")
Expand All @@ -140,8 +165,11 @@ func TestSearchResult_UnmarshalResults(t *testing.T) {
if r3.Type != "Comment" {
t.Errorf("expected type 'Comment', got %q", r3.Type)
}
if r3.Content != "<div>The Leto keyboard layout looks great. Let's finalize it.</div>" {
t.Errorf("unexpected content for comment: %q", r3.Content)
if r3.Content != nil {
t.Errorf("expected nil Content on a search result, got %q", *r3.Content)
}
if !strings.Contains(r3.PlainTextContent, `<mark class="circled-text">`) {
t.Errorf("expected a highlighted excerpt in PlainTextContent, got %q", r3.PlainTextContent)
}

// Verify timestamps are parsed
Expand Down
43 changes: 34 additions & 9 deletions go/pkg/generated/client.gen.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

24 changes: 22 additions & 2 deletions openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -29388,10 +29388,28 @@
"$ref": "#/components/schemas/Person"
},
"content": {
"type": "string"
"type": [
"string",
"null"
],
"description": "Always present, always null. `api/searches/show.json.jbuilder` renders the\nrecording's own partial and then unconditionally overwrites `content` with\n`nil` to strip the large HTML body out of the search payload. The key is\ntherefore guaranteed present on every result — required — and its value is\nguaranteed null. Read `plain_text_content` instead.",
"x-go-type": "*string"
},
"description": {
"type": "string"
"type": [
"string",
"null"
],
"description": "Always present, always null — the description-attribute counterpart to\n`content`. Read `plain_text_description` instead.",
"x-go-type": "*string"
},
"plain_text_content": {
"type": "string",
"description": "A highlighted, truncated excerpt of the recording's content — **not** plain\ntext despite the name. `excerpt_and_highlight_matches` converts the rich\ntext with `to_plain_text`, escapes it with `html_escape_once`, then wraps\neach query match in `<mark class=\"circled-text\"><span></span>…</mark>` and\ntruncates the result to 300 characters. Treat it as an HTML fragment.\n\nOptional and non-nullable: emitted only when the underlying recordable\nresponds to `content`, so a result whose type has no content attribute\nomits the key entirely rather than sending null."
},
"plain_text_description": {
"type": "string",
"description": "The description-attribute counterpart to `plain_text_content`, with the\nsame highlighting, escaping, and 300-character truncation. Optional and\nnon-nullable — omitted when the recordable has no description attribute."
},
"content_attachments": {
"type": "array",
Expand All @@ -29413,6 +29431,8 @@
},
"required": [
"app_url",
"content",
"description",
"id",
"title",
"type",
Expand Down
6 changes: 4 additions & 2 deletions python/src/basecamp/generated/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -1406,15 +1406,17 @@ class SearchResult(TypedDict):
app_url: str
bookmark_url: NotRequired[str]
bucket: NotRequired[RecordingBucket]
content: NotRequired[str]
content: str | None
content_attachments: NotRequired[list[RichTextAttachment]]
created_at: NotRequired[str]
creator: NotRequired[Person]
description: NotRequired[str]
description: str | None
description_attachments: NotRequired[list[RichTextAttachment]]
id: int
inherits_status: NotRequired[bool]
parent: NotRequired[RecordingParent]
plain_text_content: NotRequired[str]
plain_text_description: NotRequired[str]
status: NotRequired[str]
subject: NotRequired[str]
title: str
Expand Down
2 changes: 1 addition & 1 deletion ruby/lib/basecamp/generated/metadata.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"$schema": "https://basecamp.com/schemas/sdk-metadata.json",
"version": "1.0.0",
"generated": "2026-07-27T18:27:05Z",
"generated": "2026-07-28T06:50:08Z",
"operations": {
"GetAccount": {
"retry": {
Expand Down
20 changes: 12 additions & 8 deletions ruby/lib/basecamp/generated/types.rb
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# frozen_string_literal: true

# Auto-generated from OpenAPI spec. Do not edit manually.
# Generated: 2026-07-27T18:27:05Z
# Generated: 2026-07-28T06:50:08Z

require "json"
require "time"
Expand Down Expand Up @@ -3511,29 +3511,31 @@ def to_json(*args)
# SearchResult
class SearchResult
include TypeHelpers
attr_accessor :app_url, :id, :title, :type, :url, :bookmark_url, :bucket, :content, :content_attachments, :created_at, :creator, :description, :description_attachments, :inherits_status, :parent, :status, :subject, :updated_at, :visible_to_clients
attr_accessor :app_url, :content, :description, :id, :title, :type, :url, :bookmark_url, :bucket, :content_attachments, :created_at, :creator, :description_attachments, :inherits_status, :parent, :plain_text_content, :plain_text_description, :status, :subject, :updated_at, :visible_to_clients

# @return [Array<Symbol>]
def self.required_fields
%i[app_url id title type url].freeze
%i[app_url content description id title type url].freeze
end

def initialize(data = {})
@app_url = data["app_url"]
@content = data["content"]
@description = data["description"]
@id = parse_integer(data["id"])
@title = data["title"]
@type = data["type"]
@url = data["url"]
@bookmark_url = data["bookmark_url"]
@bucket = parse_type(data["bucket"], "RecordingBucket")
@content = data["content"]
@content_attachments = parse_array(data["content_attachments"], "RichTextAttachment")
@created_at = parse_datetime(data["created_at"])
@creator = parse_type(data["creator"], "Person")
@description = data["description"]
@description_attachments = parse_array(data["description_attachments"], "RichTextAttachment")
@inherits_status = parse_boolean(data["inherits_status"])
@parent = parse_type(data["parent"], "RecordingParent")
@plain_text_content = data["plain_text_content"]
@plain_text_description = data["plain_text_description"]
@status = data["status"]
@subject = data["subject"]
@updated_at = parse_datetime(data["updated_at"])
Expand All @@ -3543,25 +3545,27 @@ def initialize(data = {})
def to_h
{
"app_url" => @app_url,
"content" => @content,
"description" => @description,
"id" => @id,
"title" => @title,
"type" => @type,
"url" => @url,
"bookmark_url" => @bookmark_url,
"bucket" => @bucket,
"content" => @content,
"content_attachments" => @content_attachments,
"created_at" => @created_at,
"creator" => @creator,
"description" => @description,
"description_attachments" => @description_attachments,
"inherits_status" => @inherits_status,
"parent" => @parent,
"plain_text_content" => @plain_text_content,
"plain_text_description" => @plain_text_description,
"status" => @status,
"subject" => @subject,
"updated_at" => @updated_at,
"visible_to_clients" => @visible_to_clients,
}.compact
}.reject { |k, v| v.nil? && !["content", "description"].include?(k) }
end

def to_json(*args)
Expand Down
26 changes: 26 additions & 0 deletions ruby/test/basecamp/generated_types_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,32 @@ def test_search_type_preserves_null_key
assert_equal "Message", real_option.to_h["key"]
end

# SearchResult.content and SearchResult.description are required-and-nullable:
# api/searches/show.json.jbuilder renders the recording's own partial and then
# unconditionally overwrites both with nil to keep the large HTML body out of
# the search payload, so the keys are always present and always null. to_h must
# preserve those explicit nulls rather than compacting them away — a consumer
# has to be able to tell "the projection stripped this" from "absent".
def test_search_result_preserves_null_content_and_description
result = Basecamp::Types::SearchResult.new(
"id" => 1, "title" => "Quarterly Report", "type" => "Message",
"url" => "https://3.basecampapi.com/12345/buckets/1/messages/1.json",
"app_url" => "https://3.basecamp.com/12345/buckets/1/messages/1",
"content" => nil, "description" => nil,
"plain_text_content" => "Q1 <mark class=\"circled-text\"><span></span>Report</mark> summary."
)
hash = result.to_h

assert hash.key?("content"), "required-nullable content must stay present"
assert_nil hash["content"]
assert hash.key?("description"), "required-nullable description must stay present"
assert_nil hash["description"]
# The excerpt is the opposite contract: optional and non-nullable.
assert_includes hash["plain_text_content"], "circled-text"
assert_equal %i[app_url content description id title type url],
Basecamp::Types::SearchResult.required_fields
end

# Wormhole.color and Wormhole.destination_url are required-and-nullable: the bc3
# jbuilder always emits them, null when unset/unlinked. to_h must preserve those
# explicit nulls (the destination_url is the only field identifying the target),
Expand Down
Loading
Loading