From 3afae4f197cfbd1f147ee81c5ccea0b734925c8e Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 7 Jun 2026 11:03:45 +0100 Subject: [PATCH] fix(docs): support explicit markdown heading anchors --- CHANGELOG.md | 1 + internal/cmd/docs_edit.go | 61 ++- internal/cmd/docs_formatter_test.go | 28 ++ internal/cmd/docs_import_test.go | 14 + internal/cmd/docs_markdown.go | 361 ++++++++++++++- internal/cmd/docs_markdown_links.go | 204 +++++++-- internal/cmd/docs_markdown_links_test.go | 443 +++++++++++++++++++ internal/cmd/docs_markdown_test.go | 71 +++ internal/cmd/docs_mutation.go | 51 ++- internal/cmd/docs_write_markdown_tab_test.go | 121 +++++ internal/cmd/docs_write_markdown_test.go | 222 ++++++++++ internal/cmd/docs_write_update_test.go | 159 +++++++ 12 files changed, 1690 insertions(+), 46 deletions(-) create mode 100644 internal/cmd/docs_markdown_links_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index c67a6c92..848bbe31 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ - Docs: preserve nested list levels when writing markdown into a specific tab with `docs write --replace --markdown --tab`. (#696) - Docs: fix `docs export --tab` tab resolution against the live Docs API field mask. (#696) +- Docs: strip Pandoc-style explicit heading anchors like `{#slug}` from rendered markdown headings and resolve matching same-document links. (#703) - 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_edit.go b/internal/cmd/docs_edit.go index 13030c5c..2dc9a5a8 100644 --- a/internal/cmd/docs_edit.go +++ b/internal/cmd/docs_edit.go @@ -263,6 +263,8 @@ func (c *DocsWriteCmd) writeMarkdown(ctx context.Context, flags *RootFlags, docI cleaned, images := extractMarkdownImages(content) cleaned = normalizeMarkdownTablesForDriveImport(cleaned) + explicitHeadingAnchors := markdownImportExplicitHeadingAnchors(cleaned) + cleaned = stripMarkdownHeadingAnchors(cleaned) dryRunPayload := map[string]any{ "document_id": docID, "written": len(content), @@ -305,7 +307,7 @@ func (c *DocsWriteCmd) writeMarkdown(ctx context.Context, flags *RootFlags, docI } rewrittenHeadingLinks := 0 if markdownMayContainHeadingLinks(cleaned) { - count, rewriteErr := rewriteMarkdownHeadingLinks(ctx, docsSvc, docID) + count, rewriteErr := rewriteMarkdownHeadingLinks(ctx, docsSvc, docID, "", explicitHeadingAnchors) if rewriteErr != nil { return fmt.Errorf("rewrite heading links: %w", rewriteErr) } @@ -356,6 +358,7 @@ func (c *DocsWriteCmd) writeMarkdown(ctx context.Context, flags *RootFlags, docI func (c *DocsWriteCmd) appendMarkdown(ctx context.Context, flags *RootFlags, docID, content string) error { cleaned, images := extractMarkdownImages(content) + explicitHeadingAnchors := markdownExplicitHeadingAnchors(cleaned) dryRunPayload := map[string]any{ "document_id": docID, "written": len(cleaned), @@ -384,8 +387,13 @@ func (c *DocsWriteCmd) appendMarkdown(ctx context.Context, flags *RootFlags, doc } c.Tab = tabID insertIndex := docsAppendIndex(endIndex) + insertedMarkdownStart := insertIndex + appendElements := ParseMarkdown(cleaned) + if insertIndex > 1 && markdownAppendNeedsParagraphBoundary(appendElements) { + insertedMarkdownStart++ + } - requestCount, inserted, err := insertDocsMarkdownAt(ctx, svc, docID, insertIndex, content, c.Tab) + requestCount, inserted, err := insertDocsMarkdownAtWithOptions(ctx, svc, docID, insertIndex, content, c.Tab, true) if err != nil { if isDocsNotFound(err) { return fmt.Errorf("doc not found or not a Google Doc (id=%s)", docID) @@ -395,6 +403,15 @@ func (c *DocsWriteCmd) appendMarkdown(ctx context.Context, flags *RootFlags, doc if err := c.applyDocumentStyle(ctx, svc, docID); err != nil { return err } + rewrittenHeadingLinks := 0 + if markdownMayContainHeadingLinks(cleaned) { + count, rewriteErr := rewriteMarkdownHeadingLinksFromIndex(ctx, svc, docID, c.Tab, explicitHeadingAnchors, insertedMarkdownStart) + if rewriteErr != nil { + return fmt.Errorf("rewrite heading links: %w", rewriteErr) + } + rewrittenHeadingLinks = count + requestCount += count + } if outfmt.IsJSON(ctx) { payload := map[string]any{ @@ -408,6 +425,9 @@ func (c *DocsWriteCmd) appendMarkdown(ctx context.Context, flags *RootFlags, doc if c.Pageless { payload["pageless"] = true } + if rewrittenHeadingLinks > 0 { + payload["headingLinks"] = rewrittenHeadingLinks + } for k, v := range c.Layout.dryRunPayload() { payload[k] = v } @@ -420,6 +440,9 @@ func (c *DocsWriteCmd) appendMarkdown(ctx context.Context, flags *RootFlags, doc u.Out().Linef("requests\t%d", requestCount) u.Out().Linef("mode\tappended (markdown converted)") u.Out().Linef("index\t%d", insertIndex) + if rewrittenHeadingLinks > 0 { + u.Out().Linef("headingLinks\t%d", rewrittenHeadingLinks) + } if c.Pageless { u.Out().Linef("pageless\ttrue") } @@ -433,6 +456,7 @@ func (c *DocsWriteCmd) appendMarkdown(ctx context.Context, flags *RootFlags, doc // body content via DeleteContentRange. Other tabs are untouched. func (c *DocsWriteCmd) replaceMarkdownInTab(ctx context.Context, flags *RootFlags, docID, content string) error { cleaned, images := extractMarkdownImages(content) + explicitHeadingAnchors := markdownExplicitHeadingAnchors(cleaned) dryRunPayload := map[string]any{ "document_id": docID, "written": len(cleaned), @@ -480,7 +504,7 @@ func (c *DocsWriteCmd) replaceMarkdownInTab(ctx context.Context, flags *RootFlag } } - requestCount, inserted, err := insertDocsMarkdownAt(ctx, svc, docID, 1, content, tabID) + requestCount, inserted, err := insertDocsMarkdownAtWithOptions(ctx, svc, docID, 1, content, tabID, true) if err != nil { if isDocsNotFound(err) { return fmt.Errorf("doc not found or not a Google Doc (id=%s)", docID) @@ -490,6 +514,15 @@ func (c *DocsWriteCmd) replaceMarkdownInTab(ctx context.Context, flags *RootFlag if err := c.applyDocumentStyle(ctx, svc, docID); err != nil { return err } + rewrittenHeadingLinks := 0 + if markdownMayContainHeadingLinks(cleaned) { + count, rewriteErr := rewriteMarkdownHeadingLinks(ctx, svc, docID, tabID, explicitHeadingAnchors) + if rewriteErr != nil { + return fmt.Errorf("rewrite heading links: %w", rewriteErr) + } + rewrittenHeadingLinks = count + requestCount += count + } if outfmt.IsJSON(ctx) { payload := map[string]any{ @@ -503,6 +536,9 @@ func (c *DocsWriteCmd) replaceMarkdownInTab(ctx context.Context, flags *RootFlag if c.Pageless { payload["pageless"] = true } + if rewrittenHeadingLinks > 0 { + payload["headingLinks"] = rewrittenHeadingLinks + } for k, v := range c.Layout.dryRunPayload() { payload[k] = v } @@ -515,6 +551,9 @@ func (c *DocsWriteCmd) replaceMarkdownInTab(ctx context.Context, flags *RootFlag u.Out().Linef("requests\t%d", requestCount) u.Out().Linef("mode\treplaced tab (markdown converted)") u.Out().Linef("tabId\t%s", tabID) + if rewrittenHeadingLinks > 0 { + u.Out().Linef("headingLinks\t%d", rewrittenHeadingLinks) + } if c.Pageless { u.Out().Linef("pageless\ttrue") } @@ -626,6 +665,8 @@ func (c *DocsUpdateCmd) Run(ctx context.Context, kctx *kong.Context, flags *Root if c.Markdown { var inserted int + cleaned, _ := extractMarkdownImages(text) + explicitHeadingAnchors := markdownExplicitHeadingAnchors(cleaned) if replacing { loaded, loadErr := loadDocsTargetDocument(ctx, svc, id, c.Tab) if loadErr != nil { @@ -640,7 +681,19 @@ func (c *DocsUpdateCmd) Run(ctx context.Context, kctx *kong.Context, flags *Root requestCount = replacedRequests } } else { - requestCount, inserted, err = insertDocsMarkdownAt(ctx, svc, id, insertIndex, text, c.Tab) + insertedMarkdownStart := insertIndex + insertElements := ParseMarkdown(cleaned) + stripMarkdownElementHeadingAnchors(insertElements) + if insertIndex > 1 && markdownAppendNeedsParagraphBoundary(insertElements) { + insertedMarkdownStart++ + } + var insertedMarkdownEnd int64 + requestCount, inserted, insertedMarkdownEnd, err = insertDocsMarkdownAtWithOptionsAndEnd(ctx, svc, id, insertIndex, text, c.Tab, true) + if err == nil && markdownMayContainHeadingLinks(cleaned) { + var rewritten int + rewritten, err = rewriteMarkdownHeadingLinksInRange(ctx, svc, id, c.Tab, explicitHeadingAnchors, insertedMarkdownStart, insertedMarkdownEnd) + requestCount += rewritten + } } if err != nil { if isDocsNotFound(err) { diff --git a/internal/cmd/docs_formatter_test.go b/internal/cmd/docs_formatter_test.go index 5e78f0c4..37e5e69c 100644 --- a/internal/cmd/docs_formatter_test.go +++ b/internal/cmd/docs_formatter_test.go @@ -77,6 +77,34 @@ func TestMarkdownToDocsRequests_Strikethrough(t *testing.T) { } } +func TestMarkdownToDocsRequests_StripsExplicitHeadingAnchor(t *testing.T) { + elements := ParseMarkdown("## Files {#attachments}\n") + stripMarkdownElementHeadingAnchors(elements) + requests, text, tables := MarkdownToDocsRequests(elements, 5, "t.second") + if text != "Files\n" { + t.Fatalf("text = %q, want %q", text, "Files\n") + } + if len(tables) != 0 { + t.Fatalf("unexpected tables: %d", len(tables)) + } + if len(requests) == 0 || requests[0].UpdateParagraphStyle == nil { + t.Fatalf("expected heading paragraph style request, got %#v", requests) + } + if got := requests[0].UpdateParagraphStyle.Range; got.StartIndex != 5 || got.EndIndex != 11 || got.TabId != "t.second" { + t.Fatalf("unexpected heading range: %#v", got) + } +} + +func TestMarkdownToDocsRequests_KeepsExplicitHeadingAnchorWithoutOptIn(t *testing.T) { + _, text, tables := MarkdownToDocsRequests(ParseMarkdown("## Files {#attachments}\n"), 5, "") + if text != "Files {#attachments}\n" { + t.Fatalf("text = %q, want explicit anchor preserved", text) + } + if len(tables) != 0 { + t.Fatalf("unexpected tables: %d", len(tables)) + } +} + func TestMarkdownToDocsRequests_NestedLists(t *testing.T) { elements := ParseMarkdown("- Parent\n - **Child**\n - Grandchild\n\n1. One\n 1. Nested one") requests, text, tables := MarkdownToDocsRequests(elements, 10, "t.second") diff --git a/internal/cmd/docs_import_test.go b/internal/cmd/docs_import_test.go index bc4d5caf..8829476f 100644 --- a/internal/cmd/docs_import_test.go +++ b/internal/cmd/docs_import_test.go @@ -265,6 +265,20 @@ func TestMarkdownImage_Placeholder(t *testing.T) { } } +func TestSubtractMarkdownImagePlaceholderDrift(t *testing.T) { + images := []markdownImage{ + {token: "test", index: 0}, + {token: "test", index: 1}, + } + placeholderDrift := (utf16Len("<>") - 1) + (utf16Len("<>") - 1) + if got, want := subtractMarkdownImagePlaceholderDrift(40, 1, images), int64(40)-placeholderDrift; got != want { + t.Fatalf("subtractMarkdownImagePlaceholderDrift() = %d, want %d", got, want) + } + if got := subtractMarkdownImagePlaceholderDrift(5, 4, images); got != 4 { + t.Fatalf("subtractMarkdownImagePlaceholderDrift() floor = %d, want 4", got) + } +} + func TestMarkdownImage_IsRemote(t *testing.T) { tests := []struct { name string diff --git a/internal/cmd/docs_markdown.go b/internal/cmd/docs_markdown.go index 51b38a66..d2ab5594 100644 --- a/internal/cmd/docs_markdown.go +++ b/internal/cmd/docs_markdown.go @@ -41,6 +41,7 @@ const ( type MarkdownElement struct { Type MarkdownElementType Content string + Anchor string // for headings: explicit Pandoc-style {#id} Children []MarkdownElement URL string // for links Level int // for headings and lists @@ -76,6 +77,8 @@ func ParseMarkdown(text string) []MarkdownElement { lines := strings.Split(text, "\n") inCodeBlock := false + var codeFenceChar byte + var codeFenceLen int var codeBlockContent strings.Builder var listIndents []int listActive := false @@ -83,11 +86,18 @@ func ParseMarkdown(text string) []MarkdownElement { for i := 0; i < len(lines); i++ { line := lines[i] - // Handle code blocks - if strings.HasPrefix(line, "```") { + // Handle fenced code blocks. + if fenceChar, fenceLen, ok := markdownCodeFence(line); ok { listIndents = nil listActive = false if inCodeBlock { + if fenceChar != codeFenceChar || fenceLen < codeFenceLen { + if codeBlockContent.Len() > 0 { + codeBlockContent.WriteString("\n") + } + codeBlockContent.WriteString(line) + continue + } // End code block elements = append(elements, MarkdownElement{ Type: MDCodeBlock, @@ -95,9 +105,13 @@ func ParseMarkdown(text string) []MarkdownElement { }) codeBlockContent.Reset() inCodeBlock = false + codeFenceChar = 0 + codeFenceLen = 0 } else { // Start code block inCodeBlock = true + codeFenceChar = fenceChar + codeFenceLen = fenceLen } continue } @@ -134,6 +148,7 @@ func ParseMarkdown(text string) []MarkdownElement { if headingLevel, content := parseHeading(line); headingLevel > 0 { listIndents = nil listActive = false + _, anchor := stripMarkdownHeadingAnchor(content) headingType := MDHeading1 switch headingLevel { case 1: @@ -152,6 +167,8 @@ func ParseMarkdown(text string) []MarkdownElement { elements = append(elements, MarkdownElement{ Type: headingType, Content: content, + Anchor: anchor, + Level: headingLevel, }) continue } @@ -222,6 +239,13 @@ func ParseMarkdown(text string) []MarkdownElement { }) } + if inCodeBlock { + elements = append(elements, MarkdownElement{ + Type: MDCodeBlock, + Content: codeBlockContent.String(), + }) + } + if len(elements) > 0 && elements[len(elements)-1].Type == MDEmptyLine { elements = elements[:len(elements)-1] } @@ -775,12 +799,337 @@ func nextRune(s string) (string, int) { } func parseHeading(line string) (int, string) { - headingRegex := regexp.MustCompile(`^(#{1,6})\s+(.+)$`) - match := headingRegex.FindStringSubmatch(line) - if match == nil { + prefix, content, ok := parseMarkdownATXHeadingLine(line) + if !ok { return 0, "" } - return len(match[1]), match[2] + hashes := strings.TrimSpace(prefix) + return len(hashes), content +} + +var markdownHeadingAnchorRegex = regexp.MustCompile(`\s+\{#([^}\s]+)\}\s*$`) + +type markdownExplicitHeadingAnchor struct { + Anchor string + Text string + Occurrence int +} + +type markdownSourceHeading struct { + Text string + Anchor string +} + +func stripMarkdownHeadingAnchor(content string) (string, string) { + match := markdownHeadingAnchorRegex.FindStringSubmatchIndex(content) + if match == nil { + return content, "" + } + anchor := content[match[2]:match[3]] + return strings.TrimSpace(content[:match[0]]), anchor +} + +func stripMarkdownHeadingAnchors(markdown string) string { + lines := strings.SplitAfter(markdown, "\n") + inCodeBlock := false + var codeFenceChar byte + var codeFenceLen int + pendingSetextLine := -1 + for i, line := range lines { + body, lineEnding := splitMarkdownLineEnding(line) + if fenceChar, fenceLen, ok := markdownCodeFence(body); ok { + pendingSetextLine = -1 + if inCodeBlock { + if fenceChar == codeFenceChar && fenceLen >= codeFenceLen { + inCodeBlock = false + codeFenceChar = 0 + codeFenceLen = 0 + } + } else { + inCodeBlock = true + codeFenceChar = fenceChar + codeFenceLen = fenceLen + } + continue + } + if inCodeBlock { + continue + } + + if prefix, content, ok := parseMarkdownATXHeadingLine(body); ok { + pendingSetextLine = -1 + stripped, anchor := stripMarkdownHeadingAnchor(content) + if anchor == "" { + continue + } + lines[i] = prefix + stripped + lineEnding + continue + } + + if isMarkdownSetextUnderline(body) { + if pendingSetextLine >= 0 { + prevBody, prevLineEnding := splitMarkdownLineEnding(lines[pendingSetextLine]) + stripped, anchor := stripMarkdownHeadingAnchor(prevBody) + if anchor != "" { + lines[pendingSetextLine] = stripped + prevLineEnding + } + } + pendingSetextLine = -1 + continue + } + + if !isMarkdownSetextHeadingCandidate(body) { + pendingSetextLine = -1 + continue + } + pendingSetextLine = i + } + return strings.Join(lines, "") +} + +func markdownExplicitHeadingAnchors(markdown string) []markdownExplicitHeadingAnchor { + elements := ParseMarkdown(markdown) + anchors := make([]markdownExplicitHeadingAnchor, 0) + seen := map[string]int{} + for _, el := range elements { + if !isMarkdownHeadingElement(el.Type) { + continue + } + text := markdownHeadingSourceText(el.Content) + seen[text]++ + anchor := strings.TrimSpace(el.Anchor) + if anchor == "" { + continue + } + anchors = append(anchors, markdownExplicitHeadingAnchor{ + Anchor: anchor, + Text: text, + Occurrence: seen[text], + }) + } + return anchors +} + +func markdownImportExplicitHeadingAnchors(markdown string) []markdownExplicitHeadingAnchor { + headings := markdownImportHeadings(markdown) + anchors := make([]markdownExplicitHeadingAnchor, 0) + seen := map[string]int{} + for _, heading := range headings { + seen[heading.Text]++ + if heading.Anchor == "" { + continue + } + anchors = append(anchors, markdownExplicitHeadingAnchor{ + Anchor: heading.Anchor, + Text: heading.Text, + Occurrence: seen[heading.Text], + }) + } + return anchors +} + +func markdownImportHeadings(markdown string) []markdownSourceHeading { + var headings []markdownSourceHeading + lines := strings.Split(markdown, "\n") + inCodeBlock := false + var codeFenceChar byte + var codeFenceLen int + pendingSetextLine := "" + pendingSetext := false + for _, line := range lines { + body := strings.TrimSuffix(line, "\r") + if fenceChar, fenceLen, ok := markdownCodeFence(body); ok { + pendingSetext = false + if inCodeBlock { + if fenceChar == codeFenceChar && fenceLen >= codeFenceLen { + inCodeBlock = false + codeFenceChar = 0 + codeFenceLen = 0 + } + } else { + inCodeBlock = true + codeFenceChar = fenceChar + codeFenceLen = fenceLen + } + continue + } + if inCodeBlock { + continue + } + if _, content, ok := parseMarkdownATXHeadingLine(body); ok { + headings = append(headings, markdownSourceHeadingFromContent(content)) + pendingSetext = false + continue + } + if isMarkdownSetextUnderline(body) { + if pendingSetext { + headings = append(headings, markdownSourceHeadingFromContent(pendingSetextLine)) + } + pendingSetext = false + continue + } + if !isMarkdownSetextHeadingCandidate(body) { + pendingSetext = false + continue + } + pendingSetextLine = body + pendingSetext = true + } + return headings +} + +func markdownSourceHeadingFromContent(content string) markdownSourceHeading { + stripped, anchor := stripMarkdownHeadingAnchor(content) + return markdownSourceHeading{ + Text: markdownHeadingSourceText(stripped), + Anchor: strings.TrimSpace(anchor), + } +} + +func markdownHeadingSourceText(content string) string { + stripped, _ := stripMarkdownHeadingAnchor(content) + _, text := ParseInlineFormatting(stripped) + return markdownHeadingNormalizedText(text) +} + +func markdownHeadingNormalizedText(text string) string { + return strings.Join(strings.Fields(strings.TrimSpace(text)), " ") +} + +func splitMarkdownLineEnding(line string) (string, string) { + body := line + lineEnding := "" + if strings.HasSuffix(body, "\n") { + body = strings.TrimSuffix(body, "\n") + lineEnding = "\n" + } + if strings.HasSuffix(body, "\r") { + body = strings.TrimSuffix(body, "\r") + lineEnding = "\r" + lineEnding + } + return body, lineEnding +} + +func parseMarkdownATXHeadingLine(line string) (string, string, bool) { + spaces := 0 + for spaces < len(line) && line[spaces] == ' ' { + spaces++ + } + if spaces > 3 || spaces >= len(line) || line[spaces] != '#' { + return "", "", false + } + hashEnd := spaces + for hashEnd < len(line) && line[hashEnd] == '#' { + hashEnd++ + } + if hashEnd-spaces > 6 || hashEnd >= len(line) { + return "", "", false + } + if line[hashEnd] != ' ' && line[hashEnd] != '\t' { + return "", "", false + } + contentStart := hashEnd + for contentStart < len(line) && (line[contentStart] == ' ' || line[contentStart] == '\t') { + contentStart++ + } + if contentStart >= len(line) { + return "", "", false + } + return line[:contentStart], line[contentStart:], true +} + +func isMarkdownSetextUnderline(line string) bool { + if !markdownLineAllowsSetextIndent(line) { + return false + } + trimmed := strings.TrimSpace(line) + if trimmed == "" { + return false + } + ch := trimmed[0] + if ch != '=' && ch != '-' { + return false + } + for i := 0; i < len(trimmed); i++ { + if trimmed[i] != ch { + return false + } + } + return true +} + +func isMarkdownSetextHeadingCandidate(line string) bool { + if !markdownLineAllowsSetextIndent(line) { + return false + } + trimmed := strings.TrimSpace(line) + if trimmed == "" { + return false + } + if strings.HasPrefix(trimmed, ">") || strings.HasPrefix(trimmed, "|") { + return false + } + if strings.HasPrefix(trimmed, "- ") || strings.HasPrefix(trimmed, "* ") || strings.HasPrefix(trimmed, "+ ") { + return false + } + if markdownNumberedListRE.MatchString(trimmed) { + return false + } + return true +} + +func markdownLineAllowsSetextIndent(line string) bool { + spaces := 0 + for spaces < len(line) && line[spaces] == ' ' { + spaces++ + } + if spaces > 3 { + return false + } + return spaces >= len(line) || line[spaces] != '\t' +} + +func isMarkdownHeadingElement(t MarkdownElementType) bool { + return t >= MDHeading1 && t <= MDHeading6 +} + +func stripMarkdownElementHeadingAnchors(elements []MarkdownElement) { + for i := range elements { + if isMarkdownHeadingElement(elements[i].Type) { + if stripped, anchor := stripMarkdownHeadingAnchor(elements[i].Content); anchor != "" { + elements[i].Content = stripped + elements[i].Anchor = anchor + } + } + if len(elements[i].Children) > 0 { + stripMarkdownElementHeadingAnchors(elements[i].Children) + } + } +} + +func markdownCodeFence(line string) (byte, int, bool) { + i := 0 + for i < len(line) && line[i] == ' ' && i < 3 { + i++ + } + if i < len(line) && line[i] == ' ' { + return 0, 0, false + } + if i >= len(line) { + return 0, 0, false + } + ch := line[i] + if ch != '`' && ch != '~' { + return 0, 0, false + } + j := i + for j < len(line) && line[j] == ch { + j++ + } + if j-i < 3 { + return 0, 0, false + } + return ch, j - i, true } func isHorizontalRule(line string) bool { diff --git a/internal/cmd/docs_markdown_links.go b/internal/cmd/docs_markdown_links.go index 29225b47..cba19584 100644 --- a/internal/cmd/docs_markdown_links.go +++ b/internal/cmd/docs_markdown_links.go @@ -13,48 +13,114 @@ func markdownMayContainHeadingLinks(markdown string) bool { return strings.Contains(markdown, "](#") } -func rewriteMarkdownHeadingLinks(ctx context.Context, svc *docs.Service, docID string) (int, error) { - doc, err := svc.Documents.Get(docID). - Fields("body/content(startIndex,endIndex,paragraph(paragraphStyle(namedStyleType,headingId),elements(startIndex,endIndex,textRun(content,textStyle/link))))"). - Context(ctx). - Do() +type markdownHeadingTarget struct { + headingID string + tabID string +} + +type markdownHeadingMatchKey struct { + text string + occurrence int +} + +type markdownParagraphRef struct { + paragraph *docs.Paragraph + startIndex int64 + endIndex int64 +} + +func rewriteMarkdownHeadingLinks(ctx context.Context, svc *docs.Service, docID string, tabID string, explicitAnchors []markdownExplicitHeadingAnchor) (int, error) { + return rewriteMarkdownHeadingLinksFromIndex(ctx, svc, docID, tabID, explicitAnchors, 0) +} + +func rewriteMarkdownHeadingLinksFromIndex(ctx context.Context, svc *docs.Service, docID string, tabID string, explicitAnchors []markdownExplicitHeadingAnchor, minIndex int64) (int, error) { + return rewriteMarkdownHeadingLinksInRange(ctx, svc, docID, tabID, explicitAnchors, minIndex, 0) +} + +func rewriteMarkdownHeadingLinksInRange(ctx context.Context, svc *docs.Service, docID string, tabID string, explicitAnchors []markdownExplicitHeadingAnchor, minIndex int64, maxIndex int64) (int, error) { + getCall := svc.Documents.Get(docID).Context(ctx) + if tabID != "" { + getCall = getCall.IncludeTabsContent(true) + } + doc, err := getCall.Do() + if err != nil { + return 0, err + } + + content, resolvedTabID, err := markdownHeadingLinkContent(doc, tabID) if err != nil { return 0, err } - if doc == nil || doc.Body == nil { + if len(content) == 0 { return 0, nil } + paragraphs := markdownParagraphsInContent(content, minIndex) - headingBySlug := map[string]string{} + autoHeadingBySlug := map[string]markdownHeadingTarget{} + explicitHeadingBySlug := map[string]markdownHeadingTarget{} + explicitHeadingByKey := map[markdownHeadingMatchKey]string{} + for _, explicit := range explicitAnchors { + anchor := strings.TrimSpace(explicit.Anchor) + text := markdownHeadingNormalizedText(explicit.Text) + if anchor == "" || text == "" || explicit.Occurrence <= 0 { + continue + } + explicitHeadingByKey[markdownHeadingMatchKey{ + text: text, + occurrence: explicit.Occurrence, + }] = anchor + } slugCounts := map[string]int{} - for _, el := range doc.Body.Content { - if el == nil || el.Paragraph == nil || el.Paragraph.ParagraphStyle == nil { + usedHeadingSlugs := map[string]bool{} + headingTextCounts := map[string]int{} + for _, ref := range paragraphs { + if ref.paragraph == nil || ref.paragraph.ParagraphStyle == nil { continue } - style := el.Paragraph.ParagraphStyle - if !strings.HasPrefix(style.NamedStyleType, "HEADING_") || strings.TrimSpace(style.HeadingId) == "" { + if minIndex > 0 && ref.startIndex < minIndex { + continue + } + if maxIndex > 0 && ref.startIndex >= maxIndex { continue } - text := markdownHeadingParagraphText(el.Paragraph) - slug := markdownHeadingSlug(text, slugCounts) - if slug == "" { + style := ref.paragraph.ParagraphStyle + if !strings.HasPrefix(style.NamedStyleType, "HEADING_") || strings.TrimSpace(style.HeadingId) == "" { continue } - headingBySlug[slug] = style.HeadingId + text := markdownHeadingParagraphText(ref.paragraph) + target := markdownHeadingTarget{headingID: style.HeadingId, tabID: resolvedTabID} + matchText := markdownHeadingNormalizedText(text) + headingTextCounts[matchText]++ + explicit := explicitHeadingByKey[markdownHeadingMatchKey{ + text: matchText, + occurrence: headingTextCounts[matchText], + }] + if explicit != "" { + explicitHeadingBySlug[explicit] = target + usedHeadingSlugs[explicit] = true + } else if slug := markdownHeadingSlug(text, slugCounts, usedHeadingSlugs); slug != "" { + autoHeadingBySlug[slug] = target + } } - if len(headingBySlug) == 0 { + if len(autoHeadingBySlug) == 0 && len(explicitHeadingBySlug) == 0 { return 0, nil } var requests []*docs.Request - for _, el := range doc.Body.Content { - if el == nil || el.Paragraph == nil { + for _, ref := range paragraphs { + if ref.paragraph == nil { continue } - for _, pe := range el.Paragraph.Elements { + for _, pe := range ref.paragraph.Elements { if pe == nil || pe.TextRun == nil || pe.TextRun.TextStyle == nil || pe.TextRun.TextStyle.Link == nil { continue } + if minIndex > 0 && pe.StartIndex < minIndex { + continue + } + if maxIndex > 0 && pe.StartIndex >= maxIndex { + continue + } link := pe.TextRun.TextStyle.Link if link.Url == "" || strings.HasPrefix(link.Url, "#heading=") { continue @@ -63,17 +129,26 @@ func rewriteMarkdownHeadingLinks(ctx context.Context, svc *docs.Service, docID s if !ok || strings.TrimSpace(slug) == "" { continue } - headingID := headingBySlug[strings.TrimSpace(slug)] - if headingID == "" { + target, ok := explicitHeadingBySlug[strings.TrimSpace(slug)] + if !ok { + target, ok = autoHeadingBySlug[strings.TrimSpace(slug)] + } + if !ok || target.headingID == "" { continue } + rng := &docs.Range{ + StartIndex: pe.StartIndex, + EndIndex: pe.EndIndex, + TabId: resolvedTabID, + } + linkTarget := &docs.Link{HeadingId: target.headingID} + if target.tabID != "" { + linkTarget = &docs.Link{Heading: &docs.HeadingLink{Id: target.headingID, TabId: target.tabID}} + } requests = append(requests, &docs.Request{ UpdateTextStyle: &docs.UpdateTextStyleRequest{ - Range: &docs.Range{ - StartIndex: pe.StartIndex, - EndIndex: pe.EndIndex, - }, - TextStyle: &docs.TextStyle{Link: &docs.Link{HeadingId: headingID}}, + Range: rng, + TextStyle: &docs.TextStyle{Link: linkTarget}, Fields: "link", }, }) @@ -89,6 +164,66 @@ func rewriteMarkdownHeadingLinks(ctx context.Context, svc *docs.Service, docID s return len(requests), nil } +func markdownHeadingLinkContent(doc *docs.Document, tabID string) ([]*docs.StructuralElement, string, error) { + if doc == nil { + return nil, "", nil + } + if tabID == "" { + if doc.Body == nil { + return nil, "", nil + } + return doc.Body.Content, "", nil + } + tab, err := findTab(flattenTabs(doc.Tabs), tabID) + if err != nil { + return nil, "", err + } + if tab == nil || tab.DocumentTab == nil || tab.DocumentTab.Body == nil { + return nil, "", nil + } + resolvedTabID := tabID + if tab.TabProperties != nil && strings.TrimSpace(tab.TabProperties.TabId) != "" { + resolvedTabID = tab.TabProperties.TabId + } + return tab.DocumentTab.Body.Content, resolvedTabID, nil +} + +func markdownParagraphsInContent(content []*docs.StructuralElement, minIndex int64) []markdownParagraphRef { + var paragraphs []markdownParagraphRef + appendMarkdownParagraphs(¶graphs, content, minIndex) + return paragraphs +} + +func appendMarkdownParagraphs(paragraphs *[]markdownParagraphRef, content []*docs.StructuralElement, minIndex int64) { + for _, el := range content { + if el == nil { + continue + } + if el.Paragraph != nil { + if minIndex == 0 || el.EndIndex > minIndex { + *paragraphs = append(*paragraphs, markdownParagraphRef{ + paragraph: el.Paragraph, + startIndex: el.StartIndex, + endIndex: el.EndIndex, + }) + } + } + if el.Table == nil { + continue + } + for _, row := range el.Table.TableRows { + if row == nil { + continue + } + for _, cell := range row.TableCells { + if cell != nil { + appendMarkdownParagraphs(paragraphs, cell.Content, minIndex) + } + } + } + } +} + func markdownHeadingParagraphText(p *docs.Paragraph) string { var b strings.Builder for _, el := range p.Elements { @@ -99,7 +234,7 @@ func markdownHeadingParagraphText(p *docs.Paragraph) string { return strings.TrimSpace(b.String()) } -func markdownHeadingSlug(text string, seen map[string]int) string { +func markdownHeadingSlug(text string, seen map[string]int, used map[string]bool) string { text = strings.ToLower(strings.TrimSpace(text)) var b strings.Builder lastHyphen := false @@ -120,9 +255,16 @@ func markdownHeadingSlug(text string, seen map[string]int) string { return "" } n := seen[slug] - seen[slug] = n + 1 - if n == 0 { - return slug + for { + candidate := slug + if n > 0 { + candidate = slug + "-" + strconv.Itoa(n) + } + seen[slug] = n + 1 + n++ + if !used[candidate] { + used[candidate] = true + return candidate + } } - return slug + "-" + strconv.Itoa(n) } diff --git a/internal/cmd/docs_markdown_links_test.go b/internal/cmd/docs_markdown_links_test.go new file mode 100644 index 00000000..66c42fb6 --- /dev/null +++ b/internal/cmd/docs_markdown_links_test.go @@ -0,0 +1,443 @@ +package cmd + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "google.golang.org/api/docs/v1" + "google.golang.org/api/option" +) + +func TestRewriteMarkdownHeadingLinks_RewritesTableCellLinks(t *testing.T) { + var batchReq docs.BatchUpdateDocumentRequest + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/documents/doc1"): + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(&docs.Document{ + DocumentId: "doc1", + Body: &docs.Body{Content: []*docs.StructuralElement{ + { + StartIndex: 1, + EndIndex: 7, + Paragraph: &docs.Paragraph{ + ParagraphStyle: &docs.ParagraphStyle{NamedStyleType: "HEADING_1", HeadingId: "h.files"}, + Elements: []*docs.ParagraphElement{{ + StartIndex: 1, + EndIndex: 7, + TextRun: &docs.TextRun{Content: "Files\n"}, + }}, + }, + }, + { + StartIndex: 7, + EndIndex: 20, + Table: &docs.Table{TableRows: []*docs.TableRow{{ + TableCells: []*docs.TableCell{{ + Content: []*docs.StructuralElement{{ + StartIndex: 10, + EndIndex: 15, + Paragraph: &docs.Paragraph{Elements: []*docs.ParagraphElement{{ + StartIndex: 10, + EndIndex: 14, + TextRun: &docs.TextRun{ + Content: "Jump", + TextStyle: &docs.TextStyle{Link: &docs.Link{Url: "#attachments"}}, + }, + }}}, + }}, + }}, + }}}, + }, + }}, + }) + case r.Method == http.MethodPost && strings.Contains(r.URL.Path, "/documents/doc1:batchUpdate"): + if err := json.NewDecoder(r.Body).Decode(&batchReq); err != nil { + t.Fatalf("decode batch update: %v", err) + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{"documentId": "doc1"}) + default: + http.NotFound(w, r) + } + })) + defer srv.Close() + + svc, err := docs.NewService(context.Background(), + option.WithoutAuthentication(), + option.WithHTTPClient(srv.Client()), + option.WithEndpoint(srv.URL+"/"), + ) + if err != nil { + t.Fatalf("NewDocsService: %v", err) + } + + count, err := rewriteMarkdownHeadingLinks(context.Background(), svc, "doc1", "", []markdownExplicitHeadingAnchor{{ + Anchor: "attachments", + Text: "Files", + Occurrence: 1, + }}) + if err != nil { + t.Fatalf("rewriteMarkdownHeadingLinks: %v", err) + } + if count != 1 { + t.Fatalf("rewrite count = %d, want 1", count) + } + if len(batchReq.Requests) != 1 || batchReq.Requests[0].UpdateTextStyle == nil { + t.Fatalf("expected one UpdateTextStyle request, got %#v", batchReq.Requests) + } + styleReq := batchReq.Requests[0].UpdateTextStyle + if styleReq.Range.StartIndex != 10 || styleReq.Range.EndIndex != 14 { + t.Fatalf("unexpected rewrite range: %#v", styleReq.Range) + } + if styleReq.TextStyle == nil || styleReq.TextStyle.Link == nil || styleReq.TextStyle.Link.HeadingId != "h.files" { + t.Fatalf("unexpected link rewrite request: %#v", styleReq) + } +} + +func TestRewriteMarkdownHeadingLinks_MatchesExplicitAnchorByHeadingText(t *testing.T) { + var batchReq docs.BatchUpdateDocumentRequest + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/documents/doc1"): + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(&docs.Document{ + DocumentId: "doc1", + Body: &docs.Body{Content: []*docs.StructuralElement{ + { + StartIndex: 1, + EndIndex: 7, + Paragraph: &docs.Paragraph{ + ParagraphStyle: &docs.ParagraphStyle{NamedStyleType: "HEADING_1", HeadingId: "h.intro"}, + Elements: []*docs.ParagraphElement{{ + StartIndex: 1, + EndIndex: 7, + TextRun: &docs.TextRun{Content: "Intro\n"}, + }}, + }, + }, + { + StartIndex: 7, + EndIndex: 13, + Paragraph: &docs.Paragraph{ + ParagraphStyle: &docs.ParagraphStyle{NamedStyleType: "HEADING_1", HeadingId: "h.files"}, + Elements: []*docs.ParagraphElement{{ + StartIndex: 7, + EndIndex: 13, + TextRun: &docs.TextRun{Content: "Files\n"}, + }}, + }, + }, + { + StartIndex: 13, + EndIndex: 18, + Paragraph: &docs.Paragraph{Elements: []*docs.ParagraphElement{{ + StartIndex: 13, + EndIndex: 17, + TextRun: &docs.TextRun{ + Content: "Jump", + TextStyle: &docs.TextStyle{Link: &docs.Link{Url: "#attachments"}}, + }, + }}}, + }, + }}, + }) + case r.Method == http.MethodPost && strings.Contains(r.URL.Path, "/documents/doc1:batchUpdate"): + if err := json.NewDecoder(r.Body).Decode(&batchReq); err != nil { + t.Fatalf("decode batch update: %v", err) + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{"documentId": "doc1"}) + default: + http.NotFound(w, r) + } + })) + defer srv.Close() + + svc, err := docs.NewService(context.Background(), + option.WithoutAuthentication(), + option.WithHTTPClient(srv.Client()), + option.WithEndpoint(srv.URL+"/"), + ) + if err != nil { + t.Fatalf("NewDocsService: %v", err) + } + + count, err := rewriteMarkdownHeadingLinks(context.Background(), svc, "doc1", "", []markdownExplicitHeadingAnchor{{ + Anchor: "attachments", + Text: "Files", + Occurrence: 1, + }}) + if err != nil { + t.Fatalf("rewriteMarkdownHeadingLinks: %v", err) + } + if count != 1 { + t.Fatalf("rewrite count = %d, want 1", count) + } + styleReq := batchReq.Requests[0].UpdateTextStyle + if styleReq.TextStyle == nil || styleReq.TextStyle.Link == nil || styleReq.TextStyle.Link.HeadingId != "h.files" { + t.Fatalf("link target = %#v, want h.files", styleReq) + } +} + +func TestRewriteMarkdownHeadingLinks_ExplicitAnchorReservesAutoSlug(t *testing.T) { + var batchReq docs.BatchUpdateDocumentRequest + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/documents/doc1"): + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(&docs.Document{ + DocumentId: "doc1", + Body: &docs.Body{Content: []*docs.StructuralElement{ + { + StartIndex: 1, + EndIndex: 7, + Paragraph: &docs.Paragraph{ + ParagraphStyle: &docs.ParagraphStyle{NamedStyleType: "HEADING_1", HeadingId: "h.files1"}, + Elements: []*docs.ParagraphElement{{ + StartIndex: 1, + EndIndex: 7, + TextRun: &docs.TextRun{Content: "Files\n"}, + }}, + }, + }, + { + StartIndex: 7, + EndIndex: 13, + Paragraph: &docs.Paragraph{ + ParagraphStyle: &docs.ParagraphStyle{NamedStyleType: "HEADING_1", HeadingId: "h.files2"}, + Elements: []*docs.ParagraphElement{{ + StartIndex: 7, + EndIndex: 13, + TextRun: &docs.TextRun{Content: "Files\n"}, + }}, + }, + }, + { + StartIndex: 13, + EndIndex: 18, + Paragraph: &docs.Paragraph{Elements: []*docs.ParagraphElement{{ + StartIndex: 13, + EndIndex: 17, + TextRun: &docs.TextRun{ + Content: "Next", + TextStyle: &docs.TextStyle{Link: &docs.Link{Url: "#files-1"}}, + }, + }}}, + }, + }}, + }) + case r.Method == http.MethodPost && strings.Contains(r.URL.Path, "/documents/doc1:batchUpdate"): + if err := json.NewDecoder(r.Body).Decode(&batchReq); err != nil { + t.Fatalf("decode batch update: %v", err) + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{"documentId": "doc1"}) + default: + http.NotFound(w, r) + } + })) + defer srv.Close() + + svc, err := docs.NewService(context.Background(), + option.WithoutAuthentication(), + option.WithHTTPClient(srv.Client()), + option.WithEndpoint(srv.URL+"/"), + ) + if err != nil { + t.Fatalf("NewDocsService: %v", err) + } + + count, err := rewriteMarkdownHeadingLinks(context.Background(), svc, "doc1", "", []markdownExplicitHeadingAnchor{{ + Anchor: "files", + Text: "Files", + Occurrence: 1, + }}) + if err != nil { + t.Fatalf("rewriteMarkdownHeadingLinks: %v", err) + } + if count != 1 { + t.Fatalf("rewrite count = %d, want 1", count) + } + styleReq := batchReq.Requests[0].UpdateTextStyle + if styleReq.TextStyle == nil || styleReq.TextStyle.Link == nil || styleReq.TextStyle.Link.HeadingId != "h.files2" { + t.Fatalf("link target = %#v, want h.files2", styleReq) + } +} + +func TestRewriteMarkdownHeadingLinksFromIndex_RewritesLinkInExistingParagraphTail(t *testing.T) { + var batchReq docs.BatchUpdateDocumentRequest + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/documents/doc1"): + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(&docs.Document{ + DocumentId: "doc1", + Body: &docs.Body{Content: []*docs.StructuralElement{ + { + StartIndex: 1, + EndIndex: 14, + Paragraph: &docs.Paragraph{Elements: []*docs.ParagraphElement{ + { + StartIndex: 1, + EndIndex: 9, + TextRun: &docs.TextRun{Content: "Existing"}, + }, + { + StartIndex: 9, + EndIndex: 13, + TextRun: &docs.TextRun{ + Content: "Jump", + TextStyle: &docs.TextStyle{Link: &docs.Link{Url: "#attachments"}}, + }, + }, + }}, + }, + { + StartIndex: 14, + EndIndex: 20, + Paragraph: &docs.Paragraph{ + ParagraphStyle: &docs.ParagraphStyle{NamedStyleType: "HEADING_1", HeadingId: "h.files"}, + Elements: []*docs.ParagraphElement{{ + StartIndex: 14, + EndIndex: 20, + TextRun: &docs.TextRun{Content: "Files\n"}, + }}, + }, + }, + }}, + }) + case r.Method == http.MethodPost && strings.Contains(r.URL.Path, "/documents/doc1:batchUpdate"): + if err := json.NewDecoder(r.Body).Decode(&batchReq); err != nil { + t.Fatalf("decode batch update: %v", err) + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{"documentId": "doc1"}) + default: + http.NotFound(w, r) + } + })) + defer srv.Close() + + svc, err := docs.NewService(context.Background(), + option.WithoutAuthentication(), + option.WithHTTPClient(srv.Client()), + option.WithEndpoint(srv.URL+"/"), + ) + if err != nil { + t.Fatalf("NewDocsService: %v", err) + } + + count, err := rewriteMarkdownHeadingLinksFromIndex(context.Background(), svc, "doc1", "", []markdownExplicitHeadingAnchor{{ + Anchor: "attachments", + Text: "Files", + Occurrence: 1, + }}, 9) + if err != nil { + t.Fatalf("rewriteMarkdownHeadingLinksFromIndex: %v", err) + } + if count != 1 { + t.Fatalf("rewrite count = %d, want 1", count) + } + if len(batchReq.Requests) != 1 || batchReq.Requests[0].UpdateTextStyle == nil { + t.Fatalf("expected one UpdateTextStyle request, got %#v", batchReq.Requests) + } + styleReq := batchReq.Requests[0].UpdateTextStyle + if styleReq.Range.StartIndex != 9 || styleReq.Range.EndIndex != 13 { + t.Fatalf("unexpected rewrite range: %#v", styleReq.Range) + } + if styleReq.TextStyle == nil || styleReq.TextStyle.Link == nil || styleReq.TextStyle.Link.HeadingId != "h.files" { + t.Fatalf("unexpected link rewrite request: %#v", styleReq) + } +} + +func TestRewriteMarkdownHeadingLinksInRange_SkipsLaterExistingLinks(t *testing.T) { + var batchReq docs.BatchUpdateDocumentRequest + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/documents/doc1"): + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(&docs.Document{ + DocumentId: "doc1", + Body: &docs.Body{Content: []*docs.StructuralElement{ + { + StartIndex: 10, + EndIndex: 16, + Paragraph: &docs.Paragraph{ + ParagraphStyle: &docs.ParagraphStyle{NamedStyleType: "HEADING_1", HeadingId: "h.files"}, + Elements: []*docs.ParagraphElement{{ + StartIndex: 10, + EndIndex: 16, + TextRun: &docs.TextRun{Content: "Files\n"}, + }}, + }, + }, + { + StartIndex: 16, + EndIndex: 21, + Paragraph: &docs.Paragraph{Elements: []*docs.ParagraphElement{{ + StartIndex: 16, + EndIndex: 20, + TextRun: &docs.TextRun{ + Content: "Jump", + TextStyle: &docs.TextStyle{Link: &docs.Link{Url: "#attachments"}}, + }, + }}}, + }, + { + StartIndex: 40, + EndIndex: 46, + Paragraph: &docs.Paragraph{Elements: []*docs.ParagraphElement{{ + StartIndex: 40, + EndIndex: 45, + TextRun: &docs.TextRun{ + Content: "Later", + TextStyle: &docs.TextStyle{Link: &docs.Link{Url: "#attachments"}}, + }, + }}}, + }, + }}, + }) + case r.Method == http.MethodPost && strings.Contains(r.URL.Path, "/documents/doc1:batchUpdate"): + if err := json.NewDecoder(r.Body).Decode(&batchReq); err != nil { + t.Fatalf("decode batch update: %v", err) + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{"documentId": "doc1"}) + default: + http.NotFound(w, r) + } + })) + defer srv.Close() + + svc, err := docs.NewService(context.Background(), + option.WithoutAuthentication(), + option.WithHTTPClient(srv.Client()), + option.WithEndpoint(srv.URL+"/"), + ) + if err != nil { + t.Fatalf("NewDocsService: %v", err) + } + + count, err := rewriteMarkdownHeadingLinksInRange(context.Background(), svc, "doc1", "", []markdownExplicitHeadingAnchor{{ + Anchor: "attachments", + Text: "Files", + Occurrence: 1, + }}, 10, 21) + if err != nil { + t.Fatalf("rewriteMarkdownHeadingLinksInRange: %v", err) + } + if count != 1 { + t.Fatalf("rewrite count = %d, want 1", count) + } + if len(batchReq.Requests) != 1 || batchReq.Requests[0].UpdateTextStyle == nil { + t.Fatalf("expected one rewrite request, got %#v", batchReq.Requests) + } + if got := batchReq.Requests[0].UpdateTextStyle.Range; got.StartIndex != 16 || got.EndIndex != 20 { + t.Fatalf("rewrite range = %#v, want inserted link only", got) + } +} diff --git a/internal/cmd/docs_markdown_test.go b/internal/cmd/docs_markdown_test.go index cd737a0b..b20ff661 100644 --- a/internal/cmd/docs_markdown_test.go +++ b/internal/cmd/docs_markdown_test.go @@ -71,6 +71,76 @@ func TestParseMarkdown(t *testing.T) { } } +func TestParseMarkdown_ExplicitHeadingAnchor(t *testing.T) { + got := ParseMarkdown("# Files {#attachments}\n\n```md\n# Keep {#literal}\n```") + if len(got) != 3 { + t.Fatalf("ParseMarkdown() got %d elements, want 3: %#v", len(got), got) + } + if got[0].Content != "Files {#attachments}" || got[0].Anchor != "attachments" { + t.Fatalf("heading = content %q anchor %q, want unstripped content/attachments", got[0].Content, got[0].Anchor) + } + if got[2].Content != "# Keep {#literal}" { + t.Fatalf("code block anchor marker should stay literal, got %q", got[2].Content) + } +} + +func TestParseMarkdown_ExplicitHeadingAnchorPandocIDs(t *testing.T) { + got := ParseMarkdown("# API {#_toc}\n## Unicode {#über}\n### Dash {#-api}") + if len(got) != 3 { + t.Fatalf("ParseMarkdown() got %d elements, want 3: %#v", len(got), got) + } + want := []string{"_toc", "über", "-api"} + for i, anchor := range want { + if got[i].Anchor != anchor { + t.Fatalf("heading %d anchor = %q, want %q", i, got[i].Anchor, anchor) + } + } + if stripped := stripMarkdownHeadingAnchors("# API {#_toc}\n## Unicode {#über}\n### Dash {#-api}\n"); stripped != "# API\n## Unicode\n### Dash\n" { + t.Fatalf("stripMarkdownHeadingAnchors() = %q", stripped) + } +} + +func TestParseMarkdown_ExplicitHeadingAnchorInsideTildeFence(t *testing.T) { + got := ParseMarkdown("~~~md\n# Keep {#literal}\n~~~\n\n# Files {#attachments}") + if len(got) != 3 { + t.Fatalf("ParseMarkdown() got %d elements, want 3: %#v", len(got), got) + } + if got[0].Type != MDCodeBlock || got[0].Content != "# Keep {#literal}" { + t.Fatalf("code block = %#v, want literal anchor marker", got[0]) + } + if got[2].Content != "Files {#attachments}" || got[2].Anchor != "attachments" { + t.Fatalf("heading = content %q anchor %q, want unstripped content/attachments", got[2].Content, got[2].Anchor) + } +} + +func TestParseMarkdown_UnclosedTildeFenceRunsToEOF(t *testing.T) { + got := ParseMarkdown("~~~md\n# Keep {#literal}\nmore") + if len(got) != 1 { + t.Fatalf("ParseMarkdown() got %d elements, want 1: %#v", len(got), got) + } + if got[0].Type != MDCodeBlock || got[0].Content != "# Keep {#literal}\nmore" { + t.Fatalf("code block = %#v, want unclosed tilde fence content through EOF", got[0]) + } +} + +func TestStripMarkdownHeadingAnchors(t *testing.T) { + input := "# Files {#attachments}\n ## Indented {#indented}\nSetext {#setext}\n---\n code {#literal}\n ---\n- list {#literal}\n---\n\n```md\n# Keep {#literal}\n```\n~~~md\n# Keep tilde {#literal}\n~~~\n ```md\n# Keep indented {#literal}\n ```\n## Other\n" + want := "# Files\n ## Indented\nSetext\n---\n code {#literal}\n ---\n- list {#literal}\n---\n\n```md\n# Keep {#literal}\n```\n~~~md\n# Keep tilde {#literal}\n~~~\n ```md\n# Keep indented {#literal}\n ```\n## Other\n" + if got := stripMarkdownHeadingAnchors(input); got != want { + t.Fatalf("stripMarkdownHeadingAnchors() = %q, want %q", got, want) + } +} + +func TestMarkdownImportExplicitHeadingAnchors_CountsDriveHeadings(t *testing.T) { + got := markdownImportExplicitHeadingAnchors("Files\n---\n\n ## Files {#attachments}\n") + if len(got) != 1 { + t.Fatalf("markdownImportExplicitHeadingAnchors() got %d anchors, want 1: %#v", len(got), got) + } + if got[0].Anchor != "attachments" || got[0].Text != "Files" || got[0].Occurrence != 2 { + t.Fatalf("anchor = %#v, want attachments/Files occurrence 2", got[0]) + } +} + func TestParseMarkdown_NestedLists(t *testing.T) { result := ParseMarkdown("- Parent\n - Child\n - Grandchild\n\t- Tab sibling\n1. One\n 1. Nested one") if len(result) != 6 { @@ -400,6 +470,7 @@ func TestParseHeading(t *testing.T) { {"## Subtitle", 2, "Subtitle"}, {"### Section", 3, "Section"}, {"#### Subsection", 4, "Subsection"}, + {" ### Indented", 3, "Indented"}, {"Not a heading", 0, ""}, {"#No space", 0, ""}, } diff --git a/internal/cmd/docs_mutation.go b/internal/cmd/docs_mutation.go index f615e0a6..ceb20532 100644 --- a/internal/cmd/docs_mutation.go +++ b/internal/cmd/docs_mutation.go @@ -126,7 +126,9 @@ func replaceDocsTextRange(ctx context.Context, svc *docs.Service, doc *docs.Docu func replaceDocsMarkdownRange(ctx context.Context, svc *docs.Service, doc *docs.Document, startIdx, endIdx int64, replaceText string, tabID string) (requestCount int, inserted int, err error) { cleaned, images := extractMarkdownImages(replaceText) + explicitHeadingAnchors := markdownExplicitHeadingAnchors(cleaned) elements := ParseMarkdown(cleaned) + stripMarkdownElementHeadingAnchors(elements) prefix := "" baseIndex := startIdx if markdownReplaceNeedsParagraphBoundary(doc, startIdx, tabID, elements) { @@ -163,6 +165,7 @@ func replaceDocsMarkdownRange(ctx context.Context, svc *docs.Service, doc *docs. return 0, 0, fmt.Errorf("replace (markdown): %w", err) } + rewriteMaxIndex := baseIndex + utf16Len(textToInsert) if len(tables) > 0 { tableInserter := NewTableInserter(svc, doc.DocumentId) tableOffset := int64(0) @@ -174,6 +177,7 @@ func replaceDocsMarkdownRange(ctx context.Context, svc *docs.Service, doc *docs. } tableOffset = nextTableInsertOffset(tableOffset, tableIndex, tableEnd) } + rewriteMaxIndex += tableOffset } if len(images) > 0 { @@ -182,6 +186,15 @@ func replaceDocsMarkdownRange(ctx context.Context, svc *docs.Service, doc *docs. if imgErr != nil { return requestCount, len(prefix) + len(textToInsert), fmt.Errorf("insert images: %w", imgErr) } + rewriteMaxIndex = subtractMarkdownImagePlaceholderDrift(rewriteMaxIndex, baseIndex, images) + } + + if markdownMayContainHeadingLinks(cleaned) { + rewritten, rewriteErr := rewriteMarkdownHeadingLinksInRange(ctx, svc, doc.DocumentId, tabID, explicitHeadingAnchors, baseIndex, rewriteMaxIndex) + if rewriteErr != nil { + return requestCount, len(prefix) + len(textToInsert), fmt.Errorf("rewrite heading links: %w", rewriteErr) + } + requestCount += rewritten } return requestCount, len(prefix) + len(textToInsert), nil @@ -192,8 +205,20 @@ func markdownReplaceNeedsParagraphBoundary(doc *docs.Document, startIdx int64, t } func insertDocsMarkdownAt(ctx context.Context, svc *docs.Service, docID string, insertIdx int64, content string, tabID string) (requestCount int, inserted int, err error) { + return insertDocsMarkdownAtWithOptions(ctx, svc, docID, insertIdx, content, tabID, false) +} + +func insertDocsMarkdownAtWithOptions(ctx context.Context, svc *docs.Service, docID string, insertIdx int64, content string, tabID string, stripHeadingAnchors bool) (requestCount int, inserted int, err error) { + requestCount, inserted, _, err = insertDocsMarkdownAtWithOptionsAndEnd(ctx, svc, docID, insertIdx, content, tabID, stripHeadingAnchors) + return requestCount, inserted, err +} + +func insertDocsMarkdownAtWithOptionsAndEnd(ctx context.Context, svc *docs.Service, docID string, insertIdx int64, content string, tabID string, stripHeadingAnchors bool) (requestCount int, inserted int, endIndex int64, err error) { cleaned, images := extractMarkdownImages(content) elements := ParseMarkdown(cleaned) + if stripHeadingAnchors { + stripMarkdownElementHeadingAnchors(elements) + } prefix := "" baseIndex := insertIdx if insertIdx > 1 && markdownAppendNeedsParagraphBoundary(elements) { @@ -202,8 +227,9 @@ func insertDocsMarkdownAt(ctx context.Context, svc *docs.Service, docID string, } formattingRequests, textToInsert, tables := MarkdownToDocsRequests(elements, baseIndex, tabID) if textToInsert == "" { - return 0, 0, nil + return 0, 0, insertIdx, nil } + endIndex = insertIdx + utf16Len(prefix+textToInsert) applyTabIDToFormattingRequests(formattingRequests, tabID) @@ -221,7 +247,7 @@ func insertDocsMarkdownAt(ctx context.Context, svc *docs.Service, docID string, requestCount, err = submitBatchedDocsRequests(ctx, svc, docID, requests, nil) if err != nil { - return 0, 0, fmt.Errorf("append (markdown): %w", err) + return 0, 0, insertIdx, fmt.Errorf("append (markdown): %w", err) } if len(tables) > 0 { @@ -231,21 +257,36 @@ func insertDocsMarkdownAt(ctx context.Context, svc *docs.Service, docID string, tableIndex := table.StartIndex + tableOffset tableEnd, tableErr := tableInserter.InsertNativeTable(ctx, tableIndex, table.Cells, tabID) if tableErr != nil { - return requestCount, len(textToInsert), fmt.Errorf("insert native table: %w", tableErr) + return requestCount, len(textToInsert), endIndex, fmt.Errorf("insert native table: %w", tableErr) } tableOffset = nextTableInsertOffset(tableOffset, tableIndex, tableEnd) } + endIndex += tableOffset } if len(images) > 0 { imgErr := insertImagesIntoDocs(ctx, svc, docID, images, tabID) cleanupDocsImagePlaceholders(ctx, svc, docID, images, tabID) if imgErr != nil { - return requestCount, len(prefix) + len(textToInsert), fmt.Errorf("insert images: %w", imgErr) + return requestCount, len(prefix) + len(textToInsert), endIndex, fmt.Errorf("insert images: %w", imgErr) } + endIndex = subtractMarkdownImagePlaceholderDrift(endIndex, insertIdx, images) } - return requestCount, len(prefix) + len(textToInsert), nil + return requestCount, len(prefix) + len(textToInsert), endIndex, nil +} + +func subtractMarkdownImagePlaceholderDrift(index int64, floor int64, images []markdownImage) int64 { + for _, img := range images { + drift := utf16Len(img.placeholder()) - 1 + if drift > 0 { + index -= drift + } + } + if index < floor { + return floor + } + return index } // applyTabIDToFormattingRequests propagates tabID to every request whose diff --git a/internal/cmd/docs_write_markdown_tab_test.go b/internal/cmd/docs_write_markdown_tab_test.go index 6d411f1c..0f0bbf7a 100644 --- a/internal/cmd/docs_write_markdown_tab_test.go +++ b/internal/cmd/docs_write_markdown_tab_test.go @@ -125,6 +125,127 @@ func TestDocsWrite_MarkdownReplaceWithTab(t *testing.T) { } } +func TestDocsWrite_MarkdownReplaceWithTabRewritesExplicitHeadingAnchorLinks(t *testing.T) { + origDocs := newDocsService + origDrive := newDriveService + t.Cleanup(func() { + newDocsService = origDocs + newDriveService = origDrive + }) + + var batchRequests [][]*docs.Request + var includeTabsCalls int + var getCalls int + + docSvc, cleanup := newDocsServiceForTest(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + path := r.URL.Path + switch { + case r.Method == http.MethodGet && strings.HasPrefix(path, "/v1/documents/"): + if strings.Contains(r.URL.RawQuery, "includeTabsContent=true") { + includeTabsCalls++ + } + getCalls++ + w.Header().Set("Content-Type", "application/json") + if getCalls == 1 { + _ = json.NewEncoder(w).Encode(tabsDocWithEndIndex()) + return + } + _ = json.NewEncoder(w).Encode(&docs.Document{ + DocumentId: "doc1", + Tabs: []*docs.Tab{{ + TabProperties: &docs.TabProperties{TabId: "t.second", Title: "Second"}, + DocumentTab: &docs.DocumentTab{Body: &docs.Body{Content: []*docs.StructuralElement{ + { + StartIndex: 1, + EndIndex: 7, + Paragraph: &docs.Paragraph{ + ParagraphStyle: &docs.ParagraphStyle{NamedStyleType: "HEADING_1", HeadingId: "h.files"}, + Elements: []*docs.ParagraphElement{{ + StartIndex: 1, + EndIndex: 7, + TextRun: &docs.TextRun{Content: "Files\n"}, + }}, + }, + }, + { + StartIndex: 8, + EndIndex: 13, + Paragraph: &docs.Paragraph{ + Elements: []*docs.ParagraphElement{{ + StartIndex: 8, + EndIndex: 12, + TextRun: &docs.TextRun{ + Content: "Jump", + TextStyle: &docs.TextStyle{Link: &docs.Link{Url: "#attachments"}}, + }, + }}, + }, + }, + }}}, + }}, + }) + return + case r.Method == http.MethodPost && strings.Contains(path, ":batchUpdate"): + var req docs.BatchUpdateDocumentRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + t.Fatalf("decode batch request: %v", err) + } + batchRequests = append(batchRequests, req.Requests) + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{"documentId": "doc1"}) + return + default: + http.NotFound(w, r) + return + } + })) + defer cleanup() + + newDocsService = func(context.Context, string) (*docs.Service, error) { return docSvc, nil } + newDriveService = func(context.Context, string) (*drive.Service, error) { + t.Fatal("markdown replace with --tab must not use the Drive converter") + return nil, errors.New("unexpected Drive service call") + } + + flags := &RootFlags{Account: "a@b.com"} + ctx := newDocsJSONContext(t) + + markdown := "# Files {#attachments}\n\n[Jump](#attachments)\n" + if err := runKong(t, &DocsWriteCmd{}, []string{ + "doc1", "--text", markdown, "--replace", "--markdown", "--tab", "Second", + }, ctx, flags); err != nil { + t.Fatalf("markdown replace with tab: %v", err) + } + + if includeTabsCalls != 2 { + t.Fatalf("expected 2 tab-aware GETs, got %d", includeTabsCalls) + } + if len(batchRequests) != 3 { + t.Fatalf("expected delete + insert + link rewrite batches, got %d", len(batchRequests)) + } + insertReqs := batchRequests[1] + if len(insertReqs) == 0 || insertReqs[0].InsertText == nil { + t.Fatalf("expected insert batch, got %#v", insertReqs) + } + if got := insertReqs[0].InsertText.Text; got != "Files\n\nJump\n" { + t.Fatalf("inserted text = %q, want explicit anchor stripped", got) + } + rewriteReqs := batchRequests[2] + if len(rewriteReqs) != 1 || rewriteReqs[0].UpdateTextStyle == nil { + t.Fatalf("expected one link rewrite request, got %#v", rewriteReqs) + } + styleReq := rewriteReqs[0].UpdateTextStyle + if styleReq.Range.TabId != "t.second" || styleReq.Range.StartIndex != 8 || styleReq.Range.EndIndex != 12 { + t.Fatalf("unexpected rewrite range: %#v", styleReq.Range) + } + if styleReq.TextStyle == nil || styleReq.TextStyle.Link == nil || + styleReq.TextStyle.Link.Heading == nil || + styleReq.TextStyle.Link.Heading.Id != "h.files" || + styleReq.TextStyle.Link.Heading.TabId != "t.second" { + t.Fatalf("unexpected heading link target: %#v", styleReq.TextStyle) + } +} + func TestDocsWrite_MarkdownReplaceWithTab_NestedLists(t *testing.T) { origDocs := newDocsService origDrive := newDriveService diff --git a/internal/cmd/docs_write_markdown_test.go b/internal/cmd/docs_write_markdown_test.go index 292b0ffb..7e5b2efb 100644 --- a/internal/cmd/docs_write_markdown_test.go +++ b/internal/cmd/docs_write_markdown_test.go @@ -244,6 +244,113 @@ func TestDocsWrite_MarkdownReplaceRewritesHeadingSlugLinks(t *testing.T) { } } +func TestDocsWrite_MarkdownReplaceStripsExplicitHeadingAnchors(t *testing.T) { + origDocs := newDocsService + origDrive := newDriveService + t.Cleanup(func() { + newDocsService = origDocs + newDriveService = origDrive + }) + + var uploadBody string + var sawDocsGet bool + var batchReq docs.BatchUpdateDocumentRequest + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case strings.HasPrefix(r.URL.Path, "/upload/drive/v3/files/doc1"): + body, err := io.ReadAll(r.Body) + if err != nil { + t.Fatalf("read body: %v", err) + } + uploadBody = string(body) + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{"id": "doc1", "name": "Doc"}) + case r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/documents/doc1"): + sawDocsGet = true + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(&docs.Document{ + DocumentId: "doc1", + Body: &docs.Body{Content: []*docs.StructuralElement{ + { + StartIndex: 1, + EndIndex: 7, + Paragraph: &docs.Paragraph{ + ParagraphStyle: &docs.ParagraphStyle{NamedStyleType: "HEADING_1", HeadingId: "h.files"}, + Elements: []*docs.ParagraphElement{{ + StartIndex: 1, + EndIndex: 7, + TextRun: &docs.TextRun{Content: "Files\n"}, + }}, + }, + }, + { + StartIndex: 7, + EndIndex: 12, + Paragraph: &docs.Paragraph{ + Elements: []*docs.ParagraphElement{{ + StartIndex: 7, + EndIndex: 11, + TextRun: &docs.TextRun{ + Content: "Jump", + TextStyle: &docs.TextStyle{Link: &docs.Link{Url: "#attachments"}}, + }, + }}, + }, + }, + }}, + }) + case r.Method == http.MethodPost && strings.Contains(r.URL.Path, "/documents/doc1:batchUpdate"): + if err := json.NewDecoder(r.Body).Decode(&batchReq); err != nil { + t.Fatalf("decode batch update: %v", err) + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{"documentId": "doc1"}) + default: + http.NotFound(w, r) + } + })) + defer srv.Close() + + driveSvc, err := drive.NewService(context.Background(), + option.WithoutAuthentication(), + option.WithHTTPClient(srv.Client()), + option.WithEndpoint(srv.URL+"/drive/v3/"), + ) + if err != nil { + t.Fatalf("NewDriveService: %v", err) + } + docsSvc, err := docs.NewService(context.Background(), + option.WithoutAuthentication(), + option.WithHTTPClient(srv.Client()), + option.WithEndpoint(srv.URL+"/"), + ) + if err != nil { + t.Fatalf("NewDocsService: %v", err) + } + newDriveService = func(context.Context, string) (*drive.Service, error) { return driveSvc, nil } + newDocsService = func(context.Context, string) (*docs.Service, error) { return docsSvc, nil } + + markdown := "# Files {#attachments}\n\n[Jump](#attachments)\n" + flags := &RootFlags{Account: "a@b.com"} + ctx := newDocsJSONContext(t) + if err := runKong(t, &DocsWriteCmd{}, []string{"doc1", "--text", markdown, "--replace", "--markdown"}, ctx, flags); err != nil { + t.Fatalf("markdown replace write: %v", err) + } + if strings.Contains(uploadBody, "{#attachments}") { + t.Fatalf("upload body still contains explicit anchor: %q", uploadBody) + } + if !sawDocsGet { + t.Fatal("expected Docs get after Drive markdown import") + } + if len(batchReq.Requests) != 1 || batchReq.Requests[0].UpdateTextStyle == nil { + t.Fatalf("expected one UpdateTextStyle request, got %#v", batchReq.Requests) + } + styleReq := batchReq.Requests[0].UpdateTextStyle + if styleReq.TextStyle == nil || styleReq.TextStyle.Link == nil || styleReq.TextStyle.Link.HeadingId != "h.files" { + t.Fatalf("unexpected link rewrite request: %#v", styleReq) + } +} + func TestDocsWrite_MarkdownImagesInsertedAfterDriveUpdate(t *testing.T) { origDocs := newDocsService origDrive := newDriveService @@ -520,6 +627,121 @@ func TestDocsWrite_MarkdownAppendUsesDocsFormatting(t *testing.T) { } } +func TestDocsWrite_MarkdownAppendRewritesExplicitHeadingAnchorLinks(t *testing.T) { + origDocs := newDocsService + origDrive := newDriveService + t.Cleanup(func() { + newDocsService = origDocs + newDriveService = origDrive + }) + + var batchRequests [][]*docs.Request + var getCalls int + + docSvc, cleanup := newDocsServiceForTest(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + path := r.URL.Path + switch { + case r.Method == http.MethodGet && strings.HasPrefix(path, "/v1/documents/"): + getCalls++ + w.Header().Set("Content-Type", "application/json") + if getCalls == 1 { + _ = json.NewEncoder(w).Encode(docBodyWithText("Existing\n")) + return + } + _ = json.NewEncoder(w).Encode(&docs.Document{ + DocumentId: "doc1", + Body: &docs.Body{Content: []*docs.StructuralElement{ + { + StartIndex: 1, + EndIndex: 10, + Paragraph: &docs.Paragraph{ + ParagraphStyle: &docs.ParagraphStyle{NamedStyleType: "HEADING_1", HeadingId: "h.existing"}, + Elements: []*docs.ParagraphElement{{ + StartIndex: 1, + EndIndex: 10, + TextRun: &docs.TextRun{Content: "Existing\n"}, + }}, + }, + }, + { + StartIndex: 10, + EndIndex: 16, + Paragraph: &docs.Paragraph{ + ParagraphStyle: &docs.ParagraphStyle{NamedStyleType: "HEADING_1", HeadingId: "h.files"}, + Elements: []*docs.ParagraphElement{{ + StartIndex: 10, + EndIndex: 16, + TextRun: &docs.TextRun{Content: "Files\n"}, + }}, + }, + }, + { + StartIndex: 17, + EndIndex: 22, + Paragraph: &docs.Paragraph{Elements: []*docs.ParagraphElement{{ + StartIndex: 17, + EndIndex: 21, + TextRun: &docs.TextRun{ + Content: "Jump", + TextStyle: &docs.TextStyle{Link: &docs.Link{Url: "#attachments"}}, + }, + }}}, + }, + }}, + }) + return + case r.Method == http.MethodPost && strings.Contains(path, ":batchUpdate"): + var req docs.BatchUpdateDocumentRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + t.Fatalf("decode batch request: %v", err) + } + batchRequests = append(batchRequests, req.Requests) + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{"documentId": "doc1"}) + return + default: + http.NotFound(w, r) + return + } + })) + defer cleanup() + + newDocsService = func(context.Context, string) (*docs.Service, error) { return docSvc, nil } + newDriveService = func(context.Context, string) (*drive.Service, error) { + t.Fatal("markdown append should not use Drive update") + return nil, errors.New("unexpected Drive service call") + } + + flags := &RootFlags{Account: "a@b.com"} + ctx := newDocsJSONContext(t) + + markdown := "# Files {#attachments}\n\n[Jump](#attachments)\n" + if err := runKong(t, &DocsWriteCmd{}, []string{"doc1", "--text=" + markdown, "--append", "--markdown"}, ctx, flags); err != nil { + t.Fatalf("markdown append write: %v", err) + } + if len(batchRequests) != 2 { + t.Fatalf("expected insert + link rewrite batches, got %d", len(batchRequests)) + } + insertReqs := batchRequests[0] + if len(insertReqs) == 0 || insertReqs[0].InsertText == nil { + t.Fatalf("expected first batch to insert text, got %#v", insertReqs) + } + if got := insertReqs[0].InsertText; got.Location.Index != 9 || got.Text != "\nFiles\n\nJump\n" { + t.Fatalf("unexpected append insert: %#v", got) + } + rewriteReqs := batchRequests[1] + if len(rewriteReqs) != 1 || rewriteReqs[0].UpdateTextStyle == nil { + t.Fatalf("expected one link rewrite request, got %#v", rewriteReqs) + } + styleReq := rewriteReqs[0].UpdateTextStyle + if styleReq.Range.StartIndex != 17 || styleReq.Range.EndIndex != 21 { + t.Fatalf("unexpected rewrite range: %#v", styleReq.Range) + } + if styleReq.TextStyle == nil || styleReq.TextStyle.Link == nil || styleReq.TextStyle.Link.HeadingId != "h.files" { + t.Fatalf("unexpected link rewrite request: %#v", styleReq) + } +} + func TestDocsWrite_MarkdownAppendStartsStyledBlocksOnFreshParagraph(t *testing.T) { origDocs := newDocsService origDrive := newDriveService diff --git a/internal/cmd/docs_write_update_test.go b/internal/cmd/docs_write_update_test.go index 8025b2e3..3b2b425a 100644 --- a/internal/cmd/docs_write_update_test.go +++ b/internal/cmd/docs_write_update_test.go @@ -191,6 +191,165 @@ func TestDocsUpdate_MarkdownWithTab(t *testing.T) { } } +func TestDocsUpdate_MarkdownRewritesExplicitHeadingAnchorLinks(t *testing.T) { + origDocs := newDocsService + t.Cleanup(func() { newDocsService = origDocs }) + + var batchRequests [][]*docs.Request + gets := 0 + docSvc, cleanup := newDocsServiceForTest(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + path := r.URL.Path + switch { + case r.Method == http.MethodGet && strings.HasPrefix(path, "/v1/documents/"): + gets++ + w.Header().Set("Content-Type", "application/json") + if gets == 1 { + _ = json.NewEncoder(w).Encode(map[string]any{ + "documentId": "doc1", + "body": map[string]any{"content": []any{ + map[string]any{"startIndex": 1, "endIndex": 2}, + }}, + }) + return + } + _ = json.NewEncoder(w).Encode(markdownAnchorRewriteDoc("doc1", "h.files", "#attachments")) + return + case r.Method == http.MethodPost && strings.Contains(path, ":batchUpdate"): + var req docs.BatchUpdateDocumentRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + t.Fatalf("decode request: %v", err) + } + batchRequests = append(batchRequests, req.Requests) + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{"documentId": "doc1"}) + return + default: + http.NotFound(w, r) + return + } + })) + defer cleanup() + newDocsService = func(context.Context, string) (*docs.Service, error) { return docSvc, nil } + + flags := &RootFlags{Account: "a@b.com"} + ctx := newDocsJSONContext(t) + markdown := "# Files {#attachments}\n\n[Jump](#attachments)\n" + if err := runKong(t, &DocsUpdateCmd{}, []string{"doc1", "--text", markdown, "--markdown"}, ctx, flags); err != nil { + t.Fatalf("update markdown: %v", err) + } + + if len(batchRequests) != 2 { + t.Fatalf("expected insert and rewrite batch requests, got %d", len(batchRequests)) + } + insert := batchRequests[0][0].InsertText + if insert == nil || strings.Contains(insert.Text, "{#attachments}") { + t.Fatalf("insert text should strip explicit anchor, got %#v", insert) + } + rewrite := batchRequests[1][0].UpdateTextStyle + if rewrite == nil || rewrite.TextStyle == nil || rewrite.TextStyle.Link == nil || rewrite.TextStyle.Link.HeadingId != "h.files" { + t.Fatalf("expected native heading rewrite to h.files, got %#v", batchRequests[1]) + } +} + +func TestDocsUpdate_ReplaceRangeMarkdownRewritesExplicitHeadingAnchorLinks(t *testing.T) { + origDocs := newDocsService + t.Cleanup(func() { newDocsService = origDocs }) + + var batchRequests [][]*docs.Request + gets := 0 + docSvc, cleanup := newDocsServiceForTest(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + path := r.URL.Path + switch { + case r.Method == http.MethodGet && strings.HasPrefix(path, "/v1/documents/"): + gets++ + w.Header().Set("Content-Type", "application/json") + if gets == 1 { + _ = json.NewEncoder(w).Encode(&docs.Document{ + DocumentId: "doc1", + RevisionId: "rev1", + Body: &docs.Body{Content: []*docs.StructuralElement{{ + StartIndex: 1, + EndIndex: 8, + Paragraph: &docs.Paragraph{Elements: []*docs.ParagraphElement{{ + StartIndex: 1, + EndIndex: 7, + TextRun: &docs.TextRun{Content: "target"}, + }}}, + }}}, + }) + return + } + _ = json.NewEncoder(w).Encode(markdownAnchorRewriteDoc("doc1", "h.files", "#attachments")) + return + case r.Method == http.MethodPost && strings.Contains(path, ":batchUpdate"): + var req docs.BatchUpdateDocumentRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + t.Fatalf("decode request: %v", err) + } + batchRequests = append(batchRequests, req.Requests) + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{"documentId": "doc1"}) + return + default: + http.NotFound(w, r) + return + } + })) + defer cleanup() + newDocsService = func(context.Context, string) (*docs.Service, error) { return docSvc, nil } + + flags := &RootFlags{Account: "a@b.com"} + ctx := newDocsJSONContext(t) + markdown := "# Files {#attachments}\n\n[Jump](#attachments)\n" + if err := runKong(t, &DocsUpdateCmd{}, []string{"doc1", "--text", markdown, "--markdown", "--replace-range", "1:7"}, ctx, flags); err != nil { + t.Fatalf("update replace markdown: %v", err) + } + + if len(batchRequests) != 2 { + t.Fatalf("expected replace and rewrite batch requests, got %d", len(batchRequests)) + } + insert := batchRequests[0][1].InsertText + if insert == nil || strings.Contains(insert.Text, "{#attachments}") { + t.Fatalf("insert text should strip explicit anchor, got %#v", insert) + } + rewrite := batchRequests[1][0].UpdateTextStyle + if rewrite == nil || rewrite.TextStyle == nil || rewrite.TextStyle.Link == nil || rewrite.TextStyle.Link.HeadingId != "h.files" { + t.Fatalf("expected native heading rewrite to h.files, got %#v", batchRequests[1]) + } +} + +func markdownAnchorRewriteDoc(docID, headingID, linkURL string) *docs.Document { + return &docs.Document{ + DocumentId: docID, + Body: &docs.Body{Content: []*docs.StructuralElement{ + { + StartIndex: 1, + EndIndex: 7, + Paragraph: &docs.Paragraph{ + ParagraphStyle: &docs.ParagraphStyle{NamedStyleType: "HEADING_1", HeadingId: headingID}, + Elements: []*docs.ParagraphElement{{ + StartIndex: 1, + EndIndex: 7, + TextRun: &docs.TextRun{Content: "Files\n"}, + }}, + }, + }, + { + StartIndex: 8, + EndIndex: 13, + Paragraph: &docs.Paragraph{Elements: []*docs.ParagraphElement{{ + StartIndex: 8, + EndIndex: 12, + TextRun: &docs.TextRun{ + Content: "Jump", + TextStyle: &docs.TextStyle{Link: &docs.Link{Url: linkURL}}, + }, + }}}, + }, + }}, + } +} + func TestDocsUpdate_ReplaceRangePlainWithTab(t *testing.T) { origDocs := newDocsService t.Cleanup(func() { newDocsService = origDocs })