|
| 1 | +package cmd |
| 2 | + |
| 3 | +import ( |
| 4 | + "context" |
| 5 | + "fmt" |
| 6 | + "os" |
| 7 | + "strings" |
| 8 | + |
| 9 | + "google.golang.org/api/docs/v1" |
| 10 | + |
| 11 | + "github.com/steipete/gogcli/internal/outfmt" |
| 12 | + "github.com/steipete/gogcli/internal/ui" |
| 13 | +) |
| 14 | + |
| 15 | +type DocsCellUpdateCmd struct { |
| 16 | + DocID string `arg:"" name:"docId" help:"Doc ID"` |
| 17 | + TableIndex int `name:"table-index" help:"1-based table index in document order; negative indexes count from the end" default:"1"` |
| 18 | + Row int `name:"row" required:"" help:"1-based row number"` |
| 19 | + Col int `name:"col" required:"" help:"1-based column number"` |
| 20 | + Content string `name:"content" help:"Replacement content (omit when using --content-file)"` |
| 21 | + ContentFile string `name:"content-file" help:"Read replacement content from a file"` |
| 22 | + Format string `name:"format" help:"Content format: markdown|plain" default:"markdown" enum:"markdown,plain"` |
| 23 | + Append bool `name:"append" help:"Append inside the cell instead of replacing existing cell content"` |
| 24 | + Tab string `name:"tab" help:"Target a specific tab by title or ID (see docs list-tabs)"` |
| 25 | + TabID string `name:"tab-id" hidden:"" help:"(deprecated) Use --tab"` |
| 26 | +} |
| 27 | + |
| 28 | +func (c *DocsCellUpdateCmd) Run(ctx context.Context, flags *RootFlags) error { |
| 29 | + u := ui.FromContext(ctx) |
| 30 | + docID := strings.TrimSpace(c.DocID) |
| 31 | + if docID == "" { |
| 32 | + return usage("empty docId") |
| 33 | + } |
| 34 | + if c.TableIndex == 0 { |
| 35 | + return usage("--table-index cannot be 0") |
| 36 | + } |
| 37 | + if c.Row < 1 { |
| 38 | + return usage("--row must be >= 1") |
| 39 | + } |
| 40 | + if c.Col < 1 { |
| 41 | + return usage("--col must be >= 1") |
| 42 | + } |
| 43 | + content, err := c.resolveContent() |
| 44 | + if err != nil { |
| 45 | + return err |
| 46 | + } |
| 47 | + format := strings.ToLower(strings.TrimSpace(c.Format)) |
| 48 | + if format == "" { |
| 49 | + format = docsContentFormatMarkdown |
| 50 | + } |
| 51 | + if format != docsContentFormatMarkdown && format != docsContentFormatPlain { |
| 52 | + return usage("--format must be markdown or plain") |
| 53 | + } |
| 54 | + tab, tabErr := resolveTabArg(ctx, c.Tab, c.TabID) |
| 55 | + if tabErr != nil { |
| 56 | + return tabErr |
| 57 | + } |
| 58 | + c.Tab = tab |
| 59 | + |
| 60 | + if dryRunErr := dryRunExit(ctx, flags, "docs.cell-update", map[string]any{ |
| 61 | + "document_id": docID, |
| 62 | + "table_index": c.TableIndex, |
| 63 | + "row": c.Row, |
| 64 | + "col": c.Col, |
| 65 | + "format": format, |
| 66 | + "append": c.Append, |
| 67 | + "tab": c.Tab, |
| 68 | + }); dryRunErr != nil { |
| 69 | + return dryRunErr |
| 70 | + } |
| 71 | + |
| 72 | + svc, err := requireDocsService(ctx, flags) |
| 73 | + if err != nil { |
| 74 | + return err |
| 75 | + } |
| 76 | + loaded, err := loadDocsTargetDocument(ctx, svc, docID, c.Tab) |
| 77 | + if err != nil { |
| 78 | + return err |
| 79 | + } |
| 80 | + c.Tab = loaded.tabID |
| 81 | + |
| 82 | + ref := &tableCellRef{tableIndex: c.TableIndex, row: c.Row, col: c.Col} |
| 83 | + cell, err := findTableCell(loaded.target, ref) |
| 84 | + if err != nil { |
| 85 | + return fmt.Errorf("find table cell: %w", err) |
| 86 | + } |
| 87 | + cellText, startIdx, endIdx := getCellText(cell) |
| 88 | + if startIdx <= 0 || endIdx <= startIdx { |
| 89 | + return fmt.Errorf("target cell has no editable text range") |
| 90 | + } |
| 91 | + writeEnd := endIdx |
| 92 | + if strings.HasSuffix(cellText, "\n") { |
| 93 | + writeEnd-- |
| 94 | + } |
| 95 | + writeStart := startIdx |
| 96 | + prefixBoundary := false |
| 97 | + if c.Append { |
| 98 | + writeStart = writeEnd |
| 99 | + prefixBoundary = format == docsContentFormatMarkdown && |
| 100 | + strings.TrimSpace(strings.TrimSuffix(cellText, "\n")) != "" && |
| 101 | + markdownCellAppendNeedsBoundary(ParseMarkdown(content)) |
| 102 | + } |
| 103 | + |
| 104 | + if err := updateDocsCellContent(ctx, svc, loaded.full, writeStart, writeEnd, content, format, c.Append, prefixBoundary, c.Tab); err != nil { |
| 105 | + return err |
| 106 | + } |
| 107 | + |
| 108 | + if outfmt.IsJSON(ctx) { |
| 109 | + payload := map[string]any{ |
| 110 | + "documentId": docID, |
| 111 | + "tableIndex": c.TableIndex, |
| 112 | + "row": c.Row, |
| 113 | + "col": c.Col, |
| 114 | + "format": format, |
| 115 | + "append": c.Append, |
| 116 | + "updated": true, |
| 117 | + } |
| 118 | + if c.Tab != "" { |
| 119 | + payload["tabId"] = c.Tab |
| 120 | + } |
| 121 | + return outfmt.WriteJSON(ctx, os.Stdout, payload) |
| 122 | + } |
| 123 | + u.Out().Linef("documentId\t%s", docID) |
| 124 | + u.Out().Linef("table_index\t%d", c.TableIndex) |
| 125 | + u.Out().Linef("row\t%d", c.Row) |
| 126 | + u.Out().Linef("col\t%d", c.Col) |
| 127 | + u.Out().Linef("updated\ttrue") |
| 128 | + if c.Tab != "" { |
| 129 | + u.Out().Linef("tabId\t%s", c.Tab) |
| 130 | + } |
| 131 | + return nil |
| 132 | +} |
| 133 | + |
| 134 | +func (c *DocsCellUpdateCmd) resolveContent() (string, error) { |
| 135 | + if c.ContentFile != "" && c.Content != "" { |
| 136 | + return "", usage("cannot use both --content and --content-file") |
| 137 | + } |
| 138 | + if c.ContentFile == "" { |
| 139 | + return c.Content, nil |
| 140 | + } |
| 141 | + data, err := os.ReadFile(c.ContentFile) |
| 142 | + if err != nil { |
| 143 | + return "", fmt.Errorf("read content file: %w", err) |
| 144 | + } |
| 145 | + return string(data), nil |
| 146 | +} |
| 147 | + |
| 148 | +func updateDocsCellContent(ctx context.Context, svc *docs.Service, doc *docs.Document, startIdx, endIdx int64, content, format string, appendOnly bool, prefixBoundary bool, tabID string) error { |
| 149 | + var requests []*docs.Request |
| 150 | + if !appendOnly && startIdx < endIdx { |
| 151 | + requests = append(requests, &docs.Request{ |
| 152 | + DeleteContentRange: &docs.DeleteContentRangeRequest{ |
| 153 | + Range: &docs.Range{StartIndex: startIdx, EndIndex: endIdx, TabId: tabID}, |
| 154 | + }, |
| 155 | + }) |
| 156 | + } |
| 157 | + if content != "" { |
| 158 | + if format == docsContentFormatMarkdown { |
| 159 | + baseIndex := startIdx |
| 160 | + prefix := "" |
| 161 | + if prefixBoundary { |
| 162 | + baseIndex++ |
| 163 | + prefix = "\n" |
| 164 | + } |
| 165 | + formatReqs, textToInsert, tables := MarkdownToDocsRequests(ParseMarkdown(content), baseIndex, tabID) |
| 166 | + if len(tables) > 0 { |
| 167 | + return usage("markdown tables are not supported inside table cells") |
| 168 | + } |
| 169 | + textToInsert = strings.TrimSuffix(textToInsert, "\n") |
| 170 | + formatReqs = clampDocsCellFormatRequests(formatReqs, baseIndex+utf16Len(textToInsert)) |
| 171 | + if textToInsert != "" { |
| 172 | + requests = append(requests, &docs.Request{ |
| 173 | + InsertText: &docs.InsertTextRequest{ |
| 174 | + Location: &docs.Location{Index: startIdx, TabId: tabID}, |
| 175 | + Text: prefix + textToInsert, |
| 176 | + }, |
| 177 | + }) |
| 178 | + requests = append(requests, formatReqs...) |
| 179 | + } |
| 180 | + } else { |
| 181 | + requests = append(requests, &docs.Request{ |
| 182 | + InsertText: &docs.InsertTextRequest{ |
| 183 | + Location: &docs.Location{Index: startIdx, TabId: tabID}, |
| 184 | + Text: content, |
| 185 | + }, |
| 186 | + }) |
| 187 | + } |
| 188 | + } |
| 189 | + if len(requests) == 0 { |
| 190 | + return nil |
| 191 | + } |
| 192 | + _, err := svc.Documents.BatchUpdate(doc.DocumentId, &docs.BatchUpdateDocumentRequest{ |
| 193 | + WriteControl: &docs.WriteControl{RequiredRevisionId: doc.RevisionId}, |
| 194 | + Requests: requests, |
| 195 | + }).Context(ctx).Do() |
| 196 | + if err != nil { |
| 197 | + return fmt.Errorf("cell update: %w", err) |
| 198 | + } |
| 199 | + return nil |
| 200 | +} |
| 201 | + |
| 202 | +func markdownCellAppendNeedsBoundary(elements []MarkdownElement) bool { |
| 203 | + if len(elements) != 1 { |
| 204 | + return len(elements) > 1 |
| 205 | + } |
| 206 | + switch elements[0].Type { |
| 207 | + case MDText, MDParagraph: |
| 208 | + return false |
| 209 | + default: |
| 210 | + return true |
| 211 | + } |
| 212 | +} |
| 213 | + |
| 214 | +func clampDocsCellFormatRequests(requests []*docs.Request, maxEnd int64) []*docs.Request { |
| 215 | + out := requests[:0] |
| 216 | + for _, req := range requests { |
| 217 | + r := docsRequestRange(req) |
| 218 | + if r == nil { |
| 219 | + out = append(out, req) |
| 220 | + continue |
| 221 | + } |
| 222 | + if r.EndIndex > maxEnd { |
| 223 | + r.EndIndex = maxEnd |
| 224 | + } |
| 225 | + if r.StartIndex >= r.EndIndex { |
| 226 | + continue |
| 227 | + } |
| 228 | + out = append(out, req) |
| 229 | + } |
| 230 | + return out |
| 231 | +} |
| 232 | + |
| 233 | +func docsRequestRange(req *docs.Request) *docs.Range { |
| 234 | + switch { |
| 235 | + case req.UpdateTextStyle != nil: |
| 236 | + return req.UpdateTextStyle.Range |
| 237 | + case req.UpdateParagraphStyle != nil: |
| 238 | + return req.UpdateParagraphStyle.Range |
| 239 | + case req.CreateParagraphBullets != nil: |
| 240 | + return req.CreateParagraphBullets.Range |
| 241 | + case req.DeleteParagraphBullets != nil: |
| 242 | + return req.DeleteParagraphBullets.Range |
| 243 | + default: |
| 244 | + return nil |
| 245 | + } |
| 246 | +} |
0 commit comments