gogcli/internal/cmd/docs_formatter.go
Gonçalo Alves 7945602f15
feat: add docs update command for editing Google Docs (#219)
* feat: add docs update command for editing Google Docs

* fix: handle document content range correctly for replace

* docs: add Jarbas avatar

* feat(gogcli): add markdown formatting support for Google Docs

Phase 1 & 2 complete:
- Markdown parser supporting headings, lists, code blocks, blockquotes, links
- Google Docs API integration for formatting
- --format markdown flag for docs update command
- Heading styles (H1-H6), horizontal rules, list indentation
- Code blocks with monospace font

Pending (Phase 3):
- Inline formatting (bold, italic, inline code) - index calculation issues
- Links - index calculation issues

Usage:
  gog docs update <docId> --content-file ./doc.md --format markdown

* fix(gogcli): fix inline formatting indices in markdown formatter

- Simplified document generation to avoid index calculation errors
- Fixed ParseInlineFormatting to correctly track positions
- Preserves: headings, code blocks, blockquotes, lists, horizontal rules

Pending: inline formatting (bold, italic, code, links) - indices still need work

* fix(gogcli): use UTF-16 code units for Google Docs API indexing

- Fixed markdown formatter to use UTF-16 code units instead of UTF-8 bytes
- Added utf16Len() helper function for accurate character counting
- Fixed inline formatting indices (bold, italic, code, links)
- Added empty line handling (MDEmptyLine)
- Successfully tested with Docker course doc (21KB, emojis, diagrams)

This resolves index mismatch errors caused by multi-byte characters like emojis
which are 4 bytes in UTF-8 but 2 code units in UTF-16.

* feat(gogcli): add slides commands with markdown support

- Add 'gog slides update' command with markdown formatting
- Create slides_formatter.go for Google Slides API batch updates
- Create slides_markdown.go for markdown parsing (titles, bullets, code)
- Add slides.go with update/create/read operations
- Update googleauth service for Slides scope

Related: PR #219

* fix(gogcli): use shapes for slides text boxes instead of direct insertion

- Fixed slides creation to use CreateShape with TEXT_BOX instead of inserting text directly
- Direct text insertion into slides is not supported by Google Slides API
- Added title text box with bold 36pt font
- Added body text box for content (bullets, paragraphs, code)
- Supports markdown formatting (bold, bullets, code blocks)

Tested: Successfully created 20-slide presentation from Docker course outline

* feat: add markdown table support (formatted text output)

* feat: implement native Google Docs table insertion with multi-step API

* feat(slides): add --template flag for creating presentations from templates

* fix: stabilize docs/slides markdown + auth flow (#219) (thanks @goncaloalves)

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-02-13 22:52:17 +01:00

336 lines
9.2 KiB
Go

package cmd
import (
"fmt"
"strings"
"google.golang.org/api/docs/v1"
)
// Debug flag for markdown formatter
var debugMarkdown = false
// TableData represents a table to be inserted natively
type TableData struct {
StartIndex int64
Cells [][]string
}
// MarkdownToDocsRequests converts parsed markdown elements to Google Docs batch
// update requests. baseIndex is the insertion location in the document.
// Returns: requests, plainText, tableData (for native table insertion)
func MarkdownToDocsRequests(elements []MarkdownElement, baseIndex int64) ([]*docs.Request, string, []TableData) {
var requests []*docs.Request
var plainText strings.Builder
var tables []TableData
charOffset := baseIndex
if debugMarkdown {
fmt.Printf("[DEBUG] Starting MarkdownToDocsRequests with %d elements\n", len(elements))
}
for _, el := range elements {
startOffset := charOffset
switch el.Type {
case MDHeading1, MDHeading2, MDHeading3, MDHeading4, MDHeading5, MDHeading6:
// Parse inline formatting for heading content
styles, strippedContent := ParseInlineFormatting(el.Content)
if debugMarkdown {
fmt.Printf("[DEBUG] Heading: content=%q stripped=%q styles=%d\n", el.Content, strippedContent, len(styles))
}
if debugMarkdown {
fmt.Printf("[HEADING] Content: %q\n", el.Content)
fmt.Printf(" Stripped: %q (len=%d)\n", strippedContent, len(strippedContent))
fmt.Printf(" Styles: %v\n", styles)
}
// Add stripped heading text with newline
plainText.WriteString(strippedContent)
plainText.WriteString("\n")
charOffset += utf16Len(strippedContent + "\n")
// Apply heading style
headingStyle := getHeadingStyle(el.Type)
requests = append(requests, &docs.Request{
UpdateParagraphStyle: &docs.UpdateParagraphStyleRequest{
Range: &docs.Range{
StartIndex: startOffset,
EndIndex: charOffset,
},
ParagraphStyle: &docs.ParagraphStyle{
NamedStyleType: headingStyle,
},
Fields: "namedStyleType",
},
})
// Apply inline text styles
for _, style := range styles {
textStyleReq := buildTextStyleRequest(style, startOffset)
if textStyleReq != nil {
if debugMarkdown {
fmt.Printf(" Style request: [%d, %d]\n",
textStyleReq.UpdateTextStyle.Range.StartIndex,
textStyleReq.UpdateTextStyle.Range.EndIndex)
}
requests = append(requests, textStyleReq)
}
}
case MDCodeBlock:
// Add code block text (no inline formatting in code blocks)
codeContent := el.Content + "\n"
plainText.WriteString(codeContent)
charOffset += utf16Len(codeContent)
// Apply monospace font to entire code block
requests = append(requests, &docs.Request{
UpdateTextStyle: &docs.UpdateTextStyleRequest{
Range: &docs.Range{
StartIndex: startOffset,
EndIndex: charOffset,
},
TextStyle: &docs.TextStyle{
WeightedFontFamily: &docs.WeightedFontFamily{
FontFamily: "Courier New",
Weight: 400,
},
BackgroundColor: &docs.OptionalColor{
Color: &docs.Color{
RgbColor: &docs.RgbColor{
Red: 0.95,
Green: 0.95,
Blue: 0.95,
},
},
},
},
Fields: "weightedFontFamily,backgroundColor",
},
})
case MDBlockquote:
// Parse inline formatting for blockquote content
styles, strippedContent := ParseInlineFormatting(el.Content)
if debugMarkdown {
fmt.Printf("[BLOCKQUOTE] Content: %q -> stripped=%q\n", el.Content, strippedContent)
}
// Add stripped blockquote text
plainText.WriteString(strippedContent)
plainText.WriteString("\n")
charOffset += utf16Len(strippedContent + "\n")
// Apply blockquote style (indent)
requests = append(requests, &docs.Request{
UpdateParagraphStyle: &docs.UpdateParagraphStyleRequest{
Range: &docs.Range{
StartIndex: startOffset,
EndIndex: charOffset,
},
ParagraphStyle: &docs.ParagraphStyle{
IndentStart: &docs.Dimension{
Magnitude: 36,
Unit: "PT",
},
},
Fields: "indentStart",
},
})
// Apply inline text styles
for _, style := range styles {
textStyleReq := buildTextStyleRequest(style, startOffset)
if textStyleReq != nil {
if debugMarkdown {
fmt.Printf(" Style request: [%d, %d] (base=%d, style=[%d,%d])\n",
textStyleReq.UpdateTextStyle.Range.StartIndex,
textStyleReq.UpdateTextStyle.Range.EndIndex,
startOffset, style.Start, style.End)
}
requests = append(requests, textStyleReq)
}
}
case MDListItem, MDNumberedList:
// Parse inline formatting for list item content
styles, strippedContent := ParseInlineFormatting(el.Content)
if debugMarkdown {
fmt.Printf("[LIST] Content: %q -> stripped=%q styles=%d\n", el.Content, strippedContent, len(styles))
}
// Add list item with prefix
prefix := "• "
if el.Type == MDNumberedList {
prefix = "1. "
}
prefixLen := utf16Len(prefix)
plainText.WriteString(prefix)
plainText.WriteString(strippedContent)
plainText.WriteString("\n")
charOffset += prefixLen + utf16Len(strippedContent+"\n")
// Apply inline text styles (offset by prefix length)
for _, style := range styles {
textStyleReq := buildTextStyleRequest(style, startOffset+prefixLen)
if textStyleReq != nil {
requests = append(requests, textStyleReq)
}
}
case MDHorizontalRule:
// Add horizontal rule as a separator line using ASCII dashes
separator := strings.Repeat("-", 40)
plainText.WriteString(separator)
plainText.WriteString("\n")
charOffset += utf16Len(separator + "\n")
case MDParagraph:
// Parse inline formatting for paragraph content
styles, strippedContent := ParseInlineFormatting(el.Content)
if debugMarkdown {
fmt.Printf("[PARAGRAPH] Content: %q\n", el.Content)
fmt.Printf(" Stripped: %q (len=%d)\n", strippedContent, len(strippedContent))
fmt.Printf(" Styles: %v\n", styles)
fmt.Printf(" startOffset: %d, len+1: %d\n", startOffset, len(strippedContent)+1)
}
// Add stripped paragraph text
plainText.WriteString(strippedContent)
plainText.WriteString("\n")
charOffset += utf16Len(strippedContent + "\n")
if debugMarkdown {
fmt.Printf(" charOffset after: %d, plainText.Len: %d\n", charOffset, plainText.Len())
}
// Apply inline text styles
for _, style := range styles {
textStyleReq := buildTextStyleRequest(style, startOffset)
if textStyleReq != nil {
if debugMarkdown {
fmt.Printf(" Style request: [%d, %d]\n",
textStyleReq.UpdateTextStyle.Range.StartIndex,
textStyleReq.UpdateTextStyle.Range.EndIndex)
}
requests = append(requests, textStyleReq)
}
}
case MDEmptyLine:
// Add empty line
plainText.WriteString("\n")
charOffset += utf16Len("\n")
case MDTable:
// Handle markdown table - save for native insertion
if len(el.TableCells) == 0 {
continue
}
rows := len(el.TableCells)
cols := len(el.TableCells[0])
if rows == 0 || cols == 0 {
continue
}
if debugMarkdown {
fmt.Printf("[TABLE] %d rows x %d cols at offset %d - saving for native insertion\n", rows, cols, charOffset)
}
// Save table data for native insertion
tables = append(tables, TableData{
StartIndex: charOffset,
Cells: el.TableCells,
})
// Add a placeholder newline (table will be inserted here)
plainText.WriteString("\n")
charOffset += utf16Len("\n")
}
}
if debugMarkdown {
fmt.Printf("\n[FINAL] plainText length: %d\n", plainText.Len())
fmt.Printf("[FINAL] Final charOffset: %d\n", charOffset)
fmt.Printf("[FINAL] Total requests: %d\n", len(requests))
fmt.Printf("[FINAL] Total tables: %d\n", len(tables))
fmt.Printf("\n[FINAL] plainText content:\n%s\n[END]\n", plainText.String())
}
return requests, plainText.String(), tables
}
// buildTextStyleRequest creates a text style update request from a TextStyle
func buildTextStyleRequest(style TextStyle, baseOffset int64) *docs.Request {
// Validate indices
if style.Start < 0 || style.End < 0 || style.End <= style.Start {
return nil
}
textStyle := &docs.TextStyle{}
var fields []string
if style.Bold {
textStyle.Bold = true
fields = append(fields, "bold")
}
if style.Italic {
textStyle.Italic = true
fields = append(fields, "italic")
}
if style.Code {
textStyle.WeightedFontFamily = &docs.WeightedFontFamily{
FontFamily: "Courier New",
Weight: 400,
}
fields = append(fields, "weightedFontFamily")
}
if style.Link != "" {
textStyle.Link = &docs.Link{
Url: style.Link,
}
fields = append(fields, "link")
}
if len(fields) == 0 {
return nil
}
return &docs.Request{
UpdateTextStyle: &docs.UpdateTextStyleRequest{
Range: &docs.Range{
StartIndex: baseOffset + int64(style.Start),
EndIndex: baseOffset + int64(style.End),
},
TextStyle: textStyle,
Fields: strings.Join(fields, ","),
},
}
}
func getHeadingStyle(elType MarkdownElementType) string {
switch elType {
case MDHeading1:
return "HEADING_1"
case MDHeading2:
return "HEADING_2"
case MDHeading3:
return "HEADING_3"
case MDHeading4:
return "HEADING_4"
case MDHeading5:
return "HEADING_5"
case MDHeading6:
return "HEADING_6"
default:
return "NORMAL_TEXT"
}
}