feat(assets): add searchable media library and verified orphan cleanup

This commit is contained in:
eezd committed 2026-10-22 15:09:15 +08:00
1 parent 06be0038cf
commit 91a359c2b7
13 files changed
+424 -51

No files matched your search

+80
View File
@@ -0,0 +1,80 @@
package vault
import (
"fmt"
"strings"
)
type CleanupResult struct {
Deleted []TrashItem `json:"deleted"`
Skipped []CleanupSkip `json:"skipped"`
}
type CleanupSkip struct {
Path string `json:"path"`
Reason string `json:"reason"`
}
// CleanupImages 在写锁内重新扫描引用;派生索引不作为删除依据。
// protected 返回当前有效分享的主体路径,由业务数据拥有者在检查时读取。
func (v *Vault) CleanupImages(paths []string, protected func() ([]string, error)) (CleanupResult, error) {
result := CleanupResult{Deleted: []TrashItem{}, Skipped: []CleanupSkip{}}
v.mu.Lock()
defer v.mu.Unlock()
entries, err := v.Tree()
if err != nil {
return result, err
}
references := map[string]bool{}
for _, entry := range entries {
if entry.Dir || !IsNote(entry.Path) {
continue
}
content, _, err := v.Read(entry.Path)
if err != nil {
return result, err
}
metadata := Analyze(entry.Path, content)
if len(metadata.Warnings) > 0 {
return result, fmt.Errorf("%w:%s 的元数据异常,无法确认全库引用:%s", ErrInvalidPath, entry.Path, metadata.Warnings[0])
}
for _, ref := range metadata.Assets {
references[ref.Target] = true
}
}
scopes, err := protected()
if err != nil {
return result, err
}
seen := map[string]bool{}
for _, p := range paths {
if seen[p] {
continue
}
seen[p] = true
reason := ""
switch {
case !IsImage(p):
reason = "不是图片文件"
case references[p]:
reason = "仍被笔记引用"
default:
for _, scope := range scopes {
if p == scope || strings.HasPrefix(p, scope+"/") || IsNote(scope) && strings.HasPrefix(p, AssetsDir(scope)+"/") {
reason = "仍在有效分享范围内"
break
}
}
}
if reason != "" {
result.Skipped = append(result.Skipped, CleanupSkip{p, reason})
continue
}
item, err := v.deleteLocked(p)
if err != nil {
result.Skipped = append(result.Skipped, CleanupSkip{p, err.Error()})
continue
}
result.Deleted = append(result.Deleted, item)
}
return result, nil
}
+68
View File
@@ -0,0 +1,68 @@
package vault
import (
"errors"
"os"
"path/filepath"
"strings"
"testing"
)
func TestCleanupChecksFreshReferencesAndShares(t *testing.T) {
v := newVault(t)
if _, err := v.Write("a.md", []byte("[图片](assets/used.png)"), Precondition{}); err != nil {
t.Fatal(err)
}
for _, p := range []string{"assets/used.png", "assets/orphan.png", "public/shared.png", "a.assets/attached.png"} {
full := filepath.Join(v.Root(), p)
if err := os.MkdirAll(filepath.Dir(full), 0755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(full, []byte("image"), 0644); err != nil {
t.Fatal(err)
}
}
result, err := v.CleanupImages([]string{"assets/used.png", "assets/orphan.png", "public/shared.png", "a.assets/attached.png", "../outside.png"}, func() ([]string, error) { return []string{"public", "a.md"}, nil })
if err != nil || len(result.Deleted) != 1 || result.Deleted[0].Path != "assets/orphan.png" || len(result.Skipped) != 4 {
t.Fatalf("清理结果不符:%+v %v", result, err)
}
if _, err := v.Restore(result.Deleted[0].ID); err != nil {
t.Fatal("孤立图片不能恢复", err)
}
if _, err := v.Write("a.md", []byte("---\ntags: [broken\n---"), Precondition{}); err != nil {
t.Fatal(err)
}
result, err = v.CleanupImages([]string{"assets/orphan.png"}, func() ([]string, error) { return nil, nil })
if err == nil || len(result.Deleted) != 0 {
t.Fatal("不完整的引用分析不能删除图片", result, err)
}
if _, err := os.Stat(filepath.Join(v.Root(), "assets/orphan.png")); err != nil {
t.Fatal(err)
}
if _, err := v.Write("a.md", []byte(""), Precondition{}); err != nil {
t.Fatal(err)
}
cause := errors.New("分享读取失败")
_, err = v.CleanupImages([]string{"assets/orphan.png"}, func() ([]string, error) { return nil, cause })
if !errors.Is(err, cause) {
t.Fatal(err)
}
if _, err := os.Stat(filepath.Join(v.Root(), "assets/orphan.png")); err != nil {
t.Fatal("分享校验失败后不应删除", err)
}
}
func TestFolderUploadReusesDeduplication(t *testing.T) {
v := newVault(t)
first, err := v.SaveFolderAsset("", "a.png", strings.NewReader("image"), 10)
if err != nil {
t.Fatal(err)
}
second, err := v.SaveFolderAsset("", "b.png", strings.NewReader("image"), 10)
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) {
t.Fatalf("目标目录不存在应报错:%v", err)
}
}
+30 -6
View File
@@ -504,13 +504,17 @@ func newTrashID() string {
var trashIDPattern = regexp.MustCompile(`^\d{8}T\d{6}\.\d{3}-[0-9a-f]{8}$`)
// Delete 把笔记(连同 .assets)、图片或文件夹移入回收站
func (v *Vault) Delete(rel string) (item TrashItem, err error) {
func (v *Vault) Delete(rel string) (TrashItem, error) {
v.mu.Lock()
defer v.mu.Unlock()
return v.deleteLocked(rel)
}
func (v *Vault) deleteLocked(rel string) (item TrashItem, err error) {
p, err := v.abs(rel)
if err != nil {
return TrashItem{}, err
}
v.mu.Lock()
defer v.mu.Unlock()
info, err := os.Lstat(p)
if errors.Is(err, fs.ErrNotExist) {
return TrashItem{}, ErrNotFound
@@ -686,7 +690,28 @@ func (v *Vault) SaveAsset(note, origName string, r io.Reader, limit int64) (stri
if !IsNote(note) {
return "", ErrInvalidPath
}
p, err := v.abs(note)
return v.saveAssetAt(AssetsDir(note), origName, r, limit)
}
// SaveFolderAsset 为图片管理上传到当前目录的共享 assets 目录,沿用哈希去重。
func (v *Vault) SaveFolderAsset(folder, origName string, r io.Reader, limit int64) (string, error) {
if !IsImage(origName) {
return "", fmt.Errorf("%w:请选择图片文件", ErrInvalidPath)
}
if folder != "" {
info, err := v.Stat(folder)
if err != nil {
return "", err
}
if !info.IsDir() {
return "", ErrInvalidPath
}
}
return v.saveAssetAt(path.Join(folder, "assets"), origName, r, limit)
}
func (v *Vault) saveAssetAt(relativeDir, origName string, r io.Reader, limit int64) (string, error) {
dir, err := v.abs(relativeDir)
if err != nil {
return "", err
}
@@ -698,7 +723,6 @@ func (v *Vault) SaveAsset(note, origName string, r io.Reader, limit int64) (stri
return "", ErrTooLarge
}
name := assetName(data, origName)
dir := AssetsDir(p)
v.mu.Lock()
defer v.mu.Unlock()
if err := v.checkInside(dir); err != nil {
@@ -728,7 +752,7 @@ func (v *Vault) SaveAsset(note, origName string, r io.Reader, limit int64) (stri
} else if !bytes.Equal(existing, data) {
continue
}
return EscapeLinkPath(path.Base(AssetsDir(note))) + "/" + candidate, nil
return EscapeLinkPath(path.Base(relativeDir)) + "/" + candidate, nil
}
}