diff --git a/CHANGELOG.md b/CHANGELOG.md index 38a80c6e..05fe5624 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ ### Fixed +- Docs: render GFM `~~strikethrough~~` spans in the local markdown writer used by `docs write --tab --markdown`. (#702) - Docs: batch table-cell writes for `docs write --tab --markdown` to avoid per-cell Docs API quota bursts on table-heavy documents. (#699) — thanks @sebsnyk. - Gmail: preserve existing `gmail drafts update` attachments when `--attach` is omitted, add `--clear-attachments` for intentional removal, and keep `--attach` as explicit replacement. (#680, #681) — thanks @chrischall. diff --git a/internal/cmd/docs_formatter.go b/internal/cmd/docs_formatter.go index 0491686d..443c02ac 100644 --- a/internal/cmd/docs_formatter.go +++ b/internal/cmd/docs_formatter.go @@ -333,6 +333,10 @@ func buildTextStyleRequest(style TextStyle, baseOffset int64, tabID string) *doc textStyle.Italic = true fields = append(fields, "italic") } + if style.Strikethrough { + textStyle.Strikethrough = true + fields = append(fields, "strikethrough") + } if style.Code { textStyle.WeightedFontFamily = &docs.WeightedFontFamily{ FontFamily: "Courier New", diff --git a/internal/cmd/docs_formatter_test.go b/internal/cmd/docs_formatter_test.go index f1ac6f28..d0720cb3 100644 --- a/internal/cmd/docs_formatter_test.go +++ b/internal/cmd/docs_formatter_test.go @@ -43,6 +43,38 @@ func TestMarkdownToDocsRequests_TableStartIndexUsesBase(t *testing.T) { } } +func TestMarkdownToDocsRequests_Strikethrough(t *testing.T) { + elements := []MarkdownElement{{Type: MDParagraph, Content: "~~struck out~~ vs **bold**"}} + requests, text, tables := MarkdownToDocsRequests(elements, 10, "t.second") + + if text != "struck out vs bold\n" { + t.Fatalf("unexpected text: %q", text) + } + if len(tables) != 0 { + t.Fatalf("unexpected tables: %d", len(tables)) + } + + var sawStrike bool + for _, req := range requests { + if req.UpdateTextStyle == nil || req.UpdateTextStyle.TextStyle == nil { + continue + } + if !req.UpdateTextStyle.TextStyle.Strikethrough { + continue + } + sawStrike = true + if req.UpdateTextStyle.Fields != "strikethrough" { + t.Fatalf("unexpected strikethrough fields: %q", req.UpdateTextStyle.Fields) + } + if got := req.UpdateTextStyle.Range; got.StartIndex != 10 || got.EndIndex != 20 || got.TabId != "t.second" { + t.Fatalf("unexpected strikethrough range: %#v", got) + } + } + if !sawStrike { + t.Fatalf("missing strikethrough request: %#v", requests) + } +} + // TestMarkdownToDocsRequests_AppendBulletsAndCode is a regression test for // #594. The append path used to inline literal "• " glyphs for bullet lists // (leaving paragraphs as NORMAL_TEXT) and split fenced code blocks into one diff --git a/internal/cmd/docs_markdown.go b/internal/cmd/docs_markdown.go index a8df4320..4905f01a 100644 --- a/internal/cmd/docs_markdown.go +++ b/internal/cmd/docs_markdown.go @@ -48,12 +48,13 @@ type MarkdownElement struct { // TextStyle represents text formatting type TextStyle struct { - Bold bool - Italic bool - Code bool - Link string - Start int64 - End int64 + Bold bool + Italic bool + Strikethrough bool + Code bool + Link string + Start int64 + End int64 } // ParagraphStyle represents paragraph-level formatting @@ -423,17 +424,18 @@ func parseInlineSegment(text string) (string, []TextStyle) { } } - if marker, bold, italic, ok := inlineMarkerAt(text, i); ok { + if marker, bold, italic, strikethrough, ok := inlineMarkerAt(text, i); ok { searchFrom := i + len(marker) if end := findClosingInlineMarker(text, searchFrom, marker); end >= 0 && end > searchFrom { content, nestedStyles := parseInlineSegment(text[searchFrom:end]) start := utf16Len(stripped.String()) stripped.WriteString(content) styles = append(styles, TextStyle{ - Start: start, - End: start + utf16Len(content), - Bold: bold, - Italic: italic, + Start: start, + End: start + utf16Len(content), + Bold: bold, + Italic: italic, + Strikethrough: strikethrough, }) styles = appendShiftedStyles(styles, nestedStyles, start) i = end + len(marker) @@ -501,24 +503,41 @@ func appendShiftedStyles(styles []TextStyle, nested []TextStyle, offset int64) [ return styles } -func inlineMarkerAt(text string, i int) (marker string, bold bool, italic bool, ok bool) { - for _, candidate := range []string{"***", "___", "**", "__", "*", "_"} { +func inlineMarkerAt(text string, i int) (marker string, bold bool, italic bool, strikethrough bool, ok bool) { + for _, candidate := range []string{"***", "___", "**", "__", "~~", "*", "_"} { if !strings.HasPrefix(text[i:], candidate) { continue } if candidate[0] == '_' && !isUnderscoreOpeningDelimiter(text, i, len(candidate)) { - return "", false, false, false + return "", false, false, false, false + } + if candidate == "~~" { + if i > 0 && text[i-1] == '~' { + return "", false, false, false, false + } + if tildeRunLenAt(text, i) != len(candidate) { + return "", false, false, false, false + } + return candidate, false, false, true, true } switch len(candidate) { case 3: - return candidate, true, true, true + return candidate, true, true, false, true case 2: - return candidate, true, false, true + return candidate, true, false, false, true default: - return candidate, false, true, true + return candidate, false, true, false, true } } - return "", false, false, false + return "", false, false, false, false +} + +func tildeRunLenAt(text string, i int) int { + runEnd := i + for runEnd < len(text) && text[runEnd] == '~' { + runEnd++ + } + return runEnd - i } func findClosingInlineMarker(text string, searchFrom int, marker string) int { @@ -553,6 +572,9 @@ func closingInlineMarkerInRun(text string, searchFrom int, i int, marker string) if runLen < markerLen { return 0, runEnd, false } + if marker == "~~" { + return i, runEnd, runLen == markerLen + } if markerLen == 1 { if runLen == 1 { diff --git a/internal/cmd/docs_markdown_test.go b/internal/cmd/docs_markdown_test.go index ef3cac97..c52d0ae0 100644 --- a/internal/cmd/docs_markdown_test.go +++ b/internal/cmd/docs_markdown_test.go @@ -146,6 +146,27 @@ func TestParseInlineFormatting_NestedAndUnderscoreStyles(t *testing.T) { assertInlineStyle(t, text, styles, "both", true, true, false) } +func TestParseInlineFormatting_Strikethrough(t *testing.T) { + styles, text := ParseInlineFormatting("~~struck out~~ vs **bold**") + if text != "struck out vs bold" { + t.Fatalf("text = %q", text) + } + + assertInlineStrikethrough(t, text, styles, "struck out") + assertInlineStyle(t, text, styles, "bold", true, false, false) +} + +func TestParseInlineFormatting_LongTildeRunsAreLiteral(t *testing.T) { + styles, text := ParseInlineFormatting("~~ok~~ and ~~~not~~~ and ~~~~also not~~~~") + if text != "ok and ~~~not~~~ and ~~~~also not~~~~" { + t.Fatalf("text = %q", text) + } + if len(styles) != 1 { + t.Fatalf("expected only the exact two-tilde span to format, got %#v", styles) + } + assertInlineStrikethrough(t, text, styles, "ok") +} + func TestParseInlineFormatting_ClosingMarkerIgnoresCodeSpan(t *testing.T) { styles, text := ParseInlineFormatting("**Use `**` marker** and _keep `_` literal_") if text != "Use ** marker and keep _ literal" { @@ -259,13 +280,26 @@ func assertInlineStyle(t *testing.T, text string, styles []TextStyle, wantText s if int(style.End) > len(text) { continue } - if text[style.Start:style.End] == wantText && style.Bold == bold && style.Italic == italic && style.Code == code { + if text[style.Start:style.End] == wantText && style.Bold == bold && style.Italic == italic && style.Code == code && !style.Strikethrough { return } } t.Fatalf("missing style text=%q bold=%v italic=%v code=%v in %#v", wantText, bold, italic, code, styles) } +func assertInlineStrikethrough(t *testing.T, text string, styles []TextStyle, wantText string) { + t.Helper() + for _, style := range styles { + if int(style.End) > len(text) { + continue + } + if text[style.Start:style.End] == wantText && style.Strikethrough { + return + } + } + t.Fatalf("missing strikethrough text=%q in %#v", wantText, styles) +} + func assertInlineLink(t *testing.T, text string, styles []TextStyle, wantText string, wantURL string) { t.Helper() for _, style := range styles {