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.
205 lines
7.5 KiB
Go
205 lines
7.5 KiB
Go
package vault
|
|
|
|
import (
|
|
"archive/zip"
|
|
"bytes"
|
|
"context"
|
|
"errors"
|
|
"io/fs"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
type zipEntry struct {
|
|
name, content string
|
|
mode fs.FileMode
|
|
}
|
|
|
|
func importZip(t *testing.T, entries ...zipEntry) ImportFile {
|
|
t.Helper()
|
|
var b bytes.Buffer
|
|
w := zip.NewWriter(&b)
|
|
for _, e := range entries {
|
|
h := &zip.FileHeader{Name: e.name, Method: zip.Deflate}
|
|
if e.mode != 0 {
|
|
h.SetMode(e.mode)
|
|
}
|
|
f, err := w.CreateHeader(h)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := f.Write([]byte(e.content)); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
if err := w.Close(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
data := b.Bytes()
|
|
return ImportFile{Name: "知识库.md.zip", Reader: bytes.NewReader(data), Size: int64(len(data))}
|
|
}
|
|
|
|
func markdownFile(name, content string) ImportFile {
|
|
return ImportFile{Name: name, Reader: strings.NewReader(content), Size: int64(len(content))}
|
|
}
|
|
|
|
func TestImportZipAssetsAndMove(t *testing.T) {
|
|
v := newVault(t)
|
|
content := "# 标题\n\n.png> \"说明\")\n\n![引用][图]\n\n[图]: assets/图%20(1).png\n\n<img src=\"assets/图%20(1).png\">\n\n[子笔记](子目录/子笔记.md)\n\n\n\n\n\n`.png)`\n\n```md\n.png)\n```\n\n> ```\n> .png)\n> ```\n\n .png)\n"
|
|
source := importZip(t, zipEntry{name: "目录/笔记.md", content: content}, zipEntry{name: "目录/子目录/子笔记.md", content: "# 子笔记"}, zipEntry{name: "目录/assets/图 (1).png", content: "image"}, zipEntry{name: "未引用.txt", content: "附件"})
|
|
result, err := v.Import(context.Background(), "", "知识库", []ImportFile{source})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if result.Notes != 2 || result.Assets != 3 || len(result.Warnings) != 1 || len(result.Paths) != 1 || result.Paths[0] != "知识库" {
|
|
t.Fatalf("结果不符: %+v", result)
|
|
}
|
|
body, _, err := v.Read("知识库/目录/笔记.md")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
link := EscapeLinkPath("笔记.assets") + "/" + assetName([]byte("image"), "x.png")
|
|
expected := strings.Replace(content, "<assets/图 (1).png>", "<"+link+">", 1)
|
|
expected = strings.Replace(expected, "[图]: assets/图%20(1).png", "[图]: "+link, 1)
|
|
expected = strings.Replace(expected, "src=\"assets/图%20(1).png\"", "src=\""+link+"\"", 1)
|
|
if string(body) != expected {
|
|
t.Fatalf("正文变更不符:\n%s", body)
|
|
}
|
|
if err := v.Mkdir("移动目标"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := v.Move("知识库/目录/笔记.md", "移动目标/改名.md", nil); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
moved, _, err := v.Read("移动目标/改名.md")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !strings.Contains(string(moved), "改名.assets/") {
|
|
t.Fatal("图片链接未跟随改名")
|
|
}
|
|
if _, err := os.Stat(filepath.Join(v.root, "移动目标/改名.assets", assetName([]byte("image"), "x.png"))); err != nil {
|
|
t.Fatal("附件未跟随", err)
|
|
}
|
|
original, err := os.ReadFile(filepath.Join(v.root, "知识库/目录/assets/图 (1).png"))
|
|
if err != nil || string(original) != "image" {
|
|
t.Fatal("原始附件丢失", err)
|
|
}
|
|
}
|
|
|
|
func TestImportRejectsInvalidWithoutPublishing(t *testing.T) {
|
|
for _, tc := range []struct {
|
|
name string
|
|
entries []zipEntry
|
|
want error
|
|
}{
|
|
{"越界", []zipEntry{{name: "../outside.md", content: "x"}}, ErrInvalidImport},
|
|
{"隐藏路径", []zipEntry{{name: ".rawmark/x.md", content: "x"}}, ErrInvalidImport},
|
|
{"重复路径", []zipEntry{{name: "a.md", content: "x"}, {name: "a.md", content: "y"}}, ErrInvalidImport},
|
|
{"符号链接", []zipEntry{{name: "a.md", content: "x", mode: os.ModeSymlink | 0o777}}, ErrInvalidImport},
|
|
{"目录文件冲突", []zipEntry{{name: "a/"}, {name: "a", content: "x"}, {name: "b.md", content: "x"}}, ErrInvalidImport},
|
|
{"文件目录冲突反向", []zipEntry{{name: "a", content: "x"}, {name: "a/"}, {name: "b.md", content: "x"}}, ErrInvalidImport},
|
|
{"无笔记", []zipEntry{{name: "a.png", content: "x"}}, ErrInvalidImport},
|
|
{"非法编码", []zipEntry{{name: "a.md", content: "\xff"}}, ErrInvalidImport},
|
|
{"附件目录内笔记", []zipEntry{{name: "a.assets/a.md", content: "x"}}, ErrInvalidImport},
|
|
{"文件目录冲突", []zipEntry{{name: "a", content: "x"}, {name: "a/b.md", content: "y"}}, ErrInvalidImport},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
v := newVault(t)
|
|
_, err := v.Import(context.Background(), "", "导入", []ImportFile{importZip(t, tc.entries...)})
|
|
if !errors.Is(err, tc.want) {
|
|
t.Fatalf("错误不符: %v", err)
|
|
}
|
|
entries, err := os.ReadDir(v.root)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(entries) != 1 || entries[0].Name() != metaDir {
|
|
t.Fatal("失败后留下可见内容或临时目录")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestImportBatchConflictAndCancellation(t *testing.T) {
|
|
v := newVault(t)
|
|
if _, err := v.Write("b.md", []byte("原文"), Precondition{}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
sources := []ImportFile{markdownFile("a.md", "新文"), markdownFile("b.md", "覆盖")}
|
|
if _, err := v.Import(context.Background(), "", "", sources); !errors.Is(err, ErrExists) {
|
|
t.Fatal(err)
|
|
}
|
|
if _, _, err := v.Read("a.md"); !errors.Is(err, ErrNotFound) {
|
|
t.Fatal("批量冲突产生了半成品", err)
|
|
}
|
|
body, _, _ := v.Read("b.md")
|
|
if string(body) != "原文" {
|
|
t.Fatal("原文件被覆盖")
|
|
}
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
cancel()
|
|
if _, err := v.Import(ctx, "", "", sources[:1]); !errors.Is(err, context.Canceled) {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := v.Import(context.Background(), "不存在", "", sources[:1]); !errors.Is(err, ErrNotFound) {
|
|
t.Fatal(err)
|
|
}
|
|
result, err := v.Import(context.Background(), "", "", []ImportFile{markdownFile("a.md", "\xef\xbb\xbf正文"), markdownFile("c.md", "")})
|
|
if err != nil || result.Notes != 2 || len(result.Warnings) != 1 {
|
|
t.Fatalf("结果不符: %+v %v", result, err)
|
|
}
|
|
body, _, _ = v.Read("a.md")
|
|
if string(body) != "正文" {
|
|
t.Fatal("UTF-8 BOM 未处理")
|
|
}
|
|
}
|
|
|
|
func TestImportLimitsAndCorruptZip(t *testing.T) {
|
|
v := newVault(t)
|
|
source := markdownFile("large.md", strings.Repeat("a", importNoteLimit+1))
|
|
if _, err := v.Import(context.Background(), "", "", []ImportFile{source}); !errors.Is(err, ErrTooLarge) {
|
|
t.Fatal(err)
|
|
}
|
|
source.Size = MaxImportBytes + 1
|
|
if _, err := v.Import(context.Background(), "", "", []ImportFile{source}); !errors.Is(err, ErrTooLarge) {
|
|
t.Fatal(err)
|
|
}
|
|
broken := markdownFile("broken.zip", "not zip")
|
|
if _, err := v.Import(context.Background(), "", "导入", []ImportFile{broken}); !errors.Is(err, ErrInvalidImport) {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
func TestImportCorruptZipContent(t *testing.T) {
|
|
v := newVault(t)
|
|
var buffer bytes.Buffer
|
|
writer := zip.NewWriter(&buffer)
|
|
f, err := writer.CreateHeader(&zip.FileHeader{Name: "n.md", Method: zip.Store})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
payload := []byte("unique markdown payload")
|
|
if _, err := f.Write(payload); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := writer.Close(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
data := buffer.Bytes()
|
|
offset := bytes.Index(data, payload)
|
|
if offset < 0 {
|
|
t.Fatal("找不到测试负载")
|
|
}
|
|
data[offset] ^= 1
|
|
_, err = v.Import(context.Background(), "", "损坏", []ImportFile{{Name: "broken.zip", Reader: bytes.NewReader(data), Size: int64(len(data))}})
|
|
if !errors.Is(err, ErrInvalidImport) {
|
|
t.Fatalf("应拒绝 CRC 损坏: %v", err)
|
|
}
|
|
if _, err := os.Stat(filepath.Join(v.root, "损坏")); !os.IsNotExist(err) {
|
|
t.Fatal("损坏内容被发布", err)
|
|
}
|
|
}
|