diff --git a/core/block/import/markdown/anymark/renderer.go b/core/block/import/markdown/anymark/renderer.go index 1f08fc2863..f4f6028414 100644 --- a/core/block/import/markdown/anymark/renderer.go +++ b/core/block/import/markdown/anymark/renderer.go @@ -251,6 +251,19 @@ func (r *Renderer) renderAutoLink(_ util.BufWriter, source []byte, node ast.Node return ast.WalkContinue, nil } +// extractAnytypeObjectID extracts the objectId from an anytype://object?objectId=... URL. +// Returns empty string if the URL is not an anytype object link. +func extractAnytypeObjectID(raw string) string { + u, err := url.Parse(raw) + if err != nil { + return "" + } + if u.Scheme == "anytype" && u.Host == "object" { + return u.Query().Get("objectId") + } + return "" +} + func IsUrl(raw string) bool { colon := strings.IndexByte(raw, ':') @@ -365,10 +378,17 @@ func (r *Renderer) renderLink(_ util.BufWriter, to := int32(text.UTF16RuneCountString(r.GetText())) + markType := model.BlockContentTextMark_Link + markParam := linkPath + if objectID := extractAnytypeObjectID(linkPath); objectID != "" { + markType = model.BlockContentTextMark_Mention + markParam = objectID + } + r.AddMark(model.BlockContentTextMark{ Range: &model.Range{From: int32(r.GetMarkStart()), To: to}, - Type: model.BlockContentTextMark_Link, - Param: linkPath, + Type: markType, + Param: markParam, }) } return ast.WalkContinue, nil diff --git a/core/block/import/markdown/anymark/renderer_test.go b/core/block/import/markdown/anymark/renderer_test.go index 5652e85dfa..669f9f011b 100644 --- a/core/block/import/markdown/anymark/renderer_test.go +++ b/core/block/import/markdown/anymark/renderer_test.go @@ -223,3 +223,192 @@ type expectedBlock struct { IsImage bool Marks []*model.BlockContentTextMark } + +func TestAnytypeObjectLinks(t *testing.T) { + const objectID = "bafyreiepe46rglxszewcokukqhbbuu2quzby2hue4h6cue3lhdxil2lxii" + const spaceID = "bafyreielnx2xgkomxvfdhg36kgemica7kdokt5r7d3rmdwnndaewvfesdi.7wp5no5ccvt2" + + tests := []struct { + name string + markdown string + expected []expectedBlock + }{ + { + name: "Inline anytype object link becomes mention", + markdown: "See [My Task](anytype://object?objectId=" + objectID + "&spaceId=" + spaceID + ") for details.", + expected: []expectedBlock{ + { + Type: model.BlockContentText_Paragraph, + Text: "See My Task for details.", + Marks: []*model.BlockContentTextMark{ + { + Type: model.BlockContentTextMark_Mention, + Param: objectID, + Range: &model.Range{From: 4, To: 11}, + }, + }, + }, + }, + }, + { + name: "Anytype link without spaceId", + markdown: "Link to [Page](anytype://object?objectId=" + objectID + ") here.", + expected: []expectedBlock{ + { + Type: model.BlockContentText_Paragraph, + Text: "Link to Page here.", + Marks: []*model.BlockContentTextMark{ + { + Type: model.BlockContentTextMark_Mention, + Param: objectID, + Range: &model.Range{From: 8, To: 12}, + }, + }, + }, + }, + }, + { + name: "Regular HTTP link stays as Link mark", + markdown: "Visit [Google](https://google.com) today.", + expected: []expectedBlock{ + { + Type: model.BlockContentText_Paragraph, + Text: "Visit Google today.", + Marks: []*model.BlockContentTextMark{ + { + Type: model.BlockContentTextMark_Link, + Param: "https://google.com", + Range: &model.Range{From: 6, To: 12}, + }, + }, + }, + }, + }, + { + name: "Mixed anytype and HTTP links", + markdown: "See [task](anytype://object?objectId=" + objectID + ") and [docs](https://example.com).", + expected: []expectedBlock{ + { + Type: model.BlockContentText_Paragraph, + Text: "See task and docs.", + Marks: []*model.BlockContentTextMark{ + { + Type: model.BlockContentTextMark_Mention, + Param: objectID, + Range: &model.Range{From: 4, To: 8}, + }, + { + Type: model.BlockContentTextMark_Link, + Param: "https://example.com", + Range: &model.Range{From: 13, To: 17}, + }, + }, + }, + }, + }, + { + name: "Standalone anytype link on its own line", + markdown: "[My Task](anytype://object?objectId=" + objectID + "&spaceId=" + spaceID + ")", + expected: []expectedBlock{ + { + Type: model.BlockContentText_Paragraph, + Text: "My Task", + Marks: []*model.BlockContentTextMark{ + { + Type: model.BlockContentTextMark_Mention, + Param: objectID, + Range: &model.Range{From: 0, To: 7}, + }, + }, + }, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + blocks, _, err := MarkdownToBlocks([]byte(tt.markdown), "", nil) + require.NoError(t, err) + + var textBlocks []*model.Block + for _, block := range blocks { + if block.GetText() != nil { + textBlocks = append(textBlocks, block) + } + } + + require.Equal(t, len(tt.expected), len(textBlocks), "Number of blocks mismatch") + + for i, exp := range tt.expected { + block := textBlocks[i] + require.NotNil(t, block.GetText()) + assert.Equal(t, exp.Text, block.GetText().GetText()) + assert.Equal(t, exp.Type, block.GetText().GetStyle()) + + if len(exp.Marks) > 0 { + marks := block.GetText().GetMarks() + require.NotNil(t, marks) + require.Equal(t, len(exp.Marks), len(marks.GetMarks())) + for j, expectedMark := range exp.Marks { + actualMark := marks.GetMarks()[j] + assert.Equal(t, expectedMark.Type, actualMark.Type, "mark %d type mismatch", j) + assert.Equal(t, expectedMark.Param, actualMark.Param, "mark %d param mismatch", j) + assert.Equal(t, expectedMark.Range.From, actualMark.Range.From, "mark %d range.from mismatch", j) + assert.Equal(t, expectedMark.Range.To, actualMark.Range.To, "mark %d range.to mismatch", j) + } + } + } + }) + } +} + +func TestExtractAnytypeObjectID(t *testing.T) { + tests := []struct { + name string + input string + expected string + }{ + { + name: "Valid anytype object URL", + input: "anytype://object?objectId=bafyrei123&spaceId=space456", + expected: "bafyrei123", + }, + { + name: "Valid anytype object URL without spaceId", + input: "anytype://object?objectId=bafyrei123", + expected: "bafyrei123", + }, + { + name: "HTTP URL returns empty", + input: "https://example.com", + expected: "", + }, + { + name: "Anytype URL with wrong host", + input: "anytype://file?fileId=abc", + expected: "", + }, + { + name: "Anytype object URL without objectId param", + input: "anytype://object?spaceId=space456", + expected: "", + }, + { + name: "Empty string returns empty", + input: "", + expected: "", + }, + { + name: "Local file path returns empty", + input: "my-document.md", + expected: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := extractAnytypeObjectID(tt.input) + assert.Equal(t, tt.expected, result) + }) + } +}