From 91a359c2b7c17097774a963f15ec2356b9a05f9a Mon Sep 17 00:00:00 2001 From: eezd Date: Thu, 22 Oct 2026 15:09:15 +0800 Subject: [PATCH] feat(assets): add searchable media library and verified orphan cleanup --- internal/api/api.go | 12 ++- internal/api/assets.go | 41 +++++++++ internal/api/openapi.json | 44 ++++++++++ internal/vault/cleanup.go | 80 ++++++++++++++++++ internal/vault/cleanup_test.go | 68 +++++++++++++++ internal/vault/vault.go | 36 ++++++-- web/src/app/AppHeader.vue | 2 +- web/src/app/ImageGallery.vue | 148 +++++++++++++++++++++++++-------- web/src/app/Sidebar.vue | 2 +- web/src/app/TreeNode.vue | 2 +- web/src/app/Workspace.vue | 8 +- web/src/app/api.ts | 6 ++ web/src/app/workspace.ts | 26 +++++- 13 files changed, 424 insertions(+), 51 deletions(-) create mode 100644 internal/api/assets.go create mode 100644 internal/vault/cleanup.go create mode 100644 internal/vault/cleanup_test.go diff --git a/internal/api/api.go b/internal/api/api.go index ab85459..db8adff 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -85,6 +85,7 @@ func (s *Server) routes() []route { {"POST /api/v1/folders", authed, s.mkdir}, {"POST /api/v1/import", authed, s.importNotes}, {"POST /api/v1/assets", authed, s.uploadAsset}, + {"POST /api/v1/assets/cleanup", authed, s.cleanupImages}, {"POST /api/v1/assets/import", authed, s.importAsset}, {"PUT /api/v1/assets/{path...}", authed, s.replaceImage}, {"GET /api/v1/raw/{path...}", authed, s.raw}, @@ -389,7 +390,16 @@ func (s *Server) uploadAsset(w http.ResponseWriter, r *http.Request) { return } defer file.Close() - link, err := s.vault.SaveAsset(r.URL.Query().Get("note"), header.Filename, file, maxAssetBytes) + var link string + if r.URL.Query().Has("folder") { + if r.URL.Query().Has("note") { + writeError(w, 400, "不能同时指定笔记和文件夹") + return + } + link, err = s.vault.SaveFolderAsset(r.URL.Query().Get("folder"), header.Filename, file, maxAssetBytes) + } else { + link, err = s.vault.SaveAsset(r.URL.Query().Get("note"), header.Filename, file, maxAssetBytes) + } if err != nil { vaultError(w, r, err) return diff --git a/internal/api/assets.go b/internal/api/assets.go new file mode 100644 index 0000000..de7227b --- /dev/null +++ b/internal/api/assets.go @@ -0,0 +1,41 @@ +package api + +import ( + "net/http" + "time" +) + +func (s *Server) cleanupImages(w http.ResponseWriter, r *http.Request) { + var body struct { + Paths []string `json:"paths"` + } + if !readJSON(w, r, &body) { + return + } + if len(body.Paths) == 0 || len(body.Paths) > 1000 { + writeError(w, 400, "请选择 1~1000 张待清理图片") + return + } + result, err := s.vault.CleanupImages(body.Paths, func() ([]string, error) { + shares, err := s.store.Shares() + if err != nil { + return nil, err + } + scopes := []string{} + now := time.Now().Unix() + for _, share := range shares { + if share.ExpiresAt != 0 && share.ExpiresAt <= now { + continue + } + for _, item := range share.Items { + scopes = append(scopes, item.Path) + } + } + return scopes, nil + }) + if err != nil { + vaultError(w, r, err) + return + } + writeJSON(w, 200, result) +} diff --git a/internal/api/openapi.json b/internal/api/openapi.json index 21b9991..c894bb9 100644 --- a/internal/api/openapi.json +++ b/internal/api/openapi.json @@ -737,6 +737,14 @@ "schema": { "type": "string" } + }, + { + "name": "folder", + "in": "query", + "description": "文件夹图片管理上传目标,空字符串表示根目录;与 note 互斥", + "schema": { + "type": "string" + } } ], "requestBody": { @@ -2449,6 +2457,42 @@ } } } + }, + "/assets/cleanup": { + "post": { + "summary": "重新核对全库引用和有效分享后,将孤立图片移入回收站", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "paths" + ], + "properties": { + "paths": { + "type": "array", + "minItems": 1, + "maxItems": 1000, + "items": { + "type": "string" + } + } + } + } + } + } + }, + "responses": { + "200": { + "description": "已移入回收站的图片及跳过原因" + }, + "400": { + "description": "请求无效,或笔记元数据异常,无法确认引用" + } + } + } } }, "components": { diff --git a/internal/vault/cleanup.go b/internal/vault/cleanup.go new file mode 100644 index 0000000..735c806 --- /dev/null +++ b/internal/vault/cleanup.go @@ -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 +} diff --git a/internal/vault/cleanup_test.go b/internal/vault/cleanup_test.go new file mode 100644 index 0000000..ded64a3 --- /dev/null +++ b/internal/vault/cleanup_test.go @@ -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) + } +} diff --git a/internal/vault/vault.go b/internal/vault/vault.go index 50d3f74..4ee570c 100644 --- a/internal/vault/vault.go +++ b/internal/vault/vault.go @@ -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 } } diff --git a/web/src/app/AppHeader.vue b/web/src/app/AppHeader.vue index 107b27d..16dc12b 100644 --- a/web/src/app/AppHeader.vue +++ b/web/src/app/AppHeader.vue @@ -7,7 +7,7 @@ import { createNote, ws } from './workspace'
个人知识库 - +
diff --git a/web/src/app/ImageGallery.vue b/web/src/app/ImageGallery.vue index 5a3e6fd..6932fe9 100644 --- a/web/src/app/ImageGallery.vue +++ b/web/src/app/ImageGallery.vue @@ -1,48 +1,124 @@ - - diff --git a/web/src/app/Sidebar.vue b/web/src/app/Sidebar.vue index 21a016f..8787fc1 100644 --- a/web/src/app/Sidebar.vue +++ b/web/src/app/Sidebar.vue @@ -72,7 +72,7 @@ function onRootDrop(e: DragEvent) {
RM
个人工作区Markdown 文件 · 自动保存
diff --git a/web/src/app/TreeNode.vue b/web/src/app/TreeNode.vue index 148ec0d..208b745 100644 --- a/web/src/app/TreeNode.vue +++ b/web/src/app/TreeNode.vue @@ -85,7 +85,7 @@ function runMenu(action: 'note' | 'folder' | 'import' | 'images' | 'rename' | 'm expanded.add(path) void (action === 'note' ? createNote(path) : createFolder(path)) } else if (action === 'import') ws.importing = path - else if (action === 'images') ws.images = path + else if (action === 'images') { ws.imagesAll = false; ws.images = path } else if (action === 'rename') ws.renaming = path else if (action === 'share') ws.sharing = minimalPaths(selectedPaths) else if (action === 'move') ws.moving = [...selectedPaths] diff --git a/web/src/app/Workspace.vue b/web/src/app/Workspace.vue index 93e2c2b..5201fef 100644 --- a/web/src/app/Workspace.vue +++ b/web/src/app/Workspace.vue @@ -121,23 +121,23 @@ onBeforeUnmount(() => {
-
+
- + + - +
- diff --git a/web/src/app/api.ts b/web/src/app/api.ts index a5a5361..5e0e9bf 100644 --- a/web/src/app/api.ts +++ b/web/src/app/api.ts @@ -188,6 +188,12 @@ export const api = { return (await json<{ link: string }>(request('POST', '/api/v1/assets?note=' + encodeURIComponent(note), { body: form }))).link }, + cleanupImages: (paths: string[]) => json<{ deleted: TrashItem[]; skipped: { path: string; reason: string }[] }>(request('POST', '/api/v1/assets/cleanup', { json: { paths } })), + uploadFolderImage: async (folder: string, file: File) => { + const body = new FormData() + body.append('file', file) + return json<{ link: string }>(request('POST', '/api/v1/assets?folder=' + encodeURIComponent(folder), { body })) + }, replaceImage: (path: string, file: File) => { const body = new FormData() body.append('file', file) diff --git a/web/src/app/workspace.ts b/web/src/app/workspace.ts index 7b0a83c..7a8b9c1 100644 --- a/web/src/app/workspace.ts +++ b/web/src/app/workspace.ts @@ -71,6 +71,7 @@ export const ws = reactive({ // 图片预览与管理,独立于笔记标签和草稿 image: null as string | null, images: null as string | null, + imagesAll: false, // 电脑上侧栏折叠 sidebarCollapsed: layout.sidebarCollapsed ?? false, // 大纲面板(电脑上在右侧常驻,手机上为浮层) @@ -1094,7 +1095,7 @@ function registerBuiltinCommands() { { id: 'view.source', title: '开启/关闭源码编辑', when: hasNote, run: toggleSourceMode }, { id: 'app.trash', title: '打开回收站', run: () => (ws.settings = 'trash') }, { id: 'note.share', title: '分享当前笔记', when: hasNote, run: () => (ws.sharing = ws.active) }, - { id: 'app.images', title: '管理根目录图片', run: () => (ws.images = '') }, + { id: 'app.images', title: '管理根目录图片', run: () => { ws.imagesAll = false; ws.images = '' } }, { id: 'app.shares', title: '管理分享链接', run: () => (ws.settings = 'shares') }, { id: 'app.tokens', title: 'API 令牌', run: () => (ws.settings = 'tokens') }, ]) { @@ -1175,3 +1176,26 @@ export async function openReference(path: string, line: number) { v.focus() }) } + + +export async function uploadFolderImages(folder: string, files: File[]) { + const failures: string[] = [] + let uploaded = 0 + await withOp(async () => { + for (const file of files) { + try { await api.uploadFolderImage(folder, file); uploaded++ } + catch (error) { failures.push(file.name + ':' + errorText(error)) } + } + }) + await refreshTree() + return { uploaded, failures } +} + +export async function cleanupImages(paths: string[]) { + await flushAll() + if (!readyToReload()) throw new Error('仍有未保存内容或正在处理粘贴、上传与文件操作,请完成后再清理') + const result = await withOp(() => api.cleanupImages(paths)) + await refreshTree() + await refreshMetadata() + return result +}