170 lines
4.9 KiB
Go
170 lines
4.9 KiB
Go
package vault
|
|
|
|
import (
|
|
"bytes"
|
|
"fmt"
|
|
"html"
|
|
"net/url"
|
|
"path"
|
|
"regexp"
|
|
"sort"
|
|
"strings"
|
|
"unicode"
|
|
"unicode/utf8"
|
|
|
|
"github.com/yuin/goldmark/v2/ast"
|
|
"github.com/yuin/goldmark/v2/parser"
|
|
"go.yaml.in/yaml/v3"
|
|
)
|
|
|
|
type Reference struct {
|
|
Target string `json:"target"`
|
|
Line int `json:"line"`
|
|
}
|
|
|
|
type Metadata struct {
|
|
Title string `json:"title"`
|
|
Tags []string `json:"tags"`
|
|
Links []Reference `json:"links"`
|
|
Assets []Reference `json:"assets"`
|
|
Warnings []string `json:"warnings"`
|
|
}
|
|
|
|
var inlineTag = regexp.MustCompile(`#[\p{L}\p{N}_][\p{L}\p{N}_/-]*`)
|
|
|
|
// LocalTarget 在链接边界解码 Markdown 和 URL,拒绝远程地址、绝对路径及越界路径。
|
|
func LocalTarget(note, raw string) (string, *url.URL, bool) {
|
|
raw = html.UnescapeString(markdownEscape.ReplaceAllString(raw, "$1"))
|
|
u, err := url.Parse(raw)
|
|
if err != nil || u.IsAbs() || u.Host != "" || strings.HasPrefix(raw, "/") || u.Path == "" {
|
|
return "", nil, false
|
|
}
|
|
target := path.Clean(path.Join(path.Dir(note), u.Path))
|
|
if _, err := CleanPath(target); err != nil {
|
|
return "", nil, false
|
|
}
|
|
return target, u, true
|
|
}
|
|
|
|
// Analyze 提取语义关系而不重新序列化正文;源位置保留为原文行号。
|
|
func Analyze(note string, content []byte) Metadata {
|
|
m := Metadata{Title: strings.TrimSuffix(path.Base(note), NoteExt), Tags: []string{}, Links: []Reference{}, Assets: []Reference{}, Warnings: []string{}}
|
|
tags := map[string]bool{}
|
|
addTag := func(s string) {
|
|
s = strings.TrimPrefix(strings.TrimSpace(s), "#")
|
|
if s != "" {
|
|
tags[s] = true
|
|
}
|
|
}
|
|
source := bytes.Clone(content)
|
|
lines := bytes.SplitAfter(source, []byte("\n"))
|
|
if len(lines) > 0 && strings.TrimSpace(string(lines[0])) == "---" {
|
|
end, bodyEnd, offset := -1, 0, len(lines[0])
|
|
for i := 1; i < len(lines); i++ {
|
|
if strings.TrimSpace(string(lines[i])) == "---" {
|
|
bodyEnd = offset
|
|
end = offset + len(lines[i])
|
|
break
|
|
}
|
|
offset += len(lines[i])
|
|
}
|
|
if end < 0 {
|
|
m.Warnings = append(m.Warnings, "YAML 元数据未闭合")
|
|
end = len(source)
|
|
} else {
|
|
var front struct {
|
|
Tags yaml.Node `yaml:"tags"`
|
|
Title string `yaml:"title"`
|
|
}
|
|
if err := yaml.Unmarshal(source[len(lines[0]):bodyEnd], &front); err != nil {
|
|
m.Warnings = append(m.Warnings, fmt.Sprintf("YAML 元数据无效:%v", err))
|
|
} else {
|
|
if front.Title != "" {
|
|
m.Title = front.Title
|
|
}
|
|
switch front.Tags.Kind {
|
|
case 0:
|
|
case yaml.ScalarNode:
|
|
if front.Tags.Tag == "!!str" {
|
|
for _, tag := range strings.FieldsFunc(front.Tags.Value, func(r rune) bool { return r == ',' || unicode.IsSpace(r) }) {
|
|
addTag(tag)
|
|
}
|
|
} else {
|
|
m.Warnings = append(m.Warnings, "YAML tags 必须是字符串或字符串列表")
|
|
}
|
|
case yaml.SequenceNode:
|
|
for _, tag := range front.Tags.Content {
|
|
if tag.Kind != yaml.ScalarNode || tag.Tag != "!!str" {
|
|
m.Warnings = append(m.Warnings, "YAML tags 必须是字符串列表")
|
|
break
|
|
}
|
|
addTag(tag.Value)
|
|
}
|
|
default:
|
|
m.Warnings = append(m.Warnings, "YAML tags 必须是字符串或字符串列表")
|
|
}
|
|
}
|
|
}
|
|
for i := 0; i < end; i++ {
|
|
if source[i] != '\n' && source[i] != '\r' {
|
|
source[i] = ' '
|
|
}
|
|
}
|
|
}
|
|
root := parser.New().Parse(source)
|
|
_ = ast.Walk(root, func(n ast.Node, entering bool) (ast.WalkStatus, error) {
|
|
if !entering {
|
|
return ast.WalkContinue, nil
|
|
}
|
|
addRef := func(raw string, pos int) {
|
|
target, _, ok := LocalTarget(note, raw)
|
|
if !ok {
|
|
return
|
|
}
|
|
ref := Reference{target, bytes.Count(source[:max(0, pos)], []byte("\n")) + 1}
|
|
if IsNote(target) {
|
|
m.Links = append(m.Links, ref)
|
|
} else {
|
|
m.Assets = append(m.Assets, ref)
|
|
}
|
|
}
|
|
switch node := n.(type) {
|
|
case *ast.CodeBlock, *ast.CodeSpan, *ast.HTMLBlock, *ast.RawHTML, *ast.LinkReferenceDefinition:
|
|
return ast.WalkSkipChildren, nil
|
|
case *ast.Link:
|
|
addRef(node.Destination.Value(source), node.Pos())
|
|
return ast.WalkSkipChildren, nil
|
|
case *ast.Image:
|
|
addRef(node.Destination.Value(source), node.Pos())
|
|
return ast.WalkSkipChildren, nil
|
|
case *ast.Text:
|
|
value := node.Value.Value(source)
|
|
for _, loc := range inlineTag.FindAllStringIndex(value, -1) {
|
|
if loc[0] > 0 {
|
|
prev, _ := utf8.DecodeLastRuneInString(value[:loc[0]])
|
|
if unicode.IsLetter(prev) || unicode.IsNumber(prev) || prev == '/' || prev == '\\' || prev == '&' {
|
|
continue
|
|
}
|
|
}
|
|
addTag(value[loc[0]:loc[1]])
|
|
}
|
|
}
|
|
return ast.WalkContinue, nil
|
|
})
|
|
// HTML 资源也可能被历史导入的笔记引用;清理不能漏掉它们。
|
|
for _, span := range importLinkSpans(content) {
|
|
if !span.html {
|
|
continue
|
|
}
|
|
target, _, ok := LocalTarget(note, string(content[span.start:span.end]))
|
|
if ok {
|
|
m.Assets = append(m.Assets, Reference{target, bytes.Count(content[:span.start], []byte("\n")) + 1})
|
|
}
|
|
}
|
|
for tag := range tags {
|
|
m.Tags = append(m.Tags, tag)
|
|
}
|
|
sort.Strings(m.Tags)
|
|
return m
|
|
}
|