diff --git a/plugin/markdown/extensions/memos_ref_node.go b/plugin/markdown/extensions/memos_ref_node.go new file mode 100644 index 0000000000000..d670efb657173 --- /dev/null +++ b/plugin/markdown/extensions/memos_ref_node.go @@ -0,0 +1,91 @@ +package extensions + +import ( + "bytes" + + "github.com/yuin/goldmark" + gast "github.com/yuin/goldmark/ast" + "github.com/yuin/goldmark/parser" + "github.com/yuin/goldmark/text" + "github.com/yuin/goldmark/util" +) + +type memosRefExtension struct{} + +// MemosRefExtension marks Link nodes whose destination contains a `memos/` segment. +// +// It annotates the existing goldmark `*ast.Link` node with an attribute +var MemosRefExtension = &memosRefExtension{} + +const ( + // AttrMemosRefID is stored on `*ast.Link` when a `memos/` segment is detected. + AttrMemosRefID = "memosRefId" +) + +func (*memosRefExtension) Extend(m goldmark.Markdown) { + m.Parser().AddOptions( + parser.WithASTTransformers( + // Run after the built-in link parser has produced Link nodes. + util.Prioritized(&memosRefLinkMarker{}, 200), + ), + ) +} + +type memosRefLinkMarker struct{} + +func (*memosRefLinkMarker) Transform(doc *gast.Document, _ text.Reader, _ parser.Context) { + _ = gast.Walk(doc, func(n gast.Node, entering bool) (gast.WalkStatus, error) { + if !entering { + return gast.WalkContinue, nil + } + + link, ok := n.(*gast.Link) + if !ok { + return gast.WalkContinue, nil + } + + // Don't overwrite if already set. + if _, found := link.AttributeString(AttrMemosRefID); found { + return gast.WalkContinue, nil + } + + id, ok := extractMemosIDFromDest(link.Destination) + if !ok { + return gast.WalkContinue, nil + } + + // Store as string (detached from the parser buffer). + link.SetAttributeString(AttrMemosRefID, id) + return gast.WalkContinue, nil + }) +} + +func extractMemosIDFromDest(dest []byte) (string, bool) { + const prefix = "memos/" + idx := bytes.Index(dest, []byte(prefix)) + if idx < 0 { + return "", false + } + + start := idx + len(prefix) + if start >= len(dest) { + return "", false + } + + end := start + for end < len(dest) && isValidMemosIDByte(dest[end]) { + end++ + } + if end == start { + return "", false + } + + return string(dest[start:end]), true +} + +func isValidMemosIDByte(b byte) bool { + return (b >= '0' && b <= '9') || + (b >= 'a' && b <= 'z') || + (b >= 'A' && b <= 'Z') || + b == '-' || b == '_' +} diff --git a/plugin/markdown/extensions/memos_ref_node_test.go b/plugin/markdown/extensions/memos_ref_node_test.go new file mode 100644 index 0000000000000..d14a6da255a36 --- /dev/null +++ b/plugin/markdown/extensions/memos_ref_node_test.go @@ -0,0 +1,50 @@ +package extensions + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/yuin/goldmark" + gast "github.com/yuin/goldmark/ast" + "github.com/yuin/goldmark/extension" + "github.com/yuin/goldmark/text" +) + +func TestMemosRefExtensionMarksLinkNodes(t *testing.T) { + md := goldmark.New( + goldmark.WithExtensions( + extension.GFM, + MemosRefExtension, + ), + ) + + src := []byte("[memo](memos/abc-XYZ_9) and [other](https://example.com)") + doc := md.Parser().Parse(text.NewReader(src)) + + var links []*gast.Link + err := gast.Walk(doc, func(n gast.Node, entering bool) (gast.WalkStatus, error) { + if !entering { + return gast.WalkContinue, nil + } + if l, ok := n.(*gast.Link); ok { + links = append(links, l) + } + return gast.WalkContinue, nil + }) + require.NoError(t, err) + require.Len(t, links, 2) + + id0, ok0 := links[0].AttributeString(AttrMemosRefID) + id1, ok1 := links[1].AttributeString(AttrMemosRefID) + + // One of them should be marked as memos ref, the other should not. + if ok0 { + assert.Equal(t, "abc-XYZ_9", id0) + assert.False(t, ok1) + } else { + require.True(t, ok1) + assert.Equal(t, "abc-XYZ_9", id1) + assert.False(t, ok0) + } +} diff --git a/plugin/markdown/markdown.go b/plugin/markdown/markdown.go index e74b7e99a6ff4..e52146a15a6ae 100644 --- a/plugin/markdown/markdown.go +++ b/plugin/markdown/markdown.go @@ -2,6 +2,7 @@ package markdown import ( "bytes" + "fmt" "strings" "github.com/yuin/goldmark" @@ -21,6 +22,10 @@ import ( type ExtractedData struct { Tags []string Property *storepb.MemoPayload_Property + + // MemoRefNames contains related memo resource names in the form `memos/` extracted + // from markdown links. + MemoRefNames []string } // Service handles markdown metadata extraction. @@ -62,7 +67,8 @@ type service struct { type Option func(*config) type config struct { - enableTags bool + enableTags bool + enableMemosRef bool } // WithTagExtension enables #tag parsing. @@ -72,6 +78,13 @@ func WithTagExtension() Option { } } +// WithMemosRefExtension enables memos/ reference parsing. +func WithMemosRefExtension() Option { + return func(c *config) { + c.enableMemosRef = true + } +} + // NewService creates a new markdown service with the given options. func NewService(opts ...Option) Service { cfg := &config{} @@ -88,6 +101,10 @@ func NewService(opts ...Option) Service { exts = append(exts, extensions.TagExtension) } + if cfg.enableMemosRef { + exts = append(exts, extensions.MemosRefExtension) + } + md := goldmark.New( goldmark.WithExtensions(exts...), goldmark.WithParserOptions( @@ -293,10 +310,13 @@ func (s *service) ExtractAll(content []byte) (*ExtractedData, error) { } data := &ExtractedData{ - Tags: []string{}, - Property: &storepb.MemoPayload_Property{}, + Tags: []string{}, + Property: &storepb.MemoPayload_Property{}, + MemoRefNames: []string{}, } + memoRefSeen := map[string]bool{} + // Single walk to collect all data err = gast.Walk(root, func(n gast.Node, entering bool) (gast.WalkStatus, error) { if !entering { @@ -313,6 +333,20 @@ func (s *service) ExtractAll(content []byte) (*ExtractedData, error) { case gast.KindLink: data.Property.HasLink = true + // If this link is an internal memos reference, the memos ref extension will + // have annotated it with an attribute. + if link, ok := n.(*gast.Link); ok { + if v, found := link.AttributeString(extensions.AttrMemosRefID); found { + if id, ok := v.(string); ok && id != "" { + name := fmt.Sprintf("memos/%s", id) + if !memoRefSeen[name] { + memoRefSeen[name] = true + data.MemoRefNames = append(data.MemoRefNames, name) + } + } + } + } + case gast.KindCodeBlock, gast.KindFencedCodeBlock, gast.KindCodeSpan: data.Property.HasCode = true diff --git a/plugin/markdown/markdown_test.go b/plugin/markdown/markdown_test.go index 01d87a7ccd14c..89c9893aec333 100644 --- a/plugin/markdown/markdown_test.go +++ b/plugin/markdown/markdown_test.go @@ -404,6 +404,51 @@ func TestTruncateAtWord(t *testing.T) { } } +func TestExtractAllExtractsMemoRefNames(t *testing.T) { + svc := NewService(WithMemosRefExtension()) + + tests := []struct { + content string + expected []string + }{ + { + content: "See [a](memos/abc)", + expected: []string{"memos/abc"}, + }, + { + content: "See [a](memos/abc) and [b](memos/xyz)", + expected: []string{"memos/abc", "memos/xyz"}, + }, + { + content: "See [a](https://example.com/memos/abc)", + expected: []string{"memos/abc"}, + }, + { + content: "See [a](https://example.com/memos/abc) and [b](https://example.com/memos/xyz)", + expected: []string{"memos/abc", "memos/xyz"}, + }, + { + content: "See \n [a](memos/abc) and \n [b](https://example.com/memos/123) and \n [c](memos/abc) and \n [d](memos/xyz)", + expected: []string{"memos/abc", "memos/xyz", "memos/123"}, + }, + { + content: "See [a](https://example.com/no-memos-link) and [b](https://another.com/memos/456)", + expected: []string{"memos/456"}, + }, + { + content: "See [a](memos/123) and also [b](memos/123)", + expected: []string{"memos/123"}, + }, + } + + for _, tt := range tests { + data, err := svc.ExtractAll([]byte(tt.content)) + require.NoError(t, err) + require.NotNil(t, data) + assert.ElementsMatch(t, tt.expected, data.MemoRefNames) + } +} + // Benchmark tests. func BenchmarkGenerateSnippet(b *testing.B) { svc := NewService() diff --git a/proto/gen/store/memo.pb.go b/proto/gen/store/memo.pb.go index 270821e2d8dc3..004ade86e5986 100644 --- a/proto/gen/store/memo.pb.go +++ b/proto/gen/store/memo.pb.go @@ -22,10 +22,13 @@ const ( ) type MemoPayload struct { - state protoimpl.MessageState `protogen:"open.v1"` - Property *MemoPayload_Property `protobuf:"bytes,1,opt,name=property,proto3" json:"property,omitempty"` - Location *MemoPayload_Location `protobuf:"bytes,2,opt,name=location,proto3" json:"location,omitempty"` - Tags []string `protobuf:"bytes,3,rep,name=tags,proto3" json:"tags,omitempty"` + state protoimpl.MessageState `protogen:"open.v1"` + Property *MemoPayload_Property `protobuf:"bytes,1,opt,name=property,proto3" json:"property,omitempty"` + Location *MemoPayload_Location `protobuf:"bytes,2,opt,name=location,proto3" json:"location,omitempty"` + Tags []string `protobuf:"bytes,3,rep,name=tags,proto3" json:"tags,omitempty"` + // Related memo resource names extracted from markdown links. + // Format: memos/{memo} + MemoRefNames []string `protobuf:"bytes,4,rep,name=memo_ref_names,json=memoRefNames,proto3" json:"memo_ref_names,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -81,6 +84,13 @@ func (x *MemoPayload) GetTags() []string { return nil } +func (x *MemoPayload) GetMemoRefNames() []string { + if x != nil { + return x.MemoRefNames + } + return nil +} + // The calculated properties from the memo content. type MemoPayload_Property struct { state protoimpl.MessageState `protogen:"open.v1"` @@ -214,11 +224,12 @@ var File_store_memo_proto protoreflect.FileDescriptor const file_store_memo_proto_rawDesc = "" + "\n" + - "\x10store/memo.proto\x12\vmemos.store\"\xa0\x03\n" + + "\x10store/memo.proto\x12\vmemos.store\"\xc6\x03\n" + "\vMemoPayload\x12=\n" + "\bproperty\x18\x01 \x01(\v2!.memos.store.MemoPayload.PropertyR\bproperty\x12=\n" + "\blocation\x18\x02 \x01(\v2!.memos.store.MemoPayload.LocationR\blocation\x12\x12\n" + - "\x04tags\x18\x03 \x03(\tR\x04tags\x1a\x96\x01\n" + + "\x04tags\x18\x03 \x03(\tR\x04tags\x12$\n" + + "\x0ememo_ref_names\x18\x04 \x03(\tR\fmemoRefNames\x1a\x96\x01\n" + "\bProperty\x12\x19\n" + "\bhas_link\x18\x01 \x01(\bR\ahasLink\x12\"\n" + "\rhas_task_list\x18\x02 \x01(\bR\vhasTaskList\x12\x19\n" + diff --git a/proto/store/memo.proto b/proto/store/memo.proto index bd50df9219be1..a5c3b08c450db 100644 --- a/proto/store/memo.proto +++ b/proto/store/memo.proto @@ -11,6 +11,10 @@ message MemoPayload { repeated string tags = 3; + // Related memo resource names extracted from markdown links. + // Format: memos/{memo} + repeated string memo_ref_names = 4; + // The calculated properties from the memo content. message Property { bool has_link = 1; diff --git a/server/router/api/v1/memo_service.go b/server/router/api/v1/memo_service.go index f407b009e5fbb..c1c420d7ab517 100644 --- a/server/router/api/v1/memo_service.go +++ b/server/router/api/v1/memo_service.go @@ -62,6 +62,23 @@ func (s *APIV1Service) CreateMemo(ctx context.Context, request *v1pb.CreateMemoR if err := memopayload.RebuildMemoPayload(create, s.MarkdownService); err != nil { return nil, status.Errorf(codes.Internal, "failed to rebuild memo payload: %v", err) } + + // If markdown extraction found memo references, convert them into relations. + if create.Payload != nil { + if refs := create.Payload.GetMemoRefNames(); len(refs) > 0 { + relations := make([]*v1pb.MemoRelation, 0, len(refs)) + for _, name := range refs { + if name == "" { + continue + } + relations = append(relations, &v1pb.MemoRelation{ + RelatedMemo: &v1pb.MemoRelation_Memo{Name: name}, + Type: v1pb.MemoRelation_REFERENCE, + }) + } + request.Memo.Relations = relations + } + } if request.Memo.Location != nil { create.Payload.Location = convertLocationToStore(request.Memo.Location) } diff --git a/server/router/api/v1/test/memo_relation_from_markdown_test.go b/server/router/api/v1/test/memo_relation_from_markdown_test.go new file mode 100644 index 0000000000000..693199f4e22e5 --- /dev/null +++ b/server/router/api/v1/test/memo_relation_from_markdown_test.go @@ -0,0 +1,55 @@ +package test + +import ( + "context" + "testing" + + "github.com/stretchr/testify/require" + + apiv1 "github.com/usememos/memos/proto/gen/api/v1" +) + +func TestCreateMemoAutoCreatesRelationsFromMarkdownMemosLinks(t *testing.T) { + ctx := context.Background() + + ts := NewTestService(t) + defer ts.Cleanup() + + user, err := ts.CreateRegularUser(ctx, "user") + require.NoError(t, err) + userCtx := ts.CreateUserContext(ctx, user.ID) + + // Create the target memo to reference. + target, err := ts.Service.CreateMemo(userCtx, &apiv1.CreateMemoRequest{ + Memo: &apiv1.Memo{ + Content: "Target memo", + Visibility: apiv1.Visibility_PRIVATE, + }, + }) + require.NoError(t, err) + require.NotNil(t, target) + + // Create a memo referencing the target via an internal memos/ link. + source, err := ts.Service.CreateMemo(userCtx, &apiv1.CreateMemoRequest{ + Memo: &apiv1.Memo{ + Content: "See [target](" + target.Name + ")", + Visibility: apiv1.Visibility_PRIVATE, + }, + }) + require.NoError(t, err) + require.NotNil(t, source) + + // Verify the relation exists. + rels, err := ts.Service.ListMemoRelations(userCtx, &apiv1.ListMemoRelationsRequest{Name: source.Name}) + require.NoError(t, err) + require.NotNil(t, rels) + + found := false + for _, r := range rels.Relations { + if r.Type == apiv1.MemoRelation_REFERENCE && r.RelatedMemo != nil && r.RelatedMemo.Name == target.Name { + found = true + break + } + } + require.True(t, found, "expected REFERENCE relation to be created from markdown link") +} diff --git a/server/router/api/v1/test/test_helper.go b/server/router/api/v1/test/test_helper.go index e14fab738dcad..4520f7fef6763 100644 --- a/server/router/api/v1/test/test_helper.go +++ b/server/router/api/v1/test/test_helper.go @@ -40,6 +40,7 @@ func NewTestService(t *testing.T) *TestService { secret := "test-secret" markdownService := markdown.NewService( markdown.WithTagExtension(), + markdown.WithMemosRefExtension(), ) service := &apiv1.APIV1Service{ Secret: secret, diff --git a/server/router/api/v1/v1.go b/server/router/api/v1/v1.go index 834f054fb386c..d9386a5b4ffce 100644 --- a/server/router/api/v1/v1.go +++ b/server/router/api/v1/v1.go @@ -39,6 +39,7 @@ type APIV1Service struct { func NewAPIV1Service(secret string, profile *profile.Profile, store *store.Store) *APIV1Service { markdownService := markdown.NewService( markdown.WithTagExtension(), + markdown.WithMemosRefExtension(), ) return &APIV1Service{ Secret: secret, diff --git a/server/runner/memopayload/runner.go b/server/runner/memopayload/runner.go index 4e262da70605b..7bac4a1699eec 100644 --- a/server/runner/memopayload/runner.go +++ b/server/runner/memopayload/runner.go @@ -84,5 +84,6 @@ func RebuildMemoPayload(memo *store.Memo, markdownService markdown.Service) erro memo.Payload.Tags = data.Tags memo.Payload.Property = data.Property + memo.Payload.MemoRefNames = data.MemoRefNames return nil } diff --git a/web/vite.config.mts b/web/vite.config.mts index 5b63cb382abad..45209a8e3c090 100644 --- a/web/vite.config.mts +++ b/web/vite.config.mts @@ -3,7 +3,8 @@ import { resolve } from "path"; import { defineConfig } from "vite"; import tailwindcss from "@tailwindcss/vite"; -let devProxyServer = "http://localhost:8081"; +let devProxyServer = "http://localhost:8080"; + if (process.env.DEV_PROXY_SERVER && process.env.DEV_PROXY_SERVER.length > 0) { console.log("Use devProxyServer from environment: ", process.env.DEV_PROXY_SERVER); devProxyServer = process.env.DEV_PROXY_SERVER;