feat(assets): store note images in shared sibling assets directories
This commit is contained in:
1 parent
5abc99b89e
commit
08f4e4aa7d
14 files changed
+506
-74
No files matched your search
@@ -31,7 +31,7 @@ docker run -d --name rawmark \
|
||||
```
|
||||
|
||||
- 首次打开直接设置登录密码,之后凭密码登录;请在将新实例开放给他人访问前完成初始化。
|
||||
- 笔记以普通 `.md` 文件保存在 `数据目录/vault/`,图片按 Typora 习惯存放在同级的 `笔记名.assets/`,可直接用 Typora 等编辑器打开;外部修改会实时同步到网页。
|
||||
- 笔记以普通 `.md` 文件保存在 `数据目录/vault/`,粘贴、上传和导入的图片统一存放在笔记同级的 `assets/`,链接使用 `./assets/文件名`,同目录相同内容去重。笔记跨目录移动时图片跟随,仍被其他笔记引用的原图保留;删除单篇笔记不会删除共享图片。旧 `笔记名.assets/` 仍可读取、移动与恢复;外部修改会实时同步到网页。
|
||||
- 知识工具:标准 Markdown 链接构成图谱,支持正文 `#标签` 与 YAML `tags`。笔记或文件夹在应用内改名、移动时维护入链和相对出链;公式和代码示例不参与关系提取。
|
||||
- 日记与模板:默认日记路径为 `Daily/YYYY-MM-DD.md`(本地日期),已有日记直接打开;模板为 `Templates/` 下的普通 Markdown,支持 `{{date}}`、`{{time}}`、`{{title}}`,资源链接按新笔记位置调整。可在设置中修改目录和日记模板。
|
||||
- 外观:浅色玻璃工作台与深色模式,字体资源随应用本地提供;界面字号默认 14px,可输入 14–20px 并单独重置,与编辑器及阅读字号独立;排版、玻璃透明度、选中文本工具栏均可在设置中调整并即时保存。
|
||||
@@ -43,7 +43,7 @@ docker run -d --name rawmark \
|
||||
- 设置:桌面使用居中窗口,手机使用分类与内容两级布局;支持搜索设置并定位、常用排版项单独重置,自动保存状态与失败提示可见。外观、编辑器和日记与模板分别管理,安装入口位于「关于」。
|
||||
- 快捷键:支持按命令名称、ID 或组合键搜索,添加、逐条移除和单项/全部重置,并拒绝冲突绑定。默认 Ctrl+Alt+1~6 设置对应级别标题,同级再次触发恢复正文;字号可直接输入。
|
||||
- 文件目录操作:右键「文件目录」标题或目录空白处,可搜索、打开标签索引、新建笔记、新建目录、导入文档及管理图片;也可点击标题打开菜单。文件夹右键操作以所选文件夹为目标。
|
||||
- 导入:文件目录或文件夹右键菜单选择「导入」,支持多选 UTF-8 `.md` 文件或单个 ZIP(总上传上限 100 MB)。ZIP 新建同名文件夹并保留层级,本地图片转换为笔记独立附件,原附件保留;同名内容不覆盖,缺失的本地链接会提示,外部图片不会自动下载。
|
||||
- 导入:文件目录或文件夹右键菜单选择「导入」,支持多选 UTF-8 `.md` 文件或单个 ZIP(总上传上限 100 MB)。ZIP 新建同名文件夹并保留笔记层级,本地图片归入各笔记同级 `assets/`;全部改链后移除已迁移且无引用的重复原图,其他附件和未使用图片保留。同名不同内容不覆盖,缺失的本地链接会提示,外部图片不会自动下载。
|
||||
- 自动化:「API 令牌」中创建只读或读写令牌,以 `Authorization: Bearer <令牌>` 调用 REST API,接口说明见 `/api/v1/openapi.json`。
|
||||
|
||||
| 环境变量 | 默认值 | 说明 |
|
||||
|
||||
+1
-1
@@ -371,7 +371,7 @@ func (s *Server) mkdir(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, 200, map[string]bool{"ok": true})
|
||||
}
|
||||
|
||||
// uploadAsset 接收 multipart 表单中的 file 字段,存入 ?note= 指定笔记的 .assets 目录
|
||||
// uploadAsset 接收 multipart 表单中的 file 字段,存入指定笔记或目录下的共享 assets 目录。
|
||||
func (s *Server) uploadAsset(w http.ResponseWriter, r *http.Request) {
|
||||
r.Body = http.MaxBytesReader(w, r.Body, maxAssetBytes+1<<20)
|
||||
file, header, err := r.FormFile("file")
|
||||
|
||||
@@ -5,9 +5,41 @@ import (
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestUploadsReturnSharedRelativeLinks(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
f.write("notes/a.md", "正文")
|
||||
f.write("notes/b.md", "正文")
|
||||
var body bytes.Buffer
|
||||
form := multipart.NewWriter(&body)
|
||||
file, err := form.CreateFormFile("file", "image.png")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := io.WriteString(file, "image"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := form.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var previous string
|
||||
for _, query := range []string{"note=notes/a.md", "note=notes/b.md", "folder=notes"} {
|
||||
result := decode[struct{ Link string }](t, f.do(f.admin, "POST", "/api/v1/assets?"+query, body.String(), map[string]string{"Content-Type": form.FormDataContentType()}))
|
||||
if !strings.HasPrefix(result.Link, "./assets/") || (previous != "" && previous != result.Link) {
|
||||
t.Fatalf("上传链接或共享去重不符:%s", result.Link)
|
||||
}
|
||||
previous = result.Link
|
||||
res := f.do(f.admin, "GET", "/api/v1/raw/notes/"+strings.TrimPrefix(result.Link, "./"), "", nil)
|
||||
data, err := io.ReadAll(res.Body)
|
||||
if res.StatusCode != 200 || err != nil || string(data) != "image" {
|
||||
t.Fatalf("返回链接不能读取原图:%d %q %v", res.StatusCode, data, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestReplaceImageAPI(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
f.write("assets/a.png", "original")
|
||||
|
||||
@@ -48,7 +48,7 @@ func TestFolderUploadReusesDeduplication(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
second, err := v.SaveFolderAsset("", "b.png", strings.NewReader("image"), 10)
|
||||
if err != nil || first != second || !strings.HasPrefix(first, "assets/") {
|
||||
if err != nil || first != second || !strings.HasPrefix(first, "./assets/") {
|
||||
t.Fatalf("文件夹上传未去重:%s %s %v", first, second, err)
|
||||
}
|
||||
if _, err := v.SaveFolderAsset("missing", "a.png", strings.NewReader("image"), 10); !errors.Is(err, ErrNotFound) {
|
||||
@@ -72,14 +72,14 @@ func TestCleanupProtectsLinkedImages(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
metadata := Analyze("a.md", []byte(content))
|
||||
if len(metadata.Assets) != 1 || metadata.Assets[0].Target != link {
|
||||
if len(metadata.Assets) != 1 || metadata.Assets[0].Target != strings.TrimPrefix(link, "./") {
|
||||
t.Fatalf("链接内图片未记录引用:%+v", metadata)
|
||||
}
|
||||
result, err := v.CleanupImages([]string{link})
|
||||
result, err := v.CleanupImages([]string{strings.TrimPrefix(link, "./")})
|
||||
if err != nil || len(result.Deleted) != 0 || len(result.Skipped) != 1 || result.Skipped[0].Reason != "仍被笔记引用" {
|
||||
t.Fatalf("链接内图片未受保护:%+v %v", result, err)
|
||||
}
|
||||
if _, err := v.Stat(link); err != nil {
|
||||
if _, err := v.Stat(strings.TrimPrefix(link, "./")); err != nil {
|
||||
t.Fatal("正文引用的图片不应被移走", err)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -53,7 +53,7 @@ type linkChange struct {
|
||||
}
|
||||
|
||||
// 调用方持有写锁;先计算全部改写,移动成功后再写入,出错时保留回滚依据。
|
||||
func (v *Vault) moveLinkChanges(from, to string, directory bool, ownAssets bool) ([]linkChange, error) {
|
||||
func (v *Vault) moveLinkChanges(from, to string, directory bool, ownAssets bool, images map[string]string) ([]linkChange, error) {
|
||||
entries, err := v.Tree()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -68,7 +68,18 @@ func (v *Vault) moveLinkChanges(from, to string, directory bool, ownAssets bool)
|
||||
return nil, err
|
||||
}
|
||||
next := movedPath(entry.Path, from, to, directory)
|
||||
after, err := rewriteMovedLinks(before, entry.Path, next, from, to, directory, ownAssets)
|
||||
after, err := rewriteLocalLinks(before, entry.Path, next, func(target string) string {
|
||||
if entry.Path == from {
|
||||
if image, ok := images[target]; ok {
|
||||
return image
|
||||
}
|
||||
}
|
||||
mapped := movedPath(target, from, to, directory)
|
||||
if ownAssets {
|
||||
mapped = movedPath(mapped, AssetsDir(from), AssetsDir(to), true)
|
||||
}
|
||||
return mapped
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -92,7 +103,8 @@ func movedPath(p, from, to string, directory bool) string {
|
||||
return p
|
||||
}
|
||||
|
||||
func rewriteMovedLinks(content []byte, oldNote, newNote, from, to string, directory, ownAssets bool) ([]byte, error) {
|
||||
// 只替换链接地址,不重新序列化正文;移动和图片归位共用相同的代码/公式边界。
|
||||
func rewriteLocalLinks(content []byte, oldNote, newNote string, mapTarget func(string) string) ([]byte, error) {
|
||||
var out []byte
|
||||
last := 0
|
||||
excluded := nonProseRanges(parseMetadata(content))
|
||||
@@ -114,10 +126,7 @@ func rewriteMovedLinks(content []byte, oldNote, newNote, from, to string, direct
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
mapped := movedPath(target, from, to, directory)
|
||||
if ownAssets {
|
||||
mapped = movedPath(mapped, AssetsDir(from), AssetsDir(to), true)
|
||||
}
|
||||
mapped := mapTarget(target)
|
||||
if mapped == target && path.Dir(oldNote) == path.Dir(newNote) {
|
||||
continue
|
||||
}
|
||||
@@ -126,6 +135,9 @@ func rewriteMovedLinks(content []byte, oldNote, newNote, from, to string, direct
|
||||
return nil, err
|
||||
}
|
||||
replacement := EscapeLinkPath(filepath.ToSlash(rel))
|
||||
if strings.HasPrefix(replacement, "assets/") {
|
||||
replacement = "./" + replacement
|
||||
}
|
||||
if u.RawQuery != "" {
|
||||
replacement += "?" + u.RawQuery
|
||||
}
|
||||
|
||||
@@ -82,6 +82,7 @@ func TestReplaceImagePreservesPathAndUploadContents(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
link = strings.TrimPrefix(link, "./")
|
||||
if err := v.ReplaceImage(link, "replacement.PNG", strings.NewReader("replacement"), 100); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -89,8 +90,9 @@ func TestReplaceImagePreservesPathAndUploadContents(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
newLink = strings.TrimPrefix(newLink, "./")
|
||||
again, err := v.SaveAsset("a.md", "old.png", strings.NewReader("original"), 100)
|
||||
if err != nil || again != newLink || newLink == link {
|
||||
if err != nil || strings.TrimPrefix(again, "./") != newLink || newLink == link {
|
||||
t.Fatalf("替换后的去重不符:%q %q %q %v", link, newLink, again, err)
|
||||
}
|
||||
for name, want := range map[string]string{link: "replacement", newLink: "original"} {
|
||||
|
||||
@@ -169,6 +169,7 @@ func (v *Vault) Import(ctx context.Context, dir, folder string, files []ImportFi
|
||||
return result, fmt.Errorf("%w:没有可导入的 Markdown 笔记", ErrInvalidImport)
|
||||
}
|
||||
sort.Strings(notes)
|
||||
migrated := map[string]bool{}
|
||||
for _, note := range notes {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return result, err
|
||||
@@ -186,7 +187,7 @@ func (v *Vault) Import(ctx context.Context, dir, folder string, files []ImportFi
|
||||
return result, fmt.Errorf("%w:Markdown 必须是 UTF-8 文本", ErrInvalidImport)
|
||||
}
|
||||
content = bytes.TrimPrefix(content, []byte{0xef, 0xbb, 0xbf})
|
||||
updated, warnings, err := staged.importLinks(ctx, note, content, &total)
|
||||
updated, warnings, err := staged.importLinks(ctx, note, content, &total, migrated)
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
@@ -199,8 +200,32 @@ func (v *Vault) Import(ctx context.Context, dir, folder string, files []ImportFi
|
||||
return result, err
|
||||
}
|
||||
}
|
||||
// 所有笔记改链完成后再清理已迁移的原图,避免后处理的笔记读取不到共享来源。
|
||||
if len(migrated) > 0 {
|
||||
used := map[string]bool{}
|
||||
for _, note := range notes {
|
||||
body, _, err := staged.Read(note)
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
metadata := Analyze(note, body)
|
||||
if len(metadata.Warnings) > 0 {
|
||||
return result, fmt.Errorf("%w:%s 的元数据异常,无法确认图片引用", ErrInvalidImport, note)
|
||||
}
|
||||
for _, ref := range metadata.Assets {
|
||||
used[ref.Target] = true
|
||||
}
|
||||
}
|
||||
for original := range migrated {
|
||||
if !used[original] {
|
||||
if err := os.Remove(filepath.Join(stage, filepath.FromSlash(original))); err != nil {
|
||||
return result, err
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
result.Notes = len(notes)
|
||||
// 附件数量包含原始附件及为各笔记生成的独立副本。
|
||||
// 附件数量为最终保留的文件,包含目录共享图片与其他原始附件。
|
||||
count := 0
|
||||
err = filepath.WalkDir(stage, func(p string, d fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
|
||||
@@ -3,7 +3,6 @@ package vault
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"html"
|
||||
"net/url"
|
||||
"os"
|
||||
@@ -229,7 +228,7 @@ func importLinkSpans(content []byte) []importLink {
|
||||
return spans
|
||||
}
|
||||
|
||||
func (v *Vault) importLinks(ctx context.Context, note string, content []byte, total *int64) ([]byte, []string, error) {
|
||||
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)
|
||||
@@ -279,15 +278,12 @@ func (v *Vault) importLinks(ctx context.Context, note string, content []byte, to
|
||||
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
|
||||
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 readErr == nil && !bytes.Equal(data, existing) {
|
||||
return nil, nil, fmt.Errorf("%w:附件名称与内容冲突", ErrInvalidImport)
|
||||
}
|
||||
if os.IsNotExist(readErr) {
|
||||
if !exists {
|
||||
if int64(len(data)) > maxImportExpanded-*total {
|
||||
return nil, nil, ErrTooLarge
|
||||
}
|
||||
@@ -297,6 +293,9 @@ func (v *Vault) importLinks(ctx context.Context, note string, content []byte, to
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if dest != rel {
|
||||
migrated[rel] = true
|
||||
}
|
||||
replacements[rel] = replacement
|
||||
}
|
||||
if u.RawQuery != "" {
|
||||
|
||||
@@ -53,14 +53,14 @@ func TestImportZipAssetsAndMove(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.Notes != 2 || result.Assets != 3 || len(result.Warnings) != 1 || len(result.Paths) != 1 || result.Paths[0] != "知识库" {
|
||||
if result.Notes != 2 || result.Assets != 2 || 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")
|
||||
link := "./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)
|
||||
@@ -77,15 +77,14 @@ func TestImportZipAssetsAndMove(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(string(moved), "改名.assets/") {
|
||||
if !strings.Contains(string(moved), "./assets/") {
|
||||
t.Fatal("图片链接未跟随改名")
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(v.root, "移动目标/改名.assets", assetName([]byte("image"), "x.png"))); err != nil {
|
||||
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)
|
||||
if _, err := os.Stat(filepath.Join(v.root, "知识库/目录/assets/图 (1).png")); !os.IsNotExist(err) {
|
||||
t.Fatal("已迁移的重复原图应移除", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -101,14 +100,14 @@ func TestImportPreservesContainersAndOnlyRewritesActualLinks(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.Notes != 1 || result.Assets != 3 || len(result.Warnings) != 0 {
|
||||
if result.Notes != 1 || result.Assets != 2 || len(result.Warnings) != 0 {
|
||||
t.Fatalf("仅真实图片应生成附件副本,脚注正文不应产生缺失警告:%+v", result)
|
||||
}
|
||||
body, _, err := v.Read("导入/文章.md")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
link := EscapeLinkPath("文章.assets") + "/" + assetName([]byte("image"), "x.png")
|
||||
link := "./assets/" + assetName([]byte("image"), "x.png")
|
||||
want := strings.Replace(content, "[pic]: assets/image.png", "[pic]: "+link, 1)
|
||||
want = strings.Replace(want, "
|
||||
if string(body) != want {
|
||||
|
||||
@@ -38,9 +38,7 @@ func TestMoveMaintainsIncomingAndOutgoingLinks(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if _, err := v.SaveAsset("dir/a.md", "p.png", strings.NewReader("image"), 100); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
legacyAsset(t, v, "dir/a.md", "p.png", "image")
|
||||
if _, err := v.Move("dir/a.md", "new/renamed.md"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -67,7 +65,7 @@ func TestMetadataSkipsMathAndResolvesFootnotes(t *testing.T) {
|
||||
if !reflect.DeepEqual(m.Tags, []string{"正文"}) || len(m.Links) != 0 || len(m.Assets) != 1 || m.Assets[0].Target != "assets/foot.png" {
|
||||
t.Fatalf("扩展语法误识别:%+v", m)
|
||||
}
|
||||
rewritten, err := rewriteMovedLinks([]byte(content), "a.md", "dir/a.md", "a.md", "dir/a.md", false, false)
|
||||
rewritten, err := rewriteLocalLinks([]byte(content), "a.md", "dir/a.md", func(target string) string { return target })
|
||||
if err != nil || !strings.Contains(string(rewritten), "[fake](bad.md)") || !strings.Contains(string(rewritten), "[fake](bad2.md)") || !strings.Contains(string(rewritten), "../assets/foot.png") {
|
||||
t.Fatalf("公式或脚注改写不符:%s %v", rewritten, err)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
package vault
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type imageTransfer struct {
|
||||
source, target string
|
||||
data []byte
|
||||
created bool
|
||||
removeSource bool
|
||||
removed bool
|
||||
}
|
||||
|
||||
// 调用方持有写锁,并在后续笔记移动失败时撤回新副本。
|
||||
// 旧的独立附件仍由 Move 连同目录移动;共享图片只在其他笔记不再引用时移走原件。
|
||||
func (v *Vault) copyNoteImages(from, to string) (transfers []imageTransfer, targets map[string]string, err error) {
|
||||
content, _, err := v.Read(from)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
metadata := Analyze(from, content)
|
||||
if len(metadata.Warnings) != 0 {
|
||||
return nil, nil, fmt.Errorf("%w:%s 的元数据异常,无法确认图片引用", ErrInvalidPath, from)
|
||||
}
|
||||
entries, err := v.Tree()
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
used := map[string]bool{}
|
||||
for _, entry := range entries {
|
||||
if entry.Dir || !IsNote(entry.Path) || entry.Path == from {
|
||||
continue
|
||||
}
|
||||
body, _, e := v.Read(entry.Path)
|
||||
if e != nil {
|
||||
return nil, nil, e
|
||||
}
|
||||
m := Analyze(entry.Path, body)
|
||||
if len(m.Warnings) != 0 {
|
||||
return nil, nil, fmt.Errorf("%w:%s 的元数据异常,无法确认图片引用", ErrInvalidPath, entry.Path)
|
||||
}
|
||||
for _, ref := range m.Assets {
|
||||
used[ref.Target] = true
|
||||
}
|
||||
}
|
||||
targets = map[string]string{}
|
||||
for _, ref := range metadata.Assets {
|
||||
if !IsImage(ref.Target) || strings.HasPrefix(ref.Target, AssetsDir(from)+"/") {
|
||||
continue
|
||||
}
|
||||
if _, seen := targets[ref.Target]; seen {
|
||||
continue
|
||||
}
|
||||
source, e := v.abs(ref.Target)
|
||||
if e != nil {
|
||||
return transfers, targets, e
|
||||
}
|
||||
info, e := os.Lstat(source)
|
||||
if os.IsNotExist(e) {
|
||||
continue // 已缺失的图片保留链接,由缺失检查报告。
|
||||
}
|
||||
if e != nil {
|
||||
return transfers, targets, e
|
||||
}
|
||||
if !info.Mode().IsRegular() {
|
||||
return transfers, targets, ErrInvalidPath
|
||||
}
|
||||
data, e := os.ReadFile(source)
|
||||
if e != nil {
|
||||
return transfers, targets, e
|
||||
}
|
||||
dest, exists, e := v.assetDestination(path.Join(path.Dir(to), "assets"), path.Base(ref.Target), data)
|
||||
if e != nil {
|
||||
return transfers, targets, e
|
||||
}
|
||||
targets[ref.Target] = dest
|
||||
transfer := imageTransfer{source: ref.Target, target: dest, data: data, removeSource: !used[ref.Target] && ref.Target != dest}
|
||||
if !exists {
|
||||
full := filepath.Join(v.root, filepath.FromSlash(dest))
|
||||
if e := os.MkdirAll(filepath.Dir(full), 0o755); e != nil {
|
||||
return transfers, targets, e
|
||||
}
|
||||
if e := atomicWrite(full, data); e != nil {
|
||||
return transfers, targets, e
|
||||
}
|
||||
transfer.created = true
|
||||
}
|
||||
transfers = append(transfers, transfer)
|
||||
}
|
||||
return transfers, targets, nil
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
package vault
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestNoteUploadsShareFolderAssetsAndRejectInvalidNotes(t *testing.T) {
|
||||
v := newVault(t)
|
||||
a, err := v.SaveAsset("dir/a.md", "x.png", strings.NewReader("image"), 100)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b, err := v.SaveAsset("dir/b.md", "x.png", strings.NewReader("image"), 100)
|
||||
if err != nil || a != b || !strings.HasPrefix(a, "./assets/") {
|
||||
t.Fatalf("同目录上传应共享资源:%s %s %v", a, b, err)
|
||||
}
|
||||
for _, note := range []string{"../x.md", "folder/../x.md", "/x.md", ".hidden/x.md", "a.txt"} {
|
||||
if _, err := v.SaveAsset(note, "x.png", strings.NewReader("image"), 100); !errors.Is(err, ErrInvalidPath) {
|
||||
t.Errorf("非法笔记路径 %s:%v", note, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNoteMoveTransfersSharedImagesWithoutBreakingOtherNotes(t *testing.T) {
|
||||
for _, shared := range []bool{false, true} {
|
||||
for _, collision := range []string{"none", "same", "different"} {
|
||||
t.Run(collision+map[bool]string{false: "-exclusive", true: "-shared"}[shared], func(t *testing.T) {
|
||||
v := newVault(t)
|
||||
link, err := v.SaveAsset("old/a.md", "a.png", strings.NewReader("image"), 100)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
file := filepath.Base(link)
|
||||
body := "\n[图]: " + link + "\n<img src=\"" + link + "\">\n``\n\n$$\n\n$$\n"
|
||||
if _, err := v.Write("old/a.md", []byte(body), Precondition{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if shared {
|
||||
if _, err := v.Write("else/b.md", []byte("[图片](../old/assets/"+file+")"), Precondition{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if collision != "none" {
|
||||
if err := v.Mkdir("new/assets"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
data := "image"
|
||||
if collision == "different" {
|
||||
data = "unrelated"
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(v.Root(), "new/assets", file), []byte(data), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if _, err := v.Move("old/a.md", "new/renamed.md"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, _, err := v.Read("new/renamed.md")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
metadata := Analyze("new/renamed.md", got)
|
||||
for _, ref := range metadata.Assets {
|
||||
if filepath.ToSlash(filepath.Dir(ref.Target)) != "new/assets" {
|
||||
t.Fatalf("图片未归入目标目录:%s", got)
|
||||
}
|
||||
data, err := os.ReadFile(filepath.Join(v.Root(), ref.Target))
|
||||
if err != nil || string(data) != "image" {
|
||||
t.Fatalf("图片丢失或被覆盖:%q %v", data, err)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(string(got), "?q=1#part \"标题\"") || !strings.Contains(string(got), "``") || !strings.Contains(string(got), "$$\n") {
|
||||
t.Fatalf("正文中的标题、代码或公式被改写:%s", got)
|
||||
}
|
||||
_, err = os.Stat(filepath.Join(v.Root(), "old/assets", file))
|
||||
if (shared && err != nil) || (!shared && !os.IsNotExist(err)) {
|
||||
t.Fatalf("源图保留状态错误 shared=%v:%v", shared, err)
|
||||
}
|
||||
if collision == "different" {
|
||||
data, _ := os.ReadFile(filepath.Join(v.Root(), "new/assets", file))
|
||||
if string(data) != "unrelated" {
|
||||
t.Fatal("目标已有图片被覆盖")
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNoteDeleteAndRenamePreserveSharedAssets(t *testing.T) {
|
||||
v := newVault(t)
|
||||
link, err := v.SaveAsset("a.md", "a.png", strings.NewReader("image"), 100)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, note := range []string{"a.md", "b.md"} {
|
||||
if _, err := v.Write(note, []byte(""), Precondition{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if rewritten, err := v.Move("a.md", "renamed.md"); err != nil || rewritten {
|
||||
t.Fatalf("同目录改名无需改写图片:%v %v", rewritten, err)
|
||||
}
|
||||
item, err := v.Delete("renamed.md")
|
||||
if err != nil || item.Assets != "" {
|
||||
t.Fatalf("删除笔记不得带走共享目录:%+v %v", item, err)
|
||||
}
|
||||
if _, err := v.Stat(strings.TrimPrefix(link, "./")); err != nil {
|
||||
t.Fatal("共享图片丢失", err)
|
||||
}
|
||||
if _, err := v.Restore(item.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFailedNoteImageTransferRemovesOnlyCreatedCopies(t *testing.T) {
|
||||
v := newVault(t)
|
||||
if err := v.Mkdir("old/assets"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, name := range []string{"first.png", "second.png"} {
|
||||
if err := os.WriteFile(filepath.Join(v.Root(), "old/assets", name), []byte(name), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
body := " "
|
||||
if _, err := v.Write("old/a.md", []byte(body), Precondition{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := v.Mkdir("new/assets"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
outside := filepath.Join(t.TempDir(), "outside.png")
|
||||
if err := os.WriteFile(outside, []byte("private"), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Symlink(outside, filepath.Join(v.Root(), "new/assets/second.png")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := v.Move("old/a.md", "new/a.md"); err == nil {
|
||||
t.Fatal("越界链接应使移动失败")
|
||||
}
|
||||
got, _, err := v.Read("old/a.md")
|
||||
if err != nil || string(got) != body {
|
||||
t.Fatalf("失败后原文丢失:%s %v", got, err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(v.Root(), "new/assets/first.png")); !os.IsNotExist(err) {
|
||||
t.Fatal("已创建副本没有撤回", err)
|
||||
}
|
||||
for _, name := range []string{"first.png", "second.png"} {
|
||||
if _, err := v.Stat("old/assets/" + name); err != nil {
|
||||
t.Fatal("源图不应被移除", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestImportSharesImagesAndRemovesOnlyMigratedOriginals(t *testing.T) {
|
||||
v := newVault(t)
|
||||
source := importZip(t,
|
||||
zipEntry{name: "a.md", content: ""},
|
||||
zipEntry{name: "b.md", content: "[图片](original/p.png)"},
|
||||
zipEntry{name: "sub/c.md", content: "<img src=\"../original/p.png\">"},
|
||||
zipEntry{name: "original/p.png", content: "shared"},
|
||||
zipEntry{name: "original/unused.png", content: "unused"},
|
||||
zipEntry{name: "original/document.pdf", content: "attachment"},
|
||||
)
|
||||
result, err := v.Import(context.Background(), "", "imported", []ImportFile{source})
|
||||
if err != nil || result.Assets != 4 {
|
||||
t.Fatalf("共享图片与其他附件数量不符:%+v %v", result, err)
|
||||
}
|
||||
if _, err := v.Stat("imported/original/p.png"); !errors.Is(err, ErrNotFound) {
|
||||
t.Fatal("重复原图未清理", err)
|
||||
}
|
||||
for _, original := range []string{"unused.png", "document.pdf"} {
|
||||
if _, err := v.Stat("imported/original/" + original); err != nil {
|
||||
t.Fatal("不应清理其他原始附件", err)
|
||||
}
|
||||
}
|
||||
for _, note := range []string{"a.md", "b.md", "sub/c.md"} {
|
||||
body, _, err := v.Read("imported/" + note)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
refs := Analyze("imported/"+note, body).Assets
|
||||
if len(refs) != 1 || !strings.HasPrefix(string(body), map[string]string{"a.md": " {
|
||||
t.Fatalf("导入链接不符:%s", body)
|
||||
}
|
||||
if _, err := v.Stat(refs[0].Target); err != nil {
|
||||
t.Fatal("导入引用缺失", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
+82
-17
@@ -405,8 +405,35 @@ func (v *Vault) Move(from, to string) (rewritten bool, err error) {
|
||||
}
|
||||
}
|
||||
}
|
||||
var images []imageTransfer
|
||||
var imageTargets map[string]string
|
||||
defer func() {
|
||||
if err == nil {
|
||||
return
|
||||
}
|
||||
for _, image := range images {
|
||||
if image.removed {
|
||||
if rollback := atomicWrite(filepath.Join(v.root, filepath.FromSlash(image.source)), image.data); rollback != nil {
|
||||
err = errors.Join(err, fmt.Errorf("回滚图片原件失败: %w", rollback))
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, image := range images {
|
||||
if image.created {
|
||||
if rollback := os.Remove(filepath.Join(v.root, filepath.FromSlash(image.target))); rollback != nil {
|
||||
err = errors.Join(err, fmt.Errorf("撤回图片副本失败: %w", rollback))
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
if isNote && path.Dir(from) != path.Dir(to) {
|
||||
images, imageTargets, err = v.copyNoteImages(from, to)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
}
|
||||
// 笔记、目录和图片的移动都可能改变跨文件相对链接。
|
||||
links, err := v.moveLinkChanges(from, to, info.IsDir(), srcAssets != "")
|
||||
links, err := v.moveLinkChanges(from, to, info.IsDir(), srcAssets != "", imageTargets)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
@@ -417,10 +444,16 @@ func (v *Vault) Move(from, to string) (rewritten bool, err error) {
|
||||
return false, err
|
||||
}
|
||||
writtenLinks := 0
|
||||
assetsMoved := false
|
||||
defer func() {
|
||||
if err == nil {
|
||||
return
|
||||
}
|
||||
if assetsMoved {
|
||||
if rollback := os.Rename(dstAssets, srcAssets); rollback != nil {
|
||||
err = errors.Join(err, fmt.Errorf("回滚旧附件目录失败: %w", rollback))
|
||||
}
|
||||
}
|
||||
for _, change := range links[:writtenLinks] {
|
||||
if rollback := atomicWrite(change.path, change.before); rollback != nil {
|
||||
err = errors.Join(err, fmt.Errorf("回滚笔记引用失败: %w", rollback))
|
||||
@@ -443,6 +476,15 @@ func (v *Vault) Move(from, to string) (rewritten bool, err error) {
|
||||
if err := os.Rename(srcAssets, dstAssets); err != nil {
|
||||
return false, fmt.Errorf("移动图片目录失败: %w", err)
|
||||
}
|
||||
assetsMoved = true
|
||||
}
|
||||
for i := range images {
|
||||
if images[i].removeSource {
|
||||
if err := os.Remove(filepath.Join(v.root, filepath.FromSlash(images[i].source))); err != nil {
|
||||
return false, err
|
||||
}
|
||||
images[i].removed = true
|
||||
}
|
||||
}
|
||||
return rewritten, err
|
||||
}
|
||||
@@ -638,13 +680,16 @@ func (v *Vault) Purge(id string) error {
|
||||
|
||||
var extPattern = regexp.MustCompile(`^\.[a-z0-9]{1,8}$`)
|
||||
|
||||
// SaveAsset 把上传的文件存入笔记的 .assets 目录,以内容哈希命名(相同内容只存一份),
|
||||
// SaveAsset 把上传的文件存入笔记同级的 assets 目录,以内容哈希命名(相同内容只存一份),
|
||||
// 返回可直接写入 Markdown 的相对链接
|
||||
func (v *Vault) SaveAsset(note, origName string, r io.Reader, limit int64) (string, error) {
|
||||
if !IsNote(note) {
|
||||
return "", ErrInvalidPath
|
||||
}
|
||||
return v.saveAssetAt(AssetsDir(note), origName, r, limit)
|
||||
if _, err := CleanPath(note); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return v.saveAssetAt(path.Join(path.Dir(note), "assets"), origName, r, limit)
|
||||
}
|
||||
|
||||
// SaveFolderAsset 为图片管理上传到当前目录的共享 assets 目录,沿用哈希去重。
|
||||
@@ -685,28 +730,48 @@ func (v *Vault) saveAssetAt(relativeDir, origName string, r io.Reader, limit int
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return "", err
|
||||
}
|
||||
// 管理界面允许替换图片,哈希名称不再保证内容未变;不能把旧内容上传误判为重复。
|
||||
base := strings.TrimSuffix(name, path.Ext(name))
|
||||
target, exists, err := v.assetDestination(relativeDir, name, data)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if !exists {
|
||||
if err := atomicWrite(filepath.Join(v.root, filepath.FromSlash(target)), data); err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
return "./" + EscapeLinkPath(path.Base(relativeDir)) + "/" + EscapeLinkPath(path.Base(target)), nil
|
||||
}
|
||||
|
||||
// 管理界面允许替换图片,文件名不保证内容未变;只复用实际内容相同的普通文件。
|
||||
func (v *Vault) assetDestination(dir, name string, data []byte) (string, bool, error) {
|
||||
base, ext := strings.TrimSuffix(name, path.Ext(name)), path.Ext(name)
|
||||
for i := 0; ; i++ {
|
||||
candidate := name
|
||||
if i > 0 {
|
||||
candidate = fmt.Sprintf("%s-%d%s", base, i, path.Ext(name))
|
||||
candidate = fmt.Sprintf("%s-%d%s", base, i, ext)
|
||||
}
|
||||
target := filepath.Join(dir, candidate)
|
||||
if err := v.checkInside(target); err != nil {
|
||||
return "", err
|
||||
rel := path.Join(dir, candidate)
|
||||
full, err := v.abs(rel)
|
||||
if err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
existing, err := os.ReadFile(target)
|
||||
info, err := os.Lstat(full)
|
||||
if errors.Is(err, fs.ErrNotExist) {
|
||||
if err := atomicWrite(target, data); err != nil {
|
||||
return "", err
|
||||
return rel, false, nil
|
||||
}
|
||||
} else if err != nil {
|
||||
return "", err
|
||||
} else if !bytes.Equal(existing, data) {
|
||||
continue
|
||||
if err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
if !info.Mode().IsRegular() {
|
||||
return "", false, ErrInvalidPath
|
||||
}
|
||||
existing, err := os.ReadFile(full)
|
||||
if err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
if bytes.Equal(existing, data) {
|
||||
return rel, true, nil
|
||||
}
|
||||
return EscapeLinkPath(path.Base(relativeDir)) + "/" + candidate, nil
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -18,6 +18,20 @@ func newVault(t *testing.T) *Vault {
|
||||
return v
|
||||
}
|
||||
|
||||
// 显式构造旧版独立附件,覆盖升级后的移动与回收站恢复。
|
||||
func legacyAsset(t *testing.T, v *Vault, note, name, content string) string {
|
||||
t.Helper()
|
||||
dir := filepath.Join(v.Root(), filepath.FromSlash(AssetsDir(note)))
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
file := assetName([]byte(content), name)
|
||||
if err := os.WriteFile(filepath.Join(dir, file), []byte(content), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return EscapeLinkPath(filepath.Base(dir)) + "/" + file
|
||||
}
|
||||
|
||||
func TestCleanPathRejectsUnsafe(t *testing.T) {
|
||||
for _, p := range []string{"", "/a.md", "../a.md", "a/../b.md", "a//b.md", "./a.md", ".rawmark/x.md", "a/.git/b.md", "a\\b.md", "a\x00.md"} {
|
||||
if _, err := CleanPath(p); !errors.Is(err, ErrInvalidPath) {
|
||||
@@ -48,7 +62,7 @@ func TestSymlinkEscapeRejected(t *testing.T) {
|
||||
func TestAssetSymlinkEscapeRejected(t *testing.T) {
|
||||
v := newVault(t)
|
||||
outside := t.TempDir()
|
||||
if err := os.Symlink(outside, filepath.Join(v.Root(), "n.assets")); err != nil {
|
||||
if err := os.Symlink(outside, filepath.Join(v.Root(), "assets")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := v.SaveAsset("n.md", "x.png", strings.NewReader("image"), 100); !errors.Is(err, ErrInvalidPath) {
|
||||
@@ -70,9 +84,7 @@ func TestMoveRollsBackWhenAssetDestinationIsTooLong(t *testing.T) {
|
||||
if _, err := v.Write("refs.md", []byte(refs), Precondition{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := v.SaveAsset("n.md", "x.png", strings.NewReader("image"), 100); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
legacyAsset(t, v, "n.md", "x.png", "image")
|
||||
// 笔记名可写入,但派生的 .assets 名超过文件系统的单段长度限制。
|
||||
destination := strings.Repeat("a", 250) + ".md"
|
||||
if _, err := v.Move("n.md", destination); err == nil {
|
||||
@@ -96,9 +108,7 @@ func TestRestoreRollsBackWhenAssetsAreMissing(t *testing.T) {
|
||||
if _, err := v.Write("n.md", []byte("原文"), Precondition{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := v.SaveAsset("n.md", "x.png", strings.NewReader("image"), 100); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
legacyAsset(t, v, "n.md", "x.png", "image")
|
||||
item, err := v.Delete("n.md")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -118,9 +128,9 @@ func TestRestoreRollsBackWhenAssetsAreMissing(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAssetLinksEncodeReservedURLCharacters(t *testing.T) {
|
||||
func TestAssetLinksWithReservedNotePath(t *testing.T) {
|
||||
v := newVault(t)
|
||||
link, err := v.SaveAsset("n#?%20.md", "x.png", strings.NewReader("image"), 100)
|
||||
link, err := v.SaveAsset("目录 #?%20/n.md", "x.png", strings.NewReader("image"), 100)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -128,12 +138,12 @@ func TestAssetLinksEncodeReservedURLCharacters(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
file, _, err := v.OpenRaw(decoded)
|
||||
file, _, err := v.OpenRaw(filepath.ToSlash(filepath.Join("目录 #?%20", decoded)))
|
||||
if err != nil {
|
||||
t.Fatalf("资源链接未指向实际文件: %s %v", link, err)
|
||||
}
|
||||
file.Close()
|
||||
if strings.ContainsAny(link, "#?") || !strings.HasPrefix(link, "n%23%3F%2520.assets/") {
|
||||
if strings.ContainsAny(link, "#?") || !strings.HasPrefix(link, "./assets/") {
|
||||
t.Fatalf("链接未正确转义: %s", link)
|
||||
}
|
||||
}
|
||||
@@ -170,10 +180,7 @@ func TestMoveCarriesAssetsAndRewritesLinks(t *testing.T) {
|
||||
if _, err := v.Write("dir/笔记 A.md", []byte(src), Precondition{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
link, err := v.SaveAsset("dir/笔记 A.md", "x.png", strings.NewReader("png"), 1<<20)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
link := legacyAsset(t, v, "dir/笔记 A.md", "x.png", "png")
|
||||
if !strings.HasPrefix(link, "笔记%20A.assets/") {
|
||||
t.Fatalf("资源链接不符: %s", link)
|
||||
}
|
||||
@@ -199,7 +206,7 @@ func TestMoveCarriesAssetsAndRewritesLinks(t *testing.T) {
|
||||
func TestDeleteRestore(t *testing.T) {
|
||||
v := newVault(t)
|
||||
v.Write("n.md", []byte("x"), Precondition{})
|
||||
v.SaveAsset("n.md", "a.png", strings.NewReader("img"), 100)
|
||||
legacyAsset(t, v, "n.md", "a.png", "img")
|
||||
item, err := v.Delete("n.md")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -245,7 +252,7 @@ func TestTreeShowsImagesButHidesMeta(t *testing.T) {
|
||||
for _, e := range entries {
|
||||
paths = append(paths, e.Path)
|
||||
}
|
||||
if strings.Join(paths, ",") != "a.assets,"+link+",a.md,folder" {
|
||||
if strings.Join(paths, ",") != "a.md,assets,"+strings.TrimPrefix(link, "./")+",folder" {
|
||||
t.Fatalf("目录树不符: %v", paths)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user