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
4 changes: 3 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
@@ -1,2 +1,4 @@
credentials.json
.claude/settings.local.json
.claude/settings.local.json
/gdocs
/gdocs-cli
27 changes: 23 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,25 @@ The tool will automatically use the cached token - no browser interaction needed
./gdocs-cli --url="..." | grep "keyword"
```

### Include Comments

Use the `--comments` flag to include document comments in the markdown output:

```bash
./gdocs-cli --url="https://docs.google.com/document/d/YOUR_DOC_ID/edit" --comments
```

This appends a `## Comments` section at the end of the markdown with quoted text, author, date, and replies.

> **⚠️ Important:** The `--comments` flag requires the `https://www.googleapis.com/auth/drive.readonly` scope. If you previously authenticated without this scope, you need to delete your cached token and re-authenticate:
>
> ```bash
> rm ~/.config/gdocs-cli/token.json
> ./gdocs-cli --init
> ```
>
> Also make sure you don't have non-HTTPS redirect URIs in any of your Google OAuth clients, as Google requires HTTPS for the Drive API scope.

### Clean Output (Suppress Logs)

Use the `--clean` flag to suppress all log output and only show the markdown:
Expand Down Expand Up @@ -211,16 +230,16 @@ modified: (if available)
---
```

**Note:** The Google Docs API v1 doesn't provide author or date information. These fields may be empty unless fetched from Google Drive API.
**Note:** The Google Docs API v1 doesn't provide author or date information for the document. These fields may be empty in the frontmatter unless fetched from Google Drive API. Comments are supported via `--comments` flag (see below).

## Known Limitations

- **Tables:** Complex tables with merged cells may not convert perfectly to Markdown
- **Images:** Inline images are not currently supported
- **Drawings:** Not supported - will be skipped
- **Equations:** Not supported - will be skipped
- **Comments:** Not included in output (not in API response by default)
- **Metadata:** Author and dates require Google Drive API (not implemented in this version)
- **Comments:** Supported via `--comments` flag (requires Drive API scope, see below)
- **Metadata:** Author and dates in frontmatter are not yet extracted (comments via Drive API are supported)

## Troubleshooting

Expand Down Expand Up @@ -334,7 +353,7 @@ go test ./internal/auth -v

- **Credentials file:** Never commit your `credentials.json` to version control
- **Token cache:** Tokens are stored in `~/.config/gdocs-cli/token.json` with 0600 permissions (read/write for owner only)
- **OAuth scope:** The tool only requests `documents.readonly` scope - no write access
- **OAuth scope:** The tool requests `documents.readonly` and `drive.readonly` scopes - no write access
- **Config directory:** Created with 0700 permissions (accessible only by owner)

## License
Expand Down
18 changes: 16 additions & 2 deletions cmd/gdocs-cli/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ func main() {
configFlag := flag.String("config", "", "Path to OAuth credentials JSON file (defaults to ~/.config/gdocs-cli/config.json)")
initFlag := flag.Bool("init", false, "Initialize OAuth and save token to default location")
cleanFlag := flag.Bool("clean", false, "Clean output (suppress all logs, only output markdown)")
commentsFlag := flag.Bool("comments", false, "Include document comments in the markdown output")
instructionFlag := flag.Bool("instruction", false, "Print integration instructions for AI coding agents")
flag.Parse()

Expand Down Expand Up @@ -66,13 +67,15 @@ func main() {
}

// Run the main logic
if err := run(*urlFlag, configPath); err != nil {
if err := run(*urlFlag, configPath, *commentsFlag); err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
}

func run(docURL, credPath string) error {
// run executes the main logic of the CLI.
// It handles authentication, document fetching, and markdown conversion.
func run(docURL, credPath string, includeComments bool) error {
ctx := context.Background()

// Extract document ID from URL
Expand Down Expand Up @@ -130,6 +133,17 @@ func run(docURL, credPath string) error {
converter = markdown.NewConverter(doc)
}

// Fetch and attach comments if requested
if includeComments {
log.Println("Fetching comments...")
comments, err := gdocs.FetchComments(ctx, httpClient, docID)
if err != nil {
return fmt.Errorf("failed to fetch comments: %w", err)
}
log.Printf("Found %d comment(s)", len(comments))
converter.SetComments(comments)
}

markdownOutput, err := converter.Convert()
if err != nil {
return fmt.Errorf("conversion failed: %w", err)
Expand Down
4 changes: 3 additions & 1 deletion internal/auth/oauth.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ const (

// Google Docs API scope for read-only access
docsScope = "https://www.googleapis.com/auth/documents.readonly"
// Google Drive API scope for read-only access (used for fetching comments)
driveReadonlyScope = "https://www.googleapis.com/auth/drive.readonly"
)

// Authenticator handles OAuth2 authentication for Google Docs API.
Expand All @@ -37,7 +39,7 @@ func NewAuthenticator(credPath string) (*Authenticator, error) {
}

// Parse credentials and create OAuth2 config
config, err := google.ConfigFromJSON(credBytes, docsScope)
config, err := google.ConfigFromJSON(credBytes, docsScope, driveReadonlyScope)
if err != nil {
return nil, fmt.Errorf("failed to parse credentials: %w", err)
}
Expand Down
86 changes: 86 additions & 0 deletions internal/gdocs/comments.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
package gdocs

import (
"context"
"fmt"
"net/http"

"google.golang.org/api/drive/v3"
"google.golang.org/api/option"
)

// Comment represents a simplified Google Docs comment.
type Comment struct {
Author string
Content string
QuotedText string
CreatedTime string
Resolved bool
Replies []Reply
}

// Reply represents a reply to a comment.
type Reply struct {
Author string
Content string
CreatedTime string
}

// FetchComments retrieves all comments for a document using the Drive API.
func FetchComments(ctx context.Context, httpClient *http.Client, docID string) ([]Comment, error) {
srv, err := drive.NewService(ctx, option.WithHTTPClient(httpClient))
if err != nil {
return nil, fmt.Errorf("unable to create Drive service: %w", err)
}

var comments []Comment
pageToken := ""
for {
call := srv.Comments.List(docID).Fields("comments(author(displayName),content,quotedFileContent,createdTime,resolved,replies(author(displayName),content,createdTime)),nextPageToken").PageSize(100).Context(ctx)
if pageToken != "" {
call = call.PageToken(pageToken)
}
resp, err := call.Do()
if err != nil {
return nil, fmt.Errorf("unable to retrieve comments: %w", err)
}

for _, c := range resp.Comments {
if c.Deleted {
continue
}
comment := Comment{
Content: c.Content,
CreatedTime: c.CreatedTime,
Resolved: c.Resolved,
}
if c.Author != nil {
comment.Author = c.Author.DisplayName
}
if c.QuotedFileContent != nil {
comment.QuotedText = c.QuotedFileContent.Value
}
for _, r := range c.Replies {
if r.Deleted {
continue
}
reply := Reply{
Content: r.Content,
CreatedTime: r.CreatedTime,
}
if r.Author != nil {
reply.Author = r.Author.DisplayName
}
comment.Replies = append(comment.Replies, reply)
}
comments = append(comments, comment)
}

pageToken = resp.NextPageToken
if pageToken == "" {
break
}
}

return comments, nil
}
80 changes: 80 additions & 0 deletions internal/markdown/comments.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
package markdown

import (
"fmt"
"strings"
"time"

"github.com/famasya/gdocs-cli/internal/gdocs"
)

// ConvertComments renders a list of comments as a markdown section.
func ConvertComments(comments []gdocs.Comment) string {
if len(comments) == 0 {
return ""
}

var builder strings.Builder
builder.WriteString("## Comments\n\n")
Comment on lines +17 to +18
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Missing newline separator before the ## Comments heading.

ConvertComments is appended directly after the body content in converter.go. If the body doesn't end with a blank line, the heading will run into the last paragraph. Prepend a newline to ensure clean separation.

Proposed fix
 var builder strings.Builder
+builder.WriteString("\n")
 builder.WriteString("## Comments\n\n")
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
var builder strings.Builder
builder.WriteString("## Comments\n\n")
var builder strings.Builder
builder.WriteString("\n")
builder.WriteString("## Comments\n\n")
🤖 Prompt for AI Agents
In `@internal/markdown/comments.go` around lines 17 - 18, The "## Comments"
heading is being appended directly to the body without a preceding blank line,
so update the logic in ConvertComments (the builder using the strings.Builder
variable named builder) to prepend a newline before the heading (i.e., ensure
the heading is written as "\n## Comments\n\n" or otherwise guarantees a blank
line separator) so the comments section is separated from the preceding content.


for _, c := range comments {
if c.QuotedText != "" {
builder.WriteString("> ")
builder.WriteString(strings.ReplaceAll(c.QuotedText, "\n", "\n> "))
builder.WriteString("\n\n")
}

author := c.Author
if author == "" {
author = "Unknown"
}
author = escapeMarkdown(author)
builder.WriteString(fmt.Sprintf("**%s**", author))
if ts := formatTime(c.CreatedTime); ts != "" {
builder.WriteString(fmt.Sprintf(" (%s)", ts))
}
if c.Resolved {
builder.WriteString(" ✓ resolved")
}
builder.WriteString(": ")
builder.WriteString(c.Content)
builder.WriteString("\n")

for _, r := range c.Replies {
rAuthor := r.Author
if rAuthor == "" {
rAuthor = "Unknown"
}
rAuthor = escapeMarkdown(rAuthor)
builder.WriteString(fmt.Sprintf(" ↳ **%s**", rAuthor))
if ts := formatTime(r.CreatedTime); ts != "" {
builder.WriteString(fmt.Sprintf(" (%s)", ts))
}
builder.WriteString(": ")
builder.WriteString(r.Content)
builder.WriteString("\n")
}

builder.WriteString("\n")
}

return builder.String()
}

// formatTime converts an RFC 3339 timestamp to a short date string.
func formatTime(rfc3339 string) string {
if rfc3339 == "" {
return ""
}
t, err := time.Parse(time.RFC3339, rfc3339)
if err != nil {
return ""
}
return t.Format("2006-01-02")
}

func escapeMarkdown(s string) string {
s = strings.ReplaceAll(s, "*", "\\*")
s = strings.ReplaceAll(s, "_", "\\_")
return s
}
Loading