212 lines
5.4 KiB
Go
212 lines
5.4 KiB
Go
package vault
|
||
|
||
import (
|
||
"bytes"
|
||
"context"
|
||
"fmt"
|
||
"html"
|
||
"net/url"
|
||
"os"
|
||
"path"
|
||
"path/filepath"
|
||
"regexp"
|
||
"sort"
|
||
"strconv"
|
||
"strings"
|
||
)
|
||
|
||
var importRef = regexp.MustCompile(`^ {0,3}\[[^\]\r\n]+\]:[ \t]*`)
|
||
var importSrc = regexp.MustCompile(`(?i)<(?:img|source|video|audio)\b[^>]*?\bsrc\s*=\s*["']([^"']*)["']`)
|
||
var markdownEscape = regexp.MustCompile("\\\\([!\"#$%&'()*+,\\-./:;<=>?@\\[\\]\\\\^_`{|}~])")
|
||
|
||
type importLink struct {
|
||
start, end int
|
||
html bool
|
||
}
|
||
|
||
// linkDestination 返回目的地址本身的区间,不含尖括号、标题或闭合括号。
|
||
func linkDestination(text []byte, start int) (int, int) {
|
||
for start < len(text) && (text[start] == ' ' || text[start] == '\t') {
|
||
start++
|
||
}
|
||
if start == len(text) {
|
||
return start, start
|
||
}
|
||
if text[start] == '<' {
|
||
end := start + 1
|
||
for end < len(text) && text[end] != '>' && text[end] != '\n' && text[end] != '\r' {
|
||
if text[end] == '\\' && end+1 < len(text) {
|
||
end++
|
||
}
|
||
end++
|
||
}
|
||
if end < len(text) && text[end] == '>' {
|
||
return start + 1, end
|
||
}
|
||
return start, start
|
||
}
|
||
depth := 0
|
||
end := start
|
||
for end < len(text) {
|
||
c := text[end]
|
||
if c == '\\' && end+1 < len(text) {
|
||
end += 2
|
||
continue
|
||
}
|
||
if c == '(' {
|
||
depth++
|
||
}
|
||
if c == ')' {
|
||
if depth == 0 {
|
||
break
|
||
}
|
||
depth--
|
||
}
|
||
if c == ' ' || c == '\t' || c == '\n' || c == '\r' {
|
||
break
|
||
}
|
||
end++
|
||
}
|
||
if depth != 0 {
|
||
return start, start
|
||
}
|
||
return start, end
|
||
}
|
||
|
||
func importLinkSpans(text []byte) []importLink {
|
||
var spans []importLink
|
||
if m := importRef.FindIndex(text); m != nil {
|
||
start, end := linkDestination(text, m[1])
|
||
if end > start {
|
||
spans = append(spans, importLink{start, end, false})
|
||
}
|
||
}
|
||
for pos := 0; pos < len(text); {
|
||
i := bytes.Index(text[pos:], []byte("]("))
|
||
if i < 0 {
|
||
break
|
||
}
|
||
i += pos
|
||
// 转义的闭括号不是链接语法。
|
||
escapes := 0
|
||
for j := i - 1; j >= 0 && text[j] == '\\'; j-- {
|
||
escapes++
|
||
}
|
||
start, end := linkDestination(text, i+2)
|
||
if escapes%2 == 0 && end > start {
|
||
spans = append(spans, importLink{start, end, false})
|
||
}
|
||
pos = max(i+2, end)
|
||
}
|
||
for _, m := range importSrc.FindAllSubmatchIndex(text, -1) {
|
||
spans = append(spans, importLink{m[2], m[3], true})
|
||
}
|
||
return spans
|
||
}
|
||
|
||
func (v *Vault) importLinks(ctx context.Context, note string, content []byte, total *int64) ([]byte, []string, error) {
|
||
var failure error
|
||
missing := map[string]bool{}
|
||
replacements := map[string]string{}
|
||
transform := func(text []byte) []byte {
|
||
spans := importLinkSpans(text)
|
||
// 区间可能来自 HTML 或 Markdown,按原始顺序替换,保持其他字节不变。
|
||
sort.Slice(spans, func(i, j int) bool { return spans[i].start < spans[j].start })
|
||
var out []byte
|
||
last := 0
|
||
for _, span := range spans {
|
||
if span.start < last || failure != nil {
|
||
continue
|
||
}
|
||
raw := string(text[span.start:span.end])
|
||
target := raw
|
||
if span.html {
|
||
target = html.UnescapeString(target)
|
||
} else {
|
||
target = markdownEscape.ReplaceAllString(target, "$1")
|
||
}
|
||
u, err := url.Parse(target)
|
||
if err != nil || u.IsAbs() || u.Host != "" || strings.HasPrefix(target, "/") || u.Path == "" {
|
||
continue
|
||
}
|
||
rel := path.Clean(path.Join(path.Dir(note), u.Path))
|
||
if _, err := CleanPath(rel); err != nil {
|
||
missing[rel] = true
|
||
continue
|
||
}
|
||
info, err := os.Stat(filepath.Join(v.root, filepath.FromSlash(rel)))
|
||
if os.IsNotExist(err) {
|
||
missing[rel] = true
|
||
continue
|
||
}
|
||
if err != nil {
|
||
failure = err
|
||
continue
|
||
}
|
||
if !info.Mode().IsRegular() {
|
||
continue
|
||
}
|
||
switch strings.ToLower(path.Ext(rel)) {
|
||
case ".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg", ".avif", ".apng", ".bmp", ".ico", ".tif", ".tiff":
|
||
default:
|
||
continue
|
||
}
|
||
replacement, found := replacements[rel]
|
||
if !found {
|
||
if err := ctx.Err(); err != nil {
|
||
failure = err
|
||
continue
|
||
}
|
||
data, err := os.ReadFile(filepath.Join(v.root, filepath.FromSlash(rel)))
|
||
if err != nil {
|
||
failure = err
|
||
continue
|
||
}
|
||
// 只统计新生成的副本;已有同名附件必须与哈希对应的内容一致。
|
||
dest := filepath.Join(v.root, filepath.FromSlash(AssetsDir(note)), assetName(data, path.Base(rel)))
|
||
existing, readErr := os.ReadFile(dest)
|
||
if readErr != nil && !os.IsNotExist(readErr) {
|
||
failure = readErr
|
||
continue
|
||
}
|
||
if readErr == nil && !bytes.Equal(data, existing) {
|
||
failure = fmt.Errorf("%w:附件名称与内容冲突", ErrInvalidImport)
|
||
continue
|
||
}
|
||
if os.IsNotExist(readErr) {
|
||
if int64(len(data)) > maxImportExpanded-*total {
|
||
failure = ErrTooLarge
|
||
continue
|
||
}
|
||
*total += int64(len(data))
|
||
}
|
||
replacement, err = v.SaveAsset(note, path.Base(rel), bytes.NewReader(data), importAssetLimit)
|
||
if err != nil {
|
||
failure = err
|
||
continue
|
||
}
|
||
replacements[rel] = replacement
|
||
}
|
||
if u.RawQuery != "" {
|
||
replacement += "?" + u.RawQuery
|
||
}
|
||
if u.Fragment != "" {
|
||
replacement += "#" + u.EscapedFragment()
|
||
}
|
||
if span.html {
|
||
replacement = html.EscapeString(replacement)
|
||
}
|
||
out = append(out, text[last:span.start]...)
|
||
out = append(out, replacement...)
|
||
last = span.end
|
||
}
|
||
return append(out, text[last:]...)
|
||
}
|
||
updated := transformMarkdownLines(content, importRef, func(line []byte) []byte { return transformOutsideCode(line, transform) })
|
||
warnings := []string{}
|
||
if len(missing) > 0 {
|
||
warnings = append(warnings, note+":"+strconv.Itoa(len(missing))+" 个本地链接的目标不在导入内容中,已保留原链接")
|
||
}
|
||
return updated, warnings, failure
|
||
}
|