Show images in asset directories with preview, rename, replacement, and trash recovery. Update local Markdown references when images move and restore original files if related metadata cannot be committed. Refresh image previews after replacement and compare actual contents when deduplicating subsequent uploads.
210 lines
5.3 KiB
Go
210 lines
5.3 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
|
||
}
|
||
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
|
||
}
|
||
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
|
||
}
|