Skip to content

Commit aed52bf

Browse files
nervebandclaude
andcommitted
v1.1.0: Fix API endpoints and implement update/delete commands
- Fix GetDocument to use /blocks?id= endpoint instead of /documents/{id} - Fix SearchDocuments to use 'include' parameter instead of 'query' - Fix CreateDocument to wrap request in {"documents":[...]} format - Implement UpdateDocument using POST /blocks with position.pageId - Implement DeleteDocument using DELETE /blocks with blockIds - Add Block and BlocksResponse models for blocks API - Add SearchItem model and search result output functions - Update all tests for new API behavior Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 1f397aa commit aed52bf

File tree

6 files changed

+537
-121
lines changed

6 files changed

+537
-121
lines changed

cmd/output.go

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -245,3 +245,49 @@ func outputDeleted(docID string) {
245245
fmt.Printf("Document %s deleted successfully\n", docID)
246246
}
247247
}
248+
249+
// outputSearchResults prints search results in the specified format
250+
func outputSearchResults(items []models.SearchItem, format string) error {
251+
switch format {
252+
case "json":
253+
return outputJSON(items)
254+
case "table":
255+
return outputSearchTable(items)
256+
case "markdown":
257+
return outputSearchMarkdown(items)
258+
default:
259+
return fmt.Errorf("unsupported format: %s", format)
260+
}
261+
}
262+
263+
// outputSearchTable prints search results as a table
264+
func outputSearchTable(items []models.SearchItem) error {
265+
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
266+
267+
if !hasNoHeaders() {
268+
fmt.Fprintln(w, "DOCUMENT ID\tMATCH")
269+
fmt.Fprintln(w, "-----------\t-----")
270+
}
271+
272+
for _, item := range items {
273+
match := item.Markdown
274+
// Clean up the markdown snippet for display
275+
match = strings.ReplaceAll(match, "\n", " ")
276+
if len(match) > 80 {
277+
match = match[:77] + "..."
278+
}
279+
fmt.Fprintf(w, "%s\t%s\n", item.DocumentID, match)
280+
}
281+
282+
return w.Flush()
283+
}
284+
285+
// outputSearchMarkdown prints search results as markdown
286+
func outputSearchMarkdown(items []models.SearchItem) error {
287+
fmt.Println("# Search Results")
288+
for _, item := range items {
289+
fmt.Printf("## Document: %s\n", item.DocumentID)
290+
fmt.Printf("%s\n\n", item.Markdown)
291+
}
292+
return nil
293+
}

cmd/root.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ var (
2222
apiURL string
2323
outputFormat string
2424
cfgManager *config.Manager
25-
version = "1.0.0"
25+
version = "1.1.0"
2626

2727
// Global flags for LLM/scripting friendliness
2828
quietMode bool

cmd/search.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ var searchCmd = &cobra.Command{
2222
}
2323

2424
format := getOutputFormat()
25-
return outputDocuments(result.Items, format)
25+
return outputSearchResults(result.Items, format)
2626
},
2727
}
2828

internal/api/client.go

Lines changed: 212 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import (
77
"io"
88
"net/http"
99
"net/url"
10+
"strings"
1011
"time"
1112

1213
"github.com/ashrafali/craft-cli/internal/models"
@@ -108,25 +109,62 @@ func (c *Client) GetDocuments() (*models.DocumentList, error) {
108109
return &result, nil
109110
}
110111

111-
// GetDocument retrieves a single document by ID
112+
// GetDocument retrieves a single document by ID using the blocks endpoint
112113
func (c *Client) GetDocument(id string) (*models.Document, error) {
113-
path := fmt.Sprintf("/documents/%s", id)
114+
path := fmt.Sprintf("/blocks?id=%s", url.QueryEscape(id))
114115
data, err := c.doRequest("GET", path, nil)
115116
if err != nil {
116117
return nil, err
117118
}
118119

119-
var doc models.Document
120-
if err := json.Unmarshal(data, &doc); err != nil {
120+
var blocksResp models.BlocksResponse
121+
if err := json.Unmarshal(data, &blocksResp); err != nil {
121122
return nil, fmt.Errorf("invalid response from API: %w", err)
122123
}
123124

124-
return &doc, nil
125+
// Combine markdown from all blocks
126+
markdown := combineBlocksMarkdown(blocksResp)
127+
128+
doc := &models.Document{
129+
ID: blocksResp.ID,
130+
Title: blocksResp.Markdown,
131+
Markdown: markdown,
132+
}
133+
134+
return doc, nil
135+
}
136+
137+
// combineBlocksMarkdown extracts and combines markdown from all blocks
138+
func combineBlocksMarkdown(resp models.BlocksResponse) string {
139+
var parts []string
140+
141+
// Add the document title/header
142+
if resp.Markdown != "" {
143+
parts = append(parts, "# "+resp.Markdown)
144+
}
145+
146+
// Recursively collect markdown from all content blocks
147+
for _, block := range resp.Content {
148+
collectBlockMarkdown(&block, &parts)
149+
}
150+
151+
return strings.Join(parts, "\n\n")
152+
}
153+
154+
// collectBlockMarkdown recursively collects markdown from a block and its children
155+
func collectBlockMarkdown(block *models.Block, parts *[]string) {
156+
if block.Markdown != "" {
157+
*parts = append(*parts, block.Markdown)
158+
}
159+
for _, child := range block.Content {
160+
collectBlockMarkdown(&child, parts)
161+
}
125162
}
126163

127164
// SearchDocuments searches for documents matching a query
128165
func (c *Client) SearchDocuments(query string) (*models.SearchResult, error) {
129-
path := fmt.Sprintf("/documents/search?query=%s", url.QueryEscape(query))
166+
// Craft API uses 'include' parameter instead of 'query'
167+
path := fmt.Sprintf("/documents/search?include=%s", url.QueryEscape(query))
130168
data, err := c.doRequest("GET", path, nil)
131169
if err != nil {
132170
return nil, err
@@ -140,40 +178,195 @@ func (c *Client) SearchDocuments(query string) (*models.SearchResult, error) {
140178
return &result, nil
141179
}
142180

181+
// createDocumentsRequest wraps documents for the API
182+
type createDocumentsRequest struct {
183+
Documents []models.CreateDocumentRequest `json:"documents"`
184+
}
185+
186+
// createDocumentsResponse represents the API response for document creation
187+
type createDocumentsResponse struct {
188+
Items []struct {
189+
ID string `json:"id"`
190+
Title string `json:"title"`
191+
} `json:"items"`
192+
}
193+
143194
// CreateDocument creates a new document
144195
func (c *Client) CreateDocument(req *models.CreateDocumentRequest) (*models.Document, error) {
145-
data, err := c.doRequest("POST", "/documents", req)
196+
// Craft API expects {"documents": [...]} wrapper
197+
wrapper := createDocumentsRequest{
198+
Documents: []models.CreateDocumentRequest{*req},
199+
}
200+
201+
data, err := c.doRequest("POST", "/documents", wrapper)
146202
if err != nil {
147203
return nil, err
148204
}
149205

150-
var doc models.Document
151-
if err := json.Unmarshal(data, &doc); err != nil {
206+
var resp createDocumentsResponse
207+
if err := json.Unmarshal(data, &resp); err != nil {
152208
return nil, fmt.Errorf("invalid response from API: %w", err)
153209
}
154210

155-
return &doc, nil
211+
if len(resp.Items) == 0 {
212+
return nil, fmt.Errorf("no document returned from API")
213+
}
214+
215+
doc := &models.Document{
216+
ID: resp.Items[0].ID,
217+
Title: resp.Items[0].Title,
218+
}
219+
220+
return doc, nil
156221
}
157222

158-
// UpdateDocument updates an existing document
223+
// blockPosition specifies where to insert a block
224+
type blockPosition struct {
225+
PageID string `json:"pageId"`
226+
Position string `json:"position"` // "start", "end", or block ID
227+
}
228+
229+
// addBlockRequest is the request body for adding blocks
230+
type addBlockRequest struct {
231+
Markdown string `json:"markdown"`
232+
Position blockPosition `json:"position"`
233+
}
234+
235+
// addBlockResponse is the response from adding blocks
236+
type addBlockResponse struct {
237+
Items []struct {
238+
ID string `json:"id"`
239+
Type string `json:"type"`
240+
Markdown string `json:"markdown"`
241+
} `json:"items"`
242+
}
243+
244+
// UpdateDocument updates an existing document by adding content
245+
// Note: The Craft Connect API only supports adding content blocks, not updating title or replacing content
159246
func (c *Client) UpdateDocument(id string, req *models.UpdateDocumentRequest) (*models.Document, error) {
160-
path := fmt.Sprintf("/documents/%s", id)
161-
data, err := c.doRequest("PUT", path, req)
247+
// Title updates are not supported via the API
248+
if req.Title != "" && req.Markdown == "" {
249+
return nil, fmt.Errorf("the Craft Connect API does not support title updates. Use the Craft app to rename documents")
250+
}
251+
252+
if req.Markdown == "" {
253+
return nil, fmt.Errorf("markdown content is required for updates")
254+
}
255+
256+
// Add content blocks to the document
257+
addReq := addBlockRequest{
258+
Markdown: req.Markdown,
259+
Position: blockPosition{
260+
PageID: id,
261+
Position: "end",
262+
},
263+
}
264+
265+
data, err := c.doRequest("POST", "/blocks", addReq)
162266
if err != nil {
163267
return nil, err
164268
}
165269

166-
var doc models.Document
167-
if err := json.Unmarshal(data, &doc); err != nil {
270+
var resp addBlockResponse
271+
if err := json.Unmarshal(data, &resp); err != nil {
168272
return nil, fmt.Errorf("invalid response from API: %w", err)
169273
}
170274

171-
return &doc, nil
275+
// Return a document with the update info
276+
doc := &models.Document{
277+
ID: id,
278+
}
279+
280+
// If title was also requested, note it in return
281+
if req.Title != "" {
282+
doc.Title = req.Title + " (title not updated - API limitation)"
283+
}
284+
285+
// Set markdown to confirm what was added
286+
if len(resp.Items) > 0 {
287+
doc.Markdown = resp.Items[0].Markdown
288+
}
289+
290+
return doc, nil
291+
}
292+
293+
// deleteBlocksRequest is the request body for deleting blocks
294+
type deleteBlocksRequest struct {
295+
BlockIDs []string `json:"blockIds"`
296+
}
297+
298+
// deleteBlocksResponse is the response from deleting blocks
299+
type deleteBlocksResponse struct {
300+
Items []struct {
301+
ID string `json:"id"`
302+
} `json:"items"`
172303
}
173304

174305
// DeleteDocument deletes a document by ID
306+
// Note: The Craft Connect API does not support deleting root page blocks (documents)
307+
// This will attempt to delete all content blocks within the document
175308
func (c *Client) DeleteDocument(id string) error {
176-
path := fmt.Sprintf("/documents/%s", id)
177-
_, err := c.doRequest("DELETE", path, nil)
178-
return err
309+
// First, get the document to find all its content blocks
310+
doc, err := c.GetDocument(id)
311+
if err != nil {
312+
return fmt.Errorf("failed to get document: %w", err)
313+
}
314+
315+
// Get the blocks again to get block IDs
316+
path := fmt.Sprintf("/blocks?id=%s", url.QueryEscape(id))
317+
data, err := c.doRequest("GET", path, nil)
318+
if err != nil {
319+
return fmt.Errorf("failed to get document blocks: %w", err)
320+
}
321+
322+
var blocksResp models.BlocksResponse
323+
if err := json.Unmarshal(data, &blocksResp); err != nil {
324+
return fmt.Errorf("invalid response from API: %w", err)
325+
}
326+
327+
// Collect all content block IDs (not the root page)
328+
var blockIDs []string
329+
for _, block := range blocksResp.Content {
330+
collectBlockIDs(&block, &blockIDs)
331+
}
332+
333+
if len(blockIDs) == 0 {
334+
return fmt.Errorf("the Craft Connect API cannot delete documents (only content blocks). Document '%s' has no deletable content blocks", doc.Title)
335+
}
336+
337+
// Delete the content blocks
338+
deleteReq := deleteBlocksRequest{
339+
BlockIDs: blockIDs,
340+
}
341+
342+
_, err = c.doRequest("DELETE", "/blocks", deleteReq)
343+
if err != nil {
344+
return fmt.Errorf("failed to delete blocks: %w", err)
345+
}
346+
347+
return nil
348+
}
349+
350+
// collectBlockIDs recursively collects all block IDs
351+
func collectBlockIDs(block *models.Block, ids *[]string) {
352+
if block.ID != "" {
353+
*ids = append(*ids, block.ID)
354+
}
355+
for _, child := range block.Content {
356+
collectBlockIDs(&child, ids)
357+
}
358+
}
359+
360+
// DeleteBlock deletes a specific block by ID
361+
func (c *Client) DeleteBlock(blockID string) error {
362+
deleteReq := deleteBlocksRequest{
363+
BlockIDs: []string{blockID},
364+
}
365+
366+
_, err := c.doRequest("DELETE", "/blocks", deleteReq)
367+
if err != nil {
368+
return fmt.Errorf("failed to delete block: %w", err)
369+
}
370+
371+
return nil
179372
}

0 commit comments

Comments
 (0)