Files
RawMark/internal/vault/import_links.go
T

321 lines
8.2 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"
"html"
"net/url"
"os"
"path"
"path/filepath"
"regexp"
"sort"
"strconv"
"strings"
)
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("\\\\([!\"#$%&'()*+,\\-./:;<=>?@\\[\\]\\\\^_`{|}~])")
type importLink struct {
start, end int
html bool
}
// 链接中的空白允许一次换行,但不能跨过段落。
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, start
}
if text[start] == '<' {
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
}
}
return start, start, start
}
depth := 0
for end = start; end < len(text); end++ {
c := text[end]
if c == '\\' && end+1 < len(text) {
end++
continue
}
if c == '(' {
depth++
} else if c == ')' {
if depth == 0 {
break
}
depth--
} else if c <= ' ' || c == '<' || c == 0x7f {
break
}
}
if depth != 0 {
return start, start, start
}
return start, end, end
}
func linkTitleEnd(text []byte, pos int) int {
quote := text[pos]
close := quote
if quote == '(' {
close = ')'
}
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
}
}
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 := 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 // 链接不能嵌套,图片可以位于链接标签内。
}
}
}
}
for _, m := range importSrc.FindAllSubmatchIndex(text, -1) {
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, migrated map[string]bool) ([]byte, []string, error) {
missing := map[string]bool{}
replacements := map[string]string{}
spans := importLinkSpans(content)
var out []byte
last := 0
for _, span := range spans {
if span.start < last {
continue
}
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
}
// 只统计新生成的副本;已有同名附件必须与哈希对应的内容一致。
dir := path.Join(path.Dir(note), "assets")
dest, exists, err := v.assetDestination(dir, assetName(data, path.Base(rel)), data)
if err != nil {
return nil, nil, err
}
if !exists {
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
}
if dest != rel {
migrated[rel] = true
}
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 := append(out, content[last:]...)
warnings := []string{}
if len(missing) > 0 {
warnings = append(warnings, note+":"+strconv.Itoa(len(missing))+" 个本地链接的目标不在导入内容中,已保留原链接")
}
return updated, warnings, nil
}