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/viper v1.20.1
|
||||
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/mod v0.28.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/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/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/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
|
||||
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/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.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/etcd v0.0.0-20191023171146-3cf2f69b5738/go.mod h1:dnLIgRNXwCJa5e+c6mIZCrds/GIG4ncV9HhK5PX7jPg=
|
||||
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/common.proto";
|
||||
import "api/v1/markdown_service.proto";
|
||||
import "google/api/annotations.proto";
|
||||
import "google/api/client.proto";
|
||||
import "google/api/field_behavior.proto";
|
||||
|
|
@ -202,9 +201,6 @@ message Memo {
|
|||
// Required. The content of the memo in Markdown format.
|
||||
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.
|
||||
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"`
|
||||
// Required. The content of the memo in Markdown format.
|
||||
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.
|
||||
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.
|
||||
|
|
@ -336,13 +334,6 @@ func (x *Memo) GetContent() string {
|
|||
return ""
|
||||
}
|
||||
|
||||
func (x *Memo) GetNodes() []*Node {
|
||||
if x != nil {
|
||||
return x.Nodes
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (x *Memo) GetVisibility() Visibility {
|
||||
if x != nil {
|
||||
return x.Visibility
|
||||
|
|
@ -2000,7 +1991,7 @@ var File_api_v1_memo_service_proto protoreflect.FileDescriptor
|
|||
|
||||
const file_api_v1_memo_service_proto_rawDesc = "" +
|
||||
"\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" +
|
||||
"\x04name\x18\x01 \x01(\tB\x06\xe0A\x03\xe0A\bR\x04name\x123\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" +
|
||||
"\vcreate_time\x18\x05 \x01(\v2\x1a.google.protobuf.TimestampB\x03\xe0A\x03R\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" +
|
||||
"\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" +
|
||||
|
|
@ -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" +
|
||||
"updateTime\x12B\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" +
|
||||
"\x05nodes\x18\b \x03(\v2\x12.memos.api.v1.NodeB\x03\xe0A\x03R\x05nodes\x12=\n" +
|
||||
"\acontent\x18\a \x01(\tB\x03\xe0A\x02R\acontent\x12=\n" +
|
||||
"\n" +
|
||||
"visibility\x18\t \x01(\x0e2\x18.memos.api.v1.VisibilityB\x03\xe0A\x02R\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
|
||||
(*timestamppb.Timestamp)(nil), // 29: google.protobuf.Timestamp
|
||||
(State)(0), // 30: memos.api.v1.State
|
||||
(*Node)(nil), // 31: memos.api.v1.Node
|
||||
(*Attachment)(nil), // 32: memos.api.v1.Attachment
|
||||
(*fieldmaskpb.FieldMask)(nil), // 33: google.protobuf.FieldMask
|
||||
(*emptypb.Empty)(nil), // 34: google.protobuf.Empty
|
||||
(*Attachment)(nil), // 31: memos.api.v1.Attachment
|
||||
(*fieldmaskpb.FieldMask)(nil), // 32: google.protobuf.FieldMask
|
||||
(*emptypb.Empty)(nil), // 33: google.protobuf.Empty
|
||||
}
|
||||
var file_api_v1_memo_service_proto_depIdxs = []int32{
|
||||
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, // 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
|
||||
31, // 5: memos.api.v1.Memo.nodes:type_name -> memos.api.v1.Node
|
||||
0, // 6: memos.api.v1.Memo.visibility:type_name -> memos.api.v1.Visibility
|
||||
32, // 7: memos.api.v1.Memo.attachments:type_name -> memos.api.v1.Attachment
|
||||
16, // 8: memos.api.v1.Memo.relations:type_name -> memos.api.v1.MemoRelation
|
||||
2, // 9: memos.api.v1.Memo.reactions:type_name -> memos.api.v1.Reaction
|
||||
27, // 10: memos.api.v1.Memo.property:type_name -> memos.api.v1.Memo.Property
|
||||
4, // 11: memos.api.v1.Memo.location:type_name -> memos.api.v1.Location
|
||||
3, // 12: memos.api.v1.CreateMemoRequest.memo:type_name -> memos.api.v1.Memo
|
||||
30, // 13: memos.api.v1.ListMemosRequest.state:type_name -> memos.api.v1.State
|
||||
3, // 14: memos.api.v1.ListMemosResponse.memos:type_name -> memos.api.v1.Memo
|
||||
33, // 15: memos.api.v1.GetMemoRequest.read_mask:type_name -> google.protobuf.FieldMask
|
||||
3, // 16: memos.api.v1.UpdateMemoRequest.memo:type_name -> memos.api.v1.Memo
|
||||
33, // 17: memos.api.v1.UpdateMemoRequest.update_mask:type_name -> google.protobuf.FieldMask
|
||||
32, // 18: memos.api.v1.SetMemoAttachmentsRequest.attachments:type_name -> memos.api.v1.Attachment
|
||||
32, // 19: memos.api.v1.ListMemoAttachmentsResponse.attachments:type_name -> memos.api.v1.Attachment
|
||||
28, // 20: memos.api.v1.MemoRelation.memo:type_name -> memos.api.v1.MemoRelation.Memo
|
||||
28, // 21: memos.api.v1.MemoRelation.related_memo:type_name -> memos.api.v1.MemoRelation.Memo
|
||||
1, // 22: memos.api.v1.MemoRelation.type:type_name -> memos.api.v1.MemoRelation.Type
|
||||
16, // 23: memos.api.v1.SetMemoRelationsRequest.relations:type_name -> memos.api.v1.MemoRelation
|
||||
16, // 24: memos.api.v1.ListMemoRelationsResponse.relations:type_name -> memos.api.v1.MemoRelation
|
||||
3, // 25: memos.api.v1.CreateMemoCommentRequest.comment:type_name -> memos.api.v1.Memo
|
||||
3, // 26: memos.api.v1.ListMemoCommentsResponse.memos:type_name -> memos.api.v1.Memo
|
||||
2, // 27: memos.api.v1.ListMemoReactionsResponse.reactions:type_name -> memos.api.v1.Reaction
|
||||
2, // 28: memos.api.v1.UpsertMemoReactionRequest.reaction:type_name -> memos.api.v1.Reaction
|
||||
5, // 29: memos.api.v1.MemoService.CreateMemo:input_type -> memos.api.v1.CreateMemoRequest
|
||||
6, // 30: memos.api.v1.MemoService.ListMemos:input_type -> memos.api.v1.ListMemosRequest
|
||||
8, // 31: memos.api.v1.MemoService.GetMemo:input_type -> memos.api.v1.GetMemoRequest
|
||||
9, // 32: memos.api.v1.MemoService.UpdateMemo:input_type -> memos.api.v1.UpdateMemoRequest
|
||||
10, // 33: memos.api.v1.MemoService.DeleteMemo:input_type -> memos.api.v1.DeleteMemoRequest
|
||||
11, // 34: memos.api.v1.MemoService.RenameMemoTag:input_type -> memos.api.v1.RenameMemoTagRequest
|
||||
12, // 35: memos.api.v1.MemoService.DeleteMemoTag:input_type -> memos.api.v1.DeleteMemoTagRequest
|
||||
13, // 36: memos.api.v1.MemoService.SetMemoAttachments:input_type -> memos.api.v1.SetMemoAttachmentsRequest
|
||||
14, // 37: memos.api.v1.MemoService.ListMemoAttachments:input_type -> memos.api.v1.ListMemoAttachmentsRequest
|
||||
17, // 38: memos.api.v1.MemoService.SetMemoRelations:input_type -> memos.api.v1.SetMemoRelationsRequest
|
||||
18, // 39: memos.api.v1.MemoService.ListMemoRelations:input_type -> memos.api.v1.ListMemoRelationsRequest
|
||||
20, // 40: memos.api.v1.MemoService.CreateMemoComment:input_type -> memos.api.v1.CreateMemoCommentRequest
|
||||
21, // 41: memos.api.v1.MemoService.ListMemoComments:input_type -> memos.api.v1.ListMemoCommentsRequest
|
||||
23, // 42: memos.api.v1.MemoService.ListMemoReactions:input_type -> memos.api.v1.ListMemoReactionsRequest
|
||||
25, // 43: memos.api.v1.MemoService.UpsertMemoReaction:input_type -> memos.api.v1.UpsertMemoReactionRequest
|
||||
26, // 44: memos.api.v1.MemoService.DeleteMemoReaction:input_type -> memos.api.v1.DeleteMemoReactionRequest
|
||||
3, // 45: memos.api.v1.MemoService.CreateMemo:output_type -> memos.api.v1.Memo
|
||||
7, // 46: memos.api.v1.MemoService.ListMemos:output_type -> memos.api.v1.ListMemosResponse
|
||||
3, // 47: memos.api.v1.MemoService.GetMemo:output_type -> memos.api.v1.Memo
|
||||
3, // 48: memos.api.v1.MemoService.UpdateMemo:output_type -> memos.api.v1.Memo
|
||||
34, // 49: memos.api.v1.MemoService.DeleteMemo:output_type -> google.protobuf.Empty
|
||||
34, // 50: memos.api.v1.MemoService.RenameMemoTag:output_type -> google.protobuf.Empty
|
||||
34, // 51: memos.api.v1.MemoService.DeleteMemoTag:output_type -> google.protobuf.Empty
|
||||
34, // 52: memos.api.v1.MemoService.SetMemoAttachments:output_type -> google.protobuf.Empty
|
||||
15, // 53: memos.api.v1.MemoService.ListMemoAttachments:output_type -> memos.api.v1.ListMemoAttachmentsResponse
|
||||
34, // 54: memos.api.v1.MemoService.SetMemoRelations:output_type -> google.protobuf.Empty
|
||||
19, // 55: memos.api.v1.MemoService.ListMemoRelations:output_type -> memos.api.v1.ListMemoRelationsResponse
|
||||
3, // 56: memos.api.v1.MemoService.CreateMemoComment:output_type -> memos.api.v1.Memo
|
||||
22, // 57: memos.api.v1.MemoService.ListMemoComments:output_type -> memos.api.v1.ListMemoCommentsResponse
|
||||
24, // 58: memos.api.v1.MemoService.ListMemoReactions:output_type -> memos.api.v1.ListMemoReactionsResponse
|
||||
2, // 59: memos.api.v1.MemoService.UpsertMemoReaction:output_type -> memos.api.v1.Reaction
|
||||
34, // 60: memos.api.v1.MemoService.DeleteMemoReaction:output_type -> google.protobuf.Empty
|
||||
45, // [45:61] is the sub-list for method output_type
|
||||
29, // [29:45] is the sub-list for method input_type
|
||||
29, // [29:29] is the sub-list for extension type_name
|
||||
29, // [29:29] is the sub-list for extension extendee
|
||||
0, // [0:29] is the sub-list for field type_name
|
||||
0, // 5: memos.api.v1.Memo.visibility:type_name -> memos.api.v1.Visibility
|
||||
31, // 6: memos.api.v1.Memo.attachments:type_name -> memos.api.v1.Attachment
|
||||
16, // 7: memos.api.v1.Memo.relations:type_name -> memos.api.v1.MemoRelation
|
||||
2, // 8: 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
|
||||
4, // 10: memos.api.v1.Memo.location:type_name -> memos.api.v1.Location
|
||||
3, // 11: memos.api.v1.CreateMemoRequest.memo:type_name -> memos.api.v1.Memo
|
||||
30, // 12: memos.api.v1.ListMemosRequest.state:type_name -> memos.api.v1.State
|
||||
3, // 13: memos.api.v1.ListMemosResponse.memos:type_name -> memos.api.v1.Memo
|
||||
32, // 14: memos.api.v1.GetMemoRequest.read_mask:type_name -> google.protobuf.FieldMask
|
||||
3, // 15: memos.api.v1.UpdateMemoRequest.memo:type_name -> memos.api.v1.Memo
|
||||
32, // 16: memos.api.v1.UpdateMemoRequest.update_mask:type_name -> google.protobuf.FieldMask
|
||||
31, // 17: memos.api.v1.SetMemoAttachmentsRequest.attachments:type_name -> memos.api.v1.Attachment
|
||||
31, // 18: 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.related_memo:type_name -> memos.api.v1.MemoRelation.Memo
|
||||
1, // 21: 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.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.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.UpsertMemoReactionRequest.reaction:type_name -> memos.api.v1.Reaction
|
||||
5, // 28: memos.api.v1.MemoService.CreateMemo:input_type -> memos.api.v1.CreateMemoRequest
|
||||
6, // 29: memos.api.v1.MemoService.ListMemos:input_type -> memos.api.v1.ListMemosRequest
|
||||
8, // 30: memos.api.v1.MemoService.GetMemo:input_type -> memos.api.v1.GetMemoRequest
|
||||
9, // 31: memos.api.v1.MemoService.UpdateMemo:input_type -> memos.api.v1.UpdateMemoRequest
|
||||
10, // 32: memos.api.v1.MemoService.DeleteMemo:input_type -> memos.api.v1.DeleteMemoRequest
|
||||
11, // 33: memos.api.v1.MemoService.RenameMemoTag:input_type -> memos.api.v1.RenameMemoTagRequest
|
||||
12, // 34: memos.api.v1.MemoService.DeleteMemoTag:input_type -> memos.api.v1.DeleteMemoTagRequest
|
||||
13, // 35: memos.api.v1.MemoService.SetMemoAttachments:input_type -> memos.api.v1.SetMemoAttachmentsRequest
|
||||
14, // 36: memos.api.v1.MemoService.ListMemoAttachments:input_type -> memos.api.v1.ListMemoAttachmentsRequest
|
||||
17, // 37: memos.api.v1.MemoService.SetMemoRelations:input_type -> memos.api.v1.SetMemoRelationsRequest
|
||||
18, // 38: memos.api.v1.MemoService.ListMemoRelations:input_type -> memos.api.v1.ListMemoRelationsRequest
|
||||
20, // 39: memos.api.v1.MemoService.CreateMemoComment:input_type -> memos.api.v1.CreateMemoCommentRequest
|
||||
21, // 40: memos.api.v1.MemoService.ListMemoComments:input_type -> memos.api.v1.ListMemoCommentsRequest
|
||||
23, // 41: memos.api.v1.MemoService.ListMemoReactions:input_type -> memos.api.v1.ListMemoReactionsRequest
|
||||
25, // 42: memos.api.v1.MemoService.UpsertMemoReaction:input_type -> memos.api.v1.UpsertMemoReactionRequest
|
||||
26, // 43: memos.api.v1.MemoService.DeleteMemoReaction:input_type -> memos.api.v1.DeleteMemoReactionRequest
|
||||
3, // 44: memos.api.v1.MemoService.CreateMemo:output_type -> memos.api.v1.Memo
|
||||
7, // 45: 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.UpdateMemo:output_type -> memos.api.v1.Memo
|
||||
33, // 48: memos.api.v1.MemoService.DeleteMemo:output_type -> google.protobuf.Empty
|
||||
33, // 49: memos.api.v1.MemoService.RenameMemoTag:output_type -> google.protobuf.Empty
|
||||
33, // 50: memos.api.v1.MemoService.DeleteMemoTag:output_type -> google.protobuf.Empty
|
||||
33, // 51: memos.api.v1.MemoService.SetMemoAttachments:output_type -> google.protobuf.Empty
|
||||
15, // 52: memos.api.v1.MemoService.ListMemoAttachments:output_type -> memos.api.v1.ListMemoAttachmentsResponse
|
||||
33, // 53: memos.api.v1.MemoService.SetMemoRelations:output_type -> google.protobuf.Empty
|
||||
19, // 54: memos.api.v1.MemoService.ListMemoRelations:output_type -> memos.api.v1.ListMemoRelationsResponse
|
||||
3, // 55: memos.api.v1.MemoService.CreateMemoComment:output_type -> memos.api.v1.Memo
|
||||
22, // 56: memos.api.v1.MemoService.ListMemoComments:output_type -> memos.api.v1.ListMemoCommentsResponse
|
||||
24, // 57: memos.api.v1.MemoService.ListMemoReactions:output_type -> memos.api.v1.ListMemoReactionsResponse
|
||||
2, // 58: memos.api.v1.MemoService.UpsertMemoReaction:output_type -> memos.api.v1.Reaction
|
||||
33, // 59: memos.api.v1.MemoService.DeleteMemoReaction:output_type -> google.protobuf.Empty
|
||||
44, // [44:60] is the sub-list for method output_type
|
||||
28, // [28:44] is the sub-list for method input_type
|
||||
28, // [28:28] is the sub-list for extension type_name
|
||||
28, // [28:28] is the sub-list for extension extendee
|
||||
0, // [0:28] is the sub-list for field type_name
|
||||
}
|
||||
|
||||
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_common_proto_init()
|
||||
file_api_v1_markdown_service_proto_init()
|
||||
file_api_v1_memo_service_proto_msgTypes[1].OneofWrappers = []any{}
|
||||
type x struct{}
|
||||
out := protoimpl.TypeBuilder{
|
||||
|
|
|
|||
|
|
@ -507,114 +507,6 @@ paths:
|
|||
application/json:
|
||||
schema:
|
||||
$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:
|
||||
get:
|
||||
tags:
|
||||
|
|
@ -2312,48 +2204,6 @@ components:
|
|||
description: |-
|
||||
Optional. The related memo. Refer to `Memo.name`.
|
||||
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:
|
||||
type: object
|
||||
properties:
|
||||
|
|
@ -2436,20 +2286,6 @@ components:
|
|||
deleteRelatedMemos:
|
||||
type: boolean
|
||||
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:
|
||||
type: object
|
||||
properties:
|
||||
|
|
@ -2492,41 +2328,6 @@ components:
|
|||
description: The type of the serialized message.
|
||||
additionalProperties: true
|
||||
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:
|
||||
required:
|
||||
- type
|
||||
|
|
@ -2561,13 +2362,6 @@ components:
|
|||
properties:
|
||||
oauth2Config:
|
||||
$ref: '#/components/schemas/OAuth2Config'
|
||||
ImageNode:
|
||||
type: object
|
||||
properties:
|
||||
altText:
|
||||
type: string
|
||||
url:
|
||||
type: string
|
||||
Inbox:
|
||||
type: object
|
||||
properties:
|
||||
|
|
@ -2614,39 +2408,6 @@ components:
|
|||
type: integer
|
||||
description: Optional. The activity ID associated with this inbox notification.
|
||||
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:
|
||||
type: object
|
||||
properties:
|
||||
|
|
@ -2788,24 +2549,6 @@ components:
|
|||
type: integer
|
||||
description: The total count of memos (may be approximate).
|
||||
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:
|
||||
type: object
|
||||
properties:
|
||||
|
|
@ -2894,16 +2637,6 @@ components:
|
|||
type: number
|
||||
description: The longitude of the location.
|
||||
format: double
|
||||
MathBlockNode:
|
||||
type: object
|
||||
properties:
|
||||
content:
|
||||
type: string
|
||||
MathNode:
|
||||
type: object
|
||||
properties:
|
||||
content:
|
||||
type: string
|
||||
Memo:
|
||||
required:
|
||||
- state
|
||||
|
|
@ -2947,12 +2680,6 @@ components:
|
|||
content:
|
||||
type: string
|
||||
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:
|
||||
enum:
|
||||
- VISIBILITY_UNSPECIFIED
|
||||
|
|
@ -3055,111 +2782,6 @@ components:
|
|||
hasIncompleteTasks:
|
||||
type: boolean
|
||||
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:
|
||||
type: object
|
||||
properties:
|
||||
|
|
@ -3179,41 +2801,6 @@ components:
|
|||
type: string
|
||||
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:
|
||||
required:
|
||||
- contentId
|
||||
|
|
@ -3246,15 +2833,6 @@ components:
|
|||
type: string
|
||||
description: Output only. The creation timestamp.
|
||||
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:
|
||||
required:
|
||||
- parent
|
||||
|
|
@ -3273,22 +2851,6 @@ components:
|
|||
newTag:
|
||||
type: string
|
||||
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:
|
||||
required:
|
||||
- name
|
||||
|
|
@ -3337,11 +2899,6 @@ components:
|
|||
filter:
|
||||
type: string
|
||||
description: The filter expression for the shortcut.
|
||||
SpoilerNode:
|
||||
type: object
|
||||
properties:
|
||||
content:
|
||||
type: string
|
||||
Status:
|
||||
type: object
|
||||
properties:
|
||||
|
|
@ -3376,95 +2933,6 @@ components:
|
|||
description: |-
|
||||
S3 configuration for cloud storage backend.
|
||||
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:
|
||||
required:
|
||||
- name
|
||||
|
|
@ -3884,7 +3352,6 @@ tags:
|
|||
- name: AuthService
|
||||
- name: IdentityProviderService
|
||||
- name: InboxService
|
||||
- name: MarkdownService
|
||||
- name: MemoService
|
||||
- name: ShortcutService
|
||||
- name: UserService
|
||||
|
|
|
|||
|
|
@ -14,7 +14,6 @@ var authenticationAllowlistMethods = map[string]bool{
|
|||
"/memos.api.v1.UserService/SearchUsers": true,
|
||||
"/memos.api.v1.MemoService/GetMemo": true,
|
||||
"/memos.api.v1.MemoService/ListMemos": true,
|
||||
"/memos.api.v1.MarkdownService/GetLinkMetadata": 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 {
|
||||
return nil, err
|
||||
}
|
||||
memoSnippet, err := getMemoContentSnippet(memo.Content)
|
||||
memoSnippet, err := s.getMemoContentSnippet(memo.Content)
|
||||
if err != nil {
|
||||
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 {
|
||||
return nil, err
|
||||
}
|
||||
relatedMemoSnippet, err := getMemoContentSnippet(relatedMemo.Content)
|
||||
relatedMemoSnippet, err := s.getMemoContentSnippet(relatedMemo.Content)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "failed to get related memo content snippet")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,9 +10,6 @@ import (
|
|||
|
||||
"github.com/lithammer/shortuuid/v4"
|
||||
"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/status"
|
||||
"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 {
|
||||
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)
|
||||
}
|
||||
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)
|
||||
}
|
||||
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)
|
||||
}
|
||||
update.Content = &memo.Content
|
||||
|
|
@ -711,17 +708,14 @@ func (s *APIV1Service) RenameMemoTag(ctx context.Context, request *v1pb.RenameMe
|
|||
}
|
||||
|
||||
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 {
|
||||
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) {
|
||||
if tag, ok := node.(*ast.Tag); ok && tag.Content == request.OldTag {
|
||||
tag.Content = request.NewTag
|
||||
}
|
||||
})
|
||||
memo.Content = gomark.Restore(doc)
|
||||
if err := memopayload.RebuildMemoPayload(memo); err != nil {
|
||||
memo.Content = newContent
|
||||
|
||||
if err := memopayload.RebuildMemoPayload(memo, s.MarkdownService); err != nil {
|
||||
return nil, status.Errorf(codes.Internal, "failed to rebuild memo payload: %v", err)
|
||||
}
|
||||
if err := s.Store.UpdateMemo(ctx, &store.UpdateMemo{
|
||||
|
|
@ -842,17 +836,13 @@ func convertMemoToWebhookPayload(memo *v1pb.Memo) (*webhook.WebhookRequestPayloa
|
|||
}, nil
|
||||
}
|
||||
|
||||
func getMemoContentSnippet(content string) (string, error) {
|
||||
doc, err := gomark.Parse(content)
|
||||
func (s *APIV1Service) getMemoContentSnippet(content string) (string, error) {
|
||||
// Use goldmark service for snippet generation
|
||||
snippet, err := s.MarkdownService.GenerateSnippet([]byte(content), 64)
|
||||
if err != nil {
|
||||
return "", errors.Wrap(err, "failed to parse content")
|
||||
return "", errors.Wrap(err, "failed to generate snippet")
|
||||
}
|
||||
|
||||
plainText := renderer.NewStringRenderer().RenderDocument(doc)
|
||||
if len(plainText) > 64 {
|
||||
return substring(plainText, 64) + "...", nil
|
||||
}
|
||||
return plainText, nil
|
||||
return snippet, nil
|
||||
}
|
||||
|
||||
func substring(s string, length int) string {
|
||||
|
|
|
|||
|
|
@ -8,8 +8,6 @@ import (
|
|||
"github.com/pkg/errors"
|
||||
"google.golang.org/protobuf/types/known/timestamppb"
|
||||
|
||||
"github.com/usememos/gomark"
|
||||
|
||||
v1pb "github.com/usememos/memos/proto/gen/api/v1"
|
||||
storepb "github.com/usememos/memos/proto/gen/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)
|
||||
}
|
||||
|
||||
doc, err := gomark.Parse(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)
|
||||
snippet, err := s.getMemoContentSnippet(memo.Content)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "failed to get memo content snippet")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import (
|
|||
"testing"
|
||||
|
||||
"github.com/usememos/memos/internal/profile"
|
||||
"github.com/usememos/memos/plugin/markdown"
|
||||
apiv1 "github.com/usememos/memos/server/router/api/v1"
|
||||
"github.com/usememos/memos/store"
|
||||
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
|
||||
secret := "test-secret"
|
||||
markdownService := markdown.NewService(
|
||||
markdown.WithTagExtension(),
|
||||
markdown.WithWikilinkExtension(),
|
||||
)
|
||||
service := &apiv1.APIV1Service{
|
||||
Secret: secret,
|
||||
Profile: testProfile,
|
||||
Store: testStore,
|
||||
MarkdownService: markdownService,
|
||||
}
|
||||
|
||||
return &TestService{
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import (
|
|||
"google.golang.org/grpc/reflection"
|
||||
|
||||
"github.com/usememos/memos/internal/profile"
|
||||
"github.com/usememos/memos/plugin/markdown"
|
||||
v1pb "github.com/usememos/memos/proto/gen/api/v1"
|
||||
"github.com/usememos/memos/store"
|
||||
)
|
||||
|
|
@ -30,22 +31,27 @@ type APIV1Service struct {
|
|||
v1pb.UnimplementedShortcutServiceServer
|
||||
v1pb.UnimplementedInboxServiceServer
|
||||
v1pb.UnimplementedActivityServiceServer
|
||||
v1pb.UnimplementedMarkdownServiceServer
|
||||
v1pb.UnimplementedIdentityProviderServiceServer
|
||||
|
||||
Secret string
|
||||
Profile *profile.Profile
|
||||
Store *store.Store
|
||||
MarkdownService markdown.Service
|
||||
|
||||
grpcServer *grpc.Server
|
||||
}
|
||||
|
||||
func NewAPIV1Service(secret string, profile *profile.Profile, store *store.Store, grpcServer *grpc.Server) *APIV1Service {
|
||||
grpc.EnableTracing = true
|
||||
markdownService := markdown.NewService(
|
||||
markdown.WithTagExtension(),
|
||||
markdown.WithWikilinkExtension(),
|
||||
)
|
||||
apiv1Service := &APIV1Service{
|
||||
Secret: secret,
|
||||
Profile: profile,
|
||||
Store: store,
|
||||
MarkdownService: markdownService,
|
||||
grpcServer: grpcServer,
|
||||
}
|
||||
grpc_health_v1.RegisterHealthServer(grpcServer, apiv1Service)
|
||||
|
|
@ -57,7 +63,6 @@ func NewAPIV1Service(secret string, profile *profile.Profile, store *store.Store
|
|||
v1pb.RegisterShortcutServiceServer(grpcServer, apiv1Service)
|
||||
v1pb.RegisterInboxServiceServer(grpcServer, apiv1Service)
|
||||
v1pb.RegisterActivityServiceServer(grpcServer, apiv1Service)
|
||||
v1pb.RegisterMarkdownServiceServer(grpcServer, apiv1Service)
|
||||
v1pb.RegisterIdentityProviderServiceServer(grpcServer, apiv1Service)
|
||||
reflection.Register(grpcServer)
|
||||
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 {
|
||||
return err
|
||||
}
|
||||
if err := v1pb.RegisterMarkdownServiceHandler(ctx, gwMux, conn); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := v1pb.RegisterIdentityProviderServiceHandler(ctx, gwMux, conn); err != nil {
|
||||
return err
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,10 +9,9 @@ import (
|
|||
|
||||
"github.com/gorilla/feeds"
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/usememos/gomark"
|
||||
"github.com/usememos/gomark/renderer"
|
||||
|
||||
"github.com/usememos/memos/internal/profile"
|
||||
"github.com/usememos/memos/plugin/markdown"
|
||||
storepb "github.com/usememos/memos/proto/gen/store"
|
||||
"github.com/usememos/memos/store"
|
||||
)
|
||||
|
|
@ -24,6 +23,7 @@ const (
|
|||
type RSSService struct {
|
||||
Profile *profile.Profile
|
||||
Store *store.Store
|
||||
MarkdownService markdown.Service
|
||||
}
|
||||
|
||||
type RSSHeading struct {
|
||||
|
|
@ -31,10 +31,11 @@ type RSSHeading struct {
|
|||
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{
|
||||
Profile: profile,
|
||||
Store: store,
|
||||
MarkdownService: markdownService,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -113,7 +114,7 @@ func (s *RSSService) generateRSSFromMemoList(ctx context.Context, memoList []*st
|
|||
feed.Items = make([]*feeds.Item, itemCountLimit)
|
||||
for i := 0; i < itemCountLimit; i++ {
|
||||
memo := memoList[i]
|
||||
description, err := getRSSItemDescription(memo.Content)
|
||||
description, err := s.getRSSItemDescription(memo.Content)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
|
@ -151,13 +152,12 @@ func (s *RSSService) generateRSSFromMemoList(ctx context.Context, memoList []*st
|
|||
return rss, nil
|
||||
}
|
||||
|
||||
func getRSSItemDescription(content string) (string, error) {
|
||||
doc, err := gomark.Parse(content)
|
||||
func (s *RSSService) getRSSItemDescription(content string) (string, error) {
|
||||
html, err := s.MarkdownService.RenderHTML([]byte(content))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
result := renderer.NewHTMLRenderer().RenderDocument(doc)
|
||||
return result, nil
|
||||
return html, nil
|
||||
}
|
||||
|
||||
func getRSSHeading(ctx context.Context, stores *store.Store) (RSSHeading, error) {
|
||||
|
|
|
|||
|
|
@ -3,23 +3,23 @@ package memopayload
|
|||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"slices"
|
||||
|
||||
"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"
|
||||
"github.com/usememos/memos/store"
|
||||
)
|
||||
|
||||
type Runner struct {
|
||||
Store *store.Store
|
||||
MarkdownService markdown.Service
|
||||
}
|
||||
|
||||
func NewRunner(store *store.Store) *Runner {
|
||||
func NewRunner(store *store.Store, markdownService markdown.Service) *Runner {
|
||||
return &Runner{
|
||||
Store: store,
|
||||
MarkdownService: markdownService,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -49,7 +49,7 @@ func (r *Runner) RunOnce(ctx context.Context) {
|
|||
// Process batch
|
||||
batchSuccessCount := 0
|
||||
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)
|
||||
continue
|
||||
}
|
||||
|
|
@ -71,70 +71,21 @@ func (r *Runner) RunOnce(ctx context.Context) {
|
|||
}
|
||||
}
|
||||
|
||||
func RebuildMemoPayload(memo *store.Memo) error {
|
||||
doc, err := gomark.Parse(memo.Content)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "failed to parse content")
|
||||
}
|
||||
|
||||
func RebuildMemoPayload(memo *store.Memo, markdownService markdown.Service) error {
|
||||
if memo.Payload == nil {
|
||||
memo.Payload = &storepb.MemoPayload{}
|
||||
}
|
||||
tags := []string{}
|
||||
property := &storepb.MemoPayload_Property{}
|
||||
TraverseASTDocument(doc, func(node ast.Node) {
|
||||
switch n := node.(type) {
|
||||
case *ast.Tag:
|
||||
tag := n.Content
|
||||
if !slices.Contains(tags, tag) {
|
||||
tags = append(tags, tag)
|
||||
|
||||
// Use goldmark service to extract all metadata in a single pass (more efficient)
|
||||
data, err := markdownService.ExtractAll([]byte(memo.Content))
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "failed to extract markdown metadata")
|
||||
}
|
||||
case *ast.Link, *ast.AutoLink:
|
||||
property.HasLink = true
|
||||
case *ast.TaskListItem:
|
||||
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
|
||||
|
||||
// Set references in property
|
||||
data.Property.References = data.References
|
||||
|
||||
memo.Payload.Tags = data.Tags
|
||||
memo.Payload.Property = data.Property
|
||||
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("")
|
||||
|
||||
// Create and register RSS routes.
|
||||
rss.NewRSSService(s.Profile, s.Store).RegisterRoutes(rootGroup)
|
||||
|
||||
// Log full stacktraces if we're in dev
|
||||
logStacktraces := profile.IsDev()
|
||||
|
||||
|
|
@ -98,6 +95,9 @@ func NewServer(ctx context.Context, profile *profile.Profile, store *store.Store
|
|||
s.grpcServer = 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.
|
||||
if err := apiV1Service.RegisterGateway(ctx, echoServer); err != nil {
|
||||
return nil, errors.Wrap(err, "failed to register gRPC gateway")
|
||||
|
|
|
|||
|
|
@ -50,12 +50,17 @@
|
|||
"react-hot-toast": "^2.6.0",
|
||||
"react-i18next": "^15.7.3",
|
||||
"react-leaflet": "^4.2.1",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-router-dom": "^7.9.1",
|
||||
"react-simple-pull-to-refresh": "^1.3.3",
|
||||
"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",
|
||||
"tailwindcss": "^4.1.13",
|
||||
"textarea-caret": "^3.1.0",
|
||||
"unist-util-visit": "^5.0.0",
|
||||
"uuid": "^11.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
|
@ -66,14 +71,15 @@
|
|||
"@types/katex": "^0.16.7",
|
||||
"@types/leaflet": "^1.9.20",
|
||||
"@types/lodash-es": "^4.17.12",
|
||||
"@types/mdast": "^4.0.4",
|
||||
"@types/node": "^24.5.1",
|
||||
"@types/qs": "^6.14.0",
|
||||
"@types/react": "^18.3.24",
|
||||
"@types/react-dom": "^18.3.7",
|
||||
"@types/textarea-caret": "^3.0.4",
|
||||
"@types/unist": "^3.0.3",
|
||||
"@types/uuid": "^10.0.0",
|
||||
"@vitejs/plugin-react": "^4.7.0",
|
||||
"code-inspector-plugin": "^1.2.10",
|
||||
"eslint": "^9.35.0",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"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 { useLocation } from "react-router-dom";
|
||||
import ConfirmDialog from "@/components/ConfirmDialog";
|
||||
import { markdownServiceClient } from "@/grpcweb";
|
||||
import useNavigateTo from "@/hooks/useNavigateTo";
|
||||
import { memoStore, userStore } from "@/store";
|
||||
import { workspaceStore } from "@/store";
|
||||
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 { useTranslate } from "@/utils/i18n";
|
||||
import { hasCompletedTasks, removeCompletedTasks } from "@/utils/markdown-manipulation";
|
||||
import { Button } from "./ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
|
|
@ -44,16 +43,7 @@ interface Props {
|
|||
}
|
||||
|
||||
const checkHasCompletedTaskList = (memo: Memo) => {
|
||||
for (const node of memo.nodes) {
|
||||
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;
|
||||
return hasCompletedTasks(memo.content);
|
||||
};
|
||||
|
||||
const MemoActionMenu = observer((props: Props) => {
|
||||
|
|
@ -160,27 +150,11 @@ const MemoActionMenu = observer((props: Props) => {
|
|||
};
|
||||
|
||||
const confirmRemoveCompletedTaskListItems = async () => {
|
||||
const newNodes = JSON.parse(JSON.stringify(memo.nodes));
|
||||
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 });
|
||||
const newContent = removeCompletedTasks(memo.content);
|
||||
await memoStore.updateMemo(
|
||||
{
|
||||
name: memo.name,
|
||||
content: markdown,
|
||||
content: newContent,
|
||||
},
|
||||
["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 { useLocation } from "react-router-dom";
|
||||
import useNavigateTo from "@/hooks/useNavigateTo";
|
||||
|
|
@ -6,18 +5,34 @@ import { cn } from "@/lib/utils";
|
|||
import { Routes } from "@/router";
|
||||
import { memoFilterStore } from "@/store";
|
||||
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) => {
|
||||
const context = useContext(RendererContext);
|
||||
export const Tag: React.FC<TagProps> = ({ "data-tag": dataTag, children, className, ...props }) => {
|
||||
const context = useContext(MemoContentContext);
|
||||
const location = useLocation();
|
||||
const navigateTo = useNavigateTo();
|
||||
|
||||
const handleTagClick = () => {
|
||||
const tag = dataTag || "";
|
||||
|
||||
const handleTagClick = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
|
||||
if (context.disableFilter) {
|
||||
return;
|
||||
}
|
||||
|
|
@ -27,34 +42,30 @@ const Tag = observer(({ content }: Props) => {
|
|||
const pathname = context.parentPage || Routes.ROOT;
|
||||
const searchParams = new URLSearchParams();
|
||||
|
||||
searchParams.set("filter", stringifyFilters([{ factor: "tagSearch", value: content }]));
|
||||
searchParams.set("filter", stringifyFilters([{ factor: "tagSearch", value: tag }]));
|
||||
navigateTo(`${pathname}?${searchParams.toString()}`);
|
||||
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) {
|
||||
memoFilterStore.removeFilter((f: MemoFilter) => f.factor === "tagSearch" && f.value === content);
|
||||
memoFilterStore.removeFilter((f: MemoFilter) => f.factor === "tagSearch" && f.value === tag);
|
||||
} else {
|
||||
memoFilterStore.addFilter({
|
||||
factor: "tagSearch",
|
||||
value: content,
|
||||
value: tag,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"inline-block w-auto px-1 py-px rounded-md text-sm bg-secondary text-secondary-foreground",
|
||||
context.disableFilter ? "" : "cursor-pointer hover:opacity-80 transition-colors",
|
||||
)}
|
||||
{...props}
|
||||
className={cn("inline-block w-auto", context.disableFilter ? "" : "cursor-pointer hover:opacity-80 transition-colors", className)}
|
||||
data-tag={tag}
|
||||
onClick={handleTagClick}
|
||||
>
|
||||
<span className="opacity-70 font-mono">#</span>
|
||||
{content}
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
});
|
||||
|
||||
export default Tag;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,58 +1,76 @@
|
|||
import { observer } from "mobx-react-lite";
|
||||
import { useContext } from "react";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { markdownServiceClient } from "@/grpcweb";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { memoStore } from "@/store";
|
||||
import { Node, TaskListItemNode } from "@/types/proto/api/v1/markdown_service";
|
||||
import Renderer from "./Renderer";
|
||||
import { RendererContext } from "./types";
|
||||
import { toggleTaskAtIndex } from "@/utils/markdown-manipulation";
|
||||
import { MemoContentContext } from "./MemoContentContext";
|
||||
|
||||
interface Props {
|
||||
node: Node;
|
||||
index: string;
|
||||
symbol: string;
|
||||
indent: number;
|
||||
complete: boolean;
|
||||
children: Node[];
|
||||
/**
|
||||
* Custom checkbox component for react-markdown task lists
|
||||
*
|
||||
* Handles interactive task checkbox clicks and updates memo content.
|
||||
* This component is used via react-markdown's components prop.
|
||||
*
|
||||
* 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) => {
|
||||
const context = useContext(RendererContext);
|
||||
export const TaskListItem: React.FC<TaskListItemProps> = ({ checked, ...props }) => {
|
||||
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) {
|
||||
return;
|
||||
}
|
||||
|
||||
(node.taskListItemNode as TaskListItemNode)!.complete = on;
|
||||
const { markdown } = await markdownServiceClient.restoreMarkdownNodes({ nodes: context.nodes });
|
||||
const newChecked = e.target.checked;
|
||||
|
||||
// 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(
|
||||
{
|
||||
name: context.memoName,
|
||||
content: markdown,
|
||||
name: memo.name,
|
||||
content: newContent,
|
||||
},
|
||||
["content"],
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<li className={cn("w-full grid grid-cols-[24px_1fr]")}>
|
||||
<span className="w-6 h-6 flex justify-start items-center">
|
||||
<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;
|
||||
// Override the disabled prop from remark-gfm (which defaults to true)
|
||||
// We want interactive checkboxes, only disabled when readonly
|
||||
return <input {...props} type="checkbox" checked={checked} disabled={context.readonly} onChange={handleChange} />;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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 { 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 { cn } from "@/lib/utils";
|
||||
import { memoStore } from "@/store";
|
||||
import { Node, NodeType } from "@/types/proto/api/v1/markdown_service";
|
||||
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 Renderer from "./Renderer";
|
||||
import { RendererContext } from "./types";
|
||||
import { createConditionalComponent, isTagNode, isTaskListItemNode, isWikiLinkNode } from "./ConditionalComponent";
|
||||
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.
|
||||
const MAX_DISPLAY_HEIGHT = 256;
|
||||
|
||||
interface Props {
|
||||
nodes: Node[];
|
||||
content: string;
|
||||
memoName?: string;
|
||||
compact?: boolean;
|
||||
readonly?: 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;
|
||||
contentClassName?: string;
|
||||
onClick?: (e: React.MouseEvent) => void;
|
||||
|
|
@ -31,7 +37,7 @@ interface Props {
|
|||
type ContentCompactView = "ALL" | "SNIPPET";
|
||||
|
||||
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 currentUser = useCurrentUser();
|
||||
const memoContentContainerRef = useRef<HTMLDivElement>(null);
|
||||
|
|
@ -39,6 +45,14 @@ const MemoContent = observer((props: Props) => {
|
|||
const memo = memoName ? memoStore.getMemoByName(memoName) : null;
|
||||
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.
|
||||
useEffect(() => {
|
||||
if (!props.compact) {
|
||||
|
|
@ -54,6 +68,7 @@ const MemoContent = observer((props: Props) => {
|
|||
}, []);
|
||||
|
||||
const onMemoContentClick = async (e: React.MouseEvent) => {
|
||||
// Image clicks and other handlers
|
||||
if (onClick) {
|
||||
onClick(e);
|
||||
}
|
||||
|
|
@ -65,48 +80,40 @@ const MemoContent = observer((props: Props) => {
|
|||
}
|
||||
};
|
||||
|
||||
let prevNode: Node | null = null;
|
||||
let skipNextLineBreakFlag = false;
|
||||
const compactStates = {
|
||||
ALL: { text: t("memo.show-more"), nextState: "SNIPPET" },
|
||||
SNIPPET: { text: t("memo.show-less"), nextState: "ALL" },
|
||||
};
|
||||
|
||||
return (
|
||||
<RendererContext.Provider
|
||||
value={{
|
||||
nodes,
|
||||
memoName: memoName,
|
||||
readonly: !allowEdit,
|
||||
disableFilter: props.disableFilter,
|
||||
embeddedMemos: embeddedMemos || new Set(),
|
||||
parentPage: props.parentPage,
|
||||
}}
|
||||
>
|
||||
<MemoContentContext.Provider value={contextValue}>
|
||||
<div className={`w-full flex flex-col justify-start items-start text-foreground ${className || ""}`}>
|
||||
<div
|
||||
ref={memoContentContainerRef}
|
||||
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",
|
||||
contentClassName,
|
||||
)}
|
||||
onClick={onMemoContentClick}
|
||||
onDoubleClick={onMemoContentDoubleClick}
|
||||
>
|
||||
{nodes.map((node, index) => {
|
||||
if (prevNode?.type !== NodeType.LINE_BREAK && node.type === NodeType.LINE_BREAK && skipNextLineBreakFlag) {
|
||||
skipNextLineBreakFlag = false;
|
||||
return null;
|
||||
}
|
||||
prevNode = node;
|
||||
skipNextLineBreakFlag = true;
|
||||
return <Renderer key={`${node.type}-${index}`} index={String(index)} node={node} />;
|
||||
})}
|
||||
{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>
|
||||
)}
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm, remarkWikiLink, remarkTag, remarkPreserveType]}
|
||||
rehypePlugins={[rehypeRaw]}
|
||||
components={{
|
||||
// Conditionally render custom components based on AST node type
|
||||
input: createConditionalComponent(TaskListItem, "input", isTaskListItemNode),
|
||||
a: createConditionalComponent(WikiLink, DefaultLink, isWikiLinkNode),
|
||||
span: createConditionalComponent(Tag, "span", isTagNode),
|
||||
}}
|
||||
>
|
||||
{content}
|
||||
</ReactMarkdown>
|
||||
</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 && (
|
||||
<div className="w-full mt-1">
|
||||
<span
|
||||
|
|
@ -120,7 +127,7 @@ const MemoContent = observer((props: Props) => {
|
|||
</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")}
|
||||
value={searchText}
|
||||
onChange={(e) => setSearchText(e.target.value)}
|
||||
className="mb-2"
|
||||
className="mb-2 !text-sm"
|
||||
/>
|
||||
<div className="max-h-[200px] overflow-y-auto">
|
||||
{filteredMemos.length === 0 ? (
|
||||
|
|
|
|||
|
|
@ -1,8 +1,6 @@
|
|||
import { last } from "lodash-es";
|
||||
import { forwardRef, ReactNode, useCallback, useEffect, useImperativeHandle, useRef, useState } from "react";
|
||||
import { markdownServiceClient } from "@/grpcweb";
|
||||
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 CommandSuggestions from "./CommandSuggestions";
|
||||
import TagSuggestions from "./TagSuggestions";
|
||||
|
|
@ -154,20 +152,6 @@ const Editor = forwardRef(function Editor(props: Props, ref: React.ForwardedRef<
|
|||
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>) => {
|
||||
if (event.key === "Enter" && !isInIME) {
|
||||
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 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
|
||||
const lines = prevContent.split("\n");
|
||||
const lastLine = lines[lines.length - 1];
|
||||
const indentationMatch = lastLine.match(/^\s*/);
|
||||
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.
|
||||
// Detect list item using regex-based detection
|
||||
const listInfo = detectLastListItem(prevContent);
|
||||
if (listInfo.type) {
|
||||
const insertText = generateListContinuation(listInfo);
|
||||
editorActions.insertText(insertText);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -239,7 +239,7 @@ const MemoView: React.FC<Props> = observer((props: Props) => {
|
|||
<MemoContent
|
||||
key={`${memo.name}-${memo.updateTime}`}
|
||||
memoName={memo.name}
|
||||
nodes={memo.nodes}
|
||||
content={memo.content}
|
||||
readonly={readonly}
|
||||
onClick={handleMemoContentClick}
|
||||
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 { IdentityProviderServiceDefinition } from "./types/proto/api/v1/idp_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 { ShortcutServiceDefinition } from "./types/proto/api/v1/shortcut_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 markdownServiceClient = clientFactory.create(MarkdownServiceDefinition, channel);
|
||||
|
||||
export const identityProviderServiceClient = clientFactory.create(IdentityProviderServiceDefinition, channel);
|
||||
|
|
|
|||
|
|
@ -13,4 +13,301 @@
|
|||
body {
|
||||
@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.
|
||||
*/
|
||||
import { uniqBy } from "lodash-es";
|
||||
import { makeObservable, observable, action } from "mobx";
|
||||
import { makeObservable, observable, action, computed } from "mobx";
|
||||
import { StandardState } from "./base-store";
|
||||
|
||||
/**
|
||||
|
|
@ -94,6 +94,7 @@ class MemoFilterState extends StandardState {
|
|||
makeObservable(this, {
|
||||
filters: observable,
|
||||
shortcut: observable,
|
||||
hasActiveFilters: computed,
|
||||
addFilter: action,
|
||||
removeFilter: 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 { Attachment } from "./attachment_service";
|
||||
import { State, stateFromJSON, stateToNumber } from "./common";
|
||||
import { Node } from "./markdown_service";
|
||||
|
||||
export const protobufPackage = "memos.api.v1";
|
||||
|
||||
|
|
@ -110,8 +109,6 @@ export interface Memo {
|
|||
| undefined;
|
||||
/** Required. The content of the memo in Markdown format. */
|
||||
content: string;
|
||||
/** Output only. The parsed nodes from the content. */
|
||||
nodes: Node[];
|
||||
/** The visibility of the memo. */
|
||||
visibility: Visibility;
|
||||
/** Output only. The tags extracted from the content. */
|
||||
|
|
@ -587,7 +584,6 @@ function createBaseMemo(): Memo {
|
|||
updateTime: undefined,
|
||||
displayTime: undefined,
|
||||
content: "",
|
||||
nodes: [],
|
||||
visibility: Visibility.VISIBILITY_UNSPECIFIED,
|
||||
tags: [],
|
||||
pinned: false,
|
||||
|
|
@ -624,9 +620,6 @@ export const Memo: MessageFns<Memo> = {
|
|||
if (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) {
|
||||
writer.uint32(72).int32(visibilityToNumber(message.visibility));
|
||||
}
|
||||
|
|
@ -723,14 +716,6 @@ export const Memo: MessageFns<Memo> = {
|
|||
message.content = reader.string();
|
||||
continue;
|
||||
}
|
||||
case 8: {
|
||||
if (tag !== 66) {
|
||||
break;
|
||||
}
|
||||
|
||||
message.nodes.push(Node.decode(reader, reader.uint32()));
|
||||
continue;
|
||||
}
|
||||
case 9: {
|
||||
if (tag !== 72) {
|
||||
break;
|
||||
|
|
@ -832,7 +817,6 @@ export const Memo: MessageFns<Memo> = {
|
|||
message.updateTime = object.updateTime ?? undefined;
|
||||
message.displayTime = object.displayTime ?? undefined;
|
||||
message.content = object.content ?? "";
|
||||
message.nodes = object.nodes?.map((e) => Node.fromPartial(e)) || [];
|
||||
message.visibility = object.visibility ?? Visibility.VISIBILITY_UNSPECIFIED;
|
||||
message.tags = object.tags?.map((e) => e) || [];
|
||||
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 { codeInspectorPlugin } from "code-inspector-plugin";
|
||||
import { resolve } from "path";
|
||||
import { defineConfig } from "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/
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
react(),
|
||||
tailwindcss(),
|
||||
codeInspectorPlugin({
|
||||
bundler: "vite",
|
||||
}),
|
||||
],
|
||||
plugins: [react(), tailwindcss()],
|
||||
server: {
|
||||
host: "0.0.0.0",
|
||||
port: 3001,
|
||||
|
|
|
|||
Loading…
Reference in New Issue