Files
RawMark/internal/vault/import_links.go
T

212 lines
5.4 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}