Skip to content

Commit c530eb3

Browse files
andybergonclaudesteipete
authored
feat: add sheets notes command to read cell notes (#208)
* feat: add `sheets notes` command to read cell notes The existing `sheets get` uses the Values API which doesn't expose cell notes. This adds `sheets notes <spreadsheetId> <range>` which uses the full Spreadsheets.Get API with a narrow field mask to fetch only notes and formatted values, keeping the response payload minimal. Supports both text (tabwriter table) and JSON output modes. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: harden sheets notes output (#208) (thanks @andybergon) --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> Co-authored-by: Peter Steinberger <steipete@gmail.com>
1 parent b9582f2 commit c530eb3

4 files changed

Lines changed: 464 additions & 0 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
- Contacts: update contacts from JSON via `contacts update --from-file` (PR #200 — thanks @jrossi).
99
- Drive: add `drive ls|search --no-all-drives` to restrict queries to "My Drive" for faster/narrower results. (#258)
1010
- Gmail: add `gmail send --quote` to include quoted original message in replies. (#169) — thanks @terry-li-hm.
11+
- Sheets: add `sheets notes` to read cell notes. (#208) — thanks @andybergon.
1112

1213
### Fixed
1314
- Auth: manual OAuth flow uses an ephemeral loopback redirect port (avoids unsafe/privileged ports in browsers). (#172) — thanks @spookyuser.

internal/cmd/sheets.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ type SheetsCmd struct {
2929
Append SheetsAppendCmd `cmd:"" name:"append" aliases:"add" help:"Append values to a range"`
3030
Clear SheetsClearCmd `cmd:"" name:"clear" help:"Clear values in a range"`
3131
Format SheetsFormatCmd `cmd:"" name:"format" help:"Apply cell formatting to a range"`
32+
Notes SheetsNotesCmd `cmd:"" name:"notes" help:"Get cell notes from a range"`
3233
Metadata SheetsMetadataCmd `cmd:"" name:"metadata" aliases:"info" help:"Get spreadsheet metadata"`
3334
Create SheetsCreateCmd `cmd:"" name:"create" aliases:"new" help:"Create a new spreadsheet"`
3435
Copy SheetsCopyCmd `cmd:"" name:"copy" aliases:"cp,duplicate" help:"Copy a Google Sheet"`

internal/cmd/sheets_notes.go

Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
1+
package cmd
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"os"
7+
"regexp"
8+
"strings"
9+
10+
"github.com/steipete/gogcli/internal/outfmt"
11+
"github.com/steipete/gogcli/internal/ui"
12+
)
13+
14+
type SheetsNotesCmd struct {
15+
SpreadsheetID string `arg:"" name:"spreadsheetId" help:"Spreadsheet ID"`
16+
Range string `arg:"" name:"range" help:"Range (eg. Sheet1!A1:B10)"`
17+
}
18+
19+
func (c *SheetsNotesCmd) Run(ctx context.Context, flags *RootFlags) error {
20+
u := ui.FromContext(ctx)
21+
account, err := requireAccount(flags)
22+
if err != nil {
23+
return err
24+
}
25+
26+
spreadsheetID := normalizeGoogleID(strings.TrimSpace(c.SpreadsheetID))
27+
rangeSpec := cleanRange(c.Range)
28+
if spreadsheetID == "" {
29+
return usage("empty spreadsheetId")
30+
}
31+
if strings.TrimSpace(rangeSpec) == "" {
32+
return usage("empty range")
33+
}
34+
35+
svc, err := newSheetsService(ctx, account)
36+
if err != nil {
37+
return err
38+
}
39+
40+
resp, err := svc.Spreadsheets.Get(spreadsheetID).
41+
Ranges(rangeSpec).
42+
IncludeGridData(true).
43+
Fields("sheets(properties(title),data(startRow,startColumn,rowData(values(note,formattedValue))))").
44+
Do()
45+
if err != nil {
46+
return err
47+
}
48+
49+
type cellNote struct {
50+
Sheet string `json:"sheet"`
51+
A1 string `json:"a1"`
52+
Row int `json:"row"`
53+
Col int `json:"col"`
54+
Value string `json:"value"`
55+
Note string `json:"note"`
56+
}
57+
58+
var notes []cellNote
59+
60+
for _, sheet := range resp.Sheets {
61+
if sheet == nil {
62+
continue
63+
}
64+
sheetTitle := ""
65+
if sheet.Properties != nil {
66+
sheetTitle = strings.TrimSpace(sheet.Properties.Title)
67+
}
68+
for _, data := range sheet.Data {
69+
if data == nil {
70+
continue
71+
}
72+
startRow := int(data.StartRow)
73+
startCol := int(data.StartColumn)
74+
for ri, row := range data.RowData {
75+
if row == nil {
76+
continue
77+
}
78+
for ci, cell := range row.Values {
79+
if cell == nil {
80+
continue
81+
}
82+
if cell.Note == "" {
83+
continue
84+
}
85+
absRow := startRow + ri + 1
86+
absCol := startCol + ci + 1
87+
notes = append(notes, cellNote{
88+
Sheet: sheetTitle,
89+
A1: formatA1Cell(sheetTitle, absRow, absCol),
90+
Row: absRow,
91+
Col: absCol,
92+
Value: cell.FormattedValue,
93+
Note: cell.Note,
94+
})
95+
}
96+
}
97+
}
98+
}
99+
100+
if outfmt.IsJSON(ctx) {
101+
return outfmt.WriteJSON(ctx, os.Stdout, map[string]any{
102+
"spreadsheetId": spreadsheetID,
103+
"range": rangeSpec,
104+
"notes": notes,
105+
})
106+
}
107+
108+
if len(notes) == 0 {
109+
u.Err().Println("No notes found")
110+
return nil
111+
}
112+
113+
w, flush := tableWriter(ctx)
114+
defer flush()
115+
fmt.Fprintln(w, "A1\tVALUE\tNOTE")
116+
for _, n := range notes {
117+
fmt.Fprintf(w, "%s\t%s\t%s\n",
118+
oneLine(n.A1),
119+
oneLine(n.Value),
120+
oneLine(n.Note),
121+
)
122+
}
123+
return nil
124+
}
125+
126+
var simpleSheetNameRe = regexp.MustCompile(`^[A-Za-z0-9_]+$`)
127+
128+
func formatA1Cell(sheetTitle string, row, col int) string {
129+
colLetters, err := colIndexToLetters(col)
130+
if err != nil || row <= 0 {
131+
return ""
132+
}
133+
cell := fmt.Sprintf("%s%d", colLetters, row)
134+
if strings.TrimSpace(sheetTitle) == "" {
135+
return cell
136+
}
137+
return formatSheetPrefix(sheetTitle) + cell
138+
}
139+
140+
func formatSheetPrefix(sheetTitle string) string {
141+
title := strings.TrimSpace(sheetTitle)
142+
if title == "" {
143+
return ""
144+
}
145+
if simpleSheetNameRe.MatchString(title) {
146+
return title + "!"
147+
}
148+
escaped := strings.ReplaceAll(title, "'", "''")
149+
return "'" + escaped + "'!"
150+
}
151+
152+
func colIndexToLetters(col int) (string, error) {
153+
if col <= 0 {
154+
return "", fmt.Errorf("invalid column index %d", col)
155+
}
156+
var b []byte
157+
for col > 0 {
158+
col--
159+
b = append(b, byte('A'+(col%26)))
160+
col /= 26
161+
}
162+
for i, j := 0, len(b)-1; i < j; i, j = i+1, j-1 {
163+
b[i], b[j] = b[j], b[i]
164+
}
165+
return string(b), nil
166+
}
167+
168+
func oneLine(s string) string {
169+
s = strings.ReplaceAll(s, "\r\n", "\n")
170+
s = strings.ReplaceAll(s, "\r", "\n")
171+
// Keep output parseable in tables/TSV.
172+
s = strings.ReplaceAll(s, "\t", " ")
173+
s = strings.ReplaceAll(s, "\n", "\\n")
174+
return s
175+
}

0 commit comments

Comments
 (0)