feat(assets): store note images in shared sibling assets directories

This commit is contained in:
eezd committed 2026-10-24 18:12:23 +08:00
1 parent 5abc99b89e
commit 08f4e4aa7d
14 files changed
+507 -75

No files matched your search

+4 -4
View File
@@ -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)
}
})
+19 -7
View File
@@ -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
}
+3 -1
View File
@@ -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"} {
+27 -2
View File
@@ -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 {
+9 -10
View File
@@ -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 != "" {
+8 -9
View File
@@ -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, "![跨行](\nassets/image.png", "![跨行](\n"+link, 1)
if string(body) != want {
+2 -4
View File
@@ -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)
}
+96
View File
@@ -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
}
+197
View File
@@ -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 := "![图](" + link + "?q=1#part \"标题\")\n[图]: " + link + "\n<img src=\"" + link + "\">\n`![](" + link + ")`\n\n$$\n![](" + link + ")\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), "`![]("+link+")`") || !strings.Contains(string(got), "$$\n![]("+link+")") {
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("![]("+link+")"), 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 := "![](assets/first.png) ![](assets/second.png)"
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: "![](original/p.png)"},
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": "![](./assets/", "b.md": "[图片](./assets/", "sub/c.md": "<img src=\"./assets/"}[note]) {
t.Fatalf("导入链接不符:%s", body)
}
if _, err := v.Stat(refs[0].Target); err != nil {
t.Fatal("导入引用缺失", err)
}
}
}
+83 -18
View File
@@ -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
}
} else if err != nil {
return "", err
} else if !bytes.Equal(existing, data) {
continue
return rel, false, nil
}
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
}
}
+24 -17
View File
@@ -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)
}
}