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
24 changes: 23 additions & 1 deletion cmd/gdocs-cli/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,9 @@ func run(docURL, credPath string) error {
return fmt.Errorf("invalid URL: %w", err)
}

// Extract tab ID from URL (may be empty)
tabID := gdocs.ExtractTabID(docURL)

// Create authenticator
authenticator, err := auth.NewAuthenticator(credPath)
if err != nil {
Expand All @@ -107,7 +110,26 @@ func run(docURL, credPath string) error {
}

// Convert to markdown
converter := markdown.NewConverter(doc)
var converter *markdown.Converter
if tabID != "" {
// Find the specific tab
tab := gdocs.FindTab(doc, tabID)
if tab == nil {
return fmt.Errorf("tab '%s' not found in document", tabID)
}
if tab.DocumentTab == nil || tab.DocumentTab.Body == nil {
return fmt.Errorf("tab '%s' has no document content", tabID)
}
tabTitle := tabID
if tab.TabProperties != nil {
tabTitle = tab.TabProperties.Title
}
log.Printf("Using tab: %s", tabTitle)
converter = markdown.NewConverterFromTab(doc, tab)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
} else {
Comment thread
coderabbitai[bot] marked this conversation as resolved.
converter = markdown.NewConverter(doc)
}

markdownOutput, err := converter.Convert()
if err != nil {
return fmt.Errorf("conversion failed: %w", err)
Expand Down
47 changes: 45 additions & 2 deletions internal/gdocs/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,12 +24,55 @@ func NewClient(ctx context.Context, httpClient *http.Client) (*Client, error) {
return &Client{service: service}, nil
}

// FetchDocument retrieves a Google Docs document by its ID.
// FetchDocument retrieves a Google Docs document by its ID with all tabs included.
func (c *Client) FetchDocument(docID string) (*docs.Document, error) {
doc, err := c.service.Documents.Get(docID).Do()
doc, err := c.service.Documents.Get(docID).IncludeTabsContent(true).Do()
if err != nil {
return nil, fmt.Errorf("unable to retrieve document: %w\n\nThis could mean:\n1. The document is private and you don't have permission\n2. The document doesn't exist\n3. The document ID is incorrect", err)
}

return doc, nil
}

// FindTab searches for a tab by ID in the document's tab tree.
// Returns nil if the tab is not found.
func FindTab(doc *docs.Document, tabID string) *docs.Tab {
if doc == nil || doc.Tabs == nil {
return nil
}

for _, tab := range doc.Tabs {
if found := findTabRecursive(tab, tabID); found != nil {
return found
}
}

return nil
}

// findTabRecursive recursively searches for a tab by ID.
func findTabRecursive(tab *docs.Tab, tabID string) *docs.Tab {
if tab == nil {
return nil
}
if tab.TabProperties != nil && tab.TabProperties.TabId == tabID {
return tab
}

for _, child := range tab.ChildTabs {
if found := findTabRecursive(child, tabID); found != nil {
return found
}
}

return nil
}
Comment on lines +53 to +69
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Potential nil pointer dereference if a child tab is nil.

The function accesses tab.TabProperties without first checking if tab itself is nil. While the Google Docs API likely never returns nil entries in the ChildTabs slice, defensive programming would add a nil check.

🛡️ Suggested defensive fix
 // findTabRecursive recursively searches for a tab by ID.
 func findTabRecursive(tab *docs.Tab, tabID string) *docs.Tab {
+	if tab == nil {
+		return nil
+	}
 	if tab.TabProperties != nil && tab.TabProperties.TabId == tabID {
 		return tab
 	}

 	for _, child := range tab.ChildTabs {
 		if found := findTabRecursive(child, tabID); found != nil {
 			return found
 		}
 	}

 	return nil
 }
📝 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
// findTabRecursive recursively searches for a tab by ID.
func findTabRecursive(tab *docs.Tab, tabID string) *docs.Tab {
if tab.TabProperties != nil && tab.TabProperties.TabId == tabID {
return tab
}
for _, child := range tab.ChildTabs {
if found := findTabRecursive(child, tabID); found != nil {
return found
}
}
return nil
}
// findTabRecursive recursively searches for a tab by ID.
func findTabRecursive(tab *docs.Tab, tabID string) *docs.Tab {
if tab == nil {
return nil
}
if tab.TabProperties != nil && tab.TabProperties.TabId == tabID {
return tab
}
for _, child := range tab.ChildTabs {
if found := findTabRecursive(child, tabID); found != nil {
return found
}
}
return nil
}
🤖 Prompt for AI Agents
In `@internal/gdocs/client.go` around lines 53 - 66, The function findTabRecursive
should defensively handle nil pointers: first check if the input tab is nil
before accessing tab.TabProperties, and when iterating tab.ChildTabs skip any
nil child entries before recursing; update findTabRecursive to return nil
immediately if tab == nil, guard the TabProperties access, and only call
findTabRecursive(child, ...) for non-nil child values to avoid potential nil
pointer dereferences when traversing docs.Tab and its ChildTabs.


// GetFirstTab returns the first tab in the document.
// Returns nil if the document has no tabs.
func GetFirstTab(doc *docs.Document) *docs.Tab {
if doc == nil || doc.Tabs == nil || len(doc.Tabs) == 0 {
return nil
}
return doc.Tabs[0]
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
184 changes: 184 additions & 0 deletions internal/gdocs/client_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
package gdocs

import (
"testing"

"google.golang.org/api/docs/v1"
)

func TestFindTab(t *testing.T) {
// Create a mock document with nested tabs
doc := &docs.Document{
Title: "Test Document",
Tabs: []*docs.Tab{
{
TabProperties: &docs.TabProperties{
TabId: "t.tab1",
Title: "Tab 1",
},
DocumentTab: &docs.DocumentTab{
Body: &docs.Body{},
},
ChildTabs: []*docs.Tab{
{
TabProperties: &docs.TabProperties{
TabId: "t.tab1child1",
Title: "Tab 1 Child 1",
},
DocumentTab: &docs.DocumentTab{
Body: &docs.Body{},
},
},
},
},
{
TabProperties: &docs.TabProperties{
TabId: "t.tab2",
Title: "Tab 2",
},
DocumentTab: &docs.DocumentTab{
Body: &docs.Body{},
},
},
},
}

tests := []struct {
name string
tabID string
wantTitle string
wantNil bool
}{
{
name: "find top-level tab",
tabID: "t.tab1",
wantTitle: "Tab 1",
wantNil: false,
},
{
name: "find second top-level tab",
tabID: "t.tab2",
wantTitle: "Tab 2",
wantNil: false,
},
{
name: "find nested child tab",
tabID: "t.tab1child1",
wantTitle: "Tab 1 Child 1",
wantNil: false,
},
{
name: "tab not found",
tabID: "t.nonexistent",
wantNil: true,
},
{
name: "empty tab ID",
tabID: "",
wantNil: true,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := FindTab(doc, tt.tabID)
if tt.wantNil {
if got != nil {
t.Errorf("FindTab() = %v, want nil", got)
}
return
}
if got == nil {
t.Errorf("FindTab() = nil, want tab with title %q", tt.wantTitle)
return
}
if got.TabProperties.Title != tt.wantTitle {
t.Errorf("FindTab() title = %q, want %q", got.TabProperties.Title, tt.wantTitle)
}
})
}
}

func TestFindTab_NilTabs(t *testing.T) {
doc := &docs.Document{
Title: "Test Document",
Tabs: nil,
}

got := FindTab(doc, "t.any")
if got != nil {
t.Errorf("FindTab() with nil tabs = %v, want nil", got)
}
}

func TestFindTab_NilDocument(t *testing.T) {
got := FindTab(nil, "t.any")
if got != nil {
t.Errorf("FindTab() with nil document = %v, want nil", got)
}
}

func TestGetFirstTab_NilDocument(t *testing.T) {
got := GetFirstTab(nil)
if got != nil {
t.Errorf("GetFirstTab() with nil document = %v, want nil", got)
}
}

func TestGetFirstTab(t *testing.T) {
tests := []struct {
name string
doc *docs.Document
wantTitle string
wantNil bool
}{
{
name: "document with tabs",
doc: &docs.Document{
Tabs: []*docs.Tab{
{
TabProperties: &docs.TabProperties{
TabId: "t.first",
Title: "First Tab",
},
},
},
},
wantTitle: "First Tab",
wantNil: false,
},
{
name: "document with no tabs",
doc: &docs.Document{
Tabs: []*docs.Tab{},
},
wantNil: true,
},
{
name: "document with nil tabs",
doc: &docs.Document{
Tabs: nil,
},
wantNil: true,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := GetFirstTab(tt.doc)
if tt.wantNil {
if got != nil {
t.Errorf("GetFirstTab() = %v, want nil", got)
}
return
}
if got == nil {
t.Errorf("GetFirstTab() = nil, want tab with title %q", tt.wantTitle)
return
}
if got.TabProperties.Title != tt.wantTitle {
t.Errorf("GetFirstTab() title = %q, want %q", got.TabProperties.Title, tt.wantTitle)
}
})
}
}
16 changes: 16 additions & 0 deletions internal/gdocs/url.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,3 +23,19 @@ func ExtractDocumentID(url string) (string, error) {

return matches[1], nil
}

// tabIDPattern matches the tab query parameter in Google Docs URLs.
// Captures the full tab value without assuming a specific format.
var tabIDPattern = regexp.MustCompile(`[?&]tab=([^&#]+)`)

// ExtractTabID extracts the tab ID from a Google Docs URL if present.
// Tab IDs appear in URLs as ?tab={TAB_ID} or &tab={TAB_ID}
// Returns empty string if no tab ID is found.
func ExtractTabID(url string) string {
matches := tabIDPattern.FindStringSubmatch(url)
if len(matches) < 2 {
return ""
}

return matches[1]
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
63 changes: 63 additions & 0 deletions internal/gdocs/url_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -80,3 +80,66 @@ func TestExtractDocumentID(t *testing.T) {
})
}
}

func TestExtractTabID(t *testing.T) {
tests := []struct {
name string
url string
want string
}{
{
name: "URL with tab parameter",
url: "https://docs.google.com/document/d/1abc123xyz/edit?tab=t.v63b7x227gkk",
want: "t.v63b7x227gkk",
},
{
name: "URL with tab and other params",
url: "https://docs.google.com/document/d/1abc123xyz/edit?usp=sharing&tab=t.abc123",
want: "t.abc123",
},
{
name: "URL with tab and heading anchor",
url: "https://docs.google.com/document/d/1abc123xyz/edit?tab=t.v63b7x227gkk#heading=h.ehdxodmabfmp",
want: "t.v63b7x227gkk",
},
{
name: "URL with tab parameter with trailing params",
url: "https://docs.google.com/document/d/1abc123xyz/edit?tab=t.abc123&other=value",
want: "t.abc123",
},
{
name: "URL with numeric tab ID",
url: "https://docs.google.com/document/d/1abc123xyz/edit?tab=t.0",
want: "t.0",
},
{
name: "URL with non-standard tab format",
url: "https://docs.google.com/document/d/1abc123xyz/edit?tab=custom-tab-id",
want: "custom-tab-id",
},
{
name: "URL without tab parameter",
url: "https://docs.google.com/document/d/1abc123xyz/edit",
want: "",
},
{
name: "URL with only sharing param",
url: "https://docs.google.com/document/d/1abc123xyz/edit?usp=sharing",
want: "",
},
{
name: "empty URL",
url: "",
want: "",
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := ExtractTabID(tt.url)
if got != tt.want {
t.Errorf("ExtractTabID() = %v, want %v", got, tt.want)
}
})
}
}
Loading