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)
} else {
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
}

// 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]
}
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]
}
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