diff --git a/.gitignore b/.gitignore index b4f5f18..af4d36c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,4 @@ credentials.json -.claude/settings.local.json \ No newline at end of file +.claude/settings.local.json +/gdocs +/gdocs-cli diff --git a/README.md b/README.md index af60506..39f7c1a 100644 --- a/README.md +++ b/README.md @@ -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: @@ -211,7 +230,7 @@ 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 @@ -219,8 +238,8 @@ modified: (if available) - **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 @@ -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 diff --git a/cmd/gdocs-cli/main.go b/cmd/gdocs-cli/main.go index c262116..b152d20 100644 --- a/cmd/gdocs-cli/main.go +++ b/cmd/gdocs-cli/main.go @@ -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() @@ -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 @@ -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) diff --git a/internal/auth/oauth.go b/internal/auth/oauth.go index 3ec213c..08b0ed4 100644 --- a/internal/auth/oauth.go +++ b/internal/auth/oauth.go @@ -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. @@ -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) } diff --git a/internal/gdocs/comments.go b/internal/gdocs/comments.go new file mode 100644 index 0000000..29bb826 --- /dev/null +++ b/internal/gdocs/comments.go @@ -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 +} diff --git a/internal/markdown/comments.go b/internal/markdown/comments.go new file mode 100644 index 0000000..fa02105 --- /dev/null +++ b/internal/markdown/comments.go @@ -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") + + 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 +} diff --git a/internal/markdown/comments_test.go b/internal/markdown/comments_test.go new file mode 100644 index 0000000..8e8ee52 --- /dev/null +++ b/internal/markdown/comments_test.go @@ -0,0 +1,180 @@ +package markdown + +import ( + "testing" + + "github.com/famasya/gdocs-cli/internal/gdocs" +) + +func TestConvertComments(t *testing.T) { + tests := []struct { + name string + comments []gdocs.Comment + want string + }{ + { + name: "nil comments", + comments: nil, + want: "", + }, + { + name: "empty comments", + comments: []gdocs.Comment{}, + want: "", + }, + { + name: "single comment without quoted text", + comments: []gdocs.Comment{ + { + Author: "Alice", + Content: "This needs clarification.", + CreatedTime: "2025-01-15T10:30:00Z", + }, + }, + want: "## Comments\n\n**Alice** (2025-01-15): This needs clarification.\n\n", + }, + { + name: "single comment with quoted text", + comments: []gdocs.Comment{ + { + Author: "Bob", + Content: "Typo here.", + QuotedText: "the orignal text", + CreatedTime: "2025-03-20T14:00:00Z", + }, + }, + want: "## Comments\n\n> the orignal text\n\n**Bob** (2025-03-20): Typo here.\n\n", + }, + { + name: "resolved comment", + comments: []gdocs.Comment{ + { + Author: "Carol", + Content: "Fixed now.", + CreatedTime: "2025-02-01T08:00:00Z", + Resolved: true, + }, + }, + want: "## Comments\n\n**Carol** (2025-02-01) ✓ resolved: Fixed now.\n\n", + }, + { + name: "comment with replies", + comments: []gdocs.Comment{ + { + Author: "Dave", + Content: "Should we change this?", + CreatedTime: "2025-04-10T12:00:00Z", + Replies: []gdocs.Reply{ + { + Author: "Eve", + Content: "Yes, I agree.", + CreatedTime: "2025-04-10T13:00:00Z", + }, + { + Author: "Dave", + Content: "Done.", + CreatedTime: "2025-04-10T14:00:00Z", + }, + }, + }, + }, + want: "## Comments\n\n**Dave** (2025-04-10): Should we change this?\n ↳ **Eve** (2025-04-10): Yes, I agree.\n ↳ **Dave** (2025-04-10): Done.\n\n", + }, + { + name: "comment with multiline quoted text", + comments: []gdocs.Comment{ + { + Author: "Frank", + Content: "This paragraph is too long.", + QuotedText: "line one\nline two", + CreatedTime: "2025-05-01T09:00:00Z", + }, + }, + want: "## Comments\n\n> line one\n> line two\n\n**Frank** (2025-05-01): This paragraph is too long.\n\n", + }, + { + name: "comment with unknown author", + comments: []gdocs.Comment{ + { + Content: "Anonymous feedback.", + CreatedTime: "2025-06-01T10:00:00Z", + }, + }, + want: "## Comments\n\n**Unknown** (2025-06-01): Anonymous feedback.\n\n", + }, + { + name: "comment with no timestamp", + comments: []gdocs.Comment{ + { + Author: "Grace", + Content: "No date here.", + }, + }, + want: "## Comments\n\n**Grace**: No date here.\n\n", + }, + { + name: "multiple comments", + comments: []gdocs.Comment{ + { + Author: "Alice", + Content: "First comment.", + CreatedTime: "2025-01-01T00:00:00Z", + }, + { + Author: "Bob", + Content: "Second comment.", + QuotedText: "some text", + CreatedTime: "2025-01-02T00:00:00Z", + }, + }, + want: "## Comments\n\n**Alice** (2025-01-01): First comment.\n\n> some text\n\n**Bob** (2025-01-02): Second comment.\n\n", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := ConvertComments(tt.comments) + if got != tt.want { + t.Errorf("ConvertComments() = %q, want %q", got, tt.want) + } + }) + } +} + +func TestFormatTime(t *testing.T) { + tests := []struct { + name string + input string + want string + }{ + { + name: "valid RFC3339", + input: "2025-01-15T10:30:00Z", + want: "2025-01-15", + }, + { + name: "valid RFC3339 with offset", + input: "2025-06-20T14:30:00+07:00", + want: "2025-06-20", + }, + { + name: "empty string", + input: "", + want: "", + }, + { + name: "invalid format", + input: "not-a-date", + want: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := formatTime(tt.input) + if got != tt.want { + t.Errorf("formatTime(%q) = %q, want %q", tt.input, got, tt.want) + } + }) + } +} diff --git a/internal/markdown/converter.go b/internal/markdown/converter.go index 1615887..6850234 100644 --- a/internal/markdown/converter.go +++ b/internal/markdown/converter.go @@ -4,15 +4,17 @@ import ( "fmt" "strings" + "github.com/famasya/gdocs-cli/internal/gdocs" "google.golang.org/api/docs/v1" ) // Converter handles the conversion of Google Docs to markdown. type Converter struct { - doc *docs.Document - body *docs.Body - title string - tabName string + doc *docs.Document + body *docs.Body + title string + tabName string + comments []gdocs.Comment } // NewConverter creates a new Converter for the given document. @@ -50,6 +52,11 @@ func NewConverterFromTab(doc *docs.Document, tab *docs.Tab) *Converter { return c } +// SetComments sets the comments to be appended to the markdown output. +func (c *Converter) SetComments(comments []gdocs.Comment) { + c.comments = comments +} + // Convert processes the entire document and returns markdown. func (c *Converter) Convert() (string, error) { var builder strings.Builder @@ -68,6 +75,12 @@ func (c *Converter) Convert() (string, error) { builder.WriteString(body) } + // Append comments if present + if len(c.comments) > 0 { + builder.WriteString("\n") + builder.WriteString(ConvertComments(c.comments)) + } + return builder.String(), nil }