Skip to content
Open
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
85 changes: 61 additions & 24 deletions internal/cmd/comment_ops.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,8 @@ const (
driveCommentListFields = "nextPageToken"
driveCommentListCoreFields = "comments(id,author,content,createdTime,modifiedTime,resolved,replies)"
driveCommentListQuotedFields = "comments(id,author,content,createdTime,modifiedTime,resolved,quotedFileContent,replies)"
docsCommentListFields = "comments(id,author,content,createdTime,modifiedTime,resolved,quotedFileContent,replies(id,author,content,createdTime,modifiedTime,action,deleted))"
docsCommentListFields = "comments(id,author,content,createdTime,modifiedTime,resolved,quotedFileContent,replies(id,author,content,createdTime,modifiedTime,action,deleted))"
docsCommentListFieldsWithAnchors = "comments(id,author,content,createdTime,modifiedTime,resolved,quotedFileContent,anchor,replies(id,author,content,createdTime,modifiedTime,action,deleted))"
driveCommentDetailFields = "id, author, content, createdTime, modifiedTime, resolved, quotedFileContent, anchor, replies"
driveCommentCreateFields = "id, author, content, createdTime, quotedFileContent, anchor"
driveCommentUpdateFields = "id, author, content, modifiedTime"
Expand All @@ -44,6 +45,7 @@ type driveCommentListOptions struct {
max int64
emptyMessage string
mode driveCommentListMode
showAnchors bool
}

func listDriveComments(ctx context.Context, svc *drive.Service, fileID string, opts driveCommentListOptions) ([]*drive.Comment, string, error) {
Expand Down Expand Up @@ -102,6 +104,9 @@ func fetchDriveCommentsPage(ctx context.Context, svc *drive.Service, fileID stri

func driveCommentFieldsForList(opts driveCommentListOptions) string {
if opts.mode == driveCommentListModeExpanded {
if opts.showAnchors {
return docsCommentListFieldsWithAnchors
}
return docsCommentListFields
}
if opts.includeQuoted {
Expand All @@ -125,18 +130,22 @@ func writeDriveCommentList(ctx context.Context, u *ui.UI, opts driveCommentListO
}

if opts.mode == driveCommentListModeExpanded {
printExpandedCommentTable(ctx, comments)
printExpandedCommentTable(ctx, comments, opts.showAnchors)
} else {
printCompactCommentTable(ctx, comments, opts.includeQuoted)
}
printNextPageHint(u, nextPageToken)
return nil
}

func printExpandedCommentTable(ctx context.Context, comments []*drive.Comment) {
func printExpandedCommentTable(ctx context.Context, comments []*drive.Comment, showAnchors bool) {
w, flush := tableWriter(ctx)
defer flush()
fmt.Fprintln(w, "TYPE\tID\tAUTHOR\tQUOTED\tCONTENT\tCREATED\tRESOLVED\tACTION")
if showAnchors {
fmt.Fprintln(w, "TYPE\tID\tAUTHOR\tQUOTED\tCONTENT\tCREATED\tRESOLVED\tACTION\tANCHOR")
} else {
fmt.Fprintln(w, "TYPE\tID\tAUTHOR\tQUOTED\tCONTENT\tCREATED\tRESOLVED\tACTION")
}
for _, comment := range comments {
if comment == nil {
continue
Expand All @@ -149,16 +158,30 @@ func printExpandedCommentTable(ctx context.Context, comments []*drive.Comment) {
if comment.QuotedFileContent != nil {
quoted = truncateString(oneLineTSV(comment.QuotedFileContent.Value), 30)
}
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\t%t\t%s\n",
"comment",
comment.Id,
oneLineTSV(author),
quoted,
truncateString(oneLineTSV(comment.Content), 50),
formatDateTime(comment.CreatedTime),
comment.Resolved,
"",
)
if showAnchors {
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\t%t\t%s\t%s\n",
"comment",
comment.Id,
oneLineTSV(author),
quoted,
truncateString(oneLineTSV(comment.Content), 50),
formatDateTime(comment.CreatedTime),
comment.Resolved,
"",
truncateString(oneLineTSV(comment.Anchor), 60),
)
} else {
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\t%t\t%s\n",
"comment",
comment.Id,
oneLineTSV(author),
quoted,
truncateString(oneLineTSV(comment.Content), 50),
formatDateTime(comment.CreatedTime),
comment.Resolved,
"",
)
}
for _, reply := range comment.Replies {
if reply == nil {
continue
Expand All @@ -167,16 +190,30 @@ func printExpandedCommentTable(ctx context.Context, comments []*drive.Comment) {
if reply.Author != nil {
author = reply.Author.DisplayName
}
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n",
"reply",
reply.Id,
oneLineTSV(author),
"",
truncateString(oneLineTSV(reply.Content), 50),
formatDateTime(reply.CreatedTime),
"",
oneLineTSV(reply.Action),
)
if showAnchors {
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n",
"reply",
reply.Id,
oneLineTSV(author),
"",
truncateString(oneLineTSV(reply.Content), 50),
formatDateTime(reply.CreatedTime),
"",
oneLineTSV(reply.Action),
"",
)
} else {
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n",
"reply",
reply.Id,
oneLineTSV(author),
"",
truncateString(oneLineTSV(reply.Content), 50),
formatDateTime(reply.CreatedTime),
"",
oneLineTSV(reply.Action),
)
}
}
}
}
Expand Down
1 change: 1 addition & 0 deletions internal/cmd/docs.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ type DocsCmd struct {
Update DocsUpdateCmd `cmd:"" name:"update" help:"Insert text at a specific index in a Google Doc"`
Edit DocsEditCmd `cmd:"" name:"edit" help:"Find and replace text in a Google Doc"`
Sed DocsSedCmd `cmd:"" name:"sed" help:"Regex find/replace (sed-style: s/pattern/replacement/g)"`
Format DocsFormatCmd `cmd:"" name:"format" aliases:"fmt" help:"Apply formatting (font, color, alignment) to text in a Google Doc"`
Clear DocsClearCmd `cmd:"" name:"clear" help:"Clear all content from a Google Doc"`
Structure DocsStructureCmd `cmd:"" name:"structure" aliases:"struct" help:"Show document structure with numbered paragraphs"`
}
Expand Down
7 changes: 5 additions & 2 deletions internal/cmd/docs_comments.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ type DocsCommentsListCmd struct {
Page string `name:"page" aliases:"cursor" help:"Page token for pagination"`
All bool `name:"all" aliases:"all-pages" help:"Fetch all pages"`
FailEmpty bool `name:"fail-empty" aliases:"non-empty,require-results" help:"Exit with code 3 if no results"`
ShowAnchors bool `name:"show-anchors" aliases:"anchors" help:"Include anchor data (kix IDs) in output for discovering anchor positions from UI-created comments"`
}

func (c *DocsCommentsListCmd) Run(ctx context.Context, flags *RootFlags) error {
Expand Down Expand Up @@ -53,6 +54,7 @@ func (c *DocsCommentsListCmd) Run(ctx context.Context, flags *RootFlags) error {
max: c.Max,
emptyMessage: "No comments",
mode: driveCommentListModeExpanded,
showAnchors: c.ShowAnchors,
})
if err != nil {
return err
Expand All @@ -63,6 +65,7 @@ func (c *DocsCommentsListCmd) Run(ctx context.Context, flags *RootFlags) error {
failEmpty: c.FailEmpty,
emptyMessage: "No comments",
mode: driveCommentListModeExpanded,
showAnchors: c.ShowAnchors,
}, comments, nextPageToken)
}

Expand Down Expand Up @@ -99,8 +102,8 @@ func (c *DocsCommentsGetCmd) Run(ctx context.Context, flags *RootFlags) error {
type DocsCommentsAddCmd struct {
DocID string `arg:"" name:"docId" help:"Google Doc ID or URL"`
Content string `arg:"" name:"content" help:"Comment text"`
Quoted string `name:"quoted" help:"Quoted text to attach to the comment (shown in UIs when available)"`
Anchor string `name:"anchor" help:"Anchor JSON string (advanced; editor UIs may still treat as unanchored)"`
Quoted string `name:"quoted" help:"Quoted text metadata (note: does NOT anchor the comment to specific text due to a Google API limitation — appears as a doc-level comment)"`
Anchor string `name:"anchor" help:"Anchor JSON using kix IDs (only working format for inline anchoring). kix IDs are internal to Google and not exposed by any API — use 'docs comments list --show-anchors' on UI-created comments to discover reusable IDs. Text-offset anchors are accepted but silently ignored."`
}

func (c *DocsCommentsAddCmd) Run(ctx context.Context, flags *RootFlags) error {
Expand Down
168 changes: 168 additions & 0 deletions internal/cmd/docs_edit.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,16 @@ type DocsWriteCmd struct {
Append bool `name:"append" help:"Append instead of replacing the document body"`
Pageless bool `name:"pageless" help:"Set document to pageless mode"`
TabID string `name:"tab-id" help:"Target a specific tab by ID (see docs list-tabs)"`

// Formatting flags (applied after content write)
FontFamily string `name:"font-family" help:"Font family (e.g. Arial, Georgia, Times New Roman)"`
FontSize float64 `name:"font-size" help:"Font size in points (e.g. 12, 14, 16)"`
TextColor string `name:"text-color" help:"Text color as hex (#RRGGBB)"`
BgColor string `name:"bg-color" help:"Background highlight color as hex (#RRGGBB)"`
Alignment string `name:"alignment" help:"Paragraph alignment: left|center|right|justified"`
Underline bool `name:"underline" help:"Apply underline to written text"`
Strikethrough bool `name:"strikethrough" help:"Apply strikethrough to written text"`
LineSpacing float64 `name:"line-spacing" help:"Line spacing percentage (e.g. 150 = 1.5x)"`
}

func (c *DocsWriteCmd) Run(ctx context.Context, kctx *kong.Context, flags *RootFlags) error {
Expand Down Expand Up @@ -85,6 +95,25 @@ func (c *DocsWriteCmd) Run(ctx context.Context, kctx *kong.Context, flags *RootF
}
}

// Apply formatting if any flags set
fmtOpts := c.formattingOpts()
if fmtOpts.hasAny() {
docEnd, endErr := docsTargetEndIndex(ctx, svc, id, c.TabID)
if endErr != nil {
return fmt.Errorf("re-fetch document for formatting: %w", endErr)
}
fmtEnd := docEnd - 1
if fmtEnd > 1 {
fmtReqs := buildFormattingRequests(1, fmtEnd, fmtOpts)
if len(fmtReqs) > 0 {
_, err = svc.Documents.BatchUpdate(id, &docs.BatchUpdateDocumentRequest{Requests: fmtReqs}).Context(ctx).Do()
if err != nil {
return fmt.Errorf("apply formatting: %w", err)
}
}
}
}

if outfmt.IsJSON(ctx) {
payload := map[string]any{
"documentId": resp.DocumentId,
Expand Down Expand Up @@ -114,6 +143,23 @@ func (c *DocsWriteCmd) Run(ctx context.Context, kctx *kong.Context, flags *RootF
return nil
}

func (c *DocsWriteCmd) formattingOpts() FormattingOpts {
opts := FormattingOpts{
FontFamily: c.FontFamily, FontSize: c.FontSize,
TextColor: c.TextColor, BgColor: c.BgColor,
Alignment: c.Alignment, LineSpacing: c.LineSpacing,
}
if c.Underline {
v := true
opts.Underline = &v
}
if c.Strikethrough {
v := true
opts.Strikethrough = &v
}
return opts
}

type DocsUpdateCmd struct {
DocID string `arg:"" name:"docId" help:"Doc ID"`
Text string `name:"text" help:"Text to insert"`
Expand Down Expand Up @@ -543,3 +589,125 @@ func (c *DocsFindReplaceCmd) resolveReplaceText() (string, error) {
}
return string(data), nil
}

// DocsFormatCmd applies formatting to existing text in a Google Doc.
type DocsFormatCmd struct {
DocID string `arg:"" name:"docId" help:"Doc ID"`
Match string `name:"match" help:"Text to find and format (first occurrence unless --match-all)"`
All bool `name:"match-all" help:"Format all occurrences of --match text"`

FontFamily string `name:"font-family" help:"Font family (e.g. Arial, Georgia)"`
FontSize float64 `name:"font-size" help:"Font size in points"`
TextColor string `name:"text-color" help:"Text color as hex (#RRGGBB)"`
BgColor string `name:"bg-color" help:"Background highlight color as hex (#RRGGBB)"`
Bold bool `name:"bold" help:"Apply bold"`
Italic bool `name:"italic" help:"Apply italic"`
Underline bool `name:"underline" help:"Apply underline"`
Strikethrough bool `name:"strikethrough" help:"Apply strikethrough"`
Alignment string `name:"alignment" help:"Paragraph alignment: left|center|right|justified"`
LineSpacing float64 `name:"line-spacing" help:"Line spacing percentage (e.g. 150 = 1.5x)"`
}

func (c *DocsFormatCmd) Run(ctx context.Context, flags *RootFlags) error {
u := ui.FromContext(ctx)
id := strings.TrimSpace(c.DocID)
if id == "" {
return usage("empty docId")
}

opts := c.formattingOpts()
if !opts.hasAny() {
return usage("at least one formatting flag is required")
}

svc, err := requireDocsService(ctx, flags)
if err != nil {
return err
}

doc, err := svc.Documents.Get(id).Context(ctx).Do()
if err != nil {
if isDocsNotFound(err) {
return fmt.Errorf("doc not found or not a Google Doc (id=%s)", id)
}
return err
}

type fmtRange struct{ start, end int64 }
var ranges []fmtRange

if c.Match != "" {
if c.All {
matches := findTextMatches(doc, c.Match, true)
for _, m := range matches {
ranges = append(ranges, fmtRange{m.startIndex, m.endIndex})
}
} else {
start, end, total := findTextInDoc(doc, c.Match, true)
if total == 0 {
return fmt.Errorf("text %q not found in document", c.Match)
}
ranges = append(ranges, fmtRange{start, end})
}
if len(ranges) == 0 {
return fmt.Errorf("text %q not found in document", c.Match)
}
} else {
// Apply to entire document
if doc.Body != nil && len(doc.Body.Content) > 0 {
last := doc.Body.Content[len(doc.Body.Content)-1]
if last != nil && last.EndIndex > 2 {
ranges = append(ranges, fmtRange{1, last.EndIndex - 1})
}
}
if len(ranges) == 0 {
return fmt.Errorf("document is empty")
}
}

var allReqs []*docs.Request
for _, r := range ranges {
allReqs = append(allReqs, buildFormattingRequests(r.start, r.end, opts)...)
}

_, err = svc.Documents.BatchUpdate(id, &docs.BatchUpdateDocumentRequest{Requests: allReqs}).Context(ctx).Do()
if err != nil {
return fmt.Errorf("apply formatting: %w", err)
}

if outfmt.IsJSON(ctx) {
return outfmt.WriteJSON(ctx, os.Stdout, map[string]any{
"success": true,
"documentId": id,
"ranges": len(ranges),
})
}

u.Out().Printf("Formatted %d range(s) in document %s", len(ranges), id)
return nil
}

func (c *DocsFormatCmd) formattingOpts() FormattingOpts {
opts := FormattingOpts{
FontFamily: c.FontFamily, FontSize: c.FontSize,
TextColor: c.TextColor, BgColor: c.BgColor,
Alignment: c.Alignment, LineSpacing: c.LineSpacing,
}
if c.Bold {
v := true
opts.Bold = &v
}
if c.Italic {
v := true
opts.Italic = &v
}
if c.Underline {
v := true
opts.Underline = &v
}
if c.Strikethrough {
v := true
opts.Strikethrough = &v
}
return opts
}
Loading