diff --git a/renderer/get_link_object_ids_test.go b/renderer/get_link_object_ids_test.go new file mode 100644 index 0000000..8c1d41a --- /dev/null +++ b/renderer/get_link_object_ids_test.go @@ -0,0 +1,550 @@ +package renderer + +import ( + "path/filepath" + "sort" + "testing" + + "github.com/anyproto/anytype-heart/pb" + "github.com/anyproto/anytype-heart/pkg/lib/bundle" + "github.com/anyproto/anytype-heart/pkg/lib/pb/model" + "github.com/anyproto/anytype-heart/util/pbtypes" + "github.com/gogo/protobuf/types" + "github.com/stretchr/testify/assert" +) + +// TestGetLinkObjectIds tests the GetLinkObjectIds function which: +// 1. Only processes blocks with BlockContentOfLink type (skips text, file, etc.) +// 2. Returns target object IDs only for links with model.ObjectType_basic layout +// 3. Includes missing/invalid targets (they default to basic layout) +// 4. Deduplicates results (first occurrence wins) +// 5. Processes blocks in the order they appear in Root.ChildrenIds + +func TestGetLinkObjectIds(t *testing.T) { + t.Run("empty root children returns empty slice", func(t *testing.T) { + // given + rootBlock := &model.Block{ + Id: "root", + ChildrenIds: []string{}, + } + r := NewTestRenderer( + WithBlocksById(map[string]*model.Block{ + "root": rootBlock, + }), + ) + r.Root = rootBlock + + // when + result := r.GetLinkObjectIds() + + // then + assert.Empty(t, result) + }) + + t.Run("non-existent child blocks are skipped", func(t *testing.T) { + // given + rootBlock := &model.Block{ + Id: "root", + ChildrenIds: []string{"missing-block"}, + } + r := NewTestRenderer( + WithBlocksById(map[string]*model.Block{ + "root": rootBlock, + }), + ) + r.Root = rootBlock + + // when + result := r.GetLinkObjectIds() + + // then + assert.Empty(t, result) + }) + + t.Run("nil child blocks are skipped", func(t *testing.T) { + // given + rootBlock := &model.Block{ + Id: "root", + ChildrenIds: []string{"nil-block"}, + } + r := NewTestRenderer( + WithBlocksById(map[string]*model.Block{ + "root": rootBlock, + "nil-block": nil, + }), + ) + r.Root = rootBlock + + // when + result := r.GetLinkObjectIds() + + // then + assert.Empty(t, result) + }) + + t.Run("non-link blocks are properly skipped", func(t *testing.T) { + // given + textBlock := &model.Block{ + Id: "text-block", + Content: &model.BlockContentOfText{ + Text: &model.BlockContentText{ + Text: "some text", + }, + }, + } + rootBlock := &model.Block{ + Id: "root", + ChildrenIds: []string{"text-block"}, + } + r := NewTestRenderer( + WithBlocksById(map[string]*model.Block{ + "root": rootBlock, + "text-block": textBlock, + }), + ) + r.Root = rootBlock + + // when + result := r.GetLinkObjectIds() + + // then + assert.Empty(t, result) + }) + + t.Run("link blocks with no target details are included due to default layout", func(t *testing.T) { + // given + linkBlock := &model.Block{ + Id: "link-block", + Content: &model.BlockContentOfLink{ + Link: &model.BlockContentLink{ + TargetBlockId: "missing-target", + }, + }, + } + rootBlock := &model.Block{ + Id: "root", + ChildrenIds: []string{"link-block"}, + } + r := NewTestRenderer( + WithBlocksById(map[string]*model.Block{ + "root": rootBlock, + "link-block": linkBlock, + }), + ) + r.Root = rootBlock + + // when + result := r.GetLinkObjectIds() + + // then + // Note: Missing targets default to basic layout and are included + assert.Equal(t, []string{"missing-target"}, result) + }) + + t.Run("link blocks with non-basic layout are skipped", func(t *testing.T) { + // given + linkBlock := &model.Block{ + Id: "link-block", + Content: &model.BlockContentOfLink{ + Link: &model.BlockContentLink{ + TargetBlockId: "target-id", + }, + }, + } + rootBlock := &model.Block{ + Id: "root", + ChildrenIds: []string{"link-block"}, + } + r := NewTestRenderer( + WithBlocksById(map[string]*model.Block{ + "root": rootBlock, + "link-block": linkBlock, + }), + WithCachedPbFiles(map[string]*pb.SnapshotWithType{ + filepath.Join("objects", "target-id.pb"): { + SbType: model.SmartBlockType_Page, + Snapshot: &pb.ChangeSnapshot{Data: &model.SmartBlockSnapshotBase{ + Details: &types.Struct{Fields: map[string]*types.Value{ + bundle.RelationKeyId.String(): pbtypes.String("target-id"), + bundle.RelationKeyLayout.String(): pbtypes.Float64(float64(model.ObjectType_collection)), + }}, + }}, + }, + }), + ) + r.Root = rootBlock + + // when + result := r.GetLinkObjectIds() + + // then + assert.Empty(t, result) + }) + + t.Run("single link block with basic layout returns target id", func(t *testing.T) { + // given + linkBlock := &model.Block{ + Id: "link-block", + Content: &model.BlockContentOfLink{ + Link: &model.BlockContentLink{ + TargetBlockId: "target-id", + }, + }, + } + rootBlock := &model.Block{ + Id: "root", + ChildrenIds: []string{"link-block"}, + } + r := NewTestRenderer( + WithBlocksById(map[string]*model.Block{ + "root": rootBlock, + "link-block": linkBlock, + }), + WithCachedPbFiles(map[string]*pb.SnapshotWithType{ + filepath.Join("objects", "target-id.pb"): { + SbType: model.SmartBlockType_Page, + Snapshot: &pb.ChangeSnapshot{Data: &model.SmartBlockSnapshotBase{ + Details: &types.Struct{Fields: map[string]*types.Value{ + bundle.RelationKeyId.String(): pbtypes.String("target-id"), + bundle.RelationKeyLayout.String(): pbtypes.Float64(float64(model.ObjectType_basic)), + }}, + }}, + }, + }), + ) + r.Root = rootBlock + + // when + result := r.GetLinkObjectIds() + + // then + assert.Equal(t, []string{"target-id"}, result) + }) + + t.Run("multiple link blocks with basic layout returns all target ids", func(t *testing.T) { + // given + linkBlock1 := &model.Block{ + Id: "link-block-1", + Content: &model.BlockContentOfLink{ + Link: &model.BlockContentLink{ + TargetBlockId: "target-id-1", + }, + }, + } + linkBlock2 := &model.Block{ + Id: "link-block-2", + Content: &model.BlockContentOfLink{ + Link: &model.BlockContentLink{ + TargetBlockId: "target-id-2", + }, + }, + } + rootBlock := &model.Block{ + Id: "root", + ChildrenIds: []string{"link-block-1", "link-block-2"}, + } + r := NewTestRenderer( + WithBlocksById(map[string]*model.Block{ + "root": rootBlock, + "link-block-1": linkBlock1, + "link-block-2": linkBlock2, + }), + WithCachedPbFiles(map[string]*pb.SnapshotWithType{ + filepath.Join("objects", "target-id-1.pb"): { + SbType: model.SmartBlockType_Page, + Snapshot: &pb.ChangeSnapshot{Data: &model.SmartBlockSnapshotBase{ + Details: &types.Struct{Fields: map[string]*types.Value{ + bundle.RelationKeyId.String(): pbtypes.String("target-id-1"), + bundle.RelationKeyLayout.String(): pbtypes.Float64(float64(model.ObjectType_basic)), + }}, + }}, + }, + filepath.Join("objects", "target-id-2.pb"): { + SbType: model.SmartBlockType_Page, + Snapshot: &pb.ChangeSnapshot{Data: &model.SmartBlockSnapshotBase{ + Details: &types.Struct{Fields: map[string]*types.Value{ + bundle.RelationKeyId.String(): pbtypes.String("target-id-2"), + bundle.RelationKeyLayout.String(): pbtypes.Float64(float64(model.ObjectType_basic)), + }}, + }}, + }, + }), + ) + r.Root = rootBlock + + // when + result := r.GetLinkObjectIds() + sort.Strings(result) + // then + assert.Equal(t, []string{"target-id-1", "target-id-2"}, result) + }) + + t.Run("mixed block types filters correctly", func(t *testing.T) { + // given + linkBlockBasic := &model.Block{ + Id: "link-block-basic", + Content: &model.BlockContentOfLink{ + Link: &model.BlockContentLink{ + TargetBlockId: "basic-target", + }, + }, + } + linkBlockCollection := &model.Block{ + Id: "link-block-collection", + Content: &model.BlockContentOfLink{ + Link: &model.BlockContentLink{ + TargetBlockId: "collection-target", + }, + }, + } + textBlock := &model.Block{ + Id: "text-block", + Content: &model.BlockContentOfText{ + Text: &model.BlockContentText{Text: "text"}, + }, + } + rootBlock := &model.Block{ + Id: "root", + ChildrenIds: []string{"link-block-basic", "link-block-collection", "text-block"}, + } + r := NewTestRenderer( + WithBlocksById(map[string]*model.Block{ + "root": rootBlock, + "link-block-basic": linkBlockBasic, + "link-block-collection": linkBlockCollection, + "text-block": textBlock, + }), + WithCachedPbFiles(map[string]*pb.SnapshotWithType{ + filepath.Join("objects", "basic-target.pb"): { + SbType: model.SmartBlockType_Page, + Snapshot: &pb.ChangeSnapshot{Data: &model.SmartBlockSnapshotBase{ + Details: &types.Struct{Fields: map[string]*types.Value{ + bundle.RelationKeyId.String(): pbtypes.String("basic-target"), + bundle.RelationKeyLayout.String(): pbtypes.Float64(float64(model.ObjectType_basic)), + }}, + }}, + }, + filepath.Join("objects", "collection-target.pb"): { + SbType: model.SmartBlockType_Page, + Snapshot: &pb.ChangeSnapshot{Data: &model.SmartBlockSnapshotBase{ + Details: &types.Struct{Fields: map[string]*types.Value{ + bundle.RelationKeyId.String(): pbtypes.String("collection-target"), + bundle.RelationKeyLayout.String(): pbtypes.Float64(float64(model.ObjectType_collection)), + }}, + }}, + }, + }), + ) + r.Root = rootBlock + + // when + result := r.GetLinkObjectIds() + + // then + // Only link blocks with basic layout are included, text blocks are properly filtered out + assert.Equal(t, []string{"basic-target"}, result) + }) + + t.Run("link block with nil link content is skipped", func(t *testing.T) { + // given + linkBlock := &model.Block{ + Id: "link-block", + Content: &model.BlockContentOfLink{ + Link: nil, + }, + } + rootBlock := &model.Block{ + Id: "root", + ChildrenIds: []string{"link-block"}, + } + r := NewTestRenderer( + WithBlocksById(map[string]*model.Block{ + "root": rootBlock, + "link-block": linkBlock, + }), + ) + r.Root = rootBlock + + // when + result := r.GetLinkObjectIds() + + assert.Len(t, result, 0) + }) + + // TODO: check that page layout is actually "basic" + t.Run("target with no layout field defaults to basic and is included", func(t *testing.T) { + // given + linkBlock := &model.Block{ + Id: "link-block", + Content: &model.BlockContentOfLink{ + Link: &model.BlockContentLink{ + TargetBlockId: "target-id", + }, + }, + } + rootBlock := &model.Block{ + Id: "root", + ChildrenIds: []string{"link-block"}, + } + r := NewTestRenderer( + WithBlocksById(map[string]*model.Block{ + "root": rootBlock, + "link-block": linkBlock, + }), + WithCachedPbFiles(map[string]*pb.SnapshotWithType{ + filepath.Join("objects", "target-id.pb"): { + SbType: model.SmartBlockType_Page, + Snapshot: &pb.ChangeSnapshot{Data: &model.SmartBlockSnapshotBase{ + Details: &types.Struct{Fields: map[string]*types.Value{ + bundle.RelationKeyId.String(): pbtypes.String("target-id"), + }}, + }}, + }, + }), + ) + r.Root = rootBlock + + // when + result := r.GetLinkObjectIds() + + // then + assert.Equal(t, []string{"target-id"}, result) + }) + + t.Run("duplicate target ids are deduplicated", func(t *testing.T) { + // given + linkBlock1 := &model.Block{ + Id: "link-block-1", + Content: &model.BlockContentOfLink{ + Link: &model.BlockContentLink{ + TargetBlockId: "same-target", + }, + }, + } + linkBlock2 := &model.Block{ + Id: "link-block-2", + Content: &model.BlockContentOfLink{ + Link: &model.BlockContentLink{ + TargetBlockId: "same-target", + }, + }, + } + rootBlock := &model.Block{ + Id: "root", + ChildrenIds: []string{"link-block-1", "link-block-2"}, + } + r := NewTestRenderer( + WithBlocksById(map[string]*model.Block{ + "root": rootBlock, + "link-block-1": linkBlock1, + "link-block-2": linkBlock2, + }), + WithCachedPbFiles(map[string]*pb.SnapshotWithType{ + filepath.Join("objects", "same-target.pb"): { + SbType: model.SmartBlockType_Page, + Snapshot: &pb.ChangeSnapshot{Data: &model.SmartBlockSnapshotBase{ + Details: &types.Struct{Fields: map[string]*types.Value{ + bundle.RelationKeyId.String(): pbtypes.String("same-target"), + bundle.RelationKeyLayout.String(): pbtypes.Float64(float64(model.ObjectType_basic)), + }}, + }}, + }, + }), + ) + r.Root = rootBlock + + // when + result := r.GetLinkObjectIds() + + // then + assert.Equal(t, []string{"same-target"}, result) + }) + + t.Run("multiple layouts mixed correctly", func(t *testing.T) { + // given + linkBasic := &model.Block{ + Id: "link-basic", + Content: &model.BlockContentOfLink{ + Link: &model.BlockContentLink{TargetBlockId: "basic-target"}, + }, + } + linkSet := &model.Block{ + Id: "link-set", + Content: &model.BlockContentOfLink{ + Link: &model.BlockContentLink{TargetBlockId: "set-target"}, + }, + } + linkTodo := &model.Block{ + Id: "link-todo", + Content: &model.BlockContentOfLink{ + Link: &model.BlockContentLink{TargetBlockId: "todo-target"}, + }, + } + linkProfile := &model.Block{ + Id: "link-profile", + Content: &model.BlockContentOfLink{ + Link: &model.BlockContentLink{TargetBlockId: "profile-target"}, + }, + } + rootBlock := &model.Block{ + Id: "root", + ChildrenIds: []string{"link-basic", "link-set", "link-todo", "link-profile"}, + } + + r := NewTestRenderer( + WithBlocksById(map[string]*model.Block{ + "root": rootBlock, + "link-basic": linkBasic, + "link-set": linkSet, + "link-todo": linkTodo, + "link-profile": linkProfile, + }), + WithCachedPbFiles(map[string]*pb.SnapshotWithType{ + filepath.Join("objects", "basic-target.pb"): { + SbType: model.SmartBlockType_Page, + Snapshot: &pb.ChangeSnapshot{Data: &model.SmartBlockSnapshotBase{ + Details: &types.Struct{Fields: map[string]*types.Value{ + bundle.RelationKeyId.String(): pbtypes.String("basic-target"), + bundle.RelationKeyLayout.String(): pbtypes.Float64(float64(model.ObjectType_basic)), + }}, + }}, + }, + filepath.Join("objects", "set-target.pb"): { + SbType: model.SmartBlockType_Page, + Snapshot: &pb.ChangeSnapshot{Data: &model.SmartBlockSnapshotBase{ + Details: &types.Struct{Fields: map[string]*types.Value{ + bundle.RelationKeyId.String(): pbtypes.String("set-target"), + bundle.RelationKeyLayout.String(): pbtypes.Float64(float64(model.ObjectType_set)), + }}, + }}, + }, + filepath.Join("objects", "todo-target.pb"): { + SbType: model.SmartBlockType_Page, + Snapshot: &pb.ChangeSnapshot{Data: &model.SmartBlockSnapshotBase{ + Details: &types.Struct{Fields: map[string]*types.Value{ + bundle.RelationKeyId.String(): pbtypes.String("todo-target"), + bundle.RelationKeyLayout.String(): pbtypes.Float64(float64(model.ObjectType_todo)), + }}, + }}, + }, + filepath.Join("objects", "profile-target.pb"): { + SbType: model.SmartBlockType_Page, + Snapshot: &pb.ChangeSnapshot{Data: &model.SmartBlockSnapshotBase{ + Details: &types.Struct{Fields: map[string]*types.Value{ + bundle.RelationKeyId.String(): pbtypes.String("profile-target"), + bundle.RelationKeyLayout.String(): pbtypes.Float64(float64(model.ObjectType_profile)), + }}, + }}, + }, + }), + ) + r.Root = rootBlock + + // when + result := r.GetLinkObjectIds() + + // then + // Only basic layout should be included + assert.Equal(t, []string{"basic-target"}, result) + }) +} diff --git a/renderer/helpers.go b/renderer/helpers.go index 15e4202..5887566 100644 --- a/renderer/helpers.go +++ b/renderer/helpers.go @@ -34,11 +34,7 @@ func (r *Renderer) findTargetDetails(targetObjectId string) *types.Struct { return snapshot.GetSnapshot().GetData().GetDetails() } -type relType interface { - string | bool | int64 | model.ObjectTypeLayout | model.RelationFormat | float64 | *types.ListValue -} - -type relTransformer[V relType] func(*types.Value) V +type relTransformer[V any] func(*types.Value) V func relationToString(field *types.Value) string { return field.GetStringValue() @@ -115,7 +111,7 @@ func relationToList(field *types.Value) *types.ListValue { return null } -func getRelationField[V relType](targetDetails *types.Struct, relationKey domain.RelationKey, tr relTransformer[V]) V { +func getRelationField[V any](targetDetails *types.Struct, relationKey domain.RelationKey, tr relTransformer[V]) V { var null V if f, ok := targetDetails.GetFields()[relationKey.String()]; ok { return tr(f) diff --git a/renderer/link.go b/renderer/link.go index f5c1f8e..07efac9 100644 --- a/renderer/link.go +++ b/renderer/link.go @@ -39,11 +39,17 @@ func (r *Renderer) makeLinkBlockParams(b *model.Block) *BlockParams { objectTypeName, coverTemplate := r.getAdditionalParams(b, targetDetails) linkComponents, cardClasses := r.getLinkComponent(coverTemplate, iconTemplate, cardClasses, name, description, objectTypeName, archiveClass) + var url string + if rewriteUrl, ok := r.urlsRewriteMap[targetObjectId]; ok { + url = rewriteUrl + } else { + url = r.makeAnytypeLink(targetDetails, targetObjectId) + } lp := &LinkRenderParams{ Id: b.GetId(), SidesClasses: strings.Join(sidesClasses, " "), CardClasses: strings.Join(cardClasses, " "), - Url: templ.SafeURL(r.makeAnytypeLink(targetDetails, targetObjectId)), + Url: templ.SafeURL(url), Components: linkComponents, } blockParams.Content = LinkTemplate(lp) diff --git a/renderer/link_test.go b/renderer/link_test.go index 1767f4e..d43def4 100644 --- a/renderer/link_test.go +++ b/renderer/link_test.go @@ -441,6 +441,269 @@ func TestMakeLinkRenderParams(t *testing.T) { }) } +func TestMakeLinkBlockParamsUrlRewrite(t *testing.T) { + t.Run("uses default URL when no rewrite map set", func(t *testing.T) { + // given + r := NewTestRenderer( + WithCachedPbFiles(map[string]*pb.SnapshotWithType{ + filepath.Join("objects", "test-id.pb"): { + SbType: model.SmartBlockType_Page, + Snapshot: &pb.ChangeSnapshot{Data: &model.SmartBlockSnapshotBase{ + Details: &types.Struct{Fields: map[string]*types.Value{ + bundle.RelationKeyId.String(): pbtypes.String("test-id"), + bundle.RelationKeyName.String(): pbtypes.String("Test Page"), + bundle.RelationKeySpaceId.String(): pbtypes.String("space-123"), + }}, + }}, + }, + }), + ) + block := &model.Block{ + Content: &model.BlockContentOfLink{ + Link: &model.BlockContentLink{ + TargetBlockId: "test-id", + }, + }, + } + + // when + actual := r.makeLinkBlockParams(block) + + // then + pathAssertions := []pathAssertion{ + {"a.linkCard.isPage.c1 > attrs[href]", "anytype://object?objectId=test-id&spaceId=space-123"}, + } + assertLinkBlockAndHtmlTag(t, &BlockParams{Classes: []string{"block", "align0", "blockLink", "text"}}, actual, pathAssertions) + }) + + t.Run("uses rewrite URL when mapping exists for target object", func(t *testing.T) { + // given + r := NewTestRenderer( + WithCachedPbFiles(map[string]*pb.SnapshotWithType{ + filepath.Join("objects", "test-id.pb"): { + SbType: model.SmartBlockType_Page, + Snapshot: &pb.ChangeSnapshot{Data: &model.SmartBlockSnapshotBase{ + Details: &types.Struct{Fields: map[string]*types.Value{ + bundle.RelationKeyId.String(): pbtypes.String("test-id"), + bundle.RelationKeyName.String(): pbtypes.String("Test Page"), + bundle.RelationKeySpaceId.String(): pbtypes.String("space-123"), + }}, + }}, + }, + }), + ) + // Set URL rewrite map + r.SetUrlRewriteMap(map[string]string{ + "test-id": "https://custom-domain.com/page/test-id", + }) + + block := &model.Block{ + Content: &model.BlockContentOfLink{ + Link: &model.BlockContentLink{ + TargetBlockId: "test-id", + }, + }, + } + + // when + actual := r.makeLinkBlockParams(block) + + // then + pathAssertions := []pathAssertion{ + {"a.linkCard.isPage.c1 > attrs[href]", "https://custom-domain.com/page/test-id"}, + } + assertLinkBlockAndHtmlTag(t, &BlockParams{Classes: []string{"block", "align0", "blockLink", "text"}}, actual, pathAssertions) + }) + + t.Run("uses default URL when target object not in rewrite map", func(t *testing.T) { + // given + r := NewTestRenderer( + WithCachedPbFiles(map[string]*pb.SnapshotWithType{ + filepath.Join("objects", "test-id.pb"): { + SbType: model.SmartBlockType_Page, + Snapshot: &pb.ChangeSnapshot{Data: &model.SmartBlockSnapshotBase{ + Details: &types.Struct{Fields: map[string]*types.Value{ + bundle.RelationKeyId.String(): pbtypes.String("test-id"), + bundle.RelationKeyName.String(): pbtypes.String("Test Page"), + bundle.RelationKeySpaceId.String(): pbtypes.String("space-123"), + }}, + }}, + }, + }), + ) + // Set URL rewrite map with different object ID + r.SetUrlRewriteMap(map[string]string{ + "other-id": "https://custom-domain.com/page/other-id", + }) + + block := &model.Block{ + Content: &model.BlockContentOfLink{ + Link: &model.BlockContentLink{ + TargetBlockId: "test-id", + }, + }, + } + + // when + actual := r.makeLinkBlockParams(block) + + // then + pathAssertions := []pathAssertion{ + {"a.linkCard.isPage.c1 > attrs[href]", "anytype://object?objectId=test-id&spaceId=space-123"}, + } + assertLinkBlockAndHtmlTag(t, &BlockParams{Classes: []string{"block", "align0", "blockLink", "text"}}, actual, pathAssertions) + }) + + t.Run("handles multiple objects in rewrite map", func(t *testing.T) { + // given + r := NewTestRenderer( + WithCachedPbFiles(map[string]*pb.SnapshotWithType{ + filepath.Join("objects", "page-1.pb"): { + SbType: model.SmartBlockType_Page, + Snapshot: &pb.ChangeSnapshot{Data: &model.SmartBlockSnapshotBase{ + Details: &types.Struct{Fields: map[string]*types.Value{ + bundle.RelationKeyId.String(): pbtypes.String("page-1"), + bundle.RelationKeyName.String(): pbtypes.String("Page 1"), + bundle.RelationKeySpaceId.String(): pbtypes.String("space-123"), + }}, + }}, + }, + filepath.Join("objects", "page-2.pb"): { + SbType: model.SmartBlockType_Page, + Snapshot: &pb.ChangeSnapshot{Data: &model.SmartBlockSnapshotBase{ + Details: &types.Struct{Fields: map[string]*types.Value{ + bundle.RelationKeyId.String(): pbtypes.String("page-2"), + bundle.RelationKeyName.String(): pbtypes.String("Page 2"), + bundle.RelationKeySpaceId.String(): pbtypes.String("space-123"), + }}, + }}, + }, + }), + ) + // Set URL rewrite map with multiple mappings + r.SetUrlRewriteMap(map[string]string{ + "page-1": "https://custom-domain.com/articles/page-1", + "page-2": "https://custom-domain.com/articles/page-2", + }) + + block1 := &model.Block{ + Content: &model.BlockContentOfLink{ + Link: &model.BlockContentLink{ + TargetBlockId: "page-1", + }, + }, + } + block2 := &model.Block{ + Content: &model.BlockContentOfLink{ + Link: &model.BlockContentLink{ + TargetBlockId: "page-2", + }, + }, + } + + // when + actual1 := r.makeLinkBlockParams(block1) + actual2 := r.makeLinkBlockParams(block2) + + // then + pathAssertions1 := []pathAssertion{ + {"a.linkCard.isPage.c1 > attrs[href]", "https://custom-domain.com/articles/page-1"}, + } + pathAssertions2 := []pathAssertion{ + {"a.linkCard.isPage.c1 > attrs[href]", "https://custom-domain.com/articles/page-2"}, + } + assertLinkBlockAndHtmlTag(t, &BlockParams{Classes: []string{"block", "align0", "blockLink", "text"}}, actual1, pathAssertions1) + assertLinkBlockAndHtmlTag(t, &BlockParams{Classes: []string{"block", "align0", "blockLink", "text"}}, actual2, pathAssertions2) + }) + + t.Run("handles empty rewrite URL", func(t *testing.T) { + // given + r := NewTestRenderer( + WithCachedPbFiles(map[string]*pb.SnapshotWithType{ + filepath.Join("objects", "test-id.pb"): { + SbType: model.SmartBlockType_Page, + Snapshot: &pb.ChangeSnapshot{Data: &model.SmartBlockSnapshotBase{ + Details: &types.Struct{Fields: map[string]*types.Value{ + bundle.RelationKeyId.String(): pbtypes.String("test-id"), + bundle.RelationKeyName.String(): pbtypes.String("Test Page"), + bundle.RelationKeySpaceId.String(): pbtypes.String("space-123"), + }}, + }}, + }, + }), + ) + // Set URL rewrite map with empty URL + r.SetUrlRewriteMap(map[string]string{ + "test-id": "", + }) + + block := &model.Block{ + Content: &model.BlockContentOfLink{ + Link: &model.BlockContentLink{ + TargetBlockId: "test-id", + }, + }, + } + + // when + actual := r.makeLinkBlockParams(block) + + // then + pathAssertions := []pathAssertion{ + {"a.linkCard.isPage.c1 > attrs[href]", ""}, + } + assertLinkBlockAndHtmlTag(t, &BlockParams{Classes: []string{"block", "align0", "blockLink", "text"}}, actual, pathAssertions) + }) + + t.Run("rewrite map can be updated", func(t *testing.T) { + // given + r := NewTestRenderer( + WithCachedPbFiles(map[string]*pb.SnapshotWithType{ + filepath.Join("objects", "test-id.pb"): { + SbType: model.SmartBlockType_Page, + Snapshot: &pb.ChangeSnapshot{Data: &model.SmartBlockSnapshotBase{ + Details: &types.Struct{Fields: map[string]*types.Value{ + bundle.RelationKeyId.String(): pbtypes.String("test-id"), + bundle.RelationKeyName.String(): pbtypes.String("Test Page"), + bundle.RelationKeySpaceId.String(): pbtypes.String("space-123"), + }}, + }}, + }, + }), + ) + + block := &model.Block{ + Content: &model.BlockContentOfLink{ + Link: &model.BlockContentLink{ + TargetBlockId: "test-id", + }, + }, + } + + // First set a URL rewrite + r.SetUrlRewriteMap(map[string]string{ + "test-id": "https://first-domain.com/test-id", + }) + actual1 := r.makeLinkBlockParams(block) + + // Then update the URL rewrite + r.SetUrlRewriteMap(map[string]string{ + "test-id": "https://second-domain.com/test-id", + }) + actual2 := r.makeLinkBlockParams(block) + + // then + pathAssertions1 := []pathAssertion{ + {"a.linkCard.isPage.c1 > attrs[href]", "https://first-domain.com/test-id"}, + } + pathAssertions2 := []pathAssertion{ + {"a.linkCard.isPage.c1 > attrs[href]", "https://second-domain.com/test-id"}, + } + assertLinkBlockAndHtmlTag(t, &BlockParams{Classes: []string{"block", "align0", "blockLink", "text"}}, actual1, pathAssertions1) + assertLinkBlockAndHtmlTag(t, &BlockParams{Classes: []string{"block", "align0", "blockLink", "text"}}, actual2, pathAssertions2) + }) +} + func assertLinkBlockAndHtmlTag(t *testing.T, expected, actual *BlockParams, pathAssertions []pathAssertion) { assert.Equal(t, expected.Classes, actual.Classes) assert.Equal(t, expected.ContentClasses, actual.ContentClasses) diff --git a/renderer/renderer.go b/renderer/renderer.go index 4f6064d..4102641 100644 --- a/renderer/renderer.go +++ b/renderer/renderer.go @@ -18,6 +18,7 @@ import ( "github.com/anyproto/anytype-heart/pkg/lib/bundle" "github.com/anyproto/anytype-heart/pkg/lib/logging" "github.com/anyproto/anytype-heart/pkg/lib/pb/model" + "github.com/anyproto/anytype-heart/util/pbtypes" "github.com/gogo/protobuf/jsonpb" "github.com/gogo/protobuf/types" "go.uber.org/zap" @@ -74,6 +75,8 @@ type Renderer struct { ObjectTypeDetails *types.Struct ResolvedLayout model.ObjectTypeLayout LayoutAlign int64 + + urlsRewriteMap map[string]string } func readJsonpbSnapshot(snapshotStr string) (snapshot pb.SnapshotWithType, err error) { @@ -208,13 +211,14 @@ func NewRenderer(config RenderConfig) (r *Renderer, err error) { } r = &Renderer{ - Sp: &snapshot, - UberSp: &uberSnapshot, - CachedPbFiles: make(map[string]*pb.SnapshotWithType), - BlocksById: blocksById, - BlockNumbers: make(map[string]int), - Root: blocks[0], - Config: config, + Sp: &snapshot, + UberSp: &uberSnapshot, + CachedPbFiles: make(map[string]*pb.SnapshotWithType), + BlocksById: blocksById, + BlockNumbers: make(map[string]int), + Root: blocks[0], + Config: config, + urlsRewriteMap: make(map[string]string, 0), } objectType := getRelationField(snapshot.Snapshot.Data.GetDetails(), bundle.RelationKeyType, relationToString) @@ -235,6 +239,55 @@ func NewRenderer(config RenderConfig) (r *Renderer, err error) { return } +func (r *Renderer) GetBacklinks() []string { + return pbtypes.GetStringList(r.Sp.Snapshot.Data.GetDetails(), bundle.RelationKeyBacklinks.String()) +} + +func (r *Renderer) GetLinkObjectIds() (linkObjectIds []string) { + seen := make(map[string]struct{}) + + for _, childID := range r.Root.ChildrenIds { + b, ok := r.BlocksById[childID] + if !ok || b == nil { + continue + } + + switch b.Content.(type) { + case *model.BlockContentOfLink: + targetObjectID := b.GetLink().GetTargetBlockId() + if targetObjectID == "" { + continue + } + targetDetails := r.findTargetDetails(targetObjectID) + layout := getRelationField(targetDetails, bundle.RelationKeyLayout, relationToObjectTypeLayout) + switch layout { + // TODO: basic = page, what else? + case model.ObjectType_basic: + if _, ok := seen[targetObjectID]; !ok { + seen[targetObjectID] = struct{}{} + } + default: + continue + } + default: + continue + } + + } + + linkObjectIds = make([]string, len(seen)) + i := 0 + for objID := range seen { + linkObjectIds[i] = objID + i++ + } + return +} + +func (r *Renderer) SetUrlRewriteMap(urls map[string]string) { + r.urlsRewriteMap = urls +} + // asset resolver parts func (r *Renderer) GetEmojiUrl(code rune) string {