Skip to content

feat: add support for Google Docs tabs#3

Merged
famasya merged 3 commits intofamasya:mainfrom
ScotterC:feature/tab-support
Feb 13, 2026
Merged

feat: add support for Google Docs tabs#3
famasya merged 3 commits intofamasya:mainfrom
ScotterC:feature/tab-support

Conversation

@ScotterC
Copy link
Contributor

@ScotterC ScotterC commented Feb 2, 2026

Summary

  • Adds support for fetching content from specific tabs in Google Docs
  • Extracts tab ID from URL (?tab=t.xxx parameter) and fetches that tab's content
  • Enables fetching nested child tabs in multi-tab documents

Changes

  • internal/gdocs/url.go - Add ExtractTabID() to parse tab parameter from URLs
  • internal/gdocs/client.go - Enable IncludeTabsContent(true) on API calls, add FindTab() to recursively search tab tree
  • internal/markdown/converter.go - Add NewConverterFromTab() constructor for tab-specific conversion
  • cmd/gdocs-cli/main.go - Wire up tab extraction and selection logic

Test plan

  • All existing tests pass
  • New tests added for ExtractTabID(), FindTab(), and GetFirstTab()
  • Manual test with multi-tab Google Doc URL

Example

# Fetch specific tab
./gdocs-cli --url="https://docs.google.com/document/d/DOC_ID/edit?tab=t.v63b7x227gkk"

# Fetch default tab (backward compatible)
./gdocs-cli --url="https://docs.google.com/document/d/DOC_ID/edit"

Human Author's Note

I haven't coded go in more than a decade so this was entirely Claude's approach. I tested directly on my own doc and the implementation seemed straightforward enough.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Convert a specific tab within multi-tab Google Docs via a tab parameter in the document URL.
    • When converting a specific tab, output includes tab-aware frontmatter and uses the tab's content and title.
  • Tests

    • Added unit tests covering tab ID extraction from URLs and tab lookup/selection across various document structures.

Extract tab ID from URL (?tab=t.xxx parameter) and fetch content from
the specified tab instead of defaulting to the first tab. This enables
fetching content from nested child tabs in multi-tab documents.

- Add ExtractTabID() to parse tab parameter from URLs
- Enable IncludeTabsContent(true) on all API calls
- Add FindTab() to recursively search tab tree by ID
- Update converter to work with tab content directly
- Add comprehensive tests for new tab functionality

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
@coderabbitai
Copy link

coderabbitai bot commented Feb 2, 2026

Warning

Rate limit exceeded

@ScotterC has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 50 minutes and 14 seconds before requesting another review.

⌛ How to resolve this issue?

After the wait time has elapsed, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout.

Please see our FAQ for further information.

📝 Walkthrough

Walkthrough

Adds tab-aware conversion: extract optional tab ID from Google Docs URLs, locate tabs in fetched documents, and initialize the markdown converter from a specified tab or the document (preferring first tab) before generating markdown with updated frontmatter.

Changes

Cohort / File(s) Summary
URL Tab Extraction
internal/gdocs/url.go, internal/gdocs/url_test.go
Add ExtractTabID and tabIDPattern to parse tab query parameter from Google Docs URLs; tests cover presence, absence, and fragment/other-param combinations.
Tab Retrieval & Discovery
internal/gdocs/client.go, internal/gdocs/client_test.go
Add FindTab(doc *docs.Document, tabID string) *docs.Tab and GetFirstTab(doc *docs.Document) *docs.Tab to locate tabs (including nested); unit tests for top-level, nested, missing, and nil-tab scenarios.
Converter Tab Support
internal/markdown/converter.go
Extend Converter with body, title, tabName; add NewConverterFromTab(doc, tab); switch conversion logic to use c.body and add generateFrontmatter to include tab field when appropriate.
CLI Integration
cmd/gdocs-cli/main.go
Extract tab ID early from URL and branch converter initialization: use NewConverterFromTab when a tab ID is provided (error if not found) or NewConverter otherwise; preserve overall fetch/auth/convert flow with adjusted logs and errors.

Sequence Diagram

sequenceDiagram
    participant CLI as CLI
    participant URL as URL Parser
    participant API as Google Docs API
    participant Finder as Tab Finder
    participant Conv as Converter
    participant MD as Markdown Builder

    CLI->>URL: ExtractTabID(url)
    URL-->>CLI: tabID or ""
    CLI->>API: FetchDocument(docID) (include tabs)
    API-->>CLI: Document with tabs

    alt tabID provided
        CLI->>Finder: FindTab(document, tabID)
        Finder-->>CLI: tab or nil
        CLI->>Conv: NewConverterFromTab(document, tab)
        Note over Conv: init with tab.Body & tabName
    else no tabID
        CLI->>Conv: NewConverter(document)
        Note over Conv: prefer first tab body or doc.Body
    end

    CLI->>Conv: Convert()
    Conv->>Conv: generateFrontmatter()
    Conv->>MD: build markdown from c.body
    MD-->>Conv: markdown
    Conv-->>CLI: output
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Poem

🐇 I sniffed the URL, found a tab so neat,
I hopped through docs on tiny feet.
A body, a title, a name to show,
I stitch frontmatter where tab fields go.
Hooray—markdown blooms from every row! ✨

🚥 Pre-merge checks | ✅ 2 | ❌ 1
❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 57.14% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'feat: add support for Google Docs tabs' directly and clearly summarizes the main change—adding tab support to the Google Docs CLI tool. It is concise, specific, and accurately reflects the primary objective of the changeset.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 3

🤖 Fix all issues with AI agents
In `@cmd/gdocs-cli/main.go`:
- Around line 114-121: The code accesses tab.TabProperties.Title after
gdocs.FindTab returns a tab, which risks a nil-pointer if TabProperties is
unexpectedly nil; update the block handling the found tab (the tab variable from
gdocs.FindTab in main.go) to defensively check that tab.TabProperties is not nil
before using Title (log a clear message and return an error or fall back to a
safe/default title), and only call markdown.NewConverterFromTab(doc, tab) once
tab and tab.TabProperties are validated.

In `@internal/gdocs/client.go`:
- Around line 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.

In `@internal/gdocs/url.go`:
- Around line 27-41: The current ExtractTabID function uses a restrictive regex
`t\.[a-zA-Z0-9]+` which assumes a specific tab ID format; change the regex in
ExtractTabID to capture the entire tab parameter value without constraining
characters (e.g., use `[?&]tab=([^&]+)`) so it returns whatever follows `tab=`
until the next `&` or end-of-string, keep using regexp.MustCompile and
FindStringSubmatch and return matches[1] or "" if missing.
🧹 Nitpick comments (4)
internal/markdown/converter.go (2)

74-91: Fragile string manipulation for frontmatter injection.

The approach of trimming ---\n and appending the tab field relies on the exact output format of GenerateFrontmatter. If that function's output format changes (e.g., trailing whitespace), this will break. Additionally, if c.tabName contains YAML special characters (e.g., colons, quotes), the output may produce invalid YAML.

Consider extending the Frontmatter struct in frontmatter.go to include an optional Tab field and let GenerateFrontmatter handle it, or at minimum use proper YAML escaping for the tab name.

♻️ Suggested approach using YAML-safe formatting
 // 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
+	// 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: %q\n---\n", c.tabName)
+	}
+
+	return frontmatter, nil
 }

39-51: Consider handling the case where tab is provided but has no content.

When tab != nil but tab.DocumentTab is nil, the converter is created with a nil body. While Convert() handles this gracefully at line 66, this silent behavior might confuse users expecting content. Consider logging a warning or returning an error if a tab is explicitly requested but contains no document content.

internal/gdocs/url.go (1)

30-33: Minor: Consider precompiling the regex at package level.

The regex is compiled on every call to ExtractTabID. For better performance, especially if called frequently, consider compiling it once at package initialization, similar to how production code often handles static patterns.

♻️ Suggested optimization
+var tabIDPattern = regexp.MustCompile(`[?&]tab=(t\.[a-zA-Z0-9]+)`)
+
 // ExtractTabID extracts the tab ID from a Google Docs URL if present.
 // Tab IDs appear in URLs as ?tab=t.{TAB_ID} or &tab=t.{TAB_ID}
 // Returns empty string if no tab ID is found.
 func ExtractTabID(url string) string {
-	// Regex pattern to match tab parameter (e.g., tab=t.v63b7x227gkk)
-	pattern := `[?&]tab=(t\.[a-zA-Z0-9]+)`
-	re := regexp.MustCompile(pattern)
-
-	matches := re.FindStringSubmatch(url)
+	matches := tabIDPattern.FindStringSubmatch(url)
 	if len(matches) < 2 {
 		return ""
 	}

 	return matches[1]
 }
internal/gdocs/url_test.go (1)

84-130: Good test coverage. Consider adding edge cases for malformed tab parameters.

The tests cover the main scenarios well. Consider adding cases for:

  • Malformed tab values like ?tab=invalid (no t. prefix) - should return empty string
  • Tab parameter in an unexpected position like ?tab=t.abc&other=value with trailing params
🧪 Additional test cases
{
    name: "malformed tab without t. prefix",
    url:  "https://docs.google.com/document/d/1abc123xyz/edit?tab=invalid",
    want: "",
},
{
    name: "tab parameter with trailing params",
    url:  "https://docs.google.com/document/d/1abc123xyz/edit?tab=t.abc123&other=value",
    want: "t.abc123",
},

Comment on lines 53 to 66
// 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
}
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

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.

- Add nil check for tab.TabProperties before accessing Title (main.go)
- Add nil check at start of findTabRecursive for defensive programming
- Use less restrictive regex for tab ID extraction - treat as opaque
  identifier per Google Docs API docs
- Precompile tabIDPattern regex at package level for performance
- Add additional test cases for edge cases

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🤖 Fix all issues with AI agents
In `@cmd/gdocs-cli/main.go`:
- Around line 115-126: The code currently selects a tab via gdocs.FindTab(doc,
tabID) and proceeds to create a converter (markdown.NewConverterFromTab) even
when the tab has no DocumentTab or Body, leading to silent output with only
frontmatter; update the block after locating tab (where tab, tab.TabProperties
and tabTitle are set) to check that tab.DocumentTab != nil and
tab.DocumentTab.Body != nil (or equivalent field on the tab struct) and return a
clear error (e.g., fmt.Errorf("tab '%s' has no document content", tabID)) before
calling markdown.NewConverterFromTab so users get explicit feedback instead of
an empty body.

In `@internal/gdocs/client.go`:
- Around line 39-77: Add nil guards to avoid panics when callers pass a nil
document: at the start of FindTab and GetFirstTab check if doc == nil and return
nil immediately. No behavior change otherwise; keep findTabRecursive as-is.
Ensure the checks are added to the functions named FindTab and GetFirstTab so
both safely handle a nil *docs.Document.
🧹 Nitpick comments (1)
cmd/gdocs-cli/main.go (1)

112-129: Consider moving tab-selection logic out of the CLI entry point.

The CLI is now deciding tab selection and converter construction, which is business logic better housed in internal/gdocs or internal/markdown so cmd/gdocs-cli remains orchestration-only.

Based on learnings: “Keep the CLI entry point (cmd/gdocs-cli) free of business logic; use it only for flag parsing and orchestration.”

- Add explicit error when tab has no document content (prevents silent
  empty output)
- Add doc == nil guard to FindTab and GetFirstTab functions
- Add tests for nil document cases

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
@ScotterC ScotterC force-pushed the feature/tab-support branch from 3f5857f to 2f934c8 Compare February 2, 2026 21:34
@ScotterC
Copy link
Contributor Author

ScotterC commented Feb 4, 2026

All actionable comments addressed ✅

Fixed in commits e274bc1 and 2f934c8:

Issue Fix
Nil check for tab.TabProperties.Title Added defensive check with fallback to tabID
Nil guard in findTabRecursive Added if tab == nil { return nil } at start
Restrictive regex for tab ID Changed to [^&#]+ to treat as opaque identifier per Google's API docs
Regex compiled on each call Precompiled at package level
Silent empty output when tab has no content Added explicit error: tab '%s' has no document content
Nil doc in FindTab/GetFirstTab Added doc == nil guards to both functions

Re: architectural nitpick - For a feature this size, keeping tab-selection in the CLI entry point is appropriate. Happy to refactor if you feel strongly about it.

All tests pass. Ready for review when convenient!

@famasya famasya merged commit 8b902a6 into famasya:main Feb 13, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants