refactor: update markdown parser

- Removed the `nodes` field from the `Memo` interface in `memo_service.ts`.
- Updated the `createBaseMemo` function and the `Memo` message functions to reflect the removal of `nodes`.
- Cleaned up the serialization and deserialization logic accordingly.

chore: remove code-inspector-plugin from Vite configuration

- Deleted the `codeInspectorPlugin` from the Vite configuration in `vite.config.mts`.
- Simplified the plugins array to include only `react` and `tailwindcss`.
This commit is contained in:
Claude 2025-10-26 11:28:40 +08:00
parent bfad0708e2
commit 739fd2cde6
90 changed files with 4432 additions and 10090 deletions

2
go.mod
View File

@ -23,7 +23,7 @@ require (
github.com/spf13/cobra v1.10.1 github.com/spf13/cobra v1.10.1
github.com/spf13/viper v1.20.1 github.com/spf13/viper v1.20.1
github.com/stretchr/testify v1.10.0 github.com/stretchr/testify v1.10.0
github.com/usememos/gomark v0.0.0-20251021153759-00d1ea6c86f0 github.com/yuin/goldmark v1.7.13
golang.org/x/crypto v0.42.0 golang.org/x/crypto v0.42.0
golang.org/x/mod v0.28.0 golang.org/x/mod v0.28.0
golang.org/x/net v0.43.0 golang.org/x/net v0.43.0

4
go.sum
View File

@ -433,8 +433,6 @@ github.com/ugorji/go v1.1.7/go.mod h1:kZn38zHttfInRq0xu/PH0az30d+z6vm202qpg1oXVM
github.com/ugorji/go/codec v1.1.7/go.mod h1:Ax+UKWsSmolVDwsd+7N3ZtXu+yMGCf907BLYF3GoBXY= github.com/ugorji/go/codec v1.1.7/go.mod h1:Ax+UKWsSmolVDwsd+7N3ZtXu+yMGCf907BLYF3GoBXY=
github.com/urfave/cli v1.20.0/go.mod h1:70zkFmudgCuE/ngEzBv17Jvp/497gISqfk5gWijbERA= github.com/urfave/cli v1.20.0/go.mod h1:70zkFmudgCuE/ngEzBv17Jvp/497gISqfk5gWijbERA=
github.com/urfave/cli v1.22.1/go.mod h1:Gos4lmkARVdJ6EkW0WaNv/tZAAMe9V7XWyB60NtXRu0= github.com/urfave/cli v1.22.1/go.mod h1:Gos4lmkARVdJ6EkW0WaNv/tZAAMe9V7XWyB60NtXRu0=
github.com/usememos/gomark v0.0.0-20251021153759-00d1ea6c86f0 h1:hN+LjlPdqd/6OLYWs5mYYwJ6WUQBKBUreCt1Kg8u5jk=
github.com/usememos/gomark v0.0.0-20251021153759-00d1ea6c86f0/go.mod h1:7CZRoYFQyyljzplOTeyODFR26O+wr0BbnpTWVLGfKJA=
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo= github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo=
@ -442,6 +440,8 @@ github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+
github.com/xiang90/probing v0.0.0-20190116061207-43a291ad63a2/go.mod h1:UETIi67q53MR2AWcXfiuqkDkRtnGDLqkBTpCHuJHxtU= github.com/xiang90/probing v0.0.0-20190116061207-43a291ad63a2/go.mod h1:UETIi67q53MR2AWcXfiuqkDkRtnGDLqkBTpCHuJHxtU=
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.7.13 h1:GPddIs617DnBLFFVJFgpo1aBfe/4xcvMc3SB5t/D0pA=
github.com/yuin/goldmark v1.7.13/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
go.etcd.io/bbolt v1.3.3/go.mod h1:IbVyRI1SCnLcuJnV2u8VeU0CEYM7e686BmAb1XKL+uU= go.etcd.io/bbolt v1.3.3/go.mod h1:IbVyRI1SCnLcuJnV2u8VeU0CEYM7e686BmAb1XKL+uU=
go.etcd.io/etcd v0.0.0-20191023171146-3cf2f69b5738/go.mod h1:dnLIgRNXwCJa5e+c6mIZCrds/GIG4ncV9HhK5PX7jPg= go.etcd.io/etcd v0.0.0-20191023171146-3cf2f69b5738/go.mod h1:dnLIgRNXwCJa5e+c6mIZCrds/GIG4ncV9HhK5PX7jPg=
go.opencensus.io v0.20.1/go.mod h1:6WKK9ahsWS3RSO+PY9ZHZUfv2irvY6gN279GOPZjmmk= go.opencensus.io v0.20.1/go.mod h1:6WKK9ahsWS3RSO+PY9ZHZUfv2irvY6gN279GOPZjmmk=

View File

@ -0,0 +1,28 @@
package ast
import (
gast "github.com/yuin/goldmark/ast"
)
// TagNode represents a #tag in the markdown AST.
type TagNode struct {
gast.BaseInline
// Tag name without the # prefix
Tag []byte
}
// KindTag is the NodeKind for TagNode.
var KindTag = gast.NewNodeKind("Tag")
// Kind returns KindTag.
func (*TagNode) Kind() gast.NodeKind {
return KindTag
}
// Dump implements Node.Dump for debugging.
func (n *TagNode) Dump(source []byte, level int) {
gast.DumpHelper(n, source, level, map[string]string{
"Tag": string(n.Tag),
}, nil)
}

View File

@ -0,0 +1,32 @@
package ast
import (
gast "github.com/yuin/goldmark/ast"
)
// WikilinkNode represents [[target]] or [[target?params]] syntax.
type WikilinkNode struct {
gast.BaseInline
// Target is the link destination (e.g., "memos/1", "Hello world", "resources/101")
Target []byte
// Params are optional parameters (e.g., "align=center" from [[target?align=center]])
Params []byte
}
// KindWikilink is the NodeKind for WikilinkNode.
var KindWikilink = gast.NewNodeKind("Wikilink")
// Kind returns KindWikilink.
func (*WikilinkNode) Kind() gast.NodeKind {
return KindWikilink
}
// Dump implements Node.Dump for debugging.
func (n *WikilinkNode) Dump(source []byte, level int) {
gast.DumpHelper(n, source, level, map[string]string{
"Target": string(n.Target),
"Params": string(n.Params),
}, nil)
}

View File

@ -0,0 +1,24 @@
package extensions
import (
"github.com/yuin/goldmark"
"github.com/yuin/goldmark/parser"
"github.com/yuin/goldmark/util"
mparser "github.com/usememos/memos/plugin/markdown/parser"
)
type tagExtension struct{}
// TagExtension is a goldmark extension for #tag syntax
var TagExtension = &tagExtension{}
// Extend extends the goldmark parser with tag support.
func (*tagExtension) Extend(m goldmark.Markdown) {
m.Parser().AddOptions(
parser.WithInlineParsers(
// Priority 200 - run before standard link parser (500)
util.Prioritized(mparser.NewTagParser(), 200),
),
)
}

View File

@ -0,0 +1,24 @@
package extensions
import (
"github.com/yuin/goldmark"
"github.com/yuin/goldmark/parser"
"github.com/yuin/goldmark/util"
mparser "github.com/usememos/memos/plugin/markdown/parser"
)
type wikilinkExtension struct{}
// WikilinkExtension is a goldmark extension for [[...]] wikilink syntax
var WikilinkExtension = &wikilinkExtension{}
// Extend extends the goldmark parser with wikilink support.
func (*wikilinkExtension) Extend(m goldmark.Markdown) {
m.Parser().AddOptions(
parser.WithInlineParsers(
// Priority 199 - run before standard link parser (500) but after tags (200)
util.Prioritized(mparser.NewWikilinkParser(), 199),
),
)
}

460
plugin/markdown/markdown.go Normal file
View File

@ -0,0 +1,460 @@
package markdown
import (
"bytes"
"strings"
"github.com/yuin/goldmark"
gast "github.com/yuin/goldmark/ast"
"github.com/yuin/goldmark/extension"
east "github.com/yuin/goldmark/extension/ast"
"github.com/yuin/goldmark/parser"
"github.com/yuin/goldmark/text"
mast "github.com/usememos/memos/plugin/markdown/ast"
"github.com/usememos/memos/plugin/markdown/extensions"
"github.com/usememos/memos/plugin/markdown/renderer"
storepb "github.com/usememos/memos/proto/gen/store"
)
// ExtractedData contains all metadata extracted from markdown in a single pass
type ExtractedData struct {
Tags []string
Property *storepb.MemoPayload_Property
References []string
}
// Service handles markdown metadata extraction.
// It uses goldmark to parse markdown and extract tags, properties, and snippets.
// HTML rendering is primarily done on frontend using markdown-it, but backend provides
// RenderHTML for RSS feeds and other server-side rendering needs.
type Service interface {
// ExtractAll extracts tags, properties, and references in a single parse (most efficient)
ExtractAll(content []byte) (*ExtractedData, error)
// ExtractTags returns all #tags found in content
ExtractTags(content []byte) ([]string, error)
// ExtractProperties computes boolean properties
ExtractProperties(content []byte) (*storepb.MemoPayload_Property, error)
// ExtractReferences returns all wikilink references ([[...]]) found in content
ExtractReferences(content []byte) ([]string, error)
// RenderMarkdown renders goldmark AST back to markdown text
RenderMarkdown(content []byte) (string, error)
// RenderHTML renders markdown content to HTML
RenderHTML(content []byte) (string, error)
// GenerateSnippet creates plain text summary
GenerateSnippet(content []byte, maxLength int) (string, error)
// ValidateContent checks for syntax errors
ValidateContent(content []byte) error
// RenameTag renames all occurrences of oldTag to newTag in content
RenameTag(content []byte, oldTag, newTag string) (string, error)
}
// service implements the Service interface
type service struct {
md goldmark.Markdown
}
// Option configures the markdown service
type Option func(*config)
type config struct {
enableTags bool
enableWikilink bool
}
// WithTagExtension enables #tag parsing
func WithTagExtension() Option {
return func(c *config) {
c.enableTags = true
}
}
// WithWikilinkExtension enables [[wikilink]] parsing
func WithWikilinkExtension() Option {
return func(c *config) {
c.enableWikilink = true
}
}
// NewService creates a new markdown service with the given options
func NewService(opts ...Option) Service {
cfg := &config{}
for _, opt := range opts {
opt(cfg)
}
exts := []goldmark.Extender{
extension.GFM, // GitHub Flavored Markdown (tables, strikethrough, task lists, autolinks)
}
// Add custom extensions based on config
if cfg.enableTags {
exts = append(exts, extensions.TagExtension)
}
if cfg.enableWikilink {
exts = append(exts, extensions.WikilinkExtension)
}
md := goldmark.New(
goldmark.WithExtensions(exts...),
goldmark.WithParserOptions(
parser.WithAutoHeadingID(), // Generate heading IDs
),
)
return &service{
md: md,
}
}
// parse is an internal helper to parse content into AST
func (s *service) parse(content []byte) (gast.Node, error) {
reader := text.NewReader(content)
doc := s.md.Parser().Parse(reader)
return doc, nil
}
// ExtractTags returns all #tags found in content
func (s *service) ExtractTags(content []byte) ([]string, error) {
root, err := s.parse(content)
if err != nil {
return nil, err
}
var tags []string
// Walk the AST to find tag nodes
err = gast.Walk(root, func(n gast.Node, entering bool) (gast.WalkStatus, error) {
if !entering {
return gast.WalkContinue, nil
}
// Check for custom TagNode
if tagNode, ok := n.(*mast.TagNode); ok {
tags = append(tags, string(tagNode.Tag))
}
return gast.WalkContinue, nil
})
if err != nil {
return nil, err
}
// Deduplicate and normalize tags
return uniqueLowercase(tags), nil
}
// ExtractProperties computes boolean properties about the content
func (s *service) ExtractProperties(content []byte) (*storepb.MemoPayload_Property, error) {
root, err := s.parse(content)
if err != nil {
return nil, err
}
prop := &storepb.MemoPayload_Property{}
err = gast.Walk(root, func(n gast.Node, entering bool) (gast.WalkStatus, error) {
if !entering {
return gast.WalkContinue, nil
}
switch n.Kind() {
case gast.KindLink, mast.KindWikilink:
prop.HasLink = true
case mast.KindWikilink:
prop.HasLink = true
case gast.KindCodeBlock, gast.KindFencedCodeBlock, gast.KindCodeSpan:
prop.HasCode = true
case gast.KindCodeSpan:
prop.HasCode = true
case east.KindTaskCheckBox:
prop.HasTaskList = true
if checkBox, ok := n.(*east.TaskCheckBox); ok {
if !checkBox.IsChecked {
prop.HasIncompleteTasks = true
}
}
}
return gast.WalkContinue, nil
})
if err != nil {
return nil, err
}
return prop, nil
}
// ExtractReferences returns all wikilink references found in content
func (s *service) ExtractReferences(content []byte) ([]string, error) {
root, err := s.parse(content)
if err != nil {
return nil, err
}
references := []string{} // Initialize to empty slice, not nil
// Walk the AST to find wikilink nodes
err = gast.Walk(root, func(n gast.Node, entering bool) (gast.WalkStatus, error) {
if !entering {
return gast.WalkContinue, nil
}
// Check for custom WikilinkNode
if wikilinkNode, ok := n.(*mast.WikilinkNode); ok {
references = append(references, string(wikilinkNode.Target))
}
return gast.WalkContinue, nil
})
if err != nil {
return nil, err
}
return references, nil
}
// RenderMarkdown renders goldmark AST back to markdown text
func (s *service) RenderMarkdown(content []byte) (string, error) {
root, err := s.parse(content)
if err != nil {
return "", err
}
mdRenderer := renderer.NewMarkdownRenderer()
return mdRenderer.Render(root, content), nil
}
// RenderHTML renders markdown content to HTML using goldmark's built-in HTML renderer
func (s *service) RenderHTML(content []byte) (string, error) {
var buf bytes.Buffer
if err := s.md.Convert(content, &buf); err != nil {
return "", err
}
return buf.String(), nil
}
// GenerateSnippet creates a plain text summary from markdown content
func (s *service) GenerateSnippet(content []byte, maxLength int) (string, error) {
root, err := s.parse(content)
if err != nil {
return "", err
}
var buf strings.Builder
var lastNodeWasBlock bool
err = gast.Walk(root, func(n gast.Node, entering bool) (gast.WalkStatus, error) {
if entering {
// Skip code blocks and code spans entirely
switch n.Kind() {
case gast.KindCodeBlock, gast.KindFencedCodeBlock, gast.KindCodeSpan:
return gast.WalkSkipChildren, nil
}
// Add space before block elements (except first)
switch n.Kind() {
case gast.KindParagraph, gast.KindHeading, gast.KindListItem:
if buf.Len() > 0 && lastNodeWasBlock {
buf.WriteByte(' ')
}
}
}
if !entering {
// Mark that we just exited a block element
switch n.Kind() {
case gast.KindParagraph, gast.KindHeading, gast.KindListItem:
lastNodeWasBlock = true
}
return gast.WalkContinue, nil
}
lastNodeWasBlock = false
// Only extract plain text nodes
if textNode, ok := n.(*gast.Text); ok {
segment := textNode.Segment
buf.Write(segment.Value(content))
// Add space if this is a soft line break
if textNode.SoftLineBreak() {
buf.WriteByte(' ')
}
}
// Stop walking if we've exceeded double the max length
// (we'll truncate precisely later)
if buf.Len() > maxLength*2 {
return gast.WalkStop, nil
}
return gast.WalkContinue, nil
})
if err != nil {
return "", err
}
snippet := buf.String()
// Truncate at word boundary if needed
if len(snippet) > maxLength {
snippet = truncateAtWord(snippet, maxLength)
}
return strings.TrimSpace(snippet), nil
}
// ValidateContent checks if the markdown content is valid
func (s *service) ValidateContent(content []byte) error {
// Try to parse the content
_, err := s.parse(content)
return err
}
// ExtractAll extracts tags, properties, and references in a single parse for efficiency
func (s *service) ExtractAll(content []byte) (*ExtractedData, error) {
root, err := s.parse(content)
if err != nil {
return nil, err
}
data := &ExtractedData{
Tags: []string{},
Property: &storepb.MemoPayload_Property{},
References: []string{},
}
// Single walk to collect all data
err = gast.Walk(root, func(n gast.Node, entering bool) (gast.WalkStatus, error) {
if !entering {
return gast.WalkContinue, nil
}
// Extract tags
if tagNode, ok := n.(*mast.TagNode); ok {
data.Tags = append(data.Tags, string(tagNode.Tag))
}
// Extract references (wikilinks)
if wikilinkNode, ok := n.(*mast.WikilinkNode); ok {
data.References = append(data.References, string(wikilinkNode.Target))
}
// Extract properties based on node kind
switch n.Kind() {
case gast.KindLink, mast.KindWikilink:
data.Property.HasLink = true
case mast.KindWikilink:
data.Property.HasLink = true
case gast.KindCodeBlock, gast.KindFencedCodeBlock, gast.KindCodeSpan:
data.Property.HasCode = true
case gast.KindCodeSpan:
data.Property.HasCode = true
case east.KindTaskCheckBox:
data.Property.HasTaskList = true
if checkBox, ok := n.(*east.TaskCheckBox); ok {
if !checkBox.IsChecked {
data.Property.HasIncompleteTasks = true
}
}
}
return gast.WalkContinue, nil
})
if err != nil {
return nil, err
}
// Deduplicate and normalize tags
data.Tags = uniqueLowercase(data.Tags)
return data, nil
}
// RenameTag renames all occurrences of oldTag to newTag in content
func (s *service) RenameTag(content []byte, oldTag, newTag string) (string, error) {
root, err := s.parse(content)
if err != nil {
return "", err
}
// Walk the AST to find and rename tag nodes
err = gast.Walk(root, func(n gast.Node, entering bool) (gast.WalkStatus, error) {
if !entering {
return gast.WalkContinue, nil
}
// Check for custom TagNode and rename if it matches
if tagNode, ok := n.(*mast.TagNode); ok {
if string(tagNode.Tag) == oldTag {
tagNode.Tag = []byte(newTag)
}
}
return gast.WalkContinue, nil
})
if err != nil {
return "", err
}
// Render back to markdown using the already-parsed AST
mdRenderer := renderer.NewMarkdownRenderer()
return mdRenderer.Render(root, content), nil
}
// uniqueLowercase returns unique lowercase strings from input
func uniqueLowercase(strs []string) []string {
seen := make(map[string]bool)
var result []string
for _, s := range strs {
lower := strings.ToLower(s)
if !seen[lower] {
seen[lower] = true
result = append(result, lower)
}
}
return result
}
// truncateAtWord truncates a string at the last word boundary before maxLength
func truncateAtWord(s string, maxLength int) string {
if len(s) <= maxLength {
return s
}
// Truncate to max length
truncated := s[:maxLength]
// Find last space
lastSpace := strings.LastIndexAny(truncated, " \t\n\r")
if lastSpace > 0 {
truncated = truncated[:lastSpace]
}
return truncated + " ..."
}

View File

@ -0,0 +1,490 @@
package markdown
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestNewService(t *testing.T) {
svc := NewService()
assert.NotNil(t, svc)
}
func TestValidateContent(t *testing.T) {
svc := NewService()
tests := []struct {
name string
content string
wantErr bool
}{
{
name: "valid markdown",
content: "# Hello\n\nThis is **bold** text.",
wantErr: false,
},
{
name: "empty content",
content: "",
wantErr: false,
},
{
name: "complex markdown",
content: "# Title\n\n- List item 1\n- List item 2\n\n```go\ncode block\n```",
wantErr: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := svc.ValidateContent([]byte(tt.content))
if tt.wantErr {
assert.Error(t, err)
} else {
assert.NoError(t, err)
}
})
}
}
func TestGenerateSnippet(t *testing.T) {
svc := NewService()
tests := []struct {
name string
content string
maxLength int
expected string
}{
{
name: "simple text",
content: "Hello world",
maxLength: 100,
expected: "Hello world",
},
{
name: "text with formatting",
content: "This is **bold** and *italic* text.",
maxLength: 100,
expected: "This is bold and italic text.",
},
{
name: "truncate long text",
content: "This is a very long piece of text that should be truncated at a word boundary.",
maxLength: 30,
expected: "This is a very long piece of ...",
},
{
name: "heading and paragraph",
content: "# My Title\n\nThis is the first paragraph.",
maxLength: 100,
expected: "My Title This is the first paragraph.",
},
{
name: "code block removed",
content: "Text before\n\n```go\ncode\n```\n\nText after",
maxLength: 100,
expected: "Text before Text after",
},
{
name: "list items",
content: "- Item 1\n- Item 2\n- Item 3",
maxLength: 100,
expected: "Item 1 Item 2 Item 3",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
snippet, err := svc.GenerateSnippet([]byte(tt.content), tt.maxLength)
require.NoError(t, err)
assert.Equal(t, tt.expected, snippet)
})
}
}
func TestExtractProperties(t *testing.T) {
tests := []struct {
name string
content string
withExt bool
hasLink bool
hasCode bool
hasTasks bool
hasInc bool
}{
{
name: "plain text",
content: "Just plain text",
withExt: false,
hasLink: false,
hasCode: false,
hasTasks: false,
hasInc: false,
},
{
name: "with link",
content: "Check out [this link](https://example.com)",
withExt: false,
hasLink: true,
hasCode: false,
hasTasks: false,
hasInc: false,
},
{
name: "with inline code",
content: "Use `console.log()` to debug",
withExt: false,
hasLink: false,
hasCode: true,
hasTasks: false,
hasInc: false,
},
{
name: "with code block",
content: "```go\nfunc main() {}\n```",
withExt: false,
hasLink: false,
hasCode: true,
hasTasks: false,
hasInc: false,
},
{
name: "with completed task",
content: "- [x] Completed task",
withExt: false,
hasLink: false,
hasCode: false,
hasTasks: true,
hasInc: false,
},
{
name: "with incomplete task",
content: "- [ ] Todo item",
withExt: false,
hasLink: false,
hasCode: false,
hasTasks: true,
hasInc: true,
},
{
name: "mixed tasks",
content: "- [x] Done\n- [ ] Not done",
withExt: false,
hasLink: false,
hasCode: false,
hasTasks: true,
hasInc: true,
},
{
name: "with referenced content",
content: "See [[memos/1]] for details",
withExt: true,
hasLink: true,
hasCode: false,
hasTasks: false,
hasInc: false,
},
{
name: "everything",
content: "# Title\n\n[Link](url)\n\n`code`\n\n- [ ] Task",
withExt: false,
hasLink: true,
hasCode: true,
hasTasks: true,
hasInc: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var svc Service
if tt.withExt {
svc = NewService(WithWikilinkExtension())
} else {
svc = NewService()
}
props, err := svc.ExtractProperties([]byte(tt.content))
require.NoError(t, err)
assert.Equal(t, tt.hasLink, props.HasLink, "HasLink")
assert.Equal(t, tt.hasCode, props.HasCode, "HasCode")
assert.Equal(t, tt.hasTasks, props.HasTaskList, "HasTaskList")
assert.Equal(t, tt.hasInc, props.HasIncompleteTasks, "HasIncompleteTasks")
})
}
}
func TestExtractTags(t *testing.T) {
tests := []struct {
name string
content string
withExt bool
expected []string
}{
{
name: "no tags",
content: "Just plain text",
withExt: false,
expected: []string{},
},
{
name: "single tag",
content: "Text with #tag",
withExt: true,
expected: []string{"tag"},
},
{
name: "multiple tags",
content: "Text with #tag1 and #tag2",
withExt: true,
expected: []string{"tag1", "tag2"},
},
{
name: "duplicate tags",
content: "#work is important. #Work #WORK",
withExt: true,
expected: []string{"work"}, // Deduplicated and lowercased
},
{
name: "tags with hyphens and underscores",
content: "Tags: #work-notes #2024_plans",
withExt: true,
expected: []string{"work-notes", "2024_plans"},
},
{
name: "tags at end of sentence",
content: "This is important #urgent.",
withExt: true,
expected: []string{"urgent"},
},
{
name: "headings not tags",
content: "## Heading\n\n# Title\n\nText with #realtag",
withExt: true,
expected: []string{"realtag"},
},
{
name: "numeric tag",
content: "Issue #123",
withExt: true,
expected: []string{"123"},
},
{
name: "tag in list",
content: "- Item 1 #todo\n- Item 2 #done",
withExt: true,
expected: []string{"todo", "done"},
},
{
name: "no extension enabled",
content: "Text with #tag",
withExt: false,
expected: []string{},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var svc Service
if tt.withExt {
svc = NewService(WithTagExtension())
} else {
svc = NewService()
}
tags, err := svc.ExtractTags([]byte(tt.content))
require.NoError(t, err)
assert.ElementsMatch(t, tt.expected, tags)
})
}
}
func TestExtractReferences(t *testing.T) {
tests := []struct {
name string
content string
withExt bool
expected []string
}{
{
name: "no references",
content: "Just plain text",
withExt: false,
expected: []string{},
},
{
name: "single wikilink",
content: "Check this: [[resources/101]]",
withExt: true,
expected: []string{"resources/101"},
},
{
name: "multiple wikilinks",
content: "[[resources/101]]\n\nAnd also: [[memos/42]]",
withExt: true,
expected: []string{"resources/101", "memos/42"},
},
{
name: "wikilink with params",
content: "[[resources/101?align=center]]",
withExt: true,
expected: []string{"resources/101"},
},
{
name: "duplicate wikilinks",
content: "[[resources/101]]\n\n[[resources/101]]",
withExt: true,
expected: []string{"resources/101", "resources/101"}, // Not deduplicated at this layer
},
{
name: "no extension enabled",
content: "[[resources/101]]",
withExt: false,
expected: []string{},
},
{
name: "wikilink in sentence",
content: "Check [[memos/1]] for details",
withExt: true,
expected: []string{"memos/1"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var svc Service
if tt.withExt {
svc = NewService(WithWikilinkExtension())
} else {
svc = NewService()
}
references, err := svc.ExtractReferences([]byte(tt.content))
require.NoError(t, err)
assert.Equal(t, tt.expected, references)
})
}
}
func TestUniqueLowercase(t *testing.T) {
tests := []struct {
name string
input []string
expected []string
}{
{
name: "empty",
input: []string{},
expected: []string{},
},
{
name: "unique items",
input: []string{"tag1", "tag2", "tag3"},
expected: []string{"tag1", "tag2", "tag3"},
},
{
name: "duplicates",
input: []string{"tag", "TAG", "Tag"},
expected: []string{"tag"},
},
{
name: "mixed",
input: []string{"Work", "work", "Important", "work"},
expected: []string{"work", "important"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := uniqueLowercase(tt.input)
assert.ElementsMatch(t, tt.expected, result)
})
}
}
func TestTruncateAtWord(t *testing.T) {
tests := []struct {
name string
input string
maxLength int
expected string
}{
{
name: "no truncation needed",
input: "short",
maxLength: 10,
expected: "short",
},
{
name: "exact length",
input: "exactly ten",
maxLength: 11,
expected: "exactly ten",
},
{
name: "truncate at word",
input: "this is a long sentence",
maxLength: 10,
expected: "this is a ...",
},
{
name: "truncate very long word",
input: "supercalifragilisticexpialidocious",
maxLength: 10,
expected: "supercalif ...",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := truncateAtWord(tt.input, tt.maxLength)
assert.Equal(t, tt.expected, result)
})
}
}
// Benchmark tests
func BenchmarkGenerateSnippet(b *testing.B) {
svc := NewService()
content := []byte(`# Large Document
This is a large document with multiple paragraphs and formatting.
## Section 1
Here is some **bold** text and *italic* text with [links](https://example.com).
- List item 1
- List item 2
- List item 3
## Section 2
More content here with ` + "`inline code`" + ` and other elements.
` + "```go\nfunc example() {\n return true\n}\n```")
b.ResetTimer()
for i := 0; i < b.N; i++ {
_, err := svc.GenerateSnippet(content, 200)
if err != nil {
b.Fatal(err)
}
}
}
func BenchmarkExtractProperties(b *testing.B) {
svc := NewService()
content := []byte("# Title\n\n[Link](url)\n\n`code`\n\n- [ ] Task\n- [x] Done")
b.ResetTimer()
for i := 0; i < b.N; i++ {
_, err := svc.ExtractProperties(content)
if err != nil {
b.Fatal(err)
}
}
}

View File

@ -0,0 +1,86 @@
package parser
import (
gast "github.com/yuin/goldmark/ast"
"github.com/yuin/goldmark/parser"
"github.com/yuin/goldmark/text"
mast "github.com/usememos/memos/plugin/markdown/ast"
)
type tagParser struct{}
// NewTagParser creates a new inline parser for #tag syntax
func NewTagParser() parser.InlineParser {
return &tagParser{}
}
// Trigger returns the characters that trigger this parser.
func (*tagParser) Trigger() []byte {
return []byte{'#'}
}
// Parse parses #tag syntax
func (p *tagParser) Parse(parent gast.Node, block text.Reader, pc parser.Context) gast.Node {
line, _ := block.PeekLine()
// Must start with #
if len(line) == 0 || line[0] != '#' {
return nil
}
// Check if it's a heading (## or space after #)
if len(line) > 1 {
if line[1] == '#' {
// It's a heading (##), not a tag
return nil
}
if line[1] == ' ' {
// Space after # - heading or just a hash
return nil
}
} else {
// Just a lone #
return nil
}
// Scan tag characters
// Valid: alphanumeric, dash, underscore
tagEnd := 1 // Start after #
for tagEnd < len(line) {
c := line[tagEnd]
isValid := (c >= 'a' && c <= 'z') ||
(c >= 'A' && c <= 'Z') ||
(c >= '0' && c <= '9') ||
c == '-' || c == '_'
if !isValid {
break
}
tagEnd++
}
// Must have at least one character after #
if tagEnd == 1 {
return nil
}
// Extract tag (without #)
tagName := line[1:tagEnd]
// Make a copy of the tag name
tagCopy := make([]byte, len(tagName))
copy(tagCopy, tagName)
// Advance reader
block.Advance(tagEnd)
// Create node
node := &mast.TagNode{
Tag: tagCopy,
}
return node
}

View File

@ -0,0 +1,170 @@
package parser
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/yuin/goldmark/parser"
"github.com/yuin/goldmark/text"
mast "github.com/usememos/memos/plugin/markdown/ast"
)
func TestTagParser(t *testing.T) {
tests := []struct {
name string
input string
expectedTag string
shouldParse bool
}{
{
name: "basic tag",
input: "#tag",
expectedTag: "tag",
shouldParse: true,
},
{
name: "tag with hyphen",
input: "#work-notes",
expectedTag: "work-notes",
shouldParse: true,
},
{
name: "tag with underscore",
input: "#2024_plans",
expectedTag: "2024_plans",
shouldParse: true,
},
{
name: "numeric tag",
input: "#123",
expectedTag: "123",
shouldParse: true,
},
{
name: "tag followed by space",
input: "#tag ",
expectedTag: "tag",
shouldParse: true,
},
{
name: "tag followed by punctuation",
input: "#tag.",
expectedTag: "tag",
shouldParse: true,
},
{
name: "tag in sentence",
input: "#important task",
expectedTag: "important",
shouldParse: true,
},
{
name: "heading (##)",
input: "## Heading",
expectedTag: "",
shouldParse: false,
},
{
name: "space after hash",
input: "# heading",
expectedTag: "",
shouldParse: false,
},
{
name: "lone hash",
input: "#",
expectedTag: "",
shouldParse: false,
},
{
name: "hash with space",
input: "# ",
expectedTag: "",
shouldParse: false,
},
{
name: "special characters",
input: "#tag@special",
expectedTag: "tag",
shouldParse: true, // Stops at @
},
{
name: "mixed case",
input: "#WorkNotes",
expectedTag: "WorkNotes",
shouldParse: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
p := NewTagParser()
reader := text.NewReader([]byte(tt.input))
ctx := parser.NewContext()
node := p.Parse(nil, reader, ctx)
if tt.shouldParse {
require.NotNil(t, node, "Expected tag to be parsed")
require.IsType(t, &mast.TagNode{}, node)
tagNode := node.(*mast.TagNode)
assert.Equal(t, tt.expectedTag, string(tagNode.Tag))
} else {
assert.Nil(t, node, "Expected tag NOT to be parsed")
}
})
}
}
func TestTagParser_Trigger(t *testing.T) {
p := NewTagParser()
triggers := p.Trigger()
assert.Equal(t, []byte{'#'}, triggers)
}
func TestTagParser_MultipleTags(t *testing.T) {
// Test that parser correctly handles multiple tags in sequence
input := "#tag1 #tag2"
p := NewTagParser()
reader := text.NewReader([]byte(input))
ctx := parser.NewContext()
// Parse first tag
node1 := p.Parse(nil, reader, ctx)
require.NotNil(t, node1)
tagNode1 := node1.(*mast.TagNode)
assert.Equal(t, "tag1", string(tagNode1.Tag))
// Advance past the space
reader.Advance(1)
// Parse second tag
node2 := p.Parse(nil, reader, ctx)
require.NotNil(t, node2)
tagNode2 := node2.(*mast.TagNode)
assert.Equal(t, "tag2", string(tagNode2.Tag))
}
func TestTagNode_Kind(t *testing.T) {
node := &mast.TagNode{
Tag: []byte("test"),
}
assert.Equal(t, mast.KindTag, node.Kind())
}
func TestTagNode_Dump(t *testing.T) {
node := &mast.TagNode{
Tag: []byte("test"),
}
// Should not panic
assert.NotPanics(t, func() {
node.Dump([]byte("#test"), 0)
})
}

View File

@ -0,0 +1,104 @@
package parser
import (
"bytes"
gast "github.com/yuin/goldmark/ast"
"github.com/yuin/goldmark/parser"
"github.com/yuin/goldmark/text"
mast "github.com/usememos/memos/plugin/markdown/ast"
)
type wikilinkParser struct{}
// NewWikilinkParser creates a new inline parser for [[...]] wikilink syntax
func NewWikilinkParser() parser.InlineParser {
return &wikilinkParser{}
}
// Trigger returns the characters that trigger this parser.
func (*wikilinkParser) Trigger() []byte {
return []byte{'['}
}
// Parse parses [[target]] or [[target?params]] wikilink syntax.
func (*wikilinkParser) Parse(parent gast.Node, block text.Reader, pc parser.Context) gast.Node {
line, _ := block.PeekLine()
// Must start with [[
if len(line) < 2 || line[0] != '[' || line[1] != '[' {
return nil
}
// Find closing ]]
closePos := findClosingBrackets(line[2:])
if closePos == -1 {
return nil
}
// Extract content between [[ and ]]
// closePos is relative to line[2:], so actual position is closePos + 2
contentStart := 2
contentEnd := contentStart + closePos
content := line[contentStart:contentEnd]
// Empty content is not allowed
if len(bytes.TrimSpace(content)) == 0 {
return nil
}
// Parse target and parameters
target, params := parseTargetAndParams(content)
// Advance reader position
// +2 for [[, +len(content), +2 for ]]
block.Advance(contentEnd + 2)
// Create AST node
node := &mast.WikilinkNode{
Target: target,
Params: params,
}
return node
}
// findClosingBrackets finds the position of ]] in the byte slice
// Returns -1 if not found
func findClosingBrackets(data []byte) int {
for i := 0; i < len(data)-1; i++ {
if data[i] == ']' && data[i+1] == ']' {
return i
}
}
return -1
}
// parseTargetAndParams splits content on ? to extract target and parameters
func parseTargetAndParams(content []byte) (target []byte, params []byte) {
// Find ? separator
idx := bytes.IndexByte(content, '?')
if idx == -1 {
// No parameters
target = bytes.TrimSpace(content)
return target, nil
}
// Split on ?
target = bytes.TrimSpace(content[:idx])
params = content[idx+1:] // Keep params as-is (don't trim, might have meaningful spaces)
// Make copies to avoid issues with slice sharing
targetCopy := make([]byte, len(target))
copy(targetCopy, target)
var paramsCopy []byte
if len(params) > 0 {
paramsCopy = make([]byte, len(params))
copy(paramsCopy, params)
}
return targetCopy, paramsCopy
}

View File

@ -0,0 +1,251 @@
package parser
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/yuin/goldmark/parser"
"github.com/yuin/goldmark/text"
mast "github.com/usememos/memos/plugin/markdown/ast"
)
func TestWikilinkParser(t *testing.T) {
tests := []struct {
name string
input string
expectedTarget string
expectedParams string
shouldParse bool
}{
{
name: "basic wikilink",
input: "[[Hello world]]",
expectedTarget: "Hello world",
expectedParams: "",
shouldParse: true,
},
{
name: "memo wikilink",
input: "[[memos/1]]",
expectedTarget: "memos/1",
expectedParams: "",
shouldParse: true,
},
{
name: "resource wikilink",
input: "[[resources/101]]",
expectedTarget: "resources/101",
expectedParams: "",
shouldParse: true,
},
{
name: "with parameters",
input: "[[resources/101?align=center]]",
expectedTarget: "resources/101",
expectedParams: "align=center",
shouldParse: true,
},
{
name: "multiple parameters",
input: "[[resources/101?align=center&width=300]]",
expectedTarget: "resources/101",
expectedParams: "align=center&width=300",
shouldParse: true,
},
{
name: "inline with text after",
input: "[[resources/101]]111",
expectedTarget: "resources/101",
expectedParams: "",
shouldParse: true,
},
{
name: "whitespace trimmed",
input: "[[ Hello world ]]",
expectedTarget: "Hello world",
expectedParams: "",
shouldParse: true,
},
{
name: "empty content",
input: "[[]]",
expectedTarget: "",
expectedParams: "",
shouldParse: false,
},
{
name: "whitespace only",
input: "[[ ]]",
expectedTarget: "",
expectedParams: "",
shouldParse: false,
},
{
name: "missing closing brackets",
input: "[[Hello world",
expectedTarget: "",
expectedParams: "",
shouldParse: false,
},
{
name: "single bracket",
input: "[Hello]",
expectedTarget: "",
expectedParams: "",
shouldParse: false,
},
{
name: "nested brackets",
input: "[[outer [[inner]] ]]",
expectedTarget: "outer [[inner",
expectedParams: "",
shouldParse: true, // Stops at first ]]
},
{
name: "special characters",
input: "[[Project/2024/Notes]]",
expectedTarget: "Project/2024/Notes",
expectedParams: "",
shouldParse: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
p := NewWikilinkParser()
reader := text.NewReader([]byte(tt.input))
ctx := parser.NewContext()
node := p.Parse(nil, reader, ctx)
if tt.shouldParse {
require.NotNil(t, node, "Expected wikilink to be parsed")
require.IsType(t, &mast.WikilinkNode{}, node)
wikilinkNode := node.(*mast.WikilinkNode)
assert.Equal(t, tt.expectedTarget, string(wikilinkNode.Target))
assert.Equal(t, tt.expectedParams, string(wikilinkNode.Params))
} else {
assert.Nil(t, node, "Expected wikilink NOT to be parsed")
}
})
}
}
func TestWikilinkParser_Trigger(t *testing.T) {
p := NewWikilinkParser()
triggers := p.Trigger()
assert.Equal(t, []byte{'['}, triggers)
}
func TestFindClosingBrackets(t *testing.T) {
tests := []struct {
name string
input []byte
expected int
}{
{
name: "simple case",
input: []byte("hello]]world"),
expected: 5,
},
{
name: "not found",
input: []byte("hello world"),
expected: -1,
},
{
name: "at start",
input: []byte("]]hello"),
expected: 0,
},
{
name: "single bracket",
input: []byte("hello]world"),
expected: -1,
},
{
name: "empty",
input: []byte(""),
expected: -1,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := findClosingBrackets(tt.input)
assert.Equal(t, tt.expected, result)
})
}
}
func TestParseTargetAndParams(t *testing.T) {
tests := []struct {
name string
input []byte
expectedTarget string
expectedParams string
}{
{
name: "no params",
input: []byte("target"),
expectedTarget: "target",
expectedParams: "",
},
{
name: "with params",
input: []byte("target?param=value"),
expectedTarget: "target",
expectedParams: "param=value",
},
{
name: "multiple params",
input: []byte("target?a=1&b=2"),
expectedTarget: "target",
expectedParams: "a=1&b=2",
},
{
name: "whitespace trimmed from target",
input: []byte(" target ?param=value"),
expectedTarget: "target",
expectedParams: "param=value",
},
{
name: "empty params",
input: []byte("target?"),
expectedTarget: "target",
expectedParams: "",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
target, params := parseTargetAndParams(tt.input)
assert.Equal(t, tt.expectedTarget, string(target))
assert.Equal(t, tt.expectedParams, string(params))
})
}
}
func TestWikilinkNode_Kind(t *testing.T) {
node := &mast.WikilinkNode{
Target: []byte("test"),
}
assert.Equal(t, mast.KindWikilink, node.Kind())
}
func TestWikilinkNode_Dump(t *testing.T) {
node := &mast.WikilinkNode{
Target: []byte("test"),
Params: []byte("param=value"),
}
// Should not panic
assert.NotPanics(t, func() {
node.Dump([]byte("[[test?param=value]]"), 0)
})
}

View File

@ -0,0 +1,275 @@
package renderer
import (
"bytes"
"fmt"
"strings"
gast "github.com/yuin/goldmark/ast"
east "github.com/yuin/goldmark/extension/ast"
mast "github.com/usememos/memos/plugin/markdown/ast"
)
// MarkdownRenderer renders goldmark AST back to markdown text.
type MarkdownRenderer struct {
buf *bytes.Buffer
}
// NewMarkdownRenderer creates a new markdown renderer.
func NewMarkdownRenderer() *MarkdownRenderer {
return &MarkdownRenderer{
buf: &bytes.Buffer{},
}
}
// Render renders the AST node to markdown and returns the result.
func (r *MarkdownRenderer) Render(node gast.Node, source []byte) string {
r.buf.Reset()
r.renderNode(node, source, 0)
return r.buf.String()
}
// renderNode renders a single node and its children.
func (r *MarkdownRenderer) renderNode(node gast.Node, source []byte, depth int) {
switch n := node.(type) {
case *gast.Document:
r.renderChildren(n, source, depth)
case *gast.Paragraph:
r.renderChildren(n, source, depth)
if node.NextSibling() != nil {
r.buf.WriteString("\n\n")
}
case *gast.Text:
// Text nodes store their content as segments in the source
segment := n.Segment
r.buf.Write(segment.Value(source))
if n.SoftLineBreak() {
r.buf.WriteByte('\n')
} else if n.HardLineBreak() {
r.buf.WriteString(" \n")
}
case *gast.CodeSpan:
r.buf.WriteByte('`')
r.renderChildren(n, source, depth)
r.buf.WriteByte('`')
case *gast.Emphasis:
symbol := "*"
if n.Level == 2 {
symbol = "**"
}
r.buf.WriteString(symbol)
r.renderChildren(n, source, depth)
r.buf.WriteString(symbol)
case *gast.Link:
r.buf.WriteString("[")
r.renderChildren(n, source, depth)
r.buf.WriteString("](")
r.buf.Write(n.Destination)
if len(n.Title) > 0 {
r.buf.WriteString(` "`)
r.buf.Write(n.Title)
r.buf.WriteString(`"`)
}
r.buf.WriteString(")")
case *gast.AutoLink:
url := n.URL(source)
if n.AutoLinkType == gast.AutoLinkEmail {
r.buf.WriteString("<")
r.buf.Write(url)
r.buf.WriteString(">")
} else {
r.buf.Write(url)
}
case *gast.Image:
r.buf.WriteString("![")
r.renderChildren(n, source, depth)
r.buf.WriteString("](")
r.buf.Write(n.Destination)
if len(n.Title) > 0 {
r.buf.WriteString(` "`)
r.buf.Write(n.Title)
r.buf.WriteString(`"`)
}
r.buf.WriteString(")")
case *gast.Heading:
r.buf.WriteString(strings.Repeat("#", n.Level))
r.buf.WriteByte(' ')
r.renderChildren(n, source, depth)
if node.NextSibling() != nil {
r.buf.WriteString("\n\n")
}
case *gast.CodeBlock, *gast.FencedCodeBlock:
r.renderCodeBlock(n, source)
case *gast.Blockquote:
// Render each child line with "> " prefix
r.renderBlockquote(n, source, depth)
if node.NextSibling() != nil {
r.buf.WriteString("\n\n")
}
case *gast.List:
r.renderChildren(n, source, depth)
if node.NextSibling() != nil {
r.buf.WriteString("\n\n")
}
case *gast.ListItem:
r.renderListItem(n, source, depth)
case *gast.ThematicBreak:
r.buf.WriteString("---")
if node.NextSibling() != nil {
r.buf.WriteString("\n\n")
}
case *east.Strikethrough:
r.buf.WriteString("~~")
r.renderChildren(n, source, depth)
r.buf.WriteString("~~")
case *east.TaskCheckBox:
if n.IsChecked {
r.buf.WriteString("[x] ")
} else {
r.buf.WriteString("[ ] ")
}
case *east.Table:
r.renderTable(n, source)
if node.NextSibling() != nil {
r.buf.WriteString("\n\n")
}
// Custom Memos nodes
case *mast.TagNode:
r.buf.WriteByte('#')
r.buf.Write(n.Tag)
case *mast.WikilinkNode:
r.buf.WriteString("[[")
r.buf.Write(n.Target)
if len(n.Params) > 0 {
r.buf.WriteByte('?')
r.buf.Write(n.Params)
}
r.buf.WriteString("]]")
default:
// For unknown nodes, try to render children
r.renderChildren(n, source, depth)
}
}
// renderChildren renders all children of a node.
func (r *MarkdownRenderer) renderChildren(node gast.Node, source []byte, depth int) {
child := node.FirstChild()
for child != nil {
r.renderNode(child, source, depth+1)
child = child.NextSibling()
}
}
// renderCodeBlock renders a code block.
func (r *MarkdownRenderer) renderCodeBlock(node gast.Node, source []byte) {
if fenced, ok := node.(*gast.FencedCodeBlock); ok {
// Fenced code block with language
r.buf.WriteString("```")
if lang := fenced.Language(source); len(lang) > 0 {
r.buf.Write(lang)
}
r.buf.WriteByte('\n')
// Write all lines
lines := fenced.Lines()
for i := 0; i < lines.Len(); i++ {
line := lines.At(i)
r.buf.Write(line.Value(source))
}
r.buf.WriteString("```")
if node.NextSibling() != nil {
r.buf.WriteString("\n\n")
}
} else if codeBlock, ok := node.(*gast.CodeBlock); ok {
// Indented code block
lines := codeBlock.Lines()
for i := 0; i < lines.Len(); i++ {
line := lines.At(i)
r.buf.WriteString(" ")
r.buf.Write(line.Value(source))
}
if node.NextSibling() != nil {
r.buf.WriteString("\n\n")
}
}
}
// renderBlockquote renders a blockquote with "> " prefix.
func (r *MarkdownRenderer) renderBlockquote(node *gast.Blockquote, source []byte, depth int) {
// Create a temporary buffer for the blockquote content
tempBuf := &bytes.Buffer{}
tempRenderer := &MarkdownRenderer{buf: tempBuf}
tempRenderer.renderChildren(node, source, depth)
// Add "> " prefix to each line
content := tempBuf.String()
lines := strings.Split(strings.TrimRight(content, "\n"), "\n")
for i, line := range lines {
r.buf.WriteString("> ")
r.buf.WriteString(line)
if i < len(lines)-1 {
r.buf.WriteByte('\n')
}
}
}
// renderListItem renders a list item with proper indentation and markers.
func (r *MarkdownRenderer) renderListItem(node *gast.ListItem, source []byte, depth int) {
parent := node.Parent()
list, ok := parent.(*gast.List)
if !ok {
r.renderChildren(node, source, depth)
return
}
// Add indentation only for nested lists
// Document=0, List=1, ListItem=2 (no indent), nested ListItem=3+ (indent)
if depth > 2 {
indent := strings.Repeat(" ", depth-2)
r.buf.WriteString(indent)
}
// Add list marker
if list.IsOrdered() {
r.buf.WriteString(fmt.Sprintf("%d. ", list.Start))
list.Start++ // Increment for next item
} else {
r.buf.WriteString("- ")
}
// Render content
r.renderChildren(node, source, depth)
// Add newline if there's a next sibling
if node.NextSibling() != nil {
r.buf.WriteByte('\n')
}
}
// renderTable renders a table in markdown format.
func (r *MarkdownRenderer) renderTable(table *east.Table, source []byte) {
// This is a simplified table renderer
// A full implementation would need to handle alignment, etc.
r.renderChildren(table, source, 0)
}

View File

@ -0,0 +1,184 @@
package renderer
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/yuin/goldmark"
"github.com/yuin/goldmark/extension"
"github.com/yuin/goldmark/parser"
"github.com/yuin/goldmark/text"
"github.com/usememos/memos/plugin/markdown/extensions"
)
func TestMarkdownRenderer(t *testing.T) {
// Create goldmark instance with all extensions
md := goldmark.New(
goldmark.WithExtensions(
extension.GFM,
extensions.TagExtension,
extensions.WikilinkExtension,
),
goldmark.WithParserOptions(
parser.WithAutoHeadingID(),
),
)
tests := []struct {
name string
input string
expected string
}{
{
name: "simple text",
input: "Hello world",
expected: "Hello world",
},
{
name: "paragraph with newlines",
input: "First paragraph\n\nSecond paragraph",
expected: "First paragraph\n\nSecond paragraph",
},
{
name: "emphasis",
input: "This is *italic* and **bold** text",
expected: "This is *italic* and **bold** text",
},
{
name: "headings",
input: "# Heading 1\n\n## Heading 2\n\n### Heading 3",
expected: "# Heading 1\n\n## Heading 2\n\n### Heading 3",
},
{
name: "link",
input: "Check [this link](https://example.com)",
expected: "Check [this link](https://example.com)",
},
{
name: "image",
input: "![alt text](image.png)",
expected: "![alt text](image.png)",
},
{
name: "code inline",
input: "This is `inline code` here",
expected: "This is `inline code` here",
},
{
name: "code block fenced",
input: "```go\nfunc main() {\n}\n```",
expected: "```go\nfunc main() {\n}\n```",
},
{
name: "unordered list",
input: "- Item 1\n- Item 2\n- Item 3",
expected: "- Item 1\n- Item 2\n- Item 3",
},
{
name: "ordered list",
input: "1. First\n2. Second\n3. Third",
expected: "1. First\n2. Second\n3. Third",
},
{
name: "blockquote",
input: "> This is a quote\n> Second line",
expected: "> This is a quote\n> Second line",
},
{
name: "horizontal rule",
input: "Text before\n\n---\n\nText after",
expected: "Text before\n\n---\n\nText after",
},
{
name: "strikethrough",
input: "This is ~~deleted~~ text",
expected: "This is ~~deleted~~ text",
},
{
name: "task list",
input: "- [x] Completed task\n- [ ] Incomplete task",
expected: "- [x] Completed task\n- [ ] Incomplete task",
},
{
name: "tag",
input: "This has #tag in it",
expected: "This has #tag in it",
},
{
name: "multiple tags",
input: "#work #important meeting notes",
expected: "#work #important meeting notes",
},
{
name: "referenced content (wikilink)",
input: "Check [[memos/42]] for details",
expected: "Check [[memos/42]] for details",
},
{
name: "complex mixed content",
input: "# Meeting Notes\n\n**Date**: 2024-01-01\n\n## Attendees\n- Alice\n- Bob\n\n## Discussion\n\nWe discussed #project status.\n\nSee [[memos/1]] for background.\n\n```python\nprint('hello')\n```",
expected: "# Meeting Notes\n\n**Date**: 2024-01-01\n\n## Attendees\n\n- Alice\n- Bob\n\n## Discussion\n\nWe discussed #project status.\n\nSee [[memos/1]] for background.\n\n```python\nprint('hello')\n```",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Parse the input
source := []byte(tt.input)
reader := text.NewReader(source)
doc := md.Parser().Parse(reader)
require.NotNil(t, doc)
// Render back to markdown
renderer := NewMarkdownRenderer()
result := renderer.Render(doc, source)
// For debugging
if result != tt.expected {
t.Logf("Input: %q", tt.input)
t.Logf("Expected: %q", tt.expected)
t.Logf("Got: %q", result)
}
assert.Equal(t, tt.expected, result)
})
}
}
func TestMarkdownRendererPreservesStructure(t *testing.T) {
// Test that parsing and rendering preserves structure
md := goldmark.New(
goldmark.WithExtensions(
extension.GFM,
extensions.TagExtension,
extensions.WikilinkExtension,
),
)
inputs := []string{
"# Title\n\nParagraph",
"**Bold** and *italic*",
"- List\n- Items",
"#tag #another",
"[[wikilink]]",
"> Quote",
}
renderer := NewMarkdownRenderer()
for _, input := range inputs {
t.Run(input, func(t *testing.T) {
source := []byte(input)
reader := text.NewReader(source)
doc := md.Parser().Parse(reader)
result := renderer.Render(doc, source)
// The result should be structurally similar
// (may have minor formatting differences)
assert.NotEmpty(t, result)
})
}
}

View File

@ -1,331 +0,0 @@
syntax = "proto3";
package memos.api.v1;
import "google/api/annotations.proto";
import "google/api/field_behavior.proto";
option go_package = "gen/api/v1";
service MarkdownService {
// ParseMarkdown parses the given markdown content and returns a list of nodes.
// This is a utility method that transforms markdown text into structured nodes.
rpc ParseMarkdown(ParseMarkdownRequest) returns (ParseMarkdownResponse) {
option (google.api.http) = {
post: "/api/v1/markdown:parse"
body: "*"
};
}
// RestoreMarkdownNodes restores the given nodes to markdown content.
// This is the inverse operation of ParseMarkdown.
rpc RestoreMarkdownNodes(RestoreMarkdownNodesRequest) returns (RestoreMarkdownNodesResponse) {
option (google.api.http) = {
post: "/api/v1/markdown:restore"
body: "*"
};
}
// StringifyMarkdownNodes stringify the given nodes to plain text content.
// This removes all markdown formatting and returns plain text.
rpc StringifyMarkdownNodes(StringifyMarkdownNodesRequest) returns (StringifyMarkdownNodesResponse) {
option (google.api.http) = {
post: "/api/v1/markdown:stringify"
body: "*"
};
}
// GetLinkMetadata returns metadata for a given link.
// This is useful for generating link previews.
rpc GetLinkMetadata(GetLinkMetadataRequest) returns (LinkMetadata) {
option (google.api.http) = {get: "/api/v1/markdown/links:getMetadata"};
}
}
message ParseMarkdownRequest {
// The markdown content to parse.
string markdown = 1 [(google.api.field_behavior) = REQUIRED];
}
message ParseMarkdownResponse {
// The parsed markdown nodes.
repeated Node nodes = 1;
}
message RestoreMarkdownNodesRequest {
// The nodes to restore to markdown content.
repeated Node nodes = 1 [(google.api.field_behavior) = REQUIRED];
}
message RestoreMarkdownNodesResponse {
// The restored markdown content.
string markdown = 1;
}
message StringifyMarkdownNodesRequest {
// The nodes to stringify to plain text.
repeated Node nodes = 1 [(google.api.field_behavior) = REQUIRED];
}
message StringifyMarkdownNodesResponse {
// The plain text content.
string plain_text = 1;
}
message GetLinkMetadataRequest {
// The link URL to get metadata for.
string link = 1 [(google.api.field_behavior) = REQUIRED];
}
message LinkMetadata {
// The title of the linked page.
string title = 1;
// The description of the linked page.
string description = 2;
// The URL of the preview image for the linked page.
string image = 3;
}
enum NodeType {
NODE_UNSPECIFIED = 0;
// Block nodes.
LINE_BREAK = 1;
PARAGRAPH = 2;
CODE_BLOCK = 3;
HEADING = 4;
HORIZONTAL_RULE = 5;
BLOCKQUOTE = 6;
LIST = 7;
ORDERED_LIST_ITEM = 8;
UNORDERED_LIST_ITEM = 9;
TASK_LIST_ITEM = 10;
MATH_BLOCK = 11;
TABLE = 12;
EMBEDDED_CONTENT = 13;
// Inline nodes.
TEXT = 51;
BOLD = 52;
ITALIC = 53;
BOLD_ITALIC = 54;
CODE = 55;
IMAGE = 56;
LINK = 57;
AUTO_LINK = 58;
TAG = 59;
STRIKETHROUGH = 60;
ESCAPING_CHARACTER = 61;
MATH = 62;
HIGHLIGHT = 63;
SUBSCRIPT = 64;
SUPERSCRIPT = 65;
REFERENCED_CONTENT = 66;
SPOILER = 67;
HTML_ELEMENT = 68;
}
message Node {
NodeType type = 1;
oneof node {
// Block nodes.
LineBreakNode line_break_node = 11;
ParagraphNode paragraph_node = 12;
CodeBlockNode code_block_node = 13;
HeadingNode heading_node = 14;
HorizontalRuleNode horizontal_rule_node = 15;
BlockquoteNode blockquote_node = 16;
ListNode list_node = 17;
OrderedListItemNode ordered_list_item_node = 18;
UnorderedListItemNode unordered_list_item_node = 19;
TaskListItemNode task_list_item_node = 20;
MathBlockNode math_block_node = 21;
TableNode table_node = 22;
EmbeddedContentNode embedded_content_node = 23;
// Inline nodes.
TextNode text_node = 51;
BoldNode bold_node = 52;
ItalicNode italic_node = 53;
BoldItalicNode bold_italic_node = 54;
CodeNode code_node = 55;
ImageNode image_node = 56;
LinkNode link_node = 57;
AutoLinkNode auto_link_node = 58;
TagNode tag_node = 59;
StrikethroughNode strikethrough_node = 60;
EscapingCharacterNode escaping_character_node = 61;
MathNode math_node = 62;
HighlightNode highlight_node = 63;
SubscriptNode subscript_node = 64;
SuperscriptNode superscript_node = 65;
ReferencedContentNode referenced_content_node = 66;
SpoilerNode spoiler_node = 67;
HTMLElementNode html_element_node = 68;
}
}
message LineBreakNode {}
message ParagraphNode {
repeated Node children = 1;
}
message CodeBlockNode {
string language = 1;
string content = 2;
}
message HeadingNode {
int32 level = 1;
repeated Node children = 2;
}
message HorizontalRuleNode {
string symbol = 1;
}
message BlockquoteNode {
repeated Node children = 1;
}
message ListNode {
enum Kind {
KIND_UNSPECIFIED = 0;
ORDERED = 1;
UNORDERED = 2;
DESCRIPTION = 3;
}
Kind kind = 1;
int32 indent = 2;
repeated Node children = 3;
}
message OrderedListItemNode {
string number = 1;
int32 indent = 2;
repeated Node children = 3;
}
message UnorderedListItemNode {
string symbol = 1;
int32 indent = 2;
repeated Node children = 3;
}
message TaskListItemNode {
string symbol = 1;
int32 indent = 2;
bool complete = 3;
repeated Node children = 4;
}
message MathBlockNode {
string content = 1;
}
message TableNode {
repeated Node header = 1;
repeated string delimiter = 2;
message Row {
repeated Node cells = 1;
}
repeated Row rows = 3;
}
message EmbeddedContentNode {
// The resource name of the embedded content.
string resource_name = 1;
// Additional parameters for the embedded content.
string params = 2;
}
message TextNode {
string content = 1;
}
message BoldNode {
string symbol = 1;
repeated Node children = 2;
}
message ItalicNode {
string symbol = 1;
repeated Node children = 2;
}
message BoldItalicNode {
string symbol = 1;
string content = 2;
}
message CodeNode {
string content = 1;
}
message ImageNode {
string alt_text = 1;
string url = 2;
}
message LinkNode {
repeated Node content = 1;
string url = 2;
}
message AutoLinkNode {
string url = 1;
bool is_raw_text = 2;
}
message TagNode {
string content = 1;
}
message StrikethroughNode {
string content = 1;
}
message EscapingCharacterNode {
string symbol = 1;
}
message MathNode {
string content = 1;
}
message HighlightNode {
string content = 1;
}
message SubscriptNode {
string content = 1;
}
message SuperscriptNode {
string content = 1;
}
message ReferencedContentNode {
// The resource name of the referenced content.
string resource_name = 1;
// Additional parameters for the referenced content.
string params = 2;
}
message SpoilerNode {
string content = 1;
}
message HTMLElementNode {
string tag_name = 1;
map<string, string> attributes = 2;
repeated Node children = 3;
bool is_self_closing = 4;
}

View File

@ -4,7 +4,6 @@ package memos.api.v1;
import "api/v1/attachment_service.proto"; import "api/v1/attachment_service.proto";
import "api/v1/common.proto"; import "api/v1/common.proto";
import "api/v1/markdown_service.proto";
import "google/api/annotations.proto"; import "google/api/annotations.proto";
import "google/api/client.proto"; import "google/api/client.proto";
import "google/api/field_behavior.proto"; import "google/api/field_behavior.proto";
@ -202,9 +201,6 @@ message Memo {
// Required. The content of the memo in Markdown format. // Required. The content of the memo in Markdown format.
string content = 7 [(google.api.field_behavior) = REQUIRED]; string content = 7 [(google.api.field_behavior) = REQUIRED];
// Output only. The parsed nodes from the content.
repeated Node nodes = 8 [(google.api.field_behavior) = OUTPUT_ONLY];
// The visibility of the memo. // The visibility of the memo.
Visibility visibility = 9 [(google.api.field_behavior) = REQUIRED]; Visibility visibility = 9 [(google.api.field_behavior) = REQUIRED];

File diff suppressed because it is too large Load Diff

View File

@ -1,363 +0,0 @@
// Code generated by protoc-gen-grpc-gateway. DO NOT EDIT.
// source: api/v1/markdown_service.proto
/*
Package apiv1 is a reverse proxy.
It translates gRPC into RESTful JSON APIs.
*/
package apiv1
import (
"context"
"errors"
"io"
"net/http"
"github.com/grpc-ecosystem/grpc-gateway/v2/runtime"
"github.com/grpc-ecosystem/grpc-gateway/v2/utilities"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/grpclog"
"google.golang.org/grpc/metadata"
"google.golang.org/grpc/status"
"google.golang.org/protobuf/proto"
)
// Suppress "imported and not used" errors
var (
_ codes.Code
_ io.Reader
_ status.Status
_ = errors.New
_ = runtime.String
_ = utilities.NewDoubleArray
_ = metadata.Join
)
func request_MarkdownService_ParseMarkdown_0(ctx context.Context, marshaler runtime.Marshaler, client MarkdownServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) {
var (
protoReq ParseMarkdownRequest
metadata runtime.ServerMetadata
)
if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) {
return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err)
}
if req.Body != nil {
_, _ = io.Copy(io.Discard, req.Body)
}
msg, err := client.ParseMarkdown(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD))
return msg, metadata, err
}
func local_request_MarkdownService_ParseMarkdown_0(ctx context.Context, marshaler runtime.Marshaler, server MarkdownServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) {
var (
protoReq ParseMarkdownRequest
metadata runtime.ServerMetadata
)
if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) {
return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err)
}
msg, err := server.ParseMarkdown(ctx, &protoReq)
return msg, metadata, err
}
func request_MarkdownService_RestoreMarkdownNodes_0(ctx context.Context, marshaler runtime.Marshaler, client MarkdownServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) {
var (
protoReq RestoreMarkdownNodesRequest
metadata runtime.ServerMetadata
)
if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) {
return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err)
}
if req.Body != nil {
_, _ = io.Copy(io.Discard, req.Body)
}
msg, err := client.RestoreMarkdownNodes(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD))
return msg, metadata, err
}
func local_request_MarkdownService_RestoreMarkdownNodes_0(ctx context.Context, marshaler runtime.Marshaler, server MarkdownServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) {
var (
protoReq RestoreMarkdownNodesRequest
metadata runtime.ServerMetadata
)
if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) {
return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err)
}
msg, err := server.RestoreMarkdownNodes(ctx, &protoReq)
return msg, metadata, err
}
func request_MarkdownService_StringifyMarkdownNodes_0(ctx context.Context, marshaler runtime.Marshaler, client MarkdownServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) {
var (
protoReq StringifyMarkdownNodesRequest
metadata runtime.ServerMetadata
)
if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) {
return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err)
}
if req.Body != nil {
_, _ = io.Copy(io.Discard, req.Body)
}
msg, err := client.StringifyMarkdownNodes(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD))
return msg, metadata, err
}
func local_request_MarkdownService_StringifyMarkdownNodes_0(ctx context.Context, marshaler runtime.Marshaler, server MarkdownServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) {
var (
protoReq StringifyMarkdownNodesRequest
metadata runtime.ServerMetadata
)
if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) {
return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err)
}
msg, err := server.StringifyMarkdownNodes(ctx, &protoReq)
return msg, metadata, err
}
var filter_MarkdownService_GetLinkMetadata_0 = &utilities.DoubleArray{Encoding: map[string]int{}, Base: []int(nil), Check: []int(nil)}
func request_MarkdownService_GetLinkMetadata_0(ctx context.Context, marshaler runtime.Marshaler, client MarkdownServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) {
var (
protoReq GetLinkMetadataRequest
metadata runtime.ServerMetadata
)
if req.Body != nil {
_, _ = io.Copy(io.Discard, req.Body)
}
if err := req.ParseForm(); err != nil {
return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err)
}
if err := runtime.PopulateQueryParameters(&protoReq, req.Form, filter_MarkdownService_GetLinkMetadata_0); err != nil {
return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err)
}
msg, err := client.GetLinkMetadata(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD))
return msg, metadata, err
}
func local_request_MarkdownService_GetLinkMetadata_0(ctx context.Context, marshaler runtime.Marshaler, server MarkdownServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) {
var (
protoReq GetLinkMetadataRequest
metadata runtime.ServerMetadata
)
if err := req.ParseForm(); err != nil {
return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err)
}
if err := runtime.PopulateQueryParameters(&protoReq, req.Form, filter_MarkdownService_GetLinkMetadata_0); err != nil {
return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err)
}
msg, err := server.GetLinkMetadata(ctx, &protoReq)
return msg, metadata, err
}
// RegisterMarkdownServiceHandlerServer registers the http handlers for service MarkdownService to "mux".
// UnaryRPC :call MarkdownServiceServer directly.
// StreamingRPC :currently unsupported pending https://github.com/grpc/grpc-go/issues/906.
// Note that using this registration option will cause many gRPC library features to stop working. Consider using RegisterMarkdownServiceHandlerFromEndpoint instead.
// GRPC interceptors will not work for this type of registration. To use interceptors, you must use the "runtime.WithMiddlewares" option in the "runtime.NewServeMux" call.
func RegisterMarkdownServiceHandlerServer(ctx context.Context, mux *runtime.ServeMux, server MarkdownServiceServer) error {
mux.Handle(http.MethodPost, pattern_MarkdownService_ParseMarkdown_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) {
ctx, cancel := context.WithCancel(req.Context())
defer cancel()
var stream runtime.ServerTransportStream
ctx = grpc.NewContextWithServerTransportStream(ctx, &stream)
inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req)
annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/memos.api.v1.MarkdownService/ParseMarkdown", runtime.WithHTTPPathPattern("/api/v1/markdown:parse"))
if err != nil {
runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err)
return
}
resp, md, err := local_request_MarkdownService_ParseMarkdown_0(annotatedContext, inboundMarshaler, server, req, pathParams)
md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer())
annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md)
if err != nil {
runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err)
return
}
forward_MarkdownService_ParseMarkdown_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...)
})
mux.Handle(http.MethodPost, pattern_MarkdownService_RestoreMarkdownNodes_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) {
ctx, cancel := context.WithCancel(req.Context())
defer cancel()
var stream runtime.ServerTransportStream
ctx = grpc.NewContextWithServerTransportStream(ctx, &stream)
inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req)
annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/memos.api.v1.MarkdownService/RestoreMarkdownNodes", runtime.WithHTTPPathPattern("/api/v1/markdown:restore"))
if err != nil {
runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err)
return
}
resp, md, err := local_request_MarkdownService_RestoreMarkdownNodes_0(annotatedContext, inboundMarshaler, server, req, pathParams)
md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer())
annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md)
if err != nil {
runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err)
return
}
forward_MarkdownService_RestoreMarkdownNodes_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...)
})
mux.Handle(http.MethodPost, pattern_MarkdownService_StringifyMarkdownNodes_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) {
ctx, cancel := context.WithCancel(req.Context())
defer cancel()
var stream runtime.ServerTransportStream
ctx = grpc.NewContextWithServerTransportStream(ctx, &stream)
inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req)
annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/memos.api.v1.MarkdownService/StringifyMarkdownNodes", runtime.WithHTTPPathPattern("/api/v1/markdown:stringify"))
if err != nil {
runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err)
return
}
resp, md, err := local_request_MarkdownService_StringifyMarkdownNodes_0(annotatedContext, inboundMarshaler, server, req, pathParams)
md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer())
annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md)
if err != nil {
runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err)
return
}
forward_MarkdownService_StringifyMarkdownNodes_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...)
})
mux.Handle(http.MethodGet, pattern_MarkdownService_GetLinkMetadata_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) {
ctx, cancel := context.WithCancel(req.Context())
defer cancel()
var stream runtime.ServerTransportStream
ctx = grpc.NewContextWithServerTransportStream(ctx, &stream)
inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req)
annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/memos.api.v1.MarkdownService/GetLinkMetadata", runtime.WithHTTPPathPattern("/api/v1/markdown/links:getMetadata"))
if err != nil {
runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err)
return
}
resp, md, err := local_request_MarkdownService_GetLinkMetadata_0(annotatedContext, inboundMarshaler, server, req, pathParams)
md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer())
annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md)
if err != nil {
runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err)
return
}
forward_MarkdownService_GetLinkMetadata_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...)
})
return nil
}
// RegisterMarkdownServiceHandlerFromEndpoint is same as RegisterMarkdownServiceHandler but
// automatically dials to "endpoint" and closes the connection when "ctx" gets done.
func RegisterMarkdownServiceHandlerFromEndpoint(ctx context.Context, mux *runtime.ServeMux, endpoint string, opts []grpc.DialOption) (err error) {
conn, err := grpc.NewClient(endpoint, opts...)
if err != nil {
return err
}
defer func() {
if err != nil {
if cerr := conn.Close(); cerr != nil {
grpclog.Errorf("Failed to close conn to %s: %v", endpoint, cerr)
}
return
}
go func() {
<-ctx.Done()
if cerr := conn.Close(); cerr != nil {
grpclog.Errorf("Failed to close conn to %s: %v", endpoint, cerr)
}
}()
}()
return RegisterMarkdownServiceHandler(ctx, mux, conn)
}
// RegisterMarkdownServiceHandler registers the http handlers for service MarkdownService to "mux".
// The handlers forward requests to the grpc endpoint over "conn".
func RegisterMarkdownServiceHandler(ctx context.Context, mux *runtime.ServeMux, conn *grpc.ClientConn) error {
return RegisterMarkdownServiceHandlerClient(ctx, mux, NewMarkdownServiceClient(conn))
}
// RegisterMarkdownServiceHandlerClient registers the http handlers for service MarkdownService
// to "mux". The handlers forward requests to the grpc endpoint over the given implementation of "MarkdownServiceClient".
// Note: the gRPC framework executes interceptors within the gRPC handler. If the passed in "MarkdownServiceClient"
// doesn't go through the normal gRPC flow (creating a gRPC client etc.) then it will be up to the passed in
// "MarkdownServiceClient" to call the correct interceptors. This client ignores the HTTP middlewares.
func RegisterMarkdownServiceHandlerClient(ctx context.Context, mux *runtime.ServeMux, client MarkdownServiceClient) error {
mux.Handle(http.MethodPost, pattern_MarkdownService_ParseMarkdown_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) {
ctx, cancel := context.WithCancel(req.Context())
defer cancel()
inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req)
annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/memos.api.v1.MarkdownService/ParseMarkdown", runtime.WithHTTPPathPattern("/api/v1/markdown:parse"))
if err != nil {
runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err)
return
}
resp, md, err := request_MarkdownService_ParseMarkdown_0(annotatedContext, inboundMarshaler, client, req, pathParams)
annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md)
if err != nil {
runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err)
return
}
forward_MarkdownService_ParseMarkdown_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...)
})
mux.Handle(http.MethodPost, pattern_MarkdownService_RestoreMarkdownNodes_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) {
ctx, cancel := context.WithCancel(req.Context())
defer cancel()
inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req)
annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/memos.api.v1.MarkdownService/RestoreMarkdownNodes", runtime.WithHTTPPathPattern("/api/v1/markdown:restore"))
if err != nil {
runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err)
return
}
resp, md, err := request_MarkdownService_RestoreMarkdownNodes_0(annotatedContext, inboundMarshaler, client, req, pathParams)
annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md)
if err != nil {
runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err)
return
}
forward_MarkdownService_RestoreMarkdownNodes_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...)
})
mux.Handle(http.MethodPost, pattern_MarkdownService_StringifyMarkdownNodes_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) {
ctx, cancel := context.WithCancel(req.Context())
defer cancel()
inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req)
annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/memos.api.v1.MarkdownService/StringifyMarkdownNodes", runtime.WithHTTPPathPattern("/api/v1/markdown:stringify"))
if err != nil {
runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err)
return
}
resp, md, err := request_MarkdownService_StringifyMarkdownNodes_0(annotatedContext, inboundMarshaler, client, req, pathParams)
annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md)
if err != nil {
runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err)
return
}
forward_MarkdownService_StringifyMarkdownNodes_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...)
})
mux.Handle(http.MethodGet, pattern_MarkdownService_GetLinkMetadata_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) {
ctx, cancel := context.WithCancel(req.Context())
defer cancel()
inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req)
annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/memos.api.v1.MarkdownService/GetLinkMetadata", runtime.WithHTTPPathPattern("/api/v1/markdown/links:getMetadata"))
if err != nil {
runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err)
return
}
resp, md, err := request_MarkdownService_GetLinkMetadata_0(annotatedContext, inboundMarshaler, client, req, pathParams)
annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md)
if err != nil {
runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err)
return
}
forward_MarkdownService_GetLinkMetadata_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...)
})
return nil
}
var (
pattern_MarkdownService_ParseMarkdown_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2}, []string{"api", "v1", "markdown"}, "parse"))
pattern_MarkdownService_RestoreMarkdownNodes_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2}, []string{"api", "v1", "markdown"}, "restore"))
pattern_MarkdownService_StringifyMarkdownNodes_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2}, []string{"api", "v1", "markdown"}, "stringify"))
pattern_MarkdownService_GetLinkMetadata_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2, 2, 3}, []string{"api", "v1", "markdown", "links"}, "getMetadata"))
)
var (
forward_MarkdownService_ParseMarkdown_0 = runtime.ForwardResponseMessage
forward_MarkdownService_RestoreMarkdownNodes_0 = runtime.ForwardResponseMessage
forward_MarkdownService_StringifyMarkdownNodes_0 = runtime.ForwardResponseMessage
forward_MarkdownService_GetLinkMetadata_0 = runtime.ForwardResponseMessage
)

View File

@ -1,251 +0,0 @@
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
// versions:
// - protoc-gen-go-grpc v1.5.1
// - protoc (unknown)
// source: api/v1/markdown_service.proto
package apiv1
import (
context "context"
grpc "google.golang.org/grpc"
codes "google.golang.org/grpc/codes"
status "google.golang.org/grpc/status"
)
// This is a compile-time assertion to ensure that this generated file
// is compatible with the grpc package it is being compiled against.
// Requires gRPC-Go v1.64.0 or later.
const _ = grpc.SupportPackageIsVersion9
const (
MarkdownService_ParseMarkdown_FullMethodName = "/memos.api.v1.MarkdownService/ParseMarkdown"
MarkdownService_RestoreMarkdownNodes_FullMethodName = "/memos.api.v1.MarkdownService/RestoreMarkdownNodes"
MarkdownService_StringifyMarkdownNodes_FullMethodName = "/memos.api.v1.MarkdownService/StringifyMarkdownNodes"
MarkdownService_GetLinkMetadata_FullMethodName = "/memos.api.v1.MarkdownService/GetLinkMetadata"
)
// MarkdownServiceClient is the client API for MarkdownService service.
//
// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream.
type MarkdownServiceClient interface {
// ParseMarkdown parses the given markdown content and returns a list of nodes.
// This is a utility method that transforms markdown text into structured nodes.
ParseMarkdown(ctx context.Context, in *ParseMarkdownRequest, opts ...grpc.CallOption) (*ParseMarkdownResponse, error)
// RestoreMarkdownNodes restores the given nodes to markdown content.
// This is the inverse operation of ParseMarkdown.
RestoreMarkdownNodes(ctx context.Context, in *RestoreMarkdownNodesRequest, opts ...grpc.CallOption) (*RestoreMarkdownNodesResponse, error)
// StringifyMarkdownNodes stringify the given nodes to plain text content.
// This removes all markdown formatting and returns plain text.
StringifyMarkdownNodes(ctx context.Context, in *StringifyMarkdownNodesRequest, opts ...grpc.CallOption) (*StringifyMarkdownNodesResponse, error)
// GetLinkMetadata returns metadata for a given link.
// This is useful for generating link previews.
GetLinkMetadata(ctx context.Context, in *GetLinkMetadataRequest, opts ...grpc.CallOption) (*LinkMetadata, error)
}
type markdownServiceClient struct {
cc grpc.ClientConnInterface
}
func NewMarkdownServiceClient(cc grpc.ClientConnInterface) MarkdownServiceClient {
return &markdownServiceClient{cc}
}
func (c *markdownServiceClient) ParseMarkdown(ctx context.Context, in *ParseMarkdownRequest, opts ...grpc.CallOption) (*ParseMarkdownResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(ParseMarkdownResponse)
err := c.cc.Invoke(ctx, MarkdownService_ParseMarkdown_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *markdownServiceClient) RestoreMarkdownNodes(ctx context.Context, in *RestoreMarkdownNodesRequest, opts ...grpc.CallOption) (*RestoreMarkdownNodesResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(RestoreMarkdownNodesResponse)
err := c.cc.Invoke(ctx, MarkdownService_RestoreMarkdownNodes_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *markdownServiceClient) StringifyMarkdownNodes(ctx context.Context, in *StringifyMarkdownNodesRequest, opts ...grpc.CallOption) (*StringifyMarkdownNodesResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(StringifyMarkdownNodesResponse)
err := c.cc.Invoke(ctx, MarkdownService_StringifyMarkdownNodes_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *markdownServiceClient) GetLinkMetadata(ctx context.Context, in *GetLinkMetadataRequest, opts ...grpc.CallOption) (*LinkMetadata, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(LinkMetadata)
err := c.cc.Invoke(ctx, MarkdownService_GetLinkMetadata_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
// MarkdownServiceServer is the server API for MarkdownService service.
// All implementations must embed UnimplementedMarkdownServiceServer
// for forward compatibility.
type MarkdownServiceServer interface {
// ParseMarkdown parses the given markdown content and returns a list of nodes.
// This is a utility method that transforms markdown text into structured nodes.
ParseMarkdown(context.Context, *ParseMarkdownRequest) (*ParseMarkdownResponse, error)
// RestoreMarkdownNodes restores the given nodes to markdown content.
// This is the inverse operation of ParseMarkdown.
RestoreMarkdownNodes(context.Context, *RestoreMarkdownNodesRequest) (*RestoreMarkdownNodesResponse, error)
// StringifyMarkdownNodes stringify the given nodes to plain text content.
// This removes all markdown formatting and returns plain text.
StringifyMarkdownNodes(context.Context, *StringifyMarkdownNodesRequest) (*StringifyMarkdownNodesResponse, error)
// GetLinkMetadata returns metadata for a given link.
// This is useful for generating link previews.
GetLinkMetadata(context.Context, *GetLinkMetadataRequest) (*LinkMetadata, error)
mustEmbedUnimplementedMarkdownServiceServer()
}
// UnimplementedMarkdownServiceServer must be embedded to have
// forward compatible implementations.
//
// NOTE: this should be embedded by value instead of pointer to avoid a nil
// pointer dereference when methods are called.
type UnimplementedMarkdownServiceServer struct{}
func (UnimplementedMarkdownServiceServer) ParseMarkdown(context.Context, *ParseMarkdownRequest) (*ParseMarkdownResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method ParseMarkdown not implemented")
}
func (UnimplementedMarkdownServiceServer) RestoreMarkdownNodes(context.Context, *RestoreMarkdownNodesRequest) (*RestoreMarkdownNodesResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method RestoreMarkdownNodes not implemented")
}
func (UnimplementedMarkdownServiceServer) StringifyMarkdownNodes(context.Context, *StringifyMarkdownNodesRequest) (*StringifyMarkdownNodesResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method StringifyMarkdownNodes not implemented")
}
func (UnimplementedMarkdownServiceServer) GetLinkMetadata(context.Context, *GetLinkMetadataRequest) (*LinkMetadata, error) {
return nil, status.Errorf(codes.Unimplemented, "method GetLinkMetadata not implemented")
}
func (UnimplementedMarkdownServiceServer) mustEmbedUnimplementedMarkdownServiceServer() {}
func (UnimplementedMarkdownServiceServer) testEmbeddedByValue() {}
// UnsafeMarkdownServiceServer may be embedded to opt out of forward compatibility for this service.
// Use of this interface is not recommended, as added methods to MarkdownServiceServer will
// result in compilation errors.
type UnsafeMarkdownServiceServer interface {
mustEmbedUnimplementedMarkdownServiceServer()
}
func RegisterMarkdownServiceServer(s grpc.ServiceRegistrar, srv MarkdownServiceServer) {
// If the following call pancis, it indicates UnimplementedMarkdownServiceServer was
// embedded by pointer and is nil. This will cause panics if an
// unimplemented method is ever invoked, so we test this at initialization
// time to prevent it from happening at runtime later due to I/O.
if t, ok := srv.(interface{ testEmbeddedByValue() }); ok {
t.testEmbeddedByValue()
}
s.RegisterService(&MarkdownService_ServiceDesc, srv)
}
func _MarkdownService_ParseMarkdown_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(ParseMarkdownRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(MarkdownServiceServer).ParseMarkdown(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: MarkdownService_ParseMarkdown_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(MarkdownServiceServer).ParseMarkdown(ctx, req.(*ParseMarkdownRequest))
}
return interceptor(ctx, in, info, handler)
}
func _MarkdownService_RestoreMarkdownNodes_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(RestoreMarkdownNodesRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(MarkdownServiceServer).RestoreMarkdownNodes(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: MarkdownService_RestoreMarkdownNodes_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(MarkdownServiceServer).RestoreMarkdownNodes(ctx, req.(*RestoreMarkdownNodesRequest))
}
return interceptor(ctx, in, info, handler)
}
func _MarkdownService_StringifyMarkdownNodes_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(StringifyMarkdownNodesRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(MarkdownServiceServer).StringifyMarkdownNodes(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: MarkdownService_StringifyMarkdownNodes_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(MarkdownServiceServer).StringifyMarkdownNodes(ctx, req.(*StringifyMarkdownNodesRequest))
}
return interceptor(ctx, in, info, handler)
}
func _MarkdownService_GetLinkMetadata_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(GetLinkMetadataRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(MarkdownServiceServer).GetLinkMetadata(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: MarkdownService_GetLinkMetadata_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(MarkdownServiceServer).GetLinkMetadata(ctx, req.(*GetLinkMetadataRequest))
}
return interceptor(ctx, in, info, handler)
}
// MarkdownService_ServiceDesc is the grpc.ServiceDesc for MarkdownService service.
// It's only intended for direct use with grpc.RegisterService,
// and not to be introspected or modified (even as a copy)
var MarkdownService_ServiceDesc = grpc.ServiceDesc{
ServiceName: "memos.api.v1.MarkdownService",
HandlerType: (*MarkdownServiceServer)(nil),
Methods: []grpc.MethodDesc{
{
MethodName: "ParseMarkdown",
Handler: _MarkdownService_ParseMarkdown_Handler,
},
{
MethodName: "RestoreMarkdownNodes",
Handler: _MarkdownService_RestoreMarkdownNodes_Handler,
},
{
MethodName: "StringifyMarkdownNodes",
Handler: _MarkdownService_StringifyMarkdownNodes_Handler,
},
{
MethodName: "GetLinkMetadata",
Handler: _MarkdownService_GetLinkMetadata_Handler,
},
},
Streams: []grpc.StreamDesc{},
Metadata: "api/v1/markdown_service.proto",
}

View File

@ -230,8 +230,6 @@ type Memo struct {
DisplayTime *timestamppb.Timestamp `protobuf:"bytes,6,opt,name=display_time,json=displayTime,proto3" json:"display_time,omitempty"` DisplayTime *timestamppb.Timestamp `protobuf:"bytes,6,opt,name=display_time,json=displayTime,proto3" json:"display_time,omitempty"`
// Required. The content of the memo in Markdown format. // Required. The content of the memo in Markdown format.
Content string `protobuf:"bytes,7,opt,name=content,proto3" json:"content,omitempty"` Content string `protobuf:"bytes,7,opt,name=content,proto3" json:"content,omitempty"`
// Output only. The parsed nodes from the content.
Nodes []*Node `protobuf:"bytes,8,rep,name=nodes,proto3" json:"nodes,omitempty"`
// The visibility of the memo. // The visibility of the memo.
Visibility Visibility `protobuf:"varint,9,opt,name=visibility,proto3,enum=memos.api.v1.Visibility" json:"visibility,omitempty"` Visibility Visibility `protobuf:"varint,9,opt,name=visibility,proto3,enum=memos.api.v1.Visibility" json:"visibility,omitempty"`
// Output only. The tags extracted from the content. // Output only. The tags extracted from the content.
@ -336,13 +334,6 @@ func (x *Memo) GetContent() string {
return "" return ""
} }
func (x *Memo) GetNodes() []*Node {
if x != nil {
return x.Nodes
}
return nil
}
func (x *Memo) GetVisibility() Visibility { func (x *Memo) GetVisibility() Visibility {
if x != nil { if x != nil {
return x.Visibility return x.Visibility
@ -2000,7 +1991,7 @@ var File_api_v1_memo_service_proto protoreflect.FileDescriptor
const file_api_v1_memo_service_proto_rawDesc = "" + const file_api_v1_memo_service_proto_rawDesc = "" +
"\n" + "\n" +
"\x19api/v1/memo_service.proto\x12\fmemos.api.v1\x1a\x1fapi/v1/attachment_service.proto\x1a\x13api/v1/common.proto\x1a\x1dapi/v1/markdown_service.proto\x1a\x1cgoogle/api/annotations.proto\x1a\x17google/api/client.proto\x1a\x1fgoogle/api/field_behavior.proto\x1a\x19google/api/resource.proto\x1a\x1bgoogle/protobuf/empty.proto\x1a google/protobuf/field_mask.proto\x1a\x1fgoogle/protobuf/timestamp.proto\"\xce\x02\n" + "\x19api/v1/memo_service.proto\x12\fmemos.api.v1\x1a\x1fapi/v1/attachment_service.proto\x1a\x13api/v1/common.proto\x1a\x1cgoogle/api/annotations.proto\x1a\x17google/api/client.proto\x1a\x1fgoogle/api/field_behavior.proto\x1a\x19google/api/resource.proto\x1a\x1bgoogle/protobuf/empty.proto\x1a google/protobuf/field_mask.proto\x1a\x1fgoogle/protobuf/timestamp.proto\"\xce\x02\n" +
"\bReaction\x12\x1a\n" + "\bReaction\x12\x1a\n" +
"\x04name\x18\x01 \x01(\tB\x06\xe0A\x03\xe0A\bR\x04name\x123\n" + "\x04name\x18\x01 \x01(\tB\x06\xe0A\x03\xe0A\bR\x04name\x123\n" +
"\acreator\x18\x02 \x01(\tB\x19\xe0A\x03\xfaA\x13\n" + "\acreator\x18\x02 \x01(\tB\x19\xe0A\x03\xfaA\x13\n" +
@ -2011,7 +2002,7 @@ const file_api_v1_memo_service_proto_rawDesc = "" +
"\rreaction_type\x18\x04 \x01(\tB\x03\xe0A\x02R\freactionType\x12@\n" + "\rreaction_type\x18\x04 \x01(\tB\x03\xe0A\x02R\freactionType\x12@\n" +
"\vcreate_time\x18\x05 \x01(\v2\x1a.google.protobuf.TimestampB\x03\xe0A\x03R\n" + "\vcreate_time\x18\x05 \x01(\v2\x1a.google.protobuf.TimestampB\x03\xe0A\x03R\n" +
"createTime:K\xeaAH\n" + "createTime:K\xeaAH\n" +
"\x15memos.api.v1/Reaction\x12\x14reactions/{reaction}\x1a\x04name*\treactions2\breaction\"\x87\t\n" + "\x15memos.api.v1/Reaction\x12\x14reactions/{reaction}\x1a\x04name*\treactions2\breaction\"\xd8\b\n" +
"\x04Memo\x12\x17\n" + "\x04Memo\x12\x17\n" +
"\x04name\x18\x01 \x01(\tB\x03\xe0A\bR\x04name\x12.\n" + "\x04name\x18\x01 \x01(\tB\x03\xe0A\bR\x04name\x12.\n" +
"\x05state\x18\x02 \x01(\x0e2\x13.memos.api.v1.StateB\x03\xe0A\x02R\x05state\x123\n" + "\x05state\x18\x02 \x01(\x0e2\x13.memos.api.v1.StateB\x03\xe0A\x02R\x05state\x123\n" +
@ -2022,8 +2013,7 @@ const file_api_v1_memo_service_proto_rawDesc = "" +
"\vupdate_time\x18\x05 \x01(\v2\x1a.google.protobuf.TimestampB\x03\xe0A\x03R\n" + "\vupdate_time\x18\x05 \x01(\v2\x1a.google.protobuf.TimestampB\x03\xe0A\x03R\n" +
"updateTime\x12B\n" + "updateTime\x12B\n" +
"\fdisplay_time\x18\x06 \x01(\v2\x1a.google.protobuf.TimestampB\x03\xe0A\x01R\vdisplayTime\x12\x1d\n" + "\fdisplay_time\x18\x06 \x01(\v2\x1a.google.protobuf.TimestampB\x03\xe0A\x01R\vdisplayTime\x12\x1d\n" +
"\acontent\x18\a \x01(\tB\x03\xe0A\x02R\acontent\x12-\n" + "\acontent\x18\a \x01(\tB\x03\xe0A\x02R\acontent\x12=\n" +
"\x05nodes\x18\b \x03(\v2\x12.memos.api.v1.NodeB\x03\xe0A\x03R\x05nodes\x12=\n" +
"\n" + "\n" +
"visibility\x18\t \x01(\x0e2\x18.memos.api.v1.VisibilityB\x03\xe0A\x02R\n" + "visibility\x18\t \x01(\x0e2\x18.memos.api.v1.VisibilityB\x03\xe0A\x02R\n" +
"visibility\x12\x17\n" + "visibility\x12\x17\n" +
@ -2246,10 +2236,9 @@ var file_api_v1_memo_service_proto_goTypes = []any{
(*MemoRelation_Memo)(nil), // 28: memos.api.v1.MemoRelation.Memo (*MemoRelation_Memo)(nil), // 28: memos.api.v1.MemoRelation.Memo
(*timestamppb.Timestamp)(nil), // 29: google.protobuf.Timestamp (*timestamppb.Timestamp)(nil), // 29: google.protobuf.Timestamp
(State)(0), // 30: memos.api.v1.State (State)(0), // 30: memos.api.v1.State
(*Node)(nil), // 31: memos.api.v1.Node (*Attachment)(nil), // 31: memos.api.v1.Attachment
(*Attachment)(nil), // 32: memos.api.v1.Attachment (*fieldmaskpb.FieldMask)(nil), // 32: google.protobuf.FieldMask
(*fieldmaskpb.FieldMask)(nil), // 33: google.protobuf.FieldMask (*emptypb.Empty)(nil), // 33: google.protobuf.Empty
(*emptypb.Empty)(nil), // 34: google.protobuf.Empty
} }
var file_api_v1_memo_service_proto_depIdxs = []int32{ var file_api_v1_memo_service_proto_depIdxs = []int32{
29, // 0: memos.api.v1.Reaction.create_time:type_name -> google.protobuf.Timestamp 29, // 0: memos.api.v1.Reaction.create_time:type_name -> google.protobuf.Timestamp
@ -2257,67 +2246,66 @@ var file_api_v1_memo_service_proto_depIdxs = []int32{
29, // 2: memos.api.v1.Memo.create_time:type_name -> google.protobuf.Timestamp 29, // 2: memos.api.v1.Memo.create_time:type_name -> google.protobuf.Timestamp
29, // 3: memos.api.v1.Memo.update_time:type_name -> google.protobuf.Timestamp 29, // 3: memos.api.v1.Memo.update_time:type_name -> google.protobuf.Timestamp
29, // 4: memos.api.v1.Memo.display_time:type_name -> google.protobuf.Timestamp 29, // 4: memos.api.v1.Memo.display_time:type_name -> google.protobuf.Timestamp
31, // 5: memos.api.v1.Memo.nodes:type_name -> memos.api.v1.Node 0, // 5: memos.api.v1.Memo.visibility:type_name -> memos.api.v1.Visibility
0, // 6: memos.api.v1.Memo.visibility:type_name -> memos.api.v1.Visibility 31, // 6: memos.api.v1.Memo.attachments:type_name -> memos.api.v1.Attachment
32, // 7: memos.api.v1.Memo.attachments:type_name -> memos.api.v1.Attachment 16, // 7: memos.api.v1.Memo.relations:type_name -> memos.api.v1.MemoRelation
16, // 8: memos.api.v1.Memo.relations:type_name -> memos.api.v1.MemoRelation 2, // 8: memos.api.v1.Memo.reactions:type_name -> memos.api.v1.Reaction
2, // 9: memos.api.v1.Memo.reactions:type_name -> memos.api.v1.Reaction 27, // 9: memos.api.v1.Memo.property:type_name -> memos.api.v1.Memo.Property
27, // 10: memos.api.v1.Memo.property:type_name -> memos.api.v1.Memo.Property 4, // 10: memos.api.v1.Memo.location:type_name -> memos.api.v1.Location
4, // 11: memos.api.v1.Memo.location:type_name -> memos.api.v1.Location 3, // 11: memos.api.v1.CreateMemoRequest.memo:type_name -> memos.api.v1.Memo
3, // 12: memos.api.v1.CreateMemoRequest.memo:type_name -> memos.api.v1.Memo 30, // 12: memos.api.v1.ListMemosRequest.state:type_name -> memos.api.v1.State
30, // 13: memos.api.v1.ListMemosRequest.state:type_name -> memos.api.v1.State 3, // 13: memos.api.v1.ListMemosResponse.memos:type_name -> memos.api.v1.Memo
3, // 14: memos.api.v1.ListMemosResponse.memos:type_name -> memos.api.v1.Memo 32, // 14: memos.api.v1.GetMemoRequest.read_mask:type_name -> google.protobuf.FieldMask
33, // 15: memos.api.v1.GetMemoRequest.read_mask:type_name -> google.protobuf.FieldMask 3, // 15: memos.api.v1.UpdateMemoRequest.memo:type_name -> memos.api.v1.Memo
3, // 16: memos.api.v1.UpdateMemoRequest.memo:type_name -> memos.api.v1.Memo 32, // 16: memos.api.v1.UpdateMemoRequest.update_mask:type_name -> google.protobuf.FieldMask
33, // 17: memos.api.v1.UpdateMemoRequest.update_mask:type_name -> google.protobuf.FieldMask 31, // 17: memos.api.v1.SetMemoAttachmentsRequest.attachments:type_name -> memos.api.v1.Attachment
32, // 18: memos.api.v1.SetMemoAttachmentsRequest.attachments:type_name -> memos.api.v1.Attachment 31, // 18: memos.api.v1.ListMemoAttachmentsResponse.attachments:type_name -> memos.api.v1.Attachment
32, // 19: memos.api.v1.ListMemoAttachmentsResponse.attachments:type_name -> memos.api.v1.Attachment 28, // 19: memos.api.v1.MemoRelation.memo:type_name -> memos.api.v1.MemoRelation.Memo
28, // 20: memos.api.v1.MemoRelation.memo:type_name -> memos.api.v1.MemoRelation.Memo 28, // 20: memos.api.v1.MemoRelation.related_memo:type_name -> memos.api.v1.MemoRelation.Memo
28, // 21: memos.api.v1.MemoRelation.related_memo:type_name -> memos.api.v1.MemoRelation.Memo 1, // 21: memos.api.v1.MemoRelation.type:type_name -> memos.api.v1.MemoRelation.Type
1, // 22: memos.api.v1.MemoRelation.type:type_name -> memos.api.v1.MemoRelation.Type 16, // 22: memos.api.v1.SetMemoRelationsRequest.relations:type_name -> memos.api.v1.MemoRelation
16, // 23: memos.api.v1.SetMemoRelationsRequest.relations:type_name -> memos.api.v1.MemoRelation 16, // 23: memos.api.v1.ListMemoRelationsResponse.relations:type_name -> memos.api.v1.MemoRelation
16, // 24: memos.api.v1.ListMemoRelationsResponse.relations:type_name -> memos.api.v1.MemoRelation 3, // 24: memos.api.v1.CreateMemoCommentRequest.comment:type_name -> memos.api.v1.Memo
3, // 25: memos.api.v1.CreateMemoCommentRequest.comment:type_name -> memos.api.v1.Memo 3, // 25: memos.api.v1.ListMemoCommentsResponse.memos:type_name -> memos.api.v1.Memo
3, // 26: memos.api.v1.ListMemoCommentsResponse.memos:type_name -> memos.api.v1.Memo 2, // 26: memos.api.v1.ListMemoReactionsResponse.reactions:type_name -> memos.api.v1.Reaction
2, // 27: memos.api.v1.ListMemoReactionsResponse.reactions:type_name -> memos.api.v1.Reaction 2, // 27: memos.api.v1.UpsertMemoReactionRequest.reaction:type_name -> memos.api.v1.Reaction
2, // 28: memos.api.v1.UpsertMemoReactionRequest.reaction:type_name -> memos.api.v1.Reaction 5, // 28: memos.api.v1.MemoService.CreateMemo:input_type -> memos.api.v1.CreateMemoRequest
5, // 29: memos.api.v1.MemoService.CreateMemo:input_type -> memos.api.v1.CreateMemoRequest 6, // 29: memos.api.v1.MemoService.ListMemos:input_type -> memos.api.v1.ListMemosRequest
6, // 30: memos.api.v1.MemoService.ListMemos:input_type -> memos.api.v1.ListMemosRequest 8, // 30: memos.api.v1.MemoService.GetMemo:input_type -> memos.api.v1.GetMemoRequest
8, // 31: memos.api.v1.MemoService.GetMemo:input_type -> memos.api.v1.GetMemoRequest 9, // 31: memos.api.v1.MemoService.UpdateMemo:input_type -> memos.api.v1.UpdateMemoRequest
9, // 32: memos.api.v1.MemoService.UpdateMemo:input_type -> memos.api.v1.UpdateMemoRequest 10, // 32: memos.api.v1.MemoService.DeleteMemo:input_type -> memos.api.v1.DeleteMemoRequest
10, // 33: memos.api.v1.MemoService.DeleteMemo:input_type -> memos.api.v1.DeleteMemoRequest 11, // 33: memos.api.v1.MemoService.RenameMemoTag:input_type -> memos.api.v1.RenameMemoTagRequest
11, // 34: memos.api.v1.MemoService.RenameMemoTag:input_type -> memos.api.v1.RenameMemoTagRequest 12, // 34: memos.api.v1.MemoService.DeleteMemoTag:input_type -> memos.api.v1.DeleteMemoTagRequest
12, // 35: memos.api.v1.MemoService.DeleteMemoTag:input_type -> memos.api.v1.DeleteMemoTagRequest 13, // 35: memos.api.v1.MemoService.SetMemoAttachments:input_type -> memos.api.v1.SetMemoAttachmentsRequest
13, // 36: memos.api.v1.MemoService.SetMemoAttachments:input_type -> memos.api.v1.SetMemoAttachmentsRequest 14, // 36: memos.api.v1.MemoService.ListMemoAttachments:input_type -> memos.api.v1.ListMemoAttachmentsRequest
14, // 37: memos.api.v1.MemoService.ListMemoAttachments:input_type -> memos.api.v1.ListMemoAttachmentsRequest 17, // 37: memos.api.v1.MemoService.SetMemoRelations:input_type -> memos.api.v1.SetMemoRelationsRequest
17, // 38: memos.api.v1.MemoService.SetMemoRelations:input_type -> memos.api.v1.SetMemoRelationsRequest 18, // 38: memos.api.v1.MemoService.ListMemoRelations:input_type -> memos.api.v1.ListMemoRelationsRequest
18, // 39: memos.api.v1.MemoService.ListMemoRelations:input_type -> memos.api.v1.ListMemoRelationsRequest 20, // 39: memos.api.v1.MemoService.CreateMemoComment:input_type -> memos.api.v1.CreateMemoCommentRequest
20, // 40: memos.api.v1.MemoService.CreateMemoComment:input_type -> memos.api.v1.CreateMemoCommentRequest 21, // 40: memos.api.v1.MemoService.ListMemoComments:input_type -> memos.api.v1.ListMemoCommentsRequest
21, // 41: memos.api.v1.MemoService.ListMemoComments:input_type -> memos.api.v1.ListMemoCommentsRequest 23, // 41: memos.api.v1.MemoService.ListMemoReactions:input_type -> memos.api.v1.ListMemoReactionsRequest
23, // 42: memos.api.v1.MemoService.ListMemoReactions:input_type -> memos.api.v1.ListMemoReactionsRequest 25, // 42: memos.api.v1.MemoService.UpsertMemoReaction:input_type -> memos.api.v1.UpsertMemoReactionRequest
25, // 43: memos.api.v1.MemoService.UpsertMemoReaction:input_type -> memos.api.v1.UpsertMemoReactionRequest 26, // 43: memos.api.v1.MemoService.DeleteMemoReaction:input_type -> memos.api.v1.DeleteMemoReactionRequest
26, // 44: memos.api.v1.MemoService.DeleteMemoReaction:input_type -> memos.api.v1.DeleteMemoReactionRequest 3, // 44: memos.api.v1.MemoService.CreateMemo:output_type -> memos.api.v1.Memo
3, // 45: memos.api.v1.MemoService.CreateMemo:output_type -> memos.api.v1.Memo 7, // 45: memos.api.v1.MemoService.ListMemos:output_type -> memos.api.v1.ListMemosResponse
7, // 46: memos.api.v1.MemoService.ListMemos:output_type -> memos.api.v1.ListMemosResponse 3, // 46: memos.api.v1.MemoService.GetMemo:output_type -> memos.api.v1.Memo
3, // 47: memos.api.v1.MemoService.GetMemo:output_type -> memos.api.v1.Memo 3, // 47: memos.api.v1.MemoService.UpdateMemo:output_type -> memos.api.v1.Memo
3, // 48: memos.api.v1.MemoService.UpdateMemo:output_type -> memos.api.v1.Memo 33, // 48: memos.api.v1.MemoService.DeleteMemo:output_type -> google.protobuf.Empty
34, // 49: memos.api.v1.MemoService.DeleteMemo:output_type -> google.protobuf.Empty 33, // 49: memos.api.v1.MemoService.RenameMemoTag:output_type -> google.protobuf.Empty
34, // 50: memos.api.v1.MemoService.RenameMemoTag:output_type -> google.protobuf.Empty 33, // 50: memos.api.v1.MemoService.DeleteMemoTag:output_type -> google.protobuf.Empty
34, // 51: memos.api.v1.MemoService.DeleteMemoTag:output_type -> google.protobuf.Empty 33, // 51: memos.api.v1.MemoService.SetMemoAttachments:output_type -> google.protobuf.Empty
34, // 52: memos.api.v1.MemoService.SetMemoAttachments:output_type -> google.protobuf.Empty 15, // 52: memos.api.v1.MemoService.ListMemoAttachments:output_type -> memos.api.v1.ListMemoAttachmentsResponse
15, // 53: memos.api.v1.MemoService.ListMemoAttachments:output_type -> memos.api.v1.ListMemoAttachmentsResponse 33, // 53: memos.api.v1.MemoService.SetMemoRelations:output_type -> google.protobuf.Empty
34, // 54: memos.api.v1.MemoService.SetMemoRelations:output_type -> google.protobuf.Empty 19, // 54: memos.api.v1.MemoService.ListMemoRelations:output_type -> memos.api.v1.ListMemoRelationsResponse
19, // 55: memos.api.v1.MemoService.ListMemoRelations:output_type -> memos.api.v1.ListMemoRelationsResponse 3, // 55: memos.api.v1.MemoService.CreateMemoComment:output_type -> memos.api.v1.Memo
3, // 56: memos.api.v1.MemoService.CreateMemoComment:output_type -> memos.api.v1.Memo 22, // 56: memos.api.v1.MemoService.ListMemoComments:output_type -> memos.api.v1.ListMemoCommentsResponse
22, // 57: memos.api.v1.MemoService.ListMemoComments:output_type -> memos.api.v1.ListMemoCommentsResponse 24, // 57: memos.api.v1.MemoService.ListMemoReactions:output_type -> memos.api.v1.ListMemoReactionsResponse
24, // 58: memos.api.v1.MemoService.ListMemoReactions:output_type -> memos.api.v1.ListMemoReactionsResponse 2, // 58: memos.api.v1.MemoService.UpsertMemoReaction:output_type -> memos.api.v1.Reaction
2, // 59: memos.api.v1.MemoService.UpsertMemoReaction:output_type -> memos.api.v1.Reaction 33, // 59: memos.api.v1.MemoService.DeleteMemoReaction:output_type -> google.protobuf.Empty
34, // 60: memos.api.v1.MemoService.DeleteMemoReaction:output_type -> google.protobuf.Empty 44, // [44:60] is the sub-list for method output_type
45, // [45:61] is the sub-list for method output_type 28, // [28:44] is the sub-list for method input_type
29, // [29:45] is the sub-list for method input_type 28, // [28:28] is the sub-list for extension type_name
29, // [29:29] is the sub-list for extension type_name 28, // [28:28] is the sub-list for extension extendee
29, // [29:29] is the sub-list for extension extendee 0, // [0:28] is the sub-list for field type_name
0, // [0:29] is the sub-list for field type_name
} }
func init() { file_api_v1_memo_service_proto_init() } func init() { file_api_v1_memo_service_proto_init() }
@ -2327,7 +2315,6 @@ func file_api_v1_memo_service_proto_init() {
} }
file_api_v1_attachment_service_proto_init() file_api_v1_attachment_service_proto_init()
file_api_v1_common_proto_init() file_api_v1_common_proto_init()
file_api_v1_markdown_service_proto_init()
file_api_v1_memo_service_proto_msgTypes[1].OneofWrappers = []any{} file_api_v1_memo_service_proto_msgTypes[1].OneofWrappers = []any{}
type x struct{} type x struct{}
out := protoimpl.TypeBuilder{ out := protoimpl.TypeBuilder{

View File

@ -507,114 +507,6 @@ paths:
application/json: application/json:
schema: schema:
$ref: '#/components/schemas/Status' $ref: '#/components/schemas/Status'
/api/v1/markdown/links:getMetadata:
get:
tags:
- MarkdownService
description: |-
GetLinkMetadata returns metadata for a given link.
This is useful for generating link previews.
operationId: MarkdownService_GetLinkMetadata
parameters:
- name: link
in: query
description: The link URL to get metadata for.
schema:
type: string
responses:
"200":
description: OK
content:
application/json:
schema:
$ref: '#/components/schemas/LinkMetadata'
default:
description: Default error response
content:
application/json:
schema:
$ref: '#/components/schemas/Status'
/api/v1/markdown:parse:
post:
tags:
- MarkdownService
description: |-
ParseMarkdown parses the given markdown content and returns a list of nodes.
This is a utility method that transforms markdown text into structured nodes.
operationId: MarkdownService_ParseMarkdown
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/ParseMarkdownRequest'
required: true
responses:
"200":
description: OK
content:
application/json:
schema:
$ref: '#/components/schemas/ParseMarkdownResponse'
default:
description: Default error response
content:
application/json:
schema:
$ref: '#/components/schemas/Status'
/api/v1/markdown:restore:
post:
tags:
- MarkdownService
description: |-
RestoreMarkdownNodes restores the given nodes to markdown content.
This is the inverse operation of ParseMarkdown.
operationId: MarkdownService_RestoreMarkdownNodes
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/RestoreMarkdownNodesRequest'
required: true
responses:
"200":
description: OK
content:
application/json:
schema:
$ref: '#/components/schemas/RestoreMarkdownNodesResponse'
default:
description: Default error response
content:
application/json:
schema:
$ref: '#/components/schemas/Status'
/api/v1/markdown:stringify:
post:
tags:
- MarkdownService
description: |-
StringifyMarkdownNodes stringify the given nodes to plain text content.
This removes all markdown formatting and returns plain text.
operationId: MarkdownService_StringifyMarkdownNodes
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/StringifyMarkdownNodesRequest'
required: true
responses:
"200":
description: OK
content:
application/json:
schema:
$ref: '#/components/schemas/StringifyMarkdownNodesResponse'
default:
description: Default error response
content:
application/json:
schema:
$ref: '#/components/schemas/Status'
/api/v1/memos: /api/v1/memos:
get: get:
tags: tags:
@ -2312,48 +2204,6 @@ components:
description: |- description: |-
Optional. The related memo. Refer to `Memo.name`. Optional. The related memo. Refer to `Memo.name`.
Format: memos/{memo} Format: memos/{memo}
AutoLinkNode:
type: object
properties:
url:
type: string
isRawText:
type: boolean
BlockquoteNode:
type: object
properties:
children:
type: array
items:
$ref: '#/components/schemas/Node'
BoldItalicNode:
type: object
properties:
symbol:
type: string
content:
type: string
BoldNode:
type: object
properties:
symbol:
type: string
children:
type: array
items:
$ref: '#/components/schemas/Node'
CodeBlockNode:
type: object
properties:
language:
type: string
content:
type: string
CodeNode:
type: object
properties:
content:
type: string
CreateSessionRequest: CreateSessionRequest:
type: object type: object
properties: properties:
@ -2436,20 +2286,6 @@ components:
deleteRelatedMemos: deleteRelatedMemos:
type: boolean type: boolean
description: Optional. Whether to delete related memos. description: Optional. Whether to delete related memos.
EmbeddedContentNode:
type: object
properties:
resourceName:
type: string
description: The resource name of the embedded content.
params:
type: string
description: Additional parameters for the embedded content.
EscapingCharacterNode:
type: object
properties:
symbol:
type: string
FieldMapping: FieldMapping:
type: object type: object
properties: properties:
@ -2492,41 +2328,6 @@ components:
description: The type of the serialized message. description: The type of the serialized message.
additionalProperties: true additionalProperties: true
description: Contains an arbitrary serialized message along with a @type that describes the type of the serialized message. description: Contains an arbitrary serialized message along with a @type that describes the type of the serialized message.
HTMLElementNode:
type: object
properties:
tagName:
type: string
attributes:
type: object
additionalProperties:
type: string
children:
type: array
items:
$ref: '#/components/schemas/Node'
isSelfClosing:
type: boolean
HeadingNode:
type: object
properties:
level:
type: integer
format: int32
children:
type: array
items:
$ref: '#/components/schemas/Node'
HighlightNode:
type: object
properties:
content:
type: string
HorizontalRuleNode:
type: object
properties:
symbol:
type: string
IdentityProvider: IdentityProvider:
required: required:
- type - type
@ -2561,13 +2362,6 @@ components:
properties: properties:
oauth2Config: oauth2Config:
$ref: '#/components/schemas/OAuth2Config' $ref: '#/components/schemas/OAuth2Config'
ImageNode:
type: object
properties:
altText:
type: string
url:
type: string
Inbox: Inbox:
type: object type: object
properties: properties:
@ -2614,39 +2408,6 @@ components:
type: integer type: integer
description: Optional. The activity ID associated with this inbox notification. description: Optional. The activity ID associated with this inbox notification.
format: int32 format: int32
ItalicNode:
type: object
properties:
symbol:
type: string
children:
type: array
items:
$ref: '#/components/schemas/Node'
LineBreakNode:
type: object
properties: {}
LinkMetadata:
type: object
properties:
title:
type: string
description: The title of the linked page.
description:
type: string
description: The description of the linked page.
image:
type: string
description: The URL of the preview image for the linked page.
LinkNode:
type: object
properties:
content:
type: array
items:
$ref: '#/components/schemas/Node'
url:
type: string
ListActivitiesResponse: ListActivitiesResponse:
type: object type: object
properties: properties:
@ -2788,24 +2549,6 @@ components:
type: integer type: integer
description: The total count of memos (may be approximate). description: The total count of memos (may be approximate).
format: int32 format: int32
ListNode:
type: object
properties:
kind:
enum:
- KIND_UNSPECIFIED
- ORDERED
- UNORDERED
- DESCRIPTION
type: string
format: enum
indent:
type: integer
format: int32
children:
type: array
items:
$ref: '#/components/schemas/Node'
ListShortcutsResponse: ListShortcutsResponse:
type: object type: object
properties: properties:
@ -2894,16 +2637,6 @@ components:
type: number type: number
description: The longitude of the location. description: The longitude of the location.
format: double format: double
MathBlockNode:
type: object
properties:
content:
type: string
MathNode:
type: object
properties:
content:
type: string
Memo: Memo:
required: required:
- state - state
@ -2947,12 +2680,6 @@ components:
content: content:
type: string type: string
description: Required. The content of the memo in Markdown format. description: Required. The content of the memo in Markdown format.
nodes:
readOnly: true
type: array
items:
$ref: '#/components/schemas/Node'
description: Output only. The parsed nodes from the content.
visibility: visibility:
enum: enum:
- VISIBILITY_UNSPECIFIED - VISIBILITY_UNSPECIFIED
@ -3055,111 +2782,6 @@ components:
hasIncompleteTasks: hasIncompleteTasks:
type: boolean type: boolean
description: Computed properties of a memo. description: Computed properties of a memo.
Node:
type: object
properties:
type:
enum:
- NODE_UNSPECIFIED
- LINE_BREAK
- PARAGRAPH
- CODE_BLOCK
- HEADING
- HORIZONTAL_RULE
- BLOCKQUOTE
- LIST
- ORDERED_LIST_ITEM
- UNORDERED_LIST_ITEM
- TASK_LIST_ITEM
- MATH_BLOCK
- TABLE
- EMBEDDED_CONTENT
- TEXT
- BOLD
- ITALIC
- BOLD_ITALIC
- CODE
- IMAGE
- LINK
- AUTO_LINK
- TAG
- STRIKETHROUGH
- ESCAPING_CHARACTER
- MATH
- HIGHLIGHT
- SUBSCRIPT
- SUPERSCRIPT
- REFERENCED_CONTENT
- SPOILER
- HTML_ELEMENT
type: string
format: enum
lineBreakNode:
allOf:
- $ref: '#/components/schemas/LineBreakNode'
description: Block nodes.
paragraphNode:
$ref: '#/components/schemas/ParagraphNode'
codeBlockNode:
$ref: '#/components/schemas/CodeBlockNode'
headingNode:
$ref: '#/components/schemas/HeadingNode'
horizontalRuleNode:
$ref: '#/components/schemas/HorizontalRuleNode'
blockquoteNode:
$ref: '#/components/schemas/BlockquoteNode'
listNode:
$ref: '#/components/schemas/ListNode'
orderedListItemNode:
$ref: '#/components/schemas/OrderedListItemNode'
unorderedListItemNode:
$ref: '#/components/schemas/UnorderedListItemNode'
taskListItemNode:
$ref: '#/components/schemas/TaskListItemNode'
mathBlockNode:
$ref: '#/components/schemas/MathBlockNode'
tableNode:
$ref: '#/components/schemas/TableNode'
embeddedContentNode:
$ref: '#/components/schemas/EmbeddedContentNode'
textNode:
allOf:
- $ref: '#/components/schemas/TextNode'
description: Inline nodes.
boldNode:
$ref: '#/components/schemas/BoldNode'
italicNode:
$ref: '#/components/schemas/ItalicNode'
boldItalicNode:
$ref: '#/components/schemas/BoldItalicNode'
codeNode:
$ref: '#/components/schemas/CodeNode'
imageNode:
$ref: '#/components/schemas/ImageNode'
linkNode:
$ref: '#/components/schemas/LinkNode'
autoLinkNode:
$ref: '#/components/schemas/AutoLinkNode'
tagNode:
$ref: '#/components/schemas/TagNode'
strikethroughNode:
$ref: '#/components/schemas/StrikethroughNode'
escapingCharacterNode:
$ref: '#/components/schemas/EscapingCharacterNode'
mathNode:
$ref: '#/components/schemas/MathNode'
highlightNode:
$ref: '#/components/schemas/HighlightNode'
subscriptNode:
$ref: '#/components/schemas/SubscriptNode'
superscriptNode:
$ref: '#/components/schemas/SuperscriptNode'
referencedContentNode:
$ref: '#/components/schemas/ReferencedContentNode'
spoilerNode:
$ref: '#/components/schemas/SpoilerNode'
htmlElementNode:
$ref: '#/components/schemas/HTMLElementNode'
OAuth2Config: OAuth2Config:
type: object type: object
properties: properties:
@ -3179,41 +2801,6 @@ components:
type: string type: string
fieldMapping: fieldMapping:
$ref: '#/components/schemas/FieldMapping' $ref: '#/components/schemas/FieldMapping'
OrderedListItemNode:
type: object
properties:
number:
type: string
indent:
type: integer
format: int32
children:
type: array
items:
$ref: '#/components/schemas/Node'
ParagraphNode:
type: object
properties:
children:
type: array
items:
$ref: '#/components/schemas/Node'
ParseMarkdownRequest:
required:
- markdown
type: object
properties:
markdown:
type: string
description: The markdown content to parse.
ParseMarkdownResponse:
type: object
properties:
nodes:
type: array
items:
$ref: '#/components/schemas/Node'
description: The parsed markdown nodes.
Reaction: Reaction:
required: required:
- contentId - contentId
@ -3246,15 +2833,6 @@ components:
type: string type: string
description: Output only. The creation timestamp. description: Output only. The creation timestamp.
format: date-time format: date-time
ReferencedContentNode:
type: object
properties:
resourceName:
type: string
description: The resource name of the referenced content.
params:
type: string
description: Additional parameters for the referenced content.
RenameMemoTagRequest: RenameMemoTagRequest:
required: required:
- parent - parent
@ -3273,22 +2851,6 @@ components:
newTag: newTag:
type: string type: string
description: Required. The new tag name. description: Required. The new tag name.
RestoreMarkdownNodesRequest:
required:
- nodes
type: object
properties:
nodes:
type: array
items:
$ref: '#/components/schemas/Node'
description: The nodes to restore to markdown content.
RestoreMarkdownNodesResponse:
type: object
properties:
markdown:
type: string
description: The restored markdown content.
SetMemoAttachmentsRequest: SetMemoAttachmentsRequest:
required: required:
- name - name
@ -3337,11 +2899,6 @@ components:
filter: filter:
type: string type: string
description: The filter expression for the shortcut. description: The filter expression for the shortcut.
SpoilerNode:
type: object
properties:
content:
type: string
Status: Status:
type: object type: object
properties: properties:
@ -3376,95 +2933,6 @@ components:
description: |- description: |-
S3 configuration for cloud storage backend. S3 configuration for cloud storage backend.
Reference: https://developers.cloudflare.com/r2/examples/aws/aws-sdk-go/ Reference: https://developers.cloudflare.com/r2/examples/aws/aws-sdk-go/
StrikethroughNode:
type: object
properties:
content:
type: string
StringifyMarkdownNodesRequest:
required:
- nodes
type: object
properties:
nodes:
type: array
items:
$ref: '#/components/schemas/Node'
description: The nodes to stringify to plain text.
StringifyMarkdownNodesResponse:
type: object
properties:
plainText:
type: string
description: The plain text content.
SubscriptNode:
type: object
properties:
content:
type: string
SuperscriptNode:
type: object
properties:
content:
type: string
TableNode:
type: object
properties:
header:
type: array
items:
$ref: '#/components/schemas/Node'
delimiter:
type: array
items:
type: string
rows:
type: array
items:
$ref: '#/components/schemas/TableNode_Row'
TableNode_Row:
type: object
properties:
cells:
type: array
items:
$ref: '#/components/schemas/Node'
TagNode:
type: object
properties:
content:
type: string
TaskListItemNode:
type: object
properties:
symbol:
type: string
indent:
type: integer
format: int32
complete:
type: boolean
children:
type: array
items:
$ref: '#/components/schemas/Node'
TextNode:
type: object
properties:
content:
type: string
UnorderedListItemNode:
type: object
properties:
symbol:
type: string
indent:
type: integer
format: int32
children:
type: array
items:
$ref: '#/components/schemas/Node'
UpsertMemoReactionRequest: UpsertMemoReactionRequest:
required: required:
- name - name
@ -3884,7 +3352,6 @@ tags:
- name: AuthService - name: AuthService
- name: IdentityProviderService - name: IdentityProviderService
- name: InboxService - name: InboxService
- name: MarkdownService
- name: MemoService - name: MemoService
- name: ShortcutService - name: ShortcutService
- name: UserService - name: UserService

View File

@ -14,7 +14,6 @@ var authenticationAllowlistMethods = map[string]bool{
"/memos.api.v1.UserService/SearchUsers": true, "/memos.api.v1.UserService/SearchUsers": true,
"/memos.api.v1.MemoService/GetMemo": true, "/memos.api.v1.MemoService/GetMemo": true,
"/memos.api.v1.MemoService/ListMemos": true, "/memos.api.v1.MemoService/ListMemos": true,
"/memos.api.v1.MarkdownService/GetLinkMetadata": true,
"/memos.api.v1.AttachmentService/GetAttachmentBinary": true, "/memos.api.v1.AttachmentService/GetAttachmentBinary": true,
} }

View File

@ -1,306 +0,0 @@
package v1
import (
"context"
"github.com/pkg/errors"
"github.com/usememos/gomark"
"github.com/usememos/gomark/ast"
"github.com/usememos/gomark/renderer"
"github.com/usememos/memos/plugin/httpgetter"
v1pb "github.com/usememos/memos/proto/gen/api/v1"
)
func (*APIV1Service) ParseMarkdown(_ context.Context, request *v1pb.ParseMarkdownRequest) (*v1pb.ParseMarkdownResponse, error) {
doc, err := gomark.Parse(request.Markdown)
if err != nil {
return nil, errors.Wrap(err, "failed to parse memo content")
}
nodes := convertFromASTDocument(doc)
return &v1pb.ParseMarkdownResponse{
Nodes: nodes,
}, nil
}
func (*APIV1Service) RestoreMarkdownNodes(_ context.Context, request *v1pb.RestoreMarkdownNodesRequest) (*v1pb.RestoreMarkdownNodesResponse, error) {
markdown := gomark.Restore(convertToASTDocument(request.Nodes))
return &v1pb.RestoreMarkdownNodesResponse{
Markdown: markdown,
}, nil
}
func (*APIV1Service) StringifyMarkdownNodes(_ context.Context, request *v1pb.StringifyMarkdownNodesRequest) (*v1pb.StringifyMarkdownNodesResponse, error) {
stringRenderer := renderer.NewStringRenderer()
plainText := stringRenderer.RenderDocument(convertToASTDocument(request.Nodes))
return &v1pb.StringifyMarkdownNodesResponse{
PlainText: plainText,
}, nil
}
func (*APIV1Service) GetLinkMetadata(_ context.Context, request *v1pb.GetLinkMetadataRequest) (*v1pb.LinkMetadata, error) {
htmlMeta, err := httpgetter.GetHTMLMeta(request.Link)
if err != nil {
return nil, err
}
return &v1pb.LinkMetadata{
Title: htmlMeta.Title,
Description: htmlMeta.Description,
Image: htmlMeta.Image,
}, nil
}
func convertFromASTNode(rawNode ast.Node) *v1pb.Node {
node := &v1pb.Node{
Type: v1pb.NodeType(v1pb.NodeType_value[string(rawNode.Type())]),
}
switch n := rawNode.(type) {
case *ast.LineBreak:
node.Node = &v1pb.Node_LineBreakNode{}
case *ast.Paragraph:
children := convertFromASTNodes(n.Children)
node.Node = &v1pb.Node_ParagraphNode{ParagraphNode: &v1pb.ParagraphNode{Children: children}}
case *ast.CodeBlock:
node.Node = &v1pb.Node_CodeBlockNode{CodeBlockNode: &v1pb.CodeBlockNode{Language: n.Language, Content: n.Content}}
case *ast.Heading:
children := convertFromASTNodes(n.Children)
node.Node = &v1pb.Node_HeadingNode{HeadingNode: &v1pb.HeadingNode{Level: int32(n.Level), Children: children}}
case *ast.HorizontalRule:
node.Node = &v1pb.Node_HorizontalRuleNode{HorizontalRuleNode: &v1pb.HorizontalRuleNode{Symbol: n.Symbol}}
case *ast.Blockquote:
children := convertFromASTNodes(n.Children)
node.Node = &v1pb.Node_BlockquoteNode{BlockquoteNode: &v1pb.BlockquoteNode{Children: children}}
case *ast.List:
children := convertFromASTNodes(n.Children)
node.Node = &v1pb.Node_ListNode{ListNode: &v1pb.ListNode{Kind: convertListKindFromASTNode(n.Kind), Indent: int32(n.Indent), Children: children}}
case *ast.OrderedListItem:
children := convertFromASTNodes(n.Children)
node.Node = &v1pb.Node_OrderedListItemNode{OrderedListItemNode: &v1pb.OrderedListItemNode{Number: n.Number, Indent: int32(n.Indent), Children: children}}
case *ast.UnorderedListItem:
children := convertFromASTNodes(n.Children)
node.Node = &v1pb.Node_UnorderedListItemNode{UnorderedListItemNode: &v1pb.UnorderedListItemNode{Symbol: n.Symbol, Indent: int32(n.Indent), Children: children}}
case *ast.TaskListItem:
children := convertFromASTNodes(n.Children)
node.Node = &v1pb.Node_TaskListItemNode{TaskListItemNode: &v1pb.TaskListItemNode{Symbol: n.Symbol, Indent: int32(n.Indent), Complete: n.Complete, Children: children}}
case *ast.MathBlock:
node.Node = &v1pb.Node_MathBlockNode{MathBlockNode: &v1pb.MathBlockNode{Content: n.Content}}
case *ast.Table:
node.Node = &v1pb.Node_TableNode{TableNode: convertTableFromASTNode(n)}
case *ast.EmbeddedContent:
node.Node = &v1pb.Node_EmbeddedContentNode{EmbeddedContentNode: &v1pb.EmbeddedContentNode{ResourceName: n.ResourceName, Params: n.Params}}
case *ast.Text:
node.Node = &v1pb.Node_TextNode{TextNode: &v1pb.TextNode{Content: n.Content}}
case *ast.Bold:
node.Node = &v1pb.Node_BoldNode{BoldNode: &v1pb.BoldNode{Symbol: n.Symbol, Children: convertFromASTNodes(n.Children)}}
case *ast.Italic:
node.Node = &v1pb.Node_ItalicNode{ItalicNode: &v1pb.ItalicNode{Symbol: n.Symbol, Children: convertFromASTNodes(n.Children)}}
case *ast.BoldItalic:
childDoc := &ast.Document{Children: n.Children}
plain := renderer.NewStringRenderer().RenderDocument(childDoc)
node.Node = &v1pb.Node_BoldItalicNode{BoldItalicNode: &v1pb.BoldItalicNode{Symbol: n.Symbol, Content: plain}}
case *ast.Code:
node.Node = &v1pb.Node_CodeNode{CodeNode: &v1pb.CodeNode{Content: n.Content}}
case *ast.Image:
node.Node = &v1pb.Node_ImageNode{ImageNode: &v1pb.ImageNode{AltText: n.AltText, Url: n.URL}}
case *ast.Link:
node.Node = &v1pb.Node_LinkNode{LinkNode: &v1pb.LinkNode{Content: convertFromASTNodes(n.Content), Url: n.URL}}
case *ast.AutoLink:
node.Node = &v1pb.Node_AutoLinkNode{AutoLinkNode: &v1pb.AutoLinkNode{Url: n.URL, IsRawText: n.IsRawText}}
case *ast.Tag:
node.Node = &v1pb.Node_TagNode{TagNode: &v1pb.TagNode{Content: n.Content}}
case *ast.Strikethrough:
node.Node = &v1pb.Node_StrikethroughNode{StrikethroughNode: &v1pb.StrikethroughNode{Content: n.Content}}
case *ast.EscapingCharacter:
node.Node = &v1pb.Node_EscapingCharacterNode{EscapingCharacterNode: &v1pb.EscapingCharacterNode{Symbol: n.Symbol}}
case *ast.Math:
node.Node = &v1pb.Node_MathNode{MathNode: &v1pb.MathNode{Content: n.Content}}
case *ast.Highlight:
node.Node = &v1pb.Node_HighlightNode{HighlightNode: &v1pb.HighlightNode{Content: n.Content}}
case *ast.Subscript:
node.Node = &v1pb.Node_SubscriptNode{SubscriptNode: &v1pb.SubscriptNode{Content: n.Content}}
case *ast.Superscript:
node.Node = &v1pb.Node_SuperscriptNode{SuperscriptNode: &v1pb.SuperscriptNode{Content: n.Content}}
case *ast.ReferencedContent:
node.Node = &v1pb.Node_ReferencedContentNode{ReferencedContentNode: &v1pb.ReferencedContentNode{ResourceName: n.ResourceName, Params: n.Params}}
case *ast.Spoiler:
node.Node = &v1pb.Node_SpoilerNode{SpoilerNode: &v1pb.SpoilerNode{Content: n.Content}}
case *ast.HTMLElement:
node.Node = &v1pb.Node_HtmlElementNode{HtmlElementNode: &v1pb.HTMLElementNode{
TagName: n.TagName,
Attributes: n.Attributes,
Children: convertFromASTNodes(n.Children),
IsSelfClosing: n.IsSelfClosing,
}}
default:
node.Node = &v1pb.Node_TextNode{TextNode: &v1pb.TextNode{}}
}
return node
}
func convertFromASTNodes(rawNodes []ast.Node) []*v1pb.Node {
nodes := []*v1pb.Node{}
for _, rawNode := range rawNodes {
node := convertFromASTNode(rawNode)
nodes = append(nodes, node)
}
return nodes
}
func convertFromASTDocument(doc *ast.Document) []*v1pb.Node {
if doc == nil {
return nil
}
return convertFromASTNodes(doc.Children)
}
func convertTableFromASTNode(node *ast.Table) *v1pb.TableNode {
table := &v1pb.TableNode{
Header: convertFromASTNodes(node.Header),
Delimiter: node.Delimiter,
}
for _, row := range node.Rows {
table.Rows = append(table.Rows, &v1pb.TableNode_Row{Cells: convertFromASTNodes(row)})
}
return table
}
func convertListKindFromASTNode(node ast.ListKind) v1pb.ListNode_Kind {
switch node {
case ast.OrderedList:
return v1pb.ListNode_ORDERED
case ast.UnorderedList:
return v1pb.ListNode_UNORDERED
case ast.DescriptionList:
return v1pb.ListNode_DESCRIPTION
default:
return v1pb.ListNode_KIND_UNSPECIFIED
}
}
func convertToASTNode(node *v1pb.Node) ast.Node {
switch n := node.Node.(type) {
case *v1pb.Node_LineBreakNode:
return &ast.LineBreak{}
case *v1pb.Node_ParagraphNode:
children := convertToASTNodes(n.ParagraphNode.Children)
return &ast.Paragraph{Children: children}
case *v1pb.Node_CodeBlockNode:
return &ast.CodeBlock{Language: n.CodeBlockNode.Language, Content: n.CodeBlockNode.Content}
case *v1pb.Node_HeadingNode:
children := convertToASTNodes(n.HeadingNode.Children)
return &ast.Heading{Level: int(n.HeadingNode.Level), Children: children}
case *v1pb.Node_HorizontalRuleNode:
return &ast.HorizontalRule{Symbol: n.HorizontalRuleNode.Symbol}
case *v1pb.Node_BlockquoteNode:
children := convertToASTNodes(n.BlockquoteNode.Children)
return &ast.Blockquote{Children: children}
case *v1pb.Node_ListNode:
children := convertToASTNodes(n.ListNode.Children)
return &ast.List{Kind: convertListKindToASTNode(n.ListNode.Kind), Indent: int(n.ListNode.Indent), Children: children}
case *v1pb.Node_OrderedListItemNode:
children := convertToASTNodes(n.OrderedListItemNode.Children)
return &ast.OrderedListItem{Number: n.OrderedListItemNode.Number, Indent: int(n.OrderedListItemNode.Indent), Children: children}
case *v1pb.Node_UnorderedListItemNode:
children := convertToASTNodes(n.UnorderedListItemNode.Children)
return &ast.UnorderedListItem{Symbol: n.UnorderedListItemNode.Symbol, Indent: int(n.UnorderedListItemNode.Indent), Children: children}
case *v1pb.Node_TaskListItemNode:
children := convertToASTNodes(n.TaskListItemNode.Children)
return &ast.TaskListItem{Symbol: n.TaskListItemNode.Symbol, Indent: int(n.TaskListItemNode.Indent), Complete: n.TaskListItemNode.Complete, Children: children}
case *v1pb.Node_MathBlockNode:
return &ast.MathBlock{Content: n.MathBlockNode.Content}
case *v1pb.Node_TableNode:
return convertTableToASTNode(n.TableNode)
case *v1pb.Node_EmbeddedContentNode:
return &ast.EmbeddedContent{ResourceName: n.EmbeddedContentNode.ResourceName, Params: n.EmbeddedContentNode.Params}
case *v1pb.Node_TextNode:
return &ast.Text{Content: n.TextNode.Content}
case *v1pb.Node_BoldNode:
return &ast.Bold{Symbol: n.BoldNode.Symbol, Children: convertToASTNodes(n.BoldNode.Children)}
case *v1pb.Node_ItalicNode:
return &ast.Italic{Symbol: n.ItalicNode.Symbol, Children: convertToASTNodes(n.ItalicNode.Children)}
case *v1pb.Node_BoldItalicNode:
children := []ast.Node{}
if n.BoldItalicNode.Content != "" {
children = append(children, &ast.Text{Content: n.BoldItalicNode.Content})
}
return &ast.BoldItalic{Symbol: n.BoldItalicNode.Symbol, Children: children}
case *v1pb.Node_CodeNode:
return &ast.Code{Content: n.CodeNode.Content}
case *v1pb.Node_ImageNode:
return &ast.Image{AltText: n.ImageNode.AltText, URL: n.ImageNode.Url}
case *v1pb.Node_LinkNode:
return &ast.Link{Content: convertToASTNodes(n.LinkNode.Content), URL: n.LinkNode.Url}
case *v1pb.Node_AutoLinkNode:
return &ast.AutoLink{URL: n.AutoLinkNode.Url, IsRawText: n.AutoLinkNode.IsRawText}
case *v1pb.Node_TagNode:
return &ast.Tag{Content: n.TagNode.Content}
case *v1pb.Node_StrikethroughNode:
return &ast.Strikethrough{Content: n.StrikethroughNode.Content}
case *v1pb.Node_EscapingCharacterNode:
return &ast.EscapingCharacter{Symbol: n.EscapingCharacterNode.Symbol}
case *v1pb.Node_MathNode:
return &ast.Math{Content: n.MathNode.Content}
case *v1pb.Node_HighlightNode:
return &ast.Highlight{Content: n.HighlightNode.Content}
case *v1pb.Node_SubscriptNode:
return &ast.Subscript{Content: n.SubscriptNode.Content}
case *v1pb.Node_SuperscriptNode:
return &ast.Superscript{Content: n.SuperscriptNode.Content}
case *v1pb.Node_ReferencedContentNode:
return &ast.ReferencedContent{ResourceName: n.ReferencedContentNode.ResourceName, Params: n.ReferencedContentNode.Params}
case *v1pb.Node_SpoilerNode:
return &ast.Spoiler{Content: n.SpoilerNode.Content}
case *v1pb.Node_HtmlElementNode:
var children []ast.Node
if len(n.HtmlElementNode.Children) > 0 {
children = convertToASTNodes(n.HtmlElementNode.Children)
}
return &ast.HTMLElement{
TagName: n.HtmlElementNode.TagName,
Attributes: n.HtmlElementNode.Attributes,
Children: children,
IsSelfClosing: n.HtmlElementNode.IsSelfClosing,
}
default:
return &ast.Text{}
}
}
func convertToASTNodes(nodes []*v1pb.Node) []ast.Node {
rawNodes := []ast.Node{}
for _, node := range nodes {
rawNode := convertToASTNode(node)
rawNodes = append(rawNodes, rawNode)
}
return rawNodes
}
func convertToASTDocument(nodes []*v1pb.Node) *ast.Document {
return &ast.Document{Children: convertToASTNodes(nodes)}
}
func convertTableToASTNode(node *v1pb.TableNode) *ast.Table {
table := &ast.Table{
Header: convertToASTNodes(node.Header),
Delimiter: node.Delimiter,
}
for _, row := range node.Rows {
table.Rows = append(table.Rows, convertToASTNodes(row.Cells))
}
return table
}
func convertListKindToASTNode(kind v1pb.ListNode_Kind) ast.ListKind {
switch kind {
case v1pb.ListNode_ORDERED:
return ast.OrderedList
case v1pb.ListNode_UNORDERED:
return ast.UnorderedList
default:
// Default to description list.
return ast.DescriptionList
}
}

View File

@ -122,7 +122,7 @@ func (s *APIV1Service) convertMemoRelationFromStore(ctx context.Context, memoRel
if err != nil { if err != nil {
return nil, err return nil, err
} }
memoSnippet, err := getMemoContentSnippet(memo.Content) memoSnippet, err := s.getMemoContentSnippet(memo.Content)
if err != nil { if err != nil {
return nil, errors.Wrap(err, "failed to get memo content snippet") return nil, errors.Wrap(err, "failed to get memo content snippet")
} }
@ -130,7 +130,7 @@ func (s *APIV1Service) convertMemoRelationFromStore(ctx context.Context, memoRel
if err != nil { if err != nil {
return nil, err return nil, err
} }
relatedMemoSnippet, err := getMemoContentSnippet(relatedMemo.Content) relatedMemoSnippet, err := s.getMemoContentSnippet(relatedMemo.Content)
if err != nil { if err != nil {
return nil, errors.Wrap(err, "failed to get related memo content snippet") return nil, errors.Wrap(err, "failed to get related memo content snippet")
} }

View File

@ -10,9 +10,6 @@ import (
"github.com/lithammer/shortuuid/v4" "github.com/lithammer/shortuuid/v4"
"github.com/pkg/errors" "github.com/pkg/errors"
"github.com/usememos/gomark"
"github.com/usememos/gomark/ast"
"github.com/usememos/gomark/renderer"
"google.golang.org/grpc/codes" "google.golang.org/grpc/codes"
"google.golang.org/grpc/status" "google.golang.org/grpc/status"
"google.golang.org/protobuf/types/known/emptypb" "google.golang.org/protobuf/types/known/emptypb"
@ -53,7 +50,7 @@ func (s *APIV1Service) CreateMemo(ctx context.Context, request *v1pb.CreateMemoR
if len(create.Content) > contentLengthLimit { if len(create.Content) > contentLengthLimit {
return nil, status.Errorf(codes.InvalidArgument, "content too long (max %d characters)", contentLengthLimit) return nil, status.Errorf(codes.InvalidArgument, "content too long (max %d characters)", contentLengthLimit)
} }
if err := memopayload.RebuildMemoPayload(create); err != nil { if err := memopayload.RebuildMemoPayload(create, s.MarkdownService); err != nil {
return nil, status.Errorf(codes.Internal, "failed to rebuild memo payload: %v", err) return nil, status.Errorf(codes.Internal, "failed to rebuild memo payload: %v", err)
} }
if request.Memo.Location != nil { if request.Memo.Location != nil {
@ -338,7 +335,7 @@ func (s *APIV1Service) UpdateMemo(ctx context.Context, request *v1pb.UpdateMemoR
return nil, status.Errorf(codes.InvalidArgument, "content too long (max %d characters)", contentLengthLimit) return nil, status.Errorf(codes.InvalidArgument, "content too long (max %d characters)", contentLengthLimit)
} }
memo.Content = request.Memo.Content memo.Content = request.Memo.Content
if err := memopayload.RebuildMemoPayload(memo); err != nil { if err := memopayload.RebuildMemoPayload(memo, s.MarkdownService); err != nil {
return nil, status.Errorf(codes.Internal, "failed to rebuild memo payload: %v", err) return nil, status.Errorf(codes.Internal, "failed to rebuild memo payload: %v", err)
} }
update.Content = &memo.Content update.Content = &memo.Content
@ -711,17 +708,14 @@ func (s *APIV1Service) RenameMemoTag(ctx context.Context, request *v1pb.RenameMe
} }
for _, memo := range memos { for _, memo := range memos {
doc, err := gomark.Parse(memo.Content) // Rename tag using goldmark
newContent, err := s.MarkdownService.RenameTag([]byte(memo.Content), request.OldTag, request.NewTag)
if err != nil { if err != nil {
return nil, status.Errorf(codes.Internal, "failed to parse memo: %v", err) return nil, status.Errorf(codes.Internal, "failed to rename tag: %v", err)
} }
memopayload.TraverseASTDocument(doc, func(node ast.Node) { memo.Content = newContent
if tag, ok := node.(*ast.Tag); ok && tag.Content == request.OldTag {
tag.Content = request.NewTag if err := memopayload.RebuildMemoPayload(memo, s.MarkdownService); err != nil {
}
})
memo.Content = gomark.Restore(doc)
if err := memopayload.RebuildMemoPayload(memo); err != nil {
return nil, status.Errorf(codes.Internal, "failed to rebuild memo payload: %v", err) return nil, status.Errorf(codes.Internal, "failed to rebuild memo payload: %v", err)
} }
if err := s.Store.UpdateMemo(ctx, &store.UpdateMemo{ if err := s.Store.UpdateMemo(ctx, &store.UpdateMemo{
@ -842,17 +836,13 @@ func convertMemoToWebhookPayload(memo *v1pb.Memo) (*webhook.WebhookRequestPayloa
}, nil }, nil
} }
func getMemoContentSnippet(content string) (string, error) { func (s *APIV1Service) getMemoContentSnippet(content string) (string, error) {
doc, err := gomark.Parse(content) // Use goldmark service for snippet generation
snippet, err := s.MarkdownService.GenerateSnippet([]byte(content), 64)
if err != nil { if err != nil {
return "", errors.Wrap(err, "failed to parse content") return "", errors.Wrap(err, "failed to generate snippet")
} }
return snippet, nil
plainText := renderer.NewStringRenderer().RenderDocument(doc)
if len(plainText) > 64 {
return substring(plainText, 64) + "...", nil
}
return plainText, nil
} }
func substring(s string, length int) string { func substring(s string, length int) string {

View File

@ -8,8 +8,6 @@ import (
"github.com/pkg/errors" "github.com/pkg/errors"
"google.golang.org/protobuf/types/known/timestamppb" "google.golang.org/protobuf/types/known/timestamppb"
"github.com/usememos/gomark"
v1pb "github.com/usememos/memos/proto/gen/api/v1" v1pb "github.com/usememos/memos/proto/gen/api/v1"
storepb "github.com/usememos/memos/proto/gen/store" storepb "github.com/usememos/memos/proto/gen/store"
"github.com/usememos/memos/store" "github.com/usememos/memos/store"
@ -68,15 +66,7 @@ func (s *APIV1Service) convertMemoFromStore(ctx context.Context, memo *store.Mem
memoMessage.Attachments = append(memoMessage.Attachments, attachmentResponse) memoMessage.Attachments = append(memoMessage.Attachments, attachmentResponse)
} }
doc, err := gomark.Parse(memo.Content) snippet, err := s.getMemoContentSnippet(memo.Content)
if err != nil {
return nil, errors.Wrap(err, "failed to parse content")
}
if doc != nil {
memoMessage.Nodes = convertFromASTNodes(doc.Children)
}
snippet, err := getMemoContentSnippet(memo.Content)
if err != nil { if err != nil {
return nil, errors.Wrap(err, "failed to get memo content snippet") return nil, errors.Wrap(err, "failed to get memo content snippet")
} }

View File

@ -5,6 +5,7 @@ import (
"testing" "testing"
"github.com/usememos/memos/internal/profile" "github.com/usememos/memos/internal/profile"
"github.com/usememos/memos/plugin/markdown"
apiv1 "github.com/usememos/memos/server/router/api/v1" apiv1 "github.com/usememos/memos/server/router/api/v1"
"github.com/usememos/memos/store" "github.com/usememos/memos/store"
teststore "github.com/usememos/memos/store/test" teststore "github.com/usememos/memos/store/test"
@ -36,10 +37,15 @@ func NewTestService(t *testing.T) *TestService {
// Create APIV1Service with nil grpcServer since we're testing direct calls // Create APIV1Service with nil grpcServer since we're testing direct calls
secret := "test-secret" secret := "test-secret"
markdownService := markdown.NewService(
markdown.WithTagExtension(),
markdown.WithWikilinkExtension(),
)
service := &apiv1.APIV1Service{ service := &apiv1.APIV1Service{
Secret: secret, Secret: secret,
Profile: testProfile, Profile: testProfile,
Store: testStore, Store: testStore,
MarkdownService: markdownService,
} }
return &TestService{ return &TestService{

View File

@ -15,6 +15,7 @@ import (
"google.golang.org/grpc/reflection" "google.golang.org/grpc/reflection"
"github.com/usememos/memos/internal/profile" "github.com/usememos/memos/internal/profile"
"github.com/usememos/memos/plugin/markdown"
v1pb "github.com/usememos/memos/proto/gen/api/v1" v1pb "github.com/usememos/memos/proto/gen/api/v1"
"github.com/usememos/memos/store" "github.com/usememos/memos/store"
) )
@ -30,23 +31,28 @@ type APIV1Service struct {
v1pb.UnimplementedShortcutServiceServer v1pb.UnimplementedShortcutServiceServer
v1pb.UnimplementedInboxServiceServer v1pb.UnimplementedInboxServiceServer
v1pb.UnimplementedActivityServiceServer v1pb.UnimplementedActivityServiceServer
v1pb.UnimplementedMarkdownServiceServer
v1pb.UnimplementedIdentityProviderServiceServer v1pb.UnimplementedIdentityProviderServiceServer
Secret string Secret string
Profile *profile.Profile Profile *profile.Profile
Store *store.Store Store *store.Store
MarkdownService markdown.Service
grpcServer *grpc.Server grpcServer *grpc.Server
} }
func NewAPIV1Service(secret string, profile *profile.Profile, store *store.Store, grpcServer *grpc.Server) *APIV1Service { func NewAPIV1Service(secret string, profile *profile.Profile, store *store.Store, grpcServer *grpc.Server) *APIV1Service {
grpc.EnableTracing = true grpc.EnableTracing = true
markdownService := markdown.NewService(
markdown.WithTagExtension(),
markdown.WithWikilinkExtension(),
)
apiv1Service := &APIV1Service{ apiv1Service := &APIV1Service{
Secret: secret, Secret: secret,
Profile: profile, Profile: profile,
Store: store, Store: store,
grpcServer: grpcServer, MarkdownService: markdownService,
grpcServer: grpcServer,
} }
grpc_health_v1.RegisterHealthServer(grpcServer, apiv1Service) grpc_health_v1.RegisterHealthServer(grpcServer, apiv1Service)
v1pb.RegisterWorkspaceServiceServer(grpcServer, apiv1Service) v1pb.RegisterWorkspaceServiceServer(grpcServer, apiv1Service)
@ -57,7 +63,6 @@ func NewAPIV1Service(secret string, profile *profile.Profile, store *store.Store
v1pb.RegisterShortcutServiceServer(grpcServer, apiv1Service) v1pb.RegisterShortcutServiceServer(grpcServer, apiv1Service)
v1pb.RegisterInboxServiceServer(grpcServer, apiv1Service) v1pb.RegisterInboxServiceServer(grpcServer, apiv1Service)
v1pb.RegisterActivityServiceServer(grpcServer, apiv1Service) v1pb.RegisterActivityServiceServer(grpcServer, apiv1Service)
v1pb.RegisterMarkdownServiceServer(grpcServer, apiv1Service)
v1pb.RegisterIdentityProviderServiceServer(grpcServer, apiv1Service) v1pb.RegisterIdentityProviderServiceServer(grpcServer, apiv1Service)
reflection.Register(grpcServer) reflection.Register(grpcServer)
return apiv1Service return apiv1Service
@ -109,9 +114,6 @@ func (s *APIV1Service) RegisterGateway(ctx context.Context, echoServer *echo.Ech
if err := v1pb.RegisterActivityServiceHandler(ctx, gwMux, conn); err != nil { if err := v1pb.RegisterActivityServiceHandler(ctx, gwMux, conn); err != nil {
return err return err
} }
if err := v1pb.RegisterMarkdownServiceHandler(ctx, gwMux, conn); err != nil {
return err
}
if err := v1pb.RegisterIdentityProviderServiceHandler(ctx, gwMux, conn); err != nil { if err := v1pb.RegisterIdentityProviderServiceHandler(ctx, gwMux, conn); err != nil {
return err return err
} }

View File

@ -9,10 +9,9 @@ import (
"github.com/gorilla/feeds" "github.com/gorilla/feeds"
"github.com/labstack/echo/v4" "github.com/labstack/echo/v4"
"github.com/usememos/gomark"
"github.com/usememos/gomark/renderer"
"github.com/usememos/memos/internal/profile" "github.com/usememos/memos/internal/profile"
"github.com/usememos/memos/plugin/markdown"
storepb "github.com/usememos/memos/proto/gen/store" storepb "github.com/usememos/memos/proto/gen/store"
"github.com/usememos/memos/store" "github.com/usememos/memos/store"
) )
@ -22,8 +21,9 @@ const (
) )
type RSSService struct { type RSSService struct {
Profile *profile.Profile Profile *profile.Profile
Store *store.Store Store *store.Store
MarkdownService markdown.Service
} }
type RSSHeading struct { type RSSHeading struct {
@ -31,10 +31,11 @@ type RSSHeading struct {
Description string Description string
} }
func NewRSSService(profile *profile.Profile, store *store.Store) *RSSService { func NewRSSService(profile *profile.Profile, store *store.Store, markdownService markdown.Service) *RSSService {
return &RSSService{ return &RSSService{
Profile: profile, Profile: profile,
Store: store, Store: store,
MarkdownService: markdownService,
} }
} }
@ -113,7 +114,7 @@ func (s *RSSService) generateRSSFromMemoList(ctx context.Context, memoList []*st
feed.Items = make([]*feeds.Item, itemCountLimit) feed.Items = make([]*feeds.Item, itemCountLimit)
for i := 0; i < itemCountLimit; i++ { for i := 0; i < itemCountLimit; i++ {
memo := memoList[i] memo := memoList[i]
description, err := getRSSItemDescription(memo.Content) description, err := s.getRSSItemDescription(memo.Content)
if err != nil { if err != nil {
return "", err return "", err
} }
@ -151,13 +152,12 @@ func (s *RSSService) generateRSSFromMemoList(ctx context.Context, memoList []*st
return rss, nil return rss, nil
} }
func getRSSItemDescription(content string) (string, error) { func (s *RSSService) getRSSItemDescription(content string) (string, error) {
doc, err := gomark.Parse(content) html, err := s.MarkdownService.RenderHTML([]byte(content))
if err != nil { if err != nil {
return "", err return "", err
} }
result := renderer.NewHTMLRenderer().RenderDocument(doc) return html, nil
return result, nil
} }
func getRSSHeading(ctx context.Context, stores *store.Store) (RSSHeading, error) { func getRSSHeading(ctx context.Context, stores *store.Store) (RSSHeading, error) {

View File

@ -3,23 +3,23 @@ package memopayload
import ( import (
"context" "context"
"log/slog" "log/slog"
"slices"
"github.com/pkg/errors" "github.com/pkg/errors"
"github.com/usememos/gomark"
"github.com/usememos/gomark/ast"
"github.com/usememos/memos/plugin/markdown"
storepb "github.com/usememos/memos/proto/gen/store" storepb "github.com/usememos/memos/proto/gen/store"
"github.com/usememos/memos/store" "github.com/usememos/memos/store"
) )
type Runner struct { type Runner struct {
Store *store.Store Store *store.Store
MarkdownService markdown.Service
} }
func NewRunner(store *store.Store) *Runner { func NewRunner(store *store.Store, markdownService markdown.Service) *Runner {
return &Runner{ return &Runner{
Store: store, Store: store,
MarkdownService: markdownService,
} }
} }
@ -49,7 +49,7 @@ func (r *Runner) RunOnce(ctx context.Context) {
// Process batch // Process batch
batchSuccessCount := 0 batchSuccessCount := 0
for _, memo := range memos { for _, memo := range memos {
if err := RebuildMemoPayload(memo); err != nil { if err := RebuildMemoPayload(memo, r.MarkdownService); err != nil {
slog.Error("failed to rebuild memo payload", "err", err, "memoID", memo.ID) slog.Error("failed to rebuild memo payload", "err", err, "memoID", memo.ID)
continue continue
} }
@ -71,70 +71,21 @@ func (r *Runner) RunOnce(ctx context.Context) {
} }
} }
func RebuildMemoPayload(memo *store.Memo) error { func RebuildMemoPayload(memo *store.Memo, markdownService markdown.Service) error {
doc, err := gomark.Parse(memo.Content)
if err != nil {
return errors.Wrap(err, "failed to parse content")
}
if memo.Payload == nil { if memo.Payload == nil {
memo.Payload = &storepb.MemoPayload{} memo.Payload = &storepb.MemoPayload{}
} }
tags := []string{}
property := &storepb.MemoPayload_Property{} // Use goldmark service to extract all metadata in a single pass (more efficient)
TraverseASTDocument(doc, func(node ast.Node) { data, err := markdownService.ExtractAll([]byte(memo.Content))
switch n := node.(type) { if err != nil {
case *ast.Tag: return errors.Wrap(err, "failed to extract markdown metadata")
tag := n.Content }
if !slices.Contains(tags, tag) {
tags = append(tags, tag) // Set references in property
} data.Property.References = data.References
case *ast.Link, *ast.AutoLink:
property.HasLink = true memo.Payload.Tags = data.Tags
case *ast.TaskListItem: memo.Payload.Property = data.Property
property.HasTaskList = true
if !n.Complete {
property.HasIncompleteTasks = true
}
case *ast.CodeBlock:
property.HasCode = true
case *ast.EmbeddedContent:
// TODO: validate references.
property.References = append(property.References, n.ResourceName)
}
})
memo.Payload.Tags = tags
memo.Payload.Property = property
return nil return nil
} }
func TraverseASTDocument(doc *ast.Document, fn func(ast.Node)) {
if doc == nil {
return
}
traverseASTNodes(doc.Children, fn)
}
func traverseASTNodes(nodes []ast.Node, fn func(ast.Node)) {
for _, node := range nodes {
fn(node)
switch n := node.(type) {
case *ast.Paragraph:
traverseASTNodes(n.Children, fn)
case *ast.Heading:
traverseASTNodes(n.Children, fn)
case *ast.Blockquote:
traverseASTNodes(n.Children, fn)
case *ast.List:
traverseASTNodes(n.Children, fn)
case *ast.OrderedListItem:
traverseASTNodes(n.Children, fn)
case *ast.UnorderedListItem:
traverseASTNodes(n.Children, fn)
case *ast.TaskListItem:
traverseASTNodes(n.Children, fn)
case *ast.Bold:
traverseASTNodes(n.Children, fn)
}
}
}

View File

@ -81,9 +81,6 @@ func NewServer(ctx context.Context, profile *profile.Profile, store *store.Store
rootGroup := echoServer.Group("") rootGroup := echoServer.Group("")
// Create and register RSS routes.
rss.NewRSSService(s.Profile, s.Store).RegisterRoutes(rootGroup)
// Log full stacktraces if we're in dev // Log full stacktraces if we're in dev
logStacktraces := profile.IsDev() logStacktraces := profile.IsDev()
@ -98,6 +95,9 @@ func NewServer(ctx context.Context, profile *profile.Profile, store *store.Store
s.grpcServer = grpcServer s.grpcServer = grpcServer
apiV1Service := apiv1.NewAPIV1Service(s.Secret, profile, store, grpcServer) apiV1Service := apiv1.NewAPIV1Service(s.Secret, profile, store, grpcServer)
// Create and register RSS routes (needs markdown service from apiV1Service).
rss.NewRSSService(s.Profile, s.Store, apiV1Service.MarkdownService).RegisterRoutes(rootGroup)
// Register gRPC gateway as api v1. // Register gRPC gateway as api v1.
if err := apiV1Service.RegisterGateway(ctx, echoServer); err != nil { if err := apiV1Service.RegisterGateway(ctx, echoServer); err != nil {
return nil, errors.Wrap(err, "failed to register gRPC gateway") return nil, errors.Wrap(err, "failed to register gRPC gateway")

View File

@ -50,12 +50,17 @@
"react-hot-toast": "^2.6.0", "react-hot-toast": "^2.6.0",
"react-i18next": "^15.7.3", "react-i18next": "^15.7.3",
"react-leaflet": "^4.2.1", "react-leaflet": "^4.2.1",
"react-markdown": "^10.1.0",
"react-router-dom": "^7.9.1", "react-router-dom": "^7.9.1",
"react-simple-pull-to-refresh": "^1.3.3", "react-simple-pull-to-refresh": "^1.3.3",
"react-use": "^17.6.0", "react-use": "^17.6.0",
"rehype-raw": "^7.0.0",
"remark-gfm": "^4.0.1",
"remark-wiki-link": "^2.0.1",
"tailwind-merge": "^3.3.1", "tailwind-merge": "^3.3.1",
"tailwindcss": "^4.1.13", "tailwindcss": "^4.1.13",
"textarea-caret": "^3.1.0", "textarea-caret": "^3.1.0",
"unist-util-visit": "^5.0.0",
"uuid": "^11.1.0" "uuid": "^11.1.0"
}, },
"devDependencies": { "devDependencies": {
@ -66,14 +71,15 @@
"@types/katex": "^0.16.7", "@types/katex": "^0.16.7",
"@types/leaflet": "^1.9.20", "@types/leaflet": "^1.9.20",
"@types/lodash-es": "^4.17.12", "@types/lodash-es": "^4.17.12",
"@types/mdast": "^4.0.4",
"@types/node": "^24.5.1", "@types/node": "^24.5.1",
"@types/qs": "^6.14.0", "@types/qs": "^6.14.0",
"@types/react": "^18.3.24", "@types/react": "^18.3.24",
"@types/react-dom": "^18.3.7", "@types/react-dom": "^18.3.7",
"@types/textarea-caret": "^3.0.4", "@types/textarea-caret": "^3.0.4",
"@types/unist": "^3.0.3",
"@types/uuid": "^10.0.0", "@types/uuid": "^10.0.0",
"@vitejs/plugin-react": "^4.7.0", "@vitejs/plugin-react": "^4.7.0",
"code-inspector-plugin": "^1.2.10",
"eslint": "^9.35.0", "eslint": "^9.35.0",
"eslint-config-prettier": "^10.1.8", "eslint-config-prettier": "^10.1.8",
"eslint-plugin-prettier": "^5.5.4", "eslint-plugin-prettier": "^5.5.4",

File diff suppressed because it is too large Load Diff

View File

@ -17,14 +17,13 @@ import { useState } from "react";
import toast from "react-hot-toast"; import toast from "react-hot-toast";
import { useLocation } from "react-router-dom"; import { useLocation } from "react-router-dom";
import ConfirmDialog from "@/components/ConfirmDialog"; import ConfirmDialog from "@/components/ConfirmDialog";
import { markdownServiceClient } from "@/grpcweb";
import useNavigateTo from "@/hooks/useNavigateTo"; import useNavigateTo from "@/hooks/useNavigateTo";
import { memoStore, userStore } from "@/store"; import { memoStore, userStore } from "@/store";
import { workspaceStore } from "@/store"; import { workspaceStore } from "@/store";
import { State } from "@/types/proto/api/v1/common"; import { State } from "@/types/proto/api/v1/common";
import { NodeType } from "@/types/proto/api/v1/markdown_service";
import { Memo } from "@/types/proto/api/v1/memo_service"; import { Memo } from "@/types/proto/api/v1/memo_service";
import { useTranslate } from "@/utils/i18n"; import { useTranslate } from "@/utils/i18n";
import { hasCompletedTasks, removeCompletedTasks } from "@/utils/markdown-manipulation";
import { Button } from "./ui/button"; import { Button } from "./ui/button";
import { import {
DropdownMenu, DropdownMenu,
@ -44,16 +43,7 @@ interface Props {
} }
const checkHasCompletedTaskList = (memo: Memo) => { const checkHasCompletedTaskList = (memo: Memo) => {
for (const node of memo.nodes) { return hasCompletedTasks(memo.content);
if (node.type === NodeType.LIST && node.listNode?.children && node.listNode?.children?.length > 0) {
for (let j = 0; j < node.listNode.children.length; j++) {
if (node.listNode.children[j].type === NodeType.TASK_LIST_ITEM && node.listNode.children[j].taskListItemNode?.complete) {
return true;
}
}
}
}
return false;
}; };
const MemoActionMenu = observer((props: Props) => { const MemoActionMenu = observer((props: Props) => {
@ -160,27 +150,11 @@ const MemoActionMenu = observer((props: Props) => {
}; };
const confirmRemoveCompletedTaskListItems = async () => { const confirmRemoveCompletedTaskListItems = async () => {
const newNodes = JSON.parse(JSON.stringify(memo.nodes)); const newContent = removeCompletedTasks(memo.content);
for (const node of newNodes) {
if (node.type === NodeType.LIST && node.listNode?.children?.length > 0) {
const children = node.listNode.children;
for (let i = 0; i < children.length; i++) {
if (children[i].type === NodeType.TASK_LIST_ITEM && children[i].taskListItemNode?.complete) {
// Remove completed taskList item and next line breaks
children.splice(i, 1);
if (children[i]?.type === NodeType.LINE_BREAK) {
children.splice(i, 1);
}
i--;
}
}
}
}
const { markdown } = await markdownServiceClient.restoreMarkdownNodes({ nodes: newNodes });
await memoStore.updateMemo( await memoStore.updateMemo(
{ {
name: memo.name, name: memo.name,
content: markdown, content: newContent,
}, },
["content"], ["content"],
); );

View File

@ -1,19 +0,0 @@
import { Node } from "@/types/proto/api/v1/markdown_service";
import Renderer from "./Renderer";
import { BaseProps } from "./types";
interface Props extends BaseProps {
children: Node[];
}
const Blockquote: React.FC<Props> = ({ children }: Props) => {
return (
<blockquote className="p-2 border-l-4 rounded border-border bg-muted/50 text-muted-foreground">
{children.map((child, index) => (
<Renderer key={`${child.type}-${index}`} index={String(index)} node={child} />
))}
</blockquote>
);
};
export default Blockquote;

View File

@ -1,19 +0,0 @@
import { Node } from "@/types/proto/api/v1/markdown_service";
import Renderer from "./Renderer";
interface Props {
symbol: string;
children: Node[];
}
const Bold: React.FC<Props> = ({ children }: Props) => {
return (
<strong>
{children.map((child, index) => (
<Renderer key={`${child.type}-${index}`} index={String(index)} node={child} />
))}
</strong>
);
};
export default Bold;

View File

@ -1,14 +0,0 @@
interface Props {
symbol: string;
content: string;
}
const BoldItalic: React.FC<Props> = ({ content }: Props) => {
return (
<strong>
<em>{content}</em>
</strong>
);
};
export default BoldItalic;

View File

@ -1,9 +0,0 @@
interface Props {
content: string;
}
const Code: React.FC<Props> = ({ content }: Props) => {
return <code className="inline break-all px-1 font-mono text-sm rounded bg-muted text-muted-foreground">{content}</code>;
};
export default Code;

View File

@ -1,163 +0,0 @@
import copy from "copy-to-clipboard";
import DOMPurify from "dompurify";
import hljs from "highlight.js";
import { CopyIcon } from "lucide-react";
import { observer } from "mobx-react-lite";
import { useEffect, useMemo } from "react";
import toast from "react-hot-toast";
import { cn } from "@/lib/utils";
import { workspaceStore } from "@/store";
import MermaidBlock from "./MermaidBlock";
import { BaseProps } from "./types";
// Special languages that are rendered differently.
enum SpecialLanguage {
HTML = "__html",
MERMAID = "mermaid",
}
interface Props extends BaseProps {
language: string;
content: string;
}
const CodeBlock: React.FC<Props> = ({ language, content }: Props) => {
const formatedLanguage = useMemo(() => (language || "").toLowerCase() || "text", [language]);
// Users can set Markdown code blocks as `__html` to render HTML directly.
// Content is sanitized to prevent XSS attacks while preserving safe HTML.
if (formatedLanguage === SpecialLanguage.HTML) {
const sanitizedHTML = DOMPurify.sanitize(content, {
// Allow common safe HTML tags and attributes
ALLOWED_TAGS: [
"div",
"span",
"p",
"br",
"strong",
"b",
"em",
"i",
"u",
"s",
"strike",
"h1",
"h2",
"h3",
"h4",
"h5",
"h6",
"blockquote",
"code",
"pre",
"ul",
"ol",
"li",
"dl",
"dt",
"dd",
"table",
"thead",
"tbody",
"tr",
"th",
"td",
"a",
"img",
"figure",
"figcaption",
"hr",
"small",
"sup",
"sub",
],
ALLOWED_ATTR: "href title alt src width height class id style target rel colspan rowspan".split(" "),
// Forbid dangerous attributes and tags
FORBID_ATTR: "onerror onload onclick onmouseover onfocus onblur onchange".split(" "),
FORBID_TAGS: "script iframe object embed form input button".split(" "),
});
return (
<div
className="w-full overflow-auto my-2!"
dangerouslySetInnerHTML={{
__html: sanitizedHTML,
}}
/>
);
} else if (formatedLanguage === SpecialLanguage.MERMAID) {
return <MermaidBlock content={content} />;
}
const appTheme = workspaceStore.state.theme;
const isDarkTheme = appTheme.includes("dark");
useEffect(() => {
const dynamicImportStyle = async () => {
// Remove any existing highlight.js style
const existingStyle = document.querySelector("style[data-hljs-theme]");
if (existingStyle) {
existingStyle.remove();
}
try {
const cssModule = isDarkTheme
? await import("highlight.js/styles/github-dark-dimmed.css?inline")
: await import("highlight.js/styles/github.css?inline");
// Create and inject the style
const style = document.createElement("style");
style.textContent = cssModule.default;
style.setAttribute("data-hljs-theme", isDarkTheme ? "dark" : "light");
document.head.appendChild(style);
} catch (error) {
console.warn("Failed to load highlight.js theme:", error);
}
};
dynamicImportStyle();
}, [appTheme, isDarkTheme]);
const highlightedCode = useMemo(() => {
try {
const lang = hljs.getLanguage(formatedLanguage);
if (lang) {
return hljs.highlight(content, {
language: formatedLanguage,
}).value;
}
} catch {
// Skip error and use default highlighted code.
}
// Escape any HTML entities when rendering original content.
return Object.assign(document.createElement("span"), {
textContent: content,
}).innerHTML;
}, [formatedLanguage, content]);
const copyContent = () => {
copy(content);
toast.success("Copied to clipboard!");
};
return (
<div className="w-full my-1 bg-card border border-border rounded-md relative">
<div className="w-full px-2 py-0.5 flex flex-row justify-between items-center text-muted-foreground">
<span className="text-xs font-mono">{formatedLanguage}</span>
<CopyIcon className="w-3 h-auto cursor-pointer hover:text-foreground" onClick={copyContent} />
</div>
<div className="overflow-auto">
<pre className={cn("no-wrap overflow-auto", "w-full p-2 bg-muted/50 relative")}>
<code
className={cn(`language-${formatedLanguage}`, "block text-sm leading-5 text-foreground")}
dangerouslySetInnerHTML={{ __html: highlightedCode }}
></code>
</pre>
</div>
</div>
);
};
export default observer(CodeBlock);

View File

@ -0,0 +1,63 @@
import React from "react";
/**
* Creates a conditional component wrapper that checks AST node properties
* before deciding which component to render.
*
* This is more efficient than having every component check its own props,
* and allows us to use specific HTML element types as defaults.
*
* @param CustomComponent - Component to render when condition is met
* @param DefaultComponent - Component/element to render otherwise
* @param condition - Function to check if node matches custom component criteria
*/
export const createConditionalComponent = <P extends Record<string, any>>(
CustomComponent: React.ComponentType<P>,
DefaultComponent: React.ComponentType<P> | keyof JSX.IntrinsicElements,
condition: (node: any) => boolean,
) => {
return (props: P & { node?: any }) => {
const { node, ...restProps } = props;
// Check AST node to determine which component to use
if (node && condition(node)) {
return <CustomComponent {...(restProps as P)} node={node} />;
}
// Render default component/element
if (typeof DefaultComponent === "string") {
return React.createElement(DefaultComponent, restProps);
}
return <DefaultComponent {...(restProps as P)} />;
};
};
/**
* Condition checkers for AST node types
*
* These check the original MDAST node type preserved during transformation:
* - First checks node.data.mdastType (preserved by remarkPreserveType plugin)
* - Falls back to checking HAST properties/className for compatibility
*/
export const isWikiLinkNode = (node: any): boolean => {
// Check preserved mdast type first
if (node?.data?.mdastType === "wikiLink") {
return true;
}
// Fallback: check hast properties
return node?.properties?.className?.includes?.("wikilink") || false;
};
export const isTagNode = (node: any): boolean => {
// Check preserved mdast type first
if (node?.data?.mdastType === "tagNode") {
return true;
}
// Fallback: check hast properties
return node?.properties?.className?.includes?.("tag") || false;
};
export const isTaskListItemNode = (node: any): boolean => {
// Task list checkboxes are standard GFM - check element type
return node?.properties?.type === "checkbox" || false;
};

View File

@ -0,0 +1,30 @@
import React from "react";
/**
* Default link component for regular markdown links
*
* Handles external links with proper target and rel attributes.
*/
interface DefaultLinkProps extends React.AnchorHTMLAttributes<HTMLAnchorElement> {
node?: any; // AST node from react-markdown
href?: string;
children?: React.ReactNode;
}
export const DefaultLink: React.FC<DefaultLinkProps> = ({ href, children, ...props }) => {
const isExternal = href?.startsWith("http://") || href?.startsWith("https://");
return (
<a
{...props}
href={href}
className="text-primary hover:opacity-80 transition-colors underline"
target={isExternal ? "_blank" : undefined}
rel={isExternal ? "noopener noreferrer" : undefined}
onClick={(e) => e.stopPropagation()}
>
{children}
</a>
);
};

View File

@ -1,62 +0,0 @@
import { observer } from "mobx-react-lite";
import { useEffect } from "react";
import MemoAttachmentListView from "@/components/MemoAttachmentListView";
import useLoading from "@/hooks/useLoading";
import { cn } from "@/lib/utils";
import { attachmentStore } from "@/store";
import Error from "./Error";
interface Props {
resourceId: string;
params: string;
}
const getAdditionalClassNameWithParams = (params: URLSearchParams) => {
const additionalClassNames = [];
if (params.has("align")) {
const align = params.get("align");
if (align === "center") {
additionalClassNames.push("mx-auto");
}
}
if (params.has("size")) {
const size = params.get("size");
if (size === "lg") {
additionalClassNames.push("w-full");
} else if (size === "md") {
additionalClassNames.push("w-2/3");
} else if (size === "sm") {
additionalClassNames.push("w-1/3");
}
}
if (params.has("width")) {
const width = params.get("width");
additionalClassNames.push(`w-[${width}]`);
}
return additionalClassNames.join(" ");
};
const EmbeddedAttachment = observer(({ resourceId: uid, params: paramsStr }: Props) => {
const loadingState = useLoading();
const attachment = attachmentStore.getAttachmentByName(uid);
const params = new URLSearchParams(paramsStr);
useEffect(() => {
attachmentStore.fetchAttachmentByName(`attachments/${uid}`).finally(() => loadingState.setFinish());
}, [uid]);
if (loadingState.isLoading) {
return null;
}
if (!attachment) {
return <Error message={`Attachment not found: ${uid}`} />;
}
return (
<div className={cn("max-w-full", getAdditionalClassNameWithParams(params))}>
<MemoAttachmentListView attachments={[attachment]} />
</div>
);
});
export default EmbeddedAttachment;

View File

@ -1,97 +0,0 @@
import copy from "copy-to-clipboard";
import { ArrowUpRightIcon } from "lucide-react";
import { observer } from "mobx-react-lite";
import { useContext, useEffect } from "react";
import toast from "react-hot-toast";
import { Link } from "react-router-dom";
import MemoAttachmentListView from "@/components/MemoAttachmentListView";
import useLoading from "@/hooks/useLoading";
import { cn } from "@/lib/utils";
import { memoStore } from "@/store";
import { extractMemoIdFromName } from "@/store/common";
import MemoContent from "..";
import { RendererContext } from "../types";
import Error from "./Error";
interface Props {
resourceId: string;
params: string;
}
const EmbeddedMemo = observer(({ resourceId: uid, params: paramsStr }: Props) => {
const context = useContext(RendererContext);
const loadingState = useLoading();
const memoName = `memos/${uid}`;
const memo = memoStore.getMemoByName(memoName);
useEffect(() => {
memoStore.getOrFetchMemoByName(memoName).finally(() => loadingState.setFinish());
}, [memoName]);
if (loadingState.isLoading) {
return null;
}
if (!memo) {
return <Error message={`Memo not found: ${uid}`} />;
}
const params = new URLSearchParams(paramsStr);
const useSnippet = params.has("snippet");
const inlineMode = params.has("inline");
if (!useSnippet && (memo.name === context.memoName || context.embeddedMemos.has(memoName))) {
return <Error message={`Nested Rendering Error: ![[${memoName}]]`} />;
}
// Add the memo to the set of embedded memos. This is used to prevent infinite loops when a memo embeds itself.
context.embeddedMemos.add(memoName);
const contentNode = useSnippet ? (
<div className={cn("text-muted-foreground", inlineMode ? "" : "line-clamp-3")}>{memo.snippet}</div>
) : (
<>
<MemoContent
contentClassName={inlineMode ? "" : "line-clamp-3"}
memoName={memo.name}
nodes={memo.nodes}
embeddedMemos={context.embeddedMemos}
/>
<MemoAttachmentListView attachments={memo.attachments} />
</>
);
if (inlineMode) {
return <div className="w-full">{contentNode}</div>;
}
const copyMemoUid = (uid: string) => {
copy(uid);
toast.success("Copied memo UID to clipboard");
};
return (
<div className="relative flex flex-col justify-start items-start w-full px-3 py-2 bg-card rounded-lg border border-border hover:shadow-md transition-shadow">
<div className="w-full mb-1 flex flex-row justify-between items-center text-muted-foreground">
<div className="text-sm leading-5 select-none">
<relative-time datetime={memo.displayTime?.toISOString()} format="datetime"></relative-time>
</div>
<div className="flex justify-end items-center gap-1">
<span
className="text-xs text-muted-foreground leading-5 cursor-pointer hover:text-foreground"
onClick={() => copyMemoUid(extractMemoIdFromName(memo.name))}
>
{extractMemoIdFromName(memo.name).slice(0, 6)}
</span>
<Link
className="text-muted-foreground hover:text-foreground"
to={`/${memo.name}`}
state={{ from: context.parentPage }}
viewTransition
>
<ArrowUpRightIcon className="w-5 h-auto" />
</Link>
</div>
</div>
{contentNode}
</div>
);
});
export default EmbeddedMemo;

View File

@ -1,9 +0,0 @@
interface Props {
message: string;
}
const Error = ({ message }: Props) => {
return <p className="font-mono text-sm text-destructive">{message}</p>;
};
export default Error;

View File

@ -1,25 +0,0 @@
import EmbeddedAttachment from "./EmbeddedAttachment";
import EmbeddedMemo from "./EmbeddedMemo";
import Error from "./Error";
interface Props {
resourceName: string;
params: string;
}
const extractResourceTypeAndId = (resourceName: string) => {
const [resourceType, resourceId] = resourceName.split("/");
return { resourceType, resourceId };
};
const EmbeddedContent = ({ resourceName, params }: Props) => {
const { resourceType, resourceId } = extractResourceTypeAndId(resourceName);
if (resourceType === "memos") {
return <EmbeddedMemo resourceId={resourceId} params={params} />;
} else if (resourceType === "attachments") {
return <EmbeddedAttachment resourceId={resourceId} params={params} />;
}
return <Error message={`Unknown resource: ${resourceName}`} />;
};
export default EmbeddedContent;

View File

@ -1,9 +0,0 @@
interface Props {
symbol: string;
}
const EscapingCharacter: React.FC<Props> = ({ symbol }: Props) => {
return <span>{symbol}</span>;
};
export default EscapingCharacter;

View File

@ -1,24 +0,0 @@
import { createElement } from "react";
import { Node } from "@/types/proto/api/v1/markdown_service";
import Renderer from "./Renderer";
interface Props {
tagName: string;
attributes: { [key: string]: string };
children: Node[];
isSelfClosing: boolean;
}
const HTMLElement: React.FC<Props> = ({ tagName, attributes, children, isSelfClosing }: Props) => {
if (isSelfClosing) {
return createElement(tagName, attributes);
}
return createElement(
tagName,
attributes,
children.map((child, index) => <Renderer key={`${child.type}-${index}`} index={String(index)} node={child} />),
);
};
export default HTMLElement;

View File

@ -1,34 +0,0 @@
import { Node } from "@/types/proto/api/v1/markdown_service";
import Renderer from "./Renderer";
import { BaseProps } from "./types";
interface Props extends BaseProps {
level: number;
children: Node[];
}
const Heading: React.FC<Props> = ({ level, children }: Props) => {
const Head = `h${level}` as keyof JSX.IntrinsicElements;
const className = (() => {
switch (level) {
case 1:
return "text-5xl leading-normal font-bold";
case 2:
return "text-3xl leading-normal font-medium";
case 3:
return "text-xl leading-normal font-medium";
case 4:
return "text-lg font-bold";
}
})();
return (
<Head className={className}>
{children.map((child, index) => (
<Renderer key={`${child.type}-${index}`} index={String(index)} node={child} />
))}
</Head>
);
};
export default Heading;

View File

@ -1,9 +0,0 @@
interface Props {
content: string;
}
const Highlight: React.FC<Props> = ({ content }: Props) => {
return <mark className="bg-accent text-accent-foreground px-1 rounded">{content}</mark>;
};
export default Highlight;

View File

@ -1,12 +0,0 @@
import { Separator } from "@/components/ui/separator";
import { BaseProps } from "./types";
interface Props extends BaseProps {
symbol: string;
}
const HorizontalRule: React.FC<Props> = () => {
return <Separator className="my-3!" />;
};
export default HorizontalRule;

View File

@ -1,10 +0,0 @@
interface Props {
altText: string;
url: string;
}
const Image: React.FC<Props> = ({ altText, url }: Props) => {
return <img className="rounded" src={url} alt={altText} decoding="async" loading="lazy" />;
};
export default Image;

View File

@ -1,19 +0,0 @@
import { Node } from "@/types/proto/api/v1/markdown_service";
import Renderer from "./Renderer";
interface Props {
symbol: string;
children: Node[];
}
const Italic: React.FC<Props> = ({ children }: Props) => {
return (
<em>
{children.map((child, index) => (
<Renderer key={index} index={index.toString()} node={child} />
))}
</em>
);
};
export default Italic;

View File

@ -1,5 +0,0 @@
const LineBreak = () => {
return <br />;
};
export default LineBreak;

View File

@ -1,82 +0,0 @@
import { useState } from "react";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { markdownServiceClient } from "@/grpcweb";
import { workspaceStore } from "@/store";
import { LinkMetadata, Node } from "@/types/proto/api/v1/markdown_service";
import Renderer from "./Renderer";
interface Props {
url: string;
content?: Node[];
}
const getFaviconWithGoogleS2 = (url: string) => {
try {
const urlObject = new URL(url);
return `https://www.google.com/s2/favicons?sz=128&domain=${urlObject.hostname}`;
} catch {
return undefined;
}
};
const Link: React.FC<Props> = ({ content, url }: Props) => {
const workspaceMemoRelatedSetting = workspaceStore.state.memoRelatedSetting;
const [initialized, setInitialized] = useState<boolean>(false);
const [showTooltip, setShowTooltip] = useState<boolean>(false);
const [linkMetadata, setLinkMetadata] = useState<LinkMetadata | undefined>();
const handleMouseEnter = async () => {
if (!workspaceMemoRelatedSetting.enableLinkPreview) {
return;
}
setShowTooltip(true);
if (!initialized) {
try {
const linkMetadata = await markdownServiceClient.getLinkMetadata({ link: url });
setLinkMetadata(linkMetadata);
} catch (error) {
console.error("Error fetching URL metadata:", error);
}
setInitialized(true);
}
};
return (
<TooltipProvider>
<Tooltip open={showTooltip}>
<TooltipTrigger asChild>
<a
className="underline text-primary hover:opacity-80 transition-all"
target="_blank"
href={url}
rel="noopener noreferrer"
onMouseEnter={handleMouseEnter}
onMouseLeave={() => setShowTooltip(false)}
onClick={(e) => e.stopPropagation()}
>
{content ? content.map((child, index) => <Renderer key={`${child.type}-${index}`} index={String(index)} node={child} />) : url}
</a>
</TooltipTrigger>
{linkMetadata && (
<TooltipContent className="w-full max-w-64 sm:max-w-96 p-1">
<div className="w-full flex flex-col">
<div className="w-full flex flex-row justify-start items-center gap-1">
<img className="w-5 h-5 rounded" src={getFaviconWithGoogleS2(url)} alt={linkMetadata?.title} />
<h3 className="text-base truncate">{linkMetadata?.title}</h3>
</div>
{linkMetadata.description && (
<p className="mt-1 w-full text-sm leading-snug opacity-80 line-clamp-3">{linkMetadata.description}</p>
)}
{linkMetadata.image && (
<img className="mt-1 w-full h-32 object-cover rounded" src={linkMetadata.image} alt={linkMetadata.title} />
)}
</div>
</TooltipContent>
)}
</Tooltip>
</TooltipProvider>
);
};
export default Link;

View File

@ -1,63 +0,0 @@
import { head } from "lodash-es";
import React from "react";
import { cn } from "@/lib/utils";
import { ListNode_Kind, Node, NodeType } from "@/types/proto/api/v1/markdown_service";
import Renderer from "./Renderer";
interface Props {
index: string;
kind: ListNode_Kind;
indent: number;
children: Node[];
}
const List: React.FC<Props> = ({ kind, indent, children }: Props) => {
let prevNode: Node | null = null;
let skipNextLineBreakFlag = false;
const getListContainer = () => {
switch (kind) {
case ListNode_Kind.ORDERED:
return "ol";
case ListNode_Kind.UNORDERED:
return "ul";
case ListNode_Kind.DESCRIPTION:
return "dl";
default:
return "div";
}
};
const getAttributes = () => {
const attrs: any = {
style: { paddingLeft: `${indent > 0 ? indent * 10 : 20}px` },
};
const firstChild = head(children);
if (firstChild?.type === NodeType.ORDERED_LIST_ITEM) {
attrs.start = firstChild.orderedListItemNode?.number;
} else if (firstChild?.type === NodeType.TASK_LIST_ITEM) {
attrs.style = { paddingLeft: `${indent * 8}px` };
}
return attrs;
};
return React.createElement(
getListContainer(),
{
className: cn(kind === ListNode_Kind.ORDERED ? "list-decimal" : kind === ListNode_Kind.UNORDERED ? "list-disc" : "list-none"),
...getAttributes(),
},
children.map((child, index) => {
if (prevNode?.type !== NodeType.LINE_BREAK && child.type === NodeType.LINE_BREAK && skipNextLineBreakFlag) {
skipNextLineBreakFlag = false;
return null;
}
prevNode = child;
skipNextLineBreakFlag = true;
return <Renderer key={`${child.type}-${index}`} index={String(index)} node={child} />;
}),
);
};
export default List;

View File

@ -1,14 +0,0 @@
import TeX from "@matejmazur/react-katex";
import { cn } from "@/lib/utils";
import "katex/dist/katex.min.css";
interface Props {
content: string;
block?: boolean;
}
const Math: React.FC<Props> = ({ content, block }: Props) => {
return <TeX className={cn("max-w-full", block ? "w-full block" : "inline text-sm")} block={block} math={content}></TeX>;
};
export default Math;

View File

@ -0,0 +1,27 @@
import { createContext } from "react";
/**
* Context for MemoContent rendering
*
* Provides memo metadata and configuration to child components
* Used by custom react-markdown components (TaskListItem, WikiLink, Tag, etc.)
*/
export interface MemoContentContextType {
/** The memo resource name (e.g., "memos/123") */
memoName?: string;
/** Whether content is readonly (non-editable) */
readonly: boolean;
/** Whether to disable tag/link filtering */
disableFilter?: boolean;
/** Parent page path (for navigation) */
parentPage?: string;
}
export const MemoContentContext = createContext<MemoContentContextType>({
readonly: true,
disableFilter: false,
});

View File

@ -1,34 +0,0 @@
import { useEffect, useRef } from "react";
interface Props {
content: string;
}
const MermaidBlock: React.FC<Props> = ({ content }: Props) => {
const mermaidDockBlock = useRef<null>(null);
useEffect(() => {
const initializeMermaid = async () => {
const mermaid = (await import("mermaid")).default;
mermaid.initialize({ startOnLoad: false, theme: "default" });
if (mermaidDockBlock.current) {
mermaid.run({
nodes: [mermaidDockBlock.current],
});
}
};
initializeMermaid();
}, [content]);
return (
<pre
ref={mermaidDockBlock}
className="w-full p-2 whitespace-pre-wrap relative bg-card border border-border rounded text-card-foreground"
>
{content}
</pre>
);
};
export default MermaidBlock;

View File

@ -1,21 +0,0 @@
import { Node } from "@/types/proto/api/v1/markdown_service";
import Renderer from "./Renderer";
import { BaseProps } from "./types";
interface Props extends BaseProps {
number: string;
indent: number;
children: Node[];
}
const OrderedListItem: React.FC<Props> = ({ children }: Props) => {
return (
<li>
{children.map((child, index) => (
<Renderer key={`${child.type}-${index}`} index={String(index)} node={child} />
))}
</li>
);
};
export default OrderedListItem;

View File

@ -1,19 +0,0 @@
import { Node } from "@/types/proto/api/v1/markdown_service";
import Renderer from "./Renderer";
import { BaseProps } from "./types";
interface Props extends BaseProps {
children: Node[];
}
const Paragraph: React.FC<Props> = ({ children }: Props) => {
return (
<p>
{children.map((child, index) => (
<Renderer key={`${child.type}-${index}`} index={String(index)} node={child} />
))}
</p>
);
};
export default Paragraph;

View File

@ -1,9 +0,0 @@
interface Props {
message: string;
}
const Error = ({ message }: Props) => {
return <p className="font-mono text-sm text-destructive">{message}</p>;
};
export default Error;

View File

@ -1,55 +0,0 @@
import { observer } from "mobx-react-lite";
import { useContext, useEffect } from "react";
import useLoading from "@/hooks/useLoading";
import useNavigateTo from "@/hooks/useNavigateTo";
import { memoStore } from "@/store";
import { memoNamePrefix } from "@/store/common";
import { RendererContext } from "../types";
import Error from "./Error";
interface Props {
resourceId: string;
params: string;
}
const ReferencedMemo = observer(({ resourceId: uid, params: paramsStr }: Props) => {
const navigateTo = useNavigateTo();
const loadingState = useLoading();
const memoName = `${memoNamePrefix}${uid}`;
const memo = memoStore.getMemoByName(memoName);
const params = new URLSearchParams(paramsStr);
const context = useContext(RendererContext);
useEffect(() => {
memoStore.getOrFetchMemoByName(memoName).finally(() => loadingState.setFinish());
}, [memoName]);
if (loadingState.isLoading) {
return null;
}
if (!memo) {
return <Error message={`Memo not found: ${uid}`} />;
}
const paramsText = params.has("text") ? params.get("text") : undefined;
const displayContent = paramsText || (memo.snippet.length > 12 ? `${memo.snippet.slice(0, 12)}...` : memo.snippet);
const handleGotoMemoDetailPage = () => {
navigateTo(`/${memo.name}`, {
state: {
from: context.parentPage,
},
});
};
return (
<span
className="text-primary whitespace-nowrap cursor-pointer underline break-all hover:text-primary/80 decoration-1"
onClick={handleGotoMemoDetailPage}
>
{displayContent}
</span>
);
});
export default ReferencedMemo;

View File

@ -1,22 +0,0 @@
import Error from "./Error";
import ReferencedMemo from "./ReferencedMemo";
interface Props {
resourceName: string;
params: string;
}
const extractResourceTypeAndId = (resourceName: string) => {
const [resourceType, resourceId] = resourceName.split("/");
return { resourceType, resourceId };
};
const ReferencedContent = ({ resourceName, params }: Props) => {
const { resourceType, resourceId } = extractResourceTypeAndId(resourceName);
if (resourceType === "memos") {
return <ReferencedMemo resourceId={resourceId} params={params} />;
}
return <Error message={`Unknown resource: ${resourceName}`} />;
};
export default ReferencedContent;

View File

@ -1,139 +0,0 @@
import {
AutoLinkNode,
BlockquoteNode,
BoldItalicNode,
BoldNode,
CodeBlockNode,
CodeNode,
EmbeddedContentNode,
EscapingCharacterNode,
HeadingNode,
HighlightNode,
HorizontalRuleNode,
HTMLElementNode,
ImageNode,
ItalicNode,
LinkNode,
ListNode,
MathBlockNode,
MathNode,
Node,
NodeType,
OrderedListItemNode,
ParagraphNode,
ReferencedContentNode,
SpoilerNode,
StrikethroughNode,
SubscriptNode,
SuperscriptNode,
TableNode,
TagNode,
TaskListItemNode,
TextNode,
UnorderedListItemNode,
} from "@/types/proto/api/v1/markdown_service";
import Blockquote from "./Blockquote";
import Bold from "./Bold";
import BoldItalic from "./BoldItalic";
import Code from "./Code";
import CodeBlock from "./CodeBlock";
import EmbeddedContent from "./EmbeddedContent";
import EscapingCharacter from "./EscapingCharacter";
import HTMLElement from "./HTMLElement";
import Heading from "./Heading";
import Highlight from "./Highlight";
import HorizontalRule from "./HorizontalRule";
import Image from "./Image";
import Italic from "./Italic";
import LineBreak from "./LineBreak";
import Link from "./Link";
import List from "./List";
import Math from "./Math";
import OrderedListItem from "./OrderedListItem";
import Paragraph from "./Paragraph";
import ReferencedContent from "./ReferencedContent";
import Spoiler from "./Spoiler";
import Strikethrough from "./Strikethrough";
import Subscript from "./Subscript";
import Superscript from "./Superscript";
import Table from "./Table";
import Tag from "./Tag";
import TaskListItem from "./TaskListItem";
import Text from "./Text";
import UnorderedListItem from "./UnorderedListItem";
interface Props {
index: string;
node: Node;
}
const Renderer: React.FC<Props> = ({ index, node }: Props) => {
switch (node.type) {
case NodeType.LINE_BREAK:
return <LineBreak />;
case NodeType.PARAGRAPH:
return <Paragraph index={index} {...(node.paragraphNode as ParagraphNode)} />;
case NodeType.CODE_BLOCK:
return <CodeBlock index={index} {...(node.codeBlockNode as CodeBlockNode)} />;
case NodeType.HEADING:
return <Heading index={index} {...(node.headingNode as HeadingNode)} />;
case NodeType.HORIZONTAL_RULE:
return <HorizontalRule index={index} {...(node.horizontalRuleNode as HorizontalRuleNode)} />;
case NodeType.BLOCKQUOTE:
return <Blockquote index={index} {...(node.blockquoteNode as BlockquoteNode)} />;
case NodeType.LIST:
return <List index={index} {...(node.listNode as ListNode)} />;
case NodeType.ORDERED_LIST_ITEM:
return <OrderedListItem index={index} {...(node.orderedListItemNode as OrderedListItemNode)} />;
case NodeType.UNORDERED_LIST_ITEM:
return <UnorderedListItem {...(node.unorderedListItemNode as UnorderedListItemNode)} />;
case NodeType.TASK_LIST_ITEM:
return <TaskListItem index={index} node={node} {...(node.taskListItemNode as TaskListItemNode)} />;
case NodeType.MATH_BLOCK:
return <Math {...(node.mathBlockNode as MathBlockNode)} block={true} />;
case NodeType.TABLE:
return <Table index={index} {...(node.tableNode as TableNode)} />;
case NodeType.EMBEDDED_CONTENT:
return <EmbeddedContent {...(node.embeddedContentNode as EmbeddedContentNode)} />;
case NodeType.TEXT:
return <Text {...(node.textNode as TextNode)} />;
case NodeType.BOLD:
return <Bold {...(node.boldNode as BoldNode)} />;
case NodeType.ITALIC:
return <Italic {...(node.italicNode as ItalicNode)} />;
case NodeType.BOLD_ITALIC:
return <BoldItalic {...(node.boldItalicNode as BoldItalicNode)} />;
case NodeType.CODE:
return <Code {...(node.codeNode as CodeNode)} />;
case NodeType.IMAGE:
return <Image {...(node.imageNode as ImageNode)} />;
case NodeType.LINK:
return <Link {...(node.linkNode as LinkNode)} />;
case NodeType.AUTO_LINK:
return <Link {...(node.autoLinkNode as AutoLinkNode)} />;
case NodeType.TAG:
return <Tag {...(node.tagNode as TagNode)} />;
case NodeType.STRIKETHROUGH:
return <Strikethrough {...(node.strikethroughNode as StrikethroughNode)} />;
case NodeType.MATH:
return <Math {...(node.mathNode as MathNode)} />;
case NodeType.HIGHLIGHT:
return <Highlight {...(node.highlightNode as HighlightNode)} />;
case NodeType.ESCAPING_CHARACTER:
return <EscapingCharacter {...(node.escapingCharacterNode as EscapingCharacterNode)} />;
case NodeType.SUBSCRIPT:
return <Subscript {...(node.subscriptNode as SubscriptNode)} />;
case NodeType.SUPERSCRIPT:
return <Superscript {...(node.superscriptNode as SuperscriptNode)} />;
case NodeType.REFERENCED_CONTENT:
return <ReferencedContent {...(node.referencedContentNode as ReferencedContentNode)} />;
case NodeType.SPOILER:
return <Spoiler {...(node.spoilerNode as SpoilerNode)} />;
case NodeType.HTML_ELEMENT:
return <HTMLElement {...(node.htmlElementNode as HTMLElementNode)} />;
default:
return null;
}
};
export default Renderer;

View File

@ -1,21 +0,0 @@
import { useState } from "react";
import { cn } from "@/lib/utils";
interface Props {
content: string;
}
const Spoiler: React.FC<Props> = ({ content }: Props) => {
const [isRevealed, setIsRevealed] = useState(false);
return (
<span
className={cn("inline cursor-pointer select-none", isRevealed ? "" : "bg-muted text-muted")}
onClick={() => setIsRevealed(!isRevealed)}
>
<span className={cn(isRevealed ? "opacity-100" : "opacity-0")}>{content}</span>
</span>
);
};
export default Spoiler;

View File

@ -1,9 +0,0 @@
interface Props {
content: string;
}
const Strikethrough: React.FC<Props> = ({ content }: Props) => {
return <del>{content}</del>;
};
export default Strikethrough;

View File

@ -1,9 +0,0 @@
interface Props {
content: string;
}
const Subscript: React.FC<Props> = ({ content }: Props) => {
return <sub>{content}</sub>;
};
export default Subscript;

View File

@ -1,9 +0,0 @@
interface Props {
content: string;
}
const Superscript: React.FC<Props> = ({ content }: Props) => {
return <sup>{content}</sup>;
};
export default Superscript;

View File

@ -1,37 +0,0 @@
import { Node, TableNode_Row } from "@/types/proto/api/v1/markdown_service";
import Renderer from "./Renderer";
interface Props {
index: string;
header: Node[];
rows: TableNode_Row[];
}
const Table = ({ header, rows }: Props) => {
return (
<table className="w-auto max-w-full border border-border divide-y divide-border">
<thead className="text-sm font-medium leading-5 text-left text-foreground">
<tr className="divide-x divide-border">
{header.map((h, i) => (
<th key={i} className="py-1 px-2">
<Renderer key={`${h.type}-${i}`} index={String(i)} node={h} />
</th>
))}
</tr>
</thead>
<tbody className="divide-y divide-border text-sm leading-5 text-left text-foreground">
{rows.map((row, i) => (
<tr key={i} className="divide-x divide-border">
{row.cells.map((r, j) => (
<td key={j} className="py-1 px-2">
<Renderer key={`${r.type}-${i}-${j}`} index={String(j)} node={r} />
</td>
))}
</tr>
))}
</tbody>
</table>
);
};
export default Table;

View File

@ -1,4 +1,3 @@
import { observer } from "mobx-react-lite";
import { useContext } from "react"; import { useContext } from "react";
import { useLocation } from "react-router-dom"; import { useLocation } from "react-router-dom";
import useNavigateTo from "@/hooks/useNavigateTo"; import useNavigateTo from "@/hooks/useNavigateTo";
@ -6,18 +5,34 @@ import { cn } from "@/lib/utils";
import { Routes } from "@/router"; import { Routes } from "@/router";
import { memoFilterStore } from "@/store"; import { memoFilterStore } from "@/store";
import { stringifyFilters, MemoFilter } from "@/store/memoFilter"; import { stringifyFilters, MemoFilter } from "@/store/memoFilter";
import { RendererContext } from "./types"; import { MemoContentContext } from "./MemoContentContext";
interface Props { /**
content: string; * Custom span component for #tag elements
*
* Handles tag clicks for filtering memos.
* The remark-tag plugin creates span elements with class="tag".
*
* Note: This component should only be used for tags.
* Regular spans are handled by the default span element.
*/
interface TagProps extends React.HTMLAttributes<HTMLSpanElement> {
node?: any; // AST node from react-markdown
"data-tag"?: string;
children?: React.ReactNode;
} }
const Tag = observer(({ content }: Props) => { export const Tag: React.FC<TagProps> = ({ "data-tag": dataTag, children, className, ...props }) => {
const context = useContext(RendererContext); const context = useContext(MemoContentContext);
const location = useLocation(); const location = useLocation();
const navigateTo = useNavigateTo(); const navigateTo = useNavigateTo();
const handleTagClick = () => { const tag = dataTag || "";
const handleTagClick = (e: React.MouseEvent) => {
e.stopPropagation();
if (context.disableFilter) { if (context.disableFilter) {
return; return;
} }
@ -27,34 +42,30 @@ const Tag = observer(({ content }: Props) => {
const pathname = context.parentPage || Routes.ROOT; const pathname = context.parentPage || Routes.ROOT;
const searchParams = new URLSearchParams(); const searchParams = new URLSearchParams();
searchParams.set("filter", stringifyFilters([{ factor: "tagSearch", value: content }])); searchParams.set("filter", stringifyFilters([{ factor: "tagSearch", value: tag }]));
navigateTo(`${pathname}?${searchParams.toString()}`); navigateTo(`${pathname}?${searchParams.toString()}`);
return; return;
} }
const isActive = memoFilterStore.getFiltersByFactor("tagSearch").some((filter: MemoFilter) => filter.value === content); const isActive = memoFilterStore.getFiltersByFactor("tagSearch").some((filter: MemoFilter) => filter.value === tag);
if (isActive) { if (isActive) {
memoFilterStore.removeFilter((f: MemoFilter) => f.factor === "tagSearch" && f.value === content); memoFilterStore.removeFilter((f: MemoFilter) => f.factor === "tagSearch" && f.value === tag);
} else { } else {
memoFilterStore.addFilter({ memoFilterStore.addFilter({
factor: "tagSearch", factor: "tagSearch",
value: content, value: tag,
}); });
} }
}; };
return ( return (
<span <span
className={cn( {...props}
"inline-block w-auto px-1 py-px rounded-md text-sm bg-secondary text-secondary-foreground", className={cn("inline-block w-auto", context.disableFilter ? "" : "cursor-pointer hover:opacity-80 transition-colors", className)}
context.disableFilter ? "" : "cursor-pointer hover:opacity-80 transition-colors", data-tag={tag}
)}
onClick={handleTagClick} onClick={handleTagClick}
> >
<span className="opacity-70 font-mono">#</span> {children}
{content}
</span> </span>
); );
}); };
export default Tag;

View File

@ -1,58 +1,76 @@
import { observer } from "mobx-react-lite";
import { useContext } from "react"; import { useContext } from "react";
import { Checkbox } from "@/components/ui/checkbox";
import { markdownServiceClient } from "@/grpcweb";
import { cn } from "@/lib/utils";
import { memoStore } from "@/store"; import { memoStore } from "@/store";
import { Node, TaskListItemNode } from "@/types/proto/api/v1/markdown_service"; import { toggleTaskAtIndex } from "@/utils/markdown-manipulation";
import Renderer from "./Renderer"; import { MemoContentContext } from "./MemoContentContext";
import { RendererContext } from "./types";
interface Props { /**
node: Node; * Custom checkbox component for react-markdown task lists
index: string; *
symbol: string; * Handles interactive task checkbox clicks and updates memo content.
indent: number; * This component is used via react-markdown's components prop.
complete: boolean; *
children: Node[]; * Note: This component should only be used for task list checkboxes.
* Regular inputs are handled by the default input element.
*/
interface TaskListItemProps extends React.InputHTMLAttributes<HTMLInputElement> {
node?: any; // AST node from react-markdown
checked?: boolean;
} }
const TaskListItem = observer(({ node, complete, children }: Props) => { export const TaskListItem: React.FC<TaskListItemProps> = ({ checked, ...props }) => {
const context = useContext(RendererContext); const context = useContext(MemoContentContext);
const handleCheckboxChange = async (on: boolean) => { const handleChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
e.stopPropagation();
// Don't update if readonly or no memo context
if (context.readonly || !context.memoName) { if (context.readonly || !context.memoName) {
return; return;
} }
(node.taskListItemNode as TaskListItemNode)!.complete = on; const newChecked = e.target.checked;
const { markdown } = await markdownServiceClient.restoreMarkdownNodes({ nodes: context.nodes });
// Find the task index by walking up the DOM
const listItem = e.target.closest("li.task-list-item");
if (!listItem) {
return;
}
// Get task index from data attribute, or calculate by counting
const taskIndexStr = listItem.getAttribute("data-task-index");
let taskIndex = 0;
if (taskIndexStr !== null) {
taskIndex = parseInt(taskIndexStr);
} else {
// Fallback: Calculate index by counting previous task list items
const allTaskItems = listItem.closest("ul, ol")?.querySelectorAll("li.task-list-item") || [];
for (let i = 0; i < allTaskItems.length; i++) {
if (allTaskItems[i] === listItem) {
taskIndex = i;
break;
}
}
}
// Update memo content using the string manipulation utility
const memo = memoStore.getMemoByName(context.memoName);
if (!memo) {
return;
}
const newContent = toggleTaskAtIndex(memo.content, taskIndex, newChecked);
await memoStore.updateMemo( await memoStore.updateMemo(
{ {
name: context.memoName, name: memo.name,
content: markdown, content: newContent,
}, },
["content"], ["content"],
); );
}; };
return ( // Override the disabled prop from remark-gfm (which defaults to true)
<li className={cn("w-full grid grid-cols-[24px_1fr]")}> // We want interactive checkboxes, only disabled when readonly
<span className="w-6 h-6 flex justify-start items-center"> return <input {...props} type="checkbox" checked={checked} disabled={context.readonly} onChange={handleChange} />;
<Checkbox };
className="h-4 w-4"
checked={complete}
disabled={context.readonly}
onCheckedChange={(checked) => handleCheckboxChange(checked === true)}
/>
</span>
<p className={cn(complete && "line-through text-muted-foreground")}>
{children.map((child, index) => (
<Renderer key={`${child.type}-${index}`} index={String(index)} node={child} />
))}
</p>
</li>
);
});
export default TaskListItem;

View File

@ -1,9 +0,0 @@
interface Props {
content: string;
}
const Text: React.FC<Props> = ({ content }: Props) => {
return <span>{content}</span>;
};
export default Text;

View File

@ -1,20 +0,0 @@
import { Node } from "@/types/proto/api/v1/markdown_service";
import Renderer from "./Renderer";
interface Props {
symbol: string;
indent: number;
children: Node[];
}
const UnorderedListItem: React.FC<Props> = ({ children }: Props) => {
return (
<li>
{children.map((child, index) => (
<Renderer key={`${child.type}-${index}`} index={String(index)} node={child} />
))}
</li>
);
};
export default UnorderedListItem;

View File

@ -0,0 +1,44 @@
import React from "react";
/**
* Custom link component for react-markdown wikilinks
*
* Handles [[wikilink]] rendering with custom styling and click behavior.
* The remark-wiki-link plugin converts [[target]] to anchor elements.
*
* Note: This component should only be used for wikilinks.
* Regular links are handled by the default anchor element.
*/
interface WikiLinkProps extends React.AnchorHTMLAttributes<HTMLAnchorElement> {
node?: any; // AST node from react-markdown
href?: string;
children?: React.ReactNode;
}
export const WikiLink: React.FC<WikiLinkProps> = ({ href, children, ...props }) => {
// Extract target from href
// remark-wiki-link creates hrefs like "#/wiki/target"
const target = href?.replace("#/wiki/", "") || "";
const handleClick = (e: React.MouseEvent<HTMLAnchorElement>) => {
e.preventDefault();
e.stopPropagation();
// TODO: Implement wikilink navigation
// This could navigate to memo detail, show preview, etc.
console.log("Wikilink clicked:", target);
};
return (
<a
{...props}
href={href}
className="wikilink text-primary hover:opacity-80 transition-colors underline"
data-target={target}
onClick={handleClick}
>
{children}
</a>
);
};

View File

@ -1,26 +1,32 @@
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import { memo, useEffect, useRef, useState } from "react"; import { memo, useEffect, useRef, useState } from "react";
import ReactMarkdown from "react-markdown";
import rehypeRaw from "rehype-raw";
import remarkGfm from "remark-gfm";
import remarkWikiLink from "remark-wiki-link";
import useCurrentUser from "@/hooks/useCurrentUser"; import useCurrentUser from "@/hooks/useCurrentUser";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { memoStore } from "@/store"; import { memoStore } from "@/store";
import { Node, NodeType } from "@/types/proto/api/v1/markdown_service";
import { useTranslate } from "@/utils/i18n"; import { useTranslate } from "@/utils/i18n";
import { remarkPreserveType } from "@/utils/remark-plugins/remark-preserve-type";
import { remarkTag } from "@/utils/remark-plugins/remark-tag";
import { isSuperUser } from "@/utils/user"; import { isSuperUser } from "@/utils/user";
import Renderer from "./Renderer"; import { createConditionalComponent, isTagNode, isTaskListItemNode, isWikiLinkNode } from "./ConditionalComponent";
import { RendererContext } from "./types"; import { DefaultLink } from "./DefaultLink";
import { MemoContentContext } from "./MemoContentContext";
import { Tag } from "./Tag";
import { TaskListItem } from "./TaskListItem";
import { WikiLink } from "./WikiLink";
// MAX_DISPLAY_HEIGHT is the maximum height of the memo content to display in compact mode. // MAX_DISPLAY_HEIGHT is the maximum height of the memo content to display in compact mode.
const MAX_DISPLAY_HEIGHT = 256; const MAX_DISPLAY_HEIGHT = 256;
interface Props { interface Props {
nodes: Node[]; content: string;
memoName?: string; memoName?: string;
compact?: boolean; compact?: boolean;
readonly?: boolean; readonly?: boolean;
disableFilter?: boolean; disableFilter?: boolean;
// embeddedMemos is a set of memo resource names that are embedded in the current memo.
// This is used to prevent infinite loops when a memo embeds itself.
embeddedMemos?: Set<string>;
className?: string; className?: string;
contentClassName?: string; contentClassName?: string;
onClick?: (e: React.MouseEvent) => void; onClick?: (e: React.MouseEvent) => void;
@ -31,7 +37,7 @@ interface Props {
type ContentCompactView = "ALL" | "SNIPPET"; type ContentCompactView = "ALL" | "SNIPPET";
const MemoContent = observer((props: Props) => { const MemoContent = observer((props: Props) => {
const { className, contentClassName, nodes, memoName, embeddedMemos, onClick, onDoubleClick } = props; const { className, contentClassName, content, memoName, onClick, onDoubleClick } = props;
const t = useTranslate(); const t = useTranslate();
const currentUser = useCurrentUser(); const currentUser = useCurrentUser();
const memoContentContainerRef = useRef<HTMLDivElement>(null); const memoContentContainerRef = useRef<HTMLDivElement>(null);
@ -39,6 +45,14 @@ const MemoContent = observer((props: Props) => {
const memo = memoName ? memoStore.getMemoByName(memoName) : null; const memo = memoName ? memoStore.getMemoByName(memoName) : null;
const allowEdit = !props.readonly && memo && (currentUser?.name === memo.creator || isSuperUser(currentUser)); const allowEdit = !props.readonly && memo && (currentUser?.name === memo.creator || isSuperUser(currentUser));
// Context for custom components
const contextValue = {
memoName,
readonly: !allowEdit,
disableFilter: props.disableFilter,
parentPage: props.parentPage,
};
// Initial compact mode. // Initial compact mode.
useEffect(() => { useEffect(() => {
if (!props.compact) { if (!props.compact) {
@ -54,6 +68,7 @@ const MemoContent = observer((props: Props) => {
}, []); }, []);
const onMemoContentClick = async (e: React.MouseEvent) => { const onMemoContentClick = async (e: React.MouseEvent) => {
// Image clicks and other handlers
if (onClick) { if (onClick) {
onClick(e); onClick(e);
} }
@ -65,48 +80,40 @@ const MemoContent = observer((props: Props) => {
} }
}; };
let prevNode: Node | null = null;
let skipNextLineBreakFlag = false;
const compactStates = { const compactStates = {
ALL: { text: t("memo.show-more"), nextState: "SNIPPET" }, ALL: { text: t("memo.show-more"), nextState: "SNIPPET" },
SNIPPET: { text: t("memo.show-less"), nextState: "ALL" }, SNIPPET: { text: t("memo.show-less"), nextState: "ALL" },
}; };
return ( return (
<RendererContext.Provider <MemoContentContext.Provider value={contextValue}>
value={{
nodes,
memoName: memoName,
readonly: !allowEdit,
disableFilter: props.disableFilter,
embeddedMemos: embeddedMemos || new Set(),
parentPage: props.parentPage,
}}
>
<div className={`w-full flex flex-col justify-start items-start text-foreground ${className || ""}`}> <div className={`w-full flex flex-col justify-start items-start text-foreground ${className || ""}`}>
<div <div
ref={memoContentContainerRef} ref={memoContentContainerRef}
className={cn( className={cn(
"relative w-full max-w-full break-words text-base leading-6 space-y-1 whitespace-pre-wrap", "markdown-content relative w-full max-w-full break-words text-base leading-6",
showCompactMode == "ALL" && "line-clamp-6 max-h-60", showCompactMode == "ALL" && "line-clamp-6 max-h-60",
contentClassName, contentClassName,
)} )}
onClick={onMemoContentClick} onClick={onMemoContentClick}
onDoubleClick={onMemoContentDoubleClick} onDoubleClick={onMemoContentDoubleClick}
> >
{nodes.map((node, index) => { <ReactMarkdown
if (prevNode?.type !== NodeType.LINE_BREAK && node.type === NodeType.LINE_BREAK && skipNextLineBreakFlag) { remarkPlugins={[remarkGfm, remarkWikiLink, remarkTag, remarkPreserveType]}
skipNextLineBreakFlag = false; rehypePlugins={[rehypeRaw]}
return null; components={{
} // Conditionally render custom components based on AST node type
prevNode = node; input: createConditionalComponent(TaskListItem, "input", isTaskListItemNode),
skipNextLineBreakFlag = true; a: createConditionalComponent(WikiLink, DefaultLink, isWikiLinkNode),
return <Renderer key={`${node.type}-${index}`} index={String(index)} node={node} />; span: createConditionalComponent(Tag, "span", isTagNode),
})} }}
{showCompactMode == "ALL" && ( >
<div className="absolute bottom-0 left-0 w-full h-12 bg-linear-to-b from-transparent to-background pointer-events-none"></div> {content}
)} </ReactMarkdown>
</div> </div>
{showCompactMode == "ALL" && (
<div className="absolute bottom-0 left-0 w-full h-12 bg-gradient-to-b from-transparent to-background pointer-events-none"></div>
)}
{showCompactMode != undefined && ( {showCompactMode != undefined && (
<div className="w-full mt-1"> <div className="w-full mt-1">
<span <span
@ -120,7 +127,7 @@ const MemoContent = observer((props: Props) => {
</div> </div>
)} )}
</div> </div>
</RendererContext.Provider> </MemoContentContext.Provider>
); );
}); });

View File

@ -1,18 +0,0 @@
import { createContext } from "react";
import { Node } from "@/types/proto/api/v1/markdown_service";
interface Context {
nodes: Node[];
// embeddedMemos is a set of memo resource names that are embedded in the current memo.
// This is used to prevent infinite loops when a memo embeds itself.
embeddedMemos: Set<string>;
memoName?: string;
readonly?: boolean;
disableFilter?: boolean;
parentPage?: string;
}
export const RendererContext = createContext<Context>({
nodes: [],
embeddedMemos: new Set(),
});

View File

@ -1,6 +0,0 @@
export * from "./context";
export interface BaseProps {
index: string;
className?: string;
}

View File

@ -118,7 +118,7 @@ const AddMemoRelationPopover = () => {
placeholder={t("reference.search-placeholder")} placeholder={t("reference.search-placeholder")}
value={searchText} value={searchText}
onChange={(e) => setSearchText(e.target.value)} onChange={(e) => setSearchText(e.target.value)}
className="mb-2" className="mb-2 !text-sm"
/> />
<div className="max-h-[200px] overflow-y-auto"> <div className="max-h-[200px] overflow-y-auto">
{filteredMemos.length === 0 ? ( {filteredMemos.length === 0 ? (

View File

@ -1,8 +1,6 @@
import { last } from "lodash-es";
import { forwardRef, ReactNode, useCallback, useEffect, useImperativeHandle, useRef, useState } from "react"; import { forwardRef, ReactNode, useCallback, useEffect, useImperativeHandle, useRef, useState } from "react";
import { markdownServiceClient } from "@/grpcweb";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { Node, NodeType, OrderedListItemNode, TaskListItemNode, UnorderedListItemNode } from "@/types/proto/api/v1/markdown_service"; import { detectLastListItem, generateListContinuation } from "@/utils/markdown-list-detection";
import { Command } from "../types/command"; import { Command } from "../types/command";
import CommandSuggestions from "./CommandSuggestions"; import CommandSuggestions from "./CommandSuggestions";
import TagSuggestions from "./TagSuggestions"; import TagSuggestions from "./TagSuggestions";
@ -154,20 +152,6 @@ const Editor = forwardRef(function Editor(props: Props, ref: React.ForwardedRef<
updateEditorHeight(); updateEditorHeight();
}, []); }, []);
const getLastNode = (nodes: Node[]): Node | undefined => {
const lastNode = last(nodes);
if (!lastNode) {
return undefined;
}
if (lastNode.type === NodeType.LIST) {
const children = lastNode.listNode?.children;
if (children) {
return getLastNode(children);
}
}
return lastNode;
};
const handleEditorKeyDown = async (event: React.KeyboardEvent<HTMLTextAreaElement>) => { const handleEditorKeyDown = async (event: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (event.key === "Enter" && !isInIME) { if (event.key === "Enter" && !isInIME) {
if (event.shiftKey || event.ctrlKey || event.metaKey || event.altKey) { if (event.shiftKey || event.ctrlKey || event.metaKey || event.altKey) {
@ -176,41 +160,11 @@ const Editor = forwardRef(function Editor(props: Props, ref: React.ForwardedRef<
const cursorPosition = editorActions.getCursorPosition(); const cursorPosition = editorActions.getCursorPosition();
const prevContent = editorActions.getContent().substring(0, cursorPosition); const prevContent = editorActions.getContent().substring(0, cursorPosition);
const { nodes } = await markdownServiceClient.parseMarkdown({ markdown: prevContent });
const lastNode = getLastNode(nodes);
if (!lastNode) {
return;
}
// Get the indentation of the previous line // Detect list item using regex-based detection
const lines = prevContent.split("\n"); const listInfo = detectLastListItem(prevContent);
const lastLine = lines[lines.length - 1]; if (listInfo.type) {
const indentationMatch = lastLine.match(/^\s*/); const insertText = generateListContinuation(listInfo);
let insertText = indentationMatch ? indentationMatch[0] : ""; // Keep the indentation of the previous line
if (lastNode.type === NodeType.TASK_LIST_ITEM) {
const { symbol } = lastNode.taskListItemNode as TaskListItemNode;
insertText += `${symbol} [ ] `;
} else if (lastNode.type === NodeType.UNORDERED_LIST_ITEM) {
const { symbol } = lastNode.unorderedListItemNode as UnorderedListItemNode;
insertText += `${symbol} `;
} else if (lastNode.type === NodeType.ORDERED_LIST_ITEM) {
const { number } = lastNode.orderedListItemNode as OrderedListItemNode;
insertText += `${Number(number) + 1}. `;
} else if (lastNode.type === NodeType.TABLE) {
const columns = lastNode.tableNode?.header.length;
if (!columns) {
return;
}
insertText += "| ";
for (let i = 1; i < columns; i++) {
insertText += " | ";
}
insertText += " |";
}
if (insertText) {
// Insert the text at the current cursor position.
editorActions.insertText(insertText); editorActions.insertText(insertText);
} }
} }

View File

@ -239,7 +239,7 @@ const MemoView: React.FC<Props> = observer((props: Props) => {
<MemoContent <MemoContent
key={`${memo.name}-${memo.updateTime}`} key={`${memo.name}-${memo.updateTime}`}
memoName={memo.name} memoName={memo.name}
nodes={memo.nodes} content={memo.content}
readonly={readonly} readonly={readonly}
onClick={handleMemoContentClick} onClick={handleMemoContentClick}
onDoubleClick={handleMemoContentDoubleClick} onDoubleClick={handleMemoContentDoubleClick}

View File

@ -4,7 +4,6 @@ import { AttachmentServiceDefinition } from "./types/proto/api/v1/attachment_ser
import { AuthServiceDefinition } from "./types/proto/api/v1/auth_service"; import { AuthServiceDefinition } from "./types/proto/api/v1/auth_service";
import { IdentityProviderServiceDefinition } from "./types/proto/api/v1/idp_service"; import { IdentityProviderServiceDefinition } from "./types/proto/api/v1/idp_service";
import { InboxServiceDefinition } from "./types/proto/api/v1/inbox_service"; import { InboxServiceDefinition } from "./types/proto/api/v1/inbox_service";
import { MarkdownServiceDefinition } from "./types/proto/api/v1/markdown_service";
import { MemoServiceDefinition } from "./types/proto/api/v1/memo_service"; import { MemoServiceDefinition } from "./types/proto/api/v1/memo_service";
import { ShortcutServiceDefinition } from "./types/proto/api/v1/shortcut_service"; import { ShortcutServiceDefinition } from "./types/proto/api/v1/shortcut_service";
import { UserServiceDefinition } from "./types/proto/api/v1/user_service"; import { UserServiceDefinition } from "./types/proto/api/v1/user_service";
@ -35,6 +34,4 @@ export const inboxServiceClient = clientFactory.create(InboxServiceDefinition, c
export const activityServiceClient = clientFactory.create(ActivityServiceDefinition, channel); export const activityServiceClient = clientFactory.create(ActivityServiceDefinition, channel);
export const markdownServiceClient = clientFactory.create(MarkdownServiceDefinition, channel);
export const identityProviderServiceClient = clientFactory.create(IdentityProviderServiceDefinition, channel); export const identityProviderServiceClient = clientFactory.create(IdentityProviderServiceDefinition, channel);

View File

@ -13,4 +13,301 @@
body { body {
@apply bg-background text-foreground; @apply bg-background text-foreground;
} }
/* ========================================
* Task List Styles
* ======================================== */
/* Task list containers */
.markdown-content ul.contains-task-list,
.prose ul.contains-task-list {
padding: 0 !important;
margin: 0.5rem 0 !important;
list-style: none !important;
margin-block-start: 0 !important;
margin-block-end: 0 !important;
margin-inline-start: 0 !important;
margin-inline-end: 0 !important;
padding-block-start: 0 !important;
padding-block-end: 0 !important;
padding-inline-start: 0 !important;
padding-inline-end: 0 !important;
}
/* Remove pseudo-elements */
.markdown-content ul.contains-task-list::before,
.markdown-content ul.contains-task-list::after,
.prose ul.contains-task-list::before,
.prose ul.contains-task-list::after {
display: none !important;
content: none !important;
}
/* Task list items */
.markdown-content ul.contains-task-list li.task-list-item,
.prose ul.contains-task-list li.task-list-item {
display: flex !important;
align-items: center !important;
gap: 0.5rem !important;
margin: 0.125rem 0 !important;
padding: 0 !important;
line-height: 1.5rem !important;
list-style: none !important;
margin-block-start: 0 !important;
margin-block-end: 0 !important;
}
/* Remove list item markers */
.markdown-content ul.contains-task-list li.task-list-item::before,
.markdown-content ul.contains-task-list li.task-list-item::marker,
.prose ul.contains-task-list li.task-list-item::before,
.prose ul.contains-task-list li.task-list-item::marker {
display: none !important;
content: none !important;
}
/* Task checkboxes */
.markdown-content ul.contains-task-list li.task-list-item input[type="checkbox"],
.prose ul.contains-task-list li.task-list-item input[type="checkbox"] {
margin: 0 !important;
padding: 0 !important;
flex-shrink: 0 !important;
width: 1rem !important;
height: 1rem !important;
vertical-align: middle !important;
}
/* Task item text */
.markdown-content ul.contains-task-list li.task-list-item p,
.prose ul.contains-task-list li.task-list-item p {
margin: 0 !important;
padding: 0 !important;
display: inline !important;
line-height: inherit !important;
}
/* All children */
.markdown-content ul.contains-task-list li.task-list-item > *,
.prose ul.contains-task-list li.task-list-item > * {
margin: 0 !important;
}
/* Override parent space-y utility for task lists */
.markdown-content ul.contains-task-list + *,
.prose ul.contains-task-list + * {
margin-top: 0.25rem !important;
}
* + .markdown-content ul.contains-task-list,
* + .prose ul.contains-task-list {
margin-top: 0.25rem !important;
}
/* ========================================
* Markdown Content Styles
* Custom minimal styles for markdown rendering
* ======================================== */
.markdown-content {
color: var(--foreground);
white-space: pre-line; /* Preserve newlines but collapse multiple spaces */
}
/* Block elements should not inherit pre-line */
.markdown-content h1,
.markdown-content h2,
.markdown-content h3,
.markdown-content h4,
.markdown-content h5,
.markdown-content h6,
.markdown-content p,
.markdown-content ul,
.markdown-content ol,
.markdown-content pre,
.markdown-content blockquote,
.markdown-content table {
white-space: normal;
}
/* Code blocks need pre-wrap */
.markdown-content pre {
white-space: pre-wrap;
}
/* Headings */
.markdown-content h1 {
font-size: 1.875rem;
font-weight: 700;
line-height: 2.25rem;
margin-top: 1rem;
margin-bottom: 0.5rem;
}
.markdown-content h2 {
font-size: 1.5rem;
font-weight: 600;
line-height: 2rem;
margin-top: 0.75rem;
margin-bottom: 0.5rem;
}
.markdown-content h3 {
font-size: 1.25rem;
font-weight: 600;
line-height: 1.75rem;
margin-top: 0.75rem;
margin-bottom: 0.5rem;
}
.markdown-content h4,
.markdown-content h5,
.markdown-content h6 {
font-size: 1.125rem;
font-weight: 600;
line-height: 1.75rem;
margin-top: 0.5rem;
margin-bottom: 0.5rem;
}
/* First heading has no top margin */
.markdown-content > :first-child {
margin-top: 0;
}
/* Paragraphs */
.markdown-content p {
margin: 0.5rem 0;
}
/* Links */
.markdown-content a {
color: var(--primary);
text-decoration: underline;
transition: opacity 150ms;
}
.markdown-content a:hover {
opacity: 0.8;
}
/* Lists - MINIMAL spacing */
.markdown-content ul,
.markdown-content ol {
margin: 0.5rem 0;
padding-left: 1.5em;
list-style-position: outside;
}
.markdown-content ul {
list-style-type: disc;
}
.markdown-content ol {
list-style-type: decimal;
}
.markdown-content li {
margin: 0.125rem 0;
line-height: 1.5;
}
.markdown-content li:first-child {
margin-top: 0;
}
.markdown-content li:last-child {
margin-bottom: 0;
}
/* Nested lists */
.markdown-content li > ul,
.markdown-content li > ol {
margin: 0.25rem 0;
}
/* Code */
.markdown-content code {
font-family: var(--font-mono);
font-size: 0.875em;
background: var(--muted);
padding: 0.125rem 0.25rem;
border-radius: 0.25rem;
}
.markdown-content pre {
font-family: var(--font-mono);
font-size: 0.875rem;
background: var(--muted);
padding: 0.75rem 1rem;
border-radius: 0.5rem;
overflow-x: auto;
margin: 0.75rem 0;
}
.markdown-content pre code {
background: none;
padding: 0;
font-size: inherit;
}
/* Blockquotes */
.markdown-content blockquote {
border-left: 3px solid var(--border);
padding-left: 1rem;
margin: 0.75rem 0;
color: var(--muted-foreground);
font-style: italic;
}
/* Horizontal rule */
.markdown-content hr {
border: none;
border-top: 1px solid var(--border);
margin: 1rem 0;
}
/* Tables */
.markdown-content table {
width: 100%;
border-collapse: collapse;
margin: 0.75rem 0;
}
.markdown-content th,
.markdown-content td {
border: 1px solid var(--border);
padding: 0.5rem;
text-align: left;
}
.markdown-content th {
background: var(--muted);
font-weight: 600;
}
/* Images */
.markdown-content img {
max-width: 100%;
height: auto;
border-radius: 0.5rem;
margin: 0.75rem 0;
}
/* Strong/Bold */
.markdown-content strong {
font-weight: 600;
}
/* Emphasis/Italic */
.markdown-content em {
font-style: italic;
}
/* Inline elements shouldn't add vertical spacing */
.markdown-content code,
.markdown-content strong,
.markdown-content em,
.markdown-content a {
vertical-align: baseline;
}
} }

View File

@ -7,7 +7,7 @@
* Filters are URL-driven and shareable - copying the URL preserves the filter state. * Filters are URL-driven and shareable - copying the URL preserves the filter state.
*/ */
import { uniqBy } from "lodash-es"; import { uniqBy } from "lodash-es";
import { makeObservable, observable, action } from "mobx"; import { makeObservable, observable, action, computed } from "mobx";
import { StandardState } from "./base-store"; import { StandardState } from "./base-store";
/** /**
@ -94,6 +94,7 @@ class MemoFilterState extends StandardState {
makeObservable(this, { makeObservable(this, {
filters: observable, filters: observable,
shortcut: observable, shortcut: observable,
hasActiveFilters: computed,
addFilter: action, addFilter: action,
removeFilter: action, removeFilter: action,
removeFiltersByFactor: action, removeFiltersByFactor: action,

File diff suppressed because it is too large Load Diff

View File

@ -11,7 +11,6 @@ import { FieldMask } from "../../google/protobuf/field_mask";
import { Timestamp } from "../../google/protobuf/timestamp"; import { Timestamp } from "../../google/protobuf/timestamp";
import { Attachment } from "./attachment_service"; import { Attachment } from "./attachment_service";
import { State, stateFromJSON, stateToNumber } from "./common"; import { State, stateFromJSON, stateToNumber } from "./common";
import { Node } from "./markdown_service";
export const protobufPackage = "memos.api.v1"; export const protobufPackage = "memos.api.v1";
@ -110,8 +109,6 @@ export interface Memo {
| undefined; | undefined;
/** Required. The content of the memo in Markdown format. */ /** Required. The content of the memo in Markdown format. */
content: string; content: string;
/** Output only. The parsed nodes from the content. */
nodes: Node[];
/** The visibility of the memo. */ /** The visibility of the memo. */
visibility: Visibility; visibility: Visibility;
/** Output only. The tags extracted from the content. */ /** Output only. The tags extracted from the content. */
@ -587,7 +584,6 @@ function createBaseMemo(): Memo {
updateTime: undefined, updateTime: undefined,
displayTime: undefined, displayTime: undefined,
content: "", content: "",
nodes: [],
visibility: Visibility.VISIBILITY_UNSPECIFIED, visibility: Visibility.VISIBILITY_UNSPECIFIED,
tags: [], tags: [],
pinned: false, pinned: false,
@ -624,9 +620,6 @@ export const Memo: MessageFns<Memo> = {
if (message.content !== "") { if (message.content !== "") {
writer.uint32(58).string(message.content); writer.uint32(58).string(message.content);
} }
for (const v of message.nodes) {
Node.encode(v!, writer.uint32(66).fork()).join();
}
if (message.visibility !== Visibility.VISIBILITY_UNSPECIFIED) { if (message.visibility !== Visibility.VISIBILITY_UNSPECIFIED) {
writer.uint32(72).int32(visibilityToNumber(message.visibility)); writer.uint32(72).int32(visibilityToNumber(message.visibility));
} }
@ -723,14 +716,6 @@ export const Memo: MessageFns<Memo> = {
message.content = reader.string(); message.content = reader.string();
continue; continue;
} }
case 8: {
if (tag !== 66) {
break;
}
message.nodes.push(Node.decode(reader, reader.uint32()));
continue;
}
case 9: { case 9: {
if (tag !== 72) { if (tag !== 72) {
break; break;
@ -832,7 +817,6 @@ export const Memo: MessageFns<Memo> = {
message.updateTime = object.updateTime ?? undefined; message.updateTime = object.updateTime ?? undefined;
message.displayTime = object.displayTime ?? undefined; message.displayTime = object.displayTime ?? undefined;
message.content = object.content ?? ""; message.content = object.content ?? "";
message.nodes = object.nodes?.map((e) => Node.fromPartial(e)) || [];
message.visibility = object.visibility ?? Visibility.VISIBILITY_UNSPECIFIED; message.visibility = object.visibility ?? Visibility.VISIBILITY_UNSPECIFIED;
message.tags = object.tags?.map((e) => e) || []; message.tags = object.tags?.map((e) => e) || [];
message.pinned = object.pinned ?? false; message.pinned = object.pinned ?? false;

View File

@ -0,0 +1,83 @@
/**
* Utilities for detecting list patterns in markdown text
*
* Used by the editor for auto-continuation of lists when user presses Enter
*/
export interface ListItemInfo {
type: "task" | "unordered" | "ordered" | null;
symbol?: string; // For task/unordered lists: "- ", "* ", "+ "
number?: number; // For ordered lists: 1, 2, 3, etc.
indent?: string; // Leading whitespace
}
/**
* Detect the list item type of the last line before cursor
*
* @param contentBeforeCursor - Markdown content from start to cursor position
* @returns List item information, or null if not a list item
*/
export function detectLastListItem(contentBeforeCursor: string): ListItemInfo {
const lines = contentBeforeCursor.split("\n");
const lastLine = lines[lines.length - 1];
// Extract indentation
const indentMatch = lastLine.match(/^(\s*)/);
const indent = indentMatch ? indentMatch[1] : "";
// Task list: - [ ] or - [x] or - [X]
const taskMatch = lastLine.match(/^(\s*)([-*+])\s+\[([ xX])\]\s+/);
if (taskMatch) {
return {
type: "task",
symbol: taskMatch[2], // -, *, or +
indent,
};
}
// Unordered list: - foo or * foo or + foo
const unorderedMatch = lastLine.match(/^(\s*)([-*+])\s+/);
if (unorderedMatch) {
return {
type: "unordered",
symbol: unorderedMatch[2],
indent,
};
}
// Ordered list: 1. foo or 2) foo
const orderedMatch = lastLine.match(/^(\s*)(\d+)[.)]\s+/);
if (orderedMatch) {
return {
type: "ordered",
number: parseInt(orderedMatch[2]),
indent,
};
}
return {
type: null,
indent,
};
}
/**
* Generate the text to insert when pressing Enter on a list item
*
* @param listInfo - Information about the current list item
* @returns Text to insert at cursor
*/
export function generateListContinuation(listInfo: ListItemInfo): string {
const indent = listInfo.indent || "";
switch (listInfo.type) {
case "task":
return `${indent}${listInfo.symbol} [ ] `;
case "unordered":
return `${indent}${listInfo.symbol} `;
case "ordered":
return `${indent}${(listInfo.number || 0) + 1}. `;
default:
return indent;
}
}

View File

@ -0,0 +1,211 @@
/**
* Utilities for manipulating markdown strings (GitHub-style approach)
*
* These functions modify the raw markdown text directly without parsing to AST.
* This is the same approach GitHub uses for task list updates.
*/
/**
* Toggle a task checkbox at a specific line number
*
* @param markdown - The full markdown content
* @param lineNumber - Zero-based line number
* @param checked - New checked state
* @returns Updated markdown string
*/
export function toggleTaskAtLine(markdown: string, lineNumber: number, checked: boolean): string {
const lines = markdown.split("\n");
if (lineNumber < 0 || lineNumber >= lines.length) {
return markdown;
}
const line = lines[lineNumber];
// Match task list patterns: - [ ], - [x], - [X], etc.
const taskPattern = /^(\s*[-*+]\s+)\[([ xX])\](\s+.*)$/;
const match = line.match(taskPattern);
if (!match) {
// Not a task list item
return markdown;
}
const [, prefix, , suffix] = match;
const newCheckmark = checked ? "x" : " ";
lines[lineNumber] = `${prefix}[${newCheckmark}]${suffix}`;
return lines.join("\n");
}
/**
* Toggle a task checkbox by its index (nth task in the document)
*
* @param markdown - The full markdown content
* @param taskIndex - Zero-based index of the task (0 = first task, 1 = second task, etc.)
* @param checked - New checked state
* @returns Updated markdown string
*/
export function toggleTaskAtIndex(markdown: string, taskIndex: number, checked: boolean): string {
const lines = markdown.split("\n");
const taskPattern = /^(\s*[-*+]\s+)\[([ xX])\](\s+.*)$/;
let currentTaskIndex = 0;
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
const match = line.match(taskPattern);
if (match) {
if (currentTaskIndex === taskIndex) {
const [, prefix, , suffix] = match;
const newCheckmark = checked ? "x" : " ";
lines[i] = `${prefix}[${newCheckmark}]${suffix}`;
break;
}
currentTaskIndex++;
}
}
return lines.join("\n");
}
/**
* Remove all completed tasks from markdown
*
* @param markdown - The full markdown content
* @returns Markdown with completed tasks removed
*/
export function removeCompletedTasks(markdown: string): string {
const lines = markdown.split("\n");
const completedTaskPattern = /^(\s*[-*+]\s+)\[([xX])\](\s+.*)$/;
const result: string[] = [];
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
// Skip completed tasks
if (completedTaskPattern.test(line)) {
// Also skip the following line if it's empty (preserve spacing)
if (i + 1 < lines.length && lines[i + 1].trim() === "") {
i++; // Skip next line
}
continue;
}
result.push(line);
}
return result.join("\n");
}
/**
* Count tasks in markdown
*
* @param markdown - The full markdown content
* @returns Object with task counts
*/
export function countTasks(markdown: string): {
total: number;
completed: number;
incomplete: number;
} {
const lines = markdown.split("\n");
const taskPattern = /^(\s*[-*+]\s+)\[([ xX])\](\s+.*)$/;
let total = 0;
let completed = 0;
for (const line of lines) {
const match = line.match(taskPattern);
if (match) {
total++;
const checkmark = match[2];
if (checkmark.toLowerCase() === "x") {
completed++;
}
}
}
return {
total,
completed,
incomplete: total - completed,
};
}
/**
* Check if markdown has any completed tasks
*
* @param markdown - The full markdown content
* @returns True if there are completed tasks
*/
export function hasCompletedTasks(markdown: string): boolean {
const completedTaskPattern = /^(\s*[-*+]\s+)\[([xX])\](\s+.*)$/m;
return completedTaskPattern.test(markdown);
}
/**
* Get the line number of the nth task
*
* @param markdown - The full markdown content
* @param taskIndex - Zero-based task index
* @returns Line number, or -1 if not found
*/
export function getTaskLineNumber(markdown: string, taskIndex: number): number {
const lines = markdown.split("\n");
const taskPattern = /^(\s*[-*+]\s+)\[([ xX])\](\s+.*)$/;
let currentTaskIndex = 0;
for (let i = 0; i < lines.length; i++) {
if (taskPattern.test(lines[i])) {
if (currentTaskIndex === taskIndex) {
return i;
}
currentTaskIndex++;
}
}
return -1;
}
/**
* Extract all task items with their metadata
*
* @param markdown - The full markdown content
* @returns Array of task metadata
*/
export interface TaskItem {
lineNumber: number;
taskIndex: number;
checked: boolean;
content: string;
indentation: number;
}
export function extractTasks(markdown: string): TaskItem[] {
const lines = markdown.split("\n");
const taskPattern = /^(\s*)([-*+]\s+)\[([ xX])\](\s+.*)$/;
const tasks: TaskItem[] = [];
let taskIndex = 0;
for (let lineNumber = 0; lineNumber < lines.length; lineNumber++) {
const line = lines[lineNumber];
const match = line.match(taskPattern);
if (match) {
const [, indentStr, , checkmark, content] = match;
tasks.push({
lineNumber,
taskIndex: taskIndex++,
checked: checkmark.toLowerCase() === "x",
content: content.trim(),
indentation: indentStr.length,
});
}
}
return tasks;
}

View File

@ -0,0 +1,32 @@
import type { Root } from "mdast";
import { visit } from "unist-util-visit";
/**
* Remark plugin to preserve original mdast node types in the data field
*
* This allows us to check the original node type even after
* transformation to hast (HTML AST).
*
* The original type is stored in data.mdastType and will be available
* in the hast node as data.mdastType.
*/
export const remarkPreserveType = () => {
return (tree: Root) => {
visit(tree, (node: any) => {
// Skip text nodes and standard element types
if (node.type === "text" || node.type === "root") {
return;
}
// Preserve the original mdast type in data
if (!node.data) {
node.data = {};
}
// Store original type for custom node types
if (node.type !== "paragraph" && node.type !== "heading" && node.type !== "list" && node.type !== "listItem") {
node.data.mdastType = node.type;
}
});
};
};

View File

@ -0,0 +1,124 @@
import type { Root, Text } from "mdast";
import { visit } from "unist-util-visit";
/**
* Custom remark plugin for #tag syntax
*
* Parses #tag patterns in text nodes and converts them to HTML nodes.
* This matches the goldmark backend TagNode implementation.
*
* Examples:
* #work <span class="tag" data-tag="work">#work</span>
* #2024_plans <span class="tag" data-tag="2024_plans">#2024_plans</span>
* #work-notes <span class="tag" data-tag="work-notes">#work-notes</span>
*
* Rules:
* - Tag must start with # followed by alphanumeric, underscore, or hyphen
* - Tag ends at whitespace, punctuation (except - and _), or end of line
* - Tags at start of line after ## are headings, not tags
*/
/**
* Check if character is valid for tag content
*/
function isTagChar(char: string): boolean {
return /[a-zA-Z0-9_-]/.test(char);
}
/**
* Parse tags from text and return segments
*/
function parseTagsFromText(text: string): Array<{ type: "text" | "tag"; value: string }> {
const segments: Array<{ type: "text" | "tag"; value: string }> = [];
let i = 0;
while (i < text.length) {
// Check for tag pattern
if (text[i] === "#" && i + 1 < text.length && isTagChar(text[i + 1])) {
// Check if this might be a heading (## at start or after whitespace)
const prevChar = i > 0 ? text[i - 1] : "";
const nextChar = i + 1 < text.length ? text[i + 1] : "";
if (prevChar === "#" || nextChar === "#" || nextChar === " ") {
// This is a heading, not a tag
segments.push({ type: "text", value: text[i] });
i++;
continue;
}
// Extract tag content
let j = i + 1;
while (j < text.length && isTagChar(text[j])) {
j++;
}
const tagContent = text.slice(i + 1, j);
// Validate tag length
if (tagContent.length > 0 && tagContent.length <= 100) {
segments.push({ type: "tag", value: tagContent });
i = j;
continue;
}
}
// Regular text
let j = i + 1;
while (j < text.length && text[j] !== "#") {
j++;
}
segments.push({ type: "text", value: text.slice(i, j) });
i = j;
}
return segments;
}
/**
* Remark plugin to parse #tag syntax
*/
export const remarkTag = () => {
return (tree: Root) => {
visit(tree, "text", (node: Text, index, parent) => {
if (!parent || index === null) return;
const text = node.value;
const segments = parseTagsFromText(text);
// If no tags found, leave node as-is
if (segments.every((seg) => seg.type === "text")) {
return;
}
// Replace text node with multiple nodes (text + tag nodes)
const newNodes = segments.map((segment) => {
if (segment.type === "tag") {
// Create a custom mdast node that remark-rehype will convert to <span>
// This allows ReactMarkdown's component mapping (span: Tag) to work
return {
type: "tagNode" as any,
value: segment.value,
data: {
hName: "span",
hProperties: {
className: "tag",
"data-tag": segment.value,
},
hChildren: [{ type: "text", value: `#${segment.value}` }],
},
};
} else {
// Keep as text node
return {
type: "text" as const,
value: segment.value,
};
}
});
// Replace the current node with the new nodes
// @ts-expect-error - mdast types are complex, this is safe
parent.children.splice(index, 1, ...newNodes);
});
};
};

View File

@ -1,5 +1,4 @@
import react from "@vitejs/plugin-react"; import react from "@vitejs/plugin-react";
import { codeInspectorPlugin } from "code-inspector-plugin";
import { resolve } from "path"; import { resolve } from "path";
import { defineConfig } from "vite"; import { defineConfig } from "vite";
import tailwindcss from "@tailwindcss/vite"; import tailwindcss from "@tailwindcss/vite";
@ -12,13 +11,7 @@ if (process.env.DEV_PROXY_SERVER && process.env.DEV_PROXY_SERVER.length > 0) {
// https://vitejs.dev/config/ // https://vitejs.dev/config/
export default defineConfig({ export default defineConfig({
plugins: [ plugins: [react(), tailwindcss()],
react(),
tailwindcss(),
codeInspectorPlugin({
bundler: "vite",
}),
],
server: { server: {
host: "0.0.0.0", host: "0.0.0.0",
port: 3001, port: 3001,