Skip to content

Commit 478f5b4

Browse files
fix(docs): preserve text-run hyperlink URLs
1 parent e4239f8 commit 478f5b4

3 files changed

Lines changed: 190 additions & 18 deletions

File tree

internal/cmd/docs_commands_test.go

Lines changed: 131 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -386,26 +386,67 @@ func smartChipDocsContent() []any {
386386

387387
func newSmartChipDocsTestServer(t *testing.T) (*docs.Service, func()) {
388388
t.Helper()
389+
return newDocsContentTestServer(t, "Smart chip doc", "Chips", smartChipDocsContent)
390+
}
391+
392+
func linkedTextDocsContent() []any {
393+
return []any{
394+
map[string]any{"paragraph": map[string]any{"elements": []any{
395+
map[string]any{"startIndex": 1, "endIndex": 7, "textRun": map[string]any{"content": "Intro "}},
396+
map[string]any{"startIndex": 7, "endIndex": 21, "textRun": map[string]any{
397+
"content": "Ticket [draft]",
398+
"textStyle": map[string]any{"link": map[string]any{"url": "https://tracker.example.com/a_(draft)"}},
399+
}},
400+
map[string]any{"startIndex": 21, "endIndex": 26, "textRun": map[string]any{"content": " and "}},
401+
map[string]any{"startIndex": 26, "endIndex": 45, "textRun": map[string]any{
402+
"content": "https://example.com",
403+
"textStyle": map[string]any{"link": map[string]any{"url": "https://example.com"}},
404+
}},
405+
map[string]any{"startIndex": 45, "endIndex": 52, "textRun": map[string]any{"content": " owner "}},
406+
map[string]any{"startIndex": 52, "endIndex": 53, "person": map[string]any{
407+
"personProperties": map[string]any{"name": "Sample Person", "email": "sample@example.com"},
408+
}},
409+
map[string]any{"startIndex": 53, "endIndex": 54, "textRun": map[string]any{"content": "\n"}},
410+
}}},
411+
map[string]any{"table": map[string]any{"tableRows": []any{
412+
map[string]any{"tableCells": []any{
413+
map[string]any{"content": []any{map[string]any{"paragraph": map[string]any{"elements": []any{
414+
map[string]any{"startIndex": 55, "endIndex": 65, "textRun": map[string]any{
415+
"content": "Table link",
416+
"textStyle": map[string]any{"link": map[string]any{"url": "https://example.com/table"}},
417+
}},
418+
map[string]any{"startIndex": 65, "endIndex": 66, "textRun": map[string]any{"content": "\n"}},
419+
}}}}},
420+
map[string]any{"content": []any{map[string]any{"paragraph": map[string]any{"elements": []any{
421+
map[string]any{"startIndex": 67, "endIndex": 73, "textRun": map[string]any{"content": "Plain\n"}},
422+
}}}}},
423+
}},
424+
}}},
425+
}
426+
}
427+
428+
func newLinkedTextDocsTestServer(t *testing.T) (*docs.Service, func()) {
429+
t.Helper()
430+
return newDocsContentTestServer(t, "Linked text doc", "Links", linkedTextDocsContent)
431+
}
432+
433+
func newDocsContentTestServer(t *testing.T, title, tabTitle string, content func() []any) (*docs.Service, func()) {
434+
t.Helper()
389435

390436
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
391437
if !strings.HasPrefix(r.URL.Path, "/v1/documents/") || r.Method != http.MethodGet {
392438
http.NotFound(w, r)
393439
return
394440
}
395441

396-
response := map[string]any{
397-
"documentId": "doc1",
398-
"title": "Smart chip doc",
399-
}
442+
response := map[string]any{"documentId": "doc1", "title": title}
400443
if r.URL.Query().Get("includeTabsContent") == "true" {
401444
response["tabs"] = []any{map[string]any{
402-
"tabProperties": map[string]any{"tabId": "tab-1", "title": "Chips", "index": 0},
403-
"documentTab": map[string]any{
404-
"body": map[string]any{"content": smartChipDocsContent()},
405-
},
445+
"tabProperties": map[string]any{"tabId": "tab-1", "title": tabTitle, "index": 0},
446+
"documentTab": map[string]any{"body": map[string]any{"content": content()}},
406447
}}
407448
} else {
408-
response["body"] = map[string]any{"content": smartChipDocsContent()}
449+
response["body"] = map[string]any{"content": content()}
409450
}
410451

411452
w.Header().Set("Content-Type", "application/json")
@@ -724,6 +765,87 @@ func TestDocsCat_SmartChipsAreOptInForTextOutput(t *testing.T) {
724765
}
725766
}
726767

768+
func TestDocsCat_LinkedTextUsesRenderedPathAndJSONSidecar(t *testing.T) {
769+
t.Parallel()
770+
771+
docSvc, cleanup := newLinkedTextDocsTestServer(t)
772+
defer cleanup()
773+
774+
plain := "Intro Ticket [draft] and https://example.com owner \nTable link\n\tPlain\n"
775+
rendered := "Intro [Ticket \\[draft\\]](https://tracker.example.com/a_\\(draft\\)) and https://example.com owner @Sample Person <sample@example.com>\n[Table link](https://example.com/table)\n\tPlain\n"
776+
777+
result := runDocsCatCommand(t, docSvc, []string{"doc1"}, false)
778+
if result.err != nil {
779+
t.Fatalf("cat: %v", result.err)
780+
}
781+
if result.stdout != plain {
782+
t.Fatalf("default text changed\n got: %q\nwant: %q", result.stdout, plain)
783+
}
784+
785+
result = runDocsCatCommand(t, docSvc, []string{"doc1", "--chips"}, false)
786+
if result.err != nil {
787+
t.Fatalf("cat --chips: %v", result.err)
788+
}
789+
if result.stdout != rendered {
790+
t.Fatalf("unexpected rendered linked text\n got: %q\nwant: %q", result.stdout, rendered)
791+
}
792+
793+
result = runDocsCatCommand(t, docSvc, []string{"doc1", "--tab", "Links", "--chips"}, false)
794+
if result.err != nil {
795+
t.Fatalf("cat --tab Links --chips: %v", result.err)
796+
}
797+
if result.stdout != rendered {
798+
t.Fatalf("unexpected tab linked text\n got: %q\nwant: %q", result.stdout, rendered)
799+
}
800+
801+
result = runDocsCatCommand(t, docSvc, []string{"doc1"}, true)
802+
if result.err != nil {
803+
t.Fatalf("cat --json: %v", result.err)
804+
}
805+
var out struct {
806+
Text string `json:"text"`
807+
RenderedText string `json:"renderedText"`
808+
Chips []docsSmartChip `json:"chips"`
809+
Links []docsTextLink `json:"links"`
810+
}
811+
if err := json.Unmarshal([]byte(result.stdout), &out); err != nil {
812+
t.Fatalf("JSON parse: %v\nraw: %q", err, result.stdout)
813+
}
814+
if out.Text != plain || out.RenderedText != rendered {
815+
t.Fatalf("unexpected JSON text: %#v", out)
816+
}
817+
if len(out.Chips) != 1 || out.Chips[0].Type != "person" {
818+
t.Fatalf("unexpected mixed smart chips: %#v", out.Chips)
819+
}
820+
if len(out.Links) != 3 {
821+
t.Fatalf("links length = %d, want 3: %#v", len(out.Links), out.Links)
822+
}
823+
if out.Links[0].Text != "Ticket [draft]" || out.Links[0].URL != "https://tracker.example.com/a_(draft)" || out.Links[0].StartIndex != 7 || out.Links[0].EndIndex != 21 {
824+
t.Fatalf("unexpected first link: %#v", out.Links[0])
825+
}
826+
if out.Links[1].Text != "https://example.com" || out.Links[1].URL != out.Links[1].Text {
827+
t.Fatalf("unexpected visible-URL link: %#v", out.Links[1])
828+
}
829+
if out.Links[2].Text != "Table link" || out.Links[2].URL != "https://example.com/table" {
830+
t.Fatalf("unexpected table-cell link: %#v", out.Links[2])
831+
}
832+
}
833+
834+
func TestDocsCat_LinkedTextRespectsMaxBytesAtomically(t *testing.T) {
835+
t.Parallel()
836+
837+
docSvc, cleanup := newLinkedTextDocsTestServer(t)
838+
defer cleanup()
839+
840+
result := runDocsCatCommand(t, docSvc, []string{"doc1", "--chips", "--max-bytes", "20"}, false)
841+
if result.err != nil {
842+
t.Fatalf("cat --chips --max-bytes: %v", result.err)
843+
}
844+
if got, want := result.stdout, "Intro "; got != want {
845+
t.Fatalf("link markdown should not be partially rendered\n got: %q\nwant: %q", got, want)
846+
}
847+
}
848+
727849
func TestDocsCat_JSONAddsSmartChipDataWithoutChangingText(t *testing.T) {
728850
t.Parallel()
729851

internal/cmd/docs_read.go

Lines changed: 56 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -323,6 +323,7 @@ func docsPlainText(doc *docs.Document, maxBytes int64) string {
323323
type docsTextResult struct {
324324
Text string
325325
Chips []docsSmartChip
326+
Links []docsTextLink
326327
}
327328

328329
type docsSmartChip struct {
@@ -340,31 +341,38 @@ type docsSmartChip struct {
340341
MimeType string `json:"mimeType,omitempty"`
341342
}
342343

343-
func docsRenderedText(doc *docs.Document, maxBytes int64, collectChips bool) docsTextResult {
344+
type docsTextLink struct {
345+
Text string `json:"text"`
346+
URL string `json:"url"`
347+
StartIndex int64 `json:"startIndex,omitempty"`
348+
EndIndex int64 `json:"endIndex,omitempty"`
349+
}
350+
351+
func docsRenderedText(doc *docs.Document, maxBytes int64, collectMetadata bool) docsTextResult {
344352
if doc == nil || doc.Body == nil {
345353
return docsTextResult{}
346354
}
347355

348356
var result docsTextResult
349357
var buf bytes.Buffer
350358
for _, el := range doc.Body.Content {
351-
if !appendDocsElementRenderedText(&buf, maxBytes, el, collectChips, &result.Chips) {
359+
if !appendDocsElementRenderedText(&buf, maxBytes, el, collectMetadata, &result.Chips, &result.Links) {
352360
break
353361
}
354362
}
355363
result.Text = buf.String()
356364
return result
357365
}
358366

359-
func tabRenderedText(tab *docs.Tab, maxBytes int64, collectChips bool) docsTextResult {
367+
func tabRenderedText(tab *docs.Tab, maxBytes int64, collectMetadata bool) docsTextResult {
360368
if tab == nil || tab.DocumentTab == nil || tab.DocumentTab.Body == nil {
361369
return docsTextResult{}
362370
}
363371

364372
var result docsTextResult
365373
var buf bytes.Buffer
366374
for _, el := range tab.DocumentTab.Body.Content {
367-
if !appendDocsElementRenderedText(&buf, maxBytes, el, collectChips, &result.Chips) {
375+
if !appendDocsElementRenderedText(&buf, maxBytes, el, collectMetadata, &result.Chips, &result.Links) {
368376
break
369377
}
370378
}
@@ -414,7 +422,7 @@ func appendDocsElementText(buf *bytes.Buffer, maxBytes int64, el *docs.Structura
414422
return true
415423
}
416424

417-
func appendDocsElementRenderedText(buf *bytes.Buffer, maxBytes int64, el *docs.StructuralElement, collectChips bool, chips *[]docsSmartChip) bool {
425+
func appendDocsElementRenderedText(buf *bytes.Buffer, maxBytes int64, el *docs.StructuralElement, collectMetadata bool, chips *[]docsSmartChip, links *[]docsTextLink) bool {
418426
if el == nil {
419427
return true
420428
}
@@ -423,6 +431,20 @@ func appendDocsElementRenderedText(buf *bytes.Buffer, maxBytes int64, el *docs.S
423431
case el.Paragraph != nil:
424432
for _, p := range el.Paragraph.Elements {
425433
if p.TextRun != nil {
434+
rendered, link, ok := renderDocsTextRunLink(p)
435+
if ok {
436+
if rendered == p.TextRun.Content {
437+
if !appendLimited(buf, maxBytes, rendered) {
438+
return false
439+
}
440+
} else if !appendWholeLimited(buf, maxBytes, rendered) {
441+
return false
442+
}
443+
if collectMetadata {
444+
*links = append(*links, link)
445+
}
446+
continue
447+
}
426448
if !appendLimited(buf, maxBytes, p.TextRun.Content) {
427449
return false
428450
}
@@ -437,7 +459,7 @@ func appendDocsElementRenderedText(buf *bytes.Buffer, maxBytes int64, el *docs.S
437459
if !appendWholeLimited(buf, maxBytes, chip.Text) {
438460
return false
439461
}
440-
if collectChips {
462+
if collectMetadata {
441463
*chips = append(*chips, chip)
442464
}
443465
}
@@ -451,15 +473,15 @@ func appendDocsElementRenderedText(buf *bytes.Buffer, maxBytes int64, el *docs.S
451473
return false
452474
}
453475
for _, content := range cell.Content {
454-
if !appendDocsElementRenderedText(buf, maxBytes, content, collectChips, chips) {
476+
if !appendDocsElementRenderedText(buf, maxBytes, content, collectMetadata, chips, links) {
455477
return false
456478
}
457479
}
458480
}
459481
}
460482
case el.TableOfContents != nil:
461483
for _, content := range el.TableOfContents.Content {
462-
if !appendDocsElementRenderedText(buf, maxBytes, content, collectChips, chips) {
484+
if !appendDocsElementRenderedText(buf, maxBytes, content, collectMetadata, chips, links) {
463485
return false
464486
}
465487
}
@@ -468,6 +490,29 @@ func appendDocsElementRenderedText(buf *bytes.Buffer, maxBytes int64, el *docs.S
468490
return true
469491
}
470492

493+
func renderDocsTextRunLink(p *docs.ParagraphElement) (string, docsTextLink, bool) {
494+
if p == nil || p.TextRun == nil || p.TextRun.TextStyle == nil || p.TextRun.TextStyle.Link == nil {
495+
return "", docsTextLink{}, false
496+
}
497+
498+
text := p.TextRun.Content
499+
url := strings.TrimSpace(p.TextRun.TextStyle.Link.Url)
500+
if text == "" || url == "" {
501+
return "", docsTextLink{}, false
502+
}
503+
504+
rendered := text
505+
if strings.TrimSpace(text) != url {
506+
rendered = fmt.Sprintf("[%s](%s)", escapeMarkdownLinkLabel(text), escapeMarkdownLinkDestination(url))
507+
}
508+
return rendered, docsTextLink{
509+
Text: text,
510+
URL: url,
511+
StartIndex: p.StartIndex,
512+
EndIndex: p.EndIndex,
513+
}, true
514+
}
515+
471516
func renderDocsSmartChip(p *docs.ParagraphElement) (docsSmartChip, bool) {
472517
if p == nil {
473518
return docsSmartChip{}, false
@@ -599,6 +644,9 @@ func docsTextJSON(text string, rendered docsTextResult) map[string]any {
599644
if len(rendered.Chips) > 0 {
600645
m["chips"] = rendered.Chips
601646
}
647+
if len(rendered.Links) > 0 {
648+
m["links"] = rendered.Links
649+
}
602650
return m
603651
}
604652

internal/cmd/docs_sed_brace_structural.go

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,8 @@ func buildPersonChipRequest(email string, index int64) []*docs.Request {
145145
type ChipType int
146146

147147
const (
148+
chipTypePersonName = "person"
149+
148150
ChipTypeUnknown ChipType = iota
149151
ChipTypePerson
150152
ChipTypeDate
@@ -188,7 +190,7 @@ func parseChipURI(uri string) *ChipSpec {
188190
spec := &ChipSpec{Value: value}
189191

190192
switch chipType {
191-
case "person":
193+
case chipTypePersonName:
192194
spec.Type = ChipTypePerson
193195
case strDate:
194196
spec.Type = ChipTypeDate

0 commit comments

Comments
 (0)