diff --git a/cmd/gdocs-cli/main.go b/cmd/gdocs-cli/main.go index 09e975a..c262116 100644 --- a/cmd/gdocs-cli/main.go +++ b/cmd/gdocs-cli/main.go @@ -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 { @@ -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) diff --git a/internal/gdocs/client.go b/internal/gdocs/client.go index af23401..8b4da64 100644 --- a/internal/gdocs/client.go +++ b/internal/gdocs/client.go @@ -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] +} diff --git a/internal/gdocs/client_test.go b/internal/gdocs/client_test.go new file mode 100644 index 0000000..ef7181a --- /dev/null +++ b/internal/gdocs/client_test.go @@ -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) + } + }) + } +} diff --git a/internal/gdocs/url.go b/internal/gdocs/url.go index dcf24dc..28e0e53 100644 --- a/internal/gdocs/url.go +++ b/internal/gdocs/url.go @@ -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] +} diff --git a/internal/gdocs/url_test.go b/internal/gdocs/url_test.go index 5d217ae..27edc1f 100644 --- a/internal/gdocs/url_test.go +++ b/internal/gdocs/url_test.go @@ -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) + } + }) + } +} diff --git a/internal/markdown/converter.go b/internal/markdown/converter.go index 845836c..1615887 100644 --- a/internal/markdown/converter.go +++ b/internal/markdown/converter.go @@ -9,12 +9,45 @@ import ( // Converter handles the conversion of Google Docs to markdown. type Converter struct { - doc *docs.Document + doc *docs.Document + body *docs.Body + title string + tabName string } // NewConverter creates a new Converter for the given document. +// Uses the first tab's content by default. func NewConverter(doc *docs.Document) *Converter { - return &Converter{doc: doc} + c := &Converter{doc: doc, title: doc.Title} + + // Use tab content if available, otherwise fall back to legacy doc.Body + if doc.Tabs != nil && len(doc.Tabs) > 0 { + tab := doc.Tabs[0] + if tab.DocumentTab != nil { + c.body = tab.DocumentTab.Body + } + if tab.TabProperties != nil { + c.tabName = tab.TabProperties.Title + } + } else if doc.Body != nil { + c.body = doc.Body + } + + return c +} + +// NewConverterFromTab creates a new Converter for a specific tab. +func NewConverterFromTab(doc *docs.Document, tab *docs.Tab) *Converter { + c := &Converter{doc: doc, title: doc.Title} + + if tab != nil && tab.DocumentTab != nil { + c.body = tab.DocumentTab.Body + if tab.TabProperties != nil { + c.tabName = tab.TabProperties.Title + } + } + + return c } // Convert processes the entire document and returns markdown. @@ -22,7 +55,7 @@ func (c *Converter) Convert() (string, error) { var builder strings.Builder // Generate frontmatter - frontmatter, err := GenerateFrontmatter(c.doc) + frontmatter, err := c.generateFrontmatter() if err != nil { return "", fmt.Errorf("failed to generate frontmatter: %w", err) } @@ -30,7 +63,7 @@ func (c *Converter) Convert() (string, error) { builder.WriteString("\n") // Convert body content - if c.doc.Body != nil && c.doc.Body.Content != nil { + if c.body != nil && c.body.Content != nil { body := c.convertBody() builder.WriteString(body) } @@ -38,11 +71,30 @@ func (c *Converter) Convert() (string, error) { return builder.String(), nil } +// generateFrontmatter creates frontmatter including tab info if present. +func (c *Converter) generateFrontmatter() (string, error) { + // Use the existing GenerateFrontmatter for the base, but we'll + // add tab info if we have it + frontmatter, err := GenerateFrontmatter(c.doc) + if err != nil { + return "", err + } + + // If we have a tab name that differs from the doc title, include it + if c.tabName != "" && c.tabName != c.title { + // Insert tab info before the closing --- + frontmatter = strings.TrimSuffix(frontmatter, "---\n") + frontmatter += fmt.Sprintf("tab: %s\n---\n", c.tabName) + } + + return frontmatter, nil +} + // convertBody converts the document body to markdown. func (c *Converter) convertBody() string { var builder strings.Builder - for _, element := range c.doc.Body.Content { + for _, element := range c.body.Content { // Convert based on element type if element.Paragraph != nil { markdown := ConvertParagraph(element.Paragraph, element.Paragraph.ParagraphStyle)