* 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>
336 lines
9.2 KiB
Go
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"
|
|
}
|
|
}
|