Files
RawMark/internal/vault/metadata.go
T
eezd 4d1339851b feat(ui): redesign public reading and complete glass workspace
Add scoped public navigation, reading progress, focus and print controls without loading the editor. Polish responsive settings, graph and media layouts.

Correct share expiry units, preserve math and code during link maintenance, and normalize Markdown destinations for templates and note navigation. Verify browser flows and document the new tools.
2026-10-22 15:43:52 +08:00

180 lines
5.0 KiB
Go

package vault
import (
"bytes"
"fmt"
"html"
"net/url"
"path"
"regexp"
"sort"
"strings"
"unicode"
"unicode/utf8"
"github.com/yuin/goldmark/v2/ast"
"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 := parseMetadata(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.AutoLink, *ast.CodeBlock, *ast.CodeSpan, *ast.HTMLBlock, *ast.RawHTML, *ast.LinkReferenceDefinition:
return ast.WalkSkipChildren, nil
case *ast.Link:
addRef(node.Destination.Str(source), node.Pos())
return ast.WalkSkipChildren, nil
case *ast.Image:
addRef(node.Destination.Str(source), node.Pos())
return ast.WalkSkipChildren, nil
case *ast.Text:
value := node.Value.Str(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 资源也可能被历史导入的笔记引用;清理不能漏掉它们。
excluded := nonProseRanges(root)
for _, span := range importLinkSpans(content) {
if !span.html {
continue
}
skip := false
for _, region := range excluded {
if span.start >= region[0] && span.start < region[1] {
skip = true
break
}
}
if skip {
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
}