Fix attachment scanning for literal text and multiline links
This commit is contained in:
1 parent
c08bb8a182
commit
b4054189cf
7 files changed
+463
-222
No files matched your search
+250
-138
@@ -15,7 +15,7 @@ import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
var importRef = regexp.MustCompile(`^ {0,3}\[[^\]\r\n]+\]:[ \t]*`)
|
||||
var importRef = regexp.MustCompile(`(?m)^ {0,3}\[[^\[\]\r\n]+\]:[ \t]*`)
|
||||
var importSrc = regexp.MustCompile(`(?i)<(?:img|source|video|audio)\b[^>]*?\bsrc\s*=\s*["']([^"']*)["']`)
|
||||
var markdownEscape = regexp.MustCompile("\\\\([!\"#$%&'()*+,\\-./:;<=>?@\\[\\]\\\\^_`{|}~])")
|
||||
|
||||
@@ -24,186 +24,298 @@ type importLink struct {
|
||||
html bool
|
||||
}
|
||||
|
||||
// linkDestination 返回目的地址本身的区间,不含尖括号、标题或闭合括号。
|
||||
func linkDestination(text []byte, start int) (int, int) {
|
||||
for start < len(text) && (text[start] == ' ' || text[start] == '\t') {
|
||||
start++
|
||||
// 链接中的空白允许一次换行,但不能跨过段落。
|
||||
func linkSpace(text []byte, pos int) int {
|
||||
newline := false
|
||||
for pos < len(text) {
|
||||
switch text[pos] {
|
||||
case ' ', '\t', '\r':
|
||||
case '\n':
|
||||
if newline {
|
||||
return pos
|
||||
}
|
||||
newline = true
|
||||
default:
|
||||
return pos
|
||||
}
|
||||
pos++
|
||||
}
|
||||
return pos
|
||||
}
|
||||
|
||||
// linkDestination 返回地址区间和地址结束后的下标;地址本身不含尖括号。
|
||||
func linkDestination(text []byte, pos int) (start, end, next int) {
|
||||
start = linkSpace(text, pos)
|
||||
if start == len(text) {
|
||||
return start, start
|
||||
return start, 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++
|
||||
for end = start + 1; end < len(text); end++ {
|
||||
switch text[end] {
|
||||
case '\\':
|
||||
if end+1 < len(text) {
|
||||
end++
|
||||
}
|
||||
case '>':
|
||||
return start + 1, end, end + 1
|
||||
case '<', '\n', '\r', 0:
|
||||
return start, start, start
|
||||
}
|
||||
end++
|
||||
}
|
||||
if end < len(text) && text[end] == '>' {
|
||||
return start + 1, end
|
||||
}
|
||||
return start, start
|
||||
return start, start, start
|
||||
}
|
||||
depth := 0
|
||||
end := start
|
||||
for end < len(text) {
|
||||
for end = start; end < len(text); end++ {
|
||||
c := text[end]
|
||||
if c == '\\' && end+1 < len(text) {
|
||||
end += 2
|
||||
end++
|
||||
continue
|
||||
}
|
||||
if c == '(' {
|
||||
depth++
|
||||
}
|
||||
if c == ')' {
|
||||
} else if c == ')' {
|
||||
if depth == 0 {
|
||||
break
|
||||
}
|
||||
depth--
|
||||
}
|
||||
if c == ' ' || c == '\t' || c == '\n' || c == '\r' {
|
||||
} else if c <= ' ' || c == '<' || c == 0x7f {
|
||||
break
|
||||
}
|
||||
end++
|
||||
}
|
||||
if depth != 0 {
|
||||
return start, start
|
||||
return start, start, start
|
||||
}
|
||||
return start, end
|
||||
return start, end, 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})
|
||||
}
|
||||
func linkTitleEnd(text []byte, pos int) int {
|
||||
quote := text[pos]
|
||||
close := quote
|
||||
if quote == '(' {
|
||||
close = ')'
|
||||
}
|
||||
for pos := 0; pos < len(text); {
|
||||
i := bytes.Index(text[pos:], []byte("]("))
|
||||
if i < 0 {
|
||||
for i := pos + 1; i < len(text); i++ {
|
||||
if text[i] == '\\' && i+1 < len(text) {
|
||||
i++
|
||||
} else if text[i] == close {
|
||||
return i + 1
|
||||
} else if (quote == '(' && text[i] == '(') || (text[i] == '\n' && linkSpace(text, i) < len(text) && text[linkSpace(text, i)] == '\n') {
|
||||
break
|
||||
}
|
||||
i += pos
|
||||
// 转义的闭括号不是链接语法。
|
||||
escapes := 0
|
||||
for j := i - 1; j >= 0 && text[j] == '\\'; j-- {
|
||||
escapes++
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
// importLinkSpans 共用于导入、导出和图片改名;返回原文字节位置,不重新序列化正文。
|
||||
func importLinkSpans(content []byte) []importLink {
|
||||
var text []byte
|
||||
var source []int
|
||||
lastEnd := 0
|
||||
transformMarkdownLines(content, importRef, func(line, body []byte, offset int) []byte {
|
||||
if len(text) > 0 && offset != lastEnd {
|
||||
text = append(text, '\n', '\n')
|
||||
source = append(source, -1, -1)
|
||||
}
|
||||
start, end := linkDestination(text, i+2)
|
||||
if escapes%2 == 0 && end > start {
|
||||
spans = append(spans, importLink{start, end, false})
|
||||
start := offset + len(line) - len(body)
|
||||
text = append(text, body...)
|
||||
for i := range body {
|
||||
source = append(source, start+i)
|
||||
}
|
||||
lastEnd = offset + len(line)
|
||||
return line
|
||||
})
|
||||
// 行内代码可以跨行。遮住其中的标记,同时保留链接标签两侧的括号结构。
|
||||
masked := bytes.Repeat([]byte(" "), len(text))
|
||||
pos := 0
|
||||
for _, paragraph := range bytes.SplitAfter(text, []byte("\n\n")) {
|
||||
transformOutsideCode(paragraph, func(part []byte, offset int) []byte {
|
||||
copy(masked[pos+offset:], part)
|
||||
return part
|
||||
})
|
||||
pos += len(paragraph)
|
||||
}
|
||||
text = masked
|
||||
var spans []importLink
|
||||
add := func(start, end int, html bool) {
|
||||
spans = append(spans, importLink{source[start], source[end-1] + 1, html})
|
||||
}
|
||||
definitionEnd := 0
|
||||
for _, m := range importRef.FindAllIndex(text, -1) {
|
||||
if m[0] < definitionEnd {
|
||||
continue
|
||||
}
|
||||
label := bytes.TrimSpace(text[m[0]:m[1]])
|
||||
if bytes.HasPrefix(label, []byte("[^")) || len(bytes.TrimSpace(label[1:bytes.IndexByte(label, ']')])) == 0 {
|
||||
continue
|
||||
}
|
||||
start, end, next := linkDestination(text, m[1])
|
||||
if end == start {
|
||||
continue
|
||||
}
|
||||
titleStart := linkSpace(text, next)
|
||||
if titleStart > next && titleStart < len(text) && (text[titleStart] == '"' || text[titleStart] == '\'' || text[titleStart] == '(') {
|
||||
titleEnd := linkTitleEnd(text, titleStart)
|
||||
if titleEnd >= 0 {
|
||||
for titleEnd < len(text) && (text[titleEnd] == ' ' || text[titleEnd] == '\t' || text[titleEnd] == '\r') {
|
||||
titleEnd++
|
||||
}
|
||||
if titleEnd == len(text) || text[titleEnd] == '\n' {
|
||||
next = titleEnd
|
||||
}
|
||||
}
|
||||
}
|
||||
for next < len(text) && (text[next] == ' ' || text[next] == '\t' || text[next] == '\r') {
|
||||
next++
|
||||
}
|
||||
if next < len(text) && text[next] != '\n' {
|
||||
continue
|
||||
}
|
||||
add(start, end, false)
|
||||
definitionEnd = next
|
||||
// 引用定义及其标题不是行内正文,避免再次扫描标题中的示例标记。
|
||||
for i := m[0]; i < next; i++ {
|
||||
if text[i] != '\n' && text[i] != '\r' {
|
||||
text[i] = ' '
|
||||
}
|
||||
}
|
||||
}
|
||||
var brackets []int
|
||||
for i := 0; i < len(text); i++ {
|
||||
switch text[i] {
|
||||
case '\\':
|
||||
i++
|
||||
case '\n':
|
||||
if next := linkSpace(text, i); next < len(text) && text[next] == '\n' {
|
||||
brackets = nil
|
||||
}
|
||||
case '[':
|
||||
brackets = append(brackets, i)
|
||||
case ']':
|
||||
if len(brackets) == 0 {
|
||||
continue
|
||||
}
|
||||
open := brackets[len(brackets)-1]
|
||||
brackets = brackets[:len(brackets)-1]
|
||||
if i+1 == len(text) || text[i+1] != '(' {
|
||||
continue
|
||||
}
|
||||
start, end, next := linkDestination(text, i+2)
|
||||
if end == start {
|
||||
continue
|
||||
}
|
||||
close := linkSpace(text, next)
|
||||
if close > next && close < len(text) && (text[close] == '"' || text[close] == '\'' || text[close] == '(') {
|
||||
close = linkTitleEnd(text, close)
|
||||
if close < 0 {
|
||||
continue
|
||||
}
|
||||
close = linkSpace(text, close)
|
||||
}
|
||||
if close < len(text) && text[close] == ')' {
|
||||
add(start, end, false)
|
||||
i = close
|
||||
if open == 0 || text[open-1] != '!' {
|
||||
brackets = nil // 链接不能嵌套,图片可以位于链接标签内。
|
||||
}
|
||||
}
|
||||
}
|
||||
pos = max(i+2, end)
|
||||
}
|
||||
for _, m := range importSrc.FindAllSubmatchIndex(text, -1) {
|
||||
spans = append(spans, importLink{m[2], m[3], true})
|
||||
if m[3] > m[2] {
|
||||
add(m[2], m[3], true)
|
||||
}
|
||||
}
|
||||
sort.Slice(spans, func(i, j int) bool { return spans[i].start < spans[j].start })
|
||||
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
|
||||
}
|
||||
if !IsImage(rel) {
|
||||
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
|
||||
spans := importLinkSpans(content)
|
||||
var out []byte
|
||||
last := 0
|
||||
for _, span := range spans {
|
||||
if span.start < last {
|
||||
continue
|
||||
}
|
||||
return append(out, text[last:]...)
|
||||
raw := string(content[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 {
|
||||
return nil, nil, err
|
||||
}
|
||||
if !info.Mode().IsRegular() {
|
||||
continue
|
||||
}
|
||||
if !IsImage(rel) {
|
||||
continue
|
||||
}
|
||||
replacement, found := replacements[rel]
|
||||
if !found {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
data, err := os.ReadFile(filepath.Join(v.root, filepath.FromSlash(rel)))
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
// 只统计新生成的副本;已有同名附件必须与哈希对应的内容一致。
|
||||
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) {
|
||||
return nil, nil, readErr
|
||||
}
|
||||
if readErr == nil && !bytes.Equal(data, existing) {
|
||||
return nil, nil, fmt.Errorf("%w:附件名称与内容冲突", ErrInvalidImport)
|
||||
}
|
||||
if os.IsNotExist(readErr) {
|
||||
if int64(len(data)) > maxImportExpanded-*total {
|
||||
return nil, nil, ErrTooLarge
|
||||
}
|
||||
*total += int64(len(data))
|
||||
}
|
||||
replacement, err = v.SaveAsset(note, path.Base(rel), bytes.NewReader(data), importAssetLimit)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
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, content[last:span.start]...)
|
||||
out = append(out, replacement...)
|
||||
last = span.end
|
||||
}
|
||||
updated := transformMarkdownLines(content, importRef, func(line []byte) []byte { return transformOutsideCode(line, transform) })
|
||||
updated := append(out, content[last:]...)
|
||||
warnings := []string{}
|
||||
if len(missing) > 0 {
|
||||
warnings = append(warnings, note+":"+strconv.Itoa(len(missing))+" 个本地链接的目标不在导入内容中,已保留原链接")
|
||||
}
|
||||
return updated, warnings, failure
|
||||
return updated, warnings, nil
|
||||
}
|
||||
Reference in new issue
Block a user