mirror of https://github.com/usememos/memos.git
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:
parent
bfad0708e2
commit
739fd2cde6
2
go.mod
2
go.mod
|
|
@ -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
4
go.sum
|
|
@ -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=
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
|
@ -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),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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 + " ..."
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
@ -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.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)
|
||||||
|
}
|
||||||
|
|
@ -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: "",
|
||||||
|
expected: "",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
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)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
|
||||||
}
|
|
||||||
|
|
@ -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
|
|
@ -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
|
|
||||||
)
|
|
||||||
|
|
@ -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",
|
|
||||||
}
|
|
||||||
|
|
@ -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{
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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")
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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{
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
1243
web/pnpm-lock.yaml
1243
web/pnpm-lock.yaml
File diff suppressed because it is too large
Load Diff
|
|
@ -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"],
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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;
|
|
||||||
|
|
@ -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;
|
|
||||||
|
|
@ -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;
|
|
||||||
|
|
@ -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;
|
|
||||||
|
|
@ -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);
|
|
||||||
|
|
@ -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;
|
||||||
|
};
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -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;
|
|
||||||
|
|
@ -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;
|
|
||||||
|
|
@ -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;
|
|
||||||
|
|
@ -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;
|
|
||||||
|
|
@ -1,9 +0,0 @@
|
||||||
interface Props {
|
|
||||||
symbol: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const EscapingCharacter: React.FC<Props> = ({ symbol }: Props) => {
|
|
||||||
return <span>{symbol}</span>;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default EscapingCharacter;
|
|
||||||
|
|
@ -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;
|
|
||||||
|
|
@ -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;
|
|
||||||
|
|
@ -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;
|
|
||||||
|
|
@ -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;
|
|
||||||
|
|
@ -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;
|
|
||||||
|
|
@ -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;
|
|
||||||
|
|
@ -1,5 +0,0 @@
|
||||||
const LineBreak = () => {
|
|
||||||
return <br />;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default LineBreak;
|
|
||||||
|
|
@ -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;
|
|
||||||
|
|
@ -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;
|
|
||||||
|
|
@ -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;
|
|
||||||
|
|
@ -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,
|
||||||
|
});
|
||||||
|
|
@ -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;
|
|
||||||
|
|
@ -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;
|
|
||||||
|
|
@ -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;
|
|
||||||
|
|
@ -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;
|
|
||||||
|
|
@ -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;
|
|
||||||
|
|
@ -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;
|
|
||||||
|
|
@ -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;
|
|
||||||
|
|
@ -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;
|
|
||||||
|
|
@ -1,9 +0,0 @@
|
||||||
interface Props {
|
|
||||||
content: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const Strikethrough: React.FC<Props> = ({ content }: Props) => {
|
|
||||||
return <del>{content}</del>;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default Strikethrough;
|
|
||||||
|
|
@ -1,9 +0,0 @@
|
||||||
interface Props {
|
|
||||||
content: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const Subscript: React.FC<Props> = ({ content }: Props) => {
|
|
||||||
return <sub>{content}</sub>;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default Subscript;
|
|
||||||
|
|
@ -1,9 +0,0 @@
|
||||||
interface Props {
|
|
||||||
content: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const Superscript: React.FC<Props> = ({ content }: Props) => {
|
|
||||||
return <sup>{content}</sup>;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default Superscript;
|
|
||||||
|
|
@ -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;
|
|
||||||
|
|
@ -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;
|
|
||||||
|
|
|
||||||
|
|
@ -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;
|
|
||||||
|
|
|
||||||
|
|
@ -1,9 +0,0 @@
|
||||||
interface Props {
|
|
||||||
content: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const Text: React.FC<Props> = ({ content }: Props) => {
|
|
||||||
return <span>{content}</span>;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default Text;
|
|
||||||
|
|
@ -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;
|
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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(),
|
|
||||||
});
|
|
||||||
|
|
@ -1,6 +0,0 @@
|
||||||
export * from "./context";
|
|
||||||
|
|
||||||
export interface BaseProps {
|
|
||||||
index: string;
|
|
||||||
className?: string;
|
|
||||||
}
|
|
||||||
|
|
@ -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 ? (
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
@ -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);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue